go 语言圣经

1. 入门

go 使用 gofmt 工具格式化代码,这个工具没有任何调整参数的选项,它只有一种风格,就是官方的风格。此外 go 中可以使用 get 指令获取源码,build 指令编译源码,install 指令安装编译后的程序,run 指令编译并运行源码,test 指令测试源码,doc 指令查看文档,list 指令列出包中的内容,env 指令列出环境变量,clean 指令清除编译生成的文件,tool 指令运行 go 工具,version 指令查看版本信息。

go 1.17.1 及其后版本不再支持 get 命令,统一改用 install 指令,getg.mod 中同时用于更新依赖和安装命令,这种组合很混乱,使用起来也很不方便,比如开发人员不想同时进行更新和安装。

2. 程序结构

var 声明时会直接初始化,如果没有显示赋值,会被赋予零值 zero value,数值对应 0、字符串对应 “”。

:= 是短变量声明,这是定义一个或多个变量并根据它们的初始值为这些变量赋予适当类型的语句。

自增语句 i++i-- 不能作为左值,同时它们是『语句』而不是『表达式』,即不能出现在赋值语句的左边。

循环语句 for 可以看做三个部分:initialization; condition; post,各部分可以省略:

1
2
3
4
5
6
7
8
// while loop
for condition {
// ...
}
// infinite loop
for {
// ...
}

上面的循环,可以通过 breakreturn 跳出。

当然也有类似 python 的写法,使用 range 语法同时获得索引和内容,如果某个内容不需要可以使用空标识符 blank identifier _

声明一个变量有多种方式,下面这些都等价:

1
2
3
4
s := "" // 短变量声明,只能用在函数内部,而不能用于包变量
var s string // 依赖零值机制
var s = ""
var s string = ""

如何更新 go pkg mod 下的文件?

  1. cd /path/to/your/project 2. go get -u

strings.Split 的作用和 strings.Join 相反。

如果程序中有问题,可以使用 os.Exit 结束程序,os.Exit 会立即终止当前程序,即使是 defer 语句也不会被执行。

go 中使用 go 创建 goroutine,使用 make 创建 channel,channel 用来在 goroutine 间进行参数传递,main 本身也是一个 goroutine,每个 function 都是创建一个 goroutine。
当一个 goroutine 试图在一个 channel 上做 send 或 receive 操作时,这个 goroutine 会阻塞在调用处,直到另一个 goroutine 试图在这个 channel 上做 receive 或 send 操作,这样两个 goroutine 才会继续执行 channel 操作之后的逻辑。

go 常用动词:

  • %v: 默认格式的值
  • %+v: 打印结构体时,会添加字段名
  • %#v: Go 语法格式的值
  • %T: Go 语法格式的类型
  • %d: 十进制的整数
  • %s: 字符串或者 []byte
  • %q: 双引号包围的字符串或者字符
  • %x: 十六进制,小写字母,a-f
  • %X: 十六进制,大写字母,A-F
  • %f: 浮点数
  • %e: 科学计数法
  • %t: 布尔值

http.ResponseWriterio.Writer 有相同签名的 Write([]byte) (int, error) 方法,可以说 http.ResponseWriter 的类型也隐式地实现了 io.Writer,因此可以在需要 io.Writer 的地方使用 http.ResponseWriter

go 中的 switch 不需要主动写 break,当然也可以使用 fallthrough 强制执行下一个 case。同时可以不带表达式,这样可以实现 if-then-else 逻辑。

go 中指针是可见的,但是不能进行指针运算,即不能对指针进行加减操作,也不能通过指针访问其它变量的值,只能通过指针访问其它变量的地址。& 操作可以获取变量的地址,* 操作可以获取指针指向的变量的值。

名字的开头字母的大小写决定了名字在包外的可见性。如果一个名字是大写字母开头的,那么它将是导出的,也就是说可以被外部的包访问。

变量的一般语法是 var 变量名字 类型 = 表达式,『类型』、『= 表达式』两个部分可以省略其中一个,如果省略的是类型信息,那么将根据初始化表达式来推导变量的类型信息。如果初始化表达式被省略,那么将用零值初始化该变量。

在函数内部,可以通过『简短变量声明语句』的方式声明变量 名字 := 表达式,『简短变量声明』左边的变量可能并不是全部都是刚刚声明的。如果有一些已经在相同的词法域声明过了,那么『简短变量声明语句』对这些已经声明过的变量就只有赋值行为了。

1
2
3
in, err := os.Open(infile)
// ...
out, err := os.Create(outfile)

需要注意,『简短变量声明语句』中必须至少要声明一个新的变量,否则会导致编译错误。

此外如果变量是在外部词法域声明的,那么『简短变量声明语句』将会在当前词法域重新声明一个新的变量。

简短声明语句有些情况不支持:

  1. 当变量已经在『同一作用域』中声明过时,再次使用 := 会导致编译错误。
  2. 在函数外部(包级别)不能使用 := 进行变量声明和初始化。
  3. 如果 := 左边的所有变量都已经被声明,那么会导致编译错误。但是,如果至少有一个新的变量被声明,那么其他已经声明过的变量将会被赋予新的值。
  4. 如果尝试在一个不支持 := 的表达式中使用它,例如在 if、for 或 switch 的条件表达式中,那么会导致编译错误。

如果 var x int 声明一个 x 变量,那么 &x 就是产生一个指向该变量的指针,其类型是 *int,它保存了 x 变量的内存地址,变量有时候被称为可寻址的值。任何类型的指针的零值都是 nil。如果 p 指向某个有效变量,那么 p != nil 测试为真。

因为『指针』包含了一个变量的地址,因此如果将『指针』作为参数调用函数,那将可以在函数中通过该『指针』来更新变量的值。每次我们对一个变量『取地址』,或者『复制指针』,我们都是为原变量创建了新的『别名』。

某种意义上,『指针』=『取地址』=『别名』。

flag 包提供了一些函数,用于解析命令行参数,flag.Boolflag.Intflag.String 分别用于声明一个 bool、int、string 类型的命令行参数,返回的是对应类型的指针,通过 * 可以获取对应的值。

此外,可以通过 new(T) 创建 T 类型的匿名变量,同时初始化 T 的零值,然后返回变量地址,返回的指针类型为 *T

1
2
3
4
5
6
7
8
9
// 两种写法等价
func newInt() *int {
return new(int)
}

func newInt() *int {
var dummy int
return &dummy
}

注意每次使用 new 函数返回的都是一个新的地址,即使两次调用 new 函数传入的是同一个类型,返回的也是不同的地址。

此外 new 只是一个预定义的函数,它并不是一个关键字,因此我们可以将 new 名字重新定义为别的类型,但是此时就不能再使用 new 函数了。

一个变量的有效周期只取决于是否可达,如果有变量逃逸,就会把变量存放在堆上,我们不用感知 go 自动的垃圾回收,但是如果将指向短生命周期对象的指针保存到具有长生命周期的对象中,特别是保存到全局变量时,会阻止对短生命周期对象的垃圾回收(从而可能影响程序的性能)。

自增和自减是语句,而不是表达式,因此 x = i++ 之类的表达式是错误的。

『元组赋值』在赋值前,会对右边所有表达式先进行求值,然后再统一更新左边对应变量的值。

map 查找、类型断言或通道接收可以有两个值,第一个是成功的结果,第二个是布尔值(可以没有),用于表示操作是否成功。

1
2
3
4
5
6
7
8
9
10
11
v, ok = m[key]             // map lookup
v, ok = x.(T) // type assertion
v, ok = <-ch // channel receive

v = m[key] // map查找,失败时返回零值
v = x.(T) // type断言,失败时panic异常
v = <-ch // 管道接收,失败时返回零值(阻塞不算是失败)

_, ok = m[key] // map返回2个值
_, ok = mm[""], false // map返回1个值
_ = mm[""] // map返回1个值

对于每一个类型 T,都有一个对应的类型转换操作 T(x),用于将 x 转为 T 类型(如果 T 是指针类型,可能会需要用小括弧包装T,比如 (*int)(0))。只有当两个类型的底层基础类型相同时,才允许这种转型操作,或者是两者都是指向相同底层结构的指针类型,这些转换只改变类型而不会影响值本身。

在 go 语言程序中,每个包都有一个全局唯一的导入路径。

匿名函数处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// pc[i] is the population count of i.
var pc [256]byte
func init() {
for i := range pc {
pc[i] = pc[i/2] + byte(i&1)
}
}
// 等价通过匿名函数处理
// pc[i] is the population count of i.
var pc [256]byte = func() (pc [256]byte) {
for i := range pc {
pc[i] = pc[i/2] + byte(i&1)
}
return
}()

在包级别,『声明的顺序』并不会影响『作用域范围』,因此一个先声明的可以引用它自身或者是引用后面的一个声明,这可以让我们定义一些相互嵌套或递归的类型或函数。

3. 基础数据类型

位操作运算符 &^ 用于按位置零(AND NOT):如果对应 y 中 bit 位为 1 的话,表达式 z = x &^ y 结果 z 的对应的 bit 位为 0,否则 z 对应的 bit 位等于 x 相应的 bit 位的值。

go 中使用 Math.isNaN 判断是否『not a number』,可以使用 Math.NaN 获取 NaN。

go 中有两种进度复数 complex64complex128,如果一个浮点数面值或一个十进制整数面值后面跟着一个 i,例如 3.141592i 或 2i,它将构成一个复数的虚部,复数的实部是 0。

go 的布尔值也有短路一说,如果运算符左边值已经可以确定整个布尔表达式的值,那么运算符右边的值将不再被求值。

&& 对应逻辑乘法,||对应逻辑加法,乘法比加法优先级要高。

布尔值并不会隐式转换为数字值 0 或 1,反之亦然。

字符串的值是不可变的:一个字符串包含的字节序列永远不会被改变,当然我们也可以给一个字符串变量分配一个新字符串值。此外因为字符串是不可修改的,因此尝试修改字符串内部数据的操作也是被禁止的。

一个原生的字符串面值形式反引号代替双引号。在原生的字符串面值中,没有转义操作。

在 go 语言中,rune 是 int32 的别名,通常用来表示一个 Unicode 码点。而 string 是 Unicode 字符的序列。

注意 go 语言中,切片左闭右开,即 a[0:10] 表示从 a[0]a[9],不包括 a[10]

一个字符串是包含只读字节的数组,一旦创建,是不可变的。相比之下,一个字节 slice 的元素则可以自由地修改。字符串和字节 slice 之间可以相互转换。

当向 bytes.Buffer 添加任意字符的 UTF8 编码时,最好使用 bytes.Buffer 的 WriteRune 方法,但是 WriteByte 方法对于写入类似 [] 等 ASCII 字符则会更加有效。

因为它们的值是在编译期就确定的,因此常量可以是构成类型的一部分,例如用于指定数组类型的长度:

1
2
3
4
5
6
7
const IPv4Len = 4

// parseIPv4 parses an IPv4 address (d.d.d.d).
func parseIPv4(s string) IP {
var p [IPv4Len]byte
// ...
}

如果是批量声明的常量,除了第一个外其它的常量右边的初始化表达式都可以省略,如果省略初始化表达式则表示使用前面常量的初始化表达式写法,对应的常量类型也一样的。例如:

1
2
3
4
5
6
7
8
const (
a = 1
b
c = 2
d
)

fmt.Println(a, b, c, d) // "1 1 2 2"

在一个 const 声明语句中,在第一个声明的常量所在的行,iota 将会被置为 0,然后在每一个有常量声明的行加一。

1
2
3
4
5
6
7
8
9
10
11
type Weekday int

const (
Sunday Weekday = iota
Monday
Tuesday
Wednesday
Thursday
Friday
Saturday
)

go 中存在『无类型常量』分别是无类型的布尔型、无类型的整数、无类型的字符、无类型的浮点数、无类型的复数、无类型的字符串。

除法运算符 / 会根据操作数的类型生成对应类型的结果:

1
2
3
4
var f float64 = 212
fmt.Println((f - 32) * 5 / 9) // "100"; (f - 32) * 5 is a float64
fmt.Println(5 / 9 * (f - 32)) // "0"; 5/9 is an untyped integer, 0
fmt.Println(5.0 / 9.0 * (f - 32)) // "100"; 5.0/9.0 is an untyped float

当一个无类型的常量被赋值给一个变量的时候,无类型的常量将会被隐式转换为对应的类型,如果转换合法的话。

1
2
3
4
5
6
7
8
9
var f float64 = 3 + 0i // untyped complex -> float64
f = 2 // untyped integer -> float64
f = 1e123 // untyped floating-point -> float64
f = 'a' // untyped rune -> float64
// 等价写法
var f float64 = float64(3 + 0i)
f = float64(2)
f = float64(1e123)
f = float64('a')

4. 复合数据类型

数组是一个由固定长度的特定类型元素组成的序列,和数组对应的类型是 Slice(切片),它是可以增长和收缩的动态序列,slice 功能也更灵活。

在数组字面值中,如果在数组的长度位置出现的是 “…” 省略号,则表示数组的长度是根据初始化值的个数来计算。

1
2
q := [...]int{1, 2, 3}
fmt.Printf("%T\n", q) // "[3]int"

数组的长度是数组类型的一个组成部分,因此 [3]int[4]int 是两种不同的数组类型。

初始化索引的顺序是无关紧要的,而且没用到的索引可以省略,和前面提到的规则一样,未指定初始值的元素将用零值初始化。

1
r := [...]int{99: -1} // 一个含有100个元素的数组r,最后一个元素被初始化为-1,其它元素都是用0初始化。

go 语言对待『数组』的方式和其它很多编程语言不同,其它编程语言可能会隐式地将『数组』作为『引用』或『指针』对象传入被调用的函数。

一个 slice 由三个部分构成:指针、长度和容量:

  1. 指针指向第一个 slice 元素对应的底层数组元素的地址,要注意的是 slice 的第一个元素并不一定就是数组的第一个元素。
  2. 『长度』对应 slice 中元素的数目,『长度』不能超过『容量』。
  3. 『容量』一般是从 slice 的开始位置到底层数据的结尾位置。内置的 lencap 函数分别返回 slice 的长度和容量。
1
2
a := [...]int{0, 1, 2, 3, 4, 5} // 数组
s := []int{0, 1, 2, 3, 4, 5} // slice

数组是有长度定义的,而 slice 则是一个动态的结构,可以按需增长或缩小,给到某个 function 时,数组传递的是复制,而 slice 传递的是引用。

由于 slice 的元素是间接引用的,所以 slice 不能做 == 判断,它只能和 nil 做对比(一个零值的 slice 等于 nil)。

一个零值的 slice 等于 nil,应该用 len(s)==0 判空:

1
2
3
4
var s []int    // len(s) == 0, s == nil
s = nil // len(s) == 0, s == nil
s = []int(nil) // len(s) == 0, s == nil
s = []int{} // len(s) == 0, s != nil

在底层,make 创建了一个匿名的数组变量,然后返回一个 slice;只有通过返回的 slice 才能引用底层匿名的数组变量。

函数中『…』省略号表示接收变长的参数为 slice,而不是数组。

可以通过 make 创建 map,比如 ages := make(map[string]int) 表示 key 是 string、value 是 int 的 map。

1
2
3
4
5
6
7
8
ages := map[string]int{
"alice": 31,
"charlie": 34,
}
// 等价于
ages := make(map[string]int)
ages["alice"] = 31
ages["charlie"] = 34

map 中即使 key 找不到,也会返回对应的零值。map 中的 value 不是『变量』,所以不能做取址操作。

禁止对 map 元素取址的原因是 map 可能随着元素数量的增长而重新分配更大的内存空间,从而可能导致之前的地址无效。

向一个 nil 值的 map 存入元素将导致一个 panic 异常,需要提前对 map 做初始化

1
2
3
4
if m == nil {
m = make(map[string]int)
}
m["key"] = 42

map 的下标语法将产生两个值;第二个是一个布尔值,用于报告元素是否真的存在。

在 go 语言中,函数返回的是『值』,而不是『变量』,此外参数是『值』拷贝的。这意味着不能直接获取函数返回值的地址。

『结构体』中的成员顺序不同,是定义不同的『结构体』,如果结构体成员名字是以大写字母开头的,那么该成员就是导出的;这是 go 语言导出规则决定的。

一个命名为 S 的结构体类型将不能再包含 S 类型的成员,但是可以包含 *S 指针类型的成员。

结构体类型的零值是每个成员都是零值。如果结构体没有任何成员的话就是空结构体,写作 struct{}

1
sx := struct{}{} // 空结构体

结构体使用时,如果成员固定且排列规则,可以指定每个成员的值:

1
2
3
type Point struct{ X, Y int }

p := Point{1, 2}

更一般的,以成员名和相应的值来初始化:

1
anim := gif.GIF{LoopCount: nframes}

此外不能在外部包初始化结构体中未导出的成员,但是可以在同一个包内部的任何地方初始化未导出的成员。

函数出参、入参都是值;如果结构体作为出参,那么就是对结构体的拷贝,外部的变化不会影响到原结构体;指针结构体作为出参会影响到原结构体。

1
2
3
4
pp := &Point{1, 2}
// 等价于
pp := new(Point)
*pp = Point{1, 2}

可比较的结构体类型和其他可比较的类型一样,可以用于 map 的 key 类型。

结构体中的匿名成员,只声明一个成员对应的数据类型而不指名成员的名字。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
type Point struct {
X, Y int
}
type Circle struct {
Center Point
Radius int
}
type Wheel struct {
Circle Circle
Spokes int
}
// 等价于
type Circle struct {
Point
Radius int
}
type Wheel struct {
Circle
Spokes int
}

『结构体字面值』并没有简短表示匿名成员的语法,『结构体字面值』必须遵循形状类型声明时的结构,所以我们只能用下面的两种语法:

1
2
3
4
5
6
7
8
9
w = Wheel{Circle{Point{8, 8}, 5}, 20}

w = Wheel{
Circle: Circle{
Point: Point{X: 8, Y: 8},
Radius: 5,
},
Spokes: 20, // NOTE: trailing comma necessary here (and at Radius)
}

在包外部只有导出的结构体类型的成员才可以被访问。

一个结构体成员 Tag 是和在编译阶段关联到该成员的元信息字符串:

1
2
Year  int  `json:"released"`
Color bool `json:"color,omitempty"`

因为值中含有双引号字符,因此成员 Tag 一般用原生字符串面值的形式书写;omitempty 表示当 Go 结构体成员为空或零值时不生成该 JSON 对象(这里的空值包括 false、0、nil 指针或 nil 接口,以及任何长度为 0 的数组、切片、映射或字符串)。

5. 函数

函数声明包括『函数名』、『形式参数列表』、『返回值列表』(可省略)以及『函数体』。

1
2
3
func name(parameter-list) (result-list) {
body
}

函数的类型被称为函数的签名。如果两个函数形式参数列表和返回值列表中的变量类型一一对应,那么这两个函数被认为有相同的类型或签名。

实参通过值的方式传递,因此函数的形参是实参的拷贝。对形参进行修改不会影响实参。但是,如果实参包括引用类型,如指针,slice(切片)、map、function、channel等类型,实参可能会由于函数的间接引用被修改。

没有函数体的函数声明,表示该函数不是以Go实现的。

1
2
3
package math

func Sin(x float64) float //implemented in assembly language

准确的变量名可以传达函数返回值的含义。

1
2
3
func Size(rect image.Rectangle) (width, height int)
func Split(path string) (dir, file string)
func HourMinSec(t time.Time) (hour, minute, second int)

如果一个函数所有的返回值都有显式的变量名,那么该函数的 return 语句可以省略操作数。这称之为 bare return。

在 go 中,函数被看作第一类值(first-class values):函数像其他值一样,拥有类型,可以被赋值给其他变量,传递给函数,从函数返回。

1
2
3
4
5
6
7
8
9
10
11
12
func square(n int) int { return n * n }
func negative(n int) int { return -n }
func product(m, n int) int { return m * n }

f := square
fmt.Println(f(3)) // "9"

f = negative
fmt.Println(f(3)) // "-3"
fmt.Printf("%T\n", f) // "func(int) int"

f = product // compile error: can't assign func(int, int) int to func(int) int

函数类型的零值是 nil。调用值为 nil 的函数值会引起 panic 错误。

函数值之间是不可比较的,也不能用函数值作为 map 的 key。

『函数字面量』允许我们在使用函数时,再定义它,它的值被称为『匿名函数』。

在『函数』中定义的『内部函数』可以引用该函数的变量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// squares返回一个匿名函数。
// 该匿名函数每次被调用时都会返回下一个数的平方。
func squares() func() int {
var x int
return func() int {
x++
return x * x
}
}
func main() {
f := squares()
fmt.Println(f()) // "1"
fmt.Println(f()) // "4"
fmt.Println(f()) // "9"
fmt.Println(f()) // "16"
}

在声明可变参数函数时,需要在参数列表的最后一个参数类型之前加上省略符号『…』,这表示该函数会接收任意数量的该类型参数。

1
2
3
4
fmt.Println(sum(1, 2, 3, 4)) // "10"
// 等价于
values := []int{1, 2, 3, 4}
fmt.Println(sum(values...)) // "10"

『可变参数』函数和以『切片』作为参数的函数是不同的。

1
2
3
4
func f(...int) {}
func g([]int) {}
fmt.Printf("%T\n", f) // "func(...int)"
fmt.Printf("%T\n", g) // "func([]int)"

你可以在一个函数中执行多条 defer 语句,它们的执行顺序与声明顺序相反。释放资源的 defer 应该直接跟在请求资源的语句后。

对匿名函数采用 defer 机制,可以使其观察函数的返回值。

1
2
3
4
5
6
7
func double(x int) (result int) {
defer func() { fmt.Printf("double(%d) = %d\n", x,result) }()
return x + x
}
_ = double(4)
// Output:
// "double(4) = 8"

panic 异常发生时,程序会中断运行,并立即执行在该 goroutine 中被延迟的函数(defer 机制)。

如果在 defer 函数中调用了内置函数 recover,并且定义该 defer 语句的函数发生了 panic 异常,recover 会使程序从 panic 中恢复,并返回 panic value。导致 panic 异常的函数不会继续运行,但能正常返回。在未发生 panic 时调用 recoverrecover 会返回 nil

1
2
3
4
5
6
7
8
func Parse(input string) (s *Syntax, err error) {
defer func() {
if p := recover(); p != nil {
err = fmt.Errorf("internal error: %v", p)
}
}()
// ...parser...
}

只恢复应该被恢复的 panic 异常,在 recover 时对 panic value 进行检查,如果发现 panic value 是特殊类型,就将这个 panic 作为 error 处理。

6. 方法

在函数声明时,在其名字之前放上一个变量,即是一个方法。相当于为这种类型定义了一个独占的方法。

我们可以给同一个包内的任意命名类型定义方法,只要这个命名类型的底层类型不是指针或者 interface

只有类型(Point)和指向他们的指针(*Point),才可能是出现在接收器声明里的两种接收器。为了避免歧义,在声明方法时,如果一个类型名本身是一个指针的话,是不允许其出现在接收器中的。

1
2
type P *int
func (P) f() { /* ... */ } // compile error: invalid receiver type

想要调用指针类型方法 (*Point).ScaleBy,只要提供一个 Point 类型的指针即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
r := &Point{1, 2}
r.ScaleBy(2)
fmt.Println(*r) // "{2, 4}"
// 等价于
p := Point{1, 2}
pptr := &p
pptr.ScaleBy(2)
fmt.Println(p) // "{2, 4}"
// 等价于
p := Point{1, 2}
(&p).ScaleBy(2)
fmt.Println(p) // "{2, 4}"
// 等价于(重点)
p.ScaleBy(2) // 隐式转换

这种隐式转换只适用于『变量』,不能通过一个无法取到地址的接收器来调用指针方法。

1
Point{1, 2}.ScaleBy(2) // compile error: can't take address of Point literal

同理,我们可以用一个 *Point 这样的接收器来调用 Point 的方法,因为我们可以通过地址来找到这个变量。

1
2
3
pptr.Distance(q)
// 等价于
(*pptr).Distance(q)

在声明一个 method 的 receiver 该是指针还是非指针类型时,你需要考虑两方面的因素,第一方面是这个对象本身是不是特别大,如果声明为非指针变量时,调用会产生一次拷贝;第二方面是如果你用指针类型作为 receiver ,那么你一定要注意,这种指针类型指向的始终是一块内存地址,就算你对其进行了拷贝。

多亏了内嵌,有些时候我们给匿名 struct 类型来定义方法也有了手段。

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
var (
mu sync.Mutex // guards mapping
mapping = make(map[string]string)
)

func Lookup(key string) string {
mu.Lock()
v := mapping[key]
mu.Unlock()
return v
}
// 等价于
var cache = struct {
sync.Mutex
mapping map[string]string
}{
mapping: make(map[string]string),
}

func Lookup(key string) string {
cache.Lock()
v := cache.mapping[key]
cache.Unlock()
return v
}

p.Distance 叫作“选择器”,选择器会返回一个方法“值”->一个将方法(Point.Distance)绑定到特定接收器变量的函数。

当 T 是一个类型时,方法表达式可能会写作 T.f 或者 (*T).f ,会返回一个函数“值”,这种函数会将其第一个参数用作接收器。

通过 Point.Distance 得到的函数需要比实际的 Distance 方法多一个参数,即其需要用第一个额外参数指定接收器,后面排列 Distance 方法的参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
type Point struct{ X, Y float64 }

func (p Point) Add(q Point) Point { return Point{p.X + q.X, p.Y + q.Y} }
func (p Point) Sub(q Point) Point { return Point{p.X - q.X, p.Y - q.Y} }

type Path []Point

func (path Path) TranslateBy(offset Point, add bool) {
var op func(p, q Point) Point
if add {
op = Point.Add
} else {
op = Point.Sub
}
for i := range path {
// Call either path[i].Add(offset) or path[i].Sub(offset).
path[i] = op(path[i], offset)
}
}

7. 接口

接口类型是一种抽象的类型。它不会暴露出它所代表的对象的内部值的结构和这个对象支持的基础操作的集合;它们只会表现出它们自己的方法。

接口指定的规则非常简单:表达一个类型属于某个接口只要这个类型实现这个接口。

1
2
3
4
5
6
7
8
var w io.Writer
w = os.Stdout // OK: *os.File has Write method
w = new(bytes.Buffer) // OK: *bytes.Buffer has Write method
w = time.Second // compile error: time.Duration lacks Write method

var rwc io.ReadWriteCloser
rwc = os.Stdout // OK: *os.File has Read, Write, Close methods
rwc = new(bytes.Buffer) // compile error: *bytes.Buffer lacks Close method

空接口类型对实现它的类型没有要求,所以我们可以将任意一个值赋给空接口类型。

1
2
3
4
5
6
var any interface{}
any = true
any = 12.34
any = "hello"
any = map[string]int{"one": 1}
any = new(bytes.Buffer)

var _ io.Writer = (*bytes.Buffer)(nil) 在 Go 语言中是一种常见的模式,用于在编译时检查 *bytes.Buffer 类型是否实现了 io.Writer 接口。如果 *bytes.Buffer 没有实现 io.Writer 接口,那么这行代码就会在编译时报错,因此这是一种在编译时检查类型是否实现了接口的方法。

『接口值』,由两个部分组成,一个『具体的类型』和『那个类型的值』。它们被称为接口的『动态类型』和『动态值』。

变量总是被一个定义明确的值初始化,即使接口类型也不例外。对于一个接口的零值就是它的『类型』和『值』的部分都是 nil。可以通过使用 w==nil 或者 w!=nil 来判断接口值是否为空。

1
2
3
4
5
var w io.Writer // type 是 nil,value 也是 nil
w = os.Stdout // type 是 *os.File,value 持有 os.Stdout 的拷贝
w = new(bytes.Buffer) // type 是 *bytes.Buffer,value 持有一个新分配的缓冲区
w = nil // type 是 nil,value 也是 nil
var x interface{} = time.Now() // type 是 time.Time,value 持有 time.Now() 的拷贝

接口值可以使用 ==!= 来进行比较,它们可以用在 map 的键或者作为 switch 语句的操作数。

如果两个接口值的动态类型相同,但是这个动态类型是不可比较的(比如切片),将它们进行比较就会失败并且 panic

1
2
var x interface{} = []int{1, 2, 3}
fmt.Println(x == x) // panic: comparing uncomparable type []int

一个『不包含任何值的 nil 接口值』和一个刚好『包含 nil 指针的接口值』是不同的。

注意,判空 != null 看的是 type 和 value,只要 type 非空,这个判断也是会通过的

在 go 语言中,io.Writer 是一个接口类型,而 *bytes.Buffer 是一个具体类型。如果 out 是一个 *bytes.Buffer 类型的变量,那么 out 可能为 nil,因为 *bytes.Buffer 是一个指针类型,它可以为 nil

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

1
2
3
4
5
6
7
package sort

type Interface interface {
Len() int
Less(i, j int) bool // i, j are indices of sequence elements
Swap(i, j int)
}

go 中接口实现需要有同名的方法,但是不需要显式声明实现了哪个接口,只要实现了接口的方法,就是实现了该接口。

每次调用 errors.New 都会创建一个新的、独特的 *errorString 实例,这个实例满足 error 接口,即使你多次调用 errors.New 并传入相同的字符串,返回的也是不同的错误实例。

go 中,error 是一个内建的接口类型,它定义了一个方法 Error() string。任何类型只要实现了这个方法,就可以说它实现了 error 接口。

『具体类型的类型断言』从它的操作对象中获得具体的值。

1
2
3
4
var w io.Writer
w = os.Stdout
f := w.(*os.File) // success: f == os.Stdout
c := w.(*bytes.Buffer) // panic: interface holds *os.File, not *bytes.Buffer

『接口类型的类型断言』改变了类型的表述方式,但是它保留了接口值内部的动态类型和值的部分。

1
2
3
4
5
6
7
8
type Stringer interface {
String() string
}

var i interface{} = "hello"
s := i.(Stringer)
fmt.Println(s) // 输出 "<nil>"
// string 类型没有实现 Stringer 接口,所以 s 的值为 nil

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

下面是一个『具体类型的类型断言』,会比逐个比对异常来得优雅。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import (
"errors"
"syscall"
)

var ErrNotExist = errors.New("file does not exist")

// IsNotExist returns a boolean indicating whether the error is known to
// report that a file or directory does not exist. It is satisfied by
// ErrNotExist as well as some syscall errors.
func IsNotExist(err error) bool {
if pe, ok := err.(*PathError); ok {
err = pe.Err
}
return err == syscall.ENOENT || err == ErrNotExist
}

  • 接口被以两种不同的方式使用。在第一个方式中,一个接口的方法表达了实现这个接口的具体类型间的相似性,但是隐藏了代码的细节和这些具体类型本身的操作。重点在于方法上,而不是具体的类型上;
  • 第二个方式是利用一个接口值可以持有各种具体类型值的能力,将这个接口认为是这些类型的联合,重点在于这个接口值本身,而非它所持有的具体类型的值。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func sqlQuote(x interface{}) string {
switch x := x.(type) {
case nil:
return "NULL"
case int, uint:
return fmt.Sprintf("%d", x) // x has type interface{} here.
case bool:
if x {
return "TRUE"
}
return "FALSE"
case string:
return sqlQuoteString(x) // (not shown)
default:
panic(fmt.Sprintf("unexpected type %T: %v", x, x))
}
}

8. Goroutines 和 Channels

当一个程序启动时,其主函数即在一个单独的 goroutine 中运行,我们叫它 main goroutine。新的 goroutine 会用 go 语句来创建。

主函数返回时,所有的 goroutine 都会被直接打断,程序退出。

一个 channel 是一个通信机制,它可以让一个 goroutine 通过它给另一个 goroutine 发送值信息。

一个基于无缓存 Channels 的发送、接收操作会阻塞另一个 goroutine,因此无缓存 Channels 有时候也被称为『同步Channels』。

1
2
3
4
5
ch := make(chan int) // ch has type 'chan int'

ch <- x // a send statement
x = <-ch // a receive expression in an assignment statement
<-ch // a receive statement; result is discarded

Channels 也可以用于将多个 goroutine 连接在一起,一个 Channel 的输出作为下一个 Channel 的输入。这种串联的 Channels 就是所谓的管道(pipeline)。

当一个 channel 被关闭后,再向该 channel 发送数据将导致 panic 异常。在接收端可以通过接收表达式的第二参数来判断 channel 是否被关闭:v, ok := <-ch,如果 okfalse,那么说明 channel 已经被关闭了。

使用 range 循环可以依次从 channel 接收数据,当 channel 被关闭并且没有值可接收时跳出循环。

与文件的打开不同,只有当需要告诉接收者 goroutine,所有的数据已经全部发送时才需要关闭 channel。不管一个 channel 是否被关闭,当它没有被引用时将会被 go 的垃圾自动回收器回收。

试图重复关闭一个 channel 将导致 panic 异常,试图关闭一个 nil 值的 channel 也将导致 panic 异常。

为了表明某个 channel 只能用于接收 or 发送,go 提供了单方向的 channel 类型,类型 chan<- int 表示一个只发送 int 的 channel,只能发送不能接收。相反,类型 <-chan int 表示一个只接收 int 的 channel,只能接收不能发送。

chan<- 表示只能发送,<-chan 表示只能接收,通过箭头来记忆。

由于 close 只能在发送者(chan<-)触发,因此在接收者(<-chan)中使用 close 是不恰当的,编译器会报错。

不能将一个类似 chan<- int 类型的单向型的 channel 转换为 chan int 类型的双向型的 channel。

向缓存 Channel 的『发送操作』就是向内部缓存队列的『尾部插入』元素,『接收操作』则是从队列的『头部删除』元素(形成队列)。

可以用内置的 cap 函数获取 Channel 的容量,通过 len 函数可以获取 Channel 当前的缓存长度。如果缓存满了 chan<- 会阻塞,如果空了 <-chan 会阻塞。

如果当前有三个 goroutines,但我们使用了无缓存的 channel,那么两个慢的 goroutines 将会因为没有人接收而被永远卡住。这称为『goroutines泄漏』,这将是一个 BUG。

当主 goroutine 在启动子 goroutine 后可能会立即退出。当主 goroutine 退出时,所有的 goroutine 都会被立即终止,为了解决这一问题,可以使用 channel 来同步 goroutine 的执行。

比较地道的写法:

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
func makeThumbnails6(filenames <-chan string) int64 {
sizes := make(chan int64)
var wg sync.WaitGroup // number of working goroutines
for f := range filenames {
wg.Add(1)
// worker
go func(f string) {
defer wg.Done()
thumb, err := thumbnail.ImageFile(f)
if err != nil {
log.Println(err)
return
}
info, _ := os.Stat(thumb) // OK to ignore error
sizes <- info.Size()
}(f)
}

// closer
go func() {
wg.Wait()
close(sizes)
}()

var total int64
for size := range sizes {
total += size
}
return total
}

struct{} 是『空结构体类型的名称』,struct{}{} 是『空结构体类型的值』。

channel 发送最好放到单独的 goroutine 中,这样可以避免阻塞当前 goroutine

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
func main() {
worklist := make(chan []string) // lists of URLs, may have duplicates
unseenLinks := make(chan string) // de-duplicated URLs

// Add command-line arguments to worklist.
go func() { worklist <- os.Args[1:] }()

// Create 20 crawler goroutines to fetch each unseen link.
for i := 0; i < 20; i++ {
go func() {
for link := range unseenLinks {
foundLinks := crawl(link)
go func() { worklist <- foundLinks }()
}
}()
}

// The main goroutine de-duplicates worklist items
// and sends the unseen ones to the crawlers.
seen := make(map[string]bool)
for list := range worklist {
for _, link := range list {
if !seen[link] {
seen[link] = true
unseenLinks <- link
}
}
}
}

select 语句会一直阻塞,直到条件分支中的某个可以继续执行,当多个都准备好的时候,会随机选择一个。

channel 有零值。对一个 nilchannel 发送和接收操作会永远阻塞,在 select语句 中操作 nilchannel 永远都不会被 select 到。

当某个 channel 被关闭,会返回对应的零值,可以通过这个机制广播事件。

1
2
3
4
5
6
7
8
9
10
var done = make(chan struct{}) // 零值是一个空结构体 struct{}

func cancelled() bool {
select {
case <-done:
return true
default:
return false
}
}

9. 基于共享变量的并发

包级别的导出函数一般情况下都是并发安全的。由于 package 级的变量没法被限制在单一的 gorouine,所以修改这些变量必须使用互斥条件。

数据竞争会在两个以上的 goroutine 并发访问相同的变量且至少其中一个为写操作时发生。

sync.Mutex 是一个互斥锁,它的作用是守护在临界区入口来确保同一时间只有一个 goroutine 进入临界区。

sync.RWMutex 是一个读写锁,其允许多个只读操作并行执行,但写操作会完全互斥。

在一个独立的 goroutine 中,每一个语句的执行顺序是可以被保证的,也就是说 goroutine 内顺序是连贯的,但是在不同的 goroutine 中,执行顺序就是不确定的了。

go 并发问题一言以蔽之,『将变量限定在 goroutine 内部;如果是多个 goroutine 都需要访问的变量,使用互斥条件来访问』。

sync.Once 可以解决一次性初始化的问题,Do 这个唯一的方法需要接收初始化函数作为其参数。

go 中可以使用竞争检查器 the race detector 进行竞争检查,只需要在编译时加上 -race 参数即可。比如下面这段就是一份 race 报告,指出了两个 goroutine 在同时访问同一个变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$ go test -run=TestConcurrent -race -v gopl.io/ch9/memo1
=== RUN TestConcurrent
...
WARNING: DATA RACE
Write by goroutine 36:
runtime.mapassign1()
~/go/src/runtime/hashmap.go:411 +0x0
gopl.io/ch9/memo1.(*Memo).Get()
~/gobook2/src/gopl.io/ch9/memo1/memo.go:32 +0x205
...
Previous write by goroutine 35:
runtime.mapassign1()
~/go/src/runtime/hashmap.go:411 +0x0
gopl.io/ch9/memo1.(*Memo).Get()
~/gobook2/src/gopl.io/ch9/memo1/memo.go:32 +0x205
...
Found 1 data race(s)
FAIL gopl.io/ch9/memo1 2.393s

每一个 OS 线程都有一个固定大小的内存块(一般会是 2MB)来做栈,这个栈会用来存储当前正在被调用或挂起(指在调用其它函数时)的函数的内部变量。

一个 goroutine 会以一个很小的栈开始其生命周期,一般只需要 2KB。栈的大小会根据需要动态地伸缩,而 goroutine 的栈的最大值有 1GB。

goroutine 没有可以被程序员获取到的 id 的概念。

10. 包和工具

每个包通过控制包内名字的可见性和是否导出来实现封装特性。

如果你计划分享或发布包,那么导入路径最好是全球唯一的。

如果我们想同时导入两个有着名字相同的包,例如 math/rand 包和 crypto/rand 包,那么导入声明必须至少为一个同名包指定一个新的包名以避免冲突。

1
2
3
4
import (
"crypto/rand"
mrand "math/rand" // alternative name mrand avoids conflict
)

我们可以用下划线 _ 来重命名导入的包,_ 是空白标识符,这是『匿名导入』。

1
import _ "image/png" // register PNG decoder

GOPATH 用来指定当前工作目录,当需要不同的工作区时,只需要更新 GOPATH 即可。

  • src 子目录用于存储源代码。
  • pkg 子目录用于保存编译后的包的目标文件。
  • bin 子目录用于保存编译后的可执行程序。

GOROOT 用来指定 go 的安装目录,还有它自带的标准库包的位置。

go install 命令和 go build 命令很相似,但是它会保存每个包的编译成果,被编译的包会被保存到 $GOPATH/pkg 目录下。

go install 命令和 go build 命令都不会重新编译没有发生变化的包。

为了方便编译依赖的包,go build -i 命令将安装每个目标所依赖的包。

一个特别的构建注释参数可以提供更多的构建过程控制。

1
2
3
4
5
// 是Linux或Mac OS X时才编译这个文件
// +build linux darwin

// 不编译这个文件
// +build ignore

go 中的文档注释一般是完整的句子,第一行通常是摘要说明,以被注释者的名字开头。注释中函数的参数或其它的标识符并不需要额外的引号或其它标记注明。

1
2
3
// Fprintf formats according to a format specifier and writes to w.
// It returns the number of bytes written and any write error encountered.
func Fprintf(w io.Writer, format string, a ...interface{}) (int, error)

go doc 命令,该命令打印其后所指定的实体(包、包成员、方法)的声明与文档注释。

一个 internal 包只能被和 internal 目录有同一个父目录的包所导入。例如,net/http/internal/chunked 内部包只能被 net/http/httputilnet/http 包导入,但是不能被 net/url 包导入。

go list 命令可以查询可用包的信息,可以用 ... 表示匹配任意的包的导入路径。

1
2
3
$ go list ...xml...
encoding/xml
gopl.io/ch7/xmlselect

11. 测试

go test 按照一定的约定和组织来测试代码的程序,所有以 _test.go 为后缀名的源文件在执行 go build 时不会被构建成包的一部分,它们是 go test 测试的一部分。

每个测试函数必须导入 testing 包。其中 t 参数用于报告测试失败和附加的日志信息。

1
2
3
func TestName(t *testing.T) {
// ...
}

-run=Coverage 表示只运行测试用例,并生成覆盖率报告。-coverprofile=c.out 表示将覆盖率报告输出到文件 c.out 中。

1
2
$ go test -run=Coverage -coverprofile=c.out gopl.io/ch7/eval
ok gopl.io/ch7/eval 0.032s coverage: 68.5% of statements

基准测试函数和普通测试函数写法类似,但是以 Benchmark 为前缀名,并且带有一个 *testing.B 类型的参数;*testing.B 参数除了提供和 *testing.T 类似的方法,还有额外一些和性能测量相关的方法。它还提供了一个整数 N,用于指定操作执行的循环次数。

通过 -bench 命令行标志参数手工指定要运行的基准测试函数。该参数是一个正则表达式,用于匹配要执行的基准测试函数的名字,默认值是空的。其中“.”模式将可以匹配所有基准测试函数,但因为这里只有一个基准测试函数,因此和 -bench=IsPalindrome 参数是等价的效果。

12. 反射

没有办法来检查未知类型的表示方式,我们被卡住了。这就是我们需要反射的原因。

反射是由 reflect 包提供的。它定义了两个重要的类型,Type 和 Value。一个 Type 表示一个类型,它是一个接口。

interface{} 类型是一个空接口,它可以表示任何类型。任何类型都至少实现了零个方法的接口,所以任何类型都满足 interface{}

函数 reflect.TypeOf 接受任意的 interface{} 类型,并以 reflect.Type 形式返回其动态类型:

1
2
3
t := reflect.TypeOf(3)  // a reflect.Type
fmt.Println(t.String()) // "int"
fmt.Println(t) // "int"

fmt.Printf 提供了一个缩写 %T 参数,内部使用 reflect.TypeOf 来输出:

1
fmt.Printf("%T\n", 3) // "int"

函数 reflect.ValueOf 接受任意的 interface{} 类型,并返回一个装载着其动态值的 reflect.Value

1
2
3
4
v := reflect.ValueOf(3) // a reflect.Value
fmt.Println(v) // "3"
fmt.Printf("%v\n", v) // "3"
fmt.Println(v.String()) // NOTE: "<int Value>"

reflect.Value 也满足 fmt.Stringer 接口,但是除非 Value 持有的是字符串,否则 String 方法只返回其类型。而使用 fmt 包的 %v 标志参数会对 reflect.Values 特殊处理。

reflect.ValueOf 的逆操作是 reflect.Value.Interface 方法。

一个空的接口隐藏了值内部的表示方式和所有方法,因此只有我们知道具体的动态类型才能使用类型断言来访问内部的值。

我们使用 reflect.Value 的 Kind 方法来替代之前的类型 switch。虽然还是有无穷多的类型,但是它们的 kinds 类型却是有限的。

  • Bool、String 和 所有数字类型的基础类型;
  • Array 和 Struct 对应的聚合类型;
  • Chan、Func、Ptr、Slice 和 Map 对应的引用类型;
  • interface 类型;还有表示空值的 Invalid 类型。(空的 reflect.Value 的 kind 即为 Invalid。)

所有通过 reflect.ValueOf(x) 返回的 reflect.Value 都是不可取地址的;但是每当我们通过指针间接地获取的 reflect.Value 都是可取地址的。

这是因为 reflect.Value 的内部表示方式,它包含了值的类型和值本身。当 reflect.Value 不可取地址时,Go 运行时可以安全地移动和复制 reflect.Value,而不会影响到其内部的值。

对于指针类型,Elem() 方法返回指针指向的值;对于接口类型,Elem() 方法返回接口的动态值。

只有接口、指针类型可以使用 Elem() 方法。

反射可以越过 go 的导出规则的限制读取结构体中未导出的成员,但是并不能修改这些未导出的成员。

每次 reflect.ValueOf(x).Method(i) 方法调用都返回一个 reflect.Value 以表示对应的值,也就是一个方法是绑到它的接收者的。使用 reflect.Value.Call 方法,将可以调用一个 Func 类型的 Value 。

13. 底层编程

unsafe.Offsetof 函数的参数必须是一个字段 x.f,然后返回 f 字段相对于 x 起始地址的偏移量,包括可能的空洞。

unsafe.Pointer 和普通指针的区别在于:

  • 类型安全:unsafe.Pointer 可以被转换为任何其他类型的指针,而普通指针只能被转换为相同类型的指针。这意味着 unsafe.Pointer 可以绕过安全检查,而普通指针则不能。
  • 算术运算:unsafe.Pointer 可以进行算术运算,如加法和减法,而普通指针则不行。
  • 内存访问:unsafe.Pointer 可以直接访问内存,而普通指针则需要通过类型转换才能访问内存。
  • 使用限制:unsafe.Pointer 只能在 unsafe 包中使用,而不能在其他包中使用。