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 intfunc(a MyInt)Add(b int) MyInt {return a + MyInt(b)
}
2. 繼承
就像前面說的,Go設計者在Go誕生伊始就重新評估了對經典OO的語法概念的支持,最終放棄了對諸如類、對象以及類繼承層次體系的支持。也就是說:在Go中體現封裝概念的類型之間都是“路人”,沒有親爹和兒子的關系的“牽絆”。
談到OO中的繼承,大家更多想到的是子類繼承了父類的屬性與方法實現。Go雖然沒有像Java extends關鍵字那樣的顯式繼承語法,但Go也另辟蹊徑地對“繼承”提供了支持。這種支持方式就是類型嵌入(type embedding),看一個例子:
package mainimport "fmt"type P struct {A intb string
}func (P) M1() {fmt.Println("P M1")
}func (P) M2() {fmt.Println("P M2")
}type Q struct {c [5]intD 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 {PQE int
}// M2 重寫方法:在 T 中重寫 M2 方法,明確調用哪個嵌入結構體的 M2。
func (t T) M2() {t.P.M2() // 或者 t.Q.M2()
}func main() {var t Tt.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 {ReaderWriter
}
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 Pvar q Qvar i MyInterface = pi.M1() // P.M1i.M2() // P.M2i.M3() // P.M3i = qi.M1() // Q.M1i.M2() // Q.M2i.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的思維方式是:“優先接口、優先組合”。