前往
大廳
主題 達人專欄

會游會叫就是鴨子:Go 語言的 interface

解凍豬腳 | 2022-01-10 18:45:01 | 巴幣 4376 | 人氣 1578

 
本篇文章有不少程式碼,如果這裡的排版讓你感到閱讀困難,請服用 HackMD 好讀版



好久不見的 Golang 系列。

上次介紹過 Go 語言的 struct,我們可以自由地替一個 struct 定義它擁有的屬性(變數)和方法(函數),其中包括了以「嵌合」代替「繼承」的概念。


► 回顧 struct 的嵌合

假設我們定義了通用的 struct 叫做 animal,用來定義所有動物共通的項目,例如名字(屬性)、身長(屬性)、顏色(屬性)、睡覺(方法),那麼當我們把 animal 嵌到 dog、cat 等等各種動物的 struct 裡面,那麼這些不同的動物都會擁有身長、顏色、睡覺的屬性或方法。

type animal struct {
    name string
    length float64
    color int
}

func (a *animal) sleep() {...} // 動物睡覺

type dog struct {
    animal
    volume float64 // 狗勾吠叫的音量
}

func (d *dog) bark() {...} // 狗勾汪汪叫

type cat struct {
    animal
    pawLength float64 // 爪子長度
}

func (c *cat) meow() {...} // 貓咪喵喵叫

func main() {
    myCat := &cat{
        animal: animal{
            name: "咪咪",
        },
    }
    myCat.meow()
    myCat.sleep()
}

因為 cat 具有 animal 擁有的所有東西,所以 *cat 型態的變數仍然可以呼叫 .sleep() 方法,也可以呼叫 .name 等等在 animal 裡面就有的屬性。


► 求同存異:Golang 如何實現「多型」?

在物件導向的設計概念裡,我們之所以會需要「繼承」父類的屬性,就是因為實務上我們可能會有各種不同的型態,但它們之間會存有不少共同點。假設你現在想要設計一個購物網站,架上的每個商品都有一些共同的欄位(屬性),例如每樣商品都有價格、製造廠商、適用年齡等等,這些都是所有商品都具備的共通屬性。

倘若不用「繼承」的方式來讓電腦認知到「這些東西全都是商品的一種,只是按照分類會有各自的小小差異而已」這件事情,那麼這些物件就會變成是「完全不一樣」的東西,沒有一個統一的介面可以同時容得下它們,我們就沒有辦法開發像是購物車之類這種可以放很多種不同商品的系統了。

如果你對於 Java 之類的物件導向語言有些經驗,那你應該會知道在 Java 語言裡可以先設計一個 class 叫做 animal,然後再設計兩個子類 cat 跟 dog,讓這兩種 class 都繼承 animal,這樣一來原先接受 animal 的函數就可以直接接受 cat 或 dog 了。

但很可惜,Golang 本身缺乏這種繼承的機制。前面提到的 struct 組合,充其量只是把 animal 的屬性跟方法複製到 cat 或 dog 裡面而已,我們如果嘗試這麼做:

func touch(a animal) {
    fmt.Printf("%s 被摸摸了, 牠很開心", a.name)
}

func main() {
    myCat := &cat{}
    myCat.name = "咪咪"
    touch(myCat)
}

然後把上面舉例提到的 myCat 當成 animal 直接送進 touch 函數裡面,那麼編譯器肯定會報錯,電腦不會讓你如願摸到咪咪。

這是因為 Golang 的 struct 和 Java 的 class 本身實作的機制不同。struct 是已經固定寫死的結構、class 是相對比較抽象的模版,在 Java 裡面 class 的父子之間也有比較緊密而明確的繼承關係,所以 Java 的 animal struct 可以容得下 animal、dog、cat,但 Golang 的 animal struct 只能容得下純粹的 animal struct,不能容納 dog 或 cat。

為了解決這個問題,Golang 的做法是另外使用專門的「介面」(interface),用來容納這些不同類型的 struct。為了容納各種不同的動物,我們首先定義一個 interface,規定「具有 sleep() 方法的就是動物」,之後只要遇到這些動物之間的共通場合,就用這個 interface 來接收 struct 變數:

type animalInterface interface {
    sleep()
}

func touch(a animalInterface) {
    /*
        如果輸入的 a 是一個 dog,這裡的 a.sleep() 會呼叫 dog 底下的 sleep()
        如果輸入的 a 是一個 cat,這裡的 a.sleep() 會呼叫 cat 底下的 sleep()
    */
    a.sleep()
}

func main() {
    myCat := &cat{}
    myCat.name = "咪咪"
    myDog := &dog{}
    myDog.name = "Lucky"
    touch(myDog)
    touch(myCat)
}

像這種可能會接收到不同種類物件的情境,我們只要使用介面來處理,就可以在 touch 函數裡面直接呼叫 .sleep() 方法了,不管送進去的是 cat 還是 dog,它都可以接受並且正常執行這些 struct 底下各自的 sleep() 方法,端看你丟進去的是什麼東西。

如果今天你定義了一個 interface 叫做 duck,這個 interface 裡面包含了呱呱叫 quack()、游泳 swim(),那麼只要你有任何一個 struct 底下同時具有這兩種方法,那我們就可以說這個 struct 實作了 duck,它就能被 duck 這個介面給容納了。

「只要看到一隻鳥走起路來像鴨子、游起泳來像鴨子、叫起來也像鴨子,那麼我們就可以說這隻鳥是鴨子。」Golang 的 interface 就是基於這種概念而生,這樣的風格在程式設計的領域裡也被稱為鴨子型別(duck typing)。


► 從方法存取屬性

一般而言,如果我們想要遵從物件導向的概念來開發程式,為了安全性和易讀性等考量,通常會建議一個封裝好的物件盡量不要可以從外面「直接存取」。例如我們想開發一款遊戲,有一隻怪物史萊姆:

type monsterInterface interface {
    attack()
    run()
}

type monster struct {
    /*
        monsterInterface 在這個 struct 裡面可加可不加
        只是可以更明確表示 monster 即將用來實作 monsterInterface
    */
    monsterInterface
    name   string
    hp     int
    maxHP  int
    strong int
}

type slime struct {
    monster
}

一般我們不建議有下面這種從外面直接存取的行為:

m := &slime{
    hp:          25,
    maxHP:  25,
    strong:    3,
}
fmt.Println("攻擊史萊姆!")
m.hp -= 10 // <--- NG!

畢竟我們不曉得它的血量什麼時候會達到 0,如果用這種粗糙的方式直接操控屬性內容,就可能會在某些場合遇上 bug 而不自知(例如血量變成負的卻缺乏檢查機制,導致當我們想要顯示血條的時候出問題)。

最好的方案是應該先幫它定義好各種可能的方法,預先在這些存取的方式當中給予限制:

func (m *monster) GetHP() int {
    return m.hp
}

func (m *monster) AddHP(value int) (isDead bool) {
    m.hp += value
    if m.hp < 0 {
        m.hp = 0
        return true
    }
    if m.hp > m.maxHP {
        m.hp = m.maxHP
    }
    return false
}

func (m *monster) SetHP(value int) (isDead bool) {
    if value <= 0 {
        m.hp = 0
        return true
    }
    if value >= m.maxHP {
        m.hp = m.maxHP
        return false
    }
    m.hp = value
    return false
}

如此一來,以後只要統一用 m.AddHP(-10) 來表示「對怪物 m 造成 10 點傷害」這件事情,除了自動檢查血量上下限、減少 bug 發生率以外,對於大型專案來說也能提升開發效率(不必每次扣血都得重寫一次檢查機制),更能體現物件封裝的精神。

而在 monsterInterface 介面的設計上,我們就可以這麼定義:

type monsterInterface interface {
    GetHP()    int
    AddHP(int) bool
    SetHP(int) bool
}

有了 interface,我們就可以先把比較簡單的、抽象的運作邏輯先寫起來,預先準備好怪物的共通特點,接著才來慢慢煩惱各種不同怪物的實作細節。


► 資料型態的斷言

假設現在有一個函數 doSomething(m monsterInterface),然後我們傳了一個 slime 進去:

func doSomething(m monsterInterface) {
    fmt.Println("設定這隻怪物血量為 20")
    a.SetHP(20)
    fmt.Println("這隻怪物的血量是", a.GetHP())
}

func main() {
    m := &slime{
        hp:     25,
        maxHP:  25,
        strong: 3,
    }
    doSomething(m)
}

  在 doSomething 裡面,我們可以「在不知道 m 是什麼型態的情況下,直接呼叫 m 的 SetHP 方法」,是因為 monsterInterface 介面裡面也有定義了 SetHP 方法,所以我們才能這樣透過介面來呼叫它。

  這個時候的 m 其實還是抽象的物件,對 Golang 來說其實它還不知道 m 是什麼型態。如果我們想要把這個抽象的 m 變成具象化的實體,我們就需要進行一步「型態斷言」(type assertion),把這個 m 從 monsterInterface 轉換成 *slime:

func doSomething(m monsterInterface) {
    switch m.(type) {
    case *slime: // 如果 m 內容物的類型是個 *slime
        s := m.(*slime) // 把 m 從介面轉換成 *slime
        fmt.Printf("這是一隻力量為 %d 的史萊姆\n", s.strong)
        fmt.Printf("它的血量是 %d\n", s.GetHP())
    default: // 其他的型態
        fmt.Println("我也不知道這是什麼生物")
    }
}

使用的方法很簡單,我們先使用 m.(type) 取得這個 m 內容的型態,然後再用 switch 條件分歧式把這個 interface 可能的類型列出來,接下來就可以決定「當 m 是某某類型的時候,就做對應某某類型的事」。

確認 m 的 type 確實是 *slime 之後就可以使用 m.(*slime) 把抽象的介面轉換成具體的物件了,接下來操作它就等同直接操作一個實際且明確的 *slime 物件。


► 空介面

介面既然是個這麼萬用的容器,我們也就可以用它來容納各種不同類型的資料了。我們知道 interface{ Sleep() } 可以容納得了所有「具有 Sleep() 函式」的 struct,那麼一個完全沒有包含任何方法的空介面 interface{} 自然就可以容納得下「所有的型態」了,包括 nil、int、string、任何自訂的 struct。

有的時候我們可能會有「同時把很多個不同型態的資料放在同一個 slice 裡」的需求,那麼我們就可以直接定義空介面型態的 slice 來用:

func main() {
    mySlice := []interface{}{}
    mySlice = append(mySlice, 123)
    mySlice = append(mySlice, nil)
    mySlice = append(mySlice, "456")
    fmt.Println(mySlice)
}

要注意包含了大括號的「interface{}」是這整個介面的名字,這就相當於:

type Anything interface{
}

func main() {
    mySlice := []Anything{}
    mySlice = append(mySlice, 123)
    mySlice = append(mySlice, nil)
    mySlice = append(mySlice, "456")
    fmt.Println(mySlice)
}

既然產生 slice 實體的時候用的是「[]型態名稱{}」,所以應該是「[]interface{}{}」而不是「[]interface{}」,這點務必要搞清楚。實際執行就可以看到這些不同型態的資料成功地一起被放到同一個 slice 裡面了。

同理,函數用來接受值的時候,也可以用空介面來接受所有型態的變數:

func doSomething(value interface{}) {
    fmt.Printf("這是一個 %T 型態的資料,它的值是 %v", value, value)
}

當我們需要對它做實際操作(例如加減乘除、字串拼接)的時候,一樣要記得做 type assertion 把傳進來的 interface 實體化才能繼續下去。有了這些觀念以後,你就已經可以算是掌握了 interface 和 struct 的大部分用法,也能應付大部分的應用場景了。




縮圖素材原作者:Renée French(CC BY-SA 3.0)
送禮物贊助創作者 !
80
留言

創作回應

神秘贊助者向創作者進行贊助 ✦
有人默默贊助你,願創作能量隨時飽滿!
2022-01-10 19:20:48
解凍豬腳
感謝贊助!
2022-01-14 09:40:20
鴨子 ヽ(・×・´)ゞ
呱呱
2022-01-10 19:22:01
解凍豬腳
我相信你會游泳的
2022-01-14 09:40:30
魔化鬼鬼
提前規劃好程式架構之間的 interface,可以讓整個程式的彈性提升很多,想要支援特定功能,給他貼個 interface 就好了,對岸翻譯的「接口」我覺得貼切許多。
2022-01-11 00:31:16
解凍豬腳
我也覺得「接口」這個詞很好地代表了 interface 在這裡的意義
不過每次用了都會被朋友嗆說那是支語 XD
2022-01-14 09:41:21
雞塊
站方在發文頁面該支援一下程式碼區塊了吧
2022-01-11 02:07:12
解凍豬腳
我也希望 [e3]
不過要開發的東西看起來還有更多,大概是很難輪到程式碼區塊這種東西了
2022-01-14 09:42:12
勳章向創作者進行贊助 ✦
豬腳....讚.....
2022-01-20 02:52:31
解凍豬腳
感謝贊助
Golang 讚…
2022-01-21 20:58:22

更多創作