高效Go编程

注意:本文档撰写于2009年Go语言发布时,并未持续更新。虽然它对于使用核心语言仍是很好的指南,但并未涵盖此后语言本身(泛型)、生态系统(模块)或新增库的重要变更。相关背景请参阅issue 28782。 完整变更列表请参阅发布说明

引言

Go是一门开源编程语言,它专注于简洁性、可靠性和高效性,专为轻松构建大规模软件而设计。尽管它借鉴了现有语言的思想,但其独特特性使得高效Go程序与它亲属语言编写的程序在本质上有显著不同。将C++或Java程序直接翻译成Go不太可能得到令人满意的结果——Java程序是用Java编写的,而非Go。另一方面,从Go的视角思考问题则可能产生成功但风格迥异的程序。换言之,要写好Go,理解其特性和惯用法至关重要。了解Go编程中既定的约定(如命名、格式化、程序结构等)也同样重要,这样你编写的程序才能让其他Go程序员易于理解。

本文档提供编写清晰、地道Go代码的技巧。它作为语言规范Go语言之旅如何编写Go代码的补充,你应该先阅读这些内容。

示例

Go包源码不仅旨在作为核心库,也作为如何使用该语言的示例。此外,许多包包含可运行的、独立的可执行示例,你可以直接从go.dev网站运行,例如这个示例(如有必要,请点击“Example”一词展开)。如果你对如何处理某个问题或某物如何实现有疑问,库中的文档、代码和示例可以提供答案、思路和背景。

格式化

格式化问题争议最大,但实际影响最小。人们可以适应不同的格式化风格,但最好无需如此;如果每个人都遵循同一风格,就能减少在此话题上耗费的时间。问题在于如何在没有冗长规范风格指南的情况下接近这一理想状态。

在Go中,我们采取了一种不寻常的方法,让机器处理大多数格式化问题。gofmt程序(也可作为go fmt使用,后者在包级别而非源文件级别操作)读取Go程序,并以标准的缩进和垂直对齐风格输出源代码,保留并在必要时重新格式化注释。如果你想知道如何处理某种新的布局情况,运行gofmt;如果结果似乎不正确,请调整你的程序(或就gofmt提交错误报告),而不是设法绕过它。

例如,无需花费时间对齐结构体字段的注释。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
}

标准库中的所有Go代码都已使用gofmt格式化。

仍有一些格式化细节。简述如下:

缩进
我们使用制表符进行缩进,gofmt默认输出制表符。仅在必须时使用空格。
行长度
Go没有行长度限制。无需担心超出打孔卡限制。如果一行感觉太长,请换行并用额外一个制表符缩进。
括号
Go所需的括号比C和Java少:控制结构(ifforswitch)在其语法中没有括号。此外,运算符优先级层次更短且更清晰,因此
x<<8 + y<<16
意味着间距所暗示的含义,与其他语言不同。

注释

Go提供C风格的/* */块注释和C++风格的//行注释。行注释是常态;块注释主要作为包注释出现,但在表达式内或用于禁用大段代码时很有用。

出现在顶层声明之前(无间断换行)的注释被视为对该声明本身的文档。这些“文档注释”是给定Go包或命令的主要文档。关于文档注释的更多信息,请参阅“Go文档注释”。

命名

命名在Go中与在任何其他语言中一样重要。它们甚至具有语义效果:名称在包外的可见性由其首字母是否大写决定。因此,值得花些时间谈谈Go程序中的命名约定。

包名

当导入一个包时,包名成为其内容的访问器。在

import "bytes"

导入的包可以通过 bytes.Buffer 这样的方式引用其中内容。如果所有使用者都能用同一个名称指代包中的内容会很方便,这意味着包名应当精心设计:简短、凝练、富有表现力。按照惯例,包名采用小写单字命名,无需使用下划线或混合大小写。 宁可简短也不要冗长,因为所有使用者都需要频繁输入这个名称。 也不必预先担心命名冲突。 包名只是导入时的默认名称,不需要在整个源代码中保持唯一;在极少数冲突情况下,导入方可以在本地选择别名。通常不会造成混淆,因为导入路径中的文件名已经明确指定了具体使用哪个包。

另一个惯例是:包名应与其源码目录的基本名称保持一致。 例如 src/encoding/base64 目录下的包, 其导入路径是 "encoding/base64", 但包名为 base64, 而非 encoding_base64encodingBase64

包的使用者会通过包名引用其内容,因此包内导出的名称可以利用这一点避免重复。 (不要使用 import . 的写法,虽然它能简化某些必须在包外运行的测试,但其他情况下应避免使用。) 例如 bufio 包中的缓冲读取器类型命名为 Reader, 而非 BufReader,因为用户看到的名称是 bufio.Reader, 这样既清晰又简洁。 此外,由于导入的实体总是通过包名访问,bufio.Reader 不会与 io.Reader 冲突。 同理,创建 ring.Ring 新实例的函数—— 在 Go 中定义为构造函数—— 通常应命名为 NewRing, 但由于该包只导出了 Ring 类型, 且包名为 ring, 因此该函数直接命名为 New, 使用者看到的名称是 ring.New。 善用包结构来选择合适的名称。

另一个简短的例子是 once.Doonce.Do(setup) 读起来很流畅, 改成 once.DoOrWaitUntilDone(setup) 反而不利于理解。 冗长的名称并不自动提升可读性。 有时,清晰的文档注释比超长的命名更有价值。

Getters

Go 不会自动生成 getter 和 setter。 手动提供 getter 和 setter 并无不妥,这通常很合适, 但将 Get 前缀加入 getter 命名既非惯例也非必要。 若某个字段命名为 owner(小写未导出), 其 getter 方法应命名为 Owner(大写已导出), 而非 GetOwner。 大写命名的导出机制本身就区分了字段和方法。 若需要 setter 函数,通常命名为 SetOwner。 这两个名称在实际使用中都很自然:

owner := obj.Owner()
if owner != user {
    obj.SetOwner(user)
}

接口命名

按照惯例,单方法接口的命名应采用方法名加 -er 后缀或类似修饰, 构成表示动作主体的名词:ReaderWriterFormatterCloseNotifier 等。

这样的命名约定有很多,遵循它们及其蕴含的函数命名规律能提高效率。 ReadWriteCloseFlushString 等方法具有固定的签名和语义。 为避免混淆,除非方法具有相同签名和语义, 否则不要使用这些名称。 反之,如果你的类型实现了与知名类型同名且语义相同的方法, 应保持相同的命名和签名; 将字符串转换方法命名为 String 而非 ToString

混合大小写

最后,Go 的惯例是使用 MixedCapsmixedCaps 来书写多词名称, 而非使用下划线分隔。

分号

与 C 类似,Go 的正式语法使用分号结束语句, 但与 C 不同,这些分号不会出现在源代码中。 词法分析器在扫描时会通过简单规则自动插入分号, 因此输入文本基本不需要手动添加分号。

规则如下:如果换行前的最后一个词法元素是标识符 (包括 intfloat64 等单词)、 基本字面量(如数字或字符串常量),或以下词法元素之一:

break continue fallthrough return ++ -- ) }

词法分析器总会在该元素后插入分号。 可以概括为:“当换行出现在可能结束语句的词法元素之后时,插入分号”。

在右花括号前也可以省略分号, 因此像这样的语句:

    go func() { for { dst <- <-src } }()

不需要任何分号。 符合 Go 习惯的代码只在 for 循环子句等位置使用分号, 用于分隔初始化语句、条件表达式和后续操作。 若将多条语句写在同一行,也需要用分号分隔。

分号插入规则的一个重要结果是: 不能将控制结构(ifforswitchselect)的左花括号放在下一行。 如果这样做,花括号前会被自动插入分号,可能导致意外结果。 请这样书写:

if i < f() {
    g()
}

不要这样写

if i < f()  // 错误!
{           // 错误!
    g()
}

控制结构

Go 的控制结构与 C 语言相关,但在重要方面有所不同。 没有 dowhile 循环,只有一个稍作泛化的 forswitch 更灵活; ifswitch 可以接受一个可选的 初始化语句,类似于 forbreakcontinue 语句 可以接受一个可选标签,用于标识要中断或继续的目标; 并且还有一些新的控制结构,包括类型开关和一个 多路通信复用器 select。 语法也略有不同: 没有括号, 并且主体必须始终用花括号界定。

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 语句不会流向下一个语句时——也就是说, 主体以 breakcontinuegotoreturn 结束——那么不必要的 else 就会被省略。

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

这是一个常见情况的例子,其中代码必须防护一系列错误条件。 如果成功的控制流沿着页面向下运行,并在出现时消除错误情况, 那么代码的可读性就很好。 由于错误情况往往以 return 语句结束,因此生成的代码不需要 else 语句。

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

重新声明与重新赋值

旁白:上一节中的最后一个例子演示了 := 短变量声明形式工作方式的一个细节。 调用 os.Open 的声明如下,

f, err := os.Open(name)

这条语句声明了两个变量:ferr。 几行之后,对 f.Stat 的调用如下,

d, err := f.Stat()

这看起来似乎声明了 derr。 但请注意,err 出现在两条语句中。 这种重复是合法的:err 由第一条语句声明, 但在第二条语句中只是被重新赋值。 这意味着对 f.Stat 的调用使用了上面声明的 现有 err 变量,并只是给了它一个新值。

:= 声明中,变量 v 即使已经 被声明过也可以再次出现,前提是:

这个不寻常的特性是纯粹的实用主义, 例如,在一个长的 if-else 链中可以轻松使用同一个 err 值。 你会经常看到它的使用。

§ 值得在此指出的是,在 Go 中,函数参数和返回值的作用域 与函数体相同,即使它们在词法上出现在函数体花括号的外部。

For

Go 的 for 循环与 C 的类似,但又不完全相同。 它统一了 forwhile,并且没有 do-while。 它有三种形式,其中只有一种带有分号。

// 类似于 C 的 for
for init; condition; post { }

// 类似于 C 的 while
for condition { }

// 类似于 C 的 for(;;)
for { }

短变量声明使得在循环中直接声明索引变量变得容易。

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

如果你正在遍历一个数组、切片、字符串或映射, 或者从通道中读取,一个 range 子句可以 管理这个循环。

for key, value := range oldMap {
    newMap[key] = value
}

如果你只需要范围中的第一个项(键或索引),去掉第二个:

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

如果你只需要范围中的第二个项(值),使用空白标识符(一个下划线)来丢弃第一个:

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

空白标识符有多种用途,如后面章节所述。

对于字符串,range 会为你处理更多工作,它通过解析 UTF-8 来逐个分解 Unicode 码点。 错误的编码会消耗一个字节并生成替代符文 U+FFFD。 (名称(及其关联的内置类型)rune 是 Go 中用于表示单个 Unicode 码点的术语。 详情请参见语言规范。) 循环

for pos, char := range "日本\x80語" { // \x80 是非法的 UTF-8 编码
    fmt.Printf("character %#U starts at byte position %d\n", char, pos)
}

会输出

character U+65E5 '日' starts at byte position 0
character U+672C '本' starts at byte position 3
character U+FFFD '�' starts at byte position 6
character U+8A9E '語' starts at byte position 7

最后,Go 没有逗号运算符,并且 ++-- 是语句而非表达式。 因此,如果你希望在 for 中操作多个变量,应该使用并行赋值(尽管这意味着不能使用 ++--)。

// 反转 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 的 switch 比 C 的更通用。 表达式不必是常量甚至不必是整数, case 语句从上到下求值,直到找到匹配项, 如果 switch 没有表达式,则它会在 true 上进行匹配。 因此,可以——并且是惯用写法——将一串 if-else-if-else 链写成 switch

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
}

没有自动贯穿(fall through),但 case 可以用逗号分隔的列表来表示。

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

尽管在 Go 中并不像在其他一些类 C 语言中那样常见,但 break 语句可用于提前终止 switch。 然而,有时需要跳出的是外层循环,而不是 switch,在 Go 中可以通过在循环上添加标签并“break”到该标签来实现。 此示例展示了两种用法。

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 语句:

// Compare 按字典序比较两个字节切片,返回一个整数。
// 如果 a == b,结果为 0;如果 a < b,结果为 -1;如果 a > b,结果为 +1
func Compare(a, b []byte) int {
    for i := 0; i < len(a) && i < len(b); i++ {
        switch {
        case a[i] > b[i]:
            return 1
        case a[i] < b[i]:
            return -1
        }
    }
    switch {
    case len(a) > len(b):
        return 1
    case len(a) < len(b):
        return -1
    }
    return 0
}

类型开关

switch 也可以用来发现接口变量的动态类型。这种 类型开关 使用类型断言的语法,将关键字 type 放在括号内。 如果 switch 在表达式中声明了一个变量,那么该变量在每个 case 子句中都会具有相应的类型。 在这种情况下,重新使用相同的变量名也是惯用做法,实际上是在每个 case 中声明了一个同名但类型不同的新变量。

var t interface{}
t = functionOfSomeType()
switch t := t.(type) {
default:
    fmt.Printf("unexpected type %T\n", t)     // %T 打印 t 的实际类型
case bool:
    fmt.Printf("boolean %t\n", t)             // t 的类型是 bool
case int:
    fmt.Printf("integer %d\n", t)             // t 的类型是 int
case *bool:
    fmt.Printf("pointer to boolean %t\n", *t) // t 的类型是 *bool
case *int:
    fmt.Printf("pointer to integer %d\n", *t) // t 的类型是 *int
}

函数

多返回值

Go 的一个不寻常特性是函数和方法可以返回多个值。这种形式可以改进 C 程序中一些笨拙的惯用法,例如用于表示 EOF 的带内错误返回码 -1,以及通过地址传递参数进行修改。

在 C 中,写入错误通过负的计数值来表示,而错误代码隐藏在一个易失性位置。 在 Go 中,Write 可以返回计数值 一个错误:“是的,你写入了一些字节,但并非全部,因为你填满了设备”。 来自 os 包的文件的 Write 方法签名为:

func (file *File) Write(b []byte) (n int, err error)

正如文档所说,当 n != len(b) 时,它会返回写入的字节数和一个非 nil 的 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
}

你可以这样使用它来扫描输入切片 b 中的数字:

    for i := 0; i < len(b); {
        x, i = nextInt(b, i)
        fmt.Println(x)
    }

命名结果参数

Go 函数的返回或结果“参数”可以被命名,并像常规变量一样使用,就像输入参数一样。 当被命名时,它们在函数开始时会被初始化为其类型的零值;如果函数执行一个不带参数的 return 语句,则结果参数的当前值将作为返回值使用。

命名不是强制性的,但它们可以使代码更简短、更清晰:它们就是文档。 如果我们为 nextInt 的结果命名,那么哪个返回的 int 代表什么就一目了然了。

func nextInt(b []byte, pos int) (value, nextPos int) {

因为命名结果参数已被初始化并与不带参数的 return 绑定,它们既能简化代码又能增强清晰度。 这是一个很好地使用了它们的 io.ReadFull 版本:

func ReadFull(r Reader, buf []byte) (n int, err error) {
    for len(buf) > 0 && err == nil {
        var nr int
        nr, err = r.Read(buf)
        n += nr
        buf = buf[nr:]
    }
    return
}

延迟执行

Go 的 defer 语句将一个函数调用(被延迟 的函数)安排在执行 defer 的函数返回之前立即运行。 这是一种不寻常但有效的方式,用于处理那些无论函数通过哪条路径返回都必须释放资源的情况。 典型的例子是解锁互斥锁或关闭文件。

// Contents 以字符串形式返回文件内容。
func Contents(filename string) (string, error) {
    f, err := os.Open(filename)
    if err != nil {
        return "", err
    }
    defer f.Close()  // f.Close 将在我们完成时运行。

    var result []byte
    buf := make([]byte, 100)
    for {
        n, err := f.Read(buf[0:])
        result = append(result, buf[0:n]...) // append 会在后面讨论。
        if err != nil {
            if err == io.EOF {
                break
            }
            return "", err  // 如果我们在这里返回,f 将被关闭。
        }
    }
    return string(result), nil // 如果我们在这里返回,f 将被关闭。
}

延迟调用像 Close 这样的函数有两个优点。 首先,它保证你永远不会忘记关闭文件,如果你后来编辑函数以添加新的返回路径,这是一个很容易犯的错误。 其次,它意味着关闭操作靠近打开操作,这比将其放在函数末尾要清晰得多。

被延迟函数的参数(如果函数是方法,则包括接收器)是在 defer 执行时求值的,而不是在 call 执行时。 除了避免担心变量值在函数执行过程中变化之外,这意味着单个延迟调用点可以延迟多个函数执行。 这里有一个简单的例子。

for i := 0; i < 5; i++ {
    defer fmt.Printf("%d ", i)
}

被延迟的函数按后进先出 (LIFO) 顺序执行,因此当函数返回时,这段代码将打印 4 3 2 1 0。 一个更合理的例子是跟踪程序中函数执行的简单方法。 我们可以编写几个简单的跟踪例程,如下所示:

func trace(s string)   { fmt.Println("entering:", s) }
func untrace(s string) { fmt.Println("leaving:", s) }

// 像这样使用它们:
func a() {
    trace("a")
    defer untrace("a")
    // 做一些事情....
}

我们可以利用被延迟函数的参数在 defer 执行时求值这一事实来做得更好。 跟踪例程可以设置用于取消跟踪的例程的参数。 这个例子:

func trace(s string) string {
    fmt.Println("entering:", s)
    return s
}

func un(s string) {
    fmt.Println("leaving:", s)
}

func a() {
    defer un(trace("a"))
    fmt.Println("in a")
}

func b() {
    defer un(trace("b"))
    fmt.Println("in b")
    a()
}

func main() {
    b()
}

将打印:

entering: b
in b
entering: a
in a
leaving: a
leaving: b

对于习惯于其他语言基于块的资源管理的程序员来说,defer 可能看起来很特别,但它最有趣和最强大的应用恰恰来源于它不是基于块而是基于函数的这一事实。 在关于 panicrecover 的部分,我们将看到另一个展示其可能性的例子。

数据

使用 new 进行分配

Go 有两种内存分配原语:内建函数 newmake。 它们的功能不同,所适用的类型也不同,这可能会让人困惑,但规则其实很简单。 我们先从 new 讲起。 new 是一个分配内存的内建函数,但与一些同名函数不同,它不会 初始化 内存,只会 置零。 也就是说,new(T) 会为一个类型为 T 的新项分配一块置零的存储空间,并返回其地址,即一个类型为 *T 的值。 用 Go 的术语来说,它返回一个指向新分配的、类型为 T 的零值的指针。

从 Go 1.26 开始,new 还接受一个(值)表达式作为参数,用于指定变量的初始值。 例如,new(int64(300)) 会分配一个新的 int64 类型的变量,将其初始化为 300,并返回其地址。

由于 new 返回的内存是置零的,在设计数据结构时,安排每个类型的零值无需进一步初始化即可使用会很有帮助。这意味着数据结构的用户可以通过 new 创建一个实例并直接开始使用。 例如,bytes.Buffer 的文档指出: "Buffer 的零值是一个准备好使用的空缓冲区。" 类似地,sync.Mutex 也没有显式的构造函数或 Init 方法。 相反,sync.Mutex 的零值被定义为一个未上锁的互斥锁。

零值可用的属性具有传递性。考虑以下类型声明。

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

SyncedBuffer 类型的值在分配或声明后也能立即使用。在下面的代码片段中,pv 都无需额外设置就能正常工作。

p := new(SyncedBuffer)  // 类型 *SyncedBuffer
var v SyncedBuffer      // 类型  SyncedBuffer

构造函数与复合字面量

有时零值并不够用,这时就需要一个初始化构造函数,例如以下源自 os 包的例子。

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 中返回局部变量的地址是完全没问题的;与该变量关联的存储空间在函数返回后依然存在。 事实上,对复合字面量取地址会在每次求值时分配一个新的实例,因此我们可以将最后两行合并。

    return &File{fd, name, nil, 0}

复合字面量的字段必须按顺序排列并且全部出现。 然而,通过将元素显式标记为 字段: 对,初始化器可以以任意顺序出现,缺失的字段将保留其各自的零值。因此我们可以这样写:

    return &File{fd: fd, name: name}

作为一种极限情况,如果复合字面量根本不包含任何字段,它将创建该类型的零值。表达式 new(File)&File{} 是等价的。

复合字面量也可以用于创建数组、切片和映射,其中的字段标签分别对应索引或映射键。 在这些例子中,初始化方式与 EnoneEioEinval 的具体值无关,只要它们互不相同即可。

a := [...]string   {Enone: "no error", Eio: "Eio", Einval: "invalid argument"}
s := []string      {Enone: "no error", Eio: "Eio", Einval: "invalid argument"}
m := map[int]string{Enone: "no error", Eio: "Eio", Einval: "invalid argument"}

使用 make 进行分配

回到内存分配。 内建函数 make(T, args) 的用途与 new(T) 不同。 它只用于创建切片、映射和通道,并且返回一个 已初始化 的(而非 已置零 的)类型为 T 的值(不是 *T)。 这种区分的原因在于,这三种类型在底层代表的是引用,其关联的数据结构必须在使用前进行初始化。 例如,切片是一个包含三项内容的描述符:一个指向数据(位于某个数组内)的指针、长度和容量,在这些内容被初始化之前,切片是 nil。 对于切片、映射和通道,make 会初始化其内部的数据结构,并为使用做好准备。 例如:

make([]int, 10, 100)

会分配一个包含 100 个整数的数组,然后创建一个长度为 10、容量为 100 的切片结构,指向该数组的前 10 个元素。 (创建切片时,容量参数可以省略;更多信息请参阅关于切片的章节。) 相比之下,new([]int) 返回一个指向新分配的、置零的切片结构的指针,即一个指向 nil 切片值的指针。

以下示例说明了 newmake 的区别。

var p *[]int = new([]int)       // 分配切片结构;*p == nil;很少有用
var v  []int = make([]int, 100) // 切片 v 现在引用一个包含 100 个整数的新数组
// 不必要的复杂写法: var p *[]int = new([]int) *p = make([]int, 100, 100) // 惯用写法: v := make([]int, 100)

记住 make 只适用于映射、切片和通道,并且不返回指针。 若需显式指针,应使用 new 分配或显式取变量的地址。

数组

数组在规划内存的详细布局时很有用,有时可以帮助避免分配,但主要是作为切片(下一节的主题)的构建块。 为了给该主题奠定基础,这里简要介绍数组。

Go 和 C 中数组的工作方式有很大区别。 在 Go 中,

值的属性可能很有用,但也可能开销很大;如果你想要类似 C 的行为和效率,可以传递数组的指针。

func Sum(a *[3]float64) (sum float64) {
    for _, v := range *a {
        sum += v
    }
    return
}

array := [...]float64{7.0, 8.5, 9.1}
x := Sum(&array)  // 注意显式的取地址运算符

但即便这种风格也不是惯用的 Go 代码。 应使用切片代替。

切片

切片包裹数组,为数据序列提供了更通用、强大和便捷的接口。 除了像变换矩阵这样具有显式维度的项目外,Go 中的大部分数组编程都是通过切片而不是简单数组来完成的。

切片持有对底层数组的引用,如果将一个切片赋值给另一个,两者将引用同一个数组。 如果一个函数接受切片参数,它对切片元素所做的更改将对调用者可见,类似于传递指向底层数组的指针。 因此,一个 Read 函数可以接受切片参数,而不是指针和计数;切片的长度设置了要读取数据量的上限。 这是 os 包中 File 类型的 Read 方法的签名:

func (f *File) Read(buf []byte) (n int, err error)

该方法返回读取的字节数和错误值(如果有的话)。 要读取到一个较大缓冲区 buf 的前 32 字节,可以对该缓冲区进行切片(这里用作动词)。

    n, err := f.Read(buf[0:32])

这种切片操作很常见且高效。 实际上,暂时不考虑效率的话,以下代码片段同样会读取缓冲区的前 32 字节。

    var n int
    var err error
    for i := 0; i < 32; i++ {
        nbytes, e := f.Read(buf[i:i+1])  // 读取一个字节。
        n += nbytes
        if nbytes == 0 || e != nil {
            err = e
            break
        }
    }

切片的长度可以更改,只要它仍然适合底层数组的限制;只需将其赋值给自身的一个切片。 切片的容量,可以通过内置函数 cap 访问,报告切片可以容纳的最大长度。 下面是一个向切片追加数据的函数。如果数据超过容量,切片将被重新分配。 返回结果切片。该函数利用了这样一个事实:lencap 应用于 nil 切片时是合法的,并返回 0。

func Append(slice, data []byte) []byte {
    l := len(slice)
    if l + len(data) > cap(slice) {  // 重新分配
        // 分配所需空间的两倍,以备未来增长。
        newSlice := make([]byte, (l+len(data))*2)
        // copy 函数是预定义的,适用于任何切片类型。
        copy(newSlice, slice)
        slice = newSlice
    }
    slice = slice[0:l+len(data)]
    copy(slice[l:], data)
    return slice
}

我们必须在之后返回切片,因为虽然 Append 可以修改 slice 的元素,但切片本身(持有指针、长度和容量的运行时数据结构)是按值传递的。

向切片追加元素的想法非常有用,因此被内置函数 append 所捕获。 然而,要理解该函数的设计,我们需要更多信息,因此稍后再讨论它。

二维切片

Go 的数组和切片是一维的。 要创建相当于二维数组或切片,需要定义数组的数组或切片的切片,如下所示:

type Transform [3][3]float64  // 一个 3x3 数组,实际上是数组的数组。
type LinesOfText [][]byte     // 字节切片的切片。

因为切片是可变长度的,所以每个内部切片可以有不同的长度。 这可能是一个常见的情况,正如我们的 LinesOfText 示例所示:每行都有独立的长度。

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

有时需要分配二维切片,例如在处理像素扫描线时可能会出现这种情况。 有两种方法可以实现。 一种是独立分配每个切片;另一种是分配单个数组,并将各个切片指向其中。 选择哪种方法取决于你的应用程序。 如果切片可能增长或缩小,它们应该独立分配,以避免覆盖下一行;如果不是,使用单次分配来构造对象可能更高效。 作为参考,下面是两种方法的概略。 首先,一次分配一行:

// 分配顶层切片。
picture := make([][]uint8, YSize) // 每个y值对应一行。
// 遍历各行,为每行分配切片。
for i := range picture {
    picture[i] = make([]uint8, XSize)
}

现在作为一次分配,再分割成行:

// 分配顶层切片,与之前相同。
picture := make([][]uint8, YSize) // 每个y值对应一行。
// 分配一个大切片存储所有像素。
pixels := make([]uint8, XSize*YSize) // 尽管picture是[][]uint8,但其类型为[]uint8。
// 遍历各行,从剩余像素切片的开头切出每行。
for i := range picture {
    picture[i], pixels = pixels[:XSize], pixels[XSize:]
}

映射

映射(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。 集合可以实现为值类型为 bool 的映射。 将映射条目设置为 true 以将值放入集合,然后 通过简单的索引来测试它。

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

if attended[person] { // 如果person不在映射中,将返回false
    fmt.Println(person, "was at the meeting")
}

有时你需要区分缺失条目和 零值。是存在键为 "UTC" 的条目, 还是因为映射中根本不存在而返回0? 你可以使用一种形式的多重赋值来区分。

var seconds int
var ok bool
seconds, ok = timeZone[tz]

出于显而易见的原因,这被称为 “逗号-ok” 惯用语法。 在这个例子中,如果 tz 存在,seconds 将被适当设置,并且 ok 将为 true;如果不存在, seconds 将被设置为零,ok 将 为 false。 这里有一个函数将它与一个不错的错误报告结合起来:

func offset(tz string) int {
    if seconds, ok := timeZone[tz]; ok {
        return seconds
    }
    log.Println("unknown time zone:", tz)
    return 0
}

要测试映射中是否存在某个键而不关心其实际值, 你可以使用 空白标识符 (_) 代替通常的变量来接收值。

_, present := timeZone[tz]

要删除映射条目,请使用 delete 内置函数,其参数是映射和要删除的键。 即使该键在映射中已经不存在,这样做也是安全的。

delete(timeZone, "PDT")  // 现在是标准时间了

打印

Go语言中的格式化打印使用了类似C语言printf函数族的风格,但更加丰富和通用。这些函数位于fmt包中,并且使用大写名称:fmt.Printffmt.Fprintffmt.Sprintf等。字符串函数(如Sprintf)会返回一个字符串,而不是填充提供的缓冲区。

你不需要提供格式字符串。对于PrintfFprintfSprintf,都有对应的另一对函数,例如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))

格式化打印函数fmt.Fprint及其相关函数将实现io.Writer接口的任何对象作为第一个参数;变量os.Stdoutos.Stderr就是常见的实例。

接下来的内容开始与C语言有所不同。首先,数字格式如%d不接受表示符号性或大小的标志;相反,打印例程使用参数的类型来决定这些属性。

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

输出为:

18446744073709551615 ffffffffffffffff; -1 -1

如果你只需要默认转换(例如对整数使用十进制),可以使用通用格式%v(代表“值”);其结果与PrintPrintln产生的完全相同。此外,该格式可以打印任何值,包括数组、切片、结构体和映射。这里是一个用于打印上一节定义的时区映射的语句。

fmt.Printf("%v\n", timeZone)  // 或者直接用 fmt.Println(timeZone)

其输出为:

map[CST:-21600 EST:-18000 MST:-25200 PST:-28800 UTC:0]

对于映射,Printf及相关函数会按键的字典序排列输出。

打印结构体时,修改后的格式%+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)

输出为:

&{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, "EST":-18000, "MST":-25200, "PST":-28800, "UTC":0}

(注意上面的和号&符号。)当应用于string[]byte类型的值时,也可以通过%q使用带引号的字符串格式。替代格式%#q在可能的情况下会使用反引号代替。(%q格式也适用于整数和字符,生成一个单引号括起的字符常量。)另外,%x也适用于字符串、字节数组和字节切片以及整数,生成一个长的十六进制字符串,并且在格式中使用空格(% x)会在字节之间加入空格。

另一个方便的格式是%T,它打印值的类型

fmt.Printf("%T\n", timeZone)

输出为:

map[string]int

如果你想控制自定义类型的默认格式,只需在该类型上定义一个签名为String() string的方法即可。对于我们的简单类型T,它可能看起来像这样。

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

则以如下格式打印:

7/-2.35/"abc\tdef"

(如果你需要同时打印T类型的和指向T的指针,那么String的接收者必须是值类型;这个例子使用了指针,因为它对于结构体类型更高效且符合习惯。更多信息请参见下面关于指针与值接收者的章节。)

我们的String方法能够调用Sprintf,是因为打印例程是完全可重入的,并且可以这样包装。然而,关于这种方法有一个重要的细节需要理解:不要以会导致无限递归调用你自己的String方法的方式,通过调用Sprintf来构造String方法。如果Sprintf调用试图将接收者直接作为字符串打印,而该接收者又实现了String方法,就会发生这种情况。这是一个常见且容易犯的错误,如下例所示。

type MyString string

func (m MyString) String() string {
    return fmt.Sprintf("MyString=%s", m) // 错误:会无限递归。
}

修复方法也很简单:将参数转换为基本的字符串类型,它没有这个方法。

type MyString string
func (m MyString) String() string {
    return fmt.Sprintf("MyString=%s", string(m)) // 正确:注意转换。
}

初始化章节中,我们将看到另一种避免这种递归的技巧。

另一种打印技巧是将打印例程的参数直接传递给另一个此类例程。 Printf 的签名使用 ...interface{} 类型作为其最后一个参数,以指定可以在格式字符串之后出现任意数量(任意类型)的参数。

func Printf(format string, v ...interface{}) (n int, err error) {

在函数 Printf 内部,v 的行为类似于 []interface{} 类型的变量,但如果将它传递给另一个可变参数函数,它的行为则像一个普通的参数列表。 下面是我们上面使用的函数 log.Println 的实现。它将其参数直接传递给 fmt.Sprintln 以进行实际格式化。

// Println prints to the standard logger in the manner of fmt.Println.
func Println(v ...interface{}) {
    std.Output(2, fmt.Sprintln(v...))  // Output takes parameters (int, string)
}

我们在对 Sprintln 的嵌套调用中,在 v 后面写上 ...,以告知编译器将 v 视为一个参数列表;否则它只会将 v 作为单个切片参数传递。

打印功能远不止我们这里所涵盖的内容。详情请参见 fmt 包的 godoc 文档。

顺便说一下,... 参数可以是特定类型,例如,对于一个从整数列表中选择最小值的 min 函数,可以使用 ...int

func Min(a ...int) int {
    min := int(^uint(0) >> 1)  // largest int
    for _, i := range a {
        if i < min {
            min = i
        }
    }
    return min
}

Append

现在,我们已经具备了解释内置函数 append 设计所需的最后一块拼图。append 的签名与我们上面自定义的 Append 函数不同。 其概要如下:

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

其中 T 是任何给定类型的占位符。实际上,在 Go 中你无法编写一个类型 T 由调用者决定的函数。 这就是为什么 append 是内置的:它需要编译器的支持。

append 的作用是将元素追加到切片末尾并返回结果。之所以需要返回结果,是因为与我们手写的 Append 一样,底层的数组可能会改变。这个简单的例子

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

会打印 [1 2 3 4 5 6]。因此,append 的工作方式有点像 Printf,可以收集任意数量的参数。

但如果我们想做 Append 所做的事情——将一个切片追加到另一个切片呢?很简单:在调用点使用 ...,就像我们上面在调用 Output 时所做的一样。下面的代码片段将产生与上面完全相同的输出。

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

如果没有那个 ...,代码将无法编译,因为类型会不匹配;y 的类型不是 int

初始化

尽管表面上看起来与 C 或 C++ 中的初始化没有太大区别,但 Go 中的初始化功能更强大。 复杂的结构可以在初始化过程中构建,并且被初始化对象之间的顺序问题(甚至在不同包之间)也能被正确处理。

常量

Go 中的常量就是它们字面上的意思——常量。 它们在编译时创建,即使被定义为函数内的局部变量, 并且只能是数字、字符(rune)、字符串或布尔值。 由于编译时的限制,定义它们的表达式必须是常量表达式, 能够被编译器计算。例如, 1<<3 是一个常量表达式,而 math.Sin(math.Pi/4) 不是,因为 对 math.Sin 的函数调用需要在运行时进行。

在 Go 中,枚举常量使用 iota 枚举器创建。由于 iota 可以是表达式的一部分, 而表达式可以隐式重复,因此很容易构建复杂的 值集。

type ByteSize float64

const (
    _           = iota // ignore first value by assigning to blank identifier
    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 来实现 ByteSizeString 方法是安全的 (避免了无限递归),这不是因为进行了类型转换,而是 因为它使用 %f 调用 Sprintf, 这不是一个字符串格式:Sprintf 仅当它需要一个字符串时才会调用 String 方法,而 %f 需要的是一个浮点数值。

变量

变量可以像常量一样被初始化,但 初始化器可以是运行时计算的通用表达式。

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

init 函数

最后,每个源文件都可以定义自己的无参数 init 函数来设置所需的任何状态。(实际上每个文件可以有多个 init 函数。)这里的“最后”意味着真正的最后: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 the Append function defined above.
}

这仍然需要方法返回更新后的切片。我们可以通过重新定义方法,使其接收一个指向 ByteSlice指针 作为接收者来消除这种笨拙,这样方法就可以覆盖调用者的切片。

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

事实上,我们可以做得更好。如果我们将函数修改成看起来像标准的 Write 方法,像这样,

func (p *ByteSlice) Write(data []byte) (n int, err error) {
    slice := *p
    // Again as above.
    *p = slice
    return len(data), nil
}

那么类型 *ByteSlice 就满足了标准接口 io.Writer,这很方便。例如,我们可以向其中打印内容。

    var b ByteSlice
    fmt.Fprintf(&b, "This hour has %d days\n", 7)

我们传递了 ByteSlice 的地址,因为只有 *ByteSlice 满足 io.Writer。 关于接收者使用指针还是值的规则是:值方法可以被指针和值调用,但指针方法只能被指针调用。

这条规则源于指针方法可以修改接收者;在值上调用它们会导致方法接收到该值的一个副本,因此任何修改都将被丢弃。因此语言禁止这种错误。 不过有一个方便的例外。当值是可寻址时,语言会自动插入取地址操作符,来处理在值上调用指针方法的常见情况。 在我们的例子中,变量 b 是可寻址的,因此我们可以只用 b.Write 来调用它的 Write 方法。编译器会为我们将其重写为 (&b).Write

顺便提一下,在字节切片上使用 Write 的思想是 bytes.Buffer 实现的核心。

接口与其他类型

接口

Go 语言中的接口提供了一种指定对象行为的方式:如果某物可以做 这件事,那么它就可以被用在 这里。我们已经看过一些简单的例子;自定义打印机可以通过 String 方法实现,而 Fprintf 可以将输出生成到任何具有 Write 方法的东西上。 只包含一两个方法的接口在 Go 代码中很常见,通常以方法命名,例如 io.Writer 代表实现了 Write 方法的东西。

一个类型可以实现多个接口。例如,如果一个集合实现了 sort.Interface(包含 Len()Less(i, j int) boolSwap(i, j int)),它就可以被 sort 包中的例程排序,并且它也可以有一个自定义的格式化器。在这个虚构的例子中,Sequence 同时满足两者。

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]
}

// Copy returns a copy of the Sequence.
func (s Sequence) Copy() Sequence {
    copy := make(Sequence, 0, len(s))
    return append(copy, s...)
}

// Method for printing - sorts the elements before printing.
func (s Sequence) String() string {
    s = s.Copy() // Make a copy; don't overwrite argument.
    sort.Sort(s)
    str := "["
    for i, elem := range s { // Loop is O(N²); will fix that in next example.
        if i > 0 {
            str += " "
        }
        str += fmt.Sprint(elem)
    }
    return str + "]"
}

类型转换

SequenceString 方法重复实现了 Sprint 对切片已有的功能。(其复杂度为 O(N²),性能较差。)我们可以通过在调用 Sprint 前将 Sequence 转换为普通的 []int 来复用现有逻辑(同时提升性能)。

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

这个方法展示了另一种在 String 方法中安全调用 Sprintf 的转换技巧。由于 Sequence[]int 在忽略类型名称时本质相同,它们之间的转换是合法的。这种转换不会创建新值,只是临时将现有值视为另一种类型。(其他合法转换如整型转浮点型则会创建新值。)

在 Go 程序中,通过转换表达式类型来调用不同方法集合是一种常见模式。例如我们可以直接使用现有的 sort.IntSlice 类型简化代码:

type Sequence []int

// 打印方法 - 排序元素后打印
func (s Sequence) String() string {
    s = s.Copy()
    sort.IntSlice(s).Sort()
    return fmt.Sprint([]int(s))
}

现在 Sequence 无需同时实现排序和打印两个接口,而是利用数据项可转换为多种类型(Sequencesort.IntSlice[]int)的特性,让每种类型各司其职。这种实践方式虽不常见但颇为有效。

接口转换与类型断言

类型开关是一种转换形式:它接收一个接口值,在每个 case 分支中将其转换为对应类型。以下是 fmt.Printf 底层通过类型开关将值转为字符串的简化示例。若值已是字符串则直接获取接口持有的实际字符串值,若实现了 String 方法则调用该方法。

type Stringer interface {
    String() string
}

var value interface{} // 调用方提供的值
switch str := value.(type) {
case string:
    return str
case Stringer:
    return str.String()
}

第一个分支匹配具体类型;第二个分支将接口转换为另一接口。这种混合类型用法完全合法。

若只关心特定类型呢?比如确知值为 string 并想提取它?单分支类型开关可以实现,但更简洁的是使用类型断言。类型断言从接口值中提取指定显式类型的值,语法借鉴类型开关的 case 子句但显式声明类型:

value.(typeName)

结果是静态类型为 typeName 的新值。该类型必须是接口持有的具体类型,或是值可转换的另一接口类型。要提取已知存在于值中的字符串,可以写作:

str := value.(string)

但若值并非字符串,程序将因运行时错误崩溃。为预防此情况,可使用"逗号-ok"模式安全检测:

str, ok := value.(string)
if ok {
    fmt.Printf("字符串值为:%q\n", str)
} else {
    fmt.Printf("值不是字符串\n")
}

若类型断言失败,str 仍存在且为字符串类型,但会具有零值(空字符串)。

作为能力示例,以下 if-else 语句等效于本节开头的类型开关:

if str, ok := value.(string); ok {
    return str
} else if str, ok := value.(Stringer); ok {
    return str.String()
}

泛用性原则

当某个类型仅为实现接口而存在,且不会导出该接口之外的方法时,无需导出类型本身。仅导出接口可清晰表明:该值除了接口描述的行为外不具备其他特性。这也避免了为公共方法的每个实例重复编写文档。

在此类场景中,构造函数应返回接口值而非实现类型。例如哈希库中 crc32.NewIEEEadler32.New 都返回接口类型 hash.Hash32。在 Go 程序中将 CRC-32 算法替换为 Adler-32 只需修改构造函数调用,其余代码完全不受算法变更影响。

类似方法使得各 crypto 包中的流式加密算法可与它们链式组合的分组密码解耦。crypto/cipher 包中的 Block 接口定义了分组密码的行为(提供单数据块加密)。通过类比 bufio 包,实现该接口的密码包可用于构建由 Stream 接口表示的流式加密,而无需了解分组加密细节。

crypto/cipher 接口如下所示:

type Block interface {
    BlockSize() int
    Encrypt(dst, src []byte)
    Decrypt(dst, src []byte)
}

type Stream interface {
    XORKeyStream(dst, src []byte)
}

这是计数器模式(CTR)流的定义,它将分组密码转换为流密码;请注意,分组密码的细节已被抽象化:

// NewCTR返回一个使用给定Block进行计数器模式加密/解密的Stream。
// iv的长度必须与Block的块大小相同。
func NewCTR(block Block, iv []byte) Stream

NewCTR 不仅适用于特定的加密算法和数据源,也适用于任何实现了 Block 接口的实现以及任何 Stream。由于它们返回接口值,将CTR加密替换为其他加密模式是一个局部性的更改。只需修改构造函数调用,但由于周边代码仅将结果视为 Stream,因此不会察觉到差异。

接口与方法

由于几乎任何类型都可以附加方法,因此几乎任何类型都可以满足接口。一个说明性的例子是 http 包,其中定义了 Handler 接口。任何实现了 Handler 的对象都可以处理HTTP请求。

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

ResponseWriter 本身是一个接口,它提供了向客户端返回响应所需的方法访问。这些方法包括标准的 Write 方法,因此 http.ResponseWriter 可以在任何可以使用 io.Writer 的地方使用。 Request 是一个结构体,包含了从客户端解析后的请求表示。

为简洁起见,我们忽略POST请求,并假设HTTP请求总是GET;这种简化不影响处理器的设置方式。这里有一个简单的处理器实现,用于统计页面被访问的次数。

// 简单的计数器服务器。
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)
}

(延续我们的主题,注意 Fprintf 如何能打印到 http.ResponseWriter。) 在实际的服务器中,对 ctr.n 的访问需要保护以避免并发访问。参见 syncatomic 包的建议。

作为参考,以下是如何将这样的服务器附加到URL树的节点上。

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

但为什么要把 Counter 做成结构体?只需要一个整数就够了。(接收者需要是指针,这样对调用者才是可见的递增。)

// 更简单的计数器服务器。
type Counter int

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

如果你的程序有一些内部状态需要在页面被访问时得到通知怎么办?可以将一个通道绑定到该网页。

// 一个在每次访问时发送通知的通道。
// (可能希望该通道是有缓冲的。)
type Chan chan *http.Request

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

最后,假设我们想在 /args 上展示调用服务器二进制文件时使用的参数。 写一个函数来打印参数很容易。

func ArgServer() {
    fmt.Println(os.Args)
}

我们如何将其变成一个HTTP服务器?我们可以让 ArgServer 成为某个类型的方法,其值我们可以忽略,但有更简洁的方式。 由于我们可以为除了指针和接口之外的任何类型定义方法,我们可以为一个函数编写方法。 http 包包含以下代码:

// HandlerFunc类型是一个适配器,它允许将普通函数用作HTTP处理器。
// 如果f是一个具有适当签名的函数,HandlerFunc(f)就是一个
// 调用f的Handler对象。
type HandlerFunc func(ResponseWriter, *Request)

// ServeHTTP调用f(w, req)。
func (f HandlerFunc) ServeHTTP(w ResponseWriter, req *Request) {
    f(w, req)
}

HandlerFunc 是一个具有方法 ServeHTTP 的类型,因此该类型的值可以处理HTTP请求。查看该方法的实现:接收者是一个函数 f,而该方法调用 f。这看起来可能有些奇怪,但它与,例如,接收者是一个通道而该方法在通道上发送消息,并没有太大不同。

为了将 ArgServer 变成一个HTTP服务器,我们首先修改它,使其具有正确的签名。

// 参数服务器。
func ArgServer(w http.ResponseWriter, req *http.Request) {
    fmt.Fprintln(w, os.Args)
}

ArgServer 现在具有与 HandlerFunc 相同的签名,因此可以将其转换为该类型以访问其方法,就像我们将 Sequence 转换为 IntSlice 以访问 IntSlice.Sort 一样。 设置它的代码是简洁的:

http.Handle("/args", http.HandlerFunc(ArgServer))

当有人访问页面 /args 时,安装在该页面的处理器具有值 ArgServer 和类型 HandlerFunc。 HTTP服务器将调用该类型的 ServeHTTP 方法,以 ArgServer 作为接收者,这反过来又会(通过 HandlerFunc.ServeHTTP 内部的调用 f(w, req))调用 ArgServer。 然后参数就会被显示出来。

在本节中,我们从一个结构体、一个整数、一个通道和一个函数构建了一个HTTP服务器,这都是因为接口只是方法的集合,而方法可以为(几乎)任何类型定义。

空标识符

我们已经在for range循环映射的上下文中多次提及空标识符。空标识符可以被赋值或声明为任意类型的任意值,该值会被无害地丢弃。这有点类似于写入Unix的/dev/null文件:它代表一个只写值,用作占位符,适用于需要变量但实际值无关紧要的场景。它的用途超出了我们已经看到的那些。

多重赋值中的空标识符

for range循环中使用空标识符,是一种通用情况——多重赋值——的特例。

如果一个赋值语句左侧需要多个值,但程序不会使用其中的某个值,那么在赋值语句的左侧使用空标识符,可以避免创建占位变量,并明确表示该值将被丢弃。例如,当调用一个返回值和错误但只有错误才重要的函数时,使用空标识符来丢弃无关的值。

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

有时你会看到为了忽略错误而丢弃错误值的代码;这是非常糟糕的做法。务必检查错误返回值;它们的存在是有原因的。

// 糟糕!如果路径不存在,此代码将崩溃。
fi, _ := os.Stat(path)
if fi.IsDir() {
    fmt.Printf("%s is a directory\n", path)
}

未使用的导入和变量

导入一个包或声明一个变量而不使用它,这是一个错误。未使用的导入会使程序膨胀并拖慢编译速度,而一个已初始化但未使用的变量,至少是计算资源的浪费,也可能预示着更大的bug。然而,在程序积极开发期间,未使用的导入和变量经常出现,仅仅为了让编译通过就删除它们,然后不久后又需要它们,这会非常烦人。空标识符提供了一种变通方法。

这个半完成的程序有两个未使用的导入(fmtio)和一个未使用的变量(fd),因此它无法编译,但能看到目前代码是否正确就好了。

package main

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

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

为了消除关于未使用导入的抱怨,可以使用空标识符来引用导入包中的符号。类似地,将未使用的变量fd赋值给空标识符可以消除未使用变量的错误。这个版本的程序可以编译。

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这样未使用的导入,最终应该被使用或删除;空赋值标识了正在进行中的代码。但有时仅为了包的副作用而导入它(而不进行任何显式使用)是有用的。例如,在其init函数期间,net/http/pprof包注册了提供调试信息的HTTP处理程序。它有一个导出的API,但大多数客户端只需要处理程序注册并通过网页访问数据。要仅为包的副作用而导入它,可以将包名重命名为空标识符:

import _ "net/http/pprof"

这种导入形式清楚地表明,该包是为其副作用而导入的,因为在此文件中没有其他可能的使用方式:它没有名字。(如果它有名字,而我们没有使用该名字,编译器会拒绝该程序。)

接口检查

正如我们在上面关于接口的讨论中所看到的,一个类型无需显式声明它实现了某个接口。相反,一个类型只需实现接口的方法就实现了该接口。实际上,大多数接口转换是静态的,因此在编译时检查。例如,将一个*os.File传递给一个期望io.Reader的函数,除非*os.File实现了io.Reader接口,否则无法编译。

然而,有些接口检查确实发生在运行时。一个例子是在encoding/json包中,它定义了一个Marshaler接口。当JSON编码器接收到一个实现了该接口的值时,编码器会调用该值的编组方法将其转换为JSON,而不是执行标准转换。编码器在运行时通过类型断言来检查此属性,如下所示:

m, ok := val.(json.Marshaler)

如果只需要询问一个类型是否实现了某个接口,而不实际使用该接口本身(例如作为错误检查的一部分),可以使用空标识符来忽略类型断言的值:

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

在实现类型的包内部需要确保该类型确实满足接口时,这种情况会出现。例如,若某个类型(如json.RawMessage)需要自定义JSON表示,它应当实现json.Marshaler接口,但现有静态转换无法让编译器自动验证此实现。若该类型意外未能满足接口要求,JSON编码器仍可工作,但不会使用自定义实现。为保证实现正确性,可在包中使用空标识符进行全局声明:

var _ json.Marshaler = (*RawMessage)(nil)

此声明中,将*RawMessage转换为Marshaler的赋值操作要求*RawMessage必须实现Marshaler接口,该约束将在编译时检查。若json.Marshaler接口发生变更,此包将无法编译,从而提醒我们需要进行更新。

此结构中出现的空标识符表明该声明仅用于类型检查,而非创建变量。但请勿为每个满足接口的类型都这样做。按照惯例,仅当代码中不存在现有静态转换时才会使用此类声明(这种情况较为罕见)。

嵌入

Go语言未提供典型的基于类型的子类继承机制,但可以通过嵌入类型到结构体或接口中来"借用"部分实现。

接口嵌入非常简单。前文已提及io.Readerio.Writer接口,以下是它们的定义:

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

io 包还导出了其他几个接口,用于指定可实现多个此类方法的对象。例如,io.ReadWriter 是一个同时包含 ReadWrite 方法的接口。我们可以通过显式列出这两个方法来定义 io.ReadWriter,但更简洁且更具表达力的方式是通过嵌入这两个接口来形成新接口,如下所示:

// ReadWriter接口组合了Reader和Writer接口。
type ReadWriter interface {
    Reader
    Writer
}

这段代码的含义正如其表面所示:ReadWriter 可以执行 Reader Writer 的功能;它是嵌入接口的联合体。只有接口可以被嵌入到其他接口中。

同样的基本思想也适用于结构体,但其影响更为深远。bufio 包包含两个结构体类型:bufio.Readerbufio.Writer,它们各自当然实现了 io 包中对应的接口。此外,bufio 包还实现了带缓冲的读写器,它是通过嵌入将一个读取器和一个写入器组合到一个结构体中来实现的:它在结构体内部列出类型,但不赋予它们字段名。

// ReadWriter存储指向Reader和Writer的指针。
// 它实现了io.ReadWriter接口。
type ReadWriter struct {
    *Reader  // *bufio.Reader
    *Writer  // *bufio.Writer
}

嵌入的元素是指向结构体的指针,因此在使用前必须初始化为指向有效的结构体。这个 ReadWriter 结构体也可以写成:

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.Writerio.ReadWriter

嵌入与子类化有一个重要的区别。当我们嵌入一个类型时,该类型的方法会成为外部类型的方法,但在调用时,方法的接收者是内部类型,而非外部类型。在我们的例子中,当调用 bufio.ReadWriterRead 方法时,其效果与上面写出的转发方法完全相同;接收者是 ReadWriterreader 字段,而不是 ReadWriter 本身。

嵌入也可以是一种简单的便利功能。此示例展示了一个嵌入字段与一个常规命名字段并存的情况。

type Job struct {
    Command string
    *log.Logger
}

现在,Job 类型拥有 *log.LoggerPrintPrintfPrintln 等方法。当然,我们可以给 Logger 一个字段名,但这不是必须的。而且,一旦初始化后,我们就可以记录日志到 Job

job.Println("starting now...")

LoggerJob 结构体的一个常规字段,因此我们可以在 Job 的构造函数中以常规方式初始化它,例如:

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

或者使用复合字面量:

job := &Job{command, log.New(os.Stderr, "Job: ", log.Ldate)}

如果我们需要直接引用嵌入字段,该字段的类型名(忽略包限定符)可以用作字段名,就像在我们的 ReadWriter 结构体的 Read 方法中所做的那样。在这里,如果我们需要访问 Job 变量 job*log.Logger,我们可以写成 job.Logger,如果我们想细化 Logger 的方法,这将很有用。

func (job *Job) Printf(format string, args ...interface{}) {
    job.Logger.Printf("%q: %s", job.Command, fmt.Sprintf(format, args...))
}

嵌入类型引入了名称冲突的问题,但解决这些冲突的规则很简单。首先,字段或方法 X 会隐藏类型中更深层次嵌套的任何其他名为 X 的项。如果 log.Logger 包含一个名为 Command 的字段或方法,那么 JobCommand 字段将优先。

其次,如果相同的名称出现在相同的嵌套层级,这通常是一个错误;如果 Job 结构体包含另一个名为 Logger 的字段或方法,那么嵌入 log.Logger 将是错误的。但是,如果重复的名称在类型定义之外的程序中从未被提及,那就没问题。这个限制提供了一些保护,以防止从外部嵌入的类型发生更改;如果一个字段与另一个子类型中的另一个字段冲突,但两者都从未被使用过,那就没有问题。

并发

通过通信共享

并发编程是一个很大的话题,这里只能介绍一些 Go 特有的亮点。

在许多环境中,并发编程之所以困难,是因为实现对共享变量的正确访问需要处理许多微妙之处。Go 鼓励采用一种不同的方法:共享的值在通道(channel)上传递,并且实际上,它们从不会被不同的执行线程主动共享。在任何给定的时间,只有一个协程(goroutine)可以访问该值。从设计上就不可能发生数据竞争。 为了鼓励这种思维方式,我们将其浓缩为一句格言:

不要通过共享内存来通信; 而是通过通信来共享内存。

这种方法也可能被过度使用。例如,引用计数可能最适合通过在整数变量周围设置互斥锁(mutex)来实现。但作为一种高级方法,使用通道来控制访问使得编写清晰、正确的程序变得更加容易。

理解这种模型的一种方式是考虑一个运行在单个 CPU 上的典型单线程程序。它不需要任何同步原语。现在运行另一个这样的实例;它同样不需要同步。现在让这两个实例通信;如果通信本身就能起到同步作用,那么仍然不需要其他同步机制。例如,Unix 管道就完美地符合这种模型。尽管 Go 的并发方法起源于 Hoare 的通信顺序进程(CSP),但它也可以被视为 Unix 管道的一种类型安全泛化。

协程(Goroutines)

它们被称为 协程(goroutines),是因为现有的术语—线程、协程、进程等等—传达了不准确的含义。协程的模型很简单:它是一个与其他协程在同一个地址空间中并发执行的函数。它是轻量级的,其开销仅比分配栈空间稍多一点。 并且栈一开始很小,所以成本低,并根据需要通过分配(和释放)堆存储来增长。

协程被多路复用到多个操作系统线程上,因此如果一个协程阻塞了,比如在等待 I/O 时,其他协程可以继续运行。它们的设计隐藏了许多线程创建和管理的复杂性。

在函数或方法调用前加上 go 关键字,即可在一个新的协程中运行该调用。当调用完成时,协程会静默退出。(其效果类似于 Unix shell 中用于在后台运行命令的 & 表示法。)

go list.Sort()  // 并发运行 list.Sort;不等待它完成。

在协程调用中使用函数字面量(function literal)会很方便。

func Announce(message string, delay time.Duration) {
    go func() {
        time.Sleep(delay)
        fmt.Println(message)
    }()  // 注意括号 - 必须调用该函数。
}

在 Go 中,函数字面量是闭包(closure):其实现确保函数引用的变量在其活跃期间一直存在。

这些例子不太实用,因为这些函数无法发出完成信号。为此,我们需要通道(channels)。

通道(Channels)

与映射(map)类似,通道使用 make 分配,其结果值充当对底层数据结构的引用。 如果提供一个可选的整数参数,它将设置通道的缓冲区大小。 默认值为零,表示无缓冲或同步通道。

ci := make(chan int)            // 整数的无缓冲通道
cj := make(chan int, 0)         // 整数的无缓冲通道
cs := make(chan *os.File, 100)  // 文件指针的有缓冲通道

无缓冲通道将通信(值的交换)与同步(保证两个计算/协程处于已知状态)结合在一起。

使用通道有很多巧妙的惯用法。这里有一个入门示例。 在上一节中,我们在后台启动了一个排序操作。一个通道可以让启动排序的协程等待排序完成。

c := make(chan int)  // 分配一个通道。
// 在一个协程中启动排序;当它完成时,在通道上发信号。
go func() {
    list.Sort()
    c <- 1  // 发送一个信号;值不重要。
}()
doSomethingForAWhile()
<-c   // 等待排序完成;丢弃发送的值。

接收者(receiver)总是阻塞,直到有数据可接收。 如果通道是无缓冲的,发送者(sender)会阻塞,直到接收者接收到值。 如果通道有缓冲,发送者只阻塞到值被复制到缓冲区;如果缓冲区已满,则意味着要等待某个接收者取走一个值。

有缓冲通道可以用作信号量(semaphore),例如用于限制吞吐量。在这个例子中,传入的请求被传递给 handle,它会向通道发送一个值,处理请求,然后从通道接收一个值,以便为下一个消费者准备好"信号量"。 通道缓冲区的容量限制了对 process 的并发调用数量。

var sem = make(chan int, MaxOutstanding)

func handle(r *Request) {
    sem <- 1    // 等待活跃队列排空。
    process(r)  // 可能需要很长时间。
    <-sem       // 完成;允许下一个请求运行。
}

func Serve(queue chan *Request) {
    for {
        req := <-queue
        go handle(req)  // 不等待 handle 完成。
    }
}

一旦 MaxOutstanding 个处理器正在执行 process,任何更多的处理器在尝试向已满的通道缓冲区发送时都会阻塞,直到现有的某个处理器完成并从缓冲区接收。

但这种设计存在问题:Serve会为每个传入的请求创建新的goroutine,尽管同时只能运行MaxOutstanding个。因此,如果请求来得太快,程序可能会消耗无限的资源。我们可以通过修改Serve函数来控制goroutine的创建,从而解决这个问题:

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

(请注意,在Go 1.22之前的版本中,这段代码存在一个bug:循环变量会被所有goroutine共享。详见Go维基。)

另一种良好的资源管理方法是启动固定数量的handle goroutine,它们都从请求通道中读取。goroutine的数量限制了process的并发调用次数。这个Serve函数还接受一个通道,用于通知它退出;启动goroutine后,它会阻塞以接收退出信号。

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

func Serve(clientRequests chan *Request, quit chan bool) {
    // 启动处理器
    for i := 0; i < MaxOutstanding; i++ {
        go handle(clientRequests)
    }
    <-quit  // 等待退出指令。
}

通道的通道

Go最重要的特性之一是通道(channel)是一等值,可以像其他任何值一样分配和传递。该特性的一个常见用途是实现安全的并行多路分解。

在前一节的示例中,handle是一个理想化的请求处理器,但我们没有定义它处理的类型。如果该类型包含用于回复的通道,每个客户端就可以提供自己的答案路径。以下是Request类型的示意图定义。

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)}
// 发送请求
clientRequests <- request
// 等待响应。
fmt.Printf("answer: %d\n", <-request.resultChan)

在服务器端,处理函数是唯一需要改动的部分。

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

要使其具备实际可行性显然还有很多工作要做,但这段代码是一个速率受限、并行、非阻塞的RPC系统框架,并且完全看不到互斥锁(mutex)的影子。

并行化

这些思想的另一个应用是在多个CPU核心上并行化计算。如果计算可以分解为可独立执行的独立部分,就可以实现并行化,并用一个通道来通知每个部分何时完成。

假设我们要对一个向量执行一项昂贵操作,并且该操作在每个元素上的值是独立的,如下所示的理想化示例。

type Vector []float64

// 对 v[i], v[i+1] ... 直到 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    // 通知此部分已完成
}

我们在循环中独立启动这些部分,每个CPU一个。它们可以按任何顺序完成,但这无关紧要;我们只需要在所有goroutine启动后,通过排空通道来计数完成信号。

const numCPU = 4 // CPU核心数

func (v Vector) DoAll(u Vector) {
    c := make(chan int, numCPU)  // 缓冲是可选的,但很合理。
    for i := 0; i < numCPU; i++ {
        go v.DoSome(i*len(v)/numCPU, (i+1)*len(v)/numCPU, u, c)
    }
    // 排空通道。
    for i := 0; i < numCPU; i++ {
        <-c    // 等待一个任务完成
    }
    // 全部完成。
}

与其为 numCPU 创建常量值,我们可以向运行时查询合适的值。函数 runtime.NumCPU 返回机器的硬件 CPU 核心数,因此我们可以这样写:

var numCPU = runtime.NumCPU()

还有一个函数 runtime.GOMAXPROCS,它报告(或设置)用户指定的Go程序可同时运行的核心数。其默认值为 runtime.NumCPU 的值,但可以通过设置同名的 shell 环境变量或传入正数调用该函数来覆盖。传入零值则仅查询当前值。因此,如果我们想遵循用户的资源请求,应该这样写:

var numCPU = runtime.GOMAXPROCS(0)

请注意区分并发(concurrency)——将程序构建为独立执行的组件——和并行(parallelism)——在多个CPU上并行执行计算以提高效率。虽然Go的并发特性使得某些问题易于构建为并行计算,但Go是一门并发语言,而非并行语言,并非所有并行化问题都适合Go的模型。关于区别的讨论,请参阅此博客文章中提到的演讲。

一个有泄漏的缓冲区

并发编程的工具甚至能让非并发思想更容易表达。下面这个示例摘自一个RPC包。客户端goroutine循环从某个源(可能是网络)接收数据。为避免反复分配和释放缓冲区,它维护了一个空闲列表,并用带缓冲的channel来表示。如果channel为空,就分配一个新的缓冲区。 消息缓冲区准备就绪后,通过`serverChan`发送给服务器。

var freeList = make(chan *Buffer, 100)
var serverChan = make(chan *Buffer)

func client() {
    for {
        var b *Buffer
        // 如果可用则获取缓冲区;否则分配新的。
        select {
        case b = <-freeList:
            // 获取成功;无需其他操作。
        default:
            // 没有空闲缓冲区,分配一个新的。
            b = new(Buffer)
        }
        load(b)              // 从网络读取下一条消息。
        serverChan <- b      // 发送给服务器。
    }
}

服务器循环接收客户端发来的每条消息,处理后将缓冲区归还到空闲列表。

func server() {
    for {
        b := <-serverChan    // 等待任务。
        process(b)
        // 如果有空间则复用缓冲区。
        select {
        case freeList <- b:
            // 缓冲区已放回空闲列表;无需其他操作。
        default:
            // 空闲列表已满,直接继续。
        }
    }
}

客户端尝试从`freeList`获取缓冲区;如果没有可用缓冲区,则分配一个新的。服务器向`freeList`发送时,除非列表已满,否则会将`b`放回空闲列表。如果列表已满,缓冲区将被丢弃,由垃圾回收器回收。(`select`语句中的`default`子句在没有其他case就绪时执行,这意味着`select`永远不会阻塞。)这个实现仅用几行代码就构建了一个有泄漏的桶式空闲列表,依靠带缓冲的channel和垃圾回收器进行记账。

错误

库函数通常必须向调用者返回某种错误指示。如前所述,Go的多值返回使得在正常返回值旁返回详细的错误描述变得容易。使用此功能提供详细错误信息是良好的编程风格。例如,我们将看到,os.Open在失败时不仅返回一个nil指针,还会返回一个描述问题所在的错误值。

按照惯例,错误的类型是error,这是一个简单的内置接口。

type error interface {
    Error() string
}

库编写者可以自由地用更丰富的模型在底层实现此接口,使其不仅能显示错误,还能提供一些上下文信息。如前所述,除了通常的*os.File返回值外,os.Open还会返回一个错误值。如果文件打开成功,错误将为nil,但当出现问题时,它将包含一个os.PathError

// PathError 记录一个错误以及导致该错误的操作和文件路径。
type PathError struct {
    Op string    // "open", "unlink" 等。
    Path string  // 相关的文件。
    Err error    // 系统调用返回的错误。
}

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

PathErrorError方法会生成类似这样的字符串:

open /etc/passwx: no such file or directory

这样一个包含了问题文件名、操作以及触发它的操作系统错误的错误信息,即使在远离引发它的调用处打印出来也很有用;它比简单的“no such file or directory”提供的信息要多得多。

在可行的情况下,错误字符串应标识其来源,例如通过添加命名生成错误的操作或包的前缀。例如,在image包中,因未知格式导致的解码错误的字符串表示是“image: unknown format”。

关心错误具体细节的调用者可以使用类型开关或类型断言来查找特定的错误并提取细节。对于PathError,这可能包括检查内部的Err字段,以识别可恢复的故障。

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()  // 恢复一些空间。
        continue
    }
    return
}

这里的第二个if语句是另一个类型断言。如果失败,ok将为false,e将为nil。如果成功,ok将为true,这意味着错误类型是*os.PathError,那么e也是,我们可以检查它以获取有关错误的更多信息。

恐慌

向调用者报告错误的通常方法是将error作为额外的返回值返回。典型的Read方法就是这样一个众所周知的例子;它返回一个字节数和一个error。但如果错误不可恢复怎么办?有时程序就是无法继续执行。

为此,有一个内置函数panic,它实际上会创建一个运行时错误来停止程序(但请参阅下一节)。该函数接受一个任意类型的参数(通常是一个字符串),在程序终止时打印出来。这也是表明发生了不可能之事(例如退出一个无限循环)的一种方式。

// 使用牛顿法实现立方根的玩具示例
func CubeRoot(x float64) float64 {
    z := x/3   // 任意初始值
    for i := 0; i < 1e6; i++ {
        prevz := z
        z -= (z*z*z-x) / (3*z*z)
        if veryClose(z, prevz) {
            return z
        }
    }
    // 迭代一百万次仍未收敛;必定存在问题
    panic(fmt.Sprintf("CubeRoot(%g) did not converge", x))
}

这仅是示例,但真实的库函数应避免使用 panic。若问题可被掩盖或绕过,让程序继续运行总是优于直接终止整个程序。一个可能的反例是在初始化阶段:若库确实无法完成自身配置,此时触发恐慌是合理的。

var user = os.Getenv("USER")

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

恢复机制

当调用 panic 时(包括切片越界索引或类型断言失败等隐式触发的运行时错误),它会立即停止当前函数执行,并开始展开协程的栈结构,沿途执行所有延迟函数。若栈展开到达协程栈顶,程序将终止。但可通过内置函数 recover 重新获取协程控制权并恢复正常执行。

调用 recover 会停止栈展开并返回传递给 panic 的参数。由于栈展开过程中仅执行延迟函数内的代码,因此 recover 仅在延迟函数中生效。

recover 的一种应用场景是:在服务器中关闭出现故障的协程,而不影响其他正在运行的协程。

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) 触发恐慌,结果将被记录且协程会干净退出而不干扰其他协程。延迟闭包中无需其他操作——调用 recover 即可完全处理该状况。

由于 recover 仅在直接通过延迟函数调用时返回非 nil 值,延迟代码可调用内部使用 panicrecover 的库函数而不致失败。例如 safelyDo 中的延迟函数可在调用 recover 前调用日志函数,该日志代码将不受恐慌状态影响而正常运行。

借助此恢复模式,do 函数(及其调用的所有函数)可通过调用 panic 干净地脱离任何异常状态。此理念可用于简化复杂软件中的错误处理。以理想化的 regexp 包为例,其通过调用携带本地错误类型的 panic 来报告解析错误。以下是 Error 类型定义、error 方法及 Compile 函数:

// Error 是解析错误的类型;实现了 error 接口
type Error string
func (e Error) Error() string {
    return string(e)
}

// error 是 *Regexp 的方法,通过触发 Error 类型恐慌来报告解析错误
func (regexp *Regexp) error(err string) {
    panic(Error(err))
}

// Compile 返回正则表达式的解析表示
func Compile(str string) (regexp *Regexp, err error) {
    regexp = new(Regexp)
    // 若存在解析错误,doParse 将触发恐慌
    defer func() {
        if e := recover(); e != nil {
            regexp = nil    // 清空返回值
            err = e.(Error) // 若非解析错误将重新触发恐慌
        }
    }()
    return regexp.doParse(str), nil
}

doParse 触发恐慌,恢复代码块会将返回值设为 nil——延迟函数可修改具名返回值。随后在赋值给 err 时,通过断言问题属于本地类型 Error 来确认其为解析错误。若类型不匹配,类型断言将失败,导致运行时错误从而继续栈展开过程,如同未被中断一样。此检查意味着当发生意外情况(如索引越界)时,即便使用 panicrecover 处理解析错误,程序仍会正常报错。

配备错误处理机制后,error 方法(因其绑定到类型,与内置 error 类型同名是合理且自然的)使得报告解析错误变得轻松,无需手动处理解析栈的展开:

if pos == 0 {
    re.error("'*' illegal at start of expression")
}

尽管此模式十分实用,但应仅限于包内使用。Parse 会将内部的 panic 调用转换为 error 值,而不会向客户端暴露恐慌行为。这是值得遵循的准则。

顺便说明,此重新恐慌的惯用写法会在发生实际错误时改变恐慌值。但崩溃报告中会同时呈现原始错误和新错误,因此问题的根本原因仍然可见。故此简单的重新恐慌方式通常足够有效——毕竟已是崩溃状态——若仅需显示原始值,可编写额外代码来过滤意外问题并用原始错误重新恐慌。这留待读者作为练习。

一个Web服务器

让我们用一个完整的Go程序来结束——一个Web服务器。 这其实是一种Web转服务器。 Google在chart.apis.google.com提供了一项服务, 可以将数据自动格式化为图表和图形。 然而,这项服务在交互使用时并不方便, 因为需要将数据作为查询参数放入URL中。 这里的程序为一种数据形式提供了更友好的接口:给定一段短文本, 它会调用图表服务器生成二维码——一种由方格矩阵编码文本信息的图形。 这个图像可以用手机摄像头捕获并解析为, 例如一个网址,从而免去了在手机微小键盘上输入网址的麻烦。

以下是完整程序。 随后会有解释。

package main

import (
    "flag"
    "html/template"
    "log"
    "net/http"
)

var addr = flag.String("addr", ":1718", "http service address") // Q=17, R=18

var templ = template.Must(template.New("qr").Parse(templateStr))

func main() {
    flag.Parse()
    http.Handle("/", http.HandlerFunc(QR))
    err := http.ListenAndServe(*addr, nil)
    if err != nil {
        log.Fatal("ListenAndServe:", err)
    }
}

func QR(w http.ResponseWriter, req *http.Request) {
    templ.Execute(w, req.FormValue("s"))
}

const templateStr = `
<html>
<head>
<title>QR Link Generator</title>
</head>
<body>
{{if .}}
<img src="http://chart.apis.google.com/chart?chs=300x300&cht=qr&choe=UTF-8&chl={{.}}" />
<br>
{{.}}
<br>
<br>
{{end}}
<form action="/" name=f method="GET">
    <input maxLength=1024 size=70 name=s value="" title="Text to QR Encode">
    <input type=submit value="Show QR" name=qr>
</form>
</body>
</html>
`

main之前的部分应该容易理解。 其中一个标志为服务器设置了默认HTTP端口。模板变量templ是关键所在。它构建了一个HTML模板, 服务器将执行该模板来显示页面;稍后会详细说明。

main函数解析标志,并通过我们之前讨论的机制, 将函数QR绑定到服务器的根路径。 随后调用http.ListenAndServe启动服务器; 该函数在服务器运行期间保持阻塞。

QR函数接收包含表单数据的请求, 并在名为s的表单值数据上执行模板。

模板包html/template功能强大; 本程序仅展示了其部分能力。 本质上,它通过用传递给templ.Execute的数据项派生的元素 (本例中为表单值)替换内容,来实时重写HTML文本。 在模板文本(templateStr)中, 双花括号包裹的部分表示模板操作。 从{{if .}}{{end}}的部分仅在当前数据项的值 (称为.(点))非空时执行。 即当字符串为空时,模板的该部分会被抑制。

两处{{.}}代码片段表示在网页上显示传递给模板的数据——即查询字符串。 HTML模板包会自动进行适当转义,确保显示的文本是安全的。

模板字符串的其余部分是页面加载时显示的HTML内容。 如果以上解释过于简略,请查阅模板包的文档以获得更详尽的说明。

至此,我们得到了:一个用少量代码加上一些数据驱动HTML文本构建的实用Web服务器。 Go语言足够强大,能够用寥寥数行实现诸多功能。