Go 語言 函數(shù)、方法和接口

2023-03-22 14:56 更新

原文鏈接:https://chai2010.cn/advanced-go-programming-book/ch1-basic/ch1-04-func-method-interface.html


1.4 函數(shù)、方法和接口

函數(shù)對應操作序列,是程序的基本組成元素。Go 語言中的函數(shù)有具名和匿名之分:具名函數(shù)一般對應于包級的函數(shù),是匿名函數(shù)的一種特例,當匿名函數(shù)引用了外部作用域中的變量時就成了閉包函數(shù),閉包函數(shù)是函數(shù)式編程語言的核心。方法是綁定到一個具體類型的特殊函數(shù),Go 語言中的方法是依托于類型的,必須在編譯時靜態(tài)綁定。接口定義了方法的集合,這些方法依托于運行時的接口對象,因此接口對應的方法是在運行時動態(tài)綁定的。Go 語言通過隱式接口機制實現(xiàn)了鴨子面向?qū)ο竽P汀?

Go 語言程序的初始化和執(zhí)行總是從 main.main 函數(shù)開始的。但是如果 main 包導入了其它的包,則會按照順序?qū)⑺鼈儼M main 包里(這里的導入順序依賴具體實現(xiàn),一般可能是以文件名或包路徑名的字符串順序?qū)耄?。如果某個包被多次導入的話,在執(zhí)行的時候只會導入一次。當一個包被導入時,如果它還導入了其它的包,則先將其它的包包含進來,然后創(chuàng)建和初始化這個包的常量和變量,再調(diào)用包里的 init 函數(shù),如果一個包有多個 init 函數(shù)的話,調(diào)用順序未定義(實現(xiàn)可能是以文件名的順序調(diào)用),同一個文件內(nèi)的多個 init 則是以出現(xiàn)的順序依次調(diào)用(init 不是普通函數(shù),可以定義有多個,所以也不能被其它函數(shù)調(diào)用)。最后,當 main 包的所有包級常量、變量被創(chuàng)建和初始化完成,并且 init 函數(shù)被執(zhí)行后,才會進入 main.main 函數(shù),程序開始正常執(zhí)行。下圖是 Go 程序函數(shù)啟動順序的示意圖:


圖 1-11 包初始化流程

要注意的是,在 main.main 函數(shù)執(zhí)行之前所有代碼都運行在同一個 Goroutine 中,也是運行在程序的主系統(tǒng)線程中。如果某個 init 函數(shù)內(nèi)部用 go 關(guān)鍵字啟動了新的 Goroutine 的話,新的 Goroutine 和 main.main 函數(shù)是并發(fā)執(zhí)行的。

1.4.1 函數(shù)

在 Go 語言中,函數(shù)是第一類對象,我們可以將函數(shù)保持到變量中。函數(shù)主要有具名和匿名之分,包級函數(shù)一般都是具名函數(shù),具名函數(shù)是匿名函數(shù)的一種特例。當然,Go 語言中每個類型還可以有自己的方法,方法其實也是函數(shù)的一種。

// 具名函數(shù)
func Add(a, b int) int {
    return a+b
}

// 匿名函數(shù)
var Add = func(a, b int) int {
    return a+b
}

Go 語言中的函數(shù)可以有多個參數(shù)和多個返回值,參數(shù)和返回值都是以傳值的方式和被調(diào)用者交換數(shù)據(jù)。在語法上,函數(shù)還支持可變數(shù)量的參數(shù),可變數(shù)量的參數(shù)必須是最后出現(xiàn)的參數(shù),可變數(shù)量的參數(shù)其實是一個切片類型的參數(shù)。

// 多個參數(shù)和多個返回值
func Swap(a, b int) (int, int) {
    return b, a
}

// 可變數(shù)量的參數(shù)
// more 對應 []int 切片類型
func Sum(a int, more ...int) int {
    for _, v := range more {
        a += v
    }
    return a
}

當可變參數(shù)是一個空接口類型時,調(diào)用者是否解包可變參數(shù)會導致不同的結(jié)果:

func main() {
    var a = []interface{}{123, "abc"}

    Print(a...) // 123 abc
    Print(a)    // [123 abc]
}

func Print(a ...interface{}) {
    fmt.Println(a...)
}

第一個 Print 調(diào)用時傳入的參數(shù)是 a...,等價于直接調(diào)用 Print(123, "abc")。第二個 Print 調(diào)用傳入的是未解包的 a,等價于直接調(diào)用 Print([]interface{}{123, "abc"})

不僅函數(shù)的參數(shù)可以有名字,也可以給函數(shù)的返回值命名:

func Find(m map[int]int, key int) (value int, ok bool) {
    value, ok = m[key]
    return
}

如果返回值命名了,可以通過名字來修改返回值,也可以通過 defer 語句在 return 語句之后修改返回值:

func Inc() (v int) {
    defer func(){ v++ } ()
    return 42
}

其中 defer 語句延遲執(zhí)行了一個匿名函數(shù),因為這個匿名函數(shù)捕獲了外部函數(shù)的局部變量 v,這種函數(shù)我們一般叫閉包。閉包對捕獲的外部變量并不是傳值方式訪問,而是以引用的方式訪問。

閉包的這種引用方式訪問外部變量的行為可能會導致一些隱含的問題:

func main() {
    for i := 0; i < 3; i++ {
        defer func(){ println(i) } ()
    }
}
// Output:
// 3
// 3
// 3

因為是閉包,在 for 迭代語句中,每個 defer 語句延遲執(zhí)行的函數(shù)引用的都是同一個 i 迭代變量,在循環(huán)結(jié)束后這個變量的值為 3,因此最終輸出的都是3。

修復的思路是在每輪迭代中為每個 defer 函數(shù)生成獨有的變量??梢杂孟旅鎯煞N方式:

func main() {
    for i := 0; i < 3; i++ {
        i := i // 定義一個循環(huán)體內(nèi)局部變量 i
        defer func(){ println(i) } ()
    }
}

func main() {
    for i := 0; i < 3; i++ {
        // 通過函數(shù)傳入 i
        // defer 語句會馬上對調(diào)用參數(shù)求值
        defer func(i int){ println(i) } (i)
    }
}

第一種方法是在循環(huán)體內(nèi)部再定義一個局部變量,這樣每次迭代 defer 語句的閉包函數(shù)捕獲的都是不同的變量,這些變量的值對應迭代時的值。第二種方式是將迭代變量通過閉包函數(shù)的參數(shù)傳入,defer 語句會馬上對調(diào)用參數(shù)求值。兩種方式都是可以工作的。不過一般來說,在 for 循環(huán)內(nèi)部執(zhí)行 defer 語句并不是一個好的習慣,此處僅為示例,不建議使用。

Go 語言中,如果以切片為參數(shù)調(diào)用函數(shù)時,有時候會給人一種參數(shù)采用了傳引用的方式的假象:因為在被調(diào)用函數(shù)內(nèi)部可以修改傳入的切片的元素。其實,任何可以通過函數(shù)參數(shù)修改調(diào)用參數(shù)的情形,都是因為函數(shù)參數(shù)中顯式或隱式傳入了指針參數(shù)。函數(shù)參數(shù)傳值的規(guī)范更準確說是只針對數(shù)據(jù)結(jié)構(gòu)中固定的部分傳值,例如字符串或切片對應結(jié)構(gòu)體中的指針和字符串長度結(jié)構(gòu)體傳值,但是并不包含指針間接指向的內(nèi)容。將切片類型的參數(shù)替換為類似 reflect.SliceHeader 結(jié)構(gòu)體就很好理解切片傳值的含義了:

func twice(x []int) {
    for i := range x {
        x[i] *= 2
    }
}

type IntSliceHeader struct {
    Data []int
    Len  int
    Cap  int
}

func twice(x IntSliceHeader) {
    for i := 0; i < x.Len; i++ {
        x.Data[i] *= 2
    }
}

因為切片中的底層數(shù)組部分是通過隱式指針傳遞(指針本身依然是傳值的,但是指針指向的卻是同一份的數(shù)據(jù)),所以被調(diào)用函數(shù)是可以通過指針修改掉調(diào)用參數(shù)切片中的數(shù)據(jù)。除了數(shù)據(jù)之外,切片結(jié)構(gòu)還包含了切片長度和切片容量信息,這2個信息也是傳值的。如果被調(diào)用函數(shù)中修改了 Len 或 Cap 信息的話,就無法反映到調(diào)用參數(shù)的切片中,這時候我們一般會通過返回修改后的切片來更新之前的切片。這也是為何內(nèi)置的 append 必須要返回一個切片的原因。

Go語言中,函數(shù)還可以直接或間接地調(diào)用自己,也就是支持遞歸調(diào)用。Go 語言函數(shù)的遞歸調(diào)用深度邏輯上沒有限制,函數(shù)調(diào)用的棧是不會出現(xiàn)溢出錯誤的,因為 Go 語言運行時會根據(jù)需要動態(tài)地調(diào)整函數(shù)棧的大小。每個 goroutine 剛啟動時只會分配很小的棧(4 或 8KB,具體依賴實現(xiàn)),根據(jù)需要動態(tài)調(diào)整棧的大小,棧最大可以達到 GB 級(依賴具體實現(xiàn),在目前的實現(xiàn)中,32 位體系結(jié)構(gòu)為 250MB,64 位體系結(jié)構(gòu)為 1GB)。在 Go1.4 以前,Go 的動態(tài)棧采用的是分段式的動態(tài)棧,通俗地說就是采用一個鏈表來實現(xiàn)動態(tài)棧,每個鏈表的節(jié)點內(nèi)存位置不會發(fā)生變化。但是鏈表實現(xiàn)的動態(tài)棧對某些導致跨越鏈表不同節(jié)點的熱點調(diào)用的性能影響較大,因為相鄰的鏈表節(jié)點它們在內(nèi)存位置一般不是相鄰的,這會增加 CPU 高速緩存命中失敗的幾率。為了解決熱點調(diào)用的 CPU 緩存命中率問題,Go1.4 之后改用連續(xù)的動態(tài)棧實現(xiàn),也就是采用一個類似動態(tài)數(shù)組的結(jié)構(gòu)來表示棧。不過連續(xù)動態(tài)棧也帶來了新的問題:當連續(xù)棧動態(tài)增長時,需要將之前的數(shù)據(jù)移動到新的內(nèi)存空間,這會導致之前棧中全部變量的地址發(fā)生變化。雖然 Go 語言運行時會自動更新引用了地址變化的棧變量的指針,但最重要的一點是要明白 Go 語言中指針不再是固定不變的了(因此不能隨意將指針保持到數(shù)值變量中,Go 語言的地址也不能隨意保存到不在 GC 控制的環(huán)境中,因此使用 CGO 時不能在 C 語言中長期持有 Go 語言對象的地址)。

因為,Go 語言函數(shù)的棧會自動調(diào)整大小,所以普通 Go 程序員已經(jīng)很少需要關(guān)心棧的運行機制的。在 Go 語言規(guī)范中甚至故意沒有講到棧和堆的概念。我們無法知道函數(shù)參數(shù)或局部變量到底是保存在棧中還是堆中,我們只需要知道它們能夠正常工作就可以了??纯聪旅孢@個例子:

func f(x int) *int {
    return &x
}

func g() int {
    x := new(int)
    return *x
}

第一個函數(shù)直接返回了函數(shù)參數(shù)變量的地址——這似乎是不可以的,因為如果參數(shù)變量在棧上的話,函數(shù)返回之后棧變量就失效了,返回的地址自然也應該失效了。但是 Go 語言的編譯器和運行時比我們聰明的多,它會保證指針指向的變量在合適的地方。第二個函數(shù),內(nèi)部雖然調(diào)用 new 函數(shù)創(chuàng)建了 *int 類型的指針對象,但是依然不知道它具體保存在哪里。對于有 C/C++ 編程經(jīng)驗的程序員需要強調(diào)的是:不用關(guān)心 Go 語言中函數(shù)棧和堆的問題,編譯器和運行時會幫我們搞定;同樣不要假設(shè)變量在內(nèi)存中的位置是固定不變的,指針隨時可能會變化,特別是在你不期望它變化的時候。

1.4.2 方法

方法一般是面向?qū)ο缶幊?OOP)的一個特性,在 C++ 語言中方法對應一個類對象的成員函數(shù),是關(guān)聯(lián)到具體對象上的虛表中的。但是 Go 語言的方法卻是關(guān)聯(lián)到類型的,這樣可以在編譯階段完成方法的靜態(tài)綁定。一個面向?qū)ο蟮某绦驎梅椒▉肀磉_其屬性對應的操作,這樣使用這個對象的用戶就不需要直接去操作對象,而是借助方法來做這些事情。面向?qū)ο缶幊踢M入主流開發(fā)領(lǐng)域一般認為是從 C++ 開始的,C++ 就是在兼容 C 語言的基礎(chǔ)之上支持了 class 等面向?qū)ο蟮奶匦?。然?Java 編程則號稱是純粹的面向?qū)ο笳Z言,因為 Java 中函數(shù)是不能獨立存在的,每個函數(shù)都必然是屬于某個類的。

面向?qū)ο缶幊谈嗟闹皇且环N思想,很多號稱支持面向?qū)ο缶幊痰恼Z言只是將經(jīng)常用到的特性內(nèi)置到語言中了而已。Go 語言的祖先 C 語言雖然不是一個支持面向?qū)ο蟮恼Z言,但是 C 語言的標準庫中的 File 相關(guān)的函數(shù)也用到了的面向?qū)ο缶幊痰乃枷?。下面我們實現(xiàn)一組 C 語言風格的 File 函數(shù):

// 文件對象
type File struct {
    fd int
}

// 打開文件
func OpenFile(name string) (f *File, err error) {
    // ...
}

// 關(guān)閉文件
func CloseFile(f *File) error {
    // ...
}

// 讀文件數(shù)據(jù)
func ReadFile(f *File, offset int64, data []byte) int {
    // ...
}

其中 OpenFile 類似構(gòu)造函數(shù)用于打開文件對象,CloseFile 類似析構(gòu)函數(shù)用于關(guān)閉文件對象,ReadFile 則類似普通的成員函數(shù),這三個函數(shù)都是普通的函數(shù)。CloseFile 和 ReadFile 作為普通函數(shù),需要占用包級空間中的名字資源。不過 CloseFile 和 ReadFile 函數(shù)只是針對 File 類型對象的操作,這時候我們更希望這類函數(shù)和操作對象的類型緊密綁定在一起。

Go 語言中的做法是,將 CloseFile 和 ReadFile 函數(shù)的第一個參數(shù)移動到函數(shù)名的開頭:

// 關(guān)閉文件
func (f *File) CloseFile() error {
    // ...
}

// 讀文件數(shù)據(jù)
func (f *File) ReadFile(offset int64, data []byte) int {
    // ...
}

這樣的話,CloseFile 和 ReadFile 函數(shù)就成了 File 類型獨有的方法了(而不是 File 對象方法)。它們也不再占用包級空間中的名字資源,同時 File 類型已經(jīng)明確了它們操作對象,因此方法名字一般簡化為 Close 和 Read

// 關(guān)閉文件
func (f *File) Close() error {
    // ...
}

// 讀文件數(shù)據(jù)
func (f *File) Read(offset int64, data []byte) int {
    // ...
}

將第一個函數(shù)參數(shù)移動到函數(shù)前面,從代碼角度看雖然只是一個小的改動,但是從編程哲學角度來看,Go 語言已經(jīng)是進入面向?qū)ο笳Z言的行列了。我們可以給任何自定義類型添加一個或多個方法。每種類型對應的方法必須和類型的定義在同一個包中,因此是無法給 int 這類內(nèi)置類型添加方法的(因為方法的定義和類型的定義不在一個包中)。對于給定的類型,每個方法的名字必須是唯一的,同時方法和函數(shù)一樣也不支持重載。

方法是由函數(shù)演變而來,只是將函數(shù)的第一個對象參數(shù)移動到了函數(shù)名前面了而已。因此我們依然可以按照原始的過程式思維來使用方法。通過叫方法表達式的特性可以將方法還原為普通類型的函數(shù):

// 不依賴具體的文件對象
// func CloseFile(f *File) error
var CloseFile = (*File).Close

// 不依賴具體的文件對象
// func ReadFile(f *File, offset int64, data []byte) int
var ReadFile = (*File).Read

// 文件處理
f, _ := OpenFile("foo.dat")
ReadFile(f, 0, data)
CloseFile(f)

在有些場景更關(guān)心一組相似的操作:比如 Read 讀取一些數(shù)組,然后調(diào)用 Close 關(guān)閉。此時的環(huán)境中,用戶并不關(guān)心操作對象的類型,只要能滿足通用的 Read 和 Close 行為就可以了。不過在方法表達式中,因為得到的 ReadFile 和 CloseFile 函數(shù)參數(shù)中含有 File 這個特有的類型參數(shù),這使得 File 相關(guān)的方法無法和其它不是 File 類型但是有著相同 Read 和 Close 方法的對象無縫適配。這種小困難難不倒我們 Go 語言碼農(nóng),我們可以通過結(jié)合閉包特性來消除方法表達式中第一個參數(shù)類型的差異:

// 先打開文件對象
f, _ := OpenFile("foo.dat")

// 綁定到了 f 對象
// func Close() error
var Close = func() error {
    return (*File).Close(f)
}

// 綁定到了 f 對象
// func Read(offset int64, data []byte) int
var Read = func(offset int64, data []byte) int {
    return (*File).Read(f, offset, data)
}

// 文件處理
Read(0, data)
Close()

這剛好是方法值也要解決的問題。我們用方法值特性可以簡化實現(xiàn):

// 先打開文件對象
f, _ := OpenFile("foo.dat")

// 方法值: 綁定到了 f 對象
// func Close() error
var Close = f.Close

// 方法值: 綁定到了 f 對象
// func Read(offset int64, data []byte) int
var Read = f.Read

// 文件處理
Read(0, data)
Close()

Go語言不支持傳統(tǒng)面向?qū)ο笾械睦^承特性,而是以自己特有的組合方式支持了方法的繼承。Go 語言中,通過在結(jié)構(gòu)體內(nèi)置匿名的成員來實現(xiàn)繼承:

import "image/color"

type Point struct{ X, Y float64 }

type ColoredPoint struct {
    Point
    Color color.RGBA
}

雖然我們可以將 ColoredPoint 定義為一個有三個字段的扁平結(jié)構(gòu)的結(jié)構(gòu)體,但是我們這里將 Point 嵌入到 ColoredPoint 來提供 X 和 Y 這兩個字段。

var cp ColoredPoint
cp.X = 1
fmt.Println(cp.Point.X) // "1"
cp.Point.Y = 2
fmt.Println(cp.Y)       // "2"

通過嵌入匿名的成員,我們不僅可以繼承匿名成員的內(nèi)部成員,而且可以繼承匿名成員類型所對應的方法。我們一般會將 Point 看作基類,把 ColoredPoint 看作是它的繼承類或子類。不過這種方式繼承的方法并不能實現(xiàn) C++ 中虛函數(shù)的多態(tài)特性。所有繼承來的方法的接收者參數(shù)依然是那個匿名成員本身,而不是當前的變量。

type Cache struct {
    m map[string]string
    sync.Mutex
}

func (p *Cache) Lookup(key string) string {
    p.Lock()
    defer p.Unlock()

    return p.m[key]
}

Cache結(jié)構(gòu)體類型通過嵌入一個匿名的 sync.Mutex 來繼承它的 Lock 和 Unlock 方法. 但是在調(diào)用 p.Lock() 和 p.Unlock() 時, p 并不是 Lock 和 Unlock 方法的真正接收者, 而是會將它們展開為 p.Mutex.Lock() 和 p.Mutex.Unlock() 調(diào)用. 這種展開是編譯期完成的, 并沒有運行時代價.

在傳統(tǒng)的面向?qū)ο笳Z言(eg.C++ 或 Java)的繼承中,子類的方法是在運行時動態(tài)綁定到對象的,因此基類實現(xiàn)的某些方法看到的 this 可能不是基類類型對應的對象,這個特性會導致基類方法運行的不確定性。而在 Go 語言通過嵌入匿名的成員來“繼承”的基類方法,this 就是實現(xiàn)該方法的類型的對象,Go 語言中方法是編譯時靜態(tài)綁定的。如果需要虛函數(shù)的多態(tài)特性,我們需要借助 Go 語言接口來實現(xiàn)。

1.4.3 接口

Go 語言之父 Rob Pike 曾說過一句名言:那些試圖避免白癡行為的語言最終自己變成了白癡語言(Languages that try to disallow idiocy become themselves idiotic)。一般靜態(tài)編程語言都有著嚴格的類型系統(tǒng),這使得編譯器可以深入檢查程序員有沒有作出什么出格的舉動。但是,過于嚴格的類型系統(tǒng)卻會使得編程太過繁瑣,讓程序員把大好的青春都浪費在了和編譯器的斗爭中。Go 語言試圖讓程序員能在安全和靈活的編程之間取得一個平衡。它在提供嚴格的類型檢查的同時,通過接口類型實現(xiàn)了對鴨子類型的支持,使得安全動態(tài)的編程變得相對容易。

Go 的接口類型是對其它類型行為的抽象和概括;因為接口類型不會和特定的實現(xiàn)細節(jié)綁定在一起,通過這種抽象的方式我們可以讓對象更加靈活和更具有適應能力。很多面向?qū)ο蟮恼Z言都有相似的接口概念,但 Go 語言中接口類型的獨特之處在于它是滿足隱式實現(xiàn)的鴨子類型。所謂鴨子類型說的是:只要走起路來像鴨子、叫起來也像鴨子,那么就可以把它當作鴨子。Go 語言中的面向?qū)ο缶褪侨绱?,如果一個對象只要看起來像是某種接口類型的實現(xiàn),那么它就可以作為該接口類型使用。這種設(shè)計可以讓你創(chuàng)建一個新的接口類型滿足已經(jīng)存在的具體類型卻不用去破壞這些類型原有的定義;當我們使用的類型來自于不受我們控制的包時這種設(shè)計尤其靈活有用。Go 語言的接口類型是延遲綁定,可以實現(xiàn)類似虛函數(shù)的多態(tài)功能。

接口在 Go 語言中無處不在,在“Hello world”的例子中,fmt.Printf 函數(shù)的設(shè)計就是完全基于接口的,它的真正功能由 fmt.Fprintf 函數(shù)完成。用于表示錯誤的 error 類型更是內(nèi)置的接口類型。在 C 語言中,printf 只能將幾種有限的基礎(chǔ)數(shù)據(jù)類型打印到文件對象中。但是 Go 語言靈活接口特性,fmt.Fprintf 卻可以向任何自定義的輸出流對象打印,可以打印到文件或標準輸出、也可以打印到網(wǎng)絡、甚至可以打印到一個壓縮文件;同時,打印的數(shù)據(jù)也不僅僅局限于語言內(nèi)置的基礎(chǔ)類型,任意隱式滿足 fmt.Stringer 接口的對象都可以打印,不滿足 fmt.Stringer 接口的依然可以通過反射的技術(shù)打印。fmt.Fprintf 函數(shù)的簽名如下:

func Fprintf(w io.Writer, format string, args ...interface{}) (int, error)

其中 io.Writer 用于輸出的接口,error 是內(nèi)置的錯誤接口,它們的定義如下:

type io.Writer interface {
    Write(p []byte) (n int, err error)
}

type error interface {
    Error() string
}

我們可以通過定制自己的輸出對象,將每個字符轉(zhuǎn)為大寫字符后輸出:

type UpperWriter struct {
    io.Writer
}

func (p *UpperWriter) Write(data []byte) (n int, err error) {
    return p.Writer.Write(bytes.ToUpper(data))
}

func main() {
    fmt.Fprintln(&UpperWriter{os.Stdout}, "hello, world")
}

當然,我們也可以定義自己的打印格式來實現(xiàn)將每個字符轉(zhuǎn)為大寫字符后輸出的效果。對于每個要打印的對象,如果滿足了 fmt.Stringer 接口,則默認使用對象的 String 方法返回的結(jié)果打?。?

type UpperString string

func (s UpperString) String() string {
    return strings.ToUpper(string(s))
}

type fmt.Stringer interface {
    String() string
}

func main() {
    fmt.Fprintln(os.Stdout, UpperString("hello, world"))
}

Go 語言中,對于基礎(chǔ)類型(非接口類型)不支持隱式的轉(zhuǎn)換,我們無法將一個 int 類型的值直接賦值給 int64 類型的變量,也無法將 int 類型的值賦值給底層是 int 類型的新定義命名類型的變量。Go 語言對基礎(chǔ)類型的類型一致性要求可謂是非常的嚴格,但是 Go 語言對于接口類型的轉(zhuǎn)換則非常的靈活。對象和接口之間的轉(zhuǎn)換、接口和接口之間的轉(zhuǎn)換都可能是隱式的轉(zhuǎn)換。可以看下面的例子:

var (
    a io.ReadCloser = (*os.File)(f) // 隱式轉(zhuǎn)換, *os.File 滿足 io.ReadCloser 接口
    b io.Reader     = a             // 隱式轉(zhuǎn)換, io.ReadCloser 滿足 io.Reader 接口
    c io.Closer     = a             // 隱式轉(zhuǎn)換, io.ReadCloser 滿足 io.Closer 接口
    d io.Reader     = c.(io.Reader) // 顯式轉(zhuǎn)換, io.Closer 不滿足 io.Reader 接口
)

有時候?qū)ο蠛徒涌谥g太靈活了,導致我們需要人為地限制這種無意之間的適配。常見的做法是定義一個含特殊方法來區(qū)分接口。比如 runtime 包中的 Error 接口就定義了一個特有的 RuntimeError 方法,用于避免其它類型無意中適配了該接口:

type runtime.Error interface {
    error

    // RuntimeError is a no-op function but
    // serves to distinguish types that are run time
    // errors from ordinary errors: a type is a
    // run time error if it has a RuntimeError method.
    RuntimeError()
}

在 protobuf 中,Message 接口也采用了類似的方法,也定義了一個特有的 ProtoMessage,用于避免其它類型無意中適配了該接口:

type proto.Message interface {
    Reset()
    String() string
    ProtoMessage()
}

不過這種做法只是君子協(xié)定,如果有人刻意偽造一個 proto.Message 接口也是很容易的。再嚴格一點的做法是給接口定義一個私有方法。只有滿足了這個私有方法的對象才可能滿足這個接口,而私有方法的名字是包含包的絕對路徑名的,因此只能在包內(nèi)部實現(xiàn)這個私有方法才能滿足這個接口。測試包中的 testing.TB 接口就是采用類似的技術(shù):

type testing.TB interface {
    Error(args ...interface{})
    Errorf(format string, args ...interface{})
    ...

    // A private method to prevent users implementing the
    // interface and so future additions to it will not
    // violate Go 1 compatibility.
    private()
}

不過這種通過私有方法禁止外部對象實現(xiàn)接口的做法也是有代價的:首先是這個接口只能包內(nèi)部使用,外部包正常情況下是無法直接創(chuàng)建滿足該接口對象的;其次,這種防護措施也不是絕對的,惡意的用戶依然可以繞過這種保護機制。

在前面的方法一節(jié)中我們講到,通過在結(jié)構(gòu)體中嵌入匿名類型成員,可以繼承匿名類型的方法。其實這個被嵌入的匿名成員不一定是普通類型,也可以是接口類型。我們可以通過嵌入匿名的 testing.TB 接口來偽造私有的 private 方法,因為接口方法是延遲綁定,編譯時 private 方法是否真的存在并不重要。

package main

import (
    "fmt"
    "testing"
)

type TB struct {
    testing.TB
}

func (p *TB) Fatal(args ...interface{}) {
    fmt.Println("TB.Fatal disabled!")
}

func main() {
    var tb testing.TB = new(TB)
    tb.Fatal("Hello, playground")
}

我們在自己的 TB 結(jié)構(gòu)體類型中重新實現(xiàn)了 Fatal 方法,然后通過將對象隱式轉(zhuǎn)換為 testing.TB 接口類型(因為內(nèi)嵌了匿名的 testing.TB 對象,因此是滿足 testing.TB 接口的),然后通過 testing.TB 接口來調(diào)用我們自己的 Fatal 方法。

這種通過嵌入匿名接口或嵌入匿名指針對象來實現(xiàn)繼承的做法其實是一種純虛繼承,我們繼承的只是接口指定的規(guī)范,真正的實現(xiàn)在運行的時候才被注入。比如,我們可以模擬實現(xiàn)一個gRPC的插件:

type grpcPlugin struct {
    *generator.Generator
}

func (p *grpcPlugin) Name() string { return "grpc" }

func (p *grpcPlugin) Init(g *generator.Generator) {
    p.Generator = g
}

func (p *grpcPlugin) GenerateImports(file *generator.FileDescriptor) {
    if len(file.Service) == 0 {
        return
    }

    p.P(`import "google.golang.org/grpc"`)
    // ...
}

構(gòu)造的 grpcPlugin 類型對象必須滿足 generate.Plugin 接口(在"github.com/golang/protobuf/protoc-gen-go/generator"包中):

type Plugin interface {
    // Name identifies the plugin.
    Name() string
    // Init is called once after data structures are built but before
    // code generation begins.
    Init(g *Generator)
    // Generate produces the code generated by the plugin for this file,
    // except for the imports, by calling the generator's methods
    // P, In, and Out.
    Generate(file *FileDescriptor)
    // GenerateImports produces the import declarations for this file.
    // It is called after Generate.
    GenerateImports(file *FileDescriptor)
}

generate.Plugin接口對應的 grpcPlugin 類型的 GenerateImports 方法中使用的 p.P(...) 函數(shù)卻是通過 Init 函數(shù)注入的 generator.Generator 對象實現(xiàn)。這里的 generator.Generator 對應一個具體類型,但是如果 generator.Generator 是接口類型的話我們甚至可以傳入直接的實現(xiàn)。

Go 語言通過幾種簡單特性的組合,就輕易就實現(xiàn)了鴨子面向?qū)ο蠛吞摂M繼承等高級特性,真的是不可思議。



以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號