Golang 接口原理
Python vs C++
C++ 是静态类型语言,所谓静态类型,就是变量、表达式和函数参数的类型在编译阶段就已经确定。
对于基于虚函数的动态多态,编译器在编译阶段已经知道调用表达式的静态类型、类的继承关系以及虚函数的接口,因此可以预先确定虚函数表的布局,并生成“从对象的虚表中取某个固定槽位并调用”的代码。
例如:
class Animal {
public:
virtual void speak() = 0;
};
class Dog : public Animal {
public:
void speak() override {
std::cout << "woof";
}
};
class Cat : public Animal {
public:
void speak() override {
std::cout << "meow";
}
};
void make_sound(Animal* x) {
x->speak();
}
对于 make_sound 中的 x->speak(),编译器虽然不知道运行时传入的是 Dog 还是 Cat,但它知道 x 的静态类型是 Animal*,也知道 Animal 中存在虚函数 speak。因此,编译器可以提前确定 speak 在虚函数表中的位置,并生成访问这个固定槽位的代码。
可以把它简化理解为:
- Dog 的虚表:
slot 0 -> Dog::speak - Cat 的虚表:
slot 0 -> Cat::speak
当传入 Dog 时,运行时通过对象中的虚表指针找到 Dog 的虚表,因此 slot 0 最终指向 Dog::speak;当传入 Cat 时,同样访问 slot 0,但由于虚表不同,最终得到的是 Cat::speak。
因此,运行时真正变化的是对象的动态类型,而不是“去哪里找 speak”这件事。编译器已经知道 speak 对应哪个虚表槽位,运行时只需要根据实际对象选择正确的虚表,再从固定位置取得函数地址。
正是因为静态类型的特性,C++ 的静态类型检查还会保证传入对象满足接口要求。例如:
class Car {};
Car car;
make_sound(&car);
这段代码无法通过编译,因为 Car* 不能作为 Animal* 传入。也就是说,在程序真正运行之前,编译器已经保证传入 make_sound 的对象一定满足 Animal 所要求的接口,因此对应的虚函数槽位一定存在。
所以对于 C++ 虚函数,可以概括为:编译时确定接口和派发位置,运行时确定具体实现。
Python 是动态类型语言,所谓动态类型,就是变量本身不绑定一个固定的静态类型(甚至可以简单的认为变量本身无类型),变量在运行时可以引用不同类型的对象。例如:
class Dog:
def speak(self):
print("woof")
class Cat:
def speak(self):
print("meow")
def make_sound(x):
x.speak()
这里的 make_sound(x) 并没有规定 x 必须是 Dog、Cat,甚至也没有要求它继承某个共同的 Animal 类。
因此,对于:
x.speak()
Python 虽然知道程序要访问一个名为 speak 的方法,但在定义 make_sound 时,通常并不知道 x 最终是什么类型,也就无法像 C++ 那样提前规定“speak 一定在虚表的第几个槽位”。
只有运行时真正执行到 x.speak() 时,Python 才会根据运行时的 x 的实际对象进行属性查找。例如,它可能检查实例本身、实例所属的类,以及类的 MRO,最终找到 speak 并调用。
因此下面两种对象都可以传入:
make_sound(Dog())
make_sound(Cat())
甚至一个完全没有继承关系的类也可以:
class Robot:
def speak(self):
print("beep")
make_sound(Robot())
这就是 duck typing。Python 并不关心 Robot 是否属于某个规定好的类型,只关心运行到 x.speak() 时,这个对象是否真的能够提供 speak。
反过来,如果传入:
class Car:
pass
make_sound(Car())
Python 通常不会在 make_sound 定义时发现问题,也不会因为 Car 没有继承某个接口而拒绝运行。只有真正执行到:
x.speak()
时,才会发现 Car 没有 speak,并抛出 AttributeError。
所以,Python 必须在运行时确定对象是什么,再查找所需的方法,如果方法不存在,也是在运行时才发现
这里还有一个容易产生的误区:不能简单地说“Python 编译时不知道类有哪些方法”。如果代码里明确写着:
class Dog:
def speak(self):
pass
那么从源代码可以分析出到 Dog 定义了 speak。
真正的问题在于,调用点通常没有静态类型约束。例如:
def f(x):
x.speak()
仅仅看到这段代码,无法证明 x 一定是 Dog。它可能是 Dog、Cat、Robot,也可能是一个根本没有 speak 的对象。
而且 Python 还允许类和对象在运行时发生变化,例如:
class Dog:
pass
def speak(self):
print("woof")
Dog.speak = speak
这里 Dog 最初没有 speak,但运行过程中又被动态添加了这个方法。这进一步说明,Python 的对象接口并不一定在程序编译阶段完全固定。
因此,两者最核心的区别总结为:
C++ 虚函数是“静态确定接口和派发位置,动态确定具体实现”;Python duck typing 是“调用所需的接口主要在运行时验证,并通过动态属性查找完成派发”。
Golang
Go 是静态类型语言,并且具有明确的接口类型,因此它首先具备静态类型系统的基本特征:
- 两个方向的检查:入参方向,编译器能够检查一个具体类型的方法集是否满足某个接口,从而在编译阶段发现接口不匹配的问题;函数内部使用接口值时,也能检验是否通过接口值调用了接口中不存在的方法。
- 编译器不需要在运行时按照方法名进行查找,而可以预先把调用转换成“访问接口方法表中的某个固定槽位”。运行时只需要根据接口值实际承载的具体类型,从该槽位取得对应的函数实现并调用。
但 Go 与 C++、Java 的一个重要区别是:Go 的接口实现是隐式的。一个类型不需要显式声明“实现了某个接口”,只要它的方法集满足该接口的方法集合,就被认为实现了该接口。这一点在形式上很像 Python 的 duck typing。
这两个特点共同决定了 Go 的接口派发机制:因为接口的方法集合是静态确定的,所以调用本身可以采用类似 C++ 的固定槽位方法表派发,而不需要像 Python 那样在每次调用时根据方法名动态查找;但由于具体类型并没有显式声明自己实现哪些接口,因此“某个具体类型的方法”如何对应到“某个接口的方法槽位”,不能完全依赖显式的继承/实现关系在编译时建立。
Go Data Structures: Interfaces
下面的内容主要来自于 research!rsc: Go Data Structures: Interfaces 这篇文章
Usage
Go 的接口让你能够像在 Python 这样的纯动态语言中一样使用鸭子类型(duck typing),同时又能让编译器捕获一些明显的错误。例如,当某个参数要求传入一个具有 Read 方法的对象时,你却传入了一个 int;或者调用接口的 Read 方法时传入了错误数量的参数,这些问题都会在编译阶段被发现(也就是前面说的接口提供了两个方向的检查)。
要使用接口,首先需要定义一个接口类型,例如 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 方法即可。
而且,与 Python 这样的语言不同,如果传入的值类型不符合要求,Go 会在编译期报错,而不是等到运行时才发现问题。
不过,接口并不仅仅用于静态检查。你还可以在运行时动态检查某个接口值是否具有额外的方法。例如:
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 的静态类型是 interface{}。这意味着,编译器完全不保证它具有任何方法:它可以包含任意类型的值。
if 语句中的“comma ok”赋值:
v, ok := any.(Stringer)
是在询问:是否能够把 any 转换成一个 Stringer 类型的接口值。Stringer 接口要求实现 String 方法。
如果可以进行这种转换,那么 ok 就为真,程序进入 if 语句体,并调用 String 方法得到需要返回的字符串。
如果不能转换,后面的 switch 会继续检查 any 是否属于几个基本类型;如果这些类型也都不匹配,最后就只能放弃并返回 "???"。
这实际上可以看作 Go 的 fmt 包所做工作的一个高度简化版本。
这里的 if 其实也可以省略,只需要在 switch 最前面添加:
case Stringer:
即可。不过,我特意使用一个单独的 if 语句,是为了突出这里进行的接口检查。
再来看一个简单的例子。假设我们定义了一个 64 位整数类型 Binary。它有一个 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。ToString 会使用它的 String 方法来格式化这个值。
值得注意的是,程序中从来没有显式声明过 Binary “打算实现” Stringer 接口。
实际上,也完全没有这个必要。
运行时可以看到 Binary 具有一个 String 方法,因此它就满足 Stringer 接口。即使编写 Binary 类型的人从来没有听说过 Stringer 接口,这一点依然成立。
这些例子说明,虽然所有隐式转换都会在编译期进行检查,但显式的接口到接口转换,却可以在运行时查询一个值的方法集合。
关于接口值的使用方法,《Effective Go》中还有更多细节和示例。
Interface Values
拥有方法的编程语言通常分为两类:一种是在编译期静态地为所有方法调用准备好方法表(如 C++ 和 Java),另一种是在每次调用时进行方法查找(如 Smalltalk 以及许多模仿它的语言,包括 JavaScript 和 Python),然后再加入各种复杂的缓存机制来提高调用效率。Go 则介于两者之间:它有方法表,但这些方法表是在运行时计算出来的。
Binary 类型的值其实只是一个由两个 32 位字组成的 64 位整数(和上一篇文章一样,我们假设使用的是一台 32 位机器;这一次内存向下增长,而不是向右增长):
接口值由两个字组成:一个字是指向接口中所存值的类型信息的指针,另一个字是指向相关数据的指针。把 b 赋值给一个 Stringer 类型的接口值时,会设置接口值中的这两个字。
(接口值中包含的指针用灰色表示,是为了强调它们是隐式的,并不会直接暴露给 Go 程序。)
接口值中的第一个字指向我所说的接口表,也就是 itable(读作 i-table;在运行时源码中,其 C 实现中的名称是 Itab)。itable 开头包含接口类型、具体类型等类型信息,之后则是一组函数指针。需要注意的是,itable 对应的是接口类型,而不是动态类型。就我们的例子而言,当 Stringer 中保存的是 Binary 类型的值时,它对应的 itable 会列出用于满足 Stringer 接口的方法,也就是只有 String;Binary 的其他方法(Get)不会出现在这个 itable 中。
接口值中的第二个字指向实际的数据,在这里就是 b 的一个副本。赋值 var s Stringer = b 会复制一份 b,而不是让接口值直接指向 b,原因和 var c uint64 = b 会复制一份 b 是一样的:如果之后 b 发生变化,s 和 c 应该仍然保留原来的值,而不是变成新的值。存储在接口中的值可能任意大,但接口结构中只有一个字用来保存这个值,因此赋值时会在堆上分配一块内存,并在这个单字槽位中记录指向它的指针。(当值本身能够放进这个槽位时,显然存在一种优化方式;这一点我们稍后会谈到。)
为了检查某个接口值中是否保存了某个特定类型,就像前面的type switch那样,Go 编译器会生成效果等价于 C 表达式 s.tab->type 的代码,用它取得类型指针,并与目标类型进行比较。如果类型匹配,就可以通过解引用 s.data 来复制其中的值。
为了调用 s.String(),Go 编译器会生成效果等价于 C 表达式 s.tab->fun[0](s.data) 的代码:它从 itable 中取出相应的函数指针并调用(函数指针是如何填充的见下一小节),同时把接口值的数据字作为这个函数的第一个参数(在这个例子中,也是唯一的参数)。如果运行 8g -S x.go,就可以看到这段代码(具体细节见本文末尾)。需要注意的是,传给 itable 中函数的,是接口值第二个字中保存的那个 32 位指针,而不是该指针所指向的 64 位值。一般来说,接口调用的位置并不知道这个字表示什么,也不知道它指向的数据有多大。相反,接口实现会保证 itable 中的函数指针能够接受接口值中所保存的这种 32 位表示。因此,在这个例子中,函数指针是 (*Binary).String,而不是 Binary.String。
我们这里讨论的例子是一个只有一个方法的接口。如果接口包含更多方法,那么 itable 底部的 fun 列表中就会有更多条目。
Computing the Itable
现在我们已经知道 itable 长什么样了,但它们又是从哪里来的呢?Go 的动态类型转换意味着,让编译器或链接器预先计算出所有可能的 itable 并不现实:可能的(接口类型,具体类型)组合太多了,而且其中绝大多数实际上都不会用到。相反,编译器会为每个具体类型生成一个类型描述结构,例如 Binary、int 或 func(map[int]string)。除了其他元数据之外,这个类型描述结构还包含该类型所实现的方法列表。类似地,编译器也会为每个接口类型(例如 Stringer)生成一个不同的类型描述结构,其中同样包含一个方法列表。接口运行时会遍历接口类型方法表中的每个方法,并在具体类型的方法表中查找对应的方法,从而计算出 itable。itable 生成后,运行时会将其缓存起来,因此这种对应关系只需要计算一次。
在我们这个简单的例子中,Stringer 的方法表中只有一个方法,而 Binary 的方法表中有两个方法。一般来说,接口类型可能有 ni 个方法,具体类型可能有 nt 个方法。最直接的做法是逐一查找接口方法到具体方法的对应关系,这需要 O(ni × nt) 的时间,但我们可以做得更好。通过先对两个方法表进行排序,再同时遍历它们,就可以在 O(ni + nt) 的时间内建立这种映射关系。
Memory Optimizations
上面所描述的实现所占用的空间,可以通过两种相互补充的方式进行优化。
首先,如果涉及的是空接口类型,也就是说它没有任何方法,那么 itable 除了保存指向原始类型的指针之外就没有其他作用。在这种情况下,可以直接省略 itable,让接口值直接指向类型信息:
接口类型是否包含方法是一个静态属性——源代码中的类型要么写成 interface{},要么写成 interface{ methods... }——因此,编译器知道程序中每个位置使用的是哪一种表示形式。
其次,如果与接口值关联的实际值能够放进一个机器字中,就没有必要再引入一次间接寻址,也不需要在堆上分配内存。如果我们定义 Binary32,让它与 Binary 类似,但底层使用 uint32 实现,那么它就可以直接把实际值保存在接口值的第二个字中:
)
实际值究竟是通过指针间接保存,还是直接内联到接口值中,取决于该类型的大小。编译器会安排类型方法表中列出的函数(这些函数随后会被复制到 itable 中)正确处理传入的那个机器字。如果接收者类型能够放进一个机器字,就直接使用这个值;否则,就对它进行解引用。图中也展示了这一点:在前面较远处的 Binary 示例中,itable 里的方法是 (*Binary).String;而在 Binary32 的例子中,itable 里的方法是 Binary32.String,而不是 (*Binary32).String。
当然,如果空接口中保存的是一个机器字大小或更小的值,那么这两种优化可以同时使用:
Method Lookup Performance
Smalltalk 以及后来许多动态系统都会在每次调用方法时进行一次方法查找。为了提高速度,很多实现会在每个调用点使用一个简单的单项缓存,而且这个缓存通常就放在指令流本身之中。在多线程程序中,这些缓存必须被谨慎管理,因为多个线程可能会同时执行到同一个调用点。即使解决了竞态问题,这些缓存最终仍可能成为内存争用的来源。
由于 Go 在动态方法查找之外还带有一定的静态类型信息,因此它可以把方法查找从调用点提前到值被存入接口的时候。例如,考虑下面这段代码:
var any interface{} // initialized elsewhere
s := any.(Stringer) // dynamic conversion
for i := 0; i < 100; i++ {
fmt.Println(s.String())
}
在 Go 中,itable 会在第 2 行的赋值过程中被计算出来(或者从缓存中找到);而第 4 行执行 s.String() 调用时的分派,只需要几次内存读取和一条间接调用指令。
相比之下,如果用 Smalltalk(或者 JavaScript、Python 等)这样的动态语言来实现这段程序,就需要在第 4 行进行方法查找,而这段代码位于循环中,因此会反复执行这些不必要的工作。前面提到的缓存机制可以降低这项开销,但它仍然比一条简单的间接调用指令更昂贵。
当然,这只是一篇博客文章,所以我并没有任何具体数据来支撑上面的讨论。不过,在高度并行的程序中,避免内存争用看起来显然会带来很大的收益;同样,把方法查找移出紧密循环也是如此。另外,我这里讨论的是整体架构,而不是具体实现细节:后者大概仍然还有一些常数因子层面的优化空间。
More Information
接口的运行时支持代码位于 $GOROOT/src/pkg/runtime/iface.c。关于接口其实还有很多内容可以讨论(我们甚至还没有看到一个使用指针接收者的例子),类型描述符也是如此(除了服务于接口运行时之外,它们还是反射机制的基础),不过这些内容只能留到以后的文章中再谈了。
补充:Pointer Receivers
前面的例子中,Binary 的 String 方法使用的是值接收者,因此 Binary 类型的方法集中包含 String,Binary 可以直接作为 Stringer 接口的动态类型。如果把 String 改成使用指针接收者,例如 func (i *Binary) String() string,情况就会有所不同。此时 String 属于 *Binary 的方法集,而不属于 Binary 的方法集,因此实现 Stringer 接口的是 *Binary,而不是 Binary。例如:
var b Binary = 10
var s Stringer = &b // 正确
var t Stringer = b // 编译错误
这里 &b 的类型是 *Binary,它的方法集中包含 String() string,因此可以赋值给 Stringer。而 b 的类型是 Binary,它的方法集中并不包含这个使用指针接收者定义的 String 方法,因此不能直接赋值给 Stringer。
需要注意的是,这和普通的方法调用有所不同。对于一个可寻址的 Binary 变量,即使 String 使用的是指针接收者,仍然可以直接写 b.String(),这是语法糖,编译器会自动取得 b 的地址,将其处理为类似 (&b).String() 的调用。不过,这种自动取地址只适用于方法调用,并不会改变 Binary 本身的方法集,因此也不会使 Binary 自动实现 Stringer。
当执行 var s Stringer = &b 时,s 是一个非空接口值。概念上,它由两个机器字组成:第一个字指向一个与接口类型 Stringer 和动态具体类型 *Binary 对应的 itab,第二个字保存动态值的数据。这里的 itab 记录了接口类型 Stringer、具体类型 *Binary,以及用于实现接口方法的方法入口。由于 Stringer 只要求一个 String 方法,因此相应的方法槽 Fun[0] 指向满足该接口方法的 (*Binary).String。即使 *Binary 还具有其他方法,只要这些方法不是 Stringer 所要求的,它们也不会出现在这个 itab 的方法表中。
这里赋给接口的是 &b,其动态类型本身就是 *Binary。因此,接口数据部分保存的是指向原变量 b 的指针值,而不是 Binary 值的一份独立副本。也就是说,通过接口调用 String 时,接收者最终仍然指向原来的 b。如果之后通过 b 本身或其他指向 b 的指针修改了它的值,那么再次执行 s.String() 时,方法看到的也是修改后的值。
运行时可以通过 itab 中保存的类型信息得知接口当前的动态类型。对于这里的 s,其动态类型是 *Binary,因此类型断言 s.(*Binary) 是合法的,并且会成功。另一方面,在当前指针接收者定义下,Binary 本身并不实现 Stringer,所以 s.(Binary) 并不是一个“可以执行但结果失败”的断言,而是一个不合法的类型断言,编译器会直接报告 impossible type assertion。如果将 String 改为值接收者,那么 Binary 也会实现 Stringer;这时 s.(Binary) 才是合法的,但如果 s 的实际动态类型仍然是 *Binary,该断言会在运行时失败。
调用 s.String() 时,其效果可以理解为类似 s.tab->fun[0](s.data):先从接口值的 itab 中取得 String 对应的方法入口,再把接口的数据部分作为接收者传给该方法。对于当前例子,Fun[0] 对应 (*Binary).String,而 Data 中保存的是指向 b 的指针,因此最终调用作用于原来的 b。
评论