引言
Go 是一种新的编程语言。虽然它借鉴了现有语言的理念,但其独特的特性使得高效的 Go 程序在风格上与使用其他语言(如 C++ 或 Java)编写的程序有所不同。直接将 C++ 或 Java 程序翻译成 Go 代码不太可能产生令人满意的结果——Java 程序是用 Java 编写的,而不是 Go。相反,从 Go 的视角思考问题可能会产生一个成功但截然不同的程序。换句话说,要写好 Go 代码,理解其特性和习惯用法非常重要。同样,了解 Go 编程的惯例(例如命名、格式化、程序构造等)也很重要,这样你编写的程序对其他 Go 程序员来说易于理解。
本文档提供了编写清晰、符合 Go 习惯的代码的建议。它是对语言规范、Go 语言之旅和《如何编写 Go 代码》的补充,建议先阅读这些内容。
注意(2022 年 1 月添加):本文档是为 Go 在 2009 年发布时编写的,此后未有重大更新。虽然它仍然是理解如何使用 Go 语言本身的良好指南,但由于 Go 语言的稳定性,它对标准库的介绍较少,也未涉及自编写以来 Go 生态系统的重大变化,例如构建系统、测试、模块和多态。目前没有计划更新本文档,因为已有大量且不断增长的文档、博客和书籍很好地描述了现代 Go 的使用方式。高效 Go 仍然有用,但读者应理解它远非完整的指南。有关背景信息,请参阅 issue 28782。
示例
Go 的包源码不仅是核心库的一部分,还作为如何使用该语言的示例。此外,许多包包含可直接从 go.dev 网站运行的独立可执行示例,例如 这个示例(如有需要,点击“示例”以展开)。如果你对如何解决某个问题或实现某个功能有疑问,库中的文档、代码和示例可以提供答案、灵感和背景知识。
格式化
格式化问题是最具争议性但影响最小的。人们可以适应不同的格式化风格,但如果不必适应会更好;如果每个人都遵循相同的风格,讨论这个话题的时间就会减少。问题在于如何在不使用冗长的规范性风格指南的情况下实现这一理想状态。
在 Go 中,我们采取了一种不同寻常的方法,让机器处理大部分格式化问题。gofmt 程序(也可用作 go fmt,在包级别而非源文件级别操作)读取 Go 程序并以标准的缩进和垂直对齐方式输出源代码,保留并在需要时重新格式化注释。如果你想知道如何处理新的布局情况,运行 gofmt;如果结果看起来不正确,调整你的程序(或报告 gofmt 的 bug),而不是绕过它。
例如,你无需花时间对齐结构体字段的注释,gofmt 会为你完成。给定以下声明:
type T struct {
name string // 对象的名称
value int // 其值
}
gofmt 会将列对齐如下:
type T struct {
name string // 对象的名称
value int // 其值
}
标准包中的所有 Go 代码都已使用 gofmt 格式化。
一些格式化细节仍需注意,简要说明如下:
- 缩进:我们使用制表符(tab)进行缩进,
gofmt默认输出制表符。仅在必要时使用空格。 - 行长度:Go 没有行长度限制。不用担心打孔卡溢出。如果一行感觉太长,可以换行并用额外的制表符缩进。
- 括号:Go 比 C 和 Java 需要的括号更少:控制结构(
if、for、switch)的语法中不需要括号。此外,运算符优先级层次更短、更清晰,因此x<<8 + y<<16的含义与空格暗示的一致,不像其他语言。
注释
Go 提供 C 风格的 /* */ 块注释和 C++ 风格的 // 行注释。行注释是常态;块注释主要用作包注释,但在表达式中或禁用大段代码时也很有用。
在顶级声明之前的注释(没有中间换行)被视为对该声明本身的文档。这些“文档注释”是给定 Go 包或命令的主要文档。有关文档注释的更多信息,请参阅 Go Doc Comments。
命名
命名在 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 放入获取器名称既不习惯也不必要。如果有一个名为 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() // 错误!
{ // 错误!
g()
}
控制结构
Go 的控制结构与 C 的相关,但有重要区别。没有 do 或 while 循环,只有稍作扩展的 for;switch 更灵活;if 和 switch 接受类似 for 的可选初始化语句;break 和 continue 语句接受可选标签来标识要中断或继续的内容;还有新的控制结构,包括类型 switch 和多路通信多路复用器 select。语法也略有不同:没有括号,主体必须始终用大括号定界。
If 语句
在 Go 中,简单的 if 语句如下:
if x > 0 {
return y
}
强制使用大括号鼓励将简单 if 语句写成多行。无论如何,这都是好风格,尤其是当主体包含控制语句如 return 或 break 时。
由于 if 和 switch 接受初始化语句,常见做法是用来设置局部变量:
if err := file.Chmod(0664); err != nil {
log.Print(err)
return err
}
在 Go 库中,你会发现当 if 语句不流入下一个语句时——即主体以 break、continue、goto 或 return 结束——不必要的 else 会被省略:
f, err := os.Open(name)
if err != nil {
return err
}
codeUsing(f)
这是一个常见情况,代码必须防范一系列错误条件的示例。如果成功的控制流沿着页面向下运行,逐一消除错误情况,代码读起来会很好。由于错误情况往往以 return 语句结束,生成的代码不需要 else 语句:
f, err := os.Open(name)
if err != nil {
return err
}
d, err := f.Stat()
if err != nil {
f.Close()
return err
}
codeUsing(f, d)
重新声明和重新赋值
上一节的最后一个示例展示了一个关于 := 短声明形式工作方式的细节。调用 os.Open 的声明如下:
f, err := os.Open(name)
此语句声明了两个变量 f 和 err。几行后,调用 f.Stat 的语句如下:
d, err := f.Stat()
看起来像是声明了 d 和 err。但请注意,err 出现在两个语句中。这种重复是合法的:err 由第一个语句声明,但在第二个语句中仅被重新赋值。这意味着调用 f.Stat 使用了上面声明的现有 err 变量,只是给它赋了一个新值。
在 := 声明中,变量 v 即使已被声明也可以出现,前提是:
- 该声明与现有
v的声明在同一作用域内(如果v已在外部作用域声明,声明将创建一个新变量 §), - 初始化中的对应值可以赋值给
v, - 声明中至少有一个其他变量是新创建的。
这种不寻常的特性是纯粹的实用主义,例如在一个长的 if-else 链中可以轻松使用单一的 err 值。你会经常看到它被使用。
§ 这里值得注意的是,在 Go 中,函数参数和返回值的作作用域与函数体相同,即使它们在词法上出现在包围体的花括号之外。
For 循环
Go 的 for 循环类似于但不等同于 C 的。它统一了 for 和 while,没有 do-while。有三种形式,其中只有一种使用分号:
// 像 C 的 for
for init; condition; post { }
// 像 C 的 while
for condition { }
// 像 C 的 for(;;)
for { }
短声明使得在循环中声明索引变量变得简单:
sum := 0
for i := 0; i < 10; i++ {
sum += i
}
如果你要遍历数组、切片、字符串、映射或从通道读取,可以使用 range 子句来管理循环:
for key, value := range oldMap {
newMap[key] = value
}
如果只需要 range 中的第一个项(键或索引),可以省略第二个:
for key := range m {
if key.expired() {
delete(m, key)
}
}
如果只需要 range 中的第二个项(值),使用空白标识符 _ 丢弃第一个:
sum := 0
for _, value := range array {
sum += value
}
空白标识符有许多用途,将在后续部分讨论。
对于字符串,range 会为你做更多工作,通过解析 UTF-8 拆分出单个 Unicode 码点。错误的编码会消耗一个字节并产生替换符 U+FFFD。(术语 rune 是 Go 对单个 Unicode 码点的术语,详见语言规范。)例如:
for pos, char := range "日本\x80語" { // \x80 是一个非法的 UTF-8 编码
fmt.Printf("字符 %##U 从字节位置 %d 开始\n", char, pos)
}
输出:
字符 U+65E5 '日' 从字节位置 0 开始
字符 U+672C '本' 从字节位置 3 开始
字符 U+FFFD '�' 从字节位置 6 开始
字符 U+8A9E '語' 从字节位置 7 开始
最后,Go 没有逗号运算符,++ 和 -- 是语句而非表达式。因此,如果你想在 for 中运行多个变量,应使用并行赋值(尽管这排除了 ++ 和 --):
// 反转数组 a
for i, j := 0, len(a)-1; i < j; i, j = i+1, j-1 {
a[i], a[j] = a[j], a[i]
}
Switch 语句
Go 的 switch 比 C 的更通用。表达式不必是常量甚至整数,case 从上到下逐一评估直到找到匹配项,如果 switch 没有表达式,它会基于 true 切换。因此,可以——且习惯上——将 if-else-if-else 链写成 switch:
func unhex(c byte) byte {
switch {
case '0' <= c && c <= '9':
return c - '0'
case 'a' <= c && c <= 'f':
return c - 'a' + 10
case 'A' <= c && c <= 'F':
return c - 'A' + 10
}
return 0
}
没有自动穿透,但 case 可以用逗号分隔的列表表示:
func shouldEscape(c byte) bool {
switch c {
case ' ', '?', '&', '=', '##', '+', '%':
return true
}
return false
}
以下是一个比较字节切片的例程,使用了两个 switch 语句:
// Compare 返回一个整数,比较两个字节切片,
// 按字典序比较。
// 如果 a == b,返回 0;如果 a < b,返回 -1;如果 a > b,返回 +1
func Compare(a, b []byte) int {
for i := 0; i < len(a) && i < len(b); i++ {
switch {
case a[i] > b[i]:
return 1
case a[i] < b[i]:
return -1
}
}
switch {
case len(a) > len(b):
return 1
case len(a) < len(b):
return -1
}
return 0
}
类型 Switch
switch 还可以用来发现接口变量的动态类型。这种类型 switch 使用类型断言的语法,括号内使用关键字 type。如果 switch 在表达式中声明了一个变量,该变量在每个子句中将具有对应的类型。习惯上在这种情况下重用名称,实际上在每个 case 中声明一个同名但类型不同的新变量:
var t interface{}
t = functionOfSomeType()
switch t := t.(type) {
default:
fmt.Printf("意外的类型 %T\n", t) // %T 打印 t 的类型
case bool:
fmt.Printf("布尔值 %t\n", t) // t 是 bool 类型
case int:
fmt.Printf("整数 %d\n", t) // t 是 int 类型
case *bool:
fmt.Printf("指向布尔值的指针 %t\n", *t) // t 是 *bool 类型
case *int:
fmt.Printf("指向整数的指针 %d\n", *t) // t 是 *int 类型
}
函数
多返回值
Go 的一个不寻常特性是函数和方法可以返回多个值。这种形式可以改进 C 程序中的一些笨拙习惯:如用 -1 表示 EOF 的带内错误返回和通过地址修改参数。
在 C 中,写错误通过负数计数表示,错误码隐藏在某个易变位置。在 Go 中,Write 可以 inspect 函数可以返回计数和错误:“是的,你写了一些字节,但不是全部,因为设备已满”。os 包中文件的 Write 方法签名是:
func (file *File) Write(b []byte) (n int, err error)
正如文档所述,当 n != len(b) 时,它返回写入的字节数和非空的错误。这是常见风格;更多示例请参见错误处理部分。
类似的方法避免了传递返回值指针来模拟引用参数的需要。以下是一个从字节切片中提取数字的简单函数,返回数字和下一个位置:
func nextInt(b []byte, i int) (int, int) {
for ; i < len(b) && !isDigit(b[i]); i++ {
}
x := 0
for ; i < len(b) && isDigit(b[i]); i++ {
x = x*10 + int(b[i]) - '0'
}
return x, i
}
你可以用它来扫描输入切片 b 中的数字:
for i := 0; i < len(b); {
x, i = nextInt(b, i)
fmt.Println(x)
}
命名结果参数
Go 函数的返回或结果“参数”可以被命名,并像常规变量一样使用,就像传入参数一样。命名时,它们在函数开始时被初始化为其类型的零值;如果函数执行不带参数的 return 语句,将使用结果参数的当前值作为返回值。
名称不是强制性的,但它们可以使代码更短、更清晰:它们是文档。如果我们命名 nextInt 的结果,可以清楚地知道哪个返回的 int 是哪个:
func nextInt(b []byte, pos int) (value, nextPos int) {
因为命名结果被初始化并绑定到无参数的 return,它们可以简化并澄清代码。以下是 io.ReadFull 的一个版本,很好地使用了它们:
func ReadFull(r Reader, buf []byte) (n int, err error) {
for len(buf) > 0 && err == nil {
var nr int
nr, err = r.Read(buf)
n += nr
buf = buf[nr:]
}
return
}
Defer 延迟执行
Go 的 defer 语句安排一个函数调用(延迟函数)在执行 defer 的函数返回之前立即运行。这是一个不寻常但有效的方法,用于处理诸如必须释放资源的情况,无论函数通过哪条路径返回。典型示例是解锁互斥锁或关闭文件:
// Contents 返回文件的字符串内容。
func Contents(filename string) (string, error) {
f, err := os.Open(filename)
if err != nil {
return "", err
}
defer f.Close() // f.Close 将在我们完成时运行。
var result []byte
buf := make([]byte, 100)
for {
n, err := f.Read(buf[0:])
result = append(result, buf[0:n]...) // append 稍后讨论。
if err != nil {
if err == io.EOF {
break
}
return "", err // 如果我们在这里返回,f 将被关闭。
}
}
return string(result), nil // 如果我们在这里返回,f 将被关闭。
}
延迟对函数(如 Close)的调用有两个优点。首先,它保证你永远不会忘记关闭文件,如果你稍后编辑函数添加新的返回路径,这种错误很容易发生。其次,它意味着关闭操作靠近打开操作,比放在函数末尾更清晰。
延迟函数的参数(如果函数是方法,包括接收者)在 defer 执行时被评估,而不是在调用执行时。除了避免担心变量在函数执行期间值变化外,这意味着单个延迟调用点可以延迟多个函数执行。以下是一个简单的示例:
for i := 0; i < 5; i++ {
defer fmt.Printf("%d ", i)
}
延迟函数以 LIFO(后进先出)顺序执行,因此此代码将在函数返回时打印 4 3 2 1 0。一个更合理的示例是跟踪程序中函数执行的简单方法。我们可以编写几个简单的跟踪例程,如下:
func trace(s string) { fmt.Println("进入:", s) }
func untrace(s string) { fmt.Println("离开:", s) }
// 使用方法如下:
func a() {
trace("a")
defer untrace("a")
// 做一些事情...
}
我们可以利用延迟函数的参数在 defer 执行时被评估的特性,进一步改进。跟踪例程可以设置退出跟踪例程的参数。这个示例:
func trace(s string) string {
fmt.Println("进入:", s)
return s
}
func un(s string) {
fmt.Println("离开:", s)
}
func a() {
defer un(trace("a"))
fmt.Println("在 a 中")
}
func b() {
defer un(trace("b"))
fmt.Println("在 b 中")
a()
}
func main() {
b()
}
打印:
进入: b
在 b 中
进入: a
在 a 中
离开: a
离开: 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) // 类型 *SyncedBuffer
var v SyncedBuffer // 类型 SyncedBuffer
构造函数和复合字面量
有时零值不足以使用,需要一个初始化构造函数,如下所示,来自 os 包的示例:
func NewFile(fd int, name string) *File {
if fd < 0 {
return nil
}
f := new(File)
f.fd = fd
f.name = name
f.dirinfo = nil
f.nepipe = 0
return f
}
这里有很多样板代码。我们可以使用复合字面量来简化,它是一个每次评估时创建新实例的表达式:
func NewFile(fd int, name string) *File {
if fd < 0 {
return nil
}
f := File{fd, name, nil, 0}
return &f
}
请注意,与 C 不同,返回局部变量的地址是完全可以的;与变量关联的存储在函数返回后仍然存在。事实上,取复合字面量的地址每次评估时都会分配一个新实例,因此我们可以合并最后两行:
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) // 分配切片结构;*p == nil;很少有用
var v []int = make([]int, 100) // 切片 v 现在指向一个包含 100 个整数的新数组
// 不必要的复杂:
var p *[]int = new([]int)
*p = make([]int, 100, 100)
// 习惯用法:
v := make([]int, 100)
请记住,make 仅适用于映射、切片和通道,并且不返回指针。要获得显式指针,请使用 new 分配或显式取变量的地址。
数组
数组在规划内存的详细布局时很有用,有时可以帮助避免分配,但它们主要是切片(下一节的主题)的构建块。为了为该主题奠定基础,这里简单介绍一下数组。
Go 和 C 中数组的工作方式有重大差异。在 Go 中:
- 数组是值。将一个数组赋值给另一个数组会复制所有元素。
- 特别是,如果你将数组传递给函数,它将接收数组的副本,而不是指向它的指针。
- 数组的大小是其类型的一部分。类型
[10]int和[20]int是不同的。
值的属性可能很有用,但也很昂贵;如果你想要 C 风格的行为和效率,可以传递指向数组的指针:
func Sum(a *[3]float64) (sum float64) {
for _, v := range *a {
sum += v
}
return
}
array := [...]float64{7.0, 8.5, 9.1}
x := Sum(&array) // 注意显式的取地址操作符
但即使是这种风格也不是 Go 的习惯用法。使用切片代替。
切片
切片包装数组,提供对数据序列的更通用、强大和方便的接口。除了如变换矩阵这样具有明确维度的项目外,Go 中的大多数数组编程都使用切片而不是简单数组。
切片持有对底层数组的引用,如果你将一个切片赋值给另一个切片,两个切片都引用同一个数组。如果函数接受切片参数,对切片元素所做的更改对调用者是可见的,类似于传递指向底层数组的指针。因此,Read 函数可以接受切片参数而不是指针和计数;切片内的长度设置了读取数据的上限。以下是 os 包中 File 类型的 Read 方法的签名:
func (f *File) Read(buf []byte) (n int, err error)
该方法返回读取的字节数和可能的错误值。要读取较大缓冲区 buf 的前 32 个字节,可以切片(这里用作动词)缓冲区:
n, err := f.Read(buf[0:32])
这种切片是常见且高效的。事实上,暂时抛开效率不谈,以下片段也将读取缓冲区的前 32 个字节:
var n int
var err error
for i := 0; i < 32; i++ {
nbytes, e := f.Read(buf[i:i+1]) // 读取一个字节。
n += nbytes
if nbytes == 0 || e != nil {
err = e
break
}
}
只要切片仍在底层数组的限制范围内,切片的长度可以更改;只需将其赋值给自身的切片。切片的容量可以通过内置函数 cap 访问,报告切片可能具有的最大长度。以下是一个向切片追加数据的函数。如果数据超过容量,切片将重新分配。返回结果切片。该函数利用了 len 和 cap 对 nil 切片合法且返回 0 的特性:
func Append(slice, data []byte) []byte {
l := len(slice)
if l + len(data) > cap(slice) { // 重新分配
// 为未来增长分配双倍所需空间。
newSlice := make([]byte, (l+len(data))*2)
// copy 函数是预声明的,适用于任何切片类型。
copy(newSlice, slice)
slice = newSlice
}
slice = slice[0:l+len(data)]
copy(slice[l:], data)
return slice
}
我们必须在之后返回切片,因为尽管 Append 可以修改切片的元素,切片本身(包含指针、长度和容量的运行时数据结构)是按值传递的。
向切片追加的想法非常有用,因此被内置函数 append 捕获。为了理解该函数的设计,我们需要更多信息,因此我们稍后会返回讨论。
二维切片
Go 的数组和切片是一维的。要创建二维数组或切片的等价物,需要定义数组的数组或切片的切片,如下:
type Transform [3][3]float64 // 一个 3x3 数组,实际上是数组的数组。
type LinesOfText [][]byte // 字节切片的切片。
因为切片是可变长度的,每个内部切片可以有不同的长度。这在我们的 LinesOfText 示例中可能是常见情况:每行长度独立:
text := LinesOfText{
[]byte("现在是时候"),
[]byte("对所有优秀的 Go 程序员"),
[]byte("带来一些派对乐趣。"),
}
有时需要分配二维切片,例如在处理像素扫描线时。有两种方法可以实现这一点。一种是独立分配每个切片;另一种是分配单个数组并将各个切片指向它。使用哪种方法取决于你的应用。如果切片可能增长或缩小,应独立分配以避免覆盖下一行;如果不会,单次分配构造对象可能更有效。以下是两种方法的草图。首先,一次一行:
// 分配顶层切片。
picture := make([][]uint8, YSize) // 每单位 y 一行。
// 遍历行,为每行分配切片。
for i := range picture {
picture[i] = make([]uint8, XSize)
}
现在作为一次分配,切成行:
// 分配顶层切片,与之前相同。
picture := make([][]uint8, YSize) // 每单位 y 一行。
// 分配一个大切片来容纳所有像素。
pixels := make([]uint8, XSize*YSize) // 尽管 picture 是 [][]uint8,类型是 []uint8。
// 遍历行,从剩余像素切片的前端切片每行。
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] { // 如果 person 不在映射中,将为 false
fmt.Println(person, "参加了会议")
}
有时你需要区分缺失的条目和零值。是否存在“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("未知时区:", tz)
return 0
}
要在不关心实际值的情况下测试映射中的存在,可以使用空白标识符 _ 代替值的常规变量:
_, present := timeZone[tz]
要删除映射条目,使用内置函数 delete,其参数是映射和要删除的键。即使键已从映射中删除,这样做也是安全的:
delete(timeZone, "PDT") // 现在是标准时间
打印
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) // 或仅 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}
(注意 & 符号。)当应用于字符串或 []byte 类型的值时,引号字符串格式也可通过 %q 获得。如果可能,替代格式 %##q 将使用反引号。(%q 格式也适用于整数和符文,产生单引号符文常量。)同样,%x 适用于字符串、字节数组和字节切片以及整数,生成长十六进制字符串,格式中带空格(% x)会在字节之间放置空格。
另一个方便的格式是 %T,它打印值的类型:
fmt.Printf("%T\n", timeZone)
打印:
map[string]int
如果你想控制自定义类型的默认格式,只需在类型上定义一个具有签名 String() string 的方法。对于我们的简单类型 T,可能如下所示:
func (t *T) String() string {
return fmt.Sprintf("%d/%g/%q", t.a, t.b, t.c)
}
fmt.Printf("%v\n", t)
打印格式为:
7/-2.35/"abc\tdef"
(如果你需要打印类型 T 的值以及指向 T 的指针,String 的接收者必须是值类型;此示例使用指针,因为这对结构体类型更有效且符合习惯。有关指针与值接收者的更多信息,请参见下面的部分。)
我们的 String 方法能够调用 Sprintf,因为打印例程是完全可重入的,可以以这种方式包装。然而,这种方法有一个重要的细节需要理解:不要通过调用 Sprintf 构造 String 方法,导致无限递归调用你的 String 方法。如果 Sprintf 调用尝试直接将接收者作为字符串打印,这将再次调用该方法。这是一个常见且容易犯的错误,如下例所示:
type MyString string
func (m MyString) String() string {
return fmt.Sprintf("MyString=%s", m) // 错误:将无限递归。
}
修复也很容易:将参数转换为不具有该方法的基本字符串类型:
type MyString string
func (m MyString) String() string {
return fmt.Sprintf("MyString=%s", string(m)) // 可以:注意转换。
}
在初始化部分,我们将看到另一种避免这种递归的技术。
另一种打印技术是将打印例程的参数直接传递给另一个这样的例程。Printf 的签名使用类型 ...interface{} 作为其最终参数,指定可以在格式之后出现任意数量的参数(任意类型):
func Printf(format string, v ...interface{}) (n int, err error) {
在函数 Printf 中,v 表现得像类型 []interface{} 的变量,但如果它被传递给另一个变参函数,它表现得像常规参数列表。以下是我们上面使用的 log.Println 函数的实现。它将参数直接传递给 fmt.Sprintln 进行实际格式化:
// Println 以 fmt.Println 的方式打印到标准记录器。
func Println(v ...interface{}) {
std.Output(2, fmt.Sprintln(v...)) // Output 接受参数 (int, string)
}
我们在对 Sprintln 的嵌套调用中在 v 后写 ...,告诉编译器将 v 视为参数列表;否则,它只会将 v 作为单个切片参数传递。
关于打印的知识还有很多。有关详细信息,请参见 fmt 包的 godoc 文档。
顺便说一句,... 参数可以是特定类型的,例如用于选择整数列表中最小的 min 函数的 ...int:
func Min(a ...int) int {
min := int(^uint(0) >> 1) // 最大的整数
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 // 通过分配给空白标识符忽略第一个值
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,这不是字符串格式: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 未设置")
}
if home == "" {
home = "/home/" + user
}
if gopath == "" {
gopath = home + "/go"
}
// gopath 可能被命令行上的 --gopath 标志覆盖。
flag.StringVar(&gopath, "gopath", gopath, "覆盖默认 GOPATH")
}
方法
指针与值
正如我们在 ByteSize 中看到的,可以为除指针或接口外的任何命名类型定义方法;接收者不必是结构体。
在上面讨论切片时,我们编写了一个 Append 函数。我们可以将其定义为切片上的方法。为此,我们首先声明一个我们可以绑定方法的命名类型,然后使该方法的接收者成为该类型的值:
type ByteSlice []byte
func (slice ByteSlice) Append(data []byte) []byte {
// 与上面定义的 Append 函数主体完全相同。
}
这仍然要求方法返回更新后的切片。我们可以通过重新定义方法,使其接收者为指向 ByteSlice 的指针,从而可以覆盖调用者的切片,从而消除这种笨拙:
func (p *ByteSlice) Append(data []byte) {
slice := *p
// 如上所述的主体,无需返回。
*p = slice
}
实际上,我们可以做得更好。如果我们将函数修改为看起来像标准的 Write 方法,如下所示:
func (p *ByteSlice) Write(data []byte) (n int, err error) {
slice := *p
// 再次如上所述。
*p = slice
return len(data), nil
}
那么类型 *ByteSlice 满足标准接口 io.Writer,这很方便。例如,我们可以打印到一个:
var b ByteSlice
fmt.Fprintf(&b, "这个小时有 %d 天\n", 7)
我们传递 ByteSlice 的地址,因为只有 *ByteSlice 满足 io.Writer。关于接收者的指针与值的规则是,值方法可以对指针和值调用,但指针方法只能对指针调用。
这条规则之所以出现,是因为指针方法可以修改接收者;在值上调用它们会导致方法接收值的副本,因此任何修改都会被丢弃。因此,语言不允许这种错误。不过,有一个方便的例外。当值是可寻址的时,语言会通过自动插入地址运算符来处理在值上调用指针方法的常见情况。在我们的例子中,变量 b 是可寻址的,因此我们可以仅用 b.Write 调用其 Write 方法。编译器会为我们将其重写为 (&b).Write。
顺便说一句,在字节切片上使用 Write 的想法是 bytes.Buffer 实现的核心。
接口和其他类型
接口
Go 中的接口提供了一种指定对象行为的方式:如果某物能做这个,那么它可以在这里使用。我们已经看到了一些简单的例子;自定义打印可以通过 String 方法实现,而 Fprintf 可以生成输出到任何实现 Write 方法的对象。Go 代码中只有一两个方法的接口很常见,通常以方法名称衍生命名,例如实现 Write 的 io.Writer。
一个类型可以实现多个接口。例如,如果一个集合实现了 sort.Interface(包含 Len()、Less(i, j int) bool 和 Swap(i, j int)),它可以通过 sort 包中的例程排序,它还可以有自定义格式化器。在这个人为的例子中,Sequence 满足两者:
type Sequence []int
// sort.Interface 所需的方法。
func (s Sequence) Len() int {
return len(s)
}
func (s Sequence) Less(i, j int) bool {
return s[i] < s[j]
}
func (s Sequence) Swap(i, j int) {
s[i], s[j] = s[j], s[i]
}
// Copy 返回 Sequence 的副本。
func (s Sequence) Copy() Sequence {
copy := make(Sequence, 0, len(s))
return append(copy, s...)
}
// 用于打印的方法 - 在打印前对元素排序。
func (s Sequence) String() string {
s = s.Copy() // 制作副本;不覆盖参数。
sort.Sort(s)
str := "["
for i, elem := range s { // 循环是 O(N²);将在下一个例子中修复。
if i > 0 {
str += " "
}
str += fmt.Sprint(elem)
}
return str + "]"
}
转换
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
// 用于打印的方法 - 在打印前对元素排序
func (s Sequence) String() string {
s = s.Copy()
sort.IntSlice(s).Sort()
return fmt.Sprint([]int(s))
}
现在,我们不是让 Sequence 实现多个接口(排序和打印),而是利用数据项可以转换为多种类型(Sequence、sort.IntSlice 和 []int)的能力,每种类型完成部分工作。这种方法在实践中较为少见,但可能有效。
接口转换和类型断言
类型 switch 是一种转换形式:它们接受一个接口,并在 switch 的每个 case 中,在某种意义上将其转换为该 case 的类型。以下是 fmt.Printf 下的代码如何使用类型 switch 将值转换为字符串的简化版本。如果它已经是字符串,我们想要接口持有的实际字符串值,而如果它有 String 方法,我们想要调用该方法的结果:
type Stringer interface {
String() string
}
var value interface{} // 调用者提供的值。
switch str := value.(type) {
case string:
return str
case Stringer:
return str.String()
}
第一个 case 找到具体值;第二个将接口转换为另一个接口。这样混合类型完全没问题。
如果我们只关心一种类型呢?如果我们知道值持有一个字符串,只是想提取它?单 case 类型 switch 可以做到,但类型断言也可以。类型断言从接口值中提取指定明确类型的值。其语法借用了类型 switch 的开场子句,但使用显式类型而不是 type 关键字:
value.(typeName)
结果是一个具有静态类型 typeName 的新值。该类型必须是接口持有的具体类型,或值可以转换到的第二个接口类型。要提取我们知道在值中的字符串,我们可以写:
str := value.(string)
但如果值不包含字符串,程序将因运行时错误而崩溃。为了防止这种情况,使用“逗号,ok”习惯用法安全地测试值是否为字符串:
str, ok := value.(string)
if ok {
fmt.Printf("字符串值是: %q\n", str)
} else {
fmt.Printf("值不是字符串\n")
}
如果类型断言失败,str 仍将存在并为字符串类型,但它将是零值,即空字符串。
作为该功能的说明,以下是一个等价于本节开头的类型 switch 的 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/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 返回一个使用给定 Block 以计数器模式加密/解密的 Stream。
// iv 的长度必须与 Block 的块大小相同。
func NewCTR(block Block, iv []byte) Stream
NewCTR 不仅适用于一种特定的加密算法和数据源,而是适用于任何实现 Block 接口的实现和任何 Stream。因为它们返回接口值,替换 CTR 加密为其他加密模式是局部更改。必须编辑构造函数调用,但由于周围代码必须仅将结果视为 Stream,它不会注意到差异。
接口和方法
由于几乎任何东西都可以附加方法,几乎任何东西都可以满足接口。一个说明性的例子在 http 包中,它定义了 Handler 接口。任何实现 Handler 的对象都可以服务 HTTP 请求:
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
ResponseWriter 本身是一个接口,提供了返回客户端响应所需的方法的访问。这些方法包括标准的 Write 方法,因此 http.ResponseWriter 可以用在任何可以使用 io.Writer 的地方。Request 是一个结构体,包含来自客户端的解析后的请求表示。
为简洁起见,我们忽略 POST 请求,假设 HTTP 请求始终是 GET 请求;这种简化不会影响处理程序的设置方式。以下是一个计算页面访问次数的处理程序的简单实现:
// 简单的计数器服务器。
type Counter struct {
n int
}
func (ctr *Counter) ServeHTTP(w http.ResponseWriter, req *http.Request) {
ctr.n++
fmt.Fprintf(w, "计数器 = %d\n", ctr.n)
}
(继续我们的主题,注意 Fprintf 如何打印到 http.ResponseWriter。)在真实服务器中,对 ctr.n 的访问需要防止并发访问。有关建议,请参见 sync 和 atomic 包。
为参考,以下是如何将这样的服务器附加到 URL 树上的节点:
import "net/http"
...
ctr := new(Counter)
http.Handle("/counter", ctr)
但为什么要将 Counter 做成结构体?只需要一个整数。(接收者需要是指针,以便增量对调用者可见。)
// 更简单的计数器服务器。
type Counter int
func (ctr *Counter) ServeHTTP(w http.ResponseWriter, req *http.Request) {
*ctr++
fmt.Fprintf(w, "计数器 = %d\n", *ctr)
}
如果你的程序有一些内部状态需要在页面被访问时得到通知怎么办?将通道绑定到网页:
// 一个在每次访问时发送通知的通道。
// (可能希望通道是缓冲的。)
type Chan chan *http.Request
func (ch Chan) ServeHTTP(w http.ResponseWriter, req *http.Request) {
ch <- req
fmt.Fprint(w, "通知已发送")
}
最后,假设我们想在 /args 上呈现调用服务器二进制文件时使用的参数。编写一个打印参数的函数很简单:
func ArgServer() {
fmt.Println(os.Args)
}
我们如何将其变成 HTTP 服务器?我们可以使 ArgServer 成为某种类型的方法,其值我们忽略,但有更简洁的方法。由于我们可以为除指针和接口外的任何类型定义方法,我们可以为函数编写方法。http 包包含以下代码:
// HandlerFunc 类型是一个适配器,允许将普通函数用作 HTTP 处理程序。
// 如果 f 是一个具有适当签名的函数,HandlerFunc(f) 是一个调用 f 的 Handler 对象。
type HandlerFunc func(ResponseWriter, *Request)
// ServeHTTP 调用 f(w, req)。
func (f HandlerFunc) ServeHTTP(w ResponseWriter, req *Request) {
f(w, req)
}
HandlerFunc 是一个具有方法 ServeHTTP 的类型,因此该类型的值可以服务 HTTP 请求。看看方法的实现:接收者是一个函数 f,该方法调用 f。这可能看起来很奇怪,但与接收者是通道且方法在通道上发送的情况没有太大不同。
要使 ArgServer 成为 HTTP 服务器,我们首先修改它以具有正确的签名:
// 参数服务器。
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 不存在\n", path)
}
偶尔你会看到为了忽略错误而丢弃错误值的代码;这是糟糕的做法。始终检查错误返回;它们是有原因提供的:
// 错误!如果路径不存在,此代码将崩溃。
fi, _ := os.Stat(path)
if fi.IsDir() {
fmt.Printf("%s 是一个目录\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: 使用 fd。
}
要消除关于未使用导入的投诉,使用空白标识符引用从导入包中引用的符号。类似地,将未使用的变量 fd 分配给空白标识符将消除未使用变量的错误。这个版本的程序可以编译:
package main
import (
"fmt"
"io"
"log"
"os"
)
var _ = fmt.Printf // 用于调试;完成后删除。
var _ io.Reader // 用于调试;完成后删除。
func main() {
fd, err := os.Open("test.go")
if err != nil {
log.Fatal(err)
}
// TODO: 使用 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("值 %v 类型 %T 实现了 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 是组合 Reader 和 Writer 接口的接口。
type ReadWriter interface {
Reader
Writer
}
这正如其表面含义:ReadWriter 可以执行 Reader 和 Writer 的所有操作;它是被嵌入接口的联合。只有接口可以嵌入到接口中。
结构体嵌入
同样的基本思想也适用于结构体,但影响更为深远。bufio 包有两个结构体类型:bufio.Reader 和 bufio.Writer,它们当然分别实现了 io 包中对应的接口。bufio 还实现了一个缓冲的读写器,通过嵌入将读取器和写入器组合到一个结构体中:它在结构体中列出类型,但不给它们字段名称:
// ReadWriter 存储指向 Reader 和 Writer 的指针。
// 它实现了 io.ReadWriter 接口。
type ReadWriter struct {
*Reader // *bufio.Reader
*Writer // *bufio.Writer
}
嵌入的元素是指向结构体的指针,当然在使用前必须初始化为指向有效的结构体。ReadWriter 结构体可以写成:
type ReadWriter struct {
reader *Reader
writer *Writer
}
但为了提升字段的方法并满足 io 接口,我们还需要提供转发方法,如下所示:
func (rw *ReadWriter) Read(p []byte) (n int, err error) {
return rw.reader.Read(p)
}
通过直接嵌入结构体,我们可以避免这种繁琐的记录。嵌入类型的方法会自动附带,这意味着 bufio.ReadWriter 不仅具有 bufio.Reader 和 bufio.Writer 的方法,还满足三个接口:io.Reader、io.Writer 和 io.ReadWriter。
嵌入与子类化的一个重要区别在于:当嵌入一个类型时,该类型的方法成为外部类型的方法,但调用这些方法时,方法的接收者是内部类型,而不是外部类型。在我们的例子中,当调用 bufio.ReadWriter 的 Read 方法时,其效果与上面写出的转发方法完全相同;接收者是 ReadWriter 的 reader 字段,而不是 ReadWriter 本身。
嵌入也可以作为一种简单的便利方式。以下示例展示了一个嵌入字段与常规命名字段并存的情况:
type Job struct {
Command string
*log.Logger
}
现在 Job 类型拥有了 *log.Logger 的 Print、Printf、Println 等方法。当然,我们可以给 Logger 一个字段名称,但这并非必要。现在,一旦初始化,我们就可以记录到 Job:
job.Println("starting now...")
Logger 是 Job 结构体的常规字段,因此我们可以在 Job 的构造函数中以通常的方式初始化它,如下所示:
func NewJob(command string, logger *log.Logger) *Job {
return &Job{command, logger}
}
或者使用复合字面量:
job := &Job{command, log.New(os.Stderr, "Job: ", log.Ldate)}
如果需要直接引用嵌入字段,字段的类型名称(忽略包限定符)可作为字段名称,就像我们在 ReadWriter 结构体的 Read 方法中做的那样。如果需要访问 Job 变量 job 的 *log.Logger,我们会写 job.Logger,这在需要细化 Logger 的方法时很有用:
func (job *Job) Printf(format string, args ...interface{}) {
job.Logger.Printf("%q: %s", job.Command, fmt.Sprintf(format, args...))
}
名称冲突
嵌入类型会引入名称冲突的问题,但解决规则很简单。首先,字段或方法 X 会隐藏类型中更深层嵌套部分的任何其他 X 项目。如果 log.Logger 包含一个名为 Command 的字段或方法,Job 的 Command 字段将覆盖它。
其次,如果同一嵌套级别出现相同的名称,通常会报错;如果 Job 结构体包含另一个名为 Logger 的字段或方法,嵌入 log.Logger 将是错误的。然而,如果程序在类型定义之外从不提及重复名称,则没有问题。这种限定为外部嵌入类型的更改提供了一些保护;如果添加了一个与另一个子类型中的字段冲突的字段,只要两个字段都不被使用,就没有问题。
并发
通过通信共享
并发编程是一个大话题,这里仅介绍一些 Go 特有的亮点。
在许多环境中,并发编程因实现共享变量的正确访问所需的微妙细节而变得复杂。Go 鼓励一种不同的方法,即在通道上传递共享值,实际上,单独的执行线程从不主动共享值。任何时候只有一个 goroutine 能访问该值。通过设计,数据竞争不会发生。为了鼓励这种思维方式,我们将其简化为一句口号:
不要通过共享内存来通信;相反,通过通信来共享内存。
这种方法可能会走得太远。例如,引用计数可能最好通过在整数变量周围加互斥锁来实现。但作为一种高级方法,使用通道来控制访问可以更容易编写清晰、正确的程序。
一种思考这种模型的方式是考虑一个运行在单 CPU 上的典型单线程程序。它不需要同步原语。现在再运行一个这样的实例;它也不需要同步。现在让这两者通信;如果通信是同步器,就不需要其他同步。例如,Unix 管道完美契合这种模型。尽管 Go 的并发方法起源于 Hoare 的通信顺序进程(CSP),它也可以看作是 Unix 管道的类型安全泛化。
Goroutines
它们被称为 goroutines,因为现有的术语——线程、协程、进程等——传达了不准确的含义。goroutine 是一个简单的模型:它是一个与同一地址空间中其他 goroutine 并发执行的函数。它是轻量级的,仅比分配堆栈空间多一点成本。堆栈开始时很小,因此成本低廉,并通过按需分配(和释放)堆存储来增长。
goroutines 被多路复用到多个操作系统线程上,因此如果一个 goroutine 阻塞(例如在等待 I/O 时),其他 goroutine 可以继续运行。其设计隐藏了线程创建和管理的许多复杂性。
在函数或方法调用前加上 go 关键字,可以在新 goroutine 中运行该调用。当调用完成时,goroutine 会默默退出。(效果类似于 Unix shell 中用于后台运行命令的 & 符号。)
go list.Sort() // 并发运行 list.Sort;不等待它完成。
在 goroutine 调用中,函数字面量很方便:
func Announce(message string, delay time.Duration) {
go func() {
time.Sleep(delay)
fmt.Println(message)
}() // 注意括号 - 必须调用函数。
}
在 Go 中,函数字面量是闭包:实现确保函数引用的变量在函数活跃期间始终存活。
这些例子不太实用,因为函数无法信号完成。为此,我们需要通道。
通道
与映射类似,通道使用 make 分配,结果值是对底层数据结构的引用。如果提供了可选的整数参数,它会设置通道的缓冲区大小。默认值为零,表示无缓冲或同步通道。
ci := make(chan int) // 无缓冲的整数通道
cj := make(chan int, 0) // 无缓冲的整数通道
cs := make(chan *os.File, 100) // 缓冲的文件指针通道
无缓冲通道结合了通信(值的交换)和同步(保证两个计算(goroutines)处于已知状态)。
通道有很多优雅的习惯用法。我们先来看一个。在前一节中,我们在后台启动了一个排序。通道可以让启动的 goroutine 等待排序完成:
c := make(chan int) // 分配一个通道。
// 在 goroutine 中启动排序;完成后在通道上发送信号。
go func() {
list.Sort()
c <- 1 // 发送信号;值无关紧要。
}()
doSomethingForAWhile()
<-c // 等待排序完成;丢弃发送的值。
接收者总是阻塞直到有数据可接收。如果通道无缓冲,发送者会阻塞直到接收者收到值。如果通道有缓冲区,发送者仅阻塞直到值被复制到缓冲区;如果缓冲区已满,则意味着等待某个接收者检索值。
缓冲通道可以用作信号量,例如限制吞吐量。在以下示例中,传入的请求被传递给 handle,它将值发送到通道,处理请求,然后从通道接收值以准备“信号量”以供下一个消费者使用。通道缓冲区的容量限制了同时调用 process 的数量:
var sem = make(chan int, MaxOutstanding)
func handle(r *Request) {
sem <- 1 // 等待活动队列清空。
process(r) // 可能需要很长时间。
<-sem // 完成;允许下一个请求运行。
}
func Serve(queue chan *Request) {
for {
req := <-queue
go handle(req) // 不等待 handle 完成。
}
}
一旦有 MaxOutstanding 个处理程序在执行 process,更多的处理程序将阻塞尝试发送到已满的通道缓冲区,直到现有处理程序完成并从缓冲区接收为止。
不过,这个设计有一个问题:Serve 为每个传入的请求创建一个新的 goroutine,即使同一时刻只有 MaxOutstanding 个可以运行。如果请求来得太快,程序可能消耗无限资源。我们可以通过更改 Serve 来限制 goroutine 的创建来解决这一缺陷:
func Serve(queue chan *Request) {
for req := range queue {
sem <- 1
go func() {
process(req)
<-sem
}()
}
}
(注意:在 Go 1.22 之前的版本中,此代码有一个错误:循环变量在所有 goroutine 中共享。详情见 Go 维基。)
另一种有效管理资源的方法是启动固定数量的 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) {
// 启动处理程序
for i := 0; i < MaxOutstanding; i++ {
go handle(clientRequests)
}
<-quit // 等待退出指令。
}
通道的通道
Go 的一个重要特性是通道是第一等值,可以像其他值一样分配和传递。这种属性的常见用途是实现安全的并行解复用。
在上节的例子中,handle 是一个理想化的请求处理程序,但我们没有定义它处理的类型。如果该类型包含一个用于回复的通道,每个客户端可以提供自己的答案路径。以下是 Request 类型的示意定义:
type Request struct {
args []int
f func([]int) int
resultChan chan int
}
客户端提供一个函数及其参数,以及请求对象中的通道,用于接收答案:
func sum(a []int) (s int) {
for _, v := range a {
s += v
}
return
}
request := &Request{[]int{3, 4, 5}, sum, make(chan int)}
// 发送请求
clientRequests <- request
// 等待响应。
fmt.Printf("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
// 对 v[i], v[i+1] ... 直到 v[n-1] 应用操作。
func (v Vector) DoSome(i, n int, u Vector, c chan int) {
for ; i < n; i++ {
v[i] += u.Op(v[i])
}
c <- 1 // 信号表示此片段完成
}
我们在一个循环中独立启动每个片段,每个 CPU 一个。它们可以以任意顺序完成,但这无关紧要;我们在启动所有 goroutine 后通过清空通道来计数完成信号:
const numCPU = 4 // CPU 核心数
func (v Vector) DoAll(u Vector) {
c := make(chan int, numCPU) // 缓冲可选但合理。
for i := 0; i < numCPU; i++ {
go v.DoSome(i*len(v)/numCPU, (i+1)*len(v)/numCPU, u, c)
}
// 清空通道。
for i := 0; i < numCPU; i++ {
<-c // 等待一个任务完成
}
// 全部完成。
}
与其为 numCPU 创建一个常量值,我们可以询问运行时合适的值。函数 runtime.NumCPU 返回机器的硬件 CPU 核心数,因此我们可以写:
var numCPU = runtime.NumCPU()
还有一个函数 runtime.GOMAXPROCS,报告(或设置)用户指定的 Go 程序可以同时运行的核心数。它默认为 runtime.NumCPU 的值,但可以通过设置同名的 shell 环境变量或调用函数并传入正数来覆盖。调用它并传入零仅查询值。因此,如果我们想尊重用户的资源请求,应写:
var numCPU = runtime.GOMAXPROCS(0)
不要混淆并发(将程序结构化为独立执行的组件)和并行(在多个 CPU 上并行执行计算以提高效率)的概念。尽管 Go 的并发特性可以使某些问题容易结构化为并行计算,但 Go 是一门并发语言,而非并行语言,并非所有并行化问题都适合 Go 的模型。有关区别的讨论,请参见此博客文章中引用的演讲。
漏桶缓冲区
并发编程的工具甚至可以使非并发的想法更容易表达。以下是从一个 RPC 包中抽象出的例子。客户端 goroutine 循环从某个源(可能是网络)接收数据。为避免分配和释放缓冲区,它维护一个空闲列表,并使用缓冲通道表示。如果通道为空,则分配一个新缓冲区。一旦消息缓冲区准备好,就通过 serverChan 发送到服务器:
var freeList = make(chan *Buffer, 100)
var serverChan = make(chan *Buffer)
func client() {
for {
var b *Buffer
// 如果有可用缓冲区则获取;否则分配一个新的。
select {
case b = <-freeList:
// 获取一个;无需进一步操作。
default:
// 无空闲缓冲区,分配一个新的。
b = new(Buffer)
}
load(b) // 从网络读取下一条消息。
serverChan <- b // 发送到服务器。
}
}
服务器循环从客户端接收每条消息,处理它,并将缓冲区返回到空闲列表:
func server() {
for {
b := <-serverChan // 等待工作。
process(b)
// 如果有空间,重用缓冲区。
select {
case freeList <- b:
// 缓冲区回到空闲列表;无需进一步操作。
default:
// 空闲列表已满,继续。
}
}
}
客户端尝试从 freeList 获取缓冲区;如果没有可用缓冲区,则分配一个新的。服务器将 b 发送到 freeList 以放回空闲列表,除非列表已满,此时缓冲区将被丢弃,由垃圾回收器回收。(select 语句中的 default 子句在没有其他 case 准备好时执行,意味着 select 永不阻塞。)此实现仅用几行代码就构建了一个漏桶式空闲列表,依赖于缓冲通道和垃圾回收器进行记录。
