姜大佬推荐的一本较为深入理解go细节的书,作为golang学习进阶。(•̀⌄•́)
—— By Jihan
以下内容都是根据书中的章节目录,并且根据自己的缺陷进行记录的。
如果有兴趣,推荐先阅读原著,再看笔记。
前言
go语言的查漏补缺。在线阅读
Go编程入门
Go工具链
问题1: 书中说: 比如,目前Go不支持任意类型的不变量。这导致很多标准库中一些希望永不被更改的值目前被声明为变量。这是Go程序中的一个潜在安全隐患 const 不算常量?
go vet
go vet可以用来检测可能出现的逻辑错误,使用方法也很简单:
- 检测单个文件:
go vet a.go - 检测文件夹:
go vet ./test/或者可以增加taggo vet -tags="a" ./test/
go vet检测文件夹的时候,会自动根据文件夹下的go文件去查找对应的依赖关系,并给出相应的检测结果。
关键字和标识符
有趣的是,golang中标识符可以是一个以Unicode字母或者_开头并且完全由Unicode字母和Unicode数字组成的单词 这就意味着下面的定义也是合法的:
1 | var _ int |
而且一个由大写字符开头的标识符,为导出字符。而大写的定义是Unicode中的大写
常量和变量
常量定义中,=号并不代表赋值,而有点像绑定,类似于c中的#define
1 | //可以理解为#define的方式,实际使用的时候,替换为1 |
若干包级变量在声明时刻的依赖关系将影响它们的初始化顺序
下面这个例子中的声明的变量的初始化顺序为y = 5、c = y、b = c+1、a = b+1、x = a+1
1 | var x, y = a+1, 5 // 8 5 |
包级变量在初始化的时候不能相互依赖。比如,下面这个变量声明语句编译不通过。
1 | var x, y = y, x |
变量可被寻址,常量不行。
常量的类型转换,不允许溢出,而变量则允许。
常量和变量的类型推断方式:
1 | package main |
函数
switch中,执行时是依次比较每个case
defer
当一个函数调用被延迟后,它不会立即被执行。它将被推入由当前协程维护的一个延迟调用堆栈。 当一个函数调用(可能是也可能不是一个延迟调用)返回并进入它的退出阶段后,所有在此函数调用中已经被推入的延迟调用将被按照它们被推入堆栈的顺序逆序执行。
示例:
1 | package main |
输出:
1 | The first line. |
defer传参问题:
1 | package main |
输出:
1 | a: 2 |
下面这个例子,则会输出false
1 | package main |
一个延迟调用的实参也是在此调用被推入延迟调用堆栈之前估值的
panic和recover
一旦一个函数调用产生一个panic,此函数调用将立即进入它的退出阶段,执行defer所定义的延迟函数,这里所有定义的延迟函数都将被执行(逆序)。
通过在defer中调用内置函数recover,当前协程中的一个panic可以被消除,从而使得当前协程重新进入正常状况。
在一个处于panic状况的协程退出之前,其中的panic不会蔓延到其它协程。 如果一个协程在panic状况下退出,它将使整个程序崩溃。
一个协程调用或者延迟调用的实参是在此调用发生时被估值的。更具体地说:
- 对于一个延迟函数调用,它的实参是在此调用被推入延迟调用堆栈的时候被估值的。
- 对于一个协程调用,它的实参是在此协程被创建的时候估值的。
一些致命性错误不属于panic
对于官方标准编译器来说,很多致命性错误(比如堆栈溢出和内存不足)不能被恢复。它们一旦产生,程序将崩溃。
类型
类型定义:
1 | type ( |
类型定义的一些特点:
- 一个新定义的类型和它的源类型为两个不同的类型。
- 在两个不同的类型定义中的定义的两个类型肯定为两个不同的类型。
- 一个新定义的类型和它的源类型的底层类型(将在下面介绍)一致并且它们的值可以相互显式转换。
- 类型定义可以出现在函数体内。
类型别名声明:
1 | type ( |
类型别名,顾名思义,就是某个类型的另外一个名字。
Go类型系统
指针
- 一个指针类型的值不能被随意转换为另一个指针类型
- 一个指针值不能和其它任一指针类型的值进行比较
- 指针值不能进行数值计算,比如有指针
p,进行p++ unsafe.Pointer可以打破Go对指针的限制
在赋值中,底层间接值部将不会被复制
意味着所有的间接引用值类型,都共用一个内存值,包括string。由于string的内存是只读状态,因此上面的描述也是正确的。
| 直接存值 | 间接存值 |
|---|---|
| 布尔类型 | 切片类型 |
| 各种数值类型 | 映射类型 |
| 指针类型 | 通道类型 |
| 非类型安全指针类型 | 函数类型 |
| 结构体类型 | 接口类型 |
| 数组类型 | 字符串类型 |
使用内置copy函数来复制切片元素,复制的两个切片类型可以不同,但是底层数据结构必须相同。
map
在map的遍历中,单协程是可以对map进行增删改查的,但是注意:
- map遍历是随机的
- 在遍历过程中,没有遍历到的目录被删除,则后续也不会被遍历出来
- 在遍历过程中,增加新条目,则后续不保证会被遍历出来
- 遍历时,会对直接值进行一次拷贝,用于赋值循环变量。比如数组遍历过程中修改原始数组值,是不会在遍历的变量中体现修改。但是切片就会体现。
- 遍历中,循环变量也是一个元素备份,对循环变量的修改,也不会体现到原始值中。
- 所有被遍历的键值对将被赋值给同一对循环变量实例
类型struct{}的尺寸为零
string:
字符串赋值,在底层享用的是同一份数据
字符串和切片(字节切片或者码点切片)之间的转换,是需要进行深复制的,原因在于切片是可以被修改的,字符串则不行。
用for range遍历字符串的时候,取出的值是一个rune类型的值,但是len(s)得到的却是字符串的字节数
函数也可以认为是一个值,但是函数是不可比较类型
函数值赋值时,内置函数和init不可被用作函数值
接口
因为任何方法集都是一个空方法集的超集,所以任何类型都实现了任何空接口类型(interface{})。
在Go中,如果类型T实现了一个接口类型I,则类型T的值都可以隐式转换到类型I。 换句话说,类型T的值可以赋给类型I的可修改值。 当一个T值被转换到类型I(或者赋给一个I值)的时候:
- 如果类型
T是一个非接口类型,则此T值的一个复制将被包裹在结果(或者目标)I值中。 此操作的时间复杂度为O(n),其中n为T值的尺寸。 - 如果类型
T也为一个接口类型,则此T值中当前包裹的(非接口)值将被复制一份到结果(或者目标)I值中。 官方标准编译器为此操作做了优化,使得此操作的时间复杂度为O(1),而不是O(n)。
非接口类型和接口类型会在go运行时构建一个全局关系列表,一个非接口值内部只会存储一个指向该列表的一个条目。
非接口类型和接口类型对,存在两个部分: - 动态类型(即此非接口类型)的信息。(反射的关键)
- 一个方法表(切片类型),其中存储了所有此接口类型指定的并且为此非接口类型(动态类型)声明的方法。(多态的关键)
当非接口类型T的一个值t被包裹在接口类型I的一个接口值i中:当方法i.m被调用时,i存储的实现关系信息的方法表中的方法t.m将被找到并被调用
接口值的比较:
- 比较一个非接口值和接口值。(非接口值会被隐式转化为接口值,进而变为接口值比较)
- 比较两个接口值。
两个接口值的比较结果只有在下面两种任一情况下才为true:
- 这两个接口值都为nil接口值。
- 这两个接口值的动态类型相同、动态类型为可比较类型、并且动态值相等。
一个[]T类型的值不能直接被转换为类型[]I,即使类型T实现了接口类型I。只能通过循环来进行转换。
并发编程
协程
协程生命周期:

进一步的:

我们可以调用runtime.GOMAXPROCS函数来获取和设置逻辑处理器的数量。自从Go 1.5之后,默认初始逻辑处理器的数量和逻辑CPU的数量一致。 此新的默认设置在大多数情况下是最佳选择。但是对于某些文件操作十分频繁的程序,设置一个大于runtime.NumCPU()的GOMAXPROCS值可能是有好处的。
通道
通道类型是可比较类型。len(ch)查询通道长度,返回的是通道内还存在多少个未被接收的元素。同样也存在cap(ch)
对通道的操作都是并发安全的:
close(ch)len(ch)cap(ch)ch <- v<- ch
| 操作 | 一个零值nil通道 | 一个非零值但已关闭的双向通道 | 一个非零值且尚未关闭的双向通道 |
|---|---|---|---|
| 关闭 | 产生恐慌 | 产生恐慌 | 成功关闭© |
| 发送数据 | 永久阻塞 | 产生恐慌 | 阻塞或者成功发送(B) |
| 接收数据 | 永久阻塞 | 永不阻塞(D) | 阻塞或者成功接收(A) |
关闭一个双向通道时,关闭前的接收协程依旧可以获取缓冲区的值(如果缓冲区没有值则是零值),如果关闭时存在发送协程则会产生panic。关闭后的接收协程则会永久阻塞。可以通过通道接收的第二个返回值true/false判断接收协程是否正常接收数据,还是关闭后返回的零值
通道可以看做是一个由数据缓冲区,接收协程队列,发送协程队列组成。
我们可以得出如下的关于一个通道的内部的三个队列的各种事实:
- 如果一个通道已经关闭了,则它的发送数据协程队列和接收数据协程队列肯定都为空,但是它的缓冲队列可能不为空。
- 在任何时刻,如果缓冲队列不为空,则接收数据协程队列必为空。
- 在任何时刻,如果缓冲队列未满,则发送数据协程队列必为空。
- 如果一个通道是缓冲的,则在任何时刻,它的发送数据协程队列和接收数据协程队列之一必为空。
- 如果一个通道是非缓冲的,则在任何时刻,一般说来,它的发送数据协程队列和接收数据协程队列之一必为空, 但是有一个例外:一个协程可能在一个select流程控制中同时被推入到此通道的发送数据协程队列和接收数据协程队列中。
官方编译器,通道元素最大尺寸为65535
for-range循环控制流程也适用于通道。 此循环将不断地尝试从一个通道接收数据,直到此通道关闭并且它的缓冲队列为空为止。 和应用于数组/切片/映射的for-range语法不同,应用于通道的for-range语法中最多只能出现一个循环变量,此循环变量用来存储接收到的值。
1 | for v := range aChannel { |
这里的通道aChannel一定不能为一个单向发送通道。 如果它是一个nil零值,则此for-range循环将使当前协程永久阻塞。
select-case
一些特性:
select关键字和{之间不允许存在任何表达式和语句。fallthrough语句不能被使用.- 每个
case关键字后必须跟随一个通道接收数据操作或者一个通道发送数据操作。 通道接收数据操作可以做为源值出现在一条简单赋值语句中。 以后,一个case关键字后跟随的通道操作将被称为一个case操作。 - 所有的非阻塞
case操作中将有一个被随机选择执行(而不是按照从上到下的顺序),然后执行此操作对应的case分支代码块。 - 在所有的
case操作均为阻塞的情况下,如果default分支存在,则default分支代码块将得到执行; 否则,当前协程将被推入所有阻塞操作中相关的通道的发送数据协程队列或者接收数据协程队列中,并进入阻塞状态。
一个非阻塞的发送和接收:
1 | package main |
实现机制:
- 将所有case操作中涉及到的通道表达式和发送值表达式按照从上到下,从左到右的顺序一一估值。 在赋值语句中做为源值的数据接收操作对应的目标值在此时刻不需要被估值。
- 将所有分支随机排序。default分支总是排在最后。 所有case操作中相关的通道可能会有重复的。
- 为了防止在下一步中造成(和其它协程互相)死锁,对所有case操作中相关的通道进行排序。 排序依据并不重要,官方Go标准编译器使用通道的地址顺序进行排序。 排序结果中前N个通道不存在重复的情况。 N为所有case操作中涉及到的不重复的通道的数量。 下面,通道锁顺序是针对此排序结果中的前N个通道来说的,通道锁逆序是指此顺序的逆序。
- 按照上一步中的生成通道锁顺序获取所有相关的通道的锁。
- 按照第2步中生成的分支顺序检查相应分支:
- 如果这是一个case分支并且相应的通道操作是一个向关闭了的通道发送数据操作,则按照通道锁逆序解锁所有的通道并在当前协程中产生一个恐慌。 跳到第12步。
- 如果这是一个case分支并且相应的通道操作是非阻塞的,则按照通道锁逆序解锁所有的通道并执行相应的case分支代码块。 (此相应的通道操作可能会唤醒另一个处于阻塞状态的协程。) 跳到第12步。
- 如果这是default分支,则按照通道锁逆序解锁所有的通道并执行此default分支代码块。 跳到第12步。
(到这里,default分支肯定是不存在的,并且所有的case操作均为阻塞的。)
- 将当前协程(和对应case分支信息)推入到每个case操作中对应的通道的发送数据协程队列或接收数据协程队列中。 当前协程可能会被多次推入到同一个通道的这两个队列中,因为多个case操作中对应的通道可能为同一个。
- 使当前协程进入阻塞状态并且按照通道锁逆序解锁所有的通道。
- …,当前协程处于阻塞状态,等待其它协程通过通道操作唤醒当前协程,…
- 当前协程被另一个协程中的一个通道操作唤醒。 此唤醒通道操作可能是一个通道关闭操作,也可能是一个数据发送/接收操作。 如果它是一个数据发送/接收操作,则(当前正被解释的select-case流程中)肯定有一个相应case操作与之配合传递数据。 在此配合过程中,当前协程将从相应case操作相关的通道的接收/发送数据协程队列中弹出。
- 按照第3步中的生成的通道锁顺序获取所有相关的通道的锁。
- 将当前协程从各个case操作中对应的通道的发送数据协程队列或接收数据协程队列中(可能以非弹出的方式)移除。
- 如果当前协程是被一个通道关闭操作所唤醒,则跳到第5步。
- 如果当前协程是被一个数据发送/接收操作所唤醒,则相应的case分支已经在第9步中知晓。 按照通道锁逆序解锁所有的通道并执行此case分支代码块。
- 完毕。
常见的并发编程错误
该加同步的没有加
源文件中的代码,在运行时并非总是按照它们出现的顺序被执行。
下面这个示例程序犯了两个错误:
- 首先,主协程中对变量b的读取和匿名协程中的对变量b的写入可能会产生数据竞争;
- 其次,在主协程中,条件b == true成立并不能确保条件a != nil也成立。 编译器和CPU可能会对调整此程序中匿名协程中的某些指令的顺序已获取更快的执行速度。 所以,站在主协程的视角看,对变量b的赋值可能会发生在对变量a的赋值之前,这将造成在修改a的元素时a依然为一个nil切片。
1 | package main |
正确的行为应当使用管道或者锁来保证顺序正确性
错误复制sync标准库包中的类型的值
在实践中,sync标准库包中的类型(除了Locker接口类型)的值不应该被复制。 我们只应该复制它们的指针值。
确保每个sync.WaitGroup.Add的调用在sync.WaitGroup.Wait之前
下面这个示例会返回0~100的任何一个值
1 | package main |
没留意过多的time.After函数调用消耗了大量资源
如果一分钟内,longRunning被调用且有一百万条消息,则time.After会创建一百万个time.Timer值,则有很大的垃圾回收压力
1 | import ( |
更好的做法:
1 | func longRunning(messages <-chan string) { |
一个典型的time.Timer的使用已经在上例中展示了。一些解释:
- 如果一个
Timer值已经过期或者已经被终止(stopped),则相应的Stop方法调用返回false。 在此Timer值尚未终止的时候,Stop方法调用返回false只能意味着此Timer值已经过期。 - 一个
Timer值被终止之后,它的通道字段C最多只能含有一个过期的通知。 - 在一个
Timer终止(stopped)之后并且在重置和重用此Timer值之前,我们应该确保此Timer值中肯定不存在过期的通知。 这就是上一节中的例子中的if代码块的意义所在。
一个*Timer值的Reset方法必须在对应Timer值过期或者终止之后才能被调用; 否则,此Reset方法调用和一个可能的向此Timer值的C通道字段的发送通知操作产生数据竞争。
在多个协程中使用同一个time.Timer值比较容易写出不当的并发代码,所以尽量不要跨协程使用一个Timer值。
一些专题
类型隐式转换
T或*T实现的函数,本质上也是T或*T的成员,存在于其结构中。也就是成员函数。因此即使是特定类型的空指针调用方法,也不会出现panic:_ = ((*Age)(nil)).IsNil()
本质上,成员函数声明都会进行隐式转换,比如:
1 | func (b Book) Pages() int { |
编译器会隐式转换成下面的结构:
1 | func Book.Pages(b Book) int { |
那么当你调用b.Pages(),本质上b也会作为一个参数进行拷贝赋值。
对于方法调用,如果声明了(T).F,那么(*T).F和(T).F都可以编译通过,并进行转换成正规的结构(T).F。同理:如果声明了(*T).F,那么(*T).F和(T).F也都可以编译通过,并进行转换成正规的结构(*T).F。
对于值类型属主还是指针类型属主都可以接受的方法声明,下面列出了一些考虑因素:
- 太多的指针可能会增加垃圾回收器的负担。
- 如果一个值类型的尺寸太大,那么属主参数在传参的时候的复制成本将不可忽略。 指针类型都是小尺寸类型。 关于各种不同类型的尺寸,请阅读值复制代价一文。
- 在并发场合下,同时调用值类型属主和指针类型属主方法比较易于产生数据竞争。
- sync标准库包中的类型的值不应该被复制,所以如果一个结构体类型内嵌了这些类型,则不应该为这个结构体类型声明值类型属主的方法。
Go中有四种接口相关的类型转换情形:
- 将一个非接口值转换为一个接口类型。在这样的转换中,此非接口值的类型必须实现了此接口类型。
- 将一个接口值转换为另一个接口类型(前者接口值的类型实现了后者目标接口类型)。
- 将一个接口值转换为一个非接口类型(此非接口类型必须实现了此接口值的接口类型)。
- 将一个接口值转换为另一个接口类型(前者接口值的类型未实现后者目标接口类型,但是前者的动态类型有可能实现了目标接口类型)。
断言
断言i.(T),其中i为一个接口值,T可以为:
- 任意一个非接口类型。
- 或者一个任意接口类型。
type-switch:
1 | switch aSimpleStatement; v := x.(type) { |
其中aSimpleStatement;部分是可选的简单语句,v可要可不要(如果要,必须是个短变量声明),视实际情况而定。不能使用fallthrough
接口直接定义和嵌套效果是一样的,它们只在形式上有差别,实际方法集完全一致。比如下述Ic,Id接口就是一样的:
1 | type Ia interface { |
内嵌
即在结构体声明中,不声明变量名,只有变量类型。
编译器对于内嵌变量,会隐式声明一个和变量类型相同的变量。
内嵌的限制:
T不能是一个定义的指针类型和基类型是指针接口类型的类型。*T中的T也同样不能是一个定义的指针类型和基类型是指针接口类型的类型。- 不能内嵌自己
- 不能包含两个相同的基类型相同的内嵌
在调用过程中,内嵌的字段可以省略。比如A.B.func1,其中B是内嵌变量,那么可以写为A.func1。类似其他语言的继承方式(两种方式的优劣)。但是要注意:
- 如果
A也实现了func1,那么A.func1调用只会调用最浅的一层,即A类型的func1。(遮挡) - 如果
A类型中同时有B和C同时实现了func2,那么就不能进行省略缩写。(碰撞)
来自不同库的相同函数名,是不会发生碰撞和遮挡的。
内嵌方法获取:
- 类型
struct{T}和*struct{T}均将获取类型T的所有方法。 - 类型
*struct{T}、struct{*T}和*struct{*T}都将获取类型*T的所有方法。
简化就是有T的就能获取T的方法,有*的,就能获取到*T的方法
提升方法值的正规化和估值
以下面的代码为例:
- 提升方法表达式s.M1的完整形式为s.T.X.M1。 将此完整形式中的隐式取地址和解引用操作转换为显式操作之后的结果为(*s.T).X.M1。 在运行时刻,属主实参(*s.T).X被估值并且估值结果的一个副本被存储下来以供后用。 此估值结果为1,这就是为什么调用f()总是打印出1。
- 提升方法表达式s.M2的完整形式为s.T.X.M2。 将此完整形式中的隐式取地址和解引用操作转换为显式操作之后的结果为(&(*s.T).X).M2。 在运行时刻,属主实参&(*s.T).X被估值并且估值结果的一个副本被存储下来以供后用。 此估值结果为提升字段s.X(也就是(*s.T).X)的地址。 任何对s.X的修改都可以通过解引用此地址而反映出来,但是对s.T的修改是不会通过此地址反映出来的。 这就是为什么两个g()调用都打印出了2。
1 | package main |
非类型安全指针
- 非类型安全指针值
unsafe.Pointer是指针但uintptr值是整数,虽然uintptr常常用来存放指针值。 - 不再被使用的内存块的回收时间点是不确定的,指针值存放在
uintptr中,垃圾回收是检测不到的。 - 一个值的地址在程序运行中可能改变,比如切片扩容
正确使用非安全指针的六种模式
-
将类型
*T1的一个值转换为非类型安全指针值,然后将此非类型安全指针值转换为类型*T2。比如下面的例子:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18package main
import (
"fmt"
"unsafe"
)
func main() {
type MyString string
ms := []MyString{"C", "C++", "Go"}
fmt.Printf("%s\n", ms) // [C C++ Go]
// ss := ([]string)(ms) // 编译错误
ss := *(*[]string)(unsafe.Pointer(&ms))
ss[1] = "Rust"
fmt.Printf("%s\n", ms) // [C Rust Go]
// ms = []MyString(ss) // 编译错误
ms = *(*[]MyString)(unsafe.Pointer(&ss))
}这种类型转换共享底层数据结构,在1.17开始也可以用
unsafe.Slice((*string)(&ms[0]), len(ms)) -
将一个非类型安全指针值转换为一个uintptr值,然后使用此uintptr值。
-
将一个非类型安全指针转换为一个uintptr值,然后此uintptr值参与各种算术运算,再将算术运算的结果uintptr值转回非类型安全指针。注:
Pointer->uintptr->Pointer这个过程的转换,应该一行就写完,避免中途变为uintptr时,对应的地址解引用,被垃圾回收;以及一些操作可能导致协程堆栈大小改变,使引用的地址失效。 -
将非类型安全指针值转换为
uintptr值并传递给syscall.Syscall函数调用。这个是syscall.Syscall函数特权,它能保证进入这个函数后,改指针对应的地址不被垃圾回收或者被移动。注意从1.15后,调用的参数形式必须是uintptr(anUnsafePointer),如:1
2syscall.Syscall(syscall.SYS_READ, uintptr(fd),
uintptr(unsafe.Pointer(p)), uintptr(n)) -
将
reflect.Value.Pointer或者reflect.Value.UnsafeAddr方法的uintptr返回值立即转换为非类型安全指针。 -
将一个
reflect.SliceHeader或者reflect.StringHeader值的Data字段转换为非类型安全指针,以及其逆转换。这种方式可以直接操作slice和string的底层数据。例如:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18package main
import "fmt"
import "unsafe"
import "reflect"
func main() {
a := [...]byte{'G', 'o', 'l', 'a', 'n', 'g'}
s := "Java"
hdr := (*reflect.StringHeader)(unsafe.Pointer(&s))
hdr.Data = uintptr(unsafe.Pointer(&a))
hdr.Len = len(a)
fmt.Println(s) // Golang
// 现在,字符串s和切片a共享着底层的byte字节序列,
// 从而使得此字符串中的字节变得可以修改。
a[2], a[3], a[4], a[5] = 'o', 'g', 'l', 'e'
fmt.Println(s) // Google
}
-gcflags=all=-d=checkptr编译器动态分析选项可以检测很多非类型安全指针的错误使用。
关于(*reflect.SliceHeader).Data可能导致指针解引用,导致数据丢失问题,可参考:How to create an array or a slice from an array unsafe.Pointer
泛型
定义:是一种把明确类型的工作推迟到创建对象或者调用方法的时候才去明确的特殊的类型。也就是说在泛型使用过程中,操作的数据类型被指定为一个参数,而这种参数类型可以用在类、方法和接口中,分别被称为泛型类、泛型方法、泛型接口。
注意:一般在创建对象时,将未知的类型确定具体的类型。当没有指定泛型时,默认类型为Object类型
可参考
反射
Go反射机制设计的目标之一是任何非反射操作都可以通过反射机制来完成。
我们可以通过反射列出一个类型的所有方法和一个结构体类型的所有(导出和非导出)字段的类型
虽然reflect.Type.NumField方法返回一个结构体类型的所有字段(包括非导出字段)的数目,但是不推荐使用方法reflect.Type.FieldByName来获取非导出字段。
我们可以通过反射来检视结构体字段的标签信息,可以使用对应的Get和Lookup方法获取检视和获取相应的值。
reflect代码包也提供了一些其它函数来动态地创建出来一些非定义组合类型。例如:
1 | package main |
反射使用的三个限制(截止Go 1.17):
- 我们无法通过反射动态创建一个接口类型。
- 使用反射动态创建结构体类型的时候可能会有各种不完美的情况出现。
- 我们无法通过反射来声明一个新的类型。
一个reflect.Value值的CanSet方法将返回此reflect.Value值代表的Go值是否可以被修改(可以被赋值)。 如果一个Go值可以被修改,则我们可以调用对应的reflect.Value值的Set方法来修改此Go值。
注意:reflect.ValueOf函数直接返回的reflect.Value值都是不可修改的。
reflect标准库包中也提供了一些对应着内置函数或者各种非反射功能的函数。 下面这个例子展示了如何利用这些函数将一个自定义泛型函数绑定到不同的类型的函数值上。
1 | package main |
注意:非导出结构体字段值不能用做反射函数调用中的实参
reflect.Value类型的TrySend和TryRecv方法对应着只有一个case分支和一个default分支的select流程控制代码块。也就是非阻塞发送和接收。
我们可以使用reflect.Select函数在运行时刻来模拟具有不定case分支数量的select流程控制代码块。例如:
1 | package main |
从Go 1.17开始,一个切片可以被转化为一个相同元素类型的数组的指针类型:Value.ConvertibleTo(T Type)。 同时引入了一个Value.CanConvert(T Type)方法,用来检查一个转换是否会成功(即不会产生恐慌)
函数退出方式
- 正常返回,
return panic,能被recover捕获,阻止传播。- 调用
runtime.Goexit,退出函数,并传播到父函数,直到整个进程退出。
当函数调用中产生多次panic(比如defer里又产生了panic,或者子协程panic,主协程又panic)则新的panic将覆盖旧的panic
在下面的情况下,recover函数调用的返回值为nil(即空操作):
- 传递给相应panic函数调用的实参为nil;
- 当前协程并没有处于恐慌状态;
- recover函数并未直接在一个延迟函数调用中调用。
一些recover调用相当于空操作(No-Op):
1 | package main |
正确操作:
1 | package main |
在任何时刻,一个协程中只有最新产生的恐慌才能够被恢复。
代码块:

表达式估值顺序规则
一个表达式将在其所依赖的其它表达式估值之后进行估值
比如下面的代码将打印yzxw:
1 | package main |
白皮书中关于估值的描述:
当估值一个表达式、赋值语句或者函数返回语句中的操作数时,所有的函数调用、方法调用和通道操作将按照它们在代码中的出现顺序进行估值。
其中有个示例:
1 | y[z.f()], ok = g(h(a, b), i()+x[j()], <-c), k() |
在此赋值语句中,
c是一个通道表达式,它将被估值为一个通道值;g、h、i、j和k是一些函数表达式,它们将被估值为一些函数值;f是表达式z值的一个方法。
综合考虑上一节和本节上面已经提到的规则,编译器应该保证下列在运行时刻的估值顺序:- 此赋值中涉及到的函数调用、方法调用和通道操作必须按照这样的顺序执行:
z.f()→h()→i()→j()→<-c→g()→k(); - 调用
h()在表达式h、a和b估值之后调用; y[]在方法调用z.f()执行之后被估值;- 方法调用
z.f()在表达式z估值之后执行; x[]在调用j()执行之后被估值。
然而,下列次序在Go白皮书中未指定,它们依赖于具体编译器实现:- 表达式
y、z、g、h、a、b、x、i、j、c和k之间的相对估值顺序; - 表达式
y[]、x[]和<-c之间的相对估值顺序。
变量赋值阶段描述:
一条赋值语句的执行分为两个阶段。 首先,做为目标值的元素索引表达式中的容器值表达式和索引值表达式、做为目标值的指针解引用表达式中的指针值表达式、以及此赋值语句中的其它非目标值表达式将按照上述通常估值顺序估值。 然后,各个单值赋值将按照从左到右的顺序执行。
可以看一个示例:a, b = b, a
1 | // 估值阶段 |
但是一些同优先级的估值顺序却没有明确,比如:
1 | package main |
这段代码,输出100 99和1 99都是合理的,不同编译器实现方式不同。
switch-case估值顺序:
1 | package main |
输出结果:
1 | f(3) is called. |
select-case估值顺序:
1 | package main |
输出结果:
1 | bbb |
注意:以通道接收操作做为源值的赋值语句中的目标值表达式只有在此通道接收操作被选中之后才会被估值。
值复制成本
值尺寸(value size)
一个值的尺寸表示此值的直接部分在内存中占用多少个字节,它的间接部分(如果存在的话)对它的尺寸没有贡献。意味着:任何一个特定类型的所有值的尺寸都是相同的。所以我们也常说一个值的尺寸为此值的类型的尺寸。(至少1.17的官方编译器是如此)
一般来说,不超过4个原生字(计算机位数*4),都是小尺寸赋值,代价较小。对于标准编译器来说,除了大尺寸的结构体和数组类型,其它类型均为小尺寸类型。
通道关闭原则
不要在数据接收方或者在有多个发送者的情况下关闭通道
多个接收者和发送者进行通道关闭:
1 | package main |
注意:信号通道toStop的容量必须至少为1。 如果它的容量为0,则在中间调解者还未准备好的情况下就已经有某个协程向toStop发送信号时,此信号将被抛弃。
内存相关
内存空间分配:
变量逃逸分析
分析逃逸的编译参数:go run -gcflags "-m -l" main.go
开辟在堆的好处:
- 从栈上开辟内存块比在堆上快得多,并且不会产生内存碎片。
- 开辟在栈上的内存块不需要被垃圾回收;
- 开辟在栈上的内存块对CPU缓存更加友好。
使用内置new函数开辟的内存可能开辟在堆上,也可能开辟在栈上。
当一个协程的栈的大小改变时,一个新的内存段将申请给此栈使用。原先已经开辟在老的内存段上的内存块将很有可能被转移到新的内存段上,或者说这些内存块的地址将改变。 相应地,引用着这些开辟在此栈上的内存块的指针(它们同样开辟在此栈上)中存储的地址也将得到刷新。示例:
1 | package main |
内存块儿回收时机
- 为包级变量的直接部分开辟的内存块永远不会被回收。
- 每个协程的栈将在此协程退出之时被整体回收。
- 开辟在堆上的不再被使用的内存块将在以后某个时刻被垃圾回收器回收掉。
结语
如果你已经初步学习过golang,这本书也可以作为进阶的一本书阅读。但是个人不推荐这本书。整体来说一些知识点的表达不是很清醒,也不够明朗。但是其中一些知识点,倒是可以学习学习。
相关
go语言设计与实现 和这本书定位一样,属于golang进阶的书,但是这本书感觉更好(虽然我还没有怎么看)