🔨

Effective Go(实效 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 提交一个 bug),而不是绕过它。
举个例子,完全没有必要花时间对齐结构体字段上的注释。gofmt 会为你处理这些。给定以下声明:
gofmt 会对齐列:
所有标准包中的 Go 代码都已使用 gofmt 格式化。
一些格式化细节仍然存在。简要说明如下:
缩进
我们使用制表符进行缩进,gofmt 默认会输出制表符。仅在必要时使用空格。
行长度
Go 没有行长度限制。不要担心超出打孔卡片的长度。如果一行感觉太长,可以换行并多缩进一个制表符。
括号
Go 需要的括号比 C 和 Java 少:控制结构(如 if、for、switch)的语法中没有括号。此外,运算符优先级层次更短且更清晰,因此
这意味着空格所表示的含义,与其他语言不同。

注释

Go 提供了 C 风格的 /* */ 块注释和 C++ 风格的 // 行注释。行注释是常见的用法;块注释主要用于包注释,但在表达式内或禁用大段代码时也很有用。
出现在顶级声明之前且没有中间换行的注释被视为对该声明的文档。这些“文档注释”是特定 Go 包或命令的主要文档。有关文档注释的更多信息,请参见“Go 文档注释”。

名称

在 Go 中,名称与其他语言一样重要。它们甚至具有语义效果:名称在包外的可见性取决于其首字符是否为大写。因此,花一点时间讨论 Go 程序中的命名约定是值得的。

包名称

当一个包被导入时,包名成为访问其内容的方式。
导入的包可以使用 bytes.Buffer。确保使用相同名称来引用包的内容是有帮助的,这意味着包名应该简短、简洁且富有表现力。根据约定,包名应为小写单词,不应使用下划线或混合大小写。尽量简短,因为使用你包的每个人都会输入这个名称。无需担心名称冲突,因为包名只是导入的默认名称;它不需要在所有源代码中唯一,在出现冲突的罕见情况下,导入包可以选择不同的本地名称。无论如何,由于导入的文件名决定了使用的是哪个包,因此混淆很少发生。
另一项约定是包名是其源目录的基本名称;例如,src/encoding/base64 中的包被导入为 "encoding/base64",但其名称为 base64,而不是 encoding_base64 或 encodingBase64

导入者的命名

包的导入者将使用名称来引用其内容,因此包中的导出名称可以利用这一点以避免重复。请勿使用 import . 语法,虽然它可以简化必须在测试包外运行的测试,但通常应避免使用。例如,bufio 包中的缓冲读取器类型称为 Reader,而不是 BufReader,因为用户看到的是 bufio.Reader,这是一个清晰简洁的名称。此外,由于导入的实体始终通过其包名进行访问,bufio.Reader 不会与 io.Reader 冲突。同样,创建 ring.Ring 新实例的函数,通常会称为 NewRing,但由于 Ring 是包中唯一导出的类型,且包名为 ring,因此它被称为 New,客户端看到的是 ring.New。利用包结构来帮助你选择好的名称。
一个简短的例子是 once.Doonce.Do(setup) 读起来很好,而写成 once.DoOrWaitUntilDone(setup 并不会改善可读性。长名称并不总是能提高可读性;一个有帮助的文档注释往往比额外的长名称更有价值。

Getter

Go 不提供自动支持的 getter 和 setter。自己提供 getter 和 setter 是没有问题的,通常也是合适的,但在命名时不需要在 getter 的名称中加上 Get。如果你有一个名为 owner(小写,未导出)的字段,则 getter 方法应称为 Owner(大写,导出),而不是 GetOwner。使用大写名称进行导出提供了区分字段和方法的依据。如果需要 setter 函数,通常会称为 SetOwner。这两个名称在实践中读起来都很好。

接口名称

根据约定,单方法接口的命名方式是将方法名加上后缀 -er 或类似的修改,以构造出代理名词,例如 ReaderWriterFormatterCloseNotifier 等。
有许多这样的名称,遵循这些名称及其所捕获的函数名称是很有益的。ReadWriteCloseFlushString 等都有标准的签名和含义。为了避免混淆,除非你的方法具有相同的签名和含义,否则不要给你的方法使用这些名称。相反,如果你的类型实现了与已知类型中的方法相同含义的方法,应该使用相同的名称和签名;例如,将字符串转换的方法命名为 String 而不是 ToString

MixedCaps

最后,Go 中的约定是使用 MixedCaps 或 mixedCaps 来书写多词名称,而不是使用下划线。

分号

与 C 语言类似,Go 的正式语法使用分号来终止语句,但与 C 不同的是,这些分号在源代码中并不出现。相反,词法分析器使用简单规则在扫描时自动插入分号,因此输入文本大多不包含分号。
规则如下:如果在换行符之前的最后一个令牌是标识符(包括像 int 和 float64 这样的词)、基本字面量(如数字或字符串常量),或某些特定的令牌,那么在该位置将自动插入一个分号。
词法分析器总是在令牌后插入分号。这可以总结为:“如果换行符出现在一个可能结束语句的令牌后,则插入一个分号。”
分号在闭合大括号前也可以省略,因此像这样的语句:
不需要分号。惯用的 Go 程序中,分号只出现在 for 循环的条件部分,用于分隔初始化器、条件和继续元素。如果你在一行中写多个语句,分号也是必要的。
分号插入规则的一个结果是,你不能将控制结构(如 ifforswitch 或 select)的开括号放在下一行。如果这样做,括号前会插入一个分号,这可能导致意想不到的效果。应这样书写:
不应这样书写:

控制结构

Go 的控制结构与 C 语言相关,但在重要方面有所不同。没有 do 或 while 循环,只有稍微通用的 for 循环;switch 更灵活;if 和 switch 接受类似于 for 的可选初始化语句;break 和 continue 语句可以带可选标签,以标识要跳出或继续的部分;此外,还有新的控制结构,包括类型开关和多路通信复用器 select。语法上也略有不同:没有括号,且代码块必须始终用大括号界定。

if

在 Go 中,一个简单的 if 语句如下所示:
强制使用大括号鼓励将简单的 if 语句写成多行。这样写风格较好,尤其是当代码块包含控制语句(如 returnbreak)时。
由于 ifswitch 接受初始化语句,因此常常可以看到用来设置局部变量的示例。
在 Go 的标准库中,当 if 语句的主体没有流向下一条语句时(即主体以 breakcontinuegotoreturn 结束),通常会省略不必要的 else
这是一个常见的情况,代码必须防范一系列错误条件。当成功的控制流沿着页面向下运行时,代码的可读性更高,随着错误情况的出现,错误分支会被逐步消除。由于错误情况通常以 return 语句结束,因此结果代码不需要 else 语句。

重新声明和重新赋值

顺便提一下,上一节中的最后一个示例演示了 := 短声明形式的细节。调用 os.Open 的声明如下:
这条语句声明了两个变量,ferr。几行后,调用 f.Stat 的语句如下:
这看起来像是声明了 derr。但是注意,err 在两个语句中都出现了。这种重复是合法的:err 在第一条语句中被声明,但在第二条语句中只是被重新赋值。这意味着调用 f.Stat 使用的是上面声明的现有 err 变量,并且只是给它赋了一个新值。
:= 声明中,即使变量 v 已经声明,也可以再次出现,只要满足以下条件:
  • 该声明与现有的 v 声明在同一作用域内(如果 v 已在外部作用域中声明,则该声明会创建一个新变量),
  • 初始化中的对应值可以赋值给 v,并且
  • 该声明创建了至少一个其他变量。
这种不寻常的特性出于实用考虑,使得在长的 if-else 链中使用单一的 err 值变得容易。你会经常看到这种用法。
值得注意的是,在 Go 中,函数参数和返回值的作用域与函数体相同,尽管它们在语法上出现在包含函数体的大括号之外。

for

Go 的 for 循环与 C 的相似,但并不相同。它统一了 forwhile,并且没有 do-while。有三种形式,只有一种形式带有分号。
短声明使得在循环中直接声明索引变量变得容易。
如果你正在遍历一个数组、切片、字符串或映射,或者从通道中读取数据,可以使用 range 子句来管理循环。
如果你只需要范围中的第一个项(键或索引),可以省略第二项:
如果你只需要范围中的第二项(值),可以使用空白标识符(下划线)来丢弃第一项:
空白标识符有许多用途,稍后会详细介绍。
对于字符串,range 会为你做更多的工作,通过解析 UTF-8 来分解单个 Unicode 码点。错误的编码会消耗一个字节并产生替代符号 U+FFFD。以下循环:
将输出:
最后,Go 没有逗号运算符,++-- 是语句而不是表达式。因此,如果你想在 for 循环中运行多个变量,应该使用并行赋值(尽管这会排除 ++--)。

switch

Go 的 switch 比 C 的更通用。表达式不必是常量或整数,案例从上到下评估,直到找到匹配项。如果 switch 没有表达式,它将基于 true 进行切换。因此,可以将 if-else-if-else 链写成 switch
没有自动的贯穿,但可以用逗号分隔的列表呈现案例。
尽管在 Go 中不如某些其他 C 类语言常见,但可以使用 break 语句提前终止 switch。有时,有必要跳出外层循环而不是 switch,在 Go 中,可以通过在循环上放置标签并“跳转”到该标签来实现。这段示例展示了两种用法。
当然,continue 语句也接受可选标签,但仅适用于循环。
最后,这里有一个比较字节切片的例程,使用了两个 switch 语句:

类型开关

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

函数

多个返回值

Go 的一个不寻常的特性是函数和方法可以返回多个值。这种形式可以改善 C 程序中的一些笨拙习惯,例如使用 -1 表示 EOF 的带内错误返回以及通过地址修改传递的参数。
在 C 中,写入错误通过负计数信号,错误代码被隐藏在一个易变的位置。Go 中的 Write 可以同时返回计数错误:“是的,你写了一些字节,但不是全部,因为你填满了设备”。来自 os 包的 Write 方法的签名是:
如文档所述,它返回写入的字节数和一个非零的 error,当 n 不等于 len(b) 时。这是一种常见的风格,错误处理部分会有更多示例。
类似的做法消除了需要传递指针到返回值以模拟引用参数的需求。以下是一个简单的函数,用于从字节切片中的某个位置获取数字,返回该数字及下一个位置。
你可以像这样使用它来扫描输入切片 b 中的数字:

命名结果参数

Go 函数的返回或结果“参数”可以命名,并可以像普通变量一样使用。当命名时,它们在函数开始时初始化为其类型的零值;如果函数执行没有参数的 return 语句,则当前的结果参数值将作为返回值使用。
这些名称不是强制性的,但可以使代码更简洁明了:它们也是文档。如果我们为 nextInt 命名结果,哪个返回的 int 是哪个就变得显而易见。
因为命名结果在初始化时被绑定到无装饰的返回中,所以它们可以简化并澄清。以下是一个使用它们的 io.ReadFull 的版本:

defer

Go 的 defer 语句会调度一个函数调用(被延迟的函数),在执行 defer 的函数返回之前立即运行。这是一种独特但有效的处理方式,例如在函数返回的过程中必须释放的资源。经典的例子包括解锁互斥锁或关闭文件。
延迟调用像 Close 这样的函数有两个优点。首先,它保证你不会忘记关闭文件,这在稍后编辑函数以添加新返回路径时是很容易犯的错误。其次,它意味着关闭操作与打开操作紧密相连,这比将其放在函数末尾要清晰得多。
延迟函数的参数(如果函数是方法,则包括接收者)在defer 执行时被评估,而不是在调用 执行时被评估。除了避免担心变量在函数执行过程中值的变化外,这意味着单个延迟调用位置可以延迟多个函数执行。以下是一个简单的例子。
延迟的函数以 LIFO 顺序执行,因此这段代码将在函数返回时打印 4 3 2 1 0。一个更合理的例子是跟踪程序中的函数执行。我们可以像这样写几个简单的跟踪例程:
我们可以更好地利用延迟函数参数在执行时被评估的事实。跟踪例程可以设置未跟踪例程的参数。这段示例:
将输出:
对于习惯于其他语言中的块级资源管理的程序员来说,defer 可能显得奇怪,但它最有趣和强大的应用恰恰来自于它不是基于块而是基于函数的特性。在关于 panicrecover 的部分中,我们将看到它的另一种可能性。

数据

使用 new 进行分配

Go 有两个分配原语,内置函数 newmake。它们的用途不同,适用于不同的类型,这可能会让人困惑,但规则很简单。首先讨论 new。它是一个内置函数,用于分配内存,但与某些其他语言中的同名函数不同,它并不初始化内存,只是清零它。也就是说,new(T) 为类型 T 的新项分配零值存储并返回其地址,返回值的类型为 *T。在 Go 的术语中,它返回一个指向新分配的类型 T 的零值的指针。
由于 new 返回的内存是零值,因此在设计数据结构时,最好安排每种类型的零值可以在不进一步初始化的情况下使用。这意味着数据结构的用户可以使用 new 创建一个实例并立即开始工作。例如,bytes.Buffer 的文档中指出:“Buffer 的零值是一个准备使用的空缓冲区。”类似地,sync.Mutex 没有显式构造函数或 Init 方法。相反,sync.Mutex 的零值被定义为未锁定的互斥锁。
零值可用性属性是可传递的。考虑以下类型声明。
SyncedBuffer 类型的值在分配或声明后可以立即使用。以下代码片段中,pv 都可以正常工作,而无需进一步的初始化。

构造函数和复合字面量

有时,零值并不足够,这时需要一个初始化构造函数,例如来自 os 包的示例。
这里有很多样板代码。我们可以使用 复合字面量 来简化它,这是一种每次评估时都会创建新实例的表达式。
请注意,与 C 不同,返回局部变量的地址是完全可以的;与变量关联的存储在函数返回后仍然存在。实际上,获取复合字面量的地址每次评估时都会分配一个新实例,因此我们可以将最后两行合并。
复合字面量的字段按顺序排列,必须全部存在。然而,通过将元素显式标记为 field:value 对,初始化器可以以任何顺序出现,缺失的字段将保留其相应的零值。因此,我们可以这样写:
作为限制情况,如果复合字面量不包含任何字段,它将为该类型创建一个零值。表达式 new(File)&File{} 是等价的。
复合字面量也可以用于数组、切片和映射,字段标签作为索引或映射键。以下示例中,初始化在 EnoneEioEinval 的值无关紧要,只要它们是不同的。

使用 make 进行分配

回到分配。内置函数 make(T, *args*) 的用途与 new(T) 不同。它仅创建切片、映射和通道,并返回一个 初始化的(而不是 零值的)类型 T 的值(而不是 *T)。这种区别的原因在于,这三种类型在内部代表必须初始化的数据结构的引用。切片,例如,是一个包含指向数据(数组内部)、长度和容量的三项描述符,直到这些项被初始化,切片为 nil。对于切片、映射和通道,make 初始化内部数据结构并准备值以供使用。例如:
这会分配一个包含 100 个整数的数组,然后创建一个切片结构,长度为 10,容量为 100,指向数组的前 10 个元素。(在创建切片时,可以省略容量;有关更多信息,请参见切片部分。)相比之下,new([]int) 返回一个指向新分配的零值切片结构的指针,即指向一个 nil 切片值的指针。
以下示例说明了 newmake 之间的区别。
请记住,make 仅适用于映射、切片和通道,并且不返回指针。要获取显式指针,可以使用 new 或显式取变量的地址。

数组

数组在规划内存的详细布局时非常有用,有时可以帮助避免分配,但主要是切片的构建块,切片将在下一节中讨论。为了为该主题奠定基础,以下是关于数组的一些内容。
Go 中数组的工作方式与 C 有重大差异。在 Go 中:
  • 数组是值。将一个数组赋值给另一个数组会复制所有元素。
  • 特别是,如果将数组传递给函数,它将接收一个 副本,而不是指向它的指针。
  • 数组的大小是其类型的一部分。类型 [10]int[20]int 是不同的。
值属性可能有用,但也可能很昂贵;如果你想要类似 C 的行为和效率,可以传递指向数组的指针。
但即使这种风格也不是 Go 的习惯用法。使用切片更为合适。

切片

切片包装数组,为数据序列提供更通用、更强大和更方便的接口。除非是具有显式维度的项目(例如变换矩阵),否则在 Go 中大多数数组编程都是使用切片而不是简单数组。
切片持有对底层数组的引用,如果将一个切片赋值给另一个切片,两个切片将引用同一个数组。如果一个函数接受切片参数,对切片元素的更改将对调用者可见,类似于传递指向底层数组的指针。File 类型的 Read 方法签名如下:
该方法返回读取的字节数和错误值(如果有)。要读取更大缓冲区 buf 的前 32 个字节,可以对缓冲区进行切片。
这种切片操作是常见且高效的。实际上,暂时不考虑效率,以下代码片段也会读取缓冲区的前 32 个字节。
切片的长度可以在仍然适合底层数组的限制内进行更改;只需将其赋值为自身的切片。切片的 容量 可以通过内置函数 cap 访问,报告切片可以假设的最大长度。以下是一个将数据附加到切片的函数。如果数据超出容量,切片会重新分配。返回的切片如下所示:
我们必须在之后返回切片,因为尽管 Append 可以修改 slice 的元素,但切片本身(运行时数据结构,包含指针、长度和容量)是按值传递的。
附加到切片的概念非常有用,因此被内置函数 append 捕获。为了理解该函数的设计,我们需要一些额外的信息,因此我们稍后会回到这个主题。

二维切片

Go 的数组和切片是一维的。要创建等效的 2D 数组或切片,必须定义数组的数组或切片的切片,如下所示:
由于切片是可变长度的,因此每个内部切片的长度可以不同。这在我们的 LinesOfText 示例中是常见的:每一行的长度独立。
有时需要分配 2D 切片,这种情况可能在处理像素的扫描行时出现。实现这一点有两种方法。一种是独立分配每个切片;另一种是分配一个单一数组并将每个切片指向其中。使用哪种方法取决于你的应用。如果切片可能增长或缩小,则应独立分配以避免覆盖下一行;如果不是,使用单次分配构造对象可能更有效。以下是两种方法的示例。首先是逐行分配:
现在作为一次分配,切片成行:

映射

映射是一种方便而强大的内置数据结构,将一种类型()的值与另一种类型(元素)的值关联。键可以是任何定义了相等操作符的类型,例如整数、浮点数和复数、字符串、指针、接口(只要动态类型支持相等)、结构体和数组。切片不能用作映射键,因为未定义相等性。与切片一样,映射持有对底层数据结构的引用。如果将映射传递给更改映射内容的函数,所做的更改将在调用者中可见。
映射可以使用通常的复合字面量语法构造,使用冒号分隔的键值对,因此在初始化时很容易构建它们。
赋值和获取映射值的语法与对数组和切片的操作类似,只是索引不需要是整数。
尝试使用不在映射中的键获取映射值将返回该类型条目的零值。例如,如果映射包含整数,则查找不存在的键将返回 0。可以通过将映射的值类型设置为 bool 来实现集合。将映射条目设置为 true 以将值放入集合,然后通过简单的索引测试它。
有时需要区分缺失条目和零值。"UTC" 是否存在,还是因为它根本不在映射中而为 0?可以通过一种多重赋值的形式进行区分。
出于明显的原因,这被称为“逗号 ok”习惯用法。在此示例中,如果 tz 存在,seconds 将被适当地设置,ok 将为 true;如果不存在,seconds 将被设置为零,ok 将为 false。以下是一个将其与良好的错误报告结合在一起的函数:
要在不担心实际值的情况下测试映射中的存在,可以在值的常规变量位置使用 空白标识符 (_)。
要删除映射条目,请使用内置函数 delete,其参数是要删除的映射和键。即使键已经缺失,执行此操作也是安全的。

打印

Go 中的格式化打印使用了一种类似于 C 的 printf 家族的风格,但更加丰富和通用。这些函数位于 fmt 包中,名称以大写字母开头:fmt.Printffmt.Fprintffmt.Sprintf 等等。字符串函数(如 Sprintf 等)返回一个字符串,而不是填充提供的缓冲区。
你不需要提供格式字符串。对于 PrintfFprintfSprintf,还有另一对函数,例如 PrintPrintln。这些函数不接受格式字符串,而是为每个参数生成默认格式。Println 版本在参数之间插入空格并在输出末尾附加换行符,而 Print 版本仅在两侧的操作数都不是字符串时添加空格。以下示例中的每一行都产生相同的输出。
格式化打印函数 fmt.Fprint 等的第一个参数可以是任何实现了 io.Writer 接口的对象;变量 os.Stdoutos.Stderr 是熟悉的实例。
这里的实现与 C 开始有所不同。首先,数字格式(如 %d)不接受符号或大小标志;相反,打印例程使用参数的类型来决定这些属性。
输出为:
如果你只想要默认转换(例如整数的十进制),可以使用通用格式 %v(表示“值”);结果与 PrintPrintln 的输出完全相同。此外,该格式可以打印 任何 值,甚至是数组、切片、结构体和映射。以下是打印前面定义的时区映射的示例。
输出为:
对于映射,Printf 和其他函数按键的字母顺序对输出进行排序。
打印结构体时,修改后的格式 %+v 会用字段名称注释结构体的字段,而对于任何值,备用格式 %#v 会以完整的 Go 语法打印该值。
输出为:
(注意前面的 & 符号。)该引用字符串格式也可以通过 %q 应用于 string[]byte 类型的值。如果可能,备用格式 %#q 将使用反引号。%q 格式也适用于整数和字符,生成单引号的字符常量。还有一个方便的格式 %T,可以打印值的 类型
输出为:
如果你想控制自定义类型的默认格式,只需在该类型上定义一个签名为 String() string 的方法。对于我们的简单类型 T,可以这样定义:
将以以下格式打印:
(如果需要同时打印 T 类型的值和指针,则 String 的接收者必须是值类型;本示例使用指针是因为这对结构体类型更高效且符合习惯用法。有关更多信息,请参见下面的 指针与值接收者 部分。)
我们的 String 方法能够调用 Sprintf,因为打印例程是完全可重入的,可以以这种方式包装。然而,需要理解这个方法的一个重要细节:不要通过调用 Sprintf 的方式构造 String 方法,以至于无限递归。这种情况可能发生在 Sprintf 调用尝试直接打印接收者作为字符串时,这将再次调用该方法。这个常见且容易犯的错误示例如下。
修复起来也很简单:将参数转换为基本字符串类型,因为它没有该方法。
初始化部分 中,我们将看到另一种避免这种递归的技术。
另一种打印技术是将打印例程的参数直接传递给另一种打印例程。Printf 的签名使用类型 ...interface{} 作为最后一个参数,以指定可以在格式之后出现的任意数量的参数(任意类型)。
在函数 Printf 内,v 充当类型 []interface{} 的变量,但如果将其传递给另一个可变参数函数,它将表现得像一个常规参数列表。以下是我们之前使用的 log.Println 函数的实现。它将其参数直接传递给 fmt.Sprintln 进行实际格式化。
我们在嵌套调用 Sprintln 时写 ...,以告诉编译器将 v 视为参数列表;否则它将仅将 v 作为单个切片参数传递。
打印的内容远不止于此。请查阅 fmt 包的 godoc 文档以获取详细信息。
顺便说一句,... 参数可以是特定类型,例如 ...int,用于选择最小值的最小函数:

附加

现在我们有了需要解释 append 内置函数设计的缺失部分。append 的签名与我们上面定义的自定义 Append 函数不同。从结构上讲,它像这样:
其中 T 是任意给定类型的占位符。实际上,你不能编写一个 Go 函数,其中类型 T 是由调用者确定的。这就是 append 需要编译器支持的原因。
append 的作用是将元素附加到切片的末尾并返回结果。需要返回结果,因为与我们手动编写的 Append 一样,底层数组可能会改变。这个简单示例:
打印出 [1 2 3 4 5 6]。因此,append 的工作方式有点像 Printf,收集任意数量的参数。
但如果我们想要实现我们的 Append 函数,并将一个切片附加到另一个切片呢?很简单:在调用位置使用 ...,正如我们在对 Output 的调用中所做的。以下代码片段产生与上面相同的输出。
如果没有 ...,它将无法编译,因为类型不匹配;y 不是 int 类型。

初始化

尽管在表面上看起来与 C 或 C++ 中的初始化没有太大不同,但 Go 中的初始化更为强大。可以在初始化期间构建复杂结构,并且在初始化对象之间的顺序问题(即使在不同包之间)也会得到正确处理。

常量

Go 中的常量就是常量。它们在编译时创建,即使在函数中作为局部变量定义,也只能是数字、字符(符号)、字符串或布尔值。由于编译时的限制,定义它们的表达式必须是常量表达式,编译器能够计算。例如,1<<3 是一个常量表达式,而 math.Sin(math.Pi/4) 不是,因为对 math.Sin 的函数调用需要在运行时发生。
在 Go 中,枚举常量是通过 iota 计数器创建的。由于 iota 可以是表达式的一部分,并且表达式可以隐式重复,因此很容易构建复杂的值集合。
可以将 String 方法附加到任何用户定义类型,使得任意值能够自动格式化自己以便于打印。虽然通常会应用于结构体,但这种技术对于标量类型(如浮点类型 ByteSize)也很有用。
表达式 YB 打印为 1.00YB,而 ByteSize(1e13) 打印为 9.09TB
在这里使用 Sprintf 来实现 ByteSizeString 方法是安全的(避免无限递归),不是因为转换,而是因为它使用 %f,这不是字符串格式:Sprintf 仅在需要字符串时调用 String 方法,而 %f 需要浮点值。

变量

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

init 函数

最后,每个源文件可以定义自己的无参 init 函数,以设置所需的状态。(实际上,每个文件可以有多个 init 函数。)而且“最后”意味着最后:init 在包中的所有变量声明评估其初始化器之后调用,而这些初始化器只有在所有导入的包初始化之后才会被评估。
除了无法表达为声明的初始化之外,init 函数的一个常见用途是在实际执行开始之前验证或修复程序状态的正确性。

方法

指针与值

正如我们在 ByteSize 中看到的,方法可以为任何命名类型定义(指针或接口除外);接收者不必是结构体。
在上面关于切片的讨论中,我们编写了一个 Append 函数。我们可以将其定义为切片上的方法。为此,我们首先声明一个命名类型,以便我们可以将方法绑定到该类型,然后将方法的接收者定义为该类型的值。
这仍然要求方法返回更新后的切片。我们可以通过将方法的接收者重新定义为指向 ByteSlice指针 来消除这种笨拙,因此该方法可以覆盖调用者的切片。
实际上,我们可以做得更好。如果我们将函数修改为看起来像标准的 Write 方法,如下所示:
那么类型 *ByteSlice 满足标准接口 io.Writer,这非常方便。例如,我们可以将内容打印到其中。
我们传递一个 ByteSlice 的地址,因为只有 *ByteSlice 满足 io.Writer。关于接收者的指针与值的规则是,值方法可以在指针和值上调用,但指针方法只能在指针上调用。
这个规则的产生是因为指针方法可以修改接收者;在值上调用它们会导致方法接收值的副本,因此任何修改都会被丢弃。因此,语言禁止这种错误。不过,有一个方便的例外。当值是可寻址时,语言会通过自动插入地址操作符来处理在值上调用指针方法的常见情况。在我们的示例中,变量 b 是可寻址的,因此我们可以仅使用 b.Write 调用它的 Write 方法。编译器会自动将其重写为 (&b).Write
顺便提一句,在字节切片上使用 Write 的想法是 bytes.Buffer 实现的核心。

接口与其他类型

接口

Go 中的接口提供了一种指定对象行为的方式:如果某个东西可以做 这个,那么它就可以在 这里 使用。我们已经看到了一些简单的例子;自定义打印器可以通过 String 方法实现,而 Fprintf 可以将输出生成到任何实现了 Write 方法的对象上。只有一个或两个方法的接口在 Go 代码中很常见,通常会根据方法的名称给接口命名,例如 io.Writer 表示实现了 Write 的对象。
一个类型可以实现多个接口。例如,如果一个集合实现了 sort.Interface(包含 Len()Less(i, j int) boolSwap(i, j int) 方法),那么它就可以被 sort 包中的例程排序,同时它也可以有一个自定义格式化器。在这个例子中,Sequence 同时满足这两种接口。

转换

SequenceString 方法重做了 Sprint 已经为切片做的工作。(它的复杂度也是 O(N²),这很糟糕。)如果我们在调用 Sprint 之前将 Sequence 转换为一个普通的 []int,就可以共享工作(并加速)。
这个方法是从 String 方法安全调用 Sprintf 的另一个示例。因为这两种类型(Sequence[]int)在忽略类型名称时是相同的,所以在它们之间进行转换是合法的。转换不会创建新值,而只是暂时使现有值表现得像有了新类型。(还有其他合法的转换,例如从整数到浮点数,这会创建新值。)
在 Go 程序中,将表达式的类型转换为访问不同方法集是一种常见的习惯。例如,我们可以使用现有的类型 sort.IntSlice 来简化整个示例:
现在,我们不再需要让 Sequence 实现多个接口(排序和打印),而是利用数据项转换为多种类型的能力(Sequencesort.IntSlice[]int),每种类型执行部分工作。这在实践中比较少见,但可能有效。

接口转换与类型断言

类型切换是一种转换形式:它接受一个接口,并在切换的每个案例中,将其“转换”为该案例的类型。下面是 fmt.Printf 如何通过类型切换将值转换为字符串的简化版本。如果它已经是一个字符串,我们想要接口中持有的实际字符串值;如果它有一个 String 方法,我们想要调用该方法的结果。
第一个案例找到一个具体值;第二个将接口转换为另一个接口。以这种方式混合类型是完全可以的。
如果我们只关心一种类型呢?如果我们知道值持有一个 string,我们只想提取它?一个单案例的类型切换就可以做到,但类型断言也可以。类型断言接受一个接口值并从中提取出指定的显式类型的值。语法借用了类型切换的开头,但使用显式类型而不是 type 关键字:
结果是一个具有静态类型 typeName 的新值。该类型必须是接口所持有的具体类型,或是可以转换的第二个接口类型。为了提取我们知道在值中的字符串,我们可以写:
但如果结果值不是字符串,程序将因运行时错误崩溃。为了防止这种情况,可以使用“逗号,ok”习惯用法来安全地测试值是否是字符串:
如果类型断言失败,str 仍然存在并且是字符串类型,但它的值将是零值,即空字符串。
作为能力的说明,这里有一个 if-else 语句,它等价于本节开头的类型切换。

泛化

如果一个类型仅用于实现一个接口,并且不会有超过该接口的导出方法,则没有必要导出该类型。仅导出接口使得值没有超过接口描述的有趣行为也更加明确。它还避免了在每个常见方法的实例上重复文档。
在这种情况下,构造函数应该返回一个接口值,而不是实现类型。例如,在哈希库中,crc32.NewIEEEadler32.New 都返回接口类型 hash.Hash32。在 Go 程序中将 CRC-32 算法替换为 Adler-32 仅需更改构造函数调用;其余代码不受算法更改的影响。
类似的方法允许在各种 crypto 包中的流密码算法与它们连接的块密码分开。crypto/cipher 包中的 Block 接口指定块密码的行为,提供单个数据块的加密。然后,通过类比 bufio 包,可以使用实现该接口的密码包构造流密码,由 Stream 接口表示,而无需了解块加密的细节。
crypto/cipher 接口如下所示:
以下是计数器模式(CTR)流的定义,它将块密码转换为流密码;注意块密码的细节被抽象化:
NewCTR 适用于任何实现 Block 接口和任何 Stream 的情况。因为它们返回接口值,所以用其他加密模式替换 CTR 加密是局部的变化。构造函数调用必须编辑,但因为周围的代码只能将结果视为 Stream,所以它不会注意到差异。

接口和方法

由于几乎任何东西都可以附加方法,因此几乎任何东西都可以满足接口。一个说明性示例是 http 包,它定义了 Handler 接口。任何实现 Handler 的对象都可以处理 HTTP 请求。
ResponseWriter 本身也是一个接口,提供访问所需的方法,以便将响应返回给客户端。这些方法包括标准的 Write 方法,因此 http.ResponseWriter 可以在任何需要 io.Writer 的地方使用。Request 是一个结构体,包含来自客户端的请求的解析表示。
为了简洁起见,我们忽略 POST 请求,假设 HTTP 请求总是 GET;这种简化不影响处理程序的设置。下面是一个简单的处理程序实现,用于计算页面访问的次数。
(保持主题,注意 Fprintf 可以打印到 http.ResponseWriter。)在真实服务器中,需要保护对 ctr.n 的访问以防止并发访问。请参见 syncatomic 包以获取建议。
作为参考,以下是如何将此类服务器附加到 URL 树的节点。
但为什么要将 Counter 定义为结构体?一个整数就足够了。(接收者需要是指针,以便增量对调用者可见。)
如果你的程序有一些内部状态需要在访问页面时通知该状态呢?可以将通道与网页绑定。
最后,假设我们想在 /args 上显示用于调用服务器二进制文件的参数。编写一个函数以打印参数是很简单的。
我们如何将其转换为 HTTP 服务器?我们可以将 ArgServer 作为某种类型的方法,但有一种更简洁的方法。因为我们可以为任何类型定义方法(除了指针和接口),所以我们可以为函数定义方法。http 包包含以下代码:
HandlerFunc 是一个具有方法 ServeHTTP 的类型,因此该类型的值可以处理 HTTP 请求。查看方法的实现:接收者是一个函数 f,并且该方法调用 f。这可能看起来奇怪,但与接收者是通道并且方法在通道上发送并没有太大区别。
要将 ArgServer 变成 HTTP 服务器,我们首先将其修改为具有正确的签名。
ArgServer 现在具有与 HandlerFunc 相同的签名,因此可以转换为该类型以访问其方法,就像我们将 Sequence 转换为 IntSlice 以访问 IntSlice.Sort 一样。设置它的代码简洁明了:
当有人访问页面 /args 时,安装在该页面的处理程序的值是 ArgServer,类型是 HandlerFunc。HTTP 服务器将调用该类型的 ServeHTTP 方法,以 ArgServer 作为接收者,这将进一步调用 ArgServer(通过调用 f(w, req)HandlerFunc.ServeHTTP 中)。然后将显示参数。
在本节中,我们使用结构体、整数、通道和函数创建了一个 HTTP 服务器,都是因为接口只是方法的集合,可以为(几乎)任何类型定义。

空白标识符

我们已经提到过空白标识符几次,主要是在 for range 循环和映射的上下文中。空白标识符可以被赋值或声明为任何类型的值,而这些值将被无害地丢弃。它有点像写入 Unix 的 /dev/null 文件:它代表一个只写的值,用作占位符,在需要变量但实际值无关紧要的地方使用。它的用途超出了我们已经看到的内容。

在多重赋值中的空白标识符

for range 循环中使用空白标识符是一个特例,它属于一个更一般的情况:多重赋值。
如果赋值需要在左侧多个值,但程序只会使用其中一个值,则在赋值的左侧使用空白标识符可以避免创建一个虚拟变量,并明确表明该值将被丢弃。例如,当调用一个返回值和错误的函数时,但只对错误感兴趣,可以使用空白标识符来丢弃无关的值。
有时你会看到代码丢弃错误值以忽略错误;这种做法是非常糟糕的。始终检查错误返回;它们提供是有原因的。

未使用的导入和变量

导入一个包或声明一个变量而不使用它是错误的。未使用的导入会使程序臃肿并减慢编译速度,而初始化但未使用的变量至少是浪费的计算,并可能指示更大的错误。然而,在程序处于积极开发中时,未使用的导入和变量经常出现,删除它们以便编译顺利进行可能会很麻烦,尤其是在稍后又需要它们的情况下。空白标识符提供了一个解决方法。
下面这个半完成的程序有两个未使用的导入(fmtio)和一个未使用的变量(fd),因此它无法编译,但我们希望检查到目前为止代码是否正确。
为了消除未使用导入的警告,可以使用空白标识符引用导入包中的符号。同样,将未使用的变量 fd 赋值给空白标识符也会消除未使用变量的错误。这版程序能成功编译。
根据惯例,消除导入错误的全局声明应该紧跟在导入之后,并加以注释,以便于查找,并作为稍后清理的提醒。

仅为副作用导入

fmtio 这样的未使用导入应该最终被使用或移除:空白赋值标识符将代码标识为正在进行中的工作。但有时,仅为其副作用导入一个包是有用的,而不需要显式使用。例如,在其 init 函数中,net/http/pprof 包注册 HTTP 处理程序以提供调试信息。它有一个导出的 API,但大多数客户端只需要处理程序注册,并通过网页访问数据。为了仅为副作用导入该包,可以将包重命名为空白标识符:
这种导入形式明确表示该包是为了其副作用而被导入,因为在这个文件中没有其他可能的使用(如果有,它会被使用的名称,编译器会拒绝程序)。

接口检查

正如我们在接口讨论中看到的那样,一个类型不需要明确声明它实现了一个接口。相反,一个类型只需实现接口的方法即可实现该接口。在实践中,大多数接口转换是静态的,因此在编译时检查。例如,将 *os.File 传递给一个期望 io.Reader 的函数,除非 *os.File 实现了 io.Reader 接口,否则将无法编译。
不过,有些接口检查确实发生在运行时。例如,在 encoding/json 包中,定义了一个 Marshaler 接口。当 JSON 编码器接收到一个实现该接口的值时,编码器会调用该值的序列化方法将其转换为 JSON,而不是进行标准转换。编码器通过类型断言在运行时检查这一属性,例如:
如果只需要询问某个类型是否实现了一个接口,而不实际使用该接口本身,可能作为错误检查的一部分,可以使用空白标识符来忽略类型断言的值:
这种情况出现的一个地方是需要确保在实现该类型的包内,它确实满足接口。如果一个类型——例如 json.RawMessage——需要自定义 JSON 表示,它应该实现 json.Marshaler,但没有静态转换会导致编译器自动验证这一点。如果类型不小心未能满足接口,JSON 编码器仍能工作,但不会使用自定义实现。为了确保实现正确,可以在包中使用全局声明,使用空白标识符:
在这个声明中,涉及将 *RawMessage 转换为 Marshaler 的赋值要求 *RawMessage 实现 Marshaler,这一属性将在编译时检查。如果 json.Marshaler 接口发生变化,该包将无法编译,我们将收到通知需要更新。
在这个构造中,空白标识符的出现表明声明仅用于类型检查,而不是创建变量。不要对每个满足接口的类型都这样做,按照惯例,这种声明仅在代码中没有静态转换时使用,这是一个罕见的事件。

嵌入

Go 不提供典型的、基于类型的子类化概念,但它确实有能力通过在结构体或接口中嵌入类型来“借用”实现的某些部分。
接口嵌入非常简单。我们之前提到过 io.Readerio.Writer 接口;它们的定义如下:
io 包还导出了多个其他接口,指定可以实现多个此类方法的对象。例如,有 io.ReadWriter,一个包含 ReadWrite 的接口。我们可以通过显式列出这两个方法来指定 io.ReadWriter,但通过嵌入这两个接口来形成新的接口更简单、更具表现力,如下所示:
这表明 ReadWriter 可以执行 ReaderWriter 的操作;它是嵌入接口的并集。只有接口可以嵌入到接口中。
相同的基本思想适用于结构体,但影响更深远。bufio 包有两个结构体类型,bufio.Readerbufio.Writer,当然它们实现了来自 io 包的相应接口。而 bufio 还实现了一个缓冲读写器,它通过将一个读者和一个写者组合成一个结构体来完成,使用嵌入:它在结构体中列出类型,但不为它们指定字段名。
嵌入的元素是指向结构体的指针,当然必须初始化为指向有效结构体,才能使用。ReadWriter 结构体可以写成
但这样一来,为了提升字段的方法并满足 io 接口,我们还需要提供转发方法,如下所示:
通过直接嵌入结构体,我们避免了这种繁琐的管理。嵌入类型的方法自动继承,这意味着 bufio.ReadWriter 不仅拥有 bufio.Readerbufio.Writer 的方法,还满足所有三个接口:io.Readerio.Writerio.ReadWriter
嵌入与子类化有一个重要的区别。当我们嵌入一个类型时,该类型的方法成为外部类型的方法,但当它们被调用时,方法的接收者是内部类型,而不是外部类型。在我们的示例中,当 bufio.ReadWriterRead 方法被调用时,它的效果与上述转发方法完全相同;接收者是 ReadWriterreader 字段,而不是 ReadWriter 本身。
嵌入类型也可以是一个简单的便利。下面这个示例展示了一个嵌入字段和一个常规命名字段。
Job 类型现在拥有 *log.LoggerPrintPrintfPrintln 和其他方法。当然,我们可以给 Logger 一个字段名,但这样做并不是必要的。一旦初始化,我们可以对 Job 进行日志记录:
LoggerJob 结构体的常规字段,因此我们可以像往常一样在 Job 的构造函数内部初始化它,例如:
或者使用复合字面量:
如果我们需要直接引用嵌入字段,则字段名称是该字段的类型名称,忽略包限定符,就像在 ReadWriter 结构体的 Read 方法中一样。在这里,如果我们需要访问 Job 变量 job*log.Logger,我们可以写 job.Logger,这在我们想要扩展 Logger 的方法时会很有用。
嵌入类型引入了名称冲突的问题,但解决这些问题的规则很简单。首先,字段或方法 X 隐藏任何在类型的更深层嵌套部分的其他项目 X。如果 log.Logger 包含一个名为 Command 的字段或方法,JobCommand 字段将优先于它。
其次,如果同一名称出现在同一嵌套层次,通常会导致错误;如果 Job 结构体包含另一个名为 Logger 的字段或方法,则嵌入 log.Logger 将是错误的。然而,如果重复名称在程序外部从未提及,则没有问题。如果嵌入的类型发生变化,添加了与另一个子类型中其他字段冲突的字段,而这些字段从未被使用,则不会有问题。

并发

通过通信共享

并发编程是一个庞大的主题,这里只能介绍一些 Go 特有的亮点。
在许多环境中,并发编程因实现对共享变量的正确访问所需的细微差别而变得困难。Go 鼓励一种不同的方法,其中共享值通过通道传递,实际上,多个执行线程之间从不主动共享。任何时刻只有一个 goroutine 可以访问该值。数据竞争在设计上无法发生。为了鼓励这种思维方式,我们将其简化为一个口号:
不要通过共享内存进行通信;相反,通过通信共享内存。
这种方法可能过于极端。例如,引用计数最好是将互斥锁放在一个整数变量周围。但作为一种高级方法,使用通道控制访问使得编写清晰、正确的程序变得更容易。
可以这样思考这一模型:考虑一个在一个 CPU 上运行的典型单线程程序。它不需要同步原语。现在运行另一个这样的实例;它也不需要同步。现在让这两个实例进行通信;如果通信是同步器,则仍然不需要其他同步。Unix 管道正好完美地符合这一模型。虽然 Go 的并发方式源于 Hoare 的通信顺序进程(CSP),但它也可以被视为 Unix 管道的类型安全泛化。

Goroutines

它们被称为 goroutines,因为现有的术语——线程、协程、进程等——传达了不准确的含义。Goroutine 的模型很简单:它是一个与其他 goroutine 在同一地址空间内并发执行的函数。它是轻量级的,成本仅略高于分配堆栈空间。堆栈开始时很小,因此成本低,并根据需要通过分配(和释放)堆存储来增长。
Goroutines 被多路复用到多个操作系统线程上,因此如果一个线程阻塞,例如在等待 I/O 时,其他线程仍然可以继续运行。它们的设计隐藏了线程创建和管理的许多复杂性。
在函数或方法调用前加上 go 关键字,可以在新的 goroutine 中运行该调用。当调用完成时,goroutine 会默默退出。(效果类似于 Unix shell 中的 & 符号,用于在后台运行命令。)
函数字面量在 goroutine 调用中非常方便。
在 Go 中,函数字面量是闭包:实现确保函数引用的变量在它们活动期间存活。
这些示例并不太实用,因为函数没有办法发出完成信号。为此,我们需要通道。

通道

与映射一样,通道通过 make 分配,结果值作为对底层数据结构的引用。如果提供了一个可选的整数参数,它设置通道的缓冲区大小。默认值为零,表示无缓冲或同步通道。
无缓冲通道将通信(值的交换)与同步(确保两个计算(goroutine)处于已知状态)结合在一起。
使用通道有很多优雅的习惯用法。以下是一个起步示例。在上一节中,我们在后台启动了一个排序操作。通道可以让启动的 goroutine 等待排序完成。
接收者总是会阻塞,直到有数据可以接收。如果通道是无缓冲的,发送者会阻塞,直到接收者接收到值。如果通道有缓冲,发送者仅在值被复制到缓冲区时阻塞;如果缓冲区已满,则意味着需要等待某个接收者取走一个值。
有缓冲的通道可以像信号量一样使用,例如限制吞吐量。在这个示例中,传入的请求被传递到 handle 函数,该函数向通道发送一个值,处理请求,然后从通道接收一个值,以准备下一个消费者的“信号量”。通道缓冲区的容量限制了对 process 的同时调用次数。
一旦 MaxOutstanding 个处理程序正在执行 process,任何更多的请求将阻塞,尝试发送到已满的通道缓冲区,直到一个现有的处理程序完成并从缓冲区接收。
不过,这种设计有一个问题:Serve 为每个传入请求创建一个新的 goroutine,尽管只有 MaxOutstanding 个请求可以同时运行。因此,如果请求到达得太快,程序可能会消耗无限的资源。我们可以通过改变 Serve 来控制 goroutine 的创建:
(请注意,在 Go 1.22 之前的版本中,这段代码有一个错误:循环变量在所有 goroutine 中共享。有关详细信息,请参见 Go wiki。)
另一种更好地管理资源的方法是启动固定数量的 handle goroutine,它们都从请求通道读取。goroutine 的数量限制了对 process 的同时调用次数。这个 Serve 函数还接受一个通道,用于告知它退出;在启动 goroutines 后,它会阻塞接收该通道的信号。

通道的通道

Go 的一个重要特性是通道是第一类值,可以像其他值一样分配和传递。这个特性的一个常见用法是实现安全的并行解复用。
在前一节的示例中,handle 是一个理想化的请求处理程序,但我们没有定义它处理的类型。如果该类型包含一个用于回复的通道,每个客户端可以提供自己的答案路径。以下是 Request 类型的示意定义。
客户端提供一个函数及其参数,以及一个用于接收答案的通道。
在服务器端,处理函数唯一的变化是:
显然,还有很多工作要做以使其更现实,但这段代码是一个框架,用于构建一个速率限制的、并行的、非阻塞的 RPC 系统,并且没有任何互斥锁。

并行化

这些思想的另一个应用是将计算并行化到多个 CPU 核心。如果计算可以拆分为可以独立执行的单独部分,则可以并行化,并使用通道来信号每个部分的完成。
假设我们有一个昂贵的操作要在一个向量的项目上执行,并且每个项目的操作值是独立的,如以下理想化示例所示。
我们在循环中独立启动这些部分,每个 CPU 启动一个。它们可以以任何顺序完成,但这无关紧要;我们只需通过在启动所有 goroutine 后清空通道来计数完成信号。
我们可以通过运行时询问适当的值,而不是为 numCPU 创建一个常量值。函数 runtime.NumCPU 返回机器上的硬件 CPU 核心数量,因此我们可以写:
还有一个函数 runtime.GOMAXPROCS,它报告(或设置)用户指定的 Go 程序可以同时运行的核心数量。它默认值为 runtime.NumCPU 的值,但可以通过设置同名的环境变量或通过调用该函数并传递正数来覆盖。调用它并传递零仅查询该值。因此,如果我们想尊重用户的资源请求,我们应该写:
请务必不要混淆并发性(将程序结构为独立执行的组件)和并行性(为了效率在多个 CPU 上并行执行计算)。虽然 Go 的并发特性可以使某些问题易于结构化为并行计算,但 Go 是一种并发语言,而不是并行语言,并非所有的并行化问题都适合 Go 的模型。有关区别的讨论,请参见 这篇博客文章

漏水的缓冲区

并发编程的工具甚至可以使非并发的想法更容易表达。以下是一个从 RPC 包中抽象出来的示例。客户端 goroutine 循环接收来自某个来源的数据,可能是网络。为了避免分配和释放缓冲区,它保持一个空闲列表,并使用一个缓冲通道来表示它。如果通道为空,则分配一个新的缓冲区。一旦消息缓冲区准备好,就将其发送到 serverChan
服务器循环从客户端接收每条消息,处理它,并将缓冲区返回到空闲列表。
客户端尝试从 freeList 中获取一个缓冲区;如果没有可用的,则分配一个新的。服务器对 freeList 的发送将 b 放回空闲列表,除非列表已满,在这种情况下,缓冲区将被丢弃,由垃圾收集器回收。(select 语句中的 default 子句在没有其他情况准备好时执行,这意味着 select 从不阻塞。)此实现仅用几行代码构建了一个漏水的桶空闲列表,依赖于缓冲通道和垃圾收集器进行记账。

错误处理

库函数通常需要向调用者返回某种错误指示。如前所述,Go 的多值返回使得可以轻松地在正常返回值旁边返回详细的错误描述。使用这个特性提供详细的错误信息是良好的编程风格。例如,os.Open 不仅在失败时返回 nil 指针,还返回一个描述出错原因的错误值。
按照约定,错误的类型是 error,这是一个简单的内置接口。
库的作者可以自由地实现这个接口,以提供更丰富的模型,这样不仅可以看到错误,还可以提供一些上下文信息。如前所述,os.Open 除了返回通常的 *os.File 值外,还返回一个错误值。如果文件成功打开,错误将是 nil;但如果出现问题,它将包含一个 os.PathError
PathErrorError 方法生成的字符串如下所示:
这样的错误信息包含了问题文件名、操作和引发的操作系统错误,即使在调用它的地方很远也很有用;它比简单的“没有这样的文件或目录”要信息丰富得多。
当可行时,错误字符串应标识其来源,例如通过在生成错误的操作或包名前添加前缀。例如,在 image 包中,由于未知格式而导致的解码错误的字符串表示为“image: unknown format”。
关心具体错误细节的调用者可以使用类型开关或类型断言来查找特定错误并提取详细信息。对于 PathErrors,这可能包括检查内部的 Err 字段以获取可恢复的失败。
第二个 if 语句是另一个类型断言。如果失败,ok 将为 falsee 将为 nil。如果成功,ok 将为 true,这意味着错误的类型是 *os.PathError,然后我们可以检查以获取更多关于错误的信息。

恐慌处理

报告错误给调用者的常规方式是返回一个 error 作为额外的返回值。典型的 Read 方法就是一个众所周知的例子;它返回一个字节计数和一个 error。但如果错误是不可恢复的呢?有时程序根本无法继续。
为此,有一个内置函数 panic,它实际上会创建一个运行时错误,停止程序(但见下一节)。该函数接受一个任意类型的单一参数——通常是一个字符串——在程序终止时打印出来。它也是指示某些不可能发生的事情的方式,例如退出一个无限循环。
这只是一个示例,但真实的库函数应避免使用 panic。如果问题可以被掩盖或解决,让程序继续运行总是更好的选择。一个可能的反例是在初始化期间:如果库确实无法设置自己,可能合理地选择 panic

恢复处理

当调用 panic 时,包括因运行时错误(例如超出切片边界或失败的类型断言)而隐式调用时,当前函数的执行立即停止,并开始展开 goroutine 的调用栈,同时沿途运行任何延迟函数。如果展开到达 goroutine 调用栈的顶部,程序将终止。然而,可以使用内置函数 recover 来重新获得对 goroutine 的控制并恢复正常执行。
调用 recover 会停止展开并返回传递给 panic 的参数。由于在展开期间运行的代码仅限于延迟函数,因此 recover 仅在延迟函数内部有用。
recover 的一个应用是在服务器内部关闭失败的 goroutine,而不影响其他正在执行的 goroutine。
在这个示例中,如果 do(work) 发生恐慌,结果将被记录,goroutine 将干净地退出,而不干扰其他 goroutine。延迟闭包中无需做任何其他事情;调用 recover 完全处理了这个条件。
因为 recover 只有在直接从延迟函数调用时才返回 nil,所以延迟代码可以调用自己使用 panicrecover 的库例程,而不会失败。例如,safelyDo 中的延迟函数可以在调用 recover 之前调用记录函数,而该记录代码将不会受到恐慌状态的影响。
通过设置恢复模式,do 函数(及其调用的任何函数)可以通过调用 panic 来优雅地处理任何不好的情况。我们可以利用这个想法来简化复杂软件中的错误处理。让我们看一个理想化的 regexp 包版本,它通过调用 panic 来报告解析错误,使用一个本地错误类型。以下是 Error 的定义、一个 error 方法,以及 Compile 函数。
如果 doParse 发生恐慌,恢复块将返回值设置为 nil——延迟函数可以修改命名返回值。然后它会在赋值给 err 时检查问题是否是解析错误,通过断言它具有本地类型 Error。如果不是,类型断言将失败,导致运行时错误,继续展开栈,就好像没有中断一样。这种检查意味着如果发生意外情况,例如越界,代码将失败,即使我们使用 panicrecover 来处理解析错误。
通过错误处理,error 方法(因为它是绑定到类型的方法,因此它有相同的名称是合适的,甚至是自然的)使得报告解析错误变得简单,而不必担心手动展开解析栈:
尽管这种模式很有用,但仅应在包内使用。Parse 将其内部的 panic 调用转换为 error 值;它不会将 panic 暴露给其客户端。这是一个良好的规则。
顺便提一下,这种重新 panic 的习惯在实际错误发生时会改变 panic 值。然而,原始和新错误都会在崩溃报告中呈现,因此问题的根本原因仍然可见。因此,这种简单的重新 panic 方法通常是足够的——毕竟这是一场崩溃——但如果您希望仅显示原始值,可以编写更多代码来过滤意外问题,并用原始错误重新 panic。这留给读者作为练习。

一个 Web 服务器

最后,让我们完成一个完整的 Go 程序,一个 Web 服务器。这个服务器实际上是一种 Web 重新服务器。Google 提供了一个服务 chart.apis.google.com,可以自动将数据格式化为图表和图形。但由于需要将数据放入 URL 作为查询,因此交互使用起来比较困难。这里的程序提供了一种更好的接口:给定一小段文本,它调用图表服务器生成一个二维码,这是一种编码文本的矩阵,可以用手机的相机扫描并解释为 URL,从而节省您在手机小键盘上输入 URL 的时间。
以下是完整的程序,后面会有解释。
main 的部分应该很容易理解。一个标志设置了服务器的默认 HTTP 端口。模板变量 templ 是乐趣所在。它构建了一个 HTML 模板,由服务器执行以显示页面;稍后会详细介绍。
main 函数解析标志,并使用我们上面讨论的机制将函数 QR 绑定到服务器的根路径。然后调用 http.ListenAndServe 启动服务器;它在服务器运行时阻塞。
QR 只是接收请求,其中包含表单数据,并在名为 s 的表单值上执行模板。
模板包 html/template 功能强大;这个程序仅触及其能力。本质上,它通过替换来自传递给 templ.Execute 的数据项的元素,动态重写一段 HTML 文本。在模板文本(templateStr)中,双大括号包围的部分表示模板操作。{{if .}}{{end}} 的部分仅在当前数据项(称为 .)的值非空时执行。也就是说,当字符串为空时,这部分模板将被抑制。
两个片段 {{.}} 表示在网页上显示传递给模板的数据——查询字符串。HTML 模板包自动提供适当的转义,因此文本是安全显示的。
模板字符串的其余部分只是加载页面时显示的 HTML。如果这个解释太快,请参阅 文档 以获取有关模板包的更详细讨论。
就这样:几行代码加上一些数据驱动的 HTML 文本,就构成了一个有用的 Web 服务器。Go 足够强大,可以在几行代码中实现很多功能。