Go 组合与继承那些事

Go 不是一个(传统的)面向对象语言,尽管通过各种奇技淫巧可以实现 OO 的编程风格。

我不赞成「如何在 A 实现 B」之类的尝试。 每个东西都有它自己的特点,这个特点用好了就是优点,用不好就是缺点。非要用汽车拉磨或用驴子拉货,何必呢。

继承 vs 组合

一句话解释,继承是「is sth」,组合是「has sth」。Go 采用组合完美契合了它鸭子类型(duck typing)的设计理念。

“当看到一只鸟走起来像鸭子、游泳起来像鸭子、叫起来也像鸭子,那么这只鸟就可以被称为鸭子。

鸭子类型中,我们重点关注对象能做什么,而不在意它究竟是什么。

对这个理念我略有感触。曾经在 Kotlin (java) 开发中遇到过这样的问题:
第三方包中有个类,没有抽象出接口,我恰恰需要扩展这个东西。于是只好自己定义一个接口,然后写个代理类或者用其他奇奇怪怪的方法达成目的。
你看,它明明是我接口的实现,仅仅因为缺少 implements 关键字,我就得大费周章。

在鸭子类型中这个问题不复存在。

组合要比继承灵活得多。比如 java 中不能让「卡车」既继承「车」又继承「货运工具」,这又偏偏是显示情况。你不能建模为「车 <- 货运工具 <- 卡车」,因为货运工具也可能是飞机。而组合可以轻松办到:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
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
}

组合绝非继承

本质是语法糖

一些博客会把下面两种写法等价:

1
2
3
4
5
6
// java
public class Animal {
  public String name;
}

public class Dog extends Animal { }
1
2
3
4
5
6
7
8
// 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。为了更加明显,我们可以给这个变量指定名字:

1
2
3
4
5
6
type Dog struct {
	innerVar Animal
}

dog := Dog{Animal{"D"}}
println(dog.innerVar.Name)

对象只有一个类型

有人要说了,管它本质是啥,能用不就完事了么。可惜,你用不了… 来看看下面一种典型错误:

1
2
3
// java
public void feed(Animal a) { }
feed(new Dog()); // ok
1
2
3
// Go
func feed(a Animal) { }
feed(Dog{Animal{"D"}}) // error

在 java 中,因为有继承,Dog 也是一个 Animal,因此这么传参毫无问题。不过在 Go 中,对象只能有一个类型——是 Dog 了就不能是 AnimalDog 的确包含 Animal 但它还是 dog。就好像,汽车包含轮子,它还叫汽车,不能管它叫轮子。

建模思路

道理我都懂,可还是觉得奇怪 🤨

那是因为我们的命名太有误导性,或者说,建模思路就错了。我们随手就能写出

1
2
// java
public class Dog extends Animal { }

这样的例子,我想没人会这么写:

1
2
// java
public class Car extends Wheel { } // Wheel 是车轮

很显然,仅管「轮子」比「车」更底层,但它们没有继承关系。

而在 Go 中,用「组合」的思想,把后者实现一遍:

1
2
3
4
type Wheel struct { }
type Car struct {
  Wheel
}

诶,「车拥有轮子」,是不是通顺多了 🥳 既然 java 中行不通的思路在 Go 里毫无违和感,那反过来,把 java 里的常规思路按照所谓的“等价写法”放在 Go 里呢?「猫拥有动物」「货车拥有车」???🧐

由此可见,Go 的设计与传统面向对象完全不同。我们也不能把之前的 OOP 思路强行套在 Go 的开发中。更不应该去找什么「等价写法」。

Go 是组合而非继承,因此在建模过程中我们得 摒弃层级观念,把线性结构转为换网状结构。 比如 人 <- 教师 <- 地理教师 可以转换为 地理教师 consist of(人,地理,教师)

参数传递

建模完毕,使用中少不了传参。Go 没有继承,自然也就不能「定义父类型形参,传子类型对象」了。解决办法有两种。

直接传“子类型”

最粗暴的方案。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
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() 无法恢复 aDog 做进一步处理。

定义接口

这个需求正是接口要做的。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
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
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)
}

参考

禁止转载到私域(公众号,非自己托管的博客等),其他情况请注明原作者与可点击跳转的来源链接。