在前面两篇教程中,学院君已经介绍了 Go 语言不像 Java、PHP 等支持面向编程的语言那样,支持 class 之类的关键字来定义类,而是通过 type 关键字结合基本类型或者结构体来自定义类型系统,此外,它也不支持通过 extends 关键字来显式定义类型之间的继承关系。
所以,严格来说,Go 语言并不是一门面向对象编程语言,至少不是面向对象编程的最佳选择(Java 才是最根正苗红的),不过我们可以基于它提供的一些特性来模拟实现面向对象编程。
要实现面向对象编程,就必须实现面向对象编程的三大特性:封装、继承和多态。
封装
首先是封装,这一点我们在上篇教程中已经详细介绍过:将函数定义为归属某个自定义类型,这就等同于实现了类的成员方法,如果这个自定义类型是基于结构体的,那么结构体的字段可以看做是类的属性。
继承
然后是继承,Go 虽然没有直接提供继承相关的语法实现,但是我们通过组合的方式间接实现类似功能,所谓组合,就是将一个类型嵌入到另一个类型,从而构建新的类型结构。
传统面向对象编程中,显式定义继承关系的弊端有两个:一个是导致类的层级越来越复杂,另一个是影响了类的扩展性,很多软件设计模式的理念就是通过组合来替代继承提高类的扩展性。
我们来看一个例子,现在有一个 Animal 结构体类型,它有一个属性 Name 用于表示该动物的名称,以及三个成员方法,分别用来获取动物叫声、喜欢的食物和动物的名称:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
|
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,可以这么做:
这里,我们在 Dog 结构体类型中,嵌入了 Animal 这个类型,这样一来,我们就可以在 Dog 实例上访问所有 Animal 类型包含的属性和方法:
|
|
func main() { animal := Animal{"中华田园犬"} dog := Dog{animal} fmt.Println(dog.GetName()) fmt.Println(dog.Call()) fmt.Println(dog.FavorFood()) } |
注意这里animal := Animal{"中华田园犬"},并没有指定Name,这是按照属性顺序传递的,推荐直接指定Name
上述代码的打印结果如下:
这就相当于通过组合实现了类与类之间的继承功能。
多态
此外,我们还可以通过在子类中定义同名方法来覆盖父类方法的实现,在面向对象编程中这一术语叫做方法重写,比如在上述 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.Print(dog.Animal.FavorFood())
fmt.Println(dog.FavorFood())
只不过 Go 语言不同于 Java、PHP 等面向对象编程语言,没有专门提供引用父类实例的关键字罢了(super、parent 等),在 Go 语言中,设计哲学一切从简,没有一个多余的关键字,所有的调用都是所见即所得。
这种同一个方法在不同情况下具有不同的表现方式,就是多态,在传统面向对象编程中,多态还有另一个非常常见的使用场景 —— 类对接口的实现,Go 语言也支持此功能,关于这一块我们放到后面接口部分单独介绍。
更多细节
可以看到,与传统面向对象编程语言的继承机制不同,这种组合的实现方式更加灵活,我们不用考虑单继承还是多继承,你想要继承哪个类型的方法,直接组合进来就好了。
多继承同名方法冲突处理
需要注意组合的不同类型之间包含同名方法,比如 Animal 和 Pet 都包含了 GetName 方法,如果子类 Dog 没有重写该方法,直接在 Dog 实例上调用的话会报错:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
|
... 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()) ... } |
执行上述代码会报错:
chapter04/03-compose.go:49:17: ambiguous selector dog.GetName
除非你显式指定调用哪个父类的方法:
fmt.Println(dog.Pet.GetName())
调整组合位置改变内存布局
另外,我们还可以通过任意调整被组合类型的位置来改变类的内存布局:
和
虽然上面两个 Dog 子类的功能一致,但是它们的内存结构不同。
继承指针类型的属性和方法
当然,在 Go 语言中,你还可以以指针方式继承某个类型的属性和方法:
这种情况下,除了传入 Animal 实例的时候要传入指针引用之外,其它调用无需修改:
animal := Animal{"中华田园犬"}
fmt.Println(dog.Animal.GetName())
fmt.Print(dog.Animal.Call())
fmt.Print(dog.Animal.FavorFood())
fmt.Println(dog.FavorFood())
当我们通过组合实现类之间的继承时,由于结构体实例本身是值类型,如果传入值字面量的话,实际上传入的是结构体实例的副本,对内存耗费更大,所以组合指针类型性能更好。
为组合类型设置别名
前面的示例调用父类方法时都直接引用的是组合类型(父类)的类型字面量,其实,我们还可以像基本类型一样,为其设置别名,方便引用:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
|
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 语言是如何管理类属性和方法的可见性的。
来源:https://geekr.dev/posts/go-oop-with-type-composite
Go 不是一个(传统的)面向对象语言,尽管通过各种奇技淫巧可以实现 OO 的编程风格。
我不赞成「如何在 A 实现 B」之类的尝试。 每个东西都有它自己的特点,这个特点用好了就是优点,用不好就是缺点。非要用汽车拉磨或用驴子拉货,何必呢。
继承 vs 组合
一句话解释,继承是「is sth」,组合是「has sth」。Go 采用组合完美契合了它鸭子类型(duck typing)的设计理念。
“当看到一只鸟走起来像鸭子、游泳起来像鸭子、叫起来也像鸭子,那么这只鸟就可以被称为鸭子。
鸭子类型中,我们重点关注对象能做什么,而不在意它究竟是什么。
对这个理念我略有感触。曾经在 Kotlin (java) 开发中遇到过这样的问题:
第三方包中有个类,没有抽象出接口,我恰恰需要扩展这个东西。于是只好自己定义一个接口,然后写个代理类或者用其他奇奇怪怪的方法达成目的。
你看,它明明是我接口的实现,仅仅因为缺少 implements 关键字,我就得大费周章。
在鸭子类型中这个问题不复存在。
组合要比继承灵活得多。比如 java 中不能让「卡车」既继承「车」又继承「货运工具」,这又偏偏是显示情况。你不能建模为「车 <- 货运工具 <- 卡车」,因为货运工具也可能是飞机。而组合可以轻松办到:
|
|
type Car struct{ Id string } type Plane struct{ Owner string } type Logistics struct{ CargoType string } type Truck struct{ Car Logistics } type An255 struct{ Plane Logistics } |
组合绝非继承
本质是语法糖
一些博客会把下面两种写法等价:
|
|
// java public class Animal { public String name; } public class Dog extends Animal { } |
|
|
// Go type Animal struct { name string } type Dog struct { Animal } |
它们用起来确实很类似,都可以通过“子类”直接访问“父类”的属性 Dog.name,但这两个有本质差别:
对于 java,name 确实是 Dog 的属性,不可以 Dog.Animal.name 这样来访问。可对于 Go,Dog 是没有 name 属性的。Dog.name 只是一个 Dog.Animal.name 的语法糖。 实际上 Dog 中有一个类型为 Animal 的变量(默认变量名与类型一致),name 依然只属于 Animal。为了更加明显,我们可以给这个变量指定名字:
|
|
type Dog struct { innerVar Animal } dog := Dog{Animal{"D"}} println(dog.innerVar.Name) |
对象只有一个类型
有人要说了,管它本质是啥,能用不就完事了么。可惜,你用不了… 来看看下面一种典型错误:
|
|
// java public void feed(Animal a) { } feed(new Dog()); // ok |
|
|
// Go func feed(a Animal) { } feed(Dog{Animal{"D"}}) // error |
在 java 中,因为有继承,Dog 也是一个 Animal,因此这么传参毫无问题。不过在 Go 中,对象只能有一个类型——是 Dog 了就不能是 Animal。Dog 的确包含 Animal 但它还是 dog。就好像,汽车包含轮子,它还叫汽车,不能管它叫轮子。
建模思路
道理我都懂,可还是觉得奇怪 🤨
那是因为我们的命名太有误导性,或者说,建模思路就错了。我们随手就能写出
|
|
// java public class Dog extends Animal { } |
这样的例子,我想没人会这么写:
|
|
// java public class Car extends Wheel { } // Wheel 是车轮 |
很显然,仅管「轮子」比「车」更底层,但它们没有继承关系。
而在 Go 中,用「组合」的思想,把后者实现一遍:
|
|
type Wheel struct { } type Car struct { Wheel } |
诶,「车拥有轮子」,是不是通顺多了 🥳 既然 java 中行不通的思路在 Go 里毫无违和感,那反过来,把 java 里的常规思路按照所谓的“等价写法”放在 Go 里呢?「猫拥有动物」「货车拥有车」???🧐
由此可见,Go 的设计与传统面向对象完全不同。我们也不能把之前的 OOP 思路强行套在 Go 的开发中。更不应该去找什么「等价写法」。
Go 是组合而非继承,因此在建模过程中我们得 摒弃层级观念,把线性结构转为换网状结构。 比如 人 <- 教师 <- 地理教师 可以转换为 地理教师 consist of(人,地理,教师)。
参数传递
建模完毕,使用中少不了传参。Go 没有继承,自然也就不能「定义父类型形参,传子类型对象」了。解决办法有两种。
直接传“子类型”
最粗暴的方案。
|
|
|
|
type AnimalBaseInfo struct { Name string } type Dog struct { AnimalBaseInfo } func feed(a AnimalBaseInfo) { // “父类”形参 println("Feed" + a.Name) } func main() { dog := Dog{AnimalBaseInfo{"D"}} feed(dog.AnimalBaseInfo) // 直接传“子类”对象 } |
|
缺点是会丢失额外信息。feed() 无法恢复 a 为 Dog 做进一步处理。
定义接口
这个需求正是接口要做的。
|
|
type Animal interface{ Name() string } type AnimalBaseInfo struct{ name string } type Dog struct{ AnimalBaseInfo } // Dog 实现接口,此时可以说,Dog 是 Animal func (d *Dog) Name() string { return d.name } func feed(a Animal) { println("Feed" + a.Name()) } func main() { dog := Dog{AnimalBaseInfo{"D"}} feed(&dog) // 传 dog 自己 } |
如果 AnimalBaseInfo 字段较多,实现接口是需要写很多方法,那么可以把它们用一个 struct 表示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
|
type Animal interface{ Info() AnimalBaseInfo } // 接口直接返回结构体 type AnimalBaseInfo struct { name string age int sex bool } type Dog struct { AnimalBaseInfo } func (d *Dog) Info() AnimalBaseInfo { // 接口实现 return d.AnimalBaseInfo } func feed(a Animal) { println("Feed" + a.Info().name) } |
来源:https://chenhe.me/post/inheritance-in-go/
「三年博客,如果觉得我的文章对您有用,请帮助本站成长」
共有 0 - golang 继承与组合