首页 [译]高效 Go
文章
取消

[译]高效 Go

refer: Effective Go

介绍

Go 是一种新的编程语言。尽管它借鉴了现有语言的一些想法,但它具有一些不寻常的特性,这使得有效的 Go 程序在性质上与用其它语言编写的程序有所不同。直接将 C++ 或 Java 程序翻译成 Go 语言的程序不太可能产生令人满意的结果—— Java 程序是用 Java 编写的,而不是 Go 。另一方面,从 Go 的角度思考问题可能会产生一个成功但完全不同的程序。换句话说,要写好 Go 语言,了解它的特性和习惯用法是很重要的。同样重要的是要了解 Go 编程的既定惯例,比如命名、格式化、程序构建等,这样你编写的程序才能被其他 Go 程序员轻松理解。

本文档提供了编写清晰、符合 Go 语言习惯的代码的建议。它是对语言规范Go语言之旅和如何编写Go代码的补充,这些都是你应该首先阅读的。

2022 年 1 月添加的注释:本文档是为 2009 年 Go 语言的发布而编写的,并且自那时起没有进行过重大更新。尽管它是理解如何使用该语言本身的良好指南,由于该语言的稳定性,它对于库方面的内容很少,对于自编写以来 Go 生态系统的重大变化(如构建系统、测试、模块和多态性)则完全没有提及。没有计划对其进行更新,因为发生了很多事情,而且有一大套不断增长的文档、博客和书籍很好地描述了现代 Go 的使用。《 Effective Go 》仍然有用,但读者应该理解它远非完整指南。有关上下文,请参见问题 28782

例子

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

格式

格式问题是最有争议但影响最小的问题。人们可以适应不同的格式风格,但最好是不必这样做,如果每个人都遵循相同的风格,那么在这个主题上花费的时间就会减少。问题是如何在没有冗长的指导风格指南的情况下实现这一乌托邦。

在 Go 中,我们采用了一种不同寻常的方法,让机器处理大多数格式问题。 gofmt 程序(也可用作 go fmt ,它在包级别而不是源文件级别操作)读取 Go 程序并以标准缩进和垂直对齐的样式发出源代码,保留并在必要时重新格式化注释。如果想知道如何处理某种新的布局情况,运行 gofmt ;如果答案看起来不对,重新排列你的程序(或报告关于 gofmt 的错误),而不是绕过它。

例如,没有必要花时间将结构字段的注释排成一行。 Gofmt 将为您完成这项工作。鉴于声明

1
2
3
4
type T struct {
    name string // name of the object
    value int // its value
}

gofmt 将会将列对齐

1
2
3
4
type T struct {
    name    string // name of the object
    value   int    // its value
}

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

一些格式细节仍然存在。简要说:

缩进

我们使用制表符进行缩进, gofmt 默认会发出它们。只有在必要时使用空格。

行长度

Go 没有行长度限制。不用担心溢出打孔卡。如果一行感觉太长,可以换行并缩进一个额外的制表符。

括号

与 C 和 Java 相比, Go 需要的括号较少:控制结构( ifforswitch )在语法中不包含括号。此外,运算符优先级层次较短且更清晰,因此 x<<8 + y<<16 的含义与其他语言中的间距暗示一致。

注释

Go 提供类似于 C 的 /* */ 块注释和 C++ 的 // 行注释。行注释是常规的;块注释主要出现在包注释中,但在表达式中或用于禁用大段代码时也很有用。

出现在顶层声明之前,没有空行的注释被视为对声明本身的文档。这些“文档注释”是给定 Go 包或命令的主要文档。有关文档注释的更多信息,请参见“ Go 文档注释 ”。

命名

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

包名

当导入一个包时,包名变成了其内容的访问器。例如:

1
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) 而变得更好。长名称并不总是会使事物更易读。一个有帮助的文档注释通常比过长的名称更有价值。

方法名

Go 不提供对 gettersetter 的自动支持。为自己提供 gettersetter 并没有什么问题,而且通常是合适的,但在 getter 的名称中加入 Get 既不符合惯例,也不是必需的。如果你有一个名为 owner(小写,未导出)的字段, getter 方法应该被命名为 Owner(大写,导出),而不是 GetOwner 。将大写名称用于导出提供了一种区分字段和方法的方法。如果需要的话, setter 函数可能被称为 SetOwner 。在实践中,这两个名称都很好阅读:

1
2
3
4
owner := obj.Owner()
if owner != user {
    obj.SetOwner(user)
}

接口名

按照约定,只有一个方法的接口通常以方法名加上 -er 后缀或类似的修改构造代理名词,例如 ReaderWriterFormatterCloseNotifier 等。

有很多这样的命名,遵循它们以及它们捕捉的函数名是很有益的。 ReadWriteCloseFlushString 等具有规范的签名和含义。为了避免混淆,除非你的方法具有相同的签名和含义,否则不要给你的方法起这些名称。相反,如果你的类型实现了与某个知名类型上的方法相同含义的方法,请给它相同的名称和签名;将你的字符串转换方法命名为 String 而不是 ToString

混合大小写

最后,在 Go 中,惯例是使用 MixedCaps 或 mixedCaps 而不是使用下划线来书写多单词名称。

分号

与 C 语言类似,Go 的正式语法使用分号终止语句,但不同于 C 的是,这些分号不会出现在源代码中。相反,词法分析器使用一个简单的规则在扫描时自动插入分号,因此输入文本大多数情况下是没有分号的。

规则如下。如果换行符前的最后一个标记是标识符(其中包括像 intfloat64 这样的单词)、基本文字(如数字或字符串常量)或以下标记之一:

1
break continue fallthrough return ++ -- ) }

词法分析器会在该标记后始终插入分号。可以概括为:“如果换行符出现在可能结束语句的标记之后,插入分号”。

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

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

不需要分号。惯用的 Go 程序只在像 for 循环的子句中使用分号,用于分隔初始化、条件和继续元素。在一行上写多个语句时,分号也是必需的。

由于分号插入规则,控制结构( ifforswitchselect )的左花括号不能放在下一行。如果这样做,将在左花括号之前插入分号,这可能会导致意外效果。应该这样写:

1
2
3
if i < f() {
    g()
}

而不是这样写:

1
2
3
4
if i < f()  // 错误!
{           // 错误!
    g()
}

控制结构

Go 的控制结构与 C 语言的相关,但在重要方面有所不同。Go 中没有 dowhile 循环,只有一个稍微泛化的 for 循环;switch更加灵活;ifswitch 接受一个可选的初始化语句,就像 for 一样; breakcontinue 语句可以带有可选标签,用于标识何时中断或继续执行;还有一些新的控制结构,包括类型开关( type switch )和多路通信复用器( select )。语法也略有不同:没有括号,而且主体必须始终用大括号括起来。

条件

在 Go 中,简单的 if 语句看起来像这样:

1
2
3
if x > 0 {
    return y
}

强制使用大括号有助于将简单的if语句写成多行。无论如何,这是一种良好的风格,特别是当语句体包含控制语句(如 returnbreak )时。

由于 ifswitch 接受初始化语句,因此通常会看到它们用于设置局部变量。

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

在 Go 库中,你会发现当if语句的执行体不会进入下一个语句时——也就是说,语句体以 breakcontinuegotoreturn 结束时——不必要的 else 会被省略。

1
2
3
4
5
f, err := os.Open(name)
if err != nil {
    return err
}
codeUsing(f)

这是一个常见情况的例子,代码必须防范一系列错误条件。如果控制流程成功地沿着页面向下运行,逐渐消除错误情况,那么代码会更易读。由于错误情况通常以 return 语句结束,因此生成的代码不需要 else 语句。

1
2
3
4
5
6
7
8
9
10
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 的声明如下:

1
f, err := os.Open(name)

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

1
d, err := f.Stat()

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

:= 声明中,即使变量 v 已经被声明,它也可以出现,条件是:

  • 此声明与现有的 v 声明位于相同的作用域(如果 v 已经在外部作用域中声明,该声明将创建一个新变量§),
  • 初始化中的相应值可以赋值给 v ,并且
  • 该声明至少创建了一个其他变量。

这种不寻常的属性是纯粹的实用主义,使得在长的 if-else 链中很容易使用单个 err 值,例如。你会经常看到这样的用法。

§ 值得注意的是,在 Go 中,函数参数和返回值的作用域与函数体相同,尽管它们在包围函数体的大括号外面词法上出现。

循环

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

1
2
3
4
5
6
7
8
// 类似于C的for
for init; condition; post { }

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

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

使用短声明可以方便地在循环中声明索引变量。

1
2
3
4
sum := 0
for i := 0; i < 10; i++ {
    sum += i
}

如果循环遍历数组、切片、字符串、映射或从通道读取,可以使用 range 子句来管理循环。

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

如果只需要范围中的第一项(键或索引),可以省略第二项:

1
2
3
4
5
for key := range m {
    if key.expired() {
        delete(m, key)
    }
}

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

1
2
3
4
sum := 0
for _, value := range array {
    sum += value
}

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

对于字符串, range 会为你完成更多的工作,通过解析 UTF-8 来分解单个 Unicode 码点。错误的编码会消耗一个字节并产生替换符 U+FFFD 。(名称(带有相关内置类型) rune 是 Go 术语,用于表示单个 Unicode 码点。详见语言规范。)以下循环:

1
2
3
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 中运行多个变量,应使用并行赋值(尽管这将排除 ++-- )。

1
2
3
4
// 反转a
for i, j := 0, len(a)-1; i < j; i, j = i+1, j-1 {
    a[i], a[j] = a[j], a[i]
}

开关语句

Go 的 switch 语句比 C 的更加通用。表达式不需要是常量或者整数, case 会从上到下依次被评估,直到找到匹配项。如果 switch 没有表达式,它将切换到 true 。因此,将 if-else-if-else 链写成 switch 是可能的,也是惯用的。

1
2
3
4
5
6
7
8
9
10
11
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 可以以逗号分隔的列表形式呈现。

1
2
3
4
5
6
7
func shouldEscape(c byte) bool {
    switch c {
    case ' ', '?', '&', '=', '#', '+', '%':
        return true
    }
    return false
}

尽管在 Go 中它们不如一些其他类似 C 的语言那么常见,但是 break 语句可以用于提前终止 switch 。有时,需要中断外部循环,而不是 switch ,在 Go 中可以通过在循环上放置标签并 break 到该标签来实现。这个例子展示了两种用法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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 语句编写的比较字节切片的例程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 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 中声明了一个具有相同名称但不同类型的新变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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 程序中的一些笨拙的习惯:如使用 -1 表示 EOF 的带内错误返回和修改通过地址传递的参数。

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

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

正如文档所说,当 n != len(b) 时,它会返回写入的字节数和一个非 nil 的错误。这是一种常见的风格;更多示例请参阅有关错误处理的章节。

类似的方法消除了需要传递指向返回值的指针以模拟引用参数的需要。以下是一个简单的函数,从字节切片的某个位置获取一个数字,并返回该数字和下一个位置。

1
2
3
4
5
6
7
8
9
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 中的数字,如下所示:

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

命名返回参数

Go 函数的返回值或结果”参数”可以被赋予名字,并且像传入参数一样被用作普通变量。当命名时,在函数开始时它们会被初始化为它们类型的零值;如果函数执行一个没有参数的 return 语句,结果参数的当前值将被用作返回值。

这些名字并不是强制性的,但它们可以使代码更短、更清晰:它们就是文档。如果我们给 nextInt 的结果取名,就能清楚地知道返回的 int 是哪一个。

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

因为命名的结果被初始化并与未修饰的return关联在一起,它们不仅可以简化代码,还可以使其更清晰。以下是一个很好地使用了命名结果的io.ReadFull版本:

1
2
3
4
5
6
7
8
9
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 的函数返回之前立即运行。这是一种不同寻常但有效的处理资源释放等情况的方式,无论函数通过哪个路径返回,都必须释放资源。经典的例子包括解锁互斥锁或关闭文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 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 这样的函数有两个优势。首先,它保证您永远不会忘记关闭文件,如果以后编辑函数以添加新的返回路径,则很容易犯这个错误。其次,这意味着关闭与打开相邻,这比将其放在函数末尾更清晰。

推迟函数的参数(如果函数是一个方法,则包括接收者)在推迟执行时进行评估,而不是在调用执行时进行评估。除了避免担忧变量在函数执行过程中改变值之外,这意味着单个推迟调用站点可以推迟多个函数执行。这是一个愚蠢的例子。

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

推迟的函数以 LIFO 顺序执行,因此此代码在函数返回时将打印 4 3 2 1 0 。一个更合理的例子是通过程序跟踪函数执行的简单方法。我们可以编写一对简单的跟踪例程,如下所示:

1
2
3
4
5
6
7
8
9
func trace(s string)   { fmt.Println("entering:", s) }
func untrace(s string) { fmt.Println("leaving:", s) }

// 使用方法如下:
func a() {
    trace("a")
    defer untrace("a")
    // 做一些事情....
}

通过利用推迟函数的参数在推迟执行时进行评估的事实,我们可以更好地设置传递给取消跟踪例程的参数。以下是一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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()
}

打印:

1
2
3
4
5
6
entering: b
in b
entering: a
in a
leaving: a
leaving: b

对于从其他语言习惯于块级资源管理的程序员来说,defer 可能看起来有些奇怪,但它最有趣和强大的应用正是因为它不是基于块而是基于函数的。在关于 panicrecover 的部分,我们将看到它的另一个应用示例。

数据

使用 new 分配

Go有两个分配原语,内置函数 newmake 。它们执行不同的操作并应用于不同的类型,这可能会令人困惑,但规则很简单。首先讨论 new 。它是一个内置函数,用于分配内存,但与其他一些语言中的同名函数不同,它不会初始化内存,只会将其清零。也就是说, new(T) 会为类型为 T 的新项目分配零值存储,并返回其地址,即 *T 类型的值。在 Go 术语中,它返回指向新分配的 T 类型零值的指针。

由于 new 返回的内存是清零的,因此在设计数据结构时安排零值可以在不进行进一步初始化的情况下使用。这意味着数据结构的用户可以使用new创建一个并立即开始使用。例如, bytes.Buffer 的文档说明“ Buffer 的零值是一个准备好使用的空缓冲区”。类似地, sync.Mutex 没有显式的构造函数或 Init 方法。相反, sync.Mutex 的零值被定义为一个解锁的互斥锁。

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

1
2
3
4
type SyncedBuffer struct {
    lock    sync.Mutex
    buffer  bytes.Buffer
}

SyncedBuffer 类型的值在分配或仅声明后也是立即可用的。在下面的代码片段中, pv 都将在不进行进一步安排的情况下正常工作。

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

构造函数和复合字面量

有时零值是不够的,需要一个初始化构造函数,就像从 os 包派生的这个例子一样。

1
2
3
4
5
6
7
8
9
10
11
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
}

这里有很多样板代码。我们可以使用复合文字来简化它,复合文字是一个在每次评估时都创建新实例的表达式。

1
2
3
4
5
6
7
func NewFile(fd int, name string) *File {
    if fd < 0 {
        return nil
    }
    f := File{fd, name, nil, 0}
    return &f
}

请注意,与 C 语言不同,完全可以返回局部变量的地址;与变量相关联的存储在函数返回后仍然存在。实际上,对复合文字取地址会在每次评估时分配一个新实例,因此我们可以将这两行合并为一个。

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

复合文字的字段按顺序排列,必须全部存在。然而,通过将元素明确标记为字段:值对,初始化器可以以任何顺序出现,缺失的元素将保持它们各自的零值。因此,我们可以这样说

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

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

复合文字也可以用于数组、切片和映射,字段标签可以是适当的索引或映射键。在这些例子中,只要它们是不同的,初始化就可以工作, EnoneEioEinval 的值不受影响。

1
2
3
4
5
6
7
8
9
const (
	Enone = 0
	Eio = 1
	Einval = 5
)

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) 不同。它仅创建切片( slices )、映射( maps )和通道( channels ),并返回类型 T (而不是 *T )的已初始化(而非零值)值。区分它们的原因在于这三种类型在底层表示对在使用前必须初始化的数据结构的引用。例如,切片( slice )是一个包含指向数据的指针(位于数组内部)、长度和容量的三项描述符,直到这些项被初始化,切片都是 nil 。对于切片、映射和通道, make 初始化了内部数据结构并准备了值以供使用。例如,

1
make([]int, 10, 100)

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

这些例子说明了 newmake 之间的区别。

1
2
3
4
5
6
7
8
9
var p *[]int = new([]int)       // 分配切片结构;*p == nil;很少有用
var v  []int = make([]int, 100) // 切片v现在引用一个包含100个int的新数组

// 不必要的复杂性:
var p *[]int = new([]int)
*p = make([]int, 100, 100)

// 惯用法:
v := make([]int, 100)

请记住, make 仅适用于映射、切片和通道,不返回指针。要获得显式指针,请使用 new 进行分配,或明确取变量的地址。

数组

数组在规划内存的详细布局和有时避免分配时很有用,但主要是切片的构建块,是下一节的主题。为了为该主题打下基础,这里有关数组的一些建议。

Go 中的数组和 C 中的数组工作方式存在重大差异:

  • 数组是值。将一个数组赋值给另一个数组会复制所有元素。
  • 特别地,如果将数组传递给函数,它将接收到数组的副本,而不是指向它的指针。
  • 数组的大小是其类型的一部分。类型 [10]int[20]int 是不同的。

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

1
2
3
4
5
6
7
8
9
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)  // 注意显式的地址-of操作符

但即使这种风格也不符合 Go 的惯例。请改用切片。

切片

切片包装数组,提供了对数据序列的更一般、强大和便捷的接口。除了具有显式维度的项目(例如变换矩阵)外,Go 中的大多数数组编程都是使用切片而不是简单数组完成的。

切片保存对底层数组的引用,如果你将一个切片赋给另一个切片,两者都引用同一个数组。如果一个函数接受切片参数,并且对切片元素进行更改,这些更改将对调用者可见,类似于传递指向底层数组的指针。因此, Read 函数可以接受一个切片参数而不是指针和计数;切片中的长度设置了读取数据的上限。这是包 osFile 类型的 Read 方法的签名:

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

该方法返回读取的字节数和错误值(如果有)。要读取到较大缓冲区 buf 的前 32 个字节,切片(此处用作动词)该缓冲区。

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

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

1
2
3
4
5
6
7
8
9
10
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 访问,报告切片可能达到的最大长度。下面是一个将数据追加到切片的函数。如果数据超过容量,将重新分配切片。返回结果切片。该函数利用了对 nil 切片应用 lencap 时是合法的事实,并返回 0。

1
2
3
4
5
6
7
8
9
10
11
12
13
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 可以修改切片的元素,但切片本身(持有指针、长度和容量的运行时数据结构)是按值传递的。

对切片进行追加的想法非常有用,这就是 append 内置函数所实现的。要理解该函数的设计,我们需要更多的信息,因此我们稍后会再回到它。

二维切片

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

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

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

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

有时需要分配一个二维切片,例如在处理像素的扫描行时。有两种方法可以实现这一点。一种是独立分配每个切片;另一种是分配一个单一的数组并将单个切片指向它。使用哪种方式取决于你的应用程序。如果切片可能会增长或缩小,它们应该独立分配,以避免覆盖下一行;如果不会,通过单一分配构造对象可能更有效。以下是这两种方法的草图。首先,逐行分配:

1
2
3
4
5
6
// 分配顶层切片。
picture := make([][]uint8, YSize) // 每个 y 单位一行。
// 遍历行,为每一行分配切片。
for i := range picture {
    picture[i] = make([]uint8, XSize)
}

现在作为一次分配,切片成行:

1
2
3
4
5
6
7
8
// 分配顶层切片,与之前相同。
picture := make([][]uint8, YSize) // 每个 y 单位一行。
// 分配一个大的切片来容纳所有的像素。
pixels := make([]uint8, XSize*YSize) // 具有 []uint8 类型,即使 picture 是 [][]uint8。
// 遍历行,从剩余的像素切片的前面切片每一行。
for i := range picture {
    picture[i], pixels = pixels[:XSize], pixels[XSize:]
}

映射

映射是一种方便而强大的内置数据结构,将一个类型(键)的值与另一个类型(元素或值)的值关联起来。键可以是任何定义了相等性操作符的类型,例如整数、浮点数和复数、字符串、指针、接口(只要动态类型支持相等性)、结构体和数组。切片不能用作映射键,因为在切片上未定义相等性。与切片一样,映射保存对底层数据结构的引用。如果将映射传递给更改映射内容的函数,更改将在调用者中可见。

可以使用常规的复合文字语法构建映射,其中使用冒号分隔键值对,因此在初始化期间构建它们非常容易。

1
2
3
4
5
6
7
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,
}

分配和获取映射值在语法上看起来与对数组和切片执行相同的操作一样,只是索引不需要是整数。

1
offset := timeZone["EST"]

尝试使用不在映射中的键获取映射值将返回映射中条目类型的零值。例如,如果映射包含整数,则查找不存在的键将返回0。可以将集合实现为值类型为 bool 的映射。将映射条目设置为 true 以将值放入集合,然后通过简单的索引进行测试。

1
2
3
4
5
6
7
8
9
attended := map[string]bool{
    "Ann": true,
    "Joe": true,
    // ...
}

if attended[person] { // 如果 person 不在映射中,将为 false
    fmt.Println(person, "在会议上")
}

有时需要区分缺失的条目和零值。有关 “ UTC “ 的条目存在还是因为它根本不存在映射中而为 0 ?可以使用多赋值的形式进行区分。

1
2
3
var seconds int
var ok bool
seconds, ok = timeZone[tz]

出于显而易见的原因,这被称为“ comma ok ” 习惯用语。在此示例中,如果 tz 存在,则将适当设置 seconds ,并且 ok 将为 true ;如果不存在,则 seconds 将设置为零, ok 将为 false 。以下是将其与良好的错误报告结合在一起的函数:

1
2
3
4
5
6
7
func offset(tz string) int {
    if seconds, ok := timeZone[tz]; ok {
        return seconds
    }
    log.Println("未知时区:", tz)
    return 0
}

要测试映射中是否存在某个键而不担心实际值,可以使用下划线(_)代替通常用于值的变量。

1
_, present := timeZone[tz]

要删除映射条目,请使用内置的 delete 函数,其参数是映射和要删除的键。即使键已从映射中删除,执行此操作也是安全的。

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

打印

Go 中的格式化打印使用与 C 的 printf 系列相似但更丰富和更通用的风格。这些函数位于 fmt 包中,具有首字母大写的名称: fmt.Printffmt.Fprintffmt.Sprintf 等。字符串函数(如 Sprintf 等)返回一个字符串而不是填充提供的缓冲区。

不需要提供格式字符串。对于 PrintfFprintfSprintf 的每一个,都有另一对函数,例如 PrintPrintln 。这些函数不使用格式字符串,而是为每个参数生成默认格式。 Println 版本还在参数之间插入一个空格,并在输出末尾追加换行,而 Print 版本只有在两侧的操作数都不是字符串时才添加空格。在此示例中,每行产生相同的输出。

1
2
3
4
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 之类的数字格式不带有有符号性或大小的标志;相反,打印例程使用参数的类型来决定这些属性。

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

如果只想要默认转换,例如对于整数的十进制,可以使用通用格式%v(用于“ value ”);结果与PrintPrintln产生的结果完全相同。而且,该格式可以打印任何值,甚至是数组、切片、结构体和映射。以下是上一节中定义的时区映射的打印语句。

1
fmt.Printf("%v\\n", timeZone)  // 或者只是 fmt.Println(timeZone)

打印映射时,Printf 等按键的字典顺序对输出进行排序。

在打印结构体时,修改的格式 %+v 会使用它们的名称注释结构的字段,而对于任何值,备用格式 %#v 会使用完整的 Go 语法打印值。

1
2
3
4
5
6
7
8
9
10
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)

该引用字符串格式也适用于对类型为字符串或 []byte 的值应用 %q 时。如果可能,备用格式 %#q 将使用反引号。 ( %q 格式也适用于整数和 runes,产生带引号的 rune 常量。) 此外,%x 对字符串、字节数组和字节切片以及整数都有效,生成一个较长的十六进制字符串,格式化字符串( % x )中有一个空格会在字节之间添加空格。

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

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

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

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

我们的 String 方法能够调用 Sprintf ,因为打印例程是完全可重入的,可以这样包装。然而,关于这种方法有一个重要的细节需要了解:不要通过以将在您的 String 方法中无限递归的方式调用 Sprintf 来构造 String 方法。如果 Sprintf 调用尝试将接收方直接作为字符串打印,那么将再次调用该方法。这是一个常见且容易犯的错误,正如以下示例所示。

1
2
3
4
5
type MyString string

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

修复它也很容易:将参数转换为基本字符串类型,它没有该方法。

1
2
3
4
type MyString string
func (m MyString) String() string {
    return fmt.Sprintf("MyString=%s", string(m)) // OK:注意转换。
}

在初始化部分中,我们将看到避免此递归的另一种技术。

另一种打印技术是将打印例程的参数直接传递给另一个这样的例程。 Printf 的签名在其最后一个参数使用类型 ...interface{} 指定,以指定格式后可以出现任意数量的参数(任意类型)。在 Printf 函数内部, v 的行为就像类型为 []interface{} 的变量,但如果将它传递给另一个可变参数函数,它将像常规参数列表一样工作。以下是我们上面使用的 log.Println 函数的实现。它将其参数直接传递给 fmt.Sprintln 进行实际格式化。

1
2
3
4
// Println 以 fmt.Println 的方式打印到标准记录器。
func Println(v ...interface{}) {
    std.Output(2, fmt.Sprintln(v...))  // Output 接受参数 (int, string)
}

在对 Sprintln 的嵌套调用中,我们在 v 之后加上 ...,告诉编译器将 v 视为参数列表;否则它将 v 作为单个切片参数传递。打印还有更多的内容,我们在这里没有涉及。有关详细信息,请参阅fmt包的 godoc 文档。顺便说一下,... 参数可以是特定类型,例如 ...int ,用于 min 函数,它从整数列表中选择最小值。

1
2
3
4
5
6
7
8
9
func Min(a ...int) int {
    min := int(^uint(0) >> 1)  // 最大 int
    for _, i := range a {
        if i < min {
            min = i
        }
    }
    return min
}

追加

现在我们有了解释 append 内置函数设计的缺失部分。 append 的签名与我们上面自定义的 Append 函数不同。概括而言,它是这样的:

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

其中,T是任何给定类型的占位符。在Go中,您不能编写一个函数,其中类型T由调用者确定。这就是为什么 append 是内建的原因:它需要编译器的支持。

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

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

输出是 [1 2 3 4 5 6]。因此,append 的工作方式有点像 Printf ,收集任意数量的参数。

但是,如果我们想做我们的 Append 所做的事情,将一个切片附加到另一个切片呢?很简单:在调用站点使用 ...,就像我们在上面对 Output 的调用中所做的那样。以下代码片段生成与上面相同的输出:

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

如果没有 ... ,它将无法编译,因为类型会出错; y 的类型不是 int

初始化

虽然 Go 中的初始化看起来表面上与 C 或 C++ 中的初始化并没有太大不同,但在 Go 中,初始化更加强大。在初始化过程中可以构建复杂的结构,并且即使在不同的包之间,初始化对象之间的顺序问题也会被正确处理。

常量

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
type ByteSize float64

const (
    _           = iota // 通过赋值给空白标识符忽略第一个值
    KB ByteSize = 1 << (10 * iota)
    MB
    GB
    TB
    PB
    EB
    ZB
    YB
)

可以通过附加 String 等方法到任何用户定义的类型,使任意值能够自动格式化自己以便打印。尽管你最常见到它应用于结构体,但这种技术对于标量类型(如 ByteSize 类型的浮点数类型)也很有用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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,而 %f 不是字符串格式:Sprintf 只有在需要字符串时才会调用 String 方法,而 %f 需要浮点数值。

变量

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

1
2
3
4
5
var (
    home   = os.Getenv("HOME")
    user   = os.Getenv("USER")
    gopath = os.Getenv("GOPATH")
)

init 函数

最后,每个源文件都可以定义自己的无参数的 init 函数来设置所需的任何状态。(实际上,每个文件可以有多个 init 函数。)“最后”确实意味着最后:init 在包中的所有变量声明都已经计算了它们的初始化器之后调用,而这些初始化器仅在导入的所有包都已经初始化之后才会计算。

除了无法表示为声明的初始化之外,init 函数的常见用途是在真正的执行开始之前验证或修复程序状态的正确性。

1
2
3
4
5
6
7
8
9
10
11
12
13
func init() {
    if user == "" {
        log.Fatal("$USER not set")
    }
    if home == "" {
        home = "/home/" + user
    }
    if gopath == "" {
        gopath = home + "/go"
    }
    // gopath 可能会被命令行上的 --gopath 标志覆盖。
    flag.StringVar(&gopath, "gopath", gopath, "override default GOPATH")
}

方法

指针 vs 值

正如我们在 ByteSize 中看到的那样,方法可以为任何具名类型定义(除了指针或接口);接收者不必是结构体。

在前面关于切片的讨论中,我们编写了一个 Append 函数。我们可以将其定义为切片的方法。为此,我们首先声明一个可以将方法绑定到其中的具名类型,然后将该类型的值作为方法的接收者。

1
2
3
4
5
type ByteSlice []byte

func (slice ByteSlice) Append(data []byte) []byte {
    // 方法体与上面定义的 Append 函数完全相同。
}

这仍然需要该方法返回更新后的切片。我们可以通过将该方法重新定义为以 ByteSlice 的指针作为其接收者,使该方法可以覆盖调用者的切片,从而消除这种笨拙的情况。

1
2
3
4
5
func (p *ByteSlice) Append(data []byte) {
    slice := *p
    // 方法体如上,没有返回语句。
    *p = slice
}

事实上,我们可以做得更好。如果我们修改我们的函数,使其看起来像一个标准的 Write 方法,就像这样:

1
2
3
4
5
6
func (p *ByteSlice) Write(data []byte) (n int, err error) {
    slice := *p
    // 再次如上。
    *p = slice
    return len(data), nil
}

那么类型 *ByteSlice 就满足标准接口 io.Writer,这很方便。例如,我们可以将其用于打印。

1
2
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 代码中,具有一个或两个方法的接口很常见,通常根据方法的名称为其命名,比如实现 Write 方法的 io.Writer 接口。

一种类型可以实现多个接口。例如,如果一个集合实现了 sort.Interface 中的方法(包含 Len()Less(i, j int) boolSwap(i, j int) ),那么它就可以被 sort包中的函数进行排序,同时它还可以具有自定义的格式化程序。在这个假设的例子中, Sequence 满足了这两个接口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
type Sequence []int

// 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返回Sequence的副本。
func (s Sequence) Copy() Sequence {
    copy := make(Sequence, 0, len(s))
    return append(copy, s...)
}

// 用于打印的方法 - 在打印之前对元素进行排序。
func (s Sequence) String() string {
    s = s.Copy() // 创建一个副本;不要覆盖原始参数。
    sort.Sort(s)
    str := "["
    for i, elem := range s { // 循环是O(N²);在下一个例子中将解决这个问题。
        if i > 0 {
            str += " "
        }
        str += fmt.Sprint(elem)
    }
    return str + "]"
}

类型转换

SequenceString 方法重新创建了 Sprint 对切片的工作。(它还具有 O(N²) 的复杂度,这是相当低效的。)如果在调用 Sprint 之前将 Sequence 转换为普通的 []int ,我们可以共享这个工作(并且还可以加快速度)。

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

这个方法是调用 SprintfString 方法的另一个例子,安全地使用了转换技巧。因为这两个类型( Sequence[]int )如果忽略类型名称,是相同的,所以在它们之间进行转换是合法的。转换并不创建新值,它只是暂时地使现有值表现为具有新类型。(还有其他合法的转换,比如从整数到浮点数,这样会创建一个新值。)

在 Go 程序中,将表达式的类型转换为不同的方法集是一种习惯用法。例如,我们可以使用现有的类型 sort.IntSlice ,将整个示例简化为:

1
2
3
4
5
6
7
8
type Sequence []int

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

现在,我们不再需要让 Sequence 实现多个接口(排序和打印),而是使用了数据项能够转换为多个类型的能力( Sequencesort.IntSlice[]int ),每个类型都完成了一部分工作。这在实践中较为不寻常,但可以是有效的。

接口转换和类型断言

类型 switch 是一种转换形式:它接收一个接口,并对于 switch 中的每个 case ,在某种意义上将其转换为该 case 的类型。以下是代码中 fmt.Printf 使用类型 switch 将值转换为字符串的简化版本。如果它已经是一个字符串,我们希望获得接口持有的实际字符串值,而如果它有一个 String 方法,我们希望获得调用该方法的结果。

1
2
3
4
5
6
7
8
9
10
11
type Stringer interface {
    String() string
}

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

第一个 case 找到了一个具体的值;第二个 case 将接口转换为另一个接口。以这种方式混合类型是完全可以的。

如果我们只关心一种类型怎么办?如果我们知道该值保存了一个字符串,我们只是想提取它,那么一个一种 case 的类型 switch 就足够了,但类型断言也可以实现。类型断言从一个接口值中提取指定显式类型的值。语法借用了打开类型 switch 的子句,但使用的是显式类型而不是 type 关键字:

1
value.(typeName)

结果是一个具有静态类型 typeName 的新值。该类型必须是接口持有的具体类型,或者是该值可以转换为的第二个接口类型。为了提取我们知道存在于值中的字符串,我们可以写:

1
str := value.(string)

但如果结果发现值并不包含一个字符串,程序将因运行时错误而崩溃。为了防范这种情况,使用 “ comma, ok ”惯用法进行安全测试,以确定值是否为字符串:

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

如果类型断言失败,str 仍将存在且为字符串类型,但它将具有零值,即空字符串。

作为这种能力的说明,以下是一个等效于打开本节的类型 switchif-else 语句。

1
2
3
4
5
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/cipher 包中的 Block 接口指定了分组密码的行为,提供了对单个数据块的加密。然后,类似于 bufio 包,实现这个接口的密码包可以用来构建流密码,由 Stream 接口表示,而无需知道分组加密的详细信息。

crypto/cipher 接口如下:

1
2
3
4
5
6
7
8
9
type Block interface {
    BlockSize() int
    Encrypt(dst, src []byte)
    Decrypt(dst, src []byte)
}

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

下面是计数器模式( CTR )流密码的定义,将分组密码转换为流密码;请注意,分组密码的详细信息被抽象掉:

1
2
3
// NewCTR returns a Stream that encrypts/decrypts using the given Block in
// counter mode. The length of iv must be the same as the Block's block size.
func NewCTR(block Block, iv []byte) Stream

NewCTR 不仅适用于一个特定的加密算法和数据源,而且适用于 Block 接口的任何实现和任何 Stream。由于它们返回接口值,因此用其他加密模式替换 CTR 加密是一个局部的更改。构造函数调用必须进行编辑,但由于周围的代码必须将结果仅视为 Stream,它不会注意到差异。

接口和方法

由于几乎任何东西都可以附加方法,几乎任何东西都可以满足一个接口。一个说明性的例子在 http 包中,它定义了 Handler 接口。任何实现 Handler 接口的对象都可以用于处理 HTTP 请求。

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

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

为了简洁起见,我们忽略 POST 请求,假设 HTTP 请求总是 GET 请求;这种简化不会影响处理程序的设置方式。以下是一个用于计算页面访问次数的处理程序的简单实现。

1
2
3
4
5
6
7
8
9
// 简单的计数器服务器
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)
}

在一个真实的服务器中,对 ctr.n 的访问需要保护免受并发访问的影响。参见 syncatomic 包以获取建议。

要参考的是如何将这样的服务器附加到URL树上的节点。

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

但是为什么将 Counter 设为结构体呢?只需要一个整数。(接收者需要是一个指针,以便增量对调用者可见。)

1
2
3
4
5
6
7
// 更简单的计数器服务器
type Counter int

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

如果你的程序有一些内部状态需要被通知页面已被访问,将一个通道与Web页面绑定。

1
2
3
4
5
6
7
8
// 每次访问都发送通知的通道
// (可能希望通道是有缓冲的。)
type Chan chan *http.Request

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

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

1
2
3
func ArgServer() {
    fmt.Println(os.Args)
}

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

1
2
3
4
5
6
7
8
9
10
// 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 服务器,我们首先修改它以具有正确的签名。

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

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

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

当有人访问 /args 页面时,该页面上安装的处理程序的值是 ArgServer ,类型是 HandlerFunc 。HTTP服务器将调用该类型的 ServeHTTP 方法,以 ArgServer 作为接收者,接着 ArgServer 将被调用(通过 HandlerFunc.ServeHTTP 内部的 f(w, req) 调用)。然后将显示参数。

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

空白标识符

我们已经在遍历循环和映射的上下文中几次提到了下划线标识符。可以使用下划线标识符分配或声明任何类型的任何值,而值会被安全地丢弃。这有点类似于向 Unix 的 /dev/null 文件写入:它代表一个只能写入的值,可用作需要变量但实际值无关紧要的占位符。它的用途不仅限于我们已经看到的那些。

在多赋值中的空白标识符

for range 循环中使用下划线标识符是多重赋值的一种特殊情况。如果赋值需要左侧有多个值,但程序不使用其中一个值,可以在赋值的左侧使用下划线标识符,避免创建一个虚拟变量,并清楚地表明该值将被丢弃。例如,当调用返回值和错误的函数,但只有错误是重要的时候,可以使用下划线标识符来丢弃不相关的值。

1
2
3
if _, err := os.Stat(path); os.IsNotExist(err) {
    fmt.Printf("%s 不存在\\n", path)
}

偶尔会看到忽略错误值以忽略错误的代码,这是糟糕的实践。始终检查错误返回值;它们是有原因提供的。

1
2
3
4
5
// 糟糕的例子!如果路径不存在,此代码将崩溃。
fi, _ := os.Stat(path)
if fi.IsDir() {
    fmt.Printf("%s 是一个目录\\n", path)
}

未使用的导入和变量

导入包或声明变量而不使用它都是错误的。未使用的导入会使程序变得臃肿且减慢编译速度,而已初始化但未使用的变量至少是一次浪费的计算,可能是更大错误的指示。然而,在程序积极开发时,未使用的导入和变量经常会出现,删除它们只是为了让编译继续进行,之后可能再次需要它们,这可能会很烦人。下划线标识符提供了一种解决方法。

这个未完成的程序有两个未使用的导入( fmtio )和一个未使用的变量( fd ),因此它将无法编译,但希望查看到目前为止的代码是否正确。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package main

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

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

为了消除有关未使用导入的投诉,可以使用下划线标识符引用导入包的符号。同样,将未使用的变量 fd 赋给下划线标识符将消除未使用变量的错误。这个程序的版本是可以编译的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package main

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

var _ = fmt.Printf // 用于调试;完成后删除。
var _ io.Reader    // 用于调试;完成后删除。

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

按照约定,为了消除导入错误的全局声明应该紧随导入之后并进行注释,这样可以轻松找到它们,并作为以后清理的提醒。

导入以产生副作用

在上一个示例中,像 fmtio 这样的未使用的导入最终应该被使用或删除:使用下划线标识符的赋值表示代码正在进行中。但有时仅仅为了其副作用而导入一个包是有用的,而不需要明确使用它。例如,在其 init 函数期间, net/http/pprof 包注册提供调试信息的 HTTP 处理程序。它具有一个公开的 API ,但大多数客户端只需要处理程序注册,并通过网页访问数据。为了仅导入包以获取其副作用,请将包重命名为下划线标识符:

1
import _ "net/http/pprof"

这种导入形式明确表示导入包是为了其副作用,因为在该文件中,它没有名称,也没有其他可能的包使用方式。(如果它有名称,并且我们没有使用该名称,编译器将拒绝程序。)

接口检查

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

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

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

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

1
2
3
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 编码器仍将工作,但将不使用自定义实现。为了确保实现是正确的,可以在包中使用下划线标识符进行全局声明:

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

在这个声明中,将 *RawMessage 转换为 Marshaler 的赋值要求 *RawMessage 实现了 Marshaler,这个属性将在编译时检查。如果 json.Marshaler 接口发生变化,这个包将无法编译,我们就会知道需要更新它。

在这个结构中,下划线标识符的出现表示该声明仅用于类型检查,而不是为了创建一个变量。然而,不要为每个满足接口的类型都这样做。按照惯例,仅在代码中不存在静态转换时才使用这样的声明,而这是一个罕见的情况。

嵌入

Go 不提供典型的、基于类型的子类化概念,但它确实具有通过在结构体或接口中嵌入类型来“借用”实现的能力。

接口嵌入非常简单。我们之前提到过io.Readerio.Writer接口;以下是它们的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type Reader interface {
    Read(p []byte) (n int, err error)
}

type Writer interface {
    Write(p []byte) (n int, err error)
}
`io`包还导出了其他一些接口这些接口指定了可以实现多个这样的方法的对象例如`io.ReadWriter`这是一个包含`Read``Write`两个方法的接口我们可以通过明确列出这两个方法来指定`io.ReadWriter`但通过嵌入这两个接口来形成新接口更加简便和富有表现力如下所示

```go
// ReadWriter是结合了Reader和Writer接口的接口。
type ReadWriter interface {
    Reader
    Writer
}

这就是它看起来的样子:ReadWriter 可以执行 ReaderWriter 都能做的事情;它是嵌入的接口的联合。只有接口可以嵌入其他接口。

相同的基本思想也适用于结构体,但影响更为深远。bufio 包有两个结构体类型, bufio.Readerbufio.Writer ,它们分别实现了 io 包中相应的接口。 bufio 还实现了一个带缓冲的读写器,它通过使用嵌入将读取器和写入器组合成一个结构体来实现:它在结构体中列出了这两个类型,但没有给它们命名。

1
2
3
4
5
6
// ReadWriter存储对Reader和Writer的指针。
// 它实现了io.ReadWriter。
type ReadWriter struct {
    *Reader  // *bufio.Reader
    *Writer  // *bufio.Writer
}

嵌入的元素是指向结构体的指针,当然,在使用之前必须初始化它们以指向有效的结构体。 ReadWriter 结构体可以写成:

1
2
3
4
type ReadWriter struct {
    reader *Reader
    writer *Writer
}

但是,为了提升字段的方法并满足 io 接口,我们还需要提供转发方法,如下所示:

1
2
3
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 本身。

嵌入也可以是一种简单的便利。这个例子展示了一个常规命名字段旁边的嵌入字段。

1
2
3
4
type Job struct {
    Command string
    *log.Logger
}

现在 Job 类型具有 *log.LoggerPrintPrintfPrintln 等方法。当然,我们也可以给 Logger 一个字段名,但没有必要这样做。而且,一旦初始化完成,我们就可以记录Job

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

LoggerJob 结构体的一个常规字段,所以我们可以在 Job 的构造函数中以通常的方式进行初始化,如下所示:

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

或者使用复合字面量:

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

如果需要直接引用嵌入字段,字段的类型名称,忽略包限定符,就像我们在 ReadWriter 结构体的 Read 方法中所做的那样,可以作为字段名,如下所示:

1
2
3
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 鼓励一种不同的方法,其中共享值通过通道传递,实际上,它们从未由不同的执行线程主动共享。在任何给定时间,只有一个 goroutine 可以访问该值。设计上,数据竞态是不可能发生的。为了鼓励这种思维方式,我们将其简化为一个口号:

不要通过共享内存来通信;相反,通过通信来共享内存。 这种方法可能会走得太远。例如,引用计数可能最好通过在整数变量周围放置互斥锁来完成。但作为一种高层次的方法,使用通道来控制访问使编写清晰、正确的程序变得更容易。

对于这种模型的一种思考方式是考虑一个在单个 CPU 上运行的典型单线程程序。它不需要同步原语。现在运行另一个这样的实例;它也不需要同步。现在让这两者进行通信;如果通信是同步器,那么就不需要其他同步。例如,Unix 管道完美符合这种模型。尽管 Go 对并发的处理方式起源于 Hoare 的” Communicating Sequential Processes “( CSP ),但它也可以被看作是 Unix 管道的类型安全的概括。

Go协程

它们被称为 goroutines ,因为现有的术语——线程、协程、进程等——传达了不准确的内涵。Goroutine 有一个简单的模型:它是在同一地址空间中与其他 goroutine 并发执行的函数。它是轻量级的,成本几乎只有分配堆栈空间。而且堆栈起始很小,因此它们很便宜,并且通过根据需要分配(和释放)堆存储来增长。

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

使用 go 关键字在一个新的 goroutine 中运行函数或方法调用。当调用完成时, goroutine 会无声退出。(效果类似于 Unix shell 的 & 符号,用于在后台运行命令。)

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

在goroutine调用中,函数字面值可能会很方便。

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

在 Go 中,函数字面值是闭包:实现确保由函数引用的变量在其活动期间存活。

这些示例不太实用,因为这些函数没有通知完成的方式。为此,我们需要通道。

通道

与映射一样,通道是使用 make 分配的,生成的值充当对底层数据结构的引用。如果提供了可选的整数参数,则它设置通道的缓冲区大小。默认值为零,表示无缓冲或同步通道。

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

无缓冲通道结合了通信——值的交换——与同步——确保两个计算( goroutine )处于已知状态。

有许多使用通道的好习惯。以下是一个开始的例子。在前一节中,我们在后台启动了一个排序。通过使用通道,启动的 goroutine 可以等待排序完成。

1
2
3
4
5
6
7
8
c := make(chan int)  // 分配一个通道。
// 在goroutine中启动排序;完成后,在通道上发信号。
go func() {
    list.Sort()
    c <- 1  // 发送信号;值无关紧要。
}()
doSomethingForAWhile()
<-c   // 等待排序完成;丢弃已发送的值。

接收方始终阻塞,直到有数据可接收。如果通道是无缓冲的,则发送方会阻塞,直到接收方接收到值。如果通道具有缓冲区,则发送方仅在将值复制到缓冲区后阻塞;如果缓冲区已满,这意味着等待直到某个接收方检索到一个值。

有缓冲通道可用作信号量,例如限制吞吐量。在此示例中,传入的请求传递给处理函数,该函数将一个值发送到通道,处理请求,然后接收通道的值以准备好下一个使用者的“信号”。“通道”缓冲区的容量限制了对 process 的同时调用次数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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以控制创建 goroutines 的方式来解决这个问题。以下是一种明显的解决方案,但请注意,它存在一个我们随后将修复的错误:

1
2
3
4
5
6
7
8
9
func Serve(queue chan *Request) {
    for req := range queue {
        sem <- 1
        go func() {
            process(req) // 有错误;请参见下面的解释。
            <-sem
        }()
    }
}

错误在于,在 Go for 循环中,循环变量在每次迭代中都会被重用,因此 req 变量在所有 goroutine 之间共享。这不是我们想要的。我们需要确保对于每个 goroutine , req 都是唯一的。以下是通过将 req 的值作为参数传递给 goroutine 中的闭包来实现的一种方式:

1
2
3
4
5
6
7
8
9
func Serve(queue chan *Request) {
    for req := range queue {
        sem <- 1
        go func(req *Request) {
            process(req)
            <-sem
        }(req)
    }
}

将此版本与前一版本进行比较,以查看闭包的声明和运行方式的区别。另一种解决方案是只是使用相同名称创建一个新变量,如此例所示:

1
2
3
4
5
6
7
8
9
10
func Serve(queue chan *Request) {
    for req := range queue {
        req := req // 为goroutine创建req的新实例。
        sem <- 1
        go func() {
            process(req)
            <-sem
        }()
    }
}

写成 req := req 可能看起来有点奇怪,但在 Go 中这是合法和惯用的。您会得到具有相同名称的变量的新版本,故意在本地隐藏循环变量,但对每个 goroutine 是唯一的。

回到编写服务器的一般问题,另一种有效管理资源的方法是启动一定数量的处理 goroutine ,所有这些 goroutine 都从请求通道中读取。 goroutine 的数量限制了对 process 的同时调用次数。该 Serve 函数还接受一个通道,用于告诉它退出;在启动 goroutine 之后,它会阻塞接收该通道。

1
2
3
4
5
6
7
8
9
10
11
12
13
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  // Wait to be told to exit.
}

通道的通道

Go 中的一个最重要的特性是通道是一种一等值,可以像其他任何值一样分配和传递。利用这个特性的一个常见用法是实现安全的并行多路复用。

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

1
2
3
4
5
type Request struct {
    args        []int
    f           func([]int) int
    resultChan  chan int
}

客户端提供一个函数及其参数,以及一个位于请求对象内的通道,用于接收答案。

1
2
3
4
5
6
7
8
9
10
11
12
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)

在服务器端,唯一更改的是处理程序函数。

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

这显然还有很多工作要做,以使其更为实际,但这段代码是一个用于速率受限、并行、非阻塞的 RPC 系统的框架,并且没有一个互斥锁在眼前。

并行化

这些思想的另一个应用是在多个 CPU 核心上并行计算。如果计算可以分成可以独立执行的部分,那么它就可以并行化,使用通道来表示每个部分完成的信号。

假设我们有一个昂贵的操作要在项目向量上执行,并且每个项目上的操作的值是独立的,就像这个理想化的例子一样。

1
2
3
4
5
6
7
8
9
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 后通过排空通道来计数完成信号。

1
2
3
4
5
6
7
8
9
10
11
12
13
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 核心数,因此我们可以写成

1
var numCPU = runtime.NumCPU()

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

1
var numCPU = runtime.GOMAXPROCS(0)

一定要不要混淆并发的思想(将程序结构化为独立执行的组件)和并行性的思想(为了在多个 CPU 上提高效率而执行计算)。虽然 Go 的并发特性使得某些问题易于结构化为并行计算,但 Go 是一种并发语言,而不是并行语言,并非所有并行化问题都适合 Go 的模型。有关这一区别的讨论,请参见此博客文章中引用的演讲。

有泄漏的缓冲区

并发编程的工具甚至可以使非并发的想法更容易表达。以下是一个从 RPC 包抽象出来的示例。客户端 goroutine 循环从某个源(可能是网络)接收数据。为了避免分配和释放缓冲区,它保持一个空闲列表,并使用带缓冲的通道来表示它。如果通道为空,就会分配一个新的缓冲区。一旦消息缓冲准备好,就会发送到 serverChan

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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      // 发送到服务器。
    }
}

服务器循环接收来自客户端的每条消息,处理它,并将缓冲区返回给空闲列表。

1
2
3
4
5
6
7
8
9
10
11
12
13
func server() {
    for {
        b := <-serverChan    // 等待工作。
        process(b)
        // 如果有空间,可以重用缓冲区。
        select {
        case freeList <- b:
            // 缓冲区在空闲列表中;无需执行其他操作。
        default:
            // 空闲列表已满,继续执行。
        }
    }
}

客户端试图从 freeList 检索缓冲区;如果没有可用的,则分配一个新的。服务器发送到 freeListb 放回空闲列表,除非列表已满,在这种情况下,缓冲区被丢弃以供垃圾收集器回收。(select 语句中的 default 子句在没有其他 case 准备就绪时执行,这意味着 select 语句永远不会阻塞。)这个实现仅用几行代码构建了一个有泄漏的桶空闲列表,依赖于带缓冲的通道和垃圾收集器进行簿记。

错误

库常常需要向调用者返回某种错误指示。如前所述,Go 的多值返回使得可以轻松地在正常返回值旁边返回详细的错误描述。使用这个特性提供详细的错误信息是良好的风格。例如,正如我们将看到的, os.Open 不仅在失败时返回一个空指针,它还返回一个描述出错原因的错误值。

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

1
2
3
type error interface {
    Error() string
}

库编写者可以自由地使用更丰富的模型在底层实现这个接口,使得不仅可以看到错误,还可以提供一些上下文。正如前面提到的,除了通常的 *os.File 返回值, os.Open 还返回一个错误值。如果文件成功打开,错误将为 nil ,但当存在问题时,它将包含一个 os.PathError

1
2
3
4
5
6
7
8
9
10
// 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()
}

PathError 的 Error 方法生成一个像这样的字符串:

1
open /etc/passwx: no such file or directory

这样的错误,其中包括有问题的文件名、操作和引发它的操作系统错误,即使在距离引发它的调用很远的地方打印出来,也是有用的;它比简单的 “ no such file or directory “ 更具信息量。

在可行的情况下,错误字符串应该标识它们的来源,比如通过具有命名操作或生成错误的包的前缀。例如,在 image 包中,由于未知格式而导致的解码错误的字符串表示形式是 “ image: unknown format “。

关心精确错误详情的调用方可以使用类型开关或类型断言来查找特定的错误并提取详细信息。对于 PathErrors ,这可能包括检查内部 Err 字段以获取可恢复的故障。

1
2
3
4
5
6
7
8
9
10
11
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 将为 falsee 将为 nil。如果成功,ok 将为 true,这意味着错误的类型是 *os.PathError ,然后 e 也是,我们可以检查更多关于错误的信息。

恐慌

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
// 使用牛顿法的立方根的玩具实现。
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) 未收敛", x))
}

这只是一个示例,但是真正的库函数应该避免使用 panic 。如果问题可以被掩盖或解决,最好让事情继续运行,而不是使整个程序崩溃。一个可能的例外是在初始化期间:如果库真的无法设置自身,那么触发 panic 可能是合理的。

1
2
3
4
5
6
7
var user = os.Getenv("USER")

func init() {
    if user == "" {
        panic("未设置 $USER 的值")
    }
}

恢复

当调用 panic 时,包括对切片进行越界索引或失败的类型断言等运行时错误在内,它会立即停止当前函数的执行并开始展开 Goroutine 的堆栈,沿途执行任何延迟的函数。如果展开达到 Goroutine 的堆栈顶部,程序将崩溃。但是,可以使用内置函数 recover 来重新获得对 Goroutine 的控制,并恢复正常执行。

调用 recover 会停止展开并返回传递给 panic 的参数。由于在展开期间运行的唯一代码位于延迟函数内部,因此 recover 仅在延迟函数内部有用。

recover 的一个应用是在服务器中关闭失败的 Goroutine 而不影响其他正在执行的 Goroutine。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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 的情况下干净地退出。在延迟闭包中不需要执行任何其他操作;调用 recover 完全处理了此条件。

由于 recover 除非直接从延迟函数中调用,否则始终返回 nil ,延迟代码可以调用使用 panicrecover 的库函数而不会失败。例如,在 safelyDo 的延迟函数中可能在调用 recover 之前调用日志函数,而该日志代码将不受 panic 状态的影响而运行。

有了我们的恢复模式, do 函数(及其调用的任何函数)可以通过调用 panic 以清晰地摆脱任何糟糕的情况。我们可以利用这个思想来简化复杂软件中的错误处理。让我们看一个正则表达式包的理想化版本,该包通过使用本地错误类型调用 panic 来报告解析错误。下面是 ErrorError 方法和 Compile 函数的定义。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Error 是解析错误的类型;它满足 error 接口。
type Error string
func (e Error) Error() string {
    return string(e)
}

// error 是 *Regexp 的方法,通过 panic 报告解析错误。
func (regexp *Regexp) error(err string) {
    panic(Error(err))
}

// Compile 返回正则表达式的解析表示。
func Compile(str string) (regexp *Regexp, err error) {
    regexp = new(Regexp)
    // doParse 将在解析错误时触发 panic。
    defer func() {
        if e := recover(); e != nil {
            regexp = nil    // 清除返回值。
            err = e.(Error) // 如果不是解析错误,则重新触发。
        }
    }()
    return regexp.doParse(str), nil
}

如果 doParse 触发 panic ,恢复块将将返回值设置为 nil,延迟函数可以修改具名返回值。然后,它将在分配给 err 时检查问题是否是解析错误,方法是断言它具有本地类型 Error 。如果不是,类型断言将失败,导致运行时错误,而堆栈展开将继续进行,就好像没有中断一样。这个检查意味着如果发生了一些意外的情况,比如索引越界,尽管我们使用 panicrecover 处理解析错误,代码仍然会失败。

有了错误处理, error 方法(因为它是绑定到类型的方法,因此有相同的名称作为内置 error 类型是可以的,甚至是自然的),使得报告解析错误变得容易,无需手动展开解析堆栈:

1
2
3
if pos == 0 {
    re.error("'*' 在表达式开头非法")
}

尽管这种模式很有用,但应该仅在一个包内部使用。 Parse 将其内部的 panic 调用转换为错误值;它不将 panic 暴露给其客户端。这是一个好的规则。

顺便说一下,这种重新触发的惯用法会在发生实际错误时更改 panic 值。但是,原始和新的故障都将在崩溃报告中呈现,因此问题的根本原因仍然可见。因此,这种简单的重新触发方法通常就足够了——毕竟,它是一个崩溃——但是如果你只想显示原始值,你可以编写更多的代码来过滤意外问题并使用原始错误重新触发。这留作读者的练习。

Web服务器

让我们以一个完整的 Go 程序结束,这是一个 Web 服务器。实际上,这是一种 Web 重定向服务器。Google 在 chart.apis.google.com 上提供了一个服务,可以将数据自动格式化为图表和图形。但是,由于您需要将数据作为查询放入 URL 中,因此很难进行交互式使用。这个程序提供了一个更好的界面来处理一种形式的数据:给定一小段文本,它调用图表服务器生成一个 QR 码,一个编码文本的方框矩阵。这个图像可以通过手机摄像头抓取,并解释为 URL,从而避免您在手机的小键盘上输入 URL。

以下是完整的程序,接下来是解释。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
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>
`

前面的部分应该很容易理解。一个 flag 为服务器设置了默认的 HTTP 端口。变量 templ 是程序中的有趣部分。它构建了一个 HTML 模板,该模板将由服务器执行以显示页面;稍后我们将详细讨论。

主函数解析标志,并使用我们上面讨论的机制将函数 QR 绑定到服务器的根路径。然后调用 http.ListenAndServe 启动服务器;在服务器运行时,它会阻塞。

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

html/template 包是强大的;此程序只触及了其能力的一部分。本质上,它通过替换传递给 templ.Execute 的数据项生成的元素,即表单值,即时重写 HTML 文本的一部分。在模板文本( templateStr )中,双括号限定的部分表示模板操作。从 {{if .}}{{end}} 的部分仅在当前数据项的值,称为 .(点),非空时执行。也就是说,当字符串为空时,模板的这部分将被抑制。

两个 {{.}} 的片段表示在网页上显示传递给模板的数据——查询字符串。HTML 模板包会自动提供适当的转义,因此文本是安全的显示。

模板字符串的其余部分只是在页面加载时显示的 HTML。如果这解释得太快,请查看 template 包的文档以获得更详细的讨论。

这就是它的全部:几行代码加上一些数据驱动的 HTML 文本就能实现一个有用的 Web 服务器。Go 足够强大,可以在短短几行代码中实现很多功能。

本文由作者按照 CC BY-NC-SA 4.0 进行授权

[译]构建 Kubernetes Operator 和有状态应用的最佳实践

[译]Golang 的函数选项模式