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

记得刚从Java转Go的时候,一个用Go语言的前辈告诉我:“要少用interface{},这玩意儿很好用,可是最好不要用。”那时候个人组长打趣接话:“不会,他是从Java转过来的,碰到个问题就想定义个类。”当时我对interface{}的第一印象也是类比Java中的Object类,咱们使用Java确定不会处处去传Object啊。后来的事实证实,年轻人毕竟是年轻人,看着目前项目里漫天飞的interface{},它们时而变成函数形参让人摸不着头脑;时而隐藏在结构体字段中变化多端。不由想起之前看到的一句话:“动态语言一时爽,重构代码火葬场。”故而写下此篇关于interface{}的经验总结,供之后的本身和读者参考。html

1. interface{}之对象转型坑

一个语言用的久了,不免使用者的思惟会受到这个语言的影响,interface{}做为Go的重要特性之一,它表明的是一个相似*void的指针,能够指向不一样类型的数据。因此咱们可使用它来指向任何数据,这会带来相似与动态语言的便利性,如如下的例子:git

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和面向对象语言的不一样点了,在面向对象语言中,咱们本能够这么写:github

func fetchQuestion(id int) (BaseQuestion , bool) {
    ...
}
复制代码

只须要返回基类BaseQuestion便可,须要使用子类的方法或者字段只须要向下转型。然而在Go中,并无这种is-A的概念,代码会无情的提示你,返回值类型不匹配。
那么,咱们该如何使用这个interface{}返回值呢,咱们也不知道它是什么类型啊。因此,你得不厌其烦的一个一个判断:golang

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来处理数据副本问题:bash

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实现这个接口。fetch

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接口,从而,咱们能够经过定义接口方法来控制数据。ui

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

相关文章
相关标签/搜索