Go语言学习笔记:深入对象模型之结构体
好了,在简单探索完基础类型之后,我们就来看一下我们最关心的Struct吧!
1. 简单结构体
我们先从最简单的开始,先定义一个简单的结构体,里面错落的放一些大小不一的字段:
1 | type A struct { |
然后定义一个变量a,再用上篇文章中的DumpObject函数来看一下其内存布局:
1 | Var Type Address RootOffset LocalOffset Size |
我们可以看到变量定义在结构体里大小是不会变化的。这里估计学过C/C++朋友都知道我想干什么了,没错,这里会发现一个非常有趣的现象,这个对象的大小会比所有字段本身的占用的空间要大,因为字段和字段的中间有很多空白的区域。
这个就是Go对结构体变量进行内存对齐导致的结果。内存对齐主要是为了两个原因:
- 一个是保证CPU读取内存的速度,如果数据不对齐,一次读取可能会需要拆分为两次,
- 另一个是有一些CPU甚至不支持读取不对齐的数据,要么报错要么得到错误的结果,不如一些ARM的CPU。
关于内存对齐,我们现在只需要了解其在Go语言中存在即可,具体的规则我们到后面再来探索。
2. 结构体嵌套
Go语言中结构体还有一个非常大的特点,就是没有继承,只有嵌套。
这个是我个人非常喜欢的一个设计,因为继承很容易带来类爆炸的问题,常见的解决方法有很多,比如应用修饰模式,所以在写代码的时候,我会更倾向于使用组合而不是继承,而Go语言在这个方向走的就更远了,直接禁止了继承,只能使用组合,也就是嵌套。
2.1. 单层结构体嵌套
我们还是先从最简单的看起:单层的结构体嵌套:
1 | type B struct { |
现在我们还是用DumpObject函数来查看一下这个结构体的成员布局:
1 | Var Type Address RootOffset LocalOffset Size |
从上面的输出,我们可以发现三件事情:
- 嵌套的结构体A和外层的B的起始地址是一样的,也就是说Go不会在这种情况下为我们的结构体添加额外的字段
- 嵌套的结构体A的内存布局和原始布局完全一致,包括内存对齐的Padding
- 外层结构体的成员变量的内存对齐会受嵌入的结构体的影响
2.2. Go语言的继承:嵌入(Embedding)
虽然上面的代码实现了最简单的嵌套,但在我们访问A的内部成员的时候,我们需要通过SA进行间接访问:
1 | var b B |
如果我们想用嵌套来代替继承,这会导致使用上非常不方便,我们还是希望能像继承一样能直接访问其父类成员,为此Go的结构体提供了一个特性叫做嵌入结构体(Embedded Struct)。
1 | type B struct { |
被嵌入的结构体所有的变量和方法都会被升级(Promote)到外层结构体中,所以访问就变得简单了:
1 | var b B |
更多关于嵌入的信息,可以移步Golang的spec。
我们知道,C++里面继承能让对象内存布局变得非常复杂(虚继承给我带来的阴影久久不能散去),那么嵌入结构体会对Go的对象内存布局产生什么影响呢?我们来试一试就知道了:
1 | Var Type Address RootOffset LocalOffset Size |
可以看到,成员布局和之前完全一样!所以,我们可以将嵌入理解成一个帮助我们访问嵌套结构体成员和函数的语法糖。为什么我觉得是语法糖呢?因为:
-
在对结构体初始化时,被嵌套的结构体成员无法直接在外层结构体中初始化,而要通过嵌套的结构体本身来初始化
1
2
3
4
5
6
7
8
9
10
11var b B = B{
A: A{
FA1: true,
FA2: 1,
FA3: 2,
FA4: 3,
FA5: 4,
FA6: 5,
},
FB1: true,
} -
从上面DumpObject函数通过反射输出的结果中来看,嵌入的结构体A在类型系统中是作为一个独立的对象存在的:虽然我们可以通过
b.FA1
来访问FA1,但是我们在类型系统中看到的依然是b.A.FA1
。
一旦我们将其理解成为了语法糖而不是继承,后面很多内容就非常好理解了。
2.3. 多层结构体单嵌入
现在我们在B外面再包一层C,但是和B包A不同,在C里面,我们在嵌入的B之前加一个变量,然后来看看其内存布局,如下:
1 | type C struct { |
1 | Var Type Address RootOffset LocalOffset Size |
这里我们可以观察到两个事情:
- 无论嵌入多少层,Go永远会保持嵌入的结构体的内存布局。
- 除此以外,Go语言的结构体成员变量的布局,包括嵌入的结构体,都和定义的顺序保持一致!这个和C++的继承不同,在继承中,第一个基类的地址一定也是这个类本身的地址,而嵌入结构体的地址却没有这个行为,因为嵌入不是继承,而是一个语法糖。
这些性质都让Go的结构体的内存布局变得和C语言一样好理解,因为所写即所得。
3. Go中的结构体有多态吗?
在面向对象的语言中,多态是一个经常使用的概念。一般而言的多态分为三种:
- 非参数化多态(Ad-hoc polymorphism):比如函数重载,运算符重载,宏等等
- 参数化多态:比如泛型编程
- 运行时多态:比如虚函数调用,不同的子类行为可能不同。
那么Go的嵌入支持哪些多态呢?我们这里就来看看吧~
3.1. 函数覆盖与非参数化多态
首先,Go语言里面只有接收器,并没有虚函数,所以我们能做的就是为每一个类定义一个签名一样的函数,如下:
1 | func (a *A) Foo() { |
然后我们调用这些成员函数:
1 | var a A; var b B; var c C |
如下,可以看到输出结果是不同的,也就是说外层类的函数可以覆盖嵌入类的函数,这个和C++中继承时子类覆盖父类的行为类似:
1 | A: .FA1 = false |
我们知道在Go语言中,方法的接收器其实就是此函数的第一个参数,所以我们经常把接收器作一种语法糖,甚至我们可以定义第一个参数是这个结构体指针的函数变量,并进行赋值,那这是不是代表Go可以支持非参数化多态呢?我们这就来换一种方法写这个代码:
1 | func Foo2(a *A) { |
我们编译的时候马上就会得到如下编译错误:
1 | 'Foo2' redeclared in this package |
这说明了两个问题:
- Go的编译器不支持重载,也就是不支持非参数化多态
- Go作为语言本身认为接收器并不是一种语法糖,它是真正意义上的不同的概念。这个在另一个地方也会反映出来:如果有一个接口里面只定义了方法
Foo2()
,我们没有办法使用上述写法实现这个接口。
3.2. 参数化多态
我们知道Go是不支持泛型的,所以Go也不支持参数化多态。如果想要了解Go对泛型支持的进展,传送门在此:
3.3. 那运行时多态呢?
我们都知道运行时多态的实现一般是通过虚表(C++),嵌入类型引用(C#,Java)或者属性表(Python)来实现的。
要验证运行时多态,我们只需要简单的在基类里面调用子类覆盖的方法便可以了,所以我们只需要加一个简单的函数,如下:
1 | func (a *A) CallFoo() { |
熟悉的人一眼就能看出来,这是我们经常使用的一种设计模式,叫做模板方法,是多态最为常见的应用之一。其运行结果如下:
1 | A: .FA1 = false |
我们发现,输出的结果和调用类A的结果是一样的!也就是说:对嵌入类的函数覆盖不会改变嵌入类本身的行为,即Go的结构体不支持运行时多态!
我们能再次对这个结论进行确认吗?当然可以!我们知道,运行时多态的实现都需要对原始对象进行修改,比如:C++的虚表,C#的MethodTable,Python的__dict__,所以我们只要看一下添加完这些函数后,结构体的内存布局会不会出现变化,如下:
1 | Var Type Address RootOffset LocalOffset Size |
从上面输出的信息,我们可以看到,在嵌入的结构体中添加同名函数,不会对结构体的内存布局产生任何的影响!
也就是说,Go的行为和C中间的结构体非常类似,不支持虚函数,也不会为每个对象添加虚表。而这也正印证了Go的说法:嵌入不是继承。Go不存在子类和父类一说,比如,如果我们用如下代码做子类到父类的类型转换,是会编译报错的:
1 | var pa *A = &c // Cannot use '&c' (type *C) as type *A |
3.4. 开闭原则与里氏替换
通过上述三个实验,我们得到一个结论:Go语言的结构体不支持多态!可是在面向对象中,多态的用处非常大啊,为什么要这么设计这门语言呢?这里我们就不得不提两个概念了:开闭原则与里氏变换(LSP)。
虽然Go语言并没有将这两个概念作为Go的核心哲学,但是个人觉得,这个理念绝对是Go最重要的设计原则之一:
- 开闭原则:软件中的对象应该对于扩展是开放的,但是对于修改是封闭的。
- 里氏替换(LSP):我们可以简单的理解成“所有出现父类的地方,都可以用子类来替代,并且程序的行为保持不变”,而里氏替换是实现开闭原则的基础。
这两个原则告诉我们:
- 子类不应该修改父类的行为,如果子类需要改变行为(对修改封闭),需要通过添加新的函数来扩展父类的行为(对扩展开放)。
- 如果一定需要使用运行期多态,则所有的父类都必须不可实例化,不然就会出现子类替代父类并通过运行期多态改变父类的行为的情况,这样就违反了LSP。
我们再回头看Go的设计,上述的应用1就是Go语言对struct的设计,而应用2,就是Go语言对接口的设计!Go语言中不存在多层的继承,非侵入式接口的设计导致所有的类型都是直接实现的接口。
好的,关于多态我们先分析到这里,关于接口和运行期多态,我们之后的篇章再来分析~
4. 多嵌入
好了,看完了相对简单的单嵌入后,我们看看多嵌入的时候我们发现的这些性质是不是还能继续保持呢?
我们来写一个小类测试一下:
1 | type D struct { |
同一个的结构体是无法嵌入多次的,因为嵌入是语法糖,Go会给该结构体分配字段并用结构体名命名,所以同样的结构体嵌入多次会导致编译错误:
1 | .\object_def.go:44:5: duplicate field C |
所以我们稍稍改一下:
1 | type D struct { |
然后查看内部的成员结构:
1 | Var Type Address RootOffset LocalOffset Size |
从LocalOffset上,我们可以很快看出:
- 在多继承的情况下,嵌入的结构体成员布局完全保持不变。开闭原则被保持的非常的好。
- 定义嵌入的结构体时,其中有重复的字段(和方法)是没有关系的。
4.1. 成员选择:最短距离优先
不过现在我们有了一个新的问题,C和B都有相同的字段和方法,那当我们调用d.Foo()
的时候,到底哪一个会被调用呢?我们来试一试:
1 | .\object_def_test.go:81:6: ambiguous selector d.Foo |
果不其然出错了。这就牵涉到Go是如何选择成员进行访问了,这里其实规则非常简单:
- 存在距离相同的同名成员就报错。
- 否则同名成员之间,访问距离最短的。
比如,这里如果我们访问d.FA1
就不会产生编译错误,因为虽然有两个FA1,但是其离D的距离不一样:
所以,d.FA1
在这里会被解释成d.B.A.FA1
,我们这里可以用如下代码对其进行修改,然后在调试器下验证:
1 | d.FA1 = true |
5. 内存对齐
好的,在讨论完所有情况的内存布局之后,我们再回头来看一看一开始我们提到的内存对齐的问题。
首先,我们必须明确一点,这一节的内容,不知道比知道了好。利用内存对齐在Go里面是一件非常危险的事情,Go语言Spec对类型的对齐的定义非常的松散,如果编译器发生改变,便可能造成严重的错误,所以跳过本节不读是非常明智的选择。如果真的需要序列化和反序列化,请使用相关的包,而不是利用内存对齐和类型转换。
好了,那我们继续~
5.1. 类型的对齐值
我们先来看看每种基本类型的对齐值。在Go里,我们可以使用reflect.Type.Align()方法进行观察。这里我们写一个简单的函数来查看:
1 | func TestObjectModelAlignment(t *testing.T) { |
我的机器是amd64的,输出如下:
1 | Type Alignment |
我们可以看到:
- 每种类型对齐值的大小取决于类型的内部实现:
- 整数和浮点数大小就是其本身的大小
- 实数,因为实现是实部和虚部,所以对齐值大小是类型大小的一半
- Slice是一个指针,两个int,所以对齐值为8
- Map,Channel和Function其实都是一个指针,所以对齐值为8
- 数组的对齐值取决于内部元素的对齐值,和元素数量无关
- 结构体对齐值的计算我们下面来仔细讨论
5.2. 结构体空间占用的计算
到目前为止,Go计算结构体的空间占用流程还算简单:
- 首先定义当前的对齐值为0,并获取当前环境中的默认对齐值
- 然后对结构体的字段按定义顺序逐个进行处理,每当处理一个新字段时,都会按照下面几步检查在这个字段前面是否需要添加Padding进行对齐:
- 取当前字段类型的对齐值和默认对齐值中的最小值作为当前的对齐值
- 如果当前字段是结构体,则递归计算,并取其内部字段对齐值的最大值作为其对齐值(这个和我们上面观察到的一样)
- 从当前的偏移开始,找到最小的当前对齐值的整数倍的值,将其作为当前字段的起始偏移,前面的部分用Padding填充
- 重复1-2,直到所有字段处理完毕
- 取当前字段类型的对齐值和默认对齐值中的最小值作为当前的对齐值
- 最后对结构体的整体进行对齐,取结构体内部所有字段对齐值的最大值作为其对齐值,从最后的偏移开始找到最小的当前对齐值的整数倍的值,并将其作为结构体的最终大小
这里我们拿结构体A和B,来举一个例子,这里我们的编译环境是amd64,所以环境的默认对齐值是8:
字段 |
类型 |
字段大小 |
对齐值 |
最终对齐值 |
当前偏移 |
最终偏移 |
加入对齐 |
解释 |
---|---|---|---|---|---|---|---|---|
a.FA1 | bool | 1 | 1 | 1 | 0 | 0 | 0 | 字段对齐值是1,小于默认对齐值8,所以用1作为对齐值,偏移从0开始,最小对齐值整数倍偏移就是0,不需要加Padding |
a.FA2 | int32 | 4 | 4 | 4 | 1 | 4 | 3 | 字段对齐值为4,偏移从1开始,最小对齐值整数倍偏移为4,所以加上3个字节的Padding |
a.FA3 | int16 | 2 | 2 | 2 | 8 | 8 | 0 | 字段对齐值为2,当前偏移为8,最小对齐值整数倍偏移也为8,于是不需要加Padding |
a.FA4 | int64 | 8 | 8 | 8 | 10 | 16 | 6 | 字段对齐值为8,当前偏移为10,最小的对齐值整数倍偏移为16,所以需要加上6个字节的Padding |
a.FA5 | uint8 | 1 | 1 | 1 | 24 | 24 | 0 | 字段对齐值为1,当前偏移24,最小的对齐值整数倍偏移就是24,无需添加Padding |
a.FA6 | uint8 | 1 | 1 | 1 | 25 | 25 | 0 | 字段对齐值为1,当前偏移25,最小的对齐值整数倍偏移就是25,无需添加Padding |
最后再来计算结构体整体的对齐,现在结构体一共26个字节,结构体对齐值为内部字段对齐值的最大值即8,最小的对齐值整数倍为32,所以结构体A最终大小为32,需要添加6个字节的Padding。到这里对于结构体A的计算就完毕了。
那我们再来看看结构体B,我们知道嵌套不改变结构体的布局,所以A的布局完全保持不变。所以我们直接来看B的字段:
字段 |
类型 |
字段大小 |
对齐值 |
最终对齐值 |
当前偏移 |
最终偏移 |
加入对齐 |
解释 |
---|---|---|---|---|---|---|---|---|
b.SA | A | 32 | 8 | 8 | 0 | 0 | 0 | 字段对齐值为8,小于等于默认对齐值8,所以用8作为对齐值,偏移从0开始,最小对齐值整数倍偏移就是0,不需要加Padding |
b.FB1 | bool | 1 | 1 | 1 | 32 | 32 | 0 | 字段对齐值为1,偏移从32开始,最小对齐值整数倍偏移为32,无需Padding |
最后还是计算结构体整体的对齐,B的对齐值应该是8,因为字段对齐值的最大值是8。B的大小现在为33,所以从33开始,最小的对齐值的整数倍是40,于是B的最终大小就是40,需要添加7个字节的Padding。
6. 结论
- Go语言中没有继承,所谓的嵌入(Embedding)只不过是方便访问内部字段的语法糖
- 开闭原则和里氏替换是Go语言结构体的设计的核心思想,其结果是:
- 对修改封闭:
- 结构体嵌套时,其成员的内存布局永远不会发生改变
- 结构体不支持任何类型的多态,外层结构体永远无法改变嵌入结构体的行为
- 对扩展开放:当我们需要改变嵌入结构体行为时,只能通过添加新的函数,将想要改变的函数进行包装
- 对修改封闭:
- Go语言结构体也存在内存对齐
- Go语言Spec对内存对齐的计算约束松散,所以不到万不得已,请不要利用内存对齐实现想要的功能
- 每种类型的对齐值取决于其本身的底层实现
- 计算规则请参阅:结构体空间占用的计算
原创文章,转载请标明出处:Soul Orbit
本文链接地址:Go语言学习笔记:深入对象模型之结构体