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

Go 語言程序的初始化和執(zhí)行總是從 main.main 函數(shù)開始的。但是如果 main 包導(dǎo)入了其它的包,則會按照順序?qū)⑺鼈儼M main 包里(這里的導(dǎo)入順序依賴具體實現(xiàn),一般可能是以文件名或包路徑名的字符串順序?qū)耄?。如果某個包被多次導(dǎo)入的話,在執(zhí)行的時候只會導(dǎo)入一次。當(dāng)一個包被導(dǎo)入時,如果它還導(dǎo)入了其它的包,則先將其它的包包含進來,然后創(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)用)。最后,當(dāng) 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ù)的一種特例。當(dāng)然,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 對應(yīng) []int 切片類型
func Sum(a int, more ...int) int {
    for _, v := range more {
        a += v
    }
    return a
}

當(dāng)可變參數(shù)是一個空接口類型時,調(diào)用者是否解包可變參數(shù)會導(dǎo)致不同的結(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ù)我們一般叫閉包。閉包對捕獲的外部變量并不是傳值方式訪問,而是以引用的方式訪問。

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

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。

修復(fù)的思路是在每輪迭代中為每個 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ù)捕獲的都是不同的變量,這些變量的值對應(yīng)迭代時的值。第二種方式是將迭代變量通過閉包函數(shù)的參數(shù)傳入,defer 語句會馬上對調(diào)用參數(shù)求值。兩種方式都是可以工作的。不過一般來說,在 for 循環(huán)內(nèi)部執(zhí)行 defer 語句并不是一個好的習(xí)慣,此處僅為示例,不建議使用。

Go 語言中,如果以切片為參數(shù)調(diào)用函數(shù)時,有時候會給人一種參數(shù)采用了傳引用的方式的假象:因為在被調(diào)用函數(shù)內(nèi)部可以修改傳入的切片的元素。其實,任何可以通過函數(shù)參數(shù)修改調(diào)用參數(shù)的情形,都是因為函數(shù)參數(shù)中顯式或隱式傳入了指針參數(shù)。函數(shù)參數(shù)傳值的規(guī)范更準(zhǔn)確說是只針對數(shù)據(jù)結(jié)構(gòu)中固定的部分傳值,例如字符串或切片對應(yīng)結(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)棧對某些導(dǎo)致跨越鏈表不同節(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)棧也帶來了新的問題:當(dāng)連續(xù)棧動態(tài)增長時,需要將之前的數(shù)據(jù)移動到新的內(nèi)存空間,這會導(dǎo)致之前棧中全部變量的地址發(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ù)返回之后棧變量就失效了,返回的地址自然也應(yīng)該失效了。但是 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++ 語言中方法對應(yīng)一個類對象的成員函數(shù),是關(guān)聯(lián)到具體對象上的虛表中的。但是 Go 語言的方法卻是關(guān)聯(lián)到類型的,這樣可以在編譯階段完成方法的靜態(tài)綁定。一個面向?qū)ο蟮某绦驎梅椒▉肀磉_其屬性對應(yīng)的操作,這樣使用這個對象的用戶就不需要直接去操作對象,而是借助方法來做這些事情。面向?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 語言的標(biāo)準(zhǔn)庫中的 File 相關(guān)的函數(shù)也用到了的面向?qū)ο缶幊痰乃枷?。下面我們實現(xiàn)一組 C 語言風(fēng)格的 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ù)前面,從代碼角度看雖然只是一個小的改動,但是從編程哲學(xué)角度來看,Go 語言已經(jīng)是進入面向?qū)ο笳Z言的行列了。我們可以給任何自定義類型添加一個或多個方法。每種類型對應(yīng)的方法必須和類型的定義在同一個包中,因此是無法給 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)部成員,而且可以繼承匿名成員類型所對應(yīng)的方法。我們一般會將 Point 看作基類,把 ColoredPoint 看作是它的繼承類或子類。不過這種方式繼承的方法并不能實現(xiàn) C++ 中虛函數(shù)的多態(tài)特性。所有繼承來的方法的接收者參數(shù)依然是那個匿名成員本身,而不是當(dāng)前的變量。

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 可能不是基類類型對應(yīng)的對象,這個特性會導(dǎo)致基類方法運行的不確定性。而在 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)編程語言都有著嚴(yán)格的類型系統(tǒng),這使得編譯器可以深入檢查程序員有沒有作出什么出格的舉動。但是,過于嚴(yán)格的類型系統(tǒng)卻會使得編程太過繁瑣,讓程序員把大好的青春都浪費在了和編譯器的斗爭中。Go 語言試圖讓程序員能在安全和靈活的編程之間取得一個平衡。它在提供嚴(yán)格的類型檢查的同時,通過接口類型實現(xiàn)了對鴨子類型的支持,使得安全動態(tài)的編程變得相對容易。

Go 的接口類型是對其它類型行為的抽象和概括;因為接口類型不會和特定的實現(xiàn)細節(jié)綁定在一起,通過這種抽象的方式我們可以讓對象更加靈活和更具有適應(yīng)能力。很多面向?qū)ο蟮恼Z言都有相似的接口概念,但 Go 語言中接口類型的獨特之處在于它是滿足隱式實現(xiàn)的鴨子類型。所謂鴨子類型說的是:只要走起路來像鴨子、叫起來也像鴨子,那么就可以把它當(dāng)作鴨子。Go 語言中的面向?qū)ο缶褪侨绱?,如果一個對象只要看起來像是某種接口類型的實現(xiàn),那么它就可以作為該接口類型使用。這種設(shè)計可以讓你創(chuàng)建一個新的接口類型滿足已經(jīng)存在的具體類型卻不用去破壞這些類型原有的定義;當(dā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 卻可以向任何自定義的輸出流對象打印,可以打印到文件或標(biāo)準(zhǔn)輸出、也可以打印到網(wǎng)絡(luò)、甚至可以打印到一個壓縮文件;同時,打印的數(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")
}

當(dāng)然,我們也可以定義自己的打印格式來實現(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ǔ)類型的類型一致性要求可謂是非常的嚴(yán)格,但是 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太靈活了,導(dǎo)致我們需要人為地限制這種無意之間的適配。常見的做法是定義一個含特殊方法來區(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 接口也是很容易的。再嚴(yán)格一點的做法是給接口定義一個私有方法。只有滿足了這個私有方法的對象才可能滿足這個接口,而私有方法的名字是包含包的絕對路徑名的,因此只能在包內(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接口對應(yīng)的 grpcPlugin 類型的 GenerateImports 方法中使用的 p.P(...) 函數(shù)卻是通過 Init 函數(shù)注入的 generator.Generator 對象實現(xiàn)。這里的 generator.Generator 對應(yīng)一個具體類型,但是如果 generator.Generator 是接口類型的話我們甚至可以傳入直接的實現(xiàn)。

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



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

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號