Effective Go 编程技巧总结

Go 是一种新兴的编程语言。尽管它借鉴了其他语言的许多特性,但也具备一些独特的属性,使得用 Go 编写的高效程序在风格上与其他语言编写的程序有所不同。直接将 C++ 或 Java 程序翻译成 Go 代码,通常无法取得令人满意的结果 —— Java 程序的编写方式是 Java 风格,而非 Go 风格。另一方面,如果从 Go 的语言特性出发去思考问题,可能会编写出风格截然不同但更为成功的程序。换句话说,要编写出优秀的 Go 代码,理解 Go 语言的特性和惯用法是至关重要的。此外,了解 Go 语言编程中的约定俗成规范也同样重要,例如命名规范、格式化规则、程序结构等等,这样才能使你编写的程序易于其他 Go 语言程序员的理解。

Image

声明:本文根据官网提供的《Effective Go编程指南》翻译整理而成,旨在提供中文版指南,满足中文读者的快速学习需要。本文尽量保证编写清晰、并符合 Go 习惯的代码的技巧。

示例

Go 语言的包源码不仅作为核心库存在,还为如何使用该语言提供了示例。此外,许多包中还包含可直接在 go.dev 网站上运行的独立可执行示例。如果你对解决问题的方法或实现细节存在疑问,库中的文档、代码和示例能够为你提供答案、思路和背景信息。

格式化

格式化问题最具争议性,但实际影响最小。人们能够适应不同的格式化风格,但如果所有人都遵循相同的风格,则可以减少在这一话题上的时间和精力投入。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 语言对行长度未作限制。无需担心代码会超出打孔卡片的范围。如果一行代码过长,可进行换行并用额外的制表符进行缩进。

括号

与 C 和 Java 相比,Go 语言需要的括号更少:控制结构(ifforswitch)在语法中不使用括号。

此外,运算符优先级层次更简短且清晰,因此:

x<<8 + y<<16

其含义与代码间距所暗示的一致,这与其他语言有所不同。

Go 语言提供了 C 风格的 /* */ 块注释以及 C++ 风格的 // 行注释。行注释是常规用法;块注释主要出现在包注释中,但在表达式内部或用于禁用大量代码时也非常有用。

出现在顶级声明之前的注释(中间没有换行符),被视为对声明本身的文档注释。这些 “文档注释” 是 Go 包或命令的主要文档来源。有关文档注释的更多信息,请参见 “Go 文档注释”。

命名

如同在其他语言中一样,命名在 Go 语言中也极为重要。它们甚至具有语义效应:名称在包外的可见性由其首字母是否为大写字母决定。因此,值得花些时间来讨论 Go 程序中的命名约定。

包名

当导入一个包时,包名就成为了访问其中内容的访问符。在以下代码执行后:

import "bytes"

导入的包可以使用 bytes.Buffer 来引用其中的内容。如果所有使用该包的人都能使用相同的名称进行引用,这将非常有帮助,这意味着包名应简短、简洁且富有表现力。按照惯例,包被赋予小写的单字名称;通常无需使用下划线或混合大小写。宁可在简洁性上出错,因为每个使用你的包的人都会输入该名称。此外,无需担心名称冲突问题。包名只是导入时的默认名称;它无需在所有源代码中保持唯一性,而且在极少数出现冲突的情况下,导入包可以使用不同的本地名称。无论如何,由于导入文件中的文件名决定了实际使用的包,因此出现混淆的情况非常罕见。

另一个惯例是,包名通常为源目录的基本名称;例如,位于 src/encoding/base64 的包被导入为 "encoding/base64",但其名称为 base64,而非 encoding_base64 或 encodingBase64

导入包的人将使用该名称来引用其中的内容,因此包中的导出名称可以利用这一特性来避免重复。例如,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)。长名称并不自动提高可读性。一个有帮助的文档注释往往比额外的长名称更具价值。

Getter 方法

Go 语言并未自动提供 getter 和 setter 方法。自行提供这些方法并无不妥,也常常是必要的,但这既不是惯用法也不是必需的。如果字段名为 owner(小写,未导出),则 getter 方法应命名为 Owner(大写,已导出),而不是 GetOwner。在需要 setter 函数的情况下,通常会命名为 SetOwner。在实际应用中,这两种方法的可读性都很高:

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

接口名

按照惯例,单方法接口的命名是将方法名加上 -er 后缀或类似的修改来构造一个表示动作的名词,例如 ReaderWriterFormatterCloseNotifier 等等。

存在许多此类名称,遵循它们并理解其功能是富有成效的。ReadWriteCloseFlushString 等方法都具有规范化的签名和含义。为避免混淆,除非你的方法具有相同的签名和含义,否则不要为其命名这些名称。反之,如果你的类型实现了与知名类型的方法相同的功能,则应赋予其相同的方法名和签名;如果你的类型实现了字符串转换方法,应将其命名为 String 而非 ToString

混合大小写

最后,Go 语言的惯例是使用 MixedCaps 或 mixedCaps 而非下划线来书写多单词名称。

分号

与 C 语言类似,Go 的正式语法使用分号来终止语句,但与 C 不同的是,这些分号不会出现在源代码中。相反,词法分析器会使用一个简单的规则在扫描过程中自动插入分号,因此源文本中基本不需要出现分号。

规则如下:如果换行前的最后一个标记是标识符(包括像 int 和 float64 这样的单词),或者是一个基本字面量(如数字或字符串常量),或者以下标记之一:

break continue fallthrough return ++ -- ) }

词法分析器总是在该标记后插入分号。可以总结为,“如果换行符出现在一个可能结束语句的标记之后,则插入分号”。

如果最后一个标记是换行符,则可以省略换行符前的分号,因此像这样的语句:

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

无需任何分号。惯用的 Go 程序中分号仅出现在以下位置,如 for 循环的子句中用于分隔初始化、条件和延续部分。它们也是在一行上书写多个语句时分隔这些语句所必需的。

由分号插入规则带来的一个后果是:你不能将控制结构(ifforswitch 或 select)的左大括号放在下一行。如果这样做,在大括号前会插入分号,这可能引发不期望的后果。应这样书写:

if i < f() {
    g()
}

而不是这样:

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

控制结构

Go 的控制结构与 C 语言相似但在重要方面有所不同。没有 dowhile循环,只有一种功能稍强的for循环;switch更为灵活;ifswitch接受可选的初始化语句,类似于for的初始化部分;breakcontinue语句可以接受可选的标签来标识要跳转或继续的目标;还有新增的控制结构,包括类型switch和多路通信复用器select。此外,语法也略有不同:没有括号,且语句体必须始终用大括号括起来。

If

在 Go 中,简单的 if 语句如下所示:

if x > 0 {
    return y
}

强制要求使用大括号有助于将简单的 if 语句写在多行上。无论怎样,良好的风格是这样做,特别是当语句体包含返回、中断等控制语句时。

由于 if 和 switch 接受初始化语句,因此常用它来设置局部变量。

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

在 Go 的库中,如果 if 语句未流入下一条语句 —— 即语句体以返回、中断、继续、跳转或返回等结束 —— 则可省略不必要的 else

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

这是一个常见的情况,其中代码需要防范一系列错误条件。如果成功流程的代码能够自上而下顺畅运行,将错误条件一一消除,则代码具有良好的可读性。由于错误条件通常以返回语句结束,因此生成的代码无需 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)

此语句声明了两个变量 f 和 err。几行后,调用 f.Stat 的代码如下:

d, err := f.Stat()

看起来它声明了 d 和 err。注意到 err 在这两条语句中均出现。这种重复是合法的:第一条语句声明了 err,而第二条语句仅对 err 进行了重新赋值。这意味着,调用 f.Stat 的第二条语句使用了上文声明的 err 变量,并为其赋予了新的值。

在使用短变量声明(:=)时,变量 v 即使已被声明,也可以再次出现,前提是:

  • • 此声明与 v 的现有声明位于同一作用域(如果 v 在外部作用域中已被声明,则该声明将创建一个新的变量)。

  • • 初始化值能够被赋给 v

  • • 声明中至少还有一个新创建的变量。

这种特殊的属性纯粹是为了实用主义,使得在长 if-else 链中使用单一 err 值变得十分便捷。你会经常看到这种用法。

这里值得一提的是,Go 中函数参数和返回值的作用域与函数体相同,即使它们在语法上出现在大括号之外。

For

Go 的 for 循环与 C 类似,但不完全相同。它统一了 for 和 while,并且没有 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
}

如果只需要 range 返回的第一个项(键或索引),则可以省略第二个变量。

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

如果只需要 range 返回的第二个项(值),则可使用空白标识符(下划线)来丢弃第一个项。

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

空白标识符的多种用途将在后文介绍。

对于字符串,range 会为你做更多的工作,它通过解析 UTF-8 来拆分出各个 Unicode 码点。错误的编码会消耗一个字节并产生替换符 U+FFFD。以下循环:

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
}

没有自动的向下级联,但可以通过逗号分隔的列表来呈现多个 case

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

虽然在 Go 中 break 语句并不像在某些其他 C 风格语言中那样常见,但有时为了提前终止 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)
        }
    }

最后,这里是一个使用两个 switch 语句的字节切片比较函数。

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

类型 Switch

switch 还可以用于发现接口变量的动态类型。这种类型 switch 使用类型断言的语法,但括号内是关键字 type。如果 switch 的表达式声明了一个变量,则该变量在每个分支中的类型对应其声明。在惯用法中,可在这种情况下使用相同的名称,实际上是在每个分支中声明了具有不同类型的同名新变量。

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 可以返回一个计数和一个错误:即 “是的,你写入了一些字节,但并非全部,因为设备已满”。文件包中 Write 方法的签名如下:

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

文档指出,当 n 不等于 len(b) 时,将返回非 nil 的错误。这是常见的风格;更多示例将在错误处理部分中介绍。

一种类似的实现方式可以避免传递指针来模拟引用参数。这里有一个简单的函数,用于从字节切片的某个位置获取一个数字,返回该数字以及下一个位置。

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
}

Defer

Go 的 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 将被关闭。
        }
    }
    returnstring(result), nil// 如果在这里返回,f 将被关闭。
}

延迟执行关闭文件的操作有两个优点。首先,它保证了你永远不会忘记关闭文件,这是一个在添加新的返回路径时很容易出错的错误。其次,它意味着关闭操作位于打开操作附近,这比将其放在函数末尾要清晰得多。

延迟函数的参数(包括方法的接收者)在 defer 执行时进行求值,而不是在调用执行时求值。除了避免在函数执行过程中变量值变化的担忧外,这意味着一个单独的延迟调用站点可以延迟多个函数的执行。以下是一个简单的例子。

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")
    // 执行某些操作....
}

我们可以通过利用延迟函数的参数在延迟执行时求值这一特性来改进跟踪例程。跟踪例程可以设置延迟函数的参数。在以下示例中:

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 可能看起来有些奇怪,但其最有趣和强大的应用正是源于它并非基于块,而是基于函数的事实。在 panic 和 recover 部分中,我们将看到它的另一种可能性。

数据

使用 new 进行分配

Go 提供了两种分配原语,内置函数 new 和 make。它们执行不同的操作,并且适用于不同的类型,这可能会令人困惑,但规则很简单。我们先来谈谈 new

它是一个内置函数,用于分配内存,但与某些其他语言中的同名函数不同,它不会初始化内存,只会将其置零。也就是说,new(T) 分配了一个新的 T 类型的零值存储,并返回其地址,即类型为 *T 的值。在 Go 术语中,它返回指向 T 类型新分配的零值的指针。

由于 new 返回的内存已被置零,因此设计数据结构时安排零值的每个类型都可以在无需进一步初始化的情况下使用是很有帮助的。这使得用户在分配后可以立即开始使用该数据结构。例如,bytes.Buffer 的文档指出:“Buffer 的零值是一个已准备好使用的空缓冲区。” 同样,sync.Mutex 没有显式的构造函数或 Init 方法。相反,sync.Mutex 的零值被定义为一个未锁定的互斥锁。

零值有用这一特性具有传递性。考虑以下类型声明:

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

SyncedBuffer 类型的值在分配或声明后也可以立即使用。在以下片段中,p 和 v 都将正确工作而无需进一步操作。

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: fd, name: name}

通过标记元素为 field : value 对,即使未按顺序列出字段,也可以初始化复合字面量中的字段,而缺失的字段将保留各自的零值。因此我们可以这样写:

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

作为一种极端情况,如果复合字面量中没有字段,它将创建该类型的零值。表达式 new(File) 和 &File{} 是等价的。

还可以为数组、切片和映射创建复合字面量,此时字段标签是索引或映射键,视情况而定。在这些示例中,无论 EnoneEio 和 Einval 的值如何(只要它们是唯一的),初始化都可以正常工作。

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 切片值的指针。

这些示例展示了 new 和 make 之间的区别。

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 中的数组在工作方式上存在重大差异。

  • • 在 Go 中,数组是值。将一个数组赋值给另一个数组会复制所有元素。

  • • 特别是,如果你将数组传递给函数,它将接收该数组的一个副本,而非指向它的指针。

  • • 数组的大小是其类型的一部分。类型 [10]int 和 [20]int 是不同的。

值的特性可能非常有用,但也可能代价高昂;如果你希望获得类似 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 返回切片可以假设的最大长度。以下是一个将数据追加到切片的函数。如果数据超出容量,则重新分配切片。返回结果切片。该函数利用了当切片为 nil 时 len 和 cap 的合法性,并返回 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
}

我们必须返回切片,因为在 Go 中,尽管 Append 可以修改切片的元素,但切片本身(运行时数据结构,包含指针、长度和容量)是按值传递的。

将数据追加到切片的概念非常有用,因此它被内置的 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) // 类型为 []uint8,即使 picture 是 [][]uint8。
// 遍历行,从剩余的 pixels 切片中分割出每行。
for i := range picture {
    picture[i], pixels = pixels[:XSize], pixels[XSize:]
}

映射

映射是一种方便且功能强大的内置数据结构,用于将一种类型(键)与另一种类型(元素或值)关联起来。键可以是任何支持定义相等运算的类型,例如整数、浮点数和复数、字符串、指针、接口(只要动态类型支持相等运算)、结构体和数组。切片不能作为映射键,因为它们不支持相等运算。与切片类似,映射包含对底层数据结构的引用。如果将映射传递给函数,该函数修改映射内容,这些修改将在调用者中可见。

可以使用常规的复合字面量语法(带冒号分隔的键值对)来构造映射,因此在初始化时很容易构建它们。

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。可以使用多重赋值来区分不存在的键和零值。这被称为 “逗号后跟 ok” 的惯用法。

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

在明显易懂的原因下,这被称为 “逗号后跟 ok” 的惯用法。在本示例中,如果 tz 存在于映射中,seconds 将被正确设置且 ok 为真;否则,seconds 将被设置为零且 ok 为假。以下是将这些元素组合在一起的函数:

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 等)返回字符串而不是填充提供的缓冲区。

你无需提供格式字符串。对于每个 PrintfFprintf 和 Sprintf,还有另外两个函数对,例如 Print 和 Println。这些函数不接受格式字符串,而是为每个参数生成默认格式。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.Stdout 和 os.Stderr 是熟悉的实例。

在 Go 中,这里开始与 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(代表 “value”);结果正是 Print 和 Println 会产生的内容。此外,该格式可以打印任何值,包括数组、切片、结构体和映射。以下是打印前面定义的时区映射的示例。

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}

(注意 ampersands。)
该引用字符串格式还适用于 %q,当应用于 string 或 []byte 类型时也是如此。带空格的格式 % q 对于整数和 runes 也适用,生成单引号的 rune 常量。
此外,带有空格的格式 % 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 的值而非指针,请将接收器设置为值类型;此示例使用指针接收器是因为这对于结构体类型来说更高效且更惯用。有关指针与值接收器的详细信息,请参见下文。)

我们的 String 方法能够调用 Sprintf,因为打印程序是完全可重入的,可以被包装。关于这种方法有一个重要的细节需要注意:不要通过调用 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 在标准记录器上以 fmt.Println 的方式打印内容。
func Println(v ...interface{}) {
    std.Output(2, fmt.Sprintln(v...))  // Output 接收参数 (int, string)
}

我们在传递的嵌套调用中使用了 ...,告诉编译器将 v 作为参数列表处理;否则,它将仅作为单个切片参数传递。

可变参数列表也可以指定为特定类型,例如,接受整数列表的最小值函数:

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

初始化

尽管从表面上看与 C 或 C++ 中的初始化没有很大不同,但 Go 的初始化功能更加强大。可以在初始化过程中构建复杂的结构,并且正确处理不同包中初始化对象的顺序问题。

常量

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

在 Go 中,枚举常量是通过 iota 枚举器创建的。由于 iota 可以作为表达式的一部分,并且表达式可以隐式重复,因此可以轻松构建复杂的值集。

type ByteSize float64

const (
    _           = iota
    KB ByteSize = 1 << (10 * iota)
    MB
    GB
    TB
    PB
    EB
    ZB
    YB
)

通过为任意用户定义的类型附加 String 方法,使其能够自动格式化以供打印。尽管你最常在结构体上看到这种用法,但这种技术也适用于标量类型,例如浮点数类型 ByteSize

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

表达式 YB 的打印结果为 1.00YB,而 ByteSize(1e13) 的打印结果为 9.09TB

变量

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

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

初始化函数

最后,每个源文件可以定义自己的无参数 init 函数,用于设置所需的任何状态。(实际上,每个文件可以有多个 init 函数。)文件的 init 函数是在包中所有变量声明评估其初始化器后调用的,而变量的初始化器是在导入所有包后进行评估的。

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

func init() {
    if user == "" {
        log.Fatal("$USER 未设置")
    }
    if home == "" {
        home = "/home/" + user
    }
    if gopath == "" {
        gopath = home + "/go"
    }
    // gopath 可能会被命令行上的 --gopath 标志覆盖。
    flag.StringVar(&gopath, "gopath", gopath, "覆盖默认的 GOPATH")
}

方法

指针与值

正如我们看到的 ByteSize 示例一样,可以为任何命名类型(除了指针或接口)定义方法。接收器不需要是结构体。

在上文关于切片的讨论中,我们编写了一个 Append 函数。我们可以通过声明一个命名类型来将其作为方法定义在切片上。首先,我们需要为方法绑定定义一个命名类型,然后将接收器定义为该类型的值。

type ByteSlice []byte

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

这仍然要求方法返回更新后的切片。我们可以通过重新定义该方法,并将其接收器定义为指向 ByteSlice 的指针,从而消除这种笨拙的写法,使方法能够修改调用者的切片。

func (p *ByteSlice) Append(data []byte) {
    slice := *p
    // 方法主体与上面相同,只是去掉了返回。
    *p = slice
}

实际上,我们可以通过改进使其看起来像标准的 Write 方法,如下所示:

func (p *ByteSlice) Write(data []byte) (n int, err error) {
    slice := *p
    // 再次与上面相同。
    *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 是可寻址的,我们可以调用其 Write 方法,只需使用 b.Write。编译器会自动将其实现为 (&b).Write

顺便说一句,关于值与指针接收器的规则是:值方法可以被指针和值调用,但指针方法只能被指针调用。

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

此外,关于使用 Write 在字节切片上进行操作的例子,这正是 bytes.Buffer 实现的核心。

接口与其他类型

接口

Go 的接口提供了一种方式来指定对象的行为:如果某物能完成 此操作,那么它就可以被用于 此场景。我们已经看到了一些简单的例子;自定义打印机可以通过实现 String 方法来实现,而 Fprintf 可以将输出生成到任何具有 Write 方法的对象上。接口通常只包含一两个方法,这在 Go 代码中很常见,并且通常以方法名为基础命名,例如 io.Writer 对应于实现 Write 的接口。

一个类型可以实现多个接口。例如,一个集合可以通过实现 sort.Interface 接口来被 sort 包排序,该接口包含 Len()Less(i, j int) bool 和 Swap(i, j int),并且它还可以具有自定义的格式化方法。在以下虚构的例子中,Sequence 同时满足这两个条件。

type Sequence []int

func (s Sequence) Len() int {
    returnlen(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]
}

func (s Sequence) Copy() Sequence {
    copy := make(Sequence, 0, len(s))
    returnappend(copy, s...)
}

func (s Sequence) String() string {
    s = s.Copy()
    sort.Sort(s)
    str := "["
    for i, elem := range s {
        if i > 0 {
            str += " "
        }
        str += fmt.Sprint(elem)
    }
    return str + "]"
}

转换

Sequence 的 String 方法正在重新实现 Sprint 已经完成的工作。(它还有一个 O(N²) 的复杂度,这很差。)我们可以通过转换 Sequence 为普通 []int 来共享工作,从而在调用 Sprint 之前进行排序。

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

这个方法是使用转换技术来安全调用 Sprintf 的另一个示例。由于 Sequence 和 []int 两个类型在忽略类型名称时是相同的,因此将它们相互转换是合法的。转换不会创建新值,而是临时将现有值视为另一种类型。(当然,对于像整数到浮点数这样的其他合法转换,会创建新值。)

在 Go 程序中,通过转换表达式类型来访问不同的方法集是一种常见的惯用法。例如,我们可以通过将 Sequence 转换为 sort.IntSlice 来重用现有的 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,在某种程度上将接口转换为该 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()
}

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

如果我们只关心一个类型,怎么办?如果我们知道该值包含一个字符串,并且只想提取它?一个具有单个 case 的类型开关可以实现,但 type assertion 也可以实现。类型断言的语法借鉴了类型开关的子句,用 type 关键字替换为显式类型:

value.(typeName)

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

str := value.(string)

但如果接口中没有包含字符串,程序将在运行时崩溃。为了避免这种情况,可使用 “逗号后跟 ok” 的惯用法来安全地测试值是否为字符串:

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

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

作为这一能力的示例,以下 if-else 语句等同于本节开头介绍的类型开关。

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

通用性

如果一个类型存在只是为了实现接口,并且除了该接口之外没有其他导出的方法,那么就没有必要导出该类型本身。仅导出接口会使值的使用明确表明其行为不会超出接口所描述的范围。它还避免了重复文档中每个实例的公共方法。

在这种情况下,构造函数应返回接口值而不是实现类型。例如,在哈希库中,crc32.NewIEEE 和 adler32.New 都返回接口类型 hash.Hash32。将 CRC-32 算法替换为 Adler-32 在 Go 程序中的应用,只需要更改构建器调用;代码的其余部分不受影响。

一个类似的例子允许在 crypto 包中的流密码算法与构成它们的块密码算法之间进行分离。crypto/cipher 包中的 Block 接口指定了块密码的行为,它提供单个块数据的加密和解密。

crypto/cipher 接口如下所示:

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

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

这里定义了计数器模式(CTR)流,它将块密码转换为流密码;块密码的细节被抽象掉:

// NewCTR 返回一个 Stream,该 Stream 使用给定的 Block 以计数器模式进行加密/解密。
// 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 是一个包含客户端请求解析表示的结构体。

为了简洁起见,假设 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 打印内容。)
要将此类服务器附加到 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, "已发送通知")
}

最后,让我们说我们想在 /args 上展示用于调用服务器二进制文件的参数。编写一个用于打印参数的函数很容易。

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

如何将此函数转换为 HTTP 服务器?我们可以为一个类型创建一个方法,该类型将值忽略,但还有更简洁的方式。由于我们可以为任何类型(除了指针和接口)定义方法,我们甚至可以为一个函数定义方法。http 包包含以下代码:

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

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

HandlerFunc 是一个带有 ServeHTTP 方法的类型,因此 HandlerFunc 类型的值可以处理 HTTP 请求。查看 ServeHTTP 方法的实现:接收器是一个函数 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 是接收器,该方法将调用 ArgServer(通过方法中的 f(w, req) 调用)。参数将被显示出来。

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

空白标识符

我们已经提到了空白标识符,用于 for range 循环和映射的上下文。空白标识符可以在多重赋值中使用。

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

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

偶尔,你会看到这样的代码,它丢弃了错误值以忽略错误;这是非常糟糕的做法。始终要检查错误返回值;它们被提供是有原因的。

// 错误!此代码如果路径不存在将崩溃。
fi, _ := os.Stat(path)
if fi.IsDir() {
    fmt.Printf("%s 是目录\n", path)
}

未使用的导入和变量

未使用导入的包或声明未使用的变量是一种错误。未使用的导入会增加程序体积并减慢编译速度,而未使用的变量可能是无效计算,甚至是更大错误的征兆。在程序处于开发阶段时,未使用的导入和变量有时会出现,要删除它们以使编译通过可能会很麻烦,而仅仅是为了让编译继续进行,之后可能又需要再次使用它们。空白标识符提供了一种解决方法。

这个尚未完成的程序有两个未使用的导入(fmt 和 io)以及一个未使用的变量(fd),因此它将无法编译,但如果我们希望通过其他方式查看代码是否正确,可以这样做:

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

    _ = fd
}

通过惯例,导入后立即使用空白标识符声明来消除导入错误的全局声明应放在导入之后,并附上注释,以便于后续查找,并作为稍后清理的提醒。

导入侧效

有时,导入一个包只是为了其副作用,而无需显式使用它。例如,在其 init 函数中,net/http/pprof 包注册了提供调试信息的 HTTP 处理程序。它确实有一个导出的 API,但大多数客户端只需要副作用,即通过 Web 页面访问数据。为了导入包以获取其副作用,将包重命名为空白标识符:

import _ "net/http/pprof"

此导入形式表明该包仅用于其副作用,因为在此文件中它没有其他可能的用途。(如果它有,并且我们未使用该名称,编译器将拒绝该程序。)

接口检查

当只需要询问一个类型是否实现了接口,而无需实际使用该接口时,可以使用空白标识符忽略类型断言的值。

if _, ok := val.(json.Marshaler); ok {
    fmt.Printf("值 %v 类型为 %T 实现了 json.Marshaler\n", val, val)
}

一个出现这种情况的场景是,需要在包内保证类型确实实现了接口。如果一个类型(例如 json.RawMessage)需要自定义的 JSON 表示形式,它应该实现 json.Marshaler,但如果没有静态转换,则编译器无法自动验证这一实现。为了确保实现正确,可以使用空白标识符的全局声明:

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

在这种声明中,将 *RawMessage 转换为 Marshaler 的赋值要求 *RawMessage 实现 Marshaler,并且该属性将在编译时被检查。如果 json.Marshaler 接口发生变化,此包将不再编译,我们会注意到需要更新。

出现这种情况时,不要为每个实现接口的类型都这样做。按照惯例,这种声明只在没有静态转换的情况下使用,而这很少发生。

嵌入

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

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

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,而不是显式列出两个方法:

// ReadWriter 是包含 Reader 和 Writer 接口的联合。
type ReadWriter interface {
    Reader
    Writer
}

这表明 ReadWriter 可以执行 Reader 和 Writer 的所有操作;它是嵌入接口的联合。只有接口可以嵌入在其他接口中。

同样的基本思想适用于结构体,但其影响更为深远。bufio 包有两类结构体,bufio.Reader 和 bufio.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)
}

通过直接嵌入结构体,我们避免了这些开销。嵌入类型的 ReadWriter 不仅具有 bufio.Reader 和 bufio.Writer 的所有方法,还满足了所有三个接口:io.Readerio.Writer 和 io.ReadWriter

还有更简单的用法。这个例子显示了一个嵌入字段与一个命名字段一起使用的情况。

type Job struct {
    Command string
    *log.Logger
}

现在,Job 类型具有 log.Logger 的 PrintPrintfPrintln 等所有方法。我们可以直接在 Job 上调用这些方法:

job.Println("现在开始...")

Logger 是 Job 结构体的一个常规字段,因此我们可以在构造函数中像初始化常规字段一样初始化它。

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

或者使用复合字面量:

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

如果需要直接引用嵌入字段,可以使用字段的类型名称作为字段名,忽略包限定符。在本例中,如果需要引用 Job 变量 job 中的 *log.Logger,可以写成 job.Logger,这在想要细化 Logger 的方法时会很有用。

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

嵌入类型会引入名称冲突的问题,但解决规则很简单。首先,字段或方法 X 会隐藏更深层次嵌入的同名项目。如果 log.Logger 包含与 Job 结构体中的 Command 字段同名的字段或方法,则 Command 字段会优先。

其次,如果在同一层嵌入中出现同名项目,通常会导致错误;如果程序中从未引用过冲突的名称,则允许嵌入。这种例外提供了一些保护,防止导入自外部类型的嵌入字段被修改。

并发

通过通信共享

并发编程是一个很大的主题,这里只讨论一些 Go 特有的亮点。

Go 鼓励采用一种与许多其他环境不同的方法,通过通信而非共享内存来实现并发。共享内存的访问需要仔细处理,这使得其他环境中的并发编程变得困难。Go 推荐以下方法:

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

这种方法可以走得太远。例如,引用计数可以通过围绕一个整数的互斥锁来实现。但作为一种高层次的方法,使用通道来控制内存访问可以简化编写清晰、正确程序的过程。

一个帮助理解这一模型的方式是考虑一个典型的单线程程序在单 CPU 上运行。它不需要同步原语。现在运行另一个这样的实例;它也需要同步。现在让它们通过管道进行通信。使用 Go 的通道进行通信可以达到同样的效果,无需其他同步机制。Unix 管道可以看作是这一模型的一个特例。

协程

它们被称为 协程,因为现有的术语 —— 线程、协程、进程等 —— 都不能准确描述它们。一个协程是一个函数,它与其他协程并发执行,在同一地址空间内。它很轻量级,除了分配栈空间的成本外几乎不消耗其他资源。而且,这些栈空间一开始很小,因此协程的创建成本很低,并且会根据需要自动增长和缩减。

协程被多个操作系统线程多路复用,因此如果一个协程阻塞(例如等待 I/O),其他协程仍可继续运行。它们的设计隐藏了许多线程创建和管理的复杂细节。

在函数或方法调用前加上 go 关键字,即可在新的协程中运行该调用。当调用完成时,协程默默退出。例如:

go list.Sort()  // 在后台运行 list.Sort;不要等待它完成。

函数字面量在协程调用中非常有用。

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

在 Go 中,函数字面量是闭包:实现确保引用的变量在函数生存期内有效。

这些示例略显单薄,因为函数没有返回值,也没有办法通知调用者何时完成。为此,我们需要通道。

通道

像映射一样,通道通过 make 分配,并且返回的值作为底层数据结构的引用。如果提供了一个可选的整数参数,则设置了通道的缓冲区大小。默认情况下通道是无缓冲的(或同步的)。

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

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

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

c := make(chan int)  // 分配一个通道。
// 在后台启动排序;完成后向通道发送信号。
go func() {
    list.Sort()
    c <- 1  // 发送信号;值无关紧要。
}()
doSomethingForAWhile()
<-c   // 等待排序完成;丢弃发送的值。

接收方始终会阻塞,直到有值可以接收。如果通道是无缓冲的,发送方会阻塞,直到接收方已接收该值。如果通道有缓冲,发送方只会在值被复制到缓冲区前阻塞;如果缓冲区已满,则发送方会等待,直到某个接收方从缓冲区中检索了一个值。

缓冲通道可以用作信号量,例如限制吞吐量。在这个示例中,传入的请求被传递给 handle,它发送一个值到通道;处理请求,然后从通道接收一个值以释放 “信号量”。允许的并发调用数量由通道缓冲区的大小限制。这是一个处理请求的示例:

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 完成。
    }
}

这个设计存在一个问题:Serve 为每个传入的请求创建一个新协程,即使只有 MaxOutstanding 个可以同时运行。因此,如果请求到来过快,程序可能会消耗无限的资源。我们可以通过改变 Serve 来限制协程的创建来解决这一缺陷:

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

(注意,在 Go 1.22 之前的版本中,此代码存在一个错误:循环变量在所有协程中共享。有关详情,请参阅 Go 维基。)

另一种管理资源的良好方法是启动固定数量的 handle 协程,它们都从请求通道中读取。并发的 handle 协程数量限制了同时调用 process 的数量。这个 Serve 函数还接受一个通道,用于通知退出;启动协程后,它会阻塞等待该通道上的消息。

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 最重要的特性之一是通道本身是第一类值,可以像其他任何值一样分配和传递。一个常见的应用场景是实现安全的并行解复用。

在上一节的示例中,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("答案:%d\n", <-request.resultChan)

在服务器端,处理程序函数稍作更改:

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

显然,这只是一个框架,需要做更多工作才能使其成为现实,但它是一个无锁的、并行的、非阻塞的 RPC 系统的雏形。

并行化

另一种应用这些思想的场景是将计算并行化以利用多个 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。它们可以按任意顺序完成,但这并不重要;我们只需通过从通道中读取来计数完成的信号。

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)

切勿混淆并发的概念 —— 结构化程序以使其组件独立执行 —— 与并行化 —— 为了在多个 CPU 上高效执行而并行化计算。尽管 Go 的并发特性可以使某些并行化问题变得容易,但 Go 是一种并发语言,而非并行语言。有关区别的讨论,请参阅这篇博文所引用的演讲。

泄漏缓冲区

并发工具甚至可以使非并发思想更易于表达。这里有一个从 RPC 包中抽象出来的例子。客户端协程循环接收来自某个源(可能是网络)的数据。为了避免分配和释放缓冲区,它维护了一个自由列表,使用缓冲通道来表示它。如果通道为空,则分配新缓冲区。一旦消息缓冲区就绪,它将通过 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 中获取缓冲区;如果不可用,则分配一个新缓冲区。服务器的发送操作将 b 放回自由列表,除非列表已满,在这种情况下,缓冲区将被丢弃。select 语句中的 default 子句确保这两个 select 永远不会阻塞。

错误

库例程必须向调用者报告某些形式的错误指示。如前所述,Go 的多值返回特性使得返回详细的错误描述变得容易。例如,除了通常的返回值外,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()
}

PathError 的 Error 方法生成类似以下的字符串:

open /etc/passwx: 没有这样一个文件或目录

这样的错误字符串非常有用,即使在远离引发错误的位置打印也是如此;它比简单的 “没有这样的文件或目录” 提供了更多的信息。

每当可行时,错误字符串应标识其来源,例如通过包含操作或包的前缀。例如,在 image 包中,解析错误的字符串表示为 “image: 未知格式”。

调用者如果关心具体的错误详情,可以通过类型转换或类型断言来检查错误是否为特定类型。例如,在以下代码中,如果 os.Create 返回的错误是一个 os.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 将为假,e 将为 nil。如果成功,ok 将为真,此时 e 是一个 *os.PathError,我们可以检查其内部的 Err 字段。

恐慌

通常,报告错误的惯用方法是返回一个额外的错误值。但有些错误是不可恢复的。在这种情况下,或者当问题不可能发生时,程序可能无法继续运行。

为此,内置函数 panic 提供了一种机制。它接受一个任意类型的参数 —— 通常是字符串 —— 并在程序死亡前打印该参数。它还开始从当前函数开始向上回溯所有 goroutine 的栈,运行任何延迟的函数。如果该回溯到达 goroutine 的栈顶,程序将终止。然而,这可以通过 recover 函数来避免,该函数将在下一节中介绍。

例如,以下是一个玩具立方根实现,使用牛顿迭代法。如果迭代次数用尽仍未收敛,则表明存在错误,函数将调用 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) 未收敛", x))
}

这是一个简单的例子,实际库函数应避免使用 panic。如果问题可以被掩盖或有解决办法,则应让程序继续运行,而非终止。一个可能的例外是在初始化期间:如果库无法设置自己,它可以合理地调用 panic

var user = os.Getenv("USER")

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

恢复

当 panic 被调用时 —— 包括隐式地发生运行时错误,如索引越界或类型断言失败 —— 它立即停止当前函数的执行,并开始回溯 goroutine 的栈,运行任何延迟的函数。如果该回溯到达 goroutine 栈的顶部,程序将终止。然而,可以使用内置函数 recover 从该回溯中恢复,使 goroutine 恢复正常执行。

recover 的调用只能出现在延迟函数中。它停止回溯并返回传递给 panic 的参数。

一个应用 recover 的常见场景是在服务器中关闭失败的 goroutine,而不会影响其他正在运行的 goroutine。

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

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

在这个例子中,如果 do(work) 调用了 panic,结果将被记录,并且 goroutine 将干净地退出,而不会干扰其他 goroutine。延迟的闭包中不需要执行其他操作;调用 recover 就能完全处理这种情况。

由于 recover 总是返回 nil,除非它直接从延迟函数中调用,因此延迟代码可以调用可能使用 panic 和 recover 的库例程而无需失败。例如,在延迟的闭包中,日志记录代码可能在调用 recover 之前调用其他函数,而这些函数可能会引发 panic

使用 recover 的另一个例子是简化错误处理。例如,一个理想的正则表达式包可能会通过调用 panic 来报告解析错误,而不会使调用者陷入复杂的错误处理逻辑。这里定义了错误类型 Error,一个 error 方法,以及 Compile 函数。

// Error 是一个表示解析错误的类型;它实现了 error 接口。
type Error string
func (e Error) Error() string {
    returnstring(e)
}

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

// Compile 返回一个解析后的正则表达式。
func Compile(str string) (regexp *Regexp, err error) {
    regexp = new(Regexp)
    // doParse 在发生解析错误时会调用 panic。
    deferfunc() {
        if e := recover(); e != nil {
            regexp = nil    // 清除返回值。
            err = e.(Error) // 如果不是解析错误,类型断言将失败并重新引发 panic。
        }
    }()
    return regexp.doParse(str), nil
}

如果 doParse 调用了 panic,延迟的恢复函数将设置返回值为 nil。它还会检查问题是否为解析错误,通过将恢复的值断言为本地类型 Error 来完成。如果不成功,类型断言将失败,导致运行时错误,继续回溯,如同没有发生恢复一样。

这样的重新引发错误使我们能够记录意外错误,同时隐藏正常的错误报告。例如,如果用户传入一个格式错误的正则表达式,调用者将收到一个 error 值,但如果包内部发生了一个索引越界错误,程序将崩溃并报告两个错误:原始的 panic 和恢复失败。

网页服务器

最后,以一个完整的 Go 程序结束:一个网页服务器。这实际上是一个网页重新服务器。Google 在 chart.apis.google.com 上提供了一项服务,可以自动将数据格式化为图表和图形。它很难交互使用,因为需要将数据放入 URL 的查询部分。该程序提供了一个更友好的界面来使用其中一种数据形式:给定一段简短的文本后,它调用图表服务器生成一个二维码,这是一个由方框组成的矩阵,可以对文本进行编码。该图像可以使用你的手机相机获取,并被解释为例如 URL,从而节省了在手机小键盘上输入 URL 的麻烦。

以下是完整的程序。之后是对它的解释。

package main

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

var addr = flag.String("addr", ":1718", "HTTP 服务地址")

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 链接生成器</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="要进行 QR 编码的文本">
    <input type=submit value="显示 QR" name=qr>
</form>
</body>
</html>
`

前几个导入和 main 函数之前的片段应该易于理解。标志设置了一个默认的 HTTP 端口。模板变量 templ 是乐趣所在。它构建了一个 HTML 模板,该模板将在服务器执行时动态重写,由传递给 templ.Execute 的数据驱动。

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

QR 函数接收请求(包含表单数据),并使用模板对该请求中的表单值 s 执行操作。

html/template 包功能强大;此程序仅触及了其能力的一小部分。本质上,它允许在运行时根据传入的 templ.Execute 数据动态重写 HTML 文本。在模板字符串(templateStr)中,由双花括号括起来的部分表示模板操作。{{if .}} 到 {{end}} 的片段仅在当前数据项(称为 .)非空时执行。

在模板字符串中出现的两个 {{.}} 表示显示传递给模板的数据 —— 表单值 —— 在网页上。HTML 模板包会自动进行适当的转义,使其安全显示。

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

参考:https://go.dev/doc/effective_go

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值