Go常見錯誤第15篇:interface使用的常見錯誤和最佳實踐

coding進階 發表於 2022-11-24
Go

本文參與了思否技術徵文,歡迎正在閱讀的你也加入。

前言

這是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.

img

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,這樣會讓程式碼更簡潔易懂。

推薦閱讀

開源地址

文章和示例程式碼開源在GitHub: Go語言初級、中級和高階教程

公眾號:coding進階。關注公眾號可以獲取最新Go面試題和技術棧。

個人網站:Jincheng's Blog

知乎:無忌

福利

我為大家整理了一份後端開發學習資料禮包,包含程式語言入門到進階知識(Go、C++、Python)、後端開發技術棧、面試題等。

關注公眾號「coding進階」,傳送訊息 backend 領取資料禮包,這份資料會不定期更新,加入我覺得有價值的資料。

傳送訊息「進群」,和同行一起交流學習,答疑解惑。

References