Go 使用 interface 時(shí)的 7 個(gè)常見錯(cuò)誤
共 7264字,需瀏覽 15分鐘
·
2024-07-01 19:51
寫在正文之前
閱讀本文之前我們來先熟悉以下的代碼原則,如果你已經(jīng)很熟悉這些內(nèi)容,可以直接跳到正文。
-
接口隔離原則:絕不能強(qiáng)迫客戶實(shí)現(xiàn)其不使用的接口,也不能強(qiáng)迫客戶依賴其不使用的方法。 -
多態(tài)性:代碼變化會(huì)根據(jù)接收到的具體數(shù)據(jù)改變其行為。 -
里氏替換原則:如果你的代碼依賴于一個(gè)抽象概念,那么一個(gè)實(shí)現(xiàn)可以被另一個(gè)實(shí)現(xiàn)所替代,而無需更改你的代碼。
抽象的目的不是為了含糊不清,而是為了創(chuàng)造一個(gè)新的語義層次,在這個(gè)層次上,我們可以做到絕對精確。- E.W.Dijkstra
有機(jī)代碼是根據(jù)您在某一時(shí)刻所需的行為而增長的代碼。它不會(huì)強(qiáng)迫你提前考慮類型以及它們之間的關(guān)系,因?yàn)槟愫芸赡軣o法正確地處理它們。這就是為什么說 Go 更傾向于組合而非繼承。與預(yù)先定義由其他類型繼承的類型并希望它們適合問題領(lǐng)域的做法相比,你有一小套行為,可以從中組合出任何你想要的東西。
理論講得夠多了,讓我們開始正文,看下使用 interface 的時(shí)候最常犯的錯(cuò)誤:
too many interfaces
擁有過多接口的術(shù)語叫做接口污染。當(dāng)你在編寫具體類型之前就開始抽象時(shí),就會(huì)出現(xiàn)這種情況。由于無法預(yù)知需要哪些抽象,因此很容易編寫出過多的接口,而這些接口在日后要么是錯(cuò)誤的,要么是無用的。
Rob Pike 有一個(gè)很好建議,可以幫助我們避免接口污染:
Don’t design with interfaces, discover them. Rob Pike
Rob 在這里指出的是,你不需要提前考慮你需要什么樣的抽象。您可以從具體的結(jié)構(gòu)開始設(shè)計(jì),只有在設(shè)計(jì)需要時(shí)才創(chuàng)建接口。這樣,你的代碼就會(huì)按照預(yù)期的設(shè)計(jì)有機(jī)地發(fā)展。
接口是有代價(jià)的:它是一個(gè)新的概念,你在推理代碼時(shí)需要記住它。正如 Djikstra 所說,理想的接口必須是 "一個(gè)新的語義層次,在這個(gè)層次上,人們可以絕對精確"。
因此,在創(chuàng)建接口之前,先問問自己:你需要這么多接口嗎?
too many methods
在 PHP 項(xiàng)目中,10 個(gè)方法的接口是很常見的。在 Go 中,接口的數(shù)量很少,標(biāo)準(zhǔn)庫中所有接口的平均方法數(shù)量為 2 個(gè)。
The bigger the interface the weaker the abstraction,接口越大,抽象越弱,這實(shí)際上是 Go 的諺語之一。正如 Rob Pike 所說,這是接口最重要的一點(diǎn),這意味著接口越小越有用。
接口的實(shí)現(xiàn)越多,通用性就越強(qiáng)。如果一個(gè)接口有一大堆方法,就很難有多個(gè)實(shí)現(xiàn)。方法越多,接口就越具體。接口越具體,不同類型顯示相同行為的可能性就越低。
io.Reader 和 io.Writer 就是有用接口的一個(gè)很好的例子,它們有數(shù)以百計(jì)的實(shí)現(xiàn)。或者是 error 接口,它非常強(qiáng)大,可以在 Go 中實(shí)現(xiàn)整個(gè)錯(cuò)誤處理。
我們可以用其他接口組成一個(gè)接口。例如,這里的 ReadWriteCloser 由 3 個(gè)較小的接口組成:
type ReadWriteCloser interface {
Reader
Writer
Closer
}
非行為驅(qū)動(dòng)的接口
在傳統(tǒng)語言中,諸如 User(用戶)、Request(請求)等名詞性接口非常常見。而在 Go 語言中,大多數(shù)接口都有 er 后綴:Reader、Writer、Closer 等。這是因?yàn)椋?Go 中,接口暴露了行為,而它們的名稱則指向該行為。
在 Go 中定義接口時(shí),你定義的不是 "某物是什么",而是 "某物提供了什么"--是 "行為",而不是 "事物"!這就是為什么 Go 中沒有 File 接口,但有 Reader 和 Writer:這些都是行為,而 File 是實(shí)現(xiàn) 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.
在編寫接口時(shí),盡量考慮動(dòng)作或行為。如果你定義了一個(gè)名為 "Thing "的接口,問問自己為什么這個(gè) "Thing "不是一個(gè)結(jié)構(gòu)體 ??。
producer 端實(shí)現(xiàn)接口
經(jīng)常在 code review 中看到這種情況:人們在寫具體實(shí)現(xiàn)的同一個(gè)包中定義接口:
但是,也許客戶并不想使用生產(chǎn)者接口中的所有方法。請記住 "接口隔離原則 "中的一句話:"不應(yīng)強(qiáng)迫客戶端實(shí)現(xiàn)其不使用的方法"。下面是一個(gè)例子:
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)
}
如果客戶想使用生產(chǎn)者的所有方法,可以使用具體的結(jié)構(gòu)體。結(jié)構(gòu)體方法已經(jīng)提供了這些行為。
即使客戶想要解耦代碼并使用多種實(shí)現(xiàn)方法,他仍然可以在自己這邊創(chuàng)建一個(gè)包含所有方法的接口:
由于 Go 中的接口是隱式實(shí)現(xiàn)的,所以可以這樣實(shí)現(xiàn)。客戶端代碼不再需要導(dǎo)入某個(gè)接口并編寫實(shí)現(xiàn),因?yàn)?Go 中沒有這樣的關(guān)鍵字。如果實(shí)現(xiàn)(Implementation)與接口(Interface)有相同的方法,那么實(shí)現(xiàn)(Implementation)就已經(jīng)滿足了該接口,可以在客戶代碼中使用。
返回接口
如果一個(gè)方法返回的是接口而不是具體的結(jié)構(gòu),那么所有調(diào)用該方法的客戶端都會(huì)被迫使用相同的抽象。你需要讓客戶決定他們需要什么樣的抽象。
當(dāng)你想使用結(jié)構(gòu)體中的某項(xiàng)功能時(shí),卻因?yàn)榻涌诓还_而無法使用,這是很惱人的。這種限制可能是有原因的,但并非總是如此。下面是一個(gè)人為的例子:
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,而且每次要訪問它時(shí)都需要在代碼中添加類型斷言:
shape := NewCircle(5)
if circle, ok := shape.(Circle); ok {
fmt.Println(circle.Radius)
}
Dave Cheney 寫的 Practical Go 一書中的一個(gè)例子很有說服力:
// Save writes the contents of doc to the file f.
func Save(f *os.File, doc *Document) error
可以改進(jìn)為:
// Save writes the contents of doc to the supplied
// Writer.
func Save(w io.Writer, doc *Document) error
粹為測試而創(chuàng)建接口
接口污染的另一個(gè)原因是:僅僅因?yàn)橄肽M一個(gè)實(shí)現(xiàn),就創(chuàng)建一個(gè)只有一個(gè)實(shí)現(xiàn)的接口。
如果通過創(chuàng)建許多模擬來濫用接口,最終測試的將是生產(chǎn)中從未使用過的模擬,而不是應(yīng)用程序的實(shí)際邏輯。在您的實(shí)際代碼中,您現(xiàn)在有兩個(gè)概念(如 Djikstra 所說的語義層),而一個(gè)概念就可以了。而這只是為了測試你想要測試的東西。難道你想在每次創(chuàng)建新測試時(shí)都將語義級別加倍嗎?可以使用 testcontainers 來代替模擬數(shù)據(jù)庫。如果 testcontainers 不支持,也可以使用自己的容器。
沒有驗(yàn)證接口的兼容性
比方說,你有一個(gè)導(dǎo)出名為 User 的類型的軟件包,你實(shí)現(xiàn)了 Stringer 接口,因?yàn)槌鲇谀撤N原因,當(dāng)你打印時(shí),你不希望顯示電子郵件:
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: "[email protected]",
}
fmt.Printf("%s", u)
}
現(xiàn)在,假設(shè)你進(jìn)行了重構(gòu),不小心刪除或注釋了 String() 的實(shí)現(xiàn),你的代碼看起來就像這樣:
package users
type User struct {
Name string
Email string
}
在這種情況下,您的代碼仍然可以編譯和運(yùn)行,但輸出結(jié)果將是 {John Doe [email protected]}。沒有任何反饋執(zhí)行你之前的意圖。當(dāng)你的方法接受 User 時(shí),編譯器會(huì)幫助你,但在上述情況下,編譯器不會(huì)幫助你。
要強(qiáng)制執(zhí)行某個(gè)類型實(shí)現(xiàn)了某個(gè)接口,我們可以這樣做:
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
}
現(xiàn)在,如果我們刪除 String() 方法,就會(huì)在構(gòu)建時(shí)得到如下結(jié)果:
cannot use User{} (value of type User) as fmt.Stringer value in variable declaration: User does not implement fmt.Stringer (missing method String)
在該行中,我們試圖將一個(gè)空的 User{} 賦值給一個(gè) fmt.Stringer 類型的變量。由于 User{} 不再實(shí)現(xiàn) fmt.Stringer,我們收到了投訴。我們在變量名中使用了 _,因?yàn)槲覀儾]有真正使用它,所以不會(huì)進(jìn)行分配。
上面我們看到用戶實(shí)現(xiàn)了界面。User 和 *User 是不同的類型。因此,如果你想讓 *User 實(shí)現(xiàn)它,你可以這樣做:
var _ fmt.Stringer = (*User)(nil) // *User implements the fmt.Stringer
凡事沒有絕對,我們在寫代碼時(shí)還是要具體情況具體分析,本文只是分享一些通識,歡迎大家廣開討論。
Effective Go: https://go.dev/doc/effective_go
