go

Go 系列 接口

Posted by lichao modified on October 19, 2021

在计算机科学中,接口是计算机系统中多个组件共享的边界,不同的组件能够在边界上交换信息。如下图所示,接口的本质是引入一个新的中间层,调用方可以通过接口与具体实现分离,解除上下游的耦合,上层的模块不再需要依赖下层的具体模块,只需要依赖一个约定好的接口。 存储概览

概述

接口类型是一种抽象类型,不会暴露出它所代表的对象的值、结构,也不会暴露这个对象支持的基础操作的集合;它们只会表现出自己的方法。也就是你不知道它是什么,唯一知道的就是可以通过它的方法来做什么。

1
2
3
4
5
6
7
package io

type Writer interface {
    Write(p []byte) (n int, err error)
}

func Fprintf(w io.Writer, format string, args ...interface{}) (int, error)

这里 fmt.Fprintf 函数没有对 w 具体是什么做任何假设,而是仅仅通过 io.Writer 接口的约定来保证行为(写字节),所以第一个参数可以安全地传入一个只需要满足 io.Writer 接口的任意具体类型的值。如:

1
2
3
4
5
6
type ByteCounter int

func (c *ByteCounter) Write(p []byte) (int, error) {
    *c += ByteCounter(len(p)) // convert int to ByteCounter
    return len(p), nil
}

由于 *ByteCounter 满足 io.Writer 的约定(实现了 Write 方法),所以可以把它传入 Fprintf 函数中:

1
2
3
4
5
6
7
  var c ByteCounter
  c.Write([]byte("hello"))
  fmt.Println(c) // "5", = len("hello")
  c = 0          // reset the counter
  var name = "Dolly"
  fmt.Fprintf(&c, "hello, %s", name)
  fmt.Println(c) // "12", = len("hello, Dolly")

实现接口的条件

一个类型如果拥有一个接口需要的所有方法,那么这个类型就实现了这个接口:

1
2
3
4
5
6
7
8
var w io.Writer
w = os.Stdout           // OK: *os.File has Write method

var rwc io.ReadWriteCloser
rwc = os.Stdout         // OK: *os.File has Read, Write, Close methods

w = rwc                 // OK: io.ReadWriteCloser has Write method
rwc = w  

因为 ReadWriterReadWriteCloser 包含有 Writer 的方法,所以任何实现了 ReadWriterReadWriteCloser 的类型必定也实现了 Writer 接口。

就像信封(接口/io.Writer)封装和隐藏起信件(os.Stdout)来一样,接口类型封装并隐藏具体类型(os.File)和它的值(os.Stdout)。即使具体类型(os.File)有其它的方法(Close() ),也只有接口类型(io.Writer)暴露出来的方法(Write())会被调用到:

1
2
3
4
5
6
7
os.Stdout.Write([]byte("hello")) // OK: *os.File has Write method
os.Stdout.Close()                // OK: *os.File has Close method

var w io.Writer
w = os.Stdout
w.Write([]byte("hello")) // OK: io.Writer has Write method
w.Close()                // compile error: io.Writer lacks Close method

指针和接口

在 Go 语言中同时使用指针和接口时会发生一些让人困惑的问题,接口在定义一组方法时没有对实现的接收者做限制,所以会看到某个类型实现接口的两种方式:

存储概览

在实现接口时这两种类型也不能划等号。虽然两种类型不同,但是上图中的两种实现不可以同时存在,Go 语言的编译器会在结构体类型和指针类型都实现一个方法时报错 Method redeclared。 下面的展示了如何使用结构体、结构体指针实现接口,以及如何使用结构体、结构体指针初始化变量:

1
2
3
4
5
6
7
8
9
10
11
type Cat struct {}
type Duck interface {
    Walk()
    Quack()
}

func (c Cat) Quack {}  // 使用结构体实现接口
func (c *Cat) Quack {}  // 使用结构体指针实现接口

var d Duck = Cat{}      // 使用结构体初始化变量
var d Duck = &Cat{}     // 使用结构体指针初始化变量

实现接口的类型和初始化返回的类型两个维度共组成了四种情况,然而这四种情况不是都能通过编译器的检查:

  结构体实现接口 结构体指针实现接口
结构体初始化变量 ✔︎
结构体指针初始化变量 ✔︎ ✔︎

显然,当实现接口的类型初始化变量时返回的类型相同时,代码通过编译是理所应当的;而剩下的两种方式为什么一种能够通过编译,另一种无法通过编译呢?我们先来看一下能够通过编译的情况,即方法的接收者是结构体,而初始化的变量是结构体指针:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type Cat struct{}

type Duck interface {
    Walk()
    Quack()
}

func (c Cat) Quack() {
    fmt.Println("meow")
}

func main() {
    var c Duck = &Cat{}
    c.Quack()
}

原因是编译器会对指针变量 &Cat{}进行隐式的解引用(dereference)从而获取到指向的结构体,所以能满足接收者为结构体的 WalkQuack 方法。我们可以将这里的调用理解成 C 语言中的 d->Walk() 和 d->Speak(),它们都会先获取指向的结构体再执行对应的方法。

而在相反的情况中:

  • 一方面,由于编译器不会为Cat{}无中生有创建一个新的指针,所以不能满足接收者为指针的Quack方法;
  • 另一方面,由于指针方法可以修改接收者,如果用值调用指针方法,只会修改值的拷贝(传值),从而丢失修改,所以编译器不允许这么做。即使编译器可以创建新指针,这个指针指向的也不是最初调用该方法的结构体,而是拷贝后的。

当我们使用指针实现接口时(接收者是指针),只有指针类型的变量才能调用该方法(才会实现该接口); 当我们使用结构体实现接口时(接收者是值),指针类型和结构体类型都能调用该方法(都会实现该接口)。

Go 语言中的示例

在很多语言中,排序算法都是和序列数据类型关联,同时排序函数和具体类型元素关联。相比之下,Go语言的 sort.Sort 函数不会对具体的序列和它的元素做任何假设。为什么?因为它使用了一个接口类型 sort.Interface 来指定通用的排序算法与可能被排序到的序列类型之间的约定。这个接口的实现由序列的具体表示和它希望排序的元素决定,序列的表示经常是一个切片。

一个内置的排序算法需要知道三个东西:序列的长度,表示两个元素比较的结果,一种交换两个元素的方式;这就是sort.Interface的三个方法:

1
2
3
4
5
6
package sort
type Interface interface {
    Len() int
    Less(i, j int) bool // i, j are indices of sequence elements
    Swap(i, j int)
}

下面是类型 StringSlice 和它的 Len,Less 和 Swap 方法:

1
2
3
4
type StringSlice []string
func (p StringSlice) Len() int           { return len(p) }
func (p StringSlice) Less(i, j int) bool { return p[i] < p[j] }
func (p StringSlice) Swap(i, j int)      { p[i], p[j] = p[j], p[i] }

现在只需通过将一个切片转换为一个 StringSlice 类型就可以进行排序:

1
sort.Sort(StringSlice(names))

sort 包中提供了 Reverse 函数将排序顺序转换成逆序。如:

1
sort.Sort(sort.Reverse(StringSlice(names)))

Reverse() 只是返回了一个继承 sort.Interface (Golang 中没有继承,这个只是为了方便理解,实际应该是组合)的结构体,但是这个结构体和其他不同的是他重新定义了Less()函数(比较函数),所以Reverse()虽然返回的是初始数据,但是改变了数据的Less()方法,在排序时调用这个就会产生逆排序的效果。

1
2
3
4
5
6
7
8
type reverse struct { 
    Interface //这一块可以看出是继承自Interface 
} 
func (r reverse) Less(i, j int) bool { 
    returnr.Interface.Less(j, i) //可以看到i,j交换了一下位置,所以会出现逆排序 } 
func Reverse(data Interface) Interface {
    return &reverse{data} // 只是返回了一个结构体,所以数据没有发生改变 
}

底层实现

interface 存在两种定义方式,分别对应两种类型,eface 和 iface:

1
2
3
4
5
6
7
8
// eface
var a interface{}

// iface
type Writer interface {
    Write(p []byte) (n int, err error)
}
var b Writer

eface

不包含任何方法的 interface 类型;定义如下,包含指向底层数据和类型的两个指针:

1
2
3
4
5
6
package runtime

type eface struct {
    _type *_type// 数据类型
    data  unsafe.Pointer// 数据指针
}

其中 runtime._type 是 Go 语言类型的运行时表示。下面是运行时包中的结构体,其中包含了很多类型的元信息,例如:类型的大小、哈希、对齐以及种类等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type _type struct {
    size       uintptr // type size
    ptrdata    uintptr // size of memory prefix holding all pointers
    hash       uint32  // hash of type; avoids computation in hash tables
    tflag      tflag   // extra type information flags
    align      uint8   // alignment of variable with this type
    fieldalign uint8   // alignment of struct field with this type
    kind       uint8   // enumeration for C
    equal      func(unsafe.Pointer, unsafe.Pointer) bool      
    // function for comparing objects of this type (ptr to object A, ptr to object B) -> ==?
    gcdata     *byte    // garbage collection data
    str        nameOff  // string form
    ptrToThis  typeOff  // type for pointer to this type, may be zero
}

例子:

1
2
3
4
5
6
7
8
var empty interface{}

tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0)
if err != nil {
    return nil, err
}

empty = tty

此时 empty 如下: 存储概览

iface

包含方法的 interface 类型;这个结构体中有指向原始数据的指针 data,以及 runtime.itab 类型的 tab 字段:

1
2
3
4
5
6
package runtime 

type iface struct {
    tab  *itab// 接口表
    data unsafe.Pointer// 数据指针
}

runtime.itab 结构体是接口类型的核心组成部分,存储包括接口类型、动态类型,以及实现接口的方法指针:

1
2
3
4
5
6
7
type itab struct {
    inter *interfacetype// 接口类型 e.g. io.Writer
    _type *_type// 动态类型 e.g. *os.File
    hash  uint32
    ...
    fun   [1]uintptr// 实现接口的方法虚表指针
}

例子

1
2
3
4
5
6
7
8
var reader io.Reader 

tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0)
if err != nil {
    return nil, err
}

reader = tty

此时 reader 如下: 存储概览

接口值

首先,Go语言是种静态类型的语言,类型是编译期的概念。

接口值 = 动态类型 + 动态值:

在Go语言中,变量总是被一个定义明确的值初始化,即使接口类型也不例外。对于一个接口的零值就是它的类型和值的部分都是nil,如下图: 存储概览

持有值

interface{} 类型,不能直接对它持有的值做操作,因为 interface{} 没有任何方法。

比如说,任何实现了 Write 方法的类型就实现了 io.Writer,也就是说一个 io.Writer 的接口变量可以持有任何实现了 Write 方法的值:

1
2
3
4
5
6
7
var w io.Writer
w = new(bytes.Buffer)
var rwc io.ReadWriteCloser
rwc = os.Stdout
// 此时w:接口类型:io.Writer 动态类型:*bytes.Buffer
w = rwc     
// 此时w:接口类型:io.Writer 动态类型:*os.File

此时虽然 w 持有 *os.File,但是只暴露了 Write 方法。显然,此时如果试图将 w 赋予一个 io.ReadWriteCloser 接口,会引起 error: io.Writer lacks Close method。那么如何才能访问到其他方法呢?

空接口

1
interface{}

显然,由于上面的接口有一个空的方法集合,所以它可以被任何值满足,这也就是为什么一个接口值可以持有任意大的动态值。

接口比较

两个接口值相等仅当它们

  • 都是 nil 值
  • 动态类型相同,并且动态值也满足这个动态类型的 == 操作。 但是如果动态类型是不可比较的(比如切片),将它们进行比较就会失败并且panic。所以比较接口值有风险。

如何知道接口值的动态类型:

1
2
3
4
5
6
var w io.Writer
fmt.Printf("%T\n", w) // "<nil>"
w = os.Stdout
fmt.Printf("%T\n", w) // "*os.File"
w = new(bytes.Buffer)
fmt.Printf("%T\n", w) // "*bytes.Buffer"

nil接口

1
2
3
4
5
6
7
8
9
10
func main() {
    var a interface{} = nil
    var ptr = (*int)(nil)
    var b interface{} = ptr
    var c io.Writer = nil
    
    fmt.Println(a == nil)  // true
    fmt.Println(b == nil)  //false
    fmt.Println(c == nil)  //true
}

上面代码中,接口 b 的动态类型为 *int, 而动态值为 nil,直接使用等号只判断了动态类型,无法判断动态值是否为空。从接口值的定义来说,这是合理的。

警告:一个包含 nil 指针的接口不是 nil 接口

反射

Go 语言提供了一种机制在运行时更新变量和检查它们的值、调用它们的方法,但是在编译时并不知道这些变量的具体类型,这称为反射机制。 当我们使用反射特性时,实际上用到的就是存储在 interface 变量中的和类型相关的信息,只有 interface 才有反射的说法。

reflect 包里定义了一个接口和一个结构体,即 reflect.Type 和 reflect.Value,还提供了两个基础的关于反射的函数来获取上述的接口和结构体:

1
2
3
4
5
6
// TypeOf 函数用来提取一个接口中值的类型信息。
// 由于它的输入参数是一个空的 interface{},调用此函数时,实参会先被转化为 interface{}类型。
// 这样,实参的类型信息、方法集、值信息都存储到 interface{} 变量里。
func TypeOf(i interface{}) Type 
// ValueOf 函数的返回值 reflect.Value 表示 interface{} 里存储的实际变量
func ValueOf(i interface{}) Value

另外,通过 Type() 方法和 Interface() 方法可以打通 interface、Type、Value 三者。Type() 方法也可以返回变量的类型信息,与 reflect.TypeOf() 函数等价。Interface() 方法可以将 Value 还原成原来的 interface: 存储概览 所以,当接口包含一个指针时,可以使用反射来判断动态值是否为空:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func IsNil(i interface{}) bool {
   vi := reflect.ValueOf(i)// 从接口到反射对象
   if vi.Kind() == reflect.Ptr {
      return vi.IsNil()
   }
   return false
}

func main() {
    var a interface{} = nil // tab = nil, data = nil
    var b = (*int)(nil)
    var c interface{} = b // tab 包含 *int 类型信息, data = nil
    fmt.Println(a == nil)// true
    fmt.Println(b == nil)// true
    fmt.Println(c == nil)// false 
    fmt.Println(IsNil(c))// true
}

Value 的 Kind() 和 Type() 两个方法有什么区别?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package main

import (
   "fmt"
   "reflect"
)

type MyInt int

func main() {
    var a MyInt
    v := reflect.ValueOf(a)
    fmt.Println(v.Type())// main.MyInt
    fmt.Println(v.Kind())// int
}

Type() 返回类型,而 Kind() 返回类别。通常基础数据类型的 Type() 和 Kind() 相同,自定义数据类型则不同。

类型断言

类型断言是一个使用在接口值上的操作。形如x.(T)被称为类型断言,其中 x 必须是一个接口类型变量,T 表示一个类型,其作用是:

  • 检查接口变量x是否为 nil
  • 检查接口变量x存储的值是否为T类型

一个类型断言检查它操作对象的动态类型是否和断言的类型匹配。这里有两种可能:

  • 断言具体类型T 如果断言的类型 T 是一个具体类型,然后类型断言检查接口变量 x 的动态类型是否和T相同。如果是,类型断言的结果是 x 的动态值。 换句话说,对具体类型 T 的断言就是获取接口变量 x 的动态值。
1
2
3
4
var w io.Writer
w = os.Stdout          // 动态类型:*os.File 动态值:os.Stdout
f := w.(*os.File)      // success: f == os.Stdout
c := w.(*bytes.Buffer) // panic: interface holds *os.File, not *bytes.Buffer

在上面的代码中,w 是一个有 Write 方法的接口表达式,其动态值是 os.Stdout,断言 w.(os.File) 是针对具体类型os.File 进行的,那么 f 就是 w 的动态值 os.Stdout。如果检查失败会抛出 panic。

  • 断言接口类型T 如果断言的类型T是一个接口类型,然后类型断言检查 x 的动态类型是否满足T。如果满足,结果是 x 的动态值,但类型是 T 的类型。 换句话说,对接口类型 T 的断言的结果不是 x 的接口类型,而通常是有更多方法集合的接口类型(T),但是保留原来的动态类型和动态值。
1
2
3
4
5
6
7
var w io.Writer
w = os.Stdout            
// w:类型:io.Writer 动态类型:*os.File 动态值:os.Stdout 可获取方法:Write
rw := w.(io.ReadWriter)  // success: *os.File has both Read and Write
// rw:类型:io.ReadWriter 动态类型:*os.File 动态值:os.Stdout 可获取方法:Read、Write
w = new(ByteCounter)
rw = w.(io.ReadWriter)   // panic: *ByteCounter has no Read method

在上面的第一个类型断言后,w 和 rw 都持有 os.Stdout,因此它们都有一个动态类型*os.File,但是变量w是一个io.Writer类型,只对外公开了文件的Write方法,而rw变量还公开了它的Read方法。

例子:

1
2
3
4
5
6
var w io.Writer
w = os.Stdout            
w.Read()
rw := w.(io.ReadWriter)
w.Read()

下面两行都是将 rw 持有的动态值赋予 w。其中第二行的类型断言当 x 比 T 同有更多的方法时永远不会失败,除非 x 的动态值为 nil:

1
2
w = rw             // io.ReadWriter is assignable to io.Writer
w = rw.(io.Writer) // fails only if rw == nil

不过要记得类型断言会改变接口表达式的类型: 存储概览

被断言的接口值 x 是 nil

如果断言操作的对象 x 是一个nil接口值,那么不论被断言的类型是什么这个类型断言都会失败:

1
2
var w io.Writer = nil
res := w.(io.Writer) // panic: interface conversion: interface is nil, not io.Writer

可以用以下方式防止在失败的时候发生panic:

1
2
3
var w io.Writer = os.Stdout
f, ok := w.(*os.File)      // success:  ok, f == os.Stdout
b, ok := w.(*bytes.Buffer) // failure: !ok, b == nil

应用场景

  • 通过类型断言查询接口

下面这段逻辑是写HTTP头字段。写入io.Writer的字节是响应的一部分:

1
2
3
4
5
6
7
8
9
func writeHeader(w io.Writer, contentType string) error {
    if _, err := w.Write([]byte("Content-Type: ")); err != nil {
        return err
    }
    if _, err := w.Write([]byte(contentType)); err != nil {
        return err
    }
    // ...
}

因为Write方法需要传入一个byte切片而我们希望写入的值是一个字符串,所以我们需要使用[]byte(…)进行转换。这个转换分配内存并且做一个拷贝,但是这个拷贝在转换后几乎立马就被丢弃掉。 如何避免内存分配?事实上有一些 w 持有的动态类型有一个允许字符串高效写入的 WriteString 方法,可以避免去分配一个临时的拷贝:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func (b *Buffer) Write(p []byte) (n int, err error) {
   b.lastRead = opInvalid
   m, ok := b.tryGrowByReslice(len(p))
   if !ok {
      m = b.grow(len(p))
   }
   return copy(b.buf[m:], p), nil
}

func (b *Buffer) WriteString(s string) (n int, err error) {
   b.lastRead = opInvalid
   m, ok := b.tryGrowByReslice(len(s))
   if !ok {
      m = b.grow(len(s))
   }
   return copy(b.buf[m:], s), nil
}

但如何确定 w 是否持有该方法? 解决方式是定义一个只有这个 WriteString 方法的新接口,并且使用类型断言来检测是否 w 的动态类型满足(除了空接口interface{},接口类型很少意外巧合地被实现)这个新接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// writeString writes s to w.
// If w has a WriteString method, it is invoked instead of w.Write.
func writeString(w io.Writer, s string) (n int, err error) {
    type stringWriter interface {
        WriteString(string) (n int, err error)
    }
    if sw, ok := w.(stringWriter); ok {
        return sw.WriteString(s) // avoid a copy
    }
    return w.Write([]byte(s)) // allocate temporary copy
}

func writeHeader(w io.Writer, contentType string) error {
    if _, err := writeString(w, "Content-Type: "); err != nil {
        return err
    }
    if _, err := writeString(w, contentType); err != nil {
        return err
    }
    // ...
}

上面的writeString函数使用一个类型断言来获知一个普遍接口类型所持有的值是否满足一个更加具体的接口类型。

  • 通过类型断言判断 nil 接口 类型断言检查接口变量 x 的动态类型是否和T相同。如果是,类型断言的结果是 x 的动态值。