鴨子定型(Duck Typing)只在乎行為,不在意型態,在Go語言可以透過介面實現,同時獲得靜態時期檢查能力,然而,型態的複雜度並非憑空消失,事實上,這會令Go實現的鴨子個性十足。

因此,為了控制這群鴨子,能否掌握型態仍是必要的課題。

接收者的型態是?

在Go語言中使用介面定義行為,由於型態在實現介面時只要行為符合,不需要明確指定哪個介面,具有動態定型語言經驗的開發者,很容易就聯想到鴨子定型。

為何我們這麼說?相對於動態定型語言是在執行時期確認行為,Go在編譯時期就能檢查型態是否符合介面的行為,因此,又常被稱為編譯時期鴨子定型(Compile Time Duck Typing)。

此外,只需實現行為、不用聲明介面的Go,往往會被歸類在結構型態系統(Structural Typing)。相對來說,有些語言(例如Java)屬於名義型態系統(Nominal type system),必須明確聲明型態實現了哪個介面。

所以,對於來自這類語言的開發者,很容易就會產生出一些疑問,像是:「該怎麼知道某介面的實現型態有哪些?」、「這個型態實現了哪些介面?」等,並且會想在文件上,尋找這類資訊──因為這類語言的文件,往往會記錄某介面的實現類別有哪些。

在Go中,基本上是不用提這類問題的,API呼叫者只需在文件確認介面定義了哪些行為,呼叫API時的傳入值是否有實作,就可以了。這對於API呼叫者提供了很大的彈性,然而,此時責任會落在API的設計者身上。

首先,因為介面中並不定義方法的接收者型態,然而在實作方法時需要考慮接收者是否為指標、參考等型態;那麼,在針對介面設計時,該不該思考接收者的型態呢?

這是個務實問題,例如,假設Foo是個介面,那麼,當var f Foo = impl時,若impl是個結構實例,就會發生複製結構欄位的值,而如果impl是個指標,就只會傳遞指標,

針對介面設計時,「不應該假設接收者的型態」這個答案顯然過於理想化,事實上,查看標準程式庫的設計,就可以得到答案。

因為定義介面在必要時,在相關的名稱上,須能夠辨識接收者的型態,例如,sort.Interface的Swap方法顯然具有副作用,這表示:接收者型態必須是參考或指標,而在API上,也可以在名稱上明確指出型態,例如sort.Slice,而這麼一來,即使它的參數型態是interface{},呼叫者也能夠知道所要傳入的部分是slice型態。

nil不等於nil?

若Foo是個介面,若var f Foo = nil,那麼,f == nil比較結果會是true,然而,有個結構X實作了Foo的行為,並有底下的程式片段,那麼,f == nil的結果會是什麼?

var x *X = nil
var f Foo = x

答案會是false,實際上這是個FAQ了。在〈Why is my nil error value not equal to nil?〉(https://bit.ly/2YHLmRZ)就提到,若returnsError傳回型態為error,那麼,底下的實作會令returnsError() == nil為false:

var p *MyError = nil
if bad() {
p = ErrBad
}
return p

因為Go會檢查傳回值來看看是否有錯誤,會有這種結果就不妙了。之所以會有這種結果,是因為介面的底層實作,實際上儲存了型態與值。

就var f Foo = nil來說,代表著f在底層儲存的型態為nil,而值沒有指定,而這樣的介面值稱為nil interface,同時,只有在這個情況下,跟nil相等比較才會是true;在var f Foo = x的例子中,f在底層儲存的型態為*X,而值是nil,跟nil相等比較的結果就會是false。

在〈Go-tcha: When nil != nil〉曾提到為何Go介面底層如此實作。原因之一,是為了允許任何自訂型態(包含基本型態)擁有方法。原因之二,是為了方法接受者可以是nil,以便實作nil safty的概念等。還有一個原因是,Go為強型別,在介面這部份,若A介面有B介面未定義的行為,編譯器並不允許直接將A介面指定給B介面。

型態斷言與反射

如果B介面底層儲存的值,確實擁有A的行為,此時,我們可以透過型態斷言(Type assertion)來取得值再指定給A,例如var b B = a.(B)。

至於x.(T)這個語法,是在告知編譯器,在執行時期再來斷言型態,也就是在執行時期再來判斷x底層儲存的值,型態是否為T,若答案是肯定的,就傳回底層儲存的值。

當指定給單一變數時,若底層值實際上不是T型態的話,會引發panic,這時可以使用Comma-ok斷言,例如b, ok = a.(B);若底層型態符合,b會是底層值,ok會是true,否則b會是nil,ok會是false。透過檢查ok是否為true,開發者可以自行決定該panic或做其他處理,如果有多個型態必須判斷的時候,我們可以透過type switch語法來處理。

因此,對於方才var f Foo = x,若真的想比對f底層值是否為nil,我們可以使用f.(*X) == nil來比對,結果就會是true。但實務上鮮少這麼做,如果x可能是nil,應該在判定x為nil後,再直接指定nil給f,而不是將x指定給f。

不過,現實情況中,可能會混淆nil interface,以及介面底層值儲存nil的情況,因此,對於nil的檢查,就要考慮兩者了,在〈Go: Check Nil interface the right way〉(https://bit.ly/2t7BKEr)中,就討論了幾個方案,基本上是透過反射,最簡單的實作方式之一是:

func isNil(i interface{}) bool {
return i == nil || reflect.ValueOf(i).IsNil()
}

在意型態的鴨子

整體而言,鴨子定型是個概念,在不同語言中,會有不同的實現,而且,在動態定型語言也不是免費的,若沒有借助型態提示與相關工具檢查,一切就會在執行時期見真章,因此,單元測試就變得相對重要。

對於靜態定型語言來說,既然開發者可以實現靜態時期鴨子定型,那麼,就表示對於型態的掌握仍然是必要的,只是須看複雜度會被藏在哪裡,以及在何種情境下必須留意,還有問題會發生在編譯時期或執行時期罷了。

對於完全基於結構型態系統,更具有參考、指標等型態的Go語言來說,能否察覺這類被隱藏的複雜度更是重要。

因為,Go中的鴨子,其實仍然是在意型態的,如果開發者對此漫不經心,這群鴨子最後到底會在編譯時期,還是執行時期出來搗亂,開發者可就無法掌握。

作者簡介


Advertisement

更多 iThome相關內容