为什么在Go语言中要慎用interface{}

转载,原文出处:https://juejin.im/post/5ad1c766518825555e5e4646
记得刚从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

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,001评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,210评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 161,874评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,001评论 1 291
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,022评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,005评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,929评论 3 416
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,742评论 0 271
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,193评论 1 309
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,427评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,583评论 1 346
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,305评论 5 342
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,911评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,564评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,731评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,581评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,478评论 2 352

推荐阅读更多精彩内容