為什么在Go語(yǔ)言中要慎用interface{}

轉(zhuǎn)載,原文出處:https://juejin.im/post/5ad1c766518825555e5e4646
記得剛從Java轉(zhuǎn)Go的時(shí)候,一個(gè)用Go語(yǔ)言的前輩告訴我:“要少用interface{},這玩意兒很好用,但是最好不要用?!蹦菚r(shí)候我的組長(zhǎng)打趣接話:“不會(huì),他是從Java轉(zhuǎn)過(guò)來(lái)的,碰到個(gè)問(wèn)題就想定義個(gè)類?!碑?dāng)時(shí)我對(duì)interface{}的第一印象也是類比Java中的Object類,我們使用Java肯定不會(huì)到處去傳Object啊。后來(lái)的事實(shí)證明,年輕人畢竟是年輕人,看著目前項(xiàng)目里漫天飛的interface{},它們時(shí)而變成函數(shù)形參讓人摸不著頭腦;時(shí)而隱藏在結(jié)構(gòu)體字段中變化無(wú)窮。不禁想起以前看到的一句話:“動(dòng)態(tài)語(yǔ)言一時(shí)爽,重構(gòu)代碼火葬場(chǎng)?!惫识鴮懴麓似P(guān)于interface{}的經(jīng)驗(yàn)總結(jié),供以后的自己和讀者參考。

1. interface{}之對(duì)象轉(zhuǎn)型坑

一個(gè)語(yǔ)言用的久了,難免使用者的思維會(huì)受到這個(gè)語(yǔ)言的影響,interface{}作為Go的重要特性之一,它代表的是一個(gè)類似*void的指針,可以指向不同類型的數(shù)據(jù)。所以我們可以使用它來(lái)指向任何數(shù)據(jù),這會(huì)帶來(lái)類似與動(dòng)態(tài)語(yǔ)言的便利性,如以下的例子:

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) // 根據(jù)ID到選擇題表中找題目,返回(ChoiceQuestion)
    data2 ,ok2 := fetchFromBlankTable(id)  // 根據(jù)ID到填空題表中找題目,返回(BlankQuestion)

    if ok1 {
        return data1,ok1
    }

    if ok2 {
        return data2,ok2
    }

    return nil ,false
}

在上面的代碼中,data1是ChoiceQuestion類型,data2是BlankQuestion類型。因此,我們的interface{}指代了三種類型,分別是ChoiceQuestionBlankQuestionnil,這里就體現(xiàn)了Go和面向?qū)ο笳Z(yǔ)言的不同點(diǎn)了,在面向?qū)ο笳Z(yǔ)言中,我們本可以這么寫:

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

只需要返回基類BaseQuestion即可,需要使用子類的方法或者字段只需要向下轉(zhuǎn)型。然而在Go中,并沒(méi)有這種is-A的概念,代碼會(huì)無(wú)情的提示你,返回值類型不匹配。
那么,我們?cè)撊绾问褂眠@個(gè)interface{}返回值呢,我們也不知道它是什么類型啊。所以,你得不厭其煩的一個(gè)一個(gè)判斷:

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,好像通過(guò)Go的switch-type語(yǔ)法糖,判斷起來(lái)也不是很復(fù)雜嘛。如果你也這樣以為,并且跟我一樣用了這個(gè)方法,恭喜你已經(jīng)入坑了。
因?yàn)樾枨笥肋h(yuǎn)是多變的,假如現(xiàn)在有個(gè)需求,需要在ChoiceQuesiton打印時(shí),給它的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]}

我們得到了不一樣的輸出結(jié)果,而data根本沒(méi)有變動(dòng)??赡苡械淖x者已經(jīng)猜到了,vdata根本不是指向同一份數(shù)據(jù),換句話說(shuō),v := data.(type)這條語(yǔ)句,會(huì)新建一個(gè)data在對(duì)應(yīng)type下的副本,我們對(duì)v操作影響不到data。當(dāng)然,我們可以要求fetchFrom***Table()返回*ChoiceQuestion類型,這樣我們可以通過(guò)判斷*ChoiceQuestion來(lái)處理數(shù)據(jù)副本問(wèn)題:

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]}

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

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)
    }
}

這種代碼帶來(lái)了大量的重復(fù)結(jié)構(gòu),由此可見,interface{}的動(dòng)態(tài)特性很不能適應(yīng)復(fù)雜的數(shù)據(jù)結(jié)構(gòu),難道我們就不能有更方便的操作了么?山窮水盡之際,或許可以回頭看看面向?qū)ο笏枷?,也許繼承和多態(tài)能很好的解決我們遇到的問(wèn)題。

我們可以把這些題型抽成一個(gè)接口,并且讓BaseQuestion實(shí)現(xiàn)這個(gè)接口。

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) // 根據(jù)ID到選擇題表中找題目
    data2, ok2 := fetchFromBlankTable(id)  // 根據(jù)ID到選擇題表中找題目

    if ok1 {
        return &data1, ok1
    }

    if ok2 {
        return &data2, ok2
    }

    return nil, false
}

不管有多少題型,只要它們包含BaseQuestion,就能自動(dòng)實(shí)現(xiàn)IQuestion接口,從而,我們可以通過(guò)定義接口方法來(lái)控制數(shù)據(jù)。

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]}

這種方法無(wú)疑大大減少了副本的創(chuàng)建數(shù)量,而且易于擴(kuò)展。通過(guò)這個(gè)例子,我們也了解到了Go接口的強(qiáng)大之處,雖然Go并不是面向?qū)ο蟮恼Z(yǔ)言,但是通過(guò)良好的接口設(shè)計(jì),我們完全可以從中窺探到面向?qū)ο笏季S的影子。也難怪在Go文檔的FAQ中,對(duì)于Is Go an object-oriented language?這個(gè)問(wèn)題,官方給出的答案是yes and no.
這里還可以多扯一句,前面說(shuō)了v := data.(type)這條語(yǔ)句是拷貝data的副本,但當(dāng)data是接口對(duì)象時(shí),這條語(yǔ)句就是接口之間的轉(zhuǎn)型而不是數(shù)據(jù)副本拷貝了。

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

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

func showOptionsLen(data IQuestion) {
    //choice和data指向同一份數(shù)據(jù)
    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) // 根據(jù)ID到選擇題表中找題目
    return data1
}

func sendData(data interface{}) {
    fmt.Println("發(fā)送數(shù)據(jù) ..." , data)
}

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

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

一串很常見的業(yè)務(wù)代碼,我們根據(jù)id查詢Question,為了以后能方便的擴(kuò)展,我們使用interface{}作為返回值,然后根據(jù)data是否為nil來(lái)判斷是不是要發(fā)送這個(gè)Question。不幸的是,不管fetchQuestion()方法有沒(méi)有查到數(shù)據(jù),sendData()都會(huì)被執(zhí)行。運(yùn)行main(),打印結(jié)果如下:

發(fā)送數(shù)據(jù) ... <nil>

Process finished with exit code 0

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

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).

以上的話大意是說(shuō),interface在Go底層,被表示為一個(gè)值和值對(duì)應(yīng)的類型的集合體,具體到我們的示例代碼,fetchQuestion()的返回值interface{},其實(shí)是指(ChoiceQuestion, data1)的集合體,如果沒(méi)查到數(shù)據(jù),則我們的data1為nil,上述集合體變成(ChoiceQuestion, nil)。而Go規(guī)定中,這樣的結(jié)構(gòu)的集合體本身是非nil的,進(jìn)一步的,只有(nil,nil)這樣的集合體才能被判斷為nil。

這嚴(yán)格來(lái)說(shuō),不是interface{}的問(wèn)題,而是Go接口設(shè)計(jì)的規(guī)定,你把以上代碼中的interface{}換成其它任意你定義的接口,都會(huì)產(chǎn)生此問(wèn)題。所以我們對(duì)接口的判nil,一定要慎重,以上代碼如果改成多返回值形式,就能完全避免這個(gè)問(wèn)題。

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

func sendData(data interface{}) {
    fmt.Println("發(fā)送數(shù)據(jù) ..." , data)
}

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

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

3. 總結(jié)和引用

零零散散寫了這么多,有點(diǎn)前言不搭后語(yǔ),語(yǔ)言不通之處還望見諒。Go作為一個(gè)設(shè)計(jì)精巧的語(yǔ)言,它的成功不是沒(méi)有道理的,通過(guò)對(duì)目前遇到的幾個(gè)大問(wèn)題和總結(jié),慢慢對(duì)Go有了一點(diǎn)點(diǎn)淺薄的認(rèn)識(shí),以后碰到了類似的問(wèn)題,還可以繼續(xù)添加在文章里。
interface{}作為Go中最基本的一個(gè)接口類型,可以在代碼靈活性方面給我們提供很大的便利,但是我們也要認(rèn)識(shí)到,接口就是對(duì)一類具體事物的抽象,而interface{}作為每個(gè)結(jié)構(gòu)體都實(shí)現(xiàn)的接口,提供了一個(gè)非常高層次的抽象,以至于我們會(huì)丟失事物的大部分信息,所以我們?cè)谑褂?code>interface{}前,一定要謹(jǐn)慎思考,這就像相親之前提要求,你要是說(shuō)只要是個(gè)女的我都可以接受,那可就別怪來(lái)的人可能是高的矮的胖的瘦的美的丑的。

文中出現(xiàn)的代碼,可以在示例代碼 中找到完整版。

EffectiveGo
GoFAQ

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容