Go 語言中的接口暴露

【導讀】golang 編程中,接口怎麼設計、怎麼寫?接口設計方面有什麼社區推薦的方法和思路?本文作者結合實踐經驗做了詳細介紹。

接口是 Go 語言裏我最喜歡的特性。一個接口類型代表一組方法。與其他大多數語言不同,你不必明確聲明一個類型實現了一個接口。如果一個結構體S定義了I所要求的方法,它就隱含地實現了接口I

把接口寫得好並不容易。通過暴露廣泛的或不必要的接口,很容易污染包的 "API"。在這篇文章中,我們將解釋現有的接口指南背後的道理,並以標準庫中的例子作爲補充。

接口越大,抽象性越弱

你不太可能找到多個可以實現一個大型接口的類型。由於這個原因,"只有一到兩個方法的接口在 Go 代碼中很常見"。與其聲明大型公共接口,不如考慮使用依賴或是返回一個確定類型。

io.Readerio.Writer接口是強壯接口的比較好的例子:

type Reader interface {
 Read([]byte) (n int, err error)
}

檢索了 std 庫之後,我看到有 30 個包內 81 個結構體實現了io.Reader,被 99 個方法在 39 個包裏調用到。

Go 接口一般屬於使用接口類型值的包,而不是實現這些值的包

在實際使用接口的包內定義接口,這樣可以讓使用方定義抽象,而不是讓提供接口的一方規定接口的所有抽象。io.Copy方法就是個例子,它在可以接收WriterReader接口:

func Copy(dst Writer, src Reader) (written int64, err error)

另一個包裏的例子是color.Color接口。color.Palette類型的Index方法就是可以接收任意實現了Color接口的結構體:

func (p Palette) Index(c Color) int

如果一個類型的存在只是爲了實現一個接口,並且永遠不會有超出該接口暴露方法,那麼就沒有必要暴露該類型本身

這條原則在代碼 review(https://github.com/golang/go/wiki/CodeReviewComments#interfaces)一文中提及:

實現包應該返回具體的(通常是指針或結構)類型:這樣一來,新的方法可以被添加到實現中,而不需要大量的重構。

配合EffectiveGo中的說明,我們可以看到在提供方的包內定義一個接口的全貌。

如果一個類型的存在只是爲了實現一個接口,並且永遠不會有超出該接口暴露方法,那麼就沒有必要暴露該類型本身。

rand.Source接口是一個例子,它會作爲rand.NewSource方法的返回值使用,返回這個接口。其中的 rngSource 結構體(https://github.com/golang/go/blob/dcd3b2c173b77d93be1c391e3b5f932e0779fb1f/src/math/rand/rng.go#L180)只暴露了SourceSource64所需的方法,因此不再需要暴露這個結構體。

返回一個接口而不是返回某個類型對象,這樣做有什麼好處?

返回一個接口的做法能讓你在函數里返回多個實際類型。比如aes.NewSiper這個構造函數返回了cipher.Block接口,仔細看這個方法內有兩個實際的類型作爲返回值:

func newCipher(key []byte) (cipher.Block, error) {
  ...
  c := aesCipherAsm{aesCipher{make([]uint32, n), make([]uint32, n)}}
  ...
  if supportsAES && supportsGFMUL {
    // Returned type is aesCipherGCM.
    return &aesCipherGCM{c}, nil
  }
  // Returned type is aesCipherAsm.
  return &c, nil
}

注意前面的rand例子裏,接口就是在提供方包rand內定義的。但是這個例子內接口跑到了另一個ciper包裏定義。

在我的實踐中,在不同包中定義接口比在同一個包中定義接口要難維護得多。早期開發過程中對調用方的需求會快速迭代,迭代也會體現到定義方包的代碼裏。如果返回值只是個 interface,慢慢地需求可能就會變成需要返回更加明確的類型了。

我假設比較好的實踐是這樣的:

  1. 返回的接口需要足夠小,這樣就可以返回多個實現的實際類型。

  2. 在你的包裏有多個類型只實現了這個接口之前,暫不返回 interface。

創建一個單獨的接口專用包,實現命名和標準化。

注意這並不是 Go 語言團隊推薦的做法,這是我的一個觀察結果。在標準庫裏有很多創建一個包只放接口的做法。

hash.Hash接口就是一個例子,它就是一隻暴露接口的包:https://golang.org/pkg/hash/

package hash

type Hash interface {
  ...
}

type Hash32 interface {
 Hash
 Sum32() uint32
}

type Hash64 interface {
 Hash
 Sum64() uint64
}

我認爲把接口放進一個獨立包有以下兩方面原因:

  1. 給接口提供一個更好的命名空間。

  2. 把實現功能這件事標準化。一個只有接口的獨立包暗示着哈希函數應該有hash.Hash接口所要求的方法。

另一個例子是encoding接口:

package encoding

type BinaryMarshaler interface {
    MarshalBinary() (data []byte, err error)
}

type BinaryUnmarshaler interface {
    UnmarshalBinary(data []byte) error
}

type TextMarshaler interface {
    MarshalText() (text []byte, err error)
}

type TextUnmarshaler interface {
    UnmarshalText(text []byte) error
}

在 std 庫中有很多實現了encoding接口的結構體。與被crypto/下的包使用的 hash 接口不同,std 庫中沒有接受或返回編碼接口的函數。

那麼爲什麼還要暴露這個接口呢?

我認爲這是encoding包想要給開發者提供一種比較標準化的接口定義方式,希望所有序列化反序列化都如此實現。如果一個新的結構實現了該接口,下面這種寫法判斷是否實現了encoding.BinaryMarshaler的邏輯都不需要改變其實現:

if m, ok := v.(encoding.BinaryMarshaler); ok {
    return m.MarshalBinary()
}

值得注意的是,在compress/zlib和 compress/flate軟件包中的Resetter接口沒有遵循這個模式,因爲它在這兩個軟件包中是重複的。關於這點 Go 語言維護者也有過相關討論(https://codereview.appspot.com/97140043#msg27)。

私有接口不需要考慮前面提到的這些問題,反正這些接口也不會暴露出來

我們可以寫更大的接口而不用擔心其內容,比如encoding/gob包的gobType。接口可以在不同的包中重複使用,比如同時存在於os包和net包中的timeout接口,而不用考慮把它們放在一個單獨的位置。

經驗之談

建議最後再寫接口,這時候一般你會對接口有一個比較好的理解和抽象的設計。

對於一個方法提供方,一個好的信號就是多個類型都實現了同一組方法,這時候重構出一個接口就很合適。對於接口的實現方,要保持接口足夠小、足以讓多種結構體都實現這些方法。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/mdWw0YYEWQH_9uoj_dsSEg