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传入任何有的Read和Close函数的类型参数(Read和Close函数形式要正确), 如果你使用了一个不合规范的类型, 那么在编译过程中, 编译器就会报错.
接口不仅可以静态检查, 你还可以动态检查一个特定的接口变量是不是有额外的方法. 例如:
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函数, 而Binary有String函数, 那么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对应于这个接口类型, 而不是实际的动态类型. 根据这个例子来说, Stringer的itable保存着Binary中实现Stringer接口的函数, Binary中的别的函数不出现在itable中的.
接口变量中的第二个字指向实际的值, 在这个例子中是b的一个拷贝. var s Stringer = b 创建一个b的拷贝, 而不是指向b, 和var c uint64 = b创建一个拷贝一样的效果. 如果b之后发生改变, 那么s和c还是以前的值, 而不是改变后的新值. 存储在接口中的值可以任意大, 但是在接口中只有一个字来存储实际的值,所以赋值操作会在堆中分配一段内存空间, 然后将这段空间的地址放在接口的值上. (如果实际存放的值可以放在接口的值上, 那么可以进行优化).
就像上面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的动态类型转化, 使得compiler和linker预先生成所有可能的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, 接口的类型字段可以直接指向实际的类型.
一个接口类型是否有方法是一个静态属性, 可以从源代码中得到结果. 编译器知道这个接口类型是否有方法, 然后可以针对性地进行操作.
第二, 如果接口变量的值可以存储到一个机器字中, 那么就没有必要进行堆分配,然后将值存储到分配的内存中. 如果我们定义了类似于Binary的Binary32, 但是实现中使用了uint32, 那么就可以把接口变量的实际值存储到接口变量的第二个字中.
是使用接口变量的第二个字指向实际的值,还是直接存储在第二个字中, 取决于实际存储的值的大小. 编译器会生成合适的函数(这些函数会被拷贝到itable中), 用来对传入的字进行合适的处理. 如果这个类型可以存到一个字中, 那么这个字会被直接使用, 如果不行, 就会解引用. 下面的例子可以作为参考: Binary在itable中的String函数的形式是(*Binary).String, 而Binary32在itable中的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