学习材料

本篇为《The GO Programming Language》的学习笔记。

Preface

Go语言是Google的“Robert Griesemer, Rob Pike, Ken Thompson三人于2007年构思,并2009年发布的。和C语言很相似,但是拥有更高效的并发设施和新的数据抽象以及面向对象方式,也可以自动管理内存,拥有垃圾回收器。Go语言尤其适合构建网络服务器,比很多其他动态类型语言运行得更快。

Go的起源

Go主要起源于三股原来的语言:

  1. C语言:表达式语法,流控制语句,基础数据类型,参数的值传递,指针。语言编译成机器码。Go也被称为21世纪的C
  2. Oberon语言:包的概念来组织项目,imports和declaration,以及method declaration。
  3. CSP:CSP强调程序是一系列并行运行的进程的组合,他们之间没有任何共享的状态,进程间的沟通和同步完全依赖于channel。

Go Project

这里的project指的不是Go语言编写的项目,而是指的整个Go这个项目。它包含:Go语言,相关工具,标准库,以及极度简单化的思想。

  1. Go是极度简化的,很多其他语言中常见常用的语法和概念都没有。比如,Go没有泛型、类、继承、宏、异常、三目表达式、隐式数字类型转换、构造器和析构器、操作符重载。
  2. Go是动态类型语言,但是有一个简单的类型系统。
  3. Go中强调局部性。Go中的结构体和数组直接存储着内部的元素,支持更高效的访问。Go提供可变栈大小的轻量级线程(goroutine)。
  4. Go标准库非常丰富。Go tool可以不借助任何外部的构建脚本帮助构建go项目,源代码本身已经包含了构建方式的定义。

更多Go的相关信息和工具

1 Tutorial

Hello world

一个基本的hello world的go程序由三部分组成:

  1. package 定义
  2. imports
  3. main函数的定义

每个源文件都必须有package的定义。其中main package是个特殊的package,在这里面定义的是一个可执行的程序,而不应该是library。main package中的main function则是程序的入口。

对于大部分用户来说,go工具就可以完成下载、构建、测试、查看文档等全部开发时需要的操作了。

go中不使用;作为语句的结尾,除非要把多条语句放在同一行。go中的大括号必须和if或for放一行。

命令行参数

命令行参数存储在os.Args中,使用的时候需要引入os package。os.Args是一个slice,使用的方法类似python中的数组,可以用中括号的形式对其中的元素进行切片截取。

和其他大多数语言一样,Args的第0个参数是命令本身,从1开始后面是命令行参数

go工具的使用:在最新的go1.21.5版本中,从网络上安装一个go package的命令不再是书中的go get,而是go install xxx@version 。之前要先指定GOPATH环境变量。

使用go cli格式化代码:go fmt xxx.go

for循环:在go中只有for一个循环的关键字,但是通过后面书写的形式不同,for可以起到其他语言中while和foreach的作用:

  1. 常规的for:for initialization; condition; post
  2. 每个部分都可以省略,如果initialzation和post都省略,就同时可以省略所有分号,变成for condition的形式
  3. 省略condition,则等同于while ture
  4. for 后面直接跟上短变量声明+range,例如for _, arg := range os.Args 等同于forEach

Go中不允许声明了变量却不使用,但是range又要求前面必须有两个变量。这时可以使用_作为不用的变量名。

Go中最常用的声明变量的方式:

  1. 短变量声明:s := “”,这种只能用在函数定义内部,不能用于package级别的作用域中。
  2. var s string。

应该尽量使用上面两种声明方式。方式1用在初始值很重要的时候,而2用于不在乎初始值的时候。

Go中任何新的变量,如果不初始化,都会拥有对应类型的0值。

找重复行

go map:key必须是能够用==比较的类型。make可以用来创建一个空的map,是一个内建的函数,用于接受的变量是新建map的一个引用。map[key]如果key还不存在,在取值的时候会得到对应value类型的0值(但是并不会在这个时候自动创建map中对应的键值对。以下程序说明:

package main

func main() {
    m := make(map[string]int)
    print(m["haha"])
    //m["haha"] = 0 如果取消这一行的注释,会打印haha
    for k := range m {
        print(k)
    }
}

程序只会输出0,但是如果取消中间的注释,会输出haha。说明如果只是取值,不会创建对应的键值对;对某个健进行赋值的时候才会创建这个键值对。

文件操作:使用os.Open函数可以打开文件,返回两个值。第一个是对应打开的文件,第二个是内建的error类型对象。如果第二个值err为nil,表示打开文件的时候没有遇到问题;否则说明有错误发生。

在一个包中,包级别的声明可以以任何顺序出现,而不像在C或Cpp中,用到的函数声明必须放在文件最开头。

动画GIF

如果import的包路径有多个部分组成。image/color,但是在程序中使用的时候会用最后一部分(也就是color)代替这个包。

组合字面量:[]color.Color{…}, gif.GIF{…} 可以理解为字面创建了一个数组或者结构。

fetch url

可以使用net/http包来请求url。请求得到的resp有一个Body属性,是一个可读取的stream,可以使用ioutil.ReadAll来读出其中的内容中。

同时fetch url

可以用goroutine来创建新线程,同时于主线程运行。主线程和goroutine之间需要通过channel交流。<-ch表示从ch中接收到的内容,而ch <- fmt.Sprint(err)表示将内容放入ch中。

web server

server在接受到请求后,会启动一个新的goroutine处理,因此可以同时处理多个请求。可以通过http.HandleFunc定义某个路径使用某个处理函数,之后可以通过http.ListenAndServe启动服务器在某一个地址某一个端口上。

go中允许在if的条件前面加一个简单的语句,比如给某个变量先赋值然后再判断。这样的写法更加紧凑。

函数字面量:类似于匿名函数,在使用的时候定义的一个没有名字的函数。

Loose Ends

switch语句:switch后面可以选择加一个表达式。如果加有表达式,下面的case后面只需要跟一个表达式就可以,表示等于这个值的时候进入这个分支;如果没有表达式,下面的case要跟一个boolean值的表达式,表示这个条件为真的时候进入此分支。不同于C,go中的case匹配之后,默认会直接跳出,不再需要break。

go中的指针:go保留了指针,可以获得一个变量的存储地址,但是和C不同的是,没有保留指针的算数操作。

method:go中的function如果关联到了一个具名类型上,就叫做method。

注释://表示单行注释,/*表示多行注释,而且不能嵌套注释。

2 程序结构

Name

go中只有25个保留关键字。像int true false之类的并不是保留关键字,也就是说可以被用于变量命名。如果一个实体是在函数内部被声明的,它就是一个函数本地的;如果在函数外(包层)声明的,他就会对于包中所有文件可见;如果他的第一个字母还是大写的,就会对于外部的包可见。

命名的时候推荐采用驼峰式命名。对于多个大写字母连写(例如HTML)这样的单词,参与驼峰式命名的时候要么全部大写,要么全部小写,避免第一个字母大写第二个字母小写这种情况。

声明

A declaration names a program entity and specifies some or all its properties. “声明”给一个实体取了名字,并指定了全部或部分属性。

声明一共有四种:

  • var 声明变量
  • const 声明常量
  • type 声明类型
  • func 声明函数

变量

var 声明创建了一个特定类型的变量,给他起了个名字,并给他赋予初始值。记住,这三件事缺一不可。

声明的形式:var name type = expression type和expression可以省略,但是不能都省略。如果省略type,则会根据expression的值推断最合适的type类型,而如果省略expression则会给这个变量赋0值。

程序执行的时候,package-level的变量会在main函数运行前被初始化,而local变量在执行到对应语句的时候才会初始化。

短变量声明:通过name := expression的形式可以快速声明一个变量,称为短变量声明。实际上使用中大部分时候都会用这种短变量声明。在局部变量中使用var只有两种情况:不想使用自动推断的类型;现在还不需要赋值,不在于初始值。如果是package level的变量,只能用var而不可以用:=。

不管var还是:=都可以同时声明多个变量。var b f s = true, 2.3, "four"a, b := 0, 1。赋值语句也可以类似python中一样,同时赋给多个变量值:a, b = b, a,首先会对右边所有变量求值,然后赋给左边的变量。

:=使用的时候不必所有变量都是新的。如果左边出现了在同作用域下的其他变量,那么对于这个变量的操作就等同于赋值。注意两点:不可以同时所有变量都是赋值;必须是同作用域下的变量才可以被当作赋值。

指针:在go中,不是所有value都有地址,但是所有的variable都有。&x可以取x的地址,&也只能这么用。指针的0值是nil。相同类型的指针相等的条件是:都为nil;指向同一个变量。

不同于Cpp,在go中,函数返回的时候完全可以返回一个本地变量的指针,而且多次调用返回的指针不同。

flag.Parse可以帮助处理CLI程序的选项。

new 函数。在go中new的地位非常低,仅仅是一个内建函数。new(T)做三件事情:创建一个无名的T类型变量,赋0值,然后将其指针返回。实际上用的很少。

变量生命周期:go中变量的生命周期会持续到它变成unreachable。对于package-level的变量,生命周期会持续到整个程序结束。函数的参数和返回值实际上也是本地变量,会在含有他们的函数调用的时候被立刻创建。unreachable的推断使用的是根可达算法,而根就是所有package-level变量,以及所有当前活跃函数的本地变量。一个变量被分配到堆上还是栈上,并不取决于用new还是直接声明,而是看这个变量是否逃逸出当前函数。如果逃逸出了当前函数,就必须被分配到堆上。把一个函数的指针作为返回值返回出去,就称之为变量逃逸出了函数。

赋值

有三种类型的表达式可以一次性返回两个值:

  • map lookup。map[key]. v, ok = map[key]
  • type assertion. x.(T), v, ok = x.(T) 如果x是T类型的,v会被赋值为T。
  • Channel receive. <-ch, v, ok = <-ch

上面这三种表达式也可以不用后面的ok,只接受一个值。

可赋值:如果左右具有相同的类型,则一定可赋值。如果类型不同,也有可赋值的情况。例如nil可以赋值给任何interface或者reference类型。进一步,两边变量都可以互相赋值给对方,则两个变量可以用==或者!=判断是否相等。

type

type声明本质上就是给一个已存在的类型起一个别名。

类型转换:如果两个类型有相同的底层类型,或者是指向相同底层类型的指针,那么就可以进行类型转换。直接通过T(x)进行转换。通过type定义新类型的时候,新的类型会继承基类型的底层类型。这意味着,如果有 type A intA 的底层类型就是 int。如果你基于 A 再定义一个新类型 type B A,那么 B 的底层类型也是 int。类型转换的检查都是在编译期完成的,在运行时不会出现任何类型转换的错误。

底层类型决定了一个类型的结构,以及他支持哪些基本的操作。可以在一个具名类型和他的基类型之间用== <之类的比较。但是不同的具名类型之间不能相互比较。

具名类型可以附着函数(称为方法),增加这个类型的新行为。

包和文件

每一个package都是一个单独的name space.

在引入的时候,每一个package都是被import path唯一标识的,例如claws.top/test.按照管理,import path的最后一段(也就是test),会和package name相匹配,这样在使用的时候就可以很方便地知道package name是什么。

import 声明给引入的包绑定了一个短名字(比如test)。在这个文件中,就可以用这个短名字来代指引入的这个包。如果有多个相同名字的包,可以手动指定包的短名字,来避免冲突。

包的初始化:

  1. 包初始化顺序:按照import和依赖的顺序。从最底层的依赖开始初始化,一直到main
  2. 包中包级别变量的初始化:按照定义的顺序。同一个包的多个文件之间,按照喂给编译器的顺序。如果使用go命令,给编译器时的顺序是按照名称排序的。

由于包级别不能使用语句,只能定义实体,因此如果像对一个数组进行初始化,可以定义init函数。一个文件中可以定义多个init函数,调用的顺序是按照出现顺序来的。init函数出了不能被引用和调用,其他地方和普通函数没有区别。

关于for range,如果只用index而不用具体的值的话,可以直接省略第二个参数:for index := range list

作用域

scope是编译期的概念,指的是声明的名字指向声明,也就是声明起作用,的代码段。life time是运行期概念,指的是一个变量何时能够被程序其他部分引用。

作用域最明显的就是大括号包起来的部分。但除此以外,还有很多看不见的作用域。比如整个程序的universe作用域,for和if中出现的隐式作用域,每个文件中的作用域等等。程序中允许多个相同name存在,只要他们不是在同一个作用域中声明的。当碰到一个name时,程序也会从最小(最内层)的作用域向外找,直到找到最近的一个声明。

内建的函数、常量之类就在universe作用域中。

对于imports来说,使用的就是文件作用域,因为在一个文件中import的包在其他文件中不能使用。

对于for if语句来说,就存在两个作用域。第一个是包含初始化条件的大作用域,另一个是for的循环体或者if中显示大括号括起来的代码段。

特别需要注意的一个点就是:=。如果要利用它可以给部分变量赋值的特性,一定要注意这个赋值目标变量是同一作用域中定义的,否则还是会声明一个新的变量。换句话说,:=会尽可能新声明一个变量,除非在相同作用域已经有同名变量了。

3 基本数据类型

Go数据类型,可以分为4个大类:

  • 基本类型:数字、布尔、字符串
  • 聚合类型:数组、结构
  • 引用类型:间接指向一个变量的类型。例如pointer slice map function channel。对其中一个修改,所有备份都会观察都变化。
  • 接口类型

整数

位数 有符号 无符号
8 int8 uint8
16 int16 uint16
32 int32 uint32
64 int64 Uint64

另外还有int和uint,根据硬件和编译器不同,可能是32或64位的。

rune是int32的同义词,具有相同的结构和性质;byte和unit8是同义词。

整数的运算有5个优先级:

  1. * / % 左移右移 按位与 &^
  2. 加减 按位或 异或
  3. 比较

对于前两个优先级,都可以和赋值=拼接。

Go中的取余数,结果始终和被除数保持一致。

Go中也有溢出。

Go中所有相同基础类型的两个变量之间都可以比较。

%08b 可以按二进制输出一个数,并补全到8位。

Go中的右移是算术右移,保持符号一致。

%[1]v中的1表示这里重复使用第一个输入的参数。

Rune字面量是用单引号括起来的一个字符。

浮点数

只有float32和float64两种。

%g自动格式输出浮点数。

在数学上不存在的运算结果会得到NaN。NaN和任何数(包括NaN)比较的结果都是false。

复数

有两种:complex32和complex64

布尔值

有短路。且的优先级比或高。

字符串

不可变。len(s)返回的是字符串中的字节数,而不是字符数。字符串之间的比较是按字典序的。

字符串的不可变方便用同一个字符串中的一部分代表另一个字符串,提高了性能。但是也造成了构造字符串时需要频繁拷贝。如果要构造字符串可以用bytes.Buffer。

字符串中不仅可以包含合法的字符,也可以包含任意字符,可以用\xhh来表示任意一个字节。如果字符串用反引号,则是raw string,里面除了会删除回车符合,其他的均不会转义。而且可以包含多行字符。

Utf-8是两位Go的发明者创造的,因此在Go中对utf-8支持很好。在字符串中\uhhhh就可以代表任何unicode中的字符(注意hhhh是unicode code point,而不是二进制)。当然也可以用\xhh的形式,用二进制表示一个utf-8字符。utf-8的特点是,任何字符都不是另外一个字符的前缀。

for range还有另外一种形式,可以省略前面的两个变量:for range s {xx}s是一个字符串。range s是按照字符(rune)取的,而不是按照字节。因此可以用for range来统计字符数。

可以将字符串转换成byte slice:[]byte(s)。为了方便使用,许多对字符串的操作在bytes和string包中都有。

字符串和数字之间可以借助库函数方便地进行转换。strconv.Itoa(“123”)可以把整数转成字符串。而strconv.Atoi()可以把字符串转成整数。

常量

重要概念:常量是表达式。是一种在编译期,编译器就已经确定值的表达式。常量的底层类型是三种基本类型:数字、布尔、字符串。

const声明:定义了一个有名字的value,但是value是一个常量,看起来形式和声明变量很像。对于常量,因为编译器已经知道它的值,因此很多错误(除零、数组越界等)可以在编译器被发现。常数间的运算结果还是常量。声明的时候如果没有明确指定类型,会根据后面的值推断类型。

const声明组:在同一个组中,如果下面一个变量没有指定值,则继承上面变量的表达式。同样的,下面也可以既没有类型也没有表达式,会继承上面的类型和表达式。

iota:在同一个const组中,每次使用值从0递增。

无类型常量:没有指定类型,或者字面量,是无类型常量。无类型常量的特点是有巨大的精度(通常256位以上)。有6种无类型常量:无类型布尔,无类型整数、无类型rune、无类型浮点数、无类型复数、无类型字符串。例如const a = 1中的a就是一个无类型整数。

字面量也可以视为无类型常量(字面量满足常量的定义,首先是一个表达式,其次在编译器值已确定),因此用字面量声明变量的过程中实际上存在从untyped constant到其他类型的转换过程。在声明中的类型转换(无论是显示var a int = 1还是隐式var a = 1),只要target type可以represent原类型,就可以进行转换。这种represent包括对复数和浮点数进行舍入。在短变量声明中也存在这种隐式转换。

4 复杂类型

复杂类型涉及array map slice struct。其中array和struct是有固定大小的,而slice和struct没有固定的大小,其大小根据其中的数据会发生变化。

数组

很少直接用(一般都是通过slice使用)

数组字面量:[3]int{1,2,3}。需要注意的地方:

  • 如果[...]表示根据后面{}中的值推断数组长度
  • 如果{}中元素个数没有前面那么多,这些元素会用来填充前面的位置,后面的位置被置为0值
  • size必须是constant(编译期确定的)

如果数组的元素是可比较的,那么数组也是可以比较的。

如果把数组作为函数的参数,必须指定数组的大小,而且传入的实参必须具有同等的大小。使用数组参数的时候数组会被拷贝一份传入。

数组和元素都可以被寻址。

Slice

看起来就像没有指定大小的数组,但还是有容量限制的。Slice的容量等于底层数组的长度。多个Slice可以共享底层数组。可以访问超过slice的len,但是不超过cap位置的元素;但如果访问>=cap的元素就会引起panic。

因为底层还是数组,所以Slice和元素都是可以寻址的。

slice字面量:写起来和array一样,只不过没有指定size。使用的时候实际会创建一个对应大小的array并创建一个slice使用它作为underlying array。

slice是不可以比较的。原因:如果将slice设计为可比较的,就面临一个问题:若使用shadow,符合可以放在map中作为键(map会为key建立shadow的拷贝),但会使得slice和array在比较行为上产生不一致。因此干脆不允许slice之间直接比较,避免出错。如果要比较,需要自己编写深层次的比较代码。slice只能和nil进行比较。

可以用[]int(nil)来表示一个特定类型,但是为nil的slice字面量。go中的函数应该将nil和0长度的slice一视同仁。

make函数可以用来建一个指定长度的slice:make([]T, len, cap)。如果cap省略则cap默认等于len。

copy函数,用于将一个slice的内容拷贝到另一个。copy(dst, src)顺序和赋值一样。使用这个函数不需要担心长度越界的问题,只会拷贝两个长度都满足的元素数量。有一个返回值,就是实际拷贝的元素数。

append,用来将元素附加到一个slice上,并返回新的slice。结果slice和之前的slice可能用的是同一个underlying array,也可能不是,因此需要将这个结果赋值给一个slice来使用。虽然slice指向的数据是间接的,但是slice中的underlying array的指针,len和cap都是直接的,意味着这些东西在函数调用中会被拷贝,并且对他们的修改无法影响到调用者。append不仅可以附加一个元素,还可以一次附加多个元素。如果要附加一个列表的所有元素,可以使用…来表示一个slice中的所有元素,类似python中的拆包:slice1 = append(slice1, slice2...)

Map

用make函数可以建一个新的map。

map通过中括号取元素的时候不会出现错误,如果key不在map里,会返回map对应value的0值。map取元素的时候会同时返回一个ok值(第二个返回值),表示该key是否在map中。因此+=和++这种可以放心地应用于map的元素上。需要注意的是,因为map中的元素的存储位置可能会因为rehash等原因发生变化,因此map元素是不可以寻址的。

go中map是无序的,不应该期待map以任何规律返回元素。

nil的map和empty的map应该被一视同仁,因此一些常见的对map的操作(例如delete,len,range)对于两者的行为都是完全一致的。但是nil的map不能取任何key,否则会引起panic。

map之间也是不能比较的。

Go的set通常用一个map[T]bool表示。

如果想把一些不能比较的值作为key(如slice),常用的技巧是用fmt.Sprintf把他转换成一个字符串,或者通过任何自己的方法对key进行转化,再塞到map的key中。

bufio.Reader可以调用ReadRune读取下一个rune。

Structs

结构体。是0或多个有名称的value的聚合。每一个value都叫做一个field。struct本身是一个变量,因此struct的每个field也是一个变量,都是可以寻址的。

用.可以取结构体中的field。如果要通过一个结构体的指针取field,符合常理的做法是(*pointer).xxx,但是为了简化,直接pointer.xxx也是可以的。

在go中,函数想返回一个结构体是如果不使用指针,返回出的结构体不能被修改。这是因为函数的返回值是一个临时值,不能被直接修改。如果后续想对这个返回值进行修改,则可以在函数中返回一个结构体的指针。

一个结构体中可以同时包含exported和unexported的filed,是否exported也由首字母的大小写决定。如果没有exported,则其他包中的代码就算能看到这个结构体,也是不能访问这个field的。另外,S结构体中的field不能是S自身,但是可以是*S。

结构体的0值是由每个field的0值构成的。没有field的结构体也可以存在,写作struct{}

结构体字面量中,可以不使用field的名字指定filed的值,但是必须每个值和每个field按照顺序对应。也可以使用filed名字来指定field的值。如果在包中使用另一个包中的结构体字面量,就算通过第一种方法隐式指定unexported的field的值也是不可以的。unexported还是比较严格的。

可以用如下的方式简便地使用一个结构体字面量并得到他的指针:pp := &Point{1, 2},右边的可以理解为是一个结构体指针字面量,他也可以直接用于各种其他表达式和函数调用中。

结构体中,可以省略field的名字,称为匿名field。但匿名field并不是真的没有名字,而是会有一个和该field类型相同的隐式的名字。如果这个field刚好是一个结构体,则称之为嵌套结构体。嵌套结构体的特点是,可以如同访问自身field一样,访问嵌套结构体中的field。由于匿名field实际上还是有名字,所以不能同时有两个相同类型的匿名field。

在输出一个结构体的时候,%#v可以让结构体输出成go中字面量的形式。

JSON

json中三种基本类型number, boolean, string。两种聚合类型:array和object。object的建必须是string.

在go中定义结构体的时候field可以后面跟一个raw string,表示这个field在序列化的时候应该如何做。例如,json:"color,omitempty"就表示,这个字段在转json的时候应该叫做color,omitemtpy表示如果是0值,可以直接省略掉这个字段。

序列化成json可以通过json.Marshal完成 。序列化的时候只会包含exported的field。

反序列化的时候是大小写不敏感的,只有当json key中出现下划线时才需要特别在field中标注。

Text HTML模板

模板中可以包含{{}}`,称为actions,在使用的时候动态用其中的值作为最终的内容。如果直接用.,表示访问模板的参数,例如`.CreatedAt`会访问参数结构体的CreatedAt field。 使用模板时有两步,首先将模板读取转化成内部表示形式,然后输入内容执行模板,获得最终输出。 在HTML模板中,为了预防注入攻击,会将特殊的符号进行转义。如果带入的内容本来就是html元素,需要使用template.HTML类型,不能使用string类型。 模板中也可以出现循环,用`{{range xxx}} ...... {{end}}

5 函数

函数声明

函数声明中有名字,参数列表和返回值列表。无论是参数列表还是返回值列表,其中的变量都可以省略名字。还可以采用比较紧凑的方式书写,将相同类型的相邻参数压缩在一起,最后声明类型,例如:a, b int

如果参数列表和返回值列表的类型完全相同(含顺序),则两个函数类型(签名)相同。

Go中没有默认参数,也不能在调用的时候通过名字指定参数的值。函数在识别的时候完全是根据签名来的。如果一个函数没有函数体,则他是一个其他语言实现的函数。

递归

函数中可以出现对自己的调用,实现递归。

在其他语言中,递归时容易爆栈。但是Go中栈大小由于是动态的,相比之下没有那么容易爆栈。

多返回值

有很多函数需要返回多个值,最常见的就是最后会多一个err返回值。通过fmt.Errorf()函数可以快速创建一个含有指定message的error作为返回值。

一个返回多个返回值的函数结果,可以直接作为另一个需要多个参数的函数的输入。

如果函数的返回值列表每一个都有名字,在返回的时候可以直接用bare return的形式,即赤裸裸一个return。可以理解为函数在调用的时候会创建好对应的返回值变量,如果遇到bare return就会直接返回这些创建好的变量。

Error

根据函数执行可能出现的结果,可以将所有函数分为三类:

  1. 一定成功的函数。这类函数无论输入的参数是什么样的,一定能返回一个合理的结果。
  2. 前置条件满足时一定成功的函数。如果前置条件不满足,调用该函数会引发panic之类的错误。这种错误可以视为是调用法代码的bug。
  3. 依赖于外部因素,可能成功也可能失败的函数。对于这类函数中不确定的外部原因引起的失败,需要积极进行处理。失败也是这类函数expected的结果。

异常处理主要针对第三种。异常处理不会处理意外而无法恢复的事故(应该任由程序崩溃),也不会处理应该满足的条件(应该引起panic),而是处理脱离此程序控制的外部因素可能引起的,有预料的异常。

异常的表达:如果只可能有一种引起失败的原因,则直接在返回值的最后加一个ok的bool型返回值,表示操作是否成功。如果有多种可能引发异常的原因,则可以在返回值列表的最后用一个error类型的返回值表达。

通常,如果函数返回的err不是nil,那前面的返回值应该被忽略。但是有些函数也会返回处理一半的半成品结果,这种需要在文档进行特殊的说明。

虽然go中也有异常处理机制,但仅仅用来处理真正的意外的错误,而不是某种有预料的异常。对于有预料的异常,go中应用常规的if … return流控制语句来处理。

异常处理策略:处理异常是调用者的责任。有五种策略:

  1. 传播。直接返回函数的异常给上层调用者,或者包装之后返回给上层。包装的时候需要注意,避免使用大写字母和换行,因为这样会影响输出错误原因,不好辨识。在设计错误信息的时候也要注意,同一个包中的错误信息风格应该保持一致。
  2. 重试。对于值得重试的操作,遇到错误可以进行重试。
  3. 打印错误并停止程序。只能在main中使用。调用log.Fatallf可以方便地打印错误后停止程序。
  4. 对于无关紧要的错误,吞掉。记录一下错误然后继续执行。
  5. 完全忽视。完全不在乎有没有错误发生,忽略掉函数发回的err返回值。使用这种策略时最好进行说明。

go中函数一般先写处理好各种错误的代码,最后再写所有流程正常的逻辑。

io.EOF也是一种error,需要在使用ReadRune之类的方法时处理好。需要err不为nil的时候可以先判断一下是不是等于io.EOF,单独处理这种情况。

Function Value

Go中,函数是一等公民。函数的签名,也就是他的类型。function value也可以被声明成变量,或者当做参数传递到其他函数中。这使得不仅可以参数化函数的输入数据,也可以参数化函数的行为。

匿名函数

go中可以用函数字面量直接表示一个function value,这个没有名字的function value也被称为函数字面量。

匿名函数可以访问定义处上下文中的变量,这就使得function value不仅只是行为,也有自己的状态。因此许多人成function value为closure(闭包)。

如果想在匿名函数中使用递归,必须先声明一个变量,然后将匿名函数赋值给这个变量。函数中需要递归的调用用这个变量表示。

循环变量捕获陷阱:不要让loop内的匿名函数捕获循环变量!原因:function value(闭包)中捕获实际上是以地址的形式捕获的。而循环变量在多轮循环中实际上用的是相同的地址。
使用本地变量能解决的原因:本地变量逃逸之后,每一轮循环会有一个新的本地变量被分配到堆上不同位置,所以每个闭包读到的值不同。如果是循环变量,虽然也会逃逸,但是每一轮使用的还是堆上同一个变量。

Defered function calls

defer function诞生于一个问题:代码中很多操作是需要成对出现的,例如打开文件后必须手动关闭文件,避免文件描述符被消耗完,或是一直占用着文件。但是对于控制流比较复杂的函数,要记得在每一个函数的结局中都关闭文件就会使得代码变得不仅冗杂还容易出错忘记。于是就有了defer function,可以将一次函数调用推迟到函数返回之后执行。

函数中在遇到defer语句时,会立刻求出函数的值和所有参数的值,但保留表达式中最后一次函数调用不执行,并把它推迟到函数返回后执行。如果函数中有多个defer,最后也会按照defer出现相反的顺序执行这些被defer的函数(后进先出)。

放置defer语句最好的位置就是在获取资源的直后。借助defer会先求函数和参数值的特性,也可以精心构造一条语句,语句中调用一个函数,但是这个函数返回的是一个匿名函数,也就是defer的对象,这样就可以用一条语句安排on entry和on exist两个动作。如果defer的是一个匿名函数,由于这个匿名函数可以访问到当前函数的本地变量(包括声明的返回值变量),所以defer的函数甚至可以在return之后再修改函数的返回值。

panic

panic在go中的语义是:被认为是不可能发生的情况,如果发生说明程序代码出现了bug。因此大多数时候对待panic的态度就是不去处理,让程序崩溃。出现的panic应该被看做是一种程序中有bug的强有力的提示,一般情况下都应该尽力防止。因此panic虽然和其他语言中的异常有点相似,但是用法大相径庭。

一个很好的例子是,regexp.MustCompile方法。这个方法一般用于编译代码中硬编码的正则表达式,行为是当表达式有问题的时候会panic。这个场景下,panic意味着硬编码的正则表达式有问题,是程序中的bug。如果正则表达式来源是用户输入等不可靠的来源,则不应该使用这个方法,而是用普通的Compile方法,然后妥当地处理err。

并不是所有panic都来源于运行时错误,因为go提供了内置的panic函数用于手动触发一个panic。

当函数中出现panic的时候,如果该函数有defer的多个函数,则这些函数会按照反向的顺序执行,因为剩余的函数会按照栈上的顺序弹出执行。go中panic的时候会先执行所有defer函数,然后再展开调用栈。

Recover

虽然对待大部分panic应有的态度都是任由程序报错就好,但是有时候panic还可以进行回复(例如webserver中用户的handler崩溃,server不应该就此停止服务),或者有时候至少需要先处理一下mess再让程序退出。这时就有必要通过内置的recover函数让goroutinue从panic中回复回来。

recover必须在defer的function中执行,会终止当前函数的panick。发生panic后面的语句不会继续执行,当前函数会正常返回。

一般,不应该从恢复其他package中的panic。这个其他package有两种情况,第一是调用的其他api中的panic,第二是用户传入的callback中的panic。虽然刚才举得webserver中恢复用户handler的panic的例子可以保持server正常运行,但是也造成了一定的风险,因为恢复回来的线程状态不确定,可能有被打断的操作,会有后续出问题的风险。只应该恢复在自己掌控范围内,有意抛出的panic。实现的方法是,自己抛出的时候,带有一个自定义类型参数来调用panic,恢复的时候一定要检查panic是不是自定义的那个类型,并只恢复这种类型的panic。recover的时候会返回panic的对象(如果没有panic,会返回nil),因此可以实现这样的判断。对于不是目标panic,应当手动调用panic函数再次抛出。

6 方法

在go中,object只是一个有方法的值或者变量,而方法则是一个和特定类型关联的函数。

方法声明

方法的声明和普通函数很类似,只是在函数名之前加了一个额外的参数,比如func (path Path) Distance() float64.这个额外的参数只能有一个,被成为是方法的receiver。

指针receiver

在普通的函数中,参数的传递会拷贝参数的值,而如果使用指针类型的参数,传递时只会拷贝指针的值,可以避免对背后的内容进行拷贝。这对方法的receiver来说同样适用。

按照惯例,如果一个类型有任意一个方法是指针receiver,则所有的方法都应该保持一致,即都使用指针类型的receiver。receiver的类型只能是具名类型(本身不能是指针类型)和具名类型的指针。

在代码中进行方法调用时,如果有指针类型的方法,则不需要再对原类型的变量特意去取地址再调用方法,编译器会自动帮我们补全取地址符;反过来,如果有原类型的方法,也不需要对指针变量特地使用*符,也可以让编译器自动补足。

如果方法的参数可以接受nil作为receiver,则应该在文档中进行说明。对于能接受nil的方法,nil.Get("item")这样的调用是不能通过编译的,正确的写法应该是Values(nil).Get("item"),这一点需要注意。

用结构体嵌套来组合类型

对结构体中的struct不起名字可以声明embedded struct。embeded struct内的filed,可以透明地被外层的结构直接获取。

虽然嵌套的结构体看起来很像继承,但这其实是一个误区。典型的继承关系是is-a的,子类对象可以被当做父类对象使用。但是在go中,外层对象并不能被在函数参数中“冒充”内层对象使用,这种关系其实是一种has-a关系。

方法查找的顺序:先找coloredpoint的直接方法,然后找coloredpoint内部匿名属性的这个方法,再找匿名属性的匿名属性的方法。如果同一层级一次找到两个及以上,就会报错。

匿名的struct里面可以以匿名属性的方式包含一个其他具名struct,这个具名struct的方法在这个匿名struct的实例上也可以使用。

方法值和方法表达式

一个方法也像function value一样,被像一个普通的值一样单独拿出来,从一个具体对象上拿下来的,已经将receiver绑定到原来对象上的方法被成为method value,使用的时候可以当做一个receiver已经固定的函数一样。如果是从类型上拿下来的,成为method expression,使用的时候第一个参数需要指定receiver,就像把receiver还原到第一个参数的位置,形成一个普通的function value。

fmt对于String方法的特殊关照:fmt打印有String方法的struct的时候,会打印它的String方法的结果。需要注意的是,但是*T的String方法并不会被fmt当做T的String方法,这里并不会触发编译器的自动补全。

封装

一个对象的变量或方法如果不能被他的client访问,就叫做被封装起来了。封装,又称为信息隐藏。

在go中,只有首字母大小写这一种机制可以影响一个变量或方法是否能被其他包访问。因此如果想封装一个对象,就必须借助struct。封装的单元只有package级别一种。

封装的好处:

  1. 减少客户端需要关注的信息
  2. 将容易发生变化的细节封装起来可以让开发者更灵活地更改实现。
  3. 可以避免client随意设置对象的某个变量。

在go中,也会使用getter和setter,但getter惯例会省略Get前缀。

7 Interface

和其他很多面向对象的编程语言一样,Go中的接口(interface)是为了概括和抽象具体类型的行为能力,从而使开发者可以面向抽象而不是具体实现类编程。相比其他语言,Go中接口的特点是可以隐式套入(satisfied implicitly),即类型无需声明自己实现了什么接口,只需要拥有接口中声明的方法就可以被视为该接口的实现。

接口是一种约定

如果是知道一个value的具体类型就相当于知道了它是什么和能做什么,那么知道一个value的接口类型(或抽象类型)就相当于只知道它能做什么。

在标准库中fmt包里,就有接口的例子。fmt.Fprintf函数接受一个io.Writer类型的参数,这里io.Writer就是一个接口类型。fmt对于该writer的具体类型一无所知,但并不妨碍他知道writer有一个Write方法,这是io.Writer接口给予的约定,在函数中可以任意使用这个方法完成其职责。

另一个例子是Stringer接口,实现了这个接口的类都有String()方法,用于将这个值转换成便于打印的字符串。

接口类型

接口类型中定义着一些方法,所有能被视为是该接口实现的具体类型必须拥有所有同样的方法。

接口中可以嵌入其他接口,相当于将所有被嵌入接口的方法定义在大接口中。

在Go中,接口的命名惯例是xxxer,例如Reader, Writer, Closer。有嵌入接口的大接口例如ReadWriter,ReadWriteCloser。

接口满足

如果具体类型T拥有接口A的所有方法,则称T类型满足接口A。当且仅当T满足A接口的时候,T的value才可以被赋值到A类型的变量上。

在“拥有方法”这件事上,接口的判断是比较严格的。这意味着虽然对于*A类型receiver的方法,也可以直接传入一个A类型的值作为参数,但这并不意味着A类型拥有该方法,之所以能够调用只是因为编译器提供的语法糖,实际上是*A类型拥有这个方法。因此,可能存在*A类型满足一个接口,但A类型并不满足的情况。

空引用类型interface{}不需要其他类型拥有任何方法就可以满足,这也意味着可以把任何值赋值给一个空引用类型的变量。当空引用类型用于函数参数时,该参数的位置实际上就可以接受任何类型的值,这是fmt.Println和errorf等函数能接受任何类型值的原因。

偶尔,需要断定类型T确实满足接口A,可以使用var _ io.Writer = (*bytes.Buffer)nil这样的语句来判断。

许多接口通常需要用指针类型的类型来满足,因为他们要求的方法行为需要对receiver进行修改,例如io.Write。但也不是说只有指针类型才能满足接口,许多其他引用类型如map也是可以满足接口的。

由于Go不要求显示声明具体类型和接口之间的继承关系,所以可以实现在不修改任何代码的情况下添加接口,把一些具体类型分为一类统一处理。这个特性在对于使用一些无法修改的外部代码时非常方便。

案例-命令行参数处理

flag.Value是标准库提供的一个命令行参数的接口,需要String()和Set(string) error两个方法。通过定义满足flag.Value的自定义flag,可以通过flag.CommandLine.Var(value Value, name string, usage string)方法注册到命令行参数中。这样,再通过flag.Parse()处理时就可以得到这个命令行参数。

书上的例子中,注意celsiusFlag本身并不满足flag.Value,*celsiusFlag满足,因此在使用Var函数的时候第一个参数为&f。

Interface Value

interface类型的一个值称为是interface value,由两个部分组成,分别是具体类型(动态类型)和该类型下的值(动态值)。对于Go这样的静态类型语言,类型只是编译期的概念,因此type并不是值(或者说并不能被带入运行期),但Go提供了类型描述符的概念来说明各个类型的名字和拥有的方法。在interface value中,动态类型就是类型描述符表示的。

interface value的零值中,type和dynamic vlue两部分都是nil。而一个interface value是不是nil,只取决于type部分是不是nil。在对一个interface value赋值的时候,会同时赋值type部分和动态值部分。如果赋nil,则会两部分都赋nil。

调用interface value的方法时,由于在编译期并不知道具体的类型,因此执行的具体方法需要在运行期时动态分派。编译期需要做的是生成能在运行时找到正确的方法地址的代码,并将interface value的动态值部分的拷贝作为receiver传入方法。

interface value之间是可以比较的。如果两个interface value都是nil,或他们的值部分根据类型部分equal比较的结果相等时,两者是相等的。如果interface value的动态类型是不可比较的(例如slices、maps或functions),则在interface value比较的时候会panic。如果想知道一个interface value的具体类型是什么,可以使用fmt的Printf,使用%T来获得interface value的type。

陷阱:一个包含空指针的interface value本身并不为nil。在func f(out io.Writer)函数调用中,有对out是否为nil的判定。需要注意的是,如果传入的是一个nil的具体类型值,out其实会得到一个虽然value部分为nil,但type部分不为nil的值,out实际会判断为不为nil。

使用sort.Interface来排序

sort.Interface是一个sort包中的接口要求了三个方法:Len(), Less和Swap。可以通过定义方法满足这个sort.Interface接口来使用sort.Sort对一个序列进行排序。

  1. Len() int返回序列的长度。
  2. Less(i, j int) bool,是一个比较大小用的方法,返回i是否小于j的布尔值
  3. Swap(i, j int)是一个用来交换两个序列中元素的方法。

这样看来,go中的接口非常适合写模板方法。很多库中都会定义一个接口和对应的处理方法,客户只需要定义方法来满足接口,就可以使用处理方法。

使用http.Handler来处理http请求

http.Handler是一个http包中抽象http请求处理器的接口,要求了一个ServeHTTP(w ResponseWriter, r *Request)的方法。满足这个接口可以使用http.ListenAndServe方法来启动一个http服务器。

使用error Interface来处理错误

error是一个error包内部的接口,要求一个Error方法,用来返回错误的原因。由于时内部接口,它的实现都是在error内部的。一个典型的实现就是errorString类型,它可以通过errors.New函数创建。不同的New即使填入的信息相同,也能返回不相等的值。

除了error.New方法用来得到一个error类型的错误,更常用的是Errorf方法,可以以格式化的形式来构造错误信息,本质上也是调用了errors.New函数。

类型断言

类型断言可以用来确定操作数的动态类型是否能匹配一个类型,如同x.(T)这样来使用。根据T类型的情况,判断方式和结果有下面的区别:

  1. 如果T是一个具体类型,则x.(T)会检查x的动态类型是否就是T。如果不是,panic。如果是,x会被抽离出一个真正为T类型的变量(注意到x虽然动态类型为T,但表面上是一个其他接口类型的)。
  2. 如果T是一个接口类型,则x.(T)会检查x的动态类型是否能满足T。如果不能,panic。如果能,x中的动态类型和动态值被放在一个T接口类型的新变量中作为结果。

如果x为nil,无论T是什么,类型断言都会失败。通常情况下,没必要把一个大类型的表达式断言为一个小的接口类型(有更少方法的类型),没什么意义。但是把一个小类型的表达式断言为一个较大类型,或为了确定一个interface value的动态类型是不是某个具体类型时会使用接口断言(类型instanceof)。

如果接口断言的结果用两个变量接收,则第二个变量会得到一个bool值,表示类型断言是否ok,这样即使断言失败也不会panic。

使用类型断言对error进行细化处理

得到一个error,通常我们想知道错误的具体类型是什么,方便根据不同的错误原因采取不同的处理操作。就可以使用类型断言来做:if pe, ok := err.(*PathError); ok{...}

使用类型断言请求(额外的)行为

问题诞生的场景:现在有一个T类型的interface value,名为x。假如x确定满足另一个接口A,完成同样的行为会有更方便的解决方法。在真正处理之前,我们可以先通过类型断言,看看x是否真的满足A,并在满足的情况下请求A接口上额外的行为。

类型 Switch

指的是通过switch语句来判断具体类型,并根据具体类型完成不同行为的写法。在go中,方法不允许重载,可以通过这种方法实现对输入参数不同类型执行不同操作。

在面向对象编程语言中,有两种最常见的多态形式:子类多态(subtype polymorphism)和特设多态(ad hoc polymorphism)。子类多态强调的是一个父类型变量的行为取决于具体的子类型,而特设多态强调同一个操作(方法)根据其传入的类型不同有不同的行为。

基本写法是这样的:

switch x.(type) {
case nil:
case int, unit:
default:    
}

关于interface的建议

不要把在其他语言中,经常声明interface的习惯带到Go中。在Go中使用interface大致只有两种情况:

  1. 有两个及以上的类型需要以统一的方式来处理。
  2. 有必要时可以在多个包的交互中,使用interface来解耦。

Go中的interface一般都是非常短小的,只需要很少的方法,在go中设计接口的原则:ask only for what you need.

在Go中,如果要隐藏方法的具体实现,完全可以通过首字母的大小写,利用export机制来达到。不需要把任何东西都封装成对象,单独的函数和未封装的数据类型也有存在的必要。

8 Goroutines 和 Channels

从这章开始是Go中并发编程部分。Go中支持两种风格的并发编程。这一章关注通信顺序进程(communicating sequential processes, CSP),在这个并发模型中,可以在不同的活动之间传递,但是变量被限制在单个活动中,不会共享给其他活动使用。

goroutine

Go中,每一个可以并发执行的活动就称之为goroutine,类似操作系统中的线程。通过go fn()的形式发起一个goroutine。

channel

类似消息队列,起到goroutine之间传递值的作用。根据频道的容量,可以分为unbuffered channel和buffered channel:

  • unbuffered channel: 容量为0的channel。默认使用ch := make(chan int)ch := make(chan int, 0)都可以创建一个unbuffered channel. sender向unbuffered channel中发送消息时会阻塞,直到有其他goroutine来接收消息;反之,接收消息时如果没人发送也会阻塞,直到有人来发送消息。
  • buffered channel: 容量不为0的channel。可以用ch := make(chan int, 3)来创建一个buffered channel。在频道中消息未满时,sender发送消息可以不阻塞,满时则要阻塞等待之前的消息被接收;不空时,receiver接收消息可以不阻塞,空时也会阻塞直到有人放入新消息。

可以使用烘焙屋的例子来帮助理解buffered channel的意义。负责和面的厨师如果制作的速度比较快,他和负责烤制的厨师之间的频道就算容量再大,也最终会满,不过对于这种情况就可以考虑增加烤制厨师的数量。另外,对于处理时间不定的goroutine(例如网络请求有高峰和低谷),一个buffered channel可以起到削峰填谷的作用。

pipeline:如果一个channel用于将两个goroutine连接起来,一个的输出作为另一个的输入,也被称为一个pipeline,类似linux中的管道。

有向channel:有时希望一个goroutine只能向channel中发送数据,而另一个只能接收数据,可以将channel转换为一个有向的channel。in <-chan int就表示一个只能接收的名为in的channel,out chan<- int则表示一个只能发送的名为out的channel。

channel的零值是nil,试图向一个nil的channel发送和接收消息都会永远阻塞线程。

多路select

select语句可以让线程在多个channel中同时等待,并响应处理最先有消息到达的channel。语法和switch几乎一样。

一个空的select语句select{}会让线程永远等在这里。

select的case既可以接收chan的消息,也可以像chan中发送消息。

实用函数:time.Tick(1 * time.Second)会得到一个chan,每1s发送一个信号过来。time.After(10 * time.Second)得到一个10s后有信号发送过来的chan。time.newTicker(1 * time.Second)得到一个可以打断的chan,通过ticker.Stop()来打断信号的继续产生。

select中如果有default块,表示当前没有任何chan可用时会执行的动作。可以用来实现非阻塞的通信,在没有消息的时候也不会一直等待。

常见gorroutine使用模式

模式1 等待工作线程退出

goroutinue非常适合处理易并行(embarrassing parallel,能被分解为多个可以独立完成的任务且任务之间不需要通信也没有依赖的问题)问题。在处理这类问题时,可以在主线程中根据任务分解出的子任务,新建对应数量的工作线程。但是在go中,如果主线程退出,所有其他的工作线程会被强行打断,而不会自动等待所有工作线程都完成后才退出。等待所有工作线程结束后主线程再退出需要手动实现,实现方法是借助sync.WaitGroup(可以理解为Java中的CountdownLatch)。

var wg sync.WaitGroup
for f := range filenames {
    wg.Add(1) // 创建新线程时wg + 1
    go func(f string) {
        defer wg.Done() // <-- 每个工作线程结束时会将wg - 1
        processFile(f)
    }
}

wg.Wait() // <-- 主线程在这里等待wg清零

模式2 令牌桶

解决易并行问题的时候另一个麻烦就是,有时候任务的规模太大,程序会创建过多的goroutine,如果每一个goroutinue都需要网络或文件资源的访问,就会造成系统文件描述符(句柄)的耗尽问题。这个时候需要限制任务的并发度,不能无限制地增加。解决方案是使用buffered channel模拟一个令牌桶tokens(信号量),当goroutine需要访问文件资源时,需要先向tokens中发送一个消息,结束时取出一个消息。

var tokens = make(chan struct{}, 20) // 创建一个容量20的令牌桶,抓取网页时的并发度最高为20
func crawl(url string) []string {
    tokens <- struct{}{} // <-- 获取令牌
    list := doCrawl(url)
    <- tokens // 释放令牌
    return list
}

模式3 广度优先遍历

广度优先遍历问题的特点是,每个goroutine处理一个节点之后,还需要将其相邻的节点发送到channel中,主线程不断从channel去取出新的节点并创建goroutine进行处理。这个问题的难点是,如何在所有节点处理完后主线程退出,而不是死等在channel上。

分析退出的条件如下:当没有等待和正在处理的节点时退出。最简单的办法是使用一个计数器,在初始化和增加节点到通道中时+1,在处理完成一个节点时-1。当计数器归零时,将channel关闭,使主线程退出。

worklist := make(chan string)
// wg记录了目前有多少个待处理的url。如果这个数字归零,说明所有url处理完成,可以close channel了
wg := new(sync.WaitGroup)
seen := make(map[string]bool)

wg.Add(1) // 初始url数
go func() {
    worklist <- "http://claws.top" // 必须用一个goroutine发送所有初始urls
}()

go func() { // 用一个goroutine关闭channel
    wg.Wait()
    close(worklist)
}()

for url := range worklist {
    if !seen[url] {
        go func(url string) {
            defer wg.Done()
            for _, newUrl := range crawl(url) {
                wg.Add(1)
                worklist <- newUrl
            }
        }(url)
        seen[url] = true
    } else {
        wg.Done() // 被跳过也是一种处理完的形式
    }
}

另外一种比较巧妙的方式是channel中是节点数组,而不是一个个散落的节点。可以维护一个计数器,代表剩余的批次数,当批次计数器归零的时候就全部处理完了。在开始处理一个节点之前,将计数器+1,而每结束遍历一批就将计数器-1。因为计数器的访问仅限于主线程,可以简单地用一个变量代表。

worklist := make(chan []string)
var n int

n ++
go func() {worklist <- "https://claws.top"}

seen := make(map[string]bool)
for ; n > 0; n -- {
    list := <-worklist
    for _, url := range list {
        if !seen[url] {
            seen[url] = true
            n ++
            go func(url string) {
                worklist <- crawl(url)
            }(url)
        }
    }
}

模式4 简易线程池

要限制正在运行的goroutine数量,除了上面的令牌桶模式,还可以通过自己实现一个简单的线程池效果来实现。

for i := 0; i < 20; i ++ {
    go func() {
        for url := range urlChannel {
            for newUrl := range crawl(url) {
                urlChannel <- newUrl
            }
        }
    }
}

模式5 定时汇报

在下载文件、统计目录大小等耗时任务中,希望每秒输出一下当前的进度,可以采用这种模式。

tick := time.Tick(1 * time.Second)
var nfiles, nbytes int64
loop:
for {
    select {
        case size, ok := <- fileSizes:
            if !ok {
                break loop
            }
            nfiles ++
            nbytes += size
        case <-tick:
            printReport(nfiles, nbytes)
    }
}
printReport(nfiles, nbytes)

模式6 取消操作

有时希望下载等耗时任务可以中途取消。可以理解channel的close机制,实现广播效果。

done := make(chan, struct{})

// used to get cancelled state
func cancelled() bool {
    select {
    case <-done:
        return true
    default:
        return false
    }
}

// read from stdin, if any key is pressed, set the cancell state
go func(){
    os.Stdin.Read(make([]byte, 1))
    close(done)
}

共享内存的并发

并发基础概念

并发(concurrency)的定义:如果不能确定一个事件x和另一个事件y发生的确切先后关系,称x和y是并发的。

并发安全的函数:如果一个函数在多个goroutinue中并发调用时,仍然能正常工作,成为并发安全的函数。默认情况下,exported的函数应该时并发安全的,不安全的函数应该在文档中给予说明。

并发安全的类型:如果访问一个类型的所有方法和操作都是并发安全的,则称为一个并发安全的类型。默认类型不是并发安全的,除非有文档明确说明,这和函数的并发安全相反。

竞态条件:程序在多个goroutine中交错执行一些操作时不能给出正确结果的状况,称为竞态条件。其中最常见的一类时data race,当两个goroutine并发访问同一个变量,且至少有一个进行写入操作时,就会出现data race。我们应该在程序中避免所有data race。

避免data race的方法有三种:

  1. 避免写操作。
  2. 将变量限制在一个goroutine内访问。通过channel和处理其他goroutine对该变量的读写请求。
  3. 互斥(mutual exclusion)。允许多个goroutine访问,但是同时只有一个能读写。

互斥

可以新建sync.Mutex类型的锁,用于消除data race。访问共享变量时,需要先通过mu.Lock()获得锁,访问完成后,通过mu.Unlock()释放锁。一把锁同时只能由一个goroutine获得,且不允许重入,可以避免多个goroutine同时读写变量。

go中,不允许锁重入,这一点符合Go极度简洁的特点。在程序设计中有许多不变量(始终成立的条件),其中的一个是:在获得锁之外的地方,没有线程在访问共享变量。锁的重入导致即使Unlock,也不能保证该变量就不能被访问,破坏了这条不变量,使程序的设计变得不可信。

读写锁:sync.RWMutex。通过mu.RLock()获得读锁(共享锁),通过mu.Lock()获得写锁(排他锁)。对应的释放锁的操作是mu.RUnlock()mu.Unlock()。在实际使用中,除非确定大部分goroutine都是读取操作,且争用锁的情况时常出现,否则还是使用普通的锁就好,因为读写锁的操作比普通的锁更慢。

内存的同步问题:由于存在CPU指令重排、CPU缓存的问题,当程序运行在多个CPU的机器上时,可能出现即使一个goroutine更新了共享变量,但另一个goroutine仍然只能读取到旧值的问题。这启发我们,只要出现竞态条件,就需要尽可能消除,并在一切可能访问(无论是读还是写)的地方都进行预防。优先将变量限制在一个goroutine中访问,其次再考虑使用互斥的技术。

常见模式

sync.Once用于确保操作只会执行一次。在数据懒加载中,第一个访问数据的goroutine需要负责加载数据,而其他goroutine则不能重复加载。我们可以使用sync.Once来完成一次性操作,例如:

var loadIconsOnce sync.Once
var icons map[string]image.Image

func Icon(name string) image.Image {
    loadIconOnce.Do(loadIcons)
    return icons[name]
}

他等价于:

func Icon(name string) image.Image {
    mu.RLock()
    if icons != nil {
        icon := icons[name]
        mu.RUnlock()
        return icon
    }
    mu.RUnlock
    
    mu.Lock()
    if icons == nil {
        loadIcons()
    }
    icon = icons[name]
    mu.UnLock()
    return icon
}

使用标准库中封装好的工具可以极大简化模板代码。

非阻塞并发缓存:可以记忆化一个函数的执行结果,避免多次调用时反复执行。以并发安全的形式实现这种缓存的访问,又要尽可能避免锁的阻塞,可以配合Mutex与一个ready channel实现。Mutex用于保护结果map,确保同时只能有一个变量在访问map;ready channel用于第一次执行函数后,广播执行完成这一事件给所有等待中的goroutine,并获得执行结果。操作时,第一次执行函数调用的goroutine发现结果为nil,会先创建一个占位的结果并释放锁,并开始执行调用。调用结果得到后,该goroutine负责close ready channel来通知所有其他goroutine结果已经完成并保存,可以直接获取。另外,也可以通过将map限制在一个server goroutine中访问的方式实现。解决并发问题具体采用二者中的哪种方式,需要看具体的问题,有时候可以多做尝试,也许其中一种可以使得代码更加简化。

竞态探测工具

go tool提供了探测竞态条件的工具,通过在go build或go run或go test后面加上-race来使用,程序执行时会报告多个goroutine同时访问共享变量的情况。需要注意的是,工具只能发现实际发生的data race,如果理论上可能发生,但是在程序执行中并没有实际出现,则不会被race detector发现。

goroutine和线程

相比操作系统中的线程和其他语言中的线程,Goroutine具有以下特点:

  1. 动态成长的栈。相比于操作系统线程通常分配的固定大小的栈,goroutine的栈是动态的。一开始分配的只有最小的2KB大小,随着goroutine执行需要更多的空间,栈会被扩容,可以达到1GB大小。
  2. 调度采用m:n调度技术,意为将m个goroutine调度在n个操作系统线程中。Goroutine的切换通常不是定时触发的,而是由一些go语言结构(例如time.Sleep, 等待channel和mutex)触发,不需要切换内核上下文,更加轻量化。
  3. 没有识别符。在Java中,ThreadId用来识别一个线程,通过threadId可以实现线程本地存储等。但是在Go中,goroutine没有类似的识别符,也不支持线程本地存储。这时因为线程本地存储的滥用会导致action at a distance现象的出现,意为函数的结果不再由输入的参数决定,而是由运行的线程决定,带来需要隐蔽的bug。
  4. 通过GOMAXPROCS环境变量,决定实际用几个操作系统线程来执行所有Goroutine。默认保持和当前机器的CPU数一致,这样可以提高并发度的同时避免过多的上下文切换带来的性能损耗。

Go package

package 的作用是将散落的.go文件组织在一起,方便将相似的功能和特性组织成单元,便于大型项目的维护和修改。按照惯例,每个目录下只能有一个包,不能有属于多个包的.go文件。

Import path: 每个包都有一个全球唯一的id,通常分为多个部分,第一部分是域名,后面若干部分组成的层次结构和package所在目录的层次结构对应。

package declaration:每个.go文件的头部都有一个包声明,它的作用仅仅是决定了这个包在被其他外部包引入时默认的名字是什么。通常情况下,这个package声明中的名字取包的import path的最后一部分,除了下面三个特殊情况:

  1. 该包中编写了一个可执行的command(go可执行程序),其中的所有.go文件的package declaration统一使用package main
  2. 文件名如果以_test.go结尾,这个文件中的包名可以以_test结尾。这种情况实际上是同一个目录容纳了两个包,第一个是常规的包,而第二个是external test package。通过包名的区分,go test时才会把该目录中的两个包都进行构建。
  3. 一些依赖管理工具会在import path最后添加版本号,比如gopkg.in/yaml.v2,这样的包中.go文件的包名还是yaml,不包含最后的版本号。

引入声明:在一个文件中引入其他package时,需要用import引入声明来实现。引入声明必须放在包声明的下面,和文件中其他语句的前面。可以在引入时为包起一个别名,形式是import mrand "math/rand"。如果引入的多个包具有相同的包名时,就必须通过这种形式避免冲突。值得注意的是,为包起的别名只在当前文件中是生效的。

Blank import: 一般情况下,引入的包在该文件中必须用到,不然就会报错。但也有一种特殊情况,引入一个包的时候只是为了使用引入时初始化的效果,不需要在文件中进行使用,这时可以为这个包起一个别名_。这种引入方式称为blank import,例如import _ "image/png"

在给包和包中的函数命名时,追求简洁但没有歧义。始终记住:client在使用包中的函数时,需要同时指定包名和函数名,因此不需要将包名中已有的含义再加入到函数名中,造成冗余。例如http.Get而不是http.HttpGet

Go tool

Go tool是一个集合了go包管理系统、构建系统、文档等众多功能为一体的瑞士军刀式的开发工具。

GOPATH

在Go 1.11版本之前,管理go项目主要使用的就是GOPATH的方式。用户通过指定GOPATH,可以告诉go当前的工作空间(workspace)在哪一个目录,用户的包和下载的所有依赖包都会放在这个工作空间中。切换GOPATH就仿佛切换了一台新的电脑,除了go的标准库和go tool,一切其他的项目、依赖都是新的。

GOPATH下面按照src、bin和pkg三个目录进行组织:

  • src:存放所有包的源代码之处。下面按照import path作为相对路径来组织。例如gopl.io.ch1.helloworld包就放在$GOPATH/src/gopl.io.ch1.helloworld目录下。
  • bin:存放编译成的可执行文件。
  • pkg:存放编译后的包目标文件。go get获取网络上的包后,会自动install,install的过程就是build形成目标文件并放在pkg下。通常pkg下的包先按照cpu架构分目录.

可以看出,以传统的GOPATH方式管理项目时,并不会考虑哪一个package是依赖而哪一个项目是我自己的项目,所有的package都被放在一起,这和nodejs、maven等工具的管理方式非常不同。这种风格的优点是简单直接,所有包都按照import path放在GOPATH的src目录下,一目了然;切换工作空间就完全切换了不同的环境,不拖泥带水。但是也有一些问题:

  1. 缺少包的版本管理。多人合作时,难以确定所有人都使用同一套包的版本。
  2. 如果需要切换设备,所有依赖都需要手动下载,非常麻烦。
  3. 同时开发多个项目比较困难。多个项目如果在不同的GOPATH需要手动切换,而都在同一个GOPATH下造成目录内容庞杂,带来管理的不便。

针对上面的问题,go tool1.11版本之后,就给出了go mod模块式的版本管理方案。

go mod

go mod的管理方式抛弃了GOPATH指定工作空间的方式,而是任何一个包含go.mod文件的目录都可以作为一个模块的工作空间。go.mod中指定了当前模块的名字和版本,以及所有依赖的其他模块及版本。如果对java熟悉,可以理解为go版的maven。

go mod子命令可以用来以mod模块的方式管理go项目。常用的命令有:

  1. go mod init可以在当前目录下初始化一个go模块,会在当前目录生成一个go.mod文件。
  2. go mod download,会根据go.mod文件中定义的模块和版本,下载对应的模块版本到本地缓存(一般会先下载到GOPATH/pkg/mod/cache,然后解压源代码到GOPATH/pkg/mod的对应import path目录下。mod的依赖具有传递的性质,意味着下载时不仅会下载直接依赖的模块,还会下载所有间接依赖的模块。
  3. go mod graph,可以打印出当前模块的依赖图。
  4. go mod tidy,不仅可以添加漏掉的mod,也可以删除没用到的多余mod
  5. go mod verify,验证依赖的版本和内容。

在使用go mod时,download、tidy和verify都会使用或更新当前目录下的go.sum文件。这里存储了依赖的其他mod的hash,确保各个依赖模块版本一致。

实用go命令

go get 可以用于从网上下载go package。获取的package不仅包含所有其中的所有源码文件,也复原了版本管理系统,可以理解为clone了所有package的仓库。go get同步仓库后还会build和install这个package,意味着源码会被编译形成.a目标文件,放在GOPATH/pkg的对应import path下面。

go build 可以用于构建当前的go package。如果当前是一个可执行的go文件(main package,并且有main函数),则会编译、链接形成一个可执行的go文件,放在GOPATH/bin下。如果只是一个普通的library go package,则编译的结果会直接丢弃,此时go build的作用仅限于验证代码可以通过编译。go build -i命令可以将所有依赖的包编译形成的目标文件安装的pkg目录下面,避免下次build时需要重复编译这些依赖的包。

go doc 可以用于查看一个包或函数的文档。一个go包中,只允许一个.go文件中在package声明前面加文档注释。如果文档太长,甚至可以单独写一个doc.go文件,专门用来放置package级别的文档。

go list 可以查看package的信息。默认只会展示import path,使用go list -json 可以获得json形式的完整信息,包括引用的包、依赖的包等等。

internal包

如果一个包的import path中包含internal部分,则这个包只能被internal的上一级目录中的其他go package和文件引入,而不能被外部的包使用。对于一些既需要在项目内部共用,但又不希望被其他项目使用的包,把它放在项目根目录下的internal目录是常见的实践。

举例:对于github.com/jingjiecb/myproject/internal/util包,可以被github.com/jingjiecb/myproject/web引用,但是不可以被github.com/jingjiecb/yourproject/web引用。

测试

本书作者认为, 将软件复杂度保持可控的技术中,最为有效的两个是同行评审与测试。Go程序的测试都可以通过go test命令完成,相比其他语言的各种测试框架,Go官方提供了轻量的工具。在一个package中,如果文件名以_test.go结尾,则是一个测试文件,只会在go test的时候被构建和执行,go build时不会构建。

测试的文件与普通的go文件不同在于,其中有三类函数会被用于特殊的用途,分别是tests, benchmarks和examples,通过命名和其他函数进行区分。名字以Test开头为tests函数,被go test调用执行并报告结果;名字以Benchmark开头为benchmark函数,用于衡量一个操作的性能,被go test调用被记录平均的执行时间;名字以Example开头为example函数,提供了一些有机器检查的文档。go test在执行时,会扫描包下的_test.go文件,生成一个临时的main package并在里面调用这些特殊的函数,报告结果后将环境清理干净。

Test Functions

测试方法用于判断被测试函数的逻辑和行为是否正确。他们的形式:func TestName(t *testing.T) {xxx}。其中Name的部分首字母大写,表示这个测试的名字。测试方法中的包声明和当钱包保持一致即可。

t.Error和t.Errorf可以用于向go test报告错误,如果一个测试函数没有错误报告则视为通过。t.Fatal用于报告一个致命错误,通常用于测试初始化时,如果go test检查到致命错误,不会再继续执行剩余的测试用例。

表驱动的测试

table-driven表驱动的测试:go中对一个函数进行测试,通常不需要每一种情况写一个单独的测试函数进行测试,而是将输入和预期输出多组写在一个数组中,然后遍历这个数组执行测试和判断输出。形式模板如下:

func TestIsPalindrome(t *testing.T) {
    var tests = []struct {
        input string
        want bool
    }{
        {"", true},
        {"ab", false}
        ...
    }
    for _, test := range tests {
        if got := IsPalindrome(test.input); got != test.want {
            t.Errorf("IsPalindrome(%q) = %v", test.input, got)
        }
    }
}

错误报告需要遵循f(x) = y, want z的格式。对于上面的例子,由于z一目了然,并不需要重复给出。

随机测试

对于随机测试(生成随机的输入,对输出进行测试),获得预测的方式有两种:1

  1. 通过写一个更为简单的实现函数,并用这个函数生成预测的结果,最终比较被测函数的输出是否一致。
  2. 可以根据测试的规律,直接生成结果确定为某预测输出的输入。例如IsPalindrome函数用于判断一个字符串是否是回文数,在随机测试中,可以直接生成若干回文数字符串并用来测试,预测结果应该为true。

为了保障结果可以复现,通常需要在随机测试开始时将这次测试使用的随机数种子也记录下来。

对命令进行测试

如果希望对一个命令进行测试,通常需要把命令的主要逻辑抽离放在main之外的独立函数中(因为main函数在测试时会被忽略)。如果需要对标准输出的内容进行判断,可以将out定义为一个全局变量var out io.Writer = os.Stdout,在测试的文件中,通过赋值,将输出重定向到一个新的buffer中,实现对输出内容的获取:out = new(bytes.Buffer)

new v.s. make: 在go中,make用来初始化map slice和channel,而new可以任何类型,分配空间并返回指针,但对应的值只能是该类型的零值。通常new用来新建一个struct。

测试时,应该确保被测函数不能调用类似log.Fatal和os.Exit这类会导致程序退出的操作,否则将打断测试,调用这类操作应该被视为main函数的特权。其他错误,比如panic,会被视为测试fail,并不会导致测试的终止。任何可预期的错误,比如bad input,missing files和配置错误都需要通过函数的err返回值进行体现,并在测试中判断确实返回了对应的err。

黑盒测试和白盒测试

黑盒测试只知道被测的API和相应的文档,内部实现不可见,它只验证能否通过调用API,从给定的输入获得预期的输出。白盒测试中,测试用例知道被测代码的内部实现,会对一个操作后包内维护的数据或系统的状态进行检查。之前通过覆盖out测试标准输出的内容就属于白盒测试。

在测试时,有时不希望生产代码被完整执行,例如不希望真正给用户发送邮件、更新生产数据库等,可以使用fake implementation。通过重构生产代码,可以将一部分操作抽象为单独的函数,作为全局变量。在测试中,通过重新给这些全局变量的函数复制,实现对部分行为的替换,更方便对行为进行验证。例如,可以将发送邮件的操作声明为一个全局的函数:var notifyUser = func(username, msg string) {xxx},在测试中重新对nodifyUser进行赋值。有一个问题是,在测试函数中,如果对全局变量进行了修改而不恢复,则可能影响后续测试的行为。为了避免,通常可以将原来的值记录下载,并通过defer在函数运行结束后恢复到原来的值。例如:

saved := notifyUser
defer func() { notifyUser = saved }()

外部测试包

在之前的例子中,_test.go文件和生产代码放在同一个目录中,包声明也一样,称为in_package test。in_package test会出现包循环依赖的情况:A包依赖于B包,但是B包的测试依赖于A包。解决方法是将B包的测试放在外部测试包中。外部测试包是原包名加_test后缀的包,其中的测试文件还是以_test.go作为文件名结尾,也和生产代码放在同一目录下,但文件中的包声明语句不同。

使用go list工具可以查看生产代码、内部测试和外部测试包的测试都有哪些:

  • go list -f={{.GoFiles}} fmt查看一个包的生产代码go文件
  • go list -f={{.TestGoFiles}} fmt 查看一个包的内部测试
  • go list -f={{.XTestGoFiles}} fmt查看外部测试包的测试

外部测试包对原来包的可见性等同于任何一个外部的普通包。因此在外部测试包中编写白盒测试可能遇到不能访问的问题。解决方案是通过一个内部测试的go文件,专门将一些白盒测试需要的变量、函数导出。这个文件不做别的任何事情,通常命名为export_test.go

在编写go的测试时,作者提醒我们,最重要的是在用例失败时提供足够好的test user interface(测试用户界面),展示简洁、必要的背景信息即可。盲目使用库、通用的测试函数会导致出现测试不通过时难以定位问题(Java中的测试就是一个反面,每次通过assert得到的输出很少,需要在很长的调用栈中寻找问题)。

不要写brittle tests:brittle test指的是随着程序修改,非常容易失败的测试。往往这些测试对字符串进行了精确的匹配,或者对一个复杂的数据结构无论巨细都进行了判断,导致即使不影响程序正确性的修改也会导致测试fail。

避免brittle test的最好方法就是只检查真正关心的属性。对于一个很长的字符串,如果要判断正确性,最好截取其中关键的子串进行匹配。而对于复杂的struct,只去检查那些关键的属性就足够了。

覆盖率

通过go test -run=Coverage -coverprofile=c.out <package>的形式运行测试,可以在运行测试的同时获得覆盖率相关的日志,记录在c.out文件中。再通过go tool cover -html=c.out命令,可以调用覆盖率工具,生成html版的覆盖率测试报告,并在浏览器中展示。

不要拘泥于达成100%的测试覆盖率。一些语句,比如panic语句,本就不应该能达到。被覆盖的语句也并不一定就是bug-free的。

Benchmark Functions基准测试

基准测试的目的是测量一个函数操作的运行时间。它的函数名以Benchmark开头,形式为

func BenchmarkIsPalindrome(b *testing.B) {
    for i:= 0; i < b.N; i++ {
        ...
    }
}

默认情况下go test不运行任何基准测试函数,需要使用go test -bench=.来运行,这里的.表示运行所有该包下面的基准测试,也可以替换为测试的name(函数名中Benchmark后面的部分)。这里的N表示运行的次数,测试时,go test会根据运行时常动态选择最合适的运行次数。对于运行比较久的函数,运行次数会比较小。

为什么基准测试的N会放在测试函数中,对程序员可见?

go test将决定哪一部分是需要重复运行来评估性能,而哪一部分是测试中单次运行的权力交给程序员。对于我们来说,初始化运行环境相关的代码不需要每次运行,因此这部分可以放在循环外面,使得时间的评估更加准确可靠。

一个基准测试函数只用于评估一个具体操作的平均运行时间,比如输入为”aa”的时候回文判断的时间。但通常这个绝对时间意义不大,需要和其他操作进行比较才有意义(和其他规模的输入,其他算法都可以比较)。比如,我们将长度为10的字符串运行时间和长度为100的字符串运行时间进行比较,判断出运行的时间随着数据规模增长而增长的速度,评估算法的时间复杂度。

profiling

中文可以翻译为性能分析。go test可以在运行基准测试时,记录cpu、heap、blocking的相关事件,生成日志,之后可以通过go tool pprof工具对日志进行处理,展示cpu、内存使用和goroutine阻塞上的一些关键操作在哪里,帮助针对关键代码进行优化,而不是浪费时间盲目对性能进行优化。

Example函数

Example函数也是一种测试,给出了一个生产代码的使用例子。

目的主要有三:

  1. 作为文档,揭示与API有互动的类型和函数之间的关系,是真实的go代码,接受编译器的检查,不会像comment一样可能出现错误和不符。Example函数在go doc进行文档展示时,会根据Example的名字,自动找到对应的例子,作为文档的一部分。
  2. 可以作为测试运行。对于测试中// Output:以下的注释部分,go test可以实现输出内容的检查。
  3. 方便进行即时的实验,快速运行代码。