高效 Go 编程
引言
Go 是一门新语言。尽管它借鉴了现有语言的理念,但它具有一些不寻常的特性,使得高效的 Go 程序在风格上与用其“亲戚”语言编写的程序不同。将 C++ 或 Java 程序直接翻译成 Go 程序,不太可能产生令人满意的结果——Java 程序是用 Java 编写的,而不是 Go。另一方面,从 Go 的角度思考问题,可能会产生一个成功但截然不同的程序。换句话说,要写好 Go,理解它的特性和惯用法很重要。了解 Go 编程的既定约定也很重要,例如命名、格式、程序结构等,这样你编写的程序才能更容易被其他 Go 程序员理解。
本文档提供了编写清晰、地道 Go 代码的技巧。它补充了语言规范、Go 语言之旅和如何编写 Go 代码,你应该首先阅读所有这些内容。
2022 年 1 月补充说明:本文档是为 Go 在 2009 年发布时编写的,此后没有进行重大更新。尽管它是一个理解如何使用语言本身的良好指南,但由于语言的稳定性,它很少提及库,并且完全没有提及自编写以来 Go 生态系统发生的重大变化,例如构建系统、测试、模块和多态性。目前没有计划更新它,因为已经发生了太多变化,并且大量且不断增长的文档、博客和书籍在描述现代 Go 用法方面做得很好。《高效 Go 编程》仍然有用,但读者应该理解它远非一个完整的指南。有关上下文,请参阅问题 28782。
示例
Go 包的源代码不仅作为核心库,还旨在作为如何使用该语言的示例。此外,许多包包含可运行的、自包含的可执行示例,你可以直接从 go.dev 网站运行,例如这个(如有必要,点击“Example”一词以展开)。如果你对如何处理问题或如何实现某个功能有疑问,库中的文档、代码和示例可以提供答案、想法和背景。
格式化
格式化问题最具争议但影响最小。人们可以适应不同的格式化风格,但如果不需要适应会更好,如果每个人都遵循相同的风格,花在这个话题上的时间就会更少。问题是如何在没有冗长的规范风格指南的情况下实现这种理想状态。
在 Go 中,我们采取了一种不同寻常的方法,让机器处理大多数格式化问题。gofmt
程序(也可通过 go fmt
使用,它在包级别而不是源文件级别操作)读取 Go 程序并以标准缩进和垂直对齐样式输出源代码,保留并在必要时重新格式化注释。如果你想知道如何处理某种新的布局情况,运行 gofmt
;如果答案看起来不正确,请重新安排你的程序(或提交关于 gofmt
的错误),不要绕过它。
例如,没有必要花时间对齐结构体字段上的注释。Gofmt
会为你完成。给定声明
type T struct { name string // name of the object value int // its value }
gofmt
将对齐这些列
type T struct { name string // name of the object value int // its value }
标准包中的所有 Go 代码都已使用 gofmt
格式化。
一些格式化细节仍需注意。简要概述如下:
- 缩进
- 我们使用制表符进行缩进,
gofmt
默认会输出制表符。只在必要时才使用空格。 - 行长度
- Go 没有行长度限制。不必担心超出打孔卡的限制。如果一行感觉太长,请将其换行并额外缩进一个制表符。
- 括号
- Go 比 C 和 Java 需要更少的括号:控制结构(
if
、for
、switch
)的语法中没有括号。此外,运算符优先级层次结构更短、更清晰,因此x<<8 + y<<16
与其它语言不同,其含义与间距所暗示的一致。
注释
Go 提供 C 风格的 /* */
块注释和 C++ 风格的 //
行注释。行注释是常用的;块注释主要出现在包注释中,但在表达式内部或禁用大段代码时很有用。
在顶层声明之前、没有中间换行符的注释被视为文档化该声明本身。这些“文档注释”是给定 Go 包或命令的主要文档。有关文档注释的更多信息,请参阅“Go 文档注释”。
命名
在 Go 中,命名与其他任何语言一样重要。它们甚至具有语义效应:名称在包外的可见性取决于其首字母是否大写。因此,值得花一些时间讨论 Go 程序中的命名约定。
包名
当一个包被导入时,包名就成为了访问其内容的修饰符。在
import "bytes"
导入包可以使用 bytes.Buffer
。如果所有使用该包的人都可以使用相同的名称来引用其内容,这将很有帮助,这意味着包名应该良好:短小、简洁、富有启发性。按照惯例,包使用小写、单字名称;不应使用下划线或驼峰命名法。倾向于简洁,因为每个使用你的包的人都将输入这个名称。并且不要预先担心冲突。包名只是导入的默认名称;它不需要在所有源代码中都是唯一的,在极少数冲突情况下,导入包可以选择一个不同的名称在本地使用。无论如何,混淆很少发生,因为导入中的文件名决定了正在使用哪个包。
另一个惯例是,包名是其源目录的基本名称;src/encoding/base64
中的包被导入为 "encoding/base64"
,但其名称是 base64
,而不是 encoding_base64
或 encodingBase64
。
包的导入者将使用该名称来引用其内容,因此包中导出的名称可以利用这一事实来避免重复。(不要使用 import .
这种符号,它可能会简化必须在其测试包之外运行的测试,但除此之外应避免使用。)例如,bufio
包中的缓冲读取器类型被命名为 Reader
,而不是 BufReader
,因为用户将其视为 bufio.Reader
,这是一个清晰、简洁的名称。此外,由于导入的实体总是通过其包名来寻址,bufio.Reader
不会与 io.Reader
冲突。同样,用于创建 ring.Ring
新实例的函数——这是 Go 中“构造函数”的定义——通常会被命名为 NewRing
,但由于 Ring
是该包唯一导出的类型,并且该包名为 ring
,它被简单地命名为 New
,包的客户端将其视为 ring.New
。利用包的结构来帮助你选择好的名称。
另一个简短的例子是 once.Do
;once.Do(setup)
读起来很顺畅,写成 once.DoOrWaitUntilDone(setup)
也不会更好。长名称并不能自动提高可读性。一个有用的文档注释通常比一个超长名称更有价值。
Getter 方法
Go 不提供对 getter 和 setter 的自动支持。自己提供 getter 和 setter 并没有错,而且通常也很合适,但将 Get
放入 getter 的名称中既不符合 Go 惯用法,也不是必需的。如果你有一个名为 owner
(小写,未导出)的字段,那么 getter 方法应该命名为 Owner
(大写,已导出),而不是 GetOwner
。使用大写名称进行导出提供了区分字段和方法的钩子。如果需要 setter 函数,它很可能被命名为 SetOwner
。这两个名称在实践中都易于阅读
owner := obj.Owner() if owner != user { obj.SetOwner(user) }
接口名称
按照惯例,单方法接口的命名方式是:方法名加上“-er”后缀或类似的修饰,以构成一个施动名词:Reader
、Writer
、Formatter
、CloseNotifier
等。
有许多这样的名称,遵守它们及其所捕获的函数名是富有成效的。Read
、Write
、Close
、Flush
、String
等都具有规范的签名和含义。为避免混淆,除非你的方法具有相同的签名和含义,否则不要给它起这些名称。反之,如果你的类型实现的方法与某个知名类型上的方法具有相同的含义,则给它相同的名称和签名;将你的字符串转换方法命名为 String
,而不是 ToString
。
驼峰命名
最后,Go 的惯例是使用 MixedCaps
或 mixedCaps
而不是下划线来编写多词名称。
分号
与 C 语言类似,Go 的正式语法使用分号来终止语句,但与 C 语言不同的是,这些分号不会出现在源代码中。相反,词法分析器在扫描时使用一个简单的规则自动插入分号,因此输入文本中大部分都没有分号。
规则如下:如果换行符前的最后一个词法单元是标识符(包括 int
和 float64
等关键字)、基本字面量(例如数字或字符串常量),或下列词法单元之一
break continue fallthrough return ++ -- ) }
词法分析器总是在该词法单元后面插入一个分号。这可以总结为:“如果换行符出现在可能结束一个语句的词法单元之后,就插入一个分号”。
分号也可以在紧接右大括号之前省略,所以像这样一条语句
go func() { for { dst <- <-src } }()
不需要分号。地道的 Go 程序只在 for
循环子句等地方使用分号,用于分隔初始化器、条件和延续元素。如果你以这种方式编写代码,它们也需要分隔同一行上的多个语句。
分号插入规则的一个结果是,你不能将控制结构(if
、for
、switch
或 select
)的左大括号放在下一行。如果你这样做,分号将插入在大括号之前,这可能会导致意外效果。像这样编写它们
if i < f() { g() }
而不是这样
if i < f() // wrong! { // wrong! g() }
控制结构
Go 的控制结构与 C 语言的控制结构相关,但在重要方面有所不同。Go 没有 do
或 while
循环,只有一个稍微泛化的 for
循环;switch
更灵活;if
和 switch
接受一个可选的初始化语句,类似于 for
循环的初始化语句;break
和 continue
语句接受一个可选的标签来标识要中断或继续的循环;并且有新的控制结构,包括类型开关和多路通信多路复用器 select
。语法也略有不同:没有括号,并且代码块必须始终用大括号括起来。
If
在 Go 中,一个简单的 if
语句看起来像这样
if x > 0 { return y }
强制性的花括号鼓励将简单的 if
语句写在多行上。无论如何,这样做都是一个好习惯,特别是当主体包含控制语句(如 return
或 break
)时。
由于 if
和 switch
接受初始化语句,因此常见的是使用它来设置局部变量。
if err := file.Chmod(0664); err != nil { log.Print(err) return err }
在 Go 库中,你会发现当 if
语句不流向下一个语句时——也就是说,主体以 break
、continue
、goto
或 return
结束时——不必要的 else
会被省略。
f, err := os.Open(name) if err != nil { return err } codeUsing(f)
这是一个常见情况的例子,代码必须防范一系列错误条件。如果成功的控制流按页面顺序执行,随着错误情况的出现而消除它们,则代码读起来很好。由于错误情况通常以 return
语句结束,因此生成的代码不需要 else
语句。
f, err := os.Open(name) if err != nil { return err } d, err := f.Stat() if err != nil { f.Close() return err } codeUsing(f, d)
重新声明与重新赋值
题外话:上一节的最后一个例子展示了 :=
短声明形式的工作原理的一个细节。调用 os.Open
的声明是:
f, err := os.Open(name)
此语句声明了两个变量:f
和 err
。几行之后,调用 f.Stat
的代码是:
d, err := f.Stat()
这看起来好像声明了 d
和 err
。但请注意,err
出现在两个语句中。这种重复是合法的:err
由第一个语句声明,但在第二个语句中只是被重新赋值。这意味着调用 f.Stat
使用了上面声明的现有 err
变量,并只是给它赋了一个新值。
在 :=
声明中,即使变量 v
已经声明,也可以再次出现,前提是:
- 此声明与
v
的现有声明在同一作用域内(如果v
已在外部作用域中声明,则此声明将创建一个新变量 §); - 初始化中的相应值可赋值给
v
; - 声明至少创建了一个其他变量。
这种不寻常的特性纯粹是实用主义,使得在长 if-else
链中轻松使用单个 err
值成为可能。你会经常看到它被使用。
§ 值得注意的是,在 Go 中,函数参数和返回值的范围与函数体相同,尽管它们在词法上出现在包含函数体的大括号之外。
For
Go 的 for
循环与 C 语言的类似——但不完全相同。它统一了 for
和 while
,并且没有 do-while
。它有三种形式,其中只有一种带有分号。
// Like a C for for init; condition; post { } // Like a C while for condition { } // Like a 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。(名称(及关联的内置类型)rune
是 Go 中单个 Unicode 码点的术语。详情请参阅语言规范。)循环
for pos, char := range "日本\x80語" { // \x80 is an illegal UTF-8 encoding 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
循环中操作多个变量,应该使用并行赋值(尽管这排除了 ++
和 --
)。
// Reverse a for i, j := 0, len(a)-1; i < j; i, j = i+1, j-1 { a[i], a[j] = a[j], a[i] }
Switch
Go 的 switch
比 C 语言的更通用。表达式不必是常量甚至整数,case 会从上到下进行评估直到找到匹配项,如果 switch
没有表达式,它将根据 true
进行切换。因此,将 if
-else
-if
-else
链写成 switch
语句是可能的——并且是惯用的。
func unhex(c byte) byte { switch { case '0' <= c && c <= '9': return c - '0' case 'a' <= c && c <= 'f': return c - 'a' + 10 case 'A' <= c && c <= 'F': return c - 'A' + 10 } return 0 }
没有自动的 fall through(穿透),但 case 可以以逗号分隔的列表形式呈现。
func shouldEscape(c byte) bool { switch c { case ' ', '?', '&', '=', '#', '+', '%': return true } return false }
尽管在 Go 中它们不像在其他一些类 C 语言中那么常见,但 break
语句可以用于提前终止 switch
。然而,有时需要跳出外部循环而不是 switch
,在 Go 中可以通过在循环上放置一个标签并“break”到该标签来实现。这个例子展示了两种用法。
Loop: for n := 0; n < len(src); n += size { switch { case src[n] < sizeOne: if validateOnly { break } size = 1 update(src[n]) case src[n] < sizeTwo: if n+1 >= len(src) { err = errShortInput break Loop } if validateOnly { break } size = 2 update(src[n] + src[n+1]<<shift) } }
当然,continue
语句也接受一个可选的标签,但它只适用于循环。
本节最后,这是一个用于字节切片的比较例程,它使用了两个 switch
语句
// Compare returns an integer comparing the two byte slices, // lexicographically. // The result will be 0 if a == b, -1 if a < b, and +1 if a > b 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 中声明一个同名但类型不同的新变量。
var t interface{} t = functionOfSomeType() switch t := t.(type) { default: fmt.Printf("unexpected type %T\n", t) // %T prints whatever type t has case bool: fmt.Printf("boolean %t\n", t) // t has type bool case int: fmt.Printf("integer %d\n", t) // t has type int case *bool: fmt.Printf("pointer to boolean %t\n", *t) // t has type *bool case *int: fmt.Printf("pointer to integer %d\n", *t) // t has type *int }
函数
多返回值
Go 的一个不寻常特性是函数和方法可以返回多个值。这种形式可以改进 C 程序中一些笨拙的惯用法:带内错误返回(例如 EOF
用 -1
表示)和修改通过地址传递的参数。
在 C 语言中,写入错误通过负数计数和隐藏在易失位置的错误码来表示。在 Go 语言中,Write
可以返回计数和错误:“是的,你写入了一些字节,但不是全部,因为设备已满”。os
包中文件的 Write
方法签名为
func (file *File) Write(b []byte) (n int, err error)
正如文档所说,当 n
!=
len(b)
时,它会返回写入的字节数和一个非 nil 的 error
。这是一种常见的风格;请参阅错误处理部分以获取更多示例。
类似的方法避免了传递指向返回值指针以模拟引用参数的需要。这是一个简单的函数,用于从字节切片中的某个位置获取一个数字,返回该数字和下一个位置。
func nextInt(b []byte, i int) (int, int) { for ; i < len(b) && !isDigit(b[i]); i++ { } x := 0 for ; i < len(b) && isDigit(b[i]); i++ { x = x*10 + int(b[i]) - '0' } return x, i }
你可以像这样用它来扫描输入切片 b
中的数字
for i := 0; i < len(b); { x, i = nextInt(b, i) fmt.Println(x) }
命名结果参数
Go 函数的返回或结果“参数”可以像传入参数一样被命名并用作常规变量。命名后,它们在函数开始时会被初始化为其类型的零值;如果函数执行不带参数的 return
语句,则结果参数的当前值将用作返回值。
这些名称不是强制性的,但它们可以使代码更短、更清晰:它们是文档。如果我们命名 nextInt
的结果,那么哪个返回的 int
是哪个就显而易见了。
func nextInt(b []byte, pos int) (value, nextPos int) {
因为命名结果被初始化并绑定到无参数的 return 语句,所以它们既可以简化也可以澄清。下面是 io.ReadFull
的一个版本,它很好地利用了命名结果
func ReadFull(r Reader, buf []byte) (n int, err error) { for len(buf) > 0 && err == nil { var nr int nr, err = r.Read(buf) n += nr buf = buf[nr:] } return }
Defer
Go 的 defer
语句安排一个函数调用(被延迟的函数)在执行 defer
的函数返回之前立即运行。这是一种不寻常但有效的方法,用于处理必须释放资源的情况,无论函数通过哪条路径返回。典型的例子是解锁互斥锁或关闭文件。
// Contents returns the file's contents as a string. func Contents(filename string) (string, error) { f, err := os.Open(filename) if err != nil { return "", err } defer f.Close() // f.Close will run when we're finished. var result []byte buf := make([]byte, 100) for { n, err := f.Read(buf[0:]) result = append(result, buf[0:n]...) // append is discussed later. if err != nil { if err == io.EOF { break } return "", err // f will be closed if we return here. } } return string(result), nil // f will be closed if we return here. }
延迟调用 Close
等函数有两个优点。首先,它保证你永远不会忘记关闭文件,如果你稍后修改函数以添加新的返回路径,这是一个容易犯的错误。其次,这意味着关闭语句与打开语句相邻,这比将其放在函数的末尾清晰得多。
延迟函数的参数(如果该函数是方法,则包括接收者)在 defer
执行时求值,而不是在 call
执行时求值。除了避免担心函数执行时变量值发生变化外,这意味着单个延迟调用站点可以延迟多个函数执行。这是一个愚蠢的例子。
for i := 0; i < 5; i++ { defer fmt.Printf("%d ", i) }
延迟函数以 LIFO 顺序执行,因此当函数返回时,此代码将打印 4 3 2 1 0
。一个更合理的例子是跟踪程序函数执行的简单方法。我们可以编写几个简单的跟踪例程,如下所示
func trace(s string) { fmt.Println("entering:", s) } func untrace(s string) { fmt.Println("leaving:", s) } // Use them like this: func a() { trace("a") defer untrace("a") // do something.... }
我们可以做得更好,利用延迟函数参数在 defer
执行时求值这一事实。跟踪例程可以为取消跟踪例程设置参数。这个例子
func trace(s string) string { fmt.Println("entering:", s) return s } func un(s string) { fmt.Println("leaving:", s) } func a() { defer un(trace("a")) fmt.Println("in a") } func b() { defer un(trace("b")) fmt.Println("in b") a() } func main() { b() }
打印
entering: b in b entering: a in a leaving: a leaving: b
对于习惯于其他语言中块级资源管理的程序员来说,defer
可能看起来很特殊,但其最有趣和最强大的应用正是来自于它不是基于块而是基于函数的事实。在 panic
和 recover
一节中,我们将看到其可能性的另一个例子。
数据
使用 new
分配
Go 有两个分配原语,内置函数 new
和 make
。它们做不同的事情并应用于不同的类型,这可能会令人困惑,但规则很简单。我们先谈谈 new
。它是一个内置函数,用于分配内存,但与某些其他语言中同名的函数不同,它不初始化内存,它只将其置零。也就是说,new(T)
为类型 T
的新项分配清零存储并返回其地址,类型为 *T
的值。在 Go 术语中,它返回一个指向新分配的类型 T
的零值的指针。
由于 new
返回的内存是清零的,因此在设计数据结构时,最好安排每种类型的零值无需进一步初始化即可使用。这意味着数据结构的用户可以使用 new
创建一个并立即开始工作。例如,bytes.Buffer
的文档指出:“Buffer
的零值是一个空缓冲区,可供使用。”同样,sync.Mutex
没有显式构造函数或 Init
方法。相反,sync.Mutex
的零值被定义为未锁定的互斥锁。
零值有用特性是传递的。考虑这个类型声明。
type SyncedBuffer struct { lock sync.Mutex buffer bytes.Buffer }
类型 SyncedBuffer
的值在分配或仅声明后也立即可用。在下面的代码片段中,p
和 v
都无需进一步安排即可正常工作。
p := new(SyncedBuffer) // type *SyncedBuffer var v SyncedBuffer // type 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 语言不同,返回局部变量的地址是完全没问题的;与变量相关的存储在函数返回后仍然存在。实际上,获取复合字面量的地址会在每次求值时分配一个新实例,因此我们可以将最后两行合并。
return &File{fd, name, nil, 0}
复合字面量的字段按顺序排列,并且必须全部存在。但是,通过将元素明确标记为字段:
值对,初始化器可以以任何顺序出现,缺失的元素将保留其各自的零值。因此我们可以说
return &File{fd: fd, name: name}
作为一种极端情况,如果复合字面量不包含任何字段,它将为该类型创建一个零值。表达式 new(File)
和 &File{}
是等价的。
复合字面量也可以为数组、切片和映射创建,字段标签分别为索引或映射键。在这些示例中,初始化工作与 Enone
、Eio
和 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 个 int 值的数组,然后创建一个切片结构,其长度为 10,容量为 100,指向数组的前 10 个元素。(创建切片时,容量可以省略;有关更多信息,请参阅切片部分。)相反,new([]int)
返回一个指向新分配的、已清零的切片结构的指针,也就是说,一个指向 nil
切片值的指针。
这些例子说明了 new
和 make
之间的区别。
var p *[]int = new([]int) // allocates slice structure; *p == nil; rarely useful var v []int = make([]int, 100) // the slice v now refers to a new array of 100 ints // Unnecessarily complex: var p *[]int = new([]int) *p = make([]int, 100, 100) // Idiomatic: 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) // Note the explicit address-of operator
但即使这种风格也不是地道的 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]) // Read one byte. n += nbytes if nbytes == 0 || e != nil { err = e break } }
只要切片长度仍在底层数组的限制范围内,就可以更改其长度;只需将其赋值给其自身的切片即可。切片的容量(通过内置函数 cap
获取)报告切片可能达到的最大长度。这是一个向切片附加数据的函数。如果数据超出容量,则重新分配切片。返回结果切片。该函数利用了 len
和 cap
在应用于 nil
切片时合法并返回 0 的事实。
func Append(slice, data []byte) []byte { l := len(slice) if l + len(data) > cap(slice) { // reallocate // Allocate double what's needed, for future growth. newSlice := make([]byte, (l+len(data))*2) // The copy function is predeclared and works for any slice type. copy(newSlice, slice) slice = newSlice } slice = slice[0:l+len(data)] copy(slice[l:], data) return slice }
我们之后必须返回切片,因为尽管 Append
可以修改 slice
的元素,但切片本身(持有指针、长度和容量的运行时数据结构)是按值传递的。
将数据附加到切片的想法非常有用,以至于被 append
内置函数捕获。然而,要理解该函数的设计,我们需要更多信息,因此我们稍后会再讨论它。
二维切片
Go 的数组和切片都是一维的。要创建等效的二维数组或切片,需要定义一个数组的数组或切片的切片,像这样
type Transform [3][3]float64 // A 3x3 array, really an array of arrays. type LinesOfText [][]byte // A slice of byte slices.
由于切片是可变长度的,因此每个内部切片都可以具有不同的长度。这可能是一种常见情况,就像我们的 LinesOfText
示例一样:每行都具有独立的长度。
text := LinesOfText{ []byte("Now is the time"), []byte("for all good gophers"), []byte("to bring some fun to the party."), }
有时需要分配一个二维切片,例如在处理像素扫描行时可能会出现这种情况。有两种方法可以实现这一点。一种是独立分配每个切片;另一种是分配一个单一数组,并将单个切片指向其中。使用哪种方法取决于你的应用。如果切片可能会增长或缩小,则应独立分配它们以避免覆盖下一行;如果不会,则使用单一分配来构造对象可能更有效率。作为参考,这里是两种方法的草图。首先,一次一行
// Allocate the top-level slice. picture := make([][]uint8, YSize) // One row per unit of y. // Loop over the rows, allocating the slice for each row. for i := range picture { picture[i] = make([]uint8, XSize) }
现在作为一个整体分配,然后切片成行
// Allocate the top-level slice, the same as before. picture := make([][]uint8, YSize) // One row per unit of y. // Allocate one large slice to hold all the pixels. pixels := make([]uint8, XSize*YSize) // Has type []uint8 even though picture is [][]uint8. // Loop over the rows, slicing each row from the front of the remaining pixels slice. 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
。集合可以实现为值类型为 bool
的映射。将映射条目设置为 true
以将值放入集合中,然后通过简单索引进行测试。
attended := map[string]bool{ "Ann": true, "Joe": true, ... } if attended[person] { // will be false if person is not in the map fmt.Println(person, "was at the meeting") }
有时你需要区分缺少条目和零值。"UTC"
有条目吗?还是因为它根本不在映射中所以是 0?你可以使用多重赋值的形式进行区分。
var seconds int var ok bool seconds, ok = timeZone[tz]
出于显而易见的原因,这被称为“逗号 ok”惯用法。在这个例子中,如果 tz
存在,seconds
将被正确设置,ok
将为 true;如果不存在,seconds
将被设置为零,ok
将为 false。这是一个函数,它将它与一个漂亮的错误报告结合在一起
func offset(tz string) int { if seconds, ok := timeZone[tz]; ok { return seconds } log.Println("unknown time zone:", tz) return 0 }
要在不关心实际值的情况下测试映射中是否存在,你可以使用空白标识符 (_
) 代替通常用于值变量的标识符。
_, present := timeZone[tz]
要删除映射条目,请使用内置函数 delete
,其参数是映射和要删除的键。即使键已从映射中缺失,执行此操作也是安全的。
delete(timeZone, "PDT") // Now on Standard Time
打印
Go 中的格式化打印使用类似于 C 语言 printf
系列的风格,但更丰富、更通用。这些函数位于 fmt
包中,并使用大写名称:fmt.Printf
、fmt.Fprintf
、fmt.Sprintf
等。字符串函数(Sprintf
等)返回一个字符串,而不是填充提供的缓冲区。
你不需要提供格式字符串。对于 Printf
、Fprintf
和 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
是常见的实例。
在这里,情况开始与 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
(表示“值”);结果与 Print
和 Println
生成的完全相同。此外,这种格式可以打印任何值,甚至是数组、切片、结构体和映射。这是上一节中定义的时区映射的打印语句。
fmt.Printf("%v\n", timeZone) // or just fmt.Println(timeZone)
输出为
map[CST:-21600 EST:-18000 MST:-25200 PST:-28800 UTC:0]
对于映射,Printf
和其相关的函数按键的字典顺序排序输出。
当打印结构体时,修改后的格式 %+v
会用其名称标注结构体的字段,对于任何值,备用格式 %#v
会以完整的 Go 语法打印该值。
type T struct { a int b float64 c string } t := &T{ 7, -2.35, "abc\tdef" } fmt.Printf("%v\n", t) fmt.Printf("%+v\n", t) fmt.Printf("%#v\n", t) fmt.Printf("%#v\n", timeZone)
打印
&{7 -2.35 abc def} &{a:7 b:-2.35 c:abc def} &main.T{a:7, b:-2.35, c:"abc\tdef"} map[string]int{"CST":-21600, "EST":-18000, "MST":-25200, "PST":-28800, "UTC":0}
(注意&符号。)当应用于 string
或 []byte
类型的值时,该带引号的字符串格式也可通过 %q
获得。备用格式 %#q
在可能的情况下将使用反引号。(%q
格式也适用于整数和符文,生成一个单引号符文常量。)此外,%x
适用于字符串、字节数组和字节切片以及整数,生成一个长十六进制字符串,并且在格式中带有空格(% x
),它会在字节之间放置空格。
另一个方便的格式是 %T
,它打印值的类型。
fmt.Printf("%T\n", timeZone)
打印
map[string]int
如果你想控制自定义类型的默认格式,所需要做的就是为该类型定义一个签名是 String() string
的方法。对于我们简单的类型 T
,这可能看起来像这样。
func (t *T) String() string { return fmt.Sprintf("%d/%g/%q", t.a, t.b, t.c) } fmt.Printf("%v\n", t)
以如下格式打印
7/-2.35/"abc\tdef"
(如果你需要打印 T
类型的值以及 T
的指针,String
的接收者必须是值类型;这个例子使用指针是因为对于结构体类型来说,这种方式更高效且符合惯例。有关更多信息,请参阅下面关于指针与值接收者的部分。)
我们的 String
方法能够调用 Sprintf
,因为打印例程是完全可重入的,并且可以这样封装。然而,关于这种方法有一个重要的细节需要理解:不要通过调用 Sprintf
来构建 String
方法,使其以一种将无限递归到你的 String
方法的方式。如果 Sprintf
调用试图直接将接收者作为字符串打印,这反过来又会再次调用该方法,就会发生这种情况。这是一个常见且容易犯的错误,如下例所示。
type MyString string func (m MyString) String() string { return fmt.Sprintf("MyString=%s", m) // Error: will recur forever. }
修复起来也很容易:将参数转换为基本字符串类型,该类型没有该方法。
type MyString string func (m MyString) String() string { return fmt.Sprintf("MyString=%s", string(m)) // OK: note conversion. }
在初始化一节中,我们将看到另一种避免这种递归的技术。
另一种打印技术是将打印例程的参数直接传递给另一个这样的例程。Printf
的签名使用 ...interface{}
类型作为其最终参数,以指定在格式之后可以出现任意数量的参数(任意类型)。
func Printf(format string, v ...interface{}) (n int, err error) {
在 Printf
函数内部,v
的行为就像一个 []interface{}
类型的变量,但如果它被传递给另一个可变参数函数,它的行为就像一个常规的参数列表。这是我们上面使用的 log.Println
函数的实现。它将其参数直接传递给 fmt.Sprintln
进行实际格式化。
// Println prints to the standard logger in the manner of fmt.Println. func Println(v ...interface{}) { std.Output(2, fmt.Sprintln(v...)) // Output takes parameters (int, string) }
我们在嵌套调用 Sprintln
中的 v
之后写入 ...
,以告知编译器将 v
视为一个参数列表;否则它将仅把 v
作为单个切片参数传递。
打印方面还有更多内容,我们这里只是进行了概述。有关详细信息,请参阅 fmt
包的 godoc
文档。
顺便提一下,...
参数可以是特定类型,例如 ...int
用于选择整数列表中最小值的最小函数
func Min(a ...int) int { min := int(^uint(0) >> 1) // largest int for _, i := range a { if i < min { min = i } } return min }
Append
现在我们有了解释 append
内置函数设计所需的缺失部分。append
的签名与我们上面自定义的 Append
函数不同。从概念上讲,它像这样
func append(slice []T, elements ...T) []T
其中 T 是任何给定类型的占位符。你实际上无法在 Go 中编写一个由调用者确定类型 T 的函数。这就是为什么 append
是内置的:它需要编译器的支持。
append
所做的是将元素附加到切片的末尾并返回结果。结果需要返回,因为与我们手动编写的 Append
一样,底层数组可能会改变。这个简单的例子
x := []int{1,2,3} x = append(x, 4, 5, 6) fmt.Println(x)
打印 [1 2 3 4 5 6]
。因此 append
的工作方式有点像 Printf
,收集任意数量的参数。
但是,如果我们要像我们的 Append
函数那样,将一个切片附加到另一个切片呢?很简单:在调用处使用 ...
,就像我们在上面调用 Output
时所做的那样。这个代码片段生成的输出与上面的输出相同。
x := []int{1,2,3} y := []int{4,5,6} x = append(x, y...) fmt.Println(x)
如果没有那个 ...
,它将无法编译,因为类型会错误;y
不是 int
类型。
初始化
尽管 Go 中的初始化在表面上与 C 或 C++ 中的初始化看起来没有太大区别,但它更强大。可以在初始化期间构建复杂的结构,并且正确处理了已初始化对象之间的排序问题,甚至不同包之间的问题。
常量
Go 中的常量就是常量。它们在编译时创建,即使在函数中定义为局部变量,并且只能是数字、字符(rune)、字符串或布尔值。由于编译时限制,定义它们的表达式必须是常量表达式,可由编译器求值。例如,1<<3
是一个常量表达式,而 math.Sin(math.Pi/4)
不是,因为 math.Sin
的函数调用需要在运行时发生。
在 Go 中,枚举常量使用 iota
枚举器创建。由于 iota
可以是表达式的一部分,并且表达式可以隐式重复,因此很容易构建复杂的数值集合。
type ByteSize float64
const (
_ = iota // ignore first value by assigning to blank identifier
KB ByteSize = 1 << (10 * iota)
MB
GB
TB
PB
EB
ZB
YB
)
将 String
之类的方法附加到任何用户定义类型的能力,使得任意值能够自动格式化自身以供打印。尽管你最常看到它应用于结构体,但这种技术对于标量类型(例如像 ByteSize
这样的浮点类型)也很有用。
func (b ByteSize) String() string { switch { case b >= YB: return fmt.Sprintf("%.2fYB", b/YB) case b >= ZB: return fmt.Sprintf("%.2fZB", b/ZB) case b >= EB: return fmt.Sprintf("%.2fEB", b/EB) case b >= PB: return fmt.Sprintf("%.2fPB", b/PB) case b >= TB: return fmt.Sprintf("%.2fTB", b/TB) case b >= GB: return fmt.Sprintf("%.2fGB", b/GB) case b >= MB: return fmt.Sprintf("%.2fMB", b/MB) case b >= KB: return fmt.Sprintf("%.2fKB", b/KB) } return fmt.Sprintf("%.2fB", b) }
表达式 YB
打印为 1.00YB
,而 ByteSize(1e13)
打印为 9.09TB
。
这里使用 Sprintf
来实现 ByteSize
的 String
方法是安全的(避免无限递归),不是因为类型转换,而是因为它使用 %f
调用 Sprintf
,而 %f
不是字符串格式:Sprintf
只会在需要字符串时调用 String
方法,而 %f
需要一个浮点值。
变量
变量可以像常量一样初始化,但初始化器可以是运行时计算的通用表达式。
var ( home = os.Getenv("HOME") user = os.Getenv("USER") gopath = os.Getenv("GOPATH") )
init 函数
最后,每个源文件都可以定义自己的无参数 init
函数来设置所需的任何状态。(实际上,每个文件可以有多个 init
函数。)“最后”确实意味着最后:init
在包中的所有变量声明都已评估其初始化器之后才被调用,而这些初始化器只有在所有导入的包都被初始化之后才会被评估。
除了无法用声明表示的初始化之外,init
函数的常见用途是在实际执行开始之前验证或修复程序状态的正确性。
func init() { if user == "" { log.Fatal("$USER not set") } if home == "" { home = "/home/" + user } if gopath == "" { gopath = home + "/go" } // gopath may be overridden by --gopath flag on command line. flag.StringVar(&gopath, "gopath", gopath, "override default GOPATH") }
方法
指针与值
正如我们在 ByteSize
中看到的那样,方法可以为任何命名类型定义(除了指针或接口);接收者不必是结构体。
在上面对切片的讨论中,我们编写了一个 Append
函数。我们可以将其定义为切片的方法。为此,我们首先声明一个我们可以绑定方法的命名类型,然后将该方法的接收者设为该类型的值。
type ByteSlice []byte func (slice ByteSlice) Append(data []byte) []byte { // Body exactly the same as the Append function defined above. }
这仍然要求方法返回更新后的切片。我们可以通过重新定义方法,使其接收者为 ByteSlice
的指针来消除这种笨拙,这样方法就可以覆盖调用者的切片。
func (p *ByteSlice) Append(data []byte) { slice := *p // Body as above, without the return. *p = slice }
事实上,我们可以做得更好。如果我们修改函数,使其看起来像一个标准的 Write
方法,像这样,
func (p *ByteSlice) Write(data []byte) (n int, err error) { slice := *p // Again as above. *p = slice return len(data), nil }
那么 *ByteSlice
类型满足标准接口 io.Writer
,这很方便。例如,我们可以打印到其中。
var b ByteSlice fmt.Fprintf(&b, "This hour has %d days\n", 7)
我们传递 ByteSlice
的地址,因为只有 *ByteSlice
满足 io.Writer
。关于接收者是使用指针还是值的规则是:值方法可以在指针和值上调用,但指针方法只能在指针上调用。
这条规则之所以产生,是因为指针方法可以修改接收者;如果在值上调用它们,方法将接收到值的副本,因此任何修改都将被丢弃。因此,语言禁止这种错误。然而,有一个方便的例外。当值是可寻址的时,语言会通过自动插入地址运算符来处理在值上调用指针方法的常见情况。在我们的示例中,变量 b
是可寻址的,因此我们只需用 b.Write
即可调用其 Write
方法。编译器会将其重写为 (&b).Write
。
顺便说一下,在字节切片上使用 Write
的想法是 bytes.Buffer
实现的核心。
接口及其他类型
接口
Go 中的接口提供了一种指定对象行为的方式:如果某个东西能做这件事,那么它就可以在这里使用。我们已经看到了一些简单的例子;自定义打印机可以通过 String
方法实现,而 Fprintf
可以向任何具有 Write
方法的对象生成输出。在 Go 代码中,只有一两个方法的接口很常见,并且通常以方法名加上派生词命名,例如实现 Write
的对象称为 io.Writer
。
一个类型可以实现多个接口。例如,如果一个集合实现了 sort.Interface
(包含 Len()
、Less(i, j int) bool
和 Swap(i, j int)
),那么它可以被 sort
包中的例程排序,并且它还可以有一个自定义格式化器。在这个虚构的例子中,Sequence
同时满足这两个条件。
type Sequence []int // Methods required by sort.Interface. func (s Sequence) Len() int { return len(s) } func (s Sequence) Less(i, j int) bool { return s[i] < s[j] } func (s Sequence) Swap(i, j int) { s[i], s[j] = s[j], s[i] } // Copy returns a copy of the Sequence. func (s Sequence) Copy() Sequence { copy := make(Sequence, 0, len(s)) return append(copy, s...) } // Method for printing - sorts the elements before printing. func (s Sequence) String() string { s = s.Copy() // Make a copy; don't overwrite argument. sort.Sort(s) str := "[" for i, elem := range s { // Loop is O(N²); will fix that in next example. if i > 0 { str += " " } str += fmt.Sprint(elem) } return str + "]" }
类型转换
Sequence
的 String
方法正在重复 Sprint
已经为切片所做的工作。(它的复杂度也是 O(N²),这很糟糕。)如果我们在调用 Sprint
之前将 Sequence
转换为普通的 []int
,我们可以分担工作(并加快速度)。
func (s Sequence) String() string { s = s.Copy() sort.Sort(s) return fmt.Sprint([]int(s)) }
这种方法是从 String
方法安全调用 Sprintf
的转换技术的另一个例子。因为如果忽略类型名称,两种类型(Sequence
和 []int
)是相同的,所以在它们之间进行转换是合法的。这种转换不会创建新值,它只是临时表现得好像现有值具有新类型。(还有其他合法的转换,例如从整数到浮点数,确实会创建新值。)
在 Go 程序中,将表达式的类型进行转换以访问不同的方法集是一种惯用法。例如,我们可以使用现有的类型 sort.IntSlice
将整个例子简化为:
type Sequence []int // 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
实现多个接口(排序和打印),而是利用数据项可以转换为多种类型(Sequence
、sort.IntSlice
和 []int
)的能力,每种类型都完成部分工作。这在实践中更不常见,但可以很有效。
接口转换和类型断言
类型开关是一种转换形式:它们接受一个接口,并在开关的每个 case 中,以某种方式将其转换为该 case 的类型。这是 fmt.Printf
下的代码如何使用类型开关将值转换为字符串的简化版本。如果它已经是字符串,我们想要接口持有的实际字符串值,而如果它有一个 String
方法,我们想要调用该方法的结果。
type Stringer interface { String() string } var value interface{} // Value provided by caller. switch str := value.(type) { case string: return str case Stringer: return str.String() }
第一个 case 找到一个具体值;第二个将接口转换为另一个接口。这样混合类型是完全可以的。
如果我们只关心一种类型怎么办?如果我们知道该值包含一个 string
并且我们只想提取它怎么办?一个单 case 的类型开关可以做到,但是类型断言也可以做到。类型断言接受一个接口值,并从中提取指定显式类型的值。语法借鉴了类型开关的子句开头,但是使用显式类型而不是 type
关键字
value.(typeName)
结果是一个具有静态类型 typeName
的新值。该类型必须是接口持有的具体类型,或者是该值可以转换为的第二个接口类型。要提取我们知道值中存在的字符串,我们可以这样写
str := value.(string)
但是,如果事实证明该值不包含字符串,程序将因运行时错误而崩溃。为了防止这种情况,请使用“逗号,ok”惯用法进行测试,安全地检查该值是否为字符串
str, ok := value.(string) if ok { fmt.Printf("string value is: %q\n", str) } else { fmt.Printf("value is not a string\n") }
如果类型断言失败,str
仍然存在并且类型为字符串,但它将具有零值,即一个空字符串。
作为能力的一个例证,这是一个 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
。在 Go 程序中用 Adler-32 替换 CRC-32 算法只需要更改构造函数调用;代码的其余部分不受算法更改的影响。
类似的方法允许 crypto
包中的流密码算法与它们链接在一起的块密码分离。crypto/cipher
包中的 Block
接口指定了块密码的行为,它提供对单个数据块的加密。然后,类似于 bufio
包,实现此接口的密码包可以用于构造流密码,由 Stream
接口表示,而无需了解块加密的细节。
crypto/cipher
接口看起来像这样
type Block interface { BlockSize() int Encrypt(dst, src []byte) Decrypt(dst, src []byte) } type Stream interface { XORKeyStream(dst, src []byte) }
这是计数器模式(CTR)流的定义,它将块密码转换为流密码;请注意,块密码的细节被抽象掉了。
// NewCTR 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 请求。
type Handler interface { ServeHTTP(ResponseWriter, *Request) }
ResponseWriter
本身是一个接口,它提供访问将响应返回给客户端所需的方法。这些方法包括标准的 Write
方法,因此 http.ResponseWriter
可以在任何可以使用 io.Writer
的地方使用。Request
是一个结构体,其中包含来自客户端请求的解析表示。
为简洁起见,我们忽略 POST 请求,并假设 HTTP 请求始终是 GET 请求;这种简化不影响处理程序的设置方式。这是一个用于统计页面访问次数的简单处理程序实现。
// Simple counter server. type Counter struct { n int } func (ctr *Counter) ServeHTTP(w http.ResponseWriter, req *http.Request) { ctr.n++ fmt.Fprintf(w, "counter = %d\n", ctr.n) }
(为了与我们的主题保持一致,请注意 Fprintf
如何向 http.ResponseWriter
打印。)在实际的服务器中,对 ctr.n
的访问需要防止并发访问。有关建议,请参阅 sync
和 atomic
包。
作为参考,以下是如何将此类服务器连接到 URL 树上的节点。
import "net/http" ... ctr := new(Counter) http.Handle("/counter", ctr)
但为什么要让 Counter
成为一个结构体呢?一个整数就足够了。(接收者需要是一个指针,这样增量对调用者可见。)
// Simpler counter server. type Counter int func (ctr *Counter) ServeHTTP(w http.ResponseWriter, req *http.Request) { *ctr++ fmt.Fprintf(w, "counter = %d\n", *ctr) }
如果你的程序有一些内部状态需要通知某个页面已被访问,该怎么办?将一个通道绑定到网页上。
// A channel that sends a notification on each visit. // (Probably want the channel to be buffered.) type Chan chan *http.Request func (ch Chan) ServeHTTP(w http.ResponseWriter, req *http.Request) { ch <- req fmt.Fprint(w, "notification sent") }
最后,假设我们想在 /args
上显示调用服务器二进制文件时使用的参数。编写一个打印参数的函数很容易。
func ArgServer() { fmt.Println(os.Args) }
我们如何将其转换为 HTTP 服务器?我们可以将 ArgServer
设为某个类型的方法,忽略其值,但有更简洁的方法。由于我们可以为除指针和接口之外的任何类型定义方法,我们可以为函数编写方法。http
包包含以下代码
// The HandlerFunc type is an adapter to allow the use of // ordinary functions as HTTP handlers. If f is a function // with the appropriate signature, HandlerFunc(f) is a // Handler object that calls f. type HandlerFunc func(ResponseWriter, *Request) // ServeHTTP calls f(w, req). func (f HandlerFunc) ServeHTTP(w ResponseWriter, req *Request) { f(w, req) }
HandlerFunc
是一种带有方法 ServeHTTP
的类型,因此该类型的值可以处理 HTTP 请求。查看该方法的实现:接收者是一个函数 f
,方法调用 f
。这可能看起来很奇怪,但这与接收者是通道且方法通过通道发送数据并没有太大区别。
要将 ArgServer
变成一个 HTTP 服务器,我们首先修改它,使其具有正确的签名。
// Argument server. 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
(通过 HandlerFunc.ServeHTTP
内的 f(w, req)
调用)。然后将显示参数。
在本节中,我们从结构体、整数、通道和函数中构建了一个 HTTP 服务器,这都是因为接口只是方法集,可以为(几乎)任何类型定义。
空白标识符
我们已经提过几次空白标识符,在for
range
循环和映射的上下文中。空白标识符可以被赋值或声明为任何类型的值,其值被无害地丢弃。它有点像写入 Unix 的 /dev/null
文件:它表示一个只写值,用作需要变量但实际值无关紧要的占位符。它有超出我们已经看到的用途。
多重赋值中的空白标识符
在 for
range
循环中使用空白标识符是普遍情况的一个特殊情况:多重赋值。
如果赋值语句的左侧需要多个值,但其中一个值不会被程序使用,那么在赋值语句的左侧使用空白标识符可以避免创建虚拟变量的需要,并明确表示该值将被丢弃。例如,当调用一个返回值和错误但只关心错误时,可以使用空白标识符来丢弃无关的值。
if _, err := os.Stat(path); os.IsNotExist(err) { fmt.Printf("%s does not exist\n", path) }
偶尔你会看到一些代码丢弃错误值以忽略错误;这是一种糟糕的做法。务必检查错误返回值;它们是有原因的。
// Bad! This code will crash if path does not exist. fi, _ := os.Stat(path) if fi.IsDir() { fmt.Printf("%s is a directory\n", path) }
未使用的导入和变量
导入包或声明变量而不使用它是错误的。未使用的导入会使程序膨胀并减慢编译速度,而初始化但未使用的变量至少是一种浪费的计算,并且可能预示着一个更大的 bug。然而,在程序积极开发期间,未使用的导入和变量经常出现,为了让编译继续进行而删除它们,然后又在稍后再次需要它们,这可能会令人烦恼。空白标识符提供了一种解决方案。
这个半成品程序有两个未使用的导入(fmt
和 io
)和一个未使用的变量(fd
),所以它无法编译,但我们想知道目前为止的代码是否正确。
package main
import (
"fmt"
"io"
"log"
"os"
)
func main() {
fd, err := os.Open("test.go")
if err != nil {
log.Fatal(err)
}
// TODO: use fd.
}
为了消除关于未使用的导入的抱怨,使用空白标识符来引用导入包中的符号。类似地,将未使用的变量 fd
赋值给空白标识符将消除未使用的变量错误。这个版本的程序可以编译。
package main import ( "fmt" "io" "log" "os" ) var _ = fmt.Printf // For debugging; delete when done. var _ io.Reader // For debugging; delete when done. func main() { fd, err := os.Open("test.go") if err != nil { log.Fatal(err) } // TODO: use fd. _ = fd }
按照惯例,消除导入错误的全局声明应紧随导入之后并添加注释,这既是为了方便查找,也是为了提醒以后清理。
为了副作用而导入
上例中像 fmt
或 io
这样的未使用导入最终应该被使用或移除:空白赋值表明代码仍在开发中。但有时为了副作用而导入包,而没有任何显式使用,这很有用。例如,在 init
函数中,net/http/pprof
包注册 HTTP 处理程序以提供调试信息。它有一个导出的 API,但大多数客户端只需要处理程序注册并通过网页访问数据。为了仅为了副作用而导入包,将包重命名为空白标识符
import _ "net/http/pprof"
这种导入形式清楚地表明该包是为了其副作用而导入的,因为该包没有其他可能的用途:在此文件中,它没有名称。(如果它有名称,而我们没有使用该名称,编译器将拒绝该程序。)
接口检查
正如我们在上面关于接口的讨论中看到的,类型不需要明确声明它实现了接口。相反,类型只需实现接口的方法即可实现接口。实际上,大多数接口转换都是静态的,因此在编译时进行检查。例如,将 *os.File
传递给期望 io.Reader
的函数将无法编译,除非 *os.File
实现了 io.Reader
接口。
然而,有些接口检查确实在运行时发生。一个例子是在 encoding/json
包中,它定义了 Marshaler
接口。当 JSON 编码器接收到一个实现该接口的值时,编码器会调用该值的 marshaling 方法将其转换为 JSON,而不是执行标准转换。编码器在运行时通过类型断言检查此属性,例如
m, ok := val.(json.Marshaler)
如果只需要询问一个类型是否实现了接口,而实际上不使用接口本身(可能是作为错误检查的一部分),请使用空白标识符来忽略类型断言的值
if _, ok := val.(json.Marshaler); ok { fmt.Printf("value %v of type %T implements json.Marshaler\n", val, val) }
这种情况下出现的一种情况是,需要在实现该类型的包中保证它确实满足接口。如果一个类型——例如 json.RawMessage
——需要自定义 JSON 表示,它应该实现 json.Marshaler
,但是没有静态转换会导致编译器自动验证这一点。如果该类型无意中未能满足接口,JSON 编码器仍然会工作,但不会使用自定义实现。为了保证实现是正确的,可以在包中使用空白标识符进行全局声明
var _ json.Marshaler = (*RawMessage)(nil)
在此声明中,涉及将 *RawMessage
转换为 Marshaler
的赋值要求 *RawMessage
实现 Marshaler
,并且此属性将在编译时进行检查。如果 json.Marshaler
接口发生变化,此包将不再编译,我们将收到通知,需要对其进行更新。
在此构造中空白标识符的出现表明该声明仅用于类型检查,而不是创建变量。但是,不要对每个满足接口的类型都这样做。按照惯例,此类声明仅在代码中不存在静态转换时使用,这种情况很少见。
嵌入
Go 不提供典型的、类型驱动的子类概念,但它确实能够通过在结构体或接口中嵌入类型来“借用”实现片段。
接口嵌入非常简单。我们之前已经提到过 io.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 is the interface that combines the Reader and Writer interfaces. type ReadWriter interface { Reader Writer }
这正说明了它的意思:一个 ReadWriter
既可以做 Reader
所做的事情,也可以做 Writer
所做的事情;它是嵌入接口的并集。只有接口可以嵌入接口。
同样的基本思想也适用于结构体,但影响更为深远。bufio
包有两个结构体类型,bufio.Reader
和 bufio.Writer
,每个都实现了 io
包中类似的接口。bufio
还实现了一个缓冲读/写器,它通过使用嵌入将读/写器组合成一个结构体来实现:它列出结构体中的类型,但不给它们字段名称。
// ReadWriter stores pointers to a Reader and a Writer. // It implements io.ReadWriter. type ReadWriter struct { *Reader // *bufio.Reader *Writer // *bufio.Writer }
嵌入的元素是指向结构体的指针,在使用之前当然必须初始化以指向有效的结构体。ReadWriter
结构体可以写成
type ReadWriter struct { reader *Reader writer *Writer }
但随后为了提升字段的方法并满足 io
接口,我们还需要提供转发方法,如下所示
func (rw *ReadWriter) Read(p []byte) (n int, err error) { return rw.reader.Read(p) }
通过直接嵌入结构体,我们避免了这种簿记工作。嵌入类型的方法是免费提供的,这意味着 bufio.ReadWriter
不仅拥有 bufio.Reader
和 bufio.Writer
的方法,它还满足所有三个接口:io.Reader
、io.Writer
和 io.ReadWriter
。
嵌入与子类化有一个重要的区别。当我们嵌入一个类型时,该类型的方法成为外部类型的方法,但当它们被调用时,方法的接收者是内部类型,而不是外部类型。在我们的例子中,当调用 bufio.ReadWriter
的 Read
方法时,它的效果与上面写出的转发方法完全相同;接收者是 ReadWriter
的 reader
字段,而不是 ReadWriter
本身。
嵌入也可以仅仅是一种方便。这个例子展示了一个嵌入字段与一个常规的、命名字段并存的情况。
type Job struct { Command string *log.Logger }
Job
类型现在拥有 *log.Logger
的 Print
、Printf
、Println
和其他方法。当然,我们可以给 Logger
一个字段名,但没必要这样做。现在,一旦初始化,我们就可以记录到 Job
中
job.Println("starting now...")
Logger
是 Job
结构体的一个常规字段,所以我们可以像这样在 Job
的构造函数中以通常的方式初始化它:
func NewJob(command string, logger *log.Logger) *Job { return &Job{command, logger} }
或者使用复合字面量,
job := &Job{command, log.New(os.Stderr, "Job: ", log.Ldate)}
如果我们需要直接引用一个嵌入字段,该字段的类型名(忽略包限定符)就作为字段名,就像我们在 ReadWriter
结构体的 Read
方法中所做的那样。在这里,如果我们需要访问 Job
变量 job
的 *log.Logger
,我们会写 job.Logger
,如果我们要改进 Logger
的方法,这将很有用。
func (job *Job) Printf(format string, args ...interface{}) { job.Logger.Printf("%q: %s", job.Command, fmt.Sprintf(format, args...)) }
嵌入类型会引入名称冲突问题,但解决冲突的规则很简单。首先,字段或方法 X
会隐藏类型中更深层嵌套的任何其他项 X
。如果 log.Logger
包含一个名为 Command
的字段或方法,那么 Job
的 Command
字段将优先。
其次,如果同一名称出现在同一嵌套级别,通常是一个错误;如果 Job
结构体包含另一个名为 Logger
的字段或方法,则嵌入 log.Logger
将是错误的。但是,如果重复的名称在类型定义之外的程序中从未被提及,那么它是允许的。这种限定为防止外部嵌入类型发生更改提供了一些保护;如果添加了一个与另一个子类型中的另一个字段冲突的字段,并且这两个字段都从未使用过,则没有问题。
并发
通过通信共享
并发编程是一个很大的主题,这里只介绍一些 Go 特有的亮点。
在许多环境中,并发编程之所以困难,是因为实现对共享变量的正确访问需要精妙的技巧。Go 鼓励一种不同的方法,即共享值在通道上传递,并且实际上从不被独立的执行线程主动共享。在任何给定时间,只有一个 Goroutine 可以访问该值。按设计,数据竞争不会发生。为了鼓励这种思维方式,我们将其总结为一句口号:
不要通过共享内存来通信;相反,通过通信来共享内存。
这种方法可能有点过头了。例如,引用计数最好通过在整数变量周围放置一个互斥锁来完成。但是作为一种高级方法,使用通道来控制访问可以更容易地编写清晰、正确的程序。
思考这个模型的一种方法是考虑一个在单个 CPU 上运行的典型单线程程序。它不需要同步原语。现在运行另一个这样的实例;它也不需要同步。现在让这两个实例进行通信;如果通信是同步器,那么仍然不需要其他同步。例如,Unix 管道完美地符合这个模型。尽管 Go 的并发方法起源于 Hoare 的通信顺序进程(CSP),但它也可以被看作是 Unix 管道的一种类型安全泛化。
Goroutines
它们被称为 *goroutines*,因为现有术语——线程、协程、进程等等——传达了不准确的含义。Goroutine 有一个简单的模型:它是一个在同一地址空间中与其他 goroutine 并发执行的函数。它是轻量级的,开销仅仅是分配栈空间。并且栈一开始很小,所以很便宜,并根据需要通过分配(和释放)堆存储来增长。
Goroutines 被多路复用到多个操作系统线程上,因此如果其中一个阻塞(例如等待 I/O),其他 goroutine 将继续运行。它们的设计隐藏了线程创建和管理的许多复杂性。
在函数或方法调用前加上 go
关键字,即可在新的 goroutine 中运行该调用。当调用完成时,该 goroutine 会默默地退出。(效果类似于 Unix shell 中用于在后台运行命令的 &
符号。)
go list.Sort() // run list.Sort concurrently; don't wait for it.
函数字面量在 goroutine 调用中非常方便。
func Announce(message string, delay time.Duration) { go func() { time.Sleep(delay) fmt.Println(message) }() // Note the parentheses - must call the function. }
在 Go 中,函数字面量是闭包:其实现确保函数引用的变量在其活动期间持续存在。
这些示例不太实用,因为函数无法发出完成信号。为此,我们需要通道。
通道
与映射一样,通道也是使用 make
分配的,生成的值作为底层数据结构的引用。如果提供了可选的整数参数,它将设置通道的缓冲区大小。默认值为零,表示无缓冲或同步通道。
ci := make(chan int) // unbuffered channel of integers cj := make(chan int, 0) // unbuffered channel of integers cs := make(chan *os.File, 100) // buffered channel of pointers to Files
无缓冲通道将通信(值的交换)与同步(保证两个计算(goroutine)处于已知状态)结合起来。
通道有很多不错的用法。这里有一个入门示例。在上一节中,我们在后台启动了一个排序。通道可以允许启动 goroutine 等待排序完成。
c := make(chan int) // Allocate a channel. // Start the sort in a goroutine; when it completes, signal on the channel. go func() { list.Sort() c <- 1 // Send a signal; value does not matter. }() doSomethingForAWhile() <-c // Wait for sort to finish; discard sent value.
接收者总是阻塞,直到有数据可接收。如果通道是无缓冲的,发送者会阻塞,直到接收者收到值。如果通道有缓冲区,发送者只会阻塞,直到值被复制到缓冲区;如果缓冲区已满,这意味着要等待直到某个接收者取回一个值。
带缓冲的通道可以像信号量一样使用,例如限制吞吐量。在这个例子中,传入的请求被传递给 handle
,它将一个值发送到通道中,处理请求,然后从通道中接收一个值,为下一个消费者准备好“信号量”。通道缓冲区的容量限制了对 process
的并发调用数量。
var sem = make(chan int, MaxOutstanding) func handle(r *Request) { sem <- 1 // Wait for active queue to drain. process(r) // May take a long time. <-sem // Done; enable next request to run. } func Serve(queue chan *Request) { for { req := <-queue go handle(req) // Don't wait for handle to finish. } }
一旦 MaxOutstanding
个处理程序正在执行 process
,任何更多的请求都会在尝试发送到已满的通道缓冲区时被阻塞,直到其中一个现有处理程序完成并从缓冲区接收到数据。
然而,这种设计有一个问题:Serve
为每个传入请求创建一个新的 goroutine,尽管在任何时刻只有 MaxOutstanding
个 goroutine 可以运行。结果是,如果请求来得太快,程序可能会消耗无限资源。我们可以通过修改 Serve
来限制 goroutine 的创建,从而解决这个缺陷
func Serve(queue chan *Request) { for req := range queue { sem <- 1 go func() { process(req) <-sem }() } }
(请注意,在 Go 1.22 之前的版本中,这段代码存在一个错误:循环变量在所有 goroutine 中共享。详情请参阅 Go wiki。)
另一种管理资源的有效方法是启动固定数量的 handle
goroutine,它们都从请求通道读取。goroutine 的数量限制了对 process
的同时调用次数。这个 Serve
函数也接受一个通道,它将通过该通道被告知退出;在启动 goroutine 后,它会阻塞并从该通道接收。
func handle(queue chan *Request) { for r := range queue { process(r) } } func Serve(clientRequests chan *Request, quit chan bool) { // Start handlers for i := 0; i < MaxOutstanding; i++ { go handle(clientRequests) } <-quit // Wait to be told to exit. }
通道的通道
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)} // Send request clientRequests <- request // Wait for response. fmt.Printf("answer: %d\n", <-request.resultChan)
在服务器端,唯一改变的是处理函数。
func handle(queue chan *Request) { for req := range queue { req.resultChan <- req.f(req.args) } }
显然还有很多工作要做才能使其变得真实,但这段代码是一个限速、并行、非阻塞 RPC 系统的框架,并且没有看到一个互斥锁。
并行化
这些想法的另一个应用是将计算并行化到多个 CPU 核心上。如果计算可以分解成可以独立执行的独立部分,它就可以并行化,使用一个通道来指示每个部分何时完成。
假设我们有一个对项目向量执行的昂贵操作,并且对每个项目操作的值是独立的,如这个理想化示例所示。
type Vector []float64 // Apply the operation to v[i], v[i+1] ... up to v[n-1]. func (v Vector) DoSome(i, n int, u Vector, c chan int) { for ; i < n; i++ { v[i] += u.Op(v[i]) } c <- 1 // signal that this piece is done }
我们循环独立地启动这些部分,每个 CPU 一个。它们可以以任何顺序完成,但这并不重要;我们只需在启动所有 goroutine 后通过排空通道来计算完成信号。
const numCPU = 4 // number of CPU cores func (v Vector) DoAll(u Vector) { c := make(chan int, numCPU) // Buffering optional but sensible. for i := 0; i < numCPU; i++ { go v.DoSome(i*len(v)/numCPU, (i+1)*len(v)/numCPU, u, c) } // Drain the channel. for i := 0; i < numCPU; i++ { <-c // wait for one task to complete } // All done. }
我们不必为 numCPU 创建一个常量值,我们可以询问运行时什么值是合适的。函数 runtime.NumCPU
返回机器中硬件 CPU 核心的数量,所以我们可以这样写:
var numCPU = runtime.NumCPU()
还有一个函数 runtime.GOMAXPROCS
,它报告(或设置)Go 程序可以同时运行的用户指定的核心数量。它默认为 runtime.NumCPU
的值,但可以通过设置同名的 shell 环境变量或以正数调用该函数来覆盖。以零调用它只会查询该值。因此,如果我们要尊重用户的资源请求,我们应该这样写:
var numCPU = runtime.GOMAXPROCS(0)
务必不要混淆并发(将程序构造为独立执行的组件)和并行(为了效率在多个 CPU 上并行执行计算)的概念。尽管 Go 的并发特性可以使一些问题很容易构造为并行计算,但 Go 是一种并发语言,而不是并行语言,并且并非所有并行化问题都符合 Go 的模型。有关区别的讨论,请参阅这篇博客文章中引用的演讲。
一个有漏的缓冲区
并发编程的工具甚至可以使非并发的概念更容易表达。这里有一个从 RPC 包中抽象出来的例子。客户端 goroutine 循环从某个源(可能是一个网络)接收数据。为了避免分配和释放缓冲区,它维护一个空闲列表,并使用一个带缓冲的通道来表示它。如果通道为空,则分配一个新的缓冲区。一旦消息缓冲区准备好,它就会通过 serverChan
发送给服务器。
var freeList = make(chan *Buffer, 100) var serverChan = make(chan *Buffer) func client() { for { var b *Buffer // Grab a buffer if available; allocate if not. select { case b = <-freeList: // Got one; nothing more to do. default: // None free, so allocate a new one. b = new(Buffer) } load(b) // Read next message from the net. serverChan <- b // Send to server. } }
服务器循环从客户端接收每条消息,处理它,然后将缓冲区返回到空闲列表。
func server() { for { b := <-serverChan // Wait for work. process(b) // Reuse buffer if there's room. select { case freeList <- b: // Buffer on free list; nothing more to do. default: // Free list full, just carry on. } } }
客户端尝试从 freeList
中检索一个缓冲区;如果没有可用的,它会分配一个新的。服务器发送到 freeList
会将 b
放回空闲列表,除非列表已满,在这种情况下缓冲区会被丢弃,由垃圾回收器回收。(select
语句中的 default
子句在没有其他情况就绪时执行,这意味着 selects
永远不会阻塞。)此实现仅用几行代码就构建了一个“漏桶”空闲列表,依靠带缓冲的通道和垃圾回收器进行簿记。
Errors
库例程通常必须向调用者返回某种错误指示。如前所述,Go 的多值返回使其能够轻松地在正常返回值旁边返回详细的错误描述。使用此功能提供详细错误信息是一种好习惯。例如,正如我们将看到的,os.Open
在失败时不仅仅返回一个 nil
指针,它还会返回一个描述出错原因的错误值。
按照惯例,错误具有 error
类型,这是一个简单的内置接口。
type error interface { Error() string }
库编写者可以自由地在底层使用更丰富的模型实现此接口,这不仅可以看到错误,还可以提供一些上下文。如前所述,除了通常的 *os.File
返回值外,os.Open
还返回一个错误值。如果文件成功打开,错误将为 nil
,但当出现问题时,它将包含一个 os.PathError
。
// PathError records an error and the operation and // file path that caused it. type PathError struct { Op string // "open", "unlink", etc. Path string // The associated file. Err error // Returned by the system call. } func (e *PathError) Error() string { return e.Op + " " + e.Path + ": " + e.Err.Error() }
PathError
的 Error
生成的字符串如下所示:
open /etc/passwx: no such file or directory
这种错误,包含了有问题的文件名、操作以及它触发的操作系统错误,即使在远离引起它的调用处打印也很有用;它比简单的“没有此类文件或目录”信息量大得多。
在可行的情况下,错误字符串应识别其来源,例如通过在错误生成的操作或包名称前加上前缀。例如,在 image
包中,由于未知格式导致的解码错误的字符串表示为 "image: unknown format"。
关心精确错误细节的调用者可以使用类型开关或类型断言来查找特定错误并提取细节。对于 PathErrors
,这可能包括检查内部的 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() // Recover some space. continue } return }
这里的第二个 if
语句是另一个类型断言。如果它失败,ok
将为 false,e
将为 nil
。如果它成功,ok
将为 true,这意味着错误是 *os.PathError
类型,那么 e
也是,我们可以检查它以获取有关错误的更多信息。
恐慌(Panic)
向调用者报告错误通常是通过将 error
作为额外的返回值返回。经典的 Read
方法就是一个众所周知的例子;它返回字节计数和一个 error
。但如果错误是不可恢复的呢?有时程序根本无法继续。
为此,有一个内置函数 panic
,它实际上创建了一个运行时错误,将停止程序(但请参阅下一节)。该函数接受一个任意类型的单个参数——通常是一个字符串——在程序终止时打印。这也是指示发生了不可能的事情的一种方式,例如退出一个无限循环。
// A toy implementation of cube root using Newton's method. func CubeRoot(x float64) float64 { z := x/3 // Arbitrary initial value for i := 0; i < 1e6; i++ { prevz := z z -= (z*z*z-x) / (3*z*z) if veryClose(z, prevz) { return z } } // A million iterations has not converged; something is wrong. panic(fmt.Sprintf("CubeRoot(%g) did not converge", x)) }
这只是一个例子,但实际的库函数应避免使用 panic
。如果问题可以被掩盖或解决,让事情继续运行总是比关闭整个程序更好。一个可能的反例是初始化期间:如果库确实无法设置自身,那么“恐慌”可能是合理的。
var user = os.Getenv("USER") func init() { if user == "" { panic("no value for $USER") } }
恢复(Recover)
当调用 panic
时,包括由于运行时错误(例如切片索引越界或类型断言失败)而隐式调用时,它会立即停止当前函数的执行,并开始解开 goroutine 的栈,沿途运行任何延迟函数。如果解开过程到达 goroutine 栈的顶部,程序就会终止。但是,可以使用内置函数 recover
来重新获得 goroutine 的控制权并恢复正常执行。
调用 recover
会停止栈展开,并返回传递给 panic
的参数。因为在栈展开期间唯一运行的代码在延迟函数内部,所以 recover
只在延迟函数内部有用。
recover
的一个应用是在服务器内部关闭一个失败的 goroutine,而不杀死其他正在执行的 goroutine。
func server(workChan <-chan *Work) { for work := range workChan { go safelyDo(work) } } func safelyDo(work *Work) { defer func() { if err := recover(); err != nil { log.Println("work failed:", err) } }() do(work) }
在这个例子中,如果 do(work)
发生 panic,结果将被记录,并且 goroutine 将干净地退出,而不会干扰其他 goroutine。在延迟闭包中无需做其他任何事情;调用 recover
可以完全处理这种情况。
因为 recover
除非直接从延迟函数调用,否则总是返回 nil
,所以延迟代码可以调用那些本身使用 panic
和 recover
的库例程而不会失败。例如,safelyDo
中的延迟函数可以在调用 recover
之前调用日志函数,并且该日志代码将在不受 panic 状态影响的情况下运行。
有了我们的恢复模式,do
函数(以及它调用的任何东西)可以通过调用 panic
来干净地摆脱任何糟糕的情况。我们可以利用这个想法来简化复杂软件中的错误处理。让我们看看一个理想化的 regexp
包版本,它通过使用本地错误类型调用 panic
来报告解析错误。以下是 Error
的定义,一个 error
方法,以及 Compile
函数。
// Error is the type of a parse error; it satisfies the error interface. type Error string func (e Error) Error() string { return string(e) } // error is a method of *Regexp that reports parsing errors by // panicking with an Error. func (regexp *Regexp) error(err string) { panic(Error(err)) } // Compile returns a parsed representation of the regular expression. func Compile(str string) (regexp *Regexp, err error) { regexp = new(Regexp) // doParse will panic if there is a parse error. defer func() { if e := recover(); e != nil { regexp = nil // Clear return value. err = e.(Error) // Will re-panic if not a parse error. } }() return regexp.doParse(str), nil }
如果 doParse
发生 panic,恢复块会将返回值设置为 nil
——延迟函数可以修改命名返回值。然后它会在对 err
的赋值中检查问题是否是解析错误,通过断言它具有本地类型 Error
。如果不是,类型断言将失败,导致运行时错误,继续栈展开,就像没有任何中断一样。这个检查意味着,如果发生意想不到的事情,例如索引越界,即使我们使用 panic
和 recover
来处理解析错误,代码也会失败。
有了错误处理,error
方法(因为它是一个绑定到类型的方法,所以它与内置的 error
类型同名是很好的,甚至是自然的)使得报告解析错误变得容易,而无需手动处理解析栈的展开。
if pos == 0 { re.error("'*' illegal at start of expression") }
尽管这种模式很有用,但它应该只在包内部使用。Parse
将其内部的 panic
调用转换为 error
值;它不会将 panic
暴露给其客户端。这是一个很好的遵循规则。
顺便说一下,如果发生实际错误,这种重新恐慌(re-panic)的惯用法会改变恐慌值。然而,原始的和新的故障都将呈现在崩溃报告中,因此问题的根本原因仍然可见。因此,这种简单的重新恐慌方法通常是足够的——毕竟这是一次崩溃——但如果你只想显示原始值,你可以编写更多的代码来过滤意外问题并用原始错误重新恐慌。这留作读者的练习。
一个 Web 服务器
最后,我们来看一个完整的 Go 程序——一个 Web 服务器。这个服务器实际上是一种 Web 中继服务器。Google 在 chart.apis.google.com
提供了一项服务,可以将数据自动格式化为图表。然而,交互式使用它很困难,因为你需要将数据作为查询放入 URL。这里的程序为一种数据形式提供了一个更好的接口:给定一小段文本,它调用图表服务器生成一个二维码,一个编码文本的方块矩阵。这个图像可以通过你的手机摄像头捕捉并解释为,例如,一个 URL,从而省去你在手机小键盘上输入 URL 的麻烦。
这是完整的程序。解释如下。
package main
import (
"flag"
"html/template"
"log"
"net/http"
)
var addr = flag.String("addr", ":1718", "http service address") // Q=17, R=18
var templ = template.Must(template.New("qr").Parse(templateStr))
func main() {
flag.Parse()
http.Handle("/", http.HandlerFunc(QR))
err := http.ListenAndServe(*addr, nil)
if err != nil {
log.Fatal("ListenAndServe:", err)
}
}
func QR(w http.ResponseWriter, req *http.Request) {
templ.Execute(w, req.FormValue("s"))
}
const templateStr = `
<html>
<head>
<title>QR Link Generator</title>
</head>
<body>
{{if .}}
<img src="http://chart.apis.google.com/chart?chs=300x300&cht=qr&choe=UTF-8&chl={{.}}" />
<br>
{{.}}
<br>
<br>
{{end}}
<form action="/" name=f method="GET">
<input maxLength=1024 size=70 name=s value="" title="Text to QR Encode">
<input type=submit value="Show QR" name=qr>
</form>
</body>
</html>
`
直到 main
函数的部分应该很容易理解。那个标志为我们的服务器设置了一个默认的 HTTP 端口。模板变量 templ
是有趣的部分。它构建了一个 HTML 模板,服务器将执行该模板来显示页面;稍后会详细介绍。
main
函数解析标志,并使用我们上面讨论的机制,将 QR
函数绑定到服务器的根路径。然后调用 http.ListenAndServe
来启动服务器;它在服务器运行期间会阻塞。
QR
只是接收包含表单数据的请求,并根据表单值中名为 s
的数据执行模板。
html/template
模板包功能强大;这个程序只涉及其功能的一小部分。本质上,它通过替换从传递给 templ.Execute
的数据项(在本例中是表单值)派生的元素,即时重写一段 HTML 文本。在模板文本(templateStr
)中,双大括号分隔的部分表示模板操作。从 {{if .}}
到 {{end}}
的部分仅当当前数据项(称为 .
(点))的值非空时执行。也就是说,当字符串为空时,模板的这部分会被抑制。
两个片段 {{.}}
表示在网页上显示呈现给模板的数据——查询字符串。HTML 模板包会自动提供适当的转义,以便文本可以安全显示。
模板字符串的其余部分只是页面加载时要显示的 HTML。如果这个解释太快,请参阅模板包的文档以获取更详细的讨论。
就这样:一个有用的 Web 服务器,只需几行代码加上一些数据驱动的 HTML 文本。Go 的强大足以让很多事情在几行代码中实现。