Go 语言实战
简介
Go 是一种新的语言。虽然它借鉴了现有语言的思想,但它拥有非凡的特性,使得有效的 Go 程序与用其相关语言编写的程序在本质上有所不同。直接将 C++ 或 Java 程序翻译成 Go 语言不太可能得到令人满意的结果——Java 程序是用 Java 编写的,而不是 Go。另一方面,从 Go 的角度思考问题可能会产生一个成功的但完全不同的程序。换句话说,要写好 Go 代码,重要的是要理解它的特性和习惯用法。同样重要的是要了解 Go 编程中已有的惯例,例如命名、格式化、程序构建等等,这样你编写的程序就更容易被其他 Go 程序员理解。
本文档提供了编写清晰、符合 Go 语言习惯的代码的技巧。它补充了 语言规范、Go 语言之旅 和 如何编写 Go 代码,你应该先阅读所有这些内容。
补充说明,2022 年 1 月:本文档是为 2009 年 Go 的发布而编写的,从那时起就没有进行过重大更新。虽然它是理解如何使用该语言本身的一个很好的指南,但由于该语言的稳定性,它对库几乎没有说明,并且对自编写以来 Go 生态系统发生的重大变化(例如构建系统、测试、模块和多态性)没有任何说明。目前没有计划对其进行更新,因为发生了如此多的变化,而且越来越多的文档、博客和书籍都很好地描述了现代 Go 的用法。Go 语言实战仍然很有用,但读者应该明白它远远不是一个完整的指南。有关更多信息,请参见 问题 28782。
示例
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 没有行长限制。不要担心溢出打孔卡。如果一行感觉太长,请换行并使用额外的制表符缩进。
- 圆括号
- 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)
来得到改善。较长的名称不会自动使内容更具可读性。一个有帮助的文档注释往往比一个额外长的名称更有价值。
获取器
Go 不提供对获取器和设置器的自动支持。自己提供获取器和设置器没有错,而且这样做通常是合适的,但将 Get
放入获取器的名称中既不符合 Go 语言习惯也不必要。如果您有一个名为 owner
(小写,未导出)的字段,那么获取器方法应该称为 Owner
(大写,导出),而不是 GetOwner
。使用大写字母命名导出提供了将字段与方法区分开的依据。如果需要,设置器函数可能称为 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 类似,但有一些重要的区别。没有 `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` 循环。它将 `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 }
如果你只需要范围中的第一个元素(键或索引),则省略第二个元素
for key := range m { if key.expired() { delete(m, key) } }
如果你只需要范围中的第二个元素(值),则使用空白标识符(下划线)来丢弃第一个元素
sum := 0 for _, value := range array { sum += value }
空白标识符有很多用途,如后面部分所述。
对于字符串,`range` 会为你做更多工作,通过解析 UTF-8,将单个 Unicode 代码点分解出来。错误的编码会消耗一个字节并生成替换字符 U+FFFD。(`rune` 这个名称(以及相关的内置类型)是 Go 中对单个 Unicode 代码点的术语。有关详细信息,请参见语言规范。)循环
for pos, char := range "日本\x80語" { // \x80 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 的 `switch` 更通用。表达式不必是常量,甚至不必是整数,case 从上到下依次进行评估,直到找到匹配的 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 中它们不像其他一些类似 C 语言的语言那样常见,但 `break` 语句可以用来提前终止 `switch`。不过,有时需要退出周围的循环,而不是 `switch`,在 Go 中,可以通过在循环上添加一个标签并“中断”到该标签来实现。此示例显示了这两种用法。
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 中都会具有相应的类型。在这些情况下,重复使用名称也是一种惯例,实际上是在每个 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 程序中的一些笨拙的习惯用法:带内错误返回,例如,`-1` 表示 `EOF`,以及修改按地址传递的参数。
在 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) {
由于命名结果被初始化并与未修饰的返回语句相关联,因此它们可以简化代码,也可以使代码更清晰。这是一个使用得当的 `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` 之类的函数有两个优点。首先,它保证你永远不会忘记关闭文件,如果你稍后编辑函数以添加新的返回路径,这是一个很容易犯的错误。其次,这意味着关闭操作紧挨着打开操作,这比将关闭操作放在函数末尾要清楚得多。
延迟函数的参数(如果函数是方法,则包括接收器)是在延迟执行时评估的,而不是在调用执行时评估的。除了避免担心变量在函数执行过程中改变值之外,这也意味着一个延迟调用站点可以延迟多个函数执行。以下是一个愚蠢的例子。
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 个整数的数组,然后创建一个长度为 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` 将为真;如果不存在,`seconds` 将被设置为 0,并且 `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") // 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}
(注意与号。)该引用的字符串格式也可以通过 %q
在应用于 string
或 []byte
类型的值时使用。替代格式 %#q
尽可能使用反引号。(%q
格式也适用于整数和字符,生成带单引号的字符常量。)此外,%x
可用于字符串、字节数组和字节切片以及整数,生成一个长十六进制字符串,如果在格式中添加空格(% x
),它会在字节之间添加空格。
另一个方便的格式是 %T
,它打印值的类型。
fmt.Printf("%T\n", timeZone)
打印
map[string]int
如果您想控制自定义类型的默认格式,只需在该类型上定义一个具有 String() string
签名的函数即可。对于我们的简单类型 T
,这可能看起来像这样。
func (t *T) String() string { return fmt.Sprintf("%d/%g/%q", t.a, t.b, t.c) } fmt.Printf("%v\n", t)
以以下格式打印
7/-2.35/"abc\tdef"
(如果您需要打印类型为 T
的值以及指向 T
的指针,则 String
的接收器必须是值类型;本例使用指针,因为对于结构体类型来说,这样做更有效率,也更符合习惯用法。有关更多信息,请参见下面关于指针与值接收器 的部分。)
我们的 String
函数能够调用 Sprintf
,因为打印例程是完全可重入的,可以这样包装。不过,需要了解关于此方法的一个重要细节:不要通过以无限递归调用您的 String
函数的方式构造 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
作为单个切片参数传递。
打印的内容远不止我们这里介绍的这些。有关详细信息,请参见 godoc
文档中关于 fmt
包的说明。
顺便说一下,...
参数可以是特定类型,例如选择整数列表中最小的值的最小值函数的 ...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
函数不同。从结构上看,它就像这样
func append(slice []T, elements ...T) []T
其中 T 是任何给定类型的占位符。您实际上无法在 Go 中编写一个函数,其中 T
的类型由调用者决定。这就是 append
是内置函数的原因:它需要编译器的支持。
append
所做的是将元素追加到切片的末尾,并返回结果。需要返回结果,因为和我们手写的 Append
一样,底层数组可能会发生变化。这个简单的例子
x := []int{1,2,3} x = append(x, 4, 5, 6) fmt.Println(x)
打印 [1 2 3 4 5 6]
。所以 append
的工作方式有点像 Printf
,收集任意数量的参数。
但是,如果我们想做我们 Append
所做的事情,将一个切片追加到另一个切片呢?很简单:在调用站点使用 ...
,就像我们在上面的 Output
调用中所做的那样。这段代码片段产生的输出与上面的输出相同。
x := []int{1,2,3} y := []int{4,5,6} x = append(x, y...) fmt.Println(x)
如果没有 ...
,它将无法编译,因为类型不匹配;y
的类型不是 int
。
初始化
虽然表面上看与 C 或 C++ 中的初始化没有太大区别,但 Go 中的初始化功能更强大。可以在初始化期间构建复杂的结构,并且即使是在不同包中的已初始化对象之间,排序问题也会得到正确处理。
常量
Go 中的常量就是常量。它们是在编译时创建的,即使是在函数中定义为局部变量时也是如此,并且只能是数字、字符(字符)、字符串或布尔值。由于编译时的限制,定义它们的表达式必须是常量表达式,可以由编译器求值。例如,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 代码中很常见,并且通常使用从函数派生出的名称,例如 io.Writer
,用于实现 Write
的东西。
一个类型可以实现多个接口。例如,如果一个集合实现了 sort.Interface
接口,它就可以通过 sort
包中的例程进行排序,该接口包含 Len()
、Less(i, j int) bool
和 Swap(i, j int)
方法,并且它还可以拥有自定义格式化器。在这个人为的例子中,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
)的能力,每个类型都完成工作的一部分。在实践中这不太常见,但可能很有效。
接口转换和类型断言
类型切换 是一种转换形式:它们接受一个接口,并且对于切换中的每个情况,在某种意义上将其转换为该情况的类型。以下是如何在 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() }
第一个情况找到一个具体值;第二个将接口转换为另一个接口。以这种方式混合类型是完全可以的。
如果我们只关心一种类型怎么办?如果我们知道值包含一个 string
,并且我们只想提取它?一个单情况类型切换可以做到,但类型断言也可以做到。类型断言接受一个接口值,并从中提取指定显式类型的值。语法借鉴于打开类型切换的子句,但使用的是显式类型而不是 type
关键字
value.(typeName)
并且结果是具有静态类型 typeName
的新值。该类型必须是接口持有的具体类型,或者是该值可以转换到的第二个接口类型。要提取我们知道在值中的字符串,我们可以写
str := value.(string)
但是,如果事实证明该值不包含字符串,则程序将因运行时错误而崩溃。为了防止这种情况,使用“逗号,确定”习惯用法安全地测试该值是否为字符串
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 程序中用 CRC-32 算法替换 Adler-32 算法只需要改变构造函数调用;代码的其余部分不受算法更改的影响。
类似的方法允许各种 crypto
包中的流密码算法与它们串联在一起的块密码分离。crypto/cipher
包中的 Block
接口指定了块密码的行为,它提供对单个数据块的加密。然后,通过与 bufio
包的类比,实现此接口的密码包可以用来构建流密码,由 Stream
接口表示,而无需了解块加密的细节。
crypto/cipher
接口看起来像这样
type Block interface { BlockSize() int Encrypt(dst, src []byte) Decrypt(dst, src []byte) } type Stream interface { XORKeyStream(dst, src []byte) }
以下是将块密码转换为流密码的计数器模式 (CTR) 流的定义;请注意,块密码的细节被抽象掉了
// NewCTR 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
方法,因此可以在可以使用 io.Writer
的任何地方使用 http.ResponseWriter
。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) }
未使用的导入和变量
导入包或声明未使用的变量是一种错误。未使用的导入会使程序膨胀并减慢编译速度,而初始化但未使用的变量至少是一种浪费的计算,并且可能表明存在更大的错误。但是,当程序处于积极开发中时,未使用的导入和变量经常出现,删除它们只是为了让编译继续,然后又再次需要它们,这可能会很烦人。空白标识符提供了一种解决方法。
这个半写完的程序有两个未使用的导入 (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 编码器接收到实现了该接口的值时,编码器会调用该值的编组方法将其转换为 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) }
通过直接嵌入结构,我们避免了这种簿记。嵌入类型的 method 会自动出现,这意味着 bufio.ReadWriter
不仅具有 bufio.Reader
和 bufio.Writer
的 method,它还满足所有三个接口:io.Reader
、io.Writer
和 io.ReadWriter
。
嵌入与子类化之间存在一个重要的区别。当我们嵌入类型时,该类型的 method 会成为外部类型的 method,但是当调用它们时,method 的接收者是内部类型,而不是外部类型。在我们的示例中,当调用 bufio.ReadWriter
的 Read
method 时,它与上面写出的转发 method 的效果完全相同;接收者是 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
method 中所做的那样。在这里,如果我们需要访问 Job
变量 job
的 *log.Logger
,我们将编写 job.Logger
,这将很有用,因为如果我们想改进 Logger
的 method。
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 的 Communicating Sequential Processes (CSP),但它也可以被看作是 Unix 管道的类型安全泛化。
Goroutines
它们被称为 goroutines,因为现有的术语——线程、协程、进程等——传达了不准确的含义。Goroutine 有一个简单的模型:它是在相同地址空间中与其他 goroutines 并发执行的函数。它很轻量级,只比分配堆栈空间多花一点点成本。而且堆栈最初很小,所以它们很便宜,并且根据需要通过分配(和释放)堆存储来增长。
Goroutines 被多路复用到多个操作系统线程上,因此如果一个线程应该阻塞,例如在等待 I/O 时,其他线程会继续运行。它们的設計隐藏了线程创建和管理的许多复杂性。
在函数或方法调用前添加 go
关键字以在新的 goroutine 中运行该调用。调用完成后,goroutine 会默默退出。(效果类似于 Unix shell 的 &
符号,用于在后台运行命令。)
go list.Sort() // run list.Sort concurrently; don't wait for it.
函数字面量在 goroutine 调用中可能很方便。
func Announce(message string, delay time.Duration) { go func() { time.Sleep(delay) fmt.Println(message) }() // Note the parentheses - must call the function. }
在 Go 中,函数字面量是闭包:实现确保函数引用的变量在它们活动期间一直存在。
这些例子不太实用,因为函数无法以任何方式发出完成信号。为此,我们需要通道。
Channels
与 map 相似,通道使用 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
未缓冲的通道将通信(值的交换)与同步(保证两个计算(goroutines)处于已知状态)结合起来。
使用通道有很多不错的习惯用法。这里有一个让我们开始的例子。在上一节中,我们在后台启动了一个排序操作。通道可以让启动 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
可以同时运行。因此,如果请求过于频繁,程序可能会消耗无限的资源。我们可以通过更改 Serve
来限制 goroutine 的创建来解决这一缺陷
func Serve(queue chan *Request) { for req := range queue { sem <- 1 go func() { process(req) <-sem }() } }
(注意,在 1.22 之前的 Go 版本中,这段代码存在一个 bug:循环变量在所有 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 系统的框架,而且没有使用 mutex。
并行化
这些想法的另一个应用是在多个 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
子句在没有其他情况准备就绪时执行,这意味着 select
永远不会阻塞。)这种实现仅用几行代码构建了一个漏桶空闲列表,依靠缓冲通道和垃圾收集器来进行簿记。
错误
库例程通常必须向调用者返回某种错误指示。如前所述,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
。如果问题可以掩盖或解决,那么让程序继续运行总是比让整个程序崩溃更好。一个可能的反例是在初始化期间:如果库确实无法设置自身,那么 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)
出现恐慌,结果将被记录,并且 goroutine 将干净地退出,而不会影响其他 goroutine。 在延迟闭包中不需要做任何其他事情; 调用 recover
将完全处理这种情况。
因为 recover
总是返回 nil
,除非它直接从延迟函数中调用,所以延迟代码可以调用本身使用 panic
和 recover
的库例程,而不会失败。 例如,safelyDo
中的延迟函数可能会在调用 recover
之前调用一个日志记录函数,并且该日志记录代码将不受恐慌状态的影响。
有了我们的恢复模式,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
出现恐慌,恢复块会将返回值设置为 nil
——延迟函数可以修改命名返回值。 然后它将在赋值给 err
时检查问题是否是解析错误,方法是断言它具有本地类型 Error
。 如果没有,类型断言将失败,导致运行时错误,继续堆栈展开,就好像没有任何东西中断它一样。 此检查意味着,如果发生意外情况,例如索引越界,代码将失败,即使我们使用 panic
和 recover
来处理解析错误。
有了错误处理,error
方法(因为它是一个绑定到类型的方法,因此可以,甚至自然地,具有与内置 error
类型相同的名称)使报告解析错误变得容易,而无需担心手动展开解析堆栈。
if pos == 0 { re.error("'*' illegal at start of expression") }
虽然这种模式很有用,但它应该只在包内使用。 Parse
将其内部的 panic
调用转换为 error
值; 它不会向其客户端公开 panics
。 这是一个应该遵循的良好规则。
顺便说一下,这个重新恐慌的习语在实际发生错误时会更改恐慌值。 但是,原始错误和新错误都将在崩溃报告中显示,因此问题的根本原因仍然可见。 因此,这种简单的重新恐慌方法通常就足够了——毕竟这是崩溃——但如果你想只显示原始值,你可以编写更多代码来过滤意外问题,并使用原始错误重新恐慌。 这留给读者作为练习。
一个 Web 服务器
最后,我们用一个完整的 Go 程序——一个 Web 服务器——结束。 实际上,这是一个 Web 重复服务器。 谷歌在 chart.apis.google.com
提供了一项服务,该服务将数据自动格式化为图表和图形。 虽然很难以交互方式使用它,因为你需要将数据作为查询放在 URL 中。 这里的程序为一种数据形式提供了一个更好的界面:给定一小段文本,它调用图表服务器来生成一个 QR 码,一个编码文本的方块矩阵。 可以用手机的相机抓取该图像,并将其解释为例如 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。 如果这个解释太快,请参阅 模板包文档,了解更详细的讨论。
就是这样:几行代码再加上一些数据驱动的 HTML 文本,就可以实现一个实用的 Web 服务器。 Go 足够强大,可以使几行代码完成很多事情。