Go Data Structures: Interfaces

87 阅读8分钟

1. 前言

本文是学习这篇 Go Data Structures: Interfaces 的总结,虽然是2009年发表的,但是可以从其中学习到 Go 语言中 interface 的实现原理,需要注意的是原文是基于 gc 编译器:6g,8g 和 5g。但是也有其它编译器的实现如:gccgo,你可以从这里了解到 gccgo 对于 interfcae 的实现。

2. 使用 Interface

interface 是 Go 中一种类型,就像 uint32,string,struct 等等其它类型一样。首先我们看如何定义一个 interface。

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

在上面定义了一个名为 ReadCloser 的 interfcae。可以看出 ReadCloser 中声明了两个函数签名Read(b []byte) (n int, err os.Error)Close()

然后我们定义一个名为 ReadAndCloser 的函数,该函数的参数类型为分别为 ReadCloser 和 []bytes,当你调用该函数时,编译器会检查传递给该函数的参数类型是否匹配。因为是在编译器时检查,所以也叫做 static checking。如果你传递一份 int 类型的值给 r,那么编译器就会报错,因为 r 的类型为 ReadCloser。那么如何获取该类型的值呢?我们接着往下看。

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
}

2.1 隐式实现 interface

假设我们定义了一个 Reader 的 struct。

type PaperReader struct {
    fileName: string
}
​
func (* PaperReader) Read(b []byes) (n int, err os.Error) {
    //do something
    //return result
}
​
func (* PaperReader) Close() {
    //do something
}

我们从上面代码可以看出,我们定义了名为 PaperReader 的 struct,并且为它实现了 Reader 和 Close 两个函数,这两个函数的函数签名与 ReadCloser interface 中声明的两个函数签名一致,那么我们就可以认为 PaperReader 实现了 ReaderCloser 这个接口。并且可以将一个类型为 PaperReader 的值传递给 ReadAndClose 函数中的参数 r。

var reader = PaperReader{fileName: "test"}
ReadAndClose(reader, nil) //这样调用函数 ReadAndClose 

那么当我们将一个类型为 T 转换成一个类型为 interface 值时,编译器会检查该类型 T 是否实现了该 interface 中声明的所有函数签名的函数。如果检查到没有,则编译器就会报错。

2.2 empty interface

定义一个空的 interface 如下所示:

interface {}

因为空的 interface 中没有任何函数签名,所以可以默认所有类型的值都可以转化为一个类型 empty interface 的参数,因为任何类型都至少实现了 0 个方法。

func emptyInterface(i interfcae {}) {
    //do something
}
​
var n = 2
var s = "empty interfcae"emptyInterface(i) // 类型为int32的 n 传递给参数 i
emptyInterface(s) // 类型为string的 s 传递给参数 i

2.3 check dynamically

考虑如下代码:

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 "???"
}

ToString 的参数 any 类型为 interface {},这意味着它可以包含任何值。可以使用 any.(T) 语句判断它是否能够转换成类型 T。比如 if 语句判断它是否是为实现了名为 Stringer 的 interfcae 类型。当然你也可以使用 Type switches 来判断 any 的实际类型,如果你不熟悉 Go,可以参考 Methods and interfaces

3. Interface Values

讨论 interface value 也就是讨论一个类型为 interfcae 的值是如何在内存中表示的。

interface 可以看作拥有两个 fields 的 struct,分别为 tab 和 data。如下图所示:假设定义了一个 i 变量,其类型为 interface,其内存结构如下图所示:

3.1 Interface 内存布局

3.1.1 no-empty interface

考虑如下代码:

type Binary uint64func (i Binary) String() string {
    return strconv.Uitob64(i.Get(), 2)
}
​
func (i Binary) Get() uint64 {
    return uint64(i)
}
b := Binary(200) //定义一个变量 b,类型为 uint64
s := Stringer(b) //定义一个变量 s,类型为 Stringer

因为 Binary 实现了 Stringer,则 b 可以转换为 Stringer 类型。其内存示意图如下图所示:

截屏2023-02-18 13.31.12.png

Binary.png

从上面可以看出:

  • 变量 s 的类型为 Stringer。其中 s := Stringer(b),将一个 Binary 类型转换为 Stringer 类型。

  • 一个类型为 Stringer 的 值,指向内存中包含两个 fields 的内存,其中一个名为 tab,另一个名为 data。

  • data 指向 Binary 类型,其值等于 b,也就是 200。

  • tab 指向一个 itable。

    • itable 包含 meta 信息的 type。
    • itable 包含 Binary 实现 Stringer interface 的函数的函数指针。
  • Get 函数并不是 Stringer 中声明的函数,所以它不会出现在变量 s 的 itable 中。

  • data 指向的 Binary 的值,是 b 的一份 copy。所以当你修改 b 的值时,data 指向的值并不会发生改变。

3.1.2 empty interface

当你写下如下代码:

any := (interface{})(b) //定义一个变量 any,类型为 interfcae{}

内存示意图如下图所示:

截屏2023-02-18 13.32.30.png

empty.png

从上面可以看出:

  • empty interfcae 的 tab 字段指向的 itable 只有一个 type 字段,而没有函数指针。

3.1.3 不同 interfcae 之间的转换。

考虑当你写下如下代码:

s := Stringer(b) //定义一个变量 s,类型为 Stringer
any := (interfcae{}) //定义一个变量 any,类型为 interfcae{}

内存示意图如下图所示:

complex.drawio.png

从上面可以看出:

  • any 的 data 指向 s 所指向的 Stringer interface 的内存地址。
  • any 的 tab 值包含 type (Stringer, binary),并没有函数指针。

3.2 type switch

现在让我们来看上面提到的 type switch

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 "???"
}
b := Binary(200) //定义一个变量 b,类型为 uint64
s := Stringer(b) //定义一个变量 s,类型为 Stringer
ToString(s)

any.(Stringer) 这句话通过 s.tab->type 判断是否为 Stringer 类型。

3.3 Call Method

b := Binary(200) //定义一个变量 b,类型为 uint64
s := Stringer(b) //定义一个变量 s,类型为 Stringer
s.String()

当调用 s.String(),它从 itable 中调用正确的函数指针,并且将 interface value 作为该函数的第一个参数。也就是 (*Binary) String()。编译器产生一个类似于C语言的代码,

函数为:

String(p *TypePointer);
s.tab->fun[0](s.data)

这里需要注意的是为什么不是 (Binary) String,如果是这样,则函数为:

String(value Type);

因为并不知道具体的 interface value 的大小为多少,也即Type类型需要分配的资源是无法知道的。所以编译产生代码时,并不知道为函数参数分配多少栈内存,但是如果传递指针,则明确为 32 位。

3.4 Compute Itable

Go 类型之间的动态转换意味着编译器和链接器计算所有的 Itable 是不合理的,并且有些 Itale 并不需要。但是,编译器为每一个具体类型产生一个 type description structure* *。在 type description structure 中包含了该类型实现的方法。同样,编译器为 interface 生成 type description structure,它也包含 method list。

interface runtime 计算 itable 通过寻找 interface type 和 concrete type 的 method list,并且查找到之后,则对其进行缓存,并且只计算一次。

在我们的简单示例中,Stringer 的 method list 有一个方法,而 Binary 的 method list 有两个方法。一般来说,interface type 可能有 ni 个方法,concrete type 可能有 nt 个方法。显然,寻找从 interface method 到 concrete method 的映射的时间负责度为 O(ni × nt) ,但我们可以做得更好。通过对两个方法表进行排序并同时遍历它们,时间复杂度降为 O(ni + nt) 。

4. Memory Optimizations

4.1 Delete Itable

因为 empty interface 没有方法,所以删除可以删除 itable。其内存结构可由下图所示:

截屏2023-02-18 13.37.31.png

optimization.png

4.2 Data field optimization

当 interface 关联的值大小为单个字长,则没有必要引入间接分配或者堆内存分配。假设我们将 Binary32 定义为 uint32。

type Binary32 uint32

则内存结构如下图所示:

截屏2023-02-18 13.38.52.png

Binary32.png

interface 关联的实际值是采用 指针引用 还是 inline(如上图所示)是根据 data 的大小来决定的。当 data 的大小是单字长,则编译器在

Itable 中存储的函数为 Binary32.String() ,而不是上面所示的: *(Binary).String()

4.3 Both optimization

当 empty interface 的关联值为单个字长,其内存结构如下图所示:

截屏2023-02-18 13.39.31.png

4.4 Method Lookup Performance

Smalltalk在每次调用方法都会执行方法查询,为了提高速度,具体实现是:现在每个调用点使用简单的单条目缓存,通常是在指令流本身中。在多线程程序中,这些缓存必须小心管理,因为多个线程可能同时位于同一个 call site。即使避免了竞争,缓存最终也会成为 memory contention 的来源。

Because Go has the hint of static typing to go along with the dynamic method lookups, it can move the lookups back from the call sites to the point when the value is stored in the interface. For example, consider this code snippet:

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 语言中,方法查找在语句 2 处。在语句 4 处,s.String() 访问内存以及 a single indirect call instruction。

但是在 Smalltalk 将会在语句 4 处执行一些不必要的工作,尽管上面提到的缓存能够减少工作量,但是相比 a single indirect call instruction 还是比较昂贵。

5. 总结

  • Interface 也是 Go 语言中的一种 type,其内部由 tab 和 data 两个主要的fields组成。

    • data 表示存储的值。
    • tab 表示存储值的类型以及 interface method list。
  • interface 的内存优化

    • empty interface 删除 itable
    • data 为单个字长时采用直接访问,不另外分配内存。
  • 相比于其他动态语言,Go 的 Itable method Loopup 具有性能优势。

6. 参考

  1. Difference between direct and indirect function() calls
  2. Go Data Structures: Interfaces
  3. Duck typing
  4. Go Interface