/ Golang

Go 的 Methods, Interfaces 及 Embedded Types

This post is Traditional Chinese version of Methods, Interfaces and Embedded Types in Go
原文請參考:Methods, Interfaces and Embedded Types in Go

簡介

我的夥伴艾德問我,如果某個結構(struct)及嵌入字段(embedded field)都實作相同的界面(interface)時,會發生什麼事。我們問自己兩個問題:

  • 譯註:「匿名字段(anonymous field)」也稱之為「嵌入字段(embedded field)」,請參考 golang struct

  • 編譯器會因為一個界面有兩個實作拋出錯誤嗎?

  • 如果編譯器接受這個型別宣告,界面怎麼決定呼叫哪一個實作?

我們來寫一些程式碼以便回答問題,接著我會深入規格。我們發現一些很有趣的事,相信這值得分享給其他正在學習這個語言的人。一旦我們學會方法(method)、界面及嵌入型別背後的運作機制,那答案就呼之欲出了。就讓我們開始吧,首先是 Go 的方法。

方法(Methods)

Go 語言有函式(funtions)與方法(methods)。方法是一種具備接收者(receiver)的函式。接收者可以是一個值(value)、代名型別的指標或者結構型別的指標。任何帶有該型別的方法,就屬於該型別的方法集(method set)。

這裡宣告一個結構及方法:

type User struct {
    Name string
    Email string
}

func (u User) Notify() error

首先,我們宣告一種結構型別並且命名為 User,接著宣告方法並且命名為 Notify,這個方法的接收者能夠接受屬於 User 型別的值。呼叫 Notify 時,需要一個 User 型別的值或指標。

// User 型別的值可以呼叫 Notify 方法,Notify 的接收者使用傳值
bill := User{“Bill”, “[email protected]”}
bill.Notify()

// User 型別的指標也可以呼叫 Notify 方法,Notify 的接收者使用傳值
jill := &User{“Jill”, “[email protected]”}
jill.Notify()

這個例子中使用了指標,Go 調節並對指標取值(dereference),所以可以用指標的方式來呼叫方法。請注意到當接收者不是指標時,傳遞的是值的副本。

我們可以讓 Notify 方法的接收者使用指標:

func (u *User) Notify() error

同樣地,如下例子來呼叫 Notify 方法:

// User 型別的值可以呼叫 Notify 方法,Notify 的接收者使用傳址
bill := User{“Bill”, “[email protected]”}
bill.Notify()

// User 型別的指標也可以呼叫 Notify 方法,Notify 的接收者使用傳址
jill := &User{“Jill”, “[email protected]”}
jill.Notify()

如果不確定接收者什麼時候要用傳值?什麼時候要用傳址(指標)?Go 的維基頁面上有組很棒的規則,你可以參考並遵循這個規則。Go 維基頁面裡面也包含一個段落,說明有關社群對於接收者的命名慣例。

界面(interface)

Go 的界面是個特殊且提供了許多難以置信的彈性、抽象到我們程式之中。它們是一種特殊的方式,用來指名型別的值或指標所擁有的行為。以程式語言觀點來看,界面是一種型別,指定了一組方法集(method set),而且,界面中的任何方法也都是一種界面。

讓我們用 Go 來宣告一種界面:

type Notifier interface {
    Notify() error
}

這裡定義了 Notifier 界面並擁有一個 Notify 方法。這種命名方式是 Go 的一種慣例,當界面中只有一個方法時,這個界面就會用 -er 後綴來命名。這並不是一個強行規定,但我們應當尊重,特別是在當界面和方法的同時存在相同的函數簽名(singature)及意義。

我們可以在界面指定很多方法。標準函式庫裡,很難找到界面有超過兩個方法。

界面實作

Go 對於如何讓型別實作特定界面很獨特。Go 並不需要明確地聲明該型別要實作哪個界面。如果我們型別實作了某界面的所有方法集,就可以聲稱實作了界面。

繼續我們的範例,建立一個新的函式,接受傳入 Notifier 這個界面的值或指標:

func SendNotification(notify Notifier) error {
    return notify.Notify()
}

某個實作 Notify 方法的值或指標,以參數方式傳入 SendNotification 函式,並且在函式內呼叫這個 Notify 方法。此函式可以對任何實作這個界面的值或指標,執行特定的行為。

接著,實作 User 型別的 Notify 方法,並呼叫 SendNotification 函式傳入 User 型別的值:

func (u *User) Notify() error {
    log.Printf("用戶: 發送用戶郵件到 %s<%s>\n",
        u.Name,
        u.Email)

    return nil
}

func main() {
    user := User{
        Name:  "janet jones",
        Email: "[email protected]",
    }

    SendNotification(user)
}

// 輸出:
cannot use user (type User) as type Notifier in function argument: 
      User does not implement Notifier (Notify method has pointer receiver)

http://play.golang.org/p/VieiPRDGVu

為什麼編譯器會認為我們的值(型別)並沒有實作這個界面呢?界面順從(compliance)取決於這些方法的接收者,以及如何呼叫這些界面。下列規則決定了我們的型別是否實作界面:

  • 只要方法的接收者是 *TT,都屬於型別指標 *T 的方法集

這規則聲明了,如果我們用某個型別的指標變數,呼叫界面方法,只要方法的接收者是這個型別的指標(*T)或值(T),就滿足了這個界面。我們的範例並不滿足這規則,因為我們傳入一個值到 SendNotification 函式。

  • 只要方法的接收者是 T,都屬於型別 T 的方法集

這規則聲明了,如果我們用某個型別的變數值,呼叫界面方法,只要方法的接收者是這個型別的值(*T),就滿足了這個界面。我們的範例並不滿足這規則,因為 Notify 這個方法的接收者接受指標(*User)。

上述的兩個規則就是目前界面規格的條件,我延伸出下列規則來滿足我們的範例:

  • 任何型別 T 的方法集,並不完全是由方法接收者型別為 T 所組成

這就是我們的情況,也就是為何我們會收到編譯錯誤的訊息。Notify 方法的接收者使用了指標,我們在呼叫界面方法時用了值。修正這個問題,我們只要傳入 User 值的位址到 SendNotification 函式:

func main() {
    user := &User{
        Name:  "janet jones",
        Email: "[email protected]",
    }

    SendNotification(user)
}

// 輸出:
用戶: 發送用戶郵件到 jones<[email protected]>

http://play.golang.org/p/3NNiS4dMrK

嵌入型別

結構型別允許包含匿名或嵌入字段。也就是所謂嵌入型別。
當我們嵌入型別到結構之中,型別的名稱即扮演做字段名稱(field name),用來表示該字段。

讓我們宣告一個新的型別,並嵌入我們的 User 型別:

type Admin struct {
    User
    Level string
}

我們宣告了一個新的 Admin 結構並嵌入 User 型別。這並不是繼承,而是組成(composition)。UserAdmin 型別之間沒有關聯性(relationship)。

接著修改 main 來產生一個 Admin 型別的值,接著將這個值的位址傳入 SendNotification 函式:

func main() {
    admin := &Admin{
        User: User{
            Name:  "john smith",
            Email: "[email protected]",
        },
        Level: "super",
    }

    SendNotification(admin)
}

// Output
用戶: 發送用戶郵件到 john smith<[email protected]>

http://play.golang.org/p/2jZMCGEfxW

不出所料,我們可以用 Admin 型別指標來呼叫 SendNotification 函式。多謝組成,Admin 型別現在因為嵌入 User 型別而晉升(promotion)支援了界面。

Admin 型別包含了 User 型別的字段及方法,結構的關係為何呢?

當我們嵌入一個型別時,該型別的方法也成為了外圍(outer)型別的方法,但是,當調用時,方法接收者的型別是嵌入型別,而非外圍型別。

  • Effective Go

因為嵌入型別的名稱扮演成字段名稱,而嵌入型別存在成內圍型別,我們可以用下列方式呼叫:

admin.User.Notify()

// Output
用戶: 發送用戶郵件到 john smith<[email protected]>

http://play.golang.org/p/_huNeKVmXS

這裡我們透過型別名稱當作字段,存取了內圍型別的方法集。無論如何,這些字段跟方法集也被晉升到外圍型別。

admin.Notify()

// Output
用戶: 發送用戶郵件到 john smith<[email protected]>

http://play.golang.org/p/v4ro-KHiKJ

因此,呼叫外圍型別的 Notify 方法,也就呼叫了內圍型別的實作方法。

以下為 Go 對於內圍型別方法集晉升的規則:

給定一個結構 S 以及一個型別 T,晉升方法到結構方法集的規則如下:

  • 如果 S 包含匿名字段 T,方法接收者為 T 將會引入 S*S 的方法集

這規則聲明了,當我們嵌入一個型別(T),方法接收者為嵌入型別(T)時,將會晉升並允許外圍型別(S)及型別指標(*S)的呼叫。

  • 方法接收者為 *T 也會晉升成 *S 的方法集

這規則聲明了,當我們嵌入一個型別(T),方法接收者為嵌入型別指標(*T)時,將會晉升並允許外圍型別指標(*S)的呼叫。

  • 如果 S 包含匿名字段 *T,方法接收者為 T*T,將會引入 S*S 的方法集

這規則聲明了,當我們嵌入一個型別指標(*T),方法接收者為嵌入型別(T)或型別指標(*T)時,將會晉升並允許外圍型別(S)及型別指標(*S)的呼叫。

上述三個規則就是目前晉升方法的條件,我延伸出一條給其他案例的規則:

  • 如果 S 包含匿名字段 TS*S 方法集不會引入接收者為 *T 的方法

這規則聲明了,當我們嵌入一個型別(T),嵌入型別的方法中,方法接收者為嵌入型別指標(*T)時,不會晉升及不允許外圍型別(S)的呼叫。這個與前面聲稱的界面規則一致。

回答問題

現在,我們最終完成這個簡單的程式,提供了文章一開始所提出兩個問題的答案。在 Admin 型別內實作 Notifer 界面:

func (a *Admin) Notify() error {
    log.Printf("管理員: 發送用戶郵件到 %s<%s>\n",
        a.Name,
        a.Email)

    return nil
}

Admin 型別實作了界面,顯示管理員的訊息。這將幫助我們,當使用 Admin 型別的指標來呼叫 SendNotification 函式時,決定哪個實作會被呼叫。

現在,讓我們產生一個 Admin 型別的值,並將值的位址傳入 SendNotification 函式,看看會出現什麼:

func main() {
    admin := &Admin{
        User: User{
            Name:  "john smith",
            Email: "[email protected]",
        },
        Level: "super",
    }

    SendNotification(admin)
}

// 輸出
管理員: 發送用戶郵件到 john smith<[email protected]>

http://play.golang.org/p/NkDioPJs04

如我們所預期,Admin 型別對界面的實作被 SendNotification 所呼叫。所以現在用外圍型別呼叫 Notify 方法,看看會出現什麼:

admin.Notify() 

// 輸出
管理員: 發送用戶郵件到 john smith<[email protected]>

http://play.golang.org/p/RG50rxC0d7

我們收到 Admin 型別的實作輸出。User 型別的實作將不會被晉升到外圍型別:

所以現在我們有足夠的認識來回答問題:

  • 編譯器會因為一個界面有兩個實作拋出錯誤嗎?

不會。因為當我們使用了嵌入型別,型別的名稱扮演著字段名稱。這對字段有影響,嵌入型別的方法有一個獨一無二的名稱,如結構的內圍型別。所以我們可以同時擁有內圍及外圍實作相同的界面,各個實作將是獨一無二且可被存取。

  • 如果編譯器接受這個型別宣告,怎麼決定哪一個實作會被界面呼叫?

如果外圍型別包含了滿足界面的實作,它將被採用。除此之外,感謝方法晉升,任何實作了界面的內圍型別也可以被外圍型別所使用。

結論

這些方法、界面及嵌入型別的運作,讓 Go 變成非常獨特。這功能幫助我們建立強大的架構,以達到如同物件導向的好處,同時也免除了複雜性。這些我們在文章中所談到的功能,可以創造抽象且規模彈性的框架,並擁有最小量的程式碼及很少的混淆。

越多從語言的細部及編譯器所學到,越讓我感謝正交(orthogonal)語言。微小的功能運作在一起,讓我們具有創意,用語言設計者作夢也想不到的方式來利用這個語言。建議花點時間學習這個語言的各個功能,你可以同時達到更多創意及生產效率。