「导语」Go 是一门全新的编程语言,它从既有的编程语言中借鉴了许多理念,但其与众不同的特性使得 Go 程序在本质上就与其它语言有所不同。要想写好 Go 程序,就必须了解其特性和风格,以及 Go 语言中已建立的各种约定,如命名、格式化和程序结构等,这样你编写的 Go 程序才能更容易被其他程序员所理解。
格式化
在 Go
中,一般使用 gofmt
来对程序代码进行格式化操作。gofmt
会将 Go
程序按照 Go
的标准风格进行缩进、对齐。因此在编写代码时,你无需花额外的时间进行格式调整,gofmt
将会为你代劳。例如以下未格式化的代码:
type T struct { |
通过 gofmt
格式化后字段、字段类型以及注释信息都会自动对齐:
type T struct { |
gofmt
会随 Go
语言环境一起安装在系统上,你可以直接使用。使用方式如以下命令所示:
gofmt [flags] [path ...] |
当 path
参数为单个文件时,gofmt
仅会格式化该文件,当它为目录时,它会以递归的方式对该目录下的所有 go
文件进行格式化操作。默认情况下,gofmt
会将格式化后的源码打印到标准输出,如果要写回源文件,则需使用 -w
参数,如 gofmt -w main.go
。
在使用 VSCode
等编辑器来进行 Go
程序开发时,可以通过修改编辑器的配置使得它在保存代码时自动调用 gofmt
进行格式化操作,免去手动执行的烦恼。
需要注意,在 Go
中,默认使用制表符 (tabs
) 进行缩进,如非必要请不要使用空格;另外 Go
语言对代码行的长度没有限制,在格式化时也不会自动折行,如果一行实在太长,可以手动进行折行并插入适当的 tab
进行缩进。
注释
Go
支持 C
语言风格的块注释 /* */
和 C++
语言风格的行注释 //
。在 Go
中,行注释会更为常用,块注释一般用于对包进行注解。
Go
程序中的每个包中都应该包含一段包注释,它是指放置在包子句 package name
前的一块注释信息。对于包含有多个 go
文件的包,包注释只需出现在其中任意一个文件中即可。包注释应该在整体上对该包进行介绍,并提供包的相关信息。若某个包的实现功能比较简单,可以采用行注释
的形式提供简洁的描述信息。
Go
程序中的每个可导出(首字母大写)名称都应该有文档注释,因为它是可供外部调用的对象或方法,应该说明其实际用途。文档注释最好是完整的句子,第一句应当以被声明的对象或方法的名称开头。如以下代码所示:
// Compile parses a regular expression and returns, if successful, a Regexp |
文档注释以名称开头的好处是你可以使用 go doc
命令以及 grep
操作来根据关键字获取对象或方法的名称。例如,若你没有记住 Compile
这个函数名称,而又在寻找正则表达式解析函数时,那就可以运行以下命令:
go doc -all regexp | grep -i parse |
根据过滤查询的结果,你就知道应该使用 Compile
函数来完成相应的操作了。
Go
的语法允许成组声明,如果这组声明中的对象所表达的含义比较接近或者对象间具有相关性,那么你就可以对该组声明仅使用单个文档注释,这样可以避免为多个同质的对象分别写注释所带来的不便。如以下代码所示:
// Error codes returned by failures to parse an expression. |
命名
包名
当一个包被导入后,包名就会成为其内部成员的访问器。例如,在通过 import bytes
引入了 bytes
包后,就可以使用 bytes.Buffer
来访问其内部成员 Buffer
了。如果每个调用者都能以一致的包名去引用包的内部成员,那么代码看起来会更清晰,且不会产生混淆,因此包应该有一个恰当的名称:简短明了且易于理解。
按照 Go
的约定,包应当以小写的单个单词来命名,不需要使用下划线或驼峰命名法。另外,包名仅是导入时的默认名称,它不必在所有的源码中保持唯一,即便在少数发生包名冲突的情况下,也可为导入的包指定一个别名来局部使用它。在任何情况下,包名都不会让调用者产生混淆,因为可以通过 import
引用的文件名来获知当前正在使用哪个包。
另一个约定就是包名应为其源码目录的基本名称。例如。在 src/encoding/base64
目录下的包其包名应为 base64
,而非 encoding_base64
或 encodingBase64
。在使用该包时,应以 encoding/base64
路径来导入:import encoding/base64
。
因为包的调用者总是通过包名来引用其内部成员,所以可根据这一实际情况来对包中的可导出名称进行简化以避免读来拗口。例如,bufio
包中的缓冲读取器类型叫做 Reader
而非 BufReader
,因为用户会通过 bufio.Reader
来进行调用,很容易知道这里的 Reader
即为缓冲读取器,这种命名方式读起来更加通顺,也更简洁明了。
此外,由于被导入的成员总是通过它们的包名来确定,因此 bufio.Reader
不会与 io.Reader
发生冲突。类似地,用于创建 ring.Ring
新实例的构造函数通常会被称为 NewRing
,但是由于 Ring
是该包所导出的唯一类型,且该包名为 ring
,因此可以直接将构造函数命名为 New
,用户通过 ring.New
方法即可创建新实例。总体而言,使用包结构可以帮助你更好地进行命名。
Getters 获取器
Go
中并不提供对获取器 (getters
) 和设置器 (setters
) 的自动支持,你应当自己提供,通常也是很值得这样做的。但是在命名时需要注意,对于获取器而言,将 Get
包含在名字中是不符合 Go
语言习惯的,而且也没有必要这样做。假如你有一个名为 owner
的未导出字段,其获取器名称应该为 Owner
而非 GetOwner
。
在 Go
中,大写字母开头的名称即为导出类型,可通过包名直接调用,这种规定为区分方法和字段提供了便利,这也是获取器如此命名的原因之一。对于设置器而言,SetOwner
是个不错的选择。
接口名称
按照 Go
语言的约定,只包含一个方法的接口应当以该方法的名称加 er
后缀来命名,如 Reader
、Writer
、Formatter
、CloseNotifier
等。
诸如此类的命名还有很多,遵循这种规范会让事情变得简单明了。需要注意,Read
、Write
、Close
、Flush
、String
等名称都具有典型的签名和意义,为避免出现混淆,尽量不要使用这些名称为你的方法命名,除非你的方法具有相同的签名和意义。反之,如果你所实现的方法与一个众所周知的方法具有相同的含义,那么就应该使用与已知方法名相同的名称,比如字符串转换方法应命名为 String
而非 ToString
。
MixedCaps
Go
中约定使用驼峰的方式(如 MixedCaps
或 mixedCaps
),而非下划线的方式(如 mixed_caps
)来对多个单词进行命名。
分号
和 C
语言一样,Go
的正式语法中也会使用分号来结束语句,但与 C
语言不同的是,这些分号并不会在源码中显示地出现,而是由词法分析器根据一些简单的规则来自动插入,因此在写代码时你可以省略这些分号。
词法分析器插入分号的规则可以概括为:如果新行前的最后一个标记可以结束该段语句,则插入分号
。具体而言,如果新行前的最后一个标记为标识符(包括 int
和 float64
这类单词)、数值或字符串常量之类的基本字面量或者为以下的标记之一:
break continue fallthrough return ++ -- ) } |
那么词法分析器将会在该标记后面插入分号。
通常 Go
程序只在诸如 for
循环这样的子句中显示地使用分号,以便将初始化操作,循环执行条件以及元素增量操作区分开来。另外,如果你在一行中包含了多个语句,也需要使用分号将它们隔开。
需要注意的是,你不能将一个控制结构(if
、for
、switch
或 select
)的左大括号放在下一行,如果这样做,词法分析器会在大括号前插入一个分号,这会导致编译错误。你应该这样写:
if i < f() { |
而非这样:
if i < f() // wrong! |
控制结构
Go
中的控制结构与 C
语言有许多相似之处,但在某些方面也有所不同。Go
中不再包含 do
或 while
循环,只有一个更为通用的 for
循环;switch
也要更加灵活;if
和 switch
可以像 for
一样接受一个可选的初始化语句;break
和 continue
语句可接受一个可选的标签来标识要终止什么或继续什么;此外,还有一个包含类型选择和多路通信复用器的新控制结构 select
。另外,Go
中的控制结构在语法上也与 C
语言有些许不同,它没有圆括号,并且它的主体必须始终用大括号括住。
If
在 Go
中,一个简单的 if
语句如以下代码所示:
if x > 0 { |
强制的大括号促使你将简单的 if
语句分为多行编写,这是一种很好的编码风格,尤其是当主体包含 return
或 break
等控制语句时。
由于 if
和 switch
可接受初始化语句,因此用它们来设置局部变量会较为常见。
if err := file.Chmod(0664); err != nil { |
在 Go
的源码中,你会发现如果 if
的主体不会执行到下一条语句时,不必要的 else
会被省略。例如,其主体以 break
、continue
、goto
或 return
等语句结束时后面无需再接 else
,这也是 Go
中推荐使用的一种编码风格。
f, err := os.Open(name) |
重声明与再赋值
请看下面的代码示例:
f, err := os.Open(name) |
其中 :=
为 Go
语言中的短声明操作符。
上述代码通过调用 os.Open
函数声明了两个变量 f
和 err
,在几行之后,它又调用了 f.Stat
函数,该函数看起来似乎也声明了两个变量 d
和 err
,理论上讲 err
被声明了两次是不合法的,但在这里却没有问题,这是因为第二条语句中的 err
并不是被再次声明了,它只是被重新赋值而已。
在满足以下条件时,已被声明的变量 v
可以再次出现在 :=
的左侧:
本次声明与已声明的变量
v
处于同一作用域中(若v
在外层作用域中声明过,则此次声明会创建一个新的变量)。与初始化类型相同的值才能被赋值给
v
。在此次声明中至少另有一个变量是新声明的。
这个特性就是 Go
语言纯粹的实用主义的体现。基于此,你可以在一个很长的 if-else
链中,很方便地使用一个 err
变量来进行各种判断。
For
Go
中的 for
循环类似于 C
语言,但却不尽相同,它统一了 for
和 while
,且不再有 do-while
循环了。
Go
中的 for
循环有以下三种形式,只有一种需要使用分号:
// Like a C for |
短声明操作符可以使我们更容易在 for
循环中声明索引变量。如下代码所示:
sum := 0 |
如果你想遍历数组、切片、字符串或映射,或者从通道中读取信息,range
子句能帮你轻松实现:
// Range slice |
如果你只需要 range
遍历中的第一项(键或索引),去掉第二项就可以了:
for key := range m { |
如果你只需要 range
遍历中的第二项(值),可使用空白标识符 _
代替第一项,表示舍弃该项:
sum := 0 |
Go
中没有逗号操作符(逗号操作符是为了把几个表达式放在一起),而且 ++
和 --
操作为语句而非表达式(语句和表达式的区别在于前者不能被求值并赋值给其它变量,而后者是可以的)。因此,如果你需要在 for
循环中使用多个变量,那么在进行元素增量操作时,应采用平行赋值的方式来完成(因为它会拒绝使用 ++
和 --
)。如以下代码所示:
// Reverse a |
Switch
Go
中的 switch
比 C
中的更为通用,其表达式无需为常量或整数,case
语句会自上而下逐一进行评估直到匹配为止。如果 switch
后面没有表达式,他将匹配 true
,因此,你可以将 if-else-if-else
链写成一个 switch
语句,这也更符合 Go
的编码风格。
func unhex(c byte) byte { |
另外,switch
中的 case
子句还可以通过逗号分隔来列举相同的处理条件。如下所示:
func shouldEscape(c byte) bool { |
在 Go
中,switch
中的 case
子句最后会自带 break
的,在匹配成功后会自动跳出整个 switch
。当然,你也可以显示地使用 break
来提前终止 switch
语句。有时你可能想跳出一个嵌套的循环,那么你只需将标签 (label
) 放置到指定的循环外,然后使用 break label
语句即可跳出该循环。如下代码所示:
Loop: |
同样地,continue
语句也可以接受一个可选的标签,不过它只能在循环中使用。
类型判断
switch
也可用于判断接口变量的实际类型,它通过使用括号内带有关键字 type
的类型断言语法来实现。如果在 switch
表达式中声明了一个变量,则该变量会在某个 case
子句中具有对应的类型,你可以在每个 case
子句下重用变量的名称,这相当于重新声明了一个名称相同但类型不同的新变量。
var t interface{} |
函数
多值返回
Go
与众不同的特性之一就是函数和方法可以返回多个值,这种多值返回的形式可以改善 C
语言中一些笨拙的习惯用法,比如返回错误值(例如用 -1
表示 EOF
错误)以及修改通过地址传递的实参。
在 C
语言中,写入操作发生错误时会用一个负数表示,且错误代码会被隐藏在某个易失性的位置。而在 Go
中,写入操作会直接返回写入的字节数和一个错误值,当异常发生时,它会返回一个非 nil
的错误值 err
,且 err
中会包含详细的错误信息以便于查阅。如以下代码所示:
func (file *File) Write(b []byte) (n int, err error) |
另外,通过返回多值,可以避免将变量的地址传递给函数。比如以下函数,它从字节数组的指定位置获取一个整数,并返回了该整数和下一个查询的位置。
func nextInt(b []byte, i int) (int, int) { |
通过将下一个位置返回,就避免了将 i
的地址传递给 nextInt
函数来进行位置记录了。
可命名返回值
Go
中函数的返回值可被命名,并可作为常规变量使用,就像传入的参数一样。这些命名后的返回值会在函数执行时被初始化为与其类型相应的零值。如果该函数执行了一条不带参数的 return
语句,那么命名返回值的当前值将被返回。
返回值的命名不是强制的,但它们可以使代码更加简短清晰,因为它们本身在一定程度上就相当于文档了。比如若对 nextInt
函数的返回值进行命名,就可以很容易的知道每个返回值所代表的具体含义。
func nextInt(b []byte, pos int) (value, nextPos int) { |
由于被命名的返回值已经被初始化,并且已经被绑定到无参数的返回,因此它们会让代码更加简洁和清晰。
Defer
Go
中的 defer
语句用于对延迟执行的函数进行调度,这些被推迟的函数会在调用 defer
的函数返回前立即执行。这是处理特殊情况的一种非比寻常但却十分有效的方法,比如无论函数返回何值都必须释放其占用的资源,典型的例子就是解开互斥锁以及关闭文件描述符等。
推迟执行 Close
之类的函数有两点好处:第一,它可以确保你不会忘记关闭文件,在为函数添加新的返回路径时,忘记关闭文件的情况经常发生;第二,关闭操作离打开操作很近,这比将关闭操作放在函数结尾要更加清晰明了。
被推迟执行函数的参数在 defer
执行时就会被求值,而不是在函数调用时才求值,这样就无需担心变量值在函数执行过程中发生改变。下面是一个简单的例子:
for i := 0; i < 5; i++ { |
因为被延迟的函数是按照后进先出 (LIFO
) 的顺序来执行的,因此上述代码在函数返回时会打印 4 3 2 1 0
。
数据
New 分配
Go
提供了两种内存分配原语,即内建函数 new
和 make
,它们所做的事情不同,所应用的数据类型也不同。
new
是一个用来分配内存的内建函数,但与其它语言中的同名函数不同,它不会初始化内存,只会将内存置零,也就是说 new(T)
操作会为类型为 T
的新项分配一个已置零的内存空间,并返回它的地址,即一个类型为 *T
的值。用 Go
的术语来说,它返回一个指针,该指针指向新分配的、类型为 T
的零值。
因为 new
返回的内存已经被置零,因此在设计数据结构时,类型的零值就不必进一步初始化了,这意味着该数据结构的使用者只需用 new
创建一个新的对象就可正常使用了。例如,Go
标准库 bytes.Buffer
的注释中提到:零值的 Buffer 就是已经准备就绪的缓冲区了
。同样的,sync.Mutex
也没有显示的构造函数和初始化方法,其零值就已经被定义为是一个已解开的互斥锁了,可以直接使用。
零值属性是可传递的。考虑以下类型的声明和使用:
type SyncedBuffer struct { |
SyncedBuffer
类型的值也是在声明时就已分配好内存并可立即使用了,变量 p
和 v
无需进一步的初始化即可正常工作。
构造函数
有时直接使用零值不够友好,这时就需要一个执行初始化操作的构造函数。如标准库 os
包中的这段代码所示,它会返回一个经过初始化的 *File
对象:
func NewFile(fd int, name string) *File { |
上述代码略显冗长,可以使用复合字面量来简化它,复合字面量是一个表达式,该表达式在每次求值时都会创建一个新的实例。如以下代码所示:
func NewFile(fd int, name string) *File { |
注意,与 C
语言不同,在 Go
中返回一个局部变量的地址是完全没问题的,因为 Go
的编译器会自动决定把一个变量是放在栈上还是堆上,而无需担心内存泄露的问题。为了进一步简化上述代码,可以将最后两行代码合并为一行:
return &File{fd, name, nil, 0} |
复合字面量的字段必须按顺序全部列出。但如果以 field:value
的形式明确地标出字段,那么字段就可以按任何顺序出现,其中未指定的字段将被赋予零值。因此你也可以使用如下形式来返回一个复合字面量:
return &File{fd: fd, name: name} |
如果一个复合字面量完全不包含任何字段,它将创建该类型的零值。此时表达式 new(T)
和 &T{}
是等价的。
复合字面量同样可以用于创建数组 (array
)、切片 (slice
) 以及映射 (map
),其中字段标签可以为索引或键,需视具体情况而定。如下所示:
a := [...]string{0: "no error", 1: "error", 2: "invalid argument"} |
Make 分配
再回到内存分配上来,内建函数 make(T, args)
的目的与 new(T)
不同,它只用于创建切片 (slice
)、映射 (map
) 和通道 (channel
),并返回一个类型为 T
(而非 *T
)的已初始化的非零值。出现这种差异的原因在于,这三种类型在本质上均为引用数据类型,它们在使用前必须初始化。例如,切片是一个具有三项内容的描述符,包含指向底层数组的指针、切片的长度以及切片的容量,在这三项内容被初始化之前,该切片为 nil
。
对于切片、映射和通道而言,make
主要用于初始化其内部结构并准备好将要使用的值。例如 make([]int, 10, 100)
会分配一个包含 100
个 int
大小的数组空间,然后创建一个长度为 10
容量为 100
的切片结构,该切片会指向数组中的前 10
个元素。与此相反,new([]int)
会返回一个指向新分配的已置零的切片结构,也就是一个指向 nil
切片值的指针。
下面的例子阐明了 new
和 make
之间的区别:
// allocates slice structure; *p == nil; rarely useful |
请记住,make
只适用于映射、切片和通道且不返回指针。若要获得明确的指针,应该使用 new
来分配内存或通过 &
来显式地获取一个变量的内存地址。
数组
在需要详细地规划内存布局时,数组 (array
) 是非常有用的,有时还能避免过多的内存分配,但在 Go
中它们主要用作切片 (slice
) 的构件。
Go
中的数组与 C
中的数组主要有以下几点区别,在 Go
中:
数组是值,将一个数组赋值给另一个数组会复制其所有的元素。
特别地,如果将数组传递给某个函数,那么该函数将会接收到该数组的一份副本而非指向数组的指针。
数组的大小是其类型的一部分,数组
[10]int
和[20]int
表示不同的类型。
数组为值的属性会很有用,但使用起来代价高昂,当然,你可以向函数中传递数组的地址,但这并不是 Go
的习惯用法,在 Go
中一般会使用切片。
切片
切片 (slice
) 通过对数组进行封装,为数据序列提供了更加通用、更加强大而且更加方便的接口。除了矩阵这类需要明确维度的情况外,Go
中的大部分数组编程都是基于切片来完成的。
切片持有对底层数组的引用,如果你将某个切片赋值给另一个切片,它们会引用同一个底层数组。如果某个函数将一个切片作为参数传入,那么它对该切片中元素的修改对调用者而言同样可见,可以理解为向函数传递了底层数组的指针。
只要切片不超出底层数组的限制,那么它的长度就是可变的,只需将变化后的切片赋值给自身即可。切片的容量表示该切片可获得的最大长度,可通过内建函数 cap
得到。在追加数据到切片中时,如果数据超出其容量,会重新分配一个新的切片,新切片底层数组的地址也会发生相应的变化。下面的 Append
函数实现了向切片中追加数据的操作:
func Append(slice, data []byte) []byte { |
需要注意,该函数必须返回一个切片,这是因为尽管 Append
函数可以修改切片中的元素,但切片本身是通过值传递的,函数中对 slice
进行的修改(重新赋值)对调用者是不可见的。
在 Go
中有专门的内建函数 append
来实现追加操作,上述代码仅用来简单说明。
二维切片
Go
中的数组和切片都是一维的。要创建等价的二维数组或切片,就必须定义一个数组中的数组,或切片中的切片,就像这样:
// A 3x3 array, really an array of arrays. |
因为切片的长度是可变的,因此二维切片的内部可能拥有多个不同长度的切片。如以下代码所示:
text := LinesOfText{ |
对于一个定长的二维切片,常见的初始化方式如下所示:
// Allocate the top-level slice. |
映射
映射 (map
) 是一个方便且强大的内建数据结构,它可以关联不同类型的键和值。其中键可以是定义了相等运算符的任何类型,如整数、浮点数、复数、字符串、指针、接口、结构体以及数组。切片不能用作映射键,因为它们的相等性还未定义。与切片一样,映射也是引用类型,若将映射传入函数中,并修改了映射中内容的话,该修改会对调用者可见。
映射可以使用常规的复合字面量语法来进行构建,其键/值
之间使用冒号分隔,每个键/值对
之间使用逗号分隔。如下所示:
var timeZone = map[string]int{ |
赋值和获取映射值的语法类似于数组,不同的是映射的索引不必为整数。
offset := timeZone["EST"] |
如果使用映射中不存在的键来取值,就会返回该映射的值类型对应的零值。例如,如果某个映射的值类型为整数,那么当查找一个不存在的键时将会返回 0
。集合 (set
) 可以用一个值类型为 bool
的映射来实现,将指定键在映射中对应的值设置为 true
表示将该键加入到了集合中,此后通过简单的索引操作即可判断该键是否存在。如下代码所示:
attended := map[string]bool{ |
有时你需要区分映射中的键是真的不存在还是其值就为零值,因为如果键不存在,那么通过映射获取的结果也会为零值。此时你需要使用多重赋值的返回结果来进行区分。如以下代码所示:
offset, ok := timeZone["EST"] |
在 Go
中,这是一种被称为逗号 ok
的习惯用法。如果存在指定的键,那么 offset
就会被赋予适当的值,且 ok
会被置为 true
;如果键不存在,offset
则会被置为对应的零值,而 ok
会被置为 false
。
若仅需判断映射中是否存在指定的键而不关心实际的值,则可以使用空白标识符 _
来代替该值:
_, ok := timeZone["EST"] |
如果要删除映射中的某一键值对,可以使用内建函数 delete
,它以映射本身以及待删除的键作为参数。即便对应的键不存在,该删除操作也是安全的,不会报错。
delete(timeZone, "EST") |
打印
Go
中采用的格式化打印风格和 C
中的 printf
家族类似,但更加丰富而通用。这些函数均位于 fmt
包下,其函数名的首字母均为大写,包括 fmt.Printf
、fmt.Fprintf
以及 fmt.Sprintf
等等。使用上述函数,均需提供一个格式化的字符串,而其相对应的 Print
和 Println
系列函数则不需要,它们会为接受的每一个参数生成一种默认的格式。另外,在打印时 Println
系列的函数还会在每两个参数之间添加一个空格,并在输出末尾追加一个换行符,而 Print
系列的函数仅在相邻的参数都不是字符串时才会添加空格。使用方式如以下代码所示:
fmt.Printf("Hello %d\n", 23) |
接下来介绍的部分就与 C
中有些不同了。在 Go
中,类似于 %d
这样的数值格式并不会区分数值是否带有符号以及数值的具体大小,打印程序会根据参数的类型来动态地决定这些属性。如以下代码所示:
var x uint64 = 1<<64 - 1 |
如果你只需要打印默认的格式,如用十进制的格式打印整数,则可以使用通用的格式标识符 %v
,它打印的结果与 Print
和 Println
完全相同。此外,%v
格式还能打印其它元素的值,包括且不限于数组、切片、结构体以及映射等。打印映射的例子如以下代码所示:
fmt.Printf("%v\n", timeZone) |
因为映射中的键是无序的,所以打印的结果可能按任意的顺序输出。当打印结构体时,改进的格式 %+v
还会打印结构体中的每个字段名,另外一种格式 %#v
则会完全按照 Go
的语法来打印元素的值。如以下代码所示:
type T struct { |
当打印 string
或 []byte
类型的值时,可以使用 %q
格式来生成带引号的字符串,而另一个格式标识符 %#q
会生成带反引号的字符串。同时,%q
格式也可以用于整数和符文 (rune
),它会生成一个带单引号的符文常量。另外,%x
格式还可用于字符串、字节数组以及整数,它会生成一个很长的十六进制字符串,而带空格的格式 % x
还会在字节之间插入空格。
另一种实用的格式是 %T
,它会打印某个值的类型。如下代码会打印映射的具体类型:
fmt.Printf("%T\n", timeZone) |
如果你想控制自定义类型的默认打印格式,只需为该类型定义一个具有 String() string
签名的方法。对于简单的结构体类型 T
,可通过如下方式来定义 String
方法。
func (t *T) String() string { |
在构造 String
方法时需要注意,不要试图通过 Sprintf
将一个接收者 t
以字符串的形式打印,这会造成 String
方法的循环调用,从而导致栈溢出错误。下面的示例代码是错误的:
func (t *T) String() string { |
Append
Go
中内建函数 append
的定义如以下代码所示:
func append(slice []T, elements ...T) []T |
其中 T
表示任意的数据类型,elements
表示待添加的元素,...
是 Go
语言的一种语法糖,表示可变长度的参数。因为目前 Go
还尚未支持泛型,因此 append
函数需要编译器的支持,这也是它作为内建函数的原因。
append
函数会在切片末尾追加多个元素并返回一个切片,追加元素的切片必须被返回,与上面的分析一样,因为切片指向的底层数组在操作的过程中可能会被改变。以下为简单的使用范例:
x := []int{1,2,3} |
如果你想将一个切片追加到另外一个切片,可以使用 ...
语法来帮助你快速实现,如以下代码所示:
x := []int{1,2,3} |
初始化
从表面上看,Go
中的初始化过程与 C
或 C++
相比并无明显差别,但它确实更为强大。Go
中的初始化过程不仅可以构建复杂的数据结构,还能正确处理不同包的对象之间的初始化顺序。
常量
Go
中的常量就是不变量,它们会在编译时创建并初始化,即使它们是函数中定义的局部变量。常量只能是数字、字符 (runes
)、字符串或者布尔值。由于编译时的限制,定义常量的表达式也必须是可被编译器求值的常量表达式,例如 1<<3
就是一个常量表达式,而 math.Sin(math.Pi/4)
则不是,因为对 math.Sin
函数的调用在程序运行时才会发生。
在 Go
中,枚举常量使用 iota
来创建。因为 iota
可以作为表达式的一部分,而表达式又可以被隐式地重复,所以可以很容易地用它构建复杂的值集合。如以下代码所示:
type ByteSize float64 |
前面介绍过可以通过为用户类型重写 String
方法来自定义该类型的打印格式,尽管你经常看到这种方法会被用于结构体,但它对于像 ByteSize
这种标量类型也同样适用。如下代码所示:
func (b ByteSize) String() string { |
那么此时表达式 YB
将会打印出 1.00YB
,而 ByteSize(1e13)
则会打印出 9.09TB
。
需要注意,尽管这里使用 Sprintf
打印了接收者 b
,但是它不会引起 String
函数无限地递归调用,因为它是以 %f
的格式调用的,而不是字符串格式 %v
。Sprintf
只会在它需要字符串时才调用 String
方法。
变量
变量的初始化与常量类似,但其初始值可以是在运行时才被计算的一般表达式。如以下代码所示:
var ( |
init 函数
在 Go
中,每一个源文件都可以定义一个或多个 init
函数,该函数主要用于初始化一些必要的状态信息。需要注意,只有当源文件所在包中的所有变量都已经初始化了之后,init
函数才会被调用,并且以上这些初始化操作只有在 import
导入的包都被初始化了之后才会执行,而 init
函数的结束就意味着整体的初始化流程完成了。
init
函数除了初始化那些不能被表示成声明的变量外,还常被用于在程序真正执行之前,检验或校正程序的状态。如以下代码所示:
func init() { |
方法
正如上面介绍的 ByteSize
那样,你可以为任何已命名的类型(指针和接口除外)定义方法,并且方法的接收者不必为结构体。
在前面讨论切片时,我们编写了一个 Append
函数。我们也可以将其定义为切片的方法。首先我们需要声明一个已命名的类型来绑定该方法,然后将该类型的值作为方法的接收者。如以下代码所示:
type ByteSlice []byte |
这里我们仍然需要返回该方法更新后的切片。为了解决这种不便,我们可以重新定义该方法,将一个指向 ByteSlice
的指针作为该方法的接收者,这样该方法就能重写调用者提供的切片了。如以下代码所示:
func (p *ByteSlice) Append(data []byte) { |
以指针或值作为方法接收者的区别在于:值方法可以通过指针和值调用,而指针方法只能通过指针来调用。
之所以会有上述规则,是因为指针方法可以修改接收者,通过值调用指针方法会导致方法接收到该值的副本,对值的修改都会体现在副本上,而原值没有任何变化,因此 Go
语言从源头上不允许这种错误存在。不过这种规则有一个例外,就是当值可寻址时,可以直接使用该值调用其指针方法,编译器会为该值自动插入一个取址操作符。如以下代码所示,由于变量 b
是可寻址的,因此可以直接调用 Append
指针方法,编译器会将它重写为 (&b).Append
。
var b ByteSlice |
接口
Go
中的接口提供了一种设置对象行为的方式:如果某个对象可以做这件事,那么就可以在对象中使用它。比如,通过实现 String
方法,你可以自定义对象的打印格式,其中 String
是 Stringer
接口的一个方法,任何对象都可以重写该方法来定义自己的打印行为。
在 Go
的代码中,仅包含一两种方法的接口很常见,且接口名称通常来自于它的方法名,如 io.Writer
接口就是由其方法 Write
加 er
后缀而得名。
Go
中的每种类型都能实现多个接口。例如,为了使用 sort
包的方法对集合进行排序,可以让该集合实现 sort.Interface
接口,该接口包括 Len()
、Less(i, j int) bool
以及 Swap(i, j int)
方法,另外还可以同时为该集合实现 Stringer
接口来自定义打印的格式。如以下代码所示:
type Sequence []int |
类型转换
Sequence
的 String
方法重新实现了切片已有的格式化功能。若我们在调用 Sprint
方法之前将 Sequence
转换为 []int
切片,那么它就能共享切片的已有功能,从而简化代码实现。
func (s Sequence) String() string { |
如果忽略类型名称的话,Sequence
和 []int
其实是相同的,因此在二者间进行转换时合法的,而且转换过程并不会创建新值,只是暂时让现有的值有个新类型而已(有些合法的转换则会创建新值,如整数转为浮点数等)。
在 Go
程序中,为访问不同的方法集而进行类型转换的情况十分常见。例如,你可以使用现有的 sort.IntSlice
类型来简化整个示例:
type Sequence []int |
现在,你不需要为 Sequence
实现多个接口了,你可以将数据转换为多种类型(包括 Sequence
、sort.IntSlice
和 []int
)来使用其对应的功能,每次转换都完成一部分工作,这在实践中虽然有些不同寻常但却很有效。
类型断言
类型选择 (Type switchs
) 是类型转换的一种形式:它接受一个接口,在 switch
语句的每个 case
中,该接口会被转换为对应的类型。以下部分代码展示了 fmt.Printf
是如何通过类型选择来将某个接口值转为字符串的:
type Stringer interface { |
由代码可知,当值本身为字符串类型时,直接返回即可,而如果该值实现了 Stringer
接口,则返回其对应的 String
方法。其中第一种情况获取具体的值,第二种情况则是将一个接口转换为另一个接口,这种方式对于混合的类型来说十分有用。
如果我们知道某个接口对象拥有一个 string
值而想要提取它,单 case
的 switch
语句可以实现,使用类型断言也可行。类型断言会接收一个接口值,并从中提取指定的显示类型的值。其语法借鉴于类型选择的起始语句,但它需要提供一个明确的类型,而非 type
关键字,如以下代码所示:
value.(typeName) |
其结果是具有静态类型 typeName
的新值,typeName
必须是接口所表示的具体类型,或者是该值可以转换为的第二种接口类型。比如若要提取已知值中包含的字符串,可以使用如下代码:
str := value.(string) |
上述代码有一个潜在的 bug
,假设 value
值不是字符串类型,那么程序在运行时就会崩溃。为了避免这种情况,可以使用逗号 ok
的习惯用法来安全地判断该值是否为字符串,如以下代码所示:
str, ok := value.(string) |
此时如果断言失败,程序不会崩溃,str
也将继续存在且依然为字符串类型,但它会具有字符串的零值,也就是空串。
通用性
如果某种类型仅实现了一个接口,并且除接口的方法之外没有任何其它的导出方法,那么该类型本身就无需导出。仅导出接口方法可明确地表明该类型除接口行为外没有其它的额外行为,同时它也避免了在通用方法的每个实例上重复编写文档。
在上述情况下,类型的构造函数应当返回一个接口类型的值,而非具体的实现类型的值。例如,在标准库 hash
包中,crc32.NewIEEE
和 adler32.New
都返回接口类型 hash.Hash32
,如果要在程序中将 Adler-32
算法替换为 CRC-32
算法,仅需要修改所调用的构建函数即可,其余的通用代码则不受影响,因为它们均实现了 Hash32
接口的导出方法。
接口方法
由于几乎任何类型都可以附加方法,因此几乎任何类型都可以满足一个接口的实现。一个很直观的例子就是 http
包中定义的 Handler
接口,任何实现了 Handler
接口的对象都能够处理 HTTP
请求。Handler
接口如以下代码所示:
type Handler interface { |
其中 ResponseWriter
本身是一个接口,它可以响应客户端的请求,Request
是一个结构体,它包含了已经解析的客户端请求。
以下示例中的 Counter
结构体用于记录某个页面被访问的次数,它实现了 Handler
接口。
// Simple counter server. |
通过 http.Handle
方法,可以将 Counter
服务绑定到 URL
树的一个节点上。如以下代码所示:
import "net/http" |
接着在浏览器中访问指定的地址 IP:PORT/counter
即可看到该页面访问次数了。
由于上例中只使用了整数 n
来计数,因此我们就不需要一个完整的 Counter
结构体了,一个整数就足够了。如以下代码所示:
// Simpler counter server. |
有时,你可能需要在接收到请求时去更新一下程序的内部状态,此时你可以使用通道 (channel
) 来完成该操作。如以下代码所示:
// A channel that sends a notification on each visit. |
既然我们可以为除指针和接口以外的任何类型定义方法,同样也能为一个函数定义方法。请看以下代码:
// The HandlerFunc type is an adapter to allow the use of |
其中 HandlerFunc
是一个函数,它实现了 ServeHTTP
方法,因此该类型的值同样可以处理 HTTP
请求。从 ServeHTTP
方法的实现可以看到,其接收者为一个函数 f
,然后在方法内部又调用了函数 f
,它看起来很奇怪,但这与接收者为通道并向通道中发送信息没什么不同。
假设现在有一函数 ArgServer
,它与 HandlerFunc
函数具有相同的签名,你可以将其转换为 HandlerFunc
类型以访问其 ServeHTTP
方法(这与将 Sequence
转换为 IntSlice
以访问 IntSlice.Sort
方法一样),接着你就可使用同样的方式将 ArgServer
绑定到 URL
树的一个节点上提供 HTTP
服务了。如以下代码所示:
// Argument server. |
在本节中,我们通过一个结构体,一个整数,一个通道和一个函数分别建立了一个 HTTP
服务,它们都可以成功地运行,其根本原因在于接口只是方法的集合,而任何类型都可以实现它。
空白标识符
在介绍 for range
循环和映射时已经提过了几次空白标识符 _
,它可以被赋值为或声明为任何类型的任何值,它所代表的值将会被无影响地丢弃,可以将它理解为变量的占位符。
多重赋值
for range
循环中对空白标识符的用法是一种特殊情况,更一般的情况是在多重赋值的场景下使用它。
如果在某次赋值时需要匹配多个左值,但是其中某个变量不会在接下来的程序中用到,那么就可以用空白标识符来代替该变量,这可以避免无用变量的创建,并能清楚地表明该值将被丢弃。例如,当调用某个函数时,它返回了一个值和一个错误,如果只有错误信息重要,那么就可以使用空白标识符来丢弃无关的值。如以下代码所示:
if _, err := os.Stat(path); os.IsNotExist(err) { |
需要注意,不推荐在返回值中忽略错误,这是种糟糕的实践,请务必检查错误,避免出现不必要的程序崩溃。
未使用的导入和变量
在 Go
中,如果导入了某个包或声明了某个变量而不使用它会导致编译错误。未使用的包会让程序膨胀并拖慢编译的速度,已初始化但未使用的变量不仅会浪费计算能力,还可能暗藏着更大的 bug
。
然而在程序开发的过程中,经常会产生未使用的导入和变量,虽然以后会用到它们,但为了完成编译又不得不删除它们,这很让人苦恼,此时空白标识符就提供了一个临时的解决方案。如以下代码所示:
package main |
上述代码中,有两个未使用的导入包 fmt
和 io
以及一个未使用的变量 fd
,为了能够顺利编译,使用空白标识符进行了相应的处理。
按照惯例,使用空白标识符进行全局声明使得错误静默后,应该在其后面加上注释,这样可以让它们更容易被找到并且可以作为以后清理它们的提醒。
为额外的效果而导入
前例中未使用的 fmt
或 io
包最终总会被使用或删除,空白标识符仅代表编码工作尚在进行中。但有时导入某个包只是为了其额外的效果,而没有任何明确的使用。例如,标准库 net/http/pprof
包中的 init
函数注册了提供调试信息的 HTTP
处理程序,它有一个可导出的 API
,但是大部分客户端仅需使用其 HTTP
处理程序,然后通过网页访问相应的指标数据。
想要导入一个只利用其额外效果的包,只需将该包重命名为空白标识符即可。如以下代码所示:
import _ "net/http/pprof" |
这种导入格式能明确表示该包仅是为了其额外的效果而导入的,不会在其它地方使用该包,因为它没有名字。若它有名字而你没有使用它的话,编译器就会报错,从而拒绝编译该程序。
接口检查
就像前面接口一节中讨论的那样,一个类型无需显示地声明它实现了某个接口,只要该类型实现了接口的全部方法,就代表它实现了该接口。在 Go
的源码中,大部分接口转换都是静态的,因此会在程序编译时检测。比如在代码中将一个 *os.File
对象传入一个接收 io.Reader
接口的函数,编译器会直接报错,除非 *os.File
实现了 io.Reader
接口。
在有些情况下,接口检查会在运行时进行。例如,标准库 encoding/json
包中定义了一个 Marshaler
接口,当 JSON
编码器接收到一个实现该接口的对象时,编码器会先通过类型断言来检验该对象是否实现了 Marshaler
接口,检验通过后它会调用该对象的 MarshalJSON
方法来将其转为 JSON
。如以下代码所示:
m, ok := val.(json.Marshaler) |
如果不需要使用类型断言后的值,则可以使用空白标识符来代替。
if _, ok := val.(json.Marshaler); ok { |
内嵌
Go
中并不提供典型的、类型驱动的子类化概念,但是通过将类型内嵌到结构体或者接口中,它可以借鉴子类化的部分实现。
接口内嵌非常简单的。标准库的 io
包中有 Reader
和 Writer
两个接口,这两个接口各自包含一个方法,如以下代码所示:
type Reader interface { |
同时,io
包中也有一些其它的接口,它们可以包含多种方法。比如 ReadWriter
接口,同时包含 Read
和 Write
方法,我们可以显示地在接口中列出这两个方法,但通过将上述两个接口直接内嵌到新接口中显然更方便且更具启发性。如以下代码所示:
// ReadWriter is the interface that combines the Reader and Writer interfaces. |
正如看起来那样,ReadWriter
能够做任何 Reader
和 Writer
可以做到的事情,它是内嵌接口的联合体(内嵌接口的方法集不能重复)。需要注意,只有接口能被嵌入到接口中。
同样的思想可以应用于结构体,但其意义更加深远。标准库 bufio
包中有 Reader
和 Writer
两个结构体,它们每一个都实现了 io
包中对应的接口。此外,bufio
包还通过内嵌的方式将 Reader
和 Writer
组合到一个新的结构体中,从而实现了 ReadWriter
结构体。如以下代码所示:
// ReadWriter stores pointers to a Reader and a Writer. |
注意,在新的结构体中只列出了内嵌结构体的类型,但并未给它们赋予字段名,这是一种推荐的实现方式。
当然你也可以为类型赋予字段名,如以下代码所示:
type ReadWriter struct { |
但是为了提升内部字段的方法并且实现 io
接口,你就需要提供转发的方法,该方法的主体只是对内部对象的方法调用而已。如以下代码所示:
func (rw *ReadWriter) Read(p []byte) (n int, err error) { |
而通过直接内嵌结构体,就可以避免如此繁琐的实现,因为对于外部类型而言,内嵌类型的方法可以直接调用。这意味着,bufio.ReadWriter
不仅包括 bufio.Reader
和 bufio.Writer
的全部方法,它同时还实现了 io.Reader
、io.Writer
以及 io.ReadWriter
等三个接口。
与子类化最大的不同是,当内嵌一个类型时,该类型的方法会成为外部类型的方法,但当它们被调用时,该方法的接收者却是内部类型,而非外部的。也就是说,当 bufio.ReadWriter
的 Read
方法被调用时,接收者是 ReadWriter
的 Reader
字段,而非 ReadWriter
本身,这与上面实现的转发方法具有同样的效果。
考虑以下结构体 Job
,它包含了一个常规的命名字段和一个内嵌字段:
type Job struct { |
Job
类型现在拥有了内部类型 *log.Logger
的方法,一旦 job
对象初始化之后,就能直接调用 Log
方法记录日志了。
job.Log("starting now...") |
因为 Logger
也是 Job
结构体的常规字段,因此在 Job
的构造函数中,可以通过一般的方式来初始化它。如以下代码所示:
func NewJob(command string, logger *log.Logger) *Job { |
或者直接使用复合字面量来初始化:
job := &Job{command, log.New(os.Stderr, "Job: ", log.Ldate)} |
如果你需要直接引用内嵌字段,那么该字段的类型名称将用作字段名(忽略包限定名)。例如,你需要访问 Job
类型的变量 job
的 *log.Logger
字段,可以直接写作 job.Logger
(忽略了 log
包名)。这在需要精炼内嵌类型的方法时,会非常有用。如以下代码所示:
func (job *Job) Logf(format string, args ...interface{}) { |
内嵌类型会引入命名冲突的问题,但解决的规则却很简单。首先,字段或方法 X
会隐藏该类型中更深层嵌套的其它项 X
。例如,若 *log.Logger
包含一个名为 Command
的字段或方法,Job
的 Command
字段会覆盖它。
其次,如果相同的嵌套层级上出现了同名冲突,通常会产生一个错误,例如,若 Job
结构体中包含一个名为 Logger
的字段或方法,再将 *log.Logger
内嵌到其中的话就会产生错误。但是,如果同名的字段永远不会在类型定义之外的程序中使用的话,那就不会出错,这种同名字段一般会出现在相同嵌套层级的不同结构体中。
并发
通过通信共享内存
在并发编程中,实现对共享变量进行正确访问所需的精确控制在很多环境下都很困难。Go
语言另辟蹊径,它将共享的值在通道间传递,并且决不由单独的执行线程主动共享,在任意给定的时间点,只有一个 goroutine
能够访问该值,数据竞争从设计上就被杜绝了。为了鼓励大家采用这种思维方式,Go
语言提出了一个口号:不要通过共享内存来通信,而应通过通信来共享内存
。
Goroutine
Goroutine
是 Go
中最基本的执行单元,命名为 goroutine
的原因是由于现有的术语包括线程以及进程等都无法准确描述它的含义。goroutine
具有简单的模型,它是在同一地址空间中与其它 goroutine
并发执行的函数。goroutine
是轻量级的,所有的消耗只有栈空间的分配,而且栈在起始时非常小,所以它们很廉价,可以在需要时通过分配和释放堆空间来增加栈的大小。
Goroutine
可以在多线程的操作系统上实现多路复用,因此如果一个 goroutine
被阻塞,比如说等待 I/O
,那么其它的 goroutine
将会继续执行。它们的设计隐藏了线程创建和管理的诸多复杂性。
在函数或方法前添加 go
关键字即可创建一个新的 goroutine
,当函数完成时,该 goroutine
也会安静地退出(效果与 Unix Shell
中的 &
符号比较相似,它会让命令在后台运行)。如以下代码所示:
go list.Sort() // run list.Sort concurrently; don't wait for it. |
函数字面量在 goroutine
的调用中非常有用。
func Announce(message string, delay time.Duration) { |
在 Go
中,函数字面量都是闭包的,这可以确保函数所引用的变量与函数本身具有相同的生命周期。
Channels
通道与映射一样,也需要通过 make
来分配内存,其结果值被用作底层数据结构的引用。如果提供一个可选的整数参数,它就会为该通道设置缓冲区的大小,缓冲区默认大小为 0
,表示无缓冲或同步的通道。如以下代码所示:
ci := make(chan int) // unbuffered channel of integers |
无缓冲的通道在通信时会同步交换数据,它能确保两个 goroutine
都处于确定的状态。
通道有很多习惯用法,我们从这里开始逐步了解。在上一节中,我们在后台启动了一个排序的 goroutine
,使用通道可以使得主 goroutine
等待该排序操作完成再退出而非提前退出。如以下代码所示:
// Allocate a channel. |
在上述代码中,接收者在收到数据前会一直阻塞,在收到数据后主 goroutine
随之退出。
如果通道是不带缓冲的,那么在接收者收到值前,发送者会一直阻塞;而如果通道是带缓冲的,那么发送者只有在其值被复制到缓冲区这段时间才会阻塞;如果缓冲区满了,那么发送者会一直等待,直到某个接收者取出一个值为止。
带缓冲的通道可以被用作信号量,比如可以用它来限制吞吐量。在下面的例子中,我们会将传入的请求传递给 handle
函数,它首先会向缓冲的通道发送一个值,以确定当前的请求处理数量没有达到上限,然后开始处理该请求,处理完成后从通道中接收一个值,为处理下一次请求做准备。这里,通道的容量限制了请求处理的并发数。
var sem = make(chan int, MaxOutstanding) |
任务开始后,一旦有 MaxOutstanding
个处理任务进入运行状态,缓冲的通道就会被填满,其它任何尝试向通道发送值的 goroutine
都会被阻塞,直到某个处理任务完成并从缓冲区接收一个值后其它任务才可继续进行。
然而,上述设计有一个问题,那就是尽管只有 MaxOutstanding
个 goroutine
能够同时运行,但 Serve
函数还是会为每个到达的请求创建一个新的 goroutine
,其结果就是,如果请求来的很快,那么该程序就会无限地消耗系统资源从而引发崩溃。为了弥补这种不足,我们可以通过修改 Serve
函数来限制 goroutine
的创建。如以下代码所示:
func Serve(queue chan *Request) { |
上述代码存在一个 bug
,它出现在 for
循环中,由于循环变量 req
在每次迭代时都会被重用,因此这会导致它在所有 goroutine
间共享,这明显是不合理的。为了确保每个 goroutine
处理的 req
都是唯一的,我们可以将 req
作为参数传给 goroutine
的闭包。如以下代码所示:
func Serve(queue chan *Request) { |
另外一种管理资源的好方法就是启动固定数量的 handle goroutine
,让它们一起从 queue
通道中获取请求数据,这样在限制 goroutine
数量的同时也限制了请求处理任务的数量。Serve
函数也会从一个 quit
通道接收退出信息,在启动所有的 goroutine
后,该通道将处于阻塞状态。如以下代码所示:
func handle(queue chan *Request) { |
通道中的通道
Go
中最重要的特性之一就是通道是一等值,它可以像其它值一样进行分配和传递。该特性同样被用于实现安全、并行的多路分解。
上一节的例子中,handle
是一个非常理想化的请求处理程序,我们并没有定义它所处理的请求类型,如果该请求类型包含一个可用于回复的通道,那么每一个客户端都能提供自己的响应方式。如以下代码所示:
type Request struct { |
这里,客户端提供了一个自定义的函数及其参数,以及一个用于接收响应的通道。然后我们修改服务端,让它将计算的结果通过通道返回。如下代码所示:
func handle(queue chan *Request) { |
并行化
使用通道的另一个应用是在多 CPU
核心上实现并行运算。如果计算流程能够被分解为可独立执行的部分,那么它就可以被并行化地执行,每部分的运算完成后可使用通道发送一个完成信号。
让我们看一下以下例子,在对向量中的每一项进行极耗资源的运算时,每项的计算应该独立进行。如以下代码所示:
type Vector []float64 |
在 DoAll
方法的 for
循环中,每个 CPU
将执行一个独立的处理任务,从而实现并行计算。各项处理任务可能会以乱序的顺序完成,但这并不重要,我们只需确保所有任务均完成了即可,这里采用的处理方式是从通道中接收完成信号来统计已完成的任务数。
目前 Go
运行时在默认情况下并不会并行执行上述代码,因为它只为用户层的代码提供了单一的 CPU
处理核心。任意数量的 goroutine
都可能在系统调用的过程中被阻塞,并且任意时刻默认只会有一个 goroutine
能执行用户层代码。如果你希望使用多个 CPU
核心并行处理,就必须告诉运行时你希望有多少 goroutine
能够同时执行代码。有两种方法可以达到这一目的,其一你可以在运行作业前,将环境变量 GOMAXPROCS
的值设置为你要使用的核心数,其二你可以在程序中引入 runtime
包,并调用 runtime.GOMAXPROCS(NCPU)
函数来进行设置,另外你可以通过 runtime.NumCPU()
函数来获取当前系统的逻辑 CPU
的核心数,借助该函数在设置时会更加灵活。
注意不要混淆并发和并行的概念,我们可以将程序构造为可独立执行的各个组件,在并发模式下,各个组件会竞争 CPU
时间片,采取抢占式的调度方式;而在并行的模式下,各个组件会使用不同的 CPU
并行地进行计算,互不干扰。目前 Go
语言仍然是一种并发的而非并行的语言,并且 Go
的模型并不适用于所有的并行问题。
漏桶缓冲区
使用并发编程工具还能够轻易地表达非并发的思想。这里有一个提取自 RPC
包的例子,客户端 goroutine
会从某些来源(可能是网络)循环接收数据,为了避免频繁分配和释放缓冲区,它保存了一个空闲链表,该链表使用带缓冲的通道表示。如果通道为空,就会分配新的缓冲区。一旦消息缓冲区就绪,会通过 serverChan
通道将该消息发送给服务器端。如以下代码所示:
var freeList = make(chan *Buffer, 100) |
服务器会从客户端循环接收消息,处理它们,处理完成后会将缓冲区返回给空闲链表。如以下代码所示:
func server() { |
客户端试图从空闲链表中获取可用的缓冲区,如果没有缓冲区可用,它将会分配一个新的缓冲区。服务端在处理完之后会将缓冲区放回空闲链表,如果链表已满,则此缓冲区将被丢弃,并被垃圾回收器回收(select
语句中的 default
子句会在没有可匹配的 case
时执行,也就意味着 select
语句永远不会被阻塞)。如上所述,我们仅依靠带缓冲的通道和垃圾回收器就实现了一个漏桶缓冲区。
错误
如前文所述,Go
语言的多值返回特性使得它可以在返回常规值的同时提供一个详细的错误描述信息,这是一种良好的编码风格。
按照 Go
中的约定,错误的类型通常为 error
,它是一个简单的内建接口。如下所示:
type error interface { |
函数库的作者可以轻松地实现这个接口来提供丰富的自定义错误信息。例如,标准库 os
包中的 Open
函数,除了返回一个 *os.File
对象,同时还返回了一个 error
值。若文件被成功打开,error
值为 nil
,如果出了问题,它会返回一个 os.PathError
错误。如以下代码所示:
// PathError records an error and the operation and |
PathError
的 Error
方法会生成如下错误信息:
open /etc/passwx: no such file or directory |
该错误中包含了具体的操作、出问题的文件名以及触发的系统错误,这比简单的不存在该文件或目录
信息更具说明性。
错误字符串应尽可能地指明它们的来源,它可以包含产生该错误的具体操作或包名前缀。例如在 image
包中,由于未知格式导致解码错误的错误信息为image: unknown format
。
如果调用者关心完整的错误细节,可以使用类型选择或类型断言来查看指定错误的具体细节。如以下代码所示:
for try := 0; try < 2; try++ { |
上述代码的第二条 if
语句表示类型断言,如果断言失败了,ok
会被置为 false
,而 e
则为 nil
,如果断言成功,则 ok
会被置为 true
,也就意味着该错误属于 *os.PathError
类型,e
也会被置为对应的错误值,此时,通过 e
你可以获取关于该错误的更多信息。
Panic
向调用者报告错误的一般方式就是将 error
作为一个额外的值返回,但如果错误是不可恢复的呢,此时我们应该终止程序的运行。为此,Go
提供了一个内建的函数 panic
,它会产生一个运行时错误并终止程序运行。panic
函数接收一个任意类型的参数(一般为字符串),以便在程序终止时打印出来。它还能表明发生了意料之外的事情,比如从无限循环中退出了。
但在实际的库函数中应该尽量避免使用 panic
,如果问题可以被屏蔽或解决,最好让程序继续运行下去而不是终止整个程序。当然也有一些特殊情况,比如在程序初始化的过程中,如果程序无法满足指定的条件,那么直接 panic
也是可以理解的。如以下代码所示:
var user = os.Getenv("USER") |
Recover
当 panic
被调用后(包括隐式的运行时错误,如切片索引越界或类型断言失败),程序会立即终止当前函数的执行,并开始回溯 goroutine
的函数栈,然后运行被推迟的函数,如果回溯到函数栈的顶端,整个程序就会终止。不过我们可以使用内建的 recover
函数来重新取回 goroutine
的控制权并使其恢复正常运行。
调用 recover
将停止回溯流程,并返回传入 panic
的参数值。由于在回溯时只有被推迟的函数才会执行,因此 recover
操作只有在被推迟的函数中才会生效。
recover
的一个应用就是终止服务器内部失败的 goroutine
,而不杀死其它正常执行的 goroutine
。如以下代码所示:
func server(workChan <-chan *Work) { |
在上述代码中,如果 do(work)
函数触发了 panic
,其错误信息将会被记录,相应的 goroutine
会干净利落地退出而不会影响到其它 goroutine
。
我们可以利用 panic-recover
思想来简化复杂软件设计中的错误处理。请看以下正则表达式解析的例子:
// Error is the type of a parse error; it satisfies the error interface. |
如果 doParse
方法发生错误,它会通过调用 regexp
对象的 error
方法触发一个 Error
类型的 panic
,然后在回溯被推迟函数时,recover
块会将返回值 regexp
设置为 nil
,同时通过类型断言的方式为返回值 err
赋值(被推迟的函数能够修改命名的返回值)。如果 panic
的错误类型不为 Error
,那么类型断言将会失败,从而引发另一个运行时错误,并继续回溯函数栈,仿佛一切都没有中断过一样。同时,该类型检查也意味着如果 panic
是由于切片索引越界等错误导致的,那么即便使用了 recover
来进行错误处理,程序仍然会失败。
尽管上述模式很有用,但它应当仅在包内使用。在编写包内的 API
时,应该将其中的 panic
调用转为相应的 error
值并返回给调用者,而非直接暴露 panic
,这是一个值得遵守的良好编码规则。
另外,这种重新触发 panic
的习惯用法会在产生运行时错误时改变 panic
的值,不过不用担心,无论是原始的错误还是新的错误都会在程序的崩溃报告中显示,因此问题的根源仍然是可见的,并不影响后续的问题分析与处理。
参考资料
- 高效 Go 语言编程
- 高效 Go 语言编程中译版