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

FengY發表於2018-04-14

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

1. interface{}之物件轉型坑

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

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
}
複製程式碼

在上面的程式碼中,data1是ChoiceQuestion型別,data2是BlankQuestion型別。因此,我們的interface{}指代了三種型別,分別是ChoiceQuestionBlankQuestionnil,這裡就體現了Go和麵向物件語言的不同點了,在面嚮物件語言中,我們本可以這麼寫:

func fetchQuestion(id int) (BaseQuestion , bool) {
    ...
}
複製程式碼

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

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

// ------- 輸出--------
{{1001 CHOICE} [A B]}
data -  &{{1001 CHOICE} [A B]}
複製程式碼

EN,好像通過Go的switch-type語法糖,判斷起來也不是很複雜嘛。如果你也這樣以為,並且跟我一樣用了這個方法,恭喜你已經入坑了。
因為需求永遠是多變的,假如現在有個需求,需要在ChoiceQuesiton列印時,給它的QuestionContent欄位新增字首選擇題,於是程式碼變成以下這樣:

func printQuestion() {
	if data, ok := fetchQuestion(1001); ok {
		switch v := data.(type) {
		case ChoiceQuestion:
		    v.QuestionContent = "選擇題"+ v.QuestionContent
			fmt.Println(v)
			
		...
		fmt.Println(data)
	}
}

// ------- 輸出--------
{{1001 選擇題CHOICE} [A B]}
data -  {{1001 CHOICE} [A B]}
複製程式碼

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

func printQuestion() {
	if data, ok := fetchQuestion(1001); ok {
		switch v := data.(type) {
		case *ChoiceQuestion:
		    v.QuestionContent = "選擇題"+ v.QuestionContent
			fmt.Println(v)
		...
		fmt.Println(data)
	}
}
// ------- 輸出--------
&{{1001 選擇題CHOICE} [A B]}
data -  &{{1001 選擇題CHOICE} [A B]}
複製程式碼

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

func printQuestion() {
	if data, ok := fetchQuestion(1001); 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實現這個介面。

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

type BaseQuestion struct {
	QuestionId      int
	QuestionContent string
	QuestionType    int
}

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
}

//修改返回值為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
}
複製程式碼

不管有多少題型,只要它們包含BaseQuestion,就能自動實現IQuestion介面,從而,我們可以通過定義介面方法來控制資料。

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

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

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

//--------輸出--------
data -  &{{1002 填空題BLANK 2} [ET AI]}
複製程式碼

這種方法無疑大大減少了副本的建立數量,而且易於擴充套件。通過這個例子,我們也瞭解到了Go介面的強大之處,雖然Go並不是物件導向的語言,但是通過良好的介面設計,我們完全可以從中窺探到物件導向思維的影子。也難怪在Go文件的FAQ中,對於Is Go an object-oriented language?這個問題,官方給出的答案是yes and no.
這裡還可以多扯一句,前面說了v := data.(type)這條語句是拷貝data的副本,但當data是介面物件時,這條語句就是介面之間的轉型而不是資料副本拷貝了。

//定義新介面
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())
	}
}

//------------輸出-----------
Choice has : 2
複製程式碼

2. interface{}之nil

看以下程式碼:

func fetchFromChoiceTable(id int) (data *ChoiceQuestion) {
	if id == 1001 {
		return &ChoiceQuestion{
			BaseQuestion: BaseQuestion{
				QuestionId:      1001,
				QuestionContent: "HELLO",
			},
			Options: []string{"A", "B"},
		}
	}
	return nil
}


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

func sendData(data interface{}) {
	fmt.Println("傳送資料 ..." , data)
}

func main(){
    data := fetchQuestion(1002)
    
    if data != nil {
        sendData(data)
    }
}
複製程式碼

一串很常見的業務程式碼,我們根據id查詢Question,為了以後能方便的擴充套件,我們使用interface{}作為返回值,然後根據data是否為nil來判斷是不是要傳送這個Question。不幸的是,不管fetchQuestion()方法有沒有查到資料,sendData()都會被執行。執行main(),列印結果如下:

傳送資料 ... <nil>

Process finished with exit code 0
複製程式碼

要明白內中玄機,我們需要回憶下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).

以上的話大意是說,interface在Go底層,被表示為一個值和值對應的型別的集合體,具體到我們的示例程式碼,fetchQuestion()的返回值interface{},其實是指(*ChoiceQuestion, data1)的集合體,如果沒查到資料,則我們的data1為nil,上述集合體變成(*ChoiceQuestion, nil)。而Go規定中,這樣的結構的集合體本身是非nil的,進一步的,只有(nil,nil)這樣的集合體才能被判斷為nil。

這嚴格來說,不是interface{}的問題,而是Go介面設計的規定,你把以上程式碼中的interface{}換成其它任意你定義的介面,都會產生此問題。所以我們對介面的判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(1002); ok {
        sendData(data)
    }
}
複製程式碼

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

3. 總結和引用

零零散散寫了這麼多,有點前言不搭後語,語言不通之處還望見諒。Go作為一個設計精巧的語言,它的成功不是沒有道理的,通過對目前遇到的幾個大問題和總結,慢慢對Go有了一點點淺薄的認識,以後碰到了類似的問題,還可以繼續新增在文章裡。
interface{}作為Go中最基本的一個介面型別,可以在程式碼靈活性方面給我們提供很大的便利,但是我們也要認識到,介面就是對一類具體事物的抽象,而interface{}作為每個結構體都實現的介面,提供了一個非常高層次的抽象,以至於我們會丟失事物的大部分資訊,所以我們在使用interface{}前,一定要謹慎思考,這就像相親之前提要求,你要是說只要是個女的我都可以接受,那可就別怪來的人可能是高的矮的胖的瘦的美的醜的。

文中出現的程式碼,可以在示例程式碼 中找到完整版。

EffectiveGo
GoFAQ

相關文章