Skip to content

类型系统

Go 语言是一种静态类型的编程语言。这意味着,编译器需要在编译时知晓程序里每个值的类型。
如果提前知道类型信息,编译器就可以确保程序合理地使用值。
这有助于减少潜在的内存异常和 bug,并且使编译器有机会对代码进行一些性能优化,提高执行效率。

值的类型给编译器提供两部分信息: 第一部分,需要分配多少内存给这个值(即值的规模);
第二部分,这段内存表示声明。对于许多内置类型的情况来说,规模和表示是类型名的一部分。

int64 类型的值需要 8 字节(64 位),表示一个整数值;
float32 类型的值需要 4 字节(32 位),表示一个 IEEE-754 定义的二进制浮点数;
bool 类型的值需要 1 字节(8 位),表示布尔值 true 和 false

用户定义的类型

Go 语言允许用户定义类型。当用户声明一个新类型时,这个声明就给编译器提供了一个框架,告知必要的内存大小和表示信息。
声明后的类型与内置类型的运作方式类似。Go 语言里声明用户定义的类型有两种方法。最常用的方法是使用关键字 struct,它可以让用户创建一个结构类型。

结构类型通过组合一系列固定且唯一的字段来声明。
结构里每个字段都会用一个已知类型声明。这个已知类型可以是内置类型,也可以是其他用户定义的类型

1
2
3
4
5
6
7
// user 在程序里定义一个用户类型
type user struct {
    name string
    email string
    ext int
    privileged bool
}

这是一个结构类型的声明。这个声明以关键字 type 开始,之后是新类型的名字,最后是关键字 struct。
这个结构类型有 4 个字段,每个字段都基于一个内置类型。

使用结构类型声明变量,并初始化为其零值

1
2
// 声明 user 类型的变量
var bill user

关键字 var 创建了类型为 user 且名为 bill 的变量。
当声明变量时,这个变量对应的值总是会被初始化。这个值要么用指定的值初始化,要么用零值(即变量类型的默认值)做初始化。
对数值类型来说,零值是 0;对字符串来说,零值是空字符串;对布尔类型,零值是 false。对这个例子里的结构,结构里每个字段都会用零值初始化。

任何时候,创建一个变量并初始化为其零值,习惯是使用关键字 var。这种用法是为了更明确地表示一个变量被设置为零值。
如果变量被初始化为某个非零值,就配合结构字面量和段变量声明操作符来创建变量。

使用结构字面量来声明一个结构类型的变量

1
2
3
4
5
6
7
// 声明 user 类型的变量,并初始化所有字段
lisa := user {
    name: "Lisa",
    email: "lisa@email.com",
    ext: 123,
    privileged: true,
}

不使用字段名,创建结构类型的值

1
2
// 声明 user 类型的变量
lisa := user{"Lisa", "lisa@email.com", 123, true}

使用其他结构类型声明字段

1
2
3
4
5
// admin 需要一个 user 类型作为管理者,并附加权限
type admin struct {
    person user
    level string
}

使用结构字面量来创建字段的值

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 声明 admin 类型的变量
fred := admin {
    person: user {
        name: "Lisa",
        email: "lisa@email.com",
        ext: 123,
        privileged: true,
    },
    level: "super"
}

另一种声明用户定义的类型的方法是,基于一个已有的类型,将其作为新类型的类型说明。
当需要一个可以用已有类型表示的新类型的时候,这种方法会非常好用。
标准库使用这种声明类型的方法,从内置类型创建出很多更加明确的类型,并赋予更高级的功能。

基于 int64 声明一个新类型

1
type Duration int64

代码展示的是标准库的 time 包里的一个类型的声明。Duration 是一种描述时间间隔的类型,单位是纳秒(ns)。
这个类型使用内置的 int64 类型作为其表示。在 Duration 类型声明中,我们把 int64 类型叫做 Duration 的基础类型。
不过,虽然 int64 是基础类型,Go 并不认为 Duration 和 int64 是同一种类型。这两个类型是完全不同的有区别的类型。

类型 int64 的值不能作为类型 Duration 的值来用。
换句话说,虽然 int64 类型是基础类型,Duration 类型依然是一个独立的类型。
两种不同类型的值即便互相兼容,也不能互相赋值。编译器不会对不同类型的值做隐式转换

方法

方法能给用户定义的类型添加新的行为。方法实际上也是函数,只是在声明时,在关键字 func 和方法名之间增加了一个参数

 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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// 这个示例程序展示如何声明
// 并使用方法
package main

import (
    "fmt"
)

// user 在程序里定义一个用户类型
type user struct {
    name string
    email string
}

// notify 使用值接收者实现了一个方法
func (u user) notify {
    fmt.Printf("Sending User Email To %s<%s>\n",
        u.name
        u.email)
}

// changeEmail 使用指针接收者实现了一个方法
func (u *user) changeEmail(email string) {
    u.email = email
}

// main 是应用程序的入口
func main() {
    // user 类型的值可以用来调用
    // 使用值接收者声明的方法
    bill := user{"Bill", "bill@email.com"}
    bill.notify()

    // 指向 user 类型值的指针也可以用来调用
    // 使用值接收者声明的方法
    lisa := &user{"Lisa", "lisa@email.com"}
    lisa.notify()

    // user 类型的值可以用来调用
    // 使用指针接收者声明的方法
    bill.changeEmail("bill@newdomain.com")
    bill.notify()

    // 指向 user 类型值的指针可以用来调用
    // 使用指针接收者声明的方法
    lisa.changeEmail("lisa@newdomain.com")
    lisa.notify()
}

关键字 func 和函数名之间的参数被称作接收者,将函数与接收者的类型绑在一起。
如果一个函数有接收者,这个函数就被称为方法

Go 语言里有两种类型的接收者:值接收者指针接收者

notify 方法的接收者被声明为 user 类型的值。如果使用值接收者声明方法,调用时会使用这个值的一个副本来执行。

changeEmail 方法使用指针接收者声明。这个接收者的类型是指向 user 类型值的指针,而不是 user 类型的值。
当调用使用指针接收者声明的方法时,这个方法会共享调用方法时所指向的值

也可以使用一个值来调用使用指针接收者声明
Go 在代码背后的执行动作

1
(&bill).changeEmail("bill@newdomain.com")

在这个例子里,首先引用 bill 值得到了一个指针,这样这个指针就能够匹配方法的接收者类型,再进行调用。
Go 语言既允许使用值,也允许使用指针来调用方法,不必严格符合接收者的类型。这个支持非常方便开发者编写程序

应该使用值接收者,还是应该使用指针接收者,这个问题有时会比较迷惑人。
可以遵从标准库里一些基本的指导方针来做决定。

类型的本质

在声明一个新类型之后,声明一个该类型的方法之前,需要先回答一个问题: 这个类型的本质是什么。
如果给这个类型增加或者删除某个值,是要创建一个新值,还是要更改当前的值?
如果是要创建一个新值,该类型的方法就使用值接收者。如果是要修改当前值,就使用指针接收者。
这个答案也会影响内部传递这个类型的值的方式: 是按值做传递,还是按指针做传递。
保持传递的一致性很重要。这个背后的原则是,不要只关注某个方法是如何处理这个值,而是要关注这个值的本质是什么

内置类型

内置类型是由语言提供的一组类型。我们已经见过这些类型,分别是数值类型、字符串类型和布尔类型。
这些类型本质上是原始的类型。因此,当对这些值进行增加或者删除的时候,会创建一个新值。
基于这个结论,当把这些类型的值传递给方法或者函数时,应该传递一个对应值的副本。

引用类型

Go 语言里的引用类型有如下几个:切片、映射、通道、接口和函数类型。
当声明上述类型的变量时,创建的变量被称为标头(header)值。
从技术细节上说,字符串也是一种引用类型。每个引用类型创建的标头值是包含一个指向底层数据结构的指针。
每个引用类型还包含一组独特的字段,用于管理底层数据结构。因为标头值是为复制而设计的,所以永远不需要共享一个引用类型的值。
标头值里包含一个指针,因此通过复制来传递一个引用类型的值的副本,本质上就是共享底层数据结构

结构类型

接口

多态是指代码可以根据类型的具体实现采取不同行为的能力。
如果一个类型实现了某个接口,所有使用这个接口的地方,都可以支持这种类型的值。
标准库里有很好的例子,如 io 包里实现的流式处理接口。
io 包提供了一组构造非常好的接口和函数,来让代码轻松支持流式数据处理。
只要实现两个接口,就能利用整个 io 包背后的所有强大能力

不过,我们的程序在声明和实现接口时会涉及很多细节。
即便实现的是已有接口,也需要了解这些接口是如何工作的。
在探究接口如何工作以及实现的细节之前,我们先来看一下使用标准库里的例子

标准库

我们先来看一个示例程序,这个程序实现了流行程序 curl 的功能,

 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
package main

import (
    "fmt",
    "io",
    "net/http",
    "os"
)

// init 在 main 函数之前调用
func init() {
    if len(os.Args) != 2 {
        fmt.Println("Usage: ./example2 <url>")
        os.Exit(-1)
    }
}

// main 是应用程序的入口
func main() {
    // 从 Web 服务器得到响应
    r, err := http:Get(os.Args[1])
    if err != nil {
        fmt.Println(err)
        return
    }

    // 从 Body 复制到 Stdout
    io.Copy(os.Stdout, r.Body)
    if err := r.Body.Close(); err != nil {
        fmt.Println(err)
    }
}

代码展示了接口的能力以及在标准库里的应用。
只用了几行代码我们就通过两个函数以及配套的几口,完成了 curl 程序。 首先调用了 http 包的 Get 函数。在于服务器成功通信后,http.Get 函数会返回一个 http.Response 类型的指针。
http.Response 类型包含一个名为 Body 的字段,这个字段是一个 io.ReadCloser 接口类型的值

Body 字段作为第二个参数传给 io.Copy 函数。
io.Copy 函数的第二个参数,接受一个 io.Reader 接口类型的值,这个值表示数据流入的源。
Body 字段实现了 io.Reader 接口,因此我们可以将 Body 字段传入 io.Copy,使用 Web 服务器的返回内容作为源

io.Copy 的第一个参数是复制到的目标,这个参数必须是一个实现了 io.Writer 接口的值。
对于这个目标,我们传入了 os 包里的一个特殊值 Stdout。
这个接口值表示标准输出设备,并且已经实现了 io.Writer 接口。
当我们将 Body 和 Stdout 这两个值传给 io.Copy 函数后,这个函数会把服务器的数据分成小段,源源不断地传给终端窗口,直到最后一个片段读取并写入终端,io.Copy 函数才返回

io.Copy 函数可以以这种工作流的方式处理很多标准库里已有的类型

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 这个示例程序展示 bytes.Buffer 也可以
// 用于 io.Copy 函数
package main

import (
    "bytes"
    "fmt"
    "io"
    "os"
)

// main 是应用程序的入口
func main() {
    var b bytes.Buffer

    // 将字符串写入 Buffer
    b.Write([]byte("Hello"))

    // 使用 Fprintf 将字符串拼接到 Buffer
    fmt.Fprintf(&b, "World!")

    // 将 Buffer 的内容写到 Stdout
    io.Copy(os.Stdout, &b)
}

这个程序使用接口来拼接字符串,并将数据以流的方式输出到标准输出设备。
首先创建了一个 bytes 包里的 Buffer 类型的变量 b,用于缓冲数据。
之后使用 Write 方法将字符串 Hello 写入这个缓冲区 b。 然后调用 fmt 包里的 Fprintf 函数,将第二个字符串追加到缓冲区 b 里

fmt.Fprintf 函数接受一个 io.Writer 类型的接口值作为其第一个参数。
由于 bytes.Buffer 类型的指针实现了 io.Writer 接口,所以可以将缓存 b 传入 fmt.Fprintf 含糊,并执行追加操作。
最后,再次使用 io.Copy 函数,将字符写到终端窗口。
由于 bytes.Buffer 类型的指针也实现了 io.Reader 接口,io.Copy 函数可以用于在终端窗口显示缓冲区 b 的内容

实现

接口是用来定义行为的类型。
这些被定义的行为不由接口直接实现,而是通过方法由用户定义的类型实现。
如果用户定义的类型实现了某个接口声明的一组方法,那么这个用户定义的类型的值就可以赋给这个接口类型的值。
这个赋值会把用户定义的类型的值存入接口类型的值

对接口值方法的调用会执行接口值里存储的用户定义的类型的值对应的方法。
因为任何用户定义的类型都可以实现任何接口,所以对接口值方法的调用自然就是一种多态。
在这个关系里,用户定义的类型通常叫做实体类型,原因是如果离开内部存储的用户定义的类型的值的实现,接口值并没有具体的行为。

并不是所有值都完全等同,用户定义的类型的值或者指针要满足接口的实现,需要遵守一些规则。

方法集

 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
34
35
36
37
38
39
40
// 这个示例程序展示 Go 语言里如何使用接口
package main

import (
    "fmt"
)

// notifier 是一个定义了
// 通知类行为的接口
type notifier interface {
    notify()
}

// user 在程序里定义一个用户类型
type user struct {
    name string
    email string
}

// notify 是使用指针接受者实现的方法
func (u *user) notify() {
    fmt.Printf("Sending user email to %s<%s>\n", u.name, u.email)
}

// main 是应用程序的入口
func main() {
    // 创建一个 user 类型的值,并发送通知
    u := user{"Bill", "bill@email.com"}

    sendNotification(u)

    // 不能将 u(类型是 user)作为 sendNotification 的参数类型 notifier
    // user 类型并没有实现 notifier(notify 方式使用指针接收者声明)
}

// sendNotification 接受一个实现了 notifier 接口的值
// 并发送通知
func sendNotification(n notifier) {
    n.notify()
}

程序虽然看起来没问题,但实际上却无法通过编译。
首先声明了一个名为 notifier 的接口,包含一个名为 notify 的方法。
后面声明了名为 user 的实体类型,并声明实现了 notifier 接口。
这个方法是使用 user 类型的指针接收者实现的

sendNotification 函数接受一个 notifier 接口类型的值。
自后,使用这个接口值来调用 notify 方法。
任何一个实现了 notifier 接口的值都可以传入 sendNotification 函数。

main 函数里,创建了一个 user 实体类型的值,并将其赋值给变量 u.
之后将 u 的值传入 sendNotification 函数,不过,调用 sendNotification 的结果是产生了一个编译错误

要了解用指针接收者来实现接口时为什么 user 类型的值无法实现该接口,需要先了解方法集
方法集定义了一组关联到给定类型的值或者指针的方法。
定义方法时使用的接收者的类型决定了这个方法是关联到值,还是关联到指针,还是两个都关联

规范里描述的方法集:

Values Methods Receivers
T (t T)
*T (t T) and (t *T)

上面展示了规范里对方法集的描述。
描述中说到,T 类型的值的方法集只包含值接收者声明的方法。
而指向 T 类型的指针的方法集既包含值接收者声明的方法,也包含指针接收者声明的方法。
从值的角度看这些规则,会显得很复杂。让我们从接收者的角度来看一下这些规则

从接收者类型的角度来看方法集

Methods Receivers Values
(t T) T and *T
(t *T) *T

这个规则说,如果使用指针接收者来实现一个接口,那么只有指向那个类型的指针才能够实现对应的接口。
如果使用值接收者来实现一个接口,那么那个类型的值和指针都能够实现对应的接口。

在上面的程序中,我们使用指针接收者实现了接口,但是试图将 user 类型的值传给 sendNotification 方法。
但是,如果传递的是 user 值的地址,整个程序就能通过编译,并且能够工作了

1
2
3
4
5
6
func main() {
    // 使用 user 类型创建一个值,并发送通知
    u := user{"Bill", "bill@email.com"}

    sendNotification(&u)
}

这个程序终于可以编译并且运行。因为使用指针接收者实现的接口,只有 user 类型的指针可以传给 sendNotification 函数

现在的问题是,为什么会有这种限制?事实上,编译器并不是总能自动获得一个值的地址

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// 这个示例程序展示不是总能
// 获取值的地址
package main

import "fmt"

// duration 是一个基于 int 类型的类型
type duration int

// 使用更可读的方式格式化 duration 值
func (d *duration) pretty() string {
    return fmt.Sprintf("Duration: %d", *d)
}

// main 是应用程序的入口
func main() {
    duration(42).pretty()

    // 不能通过指针调用 furation(42) 的方法
    // 不能获取 duration(42) 的地址
}

上面所示的代码试图获取 duration 类型的值的地址,但是获取不到。
这展示了不能总是获得值的地址的一种情况。