爲什麼在Go語言中要慎用interface{}

作者 點擊上方👆👆


在掘金上看到一篇從java轉Go思想上的變化以及對go語言思考的文章,寫的很透徹,我也推敲了一遍。這裏也分享給大家,或許對將要或者已經學習golang的同學有所幫助。提示:代碼塊可以左右拖動哦~~



記得剛從Java轉Go的時候,一個用Go語言的前輩告訴我:“要少用interface{},這玩意兒很好用,但是最好不要用。”那時候我的組長打趣接話:“不會,他是從Java轉過來的,碰到個問題就想定義個類。”當時我對interface{}的第一印象也是類比Java中的Object類,我們使用Java肯定不會到處去傳Object啊。後來的事實證明,年輕人畢竟是年輕人,看着目前項目裏漫天飛的interface{},它們時而變成函數形參讓人摸不着頭腦;時而隱藏在結構體字段中變化無窮。不禁想起以前看到的一句話:“動態語言一時爽,重構代碼火葬場。”故而寫下此篇關於interface{}的經驗總結,供以後的自己和讀者參考。



1、interface{}之對象轉型坑

一個語言用的久了,難免使用者的思維會受到這個語言的影響,interface{}作爲Go的重要特性之一,它代表的是一個類似*void的指針,可以指向不同類型的數據。所以我們可以使用它來指向任何數據,這會帶來類似與動態語言的便利性,如以下的例子:


package main

import "fmt"

type BaseQuestion struct{
 QuestionId int
 QuestionContent string
}

type ChoiceQuestion struct{
 BaseQuestion
 Options []string
}

type BlankQuestion struct{
 BaseQuestion
 Blank string
}

func fetchQuestion(id int) (interface{} , bool) {
 data1 ,ok1 := fetchFromChoiceTable(id) // 根據ID到選擇題表中找題目,返回(ChoiceQuestion)
 data2 ,ok2 := fetchFromBlankTable(id)  // 根據ID到填空題表中找題目,返回(BlankQuestion)

 if ok1 {
   return data1,ok1
 }

 if ok2 {
   return data2,ok2
 }

 return nil ,false
}

func fetchFromBlankTable(id int) (BlankQuestion, bool) {

 //模擬查數據庫操作,不等於1說明沒有數據
 if id != 1 {
   return BlankQuestion{}, false
 }
 blankQuestion := BlankQuestion{
   BaseQuestion : BaseQuestion{
     1,
     "golang是哪個組織發佈的?",
   },
   Blank:  "Google",
 }

 return blankQuestion, true
}

func fetchFromChoiceTable(id int) (ChoiceQuestion, bool) {
 //模擬查數據庫操作,不等於2說明沒有數據
 if id != 2 {
   return ChoiceQuestion{}, false
 }
 choiceQuestion := ChoiceQuestion{
   BaseQuestion : BaseQuestion{
     2,
     "golang有哪些優秀項目?",
   },
   Options : []string{"docker", "Kubernetes", "lantern"},
 }


 return choiceQuestion, true
}

func printQuestion(id int){
 if data, ok := fetchQuestion(id); ok {
   switch v := data.(type) {
   case ChoiceQuestion:
     fmt.Println("ChoiceQuestion--------", v)
   case BlankQuestion:
     fmt.Println("BlankQuestion---------", v)
   case nil:
     fmt.Println("nil-----------", v)
   }
   fmt.Println("data is -----", data)
 }
}

func main() {
 printQuestion(1)
 printQuestion(2)
}


在上面的代碼中,data1是ChoiceQuestion類型,data2是BlankQuestion類型。因此,我們的interface{}指代了三種類型,分別是ChoiceQuestion、BlankQuestion和nil,這裏就體現了Go和麪向對象語言的不同點了,在面嚮對象語言中,我們本可以這麼寫:


func fetchQuestion(id int) (BaseQuestion , bool) {
   ...
}


只需要返回基類BaseQuestion即可,需要使用子類的方法或者字段只需要向下轉型。然而在Go中,並沒有這種is-a(父子繼承關係,D is a B,即D是B的子類)的概念,代碼會無情的提示你,返回值類型不匹配。那麼,我們該如何使用這個interface{}返回值呢,我們也不知道它是什麼類型啊。所以,你得不厭其煩的一個一個判斷:



那麼執行上述程序結果就是:



EN,好像通過Go的switch-type語法糖,判斷起來也不是很複雜嘛。如果你也這樣以爲,並且跟我一樣用了這個方法,恭喜你已經入坑了。

因爲需求永遠是多變的,假如現在有個需求,需要在ChoiceQuesiton打印時,給它的QuestionContent字段添加前綴選擇題,於是printQuestion函數就變成以下這樣:


func printQuestion(id int) {
 if data, ok := fetchQuestion(id); ok {
   switch v := data.(type) {
   case ChoiceQuestion:
     v.QuestionContent = "選擇題" + v.QuestionContent
     fmt.Println("ChoiceQuestion--------", v)
   case BlankQuestion:
     v.QuestionContent = "填空題" + v.QuestionContent
     fmt.Println("BlankQuestion---------", v)
   case nil:
     fmt.Println("nil-----------", v)
   }
   fmt.Println("data is -----", data)
 }
}


看輸出結果:



我們看到了不一樣的輸出結果,data輸出沒有變化。可能有的讀者已經猜到了,v和data根本不是指向同一份數據,換句話說,v := data.(type)這條語句,會新建一個data在對應type下的副本,我們對v操作影響不到data。當然,我們可以要求fetchFrom***Table()返回*ChoiceQuestion類型,這樣我們可以通過判斷*ChoiceQuestion來處理數據副本問題,那麼代碼將變成這樣:


package main

import "fmt"

type BaseQuestion struct{
 QuestionId int
 QuestionContent string
}

type ChoiceQuestion struct{
 BaseQuestion
 Options []string
}

type BlankQuestion struct{
 BaseQuestion
 Blank string
}

func fetchQuestion(id int) (interface{} , bool) {
 data1 ,ok1 := fetchFromChoiceTable(id) // 根據ID到選擇題表中找題目,返回(ChoiceQuestion)
 data2 ,ok2 := fetchFromBlankTable(id)  // 根據ID到填空題表中找題目,返回(BlankQuestion)

 if ok1 {
   return data1,ok1
 }

 if ok2 {
   return data2,ok2
 }

 return nil ,false
}

func fetchFromBlankTable(id int) (*BlankQuestion, bool) {

 //模擬查數據庫操作,不等於1說明沒有數據
 if id != 1 {
   return nil, false
 }
 blankQuestion := &BlankQuestion{
   BaseQuestion : BaseQuestion{
     1,
     "golang是哪個組織發佈的?",
   },
   Blank:  "Google",
 }

 return blankQuestion, true
}

func fetchFromChoiceTable(id int) (*ChoiceQuestion, bool) {
 //模擬查數據庫操作,不等於2說明沒有數據
 if id != 2 {
   return nil, false
 }
 choiceQuestion := &ChoiceQuestion{
   BaseQuestion : BaseQuestion{
     2,
     "golang有哪些優秀項目?",
   },
   Options : []string{"docker", "Kubernetes", "lantern"},
 }


 return choiceQuestion, true
}

func printQuestion(id int) {
 if data, ok := fetchQuestion(id); ok {
   switch v := data.(type) {
   case *ChoiceQuestion:
     v.QuestionContent = "選擇題" + v.QuestionContent
     fmt.Println("ChoiceQuestion--------", v)
   case *BlankQuestion:
     v.QuestionContent = "填空題" + v.QuestionContent
     fmt.Println("BlankQuestion---------", v)
   case nil:
     fmt.Println("nil-----------", v)
   }
   fmt.Println("data is -----", data)
 }
}

func main() {
 printQuestion(1)
 printQuestion(2)
}


再看看輸出結果,發現data也發生了變化:



我們這裏修改了fetchFrom***Table函數,不過在實際項目中,你可能有很多理由不能去動fetchFrom***Table(),也許是涉及數據庫的操作函數你沒有權限改動;也許是項目中很多地方使用了這個方法,你也不能隨便改動。這也是我沒有寫出fetchFrom***Table()的實現的原因,很多時候,這些方法對你只能是黑盒的。退一步講,即使方法簽名可以改動,我們這裏也只是列舉出了兩種題型,可能還有材料題、閱讀題、寫作題等等,如果需求要對每個題型的QuestonContent添加對應的題型前綴,我們豈不是要寫出下面這種代碼:


func printQuestion(id int) {
 if data, ok := fetchQuestion(id); ok {
   switch v := data.(type) {
   case *ChoiceQuestion:
     v.QuestionContent = "選擇題" + v.QuestionContent
     fmt.Println(v)
   case *BlankQuestion:
     v.QuestionContent = "填空題" + v.QuestionContent
     fmt.Println(v)
   case *MaterialQuestion:
     v.QuestionContent = "材料題" + v.QuestionContent
     fmt.Println(v)
   case *WritingQuestion:
     v.QuestionContent = "寫作題" + v.QuestionContent
     fmt.Println(v)
     ...
   case nil:
     fmt.Println(v)
     fmt.Println(data)
   }
 }
}


這種代碼帶來了大量的重複結構,由此可見,interface{}的動態特性很不能適應複雜的數據結構,難道我們就不能有更方便的操作了麼?山窮水盡之際,或許可以回頭看看面向對象思想,也許繼承和多態能很好的解決我們遇到的問題。



我們可以把這些題型抽成一個接口,並且讓BaseQuestion實現這個接口。那麼代碼可以寫成這樣:


package main

import "fmt"

type IQuestion interface{
 GetQuestionType() int
 GetQuestionContent()string
 AddQuestionContentPrefix(prefix string)
}

type BaseQuestion struct {
 QuestionId      int
 QuestionContent string
 QuestionType    int
}

const (
 ChoiceQuestionType = 1
 BlankQuestionType = 2
)

func (self *BaseQuestion) GetQuestionType() int {
 return self.QuestionType
}

func (self *BaseQuestion) GetQuestionContent() string {
 return self.QuestionContent
}

func (self *BaseQuestion) AddQuestionContentPrefix(prefix string) {
 self.QuestionContent = prefix + self.QuestionContent
}

type ChoiceQuestion struct{
 BaseQuestion
 Options []string
}

type BlankQuestion struct{
 BaseQuestion
 Blank string
}

//修改返回值爲IQuestion
func fetchQuestion(id int) (IQuestion, bool) {
 data1, ok1 := fetchFromChoiceTable(id) // 根據ID到選擇題表中找題目
 data2, ok2 := fetchFromBlankTable(id)  // 根據ID到選擇題表中找題目

 if ok1 {
   return &data1, ok1
 }

 if ok2 {
   return &data2, ok2
 }

 return nil, false
}

func fetchFromBlankTable(id int) (BlankQuestion, bool) {

 //模擬查數據庫操作,不等於1說明沒有數據
 if id != 1 {
   return BlankQuestion{}, false
 }
 blankQuestion := BlankQuestion{
   BaseQuestion : BaseQuestion{
     1,
     "golang是哪個組織發佈的?",
     BlankQuestionType,
   },
   Blank:  "Google",
 }

 return blankQuestion, true
}

func fetchFromChoiceTable(id int) (ChoiceQuestion, bool) {
 //模擬查數據庫操作,不等於2說明沒有數據
 if id != 2 {
   return ChoiceQuestion{}, false
 }
 choiceQuestion := ChoiceQuestion{
   BaseQuestion : BaseQuestion{
     2,
     "golang有哪些優秀項目?",
     ChoiceQuestionType,
   },
   Options : []string{"docker", "Kubernetes", "lantern"},
 }


 return choiceQuestion, true
}

func printQuestion(id int) {
 if data, ok := fetchQuestion(id); ok {
   var questionPrefix string

   //需要增加題目類型,只需要添加一段case
   switch  data.GetQuestionType() {
   case ChoiceQuestionType:
     questionPrefix = "選擇題"
   case BlankQuestionType:
     questionPrefix = "填空題"
   }

   data.AddQuestionContentPrefix(questionPrefix)
   fmt.Println("data - ", data)
 }
}

func main() {
 printQuestion(1)
 printQuestion(2)
}


這裏ChoiceQuestion和BlankQuestion類型裏包含了BaseQuestion,所以ChoiceQuestion和BlankQuestion也實現了IQuestion接口。不管有多少題型,只要它們包含BaseQuestion,就能自動實現IQuestion接口,從而,我們可以通過定義接口方法來控制數據。


上述代碼輸出結果爲:



這種方法無疑大大減少了副本的創建數量,而且易於擴展。通過這個例子,我們也瞭解到了Go接口的強大之處,雖然Go並不是面向對象的語言,但是通過良好的接口設計,我們完全可以從中窺探到面向對象思維的影子。也難怪在Go文檔的FAQ中,對於Is Go an object-oriented language?這個問題,官方給出的答案是yes and no,官方地址如下:

https://golang.google.cn/doc/faq#Is_Go_an_object-oriented_language



這裏還可以多扯一句,前面說了v := data.(type)這條語句是拷貝data的副本,但當data是接口對象時,這條語句就是接口之間的轉型而不是數據副本拷貝了。

如下代碼:


package main

import "fmt"

//IQuestion接口
type IQuestion interface{
 GetQuestionType() int
 GetQuestionContent()string
 AddQuestionContentPrefix(prefix string)
}

//BaseQuestion實現了IQuestion接口
type BaseQuestion struct {
 QuestionId      int
 QuestionContent string
 QuestionType    int
}

//題型常量
const (
 ChoiceQuestionType = 1
 BlankQuestionType = 2
)

func (self *BaseQuestion) GetQuestionType() int {
 return self.QuestionType
}

func (self *BaseQuestion) GetQuestionContent() string {
 return self.QuestionContent
}

func (self *BaseQuestion) AddQuestionContentPrefix(prefix string) {
 self.QuestionContent = prefix + self.QuestionContent
}

//ChoiceQuestion包含了BaseQuestion,故ChoiceQuestion也實現了IQuestion接口
//ChoiceQuestion自己實現了GetOptionsLen() int,故ChoiceQuestion也實現了IChoiceQuestion接口
type ChoiceQuestion struct{
 BaseQuestion
 Options []string
}

//定義新接口IChoiceQuestion,實現了IQuestion接口
type IChoiceQuestion interface {
 IQuestion
 GetOptionsLen() int
}

func (self *ChoiceQuestion) GetOptionsLen() int {
 return len(self.Options)
}

func showOptionsLen(data IQuestion) {
 //choice和data指向同一份數據
 if choice, ok := data.(IChoiceQuestion); ok {
   fmt.Println("Choice has :", choice.GetOptionsLen())
 }
}

func main() {
 choiceQuestion := &ChoiceQuestion{
   BaseQuestion: BaseQuestion{
     QuestionId: 1,
     QuestionContent: "golang有哪些優秀項目?",
     QuestionType: ChoiceQuestionType,
   },
   Options : []string{"docker", "Kubernetes", "lantern"},
 }

 //choiceQuestion實現了IQuestion接口,故可以作爲參數傳遞
 showOptionsLen(choiceQuestion)
}


這裏定義了IQuestion和IChoiceQuestion兩個接口,IChoiceQuestion裏有IQuestion,故IChoiceQuestion實現了IQuestion;

定義了BaseQuestion和ChoiceQuestion兩個struct,BaseQuestion實現了IQuestion接口;ChoiceQuestion中包含BaseQuestion,

且實現了IChoiceQuestion中的GetOptionsLen() int方法,故ChoiceQuestion同時實現了IQuestion和IChoiceQuestion兩個接口。


程序運行結果如下:




2、interface{}之nil坑

看以下代碼:


package main

import "fmt"

type BaseQuestion struct {
 QuestionId      int
 QuestionContent string
}

type ChoiceQuestion struct{
 BaseQuestion
 Options []string
}

func fetchFromChoiceTable(id int) (data *ChoiceQuestion) {
 if id == 1 {
   return &ChoiceQuestion{
     BaseQuestion: BaseQuestion{
       QuestionId:      1,
       QuestionContent: "golang有哪些優秀項目?",
     },
     Options: []string{"docker", "Kubernetes", "lantern"},
   }
 }
 return nil
}

func fetchQuestion(id int) (interface{}) {
 data1 := fetchFromChoiceTable(id) // 根據ID到選擇題表中找題目
 return data1
}

func sendData(data interface{}) {
 fmt.Println("發送數據 ... " , data)
}

func main(){
 data := fetchQuestion(1)

 if data != nil {
   sendData(data)
 }

 data1 := fetchQuestion(2)

 if data != nil {
   sendData(data1)
 }
}


一串很常見的業務代碼,我們根據id查詢Question,爲了以後能方便的擴展,我們使用interface{}作爲返回值,然後根據data是否爲nil來判斷是不是要發送這個Question。不幸的是,不管fetchQuestion()方法有沒有查到數據,sendData()都會被執行。運行程序,輸出結果如下:



要明白內中玄機,我們需要回憶下interface{}究竟是個什麼東西,文檔上說,它是一個空接口,也就是說,一個沒有聲明任何方法的接口,那麼,接口在Go的內部又究竟是怎麼表示的?我在官方文檔上找到一下幾句話:


Under the covers, interfaces are implemented as two elements, a type and a value.

The value, called the interface's dynamic value, is an arbitrary concrete value and the type is that of the value. For the int value 3, an interface value contains, schematically, (int, 3).

An interface value is nil only if the inner value and type are both unset, (nil, nil). In particular, a nil interface will always hold a nil type.

If we store a pointer of type *int inside an interface value, the inner type will be *intregardless of the value of the pointer: (*int, nil).

Such an interface value will therefore be non-nil even when the pointer inside is nil.

看不懂的來看看中文:


在底層,接口作爲兩個元素實現:一個類型和一個值。

該值被稱爲接口的動態值, 它是一個任意的具體值,而該接口的類型則爲該值的類型。對於 int 值3, 一個接口值示意性地包含(int, 3)。

只有在內部值和類型都未設置時(nil, nil),一個接口的值才爲 nil。特別是,一個 nil 接口將總是擁有一個 nil 類型。

若我們在一個接口值中存儲一個 *int 類型的指針,則內部類型將爲 *int,無論該指針的值是什麼:(*int, nil)。 

因此,這樣的接口值會是非 nil 的,即使在該指針的內部爲 nil。


具體到我們的示例代碼,fetchQuestion()的返回值interface{},其實是指(*ChoiceQuestion, data1)的集合體,如果沒查到數據,則我們的data1爲nil,上述集合體變成(*ChoiceQuestion, nil)。而Go規定中,這樣的結構的集合體本身是非nil的,進一步的,只有(nil,nil)這樣的集合體才能被判斷爲nil。


這嚴格來說,不是interface{}的問題,而是Go接口設計的規定,你把以上代碼中的interface{}換成其它任意你定義的接口,都會產生此問題。所以我們對接口的判nil,一定要慎重,以上代碼如果改成多返回值形式,就能完全避免這個問題。如下:


package main

import "fmt"

type BaseQuestion struct {
 QuestionId      int
 QuestionContent string
}

type ChoiceQuestion struct{
 BaseQuestion
 Options []string
}

func fetchFromChoiceTable(id int) (data *ChoiceQuestion) {
 if id == 1 {
   return &ChoiceQuestion{
     BaseQuestion: BaseQuestion{
       QuestionId:      1,
       QuestionContent: "golang有哪些優秀項目?",
     },
     Options: []string{"docker", "Kubernetes", "lantern"},
   }
 }
 return nil
}

func fetchQuestion(id int) (interface{},bool) {
 data1 := fetchFromChoiceTable(id) // 根據ID到選擇題表中找題目
 if data1 != nil {
   return data1,true
 }
 return nil,false
}

func sendData(data interface{}) {
 fmt.Println("發送數據 ..." , data)
}

func main(){
 if data, ok := fetchQuestion(1); ok {
   sendData(data)
 }

 if data, ok := fetchQuestion(2); ok {
   sendData(data)
 }
}


當然,也有很多其它的辦法可以解決,大家可以自行探索。


3、總結和引用

零零散散寫了這麼多,有點前言不搭後語,語言不通之處還望見諒。Go作爲一個設計精巧的語言,它的成功不是沒有道理的,通過對目前遇到的幾個大問題和總結,慢慢對Go有了一點點淺薄的認識,以後碰到了類似的問題,還可以繼續添加在文章裏。

interface{}作爲Go中最基本的一個接口類型,可以在代碼靈活性方面給我們提供很大的便利,但是我們也要認識到,接口就是對一類具體事物的抽象,而interface{}作爲每個結構體都實現的接口,提供了一個非常高層次的抽象,以至於我們會丟失事物的大部分信息,所以我們在使用interface{}前,一定要謹慎思考,這就像相親之前提要求,你要是說只要是個女的我都可以接受,那可就別怪來的人可能是高的矮的胖的瘦的美的醜的。



END



本文由“壹伴編輯器”提供技術支持

本文來自掘金網FengY的博客,原文可點擊左下角 閱讀原文 查閱,侵刪。

本文由“壹伴編輯器”提供技術支持

推薦閱讀:

推薦一款超好用的工具

四款神器,教你笑傲江湖


歷史文章:

GitHub上優秀的Go開源項目

利用golang優雅的實現單實例

golang併發編程之互斥鎖、讀寫鎖詳解

go語言nil和interface詳解

vmware上安裝linux過程記錄

NAT模式實現虛擬機共享主機網絡


我是小碗湯,我們一起學習。




本文分享自微信公衆號 - 我的小碗湯(mysmallsoup)。
如有侵權,請聯繫 [email protected] 刪除。
本文參與“OSC源創計劃”,歡迎正在閱讀的你也加入,一起分享。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章