likes
comments
collection
share

Go 结构体(其二):结构体与嵌入

作者站长头像
站长
· 阅读数 5

在传统的面向对象语言中,继承被认为是实现代码复用和提高代码可维护性的关键机制之一,其内容为:从已有的类中派生出新的类,新的类能够吸收已有类的属性和行为,并且能够在此基础上扩展新的属性和行为[1]。这是一种"is-a"关系,即一个类是另一个类的子类,意味着子类继承了父类的属性和方法,并且通常可以被视为一种特化或扩展。

然而,在面向对象编程的实践中,继承也带来了一些局限性,比如[2][3][4]

  • 当类之间存在多层继承关系时,会形成复杂的继承层次,难以维护和理解;
  • 破坏了类的封装性,子类了解父类的内部细节;
  • 子类与父类强耦合,一旦父类行为改变,子类极大概率需要强制变动。

或许正因如此,在 Golang 的设计中并没有加入这种"is-a"的继承机制。在介绍 Go 的"继承"机制前,我们先谈论一下合成复用设计原则。合成复用设计原则(CRP)强调使用对象的复用,而不是继承,其认为通过继承实现的复用破坏了类的封装性,它会将父类的细节暴露给子类,使得父类对子类透明,父类的实现的任何变动都可能导致子类的实现发生变化,这不利于类的扩展与维护[5]。CRP 中推荐使用复用,其通常比继承更有利于封装,通过将对象作为组件嵌入到另一个对象中,可以更好地隐藏内部实现细节;同时复用也不会像多层继承那样形成复杂的层次结构,难以管理。与其类似,Go 语言的"继承"采用了与合成复用原则近似的设计理念——组合[6]

通常,组合指的是在一个类型中嵌入另一个类型来创建新类型的过程,即通过组合简单的类型来创建更复杂的类型。但与继承不同,组合是一种"has-a"关系,这意味着由几个其他类型组成的类型具有这些类型的功能[7]

关于 Go 的组合,Go 语言之父 Rob Pike 曾经这样说过:如果 C++ 和 Java 是关于类型层次结构和类型分类的,那么 Go 就是关于组合的。正如 Doug McIlroy,Unix 管道的最终发明者,在 1964 年说道:"我们应该有一些耦合程序的方法,就像花园软管一样——当需要以另一种方式处理数据时,拧入另一个部分。这也是IO的方式"。这也是 Go 的方式。Go 采纳了这个想法并将其推向更远。它是一种组合和耦合的语言[8]

  • 一个明显的例子是接口为我们提供组件组合的方式。不管那个东西是什么,如果它实现了方法 M 就可以把它放入其中。
  • 另一个重要的例子是并发性如何为我们提供独立执行计算的组合。
  • 甚至还有一种不寻常(而且非常简单)的类型组合形式:嵌入。

该描述中最后提到的一种组合形式,"嵌入",与本节我们即将讨论的 Go 的"继承"密切相关。嵌入,也称嵌套、类型嵌入或者类型组合。这种类型组合并非继承,它的表现有些类似于组合(代码复用的组合)+委托[9]。关于委托,在委托模式中解释道:有两个对象参与处理同一个请求,接受请求的对象将请求委托给另一个对象来处理,比如调用 A 类的 methodA 方法,其实背后是 B 类的 methodA 去执行。委托模式使得我们可以用聚合来替代继承[10]。这种近似"委托"的嵌入方式为 Go 语言"继承"的设计哲学奠定了基础,在本小节的内容中,我们也即将通过探讨 Go 的嵌入来了解其"继承"机制的一角。

Go 支持两种嵌入方式:具名嵌入和匿名嵌入。

  • 具名嵌入,指在结构体中嵌入在另一个类型时,将被嵌入的类型当作结构体具有字段名的字段来使用,可以在外部类型上通过具名嵌入类型的字段名访问被嵌入类型的属性和方法。
  • 匿名嵌入,是在结构体中嵌入在另一个类型时,将被嵌入的类型当作结构体的匿名字段来使用,外部类型可以直接访问被嵌入类型的属性和方法。

4.1 具名嵌入

具名嵌入是将一个类型嵌入到结构体中,并以字段名称的方式引入。在下面给定的示例代码中,我们定义了一个名为Monitor的结构体,并在其中嵌入了Student结构体类型,使用字段名StuInfo标识。这实质上使得Student成为Monitor的一部分,作为其属性存在。如下:

type Student struct {
    Name  string
    Sex   string
    Age   int
    Class int
}

func (recv Student) Introduce() string {
  return fmt.Sprintf("I'm a Class %d student. My name is %s",recv.Class,recv.Name)
}

func (recv Student) PrintAge() {
    str := fmt.Sprintf("I'm %d",recv.Age)
    fmt.Println(str)
}

// 字段嵌入
type Monitor struct{
    ClassID uint64
    StuInfo	Student
}

通过具名嵌入,Monitor结构体不仅包含自己的属性,如 ClassID,还包含了Student结构体的所有字段和方法。这意味着,通过Monitor实例,我们可以轻松地访问和操作Student实例的数据和行为。例如,当我们需要访问Student中的属性或者调用其方法PrintAge时,要先通过选择器.访问字段StuInfo,然后才能调用方法,像这样:monitor.StuInfo.PrintAge()

需要注意:具名嵌入后,被嵌入类型的属性和方法不会被"提升"至外部类型,即Monitor不能直接访问Student的属性和方法,如monitor.PrintAge()monitor.Name

4.2 匿名嵌入

匿名嵌入,也称类型组合、类型嵌入,是在结构体中嵌入在另一个类型时,将被嵌入的类型当作结构体的匿名字段来使用。在下面给定的代码示例中,我们定义了一个名为Monitor的结构体,通过匿名嵌入的方式将Student结构体类型直接嵌套到Monitor中,这使得Student类型成为Monitor的内部结构,并以匿名字段的形式存在:

type Student struct {
    Name  string
    Sex   string
    Age   int
    Class int
}

func (recv Student) Introduce() string {
  return fmt.Sprintf("I'm a Class %d student. My name is %s",recv.Class,recv.Name)
}

func (recv Student) PrintAge() {
    str := fmt.Sprintf("I'm %d",recv.Age)
    fmt.Println(str)
}

// 匿名嵌入/类型嵌入
type Monitor struct{
    ClassID uint64
    Student
}

通过匿名嵌入,Monitor结构体不仅有自己定义的属性 ClassID,还直接拥有了Student结构体的所有字段和方法。其定义形式与如下效果类似:

type Monitor struct {
    ClassID uint64

    // 以下为 Student 的字段
    Name  string
    Sex   string
    Age   int
    Class int
}

func (recv Monitor) Introduce() string {
  return fmt.Sprintf("I'm a Class %d student. My name is %s",recv.Class,recv.Name)
}

func (recv Monitor) PrintAge() {
    str := fmt.Sprintf("I'm %d",recv.Age)
    fmt.Println(str)
}

可见,匿名嵌入的类型的属性和方法被"提升"至了外部类型,Monitor可以直接访问该属性和方法,如monitor.PrintAge()。此时我们看似是在访问Monitor自己的方法PrintAge,可实际上,我们所访问的是其内部类型StudentPrintAge方法。这可以这样解释:Monitor将访问"委托"给了Student。而这种"委托"不仅用于"委托"类型的行为,也可用于"委托"类型的属性,同样是Monitor将访问"委托"给了Student,如monitor.Name

var monitor Monitor
monitor.Age = 18

// 方式1:直接调用
monitor.PrintAge()

// 方式2:访问内部类型调用
monitor.Student.PrintAge()

// output:
// 方式1:
//	I'm 18

// 方式2:
//	I'm 18

可需要注意,虽然外部类型Monitor可以直接调用内部类型的方法,但方法的接受者仍然是内部类型Student,而非Monitor[11]。调用monitor.PrintAge()时最终会被"展开"为monitor.Student.PrintAge()的形式。这种展开是编译期完成的, 所以没有运行时代价[12]。编译器在编译期间会确定方法的接收者,使得调用 monitor.PrintAge() 时实际上调用了 monitor.Student.PrintAge()

下面的代码示例中,我们将为外部结构体Monitor添加字段Age和方法PrintAge,从而隐藏内部StudentAgeName

// 只为 Monitor 实现了 PrintAge 方法
// 没有实现 Introduce 方法
type Monitor struct{
    ClassID uint64
    Age		int
    Student
}

func (recv Monitor) PrintAge() {
    str := fmt.Sprintf("Monitor: I'm %d",recv.Age)
    fmt.Println(str)
}

// main:
var monitor Monitor
monitor.Student = Student{
    Name:"Ayanokoji",
    Age:18,
}
monitor.PrintAge()
monitor.Student.PrintAge()

// output:
// Monitor: I'm 0
// I'm 18

Monitor实现了方法PrintAge后,我们再次通过调用monitor.PrintAge()monitor.Student.PrintAge()时可以看到这两种调用的输出不一样了。而且两次输出的年龄也不一样了,这是因为monitor.PrintAge()现在所访问的是外部类型Monitor自己的方法,不再是Student的方法;而内部类型对应的属性和方法只是被外部类型隐藏,其一直存在,而我们在初始化Monitor时只为其内部类型的Age赋值,没有给Monitor自己的Age赋值,所以内部类型的Age和外部类型的Age并不相同,故输出值也不同。

4.3 匿名嵌入与接口

在前文中,我们提到了将一个类型匿名嵌入结构体后,该结构体将拥有被嵌入类型的属性和方法。同理,当我们将接口类型或者实现了某个接口的其他类型匿名嵌入结构体后,该结构体也拥有对应接口所有的方法,即实现了该接口。

然而,在下面的代码示例中,结构体Student虽然拥有了StudentInterface接口的所有方法,并在形式上实现了它,但实际上却无法直接调用该接口的任何方法。这是因为Student并没有实现这些方法,如果尝试通过Student的实例直接调用接口方法,程序则会崩溃。

type StudentInterface interface {
    ChangeClass(class int)
    ChangeName(name string)
}

type Student struct {
    Name  string
    Sex   string
    Age   int
    Class int
    StudentInterface
}

func (recv *Student) Introduce() string {
  return fmt.Sprintf("I'm a Class %d student. My name is %s",recv.Class,recv.Name)
}

为了能够正式调用StudentInterface接口的方法,比如ChangeClass,我们需要为Student结构体显式实现该方法。

func (recv *Student) ChangeClass(class int) {
    recv.Class = class
}

此处我们只实现了ChangeClass方法,并未实现ChangeName方法,因此只能通过Student实例调用接口的ChangeClass方法,调用ChangeName则会导致程序崩溃。

当然,这种情况在结构体中匿名嵌入实现了接口的类型时也一样,外部类型虽然会实现相应的接口,但却无法真正调用对应的方法。假设我们将实现了StudentInterface接口的Student结构体被匿名嵌入了结构体Student2中,那么Student的属性和方法自然也会被"提升"至Student2中,使得Student2也形式上实现了StudentInterface接口。

type Student2 struct {
    Student
}

匿名嵌入类型Student后,Student2虽然可以直接调用StudentInterface接口的ChangeClass方法(直接调用时会被"展开"为其内部类型的调用),但调用ChangeName依然会导致程序崩溃,因为无论是内部类型Student还是外部类型Student2都未具体实现该方法。

func (recv *Student2) ChangeName(name string) {
    recv.Name = name
}

为了解决这一问题,我们为Student2显式实现了ChangeName方法。也就是说在此时,Student2不仅拥有方法ChangeClass,还真正拥有了方法ChangeName,即Student2真正实现了接口StudentInterface

外此,因为Student2的方法集是"继承"的Student的,所以我们还可以让Student实现ChangeName方法后也能解决该问题。

func (recv *Student) ChangeClass(class int) {
    recv.Class = class
}

func (recv *Student) ChangeName(name string) {
    recv.Name = name
}

// 没有主动实现任何方法
type Student2 struct {
    Student
}

以上就是关于 Go 嵌入的谈论,尽管嵌入在某种程度上提供了一种实现类似继承的方式,但在 Go 中并没有真正的继承概念。在经典的面向对象语言中,继承通常还包括子类可以重写或重载父类的方法,而在 Go 中,嵌入只是简单地引入了字段和方法,没有提供对它们的重写或重载。

简言之,在 Go 语言中,嵌入和面向对象的继承有一些相似之处,但也存在一些重要的区别。嵌入是通过将一个类型嵌入到另一个类型中来实现的,且它的表现有些类似于组合+委托,这使得外部类型可以直接访问被嵌入类型的方法和属性,就像它们是外部类型的一部分一样。

为什么在结构体中匿名嵌入接口或者嵌入实现了接口的类型后,外部结构体也实现了相应的接口呢?

在解释为什么之前,我们先得了解什么是鸭子类型(duck typing),其描述为:一只鸟走起来时像鸭子,游泳时像鸭子,叫起来也像鸭子,那么就可以认为这只鸟就是鸭子。鸭子类型,是一种在运行时关注对象行为而非其类型的编程风格,它并不关注于物件所拥有属性,只关注物件的行为,只关注物件做什么[16]

例如,在不使用鸭子类型的语言中,我们可以编写一个函数,它接受一个类型为"鸭子"的物件,并调用它的"走"和"叫"方法。在使用鸭子类型的语言中,这样的一个函数可以接受一个任意类型的物件,并调用它的"走"和"叫"方法。如果这些需要被调用的方法不存在,那么将引发一个运行时错误。任何拥有这样的正确的"走"和"叫"方法的物件都可被函数接受的这种行为引出了以上表述,这种决定类型的方式因此得名[16]

Go 语言的接口就完美支持这种鸭子类型[17]。在Student2内嵌了结构体Student后,它就拥有了Student所有的方法和属性,Student2也就拥有了StudentInterface中的所有行为,所以从行为上看这只"鸟"Student2就是"鸭子"StudentInterface,即Student实现了接口StudentInterface