了解一门语言的高级特性,仅仅从浮于表面,是无法把握住语言的精髓的。学习过 C++ 的高阶开发者,一定读过神书《Inside The C++ Object Model》,本文的目标是一样的:通过对象模型,掌握 Go 语言的底层机制,从更深层次解释语言特性。
编译与执行
众所周知,Go 源码并不能直接运行,所有代码必须一行行,通过“编译”——“汇编”——“链接” 阶段 转化为低级的机器语言指令,即可执行程序。
“汇编”和“链接”阶段各种语言并无区别,所以一般通过“编译”和“执行”阶段来支持各种语言特性。对于 Go 语言,执行过程并无法直接修改执行指令,因此所有语言特性都是“编译”相关的。理解这一点很重要,因为下面依赖“编译”的产物 汇编代码 来解读对象模型。
什么是对象模型?
何为 Go 对象模型? Go 对象模型可以概括为以下两部分:
- 支持面向对象程序设计的部分
- 封装
- 继承
- 多态
- 各种特性的底层实现机制
- 反射
下面分别从 struct 和 interface 来解释模型如何支持以上两部分。
Struct 语意学
面向对象编程,把对象作为程序的基本单元,一个对象包含了数据和操作数据的函数,前者为成员变量,后者为成员函数。所以研究对象需要分别从成员变量和成员函数入手。
成员变量
以下有三段程序:
// First: global varible var ( X,Y,Z float32 ) // Second: simple type type point3d struct { X, Y, Z float32 } // Third: inherit type type point struct { X float32 } type point2d struct { point Y float32 } type point3d struct { point2d Z float32 }
从风格来看,三段程序截然不同。有许多令人信服的讨论告诉我们,为什么“数据封装”(Second & Third)要比使用“全局变量”好。但,从程序员的角度看,会有几个疑问:
- “数据封装” 之后,内存成本增加了多少?
- “数据封装” 之后,在执行过程中,变量的存储效率是否降低了?
内存布局
先看内存变化。了解内存变化最好的办法就是通过代码打印对象的内存大小,先看全局变量大小
var ( X, Y, Z float32 ) func main() { fmt.Printf("X size:%v, Y size:%v, Z size:%v\n", unsafe.Sizeof(X), unsafe.Sizeof(Y), unsafe.Sizeof(Z)) fmt.Printf("X addr:%v, Y addr:%v, Z addr:%v\n", &X, &Y, &Z) }
执行程序输出为:
$ go run variable.go X size:4, Y size:4, Z size:4 X addr:0x118ee88, Y addr:0x118ee8c, Z addr:0x118ee90
可以看到,X、Y、Z三个字段大小均为4字节,且三个字段内存地址顺序排列。
再看第二段代码的输出
func TestLayout(t *testing.T) { p := point3d{X: 1, Y: 2, Z: 3} fmt.Printf("point3d size:%v, align:%v\n", unsafe.Sizeof(p), unsafe.Alignof(p)) typ := reflect.TypeOf(p) fmt.Printf("Struct:%v is %d bytes long\n", typ.Name(), typ.Size()) fmt.Printf("X at offset %v, size=%d\n", unsafe.Offsetof(p.X), unsafe.Sizeof(p.X)) fmt.Printf("Y at offset %v, size=%d\n", unsafe.Offsetof(p.Y), unsafe.Sizeof(p.Y)) fmt.Printf("Z at offset %v, size=%d\n", unsafe.Offsetof(p.Z), unsafe.Sizeof(p.Z)) }
执行程序输出为:
$ go test -v -run TestLayout === RUN TestLayout point3d size:12, align:4 Struct:point3d is 12 bytes long X at offset 0, size=4 Y at offset 4, size=4 Z at offset 8, size=4
可以看到,X、Y、Z三个字段大小一样为4字节,内存排列也与上一个版本一样。
继续,第三段代码
func TestLayout(t *testing.T) { p := point3d{point2d: point2d{point: point{X: 1}, Y: 2}, Z: 3} fmt.Printf("point3d size:%v, align:%v\n", unsafe.Sizeof(p), unsafe.Alignof(p)) typ := reflect.TypeOf(p) fmt.Printf("Struct:%v is %d bytes long\n", typ.Name(), typ.Size()) fmt.Printf("X at offset %v, size=%d\n", unsafe.Offsetof(p.X), unsafe.Sizeof(p.X)) fmt.Printf("Y at offset %v, size=%d\n", unsafe.Offsetof(p.Y), unsafe.Sizeof(p.Y)) fmt.Printf("Z at offset %v, size=%d\n", unsafe.Offsetof(p.Z), unsafe.Sizeof(p.Z)) }
执行程序输出为:
$ go test -v -run TestLayout === RUN TestLayout point3d size:12, align:4 Struct:point3d is 12 bytes long X at offset 0, size=4 Y at offset 4, size=4 Z at offset 8, size=4
可以看到,X、Y、Z三个字段大小一样为4字节,内存排列也与之前两个版本一样。
综上所述,我们可以看到,无论是否封装,还是多深的继承层次,对成员变量的内存布局都并无影响,均按照字段定义的顺序排列(不考虑内存对齐的情况)。即内存布局类似如下: