Go語言組合和方法集

2020-07-16 10:05:09
結構型別(struct)為Go語言提供了強大的型別擴充套件,主要體現在兩個方面:第一,struct 可以嵌入任意其他型別的欄位;第二,struct 可以巢狀自身的指標型別的欄位。

這兩個特性決定了 struct 型別有著強大的表達力,幾乎可以表示任意的資料結構。同時,結合結構型別的方法,“資料+方法”可以靈活地表達程式邏輯。

Go語言的結構(struct)和C語言的 struct 一樣,記憶體分配按照欄位順序依次開闢連續的儲存空間,沒有插入額外的東西(除欄位對齊外),不像 C++ 那樣為了實現多型在物件記憶體模型裡插入了虛擬函數指標,這種設計的優點使資料和邏輯徹底分離,物件記憶體區只存放資料,乾淨簡單;型別的方法也是顯式帶上接收者,沒有像 C++ 一樣使用隱式的 this 指標,這是一種優秀的設計方法。

Go語言中的資料就是資料,邏輯就是邏輯,二者是“正交”的,底層實現上沒有相關性,在語言使用層又為開發者提供了統一的資料和邏輯抽象檢視,這種外部統一、內部隔離的物件導向設計是Go語言優秀設計的體現。

組合

從前面討論的命名型別的方法可知,使用 type 定義的新型別不會繼承原有型別的方法,有個特例就是命名結構型別,命名結構型別可以巢狀其他的命名型別的欄位,外層的結構型別是可以呼叫嵌入欄位型別的方法,這種呼叫既可以是顯式的呼叫,也可以是隱式的呼叫。這就是 Go 的“繼承”,準確地說這就是 Go 的“組合”。

因為Go語言沒有繼承的語意,結構和欄位之間是“has k”的關係,而不是“is a”的關係,沒有父子的概念,僅僅是整體和區域性的概念,所以後續統稱這種巢狀的結構和欄位的關係為組合。

struct 中的組合非常靈活,可以表現為水平的欄位擴充套件,由於 struct 可以巢狀其他 struct 欄位,所以組合也可以分層次擴充套件。struct 型別中的欄位稱為“內嵌欄位”,內嵌欄位的存取和方法呼叫遵照的規約接下來進行講解。

內嵌欄位的初始化和存取

struct 的欄位存取使用點操作符“.”,struct 的欄位可以巢狀很多層,只要內嵌的欄位是唯一的即可,不需要使用全路徑進行存取。在以下範例中,可以使用 z.a 代替 z.Y.X.a。
package main

type X struct {
    a int
}
type Y struct {
    X
    b int
}
type Z struct {
    Y
    c int
}

func main() {
    x := X{a: 1}
    y := Y{
        X: x,
        b: 2,
    }
    z := Z{
        Y: y,
        c: 3,
    }
    //z.a, z.Y.a, z.Y.X.a 三者是等價的, z.a z.Y.a 是 z.Y.X.a 的簡寫
    println(z.a, z.Y.a, z.Y.X.a) //1 1 1
    z = Z{}
    z.a = 2
    println(z.a, z.Y.a, z.Y.X.a) //2 2 2
}
在 struct 的多層巢狀中,不同巢狀層次可以有相同的欄位,此時最好使用完全路徑進行存取和初始化。在實際資料結構的定義中應該盡量避開相同的欄位,以免在使用中出現歧義。例如:
package main

type X struct {
    a int
}
type Y struct {
    X
    a int
}
type Z struct {
    Y
    a int
}

func main() {
    x := X{a: 1}
    y := Y{
        X: x,
        a: 2,
    }
    z := Z{
        Y: y,
        a: 3,
    }
    //此時的z.a, z.Y.a, z.Y.X.a 代表不同的欄位
    println(z.a, z.Y.a, z.Y.X.a) // 3 2 1
    z = Z{}
    z.a = 4
    z.Y.a = 5
    z.Y.X.a = 6
    //此時的z.a, z.Y.a, z.Y.X.a 代表不同的欄位
    println(z.a, z.Y.a, z.Y.X.a) // 4 5 6
}

內嵌欄位的方法呼叫

struct 型別方法呼叫也使用點操作符,不同巢狀層次的欄位可以有相同的方法,外層變數呼叫內嵌欄位的方法時也可以像巢狀欄位的存取一樣使用簡化模式。如果外層欄位和內層欄位有相同的方法,則使用簡化模式存取外層的方法會覆蓋內層的方法。

即在簡寫模式下,Go 編譯器優先從外向內逐層查詢方法,同名方法中外層的方法能夠覆蓋內層的方法。這個特性有點類似於物件導向程式設計中,子類覆蓋父類別的同名方法。範例如下:
package main

import "fmt"

type X struct {
    a int
}
type Y struct {
    X
    b int
}
type Z struct {
    Y
    c int
}

func (x X) Print() {
    fmt.Printf("In X, a = %dn", x.a)
}
func (x X) XPrint() {
    fmt.Printf("In X, a = %dn", x.a)
}
func (y Y) Print() {
    fmt.Printf("In Y, b = %dn", y.b)
}
func (z Z) Print() {
    fmt.Printf("In Z, c = %dn", z.c)
    //顯式的完全路徑呼叫內嵌欄位的方法
    z.Y.Print()
    z.Y.X.Print()
}
func main() {
    x := X{a: 1}
    y := Y{
        X: x,
        b: 2,
    }
    z := Z{
        Y: y,
        c: 3,
    }
    //從外向內查詢,首先找到的是 Z 的 Print() 方法
    z.Print()
    //從外向內查詢,最後找到的是 x 的 XPrint()方法
    z.XPrint()
    z.Y.XPrint()
}
不推薦在多層的 struct 型別中內嵌多個同名的欄位;但是並不反對 struct 定義和內嵌欄位同名方法的用法,因為這提供了一種程式設計技術,使得 struct 能夠重寫內嵌欄位的方法,提供物件導向程式設計中子類覆蓋父類別的同名方法的功能。

組合的方法集

組合結構的方法集有如下規則:
  • 若型別 S 包含匿名欄位 T,則 S 的方法集包含 T 的方法集。
  • 若型別 S 包含匿名欄位 *T,則 S 的方法集包含 T 和 *T 方法集。
  • 不管型別 S 中嵌入的匿名欄位是 T 還是*T,*S 方法集總是包含 T 和 *T 方法集。

下面舉個例子來驗證這個規則的正確性,前面講到方法集時提到 Go 編譯器會對方法呼叫進行自動轉換,為了阻止自動轉換,本範例使用方法表示式的呼叫方式,這樣能更清楚地理解這個方法集的規約。
package main

type X struct {
    a int
}
type Y struct {
    X
}
type Z struct {
    *X
}

func (x X) Get() int {
    return x.a
}
func (x *X) Set(i int) {
    x.a = i
}
func main() {
    x := X{a: 1}
    y := Y{
        X: x,
    }
    println(y.Get()) // 1
    //此處編譯器做了自動轉換
    y.Set(2)
    println(y.Get()) // 2
    //為了不讓編譯器做自動轉換,使用方法表示式呼叫方式
    //Y 內嵌欄位 X,所以 type y 的方法集是 Get, type *Y 的方法集是 Set Get
    (*Y).Set(&y, 3)
    //type y 的方法集合並沒有 Set 方法,所以下一句編譯不能通過
    //Y.Set(y, 3)
    println(y.Get()) // 3
    z := Z{
        X: &x,
    }
    //按照巢狀欄位的方法集的規則
    //Z 內嵌欄位*X ,所以 type Z 和 type *Z 方法集都包含型別 X 定義的方法 Get 和 Set
    //為了不讓編譯器做自動轉換,仍然使用方法表示式呼叫方式
    Z.Set(z, 4)
    println(z.Get()) // 4
    (*Z).Set(&z, 5)
    println(z.Get()) // 5
}
到目前為止還沒有發現方法集有多大的用途,而且通過實踐發現,Go 編譯器會進行自動轉換,看起來不需要太關注方法集,這種認識是錯誤的。編譯器的自動轉換僅適用於直接通過型別範例呼叫方法時才有效,型別範例傳遞給介面時,編譯器不會進行自動轉換,而是會進行嚴格的方法集校驗。

Go 函數的呼叫實參都是值拷貝,方法呼叫引數傳遞也是一樣的機制,具體型別變數傳遞給介面時也是值拷貝,如果傳遞給介面變數的是值型別,但呼叫方法的接收者是指標型別,則程式執行時雖然能夠將接收者轉換為指標,但這個指標是副本的指標,並不是我們期望的原變數的指標。

所以語言設計者為了杜絕這種非期望的行為,在編譯時做了嚴格的方法集合的檢查,不允許產生這種呼叫;如果傳遞給介面的變數是指標型別,則介面呼叫的是值型別的方法,程式執行時能夠自動轉換為值型別,這種轉換不會帶來副作用,符合呼叫者的預期,所以這種轉換是允許的,而且這種情況符合方法集的規約。