1. 接口

1.1 鸭子类型

鸭子类型,是一种动态编程语言的对象推断策略,核心在于关注对象能如何被使用,而非对象的类型本身。在 Go 语言中,它通过接口的方式得到了完美的支持。根据使用需求,开发者在使用 “ 鸭子 “ 的时候确定其是否具备必要的 “ 技能 “,而无需关心它是否为真正的 “ 鸭子 “。

具体来说,Go 语言中的鸭子类型是通过 Interface 来实现的多态性。与 Python 等动态类型的语言不同,Go 并不要求类型显式声明实现某个接口,只要实现了相关的方法即可。在鸭子类型这种设计风格中,开发者不关心对象是什么类型,只关心对象有没有特定的方法。

总的来说,鸭子类型提供了一种非侵入式的接口,任何类型,只要实现了该接口中的方法,都可以被视为同一类型处理。

1.2 接口详解

1.2.1 快速了解

在 Go 语言中,接口是一种定义了一组方法签名的抽象类型。接口定义了对象的行为规范,而不关心对象的具体实现。接口中的方法只有声明,没有实现。任何类型只要实现了接口中定义的所有方法,就被称为实现了该接口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
//接口的定义
type Duck interface {
//方法的申请
Gaga()
Walk()
Swimming()
}

type pskDuck struct {
legs int
}

func (pd *pskDuck) Gaga() {
fmt.Println("嘎嘎")
}

func (pd *pskDuck) Walk() {
fmt.Println("this is pskduck walking")
}

func (pd *pskDuck) Swimming() {
fmt.Println("this is pskduck swiming")
}

func main() {
var d Duck = &pskDuck {}
d.Walk()
}
多个接口对应一个结构体
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
type MyWriter interface {
Write(string) error
}

type MyCloser interface {
Close() error
}

type writerCloser struct {}

func (wc *writerCloser) Write(string) error {
fmt.Println("write string")
return nil
}

func (wc *writerCloser) Close() error {
fmt.Println("close")
return nil
}

func main() {
var mw MyWriter = &writerCloser {}
var mc MyCloser = &writerCloser {}
}
接口嵌套
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
type MyWriter interface {
Write(string)
}

type MyReader interface {
Read() string
}

type MyReadWriter interface {
MyWriter
MyReader
ReadWrite()
}

type SreadWriter struct {}

func (s *SreadWriter) Write(s2 string) {
fmt.Println("write")
}

func (s *SreadWriter) Read() string {
fmt.Println("read")
return ""
}

func (s *SreadWriter) ReadWrite() {
fmt.Println("read write")
}

func main() {
var mrw MyReadWriter = &SreadWriter {}
mrw.Read()
}

1.2.2 空接口类型

空接口的类型可以接受任意类型的数据,它只需要记录这个类型在哪,是什么类型的就足够了。下面这个 _type 就指向接口的动态类型元数据,而 data 指向接口的动态值。

一个空接口的变量 e 在它被赋值以前,_type 和 data 都为 nil。现在我们有一个 *os.File 类型的变量 f,在上面类型系统小节了解过后,我们知道 *os.File 对应下面这样类型的元数据。

1
2
var e interface{}
f, _ := os.Open("test.txt")

如果把 f 赋值给 e,而又因为 f 本身就是个指针,所以这个 data 就等于 f,且 _type 指向 *os.File 的类型元数据。

1
2
3
var e interface{}
f, _ := os.Open("test.txt")
e = f

我们已经知道类型元数据这里可以找到 *os.File 的方法元数据数组,里面就有我们常用的 Read 和 Write 这些方法的描述信息。

这就是空接口类型变量赋值前后的变化,下面来看看非空接口。

1.2.3 非空接口类型

非空接口就是有方法列表的接口类型,一个变量要想赋值给一个非空接口类型,必须要实现接口类型的所有方法才行。与空接口类型一样,下面这个 data 字段指向接口的动态值,所以接口要求的方法列表以及接口动态类型信息一定要存储在下面这个 itab 结构体里。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
type iface struct {
tab *itab // 指向itab类型结构体
data unsafe.Pointer
}

type itab struct {
inter *interfacetype // 指向interface类型的元数据
_type *_type // 指向接口的动态类型元数据
hash uint32 // 从动态类型元数据中拷贝来的类型哈希值,在快速判断类型是否相等时使用
_ [4]byte
fun [1]uintptr // 记录的是这个动态类型实现的那些接口要求的方法地址
}

// 接口类型的元数据
type interfacetype struct {
typ _type
pkgpath name
mhdr []imethod // 接口要求的方法列表就记录在这里
}

还是来看个例子,我们声明一个 io.ReadWriter 类型的变量 rw,它被赋值以前 data 和 tab 都为 nil。

1
2
var rw io.ReadWriter
f, _ := os.Open("test.txt")

下面我们把一个 *os.File 类型的变量 f 赋值给 rw,此时 rw 的动态值就是 f 了。

1
2
3
var rw io.ReadWriter
f, _ := os.Open("test.txt")
rw = f

而 tab 会指向一个 itab 结构体,并且它的接口类型为 io.ReadWriter,动态类型为 *os.File。同时,要注意 itab 这里的 fun,它会从动态类型元数据中拷贝接口要求的那些方法的地址,以便通过 rw 快速定位到方法,而无需再去类型元数据那里查找。

这就是非空接口的结构,但是关于 itab 还是需要额外注意一点,因为接口类型一旦确定了,动态类型也就确定了,那么 itab 的内容就不会改变了,所以这个 itab 结构体是可复用的。

1.2.4 缓存机制

实际上 Go 语言会把用到的 itab 结构体缓存起来,并且以接口类型和动态类型的组合为 key,以 itab 结构体指针为 value 构造一个哈希表,用于存储与查询 itab 缓存信息。

这里的哈希表与 map 底层的哈希表不同,是一种更为简便的设计。需要一个 itab 时,会先去这里查找。而 key 的哈希值是下面这样计算的,用接口类型的类型哈希值与动态类型的类型哈希值进行异或运算。

1
2
3
func itabHashFunc(inter *interfacetypr, typ *_type) uintptr {
return uintptr(inter.typ.hash ^ typ.hash)
}

如果已经有对应的 itab 指针,就直接拿来使用。

如果 itab 缓存中没有,则需要创建一个 itab 结构体,然后添加到这个哈希表中。

明确了空接口和非空接口的数据结构,理解了接口动态值与动态类型在赋值前后的变化,接下来就可以看看类型断言的具体实现了。

1.3 Slice 的常见错误

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
func mPrint(data ...interface{}) {
for _, value := range datas {
fmt.Println(value)
}
}

func main() {
//正确
var data = []interface{} {
"bobby", 18, 1.80
}

//下面这种在传入mPrint后会报错
var data = []string {
"bobby", "bobby2", "bobby3"
}

//正确
var data []interface{}
for _, value := range data {
data = append(data, value)
}

mPrint(data...)
}

2. 类型断言

2.1 快速了解

在 Go 语言(Golang)中,断言(Type Assertion)是一种用于在运行时检查接口值实际保存的具体类型,并获取对应类型值的机制。

例如,下面 add 函数中入参被定义成了空接口的类型,因此我们可以针对入参进行类型断言,通过 a.(int) 的方式来判断入参的类型是否为 int 类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func add(a, b interface{}) interface{} {
ai, ok := a.(int)
if !ok {
panic("not int type")
}
bi, _ := b.(int)
return ai + bi
}

func main() {
a := 1
b := 2
fmt.Println(add(a,b))
}

当然,我们可以利用 switch 语法来适应不同类型的断言:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func add(a, b interface{}) interface{} {
switch a.(type) {
case int :
ai, _ := a.(int)
bi, _ := b.(int)
return ai + bi
case int32:
ai, _ := a.(int32)
bi, _ := b.(int32)
return ai + bi
case float64:
af, _ := a.(float64)
bf, _ := b.(float64)
return af + bf
default:
panic("not supported type")
}
}

func main() {
fmt.Println(add(1,2))
fmt.Println(add(1.2,2.3))
}

而从前面的接口篇章可以得知,接口可以分为「空接口」和「非空接口」两类。

相对于接口这种「抽象类型」,int、string、slice 等等都可以被称为「具体类型」。

  • 类型断言作用于接口值之上,可以是空接口或非空接口
  • 断言的目标类型,可以是具体类型或非空接口类型

这样,就组合出了四种类型断言,接下来我们就逐一看看它们究竟是怎样 “ 断言 “ 的。

2.2 空接口.(具体类型)

1
2
3
var e interface {}

r, ok := e.(*os.File)

上面的代码中 e.(*os.File) 是要判断 e 的动态类型是否为 *os.File,而这只需要确定 _type 是否指向 *os.File 的类型元数据即可。

在 go 语言里每种类型的类型元数据都是全局唯一的,如果像下面这样给 e 赋值,那么 e 的动态值就是 f,动态类型就是 *os.File。

所以下面代码会断言成功,ok 为 true,r 被赋值为 e 的动态值。

1
2
3
4
5
6
var e interface {}

f, _ := os.Open("output.txt")
e = f

r, ok := e.(*os.File)

而如果像下面这样赋值,e 的动态类型就是 string 类型,那么类型断言就会失败。

此时 ok 就为 false,r 会被赋值为 *os.File 的类型零值 nil。

1
2
3
4
5
6
var e interface {}

f := "output"
e = f

r, ok := e.(*os.File)

2.3 非空接口.(具体类型)

1
2
3
var rw io.ReadWriter

r, ok := rw.(*os.File)

上面代码中 rw.(*os.File) 是要判断 rw 的动态类型是否为 *os.File。

之前的篇章中,我们也介绍过,程序中用到的 itab 结构体都会缓存起来,可以通过接口类型和动态类型组合起来的 key,查找到对应的 itab 指针。

所以这里的类型断言只需要一次比较就能完成,只要看 tab = &itab 是否指向这个 itab 结构体即可

如果 rw 像下面代码这样赋值,那么它的动态值就是 f,动态类型就是 *os.File。

1
2
3
4
5
6
var rw io.ReadWriter

f, _ := os.Open("output.txt")
rw = f

r, ok := rw.(*os.File)

所以 tab 指向下面图中第二块部分的这个 itab 结构体,因此类型断言成功,ok 为 true 且 r 被赋值为 rw 的动态值。

然而,如果 rw 动态类型不是 *os.File。

1
2
3
4
5
6
var rw io.ReadWriter

f, _ := output{name: "output"}
rw = f

r, ok := rw.(*os.File)

那么此时 tab 就指向下面图中第一块部分,而不是图中第二块部分的地方。所以类型断言就会失败,ok 为 false 且 r 会被置为 *os.File 的类型零值 nil。

2.4 空接口.(非空接口)

1
2
3
var e interface {}

rw, ok := e.(ioReadWriter)

e.(ioReadWriter) 是要判断 e 的动态类型是否实现了 io.ReadWriter 接口,当然之前的篇章中我们已经介绍过类型关联的方法列表该去哪里找了。

如果像下面代码中 e 这样赋值,那么它的动态值就是 f,动态类型就是 *os.File。

1
2
3
4
5
6
var e interface {}

f, _ := os.Open("output.txt")
e = f

rw, ok := e.(ioReadWriter)

虽然 *os.File 元数据后面可以找到类型关联的方法元数据数组,但也不必每次都去检查这里是否有对应接口要求的所有方法。

因为存在 itab 缓存,所以可以先去 itab 缓存中查找一下。若没有 io.ReadWriter 和 *os.File 对应的 itab 结构体,再去检查 *os.File 的方法列表也不迟。

值得强调的是,就算能够从缓存中查找到对应的 itab 指针,也要进一步判断 itab.fun[0] 是否等于 0。

这是因为断言失败的类型组合,其对应的 itab 结构体也会被缓存起来,只是会把 itab.fun[0] 置为 0,用以表示这里的动态类型并没有实现对应的接口。

这样以后再遇到同种类型断言时,就不用再去检查方法列表了,可以直接断言失败。

而我们上面这个例子中,类型断言是成功的,所以 ok 为 true,而 rw 就是一个 io.ReadWriter 类型的变量,其动态值与 e 相同,tab 就指向下面这样一个 itab 结构体。

但如果 e 被赋值为一个字符串,那么它的动态类型就是 string,并没有实现要求的 Read 和 Write 方法,所以对应的 itab 中 fun[0] 的值为 0,并且会被添加到 itab 缓存中。

1
2
3
4
5
6
var e interface {}

f, _ := "output"
e = f

rw, ok := e.(ioReadWriter)

因此这里断言失败,ok 为 false 且 rw 为 io.ReadWriter 的类型零值 nil。

2.5 非空接口.(非空接口)

1
2
3
var w io.Writer

rw, ok := w.(io.ReadWriter)

w.(io.ReadWriter) 是要判断 w 存储的动态类型是否实现了 io.ReadWriter 接口,w 是 io.Writer 类型,接口要求一个 Write 方法,而 io.ReadWriter 接口要求实现 Read 和 Write 两个方法。

如果 w 像下面的代码这样赋值,其动态值就是 f。

1
2
3
4
5
6
var w io.Writer

f, _ := os.Open("output.txt")
w = f

rw, ok := w.(io.ReadWriter)

tab 指向下图这样一个 itab 结构体,要确定 *os.File 是否实现了 io.ReadWriter 接口,同样会先去 itab 缓存里查找这个组合对应的 itab 指针。

若存在,且 itabfun[0] 不等于 0,则断言成功;若不存在,再去检查 *os.File 的方法列表,并缓存 itab 信息。

而在这里的代码中,断言是成功的,即 ok 为 true 且 rw 为 io.ReadWriter 类型的变量,动态值与 w 相同,而 tab 指向下面这个 itab 结构体。

如果我们自定义一个 output 类型,并且 *output 类型只实现了 io.Writer 接口,并没有实现 io.ReadWriter 接口。

1
2
3
4
5
6
7
type output struct {
name string
}

func (o *output) Write(b []byte) (n int, err error) {
return len(o.name), nil
}

现在如果把这个 output 类型的变量赋值给 w,那么此时 w 的动态类型就为 *output,并没有实现指定接口,所以 fun[0] = 0,该 itab 结构体就会被缓存起来。

1
2
3
4
5
6
var w io.Writer

f, _ := output{name: "john")
w = &f

rw, ok := w.(io.ReadWriter)

因此,类型断言会失败,ok 为 false 且 rw 的 tab 和 data 均为 nil。

所以,类型断言的关键是明确接口的动态类型,以及对应的类型实现了哪些方法。而明确这些的关键还是「类型元数据」,以及空接口与非空接口的「数据结构」。