Go 数据结构:接口

说明

  1. 由于原文发布于 2009 年,所以其中的示例已无法运行,故有所改动


1. 用法

Go interface 使我们可以像在纯动态语言(比如 Python)中一样,使用鸭子类型注释 1,但仍然可以让编译器捕获明显的错误,比如在期望拥有 Read 方法的对象的地方传递 int,或者使用错误数量的参数调用 Read 方法。为使用接口,首先定义接口类型(比如,ReadCloser):

然后将函数定义为接受 ReadCloser。比如,下面的函数重复地调用 Read,获取所有请求数据,然后调用 Close

调用 ReadAndClose 的代码可以传递任何类型的值,只要它拥有具有正确签名的 ReadClose 方法。与 Python 等语言不同的是,如果传递错误类型的值,那么将在编译时而非运行时收到错误。

接口不仅限于静态检查。可以动态检查特定的接口值是否具有额外方法。比如:

any 具有静态类型 interface{},这意味着根本无法保证它拥有哪些方法:它可以包含任何类型。if 语句里的“逗号 ok”赋值判断是否能将 any 转换为拥有方法 String 的类型 Stringer 的接口值。如果可以,那么语句体调用该方法,获取返回的字符串。否则,在放弃之前,switch 拦截一些基本类型。这基本上就是 fmt 包功能的精简版本。(可以通过在 switch 的顶部添加 case Stringer: 的方式,替换 if,使用单独语句的目的是引起大家对该检查的注意。)

下面示例中的 64 位整数类型拥有以二进制形式打印值的 String 方法,以及无关紧要的 Get 方法:

可以将类型 Binary 的值传递给 ToString,它将使用 String 方法格式化该值,尽管程序从未明确表明 Binary 意图实现 Stringer 接口。这是不必要的:运行时可以看到 Binary 拥有 String 方法,因此它实现 Stringer 接口,即便 Binary 的作者从未听过 Stringer 接口。

这些示例表明,在编译时已检查所有隐式转换,显式的接口到接口转换可以在运行时查询方法集。《Effective Go》提供更多关于如何使用接口值的细节和示例。


2. 接口值

具有方法的语言通常分为两类:静态地准备所有方法调用表(比如 C++注释 2 和 Java),或者在每次调用时进行方法查找(比如 Smalltalk,及其许多模仿者,包括 Javascript 和 Python),并且添加缓存,以提高调用性能。Go 位于两者之间:使用方法表,但在运行时计算这些表。

作为预热,类型 Binary 的值是由 2 个 32 位字组成的 64 位整数(假设是 32 位机器,内存向下生长):

gointer1.png

接口值被表示为两字对(two-word pair),其中一个指针指向存储在接口中的类型的信息,另一个指针指向关联的数据。把 b 赋值给类型 Stringer 的接口值将同时设置接口值的两个字。

gointer2.png

(接口值中包含的指针是灰色的,以强调它们是隐式的,未被直接暴露给 Go 程序。)

接口值中的第一个字指向接口表(interface table,itable)。itable 以涉及的类型的一些元数据开始,然后是函数指针列表。请注意,itable 对应于接口类型,而非动态类型。在上述示例中,持有类型为 BinaryStringer 接口值的 itable 列出用于满足 Stringer 的方法,即 String 方法:Binary 的其它方法(Get)不会出现在 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 中调用适当的函数指针,将接口值的数据字作为函数的第一个参数。注意,itable 中的函数接收的是接口值的第二个字中的 32 位指针,而不是该指针指向的 64 位值。通常情况下,接口调用点不知道该字的含义,也不知道它指向多少数据。相反,接口代码将安排 itable 中的函数指针期望接口值中存储的 32 位表示。因此,本例中的函数指针是 (*Binary).String 而不是 Binary.String

上述示例是仅包含一个方法的接口。拥有多个方法的接口在 itable 底部的 fun 列表中有多个条目。


3. 计算 Itable

至此,我们已经知道 itable 的结构,那么如何生成 itable 呢?Go 的动态类型转换意味着,对于编译器或链接器而言,无法预计算所有可能的 itable:存在太多 (接口类型, 具体类型) 对,并且大多数是不需要的。编译器为每个具体类型(比如 Binaryintfunc(map[string]interface{}))生成类型描述结构体(type description structure)。类型描述结构体包含该类型实现的方法列表。类似地,编译器为每个接口类型(比如 Stringer)生成(不同的)类型描述结构;它也包含方法列表。接口运行时通过在具体类型的方法表中查找接口类型方法表中列出的每个方法的方式,计算 itable。运行时在生成 itable 后将其缓存,以便仅需计算一次该对应关系。

在前面的示例中,Stringer 的方法表只有一个方法,而 Binary 的方法表有两个方法。假设接口类型有 ni 个方法,具体类型有 nt 个方法。那么寻找从接口方法到具体方法的映射的搜索应该花费 O(ni × nt) 时间。但通过对两个方法表进行排序,并且同时遍历的方式,可以在 O(ni + nt) 的时间内构建映射。


4. 内存优化

可以通过两种互补的方式优化上述实现使用的空间。

首先,如果参与的接口类型为空 - 没有方法 - 那么 itable 除保存指向原始类型的指针外,没有任何作用。在这种情况下,可以丢弃 itable,值直接指向该类型:

gointer3.png

接口类型是否拥有方法是静态属性,即源代码中的类型要么声明为 interface{},要么声明为 interface{ methods... },因此编译器知道程序中的每个点使用的是哪种表示形式。

其次,如果与接口值关联的值可以装进单个机器字,那么无需引入间接引用或堆分配。如果定义 Binary32 类似于 Binary,但实现为 uint32,那么可以通过将实际值保存在第二个字中的方式,将其保存在接口中:

gointer4.png

实际值是被指向还是被内联取决于类型的大小。编译器将安排类型方法表中列出的函数(它们将被复制到 itables 中)对传入的字进行正确处理。如果接收器类型可以装进单个字,那么直接使用;否则,将被解引用。该图说明了这一点:在前面的 Binary 版本中,itable 中的方法是 (*Binary).String,而在 Binary32 示例中,itable 中的方法是 Binary32.String 而非 (*Binary32).String

当然,持有字大小(或更小)值的空接口可以同时利用这两种优化:

gointer5.png


5. 方法查找性能

Smalltalk 及许多后续动态系统在每次调用方法时,都执行方法查找。为提高速度,许多实现在每个调用点使用简单的单项缓存,通常位于指令流本身中。在多线程程序中,必须仔细管理这些缓存,因为多个线程可能同时位于同一个调用点。即使已避免竞态条件,这些缓存也将成为内存争用的源头。

由于 Go 同时具备静态类型特性和动态方法查找,因此可以将方法查找的过程从调用点移至在接口中存储值的时刻。比如,对于如下代码片段:

在 Go 中,在第 2 行的赋值过程中计算(或在缓存中找到)itable;在第 4 行执行的 s.String() 调用的分发过程只涉及几次内存获取和一条间接调用指令。

相比之下,在像 Smalltalk(或 JavaScript、Python 等)这样的动态语言中,实现该程序时,将在第 4 行进行方法查找,这将在循环中重复执行不必要的工作,导致执行成本较高。虽然之前提到的缓存使得该过程相对较为节省,但仍然比单个间接调用指令的开销要大。


注释

注释 1 - 鸭子类型

鸭子类型是动态类型语言判断对象是不是某种类型时使用的方法,也叫鸭子判定法。判断一只鸟是不是鸭子,我们只关心它游泳像不像鸭子;叫起来像不像鸭子;走路像不像鸭子。 换言之,如果对象的行为与预期一致(即能够接受某些消息),那么就认定它是某种类型的对象。

注释 2 - C++ 虚函数表

在 C++ 中,当一个类通过继承关系派生出子类,并且子类重写基类的虚函数时,通过使用虚函数表,实现动态绑定。

在编译阶段,编译器将为每个具有虚函数的类生成虚函数表。虚函数表中的每个条目存储对应虚函数的地址。对于派生类,如果它重写基类的虚函数,那么它的虚函数表将替换为包含新函数地址的表。

虚函数表与每个包含虚函数的类相关联。每个类实例都包含指向其对应虚函数表的指针,这个指针称为虚指针(vptr)。当调用虚函数时,实际上是通过虚指针找到对应的虚函数表,然后根据函数在虚函数表中的索引,进行调用。

C++ 通过使用虚函数表实现运行时多态性,可以在运行时根据对象的实际类型,确定调用的函数版本,而不仅仅是根据编译时的静态类型。这种动态绑定的特性使得 C++ 具有灵活的对象多态性和多层次的继承体系。