结构类型(struct)为Go语言提供了强大的类型扩展,主要体现在两个方面:第一,struct 可以嵌入任意其他类型的字段;第二,struct 可以嵌套自身的指针类型的字段。
这两个特性决定了 struct 类型有着强大的表达力,几乎可以表示任意的数据结构。同时,结合结构类型的方法,“数据+方法”可以灵活地表达程序逻辑。
Go语言的结构(struct)和C语言的 struct 一样,内存分配按照字段顺序依次开辟连续的存储空间,没有插入额外的东西(除字段对齐外),不像 C++ 那样为了实现多态在对象内存模型里插入了虚拟函数指针,这种设计的优点使数据和逻辑彻底分离,对象内存区只存放数据,干净简单;类型的方法也是显式带上接收者,没有像 C++ 一样使用隐式的 this 指针,这是一种优秀的设计方法。
Go语言中的数据就是数据,逻辑就是逻辑,二者是“正交”的,底层实现上没有相关性,在语言使用层又为开发者提供了统一的数据和逻辑抽象视图,这种外部统一、内部隔离的面向对象设计是Go语言优秀设计的体现。
从前面讨论的命名类型的方法可知,使用 type 定义的新类型不会继承原有类型的方法,有个特例就是命名结构类型,命名结构类型可以嵌套其他的命名类型的字段,外层的结构类型是可以调用嵌入字段类型的方法,这种调用既可以是显式的调用,也可以是隐式的调用。这就是 Go 的“继承”,准确地说这就是 Go 的“组合”。
因为Go语言没有继承的语义,结构和字段之间是“has k”的关系,而不是“is a”的关系,没有父子的概念,仅仅是整体和局部的概念,所以后续统称这种嵌套的结构和字段的关系为组合。
struct 中的组合非常灵活,可以表现为水平的字段扩展,由于 struct 可以嵌套其他 struct 字段,所以组合也可以分层次扩展。struct 类型中的字段称为“内嵌字段”,内嵌字段的访问和方法调用遵照的规约接下来进行讲解。
struct 的字段访问使用点操作符“.”,struct 的字段可以嵌套很多层,只要内嵌的字段是唯一的即可,不需要使用全路径进行访问。在以下示例中,可以使用 z.a 代替 z.Y.X.a。
package main
type X struct {
a int
}
type Y struct {
X
b int
}
type Z struct {
Y
c int
}
func main() {
x := X{a: 1}
y := Y{
X: x,
b: 2,
}
z := Z{
Y: y,
c: 3,
}
//z.a, z.Y.a, z.Y.X.a 三者是等价的, z.a z.Y.a 是 z.Y.X.a 的简写
println(z.a, z.Y.a, z.Y.X.a) //1 1 1
z = Z{}
z.a = 2
println(z.a, z.Y.a, z.Y.X.a) //2 2 2
}
在 struct 的多层嵌套中,不同嵌套层次可以有相同的字段,此时最好使用完全路径进行访问和初始化。在实际数据结构的定义中应该尽量避开相同的字段,以免在使用中出现歧义。例如:
package main
type X struct {
a int
}
type Y struct {
X
a int
}
type Z struct {
Y
a int
}
func main() {
x := X{a: 1}
y := Y{
X: x,
a: 2,
}
z := Z{
Y: y,
a: 3,
}
//此时的z.a, z.Y.a, z.Y.X.a 代表不同的字段
println(z.a, z.Y.a, z.Y.X.a) // 3 2 1
z = Z{}
z.a = 4
z.Y.a = 5
z.Y.X.a = 6
//此时的z.a, z.Y.a, z.Y.X.a 代表不同的字段
println(z.a, z.Y.a, z.Y.X.a) // 4 5 6
}
struct 类型方法调用也使用点操作符,不同嵌套层次的字段可以有相同的方法,外层变量调用内嵌字段的方法时也可以像嵌套字段的访问一样使用简化模式。如果外层字段和内层字段有相同的方法,则使用简化模式访问外层的方法会覆盖内层的方法。
即在简写模式下,Go 编译器优先从外向内逐层查找方法,同名方法中外层的方法能够覆盖内层的方法。这个特性有点类似于面向对象编程中,子类覆盖父类的同名方法。示例如下:
package main
import "fmt"
type X struct {
a int
}
type Y struct {
X
b int
}
type Z struct {
Y
c int
}
func (x X) Print() {
fmt.Printf("In X, a = %d\n", x.a)
}
func (x X) XPrint() {
fmt.Printf("In X, a = %d\n", x.a)
}
func (y Y) Print() {
fmt.Printf("In Y, b = %d\n", y.b)
}
func (z Z) Print() {
fmt.Printf("In Z, c = %d\n", z.c)
//显式的完全路径调用内嵌字段的方法
z.Y.Print()
z.Y.X.Print()
}
func main() {
x := X{a: 1}
y := Y{
X: x,
b: 2,
}
z := Z{
Y: y,
c: 3,
}
//从外向内查找,首先找到的是 Z 的 Print() 方法
z.Print()
//从外向内查找,最后找到的是 x 的 XPrint()方法
z.XPrint()
z.Y.XPrint()
}
不推荐在多层的 struct 类型中内嵌多个同名的字段;但是并不反对 struct 定义和内嵌字段同名方法的用法,因为这提供了一种编程技术,使得 struct 能够重写内嵌字段的方法,提供面向对象编程中子类覆盖父类的同名方法的功能。
组合结构的方法集有如下规则:
下面举个例子来验证这个规则的正确性,前面讲到方法集时提到 Go 编译器会对方法调用进行自动转换,为了阻止自动转换,本示例使用方法表达式的调用方式,这样能更清楚地理解这个方法集的规约。
package main
type X struct {
a int
}
type Y struct {
X
}
type Z struct {
*X
}
func (x X) Get() int {
return x.a
}
func (x *X) Set(i int) {
x.a = i
}
func main() {
x := X{a: 1}
y := Y{
X: x,
}
println(y.Get()) // 1
//此处编译器做了自动转换
y.Set(2)
println(y.Get()) // 2
//为了不让编译器做自动转换,使用方法表达式调用方式
//Y 内嵌字段 X,所以 type y 的方法集是 Get, type *Y 的方法集是 Set Get
(*Y).Set(&y, 3)
//type y 的方法集合并没有 Set 方法,所以下一句编译不能通过
//Y.Set(y, 3)
println(y.Get()) // 3
z := Z{
X: &x,
}
//按照嵌套字段的方法集的规则
//Z 内嵌字段*X ,所以 type Z 和 type *Z 方法集都包含类型 X 定义的方法 Get 和 Set
//为了不让编译器做自动转换,仍然使用方法表达式调用方式
Z.Set(z, 4)
println(z.Get()) // 4
(*Z).Set(&z, 5)
println(z.Get()) // 5
}
到目前为止还没有发现方法集有多大的用途,而且通过实践发现,Go 编译器会进行自动转换,看起来不需要太关注方法集,这种认识是错误的。编译器的自动转换仅适用于直接通过类型实例调用方法时才有效,类型实例传递给接口时,编译器不会进行自动转换,而是会进行严格的方法集校验。
Go 函数的调用实参都是值拷贝,方法调用参数传递也是一样的机制,具体类型变量传递给接口时也是值拷贝,如果传递给接口变量的是值类型,但调用方法的接收者是指针类型,则程序运行时虽然能够将接收者转换为指针,但这个指针是副本的指针,并不是我们期望的原变量的指针。
所以语言设计者为了杜绝这种非期望的行为,在编译时做了严格的方法集合的检查,不允许产生这种调用;如果传递给接口的变量是指针类型,则接口调用的是值类型的方法,程序运行时能够自动转换为值类型,这种转换不会带来副作用,符合调用者的预期,所以这种转换是允许的,而且这种情况符合方法集的规约。