go语言入门学习
更多:go官网
历史
Go语言构想与2007年9月,于2009年11月发布。主要思想来自3种语言:
- C,基础语法和编译
- Pascal,包概念
- CSP(Communication Sequential Process),并发思想
Go项目诞生是为了解决Google中系统复杂性太高的问题。因此,简单性是Go思想的重要部分。设计上,Go
- 没有隐式类型转换
- 没有构造和析构函数
- 没有运算符重载
- 没有形参默认值
- 没有继承
- 没有泛型
- 没有异常
- 没有宏(macro)
- 没有函数注记
- 没有线程局部存储
快速开始
范例1:Hello world
1 | package main |
在诸多语言中,C对Go的影响是最深的。.go
文件需要经过编译成二进制文件才可以运行。
go run
可以直接运行.go
文件go build
可以编译生成二进制文件,并在之后直接执行
在代码结构上,
- 先声明当前包名,其中命名为
main
的包名代表代码是可执行程序,而非一个库文件 - 再
import
依赖包,go自带100+内置包。在编译时,编译器会抛弃未被使用的包,减少体积 - 接下来是程序代码,命名为
main
的函数是执行的入口
Go代码有着标准的代码格式,并可以通过gofmt
格式化代码。代码中不需要在行尾写分号,后面紧跟特定token的换行符会自动转成分号。因此,Go代码中换行会影响代码编译。
范例2:命令行参数
1 | // version 1 |
- 切片(slice)是序列数组元素的表示方式,可以用
s[i]
或s[m:n]
(m或n缺失是表示头和尾元素位置)获取1或n-m个元素。使用len(s)
获取长度。 - 注释以
//
开头 - import多个库时,可以用
()
包裹列表的形式声明,这种写法更为常见 - 使用
var
开头表示变量声明,未指定初始值的变量会隐式初始化为当前类型的“零值”(0或’’等) :=
式的声明可以省去var
更快地为一组变量初始化- go中的for循环是唯一的循环语句,分为以下三部分。缺失initialization和condition时可以表示while循环
1 | for initialization; condition; post { |
1 | // version 2 |
- 在循环中,
range
可以生产一对值,index和value _
专门用来替代不需要使用的变量名,否则go会报错- 另外也可以直接用
strings.Join
方法实现效果
范例3:寻找重复行
1 | package main |
- 同
for
循环一样,if
语句也不需要()
包裹 - 内置的
make
函数可以创建一个新的map。map也可以被for
循环遍历,每次循环的pair分别是key和value counts[input.Text()]
中当key不存在时,会返回零值0- bufio库可以更方便地帮忙处理程序的输入(input)和输出(output)
input.Scan()
获取下一行,并自动去掉末尾换行符,在没有内容时返回false
input.Text()
获取当前位置的文本
Printf
和C语言风格类似,里面行如%s
,%v
的特殊符号称为verbs
从文件中寻找代码如下:
1 | func main() { |
除了上面的流模式读取文件外,还可以直接把整个文件直接读进内存,再将二进制数据string化并处理。此处可以使用io/ioutil
中的ReadFile
方法。转换过程用string(data)
完成。
日常使用时,通常借助bufio,ioutil等高层级API就可以完成任务,而不需要深入实现内部。
范例4:Gif
1 | package main |
- 使用
const
声明常量,常量的值只能是number,string或boolean gif.GIF{...}
是合成字面量的写法,其类型是struct,可以字面量声明其field,未声明fields均为零值(zero value)- image库API可以操作图像
范例5:fetch
1 | package main |
- 和网络相关的API都位于net库中,如
http.Get(url)
os.Exit(1)
代表异常退出
范例6:并行fetch
1 | package main |
- goroutine是go中并行执行函数的表示,channel是goroutine间相互沟通的方式,传递特定类型数据。goroutine相互沟通时,沟通的两者会对其他goroutine block,保证没有冲突
- goroutine使用
go
创建,channel使用chan
创建,ch <-
表示向channel发送,<- ch
表示从channel接收
- goroutine使用
ioutil.Discard
输出流会直接丢弃流内容
范例7:web server
1 | package main |
- 使用
http
库的HandleFunc
和ListenAndServer
可以便捷地启动一个服务器
1 | package main |
- server会为每个请求创建一个新的goroutine处理,为了避免并发读写count时的bug,使用了mutex锁保证读写是都是串行的
os.Stdout
,ioutil.Discard
,http.ResponseWriter
都实现了io.Writer
接口,因此可以用在任何需要输出流的地方- if语句前可以增加前置语句,如
if err:=xxx; err != nil
杂项
- go中的
switch
没有fall through机制,若需要,需要显示声明fallthrough
。case
支持表达式,switch
后支持没有操作数,此时的switch称为tagless switch。break
,continue
,goto
命令如常 - go中有命名类型,类似ts中的
interface
,行如type Point struct { X, Y int }
- go中有指针,
*
表示去指针对应的值,&
表示取变量的指针,另外不支持指针上的算术运算 - go中的方法指命名类型上的函数,interface意义如常
- 可以去这里寻找标准库的包,或去这里寻找社区贡献的包
- 注释风格同其他语言,
//
表示单行注释,/* */
表示多行注释。不支持嵌套注释
程序结构
命名
和JS类似
- 以**Unicode字母或下划线
_
**开头 - 后跟Unicode字母或数字或下划线
- 大小写敏感
go目前(2020/01/01)有25个不允许用来命名的关键字,其中几个可能是对前端较难想到的是
select
defer
chan
range
fallthrough
另外,还有一些预定义常量、类型、函数可以用来命名,但很容易造成误解,下面举些例子:
- 常量:
true
iota
nil
- 类型:
int
complex128
uintptr
rune
error
- 函数:
make
len
imag
panic
close
包名始终小写,在函数域内命名的函数只在函数域内可见,否则在整个包域内可见。整个包内声明的变量用首字母区分可见性:
- 首字母大写的可以被其他包访问,如
fmt.FPrintf
- 反之则只在包内可见
命名长度没有限制,但建议scope越大的变量命名越长。Go使用驼峰风格的变量命名,首字母缩略词和首字母同大写或同小写。
声明
声明有4钟:
var
变量const
常量type
类型func
函数
声明在函数域内可见,或在整个包域内可见。函数返回可以是一组变量。
变量
1 | var name type = expression |
Go中的变量声明如上所示,其中的type
部分或expression
部分可以省略,但是不能同时省略。
- type缺失时,name的类型由expression字面量或返回值决定
- expression缺失时,name的值自动设置为type类型的“零值”(zero value)
- 数值零值为0,字符串零值为
""
,布尔类型零值为false
- 其余接口或引用类型零值为
nil
,如指针、map、切片、函数、channel - 聚合类型的零值即其所有组成元素的零值
- 数值零值为0,字符串零值为
所以,Go中不存在未初始化的变量。包级别变量在main
函数开始前初始化,局部变量在声明过程中初始化。一组变量可以同时被初始化。
1 | var b, f, s = true, 1.3, "string" |
简写式
在函数域内的局部变量声明可以使用简写式,即:=
。在已知变量初始值时可以省去写var
。在初始值并不重要或最好显式写明类型时,还是使用var foo type
的形式比较好。和var
声明一样,也可以同时用简写式声明多个局部变量。但要注意,不要把这种写法和元组赋值(tuple assignment)搞混了。
1 | // multiple initialzier expression |
另外,简写式声明里可以写部分已经声明的局部变量,在这里会当做赋值处理。但是简写式声明中要至少包含一个未声明变量
1 | in, err := os.OpenFile(infile) |
指针
Go中的指针和C中类似,用&
表示取一个变量的地址,用*
表示访问某个地址所在的位置。指针的零值为nil
,因此可以用p != nil
来判断指针是否指向变量。
new
函数
可以通过new
函数,声明类型T
创建新的匿名变量,函数返回变量的指针即*T
类型。这在不需要变量名时很好用。每次调用new
函数新建变量时,返回的地址不同,除非类型不附加任何信息,如struct {}
或[0]int
。
1 | p := new(int) |
另外,由于new
只是预定义函数,所以可以用来做变量名。
生命周期
生命周期即变量从创建到被回收的时间。包级别的变量会在整个程序执行过程中存在。局部变量则会在未被引用(unreachable)时释放内存。Go中的垃圾回收机制会自动帮你完成这件事。但是如果有下面这种情况出现,则会阻止垃圾回收释放内存。
1 | var global *int |
在上述情况下,x
局部变量从f
函数中逃逸,并不会在f
函数返回时被回收,持久存储在堆(heap)中。应尽量避免这种情况带来的额外内存损耗。
赋值
和其余语言赋值没什么太大区别。
不同的是,额外增加了元组赋值。=
右侧的一组变量会先求值,再赋给左侧变量。建议在不需要复杂运算时使用。同时,有些表达式和函数也会返回一组值,此时需要用元组赋值的方式接收。在不需要某个变量时,可以使用_
占位。
1 | x, y= y, x |
可赋值性
除了一些显式的赋值外,还有函数返回、字面量声明等。Go中的赋值当且仅当=
左右的值和变量类型相同才可进行(对于==
和!=
的判断也是这样)。nil
可以赋值给任何复杂类型或引用类型。
类型声明
Go中可以定义类型。Go中的类型定义储存值的符号、它们的大小、固有操作以及方法,使用type name underlying-name
声明。它通常出现在包级别,有些也会通过首字母大写的形式export出去。
1 | type Celsius float64 |
两个有着相同底层类型的命名类型并不是同一种类型,也不能直接相互赋值和比较。但是可以使用强制类型转换转换到想同类型来比较。所有的类型T
都有对应的强制类型转换操作T(x)
。两个有相同类层类型或指向相同底层类型的未命名指针可以相互强制转换。另外,Go中的强制类型转换从不会在运行时出错。
比较特别的是,类型上还可以声明方法。
1 | func (c Celsius) String() string { |
包和文件
Go中的包即其他语言中的库、模块。以实现模块化、封装、分发和重用。和Java类似,一个包的代码可以存放在多个文件内,通常位于同一个文件夹下。每个包都有相互隔离的命名空间,需要用·image.Decode
的形式使用。需要export
的变量、类型、函数使用首字母大写的形式。
建议在每个export出去的变量、类型、函数前使用注释说明。另外,建议在包开头留下doc comment,或将更多注释放在doc.go
中。
import
1 | package main |
每一个包都有自己的import路径,Go语言标准并不定义如何解释import路径,这一步交给解释工具完成。每个包的包名通常和路径的最后一段同名。为避免包名冲突,import可以指定包的别名。
在引用了未被使用的包时会报错。,借助goimports
等工具和正确的IDE配置,可以在保存代码时自动标准化代码格式。
包初始化
1 | var a = b + c //third |
初始化时,先按照依赖的顺序初始化包级别变量。而.go
文件的处理顺序则按照传给go编译器的顺序。初始化的过程是自底向上的,即当所有依赖包都初始化完成后,才会初始化main
包,再执行main
函数。对于初始化过程复杂的变量,可以在init
函数中声明,而init
是在程序启动时,按照声明的顺序一个一个执行的。
作用域
作用域是编译时的,和运行时的生命周期概念相对应。作用域描述一个声明的可见范围。和C系列语言类似,用大括号{}
包裹会形成词法块作用域。Go在全局作用域下预定义了一些常量、函数、类型等。在函数外声明的作用域是包级别的,import
进来的包作用域是文件级别的。局部声明只在块作用域内。内部作用域会覆盖外部作用域的同名声明。
另外,Go中还有一些隐式的作用域,比如for
,if
,switch
表达式中的作用域。
1 | func main() { |
上面的for
和if
内部的x是一个单独的作用域。另外注意,简写式中会声明局部变量,会覆盖外部的同名变量,可能会带来意料之外的结果。可以通过var xxx type
的形式声明变量。
1 | var cwd string |
基础数据结构
Go有4大类数据类型:
- 基础类型,即数字、字符串、布尔值
- 聚合类型,即数组、struct
- 引用类型,包括函数、指针、slice、map、channel
- 接口类型
这一部分先说基础类型
整型
Go的数字类型包含了不同size的整型、浮点数和复数,以及它们的有无符号性。
整型有8、16、32、64四种长度,以及对应的signed和unsigned。组合一下即下面8种:
int8
int16
int32
int64
uint8
uint16
uint32
uint64
另外,rune
是int32
的别称,通常用来表示1个Unicode字符,byte
是unit8
的别称。uintptr
用来表示一个可以承载任意指针值的无符号整型。
Go中对整型的处理和C风格很像。
- 用首位表示符号位(signed int中)
- 类型承载范围和C一样,如int8表示-128到127
- 和C一样的操作符以及优先级,唯一区别是
&^
表示位清除,x &^ y
表示根据y各位将x各位清0 <<
左移位,空位取0,>>
右移位,无符号数补零,有符号数补符号位- 整型间除法会清除小数部分
- 取余
%
运算结果符号和被除数有关 - 超过位数的会溢出
0
开头表示八进制(通常用来表示POSIX系统中文件权限设置),0x
表示十六进制
不一样的是:
- 相同类型才可比较大小,否则需要用
int()
强制转换为1种类型再比较。某些类型转换只改变值的使用方式,有些则会改变值,如int
和float
之间的转换 - 用
%d
,%o
,%x
分别表示整型、八进制和十六进制数 - 用
%c
表示显示对应的Unicode字符,%q
显示带引号版本
浮点数
浮点数有float32
和float64
两种类型,服从IEEE754标准。为保证精确性,通常使用flaot64
。另外,还有以下特点
.
前后的0可以省略%g
,%e
和%f
分别打印最合适展示精确度版本、指数版本和原始版本- 有
+Inf
,-Inf
和NaN
特殊值,表现类似JS
复数
Go有两种复数类型:complex64
和complex128
,它们其实是由float32
和float64
组成的。复数可以通过complex
内置函数初始化,或者直接使用字面量。
1 | var x complex128 = complex(1, 2) // 1 + 2i |
复数间可以判断相等性,无法判断大小。math/cmplx
包里包含一些复数的数学运算。
布尔类型
即type bool
,和其他语言类似,有短路行为,&&
比||
优先级更高。bool
类型和整型之间不能相互隐式转换。
字符串
字符串表示一组不可修改的比特位序列,但通常用来承载可读的Unicode编码点。len
返回字符串长度,[i]
返回第i
个字节处的值。越界读取会导致panic。
s[i:j]
表示自带的substring操作,其中i
和j
均可省略- 字符串间可以比较大小和相等性,其中大小通过逐字节子母序比较
+
可表示字符串拼接- 不可变性:不允许修改字符串的值(如
s[0] = 'L'
),这使得Go可以在底层复用字符串,节省内存
字面量字符串
用双引号"
包裹,UTF-8编码。双引号中的反斜线\
有特殊含义。如
\n
表示换行\t
表示制表符\x
表示后接十六进制的高低位\ooo
表示三个八进制位
由反引号`` `包裹的表示纯文本字面量,其中的换行和格式也会被跨平台保留。可以用来书写多行字符串。
Unicode表示
Go中使用UTF-8变长编码:
0xxxxxxx
表示ASCII码11xxxxxx 10xxxxxx
表示两字节长度110xxxxx 10xxxxxx 10xxxxxx
表示三字节长度1110xxxx 10xxxxxx 10xxxxxx 10xxxxxx
表示四字节长度
可以由\uhhhh
表示16比特或\U
表示32比特,如世界:\u4e16\u754c
。unicode
包和unicode/utf8
包提供了编解码工具。utf6.DecodeRuneInString
可以读取一个自然字符的数据,而非一个字节一个字节读取,utf8.RuneCountInString
返回字符串的自然字符长度。幸运的是,range
循环会自动调用utf8解码其中的自然字符。
1 | import "unicode/uft8" |
当Go Unicode解析失败时,会使用特殊的Unicode占位符\ufffd
,显示为带有问号的特殊字符。另外,rune[]
可以直接将字符串转成编码后的每个Unicode编码点。这个rune数组进行string()
强制类型转换后即原始字符串。当然你也可以直接string()
装换一个整型数,不合规的整形数会得到上面提到的特殊字符。
1 | s := "世界" |
字符串和Byte Slices
bytes
, strings
, strconv
, unicode
是和string相关的几个包。strings
提供基本的字符串搜索、比较、修改等操作,bytes
提供修改字节数组的一些操作。有时,使用byte.Buffer
类型,在操作字符串字节时会更有效率。strconv
提供了将其他类型转成字符串和修饰字符串的操作函数。unicode
提供了一些以rune为中心的函数,如IsDigit
, IsLetter
, isUpper
等。
1 | // basename removes directory and filename suffix |
path
和path/filepath
包提供了更多文件夹和目录的操作函数。
尽管字符串中的字节序列是不可更改的。其对应的字节序列数组则是可以自由修改的。[]byte(s)
会分配一个字符串s
的字节序列拷贝,也可以对应用string(b)
还原。bytes
包提供的Buffer
类型可以很方便地承载[]byte
类型。
1 | func intsToString(values []int) string { |
上述函数中,WriteString
和WriteByte
用于向Buffer中写入字节或字节序列,该类型还有许多其他应用场景。
字符串和整型间的转换
- 字符串 -> 整型,
fmt.Sprintf
或strconv.Itoa
- 整型 -> 字符串,
strconv.FormatInt
或strconv.FormatUint
或strconv.ParseInt
或Atoi
常量
常量有以下几个基本特点:
- 编译时即对编译器可知
- 必须是基础类型:boolean,string或number
常量使用const
声明,形式看起来和使用var
类似,不过值是常量。对常量进行的所有操作,如数学运算、逻辑运算、比较、内置函数求值,都是在编译期就确定了。
常量可以组声明,声明时可以不显式声明类型,此时将使用右侧操作数推断常量类型。
1 | const ( |
还有个不常用的点:组声明时,除了第一个常量,剩下的常量可以不写右侧操作数,此时会使用上一个常量来初始化。
1 | const ( |
常量生成器iota
iota
即常量生成器,它从0开始,每次常量声明后加一。利用这个规律可以方便地生成一组常量枚举。
1 | const ( |
无类型常量
常量和变量不一样的点在,常量是可以不声明类型的,根据常量限定的类型,有下面一些类型:
- 无类型整型
- 无类型Boolean
- 无类型rune
- 无类型浮点数
- 无类型复数
- 无类型字符串
常量在使用时,会隐式转换成需要的类型,并在无法转换时抛出错误。
1 | var f float64 = 3 + i // complex -> float64 |
实际上,这些无类型常量有一个隐含类型,如:
- 无类型整型 ->
int
- 无类型浮点数 ->
float64
- 无类型复数 ->
complex128
- 无类型rune ->
int32
(rune
)
聚合类型
基本类型是数据结构的组成“原子”。原子的组合就构成了“分子”——聚合类型:
- array
- slices
- maps
- structs
其中array和structs是聚合类型的基础,它们都有着固定大小。而slice和map则是动态大小。
array
1 | var a [3]int |
类似C风格,array表示由0或多个同一类型元素组成的定长序列。声明数组时,需要使用常量表达式作为数组长度。当数组元素全部列出时,可以用...
代替长度。元素未声明初始值时,按零值(zero value)处理。
1 | var q [3]int = [3]int{1, 2} |
另外,当元素较多时,还可以用index到value的键值对形式声明,未声明的值为零值。下面的例子中,r
长度100,除了最后一个元素为-1之外,其余都为0.
1 | r := [...]int{99: -1} |
若数组数组具有可比性,则数组也具有可比性。另外,不同长度的数组是不同类型。[4]int
和[3]int
不是同一类型。
1 | import "crypto/sha256" |
Go中将数组作为参数传递时,传递的是复制的新数组,而不是传入数组的引用,这是Go和其他语言不大一样的地方。当然可以通过传入数组指针的方式,实现在函数内修改数组内容。由于数组是定长的,在更多时候,函数参数使用slice类型传入。
slice
slice和array类型紧密相关,使用[]T
声明。每个slice的底层都基于一个array。slice只是一个指针指向array中的某一个元素作为开始,除此之外,它还有len
和cap
函数分别用来表示切片长度,和切片最大容量(从切片开始到底层array结尾)。
因此不同slice可以共享同一个array,它们之间可以相互重叠。s[i:j]
是从创建slice的方式,遵从左闭右开原则,i
和j
均可省略,省略时分别表示0和数组最末尾元素。创建可以基于一个array变量或一个array指针或其他slice。创建超过array范围会引起panic,只超过len(s)
则会拓展这个slice。因为string实际上是[]byte
切片,所以s[i:j]
和substring
是一个意思。
从上面可以看到,slice即一个指向数组元素的指针,所以传递一个slice时,可以修改底层array的值。下面这个反转数组的函数不限数组长度:
1 | func reverse(s []int) { |
上面的s
是一个切片字面量,和array的区别在于没有声明长度。这种写法实际上会生成以后面值为全部元素的数组,并把切片指向这个数组。类似地,还可以使用make
创建一个切片。
1 | make([]T, len) |
由于切片只是引用,从效率和可理解性上考虑,切片间不具有可比性。不过切片可以和nil
比较,nil
表示空切片,而非“没有元素”的切片。不过Go中slice相关的函数对待这两种切片行为一样。
1 | var s []int // s == nil |
append
和copy
append
函数可以操作slice。如果append之后,slice长度超过了底层array的长度,append
会自动拓展底层array长度。另外,append不仅可以追加单个元素,还可以追加任意个元素,或解构后的slice。
1 | var runes []rune |
在不借助append
实现类似append
功能时,就需要自己借助cap(x)
和make
完成底层array的长度扩充。如同下面的一段代码。
1 | func appendInt(x []int, y ...int) { |
上面的...
表示剩余参数,
借助copy
还能实现一些slice的原址操作。
1 | func remove(slice []int, i int) []int { |
map
map即键值对,其中key要求具有可比较性。map有两种构造方式:make
或字面量:
1 | ages := make(map[string]int) |
map使用下标访问,使用delete
删除键。另外,访问不存在的key时,值是value
类型的零值。因此可以免去一些多余的初始化步骤。由于map的值并不是变量,所以不能用&
获取地址。
1 | ages["Cindy"] = 23 |
map在遍历时,顺序是随机的。因此如果需要确定顺序,需要事先手动排序。
1 | import "sort" |
map的delete
,len
,range
和取值操作都可以对零值nil
进行,但是存储到nil
map时会报错。由于访问map不存在的key会返回默认的零值,所以下标操作用第二个参数返回是否对应的key,*且参数通常命名ok
*。
1 | if age, ok := ages["Ed"]; !ok { |
Go中没有set
类型,可以用map[string]bool
等价。当key可能不可比较时(如用slice做key),可以用额外的序列化使用。
struct
struct类似ts中的interface
。由零或多个fields组成,每个field使用点来访问。struct和field都是变量,所以可以用&
获取地址。对地址也可以使用点来访问field。
1 | type Employee struct { |
相同类型的两个key可以在一起声明。在Go的struct中,field的组合和排序都意味着不同的type。和包一样,大写的field被导出可被访问,这也是Go的一种通用的设计。
1 | type Employee2 struct { |
struct类型的field不能自指,但是允许包含自己类型的指针,比如最经典的二叉树场景。
1 | type tree struct { |
struct的零值由各field零值组成,不是nil,没有field的空struct写作struct{}
。不携带信息,但可能在有些地方会有用。
字面量struct
两种声明方式:
1 | type Point struct{ X, Y int } |
- 将所有fields按顺序声明,struct的fields有任何改动都需要修改,所以通常只在小规模struct以及包内部使用
- 使用键值对方式声明,可以省略field,且对顺序不敏感
另外,在Go中,所有的函数参数传递都是传值。因此,如果函数内部需要修改struct时,不能传递struct类型,而需要传递指针。由于struct传递指针的场景比较多,所以提供了类似p := &Point{1, 1}
的简写语法糖。
1 | func AwardAnnualPrize(e *Employee) { |
如果struct的所有field都具有可比性,则struct也具有可比性,可以比较是否相等。因此,struct在有些情况可以用来作为map的key。
struct嵌入与匿名域
匿名域用于struct之间的组合,可以达到类似类继承的效果。在struct声明中,如果field类型是有名称的,则可以忽略掉field名,得到一个匿名域。匿名域类型或类型内的各field对应用struct可见。有点类似TS中interface
的extends
。
1 | type Point struct { |
换种说法,匿名域就是向下访问时可以省去不写的中间域。即使中间域类型是首字母小写不对外可见的,只要剩下域对外可见也可以访问。struct这种组合思想是Go在面向对象上的核心。
JSON
编解码JSON数据的方法都位于encoding/json
,其中编解码整块数据的函数分别为json.Marshal
和json.Unmarshal
,前者传入Go数据结构,返回压缩后的JSON字符串,使用json.MarshalIndent
可以返回美化后的JSON字符串。编码时,只有被导出的域才会出现在JSON字符串中。且field之后的field tag可以作为metadata修改JSON行为,如指定被JSON字符串化之后的key名。或用下面的omitempty
忽略掉零值的key。
1 | type Movie struct { |
相反,在解码JSON数据时,需要显式声明struct结构来接收JSON数据。json.Unmarshal
方法的第二个入参即struct的指针。在解析JSON时,对key是不区分大小写的,因此只需要对a_b
类型的JSON key指定field tag。
对于stream格式的JSON数据,使用json.Encode
和json.Decode
编解码。
HTML和文本模板
text/template
和html/template
用于文本模板和HTML模板。它们都使用双花括号包裹带有逻辑的简单语句。其中,html/template
还会默认对文本做escape脱敏处理(对template.HTML
不会escape)。
- 使用
template.New
创建模板 template.Funcs
向模板内插入函数template.Must
保证模板有内容template.Parse
解析模板- 使用模板的
Execute
方法生成解析后内容
1 | var report = template.Must(template.New("issueList")).Funcs( |
函数
声明
1 | func add (x, y int) (z int) { z = x - y; return } |
- 相同类型入参可以聚合
x, y int
- 返回值为多个时,需要用
()
包裹 - 返回值也可以给予变量名,这种情况下,相当于提前为返回值声明变量
- 入参是传值,即入参会复制一份传递给函数内部,只有像slice、map、function、channel这种引用实现的类型在函数内改变会影响外部值
- 只有函数声明,没有函数体的函数表示函数由其他语言实现,如
func Sin(x float64) float64
递归
Go的递归和其他语言无异。不同的是,传统语言的函数递归借助定长的栈实现,大小从64KB到2MB不等,而Go使用变长栈实现,避免的栈溢出的情况。
多返回值
1 | func Size(rect image.Rectangle) (width, height int) |
Go支持同时返回多个返回值。同类型返回值可以压缩,还可以声明有名称的返回值。在多返回值时,还可以直接传递给需要多个入参的函数,
1 | log.Println(findLinks(url)) |
返回值有名称时,会作为函数体内最外层变量出现。因此,不需要显示return
返回值,这种现象也称为“裸返回”(bare return)。由于裸返回时,return
后不会跟随返回值,不利于代码可读性,所以只在需要的时候使用它。
错误
函数返回错误在Go中是普遍现象。有时,错误类型只需要有1种,这时通常用bool
类型的ok
表示。如对map
类型的变量的访问。但大多数时候,错误原因可能要有比较多种,这时可以用error
类型的err
表示。
在Go中较少使用exception表示失败(尽管Go也有exception机制),Go只在真正的bug处,才使用异常打印stack trace信息。在Go中较常出现的是普通的error类型,它只作为普通控制流的一部分。
处理策略
error处理由调用方负责,有5种策略:
- 向上传递,在没有error时,可以用
fmt.Errorf
制造一个自定义错误信息的错误。Go建议仔细设计错误信息内容,不使用大写字母,不使用换行。建议函数的每一层补充上更多信息。 - 重试,在有些场景下,如测试服务端连接
- 退出,严重问题时,可以用
os.Exit(1)
退出,或者用log.Fatalf
打印错误信息后退出 - 打印日志后继续,对于简单问题,可以打印日志后继续流程
- 忽略,在特殊情况下,可以直接忽略,如错误确实不会影响功能实现
Go建议是使用函数时考虑错误处理的场景。
EOF
EOF(End Of File)是一种特殊的错误类型,io.EOF
表示输入流没有更多内容了。
1 | in := bufio.NewReader(os.Stdin) |
作为值的函数
这一章很类似JS或TS
Go中函数是一级成员。这意味着,它可以作为一种类型,传递给变量、入参或者返回,就像其他值的类型一样。函数是一种引用类型,所以可以为nil
,但是执行nil
会导致panic。
1 | var f func(int) nil |
再次基础上,就可以对函数做更灵活而精准的设计,拆分函数关注点和抽象层次。构造出更灵活的程序。以strings.Map
为例
1 | func add1(r rune) rune { return r + 1 } |
匿名函数
Go中只能在包级别声明有名函数,而匿名函数可以在块作用域、函数作用域内声明。因此,高阶函数、闭包等概念Go中也有。由于这些概念JS中也有,这里就不再赘述。
循环变量捕获
JS也有类似问题,不过原因不同
1 | // 一段会有问题的代码 |
上面的for循环中,循环变量dir
在append
的回调中有使用,我们回忆一下,for循环中循环变量位于for语句块外,在整个for循环后才销毁。所以这会导致每一个回调执行时,dir都被更新为最新的值。将dir在循环体内再次赋值即可。
1 | var rmdirs []func() |
变长参数
1 | func sum(vals ...int) int { |
类似JS中的剩余参数,Go中也使用rest ...type
表示函数的剩余入参。rest需要声明类型,rest为slice类型。要注意的是,这种函数和直接传入一个slice参数的函数类型并不一样。另外,在剩余参数类型不明确时,可以用interface{}
表示。
1 | // 在变量后使用`...`表示解构 |
延迟函数调用(Deferred Function Calls)
在语句前加上defer
标识符,会让defer
后的函数调用推迟到所在函数的**return
之后**执行。defer
后的函数和表达式会立即求值。defer
的函数调用在函数panic后仍然会被调用。可以用来执行一些释放资源的操作,如以下场景:
- open和close
- connect和disconnect
- lock和unlock
1 | var mu sync.Mutex |
最合适的使用时机是在刚刚获得资源之后。还可以利用defer完成进入和离开函数的成对操作做一些调试。
1 | func bigSlowOperation() { |
由于defer在函数最后执行的特点,甚至可以在defer中获取和修改函数返回值。
1 | func triple(x int) (result int) { |
同时也由于defer的这个特点,在for循环中使用defer一定要谨慎。
panic
Go中的panic类似于其他语言的exception,它一般代表程序中存在bug和不应该出现的情况。panic后,正常程序执行停止,defer的函数被倒序执行,然后函数崩溃并带有错误信息。
除了系统触发的panic,还可以直接通过panic("certain message")
手动触发一个panic。一些包中以Must开头的API通常表示,在不符合规范的时候API会panic。建议只在内部可信任环境下使用这种API。
recover
就像其他语言中的try catch一样,Go中的panic同样有机制去妥善处理。Go有内置的recover
函数,可以用于在panic中恢复。
recover
需要在defer的函数中使用recover
函数会返回panic的value,在没有panic的情况下,该函数返回nil
下面是一些使用recover
的建议:
- 不要毫无条件地从panic中recover,这可能会掩盖一些潜在的bug或资源泄露
- 在panic后,可以使用
runtime.Stack
这样的方法打印一下错误的详细信息,再recover - 可以定义一些外部不可见的类型,在调用panic时传入,在recover返回时判断类型,从而做到针对特定情况panic执行recover,其余情况仍旧panic
- 对于预期中的error不使用panic
1 | func soleTitle(doc *html.Node) (title string, err error) { |
方法
Go也有OOP的特性,即对象上具有方法,方法需要关联在一个特定类型上。
声明
1 | import "math" |
声明里,在普通声明的函数名前,增加函数绑定的类型receiver,即完成了方法的声明。Go中没有this
和self
这样的保留字。类型receiver中的变量,即方法可以访问的类型变量。变量名由于会比较常用,所以通常取类型首字母。
其他方法的行为类似其他OOP语言:诸如方法名和函数名不在一个命名空间,所以可以重名;方法名之间不能重名;方法名不能和属性名相同。由于Go中声明命名类型比较自由,而方法可以很方便绑定在命名类型上,所以可以给基础类型,如数字、字符串等,增加新方法。
指针receiver
上面提到,访问方法需要一个receiver。除了变量本身,指针也可以作为receiver。在Go中函数入参都是传值的,也就是传入值的复制。所以除了map、slice这种引用类型,其余类型的值在方法内改变并不会影响到外部。如果需要方法改变receiver本身的话,可以指定将方法绑定在指针类型上。
1 | func (p *Point) ScaleBy(factor float64) { |
通常会规定,如果类型上有方法是指针类型的receiver,所有的方法都需要有一个指针类型receiver。类似struct一节介绍的,如果变量具有类型T
,而方法的receiver是*T
,我们可以直接使用简写的p.ScaleBy(2)
而不需要写成(&p).ScaleBy(2)
。相反地,receiver要求类型T
,变量传入*T
也是可以的。
但是,直接将字面量传入给指针类型的receiver是不允许的,如Point{1, 2}.ScaleBy(2)
。
Nil是合法的Receiver
Go中,nil
在很多时候是合法的零值。同样也可以作为receiver。当然在你的命名类型中,最好对合法的nil
类型加以说明。Go的内置类型和操作,如slice,map、struct、append、make等也可以正常地处理nil
。
组合和struct embedding
在此前的struct一节中,已经介绍了Go的struct embedding设计。这里结合方法继续讨论一下。首先我们先回忆下struct embedding是啥。
1 | import "image/color" |
匿名的field会直接将field类型中的成员和方法都组合(composite)进当前类型中(和TS中的extends有点像)。如上面的ColoredPoint
就直接拥有了Point
的Distance
和ScaleBy
功能(当然也可以访问Point)。Go更希望用组合(composition)而非派生(derivation)构造更复杂的类型。比如,上面的ColoredPoint
并不是一个Point
,并不能当做一个Point访问和使用。
匿名field如果是指针类型,除了上面的特性,还能实现让两个变量共享一个底层的结构。
1 | type ColoredPoint struct { |
在访问receiver上的方法时,Go首先会去直接声明的field中寻找,然后再去embedded的field中寻找,再向下寻找。方法只能在命名类型和其指针类型上定义,但是借助struct embedding也可以实现,将功能聚合在一起。
1 | var ( |
上面重写之后的代码表现力明显更好了。
方法值(method value)和方法表达式(method expression)
1 | p := Point{1, 2} |
p.Distance
会得到一个method value,它是一个绑定到了特定receiver上的一个方法,本身也是一个函数。可以当做函数类型的值用作入参或返回值。这个和JS还比较像。
类似的,Go中还有method expression的概念。即直接用类型名加点(.
)访问方法得到一个method expression。它也是一个函数,可以看做是一个没有绑定receiver的方法。调用函数时,传入的第一个入参会当做receiver,后续的作为方法入参。这个特性在需要根据情况灵活选择方法时很好用。
1 | func (p Point) Add(q Point) Point { return Point{p.X + q.X, p.Y + q.Y} } |
一个实例:bitset
bytes.Buffer
经常用来拼接字符串fmt
的print打印字符串时会调用变量的String
方法
封装
首先,Go的封装细粒度只到package一层,package内不控制可见性。所以当你想要控制可见性时,需要用拆分package实现。
封装用来掩盖不需要像使用者展示的信息和细节。Go中唯一用来控制可见性的手段是一个大小写约定:大写表示从包中导出,小写表示包内可见,对于struct中的field以及类型的method也是如此(包外访问不了,包内随便访问)。通常来讲,当需要封装对象时,我们都会使用struct。
1 | // 建议 |
使用struct封装本来就很简单的类型有以下几个原因:
- 使用方无法修改对象值,这样只用查阅更少的声明,就能得到对象值大致的可能范围
- 对使用方掩盖实现细节,可以避免让使用方依赖那些可能改变的内容,也给开发者重构空间,开发者可以在不改变API兼容性的情况下灵活调整内部实现
- 避免使用方任意修改对象值,造成更多边缘情况,提高程序编写难度和程序不稳定性
有时,封装也会暴露出一些getter和setter。通常命名上,会直接使用field的首字母大写形式,省去不必要的Get
,Fetch
,Lookup
前缀。实际上,Go并不禁止导出field,只是在有些情况下,导出field会影响代码可靠性。
封装并不总是必要的。有时,底层数据结构是目标数据结构的充要表示,不多不少,场景变数不多,这时可以不用struct封装。但是,像IntSet这种,实现细节变数多,程序实现稳定性低,需要被保护起来,避免影响使用者。从而要采取封装的方式,把那些变数变得“不透明”。
接口(interface)
如其他OOP语言一样,Go中也有用于标识抽象类型的接口描述。不同的是,Go中的接口都是隐式满足的,松耦合。
作为约定的接口
之前介绍过的所有类型都是具体类型(concrete type),具体类型指数据表示和行为实现在类型确定后,就已一清二楚。为了保证语言灵活性,Go中还有接口类型(interface type)。这种类型不暴露内在结构和实现细节,而是给出接口输入输出,作为一种约定交由具体实现方完成,从而实现依赖反转(DI)。这一概念的设计上和其他OOP语言无二。不过在使用上,Go并不要求实现方明确依赖关系,只需实现约定即可。
1 | package fmt |
同样的,实现String
方法也让类型隐式满足了fmt.Stringer
的定义。Go中单方法interface的命名,通常以动词的名词形态为主。
1 | package fmt |
接口中也有类似struct embedding的嵌入式写法,简化interface的组合成本。另外,interface中方法的顺序不影响interface类型。
1 | package io |
接口的满足
Go中变量可以是接口类型,在给接口类型变量赋值时,需要检查值的方法是否满足了接口的类型定义,这一点和其他OOP语言相似。要注意,尽管Go有将变量T
转成*T
的隐式转换,但是类型T
的方法和*T
的方法receiver并不同。而通常具体类型中会在接口声明的方法中做一些写操作,因而指定receiver为指针类型,这有可能会导致无法满足接口定义。
接口覆盖了其包裹的内部类型,所以,即使内部类型满足其他方法,赋值给接口类型后,也只能方法接口拥有的方法。
1 | var w io.Writer |
Go中还有一个通用的不可或缺的类型interface{}
它表示对类型没有任何要求,同时也意味着该类型变量上无法执行任何操作,类似ts中的Unknown
。
1 | var any interface{} |
Go中具体类型对接口类型的满足都是隐式的,无需显式声明。所以一个具体类型可能会同时满足很多接口类型。可以把接口类型认为是将一些具体类型中公共的部分抽象出来的共同行为,将之作为grouping出来的共性。
使用flag.Value
解析命令行参数
fmt.Sscanf
可以从输入中按格式解析出特定类型参数
接口值
1 | var w io.Writer |
Go中,接口类型可以作为变量的合法类型。接口类型值因此具有动态类型和动态值。在Go中可以近似用类型描述符(type descriptor)表示,其中type表示具体类型,value表示具体值。在初始化时,type和value都是nil
。
1 | +-----------+ |
而在第二和第三行,为w赋值为os.Stdout
以及*bytes.Buffer
类型时,type分别会变成os.Stdout
和*bytes.Buffer
,同时value也会被设置为对应初始值的指针。这个过程会完成类似于io.Writer(os.Stdout)
的隐式类型转换。此时访问w
的方法,会被动态分配到value上实现。而在最后又将w
还原为初始值nil
。
接口类型之间不一定可以比较,当接口值都为nil
或接口值对应的具体类型相同以及具体值相同时,接口值相同。然而,如果具体类型不可比较时(如slice,function等),接口类型也不可比较。Go的fmt
中,可以用%T
打印变量类型。
陷阱:nil值可以存在于非nil的接口值中
1 | const debug = true |
在上面的判断中,out
已经有了具体的类型,因此接口类型的out
不等于nil
,然而out
的具体值却是nil
,这使得Write
行为无法保证。解决办法是,在一开始为buf
声明为io.Writer
类型即可。
sort.Interface
Go使用sort包中的sort.Interface
实现排序功能。同时对于常见类型string、int等也有事先封装好的sort.Strings()
,sort.Int()
。对于自定义类型,在实现sort.Interface
接口后,也可使用sort.Sort
排序。接口定义如下:
1 | package sort |
三个方法分别用来返回长度、比较大小和交换顺序。这也是排序的几个基本操作。下面给出了字符串排序的内部实现:
1 | type StringSlice []string |
在排序struct等复杂类型slice时,建议定义指针类型数组,这样可以让swap时速度更快。
sort还有一个方便的反向排序方法sort.Reverse
,它借助了struct embedding,用一个内部类型reverse
封装了外部实现接口的类型,另外,直接在reverse
上定义了Less,覆盖了Interface
的实现,从而实现了反向排序:
1 | package sort |
除了slice类型外,其他实现了sort.Interface
接口的类型一样可以排序:
1 | type customSort struct { |
http.Handler
接口
1 | package http |
实现了这个接口的可以传递给ListenAndServe
。但通常用不到这种原始的方式。Go的http包提供的ServeMux
类型可以给请求分路,聚合一堆http.Handlers
。写起来像下面这样:
1 | func main() { |
其中http.HandlerFunc
将传入的函数包裹成了满足Handler
接口的类型。
1 | package http |
对于上面的使用还是要写一些模板代码,对此可以将mux.Handler
简写成mux.HandlerFunc("list", db.list)
。可以再减少一点代码。实际上,http还提供了一个全局的ServeMux
对象实例DefaultServeMux
,不需要手动创建。
1 | func main() { |
go中每一个handler都在一个单独的goroutine上,要妥善处理好并发的情况。
error
接口
1 | type error interface { |
error
类型实现了error
接口。整个errors
包都围绕这个接口设计,除了errors.New()
方法,还可以直接通过fmt.Errorf
返回一个格式化后的error值。
1 | package errors |
简单的数学表达式求值器
递归的AST解析
略。
类型断言
类型断言(type assertion),写作x.(T)
,通常用来将动态类型限定到更严格的类型。
T
是具体类型时,会判断x
类型是否和T
一致,是则将x
类型设置为T
,否则panic
T
是抽象类型interface时,会判断x
是否满足T
接口,是则将x
类型设置为接口T
,否则panic
1 | var w io.Writer |
当对nil
进行类型断言时时,断言一定失败。另外,类型断言可以支持第二个返回参数ok
表示是否成功,此时不会panic。
1 | if w, ok := w.(*os.File); ok { |
应用:错误类型区分
借助类型断言,可以将判断抛出的具体错误类型,os
包提供了IsExist
,isNotExist
,isPermission
用来区分文件已存在,文件不存在,不允许几种错误。我们以文件不存在为例,此时抛出的PathError
类型错误包含了具体的错误类型。
1 | type PathError struct { |
使用断言后,就可以从err
中拿到具体错误类型,从而判断是否是文件不存在导致的:
1 | import ( |
另外,建议在错误抛出时就进行检测,在聚合后,原始错误的数据结构可能会丢失从而无法判断。
方法查询
抽象类型如io.Writer
可能缺少使用者需要的方法如io.WriteString
(尽管满足io.Writer
的大多数具体类型除了必须满足的Write
方法外,都对写入字符串支持了WriteString
方法)。
可以定义一个临时接口类型,判断满足抽象类型的变量是否具有指定方法。因为Go中接口的满足是隐式的(类似鸭子类型),不像许多强类型语言一样,需要显式声明。之前使用弱类型语言的可能能很好接受。
1 | func writeString(w io.Writer, s string) (n int, err error) { |
实际上,fmt.Sprintf
打印不同类型的变量时,也借助了类型断言,对于特定类型调用特定方法,最后再使用反射处理其他类型。
1 | package fmt |
Type switch
interface除了之前说的让多个具体类型有一致表现的用法外,还可以作为可区分具体类型的合集来使用。这种时候需要结合type switch的用法。如下所示:
1 | switch x.(type) { |
通常在确定了x
的类型后,还需要直接使用x
。此时可以写作switch x:= x.(type)
。
这种用法和之前的用法不同在于:这里接口不作为有一致表现而存在,它只是用来暂存将要区分开的具体类型,而这些具体类型往往时有不同表现的。所以这种用法里的接口几乎没有方法。换一种说法,之前的用法里,接口背后的具体类型细节需要被掩盖来使用,而这里需要使用具体类型的细节。
一些忠告
和上一章方法类似,接口是一种很好使用的面向对象的特性。但不建议上来就从定义一堆接口开始,这样通常会产生一大堆只有一个具体类型实现的接口。接口是抽象类型,是通过具体类型抽象得来的。通常是在需要用统一的方式处理不同类型时,拿来使用。
同时,大多数Go程序中,接口往往小且包含比较少的方法。像是io.Writer
或fmt.Stringer
。和方法一章一样,它们虽然是面向对象的特性,但是不是Go中一定要使用的语言特性。只在需要的时候使用。大多数时候,直接使用函数就足够了。在书中,方法如input.Write
的使用就远不如函数如fmt.Printf
来得频繁。
goroutine和信道
Go支持两种并发编程的风格,第一种在本章介绍,通过goroutines和channels支持通信顺序进程(Communicating sequential processes,CSP),这种情况下,值会在goroutine间来回传递,而变量在多数情况下被限制自单个活动中。下一章介绍的共享变量风格的并发编程更接近传统的并发风格。
认识goroutine
1 | func main() { |
- goroutine类似线程,有着定量而非定性的差异
main
函数也会启动一个main goroutine- goroutine通过
go
启动一个函数或方法调用,并在声明后立即返回 - 除了
main
函数返回或程序结束(os.Exit
)外,一个goroutine没有办法直接停止另一个,但可以通过传值的方式间接实现。
简单示例
服务器处理请求是最典型的并发场景。
1. Clock Server
1 | package main |
listener.Accept
会在接收到TCP连接请求前一直阻塞time.Format
方法通过一个特殊的样例(15:04:05)表示要格式化的格式,time.Parse
也是如此- client端可以用
net.Dial
发起一个TCP连接请求
上述的服务端是串行处理client的请求,并每秒打印当前时间,在handleConn(conn)
前加上go
关键字后,即可让服务端并行处理client的请求。
2. Echo Server
上面的例子是在一个连接中使用一个goroutine,当然每个连接也可以创建多个goroutine。
1 | // server |
在echo
前加上go
即可让服务器同时相应多个请求,返回“回声”。同理,在client端打印服务端返回的代码前加上go
即可让使用者输入的同时打印返回的“回声”。
信道(channel)
go
启动并行的活动,信道作为活动间通信的通道,借助它可以发送和接收消息。信道通过make
构造,需要指定传输消息的类型,作为信道类型。可以使用close
关闭信道。后续的发送操作会panic,接收操作会得所有到已发送的值,而再之后的后续接收操作只能得到信道类型对应的零值。
1 | ch := make(chan int) |
信道还分为有缓冲区和无缓冲区两种类型,上述的make
构造的都是无缓冲区的信道,指定第二个参数可以构造有缓冲区的信道。
1 | ch = make(chan int) // 无缓冲区 |
无缓冲信道(Unbuffered Channels)
向无缓冲区发送消息会阻塞发送所在的goroutine,直到对应的goroutine在同一个信道上执行接收操作。相反地,接收消息在先的话,也会阻塞直到同一个信道上执行了发送操作。这种机制会同步两个goroutine的执行进度。如果发送信息在先,则接收信息会在发送所在的goroutine之前发生。从而,我们可以基于这个假设的前提保证一些事实。
1 | func main() { |
上述程序里,会在接收完服务端返回后,才会关闭客户端。这里需要的是一个事件,使用的信道类型其实并不重要,所以使用了struct{}
。实际应用中会使用bool
或是int
这样的简单类型。
流水线
借助上面提到的无缓冲区信道,可以实现多个goroutine之间的接续传递,也可以叫做流水线。
1 | +-----------+ +-----------+ +-----------+ |
1 | func main() { |
上面的流水线中,Counter在打印100个自然数后,会关闭信道。会有之前所说的一些特性:
- 向关闭信道写入消息会panic
- 从关闭信道读取信息会得到所有未发送的消息,再之后只能得到零值
- 关闭信道不会影响其他goroutine执行
所以在上面的程序中,Printer会继续打印0,只有Counter正常退出。Go中没有直接的获取信道是否关闭的方法,但是对于从信道中读取消息有第二个ok
参数,为false
时表示信道已关闭且读取完所有消息。
1 | x, ok := naturals |
上面的模板代码,go用range
已封装好,不必重复书写。
1 | go func() { |
不是所有信道在不用后都要显式关闭,只在需要传达信道关闭信息时再手动close
关闭。其余的信道会在gc过程中回收。但这不意味着文件读取也可以不显式关闭:文件的读写操作后一定要执行关闭操作。关闭一个已关闭的信道会panic,关闭nil
的信道也是一样。
单向信道
以上一小节为例,有三个goroutine,函数签名如下:
1 | func counter(out chan int) |
其中的信道入参分别用来接收或发送消息(绝大多数信道也是如此)。因此对于这两种信道的细分,go类型系统提供了单向信道类型,即只读或只写。同时提供了类型助记符:
chan<-
表示只读,只可读取消息,不可关闭<-chan
表示只写,只可发送消息和关闭
违背只读只写上述规则,会在编译期间抛出错误。同时,双向信道可以隐式covert到单向信道,反之不可以。
1 | func main() { |
缓冲信道(Buffered Channel)
1 | ch = make(chan string, 3) |
可以用队列类别缓冲信道,不同的是缓冲信道和goroutine是紧密相连的。
- 写操作会在队列充满时阻塞
- 读操作会在队列为空时阻塞
通过cap
和len
可以查看缓冲信道的实时容量和长度。虽然缓冲信道可以按队列去理解,但是不要把它拿去当队列来用。那么和无缓冲信道相比,缓冲信道应用场景有什么不同呢?
我们用流水线举例,流水线上的各道工序复杂程度有难有易,如果工作空间有限,每一道工序后都需要在下一道工序空闲时才能交付,一些简单工序就需要等待。这时就像无缓冲信道。假设工作空间宽裕,每道工序完成后,如果下游还未就绪,可以先放在空闲空间下,直接继续工作。这就是缓冲信道,多出来的工作空间即缓冲区,工序即goroutine。缓冲区可以弥补上下游工序工作效率的些微差异,缓冲区越大,可以容忍的效率差异就越大。如果工序间有明显差异,比如始终更快或更慢,此时增加缓冲区无法提供帮助,可以采用增加工序工人来提高工作效率,即在同一信道上使用更多goroutine。
从上面的比喻,可以得出两种信道的区别:
- 无缓冲信道重点在同步,它可以确保上下游goroutine的同步性
- 缓冲信道则使用了队列来解耦上下游goroutine,使之不因为阻塞影响工作效率
所以,我们假设有多个网站镜像来为网络请求提供服务,就可以使用缓冲信道,优先响应的可以直接提供服务,且在响应后可以继续工作。
1 | func mirroredQuery() string { |
并行循环
有些任务可以拆分成等效的相互独立的小任务,这种情况也被称为“令人尴尬的并行”,是最简单的并行工作场景,它的工作量和并行数呈线性关系。我们假设有一个并行处理图片缩小的程序,能返回缩小后的文件总体积,并在合适的时候停止。在程序编写过程中,会遇到一些问题:
- 有错误出现时,未关闭剩余信道,导致goroutine泄露,并造成程序不响应或内存耗尽
- for循环结合延迟执行代码时,循环描述体中的变量陷阱
- 要支持任意长度的图片列表,不能写死缓存信道的容量
- 无从直接得知goroutine是否执行完成
最终得到下面的最终版本:
1 | func makeThumbnails(filenames <-chan string) int64 { |
上面程序里面有几点需要特别说明:
- 第一个for循环会将信道输入的图片文件列表转成任务的列表,然后再启动一个goroutine负责关闭信道,最后从信道中拿出所有的大小加总返回
- 关闭函数必须写成goroutine的形式。因为
sizes
的range结束依赖于sizes
信道的关闭,同时sizes
信道又必须等待所有图片处理任务执行完之后再关闭。等待和加总图片大小需要并行,所以需要一个新的goroutine去做 - 任务完成借助
sync.WaitGroup
完成,wg.Wait()
会阻塞直到wg.Done()
将所有任务清零
样例:并发web爬虫
将第5章中的worklist
由slice
改为channel
,让爬取网页内容的过程并发执行即可得到一个并发的web爬虫。
1 | func crawl(url string) []string { |
上面的程序在执行了一段时间后,会因为客观限制出现报错信息。这是因为程序过于并发了。由于硬件资源的限制,当并发数超过一定界限后,程序性能反而不如以前甚至会无法运行。因此需要手动限制并发量。这里有两种思路:
- 通过限制发放许可证(token)的方式限制爬取goroutine是否执行,许可证数量有限,许可证用完后,阻止goroutine执行。当然作为信道的token,是在多个爬取goroutine间共享的。
- 限制爬取goroutine总数,只创建固定个数的goroutine
1 | // 信号量,占有表示被使用中 |
将上面代码main
函数中的worklist
延迟在for循环内赋值,使用n
记录当前任务中的正在执行的任务数,可以实现在所有任务执行完成后退出程序。
1 | func main() { |
select
实现多工
在之前的例子里,从信道中读取/写入值,会阻塞当前goroutine进度。如果需要同时接收两个信道的值,需要select
语句块。select
语句块使用类似switch
。
1 | select { |
每一个case
可以是接收或是发送消息的语句,select
语句在其中一个case发生后,才会继续(select{}
会一直等待程序执行)。default
可以指定没有任何一个case发生时的处理方式。
原文中给出的time.Tick
例子会返回一个channel,并以设定的时间间隔发送消息。但是,再不从channel读取信息后,会造成goroutine泄露。因此只在整个生命周期都需要时才会使用。倒计时这种场景下建议使用更复杂的方式:
1 | ticker := time.NewTicker(1 * time.Second) |
对于一个nil信道的发送和接收会一直阻塞,select中的case也不会被选中。利用这个特性可以实现取消等功能。
并发目录遍历
借助ioutil.ReadDir
可以实现遍历根文件夹下所有文件体积的功能。下面是一个纯单线程版本。
1 | func walkDir(dir string, fileSizes chan<- int64) { |
上面的版本可以实现功能,但是速度很慢,而且不能实时显示进度。这里我们用time.Ticker
定时打印进度,同时通过命令行参数p
控制。
1 | var progress = flag.Bool("v", false, "show progress") |
其中if *progress
语句在没有传递-p
参数时,不会为tick赋值,而nil的tick值会让select中永远不会进入这个case,从而不打印进度。
在遍历根目录下的递归调用walkDir
中,也可以使用goroutine,并通过sync.WaitGroup
保证执行完成后关闭fileSizes
信道。当然,无限制的创建goroutine会出现和上上小节一样的问题,所以也需要信号量(semaphore),保证不至于创建过多goroutine。
1 | var sema = make(chan struct{}, 20) |
取消
一样的,一个goroutine没有直接关闭另一个goroutine的办法。按照之前提到的通过信道传递消息的思路,但是一个信道只会被消费一次,我们这里的场景需要广播更合适。
之前提到,一个被关闭的信道在传递完信道内的消息后,后续再从这个信道获取值,会立即返回一个零值。可以利用这个特性,在执行取消操作后,将信道关闭即可,可以写出下面这样的函数。
1 | var done = make(chan struct{}) |
然后在程序的瓶颈处,检查这个函数的返回值,一旦返回true
则立即中止程序。比如,之前提到获取token的函数里。
1 | func dirents(dir string) []os.FileInfo { |
按上面这种方式退出程序后,有可能出现goroutine还没有妥善关闭的情况,可以在调试时,程序的最后用panic
打印系统信息,查看具体情况。
样例:聊天服务器
聊天服务器也是并发和各种信道常用的场景,它包括:
- 用户的接入、退出
- 用户信息的广播
- 用户session的维护
我们可以用一个信道表示一个接入的用户,在一个全局的文件中处理用户登入、登出,即信道的信息维护,这里可以用map表示,对于接收到的消息,像注册的所有信道逐个发送,即广播。
1 | type client chan<- string // 只写信道 |
同时,启动一个tcp服务器,单独启动一个goroutine负责上面的信道管理,另外对于每一个接入的连接,启动一个独立的goroutine处理。
1 | func main() { |
在处理tcp连接的函数里,负责接入客户端,同时将连接中的内容写入到messages
信道中,以便广播给其他客户端。
1 | func handleConn(conn net.Conn) { |
上面的map没有使用lock操作,是因为它的读写都限制在了一个goroutine内,因此是并发安全的,其他并发使用的信道和net.Conn
也是并发安全的。
并发和共享变量
使用信道在goroutine间沟通是一种并发的范式,其中也略过了一些关键而细小的问题,这些在后面这种并发编程模式中会经常讨论。
竞险(race conditions)
1 | package bank |
上面以银行为例,实际上给出了一个可以读写的变量。在串行执行场景下,不会有问题。在并发场景下,对balance
读写的同时进行,就会造成一些问题。这种情况也叫数据争用(data race),即有两个goroutine并发访问一个变量,且至少有一个是写操作。这种数据争用有时候还会带来未定义的行为。
在使用共享变量的模式并发编程时,如果不小心处理,很容易遇到数据争用的情况。然而,绝大多数的数据争用都“来者不善”,以至于我们要留心发生数据争用的场景:有两个goroutine并发访问一个变量,且至少有一个是写操作。下面有三种方式去避免:
- 不要写变量,比如将变量初始化好之后,使之只读或不可变
- 避免在多个goroutine上操作变量,将操作限制在一个goroutine上,就像前一章中的broadcaster,这样的goroutine也叫做调度者goroutine。Go中有句箴言总结的很好:不要通过共享变量传递消息,通过传递消息来共享变量。这里的传递消息就是指通过信道发送和接收。当实在无法限制多个goroutine访问一个变量,也尽量限制访问,通过信道传递给其他goroutine,达到串行限制(serial confinement)的效果。
- 在同一时间仅允许一个goroutine访问变量,即后面会提到的互斥锁
互斥锁(sync.Mutex
)
互斥锁和之前提到的信号量(counting semaphore)很类似,更像是一个容量为1的信号量,即二进制信号量(binary semaphore)。每次执行后续操作前,都需要从一个全局信道中获取token,
1 | var ( |
将上面的信号量表示使用sync.Mutex
替代就是互斥锁的使用方式:
var sema = make(chan struct{}, 1)
=>var mu sync.Mutex
sema <- struct{}{}
=>mu.Lock()
<-sema
=>mu.Unlock()
通常来说,互斥锁使用的范围很小,这一区域也叫临界区(critical section),被mutex守护的共享变量会紧跟在Lock
之后。在程序较长时,为了避免在所有返回处显式Unlock
可以使用defer
,这会稍微增加一些显式Unlock
的成本,但会让代码更简洁。
另外,互斥锁是不可重入的,即不能对一个已经上锁的共享变量上锁,这会导致死锁,因此确保互斥锁和其守护的变量不被导出。
读/写互斥锁(sync.RWMutex
)
1 | var mu sync.RWMutex |
sync.RWMutex
可以限制写操作,而允许多个读操作同时进行。RLock
方法开启,RUnlock
关闭互斥锁。注意,只在确定没有对共享变量写操作发生的时候使用RLock
方法,我们不能简单的假设一个逻辑读操作,在背后没有注入写入缓存或更新计数器等行为。如果不确定,请使用完整的互斥锁。
同时,sync.RWMutex
只在大多数读操作在争用锁时会比较合适。其更复杂的实现,让它在其他场景下工作慢于普通的互斥锁。
内存同步
上面提到的对于Balance
这个只读的函数也使用的互斥锁或者信道来限制多个goroutine访问共享变量,其中一个明显的原因是:读取操作发生在写操作如Withdraw
或Deposit
中间时,也会造成问题。另一个不那么明显的原因是,类似互斥锁、信道这种同步操作也会同步内存。
简单点说,在现代CPU架构中,多个处理器内很可能有缓存,每个goroutine对共享变量的修改很可能在多个缓存中,而对其他goroutine不可见,直到同步操作把缓存中的修改同步到主内存中,保证对所有goroutine可见且一致。
同一个goroutine内部是串行稳定的,但goroutine之间无法保证顺序。还有一种错误认识,goroutine的代码会逐行交错(interleaving)执行。但在现代的CPU架构和编译器中,并不是这么实现的。总而言之,把对变量的使用限制在同一个goroutine内,对其他变量使用互斥锁。
懒初始化(sync.Once
)
1 | var icons map[string]image.Image |
通常来说,我们会推迟一个计算量比较大的初始化操作到使用时才进行,如上面Icon
函数做的那样。很显然Icon
函数不是并发安全的。在其中混有读写操作,且和外界共享icons变量。这时我们需要在初始化的时候对loadIcons
函数加锁。加锁时要区分icons
的是否初始化状态,可以对只读操作使用读/写锁,再对写入操作使用互斥锁。像下面这样:
1 | var mu sync.RWMutex |
实际上,上面就是一个只做一次的操作(通常是初始化操作),为了维护一个是否完成的bool值,额外增加了一些操作,较容易出错。go对这种情况提供了sync.Once
支持,在Do
方法中传入只执行的函数,这个互斥锁会在第一次执行时上锁并将对变量的改动同步到其他goroutine中,同时维护一个bool值,在后续的执行中,直接跳过这一步。重写之后的Icon
变得简单了很多。
1 | var loadIconsOnce sync.Once |
竞险检测器
很多时候goroutine访问共享变量导致的竞险并不那么容易发现和避免。Go的配套工具链提供了-race
标志位用来检查程序中可能存在的竞险情况,在go run
, go build
, go test
后添加都可以。它可以记录对共享变量所做的所有读写操作以及对应的goroutine,还有程序中由sync
和信道触发的同步操作。竞险检测器在分析这些事件流的时候可以给出包含共享变量和对其读写goroutine报告。在绝大多数情况下已经足够你查问题了。
竞险检测器只能报告程序覆盖到代码的竞险情况,所以尽量让测试覆盖到所有代码。竞险检查会稍微占用更多时间和内存,但是是可以接受的。
样例:并发无阻塞缓存
实现这么一个并发数据结构,大抵有两种构建思路:
- 使用有锁的共享变量
- 借助信道和通信实现串行化
不同场景下,他们实现的复杂度可能会稍有不同。
goroutine和线程
goroutine和线程有些很多小地方上的区别,这些区别让goroutine和线程有着较大区别。
- 线程的栈一般是固定的(通常是2MB),goroutine的栈是灵活的,从较少的大小开始(通常是2KB),可以扩大和缩小
- goroutine有自己的调度机制(m:n调度),把m个goroutine复用或调度到n的操作系统的线程
GOMAXPROCS
环境变量决定了Go代码可以使用多少个操作系统线程
最后,goroutine没有其他操作系统或编程语言中用来支持多线程的为每个线程添加一个唯一标识的设计。这个是特别设计的,用来避免线程池的滥用。Go更推荐只由入参显式决定函数表现的风格,避免让函数收到执行线程的影响。
包和Go工具
如今中小型的程序可能会包含10000个函数,其中绝大多数来自其他人的包。包可以将程序中相互关联的特性整理到独立的单元中,进而在组织或社区中重用、分享。包名和导出的类型、变量、函数名都应简短清晰,Go使用首字母大小写控制可见性,从而掩盖实现细节,保证稳定性或实现互斥锁。
Go的构建速度算是比较快的。主要有3个原因:
- Go在每个文件开头都显式列出了引入的文件,无需读取整个文件
- Go中没有引入是一个有向无环图,因此可以并行编译
- 编译好的Go包的目标文件会包含自身和自身的依赖,每一次的import只需读取一遍目标文件即可
import路径
Go的语言规范并没有规定import路径,路径的实现是由相关工具决定的。但是为了避免冲突,除了标准包以外的包,都需要以域名或组织名开头,如encoding/json
,golang.org/x/net/html
。
声明和import
每一个Go文件的开头,都需要以package xxx
的形式标识包名。通常,包名是import路径的最后一段。但是有3个例外情况:
main
包名表示告诉go build
需要构建一个可执行文件- 目录下包含
_test
后缀文件名的,在执行go test
时会额外被构建用于测试的包 - 有的报名后面会跟版本号,这个时候以没有版本号的作为包名
在import时,如果引入包较多时,可以用圆括号列表形式引入。列表间可以用空行隔开,通常用来分组域名,这个在按照字母顺序排序import时有用——排序会在每组内进行。如果不同域内的包名一样,如math/rand
和crypto/rand
,可以用重命名引入(renaming import)。像下面这样
1 | import ( |
这种重命名只在当前文件内有效。重命名通常可以避免包名冲突,或者简化一些复杂的包名,在简化时,注意对同样的原名,使用同样的缩写名。go build
会报告编译中循环依赖。
空导入
有的时候,我们的引入的目的在包的副作用,如其中全局变量的初始化或init
函数的执行。这个时候可以用空白标识符_
进行重命名即可,如import _ "image/png"
。这种即空导入。
在书中例子里,对image/png
的空导入,实现了png解码相关配置的全局注册(image.RegisterFormat
),从而可以解码png图片。类似的思路在database/sql
包中也有用到。
命名
对于包名,有下面一些建议的命名规范:
- 使用简短明了的包名
- 使用有描述性且没有歧义的名字,且最好不要使用经常会用来命名局部变量的包名,如
path
- 包名通常使用单数形式,需要和其他情况区分开时,如
strings
,bytes
- 避免使用具有隐藏含义的名称,如
temp
对于包中的成员名:
- 考虑和包名一起构成完整的含义,不需要包含包名,如
flat.Int
,http.Get
- 有的包名表示一种类型,在内部会有对应的类型定义和
New
操作 - 即使对于有着很多成员的包,其最关键的几个方法仍然是最简单的
Go工具
Go工具像一个瑞士军刀,它的各个子命令提供了诸如包管理器(go get
)、构建系统(go build
, go run
, go install
)、测试驱动(go test
)等等。
工作区组织
日常经常使用的是GOPATH
环境变量,用于说明当前工作区的根路径。GOROOT
表示go源码的根路径,GOOS
表示操作系统,GOARCH
表示处理器架构。更多配置可以执行go env
查看。
包下载
执行go get
下载,下载时不仅包含源码的拷贝,还包含源码的版本控制信息。Go工具会自动判断流行的代码托管方式。对于不那么有名的托管网站,需要自己显式说明保本控制的协议,可以通过go help importpath
查看细节。
Go工具在访问包的导入路径域名如golang.org
时,会试图从网页的<meta>
标签中寻找类似下面这样指示目标路径的信息。
1 | <meta name="go-import" content="golang.org/x/net git https://go.googlesource.com/net"> |
另外,执行go get -u
时会获取所有包的最新版本,在需要锁定版本时比较麻烦,可以借助vendor解决,在go help gopath
中有介绍。
包构建
- 使用
go build
构建时,对于库类型代码只会检查错误,对于main包,则会构建可执行文件 - 一个目录包含一个包,因此导入时,要么指定导入路径,要么指定相对路径,否则会以当前目录为基础构建。构建得到的可执行文件名称是go文件的前缀。
go build
构建时,会丢弃已编译的代码,只保留可执行文件。go install
构建时,会保留已编译的代码,编译好的包位于$GOPATH/pkg
下,编译得到的执行文件位于$GOPATH/bin
下。再之后,go build
和go install
不会编译未被改变的包或命令,从而让执行过程更快。go build -i
可以安装项目的所有依赖- 修改
GOOS
和GOARCH
可以改变包目标的平台和架构,默认只会针对当前平台和架构打包。 - 文件末尾以诸如
_linux.go
,axm_amd64.s
结尾时,只会在打对应平台包的时候才会编译此文件。另外还可以以// +build linux darwin
的注释形式做到更好的细粒度。// +build ignore
表示编译时跳过该文件。
go doc go/build
下有更多介绍。
包文档
Go建议在导出的包成员和包声明前使用描述用途和用法的注释。注释宜简单且清晰,对于大段的注释,使用同名的文档文件(通常名为doc.go
)进行说明。如果行为本身就足够明显,就不需要写注释。
go doc
命令可以查看包、包成员、包方法的文档。还有个很相似的命令godoc
,它可以托管一个能够查看当前工作目录下文档的服务器。
内部包
有些包可能希望导出只对个别信任的包可见,对于这种包,导入路径中需要包含internal
。这些内部包只对internal
的父目录下文件可见,如net/http/internal/chunked
对net/http/httputil
可见,但对net/url
不可见。
查询包
go list
工具可以查询包的导入路径。使用...
通配符可以查到更多内容。
1 | go list github.com/go-sql-driver/mysql |
结合-json
可以打印json格式的包详情,或者结合-f
加上text/template
语法打印特定格式的字符串。
更多使用方式查看go help list
。
测试
同行评审和测试是两种避免代码错误的方式。Go尽量让写自动化测试代码不是一件很困难的事。在Go中进行测试,你需要了解的只不过是普通的Go语法规范和一些约定而已。
go test
工具
Go的测试都借助go test
完成。所有和测试相关的文件必须以_test.go
结尾,这些文件不会在打包时包括进去,只会在运行测试时运行。在文件中有三类函数会被特殊处理:
- 测试函数:必须以
Test
开头,表示检测一些逻辑的正确性,运行后会给出PASS
或FAIL
- 基准测试函数:必须以
Benchmark
开头,表示测量一些操作的性能,运行后会给出运行时间 - 样例函数:必须以
Example
开头,表示提供一些格式化的文档
go test
运行完成时,会生成一个临时的main
包,构建并运行,最后给出结果并清理现场
测试函数
测试函数均以Test
开头,函数入参是test包提供的用来打印错误或其他日志的工具集。
1 | import "testing" |
接着就像写普通Go代码一样去执行case就行了。
1 | package word |
运行时,结合-v
标记可以打印详细信息,结合-run
标识可以只运行符合指定模式的case。
1 | go test -v -run="French|Canal" |
case之间的代码相似性很高,建议用配置的方式批量运行case,减少模板代码书写。
1 | func TestPalindrome(t *tesing.T) { |
go test
在运行测试时,每个case的错误不会中断其他case的执行,也不会panic,来保证一次运行能获得所有case的执行结果。对于需要中断的情况,可以使用t.Fatal
或者t.Fatalf
。
随机化测试
不同于上面提到的选择特定case进行测试。随机化测试可以覆盖更广的范围。在验证随机化测试是否符合预期上,有两种思路:
- 使用另外一种方式给出结果,对比两种方式的结果是否相同
- 使用固定的模式生成随机化case,使其预期结果可以事先推导出来
另外,在随机化测试时还要考虑能否再次复现问题case的情况。
测试一个命令
对于go test
而言,main
包只是一个库,可以将main
函数中需要测试的逻辑抽离成函数,在*_test.go
中测试即可。最好将log.Fatal
或者os.Exit
这种中断程序执行的语句放在main
函数中,避免中断测试流程。
白盒测试
白盒测试即在对测试对象有清楚认识的情形下进行测试;黑盒测试则相反,更站在客户端的角度去测试包。在白盒测试下,我们可以修改原先包的一些实现方式,使之更易被测试。比如,可以将其中会有副作用的部分,如发邮件、写数据库、发短信的函数覆盖。(类似mock的思路)
但是,在覆盖后,别忘了还原回去,避免影响后续测试。类似下面这样:
1 | func TestCheckQuotaNotificationUser(t *testing.T) { |
这种覆盖方式正常情况下不会有风险,因为go test
通常不会并行运行多个测试。
外部测试包
上面提到的都是直接在包下新建*_test.go
文件的方式进行测试。有些情况下,如果测试文件内需要引用更高层包,会产生循环引用,这是上一章提到不允许的。这时可以定义为外部包。如:
net/url
下的测试文件导入了net/http
包,而net/http
包中又导入了net/url
。这个时候在net/url
下的测试文件使用package url_test
声明,表示是另一个包net/url_test
。然后,通过导入net/url
和net/http
的方式进行测试。就可以避免循环引用。
可以通过go list -f
指定.GoFiles
,.TestGoFiles
和.XTestGoFiles
分别查看包中的源文件、测试文件和外部测试包文件。
然而,外部测试包并不能访问到包内对外不可见的变量或函数。这个时候,可以在包内创建一个后门测试文件,用于导出一些内部变量或函数对外部包测试可见,通常命名为export_test.go
。这类文件内不包含实际的测试。如fmt
包下的export_test.go
。
1 | package fmt |
写高效的测试
Go在设计上和其他很多语言不同,并不包含一个大而全的测试框架,也没有创建、清除操作,和常用的断言、判断方法等。Go认为写case是作者自己的事,而且就像写普通的程序一样,不要有死记硬背和长篇大论,只需简明扼要地表达测试意图。
在写测试代码时,避免过早抽象,先想着把功能实现,然后再想怎么通过抽象减少重复和复杂度。
避免“脆弱”的测试
有两种应用:一种是真正bug很多的(buggy),另一种是合理改动也过不了case的(brittle)。而这里过不了case可能只是因为判断逻辑写的不够宽容,死抠细节导致很容易过时。避免这种情况一个很直接的办法是只检查你关心的特性,使用更简单和时间稳定的方式检查,如不要依赖字符串匹配。去检查本质。
覆盖率
Testing shows the presence, not the absence of bugs —— Edsger Dijkstra
覆盖率一定程度上能对测试的覆盖程度有启发性的指示作用。使用go test -coverprofile
可以指定覆盖率数据输出,如果不需要输出,只看摘要,可以只用go test -cover
。使用go tool cover
可以显示覆盖率使用介绍。
最后要说明的是,被覆盖到的代码并不是没有bug,测试是一种务实的努力。它是在写测试代价和失败代价的中间的一个折中。
性能测试函数
这类函数都以Benchmark
开头,和测试函数类似,函数入参是*testing.B
类型的变量。默认情况下,不会执行任何性能测试,需要指定-bench
值,去匹配对应函数执行,“.
”表示匹配所有。如go test -bench=.
性能测试函数写法如下:
1 | import "testing" |
之所以需要自己在基准测试函数中写循环,而不集成在测试驱动中,是避免一些一次性操作影响执行时间测量。-benchmem
标识会显示内存分配的使用情况。性能测试函数可以用来对比两种策略或算法的相对时间优劣,以及通过调整循环次数,整体上考察代码设计。
性能侧写(Profilling)
性能测试函数能帮你发现整体的性能好坏,但不能告诉你哪里做得不够好。
Knuth曾说过“不要过早优化”,然而结合上下文的原话的意思则是,寻找性能优化点并不那么容易,程序员们在写需求前浪费了大量时间在寻找优化点上,先把事情做出来,不要杞人忧天过早优化。但是优秀的程序员会努力找到优化点并改善之。
寻找关键点的方式就叫profiling。profile通过采样的方式给出占用时间、资源最多的对象,从而可以对应去优化。Go提供3种profile
- CPU profile,标记占用CPU时间最长的函数
- heap profile,标记分配内存最多的声明
- blocking profile,标记阻塞goroutine时间最久的操作
对应在go test
上的标识为-cpuprofile
,-memprofile
,-blockprofile
。借助go tool pprof
可以打印侧写数据,以及可视化数据。
样例函数
1 | func ExampleIsPalindrome() { |
最后一种会被go test
特殊处理的是样例函数,这类函数以Example
开头,并没有入参,也没有返回。它的作用主要有以下3点:
- 文档记录,且更能传达意图,同时由于样例函数是实际的Go代码,对比文档,随着代码演化,不会有过期风险。命名单纯叫
Example
的函数作为整个包的样例函数。 - 函数最火包含
// Output:
注释的话,go test
会检查标准输出是否能匹配注释中的输出 - 在
godoc
中可以作为playground,提供给用户动态编辑、运行的功能
反射
反射能在运行时不知道变量类型情况下去修改和查询变量值。反射还能让我们将类型作为第一成员的值来使用。类似fmt.Sprintf
和text/template
中就有用到这个特性
reflect.Type
和reflect.Value
reflect.Type
和reflect.Value
分别表示变量的类型和值。其中类型通过reflect.TypeOf
得到,得到的reflect.Type
可以保存任何类型值。
1 | t := reflect.TypeOf(3) // a reflect.Type |
返回的类型总是interface的动态类型,所以总是确切类型。
reflect.ValueOf
可以得到任意类型的变量值。返回的reflect.Value
满足fmt.Stringer
接口,不过打印出来的是变量类型。
1 | v := reflect.ValueOf(3) // a reflect.Value |
reflect.Value.Interface
方法返回一个保存相同值的interface{}
类型。它和reflect.Value
不同在于,一个interface{}
类型的变量掩盖了外部表现和内部实现细节,因此无从对其操作。``reflect.Value的
Kind`方法可以返回类型的底层表示方法,因此使用时,可以只关心Go中定义的类型。
递归值输出函数Display
利用上面提到的Kind
方法,可以实现递归打印任意类型值的函数。
1 | func display(path string, v reflect.Value) { |
上面用到了许多reflect.Value
的方法,不是所有的都安全:
v.Index()
和v.Len()
类似len()
和[i]
下标取值v.NumbField()
返回结构体中的字段数目,v.Field(i)
则返回第i位的reflect.Value
类型值v.MapKeys()
返回无序的map keyv.IsNil()
和v.Elem()
分别判断是否为空和获取值
上述方法在遇到有环的数据结构时,会无限打印,可以借助下一章里的unsafe
包解决。
使用reflect.Value
设置变量
Go中的变量都是有地址的,可以通过这个地址去修改变量的值。
1 | x := 2 |
上面的d
即变量x
。借助这个方式我们可以用Addr()
获取地址,用Interface()
获取interface{}
类型的值,再使用类型断言转成具体的变量类型。像下面这样。
1 | px := d.Addr().Interface().(*int) |
又或者,可以通过Set
方法设置一个reflect.Value
。针对特定类型,还有SetInt
、SetUint
、SetString
这样的方法。注意,这些方法只使用在特定类型上,对于interface{}
或其他类型使用,会引起panic。
1 | d.Set(reflect.ValueOf(4)) |
另外,反射不能更新那些没有对外导出的结构体字段,尽管这些字段可以在发射中读取到。CanSet()
可以判断一个reflect.Value
是否可以修改,类似的,CanAddr()
可以判断一个reflect.Value
是否可以获取到地址。
利用上面的特性,可以实现encoding/json中类似的解析JSON字符串的效果。
访问结构体的field tag
1 | var data struct { |
我们在JSON一节提到,可以在结构体后使用field tag作为JSON解析过程中的metadata。实际上,除了json
还可以设置其他tag。这个tag也可以通过反射特性拿到。
reflect.Type
的Field()
方法可以返回一个reflect.StructField
类型,其中包含了字段名、字段类型以及可选的标签。其中Tag
字段即field tag对应的字符串,它的Get
方法可以返回特定标识后的标签值。
1 | func Unpack(req *http.Request, ptr interface{}) error { |
展示类型的方法
reflect.Type
和reflect.Value
都有一个Method()
方法。reflect.Type
中的方法返回reflect.Method
实例,结构体中包含方法名和方法类型。reflect.Value
中的Method()
方法则返回一个reflect.Value
类型,即一个绑定到receiver上的方法。
1 | func Print(x interface{}) { |
一些忠告
反射在规范的类型系统外,引入了更高自由度和编程的灵活性,但同时也带来了弱类型解释型语言(没错,JS就是你)的弊病:编译期问题会变成运行时问题、代码可读性变差、性能更差。
反射虽然提供了很强大的功能,但是失去了类型的保护,需要额外处理类型的边界case,否则很容易在运行时出现panic。而这些在使用特定类型时会在编译期就被发现。因此,在使用时,建议将包中使用反射的部分完全封装在内,不对外暴露,同时做一些额外的动态检查。同时,在出错时,给出类型上更友好的提示。
1 | fmt.Printf("%d %s\n", "hello", 42) // "%!d(string=hello) %!s(int=42)" |
另外,interface{}
类型和大量出现的反射代码会让代码安逸理解,需要辅以更加完善的文档和注释来解释。
最后,基于反射的函数执行速度比普通基于特定类型的函数慢至少一两个级别。因此,尽量不要在代码执行的关键路径上使用反射实现,类似测试代码这种小数据量和执行覆盖频率的代码就可以使用。
低阶特性
Go已经尽量掩盖了它在底层的实现,用来避免出现难以调试的神秘问题。但在有些时候,比如为了追求性能,或者希望和操作系统底层交互,可能希望绕开这个限制。这一章的内容介绍的unsafe
包提供了这么一个窗口,cgo
工具可以将创建C库和Go的绑定关系。
unsafe.Sizeof
,unsafe.Alignof
和unsafe.Offsetof
这三个API能让你了解一些Go在内存结构上的一些细节。其中
Sizeof
返回操作数在内存中占用的大小Alignof
返回操作数“对齐”需要的内存大小Offsetof
返回结构体中字段在结构体内存的偏移量
这几个API并不像它们名字里写的不安全,对于了解底层的内存表示是有帮助的,比如在需要优化内存性能时。
unsafe.Pointer
unsafe.Pointer
是一个可以指向任意类型变量的指针,同时也可以把unsafe.Pointer
类型指针转换回特定类型指针。
1 | package math |
同时unsafe.Pointer
可以转换为uintptr
类型,这个类型用整数表示了地址。这个整数类型足够大,足以表示任何类型的指针。但在使用时要多加注意,因为Go的垃圾回收机制使得一个变量的地址很可能会在运行过程中改变,从而使之前的uintptr
类型变量失去意义。建议尽可能减少unsafe.Pointer
到uintptr
和对uintptr
的使用。如果有包返回了一个uintptr
类型,建议立即将其转换为unsafe.Pointer
类型,确保指针能指向同一个变量。
cgo
使用cgo可以在go中使用C语言,反之亦然,这里从略,具体参考https://golang.org/cmd/cgo。
再一些忠告
unsafe
包和reflect
包很像,提供了一些高级特性,但是更甚。它绕开了语言对不可控因素的隔离,会带来一些风险。所以,在特殊场景下,经过仔细考量和验证证实,使用unsafe
确实会带来关键性能提升时,再在代码的关键位置使用unsafe
,同时,尽量保证对代码其他地方透明。
最后,忘掉最后两章吧,先去踏踏实实写一些Go程序,在能用上reflect
和unsafe
的时候,你自然回想起来的。
祝,happy Go programming。
-END-