go data structures: Interfaces

用法

go的接口, 实现了类似于python这种动态语言的鸭子类型(duck typing)编程模式, 同时编译器会进行必要的检查, 可以发现当需要传入一个拥有Read方法的变量时, 而实际上传入了一个int类型. 为了使用接口, 你需要定义一个接口类型(假设这里的, ReadCloser):

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
}

你可以向ReadAndClose传入任何有的ReadClose函数的类型参数(ReadClose函数形式要正确), 如果你使用了一个不合规范的类型, 那么在编译过程中, 编译器就会报错.

接口不仅可以静态检查, 你还可以动态检查一个特定的接口变量是不是有额外的方法. 例如:

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 “???”
}

拥有静态类型interface{}的变量, 不保证这个变量对应的类型有任何方法, 这个变量可以是任意类型. 上述代码中的if 语句中的ok用来返回这个变量是否可以转化为拥有String函数的Stringer接口类型. 如果可以的话, 调用v.String()函数获取这个变量的字符串表示, 否则尝试一些基本类型, 如果不是, 放弃尝试, 返回一个默认值. 上述函数是fmt包中的行为的简化版.

下面这个例子, 我们可以给一个基本类型定义一个别名, 然后提供一些函数, 这里是给uint64提供一个别名, 然后定义一个String函数和Get函数:

type Binary uint64

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

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

ToString函数内部对传参调用了String函数, BinaryString函数, 那么Binary类型变量就可以作为参数传入ToString函数. 运行期间, 只要发现一个类型有String函数, 那么这个类型就可以当做实现了Stringer接口的变量, 不用关心是Binary先定义, 还是Stringer先定义, 或者定义Binary类型的作者是否知道有Stringer类型. 这点与C++, java一类的接口是很不一样的.

接口类型的变量

对于对象的方法调用, 有两种实现做法, 一种是为一个类的所有方法调用准备一个静态表, 或者说在每一次方法调用时, 进行一个方法查找, 同时使用缓存的方式来提高查找效率. go语言使用了一个中间的方式, go语言确定有方法表, 但是这个表是运行时生成的.

有一点需要说明, Binary类型的变量是一个由两个32-bit(word)组成的64-bit的数字 (我们假设32-bit的机器, 内存向下增长):

接口类型的变量由两个字的组表示, 其中一个字是一个指针, 这个指针用来指向这个接口所存的值的类型信息, 另外一个字作为指针用来指向关联的数据. b赋值给一个Stringer接口类型, 会同时设置这个接口类型变量的这两个指针.

图片中接口变量所使用的箭头是灰色的, 表示这些信息是隐式的, go语言没有直接暴露这些信息.

接口变量的第一个字指向interface table或者说itable(在运行时源代码中, C实现的名字是Itab). 这个表格开始是一些关于这个类型的元数据, 之后是函数指针列表. 注意, 这个itable对应于这个接口类型, 而不是实际的动态类型. 根据这个例子来说, Stringeritable保存着Binary中实现Stringer接口的函数, Binary中的别的函数不出现在itable中的.

接口变量中的第二个字指向实际的值, 在这个例子中是b的一个拷贝. var s Stringer = b 创建一个b的拷贝, 而不是指向b, var c uint64 = b创建一个拷贝一样的效果. 如果b之后发生改变, 那么sc还是以前的值, 而不是改变后的新值. 存储在接口中的值可以任意大, 但是在接口中只有一个字来存储实际的值,所以赋值操作会在堆中分配一段内存空间, 然后将这段空间的地址放在接口的值上. (如果实际存放的值可以放在接口的值上, 那么可以进行优化).

就像上面type switch展示的那样, 为了检查一个接口变量是否存有一个特定类型的值, go编译器会生成类似于C表达式: s.tab->type 来获取类型指针, 然后与特定类型进行比较. 如果类型匹配, 这个接口变量的值(s.data)将会拷贝到目标对象中.

为了调用s.String(), go编译器生成类似于C表达式: s.tab->fun[0](s.data)的代码, 查找合适的函数, 然后传入接口的值作为第一个变量, 进行调用. 注意, itable表中的函数传入的是32-bit的指针(接口变量的第二个字),而不是这个指针指向的64-bit的值. 通常来说, 接口类型调用不知道这个字的含义, 也不知道这个指针指向了多少数据.  接口类型的生成代码使itable中的函数指针接受存储在接口变量的值. 所以,这个例子中的函数指针是(*Binary).String而不是Binary.String.

上述例子是一个只有一个函数的接口, 拥有多个方法的接口, itable底部的fun列表中有更多的方法入口.

生成Itable

现在, 我们已经知道了itables的形式, 那么go如何生成这些itables? go的动态类型转化, 使得compilerlinker预先生成所有可能的itables不太现实, 代码中有太多的(interface type, concrete type), 并且绝大多数的都是不需要的. 但是, go的编译器会为每一个具体类型(例如Binary, int, 或者func(map[int]string))生成一个类型描述结构. 除了一些元数据之外, 类型描述结构会包含这个类型实现的方法列表. 当然, go编译器也会为所有的接口类型生成类型描述结构, 这些结构也包含方法列表. 接口在运行时通过在具体类型中查找所有接口声明的方法来生成itable, go运行环境会缓存这些itable, 以便后续出现时使用.

在我们的简单例子中, Stringer的方法表中有一个方法, Binary的表中有两个方法. 通常情况下, 接口类型有ni个方法, 具体类型有nt个方法. 使用最直观的搜索方法需要O(ni*nt)的时间, 但是通过对接口中的方法和具体类型的方法预先排序, 我们可以使用O(ni+nt)的时间来建立这种映射关系.

内存优化

上述实现中的内存使用可以通过以下两种方式进行优化.

第一, 如果接口类型为空, 也就是说这个类型没有任何函数, 那么itable没有什么特殊的用处. 在这种情况下, 我们可以移除itable, 接口的类型字段可以直接指向实际的类型.

 

一个接口类型是否有方法是一个静态属性, 可以从源代码中得到结果. 编译器知道这个接口类型是否有方法, 然后可以针对性地进行操作.

第二, 如果接口变量的值可以存储到一个机器字中, 那么就没有必要进行堆分配,然后将值存储到分配的内存中. 如果我们定义了类似于BinaryBinary32, 但是实现中使用了uint32, 那么就可以把接口变量的实际值存储到接口变量的第二个字中.

 

是使用接口变量的第二个字指向实际的值,还是直接存储在第二个字中, 取决于实际存储的值的大小. 编译器会生成合适的函数(这些函数会被拷贝到itable), 用来对传入的字进行合适的处理. 如果这个类型可以存到一个字中, 那么这个字会被直接使用, 如果不行, 就会解引用. 下面的例子可以作为参考: Binaryitable中的String函数的形式是(*Binary).String, Binary32itable中的String函数的形式是Binary32.String, 而不是(*Binary32).String.

当然, 空接口保存一个不大于一个字的变量可以使用上述的两种优化:

 

方法查询性能

Smalltalk和许多动态语言在每次方法调用的时候, 进行一次方法查找. 从速度考虑, 许多实现在每个调用点使用单一的缓存用于查找方法. 在多进程环境中, 这些缓存需要小心管理, 因为多个线程可能同时位于同一调用点. 就算这些竞争能够被避免, 这些缓存也可能会成为一种内存争用, 从而影响性能.

因为go是一种静态类型加上动态方法查找, 所以go可以把查找移动到值存储到接口的变量的时候. 例如, 考虑下面的代码段:

   1. var any interface{} // initialized elsewhere
   2. s := any.(Stringer) // dynamic conversion
   3. for i := 0; i < 100; i++ {
   4.fmt.Println(s.String())
   5.}

go语言中, 它的itable计算(或者在cache)中查找位于第2行的赋值语句; 4行的s.String()函数调用是几条内存访问和一个间接调用语句.

相对的, 在动态语言例如Smalltalk(或者JavaScript, Python), 这段代码的实现会在第4步执行方法查找, 在一个循环中, 这样做会重复很多无意义的工作. 通过缓存,  可能会使这个过程变得比较快, 但是还是比一次间接调用语句更加花费时间.

当然, 这个是一篇博客给出的说明, 我没有任何数据佐证这个结果, 但是减少内存争用在一个繁忙的并行程序中, 应该会对程序的速度有比较大的提升, go语言的将方法查找从循环中移到循环前面, 正好可以起到这个作用.

更多信息

接口的动态支持在$GOROOT/src/pkg/runtime/iface.c文件. 这个文件中有更多关于接口和类型描述的信息(可以用于反射,以及获取接口运行时信息), 这些信息以后的博客可能会说明.

代码

支持代码(x.go)

Supporting code (x.go):

package main

import (
    "fmt"
    "strconv"
)

type Stringer interface {
    String() string
}

type Binary uint64

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

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

func main() {
    b := Binary(200)
    s := Stringer(b)
    fmt.Println(s.String())
}

如果想要查看编译后的汇编代码, 在我的电脑上是:

$ /usr/local/go/pkg/tool/linux_amd64/compile -S test.go

翻译原文参考: https://research.swtch.com/interfaces

原文地址:https://www.cnblogs.com/albizzia/p/10878744.html