原文 Go Data Structures: Interfaces

作者 Russ Cox

声明:本文目的仅仅作为个人mark,所以在翻译的过程中参杂了自己的思想甚至改变了部分内容。但由于译者水平有限,所写文字或者代码可能会误导读者,如发现文章有问题,请尽快告知,不胜感激。


一些知识点

  1. Method Set方法集合,Go中每个类型都有其与之关联的方法集合,interface类型的方法集合是其接口,除了interface类型的其他类型T的方法集合是所有receiverT的所有方法,而类型*T的方法集合则是receiver*T或者T的所有方法
  2. 方法调用,如果x是类型T的实例,且表达式&x可以生成一个指向类型*T的指针,那么:假如*T的方法集合包含了someMethod方法而T没有,x.someMethod()是有效的,其本质是(&x).someMethod()

正文

Go中的接口是允许我们使用鸭子类型,但他和某些动态语言(比如Python)不同的是:Go编译时会捕获那些显而易见的错误,比如当接口中定义了Read()方法时,如果我们传递int类型,或者是即使我们传递了一个有Read()方法的类型但参数的数量或者类型和接口中定义的不一致,都会导致报错。来看一个简单的接口例子:

type ReadCloser interface {
    Read(b []byte) (n int, err os.Error)
    Close()
}

然后我们就可以定义一个接收ReadCloser类型的函数:

// 这个函数先调用Read()方法获取请求的数据然后调用Close()方法
func ReadAndClose(r ReadCloser, buf []byte) (n int, err os.Error) {
    for len(buf) > 0 && err == nil {
        var nr int
        nr, err = r.Read(buf)
        n += nr
        buf = buf[nr:]
    }
    r.Close()
    return
}

任何一个实现了ReadCloser中所定义的方法(不仅仅是方法名相同,方法的参数数量以及对应的类型也要相同,在原文中作者称之为signatures)的类型都可以传递到ReadAndClose函数中并执行,如果我们在某个地方传递了一个int类型过去,Go在编译的时候就会报错,但是像Python则会是在运行时报错。

同时,接口不仅限于静态检查。我们可以动态检查特定接口值是否具有其他方法。比如:

type Stringer interface {
    String() string
}

func ToString(any interface{}) string {
    if v, ok := any.(Stringer); ok {
        return v.String()
    }
    switch v := any.(type) {
    case int:
        return strconv.Itoa(v)
    case float:
        return strconv.Ftoa(v, 'g', -1)
    }
    return "???"
}

any参数被定义为空接口类型,也就是说在其中并没有限定必须含有哪些方法,更进一步的说:任何类型都可以作为参数传递进来。if语句中的comma ok赋值询问是否可以将any转换为具有String方法的Stringer类型的接口值。如果是的话,接下来的语句会执行String方法并返回一个字符串。否则,switch语句则会在结束前判断其是否为几个基本类型然后执行响应的逻辑。

实现一个简单的例子,有一个新的64位整数类型,他有一个以二进制形式打印值的String方法,还有一个Get方法:

type Binary uint64

func (i Binary) String() string {
    return strconv.Uitob64(i.Get(), 2)
}

func (i Binary) Get() uint64 {
    return uint64(i)
}

Binary类型的值可以传递给ToString,即使程序从未说明Binary实现了Stringer接口,它也会使用String方法对其进行格式化。因为运行时可以知道Binary有一个String方法,所以它实现了Stringer,即使Binary的作者从未听说过Stringer

这些示例表明,即使在编译时检查了所有隐式转换,显式地接口到接口的转换也可以在运行时查询方法集。Effective Go中有更多的如何使用接口的例子。

接口值

含有"方法"概念的语言大部分都属于两个阵营中的一个(比如:C++ Java),要么是静态的为所有方法调用预置一个表,要么是调用时再查找(比如:Python)然后将其缓存起来。Go语言则是两边都沾一点:虽然他有方法集合表,但这个表是运行时构建。

先来做一个热身,Binary是一个由两个32位"字"组成的64位长的类型

Binary

接口类型的值表现为两个字(假设我们处于32位系统中,那么一个字就是32位,本文中如没有特别声明,默认为32位系统),其中第一个字作为指针指向真正的值的元数据(包含类型,方法列表),第二个字作为指针指向真正的值。如下图所示:s := Stringer(b)赋值语句会隐式的对这两个字填充值。

接口的数据结构

接口的第一个字指向了我比较喜欢用的叫做interface table或者itable的东西。itable开头是一个存储了类型相关的元数据信息,接下来就是一个由函数指针组成的列表。注意:itable和接口类型相对应,而不是和动态类型。就我们的例子而言:Stringer中的itable只是为了Stringer而建立,只关联了Stringer中定义的String方法,而像Binary中定义的Get方法则不在其范围内。

接口的第二个字我称之为data,其存储或者指向了实际的数据,这上面的例子中也就是指向了b。赋值语句var s Stringer = b实际上对b做了拷贝,而不是对b进行引用。存储在接口中的值可能有任意大小,但接口只提供了一个字来专门存储真实数据,所以赋值语句在堆上分配了一块内存,并将该字设置为对这块内存的引用。

ps:++itable所指向的元数据是可以被同一个类型的不同实例所共享的,而data则没法共享。++

如果我们想要知道接口是否内含了一个特定的类型,就像上面代码中的type swith 那样,Go编译器会产生类似于C语言中的s.tab->type表达式等效的代码来获得类型指针然后检查其是否是我们所期望的类型。如果类型匹配,那么值会通过s.data解引用copy过去。

如果我们想要调用s.String(),Go编译器会产生和C语言中s.tab->fun[0](s.data)表达式等效的代码。他会从itable中找到并调用对应的函数指针,然后将data中存储的数据作为第一个参数传递过去(仅仅是在本例中)。如果运行 8g -S x.go(在文章末尾有详解) 就可以看到这个过程。需要注意的是:Go编译器传递到itable中的是data中的值(32位)而不是该值所对应的Binary(64位)。通常负责执行接口调用的组件并不知道这个字表示啥,也不知道这个指针指向了多大的数据,相反的,接口代码安排itable中的函数接收接口的datada这个32位长的指向原始数据的指针的形式来作为参数传递。因此,本例中的函数指针是(*Binary).String而不是Binary.String

在本例中,我们仅仅考虑了只有一个方法的接口的情况,而包含多个方法的接口则是在itable的尾部拥有更长的函数指针列表。

计算itable

现在我们已经知道itable长啥样了,但我们还不清楚他们是怎么生成的。Go的动态类型转换意味着:对于编译器或者链接器来说,因为有太多的接口类型以及具体类型(可以说是除接口类型以外的所有类型),预先计算出所有可能的itable是不合理的,而且如果这样做的话很可能绝大多数我们用不到。相反的,Go编译器为每一个具体类型(Binary, int, func(map[int]string) 等等)生成一个用来描述类型的结构-类型描述结构。在元数据中,类型描述结构包含由该类型所实现的方法列表。相似的,编译器也会为每一个接口类型(比如说: Stringer)生成一个不同类型的类型描述结构,这个结构里面也包含了一个方法列表。接口运行时通过查找具体类型的方法表,再根据接口类型的方法表中所列出的每个方法来计算itable。运行时会在计算出itable后将其缓存起来。所以这样只需计算一次。

在我们的简单例子中,Stringer中的方法表中只有一个方法,而Binary的方法表中则有两个方法,通常接口可能会有 ni 个方法,而具体的类型可能会有 nt 个方法,显然为了找到具体类型方法与接口方法的映射将会需要O(ni * nt)的时间,但我们可以做一些优化。通过对两个方法表进行排序并进行同时处理,我们可以用O(ni + nt)的时间来完成这个映射的构建。

内存优化

我们大体上有两种方式来进行内存优化。

首先,如果是空接口interface {},因为空接口没有定义任何方法,所以itable中的方法列表就是一个空的,也就是说其中就仅仅剩下了一个指向原始类型的指针。这种情况下,我们就可以直接丢弃掉itable然后在第一个字中放一个指向原始类型的指针就可以了。

空接口

编译器根据一个接口是否含有方法,选用不同的接口结构。

然后,如果原始的值可以直接放入字中,也就是说其小于32位,那么我们就不要在堆上申请空间来存储了,直接把他放到data中就好了。

原始数据的长度小于等于字的长度

data中是存原始数据的指针还是直接存原始数据取决于原始数据的大小(长度),编译器管理每个类型的方法列表中的函数,并根据data中是指针还是原数据作出响应的处理。上面代码中的Binary因为是64位的,所以data存储的是指针,而itable的方法中存储的是(*Binary).String;如果Binary是32位的,那么data中存储的就是原数据,itable中方法列表存储的则是Binary.String

文末总结

  1. 编译过程中,编译器会为每一个类型创建一个类型描述符,该类型描述符包含了该类型的方法集合。
  2. 除了接口类型的其他类型(我们可以称之为具体类型)和程序中所定义的所有接口类型存在某些转换关系,而当某个具体类型type A可以转换为某个接口类型interface B时,我们可以认为该具体类型A和该接口类型B存在转换关系,转换关系存储在itable中,该itable对所有的A -> B转换都通用。但因为我们在程序中可能定义了许许多多的接口口类型于具体类型,所以我们将"所有的转换关系在编译时预先生成出来"这种方式不可取,一是麻烦,二是会生成很多程序根本就用不到的itable,所以itable在运行时生成。
posted on 2019-03-19 21:05  MnCu  阅读(894)  评论(0编辑  收藏  举报