Go 使用 interface 時的 7 個常見錯誤

寫在正文之前

閱讀本文之前我們來先熟悉以下的代碼原則,如果你已經很熟悉這些內容,可以直接跳到正文。

抽象的目的不是爲了含糊不清,而是爲了創造一個新的語義層次,在這個層次上,我們可以做到絕對精確。- E.W.Dijkstra

有機代碼是根據您在某一時刻所需的行爲而增長的代碼。它不會強迫你提前考慮類型以及它們之間的關係,因爲你很可能無法正確地處理它們。這就是爲什麼說 Go 更傾向於組合而非繼承。與預先定義由其他類型繼承的類型並希望它們適合問題領域的做法相比,你有一小套行爲,可以從中組合出任何你想要的東西。

理論講得夠多了,讓我們開始正文,看下使用 interface 的時候最常犯的錯誤:

too many interfaces

擁有過多接口的術語叫做接口污染。當你在編寫具體類型之前就開始抽象時,就會出現這種情況。由於無法預知需要哪些抽象,因此很容易編寫出過多的接口,而這些接口在日後要麼是錯誤的,要麼是無用的。

Rob Pike 有一個很好建議,可以幫助我們避免接口污染:

Don’t design with interfaces, discover them. Rob Pike

Rob 在這裏指出的是,你不需要提前考慮你需要什麼樣的抽象。您可以從具體的結構開始設計,只有在設計需要時才創建接口。這樣,你的代碼就會按照預期的設計有機地發展。

接口是有代價的:它是一個新的概念,你在推理代碼時需要記住它。正如 Djikstra 所說,理想的接口必須是 "一個新的語義層次,在這個層次上,人們可以絕對精確"。

因此,在創建接口之前,先問問自己:你需要這麼多接口嗎?

too many methods

在 PHP 項目中,10 個方法的接口是很常見的。在 Go 中,接口的數量很少,標準庫中所有接口的平均方法數量爲 2 個。

The bigger the interface the weaker the abstraction, 接口越大,抽象越弱,這實際上是 Go 的諺語之一。正如 Rob Pike 所說,這是接口最重要的一點,這意味着接口越小越有用。

接口的實現越多,通用性就越強。如果一個接口有一大堆方法,就很難有多個實現。方法越多,接口就越具體。接口越具體,不同類型顯示相同行爲的可能性就越低。

io.Reader 和 io.Writer 就是有用接口的一個很好的例子,它們有數以百計的實現。或者是 error 接口,它非常強大,可以在 Go 中實現整個錯誤處理。

我們可以用其他接口組成一個接口。例如,這裏的 ReadWriteCloser 由 3 個較小的接口組成:

type ReadWriteCloser interface {
 Reader
 Writer
 Closer
}

非行爲驅動的接口

在傳統語言中,諸如 User(用戶)、Request(請求)等名詞性接口非常常見。而在 Go 語言中,大多數接口都有 er 後綴:Reader、Writer、Closer 等。這是因爲,在 Go 中,接口暴露了行爲,而它們的名稱則指向該行爲。

在 Go 中定義接口時,你定義的不是 "某物是什麼",而是 "某物提供了什麼"-- 是 "行爲",而不是 "事物"!這就是爲什麼 Go 中沒有 File 接口,但有 Reader 和 Writer:這些都是行爲,而 File 是實現 Reader 和 Writer 的事物。

在 Effective Go[1] 中也有提到過:

Interfaces in Go provide a way to specify the behavior of an object: if something can do this, then it can be used here.

在編寫接口時,儘量考慮動作或行爲。如果你定義了一個名爲 "Thing" 的接口,問問自己爲什麼這個 "Thing" 不是一個結構體 😁。

producer 端實現接口

經常在 code review 中看到這種情況:人們在寫具體實現的同一個包中定義接口:

但是,也許客戶並不想使用生產者接口中的所有方法。請記住 "接口隔離原則" 中的一句話:"不應強迫客戶端實現其不使用的方法"。下面是一個例子:

package main

// ====== producer side

// This interface is not needed
type UsersRepository interface {
    GetAllUsers()
    GetUser(id string)
}

type UserRepository struct {
}

func (UserRepository) GetAllUsers()      {}
func (UserRepository) GetUser(id string) {}

// ====== client side

// Client only needs GetUser and
// can create this interface implicitly implemented
// by concrete UserRepository on his side 
type UserGetter interface {
    GetUser(id string)
}

如果客戶想使用生產者的所有方法,可以使用具體的結構體。結構體方法已經提供了這些行爲。

即使客戶想要解耦代碼並使用多種實現方法,他仍然可以在自己這邊創建一個包含所有方法的接口:

由於 Go 中的接口是隱式實現的,所以可以這樣實現。客戶端代碼不再需要導入某個接口並編寫實現,因爲 Go 中沒有這樣的關鍵字。如果實現(Implementation)與接口(Interface)有相同的方法,那麼實現(Implementation)就已經滿足了該接口,可以在客戶代碼中使用。

返回接口

如果一個方法返回的是接口而不是具體的結構,那麼所有調用該方法的客戶端都會被迫使用相同的抽象。你需要讓客戶決定他們需要什麼樣的抽象。

當你想使用結構體中的某項功能時,卻因爲接口不公開而無法使用,這是很惱人的。這種限制可能是有原因的,但並非總是如此。下面是一個人爲的例子:

package main

import "math"

type Shape interface {
    Area() float64
    Perimeter() float64
}

type Circle struct {
    Radius float64
}

func (c Circle) Area() float64 {
    return math.Pi * c.Radius * c.Radius
}

func (c Circle) Perimeter() float64 {
    return 2 * math.Pi * c.Radius
}

// NewCircle returns an interface instead of struct
func NewCircle(radius float64) Shape {
    return Circle{Radius: radius}
}

func main() {
    circle := NewCircle(5)

    // we lose access to circle.Radius
}

在上面的示例中,我們不僅無法訪問 circle.Radius,而且每次要訪問它時都需要在代碼中添加類型斷言:

shape := NewCircle(5)

if circle, ok := shape.(Circle); ok {
    fmt.Println(circle.Radius)
}

Dave Cheney 寫的 Practical Go 一書中的一個例子很有說服力:

// Save writes the contents of doc to the file f.
func Save(f *os.File, doc *Document) error

可以改進爲:

// Save writes the contents of doc to the supplied
// Writer.
func Save(w io.Writer, doc *Document) error

粹爲測試而創建接口

接口污染的另一個原因是:僅僅因爲想模擬一個實現,就創建一個只有一個實現的接口。

如果通過創建許多模擬來濫用接口,最終測試的將是生產中從未使用過的模擬,而不是應用程序的實際邏輯。在您的實際代碼中,您現在有兩個概念(如 Djikstra 所說的語義層),而一個概念就可以了。而這只是爲了測試你想要測試的東西。難道你想在每次創建新測試時都將語義級別加倍嗎?可以使用 testcontainers 來代替模擬數據庫。如果 testcontainers 不支持,也可以使用自己的容器。

沒有驗證接口的兼容性

比方說,你有一個導出名爲 User 的類型的軟件包,你實現了 Stringer 接口,因爲出於某種原因,當你打印時,你不希望顯示電子郵件:

package users

type User struct {
    Name  string
    Email string
}

func (u User) String() string {
    return u.Name
}

客戶端的代碼如下:

package main

import (
    "fmt"

    "pkg/users"
)

func main() {
    u := users.User{
       Name:  "John Doe",
       Email: "john.doe@gmail.com",
    }
    fmt.Printf("%s", u)
}

現在,假設你進行了重構,不小心刪除或註釋了 String() 的實現,你的代碼看起來就像這樣:

package users

type User struct {
    Name  string
    Email string
}

在這種情況下,您的代碼仍然可以編譯和運行,但輸出結果將是 {John Doe john.doe@gmail.com}。沒有任何反饋執行你之前的意圖。當你的方法接受 User 時,編譯器會幫助你,但在上述情況下,編譯器不會幫助你。

要強制執行某個類型實現了某個接口,我們可以這樣做:

package users

import "fmt"

type User struct {
    Name  string
    Email string
}

var _ fmt.Stringer = User{} // User implements the fmt.Stringer

func (u User) String() string {
    return u.Name
}

現在,如果我們刪除 String() 方法,就會在構建時得到如下結果:

cannot use User{} (value of type User) as fmt.Stringer value in variable declaration: User does not implement fmt.Stringer (missing method String)

在該行中,我們試圖將一個空的 User{} 賦值給一個 fmt.Stringer 類型的變量。由於 User{} 不再實現 fmt.Stringer,我們收到了投訴。我們在變量名中使用了 _,因爲我們並沒有真正使用它,所以不會進行分配。

上面我們看到用戶實現了界面。User 和 *User 是不同的類型。因此,如果你想讓 *User 實現它,你可以這樣做:

var _ fmt.Stringer = (*User)(nil) // *User implements the fmt.Stringer

凡事沒有絕對,我們在寫代碼時還是要具體情況具體分析,本文只是分享一些通識,歡迎大家廣開討論。

參考資料

[1]

Effective Go: https://go.dev/doc/effective_go

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