高效 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 语言不支持自动提供 getter 和 setter 方法。自己提供 getter 和 setter 方法并无不妥,而且通常情况下这样做是合适的,但将 Get
放入 getter 方法的名称中既不符合惯例,也没有必要。如果您有一个名为 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
。
MixedCaps
最后,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
。语法也略有不同:没有括号,并且主体必须始终以大括号分隔。
如果
在 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 中,函数参数和返回值的作用域与函数体相同,即使它们在语法上出现在包围函数体的花括号之外。
对于
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 }
如果您只需要范围内的第一个项目(键或索引),请删除第二个项目
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
没有表达式,则会切换到 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 }
没有自动贯穿,但情况可以以逗号分隔列表的形式呈现。
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 在表达式中声明了一个变量,则该变量在每个子句中都将具有相应的类型。在这种情况下,重用名称也是惯例,实际上是在每种情况下都声明一个具有相同名称但不同类型的新变量。
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)
时,它返回写入的字节数和非空 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 }
推迟
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) }
推迟的函数按后进先出顺序执行,因此当函数返回时,此代码将导致打印 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}
复合字面量的字段按顺序排列,并且必须全部存在。但是,通过将元素明确标记为 field:
value 对,初始化程序可以按任何顺序出现,缺少的元素保留为它们各自的零值。因此,我们可以说
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
访问,它报告切片可能具有的最大长度。下面是一个将数据追加到切片中的函数。如果数据超出容量,则会重新分配切片。返回结果切片。该函数利用了当应用于 nil
切片时,len
和 cap
是合法的,并且返回 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 的数组和切片是一维的。要创建相当于 2D 数组或切片的东西,有必要定义一个数组的数组或切片的切片,如下所示
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."), }
有时有必要分配一个 2D 切片,这种情况可能出现在处理像素扫描线时。有两种方法可以实现这一点。一种方法是独立分配每个切片;另一种方法是分配一个单一的数组,并将各个切片指向它。使用哪种方法取决于你的应用程序。如果切片可能增长或缩小,则应独立分配它们以避免覆盖下一行;如果不是,则使用单个分配来构造对象可能会更有效率。为了参考,这里是对这两种方法的概述。首先,一次一行
// 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
,因为打印例程是完全可重入的,并且可以这样包装。但是,关于此方法有一个重要的细节需要了解:不要通过以将无限期地递归到 String
方法的方式调用 Sprintf
来构造 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
函数不同。示意图如下所示
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 代码中,仅包含一个或两个方法的接口很常见,并且通常会根据方法命名,例如,对于实现 Write
的内容,命名为 io.Writer
。
一个类型可以实现多个接口。例如,如果集合实现了包含 Len()
、Less(i, j int) bool
和 Swap(i, j int)
的 sort.Interface
,则可以由包 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
)的能力,每种类型都执行一部分工作。在实践中,这并不常见,但很有效。
接口转换和类型断言
类型转换是一种转换形式:它们采用一个接口,并且对于转换中的每个情况,从某种意义上将其转换为该情况的类型。以下是 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
方法,因此 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 服务器将使用 ArgServer
作为接收器调用该类型的 ServeHTTP
方法,该方法又将调用 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) }
通过直接嵌入结构,我们可以避免这种记录。嵌入类型的这些方法是免费提供的,这意味着 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 的并发方法起源于霍尔的通信顺序进程 (CSP),但它也可以被视为 Unix 管道的类型安全泛化。
Goroutine
它们被称为“goroutine”,因为现有的术语(线程、协程、进程等)传达了不准确的含义。Goroutine 有一个简单的模型:它是一个与同一地址空间中的其他 goroutine 并发执行的函数。它很轻量,开销仅比分配堆栈空间稍多。堆栈从很小开始,因此很便宜,并且会根据需要通过分配(和释放)堆存储来增长。
Goroutine 被复用到多个操作系统线程上,因此如果一个 goroutine 应该阻塞,例如在等待 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) // Buggy; see explanation below. <-sem }() } }
错误在于,在 Go for
循环中,循环变量在每次迭代中都会被重用,因此 req
变量在所有 goroutine 中都是共享的。这不是我们想要的。我们需要确保 req
对于每个 goroutine 都是唯一的。以下是一种方法,将 req
的值作为参数传递给 goroutine 中的闭包
func Serve(queue chan *Request) { for req := range queue { sem <- 1 go func(req *Request) { process(req) <-sem }(req) } }
将此版本与上一个版本进行比较,以了解闭包的声明和运行方式有何不同。另一种解决方案是创建一个具有相同名称的新变量,如下例所示
func Serve(queue chan *Request) { for req := range queue { req := req // Create new instance of req for the goroutine. sem <- 1 go func() { process(req) <-sem }() } }
写
req := req
看起来可能很奇怪
但在 Go 中这样做是合法的且习惯的。您可以获得具有相同名称的新变量版本,故意在本地隐藏循环变量,但对于每个 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. }
回到编写服务器的一般问题,另一种管理资源的方法是启动固定数量的 handle
goroutine,所有这些 goroutine 都从请求通道中读取。goroutine 的数量限制了对 process
的同时调用次数。此 Serve
函数还接受一个通道,它将在该通道上被告知退出;启动 goroutine 后,它会阻塞,从该通道接收。
通道的通道
Go 最重要的特性之一是通道是一个可以像其他任何值一样分配和传递的一等值。此特性的一个常见用途是实现安全的并行解复用。
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
子句在没有其他情况就绪时执行,这意味着 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:未知格式”。
关心精确错误详细信息的调用者可以使用类型转换或类型断言来查找特定错误并提取详细信息。对于 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
也是如此,我们可以检查它以获取有关错误的更多信息。
恐慌
向调用者报告错误的通常方法是将 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") } }
恢复
当调用 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
发生 panic,恢复块会将返回值设置为 nil
,延迟函数可以修改命名返回值。然后,在对 err
的赋值中,它会通过断言其具有本地类型 Error
来检查问题是否是解析错误。如果不是,类型断言将失败,从而导致运行时错误,该错误会继续堆栈展开,就好像没有任何东西中断它一样。此检查意味着,如果发生意外情况(例如索引超出范围),代码将失败,即使我们使用 panic
和 recover
来处理解析错误也是如此。
有了错误处理,error
方法(因为它是一个绑定到类型的函数,所以它具有与内置 error
类型相同的名称是完全可以的,甚至很自然)可以轻松报告解析错误,而无需担心手动展开解析堆栈
if pos == 0 { re.error("'*' illegal at start of expression") }
尽管此模式很有用,但它应该仅在包内使用。Parse
将其内部 panic
调用转换为 error
值;它不会向其客户端公开 panics
。这是一个很好的规则。
顺便说一句,如果发生实际错误,这种重新 panic 的习惯用法会更改 panic 值。但是,原始故障和新故障都将在崩溃报告中显示,因此问题的根本原因仍然可见。因此,这种简单的重新 panic 方法通常就足够了,毕竟它是一个崩溃,但是如果你只想显示原始值,你可以编写更多代码来过滤意外问题,并使用原始错误重新 panic。这留给读者作为练习。
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。如果这个解释太快,请参阅模板包的 文档 以进行更全面的讨论。
就是这样:一个有用的 Web 服务器,只需几行代码和一些数据驱动的 HTML 文本。Go 足够强大,可以在几行中完成很多事情。