1
0
mirror of https://github.com/chai2010/advanced-go-programming-book.git synced 2025-05-23 20:02:22 +00:00

Merge pull request #568 from iGmainC/master

第一章优化排版
This commit is contained in:
chai2010 2022-01-18 06:58:38 +08:00 committed by GitHub
commit 2da13f2e69
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 201 additions and 214 deletions

View File

@ -1,44 +1,43 @@
# 1.3 数组、字符串和切片
在主流的编程语言中数组及其相关的数据结构是使用得最为频繁的,只有在它(们)不能满足时才会考虑链表、hash表hash表可以看作是数组和链表的混合体和更复杂的自定义数据结构。
在主流的编程语言中数组及其相关的数据结构是使用得最为频繁的,只有在它(们)不能满足时才会考虑链表、hash hash 表可以看作是数组和链表的混合体)和更复杂的自定义数据结构。
Go语言中数组、字符串和切片三者是密切相关的数据结构。这三种数据类型在底层原始数据有着相同的内存结构在上层因为语法的限制而有着不同的行为表现。首先Go语言的数组是一种值类型虽然数组的元素可以被修改但是数组本身的赋值和函数传参都是以整体复制的方式处理的。Go语言字符串底层数据也是对应的字节数组但是字符串的只读属性禁止了在程序中对底层字节数组的元素的修改。字符串赋值只是复制了数据地址和对应的长度而不会导致底层数据的复制。切片的行为更为灵活切片的结构和字符串结构类似但是解除了只读限制。切片的底层数据虽然也是对应数据类型的数组但是每个切片还有独立的长度和容量信息切片赋值和函数传参数时也是将切片头信息部分按传值方式处理。因为切片头含有底层数据的指针所以它的赋值也不会导致底层数据的复制。其实Go语言的赋值和函数传参规则很简单除了闭包函数以引用的方式对外部变量访问之外其它赋值和函数传参数都是以传值的方式处理。要理解数组、字符串和切片三种不同的处理方式的原因需要详细了解它们的底层数据结构。
Go 语言中数组、字符串和切片三者是密切相关的数据结构。这三种数据类型在底层原始数据有着相同的内存结构在上层因为语法的限制而有着不同的行为表现。首先Go 语言的数组是一种值类型虽然数组的元素可以被修改但是数组本身的赋值和函数传参都是以整体复制的方式处理的。Go 语言字符串底层数据也是对应的字节数组,但是字符串的只读属性禁止了在程序中对底层字节数组的元素的修改。字符串赋值只是复制了数据地址和对应的长度,而不会导致底层数据的复制。切片的行为更为灵活,切片的结构和字符串结构类似,但是解除了只读限制。切片的底层数据虽然也是对应数据类型的数组,但是每个切片还有独立的长度和容量信息,切片赋值和函数传参数时也是将切片头信息部分按传值方式处理。因为切片头含有底层数据的指针,所以它的赋值也不会导致底层数据的复制。其实 Go 语言的赋值和函数传参规则很简单,除了闭包函数以引用的方式对外部变量访问之外,其它赋值和函数传参数都是以传值的方式处理。要理解数组、字符串和切片三种不同的处理方式的原因需要详细了解它们的底层数据结构。
## 1.3.1 数组
数组是一个由固定长度的特定类型元素组成的序列一个数组可以由零个或多个元素组成。数组的长度是数组类型的组成部分。因为数组的长度是数组类型的一个部分不同长度或不同类型的数据组成的数组都是不同的类型因此在Go语言中很少直接使用数组不同长度的数组因为类型不同无法直接赋值。和数组对应的类型是切片切片是可以动态增长和收缩的序列切片的功能也更加灵活但是要理解切片的工作原理还是要先理解数组。
数组是一个由固定长度的特定类型元素组成的序列,一个数组可以由零个或多个元素组成。数组的长度是数组类型的组成部分。因为数组的长度是数组类型的一个部分,不同长度或不同类型的数据组成的数组都是不同的类型,因此在 Go 语言中很少直接使用数组(不同长度的数组因为类型不同无法直接赋值)。和数组对应的类型是切片,切片是可以动态增长和收缩的序列,切片的功能也更加灵活,但是要理解切片的工作原理还是要先理解数组。
我们先看看数组有哪些定义方式:
```go
var a [3]int // 定义长度为3的int型数组, 元素全部为0
var b = [...]int{1, 2, 3} // 定义长度为3的int型数组, 元素为 1, 2, 3
var c = [...]int{2: 3, 1: 2} // 定义长度为3的int型数组, 元素为 0, 2, 3
var d = [...]int{1, 2, 4: 5, 6} // 定义长度为6的int型数组, 元素为 1, 2, 0, 0, 5, 6
var a [3]int // 定义长度为 3 int 型数组, 元素全部为 0
var b = [...]int{1, 2, 3} // 定义长度为 3 int 型数组, 元素为 1, 2, 3
var c = [...]int{2: 3, 1: 2} // 定义长度为 3 int 型数组, 元素为 0, 2, 3
var d = [...]int{1, 2, 4: 5, 6} // 定义长度为 6 int 型数组, 元素为 1, 2, 0, 0, 5, 6
```
第一种方式是定义一个数组变量的最基本的方式,数组的长度明确指定,数组中的每个元素都以零值初始化。
第二种方式定义数组,可以在定义的时候顺序指定全部元素的初始化值,数组的长度根据初始化元素的数目自动计算。
第三种方式是以索引的方式来初始化数组的元素,因此元素的初始化值出现顺序比较随意。这种初始化方式和`map[int]Type`类型的初始化语法类似。数组的长度以出现的最大的索引为准,没有明确初始化的元素依然用0值初始化。
第三种方式是以索引的方式来初始化数组的元素,因此元素的初始化值出现顺序比较随意。这种初始化方式和 `map[int]Type` 类型的初始化语法类似。数组的长度以出现的最大的索引为准,没有明确初始化的元素依然用值初始化。
第四种方式是混合了第二种和第三种的初始化方式,前面两个元素采用顺序初始化,第三第四个元素零值初始化,第五个元素通过索引初始化,最后一个元素跟在前面的第五个元素之后采用顺序初始化。
数组的内存结构比较简单。比如下面是一个`[4]int{2,3,5,7}`数组值对应的内存结构:
数组的内存结构比较简单。比如下面是一个 `[4]int{2,3,5,7}` 数组值对应的内存结构:
![](../images/ch1-7-array-4int.ditaa.png)
*图 1-7 数组布局*
Go语言中数组是值语义。一个数组变量即表示整个数组它并不是隐式的指向第一个元素的指针比如C语言的数组而是一个完整的值。当一个数组变量被赋值或者被传递的时候实际上会复制整个数组。如果数组较大的话数组的赋值也会有较大的开销。为了避免复制数组带来的开销可以传递一个指向数组的指针但是数组指针并不是数组。
Go 语言中数组是值语义。一个数组变量即表示整个数组,它并不是隐式的指向第一个元素的指针(比如 C 语言的数组),而是一个完整的值。当一个数组变量被赋值或者被传递的时候,实际上会复制整个数组。如果数组较大的话,数组的赋值也会有较大的开销。为了避免复制数组带来的开销,可以传递一个指向数组的指针,但是数组指针并不是数组。
```go
var a = [...]int{1, 2, 3} // a 是一个数组
var b = &a // b 是指向数组的指针
fmt.Println(a[0], a[1]) // 打印数组的前2个元素
fmt.Println(a[0], a[1]) // 打印数组的前 2 个元素
fmt.Println(b[0], b[1]) // 通过数组指针访问数组元素的方式和数组类似
for i, v := range b { // 通过数组指针迭代数组的元素
@ -46,11 +45,11 @@ for i, v := range b { // 通过数组指针迭代数组的元素
}
```
其中`b`是指向`a`数组的指针,但是通过`b`访问数组中元素的写法和`a`类似的。还可以通过`for range`来迭代数组指针指向的数组元素。其实数组指针类型除了类型和数组不同之外,通过数组指针操作数组的方式和通过数组本身的操作类似,而且数组指针赋值时只会拷贝一个指针。但是数组指针类型依然不够灵活,因为数组的长度是数组类型的组成部分,指向不同长度数组的数组指针类型也是完全不同的。
其中 `b` 是指向 `a` 数组的指针,但是通过 `b` 访问数组中元素的写法和 `a` 类似的。还可以通过 `for range` 来迭代数组指针指向的数组元素。其实数组指针类型除了类型和数组不同之外,通过数组指针操作数组的方式和通过数组本身的操作类似,而且数组指针赋值时只会拷贝一个指针。但是数组指针类型依然不够灵活,因为数组的长度是数组类型的组成部分,指向不同长度数组的数组指针类型也是完全不同的。
可以将数组看作一个特殊的结构体,结构的字段名对应数组的索引,同时结构体成员的数目是固定的。内置函数`len`可以用于计算数组的长度,`cap`函数可以用于计算数组的容量。不过对于数组类型来说,`len``cap`函数返回的结果始终是一样的,都是对应数组类型的长度。
可以将数组看作一个特殊的结构体,结构的字段名对应数组的索引,同时结构体成员的数目是固定的。内置函数 `len` 可以用于计算数组的长度,`cap` 函数可以用于计算数组的容量。不过对于数组类型来说,`len` `cap` 函数返回的结果始终是一样的,都是对应数组类型的长度。
我们可以用`for`循环来迭代数组。下面常见的几种方式都可以用来遍历数组:
我们可以用 `for` 循环来迭代数组。下面常见的几种方式都可以用来遍历数组:
```go
for i := range a {
@ -64,9 +63,9 @@ for i, v := range b { // 通过数组指针迭代数组的元素
}
```
`for range`方式迭代的性能可能会更好一些,因为这种迭代可以保证不会出现数组越界的情形,每轮迭代对数组元素的访问时可以省去对下标越界的判断。
`for range` 方式迭代的性能可能会更好一些,因为这种迭代可以保证不会出现数组越界的情形,每轮迭代对数组元素的访问时可以省去对下标越界的判断。
`for range`方式迭代,还可以忽略迭代时的下标:
`for range` 方式迭代,还可以忽略迭代时的下标:
```go
var times [5][0]int
@ -75,7 +74,7 @@ for i, v := range b { // 通过数组指针迭代数组的元素
}
```
其中`times`对应一个`[5][0]int`类型的数组,虽然第一维数组有长度,但是数组的元素`[0]int`大小是0因此整个数组占用的内存大小依然是0。没有付出额外的内存代价我们就通过`for range`方式实现了`times`次快速迭代。
其中 `times` 对应一个 `[5][0]int` 类型的数组,虽然第一维数组有长度,但是数组的元素 `[0]int` 大小是 0因此整个数组占用的内存大小依然是 0。没有付出额外的内存代价我们就通过 `for range` 方式实现了 `times` 次快速迭代。
数组不仅仅可以用于数值类型,还可以定义字符串数组、结构体数组、函数数组、接口数组、管道数组等等:
@ -108,12 +107,12 @@ var chanList = [2]chan int{}
我们还可以定义一个空的数组:
```go
var d [0]int // 定义一个长度为0的数组
var e = [0]int{} // 定义一个长度为0的数组
var f = [...]int{} // 定义一个长度为0的数组
var d [0]int // 定义一个长度为 0 的数组
var e = [0]int{} // 定义一个长度为 0 的数组
var f = [...]int{} // 定义一个长度为 0 的数组
```
长度为0的数组在内存中并不占用空间。空数组虽然很少直接使用但是可以用于强调某种特有类型的操作时避免分配额外的内存空间比如用于管道的同步操作
长度为 0 的数组在内存中并不占用空间。空数组虽然很少直接使用,但是可以用于强调某种特有类型的操作时避免分配额外的内存空间,比如用于管道的同步操作:
```go
c1 := make(chan [0]int)
@ -130,25 +129,25 @@ var f = [...]int{} // 定义一个长度为0的数组
c2 := make(chan struct{})
go func() {
fmt.Println("c2")
c2 <- struct{}{} // struct{}部分是类型, {}表示对应的结构体值
c2 <- struct{}{} // struct{} 部分是类型, {} 表示对应的结构体值
}()
<-c2
```
我们可以用`fmt.Printf`函数提供的`%T``%#v`谓词语法来打印数组的类型和详细信息:
我们可以用 `fmt.Printf` 函数提供的 `%T` `%#v` 谓词语法来打印数组的类型和详细信息:
```go
fmt.Printf("b: %T\n", b) // b: [3]int
fmt.Printf("b: %#v\n", b) // b: [3]int{1, 2, 3}
```
在Go语言中数组类型是切片和字符串等结构的基础。以上数组的很多操作都可以直接用于字符串或切片中。
Go 语言中,数组类型是切片和字符串等结构的基础。以上数组的很多操作都可以直接用于字符串或切片中。
## 1.3.2 字符串
一个字符串是一个不可改变的字节序列字符串通常是用来包含人类可读的文本数据。和数组不同的是字符串的元素不可修改是一个只读的字节数组。每个字符串的长度虽然也是固定的但是字符串的长度并不是字符串类型的一部分。由于Go语言的源代码要求是UTF8编码导致Go源代码中出现的字符串面值常量一般也是UTF8编码的。源代码中的文本字符串通常被解释为采用UTF8编码的Unicode码点rune序列。因为字节序列对应的是只读的字节序列因此字符串可以包含任意的数据包括byte值0。我们也可以用字符串表示GBK等非UTF8编码的数据不过这种时候将字符串看作是一个只读的二进制数组更准确因为`for range`等语法并不能支持非UTF8编码的字符串的遍历。
一个字符串是一个不可改变的字节序列,字符串通常是用来包含人类可读的文本数据。和数组不同的是,字符串的元素不可修改,是一个只读的字节数组。每个字符串的长度虽然也是固定的,但是字符串的长度并不是字符串类型的一部分。由于 Go 语言的源代码要求是 UTF8 编码,导致 Go 源代码中出现的字符串面值常量一般也是 UTF8 编码的。源代码中的文本字符串通常被解释为采用 UTF8 编码的 Unicode 码点rune序列。因为字节序列对应的是只读的字节序列因此字符串可以包含任意的数据包括 byte 0。我们也可以用字符串表示 GBK 等非 UTF8 编码的数据,不过这种时候将字符串看作是一个只读的二进制数组更准确,因为 `for range` 等语法并不能支持非 UTF8 编码的字符串的遍历。
Go语言字符串的底层结构在`reflect.StringHeader`中定义:
Go 语言字符串的底层结构在 `reflect.StringHeader` 中定义:
```go
type StringHeader struct {
@ -157,7 +156,7 @@ type StringHeader struct {
}
```
字符串结构由两个信息组成:第一个是字符串指向的底层字节数组,第二个是字符串的字节的长度。字符串其实是一个结构体,因此字符串的赋值操作也就是`reflect.StringHeader`结构体的复制过程,并不会涉及底层字节数组的复制。在前面数组一节提到的`[2]string`字符串数组对应的底层结构和`[2]reflect.StringHeader`对应的底层结构是一样的,可以将字符串数组看作一个结构体数组。
字符串结构由两个信息组成:第一个是字符串指向的底层字节数组,第二个是字符串的字节的长度。字符串其实是一个结构体,因此字符串的赋值操作也就是 `reflect.StringHeader` 结构体的复制过程,并不会涉及底层字节数组的复制。在前面数组一节提到的 `[2]string` 字符串数组对应的底层结构和 `[2]reflect.StringHeader` 对应的底层结构是一样的,可以将字符串数组看作一个结构体数组。
我们可以看看字符串“Hello, world”本身对应的内存结构
@ -165,7 +164,6 @@ type StringHeader struct {
*图 1-8 字符串布局*
分析可以发现“Hello, world”字符串底层数据和以下数组是完全一致的
```go
@ -185,7 +183,7 @@ s1 := "hello, world"[:5]
s2 := "hello, world"[7:]
```
字符串和数组类似,内置的`len`函数返回字符串的长度。也可以通过`reflect.StringHeader`结构访问字符串的长度(这里只是为了演示字符串的结构,并不是推荐的做法):
字符串和数组类似,内置的 `len` 函数返回字符串的长度。也可以通过 `reflect.StringHeader` 结构访问字符串的长度(这里只是为了演示字符串的结构,并不是推荐的做法):
```go
fmt.Println("len(s):", (*reflect.StringHeader)(unsafe.Pointer(&s)).Len) // 12
@ -193,7 +191,7 @@ fmt.Println("len(s1):", (*reflect.StringHeader)(unsafe.Pointer(&s1)).Len) // 5
fmt.Println("len(s2):", (*reflect.StringHeader)(unsafe.Pointer(&s2)).Len) // 5
```
根据Go语言规范Go语言的源文件都是采用UTF8编码。因此Go源文件中出现的字符串面值常量一般也是UTF8编码的对于转义字符则没有这个限制。提到Go字符串时我们一般都会假设字符串对应的是一个合法的UTF8编码的字符序列。可以用内置的`print`调试函数或`fmt.Print`函数直接打印,也可以用`for range`循环直接遍历UTF8解码后的Unicode码点值。
根据 Go 语言规范Go 语言的源文件都是采用 UTF8 编码。因此Go 源文件中出现的字符串面值常量一般也是 UTF8 编码的(对于转义字符,则没有这个限制)。提到 Go 字符串时,我们一般都会假设字符串对应的是一个合法的 UTF8 编码的字符序列。可以用内置的 `print` 调试函数或 `fmt.Print` 函数直接打印,也可以用 `for range` 循环直接遍历 UTF8 解码后的 Unicode 码点值。
下面的“Hello, 世界”字符串中包含了中文字符,可以通过打印转型为字节类型来查看字符底层对应的数据:
@ -208,7 +206,7 @@ fmt.Printf("%#v\n", []byte("Hello, 世界"))
0x95, 0x8c}
```
分析可以发现`0xe4, 0xb8, 0x96`对应中文“世”,`0xe7, 0x95, 0x8c`对应中文“界”。我们也可以在字符串面值中直指定UTF8编码后的值源文件中全部是ASCII码可以避免出现多字节的字符
分析可以发现`0xe4, 0xb8, 0x96`对应中文“世”,`0xe7, 0x95, 0x8c`对应中文“界”。我们也可以在字符串面值中直指定 UTF8 编码后的值(源文件中全部是 ASCII 码,可以避免出现多字节的字符)。
```go
fmt.Println("\xe4\xb8\x96") // 打印: 世
@ -221,15 +219,15 @@ fmt.Println("\xe7\x95\x8c") // 打印: 界
*图 1-9 字符串布局*
Go语言的字符串中可以存放任意的二进制字节序列而且即使是UTF8字符序列也可能会遇到坏的编码。如果遇到一个错误的UTF8编码输入将生成一个特别的Unicode字符\uFFFD这个字符在不同的软件中的显示效果可能不太一样在印刷中这个符号通常是一个黑色六角形或钻石形状里面包含一个白色的问号<E58FB7>
Go 语言的字符串中可以存放任意的二进制字节序列,而且即使是 UTF8 字符序列也可能会遇到坏的编码。如果遇到一个错误的 UTF8 编码输入,将生成一个特别的 Unicode 字符‘\uFFFD这个字符在不同的软件中的显示效果可能不太一样在印刷中这个符号通常是一个黑色六角形或钻石形状里面包含一个白色的问号<E58FB7>
下面的字符串中我们故意损坏了第一字符的第二和第三字节因此第一字符将会打印为“<EFBFBD>第二和第三字节则被忽略后面的“abc”依然可以正常解码打印错误编码不会向后扩散是UTF8编码的优秀特性之一
下面的字符串中我们故意损坏了第一字符的第二和第三字节因此第一字符将会打印为“<EFBFBD>第二和第三字节则被忽略后面的“abc”依然可以正常解码打印错误编码不会向后扩散是 UTF8 编码的优秀特性之一)。
```go
fmt.Println("\xe4\x00\x00\xe7\x95\x8cabc") // <20>界abc
```
不过在`for range`迭代这个含有损坏的UTF8字符串时第一字符的第二和第三字节依然会被单独迭代到不过此时迭代的值是损坏后的0
不过在 `for range` 迭代这个含有损坏的 UTF8 字符串时,第一字符的第二和第三字节依然会被单独迭代到,不过此时迭代的值是损坏后的 0
```go
for i, c := range "\xe4\x00\x00\xe7\x95\x8cabc" {
@ -244,7 +242,7 @@ for i, c := range "\xe4\x00\x00\xe7\x95\x8cabc" {
// 8 99 // c
```
如果不想解码UTF8字符串想直接遍历原始的字节码可以将字符串强制转为`[]byte`字节序列后再行遍历(这里的转换一般不会产生运行时开销):
如果不想解码 UTF8 字符串,想直接遍历原始的字节码,可以将字符串强制转为 `[]byte` 字节序列后再行遍历(这里的转换一般不会产生运行时开销):
```go
for i, c := range []byte("世界abc") {
@ -261,20 +259,20 @@ for i := 0; i < len(s); i++ {
}
```
Go语言除了`for range`语法对UTF8字符串提供了特殊支持外还对字符串和`[]rune`类型的相互转换提供了特殊的支持。
Go 语言除了 `for range` 语法对 UTF8 字符串提供了特殊支持外,还对字符串和 `[]rune` 类型的相互转换提供了特殊的支持。
```go
fmt.Printf("%#v\n", []rune("世界")) // []int32{19990, 30028}
fmt.Printf("%#v\n", []rune("世界")) // []int32{19990, 30028}
fmt.Printf("%#v\n", string([]rune{'世', '界'})) // 世界
```
从上面代码的输出结果来看,我们可以发现`[]rune`其实是`[]int32`类型,这里的`rune`只是`int32`类型的别名,并不是重新定义的类型。`rune`用于表示每个Unicode码点目前只使用了21个bit位。
从上面代码的输出结果来看,我们可以发现 `[]rune` 其实是 `[]int32` 类型,这里的 `rune` 只是 `int32` 类型的别名,并不是重新定义的类型。`rune` 用于表示每个 Unicode 码点,目前只使用了 21 bit 位。
字符串相关的强制类型转换主要涉及到`[]byte``[]rune`两种类型。每个转换都可能隐含重新分配内存的代价,最坏的情况下它们的运算时间复杂度都是`O(n)`。不过字符串和`[]rune`的转换要更为特殊一些,因为一般这种强制类型转换要求两个类型的底层内存结构要尽量一致,显然它们底层对应的`[]byte``[]int32`类型是完全不同的内部布局,因此这种转换可能隐含重新分配内存的操作。
字符串相关的强制类型转换主要涉及到 `[]byte` `[]rune` 两种类型。每个转换都可能隐含重新分配内存的代价,最坏的情况下它们的运算时间复杂度都是 `O(n)`。不过字符串和 `[]rune` 的转换要更为特殊一些,因为一般这种强制类型转换要求两个类型的底层内存结构要尽量一致,显然它们底层对应的 `[]byte` `[]int32` 类型是完全不同的内部布局,因此这种转换可能隐含重新分配内存的操作。
下面分别用伪代码简单模拟Go语言对字符串内置的一些操作这样对每个操作的处理的时间复杂度和空间复杂度都会有较明确的认识。
下面分别用伪代码简单模拟 Go 语言对字符串内置的一些操作,这样对每个操作的处理的时间复杂度和空间复杂度都会有较明确的认识。
**`for range`对字符串的迭代模拟实现**
**`for range` 对字符串的迭代模拟实现**
```go
func forOnString(s string, forBody func(i int, r rune)) {
@ -287,9 +285,9 @@ func forOnString(s string, forBody func(i int, r rune)) {
}
```
`for range`迭代字符串时每次解码一个Unicode字符然后进入`for`循环体,遇到崩坏的编码并不会导致迭代停止。
`for range` 迭代字符串时,每次解码一个 Unicode 字符,然后进入 `for` 循环体,遇到崩坏的编码并不会导致迭代停止。
**`[]byte(s)`转换模拟实现**
**`[]byte(s)` 转换模拟实现**
```go
func str2bytes(s string) []byte {
@ -302,9 +300,9 @@ func str2bytes(s string) []byte {
}
```
模拟实现中新创建了一个切片,然后将字符串的数组逐一复制到了切片中,这是为了保证字符串只读的语义。当然,在将字符串转为`[]byte`时,如果转换后的变量并没有被修改的情形,编译器可能会直接返回原始的字符串对应的底层数据。
模拟实现中新创建了一个切片,然后将字符串的数组逐一复制到了切片中,这是为了保证字符串只读的语义。当然,在将字符串转为 `[]byte` 时,如果转换后的变量并没有被修改的情形,编译器可能会直接返回原始的字符串对应的底层数据。
**`string(bytes)`转换模拟实现**
**`string(bytes)` 转换模拟实现**
```go
func bytes2str(s []byte) (p string) {
@ -321,9 +319,9 @@ func bytes2str(s []byte) (p string) {
}
```
因为Go语言的字符串是只读的无法直接同构构造底层字节数组生成字符串。在模拟实现中通过`unsafe`包获取了字符串的底层数据结构,然后将切片的数据逐一复制到了字符串中,这同样是为了保证字符串只读的语义不会受切片的影响。如果转换后的字符串在生命周期中原始的`[]byte`的变量并不会发生变化,编译器可能会直接基于`[]byte`底层的数据构建字符串。
因为 Go 语言的字符串是只读的,无法直接同构构造底层字节数组生成字符串。在模拟实现中通过 `unsafe` 包获取了字符串的底层数据结构,然后将切片的数据逐一复制到了字符串中,这同样是为了保证字符串只读的语义不会受切片的影响。如果转换后的字符串在生命周期中原始的 `[]byte` 的变量并不会发生变化,编译器可能会直接基于 `[]byte` 底层的数据构建字符串。
**`[]rune(s)`转换模拟实现**
**`[]rune(s)` 转换模拟实现**
```go
func str2runes(s string) []rune{
@ -337,9 +335,9 @@ func str2runes(s string) []rune{
}
```
因为底层内存结构的差异,字符串到`[]rune`的转换必然会导致重新分配`[]rune`内存空间然后依次解码并复制对应的Unicode码点值。这种强制转换并不存在前面提到的字符串和字节切片转化时的优化情况。
因为底层内存结构的差异,字符串到 `[]rune` 的转换必然会导致重新分配 `[]rune` 内存空间,然后依次解码并复制对应的 Unicode 码点值。这种强制转换并不存在前面提到的字符串和字节切片转化时的优化情况。
**`string(runes)`转换模拟实现**
**`string(runes)` 转换模拟实现**
```go
func runes2string(s []int32) string {
@ -353,11 +351,11 @@ func runes2string(s []int32) string {
}
```
同样因为底层内存结构的差异,`[]rune`到字符串的转换也必然会导致重新构造字符串。这种强制转换并不存在前面提到的优化情况。
同样因为底层内存结构的差异,`[]rune` 到字符串的转换也必然会导致重新构造字符串。这种强制转换并不存在前面提到的优化情况。
## 1.3.3 切片(slice)
简单地说切片就是一种简化版的动态数组。因为动态数组的长度是不固定切片的长度自然也就不能是类型的组成部分了。数组虽然有适用它们的地方但是数组的类型和操作都不够灵活因此在Go代码中数组使用的并不多。而切片则使用得相当广泛理解切片的原理和用法是一个Go程序员的必备技能。
简单地说,切片就是一种简化版的动态数组。因为动态数组的长度是不固定,切片的长度自然也就不能是类型的组成部分了。数组虽然有适用它们的地方,但是数组的类型和操作都不够灵活,因此在 Go 代码中数组使用的并不多。而切片则使用得相当广泛,理解切片的原理和用法是一个 Go 程序员的必备技能。
我们先看看切片的结构定义,`reflect.SliceHeader`
@ -369,30 +367,29 @@ type SliceHeader struct {
}
```
可以看出切片的开头部分和Go字符串是一样的但是切片多了一个`Cap`成员表示切片指向的内存空间的最大容量(对应元素的个数,而不是字节数)。下图是`x := []int{2,3,5,7,11}``y := x[1:3]`两个切片对应的内存结构。
可以看出切片的开头部分和 Go 字符串是一样的,但是切片多了一个 `Cap` 成员表示切片指向的内存空间的最大容量(对应元素的个数,而不是字节数)。下图是 `x := []int{2,3,5,7,11}` `y := x[1:3]` 两个切片对应的内存结构。
![](../images/ch1-10-slice-1.ditaa.png)
*图 1-10 切片布局*
让我们看看切片有哪些定义方式:
```go
var (
a []int // nil切片, 和 nil 相等, 一般用来表示一个不存在的切片
a []int // nil 切片, 和 nil 相等, 一般用来表示一个不存在的切片
b = []int{} // 空切片, 和 nil 不相等, 一般用来表示一个空的集合
c = []int{1, 2, 3} // 有3个元素的切片, len和cap都为3
d = c[:2] // 有2个元素的切片, len为2, cap为3
e = c[0:2:cap(c)] // 有2个元素的切片, len为2, cap为3
f = c[:0] // 有0个元素的切片, len为0, cap为3
g = make([]int, 3) // 有3个元素的切片, len和cap都为3
h = make([]int, 2, 3) // 有2个元素的切片, len为2, cap为3
i = make([]int, 0, 3) // 有0个元素的切片, len为0, cap为3
c = []int{1, 2, 3} // 有 3 个元素的切片, len cap 都为 3
d = c[:2] // 有 2 个元素的切片, len 2, cap 3
e = c[0:2:cap(c)] // 有 2 个元素的切片, len 2, cap 3
f = c[:0] // 有 0 个元素的切片, len 0, cap 3
g = make([]int, 3) // 有 3 个元素的切片, len cap 都为 3
h = make([]int, 2, 3) // 有 2 个元素的切片, len 2, cap 3
i = make([]int, 0, 3) // 有 0 个元素的切片, len 0, cap 3
)
```
和数组一样,内置的`len`函数返回切片中有效元素的长度,内置的`cap`函数返回切片容量大小,容量必须大于或等于切片的长度。也可以通过`reflect.SliceHeader`结构访问切片的信息(只是为了说明切片的结构,并不是推荐的做法)。切片可以和`nil`进行比较,只有当切片底层数据指针为空时切片本身为`nil`这时候切片的长度和容量信息将是无效的。如果有切片的底层数据指针为空但是长度和容量不为0的情况那么说明切片本身已经被损坏了比如直接通过`reflect.SliceHeader``unsafe`包对切片作了不正确的修改)。
和数组一样,内置的 `len` 函数返回切片中有效元素的长度,内置的 `cap` 函数返回切片容量大小,容量必须大于或等于切片的长度。也可以通过 `reflect.SliceHeader` 结构访问切片的信息(只是为了说明切片的结构,并不是推荐的做法)。切片可以和 `nil` 进行比较,只有当切片底层数据指针为空时切片本身为 `nil`,这时候切片的长度和容量信息将是无效的。如果有切片的底层数据指针为空,但是长度和容量不为 0 的情况,那么说明切片本身已经被损坏了(比如直接通过 `reflect.SliceHeader` `unsafe` 包对切片作了不正确的修改)。
遍历切片的方式和遍历数组的方式类似:
@ -412,59 +409,58 @@ var (
如前所说,切片是一种简化版的动态数组,这是切片类型的灵魂。除了构造切片和遍历切片之外,添加切片元素、删除切片元素都是切片处理中经常遇到的问题。
**添加切片元素**
内置的泛型函数`append`可以在切片的尾部追加`N`个元素:
内置的泛型函数 `append` 可以在切片的尾部追加 `N` 个元素:
```go
var a []int
a = append(a, 1) // 追加1个元素
a = append(a, 1) // 追加 1 个元素
a = append(a, 1, 2, 3) // 追加多个元素, 手写解包方式
a = append(a, []int{1,2,3}...) // 追加个切片, 切片需要解包
a = append(a, []int{1,2,3}...) // 追加 1 个切片, 切片需要解包
```
不过要注意的是,在容量不足的情况下,`append`的操作会导致重新分配内存,可能导致巨大的内存分配和复制数据代价。即使容量足够,依然需要用`append`函数的返回值来更新切片本身,因为新切片的长度已经发生了变化。
不过要注意的是,在容量不足的情况下,`append` 的操作会导致重新分配内存,可能导致巨大的内存分配和复制数据代价。即使容量足够,依然需要用 `append` 函数的返回值来更新切片本身,因为新切片的长度已经发生了变化。
除了在切片的尾部追加,我们还可以在切片的开头添加元素:
```go
var a = []int{1,2,3}
a = append([]int{0}, a...) // 在开头添加1个元素
a = append([]int{-3,-2,-1}, a...) // 在开头添加1个切片
a = append([]int{0}, a...) // 在开头添加 1 个元素
a = append([]int{-3,-2,-1}, a...) // 在开头添加 1 个切片
```
在开头一般都会导致内存的重新分配而且会导致已有的元素全部复制1次。因此从切片的开头添加元素的性能一般要比从尾部追加元素的性能差很多。
在开头一般都会导致内存的重新分配,而且会导致已有的元素全部复制 1 次。因此,从切片的开头添加元素的性能一般要比从尾部追加元素的性能差很多。
由于`append`函数返回新的切片,也就是它支持链式操作。我们可以将多个`append`操作组合起来,实现在切片中间插入元素:
由于 `append` 函数返回新的切片,也就是它支持链式操作。我们可以将多个 `append` 操作组合起来,实现在切片中间插入元素:
```go
var a []int
a = append(a[:i], append([]int{x}, a[i:]...)...) // 在第i个位置插入x
a = append(a[:i], append([]int{1,2,3}, a[i:]...)...) // 在第i个位置插入切片
a = append(a[:i], append([]int{x}, a[i:]...)...) // 在第 i 个位置插入 x
a = append(a[:i], append([]int{1,2,3}, a[i:]...)...) // 在第 i 个位置插入切片
```
每个添加操作中的第二个`append`调用都会创建一个临时切片,并将`a[i:]`的内容复制到新创建的切片中,然后将临时创建的切片再追加到`a[:i]`
每个添加操作中的第二个 `append` 调用都会创建一个临时切片,并将 `a[i:]` 的内容复制到新创建的切片中,然后将临时创建的切片再追加到 `a[:i]`
可以用`copy``append`组合可以避免创建中间的临时切片,同样是完成添加元素的操作:
可以用 `copy` `append` 组合可以避免创建中间的临时切片,同样是完成添加元素的操作:
```go
a = append(a, 0) // 切片扩展1个空间
copy(a[i+1:], a[i:]) // a[i:]向后移动1个位置
a = append(a, 0) // 切片扩展 1 个空间
copy(a[i+1:], a[i:]) // a[i:] 向后移动 1 个位置
a[i] = x // 设置新添加的元素
```
第一句`append`用于扩展切片的长度,为要插入的元素留出空间。第二句`copy`操作将要插入位置开始之后的元素向后挪动一个位置。第三句真实地将新添加的元素赋值到对应的位置。操作语句虽然冗长了一点,但是相比前面的方法,可以减少中间创建的临时切片。
第一句 `append` 用于扩展切片的长度,为要插入的元素留出空间。第二句 `copy` 操作将要插入位置开始之后的元素向后挪动一个位置。第三句真实地将新添加的元素赋值到对应的位置。操作语句虽然冗长了一点,但是相比前面的方法,可以减少中间创建的临时切片。
`copy``append`组合也可以实现在中间位置插入多个元素(也就是插入一个切片):
`copy` `append` 组合也可以实现在中间位置插入多个元素(也就是插入一个切片):
```go
a = append(a, x...) // 为x切片扩展足够的空间
copy(a[i+len(x):], a[i:]) // a[i:]向后移动len(x)个位置
a = append(a, x...) // 为 x 切片扩展足够的空间
copy(a[i+len(x):], a[i:]) // a[i:] 向后移动 len(x) 个位置
copy(a[i:], x) // 复制新添加的切片
```
稍显不足的是,在第一句扩展切片容量的时候,扩展空间部分的元素复制是没有必要的。没有专门的内置函数用于扩展切片的容量,`append`本质是用于追加元素而不是扩展容量,扩展切片容量只是`append`的一个副作用。
稍显不足的是,在第一句扩展切片容量的时候,扩展空间部分的元素复制是没有必要的。没有专门的内置函数用于扩展切片的容量,`append` 本质是用于追加元素而不是扩展容量,扩展切片容量只是 `append` 的一个副作用。
**删除切片元素**
@ -472,54 +468,53 @@ copy(a[i:], x) // 复制新添加的切片
```go
a = []int{1, 2, 3}
a = a[:len(a)-1] // 删除尾部1个元素
a = a[:len(a)-N] // 删除尾部N个元素
a = a[:len(a)-1] // 删除尾部 1 个元素
a = a[:len(a)-N] // 删除尾部 N 个元素
```
删除开头的元素可以直接移动数据指针:
```go
a = []int{1, 2, 3}
a = a[1:] // 删除开头1个元素
a = a[N:] // 删除开头N个元素
a = a[1:] // 删除开头 1 个元素
a = a[N:] // 删除开头 N 个元素
```
删除开头的元素也可以不移动数据指针,但是将后面的数据向开头移动。可以用`append`原地完成(所谓原地完成是指在原有的切片数据对应的内存区间内完成,不会导致内存空间结构的变化):
删除开头的元素也可以不移动数据指针,但是将后面的数据向开头移动。可以用 `append` 原地完成(所谓原地完成是指在原有的切片数据对应的内存区间内完成,不会导致内存空间结构的变化):
```go
a = []int{1, 2, 3}
a = append(a[:0], a[1:]...) // 删除开头1个元素
a = append(a[:0], a[N:]...) // 删除开头N个元素
a = append(a[:0], a[1:]...) // 删除开头 1 个元素
a = append(a[:0], a[N:]...) // 删除开头 N 个元素
```
也可以用`copy`完成删除开头的元素:
也可以用 `copy` 完成删除开头的元素:
```go
a = []int{1, 2, 3}
a = a[:copy(a, a[1:])] // 删除开头1个元素
a = a[:copy(a, a[N:])] // 删除开头N个元素
a = a[:copy(a, a[1:])] // 删除开头 1 个元素
a = a[:copy(a, a[N:])] // 删除开头 N 个元素
```
对于删除中间的元素,需要对剩余的元素进行一次整体挪动,同样可以用`append``copy`原地完成:
对于删除中间的元素,需要对剩余的元素进行一次整体挪动,同样可以用 `append` `copy` 原地完成:
```go
a = []int{1, 2, 3, ...}
a = append(a[:i], a[i+1:]...) // 删除中间1个元素
a = append(a[:i], a[i+N:]...) // 删除中间N个元素
a = append(a[:i], a[i+1:]...) // 删除中间 1 个元素
a = append(a[:i], a[i+N:]...) // 删除中间 N 个元素
a = a[:i+copy(a[i:], a[i+1:])] // 删除中间1个元素
a = a[:i+copy(a[i:], a[i+N:])] // 删除中间N个元素
a = a[:i+copy(a[i:], a[i+1:])] // 删除中间 1 个元素
a = a[:i+copy(a[i:], a[i+N:])] // 删除中间 N 个元素
```
删除开头的元素和删除尾部的元素都可以认为是删除中间元素操作的特殊情况。
**切片内存技巧**
在本节开头的数组部分我们提到过有类似`[0]int`的空数组,空数组一般很少用到。但是对于切片来说,`len``0`但是`cap`容量不为`0`的切片则是非常有用的特性。当然,如果`len``cap`都为`0`的话,则变成一个真正的空切片,虽然它并不是一个`nil`值的切片。在判断一个切片是否为空时,一般通过`len`获取切片的长度来判断,一般很少将切片和`nil`值做直接的比较。
比如下面的`TrimSpace`函数用于删除`[]byte`中的空格。函数实现利用了0长切片的特性实现高效而且简洁。
在本节开头的数组部分我们提到过有类似 `[0]int` 的空数组,空数组一般很少用到。但是对于切片来说,`len``0` 但是 `cap` 容量不为 `0` 的切片则是非常有用的特性。当然,如果 `len``cap` 都为 `0` 的话,则变成一个真正的空切片,虽然它并不是一个 `nil` 值的切片。在判断一个切片是否为空时,一般通过 `len` 获取切片的长度来判断,一般很少将切片和 `nil` 值做直接的比较。
比如下面的 `TrimSpace` 函数用于删除 `[]byte` 中的空格。函数实现利用了 0 长切片的特性,实现高效而且简洁。
```go
func TrimSpace(s []byte) []byte {
@ -547,14 +542,13 @@ func Filter(s []byte, fn func(x byte) bool) []byte {
}
```
切片高效操作的要点是要降低内存分配的次数,尽量保证`append`操作不会超出`cap`的容量,降低触发内存分配的次数和每次分配内存大小。
切片高效操作的要点是要降低内存分配的次数,尽量保证 `append` 操作不会超出 `cap` 的容量,降低触发内存分配的次数和每次分配内存大小。
**避免切片内存泄漏**
如前面所说,切片操作并不会复制底层的数据。底层的数组会被保存在内存中,直到它不再被引用。但是有时候可能会因为一个小的内存引用而导致底层整个数组处于被使用的状态,这会延迟自动内存回收器对底层数组的回收。
例如,`FindPhoneNumber`函数加载整个文件到内存,然后搜索第一个出现的电话号码,最后结果以切片方式返回。
例如,`FindPhoneNumber` 函数加载整个文件到内存,然后搜索第一个出现的电话号码,最后结果以切片方式返回。
```go
func FindPhoneNumber(filename string) []byte {
@ -563,9 +557,9 @@ func FindPhoneNumber(filename string) []byte {
}
```
这段代码返回的`[]byte`指向保存整个文件的数组。因为切片引用了整个原始数组,导致自动垃圾回收器不能及时释放底层数组的空间。一个小的需求可能导致需要长时间保存整个文件数据。这虽然这并不是传统意义上的内存泄漏,但是可能会拖慢系统的整体性能。
这段代码返回的 `[]byte` 指向保存整个文件的数组。因为切片引用了整个原始数组,导致自动垃圾回收器不能及时释放底层数组的空间。一个小的需求可能导致需要长时间保存整个文件数据。这虽然这并不是传统意义上的内存泄漏,但是可能会拖慢系统的整体性能。
要修复这个问题可以将感兴趣的数据复制到一个新的切片中数据的传值是Go语言编程的一个哲学虽然传值有一定的代价但是换取的好处是切断了对原始数据的依赖
要修复这个问题,可以将感兴趣的数据复制到一个新的切片中(数据的传值是 Go 语言编程的一个哲学,虽然传值有一定的代价,但是换取的好处是切断了对原始数据的依赖):
```go
func FindPhoneNumber(filename string) []byte {
@ -579,25 +573,24 @@ func FindPhoneNumber(filename string) []byte {
```go
var a []*int{ ... }
a = a[:len(a)-1] // 被删除的最后一个元素依然被引用, 可能导致GC操作被阻碍
a = a[:len(a)-1] // 被删除的最后一个元素依然被引用, 可能导致 GC 操作被阻碍
```
保险的方式是先将需要自动内存回收的元素设置为`nil`,保证自动回收器可以发现需要回收的对象,然后再进行切片的删除操作:
保险的方式是先将需要自动内存回收的元素设置为 `nil`,保证自动回收器可以发现需要回收的对象,然后再进行切片的删除操作:
```go
var a []*int{ ... }
a[len(a)-1] = nil // GC回收最后一个元素内存
a[len(a)-1] = nil // GC 回收最后一个元素内存
a = a[:len(a)-1] // 从切片删除最后一个元素
```
当然如果切片存在的周期很短的话可以不用刻意处理这个问题。因为如果切片本身已经可以被GC回收的话切片对应的每个元素自然也就是可以被回收的了。
当然,如果切片存在的周期很短的话,可以不用刻意处理这个问题。因为如果切片本身已经可以被 GC 回收的话,切片对应的每个元素自然也就是可以被回收的了。
**切片类型强制转换**
为了安全,当两个切片类型`[]T``[]Y`的底层原始切片类型不同时Go语言是无法直接转换类型的。不过安全都是有一定代价的有时候这种转换是有它的价值的——可以简化编码或者是提升代码的性能。比如在64位系统上需要对一个`[]float64`切片进行高速排序,我们可以将它强制转为`[]int`整数切片,然后以整数的方式进行排序(因为`float64`遵循IEEE754浮点数标准特性当浮点数有序时对应的整数也必然是有序的
为了安全,当两个切片类型 `[]T` `[]Y` 的底层原始切片类型不同时Go 语言是无法直接转换类型的。不过安全都是有一定代价的,有时候这种转换是有它的价值的——可以简化编码或者是提升代码的性能。比如在 64 位系统上,需要对一个 `[]float64` 切片进行高速排序,我们可以将它强制转为 `[]int` 整数切片,然后以整数的方式进行排序(因为 `float64` 遵循 IEEE754 浮点数标准特性,当浮点数有序时对应的整数也必然是有序的)。
下面的代码通过两种方法将`[]float64`类型的切片转换为`[]int`类型的切片:
下面的代码通过两种方法将 `[]float64` 类型的切片转换为 `[]int` 类型的切片:
```go
// +build amd64 arm64
@ -610,7 +603,7 @@ func SortFloat64FastV1(a []float64) {
// 强制类型转换
var b []int = ((*[1 << 20]int)(unsafe.Pointer(&a[0])))[:len(a):cap(a)]
// 以int方式给float64排序
// 以 int 方式给 float64 排序
sort.Ints(b)
}
@ -621,14 +614,13 @@ func SortFloat64FastV2(a []float64) {
cHdr := (*reflect.SliceHeader)(unsafe.Pointer(&c))
*cHdr = *aHdr
// 以int方式给float64排序
// 以 int 方式给 float64 排序
sort.Ints(c)
}
```
第一种强制转换是先将切片数据的开始地址转换为一个较大的数组的指针,然后对数组指针对应的数组重新做切片操作。中间需要`unsafe.Pointer`来连接两个不同类型的指针传递。需要注意的是Go语言实现中非0大小数组的长度不得超过2GB因此需要针对数组元素的类型大小计算数组的最大长度范围`[]uint8`最大2GB`[]uint16`最大1GB以此类推但是`[]struct{}`数组的长度可以超过2GB
第一种强制转换是先将切片数据的开始地址转换为一个较大的数组的指针,然后对数组指针对应的数组重新做切片操作。中间需要 `unsafe.Pointer` 来连接两个不同类型的指针传递。需要注意的是Go语言实现中非0大小数组的长度不得超过 2GB因此需要针对数组元素的类型大小计算数组的最大长度范围`[]uint8` 最大 2GB`[]uint16` 最大 1GB以此类推但是 `[]struct{}` 数组的长度可以超过 2GB
第二种转换操作是分别取到两个不同类型的切片头信息指针,任何类型的切片头部信息底层都是对应`reflect.SliceHeader`结构,然后通过更新结构体方式来更新切片信息,从而实现`a`对应的`[]float64`切片到`c`对应的`[]int`类型切片的转换。
通过基准测试,我们可以发现用`sort.Ints`对转换后的`[]int`排序的性能要比用`sort.Float64s`排序的性能好一点。不过需要注意的是,这个方法可行的前提是要保证`[]float64`中没有NaN和Inf等非规范的浮点数因为浮点数中NaN不可排序正0和负0相等但是整数中没有这类情形
第二种转换操作是分别取到两个不同类型的切片头信息指针,任何类型的切片头部信息底层都是对应 `reflect.SliceHeader` 结构,然后通过更新结构体方式来更新切片信息,从而实现 `a` 对应的 `[]float64` 切片到 `c` 对应的 `[]int` 类型切片的转换。
通过基准测试,我们可以发现用 `sort.Ints` 对转换后的 `[]int` 排序的性能要比用 `sort.Float64s` 排序的性能好一点。不过需要注意的是,这个方法可行的前提是要保证 `[]float64` 中没有 NaN 和 Inf 等非规范的浮点数(因为浮点数中 NaN 不可排序,正 0 和负 0 相等,但是整数中没有这类情形)。

View File

@ -1,19 +1,18 @@
# 1.4 函数、方法和接口
函数对应操作序列是程序的基本组成元素。Go语言中的函数有具名和匿名之分具名函数一般对应于包级的函数是匿名函数的一种特例当匿名函数引用了外部作用域中的变量时就成了闭包函数闭包函数是函数式编程语言的核心。方法是绑定到一个具体类型的特殊函数Go语言中的方法是依托于类型的必须在编译时静态绑定。接口定义了方法的集合这些方法依托于运行时的接口对象因此接口对应的方法是在运行时动态绑定的。Go语言通过隐式接口机制实现了鸭子面向对象模型。
函数对应操作序列是程序的基本组成元素。Go 语言中的函数有具名和匿名之分具名函数一般对应于包级的函数是匿名函数的一种特例当匿名函数引用了外部作用域中的变量时就成了闭包函数闭包函数是函数式编程语言的核心。方法是绑定到一个具体类型的特殊函数Go 语言中的方法是依托于类型的必须在编译时静态绑定。接口定义了方法的集合这些方法依托于运行时的接口对象因此接口对应的方法是在运行时动态绑定的。Go 语言通过隐式接口机制实现了鸭子面向对象模型。
Go语言程序的初始化和执行总是从`main.main`函数开始的。但是如果`main`包导入了其它的包,则会按照顺序将它们包含进`main`包里(这里的导入顺序依赖具体实现,一般可能是以文件名或包路径名的字符串顺序导入)。如果某个包被多次导入的话,在执行的时候只会导入一次。当一个包被导入时,如果它还导入了其它的包,则先将其它的包包含进来,然后创建和初始化这个包的常量和变量,再调用包里的`init`函数,如果一个包有多个`init`函数的话,调用顺序未定义(实现可能是以文件名的顺序调用),同一个文件内的多个`init`则是以出现的顺序依次调用(`init`不是普通函数,可以定义有多个,所以也不能被其它函数调用)。最后,当`main`包的所有包级常量、变量被创建和初始化完成,并且`init`函数被执行后,才会进入`main.main`函数程序开始正常执行。下图是Go程序函数启动顺序的示意图
Go 语言程序的初始化和执行总是从 `main.main` 函数开始的。但是如果 `main` 包导入了其它的包,则会按照顺序将它们包含进 `main` 包里(这里的导入顺序依赖具体实现,一般可能是以文件名或包路径名的字符串顺序导入)。如果某个包被多次导入的话,在执行的时候只会导入一次。当一个包被导入时,如果它还导入了其它的包,则先将其它的包包含进来,然后创建和初始化这个包的常量和变量,再调用包里的 `init` 函数,如果一个包有多个 `init` 函数的话,调用顺序未定义(实现可能是以文件名的顺序调用),同一个文件内的多个 `init` 则是以出现的顺序依次调用(`init` 不是普通函数,可以定义有多个,所以也不能被其它函数调用)。最后,当 `main` 包的所有包级常量、变量被创建和初始化完成,并且 `init` 函数被执行后,才会进入 `main.main` 函数,程序开始正常执行。下图是 Go 程序函数启动顺序的示意图:
![](../images/ch1-11-init.ditaa.png)
*图 1-11 包初始化流程*
要注意的是,在`main.main`函数执行之前所有代码都运行在同一个goroutine也就是程序的主系统线程中。因此如果某个`init`函数内部用go关键字启动了新的goroutine的话新的goroutine只有在进入`main.main`函数之后才可能被执行到。
要注意的是,在 `main.main` 函数执行之前所有代码都运行在同一个goroutine也就是程序的主系统线程中。因此如果某个 `init` 函数内部用 go 关键字启动了新的 goroutine 的话,新的 goroutine 只有在进入 `main.main` 函数之后才可能被执行到。
## 1.4.1 函数
在Go语言中函数是第一类对象我们可以将函数保持到变量中。函数主要有具名和匿名之分包级函数一般都是具名函数具名函数是匿名函数的一种特例。当然Go语言中每个类型还可以有自己的方法方法其实也是函数的一种。
Go 语言中函数是第一类对象我们可以将函数保持到变量中。函数主要有具名和匿名之分包级函数一般都是具名函数具名函数是匿名函数的一种特例。当然Go 语言中每个类型还可以有自己的方法,方法其实也是函数的一种。
```go
// 具名函数
@ -27,7 +26,7 @@ var Add = func(a, b int) int {
}
```
Go语言中的函数可以有多个参数和多个返回值参数和返回值都是以传值的方式和被调用者交换数据。在语法上函数还支持可变数量的参数可变数量的参数必须是最后出现的参数可变数量的参数其实是一个切片类型的参数。
Go 语言中的函数可以有多个参数和多个返回值,参数和返回值都是以传值的方式和被调用者交换数据。在语法上,函数还支持可变数量的参数,可变数量的参数必须是最后出现的参数,可变数量的参数其实是一个切片类型的参数。
```go
// 多个参数和多个返回值
@ -60,7 +59,7 @@ func Print(a ...interface{}) {
}
```
第一个`Print`调用时传入的参数是`a...`,等价于直接调用`Print(123, "abc")`。第二个`Print`调用传入的是未解包的`a`,等价于直接调用`Print([]interface{}{123, "abc"})`
第一个 `Print` 调用时传入的参数是 `a...`,等价于直接调用 `Print(123, "abc")`。第二个 `Print` 调用传入的是未解包的 `a`,等价于直接调用 `Print([]interface{}{123, "abc"})`
不仅函数的参数可以有名字,也可以给函数的返回值命名:
@ -71,7 +70,7 @@ func Find(m map[int]int, key int) (value int, ok bool) {
}
```
如果返回值命名了,可以通过名字来修改返回值,也可以通过`defer`语句在`return`语句之后修改返回值:
如果返回值命名了,可以通过名字来修改返回值,也可以通过 `defer` 语句在 `return` 语句之后修改返回值:
```go
func Inc() (v int) {
@ -80,7 +79,7 @@ func Inc() (v int) {
}
```
其中`defer`语句延迟执行了一个匿名函数,因为这个匿名函数捕获了外部函数的局部变量`v`,这种函数我们一般叫闭包。闭包对捕获的外部变量并不是传值方式访问,而是以引用的方式访问。
其中 `defer` 语句延迟执行了一个匿名函数,因为这个匿名函数捕获了外部函数的局部变量 `v`,这种函数我们一般叫闭包。闭包对捕获的外部变量并不是传值方式访问,而是以引用的方式访问。
闭包的这种引用方式访问外部变量的行为可能会导致一些隐含的问题:
@ -96,30 +95,30 @@ func main() {
// 3
```
因为是闭包,在`for`迭代语句中,每个`defer`语句延迟执行的函数引用的都是同一个`i`迭代变量在循环结束后这个变量的值为3因此最终输出的都是3。
因为是闭包,在 `for` 迭代语句中,每个 `defer` 语句延迟执行的函数引用的都是同一个 `i` 迭代变量,在循环结束后这个变量的值为 3因此最终输出的都是3。
修复的思路是在每轮迭代中为每个`defer`函数生成独有的变量。可以用下面两种方式:
修复的思路是在每轮迭代中为每个 `defer` 函数生成独有的变量。可以用下面两种方式:
```go
func main() {
for i := 0; i < 3; i++ {
i := i // 定义一个循环体内局部变量i
i := i // 定义一个循环体内局部变量 i
defer func(){ println(i) } ()
}
}
func main() {
for i := 0; i < 3; i++ {
// 通过函数传入i
// 通过函数传入 i
// defer 语句会马上对调用参数求值
defer func(i int){ println(i) } (i)
}
}
```
第一种方法是在循环体内部再定义一个局部变量,这样每次迭代`defer`语句的闭包函数捕获的都是不同的变量,这些变量的值对应迭代时的值。第二种方式是将迭代变量通过闭包函数的参数传入,`defer`语句会马上对调用参数求值。两种方式都是可以工作的。不过一般来说,在`for`循环内部执行`defer`语句并不是一个好的习惯,此处仅为示例,不建议使用。
第一种方法是在循环体内部再定义一个局部变量,这样每次迭代 `defer` 语句的闭包函数捕获的都是不同的变量,这些变量的值对应迭代时的值。第二种方式是将迭代变量通过闭包函数的参数传入,`defer` 语句会马上对调用参数求值。两种方式都是可以工作的。不过一般来说,在 `for` 循环内部执行 `defer` 语句并不是一个好的习惯,此处仅为示例,不建议使用。
Go语言中如果以切片为参数调用函数时有时候会给人一种参数采用了传引用的方式的假象因为在被调用函数内部可以修改传入的切片的元素。其实任何可以通过函数参数修改调用参数的情形都是因为函数参数中显式或隐式传入了指针参数。函数参数传值的规范更准确说是只针对数据结构中固定的部分传值例如字符串或切片对应结构体中的指针和字符串长度结构体传值但是并不包含指针间接指向的内容。将切片类型的参数替换为类似`reflect.SliceHeader`结构体就很好理解切片传值的含义了:
Go 语言中,如果以切片为参数调用函数时,有时候会给人一种参数采用了传引用的方式的假象:因为在被调用函数内部可以修改传入的切片的元素。其实,任何可以通过函数参数修改调用参数的情形,都是因为函数参数中显式或隐式传入了指针参数。函数参数传值的规范更准确说是只针对数据结构中固定的部分传值,例如字符串或切片对应结构体中的指针和字符串长度结构体传值,但是并不包含指针间接指向的内容。将切片类型的参数替换为类似 `reflect.SliceHeader` 结构体就很好理解切片传值的含义了:
```go
func twice(x []int) {
@ -141,11 +140,11 @@ func twice(x IntSliceHeader) {
}
```
因为切片中的底层数组部分是通过隐式指针传递(指针本身依然是传值的,但是指针指向的却是同一份的数据)所以被调用函数是可以通过指针修改掉调用参数切片中的数据。除了数据之外切片结构还包含了切片长度和切片容量信息这2个信息也是传值的。如果被调用函数中修改了`Len``Cap`信息的话,就无法反映到调用参数的切片中,这时候我们一般会通过返回修改后的切片来更新之前的切片。这也是为何内置的`append`必须要返回一个切片的原因。
因为切片中的底层数组部分是通过隐式指针传递(指针本身依然是传值的,但是指针指向的却是同一份的数据)所以被调用函数是可以通过指针修改掉调用参数切片中的数据。除了数据之外切片结构还包含了切片长度和切片容量信息这2个信息也是传值的。如果被调用函数中修改了 `Len` `Cap` 信息的话,就无法反映到调用参数的切片中,这时候我们一般会通过返回修改后的切片来更新之前的切片。这也是为何内置的 `append` 必须要返回一个切片的原因。
Go语言中函数还可以直接或间接地调用自己也就是支持递归调用。Go语言函数的递归调用深度逻辑上没有限制函数调用的栈是不会出现溢出错误的因为Go语言运行时会根据需要动态地调整函数栈的大小。每个goroutine刚启动时只会分配很小的栈4或8KB具体依赖实现根据需要动态调整栈的大小栈最大可以达到GB级依赖具体实现在目前的实现中32位体系结构为250MB,64位体系结构为1GB。在Go1.4以前Go的动态栈采用的是分段式的动态栈通俗地说就是采用一个链表来实现动态栈每个链表的节点内存位置不会发生变化。但是链表实现的动态栈对某些导致跨越链表不同节点的热点调用的性能影响较大因为相邻的链表节点它们在内存位置一般不是相邻的这会增加CPU高速缓存命中失败的几率。为了解决热点调用的CPU缓存命中率问题Go1.4之后改用连续的动态栈实现也就是采用一个类似动态数组的结构来表示栈。不过连续动态栈也带来了新的问题当连续栈动态增长时需要将之前的数据移动到新的内存空间这会导致之前栈中全部变量的地址发生变化。虽然Go语言运行时会自动更新引用了地址变化的栈变量的指针但最重要的一点是要明白Go语言中指针不再是固定不变的了因此不能随意将指针保持到数值变量中Go语言的地址也不能随意保存到不在GC控制的环境中因此使用CGO时不能在C语言中长期持有Go语言对象的地址
Go语言中函数还可以直接或间接地调用自己也就是支持递归调用。Go 语言函数的递归调用深度逻辑上没有限制,函数调用的栈是不会出现溢出错误的,因为 Go 语言运行时会根据需要动态地调整函数栈的大小。每个 goroutine 刚启动时只会分配很小的栈4 8KB具体依赖实现根据需要动态调整栈的大小栈最大可以达到 GB 依赖具体实现在目前的实现中32 位体系结构为 250MB64 位体系结构为 1GB。在 Go1.4 以前Go 的动态栈采用的是分段式的动态栈,通俗地说就是采用一个链表来实现动态栈,每个链表的节点内存位置不会发生变化。但是链表实现的动态栈对某些导致跨越链表不同节点的热点调用的性能影响较大,因为相邻的链表节点它们在内存位置一般不是相邻的,这会增加 CPU 高速缓存命中失败的几率。为了解决热点调用的 CPU 缓存命中率问题Go1.4 之后改用连续的动态栈实现,也就是采用一个类似动态数组的结构来表示栈。不过连续动态栈也带来了新的问题:当连续栈动态增长时,需要将之前的数据移动到新的内存空间,这会导致之前栈中全部变量的地址发生变化。虽然 Go 语言运行时会自动更新引用了地址变化的栈变量的指针,但最重要的一点是要明白 Go 语言中指针不再是固定不变的了因此不能随意将指针保持到数值变量中Go 语言的地址也不能随意保存到不在 GC 控制的环境中,因此使用 CGO 时不能在 C 语言中长期持有 Go 语言对象的地址)。
因为Go语言函数的栈会自动调整大小所以普通Go程序员已经很少需要关心栈的运行机制的。在Go语言规范中甚至故意没有讲到栈和堆的概念。我们无法知道函数参数或局部变量到底是保存在栈中还是堆中我们只需要知道它们能够正常工作就可以了。看看下面这个例子
因为Go 语言函数的栈会自动调整大小,所以普通 Go 程序员已经很少需要关心栈的运行机制的。在 Go 语言规范中甚至故意没有讲到栈和堆的概念。我们无法知道函数参数或局部变量到底是保存在栈中还是堆中,我们只需要知道它们能够正常工作就可以了。看看下面这个例子:
```go
func f(x int) *int {
@ -158,13 +157,13 @@ func g() int {
}
```
第一个函数直接返回了函数参数变量的地址——这似乎是不可以的因为如果参数变量在栈上的话函数返回之后栈变量就失效了返回的地址自然也应该失效了。但是Go语言的编译器和运行时比我们聪明的多它会保证指针指向的变量在合适的地方。第二个函数内部虽然调用`new`函数创建了`*int`类型的指针对象但是依然不知道它具体保存在哪里。对于有C/C++编程经验的程序员需要强调的是不用关心Go语言中函数栈和堆的问题编译器和运行时会帮我们搞定同样不要假设变量在内存中的位置是固定不变的指针随时可能会变化特别是在你不期望它变化的时候。
第一个函数直接返回了函数参数变量的地址——这似乎是不可以的,因为如果参数变量在栈上的话,函数返回之后栈变量就失效了,返回的地址自然也应该失效了。但是 Go 语言的编译器和运行时比我们聪明的多,它会保证指针指向的变量在合适的地方。第二个函数,内部虽然调用 `new` 函数创建了 `*int` 类型的指针对象,但是依然不知道它具体保存在哪里。对于有 C/C++ 编程经验的程序员需要强调的是:不用关心 Go 语言中函数栈和堆的问题,编译器和运行时会帮我们搞定;同样不要假设变量在内存中的位置是固定不变的,指针随时可能会变化,特别是在你不期望它变化的时候。
## 1.4.2 方法
方法一般是面向对象编程(OOP)的一个特性在C++语言中方法对应一个类对象的成员函数是关联到具体对象上的虚表中的。但是Go语言的方法却是关联到类型的这样可以在编译阶段完成方法的静态绑定。一个面向对象的程序会用方法来表达其属性对应的操作这样使用这个对象的用户就不需要直接去操作对象而是借助方法来做这些事情。面向对象编程(OOP)进入主流开发领域一般认为是从C++开始的C++就是在兼容C语言的基础之上支持了class等面向对象的特性。然后Java编程则号称是纯粹的面向对象语言因为Java中函数是不能独立存在的每个函数都必然是属于某个类的。
方法一般是面向对象编程(OOP)的一个特性,在 C++ 语言中方法对应一个类对象的成员函数,是关联到具体对象上的虚表中的。但是 Go 语言的方法却是关联到类型的,这样可以在编译阶段完成方法的静态绑定。一个面向对象的程序会用方法来表达其属性对应的操作,这样使用这个对象的用户就不需要直接去操作对象,而是借助方法来做这些事情。面向对象编程进入主流开发领域一般认为是从 C++ 开始的C++ 就是在兼容 C 语言的基础之上支持了 class 等面向对象的特性。然后 Java 编程则号称是纯粹的面向对象语言,因为 Java 中函数是不能独立存在的,每个函数都必然是属于某个类的。
面向对象编程更多的只是一种思想很多号称支持面向对象编程的语言只是将经常用到的特性内置到语言中了而已。Go语言的祖先C语言虽然不是一个支持面向对象的语言但是C语言的标准库中的File相关的函数也用到了的面向对象编程的思想。下面我们实现一组C语言风格的File函数
面向对象编程更多的只是一种思想很多号称支持面向对象编程的语言只是将经常用到的特性内置到语言中了而已。Go 语言的祖先 C 语言虽然不是一个支持面向对象的语言,但是 C 语言的标准库中的 File 相关的函数也用到了的面向对象编程的思想。下面我们实现一组 C 语言风格的 File 函数:
```go
// 文件对象
@ -188,9 +187,9 @@ func ReadFile(f *File, offset int64, data []byte) int {
}
```
其中`OpenFile`类似构造函数用于打开文件对象,`CloseFile`类似析构函数用于关闭文件对象,`ReadFile`则类似普通的成员函数,这三个函数都是普通的函数。`CloseFile``ReadFile`作为普通函数,需要占用包级空间中的名字资源。不过`CloseFile``ReadFile`函数只是针对`File`类型对象的操作,这时候我们更希望这类函数和操作对象的类型紧密绑定在一起。
其中 `OpenFile` 类似构造函数用于打开文件对象,`CloseFile` 类似析构函数用于关闭文件对象,`ReadFile` 则类似普通的成员函数,这三个函数都是普通的函数。`CloseFile` `ReadFile` 作为普通函数,需要占用包级空间中的名字资源。不过 `CloseFile` `ReadFile` 函数只是针对 `File` 类型对象的操作,这时候我们更希望这类函数和操作对象的类型紧密绑定在一起。
Go语言中的做法是`CloseFile``ReadFile`函数的第一个参数移动到函数名的开头:
Go 语言中的做法是,将 `CloseFile` `ReadFile` 函数的第一个参数移动到函数名的开头:
```go
// 关闭文件
@ -204,7 +203,7 @@ func (f *File) ReadFile(offset int64, data []byte) int {
}
```
这样的话,`CloseFile``ReadFile`函数就成了`File`类型独有的方法了(而不是`File`对象方法)。它们也不再占用包级空间中的名字资源,同时`File`类型已经明确了它们操作对象,因此方法名字一般简化为`Close``Read`
这样的话,`CloseFile` `ReadFile` 函数就成了 `File` 类型独有的方法了(而不是 `File` 对象方法)。它们也不再占用包级空间中的名字资源,同时 `File` 类型已经明确了它们操作对象,因此方法名字一般简化为 `Close` `Read`
```go
// 关闭文件
@ -218,7 +217,7 @@ func (f *File) Read(offset int64, data []byte) int {
}
```
将第一个函数参数移动到函数前面从代码角度看虽然只是一个小的改动但是从编程哲学角度来看Go语言已经是进入面向对象语言的行列了。我们可以给任何自定义类型添加一个或多个方法。每种类型对应的方法必须和类型的定义在同一个包中因此是无法给`int`这类内置类型添加方法的(因为方法的定义和类型的定义不在一个包中)。对于给定的类型,每个方法的名字必须是唯一的,同时方法和函数一样也不支持重载。
将第一个函数参数移动到函数前面从代码角度看虽然只是一个小的改动但是从编程哲学角度来看Go 语言已经是进入面向对象语言的行列了。我们可以给任何自定义类型添加一个或多个方法。每种类型对应的方法必须和类型的定义在同一个包中,因此是无法给 `int` 这类内置类型添加方法的(因为方法的定义和类型的定义不在一个包中)。对于给定的类型,每个方法的名字必须是唯一的,同时方法和函数一样也不支持重载。
方法是由函数演变而来,只是将函数的第一个对象参数移动到了函数名前面了而已。因此我们依然可以按照原始的过程式思维来使用方法。通过叫方法表达式的特性可以将方法还原为普通类型的函数:
@ -237,7 +236,7 @@ ReadFile(f, 0, data)
CloseFile(f)
```
在有些场景更关心一组相似的操作:比如`Read`读取一些数组,然后调用`Close`关闭。此时的环境中,用户并不关心操作对象的类型,只要能满足通用的`Read``Close`行为就可以了。不过在方法表达式中,因为得到的`ReadFile``CloseFile`函数参数中含有`File`这个特有的类型参数,这使得`File`相关的方法无法和其它不是`File`类型但是有着相同`Read``Close`方法的对象无缝适配。这种小困难难不倒我们Go语言码农我们可以通过结合闭包特性来消除方法表达式中第一个参数类型的差异
在有些场景更关心一组相似的操作:比如 `Read` 读取一些数组,然后调用 `Close` 关闭。此时的环境中,用户并不关心操作对象的类型,只要能满足通用的 `Read` `Close` 行为就可以了。不过在方法表达式中,因为得到的 `ReadFile` `CloseFile` 函数参数中含有 `File` 这个特有的类型参数,这使得 `File` 相关的方法无法和其它不是 `File` 类型但是有着相同 `Read` `Close` 方法的对象无缝适配。这种小困难难不倒我们 Go 语言码农,我们可以通过结合闭包特性来消除方法表达式中第一个参数类型的差异:
```go
// 先打开文件对象
@ -279,7 +278,7 @@ Read(0, data)
Close()
```
Go语言不支持传统面向对象中的继承特性而是以自己特有的组合方式支持了方法的继承。Go语言中通过在结构体内置匿名的成员来实现继承
Go语言不支持传统面向对象中的继承特性而是以自己特有的组合方式支持了方法的继承。Go 语言中,通过在结构体内置匿名的成员来实现继承:
```go
import "image/color"
@ -292,7 +291,7 @@ type ColoredPoint struct {
}
```
虽然我们可以将`ColoredPoint`定义为一个有三个字段的扁平结构的结构体,但是我们这里将`Point`嵌入到`ColoredPoint`来提供`X``Y`这两个字段。
虽然我们可以将 `ColoredPoint` 定义为一个有三个字段的扁平结构的结构体,但是我们这里将 `Point` 嵌入到 `ColoredPoint` 来提供 `X` `Y` 这两个字段。
```go
var cp ColoredPoint
@ -302,7 +301,7 @@ cp.Point.Y = 2
fmt.Println(cp.Y) // "2"
```
通过嵌入匿名的成员我们不仅可以继承匿名成员的内部成员而且可以继承匿名成员类型所对应的方法。我们一般会将Point看作基类把ColoredPoint看作是它的继承类或子类。不过这种方式继承的方法并不能实现C++中虚函数的多态特性。所有继承来的方法的接收者参数依然是那个匿名成员本身,而不是当前的变量。
通过嵌入匿名的成员,我们不仅可以继承匿名成员的内部成员,而且可以继承匿名成员类型所对应的方法。我们一般会将 Point 看作基类,把 ColoredPoint 看作是它的继承类或子类。不过这种方式继承的方法并不能实现 C++ 中虚函数的多态特性。所有继承来的方法的接收者参数依然是那个匿名成员本身,而不是当前的变量。
```go
type Cache struct {
@ -318,23 +317,23 @@ func (p *Cache) Lookup(key string) string {
}
```
`Cache`结构体类型通过嵌入一个匿名的`sync.Mutex`来继承它的`Lock``Unlock`方法. 但是在调用`p.Lock()``p.Unlock()`时, `p`并不是`Lock``Unlock`方法的真正接收者, 而是会将它们展开为`p.Mutex.Lock()``p.Mutex.Unlock()`调用. 这种展开是编译期完成的, 并没有运行时代价.
`Cache`结构体类型通过嵌入一个匿名的 `sync.Mutex` 来继承它的 `Lock` `Unlock` 方法. 但是在调用 `p.Lock()` `p.Unlock()` 时, `p` 并不是 `Lock` `Unlock` 方法的真正接收者, 而是会将它们展开为 `p.Mutex.Lock()` `p.Mutex.Unlock()` 调用. 这种展开是编译期完成的, 并没有运行时代价.
在传统的面向对象语言(eg.C++或Java)的继承中,子类的方法是在运行时动态绑定到对象的,因此基类实现的某些方法看到的`this`可能不是基类类型对应的对象这个特性会导致基类方法运行的不确定性。而在Go语言通过嵌入匿名的成员来“继承”的基类方法`this`就是实现该方法的类型的对象Go语言中方法是编译时静态绑定的。如果需要虚函数的多态特性我们需要借助Go语言接口来实现。
在传统的面向对象语言eg.C++ 或 Java的继承中,子类的方法是在运行时动态绑定到对象的,因此基类实现的某些方法看到的 `this` 可能不是基类类型对应的对象,这个特性会导致基类方法运行的不确定性。而在 Go 语言通过嵌入匿名的成员来“继承”的基类方法,`this` 就是实现该方法的类型的对象Go 语言中方法是编译时静态绑定的。如果需要虚函数的多态特性,我们需要借助 Go 语言接口来实现。
## 1.4.3 接口
Go语言之父Rob Pike曾说过一句名言那些试图避免白痴行为的语言最终自己变成了白痴语言Languages that try to disallow idiocy become themselves idiotic。一般静态编程语言都有着严格的类型系统这使得编译器可以深入检查程序员有没有作出什么出格的举动。但是过于严格的类型系统却会使得编程太过繁琐让程序员把大好的青春都浪费在了和编译器的斗争中。Go语言试图让程序员能在安全和灵活的编程之间取得一个平衡。它在提供严格的类型检查的同时通过接口类型实现了对鸭子类型的支持使得安全动态的编程变得相对容易。
Go 语言之父 *Rob Pike* 曾说过一句名言那些试图避免白痴行为的语言最终自己变成了白痴语言Languages that try to disallow idiocy become themselves idiotic。一般静态编程语言都有着严格的类型系统这使得编译器可以深入检查程序员有没有作出什么出格的举动。但是过于严格的类型系统却会使得编程太过繁琐让程序员把大好的青春都浪费在了和编译器的斗争中。Go 语言试图让程序员能在安全和灵活的编程之间取得一个平衡。它在提供严格的类型检查的同时,通过接口类型实现了对鸭子类型的支持,使得安全动态的编程变得相对容易。
Go的接口类型是对其它类型行为的抽象和概括因为接口类型不会和特定的实现细节绑定在一起通过这种抽象的方式我们可以让对象更加灵活和更具有适应能力。很多面向对象的语言都有相似的接口概念但Go语言中接口类型的独特之处在于它是满足隐式实现的鸭子类型。所谓鸭子类型说的是只要走起路来像鸭子、叫起来也像鸭子那么就可以把它当作鸭子。Go语言中的面向对象就是如此如果一个对象只要看起来像是某种接口类型的实现那么它就可以作为该接口类型使用。这种设计可以让你创建一个新的接口类型满足已经存在的具体类型却不用去破坏这些类型原有的定义当我们使用的类型来自于不受我们控制的包时这种设计尤其灵活有用。Go语言的接口类型是延迟绑定可以实现类似虚函数的多态功能。
Go 的接口类型是对其它类型行为的抽象和概括;因为接口类型不会和特定的实现细节绑定在一起,通过这种抽象的方式我们可以让对象更加灵活和更具有适应能力。很多面向对象的语言都有相似的接口概念,但 Go 语言中接口类型的独特之处在于它是满足隐式实现的鸭子类型。所谓鸭子类型说的是只要走起路来像鸭子、叫起来也像鸭子那么就可以把它当作鸭子。Go 语言中的面向对象就是如此如果一个对象只要看起来像是某种接口类型的实现那么它就可以作为该接口类型使用。这种设计可以让你创建一个新的接口类型满足已经存在的具体类型却不用去破坏这些类型原有的定义当我们使用的类型来自于不受我们控制的包时这种设计尤其灵活有用。Go 语言的接口类型是延迟绑定,可以实现类似虚函数的多态功能。
接口在Go语言中无处不在在“Hello world”的例子中`fmt.Printf`函数的设计就是完全基于接口的,它的真正功能由`fmt.Fprintf`函数完成。用于表示错误的`error`类型更是内置的接口类型。在C语言中`printf`只能将几种有限的基础数据类型打印到文件对象中。但是Go语言灵活接口特性`fmt.Fprintf`却可以向任何自定义的输出流对象打印,可以打印到文件或标准输出、也可以打印到网络、甚至可以打印到一个压缩文件;同时,打印的数据也不仅仅局限于语言内置的基础类型,任意隐式满足`fmt.Stringer`接口的对象都可以打印,不满足`fmt.Stringer`接口的依然可以通过反射的技术打印。`fmt.Fprintf`函数的签名如下:
接口在 Go 语言中无处不在在“Hello world”的例子中`fmt.Printf` 函数的设计就是完全基于接口的,它的真正功能由 `fmt.Fprintf` 函数完成。用于表示错误的 `error` 类型更是内置的接口类型。在 C 语言中,`printf` 只能将几种有限的基础数据类型打印到文件对象中。但是 Go 语言灵活接口特性,`fmt.Fprintf` 却可以向任何自定义的输出流对象打印,可以打印到文件或标准输出、也可以打印到网络、甚至可以打印到一个压缩文件;同时,打印的数据也不仅仅局限于语言内置的基础类型,任意隐式满足 `fmt.Stringer` 接口的对象都可以打印,不满足 `fmt.Stringer` 接口的依然可以通过反射的技术打印。`fmt.Fprintf` 函数的签名如下:
```go
func Fprintf(w io.Writer, format string, args ...interface{}) (int, error)
```
其中`io.Writer`用于输出的接口,`error`是内置的错误接口,它们的定义如下:
其中 `io.Writer` 用于输出的接口,`error` 是内置的错误接口,它们的定义如下:
```go
type io.Writer interface {
@ -362,7 +361,7 @@ func main() {
}
```
当然,我们也可以定义自己的打印格式来实现将每个字符转为大写字符后输出的效果。对于每个要打印的对象,如果满足了`fmt.Stringer`接口,则默认使用对象的`String`方法返回的结果打印:
当然,我们也可以定义自己的打印格式来实现将每个字符转为大写字符后输出的效果。对于每个要打印的对象,如果满足了 `fmt.Stringer` 接口,则默认使用对象的 `String` 方法返回的结果打印:
```go
type UpperString string
@ -380,7 +379,7 @@ func main() {
}
```
Go语言中对于基础类型非接口类型不支持隐式的转换我们无法将一个`int`类型的值直接赋值给`int64`类型的变量,也无法将`int`类型的值赋值给底层是`int`类型的新定义命名类型的变量。Go语言对基础类型的类型一致性要求可谓是非常的严格但是Go语言对于接口类型的转换则非常的灵活。对象和接口之间的转换、接口和接口之间的转换都可能是隐式的转换。可以看下面的例子
Go 语言中,对于基础类型(非接口类型)不支持隐式的转换,我们无法将一个 `int` 类型的值直接赋值给 `int64` 类型的变量,也无法将 `int` 类型的值赋值给底层是 `int` 类型的新定义命名类型的变量。Go 语言对基础类型的类型一致性要求可谓是非常的严格,但是 Go 语言对于接口类型的转换则非常的灵活。对象和接口之间的转换、接口和接口之间的转换都可能是隐式的转换。可以看下面的例子:
```go
var (
@ -391,7 +390,7 @@ var (
)
```
有时候对象和接口之间太灵活了,导致我们需要人为地限制这种无意之间的适配。常见的做法是定义一个含特殊方法来区分接口。比如`runtime`包中的`Error`接口就定义了一个特有的`RuntimeError`方法,用于避免其它类型无意中适配了该接口:
有时候对象和接口之间太灵活了,导致我们需要人为地限制这种无意之间的适配。常见的做法是定义一个含特殊方法来区分接口。比如 `runtime` 包中的 `Error` 接口就定义了一个特有的 `RuntimeError` 方法,用于避免其它类型无意中适配了该接口:
```go
type runtime.Error interface {
@ -405,7 +404,7 @@ type runtime.Error interface {
}
```
在protobuf中`Message`接口也采用了类似的方法,也定义了一个特有的`ProtoMessage`,用于避免其它类型无意中适配了该接口:
protobuf 中,`Message` 接口也采用了类似的方法,也定义了一个特有的 `ProtoMessage`,用于避免其它类型无意中适配了该接口:
```go
type proto.Message interface {
@ -415,7 +414,7 @@ type proto.Message interface {
}
```
不过这种做法只是君子协定,如果有人刻意伪造一个`proto.Message`接口也是很容易的。再严格一点的做法是给接口定义一个私有方法。只有满足了这个私有方法的对象才可能满足这个接口,而私有方法的名字是包含包的绝对路径名的,因此只能在包内部实现这个私有方法才能满足这个接口。测试包中的`testing.TB`接口就是采用类似的技术:
不过这种做法只是君子协定,如果有人刻意伪造一个 `proto.Message` 接口也是很容易的。再严格一点的做法是给接口定义一个私有方法。只有满足了这个私有方法的对象才可能满足这个接口,而私有方法的名字是包含包的绝对路径名的,因此只能在包内部实现这个私有方法才能满足这个接口。测试包中的 `testing.TB` 接口就是采用类似的技术:
```go
type testing.TB interface {
@ -432,7 +431,7 @@ type testing.TB interface {
不过这种通过私有方法禁止外部对象实现接口的做法也是有代价的:首先是这个接口只能包内部使用,外部包正常情况下是无法直接创建满足该接口对象的;其次,这种防护措施也不是绝对的,恶意的用户依然可以绕过这种保护机制。
在前面的方法一节中我们讲到,通过在结构体中嵌入匿名类型成员,可以继承匿名类型的方法。其实这个被嵌入的匿名成员不一定是普通类型,也可以是接口类型。我们可以通过嵌入匿名的`testing.TB`接口来伪造私有的`private`方法,因为接口方法是延迟绑定,编译时`private`方法是否真的存在并不重要。
在前面的方法一节中我们讲到,通过在结构体中嵌入匿名类型成员,可以继承匿名类型的方法。其实这个被嵌入的匿名成员不一定是普通类型,也可以是接口类型。我们可以通过嵌入匿名的 `testing.TB` 接口来伪造私有的 `private` 方法,因为接口方法是延迟绑定,编译时 `private` 方法是否真的存在并不重要。
```go
package main
@ -456,7 +455,7 @@ func main() {
}
```
我们在自己的`TB`结构体类型中重新实现了`Fatal`方法,然后通过将对象隐式转换为`testing.TB`接口类型(因为内嵌了匿名的`testing.TB`对象,因此是满足`testing.TB`接口的),然后通过`testing.TB`接口来调用我们自己的`Fatal`方法。
我们在自己的 `TB` 结构体类型中重新实现了 `Fatal` 方法,然后通过将对象隐式转换为 `testing.TB` 接口类型(因为内嵌了匿名的 `testing.TB` 对象,因此是满足 `testing.TB` 接口的),然后通过 `testing.TB` 接口来调用我们自己的 `Fatal` 方法。
这种通过嵌入匿名接口或嵌入匿名指针对象来实现继承的做法其实是一种纯虚继承我们继承的只是接口指定的规范真正的实现在运行的时候才被注入。比如我们可以模拟实现一个gRPC的插件
@ -481,7 +480,7 @@ func (p *grpcPlugin) GenerateImports(file *generator.FileDescriptor) {
}
```
构造的`grpcPlugin`类型对象必须满足`generate.Plugin`接口(在"github.com/golang/protobuf/protoc-gen-go/generator"包中):
构造的 `grpcPlugin` 类型对象必须满足 `generate.Plugin` 接口(在"github.com/golang/protobuf/protoc-gen-go/generator"包中):
```go
type Plugin interface {
@ -500,6 +499,6 @@ type Plugin interface {
}
```
`generate.Plugin`接口对应的`grpcPlugin`类型的`GenerateImports`方法中使用的`p.P(...)`函数却是通过`Init`函数注入的`generator.Generator`对象实现。这里的`generator.Generator`对应一个具体类型,但是如果`generator.Generator`是接口类型的话我们甚至可以传入直接的实现。
`generate.Plugin`接口对应的 `grpcPlugin` 类型的 `GenerateImports` 方法中使用的 `p.P(...)` 函数却是通过 `Init` 函数注入的 `generator.Generator` 对象实现。这里的 `generator.Generator` 对应一个具体类型,但是如果 `generator.Generator` 是接口类型的话我们甚至可以传入直接的实现。
Go语言通过几种简单特性的组合就轻易就实现了鸭子面向对象和虚拟继承等高级特性真的是不可思议。
Go 语言通过几种简单特性的组合,就轻易就实现了鸭子面向对象和虚拟继承等高级特性,真的是不可思议。

View File

@ -1,26 +1,26 @@
# 1.5 面向并发的内存模型
在早期CPU都是以单核的形式顺序执行机器指令。Go语言的祖先C语言正是这种顺序编程语言的代表。顺序编程语言中的顺序是指所有的指令都是以串行的方式执行在相同的时刻有且仅有一个CPU在顺序执行程序的指令。
在早期CPU 都是以单核的形式顺序执行机器指令。Go 语言的祖先 C 语言正是这种顺序编程语言的代表。顺序编程语言中的顺序是指:所有的指令都是以串行的方式执行,在相同的时刻有且仅有一个 CPU 在顺序执行程序的指令。
随着处理器技术的发展,单核时代以提升处理器频率来提高运行效率的方式遇到了瓶颈,目前各种主流的CPU频率基本被锁定在了3GHZ附近。单核CPU的发展的停滞给多核CPU的发展带来了机遇。相应地编程语言也开始逐步向并行化的方向发展。Go语言正是在多核和网络化的时代背景下诞生的原生支持并发的编程语言。
随着处理器技术的发展,单核时代以提升处理器频率来提高运行效率的方式遇到了瓶颈,目前各种主流的 CPU 频率基本被锁定在了 3Ghz 附近。单核 CPU 的发展的停滞,给多核 CPU 的发展带来了机遇。相应地编程语言也开始逐步向并行化的方向发展。Go 语言正是在多核和网络化的时代背景下诞生的原生支持并发的编程语言。
常见的并行编程有多种模型主要有多线程、消息传递等。从理论上来看多线程和基于消息的并发编程是等价的。由于多线程并发模型可以自然对应到多核的处理器主流的操作系统因此也都提供了系统级的多线程支持同时从概念上讲多线程似乎也更直观因此多线程编程模型逐步被吸纳到主流的编程语言特性或语言扩展库中。而主流编程语言对基于消息的并发编程模型支持则相比较少Erlang语言是支持基于消息传递并发编程模型的代表者它的并发体之间不共享内存。Go语言是基于消息并发模型的集大成者它将基于CSP模型的并发编程内置到了语言中通过一个go关键字就可以轻易地启动一个Goroutine与Erlang不同的是Go语言的Goroutine之间是共享内存的。
常见的并行编程有多种模型主要有多线程、消息传递等。从理论上来看多线程和基于消息的并发编程是等价的。由于多线程并发模型可以自然对应到多核的处理器主流的操作系统因此也都提供了系统级的多线程支持同时从概念上讲多线程似乎也更直观因此多线程编程模型逐步被吸纳到主流的编程语言特性或语言扩展库中。而主流编程语言对基于消息的并发编程模型支持则相比较少Erlang 语言是支持基于消息传递并发编程模型的代表者它的并发体之间不共享内存。Go 语言是基于消息并发模型的集大成者,它将基于 CSP 模型的并发编程内置到了语言中,通过一个 go 关键字就可以轻易地启动一个 Goroutine Erlang 不同的是 Go 语言的 Goroutine 之间是共享内存的。
## 1.5.1 Goroutine和系统线程
Goroutine是Go语言特有的并发体是一种轻量级的线程由go关键字启动。在真实的Go语言的实现中goroutine和系统线程也不是等价的。尽管两者的区别实际上只是一个量的区别但正是这个量变引发了Go语言并发编程质的飞跃。
Goroutine是 Go 语言特有的并发体,是一种轻量级的线程,由 go 关键字启动。在真实的 Go 语言的实现中goroutine 和系统线程也不是等价的。尽管两者的区别实际上只是一个量的区别,但正是这个量变引发了 Go 语言并发编程质的飞跃。
首先每个系统级线程都会有一个固定大小的栈一般默认可能是2MB这个栈主要用来保存函数递归调用时参数和局部变量。固定了栈的大小导致了两个问题一是对于很多只需要很小的栈空间的线程来说是一个巨大的浪费二是对于少数需要巨大栈空间的线程来说又面临栈溢出的风险。针对这两个问题的解决方案是要么降低固定的栈大小提升空间的利用率要么增大栈的大小以允许更深的函数递归调用但这两者是没法同时兼得的。相反一个Goroutine会以一个很小的栈启动可能是2KB或4KB当遇到深度递归导致当前栈空间不足时Goroutine会根据需要动态地伸缩栈的大小主流实现中栈的最大值可达到1GB。因为启动的代价很小所以我们可以轻易地启动成千上万个Goroutine。
首先,每个系统级线程都会有一个固定大小的栈(一般默认可能是 2MB这个栈主要用来保存函数递归调用时参数和局部变量。固定了栈的大小导致了两个问题一是对于很多只需要很小的栈空间的线程来说是一个巨大的浪费二是对于少数需要巨大栈空间的线程来说又面临栈溢出的风险。针对这两个问题的解决方案是要么降低固定的栈大小提升空间的利用率要么增大栈的大小以允许更深的函数递归调用但这两者是没法同时兼得的。相反一个 Goroutine 会以一个很小的栈启动(可能是 2KB 4KB当遇到深度递归导致当前栈空间不足时Goroutine 会根据需要动态地伸缩栈的大小主流实现中栈的最大值可达到1GB。因为启动的代价很小所以我们可以轻易地启动成千上万个 Goroutine。
Go的运行时还包含了其自己的调度器这个调度器使用了一些技术手段可以在n个操作系统线程上多工调度m个Goroutine。Go调度器的工作和内核的调度是相似的但是这个调度器只关注单独的Go程序中的Goroutine。Goroutine采用的是半抢占式的协作调度只有在当前Goroutine发生阻塞时才会导致调度同时发生在用户态调度器会根据具体函数只保存必要的寄存器切换的代价要比系统线程低得多。运行时有一个`runtime.GOMAXPROCS`变量用于控制当前运行正常非阻塞Goroutine的系统线程数目。
Go的运行时还包含了其自己的调度器这个调度器使用了一些技术手段可以在 n 个操作系统线程上多工调度 m Goroutine。Go 调度器的工作和内核的调度是相似的,但是这个调度器只关注单独的 Go 程序中的 Goroutine。Goroutine 采用的是半抢占式的协作调度,只有在当前 Goroutine 发生阻塞时才会导致调度;同时发生在用户态,调度器会根据具体函数只保存必要的寄存器,切换的代价要比系统线程低得多。运行时有一个 `runtime.GOMAXPROCS` 变量,用于控制当前运行正常非阻塞 Goroutine 的系统线程数目。
在Go语言中启动一个Goroutine不仅和调用函数一样简单而且Goroutine之间调度代价也很低这些因素极大地促进了并发编程的流行和发展。
Go 语言中启动一个 Goroutine 不仅和调用函数一样简单,而且 Goroutine 之间调度代价也很低,这些因素极大地促进了并发编程的流行和发展。
## 1.5.2 原子操作
所谓的原子操作就是并发编程中“最小的且不可并行化”的操作。通常,如果多个并发体对同一个共享资源进行的操作是原子的话,那么同一时刻最多只能有一个并发体对该资源进行操作。从线程角度看,在当前线程修改共享资源期间,其它的线程是不能访问该资源的。原子操作对于多线程并发编程模型来说,不会发生有别于单线程的意外情况,共享资源的完整性可以得到保证。
一般情况下原子操作都是通过“互斥”访问来保证的通常由特殊的CPU指令提供保护。当然如果仅仅是想模拟下粗粒度的原子操作我们可以借助于`sync.Mutex`来实现:
一般情况下,原子操作都是通过“互斥”访问来保证的,通常由特殊的 CPU 指令提供保护。当然,如果仅仅是想模拟下粗粒度的原子操作,我们可以借助于 `sync.Mutex` 来实现:
```go
import (
@ -53,9 +53,9 @@ func main() {
}
```
`worker`的循环中,为了保证`total.value += i`的原子性,我们通过`sync.Mutex`加锁和解锁来保证该语句在同一时刻只被一个线程访问。对于多线程模型的程序而言,进出临界区前后进行加锁和解锁都是必须的。如果没有锁的保护,`total`的最终值将由于多线程之间的竞争而可能会不正确。
`worker` 的循环中,为了保证 `total.value += i` 的原子性,我们通过 `sync.Mutex` 加锁和解锁来保证该语句在同一时刻只被一个线程访问。对于多线程模型的程序而言,进出临界区前后进行加锁和解锁都是必须的。如果没有锁的保护,`total` 的最终值将由于多线程之间的竞争而可能会不正确。
用互斥锁来保护一个数值型的共享资源,麻烦且效率低下。标准库的`sync/atomic`包对原子操作提供了丰富的支持。我们可以重新实现上面的例子:
用互斥锁来保护一个数值型的共享资源,麻烦且效率低下。标准库的 `sync/atomic` 包对原子操作提供了丰富的支持。我们可以重新实现上面的例子:
```go
import (
@ -84,7 +84,7 @@ func main() {
}
```
`atomic.AddUint64`函数调用保证了`total`的读取、更新和保存是一个原子操作,因此在多线程中访问也是安全的。
`atomic.AddUint64` 函数调用保证了 `total` 的读取、更新和保存是一个原子操作,因此在多线程中访问也是安全的。
原子操作配合互斥锁可以实现非常高效的单件模式。互斥锁的代价比普通整数的原子读写高很多,在性能敏感的地方可以增加一个数字型的标志位,通过原子检测标志位状态降低互斥锁的使用次数来提高性能。
@ -113,7 +113,7 @@ func Instance() *singleton {
}
```
我们可以将通用的代码提取出来,就成了标准库中`sync.Once`的实现:
我们可以将通用的代码提取出来,就成了标准库中 `sync.Once` 的实现:
```go
type Once struct {
@ -136,7 +136,7 @@ func (o *Once) Do(f func()) {
}
```
基于`sync.Once`重新实现单件模式:
基于 `sync.Once` 重新实现单件模式:
```go
var (
@ -152,7 +152,7 @@ func Instance() *singleton {
}
```
`sync/atomic`包对基本的数值类型及复杂对象的读写都提供了原子操作的支持。`atomic.Value`原子对象提供了`Load``Store`两个原子方法,分别用于加载和保存数据,返回值和参数都是`interface{}`类型,因此可以用于任意的自定义复杂类型。
`sync/atomic` 包对基本的数值类型及复杂对象的读写都提供了原子操作的支持。`atomic.Value` 原子对象提供了 `Load` `Store` 两个原子方法,分别用于加载和保存数据,返回值和参数都是 `interface{}` 类型,因此可以用于任意的自定义复杂类型。
```go
var config atomic.Value // 保存当前配置信息
@ -201,14 +201,13 @@ func main() {
}
```
我们创建了`setup`线程,用于对字符串`a`的初始化工作,初始化完成之后设置`done`标志为`true``main`函数所在的主线程中,通过`for !done {}`检测`done`变为`true`时,认为字符串初始化工作完成,然后进行字符串的打印工作。
我们创建了 `setup` 线程,用于对字符串 `a` 的初始化工作,初始化完成之后设置 `done` 标志为 `true``main` 函数所在的主线程中,通过 `for !done {}` 检测 `done` 变为 `true` 时,认为字符串初始化工作完成,然后进行字符串的打印工作。
但是Go语言并不保证在`main`函数中观测到的对`done`的写入操作发生在对字符串`a`的写入的操作之后,因此程序很可能打印一个空字符串。更糟糕的是,因为两个线程之间没有同步事件,`setup`线程对`done`的写入操作甚至无法被`main`线程看到,`main`函数有可能陷入死循环中。
但是 Go 语言并不保证在 `main` 函数中观测到的对 `done` 的写入操作发生在对字符串 `a` 的写入的操作之后,因此程序很可能打印一个空字符串。更糟糕的是,因为两个线程之间没有同步事件,`setup`线程对 `done` 的写入操作甚至无法被 `main` 线程看到,`main`函数有可能陷入死循环中。
在Go语言中同一个Goroutine线程内部顺序一致性内存模型是得到保证的。但是不同的Goroutine之间并不满足顺序一致性内存模型需要通过明确定义的同步事件来作为同步的参考。如果两个事件不可排序那么就说这两个事件是并发的。为了最大化并行Go语言的编译器和处理器在不影响上述规定的前提下可能会对执行语句重新排序CPU也会对一些指令进行乱序执行
因此如果在一个Goroutine中顺序执行`a = 1; b = 2;`两个语句虽然在当前的Goroutine中可以认为`a = 1;`语句先于`b = 2;`语句执行但是在另一个Goroutine中`b = 2;`语句可能会先于`a = 1;`语句执行甚至在另一个Goroutine中无法看到它们的变化可能始终在寄存器中。也就是说在另一个Goroutine看来, `a = 1; b = 2;`两个语句的执行顺序是不确定的。如果一个并发程序无法确定事件的顺序关系,那么程序的运行结果往往会有不确定的结果。比如下面这个程序:
在 Go 语言中,同一个 Goroutine 线程内部,顺序一致性内存模型是得到保证的。但是不同的 Goroutine 之间并不满足顺序一致性内存模型需要通过明确定义的同步事件来作为同步的参考。如果两个事件不可排序那么就说这两个事件是并发的。为了最大化并行Go 语言的编译器和处理器在不影响上述规定的前提下可能会对执行语句重新排序CPU 也会对一些指令进行乱序执行)。
因此,如果在一个 Goroutine 中顺序执行 `a = 1; b = 2;` 两个语句,虽然在当前的 Goroutine 中可以认为 `a = 1;` 语句先于 `b = 2;` 语句执行,但是在另一个 Goroutine 中 `b = 2;` 语句可能会先于 `a = 1;` 语句执行,甚至在另一个 Goroutine 中无法看到它们的变化(可能始终在寄存器中)。也就是说在另一个 Goroutine 看来, `a = 1; b = 2;`两个语句的执行顺序是不确定的。如果一个并发程序无法确定事件的顺序关系,那么程序的运行结果往往会有不确定的结果。比如下面这个程序:
```go
func main() {
@ -216,7 +215,7 @@ func main() {
}
```
根据Go语言规范`main`函数退出时程序结束不会等待任何后台线程。因为Goroutine的执行和`main`函数的返回事件是并发的,谁都有可能先发生,所以什么时候打印,能否打印都是未知的。
根据 Go 语言规范,`main`函数退出时程序结束,不会等待任何后台线程。因为 Goroutine 的执行和 `main` 函数的返回事件是并发的,谁都有可能先发生,所以什么时候打印,能否打印都是未知的。
用前面的原子操作并不能解决问题,因为我们无法确定两个原子操作之间的顺序。解决问题的办法就是通过同步原语来给两个事件明确排序:
@ -233,9 +232,9 @@ func main() {
}
```
`<-done`执行时,必然要求`done <- 1`也已经执行。根据同一个Goroutine依然满足顺序一致性规则我们可以判断当`done <- 1`执行时,`println("你好, 世界")`语句必然已经执行完成了。因此,现在的程序确保可以正常打印结果。
`<-done` 执行时,必然要求 `done <- 1` 也已经执行。根据同一个 Goroutine 依然满足顺序一致性规则,我们可以判断当 `done <- 1` 执行时,`println("你好, 世界")` 语句必然已经执行完成了。因此,现在的程序确保可以正常打印结果。
当然,通过`sync.Mutex`互斥量也是可以实现同步的:
当然,通过 `sync.Mutex` 互斥量也是可以实现同步的:
```go
func main() {
@ -251,26 +250,25 @@ func main() {
}
```
可以确定后台线程的`mu.Unlock()`必然在`println("你好, 世界")`完成后发生(同一个线程满足顺序一致性),`main`函数的第二个`mu.Lock()`必然在后台线程的`mu.Unlock()`之后发生(`sync.Mutex`保证),此时后台线程的打印工作已经顺利完成了。
可以确定后台线程的 `mu.Unlock()` 必然在 `println("你好, 世界")` 完成后发生(同一个线程满足顺序一致性),`main` 函数的第二个 `mu.Lock()` 必然在后台线程的 `mu.Unlock()` 之后发生(`sync.Mutex` 保证),此时后台线程的打印工作已经顺利完成了。
## 1.5.4 初始化顺序
前面函数章节中我们已经简单介绍过程序的初始化顺序这是属于Go语言面向并发的内存模型的基础规范。
前面函数章节中我们已经简单介绍过程序的初始化顺序,这是属于 Go 语言面向并发的内存模型的基础规范。
Go程序的初始化和执行总是从`main.main`函数开始的。但是如果`main`包里导入了其它的包,则会按照顺序将它们包含进`main`包里(这里的导入顺序依赖具体实现,一般可能是以文件名或包路径名的字符串顺序导入)。如果某个包被多次导入的话,在执行的时候只会导入一次。当一个包被导入时,如果它还导入了其它的包,则先将其它的包包含进来,然后创建和初始化这个包的常量和变量。然后就是调用包里的`init`函数,如果一个包有多个`init`函数的话,实现可能是以文件名的顺序调用,同一个文件内的多个`init`则是以出现的顺序依次调用(`init`不是普通函数,可以定义有多个,所以不能被其它函数调用)。最终,在`main`包的所有包常量、包变量被创建和初始化,并且`init`函数被执行后,才会进入`main.main`函数程序开始正常执行。下图是Go程序函数启动顺序的示意图
Go程序的初始化和执行总是从 `main.main` 函数开始的。但是如果 `main` 包里导入了其它的包,则会按照顺序将它们包含进 `main` 包里(这里的导入顺序依赖具体实现,一般可能是以文件名或包路径名的字符串顺序导入)。如果某个包被多次导入的话,在执行的时候只会导入一次。当一个包被导入时,如果它还导入了其它的包,则先将其它的包包含进来,然后创建和初始化这个包的常量和变量。然后就是调用包里的 `init` 函数,如果一个包有多个 `init` 函数的话,实现可能是以文件名的顺序调用,同一个文件内的多个 `init` 则是以出现的顺序依次调用(`init`不是普通函数,可以定义有多个,所以不能被其它函数调用)。最终,在 `main` 包的所有包常量、包变量被创建和初始化,并且 `init` 函数被执行后,才会进入 `main.main` 函数,程序开始正常执行。下图是 Go 程序函数启动顺序的示意图:
![](../images/ch1-12-init.ditaa.png)
*图 1-12 包初始化流程*
要注意的是,在`main.main`函数执行之前所有代码都运行在同一个Goroutine中也是运行在程序的主系统线程中。如果某个`init`函数内部用go关键字启动了新的Goroutine的话新的Goroutine和`main.main`函数是并发执行的。
要注意的是,在 `main.main` 函数执行之前所有代码都运行在同一个 Goroutine 中,也是运行在程序的主系统线程中。如果某个 `init` 函数内部用 go 关键字启动了新的 Goroutine 的话,新的 Goroutine `main.main` 函数是并发执行的。
因为所有的`init`函数和`main`函数都是在主线程完成,它们也是满足顺序一致性模型的。
因为所有的 `init` 函数和 `main` 函数都是在主线程完成,它们也是满足顺序一致性模型的。
## 1.5.5 Goroutine的创建
`go`语句会在当前Goroutine对应函数返回前创建新的Goroutine. 例如:
`go` 语句会在当前 Goroutine 对应函数返回前创建新的 Goroutine。例如:
```go
var a string
@ -285,12 +283,11 @@ func hello() {
}
```
执行`go f()`语句创建Goroutine和`hello`函数是在同一个Goroutine中执行, 根据语句的书写顺序可以确定Goroutine的创建发生在`hello`函数返回之前, 但是新创建Goroutine对应的`f()`的执行事件和`hello`函数返回的事件则是不可排序的,也就是并发的。调用`hello`可能会在将来的某一时刻打印`"hello, world"`,也很可能是在`hello`函数执行完成后才打印。
执行 `go f()` 语句创建 Goroutine `hello` 函数是在同一个 Goroutine 中执行, 根据语句的书写顺序可以确定 Goroutine 的创建发生在 `hello` 函数返回之前, 但是新创建 Goroutine 对应的 `f()` 的执行事件和 `hello` 函数返回的事件则是不可排序的,也就是并发的。调用 `hello` 可能会在将来的某一时刻打印 `"hello, world"`,也很可能是在 `hello` 函数执行完成后才打印。
## 1.5.6 基于Channel的通信
Channel通信是在Goroutine之间进行同步的主要方法。在无缓存的Channel上的每一次发送操作都有与其对应的接收操作相配对发送和接收操作通常发生在不同的Goroutine上在同一个Goroutine上执行2个操作很容易导致死锁。**无缓存的Channel上的发送操作总在对应的接收操作完成前发生.**
## 1.5.6 基于 Channel 的通信
Channel 通信是在 Goroutine 之间进行同步的主要方法。在无缓存的 Channel 上的每一次发送操作都有与其对应的接收操作相配对,发送和接收操作通常发生在不同的 Goroutine 上(在同一个 Goroutine 上执行两个操作很容易导致死锁)。**无缓存的 Channel 上的发送操作总在对应的接收操作完成前发生.**
```go
var done = make(chan bool)
@ -308,9 +305,9 @@ func main() {
}
```
可保证打印出“hello, world”。该程序首先对`msg`进行写入,然后在`done`管道上发送同步信号,随后从`done`接收对应的同步信号,最后执行`println`函数。
可保证打印出“hello, world”。该程序首先对 `msg` 进行写入,然后在 `done` 管道上发送同步信号,随后从 `done` 接收对应的同步信号,最后执行 `println` 函数。
若在关闭Channel后继续从中接收数据接收者就会收到该Channel返回的零值。因此在这个例子中`close(c)`关闭管道代替`done <- false`依然能保证该程序产生相同的行为。
若在关闭 Channel 后继续从中接收数据,接收者就会收到该 Channel 返回的零值。因此在这个例子中,用 `close(c)` 关闭管道代替 `done <- false` 依然能保证该程序产生相同的行为。
```go
var done = make(chan bool)
@ -328,9 +325,9 @@ func main() {
}
```
**对于从无缓冲Channel进行的接收发生在对该Channel进行的发送完成之前。**
**对于从无缓冲 Channel 进行的接收,发生在对该 Channel 进行的发送完成之前。**
基于上面这个规则可知交换两个Goroutine中的接收和发送操作也是可以的但是很危险
基于上面这个规则可知,交换两个 Goroutine 中的接收和发送操作也是可以的(但是很危险):
```go
var done = make(chan bool)
@ -347,11 +344,11 @@ func main() {
}
```
也可保证打印出“hello, world”。因为`main`线程中`done <- true`发送完成前,后台线程`<-done`接收已经开始,这保证`msg = "hello, world"`被执行了,所以之后`println(msg)`的msg已经被赋值过了。简而言之后台线程首先对`msg`进行写入,然后从`done`中接收信号,随后`main`线程向`done`发送对应的信号,最后执行`println`函数完成。但是若该Channel为带缓冲的例如`done = make(chan bool, 1)``main`线程的`done <- true`接收操作将不会被后台线程的`<-done`接收操作阻塞该程序将无法保证打印出“hello, world”。
也可保证打印出“hello, world”。因为 `main` 线程中 `done <- true` 发送完成前,后台线程 `<-done` 接收已经开始,这保证 `msg = "hello, world"` 被执行了,所以之后 `println(msg)` 的msg已经被赋值过了。简而言之后台线程首先对 `msg` 进行写入,然后从 `done` 中接收信号,随后 `main` 线程向 `done` 发送对应的信号,最后执行 `println` 函数完成。但是,若该 Channel 为带缓冲的(例如,`done = make(chan bool, 1)``main`线程的 `done <- true` 接收操作将不会被后台线程的 `<-done` 接收操作阻塞该程序将无法保证打印出“hello, world”。
对于带缓冲的Channel**对于Channel的第`K`个接收完成操作发生在第`K+C`个发送操作完成之前,其中`C`是Channel的缓存大小。** 如果将`C`设置为0自然就对应无缓存的Channel也即使第K个接收完成在第K个发送完成之前。因为无缓存的Channel只能同步发1个也就简化为前面无缓存Channel的规则**对于从无缓冲Channel进行的接收发生在对该Channel进行的发送完成之前。**
对于带缓冲的Channel**对于 Channel 的第 `K` 个接收完成操作发生在第 `K+C` 个发送操作完成之前,其中 `C` Channel 的缓存大小。** 如果将 `C` 设置为 0 自然就对应无缓存的 Channel也即使第 K 个接收完成在第 K 个发送完成之前。因为无缓存的 Channel 只能同步发 1 个,也就简化为前面无缓存 Channel 的规则:**对于从无缓冲 Channel 进行的接收,发生在对该 Channel 进行的发送完成之前。**
我们可以根据控制Channel的缓存大小来控制并发执行的Goroutine的最大数目, 例如:
我们可以根据控制 Channel 的缓存大小来控制并发执行的 Goroutine 的最大数目, 例如:
```go
var limit = make(chan int, 3)
@ -375,9 +372,9 @@ func main() {
}
```
在循环创建`Goroutine`过程中,使用了匿名函数并在函数中引用了循环变量`w`,由于`w`是引用传递的而非值传递,因此无法保证`Goroutine`在运行时调用的`w`与循环创建时的`w`是同一个值,为了解决这个问题,我们可以利用函数传参的值复制来为每个`Goroutine`单独复制一份`w`
在循环创建 `Goroutine` 过程中,使用了匿名函数并在函数中引用了循环变量 `w`,由于 `w` 是引用传递的而非值传递,因此无法保证 `Goroutine` 在运行时调用的 `w` 与循环创建时的 `w` 是同一个值,为了解决这个问题,我们可以利用函数传参的值复制来为每个 `Goroutine` 单独复制一份 `w`
循环创建结束后,在`main`函数中最后一句`select{}`是一个空的管道选择语句,该语句会导致`main`线程阻塞,从而避免程序过早退出。还有`for{}``<-make(chan int)`等诸多方法可以达到类似的效果。因为`main`线程被阻塞了,如果需要程序正常退出的话可以通过调用`os.Exit(0)`实现。
循环创建结束后,在 `main` 函数中最后一句 `select{}` 是一个空的管道选择语句,该语句会导致 `main` 线程阻塞,从而避免程序过早退出。还有 `for{}``<-make(chan int)` 等诸多方法可以达到类似的效果。因为 `main` 线程被阻塞了,如果需要程序正常退出的话可以通过调用 `os.Exit(0)` 实现。
## 1.5.7 不靠谱的同步
@ -389,7 +386,7 @@ func main() {
}
```
刚接触Go语言的话可能希望通过加入一个随机的休眠时间来保证正常的输出
刚接触 Go 语言的话,可能希望通过加入一个随机的休眠时间来保证正常的输出:
```go
func main() {
@ -398,8 +395,8 @@ func main() {
}
```
因为主线程休眠了1秒钟因此这个程序大概率是可以正常输出结果的。因此很多人会觉得这个程序已经没有问题了。但是这个程序是不稳健的依然有失败的可能性。我们先假设程序是可以稳定输出结果的。因为Go线程的启动是非阻塞的`main`线程显式休眠了1秒钟退出导致程序结束我们可以近似地认为程序总共执行了1秒多时间。现在假设`println`函数内部实现休眠的时间大于`main`线程休眠的时间的话,就会导致矛盾:后台线程既然先于`main`线程完成打印,那么执行时间肯定是小于`main`线程执行时间的。当然这是不可能的。
因为主线程休眠了 1 秒钟,因此这个程序大概率是可以正常输出结果的。因此,很多人会觉得这个程序已经没有问题了。但是这个程序是不稳健的,依然有失败的可能性。我们先假设程序是可以稳定输出结果的。因为 Go 线程的启动是非阻塞的,`main` 线程显式休眠了 1 秒钟退出导致程序结束,我们可以近似地认为程序总共执行了 1 秒多时间。现在假设 `println` 函数内部实现休眠的时间大于 `main` 线程休眠的时间的话,就会导致矛盾:后台线程既然先于 `main` 线程完成打印,那么执行时间肯定是小于 `main` 线程执行时间的。当然这是不可能的。
严谨的并发程序的正确性不应该是依赖于CPU的执行速度和休眠时间等不靠谱的因素的。严谨的并发也应该是可以静态推导出结果的根据线程内顺序一致性结合Channel或`sync`同步事件的可排序性来推导,最终完成各个线程各段代码的偏序关系排序。如果两个事件无法根据此规则来排序,那么它们就是并发的,也就是执行先后顺序不可靠的。
严谨的并发程序的正确性不应该是依赖于 CPU 的执行速度和休眠时间等不靠谱的因素的。严谨的并发也应该是可以静态推导出结果的:根据线程内顺序一致性,结合 Channel `sync` 同步事件的可排序性来推导,最终完成各个线程各段代码的偏序关系排序。如果两个事件无法根据此规则来排序,那么它们就是并发的,也就是执行先后顺序不可靠的。
解决同步问题的思路是相同的:使用显式的同步。

View File

@ -1,4 +1,3 @@
## 1.8 补充说明
本书定位是Go语言进阶图书因此读者需要有一定的Go语言基础。如果对Go语言不太了解作者推荐通过以下资料开始学习Go语言。首先是安装Go语言环境然后通过`go tool tour`命令打开“A Tour of Go”教程学习。在学习“A Tour of Go”教程的同时可以阅读Go语言官方团队出版的[《The Go Programming Language》](http://www.gopl.io/)教程。[《The Go Programming Language》](http://www.gopl.io/)在国内Go语言社区被称为Go语言圣经它将带你系统地学习Go语言。在学习的同时可以尝试用Go语言解决一些小问题如果遇到要查阅API的时候可以通过godoc命令打开自带的文档查询。Go语言本身不仅仅包含了所有的文档也包含了所有标准库的实现代码这是第一手的最权威的Go语言资料。我们认为此时你应该已经可以熟练使用Go语言了。
本书定位是 Go 语言进阶图书,因此读者需要有一定的 Go 语言基础。如果对 Go 语言不太了解,作者推荐通过以下资料开始学习 Go 语言。首先是安装 Go 语言环境,然后通过 `go tool tour` 命令打开“A Tour of Go”教程学习。在学习“A Tour of Go”教程的同时可以阅读 Go 语言官方团队出版的[《The Go Programming Language》](http://www.gopl.io/)教程。[《The Go Programming Language》](http://www.gopl.io/)在国内 Go 语言社区被称为 Go 语言圣经,它将带你系统地学习 Go 语言。在学习的同时可以尝试用 Go 语言解决一些小问题,如果遇到要查阅 API 的时候可以通过 `godoc` 命令打开自带的文档查询。Go 语言本身不仅仅包含了所有的文档,也包含了所有标准库的实现代码,这是第一手的最权威的 Go 语言资料。我们认为此时你应该已经可以熟练使用 Go 语言了。

View File

@ -1,7 +1,7 @@
# 第1章 语言基础
# 第 1 章 语言基础
*我不知道你过去10年为什么不快乐。但相信我抛掉过去的沉重使用Go语言体会最初的快乐——469856321*
*我不知道,你过去 10 年为什么不快乐。但相信我,抛掉过去的沉重,使用 Go 语言体会最初的快乐——469856321*
*搬砖民工也会建成自己的罗马帝国。——小张*
本章首先简要介绍Go语言的发展历史并较详细地分析了“Hello World”程序在各个祖先语言中演化过程。然后对以数组、字符串和切片为代表的基础结构对以函数、方法和接口所体现的面向过程和鸭子对象的编程以及Go语言特有的并发编程模型和错误处理哲学做了简单介绍。最后针对macOS、Windows、Linux几个主流的开发平台推荐了几个较友好的Go语言编辑器和集成开发环境因为好的工具可以极大地提高我们的效率。
本章首先简要介绍 Go 语言的发展历史并较详细地分析了“Hello World”程序在各个祖先语言中演化过程。然后对以数组、字符串和切片为代表的基础结构对以函数、方法和接口所体现的面向过程和鸭子对象的编程以及 Go 语言特有的并发编程模型和错误处理哲学做了简单介绍。最后,针对 macOS、Windows、Linux 几个主流的开发平台,推荐了几个较友好的 Go 语言编辑器和集成开发环境,因为好的工具可以极大地提高我们的效率。