概述
在前面两篇教程中,学院君已经介绍了 Go 语言不像 Java、PHP 等支持面向编程的语言那样,支持 class
之类的关键字来定义类,而是通过 type
关键字结合基本类型或者结构体来自定义类型系统,此外,它也不支持通过 extends
关键字来显式定义类型之间的继承关系。
所以,严格来说,Go 语言并不是一门面向对象编程语言,至少不是面向对象编程的最佳选择(Java 才是最根正苗红的),不过我们可以基于它提供的一些特性来模拟实现面向对象编程。
要实现面向对象编程,就必须实现面向对象编程的三大特性:封装、继承和多态。
封装
首先是封装,这一点我们在上篇教程中已经详细介绍过:将函数定义为归属某个自定义类型,这就等同于实现了类的成员方法,如果这个自定义类型是基于结构体的,那么结构体的字段可以看做是类的属性。
继承
然后是继承,Go 虽然没有直接提供继承相关的语法实现,但是我们通过组合的方式间接实现类似功能,所谓组合,就是将一个类型嵌入到另一个类型,从而构建新的类型结构。
传统面向对象编程中,显式定义继承关系的弊端有两个:一个是导致类的层级越来越复杂,另一个是影响了类的扩展性,很多软件设计模式的理念就是通过组合来替代继承提高类的扩展性。
我们来看一个例子,现在有一个 Animal
结构体类型,它有一个属性 Name
用于表示该动物的名称,以及三个成员方法,分别用来获取动物叫声、喜欢的食物和动物的名称:
type Animal struct { Name string } func (a Animal) Call() string { return "动物的叫声..." } func (a Animal) FavorFood() string { return "爱吃的食物..." } func (a Animal) GetName() string { return a.Name }
如果我们要定义一个继承自该类型的子类 Dog
,可以这么做:
type Dog struct { Animal }
这里,我们在 Dog
结构体类型中,嵌入了 Animal
这个类型,这样一来,我们就可以在 Dog
实例上访问所有 Animal
类型包含的属性和方法:
func main() { animal := Animal{"中华田园犬"} dog := Dog{animal} fmt.Println(dog.GetName()) fmt.Println(dog.Call()) fmt.Println(dog.FavorFood()) }
上述代码的打印结果如下:
中华田园犬 动物的叫声... 爱吃的食物...
这就相当于通过组合实现了类与类之间的继承功能。
多态
此外,我们还可以通过在子类中定义同名方法来覆盖父类方法的实现,在面向对象编程中这一术语叫做方法重写,比如在上述 Dog
类型中,我们可以重写 Call
方法和 FavorFood
方法的实现如下:
func (d Dog) FavorFood() string { return "骨头" } func (d Dog) Call() string { return "汪汪汪" }
当我们再执行 main
函数时,直接在 Dog
实例上调用 Call
方法或 FavorFood
方法时,调用的就是 Dog
类中定义的方法而不是 Animal
中定义的方法:
当然,你可以可以像这样继续调用父类 Animal
中的方法:
fmt.Print(dog.Animal.Call()) fmt.Println(dog.Call()) fmt.Print(dog.Animal.FavorFood()) fmt.Println(dog.FavorFood())
只不过 Go 语言不同于 Java、PHP 等面向对象编程语言,没有专门提供引用父类实例的关键字罢了(super
、parent
等),在 Go 语言中,设计哲学一切从简,没有一个多余的关键字,所有的调用都是所见即所得。
这种同一个方法在不同情况下具有不同的表现方式,就是多态,在传统面向对象编程中,多态还有另一个非常常见的使用场景 —— 类对接口的实现,Go 语言也支持此功能,关于这一块我们放到后面接口部分单独介绍。
更多细节
可以看到,与传统面向对象编程语言的继承机制不同,这种组合的实现方式更加灵活,我们不用考虑单继承还是多继承,你想要继承哪个类型的方法,直接组合进来就好了。
多继承同名方法冲突处理
需要注意组合的不同类型之间包含同名方法,比如 Animal
和 Pet
都包含了 GetName
方法,如果子类 Dog
没有重写该方法,直接在 Dog
实例上调用的话会报错:
... type Pet struct { Name string } func (p Pet) GetName() string { return p.Name } type Dog struct { Animal Pet } ... func main() { animal := Animal{"中华田园犬"} pet := Pet{"宠物狗"} dog := Dog{animal, pet} fmt.Println(dog.GetName()) ... }
执行上述代码会报错:
# command-line-arguments chapter04/03-compose.go:49:17: ambiguous selector dog.GetName
除非你显式指定调用哪个父类的方法:
fmt.Println(dog.Pet.GetName())
调整组合位置改变内存布局
另外,我们还可以通过任意调整被组合类型的位置来改变类的内存布局:
type Dog struct { Animal Pet }
和
type Dog struct { Pet Animal }
虽然上面两个 Dog
子类的功能一致,但是它们的内存结构不同。
继承指针类型的属性和方法
当然,在 Go 语言中,你还可以以指针方式继承某个类型的属性和方法:
type Dog struct { *Animal Pet }
这种情况下,除了传入 Animal
实例的时候要传入指针引用之外,其它调用无需修改:
func main() { animal := Animal{"中华田园犬"} pet := Pet{"宠物狗"} dog := Dog{&animal, pet} fmt.Println(dog.Animal.GetName()) fmt.Print(dog.Animal.Call()) fmt.Println(dog.Call()) fmt.Print(dog.Animal.FavorFood()) fmt.Println(dog.FavorFood()) }
当我们通过组合实现类之间的继承时,由于结构体实例本身是值类型,如果传入值字面量的话,实际上传入的是结构体实例的副本,对内存耗费更大,所以组合指针类型性能更好。
为组合类型设置别名
前面的示例调用父类方法时都直接引用的是组合类型(父类)的类型字面量,其实,我们还可以像基本类型一样,为其设置别名,方便引用:
type Dog struct { animal *Animal pet Pet } ... func main() { animal := Animal{"中华田园犬"} pet := Pet{"宠物狗"} dog := Dog{&animal, pet} // 通过 animal 引用 Animal 类型实例 fmt.Println(dog.animal.GetName()) fmt.Print(dog.animal.Call()) fmt.Println(dog.Call()) fmt.Print(dog.animal.FavorFood()) fmt.Println(dog.FavorFood()) }
关于 Go 语言如何通过组合实现类与类之间的继承和方法重写,学院君就简单介绍到这里,下篇教程,我们一起来看看 Go 语言是如何管理类属性和方法的可见性的。
本篇教程的源码可以在 Github 代码仓库获取:https://github.com/geekr-dev/go-tutorial/blob/main/chapter04/03-compose.go。
学习打卡
打卡。
在“继承指针类型的属性和方法” 这一小节中第一段代码,是不是少写了Pet
是的 感谢反馈 已修正
再看一遍