良好的(乾淨的) Golang 編碼 (Clean Go code)(6)

Golang 的介面 (Interfaces in Go)

一般而言,golang處理介面的方式跟其他程式語言相當不同,我們並不會在一個物件(struct)使用到介面時特別的宣告它,例如在C語言或Java會需要在函數簽章中特別寫出來繼承、使用了某介面,在Go中,當一個物件包含了某介面的所有方法,它就屬於這個介面的一種,介面宣告時會宣告包含哪些方法,而物件則利用實作了這些方法來變成該介面的一員。舉例來說,任何一個struct,若包含了Error()方法,它就屬於Error介面的一員,可以被當成error type返回,因為golang介面這樣的特性,使得golang寫interface超級容易且讓golang這個程式語言感覺起來步調很快且很靈動。

但是,這樣的方式也有其缺點,當我們不需要特別宣告struct屬於某介面,我們閱讀程式時必須自己判斷這個struct是否有實作某些方法來辨別是否屬於該介面。因此呢,很常見的作法是定義介面時包含盡量少的方法,這樣比較容易判斷是否struct滿足了該介面的要求。

另外一個方式是struct創建建構子時,返回的是一個介面,而不是該struct type:

type Writer interface {
	Write(p []byte) (n int, err error)
}

type NullWriter struct {}

func (writer *NullWriter) Write(data []byte) (n int, err error) {
    // do nothing
    return len(data), nil
}

func NewNullWriter() Writer {
    return &NullWriter{}
}

上面例子中的NullWriter struct的建構子(NewNullWriter())確保了NullWriter屬於Writer這個介面,因為如果我們此時將Write這個方法刪除,則編譯器(compiler)會給我們一個編譯錯誤。這樣的作法能讓我們可以依賴編譯器做為安全保障來確保程式如我們預期般的運作,而不會因為漏掉或Typo某個方法而導致程式出錯。

有些時候,我們也許用不到建構子,或希望建構子返回的是一個具體的物件type而不是介面,例如例子中,NullWriter struct並沒有任何的屬性變數需要初始化,因此不需要特別寫一個建構子。此時,我們可以用一個更簡短的方法來達成確認是否某struct有成功實作成某介面,如下:

type Writer interface {
	Write(p []byte) (n int, err error)
}

type NullWriter struct {}
var _ Writer = &NullWriter{}

特別注意var _ io.Writer = &NullWriter{} 這一行,其中_是golang的空白符(blank identifier),代表一個我們不在意也不會用到的變數名,我們將NullWriter初始化並賦值(assign)給空白符變數,並指定該空白符變數為Writer這種type (實際上為interface),這樣的作法會讓編譯器去檢查NullWriter是否能賦值給Writer這個介面,也就是NullWriter是否屬於Writer這個介面,是否有實作了這個介面的所有方法。同時呢,這樣的做法能讓我們檢查一個struct屬於多個介面的情況,一一檢查每個介面的合約(也就是包含的方法)是否滿足:

type NullReaderWriter struct{}
var _ Writer = &NullWriter{}
var _ Reader = &NullWriter{}

從上面的code,我們列出一個struct要滿足的介面,作為一個讀者,可以很容易的理解到這個struct需要滿足的介面有哪些,同時編譯器也可以幫我們把關是否這個介面的方法都有被struct實作了。因此,我們一般都會採用這樣的作法來確保interface的實作。事實上,還有第三個作法,更加明確的寫出來一個struct是那些介面,然而,這個做法實際上卻反而會帶來困擾,這個作法使用內嵌的方式,將介面作為sturct的屬性之一。

甚麼? Okay,在我們開始說明這個禁忌之術之前,讓我們稍微回頭思考一下,在golang,我們可以用內嵌struct的方式來達成類繼承,這個很棒,讓golang擁有物件導向中的好處,抽出可重複使用的struct。

type Metadata struct {
    CreatedBy types.User
}

type Document struct {
    *Metadata
    Title string
    Body string
}

type AudioFile struct {
    *Metadata
    Title string
    Body string
}

上面的例子中,我們定義了Metadata這個物件(struct),它包含了一個屬性(CreatedBy),而這個屬性會被其他許多struct使用到,我們將這個屬性寫在Metadata裡,然後用內嵌struct的方式讓其他struct使用,而不是每個struct都寫一個CreatedBy,這樣的好處就是當我們要修改這個屬性,我們只要改Metadata一個地方。如同我們在前面討論的,我們希望程式在一個地方的改變不會讓程式的其他地方掛掉,而保持相關的屬性集中化,並用內嵌struct的方式能達成這樣的目的–介面也是類似的情況,只是主要關注的是方法而非屬性變數。

接著,我們來看看我們要怎麼用建構子來避免當我們修改Metadata時造成其他部分的程式掛掉的情況:

func NewMetadata(user types.User) Metadata {
    return &Metadata{
        CreatedBy: user,
    }
}

func NewDocument(title string, body string) Document {
    return Document{
        Metadata: NewMetadata(),
        Title: title,
        Body: body,
    }
}

假設我們之後決定要在Metadata新增一個CreatedAt的屬性,這時,很簡單,Metadata更新以外,只需要更新NewMetaData這個建構子如下:

func NewMetadata(user types.User) Metadata {
    return &Metadata{
        CreatedBy: user,
        CreatedAt: time.Now(),
    }
}

程式其他的地方都在沒有影響下完成了更新,例如我們的Document及AudioFile這兩個struct,也同時完成了更新,這是一個很好的例子展示了程式碼的可維護性及為什麼要達成鬆耦合;我們還可以更新方法而不破壞原本其他的code:

type Metadata struct {
    CreatedBy types.User
    CreatedAt time.Time
    UpdatedBy types.User
    UpdatedAt time.Time
}

func (metadata *Metadata) AddUpdateInfo(user types.User) {
    metadata.UpdatedBy = user
    metadata.UpdatedAt = time.Now()
}

也就是,不影響其他地方原有的code的情況下,我們成功的添加了新的功能。這樣做法讓新增功能又快又不痛苦,正是我們clean code希望達成的目標。

現在,我們來看看使用內嵌interface這樣的方式,考量如下的code:

type NullWriter struct {
    Writer
}

func NewNullWriter() io.Writer {
    return &NullWriter{}
}

編譯沒問題。技術上來說,我們內嵌一個Writer介面在我們的NullWriter struct,它會繼承所有介面所定義的方法,因此有些人會覺得這樣的做法是一個很清楚的做法來說明struct屬於某介面,然而,我們必須要很小心地使用這個技巧。

func main() {
    w := NewNullWriter()

    w.Write([]byte{1, 2, 3})
}

這段code,編譯器不會叫,會順利的過編譯,NewNullWriter()返回一個Writer w,而w是一個Writer介面的物件(藉著內嵌interface的方式),編譯器認為一切都好。但是當我們執行時,會出現下面這段:

panic: runtime error: invalid memory address or nil pointer dereference

想一下發生了甚麼? 在golang,一個介面的方法基本上是一個函數指標,在此例中,既然我們呼叫了介面的方法(w.Write),指向一個介面的方法而非一個真正有實作的函數,因此變成了呼叫nil指標;為了避免上述發生,我們需要提供NullWriter一個屬於該介面的、且有實作介面方法的struct:

func main() {
  w := NullWriter{
    Writer: &bytes.Buffer{},
  }

    w.Write([]byte{1, 2, 3})
}

注意: 這個例子中,Writer被指到一個內嵌io.Writer介面的物件,我們也可以用w.Writer.Write()來呼叫Write方法。

這樣修改後,我們不再引發panic,可以使用NullWriter的Writer介面功能了,這樣的初始化方法跟前面提到的將一些屬性變數初始化為nil的情況類似,因此處理作法也應該類似,但是,這就是內嵌介面不太好的原因,在前面的章節中,我們有提到處理的方式是將這些可能為nil的屬性變成私有變數並建立一個getter方法來處理,來確保這些屬性不會是nil。但是,內嵌介面不能這樣處理,因為介面都是公開的(public)。

另一個考量是使用內嵌介面可能會導致部分覆寫(overriding)介面方法的混亂:

type MyReadCloser struct {
  io.ReadCloser
}

func (closer *MyReadCloser) Read(data []byte) { ... }

func main() {
  closer := MyReadCloser{}
  
  closer.Read([]byte{1, 2, 3}) 	// works fine
  closer.Close() 		// causes panic
  closer.ReadCloser.Close() 		// no panic 
}

即使看起來像是我們在覆寫(overriding)方法,覆寫在C#或Java這樣很一般的做法,但我們在這邊其實不是在覆寫,Go並沒有支持繼承(也就沒有superclass相關的紀錄跟處理),我們可以模仿類似的行為,但是它就不是build-in的方式。使用內嵌介面因此需要特別小心,我們可能建立了混亂且容易有bug的程式碼,好處僅僅是省了幾行code。

有些人會認為為了進行測試介面的部份方法,使用內嵌介面來建立一個mock struct是一個好的方法,基本上,使用內嵌介面,就不需要實作所有的介面方法,而可以選擇要測試的方法才實作;在測試的領域,我可以了解這個好處,但我個人仍不喜歡這樣的方式。

讓我們回到乾淨程式碼及良好的使用介面。是時候討論使用介面來做為函數的輸入參數及返回值(return)了,有一句在golang世界的俗話是這麼說的:

Be conservative in what you do; be liberal in what you accept from others – Jon Postel

這句話其實跟Go沒甚麼關係,這句話的來源是早期的TCP 通訊協定的spec。

換句話說,你應該寫的函數是接收介面當輸入參數,而返回值應該是一個具體的物件(concrete type),通常這樣是一個好的方式。如下面所舉的例子,我們可以建立一個函數,接收writer介面作為輸入參數,函數中呼叫writer介面的Write方法:

type Pipe struct {
    writer io.Writer
    buffer bytes.Buffer
}

func NewPipe(w io.Writer) *Pipe {
    return &Pipe{
        writer: w,
    }
} 

func (pipe *Pipe) Save() error {
    if _, err := pipe.writer.Write(pipe.FlushBuffer()); err != nil {
        return err
    }
    return nil
}

假設當我們執行應用程式時會寫入一個檔案,但是我們不想每一次測試時寫入一個新的檔案,我們可以建立一個假的物件(mock type),這個物件基本上不做任何事,就是基本的依賴注入(dependency injection)及模擬對象(Mocking),重點是這個在golang很容易達成:

type NullWriter struct {}

func (w *NullWriter) Write(data []byte) (int, error) {
    return len(data), nil
}

func TestFn(t *testing.T) {
    ...
    pipe := NewPipe(NullWriter{})
    ...
}

NOTE: There is actually already a null writer implementation built into the ioutil package named Discard

結合上面兩段程式,當用NullWriter建構我們的Pipe struct(即pipe:= NewPipe(NullWriter{}),並呼叫Save()函數時,基本上不會做甚麼事,我們只是多寫了四行code,但完成了pipe的測試,這也是為什麼我們建議讓介面越小越好,它讓我們容易實作。然而,這樣的方式同時也有缺點。

空介面(The Empty interface{})

不像其他程式語言,Go並沒有泛型的概念,有許多的建議golang應該要加入泛型,但目前為止都被Go Language團隊否定了。不幸的,沒有泛型,開發者必須找出有創意的其他方式,這些方式通常使用了空介面(interface{}),以下這段描述為什麼這些太有創意的方式可能造成了不好的程式碼,同時也有例子來說明比較合適的使用空介面(interface{})的方式,及如何避免使用時挖坑給自己跳。

如同在前面所說的,Go 會判斷struct是否有實作介面的方法來認定struct是否屬於某介面,那麼,如果有一個介面沒有方法,那會怎麼樣? 例如,空介面(interface{})就沒有方法。或是我們再定義一個EmptyInterface {}如下:

type EmptyInterface interface {}

其實,上面這行定義的跟golang內建的空介面是一樣的;這樣的作法的結果是:我們可以寫一個函數接收任何type作為我們的輸入參數,這在某些函數中非常有用,例如print helpers,這也是golang fmt package中Println函數能接收任何type當參數的原因。如下所示:

func Println(v ...interface{}) {
    ...
}

上面的這個例子中,Println函數不僅只接收一個interface{},而是接收了一個切片(slice)的interface{},因為interface{}並沒有定義任何的方法,所有的物件型態都屬於空介面,所以上面的函數可以接受任何型態的輸入,甚至輸入一個內含不同型態的切片(slice),這樣的做法在字串轉換中很常看到;另一個好的例子來自於json的標準函示庫:

func InsertItemHandler(w http.ResponseWriter, r *http.Request) {
    var item Item
    if err := json.NewDecoder(r.Body).Decode(&item); err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }

    if err := db.InsertItem(item); err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    w.WriteHeader(http.StatsOK)
}

上面所有比較不優雅的code都被限制在Decode函數裡了,所以,當使用這個函數時,開發者不用擔心型態反射或是型態轉換的問題,我們只要注意提供一個指標(&item)給一個具體的物件(item)。這是好的做法,因為Decode()函數技術上是返回了一個具體的物件,Item,其內容是由Http request的Body取得;這也表示我們不用去處理interface{}潛在的風險,它都包起來了。

但即使是使用好的程式實踐方式,當使用interface{}時,仍有些問題要去處理,譬如上例中,如果我們傳入一個合法的JSON字串,但其實內容跟Item沒有甚麼關係,我們其實不會收到error message,因為interface{}甚麼都可以收–但是我們的item變數會保留原本的預設值(default value)而不是輸入的值,所以,雖然我們不需管型態轉換及型態映射的問題處理,我們還是需要留心傳入的JSON是一個Item type的內容。但目前就是這樣,沒有一個decoder不使用interface{}。

上述使用interface{}造成的問題的根源,其實是因為我們將golang這個靜態型別語言(statically typed)當動態型別語言(dynamically typed)來使用,這個問題當我們看一些不好的interface{}實作會更清楚,最一般的例子來自於開發者試圖實作一個泛型的list或map。讓我們來看看以下這個使用interface{}來存放任意型態的物件的Hashmap的例子:

type HashMap struct {
    store map[string]interface{}
}

func (hashmap *HashMap) Insert(key string, value interface{}) {
    hashmap.store[key] = value
}

func (hashmap *HashMap) Get(key string) (interface{}, error) {
    value, ok := hashmap.store[key]
    if !ok {
        return nil, ErrKeyNotFoundInHashMap
    }
    return value
}

此段程式為了精簡及表達重點,因此沒有加入thread safe的部分

請知曉上面的這個模式其實出現在很多Go的package,包括在標準函示庫如sync中的sync.Map,那,這有甚麼問題呢?我們來看看使用上面這段code使用的一個例子:

func SomeFunction(id string) (Item, error) {
    itemIface, err := hashmap.Get(id)
    if err != nil {
        return EmptyItem, err
    }
    item, ok := itemIface.(Item)
    if !ok {
        return EmptyItem, ErrCastingItem
    }
    return item, nil
}

第一眼看過去,沒甚麼問題,但是,當我們增加不同的type在我們的store中,會產生問題,注意,我們並不能阻止insert不同的type到我們的hashmap的store中,而當有人這麼做了,例如insert 一個指標(例如*Item)到store中,那我們的上面這段code就出問題了,有時候,像這樣的問題甚至在我們的測試中都不一定抓的到。根據不同規模的系統,這樣的情況就可能產生那種特別難抓到的bug。

像這樣的code永遠都不應該出現在正式環境,記得:Go目前尚沒有支援泛型,這是工程師們必須接受的現狀,如果我們真的一定要用泛型,應該考慮用其他的語言,而不是用創意但危險的方式來達成。

那要怎麼不讓上面的code出現在正式環境?最簡單的解法是寫一個使用具體型態而不是使用interface{}的函數,當然,這不總是最優的解法,因為難免有些package中的功能我們不是那麼容易靠自己重新實作。因此,一個比較好的方式也許是建立一個包裝函數(wrapper),達成我們要的功能同時保障型別安全:

type ItemCache struct {
  kv tinykv.KV
} 

func (cache *ItemCache) Get(id string) (Item, error) {
  value, ok := cache.kv.Get(id)
  if !ok {
    return EmptyItem, ErrItemNotFound
  }
  return interfaceToItem(value)
}

func interfaceToItem(v interface{}) (Item, error) {
  item, ok := v.(Item)
  if !ok {
    return EmptyItem, ErrCouldNotCastItem
  }
  return item, nil
}

func (cache *ItemCache) Put(id string, item Item) error {
  return cache.kv.Put(id, item)
}

注意上述的包裝函數,這樣做以後保障我們使用對的type且不會直接使用interface{}來傳入,也因此保障了我們不會不小心讓我們的store中有其他type的值,同時取得值時也盡量限制了型態轉換。這是很直覺的做法,雖然有點手動的感覺。

Summary

首先,謝謝你們一路讀到了這邊,希望這篇文章能提供一些乾淨程式碼的概念並幫助到你的程式可讀、可維護、並穩定。

讓我們很快的看一下我們介紹過的議題:

  • Functions—一個函數名應該反映它的範圍,越小的範圍,函數名應該越特定;確保每個函數處理一個單一的目的,並盡可能的短,一個簡單的法則是一個函數應該在5-8行,且應該只有最多2-3各輸入參數。
  • Variables—跟函數相反,變數的作用域越小時可以取普通的名或縮寫,同時也建議應該限制變數的作用域越小越好,以免不小心改到。同時,應該盡量減少修改變數,尤其當變數的作用域越大時。
  • Return Values—應該盡量返回具體的物件(Concrete types),盡量讓你package的使用者無法犯錯,且盡量讓他們容易理解你函數的返回值。
  • Pointers—使用指標時要小心,限制指標的作用域及可變性,記得: Garbage collection主要幫助的是記憶體管理,它並不會去處理複雜的指標問題。
  • Interfaces—盡量多使用介面以達成程式的鬆耦合;用包裝函是將任何使用空介面(interface{})的函數包裝起來以避免使用者犯下型別相關的問題。

最後,乾淨的程式碼其實是很主觀的,但一個共通的標準可能比大家都同意這是最佳的更為重要,畢竟很難大家都同意所謂最佳的作法。同時,我們也要了解狂熱的要求clean code從來不是我們的目標,大部分的程式永遠都不會是完全的clean的,永遠也會有模糊地帶及空間違反這篇文章所說的,但是,請記得寫乾淨的程式碼最重要的理由是幫助你自己及其他工程師,以確保我們出產的程式的穩定性及讓debug容易些,我們藉由讓我們的程式碼易讀、易懂、易消化來幫助我們自己及協力工程師,我們也幫助整個程式專案的人員容易新增功能而不會搞掛整個系統。讓我們一起建立乾淨的程式碼的文化,不僅為了自己也為了每個人。