|
| 1 | +# 谈谈Go与面向对象 |
| 2 | + |
| 3 | +本文面向已经会 Go 的基础语法并基本掌握一门面向对象语言的读者。 |
| 4 | + |
| 5 | +先抛个问题:Go 是不是面向对象语言? |
| 6 | + |
| 7 | +官方的回答是:「Yes and no」。 |
| 8 | + |
| 9 | +Go 语言可以做到绝大多数面向对象语言的特性,但它不是一门「标准」的面向对象语言,它没有「type hierarchy」。 |
| 10 | + |
| 11 | +一开始,我觉得它以自己的奇怪甚至近乎「妖魔」的方式与面向对象打了个擦边球; |
| 12 | +后来,我反而觉得完美面向对象就应该是这样灵活的,现在的所谓的「标准面向对象」,反而是一种不完美的实现。 |
| 13 | + |
| 14 | +下面来谈谈 Go 如何实现面向对象。因为 Java 是比较「规整」的面向对象实现,所以下文会多次与 Java 进行对比。 |
| 15 | + |
| 16 | +## 封装 |
| 17 | + |
| 18 | +封装指隐藏对象的属性和实现细节,仅对外提供公共访问方式。 |
| 19 | + |
| 20 | +这个应该不用多讲,Go 使用大小写控制可访问性(包外),大写代表导出(public),小写代表私有(private)。 |
| 21 | + |
| 22 | +方法暴露,使用 `receiver` ,类型、变量、方法的可见性规则都是用大小写。 |
| 23 | + |
| 24 | +```go |
| 25 | +type Person struct { |
| 26 | + Name string // 大写,导出,包外可见,public |
| 27 | + age int // 小写,私有,仅包内可见,private |
| 28 | +} |
| 29 | + |
| 30 | +// Speak 暴露公有方法 |
| 31 | +func (p *Person) Speak() { |
| 32 | + fmt.Println("Hello, my name is", p.Name) |
| 33 | +} |
| 34 | + |
| 35 | +// SetAge GetAge 方法略 |
| 36 | +``` |
| 37 | + |
| 38 | +为类增加方法,Go 与 Java 最大的不同是,Java 的方法是在类内的,而 Go 在类外,有点像 struct 上贴了一个个狗皮膏药的感觉。 |
| 39 | +起初可能会不习惯,但这让方法的添加变得更灵活,receiver 的设计更使得所有类都能拥有方法。 |
| 40 | + |
| 41 | +Go 的 receiver 的设计更符合底层的面向对象的思想:为某一类事物,附加一些行为。 |
| 42 | + |
| 43 | +Go 的任何非内置的「定义类型」,都能拥有方法;而 Java,只有类才能拥有方法,或者说,想在一个东西上施加操作,必须定义一个类,显得臃肿。 |
| 44 | + |
| 45 | +## 继承 |
| 46 | + |
| 47 | +我对继承的理解是,子类拥有父类的所有属性和方法。 |
| 48 | + |
| 49 | +Go 的继承使用类似组合的方式实现,与标准面向对象实现最大的区别在于,基类变量不能引用子类变量。 |
| 50 | + |
| 51 | +```go |
| 52 | +type Man struct { |
| 53 | + Person // 内嵌,继承 |
| 54 | + otherField string // 组合 |
| 55 | +} |
| 56 | + |
| 57 | +man := Man{"bird"} |
| 58 | +_ = man.Person.Name |
| 59 | +_ = man.Name // 省略 Person 匿名字段 |
| 60 | +man.Speak() |
| 61 | +``` |
| 62 | + |
| 63 | +如上,在 Man 中加入一个只有类型没有名字的 Person 属性,就是内嵌。Go 的一大特性就是,内嵌字段可以被省略。详见代码。 |
| 64 | + |
| 65 | +所以,Man 可以省略 Person,**直接访问 Person 的所有属性并调用其方法**。所以看起来,就像继承一样。 |
| 66 | + |
| 67 | +总结一下就是,结构体内嵌匿名变量就是继承,无名就是组合。 |
| 68 | + |
| 69 | +和 Java 的区别在于, `var p person = Man{}` 是不可行的。父类变量无法引用子类对象(但这并不意味着 Go 没有多态)。 |
| 70 | +所以 Go 的继承是不满足「里氏替换」原则的。 |
| 71 | + |
| 72 | +我觉得这个特性挺好,逼迫开发者少使用继承,多面向抽象编程。 |
| 73 | + |
| 74 | +如何实现对方法的重写(Override),即子类覆盖父类的同签名方法,以实现不同表现? |
| 75 | + |
| 76 | +稍微岔开一下,Go 没有「重载」,即同函数名,函数签名却不同。所以这个问题其实是,Go 如何覆盖父类同名方法? |
| 77 | + |
| 78 | +这时候就体现 Go 的设计哲学了, less is more。你都不需要知道什么是重写: |
| 79 | + |
| 80 | +没有那么多的术语和要记的东西,也不需要新的关键字,一切都是自然而然: |
| 81 | +子类想用父类的,直接继承了不用管;子类想拥有不同的表现形式(行为),那就自己定义一个。 |
| 82 | + |
| 83 | +怎么自己定义一个方法呢,很简单,定义一个以子类作为 receiver 的方法。正如前面的继承,没有新增任何关键字。 |
| 84 | + |
| 85 | +简单理一下逻辑,如果调用方法的时候,子类本身拥有该方法,那就直接调用;如果没有,就看看父类有没有,一层层找上去。 |
| 86 | + |
| 87 | +如何实现多继承?想继承谁,内嵌什么类型就行。 |
| 88 | + |
| 89 | +至此,Go 非常优雅且简单地实现了继承。 |
| 90 | + |
| 91 | +这里还有个面向对象的原则,「使用组合而不是继承」,通俗继承的坏处很多,比如父类改了子类就会被动跟着改动。 |
| 92 | +所以 Go 使用了组合的方式来实现了继承,是不是天生规避了一些继承的缺点? |
| 93 | +以及继承会暴露父类的实现细节,这个问题 Go 也存在。 |
| 94 | + |
| 95 | +## 多态 |
| 96 | + |
| 97 | +Go 的多态是用 interface 实现的, interface 是 Go 语言的灵魂之一,为静态的 Go 语言增添了动态性。 |
| 98 | + |
| 99 | +我理解的接口,是一种「约定」,接口的方法,约定了一系列操作,某样东西能完成这个操作,它就实现了这个接口。 |
| 100 | + |
| 101 | +下面这段代码演示了许多面向对象的内容,其中,末尾的 `AllSpeak` 函数是多态的展示。 |
| 102 | + |
| 103 | +```go |
| 104 | +type Speaker interface { |
| 105 | + Speak() |
| 106 | +} |
| 107 | +type Person struct { |
| 108 | + Name string |
| 109 | +} |
| 110 | +func (p Person) Speak() { |
| 111 | + fmt.Println("Hello, my name is", p.Name) |
| 112 | +} |
| 113 | +type Dog struct { |
| 114 | + Name string |
| 115 | +} |
| 116 | + |
| 117 | +type SingleDog struct { |
| 118 | + Dog |
| 119 | +} |
| 120 | + |
| 121 | +func (d Dog) Speak() { |
| 122 | + fmt.Println("Wang Wang, my name is", d.Name) |
| 123 | +} |
| 124 | +func TestInf(t *testing.T) { |
| 125 | + |
| 126 | + var s Speaker |
| 127 | + p := Person{"Tom"} |
| 128 | + s = p // 接口变量能指向实现了该接口的对象 |
| 129 | + s.Speak() |
| 130 | + |
| 131 | + person := Person{"Tom"} |
| 132 | + dog := Dog{"Jerry"} |
| 133 | + singleDog := SingleDog{Dog{Name: "SingleDog"}} |
| 134 | + speakers := []Speaker{person, dog, singleDog} |
| 135 | + AllSpeak(speakers) |
| 136 | +} |
| 137 | + |
| 138 | +// AllSpeak 多态演示 |
| 139 | +func AllSpeak(s []Speaker) { |
| 140 | + for _, v := range s { |
| 141 | + v.Speak() |
| 142 | + } |
| 143 | +``` |
| 144 | + |
| 145 | +Go 语言的接口是非常灵活的,不需要显示声明,Java 需要 `implements`,而 Go 是 DuckType。 |
| 146 | + |
| 147 | +什么是 DuckType?鸭子会嘎嘎叫,所以会嘎嘎叫的就能当成鸭子。 |
| 148 | + |
| 149 | +翻译成编程语言,接口是一个约定,遵循了这个约定,就实现了接口。这个遵循,只要某个类型的方法签名是某接口定义的方法签名的超集就行。 |
| 150 | + |
| 151 | +所以,一定记住,在编码层面,方法先于接口,即先有了方法,才有了是否实现该接口的判断。而不是 Java 的先明确实现什么接口,挖好坑,再去填。完全相反。 |
| 152 | + |
| 153 | +如果实现多态?接口类型的变量,可以引用实现了该接口的任何类型的变量。 |
| 154 | + |
| 155 | +在 `AllSpeak` 函数中,形参是 `Speaker` 接口的切片,实参却是 Person、Dog、SingleDog 类型。 |
| 156 | +为什么能调用成功呢?因为 `Person` 和 `Dog` 都拥有 `Speak()` 方法,满足了 `Speaker` 接口定义的所有函数的签名。 |
| 157 | + |
| 158 | +那为什么 SingleDog 类型也能传参成功,是不是可以理解为,SingleDog 继承了 Dog 的接口?似乎变得复杂起来了。 |
| 159 | + |
| 160 | +Go 没有那么多术语。紧抓两点:1编码时,先看方法,再看接口 2某类型拥有的方法是某接口方法的超集,就实现了该接口 |
| 161 | + |
| 162 | +所以,是 SingleDog 先「继承」了Dog的 `Speak()` 方法,而后该方法刚好又满足了 `Speaker()` 的约定,自然就实现了该接口。 |
| 163 | + |
| 164 | +许多语言的接口与继承,对于开发者而言其实是迷惑的,可能经常分不清到底该用哪个,而 Go 不会。 |
| 165 | +许多面向对象语言的继承,是被滥用的,而 Go,压根没开这个门,你只能用接口。 |
| 166 | +它以一种非常灵活的方式,实现了多态,并且接口的设计,对「依赖反转(面向抽象编程)」天然友好。 |
| 167 | + |
| 168 | +interface 真的是个好东西,当你不知道怎么设计更优雅的时候,它总能给你带来惊喜。 |
| 169 | + |
| 170 | + |
0 commit comments