在传统的面向对象语言中,继承被认为是实现代码复用和提高代码可维护性的关键机制之一,其内容为:从已有的类中派生出新的类,新的类能够吸收已有类的属性和行为,并且能够在此基础上扩展新的属性和行为^[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
的一部分,作为其属性存在。如下:
go
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
的内部结构,并以匿名字段的形式存在:
go
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
结构体的所有字段和方法。其定义形式与如下效果类似:
go
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
,可实际上,我们所访问的是其内部类型Student
的PrintAge
方法。这可以这样解释:Monitor
将访问"委托"给了Student
。而这种"委托"不仅用于"委托"类型的行为,也可用于"委托"类型的属性,同样是Monitor
将访问"委托"给了Student
,如monitor.Name
。
go
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.PrintAge()
时,若外部类型实现了该方法,内部的PrintAge
则会被外部的PrintAge
隐藏(Shadow,类似于方法PrintAge
被Monitor
重写),因此不会"展开"调用内部类的方法,会直接调用外部类自己的PrintAge
方法。同理,如果Monitor
拥有自己Name
字段时,外部类型的Name
会隐藏内部类型的Name
,我们再以monitor.Name
的形式访问时,访问到的是外部类型自己的属性,不再是内部类型的属性了^[13]^^[14]^。虽然当外部类型实现了与内部类型相同的方法或者拥有了与内部类型相同的属性时,内部类型的实现就不会被"提升",但内部类型的属性和行为却是一直存在的,因此还可以通过直接访问内部类型,来调用没有被"提升"的内部类型实现的方法或属性,如:monitor.Student.Name
^[15]^。
下面的代码示例中,我们将为外部结构体Monitor
添加字段Age
和方法PrintAge
,从而隐藏内部Student
的Age
和Name
:
go
// 只为 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
的实例直接调用接口方法,程序则会崩溃。
go
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
结构体显式实现该方法。
go
func (recv *Student) ChangeClass(class int) {
recv.Class = class
}
此处我们只实现了ChangeClass
方法,并未实现ChangeName
方法,因此只能通过Student
实例调用接口的ChangeClass
方法,调用ChangeName
则会导致程序崩溃。
当然,这种情况在结构体中匿名嵌入实现了接口的类型时也一样,外部类型虽然会实现相应的接口,但却无法真正调用对应的方法。假设我们将实现了StudentInterface
接口的Student
结构体被匿名嵌入了结构体Student2
中,那么Student
的属性和方法自然也会被"提升"至Student2
中,使得Student2
也形式上实现了StudentInterface
接口。
go
type Student2 struct {
Student
}
匿名嵌入类型Student
后,Student2
虽然可以直接调用StudentInterface
接口的ChangeClass
方法(直接调用时会被"展开"为其内部类型的调用),但调用ChangeName
依然会导致程序崩溃,因为无论是内部类型Student
还是外部类型Student2
都未具体实现该方法。
go
func (recv *Student2) ChangeName(name string) {
recv.Name = name
}
为了解决这一问题,我们为Student2
显式实现了ChangeName
方法。也就是说在此时,Student2
不仅拥有方法ChangeClass
,还真正拥有了方法ChangeName
,即Student2
真正实现了接口StudentInterface
。
外此,因为Student2
的方法集是"继承"的Student
的,所以我们还可以让Student
实现ChangeName
方法后也能解决该问题。
go
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
。