逑识

吾生也有涯,而知也无涯,以无涯奉有涯,其易欤?

0%

高效 Go 语言编程

导语」Go 是一门全新的编程语言,它从既有的编程语言中借鉴了许多理念,但其与众不同的特性使得 Go 程序在本质上就与其它语言有所不同。要想写好 Go 程序,就必须了解其特性和风格,以及 Go 语言中已建立的各种约定,如命名、格式化和程序结构等,这样你编写的 Go 程序才能更容易被其他程序员所理解。

格式化

Go 中,一般使用 gofmt 来对程序代码进行格式化操作。gofmt 会将 Go 程序按照 Go 的标准风格进行缩进、对齐。因此在编写代码时,你无需花额外的时间进行格式调整,gofmt 将会为你代劳。例如以下未格式化的代码:

type T struct {
name string // name of the object
value int // its value
}

通过 gofmt 格式化后字段、字段类型以及注释信息都会自动对齐:

type T struct {
name string // name of the object
value int // its value
}

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
// MustCompile is like Compile but panics if the expression cannot be parsed.
// parsed. It simplifies safe initialization of global variables holding
func Compile(str string) (*Regexp, error) {

文档注释以名称开头的好处是你可以使用 go doc 命令以及 grep 操作来根据关键字获取对象或方法的名称。例如,若你没有记住 Compile 这个函数名称,而又在寻找正则表达式解析函数时,那就可以运行以下命令:

$ go doc -all regexp | grep -i parse
Compile parses a regular expression and returns, if successful, a Regexp
MustCompile is like Compile but panics if the expression cannot be parsed.
parsed. It simplifies safe initialization of global variables holding

根据过滤查询的结果,你就知道应该使用 Compile 函数来完成相应的操作了。

Go 的语法允许成组声明,如果这组声明中的对象所表达的含义比较接近或者对象间具有相关性,那么你就可以对该组声明仅使用单个文档注释,这样可以避免为多个同质的对象分别写注释所带来的不便。如以下代码所示:

// Error codes returned by failures to parse an expression.
var (
ErrInternal = errors.New("regexp: internal error")
ErrUnmatchedLpar = errors.New("regexp: unmatched '('")
ErrUnmatchedRpar = errors.New("regexp: unmatched ')'")
...
)

命名

包名

当一个包被导入后,包名就会成为其内部成员的访问器。例如,在通过 import bytes 引入了 bytes 包后,就可以使用 bytes.Buffer 来访问其内部成员 Buffer 了。如果每个调用者都能以一致的包名去引用包的内部成员,那么代码看起来会更清晰,且不会产生混淆,因此包应该有一个恰当的名称:简短明了且易于理解。

按照 Go 的约定,包应当以小写的单个单词来命名,不需要使用下划线或驼峰命名法。另外,包名仅是导入时的默认名称,它不必在所有的源码中保持唯一,即便在少数发生包名冲突的情况下,也可为导入的包指定一个别名来局部使用它。在任何情况下,包名都不会让调用者产生混淆,因为可以通过 import 引用的文件名来获知当前正在使用哪个包。

另一个约定就是包名应为其源码目录的基本名称。例如。在 src/encoding/base64 目录下的包其包名应为 base64,而非 encoding_base64encodingBase64。在使用该包时,应以 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 后缀来命名,如 ReaderWriterFormatterCloseNotifier 等。

诸如此类的命名还有很多,遵循这种规范会让事情变得简单明了。需要注意,ReadWriteCloseFlushString 等名称都具有典型的签名和意义,为避免出现混淆,尽量不要使用这些名称为你的方法命名,除非你的方法具有相同的签名和意义。反之,如果你所实现的方法与一个众所周知的方法具有相同的含义,那么就应该使用与已知方法名相同的名称,比如字符串转换方法应命名为 String 而非 ToString

MixedCaps

Go 中约定使用驼峰的方式(如 MixedCapsmixedCaps),而非下划线的方式(如 mixed_caps)来对多个单词进行命名。

分号

C 语言一样,Go 的正式语法中也会使用分号来结束语句,但与 C 语言不同的是,这些分号并不会在源码中显示地出现,而是由词法分析器根据一些简单的规则来自动插入,因此在写代码时你可以省略这些分号。

词法分析器插入分号的规则可以概括为:如果新行前的最后一个标记可以结束该段语句,则插入分号。具体而言,如果新行前的最后一个标记为标识符(包括 intfloat64 这类单词)、数值或字符串常量之类的基本字面量或者为以下的标记之一:

break continue fallthrough return ++ -- ) }

那么词法分析器将会在该标记后面插入分号。

通常 Go 程序只在诸如 for 循环这样的子句中显示地使用分号,以便将初始化操作,循环执行条件以及元素增量操作区分开来。另外,如果你在一行中包含了多个语句,也需要使用分号将它们隔开。

需要注意的是,你不能将一个控制结构(ifforswitchselect)的左大括号放在下一行,如果这样做,词法分析器会在大括号前插入一个分号,这会导致编译错误。你应该这样写:

if i < f() {
g()
}

而非这样:

if i < f()  // wrong!
{ // wrong!
g()
}

控制结构

Go 中的控制结构与 C 语言有许多相似之处,但在某些方面也有所不同。Go 中不再包含 dowhile 循环,只有一个更为通用的 for 循环;switch 也要更加灵活;ifswitch 可以像 for 一样接受一个可选的初始化语句;breakcontinue 语句可接受一个可选的标签来标识要终止什么或继续什么;此外,还有一个包含类型选择和多路通信复用器的新控制结构 select。另外,Go 中的控制结构在语法上也与 C 语言有些许不同,它没有圆括号,并且它的主体必须始终用大括号括住。

If

Go 中,一个简单的 if 语句如以下代码所示:

if x > 0 {
return y
}

强制的大括号促使你将简单的 if 语句分为多行编写,这是一种很好的编码风格,尤其是当主体包含 returnbreak 等控制语句时。

由于 ifswitch 可接受初始化语句,因此用它们来设置局部变量会较为常见。

if err := file.Chmod(0664); err != nil {
log.Print(err)
return err
}

Go 的源码中,你会发现如果 if 的主体不会执行到下一条语句时,不必要的 else 会被省略。例如,其主体以 breakcontinuegotoreturn 等语句结束时后面无需再接 else,这也是 Go 中推荐使用的一种编码风格。

f, err := os.Open(name)
if err != nil {
return err
}
codeUsing(f)

重声明与再赋值

请看下面的代码示例:

f, err := os.Open(name)
if err != nil {
return err
}

d, err := f.Stat()
if err != nil {
f.Close()
return err
}
codeUsing(f, d)

其中 :=Go 语言中的短声明操作符。

上述代码通过调用 os.Open 函数声明了两个变量 ferr,在几行之后,它又调用了 f.Stat 函数,该函数看起来似乎也声明了两个变量 derr,理论上讲 err 被声明了两次是不合法的,但在这里却没有问题,这是因为第二条语句中的 err 并不是被再次声明了,它只是被重新赋值而已。

在满足以下条件时,已被声明的变量 v 可以再次出现在 := 的左侧:

  1. 本次声明与已声明的变量 v 处于同一作用域中(若 v 在外层作用域中声明过,则此次声明会创建一个新的变量)。

  2. 与初始化类型相同的值才能被赋值给 v

  3. 在此次声明中至少另有一个变量是新声明的。

这个特性就是 Go 语言纯粹的实用主义的体现。基于此,你可以在一个很长的 if-else 链中,很方便地使用一个 err 变量来进行各种判断。

For

Go 中的 for 循环类似于 C 语言,但却不尽相同,它统一了 forwhile,且不再有 do-while 循环了。

Go 中的 for 循环有以下三种形式,只有一种需要使用分号:

// Like a C for
for init; condition; post { }

// Like a C while
for condition { }

// Like a C for(;;)
for { }

短声明操作符可以使我们更容易在 for 循环中声明索引变量。如下代码所示:

sum := 0
for i := 0; i < 10; i++ {
sum += i
}

如果你想遍历数组、切片、字符串或映射,或者从通道中读取信息,range 子句能帮你轻松实现:

// Range slice
for index, value := range slice {}
// Range string
for index, rune := range str {}
// Range map
for key, value := range oldMap {
newMap[key] = value
}
// Range channel
for value := range channel {}

如果你只需要 range 遍历中的第一项(键或索引),去掉第二项就可以了:

for key := range m {
if key.expired() {
delete(m, key)
}
}

如果你只需要 range 遍历中的第二项(值),可使用空白标识符 _ 代替第一项,表示舍弃该项:

sum := 0
for _, value := range array {
sum += value
}

Go 中没有逗号操作符(逗号操作符是为了把几个表达式放在一起),而且 ++-- 操作为语句而非表达式(语句和表达式的区别在于前者不能被求值并赋值给其它变量,而后者是可以的)。因此,如果你需要在 for 循环中使用多个变量,那么在进行元素增量操作时,应采用平行赋值的方式来完成(因为它会拒绝使用 ++--)。如以下代码所示:

// Reverse a
for i, j := 0, len(a)-1; i < j; i, j = i+1, j-1 {
a[i], a[j] = a[j], a[i]
}

Switch

Go 中的 switchC 中的更为通用,其表达式无需为常量或整数,case 语句会自上而下逐一进行评估直到匹配为止。如果 switch 后面没有表达式,他将匹配 true,因此,你可以将 if-else-if-else 链写成一个 switch 语句,这也更符合 Go 的编码风格。

func unhex(c byte) byte {
switch {
case '0' <= c && c <= '9':
return c - '0'
case 'a' <= c && c <= 'f':
return c - 'a' + 10
case 'A' <= c && c <= 'F':
return c - 'A' + 10
}
return 0
}

另外,switch 中的 case 子句还可以通过逗号分隔来列举相同的处理条件。如下所示:

func shouldEscape(c byte) bool {
switch c {
case ' ', '?', '&', '=', '#', '+', '%':
return true
}
return false
}

Go 中,switch 中的 case 子句最后会自带 break 的,在匹配成功后会自动跳出整个 switch。当然,你也可以显示地使用 break 来提前终止 switch 语句。有时你可能想跳出一个嵌套的循环,那么你只需将标签 (label) 放置到指定的循环外,然后使用 break label 语句即可跳出该循环。如下代码所示:

Loop:
for n := 0; n < len(src); n += size {
switch {
case src[n] < sizeOne:
if validateOnly {
break
}
size = 1
update(src[n])

case src[n] < sizeTwo:
if n+1 >= len(src) {
err = errShortInput
break Loop
}
if validateOnly {
break
}
size = 2
update(src[n] + src[n+1]<<shift)
}
}

同样地,continue 语句也可以接受一个可选的标签,不过它只能在循环中使用。

类型判断

switch 也可用于判断接口变量的实际类型,它通过使用括号内带有关键字 type 的类型断言语法来实现。如果在 switch 表达式中声明了一个变量,则该变量会在某个 case 子句中具有对应的类型,你可以在每个 case 子句下重用变量的名称,这相当于重新声明了一个名称相同但类型不同的新变量。

var t interface{}
t = functionOfSomeType()
switch t := t.(type) {
default:
fmt.Printf("unexpected type %T", t) // %T prints whatever type t has
case bool:
fmt.Printf("boolean %t\n", t) // t has type bool
case int:
fmt.Printf("integer %d\n", t) // t has type int
case *bool:
fmt.Printf("pointer to boolean %t\n", *t) // t has type *bool
case *int:
fmt.Printf("pointer to integer %d\n", *t) // t has type *int
}

函数

多值返回

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) {
for ; i < len(b) && !isDigit(b[i]); i++ {
}
x := 0
for ; i < len(b) && isDigit(b[i]); i++ {
x = x*10 + int(b[i]) - '0'
}
return x, i
}

通过将下一个位置返回,就避免了将 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++ {
defer fmt.Printf("%d ", i)
}

因为被延迟的函数是按照后进先出 (LIFO) 的顺序来执行的,因此上述代码在函数返回时会打印 4 3 2 1 0

数据

New 分配

Go 提供了两种内存分配原语,即内建函数 newmake,它们所做的事情不同,所应用的数据类型也不同。

new 是一个用来分配内存的内建函数,但与其它语言中的同名函数不同,它不会初始化内存,只会将内存置零,也就是说 new(T) 操作会为类型为 T 的新项分配一个已置零的内存空间,并返回它的地址,即一个类型为 *T 的值。用 Go 的术语来说,它返回一个指针,该指针指向新分配的、类型为 T 的零值。

因为 new 返回的内存已经被置零,因此在设计数据结构时,类型的零值就不必进一步初始化了,这意味着该数据结构的使用者只需用 new 创建一个新的对象就可正常使用了。例如,Go 标准库 bytes.Buffer 的注释中提到:零值的 Buffer 就是已经准备就绪的缓冲区了。同样的,sync.Mutex 也没有显示的构造函数和初始化方法,其零值就已经被定义为是一个已解开的互斥锁了,可以直接使用。

零值属性是可传递的。考虑以下类型的声明和使用:

type SyncedBuffer struct {
lock sync.Mutex
buffer bytes.Buffer
}

p := new(SyncedBuffer) // type *SyncedBuffer
var v SyncedBuffer // type SyncedBuffer

SyncedBuffer 类型的值也是在声明时就已分配好内存并可立即使用了,变量 pv 无需进一步的初始化即可正常工作。

构造函数

有时直接使用零值不够友好,这时就需要一个执行初始化操作的构造函数。如标准库 os 包中的这段代码所示,它会返回一个经过初始化的 *File 对象:

func NewFile(fd int, name string) *File {
if fd < 0 {
return nil
}
f := new(File)
f.fd = fd
f.name = name
f.dirinfo = nil
f.nepipe = 0
return f
}

上述代码略显冗长,可以使用复合字面量来简化它,复合字面量是一个表达式,该表达式在每次求值时都会创建一个新的实例。如以下代码所示:

func NewFile(fd int, name string) *File {
if fd < 0 {
return nil
}
f := File{fd, name, nil, 0}
return &f
}

注意,与 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"}
s := []string{0: "no error", 1: "error", 2: "invalid argument"}
m := map[int]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) 会分配一个包含 100int 大小的数组空间,然后创建一个长度为 10 容量为 100 的切片结构,该切片会指向数组中的前 10 个元素。与此相反,new([]int) 会返回一个指向新分配的已置零的切片结构,也就是一个指向 nil 切片值的指针。

下面的例子阐明了 newmake 之间的区别:

// allocates slice structure; *p == nil; rarely useful
var p *[]int = new([]int)
// the slice v now refers to a new array of 100 ints
var v []int = make([]int, 100)

// Unnecessarily complex:
var p *[]int = new([]int)
*p = make([]int, 100, 100)

// Idiomatic:
v := make([]int, 100)

请记住,make 只适用于映射、切片和通道且不返回指针。若要获得明确的指针,应该使用 new 来分配内存或通过 & 来显式地获取一个变量的内存地址。

数组

在需要详细地规划内存布局时,数组 (array) 是非常有用的,有时还能避免过多的内存分配,但在 Go 中它们主要用作切片 (slice) 的构件。

Go 中的数组与 C 中的数组主要有以下几点区别,在 Go 中:

  1. 数组是值,将一个数组赋值给另一个数组会复制其所有的元素。

  2. 特别地,如果将数组传递给某个函数,那么该函数将会接收到该数组的一份副本而非指向数组的指针。

  3. 数组的大小是其类型的一部分,数组 [10]int[20]int 表示不同的类型。

数组为值的属性会很有用,但使用起来代价高昂,当然,你可以向函数中传递数组的地址,但这并不是 Go 的习惯用法,在 Go 中一般会使用切片。

切片

切片 (slice) 通过对数组进行封装,为数据序列提供了更加通用、更加强大而且更加方便的接口。除了矩阵这类需要明确维度的情况外,Go 中的大部分数组编程都是基于切片来完成的。

切片持有对底层数组的引用,如果你将某个切片赋值给另一个切片,它们会引用同一个底层数组。如果某个函数将一个切片作为参数传入,那么它对该切片中元素的修改对调用者而言同样可见,可以理解为向函数传递了底层数组的指针。

只要切片不超出底层数组的限制,那么它的长度就是可变的,只需将变化后的切片赋值给自身即可。切片的容量表示该切片可获得的最大长度,可通过内建函数 cap 得到。在追加数据到切片中时,如果数据超出其容量,会重新分配一个新的切片,新切片底层数组的地址也会发生相应的变化。下面的 Append 函数实现了向切片中追加数据的操作:

func Append(slice, data []byte) []byte {
l := len(slice)
// reallocate
if l+len(data) > cap(slice) {
// Allocate double what's needed, for future growth.
newSlice := make([]byte, (l+len(data))*2)
// The copy function is predeclared and works for any slice type.
copy(newSlice, slice)
slice = newSlice
}
slice = slice[0 : l+len(data)]
for i, c := range data {
slice[l+i] = c
}
return slice
}

需要注意,该函数必须返回一个切片,这是因为尽管 Append 函数可以修改切片中的元素,但切片本身是通过值传递的,函数中对 slice 进行的修改(重新赋值)对调用者是不可见的。

Go 中有专门的内建函数 append 来实现追加操作,上述代码仅用来简单说明。

二维切片

Go 中的数组和切片都是一维的。要创建等价的二维数组或切片,就必须定义一个数组中的数组,或切片中的切片,就像这样:

// A 3x3 array, really an array of arrays.
type Transform [3][3]float64
// A slice of byte slices.
type LinesOfText [][]byte

因为切片的长度是可变的,因此二维切片的内部可能拥有多个不同长度的切片。如以下代码所示:

text := LinesOfText{
[]byte("Now is the time"),
[]byte("for all good gophers"),
[]byte("to bring some fun to the party."),
}

对于一个定长的二维切片,常见的初始化方式如下所示:

// Allocate the top-level slice.
matrix := make([][]int, 10)
// Loop over the rows, allocating the slice for each row.
for i := range matrix {
matrix[i] = make([]int, 10)
}

映射

映射 (map) 是一个方便且强大的内建数据结构,它可以关联不同类型的键和值。其中键可以是定义了相等运算符的任何类型,如整数、浮点数、复数、字符串、指针、接口、结构体以及数组。切片不能用作映射键,因为它们的相等性还未定义。与切片一样,映射也是引用类型,若将映射传入函数中,并修改了映射中内容的话,该修改会对调用者可见。

映射可以使用常规的复合字面量语法来进行构建,其键/值之间使用冒号分隔,每个键/值对之间使用逗号分隔。如下所示:

var timeZone = map[string]int{
"UTC": 0*60*60,
"EST": -5*60*60,
"CST": -6*60*60,
"MST": -7*60*60,
"PST": -8*60*60,
}

赋值和获取映射值的语法类似于数组,不同的是映射的索引不必为整数。

offset := timeZone["EST"]

如果使用映射中不存在的键来取值,就会返回该映射的值类型对应的零值。例如,如果某个映射的值类型为整数,那么当查找一个不存在的键时将会返回 0。集合 (set) 可以用一个值类型为 bool 的映射来实现,将指定键在映射中对应的值设置为 true 表示将该键加入到了集合中,此后通过简单的索引操作即可判断该键是否存在。如下代码所示:

attended := map[string]bool{
"Ann": true,
"Joe": true,
...
}

// will be false if person is not in the map
if attended[person] {
fmt.Println(person, "was at the meeting")
}

有时你需要区分映射中的键是真的不存在还是其值就为零值,因为如果键不存在,那么通过映射获取的结果也会为零值。此时你需要使用多重赋值的返回结果来进行区分。如以下代码所示:

offset, ok := timeZone["EST"]

Go 中,这是一种被称为逗号 ok的习惯用法。如果存在指定的键,那么 offset 就会被赋予适当的值,且 ok 会被置为 true;如果键不存在,offset 则会被置为对应的零值,而 ok 会被置为 false

若仅需判断映射中是否存在指定的键而不关心实际的值,则可以使用空白标识符 _ 来代替该值:

_, ok := timeZone["EST"]

如果要删除映射中的某一键值对,可以使用内建函数 delete,它以映射本身以及待删除的键作为参数。即便对应的键不存在,该删除操作也是安全的,不会报错。

delete(timeZone, "EST")

打印

Go 中采用的格式化打印风格和 C 中的 printf 家族类似,但更加丰富而通用。这些函数均位于 fmt 包下,其函数名的首字母均为大写,包括 fmt.Printffmt.Fprintf 以及 fmt.Sprintf 等等。使用上述函数,均需提供一个格式化的字符串,而其相对应的 PrintPrintln 系列函数则不需要,它们会为接受的每一个参数生成一种默认的格式。另外,在打印时 Println 系列的函数还会在每两个参数之间添加一个空格,并在输出末尾追加一个换行符,而 Print 系列的函数仅在相邻的参数都不是字符串时才会添加空格。使用方式如以下代码所示:

fmt.Printf("Hello %d\n", 23)
fmt.Fprint(os.Stdout, "Hello ", 23, "\n")
fmt.Println("Hello", 23)
fmt.Println(fmt.Sprint("Hello ", 23))

接下来介绍的部分就与 C 中有些不同了。在 Go 中,类似于 %d 这样的数值格式并不会区分数值是否带有符号以及数值的具体大小,打印程序会根据参数的类型来动态地决定这些属性。如以下代码所示:

var x uint64 = 1<<64 - 1
fmt.Printf("%d %x; %d %x\n", x, x, int64(x), int64(x))
// output
18446744073709551615 ffffffffffffffff; -1 -1

如果你只需要打印默认的格式,如用十进制的格式打印整数,则可以使用通用的格式标识符 %v,它打印的结果与 PrintPrintln 完全相同。此外,%v 格式还能打印其它元素的值,包括且不限于数组、切片、结构体以及映射等。打印映射的例子如以下代码所示:

fmt.Printf("%v\n", timeZone)
// or just fmt.Println(timeZone)
fmt.Println(timeZone)
// output
map[CST:-21600 PST:-28800 EST:-18000 UTC:0 MST:-25200]

因为映射中的键是无序的,所以打印的结果可能按任意的顺序输出。当打印结构体时,改进的格式 %+v 还会打印结构体中的每个字段名,另外一种格式 %#v 则会完全按照 Go 的语法来打印元素的值。如以下代码所示:

type T struct {
a int
b float64
c string
}
t := &T{ 7, -2.35, "abc\tdef" }
fmt.Printf("%v\n", t)
fmt.Printf("%+v\n", t)
fmt.Printf("%#v\n", t)
fmt.Printf("%#v\n", timeZone)
// output
&{7 -2.35 abc def}
&{a:7 b:-2.35 c:abc def}
&main.T{a:7, b:-2.35, c:"abc\tdef"}
map[string]int{"CST":-21600, "PST":-28800, "EST":-18000, "UTC":0, "MST":-25200}

当打印 string[]byte 类型的值时,可以使用 %q 格式来生成带引号的字符串,而另一个格式标识符 %#q 会生成带反引号的字符串。同时,%q 格式也可以用于整数和符文 (rune),它会生成一个带单引号的符文常量。另外,%x 格式还可用于字符串、字节数组以及整数,它会生成一个很长的十六进制字符串,而带空格的格式 % x 还会在字节之间插入空格。

另一种实用的格式是 %T,它会打印某个值的类型。如下代码会打印映射的具体类型:

fmt.Printf("%T\n", timeZone)
// output
map[string]int

如果你想控制自定义类型的默认打印格式,只需为该类型定义一个具有 String() string 签名的方法。对于简单的结构体类型 T,可通过如下方式来定义 String 方法。

func (t *T) String() string {
return fmt.Sprintf("%d/%g/%q", t.a, t.b, t.c)
}
fmt.Printf("%v\n", t)
// output
7/-2.35/"abc\tdef"

在构造 String 方法时需要注意,不要试图通过 Sprintf 将一个接收者 t 以字符串的形式打印,这会造成 String 方法的循环调用,从而导致栈溢出错误。下面的示例代码是错误的:

func (t *T) String() string {
// Error: will recur forever.
return fmt.Sprintf("%v", t)
}
fmt.Printf("%v\n", t)

Append

Go 中内建函数 append 的定义如以下代码所示:

func append(slice []T, elements ...T) []T

其中 T 表示任意的数据类型,elements 表示待添加的元素,...Go 语言的一种语法糖,表示可变长度的参数。因为目前 Go 还尚未支持泛型,因此 append 函数需要编译器的支持,这也是它作为内建函数的原因。

append 函数会在切片末尾追加多个元素并返回一个切片,追加元素的切片必须被返回,与上面的分析一样,因为切片指向的底层数组在操作的过程中可能会被改变。以下为简单的使用范例:

x := []int{1,2,3}
x = append(x, 4, 5, 6)
fmt.Println(x)
// output
[1 2 3 4 5 6]

如果你想将一个切片追加到另外一个切片,可以使用 ... 语法来帮助你快速实现,如以下代码所示:

x := []int{1,2,3}
y := []int{4,5,6}
x = append(x, y...)
fmt.Println(x)
// output
[1 2 3 4 5 6]

初始化

从表面上看,Go 中的初始化过程与 CC++ 相比并无明显差别,但它确实更为强大。Go 中的初始化过程不仅可以构建复杂的数据结构,还能正确处理不同包的对象之间的初始化顺序。

常量

Go 中的常量就是不变量,它们会在编译时创建并初始化,即使它们是函数中定义的局部变量。常量只能是数字、字符 (runes)、字符串或者布尔值。由于编译时的限制,定义常量的表达式也必须是可被编译器求值的常量表达式,例如 1<<3 就是一个常量表达式,而 math.Sin(math.Pi/4) 则不是,因为对 math.Sin 函数的调用在程序运行时才会发生。

Go 中,枚举常量使用 iota 来创建。因为 iota 可以作为表达式的一部分,而表达式又可以被隐式地重复,所以可以很容易地用它构建复杂的值集合。如以下代码所示:

type ByteSize float64

const (
// ignore first value by assigning to blank identifier
_ = iota
KB ByteSize = 1 << (10 * iota)
MB
GB
TB
PB
EB
ZB
YB
)

前面介绍过可以通过为用户类型重写 String 方法来自定义该类型的打印格式,尽管你经常看到这种方法会被用于结构体,但它对于像 ByteSize 这种标量类型也同样适用。如下代码所示:

func (b ByteSize) String() string {
switch {
case b >= YB:
return fmt.Sprintf("%.2fYB", b/YB)
case b >= ZB:
return fmt.Sprintf("%.2fZB", b/ZB)
case b >= EB:
return fmt.Sprintf("%.2fEB", b/EB)
case b >= PB:
return fmt.Sprintf("%.2fPB", b/PB)
case b >= TB:
return fmt.Sprintf("%.2fTB", b/TB)
case b >= GB:
return fmt.Sprintf("%.2fGB", b/GB)
case b >= MB:
return fmt.Sprintf("%.2fMB", b/MB)
case b >= KB:
return fmt.Sprintf("%.2fKB", b/KB)
}
return fmt.Sprintf("%.2fB", b)
}

那么此时表达式 YB 将会打印出 1.00YB,而 ByteSize(1e13) 则会打印出 9.09TB

需要注意,尽管这里使用 Sprintf 打印了接收者 b,但是它不会引起 String 函数无限地递归调用,因为它是以 %f 的格式调用的,而不是字符串格式 %vSprintf 只会在它需要字符串时才调用 String 方法。

变量

变量的初始化与常量类似,但其初始值可以是在运行时才被计算的一般表达式。如以下代码所示:

var (
home = os.Getenv("HOME")
user = os.Getenv("USER")
gopath = os.Getenv("GOPATH")
)

init 函数

Go 中,每一个源文件都可以定义一个或多个 init 函数,该函数主要用于初始化一些必要的状态信息。需要注意,只有当源文件所在包中的所有变量都已经初始化了之后,init函数才会被调用,并且以上这些初始化操作只有在 import 导入的包都被初始化了之后才会执行,而 init 函数的结束就意味着整体的初始化流程完成了。

init 函数除了初始化那些不能被表示成声明的变量外,还常被用于在程序真正执行之前,检验或校正程序的状态。如以下代码所示:

func init() {
if user == "" {
log.Fatal("$USER not set")
}
if home == "" {
home = "/home/" + user
}
if gopath == "" {
gopath = home + "/go"
}
// gopath may be overridden by --gopath flag on command line.
flag.StringVar(&gopath, "gopath", gopath, "override default GOPATH")
}

方法

正如上面介绍的 ByteSize 那样,你可以为任何已命名的类型(指针和接口除外)定义方法,并且方法的接收者不必为结构体。

在前面讨论切片时,我们编写了一个 Append 函数。我们也可以将其定义为切片的方法。首先我们需要声明一个已命名的类型来绑定该方法,然后将该类型的值作为方法的接收者。如以下代码所示:

type ByteSlice []byte

func (slice ByteSlice) Append(data []byte) []byte {
// Body exactly the same as above
}

这里我们仍然需要返回该方法更新后的切片。为了解决这种不便,我们可以重新定义该方法,将一个指向 ByteSlice 的指针作为该方法的接收者,这样该方法就能重写调用者提供的切片了。如以下代码所示:

func (p *ByteSlice) Append(data []byte) {
slice := *p
// Body as above, without the return.
*p = slice
}

以指针或值作为方法接收者的区别在于:值方法可以通过指针和值调用,而指针方法只能通过指针来调用。

之所以会有上述规则,是因为指针方法可以修改接收者,通过值调用指针方法会导致方法接收到该值的副本,对值的修改都会体现在副本上,而原值没有任何变化,因此 Go 语言从源头上不允许这种错误存在。不过这种规则有一个例外,就是当值可寻址时,可以直接使用该值调用其指针方法,编译器会为该值自动插入一个取址操作符。如以下代码所示,由于变量 b 是可寻址的,因此可以直接调用 Append 指针方法,编译器会将它重写为 (&b).Append

var b ByteSlice
b.Append(data)

接口

Go 中的接口提供了一种设置对象行为的方式:如果某个对象可以做这件事,那么就可以在对象中使用它。比如,通过实现 String 方法,你可以自定义对象的打印格式,其中 StringStringer 接口的一个方法,任何对象都可以重写该方法来定义自己的打印行为。

Go 的代码中,仅包含一两种方法的接口很常见,且接口名称通常来自于它的方法名,如 io.Writer 接口就是由其方法 Writeer 后缀而得名。

Go 中的每种类型都能实现多个接口。例如,为了使用 sort 包的方法对集合进行排序,可以让该集合实现 sort.Interface 接口,该接口包括 Len()Less(i, j int) bool 以及 Swap(i, j int) 方法,另外还可以同时为该集合实现 Stringer 接口来自定义打印的格式。如以下代码所示:

type Sequence []int

// Methods required by sort.Interface.
func (s Sequence) Len() int {
return len(s)
}
func (s Sequence) Less(i, j int) bool {
return s[i] < s[j]
}
func (s Sequence) Swap(i, j int) {
s[i], s[j] = s[j], s[i]
}

// Method for printing - sorts the elements before printing.
func (s Sequence) String() string {
sort.Sort(s)
str := "["
for i, elem := range s {
if i > 0 {
str += " "
}
str += fmt.Sprint(elem)
}
return str + "]"
}

类型转换

SequenceString 方法重新实现了切片已有的格式化功能。若我们在调用 Sprint 方法之前将 Sequence 转换为 []int 切片,那么它就能共享切片的已有功能,从而简化代码实现。

func (s Sequence) String() string {
sort.Sort(s)
return fmt.Sprint([]int(s))
}

如果忽略类型名称的话,Sequence[]int 其实是相同的,因此在二者间进行转换时合法的,而且转换过程并不会创建新值,只是暂时让现有的值有个新类型而已(有些合法的转换则会创建新值,如整数转为浮点数等)。

Go 程序中,为访问不同的方法集而进行类型转换的情况十分常见。例如,你可以使用现有的 sort.IntSlice 类型来简化整个示例:

type Sequence []int

// Method for printing - sorts the elements before printing
func (s Sequence) String() string {
sort.IntSlice(s).Sort()
return fmt.Sprint([]int(s))
}

现在,你不需要为 Sequence 实现多个接口了,你可以将数据转换为多种类型(包括 Sequencesort.IntSlice[]int)来使用其对应的功能,每次转换都完成一部分工作,这在实践中虽然有些不同寻常但却很有效。

类型断言

类型选择 (Type switchs) 是类型转换的一种形式:它接受一个接口,在 switch 语句的每个 case 中,该接口会被转换为对应的类型。以下部分代码展示了 fmt.Printf 是如何通过类型选择来将某个接口值转为字符串的:

type Stringer interface {
String() string
}

var value interface{} // Value provided by caller.
switch str := value.(type) {
case string:
return str
case Stringer:
return str.String()
}

由代码可知,当值本身为字符串类型时,直接返回即可,而如果该值实现了 Stringer 接口,则返回其对应的 String 方法。其中第一种情况获取具体的值,第二种情况则是将一个接口转换为另一个接口,这种方式对于混合的类型来说十分有用。

如果我们知道某个接口对象拥有一个 string 值而想要提取它,单 caseswitch 语句可以实现,使用类型断言也可行。类型断言会接收一个接口值,并从中提取指定的显示类型的值。其语法借鉴于类型选择的起始语句,但它需要提供一个明确的类型,而非 type 关键字,如以下代码所示:

value.(typeName)

其结果是具有静态类型 typeName 的新值,typeName 必须是接口所表示的具体类型,或者是该值可以转换为的第二种接口类型。比如若要提取已知值中包含的字符串,可以使用如下代码:

str := value.(string)

上述代码有一个潜在的 bug,假设 value 值不是字符串类型,那么程序在运行时就会崩溃。为了避免这种情况,可以使用逗号 ok的习惯用法来安全地判断该值是否为字符串,如以下代码所示:

str, ok := value.(string)
if ok {
fmt.Printf("string value is: %q\n", str)
} else {
fmt.Printf("value is not a string\n")
}

此时如果断言失败,程序不会崩溃,str 也将继续存在且依然为字符串类型,但它会具有字符串的零值,也就是空串。

通用性

如果某种类型仅实现了一个接口,并且除接口的方法之外没有任何其它的导出方法,那么该类型本身就无需导出。仅导出接口方法可明确地表明该类型除接口行为外没有其它的额外行为,同时它也避免了在通用方法的每个实例上重复编写文档。

在上述情况下,类型的构造函数应当返回一个接口类型的值,而非具体的实现类型的值。例如,在标准库 hash 包中,crc32.NewIEEEadler32.New 都返回接口类型 hash.Hash32,如果要在程序中将 Adler-32 算法替换为 CRC-32 算法,仅需要修改所调用的构建函数即可,其余的通用代码则不受影响,因为它们均实现了 Hash32 接口的导出方法。

接口方法

由于几乎任何类型都可以附加方法,因此几乎任何类型都可以满足一个接口的实现。一个很直观的例子就是 http 包中定义的 Handler 接口,任何实现了 Handler 接口的对象都能够处理 HTTP 请求。Handler 接口如以下代码所示:

type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}

其中 ResponseWriter 本身是一个接口,它可以响应客户端的请求,Request 是一个结构体,它包含了已经解析的客户端请求。

以下示例中的 Counter 结构体用于记录某个页面被访问的次数,它实现了 Handler 接口。

// Simple counter server.
type Counter struct {
n int
}

func (ctr *Counter) ServeHTTP(w http.ResponseWriter, req *http.Request) {
ctr.n++
fmt.Fprintf(w, "counter = %d\n", ctr.n)
}

通过 http.Handle 方法,可以将 Counter 服务绑定到 URL 树的一个节点上。如以下代码所示:

import "net/http"
...
ctr := new(Counter)
http.Handle("/counter", ctr)

接着在浏览器中访问指定的地址 IP:PORT/counter 即可看到该页面访问次数了。

由于上例中只使用了整数 n 来计数,因此我们就不需要一个完整的 Counter 结构体了,一个整数就足够了。如以下代码所示:

// Simpler counter server.
type Counter int

func (ctr *Counter) ServeHTTP(w http.ResponseWriter, req *http.Request) {
*ctr++
fmt.Fprintf(w, "counter = %d\n", *ctr)
}

有时,你可能需要在接收到请求时去更新一下程序的内部状态,此时你可以使用通道 (channel) 来完成该操作。如以下代码所示:

// A channel that sends a notification on each visit.
// (Probably want the channel to be buffered.)
type Chan chan *http.Request

func (ch Chan) ServeHTTP(w http.ResponseWriter, req *http.Request) {
ch <- req
fmt.Fprint(w, "notification sent")
}

既然我们可以为除指针和接口以外的任何类型定义方法,同样也能为一个函数定义方法。请看以下代码:

// The HandlerFunc type is an adapter to allow the use of
// ordinary functions as HTTP handlers. If f is a function
// with the appropriate signature, HandlerFunc(f) is a
// Handler object that calls f.
type HandlerFunc func(ResponseWriter, *Request)

// ServeHTTP calls f(c, req).
func (f HandlerFunc) ServeHTTP(w ResponseWriter, req *Request) {
f(w, req)
}

其中 HandlerFunc 是一个函数,它实现了 ServeHTTP 方法,因此该类型的值同样可以处理 HTTP 请求。从 ServeHTTP 方法的实现可以看到,其接收者为一个函数 f,然后在方法内部又调用了函数 f,它看起来很奇怪,但这与接收者为通道并向通道中发送信息没什么不同。

假设现在有一函数 ArgServer,它与 HandlerFunc 函数具有相同的签名,你可以将其转换为 HandlerFunc 类型以访问其 ServeHTTP 方法(这与将 Sequence 转换为 IntSlice 以访问 IntSlice.Sort 方法一样),接着你就可使用同样的方式将 ArgServer 绑定到 URL 树的一个节点上提供 HTTP 服务了。如以下代码所示:

// Argument server.
func ArgServer(w http.ResponseWriter, req *http.Request) {
fmt.Fprintln(w, os.Args)
}
http.Handle("/args", http.HandlerFunc(ArgServer))

在本节中,我们通过一个结构体,一个整数,一个通道和一个函数分别建立了一个 HTTP 服务,它们都可以成功地运行,其根本原因在于接口只是方法的集合,而任何类型都可以实现它。

空白标识符

在介绍 for range 循环和映射时已经提过了几次空白标识符 _,它可以被赋值为或声明为任何类型的任何值,它所代表的值将会被无影响地丢弃,可以将它理解为变量的占位符。

多重赋值

for range 循环中对空白标识符的用法是一种特殊情况,更一般的情况是在多重赋值的场景下使用它。

如果在某次赋值时需要匹配多个左值,但是其中某个变量不会在接下来的程序中用到,那么就可以用空白标识符来代替该变量,这可以避免无用变量的创建,并能清楚地表明该值将被丢弃。例如,当调用某个函数时,它返回了一个值和一个错误,如果只有错误信息重要,那么就可以使用空白标识符来丢弃无关的值。如以下代码所示:

if _, err := os.Stat(path); os.IsNotExist(err) {
fmt.Printf("%s does not exist\n", path)
}

需要注意,不推荐在返回值中忽略错误,这是种糟糕的实践,请务必检查错误,避免出现不必要的程序崩溃。

未使用的导入和变量

Go 中,如果导入了某个包或声明了某个变量而不使用它会导致编译错误。未使用的包会让程序膨胀并拖慢编译的速度,已初始化但未使用的变量不仅会浪费计算能力,还可能暗藏着更大的 bug

然而在程序开发的过程中,经常会产生未使用的导入和变量,虽然以后会用到它们,但为了完成编译又不得不删除它们,这很让人苦恼,此时空白标识符就提供了一个临时的解决方案。如以下代码所示:

package main

import (
"fmt"
"io"
"log"
"os"
)

var _ = fmt.Printf // For debugging; delete when done.
var _ io.Reader // For debugging; delete when done.

func main() {
fd, err := os.Open("test.go")
if err != nil {
log.Fatal(err)
}
// TODO: use fd.
_ = fd
}

上述代码中,有两个未使用的导入包 fmtio 以及一个未使用的变量 fd,为了能够顺利编译,使用空白标识符进行了相应的处理。

按照惯例,使用空白标识符进行全局声明使得错误静默后,应该在其后面加上注释,这样可以让它们更容易被找到并且可以作为以后清理它们的提醒。

为额外的效果而导入

前例中未使用的 fmtio 包最终总会被使用或删除,空白标识符仅代表编码工作尚在进行中。但有时导入某个包只是为了其额外的效果,而没有任何明确的使用。例如,标准库 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)
// Marshal to json.
b, err := m.MarshalJSON()

如果不需要使用类型断言后的值,则可以使用空白标识符来代替。

if _, ok := val.(json.Marshaler); ok {
fmt.Printf("value %v of type %T implements json.Marshaler\n", val, val)
}

内嵌

Go 中并不提供典型的、类型驱动的子类化概念,但是通过将类型内嵌到结构体或者接口中,它可以借鉴子类化的部分实现。

接口内嵌非常简单的。标准库的 io 包中有 ReaderWriter 两个接口,这两个接口各自包含一个方法,如以下代码所示:

type Reader interface {
Read(p []byte) (n int, err error)
}

type Writer interface {
Write(p []byte) (n int, err error)
}

同时,io 包中也有一些其它的接口,它们可以包含多种方法。比如 ReadWriter 接口,同时包含 ReadWrite 方法,我们可以显示地在接口中列出这两个方法,但通过将上述两个接口直接内嵌到新接口中显然更方便且更具启发性。如以下代码所示:

// ReadWriter is the interface that combines the Reader and Writer interfaces.
type ReadWriter interface {
Reader
Writer
}

正如看起来那样,ReadWriter 能够做任何 ReaderWriter 可以做到的事情,它是内嵌接口的联合体(内嵌接口的方法集不能重复)。需要注意,只有接口能被嵌入到接口中。

同样的思想可以应用于结构体,但其意义更加深远。标准库 bufio 包中有 ReaderWriter 两个结构体,它们每一个都实现了 io 包中对应的接口。此外,bufio 包还通过内嵌的方式将 ReaderWriter 组合到一个新的结构体中,从而实现了 ReadWriter 结构体。如以下代码所示:

// ReadWriter stores pointers to a Reader and a Writer.
// It implements io.ReadWriter.
type ReadWriter struct {
*Reader // *bufio.Reader
*Writer // *bufio.Writer
}

注意,在新的结构体中只列出了内嵌结构体的类型,但并未给它们赋予字段名,这是一种推荐的实现方式。

当然你也可以为类型赋予字段名,如以下代码所示:

type ReadWriter struct {
reader *Reader
writer *Writer
}

但是为了提升内部字段的方法并且实现 io 接口,你就需要提供转发的方法,该方法的主体只是对内部对象的方法调用而已。如以下代码所示:

func (rw *ReadWriter) Read(p []byte) (n int, err error) {
return rw.reader.Read(p)
}

而通过直接内嵌结构体,就可以避免如此繁琐的实现,因为对于外部类型而言,内嵌类型的方法可以直接调用。这意味着,bufio.ReadWriter 不仅包括 bufio.Readerbufio.Writer 的全部方法,它同时还实现了 io.Readerio.Writer 以及 io.ReadWriter 等三个接口。

与子类化最大的不同是,当内嵌一个类型时,该类型的方法会成为外部类型的方法,但当它们被调用时,该方法的接收者却是内部类型,而非外部的。也就是说,当 bufio.ReadWriterRead 方法被调用时,接收者是 ReadWriterReader 字段,而非 ReadWriter 本身,这与上面实现的转发方法具有同样的效果。

考虑以下结构体 Job,它包含了一个常规的命名字段和一个内嵌字段:

type Job struct {
Command string
*log.Logger
}

Job 类型现在拥有了内部类型 *log.Logger 的方法,一旦 job 对象初始化之后,就能直接调用 Log 方法记录日志了。

job.Log("starting now...")

因为 Logger 也是 Job 结构体的常规字段,因此在 Job 的构造函数中,可以通过一般的方式来初始化它。如以下代码所示:

func NewJob(command string, logger *log.Logger) *Job {
return &Job{command, logger}
}

或者直接使用复合字面量来初始化:

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{}) {
job.Logger.Logf("%q: %s", job.Command, fmt.Sprintf(format, args...))
}

内嵌类型会引入命名冲突的问题,但解决的规则却很简单。首先,字段或方法 X 会隐藏该类型中更深层嵌套的其它项 X。例如,若 *log.Logger 包含一个名为 Command 的字段或方法,JobCommand 字段会覆盖它。

其次,如果相同的嵌套层级上出现了同名冲突,通常会产生一个错误,例如,若 Job 结构体中包含一个名为 Logger 的字段或方法,再将 *log.Logger 内嵌到其中的话就会产生错误。但是,如果同名的字段永远不会在类型定义之外的程序中使用的话,那就不会出错,这种同名字段一般会出现在相同嵌套层级的不同结构体中。

并发

通过通信共享内存

在并发编程中,实现对共享变量进行正确访问所需的精确控制在很多环境下都很困难。Go 语言另辟蹊径,它将共享的值在通道间传递,并且决不由单独的执行线程主动共享,在任意给定的时间点,只有一个 goroutine 能够访问该值,数据竞争从设计上就被杜绝了。为了鼓励大家采用这种思维方式,Go 语言提出了一个口号:不要通过共享内存来通信,而应通过通信来共享内存

Goroutine

GoroutineGo 中最基本的执行单元,命名为 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 func() {
time.Sleep(delay)
fmt.Println(message)
}() // Note the parentheses - must call the function.
}

Go 中,函数字面量都是闭包的,这可以确保函数所引用的变量与函数本身具有相同的生命周期。

Channels

通道与映射一样,也需要通过 make 来分配内存,其结果值被用作底层数据结构的引用。如果提供一个可选的整数参数,它就会为该通道设置缓冲区的大小,缓冲区默认大小为 0,表示无缓冲或同步的通道。如以下代码所示:

ci := make(chan int)            // unbuffered channel of integers
cj := make(chan int, 0) // unbuffered channel of integers
cs := make(chan *os.File, 100) // buffered channel of pointers to Files

无缓冲的通道在通信时会同步交换数据,它能确保两个 goroutine 都处于确定的状态。

通道有很多习惯用法,我们从这里开始逐步了解。在上一节中,我们在后台启动了一个排序的 goroutine,使用通道可以使得主 goroutine 等待该排序操作完成再退出而非提前退出。如以下代码所示:

// Allocate a channel.
c := make(chan int)
// Start the sort in a goroutine; when it completes, signal on the channel.
go func() {
list.Sort()
// Send a signal; value does not matter.
c <- 1
}()
doSomethingForAWhile()
// Wait for sort to finish; discard sent value.
<-c

在上述代码中,接收者在收到数据前会一直阻塞,在收到数据后主 goroutine 随之退出。

如果通道是不带缓冲的,那么在接收者收到值前,发送者会一直阻塞;而如果通道是带缓冲的,那么发送者只有在其值被复制到缓冲区这段时间才会阻塞;如果缓冲区满了,那么发送者会一直等待,直到某个接收者取出一个值为止。

带缓冲的通道可以被用作信号量,比如可以用它来限制吞吐量。在下面的例子中,我们会将传入的请求传递给 handle 函数,它首先会向缓冲的通道发送一个值,以确定当前的请求处理数量没有达到上限,然后开始处理该请求,处理完成后从通道中接收一个值,为处理下一次请求做准备。这里,通道的容量限制了请求处理的并发数。

var sem = make(chan int, MaxOutstanding)

func handle(r *Request) {
sem <- 1 // Wait for active queue to drain.
process(r) // May take a long time.
<-sem // Done; enable next request to run.
}

func Serve(queue chan *Request) {
for {
req := <-queue
go handle(req) // Don't wait for handle to finish.
}
}

任务开始后,一旦有 MaxOutstanding 个处理任务进入运行状态,缓冲的通道就会被填满,其它任何尝试向通道发送值的 goroutine 都会被阻塞,直到某个处理任务完成并从缓冲区接收一个值后其它任务才可继续进行。

然而,上述设计有一个问题,那就是尽管只有 MaxOutstandinggoroutine 能够同时运行,但 Serve 函数还是会为每个到达的请求创建一个新的 goroutine,其结果就是,如果请求来的很快,那么该程序就会无限地消耗系统资源从而引发崩溃。为了弥补这种不足,我们可以通过修改 Serve 函数来限制 goroutine 的创建。如以下代码所示:

func Serve(queue chan *Request) {
for req := range queue {
sem <- 1
go func() {
process(req) // Buggy; see explanation below.
<-sem
}()
}
}

上述代码存在一个 bug,它出现在 for 循环中,由于循环变量 req 在每次迭代时都会被重用,因此这会导致它在所有 goroutine 间共享,这明显是不合理的。为了确保每个 goroutine 处理的 req 都是唯一的,我们可以将 req 作为参数传给 goroutine 的闭包。如以下代码所示:

func Serve(queue chan *Request) {
for req := range queue {
sem <- 1
go func(req *Request) {
process(req)
<-sem
}(req)
}
}

另外一种管理资源的好方法就是启动固定数量的 handle goroutine,让它们一起从 queue 通道中获取请求数据,这样在限制 goroutine 数量的同时也限制了请求处理任务的数量。Serve 函数也会从一个 quit 通道接收退出信息,在启动所有的 goroutine 后,该通道将处于阻塞状态。如以下代码所示:

func handle(queue chan *Request) {
for r := range queue {
process(r)
}
}

func Serve(clientRequests chan *Request, quit chan bool) {
// Start handlers
for i := 0; i < MaxOutstanding; i++ {
go handle(clientRequests)
}
<-quit // Wait to be told to exit.
}

通道中的通道

Go 中最重要的特性之一就是通道是一等值,它可以像其它值一样进行分配和传递。该特性同样被用于实现安全、并行的多路分解。

上一节的例子中,handle 是一个非常理想化的请求处理程序,我们并没有定义它所处理的请求类型,如果该请求类型包含一个可用于回复的通道,那么每一个客户端都能提供自己的响应方式。如以下代码所示:

type Request struct {
args []int
f func([]int) int
resultChan chan int
}

func sum(a []int) (s int) {
for _, v := range a {
s += v
}
return
}

request := &Request{[]int{3, 4, 5}, sum, make(chan int)}
// Send request
clientRequests <- request
// Wait for response.
fmt.Printf("answer: %d\n", <-request.resultChan)

这里,客户端提供了一个自定义的函数及其参数,以及一个用于接收响应的通道。然后我们修改服务端,让它将计算的结果通过通道返回。如下代码所示:

func handle(queue chan *Request) {
for req := range queue {
req.resultChan <- req.f(req.args)
}
}

并行化

使用通道的另一个应用是在多 CPU 核心上实现并行运算。如果计算流程能够被分解为可独立执行的部分,那么它就可以被并行化地执行,每部分的运算完成后可使用通道发送一个完成信号。

让我们看一下以下例子,在对向量中的每一项进行极耗资源的运算时,每项的计算应该独立进行。如以下代码所示:

type Vector []float64

// Apply the operation to v[i], v[i+1] ... up to v[n-1].
func (v Vector) DoSome(i, n int, u Vector, c chan int) {
for ; i < n; i++ {
v[i] += u.Op(v[i])
}
c <- 1 // signal that this piece is done
}

const NCPU = 4 // number of CPU cores

func (v Vector) DoAll(u Vector) {
c := make(chan int, NCPU) // Buffering optional but sensible.
for i := 0; i < NCPU; i++ {
go v.DoSome(i*len(v)/NCPU, (i+1)*len(v)/NCPU, u, c)
}
// Drain the channel.
for i := 0; i < NCPU; i++ {
<-c // wait for one task to complete
}
// All done.
}

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)
var serverChan = make(chan *Buffer)

func client() {
for {
var b *Buffer
// Grab a buffer if available; allocate if not.
select {
case b = <-freeList:
// Got one; nothing more to do.
default:
// None free, so allocate a new one.
b = new(Buffer)
}
load(b) // Read next message from the net.
serverChan <- b // Send to server.
}
}

服务器会从客户端循环接收消息,处理它们,处理完成后会将缓冲区返回给空闲链表。如以下代码所示:

func server() {
for {
b := <-serverChan // Wait for work.
process(b)
// Reuse buffer if there's room.
select {
case freeList <- b:
// Buffer on free list; nothing more to do.
default:
// Free list full, just carry on.
}
}
}

客户端试图从空闲链表中获取可用的缓冲区,如果没有缓冲区可用,它将会分配一个新的缓冲区。服务端在处理完之后会将缓冲区放回空闲链表,如果链表已满,则此缓冲区将被丢弃,并被垃圾回收器回收(select 语句中的 default 子句会在没有可匹配的 case时执行,也就意味着 select 语句永远不会被阻塞)。如上所述,我们仅依靠带缓冲的通道和垃圾回收器就实现了一个漏桶缓冲区。

错误

如前文所述,Go 语言的多值返回特性使得它可以在返回常规值的同时提供一个详细的错误描述信息,这是一种良好的编码风格。

按照 Go 中的约定,错误的类型通常为 error,它是一个简单的内建接口。如下所示:

type error interface {
Error() string
}

函数库的作者可以轻松地实现这个接口来提供丰富的自定义错误信息。例如,标准库 os 包中的 Open 函数,除了返回一个 *os.File 对象,同时还返回了一个 error 值。若文件被成功打开,error 值为 nil,如果出了问题,它会返回一个 os.PathError 错误。如以下代码所示:

// PathError records an error and the operation and
// file path that caused it.
type PathError struct {
Op string // "open", "unlink", etc.
Path string // The associated file.
Err error // Returned by the system call.
}

func (e *PathError) Error() string {
return e.Op + " " + e.Path + ": " + e.Err.Error()
}

PathErrorError 方法会生成如下错误信息:

open /etc/passwx: no such file or directory

该错误中包含了具体的操作、出问题的文件名以及触发的系统错误,这比简单的不存在该文件或目录信息更具说明性。

错误字符串应尽可能地指明它们的来源,它可以包含产生该错误的具体操作或包名前缀。例如在 image 包中,由于未知格式导致解码错误的错误信息为image: unknown format

如果调用者关心完整的错误细节,可以使用类型选择或类型断言来查看指定错误的具体细节。如以下代码所示:

for try := 0; try < 2; try++ {
file, err = os.Create(filename)
if err == nil {
return
}
if e, ok := err.(*os.PathError); ok && e.Err == syscall.ENOSPC {
deleteTempFiles() // Recover some space.
continue
}
return
}

上述代码的第二条 if 语句表示类型断言,如果断言失败了,ok 会被置为 false,而 e 则为 nil,如果断言成功,则 ok 会被置为 true,也就意味着该错误属于 *os.PathError 类型,e 也会被置为对应的错误值,此时,通过 e 你可以获取关于该错误的更多信息。

Panic

向调用者报告错误的一般方式就是将 error 作为一个额外的值返回,但如果错误是不可恢复的呢,此时我们应该终止程序的运行。为此,Go 提供了一个内建的函数 panic,它会产生一个运行时错误并终止程序运行。panic 函数接收一个任意类型的参数(一般为字符串),以便在程序终止时打印出来。它还能表明发生了意料之外的事情,比如从无限循环中退出了。

但在实际的库函数中应该尽量避免使用 panic,如果问题可以被屏蔽或解决,最好让程序继续运行下去而不是终止整个程序。当然也有一些特殊情况,比如在程序初始化的过程中,如果程序无法满足指定的条件,那么直接 panic 也是可以理解的。如以下代码所示:

var user = os.Getenv("USER")

func init() {
if user == "" {
panic("no value for $USER")
}
}

Recover

panic 被调用后(包括隐式的运行时错误,如切片索引越界或类型断言失败),程序会立即终止当前函数的执行,并开始回溯 goroutine 的函数栈,然后运行被推迟的函数,如果回溯到函数栈的顶端,整个程序就会终止。不过我们可以使用内建的 recover 函数来重新取回 goroutine 的控制权并使其恢复正常运行。

调用 recover 将停止回溯流程,并返回传入 panic 的参数值。由于在回溯时只有被推迟的函数才会执行,因此 recover 操作只有在被推迟的函数中才会生效。

recover 的一个应用就是终止服务器内部失败的 goroutine,而不杀死其它正常执行的 goroutine。如以下代码所示:

func server(workChan <-chan *Work) {
for work := range workChan {
go safelyDo(work)
}
}

func safelyDo(work *Work) {
defer func() {
if err := recover(); err != nil {
log.Println("work failed:", err)
}
}()
do(work)
}

在上述代码中,如果 do(work) 函数触发了 panic,其错误信息将会被记录,相应的 goroutine 会干净利落地退出而不会影响到其它 goroutine

我们可以利用 panic-recover 思想来简化复杂软件设计中的错误处理。请看以下正则表达式解析的例子:

// Error is the type of a parse error; it satisfies the error interface.
type Error string
func (e Error) Error() string {
return string(e)
}

// error is a method of *Regexp that reports parsing errors by
// panicking with an Error.
func (regexp *Regexp) error(err string) {
panic(Error(err))
}

// Compile returns a parsed representation of the regular expression.
func Compile(str string) (regexp *Regexp, err error) {
regexp = new(Regexp)
// doParse will panic if there is a parse error.
defer func() {
if e := recover(); e != nil {
regexp = nil // Clear return value.
err = e.(Error) // Will re-panic if not a parse error.
}
}()
return regexp.doParse(str), nil
}

如果 doParse 方法发生错误,它会通过调用 regexp 对象的 error 方法触发一个 Error 类型的 panic,然后在回溯被推迟函数时,recover 块会将返回值 regexp 设置为 nil,同时通过类型断言的方式为返回值 err 赋值(被推迟的函数能够修改命名的返回值)。如果 panic 的错误类型不为 Error,那么类型断言将会失败,从而引发另一个运行时错误,并继续回溯函数栈,仿佛一切都没有中断过一样。同时,该类型检查也意味着如果 panic 是由于切片索引越界等错误导致的,那么即便使用了 recover 来进行错误处理,程序仍然会失败。

尽管上述模式很有用,但它应当仅在包内使用。在编写包内的 API 时,应该将其中的 panic 调用转为相应的 error 值并返回给调用者,而非直接暴露 panic,这是一个值得遵守的良好编码规则。

另外,这种重新触发 panic 的习惯用法会在产生运行时错误时改变 panic 的值,不过不用担心,无论是原始的错误还是新的错误都会在程序的崩溃报告中显示,因此问题的根源仍然是可见的,并不影响后续的问题分析与处理。

参考资料

  1. 高效 Go 语言编程
  2. 高效 Go 语言编程中译版

欢迎关注我的其它发布渠道