本文參與了思否技術徵文,歡迎正在閱讀的你也加入。
前言
這是Go常見錯誤系列的第15篇:interface使用的常見錯誤和最佳實踐。
素材來源於Go佈道者,現Docker公司資深工程師Teiva Harsanyi。
本文涉及的原始碼全部開源在:Go常見錯誤原始碼,歡迎大家關注公眾號,及時獲取本系列最新更新。
常見錯誤和最佳實踐
interface是Go語言裡的核心功能,但是在日常開發中,經常會出現interface被亂用的情況,程式碼過度抽象,或者抽象不合理,導致程式碼晦澀難懂。
本文先帶大家回顧下interface的重要概念,然後講解使用interface的常見錯誤和最佳實踐。
interface重要概念回顧
interface裡面包含了若干個方法,大家可以理解為一個interface代表了一類群體的共同行為。
結構體要實現interface不需要類似implement的關鍵字,只要該結構體實現了interface裡的所有方法即可。
我們拿Go語言裡的io標準庫來說明interface的強大之處。io標準庫包含了2個interface:
- io.Reader:表示從某個資料來源讀資料
- io.Writer:表示寫資料到目標位置,比如寫到指定檔案或者資料庫
Figure 2.3 io.Reader reads from a data source and fills a byte slice, whereas io.Writer writes to a target from a byte slice.
io.Reader這個interface裡只有一個Read方法:
type Reader interface {
Read(p []byte) (n int, err error)
}
Read reads up to len(p) bytes into p. It returns the number of bytes read (0 <= n <= len(p)) and any error encountered. Even if Read returns n < len(p), it may use all of p as scratch space during the call. If some data is available but not len(p) bytes, Read conventionally returns what is available instead of waiting for more.
如果某個結構體要實現io.Reader,需要實現Read方法。這個方法要包含以下邏輯:
- 入參:接受元素型別為byte的slice作為方法的入參。
- 方法邏輯:把Reader物件裡的資料讀出來賦值給p。比如Reader物件可能是一個strings.Reader,那呼叫Read方法就是把string的值賦值給p。
- 返回值:要麼返回讀到的位元組數,要麼返回error。
io.Writer這個interface裡只有一個Write方法:
type Writer interface {
Write(p []byte) (n int, err error)
}
Write writes len(p) bytes from p to the underlying data stream. It returns the number of bytes written from p (0 <= n <= len(p)) and any error encountered that caused the write to stop early. Write must return a non-nil error if it returns n < len(p). Write must not modify the slice data, even temporarily.
如果某個結構體要實現io.Writer,需要實現Write方法。這個方法要包含以下邏輯:
- 入參:接受元素型別為byte的slice作為方法的入參。
- 方法邏輯:把p的值寫入到Writer物件。比如Writer物件可能是一個os.File型別,那呼叫Write方法就是把p的值寫入到檔案裡。
- 返回值:要麼返回寫入的位元組數,要麼返回error。
這2個函式看起來非常抽象,很多Go初級開發者都不太理解,為啥要設計這樣2個interface?
試想這樣一個場景,假設我們要實現一個函式,功能是複製一個檔案的內容到另一個檔案。
方式1:這個函式用2個*os.Files作為引數,來從一個檔案讀內容,寫入到另一個檔案
func copySourceToDest(source *io.File, dest *io.File) error { // ... }
方式2:使用io.Reader和io.Writer作為引數。由於os.File實現了io.Reader和io.Writer,所以os.File也可以作為下面函式的引數,傳參給source和dest。
func copySourceToDest(source io.Reader, dest io.Writer) error { // ... }
方法2的實現會更通用一些,source既可以是檔案,也可以是字串物件(strings.Reader),dest既可以是檔案,也可以是其它資料庫物件(比如我們自己實現一個io.Writer,Write方法是把資料寫入到資料庫)。
在設計interface的時候要考慮到簡潔性,如果interface裡定義的方法很多,那這個interface的抽象就會不太好。
引用Go語言設計者Rob Pike在Gopherfest 2015上的技術分享Go Proverbs with Rob Pike中關於interface的說明:
The bigger the interface, the weaker the abstraction.
當然,我們也可以把多個interface結合為一個interface,在有些場景下是可以方便程式碼編寫的。
比如io.ReaderWriter就結合了io.Reader和io.Writer的方法。
type ReadWriter interface {
Reader
Writer
}
何時使用interface
下面介紹2個常見的使用interface的場景。
公共行為可以抽象為interface
比如上面介紹過的io.Reader和io.Writer就是很好的例子。Go標準庫裡大量使用interface,感興趣的可以去查閱原始碼。
使用interface讓Struct成員變數變為private
比如下面這段程式碼示例:
package main
type Halloween struct {
Day, Month string
}
func NewHalloween() Halloween {
return Halloween { Month: "October", Day: "31" }
}
func (o Halloween) UK(Year string) string {
return o.Day + " " + o.Month + " " + Year
}
func (o Halloween) US(Year string) string {
return o.Month + " " + o.Day + " " + Year
}
func main() {
o := NewHalloween()
s_uk := o.UK("2020")
s_us := o.US("2020")
println(s_uk, s_us)
}
變數o可以直接訪問Halloween結構體裡的所有成員變數。
有時候我們可能想做一些限制,不希望結構體裡的成員變數被隨意訪問和修改,那就可以藉助interface。
type Country interface {
UK(string) string
US(string) string
}
func NewHalloween() Country {
o := Halloween { Month: "October", Day: "31" }
return Country(o)
}
我們定義一個新的interface去實現Halloween的所有方法,然後NewHalloween返回這個interface型別。
那外部呼叫NewHalloween得到的物件就只能使用Halloween結構體裡定義的方法,而不能訪問結構體的成員變數。
亂用Interface的場景
interface在Go程式碼裡經常被亂用,不少C#或者Java開發背景的人在轉Go的時候,通常會先把介面型別抽象好,再去定義具體的型別。
然後,這並不是Go裡推薦的。
Don’t design with interfaces, discover them.
—Rob Pike
正如Rob Pike所說,不要一上來做程式碼設計的時候就先把interface給定義了。
除非真的有需要,否則是不推薦一開始就在程式碼裡使用interface的。
最佳實踐應該是先不要想著interface,因為過度使用interface會讓程式碼晦澀難懂。
我們應該先按照沒有interface的場景去寫程式碼,如果最後發現使用interface能帶來額外的好處,再去使用interface。
注意事項
使用interface進行方法呼叫的時候,有些開發者可能遇到過一些效能問題。
因為程式執行的時候,需要去雜湊表資料結構裡找到interface的具體實現型別,然後呼叫該型別的方法。
但是這個開銷是很小的,通常不需要關注。
總結
interface是Go語言裡一個核心功能,但是使用不當也會導致程式碼晦澀難懂。
因此,不要在寫程式碼的時候一上來就先寫interface。
要先按照沒有interface的場景去寫程式碼,如果最後發現使用interface真的可以帶來好處再去使用interface。
如果使用interface沒有讓程式碼更好,那就不要使用interface,這樣會讓程式碼更簡潔易懂。
推薦閱讀
- Go面試題系列,看看你會幾題?
- Go常見錯誤第1篇:未知列舉值
- Go常見錯誤第2篇:benchmark效能測試的坑
- Go常見錯誤第3篇:go指標的效能問題和記憶體逃逸
- Go常見錯誤第4篇:break操作的注意事項
- Go常見錯誤第5篇:Go語言Error管理
- Go常見錯誤第6篇:slice初始化常犯的錯誤
- Go常見錯誤第7篇:不使用-race選項做併發競爭檢測
- Go常見錯誤第8篇:併發程式設計中Context使用常見錯誤
- Go常見錯誤第9篇:使用檔名稱作為函式輸入
- Go常見錯誤第10篇:Goroutine和迴圈變數一起使用的坑
- Go常見錯誤第11篇:意外的變數遮蔽(variable shadowing)
- Go常見錯誤第12篇:如何破解箭頭型程式碼
- Go常見錯誤第13篇:init函式的常見錯誤和最佳實踐
- Go常見錯誤第14篇:過度使用getter和setter方法
開源地址
文章和示例程式碼開源在GitHub: Go語言初級、中級和高階教程。
公眾號:coding進階。關注公眾號可以獲取最新Go面試題和技術棧。
個人網站:Jincheng's Blog。
知乎:無忌。
福利
我為大家整理了一份後端開發學習資料禮包,包含程式語言入門到進階知識(Go、C++、Python)、後端開發技術棧、面試題等。
關注公眾號「coding進階」,傳送訊息 backend 領取資料禮包,這份資料會不定期更新,加入我覺得有價值的資料。
傳送訊息「進群」,和同行一起交流學習,答疑解惑。