Go 面向对象,封装、继承、多态
经典OO(Object-oriented 面向对象)的三大特性是封装、继承与多态,这里我们看看Go中是如何对应的。
1. 封装
封装就是把数据以及操作数据的方法“打包”到一个抽象数据类型中,这个类型封装隐藏了实现的细节,所有数据仅能通过导出的方法来访问和操作。这个抽象数据类型的实例被称为对象。经典OO语言,如Java、C++等都是通过类(class)来表达封装的概念,通过类的实例来映射对象的。熟悉Java的童鞋一定记得**《Java编程思想》**一书的第二章的标题:“一切都是对象”。在Java中所有属性、方法都定义在一个个的class中。
Go语言没有class,那么封装的概念又是如何体现的呢?来自OO语言的初学者进入Go世界后,都喜欢“对号入座”,即Go中什么语法元素与class最接近!于是他们找到了struct类型。
Go中的struct类型中提供了对真实世界聚合抽象的能力,struct的定义中可以包含一组字段(field),如果从OO角度来看,你也可以将这些字段视为属性,同时,我们也可以为struct类型定义方法(method),下面例子中我们定义了一个名为Point的struct类型,它拥有一个导出方法Length:
type Point struct {
x, y float64
}
func (p Point) Length() float64 {
return math.Sqrt(p.x * p.x + p.y * p.y)
}
我们看到,从语法形式上来看,与经典OO声明类的方法不同,Go方法声明并不需要放在声明struct类型的大括号中。Length方法与Point类型建立联系的纽带是一个被称为receiver参数的语法元素。
那么,struct是否就是对应经典OO中的类呢? 是,也不是!从数据聚合抽象来看,似乎是这样, struct类型可以拥有多个异构类型的、代表不同抽象能力的字段(比如整数类型int可以用来抽象一个真实世界物体的长度,string类型字段可以用来抽象真实世界物体的名字等)。
但从拥有方法的角度,不仅是struct类型,Go中除了内置类型的所有其他具名类型都可以拥有自己的方法,哪怕是一个底层类型为int的新类型MyInt:
type MyInt int
func(a MyInt)Add(b int) MyInt {
return a + MyInt(b)
}
2. 继承
就像前面说的,Go设计者在Go诞生伊始就重新评估了对经典OO的语法概念的支持,最终放弃了对诸如类、对象以及类继承层次体系的支持。也就是说:在Go中体现封装概念的类型之间都是“路人”,没有亲爹和儿子的关系的“牵绊”。
谈到OO中的继承,大家更多想到的是子类继承了父类的属性与方法实现。Go虽然没有像Java extends关键字那样的显式继承语法,但Go也另辟蹊径地对“继承”提供了支持。这种支持方式就是类型嵌入(type embedding),看一个例子:
package main
import "fmt"
type P struct {
A int
b string
}
func (P) M1() {
fmt.Println("P M1")
}
func (P) M2() {
fmt.Println("P M2")
}
type Q struct {
c [5]int
D float64
}
func (Q) M2() {
fmt.Println("Q M2")
}
func (Q) M3() {
fmt.Println("Q M3")
}
func (Q) M4() {
fmt.Println("Q M3")
}
type T struct {
P
Q
E int
}
// M2 重写方法:在 T 中重写 M2 方法,明确调用哪个嵌入结构体的 M2。
func (t T) M2() {
t.P.M2() // 或者 t.Q.M2()
}
func main() {
var t T
t.M1()
//需要显式调用
t.P.M2()
t.Q.M2()
// 或重写方法
t.M2()
t.M3()
t.M4()
println(t.A, t.D, t.E)
}
我们看到类型T通过嵌入P、Q两个类型,“继承”了P、Q的导出方法(M1~M4)和导出字段(A、D)。
不过实际Go中的这种“继承”机制并非经典OO中的继承,其外围类型(T)与嵌入的类型(P、Q)之间没有任何“亲缘”关系。P、Q的导出字段和导出方法只是被提升为T的字段和方法罢了,其本质是一种组合,是组合中的代理(delegate)模式的一种实现。T只是一个代理(delegate),对外它提供了它可以代理的所有方法,如例子中的M1~M4方法。当外界发起对T的M1方法的调用后,T将该调用委派给它内部的P实例来实际执行M1方法。
以经典OO理论话术去理解就是T与P、Q的关系不是is-a,而是has-a的关系。
组合大于继承
其实这种继承更应该被称为组合。Go 更愿意将模块分成互相独立的小单元,分别处理不同方面的需求,最后以匿名嵌入的方式组合到一起,共同实现对外接口。也就是组合大于继承的思想。
组合没有父子依赖,不会破坏封装。且整体和局部松耦合,可任意增加来实现扩展。各单元持有单一职责,互不关联,自由灵活组合,实现和维护更加简单。
匿名嵌套
匿名嵌套在编译时会根据嵌套类型生成包装方法,包装方法实际是调用嵌套类型的原始方法。
拓:匿名嵌套的多种玩法
- struct 匿名嵌套 struct(上面已经展示过了)
- interface 匿名嵌套 interface
- struct 匿名嵌套 interface
interface 匿名嵌套 interface
接口可嵌入其他匿名接口,相当于将其声明的方法集导入。
当然,注意只有实现了两个接口的全部的方法,才算实现大接口哈。
Go 标准库中经典用法如下:
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}
type ReadWriter interface {
Reader
Writer
}
struct 匿名嵌套 interface
编译器自动为 struct 的方法集加上 interface 的所有方法。
(后面是我猜的)如我们通过 struct.M () 调用 interface 的 M 方法,编译器实际为 struct 生成包装方法 struct.interface.M()
。
注意:struct 中的 interface 要记得赋值哈,不然调用时会显示 interface nil panic。
type I interface {
M()
}
type A struct {
I
}
type B struct {
}
func (B) M() {
print("B")
}
func main() {
var a A = A{I: B{}}
a.M() // B
// 当然 A 也是 I 接口类型
var i I = A{I: B{}}
i.M() // A
}
我们同时验证以下匿名嵌套的同名覆盖问题:
type I interface {
M()
}
type A struct {
I
}
type B struct {
}
// 多加这个:验证匿名方法同名覆盖
func (A) M() {
print("A")
}
func (B) M() {
print("B")
}
func main() {
var a A = A{I: B{}}
a.M() // A
}
Go 标准库中经典用法如下:
context 包中:
type valueCtx struct {
Context // 匿名接口
key, val interface{}
}
// 创建 valueCtx
func WithValue(parent Context, key, val interface{}) Context {
return &valueCtx{parent, key, val}
}
// 实际重写了 Value() 接口,其他父 context 的方法依旧可以调用
func (c *valueCtx) Value(key interface{}) interface{} {
if c.key == key {
return c.val
}
return c.Context.Value(key)
}
3. 多态
经典OO中的多态是尤指运行时多态,指的是调用方法时,会根据调用方法的实际对象的类型来调用不同类型的方法实现。
下面是一个C++中典型多态的例子:
#include <iostream>
class P {
public:
virtual void M() = 0;
};
class C1: public P {
public:
void M();
};
void C1::M() {
std::cout << "c1.M()\n";
}
class C2: public P {
public:
void M();
};
void C2::M() {
std::cout << "c2.M()\n";
}
int main() {
C1 c1;
C2 c2;
P *p = &c1;
p->M(); // c1.M()
p = &c2;
p->M(); // c2.M()
}
这段代码比较清晰,一个父类P和两个子类C1和C2。父类P有一个虚拟成员函数M,两个子类C1和C2分别重写了M成员函数。在main中,我们声明父类P的指针,然后将C1和C2的对象实例分别赋值给p并调用M成员函数,从结果来看,在运行时p实际调用的函数会根据其指向的对象实例的实际类型而分别调用C1和C2的M。
显然,经典OO的多态实现依托的是类型的层次关系。那么对应没有了类型层次体系的Go来说,它又是如何实现多态的呢?Go使用接口来解锁多态!
和经典OO语言相比,Go更强调行为聚合与一致性,而非数据。因此Go提供了对类似duck typing的支持,即基于行为集合的类型适配,但相较于ruby等动态语言,Go的静态类型机制还可以保证应用duck typing时的类型安全。
Go的接口类型本质就是一组方法集合(行为集合),一个类型如果实现了某个接口类型中的所有方法,那么就可以作为动态类型赋值给接口类型。通过该接口类型变量的调用某一方法,实际调用的就是其动态类型的方法实现。看下面例子:
type MyInterface interface {
M1()
M2()
M3()
}
type P struct {
}
func (P) M1() {}
func (P) M2() {}
func (P) M3() {}
type Q int
func (Q) M1() {}
func (Q) M2() {}
func (Q) M3() {}
func main() {
var p P
var q Q
var i MyInterface = p
i.M1() // P.M1
i.M2() // P.M2
i.M3() // P.M3
i = q
i.M1() // Q.M1
i.M2() // Q.M2
i.M3() // Q.M3
}
Go这种无需类型继承层次体系、低耦合方式的多态实现,是不是用起来更轻量、更容易些呢!
Go 通过接口来实现多态。
Go 的接口类型本质就是一组方法集合 (行为集合),一个类型如果实现了某个接口类型中的所有方法,那么就可以作为动态类型赋值给接口类型(注意:定义一个接口变量,该变量本质是个 Struct 类型的变量哦)。
Go 的接口是特别重要的东西,通过学习 Go 接口的底层实现可以学到很多东西,例如动态语言的实现,方法动态派发实现等。
4. Gopher的“OO思维”
到这里,来自经典OO语言阵营的小伙伴们是不是已经找到了当初在入门Go语言时“感觉到别扭”的原因了呢!这种“别扭”就在于Go对于OO支持的方式与经典OO语言的差别:秉持着经典OO思维的小伙伴一上来就要建立的继承层次体系,但Go没有,也不需要。
要转变为正宗的Gopher的OO思维其实也不难,那就是“prefer接口,prefer组合,将习惯了的is-a思维改为has-a思维”。
5. 小结
是时候给出一些结论性的观点了:
- Go支持OO,只是用的不是经典OO的语法和带层次的类型体系;
- Go支持OO,只是用起来需要换种思维;
- 在Go中玩转OO的思维方式是:“优先接口、优先组合”。