mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-19 15:43:44 +08:00
mod
This commit is contained in:
165
极客时间专栏/Go语言核心36讲/模块二:Go语言进阶技术/07 | 数组和切片.md
Normal file
165
极客时间专栏/Go语言核心36讲/模块二:Go语言进阶技术/07 | 数组和切片.md
Normal file
@@ -0,0 +1,165 @@
|
||||
<audio id="audio" title="07 | 数组和切片" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/70/cf/7013b26fc8baa2f9f01ca3f701ee7bcf.mp3"></audio>
|
||||
|
||||
从本篇文章开始,我们正式进入了模块2的学习。在这之前,我们已经聊了很多的Go语言和编程方面的基础知识,相信你已经对Go语言的开发环境配置、常用源码文件写法,以及程序实体(尤其是变量)及其相关的各种概念和编程技巧(比如类型推断、变量重声明、可重名变量、类型断言、类型转换、别名类型和潜在类型等)都有了一定的理解。
|
||||
|
||||
它们都是我认为的Go语言编程基础中比较重要的部分,同时也是后续文章的基石。如果你在后面的学习过程中感觉有些吃力,那可能是基础仍未牢固,可以再回去复习一下。
|
||||
|
||||
我们这次主要讨论Go语言的数组(array)类型和切片(slice)类型。数组和切片有时候会让初学者感到困惑。
|
||||
|
||||
它们的共同点是都属于集合类的类型,并且,它们的值也都可以用来存储某一种类型的值(或者说元素)。
|
||||
|
||||
不过,它们最重要的不同是:**数组类型的值(以下简称数组)的长度是固定的,而切片类型的值(以下简称切片)是可变长的。**
|
||||
|
||||
数组的长度在声明它的时候就必须给定,并且之后不会再改变。可以说,数组的长度是其类型的一部分。比如,`[1]string`和`[2]string`就是两个不同的数组类型。
|
||||
|
||||
而切片的类型字面量中只有元素的类型,而没有长度。切片的长度可以自动地随着其中元素数量的增长而增长,但不会随着元素数量的减少而减小。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ed/6c/edb5acaf595673e083cdcf1ea7bb966c.png" alt="">
|
||||
|
||||
(数组与切片的字面量)
|
||||
|
||||
我们其实可以把切片看做是对数组的一层简单的封装,因为在每个切片的底层数据结构中,一定会包含一个数组。数组可以被叫做切片的底层数组,而切片也可以被看作是对数组的某个连续片段的引用。
|
||||
|
||||
>
|
||||
也正因为如此,Go语言的切片类型属于引用类型,同属引用类型的还有字典类型、通道类型、函数类型等;而Go语言的数组类型则属于值类型,同属值类型的有基础数据类型以及结构体类型。
|
||||
注意,Go语言里不存在像Java等编程语言中令人困惑的“传值或传引用”问题。在Go语言中,我们判断所谓的“传值”或者“传引用”只要看被传递的值的类型就好了。
|
||||
如果传递的值是引用类型的,那么就是“传引用”。如果传递的值是值类型的,那么就是“传值”。从传递成本的角度讲,引用类型的值往往要比值类型的值低很多。
|
||||
我们在数组和切片之上都可以应用索引表达式,得到的都会是某个元素。我们在它们之上也都可以应用切片表达式,也都会得到一个新的切片。
|
||||
|
||||
|
||||
我们通过调用内建函数`len`,得到数组和切片的长度。通过调用内建函数`cap`,我们可以得到它们的容量。
|
||||
|
||||
但要注意,数组的容量永远等于其长度,都是不可变的。切片的容量却不是这样,并且它的变化是有规律可寻的。
|
||||
|
||||
下面我们就通过一道题来了解一下。**我们今天的问题就是:怎样正确估算切片的长度和容量?**
|
||||
|
||||
为此,我编写了一个简单的命令源码文件demo15.go。
|
||||
|
||||
```
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func main() {
|
||||
// 示例1。
|
||||
s1 := make([]int, 5)
|
||||
fmt.Printf("The length of s1: %d\n", len(s1))
|
||||
fmt.Printf("The capacity of s1: %d\n", cap(s1))
|
||||
fmt.Printf("The value of s1: %d\n", s1)
|
||||
s2 := make([]int, 5, 8)
|
||||
fmt.Printf("The length of s2: %d\n", len(s2))
|
||||
fmt.Printf("The capacity of s2: %d\n", cap(s2))
|
||||
fmt.Printf("The value of s2: %d\n", s2)
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我描述一下它所做的事情。
|
||||
|
||||
首先,我用内建函数`make`声明了一个`[]int`类型的变量`s1`。我传给`make`函数的第二个参数是`5`,从而指明了该切片的长度。我用几乎同样的方式声明了切片`s2`,只不过多传入了一个参数`8`以指明该切片的容量。
|
||||
|
||||
现在,具体的问题是:切片`s1`和`s2`的容量都是多少?
|
||||
|
||||
这道题的典型回答:切片`s1`和`s2`的容量分别是`5`和`8`。
|
||||
|
||||
## 问题解析
|
||||
|
||||
解析一下这道题。`s1`的容量为什么是`5`呢?因为我在声明`s1`的时候把它的长度设置成了`5`。当我们用`make`函数初始化切片时,如果不指明其容量,那么它就会和长度一致。如果在初始化时指明了容量,那么切片的实际容量也就是它了。这也正是`s2`的容量是`8`的原因。
|
||||
|
||||
我们顺便通过`s2`再来明确下长度、容量以及它们的关系。我在初始化`s2`代表的切片时,同时也指定了它的长度和容量。
|
||||
|
||||
我在刚才说过,可以把切片看做是对数组的一层简单的封装,因为在每个切片的底层数据结构中,一定会包含一个数组。数组可以被叫做切片的底层数组,而切片也可以被看作是对数组的某个连续片段的引用。
|
||||
|
||||
在这种情况下,切片的容量实际上代表了它的底层数组的长度,这里是`8`。(注意,切片的底层数组等同于我们前面讲到的数组,其长度不可变。)
|
||||
|
||||
现在你需要跟着我一起想象:**有一个窗口,你可以通过这个窗口看到一个数组,但是不一定能看到该数组中的所有元素,有时候只能看到连续的一部分元素。**
|
||||
|
||||
现在,这个数组就是切片`s2`的底层数组,而这个窗口就是切片`s2`本身。`s2`的长度实际上指明的就是这个窗口的宽度,决定了你透过`s2`,可以看到其底层数组中的哪几个连续的元素。
|
||||
|
||||
由于`s2`的长度是`5`,所以你可以看到底层数组中的第1个元素到第5个元素,对应的底层数组的索引范围是[0, 4]。
|
||||
|
||||
切片代表的窗口也会被划分成一个一个的小格子,就像我们家里的窗户那样。每个小格子都对应着其底层数组中的某一个元素。
|
||||
|
||||
我们继续拿`s2`为例,这个窗口最左边的那个小格子对应的正好是其底层数组中的第一个元素,即索引为`0`的那个元素。因此可以说,`s2`中的索引从`0`到`4`所指向的元素恰恰就是其底层数组中索引从`0`到`4`代表的那5个元素。
|
||||
|
||||
请记住,当我们用`make`函数或切片值字面量(比如`[]int{1, 2, 3}`)初始化一个切片时,该窗口最左边的那个小格子总是会对应其底层数组中的第1个元素。
|
||||
|
||||
但是当我们通过切片表达式基于某个数组或切片生成新切片的时候,情况就变得复杂起来了。
|
||||
|
||||
**我们再来看一个例子:**
|
||||
|
||||
```
|
||||
s3 := []int{1, 2, 3, 4, 5, 6, 7, 8}
|
||||
s4 := s3[3:6]
|
||||
fmt.Printf("The length of s4: %d\n", len(s4))
|
||||
fmt.Printf("The capacity of s4: %d\n", cap(s4))
|
||||
fmt.Printf("The value of s4: %d\n", s4)
|
||||
|
||||
```
|
||||
|
||||
切片`s3`中有8个元素,分别是从`1`到`8`的整数。`s3`的长度和容量都是`8`。然后,我用切片表达式`s3[3:6]`初始化了切片`s4`。问题是,这个`s4`的长度和容量分别是多少?
|
||||
|
||||
这并不难,用减法就可以搞定。首先你要知道,切片表达式中的方括号里的那两个整数都代表什么。我换一种表达方式你也许就清楚了,即:[3, 6)。
|
||||
|
||||
这是数学中的区间表示法,常用于表示取值范围,我其实已经在本专栏用过好几次了。由此可知,`[3:6]`要表达的就是透过新窗口能看到的`s3`中元素的索引范围是从`3`到`5`(注意,不包括`6`)。
|
||||
|
||||
这里的`3`可被称为起始索引,`6`可被称为结束索引。那么`s4`的长度就是`6`减去`3`,即`3`。因此可以说,`s4`中的索引从`0`到`2`指向的元素对应的是`s3`及其底层数组中索引从`3`到`5`的那3个元素。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/96/55/96e2c7129793ee5e73a574ef8f3ad755.png" alt="">
|
||||
|
||||
(切片与数组的关系)
|
||||
|
||||
再来看容量。我在前面说过,切片的容量代表了它的底层数组的长度,但这仅限于使用`make`函数或者切片值字面量初始化切片的情况。
|
||||
|
||||
更通用的规则是:一个切片的容量可以被看作是透过这个窗口最多可以看到的底层数组中元素的个数。
|
||||
|
||||
由于`s4`是通过在`s3`上施加切片操作得来的,所以`s3`的底层数组就是`s4`的底层数组。
|
||||
|
||||
又因为,在底层数组不变的情况下,切片代表的窗口可以向右扩展,直至其底层数组的末尾。
|
||||
|
||||
所以,`s4`的容量就是其底层数组的长度`8`,减去上述切片表达式中的那个起始索引`3`,即`5`。
|
||||
|
||||
注意,切片代表的窗口是无法向左扩展的。也就是说,我们永远无法透过`s4`看到`s3`中最左边的那3个元素。
|
||||
|
||||
最后,顺便提一下把切片的窗口向右扩展到最大的方法。对于`s4`来说,切片表达式`s4[0:cap(s4)]`就可以做到。我想你应该能看懂。该表达式的结果值(即一个新的切片)会是`[]int{4, 5, 6, 7, 8}`,其长度和容量都是`5`。
|
||||
|
||||
## 知识扩展
|
||||
|
||||
**问题1:怎样估算切片容量的增长?**
|
||||
|
||||
一旦一个切片无法容纳更多的元素,Go语言就会想办法扩容。但它并不会改变原来的切片,而是会生成一个容量更大的切片,然后将把原有的元素和新元素一并拷贝到新切片中。在一般的情况下,你可以简单地认为新切片的容量(以下简称新容量)将会是原切片容量(以下简称原容量)的2倍。
|
||||
|
||||
但是,当原切片的长度(以下简称原长度)大于或等于`1024`时,Go语言将会以原容量的`1.25`倍作为新容量的基准(以下新容量基准)。新容量基准会被调整(不断地与`1.25`相乘),直到结果不小于原长度与要追加的元素数量之和(以下简称新长度)。最终,新容量往往会比新长度大一些,当然,相等也是可能的。
|
||||
|
||||
另外,如果我们一次追加的元素过多,以至于使新长度比原容量的2倍还要大,那么新容量就会以新长度为基准。注意,与前面那种情况一样,最终的新容量在很多时候都要比新容量基准更大一些。更多细节可参见`runtime`包中slice.go文件里的`growslice`及相关函数的具体实现。
|
||||
|
||||
我把展示上述扩容策略的一些例子都放到了demo16.go文件中。你可以去试运行看看。
|
||||
|
||||
**问题 2:切片的底层数组什么时候会被替换?**
|
||||
|
||||
确切地说,一个切片的底层数组永远不会被替换。为什么?虽然在扩容的时候Go语言一定会生成新的底层数组,但是它也同时生成了新的切片。
|
||||
|
||||
它只是把新的切片作为了新底层数组的窗口,而没有对原切片,及其底层数组做任何改动。
|
||||
|
||||
请记住,在无需扩容时,`append`函数返回的是指向原底层数组的原切片,而在需要扩容时,`append`函数返回的是指向新底层数组的新切片。所以,严格来讲,“扩容”这个词用在这里虽然形象但并不合适。不过鉴于这种称呼已经用得很广泛了,我们也没必要另找新词了。
|
||||
|
||||
顺便说一下,只要新长度不会超过切片的原容量,那么使用`append`函数对其追加元素的时候就不会引起扩容。这只会使紧邻切片窗口右边的(底层数组中的)元素被新的元素替换掉。你可以运行demo17.go文件以增强对这些知识的理解。
|
||||
|
||||
**总结**
|
||||
|
||||
总结一下,我们今天一起探讨了数组和切片以及它们之间的关系。切片是基于数组的,可变长的,并且非常轻快。一个切片的容量总是固定的,而且一个切片也只会与某一个底层数组绑定在一起。
|
||||
|
||||
此外,切片的容量总会是在切片长度和底层数组长度之间的某一个值,并且还与切片窗口最左边对应的元素在底层数组中的位置有关系。那两个分别用减法计算切片长度和容量的方法你一定要记住。
|
||||
|
||||
另外,如果新的长度比原有切片的容量还要大,那么底层数组就一定会是新的,而且`append`函数也会返回一个新的切片。还有,你其实不必太在意切片“扩容”策略中的一些细节,只要能够理解它的基本规律并可以进行近似的估算就可以了。
|
||||
|
||||
**思考题**
|
||||
|
||||
这里仍然是聚焦于切片的问题。
|
||||
|
||||
1. 如果有多个切片指向了同一个底层数组,那么你认为应该注意些什么?
|
||||
1. 怎样沿用“扩容”的思想对切片进行“缩容”?请写出代码。
|
||||
|
||||
这两个问题都是开放性的,你需要认真思考一下。最好在动脑的同时动动手。
|
||||
|
||||
[戳此查看Go语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers)
|
||||
148
极客时间专栏/Go语言核心36讲/模块二:Go语言进阶技术/08 | container包中的那些容器.md
Normal file
148
极客时间专栏/Go语言核心36讲/模块二:Go语言进阶技术/08 | container包中的那些容器.md
Normal file
@@ -0,0 +1,148 @@
|
||||
<audio id="audio" title="08 | container包中的那些容器" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/7c/9f/7c4b9c8aaf83f93cb8f9c632807c449f.mp3"></audio>
|
||||
|
||||
我们在上次讨论了数组和切片,当我们提到数组的时候,往往会想起链表。那么Go语言的链表是什么样的呢?
|
||||
|
||||
Go语言的链表实现在标准库的`container/list`代码包中。这个代码包中有两个公开的程序实体——`List`和`Element`,List实现了一个双向链表(以下简称链表),而Element则代表了链表中元素的结构。
|
||||
|
||||
**那么,我今天的问题是:可以把自己生成的`Element`类型值传给链表吗?**
|
||||
|
||||
我们在这里用到了`List`的四种方法。
|
||||
|
||||
`MoveBefore`方法和`MoveAfter`方法,它们分别用于把给定的元素移动到另一个元素的前面和后面。
|
||||
|
||||
`MoveToFront`方法和`MoveToBack`方法,分别用于把给定的元素移动到链表的最前端和最后端。
|
||||
|
||||
在这些方法中,“给定的元素”都是`*Element`类型的,`*Element`类型是`Element`类型的指针类型,`*Element`的值就是元素的指针。
|
||||
|
||||
```
|
||||
func (l *List) MoveBefore(e, mark *Element)
|
||||
func (l *List) MoveAfter(e, mark *Element)
|
||||
|
||||
func (l *List) MoveToFront(e *Element)
|
||||
func (l *List) MoveToBack(e *Element)
|
||||
|
||||
```
|
||||
|
||||
具体问题是,如果我们自己生成这样的值,然后把它作为“给定的元素”传给链表的方法,那么会发生什么?链表会接受它吗?
|
||||
|
||||
这里,给出一个**典型回答**:不会接受,这些方法将不会对链表做出任何改动。因为我们自己生成的`Element`值并不在链表中,所以也就谈不上“在链表中移动元素”。更何况链表不允许我们把自己生成的`Element`值插入其中。
|
||||
|
||||
## 问题解析
|
||||
|
||||
在`List`包含的方法中,用于插入新元素的那些方法都只接受`interface{}`类型的值。这些方法在内部会使用`Element`值,包装接收到的新元素。
|
||||
|
||||
这样做正是为了避免直接使用我们自己生成的元素,主要原因是避免链表的内部关联,遭到外界破坏,这对于链表本身以及我们这些使用者来说都是有益的。
|
||||
|
||||
`List`的方法还有下面这几种:
|
||||
|
||||
`Front`和`Back`方法分别用于获取链表中最前端和最后端的元素,<br>
|
||||
`InsertBefore`和`InsertAfter`方法分别用于在指定的元素之前和之后插入新元素,`PushFront`和`PushBack`方法则分别用于在链表的最前端和最后端插入新元素。
|
||||
|
||||
```
|
||||
func (l *List) Front() *Element
|
||||
func (l *List) Back() *Element
|
||||
|
||||
func (l *List) InsertBefore(v interface{}, mark *Element) *Element
|
||||
func (l *List) InsertAfter(v interface{}, mark *Element) *Element
|
||||
|
||||
func (l *List) PushFront(v interface{}) *Element
|
||||
func (l *List) PushBack(v interface{}) *Element
|
||||
|
||||
```
|
||||
|
||||
这些方法都会把一个`Element`值的指针作为结果返回,它们就是链表留给我们的安全“接口”。拿到这些内部元素的指针,我们就可以去调用前面提到的用于移动元素的方法了。
|
||||
|
||||
**知识扩展**
|
||||
|
||||
**1. 问题:为什么链表可以做到开箱即用?**
|
||||
|
||||
`List`和`Element`都是结构体类型。结构体类型有一个特点,那就是它们的零值都会是拥有特定结构,但是没有任何定制化内容的值,相当于一个空壳。值中的字段也都会被分别赋予各自类型的零值。
|
||||
|
||||
>
|
||||
广义来讲,所谓的零值就是只做了声明,但还未做初始化的变量被给予的缺省值。每个类型的零值都会依据该类型的特性而被设定。
|
||||
比如,经过语句`var a [2]int`声明的变量`a`的值,将会是一个包含了两个`0`的整数数组。又比如,经过语句`var s []int`声明的变量`s`的值将会是一个`[]int`类型的、值为`nil`的切片。
|
||||
|
||||
|
||||
那么经过语句`var l list.List`声明的变量`l`的值将会是什么呢?[1] 这个零值将会是一个长度为`0`的链表。这个链表持有的根元素也将会是一个空壳,其中只会包含缺省的内容。那这样的链表我们可以直接拿来使用吗?
|
||||
|
||||
答案是,可以的。这被称为“开箱即用”。Go语言标准库中很多结构体类型的程序实体都做到了开箱即用。这也是在编写可供别人使用的代码包(或者说程序库)时,我们推荐遵循的最佳实践之一。那么,语句`var l list.List`声明的链表`l`可以直接使用,这是怎么做到的呢?
|
||||
|
||||
关键在于它的“延迟初始化”机制。
|
||||
|
||||
所谓的**延迟初始化**,你可以理解为把初始化操作延后,仅在实际需要的时候才进行。延迟初始化的优点在于“延后”,它可以分散初始化操作带来的计算量和存储空间消耗。
|
||||
|
||||
例如,如果我们需要集中声明非常多的大容量切片的话,那么那时的CPU和内存空间的使用量肯定都会一个激增,并且只有设法让其中的切片及其底层数组被回收,内存使用量才会有所降低。
|
||||
|
||||
如果数组是可以被延迟初始化的,那么计算量和存储空间的压力就可以被分散到实际使用它们的时候。这些数组被实际使用的时间越分散,延迟初始化带来的优势就会越明显。
|
||||
|
||||
>
|
||||
实际上,Go语言的切片就起到了延迟初始化其底层数组的作用,你可以想一想为什么会这么说的理由。
|
||||
延迟初始化的缺点恰恰也在于“延后”。你可以想象一下,如果我在调用链表的每个方法的时候,它们都需要先去判断链表是否已经被初始化,那这也会是一个计算量上的浪费。在这些方法被非常频繁地调用的情况下,这种浪费的影响就开始显现了,程序的性能将会降低。
|
||||
|
||||
|
||||
在这里的链表实现中,一些方法是无需对是否初始化做判断的。比如`Front`方法和`Back`方法,一旦发现链表的长度为`0`,直接返回`nil`就好了。
|
||||
|
||||
又比如,在用于删除元素、移动元素,以及一些用于插入元素的方法中,只要判断一下传入的元素中指向所属链表的指针,是否与当前链表的指针相等就可以了。
|
||||
|
||||
如果不相等,就一定说明传入的元素不是这个链表中的,后续的操作就不用做了。反之,就一定说明这个链表已经被初始化了。
|
||||
|
||||
原因在于,链表的`PushFront`方法、`PushBack`方法、`PushBackList`方法以及`PushFrontList`方法总会先判断链表的状态,并在必要时进行初始化,这就是延迟初始化。
|
||||
|
||||
而且,我们在向一个空的链表中添加新元素的时候,肯定会调用这四个方法中的一个,这时新元素中指向所属链表的指针,一定会被设定为当前链表的指针。所以,指针相等是链表已经初始化的充分必要条件。
|
||||
|
||||
明白了吗?`List`利用了自身以及`Element`在结构上的特点,巧妙地平衡了延迟初始化的优缺点,使得链表可以开箱即用,并且在性能上可以达到最优。
|
||||
|
||||
**问题 2:`Ring`与`List`的区别在哪儿?**
|
||||
|
||||
`container/ring`包中的`Ring`类型实现的是一个循环链表,也就是我们俗称的环。其实`List`在内部就是一个循环链表。它的根元素永远不会持有任何实际的元素值,而该元素的存在就是为了连接这个循环链表的首尾两端。
|
||||
|
||||
所以也可以说,`List`的零值是一个只包含了根元素,但不包含任何实际元素值的空链表。那么,既然`Ring`和`List`在本质上都是循环链表,那它们到底有什么不同呢?
|
||||
|
||||
最主要的不同有下面几种。
|
||||
|
||||
1. `Ring`类型的数据结构仅由它自身即可代表,而`List`类型则需要由它以及`Element`类型联合表示。这是表示方式上的不同,也是结构复杂度上的不同。
|
||||
1. 一个`Ring`类型的值严格来讲,只代表了其所属的循环链表中的一个元素,而一个`List`类型的值则代表了一个完整的链表。这是表示维度上的不同。
|
||||
1. 在创建并初始化一个`Ring`值的时候,我们可以指定它包含的元素的数量,但是对于一个`List`值来说却不能这样做(也没有必要这样做)。循环链表一旦被创建,其长度是不可变的。这是两个代码包中的`New`函数在功能上的不同,也是两个类型在初始化值方面的第一个不同。
|
||||
1. 仅通过`var r ring.Ring`语句声明的`r`将会是一个长度为`1`的循环链表,而`List`类型的零值则是一个长度为`0`的链表。别忘了`List`中的根元素不会持有实际元素值,因此计算长度时不会包含它。这是两个类型在初始化值方面的第二个不同。
|
||||
1. `Ring`值的`Len`方法的算法复杂度是O(N)的,而`List`值的`Len`方法的算法复杂度则是O(1)的。这是两者在性能方面最显而易见的差别。
|
||||
|
||||
其他的不同基本上都是方法方面的了。比如,循环链表也有用于插入、移动或删除元素的方法,不过用起来都显得更抽象一些,等等。
|
||||
|
||||
**总结**
|
||||
|
||||
我们今天主要讨论了`container/list`包中的链表实现。我们详细讲解了链表的一些主要的使用技巧和实现特点。由于此链表实现在内部就是一个循环链表,所以我们还把它与`container/ring`包中的循环链表实现做了一番比较,包括结构、初始化以及性能方面。
|
||||
|
||||
**思考题**
|
||||
|
||||
1. `container/ring`包中的循环链表的适用场景都有哪些?
|
||||
1. 你使用过`container/heap`包中的堆吗?它的适用场景又有哪些呢?
|
||||
|
||||
在这里,我们先不求对它们的实现了如指掌,能用对、用好才是我们进阶之前的第一步。好了,感谢你的收听,我们下次再见。
|
||||
|
||||
[1]:`List`这个结构体类型有两个字段,一个是`Element`类型的字段`root`,另一个是`int`类型的字段`len`。顾名思义,前者代表的就是那个根元素,而后者用于存储链表的长度。注意,它们都是包级私有的,也就是说使用者无法查看和修改它们。
|
||||
|
||||
像前面那样声明的`l`,其字段`root`和`len`都会被赋予相应的零值。`len`的零值是`0`,正好可以表明该链表还未包含任何元素。由于`root`是`Element`类型的,所以它的零值就是该类型的空壳,用字面量表示的话就是`Element{}`。
|
||||
|
||||
`Element`类型包含了几个包级私有的字段,分别用于存储前一个元素、后一个元素以及所属链表的指针值。另外还有一个名叫`Value`的公开的字段,该字段的作用就是持有元素的实际值,它是`interface{}`类型的。在`Element`类型的零值中,这些字段的值都会是`nil`。
|
||||
|
||||
## 参考阅读
|
||||
|
||||
### 切片与数组的比较
|
||||
|
||||
切片本身有着占用内存少和创建便捷等特点,但它的本质上还是数组。切片的一大好处是可以让我们通过窗口快速地定位并获取,或者修改底层数组中的元素。
|
||||
|
||||
不过,当我们想删除切片中的元素的时候就没那么简单了。元素复制一般是免不了的,就算只删除一个元素,有时也会造成大量元素的移动。这时还要注意空出的元素槽位的“清空”,否则很可能会造成内存泄漏。
|
||||
|
||||
另一方面,在切片被频繁“扩容”的情况下,新的底层数组会不断产生,这时内存分配的量以及元素复制的次数可能就很可观了,这肯定会对程序的性能产生负面的影响。
|
||||
|
||||
尤其是当我们没有一个合理、有效的”缩容“策略的时候,旧的底层数组无法被回收,新的底层数组中也会有大量无用的元素槽位。过度的内存浪费不但会降低程序的性能,还可能会使内存溢出并导致程序崩溃。
|
||||
|
||||
由此可见,正确地使用切片是多么的重要。不过,一个更重要的事实是,任何数据结构都不是银弹。不是吗?数组的自身特点和适用场景都非常鲜明,切片也是一样。它们都是Go语言原生的数据结构,使用起来也都很方便.不过,你的集合类工具箱中不应该只有它们。这就是我们使用链表的原因。
|
||||
|
||||
不过,对比来看,一个链表所占用的内存空间,往往要比包含相同元素的数组所占内存大得多。这是由于链表的元素并不是连续存储的,所以相邻的元素之间需要互相保存对方的指针。不但如此,每个元素还要存有它所属链表的指针。
|
||||
|
||||
有了这些关联,链表的结构反倒更简单了。它只持有头部元素(或称为根元素)基本上就可以了。当然了,为了防止不必要的遍历和计算,链表的长度记录在内也是必须的。
|
||||
|
||||
[戳此查看Go语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers)
|
||||
|
||||
|
||||
159
极客时间专栏/Go语言核心36讲/模块二:Go语言进阶技术/09 | 字典的操作和约束.md
Normal file
159
极客时间专栏/Go语言核心36讲/模块二:Go语言进阶技术/09 | 字典的操作和约束.md
Normal file
@@ -0,0 +1,159 @@
|
||||
<audio id="audio" title="09 | 字典的操作和约束" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f6/17/f68cd856a807f8bb93c8b2261bb9a017.mp3"></audio>
|
||||
|
||||
至今为止,我们讲过的集合类的高级数据类型都属于针对单一元素的容器。
|
||||
|
||||
它们或用连续存储,或用互存指针的方式收纳元素,这里的每个元素都代表了一个从属某一类型的独立值。
|
||||
|
||||
我们今天要讲的字典(map)却不同,它能存储的不是单一值的集合,而是键值对的集合。
|
||||
|
||||
>
|
||||
什么是键值对?它是从英文key-value pair直译过来的一个词。顾名思义,一个键值对就代表了一对键和值。
|
||||
注意,一个“键”和一个“值”分别代表了一个从属于某一类型的独立值,把它们两个捆绑在一起就是一个键值对了。
|
||||
|
||||
|
||||
在Go语言规范中,应该是为了避免歧义,他们将键值对换了一种称呼,叫做:“键-元素对”。我们也沿用这个看起来更加清晰的词来讲解。
|
||||
|
||||
## 知识前导:为什么字典的键类型会受到约束?
|
||||
|
||||
Go语言的字典类型其实是一个哈希表(hash table)的特定实现,在这个实现中,键和元素的最大不同在于,键的类型是受限的,而元素却可以是任意类型的。
|
||||
|
||||
如果要探究限制的原因,我们就先要了解哈希表中最重要的一个过程:映射。
|
||||
|
||||
你可以把键理解为元素的一个索引,我们可以在哈希表中通过键查找与它成对的那个元素。
|
||||
|
||||
键和元素的这种对应关系,在数学里就被称为“映射”,这也是“map”这个词的本意,哈希表的映射过程就存在于对键-元素对的增、删、改、查的操作之中。
|
||||
|
||||
```
|
||||
aMap := map[string]int{
|
||||
"one": 1,
|
||||
"two": 2,
|
||||
"three": 3,
|
||||
}
|
||||
k := "two"
|
||||
v, ok := aMap[k]
|
||||
if ok {
|
||||
fmt.Printf("The element of key %q: %d\n", k, v)
|
||||
} else {
|
||||
fmt.Println("Not found!")
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
比如,我们要在哈希表中查找与某个键值对应的那个元素值,那么我们需要先把键值作为参数传给这个哈希表。
|
||||
|
||||
哈希表会先用哈希函数(hash function)把键值转换为哈希值。哈希值通常是一个无符号的整数。一个哈希表会持有一定数量的桶(bucket),我们也可以叫它哈希桶,这些哈希桶会均匀地储存其所属哈希表收纳的键-元素对。
|
||||
|
||||
因此,哈希表会先用这个键哈希值的低几位去定位到一个哈希桶,然后再去这个哈希桶中,查找这个键。
|
||||
|
||||
由于键-元素对总是被捆绑在一起存储的,所以一旦找到了键,就一定能找到对应的元素值。随后,哈希表就会把相应的元素值作为结果返回。
|
||||
|
||||
只要这个键-元素对存在哈希表中就一定会被查找到,因为哈希表增、改、删键-元素对时的映射过程,与前文所述如出一辙。
|
||||
|
||||
**现在我们知道了,映射过程的第一步就是:把键值转换为哈希值。**
|
||||
|
||||
在Go语言的字典中,每一个键值都是由它的哈希值代表的。也就是说,字典不会独立存储任何键的值,但会独立存储它们的哈希值。
|
||||
|
||||
你是不是隐约感觉到了什么?我们接着往下看。
|
||||
|
||||
**我们今天的问题是:字典的键类型不能是哪些类型?**
|
||||
|
||||
这个问题你可以在Go语言规范中找到答案,但却没那么简单。它的典型回答是:Go语言字典的键类型不可以是函数类型、字典类型和切片类型。
|
||||
|
||||
## 问题解析
|
||||
|
||||
我们来解析一下这个问题。
|
||||
|
||||
Go语言规范规定,在键类型的值之间必须可以施加操作符`==`和`!=`。换句话说,键类型的值必须要支持判等操作。由于函数类型、字典类型和切片类型的值并不支持判等操作,所以字典的键类型不能是这些类型。
|
||||
|
||||
另外,如果键的类型是接口类型的,那么键值的实际类型也不能是上述三种类型,否则在程序运行过程中会引发panic(即运行时恐慌)。
|
||||
|
||||
我们举个例子:
|
||||
|
||||
```
|
||||
var badMap2 = map[interface{}]int{
|
||||
"1": 1,
|
||||
[]int{2}: 2, // 这里会引发panic。
|
||||
3: 3,
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这里的变量`badMap2`的类型是键类型为`interface{}`、值类型为`int`的字典类型。这样声明并不会引起什么错误。或者说,我通过这样的声明躲过了Go语言编译器的检查。
|
||||
|
||||
注意,我用字面量在声明该字典的同时对它进行了初始化,使它包含了三个键-元素对。其中第二个键-元素对的键值是`[]int{2}`,元素值是`2`。这样的键值也不会让Go语言编译器报错,因为从语法上说,这样做是可以的。
|
||||
|
||||
但是,当我们运行这段代码的时候,Go语言的运行时(runtime)系统就会发现这里的问题,它会抛出一个panic,并把根源指向字面量中定义第二个键-元素对的那一行。我们越晚发现问题,修正问题的成本就会越高,所以最好不要把字典的键类型设定为任何接口类型。如果非要这么做,请一定确保代码在可控的范围之内。
|
||||
|
||||
还要注意,如果键的类型是数组类型,那么还要确保该类型的元素类型不是函数类型、字典类型或切片类型。
|
||||
|
||||
比如,由于类型`[1][]string`的元素类型是`[]string`,所以它就不能作为字典类型的键类型。另外,如果键的类型是结构体类型,那么还要保证其中字段的类型的合法性。无论不合法的类型被埋藏得有多深,比如`map[[1][2][3][]string]int`,Go语言编译器都会把它揪出来。
|
||||
|
||||
你可能会有疑问,为什么键类型的值必须支持判等操作?我在前面说过,Go语言一旦定位到了某一个哈希桶,那么就会试图在这个桶中查找键值。具体是怎么找的呢?
|
||||
|
||||
首先,每个哈希桶都会把自己包含的所有键的哈希值存起来。Go语言会用被查找键的哈希值与这些哈希值逐个对比,看看是否有相等的。如果一个相等的都没有,那么就说明这个桶中没有要查找的键值,这时Go语言就会立刻返回结果了。
|
||||
|
||||
如果有相等的,那就再用键值本身去对比一次。为什么还要对比?原因是,不同值的哈希值是可能相同的。这有个术语,叫做“哈希碰撞”。
|
||||
|
||||
所以,即使哈希值一样,键值也不一定一样。如果键类型的值之间无法判断相等,那么此时这个映射的过程就没办法继续下去了。最后,只有键的哈希值和键值都相等,才能说明查找到了匹配的键-元素对。
|
||||
|
||||
以上内容涉及的示例都在demo18.go中。
|
||||
|
||||
## 知识扩展
|
||||
|
||||
**问题1:应该优先考虑哪些类型作为字典的键类型?**
|
||||
|
||||
你现在已经清楚了,在Go语言中,有些类型的值是支持判等的,有些是不支持的。那么在这些值支持判等的类型当中,哪些更适合作为字典的键类型呢?
|
||||
|
||||
这里先抛开我们使用字典时的上下文,只从性能的角度看。在前文所述的映射过程中,“把键值转换为哈希值”以及“把要查找的键值与哈希桶中的键值做对比”, 明显是两个重要且比较耗时的操作。
|
||||
|
||||
因此,可以说,**求哈希和判等操作的速度越快,对应的类型就越适合作为键类型。**
|
||||
|
||||
对于所有的基本类型、指针类型,以及数组类型、结构体类型和接口类型,Go语言都有一套算法与之对应。这套算法中就包含了哈希和判等。以求哈希的操作为例,宽度越小的类型速度通常越快。对于布尔类型、整数类型、浮点数类型、复数类型和指针类型来说都是如此。对于字符串类型,由于它的宽度是不定的,所以要看它的值的具体长度,长度越短求哈希越快。
|
||||
|
||||
类型的宽度是指它的单个值需要占用的字节数。比如,`bool`、`int8`和`uint8`类型的一个值需要占用的字节数都是`1`,因此这些类型的宽度就都是`1`。
|
||||
|
||||
以上说的都是基本类型,再来看高级类型。对数组类型的值求哈希实际上是依次求得它的每个元素的哈希值并进行合并,所以速度就取决于它的元素类型以及它的长度。细则同上。
|
||||
|
||||
与之类似,对结构体类型的值求哈希实际上就是对它的所有字段值求哈希并进行合并,所以关键在于它的各个字段的类型以及字段的数量。而对于接口类型,具体的哈希算法,则由值的实际类型决定。
|
||||
|
||||
我不建议你使用这些高级数据类型作为字典的键类型,不仅仅是因为对它们的值求哈希,以及判等的速度较慢,更是因为在它们的值中存在变数。
|
||||
|
||||
比如,对一个数组来说,我可以任意改变其中的元素值,但在变化前后,它却代表了两个不同的键值。
|
||||
|
||||
对于结构体类型的值情况可能会好一些,因为如果我可以控制其中各字段的访问权限的话,就可以阻止外界修改它了。把接口类型作为字典的键类型最危险。
|
||||
|
||||
还记得吗?如果在这种情况下Go运行时系统发现某个键值不支持判等操作,那么就会立即抛出一个panic。在最坏的情况下,这足以使程序崩溃。
|
||||
|
||||
那么,在那些基本类型中应该优先选择哪一个?答案是,优先选用数值类型和指针类型,通常情况下类型的宽度越小越好。如果非要选择字符串类型的话,最好对键值的长度进行额外的约束。
|
||||
|
||||
那什么是不通常的情况?笼统地说,Go语言有时会对字典的增、删、改、查操作做一些优化。
|
||||
|
||||
比如,在字典的键类型为字符串类型的情况下;又比如,在字典的键类型为宽度为`4`或`8`的整数类型的情况下。
|
||||
|
||||
**问题2:在值为`nil`的字典上执行读操作会成功吗,那写操作呢?**
|
||||
|
||||
好了,为了避免烧脑太久,我们再来说一个简单些的问题。由于字典是引用类型,所以当我们仅声明而不初始化一个字典类型的变量的时候,它的值会是`nil`。
|
||||
|
||||
在这样一个变量上试图通过键值获取对应的元素值,或者添加键-元素对,会成功吗?这个问题虽然简单,但却是我们必须铭记于心的,因为这涉及程序运行时的稳定性。
|
||||
|
||||
我来说一下答案。除了添加键-元素对,我们在一个值为`nil`的字典上做任何操作都不会引起错误。当我们试图在一个值为`nil`的字典中添加键-元素对的时候,Go语言的运行时系统就会立即抛出一个panic。你可以运行一下demo19.go文件试试看。
|
||||
|
||||
**总结**
|
||||
|
||||
我们这次主要讨论了与字典类型有关的,一些容易让人困惑的问题。比如,为什么字典的键类型会受到约束?又比如,我们通常应该选取什么样的类型作为字典的键类型。
|
||||
|
||||
我以Go语言规范为起始,并以Go语言源码为依据回答了这些问题。认真看了这篇文章之后,你应该对字典中的映射过程有了一定的理解。
|
||||
|
||||
另外,对于Go语言在那些合法的键类型上所做的求哈希和判等的操作,你也应该有所了解了。
|
||||
|
||||
再次强调,永远要注意那些可能引发panic的操作,比如像一个值为`nil`的字典添加键-元素对。
|
||||
|
||||
**思考题**
|
||||
|
||||
今天的思考题是关于并发安全性的。更具体地说,在同一时间段内但在不同的goroutine(或者说go程)中对同一个值进行操作是否是安全的。这里的安全是指,该值不会因这些操作而产生混乱,或其它不可预知的问题。
|
||||
|
||||
具体的思考题是:字典类型的值是并发安全的吗?如果不是,那么在我们只在字典上添加或删除键-元素对的情况下,依然不安全吗?感谢你的收听,我们下期再见。
|
||||
|
||||
[戳此查看Go语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers)
|
||||
|
||||
|
||||
171
极客时间专栏/Go语言核心36讲/模块二:Go语言进阶技术/10 | 通道的基本操作.md
Normal file
171
极客时间专栏/Go语言核心36讲/模块二:Go语言进阶技术/10 | 通道的基本操作.md
Normal file
@@ -0,0 +1,171 @@
|
||||
<audio id="audio" title="10 | 通道的基本操作" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/bd/93/bd22c7e58b23bba062fd7d5cef1bdd93.mp3"></audio>
|
||||
|
||||
作为Go语言最有特色的数据类型,通道(channel)完全可以与goroutine(也可称为go程)并驾齐驱,共同代表Go语言独有的并发编程模式和编程哲学。
|
||||
|
||||
>
|
||||
Don’t communicate by sharing memory; share memory by communicating. (不要通过共享内存来通信,而应该通过通信来共享内存。)
|
||||
|
||||
|
||||
这是作为Go语言的主要创造者之一的Rob Pike的至理名言,这也充分体现了Go语言最重要的编程理念。而通道类型恰恰是后半句话的完美实现,我们可以利用通道在多个goroutine之间传递数据。
|
||||
|
||||
## 前导内容:通道的基础知识
|
||||
|
||||
通道类型的值本身就是并发安全的,这也是Go语言自带的、唯一一个可以满足并发安全性的类型。它使用起来十分简单,并不会徒增我们的心智负担。
|
||||
|
||||
在声明并初始化一个通道的时候,我们需要用到Go语言的内建函数`make`。就像用`make`初始化切片那样,我们传给这个函数的第一个参数应该是代表了通道的具体类型的类型字面量。
|
||||
|
||||
在声明一个通道类型变量的时候,我们首先要确定该通道类型的元素类型,这决定了我们可以通过这个通道传递什么类型的数据。
|
||||
|
||||
比如,类型字面量`chan int`,其中的`chan`是表示通道类型的关键字,而`int`则说明了该通道类型的元素类型。又比如,`chan string`代表了一个元素类型为`string`的通道类型。
|
||||
|
||||
在初始化通道的时候,`make`函数除了必须接收这样的类型字面量作为参数,还可以接收一个`int`类型的参数。
|
||||
|
||||
后者是可选的,用于表示该通道的容量。所谓通道的容量,就是指通道最多可以缓存多少个元素值。由此,虽然这个参数是`int`类型的,但是它是不能小于`0`的。
|
||||
|
||||
当容量为`0`时,我们可以称通道为非缓冲通道,也就是不带缓冲的通道。而当容量大于`0`时,我们可以称为缓冲通道,也就是带有缓冲的通道。非缓冲通道和缓冲通道有着不同的数据传递方式,这个我在后面会讲到。
|
||||
|
||||
**一个通道相当于一个先进先出(FIFO)的队列。也就是说,通道中的各个元素值都是严格地按照发送的顺序排列的,先被发送通道的元素值一定会先被接收。元素值的发送和接收都需要用到操作符`<-`。我们也可以叫它接送操作符。一个左尖括号紧接着一个减号形象地代表了元素值的传输方向。**
|
||||
|
||||
```
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func main() {
|
||||
ch1 := make(chan int, 3)
|
||||
ch1 <- 2
|
||||
ch1 <- 1
|
||||
ch1 <- 3
|
||||
elem1 := <-ch1
|
||||
fmt.Printf("The first element received from channel ch1: %v\n",
|
||||
elem1)
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在demo20.go文件中,我声明并初始化了一个元素类型为`int`、容量为`3`的通道`ch1`,并用三条语句,向该通道先后发送了三个元素值`2`、`1`和`3`。
|
||||
|
||||
这里的语句需要这样写:依次敲入通道变量的名称(比如`ch1`)、接送操作符`<-`以及想要发送的元素值(比如`2`),并且这三者之间最好用空格进行分割。
|
||||
|
||||
这显然表达了“这个元素值将被发送该通道”这个语义。由于该通道的容量为3,所以,我可以在通道不包含任何元素值的时候,连续地向该通道发送三个值,此时这三个值都会被缓存在通道之中。
|
||||
|
||||
当我们需要从通道接收元素值的时候,同样要用接送操作符`<-`,只不过,这时需要把它写在变量名的左边,用于表达“要从该通道接收一个元素值”的语义。
|
||||
|
||||
比如:`<-ch1`,这也可以被叫做接收表达式。在一般情况下,接收表达式的结果将会是通道中的一个元素值。
|
||||
|
||||
如果我们需要把如此得来的元素值存起来,那么在接收表达式的左边就需要依次添加赋值符号(`=`或`:=`)和用于存值的变量的名字。因此,语句`elem1 := <-ch1`会将最先进入`ch1`的元素`2`接收来并存入变量`elem1`。
|
||||
|
||||
现在我们来看一道与此有关的题目。**今天的问题是:对通道的发送和接收操作都有哪些基本的特性?**
|
||||
|
||||
这个问题的背后隐藏着很多的知识点,**我们来看一下典型回答**。
|
||||
|
||||
它们的基本特性如下。
|
||||
|
||||
1. 对于同一个通道,发送操作之间是互斥的,接收操作之间也是互斥的。
|
||||
1. 发送操作和接收操作中对元素值的处理都是不可分割的。
|
||||
1. 发送操作在完全完成之前会被阻塞。接收操作也是如此。
|
||||
|
||||
## 问题解析
|
||||
|
||||
**我们先来看第一个基本特性。** 在同一时刻,Go语言的运行时系统(以下简称运行时系统)只会执行对同一个通道的任意个发送操作中的某一个。
|
||||
|
||||
直到这个元素值被完全复制进该通道之后,其他针对该通道的发送操作才可能被执行。
|
||||
|
||||
类似的,在同一时刻,运行时系统也只会执行,对同一个通道的任意个接收操作中的某一个。
|
||||
|
||||
直到这个元素值完全被移出该通道之后,其他针对该通道的接收操作才可能被执行。即使这些操作是并发执行的也是如此。
|
||||
|
||||
这里所谓的并发执行,你可以这样认为,多个代码块分别在不同的goroutine之中,并有机会在同一个时间段内被执行。
|
||||
|
||||
另外,对于通道中的同一个元素值来说,发送操作和接收操作之间也是互斥的。例如,虽然会出现,正在被复制进通道但还未复制完成的元素值,但是这时它绝不会被想接收它的一方看到和取走。
|
||||
|
||||
**这里要注意的一个细节是,元素值从外界进入通道时会被复制。更具体地说,进入通道的并不是在接收操作符右边的那个元素值,而是它的副本。**
|
||||
|
||||
另一方面,元素值从通道进入外界时会被移动。这个移动操作实际上包含了两步,第一步是生成正在通道中的这个元素值的副本,并准备给到接收方,第二步是删除在通道中的这个元素值。
|
||||
|
||||
**顺着这个细节再来看第二个基本特性。** 这里的“不可分割”的意思是,它们处理元素值时都是一气呵成的,绝不会被打断。
|
||||
|
||||
例如,发送操作要么还没复制元素值,要么已经复制完毕,绝不会出现只复制了一部分的情况。
|
||||
|
||||
又例如,接收操作在准备好元素值的副本之后,一定会删除掉通道中的原值,绝不会出现通道中仍有残留的情况。
|
||||
|
||||
这既是为了保证通道中元素值的完整性,也是为了保证通道操作的唯一性。对于通道中的同一个元素值来说,它只可能是某一个发送操作放入的,同时也只可能被某一个接收操作取出。
|
||||
|
||||
**再来说第三个基本特性。** 一般情况下,发送操作包括了“复制元素值”和“放置副本到通道内部”这两个步骤。
|
||||
|
||||
在这两个步骤完全完成之前,发起这个发送操作的那句代码会一直阻塞在那里。也就是说,在它之后的代码不会有执行的机会,直到这句代码的阻塞解除。
|
||||
|
||||
更细致地说,在通道完成发送操作之后,运行时系统会通知这句代码所在的goroutine,以使它去争取继续运行代码的机会。
|
||||
|
||||
另外,接收操作通常包含了“复制通道内的元素值”“放置副本到接收方”“删掉原值”三个步骤。
|
||||
|
||||
在所有这些步骤完全完成之前,发起该操作的代码也会一直阻塞,直到该代码所在的goroutine收到了运行时系统的通知并重新获得运行机会为止。
|
||||
|
||||
说到这里,你可能已经感觉到,**如此阻塞代码其实就是为了实现操作的互斥和元素值的完整。**
|
||||
|
||||
下面我来说一个关于通道操作阻塞的问题。
|
||||
|
||||
## 知识扩展
|
||||
|
||||
**问题1:发送操作和接收操作在什么时候可能被长时间的阻塞?**
|
||||
|
||||
先说针对**缓冲通道**的情况。如果通道已满,那么对它的所有发送操作都会被阻塞,直到通道中有元素值被接收走。
|
||||
|
||||
这时,通道会优先通知最早因此而等待的、那个发送操作所在的goroutine,后者会再次执行发送操作。
|
||||
|
||||
由于发送操作在这种情况下被阻塞后,它们所在的goroutine会顺序地进入通道内部的发送等待队列,所以通知的顺序总是公平的。
|
||||
|
||||
相对的,如果通道已空,那么对它的所有接收操作都会被阻塞,直到通道中有新的元素值出现。这时,通道会通知最早等待的那个接收操作所在的goroutine,并使它再次执行接收操作。
|
||||
|
||||
因此而等待的、所有接收操作所在的goroutine,都会按照先后顺序被放入通道内部的接收等待队列。
|
||||
|
||||
对于**非缓冲通道**,情况要简单一些。无论是发送操作还是接收操作,一开始执行就会被阻塞,直到配对的操作也开始执行,才会继续传递。由此可见,非缓冲通道是在用同步的方式传递数据。也就是说,只有收发双方对接上了,数据才会被传递。
|
||||
|
||||
并且,数据是直接从发送方复制到接收方的,中间并不会用非缓冲通道做中转。相比之下,缓冲通道则在用异步的方式传递数据。
|
||||
|
||||
在大多数情况下,缓冲通道会作为收发双方的中间件。正如前文所述,元素值会先从发送方复制到缓冲通道,之后再由缓冲通道复制给接收方。
|
||||
|
||||
但是,当发送操作在执行的时候发现空的通道中,正好有等待的接收操作,那么它会直接把元素值复制给接收方。
|
||||
|
||||
以上说的都是在正确使用通道的前提下会发生的事情。下面我特别说明一下,由于错误使用通道而造成的阻塞。
|
||||
|
||||
对于值为`nil`的通道,不论它的具体类型是什么,对它的发送操作和接收操作都会永久地处于阻塞状态。它们所属的goroutine中的任何代码,都不再会被执行。
|
||||
|
||||
注意,由于通道类型是引用类型,所以它的零值就是`nil`。换句话说,当我们只声明该类型的变量但没有用`make`函数对它进行初始化时,该变量的值就会是`nil`。我们一定不要忘记初始化通道!
|
||||
|
||||
你可以去看一下demo21.go,我在里面用代码罗列了一下会造成阻塞的几种情况。
|
||||
|
||||
**问题2:发送操作和接收操作在什么时候会引发panic?**
|
||||
|
||||
对于一个已初始化,但并未关闭的通道来说,收发操作一定不会引发panic。但是通道一旦关闭,再对它进行发送操作,就会引发panic。
|
||||
|
||||
另外,如果我们试图关闭一个已经关闭了的通道,也会引发panic。注意,接收操作是可以感知到通道的关闭的,并能够安全退出。
|
||||
|
||||
更具体地说,当我们把接收表达式的结果同时赋给两个变量时,第二个变量的类型就是一定`bool`类型。它的值如果为`false`就说明通道已经关闭,并且再没有元素值可取了。
|
||||
|
||||
注意,如果通道关闭时,里面还有元素值未被取出,那么接收表达式的第一个结果,仍会是通道中的某一个元素值,而第二个结果值一定会是`true`。
|
||||
|
||||
因此,通过接收表达式的第二个结果值,来判断通道是否关闭是可能有延时的。
|
||||
|
||||
由于通道的收发操作有上述特性,所以除非有特殊的保障措施,我们千万不要让接收方关闭通道,而应当让发送方做这件事。这在demo22.go中有一个简单的模式可供参考。
|
||||
|
||||
## 总结
|
||||
|
||||
今天我们讲到了通道的一些常规操作,包括初始化、发送、接收和关闭。通道类型是Go语言特有的,所以你一开始肯定会感到陌生,其中的一些规则和奥妙还需要你铭记于心,并细心体会。
|
||||
|
||||
首先是在初始化通道时设定其容量的意义,这有时会让通道拥有不同的行为模式。对通道的发送操作和接收操作都有哪些基本特性,也是我们必须清楚的。
|
||||
|
||||
这涉及了它们什么时候会互斥,什么时候会造成阻塞,什么时候会引起panic,以及它们收发元素值的顺序是怎样的,它们是怎样保证元素值的完整性的,元素值通常会被复制几次,等等。
|
||||
|
||||
最后别忘了,通道也是Go语言的并发编程模式中重要的一员。
|
||||
|
||||
## 思考题
|
||||
|
||||
我希望你能通过试验获得下述问题的答案。
|
||||
|
||||
1. 通道的长度代表着什么?它在什么时候会通道的容量相同?
|
||||
1. 元素值在经过通道传递时会被复制,那么这个复制是浅表复制还是深层复制呢?
|
||||
|
||||
[戳此查看Go语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers)
|
||||
|
||||
|
||||
227
极客时间专栏/Go语言核心36讲/模块二:Go语言进阶技术/11 | 通道的高级玩法.md
Normal file
227
极客时间专栏/Go语言核心36讲/模块二:Go语言进阶技术/11 | 通道的高级玩法.md
Normal file
@@ -0,0 +1,227 @@
|
||||
<audio id="audio" title="11 | 通道的高级玩法" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/31/87/31d7a93abbd97e31097763d11ef2fa87.mp3"></audio>
|
||||
|
||||
我们已经讨论过了通道的基本操作以及背后的规则。今天,我再来讲讲通道的高级玩法。
|
||||
|
||||
首先来说说单向通道。我们在说“通道”的时候指的都是双向通道,即:既可以发也可以收的通道。
|
||||
|
||||
所谓单向通道就是,只能发不能收,或者只能收不能发的通道。一个通道是双向的,还是单向的是由它的类型字面量体现的。
|
||||
|
||||
还记得我们在上篇文章中说过的接收操作符`<-`吗?如果我们把它用在通道的类型字面量中,那么它代表的就不是“发送”或“接收”的动作了,而是表示通道的方向。
|
||||
|
||||
比如:
|
||||
|
||||
```
|
||||
var uselessChan = make(chan<- int, 1)
|
||||
|
||||
```
|
||||
|
||||
我声明并初始化了一个名叫`uselessChan`的变量。这个变量的类型是`chan<- int`,容量是`1`。
|
||||
|
||||
请注意紧挨在关键字`chan`右边的那个`<-`,这表示了这个通道是单向的,并且只能发而不能收。
|
||||
|
||||
类似的,如果这个操作符紧挨在`chan`的左边,那么就说明该通道只能收不能发。所以,前者可以被简称为发送通道,后者可以被简称为接收通道。
|
||||
|
||||
注意,与发送操作和接收操作对应,这里的“发”和“收”都是站在操作通道的代码的角度上说的。
|
||||
|
||||
从上述变量的名字上你也能猜到,这样的通道是没用的。通道就是为了传递数据而存在的,声明一个只有一端(发送端或者接收端)能用的通道没有任何意义。那么,单向通道的用途究竟在哪儿呢?
|
||||
|
||||
**问题:单向通道有什么应用价值?**
|
||||
|
||||
你可以先自己想想,然后再接着往下看。
|
||||
|
||||
**典型回答**
|
||||
|
||||
概括地说,单向通道最主要的用途就是约束其他代码的行为。
|
||||
|
||||
**问题解析**
|
||||
|
||||
这需要从两个方面讲,都跟函数的声明有些关系。先来看下面的代码:
|
||||
|
||||
```
|
||||
func SendInt(ch chan<- int) {
|
||||
ch <- rand.Intn(1000)
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我用`func`关键字声明了一个叫做`SendInt`的函数。这个函数只接受一个`chan<- int`类型的参数。在这个函数中的代码只能向参数`ch`发送元素值,而不能从它那里接收元素值。这就起到了约束函数行为的作用。
|
||||
|
||||
你可能会问,我自己写的函数自己肯定能确定操作通道的方式,为什么还要再约束?好吧,这个例子可能过于简单了。在实际场景中,这种约束一般会出现在接口类型声明中的某个方法定义上。请看这个叫`Notifier`的接口类型声明:
|
||||
|
||||
```
|
||||
type Notifier interface {
|
||||
SendInt(ch chan<- int)
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在接口类型声明的花括号中,每一行都代表着一个方法的定义。接口中的方法定义与函数声明很类似,但是只包含了方法名称、参数列表和结果列表。
|
||||
|
||||
一个类型如果想成为一个接口类型的实现类型,那么就必须实现这个接口中定义的所有方法。因此,如果我们在某个方法的定义中使用了单向通道类型,那么就相当于在对它的所有实现做出约束。
|
||||
|
||||
在这里,`Notifier`接口中的`SendInt`方法只会接受一个发送通道作为参数,所以,在该接口的所有实现类型中的`SendInt`方法都会受到限制。这种约束方式还是很有用的,尤其是在我们编写模板代码或者可扩展的程序库的时候。
|
||||
|
||||
顺便说一下,我们在调用`SendInt`函数的时候,只需要把一个元素类型匹配的双向通道传给它就行了,没必要用发送通道,因为Go语言在这种情况下会自动地把双向通道转换为函数所需的单向通道。
|
||||
|
||||
```
|
||||
intChan1 := make(chan int, 3)
|
||||
SendInt(intChan1)
|
||||
|
||||
```
|
||||
|
||||
在另一个方面,我们还可以在函数声明的结果列表中使用单向通道。如下所示:
|
||||
|
||||
```
|
||||
func getIntChan() <-chan int {
|
||||
num := 5
|
||||
ch := make(chan int, num)
|
||||
for i := 0; i < num; i++ {
|
||||
ch <- i
|
||||
}
|
||||
close(ch)
|
||||
return ch
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
函数`getIntChan`会返回一个`<-chan int`类型的通道,这就意味着得到该通道的程序,只能从通道中接收元素值。这实际上就是对函数调用方的一种约束了。
|
||||
|
||||
另外,我们在Go语言中还可以声明函数类型,如果我们在函数类型中使用了单向通道,那么就相等于在约束所有实现了这个函数类型的函数。
|
||||
|
||||
我们再顺便看一下调用`getIntChan`的代码:
|
||||
|
||||
```
|
||||
intChan2 := getIntChan()
|
||||
for elem := range intChan2 {
|
||||
fmt.Printf("The element in intChan2: %v\n", elem)
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我把调用`getIntChan`得到的结果值赋给了变量`intChan2`,然后用`for`语句循环地取出了该通道中的所有元素值,并打印出来。
|
||||
|
||||
这里的`for`语句也可以被称为带有`range`子句的`for`语句。它的用法我在后面讲`for`语句的时候专门说明。现在你只需要知道关于它的三件事:
|
||||
|
||||
1. 上述`for`语句会不断地尝试从通道`intChan2`中取出元素值。即使`intChan2`已经被关闭了,它也会在取出所有剩余的元素值之后再结束执行。
|
||||
1. 通常,当通道`intChan2`中没有元素值时,这条`for`语句会被阻塞在有`for`关键字的那一行,直到有新的元素值可取。不过,由于这里的`getIntChan`函数会事先将`intChan2`关闭,所以它在取出`intChan2`中的所有元素值之后会直接结束执行。
|
||||
1. 倘若通道`intChan2`的值为`nil`,那么这条`for`语句就会被永远地阻塞在有`for`关键字的那一行。
|
||||
|
||||
这就是带`range`子句的`for`语句与通道的联用方式。不过,它是一种用途比较广泛的语句,还可以被用来从其他一些类型的值中获取元素。除此之外,Go语言还有一种专门为了操作通道而存在的语句:`select`语句。
|
||||
|
||||
**知识扩展**
|
||||
|
||||
**问题1:`select`语句与通道怎样联用,应该注意些什么?**
|
||||
|
||||
`select`语句只能与通道联用,它一般由若干个分支组成。每次执行这种语句的时候,一般只有一个分支中的代码会被运行。
|
||||
|
||||
`select`语句的分支分为两种,一种叫做候选分支,另一种叫做默认分支。候选分支总是以关键字`case`开头,后跟一个`case`表达式和一个冒号,然后我们可以从下一行开始写入当分支被选中时需要执行的语句。
|
||||
|
||||
默认分支其实就是default case,因为,当且仅当没有候选分支被选中时它才会被执行,所以它以关键字`default`开头并直接后跟一个冒号。同样的,我们可以在`default:`的下一行写入要执行的语句。
|
||||
|
||||
由于`select`语句是专为通道而设计的,所以每个`case`表达式中都只能包含操作通道的表达式,比如接收表达式。
|
||||
|
||||
当然,如果我们需要把接收表达式的结果赋给变量的话,还可以把这里写成赋值语句或者短变量声明。下面展示一个简单的例子。
|
||||
|
||||
```
|
||||
// 准备好几个通道。
|
||||
intChannels := [3]chan int{
|
||||
make(chan int, 1),
|
||||
make(chan int, 1),
|
||||
make(chan int, 1),
|
||||
}
|
||||
// 随机选择一个通道,并向它发送元素值。
|
||||
index := rand.Intn(3)
|
||||
fmt.Printf("The index: %d\n", index)
|
||||
intChannels[index] <- index
|
||||
// 哪一个通道中有可取的元素值,哪个对应的分支就会被执行。
|
||||
select {
|
||||
case <-intChannels[0]:
|
||||
fmt.Println("The first candidate case is selected.")
|
||||
case <-intChannels[1]:
|
||||
fmt.Println("The second candidate case is selected.")
|
||||
case elem := <-intChannels[2]:
|
||||
fmt.Printf("The third candidate case is selected, the element is %d.\n", elem)
|
||||
default:
|
||||
fmt.Println("No candidate case is selected!")
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我先准备好了三个类型为`chan int`、容量为`1`的通道,并把它们存入了一个叫做`intChannels`的数组。
|
||||
|
||||
然后,我随机选择一个范围在[0, 2]的整数,把它作为索引在上述数组中选择一个通道,并向其中发送一个元素值。
|
||||
|
||||
最后,我用一个包含了三个候选分支的`select`语句,分别尝试从上述三个通道中接收元素值,哪一个通道中有值,哪一个对应的候选分支就会被执行。后面还有一个默认分支,不过在这里它是不可能被选中的。
|
||||
|
||||
在使用`select`语句的时候,我们首先需要注意下面几个事情。
|
||||
|
||||
1. 如果像上述示例那样加入了默认分支,那么无论涉及通道操作的表达式是否有阻塞,`select`语句都不会被阻塞。如果那几个表达式都阻塞了,或者说都没有满足求值的条件,那么默认分支就会被选中并执行。
|
||||
1. 如果没有加入默认分支,那么一旦所有的`case`表达式都没有满足求值条件,那么`select`语句就会被阻塞。直到至少有一个`case`表达式满足条件为止。
|
||||
1. 还记得吗?我们可能会因为通道关闭了,而直接从通道接收到一个其元素类型的零值。所以,在很多时候,我们需要通过接收表达式的第二个结果值来判断通道是否已经关闭。一旦发现某个通道关闭了,我们就应该及时地屏蔽掉对应的分支或者采取其他措施。这对于程序逻辑和程序性能都是有好处的。
|
||||
1. `select`语句只能对其中的每一个`case`表达式各求值一次。所以,如果我们想连续或定时地操作其中的通道的话,就往往需要通过在`for`语句中嵌入`select`语句的方式实现。但这时要注意,简单地在`select`语句的分支中使用`break`语句,只能结束当前的`select`语句的执行,而并不会对外层的`for`语句产生作用。这种错误的用法可能会让这个`for`语句无休止地运行下去。
|
||||
|
||||
下面是一个简单的示例。
|
||||
|
||||
```
|
||||
intChan := make(chan int, 1)
|
||||
// 一秒后关闭通道。
|
||||
time.AfterFunc(time.Second, func() {
|
||||
close(intChan)
|
||||
})
|
||||
select {
|
||||
case _, ok := <-intChan:
|
||||
if !ok {
|
||||
fmt.Println("The candidate case is closed.")
|
||||
break
|
||||
}
|
||||
fmt.Println("The candidate case is selected.")
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我先声明并初始化了一个叫做`intChan`的通道,然后通过`time`包中的`AfterFunc`函数约定在一秒钟之后关闭该通道。
|
||||
|
||||
后面的`select`语句只有一个候选分支,我在其中利用接收表达式的第二个结果值对`intChan`通道是否已关闭做了判断,并在得到肯定结果后,通过`break`语句立即结束当前`select`语句的执行。
|
||||
|
||||
这个例子以及前面那个例子都可以在demo24.go文件中被找到。你应该运行下,看看结果如何。
|
||||
|
||||
上面这些注意事项中的一部分涉及到了`select`语句的分支选择规则。我觉得很有必要再专门整理和总结一下这些规则。
|
||||
|
||||
**问题2:`select`语句的分支选择规则都有哪些?**
|
||||
|
||||
规则如下面所示。
|
||||
|
||||
<li>对于每一个`case`表达式,都至少会包含一个代表发送操作的发送表达式或者一个代表接收操作的接收表达式,同时也可能会包含其他的表达式。比如,如果`case`表达式是包含了接收表达式的短变量声明时,那么在赋值符号左边的就可以是一个或两个表达式,不过此处的表达式的结果必须是可以被赋值的。当这样的`case`表达式被求值时,它包含的多个表达式总会以从左到右的顺序被求值。<br>
|
||||
<br></li>
|
||||
<li>`select`语句包含的候选分支中的`case`表达式都会在该语句执行开始时先被求值,并且求值的顺序是依从代码编写的顺序从上到下的。结合上一条规则,在`select`语句开始执行时,排在最上边的候选分支中最左边的表达式会最先被求值,然后是它右边的表达式。仅当最上边的候选分支中的所有表达式都被求值完毕后,从上边数第二个候选分支中的表达式才会被求值,顺序同样是从左到右,然后是第三个候选分支、第四个候选分支,以此类推。<br>
|
||||
<br></li>
|
||||
<li>对于每一个`case`表达式,如果其中的发送表达式或者接收表达式在被求值时,相应的操作正处于阻塞状态,那么对该`case`表达式的求值就是不成功的。在这种情况下,我们可以说,这个`case`表达式所在的候选分支是不满足选择条件的。<br>
|
||||
<br></li>
|
||||
<li>仅当`select`语句中的所有`case`表达式都被求值完毕后,它才会开始选择候选分支。这时候,它只会挑选满足选择条件的候选分支执行。如果所有的候选分支都不满足选择条件,那么默认分支就会被执行。如果这时没有默认分支,那么`select`语句就会立即进入阻塞状态,直到至少有一个候选分支满足选择条件为止。一旦有一个候选分支满足选择条件,`select`语句(或者说它所在的goroutine)就会被唤醒,这个候选分支就会被执行。<br>
|
||||
<br></li>
|
||||
<li>如果`select`语句发现同时有多个候选分支满足选择条件,那么它就会用一种伪随机的算法在这些分支中选择一个并执行。注意,即使`select`语句是在被唤醒时发现的这种情况,也会这样做。<br>
|
||||
<br></li>
|
||||
<li>一条`select`语句中只能够有一个默认分支。并且,默认分支只在无候选分支可选时才会被执行,这与它的编写位置无关。<br>
|
||||
<br></li>
|
||||
1. `select`语句的每次执行,包括`case`表达式求值和分支选择,都是独立的。不过,至于它的执行是否是并发安全的,就要看其中的`case`表达式以及分支中,是否包含并发不安全的代码了。
|
||||
|
||||
我把与以上规则相关的示例放在demo25.go文件中了。你一定要去试运行一下,然后尝试用上面的规则去解释它的输出内容。
|
||||
|
||||
**总结**
|
||||
|
||||
今天,我们先讲了单向通道的表示方法,操作符“`<-`”仍然是关键。如果只用一个词来概括单向通道存在的意义的话,那就是“约束”,也就是对代码的约束。
|
||||
|
||||
我们可以使用带`range`子句的`for`语句从通道中获取数据,也可以通过`select`语句操纵通道。
|
||||
|
||||
`select`语句是专门为通道而设计的,它可以包含若干个候选分支,每个分支中的`case`表达式都会包含针对某个通道的发送或接收操作。
|
||||
|
||||
当`select`语句被执行时,它会根据一套**分支选择规则**选中某一个分支并执行其中的代码。如果所有的候选分支都没有被选中,那么默认分支(如果有的话)就会被执行。注意,发送和接收操作的阻塞是分支选择规则的一个很重要的依据。
|
||||
|
||||
**思考题**
|
||||
|
||||
今天的思考题都由上述内容中的线索延伸而来。
|
||||
|
||||
1. 如果在`select`语句中发现某个通道已关闭,那么应该怎样屏蔽掉它所在的分支?
|
||||
1. 在`select`语句与`for`语句联用时,怎样直接退出外层的`for`语句?
|
||||
|
||||
[戳此查看Go语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers)
|
||||
263
极客时间专栏/Go语言核心36讲/模块二:Go语言进阶技术/12 | 使用函数的正确姿势.md
Normal file
263
极客时间专栏/Go语言核心36讲/模块二:Go语言进阶技术/12 | 使用函数的正确姿势.md
Normal file
@@ -0,0 +1,263 @@
|
||||
<audio id="audio" title="12 | 使用函数的正确姿势" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/39/6a/39abcabb32dc7dc48fda652f56b32f6a.mp3"></audio>
|
||||
|
||||
在前几期文章中,我们分了几次,把Go语言自身提供的,所有集合类的数据类型都讲了一遍,额外还讲了标准库的`container`包中的几个类型。
|
||||
|
||||
在几乎所有主流的编程语言中,集合类的数据类型都是最常用和最重要的。我希望通过这几次的讨论,能让你对它们的运用更上一层楼。
|
||||
|
||||
从今天开始,我会开始向你介绍使用Go语言进行模块化编程时,必须了解的知识,这包括几个重要的数据类型以及一些模块化编程的技巧。首先我们需要了解的是Go语言的函数以及函数类型。
|
||||
|
||||
### 前导内容:函数是一等的公民
|
||||
|
||||
在Go语言中,函数可是一等的(first-class)公民,函数类型也是一等的数据类型。这是什么意思呢?
|
||||
|
||||
简单来说,这意味着函数不但可以用于封装代码、分割功能、解耦逻辑,还可以化身为普通的值,在其他函数间传递、赋予变量、做类型判断和转换等等,就像切片和字典的值那样。
|
||||
|
||||
而更深层次的含义就是:函数值可以由此成为能够被随意传播的独立逻辑组件(或者说功能模块)。
|
||||
|
||||
对于函数类型来说,它是一种对一组输入、输出进行模板化的重要工具,它比接口类型更加轻巧、灵活,它的值也借此变成了可被热替换的逻辑组件。比如,我在demo26.go文件中是这样写的:
|
||||
|
||||
```
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
type Printer func(contents string) (n int, err error)
|
||||
|
||||
func printToStd(contents string) (bytesNum int, err error) {
|
||||
return fmt.Println(contents)
|
||||
}
|
||||
|
||||
func main() {
|
||||
var p Printer
|
||||
p = printToStd
|
||||
p("something")
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这里,我先声明了一个函数类型,名叫`Printer`。
|
||||
|
||||
注意这里的写法,在类型声明的名称右边的是`func`关键字,我们由此就可知道这是一个函数类型的声明。
|
||||
|
||||
在`func`右边的就是这个函数类型的参数列表和结果列表。其中,参数列表必须由圆括号包裹,而只要结果列表中只有一个结果声明,并且没有为它命名,我们就可以省略掉外围的圆括号。
|
||||
|
||||
书写函数签名的方式与函数声明的是一致的。只是紧挨在参数列表左边的不是函数名称,而是关键字`func`。这里函数名称和`func`互换了一下位置而已。
|
||||
|
||||
>
|
||||
函数的签名其实就是函数的参数列表和结果列表的统称,它定义了可用来鉴别不同函数的那些特征,同时也定义了我们与函数交互的方式。
|
||||
|
||||
|
||||
注意,各个参数和结果的名称不能算作函数签名的一部分,甚至对于结果声明来说,没有名称都可以。
|
||||
|
||||
只要两个函数的参数列表和结果列表中的元素顺序及其类型是一致的,我们就可以说它们是一样的函数,或者说是实现了同一个函数类型的函数。
|
||||
|
||||
严格来说,函数的名称也不能算作函数签名的一部分,它只是我们在调用函数时,需要给定的标识符而已。
|
||||
|
||||
我在下面声明的函数`printToStd`的签名与`Printer`的是一致的,因此前者是后者的一个实现,即使它们的名称以及有的结果名称是不同的。
|
||||
|
||||
通过`main`函数中的代码,我们就可以证实这两者的关系了,我顺利地把`printToStd`函数赋给了`Printer`类型的变量`p`,并且成功地调用了它。
|
||||
|
||||
总之,“函数是一等的公民”是函数式编程(functional programming)的重要特征。Go语言在语言层面支持了函数式编程。我们下面的问题就与此有关。
|
||||
|
||||
**今天的问题是:怎样编写高阶函数?**
|
||||
|
||||
先来说说什么是高阶函数?简单地说,高阶函数可以满足下面的两个条件:
|
||||
|
||||
**1. 接受其他的函数作为参数传入;**<br>
|
||||
**2. 把其他的函数作为结果返回。**
|
||||
|
||||
只要满足了其中任意一个特点,我们就可以说这个函数是一个高阶函数。高阶函数也是函数式编程中的重要概念和特征。
|
||||
|
||||
具体的问题是,我想通过编写`calculate`函数来实现两个整数间的加减乘除运算,但是希望两个整数和具体的操作都由该函数的调用方给出,那么,这样一个函数应该怎样编写呢。
|
||||
|
||||
**典型回答**
|
||||
|
||||
首先,我们来声明一个名叫`operate`的函数类型,它有两个参数和一个结果,都是`int`类型的。
|
||||
|
||||
```
|
||||
type operate func(x, y int) int
|
||||
|
||||
```
|
||||
|
||||
然后,我们编写`calculate`函数的签名部分。这个函数除了需要两个`int`类型的参数之外,还应该有一个`operate`类型的参数。
|
||||
|
||||
该函数的结果应该有两个,一个是`int`类型的,代表真正的操作结果,另一个应该是`error`类型的,因为如果那个`operate`类型的参数值为`nil`,那么就应该直接返回一个错误。
|
||||
|
||||
>
|
||||
顺便说一下,函数类型属于引用类型,它的值可以为`nil`,而这种类型的零值恰恰就是`nil`。
|
||||
|
||||
|
||||
```
|
||||
func calculate(x int, y int, op operate) (int, error) {
|
||||
if op == nil {
|
||||
return 0, errors.New("invalid operation")
|
||||
}
|
||||
return op(x, y), nil
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
`calculate`函数实现起来就很简单了。我们需要先用卫述语句检查一下参数,如果`operate`类型的参数`op`为`nil`,那么就直接返回`0`和一个代表了具体错误的`error`类型值。
|
||||
|
||||
>
|
||||
卫述语句是指被用来检查关键的先决条件的合法性,并在检查未通过的情况下立即终止当前代码块执行的语句。在Go语言中,if 语句常被作为卫述语句。
|
||||
|
||||
|
||||
如果检查无误,那么就调用`op`并把那两个操作数传给它,最后返回`op`返回的结果和代表没有错误发生的`nil`。
|
||||
|
||||
**问题解析**
|
||||
|
||||
其实只要你搞懂了“函数是一等的公民”这句话背后的含义,这道题就会很简单。我在上面已经讲过了,希望你已经清楚了。我在上一个例子中展示了其中一点,即:把函数作为一个普通的值赋给一个变量。
|
||||
|
||||
在这道题中,我问的其实是怎样实现另一点,即:让函数在其他函数间传递。
|
||||
|
||||
在答案中,`calculate`函数的其中一个参数是`operate`类型的,而且后者就是一个函数类型。在调用`calculate`函数的时候,我们需要传入一个`operate`类型的函数值。这个函数值应该怎么写?
|
||||
|
||||
只要它的签名与`operate`类型的签名一致,并且实现得当就可以了。我们可以像上一个例子那样先声明好一个函数,再把它赋给一个变量,也可以直接编写一个实现了`operate`类型的匿名函数。
|
||||
|
||||
```
|
||||
op := func(x, y int) int {
|
||||
return x + y
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
`calculate`函数就是一个高阶函数。但是我们说高阶函数的特点有两个,而该函数只展示了其中**一个特点,即:接受其他的函数作为参数传入。**
|
||||
|
||||
**那另一个特点,把其他的函数作为结果返回。**这又是怎么玩的呢?你可以看看我在demo27.go文件中声明的函数类型`calculateFunc`和函数`genCalculator`。其中,`genCalculator`函数的唯一结果的类型就是`calculateFunc`。
|
||||
|
||||
这里先给出使用它们的代码。
|
||||
|
||||
```
|
||||
x, y = 56, 78
|
||||
add := genCalculator(op)
|
||||
result, err = add(x, y)
|
||||
fmt.Printf("The result: %d (error: %v)\n", result, err)
|
||||
|
||||
```
|
||||
|
||||
你可以自己写出`calculateFunc`类型和`genCalculator`函数的实现吗?你可以动手试一试
|
||||
|
||||
**知识扩展**
|
||||
|
||||
**问题1:如何实现闭包?**
|
||||
|
||||
闭包又是什么?你可以想象一下,在一个函数中存在对外来标识符的引用。所谓的外来标识符,既不代表当前函数的任何参数或结果,也不是函数内部声明的,它是直接从外边拿过来的。
|
||||
|
||||
还有个专门的术语称呼它,叫自由变量,可见它代表的肯定是个变量。实际上,如果它是个常量,那也就形成不了闭包了,因为常量是不可变的程序实体,而闭包体现的却是由“不确定”变为“确定”的一个过程。
|
||||
|
||||
我们说的这个函数(以下简称闭包函数)就是因为引用了自由变量,而呈现出了一种“不确定”的状态,也叫“开放”状态。
|
||||
|
||||
也就是说,它的内部逻辑并不是完整的,有一部分逻辑需要这个自由变量参与完成,而后者到底代表了什么在闭包函数被定义的时候却是未知的。
|
||||
|
||||
即使对于像Go语言这种静态类型的编程语言而言,我们在定义闭包函数的时候最多也只能知道自由变量的类型。
|
||||
|
||||
在我们刚刚提到的`genCalculator`函数内部,实际上就实现了一个闭包,而`genCalculator`函数也是一个高阶函数。
|
||||
|
||||
```
|
||||
func genCalculator(op operate) calculateFunc {
|
||||
return func(x int, y int) (int, error) {
|
||||
if op == nil {
|
||||
return 0, errors.New("invalid operation")
|
||||
}
|
||||
return op(x, y), nil
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
`genCalculator`函数只做了一件事,那就是定义一个匿名的、`calculateFunc`类型的函数并把它作为结果值返回。
|
||||
|
||||
而这个匿名的函数就是一个闭包函数。它里面使用的变量`op`既不代表它的任何参数或结果也不是它自己声明的,而是定义它的`genCalculator`函数的参数,所以是一个自由变量。
|
||||
|
||||
这个自由变量究竟代表了什么,这一点并不是在定义这个闭包函数的时候确定的,而是在`genCalculator`函数被调用的时候确定的。
|
||||
|
||||
只有给定了该函数的参数`op`,我们才能知道它返回给我们的闭包函数可以用于什么运算。
|
||||
|
||||
看到`if op == nil {`那一行了吗?Go语言编译器读到这里时会试图去寻找`op`所代表的东西,它会发现`op`代表的是`genCalculator`函数的参数,然后,它会把这两者联系起来。这时可以说,自由变量`op`被“捕获”了。
|
||||
|
||||
当程序运行到这里的时候,`op`就是那个参数值了。如此一来,这个闭包函数的状态就由“不确定”变为了“确定”,或者说转到了“闭合”状态,至此也就真正地形成了一个闭包。
|
||||
|
||||
看出来了吗?我们在用高阶函数实现闭包。这也是高阶函数的一大功用。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/61/08/61f3689a0023e83407ccae081cdd8108.png" alt="">
|
||||
|
||||
(高阶函数与闭包)
|
||||
|
||||
那么,实现闭包的意义又在哪里呢?表面上看,我们只是延迟实现了一部分程序逻辑或功能而已,但实际上,我们是在动态地生成那部分程序逻辑。
|
||||
|
||||
我们可以借此在程序运行的过程中,根据需要生成功能不同的函数,继而影响后续的程序行为。这与GoF设计模式中的“模板方法”模式有着异曲同工之妙,不是吗?
|
||||
|
||||
**问题2:传入函数的那些参数值后来怎么样了?**
|
||||
|
||||
让我们把目光再次聚焦到函数本身。我们先看一个示例。
|
||||
|
||||
```
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func main() {
|
||||
array1 := [3]string{"a", "b", "c"}
|
||||
fmt.Printf("The array: %v\n", array1)
|
||||
array2 := modifyArray(array1)
|
||||
fmt.Printf("The modified array: %v\n", array2)
|
||||
fmt.Printf("The original array: %v\n", array1)
|
||||
}
|
||||
|
||||
func modifyArray(a [3]string) [3]string {
|
||||
a[1] = "x"
|
||||
return a
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这个命令源码文件(也就是demo28.go)在运行之后会输出什么?这是我常出的一道考题。
|
||||
|
||||
我在`main`函数中声明了一个数组`array1`,然后把它传给了函数`modify`,`modify`对参数值稍作修改后将其作为结果值返回。`main`函数中的代码拿到这个结果之后打印了它(即`array2`),以及原来的数组`array1`。关键问题是,原数组会因`modify`函数对参数值的修改而改变吗?
|
||||
|
||||
答案是:原数组不会改变。为什么呢?原因是,所有传给函数的参数值都会被复制,函数在其内部使用的并不是参数值的原值,而是它的副本。
|
||||
|
||||
由于数组是值类型,所以每一次复制都会拷贝它,以及它的所有元素值。我在`modify`函数中修改的只是原数组的副本而已,并不会对原数组造成任何影响。
|
||||
|
||||
注意,对于引用类型,比如:切片、字典、通道,像上面那样复制它们的值,只会拷贝它们本身而已,并不会拷贝它们引用的底层数据。也就是说,这时只是浅表复制,而不是深层复制。
|
||||
|
||||
以切片值为例,如此复制的时候,只是拷贝了它指向底层数组中某一个元素的指针,以及它的长度值和容量值,而它的底层数组并不会被拷贝。
|
||||
|
||||
另外还要注意,就算我们传入函数的是一个值类型的参数值,但如果这个参数值中的某个元素是引用类型的,那么我们仍然要小心。
|
||||
|
||||
比如:
|
||||
|
||||
```
|
||||
complexArray1 := [3][]string{
|
||||
[]string{"d", "e", "f"},
|
||||
[]string{"g", "h", "i"},
|
||||
[]string{"j", "k", "l"},
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
变量`complexArray1`是`[3][]string`类型的,也就是说,虽然它是一个数组,但是其中的每个元素又都是一个切片。这样一个值被传入函数的话,函数中对该参数值的修改会影响到`complexArray1`本身吗?我想,这可以留作今天的思考题。
|
||||
|
||||
**总结**
|
||||
|
||||
我们今天主要聚焦于函数的使用手法。在Go语言中,函数可是一等的(first-class)公民。它既可以被独立声明,也可以被作为普通的值来传递或赋予变量。除此之外,我们还可以在其他函数的内部声明匿名函数并把它直接赋给变量。
|
||||
|
||||
你需要记住Go语言是怎样鉴别一个函数的,函数的签名在这里起到了至关重要的作用。
|
||||
|
||||
函数是Go语言支持函数式编程的主要体现。我们可以通过“把函数传给函数”以及“让函数返回函数”来编写高阶函数,也可以用高阶函数来实现闭包,并以此做到部分程序逻辑的动态生成。
|
||||
|
||||
我们在最后还说了一下关于函数传参的一个注意事项,这很重要,可能会关系到程序的稳定和安全。
|
||||
|
||||
一个相关的原则是:既不要把你程序的细节暴露给外界,也尽量不要让外界的变动影响到你的程序。你可以想想这个原则在这里可以起到怎样的指导作用。
|
||||
|
||||
**思考题**
|
||||
|
||||
今天我给你留下两道思考题。
|
||||
|
||||
1. `complexArray1`被传入函数的话,这个函数中对该参数值的修改会影响到它的原值吗?
|
||||
1. 函数真正拿到的参数值其实只是它们的副本,那么函数返回给调用方的结果值也会被复制吗?
|
||||
|
||||
[戳此查看Go语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers)
|
||||
|
||||
|
||||
252
极客时间专栏/Go语言核心36讲/模块二:Go语言进阶技术/13 | 结构体及其方法的使用法门.md
Normal file
252
极客时间专栏/Go语言核心36讲/模块二:Go语言进阶技术/13 | 结构体及其方法的使用法门.md
Normal file
@@ -0,0 +1,252 @@
|
||||
<audio id="audio" title="13 | 结构体及其方法的使用法门" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/be/86/be9b7768bad2dde8afcd696bfdf82186.mp3"></audio>
|
||||
|
||||
我们都知道,结构体类型表示的是实实在在的数据结构。一个结构体类型可以包含若干个字段,每个字段通常都需要有确切的名字和类型。
|
||||
|
||||
## 前导内容:结构体类型基础知识
|
||||
|
||||
当然了,结构体类型也可以不包含任何字段,这样并不是没有意义的,因为我们还可以为类型关联上一些方法,这里你可以把方法看做是函数的特殊版本。
|
||||
|
||||
函数是独立的程序实体。我们可以声明有名字的函数,也可以声明没名字的函数,还可以把它们当做普通的值传来传去。我们能把具有相同签名的函数抽象成独立的函数类型,以作为一组输入、输出(或者说一类逻辑组件)的代表。
|
||||
|
||||
方法却不同,它需要有名字,不能被当作值来看待,最重要的是,它必须隶属于某一个类型。方法所属的类型会通过其声明中的接收者(receiver)声明体现出来。
|
||||
|
||||
接收者声明就是在关键字`func`和方法名称之间的圆括号包裹起来的内容,其中必须包含确切的名称和类型字面量。
|
||||
|
||||
**接收者的类型其实就是当前方法所属的类型,而接收者的名称,则用于在当前方法中引用它所属的类型的当前值。**
|
||||
|
||||
我们举个例子来看一下。
|
||||
|
||||
```
|
||||
// AnimalCategory 代表动物分类学中的基本分类法。
|
||||
type AnimalCategory struct {
|
||||
kingdom string // 界。
|
||||
phylum string // 门。
|
||||
class string // 纲。
|
||||
order string // 目。
|
||||
family string // 科。
|
||||
genus string // 属。
|
||||
species string // 种。
|
||||
}
|
||||
|
||||
func (ac AnimalCategory) String() string {
|
||||
return fmt.Sprintf("%s%s%s%s%s%s%s",
|
||||
ac.kingdom, ac.phylum, ac.class, ac.order,
|
||||
ac.family, ac.genus, ac.species)
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
结构体类型`AnimalCategory`代表了动物的基本分类法,其中有7个`string`类型的字段,分别表示各个等级的分类。
|
||||
|
||||
下边有个名叫`String`的方法,从它的接收者声明可以看出它隶属于`AnimalCategory`类型。
|
||||
|
||||
通过该方法的接收者名称`ac`,我们可以在其中引用到当前值的任何一个字段,或者调用到当前值的任何一个方法(也包括`String`方法自己)。
|
||||
|
||||
这个`String`方法的功能是提供当前值的字符串表示形式,其中的各个等级分类会按照从大到小的顺序排列。使用时,我们可以这样表示:
|
||||
|
||||
```
|
||||
category := AnimalCategory{species: "cat"}
|
||||
fmt.Printf("The animal category: %s\n", category)
|
||||
|
||||
```
|
||||
|
||||
这里,我用字面量初始化了一个`AnimalCategory`类型的值,并把它赋给了变量`category`。为了不喧宾夺主,我只为其中的`species`字段指定了字符串值`"cat"`,该字段代表最末级分类“种”。
|
||||
|
||||
在Go语言中,我们可以通过为一个类型编写名为`String`的方法,来自定义该类型的字符串表示形式。这个`String`方法不需要任何参数声明,但需要有一个`string`类型的结果声明。
|
||||
|
||||
正因为如此,我在调用`fmt.Printf`函数时,使用占位符`%s`和`category`值本身就可以打印出后者的字符串表示形式,而无需显式地调用它的`String`方法。
|
||||
|
||||
`fmt.Printf`函数会自己去寻找它。此时的打印内容会是`The animal category: cat`。显而易见,`category`的`String`方法成功地引用了当前值的所有字段。
|
||||
|
||||
>
|
||||
方法隶属的类型其实并不局限于结构体类型,但必须是某个自定义的数据类型,并且不能是任何接口类型。
|
||||
一个数据类型关联的所有方法,共同组成了该类型的方法集合。同一个方法集合中的方法不能出现重名。并且,如果它们所属的是一个结构体类型,那么它们的名称与该类型中任何字段的名称也不能重复。
|
||||
我们可以把结构体类型中的一个字段看作是它的一个属性或者一项数据,再把隶属于它的一个方法看作是附加在其中数据之上的一个能力或者一项操作。将属性及其能力(或者说数据及其操作)封装在一起,是面向对象编程(object-oriented programming)的一个主要原则。
|
||||
Go语言摄取了面向对象编程中的很多优秀特性,同时也推荐这种封装的做法。从这方面看,Go语言其实是支持面向对象编程的,但它选择摒弃了一些在实际运用过程中容易引起程序开发者困惑的特性和规则。
|
||||
|
||||
|
||||
现在,让我们再把目光放到结构体类型的字段声明上。我们来看下面的代码:
|
||||
|
||||
```
|
||||
type Animal struct {
|
||||
scientificName string // 学名。
|
||||
AnimalCategory // 动物基本分类。
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我声明了一个结构体类型,名叫`Animal`。它有两个字段。一个是`string`类型的字段`scientificName`,代表了动物的学名。而另一个字段声明中只有`AnimalCategory`,它正是我在前面编写的那个结构体类型的名字。这是什么意思呢?
|
||||
|
||||
**那么,我们今天的问题是:`Animal`类型中的字段声明`AnimalCategory`代表了什么?**
|
||||
|
||||
更宽泛地讲,如果结构体类型的某个字段声明中只有一个类型名,那么该字段代表了什么?
|
||||
|
||||
**这个问题的典型回答是**:字段声明`AnimalCategory`代表了`Animal`类型的一个嵌入字段。Go语言规范规定,如果一个字段的声明中只有字段的类型名而没有字段的名称,那么它就是一个嵌入字段,也可以被称为匿名字段。我们可以通过此类型变量的名称后跟“.”,再后跟嵌入字段类型的方式引用到该字段。也就是说,嵌入字段的类型既是类型也是名称。
|
||||
|
||||
## 问题解析
|
||||
|
||||
说到引用结构体的嵌入字段,`Animal`类型有个方法叫`Category`,它是这么写的:
|
||||
|
||||
```
|
||||
func (a Animal) Category() string {
|
||||
return a.AnimalCategory.String()
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
`Category`方法的接收者类型是`Animal`,接收者名称是`a`。在该方法中,我通过表达式`a.AnimalCategory`选择到了`a`的这个嵌入字段,然后又选择了该字段的`String`方法并调用了它。
|
||||
|
||||
顺便提一下,在某个代表变量的标识符的右边加“.”,再加上字段名或方法名的表达式被称为选择表达式,它用来表示选择了该变量的某个字段或者方法。
|
||||
|
||||
这是Go语言规范中的说法,与“引用结构体的某某字段”或“调用结构体的某某方法”的说法是相通的。我在以后会混用这两种说法。
|
||||
|
||||
实际上,把一个结构体类型嵌入到另一个结构体类型中的意义不止如此。嵌入字段的方法集合会被无条件地合并进被嵌入类型的方法集合中。例如下面这种:
|
||||
|
||||
```
|
||||
animal := Animal{
|
||||
scientificName: "American Shorthair",
|
||||
AnimalCategory: category,
|
||||
}
|
||||
fmt.Printf("The animal: %s\n", animal)
|
||||
|
||||
```
|
||||
|
||||
我声明了一个`Animal`类型的变量`animal`并对它进行初始化。我把字符串值`"American Shorthair"`赋给它的字段`scientificName`,并把前面声明过的变量`category`赋给它的嵌入字段`AnimalCategory`。
|
||||
|
||||
我在后面使用`fmt.Printf`函数和`%s`占位符试图打印`animal`的字符串表示形式,相当于调用`animal`的`String`方法。虽然我们还没有为`Animal`类型编写`String`方法,但这样做是没问题的。因为在这里,嵌入字段`AnimalCategory`的`String`方法会被当做`animal`的方法调用。
|
||||
|
||||
**那如果我也为`Animal`类型编写一个`String`方法呢?这里会调用哪一个呢?**
|
||||
|
||||
答案是,`animal`的`String`方法会被调用。这时,我们说,嵌入字段`AnimalCategory`的`String`方法被“屏蔽”了。注意,只要名称相同,无论这两个方法的签名是否一致,被嵌入类型的方法都会“屏蔽”掉嵌入字段的同名方法。
|
||||
|
||||
类似的,由于我们同样可以像访问被嵌入类型的字段那样,直接访问嵌入字段的字段,所以如果这两个结构体类型里存在同名的字段,那么嵌入字段中的那个字段一定会被“屏蔽”。这与我们在前面讲过的,可重名变量之间可能存在的“屏蔽”现象很相似。
|
||||
|
||||
正因为嵌入字段的字段和方法都可以“嫁接”到被嵌入类型上,所以即使在两个同名的成员一个是字段,另一个是方法的情况下,这种“屏蔽”现象依然会存在。
|
||||
|
||||
不过,即使被屏蔽了,我们仍然可以通过链式的选择表达式,选择到嵌入字段的字段或方法,就像我在`Category`方法中所做的那样。这种“屏蔽”其实还带来了一些好处。我们看看下面这个`Animal`类型的`String`方法的实现:
|
||||
|
||||
```
|
||||
func (a Animal) String() string {
|
||||
return fmt.Sprintf("%s (category: %s)",
|
||||
a.scientificName, a.AnimalCategory)
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在这里,我们把对嵌入字段的`String`方法的调用结果融入到了`Animal`类型的同名方法的结果中。这种将同名方法的结果逐层“包装”的手法是很常见和有用的,也算是一种惯用法了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/47/b2/471b42767d0c82af8acd22c13dfd33b2.png" alt=""><br>
|
||||
(结构体类型中的嵌入字段)
|
||||
|
||||
**最后,我还要提一下多层嵌入的问题。**也就是说,嵌入字段本身也有嵌入字段的情况。请看我声明的`Cat`类型:
|
||||
|
||||
```
|
||||
type Cat struct {
|
||||
name string
|
||||
Animal
|
||||
}
|
||||
|
||||
func (cat Cat) String() string {
|
||||
return fmt.Sprintf("%s (category: %s, name: %q)",
|
||||
cat.scientificName, cat.Animal.AnimalCategory, cat.name)
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
结构体类型`Cat`中有一个嵌入字段`Animal`,而`Animal`类型还有一个嵌入字段`AnimalCategory`。
|
||||
|
||||
在这种情况下,“屏蔽”现象会以嵌入的层级为依据,嵌入层级越深的字段或方法越可能被“屏蔽”。
|
||||
|
||||
例如,当我们调用`Cat`类型值的`String`方法时,如果该类型确有`String`方法,那么嵌入字段`Animal`和`AnimalCategory`的`String`方法都会被“屏蔽”。
|
||||
|
||||
如果该类型没有`String`方法,那么嵌入字段`Animal`的`String`方法会被调用,而它的嵌入字段`AnimalCategory`的`String`方法仍然会被屏蔽。
|
||||
|
||||
只有当`Cat`类型和`Animal`类型都没有`String`方法的时候,`AnimalCategory`的`String`方法菜会被调用。
|
||||
|
||||
最后的最后,如果处于同一个层级的多个嵌入字段拥有同名的字段或方法,那么从被嵌入类型的值那里,选择此名称的时候就会引发一个编译错误,因为编译器无法确定被选择的成员到底是哪一个。
|
||||
|
||||
以上关于嵌入字段的所有示例都在demo29.go中,希望能对你有所帮助。
|
||||
|
||||
## 知识扩展
|
||||
|
||||
**问题1:Go语言是用嵌入字段实现了继承吗?**
|
||||
|
||||
这里强调一下,Go语言中根本没有继承的概念,它所做的是通过嵌入字段的方式实现了类型之间的组合。这样做的具体原因和理念请见Go语言官网的FAQ中的[Why is there no type inheritance?](https://golang.org/doc/faq#inheritance)。
|
||||
|
||||
简单来说,面向对象编程中的继承,其实是通过牺牲一定的代码简洁性来换取可扩展性,而且这种可扩展性是通过侵入的方式来实现的。
|
||||
|
||||
类型之间的组合采用的是非声明的方式,我们不需要显式地声明某个类型实现了某个接口,或者一个类型继承了另一个类型。
|
||||
|
||||
同时,类型组合也是非侵入式的,它不会破坏类型的封装或加重类型之间的耦合。
|
||||
|
||||
我们要做的只是把类型当做字段嵌入进来,然后坐享其成地使用嵌入字段所拥有的一切。如果嵌入字段有哪里不合心意,我们还可以用“包装”或“屏蔽”的方式去调整和优化。
|
||||
|
||||
另外,类型间的组合也是灵活的,我们总是可以通过嵌入字段的方式把一个类型的属性和能力“嫁接”给另一个类型。
|
||||
|
||||
这时候,被嵌入类型也就自然而然地实现了嵌入字段所实现的接口。再者,组合要比继承更加简洁和清晰,Go语言可以轻而易举地通过嵌入多个字段来实现功能强大的类型,却不会有多重继承那样复杂的层次结构和可观的管理成本。
|
||||
|
||||
接口类型之间也可以组合。在Go语言中,接口类型之间的组合甚至更加常见,我们常常以此来扩展接口定义的行为或者标记接口的特征。与此有关的内容我在下一篇文章中再讲。
|
||||
|
||||
在我面试过的众多Go工程师中,有很多人都在说“Go语言用嵌入字段实现了继承”,而且深信不疑。
|
||||
|
||||
要么是他们还在用其他编程语言的视角和理念来看待Go语言,要么就是受到了某些所谓的“Go语言教程”的误导。每当这时,我都忍不住当场纠正他们,并建议他们去看看官网上的解答。
|
||||
|
||||
**问题2:值方法和指针方法都是什么意思,有什么区别?**
|
||||
|
||||
我们都知道,方法的接收者类型必须是某个自定义的数据类型,而且不能是接口类型或接口的指针类型。所谓的值方法,就是接收者类型是非指针的自定义数据类型的方法。
|
||||
|
||||
比如,我们在前面为`AnimalCategory`、`Animal`以及`Cat`类型声明的那些方法都是值方法。就拿`Cat`来说,它的`String`方法的接收者类型就是`Cat`,一个非指针类型。那什么叫指针类型呢?请看这个方法:
|
||||
|
||||
```
|
||||
func (cat *Cat) SetName(name string) {
|
||||
cat.name = name
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
方法`SetName`的接收者类型是`*Cat`。`Cat`左边再加个`*`代表的就是`Cat`类型的指针类型。
|
||||
|
||||
这时,`Cat`可以被叫做`*Cat`的基本类型。你可以认为这种指针类型的值表示的是指向某个基本类型值的指针。
|
||||
|
||||
我们可以通过把取值操作符`*`放在这样一个指针值的左边来组成一个取值表达式,以获取该指针值指向的基本类型值,也可以通过把取址操作符`&`放在一个可寻址的基本类型值的左边来组成一个取址表达式,以获取该基本类型值的指针值。
|
||||
|
||||
所谓的指针方法,就是接收者类型是上述指针类型的方法。
|
||||
|
||||
那么值方法和指针方法之间有什么不同点呢?它们的不同如下所示。
|
||||
|
||||
<li>
|
||||
<p>值方法的接收者是该方法所属的那个类型值的一个副本。我们在该方法内对该副本的修改一般都不会体现在原值上,除非这个类型本身是某个引用类型(比如切片或字典)的别名类型。<br>
|
||||
<br> 而指针方法的接收者,是该方法所属的那个基本类型值的指针值的一个副本。我们在这样的方法内对该副本指向的值进行修改,却一定会体现在原值上。<br></p>
|
||||
</li>
|
||||
<li>
|
||||
<p>一个自定义数据类型的方法集合中仅会包含它的所有值方法,而该类型的指针类型的方法集合却囊括了前者的所有方法,包括所有值方法和所有指针方法。<br><br>
|
||||
严格来讲,我们在这样的基本类型的值上只能调用到它的值方法。但是,Go语言会适时地为我们进行自动地转译,使得我们在这样的值上也能调用到它的指针方法。<br><br>
|
||||
比如,在`Cat`类型的变量`cat`之上,之所以我们可以通过`cat.SetName("monster")`修改猫的名字,是因为Go语言把它自动转译为了`(&cat).SetName("monster")`,即:先取`cat`的指针值,然后在该指针值上调用`SetName`方法。</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>在后边你会了解到,一个类型的方法集合中有哪些方法与它能实现哪些接口类型是息息相关的。如果一个基本类型和它的指针类型的方法集合是不同的,那么它们具体实现的接口类型的数量就也会有差异,除非这两个数量都是零。<br><br>
|
||||
比如,一个指针类型实现了某某接口类型,但它的基本类型却不一定能够作为该接口的实现类型。</p>
|
||||
</li>
|
||||
|
||||
能够体现值方法和指针方法之间差异的小例子我放在demo30.go文件里了,你可以参照一下。
|
||||
|
||||
**总结**
|
||||
|
||||
结构体类型的嵌入字段比较容易让Go语言新手们迷惑,所以我在本篇文章着重解释了它的编写方法、基本的特性和规则以及更深层次的含义。在理解了结构体类型及其方法的组成方式和构造套路之后,这些知识应该是你重点掌握的。
|
||||
|
||||
嵌入字段是其声明中只有类型而没有名称的字段,它可以以一种很自然的方式为被嵌入的类型带来新的属性和能力。在一般情况下,我们用简单的选择表达式就可以直接引用到它们的字段和方法。
|
||||
|
||||
不过,我们需要小心可能产生“屏蔽”现象的地方,尤其是当存在多个嵌入字段或者多层嵌入的时候。“屏蔽”现象可能会让你的实际引用与你的预期不符。
|
||||
|
||||
另外,你一定要梳理清楚值方法和指针方法的不同之处,包括这两种方法各自能做什么、不能做什么以及会影响到其所属类型的哪些方面。这涉及值的修改、方法集合和接口实现。
|
||||
|
||||
最后,再次强调,嵌入字段是实现类型间组合的一种方式,这与继承没有半点儿关系。Go语言虽然支持面向对象编程,但是根本就没有“继承”这个概念。
|
||||
|
||||
**思考题**
|
||||
|
||||
1. 我们可以在结构体类型中嵌入某个类型的指针类型吗?如果可以,有哪些注意事项?
|
||||
1. 字面量`struct{}`代表了什么?又有什么用处?
|
||||
|
||||
[戳此查看Go语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers)
|
||||
|
||||
|
||||
229
极客时间专栏/Go语言核心36讲/模块二:Go语言进阶技术/14 | 接口类型的合理运用.md
Normal file
229
极客时间专栏/Go语言核心36讲/模块二:Go语言进阶技术/14 | 接口类型的合理运用.md
Normal file
@@ -0,0 +1,229 @@
|
||||
<audio id="audio" title="14 | 接口类型的合理运用" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/7f/55/7f3b819f063f6589d1dd94be584d4b55.mp3"></audio>
|
||||
|
||||
你好,我是郝林,今天我们来聊聊接口的相关内容。
|
||||
|
||||
## 前导内容:正确使用接口的基础知识
|
||||
|
||||
在Go语言的语境中,当我们在谈论“接口”的时候,一定指的是接口类型。因为接口类型与其他数据类型不同,它是没法被实例化的。
|
||||
|
||||
更具体地说,我们既不能通过调用`new`函数或`make`函数创建出一个接口类型的值,也无法用字面量来表示一个接口类型的值。
|
||||
|
||||
对于某一个接口类型来说,如果没有任何数据类型可以作为它的实现,那么该接口的值就不可能存在。
|
||||
|
||||
我已经在前面展示过,通过关键字`type`和`interface`,我们可以声明出接口类型。
|
||||
|
||||
接口类型的类型字面量与结构体类型的看起来有些相似,它们都用花括号包裹一些核心信息。只不过,结构体类型包裹的是它的字段声明,而接口类型包裹的是它的方法定义。
|
||||
|
||||
这里你要注意的是:接口类型声明中的这些方法所代表的就是该接口的方法集合。一个接口的方法集合就是它的全部特征。
|
||||
|
||||
对于任何数据类型,只要它的方法集合中完全包含了一个接口的全部特征(即全部的方法),那么它就一定是这个接口的实现类型。比如下面这样:
|
||||
|
||||
```
|
||||
type Pet interface {
|
||||
SetName(name string)
|
||||
Name() string
|
||||
Category() string
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我声明了一个接口类型`Pet`,它包含了3个方法定义,方法名称分别为`SetName`、`Name`和`Category`。这3个方法共同组成了接口类型`Pet`的方法集合。
|
||||
|
||||
只要一个数据类型的方法集合中有这3个方法,那么它就一定是`Pet`接口的实现类型。这是一种无侵入式的接口实现方式。这种方式还有一个专有名词,叫“Duck typing”,中文常译作“鸭子类型”。你可以到百度的[百科页面](https://baike.baidu.com/item/%E9%B8%AD%E5%AD%90%E7%B1%BB%E5%9E%8B)上去了解一下详情。
|
||||
|
||||
顺便说一句,**怎样判定一个数据类型的某一个方法实现的就是某个接口类型中的某个方法呢?**
|
||||
|
||||
这有两个充分必要条件,一个是“两个方法的签名需要完全一致”,另一个是“两个方法的名称要一模一样”。显然,这比判断一个函数是否实现了某个函数类型要更加严格一些。
|
||||
|
||||
如果你查阅了上篇文章附带的最后一个示例的话,那么就一定会知道,虽然结构体类型`Cat`不是`Pet`接口的实现类型,但它的指针类型`*Cat`却是这个的实现类型。
|
||||
|
||||
如果你还不知道原因,那么请跟着我一起来看。我已经把`Cat`类型的声明搬到了demo31.go文件中,并进行了一些简化,以便你看得更清楚。对了,由于`Cat`和`Pet`的发音过于相似,我还把`Cat`重命名为了`Dog`。
|
||||
|
||||
我声明的类型`Dog`附带了3个方法。其中有2个值方法,分别是`Name`和`Category`,另外还有一个指针方法`SetName`。
|
||||
|
||||
这就意味着,`Dog`类型本身的方法集合中只包含了2个方法,也就是所有的值方法。而它的指针类型`*Dog`方法集合却包含了3个方法,
|
||||
|
||||
也就是说,它拥有`Dog`类型附带的所有值方法和指针方法。又由于这3个方法恰恰分别是`Pet`接口中某个方法的实现,所以`*Dog`类型就成为了`Pet`接口的实现类型。
|
||||
|
||||
```
|
||||
dog := Dog{"little pig"}
|
||||
var pet Pet = &dog
|
||||
|
||||
```
|
||||
|
||||
正因为如此,我可以声明并初始化一个`Dog`类型的变量`dog`,然后把它的指针值赋给类型为`Pet`的变量`pet`。
|
||||
|
||||
这里有几个名词需要你先记住。对于一个接口类型的变量来说,例如上面的变量`pet`,我们赋给它的值可以被叫做它的实际值(也称**动态值**),而该值的类型可以被叫做这个变量的实际类型(也称**动态类型**)。
|
||||
|
||||
比如,我们把取址表达式`&dog`的结果值赋给了变量`pet`,这时这个结果值就是变量`pet`的动态值,而此结果值的类型`*Dog`就是该变量的动态类型。
|
||||
|
||||
动态类型这个叫法是相对于**静态类型**而言的。对于变量`pet`来讲,它的**静态类型**就是`Pet`,并且永远是`Pet`,但是它的动态类型却会随着我们赋给它的动态值而变化。
|
||||
|
||||
比如,只有我把一个`*Dog`类型的值赋给变量`pet`之后,该变量的动态类型才会是`*Dog`。如果还有一个`Pet`接口的实现类型`*Fish`,并且我又把一个此类型的值赋给了`pet`,那么它的动态类型就会变为`*Fish`。
|
||||
|
||||
还有,在我们给一个接口类型的变量赋予实际的值之前,它的动态类型是不存在的。
|
||||
|
||||
你需要想办法搞清楚接口类型的变量(以下简称接口变量)的动态值、动态类型和静态类型都是什么意思。因为我会在后面基于这些概念讲解更深层次的知识。
|
||||
|
||||
好了,我下面会就“怎样用好Go语言的接口”这个话题提出一系列问题,也请你跟着我一起思考这些问题。
|
||||
|
||||
**那么今天的问题是:当我们为一个接口变量赋值时会发生什么?**
|
||||
|
||||
为了突出问题,我把`Pet`接口的声明简化了一下。
|
||||
|
||||
```
|
||||
type Pet interface {
|
||||
Name() string
|
||||
Category() string
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我从中去掉了`Pet`接口的那个名为`SetName`的方法。这样一来,`Dog`类型也就变成`Pet`接口的实现类型了。你可以在demo32.go文件中找到本问题的代码。
|
||||
|
||||
现在,我先声明并初始化了一个`Dog`类型的变量`dog`,这时它的`name`字段的值是`"little pig"`。然后,我把该变量赋给了一个`Pet`类型的变量`pet`。最后我通过调用`dog`的方法`SetName`把它的`name`字段的值改成了`"monster"`。
|
||||
|
||||
```
|
||||
dog := Dog{"little pig"}
|
||||
var pet Pet = dog
|
||||
dog.SetName("monster")
|
||||
|
||||
```
|
||||
|
||||
所以,我要问的具体问题是:在以上代码执行后,`pet`变量的字段`name`的值会是什么?
|
||||
|
||||
**这个题目的典型回答是**:`pet`变量的字段`name`的值依然是`"little pig"`。
|
||||
|
||||
## 问题解析
|
||||
|
||||
首先,由于`dog`的`SetName`方法是指针方法,所以该方法持有的接收者就是指向`dog`的指针值的副本,因而其中对接收者的`name`字段的设置就是对变量`dog`的改动。那么当`dog.SetName("monster")`执行之后,`dog`的`name`字段的值就一定是`"monster"`。如果你理解到了这一层,那么请小心前方的陷阱。
|
||||
|
||||
为什么`dog`的`name`字段值变了,而`pet`的却没有呢?这里有一条通用的规则需要你知晓:如果我们使用一个变量给另外一个变量赋值,那么真正赋给后者的,并不是前者持有的那个值,而是该值的一个副本。
|
||||
|
||||
例如,我声明并初始化了一个`Dog`类型的变量`dog1`,这时它的`name`是`"little pig"`。然后,我在把`dog1`赋给变量`dog2`之后,修改了`dog1`的`name`字段的值。这时,`dog2`的`name`字段的值是什么?
|
||||
|
||||
```
|
||||
dog1 := Dog{"little pig"}
|
||||
dog2 := dog1
|
||||
dog1.name = "monster"
|
||||
|
||||
```
|
||||
|
||||
这个问题与前面那道题几乎一样,只不过这里没有涉及接口类型。这时的`dog2`的`name`仍然会是`"little pig"`。这就是我刚刚告诉你的那条通用规则的又一个体现。
|
||||
|
||||
当你知道了这条通用规则之后,确实可以把前面那道题做对。不过,如果当我问你为什么的时候你只说出了这一个原因,那么,我只能说你仅仅答对了一半。
|
||||
|
||||
那么另一半是什么?这就需要从接口类型值的存储方式和结构说起了。我在前面说过,接口类型本身是无法被值化的。在我们赋予它实际的值之前,它的值一定会是`nil`,这也是它的零值。
|
||||
|
||||
反过来讲,一旦它被赋予了某个实现类型的值,它的值就不再是`nil`了。不过要注意,即使我们像前面那样把`dog`的值赋给了`pet`,`pet`的值与`dog`的值也是不同的。这不仅仅是副本与原值的那种不同。
|
||||
|
||||
当我们给一个接口变量赋值的时候,该变量的动态类型会与它的动态值一起被存储在一个专用的数据结构中。
|
||||
|
||||
严格来讲,这样一个变量的值其实是这个专用数据结构的一个实例,而不是我们赋给该变量的那个实际的值。所以我才说,`pet`的值与`dog`的值肯定是不同的,无论是从它们存储的内容,还是存储的结构上来看都是如此。不过,我们可以认为,这时`pet`的值中包含了`dog`值的副本。
|
||||
|
||||
我们就把这个专用的数据结构叫做`iface`吧,在Go语言的`runtime`包中它其实就叫这个名字。
|
||||
|
||||
`iface`的实例会包含两个指针,一个是指向类型信息的指针,另一个是指向动态值的指针。这里的类型信息是由另一个专用数据结构的实例承载的,其中包含了动态值的类型,以及使它实现了接口的方法和调用它们的途径,等等。
|
||||
|
||||
总之,接口变量被赋予动态值的时候,存储的是包含了这个动态值的副本的一个结构更加复杂的值。你明白了吗?
|
||||
|
||||
## 知识扩展
|
||||
|
||||
**问题 1:接口变量的值在什么情况下才真正为`nil`?**
|
||||
|
||||
这个问题初看起来就不是个问题。对于一个引用类型的变量,它的值是否为`nil`完全取决于我们赋给它了什么,是这样吗?我们先来看一段代码:
|
||||
|
||||
```
|
||||
var dog1 *Dog
|
||||
fmt.Println("The first dog is nil. [wrap1]")
|
||||
dog2 := dog1
|
||||
fmt.Println("The second dog is nil. [wrap1]")
|
||||
var pet Pet = dog2
|
||||
if pet == nil {
|
||||
fmt.Println("The pet is nil. [wrap1]")
|
||||
} else {
|
||||
fmt.Println("The pet is not nil. [wrap1]")
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在demo33.go文件的这段代码中,我先声明了一个`*Dog`类型的变量`dog1`,并且没有对它进行初始化。这时该变量的值是什么?显然是`nil`。然后我把该变量赋给了`dog2`,后者的值此时也必定是`nil`,对吗?
|
||||
|
||||
现在问题来了:当我把`dog2`赋给`Pet`类型的变量`pet`之后,变量`pet`的值会是什么?答案是`nil`吗?
|
||||
|
||||
如果你真正理解了我在上一个问题的解析中讲到的知识,尤其是接口变量赋值及其值的数据结构那部分,那么这道题就不难回答。你可以先思考一下,然后再接着往下看。
|
||||
|
||||
当我们把`dog2`的值赋给变量`pet`的时候,`dog2`的值会先被复制,不过由于在这里它的值是`nil`,所以就没必要复制了。
|
||||
|
||||
然后,Go语言会用我上面提到的那个专用数据结构`iface`的实例包装这个`dog2`的值的副本,这里是`nil`。
|
||||
|
||||
虽然被包装的动态值是`nil`,但是`pet`的值却不会是`nil`,因为这个动态值只是`pet`值的一部分而已。
|
||||
|
||||
顺便说一句,这时的`pet`的动态类型就存在了,是`*Dog`。我们可以通过`fmt.Printf`函数和占位符`%T`来验证这一点,另外`reflect`包的`TypeOf`函数也可以起到类似的作用。
|
||||
|
||||
换个角度来看。我们把`nil`赋给了`pet`,但是`pet`的值却不是`nil`。
|
||||
|
||||
这很奇怪对吗?其实不然。在Go语言中,我们把由字面量`nil`表示的值叫做无类型的`nil`。这是真正的`nil`,因为它的类型也是`nil`的。虽然`dog2`的值是真正的`nil`,但是当我们把这个变量赋给`pet`的时候,Go语言会把它的类型和值放在一起考虑。
|
||||
|
||||
也就是说,这时Go语言会识别出赋予`pet`的值是一个`*Dog`类型的`nil`。然后,Go语言就会用一个`iface`的实例包装它,包装后的产物肯定就不是`nil`了。
|
||||
|
||||
只要我们把一个有类型的`nil`赋给接口变量,那么这个变量的值就一定不会是那个真正的`nil`。因此,当我们使用判等符号`==`判断`pet`是否与字面量`nil`相等的时候,答案一定会是`false`。
|
||||
|
||||
那么,怎样才能让一个接口变量的值真正为`nil`呢?要么只声明它但不做初始化,要么直接把字面量`nil`赋给它。
|
||||
|
||||
**问题 2:怎样实现接口之间的组合?**
|
||||
|
||||
接口类型间的嵌入也被称为接口的组合。我在前面讲过结构体类型的嵌入字段,这其实就是在说结构体类型间的嵌入。
|
||||
|
||||
接口类型间的嵌入要更简单一些,因为它不会涉及方法间的“屏蔽”。只要组合的接口之间有同名的方法就会产生冲突,从而无法通过编译,即使同名方法的签名彼此不同也会是如此。因此,接口的组合根本不可能导致“屏蔽”现象的出现。
|
||||
|
||||
与结构体类型间的嵌入很相似,我们只要把一个接口类型的名称直接写到另一个接口类型的成员列表中就可以了。比如:
|
||||
|
||||
```
|
||||
type Animal interface {
|
||||
ScientificName() string
|
||||
Category() string
|
||||
}
|
||||
|
||||
type Pet interface {
|
||||
Animal
|
||||
Name() string
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
接口类型`Pet`包含了两个成员,一个是代表了另一个接口类型的`Animal`,一个是方法`Name`的定义。它们都被包含在`Pet`的类型声明的花括号中,并且都各自独占一行。此时,`Animal`接口包含的所有方法也就成为了`Pet`接口的方法。
|
||||
|
||||
Go语言团队鼓励我们声明体量较小的接口,并建议我们通过这种接口间的组合来扩展程序、增加程序的灵活性。
|
||||
|
||||
这是因为相比于包含很多方法的大接口而言,小接口可以更加专注地表达某一种能力或某一类特征,同时也更容易被组合在一起。
|
||||
|
||||
Go语言标准库代码包`io`中的`ReadWriteCloser`接口和`ReadWriter`接口就是这样的例子,它们都是由若干个小接口组合而成的。以`io.ReadWriteCloser`接口为例,它是由`io.Reader`、`io.Writer`和`io.Closer`这三个接口组成的。
|
||||
|
||||
这三个接口都只包含了一个方法,是典型的小接口。它们中的每一个都只代表了一种能力,分别是读出、写入和关闭。我们编写这几个小接口的实现类型通常都会很容易。并且,一旦我们同时实现了它们,就等于实现了它们的组合接口`io.ReadWriteCloser`。
|
||||
|
||||
即使我们只实现了`io.Reader`和`io.Writer`,那么也等同于实现了`io.ReadWriter`接口,因为后者就是前两个接口组成的。可以看到,这几个`io`包中的接口共同组成了一个接口矩阵。它们既相互关联又独立存在。
|
||||
|
||||
我在demo34.go文件中写了一个能够体现接口组合优势的小例子,你可以去参看一下。总之,善用接口组合和小接口可以让你的程序框架更加稳定和灵活。
|
||||
|
||||
**总结**
|
||||
|
||||
好了,我们来简要总结一下。
|
||||
|
||||
Go语言的接口常用于代表某种能力或某类特征。首先,我们要弄清楚的是,接口变量的动态值、动态类型和静态类型都代表了什么。这些都是正确使用接口变量的基础。当我们给接口变量赋值时,接口变量会持有被赋予值的副本,而不是它本身。
|
||||
|
||||
更重要的是,接口变量的值并不等同于这个可被称为动态值的副本。它会包含两个指针,一个指针指向动态值,一个指针指向类型信息。
|
||||
|
||||
基于此,即使我们把一个值为`nil`的某个实现类型的变量赋给了接口变量,后者的值也不可能是真正的`nil`。虽然这时它的动态值会为`nil`,但它的动态类型确是存在的。
|
||||
|
||||
请记住,除非我们只声明而不初始化,或者显式地赋给它`nil`,否则接口变量的值就不会为`nil`。
|
||||
|
||||
后面的一个问题相对轻松一些,它是关于程序设计方面的。用好小接口和接口组合总是有益的,我们可以以此形成接口矩阵,进而搭起灵活的程序框架。如果在实现接口时再配合运用结构体类型间的嵌入手法,那么接口组合就可以发挥更大的效用。
|
||||
|
||||
**思考题**
|
||||
|
||||
如果我们把一个值为`nil`的某个实现类型的变量赋给了接口变量,那么在这个接口变量上仍然可以调用该接口的方法吗?如果可以,有哪些注意事项?如果不可以,原因是什么?
|
||||
|
||||
[戳此查看Go语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers)
|
||||
|
||||
|
||||
219
极客时间专栏/Go语言核心36讲/模块二:Go语言进阶技术/15 | 关于指针的有限操作.md
Normal file
219
极客时间专栏/Go语言核心36讲/模块二:Go语言进阶技术/15 | 关于指针的有限操作.md
Normal file
@@ -0,0 +1,219 @@
|
||||
<audio id="audio" title="15 | 关于指针的有限操作" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/42/24/4215538c39a517296e12873fa6262924.mp3"></audio>
|
||||
|
||||
在前面的文章中,我们已经提到过很多次“指针”了,你应该已经比较熟悉了。不过,我们那时大多指的是指针类型及其对应的指针值,今天我们讲的则是更为深入的内容。
|
||||
|
||||
让我们先来复习一下。
|
||||
|
||||
```
|
||||
type Dog struct {
|
||||
name string
|
||||
}
|
||||
|
||||
func (dog *Dog) SetName(name string) {
|
||||
dog.name = name
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
对于基本类型`Dog`来说,`*Dog`就是它的指针类型。而对于一个`Dog`类型,值不为`nil`的变量`dog`,取址表达式`&dog`的结果就是该变量的值(也就是基本值)的指针值。
|
||||
|
||||
如果一个方法的接收者是`*Dog`类型的,那么该方法就是基本类型`Dog`的指针方法。
|
||||
|
||||
在这种情况下,这个方法的接收者,实际上就是当前的基本值的指针值。
|
||||
|
||||
我们可以通过指针值无缝地访问到基本值包含的任何字段,以及调用与之关联的任何方法。这应该就是我们在编写Go程序的过程中,用得最频繁的“指针”了。
|
||||
|
||||
从传统意义上说,指针是一个指向某个确切的内存地址的值。这个内存地址可以是任何数据或代码的起始地址,比如,某个变量、某个字段或某个函数。
|
||||
|
||||
我们刚刚只提到了其中的一种情况,在Go语言中还有其他几样东西可以代表“指针”。其中最贴近传统意义的当属`uintptr`类型了。该类型实际上是一个数值类型,也是Go语言内建的数据类型之一。
|
||||
|
||||
根据当前计算机的计算架构的不同,它可以存储32位或64位的无符号整数,可以代表任何指针的位(bit)模式,也就是原始的内存地址。
|
||||
|
||||
再来看Go语言标准库中的`unsafe`包。`unsafe`包中有一个类型叫做`Pointer`,也代表了“指针”。
|
||||
|
||||
`unsafe.Pointer`可以表示任何指向可寻址的值的指针,同时它也是前面提到的指针值和`uintptr`值之间的桥梁。也就是说,通过它,我们可以在这两种值之上进行双向的转换。这里有一个很关键的词——可寻址的(addressable)。在我们继续说`unsafe.Pointer`之前,需要先要搞清楚这个词的确切含义。
|
||||
|
||||
**今天的问题是:你能列举出Go语言中的哪些值是不可寻址的吗?**
|
||||
|
||||
**这道题的典型回答是**以下列表中的值都是不可寻址的。
|
||||
|
||||
- 常量的值。
|
||||
- 基本类型值的字面量。
|
||||
- 算术操作的结果值。
|
||||
- 对各种字面量的索引表达式和切片表达式的结果值。不过有一个例外,对切片字面量的索引结果值却是可寻址的。
|
||||
- 对字符串变量的索引表达式和切片表达式的结果值。
|
||||
- 对字典变量的索引表达式的结果值。
|
||||
- 函数字面量和方法字面量,以及对它们的调用表达式的结果值。
|
||||
- 结构体字面量的字段值,也就是对结构体字面量的选择表达式的结果值。
|
||||
- 类型转换表达式的结果值。
|
||||
- 类型断言表达式的结果值。
|
||||
- 接收表达式的结果值。
|
||||
|
||||
## 问题解析
|
||||
|
||||
初看答案中的这些不可寻址的值好像并没有什么规律。不过别急,我们一起来梳理一下。你可以对照着demo35.go文件中的代码来看,这样应该会让你理解起来更容易一些。
|
||||
|
||||
常量的值总是会被存储到一个确切的内存区域中,并且这种值肯定是**不可变的**。基本类型值的字面量也是一样,其实它们本就可以被视为常量,只不过没有任何标识符可以代表它们罢了。
|
||||
|
||||
**第一个关键词:不可变的。**由于Go语言中的字符串值也是不可变的,所以对于一个字符串类型的变量来说,基于它的索引或切片的结果值也都是不可寻址的,因为即使拿到了这种值的内存地址也改变不了什么。
|
||||
|
||||
算术操作的结果值属于一种**临时结果**。在我们把这种结果值赋给任何变量或常量之前,即使能拿到它的内存地址也是没有任何意义的。
|
||||
|
||||
**第二个关键词:临时结果。**这个关键词能被用来解释很多现象。我们可以把各种对值字面量施加的表达式的求值结果都看做是临时结果。
|
||||
|
||||
我们都知道,Go语言中的表达式有很多种,其中常用的包括以下几种。
|
||||
|
||||
- 用于获得某个元素的索引表达式。
|
||||
- 用于获得某个切片(片段)的切片表达式。
|
||||
- 用于访问某个字段的选择表达式。
|
||||
- 用于调用某个函数或方法的调用表达式。
|
||||
- 用于转换值的类型的类型转换表达式。
|
||||
- 用于判断值的类型的类型断言表达式。
|
||||
- 向通道发送元素值或从通道那里接收元素值的接收表达式。
|
||||
|
||||
我们把以上这些表达式施加在某个值字面量上一般都会得到一个临时结果。比如,对数组字面量和字典字面量的索引结果值,又比如,对数组字面量和切片字面量的切片结果值。它们都属于临时结果,都是不可寻址的。
|
||||
|
||||
一个需要特别注意的例外是,对切片字面量的索引结果值是可寻址的。因为不论怎样,每个切片值都会持有一个底层数组,而这个底层数组中的每个元素值都是有一个确切的内存地址的。
|
||||
|
||||
你可能会问,那么对切片字面量的切片结果值为什么却是不可寻址的?这是因为切片表达式总会返回一个新的切片值,而这个新的切片值在被赋给变量之前属于临时结果。
|
||||
|
||||
你可能已经注意到了,我一直在说针对数组值、切片值或字典值的**字面量**的表达式会产生临时结果。如果针对的是数组类型或切片类型的**变量**,那么索引或切片的结果值就都不属于临时结果了,是可寻址的。
|
||||
|
||||
这主要因为变量的值本身就不是“临时的”。对比而言,值字面量在还没有与任何变量(或者说任何标识符)绑定之前是没有落脚点的,我们无法以任何方式引用到它们。这样的值就是“临时的”。
|
||||
|
||||
再说一个例外。我们通过对字典类型的变量施加索引表达式,得到的结果值不属于临时结果,可是,这样的值却是不可寻址的。原因是,字典中的每个键-元素对的存储位置都可能会变化,而且这种变化外界是无法感知的。
|
||||
|
||||
我们都知道,字典中总会有若干个哈希桶用于均匀地储存键-元素对。当满足一定条件时,字典可能会改变哈希桶的数量,并适时地把其中的键-元素对搬运到对应的新的哈希桶中。
|
||||
|
||||
在这种情况下,获取字典中任何元素值的指针都是无意义的,也是**不安全的**。我们不知道什么时候那个元素值会被搬运到何处,也不知道原先的那个内存地址上还会被存放什么别的东西。所以,这样的值就应该是不可寻址的。
|
||||
|
||||
**第三个关键词:不安全的。**“不安全的”操作很可能会破坏程序的一致性,引发不可预知的错误,从而严重影响程序的功能和稳定性。
|
||||
|
||||
再来看函数。函数在Go语言中是一等公民,所以我们可以把代表函数或方法的字面量或标识符赋给某个变量、传给某个函数或者从某个函数传出。但是,这样的函数和方法都是不可寻址的。一个原因是函数就是代码,是不可变的。
|
||||
|
||||
另一个原因是,拿到指向一段代码的指针是不安全的。此外,对函数或方法的调用结果值也是不可寻址的,这是因为它们都属于临时结果。
|
||||
|
||||
至于典型回答中最后列出的那几种值,由于都是针对值字面量的某种表达式的结果值,所以都属于临时结果,都不可寻址。
|
||||
|
||||
好了,说了这么多,希望你已经有所领悟了。我来总结一下。
|
||||
|
||||
1. **不可变的**值不可寻址。常量、基本类型的值字面量、字符串变量的值、函数以及方法的字面量都是如此。其实这样规定也有安全性方面的考虑。
|
||||
1. 绝大多数被视为**临时结果**的值都是不可寻址的。算术操作的结果值属于临时结果,针对值字面量的表达式结果值也属于临时结果。但有一个例外,对切片字面量的索引结果值虽然也属于临时结果,但却是可寻址的。
|
||||
1. 若拿到某值的指针可能会破坏程序的一致性,那么就是**不安全的**,该值就不可寻址。由于字典的内部机制,对字典的索引结果值的取址操作都是不安全的。另外,获取由字面量或标识符代表的函数或方法的地址显然也是不安全的。
|
||||
|
||||
最后说一句,如果我们把临时结果赋给一个变量,那么它就是可寻址的了。如此一来,取得的指针指向的就是这个变量持有的那个值了。
|
||||
|
||||
## 知识扩展
|
||||
|
||||
**问题1:不可寻址的值在使用上有哪些限制?**
|
||||
|
||||
首当其冲的当然是无法使用取址操作符`&`获取它们的指针了。不过,对不可寻址的值施加取址操作都会使编译器报错,所以倒是不用太担心,你只要记住我在前面讲述的那几条规律,并在编码的时候提前注意一下就好了。
|
||||
|
||||
我们来看下面这个小问题。我们依然以那个结构体类型`Dog`为例。
|
||||
|
||||
```
|
||||
func New(name string) Dog {
|
||||
return Dog{name}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们再为它编写一个函数`New`。这个函数会接受一个名为`name`的`string`类型的参数,并会用这个参数初始化一个`Dog`类型的值,最后返回该值。我现在要问的是:如果我调用该函数,并直接以链式的手法调用其结果值的指针方法`SetName`,那么可以达到预期的效果吗?
|
||||
|
||||
```
|
||||
New("little pig").SetName("monster")
|
||||
|
||||
```
|
||||
|
||||
如果你还记得我在前面讲述的内容,那么肯定会知道调用`New`函数所得到的结果值属于临时结果,是不可寻址的。
|
||||
|
||||
可是,那又怎样呢?别忘了,我在讲结构体类型及其方法的时候还说过,我们可以在一个基本类型的值上调用它的指针方法,这是因为Go语言会自动地帮我们转译。
|
||||
|
||||
更具体地说,对于一个`Dog`类型的变量`dog`来说,调用表达式`dog.SetName("monster")`会被自动地转译为`(&dog).SetName("monster")`,即:先取`dog`的指针值,再在该指针值上调用`SetName`方法。
|
||||
|
||||
发现问题了吗?由于`New`函数的调用结果值是不可寻址的,所以无法对它进行取址操作。因此,上边这行链式调用会让编译器报告两个错误,一个是果,即:不能在`New("little pig")`的结果值上调用指针方法。一个是因,即:不能取得`New("little pig")`的地址。
|
||||
|
||||
除此之外,我们都知道,Go语言中的`++`和`--`并不属于操作符,而分别是自增语句和自减语句的重要组成部分。
|
||||
|
||||
虽然Go语言规范中的语法定义是,只要在`++`或`--`的左边添加一个表达式,就可以组成一个自增语句或自减语句,但是,它还明确了一个很重要的限制,那就是这个表达式的结果值必须是可寻址的。这就使得针对值字面量的表达式几乎都无法被用在这里。
|
||||
|
||||
不过这有一个例外,虽然对字典字面量和字典变量索引表达式的结果值都是不可寻址的,但是这样的表达式却可以被用在自增语句和自减语句中。
|
||||
|
||||
与之类似的规则还有两个。一个是,在赋值语句中,赋值操作符左边的表达式的结果值必须可寻址的,但是对字典的索引结果值也是可以的。
|
||||
|
||||
另一个是,在带有`range`子句的`for`语句中,在`range`关键字左边的表达式的结果值也都必须是可寻址的,不过对字典的索引结果值同样可以被用在这里。以上这三条规则我们合并起来记忆就可以了。
|
||||
|
||||
与这些定死的规则相比,我刚刚讲到的那个与指针方法有关的问题,你需要好好理解一下,它涉及了两个知识点的联合运用。起码在我面试的时候,它是一个可选择的考点。
|
||||
|
||||
**问题 2:怎样通过`unsafe.Pointer`操纵可寻址的值?**
|
||||
|
||||
前边的基础知识很重要。不过现在让我们再次关注指针的用法。我说过,`unsafe.Pointer`是像`*Dog`类型的值这样的指针值和`uintptr`值之间的桥梁,那么我们怎样利用`unsafe.Pointer`的中转和`uintptr`的底层操作来操纵像`dog`这样的值呢?
|
||||
|
||||
首先说明,这是一项黑科技。它可以绕过Go语言的编译器和其他工具的重重检查,并达到潜入内存修改数据的目的。这并不是一种正常的编程手段,使用它会很危险,很有可能造成安全隐患。
|
||||
|
||||
我们总是应该优先使用常规代码包中提供的API去编写程序,当然也可以把像`reflect`以及`go/ast`这样的代码包作为备选项。作为上层应用的开发者,请谨慎地使用`unsafe`包中的任何程序实体。
|
||||
|
||||
不过既然说到这里了,我们还是要来一探究竟的。请看下面的代码:
|
||||
|
||||
```
|
||||
dog := Dog{"little pig"}
|
||||
dogP := &dog
|
||||
dogPtr := uintptr(unsafe.Pointer(dogP))
|
||||
|
||||
```
|
||||
|
||||
我先声明了一个`Dog`类型的变量`dog`,然后用取址操作符`&`,取出了它的指针值,并把它赋给了变量`dogP`。
|
||||
|
||||
最后,我使用了两个类型转换,先把`dogP`转换成了一个`unsafe.Pointer`类型的值,然后紧接着又把后者转换成了一个`uintptr`的值,并把它赋给了变量`dogPtr`。这背后隐藏着一些转换规则,如下:
|
||||
|
||||
1. 一个指针值(比如`*Dog`类型的值)可以被转换为一个`unsafe.Pointer`类型的值,反之亦然。
|
||||
1. 一个`uintptr`类型的值也可以被转换为一个`unsafe.Pointer`类型的值,反之亦然。
|
||||
1. 一个指针值无法被直接转换成一个`uintptr`类型的值,反过来也是如此。
|
||||
|
||||
所以,对于指针值和`uintptr`类型值之间的转换,必须使用`unsafe.Pointer`类型的值作为中转。那么,我们把指针值转换成`uintptr`类型的值有什么意义吗?
|
||||
|
||||
```
|
||||
namePtr := dogPtr + unsafe.Offsetof(dogP.name)
|
||||
nameP := (*string)(unsafe.Pointer(namePtr))
|
||||
|
||||
```
|
||||
|
||||
这里需要与`unsafe.Offsetof`函数搭配使用才能看出端倪。`unsafe.Offsetof`函数用于获取两个值在内存中的起始存储地址之间的偏移量,以字节为单位。
|
||||
|
||||
这两个值一个是某个字段的值,另一个是该字段值所属的那个结构体值。我们在调用这个函数的时候,需要把针对字段的选择表达式传给它,比如`dogP.name`。
|
||||
|
||||
有了这个偏移量,又有了结构体值在内存中的起始存储地址(这里由`dogPtr`变量代表),把它们相加我们就可以得到`dogP`的`name`字段值的起始存储地址了。这个地址由变量`namePtr`代表。
|
||||
|
||||
此后,我们可以再通过两次类型转换把`namePtr`的值转换成一个`*string`类型的值,这样就得到了指向`dogP`的`name`字段值的指针值。
|
||||
|
||||
你可能会问,我直接用取址表达式`&(dogP.name)`不就能拿到这个指针值了吗?干嘛绕这么大一圈呢?你可以想象一下,如果我们根本就不知道这个结构体类型是什么,也拿不到`dogP`这个变量,那么还能去访问它的`name`字段吗?
|
||||
|
||||
答案是,只要有`namePtr`就可以。它就是一个无符号整数,但同时也是一个指向了程序内部数据的内存地址。它可能会给我们带来一些好处,比如可以直接修改埋藏得很深的内部数据。
|
||||
|
||||
但是,一旦我们有意或无意地把这个内存地址泄露出去,那么其他人就能够肆意地改动`dogP.name`的值,以及周围的内存地址上存储的任何数据了。
|
||||
|
||||
即使他们不知道这些数据的结构也无所谓啊,改不好还改不坏吗?不正确地改动一定会给程序带来不可预知的问题,甚至造成程序崩溃。这可能还是最好的灾难性后果;所以我才说,使用这种非正常的编程手段会很危险。
|
||||
|
||||
好了,现在你知道了这种手段,也知道了它的危险性,那就谨慎对待,防患于未然吧。
|
||||
|
||||
## 总结
|
||||
|
||||
我们今天集中说了说与指针有关的问题。基于基本类型的指针值应该是我们最常用到的,也是我们最需要关注的,比如`*Dog`类型的值。怎样得到一个这样的指针值呢?这需要用到取址操作和操作符`&`。
|
||||
|
||||
不过这里还有个前提,那就是取址操作的操作对象必须是可寻址的。关于这方面你需要记住三个关键词:不可变的、临时结果和不安全的。只要一个值符合了这三个关键词中的任何一个,它就是不可寻址的。
|
||||
|
||||
但有一个例外,对切片字面量的索引结果值是可寻址的。那么不可寻址的值在使用上有哪些限制呢?一个最重要的限制是关于指针方法的,即:无法调用一个不可寻址值的指针方法。这涉及了两个知识点的联合运用。
|
||||
|
||||
相比于刚说到的这些,`unsafe.Pointer`类型和`uintptr`类型的重要性好像就没那么高了。它们的值同样可以代表指针,并且比前面说的指针值更贴近于底层和内存。
|
||||
|
||||
虽然我们可以利用它们去访问或修改一些内部数据,而且就灵活性而言,这种要比通用的方式高很多,但是这往往也会带来不容小觑的安全隐患。
|
||||
|
||||
因此,在很多时候,使用它们操纵数据是弊大于利的。不过,对于硬币的背面,我们也总是有必要去了解的。
|
||||
|
||||
## 思考题
|
||||
|
||||
今天的思考题是:引用类型的值的指针值是有意义的吗?如果没有意义,为什么?如果有意义,意义在哪里?
|
||||
|
||||
[戳此查看Go语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers)
|
||||
|
||||
|
||||
152
极客时间专栏/Go语言核心36讲/模块二:Go语言进阶技术/16 | go语句及其执行规则(上).md
Normal file
152
极客时间专栏/Go语言核心36讲/模块二:Go语言进阶技术/16 | go语句及其执行规则(上).md
Normal file
@@ -0,0 +1,152 @@
|
||||
<audio id="audio" title="16 | go语句及其执行规则(上)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/9a/05/9acf9a9b795b26a45367c0b836b40205.mp3"></audio>
|
||||
|
||||
你很棒,已经学完了关于Go语言数据类型的全部内容。我相信你不但已经知晓了怎样高效地使用Go语言内建的那些数据类型,还明白了怎样正确地创造自己的数据类型。
|
||||
|
||||
对于Go语言的编程知识,你确实已经知道了不少了。不过,如果你真想玩转Go语言还需要知道它的一些特色流程和语法。
|
||||
|
||||
尤其是我们将会在本篇文章中讨论的`go`语句,这也是Go语言的最大特色了。它足可以代表Go语言最重要的编程哲学和并发编程模式。
|
||||
|
||||
让我们再重温一下下面这句话:
|
||||
|
||||
>
|
||||
Don’t communicate by sharing memory; share memory by communicating.
|
||||
|
||||
|
||||
从Go语言编程的角度解释,这句话的意思就是:不要通过共享数据来通讯,恰恰相反,要以通讯的方式共享数据。
|
||||
|
||||
我们已经知道,通道(也就是channel)类型的值,可以被用来以通讯的方式共享数据。更具体地说,它一般被用来在不同的goroutine之间传递数据。那么goroutine到底代表着什么呢?
|
||||
|
||||
简单来说,goroutine代表着并发编程模型中的用户级线程。你可能已经知道,操作系统本身提供了进程和线程,这两种并发执行程序的工具。
|
||||
|
||||
## 前导内容:进程与线程
|
||||
|
||||
进程,描述的就是程序的执行过程,是运行着的程序的代表。换句话说,一个进程其实就是某个程序运行时的一个产物。如果说静静地躺在那里的代码就是程序的话,那么奔跑着的、正在发挥着既有功能的代码就可以被称为进程。
|
||||
|
||||
我们的电脑为什么可以同时运行那么多应用程序?我们的手机为什么可以有那么多App同时在后台刷新?这都是因为在它们的操作系统之上有多个代表着不同应用程序或App的进程在同时运行。
|
||||
|
||||
再来说说线程。首先,线程总是在进程之内的,它可以被视为进程中运行着的控制流(或者说代码执行的流程)。
|
||||
|
||||
一个进程至少会包含一个线程。如果一个进程只包含了一个线程,那么它里面的所有代码都只会被串行地执行。每个进程的第一个线程都会随着该进程的启动而被创建,它们可以被称为其所属进程的主线程。
|
||||
|
||||
相对应的,如果一个进程中包含了多个线程,那么其中的代码就可以被并发地执行。除了进程的第一个线程之外,其他的线程都是由进程中已存在的线程创建出来的。
|
||||
|
||||
也就是说,主线程之外的其他线程都只能由代码显式地创建和销毁。这需要我们在编写程序的时候进行手动控制,操作系统以及进程本身并不会帮我们下达这样的指令,它们只会忠实地执行我们的指令。
|
||||
|
||||
不过,在Go程序当中,Go语言的运行时(runtime)系统会帮助我们自动地创建和销毁系统级的线程。这里的系统级线程指的就是我们刚刚说过的操作系统提供的线程。
|
||||
|
||||
而对应的用户级线程指的是架设在系统级线程之上的,由用户(或者说我们编写的程序)完全控制的代码执行流程。用户级线程的创建、销毁、调度、状态变更以及其中的代码和数据都完全需要我们的程序自己去实现和处理。
|
||||
|
||||
这带来了很多优势,比如,因为它们的创建和销毁并不用通过操作系统去做,所以速度会很快,又比如,由于不用等着操作系统去调度它们的运行,所以往往会很容易控制并且可以很灵活。
|
||||
|
||||
但是,劣势也是有的,最明显也最重要的一个劣势就是复杂。如果我们只使用了系统级线程,那么我们只要指明需要新线程执行的代码片段,并且下达创建或销毁线程的指令就好了,其他的一切具体实现都会由操作系统代劳。
|
||||
|
||||
但是,如果使用用户级线程,我们就不得不既是指令下达者,又是指令执行者。我们必须全权负责与用户级线程有关的所有具体实现。
|
||||
|
||||
操作系统不但不会帮忙,还会要求我们的具体实现必须与它正确地对接,否则用户级线程就无法被并发地,甚至正确地运行。毕竟我们编写的所有代码最终都需要通过操作系统才能在计算机上执行。这听起来就很麻烦,不是吗?
|
||||
|
||||
**不过别担心,Go语言不但有着独特的并发编程模型,以及用户级线程goroutine,还拥有强大的用于调度goroutine、对接系统级线程的调度器。**
|
||||
|
||||
这个调度器是Go语言运行时系统的重要组成部分,它主要负责统筹调配Go并发编程模型中的三个主要元素,即:G(goroutine的缩写)、P(processor的缩写)和M(machine的缩写)。
|
||||
|
||||
其中的M指代的就是系统级线程。而P指的是一种可以承载若干个G,且能够使这些G适时地与M进行对接,并得到真正运行的中介。
|
||||
|
||||
从宏观上说,G和M由于P的存在可以呈现出多对多的关系。当一个正在与某个M对接并运行着的G,需要因某个事件(比如等待I/O或锁的解除)而暂停运行的时候,调度器总会及时地发现,并把这个G与那个M分离开,以释放计算资源供那些等待运行的G使用。
|
||||
|
||||
而当一个G需要恢复运行的时候,调度器又会尽快地为它寻找空闲的计算资源(包括M)并安排运行。另外,当M不够用时,调度器会帮我们向操作系统申请新的系统级线程,而当某个M已无用时,调度器又会负责把它及时地销毁掉。
|
||||
|
||||
正因为调度器帮助我们做了很多事,所以我们的Go程序才总是能高效地利用操作系统和计算机资源。程序中的所有goroutine也都会被充分地调度,其中的代码也都会被并发地运行,即使这样的goroutine有数以十万计,也仍然可以如此。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9e/7d/9ea14f68ffbcde373ddb61e186695d7d.png" alt="">
|
||||
|
||||
** M、P、G之间的关系(简化版)**
|
||||
|
||||
由于篇幅原因,关于Go语言内部的调度器和运行时系统的更多细节,我在这里就不再深入讲述了。你需要知道,Go语言实现了一套非常完善的运行时系统,保证了我们的程序在高并发的情况下依旧能够稳定、高效地运行。
|
||||
|
||||
如果你对这些具体的细节感兴趣,并还想进一步探索,那么我推荐你去看看我写的那本《Go并发编程实战》。我在这本书中用了相当大的篇幅阐释了Go语言并发编程模型的原理、运作机制,以及所有与之紧密相关的知识。
|
||||
|
||||
下面,我会从编程实践的角度出发,以`go`语句的用法为主线,向你介绍`go`语句的执行规则、最佳实践和使用禁忌。
|
||||
|
||||
我们来看一下今天的**问题:什么是主goroutine,它与我们启用的其他goroutine有什么不同?**
|
||||
|
||||
我们具体来看一道我在面试中经常提问的编程题。
|
||||
|
||||
```
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func main() {
|
||||
for i := 0; i < 10; i++ {
|
||||
go func() {
|
||||
fmt.Println(i)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在demo38.go中,我只在`main`函数中写了一条`for`语句。这条`for`语句中的代码会迭代运行10次,并有一个局部变量`i`代表着当次迭代的序号,该序号是从`0`开始的。
|
||||
|
||||
在这条`for`语句中仅有一条`go`语句,这条`go`语句中也仅有一条语句。这条最里面的语句调用了`fmt.Println`函数并想要打印出变量`i`的值。
|
||||
|
||||
这个程序很简单,三条语句逐条嵌套。我的具体问题是:这个命令源码文件被执行后会打印出什么内容?
|
||||
|
||||
这道题的**典型回答**是:不会有任何内容被打印出来。
|
||||
|
||||
## 问题解析
|
||||
|
||||
与一个进程总会有一个主线程类似,每一个独立的Go程序在运行时也总会有一个主goroutine。这个主goroutine会在Go程序的运行准备工作完成后被自动地启用,并不需要我们做任何手动的操作。
|
||||
|
||||
想必你已经知道,每条`go`语句一般都会携带一个函数调用,这个被调用的函数常常被称为`go`函数。而主goroutine的`go`函数就是那个作为程序入口的`main`函数。
|
||||
|
||||
一定要注意,`go`函数真正被执行的时间,总会与其所属的`go`语句被执行的时间不同。当程序执行到一条`go`语句的时候,Go语言的运行时系统,会先试图从某个存放空闲的G的队列中获取一个G(也就是goroutine),它只有在找不到空闲G的情况下才会去创建一个新的G。
|
||||
|
||||
这也是为什么我总会说“启用”一个goroutine,而不说“创建”一个goroutine的原因。已存在的goroutine总是会被优先复用。
|
||||
|
||||
然而,创建G的成本也是非常低的。创建一个G并不会像新建一个进程或者一个系统级线程那样,必须通过操作系统的系统调用来完成,在Go语言的运行时系统内部就可以完全做到了,更何况一个G仅相当于为需要并发执行代码片段服务的上下文环境而已。
|
||||
|
||||
在拿到了一个空闲的G之后,Go语言运行时系统会用这个G去包装当前的那个`go`函数(或者说该函数中的那些代码),然后再把这个G追加到某个存放可运行的G的队列中。
|
||||
|
||||
这类队列中的G总是会按照先入先出的顺序,很快地由运行时系统内部的调度器安排运行。虽然这会很快,但是由于上面所说的那些准备工作还是不可避免的,所以耗时还是存在的。
|
||||
|
||||
因此,`go`函数的执行时间总是会明显滞后于它所属的`go`语句的执行时间。当然了,这里所说的“明显滞后”是对于计算机的CPU时钟和Go程序来说的。我们在大多数时候都不会有明显的感觉。
|
||||
|
||||
在说明了原理之后,我们再来看这种原理下的表象。请记住,只要`go`语句本身执行完毕,Go程序完全不会等待`go`函数的执行,它会立刻去执行后边的语句。这就是所谓的异步并发地执行。
|
||||
|
||||
这里“后边的语句”指的一般是`for`语句中的下一个迭代。然而,当最后一个迭代运行的时候,这个“后边的语句”是不存在的。
|
||||
|
||||
在demo38.go中的那条`for`语句会以很快的速度执行完毕。当它执行完毕时,那10个包装了`go`函数的goroutine往往还没有获得运行的机会。
|
||||
|
||||
请注意,`go`函数中的那个对`fmt.Println`函数的调用是以`for`语句中的变量`i`作为参数的。你可以想象一下,如果当`for`语句执行完毕的时候,这些`go`函数都还没有执行,那么它们引用的变量`i`的值将会是什么?
|
||||
|
||||
它们都会是`10`,对吗?那么这道题的答案会是“打印出10个`10`”,是这样吗?
|
||||
|
||||
在确定最终的答案之前,你还需要知道一个与主goroutine有关的重要特性,即:一旦主goroutine中的代码(也就是`main`函数中的那些代码)执行完毕,当前的Go程序就会结束运行。
|
||||
|
||||
如此一来,如果在Go程序结束的那一刻,还有goroutine未得到运行机会,那么它们就真的没有运行机会了,它们中的代码也就不会被执行了。
|
||||
|
||||
我们刚才谈论过,当`for`语句的最后一个迭代运行的时候,其中的那条`go`语句即是最后一条语句。所以,在执行完这条`go`语句之后,主goroutine中的代码也就执行完了,Go程序会立即结束运行。那么,如果这样的话,还会有任何内容被打印出来吗?
|
||||
|
||||
严谨地讲,Go语言并不会去保证这些goroutine会以怎样的顺序运行。由于主goroutine会与我们手动启用的其他goroutine一起接受调度,又因为调度器很可能会在goroutine中的代码只执行了一部分的时候暂停,以期所有的goroutine有更公平的运行机会。
|
||||
|
||||
所以哪个goroutine先执行完、哪个goroutine后执行完往往是不可预知的,除非我们使用了某种Go语言提供的方式进行了人为干预。然而,在这段代码中,我们并没有进行任何人为干预。
|
||||
|
||||
那答案到底是什么呢?就demo38.go中如此简单的代码而言,绝大多数情况都会是“不会有任何内容被打印出来”。
|
||||
|
||||
但是为了严谨起见,无论应聘者的回答是“打印出10个`10`”还是“不会有任何内容被打印出来”,又或是“打印出乱序的`0`到`9`”,我都会紧接着去追问“为什么?”因为只有你知道了这背后的原理,你做出的回答才会被认为是正确的。
|
||||
|
||||
这个原理是如此的重要,以至于如果你不知道它,那么就几乎无法编写出正确的可并发执行的程序。如果你不知道此原理,那么即使你写的并发程序看起来可以正确地运行,那也肯定是运气好而已。
|
||||
|
||||
## 总结
|
||||
|
||||
今天,我描述了goroutine在操作系统的并发编程体系,以及在Go语言并发编程模型中的地位和作用。这些知识点会为你打下一个坚实的基础。
|
||||
|
||||
我还提到了Go语言内部的运行时系统和调度器,以及它们围绕着goroutine做的那些统筹调配和维护工作。这些内容中的每句话应该都会对你正确理解goroutine起到实质性的作用。你可以用这些知识去解释主问题中的那个程序在运行后为什么会产出那样的结果。
|
||||
|
||||
下一篇内容,我们还会继续围绕go语句以及执行规则谈一些扩展知识,今天留给你的思考题就是:用什么手段可以对goroutine的启用数量加以限制?
|
||||
|
||||
感谢你的收听,我们下次再见。
|
||||
|
||||
[戳此查看Go语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers)
|
||||
|
||||
|
||||
141
极客时间专栏/Go语言核心36讲/模块二:Go语言进阶技术/17 | go语句及其执行规则(下).md
Normal file
141
极客时间专栏/Go语言核心36讲/模块二:Go语言进阶技术/17 | go语句及其执行规则(下).md
Normal file
@@ -0,0 +1,141 @@
|
||||
<audio id="audio" title="17 | go语句及其执行规则(下)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/32/69/320eb1c9984006456cf43768d3b0b369.mp3"></audio>
|
||||
|
||||
你好,我是郝林,今天我们继续分享go语句执行规则的内容。
|
||||
|
||||
在上一篇文章中,我们讲到了goroutine在操作系统的并发编程体系,以及在Go语言并发编程模型中的地位和作用等一系列内容,今天我们继续来聊一聊这个话题。
|
||||
|
||||
## 知识扩展
|
||||
|
||||
**问题1:怎样才能让主goroutine等待其他goroutine?**
|
||||
|
||||
我刚才说过,一旦主goroutine中的代码执行完毕,当前的Go程序就会结束运行,无论其他的goroutine是否已经在运行了。那么,怎样才能做到等其他的goroutine运行完毕之后,再让主goroutine结束运行呢?
|
||||
|
||||
其实有很多办法可以做到这一点。其中,最简单粗暴的办法就是让主goroutine“小睡”一会儿。
|
||||
|
||||
```
|
||||
for i := 0; i < 10; i++ {
|
||||
go func() {
|
||||
fmt.Println(i)
|
||||
}()
|
||||
}
|
||||
time.Sleep(time.Millisecond * 500)
|
||||
|
||||
```
|
||||
|
||||
在`for`语句的后边,我调用了`time`包的`Sleep`函数,并把`time.Millisecond * 500`的结果作为参数值传给了它。`time.Sleep`函数的功能就是让当前的goroutine(在这里就是主goroutine)暂停运行一段时间,直到到达指定的恢复运行时间。
|
||||
|
||||
我们可以把一个相对的时间传给该函数,就像我在这里传入的“500毫秒”那样。`time.Sleep`函数会在被调用时用当前的绝对时间,再加上相对时间计算出在未来的恢复运行时间。显然,一旦到达恢复运行时间,当前的goroutine就会从“睡眠”中醒来,并开始继续执行后边的代码。
|
||||
|
||||
这个办法是可行的,只要“睡眠”的时间不要太短就好。不过,问题恰恰就在这里,我们让主goroutine“睡眠”多长时间才是合适的呢?如果“睡眠”太短,则很可能不足以让其他的goroutine运行完毕,而若“睡眠”太长则纯属浪费时间,这个时间就太难把握了。
|
||||
|
||||
你可能会想到,既然不容易预估时间,那我们就让其他的goroutine在运行完毕的时候告诉我们好了。这个思路很好,但怎么做呢?
|
||||
|
||||
你是否想到了通道呢?我们先创建一个通道,它的长度应该与我们手动启用的goroutine的数量一致。在每个手动启用的goroutine即将运行完毕的时候,我们都要向该通道发送一个值。
|
||||
|
||||
注意,这些发送表达式应该被放在它们的`go`函数体的最后面。对应的,我们还需要在`main`函数的最后从通道接收元素值,接收的次数也应该与手动启用的goroutine的数量保持一致。关于这些你可以到demo39.go文件中,去查看具体的写法。
|
||||
|
||||
其中有一个细节你需要注意。我在声明通道`sign`的时候是以`chan struct{}`作为其类型的。其中的类型字面量`struct{}`有些类似于空接口类型`interface{}`,它代表了既不包含任何字段也不拥有任何方法的空结构体类型。
|
||||
|
||||
注意,`struct{}`类型值的表示法只有一个,即:`struct{}{}`。并且,它占用的内存空间是`0`字节。确切地说,这个值在整个Go程序中永远都只会存在一份。虽然我们可以无数次地使用这个值字面量,但是用到的却都是同一个值。
|
||||
|
||||
当我们仅仅把通道当作传递某种简单信号的介质的时候,用`struct{}`作为其元素类型是再好不过的了。顺便说一句,我在讲“结构体及其方法的使用法门”的时候留过一道与此相关的思考题,你可以返回去看一看。
|
||||
|
||||
再说回当下的问题,有没有比使用通道更好的方法?如果你知道标准库中的代码包`sync`的话,那么可能会想到`sync.WaitGroup`类型。没错,这是一个更好的答案。不过具体的使用方式我在后边讲`sync`包的时候再说。
|
||||
|
||||
**问题2:怎样让我们启用的多个goroutine按照既定的顺序运行?**
|
||||
|
||||
在很多时候,当我沿着上面的主问题以及第一个扩展问题一路问下来的时候,应聘者往往会被这第二个扩展问题难住。
|
||||
|
||||
所以基于上一篇主问题中的代码,怎样做到让从`0`到`9`这几个整数按照自然数的顺序打印出来?你可能会说,我不用goroutine不就可以了嘛。没错,这样是可以,但是如果我不考虑这样做呢。你应该怎么解决这个问题?
|
||||
|
||||
当然了,众多应聘者回答的其他答案也是五花八门的,有的可行,有的不可行,还有的把原来的代码改得面目全非。我下面就来说说我的思路,以及心目中的答案吧。这个答案并不一定是最佳的,也许你在看完之后还可以想到更优的答案。
|
||||
|
||||
首先,我们需要稍微改造一下`for`语句中的那个`go`函数,要让它接受一个`int`类型的参数,并在调用它的时候把变量`i`的值传进去。为了不改动这个`go`函数中的其他代码,我们可以把它的这个参数也命名为`i`。
|
||||
|
||||
```
|
||||
for i := 0; i < 10; i++ {
|
||||
go func(i int) {
|
||||
fmt.Println(i)
|
||||
}(i)
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
只有这样,Go语言才能保证每个goroutine都可以拿到一个唯一的整数。其原因与`go`函数的执行时机有关。
|
||||
|
||||
我在前面已经讲过了。在`go`语句被执行时,我们传给`go`函数的参数`i`会先被求值,如此就得到了当次迭代的序号。之后,无论`go`函数会在什么时候执行,这个参数值都不会变。也就是说,`go`函数中调用的`fmt.Println`函数打印的一定会是那个当次迭代的序号。
|
||||
|
||||
然后,我们在着手改造`for`语句中的`go`函数。
|
||||
|
||||
```
|
||||
for i := uint32(0); i < 10; i++ {
|
||||
go func(i uint32) {
|
||||
fn := func() {
|
||||
fmt.Println(i)
|
||||
}
|
||||
trigger(i, fn)
|
||||
}(i)
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我在`go`函数中先声明了一个匿名的函数,并把它赋给了变量`fn`。这个匿名函数做的事情很简单,只是调用`fmt.Println`函数以打印`go`函数的参数`i`的值。
|
||||
|
||||
在这之后,我调用了一个名叫`trigger`的函数,并把`go`函数的参数`i`和刚刚声明的变量`fn`作为参数传给了它。注意,`for`语句声明的局部变量`i`和`go`函数的参数`i`的类型都变了,都由`int`变为了`uint32`。至于为什么,我一会儿再说。
|
||||
|
||||
再来说`trigger`函数。该函数接受两个参数,一个是`uint32`类型的参数`i`, 另一个是`func()`类型的参数`fn`。你应该记得,`func()`代表的是既无参数声明也无结果声明的函数类型。
|
||||
|
||||
```
|
||||
trigger := func(i uint32, fn func()) {
|
||||
for {
|
||||
if n := atomic.LoadUint32(&count); n == i {
|
||||
fn()
|
||||
atomic.AddUint32(&count, 1)
|
||||
break
|
||||
}
|
||||
time.Sleep(time.Nanosecond)
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
`trigger`函数会不断地获取一个名叫`count`的变量的值,并判断该值是否与参数`i`的值相同。如果相同,那么就立即调用`fn`代表的函数,然后把`count`变量的值加`1`,最后显式地退出当前的循环。否则,我们就先让当前的goroutine“睡眠”一个纳秒再进入下一个迭代。
|
||||
|
||||
注意,我操作变量`count`的时候使用的都是原子操作。这是由于`trigger`函数会被多个goroutine并发地调用,所以它用到的非本地变量`count`,就被多个用户级线程共用了。因此,对它的操作就产生了竞态条件(race condition),破坏了程序的并发安全性。
|
||||
|
||||
所以,我们总是应该对这样的操作加以保护,在`sync/atomic`包中声明了很多用于原子操作的函数。
|
||||
|
||||
另外,由于我选用的原子操作函数对被操作的数值的类型有约束,所以我才对`count`以及相关的变量和参数的类型进行了统一的变更(由`int`变为了`uint32`)。
|
||||
|
||||
纵观`count`变量、`trigger`函数以及改造后的`for`语句和`go`函数,我要做的是,让`count`变量成为一个信号,它的值总是下一个可以调用打印函数的`go`函数的序号。
|
||||
|
||||
这个序号其实就是启用goroutine时,那个当次迭代的序号。也正因为如此,`go`函数实际的执行顺序才会与`go`语句的执行顺序完全一致。此外,这里的`trigger`函数实现了一种自旋(spinning)。除非发现条件已满足,否则它会不断地进行检查。
|
||||
|
||||
最后要说的是,因为我依然想让主goroutine最后一个运行完毕,所以还需要加一行代码。不过既然有了`trigger`函数,我就没有再使用通道。
|
||||
|
||||
```
|
||||
trigger(10, func(){})
|
||||
|
||||
```
|
||||
|
||||
调用`trigger`函数完全可以达到相同的效果。由于当所有我手动启用的goroutine都运行完毕之后,`count`的值一定会是`10`,所以我就把`10`作为了第一个参数值。又由于我并不想打印这个`10`,所以我把一个什么都不做的函数作为了第二个参数值。
|
||||
|
||||
总之,通过上述的改造,我使得异步发起的`go`函数得到了同步地(或者说按照既定顺序地)执行,你也可以动手自己试一试,感受一下。
|
||||
|
||||
**总结**
|
||||
|
||||
在本篇文章中,我们接着上一篇文章的主问题,讨论了当我们想让运行结果更加可控的时候,应该怎样去做。
|
||||
|
||||
主goroutine的运行若过早结束,那么我们的并发程序的功能就很可能无法全部完成。所以我们往往需要通过一些手段去进行干涉,比如调用`time.Sleep`函数或者使用通道。我们在后面的文章中还会讨论更高级的手段。
|
||||
|
||||
另外,`go`函数的实际执行顺序往往与其所属的`go`语句的执行顺序(或者说goroutine的启用顺序)不同,而且默认情况下的执行顺序是不可预知的。那怎样才能让这两个顺序一致呢?其实复杂的实现方式有不少,但是可能会把原来的代码改得面目全非。我在这里提供了一种比较简单、清晰的改造方案,供你参考。
|
||||
|
||||
总之,我希望通过上述基础知识以及三个连贯的问题帮你串起一条主线。这应该会让你更快地深入理解goroutine及其背后的并发编程模型,从而更加游刃有余地使用`go`语句。
|
||||
|
||||
**思考题**
|
||||
|
||||
1.`runtime`包中提供了哪些与模型三要素G、P和M相关的函数?(模型三要素内容在上一篇)
|
||||
|
||||
[戳此查看Go语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers)
|
||||
|
||||
|
||||
251
极客时间专栏/Go语言核心36讲/模块二:Go语言进阶技术/18 | if语句、for语句和switch语句.md
Normal file
251
极客时间专栏/Go语言核心36讲/模块二:Go语言进阶技术/18 | if语句、for语句和switch语句.md
Normal file
@@ -0,0 +1,251 @@
|
||||
<audio id="audio" title="18 | if语句、for语句和switch语句" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b4/f4/b4aac5abbe96b64f4e3f62a81a0abff4.mp3"></audio>
|
||||
|
||||
在上两篇文章中,我主要为你讲解了与`go`语句、goroutine和Go语言调度器有关的知识和技法。
|
||||
|
||||
内容很多,你不用急于完全消化,可以在编程实践过程中逐步理解和感悟,争取夯实它们。
|
||||
|
||||
现在,让我们暂时走下神坛,回归民间。我今天要讲的`if`语句、`for`语句和`switch`语句都属于Go语言的基本流程控制语句。它们的语法看起来很朴素,但实际上也会有一些使用技巧和注意事项。我在本篇文章中会以一系列面试题为线索,为你讲述它们的用法。
|
||||
|
||||
那么,**今天的问题是:使用携带`range`子句的`for`语句时需要注意哪些细节?** 这是一个比较笼统的问题。我还是通过编程题来讲解吧。
|
||||
|
||||
>
|
||||
本问题中的代码都被放在了命令源码文件demo41.go的`main`函数中的。为了专注问题本身,本篇文章中展示的编程题会省略掉一部分代码包声明语句、代码包导入语句和`main`函数本身的声明部分。
|
||||
|
||||
|
||||
```
|
||||
numbers1 := []int{1, 2, 3, 4, 5, 6}
|
||||
for i := range numbers1 {
|
||||
if i == 3 {
|
||||
numbers1[i] |= i
|
||||
}
|
||||
}
|
||||
fmt.Println(numbers1)
|
||||
|
||||
```
|
||||
|
||||
我先声明了一个元素类型为`int`的切片类型的变量`numbers1`,在该切片中有6个元素值,分别是从`1`到`6`的整数。我用一条携带`range`子句的`for`语句去迭代`numbers1`变量中的所有元素值。
|
||||
|
||||
在这条`for`语句中,只有一个迭代变量`i`。我在每次迭代时,都会先去判断`i`的值是否等于`3`,如果结果为`true`,那么就让`numbers1`的第`i`个元素值与`i`本身做按位或的操作,再把操作结果作为`numbers1`的新的第`i`个元素值。最后我会打印出`numbers1`的值。
|
||||
|
||||
所以具体的问题就是,这段代码执行后会打印出什么内容?
|
||||
|
||||
这里的**典型回答**是:打印的内容会是`[1 2 3 7 5 6]`。
|
||||
|
||||
## 问题解析
|
||||
|
||||
你心算得到的答案是这样吗?让我们一起来复现一下这个计算过程。
|
||||
|
||||
当`for`语句被执行的时候,在`range`关键字右边的`numbers1`会先被求值。
|
||||
|
||||
这个位置上的代码被称为`range`表达式。`range`表达式的结果值可以是数组、数组的指针、切片、字符串、字典或者允许接收操作的通道中的某一个,并且结果值只能有一个。
|
||||
|
||||
对于不同种类的`range`表达式结果值,`for`语句的迭代变量的数量可以有所不同。
|
||||
|
||||
就拿我们这里的`numbers1`来说,它是一个切片,那么迭代变量就可以有两个,右边的迭代变量代表当次迭代对应的某一个元素值,而左边的迭代变量则代表该元素值在切片中的索引值。
|
||||
|
||||
那么,如果像本题代码中的`for`语句那样,只有一个迭代变量的情况意味着什么呢?这意味着,该迭代变量只会代表当次迭代对应的元素值的索引值。
|
||||
|
||||
更宽泛地讲,当只有一个迭代变量的时候,数组、数组的指针、切片和字符串的元素值都是无处安放的,我们只能拿到按照从小到大顺序给出的一个个索引值。
|
||||
|
||||
因此,这里的迭代变量`i`的值会依次是从`0`到`5`的整数。当`i`的值等于`3`的时候,与之对应的是切片中的第4个元素值`4`。对`4`和`3`进行按位或操作得到的结果是`7`。这就是答案中的第4个整数是`7`的原因了。
|
||||
|
||||
**现在,我稍稍修改一下上面的代码。我们再来估算一下打印内容。**
|
||||
|
||||
```
|
||||
numbers2 := [...]int{1, 2, 3, 4, 5, 6}
|
||||
maxIndex2 := len(numbers2) - 1
|
||||
for i, e := range numbers2 {
|
||||
if i == maxIndex2 {
|
||||
numbers2[0] += e
|
||||
} else {
|
||||
numbers2[i+1] += e
|
||||
}
|
||||
}
|
||||
fmt.Println(numbers2)
|
||||
|
||||
```
|
||||
|
||||
注意,我把迭代的对象换成了`numbers2`。`numbers2`中的元素值同样是从`1`到`6`的6个整数,并且元素类型同样是`int`,但它是一个数组而不是一个切片。
|
||||
|
||||
在`for`语句中,我总是会对紧挨在当次迭代对应的元素后边的那个元素,进行重新赋值,新的值会是这两个元素的值之和。当迭代到最后一个元素时,我会把此`range`表达式结果值中的第一个元素值,替换为它的原值与最后一个元素值的和,最后,我会打印出`numbers2`的值。
|
||||
|
||||
**对于这段代码,我的问题依旧是:打印的内容会是什么?你可以先思考一下。**
|
||||
|
||||
好了,我要公布答案了。打印的内容会是`[7 3 5 7 9 11]`。我先来重现一下计算过程。当`for`语句被执行的时候,在`range`关键字右边的`numbers2`会先被求值。
|
||||
|
||||
这里需要注意两点:
|
||||
|
||||
1. `range`表达式只会在`for`语句开始执行时被求值一次,无论后边会有多少次迭代;
|
||||
1. `range`表达式的求值结果会被复制,也就是说,被迭代的对象是`range`表达式结果值的副本而不是原值。
|
||||
|
||||
基于这两个规则,我们接着往下看。在第一次迭代时,我改变的是`numbers2`的第二个元素的值,新值为`3`,也就是`1`和`2`之和。
|
||||
|
||||
但是,被迭代的对象的第二个元素却没有任何改变,毕竟它与`numbers2`已经是毫不相关的两个数组了。因此,在第二次迭代时,我会把`numbers2`的第三个元素的值修改为`5`,即被迭代对象的第二个元素值`2`和第三个元素值`3`的和。
|
||||
|
||||
以此类推,之后的`numbers2`的元素值依次会是`7`、`9`和`11`。当迭代到最后一个元素时,我会把`numbers2`的第一个元素的值修改为`1`和`6`之和。
|
||||
|
||||
好了,现在该你操刀了。你需要把`numbers2`的值由一个数组改成一个切片,其中的元素值都不要变。为了避免混淆,你还要把这个切片值赋给变量`numbers3`,并且把后边代码中所有的`numbers2`都改为`numbers3`。
|
||||
|
||||
问题是不变的,执行这段修改版的代码后打印的内容会是什么呢?如果你实在估算不出来,可以先实际执行一下,然后再尝试解释看到的答案。提示一下,切片与数组是不同的,前者是引用类型的,而后者是值类型的。
|
||||
|
||||
我们可以先接着讨论后边的内容,但是我强烈建议你一定要回来,再看看我留给你的这个问题,认真地思考和计算一下。
|
||||
|
||||
**知识扩展**
|
||||
|
||||
**问题1:`switch`语句中的`switch`表达式和`case`表达式之间有着怎样的联系?**
|
||||
|
||||
先来看一段代码。
|
||||
|
||||
```
|
||||
value1 := [...]int8{0, 1, 2, 3, 4, 5, 6}
|
||||
switch 1 + 3 {
|
||||
case value1[0], value1[1]:
|
||||
fmt.Println("0 or 1")
|
||||
case value1[2], value1[3]:
|
||||
fmt.Println("2 or 3")
|
||||
case value1[4], value1[5], value1[6]:
|
||||
fmt.Println("4 or 5 or 6")
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我先声明了一个数组类型的变量`value1`,该变量的元素类型是`int8`。在后边的`switch`语句中,被夹在`switch`关键字和左花括号`{`之间的是`1 + 3`,这个位置上的代码被称为`switch`表达式。这个`switch`语句还包含了三个`case`子句,而每个`case`子句又各包含了一个`case`表达式和一条打印语句。
|
||||
|
||||
所谓的`case`表达式一般由`case`关键字和一个表达式列表组成,表达式列表中的多个表达式之间需要有英文逗号`,`分割,比如,上面代码中的`case value1[0], value1[1]`就是一个`case`表达式,其中的两个子表达式都是由索引表达式表示的。
|
||||
|
||||
另外的两个`case`表达式分别是`case value1[2], value1[3]`和`case value1[4], value1[5], value1[6]`。
|
||||
|
||||
此外,在这里的每个`case`子句中的那些打印语句,会分别打印出不同的内容,这些内容用于表示`case`子句被选中的原因,比如,打印内容`0 or 1`表示当前`case`子句被选中是因为`switch`表达式的结果值等于`0`或`1`中的某一个。另外两条打印语句会分别打印出`2 or 3`和`4 or 5 or 6`。
|
||||
|
||||
现在问题来了,拥有这样三个`case`表达式的`switch`语句可以成功通过编译吗?如果不可以,原因是什么?如果可以,那么该`switch`语句被执行后会打印出什么内容。
|
||||
|
||||
我刚才说过,只要`switch`表达式的结果值与某个`case`表达式中的任意一个子表达式的结果值相等,该`case`表达式所属的`case`子句就会被选中。
|
||||
|
||||
并且,一旦某个`case`子句被选中,其中的附带在`case`表达式后边的那些语句就会被执行。与此同时,其他的所有`case`子句都会被忽略。
|
||||
|
||||
当然了,如果被选中的`case`子句附带的语句列表中包含了`fallthrough`语句,那么紧挨在它下边的那个`case`子句附带的语句也会被执行。
|
||||
|
||||
正因为存在上述判断相等的操作(以下简称判等操作),`switch`语句对`switch`表达式的结果类型,以及各个`case`表达式中子表达式的结果类型都是有要求的。毕竟,在Go语言中,只有类型相同的值之间才有可能被允许进行判等操作。
|
||||
|
||||
如果`switch`表达式的结果值是无类型的常量,比如`1 + 3`的求值结果就是无类型的常量`4`,那么这个常量会被自动地转换为此种常量的默认类型的值,比如整数`4`的默认类型是`int`,又比如浮点数`3.14`的默认类型是`float64`。
|
||||
|
||||
因此,由于上述代码中的`switch`表达式的结果类型是`int`,而那些`case`表达式中子表达式的结果类型却是`int8`,它们的类型并不相同,所以这条`switch`语句是无法通过编译的。
|
||||
|
||||
再来看一段很类似的代码:
|
||||
|
||||
```
|
||||
value2 := [...]int8{0, 1, 2, 3, 4, 5, 6}
|
||||
switch value2[4] {
|
||||
case 0, 1:
|
||||
fmt.Println("0 or 1")
|
||||
case 2, 3:
|
||||
fmt.Println("2 or 3")
|
||||
case 4, 5, 6:
|
||||
fmt.Println("4 or 5 or 6")
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
其中的变量`value2`与`value1`的值是完全相同的。但不同的是,我把`switch`表达式换成了`value2[4]`,并把下边那三个`case`表达式分别换为了`case 0, 1`、`case 2, 3`和`case 4, 5, 6`。
|
||||
|
||||
如此一来,`switch`表达式的结果值是`int8`类型的,而那些`case`表达式中子表达式的结果值却是无类型的常量了。这与之前的情况恰恰相反。那么,这样的`switch`语句可以通过编译吗?
|
||||
|
||||
答案是肯定的。因为,如果`case`表达式中子表达式的结果值是无类型的常量,那么它的类型会被自动地转换为`switch`表达式的结果类型,又由于上述那几个整数都可以被转换为`int8`类型的值,所以对这些表达式的结果值进行判等操作是没有问题的。
|
||||
|
||||
当然了,如果这里说的自动转换没能成功,那么`switch`语句照样通不过编译。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/91/1c/91add0a66b9956f81086285aabc20c1c.png" alt="">
|
||||
|
||||
(switch语句中的自动类型转换)
|
||||
|
||||
通过上面这两道题,你应该可以搞清楚`switch`表达式和`case`表达式之间的联系了。由于需要进行判等操作,所以前者和后者中的子表达式的结果类型需要相同。
|
||||
|
||||
`switch`语句会进行有限的类型转换,但肯定不能保证这种转换可以统一它们的类型。还要注意,如果这些表达式的结果类型有某个接口类型,那么一定要小心检查它们的动态值是否都具有可比性(或者说是否允许判等操作)。
|
||||
|
||||
因为,如果答案是否定的,虽然不会造成编译错误,但是后果会更加严重:引发panic(也就是运行时恐慌)。
|
||||
|
||||
**问题2:`switch`语句对它的`case`表达式有哪些约束?**
|
||||
|
||||
我在上一个问题的阐述中还重点表达了一点,不知你注意到了没有,那就是:`switch`语句在`case`子句的选择上是具有唯一性的。
|
||||
|
||||
正因为如此,`switch`语句不允许`case`表达式中的子表达式结果值存在相等的情况,不论这些结果值相等的子表达式,是否存在于不同的`case`表达式中,都会是这样的结果。具体请看这段代码:
|
||||
|
||||
```
|
||||
value3 := [...]int8{0, 1, 2, 3, 4, 5, 6}
|
||||
switch value3[4] {
|
||||
case 0, 1, 2:
|
||||
fmt.Println("0 or 1 or 2")
|
||||
case 2, 3, 4:
|
||||
fmt.Println("2 or 3 or 4")
|
||||
case 4, 5, 6:
|
||||
fmt.Println("4 or 5 or 6")
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
变量`value3`的值同`value1`,依然是由从`0`到`6`的7个整数组成的数组,元素类型是`int8`。`switch`表达式是`value3[4]`,三个`case`表达式分别是`case 0, 1, 2`、`case 2, 3, 4`和`case 4, 5, 6`。
|
||||
|
||||
由于在这三个`case`表达式中存在结果值相等的子表达式,所以这个`switch`语句无法通过编译。不过,好在这个约束本身还有个约束,那就是只针对结果值为常量的子表达式。
|
||||
|
||||
比如,子表达式`1+1`和`2`不能同时出现,`1+3`和`4`也不能同时出现。有了这个约束的约束,我们就可以想办法绕过这个对子表达式的限制了。再看一段代码:
|
||||
|
||||
```
|
||||
value5 := [...]int8{0, 1, 2, 3, 4, 5, 6}
|
||||
switch value5[4] {
|
||||
case value5[0], value5[1], value5[2]:
|
||||
fmt.Println("0 or 1 or 2")
|
||||
case value5[2], value5[3], value5[4]:
|
||||
fmt.Println("2 or 3 or 4")
|
||||
case value5[4], value5[5], value5[6]:
|
||||
fmt.Println("4 or 5 or 6")
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
变量名换成了`value5`,但这不是重点。重点是,我把`case`表达式中的常量都换成了诸如`value5[0]`这样的索引表达式。
|
||||
|
||||
虽然第一个`case`表达式和第二个`case`表达式都包含了`value5[2]`,并且第二个`case`表达式和第三个`case`表达式都包含了`value5[4]`,但这已经不是问题了。这条`switch`语句可以成功通过编译。
|
||||
|
||||
不过,这种绕过方式对用于类型判断的`switch`语句(以下简称为类型`switch`语句)就无效了。因为类型`switch`语句中的`case`表达式的子表达式,都必须直接由类型字面量表示,而无法通过间接的方式表示。代码如下:
|
||||
|
||||
```
|
||||
value6 := interface{}(byte(127))
|
||||
switch t := value6.(type) {
|
||||
case uint8, uint16:
|
||||
fmt.Println("uint8 or uint16")
|
||||
case byte:
|
||||
fmt.Printf("byte")
|
||||
default:
|
||||
fmt.Printf("unsupported type: %T", t)
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
变量`value6`的值是空接口类型的。该值包装了一个`byte`类型的值`127`。我在后面使用类型`switch`语句来判断`value6`的实际类型,并打印相应的内容。
|
||||
|
||||
这里有两个普通的`case`子句,还有一个`default case`子句。前者的`case`表达式分别是`case uint8, uint16`和`case byte`。你还记得吗?`byte`类型是`uint8`类型的别名类型。
|
||||
|
||||
因此,它们两个本质上是同一个类型,只是类型名称不同罢了。在这种情况下,这个类型`switch`语句是无法通过编译的,因为子表达式`byte`和`uint8`重复了。好了,以上说的就是`case`表达式的约束以及绕过方式,你学会了吗。
|
||||
|
||||
**总结**
|
||||
|
||||
我们今天主要讨论了`for`语句和`switch`语句,不过我并没有说明那些语法规则,因为它们太简单了。我们需要多加注意的往往是那些隐藏在Go语言规范和最佳实践里的细节。
|
||||
|
||||
这些细节其实就是我们很多技术初学者所谓的“坑”。比如,我在讲`for`语句的时候交代了携带`range`子句时只有一个迭代变量意味着什么。你必须知道在迭代数组或切片时只有一个迭代变量的话是无法迭代出其中的元素值的,否则你的程序可能就不会像你预期的那样运行了。
|
||||
|
||||
还有,`range`表达式的结果值是会被复制的,实际迭代时并不会使用原值。至于会影响到什么,那就要看这个结果值的类型是值类型还是引用类型了。
|
||||
|
||||
说到`switch`语句,你要明白其中的`case`表达式的所有子表达式的结果值都是要与`switch`表达式的结果值判等的,因此它们的类型必须相同或者能够都统一到`switch`表达式的结果类型。如果无法做到,那么这条`switch`语句就不能通过编译。
|
||||
|
||||
最后,同一条`switch`语句中的所有`case`表达式的子表达式的结果值不能重复,不过好在这只是对于由字面量直接表示的子表达式而言的。
|
||||
|
||||
请记住,普通`case`子句的编写顺序很重要,最上边的`case`子句中的子表达式总是会被最先求值,在判等的时候顺序也是这样。因此,如果某些子表达式的结果值有重复并且它们与`switch`表达式的结果值相等,那么位置靠上的`case`子句总会被选中。
|
||||
|
||||
**思考题**
|
||||
|
||||
1. 在类型`switch`语句中,我们怎样对被判断类型的那个值做相应的类型转换?
|
||||
1. 在`if`语句中,初始化子句声明的变量的作用域是什么?
|
||||
|
||||
[戳此查看Go语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers)
|
||||
|
||||
|
||||
165
极客时间专栏/Go语言核心36讲/模块二:Go语言进阶技术/19 | 错误处理(上).md
Normal file
165
极客时间专栏/Go语言核心36讲/模块二:Go语言进阶技术/19 | 错误处理(上).md
Normal file
@@ -0,0 +1,165 @@
|
||||
<audio id="audio" title="19 | 错误处理(上)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/87/ef/8721ac7cd9292553db302c15cb996bef.mp3"></audio>
|
||||
|
||||
提到Go语言中的错误处理,我们其实已经在前面接触过几次了。
|
||||
|
||||
比如,我们声明过`error`类型的变量`err`,也调用过`errors`包中的`New`函数。今天,我会用这篇文章为你梳理Go语言错误处理的相关知识,同时提出一些关键问题并与你一起探讨。
|
||||
|
||||
我们说过`error`类型其实是一个接口类型,也是一个Go语言的内建类型。在这个接口类型的声明中只包含了一个方法`Error`。`Error`方法不接受任何参数,但是会返回一个`string`类型的结果。它的作用是返回错误信息的字符串表示形式。
|
||||
|
||||
我们使用`error`类型的方式通常是,在函数声明的结果列表的最后,声明一个该类型的结果,同时在调用这个函数之后,先判断它返回的最后一个结果值是否“不为`nil`”。
|
||||
|
||||
如果这个值“不为`nil`”,那么就进入错误处理流程,否则就继续进行正常的流程。下面是一个例子,代码在demo44.go文件中。
|
||||
|
||||
```
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func echo(request string) (response string, err error) {
|
||||
if request == "" {
|
||||
err = errors.New("empty request")
|
||||
return
|
||||
}
|
||||
response = fmt.Sprintf("echo: %s", request)
|
||||
return
|
||||
}
|
||||
|
||||
func main() {
|
||||
for _, req := range []string{"", "hello!"} {
|
||||
fmt.Printf("request: %s\n", req)
|
||||
resp, err := echo(req)
|
||||
if err != nil {
|
||||
fmt.Printf("error: %s\n", err)
|
||||
continue
|
||||
}
|
||||
fmt.Printf("response: %s\n", resp)
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们先看`echo`函数的声明。`echo`函数接受一个`string`类型的参数`request`,并会返回两个结果。
|
||||
|
||||
这两个结果都是有名称的,第一个结果`response`也是`string`类型的,它代表了这个函数正常执行后的结果值。
|
||||
|
||||
第二个结果`err`就是`error`类型的,它代表了函数执行出错时的结果值,同时也包含了具体的错误信息。
|
||||
|
||||
当`echo`函数被调用时,它会先检查参数`request`的值。如果该值为空字符串,那么它就会通过调用`errors.New`函数,为结果`err`赋值,然后忽略掉后边的操作并直接返回。
|
||||
|
||||
此时,结果`response`的值也会是一个空字符串。如果`request`的值并不是空字符串,那么它就为结果`response`赋一个适当的值,然后返回,此时结果`err`的值会是`nil`。
|
||||
|
||||
再来看`main`函数中的代码。我在每次调用`echo`函数之后,都会把它返回的结果值赋给变量`resp`和`err`,并且总是先检查`err`的值是否“不为`nil`”,如果是,就打印错误信息,否则就打印常规的响应信息。
|
||||
|
||||
这里值得注意的地方有两个。第一,在`echo`函数和`main`函数中,我都使用到了卫述语句。我在前面讲函数用法的时候也提到过卫述语句。简单地讲,它就是被用来检查后续操作的前置条件并进行相应处理的语句。
|
||||
|
||||
对于`echo`函数来说,它进行常规操作的前提是:传入的参数值一定要符合要求。而对于调用`echo`函数的程序来说,进行后续操作的前提就是`echo`函数的执行不能出错。
|
||||
|
||||
>
|
||||
我们在进行错误处理的时候经常会用到卫述语句,以至于有些人会吐槽说:“我的程序满屏都是卫述语句,简直是太难看了!”
|
||||
不过,我倒认为这有可能是程序设计上的问题。每个编程语言的理念和风格几乎都会有明显的不同,我们常常需要顺应它们的纹理去做设计,而不是用其他语言的编程思想来编写当下语言的程序。
|
||||
|
||||
|
||||
再来说第二个值得注意的地方。我在生成`error`类型值的时候,用到了`errors.New`函数。
|
||||
|
||||
这是一种最基本的生成错误值的方式。我们调用它的时候传入一个由字符串代表的错误信息,它会给返回给我们一个包含了这个错误信息的`error`类型值。该值的静态类型当然是`error`,而动态类型则是一个在`errors`包中的,包级私有的类型`*errorString`。
|
||||
|
||||
显然,`errorString`类型拥有的一个指针方法实现了`error`接口中的`Error`方法。这个方法在被调用后,会原封不动地返回我们之前传入的错误信息。实际上,`error`类型值的`Error`方法就相当于其他类型值的`String`方法。
|
||||
|
||||
我们已经知道,通过调用`fmt.Printf`函数,并给定占位符`%s`就可以打印出某个值的字符串表示形式。
|
||||
|
||||
对于其他类型的值来说,只要我们能为这个类型编写一个`String`方法,就可以自定义它的字符串表示形式。而对于`error`类型值,它的字符串表示形式则取决于它的`Error`方法。
|
||||
|
||||
在上述情况下,`fmt.Printf`函数如果发现被打印的值是一个`error`类型的值,那么就会去调用它的`Error`方法。`fmt`包中的这类打印函数其实都是这么做的。
|
||||
|
||||
顺便提一句,当我们想通过模板化的方式生成错误信息,并得到错误值时,可以使用`fmt.Errorf`函数。该函数所做的其实就是先调用`fmt.Sprintf`函数,得到确切的错误信息;再调用`errors.New`函数,得到包含该错误信息的`error`类型值,最后返回该值。
|
||||
|
||||
好了,我现在问一个关于对错误值做判断的问题。我们今天的**问题是:对于具体错误的判断,Go语言中都有哪些惯用法?**
|
||||
|
||||
由于`error`是一个接口类型,所以即使同为`error`类型的错误值,它们的实际类型也可能不同。这个问题还可以换一种问法,即:怎样判断一个错误值具体代表的是哪一类错误?
|
||||
|
||||
这道题的**典型回答**是这样的:
|
||||
|
||||
1. 对于类型在已知范围内的一系列错误值,一般使用类型断言表达式或类型`switch`语句来判断;
|
||||
1. 对于已有相应变量且类型相同的一系列错误值,一般直接使用判等操作来判断;
|
||||
1. 对于没有相应变量且类型未知的一系列错误值,只能使用其错误信息的字符串表示形式来做判断。
|
||||
|
||||
## 问题解析
|
||||
|
||||
如果你看过一些Go语言标准库的源代码,那么对这几种情况应该都不陌生。我下面分别对它们做个说明。
|
||||
|
||||
类型在已知范围内的错误值其实是最容易分辨的。就拿`os`包中的几个代表错误的类型`os.PathError`、`os.LinkError`、`os.SyscallError`和`os/exec.Error`来说,它们的指针类型都是`error`接口的实现类型,同时它们也都包含了一个名叫`Err`,类型为`error`接口类型的代表潜在错误的字段。
|
||||
|
||||
如果我们得到一个`error`类型值,并且知道该值的实际类型肯定是它们中的某一个,那么就可以用类型`switch`语句去做判断。例如:
|
||||
|
||||
```
|
||||
func underlyingError(err error) error {
|
||||
switch err := err.(type) {
|
||||
case *os.PathError:
|
||||
return err.Err
|
||||
case *os.LinkError:
|
||||
return err.Err
|
||||
case *os.SyscallError:
|
||||
return err.Err
|
||||
case *exec.Error:
|
||||
return err.Err
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
函数`underlyingError`的作用是:获取和返回已知的操作系统相关错误的潜在错误值。其中的类型`switch`语句中有若干个`case`子句,分别对应了上述几个错误类型。当它们被选中时,都会把函数参数`err`的`Err`字段作为结果值返回。如果它们都未被选中,那么该函数就会直接把参数值作为结果返回,即放弃获取潜在错误值。
|
||||
|
||||
只要类型不同,我们就可以如此分辨。但是在错误值类型相同的情况下,这些手段就无能为力了。在Go语言的标准库中也有不少以相同方式创建的同类型的错误值。
|
||||
|
||||
我们还拿`os`包来说,其中不少的错误值都是通过调用`errors.New`函数来初始化的,比如:`os.ErrClosed`、`os.ErrInvalid`以及`os.ErrPermission`,等等。
|
||||
|
||||
注意,与前面讲到的那些错误类型不同,这几个都是已经定义好的、确切的错误值。`os`包中的代码有时候会把它们当做潜在错误值,封装进前面那些错误类型的值中。
|
||||
|
||||
如果我们在操作文件系统的时候得到了一个错误值,并且知道该值的潜在错误值肯定是上述值中的某一个,那么就可以用普通的`switch`语句去做判断,当然了,用`if`语句和判等操作符也是可以的。例如:
|
||||
|
||||
```
|
||||
printError := func(i int, err error) {
|
||||
if err == nil {
|
||||
fmt.Println("nil error")
|
||||
return
|
||||
}
|
||||
err = underlyingError(err)
|
||||
switch err {
|
||||
case os.ErrClosed:
|
||||
fmt.Printf("error(closed)[%d]: %s\n", i, err)
|
||||
case os.ErrInvalid:
|
||||
fmt.Printf("error(invalid)[%d]: %s\n", i, err)
|
||||
case os.ErrPermission:
|
||||
fmt.Printf("error(permission)[%d]: %s\n", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这个由`printError`变量代表的函数会接受一个`error`类型的参数值。该值总会代表某个文件操作相关的错误,这是我故意地以不正确的方式操作文件后得到的。
|
||||
|
||||
虽然我不知道这些错误值的类型的范围,但却知道它们或它们的潜在错误值一定是某个已经在`os`包中定义的值。
|
||||
|
||||
所以,我先用`underlyingError`函数得到它们的潜在错误值,当然也可能只得到原错误值而已。然后,我用`switch`语句对错误值进行判等操作,三个`case`子句分别对应我刚刚提到的那三个已存在于`os`包中的错误值。如此一来,我就能分辨出具体错误了。
|
||||
|
||||
对于上面这两种情况,我们都有明确的方式去解决。但是,如果我们对一个错误值可能代表的含义知之甚少,那么就只能通过它拥有的错误信息去做判断了。
|
||||
|
||||
好在我们总是能通过错误值的`Error`方法,拿到它的错误信息。其实`os`包中就有做这种判断的函数,比如:`os.IsExist`、`os.IsNotExist`和`os.IsPermission`。命令源码文件demo45.go中包含了对它们的应用,这大致跟前面展示的代码差不太多,我就不在这里赘述了。
|
||||
|
||||
## 总结
|
||||
|
||||
今天我们一起初步学习了错误处理的内容。我们总结了错误类型、错误值的处理技巧和设计方式,并一起分享了Go语言中处理错误的最基本方式。由于错误处理的内容分为上下两篇,在下一次的文章中,我们会站在建造者的角度,一起来探索一下:怎样根据实际情况给予恰当的错误值。
|
||||
|
||||
## 思考题
|
||||
|
||||
请列举出你经常用到或者看到的3个错误类型,它们所在的错误类型体系都是怎样的?你能画出一棵树来描述它们吗?
|
||||
|
||||
感谢你的收听,我们下期再见。
|
||||
|
||||
[戳此查看Go语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers)
|
||||
|
||||
|
||||
87
极客时间专栏/Go语言核心36讲/模块二:Go语言进阶技术/20 | 错误处理 (下).md
Normal file
87
极客时间专栏/Go语言核心36讲/模块二:Go语言进阶技术/20 | 错误处理 (下).md
Normal file
@@ -0,0 +1,87 @@
|
||||
<audio id="audio" title="20 | 错误处理 (下)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/25/b7/254d1d0385a81bd3cbe5530feeca68b7.mp3"></audio>
|
||||
|
||||
你好,我是郝林,今天我们继续来分享错误处理。
|
||||
|
||||
在上一篇文章中,我们主要讨论的是从使用者的角度看“怎样处理好错误值”。那么,接下来我们需要关注的,就是站在建造者的角度,去关心“怎样才能给予使用者恰当的错误值”的问题了。
|
||||
|
||||
## 知识扩展
|
||||
|
||||
**问题:怎样根据实际情况给予恰当的错误值?**
|
||||
|
||||
我们已经知道,构建错误值体系的基本方式有两种,即:创建立体的错误类型体系和创建扁平的错误值列表。
|
||||
|
||||
先说错误类型体系。由于在Go语言中实现接口是非侵入式的,所以我们可以做得很灵活。比如,在标准库的`net`代码包中,有一个名为`Error`的接口类型。它算是内建接口类型`error`的一个扩展接口,因为`error`是`net.Error`的嵌入接口。
|
||||
|
||||
`net.Error`接口除了拥有`error`接口的`Error`方法之外,还有两个自己声明的方法:`Timeout`和`Temporary`。
|
||||
|
||||
`net`包中有很多错误类型都实现了`net.Error`接口,比如:
|
||||
|
||||
1. `*net.OpError`;
|
||||
1. `*net.AddrError`;
|
||||
1. `net.UnknownNetworkError`等等。
|
||||
|
||||
你可以把这些错误类型想象成一棵树,内建接口`error`就是树的根,而`net.Error`接口就是一个在根上延伸的第一级非叶子节点。
|
||||
|
||||
同时,你也可以把这看做是一种多层分类的手段。当`net`包的使用者拿到一个错误值的时候,可以先判断它是否是`net.Error`类型的,也就是说该值是否代表了一个网络相关的错误。
|
||||
|
||||
如果是,那么我们还可以再进一步判断它的类型是哪一个更具体的错误类型,这样就能知道这个网络相关的错误具体是由于操作不当引起的,还是因为网络地址问题引起的,又或是由于网络协议不正确引起的。
|
||||
|
||||
当我们细看`net`包中的这些具体错误类型的实现时,还会发现,与`os`包中的一些错误类型类似,它们也都有一个名为`Err`、类型为`error`接口类型的字段,代表的也是当前错误的潜在错误。
|
||||
|
||||
所以说,这些错误类型的值之间还可以有另外一种关系,即:链式关系。比如说,使用者调用`net.DialTCP`之类的函数时,`net`包中的代码可能会返回给他一个`*net.OpError`类型的错误值,以表示由于他的操作不当造成了一个错误。
|
||||
|
||||
同时,这些代码还可能会把一个`*net.AddrError`或`net.UnknownNetworkError`类型的值赋给该错误值的`Err`字段,以表明导致这个错误的潜在原因。如果,此处的潜在错误值的`Err`字段也有非`nil`的值,那么将会指明更深层次的错误原因。如此一级又一级就像链条一样最终会指向问题的根源。
|
||||
|
||||
把以上这些内容总结成一句话就是,用类型建立起树形结构的错误体系,用统一字段建立起可追根溯源的链式错误关联。这是Go语言标准库给予我们的优秀范本,非常有借鉴意义。
|
||||
|
||||
不过要注意,如果你不想让包外代码改动你返回的错误值的话,一定要小写其中字段的名称首字母。你可以通过暴露某些方法让包外代码有进一步获取错误信息的权限,比如编写一个可以返回包级私有的`err`字段值的公开方法`Err`。
|
||||
|
||||
相比于立体的错误类型体系,扁平的错误值列表就要简单得多了。当我们只是想预先创建一些代表已知错误的错误值时候,用这种扁平化的方式就很恰当了。
|
||||
|
||||
不过,由于`error`是接口类型,所以通过`errors.New`函数生成的错误值只能被赋给变量,而不能赋给常量,又由于这些代表错误的变量需要给包外代码使用,所以其访问权限只能是公开的。
|
||||
|
||||
这就带来了一个问题,如果有恶意代码改变了这些公开变量的值,那么程序的功能就必然会受到影响。因为在这种情况下我们往往会通过判等操作来判断拿到的错误值具体是哪一个错误,如果这些公开变量的值被改变了,那么相应的判等操作的结果也会随之改变。
|
||||
|
||||
这里有两个解决方案。第一个方案是,先私有化此类变量,也就是说,让它们的名称首字母变成小写,然后编写公开的用于获取错误值以及用于判等错误值的函数。
|
||||
|
||||
比如,对于错误值`os.ErrClosed`,先改写它的名称,让其变成`os.errClosed`,然后再编写`ErrClosed`函数和`IsErrClosed`函数。
|
||||
|
||||
当然了,这不是说让你去改动标准库中已有的代码,这样做的危害会很大,甚至是致命的。我只能说,对于你可控的代码,最好还是要尽量收紧访问权限。
|
||||
|
||||
再来说第二个方案,此方案存在于`syscall`包中。该包中有一个类型叫做`Errno`,该类型代表了系统调用时可能发生的底层错误。这个错误类型是`error`接口的实现类型,同时也是对内建类型`uintptr`的再定义类型。
|
||||
|
||||
由于`uintptr`可以作为常量的类型,所以`syscall.Errno`自然也可以。`syscall`包中声明有大量的`Errno`类型的常量,每个常量都对应一种系统调用错误。`syscall`包外的代码可以拿到这些代表错误的常量,但却无法改变它们。
|
||||
|
||||
我们可以仿照这种声明方式来构建我们自己的错误值列表,这样就可以保证错误值的只读特性了。
|
||||
|
||||
好了,总之,扁平的错误值列表虽然相对简单,但是你一定要知道其中的隐患以及有效的解决方案是什么。
|
||||
|
||||
**总结**
|
||||
|
||||
今天,我从两个视角为你总结了错误类型、错误值的处理技巧和设计方式。我们先一起看了一下Go语言中处理错误的最基本方式,这涉及了函数结果列表设计、`errors.New`函数、卫述语句以及使用打印函数输出错误值。
|
||||
|
||||
接下来,我提出的第一个问题是关于错误判断的。对于一个错误值来说,我们可以获取到它的类型、值以及它携带的错误信息。
|
||||
|
||||
如果我们可以确定其类型范围或者值的范围,那么就可以使用一些明确的手段获知具体的错误种类。否则,我们就只能通过匹配其携带的错误信息来大致区分它们的种类。
|
||||
|
||||
由于底层系统给予我们的错误信息还是很有规律可循的,所以用这种方式去判断效果还比较显著。但是第三方程序给出的错误信息很可能就没那么规整了,这种情况下靠错误信息去辨识种类就会比较困难。
|
||||
|
||||
有了以上阐释,当把视角从使用者换位到建造者,我们往往就会去自觉地仔细思考程序错误体系的设计了。我在这里提出了两个在Go语言标准库中使用很广泛的方案,即:立体的错误类型体系和扁平的错误值列表。
|
||||
|
||||
之所以说错误类型体系是立体的,是因为从整体上看它往往呈现出树形的结构。通过接口间的嵌套以及接口的实现,我们就可以构建出一棵错误类型树。
|
||||
|
||||
通过这棵树,使用者就可以一步步地确定错误值的种类了。另外,为了追根溯源的需要,我们还可以在错误类型中,统一安放一个可以代表潜在错误的字段。这叫做链式的错误关联,可以帮助使用者找到错误的根源。
|
||||
|
||||
相比之下,错误值列表就比较简单了。它其实就是若干个名称不同但类型相同的错误值集合。
|
||||
|
||||
不过需要注意的是,如果它们是公开的,那就应该尽量让它们成为常量而不是变量,或者编写私有的错误值以及公开的获取和判等函数,否则就很难避免恶意的篡改。
|
||||
|
||||
这其实是“最小化访问权限”这个程序设计原则的一个具体体现。无论怎样设计程序错误体系,我们都应该把这一点考虑在内。
|
||||
|
||||
**思考题**
|
||||
|
||||
请列举出你经常用到或者看到的3个错误值,它们分别在哪个错误值列表里?这些错误值列表分别包含的是哪个种类的错误?
|
||||
|
||||
[戳此查看Go语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers)
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
<audio id="audio" title="21 | panic函数、recover函数以及defer语句 (上)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/fb/86/fb1424dc84fb9487b84c18b5d3049586.mp3"></audio>
|
||||
|
||||
我在上两篇文章中,详细地讲述了Go语言中的错误处理,并从两个视角为你总结了错误类型、错误值的处理技巧和设计方式。
|
||||
|
||||
在本篇,我要给你展示Go语言的另外一种错误处理方式。不过,严格来说,它处理的不是错误,而是异常,并且是一种在我们意料之外的程序异常。
|
||||
|
||||
## 前导知识:运行时恐慌panic
|
||||
|
||||
这种程序异常被叫做panic,我把它翻译为运行时恐慌。其中的“恐慌”二字是由panic直译过来的,而之所以前面又加上了“运行时”三个字,是因为这种异常只会在程序运行的时候被抛出来。
|
||||
|
||||
我们举个具体的例子来看看。
|
||||
|
||||
比如说,一个Go程序里有一个切片,它的长度是5,也就是说该切片中的元素值的索引分别为`0`、`1`、`2`、`3`、`4`,但是,我在程序里却想通过索引`5`访问其中的元素值,显而易见,这样的访问是不正确的。
|
||||
|
||||
Go程序,确切地说是程序内嵌的Go语言运行时系统,会在执行到这行代码的时候抛出一个“index out of range”的panic,用以提示你索引越界了。
|
||||
|
||||
当然了,这不仅仅是个提示。当panic被抛出之后,如果我们没有在程序里添加任何保护措施的话,程序(或者说代表它的那个进程)就会在打印出panic的详细情况(以下简称panic详情)之后,终止运行。
|
||||
|
||||
现在,就让我们来看一下这样的panic详情中都有什么。
|
||||
|
||||
```
|
||||
panic: runtime error: index out of range
|
||||
|
||||
goroutine 1 [running]:
|
||||
main.main()
|
||||
/Users/haolin/GeekTime/Golang_Puzzlers/src/puzzlers/article19/q0/demo47.go:5 +0x3d
|
||||
exit status 2
|
||||
|
||||
```
|
||||
|
||||
这份详情的第一行是“panic: runtime error: index out of range”。其中的“runtime error”的含义是,这是一个`runtime`代码包中抛出的panic。在这个panic中,包含了一个`runtime.Error`接口类型的值。`runtime.Error`接口内嵌了`error`接口,并做了一点点扩展,`runtime`包中有不少它的实现类型。
|
||||
|
||||
实际上,此详情中的“panic:”右边的内容,正是这个panic包含的`runtime.Error`类型值的字符串表示形式。
|
||||
|
||||
此外,panic详情中,一般还会包含与它的引发原因有关的goroutine的代码执行信息。正如前述详情中的“goroutine 1 [running]”,它表示有一个ID为`1`的goroutine在此panic被引发的时候正在运行。
|
||||
|
||||
注意,这里的ID其实并不重要,因为它只是Go语言运行时系统内部给予的一个goroutine编号,我们在程序中是无法获取和更改的。
|
||||
|
||||
我们再看下一行,“main.main()”表明了这个goroutine包装的`go`函数就是命令源码文件中的那个`main`函数,也就是说这里的goroutine正是主goroutine。再下面的一行,指出的就是这个goroutine中的哪一行代码在此panic被引发时正在执行。
|
||||
|
||||
这包含了此行代码在其所属的源码文件中的行数,以及这个源码文件的绝对路径。这一行最后的`+0x3d`代表的是:此行代码相对于其所属函数的入口程序计数偏移量。不过,一般情况下它的用处并不大。
|
||||
|
||||
最后,“exit status 2”表明我的这个程序是以退出状态码`2`结束运行的。在大多数操作系统中,只要退出状态码不是`0`,都意味着程序运行的非正常结束。在Go语言中,因panic导致程序结束运行的退出状态码一般都会是`2`。
|
||||
|
||||
综上所述,我们从上边的这个panic详情可以看出,作为此panic的引发根源的代码处于demo47.go文件中的第5行,同时被包含在`main`包(也就是命令源码文件所在的代码包)的`main`函数中。
|
||||
|
||||
那么,我的第一个问题也随之而来了。我今天的问题是:**从panic被引发到程序终止运行的大致过程是什么?**
|
||||
|
||||
**这道题的典型回答是这样的。**
|
||||
|
||||
我们先说一个大致的过程:某个函数中的某行代码有意或无意地引发了一个panic。这时,初始的panic详情会被建立起来,并且该程序的控制权会立即从此行代码转移至调用其所属函数的那行代码上,也就是调用栈中的上一级。
|
||||
|
||||
这也意味着,此行代码所属函数的执行随即终止。紧接着,控制权并不会在此有片刻的停留,它又会立即转移至再上一级的调用代码处。控制权如此一级一级地沿着调用栈的反方向传播至顶端,也就是我们编写的最外层函数那里。
|
||||
|
||||
这里的最外层函数指的是`go`函数,对于主goroutine来说就是`main`函数。但是控制权也不会停留在那里,而是被Go语言运行时系统收回。
|
||||
|
||||
随后,程序崩溃并终止运行,承载程序这次运行的进程也会随之死亡并消失。与此同时,在这个控制权传播的过程中,panic详情会被逐渐地积累和完善,并会在程序终止之前被打印出来。
|
||||
|
||||
## 问题解析
|
||||
|
||||
panic可能是我们在无意间(或者说一不小心)引发的,如前文所述的索引越界。这类panic是真正的、在我们意料之外的程序异常。不过,除此之外,我们还是可以有意地引发panic。
|
||||
|
||||
Go语言的内建函数`panic`是专门用于引发panic的。`panic`函数使程序开发者可以在程序运行期间报告异常。
|
||||
|
||||
注意,这与从函数返回错误值的意义是完全不同的。当我们的函数返回一个非`nil`的错误值时,函数的调用方有权选择不处理,并且不处理的后果往往是不致命的。
|
||||
|
||||
这里的“不致命”的意思是,不至于使程序无法提供任何功能(也可以说僵死)或者直接崩溃并终止运行(也就是真死)。
|
||||
|
||||
但是,当一个panic发生时,如果我们不施加任何保护措施,那么导致的直接后果就是程序崩溃,就像前面描述的那样,这显然是致命的。
|
||||
|
||||
为了更清楚地展示答案中描述的过程,我编写了demo48.go文件。你可以先查看一下其中的代码,再试着运行它,并体会它打印的内容所代表的含义。
|
||||
|
||||
我在这里再提示一点。panic详情会在控制权传播的过程中,被逐渐地积累和完善,并且,控制权会一级一级地沿着调用栈的反方向传播至顶端。
|
||||
|
||||
因此,在针对某个goroutine的代码执行信息中,调用栈底端的信息会先出现,然后是上一级调用的信息,以此类推,最后才是此调用栈顶端的信息。
|
||||
|
||||
比如,`main`函数调用了`caller1`函数,而`caller1`函数又调用了`caller2`函数,那么`caller2`函数中代码的执行信息会先出现,然后是`caller1`函数中代码的执行信息,最后才是`main`函数的信息。
|
||||
|
||||
```
|
||||
goroutine 1 [running]:
|
||||
main.caller2()
|
||||
/Users/haolin/GeekTime/Golang_Puzzlers/src/puzzlers/article19/q1/demo48.go:22 +0x91
|
||||
main.caller1()
|
||||
/Users/haolin/GeekTime/Golang_Puzzlers/src/puzzlers/article19/q1/demo48.go:15 +0x66
|
||||
main.main()
|
||||
/Users/haolin/GeekTime/Golang_Puzzlers/src/puzzlers/article19/q1/demo48.go:9 +0x66
|
||||
exit status 2
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/60/d7/606ff433a6b58510f215e57792822bd7.png" alt="">
|
||||
|
||||
(从panic到程序崩溃)
|
||||
|
||||
好了,到这里,我相信你已经对panic被引发后的程序终止过程有一定的了解了。深入地了解此过程,以及正确地解读panic详情应该是我们的必备技能,这在调试Go程序或者为Go程序排查错误的时候非常重要。
|
||||
|
||||
## 总结
|
||||
|
||||
最近的两篇文章,我们是围绕着panic函数、recover函数以及defer语句进行的。今天我主要讲了panic函数。这个函数是专门被用来引发panic的。panic也可以被称为运行时恐慌,它是一种只能在程序运行期间抛出的程序异常。
|
||||
|
||||
Go语言的运行时系统可能会在程序出现严重错误时自动地抛出panic,我们在需要时也可以通过调用`panic`函数引发panic。但不论怎样,如果不加以处理,panic就会导致程序崩溃并终止运行。
|
||||
|
||||
## 思考题
|
||||
|
||||
一个函数怎样才能把panic转化为`error`类型值,并将其作为函数的结果值返回给调用方?
|
||||
|
||||
[戳此查看Go语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers)
|
||||
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
<audio id="audio" title="22 | panic函数、recover函数以及defer语句(下)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/3f/98/3f98658635e74f6aba9f05ce55e42298.mp3"></audio>
|
||||
|
||||
你好,我是郝林,今天我们继续来聊聊panic函数、recover函数以及defer语句的内容。
|
||||
|
||||
我在前一篇文章提到过这样一个说法,panic之中可以包含一个值,用于简要解释引发此panic的原因。
|
||||
|
||||
如果一个panic是我们在无意间引发的,那么其中的值只能由Go语言运行时系统给定。但是,当我们使用`panic`函数有意地引发一个panic的时候,却可以自行指定其包含的值。我们今天的第一个问题就是针对后一种情况提出的。
|
||||
|
||||
## 知识扩展
|
||||
|
||||
### 问题 1:怎样让panic包含一个值,以及应该让它包含什么样的值?
|
||||
|
||||
这其实很简单,在调用`panic`函数时,把某个值作为参数传给该函数就可以了。由于`panic`函数的唯一一个参数是空接口(也就是`interface{}`)类型的,所以从语法上讲,它可以接受任何类型的值。
|
||||
|
||||
但是,我们最好传入`error`类型的错误值,或者其他的可以被有效序列化的值。这里的“有效序列化”指的是,可以更易读地去表示形式转换。
|
||||
|
||||
还记得吗?对于`fmt`包下的各种打印函数来说,`error`类型值的`Error`方法与其他类型值的`String`方法是等价的,它们的唯一结果都是`string`类型的。
|
||||
|
||||
我们在通过占位符`%s`打印这些值的时候,它们的字符串表示形式分别都是这两种方法产出的。
|
||||
|
||||
一旦程序异常了,我们就一定要把异常的相关信息记录下来,这通常都是记到程序日志里。
|
||||
|
||||
我们在为程序排查错误的时候,首先要做的就是查看和解读程序日志;而最常用也是最方便的日志记录方式,就是记下相关值的字符串表示形式。
|
||||
|
||||
所以,如果你觉得某个值有可能会被记到日志里,那么就应该为它关联`String`方法。如果这个值是`error`类型的,那么让它的`Error`方法返回你为它定制的字符串表示形式就可以了。
|
||||
|
||||
对于此,你可能会想到`fmt.Sprintf`,以及`fmt.Fprintf`这类可以格式化并输出参数的函数。
|
||||
|
||||
是的,它们本身就可以被用来输出值的某种表示形式。不过,它们在功能上,肯定远不如我们自己定义的`Error`方法或者`String`方法。因此,为不同的数据类型分别编写这两种方法总是首选。
|
||||
|
||||
可是,这与传给`panic`函数的参数值又有什么关系呢?其实道理是相同的。至少在程序崩溃的时候,panic包含的那个值字符串表示形式会被打印出来。
|
||||
|
||||
另外,我们还可以施加某种保护措施,避免程序的崩溃。这个时候,panic包含的值会被取出,而在取出之后,它一般都会被打印出来或者记录到日志里。
|
||||
|
||||
既然说到了应对panic的保护措施,我们再来看下面一个问题。
|
||||
|
||||
### 问题 2:怎样施加应对panic的保护措施,从而避免程序崩溃?
|
||||
|
||||
Go语言的内建函数`recover`专用于恢复panic,或者说平息运行时恐慌。`recover`函数无需任何参数,并且会返回一个空接口类型的值。
|
||||
|
||||
如果用法正确,这个值实际上就是即将恢复的panic包含的值。并且,如果这个panic是因我们调用`panic`函数而引发的,那么该值同时也会是我们此次调用`panic`函数时,传入的参数值副本。请注意,这里强调用法的正确。我们先来看看什么是不正确的用法。
|
||||
|
||||
```
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"errors"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Println("Enter function main.")
|
||||
// 引发panic。
|
||||
panic(errors.New("something wrong"))
|
||||
p := recover()
|
||||
fmt.Printf("panic: %s\n", p)
|
||||
fmt.Println("Exit function main.")
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在上面这个`main`函数中,我先通过调用`panic`函数引发了一个panic,紧接着想通过调用`recover`函数恢复这个panic。可结果呢?你一试便知,程序依然会崩溃,这个`recover`函数调用并不会起到任何作用,甚至都没有机会执行。
|
||||
|
||||
还记得吗?我提到过panic一旦发生,控制权就会讯速地沿着调用栈的反方向传播。所以,在`panic`函数调用之后的代码,根本就没有执行的机会。
|
||||
|
||||
那如果我把调用`recover`函数的代码提前呢?也就是说,先调用`recover`函数,再调用`panic`函数会怎么样呢?
|
||||
|
||||
这显然也是不行的,因为,如果在我们调用`recover`函数时未发生panic,那么该函数就不会做任何事情,并且只会返回一个`nil`。
|
||||
|
||||
换句话说,这样做毫无意义。那么,到底什么才是正确的`recover`函数用法呢?这就不得不提到`defer`语句了。
|
||||
|
||||
顾名思义,`defer`语句就是被用来延迟执行代码的。延迟到什么时候呢?这要延迟到该语句所在的函数即将执行结束的那一刻,无论结束执行的原因是什么。
|
||||
|
||||
这与`go`语句有些类似,一个`defer`语句总是由一个`defer`关键字和一个调用表达式组成。
|
||||
|
||||
这里存在一些限制,有一些调用表达式是不能出现在这里的,包括:针对Go语言内建函数的调用表达式,以及针对`unsafe`包中的函数的调用表达式。
|
||||
|
||||
顺便说一下,对于`go`语句中的调用表达式,限制也是一样的。另外,在这里被调用的函数可以是有名称的,也可以是匿名的。我们可以把这里的函数叫做`defer`函数或者延迟函数。注意,被延迟执行的是`defer`函数,而不是`defer`语句。
|
||||
|
||||
我刚才说了,无论函数结束执行的原因是什么,其中的`defer`函数调用都会在它即将结束执行的那一刻执行。即使导致它执行结束的原因是一个panic也会是这样。正因为如此,我们需要联用`defer`语句和`recover`函数调用,才能够恢复一个已经发生的panic。
|
||||
|
||||
我们来看一下经过修正的代码。
|
||||
|
||||
```
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"errors"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Println("Enter function main.")
|
||||
defer func(){
|
||||
fmt.Println("Enter defer function.")
|
||||
if p := recover(); p != nil {
|
||||
fmt.Printf("panic: %s\n", p)
|
||||
}
|
||||
fmt.Println("Exit defer function.")
|
||||
}()
|
||||
// 引发panic。
|
||||
panic(errors.New("something wrong"))
|
||||
fmt.Println("Exit function main.")
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在这个`main`函数中,我先编写了一条`defer`语句,并在`defer`函数中调用了`recover`函数。仅当调用的结果值不为`nil`时,也就是说只有panic确实已发生时,我才会打印一行以“panic:”为前缀的内容。
|
||||
|
||||
紧接着,我调用了`panic`函数,并传入了一个`error`类型值。这里一定要注意,我们要尽量把`defer`语句写在函数体的开始处,因为在引发panic的语句之后的所有语句,都不会有任何执行机会。
|
||||
|
||||
也只有这样,`defer`函数中的`recover`函数调用才会拦截,并恢复`defer`语句所属的函数,及其调用的代码中发生的所有panic。
|
||||
|
||||
至此,我向你展示了两个很典型的`recover`函数的错误用法,以及一个基本的正确用法。
|
||||
|
||||
我希望你能够记住错误用法背后的缘由,同时也希望你能真正地理解联用`defer`语句和`recover`函数调用的真谛。
|
||||
|
||||
在命令源码文件demo50.go中,我把上述三种用法合并在了一段代码中。你可以运行该文件,并体会各种用法所产生的不同效果。
|
||||
|
||||
下面我再来多说一点关于`defer`语句的事情。
|
||||
|
||||
### 问题 3:如果一个函数中有多条`defer`语句,那么那几个`defer`函数调用的执行顺序是怎样的?
|
||||
|
||||
如果只用一句话回答的话,那就是:在同一个函数中,`defer`函数调用的执行顺序与它们分别所属的`defer`语句的出现顺序(更严谨地说,是执行顺序)完全相反。
|
||||
|
||||
当一个函数即将结束执行时,其中的写在最下边的`defer`函数调用会最先执行,其次是写在它上边、与它的距离最近的那个`defer`函数调用,以此类推,最上边的`defer`函数调用会最后一个执行。
|
||||
|
||||
如果函数中有一条`for`语句,并且这条`for`语句中包含了一条`defer`语句,那么,显然这条`defer`语句的执行次数,就取决于`for`语句的迭代次数。
|
||||
|
||||
并且,同一条`defer`语句每被执行一次,其中的`defer`函数调用就会产生一次,而且,这些函数调用同样不会被立即执行。
|
||||
|
||||
那么问题来了,这条`for`语句中产生的多个`defer`函数调用,会以怎样的顺序执行呢?
|
||||
|
||||
为了彻底搞清楚,我们需要弄明白`defer`语句执行时发生的事情。
|
||||
|
||||
其实也并不复杂,在`defer`语句每次执行的时候,Go语言会把它携带的`defer`函数及其参数值另行存储到一个链表中。
|
||||
|
||||
这个链表与该`defer`语句所属的函数是对应的,并且,它是先进后出(FILO)的,相当于一个栈。
|
||||
|
||||
在需要执行某个函数中的`defer`函数调用的时候,Go语言会先拿到对应的链表,然后从该链表中一个一个地取出`defer`函数及其参数值,并逐个执行调用。
|
||||
|
||||
这正是我说“`defer`函数调用与其所属的`defer`语句的执行顺序完全相反”的原因了。
|
||||
|
||||
下面该你出场了,我在demo51.go文件中编写了一个与本问题有关的示例,其中的核心代码很简单,只有几行而已。
|
||||
|
||||
我希望你先查看代码,然后思考并写下该示例被运行时,会打印出哪些内容。
|
||||
|
||||
如果你实在想不出来,那么也可以先运行示例,再试着解释打印出的内容。总之,你需要完全搞明白那几行内容为什么会以那样的顺序出现的确切原因。
|
||||
|
||||
## 总结
|
||||
|
||||
我们这两期的内容主要讲了两个函数和一条语句。`recover`函数专用于恢复panic,并且调用即恢复。
|
||||
|
||||
它在被调用时会返回一个空接口类型的结果值。如果在调用它时并没有panic发生,那么这个结果值就会是`nil`。
|
||||
|
||||
而如果被恢复的panic是我们通过调用`panic`函数引发的,那么它返回的结果值就会是我们传给`panic`函数参数值的副本。
|
||||
|
||||
对`recover`函数的调用只有在`defer`语句中才能真正起作用。`defer`语句是被用来延迟执行代码的。
|
||||
|
||||
更确切地说,它会让其携带的`defer`函数的调用延迟执行,并且会延迟到该`defer`语句所属的函数即将结束执行的那一刻。
|
||||
|
||||
在同一个函数中,延迟执行的`defer`函数调用,会与它们分别所属的`defer`语句的执行顺序完全相反。还要注意,同一条`defer`语句每被执行一次,就会产生一个延迟执行的`defer`函数调用。
|
||||
|
||||
这种情况在`defer`语句与`for`语句联用时经常出现。这时更要关注`for`语句中,同一条`defer`语句产生的多个`defer`函数调用的实际执行顺序。
|
||||
|
||||
以上这些,就是关于Go语言中特殊的程序异常,及其处理方式的核心知识。这里边可以衍生出很多面试题目。
|
||||
|
||||
## 思考题
|
||||
|
||||
我们可以在`defer`函数中恢复panic,那么可以在其中引发panic吗?
|
||||
|
||||
[戳此查看Go语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers)
|
||||
Reference in New Issue
Block a user