golang技术随笔一深入理解interface

Go语言的主要设计者之一罗布·派克( Rob Pike)曾经说过,如果只能选择一个Go语言的特 性移植到其他语言中,他会选择接口。可见接口在golang中的地位,及其对gloang这门语言所带来的活力。

golang中的interface是什么

接口相当于是一份契约,它规定了一个对象所能提供的一组操作。要理解golang中接口的概念我们最好还是先来看看别的现代语言是如何实现接口的。
C++没有提供interface这样的关键字,它通过纯虚基类实现接口,而java则通过interface关键字声明接口。它们有个共同特征就是一个类要实现该接口必须进行显示的声明,如下是java方式:
interface IFoo {
    void Bar();
}
class Foo implements IFoo { 
    void Bar(){}
}
这种必须 明确声明自己实现了 某个接口的方式 我们称为侵入式接口。关于侵入式接口的坏处我们这里就不再详细讨论,看java庞大的继承体系及其繁复的接口类型我们就可以窥之一二了。
golang则采取了完全不同的设计理念,在Go语言中,一个类只需要实现了接口要求的所有函数,我们就说这个类实现了该接口, 例如:
type IWriter interface {
    Write(buf [] byte) (n int,err error)
}
type File struct {
    // ...
}
func (f *File) Write(buf [] byte) (n int,err error) {
    // ...
}

非侵入式接口一个很重要的好处就是去掉了繁杂的继承体系,我们看许大神在《go语言编程》一书中作的总结:
其一, Go语言的标准库,再也不需要绘制类库的继承树图。你一定见过不少C++、 Java、 C#类库的继承树图。这里给个Java继承树图: http://docs.oracle.com/javase/1.4.2/docs/apI/Overview-tree.html 在Go中,类的继承树并无意义,你只需要知道这个类实现了哪些方法,每个方法是啥含义就足够了。
其二,实现类的时候,只需要关心自己应该提供哪些方法,不用再纠结接口需要拆得多细才合理。接口由使用方按需定义,而不用事前规划。
其三,不用为了实现一个接口而导入一个包,因为多引用一个外部的包,就意味着更多的耦合。接口由使用方按自身需求来定义,使用方无需关心是否有其他模块定义过类似的接口。
如果仔细研究golang中的结构,学C++的同学可能会发现,golang中关于接口的概念很似有点像C++中的Concept,不知道concept的同学可以参看刘未鹏的《C++0x漫谈》系列之:Concept,Concept!。c++用模板来达到这样的效果,不管你使用什么类型来实例化,只要满足该模板所对应的一组操作就可以正常实例化,否则则会编译不通过。不同于C++的模板在可以完全在编译时检查,golang在大多数情况下只能在运行时进行接口查询,关于接口查询的详细情况我们稍后再解释。
另外,如果有同学之前了解过Qt,则很容易的发现这种非侵入式接口的另一个好处,Qt里面一个重要的特性就是信号与槽,它实现了监听者与接收者之间的解耦,它所用的方式实际上是qt的预处理生成静态的连接代码。而如果使用golang来实现这套机制就简直在方便了,不需要预先生成代码的方式, 监听者与接收者之间的解耦本身就是golang的自然表现。

golang中的interface在面向对象思想中所扮演的角色

golang不支持完整的面向对象思想,它没有继承,多态则完全依赖接口实现。golang只能模拟继承,其本质是组合,只不过golang语言为我们提供了一些语法糖使其看起来达到了继承的效果。面向对象中一个很重要的基本原则--里氏代换原则(Liskov Substitution Principle LSP)在这里就行不通了,习惯面向对象语言的同学可能会有些不适应,当你将一个父类的指针指向子类的对象时,golang会毫不吝啬的抛出一个编译错误
golang的设计理念是大道至简,传统的继承概念在golang中已经显得不是那么必要,golang通过接口去实现多态,下面我们看一个例子,看看golang是如何实现依赖倒置原则的,先看C++的实现:
struct IPizzaCooker {
    virtual void Prepare(Pizza*) = 0;
    virtual void Bake(Pizza*) = 0;
    virtual void Cut(Pizza*) = 0;
}
 
struct PizzaDefaultCooker : public IPizzaCooker {
    Pizza* CookOnePizza() {
        Pizza* p = new Pizza();
        Prepare(p);
        Bake(p);
        Cut(p);
        return p;
    }
    virtual void Prepare(Pizza*) {
        //....default prepare pizza
    }
    virtual void Bake(Pizza*) {
        //....default bake pizza
    }
    virtual void Cut(Pizza*) {
        //....default cut pizza
    }
}
 
struct MyPizzaCooker : public PizzaDefaultCooker {
    virtual void Bake(Pizza*) {
        //....bake pizza use my style
    }
}
 
int main() {
    MyPizzaCooker cooker;
    Pizza* p = cooker.CookOnePizza();
    //....
    return 0;
}

本例子很简单,就是通过一个做pizza的类烹饪一个新pizza,烹饪的流程在父类中实现CookOnePizza,子类重写了Bake方法。下面我们看看golang中是如何实现这个例子的:
type IPizzaCooker interface {
    Prepare(*Pizza)
    Bake(*Pizza)
    Cut(*Pizza)
}
 
func cookOnePizza(ipc IPizzaCooker) *Pizza {
    p := new(Pizza)
    ipc.Prepare(p)
    ipc.Bake(p)
    ipc.Cut(p)
    return p
}
 
type PizzaDefaultCooker struct {
}
 
func (this *PizzaDefaultCooker) CookOnePizza() *Pizza {
    return cookOnePizza(this)
}
func (this *PizzaDefaultCooker) Prepare(*Pizza) {
    //....default prepare pizza
}
func (this *PizzaDefaultCooker) Bake(*Pizza) {
    //....default bake pizza
}
func (this *PizzaDefaultCooker) Cut(*Pizza) {
    //....default cut pizza
}
 
type MyPizzaCooker struct {
    PizzaDefaultCooker
}
 
func (this *MyPizzaCooker) CookOnePizza() *Pizza {
    return cookOnePizza(this)
}
func (this *MyPizzaCooker) Bake(*Pizza) {
    //....bake pizza use my style
}
 
func main() {
    var cooker MyPizzaCooker
    p := cooker.CookOnePizza()
    //....
}

由于golang的多态必须借助接口来实现,这实际上已不是严格意义上的依赖倒置了,在这个例子中golang显得有些笨拙,它其实 完全可以有更优雅的实现方案, 举这个例子只是为了给大家介绍多态在golang中的实现方式,以及所谓模拟继承并不等价于面向对象中的继承关系。

interface的内存布局

了解interface的内存结构是非常有必要的,只有了解了这一点,我们才能进一步分析诸如类型断言等情况的效率问题。先看一个例子:
type Stringer interface {
    String() string
}
 
type Binary uint64
 
func (i Binary) String() string {
    return strconv.Uitob64(i.Get(),2)
}
 
func (i Binary) Get() uint64 {
    return uint64(i)
}
 
func main() {
    b := Binary{}
    s := Stringer(b)
    fmt.Print(s.String())
}

interface在内存上实际由两个成员组成,如下图,tab指向虚表,data则指向实际引用的数据。虚表描绘了实际的类型信息及该接口所需要的方法
观察itable的结构,首先是描述type信息的一些元数据,然后是满足Stringger接口的函数指针列表(注意,这里不是实际类型Binary的函数指针集哦)。因此我们如果通过接口进行函数调用,实际的操作其实就是s.tab->fun[0](s.data)。是不是和C++的虚表很像?接下来我们要看看golang的虚表和C++的虚表区别在哪里。
先看C++,它为每种类型创建了一个方法集,而它的虚表实际上就是这个方法集本身或是它的一部分而已,当面临多继承 (或者叫实现多个接口时,这是很常见的),C++对象结构里就会存在多个虚表指针,每个虚表指针指向该方法集的不同部分,因此,C++方法集里面函数指针有严格的顺序。许多C++新手在面对多继承时就变得蛋疼菊紧了,因为它的这种设计方式,为了保证其虚表能够正常工作,C++引入了很多概念,什么虚继承啊,接口函数同名问题啊,同一个接口在不同的层次上被继承多次的问题啊等等……就是老手也很容易因疏忽而写出问题代码出来。
我们再来看golang的实现方式,同C++一样,golang也为 每种类型创建了一个方法集,不同的是接口的虚表是在运行时专门生成的。可能细心的同学能够发现为什么要在运行时生成虚表。因为太多了,每一种接口类型和所有满足其接口的实体类型的组合就是其可能的虚表数量,实际上其中的大部分是不需要的,因此golang选择在运行时生成它,例如,当例子中当首次遇见 s := Stringer(b)这样的语句时,golang会生成Stringer接口对应于Binary类型的虚表,并将其缓存。
理解了golang的内存结构,再来分析诸如类型断言等情况的效率问题就很容易了,当判定一种类型是否满足某个接口时,golang使用类型的方法集和接口所需要的方法集进行匹配,如果类型的方法集完全包含接口的方法集,则可认为该类型满足该接口。例如某类型有m个方法,某接口有n个方法,则很容易知道这种判定的时间复杂度为O(mXn),不过可以使用预先排序的方式进行优化,实际的时间复杂度为O(m+n)。

总纲传送门: golang技术随笔总纲


参考文献

《go语言编程》 许世伟
http://research.swtch.com/interfaces(貌似现在打不开了……)

相关文章

什么是Go的接口? 接口可以说是一种类型,可以粗略的理解为他...
1、Golang指针 在介绍Golang指针隐式间接引用前,先简单说下...
1、概述 1.1 Protocol buffers定义 Protocol buffe...
判断文件是否存在,需要用到"os"包中的两个函数: os.Stat(...
1、编译环境 OS :Loongnix-Server Linux release 8.3 CPU指...
1、概述 Golang是一种强类型语言,虽然在代码中经常看到i:=1...