如何在 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 中定義的。
在我的短暫經歷中…
這一模式比前幾種更難執行。開發初期,客戶端包的需求快速演變,導致頻繁修改生產者包。如果返回類型是接口,它可能逐漸變得過於龐大,最終返回具體類型反而更合理。
我認爲該模式的運作機制是:
-
返回的接口必須足夠小,纔能有多種實現。
-
除非包內有多個類型僅實現該接口,否則不要急於返回接口。多類型相同行爲簽名能驗證抽象的正確性。
「考慮創建一個僅包含接口的獨立包以統一命名空間和標準化」
雖然這不是 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
}
將接口移到單獨的包中,而不是在子目錄中暴露,可能有兩個好處:
-
爲接口提供更好的命名空間。hash.Hash 比 adler32.Hash 更容易理解。
-
標準化功能的實現方式。一個僅包含接口的獨立包暗示了哈希函數應具有 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