如何在 Go 中設計並公開接口

Go 語言中的接口(interface)是其最具特色的功能之一。與許多其他語言不同,在 Go 中,類型不需要顯式聲明實現某個接口。只要一個類型定義了接口所需的方法,它就自動實現了該接口。

然而,編寫良好的接口並不容易。不恰當地暴露寬泛或不必要的接口,容易污染包的 API。本文將解釋現有接口設計準則背後的邏輯,並結合標準庫中的示例進行說明。

「接口越大,抽象越弱」

大型接口往往難以找到多個實現類型。因此,Go 代碼中常見的接口通常只包含一兩個方法。與其聲明大型公共接口,不如依賴或返回具體類型。

例如,io.Reader 和 io.Writer 是強大接口的典範:

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

在標準庫中,有 81 個結構體實現了 io.Reader 接口,分佈在 30 個包中;有 99 個方法或函數在 39 個包中使用了該接口。

「接口應定義在使用它們的包中,而非實現它們的包」

在真正使用接口的包中定義接口,可以讓客戶端定義抽象,而不是由提供者強行規定所有客戶端的抽象。

例如,io.Copy 函數接受 Writer 和 Reader 接口作爲參數,這些接口都在同一個包中定義:

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

「如果一個類型僅用於實現接口,且不會有超出該接口的導出方法,則無需導出該類型本身」

根據 CodeReviewComments 的指南:

實現包應返回具體類型(通常是指針或結構體):這樣可以在不需要大量重構的情況下向實現中添加新方法。

結合 EffectiveGo 的說法,我們可以全面理解,在生產者包中定義接口是可以接受的:

如果一個類型僅用於實現接口,且不會有超出該接口的導出方法,則無需導出該類型本身。

例如,rand.Source 接口由 rand.NewSource 返回。構造函數中的底層結構體 rngSource 僅導出 Source 和 Source64 接口所需的方法,因此該類型本身未被導出。

package rand

type Source interface {
 Int63() int64
 Seed(seed int64)
}

func NewSource(seed int64) Source {
 return newSource(seed)
}

func newSource(seed int64) *rngSource {
 var rng rngSource
 rng.Seed(seed)
 return &rng
}

rand 包還有兩個實現該接口的類型:lockedSource 和 Rand(後者被導出,因爲它有其他公共方法)。

那麼返回接口而不是具體類型有什麼好處呢?

返回接口允許函數返回多個具體類型。例如,aes.NewCipher 構造函數返回 cipher.Block 接口。如果查看構造函數內部,可以看到返回了兩種不同的結構體:

func newCipher(key []byte) (cipher.Block, error) {
 ...
 c := aesCipherAsm{aesCipher{make([]uint32, n), make([]uint32, n)}}
 ...
 if supportsAES && supportsGFMUL {
  // 返回類型是 aesCipherGCM。
  return &aesCipherGCM{c}, nil
 }
 // 返回類型是 aesCipherAsm。
 return &c, nil
}

需要注意的是,在前面的例子中,接口是在生產者包 rand 中定義的。然而在這個例子中,返回的類型是在另一個包 cipher 中定義的。

在我的短暫經歷中…

這一模式比前幾種更難執行。開發初期,客戶端包的需求快速演變,導致頻繁修改生產者包。如果返回類型是接口,它可能逐漸變得過於龐大,最終返回具體類型反而更合理。

我認爲該模式的運作機制是:

  1. 返回的接口必須足夠小,纔能有多種實現。

  2. 除非包內有多個類型僅實現該接口,否則不要急於返回接口。多類型相同行爲簽名能驗證抽象的正確性。

「考慮創建一個僅包含接口的獨立包以統一命名空間和標準化」

雖然這不是 Go 團隊的官方指南,但在標準庫中,包含僅接口的包是一種常見模式。

例如,hash.Hash 接口由 hash/ 子目錄下的包(如 hash/crc32 和 hash/adler32)實現。hash 包僅暴露接口:

package hash

type Hash interface {
 ...
}

type Hash32 interface {
 Hash
 Sum32() uint32
}

type Hash64 interface {
 Hash
 Sum64() uint64
}

將接口移到單獨的包中,而不是在子目錄中暴露,可能有兩個好處:

  1. 爲接口提供更好的命名空間。hash.Hash 比 adler32.Hash 更容易理解。

  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
}

標準庫中有許多結構體實現了這些 encoding 接口。然而,與 hash 不同,標準庫中沒有函數接受或返回 encoding 接口。

那麼爲什麼要暴露它們呢?

可能是爲了向開發者提示二進制序列化的標準方法簽名。如果一個新結構體實現了 encoding.BinaryMarshaler 接口,現有的包在測試值是否實現該接口時,無需更改其實現:

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

值得注意的是,這種模式並未在 compress/zlib 和 compress/flate 包中的 Resetter 接口中遵循,因爲它在兩個包中都被重複定義。然而,這似乎是 Go 維護者之間討論的一個話題。

最後,私有接口無需處理上述考慮,因爲它們不會被暴露。我們可以擁有較大的接口,例如 encoding/gob 包中的 gobType,而無需擔心其內容。接口可以在多個包中重複,例如 os 和 net 包中都存在的 timeout 接口,而無需考慮將它們放置在單獨的位置。

type timeout interface {
 Timeout() bool
}

本文翻譯自《Exposing interfaces in Go》,並在此基礎上進行了總結與概括。如需瞭解更多細節,歡迎查閱原文:https://www.efekarakus.com/golang/2019/12/29/working-with-interfaces-in-go.html


References
https://www.efekarakus.com/golang/2019/12/29/working-with-interfaces-in-go.html

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