Go-学习指南第二版-全-

Go 学习指南第二版(全)

原文:zh.annas-archive.org/md5/49044fc3671d64b7c4ed200e906a7f6d

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

在第一版的前言中,我写道:

我的第一个书名选择是无聊的 Go,因为,正确书写的话,Go 是无聊的……

无聊并不意味着琐碎。正确使用 Go 需要理解其特性如何预期地结合在一起。虽然你可以编写看起来像 Java 或 Python 的 Go 代码,但结果会让你感到不满,并想知道这些吵闹声是怎么回事。这本书就是为此而来。它逐步介绍 Go 的特性,解释如何最好地使用它们编写可以增长的惯用代码。

Go 仍然是一种功能集小而精的语言。它仍然缺少继承、面向方面的编程、函数重载、操作符重载、模式匹配、命名参数、异常等许多复杂化其他语言的特性。那么,为什么一本关于一种无聊语言的书需要更新呢?

有几个原因推动这个版本的出现。首先,无聊并不意味着琐碎,也不意味着不变。在过去的三年里,新的特性、工具和库已经出现。结构化日志记录、模糊测试、工作区和漏洞检查等改进有助于 Go 开发人员创建可靠、持久、可维护的代码。现在,由于 Go 开发人员已经有了几年的泛型使用经验,标准库开始包含类型约束和泛型函数,以减少重复代码。甚至不安全的包也已更新,使其更加安全。Go 开发人员需要一本资源,来解释如何最好地利用这些新特性。

第二,第一版对某些 Go 方面并没有给予足够的重视。导论章节的流畅性不如我希望的那样。丰富的 Go 工具生态系统没有被深入探讨。第一版的读者要求增加练习和额外的示例代码。这个版本试图解决这些限制。

最后,Go 团队引入了一些新东西,而且,我敢说是令人兴奋的。现在有一种策略,使得 Go 能够保持长期软件工程项目所需的向后兼容性,同时提供引入向后兼容性变更来解决长期设计缺陷的能力。Go 1.22 中引入的新的 for 循环变量作用域规则是第一个利用这种方法的特性。

Go 仍然无聊,它仍然很棒,而且比以往任何时候都要好。希望你喜欢这第二版。

谁应该阅读本书

本书面向希望学习第二门(或第五门)语言的开发者。重点是对 Go 完全陌生(仅知道它有一个可爱吉祥物)的人到已经完成 Go 教程甚至编写过一些 Go 代码的人。学习 Go 的重点不仅仅是如何用 Go 编写程序;更重要的是如何习惯性地写出 Go 代码。更有经验的 Go 开发者可以找到如何最佳使用语言新特性的建议。最重要的是读者希望学习如何编写看起来像 Go 的 Go 代码。

开发者需要具备使用开发工具的经验,例如版本控制(首选 Git)和集成开发环境(IDE)。读者应熟悉基本的计算机科学概念,如并发性和抽象性,因为本书会解释它们在 Go 语言中的工作原理。部分代码示例可从 GitHub 下载,还有数十个示例可以在线尝试,位于 The Go Playground。虽然不需要连接互联网,但在审阅可执行示例时会很有帮助。由于 Go 通常用于构建和调用 HTTP 服务器,一些示例假定读者熟悉基本的 HTTP 概念。

虽然 Go 的大部分特性在其他语言中也存在,但 Go 进行了不同的权衡,因此用 Go 编写的程序具有不同的结构。学习 Go 从如何设置 Go 开发环境开始,然后涵盖变量、类型、控制结构和函数。如果你有跳过这些内容的冲动,请抑制住,仔细看看。往往细节决定了你的 Go 代码是否符合习惯用法。一些乍一看显而易见的内容,仔细深思后可能会有微妙的惊喜。

本书使用的约定

本书使用以下排版约定:

斜体

指示新术语、URL、电子邮件地址、文件名和文件扩展名。

等宽字体

用于程序清单,以及段落内引用程序元素,如变量或函数名、数据库、数据类型、环境变量、语句和关键字。

等宽字体粗体

显示用户应该按字面输入的命令或其他文本。

等宽字体斜体

显示应由用户提供的值或由上下文确定的值替换的文本。

提示

此元素表示提示或建议。

注意

此元素表示一般注意事项。

警告

此元素表示警告或注意事项。

使用代码示例

补充材料(例如代码示例、练习等)可从 https://github.com/learning-go-book-2e 下载。

如果您有技术问题或在使用代码示例时遇到问题,请发送电子邮件至 bookquestions@oreilly.com

本书旨在帮助您完成工作。一般情况下,如果本书提供示例代码,您可以在程序和文档中使用它。除非您复制了大量代码片段,否则不需要联系我们寻求许可。例如,编写使用本书多个代码片段的程序不需要许可。出售或分发奥莱利图书中的示例代码需要许可。引用本书并引用示例代码来回答问题不需要许可。将本书中大量示例代码合并到您产品的文档中需要许可。

我们感谢但不要求署名。署名通常包括标题、作者、出版商和 ISBN。例如:“Learning Go by Jon Bodner (O’Reilly). Copyright 2024 Jon Bodner, 978-1-098-13929-2.”

如果您认为使用代码示例超出了合理使用范围或以上所给的许可,请随时联系我们:permissions@oreilly.com

奥莱利在线学习

注意

超过 40 年来,奥莱利传媒提供技术和商业培训、知识和见解,帮助公司取得成功。

我们独特的专家和创新者网络通过书籍、文章以及我们的在线学习平台分享他们的知识和专长。奥莱利的在线学习平台为您提供即时访问的现场培训课程、深度学习路径、互动编码环境以及来自奥莱利和其他 200 多个出版商的广泛文本和视频资源。欲了解更多信息,请访问http://oreilly.com

如何联系我们

请将有关本书的评论和问题发送至出版商:

  • 奥莱利传媒有限公司

  • 1005 Gravenstein Highway North

  • Sebastopol, CA 95472

  • 800-889-8969(美国或加拿大)

  • 707-827-7019(国际或本地)

  • 707-829-0104(传真)

  • support@oreilly.com

  • https://www.oreilly.com/about/contact.html

我们为本书设有网页,列出勘误表、示例和任何额外信息。您可以访问https://oreil.ly/learning-go-2ed

欲获取有关我们的图书和课程的新闻和信息,请访问https://oreilly.com

在 LinkedIn 上找到我们:https://linkedin.com/company/oreilly-media

在 Twitter 上关注我们:https://twitter.com/oreillymedia

在 YouTube 上观看我们:https://youtube.com/oreillymedia

第二版致谢

对第一版《学习 Go 语言》的反应让我感到受宠若惊。它始于 2019 年末,完成于 2020 年末,并于 2021 年初发布。无意中成为了我的疫情项目。有些人烤面包;我解释指针。

完成后,有人问我下一本书会是什么。我告诉他们,我打算好好休息一下,然后开始写一本浪漫小说。但在我开始写有关海盗和公主的内容之前,一件惊人的事情发生了:《学习 Go 语言》取得了巨大成功。它在 O’Reilly Learning 上有将近一年时间位列最受欢迎的五本书之一。随着销量的增长,我发现第一版中的一些需要修正的地方,并收到读者指出错误和遗漏的来信。有些错误在后来的印刷中作为勘误进行了修正,但我联系了 O’Reilly,询问他们是否希望推出新版,他们表示有兴趣。

回到 O’Reilly 出版第二版《学习 Go 语言》是一次愉快的经历。Rita Fernando 提供了指导、反馈和编辑。Jonathan Amsterdam、Leam Hall、Katie Hockman、Thomas Hunter、Max Horstmann 和 Natalie Pistunovich 的反馈使这版书变得更加完美。Chris Hines 在找出我的错误并提出更好的例子方面做得非常彻底。Abby Deng 和 Datadog 的 Go 书籍俱乐部成员的评论和反馈使我能够根据新手开发者的反馈进行改进。剩下的(希望很少)错误全都是我的责任。

我的妻子和孩子们很体贴,不介意我在家庭电影之夜中缺席,而是专心研究描述新 Go 特性的最佳方式。

我还要感谢第一版的读者,他们用他们的善意话语联系了我。感谢大家的支持和鼓励。

第一版致谢

写书看起来是一项孤独的任务,但没有众多人的帮助是无法完成的。我告诉 Carmen Andoh 我想写一本关于 Go 的书,在 2019 年的 GopherCon 上,她介绍我认识了 O’Reilly 的 Zan McQuade。Zan 指导我完成了这本书的收购过程,并在我写作《学习 Go 语言》期间继续给予我建议。Michele Cronin 编辑了文本,提供了反馈,并在不可避免的困难时期倾听。Tonya Trybula 的文字编辑和 Beth Kelly 的制作编辑使我的草稿具备了出版品质。

写作过程中,我收到了许多人的关键反馈(和鼓励),包括 Jonathan Altman、Jonathan Amsterdam、Johnny Ray Austin、Chris Fauerbach、Chris Hines、Bill Kennedy、Tony Nelson、Phil Pearl、Liz Rice、Aaron Schlesinger、Chris Stout、Kapil Thangavelu、Claire Trivisonno、Volker Uhrig、Jeff Wendling 和 Kris Zaragoza。我特别要感谢 Rob Liebowitz,他详细的笔记和迅速的反馈使这本书比没有他的努力要好得多。

我的家人包容了我在电脑前度过的夜晚和周末,而不是和他们在一起。特别是我的妻子,劳拉,慷慨地假装我凌晨 1 点或更晚回到床上时没有吵醒她。

最后,我想要纪念那两个四十年前开启我这条道路的人。第一个是保罗·戈尔德斯坦,童年朋友的父亲。1982 年,保罗给我们展示了一台康梅多 PET,输入了 PRINT 2 + 2,并按下了回车键。屏幕显示 4 时,我感到非常惊讶,从那刻起我便迷上了计算机。后来,他教会了我如何编程,甚至让我借用了 PET 几周。其次,我要感谢我的母亲,她鼓励我对编程和计算机的兴趣,尽管她完全不知道这一切的意义所在。她为我买了 Atari 2600 的 BASIC 编程插件,还有 VIC-20 和康梅多 64,并购买了启发我想要写自己程序的书籍。

感谢大家帮助我实现这个梦想。

第一章:设置你的 Go 开发环境

每种编程语言都需要一个开发环境,Go 也不例外。如果你已经构建了一两个 Go 程序,那么你有一个工作的环境,但你可能错过了一些更新的技术和工具。如果这是你第一次在计算机上设置 Go,不用担心;安装 Go 及其支持工具非常简单。设置环境并验证后,你将构建一个简单的程序,了解不同构建和运行 Go 代码的方式,然后探索一些使 Go 开发更轻松的工具和技术。

安装 Go 工具

要构建 Go 代码,你需要下载并安装 Go 开发工具。你可以在Go 官网下载页面找到最新版本的工具。选择适合你平台的下载并安装。Mac 使用.pkg安装程序,Windows 使用.msi安装程序会自动将 Go 安装到正确的位置,移除旧版本安装,并将 Go 二进制文件放在默认的可执行路径中。

提示

如果你是 Mac 开发者,可以使用Homebrew命令brew install go安装 Go。使用Chocolatey的 Windows 开发者可以使用命令choco install golang安装 Go。

各种 Linux 和 BSD 安装程序都是 gzip 压缩的 TAR 文件,会解压缩为一个名为go的目录。将此目录复制到/usr/local并将/usr/local/go/bin添加到你的$PATH,以便go命令可用:

$ tar -C /usr/local -xzf go1.20.5.linux-amd64.tar.gz
$ echo 'export PATH=$PATH:/usr/local/go/bin' >> $HOME/.bash_profile
$ source $HOME/.bash_profile

你可能需要 root 权限才能写入/usr/local。如果tar命令失败,请使用sudo tar -C /usr/local -xzf go1.20.5.linux-amd64.tar.gz重新运行。

注意

Go 程序编译为单个本地二进制文件,不需要安装其他软件即可运行它们。这与需要安装虚拟机来运行程序的 Java、Python 和 JavaScript 等语言形成对比。使用单个本地二进制文件使得 Go 程序的分发变得更加容易。本书不涵盖容器技术,但使用 Docker 或 Kubernetes 的开发者通常可以将 Go 应用程序打包在 scratch 或 distroless 镜像中。你可以在 Geert Baeke 的博客文章"Distroless or Scratch for Go Apps"中找到详细信息。

你可以通过打开终端或命令提示符并输入以下命令来验证你的环境是否设置正确:

$ go version

如果一切设置正确,你应该看到类似以下内容的输出:

go version go1.20.5 darwin/arm64

这告诉你这是在 macOS 上的 Go 版本 1.20.5。(Darwin 是 macOS 核心的操作系统,arm64 是基于 ARM 设计的 64 位芯片的名称。)在 x64 Linux 上,你会看到:

go version go1.20.5 linux/amd64

解决 Go 安装问题

如果你收到错误而不是版本消息,则可能是你的可执行路径中没有go,或者你的路径中有另一个名为go的程序。在 macOS 和其他类 Unix 系统上,使用which go命令查看是否执行了go命令,如果没有返回任何内容,则需要修复你的可执行路径。

如果你使用的是 Linux 或 BSD 系统,在 32 位系统上安装了 64 位 Go 开发工具,或者安装了错误芯片架构的开发工具,这是有可能的。

Go 工具

所有的 Go 开发工具都通过go命令访问。除了go version之外,还有编译器(go build)、代码格式化器(go fmt)、依赖管理器(go mod)、测试运行器(go test)、扫描常见编码错误的工具(go vet)等。它们在第 10、11 和 15 章节详细讨论。现在,让我们通过编写大家最喜欢的第一个应用程序“Hello, World!"”来快速看看最常用的工具。

注意

自 2009 年 Go 发布以来,Go 开发者在组织代码和处理依赖方面发生了几次变化。因此,有许多互相矛盾的建议,大多数已经过时(例如,你可以安全地忽略关于GOROOTGOPATH的讨论)。

对于现代的 Go 开发,规则很简单:你可以按自己的意愿组织项目并将其存储在任何地方。

你的第一个 Go 程序

让我们来学习编写 Go 程序的基础知识。在此过程中,你会看到构成简单 Go 程序的各个部分。你可能暂时不会理解所有内容,没关系,书中的其余部分会帮你理解!

制作一个 Go 模块

你需要做的第一件事是创建一个目录来容纳你的程序。将其命名为ch1。在命令行中,进入这个新目录。如果你的计算机终端使用 bash 或 zsh,看起来会像这样:

$ mkdir ch1
$ cd ch1

在目录内部,运行go mod init命令将这个目录标记为 Go 模块:

$ go mod init hello_world
go: creating new go.mod: module hello_world

你将在第 Chapter 10 学到更多关于模块的内容,但现在你只需要知道,一个 Go 项目被称为module(模块)。一个模块不仅仅是源代码,它还是该模块内代码依赖的精确规范。每个模块在其根目录下都有一个go.mod文件。运行go mod init会为你创建这个文件。一个基本的go.mod文件内容如下:

module hello_world

go 1.20

go.mod文件声明了模块的名称、模块支持的最低 Go 版本,以及任何其他依赖于该模块的模块。你可以将它视为类似于 Python 使用的requirements.txt文件或 Ruby 使用的Gemfile文件。

您不应直接编辑go.mod文件。相反,请使用go getgo mod tidy命令来管理对文件的更改。再次强调,与模块相关的所有内容都在第十章中讨论。

go build

现在让我们写些代码!打开文本编辑器,输入以下文本,并将其保存在ch1文件夹内,文件名为hello.go

package main

import "fmt"

func main() {
fmt.Println("Hello, world!")
}

(是的,这个代码示例中的缩进看起来很杂乱:我故意这样做的!你很快就会明白为什么。)

让我们快速浏览一下您创建的 Go 文件的各部分。第一行是包声明。在 Go 模块中,代码被组织为一个或多个包。Go 模块中的main包包含启动 Go 程序的代码。

接下来是一个导入声明。import语句列出了此文件中引用的包。您在这里使用了标准库中fmt(通常发音为“fumpt”)包中的函数。与其他语言不同,Go 只导入整个包。您不能将导入限制为包内的特定类型、函数、常量或变量。

所有的 Go 程序都从main包中的main函数开始。您可以使用func main()和左括号来声明这个函数。与 Java、JavaScript 和 C 类似,Go 使用大括号来标记代码块的开始和结束。

函数的主体由一行组成。它表示您正在调用fmt包中的Println函数,并传递参数"Hello, world!"。作为经验丰富的开发者,您可能已经猜到这个函数调用的作用。

文件保存后,返回到您的终端或命令提示符,并键入:

$ go build

创建一个名为hello_world的可执行文件(在 Windows 上为hello_world.exe),保存在当前目录中。运行它,你会惊讶地看到屏幕上打印出Hello, world!

$ ./hello_world
Hello, world!

二进制文件的名称与模块声明中的名称相匹配。如果您希望应用程序有不同的名称,或者希望将其存储在不同的位置,请使用-o标志。例如,如果您想将代码编译为名为“hello”的二进制文件,可以使用以下命令:

$ go build -o hello

在“使用go run来尝试运行小程序”中,我将介绍执行 Go 程序的另一种方式。

go fmt

Go 语言的主要设计目标之一是创建一种能够高效编写代码的语言。这意味着具有简单的语法和快速的编译器。这也导致 Go 语言的作者重新考虑了代码格式化的问题。大多数语言允许以多种方式格式化代码。但 Go 语言不允许这样做。强制执行标准格式使得编写操作源代码的工具变得更加容易。这简化了编译器,并允许创建一些聪明的用于生成代码的工具。

还有一个次要的好处。开发人员历来在格式争论上浪费了大量时间。由于 Go 定义了一种标准的代码格式化方式,Go 开发者避免了关于大括号风格制表符还是空格的争论。例如,Go 程序使用制表符缩进,如果开括号不与声明或开始块的命令在同一行,会导致语法错误。

注意

许多 Go 开发者认为,Go 团队定义标准格式是为了避免开发者争论,并后来发现了工具化的优势。然而,Go 的开发主导人 Russ Cox 公开表示 更好的工具化是他最初的动机。

Go 开发工具包含一个命令,go fmt,它可以自动修复代码中的空白以匹配标准格式。但它无法修复放置在错误行的大括号。运行以下命令:

$ go fmt ./...
hello.go

使用 ./... 告诉 Go 工具将命令应用于当前目录及其所有子目录中的所有文件。当你了解更多 Go 工具时,你会再次遇到它。

如果你打开 hello.go,你会看到包含 fmt.Println 的行现在使用单个制表符正确缩进了。

提示

记得在编译代码之前运行 go fmt,并且至少在提交源代码更改到仓库之前!如果忘记了,请进行单独的提交,只包含 go fmt ./...,这样你就不会把逻辑变更混入格式更改的大雪崩中。

go vet

在一个类别的 bug 中,代码在语法上是有效的,但很可能是不正确的。go 工具包括一个称为 go vet 的命令来检测这些类型的错误。向程序添加一个并观察它被检测到。将 hello.go 中的 fmt.Println 行修改为以下内容:

fmt.Printf("Hello, %s!\n")
注意

fmt.Printf 类似于 C、Java、Ruby 和许多其他语言中的 printf。如果你以前没有见过 fmt.Printf,它是一个函数,其第一个参数是模板,其余参数是模板中占位符的值。

在这个示例中,你有一个模板("Hello, %s!\n"),其中包含 %s 占位符,但没有为占位符指定值。这段代码可以编译和运行,但是不正确。go vet 的其中一个检测项目是检查格式化模板中每个占位符是否有值。在修改后的代码上运行 go vet,它会发现一个错误:

$ go vet ./...
# hello_world
./hello.go:6:2: fmt.Printf format %s reads arg #1, but call has 0 args

现在 go vet 发现了这个 bug,你可以轻松修复它。将 hello.go 中的第 6 行修改为:

fmt.Printf("Hello, %s!\n", "world")

虽然 go vet 可以捕获几种常见的编程错误,但它无法检测一些问题。幸运的是,第三方的 Go 代码质量工具可以填补这个空白。一些最受欢迎的代码质量工具在 “使用代码质量扫描器” 中有介绍。

提示

就像您应该运行go fmt来确保代码格式正确一样,运行go vet来扫描有效代码中可能存在的错误。这些命令只是确保代码高质量的第一步。除了本书的建议外,所有 Go 开发者都应该阅读"Effective Go"Go 代码审查评论页面,以了解符合习惯用法的 Go 代码是什么样子的。

选择您的工具

虽然您可以仅使用文本编辑器和go命令编写小型 Go 程序,但在处理更大项目时,您可能需要更高级的工具。与文本编辑器相比,Go IDE 提供了许多优势,包括保存时自动格式化、代码完成、类型检查、错误报告和集成调试。大多数文本编辑器和 IDE 都提供了出色的Go 开发工具。如果您还没有喜欢的工具,两个最受欢迎的 Go 开发环境是 Visual Studio Code 和 GoLand。

Visual Studio Code

如果您正在寻找一个免费的开发环境,Microsoft 的 Visual Studio Code 是您的最佳选择。自 2015 年发布以来,VS Code 已成为开发者最受欢迎的源代码编辑器。它不包含 Go 支持,但您可以通过从扩展库下载 Go 扩展来将其打造成 Go 开发环境。

VS Code 的 Go 支持依赖于通过其内置市场访问的第三方扩展。这包括 Go 开发工具、Delve 调试器,以及由 Go 团队开发的 gopls,一个 Go 语言服务器。虽然您需要自行安装 Go 编译器,但 Go 扩展会为您安装 Delve 和 gopls。

注意

什么是语言服务器?它是一个标准规范,用于启用编辑器实现智能编辑行为,如代码完成、质量检查或查找变量或函数在代码中使用的所有位置。您可以通过访问语言服务器协议网站了解更多关于语言服务器及其能力的信息。

设置好工具后,您可以打开项目并开始使用它。图 1-1 展示了项目窗口的样子。"使用 VS Code 进行 Go 编程入门"是一个教程,演示了 VS Code 的 Go 扩展。

VS Code

图 1-1. Visual Studio Code

GoLand

GoLand 是 JetBrains 推出的 Go 专用 IDE。尽管 JetBrains 以 Java 为中心的工具而闻名,GoLand 是一个出色的 Go 开发环境。正如你在 图 1-2 中所见,GoLand 的用户界面类似于 IntelliJ、PyCharm、RubyMine、WebStorm、Android Studio 或其他 JetBrains 的 IDE。其支持包括重构、语法高亮、代码补全和导航、文档弹出、调试器、代码覆盖等。除了 Go 支持外,GoLand 还包括 JavaScript/HTML/CSS 和 SQL 数据库工具。不像 VS Code 需要你安装插件才能使用。

GoLand 窗口

图 1-2. GoLand

如果你已经订阅了 IntelliJ Ultimate,可以通过插件添加 Go 支持。虽然 GoLand 是商业软件,JetBrains 为学生和核心开源贡献者提供 免费许可计划。如果你不符合免费许可的条件,可以使用 30 天的免费试用。之后,你需要付费购买 GoLand。

The Go Playground

还有一个重要的 Go 开发工具,但这并不是你需要安装的。访问 The Go Playground ,你会看到一个类似于 图 1-3 的窗口。如果你曾使用过 irbnodepython 这样的命令行环境,你会发现 The Go Playground 的感觉很类似。它为你提供一个地方来尝试和分享小程序。将你的程序输入窗口并点击运行按钮来执行代码。点击格式化按钮会运行 go fmt 并更新你的导入。点击分享按钮会创建一个独特的 URL,你可以将其发送给他人查看你的程序,或在未来的某个日期返回你的代码(这些 URL 已被证明长期有效,但我不建议将 Playground 作为你的源代码仓库)。

Go Playground

图 1-3. The Go Playground

正如你在 图 1-4 中看到的,你可以通过像 -- filename.go -- 这样的行来模拟多个文件。你甚至可以通过在文件名中包含 /,如 -- subdir/my_code.go --,来创建模拟的子目录。

请注意,Go Playground 是在别人的计算机上(特别是 Google 的计算机),所以你没有完全自由。它为您提供了几个版本的 Go(通常是当前版本、上一个版本和最新的开发版本)。您只能连接到 localhost,并且长时间运行或使用过多内存的进程会被停止。如果您的程序依赖于时间,请考虑时钟设置为 2009 年 11 月 10 日 23:00:00 UTC(Go 初始公告的日期)。即使存在这些限制,Go Playground 也是一个有用的方式,可以在不在本地创建新项目的情况下尝试新想法。在本书的整个过程中,您将找到指向 Go Playground 的链接,以便您可以运行代码示例而无需将它们复制到您的计算机上。

警告

不要将敏感信息(例如个人身份信息、密码或私钥)放入您的 Playground 中!如果单击“共享”按钮,信息将保存在 Google 的服务器上,并可通过相关的共享 URL 访问。如果不小心这样做,请联系 Google 的 security@golang.org 并提供 URL 及删除内容的原因。

Go Playground 多文件

图 1-4. Go Playground 支持多文件

Makefile

使用 IDE 很方便,但很难自动化。现代软件开发依赖于可重复、可自动化的构建,可以由任何人、任何地方、任何时候运行。需要这种工具的做法是良好的软件工程实践。它避免了开发人员用耸肩和声明“在我的机器上可以运行!”来摆脱任何构建问题的情况。做到这一点的方法是使用某种脚本来指定构建步骤。Go 开发者已经采用 make 作为他们的解决方案。它允许开发者指定构建程序所需的一组操作以及执行步骤的顺序。您可能不熟悉 make,但它自 1976 年以来就被用来构建 Unix 系统上的程序。

ch1 目录中创建名为Makefile的文件,并包含以下内容:

.DEFAULT_GOAL := build

.PHONY:fmt vet build
fmt:
        go fmt ./...

vet: fmt
        go vet ./...

build: vet
        go build

即使你以前没有见过 Makefile,弄清楚其中的情况也不是太困难。每个可能的操作被称为目标.DEFAULT_GOAL 定义了在没有指定目标时运行的目标。在本例中,默认是build目标。接下来是目标的定义。冒号(:)前的单词是目标的名称。目标后面的任何单词(例如在build: vet行中的vet)是在指定目标运行之前必须运行的其他目标。目标执行的任务位于目标后的缩进行中。.PHONY 行防止 make 在项目中的目录或文件与列出的目标之一同名时混淆。

运行 make,您应该看到以下输出:

$ make
go fmt ./...
go vet ./...
go build

输入单个命令可以正确格式化代码,检查非明显错误并编译它。你也可以用make vet来审核代码,或者只是运行格式化器make fmt。这可能看起来不是很大的改进,但确保在触发构建之前总是进行格式化和审核,无论是开发人员还是运行在持续集成构建服务器上的脚本,都意味着你不会错过任何步骤。

Makefile 的一个缺点是它们非常挑剔。在目标中,你必须用制表符缩进步骤。它们也不会默认支持 Windows。如果你在 Windows 计算机上进行 Go 开发,你需要先安装make。最简单的方法是先安装像Chocolatey这样的包管理器,然后使用它来安装make(对于 Chocolatey,命令是choco install make)。

如果你想了解更多关于编写 Makefile 的内容,Chase Lambert 有一篇很好的教程,但它确实使用了少量的 C 来解释这些概念。

你可以在本书的第一章代码库中找到本章的代码。

Go 兼容性承诺

与所有编程语言一样,Go 开发工具定期更新。自 Go 1.2 以来,大约每六个月发布一次新版本。还会根据需要发布包含错误修复和安全修复的补丁版本。考虑到快速的开发周期和 Go 团队对向后兼容性的承诺,Go 的发布往往是增量的而非扩展的。Go 兼容性承诺详细描述了 Go 团队计划如何避免破坏 Go 代码。它表明,除非为了修复错误或安全漏洞,否则不会对以 1 开头的任何 Go 版本进行向后兼容性变更。在他的 GopherCon 2022 主题演讲“兼容性:如何保持 Go 程序的正常运行”中,Russ Cox 讨论了 Go 团队确保 Go 代码不破坏的所有方法。他说:“我认为,优先考虑兼容性是我们在 Go 1 中做出的最重要的设计决策。”

这一保证不适用于go命令。go命令的标志和功能发生了不兼容的更改,这种情况很可能会再次发生。

保持更新

Go 程序编译为独立的本地二进制文件,因此你不必担心更新开发环境会导致当前部署的程序失败。你可以在同一台计算机或虚拟机上同时运行使用不同 Go 版本编译的程序。

当您准备更新计算机上安装的 Go 开发工具时,Mac 和 Windows 用户有最简单的路径。那些使用 brewchocolatey 安装的用户可以使用这些工具进行更新。那些使用https://golang.org/dl上的安装程序的用户可以下载最新的安装程序,在安装新版本时删除旧版本。

Linux 和 BSD 用户需要下载最新版本,将旧版本移动到备份目录,解压新版本,然后删除旧版本:

$ mv /usr/local/go /usr/local/old-go
$ tar -C /usr/local -xzf go1.20.6.linux-amd64.tar.gz
$ rm -rf /usr/local/old-go

注意

从技术上讲,您无需将现有安装移动到新位置;您可以直接删除它并安装新版本。但是,这属于“安全第一”的范畴。如果在安装新版本时出现问题,保留之前的版本是很有好处的。

练习

每章末尾都有练习,让您可以尝试我介绍的想法。您可以在第一章代码库中找到这些练习的答案。

  1. 取“Hello, world!” 程序并在 Go Playground 上运行它。与希望学习有关 Go 的同事分享代码在 playground 上的链接。

  2. 在 Makefile 中添加一个名为 clean 的目标,用于删除 hello_world 二进制文件和 go build 创建的任何其他临时文件。查看Go 命令文档,找到帮助实现此功能的 go 命令。

  3. 尝试修改“Hello, world!” 程序中的格式。添加空行、空格,更改缩进,插入新行。在做出修改后,运行 go fmt 查看格式化是否被撤销。此外,运行 go build 查看代码是否仍然可以编译。您还可以在函数中间添加额外的 fmt.Println 调用,以查看会发生什么。

总结

在本章中,您学习了如何安装和配置您的 Go 开发环境。您还了解了构建 Go 程序和确保代码质量的工具。现在您的环境已准备就绪,接下来将进入下一章,在那里您将探索 Go 中的内置类型以及如何声明变量。

第二章:预声明类型和声明

现在,您已经设置好开发环境,是时候开始了解 Go 语言的语言特性以及如何最佳地使用它们了。当试图弄清楚“最佳”意味着什么时,有一个主要的原则:以一种使您的意图清晰的方式编写您的程序。在我介绍功能并讨论选项时,我将解释为什么我认为特定的方法会产生更清晰的代码。

我将首先查看内置于 Go 中的类型以及如何声明这些类型的变量。尽管每位程序员对这些概念都有经验,但 Go 在某些方面有所不同,并且与其他语言之间存在微妙的差异。

预声明类型

Go 语言内置了许多类型。这些被称为预声明类型。它们类似于其他语言中的类型:布尔型、整数型、浮点型和字符串型。对于那些从其他语言过渡的开发人员来说,习惯地使用这些类型有时是一个挑战。你将看到这些类型如何在 Go 中发挥最佳作用。在我回顾这些类型之前,让我们先了解一些适用于所有类型的概念。

零值

像大多数现代语言一样,Go 会为声明但未赋值的变量分配一个默认的零值。显式的零值使得代码更清晰,并消除了在 C 和 C++程序中发现的错误源。当我讨论每种类型时,我还将涵盖该类型的零值。您可以在《Go 编程语言规范》中找到有关零值的详细信息。

字面量

Go 语言中的字面量是明确指定的数字、字符或字符串。Go 程序有四种常见的字面量类型。(在讨论复数时,我将涵盖一种罕见的第五种字面量类型。)

整数字面量是一系列数字。整数字面量默认为十进制,但使用不同的前缀表示其他进制:0b 表示二进制(基数为 2),0o 表示八进制(基数为 8),0x 表示十六进制(基数为 16)。前缀的字母可以是大写或小写。在一个前导的 0 后没有字母的情况下,是另一种表示八进制字面量的方式。不要使用这种表示法,因为它会非常令人困惑。

为了更容易阅读更长的整数字面量,Go 允许您在字面量的中间放置下划线。这使您可以例如在十进制数中按千位组织(1_234)。这些下划线对数字的值没有影响。下划线的唯一限制是它们不能出现在数字的开头或结尾,并且不能相互挨着。您可以在字面量中的每个数字之间放置下划线(1_2_3_4),但是不要这样做。通过在千位处分隔十进制数或在 1 字节、2 字节或 4 字节边界处分隔二进制、八进制或十六进制数来提高可读性。

浮点文字 有一个小数点来表示值的小数部分。它们也可以使用字母 e 和正负数来指定指数(例如 6.03e23)。您还可以选择使用 0x 前缀以及字母 p 来以十六进制编写它们,指示任何指数(0x12.34p5,相当于十进制中的 582.5)。与整数文字一样,您可以使用下划线来格式化您的浮点文字。

rune 文字 表示一个字符,用单引号括起来。与许多其他语言不同,在 Go 中,单引号和双引号不能互换使用。rune 文字可以写成单个 Unicode 字符('a')、8 位八进制数字('\141')、8 位十六进制数字('\x61')、16 位十六进制数字('\u0061')或 32 位 Unicode 数字('\U00000061')。还有几个反斜杠转义的 rune 文字,其中最有用的是换行符('\n')、制表符('\t')、单引号('\'')和反斜杠('\\')。

实际上,使用十进制来表示整数和浮点数文字。八进制表示很少见,主要用于表示 POSIX 权限标志值(例如 0o777 表示 rwxrwxrwx)。十六进制和二进制有时用于位过滤器或网络和基础设施应用程序。除非上下文使代码更清晰,否则避免使用任何 rune 文字的数值转义。

有两种方式表示字符串文字。大多数情况下,您应该使用双引号创建解释字符串文字(例如,类型 "问候和 致意")。这些包含零个或多个 rune 文字。它们被称为“解释性”的原因是它们将 rune 文字(数值和反斜杠转义的)解释为单个字符。

注意

一个 rune 文字的反斜杠转义在字符串文字中不合法:单引号转义。它被双引号的反斜杠转义替换。

不能出现在解释字符串文字中的唯一字符是未转义的反斜杠、未转义的换行符和未转义的双引号。如果您使用解释字符串文字,并希望问候语在不同行与致意语,并且希望“致意”出现在引号中,则需要输入 "问候和\n\"致意\""

如果需要在字符串中包含反斜杠、双引号或换行符,使用原始字符串文字更容易。这些用反引号(`)界定,并且可以包含除反引号以外的任何字符。原始字符串文字中没有转义字符;所有字符均按原样包含。使用原始字符串文字时,您可以这样写一个多行问候语:

`Greetings and
"Salutations"`

字面量被视为 无类型的。我将在 “字面量是无类型的” 中更详细地探讨这一概念。正如你在 “var Versus :=” 中所看到的,Go 中有些情况下类型并没有显式声明。在这些情况下,Go 使用字面量的 默认类型;如果表达式中没有明确表明字面量的类型,字面量将默认为某一类型。在讨论不同的预声明类型时,我将提到字面量的默认类型。

布尔类型

bool 类型表示布尔变量。bool 类型的变量可以有两个值:truefalsebool 的零值是 false

var flag bool // no value assigned, set to false
var isAwesome = true

谈论变量类型而不展示变量声明,或者反之,都是很难的。我将先使用变量声明并在 “var Versus :=” 中对其进行描述。

数值类型

Go 语言拥有大量的数值类型:12 种类型(以及一些特殊名称),分为三类。如果你来自像 JavaScript 这样只使用单一数值类型的语言,可能会觉得这很多。实际上,一些类型经常被使用,而其他一些则更为奇特。我将从整数类型开始讨论,然后再转向浮点类型和非常不寻常的复数类型。

整数类型

Go 语言提供了带有各种大小的有符号和无符号整数,从一到八字节不等。它们在 表 2-1 中展示。

表 2-1. Go 语言中的整数类型

类型名称 取值范围
int8 -128 到 127
int16 -32768 到 32767
int32 -2147483648 到 2147483647
int64 -9223372036854775808 到 9223372036854775807
uint8 0 到 255
uint16 0 到 65535
uint32 0 到 4294967295
uint64 0 到 18446744073709551615

从名称就能看出,所有整数类型的零值都是 0

特殊的整数类型

Go 语言确实有一些特殊的整数类型名称。byteuint8 的别名;在 Go 代码中可以合法地在 byteuint8 之间进行赋值、比较或执行数学运算。然而,在 Go 代码中很少看到 uint8,通常直接称之为 byte

第二个特殊名称是 int。在 32 位 CPU 上,int 是一个 32 位有符号整数,类似于 int32。在大多数 64 位 CPU 上,int 是一个 64 位有符号整数,就像 int64 一样。因为 int 在平台间不一致,如果在没有类型转换的情况下在 intint32int64 之间赋值、比较或执行数学运算,会导致编译时错误(详见 “显式类型转换”)。整数字面量默认为 int 类型。

注意

一些不常见的 64 位 CPU 架构使用 32 位有符号整数作为 int 类型。Go 支持其中的三种:amd64p32mips64p32mips64p32le

第三个特殊名称是 uint。它遵循与 int 相同的规则,只是无符号的(值始终为 0 或正数)。

还有两个特殊的整数类型名称,runeuintptr。你之前已经看过 rune 字面量,我将在“字符串和 rune 的味道”中讨论rune类型,以及在第十六章中讨论uintptr

如何选择使用哪种整数类型

Go 提供的整数类型比一些其他语言更多。考虑到这些选择,你可能会想知道何时应该使用每种类型。你应该遵循三个简单的规则:

  • 如果你正在处理具有特定大小或符号的整数的二进制文件格式或网络协议,请使用相应的整数类型。

  • 如果你正在编写一个应该适用于任何整数类型的库函数,可以利用 Go 的泛型支持,并使用泛型类型参数来表示任何整数类型(我在第五章中更详细地讨论函数及其参数,以及在第八章中更详细地讨论泛型)。

  • 在所有其他情况下,只需使用int

注意

你可能会遇到遗留代码,其中有一对函数完成相同的任务,但一个使用int64作为参数和变量的类型,另一个使用uint64。原因是 API 在 Go 添加泛型之前创建的。没有泛型,你需要编写带有稍微不同名称的函数来使用不同类型实现相同的算法。使用int64uint64意味着你可以编写一次代码,让调用者使用类型转换来传递值并转换返回的数据。

你可以在strconv包中的函数FormatIntFormatUint中看到这种模式,它们在 Go 标准库中。

整数运算符

Go 整数支持通常的算术运算符:+、-*/,使用%进行模运算。整数除法的结果是整数;如果要获得浮点结果,需要使用类型转换将整数转换为浮点数。另外,要小心不要将整数除以 0;这会导致恐慌(我在“panic 和 recover”中更详细地讨论恐慌)。

注意

Go 中的整数除法向零截断;详细信息请参见 Go 规范中关于算术运算符的部分。

你可以将任何算术运算符与=结合使用来修改变量:+=-=*=/=%=。例如,以下代码的结果是变量x的值为 20:

var x int = 10
x *= 2

可以使用==!=>>=<<=比较整数。

Go 还为整数提供了位操作运算符。你可以使用<<>>进行位左移和右移,或者使用&(按位与)、|(按位或)、^(按位异或)和&^(按位与非)进行位掩码操作。与算术运算符一样,你也可以将所有位操作符与=结合使用来修改变量:&=|=^=&^=<<=>>=

浮点类型

Go 有两种浮点类型,如表 2-2 所示。

表 2-2. Go 中的浮点类型

类型名称 最大绝对值 最小(非零)绝对值
float32 3.40282346638528859811704183484516925440e+38 1.401298464324817070923729583289916131280e-45
float64 1.797693134862315708145274237317043567981e+308 4.940656458412465441765687928682213723651e-324

就像整数类型一样,浮点类型的零值是 0。

Go 语言中的浮点数与其他语言的浮点数类似。Go 语言使用 IEEE 754 规范,提供了大范围和有限精度。选择使用哪种浮点类型很简单:除非必须与现有格式兼容,否则使用float64。浮点数字面量的默认类型是float64,因此始终使用float64是最简单的选项。它还有助于减少浮点数精度问题,因为float32只有六到七位小数的精度。除非已使用分析器确定它是问题的重要来源,否则不必担心内存大小的差异(测试和分析详见第十五章)。

更重要的问题是是否应该完全使用浮点数。在许多情况下,答案是否定的。就像其他语言一样,Go 语言的浮点数有很大的范围,但不能存储该范围内的每个值;它们存储最接近的近似值。由于浮点数不是精确的,它们只能在可以接受不精确值或者浮点数规则已充分理解的情况下使用。这限制了它们的使用范围,例如图形、统计和科学操作。

警告

浮点数不能精确表示小数值。不要用它们来表示货币或任何其他必须有精确小数表示的值!在“导入第三方代码”中,您将查看一个处理精确小数值的第三方模块。

您可以使用所有标准的数学和比较运算符处理浮点数,除了 %。浮点数除法有一些有趣的特性。将非零浮点变量除以 0 会返回+Inf-Inf(正无穷或负无穷),取决于数字的符号。将设置为 0 的浮点变量除以 0 会返回NaN(非数值)。

虽然 Go 允许你使用==!=来比较浮点数,但最好不要这样做。由于浮点数的不精确性,两个浮点数可能在你认为它们应该相等时不相等。相反,定义一个允许的最大差异,并查看两个浮点数之间的差异是否小于该值。这个值(有时称为epsilon)取决于你的精度需求;我无法给你一个简单的规则。如果不确定,建议咨询你附近友好的数学家。如果找不到,请参考The Floating Point Guide 的“Comparison”页面,它可以帮助你(或可能说服你除非绝对必要,否则避免使用浮点数)。

复杂类型(你可能不会使用这些)

还有一种数值类型,非常不寻常。Go 对复数有一流支持。如果你不知道复数是什么,那你不是这个特性的目标用户;可以跳过这部分。

在 Go 中,复数支持并不复杂。Go 定义了两种复数类型。complex64使用float32值表示实部和虚部,而complex128使用float64值。两者都可以使用内置函数complex进行声明:

var complexNum = complex(20.3, 10.2)

Go 使用几条规则来确定complex函数返回值的类型:

  • 如果你对函数参数同时使用未类型化的常量或字面量,将创建一个未类型化的复数字面量,默认类型为complex128

  • 如果complex函数的两个值都是float32类型,将创建一个complex64

  • 如果一个值是float32,另一个值是可以适应float32的未类型化常量或字面量,则将创建一个complex64

  • 否则,将创建一个complex128

所有标准的浮点运算符都可以用于复数。与浮点数一样,你可以使用==!=进行比较,但它们有相同的精度限制,因此最好使用 epsilon 技术。你可以使用内置函数realimag分别提取复数的实部和虚部。math/cmplx包提供了额外的函数来操作complex128值。

两种复数类型的零值都将实部和虚部分别赋值为 0。

示例 2-1 展示了一个简单的程序,演示了复数的工作原理。你可以在The Go Playground上自行运行它,或者在第二章存储库sample_code/complex_numbers目录中查看它。

Example 2-1. 复数
func main() {
    x := complex(2.5, 3.1)
    y := complex(10.2, 2)
    fmt.Println(x + y)
    fmt.Println(x - y)
    fmt.Println(x * y)
    fmt.Println(x / y)
    fmt.Println(real(x))
    fmt.Println(imag(x))
    fmt.Println(cmplx.Abs(x))
}

运行此代码会得到以下结果:

(12.7+5.1i)
(-7.699999999999999+1.1i)
(19.3+36.62i)
(0.2934098482043688+0.24639022584228065i)
2.5
3.1
3.982461550347975

你也可以看到浮点数不精确性在这里展示出来。

如果你想知道第五种原始字面量是什么,Go 支持虚数字面量来表示复数的虚部。它们看起来像浮点字面量,但后缀为i

尽管 Go 语言作为预声明类型拥有复数,但并不是流行的数值计算语言。采用受限,因为其他特性(如矩阵支持)不是语言的一部分,而库必须使用效率低下的替代品,比如切片的切片。 (您将在第三章中查看切片及其在第六章中的实现。)但是,如果您需要在较大程序的一部分中计算 Mandelbrot 集,或者实现二次方程求解器,复数支持在这里为您提供帮助。

也许你会想知道为什么 Go 语言包括复数。答案很简单:Ken Thompson,Go 语言(和 Unix)的创造者之一,认为它们会很有趣。有关从 Go 的未来版本中移除复数的讨论,但忽略这一功能会更容易。

注意

如果您确实希望在 Go 中编写数值计算应用程序,可以使用第三方Gonum包。它利用复数,并为线性代数、矩阵、积分和统计等提供有用的库。但您应该首先考虑其他语言。

字符串和符文的味道

这让我们来谈谈字符串。像大多数现代语言一样,Go 语言将字符串作为内置类型。字符串的零值是空字符串。Go 支持 Unicode;正如我展示的“文字”,你可以在字符串中放入任何 Unicode 字符。像整数和浮点数一样,字符串使用==比较是否相等,使用!=比较是否不等,使用>>=<<=进行排序。它们通过+运算符进行连接。

Go 中的字符串是不可变的;您可以重新分配字符串变量的值,但不能更改分配给它的字符串的值。

Go 还有一种表示单个代码点的类型。符文类型是int32类型的别名,就像byteuint8的别名一样。正如您可能猜到的那样,符文文字的默认类型是符文,字符串文字的默认类型是字符串。

如果您引用字符,请使用 rune 类型,而不是int32类型。它们可能对编译器来说是相同的,但您应该使用能够澄清代码意图的类型:

var myFirstInitial rune = 'J' // good - the type name matches the usage
var myLastInitial int32 = 'B' // bad - legal but confusing

我将在下一章节中详细讨论字符串,涵盖一些实现细节、与字节和符文的关系,以及高级功能和陷阱。

显式类型转换

大多数具有多个数字类型的语言在需要时会自动从一个类型转换为另一个类型。这称为自动类型提升,虽然它看起来非常方便,但事实证明,正确转换一个类型到另一个类型的规则可能会变得复杂并产生意外结果。作为一种重视意图清晰性和可读性的语言,Go 不允许在变量之间进行自动类型提升。当变量类型不匹配时,必须使用类型转换。即使是不同大小的整数和浮点数也必须转换为相同类型以进行交互。这清楚地表明您需要的确切类型,而无需记住任何类型转换规则(见 示例 2-2)。

示例 2-2. 类型转换
var x int = 10
var y float64 = 30.2
var sum1 float64 = float64(x) + y
var sum2 int = x + int(y)
fmt.Println(sum1, sum2)

在这个示例代码中,您定义了四个变量。x 是一个值为 10 的 inty 是一个值为 30.2 的 float64。由于它们不是相同的类型,您需要将它们转换为同一类型以进行相加。对于 sum1,您使用 float64 类型转换将 x 转换为 float64,对于 sum2,您使用 int 类型转换将 y 转换为 int。当您运行此代码时,它会打印出 40.2 40。

同一行为适用于不同大小的整数类型(见 示例 2-3)。

示例 2-3. 整数类型转换
var x int = 10
var b byte = 100
var sum3 int = x + int(b)
var sum4 byte = byte(x) + b
fmt.Println(sum3, sum4)

您可以在 Go Playground 上运行这些示例,或者在 第二章存储库 中的 sample_code/type_conversion 目录中运行。

围绕类型的严格性还有其他影响。由于 Go 中的所有类型转换都是显式的,您不能将另一种 Go 类型视为布尔值。在许多语言中,非零数字或非空字符串可以解释为布尔值 true。就像自动类型提升一样,“真值”值得从语言到语言的规则有所不同,这可能会令人困惑。不足为奇,Go 不允许真值。事实上,没有其他类型可以被隐式或显式地转换为布尔型。如果要将另一种数据类型转换为布尔型,必须使用其中一个比较运算符(==!=><<=>=)。例如,要检查变量 x 是否等于 0,代码将是 x == 0。如果要检查字符串 s 是否为空,请使用 s == ""

注意

类型转换是 Go 中选择在一定程度上增加冗余性以换取简洁性和清晰度的地方之一。您会多次看到这种权衡。惯用的 Go 更看重可理解性而不是简洁性。

文字量没有类型

虽然您不能将两个声明为不同类型整数的整数变量相加,但是 Go 允许您在浮点表达式中使用整数字面量,甚至将整数字面量赋值给浮点变量:

var x float64 = 10
var y float64 = 200.3 * 5

这是因为,正如我之前提到的,Go 中的文字都是无类型的。Go 是一门实用的语言,推迟类型指定是有意义的。这意味着它们可以与任何类型兼容的变量一起使用。当你在第七章看到用户定义的类型时,你会发现甚至可以在预定义类型的基础上使用文字。无类型的能力有限;你不能将文字串赋给数字类型的变量,或将文字数字赋给字符串变量,也不能将浮点文字赋给int。这些都会被编译器标记为错误。还存在大小限制;虽然你可以写比任何整数都大的数字文字,但如果试图将其分配给超出指定变量的值的文字,例如将文字 1000 分配给byte类型的变量,编译时会出错。

var:=的比较:

对于一门小语言来说,Go 有很多声明变量的方法。这背后有一个原因:每种声明风格都传达了关于变量如何使用的信息。让我们逐一介绍在 Go 中声明变量的方法,并看看每种方法何时适用。

在 Go 中声明变量的最冗长方式使用var关键字,显式类型和赋值。它看起来像这样:

var x int = 10

如果=右侧的类型是你变量的预期类型,你可以在=左侧省略类型。由于整数文字的默认类型是int,以下声明x为类型为int的变量:

var x = 10

相反,如果你想声明一个变量并将其赋值为零值,可以保留类型并在右侧省略=

var x int

你可以使用var一次声明多个变量,它们可以是相同类型的:

var x, y int = 10, 20

你可以声明同一类型的所有零值:

var x, y int

或者不同类型的:

var x, y = 10, "hello"

还有一种使用var的方法。如果你一次声明多个变量,可以将它们包装在声明列表中:

var (
    x    int
    y        = 20
    z    int = 30
    d, e     = 40, "hello"
    f, g string
)

Go 还支持一种短声明和赋值格式。当你在函数内部时,可以使用:=运算符来替代使用类型推断的var声明。以下两个语句完全相同——它们声明xint,其值为 10:

var x = 10
x := 10

var一样,你可以使用:=一次声明多个变量。以下两行都将 10 赋给x,将“hello”赋给y

var x, y = 10, "hello"
x, y := 10, "hello"

:=运算符可以做一件var做不到的事情:它允许你对已存在的变量赋值。只要:=的左侧至少有一个新变量,其他任何变量都可以已经存在:

x := 10
x, y := 30, "hello"

使用:=有一个限制。如果你在包级别声明一个变量,你必须使用var,因为在函数外部使用:=是不合法的。

如何知道使用哪种风格?一如既往,选择能最清晰表达你意图的方式。在函数内部最常见的声明风格是 :=。在函数外部,仅在罕见情况下声明多个包级变量时才使用声明列表。

在某些函数内部的情况下,应避免使用 :=

  • 当将变量初始化为其零值时,请使用 var x int。这样可以明确表明零值是有意为之的。

  • 当将无类型常量或文本分配给变量时,如果默认类型不是你想要的变量类型,请使用带有指定类型的长形式 var。虽然使用类型转换来指定值的类型并使用 := 来写 x := byte(20) 是合法的,但习惯上写成 var x byte = 20

  • 因为 := 允许你对新变量和现有变量进行赋值,有时当你认为在重用现有变量时它会创建新变量(详见“变量屏蔽”)。在这些情况下,使用 var 显式声明所有新变量,以明确表明哪些变量是新的,然后使用赋值运算符 (=) 为新变量和旧变量赋值。

虽然 var:= 允许在同一行上声明多个变量,但仅在从函数返回多个值或使用逗号 ok 惯用法时使用这种风格(参见第 5 章 和 “逗号 ok 惯用法”)。

很少在函数外部声明变量,即所谓的包块(参见“块”)。值会改变的包级变量是一个坏主意。当你在函数外部有一个变量时,很难追踪其所做的更改,这使得理解数据如何在程序中流动变得困难。这可能导致隐含的错误。一般规则是,只在包块中声明那些实际上是不可变的变量。

提示

避免在函数外部声明变量,因为它们会使数据流分析变得复杂。

你可能会想知道:Go 是否提供了确保值不可变的方法?答案是肯定的,但它与你在其他编程语言中见过的方式有些不同。现在是学习 const 的时候了。

使用 const

许多语言都有一种声明值为不可变的方法。在 Go 中,可以使用 const 关键字实现。乍一看,它似乎与其他语言中的方式完全相同。尝试在示例 2-4 中的代码中使用 Go Playground第二章存储库 中的 sample_code/const_declaration 目录。

示例 2-4. const 声明
package main

import "fmt"

const x int64 = 10

const (
    idKey   = "id"
    nameKey = "name"
)

const z = 20 * 10

func main() {
    const y = "hello"

    fmt.Println(x)
    fmt.Println(y)

    x = x + 1 // this will not compile!
    y = "bye" // this will not compile!
    fmt.Println(x)
    fmt.Println(y)
}

当你运行这段代码时,编译会失败,并显示以下错误消息:

./prog.go:20:2: cannot assign to x (constant 10 of type int64)
./prog.go:21:2: cannot assign to y (untyped string constant "hello")

正如你所看到的,你可以在包级别或函数内部声明一个常量。与 var 类似,你可以(也应该)在一组括号中声明一组相关的常量。

请注意,在 Go 中,const 的功能非常有限。Go 中的常量是将文字值命名的一种方式。它们只能保存编译器在编译时能够确定的值。这意味着它们可以被分配:

  • 数字文字值

  • truefalse

  • 字符串

  • Runes

  • 内建函数 complexrealimaglencap 返回的值

  • 由操作符和前置值组成的表达式

注意

我将在下一章中介绍 lencap 函数。可以与 const 一起使用的另一个值称为 iota。在讨论在 Chapter 7 中创建自己的类型时,我会谈到 iota

Go 不提供在运行时指定计算出的值为不可变的方法。例如,以下代码将无法编译,错误信息为 x + y (value of type int) is not constant

x := 5
y := 10
const z = x + y // this won't compile!

正如你将在下一章中看到的那样,Go 中没有不可变的数组、切片、映射或结构体,也无法声明结构体中的字段为不可变。这比听起来的限制要少。在函数内部,如果变量被修改,这是明确的,因此不可变性不那么重要。在 “Go Is Call by Value” 中,你将看到 Go 如何防止对作为参数传递给函数的变量进行修改。

提示

在 Go 中,常量是将文字值命名的一种方式。在 Go 中,没有方法可以声明一个变量是不可变的。

类型化和非类型化常量

常量可以是类型化的或非类型化的。非类型化的常量与文字值完全相同;它没有自己的类型,但在无法推断出其他类型时使用默认类型。类型化常量只能直接分配给该类型的变量。

是否使常量类型化取决于常量声明的原因。如果你为一个可能与多种数值类型一起使用的数学常量命名,保持常量为非类型化是合适的。通常情况下,保持常量为非类型化可以提供更大的灵活性。在某些情况下,你会希望常量强制类型化。当我在 “iota Is for Enumerations—Sometimes” 中讨论使用 iota 进行枚举时,你将看到一种对类型化常量的用法。

下面是非类型化常量声明的样子:

const x = 10

以下所有的赋值都是合法的:

var y int = x
var z float64 = x
var d byte = x

下面是类型化常量声明的样子:

const typedX int = 10

这个常量只能直接分配给一个 int。将其分配给任何其他类型会产生编译时错误,如下所示:

cannot use typedX (type int) as type float64 in assignment

未使用的变量

Go 的一个目标是使大型团队更容易协作开发程序。为此,Go 有一些编程语言中独特的规则。在第一章中,你看到 Go 程序需要使用go fmt按照特定方式格式化,以便更轻松地编写代码操作工具并提供编码标准。另一个 Go 的要求是每个声明的局部变量都必须被读取。声明局部变量但不读取其值是编译时错误

编译器对未使用的变量检查并不彻底。只要变量被读取一次,编译器就不会抱怨,即使有写入变量而从未被读取的情况。你可以在Go Playground上或第二章存储库sample_code/assignments_not_read目录中运行以下有效的 Go 程序:

func main() {
    x := 10 // this assignment isn't read!
    x = 20
    fmt.Println(x)
    x = 30 // this assignment isn't read!
}

虽然编译器和go vet不能捕获对x分配值为 10 和 30 的未使用情况,但第三方工具可以检测到它们。我会在“使用代码质量扫描工具”中讨论这些工具。

注意

Go 编译器不会阻止你创建未读的包级别变量。这是你应该避免创建包级别变量的另一个理由。

命名变量和常量

Go 对命名变量的规则与 Go 开发者在命名变量和常量时遵循的模式之间存在差异。与大多数语言一样,Go 要求标识符名称以字母或下划线开头,名称可以包含数字、下划线和字母。Go 对“字母”和“数字”的定义比许多语言宽泛一些。任何被认为是字母或数字的 Unicode 字符都是允许的。这使得示例 2-5 中所有变量定义都是完全有效的 Go 代码。

示例 2-5. 绝对不应该使用的变量名
_0 := 0_0
_𝟙 := 20
π := 3
a := "hello" // Unicode U+FF41
__ := "double underscore" // two underscores
fmt.Println(_0)
fmt.Println(_𝟙)
fmt.Println(π)
fmt.Println(a)
fmt.Println(__)

你可以在Go Playground上测试这段糟糕的代码。虽然它可以工作,但不要使用这样的变量名。这些名称被认为不符合习惯,因为它们违反了确保代码传达其功能的基本规则。这些名称令人困惑,或在许多键盘上难以输入。类似的 Unicode 代码点是最阴险的,因为即使它们看起来是相同的字符,它们代表的是完全不同的变量。你可以在Go Playground上或第二章存储库sample_code/look_alike_code_points目录中运行所示代码。

示例 2-6. 使用类似代码点作为变量名
func main() {
    a := "hello"   // Unicode U+FF41
    a := "goodbye" // standard lowercase a (Unicode U+0061)
    fmt.Println(a)
    fmt.Println(a)
}

当你运行这个程序时,你会得到:

hello
goodbye

即使下划线在变量名中是有效的字符,但它很少被使用,因为 Go 语言习惯不使用蛇形命名法(例如 index_counternumber_tries)。相反,Go 语言习惯使用驼峰命名法(例如 indexCounternumberTries),当标识符名称由多个单词组成时。

注意

在 Go 中,下划线本身 (_) 是一个特殊的标识符名称;在讲解第五章(Chapter 5)中函数时,我会更详细地讨论它。

在许多语言中,常量总是使用全部大写字母编写,用下划线分隔单词(例如 INDEX_COUNTERNUMBER_TRIES)。但 Go 语言不遵循此模式。这是因为 Go 使用包级声明名称的首字母大小写来确定该项是否可以在包外部访问。在讲解第十章(Chapter 10)时,我会重新讨论这一点。

在函数内部,更倾向于使用短变量名。变量的作用域越小,其名称就越短。在 Go 语言中,常见于 for 循环中使用单个字母作为变量名。例如,在 for-range 循环中,变量名 kv(分别代表)被用作变量名。如果使用标准的 for 循环,ij 是常见的索引变量名。对于常见类型的变量,还有其他习惯用法来命名;随着我讲解标准库的更多部分,我会提到它们。

一些类型系统较弱的语言鼓励开发者在变量名中包含变量的预期类型。由于 Go 语言是强类型的,你不需要这样做来跟踪底层类型。然而,你可能会看到 Go 代码中使用类型的首字母作为变量名(例如 i 表示整数或 f 表示浮点数)。当你定义自己的类型时,类似的模式同样适用,特别是在命名接收器变量时(这在“方法”中讨论)。

这些短名称有两个目的。首先,它们消除了重复输入,使你的代码更简洁。其次,它们作为检查代码复杂性的一种方式。如果你发现很难跟踪你的短命名变量,那么你的代码块可能做得太多了。

在包块中命名变量和常量时,使用更具描述性的名称。名称仍然应该不包含类型,但由于作用域更广,你需要一个更完整的名称来澄清值代表的含义。

想要了解更多关于 Go 命名建议的讨论,请阅读Google Go 风格决策的命名部分

练习

这些练习演示了本章讨论的概念。这些练习的解答以及本章的程序位于第二章的存储库中。

  1. 编写一个程序,声明一个名为i且值为 20 的整数变量。将i赋给名为f的浮点变量。打印出if

  2. 编写一个程序,声明一个名为value的常量,它可以分配给整数和浮点变量。将其分配给一个名为i的整数和一个名为f的浮点变量。打印出if

  3. 编写一个具有三个变量的程序,一个名为b且类型为byte,一个名为smallI且类型为int32,一个名为bigI且类型为uint64。为每种类型的变量分配其类型的最大合法值;然后将每个变量加1。打印出它们的值。

总结

在这里,你已经涵盖了很多内容,理解了如何使用预声明类型,声明变量以及如何使用赋值和运算符。在下一章中,我们将看看 Go 语言中的复合类型:数组、切片、映射和结构体。我们还将再次查看字符串和符文以及它们与字符编码的交互。

第三章:复合类型

在上一章中,你已经了解了字面量和预声明变量类型:数字、布尔值和字符串。在本章中,你将了解 Go 中的复合类型、支持它们的内置函数以及与它们一起工作的最佳实践。

数组——过于严格,无法直接使用

和大多数编程语言一样,Go 也有数组。然而,在 Go 中很少直接使用数组。稍后你将了解原因,但首先让我们快速了解数组声明语法和使用。

数组中的所有元素必须是指定的类型。有几种声明样式。在第一种样式中,你指定数组的大小和数组元素的类型:

var x [3]int

这将创建一个包含三个 int 类型元素的数组。因为没有指定初始值,所有元素(x[0]x[1]x[2])都会初始化为 int 类型的零值,当然是 0。如果数组有初始值,可以使用数组字面量指定它们:

var x = [3]int{10, 20, 30}

如果你有一个稀疏数组(大部分元素设置为零值的数组),你可以在数组字面量中仅指定非零值的索引:

var x = [12]int{1, 5: 4, 6, 10: 100, 15}

这将创建一个包含以下值的 12 个 int 类型数组:[1, 0, 0, 0, 0, 4, 6, 0, 0, 0, 100, 15]。

当使用数组字面量初始化数组时,可以用 ... 替换指定数组中元素数量的数字:

var x = [...]int{10, 20, 30}

你可以使用 ==!= 来比较两个数组。如果它们长度相同并且包含相等的值,则它们相等:

var x = [...]int{1, 2, 3}
var y = [3]int{1, 2, 3}
fmt.Println(x == y) // prints true

Go 只有一维数组,但你可以模拟多维数组:

var x [2][3]int

这声明了 x 为长度为 2 的数组,其类型是长度为 3 的 int 数组。这听起来有些迂腐,但有些语言有真正的矩阵支持,比如 Fortran 或 Julia;而 Go 不是其中之一。

和大多数语言一样,Go 中的数组是通过方括号语法进行读写的:

x[0] = 10
fmt.Println(x[2])

你不能读取或写入数组的末尾或使用负索引。如果你使用常量或字面量索引这样做,会导致编译时错误。使用变量索引进行越界读取或写入会在运行时编译,但会失败并产生panic(你将在“panic and recover”中了解更多关于 panic 的信息)。

最后,内置函数 len 接受一个数组并返回其长度:

fmt.Println(len(x))

之前我说过,在 Go 中很少显式使用数组。这是因为它们有一个不寻常的限制:Go 认为数组的大小是数组的类型的一部分。这意味着声明为 [3]int 的数组与声明为 [4]int 的数组是不同的类型。这也意味着你不能使用变量指定数组的大小,因为类型必须在编译时而不是运行时解析。

此外,不能使用类型转换直接将不同大小的数组转换为相同类型。因为不能将不同大小的数组相互转换,所以无法编写处理任何大小数组的函数,也无法将不同大小的数组分配给同一个变量。

注意

当我讨论内存布局时,您将了解数组在背后的工作原理,详见第六章。

因此,请不要在不提前知道所需长度的情况下使用数组。例如,标准库中的一些加密函数返回数组,因为校验和的大小是算法的一部分。这是例外而不是规则。

这引出了一个问题:为什么语言中会有这样一种有限的功能?Go 中存在数组的主要原因是为切片提供后备存储,这是 Go 中最有用的功能之一。

切片

大多数情况下,当您需要一个保存数值序列的数据结构时,应该使用切片。切片如此有用的原因在于您可以根据需要扩展切片的长度。这是因为切片的长度不是其类型的一部分。这消除了数组的最大限制,并允许您编写处理任何大小切片的单个函数(我将在第五章中讨论函数编写)。在介绍了在 Go 中使用切片的基础知识后,我将讨论最佳使用方法。

使用切片看起来很像使用数组,但存在细微差别。首先要注意的是,在声明时不需要指定切片的大小:

var x = []int{10, 20, 30}
提示

使用[...]创建数组。使用[]创建切片。

这使用切片字面量创建了三个int的切片。与数组一样,您还可以仅指定切片字面量中非零值的索引:

var x = []int{1, 5: 4, 6, 10: 100, 15}

这将创建一个包含以下值的 12 个int的切片:[1, 0, 0, 0, 0, 4, 6, 0, 0, 0, 100, 15]。

您可以模拟多维切片并创建切片的切片:

var x [][]int

您可以使用括号语法读取和写入切片,与数组一样,您不能读取或写入超出末尾或使用负索引:

x[0] = 10
fmt.Println(x[2])

到目前为止,切片看起来与数组相同。当您尝试声明不使用字面量的切片时,您开始看到数组和切片之间的区别:

var x []int

这将创建一个int切片。由于未分配任何值,x被分配了切片的零值,这是您之前未见过的:nil。我将在第六章中更多地讨论nil,但它与其他语言中的null略有不同。在 Go 中,nil是一个表示某些类型缺少值的标识符。与前一章中看到的无类型数值常量一样,nil没有类型,因此可以将其赋值或与不同类型的值进行比较。nil切片不包含任何内容。

切片是你见过的第一个不可比较的类型。使用==来比较两个切片是否相同或者使用!=来比较它们是否不同是编译时错误。你只能将一个切片与nil使用==比较:

fmt.Println(x == nil) // prints true

自 Go 1.21 以来,标准库中的slices包包含了两个比较切片的函数。slices.Equal函数接受两个切片,并在这两个切片长度相同且所有元素相等时返回true。它要求切片的元素是可比较的。另一个函数slice.EqualFunc允许你传递一个函数来确定相等性,并且不要求切片元素是可比较的。你将会在“传递函数作为参数”章节学习如何将函数传递给函数。slices包中的其他函数在“向标准库添加泛型”章节中有详细介绍。

x := []int{1, 2, 3, 4, 5}
y := []int{1, 2, 3, 4, 5}
z := []int{1, 2, 3, 4, 5, 6}
s := []string{"a", "b", "c"}
fmt.Println(slices.Equal(x, y)) // prints true
fmt.Println(slices.Equal(x, z)) // prints false
fmt.Println(slices.Equal(x, s)) // does not compile
警告

reflect包中包含一个名为DeepEqual的函数,可以比较几乎任何东西,包括切片。这是一个遗留函数,主要用于测试。在包含slices.Equalslices.EqualFunc之前,通常使用reflect.DeepEqual来比较切片。不要在新代码中使用它,因为它比slices包中的函数更慢,也不够安全。

len

Go 语言提供了几个内置函数来处理切片。当你查看数组时,已经看到了内置的len函数。它对切片也适用。将一个nil切片传递给len将返回 0。

注意

内置函数len是 Go 语言的一部分,因为它可以做一些你自己编写的函数无法做到的事情。你已经看到len的参数可以是任何类型的数组或切片。很快你会看到它也适用于字符串和映射。在“Channels”章节中,你将看到它与通道一起使用。试图将任何其他类型的变量传递给len会导致编译时错误。正如你将在第五章中看到的,Go 语言不允许开发者编写一个接受任何字符串、数组、切片、通道或映射的函数,但却拒绝其他类型。

append

内置的append函数用于增长切片:

var x []int
x = append(x, 10) // assign result to the variable that's passed in

append函数至少接受两个参数,一个任意类型的切片和一个该类型的值。它返回一个相同类型的切片,并将其赋给传递给append的变量。在这个示例中,你正在向一个nil切片追加,但你也可以向已有元素的切片追加:

var x = []int{1, 2, 3}
x = append(x, 4)

你可以一次追加多个值:

x = append(x, 5, 6, 7)

通过使用...操作符将源切片扩展为单独的值,可以将一个切片追加到另一个切片(你将在“可变输入参数和切片”中学到更多关于...操作符的知识):

y := []int{20, 30, 40}
x = append(x, y...)

如果忘记为append返回的值分配值,这将是编译时错误。你可能会想知道为什么这似乎有些重复。我将在第五章中详细讨论这个问题,但是 Go 是一种按值传递的语言。每次将参数传递给函数时,Go 都会复制传入的值。将切片传递给append函数实际上是将切片的副本传递给函数。函数向切片的副本添加值并返回副本。然后,将返回的切片重新分配给调用函数中的变量。

容量

正如你所见,切片是值的序列。切片中的每个元素都分配给连续的内存位置,这使得读写这些值非常快速。切片的长度是已分配值的连续内存位置的数量。每个切片还有一个容量,即预留的连续内存位置数量。这可以大于长度。每次向切片追加值时,都会向切片末尾添加一个或多个值。每添加一个值,长度增加一次。当长度达到容量时,没有更多空间可放置值。如果在长度等于容量时尝试添加额外的值,则append函数使用 Go 运行时为切片分配一个新的背景数组,其容量更大。原始背景数组中的值被复制到新数组中,新值被添加到新背景数组的末尾,然后更新切片以引用新背景数组。最后,更新后的切片被返回。

当切片通过append增长时,Go 运行时需要时间来分配新内存,并将现有数据从旧内存复制到新内存。旧内存也需要进行垃圾回收。因此,当当前容量小于 256 时,Go 运行时通常会将切片的容量加倍。截至 Go 1.18,切片的规则是:当当前容量小于 256 时,切片的容量增加(current_capacity + 768)/4。这慢慢地以 25%的增长收敛(容量为 512 的切片将增长 63%,但容量为 4096 的切片只增长 30%)。

就像内置的len函数返回切片的当前长度一样,内置的cap函数返回切片的当前容量。与len相比,cap的使用频率要低得多。大多数情况下,cap用于检查切片是否足够大以容纳新数据,或者是否需要调用make来创建一个新的切片。

你也可以将数组传递给cap函数,但对于数组,cap总是返回与len相同的值。不要将其放入你的代码中,而是将此技巧留到 Go 知识竞赛之夜。

让我们看看向切片添加元素如何改变长度和容量。在示例 3-1 中运行代码在Go Playground或在第三章存储库sample_code/len_cap目录中。

示例 3-1。理解容量
var x []int
fmt.Println(x, len(x), cap(x))
x = append(x, 10)
fmt.Println(x, len(x), cap(x))
x = append(x, 20)
fmt.Println(x, len(x), cap(x))
x = append(x, 30)
fmt.Println(x, len(x), cap(x))
x = append(x, 40)
fmt.Println(x, len(x), cap(x))
x = append(x, 50)
fmt.Println(x, len(x), cap(x))

当您构建并运行代码时,您将看到以下输出。注意容量如何何时增加:

[] 0 0
[10] 1 1
[10 20] 2 2
[10 20 30] 3 4
[10 20 30 40] 4 4
[10 20 30 40 50] 5 8

虽然切片自动增长很方便,但是一次性为其指定大小要高效得多。如果您知道要放入切片中的事物数量,使用正确的初始容量来创建它。您可以使用make函数来实现这一点。

make

您已经看到两种声明切片的方式,使用切片字面量或nil零值。虽然有用,但是这两种方式都不允许您创建已指定长度或容量的空切片。这是内置的make函数的任务。它允许您指定类型、长度,并且可选地指定容量。让我们来看一下:

x := make([]int, 5)

这将创建一个长度为 5 且容量为 5 的int切片。由于长度为 5,x[0]x[4]都是有效元素,并且它们都初始化为 0。

一个常见的初学者错误是尝试使用append来填充这些初始元素:

x := make([]int, 5)
x = append(x, 10)

将 10 放在切片末尾,在 0–4 元素的零值之后,因为append总是增加切片的长度。现在x的值是[0 0 0 0 0 10],长度为 6,容量为 10(一旦附加的第六个元素,容量就会加倍)。

您还可以使用make指定初始容量:

x := make([]int, 5, 10)

这将创建一个长度为 5 且容量为 10 的int切片。

您还可以创建长度为零但容量大于零的切片:

x := make([]int, 0, 10)

在这种情况下,您有一个非nil的长度为 0 但容量为 10 的切片。由于长度为 0,您无法直接对其进行索引,但可以向其附加值:

x := make([]int, 0, 10)
x = append(x, 5,6,7,8)

现在x的值是[5 6 7 8],长度为 4,容量为 10。

警告

绝不要指定容量小于长度!这是一个编译时错误,如果使用常量或数字字面量这样做。如果您使用变量指定比长度更小的容量,您的程序将在运行时发生恐慌。

清空切片

Go 1.21 添加了一个clear函数,接受一个切片并将所有切片的元素设置为它们的零值。切片的长度保持不变。以下代码:

s := []string{"first", "second", "third"}
fmt.Println(s, len(s))
clear(s)
fmt.Println(s, len(s))

输出如下:

[first second third] 3
[  ] 3

(记住,字符串的零值是空字符串""!)

声明您的切片

现在您已经看到所有这些创建切片的方法,您应该如何选择使用哪种切片声明风格?主要目标是尽量减少切片需要增长的次数。如果可能切片根本不需要增长,请使用没有分配值的var声明来创建nil切片,如示例 3-2 所示。

示例 3-2。声明一个可能保持nil的切片
var data []int
注意

你可以使用空切片字面量创建一个切片:

var x = []int{}

这将创建一个零长度和零容量的切片。这与nil切片的不同是令人困惑的。由于实现原因,将零长度的切片与nil比较返回false,而将nil切片与nil比较返回true。为简单起见,推荐使用nil切片。零长度的切片仅在将切片转换为 JSON 时有用。你将在“encoding/json”中进一步了解。

如果有一些起始值,或者切片的值不会改变,那么切片字面量是一个不错的选择(见示例 3-3)。

示例 3-3. 使用默认值声明切片
data := []int{2, 4, 6, 8} // numbers we appreciate

如果你对切片需要多大有一个很好的想法,但在编写程序时不知道这些值是多少,请使用make。那么问题就变成了在调用make时是否应指定一个非零长度,或者指定零长度和非零容量。有三种可能性:

  • 如果你使用切片作为缓冲区(你会在“io and Friends”中看到这一点),那么请指定一个非零长度。

  • 如果你确信知道你想要的确切大小,你可以指定长度并索引到切片中设置值。当在一个切片中转换值并将它们存储在第二个切片中时,通常会这样做。这种方法的缺点是,如果大小错误,你将在切片末尾得到零值,或者因尝试访问不存在的元素而引发恐慌。

  • 在其他情况下,使用零长度和指定容量的make。这允许你使用append向切片添加项目。如果项目数量较少,你不会在末尾得到多余的零值。如果项目数量较多,你的代码不会出现恐慌。

Go 社区在第二种和第三种方法之间存在分歧。我个人更喜欢使用一个初始化为零长度的切片来使用append。在某些情况下可能会慢一些,但较不容易引入 bug。

警告

append总是增加切片的长度!如果你使用make指定了切片的长度,请确保在这样做之前是有意为之,否则可能会在切片的开头得到一堆意外的零值。

切片切片

切片表达式从一个切片中创建一个切片。它写在方括号内,由起始偏移和结束偏移组成,用冒号(:)分隔。起始偏移是包含在新切片中的第一个位置,结束偏移是要包含的最后一个位置的下一个位置。如果省略起始偏移,假定为 0。同样,如果省略结束偏移,则用切片的结尾替代。通过在示例 3-4 中的代码上运行它,你可以看到它是如何工作的,可以在The Go Playground第三章代码库sample_code/slicing_slices目录中查看。

示例 3-4. 切片切片
x := []string{"a", "b", "c", "d"}
y := x[:2]
z := x[1:]
d := x[1:3]
e := x[:]
fmt.Println("x:", x)
fmt.Println("y:", y)
fmt.Println("z:", z)
fmt.Println("d:", d)
fmt.Println("e:", e)

它会得到以下输出:

x: [a b c d]
y: [a b]
z: [b c d]
d: [b c]
e: [a b c d]

当你从一个切片中取出另一个切片时,你并没有复制数据。相反,现在你有两个共享内存的变量。这意味着对切片中元素的更改会影响所有共享该元素的切片。让我们看看当你改变值时会发生什么。你可以在示例 3-5 中的代码上运行这段代码,在Go Playground第三章代码库sample_code/slice_share_storage目录中运行。

示例 3-5. 具有重叠存储的切片
x := []string{"a", "b", "c", "d"}
y := x[:2]
z := x[1:]
x[1] = "y"
y[0] = "x"
z[1] = "z"
fmt.Println("x:", x)
fmt.Println("y:", y)
fmt.Println("z:", z)

你将得到以下输出:

x: [x y z d]
y: [x y]
z: [y z d]

修改x同时也修改了yz,而对yz的修改也会影响x

当与append结合使用时,切片切片变得更加令人困惑。在示例 3-6 中尝试这段代码,在Go Playground第三章代码库sample_code/slice_append_storage目录中尝试。

示例 3-6. append使得重叠切片变得更加混乱
x := []string{"a", "b", "c", "d"}
y := x[:2]
fmt.Println(cap(x), cap(y))
y = append(y, "z")
fmt.Println("x:", x)
fmt.Println("y:", y)

运行此代码会得到以下输出:

4 4
x: [a b z d]
y: [a b z]

当你从另一个切片中取出一个切片时,子切片的容量被设置为原始切片的容量减去子切片在原始切片中的起始偏移量。这意味着原始切片中子切片之后的元素,包括未使用的容量,会被两个切片共享。

当你从x中创建y切片时,长度设置为 2,但容量设置为 4,与x相同。由于容量为 4,向y末尾添加元素会将值放在x的第三个位置。

这种行为会产生一些奇怪的场景,多个切片相互添加并覆盖彼此的数据。看看你能否猜到示例 3-7 中的代码会打印出什么,然后在Go Playground第三章代码库sample_code/confusing_slices目录中运行以查看是否猜对了。

示例 3-7. 更加令人困惑的切片
x := make([]string, 0, 5)
x = append(x, "a", "b", "c", "d")
y := x[:2]
z := x[2:]
fmt.Println(cap(x), cap(y), cap(z))
y = append(y, "i", "j", "k")
x = append(x, "x")
z = append(z, "y")
fmt.Println("x:", x)
fmt.Println("y:", y)
fmt.Println("z:", z)

为了避免复杂的切片情况,你应该要么永远不要在子切片上使用append,要么确保append不会通过使用完整切片表达式而导致覆盖。这有点奇怪,但它清楚地表明了父切片和子切片之间共享的内存量。完整切片表达式包括第三部分,指示了父切片容量中可用于子切片的最后位置。从这个数字中减去起始偏移量即可得到子切片的容量。示例 3-8 展示了前一示例中修改为使用完整切片表达式的前四行代码。

示例 3-8. 完整切片表达式可以防止append的问题
x := make([]string, 0, 5)
x = append(x, "a", "b", "c", "d")
y := x[:2:2]
z := x[2:4:4]

The Go Playground或者第三章代码库sample_code/full_slice_expression目录中尝试这段代码。yz的容量都为2。因为你限制了子切片的容量为它们的长度,向yz附加额外元素会创建新的切片,这些切片不会与其他切片交互。运行此代码后,x设置为[a b c d x]y设置为[a b i j k]z设置为[c d y]

警告

当对切片进行切片时要小心!两个切片共享同一内存,对其中一个的更改会反映在另一个上。避免在切片后修改切片或者在通过切片生成后修改切片。使用三部分切片表达式防止append在切片之间共享容量。

复制

如果需要创建与原始切片独立的切片,请使用内置的copy函数。我们来看一个简单的例子,可以在The Go Playground上运行,或者在第三章代码库sample_code/copy_slice目录中运行:

x := []int{1, 2, 3, 4}
y := make([]int, 4)
num := copy(y, x)
fmt.Println(y, num)

你会得到以下输出:

[1 2 3 4] 4

copy函数接受两个参数。第一个是目标切片,第二个是源切片。该函数尽可能从源切片向目标切片复制尽可能多的值,受限于较小的切片,并返回复制的元素数。xy容量不重要;长度才重要。

你也可以复制切片的子集。以下代码将四元素切片的前两个元素复制到两元素切片中:

x := []int{1, 2, 3, 4}
y := make([]int, 2)
num := copy(y, x)

变量y设置为[1 2],num设置为 2。

你也可以从源切片的中间复制:

x := []int{1, 2, 3, 4}
y := make([]int, 2)
copy(y, x[2:])

通过对切片进行切片来复制x的第三和第四个元素。还请注意不要将copy的输出分配给变量。如果你不需要复制的元素数,那么你不需要分配它。

copy函数允许在覆盖底层切片的重叠部分之间进行复制:

x := []int{1, 2, 3, 4}
num := copy(x[:3], x[1:])
fmt.Println(x, num)

在这种情况下,你在x的最后三个值之上复制了x的前三个值。这将打印出[2 3 4 4] 3。

你可以通过对数组进行切片来使用copy。你可以将数组作为复制的源或目标。你可以在The Go Playground上试试以下代码,或者在第三章代码库sample_code/copy_array目录中试试:

x := []int{1, 2, 3, 4}
d := [4]int{5, 6, 7, 8}
y := make([]int, 2)
copy(y, d[:])
fmt.Println(y)
copy(d[:], x)
fmt.Println(d)

第一次调用copy将数组d的前两个值复制到切片y中。第二次将切片x的所有值复制到数组d中。这将产生以下输出:

[5 6]
[1 2 3 4]

将数组转换为切片

切片不是你唯一可以切片的东西。如果你有一个数组,你可以使用切片表达式从中获取切片。这是将数组连接到仅接受切片的函数的有用方式。要将整个数组转换为切片,请使用[:]语法:

xArray := [4]int{5, 6, 7, 8}
xSlice := xArray[:]

你也可以将数组的子集转换为切片:

x := [4]int{5, 6, 7, 8}
y := x[:2]
z := x[2:]

注意从数组中获取切片具有与从切片中获取切片相同的内存共享属性。如果在The Go Playground第三章仓库中的sample_code/slice_array_memory目录中运行以下代码:

x := [4]int{5, 6, 7, 8}
y := x[:2]
z := x[2:]
x[0] = 10
fmt.Println("x:", x)
fmt.Println("y:", y)
fmt.Println("z:", z)

你会得到如下输出:

x: [10 6 7 8]
y: [10 6]
z: [7 8]

将切片转换为数组

使用类型转换可以从切片创建一个数组变量。你可以将整个切片转换为相同类型的数组,或者从切片的子集创建数组。

当你将切片转换为数组时,切片中的数据将被复制到新的内存中。这意味着对切片的更改不会影响数组,反之亦然。

下面的代码:

xSlice := []int{1, 2, 3, 4}
xArray := [4]int(xSlice)
smallArray := [2]int(xSlice)
xSlice[0] = 10
fmt.Println(xSlice)
fmt.Println(xArray)
fmt.Println(smallArray)

输出结果如下:

[10 2 3 4]
[1 2 3 4]
[1 2]

数组的大小必须在编译时指定。在切片到数组类型转换中使用[...]会导致编译时错误。

虽然数组的大小可以比切片的大小小,但不能比其大。不幸的是,编译器无法检测到这一点,如果你指定的数组大小比切片的长度(而非容量)大,你的代码将在运行时崩溃。以下代码:

panicArray := [5]int(xSlice)
fmt.Println(panicArray)

在运行时,以下消息导致程序崩溃:

panic: runtime error: cannot convert slice with length 4 to array
     or pointer to array with length 5
注意

尽管我还没有讨论过指针,但你也可以使用类型转换将切片转换为数组的指针:

xSlice := []int{1,2,3,4}
xArrayPointer := (*[4]int)(xSlice)

将切片转换为数组指针后,两者之间共享存储。对其中一个的更改会影响另一个:

xSlice[0] = 10
xArrayPointer[1] = 20
fmt.Println(xSlice) // prints [10 20 3 4]
fmt.Println(xArrayPointer) // prints &[10 20 3 4]

指针在第六章中有详细介绍。

你可以在The Go Playground第三章仓库sample_code/array_conversion目录中尝试所有数组类型转换。

在“数组——直接使用过于严格”中,我提到当传入的数组大小可能变化时,无法将数组用作函数参数。技术上,你可以通过将数组转换为切片,再将切片转换为不同大小的数组,并将第二个数组传递给函数来绕过此限制。第二个数组必须比第一个数组短,否则程序将会崩溃。虽然这在紧急情况下可能有所帮助,但如果你经常这样做,强烈考虑修改函数的 API 以接受切片而不是数组。

字符串、符文和字节

现在我已经讨论了切片,我们可以再次看字符串。你可能会认为 Go 语言中的字符串是由符文组成的,但事实并非如此。在底层,Go 语言使用一系列字节来表示字符串。这些字节可以不必遵循任何特定的字符编码,但是几个 Go 库函数(以及我在下一章中讨论的for-range循环)假设一个字符串由一系列 UTF-8 编码的代码点组成。

注意

根据语言规范,Go 源代码总是用 UTF-8 编写。除非在字符串文字中使用十六进制转义,否则你的字符串文字是用 UTF-8 编写的。

就像你可以从数组或切片中提取单个值一样,你也可以通过使用索引表达式从字符串中提取单个值:

var s string = "Hello there"
var b byte = s[6]

像数组和切片一样,字符串的索引是从零开始的;在这个示例中,b被赋予了在s中第七个位置的数值116(小写字母 t 的 UTF-8 值)。

你用于数组和切片的切片表达式符号也适用于字符串:

var s string = "Hello there"
var s2 string = s[4:7]
var s3 string = s[:5]
var s4 string = s[6:]

这将“o t”赋给s2,“Hello”赋给s3,“there”赋给s4。你可以在The Go Playground第三章代码库sample_code/string_slicing目录中尝试这段代码。

尽管 Go 语言允许你使用切片符号来创建子串,并使用索引符号从字符串中提取单个条目,这确实很方便,但在这样做时要小心。由于字符串是不可变的,它们不会像切片的切片那样有修改问题。不过,也存在不同的问题。一个字符串由一系列字节组成,而 UTF-8 中的一个代码点可以是一到四个字节长。前面的示例完全由 UTF-8 中长度为一个字节的代码点组成,所以一切都按预期进行。但是在处理非英语或表情符号的语言时,你会遇到长度为多个字节的 UTF-8 代码点:

var s string = "Hello "
var s2 string = s[4:7]
var s3 string = s[:5]
var s4 string = s[6:]

在这个示例中,s3仍然等于“Hello。”变量s4设置为太阳表情符号。但是s2并没有被设置为“o 。”而是得到了“o 。”这是因为你只复制了太阳表情符号代码点的第一个字节,这个字节单独来看不是有效的代码点。

Go 语言允许你将字符串传递给内置的len函数以找到字符串的长度。由于字符串索引和切片表达式计算的是字节位置,因此返回的长度是以字节为单位的长度,而不是代码点的长度:

var s string = "Hello "
fmt.Println(len(s))

这段代码打印出 10,而不是 7,因为用 UTF-8 编码的太阳笑脸表情符号需要四个字节来表示。你可以在The Go Playground第三章代码库sample_code/sun_slicing目录中运行这些太阳表情符号的示例。

警告

尽管 Go 允许您在字符串中使用切片和索引语法,但只有在您知道您的字符串仅包含占用一个字节的字符时才应使用它。

由于 rune、字符串和字节之间的复杂关系,Go 提供了一些有趣的类型转换。单个 rune 或字节可以转换为字符串:

var a rune    = 'x'
var s string  = string(a)
var b byte    = 'y'
var s2 string = string(b)
警告

新手 Go 开发者常见的一个错误是尝试通过类型转换将int转换为string

var x int = 65
var y = string(x)
fmt.Println(y)

这导致y具有值“A”,而不是“65”。从 Go 1.15 开始,go vet阻止从除runebyte以外的任何整数类型进行字符串类型转换。

字符串可以在字节片段或 rune 切片之间来回转换。在示例 3-9 和第三章存储库中的sample_code/string_to_slice目录中尝试此代码:

示例 3-9. 将字符串转换为切片
var s string = "Hello, "
var bs []byte = []byte(s)
var rs []rune = []rune(s)
fmt.Println(bs)
fmt.Println(rs)

当您运行此代码时,您会看到以下输出:

[72 101 108 108 111 44 32 240 159 140 158]
[72 101 108 108 111 44 32 127774]

第一行输出将字符串转换为 UTF-8 字节。第二行将字符串转换为 runes。

Go 中大多数数据都是按字节序列读取和写入的,因此最常见的字符串类型转换是通过字节切片来回转换。很少使用 rune 切片。

不要使用切片和索引表达式处理字符串,应该使用标准库中stringsunicode/utf8包中的函数从字符串中提取子字符串和代码点。在下一章中,您将看到如何使用for-range循环迭代字符串中的代码点。

映射

当您有顺序数据时,切片非常有用。与大多数语言一样,Go 提供了一种内置数据类型,用于在您想要将一个值关联到另一个值的情况下使用。映射类型的书写方式为map[keyType]valueType。让我们看看声明映射的几种方法。首先,您可以使用var声明创建一个映射变量,该变量被设置为其零值:

var nilMap map[string]int

在这种情况下,nilMap被声明为具有string键和int值的映射。映射的零值是nilnil映射的长度为 0。尝试读取nil映射始终返回映射值类型的零值。但是,尝试向nil映射变量写入数据将导致恐慌

您可以使用:=声明通过map 字面量创建一个映射变量:

totalWins := map[string]int{}

在这种情况下,您正在使用空的映射字面量。这不同于nil映射。它的长度为 0,但您可以读取和写入分配了空映射字面量的映射。以下是一个非空映射字面量的示例:

teams := map[string][]string {
    "Orcas": []string{"Fred", "Ralph", "Bijou"},
    "Lions": []string{"Sarah", "Peter", "Billie"},
    "Kittens": []string{"Waldo", "Raul", "Ze"},
}

映射字面量的主体是键,后跟冒号(:),然后是值。每个键值对在映射中用逗号分隔,即使是最后一行也是如此。在这个例子中,值是一个字符串切片。映射中值的类型可以是任何类型。关于键类型的一些限制稍后会讨论。

如果你知道打算放入地图中的键值对数量,但不知道确切的值,可以使用make创建具有默认大小的地图:

ages := make(map[int][]string, 10)

使用make创建的地图仍然具有长度为 0,并且它们可以超过最初指定的大小。

地图在几个方面类似于切片:

  • 随着向地图添加键值对,地图会自动增长。

  • 如果你知道打算插入地图中的键值对数量,可以使用make创建具有特定初始大小的地图。

  • 将地图传递给len函数可以告诉你地图中键值对的数量。

  • 地图的零值为nil

  • 地图不可比较。你可以检查它们是否等于nil,但不能使用==检查两个地图是否具有相同的键和值,也不能使用!=检查它们是否不同。

地图的键可以是任何可比较的类型。这意味着你不能使用切片或地图作为地图的键

提示

何时应该使用地图,何时应该使用切片?当数据应按顺序处理或元素的顺序重要时,应使用切片来表示数据列表。

当你需要使用除递增整数值以外的其他方式来组织值时,地图非常有用,例如名称。

读取和写入地图

让我们看一个声明、写入和读取地图的简短程序。你可以在示例 3-10 上的The Go Playground第三章存储库sample_code/map_read_write目录中运行此程序。

示例 3-10. 使用地图
totalWins := map[string]int{}
totalWins["Orcas"] = 1
totalWins["Lions"] = 2
fmt.Println(totalWins["Orcas"])
fmt.Println(totalWins["Kittens"])
totalWins["Kittens"]++
fmt.Println(totalWins["Kittens"])
totalWins["Lions"] = 3
fmt.Println(totalWins["Lions"])

运行此程序时,你将看到以下输出:

1
0
1
3

通过将键放在方括号内并使用=指定值来为地图键分配值,通过将键放在方括号内读取分配给地图键的值。请注意,你不能使用:=来为地图键分配值。

当你尝试读取从未设置过的地图键的值时,地图将返回地图值类型的零值。在本例中,值类型为int,因此会返回 0. 你可以使用++运算符来递增地图键的数值。因为地图默认返回其零值,所以即使与键关联的现有值不存在,这也适用。

逗号 ok 习语

正如你所见,如果要查找与地图中不存在的键关联的值,地图将返回零值。这在实现像前面看到的totalWins计数器时非常方便。然而,有时确实需要查找键是否存在于地图中。Go 提供了逗号 ok 习语来区分关联零值的键和不在地图中的键:

m := map[string]int{
    "hello": 5,
    "world": 0,
}
v, ok := m["hello"]
fmt.Println(v, ok)

v, ok = m["world"]
fmt.Println(v, ok)

v, ok = m["goodbye"]
fmt.Println(v, ok)

与将映射读取的结果分配给单个变量不同,使用逗号 ok 惯用法将映射读取的结果分配给两个变量。第一个变量获取与键关联的值。第二个返回的值是一个布尔值,通常命名为ok。如果oktrue,则键存在于映射中。如果okfalse,则键不存在。在这个示例中,代码打印出5 true0 true0 false

注意

在 Go 语言中,当你想要区分读取一个值和返回零值时,可以使用逗号 ok 惯用法。你将在第十二章再次看到它,并且在使用类型断言时也会看到它在第七章。

从映射中删除

键-值对可以通过内置的delete函数从映射中移除:

m := map[string]int{
    "hello": 5,
    "world": 10,
}
delete(m, "hello")

delete函数接收一个映射和一个键,然后移除具有指定键的键-值对。如果键不在映射中或映射为nil,则什么也不发生。delete函数不返回值。

清空映射

你在“清空切片”中看到的clear函数也适用于映射。清空后的映射长度被设为零,不同于清空切片。以下代码:

m := map[string]int{
    "hello": 5,
    "world": 10,
}
fmt.Println(m, len(m))
clear(m)
fmt.Println(m, len(m))

输出如下:

map[hello:5 world:10] 2
map[] 0

比较映射

Go 1.21 添加了一个名为maps的包到标准库中,其中包含用于处理映射的辅助函数。你将在“向标准库添加泛型”中了解更多关于该包的信息。包中的两个函数对比两个映射是否相等非常有用,maps.Equalmaps.EqualFunc。它们类似于slices.Equalslices.EqualFunc函数:

m := map[string]int{
    "hello": 5,
    "world": 10,
}
n := map[string]int{
    "world": 10,
    "hello": 5,
}
fmt.Println(maps.Equal(m, n)) // prints true

使用映射作为集合

许多语言在它们的标准库中包含了集合。集合是一种数据类型,确保值最多只有一个,但不保证值的顺序。检查元素是否在集合中是快速的,无论集合中有多少元素。(检查元素是否在切片中则会花费更多时间,因为切片中的元素越多,时间越长。)

Go 语言不包含集合,但可以使用映射来模拟其部分功能。将映射的键用于要放入集合的类型,并将值用bool类型。示例 3-11 中的代码演示了这一概念。你可以在The Go Playground第三章示例代码库sample_code/map_set目录中运行它。

示例 3-11. 使用映射作为集合
intSet := map[int]bool{}
vals := []int{5, 10, 2, 5, 8, 7, 3, 9, 1, 2, 10}
for _, v := range vals {
    intSet[v] = true
}
fmt.Println(len(vals), len(intSet))
fmt.Println(intSet[5])
fmt.Println(intSet[500])
if intSet[100] {
    fmt.Println("100 is in the set")
}

你需要一个int类型的集合,因此创建一个映射,其中键是int类型,值是bool类型。你使用for-range循环(我在“for-range 语句”中讨论)迭代vals中的值,将它们放入intSet中,并将每个int与布尔值true关联。

我们向intSet写入了 11 个值,但intSet的长度为 8,因为在映射中不能有重复的键。如果你在intSet中查找 5,它会返回true,因为存在一个值为 5 的键。然而,如果你在intSet中查找 500 或 100,它会返回false。这是因为你没有将这两个值放入intSet中,这导致映射返回映射值的零值,而bool类型的零值为false

如果你需要提供像并集、交集和差集等操作的集合,你可以自己编写一个,或者使用其中一个提供这些功能的许多第三方库。(关于使用第三方库,您将在第十章中学到更多。)

注意

有些人喜欢在用映射实现集合时使用struct{}作为值。(我将在下一节讨论结构体。)优点是空结构体使用零字节,而布尔值使用一个字节。

缺点是使用struct{}会使您的代码变得笨拙。赋值不够明显,并且您需要使用逗号-ok 惯用法来检查值是否在集合中:

intSet := map[int]struct{}{}
vals := []int{5, 10, 2, 5, 8, 7, 3, 9, 1, 2, 10}
for _, v := range vals {
    intSet[v] = struct{}{}
}
if _, ok := intSet[5]; ok {
    fmt.Println("5 is in the set")
}

除非您有非常大的集合,否则内存使用的差异可能不足以抵消其缺点。

结构体

映射是存储某些类型数据的方便方式,但它们有一些限制。它们不定义 API,因为没有办法限制映射只允许特定的键。此外,映射中所有值必须是相同类型的。因此,映射不是将数据从一个函数传递到另一个函数的理想方式。当你有想要一起组合的相关数据时,你应该定义一个结构体

注意

如果你已经了解一种面向对象的语言,你可能想知道类和结构体之间的区别。区别很简单:Go 语言没有类,因为它没有继承。这并不意味着 Go 语言没有一些面向对象语言的特性,只是它的实现方式有些不同。您将在第七章中学到更多关于 Go 语言面向对象特性的内容。

大多数语言都有类似结构体的概念,Go 语言用于读取和写入结构体的语法看起来应该很熟悉:

type person struct {
    name string
    age  int
    pet  string
}

结构体类型使用关键字type、结构体类型的名称、关键字struct和一对大括号{}定义。在大括号内,列出结构体的字段。就像在var声明中先放变量名后放变量类型一样,在结构体声明中先放结构体字段名后放结构体字段类型。还要注意,与映射字面量不同,结构体声明中没有逗号分隔字段。您可以在函数内或函数外定义结构体类型。在函数内定义的结构体类型只能在该函数内使用。(关于函数,您将在第五章中学到更多。)

注意

从技术上讲,你可以将结构体定义作用域限定在任何块级别。在第四章中你会学到更多关于块的知识。

一旦声明了结构体类型,就可以定义该类型的变量:

var fred person

在这里我们使用了var声明。由于fred没有被赋值,它得到了person结构体类型的零值。零值结构体将所有字段设置为字段的零值。

结构体字面量也可以分配给变量:

bob := person{}

与映射不同,分配空的结构体字面量和不分配任何值之间没有区别。两者都会将结构体中的所有字段初始化为它们的零值。非空结构体字面量有两种风格。首先,结构体字面量可以被指定为字段值的逗号分隔列表,放在大括号内:

julia := person{
    "Julia",
    40,
    "cat",
}

当使用这种结构体字面量格式时,必须为结构体中的每个字段指定一个值,并且这些值按照结构体定义中声明的顺序赋给字段。

第二种结构体字面量风格看起来像映射字面量的风格:

beth := person{
    age:  30,
    name: "Beth",
}

你可以使用结构体中字段的名称来指定值。这种风格有一些优势。它允许你以任意顺序指定字段,并且不需要为所有字段提供值。未指定的字段将被设置为其零值。

你不能混合这两种结构体字面量风格:要么所有字段都用名称指定,要么一个也不用。对于所有字段始终被指定的小结构体,简单的结构体字面量风格是可以接受的。在其他情况下,使用名称。这样更冗长,但清楚地表明了赋值给哪个字段的值,无需引用结构体定义。这也更易维护。如果你初始化结构体时不使用字段名称,并且结构体的未来版本添加了额外的字段,你的代码将不再编译。

结构体中的字段可以通过点表示法进行访问:

bob.name = "Bob"
fmt.Println(bob.name)

就像你用括号同时读取和写入映射一样,你可以使用点表示法读取和写入结构体字段。

匿名结构体

你也可以声明一个变量实现一个结构体类型,而不先给结构体类型命名。这称为匿名结构体

var person struct {
    name string
    age  int
    pet  string
}

person.name = "bob"
person.age = 50
person.pet = "dog"

pet := struct {
    name string
    kind string
}{
    name: "Fido",
    kind: "dog",
}

在这个例子中,变量personpet的类型是匿名结构体。你可以像对命名结构体类型一样,分配(和读取)匿名结构体中的字段。正如你可以用结构体字面量初始化命名结构体的实例一样,你也可以对匿名结构体做同样的事情。

也许你会想知道,拥有仅关联于单个实例的数据类型何时有用。匿名结构体在两种常见情况下非常方便。第一种情况是当你将外部数据转换为结构体,或者将结构体转换为外部数据(如 JSON 或 Protocol Buffers)时。分别称为反序列化序列化数据。你将在“encoding/json”中学习如何做到这一点。

编写测试是匿名结构体出现的另一个地方。在第十五章中编写表驱动测试时,您将使用匿名结构体的切片。

比较和转换结构体

结构体是否可比取决于结构体的字段。完全由可比较类型组成的结构体是可比较的;具有切片或映射字段的结构体不可比较(正如您将在后面的章节中看到的,函数和通道字段也会阻止结构体可比较)。

与 Python 或 Ruby 不同,在 Go 中没有魔术方法可以被重写来重新定义相等性并使 ==!= 适用于不可比较的结构体。当然,您可以编写自己的函数来比较结构体。

就像 Go 不允许不同原始类型的变量之间的比较一样,Go 也不允许表示不同类型的结构体之间的比较。如果两个结构体的字段具有相同的名称、顺序和类型,Go 允许你从一个结构体类型转换为另一个结构体类型。让我们看看这意味着什么。给定这个结构体:

type firstPerson struct {
    name string
    age  int
}

您可以使用类型转换将 firstPerson 的实例转换为 secondPerson,但是您不能使用 == 来比较 firstPerson 的实例和 secondPerson 的实例,因为它们是不同的类型:

type secondPerson struct {
    name string
    age  int
}

你不能将 firstPerson 的实例转换为 thirdPerson,因为字段的顺序不同:

type thirdPerson struct {
    age  int
    name string
}

你不能将 firstPerson 的实例转换为 fourthPerson,因为字段名称不匹配:

type fourthPerson struct {
    firstName string
    age       int
}

最后,你不能将 firstPerson 的实例转换为 fifthPerson,因为有一个额外的字段:

type fifthPerson struct {
    name          string
    age           int
    favoriteColor string
}

匿名结构体增加了一个小变化:如果正在比较两个结构体变量,并且至少有一个类型是匿名结构体,那么如果两个结构体的字段具有相同的名称、顺序和类型,您可以在不进行类型转换的情况下比较它们。如果两个结构体的字段具有相同的名称、顺序和类型,您还可以在命名和匿名结构体类型之间进行赋值:

type firstPerson struct {
    name string
    age  int
}
f := firstPerson{
    name: "Bob",
    age:  50,
}
var g struct {
    name string
    age  int
}

// compiles -- can use = and == between identical named and anonymous structs
g = f
fmt.Println(f == g)

练习

以下练习将测试您对 Go 复合类型的了解。您可以在第三章存储库exercise_solutions 目录中找到解决方案。

  1. 编写一个程序,定义一个名为 greetings 的字符串切片类型的变量,其值为 "Hello""Hola""नमस्कार""こんにちは""Привіт"。创建一个包含前两个值的子切片;第二个子切片包含第二、第三和第四个值;第三个子切片包含第四和第五个值。打印出所有四个切片。

  2. 编写一个程序,定义一个名为 message 的字符串变量,其值为 "Hi ![](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/lrn-go-2e/img/woman.png) and ![](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/lrn-go-2e/img/man.png)",并打印出其中的第四个符文作为字符,而不是数字。

  3. 编写一个程序,定义一个名为Employee的结构体,它包含三个字段:firstNamelastNameid。前两个字段的类型是string,最后一个字段(id)的类型是int。使用任意值创建三个此结构体的实例。第一个实例使用结构体字面量样式且没有字段名初始化,第二个实例使用结构体字面量样式并指定字段名,第三个实例使用var声明。使用点符号赋值填充第三个结构体的字段。打印出这三个结构体的所有信息。

结语

你已经学到了很多关于 Go 语言中复合类型的知识。除了学习更多关于字符串的知识外,现在你还知道如何使用内置的通用容器类型:切片(slices)和映射(maps)。你还可以通过结构体构建自己的复合类型。在下一章中,你将看到 Go 语言的控制结构:forif/elseswitch。你还将了解 Go 语言如何将代码组织成块,并且不同的块级别可能会导致意想不到的行为。

第四章:块、遮蔽和控制结构

现在我已经讲解了变量、常量和内置类型,你已经准备好学习编程逻辑和组织了。我将首先解释块及其控制标识符何时可用。然后我会介绍 Go 语言的控制结构:ifforswitch。最后,我将讨论goto及其唯一应用场景。

在许多地方,Go 语言允许你声明变量。你可以在函数外部声明它们,作为函数的参数,以及在函数内部作为局部变量。

注意

到目前为止,你只写了main函数,但在下一章节中你将编写带参数的函数。

每次声明发生的地方称为。变量、常量、类型和在任何函数外声明的函数位于 块中。你在程序中使用import语句来访问打印和数学函数(我将在第十章详细讨论它们)。它们为其他包定义了在包含import语句的文件中有效的名称。这些名称位于文件 块中。函数的顶级所有变量(包括函数的参数)都位于一个块中。在函数内部,每一组大括号({})定义另一个块,在稍后你将看到 Go 语言中的控制结构定义了它们自己的块。

你可以从任何外部块中访问在其中一个内部块中定义的标识符。这引出了一个问题:当你在一个包含块中有一个与标识符同名的声明时会发生什么?如果这样做,你会遮蔽 外部块中创建的标识符。

遮蔽变量

在我解释什么是遮蔽之前,让我们看一些代码(参见示例 4-1)。你可以在The Go Playground上运行它,或者在第四章示例代码/遮蔽变量目录中的仓库中运行它。

示例 4-1。遮蔽变量
func main() {
    x := 10
    if x > 5 {
        fmt.Println(x)
        x := 5
        fmt.Println(x)
    }
    fmt.Println(x)
}

Before you run this code, try to guess what it’s going to print out:

  • 什么也不打印;代码无法编译

  • 10 on line one, 5 on line two, 5 on line three

  • 10 on line one, 5 on line two, 10 on line three

下面是发生的事情:

10
5
10

遮蔽变量 是指与包含块中的变量同名的变量。只要遮蔽变量存在,就无法访问被遮蔽的变量。

在这种情况下,你几乎肯定不想在if语句内部创建一个全新的x。相反,你可能想要将 5 赋给函数块顶层声明的x。在if语句内部的第一个fmt.Println处,你可以访问函数块顶层声明的x。然而,在接下来的一行中,你通过在if语句体内部创建的块中声明同名新变量遮蔽x。在第二个fmt.Println处,当你访问名为x的变量时,你得到的是具有值 5 的遮蔽变量。if语句体的闭合括号结束了包含遮蔽x的块,在第三个fmt.Println处,当你访问名为x的变量时,你得到的是函数顶层声明的变量,其值为 10。注意,这个x并没有消失或被重新赋值;一旦在内部块中遮蔽了它,就无法访问它。

我在上一章提到过,在某些情况下,我避免使用:=,因为这可能会使使用的变量不清晰。这是因为在使用:=时很容易意外地遮蔽一个变量。记住,你可以使用:=同时创建和赋值多个变量。此外,左侧并不是所有变量都必须是新变量才能合法使用:=。让我们看另一个程序(见示例 4-2),你可以在The Go Playground第四章代码库sample_code/shadow_multiple_assignment目录中找到它。

示例 4-2. 使用多重赋值进行遮蔽
func main() {
    x := 10
    if x > 5 {
        x, y := 5, 20
        fmt.Println(x, y)
    }
    fmt.Println(x)
}

运行这段代码会得到以下结果:

5 20
10

尽管外部块中存在x的定义,但x仍然在if语句内部被遮蔽了。这是因为:=只重新使用当前块中声明的变量。在使用:=时,请确保左侧没有来自外部作用域的变量,除非你打算遮蔽它们。

你还需要注意确保不要遮蔽导入的包。我会在第十章更详细地讨论导入包的内容,但你一直在导入fmt包来打印我们程序的结果。让我们看看当你在main函数内部声明一个名为fmt的变量时会发生什么,正如示例 4-3 所示。你可以在The Go Playground第四章代码库sample_code/shadow_package_names目录中尝试运行它。

示例 4-3. 遮蔽包名
func main() {
    x := 10
    fmt.Println(x)
    fmt := "oops"
    fmt.Println(fmt)
}

当你尝试运行这段代码时,会得到一个错误:

fmt.Println undefined (type string has no field or method Println)

请注意问题不是你将变量命名为fmt,而是你尝试访问本地变量fmt没有的内容。一旦声明了本地变量fmt,它将屏蔽文件块中的fmt包,使得在main函数的其余部分中无法使用fmt包。

由于在某些情况下屏蔽是有用的(稍后章节将指出),go vet不会将其报告为可能的错误。在“使用代码质量扫描工具”中,您将学习有关可以检测代码中意外屏蔽的第三方工具。

if

Go 中的if语句与大多数编程语言中的if语句非常相似。因为它是如此熟悉的结构,所以我在之前的示例代码中使用它时并不担心会造成困惑。示例 4-5 展示了一个更完整的示例。

示例 4-5. ifelse
n := rand.Intn(10)
if n == 0 {
    fmt.Println("That's too low")
} else if n > 5 {
    fmt.Println("That's too big:", n)
} else {
    fmt.Println("That's a good number:", n)
}

Go 中if语句与其他语言的最显著区别是你不需要在条件周围加括号。但 Go 在if语句中添加了另一个功能,帮助您更好地管理变量。

正如我在“变量屏蔽”中所讨论的,任何在ifelse语句的大括号内声明的变量仅存在于该块中。这并不罕见;大多数语言都是如此。Go 增加的是能够声明仅在条件和if以及else块中有效的变量。看看示例 4-6,它重新编写了我们先前的示例以使用这种作用域。

示例 4-6. 将变量作用域限制为if语句
if n := rand.Intn(10); n == 0 {
    fmt.Println("That's too low")
} else if n > 5 {
    fmt.Println("That's too big:", n)
} else {
    fmt.Println("That's a good number:", n)
}

拥有这种特殊作用域非常方便。它允许你创建仅在需要时可用的变量。一旦if/else语句系列结束,n就变得未定义。你可以尝试在示例 4-7 中的Go Playground第四章代码库中的sample_code/if_bad_scope目录运行此代码来测试。

示例 4-7. 超出范围…​
if n := rand.Intn(10); n == 0 {
    fmt.Println("That's too low")
} else if n > 5 {
    fmt.Println("That's too big:", n)
} else {
    fmt.Println("That's a good number:", n)
}
fmt.Println(n)

尝试运行此代码会产生编译错误:

undefined: n
注意

技术上,您可以在if语句中的比较之前放置任何简单语句,包括不返回值的函数调用或将新值分配给现有变量。但不要这样做。只使用此功能定义仅限于if/else语句的新变量;其他任何操作都可能会令人困惑。

还要注意,就像任何其他块一样,作为if语句的一部分声明的变量将屏蔽包含块中具有相同名称的变量。

四种for

与 C 语言家族中的其他语言一样,Go 使用for语句进行循环。使 Go 与其他语言不同的是,for是该语言中唯一的循环关键字。Go 通过四种格式使用for关键字来实现这一点:

  • 完整的,类似 C 风格的for

  • 仅有条件的for

  • 一个无限的for

  • for-range

完整的for语句

第一个 for 循环风格是你可能从 C、Java 或 JavaScript 中熟悉的完整 for 声明,如 示例 4-8 所示。

示例 4-8. 一个完整的 for 语句
for i := 0; i < 10; i++ {
    fmt.Println(i)
}

你可能对发现这个程序打印出从 0 到 9 的数字感到不惊讶。

就像 if 语句一样,for 语句在其各部分周围不使用括号。否则,它看起来应该很熟悉。if 语句有三个部分,用分号分隔。第一部分是初始化,在循环开始前设置一个或多个变量。关于初始化部分,你应该记住两个重要的细节。首先,你必须使用 := 来初始化变量;在这里不合法使用 var。其次,就像 if 语句中的变量声明一样,你可以在这里遮蔽一个变量。

第二部分是比较。这必须是一个评估为 bool 的表达式。它在每次循环迭代之前立即检查。如果表达式评估为 true,则执行循环体。

标准 for 语句的最后部分是增量。通常你会在这里看到像 i++ 这样的东西,但任何赋值都是有效的。它在每次循环迭代之后立即运行,然后再评估条件。

Go 允许你省略 for 语句的三个部分中的一个或多个。最常见的情况是,如果它是基于循环前计算的值:

i := 0
for ; i < 10; i++ {
    fmt.Println(i)
}

或者你会因为在循环内有更复杂的增量规则而省略增量:

for i := 0; i < 10; {
    fmt.Println(i)
    if i % 2 == 0 {
        i++
    } else {
        i+=2
    }
}

仅有条件的 for 语句

当你在 for 语句中省略初始化和增量时,请不要包含分号。(如果你这样做,go fmt 会将它们删除。)这留下了一个像在 C、Java、JavaScript、Python、Ruby 和许多其他语言中找到的 while 语句一样工作的 for 语句。看起来像 示例 4-9。

示例 4-9. 仅有条件的 for 语句
i := 1
for i < 100 {
        fmt.Println(i)
        i = i * 2
}

无限 for 语句

第三种 for 语句格式也取消了条件。Go 有一个版本的 for 循环,永远循环。如果你在 1980 年代学习编程,你的第一个程序可能是在 BASIC 中的一个无限循环,永远打印 HELLO 到屏幕上:

10 PRINT "HELLO"
20 GOTO 10

示例 4-10 展示了这个程序的 Go 版本。你可以在本地运行它,或者在 The Go Playground第四章存储库sample_code/infinite_for 目录中试用它。

示例 4-10. 无限循环怀旧
package main

import "fmt"

func main() {
    for {
        fmt.Println("Hello")
    }
}

运行这个程序会给你同样的输出,这个输出填满了数百万台 Commodore 64 和 Apple ] 的屏幕:

Hello
Hello
Hello
Hello
Hello
Hello
Hello
...

当你厌倦了走在记忆的长廊上时,请按 Ctrl-C。

注意

如果在 The Go Playground 上运行 [示例 4-10,你会发现它会在几秒钟后停止执行。作为共享资源,Playground 不允许任何一个程序运行太长时间。

break 和 continue

如何在不使用键盘或关闭计算机的情况下退出无限for循环?这是break语句的任务。它立即退出循环,就像其他语言中的break语句一样。当然,你可以在任何for语句中使用break,不仅限于无限for语句。

注意

在 Java、C 和 JavaScript 中,Go 没有等同于do关键字。如果你想至少迭代一次,最简洁的方法是使用以if语句结束的无限for循环。例如,如果你有一些 Java 代码使用了do/while循环:

do {
    // things to do in the loop
} while (CONDITION);

Go 版本如下:

for {
    // things to do in the loop
    if !CONDITION {
        break
    }
}

注意条件前面有一个!用于否定Java 代码中的条件。Go 代码指定如何退出循环,而 Java 代码则指定如何保持在循环中。

Go 还包括continue关键字,它跳过for循环的其余部分,直接进入下一次迭代。技术上,你不需要continue语句。你可以像示例 4-11 那样编写代码。

示例 4-11. 令人困惑的代码
for i := 1; i <= 100; i++ {
    if i%3 == 0 {
        if i%5 == 0 {
            fmt.Println("FizzBuzz")
        } else {
            fmt.Println("Fizz")
        }
    } else if i%5 == 0 {
        fmt.Println("Buzz")
    } else {
        fmt.Println(i)
    }
}

但这并不是惯用写法。Go 鼓励短小的if语句体,尽可能左对齐。嵌套代码更难以跟踪。使用continue语句使理解正在发生的事情变得更容易。示例 4-12 展示了从前面示例中重写为使用continue的代码。

示例 4-12. 使用continue使代码更清晰
for i := 1; i <= 100; i++ {
    if i%3 == 0 && i%5 == 0 {
        fmt.Println("FizzBuzz")
        continue
    }
    if i%3 == 0 {
        fmt.Println("Fizz")
        continue
    }
    if i%5 == 0 {
        fmt.Println("Buzz")
        continue
    }
    fmt.Println(i)
}

如你所见,用一系列使用continue语句的if语句来替换if/else语句链,可以使条件对齐。这改善了条件的布局,意味着你的代码更易读和理解。

for-range语句

第四个for语句格式用于遍历一些 Go 内置类型中的元素。它被称为for-range循环,类似于其他语言中的迭代器。本节展示如何在字符串、数组、切片和映射中使用for-range循环。当我在第十二章中讲解通道时,我会讨论如何在其中使用for-range循环。

注意

你只能用for-range循环来迭代内置复合类型和基于它们的用户定义类型。

首先,让我们看看如何在切片中使用for-range循环。你可以在示例 4-13 中尝试这段代码,也可以在Go Playground第四章代码库sample_code/for_range目录中的main.go中的forRangeKeyValue函数中进行尝试。

示例 4-13. for-range循环
evenVals := []int{2, 4, 6, 8, 10, 12}
for i, v := range evenVals {
    fmt.Println(i, v)
}

运行此代码将产生以下输出:

0 2
1 4
2 6
3 8
4 10
5 12

使 for-range 循环有趣的是你会得到两个循环变量。第一个变量是正在迭代的数据结构中的位置,而第二个变量是该位置上的值。这两个循环变量的惯用名称取决于正在循环的内容。当遍历数组、切片或字符串时,通常使用 i 作为 index。当遍历映射时,则使用 k(表示 key)。

第二个变量通常称为 v,表示 value,但有时会根据正在迭代的值的类型命名。当然,你可以给变量任意名称。如果循环体只包含几条语句,单字母变量名效果很好。对于更长(或嵌套的)循环,应使用更具描述性的名称。

如果你在 for-range 循环中不需要使用第一个变量怎么办?请记住,Go 要求你访问所有声明的变量,这条规则也适用于 for 循环中声明的变量。如果你不需要访问键,可以使用下划线 (_) 作为变量名。这告诉 Go 忽略该值。

让我们重写切片遍历代码,不打印位置信息。你可以在 示例 4-14 中运行代码,也可以在 Go Playground 上运行,或者在 第四章代码库sample_code/for_range 目录中的 main.go 中的 forRangeIgnoreKey 函数中运行。

示例 4-14. 在 for-range 循环中忽略切片索引
evenVals := []int{2, 4, 6, 8, 10, 12}
for _, v := range evenVals {
    fmt.Println(v)
}

运行此代码将产生以下输出:

2
4
6
8
10
12
提示

在任何返回值但要忽略它的情况下,请使用下划线来隐藏值。当我在 第五章 谈到函数和 第十章 谈到包时,你会再次看到下划线的模式。

如果你想要键而不想要值怎么办?在这种情况下,Go 允许你只留下第二个变量。这是有效的 Go 代码:

uniqueNames := map[string]bool{"Fred": true, "Raul": true, "Wilma": true}
for k := range uniqueNames {
    fmt.Println(k)
}

遍历键的最常见原因是在映射用作集时。在这些情况下,值并不重要。然而,当遍历数组或切片时,也可以省略值。这种情况很少见,因为遍历线性数据结构的常见原因是访问数据。如果你发现自己在数组或切片上使用此格式,很可能选择了错误的数据结构,应考虑重构。

注意

当你在 第十二章 看 channels 时,你会看到 for-range 循环每次迭代只返回单个值的情况。

遍历映射

for-range 循环遍历映射的方式非常有趣。你可以在 示例 4-15 中运行代码,也可以在 Go Playground 上运行,或者在 第四章代码库sample_code/iterate_map 目录中运行。

示例 4-15. Map 迭代顺序变化
m := map[string]int{
    "a": 1,
    "c": 3,
    "b": 2,
}

for i := 0; i < 3; i++ {
    fmt.Println("Loop", i)
    for k, v := range m {
        fmt.Println(k, v)
    }
}

当你构建并运行这个程序时,输出会有所不同。以下是一种可能性:

Loop 0
c 3
b 2
a 1
Loop 1
a 1
c 3
b 2
Loop 2
b 2
a 1
c 3

键和值的顺序变化;某些运行可能是相同的。这实际上是一个安全功能。在早期的 Go 版本中,如果你向 map 中插入相同的项,键的迭代顺序通常(但不总是)是相同的。这导致了两个问题:

  • 人们可能编写假设顺序固定的代码,但这些代码会在奇怪的时候出错。

  • 如果 map 总是将项哈希到完全相同的值,并且你知道服务器在 map 中存储用户数据,你可以通过发送具有所有哈希到同一个桶的密钥的特殊制作数据来减慢服务器,这称为 Hash DoS 攻击。

为了防止这两个问题,Go 团队对 map 实现进行了两个更改。首先,他们修改了 map 的哈希算法,以包括每次创建 map 变量时生成的随机数。其次,他们使得 for-range 在 map 上的迭代顺序每次都有所变化。这两个变化使得实现 Hash DoS 攻击变得更加困难。

注意

这个规则有一个例外。为了更容易调试和记录 map,格式化函数(如 fmt.Println)总是按升序输出它们的键。

迭代字符串

如前所述,你也可以使用 for-range 循环来处理字符串。让我们来看看。你可以在你的计算机上或者在The Go Playground或在第四章仓库sample_code/iterate_string目录中运行示例 4-16 中的代码。

示例 4-16. 迭代字符串
samples := []string{"hello", "apple_π!"}
for _, sample := range samples {
    for i, r := range sample {
        fmt.Println(i, r, string(r))
    }
    fmt.Println()
}

当代码迭代单词“hello”时,输出没有什么意外:

0 104 h
1 101 e
2 108 l
3 108 l
4 111 o

在第一列是索引;第二列是字母的数值;第三列是转换为字符串的字母类型的数值。

查看“apple_π!”的结果更有趣:

0 97 a
1 112 p
2 112 p
3 108 l
4 101 e
5 95 _
6 960 π
8 33 !

注意此输出的两个问题。首先,注意第一列跳过了编号 7. 其次,位置 6 的值是 960. 这远远超过了一个字节可以容纳的范围。但是在第三章中,你看到字符串是由字节组成的。这是怎么回事?

当你用 for-range 循环迭代字符串时,会出现特殊的行为。它迭代的是 runes 而不是 bytes。每当 for-range 循环遇到字符串中的多字节 rune 时,它将 UTF-8 表示转换为一个单一的 32 位数值并赋给该值。偏移量按 rune 的字节长度增加。如果 for-range 循环遇到一个不能表示有效 UTF-8 值的字节,则返回 Unicode 替换字符(十六进制值为 0xfffd)。

提示

使用 for-range 循环按顺序访问字符串中的字符。第一个变量保存从字符串开头的字节数,但第二个变量的类型是 rune。

for-range 的值是一个副本

你应该注意,每次 for-range 循环迭代你的复合类型时,它会将复合类型的值复制到值变量中。修改值变量不会修改复合类型中的值。示例 4-17 展示了一个快速演示这一点的程序。你可以在 The Go Playground第四章代码库sample_code/for_range 目录中的 forRangeIsACopy 函数中尝试。

示例 4-17. 修改值不会修改源
evenVals := []int{2, 4, 6, 8, 10, 12}
for _, v := range evenVals {
    v *= 2
}
fmt.Println(evenVals)

运行此代码会得到以下输出:

[2 4 6 8 10 12]

在 Go 1.22 版本之前,值变量只会被创建一次,并在 for 循环的每次迭代中重用。从 Go 1.22 开始,默认行为是在 for 循环的每次迭代中创建一个新的索引和值变量。这个变更可能看起来不重要,但它可以防止一个常见的 bug。当我在 “Goroutines, for Loops, and Varying Variables” 中讨论 goroutines 和 for-range 循环时,你会看到在 Go 1.22 之前,如果在 for-range 循环中启动 goroutines,你需要小心如何传递索引和值给 goroutines,否则可能会得到意外的结果。

因为这是一个向后兼容性的变更(即使是消除了一个常见的错误),你可以通过在你的模块的 go.mod 文件中指定 Go 版本来控制是否启用此行为。我在 “Using go.mod” 中详细讨论了这个问题。

for 语句的其他三种形式一样,你可以在 for-range 循环中使用 breakcontinue

给你的 for 语句加上标签

默认情况下,breakcontinue 关键字适用于直接包含它们的 for 循环。如果你有嵌套的 for 循环,并且想要退出或跳过外部循环的迭代器,该怎么办?我们来看一个例子。你将修改早期的字符串迭代程序,在字符串中第一次遇到字母“l”时停止迭代。你可以在 示例 4-18 中运行这段代码,也可以在 The Go Playground第四章代码库sample_code/for_label 目录中运行。

示例 4-18. 标签
func main() {
    samples := []string{"hello", "apple_π!"}
outer:
    for _, sample := range samples {
        for i, r := range sample {
            fmt.Println(i, r, string(r))
            if r == 'l' {
                continue outer
            }
        }
        fmt.Println()
    }
}

注意,标签 outergo fmt 缩进到与周围函数相同的级别。标签总是缩进到与块的大括号相同的级别,这样更容易注意到。运行程序会得到以下输出:

0 104 h
1 101 e
2 108 l
0 97 a
1 112 p
2 112 p
3 108 l

带标签的嵌套 for 循环很少见。它们最常用于实现类似以下伪代码的算法:

outer:
    for _, outerVal := range outerValues {
        for _, innerVal := range outerVal {
            // process innerVal
            if invalidSituation(innerVal) {
                continue outer
            }
        }
        // here we have code that runs only when all of the
        // innerVal values were successfully processed
    }

选择正确的 for 语句

现在我已经覆盖了所有形式的for语句,你可能想知道何时使用哪种格式。大多数情况下,你会使用for-range格式。for-range循环是遍历字符串的最佳方式,因为它能正确地返回符文而不是字节。你还看到了for-range循环在遍历切片和映射时表现良好,并且在第十二章中你将看到for-range与通道的自然结合。

提示

在遍历所有内置复合类型实例的内容时,最好使用for-range循环。它避免了在使用数组、切片或映射时所需的大量样板代码。

当应该使用完整的for循环?最佳场景是在复合类型中不从第一个元素到最后一个元素进行迭代时。虽然你可以在for-range循环中使用一些ifcontinuebreak的组合,但标准的for循环更清晰地表示你的迭代的起始和结束。比较下面这两段代码片段,它们都是在数组中从第二个到倒数第二个元素进行迭代。首先是for-range循环:

evenVals := []int{2, 4, 6, 8, 10}
for i, v := range evenVals {
    if i == 0 {
        continue
    }
    if i == len(evenVals)-1 {
        break
    }
    fmt.Println(i, v)
}

下面是相同的代码,使用了标准的for循环:

evenVals := []int{2, 4, 6, 8, 10}
for i := 1; i < len(evenVals)-1; i++ {
    fmt.Println(i, evenVals[i])
}

标准的for循环代码既更短又更易于理解。

警告

这种模式无法跳过字符串的开头。记住,标准的for循环不能正确处理多字节字符。如果你想跳过字符串中的某些符文,你需要使用for-range循环,这样它才能正确地为你处理符文。

另外两种for语句格式使用较少。仅条件的for循环就像替代它的while循环一样,在基于计算值进行循环时非常有用。

无限的for循环在某些情况下非常有用。for循环的主体应始终包含breakreturn,因为你很少希望无限循环。现实世界的程序应限制迭代,并在无法完成操作时优雅地失败。如前所示,无限的for循环可以与if语句结合使用,以模拟其他语言中存在的do语句。无限的for循环还用于实现一些版本的迭代器模式,在我回顾标准库时你将会看到“io and Friends”。

开关

像许多 C 衍生语言一样,Go 语言有switch语句。在这些语言中,大多数开发者避免使用switch语句,因为其对可以切换的值有限制,并且有默认的穿透行为。但是 Go 语言不同,它使得switch语句变得很有用。

注意

对于那些熟悉 Go 语言的读者,我将在本章中介绍表达式 switch语句。当我在第七章讨论接口时,我将讨论类型 switch语句。

乍一看,在 Go 中,switch 语句看起来并不比在 C/C++、Java 或 JavaScript 中的样子有多大的不同,但其中还是有些意外的地方。让我们看一个 switch 语句的示例。你可以在 Example 4-19 中的代码或在 第四章仓库 中的 sample_code/switch 目录下的 main.go 文件中的 basicSwitch 函数中运行这段代码。

示例 4-19. switch 语句
words := []string{"a", "cow", "smile", "gopher",
    "octopus", "anthropologist"}
for _, word := range words {
    switch size := len(word); size {
    case 1, 2, 3, 4:
        fmt.Println(word, "is a short word!")
    case 5:
        wordLen := len(word)
        fmt.Println(word, "is exactly the right length:", wordLen)
    case 6, 7, 8, 9:
    default:
        fmt.Println(word, "is a long word!")
    }
}

当你运行这段代码时,会得到以下输出:

a is a short word!
cow is a short word!
smile is exactly the right length: 5
anthropologist is a long word!

我将详细介绍 switch 语句的特性以解释其输出。和 if 语句一样,在 switch 中,你不需要在比较的值周围加上括号。同样如 if 语句一样,你可以在 switch 语句中声明一个在所有分支中都可见的变量。在这种情况下,你将变量 size 的作用域限定在 switch 语句的所有 case 中。

所有的 case 子句(和可选的 default 子句)都包含在一对大括号内。但请注意,你不需要在 case 子句的内容周围加上大括号。你可以在 case(或 default)子句内有多行代码,它们都被认为是同一个代码块的一部分。

case 5: 内,你声明了一个新变量 wordLen。由于这是一个新的代码块,你可以在其中声明新的变量。和其他任何代码块一样,case 子句的代码块内声明的变量只在该代码块内可见。

如果你习惯在 switch 语句中的每个 case 结尾放置 break 语句,那么你会高兴地注意到它们已经消失了。在 Go 中,默认情况下,switch 语句中的 case 不会穿透。这与 Ruby 或者(如果你是老派程序员的话)Pascal 的行为更加一致。

这引出了一个问题:如果 case 不再穿透,那么如果有多个值应该触发相同的逻辑,你该怎么办?在 Go 中,你用逗号分隔多个匹配值,就像匹配 1、2、3 和 4 或者 6、7、8 和 9 一样。这就是为什么对于 acow 你会得到相同的输出。

这就引出了下一个问题:如果你没有了穿透,而且你有一个空的 case(就像在样例程序中当你的参数长度是 6、7、8 或 9 个字符时一样),会发生什么?在 Go 中,空的 case 意味着什么也不会发生。这就是为什么当你使用 octopusgopher 作为参数时,程序不会输出任何内容。

提示

出于完整性考虑,Go 确实包含了 fallthrough 关键字,它允许一个 case 继续执行下一个 case。在使用 fallthrough 的算法之前,请三思。如果你发现自己需要使用 fallthrough,尝试重构你的逻辑以消除 case 之间的依赖关系。

在示例程序中,您正在根据整数值进行switch,但这并不是您可以做的全部。您可以对任何可以使用==进行比较的类型进行switch,其中包括除切片、映射、通道、函数和包含这些类型字段的结构体之外的所有内置类型。

即使在每个case子句的末尾不需要放置break语句,但当您希望从case中早期退出时,可以使用它们。但是,需要break语句可能表明您正在做某些过于复杂的事情。考虑重构您的代码以去除它。

还有一个地方可能会使用break语句在switch语句的case中。如果您在for循环内部有一个switch语句,并且希望跳出for循环,请在for语句上放置一个标签,并在break上放置标签的名称。如果不使用标签,Go 会假设您想跳出case。让我们看一个快速示例,您希望一旦达到 7 就跳出for循环。您可以在 Example 4-20 中运行代码,在The Go Playground或在第四章存储库sample_code/switch目录中的main.go文件中的missingLabel函数中运行它。

示例 4-20. 缺少标签的情况
func main() {
    for i := 0; i < 10; i++ {
        switch i {
        case 0, 2, 4, 6:
            fmt.Println(i, "is even")
        case 3:
            fmt.Println(i, "is divisible by 3 but not 2")
        case 7:
            fmt.Println("exit the loop!")
            break
        default:
            fmt.Println(i, "is boring")
        }
    }
}

运行此代码会产生以下输出:

0 is even
1 is boring
2 is even
3 is divisible by 3 but not 2
4 is even
5 is boring
6 is even
exit the loop!
8 is boring
9 is boring

这不是预期的行为。目标是在遇到 7 时跳出for循环,但break被解释为退出case。要解决此问题,您需要引入一个标签,就像在跳出嵌套的for循环时所做的那样。首先,给for语句加上标签:

loop:
    for i := 0; i < 10; i++ {

然后您可以在您的break上使用标签:

break loop

您可以在The Go Playground上查看这些更改,或者在第四章存储库中的sample_code/switch目录中的main.go文件中的labeledBreak函数中查看它。再次运行时,您将获得预期的输出:

0 is even
1 is boring
2 is even
3 is divisible by 3 but not 2
4 is even
5 is boring
6 is even
exit the loop!

空白switch

您可以以另一种更强大的方式使用switch语句。正如 Go 允许您从for语句的声明中省略部分内容一样,您可以编写一个不指定要比较的值的switch语句。这被称为空白switch。常规的switch只允许您检查相等的值。空白switch允许您对每个case使用任何布尔比较。您可以在 Example 4-21 中尝试这段代码,在The Go Playground或在第四章存储库sample_code/blank_switch目录中的main.go文件中的basicBlankSwitch函数中。

示例 4-21. 空白的switch
words := []string{"hi", "salutations", "hello"}
for _, word := range words {
    switch wordLen := len(word); {
    case wordLen < 5:
        fmt.Println(word, "is a short word!")
    case wordLen > 10:
        fmt.Println(word, "is a long word!")
    default:
        fmt.Println(word, "is exactly the right length.")
    }
}

当您运行此程序时,会得到以下输出:

hi is a short word!
salutations is a long word!
hello is exactly the right length.

就像普通的switch语句一样,你可以选择在空的switch中包含一个短变量声明。但与普通的switch不同的是,你可以为你的情况编写逻辑测试。空的switch很酷,但不要过度使用。如果你发现自己写了一个所有情况都是对同一变量的相等比较的空switch

switch {
case a == 2:
    fmt.Println("a is 2")
case a == 3:
    fmt.Println("a is 3")
case a == 4:
    fmt.Println("a is 4")
default:
    fmt.Println("a is ", a)
}

你应该用一个表达式switch语句替换它:

switch a {
case 2:
    fmt.Println("a is 2")
case 3:
    fmt.Println("a is 3")
case 4:
    fmt.Println("a is 4")
default:
    fmt.Println("a is ", a)
}

ifswitch之间的选择

就功能而言,一系列if/else语句和空的switch语句之间没有太大区别。两者都允许一系列比较。那么,何时应该使用switch,何时应该使用一组ifif/else语句呢?switch语句,即使是空的switch,表明每个case中的值或比较之间存在关系。为了演示清晰度的差异,可以使用空的switch重新编写 FizzBuzz 程序(参见示例 4-22)。你也可以在第四章代码库sample_code/simplest_fizzbuzz目录中找到这段代码。

示例 4-22。用空的switch重写一系列if语句
for i := 1; i <= 100; i++ {
    switch {
    case i%3 == 0 && i%5 == 0:
        fmt.Println("FizzBuzz")
    case i%3 == 0:
        fmt.Println("Fizz")
    case i%5 == 0:
        fmt.Println("Buzz")
    default:
        fmt.Println(i)
    }
}

大多数人都会同意这是最可读的版本。不再需要continue语句,default情况下的默认行为也变得明确。

当然,在 Go 语言中,没有任何东西能阻止你在空switch的每个case上做各种无关的比较。然而,这并不符合习惯用法。如果你发现自己处于这样一种情况下,应该使用一系列if/else语句(或者考虑重构你的代码)。

提示

当你有多个相关的情况时,更倾向于使用空的switch语句而不是if/else链。使用switch可以使比较更加明显,并强调它们是一组相关的问题。

goto——是的,goto

Go 语言有第四种控制语句,但很可能你永远不会使用它。自从 Edsger Dijkstra 在 1968 年写下“谈谈goto语句的害处”以来,goto语句一直是编码家族中的害群之马。这有充分的理由。传统上,goto很危险,因为它可以跳转到程序中的几乎任何地方;你可以跳进或跳出循环,跳过变量定义,或者跳入if语句中一组语句的中间。这使得理解使用goto的程序变得困难。

大多数现代语言不包括goto。然而 Go 语言有一个goto语句。你仍然应该尽量避免使用它,但它有一些用途,而 Go 语言对它施加的限制使其更适合结构化编程。

在 Go 语言中,goto语句指定了一个带标签的代码行,并且执行跳转到它。但你不能随处跳转。Go 语言禁止跳过变量声明和进入内部或并行块的跳转。

示例 4-23 中的程序展示了两个非法的goto语句。你可以尝试在Go Playground第四章存储库sample_code/broken_goto目录中运行它。

Example 4-23. Go 的goto有规则。
func main() {
    a := 10
    goto skip
    b := 20
skip:
    c := 30
    fmt.Println(a, b, c)
    if c > a {
        goto inner
    }
    if a < b {
    inner:
        fmt.Println("a is less than b")
    }
}

尝试运行这个程序会产生以下错误:

goto skip jumps over declaration of b at ./main.go:8:4
goto inner jumps into block starting at ./main.go:15:11

那么goto应该用在什么地方?大多数情况下,不应该用。带标签的breakcontinue语句允许您跳出深度嵌套的循环或跳过迭代。示例 4-24 中的程序具有合法的goto,展示了少数几个有效使用场景之一。你还可以在第四章存储库sample_code/good_goto目录中找到这段代码。

示例 4-24. 使用goto的理由。
func main() {
    a := rand.Intn(10)
    for a < 100 {
        if a%5 == 0 {
            goto done
        }
        a = a*2 + 1
    }
    fmt.Println("do something when the loop completes normally")
done:
    fmt.Println("do complicated stuff no matter why we left the loop")
    fmt.Println(a)
}

这个例子是人为构造的,但它展示了goto如何使程序更清晰。在这个简单的情况下,有一些逻辑你不想在函数中间运行,但你希望在函数结束时运行。有方法可以在没有goto的情况下做到这一点。你可以设置一个布尔标志或在for循环之后复制复杂的代码,而不是使用goto,但这两种方法都有缺点。在你的代码中满布布尔标志以控制逻辑流的功能与goto语句几乎相同,只是更冗长。复制复杂的代码是有问题的,因为它使得你的代码更难维护。这些情况很少见,但如果你找不到重构逻辑的方法,像这样使用goto实际上会改进你的代码。

如果你想看一个实际的例子,你可以看一下标准库中strconv包的文件atof.go中的floatBits方法。这个方法太长了,无法完全包含,但方法以这段代码结束:

overflow:
    // ±Inf
    mant = 0
    exp = 1<<flt.expbits - 1 + flt.bias
    overflow = true

out:
    // Assemble bits.
    bits := mant & (uint64(1)<<flt.mantbits - 1)
    bits |= uint64((exp-flt.bias)&(1<<flt.expbits-1)) << flt.mantbits
    if d.neg {
        bits |= 1 << flt.mantbits << flt.expbits
    }
    return bits, overflow

在这些行之前,有几个条件检查。有些需要运行overflow标签后的代码,而其他条件则需要跳过该代码直接到out。根据条件,有goto语句跳转到overflowout。你可能可以想出一种避免goto语句的方法,但它们都会使代码更难理解。

提示

你应该尽力避免使用goto。但在它使你的代码更易读的罕见情况下,它是一个选项。

练习

现在是时候应用你在 Go 中学到的所有关于控制结构和块的知识了。你可以在第四章存储库中找到这些练习的答案。

  1. 编写一个for循环,将 100 个 0 到 100 之间的随机数放入一个int切片中。

  2. 循环遍历你在练习 1 中创建的切片。对于切片中的每个值,应用以下规则:

    1. 如果值可被 2 整除,打印“二!”

    2. 如果值可被 3 整除,打印“三!”

    3. 如果值可同时被 2 和 3 整除,打印“六!”。不打印其他任何内容。

    4. 否则,打印“不要紧”。

  3. 开始一个新程序。在main函数中声明一个名为totalint变量。编写一个for循环,使用名为i的变量从 0(包含)迭代到 10(不包含)。for循环的主体应如下所示:

    total := total + i
    fmt.Println(total)
    

    for循环结束后,打印出total的值。会打印出什么?这段代码中可能存在的 bug 是什么?

总结

本章覆盖了许多编写符合惯例的 Go 语言的重要主题。你已经学习了关于代码块、变量屏蔽和控制结构的知识,并学会了如何正确使用它们。此时,你已经能够编写简单的 Go 程序,适合在main函数中运行。是时候转向更大的程序了,使用函数来组织你的代码。

第五章:函数

到目前为止,你的程序在main函数中被限制在几行代码中。现在是时候扩展一下了。在本章中,你将学习如何在 Go 语言中编写函数,并看到你可以用它们做的所有有趣的事情。

声明和调用函数

Go 函数的基础对于任何已经在其他具有一级函数的语言(如 C、Python、Ruby 或 JavaScript)中编程过的人来说都是熟悉的。(Go 也有方法,我会在第七章中讨论。)就像控制结构一样,Go 在函数特性上添加了自己的风格。有些是改进,而其他一些则是应该避免的实验。我会在本章中都覆盖到。

你已经看到了函数的声明和使用。每个 Go 程序都从一个main函数开始,并且你一直在调用fmt.Println函数来打印到屏幕上。由于main函数不接受参数或返回值,让我们看看当一个函数有参数时会是什么样子:

func div(num int, denom int) int {
    if denom == 0 {
        return 0
    }
    return num / denom
}

让我们看看这段代码样本中的所有新内容。函数声明由四部分组成:关键字func、函数名称、输入参数和返回类型。输入参数在括号内列出,用逗号分隔,参数名在前,类型在后。Go 是一种类型化语言,因此必须指定参数的类型。返回类型写在输入参数的右括号和函数体的左大括号之间。

就像其他语言一样,Go 语言有一个return关键字用于从函数中返回值。如果一个函数返回一个值,你必须提供一个return。如果一个函数不返回任何东西,则不需要在函数末尾写上return语句。只有在你在函数的最后一行之前退出函数时,才需要return关键字。

main函数没有输入参数或返回值。当一个函数没有输入参数时,使用空括号(())。当一个函数不返回任何东西时,在输入参数的右括号和函数体的左大括号之间什么也不写:

func main() {
    result := div(5, 2)
    fmt.Println(result)
}

这个简单程序的代码在第五章仓库sample_code/simple_div目录中。

调用一个函数对于有经验的开发者来说应该是很熟悉的。在:=的右侧,你用值 5 和 2 调用div函数。在左侧,你将返回的值赋给变量result

提示

当你有两个或更多连续的相同类型的输入参数时,你可以像这样一次性指定类型:

func div(num, denom int) int {

模拟命名和可选参数

在介绍 Go 的独特函数特性之前,我将提到 Go 没有的两个功能:命名和可选输入参数。除了下一节将要讲解的一个例外,你必须为函数提供所有参数。如果你想模仿命名和可选参数,定义一个结构体,其中包含与所需参数匹配的字段,并将结构体传递给函数。示例 5-1 展示了演示此模式的代码片段。

示例 5-1. 使用结构体模拟命名参数
type MyFuncOpts struct {
    FirstName string
    LastName  string
    Age       int
}

func MyFunc(opts MyFuncOpts) error {
    // do something here
}

func main() {
    MyFunc(MyFuncOpts{
        LastName: "Patel",
        Age:      50,
    })
    MyFunc(MyFuncOpts{
        FirstName: "Joe",
        LastName:  "Smith",
    })
}

本程序的代码位于 sample_code/named_optional_parameters 目录中的 第五章存储库

在实践中,没有命名和可选参数并不是限制。一个函数不应该有太多参数,并且当函数有很多输入时,命名和可选参数才有用。如果你发现自己处于这种情况中,你的函数可能太复杂了。

变参输入参数和切片

你一直在使用 fmt.Println 将结果打印到屏幕上,你可能已经注意到它允许任意数量的输入参数。它是如何做到的呢?与许多语言一样,Go 支持变参参数。变参参数必须是输入参数列表中的最后一个(或仅有的)参数。你用三个点(...类型前指示它。在函数内部创建的变量是指定类型的切片。你可以像使用任何其他切片一样使用它。我们通过编写一个程序来看看它们是如何工作的,该程序将一个基础数添加到可变数量的参数中,并将结果作为 int 类型的切片返回。你可以在 The Go Playground第五章存储库sample_code/variadic 目录中运行此程序。首先,编写变参函数:

func addTo(base int, vals ...int) []int {
    out := make([]int, 0, len(vals))
    for _, v := range vals {
        out = append(out, base+v)
    }
    return out
}

现在以几种方式调用它:

func main() {
    fmt.Println(addTo(3))
    fmt.Println(addTo(3, 2))
    fmt.Println(addTo(3, 2, 4, 6, 8))
    a := []int{4, 3}
    fmt.Println(addTo(3, a...))
    fmt.Println(addTo(3, []int{1, 2, 3, 4, 5}...))
}

正如你所见,你可以为变参参数提供任意多的值,也可以完全不提供值。由于变参参数被转换为切片,因此你可以将切片作为输入。然而,你必须在变量或切片文字后面加上三个点(...)。如果不这样做,将导致编译时错误。

当你构建并运行此程序时,会得到以下输出:

[]
[5]
[5 7 9 11]
[7 6]
[4 5 6 7 8]

多返回值

你会看到 Go 和其他语言之间的第一个区别是 Go 允许多返回值。让我们向前面的除法程序添加一个小功能。你将从函数中返回被除数和余数。下面是更新后的函数:

func divAndRemainder(num, denom int) (int, int, error) {
    if denom == 0 {
        return 0, 0, errors.New("cannot divide by zero")
    }
    return num / denom, num % denom, nil
}

支持多返回值的几个更改。当一个 Go 函数返回多个值时,返回值的类型将以逗号分隔放在括号内。此外,如果一个函数返回多个值,你必须用逗号分隔它们全部返回。不要在返回值周围加括号;这会导致编译时错误。

还有一些您尚未看到的内容:创建并返回error。如果您想了解更多关于错误的信息,请跳转到第九章。目前,您只需知道可以使用 Go 的多返回值支持在函数出现问题时返回一个error。如果函数成功完成,将为错误的值返回nil。按照惯例,error始终是函数返回的最后一个(或唯一的)值。

调用更新后的函数如下:

func main() {
    result, remainder, err := divAndRemainder(5, 2)
    if err != nil {
        fmt.Println(err)
        os.Exit(1)
    }
    fmt.Println(result, remainder)
}

此程序的代码位于第五章存储库中的sample_code/updated_div目录中。

我在“var Versus :=”中讨论了一次性分配多个值的问题。在这里,您正在使用该功能将函数调用的结果分配给三个变量。在:=的右侧,您使用值 5 和 2 调用divAndRemainder函数。在左侧,您将返回的值分配给变量resultremaindererr。通过将errnil进行比较来检查是否发生了错误。

多返回值是多个值

如果您熟悉 Python,可能会认为多返回值与 Python 函数返回的元组类似,如果将元组的值分配给多个变量,则可以选择进行解构。示例 5-2 展示了在 Python 解释器中运行的一些示例代码。

示例 5-2. Python 中的多返回值是解构的元组
>>> def div_and_remainder(n,d):
...   if d == 0:
...     raise Exception("cannot divide by zero")
...   return n / d, n % d
>>> v = div_and_remainder(5,2)
>>> v
(2.5, 1)
>>> result, remainder = div_and_remainder(5,2)
>>> result
2.5
>>> remainder
1

在 Python 中,您可以将所有返回的值分配给单个变量或多个变量。但是,Go 不是这样工作的。您必须为函数返回的每个值分配一个变量。如果尝试将多个返回值分配给一个变量,则会收到编译时错误。

忽略返回值

如果调用函数时不想使用所有返回的值怎么办?正如“Unused Variables”中所述,Go 不允许未使用的变量。如果函数返回多个值,但您不需要读取一个或多个值,则将未使用的值分配给名称_。例如,如果您不打算读取remainder,则可以将赋值写为result, _, err := divAndRemainder(5, 2)

令人惊讶的是,Go 确实允许您隐式地忽略所有函数的返回值。您可以写divAndRemainder(5,2),返回的值会被丢弃。实际上,自最早的示例以来,您一直在这样做:fmt.Println返回两个值,但习惯上会忽略它们。在几乎所有其他情况下,您应该明确地使用下划线来表示您正在忽略返回值。

提示

当您不需要读取函数返回的值时,请使用_

命名返回值

除了允许从函数返回多个值外,Go 还允许为返回的值指定名称。再次重写divAndRemainder函数,这次使用命名返回值:

func divAndRemainder(num, denom int) (result int, remainder int, err error) {
    if denom == 0 {
        err = errors.New("cannot divide by zero")
        return result, remainder, err
    }
    result, remainder = num/denom, num%denom
    return result, remainder, err
}

当你为返回值提供名称时,你所做的是预先声明在函数中用于保存返回值的变量。它们被写成括号内的逗号分隔列表。即使只有单个返回值,你也必须用括号括起命名的返回值。命名返回值在创建时被初始化为它们的零值。这意味着你可以在任何显式使用或分配之前返回它们。

小贴士

如果你只想为一些返回值命名,可以使用_作为任何你希望匿名的返回值的名称。

有一件重要的事情需要注意:用于命名返回值的名称仅在函数内部有效;它不会强制外部使用任何名称。将返回值分配给不同名称的变量是完全合法的:

func main() {
    x, y, z := divAndRemainder(5, 2)
    fmt.Println(x, y, z)
}

虽然命名返回值有时可以帮助澄清代码,但它们确实存在一些潜在的特例情况。首先是阴影问题。与任何其他变量一样,你可以遮蔽一个命名返回值。确保你正在将值分配给返回值而不是其阴影。

命名返回值的另一个问题是你不一定要返回它们。让我们看看divAndRemainder的另一个变体。你可以在The Go Playground上运行它,或者在第五章存储库sample_code/named_div目录中的main.go中的divAndRemainderConfusing函数中运行它:

func divAndRemainder(num, denom int) (result int, remainder int, err error) {
    // assign some values
    result, remainder = 20, 30
    if denom == 0 {
        return 0, 0, errors.New("cannot divide by zero")
    }
    return num / denom, num % denom, nil
}

注意,你分配了resultremainder的值,然后直接返回了不同的值。在运行这段代码之前,试着猜测当传递 5 和 2 到这个函数时会发生什么。结果可能会让你惊讶:

2 1

即使从return语句返回的值从未分配给命名返回参数,它们也会被返回。这是因为 Go 编译器插入了将返回的任何内容分配给返回参数的代码。命名返回参数提供了一种声明意图使用变量来保存返回值的方法,但不要求你使用它们。

一些开发人员喜欢使用命名返回参数,因为它们提供了额外的文档。然而,我发现它们的价值有限。阴影使它们变得混乱,忽略它们也一样。命名返回参数在一个情况下是必不可少的。我将在本章稍后讨论defer时谈到这一点。

空白返回——永远不要使用这些!

如果你使用命名返回值,你需要注意 Go 语言中一个严重的设计缺陷:空白(有时称为naked)返回。如果你有命名的返回值,你可以只写return而不指定返回的值。这将返回分配给命名返回值的最后值。最后一次,用空白返回重写divAndRemainder函数:

func divAndRemainder(num, denom int) (result int, remainder int, err error) {
    if denom == 0 {
        err = errors.New("cannot divide by zero")
        return
    }
    result, remainder = num/denom, num%denom
    return
}

使用空返回会对函数做一些额外的更改。当输入无效时,函数会立即返回。由于未给resultremainder赋值,它们的零值会被返回。如果您正在返回命名返回值的零值,请确保它们是有意义的。还要注意,您仍然必须在函数末尾放置一个return。即使函数使用空返回,该函数也会返回值。省略return将导致编译时错误。

起初,您可能会发现空返回很方便,因为它们允许您避免一些输入。然而,大多数经验丰富的 Go 开发人员认为空返回是一个坏主意,因为它们会使数据流变得更难理解。优秀的软件应该清晰易读;它的运行过程应该是显而易见的。当您使用空返回时,代码的读者需要通过程序回溯查找最后一次给返回参数赋值的值,才能看到实际返回的内容。

警告

如果您的函数返回值,永远不要使用空返回。这会让弄清楚实际返回的值变得非常混乱。

函数是值

就像在许多其他语言中一样,Go 语言中的函数也是值。函数的类型由关键字func、参数类型和返回值类型组成。这种组合称为函数的签名。具有完全相同数量和类型的参数和返回值的任何函数都符合该类型的签名。

由于函数是值,因此可以声明一个函数变量:

var myFuncVariable func(string) int

myFuncVariable可以被分配任何具有单一string类型参数和单一int类型返回值的函数。以下是一个更长的示例:

func f1(a string) int {
    return len(a)
}

func f2(a string) int {
    total := 0
    for _, v := range a {
        total += int(v)
    }
    return total
}

func main() {
    var myFuncVariable func(string) int
    myFuncVariable = f1
    result := myFuncVariable("Hello")
    fmt.Println(result)

    myFuncVariable = f2
    result = myFuncVariable("Hello")
    fmt.Println(result)
}

您可以在The Go Playground第五章代码库的 sample_code/func_value 目录上运行此程序。输出结果为:

5
500

函数变量的默认零值为nil。尝试使用具有nil值的函数变量会导致恐慌(这在“panic 和 recover”中有涵盖)。

将函数作为值可以让您做一些聪明的事情,例如使用函数作为映射中的值来构建一个简单的计算器。让我们看看这是如何工作的。以下代码可在The Go Playground第五章代码库的 sample_code/calculator 目录中找到。首先,创建一组具有相同签名的函数:

func add(i int, j int) int { return i + j }

func sub(i int, j int) int { return i - j }

func mul(i int, j int) int { return i * j }

func div(i int, j int) int { return i / j }

接下来,创建一个映射,将数学运算符与每个函数关联起来:

var opMap = map[string]func(int, int) int{
    "+": add,
    "-": sub,
    "*": mul,
    "/": div,
}

最后,尝试使用几个表达式来测试计算器:

func main() {
    expressions := [][]string{
        {"2", "+", "3"},
        {"2", "-", "3"},
        {"2", "*", "3"},
        {"2", "/", "3"},
        {"2", "%", "3"},
        {"two", "+", "three"},
        {"5"},
    }
    for _, expression := range expressions {
        if len(expression) != 3 {
            fmt.Println("invalid expression:", expression)
            continue
        }
        p1, err := strconv.Atoi(expression[0])
        if err != nil {
            fmt.Println(err)
            continue
        }
        op := expression[1]
        opFunc, ok := opMap[op]
        if !ok {
            fmt.Println("unsupported operator:", op)
            continue
        }
        p2, err := strconv.Atoi(expression[2])
        if err != nil {
            fmt.Println(err)
            continue
        }
        result := opFunc(p1, p2)
        fmt.Println(result)
    }
}

使用标准库中的strconv.Atoi函数将string转换为int时,第二个返回值是error。和以往一样,您需要检查函数返回的错误并正确处理错误条件。

你使用op作为opMap映射中的键,并将与该键关联的值分配给变量opFuncopFunc的类型是func(int, int) int。如果映射中没有与提供的键关联的函数,你会打印错误消息并跳过循环的其余部分。然后,你使用之前解码的p1p2变量调用分配给opFunc变量的函数。在变量中调用函数的方式与直接调用函数的方式完全相同。

当你运行这个程序时,你可以看到简单计算器的工作:

5
-1
6
0
unsupported operator: %
strconv.Atoi: parsing "two": invalid syntax
invalid expression: [5]
注意

不要编写脆弱的程序。这个示例的核心逻辑相对简短。在for循环内的 22 行代码中,有 6 行用于实现实际算法,另外 16 行用于错误检查和数据验证。你可能会被诱惑不验证输入数据或检查错误,但这样做会产生不稳定、难以维护的代码。错误处理是区分专业人员和业余人员的关键。

函数类型声明

就像你可以使用type关键字定义struct一样,你也可以用它来定义函数类型(我将在第七章详细介绍类型声明):

type opFuncType func(int,int) int

然后你可以将opMap的声明重写为这样:

var opMap = map[string]opFuncType {
    // same as before
}

你不需要修改这些函数。任何具有两个int类型输入参数和单个int类型返回值的函数都会自动满足该类型,并可以被分配为映射中的值。

声明函数类型的优点是什么?一个用途是文档化。如果你要多次引用某个东西,给它起个名字是很有用的。在“函数类型是接口的桥梁”中,你会看到另一个用途。

匿名函数

你不仅可以将函数分配给变量,还可以在函数内部定义新函数并将其分配给变量。这里有一个演示程序,你可以在Go Playground上运行它。该代码也可以在第五章代码库sample_code/anon_func目录中找到:

func main() {
    f := func(j int) {
        fmt.Println("printing", j, "from inside of an anonymous function")
    }
    for i := 0; i < 5; i++ {
        f(i)
    }
}

内部函数是匿名的;它们没有名称。你可以使用关键字func紧跟输入参数、返回值和开括号来声明匿名函数。试图在func和输入参数之间放置函数名是编译时错误。

就像任何其他函数一样,通过使用括号来调用匿名函数。在这个例子中,你将for循环中的i变量传递到这里。它被分配给匿名函数的j输入参数。

该程序输出如下:

printing 0 from inside of an anonymous function
printing 1 from inside of an anonymous function
printing 2 from inside of an anonymous function
printing 3 from inside of an anonymous function
printing 4 from inside of an anonymous function

不必将匿名函数分配给变量。你可以直接编写内联函数并立即调用它们。前面的程序可以重写为这样:

func main() {
    for i := 0; i < 5; i++ {
        func(j int) {
            fmt.Println("printing", j, "from inside of an anonymous function")
        }(i)
    }
}

你可以在The Go Playground上运行这个示例,或者在第五章代码库sample_code/anon_func目录中运行。

现在,这不是你通常会做的事情。如果你正在声明并立即执行一个匿名函数,那么你可能会摆脱匿名函数,直接调用代码。然而,在不将匿名函数赋给变量的情况下声明它们,在两种情况下是有用的:defer语句和启动 goroutines。稍后我会讲解defer语句。Goroutines 在第十二章中有详细介绍。

由于你可以在包范围内声明变量,你也可以声明分配给匿名函数的包范围变量:

var (
    add = func(i, j int) int { return i + j }
    sub = func(i, j int) int { return i - j }
    mul = func(i, j int) int { return i * j }
    div = func(i, j int) int { return i / j }
)

func main() {
    x := add(2, 3)
    fmt.Println(x)
}

与普通函数定义不同,你可以给包级别的匿名函数分配一个新值:

func main() {
    x := add(2, 3)
    fmt.Println(x)
    changeAdd()
    y := add(2, 3)
    fmt.Println(y)
}

func changeAdd() {
    add = func(i, j int) int { return i + j + j }
}

运行这段代码将得到以下输出:

5
8

你可以在The Go Playground上尝试这个示例。这段代码也在第五章代码库sample_code/package_level_anon目录中。

在使用包级别的匿名函数之前,请务必确定你确实需要这种能力。包级别的状态应该是不可变的,以便更容易理解数据流。如果一个函数的含义在程序运行时发生变化,不仅数据流变得难以理解,处理数据也变得困难。

闭包

在函数内部声明的函数是特殊的;它们是closures。这是一个计算机科学术语,意味着在函数内部声明的函数能够访问和修改在外部函数中声明的变量。让我们看一个快速的示例,看看这是如何工作的。你可以在The Go Playground上找到这段代码。这段代码也在第五章代码库sample_code/closure目录中:

func main() {
    a := 20
    f := func() {
        fmt.Println(a)
        a = 30
    }
    f()
    fmt.Println(a)
}

运行这个程序将得到以下输出:

20
30

分配给f的匿名函数可以读取和写入a,即使a没有传递给函数。

就像任何内部作用域一样,你可以在闭包内部遮蔽一个变量。如果你改变代码为:

func main() {
    a := 20
    f := func() {
        fmt.Println(a)
        a := 30
        fmt.Println(a)
    }
    f()
    fmt.Println(a)
}

这将会打印出以下内容:

20
30
20

在闭包中使用:=而不是=会创建一个新的a,当闭包退出时它将不复存在。在处理内部函数时,务必使用正确的赋值操作符,特别是当左手边有多个变量时。你可以在The Go Playground上尝试这段代码。这段代码也在第五章代码库sample_code/closure_shadow目录中。

这种内部函数和闭包的东西起初可能看起来并不那么有用。在一个更大的函数内部创建微型函数有什么好处呢?为什么 Go 语言具有这个特性?

闭包允许您限制函数的作用域。如果一个函数只会从另一个函数调用,但会被多次调用,可以使用内部函数来“隐藏”调用函数。这样可以减少包级别的声明数量,从而更容易找到未使用的名称。

如果在函数内部多次重复使用某个逻辑片段,可以使用闭包来消除这种重复。我编写了一个简单的 Lisp 解释器,其中有一个Scan函数,用于处理输入字符串以查找 Lisp 程序的各个部分。它依赖于两个闭包,buildCurTokenupdate,以使代码更简洁、更易于理解。您可以在 GitHub 上查看它。

当闭包被传递给其他函数或从函数中返回时,它们确实变得非常有趣。它们允许您获取函数内部的变量并在函数外部使用这些值。

将函数作为参数传递

由于函数是值,您可以使用其参数和返回类型指定函数的类型,因此可以将函数作为参数传递给其他函数。如果您不习惯将函数视为数据来处理,您可能需要一些时间来考虑创建引用局部变量的闭包并将其传递给另一个函数的后果。这是一个非常有用的模式,在标准库中多次出现。

其中一个例子是对切片进行排序。标准库中的sort包含一个名为sort.Slice的函数。它接受任何切片以及一个用于对传入切片排序的函数。我们来看看如何通过使用不同的字段对结构体切片进行排序。

注意

sort.Slice 函数在 Go 语言引入泛型之前就存在了,因此它会执行一些内部魔法来使其与任何类型的切片一起工作。我将在 第十六章 中更详细地讨论这些魔法。

让我们看看如何使用闭包以不同方式对相同数据进行排序。您可以在 The Go Playground 上运行此代码。该代码也位于 第五章代码库sample_code/sort_sample 目录中。首先,定义一个简单的类型,一个该类型值的切片,并打印出该切片:

type Person struct {
    FirstName string
    LastName  string
    Age       int
}

people := []Person{
    {"Pat", "Patterson", 37},
    {"Tracy", "Bobdaughter", 23},
    {"Fred", "Fredson", 18},
}
fmt.Println(people)

接下来,按姓氏对切片进行排序并打印出结果:

// sort by last name
sort.Slice(people, func(i, j int) bool {
    return people[i].LastName < people[j].LastName
})
fmt.Println(people)

传递给 sort.Slice 的闭包有两个参数,ij,但在闭包内部使用了 people,因此可以按 LastName 字段对其进行排序。在计算机科学术语中,people闭包捕获。接下来,您可以按 Age 字段进行相同的操作:

// sort by age
sort.Slice(people, func(i, j int) bool {
    return people[i].Age < people[j].Age
})
fmt.Println(people)

运行这段代码将得到以下输出:

[{Pat Patterson 37} {Tracy Bobdaughter 23} {Fred Fredson 18}]
[{Tracy Bobdaughter 23} {Fred Fredson 18} {Pat Patterson 37}]
[{Fred Fredson 18} {Tracy Bobdaughter 23} {Pat Patterson 37}]

调用sort.Slice会改变people切片。我在 “Go Is Call by Value” 中简要讨论了这一点,并在下一章节中详细介绍。

提示

将函数作为参数传递给其他函数对于在同一类型数据上执行不同操作非常有用。

从函数中返回函数

除了使用闭包将一些函数状态传递给另一个函数外,你还可以从函数中返回一个闭包。让我们通过编写一个返回乘法器函数的函数来演示这一点。你可以在The Go Playground上运行这个程序。代码也在第五章仓库sample_code/makeMult目录中。这是一个返回闭包的函数:

func makeMult(base int) func(int) int {
    return func(factor int) int {
        return base * factor
    }
}

下面是函数的使用方法:

func main() {
    twoBase := makeMult(2)
    threeBase := makeMult(3)
    for i := 0; i < 3; i++ {
        fmt.Println(twoBase(i), threeBase(i))
    }
}

运行这个程序将得到以下输出:

0 0
2 3
4 6

现在你已经看到闭包的实际应用,你可能想知道 Go 开发者经常使用它们多少。事实证明,它们非常有用。你看到它们如何用于对切片进行排序。闭包还用于使用sort.Search高效地搜索排序的切片。至于返回闭包,当你为 Web 服务器构建中间件时,你会在“中间件”中看到这种模式。Go 还使用闭包实现资源清理,通过defer关键字。

注意

如果你花时间与使用像 Haskell 这样的函数式编程语言的程序员在一起,你可能会听到高阶函数这个术语。这是一种说法,即一个函数的输入参数或返回值是一个函数。作为 Go 开发者,你与他们一样酷!

defer

程序经常创建临时资源,如文件或网络连接,需要进行清理。无论一个函数有多少个退出点,或者函数是否成功完成,都必须进行清理。在 Go 中,清理代码使用defer关键字附加到函数中。

让我们看看如何使用defer来释放资源。通过编写一个简单版本的cat,Unix 的用于打印文件内容的实用程序。你不能在 The Go Playground 上传入参数,但是你可以在第五章仓库sample_code/simple_cat目录中找到这个示例的代码:

func main() {
    if len(os.Args) < 2 {
        log.Fatal("no file specified")
    }
    f, err := os.Open(os.Args[1])
    if err != nil {
        log.Fatal(err)
    }
    defer f.Close()
    data := make([]byte, 2048)
    for {
        count, err := f.Read(data)
        os.Stdout.Write(data[:count])
        if err != nil {
            if err != io.EOF {
                log.Fatal(err)
            }
            break
        }
    }
}

这个例子介绍了我在后续章节中更详细介绍的一些新功能。随时阅读以获取更多信息。

首先,通过检查os.Args(在os包中的切片)的长度来确保命令行中指定了文件名。os.Args的第一个值是程序的名称。剩余的值是传递给程序的参数。检查os.Args的长度至少为 2 来确定是否提供了程序的参数。如果没有,使用log包中的Fatal函数打印一条消息并退出程序。接下来,使用os包中的Open函数获取一个只读文件句柄。Open函数返回的第二个值是一个错误。如果打开文件时出现问题,打印错误消息并退出程序。正如前面提到的,我会在第九章讨论错误。

一旦确定存在有效的文件句柄,你需要在使用后关闭它,无论如何退出函数。为了确保清理代码运行,你使用defer关键字,后跟函数或方法调用。在这种情况下,你使用文件变量的Close方法。(我在第七章中详细介绍了 Go 中的at方法。)通常情况下,函数调用会立即运行,但defer延迟调用直到周围函数退出。

通过将字节片段传递给文件变量的Read方法,你可以从文件句柄读取。我将在“io and Friends”中详细讨论如何使用这个方法,但Read会返回已读入片段的字节数和一个错误。如果发生错误,你需要检查是否是文件结束标记。如果已到文件末尾,使用break退出for循环。对于所有其他错误,使用log.Fatal报告并立即退出。我将在“Go Is Call by Value”中稍作介绍切片和函数参数,并在下一章节中详细讨论这种模式时,详细讨论指针。

simple_cat目录内构建并运行程序会产生以下结果:

$ go build
$ ./simple_cat simple_cat.go
package main

import (
    "io"
    "log"
    "os"
)
...

你应该了解关于defer的更多事项。首先,你可以在defer中使用函数、方法或闭包。(当我提到带有defer的函数时,心理上扩展为“函数、方法或闭包”。)

你可以在 Go 函数中defer多个函数。它们按照后进先出(LIFO)的顺序运行;最后注册的defer首先运行。

在返回语句之后,defer函数内的代码会运行。正如我提到的,你可以向defer提供带有输入参数的函数。输入参数会立即评估,并且它们的值会存储,直到函数运行。

这是一个快速示例,演示了defer的这两个特性:

func deferExample() int {
    a := 10
    defer func(val int) {
        fmt.Println("first:", val)
    }(a)
    a = 20
    defer func(val int) {
        fmt.Println("second:", val)
    }(a)
    a = 30
    fmt.Println("exiting:", a)
    return a
}

运行此代码会产生以下输出:

exiting: 30
second: 20
first: 10

你可以在Go Playground上运行此代码。它还可以在第五章代码库sample_code/defer_example目录中找到。

注意

你可以向defer提供返回值的函数,但没有办法读取这些值:

func example() {
    defer func() int {
        return 2 // there's no way to read this value
    }()
}

也许你在想是否有一种方法让延迟函数检查或修改其周围函数的返回值。确实有一种方法,这也是使用命名返回值的最佳理由。它允许你的代码根据错误采取行动。当我在第九章讨论错误时,我将讨论一种使用defer向从函数返回的错误添加上下文信息的模式。让我们看一种使用命名返回值和defer处理数据库事务清理的方法:

func DoSomeInserts(ctx context.Context, db *sql.DB, value1, value2 string)
                  (err error) {
    tx, err := db.BeginTx(ctx, nil)
    if err != nil {
        return err
    }
    defer func() {
        if err == nil {
            err = tx.Commit()
        }
        if err != nil {
            tx.Rollback()
        }
    }()
    _, err = tx.ExecContext(ctx, "INSERT INTO FOO (val) values $1", value1)
    if err != nil {
        return err
    }
    // use tx to do more database inserts here
    return nil
}

我不会在本书中涵盖 Go 的数据库支持,但标准库在database/sql包中提供了广泛的数据库支持。在示例函数中,您创建一个事务来执行一系列数据库插入操作。如果其中任何一个失败,您希望回滚(不修改数据库)。如果它们全部成功,您希望提交(保存数据库更改)。您使用defer和闭包来检查是否已给err赋值。如果没有,您运行tx.Commit,这也可能返回一个错误。如果返回了错误,变量err将被修改。如果任何数据库交互返回错误,您会调用tx.Rollback

注意

新的 Go 开发者往往会忘记在指定defer函数时的大括号后面加上括号。如果省略它们,这是一个编译时错误,最终会形成习惯。记住提供括号可以让您指定在运行函数时传递的值。

Go 中的常见模式是,分配资源的函数还会返回一个清理资源的闭包。在第五章的示例代码/simple_cat_cancel 目录中,有一个重写的简单cat程序。首先,您编写一个打开文件并返回一个闭包的辅助函数:

func getFile(name string) (*os.File, func(), error) {
    file, err := os.Open(name)
    if err != nil {
        return nil, nil, err
    }
    return file, func() {
        file.Close()
    }, nil
}

辅助函数返回一个文件、一个函数和一个错误。*表示在 Go 中,文件引用是一个指针。我将在下一章中详细讨论这一点。

现在在main中,您使用getFile函数:

f, closer, err := getFile(os.Args[1])
if err != nil {
    log.Fatal(err)
}
defer closer()

因为 Go 不允许未使用的变量,从函数返回closer意味着如果函数未被调用,则程序将无法编译。这提醒用户使用defer。正如前面所述,当您defer时,在closer后面加上括号。

注意

如果您习惯于使用语言中的块在函数内部控制资源清理(例如 Java、JavaScript 和 Python 中的try/catch/finally块或 Ruby 中的begin/rescue/ensure块),使用defer可能会感觉奇怪。

这些资源清理块的缺点是它们会在函数中创建另一个缩进级别,这使得代码更难阅读。嵌套代码难以跟踪不仅仅是我的观点。在一项发表于2017 年的Empirical Software Engineering论文中,Vard Antinyan 等人发现,“在提出的十一种代码特征中,只有两种明显影响复杂性增长:嵌套深度和缺乏结构。”

关于使程序更易于阅读和理解的研究并不新鲜。您可以找到许多几十年前的论文,包括 Richard Miara 等人在 1983 年的一篇论文,尝试找出适合使用的正确缩进量(根据他们的结果,是两到四个空格)。

Go 是按值调用的

你可能听人说 Go 是一个按值传递的语言,想知道这是什么意思。这意味着当你把一个变量作为参数传递给函数时,Go 总是会复制变量的值。我们来看看。你可以在Go Playground上运行这段代码。它还在第五章代码库sample_code/pass_value_type目录中。首先,你定义一个简单的结构体:

type person struct {
    age  int
    name string
}

接下来,你将编写一个函数,接收一个int、一个string和一个person,并修改它们的值:

func modifyFails(i int, s string, p person) {
    i = i * 2
    s = "Goodbye"
    p.name = "Bob"
}

然后,你从main中调用这个函数,看看修改是否生效:

func main() {
    p := person{}
    i := 2
    s := "Hello"
    modifyFails(i, s, p)
    fmt.Println(i, s, p)
}

如函数名称所示,运行此代码将显示函数不会更改传递给它的参数的值:

2 Hello {0 }

我包含了person结构体,以表明这不仅适用于基本类型。如果你有 Java、JavaScript、Python 或 Ruby 的编程经验,可能会觉得结构体的行为很奇怪。毕竟,这些语言在将对象作为参数传递给函数时允许修改对象的字段。这种差异的原因是我在讨论指针时会解释的。

对于映射和切片的行为有所不同。让我们看看在函数内部尝试修改它们时会发生什么。你可以在Go Playground上运行这段代码。它还在第五章代码库sample_code/pass_map_slice目录中。你将编写一个修改映射参数的函数和一个修改切片参数的函数:

func modMap(m map[int]string) {
    m[2] = "hello"
    m[3] = "goodbye"
    delete(m, 1)
}

func modSlice(s []int) {
    for k, v := range s {
        s[k] = v * 2
    }
    s = append(s, 10)
}

然后,你从main中调用这些函数:

func main() {
    m := map[int]string{
        1: "first",
        2: "second",
    }
    modMap(m)
    fmt.Println(m)

    s := []int{1, 2, 3}
    modSlice(s)
    fmt.Println(s)
}

当你运行这段代码时,会看到一些有趣的事情:

map[2:hello 3:goodbye]
[2 4 6]

对于映射来说,解释发生的事情很容易:对映射参数的任何更改都会反映在传递到函数中的变量中。对于切片来说,情况就更复杂了。你可以修改切片中的任何元素,但不能扩展切片的长度。这对直接传递给函数的映射和切片以及结构体中的映射和切片字段都适用。

该程序引出了一个问题:为什么映射和切片的行为与其他类型不同?这是因为映射和切片都是用指针实现的。我将在下一章节详细介绍。

小贴士

Go 中的每种类型都是值类型。只是有时候这个值是一个指针。

按值传递是 Go 对常量支持有限的一个原因。由于变量是按值传递的,你可以确保调用函数不会修改传递进去的变量的值(除非变量是切片或映射)。总的来说,这是件好事。当函数不修改其输入参数,而是返回新计算的值时,它使得理解程序中数据流动的过程变得更容易。

虽然这种方法很容易理解,但有时你需要将可变的东西传递给一个函数。那么你该怎么办?这时你就需要一个指针。

练习

这些练习测试你对 Go 语言中函数的理解以及如何使用它们的知识。解决方案可在第五章仓库exercise_solutions目录中找到。

  1. 这个简单的计算器程序没有处理一个错误情况:除以零。更改数学操作的函数签名,以返回一个int和一个error。在div函数中,如果除数是0,则返回errors.New("division by zero")作为错误。在所有其他情况下,返回nil。调整main函数以检查此错误。

  2. 编写一个名为fileLen的函数,该函数具有string类型的输入参数,并返回一个int和一个error。该函数接受一个文件名,并返回文件的字节数。如果读取文件时出错,返回错误。使用defer确保文件正确关闭。

  3. 编写一个名为prefixer的函数,该函数具有string类型的输入参数,并返回一个函数,该函数具有string类型的输入参数并返回一个string类型的返回值。返回的函数应该将其输入以prefixer传入的字符串作为前缀。使用以下main函数来测试prefixer

    func main() {
        helloPrefix := prefixer("Hello")
        fmt.Println(helloPrefix("Bob")) // should print Hello Bob
        fmt.Println(helloPrefix("Maria")) // should print Hello Maria
    }
    

结语

在本章中,你已经学习了 Go 语言中的函数,它们与其他语言中的函数相似之处以及它们的独特特性。在下一章中,你将学习指针,了解它们并不像许多新的 Go 开发者期望的那样可怕,并学习如何利用它们编写高效的程序。

第六章:指针

现在你已经了解了变量和函数,是时候学习指针语法了。接下来我将通过将 Go 语言中指针的行为与其他语言中类的行为进行比较来澄清指针的行为。你还将学习如何以及何时使用指针,在 Go 语言中如何分配内存,以及如何正确使用指针和值使 Go 程序更快、更高效。

快速指针入门

指针是一个变量,它保存值存储的内存位置。如果你学过计算机科学课程,可能已经看过用图形表示变量存储在内存中的方式。以下两个变量的表示形式类似于图 6-1:

var x int32 = 10
var y bool = true

Variables in Memory

图 6-1. 在内存中存储两个变量

每个变量都存储在一个或多个连续的内存位置中,称为地址。不同类型的变量可能占用不同数量的内存。在这个例子中,你有两个变量,x是一个 32 位的整数,y是一个布尔值。存储一个 32 位整数需要四个字节,因此x的值存储在从地址 1 到地址 4 的四个字节中。布尔值只需要一个字节(只需一个位来表示truefalse,但可以独立寻址的最小内存单位是一个字节),因此y的值存储在地址 5 处的一个字节中,其中true的值表示为 1。

指针是一个变量,它包含另一个变量存储的地址。图 6-2 展示了以下指针如何在内存中存储:

var x int32 = 10
var y bool = true
pointerX := &x
pointerY := &y
var pointerZ *string

Pointers in Memory

Figure 6-2. 在内存中存储指针

虽然不同类型的变量可能占用不同数量的内存位置,但无论指向何种类型,每个指针始终占据相同数量的内存位置。本章的示例使用 4 字节的指针,但许多现代计算机使用 8 字节的指针。指针保存一个数字,该数字表示被指向的数据存储的内存位置。这个数字称为地址。我们指向x的指针pointerX存储在位置 6,并具有值 1,即x的地址。类似地,我们指向y的指针pointerY存储在位置 10,并具有值 5,即y的地址。最后一个指针pointerZ存储在位置 14,并具有值 0,因为它没有指向任何东西。

指针的零值为nil。你之前几次见过nil,作为切片、映射和函数的零值。这些类型都是用指针实现的。(还有两种类型,通道和接口,也是用指针实现的。你将在“接口快速入门”和“通道”中详细了解它们。)正如我在第三章中所述,nil是一个无类型标识符,表示某些类型的缺失值。与 C 中的NULL不同,nil不是 0 的另一个名称;你不能将其与数字来回转换。

警告

正如在第四章中所提到的,nil在宇宙块中有定义。因为nil是在宇宙块中定义的值,它可以被隐藏。除非你试图愚弄你的同事且不关心你的年度审查,否则永远不要将变量或函数命名为nil

Go 的指针语法部分借鉴自 C 和 C++。由于 Go 具有垃圾回收器,大多数内存管理痛点被消除。此外,一些你可以在 C 和 C++中使用的指针技巧,包括指针算术,在 Go 中是不允许的。

注意

Go 标准库确实有一个unsafe包,它允许你在数据结构上进行一些低级操作。虽然在 C 中常用指针操作进行常规操作,但在 Go 开发者中极少使用unsafe。你将在第十六章中快速了解它。

&地址运算符。它前置于值类型,并返回存储值的地址:

x := "hello"
pointerToX := &x

*间接运算符。它前置于指针类型的变量,并返回指向的值。这称为解引用

x := 10
pointerToX := &x
fmt.Println(pointerToX)  // prints a memory address
fmt.Println(*pointerToX) // prints 10
z := 5 + *pointerToX
fmt.Println(z)           // prints 15

在解引用指针之前,你必须确保指针非nil。如果尝试对nil指针进行解引用,程序将会崩溃:

var x *int
fmt.Println(x == nil) // prints true
fmt.Println(*x)       // panics

指针类型是表示指针的类型。它在类型名前写上*。指针类型可以基于任何类型:

x := 10
var pointerToX *int
pointerToX = &x

内置函数new创建一个指针变量。它返回所提供类型的零值实例的指针:

var x = new(int)
fmt.Println(x == nil) // prints false
fmt.Println(*x)       // prints 0

new函数很少使用。对于结构体,请在结构体文字之前加上&以创建指针实例。你不能在原始文字(数字、布尔值和字符串)或常量之前使用&,因为它们没有内存地址;它们只存在于编译时。当你需要一个指向原始类型的指针时,请声明一个变量并指向它:

x := &Foo{}
var y string
z := &y

有时无法取常量的地址有点不方便。如果你有一个结构体,其中一个字段是指向原始类型的指针,你不能直接将字面量赋给该字段:

type person struct {
    FirstName  string
    MiddleName *string
    LastName   string
}

p := person{
  FirstName:  "Pat",
  MiddleName: "Perry", // This line won't compile
  LastName:   "Peterson",
}

编译此代码会返回错误:

cannot use "Perry" (type string) as type *string in field value

如果你尝试在"Perry"之前加上&,你将会得到错误消息:

cannot take the address of "Perry"

有两种方法可以解决这个问题。第一种方法是做之前展示的事情,即引入一个变量来保存常量值。第二种方法是编写一个通用的辅助函数,接受任何类型的参数并返回该类型的指针:

func makePointerT any *T {
    return &t
}

有了那个函数,你现在可以写:

p := person{
  FirstName:  "Pat",
  MiddleName: makePointer("Perry"), // This works
  LastName:   "Peterson",
}

为什么这样做?当你将一个常量传递给一个函数时,该常量被复制到一个参数中,该参数是一个变量。因为它是一个变量,它在内存中有一个地址。然后函数返回变量的内存地址。编写通用函数在第八章中有介绍。

提示

使用一个辅助函数将一个常量值转换为指针。

不要害怕指针

指针的第一条规则是不要害怕它们。如果你习惯于 Java、JavaScript、Python 或 Ruby,你可能会觉得指针很吓人。然而,指针实际上是类中熟悉的行为。在 Go 语言中,非指针的结构体才是不寻常的。

在 Java 和 JavaScript 中,原始类型和类之间的行为有所不同(Python 和 Ruby 没有原始值,但使用不可变实例来模拟它们)。当将原始值分配给另一个变量或传递给函数或方法时,对其他变量进行的任何更改不会反映在原始变量中,就像在示例 6-1 中所示的那样。

示例 6-1. 在 Java 中分配原始变量不共享内存
int x = 10;
int y = x;
y = 20;
System.out.println(x); // prints 10

然而,让我们看看当一个类的实例被分配给另一个变量或传递给函数或方法时会发生什么(示例 6-2 中的代码是用 Python 编写的,但是你可以在第六章代码示例sample_code/language_pointer_examples目录中找到类似的 Java、JavaScript 和 Ruby 代码)。

示例 6-2. 将类实例传递给函数
class Foo:
    def __init__(self, x):
        self.x = x

def outer():
    f = Foo(10)
    inner1(f)
    print(f.x)
    inner2(f)
    print(f.x)
    g = None
    inner2(g)
    print(g is None)

def inner1(f):
    f.x = 20

def inner2(f):
    f = Foo(30)

outer()

运行此代码会打印以下输出:

20
20
True

这是因为在 Java、Python、JavaScript 和 Ruby 中以下场景是正确的:

  • 如果你将一个类的实例传递给一个函数,并且改变字段的值,则这种改变将反映在传入的变量中。

  • 如果重新分配参数,则传入的变量不会反映变化。

  • 如果你为参数值传递nil/null/None,则将参数本身设置为新值不会修改调用函数中的变量。

有些人解释这种行为时说,在这些语言中类实例是通过引用传递的。这是不正确的。如果它们是通过引用传递的,场景二和场景三将改变调用函数中的变量。这些语言始终是按值传递的,就像在 Go 语言中一样。

你所看到的是这些语言中每个类的每个实例都作为指针实现。当将类实例传递给函数或方法时,被复制的值是指向实例的指针。由于 outerinner1 引用同一内存,对 inner1 中的 f 字段的更改会反映在 outer 中的变量上。当 inner2f 重新分配给一个新的类实例时,这创建了一个单独的实例,并且不会影响 outer 中的变量。

当你在 Go 中使用指针变量或参数时,你会看到完全相同的行为。Go 和这些语言的不同之处在于,Go 给你选择使用指针或值的选择,无论是基本类型还是结构体。大多数情况下,你应该使用值。值使得更容易理解数据何时以及如何被修改。第二个好处是,使用值减少了垃圾收集器需要做的工作量。我将在 “减少垃圾收集器的工作负荷” 中讨论这一点。

指针表示可变参数

正如你已经看到的,Go 常量为可以在编译时计算的字面表达式提供了名称。Go 没有机制声明其他类型的值是不可变的。现代软件工程推崇不可变性。MIT 的《软件构造课程》 总结了原因:“不可变类型更安全,更易于理解,并且更容易变更。可变性使得理解程序的运行变得更加困难,并且更难强制执行合同。”

Go 语言中缺乏不可变声明可能看起来是个问题,但可以选择值或指针参数类型来解决这个问题。正如《软件构造课程材料》所解释的那样:“使用可变对象是完全可以的,只要在方法内部完全本地使用,并且只有一个对该对象的引用。” 而不是声明某些变量和参数是不可变的,Go 开发者使用指针来指示参数是可变的。

由于 Go 是按值传递的语言,传递给函数的是值的副本。对于非指针类型(如基本类型、结构体和数组),这意味着被调用函数无法修改原始数据。由于被调用函数有原始数据的副本,原始数据的不可变性是得到保证的。

注意

我将在 “映射和切片的区别” 中讨论将映射和切片传递给函数的情况。

然而,如果将指针传递给函数,函数将获得指针的副本。这仍然指向原始数据,这意味着被调用函数可以修改原始数据。

这带来了几个相关的影响。

首先的含义是,当你将一个 nil 指针传递给一个函数时,你不能使其值变为非 nil。你只能在指针已经有值的情况下重新赋值。虽然刚开始会感到困惑,但这是有道理的。由于内存位置是通过按值调用函数传递的,你不能改变内存地址,就像你不能改变 int 参数的值一样。你可以用以下程序来演示这一点:

func failedUpdate(g *int) {
    x := 10
    g = &x
}

func main() {
    var f *int // f is nil
    failedUpdate(f)
    fmt.Println(f) // prints nil
}

该代码的流程如 图 6-3 所示。

更新  指针失败

图 6-3. 更新 nil 指针失败

你在 main 中开始有一个 nil 变量 f。当你调用 failedUpdate 时,你将 f 的值,即 nil,复制到名为 g 的参数中。这意味着 g 也被设置为 nil。然后在 failedUpdate 中声明一个值为 10 的新变量 x。接下来,你将 failedUpdate 中的 g 改为指向 x。这并不会改变 main 中的 f,当你退出 failedUpdate 并返回到 main 时,f 仍然是 nil

复制指针的第二个含义是,如果你希望在退出函数时保留指针参数分配的值,你必须对指针进行解引用并设置值。如果改变指针,你改变的是副本而不是原始的值。解引用将新值放入原始和副本指向的内存位置。以下是展示这一过程的简短程序:

func failedUpdate(px *int) {
    x2 := 20
    px = &x2
}

func update(px *int) {
    *px = 20
}

func main() {
    x := 10
    failedUpdate(&x)
    fmt.Println(x) // prints 10
    update(&x)
    fmt.Println(x) // prints 20
}

该代码的流程如 图 6-4 所示。

在这个例子中,你在 main 中将 x 设置为 10。当你调用 failedUpdate 时,你将 x 的地址复制到参数 px 中。接下来,在 failedUpdate 中声明 x2 并设置为 20。然后你将 pxfailedUpdate 中指向 x2 的地址。当你返回到 main 时,x 的值保持不变。当你调用 update 时,再次将 x 的地址复制到 px 中。但是,这次你改变了 updatepx 指向的值,也就是 main 中的变量 x。当你返回到 main 时,x 的值已经改变了。

更新指针的错误方法和正确方法

图 6-4. 更新指针的错误方法和正确方法

指针是最后的选择。

说到这里,在 Go 中使用指针时应当小心。正如前面讨论的那样,它们使得数据流程更难理解,并可能为垃圾收集器增加额外的工作。与其通过将指针传递到函数中来填充结构体,不如让函数实例化并返回结构体(参见示例 6-3 和 6-4)。

示例 6-3. 不要这样做
func MakeFoo(f *Foo) error {
  f.Field1 = "val"
  f.Field2 = 20
  return nil
}
示例 6-4. 这样做
func MakeFoo() (Foo, error) {
  f := Foo{
    Field1: "val",
    Field2: 20,
  }
  return f, nil
}

唯一应该使用指针参数修改变量的时机是当函数期望一个接口时。在使用 JSON 时你会看到这种模式(我将在 “encoding/json” 中更多地讨论 Go 标准库中的 JSON 支持):

f := struct {
  Name string `json:"name"`
  Age int `json:"age"`
}{}
err := json.Unmarshal([]byte(`{"name": "Bob", "age": 30}`), &f)

Unmarshal函数从包含 JSON 的字节片段中填充变量。它声明为接受一个字节片段和一个any参数。传递给any参数的值必须是一个指针。如果不是,将返回错误。

为什么要将指针传递给Unmarshal而不是让它返回一个值?有两个原因。首先,这个函数早于 Go 泛型的添加,而没有泛型(我将在第八章详细讨论),没有办法知道要创建和返回什么类型的值。

第二个原因是,传递指针可以控制内存分配。迭代数据并将其从 JSON 转换为 Go 结构体是一种常见的设计模式,因此Unmarshal针对这种情况进行了优化。如果Unmarshal函数返回一个值,并且在循环中调用Unmarshal,则每个循环迭代都会创建一个结构体实例。这会给垃圾收集器带来更多工作量,从而减慢程序的速度。在查看“Slices as Buffers”时,您会看到此模式的另一个用途,而在“Reducing the Garbage Collector’s Workload”中我将详细讨论高效内存使用。

由于 JSON 集成非常普遍,这个 API 有时被新的 Go 开发人员视为常见情况,而不是应该是的例外情况。

当从函数返回值时,应优先考虑值类型。仅当数据类型内部存在需要修改的状态时,才使用指针类型作为返回类型。在查看“io and Friends”中的 I/O 时,您将看到用于读取或写入数据的缓冲区。此外,某些与并发使用的数据类型必须始终作为指针传递。您将在第十二章中看到这些内容。

指针传递性能

如果一个结构体足够大,使用结构体的指针作为输入参数或返回值可以提升性能。将指针传递给函数的时间对于所有数据大小都是常数级别的,大约一纳秒。这是有道理的,因为指针的大小对所有数据类型都是相同的。将值传递给函数随着数据变大会花费更长的时间。当值达到大约 10 兆字节时,传递值大约需要 0.7 毫秒。

返回指针与返回值的行为更有趣。对于小于 10 兆字节的数据结构,返回指针类型实际上比返回值类型更慢。例如,一个 100 字节的数据结构返回大约需要 10 纳秒,但指向该数据结构的指针则需要约 30 纳秒。随着数据结构变得更大,性能优势则会反转。返回 10 兆字节的数据需要将近 1.5 毫秒,但返回指向它的指针则少于半毫秒。

您应该知道这些时间非常短暂。在绝大多数情况下,使用指针和值之间的差异不会影响程序的性能。但是,如果在函数之间传递的数据量达到几兆字节,请考虑使用指针,即使该数据旨在是不可变的。

所有这些数字来自一台配备 32 GB RAM 的 i7-8700 计算机。不同的 CPU 可能会产生不同的交叉点。例如,在配备 16 GB RAM 的 Apple M1 CPU 上,在大约 100 千字节的大小时,返回指针(5 微秒)比返回值(8 微秒)更快。您可以在第六章代码库中的 sample_code/pointer_perf 目录中运行自己的性能测试。运行命令 go test ./…​ -bench=. 来查找您自己的结果。(性能基准测试在“使用基准测试”中介绍。)

零值与无值

Go 指针也常用于指示已分配了零值的变量或字段与根本没有分配值的变量或字段之间的区别。如果此区别在您的程序中很重要,请使用nil指针来表示未分配的变量或结构体字段。

因为指针还表示可变性,在使用此模式时要小心。与其从函数中返回设置为nil的指针,不如使用您在映射中看到的逗号 ok 惯用法,并返回一个值类型和一个布尔值。

请记住,如果通过参数或参数上的字段将nil指针传递到函数中,则无法在函数内部设置值,因为没有地方存储该值。如果传入指针的值不为nil,则不要修改它,除非您记录了该行为。

再次提醒,JSON 转换是证明规则的例外。在将数据在 JSON 中来回转换时(是的,我会在“encoding/json”中更多地讨论 Go 标准库中的 JSON 支持),您经常需要一种区分零值和根本没有分配值的方式。对于结构体中可为空的字段,请使用指针值。

当不使用 JSON(或其他外部协议)时,请抵制使用指针字段来表示无值的诱惑。虽然指针确实提供了一种方便的方式来指示无值,但如果您不打算修改该值,则应改为使用值类型,配合布尔值使用。

映射和切片之间的区别

正如您在前一章节中看到的那样,对传递给函数的映射所做的任何修改都会反映在传递的原始变量中。现在您已经了解了指针,您可以理解其中的原因:在 Go 运行时中,映射被实现为指向结构体的指针。将映射传递给函数意味着您在复制一个指针。

因此,在使用映射作为输入参数或返回值时,尤其是在公共 API 上,你应该仔细考虑。从 API 设计的角度来看,映射是一个不好的选择,因为它们并未明确定义映射中包含的值;没有任何东西明确定义映射中的任何键,所以唯一了解它们的方法是通过代码进行跟踪。从不可变性的角度来看,映射也不好,因为了解映射中最终包含了什么的唯一方法是跟踪与其交互的所有函数。这会阻止你的 API 自我记录。如果你习惯于动态语言,请不要将映射作为替代其他语言缺乏结构的方式。Go 是一种强类型语言;与其传递映射,不如使用结构体。(当我讨论内存布局时,你会了解更喜欢结构体的另一个原因,见“减少垃圾收集器的工作量”。)

注意

在某些情况下,映射输入参数或返回值是正确的选择。结构体要求在编译时命名其字段。如果你的数据键在编译时不是已知的,那么映射是理想的选择。

与此同时,将切片传递给函数具有更复杂的行为:对切片内容的任何修改都会反映在原始变量中,但使用 append 来改变长度则不会反映在原始变量中,即使切片的容量大于其长度。这是因为切片被实现为一个具有三个字段的结构体:一个 int 类型的长度字段,一个 int 类型的容量字段,以及指向内存块的指针。图 6-5 演示了它们之间的关系。

切片的内存布局

图 6-5. 切片的内存布局

当切片被复制到另一个变量或传递给函数时,会复制长度、容量和指针。图 6-6 展示了两个切片变量指向相同内存的情况。

切片及其副本的内存布局

图 6-6. 切片及其副本的内存布局

修改切片中的值会改变指针指向的内存,因此更改会在副本和原始切片中都可见。你可以在图 6-7 中看到内存中的变化。

修改切片的内容

图 6-7. 修改切片的内容

如果切片副本被追加,并且切片有足够的容量来存放新值,则副本的长度会改变,并且新值将存储在副本和原始切片共享的内存块中。然而,原始切片中的长度保持不变。由于这些值超出了原始切片的长度,Go 运行时阻止了原始切片看到这些值。图 6-8 强调了在一个切片变量中可见但在另一个中不可见的值。

改变长度在原始中是不可见的

图 6-8. 改变长度在原始中是不可见的

如果切片副本被追加并且切片没有足够的容量来存放新值,那么会分配一个新的、更大的内存块,将值复制过去,并更新复制品中的指针、长度和容量字段。因为这些信息只在副本中,所以在原始切片中不会反映出这些更改。图 6-9 展示了现在每个切片变量指向不同的内存块。

改变容量改变存储

图 6-9. 改变容量改变存储

结果是,传递给函数的切片可以修改其内容,但切片本身无法调整大小。作为唯一可用的线性数据结构,切片经常在 Go 程序中传递。默认情况下,你应该假设一个函数不会修改切片。你的函数文档应该说明它是否修改了切片的内容。

注意

你可以将任何大小的切片传递给函数的原因是,传递给函数的数据类型对于任何大小的切片都是相同的:一个包含两个int值和一个指针的结构体。你不能编写一个接受任何大小数组的函数的原因是,整个数组会传递给函数,而不仅仅是数据的指针。

有一种情况非常有用,即能够修改切片输入参数的内容(但不能改变大小)。这使它们成为可重复使用的缓冲区的理想选择。

作为缓冲区的切片

当从外部资源(如文件或网络连接)读取数据时,许多语言会使用如下代码:

r = open_resource()
while r.has_data() {
  data_chunk = r.next_chunk()
  process(data_chunk)
}
close(r)

这种模式的问题在于,每次你通过那个while循环迭代时,都会分配另一个data_chunk,尽管每个都只使用一次。正如我在“指针是最后的选择”中讨论Unmarshal函数时所述,这样会产生大量不必要的内存分配。垃圾收集语言会自动处理这些分配,但在处理完成后仍需要进行清理工作。

即使 Go 是一种垃圾收集语言,编写符合惯用 Go 的代码意味着避免不必要的分配。与每次从数据源读取时返回新分配相比,你可以创建一个字节切片并将其用作从数据源读取数据的缓冲区:

file, err := os.Open(fileName)
if err != nil {
    return err
}
defer file.Close()
data := make([]byte, 100)
for {
    count, err := file.Read(data)
    process(data[:count])
    if err != nil {
        if errors.Is(err, io.EOF) {
            return nil
        }
        return err
    }
}

记住,当切片传递到函数时,你不能改变其长度或容量,但你可以改变当前长度内的内容。在这段代码中,你创建了一个 100 字节的缓冲区,每次循环时,你将下一个字节块(最多 100 个字节)复制到切片中。然后将填充好的缓冲区部分传递给process函数。如果发生错误(除了io.EOF,表示没有更多数据可读的特殊错误),则返回该错误。当返回io.EOF作为错误时,表示没有更多数据,函数返回nil。你将在“io 和朋友们”中详细了解更多关于 I/O 的细节,错误处理则在第九章中涵盖。

减少垃圾收集器的工作负载

使用缓冲区仅仅是减少垃圾收集器工作量的一个例子。当程序员谈论“垃圾”的时候,他们指的是“没有指针指向它的数据”。一旦某些数据没有指针指向它,那么这些数据占用的内存可以被重复使用。如果内存没有被回收,程序的内存使用将会持续增长,直到计算机的 RAM 用完。垃圾收集器的工作是自动检测未使用的内存并回收,以便可以重复使用。Go 语言拥有垃圾收集器是非常棒的,因为数十年的经验表明,手动正确管理内存对人类来说非常困难。但是仅仅因为有了垃圾收集器,并不意味着你应该大量创建垃圾。

如果你花时间学习编程语言的实现方式,你可能已经了解了堆(heap)和栈(stack)。如果你不熟悉,这里是栈的工作原理。是连续的内存块。执行线程中的每个函数调用共享同一个栈。在栈上分配内存是快速且简单的。栈指针跟踪最后一次分配内存的位置。通过更改栈指针的值来进行额外的内存分配。当函数被调用时,为函数的数据创建一个新的栈帧。局部变量与传入函数的参数一起存储在栈上。每个新变量都会使栈指针移动相应值的大小。当函数退出时,其返回值通过栈复制回调用函数,并且栈指针移回已退出函数的栈帧开头,释放该函数的所有局部变量和参数使用的栈内存。

注意

自版本 1.17 以来,Go 语言使用寄存器(CPU 上直接的一小块非常高速的内存)和栈来传递函数的输入和输出值。这样做更快速也更复杂,但仍然适用于仅栈的函数调用一般概念。

要将东西存储在堆栈上,必须在编译时确切地知道它的大小。当你看 Go 中的值类型时(原始值、数组和结构体),它们都有一个共同点:你在编译时知道它们占用多少内存。这就是为什么数组的大小被视为类型的一部分的原因。因为它们的大小是已知的,所以它们可以在堆栈上分配,而不是在堆上。指针类型的大小也是已知的,并且也存储在堆栈上。

注意

Go 的一个不寻常之处在于它可以在程序运行时增加堆栈的大小。这是可能的,因为每个 goroutine 都有自己的堆栈,并且 goroutine 是由 Go 运行时管理的,而不是由底层操作系统管理的(我在讨论并发时会详细介绍 goroutine,参见第十二章)。这既有优势(Go 的堆栈从小开始,并且使用的内存较少),也有劣势(当堆栈需要增长时,需要复制堆栈上的所有数据,这是缓慢的)。还有可能编写最坏情况下的代码,导致堆栈反复增长和缩小。

当涉及到指针指向的数据时,规则变得更加复杂。为了让 Go 将指针指向的数据分配在堆栈上,必须满足几个条件。数据必须是一个局部变量,其数据大小在编译时是已知的。指针不能从函数中返回。如果将指针传递给函数,编译器必须能够确保这些条件仍然成立。如果大小未知,你无法通过移动堆栈指针来为其腾出空间。如果返回指针变量,当函数退出时,指针指向的内存将不再有效。当编译器确定无法将数据存储在堆栈上时,我们称指针指向的数据 逃逸 出堆栈,并且编译器将数据存储在堆上。

堆是由垃圾回收器(或在像 C 和 C++ 这样的语言中手动管理)管理的内存。我不会讨论垃圾回收器算法实现的细节,但它们比移动堆栈指针要复杂得多。任何存储在堆上的数据在能够追溯到堆栈上的指针类型变量时都是有效的。一旦没有更多的指向该数据的堆栈变量,无论是直接还是通过指针链,该数据就变成了 垃圾,垃圾回收器的工作就是清理它。这个在Go Playground上的程序演示了堆上的数据何时变成垃圾。

注意

C 语言程序中常见的 bug 源自于返回指向局部变量的指针。在 C 语言中,这会导致指针指向无效的内存。Go 编译器更加智能。当它发现返回一个指向局部变量的指针时,局部变量的值会被存储在堆上。

Go 编译器进行的逃逸分析并不完美。在某些情况下,本应存储在栈上的数据会逃逸到堆上。然而,编译器必须保守,不能冒将值留在栈上的风险,因为留下对无效数据的引用会导致内存损坏。更新的 Go 发行版改进了逃逸分析。

也许你会想知道:将东西存储在堆上有什么不好?与性能相关的问题涉及两个方面。首先,垃圾收集器需要时间来执行其工作。追踪堆上所有可用的空闲内存块或跟踪仍具有有效指针的已使用内存块并非易事。这些时间会从程序本应执行的处理中抽取。已编写许多垃圾收集算法,并可以大致分为两类:设计用于更高吞吐量(在单次扫描中找到尽可能多的垃圾)或更低延迟(尽快完成垃圾扫描)。Jeffrey Dean,Google 工程成功背后的天才之一,与其合作的 2013 年文章名为“The Tail at Scale”。文章主张系统应优化以降低延迟,以保持响应时间低延迟。Go 运行时使用的垃圾收集器偏向于低延迟。每个垃圾收集周期被设计为“停止世界”(即暂停程序)少于 500 微秒。然而,如果你的 Go 程序创建大量垃圾,垃圾收集器在一个周期内可能无法找到所有垃圾,从而减慢收集器并增加内存使用。

注意

如果你对实现细节感兴趣,可以听听 Rick Hudson 在 2018 年国际内存管理研讨会上的演讲,描述了 Go 垃圾收集器的历史和实现

计算机硬件的第二个问题涉及其性质。RAM 可能意味着“随机存取存储器”,但从内存中读取最快的方式是顺序读取。在 Go 语言中,结构体切片的数据是顺序存储在内存中的。这使得加载和处理速度都非常快。指向结构体的指针切片(或者结构体的字段是指针)的数据则分散在 RAM 中,使得读取和处理速度要慢得多。Forrest Smith 写了一篇深入的博客文章,探讨了这对性能的影响有多大。他的数据表明,通过随机存储在 RAM 中存储的指针来访问数据大约慢了两个数量级。

编写软件时考虑到它运行的硬件的方法被称为机械同情心。这个术语源自汽车赛车界,意思是了解汽车正在做什么的司机可以最有效地挤出最后一点性能。2011 年,Martin Thompson 开始将这个术语应用于软件开发。在 Go 中遵循最佳实践会自动带给你这种效果。

比较 Go 和 Java 的方法。在 Java 中,本地变量和参数存储在堆栈中,与 Go 类似。然而,正如之前讨论的那样,Java 中的对象实现为指针。对于每个对象变量实例,只在堆栈上分配指向它的指针;对象内部的数据分配在堆上。只有原始值(数字、布尔值和字符)完全存储在堆栈上。这意味着 Java 中的垃圾回收器需要做大量的工作。这也意味着 Java 中List接口的实现是使用指向指针数组的指针。尽管它们看起来像是线性数据结构,但实际上从中读取数据涉及通过内存跳转,这是非常低效的。Python、Ruby 和 JavaScript 中的顺序数据类型有类似的行为。为了避免所有这些低效,Java 虚拟机包含一些非常聪明的垃圾回收器,可以进行大量的工作,有些优化了吞吐量,有些优化了延迟,并且都有配置设置,可以调整它们以获得最佳性能。Python、Ruby 和 JavaScript 的虚拟机则没有那么优化,它们的性能相应受到影响。

现在你可以看到为什么 Go 鼓励你节制地使用指针了。通过尽可能地将数据存储在堆栈上,你可以减少垃圾收集器的工作量。结构体或原始类型的切片在内存中按顺序排列,以便快速访问。当垃圾收集器确实进行工作时,它被优化为快速返回而不是收集大量垃圾。使这种方法奏效的关键是在第一次创建时减少垃圾。虽然专注于优化内存分配可能会感觉像是过早优化,但在 Go 中的惯用方法也是最有效的方法。

如果你想了解更多关于 Go 中堆与栈分配和逃逸分析的信息,可以阅读一些优秀的博客文章,包括Bill Kennedy 的 Ardan LabsAchille Roussel 以及 Rick Branson 的 Segment

调优垃圾回收器

垃圾收集器并不会在不再被引用的内存立即回收。这样做会严重影响性能。相反,它会让垃圾堆积一段时间。堆中几乎总是包含既存数据又包含不再需要的内存。Go 运行时提供了一些设置来控制堆的大小。第一个是 GOGC 环境变量。垃圾收集器在垃圾收集周期结束时查看堆大小,并使用公式 CURRENT_HEAP_SIZE + CURRENT_HEAP_SIZE*GOGC/100 计算需要达到的堆大小以触发下一个垃圾收集周期。

注意

GOGC 的堆大小计算比刚刚描述的更复杂。它考虑的不仅仅是堆大小,还包括所有 goroutines 的所有堆栈的大小以及用于保存包级变量的内存。大多数情况下,堆大小远远大于这些其他内存区域的大小,但在某些情况下,它们确实会产生影响。

默认情况下,GOGC 设置为 100,这意味着触发下一个收集的堆大小大约是当前收集结束时堆大小的两倍。将 GOGC 设置为较小的值将减少目标堆大小,将其设置为较大的值将增加它。粗略估计,将 GOGC 的值翻倍将使 GC 消耗的 CPU 时间减少一半。

GOGC 设置为 off 可以禁用垃圾收集。这将使你的程序运行更快。然而,在长时间运行的进程中关闭垃圾收集可能会使用计算机上的所有可用内存。这通常不被认为是最优的行为。

第二个垃圾收集设置指定了你的 Go 程序允许使用的总内存量的限制。Java 开发者可能熟悉 -Xmx JVM 参数,而 GOMEMLIMIT 类似。默认情况下,它是禁用的(技术上设置为 math.MaxInt64,但你的计算机不太可能有那么多内存)。GOMEMLIMIT 的值以字节为单位指定,但你可以选择使用后缀 BKiBMiBGiBTiB。例如,GOMEMLIMIT=3GiB 将内存限制设置为 3 gibibytes(等于 3,221,225,472 字节)。

注意

如果你以前没有见过这些后缀,它们是更常用的十进制 KB、MB、GB 和 TB 的官方二进制对应物。KiB 等于 2¹⁰,MiB 等于 2²⁰,以此类推。在处理计算机时使用 KiB、MiB 等朋友是 技术上正确的

限制最大内存量可能会提高程序性能似乎有些违反直觉,但增加这个标志有很好的原因。主要原因是计算机(或虚拟机或容器)没有无限的 RAM。如果内存使用出现突然的临时峰值,仅依赖 GOGC 可能导致堆大小超过可用内存量。这会导致内存交换到磁盘,非常慢。根据操作系统及其设置,这可能会导致程序崩溃。指定最大内存限制可防止堆超出计算机资源。

GOMEMLIMIT 是一个在特定情况下可以超过的限制。在垃圾回收系统中经常出现的问题是,当收集器无法释放足够的内存以达到内存限制或者垃圾回收周期频繁触发时,就会出现这种情况。被称为抖动,这导致程序除了运行垃圾回收器外什么也不做。如果 Go 运行时检测到抖动即将发生,它会选择结束当前的垃圾回收周期并超出限制。这意味着你应该将 GOMEMLIMIT 设定在可用内存的绝对最大量之下,以便有备用容量。

如果为 GOMEMLIMIT 指定了一个值,你可以将 GOGC 设置为 off 而不会耗尽内存,但这可能不会产生期望的性能效果。你可能会发现自己在频繁且非常短暂的暂停之间进行交换,以换取不经常的更长暂停。如果你运行的是 web 服务,这会导致不一致的响应时间,而这正是 Go 垃圾回收设计的行为之一,它旨在避免这种情况。

最佳选择是将这两个环境变量一起使用,以确保垃圾回收的合理节奏和应该遵守的最大值。你可以通过阅读“Go 垃圾回收指南”来了解更多关于如何使用 GOGCGOMEMLIMIT 的信息,这是由 Go 开发团队提供的。

练习

现在你已经了解了 Go 中的指针和内存,通过完成这些练习来加强有效使用指针。你可以在第六章的代码库中找到这些练习的答案。

  1. 创建一个名为 Person 的结构体,具有三个字段:类型为 stringFirstNameLastName,以及类型为 intAge。编写一个名为 MakePerson 的函数,接受 firstNamelastNameage,并返回一个 Person。再编写一个名为 MakePersonPointer 的函数,接受 firstNamelastNameage,并返回一个 *Person。从 main 函数中调用这两个函数。使用 go build -gcflags="-m" 编译你的程序。这会同时编译你的代码并打印出哪些值逃逸到堆上。你对逃逸的结果感到惊讶吗?

  2. 编写两个函数。UpdateSlice 函数接收一个 []string 和一个 string。它将传入切片的最后位置设置为传入的 string。在 UpdateSlice 结束时,打印修改后的切片。GrowSlice 函数也接收一个 []string 和一个 string。它将 string 追加到切片中。在 GrowSlice 结束时,打印修改后的切片。从 main 函数中调用这些函数。在调用每个函数之前和之后打印出切片。你能理解为什么某些变化在 main 函数中可见,而某些变化则不可见吗?

  3. 编写一个程序,用 10,000,000 个条目构建一个 []Person(它们可以是相同的姓名和年龄)。看看运行所需的时间。更改 GOGC 的值,看看它如何影响程序完成所需的时间。设置环境变量 GODEBUG=gctrace=1,查看垃圾收集的发生时间,并查看更改 GOGC 如何改变垃圾收集的次数。如果创建容量为 10,000,000 的切片会发生什么?

总结

本章稍微揭示了一些底层细节,帮助你理解指针,它们是什么,如何使用它们,以及最重要的是什么时候使用它们。在下一章中,你将深入了解 Go 语言的方法、接口和类型的实现方式,以及它们与其他语言的不同之处及其所具有的强大能力。

第七章:类型、方法和接口

正如你在前面的章节中看到的,Go 是一种静态类型语言,具有内置类型和用户定义类型。像大多数现代语言一样,Go 允许你为类型附加方法。它还具有类型抽象,允许你编写调用方法的代码,而不需显式指定实现方式。

然而,Go 对方法、接口和类型的处理方式与今天大多数其他常用语言非常不同。Go 设计旨在鼓励软件工程师提倡的最佳实践,避免继承,而鼓励组合。在本章中,你将了解类型、方法和接口,并看看如何使用它们构建可测试和可维护的程序。

Go 中的类型

回到“结构体”,你看到了如何定义结构体类型:

type Person struct {
    FirstName string
    LastName  string
    Age       int
}

这应该被理解为声明一个名为 Person 的用户定义类型,并具有随后的结构文字的 基础类型。除了结构文字,你还可以使用任何原始类型或复合类型文字来定义具体类型。以下是一些例子:

type Score int
type Converter func(string)Score
type TeamScores map[string]Score

Go 允许你在任何代码块级别声明类型,从包级块到更低级别。然而,你只能在其作用域内访问该类型。唯一的例外是从其他包导出的类型。关于这些,我会在第十章详细讨论。

注意

为了更容易讨论类型,我将定义几个术语。抽象类型 是指规定类型应该做什么而不是如何做到的类型。具体类型 指定了什么和如何。这意味着类型有指定的存储数据的方法,并提供了在类型上声明的任何方法的实现。虽然 Go 中的所有类型都是抽象或具体的,但某些语言允许混合类型,如 Java 中具有默认方法的抽象类或接口。

方法

像大多数现代语言一样,Go 支持用户定义类型的方法。

一种类型的方法在包级别块中定义:

type Person struct {
    FirstName string
    LastName  string
    Age       int
}

func (p Person) String() string {
    return fmt.Sprintf("%s %s, age %d", p.FirstName, p.LastName, p.Age)
}

方法声明看起来像函数声明,但有一个额外的 接收器 规范。接收器出现在关键字 func 和方法名称之间。像所有其他变量声明一样,接收器名称出现在类型之前。按照惯例,接收器名称通常是类型名称的简短缩写,通常是其首字母。使用 thisself 是非惯用法的。

声明方法和函数之间有一个关键区别:方法只能在包级别块中定义,而函数可以在任何块中定义。

就像函数一样,方法名不能被重载。您可以为不同的类型使用相同的方法名,但不能在同一类型的两个不同方法上使用相同的方法名。尽管从具有方法重载的语言过来时,这种哲学感觉有限制,但不重用名称是 Go 的一部分,使得代码的作用更加明确。

我将在第十章中详细讨论包,但请注意,方法必须在与其关联类型相同的包中声明;Go 不允许您向不受您控制的类型添加方法。虽然您可以在同一包中类型声明的不同文件中定义方法,但最好将类型定义及其关联的方法放在一起,以便易于理解实现。

方法调用对于那些在其他语言中使用方法的人应该看起来很熟悉:

p := Person{
    FirstName: "Fred",
    LastName:  "Fredson",
    Age:       52,
}
output := p.String()

指针接收者和值接收者

正如我在第六章中所述,Go 使用指针类型的参数来表示函数可能修改参数。方法接收者也适用相同的规则。它们可以是指针接收者(类型是指针)或值接收者(类型是值类型)。以下规则帮助您确定何时使用每种类型的接收者:

  • 如果您的方法修改接收者,您必须使用指针接收者。

  • 如果您的方法需要处理nil实例(参见“为 nil 实例编写方法”),那么它必须使用指针接收者。

  • 如果您的方法不修改接收者,您可以使用值接收者。

如果一个方法没有修改接收者,是否使用值接收者取决于类型声明中的其他方法。当一个类型有任何指针接收者方法时,一个常见的做法是保持一致,即使对于所有不修改接收者的方法也使用指针接收者。

这里是一些简单的代码,演示指针和值接收者。它从一个类型开始,该类型有两个方法,一个使用值接收者,另一个使用指针接收者:

type Counter struct {
    total       int
    lastUpdated time.Time
}

func (c *Counter) Increment() {
    c.total++
    c.lastUpdated = time.Now()
}

func (c Counter) String() string {
    return fmt.Sprintf("total: %d, last updated: %v", c.total, c.lastUpdated)
}

然后,您可以尝试使用以下代码来测试这些方法。您可以在Go Playground上运行它,或者使用第七章代码库中的sample_code/pointer_value目录中的代码:

var c Counter
fmt.Println(c.String())
c.Increment()
fmt.Println(c.String())

您应该看到以下输出:

total: 0, last updated: 0001-01-01 00:00:00 +0000 UTC
total: 1, last updated: 2009-11-10 23:00:00 +0000 UTC m=+0.000000001

您可能注意到的一件事是,即使c是值类型,您仍然可以调用指针接收者方法。当您在本地变量(值类型)上使用指针接收者时,Go 在调用方法时会自动取本地变量的地址。在这种情况下,c.Increment() 被转换为 (&c).Increment()

如果在指针变量上调用值接收者,Go 在调用方法时会自动解引用指针。在代码中:

c := &Counter{}
fmt.Println(c.String())
c.Increment()
fmt.Println(c.String())

调用 c.String() 会被悄悄地转换为 (*c).String()

警告

如果您使用空指针实例调用值接收器方法,您的代码将编译通过,但在运行时会引发恐慌(我在“恐慌和恢复”中讨论了恐慌)。

请注意,仍然适用将值传递给函数的规则。如果您将值类型传递给函数并在传递的值上调用指针接收器方法,则是在对副本调用该方法。您可以在The Go Playground上尝试以下代码,或使用第七章存储库中的sample_code/update_wrong目录中的代码:

func doUpdateWrong(c Counter) {
    c.Increment()
    fmt.Println("in doUpdateWrong:", c.String())
}

func doUpdateRight(c *Counter) {
    c.Increment()
    fmt.Println("in doUpdateRight:", c.String())
}

func main() {
    var c Counter
    doUpdateWrong(c)
    fmt.Println("in main:", c.String())
    doUpdateRight(&c)
    fmt.Println("in main:", c.String())
}

运行此代码时,您将得到以下输出:

in doUpdateWrong: total: 1, last updated: 2009-11-10 23:00:00 +0000 UTC
    m=+0.000000001
in main: total: 0, last updated: 0001-01-01 00:00:00 +0000 UTC
in doUpdateRight: total: 1, last updated: 2009-11-10 23:00:00 +0000 UTC
    m=+0.000000001
in main: total: 1, last updated: 2009-11-10 23:00:00 +0000 UTC m=+0.000000001

doUpdateRight中的参数类型是*Counter,它是一个指针实例。如您所见,您可以在其上同时调用IncrementString。对于指针实例,Go 认为指针和值接收器方法都在方法集中。对于值实例,只有值接收器方法在方法集中。这看起来现在可能是一个琐碎的细节,但我将在接下来讨论接口时再回到它。

注意

对于新手 Go 程序员(实际上,即使对于不那么新的 Go 程序员),这可能会有些混淆,但是 Go 对指针类型到值类型以及反之间的自动转换只是一种语法糖。这与方法集概念是独立的。Alexey Gronskiy 在博客文章中详细探讨了为什么指针实例的方法集包括指针和值接收器方法,但值实例的方法集只包括值接收器方法。

最后一条注意事项:除非需要满足接口的要求,否则不要为 Go 结构体编写 getter 和 setter 方法(我将在“接口快速入门课程”中开始介绍接口)。Go 鼓励您直接访问字段。保留方法用于业务逻辑。唯一的例外是当您需要将多个字段作为单个操作更新或更新不是简单的新值分配时。之前定义的Increment方法展示了这两个属性。

nil实例编写您的方法

前面的部分涵盖了指针接收器,这可能会让您想知道在调用nil实例上的方法时会发生什么。在大多数语言中,这会产生某种错误。(Objective-C 允许您在nil实例上调用方法,但它总是什么都不做。)

Go 有些不同之处。它实际上尝试调用该方法。如前所述,如果它是一个值接收器方法,您将会得到一个恐慌,因为指针没有指向任何值。如果它是一个指针接收器方法,如果该方法编写得能够处理nil实例的可能性,它就能正常工作。

在某些情况下,期望一个nil接收器可以使代码更简单。这里有一个利用nil接收器值的二叉树实现:

type IntTree struct {
    val         int
    left, right *IntTree
}

func (it *IntTree) Insert(val int) *IntTree {
    if it == nil {
        return &IntTree{val: val}
    }
    if val < it.val {
        it.left = it.left.Insert(val)
    } else if val > it.val {
        it.right = it.right.Insert(val)
    }
    return it
}

func (it *IntTree) Contains(val int) bool {
    switch {
    case it == nil:
        return false
    case val < it.val:
        return it.left.Contains(val)
    case val > it.val:
        return it.right.Contains(val)
    default:
        return true
    }
}
注意

Contains 方法不修改*IntTree,但是它是用指针接收器声明的。这展示了之前提到的关于支持nil接收器的规则。具有值接收器的方法不能检查nil,并且如前所述,如果使用nil接收器调用,它会引发恐慌。

下面的代码使用了这棵树。你可以在Go Playground上试一下,或者使用第七章代码库中的sample_code/tree目录中的代码:

func main() {
    var it *IntTree
    it = it.Insert(5)
    it = it.Insert(3)
    it = it.Insert(10)
    it = it.Insert(2)
    fmt.Println(it.Contains(2))  // true
    fmt.Println(it.Contains(12)) // false
}

Go 允许你在nil接收器上调用方法非常聪明,而且在像前面的树节点示例中这样的情况下非常有用。然而,大多数情况下它并不是很有用。指针接收器的工作原理与指针函数参数相似;传递给方法的是指针的副本。就像将nil参数传递给函数一样,如果更改指针的副本,则并未更改原始指针。这意味着你不能编写一个处理nil并使原始指针非nil的指针接收器方法。

如果你的方法有一个指针接收器,并且对于nil接收器不起作用,你必须决定你的方法应该如何处理nil接收器。一种选择是将其视为致命缺陷,就像尝试访问切片超出其长度的位置一样。在这种情况下,什么都不做,让代码发生恐慌。(同时确保编写良好的测试,如第 15 章中所讨论的。)如果nil接收器是可恢复的内容,检查nil并返回一个错误(我在第 9 章中讨论错误)。

方法也是函数

Go 中的方法与函数非常相似,你可以在任何需要函数类型的变量或参数的地方使用方法作为函数的替代品。

让我们从这个简单类型开始:

type Adder struct {
    start int
}

func (a Adder) AddTo(val int) int {
    return a.start + val
}

你可以以通常的方式创建类型的实例并调用其方法:

myAdder := Adder{start: 10}
fmt.Println(myAdder.AddTo(5)) // prints 15

你还可以将方法分配给变量或将其传递给func(int)int类型的参数。这称为方法值

f1 := myAdder.AddTo
fmt.Println(f1(10))           // prints 20

方法值有点像闭包,因为它可以访问创建它的实例字段中的值。

你还可以从类型本身创建一个函数。这称为方法表达式

f2 := Adder.AddTo
fmt.Println(f2(myAdder, 15))  // prints 25

在方法表达式中,第一个参数是方法的接收器;函数签名是func(Adder, int) int

方法值和方法表达式并不是什么聪明的边界情况。当你查看“隐式接口使依赖注入更容易”时,你会看到如何使用它们之一。

函数与方法

由于你可以将方法用作函数,你可能会想知道何时应该声明函数,何时应该使用方法。

区分因素是您的函数是否依赖于其他数据。正如我多次提到的那样,包级别的状态应该是有效不可变的。每当您的逻辑依赖于在启动时配置或在程序运行时更改的值时,这些值应存储在一个结构体中,并且该逻辑应作为方法实现。如果您的逻辑仅依赖于输入参数,则应该是一个函数。

类型声明不等同于继承

除了基于内置 Go 类型和结构字面量声明类型之外,您还可以基于其他用户定义的类型声明用户定义的类型:

type HighScore Score
type Employee Person

许多概念都可以被视为“面向对象的”,但一个概念尤为突出:继承。通过继承,声明了父类型的状态和方法可以在子类型上使用,并且子类型的值可以替代父类型的值¹。

声明一个基于另一种类型的类型看起来有点像继承,但实际上并不是。这两种类型具有相同的基础类型,但仅此而已。这些类型之间没有层次结构。在具有继承的语言中,子实例可以在任何父实例可以使用的地方使用。子实例还具有父实例的所有方法和数据结构。但在 Go 语言中并非如此。你不能将HighScore类型的实例赋给Score类型的变量,反之亦然,除非进行类型转换。也不能将它们中的任何一个分配给int类型的变量而不进行类型转换。此外,在Score上定义的任何方法在HighScore上也不存在:

// assigning untyped constants is valid
var i int = 300
var s Score = 100
var hs HighScore = 200
hs = s                  // compilation error!
s = i                   // compilation error!
s = Score(i)            // ok
hs = HighScore(s)       // ok

具有内置类型作为其基础类型的用户定义类型可以分配与基础类型兼容的字面量和常量。它们也可以与这些类型的运算符一起使用:

var s Score = 50
scoreWithBonus := s + 100 // type of scoreWithBonus is Score
提示

在具有共享基础类型的类型之间进行类型转换会保持相同的基础存储,但关联不同的方法。

类型是可执行文档

尽管我们深知应该声明一个结构体类型来保存一组相关数据,但当您应该声明一个基于其他内置类型或基于另一个用户定义类型的用户定义类型时,这一点并不十分清晰。简短的答案是,类型是文档。它们通过为概念提供名称并描述预期数据的类型,使代码更加清晰。对于阅读您代码的人来说,当一个方法有一个Percentage类型的参数时,比有一个int类型的参数更加清晰,而且更难以使用无效值调用它。

当声明一个用户定义的类型基于另一个用户定义的类型时,相同的逻辑也适用。当您拥有相同的基础数据但需要执行不同操作集时,请创建两种类型。声明一种类型基于另一种类型可以避免一些重复,并清楚地表明这两种类型是相关的。

iota用于枚举——有时

许多编程语言都有枚举的概念,允许你指定类型只能有限的几个值。Go 语言没有枚举类型,而是使用 iota,它允许你为一组常量赋予递增的值。

注意

iota 的概念源自编程语言 APL(全称“A Programming Language”)。在 APL 中,要生成前三个正整数的列表,你可以写作 ι3,其中 ι 是小写希腊字母 iota。

APL 因其高度依赖自定义符号而闻名,以至于需要配备特殊键盘的计算机。例如,(~R∊R∘.×R)/R←1↓ιR 是一个 APL 程序,用于找出小于变量 R 值的所有质数。

Go 语言强调可读性,却从另一种以简洁著称的语言借鉴概念,这或许有些讽刺,但这也是为什么你应该学习多种编程语言:你可以从任何地方获得灵感。

使用 iota 时,最佳实践是首先定义一个基于 int 的类型,该类型将表示所有有效值:

type MailCategory int

接下来,使用 const 块为你的类型定义一组值:

const (
    Uncategorized MailCategory = iota
    Personal
    Spam
    Social
    Advertisements
)

const 块中,第一个常量的类型已经指定,并且其值设置为 iota。随后的每一行既没有指定类型也没有指定值。当 Go 编译器看到这种写法时,会将类型和赋值重复到块中的所有后续常量,这就是 iota 的作用。iota 的值递增,从 0 开始为第一个常量(Uncategorized),第二个常量为 1Personal),依此类推。当创建新的 const 块时,iota 会被重置为 0

iota 的值递增,每个常量在 const 块中,无论是否使用 iota 来定义常量的值。以下代码演示了在 const 块中间歇性使用 iota 时会发生什么:

const (
    Field1 = 0
    Field2 = 1 + iota
    Field3 = 20
    Field4
    Field5 = iota
)

func main() {
    fmt.Println(Field1, Field2, Field3, Field4, Field5)
}

你可以在The Go Playground上运行此代码,并查看(也许是意外的)结果:

0 2 20 20 4

Field2 被赋值为 2,因为在 const 块的第二行中,iota 的值为 1Field4 被赋值为 20,因为它没有显式指定类型或值,所以它获取了前一行的具有类型和赋值的值。最后,Field5 获取值 4,因为它是第五行,而 iota0 开始计数。

这是我见过的关于 iota 的最佳建议:

不要将iota用于定义其值在其他地方明确定义的常量。例如,在实施规范的部分和规范指定分配给哪些常量的值时,应明确写出常量值。仅在“内部”目的中使用iota。也就是说,通过名称引用而不是通过值引用常量。这样,您可以在任何时刻/列表中的位置上最优地使用iota插入新常量,而不会造成破坏。

丹尼·范·休曼

重要的是要理解,Go 中没有任何东西会阻止您(或其他任何人)创建您类型的其他值。此外,如果在字面常量列表中间插入一个新标识符,则所有后续标识符将重新编号。如果这些常量表示另一个系统或数据库中的值,则会以微妙的方式破坏您的应用程序。鉴于这两个限制条件,基于 iota 的枚举仅在您关心能够区分一组值并且不特别关心背后的值时才有意义。如果实际值很重要,请明确指定它。

警告

因为您可以将字面表达式分配给常量,所以您会看到示例代码建议在此类情况下使用 iota

type BitField int

const (
    Field1 BitField = 1 << iota // assigned 1
    Field2                      // assigned 2
    Field3                      // assigned 4
    Field4                      // assigned 8
)

虽然这很聪明,但在使用此模式时要小心。如果这样做,请记录您所做的事情。如前所述,当您关心值时,使用 iota 与常量是脆弱的。您不希望未来的维护者在列表中间插入新常量并破坏您的代码。

注意,iota 从 0 开始编号。如果您使用自己的常量集表示不同的配置状态,则零值可能会有用。您在 MailCategory 类型中之前看到过这一点。当邮件首次到达时,它是未分类的,因此零值是合理的。如果您的常量没有合理的默认值,一个常见的模式是将常量块中的第一个 iota 值分配给 _ 或指示该值无效的常量。这样可以轻松检测变量是否已正确初始化。

使用嵌入进行组合

软件工程建议“优先使用对象组合而不是类继承”可以追溯到至少 1994 年出版的 设计模式 一书(Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides,Addison-Wesley),更为人所知的是四人帮书籍。虽然 Go 没有继承,但通过内置支持组合和提升来鼓励代码重用:

type Employee struct {
    Name         string
    ID           string
}

func (e Employee) Description() string {
    return fmt.Sprintf("%s (%s)", e.Name, e.ID)
}

type Manager struct {
    Employee
    Reports []Employee
}

func (m Manager) FindNewEmployees() []Employee {
    // do business logic
}

请注意,Manager 包含一个类型为 Employee 的字段,但未为该字段分配名称。这使 Employee 成为一个嵌入字段。在嵌入字段上声明的任何字段或方法都会提升到包含的结构体中,并且可以直接在其上调用。这使得以下代码有效:

m := Manager{
    Employee: Employee{
        Name: "Bob Bobson",
        ID:   "12345",
    },
    Reports: []Employee{},
}
fmt.Println(m.ID)            // prints 12345
fmt.Println(m.Description()) // prints Bob Bobson (12345)
注意

你可以嵌入结构中的任何类型,不仅仅是另一个结构。这样可以将嵌入类型的方法提升到包含它的结构体中。

如果包含的结构体具有与嵌入字段相同名称的字段或方法,则需要使用嵌入字段的类型来引用被隐藏的字段或方法。如果你定义的类型如下:

type Inner struct {
    X int
}

type Outer struct {
    Inner
    X int
}

只能通过显式指定Inner来访问Inner上的X

o := Outer{
    Inner: Inner{
        X: 10,
    },
    X: 20,
}
fmt.Println(o.X)       // prints 20
fmt.Println(o.Inner.X) // prints 10

嵌入不是继承

编程语言中内置的嵌入支持很少见(我不知道其他流行语言支持它)。许多熟悉继承(在许多语言中都有)的开发人员尝试将嵌入视为继承来理解。这条路不通。你不能将Manager类型的变量赋给Employee类型的变量。如果你想访问Manager中的Employee字段,必须显式地这样做。你可以在Go Playground上运行以下代码或者使用第七章仓库中的sample_code/embedding目录中的代码:

var eFail Employee = m        // compilation error!
var eOK Employee = m.Employee // ok!

你将会得到错误:

cannot use m (type Manager) as type Employee in assignment

此外,Go 没有具体类型的动态分派。嵌入字段上的方法并不知道它们被嵌入了。如果在嵌入字段上有一个方法调用另一个嵌入字段上的方法,并且包含结构体具有同名方法,则会调用嵌入字段上的方法,而不是包含结构体上的方法。此行为在以下代码中得到了演示,你可以在Go Playground上运行它,或者使用第七章仓库中的sample_code/no_dispatch目录中的代码:

type Inner struct {
    A int
}

func (i Inner) IntPrinter(val int) string {
    return fmt.Sprintf("Inner: %d", val)
}

func (i Inner) Double() string {
    return i.IntPrinter(i.A * 2)
}

type Outer struct {
    Inner
    S string
}

func (o Outer) IntPrinter(val int) string {
    return fmt.Sprintf("Outer: %d", val)
}

func main() {
    o := Outer{
        Inner: Inner{
            A: 10,
        },
        S: "Hello",
    }
    fmt.Println(o.Double())
}

运行此代码将产生以下输出:

Inner: 20

虽然在一个具体类型内嵌另一个类型不允许你将外部类型视为内部类型,但嵌入字段的方法确实计入包含结构体的方法集。这意味着它们可以使包含的结构体实现一个接口。

接口的快速课程

尽管 Go 的并发模型(我在第十二章中介绍)备受瞩目,但 Go 设计的真正亮点是它的隐式接口,这是 Go 中唯一的抽象类型。让我们看看它们为何如此出色。

让我们先快速看一下如何声明接口。接口在本质上很简单。像其他用户定义的类型一样,你使用type关键字。

这是fmt包中Stringer接口的定义:

type Stringer interface {
    String() string
}

在接口声明中,接口文字出现在接口类型名称之后。它列出了具体类型必须实现的方法,以符合接口的要求。接口定义的方法称为接口的方法集。正如我在“指针接收器和值接收器”中所述,指针实例的方法集包含使用指针接收器和值接收器定义的方法,而值实例的方法集仅包含使用值接收器定义的方法。以下是使用先前定义的Counter结构的快速示例:

type Incrementer interface {
    Increment()
}

var myStringer fmt.Stringer
var myIncrementer Incrementer
pointerCounter := &Counter{}
valueCounter := Counter{}

myStringer = pointerCounter    // ok
myStringer = valueCounter      // ok
myIncrementer = pointerCounter // ok
myIncrementer = valueCounter   // compile-time error!

尝试编译这段代码会导致错误cannot use valueCounter (variable of type Counter) as Incrementer value in assignment: Counter does not implement Incrementer (method Increment has pointer receiver).

你可以在The Go Playground上尝试这段代码,或者使用第七章代码库sample_code/method_set目录中的代码。

与其他类型一样,接口可以在任何块中声明。

接口通常以“er”结尾命名。你已经见过fmt.Stringer,但还有很多,包括io.Readerio.Closerio.ReadCloserjson.Marshalerhttp.Handler

接口是类型安全的鸭子类型

到目前为止,关于 Go 语言接口的讨论并没有什么不同于其他语言的接口。使 Go 语言接口特殊的是它们是隐式实现的。正如你在前面示例中使用的Counter结构类型和Incrementer接口类型所看到的那样,具体类型不声明它实现了一个接口。如果具体类型的方法集包含接口方法集中的所有方法,则具体类型实现了该接口。因此,具体类型可以赋给声明为接口类型的变量或字段。

这种隐式行为使接口成为 Go 语言中最有趣的类型特性,因为它们既实现了类型安全又实现了解耦,将静态语言和动态语言的功能桥接起来。

要理解其中的原因,让我们讨论一下为什么语言需要接口。前面我提到过设计模式教导开发者更倾向于组合而非继承。书中的另一个建议是“针对接口编程,而不是针对实现编程”。这样做允许你依赖行为而不是实现,使你能够根据需要交换实现。这使得你的代码能够随着需求的变化而演变。

Python、Ruby 和 JavaScript 等动态类型语言没有接口。相反,这些开发者使用鸭子类型,其基于“如果它走起来像鸭子,叫起来像鸭子,那它就是鸭子”的表达。该概念是,只要函数能够找到预期的方法来调用,你就可以将类型的实例作为参数传递给函数:

class Logic:
    def process(self, data):
        # business logic

def program(logic):
    # get data from somewhere
    logic.process(data)

logicToUse = Logic()
program(logicToUse)

鸭子类型起初可能听起来很奇怪,但它已被用于构建大型且成功的系统。如果你在静态类型语言中编程,这听起来像彻头彻尾的混乱。在没有明确指定类型的情况下,很难知道应该期望什么功能。当新开发人员加入项目或现有开发人员忘记代码的作用时,他们必须跟踪代码以确定实际的依赖关系。

Java 开发人员使用不同的模式。他们定义一个接口,创建接口的实现,但仅在客户端代码中引用接口:

public interface Logic {
    String process(String data);
}

public class LogicImpl implements Logic {
    public String process(String data) {
        // business logic
    }
}

public class Client {
    private final Logic logic;
    // this type is the interface, not the implementation

    public Client(Logic logic) {
        this.logic = logic;
    }

    public void program() {
        // get data from somewhere
        this.logic.process(data);
    }
}

public static void main(String[] args) {
    Logic logic = new LogicImpl();
    Client client = new Client(logic);
    client.program();
}

动态语言开发人员看到 Java 中的显式接口,不明白在有显式依赖关系的情况下如何随时间重构代码。从不同提供者切换到新实现意味着重写代码以依赖新接口。

Go 的开发人员认为两种方法都是正确的。如果你的应用程序会随时间增长和变化,你需要灵活性来改变实现。然而,为了让人们理解你的代码在做什么(随着时间的推移,新的人员在同一代码上工作时),你还需要指定代码依赖的内容。这就是隐式接口的用武之地。Go 代码是前两种风格的混合:

type LogicProvider struct {}

func (lp LogicProvider) Process(data string) string {
    // business logic
}

type Logic interface {
    Process(data string) string
}

type Client struct{
    L Logic
}

func(c Client) Program() {
    // get data from somewhere
    c.L.Process(data)
}

main() {
    c := Client{
        L: LogicProvider{},
    }
    c.Program()
}

Go 代码提供了一个接口,但只有调用者(Client)知道它;在LogicProvider上没有声明任何内容来表明它符合接口。这足以允许将来引入新的逻辑提供者,并提供可执行的文档以确保传递给客户端的任何类型都与客户端的需求匹配。

Tip

接口指定调用者需要什么。客户端代码定义接口以指定它需要哪些功能。

这并不意味着接口不能共享。你已经在标准库中看到了几个用于输入和输出的接口。拥有标准接口是强大的;如果你的代码编写为与io.Readerio.Writer一起工作,它将正确地运行,无论是写入本地磁盘上的文件还是内存中的值。

此外,使用标准接口鼓励装饰器模式。在 Go 中编写工厂函数的常见做法是,接受一个接口实例并返回实现相同接口的另一种类型。例如,假设你有以下定义的函数:

func process(r io.Reader) error

你可以用以下代码处理来自文件的数据:

r, err := os.Open(fileName)
if err != nil {
    return err
}
defer r.Close()
return process(r)

os.Open 返回的 os.File 实例符合 io.Reader 接口,并可以在任何读取数据的代码中使用。如果文件是 gzip 压缩的,你可以将 io.Reader 包装在另一个 io.Reader 中:

r, err := os.Open(fileName)
if err != nil {
    return err
}
defer r.Close()
gz, err := gzip.NewReader(r)
if err != nil {
    return err
}
defer gz.Close()
return process(gz)

现在从未压缩文件读取的完全相同的代码改为从压缩文件读取。

Tip

如果标准库中的接口描述了你的代码所需的功能,请使用它!常用的接口包括io.Readerio.Writerio.Closer

对于满足接口的类型而言,指定附加方法并不是不合适的做法。一组客户端代码可能不关心这些方法,但其他人可能关心。例如,io.File 类型也满足 io.Writer 接口。如果你的代码只关心从文件中读取数据,可以使用 io.Reader 接口来引用文件实例,并忽略其他方法。

嵌入和接口

嵌入不仅适用于结构体。你还可以在接口中嵌入接口。例如,io.ReadCloser 接口由 io.Readerio.Closer 组成:

type Reader interface {
        Read(p []byte) (n int, err error)
}

type Closer interface {
        Close() error
}

type ReadCloser interface {
        Reader
        Closer
}
注意

正如你可以在结构体中嵌入具体类型一样,你也可以在结构体中嵌入接口。你会在“在 Go 中使用存根”中看到这种用法。

接受接口,返回结构体

你经常会听到经验丰富的 Go 开发者说,你的代码应该“接受接口,返回结构体”。这句话很可能是由 Jack Lindamood 在他 2016 年的博客文章“Go 中的预防性接口反模式”中首次提出的。这意味着由你的函数调用的业务逻辑应通过接口调用,但你函数的输出应为具体类型。我已经解释了为什么函数应接受接口:它们使你的代码更灵活,并明确声明正在使用的确切功能。

函数应返回具体类型的主要原因是它们使得在代码的新版本中逐步更新函数的返回值变得更容易。当函数返回一个具体类型时,可以添加新方法和字段而不会破坏调用该函数的现有代码,因为新字段和方法会被忽略。但对于接口来说并非如此。向接口添加新方法意味着必须更新该接口的所有现有实现,否则你的代码就会出现问题。从语义化版本的角度来看,这是后向兼容的次要发布和后向不兼容的主要发布之间的区别。如果你正在暴露一个 API 供其他人使用(无论是在你的组织内部还是作为开源项目的一部分),避免破坏向后兼容的变更会让你的用户满意。

在某些罕见情况下,最不坏的选择是使你的函数返回接口。例如,标准库中的database/sql/driver包定义了一组接口,定义了数据库驱动程序必须提供的内容。数据库驱动程序的作者有责任提供这些接口的具体实现,因此标准库中database/sql/driver定义的所有接口上的几乎所有方法都返回接口。从 Go 1.8 开始,期望数据库驱动程序支持额外的功能。标准库承诺兼容性,因此不能更新现有接口以添加新方法,也不能更新这些接口上的现有方法以返回不同类型。解决方案是保持现有接口不变,定义描述新功能的新接口,并告诉数据库驱动程序作者他们应该在其具体类型上实现旧方法和新方法。

这引出了一个问题,即如何检查这些新方法是否存在,以及如果存在如何访问它们。你将在“类型断言和类型开关”中学习如何做到这一点。

而不是编写一个返回基于输入参数返回不同实例的接口的单个工厂函数,试着为每种具体类型编写单独的工厂函数。在某些情况下(例如可以返回一种或多种类型令牌的解析器),这是不可避免的,你别无选择,只能返回一个接口。

错误是此规则的一个例外。正如你将在第九章中看到的那样,Go 函数和方法可以声明返回error接口类型的返回参数。在error的情况下,不同的接口实现很可能会被返回,因此你需要使用接口来处理所有可能的选项,因为在 Go 中接口是唯一的抽象类型。

这种模式存在一个潜在的缺点。正如我在“减少垃圾收集器工作量”中讨论的那样,减少堆分配可以通过减少垃圾收集器的工作量来提高性能。返回一个结构体可以避免堆分配,这是很好的。然而,当调用带有接口类型参数的函数时,每个接口参数都会进行堆分配。找出更好的抽象与更好的性能之间的权衡应该在程序的整个生命周期内完成。编写你的代码使其可读和可维护。如果你发现你的程序太慢,并且你已经对其进行了分析,并且确定性能问题是由接口参数引起的堆分配导致的,那么你应该重写函数以使用具体类型参数。如果将多个接口实现传递给函数,这意味着需要创建多个带有重复逻辑的函数。

注意

来自 C++或 Rust 背景的开发人员可能会尝试使用泛型作为让编译器生成专门函数的一种方式。截至 Go 1.21,这可能不会生成更快的代码。我将在“Go 语言惯用法和泛型”中详细介绍原因。

接口和nil

在讨论指针时(见第六章](ch06.html#unique_chapter_id_06),我还谈到了nil,即指针类型的零值。您也可以使用nil来表示接口实例的零值,但这对于具体类型来说并不简单。

要理解接口与nil之间的关系,需要了解一些关于接口实现方式的知识。在 Go 运行时中,接口实现为一个结构体,其中包含两个指针字段,一个用于值,一个用于值的类型。只要类型字段非nil,接口就非nil。(由于不能有没有类型的变量,如果值指针非nil,则类型指针始终非nil

要将接口视为nil类型和值都必须为nil。以下代码在前两行上打印出true,在最后一行上打印出false

var pointerCounter *Counter
fmt.Println(pointerCounter == nil) // prints true
var incrementer Incrementer
fmt.Println(incrementer == nil) // prints true
incrementer = pointerCounter
fmt.Println(incrementer == nil) // prints false

您可以在Go Playground上运行此代码,或者使用第七章存储库中的sample_code/interface_nil目录中的代码。

对于具有接口类型的变量,nil表示您是否可以在其上调用方法。正如我之前讨论过的,您可以在nil具体实例上调用方法,因此对于分配了nil具体实例的接口变量也可以调用方法。如果接口变量为nil,在其上调用任何方法将触发恐慌(我将在“恐慌和恢复”中讨论)。如果接口变量为非nil,则可以在其上调用方法。(但请注意,如果值为nil并且分配类型的方法未正确处理nil,仍可能触发恐慌。)

由于具有非nil类型的接口实例不等于nil,因此在类型为非nil时,要确定与接口关联的值是否为nil并不简单。您必须使用反射(我将在“使用反射检查接口值是否为 nil”中讨论)来找出。

接口可比较

在第三章中,您学习了可比较类型,这些类型可以使用==进行相等性检查。也许您会惊讶地发现接口也是可以比较的。正如一个接口仅在其类型和值字段都为nil时才等于nil一样,两个接口类型的实例仅在它们的类型和值都相等时才相等。这引发了一个问题:如果类型不可比较会发生什么?让我们通过一个简单的例子来探讨这个概念。首先定义一个接口和该接口的一些实现:

type Doubler interface {
    Double()
}

type DoubleInt int

func (d *DoubleInt) Double() {
    *d = *d * 2
}

type DoubleIntSlice []int

func (d DoubleIntSlice) Double() {
    for i := range d {
        d[i] = d[i] * 2
    }
}

DoubleInt 上的 Double 方法声明为指针接收器,因为你要修改 int 的值。对于 DoubleIntSlice 上的 Double 方法,可以使用值接收器,因为如 “映射与切片的区别” 所述,可以更改参数为切片类型的项的值。*DoubleInt 类型是可比较的(所有指针类型都是),而 DoubleIntSlice 类型不可比较(切片不可比较)。

你还有一个函数,它接受两个 Doubler 类型的参数,并打印它们是否相等:

func DoublerCompare(d1, d2 Doubler) {
    fmt.Println(d1 == d2)
}

现在你定义了四个变量:

var di DoubleInt = 10
var di2 DoubleInt = 10
var dis = DoubleIntSlice{1, 2, 3}
var dis2 = DoubleIntSlice{1, 2, 3}

现在,你要调用这个函数三次。第一次调用如下:

DoublerCompare(&di, &di2)

这会打印出 false。类型匹配(都是 *DoubleInt),但你比较的是指针而不是值,而这些指针指向不同的实例。

接下来,你将 *DoubleIntDoubleIntSlice 进行比较:

DoublerCompare(&di, dis)

这会打印出 false,因为类型不匹配。

最后,你遇到了问题的情况:

DoublerCompare(dis, dis2)

此代码可以编译通过,但在运行时会触发 panic:

panic: runtime error: comparing uncomparable type main.DoubleIntSlice

整个程序在第七章存储库的 sample_code/comparable 目录中都可用。

还要注意,映射的键必须是可比较的,因此可以定义一个接口作为键的映射:

m := map[Doubler]int{}

如果向此映射添加键值对且键不可比较,那也会触发 panic。

考虑到这种行为,在使用接口进行 ==!= 比较时要小心,或者将接口用作映射键时,很容易意外触发会崩溃程序的 panic。即使当前所有接口实现都是可比较的,你也不知道其他人在使用或修改你的代码时会发生什么,并且没有办法指定接口只能由可比较类型实现。如果想要更加安全,可以使用 reflect.ValueComparable 方法在使用 ==!= 之前检查接口。(关于反射,你可以在 “反射让你在运行时操作类型” 中了解更多)。

空接口毫无意义

有时在静态类型语言中,你需要一种方式来表示变量可以存储任何类型的值。Go 使用 空接口 interface{} 表示这种情况:

var i interface{}
i = 20
i = "hello"
i = struct {
    FirstName string
    LastName string
} {"Fred", "Fredson"}
注意

interface{} 并非特例语法。空接口类型仅表示变量可以存储任何类型的值,该类型实现了零个或多个方法。这恰好匹配 Go 中的每种类型。

为了提高可读性,Go 在 interface{} 的类型别名中添加了 any。遗留代码(在 Go 1.18 中添加 any 之前编写的代码)使用 interface{},但对于新代码,请坚持使用 any

因为空接口无法告诉你关于其表示的值的任何信息,所以你无法对其进行太多操作。any 的一个常见用途是作为从外部源(如 JSON 文件)读取的具有不确定模式的数据的占位符:

data := map[string]any{}
contents, err := os.ReadFile("testdata/sample.json")
if err != nil {
    return err
}
json.Unmarshal(contents, &data)
// the contents are now in the data map
注意

在 Go 添加泛型之前编写的用户创建的数据容器使用空接口来存储值(我将在第 8 章中讨论泛型)。标准库中的一个示例是container/list。现在泛型是 Go 的一部分,请为任何新创建的数据容器使用它们。

如果您看到一个接受空接口的函数,很可能使用反射(我将在第 16 章中讨论)来填充或读取值。在上面的例子中,json.Unmarshal函数的第二个参数声明为any类型。

这些情况应该相对罕见。避免使用any。正如您所看到的,Go 被设计为一种强类型语言,试图绕过这一点是不符合习惯的。

如果您发现自己处于必须将值存储到空接口中的情况中,您可能想知道如何再次读取该值。为了做到这一点,您需要了解类型断言和类型切换。

类型断言和类型切换

Go 为检查接口类型的变量是否具有特定具体类型或具体类型是否实现另一个接口提供了两种方法。让我们从看类型断言开始。类型断言指出实现接口的具体类型,或指出另一个同样被存储在接口中值的具体类型也实现了另一个接口。您可以在Go Playground上试试,或在第七章仓库sample_code/type_assertions目录中的main.go中的typeAssert函数中看到:

type MyInt int

func main() {
    var i any
    var mine MyInt = 20
    i = mine
    i2 := i.(MyInt)
    fmt.Println(i2 + 1)
}

在上述代码中,变量i2的类型为MyInt

您可能想知道如果类型断言错误会发生什么。在这种情况下,您的代码会导致恐慌。您可以在Go Playground上尝试或在第七章仓库sample_code/type_assertions目录中的main.go中的typeAssertPanicWrongType函数中看到这一点:

i2 := i.(string)
fmt.Println(i2)

运行此代码会导致以下恐慌:

panic: interface conversion: interface {} is main.MyInt, not string

正如您已经看到的,Go 对具体类型非常谨慎。即使两种类型共享底层类型,类型断言也必须匹配存储在接口中的值的类型。以下代码将导致恐慌。您可以在Go Playground上尝试或在第七章仓库sample_code/type_assertions目录中的main.go中的typeAssertPanicTypeNotIdentical函数中看到:

i2 := i.(int)
fmt.Println(i2 + 1)

显然,崩溃不是预期的行为。您可以通过使用逗号 ok 惯用法来避免这种情况,就像在检测地图中是否有零值时所看到的“逗号 ok 惯用法”一样。您可以在第七章仓库sample_code/type_assertions目录中的main.go中的typeAssertCommaOK函数中看到这一点:

i2, ok := i.(int)
if !ok {
    return fmt.Errorf("unexpected type for %v",i)
}
fmt.Println(i2 + 1)

如果类型转换成功,则布尔值ok设置为true。如果不成功,则ok设置为false,另一个变量(在本例中为i2)设置为其零值。然后,你在if语句中处理意外情况。我会在第九章中详细讨论错误处理。

注意

类型断言与类型转换非常不同。转换将值更改为新类型,而断言则显示存储在接口中的值的类型。类型转换可以应用于具体类型和接口。类型断言只能应用于接口类型。所有类型断言在运行时都会被检查,因此如果你不使用逗号 ok 惯用法,它们可能会在运行时失败并引发恐慌。大多数类型转换在编译时检查,因此如果无效,你的代码将无法编译。(切片和数组指针之间的类型转换可能在运行时失败,并且不支持逗号 ok 惯用法,因此在使用它们时要小心!)

即使你绝对确定你的类型断言是有效的,也使用逗号 OK 惯用法版本。你不知道其他人(或者六个月后的你)将如何重用你的代码。 sooner or later, your unvalidated type assertions will fail at runtime.

当一个接口可能是多种可能类型之一时,应该使用类型 switch代替:

func doThings(i any) {
    switch j := i.(type) {
    case nil:
        // i is nil, type of j is any
    case int:
        // j is of type int
    case MyInt:
        // j is of type MyInt
    case io.Reader:
        // j is of type io.Reader
    case string:
        // j is a string
    case bool, rune:
        // i is either a bool or rune, so j is of type any
    default:
        // no idea what i is, so j is of type any
    }
}

类型switch看起来很像你早些时候在switch中看到的switch语句。不同之处在于,你指定一个接口类型的变量,然后跟上.(type)。通常情况下,你将正在检查的变量分配给另一个仅在switch内有效的变量。

注意

由于类型switch的目的是从现有变量派生新变量,因此将正在进行 switch 的变量分配给同名变量(i := i.(type))是一种惯用法,这是少数几个使用影子变量是一个好主意的地方之一。为了使注释更易读,本例没有使用影子变量。

新变量的类型取决于哪种情况匹配。你可以在一个情况中使用nil来查看接口是否没有关联类型。如果在一个情况中列出多种类型,则新变量的类型为any。与switch语句一样,如果没有指定类型匹配时有一个default情况,则新变量的类型与匹配的情况类型相同。

到目前为止的例子都使用了带有类型断言和类型 switch 的any接口,你可以从所有接口类型中发现具体类型。

提示

如果你不知道接口中存储的值的类型,你需要使用反射。我会在第十六章中详细讨论反射。

谨慎使用类型断言和类型 switch

虽然能从接口变量中提取具体实现看似方便,但应该少用这些技术。大多数情况下,应将参数或返回值视为所提供的类型,而不是可能的其他类型。否则,函数的 API 未能准确声明其执行任务所需的类型。如果需要不同的类型,应该明确指定。

尽管如此,类型断言和类型切换在某些情况下很有用。类型断言的一个常见用法是查看接口背后的具体类型是否还实现了另一个接口。这允许您指定可选接口。例如,标准库使用这种技术在调用 io.Copy 函数时允许更高效的复制。此函数具有两个类型为 io.Writerio.Reader 的参数,并调用 io.copyBuffer 函数来执行其工作。如果 io.Writer 参数还实现了 io.WriterTo,或者 io.Reader 参数还实现了 io.ReaderFrom,则函数中的大部分工作可以跳过。

// copyBuffer is the actual implementation of Copy and CopyBuffer.
// if buf is nil, one is allocated.
func copyBuffer(dst Writer, src Reader, buf []byte) (written int64, err error) {
    // If the reader has a WriteTo method, use it to do the copy.
    // Avoids an allocation and a copy.
    if wt, ok := src.(WriterTo); ok {
        return wt.WriteTo(dst)
    }
    // Similarly, if the writer has a ReadFrom method, use it to do the copy.
    if rt, ok := dst.(ReaderFrom); ok {
        return rt.ReadFrom(src)
    }
    // function continues...
}

另一个使用可选接口的地方是在演化 API 时。正如《接受接口,返回结构》中所述,数据库驱动程序的 API 随时间变化而变化。这种变化的原因之一是上下文的添加,在第十四章中有详细讨论。上下文是传递给函数的参数,提供了管理取消等功能的标准方式。它在 Go 1.7 中添加,这意味着旧代码不支持它。这包括旧的数据库驱动程序。

在 Go 1.8 中,database/sql/driver 包定义了现有接口的新的上下文感知模拟。例如,StmtExecContext 接口定义了一个名为 ExecContext 的方法,它是 StmtExec 方法的上下文感知替代。当将 Stmt 的实现传递给标准库数据库代码时,它会检查是否还实现了 StmtExecContext。如果是,将调用 ExecContext。如果不是,Go 标准库提供了一个取消支持的后备实现:

func ctxDriverStmtExec(ctx context.Context, si driver.Stmt,
                       nvdargs []driver.NamedValue) (driver.Result, error) {
    if siCtx, is := si.(driver.StmtExecContext); is {
        return siCtx.ExecContext(ctx, nvdargs)
    }
    // fallback code is here
}

这种可选接口技术有一个缺点。前面提到,接口的实现通常使用装饰器模式来包装相同接口的其他实现以进行层级行为。问题在于,如果一个可选接口由包装实现之一实现,你无法使用类型断言或类型切换来检测它。例如,标准库包括一个 bufio 包,提供了缓冲读取器。您可以通过将其传递给 bufio.NewReader 函数并使用返回的 *bufio.Reader 缓冲任何其他 io.Reader 实现。如果传入的 io.Reader 也实现了 io.ReaderFrom,将其包装在缓冲读取器中将阻止优化。

处理错误时也可以看到这一点。如前所述,它们实现了 error 接口。错误可以通过包装其他错误来包含额外信息。类型开关或类型断言无法检测或匹配已包装的错误。如果您希望采取不同的行为来处理返回错误的不同具体实现,请使用 errors.Iserrors.As 函数来测试和访问已包装的错误。

类型 switch 语句提供了区分需要不同处理的接口多个实现的能力。当只有某些可能的有效类型可以提供给接口时,它们非常有用。确保在类型 switch 中包含一个 default 情况来处理在开发时不知道的实现。这可以保护您免受在添加新接口实现时忘记更新类型 switch 语句的影响:

func walkTree(t *treeNode) (int, error) {
    switch val := t.val.(type) {
    case nil:
        return 0, errors.New("invalid expression")
    case number:
        // we know that t.val is of type number, so return the
        // int value
        return int(val), nil
    case operator:
        // we know that t.val is of type operator, so
        // find the values of the left and right children, then
        // call the process() method on operator to return the
        // result of processing their values.
        left, err := walkTree(t.lchild)
        if err != nil {
            return 0, err
        }
        right, err := walkTree(t.rchild)
        if err != nil {
            return 0, err
        }
        return val.process(left, right), nil
    default:
        // if a new treeVal type is defined, but walkTree wasn't updated
        // to process it, this detects it
        return 0, errors.New("unknown node type")
    }
}

你可以在Go Playground上看到完整的实现,或者在第七章存储库sample_code/type_switch 目录中找到。

注意

您可以通过使接口未导出并且至少一个方法未导出来进一步保护自己免受意外接口实现的影响。如果接口被导出,它可以嵌入在另一个包中的结构体中,使结构体实现接口。我将在 第十章 中更多地讨论包和导出标识符。

函数类型是接口的桥梁

在类型声明中,还有一件事情我没有讲到。一旦你理解了在结构体上声明方法的概念,你就能开始看到具有 intstring 底层类型的用户定义类型也可以有方法。毕竟,方法提供了与实例状态交互的业务逻辑,而整数和字符串也有状态。

然而,在 Go 中,方法允许在 任何 用户定义的类型上,包括用户定义的函数类型。这听起来像是一个学术边角案例,但它们实际上非常有用。它们允许函数实现接口。最常见的用法是用于 HTTP 处理程序。HTTP 处理程序处理 HTTP 服务器请求。它由一个接口定义:

type Handler interface {
    ServeHTTP(http.ResponseWriter, *http.Request)
}

通过使用类型转换到 http.HandlerFunc,任何具有 func(http.ResponseWriter,*http.Request) 签名的函数都可以用作 http.Handler

type HandlerFunc func(http.ResponseWriter, *http.Request)

func (f HandlerFunc) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    f(w, r)
}

这使您可以使用函数、方法或闭包实现 HTTP 处理程序,使用与用于满足 http.Handler 接口的其他类型相同的代码路径。

Go 中的函数是一流的概念,因此它们经常作为参数传递给函数。同时,Go 鼓励小接口,并且仅有一个方法的接口可以轻松替代函数类型的参数。问题在于:在何时应该让您的函数或方法指定函数类型的输入参数,而何时应该使用接口?

如果您的单一函数可能依赖于许多其他函数或未在其输入参数中指定的其他状态,请使用接口参数并定义函数类型以将函数桥接到接口。这就是在http包中所做的;Handler很可能只是需要配置的一系列调用的入口点。但是,如果它是一个简单的函数(例如在sort.Slice中使用的函数),那么函数类型的参数是一个很好的选择。

隐式接口使依赖注入更加容易

在前言中,我将软件编写比作建造桥梁。软件与物理基础设施的共同点之一是,任何被多人长时间使用的程序都需要维护。虽然程序不会磨损,但开发人员经常被要求更新程序以修复错误、添加功能并在新环境中运行。因此,您应该以使其更易于修改的方式来构造程序。软件工程师讨论解耦代码,以便程序的不同部分的更改互不影响。

为了简化解耦,已经开发出了一种称为依赖注入的技术。依赖注入是指您的代码应明确指定执行任务所需的功能。它比您想象的要古老得多;1996 年,Robert Martin 写了一篇名为《依赖倒置原则》的文章。

Go 隐式接口的一个令人惊讶的好处是,它们使依赖注入成为解耦代码的一种优秀方式。尽管其他语言的开发人员通常使用大型复杂的框架来注入其依赖关系,但事实是,在 Go 中很容易实现依赖注入,而无需任何额外的库。让我们通过一个简单的示例来看看如何使用隐式接口通过依赖注入来组合应用程序。

为了更好地理解这个概念,并了解如何在 Go 中实现依赖注入,您将构建一个非常简单的 Web 应用程序。(我将在《服务器》中更详细地讨论 Go 的内置 HTTP 服务器支持;可以将其视为预览。)首先编写一个小的实用函数,一个记录器:

func LogOutput(message string) {
    fmt.Println(message)
}

应用程序还需要的另一件事是数据存储。让我们创建一个简单的数据存储:

type SimpleDataStore struct {
    userData map[string]string
}

func (sds SimpleDataStore) UserNameForID(userID string) (string, bool) {
    name, ok := sds.userData[userID]
    return name, ok
}

还要定义一个工厂函数来创建SimpleDataStore的实例:

func NewSimpleDataStore() SimpleDataStore {
    return SimpleDataStore{
        userData: map[string]string{
            "1": "Fred",
            "2": "Mary",
            "3": "Pat",
        },
    }
}

接下来,编写一些业务逻辑来查找用户并打招呼或告别。您的业务逻辑需要一些数据来进行工作,因此它需要一个数据存储。您还希望您的业务逻辑在调用时记录日志,因此它依赖于一个记录器。但是,您不希望强制它依赖于LogOutputSimpleDataStore,因为以后您可能希望使用不同的记录器或数据存储。您的业务逻辑需要的是描述其依赖关系的接口:

type DataStore interface {
    UserNameForID(userID string) (string, bool)
}

type Logger interface {
    Log(message string)
}

要使你的LogOutput函数符合此接口,你需要定义一个带有方法的函数类型:

type LoggerAdapter func(message string)

func (lg LoggerAdapter) Log(message string) {
    lg(message)
}

令人惊讶的是,LoggerAdapterSimpleDataStore恰好符合你的业务逻辑所需的接口,但是这两种类型都不知道自己符合这些接口。

现在你已经定义了依赖项,让我们来看看你的业务逻辑的实现:

type SimpleLogic struct {
    l  Logger
    ds DataStore
}

func (sl SimpleLogic) SayHello(userID string) (string, error) {
    sl.l.Log("in SayHello for " + userID)
    name, ok := sl.ds.UserNameForID(userID)
    if !ok {
        return "", errors.New("unknown user")
    }
    return "Hello, " + name, nil
}

func (sl SimpleLogic) SayGoodbye(userID string) (string, error) {
    sl.l.Log("in SayGoodbye for " + userID)
    name, ok := sl.ds.UserNameForID(userID)
    if !ok {
        return "", errors.New("unknown user")
    }
    return "Goodbye, " + name, nil
}

你有一个带有两个字段的struct:一个是Logger,另一个是DataStoreSimpleLogic中没有提到具体类型,因此对它们没有依赖。如果以后从完全不同的提供者中交换新的实现,那就没有问题,因为提供者与你的接口没有关系。这与像 Java 这样的显式接口非常不同。尽管 Java 使用接口来解耦实现与接口,但显式接口将客户端和提供者绑定在一起。这使得在 Java(以及其他具有显式接口的语言)中替换依赖关系比在 Go 中更困难。

当你想要一个SimpleLogic实例时,你调用一个工厂函数,传递接口并返回一个结构体:

func NewSimpleLogic(l Logger, ds DataStore) SimpleLogic {
    return SimpleLogic{
        l:    l,
        ds: ds,
    }
}
注意

SimpleLogic中的字段是未导出的。这意味着它们只能被与SimpleLogic在同一包内的代码访问。虽然在 Go 语言中不能强制实现不可变性,但限制可以访问这些字段的代码可以减少其意外修改的可能性。我将在第十章中详细讨论导出和未导出标识符。

现在你要到达你的 API。你将只有一个端点/hello,它向提供用户 ID 的人打招呼。(请在你的真实应用程序中不要使用查询参数作为认证信息;这只是一个快速示例。)你的控制器需要业务逻辑来打招呼,所以你为此定义了一个接口:

type Logic interface {
    SayHello(userID string) (string, error)
}

这个方法在你的SimpleLogic结构体上是可用的,但是再次强调,具体类型并不知道这个接口。此外,SimpleLogic上的另一个方法SayGoodbye不在接口中,因为你的控制器不关心它。接口是由客户端代码拥有的,因此它的方法集是根据客户端代码的需求定制的:

type Controller struct {
    l     Logger
    logic Logic
}

func (c Controller) SayHello(w http.ResponseWriter, r *http.Request) {
    c.l.Log("In SayHello")
    userID := r.URL.Query().Get("user_id")
    message, err := c.logic.SayHello(userID)
    if err != nil {
        w.WriteHeader(http.StatusBadRequest)
        w.Write([]byte(err.Error()))
        return
    }
    w.Write([]byte(message))
}

就像你的其他类型有工厂函数一样,让我们为Controller编写一个工厂函数:

func NewController(l Logger, logic Logic) Controller {
    return Controller{
        l:     l,
        logic: logic,
    }
}

同样,你接受接口并返回结构体。

最后,在main函数中连接所有组件并启动服务器:

func main() {
    l := LoggerAdapter(LogOutput)
    ds := NewSimpleDataStore()
    logic := NewSimpleLogic(l, ds)
    c := NewController(l, logic)
    http.HandleFunc("/hello", c.SayHello)
    http.ListenAndServe(":8080", nil)
}

你可以在第七章存储库sample_code/dependency_injection目录中找到此应用程序的完整代码。

main函数是唯一知道所有具体类型的代码部分。如果你想要切换不同的实现,这是唯一需要改变的地方。通过依赖注入来外部化依赖项意味着您限制了随时间演变代码所需的更改。

依赖注入也是简化测试的一个很好的模式。这并不令人惊讶,因为编写单元测试实际上是在不同环境中重复使用你的代码,这个环境限制输入和输出以验证功能。例如,你可以通过注入一个能捕获日志输出并符合Logger接口的类型来验证测试中的日志输出。我会在第十五章详细讨论这一点。

注意

http.HandleFunc("/hello", c.SayHello)展示了我之前讨论的两点。

首先,你将SayHello方法视为一个函数。

其次,http.HandleFunc函数接收一个函数,并将其转换为http.HandlerFunc函数类型,它声明了一个方法以满足http.Handler接口,这个类型用于表示 Go 中的请求处理程序。你将一个类型的方法转换成另一个类型的方法,这非常巧妙。

Wire

如果你觉得手写依赖注入代码太麻烦,可以使用Wire,这是由 Google 编写的依赖注入辅助工具。Wire 利用代码生成来自动创建你在main中手动编写的具体类型声明。

Go 不是特别面向对象(这很好)

现在你已经看过 Go 中类型的惯用用法,你会发现将 Go 归类为某种特定风格的语言是困难的。它显然不是严格的过程式语言。同时,Go 缺乏方法重写、继承或对象的特性,这也使它不是特别面向对象的语言。Go 有函数类型和闭包,但它也不是一种函数式语言。如果你试图强行将 Go 归入其中一个类别,结果会是非惯用的代码。

如果要给 Go 的风格贴上标签,最合适的词是实用。它从多个地方借鉴概念,主要目标是创建一种简单、可读性强且适合大团队长期维护的语言。

练习

在这些练习中,你将构建一个程序,利用你所学的关于类型、方法和接口的知识。答案可以在第七章仓库exercise_solutions目录中找到。

  1. 你被要求管理一个篮球联赛,并准备编写一个程序来帮助你。定义两种类型。第一种称为Team,有一个用于球队名称和一个用于球员名称的字段。第二种类型称为League,有一个用于联赛中的球队的Teams字段和一个用于映射球队名称到胜场数的Wins字段。

  2. League添加两个方法。第一个方法名为MatchResult,接受四个参数:第一支队伍的名称,其在比赛中的得分,第二支队伍的名称,以及其在比赛中的得分。此方法应更新League中的Wins字段。向League添加第二个方法名为Ranking,返回按胜场排序的球队名称切片。在你的程序中构建数据结构,并从main函数中调用这些方法,使用一些示例数据。

  3. 定义一个名为Ranker的接口,它有一个名为Ranking的方法,返回一个字符串切片。编写一个名为RankPrinter的函数,有两个参数,第一个参数类型为Ranker,第二个参数类型为io.Writer。使用io.WriteString函数将Ranker返回的值写入io.Writer,每个结果之间用换行分隔。从main函数中调用此函数。

结束语

在本章中,您学习了有关类型、方法、接口及其最佳实践的知识。在下一章中,您将学习泛型,它通过允许您重用逻辑和使用不同类型的自定义容器来提高可读性和可维护性。

¹ 针对观众中的计算机科学家,我意识到子类型化并非继承。然而,大多数编程语言使用继承来实现子类型化,因此这些定义在流行使用中经常混淆。

第八章:泛型

“不要重复你自己”是常见的软件工程建议。重用数据结构或函数比重新创建它更好,因为很难保持重复代码之间的代码变更同步。在像 Go 这样的强类型语言中,必须在编译时知道每个函数参数和每个结构字段的类型。这种严格性使编译器能够帮助验证您的代码是否正确,但有时当您希望重用函数中的逻辑或结构中的字段时,您可能需要不同类型的支持。通过类型参数(俗称泛型),Go 提供了这种功能。在本章中,您将了解为什么人们需要泛型,Go 的泛型实现可以做什么,泛型不能做什么以及如何正确使用它们。

泛型减少重复代码并增加类型安全性。

Go 是一种静态类型语言,这意味着在编译代码时会检查变量和参数的类型。内置类型(映射、切片、通道)和函数(如lencapmake)能够接受和返回不同具体类型的值,但是直到 Go 1.18 之前,用户定义的 Go 类型和函数不能。

如果您习惯于动态类型语言,其中类型直到运行代码时才会被评估,您可能不理解泛型的重要性,也可能对它们的含义有些不清楚。将它们视为“类型参数”会有所帮助。到目前为止,在本书中,您已经看到了接受参数的函数,这些参数的值在调用函数时指定。在“多返回值”中,函数divAndRemainder有两个int参数并返回两个int值:

func divAndRemainder(num, denom int) (int, int, error) {
    if denom == 0 {
        return 0, 0, errors.New("cannot divide by zero")
    }
    return num / denom, num % denom, nil
}

类似地,通过在声明结构体时为字段指定类型来创建结构体。在这里,Node有一个类型为int的字段和另一个类型为*Node的字段:

type Node struct {
    val  int
    next *Node
}

然而,在某些情况下,编写函数或结构,留下参数或字段的具体类型未指定,也是有用的。

泛型类型的优势很容易理解。在“为 nil 实例编写您的方法”中,您看到了一个int的二叉树。如果您想要一个字符串或 float64 的二叉树并且需要类型安全,您有几个选择。第一种可能性是为每种类型编写自定义树,但是这样大量重复的代码冗长且容易出错。

没有泛型,避免重复代码的唯一方法将是修改您的树实现,使其使用接口来指定如何排序值。该接口将如下所示:

type Orderable interface {
    // Order returns:
    // a value < 0 when the Orderable is less than the supplied value,
    // a value > 0 when the Orderable is greater than the supplied value,
    // and 0 when the two values are equal.
    Order(any) int
}

现在您有了Orderable,可以修改您的Tree实现以支持它:

type Tree struct {
    val         Orderable
    left, right *Tree
}

func (t *Tree) Insert(val Orderable) *Tree {
    if t == nil {
        return &Tree{val: val}
    }

    switch comp := val.Order(t.val); {
    case comp < 0:
        t.left = t.left.Insert(val)
    case comp > 0:
        t.right = t.right.Insert(val)
    }
    return t
}

有了OrderableInt类型,您可以插入int值:

type OrderableInt int

func (oi OrderableInt) Order(val any) int {
    return int(oi - val.(OrderableInt))
}

func main() {
    var it *Tree
    it = it.Insert(OrderableInt(5))
    it = it.Insert(OrderableInt(3))
    // etc...
}

虽然此代码可以正确工作,但它不允许编译器验证插入到数据结构中的值是否都相同。如果您还有一个OrderableString类型:

type OrderableString string

func (os OrderableString) Order(val any) int {
    return strings.Compare(string(os), val.(string))
}

以下代码编译通过:

var it *Tree
it = it.Insert(OrderableInt(5))
it = it.Insert(OrderableString("nope"))

函数 Order 使用 any 来表示传入的值。这实际上绕过了 Go 的主要优势之一:检查编译时类型安全性。当你编译试图将 OrderableString 插入已包含 OrderableIntTree 的代码时,编译器接受该代码。然而,程序在运行时会出现恐慌:

panic: interface conversion: interface {} is main.OrderableInt, not string

您可以在第八章存储库sample_code/non_generic_tree 目录中尝试此代码。

有了泛型,可以为多个类型实现数据结构并在编译时检测不兼容的数据。稍后您将看到如何正确使用它们。

没有泛型的数据结构虽然不方便,但真正的限制在于编写函数。Go 标准库中的几个实现决策是因为泛型最初不是该语言的一部分。例如,而不是编写多个处理不同数值类型的函数,Go 使用 float64 参数实现了 math.Maxmath.Minmath.Mod 函数,这些参数的范围足以准确表示几乎所有其他数值类型(除了值大于 2⁵³ – 1 或小于 –2⁵³ – 1 的 intint64uint)。

没有泛型,一些其他事情是不可能的。您无法创建一个由接口指定的变量的新实例,也无法指定同一接口类型的两个参数也是相同的具体类型。没有泛型,您无法编写一个处理任何类型切片的函数,而不用反射并放弃一些性能以及编译时类型安全性(这就是 sort.Slice 的工作原理)。这意味着在泛型引入 Go 之前,操作切片的函数(如 mapreducefilter)将被重复为每种切片类型实现。虽然简单的算法足够简单,但许多(如果不是大多数)软件工程师发现简单复制代码很烦人,只是因为编译器不足够智能而无法自动完成。

引入 Go 中的泛型

自从 Go 首次发布以来,人们一直呼吁将泛型添加到该语言中。Go 的开发领导 Russ Cox 在 2009 年写了一篇博客文章,解释了为什么最初没有包含泛型。Go 强调快速编译器、可读性代码和良好执行时间,但他们所知的所有泛型实现都无法同时满足这三个要求。经过十年的研究,Go 团队提出了一种可行的方法,详见类型参数提案

通过查看一个栈来了解泛型在 Go 中的工作原理。如果你没有计算机科学背景,栈是一种数据类型,其中的值按照 LIFO 顺序添加和删除。这就像一堆等待被清洗的盘子;最先放置的在底部,只有通过处理后来添加的才能得到它们。让我们看看如何使用泛型来制作一个栈:

type Stack[T any] struct {
    vals []T
}

func (s *Stack[T]) Push(val T) {
    s.vals = append(s.vals, val)
}

func (s *Stack[T]) Pop() (T, bool) {
    if len(s.vals) == 0 {
        var zero T
        return zero, false
    }
    top := s.vals[len(s.vals)-1]
    s.vals = s.vals[:len(s.vals)-1]
    return top, true
}

有几件事需要注意。首先,在类型声明后面你有[T any]。类型参数信息放在括号内,有两部分。第一部分是类型参数的名称。你可以为类型参数选择任何名称,但使用大写字母是惯例。第二部分是类型约束,它使用 Go 接口指定哪些类型是有效的。如果任何类型都可以使用,可以使用宇宙块标识符any来指定,你首次在“空接口不表达任何内容”中看到它。在Stack声明内部,你声明vals的类型为[]T

接下来,看一下方法声明。就像你在你的vals声明中使用了T一样,在这里也是一样。你还需要在接收器部分使用Stack[T]而不是Stack来引用类型。

最后,泛型使得零值处理有些有趣。在Pop中,你不能简单地返回nil,因为对于值类型(如int)来说,这不是有效的值。获取泛型的零值的最简单方法是使用var声明一个变量并返回它,因为根据定义,var始终将其变量初始化为零值,如果未分配其他值。

使用泛型类型类似于使用非泛型类型:

func main() {
    var intStack Stack[int]
    intStack.Push(10)
    intStack.Push(20)
    intStack.Push(30)
    v, ok := intStack.Pop()
    fmt.Println(v, ok)
}

唯一的区别是,在声明变量时,你包括要与你的Stack一起使用的类型——在本例中是int。如果尝试将字符串推送到栈上,编译器将捕获它。添加以下行:

intStack.Push("nope")

会产生编译错误:

cannot use "nope" (untyped string constant) as int value
  in argument to intStack.Push

你可以在Go Playground上或第八章代码库中的sample_code/stack目录中尝试这个泛型栈。

向你的栈添加另一个方法来告诉你栈是否包含一个值:

func (s Stack[T]) Contains(val T) bool {
    for _, v := range s.vals {
        if v == val {
            return true
        }
    }
    return false
}

不幸的是,这不会编译。它会产生一个错误:

invalid operation: v == val (type parameter T is not comparable with ==)

就像interface{}什么也不说一样,any也一样。你只能存储any类型的值并检索它们。要使用==,你需要不同的类型。由于几乎所有 Go 类型都可以使用==!=进行比较,所以在宇宙块中定义了一个新的内置接口称为comparable。如果将Stack的定义更改为使用comparable

type Stack[T comparable] struct {
    vals []T
}

然后可以使用你的新方法:

func main() {
    var s Stack[int]
    s.Push(10)
    s.Push(20)
    s.Push(30)
    fmt.Println(s.Contains(10))
    fmt.Println(s.Contains(5))
}

这将输出以下内容:

true
false

你可以在Go Playground上或第八章代码库中的sample_code/comparable_stack目录中尝试这个更新的栈。

稍后,您将看到如何制作一个通用二叉树。首先,我将介绍一些额外的概念:通用函数,通用函数如何与接口配合工作,以及类型术语

通用函数抽象算法

正如我所示,您也可以编写通用函数。我之前提到没有泛型使得编写适用于所有类型的映射、归约和过滤实现变得困难。泛型使这变得容易。以下是来自类型参数提案的实现:

// Map turns a []T1 to a []T2 using a mapping function.
// This function has two type parameters, T1 and T2.
// This works with slices of any type.
func MapT1, T2 any T2) []T2 {
    r := make([]T2, len(s))
    for i, v := range s {
        r[i] = f(v)
    }
    return r
}

// Reduce reduces a []T1 to a single value using a reduction function.
func ReduceT1, T2 any T2) T2 {
    r := initializer
    for _, v := range s {
        r = f(r, v)
    }
    return r
}

// Filter filters values from a slice using a filter function.
// It returns a new slice with only the elements of s
// for which f returned true.
func FilterT any bool) []T {
    var r []T
    for _, v := range s {
        if f(v) {
            r = append(r, v)
        }
    }
    return r
}

函数在变量参数之前将其类型参数放在函数名称后。MapReduce有两个类型参数,都是any类型,而Filter只有一个。当您运行代码时:

words := []string{"One", "Potato", "Two", "Potato"}
filtered := Filter(words, func(s string) bool {
    return s != "Potato"
})
fmt.Println(filtered)
lengths := Map(filtered, func(s string) int {
    return len(s)
})
fmt.Println(lengths)
sum := Reduce(lengths, 0, func(acc int, val int) int {
    return acc + val
})
fmt.Println(sum)

您将得到输出:

[One Two]
[3 3]
6

The Go Playground第八章存储库中的sample_code/map_filter_reduce目录上自行尝试。

泛型和接口

您可以使用任何接口作为类型约束,不仅仅是anycomparable。例如,假设您想制作一个类型,该类型持有任何两个相同类型的值,只要该类型实现了fmt.Stringer。泛型使得在编译时强制执行这一点成为可能:

type Pair[T fmt.Stringer] struct {
    Val1 T
    Val2 T
}

您还可以创建具有类型参数的接口。例如,这是一个带有方法的接口,该方法与指定类型的值进行比较并返回float64。它还嵌入了fmt.Stringer

type Differ[T any] interface {
    fmt.Stringer
    Diff(T) float64
}

你将使用这两种类型来创建比较函数。该函数接受两个Pair实例,这些实例具有Differ类型的字段,并返回较接近数值的Pair

func FindCloser[T Differ[T]](pair1, pair2 Pair[T]) Pair[T] {
    d1 := pair1.Val1.Diff(pair1.Val2)
    d2 := pair2.Val1.Diff(pair2.Val2)
    if d1 < d2 {
        return pair1
    }
    return pair2
}

注意,FindCloser接受具有符合Differ接口的字段的Pair实例。Pair要求其字段都是相同类型,并且该类型满足fmt.Stringer接口;这使得该函数更具选择性。如果Pair实例中的字段不满足Differ,编译器将阻止您将其与FindCloser一起使用。

现在定义满足Differ接口的一对类型:

type Point2D struct {
    X, Y int
}

func (p2 Point2D) String() string {
    return fmt.Sprintf("{%d,%d}", p2.X, p2.Y)
}

func (p2 Point2D) Diff(from Point2D) float64 {
    x := p2.X - from.X
    y := p2.Y - from.Y
    return math.Sqrt(float64(x*x) + float64(y*y))
}

type Point3D struct {
    X, Y, Z int
}

func (p3 Point3D) String() string {
    return fmt.Sprintf("{%d,%d,%d}", p3.X, p3.Y, p3.Z)
}

func (p3 Point3D) Diff(from Point3D) float64 {
    x := p3.X - from.X
    y := p3.Y - from.Y
    z := p3.Z - from.Z
    return math.Sqrt(float64(x*x) + float64(y*y) + float64(z*z))
}

这是使用此代码的样子:

func main() {
    pair2Da := Pair[Point2D]{Point2D{1, 1}, Point2D{5, 5}}
    pair2Db := Pair[Point2D]{Point2D{10, 10}, Point2D{15, 5}}
    closer := FindCloser(pair2Da, pair2Db)
    fmt.Println(closer)

    pair3Da := Pair[Point3D]{Point3D{1, 1, 10}, Point3D{5, 5, 0}}
    pair3Db := Pair[Point3D]{Point3D{10, 10, 10}, Point3D{11, 5, 0}}
    closer2 := FindCloser(pair3Da, pair3Db)
    fmt.Println(closer2)
}

The Go Playground第八章存储库中的sample_code/generic_interface目录上自行运行它。

使用类型术语指定运算符

泛型还需要表示运算符。divAndRemainder函数在int上运行良好,但在其他整数类型上使用需要类型转换,而uint允许您表示int无法处理的值。如果要编写divAndRemainder的通用版本,您需要一种指定可以使用/%的方式。Go 泛型通过类型元素实现这一点,该元素由一个或多个类型术语组成在接口内:

type Integer interface {
    int | int8 | int16 | int32 | int64 |
        uint | uint8 | uint16 | uint32 | uint64 | uintptr
}

在 “嵌入和接口” 中,您学习了如何嵌入接口以指示包含接口的方法集包括嵌入接口的方法。类型元素指定可以分配给类型参数的类型和支持的操作符。它们列出了由 | 分隔的具体类型。允许的操作符是所有列出类型都有效的那些操作符。模数 (%) 操作符仅适用于整数,因此我们列出了所有整数类型(可以省略 byterune,因为它们分别是 uint8int32 的类型别名)。

请注意,具有类型元素的接口仅作为类型约束有效。将它们用作变量、字段、返回值或参数的类型会导致编译时错误。

现在您可以编写您自己的 divAndRemainder 的通用版本,并将其与内置的 uint 类型(或 Integer 中列出的任何其他类型)一起使用。

func divAndRemainderT Integer (T, T, error) {
    if denom == 0 {
        return 0, 0, errors.New("cannot divide by zero")
    }
    return num / denom, num % denom, nil
}

func main() {
    var a uint = 18_446_744_073_709_551_615
    var b uint = 9_223_372_036_854_775_808
    fmt.Println(divAndRemainder(a, b))
}

默认情况下,类型术语精确匹配。如果您尝试使用 divAndRemainder 与其底层类型为 Integer 中列出的类型之一的用户定义类型,将会收到错误。这段代码:

type MyInt int
var myA MyInt = 10
var myB MyInt = 20
fmt.Println(divAndRemainder(myA, myB))

会产生以下错误:

MyInt does not satisfy Integer (possibly missing ~ for int in Integer)

错误文本提供了解决此问题的提示。如果您希望一个类型术语对任何具有该类型术语作为其底层类型的类型有效,可以在类型术语前加上 ~。这将修改 Integer 的定义如下:

type Integer interface {
    ~int | ~int8 | ~int16 | ~int32 | ~int64 |
        ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr
}

您可以在 The Go Playground 上或在 第八章存储库sample_code/type_terms 目录中查看 divAndRemainder 函数的通用版本。

添加类型术语使您可以定义一种类型,使您可以编写通用比较函数:

type Ordered interface {
    ~int | ~int8 | ~int16 | ~int32 | ~int64 |
        ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr |
        ~float32 | ~float64 |
        ~string
}

Ordered 接口列出了所有支持 ==, !=, <, >, <=, 和 >= 操作符的类型。有一种方法可以指定一个变量表示一个可排序类型是如此有用,因此 Go 1.21 添加了 cmp,该包定义了这个 Ordered 接口。该包还定义了两个比较函数。Compare 函数返回 -1、0 或 1,具体取决于其第一个参数是否小于、等于或大于其第二个参数,而 Less 函数在其第一个参数小于其第二个参数时返回 true

在用于类型参数的接口中,同时具有类型元素和方法元素是合法的。例如,您可以指定一个类型必须具有 int 的底层类型和一个 String() string 方法:

type PrintableInt interface {
    ~int
    String() string
}

请注意,Go 语言允许您声明一个类型参数接口,但实际上无法实例化。如果在PrintableInt中使用int而不是~int,则没有任何有效的类型可以满足它,因为int没有方法。这可能看起来很糟糕,但编译器仍会帮助您。如果您声明了一个带有不可能类型参数的类型或函数,任何尝试使用它都会导致编译器错误。假设您声明了这些类型:

type ImpossiblePrintableInt interface {
    int
    String() string
}

type ImpossibleStruct[T ImpossiblePrintableInt] struct {
    val T
}

type MyInt int

func (mi MyInt) String() string {
    return fmt.Sprint(mi)
}

即使您无法实例化ImpossibleStruct,编译器对所有这些声明也没有任何问题。但是,一旦尝试使用ImpossibleStruct,编译器就会抱怨。此代码:

s := ImpossibleStruct[int]{10}
s2 := ImpossibleStruct[MyInt]{10}

会产生以下编译时错误:

int does not implement ImpossiblePrintableInt (missing String method)
MyInt does not implement ImpossiblePrintableInt (possibly missing ~ for
int in constraint ImpossiblePrintableInt)

Go Playground第八章存储库sample_code/impossible目录中尝试这个。

除了内置的基本类型之外,类型项还可以是切片、映射、数组、通道、结构体,甚至函数。当您想要确保类型参数具有特定的基础类型和一个或多个方法时,它们非常有用。

类型推断和泛型

就像在使用:=运算符时 Go 支持类型推断一样,在调用泛型函数时也支持类型推断以简化调用。您可以在之前对MapFilterReduce的调用中看到这一点。在某些情况下,类型推断是不可能的(例如,当类型参数仅用作返回值时)。发生这种情况时,必须指定所有类型参数。下面是一个稍微愚蠢的代码片段,演示了类型推断不起作用的情况:

type Integer interface {
    int | int8 | int16 | int32 | int64 | uint | uint8 | uint16 | uint32 | uint64
}

func ConvertT1, T2 Integer T2 {
    return T2(in)
}

func main() {
    var a int = 10
    b := Convertint, int64 // can't infer the return type
    fmt.Println(b)
}

Go Playground第八章存储库sample_code/type_inference目录中尝试它。

类型元素限制常量

类型元素还指定了可以分配给泛型类型变量的常量。像运算符一样,这些常量需要对类型元素中的所有类型项有效。在Ordered中没有可以分配给每个列出的类型的常量,因此您不能将常量分配给该泛型类型的变量。如果使用Integer接口,则以下代码不会编译,因为您不能将值 1,000 分配给 8 位整数:

// INVALID!
func PlusOneThousandT Integer T {
    return in + 1_000
}

然而,这是有效的:

// VALID
func PlusOneHundredT Integer T {
    return in + 100
}

将泛型函数与泛型数据结构结合起来

让我们返回二叉树示例,并看看如何将您学到的所有内容结合起来,使得单个树适用于任何具体类型。

关键是要意识到,您的树需要的是一个单一的泛型函数,它比较两个值并告诉您它们的顺序:

type OrderableFunc [T any] func(t1, t2 T) int

现在您有了OrderableFunc,可以稍微修改树的实现。首先,您将其拆分为两种类型,TreeNode

type Tree[T any] struct {
    f    OrderableFunc[T]
    root *Node[T]
}

type Node[T any] struct {
    val         T
    left, right *Node[T]
}

使用构造函数构建一个新的Tree

func NewTreeT any *Tree[T] {
    return &Tree[T]{
        f: f,
    }
}

Tree的方法非常简单,因为它们只是调用Node来完成所有真正的工作。

func (t *Tree[T]) Add(v T) {
    t.root = t.root.Add(t.f, v)
}

func (t *Tree[T]) Contains(v T) bool {
    return t.root.Contains(t.f, v)
}

Node上的AddContains方法与之前看到的非常相似。唯一的区别在于,你正在使用的函数用于排序元素的方式是通过参数传递的:

func (n *Node[T]) Add(f OrderableFunc[T], v T) *Node[T] {
    if n == nil {
        return &Node[T]{val: v}
    }
    switch r := f(v, n.val); {
    case r <= -1:
        n.left = n.left.Add(f, v)
    case r >= 1:
        n.right = n.right.Add(f, v)
    }
    return n
}

func (n *Node[T]) Contains(f OrderableFunc[T], v T) bool {
    if n == nil {
        return false
    }
    switch r := f(v, n.val); {
    case r <= -1:
        return n.left.Contains(f, v)
    case r >= 1:
        return n.right.Contains(f, v)
    }
    return true
}

现在你需要一个函数来匹配OrderedFunc的定义。幸运的是,你已经见过一个:cmp包中的Compare。当你在Tree中使用它时,看起来像这样:

t1 := NewTree(cmp.Compare[int])
t1.Add(10)
t1.Add(30)
t1.Add(15)
fmt.Println(t1.Contains(15))
fmt.Println(t1.Contains(40))

对于结构体,你有两个选项。你可以写一个函数:

type Person struct {
    Name string
    Age int
}

func OrderPeople(p1, p2 Person) int {
    out := cmp.Compare(p1.Name, p2.Name)
    if out == 0 {
        out = cmp.Compare(p1.Age, p2.Age)
    }
    return out
}

然后,在创建树时,你可以将该函数传递进去:

t2 := NewTree(OrderPeople)
t2.Add(Person{"Bob", 30})
t2.Add(Person{"Maria", 35})
t2.Add(Person{"Bob", 50})
fmt.Println(t2.Contains(Person{"Bob", 30}))
fmt.Println(t2.Contains(Person{"Fred", 25}))

你也可以不使用函数,而是为NewTree提供一个方法。正如我在“方法也是函数”中所说的,你可以使用方法表达式将方法视为函数。我们在这里就这样做。首先,编写这个方法:

func (p Person) Order(other Person) int {
    out := cmp.Compare(p.Name, other.Name)
    if out == 0 {
        out = cmp.Compare(p.Age, other.Age)
    }
    return out
}

然后使用它:

t3 := NewTree(Person.Order)
t3.Add(Person{"Bob", 30})
t3.Add(Person{"Maria", 35})
t3.Add(Person{"Bob", 50})
fmt.Println(t3.Contains(Person{"Bob", 30}))
fmt.Println(t3.Contains(Person{"Fred", 25}))

你可以在The Go Playground找到此树的代码,或者在第八章仓库sample_code/generic_tree目录中找到。

更多关于可比较性的内容

正如你在“接口可比较”中看到的那样,接口是 Go 语言中可比较的类型之一。这意味着在使用==!=操作符时需要小心,如果接口的底层类型不可比较,你的代码会在运行时抛出异常。

当使用泛型与comparable接口时,这个坑仍然存在。假设你已经定义了一个接口和一些实现:

type Thinger interface {
    Thing()
}

type ThingerInt int

func (t ThingerInt) Thing() {
    fmt.Println("ThingInt:", t)
}

type ThingerSlice []int

func (t ThingerSlice) Thing() {
    fmt.Println("ThingSlice:", t)
}

你还定义了一个通用函数,只接受comparable的值:

func ComparerT comparable {
    if t1 == t2 {
        fmt.Println("equal!")
    }
}

intThingerInt类型的变量调用此函数是合法的。

var a int = 10
var b int = 10
Comparer(a, b) // prints true

var a2 ThingerInt = 20
var b2 ThingerInt = 20
Comparer(a2, b2) // prints true

编译器不允许你使用ThingerSlice(或[]int)类型的变量调用此函数:

var a3 ThingerSlice = []int{1, 2, 3}
var b3 ThingerSlice = []int{1, 2, 3}
Comparer(a3, b3) // compile fails: "ThingerSlice does not satisfy comparable"

然而,用Thinger类型的变量调用它是完全合法的。如果使用ThingerInt,代码编译并按预期工作:

var a4 Thinger = a2
var b4 Thinger = b2
Comparer(a4, b4) // prints true

但是你也可以将ThingerSlice分配给Thinger类型的变量。这就是问题所在:

a4 = a3
b4 = b3
Comparer(a4, b4) // compiles, panics at runtime

编译器不会阻止你构建此代码,但如果运行它,你的程序会因为panic: runtime error: comparing uncomparable type main.ThingerSlice而崩溃(详见“panic 和 recover”获取更多信息)。你可以在The Go Playground或者第八章仓库sample_code/more_comparable目录中自己尝试这段代码。

想要了解有关可比较类型与泛型交互以及为何做出此设计决策的更多技术细节,请阅读 Go 团队成员 Robert Griesemer 的博文“All Your Comparable Types”

遗漏的事情

Go 保持一种小而集中的语言风格,而 Go 的泛型实现并未包含许多其他语言泛型实现中存在的功能。本节描述了 Go 泛型初始实现中缺少的一些功能。

虽然你可以构建一个适用于用户定义和内置类型的单一树,像 Python、Ruby 和 C++ 这样的语言通过不同的方式解决了这个问题。它们包括运算符重载,允许用户定义的类型指定操作符的实现。Go 将不会添加这个特性。这意味着你不能使用range来迭代用户定义的容器类型,也不能使用[]来对它们进行索引。

不使用运算符重载有很多理由。首先,Go 有惊人数量的运算符。其次,Go 也没有函数或方法重载,你需要一种方式来为不同的类型指定不同的操作符功能。此外,运算符重载可能导致代码难以理解,因为开发人员会为符号创造聪明的含义(在 C++ 中,<<对某些类型意味着“左移位”,对其他类型意味着“将右侧的值写入左侧的值”)。这些是 Go 试图避免的可读性问题。

另一个有用的特性被初始 Go 泛型实现忽略,即方法上的额外类型参数。回顾Map/Reduce/Filter函数,你可能会认为它们作为方法会很有用,就像这样:

type functionalSlice[T any] []T

// THIS DOES NOT WORK
func (fs functionalSlice[T]) MapE any E) functionalSlice[E] {
    out := make(functionalSlice[E], len(fs))
    for i, v := range fs {
        out[i] = f(v)
    }
    return out
}

// THIS DOES NOT WORK
func (fs functionalSlice[T]) ReduceE any E) E {
    out := start
    for _, v := range fs {
        out = f(out, v)
    }
    return out
}

你可以像这样使用它:

var numStrings = functionalSlice[string]{"1", "2", "3"}
sum := numStrings.Map(func(s string) int {
    v, _ := strconv.Atoi(s)
    return v
}).Reduce(0, func(acc int, cur int) int {
    return acc + cur
})

不幸的是,对于函数式编程的爱好者来说,这并不适用。与其将方法调用链在一起,你需要嵌套函数调用或者采用更可读的方法,逐个调用函数并将中间值赋给变量。类型参数提案详细说明了排除参数化方法的原因。

Go 也没有可变类型参数。如 “可变输入参数和切片” 中所讨论的那样,要实现一个接受不同数量参数的函数,你需要指定最后一个参数类型以 ... 开头。例如,没有办法为这些可变参数指定某种类型模式,比如交替的stringint。所有可变变量必须匹配一个声明的单一类型,可以是泛型或非泛型的。

Go 泛型中省略的其他特性更为奥秘。包括以下内容:

特化

一个函数或方法可以在泛型版本之外重载为一个或多个特定于类型的版本。由于 Go 没有重载,这个特性不在考虑之列。

柯里化

允许你根据另一个泛型函数或类型部分实例化一个函数或类型,指定一些类型参数。

元编程

允许你指定在编译时运行的代码,以生成在运行时运行的代码。

Go 的惯用方式和泛型

明显地,向 Go 中添加泛型改变了某些使用 Go 习惯的建议。使用 float64 来表示任何数值类型将不再适用。在数据结构或函数参数中,应该使用 any 而不是 interface{} 表示未指定类型。可以使用单个函数处理不同的切片类型。但不必立即将所有旧代码转换为使用类型参数。随着新的设计模式的发明和完善,你的旧代码仍将正常运行。

目前来看,判断泛型对性能的长期影响为时尚早。截至目前,编译时间没有影响。Go 1.18 编译器比以前的版本慢,但 Go 1.20 的编译器解决了这个问题。

也有一些关于泛型运行时影响的研究。Vicent Marti 写了一篇详细博文,探讨了泛型导致代码变慢的案例和解释其实现细节。相反,Eli Bendersky 写了一篇博文,展示了泛型使排序算法更快的情况。

特别是,不要为了提高性能而将具有接口参数的函数更改为具有泛型类型参数的函数。例如,将这个简单的函数:

type Ager interface {
    age() int
}

func doubleAge(a Ager) int {
    return a.age() * 2
}

转换为:

func doubleAgeGenericT Ager int {
    return a.age() * 2
}

在 Go 1.20 中,函数调用慢了约 30%。(对于非平凡函数,性能差异不显著。)你可以使用第八章仓库sample_code/perf 目录下的代码运行基准测试。

对于其他语言中有泛型经验的开发人员,这可能会令人感到惊讶。例如,在 C++ 中,编译器使用泛型在抽象数据类型上执行正常的运行时操作(确定使用的具体类型),将其转换为编译时操作,为每个具体类型生成唯一的函数。这使得生成的二进制文件更大但更快。正如 Vicent 在他的博客文章中解释的那样,当前的 Go 编译器仅为不同的基础类型生成唯一的函数。此外,所有指针类型共享单个生成的函数。为了区分传递给共享生成函数的不同类型,编译器添加额外的运行时查找。这导致性能下降。

同样地,随着 Go 中泛型实现的成熟,预计运行时性能会改善。与往常一样,目标是编写可维护的程序,其速度足以满足您的需求。使用“使用基准测试”中讨论的基准测试和性能分析工具来衡量和改进您的代码。

向标准库添加泛型

Go 1.18 版本中泛型的初始发布非常保守。它在 universe 块中添加了新的接口anycomparable,但标准库中没有发生任何 API 更改来支持泛型。已进行了风格上的更改;标准库中几乎所有使用interface{}的地方都被替换为any

随着 Go 社区对泛型的适应越来越舒适,我们开始看到更多变化。从 Go 1.21 开始,标准库包括使用泛型来实现切片、映射和并发常见算法的函数。在第三章中,我介绍了slicesmaps包中的EqualEqualFunc函数。这些包中的其他函数简化了切片和映射的操作。slices包中的InsertDeleteDeleteFunc函数允许开发人员避免编写一些非常棘手的切片处理代码。maps.Clone函数利用 Go 运行时提供了创建映射的浅复制的更快方法。在“仅运行代码一次”中,您将了解到sync.OnceValuesync.OnceValues,它们使用泛型构建仅调用一次并返回一个或两个值的函数。建议使用这些包中的函数而不是编写自己的实现。标准库的未来版本可能会包含利用泛型的其他新函数和类型。

未来解锁的功能

泛型可能成为其他未来功能的基础。一个可能性是总和类型。正如类型元素用于指定可以替换为类型参数的类型一样,它们也可以用于变量参数中的接口。这将启用一些有趣的功能。今天,Go 在 JSON 中存在一个常见情况的问题:一个字段可以是单个值或值列表。即使有了泛型,处理这种情况的唯一方法仍然是使用类型为any的字段。添加总和类型将允许您创建一个接口,指定一个字段可以是字符串、字符串切片,以及其他内容。然后,类型开关可以完全枚举每个有效类型,提高类型安全性。这种指定一组有界类型的能力允许许多现代语言(包括 Rust 和 Swift)使用总和类型来表示枚举。鉴于 Go 当前枚举功能的弱点,这可能是一个吸引人的解决方案,但需要时间来评估和探索这些想法。

练习

现在你已经看到了泛型的工作原理,将其应用于解决以下问题。解决方案位于第八章代码库exercise_solutions目录中。

  1. 编写一个通用函数,用于将传入的任何整数或浮点数的值加倍。定义所需的通用接口。

  2. 定义一个名为 Printable 的泛型接口,该接口匹配实现了 fmt.Stringer 并且底层类型为 intfloat64 的类型。定义满足此接口的类型。编写一个函数,接受一个 Printable 并将其值使用 fmt.Println 打印到屏幕上。

  3. 编写一个泛型的单链表数据类型。每个元素可以存储一个可比较的值,并且有一个指向列表中下一个元素的指针。要实现的方法如下:

    // adds a new element to the end of the linked list
    Add(T)
    // adds an element at the specified position in the linked list
    Insert(T, int)
    // returns the position of the supplied value, -1 if it's not present
    Index (T) int
    

结束语

在本章中,你已经了解了泛型以及如何使用它们来简化你的代码。对于 Go 语言而言,泛型仍处于早期阶段。看到它们如何帮助语言发展,同时仍保持 Go 语言特有的精神,这将是令人兴奋的。

在接下来的章节中,你将学习如何正确地使用 Go 语言中最具争议的特性之一:errors。

第九章:错误

错误处理是开发者从其他语言转向 Go 时面临的最大挑战之一。对于习惯于异常的人来说,Go 的方法感觉过时。但 Go 方法的背后是坚实的软件工程原则。在本章中,您将学习如何在 Go 中处理错误。您还将了解panicrecover,Go 用于处理应该停止执行的错误的系统。

如何处理错误:基础知识

如同在第五章中简要介绍的那样,Go 通过在函数的最后一个返回值返回error类型的值来处理错误。虽然这完全是按约定来的,但这一约定如此之强,以至于不应该违反。当函数正常执行时,错误参数返回nil。如果出现问题,则返回一个错误值。调用函数通过将其与nil比较来检查错误返回值,处理错误,或返回自己的错误。一个带有错误处理的简单函数看起来像这样:

func calcRemainderAndMod(numerator, denominator int) (int, int, error) {
    if denominator == 0 {
        return 0, 0, errors.New("denominator is 0")
    }
    return numerator / denominator, numerator % denominator, nil
}

使用errors包中的New函数通过字符串创建新的错误。错误消息不应大写,也不应以标点符号或换行符结尾。在大多数情况下,当返回非nil错误时,应将其他返回值设置为它们的零值。在我讨论哨兵错误时,您将看到此规则的一个例外。

与具有异常的语言不同,Go 没有特殊的结构来检测是否返回了错误。当函数返回错误时,使用if语句来检查错误变量是否为非nil

func main() {
    numerator := 20
    denominator := 3
    remainder, mod, err := calcRemainderAndMod(numerator, denominator)
    if err != nil {
        fmt.Println(err)
        os.Exit(1)
    }
    fmt.Println(remainder, mod)
}

您可以在第九章库sample_code/error_basics目录中尝试此代码。

error是一个内置接口,定义了一个方法:

type error interface {
    Error() string
}

任何实现此接口的内容都被视为错误。您从函数返回nil以指示未发生错误的原因是nil是任何接口类型的零值。

Go 使用返回的错误而不是抛出异常有两个非常好的原因。首先,异常会通过代码添加至少一条新的代码路径。这些路径有时不太清晰,特别是在函数不包括异常可能性声明的语言中。这会导致代码以令人惊讶的方式崩溃,当异常没有被正确处理时,甚至更糟糕的是,代码并不会崩溃,但其数据没有正确初始化、修改或存储。

第二个原因更微妙,但展示了 Go 的特性如何协同工作。Go 编译器要求读取所有变量。使错误返回值迫使开发者要么检查和处理错误条件,要么通过使用下划线(_)明确表明他们正在忽略错误。

异常处理可能会生成更短的代码,但更少的行数并不一定使代码更易于理解或维护。正如您所见,Go 的惯用法更青睐清晰的代码,即使代码行数更多。

另一个需要注意的是 Go 中代码的流程。错误处理位于if语句的缩进内,而业务逻辑则不是。这为哪些代码处于“黄金路径”以及哪些代码是异常条件提供了快速的视觉线索。

注意

第二种情况是重用的err变量。Go 编译器要求每个变量至少被读取一次。它不要求对变量的每次写入都进行读取。如果多次使用err变量,则只需读取一次即可使编译器满意。在“staticcheck”中,您将看到一种检测此问题的方法。

使用字符串表示简单错误

Go 标准库提供了两种从字符串创建错误的方式。第一种是errors.New函数。它接受一个string并返回一个error。当您调用返回的错误实例的Error方法时,此字符串将被返回。如果将错误传递给fmt.Println,它会自动调用Error方法:

func doubleEven(i int) (int, error) {
    if i % 2 != 0 {
        return 0, errors.New("only even numbers are processed")
    }
    return i * 2, nil
}

func main() {
    result, err := doubleEven(1)
    if err != nil {
        fmt.Println(err) // prints "only even numbers are processed"
    }
    fmt.Println(result)
}

第二种方法是使用fmt.Errorf函数。此函数允许您通过使用fmt.Printf占位符在错误消息中包含运行时信息。与errors.New类似,调用返回的错误实例的Error方法时返回此字符串:

func doubleEven(i int) (int, error) {
    if i % 2 != 0 {
        return 0, fmt.Errorf("%d isn't an even number", i)
    }
    return i * 2, nil
}

您可以在第九章代码库sample_code/string_error目录中找到此代码。

哨兵错误

有些错误意味着由于当前状态存在问题而无法继续处理。在他的博客文章“不仅仅检查错误,优雅地处理它们”中,Dave Cheney,一个多年活跃于 Go 社区的开发者,创造了术语哨兵错误来描述这些错误:

名称源自计算机编程实践中使用特定值表示无法进一步处理。在 Go 中也是如此,我们使用特定值来表示错误。

哨兵错误是少数在包级别声明的变量之一。按照惯例,它们的名称以Err开头(io.EOF是显著的例外)。它们应被视为只读;虽然 Go 编译器无法强制执行这一点,但修改它们的值是编程错误。

哨兵错误通常用于指示无法启动或继续处理。例如,标准库包括一个用于处理 ZIP 文件的包,archive/zip。此包定义了几个哨兵错误,包括ErrFormat,当传入的数据不表示 ZIP 文件时返回。您可以在The Go Playground或第九章代码库的sample_code/sentinel_error目录中尝试此代码:

func main() {
    data := []byte("This is not a zip file")
    notAZipFile := bytes.NewReader(data)
    _, err := zip.NewReader(notAZipFile, int64(len(data)))
    if err == zip.ErrFormat {
        fmt.Println("Told you so")
    }
}

标准库中另一个哨兵错误的例子是rsa.ErrMessageTooLong,位于crypto/rsa包中。它指示由于消息过长而无法使用提供的公钥进行加密。哨兵错误context.Canceled在第十四章中有详细介绍。

在定义哨兵错误之前,请确保您确实需要一个。一旦定义了哨兵错误,它就成为您公共 API 的一部分,并且您已经承诺它在所有未来向后兼容的发布中都可用。更好的做法是重用标准库中的现有错误之一,或者定义一个错误类型,其中包含有关导致返回错误的条件的信息(您将在下一节中看到如何实现)。但是,如果您有一个错误条件,指示应用程序中已达到特定状态,进一步处理不可能,并且不需要使用其他信息来解释错误状态,则哨兵错误是正确的选择。

如何测试哨兵错误?如前面的代码示例所示,使用==来测试调用函数时是否返回了文档明确说明返回哨兵错误的情况。在“Is and As”中,我将讨论如何在其他情况下检查哨兵错误。

到目前为止,您看到的所有错误都是字符串。但是 Go 错误可以包含更多信息。让我们看看如何做到这一点。

错误是值

由于error是一个接口,您可以定义自己的错误类型,其中包含用于日志记录或错误处理的附加信息。例如,您可能希望在错误中包含状态码以指示应向用户报告的错误类型。这样可以避免字符串比较(其文本可能会更改)来确定错误原因。让我们看看这是如何工作的。首先,定义自己的枚举以表示状态码:

type Status int

const (
    InvalidLogin Status = iota + 1
    NotFound
)

接下来,定义一个StatusErr来保存这个值:

type StatusErr struct {
    Status    Status
    Message   string
}

func (se StatusErr) Error() string {
    return se.Message
}

现在,您可以使用StatusErr来提供有关出现问题的更多详细信息:

func LoginAndGetData(uid, pwd, file string) ([]byte, error) {
    token, err := login(uid, pwd)
    if err != nil {
        return nil, StatusErr{
            Status:    InvalidLogin,
            Message: fmt.Sprintf("invalid credentials for user %s", uid),
        }
    }
    data, err := getData(token, file)
    if err != nil {
        return nil, StatusErr{
            Status:    NotFound,
            Message: fmt.Sprintf("file %s not found", file),
        }
    }
    return data, nil
}

您可以在第九章存储库sample_code/custom_error目录中找到此代码。

即使定义了自己的自定义错误类型,也要始终将error作为错误结果的返回类型。这允许您从函数中返回不同类型的错误,并允许调用者选择不依赖于特定的错误类型。

如果您使用自己的错误类型,请确保不要返回未初始化的实例。看看如果这样做会发生什么。在The Go Playground第九章存储库sample_code/return_custom_error目录中尝试以下代码:

func GenerateErrorBroken(flag bool) error {
    var genErr StatusErr
    if flag {
        genErr = StatusErr{
            Status: NotFound,
        }
    }
    return genErr
}

func main() {
    err := GenerateErrorBroken(true)
    fmt.Println("GenerateErrorBroken(true) returns non-nil error:", err != nil)
    err = GenerateErrorBroken(false)
    fmt.Println("GenerateErrorBroken(false) returns non-nil error:", err != nil)
}

运行此程序将产生以下输出:

true
true

这不是指针类型与值类型的问题;如果你声明genErr的类型是*StatusErr,你会看到相同的输出。err非空的原因是error是一个接口。正如我在“接口和 nil”中讨论的那样,要使接口被认为是nil,必须同时满足底层类型和底层值都是nil。不管genErr是不是指针,接口的底层类型部分都不是nil

你可以用两种方法修复这个问题。最常见的方法是在函数成功完成时显式地返回nil作为错误值:

func GenerateErrorOKReturnNil(flag bool) error {
    if flag {
        return StatusErr{
            Status: NotFound,
        }
    }
    return nil
}

这样做的好处是不需要你阅读代码来确保return语句上的错误变量被正确定义。

另一种方法是确保任何持有error的局部变量都是error类型:

func GenerateErrorUseErrorVar(flag bool) error {
    var genErr error
    if flag {
        genErr = StatusErr{
            Status: NotFound,
        }
    }
    return genErr
}
警告

当使用自定义错误时,永远不要定义一个变量为你自定义错误的类型。要么在没有错误发生时显式地返回nil,要么定义变量为error类型。

如在“谨慎使用类型断言和类型切换”中所述,不要使用类型断言或类型切换来访问自定义错误的字段和方法。相反,请使用errors.As,这在“Is 和 As”中有讨论。

包装错误

当错误通过你的代码传递回来时,你通常希望为其添加信息。这可以是接收到错误的函数的名称或它试图执行的操作。当你在保留错误的同时添加信息时,称为包装错误。当你有一系列被包装的错误时,称为错误树

Go 标准库中的一个函数用于包装错误,你已经见过它了。fmt.Errorf函数有一个特殊的占位符%w。使用它可以创建一个错误,其格式化字符串包含另一个错误的格式化字符串,并且包含原始错误。惯例是在错误格式字符串的末尾写上: %w,并且将要被包装的错误作为fmt.Errorf的最后一个参数传递。

标准库还提供了一个解包错误的函数,即errors包中的Unwrap函数。你将一个错误传递给它,如果存在包装错误,它将返回被包装的错误。如果没有,它将返回nil。这里有一个快速示例程序,演示了使用fmt.Errorf包装和使用errors.Unwrap解包的过程。你可以在The Go Playground上运行它,或者在第九章代码库sample_code/wrap_error目录中运行它:

func fileChecker(name string) error {
    f, err := os.Open(name)
    if err != nil {
        return fmt.Errorf("in fileChecker: %w", err)
    }
    f.Close()
    return nil
}

func main() {
    err := fileChecker("not_here.txt")
    if err != nil {
        fmt.Println(err)
        if wrappedErr := errors.Unwrap(err); wrappedErr != nil {
            fmt.Println(wrappedErr)
        }
    }
}

当你运行这个程序时,你会看到以下输出:

in fileChecker: open not_here.txt: no such file or directory
open not_here.txt: no such file or directory
注意

通常不直接调用errors.Unwrap。相反,你可以使用errors.Iserrors.As来查找特定的包装错误。我将在下一节讨论这两个函数。

如果你想用你自定义的错误类型包装一个错误,你的错误类型需要实现 Unwrap 方法。该方法不带参数并返回一个 error。下面是对你之前定义的错误的更新,演示了如何实现这一点。你可以在 sample_code/custom_wrapped_error 目录中的 第九章存储库 找到它:

type StatusErr struct {
    Status  Status
    Message string
    Err     error
}

func (se StatusErr) Error() string {
    return se.Message
}

func (se StatusErr) Unwrap() error {
    return se.Err
}

现在你可以使用 StatusErr 包装底层错误:

func LoginAndGetData(uid, pwd, file string) ([]byte, error) {
    token, err := login(uid,pwd)
    if err != nil {
        return nil, StatusErr {
            Status: InvalidLogin,
            Message: fmt.Sprintf("invalid credentials for user %s",uid),
            Err: err,
        }
    }
    data, err := getData(token, file)
    if err != nil {
        return nil, StatusErr {
            Status: NotFound,
            Message: fmt.Sprintf("file %s not found",file),
            Err: err,
        }
    }
    return data, nil
}

并非所有的错误都需要包装。一个库可能会返回一个错误,意味着处理无法继续,但错误消息包含了在程序其他部分不需要的实现细节。在这种情况下,创建一个全新的错误并返回是完全可以接受的。理解情况并确定需要返回什么。

提示

如果你想创建一个包含另一个错误消息的新错误,但又不想包装它,请使用 fmt.Errorf 创建一个错误,但是使用 %v 动词而不是 %w

err := internalFunction()
if err != nil {
    return fmt.Errorf("internal failure: %v", err)
}

包装多个错误

有时一个函数会生成多个应返回的错误。例如,如果你编写一个函数来验证结构体中的字段,最好为每个无效字段返回一个错误。由于标准函数签名返回 error 而不是 []error,你需要将多个错误合并为单个错误。这就是 errors.Join 函数的用途:

type Person struct {
    FirstName string
    LastName  string
    Age       int
}

func ValidatePerson(p Person) error {
    var errs []error
    if len(p.FirstName) == 0 {
        errs = append(errs, errors.New("field FirstName cannot be empty"))
    }
    if len(p.LastName) == 0 {
        errs = append(errs, errors.New("field LastName cannot be empty"))
    }
    if p.Age < 0 {
        errs = append(errs, errors.New("field Age cannot be negative"))
    }
    if len(errs) > 0 {
        return errors.Join(errs...)
    }
    return nil
}

你可以在 sample_code/join_error 目录中的 第九章存储库 找到这段代码。

合并多个错误的另一种方法是将多个 %w 动词传递给 fmt.Errorf

err1 := errors.New("first error")
err2 := errors.New("second error")
err3 := errors.New("third error")
err := fmt.Errorf("first: %w, second: %w, third: %w", err1, err2, err3)

你可以实现自己的支持多个包装错误的 error 类型。为此,实现 Unwrap 方法但让它返回 []error 而不是 error

type MyError struct {
    Code   int
    Errors []error
}

type (m MyError) Error() string {
    return errors.Join(m.Errors...).Error()
}

func (m MyError) Unwrap() []error {
    return m.Errors
}

Go 不支持方法重载,因此你不能创建一个提供 Unwrap 的单一类型。还要注意,如果将 errors.Unwrap 函数传递给实现 []error 变体的错误,它将返回 nil。这也是你不应直接调用 errors.Unwrap 函数的另一个原因。

如果你需要处理可能包装零个、一个或多个错误的错误,请使用此代码作为基础。你可以在 sample_code/custom_wrapped_multi_error 目录中的 第九章存储库 找到它:

var err error
err = funcThatReturnsAnError()
switch err := err.(type) {
case interface {Unwrap() error}:
    // handle single error
    innerErr := err.Unwrap()
    // process innerErr
case interface {Unwrap() []error}:
    //handle multiple wrapped errors
    innerErrs := err.Unwrap()
    for _, innerErr := range innerErrs {
        // process each innerErr
    }
default:
    // handle no wrapped error
}

由于标准库没有定义接口来表示具有 Unwrap 变体的错误,此代码使用匿名接口在类型切换中匹配方法并访问包装的错误。在编写自己的代码之前,请查看是否可以使用 errors.Iserrors.As 检查错误树。让我们看看它们是如何工作的。

Is 和 As

包装错误是获取有关错误的附加信息的有用方式,但它也引入了问题。如果包装了一个 sentinel 错误,则无法使用 == 进行检查,也无法使用类型断言或类型切换来匹配包装的自定义错误。Go 通过 errors 包中的两个函数 IsAs 解决了这个问题。

要检查返回的错误或其包装的任何错误是否与特定的 sentinel 错误实例匹配,请使用 errors.Is。它接受两个参数:要检查的错误和要比较的实例。如果错误树中的任何错误与提供的 sentinel 错误匹配,则 errors.Is 函数返回 true。您将编写一个简短的程序来演示 errors.Is 的工作原理。您可以在Go Playground第九章存储库sample_code/is_error 目录中运行它:

func fileChecker(name string) error {
    f, err := os.Open(name)
    if err != nil {
        return fmt.Errorf("in fileChecker: %w", err)
    }
    f.Close()
    return nil
}

func main() {
    err := fileChecker("not_here.txt")
    if err != nil {
        if errors.Is(err, os.ErrNotExist) {
            fmt.Println("That file doesn't exist")
        }
    }
}

运行此程序会产生以下输出:

That file doesn't exist

默认情况下,errors.Is 使用 == 比较每个包装错误与指定错误。如果对您定义的错误类型(例如,如果您的错误是非可比较类型),这种比较方式不起作用,请在您的错误上实现 Is 方法:

type MyErr struct {
    Codes []int
}

func (me MyErr) Error() string {
    return fmt.Sprintf("codes: %v", me.Codes)
}

func (me MyErr) Is(target error) bool {
    if me2, ok := target.(MyErr); ok {
        return slices.Equal(me.Codes, me2.Codes)
    }
    return false
}

slices.Equal 函数在“Slices”中已经提到。)

定义自己的 Is 方法的另一个用途是允许与不完全相同实例的错误进行比较。您可能希望对错误进行模式匹配,指定一个过滤器实例来匹配具有部分相同字段的错误。定义一个新的错误类型,ResourceErr

type ResourceErr struct {
    Resource     string
    Code         int
}

func (re ResourceErr) Error() string {
    return fmt.Sprintf("%s: %d", re.Resource, re.Code)
}

如果您希望两个 ResourceErr 实例在任何字段设置时匹配,可以通过编写自定义的 Is 方法来实现:

func (re ResourceErr) Is(target error) bool {
    if other, ok := target.(ResourceErr); ok {
        ignoreResource := other.Resource == ""
        ignoreCode := other.Code == 0
        matchResource := other.Resource == re.Resource
        matchCode := other.Code == re.Code
        return matchResource && matchCode ||
            matchResource && ignoreCode ||
            ignoreResource && matchCode
    }
    return false
}

现在,您可以找到例如所有与数据库相关的错误,无论代码如何:

if errors.Is(err, ResourceErr{Resource: "Database"}) {
    fmt.Println("The database is broken:", err)
    // process the codes
}

您可以在Go Playground第九章存储库sample_code/custom_is_error_pattern_match 目录中查看此代码。

errors.As 函数允许您检查返回的错误(或其包装的任何错误)是否与特定类型匹配。它接受两个参数。第一个参数是正在检查的错误,第二个参数是您要查找的类型的变量的指针。如果函数返回 true,则在错误树中找到了匹配的错误,并且该匹配的错误被赋给第二个参数。如果函数返回 false,则在错误树中找不到匹配项。尝试使用 MyErr

err := AFunctionThatReturnsAnError()
var myErr MyErr
if errors.As(err, &myErr) {
    fmt.Println(myErr.Codes)
}

请注意,您使用 var 声明特定类型的变量,并将此变量的指针传递给 errors.As

您不必将错误类型的变量指针作为 errors.As 的第二个参数传递。您可以传递一个指向接口的指针,以找到符合接口的错误:

err := AFunctionThatReturnsAnError()
var coder interface {
    CodeVals() []int
}
if errors.As(err, &coder) {
    fmt.Println(coder.CodeVals())
}

该示例使用了匿名接口,但任何接口类型都可以。你可以在第九章存储库sample_code/custom_as_error目录中找到errors.As的两个示例。

警告

如果errors.As的第二个参数不是指向错误或接口的指针,则该方法会 panic。

正如你可以通过Is方法覆盖默认的errors.Is比较一样,你也可以通过在你的错误上实现一个As方法来覆盖默认的errors.As比较。实现As方法是复杂的,并且需要反射(我将在第十六章中讨论 Go 中的反射)。只有在不寻常的情况下才应该这样做,例如当你想匹配一种类型的错误并返回另一种类型时。

提示

当你寻找特定实例或特定时,请使用errors.Is。当你寻找特定类型时,请使用errors.As

使用 defer 包装错误

有时你会发现自己用相同的消息包装多个错误:

func DoSomeThings(val1 int, val2 string) (string, error) {
    val3, err := doThing1(val1)
    if err != nil {
        return "", fmt.Errorf("in DoSomeThings: %w", err)
    }
    val4, err := doThing2(val2)
    if err != nil {
        return "", fmt.Errorf("in DoSomeThings: %w", err)
    }
    result, err := doThing3(val3, val4)
    if err != nil {
        return "", fmt.Errorf("in DoSomeThings: %w", err)
    }
    return result, nil
}

你可以通过使用defer来简化这段代码:

func DoSomeThings(val1 int, val2 string) (_ string, err error) {
    defer func() {
        if err != nil {
            err = fmt.Errorf("in DoSomeThings: %w", err)
        }
    }()
    val3, err := doThing1(val1)
    if err != nil {
        return "", err
    }
    val4, err := doThing2(val2)
    if err != nil {
        return "", err
    }
    return doThing3(val3, val4)
}

你必须给返回值命名,这样你才能在延迟函数中引用err。如果你给一个返回值命名,那么所有的返回值都必须命名,所以在这里你对未显式赋值的字符串返回值使用了下划线。

defer闭包中,代码会检查是否返回了错误。如果是这样,它会将错误重新赋值为一个新的错误,此错误会用一个消息包装原始错误,指示哪个函数检测到了错误。

当每个错误都用相同的消息包装时,这种模式效果很好。如果你想要用更多的细节定制包装错误,可以在每个fmt.Errorf中同时放置具体和一般的消息。

panic 和 recover

之前的章节中提到过panic,但没有详细介绍它们是什么。Panic 类似于 Java 或 Python 中的Error。这是 Go 运行时在无法确定接下来应该发生什么时生成的一种状态。这几乎总是由于编程错误引起的,例如尝试读取超出切片末尾或将负大小传递给make。如果 Go 运行时检测到自身的错误,例如垃圾收集器的不当行为,它也会panic。但我从未见过这种情况发生。如果发生panic,最后责备运行时。

一旦发生 panic,当前函数立即退出,并且任何附加到当前函数的 defer 开始运行。当这些 defer 完成时,调用函数的 defer 运行,依此类推,直到达到main。然后程序以消息和堆栈跟踪退出。

注意

如果除了主 goroutine 之外的 goroutine 中发生了 panic(goroutine 在“Goroutines”中介绍),则 defer 链以启动该 goroutine 的函数结束。如果任何goroutine 发生了 panic 但未被 recover,程序将退出。

如果您的程序中有任何不可恢复的情况,您可以自行创建panic。内置函数panic接受一个参数,可以是任何类型。通常是一个字符串。以下是一个简单的会触发 panic 的程序;您可以在Go Playground上运行它,或者在第九章存储库sample_code/panic目录中运行它:

func doPanic(msg string) {
    panic(msg)
}

func main() {
    doPanic(os.Args[0])
}

运行此代码会产生以下输出:

panic: /tmpfs/play

goroutine 1 [running]:
main.doPanic(...)
    /tmp/sandbox567884271/prog.go:6
main.main()
    /tmp/sandbox567884271/prog.go:10 +0x5f

如您所见,panic会打印出其消息,然后是堆栈跟踪。

Go 提供了一种捕获panic的方法,以提供更优雅的关闭或防止关闭。内置的recover函数是从defer中调用的,用于检查是否发生了panic。如果有panic,则返回给panic赋的值。一旦发生recover,执行会正常继续。让我们看看另一个示例程序。在Go Playground上运行它,或者在第九章存储库sample_code/panic_recover目录中运行它:

func div60(i int) {
    defer func() {
        if v := recover(); v != nil {
            fmt.Println(v)
        }
    }()
    fmt.Println(60 / i)
}

func main() {
    for _, val := range []int{1, 2, 0, 6} {
        div60(val)
    }
}

使用recover有一个特定的模式。您可以使用defer注册一个函数来处理可能的panic。在if语句中调用recover,并检查是否找到了非 nil 值。您必须在defer中调用recover,因为一旦发生panic,只有延迟函数会运行。

运行此代码会产生以下输出:

60
30
runtime error: integer divide by zero
10

由于recover使用非 nil 值来检测是否发生了panic,聪明的读者可能会提出一个问题:如果您调用panic(nil)并且有一个recover会发生什么?在 Go 版本 1.21 之前编译的代码中,答案是“没有什么了不起的事情。”在这些版本中,recover会停止panic的传播,但没有消息或数据表明发生了什么。从 Go 1.21 开始,panic(nil)的调用与panic(new(runtime.PanicNilError))相同。

虽然panicrecover看起来很像其他语言中的异常处理,但它们不打算这样使用。保留panic用于致命情况,并使用recover作为优雅处理这些情况的一种方式。如果您的程序发生了 panic,请小心尝试在 panic 发生后继续执行。在计算机由于内存或磁盘空间等资源不足而触发panic时,最安全的做法是使用recover将情况记录到监控软件并使用os.Exit(1)关闭。如果程序错误导致了 panic,您可以尝试继续执行,但很可能会再次遇到相同的问题。在上述示例程序中,如果出现除以零,则检查并返回错误是惯用的做法。

你不依赖于panicrecover的原因是recover不清楚什么可能失败。它只是确保如果某事失败,你可以打印出一条消息并继续。惯用的 Go 代码偏爱明确列出可能的失败条件的代码,而不是处理任何事情但什么都不说的短代码。

在一个情况下推荐使用recover。如果你正在为第三方创建库,请不要让panic逃离公共 API 的边界。如果可能发生panic,公共函数应该使用recoverpanic转换为错误,返回它,并让调用代码决定如何处理它们。

注意

虽然内置在 Go 中的 HTTP 服务器在处理程序中恢复了 panic,但戴维·西蒙兹在GitHub 评论中说,截至 2015 年,Go 团队认为这是一个错误。

从错误获取堆栈跟踪

新 Go 开发人员倾向于使用panicrecover的原因之一是他们希望在出现问题时获得堆栈跟踪。默认情况下,Go 不提供这个功能。如我所示,你可以使用错误包装手动构建调用堆栈,但一些第三方带有错误类型的库会自动生成这些堆栈(参见第十章了解如何将第三方代码整合到你的程序中)。Cockroachdb 提供了一个第三方库,其中包含用于带堆栈跟踪的错误包装的函数。

默认情况下,不会打印出堆栈跟踪。如果想看到堆栈跟踪,请使用fmt.Printf和详细输出动词(%+v)。查阅文档了解更多信息。

注意

当你的错误中有一个堆栈跟踪时,输出会包含程序编译时计算机上文件的完整路径。如果不想暴露路径,构建代码时请使用-trimpath标志。这会用包名替换完整路径。

练习

查看sample_code/exercise目录中第九章存储库中的代码。你将在每个练习中修改这些代码。它的功能是正确的,但应改进其错误处理。

  1. 创建一个哨兵错误来表示无效的 ID。在main中,使用errors.Is检查哨兵错误,并在发现时打印消息。

  2. 定义一个自定义错误类型来表示空字段错误。此错误应包括空Employee字段的名称。在main中,使用errors.As检查此错误。打印一个包含字段名的消息。

  3. 而不是返回发现的第一个错误,返回一个包含所有验证期间发现的错误的单个错误。更新main中的代码,正确报告多个错误。

结束

本章涵盖了 Go 语言中的错误,它们是什么,如何定义自己的错误,以及如何检查它们。您还学习了panicrecover。下一章将讨论包和模块,如何在程序中使用第三方代码,以及如何发布您自己的代码供他人使用。

第十章:模块、包和导入

大多数现代编程语言都有一个将代码组织成命名空间和库的系统,Go 也不例外。正如你在探索其他特性时所见,Go 引入了一些新的方法来实现这一古老的理念。在这一章中,你将学习如何使用包和模块组织代码,如何导入它们,如何使用第三方库,以及如何创建自己的库。

仓库、模块和包

Go 中的库管理基于三个概念:仓库、模块和包。仓库 对所有开发者来说都很熟悉。它是版本控制系统中存储项目源代码的地方。模块 是一组作为单个单元分发和版本化的 Go 源代码。模块存储在仓库中。模块由一个或多个 组成,包是源代码的目录。包给模块提供了组织和结构。

注意

虽然你可以在一个仓库中存储多个模块,但并不鼓励这样做。每个模块内的所有内容都是一起进行版本管理的。在一个仓库中维护两个模块需要你在单个仓库中跟踪两个不同模块的版本。

不幸的是,不同的编程语言使用这些术语来表示不同的概念。虽然 Java 和 Go 中的包是相似的,但 Java 仓库是存储多个 构件(类似于 Go 模块的东西)的集中地点。Node.js 和 Go 交换了这些术语的含义:Node.js 包类似于 Go 所称的模块,而 Go 包类似于 Node.js 模块。起初这些术语可能会令人困惑,但当你对 Go 越来越熟悉时,这些术语会变得更加熟悉。

在使用来自标准库之外的包的代码之前,你需要确保你有一个正确创建的模块。每个模块都有一个全局唯一的标识符。这不仅仅是 Go 的特性。Java 使用反向域名约定 (com.*companyname*.*projectname*.library) 定义全局唯一的包声明。

在 Go 中,这个名字被称为 模块路径。通常基于存储模块的仓库。例如,你可以在 https://github.com/jonbodner/proteus 找到 Proteus,这是我编写的一个简化 Go 中关系型数据库访问的模块。它的模块路径是 github.com/jonbodner/proteus

注意

回到 “你的第一个 Go 程序”,你创建了一个名为 hello_world 的模块,显然这不是全局唯一的。如果你只是为本地使用创建一个模块,那么这没问题。但是如果你将一个具有非唯一名称的模块放入源代码仓库中,那么其他模块将无法导入它。

使用 go.mod

当目录树中的 Go 源代码包含有效的 go.mod 文件时,它变成一个模块。不要手动创建此文件,使用 go mod 命令的子命令来管理模块。命令 go mod init *MODULE_PATH* 创建使当前目录成为模块根的 go.mod 文件。MODULE_PATH 是唯一标识您的模块的全局唯一名称。模块路径区分大小写。为避免混淆,不要在其中使用大写字母。

查看 go.mod 文件的内容:

module github.com/learning-go-book-2e/money

go 1.21

require (
    github.com/learning-go-book-2e/formatter v0.0.0-20220918024742-18...
    github.com/shopspring/decimal v1.3.1
)

require (
    github.com/fatih/color v1.13.0 // indirect
    github.com/mattn/go-colorable v0.1.9 // indirect
    github.com/mattn/go-isatty v0.0.14 // indirect
    golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c // indirect
)

每个 go.mod 文件都以一个 module 指令开始,包含 module 一词和模块的唯一路径。接下来,go.mod 文件使用 go 指令指定 Go 的最低兼容版本。模块中的所有源代码必须与指定版本兼容。例如,如果指定了(相当旧的)版本 1.12,则编译器不允许在数字文字中使用下划线,因为该功能是在 Go 1.13 中添加的。

使用 go 指令管理 Go 构建版本

如果 go 指令指定的 Go 版本比已安装的版本更新,会发生什么?如果安装的是 Go 1.20 或更早版本,则会忽略更新的 Go 版本,并使用已安装版本的功能。如果安装的是 Go 1.21 或更新版本,则默认行为是下载更新的 Go 版本并用其构建您的代码。您可以在 Go 1.21 及更新版本中通过 toolchain 指令和 GOTOOLCHAIN 环境变量来控制此行为。可以为它们分配以下值:

  • auto,会下载更新的 Go 版本。(这是 Go 1.21 及更高版本的默认行为。)

  • local,恢复 Go 1.21 之前发布的行为。

  • 特定的 Go 版本(例如 go1.20.4),这意味着将下载并使用特定的版本来构建程序。

例如,命令行 GOTOOLCHAIN=go1.18 go build 将使用 Go 1.18 构建您的 Go 程序,必要时进行下载。

如果同时设置了 GOTOOLCHAIN 环境变量和 toolchain 指令,则使用分配给 GOTOOLCHAIN 的值。

有关 go 指令、toolchain 指令和 GOTOOLCHAIN 环境变量的详细信息,请参阅官方 Go 工具链文档

如《for-range 值是一个副本》中所讨论的,Go 1.22 引入了语言的首个向后不兼容的更改。当使用 Go 1.22 或更高版本时,如果 go 指令设置为 1.22 或更高,则 for 循环在每次迭代时创建新的索引和值变量。此行为适用于每个模块。每个导入模块中 go 指令的值确定该模块的语言级别。(《导入第三方代码》介绍了如何在程序中使用和管理多个模块。)

您可以通过一个简短的示例看到这种差异。您可以在 第十章存储库 中的 sample_code/loop_test 目录中找到代码。

loop.go 文件中的代码很简单:

func main() {
    x := []int{1, 2, 3, 4, 5}
    for _, v := range x {
        fmt.Printf("%p\n", &v)
    }
}

如果您以前没有见过,在 fmt 格式化语言中 %p 动词返回指针的内存位置。存储库的 go.mod 文件中的 go 指令设置为 1.21。构建和运行程序将产生以下输出:

140000140a8
140000140a8
140000140a8
140000140a8
140000140a8

当使用较早版本的 Go 构建时,程序会五次打印相同的内存地址。(这里显示的内存地址可能会有所不同,但所有地址都是相同的值。)

go.mod 中的 go 指令值更改为 1.22,然后重新构建和运行程序。您现在将得到如下输出:

1400000e0b0
1400000e0b8
1400000e0d0
1400000e0d8
1400000e0e0

注意每个内存地址值都不同,表明在每次迭代中创建了一个新变量。(这里显示的内存地址可能会有所不同,但每个地址都是不同的值。)

require 指令

go.mod 文件中的下一节是 require 指令。require 指令仅在您的模块具有依赖项时才存在。它们列出了您的模块依赖的模块及其所需的最低版本。第一个 require 部分列出了您的模块的直接依赖项。第二个部分列出了您模块依赖的依赖项。此部分中的每行都以 // indirect 注释结尾。标记为间接依赖项和未标记的模块之间没有功能上的区别;这只是在查看 go.mod 文件时为人们提供的文档。在使用 go get 的方式时,有一种情况下会标记直接依赖项为间接依赖项,我将在讨论 go get 的方式时进行说明。在 “导入第三方代码” 中,您将学习如何添加和管理模块的依赖项。

虽然 modulegorequire 指令是 go.mod 文件中最常用的指令,但还有其他三个指令。我将在 “覆盖依赖项” 中介绍 replaceexclude 指令,并在 “撤回模块的版本” 中介绍 retract 指令。

构建包

现在您已经学会将您的代码目录变成一个模块,是时候开始使用包来组织您的代码了。您将从了解 import 的工作原理开始,然后转向创建和组织包,然后再看看 Go 包的一些特性,包括好的和坏的。

导入和导出

即使我还没有讨论它的功能及其在 Go 中与其他语言的不同之处,示例程序一直在使用 import 语句。Go 的 import 语句允许您访问另一个包中导出的常量、变量、函数和类型。包的导出标识符(标识符是变量、常量、类型、函数、方法或结构体中的字段的名称)在没有 import 语句的情况下无法从当前包的另一个包中访问。

这引出了一个问题,如何在 Go 中导出标识符?Go 使用 大写字母 来确定包级别标识符是否对外部可见,而不是使用特殊的关键字。以大写字母开头的标识符被称为 导出的 标识符。相反,以小写字母或下划线开头的标识符只能从声明它的包内部访问。在 Go 中,标识符不能以数字开头,但可以包含数字。

任何您导出的内容都是包的 API 的一部分。在导出标识符之前,请确保您打算将其暴露给客户端。记录所有导出的标识符,并保持向后兼容,除非您有意进行主要版本更改(有关更多信息,请参阅“模块版本化”)。

创建和访问包

在 Go 中创建包很容易。让我们看一个小程序来演示这一点。您可以在package_example 本书目录中找到它。在 package_example 中,您会看到两个额外的目录,mathdo-format。在 math 中,有一个名为 math.go 的文件,内容如下:

package math

func Double(a int) int {
    return a * 2
}

文件的第一行称为 包声明。它由关键字 package 和包的名称组成。包声明始终是 Go 源文件中的第一行,且不能为空且非注释。

do-format 目录中有一个名为 formatter.go 的文件,内容如下:

package format

import "fmt"

func Number(num int) string {
    return fmt.Sprintf("The number is %d", num)
}

请注意,包声明中的包名是 format,但它位于 do-format 目录中。很快将介绍如何与此包交互。

最后,在根目录中的文件 main.go 中包含以下内容:

package main

import (
    "fmt"

    "github.com/learning-go-book-2e/package_example/do-format"
    "github.com/learning-go-book-2e/package_example/math"
)

func main() {
    num := math.Double(2)
    output := format.Number(num)
    fmt.Println(output)
}

本文件的第一行很熟悉。在本章之前的所有程序中,代码的第一行都是package main。稍后我会详细讨论这意味着什么。

接下来是导入部分。它导入了三个包。第一个是标准库中的 fmt 包。您在之前的章节中已经做过这个操作。接下来的两个导入引用程序内部的包。除了标准库之外,当从任何地方导入时,您必须指定 导入路径。导入路径由模块路径和模块内包路径组成。例如,导入 "github.com/learning-go-book-2e/package_example/math" 其中模块路径为 github.com/learning-go-book-2e/package_example,而 /math 是模块内包路径。

导入包但未使用包导出的任何标识符是编译时错误。这确保了由 Go 编译器生成的二进制文件仅包含程序中实际使用的代码。

警告

您可能会在网络上遇到关于相对路径导入路径的过时文档。它们不适用于模块。(而且本来就不是一个好主意。)

运行此程序时,您将看到以下输出:

$ go build
$ ./package_example
The number is 4

main 函数通过在函数名前加上包名调用了 math 包中的 Double 函数。在之前的章节中调用标准库中的函数时,您也调用了 format 包中的 Number 函数。您可能会想知道这个 format 包是从哪里来的,因为导入中写的是 github.com/learning-go-book-2e/package_example/do-format

目录中的每个 Go 文件必须具有相同的包子句。(在“测试您的公共 API”中,您会看到一个微小的例外。)您使用 github.com/learning-go-book-2e/package_example/do-format 导入了 format 包。这是因为 包的名称由其包子句确定,而不是其导入路径

作为一般规则,应使包名与包含包的目录名相匹配。如果包名与包含它的目录名不匹配,则很难发现包的名称。但在少数情况下,您将为包使用与目录不同的名称。

第一个是您一直在做的事情,但没有意识到。通过使用特殊包名 main,您声明了一个 Go 应用程序的起始点。由于无法导入 main 包,这不会产生混淆的导入语句。

包名与目录名不匹配的其他原因较少见。如果您的目录名包含 Go 标识符中不合法的字符,则必须选择与目录名不同的包名。在您的情况下,do-format 不是有效的标识符名称,因此它被替换为 format。最好通过永远不创建名称不是有效标识符的目录来避免这种情况。

创建一个名称与包名不匹配的目录的最终原因是使用目录支持版本控制。我将在“版本化您的模块”中更详细地讨论这一点。

如在“块”中讨论的那样,import语句中的包名位于文件块中。如果您在另一个包的两个不同文件中使用一个包中的导出符号,则必须在第二个包的两个文件中都导入第一个包。

包命名

包名应具有描述性。而不是称为util的包,应创建一个描述包功能的包名。例如,假设您有两个辅助函数:一个从字符串中提取所有名称,另一个正确格式化名称。不要在名为util的包中创建名为ExtractNamesFormatNames的两个函数。如果这样做,每次使用这些函数时,它们将被称为util.ExtractNamesutil.FormatNames,而util包并未告诉您这些函数的实际功能。

一个选择是在称为extract的包中创建一个名为Names的函数,以及在称为format的包中创建一个名为Names的第二个函数。这两个函数具有相同的名称是可以接受的,因为它们始终会通过其包名称加以区分。第一个将在导入时被称为extract.Names,第二个将被称为format.Names

更好的选择是考虑语法成分。函数或方法执行某些操作,因此它应该是一个动词或动作词。包将是一个名词,用来描述由包中的函数创建或修改的项目类型。遵循这些规则,您将创建一个名为names的包,其中包含两个函数,ExtractFormat。第一个将被称为names.Extract,第二个将被称为names.Format

您还应避免在包内函数和类型的名称中重复包的名称。当标识符的名称与包的名称相同时,此规则的例外情况会发生。例如,标准库中的sort包具有名为Sort的函数,而context包定义了Context接口。

覆盖包的名称

有时你会发现自己导入了两个包名冲突的包。例如,标准库包括两个生成随机数的包;一个是加密安全的(crypto/rand),另一个则不是(math/rand)。在不需要为加密生成随机数时,可以使用常规生成器,但需要用不可预测的值对其进行种子化。常见模式是使用加密生成器的值来初始化常规随机数生成器。在 Go 中,这两个包都有相同的名称(rand)。当发生这种情况时,在当前文件中为其中一个包提供替代名称。在Go Playground第十章库中的sample_code/package_name_override目录中尝试这段代码。首先,查看导入部分:

import (
    crand "crypto/rand"
    "encoding/binary"
    "fmt"
    "math/rand"
)

你用名称crand导入了crypto/rand。这样会覆盖包内声明的rand名称。然后你正常地导入了math/rand。当你查看seedRand函数时,你会看到你使用rand前缀访问math/rand中的标识符,并使用crand前缀访问crypto/rand包中的标识符:

func seedRand() *rand.Rand {
    var b [8]byte
    _, err := crand.Read(b[:])
    if err != nil {
        panic("cannot seed with cryptographic random number generator")
    }
    r := rand.New(rand.NewSource(int64(binary.LittleEndian.Uint64(b[:]))))
    return r
}
注意

你可以使用另外两个符号作为包名。包名.(点)将所有导出的标识符放入当前包的命名空间中;你不需要前缀来引用它们。这是不鼓励的,因为它会使你的源代码不够清晰。你不能仅仅通过看它的名称就知道某些东西是在当前包中定义的还是被导入的。

你还可以使用_(下划线)作为包名。当我讨论“尽量避免使用 init 函数”时,你将了解它的作用。

正如我在“变量遮蔽”中讨论的那样,包名可以被遮蔽。声明与包相同名称的变量、类型或函数会使得该包在声明的区块内无法访问。如果这是不可避免的(例如,新导入的包与现有标识符冲突),请修改包名以解决冲突。

使用 Go Doc 注释文档化您的代码

创建供他人使用的模块的一个重要部分是正确地进行文档化。Go 有自己的写入注释的格式,这些注释会自动转换为文档。它称为Go Doc格式,非常简单。以下是规则:

  • 将注释直接放在要文档化的项之前,注释与项的声明之间不要留空行。

  • 每行注释以双斜杠(//)开始,后跟一个空格。虽然使用//标记注释块是合法的,但习惯上使用双斜杠更为惯用。

  • 对于符号的注释(函数、类型、常量、变量或方法),注释中的第一个词应该是符号的名称。您也可以在符号名称前使用“A”或“An”来帮助使注释文本在语法上正确。

  • 使用空白注释行(双斜杠和一个换行符)将您的注释分成多个段落。

正如我将在“使用pkg.go.dev”中讨论的那样,您可以在线查看以 HTML 格式发布的公共文档。如果您想让您的文档看起来更加漂亮,有几种格式化方式:

  • 如果您希望您的注释包含一些预格式化的内容(如表格或源代码),请在双斜杠后面加一个额外的空格,以使内容的行缩进。

  • 如果您想在注释中添加标题,请在双斜杠后面放置 # 和一个空格。与 Markdown 不同,您不能使用多个 # 字符来创建不同级别的标题。

  • 要创建指向另一个包的链接(无论该包是否在当前模块中),请将包路径放在方括号内([])。

  • 要链接到导出的符号,请将其名称放在方括号中。如果该符号在另一个包中,请使用[pkgName.SymbolName]

  • 如果在注释中包含原始 URL,它将被转换为链接。

  • 如果您想要包含指向网页的文本,请将文本放在方括号内([])。在注释块的末尾,使用格式 // [*TEXT*]: *URL* 声明您的文本与其 URL 之间的映射。您将很快看到一个示例。

在包声明之前的注释创建包级别的注释。如果您对包有详尽的注释(例如fmt包中的广泛格式化文档),惯例是将注释放在名为 doc.go 的文件中。

让我们浏览一个有详细注释的文件,从示例 10-1 中的包级别注释开始。

示例 10-1. 一个包级别的注释
// Package convert provides various utilities to
// make it easy to convert money from one currency to another.
package convert

接下来,在一个导出的结构体上放置一个注释(参见示例 10-2)。注意它以结构体的名称开头。

示例 10-2. 一个结构体的注释
// Money represents the combination of an amount of money
// and the currency the money is in.
//
// The value is stored using a [github.com/shopspring/decimal.Decimal]
type Money struct {
    Value    decimal.Decimal
    Currency string
}

最后,这是一个函数的注释(参见示例 10-3)。

示例 10-3. 一个有详细注释的函数
// Convert converts the value of one currency to another.
//
// It has two parameters: a Money instance with the value to convert,
// and a string that represents the currency to convert to. Convert returns
// the converted currency and any errors encountered from unknown or unconvertible
// currencies.
//
// If an error is returned, the Money instance is set to the zero value.
//
// Supported currencies are:
//   - USD - US Dollar
//   - CAD - Canadian Dollar
//   - EUR - Euro
//   - INR - Indian Rupee
//
// More information on exchange rates can be found at [Investopedia].
//
// [Investopedia]: https://www.investopedia.com/terms/e/exchangerate.asp
func Convert(from Money, to string) (Money, error) {
    // ...
}

Go 包含一个名为go doc的命令行工具,用于显示 godoc 文档。命令go doc PACKAGE_NAME 会显示指定包的文档以及包中标识符的列表。使用go doc PACKAGE_NAME.IDENTIFIER_NAME 可以显示包中特定标识符的文档。

如果您想在文档发布到 Web 之前预览其 HTML 格式化,请使用pkgsite。这是与pkg.go.dev相同的程序(稍后在本章中将详细讨论)。要安装pkgsite,请使用以下命令:

$ go install golang.org/x/pkgsite/cmd/pkgsite@latest

(我将在“使用go install添加第三方工具”中详细讨论go install。)

要查看源代码的注释以 HTML 格式呈现,请转到您模块的根目录并运行以下命令:

$ pkgsite

然后去http://localhost:8080查看您的项目及其源代码。

您可以通过阅读官方的Go Doc Comments 文档找到有关注释和潜在陷阱的更多详细信息。

提示

确保适当地注释您的代码。至少,任何导出的标识符都应该有注释。在“使用代码质量扫描工具”中,您将看到一些第三方工具,它们可以报告导出标识符缺失的注释。

使用internal

有时候,您想要在模块中的多个包之间共享函数、类型或常量,但又不希望将其作为 API 的一部分。Go 通过特殊的internal包名来支持这一点。

当您创建一个名为internal的包时,该包及其子包中的导出标识符仅对internal的直接父包和internal的同级包可访问。让我们通过一个示例来看看它是如何工作的。您可以在GitHub上找到该代码。目录树如图 10-1 所示。

您在internal包的internal.go文件中声明了一个简单的函数:

func Doubler(a int) int {
    return a * 2
}

您可以从foo包中的foo.gosibling包中的sibling.go访问此函数。

显示内部包可以使用的文件树

图 10-1. internal_package_example的文件树

请注意,试图从bar包的bar.go或根包的example.go中使用内部函数会导致编译错误:

$ go build ./...
package github.com/learning-go-book-2e/internal_example
example.go:3:8: use of internal package
github.com/learning-go-book-2e/internal_example/foo/internal not allowed

package github.com/learning-go-book-2e/internal_example/bar
bar/bar.go:3:8: use of internal package
github.com/learning-go-book-2e/internal_example/foo/internal not allowed

避免循环依赖

Go 语言的两个目标是快速编译器和易于理解的源代码。为支持这一点,Go 不允许包之间存在循环依赖。如果包 A 直接或间接导入包 B,那么包 B 就不能直接或间接导入包 A。

让我们看一个快速示例来解释这个概念。您可以在第十章存储库sample_code/circular_dependency_example目录中找到代码。有两个包,petperson。在pet包的pet.go中,您有以下内容:

import "github.com/learning-go-book-2e/ch10/sample_code/
circular_dependency_example/person"

var owners = map[string]person.Person{
	"Bob":   {"Bob", 30, "Fluffy"},
	"Julia": {"Julia", 40, "Rex"},
}

而在person包的person.go中,您有以下内容:

import "github.com/learning-go-book-2e/ch10/sample_code/
circular_dependency_example/pet"

var pets = map[string]pet.Pet{
	"Fluffy": {"Fluffy", "Cat", "Bob"},
	"Rex":    {"Rex", "Dog", "Julia"},
}

如果您尝试构建此程序,将会收到一个错误:

$ go build ./sample_code/circular_dependency_example
package github.com/learning-go-book-2e/ch10/sample_code/
    circular_dependency_example
	imports github.com/learning-go-book-2e/ch10/sample_code/
        circular_dependency_example/person
	imports github.com/learning-go-book-2e/ch10/sample_code/
        circular_dependency_example/pet
	imports github.com/learning-go-book-2e/ch10/sample_code/
        circular_dependency_example/person: import cycle not allowed

如果您发现自己处于循环依赖中,您有几个选择。在某些情况下,这是由于将包分割得过细造成的。如果两个包彼此依赖,它们应该合并为一个单独的包的可能性很高。您可以将personpet包合并为一个包,问题就解决了。

如果您有充分的理由保持包的分离,可能可以将导致循环依赖的项仅移动到两个包中的一个或者移动到一个新包中。

组织您的模块

没有官方的方式来组织 Go 模块中的包,但是多年来出现了几种模式。它们的指导原则是你应该专注于使你的代码易于理解和维护。

当你的模块很小的时候,将所有代码放在一个包中。只要没有其他模块依赖于你的模块,延迟组织是没有害处的。

当你的模块逐渐增大时,你会希望进行一些整理,以使你的代码更易读。首先要问的问题是你正在创建什么类型的模块。你可以将模块分为两大类:那些用作单个应用程序的模块和主要用作库的模块。如果你确定你的模块只用作应用程序,那么将项目的根目录命名为main包。main包中的代码应该尽量精简;将所有逻辑放在一个internal目录中,而main函数中的代码仅需调用internal中的代码。这样,你可以确保没有人会创建依赖于你应用程序实现的模块。

如果你希望你的模块作为库被使用,那么你的模块根目录应该有一个与存储库名称匹配的包名。这确保了导入名称与包名匹配。为使其工作,你必须确保你的存储库名称是有效的 Go 标识符。特别地,你不能在存储库名称中使用连字符作为单词分隔符,因为连字符在包名中不是有效的字符。

对于库模块而言,包含一个或多个与其一起作为实用工具的应用程序是很常见的。在这种情况下,在你的模块根目录下创建一个名为cmd的目录。在cmd中,为从你的模块构建的每个二进制文件创建一个目录。例如,你可能有一个模块,其中既包含一个 Web 应用程序,又包含一个分析 Web 应用程序数据库中数据的命令行工具。在这些目录中,使用main作为包名。

更详细的信息,请参考 Eli Bendersky 的这篇博文,它提供了关于如何组织一个简单 Go 模块的良好建议。

随着项目变得更加复杂,你会有将包拆分的冲动。确保组织你的代码以限制包之间的依赖关系。一个常见的模式是按功能划分代码。例如,如果你在 Go 语言中编写了一个购物网站,你可以将所有客户管理的代码放在一个包中,将所有库存管理的代码放在另一个包中。这种风格限制了包之间的依赖关系,使得以后将单个 Web 应用程序重构为多个微服务变得更容易。这种风格与许多 Java 应用程序的组织方式相反,后者将所有业务逻辑放在一个包中,所有数据库逻辑放在另一个包中,将数据传输对象放在第三个包中。

在开发库时,请利用 internal 包。如果在模块中创建多个包,并且它们不在 internal 包中,则导出符号以便其他包在您的模块中使用,这意味着任何人都可以使用它。在软件工程中有一个原则叫做 Hyrum’s law:“只要 API 有足够多的用户,那么您在合同中承诺的内容就无关紧要:系统的所有可观察行为都会被某人依赖。”一旦某物成为您的 API 的一部分,您有责任继续支持它,直到决定发布一个不向后兼容的新版本。您将在 “更新到不兼容版本” 中学习如何做到这一点。如果您有一些符号希望仅在模块内共享,请将它们放在 internal 中。如果您改变主意,随时可以稍后将包移出 internal

要了解有关 Go 项目结构建议的概述,请观看 Kat Zien 在 GopherCon 2018 的演讲“你如何组织你的 Go 应用程序”

警告

GitHub 上的 “golang-standards” 仓库声称是 “标准” 模块布局。Go 的开发主管 Russ Cox 已经公开表示这不被 Go 团队认可,并且它推荐的结构实际上是反模式。请不要将此仓库作为组织代码的方法。

优雅地重命名和重新组织您的 API

使用模块一段时间后,您可能会意识到其 API 不理想。您可能想要重命名一些导出标识符或将它们移动到模块中的另一个包中。为避免破坏向后兼容性的变更,请勿移除原始标识符;而是提供替代名称。

对于函数或方法,这很容易。您声明一个调用原始函数或方法的函数或方法。对于常量,只需声明一个新常量,类型和值相同,但名称不同。

如果您想重命名或移动一个导出类型,可以使用别名。简单地说,别名 是类型的新名称。您在第七章 中看到如何使用 type 关键字基于现有类型声明一个新类型。您也可以使用 type 关键字声明一个别名。假设您有一个名为 Foo 的类型:

type Foo struct {
    x int
    S string
}

func (f Foo) Hello() string {
    return "hello"
}

func (f Foo) goodbye() string {
    return "goodbye"
}

如果您想让用户通过名称 Bar 访问 Foo,您所需做的只是这样:

type Bar = Foo

要创建别名,请使用 type 关键字、别名的名称、一个等号以及原始类型的名称。别名具有与原始类型相同的字段和方法。

别名甚至可以分配给原始类型的变量,而无需进行类型转换:

func MakeBar() Bar {
    bar := Bar{
        x: 20,
        S: "Hello",
    }
    var f Foo = bar
    fmt.Println(f.Hello())
    return bar
}

重要的一点要记住:别名只是类型的另一个名称。如果您想要添加新的方法或更改别名结构中的字段,必须将它们添加到原始类型中。

你可以为在同一个包中定义的原始类型或在不同包中定义的类型设置别名。甚至可以为另一个模块中的类型设置别名。在另一个包中设置别名的一个缺点是:你不能使用别名来引用原始类型的未导出方法和字段。这种限制是有道理的,因为别名存在的目的是允许逐步更改包的 API,并且 API 只包括包的导出部分。为了解决这个限制,调用类型的原始包中的代码来操作未导出的字段和方法。

两种类型的导出标识符不能有备用名称。第一种是包级变量。第二种是结构体中的字段。一旦为导出的结构体字段选择了名称,就没有办法创建备用名称。

避免使用 init 函数

当你阅读 Go 代码时,通常清楚哪些方法和函数被调用。Go 没有方法重写或函数重载的一个原因是为了更容易理解哪些代码正在运行。然而,有一种方法可以在不显式调用任何内容的情况下设置包中的状态:init 函数。当你声明一个名为 init 的函数,它不带参数并且不返回任何值时,它会在另一个包首次引用该包时运行。由于 init 函数没有任何输入或输出,它们只能通过副作用工作,与包级别的函数和变量进行交互。

init 函数还有另一个独特的特性。Go 允许在单个包中或甚至在包中的单个文件中声明多个 init 函数。有一个记录的顺序来运行单个包中的多个 init 函数,但与其记住它,不如简单地避免使用它们。

有些包,比如数据库驱动程序,使用 init 函数来注册数据库驱动程序。然而,你并不使用包中的任何标识符。正如前面提到的,Go 不允许你有未使用的导入。为了解决这个问题,Go 允许空白导入,其中分配给导入的名称是下划线 (_)。正如下划线允许你跳过从函数返回的未使用值一样,空白导入触发包中的 init 函数,但不允许你访问包中的任何导出标识符:

import (
    "database/sql"

    _ "github.com/lib/pq"
)

这种模式被认为是过时的,因为不清楚是否正在执行注册操作。Go 对其标准库的兼容性保证意味着你被限制使用它来注册数据库驱动程序和图像格式,但如果你在自己的代码中有一个注册模式,要显式地注册你的插件。

今天init函数的主要用途是初始化不能在单个赋值中配置的包级变量。在包的顶层有可变状态是个坏主意,因为这会使得理解数据在你的应用程序中的流动变得更加困难。这意味着通过init配置的任何包级变量都应该是有效不可变的。虽然 Go 没有提供强制确保它们的值不会改变的方法,但你应确保你的代码不会改变它们。如果你有需要在程序运行时修改的包级变量,看看是否可以重构你的代码,将该状态放入一个由包中的函数初始化并返回的结构体中。

隐式调用init函数意味着你应该记录它们的行为。例如,一个包含init函数的包,用于加载文件或访问网络,应该在包级别的注释中说明,以便对代码安全性敏感的用户不会因意外的 I/O 而感到惊讶。

使用模块

你已经学习了如何在单个模块内使用包,现在是时候学习如何与第三方模块及其内部的包集成了。接下来,你将学习如何发布和版本化你自己的模块,以及 Go 的集中服务:pkg.go.dev、模块代理和校验和数据库。

导入第三方代码

到目前为止,你已经从标准库中导入了fmterrorsosmath等包。Go 使用相同的导入系统来集成来自第三方的包。与许多其他编译语言不同,Go 总是将应用程序从源代码构建成单个二进制文件。这包括你的模块的源代码以及你的模块依赖的所有模块的源代码。(Go 编译器足够智能,不会在生成的二进制文件中包含未引用的包。)正如你从自己的模块内导入包时看到的那样,当你导入第三方包时,你需要指定源代码仓库中包的位置。

让我们来看一个例子。我在第二章中提到,当你需要准确表示小数时,永远不要使用浮点数。如果确实需要准确的表示,一个好的选择是来自ShopSpringdecimal模块。你还将看到我为本书编写的一个简单的格式化模块。这两个模块都在本书的money repository中的一个小程序中使用。该程序计算包含税款的商品价格,并以整洁的格式输出。

下面的代码在main.go中:

package main

import (
    "fmt"
    "log"
    "os"

    "github.com/learning-go-book-2e/formatter"
    "github.com/shopspring/decimal"
)

func main() {
    if len(os.Args) < 3 {
        fmt.Println("Need two parameters: amount and percent")
        os.Exit(1)
    }
    amount, err := decimal.NewFromString(os.Args[1])
    if err != nil {
        log.Fatal(err)
    }
    percent, err := decimal.NewFromString(os.Args[2])
    if err != nil {
        log.Fatal(err)
    }
    percent = percent.Div(decimal.NewFromInt(100))
    total := amount.Add(amount.Mul(percent)).Round(2)
    fmt.Println(formatter.Space(80, os.Args[1], os.Args[2],
                                total.StringFixed(2)))
}

两个导入github.com/learning-go-book-2e/formattergithub.com/shopspring/decimal指定了第三方导入。请注意,它们包含包在存储库中的位置。一旦导入,您可以像任何其他导入的包一样访问这些包中的导出项。

在构建应用程序之前,请查看go.mod文件。其内容应如下所示:

module github.com/learning-go-book-2e/money

go 1.20

如果尝试构建,会收到以下消息:

$ go build
main.go:8:2: no required module provides package
    github.com/learning-go-book-2e/formatter; to add it:
        go get github.com/learning-go-book-2e/formatter
main.go:9:2: no required module provides package
    github.com/shopspring/decimal; to add it:
        go get github.com/shopspring/decimal

正如错误所指出的,直到您向go.mod文件添加对第三方模块的引用,才能构建程序。go get命令下载模块并更新go.mod文件。在使用go get时有两个选项。最简单的选项是告诉go get扫描您模块的源代码,并添加任何在import语句中找到的模块到go.mod中:

$ go get ./...
go: downloading github.com/shopspring/decimal v1.3.1
go: downloading github.com/learning-go-book-2e/formatter
    v0.0.0-20220918024742-1835a89362c9
go: downloading github.com/fatih/color v1.13.0
go: downloading github.com/mattn/go-colorable v0.1.9
go: downloading github.com/mattn/go-isatty v0.0.14
go: downloading golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c
go: added github.com/fatih/color v1.13.0
go: added github.com/learning-go-book-2e/formatter
    v0.0.0-20220918024742-1835a89362c9
go: added github.com/mattn/go-colorable v0.1.9
go: added github.com/mattn/go-isatty v0.0.14
go: added github.com/shopspring/decimal v1.3.1
go: added golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c

由于包的位置在源代码中,go get能够获取包的模块并下载它。如果现在查看go.mod文件,您将看到这个内容:

module github.com/learning-go-book-2e/money

go 1.20

require (
    github.com/learning-go-book-2e/formatter v0.0.0-20220918024742-1835a89362c9
    github.com/shopspring/decimal v1.3.1
)

require (
    github.com/fatih/color v1.13.0 // indirect
    github.com/mattn/go-colorable v0.1.9 // indirect
    github.com/mattn/go-isatty v0.0.14 // indirect
    golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c // indirect
)

go.mod文件的第一个require部分列出了导入到您模块的模块。模块名后面是版本号。对于formatter模块来说,它没有版本标签,因此 Go 会生成一个伪版本

您还会看到第二个require指令部分,其中的模块带有indirect注释。这些模块中的一个(github.com/fatih/color)直接被formatter使用。而formatter又依赖于第二个require指令部分的其他三个模块。您模块的所有依赖(及其依赖的依赖等等)使用的所有模块都包含在您模块的go.mod文件中。只在依赖中使用的模块标记为间接。

除了更新go.mod文件外,还创建了一个go.sum文件。您项目的依赖树中每个模块在go.sum文件中都有一到两个条目:一个是模块及其版本及模块哈希的条目;另一个是模块的go.mod文件的哈希条目。下面是go.sum文件的示例:

github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs...
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46q...
github.com/learning-go-book-2e/formatter v0.0.0-20220918024742-1835a8...
github.com/learning-go-book-2e/formatter v0.0.0-20220918024742-1835a8...
github.com/mattn/go-colorable v0.1.9 h1:sqDoxXbdeALODt0DAeJCVp38ps9Zo...
github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJr...
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1...
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP...
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj...
github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG...
github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WC...
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce...
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce...
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c h1:F1jZWGFhYfh0Ci...
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1M...

您会看到这些哈希用途在“模块代理服务器”中。您可能还注意到某些依赖项有多个版本。我将在“最小版本选择”中讨论这一点。

让我们验证一下您的模块现在是否设置正确。再次运行go build,然后运行money二进制文件并传递一些参数:

$ go build
$ ./money 99.99 7.25
99.99           7.25                                                 107.24

注意

此示例程序已经检入,但没有go.sum文件,并且go.mod文件不完整。这样做是为了让您看到这些文件填充后的效果。在提交自己的模块到源代码控制时,请务必包含最新的go.modgo.sum文件。这样做可以明确指定正在使用的依赖项的确切版本。这样可以实现可重复构建;当其他人(包括未来的您自己)构建此模块时,他们将获得完全相同的二进制文件。

正如我之前提到的,还有另一种使用 go get 的方式。而不是让它扫描你的源代码来发现模块,你可以直接传递模块路径给 go get。要看到这一点的效果,请回滚对 go.mod 文件的更改并删除 go.sum 文件。在类 Unix 系统上,以下命令将执行此操作:

$ git restore go.mod
$ rm go.sum

现在,直接将模块路径传递给 go get

$ go get github.com/learning-go-book-2e/formatter
go: added github.com/learning-go-book-2e/
    formatter v0.0.0-20200921021027-5abc380940ae
$ go get github.com/shopspring/decimal
go: added github.com/shopspring/decimal v1.3.1

注意

一些眼尖的读者可能已经注意到,当我们第二次使用 go get 时,并没有显示 go: downloading 的消息。原因是 Go 在你的本地计算机上维护了一个 模块缓存。一旦下载了模块的某个版本,就会在缓存中保留一个副本。源代码非常紧凑,而驱动器非常大,所以通常不会引起关注。然而,如果你想删除模块缓存,可以使用命令 go clean -modcache

查看 go.mod 的内容,它们会与之前略有不同:

module github.com/learning-go-book-2e/money

go 1.20

require (
    github.com/fatih/color v1.13.0 // indirect
    github.com/learning-go-book-2e/
    formatter v0.0.0-20220918024742-1835a89362c9 // indirect
    github.com/mattn/go-colorable v0.1.9 // indirect
    github.com/mattn/go-isatty v0.0.14 // indirect
    github.com/shopspring/decimal v1.3.1 // indirect
    golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c // indirect
)

请注意,所有的导入都标记为 indirect,而不仅仅是来自 formatter 的导入。当你运行 go get 并传递一个模块名时,它不会检查你的源代码以确定你指定的模块是否在你的主模块中使用。为了安全起见,它添加了一个间接注释。

如果你想自动修复这个标签,请使用命令 go mod tidy。它会扫描你的源代码,并与你模块的源代码同步 go.modgo.sum 文件,添加和删除模块引用。它还确保间接注释是正确的。

也许你想知道为什么要费心使用 go get 来管理模块名称。原因在于它允许你更新单个模块的版本。

版本管理

让我们看看 Go 的模块系统如何使用版本。我写了一个简单的模块,你将在另一个税收程序中使用它。在 main.go 中,有以下第三方导入:

"github.com/learning-go-book-2e/simpletax"
"github.com/shopspring/decimal"

与之前一样,示例程序没有与 go.modgo.sum 一起检入,因此你可以看到发生了什么。当构建程序时,你会看到以下内容:

$ go get ./...
go: downloading github.com/learning-go-book-2e/simpletax v1.1.0
go: added github.com/learning-go-book-2e/simpletax v1.1.0
go: added github.com/shopspring/decimal v1.3.1
$ go build

go.mod 文件已更新:

module github.com/learning-go-book-2e/region_tax

go 1.20

require (
    github.com/learning-go-book-2e/simpletax v1.1.0
    github.com/shopspring/decimal v1.3.1
)

你的依赖关系还有一个 go.sum 文件,其中包含哈希值。运行代码,看看是否工作正常:

$ ./region_tax 99.99 12345
2022/09/19 22:04:38 unknown zip: 12345

看起来这个答案有些出乎意料。模块的最新版本可能存在 bug。默认情况下,当你将一个依赖项添加到你的模块时,Go 会选择其最新版本。然而,版本控制的一个有用之处在于,你可以指定一个较早的模块版本。首先,你可以用 go list 命令查看模块的所有版本:

$ go list -m -versions github.com/learning-go-book-2e/simpletax
github.com/learning-go-book-2e/simpletax v1.0.0 v1.1.0

默认情况下,go list 命令列出你模块中使用的包。使用 -m 标志可以改变输出,列出模块而非包;-versions 标志则改变 go list 的输出,报告指定模块的可用版本。在这种情况下,你会看到有两个版本,v1.0.0 和 v1.1.0. 让我们降级到版本 v1.0.0,看看是否能解决你的问题。你可以使用 go get 命令来做到这一点:

$ go get github.com/learning-go-book-2e/simpletax@v1.0.0
go: downloading github.com/learning-go-book-2e/simpletax v1.0.0
go: downgraded github.com/learning-go-book-2e/simpletax v1.1.0 => v1.0.0

go get 命令允许你使用模块,更新你的依赖项版本。

现在如果你查看 go.mod,你会看到版本已经更改了:

module github.com/learning-go-book-2e/region_tax

go 1.20

require (
    github.com/learning-go-book-2e/simpletax v1.0.0
    github.com/shopspring/decimal v1.3.1
)

你还可以在 go.sum 中看到 simpletax 包含两个版本:

github.com/learning-go-book-2e/simpletax v1.0.0 h1:KZU8aXRCHkvgFmBWkV...
github.com/learning-go-book-2e/simpletax v1.0.0/go.mod h1:lR4YYZwbDTI...
github.com/learning-go-book-2e/simpletax v1.1.0 h1:sG83gscauX/b8yKKY9...
github.com/learning-go-book-2e/simpletax v1.1.0/go.mod h1:lR4YYZwbDTI...

这没问题;如果你更改模块的版本,甚至从你的模块中删除一个模块,它的条目可能仍然会留在 go.sum 中。这不会引起问题。

当你再次构建和运行代码时,问题已经解决了:

$ go build
$ ./region_tax 99.99 12345
107.99

最小版本选择

在某些情况下,你的模块将依赖于两个或更多依赖于同一模块的模块。通常情况下,这些模块声明依赖于该模块的不同次要或修订版本。Go 如何解决这个问题?

模块系统使用最小版本选择原则:你总是会获得在所有依赖项的 go.mod 文件中都声明可以工作的最低版本的依赖项。假设你的模块直接依赖于模块 A、B 和 C。这三个模块都依赖于模块 D。模块 A 的 go.mod 文件声明依赖于 v1.1.0,模块 B 声明依赖于 v1.2.0,模块 C 声明依赖于 v1.2.3. Go 只会导入模块 D 一次,并且它会选择 v1.2.3 版本,因为这是满足所有要求的最低版本,如 Go 模块参考 所述。

你可以在 “导入第三方代码” 中的示例程序中看到这个操作。命令 go mod graph 显示了你模块及其所有依赖项的依赖图。以下是部分输出:

github.com/learning-go-book-2e/money github.com/fatih/color@v1.13.0
github.com/learning-go-book-2e/money github.com/mattn/go-colorable@v0.1.9
github.com/learning-go-book-2e/money github.com/mattn/go-isatty@v0.0.14
github.com/fatih/color@v1.13.0 github.com/mattn/go-colorable@v0.1.9
github.com/fatih/color@v1.13.0 github.com/mattn/go-isatty@v0.0.14
github.com/mattn/go-colorable@v0.1.9 github.com/mattn/go-isatty@v0.0.12

每一行列出两个模块,第一个是父模块,第二个是依赖及其版本。你会注意到 github.com/fatih/color 模块依赖于 github.com/mattn/go-isatty 的 v0.0.14 版本,而 github.com/mattn/go-colorable 则依赖于 v0.0.12. Go 编译器选择使用版本 v0.0.14,因为它是满足所有要求的最低版本。尽管截至撰写本文时,github.com/mattn/go-isatty 的最新版本是 v0.0.16,但你的最低版本要求满足了 v0.0.14,所以就使用了这个版本。

这个系统并不完美。您可能会发现,虽然模块 A 与模块 D 的版本 v1.1.0 兼容,但却无法与版本 v1.2.3 兼容。那么该怎么办呢?Go 的答案是,您需要联系模块作者修复其不兼容性。导入兼容性规则说,“如果旧包和新包具有相同的导入路径,则新包必须向后兼容旧包。”一个模块的所有次要和补丁版本必须向后兼容。如果不兼容,则是一个 bug。在我们的假设示例中,要么模块 D 需要修复因为它打破了向后兼容性,要么模块 A 需要修复因为它对模块 D 行为的假设是错误的。

你可能不会觉得这个答案令人满意,但它是诚实的。一些构建系统,如 npm,将包含同一包的多个版本。这可能会引入一系列自己的 bug,尤其是在存在包级状态时。它还会增加应用程序的大小。最终,有些问题最好由社区而不是代码来解决。

更新到兼容版本

如果您明确希望升级依赖关系怎么办?假设在编写初始程序后,simpletax 还有三个版本。第一个修复了初始 v1.1.0 版本中的问题。由于这是一个修复 bug 的补丁发布,没有新功能,它将发布为 v1.1.1。第二个保留当前功能,但还添加了一个新功能。它将版本号为 v1.2.0。最后一个修复了在版本 v1.2.0 中发现的 bug。它的版本号是 v1.2.1。

要升级到当前次要版本的错误修复版本,请使用命令 go get -u=patch github.com/learning-go-book-2e/simpletax。由于您已降级到 v1.0.0,因此将保持在该版本,因为没有具有相同次要版本的修补版本。

使用 go get github.com/learning-go-book-2e/simpletax@v1.1.0 升级到版本 v1.1.0,然后运行 go get -u=patch github.com/learning-go-book-2e/​sim⁠pletax。这将升级版本至 v1.1.1。

最后,使用命令 go get -u github.com/learning-go-book-2e/simpletax 来获取 simpletax 的最新版本。这将使您升级到版本 v1.2.1。

更新到不兼容的版本

让我们回到程序。你们正在扩展到加拿大,幸运的是,simpletax 模块的一个版本同时处理美国和加拿大。然而,这个版本与之前的版本有稍微不同的 API,因此其版本为 v2.0.0。

为处理不兼容性,Go 模块遵循语义化导入版本规则。该规则有两部分:

  • 模块的主要版本必须递增。

  • 对于除了 0 和 1 之外的所有主要版本,模块的路径必须以 vN结尾,其中N是主要版本号。

路径更改是因为导入路径唯一标识一个包。根据定义,不同版本的包不属于同一个包。使用不同的路径意味着您可以在程序的不同部分导入两个不兼容的包版本,从而实现平滑升级。

让我们看看这如何改变程序。首先,将simpletax的导入更改为以下内容:

"github.com/learning-go-book-2e/simpletax/v2"

这将把您的导入改为指向 v2 模块。

接下来,将 main 中的代码更改为以下内容:

func main() {
    amount, err := decimal.NewFromString(os.Args[1])
    if err != nil {
        log.Fatal(err)
    }
    zip := os.Args[2]
    country := os.Args[3]
    percent, err := simpletax.ForCountryPostalCode(country, zip)
    if err != nil {
        log.Fatal(err)
    }
    total := amount.Add(amount.Mul(percent)).Round(2)
    fmt.Println(total)
}

程序现在从命令行读取第三个参数,即国家代码。程序还在simpletax包中调用不同的函数。当您运行go get ./...时,依赖项将自动更新:

$ go get ./...
go: downloading github.com/learning-go-book-2e/simpletax/v2 v2.0.0
go: added github.com/learning-go-book-2e/simpletax/v2 v2.0.0

构建并运行程序,查看新输出:

$ go build
$ ./region_tax 99.99 M4B1B4 CA
112.99
$ ./region_tax 99.99 12345 US
107.99

查看 go.mod 文件时,您会看到包括新版本的 simpletax

module github.com/learning-go-book-2e/region_tax

go 1.20

require (
    github.com/learning-go-book-2e/simpletax v1.0.0
    github.com/learning-go-book-2e/simpletax/v2 v2.0.0
    github.com/shopspring/decimal v1.3.1
)

go.sum也已更新:

github.com/learning-go-book-2e/simpletax v1.0.0 h1:KZU8aXRCHkvgFmBWkV...
github.com/learning-go-book-2e/simpletax v1.0.0/go.mod h1:lR4YYZwbDTI...
github.com/learning-go-book-2e/simpletax v1.1.0 h1:sG83gscauX/b8yKKY9...
github.com/learning-go-book-2e/simpletax v1.1.0/go.mod h1:lR4YYZwbDTI...
github.com/learning-go-book-2e/simpletax/v2 v2.0.0 h1:EUFWy1BBA2omgkm...
github.com/learning-go-book-2e/simpletax/v2 v2.0.0/go.mod h1:yGLh6ngH...
github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG...
github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WC...

即使不再使用,旧版本的 simpletax 仍然被引用。使用 go mod tidy 删除那些未使用的版本。然后您将只看到 go.modgo.sum 中引用的 v2.0.0 版本的 simpletax

Vendoring

为了确保模块始终使用相同的依赖项构建,一些组织喜欢在其模块内部保留依赖项的副本。这称为 vendoring。通过运行命令 go mod vendor 启用它。这将在您的模块顶层创建一个名为 vendor 的目录,其中包含所有您模块的依赖项。这些依赖项用于替代存储在您计算机上的模块缓存。

如果在 go.mod 中添加了新的依赖项,或者使用 go get 升级了现有依赖项的版本,您需要再次运行 go mod vendor 更新 vendor 目录。如果忘记执行此操作,go buildgo rungo test 将显示错误消息并拒绝运行。

较旧的 Go 依赖管理系统需要进行 vendoring,但随着 Go 模块和代理服务器的出现(详见“模块代理服务器”),这一做法已不再受欢迎。您仍然可能想要进行 vendoring 的一个原因是在某些 CI/CD(持续集成/持续交付)流水线中,这样可以加快构建代码的速度和效率。如果流水线的构建服务器是短暂存在的,模块缓存可能不会被保留。将依赖项 vendoring 可以避免每次触发构建时都进行多次网络调用以下载依赖项。不过,其缺点是会显著增加您的代码库在版本控制中的大小。

使用 pkg.go.dev

虽然没有单一的 Go 模块集中存储库,但有一个单一的服务收集 Go 模块的文档信息。Go 团队创建了一个名为pkg.go.dev的站点,它自动索引开源 Go 模块。对于每个模块,包索引发布了 godocs、使用的许可证、README、模块的依赖项以及依赖于该模块的开源模块。你可以在图 10-2 中看到pkg.go.dev对你的simpletax模块的信息。

图 10-2. 使用 pkg.go.dev 查找并了解第三方模块

发布你的模块

使你的模块对其他人可用就像将其放入版本控制系统一样简单。无论你是在公共版本控制系统(如 GitHub)还是你自己或公司托管的私有版本控制系统上发布你的模块作为开源,都适用这一点。由于 Go 程序从源代码构建并使用仓库路径来标识自己,所以不需要显式地将你的模块上传到像 Maven Central 或 npm 这样的中央库仓库。确保同时检入你的go.mod文件和go.sum文件。

注意

大多数 Go 开发者使用 Git 进行版本控制,但 Go 也支持 Subversion、Mercurial、Bazaar 和 Fossil。默认情况下,Git 和 Mercurial 可用于公共存储库,任何支持的版本控制系统都可用于私有存储库。有关详细信息,请查看Go 模块的版本控制系统文档

在发布开源模块时,你应该在仓库根目录中包含一个名为LICENSE的文件,该文件指定了你发布代码的开源许可证。It’s FOSS是一个了解各种开源许可证的好资源。

大致上,你可以将开源许可证分为两类:宽松许可(允许你的代码使用者保持其代码私有)和非宽松许可(要求你的代码使用者将其代码开源)。虽然你可以选择自己喜欢的许可证,但 Go 社区更青睐宽松许可证,例如 BSD、MIT 和 Apache。由于 Go 直接将第三方代码编译到每个应用程序中,使用类似 GPL 的非宽松许可证将要求使用你的代码的人也将他们的代码开源化。对于许多组织来说,这是不可接受的。

最后一点需要注意:不要编写自己的许可证。很少有人会相信这种许可证已经得到律师的适当审查,他们也无法知道你在他们的模块上作出了什么声明。

模块版本控制

无论你的模块是公共的还是私有的,你都应该正确地为你的模块版本化,以便它能够与 Go 的模块系统正确地工作。只要你在添加功能或修复错误,这个过程就很简单。将你的更改存储在你的源代码仓库中,然后应用一个遵循我在“语义化版本控制”中讨论的语义版本规则的标签。

Go 的语义化版本支持预发布的概念。假设你的模块当前版本标记为v1.3.4。你正在开发的版本是 1.4.0,虽然还没有完成,但你想尝试将其导入另一个模块。你应该在你的版本标签的末尾添加一个连字符(-),然后跟上预发布构建的标识符。在这种情况下,使用如v1.4.0-beta1来表示版本 1.4.0 的 beta 1 或v1.4.0-rc2来表示发布候选 2。如果你想依赖于一个预发布候选版本,你必须在go get中显式地指定它的版本,因为 Go 不会自动选择预发布版本。

如果你达到了需要破坏向后兼容性的程度,这个过程会变得更加复杂。正如你在导入simpletax模块的第 2 版时所看到的,一个向后兼容性的变化需要不同的导入路径。需要执行几个步骤。

首先,你需要选择一种存储新版本的方式。Go 支持两种方式来创建不同的导入路径:

  • 在你的模块中创建一个名为vN的子目录,其中N是你的模块的主要版本。例如,如果你正在创建模块的第 2 版,将此目录命名为v2。将你的代码复制到这个子目录中,包括READMELICENSE文件。

  • 在你的版本控制系统中创建一个分支。你可以将旧代码或新代码放在新分支上。如果将新代码放在分支上,则将分支命名为vN;如果将旧代码放在那里,则将分支命名为vN-1。例如,如果你正在创建模块的第 2 版并希望将第 1 版代码放在分支上,则将分支命名为v1

在决定如何存储你的新代码之后,你需要在子目录或分支的代码中更改导入路径。你的go.mod文件中的模块路径必须以/vN结尾,并且模块内的所有导入也必须使用/vN。遍历所有代码可能会很繁琐,但是 Marwan Sulaiman 已经创建了一个自动化工具来完成这项工作。一旦路径修复好了,就可以开始实施你的更改。

注意

从技术上讲,你可以只修改go.mod和你的导入语句,标记你的主分支为最新版本,而不必费心使用子目录或带版本的分支。然而,这并不是一个好的实践,因为它会让人不清楚在哪里找到你模块的旧主要版本。

当您准备发布新代码时,在您的代码库上放置一个标签,格式如 vN.0.0. 如果您使用子目录系统或将最新代码放在主分支上,请在主分支上打标签。如果您将新代码放在不同的分支上,请在该分支上打标签。

您可以在文章 “Go 模块:v2 及更高版本” 中和 “开发主要版本更新” 中找到关于将代码更新到不兼容版本的更多细节。

覆盖依赖项

分支会发生。虽然开源社区对分支持有偏见,但有时模块停止维护,或者您想要尝试未被模块所有者接受的更改。replace 指令会将所有依赖您模块的模块中的所有模块引用重定向,并用指定的模块分支替换它们。它看起来像这样:

replace github.com/jonbodner/proteus => github.com/someone/my_proteus v1.0.0

左边的 => 指定了原始模块的位置,右边是替换后的位置。右边必须指定一个版本,但左边的版本是可选的。如果左边指定了一个版本,那么只会替换这个特定版本的原始模块。如果没有指定版本,任何版本的原始模块都将被替换为分支的特定版本。

replace 指令也可以引用本地文件系统上的路径:

replace github.com/jonbodner/proteus => ../projects/proteus

在本地的 replace 指令中,必须省略右边的模块版本。

警告

避免使用本地 replace 指令。在 Go 工作区被发明之前,它们提供了一种同时修改多个模块的方法,但现在它们是产生破坏模块的潜在来源。如果通过版本控制共享您的模块,那么带有 replace 指令本地引用的模块可能不会为其他人构建,因为您不能保证其他人在他们的驱动器上具有相同位置的替换模块。

您也可能希望阻止使用模块的特定版本。也许它有一个 bug 或与您的模块不兼容。Go 提供了 exclude 指令来阻止使用模块的特定版本:

exclude github.com/jonbodner/proteus v0.10.1

当一个模块版本被排除时,任何依赖模块中对该模块版本的提及都会被忽略。如果排除了一个模块版本,并且这是您的模块依赖中唯一需要的那个模块版本,请使用 go get 将另一个版本的该模块间接导入到您模块的 go.mod 文件中,以便您的模块仍然可以编译。

撤销模块的版本

迟早,你会意外发布一个你不希望任何人使用的模块版本。也许在测试完成之前不小心发布了它。也许在发布后,发现了一个关键性漏洞,不再允许任何人使用它。不管原因是什么,Go 为你提供了一种方法,可以指示应忽略某些模块版本。这是通过在你的模块的go.mod文件中添加retract指令来完成的。它由单词retract和不应再使用的语义版本组成。如果一个版本范围不应该使用,你可以通过在方括号内放置上下界来排除该范围内的所有版本,用逗号分隔。虽然不是必需的,但你应该在版本或版本范围后面加上注释,解释撤回的原因。

如果你希望撤回多个非连续版本,你可以用多个retract指令来指定它们。在所示的示例中,版本 1.5.0 被排除,以及从 1.7.0 到 1.8.5(含)的所有版本:

retract v1.5.0 // not fully tested
retract [v1.7.0, v.1.8.5] // posts your cat photos to LinkedIn w/o permission

retract指令添加到go.mod文件需要你创建模块的新版本。如果新版本仅包含撤回操作,则应将其撤回。

当版本被撤回时,已指定该版本的现有构建将继续工作,但go getgo mod tidy将不会升级到这些版本。当你使用go list命令时,它们将不再显示为选项。如果模块的最新版本被撤回,它将不再与@latest匹配;而是将匹配最高的未撤回版本。

注意

虽然retract可能会与exclude混淆,但它们之间有一个非常重要的区别。你使用retract来防止他人使用你的模块的特定版本。exclude指令则阻止你使用另一个模块的版本。

使用工作空间同时修改模块

使用你的源代码仓库及其标签作为跟踪依赖及其版本的方法有一个缺点。如果你想同时修改两个(或更多)模块,并且希望在这些模块之间进行实验性更改,你需要一种方法来使用模块的本地副本,而不是源代码仓库中的版本。

警告

你可能会在网上找到过时的建议,尝试通过临时在go.mod中添加replace指令指向本地目录来解决这个问题。不要这样做!很容易忘记在提交和推送代码之前撤销这些更改。工作空间的引入就是为了避免这种反模式。

Go 使用工作空间来解决这个问题。工作空间允许你在计算机上下载多个模块,并且这些模块之间的引用将自动解析为本地源代码,而不是存储在你的仓库中的代码。

注意

本节假设您拥有 GitHub 帐户。如果没有,您仍然可以跟着进行。我将使用组织名learning-go-book-2e,但您应该用您的 GitHub 帐户名或组织名替换它。

让我们从两个示例模块开始。在my_workspace目录中创建一个workspace_lib和一个workspace_app目录。在workspace_lib目录中,运行go mod init github.com/learning-go-book-2e/workspace_lib。创建一个名为lib.go的文件,内容如下:

package workspace_lib

func AddNums(a, b int) int {
    return a + b
}

workspace_app目录中,运行go mod init github.com/learning-go-book-2e/workspace_app。创建一个名为app.go的文件,内容如下:

package main

import (
    "fmt"
    "github.com/learning-go-book-2e/workspace_lib"
)

func main() {
    fmt.Println(workspace_lib.AddNums(2, 3))
}

在之前的章节中,您使用了go get ./...来向go.mod添加require指令。让我们看看在这里尝试会发生什么:

$ go get ./...
github.com/learning-go-book-2e/workspace_app imports
    github.com/learning-go-book-2e/workspace_lib: cannot find module
        providing package github.com/learning-go-book-2e/workspace_lib

由于workspace_lib尚未推送到 GitHub,您无法将其拉入。如果尝试运行go build,您将收到类似的错误:

$ go build
app.go:5:2: no required module provides
    package github.com/learning-go-book-2e/workspace_lib; to add it:
        go get github.com/learning-go-book-2e/workspace_lib

让我们利用工作空间,使workplace_app能够查看workspace_lib的本地副本。转到my_workspace目录并运行以下命令:

$ go work init ./workspace_app
$ go work use ./workspace_lib

这会在my_workspace中创建一个go.work文件,内容如下:

go 1.20

use (
    ./workspace_app
    ./workspace_lib
)
警告

go.work文件仅适用于您的本地计算机。不要将其提交到源代码控制中!

现在如果您构建workspace_app,一切都正常:

$ cd workspace_app
$ go build
$ ./workspace_app
5

现在您确信workspace_lib工作正常,可以将其推送到 GitHub。在 GitHub 上,创建一个名为workspace_lib的空公共存储库,然后在workspace_lib目录中运行以下命令:

$ git init
$ git add .
$ git commit -m "first commit"
$ git remote add origin git@github.com:learning-go-book-2e/workspace_lib.git
$ git branch -M main
$ git push -u origin main

在运行这些命令后,转到https://github.com/learning-go-book-2e/workspace_lib/releases/new(用您的帐户或组织替换“learning-go-book-2e”),并创建一个带有标签 v0.1.0 的新发布。

现在如果您回到workspace_app目录并运行go get ./...require指令将被添加,因为有一个可以下载的公共模块:

$ go get ./...
go: downloading github.com/learning-go-book-2e/workspace_lib v0.1.0
go: added github.com/learning-go-book-2e/workspace_lib v0.1.0
$ cat go.mod
module github.com/learning-go-book-2e/workspace_app

go 1.20

require github.com/learning-go-book-2e/workspace_lib v0.1.0

您可以通过设置环境变量GOWORK=off并构建您的应用程序来验证您的代码是否正常工作:

$ rm workspace_app
$ GOWORK=off go build
$ ./workspace_app
5

即使现在有一个require指令指向公共模块,您仍然可以在我们的本地工作空间中继续进行更新,这些更新将被使用。在workspace_lib中,修改lib.go文件,并添加以下函数:

func SubNums(a, b int) int {
    return a - b
}

workspace_app中,修改app.go文件,并在main函数的末尾添加以下行:

    fmt.Println(workspace_lib.SubNums(2,3))

现在运行go build,看到它使用本地模块而不是公共模块:

$ go build
$ ./workspace_app
5
-1

一旦您进行了编辑并希望发布您的软件,您需要更新模块的版本信息以引用更新的代码。这需要您按依赖顺序将模块提交到源代码控制中:

  1. 选择一个修改后不依赖于工作空间中任何修改模块的修改模块。

  2. 将此模块提交到您的源代码仓库。

  3. 在你的源代码仓库中为新提交的模块创建一个新的版本标签。

  4. 使用 go get 命令来更新依赖于新提交模块的模块在 go.mod 中指定的版本。

  5. 重复前四个步骤,直到所有修改的模块都已提交。

如果将来必须对 workspace_lib 进行更改,并希望在不推回到 GitHub 并创建大量临时版本的情况下在 workspace_app 中测试它们,可以再次 git pull 最新版本的模块到您的工作区,并进行更新。

模块代理服务器

Go 不使用单一的中央库存储库来依赖库,而是采用混合模型。每个 Go 模块都存储在源代码仓库中,如 GitHub 或 GitLab。但是,默认情况下,go get 不直接从源代码仓库获取代码。相反,它将请求发送到由 Google 运行的 代理服务器。当代理服务器收到 go get 请求时,它检查其缓存以查看此模块版本之前是否有请求。如果有,它返回缓存的信息。如果代理服务器未缓存某个模块或某个模块的版本,则从模块的仓库下载该模块,存储一个副本,并返回该模块。这使得代理服务器可以保存几乎所有公共 Go 模块的每个版本的副本。

除了代理服务器外,Google 还维护一个 校验和数据库。它存储了代理服务器缓存的每个模块的每个版本的信息。正如代理服务器保护您免受模块或模块版本被从互联网移除的影响一样,校验和数据库则保护您免受对模块版本的修改影响。这可能是恶意行为(某人劫持了一个模块并插入了恶意代码),也可能是无意的(模块维护者修复了一个错误或添加了一个新特性并重用了现有的版本标签)。无论哪种情况,您都不希望使用已更改的模块版本,因为您将无法构建相同的二进制文件,并且不知道对您的应用程序造成的影响。

每当您通过 go getgo mod tidy 下载一个模块时,Go 工具都会为该模块计算一个哈希值,并联系校验和数据库以比较计算的哈希值与存储在该模块版本的哈希值是否匹配。如果不匹配,则不安装该模块。

指定代理服务器

一些人反对向 Google 发送第三方库的请求。有几个选择:

  • 您可以通过将 GOPROXY 环境变量设置为 direct 来完全禁用代理。您将直接从它们的仓库下载模块,但如果您依赖的版本已从仓库中移除,则无法访问它。

  • 你可以运行自己的代理服务器。Artifactory 和 Sonatype 的企业仓库产品均支持 Go 代理服务器。Athens 项目提供了一个开源代理服务器。在你的网络上安装其中一个产品,然后将GOPROXY指向该 URL。

使用私有仓库

大多数组织将它们的代码存储在私有仓库中。如果你想在另一个 Go 模块中使用私有模块,你不能从 Google 的代理服务器请求它。Go 将回退到直接检查私有仓库,但你可能不希望将私有服务器和仓库的名称泄露给外部服务。

如果你正在使用自己的代理服务器,或者已禁用代理,这不是问题。运行私有代理服务器有一些额外的好处。首先,它加快了第三方模块的下载速度,因为它们被缓存在公司网络中。如果访问你的私有仓库需要身份验证,使用私有代理服务器意味着你不必担心在 CI/CD 流水线中泄露身份验证信息。私有代理服务器配置为对你的私有仓库进行身份验证(参见Athens 的身份验证配置文档),而对私有代理服务器的调用是未经身份验证的。

如果你使用公共代理服务器,你可以将GOPRIVATE环境变量设置为你的私有仓库的逗号分隔列表。例如,如果你将GOPRIVATE设置为:

GOPRIVATE=*.example.com,company.com/repo

存储在example.com的任何子域或以company.com/repo开头的 URL 的仓库中的任何模块将直接下载。

附加细节

Go 团队在线提供完整的Go 模块参考。除了本章内容外,模块参考还涵盖了其他主题,如使用除 Git 外的版本控制系统,模块缓存的结构和 API,控制模块查找行为的其他环境变量,以及模块代理和校验和数据库的 REST API。

练习

  1. 在你自己的公共仓库中创建一个模块。该模块有一个名为Add的函数,带有两个int参数和一个int返回值。该函数将两个参数相加并返回它们。将此版本设为 v1.0.0。

  2. 在你的模块中添加 godoc 注释,描述包和Add函数。确保在你的Add函数的 godoc 中包含一个链接到https://www.mathsisfun.com/numbers/addition.html。将此版本设为 v1.0.1。

  3. Add改为通用的。导入golang.org/x/exp/constraints包。在该包中将IntegerFloat类型合并,创建一个名为Number的接口。重写Add函数,接受两个Number类型的参数,并返回一个Number类型的值。再次对你的模块进行版本化。因为这是一个向后不兼容的变更,所以应该将你的模块版本设为 v2.0.0。

结束

在本章中,你学习了如何组织代码并与 Go 源代码生态系统互动。你了解了模块的工作原理,如何将代码组织成包,如何使用第三方模块,以及如何发布你自己的模块。在下一章中,你将进一步了解 Go 附带的开发工具,学习一些必备的第三方工具,并探索一些技术,以便更好地控制你的构建过程。

第十一章:Go 工具

编程语言不是孤立存在的。为了有用,工具必须帮助开发者将源代码转化为可执行文件。由于 Go 旨在解决今天软件工程师面临的问题,并帮助他们构建高质量的软件,因此对简化通常在其他开发平台中困难的任务的工具进行了精心设计。这包括改进构建、格式化、更新、验证和分发的方式,甚至还包括用户如何安装您的代码。

我已经涵盖了许多捆绑的 Go 工具:go vetgo fmtgo modgo getgo listgo workgo docgo build。由go test工具提供的测试支持非常广泛,它在第十五章中单独进行了介绍。在本章中,您将探索使 Go 开发变得出色的其他工具,无论是来自 Go 团队还是第三方。

使用go run来尝试小程序

Go 是一种编译语言,这意味着在运行 Go 代码之前,它必须被转换成可执行文件。这与像 Python 或 JavaScript 这样的解释语言形成对比,后者允许您编写一个快速脚本来测试一个想法并立即执行它。拥有快速反馈循环非常重要,因此 Go 通过go run命令提供类似的功能。它一步构建和执行程序。让我们回到第一章的第一个程序。把它放在名为hello.go的文件中:

package main

import "fmt"

func main() {
    fmt.Println("Hello, world!")
}

(你也可以在第十一章的代码库sample_code/gorun目录中找到这段代码。)

一旦文件保存,使用go run命令构建并执行它:

go run hello.go
Hello, world!

运行go run命令后,查看目录中,你会发现没有保存任何二进制文件;目录中唯一的文件是你刚创建的hello.go文件。可执行文件去哪了(无意冒犯)?

go run命令实际上确实将您的代码编译成一个二进制文件。但是,该二进制文件是在临时目录中构建的。go run命令会构建该二进制文件,从该临时目录执行该二进制文件,然后在程序完成后删除该二进制文件。这使得go run命令非常适合测试小程序或像使用脚本语言一样使用 Go。

小贴士

当你希望像运行脚本一样运行 Go 程序并立即执行源代码时,请使用go run

使用go install添加第三方工具

虽然有些人选择将他们的 Go 程序作为预编译的二进制文件分发,但是用 Go 编写的工具也可以从源代码构建并通过go install命令安装到您的计算机上。

如您在“发布您的模块”中看到的,Go 模块通过其源代码存储库进行标识。go install命令接受一个参数,即模块源代码存储库中主包的路径,后跟@及您想要的工具版本(如果只想获取最新版本,请使用@latest)。然后它会下载、编译并安装该工具。

警告

请务必在包名后面包含@version@latest!如果不这样做,将触发各种令人困惑的行为,几乎肯定不是您想要的结果。如果当前目录不在模块中,或者当前目录是模块但模块的go.mod文件中没有引用该包,您会收到错误消息,或者安装go.mod文件中提到的包版本。

默认情况下,go install将二进制文件放置在您的主目录中的go/bin目录中。设置GOBIN环境变量以更改此位置是强烈推荐的。建议您将go install目录添加到可执行搜索路径中(这可以通过修改 Unix 和 Windows 上的PATH环境变量来完成)。为简单起见,本章中的所有示例假定您已添加了此目录。

注意

其他环境变量由go工具识别。您可以使用go help environment命令获取完整的列表,以及每个变量的简要描述。其中许多控制低级行为,可以安全地忽略。在需要时,我会指出相关的变量。

一些在线资源告诉您设置GOROOTGOPATH环境变量。GOROOT指定安装 Go 开发环境的位置,而GOPATH用于存储所有 Go 源代码,包括您自己的和第三方依赖项。设置这些变量已不再必要;go工具会自动确定GOROOT,并且基于GOPATH的开发已被模块取代。

让我们看一个快速的例子。Jaana Dogan 创建了一个名为hey的出色的 Go 工具,用于对 HTTP 服务器进行负载测试。您可以将其指向您选择的网站或您编写的应用程序。以下是使用go install命令安装hey的方法:

$ go install github.com/rakyll/hey@latest
go: downloading github.com/rakyll/hey v0.1.4
go: downloading golang.org/x/net v0.0.0-20181017193950-04a2e542c03f
go: downloading golang.org/x/text v0.3.0

这将下载hey及其所有依赖项,构建程序并将二进制文件安装到您的 Go 二进制目录中。

注意

正如我在“模块代理服务器”中介绍的,Go 存储库的内容被缓存在代理服务器中。根据GOPROXY环境变量中的存储库和值,go install可能会从代理服务器下载或直接从存储库下载。如果go install直接从存储库下载,则依赖于您计算机上已安装的命令行工具。例如,要从 GitHub 下载,必须安装 Git。

现在您已经构建并安装了hey,您可以使用以下命令运行它:

$ hey https://go.dev

Summary:
  Total:           2.1272 secs
  Slowest:         1.4227 secs
  Fastest:         0.0573 secs
  Average:         0.3467 secs
  Requests/sec:    94.0181

如果您已经安装了某个工具并希望将其更新到新版本,请使用指定的更高版本或使用 @latest 重新运行 go install

$ go install github.com/rakyll/hey@latest

当然,您不必将通过 go install 安装的程序留在 go/bin 目录中;它们是常规的可执行二进制文件,可以存储在计算机上的任何位置。同样,您不必使用 go install 分发用 Go 编写的程序;您可以提供一个可下载的二进制文件。但是,go install 对于 Go 开发者来说非常方便,已成为分发第三方开发者工具的首选方法。

使用 goimports 改善导入格式

go fmt 的增强版本 goimports 还可以清理您的导入语句。它会按字母顺序排列它们,删除未使用的导入,并尝试猜测任何未指定的导入。它的猜测有时不准确,因此您应该自己插入导入。

您可以使用命令 go install golang.org/x/tools/cmd/goimports@latest 下载 goimports。使用以下命令在您的项目中运行它:

$ goimports -l -w .

使用 -l 标志告诉 goimports 将格式不正确的文件打印到控制台。使用 -w 标志告诉 goimports 在原地修改文件。. 指定要扫描的文件:当前目录及其所有子目录中的所有内容。

注意

golang.org/x 下的包是 Go 项目的一部分,但位于主 Go 树之外。尽管有用,它们的开发与 Go 标准库相比遵循更宽松的兼容性要求,可能会引入向后不兼容的更改。一些标准库中的包,例如 第十四章 中介绍的 context 包,最初就是从 golang.org/x 开始的。“使用 Go Doc 注释文档化您的代码” 中涵盖的 pkgsite 工具也位于此处。您可以在 “子仓库” 部分 查看其他包。

使用代码质量扫描工具

回顾一下go vet,你使用了内置工具 go vet,它扫描源代码以查找常见的编程错误。许多第三方工具可以检查代码风格并扫描可能被 go vet 忽略的潜在错误。这些工具通常称为代码检查器。¹ 除了可能的编程错误外,这些工具建议的一些更改包括正确命名变量、格式化错误消息以及在公共方法和类型上放置注释。这些不是错误,因为它们不会阻止程序编译或使程序运行不正确,但它们会标记编写非惯用代码的情况。

当您将代码检查器添加到构建过程中时,请遵循旧的格言“信任,但验证”。由于代码检查器发现的问题类型更加模糊,它们有时会产生误报和漏报。这意味着您不一定需要按照它们的建议进行更改,但您应该认真对待这些建议。Go 开发人员期望代码看起来某种方式并遵循某些规则,如果您的代码不符合这些规则,它就会显得突兀。

如果您认为代码检查器的建议不实用,每个代码检查工具都允许您向源代码添加一个注释来阻止错误结果(注释的格式因检查工具而异,请查阅每个工具的文档了解应写入的内容)。您的注释还应包括为何忽略检查工具发现的原因,以便代码审查人员(以及未来的您,在六个月后回顾源代码时)能够理解您的推理。

staticcheck

如果您必须选择一个第三方扫描程序,请使用staticcheck。它得到了许多活跃于 Go 社区的公司的支持,包括超过 150 种代码质量检查,并且尽可能少产生误报。您可以通过go install honnef.co/go/tools/cmd/staticcheck@latest安装它。使用staticcheck ./...来检查您的模块。

这是staticcheck发现的一个示例,而go vet没有找到:

package main

import "fmt"

func main() {
    s := fmt.Sprintf("Hello")
    fmt.Println(s)
}

(您还可以在第十一章存储库中的sample_code/staticcheck_test目录找到此代码。)

如果您在此代码上运行go vet,它不会发现任何问题。但是,staticcheck注意到了一个问题:

$ staticcheck ./...
main.go:6:7: unnecessary use of fmt.Sprintf (S1039)

将带括号的代码传递给staticcheck时使用-explain标志以解释该问题:

$ staticcheck -explain S1039
Unnecessary use of fmt.Sprint

Calling fmt.Sprint with a single string argument is unnecessary
and identical to using the string directly.

Available since
    2020.1

Online documentation
    https://staticcheck.io/docs/checks#S1039

staticcheck发现的另一个常见问题是对变量的未使用赋值。虽然 Go 编译器要求所有变量至少读取一次,但它不检查对变量赋值的每一个值是否都被读取。在函数内部有多个函数调用时,重复使用err变量是常见的做法。如果在其中一个函数调用后忘记写if err != nil,编译器将无法帮助您。但是,staticcheck可以。这段代码编译没有问题:

func main() {
        err := returnErr(false)
        if err != nil {
                fmt.Println(err)
        }
        err = returnErr(true)
        fmt.Println("end of program")
}

(此代码位于第十一章存储库中的sample_code/check_err目录。)

运行staticcheck发现了这个错误:

$ staticcheck ./...
main.go:13:2: this value of err is never used (SA4006)
main.go:13:8: returnErr doesn't have side effects and its return value is
ignored (SA4017)

第 13 行存在两个相关问题。首先,returnErr返回的错误没有被读取。其次,returnErr函数的输出(即错误)被忽略了。

revive

另一个很好的 lint 选项是revive。它基于golint,这是 Go 团队曾经维护的一个工具。使用命令go install github.com/mgechev/revive@latest安装revive。默认情况下,它仅启用了golint中存在的规则。它可以发现像没有注释的导出标识符、不遵循命名约定的变量或不是最后一个返回值的错误返回值等风格和代码质量问题。

使用配置文件,您可以启用更多规则。例如,要启用检查宇宙块标识符屏蔽的检查,请创建名为built_in.toml的文件,并包含以下内容:

[rule.redefines-builtin-id]

如果您扫描以下代码:

package main

import "fmt"

func main() {
    true := false
    fmt.Println(true)
}

您将收到此警告:

$ revive -config built_in.toml ./...
main.go:6:2: assignment creates a shadow of built-in identifier true

(您也可以在第十一章代码库sample_code/revive_test目录中找到这段代码。)

另外可以启用的规则专注于代码组织的看法,如限制函数中的行数或文件中的公共结构数量。甚至有用于评估函数逻辑复杂性的规则。查看revive文档及其支持的规则

golangci-lint

如果您更喜欢使用自助餐方式选择工具,可以考虑使用golangci-lint。它旨在尽可能高效地配置和运行超过 50 个代码质量工具,包括go vetstaticcheckrevive

虽然您可以使用go install来安装golangci-lint,但建议您下载二进制版本。按照网站上的安装说明操作。安装完成后,运行golangci-lint

$ golangci-lint run

在“未使用的变量”中,您看到了一个将变量设置为从未读取过的值的程序,我提到go vet和 Go 编译器无法检测到这些问题。staticcheckrevive也都无法捕捉到这个问题。然而,golangci-lint捆绑的工具之一可以:

$ golangci-lint run
main.go:6:2: ineffectual assignment to x (ineffassign)
    x := 10
    ^
main.go:9:2: ineffectual assignment to x (ineffassign)
    x = 30
    ^

您还可以使用golangci-lint提供的屏蔽检查功能,这超出了revive的能力。通过在运行golangci-lint的目录中创建名为.golangci.yml的文件,配置golangci-lint来检测自己代码中的宇宙块标识符和标识符的屏蔽:

linters:
  enable:
    - govet
    - predeclared

linters-settings:
  govet:
    check-shadowing: true
    settings:
      shadow:
        strict: true
    enable-all: true

使用这些设置,在此代码上运行golangci-lint

package main

import "fmt"

var b = 20

func main() {
    true := false
    a := 10
    b := 30
    if true {
        a := 20
        fmt.Println(a)
    }
    fmt.Println(a, b)
}

检测到以下问题:

$ golangci-lint run
main.go:5:5: var `b` is unused (unused)
var b = 20
    ^
main.go:10:2: shadow: declaration of "b" shadows declaration at line 5 (govet)
    b := 30
    ^
main.go:12:3: shadow: declaration of "a" shadows declaration at line 9 (govet)
        a := 20
        ^
main.go:8:2: variable true has same name as predeclared identifier (predeclared)
    true := false
    ^

(您可以在第十一章代码库sample_code/golangci-lint_test目录中找到golangci-lint的代码示例。)

因为golangci-lint运行了很多工具(截至目前为止,默认运行了 7 种不同的工具,并允许你启用超过 50 种工具),你的团队可能会对它的一些建议持不同意见。查阅文档以了解每个工具的功能。一旦达成对启用哪些检查器达成一致意见,请更新你模块根目录下的.golangci.yml文件,并提交到源代码控制。查看文件格式的文档

注意

虽然golangci-lint允许你在家目录下拥有配置文件,但是如果你与其他开发人员一起工作,不要把它放在那里。除非你喜欢在代码审查中添加数小时的愚蠢争论,你要确保每个人都使用相同的代码质量测试和格式化规则。

我建议你开始将go vet作为自动化构建流程的必需部分。接下来添加staticcheck,因为它几乎不会产生误报。当你对配置工具和设置代码质量标准感兴趣时,看看revive,但要注意它可能存在误报和漏报,所以你不能要求你的团队修复它报告的每个问题。一旦习惯了它们的建议,尝试使用golangci-lint并调整其设置,直到适合你的团队。

使用 govulncheck 扫描易受攻击的依赖关系

到目前为止,你看到的工具没有强制执行一种代码质量:软件漏洞。拥有丰富的第三方模块生态系统非常棒,但是聪明的黑客会在库中找到安全漏洞并加以利用。开发人员在报告这些漏洞时修补它们,但是如何确保使用受影响版本库的软件更新到修复版本呢?

Go 团队发布了一个名为govulncheck的工具来解决这个问题。它扫描你的依赖关系,并查找已知的漏洞,包括标准库和导入到你模块中的第三方库。这些漏洞在 Go 团队维护的公共数据库中报告。你可以通过以下方式安装它:

$ go install golang.org/x/vuln/cmd/govulncheck@latest

让我们看一个小程序,看看漏洞检查器是如何运行的。首先,下载存储库main.go中的源代码非常简单。它导入了一个第三方 YAML 库,并使用它将一个小的 YAML 字符串加载到一个结构体中:

func main() {
    info := Info{}

    err := yaml.Unmarshal([]byte(data), &info)
    if err != nil {
        fmt.Printf("error: %v\n", err)
        os.Exit(1)
    }
    fmt.Printf("%+v\n", info)
}

go.mod文件包含了所需的模块及其版本:

module github.com/learning-go-book-2e/vulnerable

go 1.20

require gopkg.in/yaml.v2 v2.2.7

require gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect

让我们看看在这个项目上运行govulncheck时会发生什么:

$ govulncheck ./...
Using go1.21 and govulncheck@v1.0.0 with vulnerability data from
    https://vuln.go.dev (last modified 2023-07-27 20:09:46 +0000 UTC).

Scanning your code and 49 packages across 1 dependent module
    for known vulnerabilities...

Vulnerability #1: GO-2020-0036
    Excessive resource consumption in YAML parsing in gopkg.in/yaml.v2
  More info: https://pkg.go.dev/vuln/GO-2020-0036
  Module: gopkg.in/yaml.v2
    Found in: gopkg.in/yaml.v2@v2.2.7
    Fixed in: gopkg.in/yaml.v2@v2.2.8
    Example traces found:
      #1: main.go:25:23: vulnerable.main calls yaml.Unmarshal

Your code is affected by 1 vulnerability from 1 module.

本模块使用了一个旧版且易受攻击的 YAML 包。govulncheck贴心地给出了调用有问题代码的确切代码行。

注意

如果 govulncheck 知道你的代码使用了某个模块中的漏洞,但无法找到明确调用模块中有问题的部分,那么会收到较轻微的警告。该消息会告知你库的漏洞及解决问题的版本,但还会指出你的模块可能不受影响。

让我们升级到一个修复版本,并查看是否解决了问题:

$ go get -u=patch gopkg.in/yaml.v2
go: downloading gopkg.in/yaml.v2 v2.2.8
go: upgraded gopkg.in/yaml.v2 v2.2.7 => v2.2.8
$ govulncheck ./...
Using go1.21and govulncheck@v1.0.0 with vulnerability data from
    https://vuln.go.dev (last modified 2023-07-27 20:09:46 +0000 UTC).

Scanning your code and 49 packages across 1 dependent module
    for known vulnerabilities...

No vulnerabilities found.

记住,应始终力求对项目依赖进行尽可能小的更改,因为这样可以减少依赖变更导致代码出现问题的可能性。因此,请升级到最新的 v2.2.x 补丁版本,即 v2.2.8. 当再次运行 govulncheck 时,不会出现已知问题。

当前,govulncheck 需要进行 go install 才能下载,但很可能最终会添加到标准工具集中。与此同时,确保在构建过程中像常规一样安装并运行它以检查你的项目。你可以在宣布它的博客文章中了解更多信息。

将内容嵌入到您的程序中

许多程序分发时都带有支持文件目录;可能是网页模板或程序启动时加载的标准数据。如果 Go 程序需要支持文件,你可以包含一个文件目录,但这会削弱 Go 的一个优点:将代码编译为单个易于分发的二进制文件。不过,还有另一种选择。你可以通过使用 go:embed 注释将文件内容嵌入到你的 Go 二进制文件中。

在 GitHub 的 sample_code/embed_passwords 目录中可以找到演示嵌入的程序,该程序检查密码是否是最常用的 10,000 个密码之一。你不会直接将密码列表写入源代码,而是将其嵌入。

main.go 中的代码很简单:

package main

import (
    _ "embed"
    "fmt"
    "os"
    "strings"
)

//go:embed passwords.txt
var passwords string

func main() {
    pwds := strings.Split(passwords, "\n")
    if len(os.Args) > 1 {
        for _, v := range pwds {
            if v == os.Args[1] {
                fmt.Println("true")
                os.Exit(0)
            }
        }
        fmt.Println("false")
    }
}

要启用嵌入,必须执行两个操作。首先,必须导入 embed 包。Go 编译器使用此导入作为指示启用嵌入的标志。因为这段示例代码并未引用 embed 包中导出的任何内容,你可以使用空白导入,这在 “尽量避免 init 函数” 中有讨论。embed 包中唯一导出的符号是 FS,你将在下一个示例中看到它。

接下来,在每个保存文件内容的包级变量前直接放置一个魔法注释。这个注释必须以go:embed开头,斜线和go:embed之间不能有空格。该注释还必须直接在变量声明的前一行。在这个示例中,你将passwords.txt的内容嵌入到名为passwords的包级变量中。习惯上,将包含嵌入值的变量视为不可变。正如前面提到的,你只能嵌入到包级变量中。变量必须是string[]byteembed.FS类型。如果只有一个文件,使用string[]byte是最简单的。

如果需要将一个或多个目录的文件放入程序中,请使用embed.FS类型的变量。此类型实现了io/fs包中定义的三个接口:FSReadDirFSReadFileFS。这允许embed.FS的实例表示一个虚拟文件系统。下面的程序提供了一个简单的命令行帮助系统。如果没有提供帮助文件,它会列出所有可用的文件。如果指定了一个不存在的文件,则返回错误消息。

package main

import (
    "embed"
    "fmt"
    "io/fs"
    "os"
    "strings"
)

//go:embed help
var helpInfo embed.FS

func main() {
    if len(os.Args) == 1 {
        printHelpFiles()
        os.Exit(0)
    }
    data, err := helpInfo.ReadFile("help/" + os.Args[1])
    if err != nil {
        fmt.Println(err)
        os.Exit(1)
    }
    fmt.Println(string(data))
}

你可以在第十一章代码库sample_code/help_system目录中找到此代码和示例帮助文件。

当构建和运行此程序时,输出如下:

$ go build
$ ./help_system
contents:
advanced/topic1.txt
advanced/topic2.txt
info.txt

$ ./help_system advanced/topic1.txt
This is advanced topic 1.

$ ./help_system advanced/topic3.txt
open help/advanced/topic3.txt: file does not exist

你应该注意到一些事情。首先,你不再需要为embed使用空白导入,因为你正在使用embed.FS。其次,目录名称是嵌入的文件系统的一部分。这个程序的用户不输入“help/”前缀,因此你必须在调用ReadFile时预先添加它。

printHelpFiles函数展示了如何像处理真实文件系统一样处理嵌入的虚拟文件系统:

func printHelpFiles() {
    fmt.Println("contents:")
    fs.WalkDir(helpInfo, "help",
        func(path string, d fs.DirEntry, err error) error {
            if !d.IsDir() {
                _, fileName, _ := strings.Cut(path, "/")
                fmt.Println(fileName)
            }
            return nil
        })
}

使用io/fs中的WalkDir函数来遍历嵌入式文件系统。WalkDir接受一个fs.FS实例,一个起始路径和一个函数。这个函数会在文件系统中的每个文件和目录上被调用,从指定的路径开始。如果fs.DirEntry不是一个目录,你会打印出它的完整路径名,并通过使用strings.Cut来去掉help/前缀。

关于文件嵌入还有一些需要了解的事情。尽管所有的示例都是文本文件,你也可以嵌入二进制文件。你还可以通过将它们的名称用空格分隔来将多个文件或目录嵌入到单个embed.FS变量中。当嵌入一个文件或目录的名称中含有空格时,请将名称放在引号中。

除了精确的文件和目录名外,你还可以使用通配符和范围来指定你想要嵌入的文件和目录的名称。语法在标准库中的 path 包的 Match 函数文档 中定义,但遵循常见约定。例如,* 匹配 0 或多个字符,? 匹配一个字符。

所有的嵌入规范,无论是否使用匹配模式,都会由编译器检查。如果它们无效,则编译失败。以下是模式可能无效的情况:

  • 如果指定的名称或模式不匹配文件或目录

  • 如果你为 string[]byte 变量指定了多个文件名或模式

  • 如果你为 string[]byte 变量指定了一个模式,并且它匹配多个文件

嵌入隐藏文件

包括以 ._ 开头的目录树中的文件有点复杂。许多操作系统将其视为隐藏文件,因此在指定目录名时默认情况下不包括它们。但是,你可以通过两种方式覆盖此行为。第一种是在你想要嵌入的目录名后加上 /*。这将包括根目录中的所有隐藏文件,但不会包括其子目录中的隐藏文件。要包括所有子目录中的所有隐藏文件,请在目录名前加上 all:

此示例程序(你可以在 第十一章存储库sample_code/embed_hidden 目录中找到)使这一点更容易理解。在示例中,目录 parent_dir 包含两个文件,.hiddenvisible,以及一个子目录 child_dirchild_dir 子目录包含两个文件,.hiddenvisible

这是程序的代码:

//go:embed parent_dir
var noHidden embed.FS

//go:embed parent_dir/*
var parentHiddenOnly embed.FS

//go:embed all:parent_dir
var allHidden embed.FS

func main() {
    checkForHidden("noHidden", noHidden)
    checkForHidden("parentHiddenOnly", parentHiddenOnly)
    checkForHidden("allHidden", allHidden)
}

func checkForHidden(name string, dir embed.FS) {
    fmt.Println(name)
    allFileNames := []string{
        "parent_dir/.hidden",
        "parent_dir/child_dir/.hidden",
    }
    for _, v := range allFileNames {
        _, err := dir.Open(v)
        if err == nil {
            fmt.Println(v, "found")
        }
    }
    fmt.Println()
}

程序的输出显示如下:

noHidden

parentHiddenOnly
parent_dir/.hidden found

allHidden
parent_dir/.hidden found
parent_dir/child_dir/.hidden found

使用 go generate

go generate 工具有些不同,因为它本身不执行任何操作。当你运行 go generate 时,它会查找源代码中特殊格式的注释,并运行这些注释中指定的程序。虽然你可以使用 go generate 运行任何内容,但它最常用于开发者运行(顾名思义)生成源代码的工具。这可能是通过分析现有代码并添加功能,或者处理模式并生成源代码。

自动转换为代码的一个很好的例子是 Protocol Buffers,有时称为 protobufs。Protobuf 是一种流行的二进制格式,被 Google 用于存储和传输数据。在处理 protobuf 时,您编写一个 schema,它是数据结构的语言无关描述。希望编写与 protobuf 格式数据交互的程序的开发人员运行处理 schema 的工具,并生成特定于语言的数据结构以保存数据以及特定于语言的函数来读取和写入 protobuf 格式的数据。

让我们看看这在 Go 中是如何工作的。您可以在 proto_generate 仓库 中找到一个示例模块,其中包含一个名为 person.proto 的 protobuf schema 文件:

syntax = "proto3";

message Person {
  string name = 1;
  int32 id = 2;
  string email = 3;
}

尽管制作一个实现 Person 的结构体很容易,但编写从二进制格式转换回去的代码却很困难。让我们使用 Google 的工具来完成这些艰难的工作,并使用 go generate 调用它们。您需要安装两个工具。首先是适用于您计算机的 protoc 二进制文件(请参阅 安装说明)。接下来,使用 go install 安装 Go protobuf 插件:

$ go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.28

main.go 中,有一个由 go generate 处理的神奇注释:

//go:generate protoc -I=. --go_out=.
  --go_opt=module=github.com/learning-go-book-2e/proto_generate
  --go_opt=Mperson.proto=github.com/learning-go-book-2e/proto_generate/data
  person.proto

(如果您在 GitHub 上查看源代码,您会看到这应该是一行。它被换行以适应打印页面的约束。)

输入以下内容运行 go generate

$ go generate ./...

运行 go generate 后,您会看到一个名为 data 的新目录,其中包含一个名为 person.pb.go 的文件。它包含 Person 结构体的源代码,以及在 google.golang.org/protobuf/proto 模块中由 MarshalUnmarshal 函数使用的一些方法和函数。您在 main 函数中调用这些函数:

func main() {
    p := &data.Person{
        Name:  "Bob Bobson",
        Id:    20,
        Email: "bob@bobson.com",
    }
    fmt.Println(p)
    protoBytes, _ := proto.Marshal(p)
    fmt.Println(protoBytes)
    var p2 data.Person
    proto.Unmarshal(protoBytes, &p2)
    fmt.Println(&p2)
}

像往常一样构建和运行程序:

$ go build
$ ./proto_generate
name:"Bob Bobson"  id:20  email:"bob@bobson.com"
[10 10 66 111 98 32 66 111 98 115 111 110 16 20 26 14 98
  111 98 64 98 111 98 115 111 110 46 99 111 109]
name:"Bob Bobson"  id:20  email:"bob@bobson.com"

另一个常与 go generate 一起使用的工具是 stringer。正如我在 “iota 用于枚举——有时” 中讨论的那样,Go 中的枚举缺少许多其他语言中的枚举所具有的特性之一是为枚举中的每个值自动生成可打印的名称。 stringer 工具与 go generate 一起使用,为您枚举的值添加一个 String 方法,以便可以打印它们。

使用 go install golang.org/x/tools/cmd/stringer@latest 安装 stringer。在 第十一章仓库sample_code/stringer_demo 目录中提供了一个非常简单的示例,展示了如何使用 stringer。以下是 main.go 中的源代码:

type Direction int

const (
    _ Direction = iota
    North
    South
    East
    West
)

//go:generate stringer -type=Direction

func main() {
    fmt.Println(North.String())
}

运行 go generate ./...,您将看到生成一个名为 direction_string.go 的新文件。使用 go build 构建 string_demo 二进制文件,并在运行时,您将获得输出:

North

您可以以多种方式配置 stringer 及其输出。Arjun Mahishi 写了一篇很棒的 博客文章 描述了如何使用 stringer 并自定义其输出。

使用 go generate 和 Makefile 进行工作

由于 go generate 的工作是运行其他工具,你可能会想知道在项目中已经有一个完全正常的 Makefile 时是否值得使用它。go generate 的优势在于它创建了职责的分离。使用 go generate 命令来机械地创建源代码,并使用 Makefile 来验证和编译源代码。

将由 go generate 创建的源代码提交到版本控制是一种最佳实践。(在 第十一章的存储库 的示例项目中不包括生成的源代码,因此你可以看到 go generate 的工作。)这使得浏览你源代码的人可以看到每个被调用的东西,甚至是生成的部分。它也意味着他们不需要安装诸如 protoc 这样的工具来构建你的代码。

技术上来说,提交你生成的源代码意味着你不一定需要运行 go generate,除非它会产生不同的输出,例如处理修改的 protobuf 定义或更新的枚举。然而,自动化在 go build 之前调用 go generate 仍然是一个好主意。依赖手动流程会带来麻烦。一些生成器工具,如 stringer,包含聪明的技巧来阻止编译,如果你忘记重新运行 go generate,但这并非普遍。在测试中试图理解为什么变更没有显示之前,你会浪费时间,最终发现你忘记调用 go generate。 (在学习教训之前,我犯了这个错误多次。)因此,最好是在你的 Makefile 中添加一个 generate 步骤,并将其作为你的 build 步骤的依赖项。

然而,在两种情况下我会忽略这些建议。首先是当在相同输入上调用 go generate 产生具有轻微差异的源文件时,例如时间戳。一个良好编写的 go generate 工具每次在相同输入上运行时应该产生相同的输出,但不能保证你需要使用的每个工具都是良好编写的。你不希望不断地检查功能上相同的新版本文件,因为它们会混乱你的版本控制系统并使得代码审查更加嘈杂。

如果 go generate 花费很长时间才能完成,这是第二种情况。快速构建是 Go 的一个特性,因为它们允许开发者保持专注并获得快速反馈。如果因为生成相同文件而显著减慢构建速度,那么这种开发者生产力的损失是不值得的。在这两种情况下,你只能留下大量注释来提醒人们在变更时重新构建,并希望你团队的每个人都很勤奋。

在 Go 二进制文件内读取构建信息

随着公司开发越来越多的自己的软件,他们越来越希望确切地了解他们已部署到他们的数据中心和云环境中的内容,包括版本和依赖关系。您可能会想知道为什么要从编译代码中获取这些信息。毕竟,公司已经在版本控制中拥有这些信息。

具有成熟的开发和部署流水线的公司可以在部署程序之前捕获此信息,以确保信息的准确性。然而,许多公司,如果不是大多数公司,不跟踪已部署的内部软件的确切版本。在某些情况下,软件可以部署多年而无需更换,并且没有人记得太多关于它。如果在流行的 Log4j 库中发现了严重的漏洞报告,需要找到某种方法扫描已部署的软件并确定已部署的第三方库的版本,或者为了安全起见重新部署所有内容。在 Java 的世界中,这个确切的问题就发生了。

幸运的是,Go 为您解决了这个问题。使用 go build 创建的每个 Go 二进制文件都自动包含了构建信息,包括构成该二进制文件的模块的版本以及使用的构建命令、版本控制系统以及代码在版本控制系统中的修订版本。您可以使用 go version -m 命令查看此信息。以下是在 Apple Silicon Mac 上构建 vulnerable 程序的输出:

$ go build
go: downloading gopkg.in/yaml.v2 v2.2.7
$ go version -m vulnerable
vulnerable: go1.20
    path     github.com/learning-go-book-2e/vulnerable
    mod      github.com/learning-go-book-2e/vulnerable    (devel)
    dep      gopkg.in/yaml.v2  v2.2.7  h1:VUgggvou5XRW9mHwD/yXxIYSMtY0zoKQf/v...
    build    -compiler=gc
    build    CGO_ENABLED=1
    build    CGO_CFLAGS=
    build    CGO_CPPFLAGS=
    build    CGO_CXXFLAGS=
    build    CGO_LDFLAGS=
    build    GOARCH=arm64
    build    GOOS=darwin
    build    vcs=git
    build    vcs.revision=623a65b94fd02ea6f18df53afaaea3510cd1e611
    build    vcs.time=2022-10-02T03:31:05Z
    build    vcs.modified=false

由于这些信息嵌入到每个二进制文件中,govulncheck能够扫描 Go 程序,检查是否存在已知漏洞的库:

$ govulncheck -mode binary vulnerable
Using govulncheck@v1.0.0 with vulnerability data from
    https://vuln.go.dev (last modified 2023-07-27 20:09:46 +0000 UTC).

Scanning your binary for known vulnerabilities...

Vulnerability #1: GO-2020-0036
    Excessive resource consumption in YAML parsing in gopkg.in/yaml.v2
  More info: https://pkg.go.dev/vuln/GO-2020-0036
  Module: gopkg.in/yaml.v2
    Found in: gopkg.in/yaml.v2@v2.2.7
    Fixed in: gopkg.in/yaml.v2@v2.2.8
    Example traces found:
      #1: yaml.Unmarshal

Your code is affected by 1 vulnerability from 1 module.

请注意,govulncheck 在检查二进制文件时无法追踪到确切的代码行。如果 govulncheck 在二进制文件中发现问题,请使用 go version -m 查找确切的部署版本,从版本控制中检出代码,并再次针对源代码运行以准确定位问题。

如果您想构建自己的工具来读取构建信息,请查看标准库中的 debug/buildinfo 包。

为其他平台构建 Go 二进制文件

像 Java、JavaScript 或 Python 这样基于虚拟机的语言的一个优点是,您可以将您的代码放在安装了虚拟机的任何计算机上运行。这种可移植性使得使用这些语言的开发人员能够在 Windows 或 Mac 计算机上构建程序,并在 Linux 服务器上部署,即使操作系统和可能的 CPU 架构不同。

Go 程序被编译成本地代码,因此生成的二进制文件只能与单个操作系统和 CPU 架构兼容。但是,这并不意味着 Go 开发人员需要维护多台机器(虚拟或其他)以在多个平台上发布。go build 命令使得跨平台编译变得简单,或者为不同的操作系统和/或 CPU 创建二进制文件。运行 go build 时,目标操作系统由 GOOS 环境变量指定。类似地,GOARCH 环境变量指定 CPU 架构。如果没有显式设置它们,go build 默认使用您当前计算机的值,这就是为什么您以前从未担心过这些变量的原因。

您可以在 安装文档 中找到 GOOSGOARCH 的有效值和组合(有时发音为“GOOSE”和“GORCH”)。一些支持的操作系统和 CPU 有点奇特,而其他可能需要一些翻译。例如,darwin 指的是 macOS(Darwin 是 macOS 内核的名称),而 amd64 表示 64 位兼容 Intel 的 CPU。

让我们最后再回到 vulnerable 程序。当使用 Apple Silicon Mac(具有 ARM64 CPU)时,运行 go build 默认为 darwinGOOSarm64GOARCH。您可以使用 file 命令确认这一点:

$ go build
$ file vulnerable
vulnerable: Mach-O 64-bit executable arm64

这里是如何在 64 位 Intel CPU 的 Linux 上构建二进制文件:

$ GOOS=linux GOARCH=amd64 go build
$ file vulnerable
vulnerable: ELF 64-bit LSB executable, x86-64, version 1 (SYSV),
    statically linked, Go BuildID=IDHVCE8XQPpWluGpMXpX/4VU3GpRZEifN
    8TzUrT_6/1c30VcDYNVPfSSN-zCkz/JsZSLAbWkxqIVhPkC5p5, with debug_info,
    not stripped

使用构建标签

当编写需要在多个操作系统或 CPU 架构上运行的程序时,有时需要为不同平台编写不同的代码。您可能还希望编写一个模块,利用最新的 Go 特性,但仍与旧版 Go 编译器兼容。

您可以通过两种方式创建目标代码。第一种方法是使用文件名来指示何时应将文件包含在构建中。您可以在 .go 之前的文件名中添加目标 GOOSGOARCH,用 _ 分隔。例如,如果您有一个只希望在 Windows 上编译的文件,您将文件命名为 something_windows.go,但如果您希望在构建 ARM64 Windows 时编译它,则将文件命名为 something_windows_arm64.go

构建标签(也称为构建约束)是您可以使用的另一种指定文件何时编译的选项。与嵌入和生成类似,构建标签利用了一个魔术注释。在这种情况下,它是 //go:build。此注释必须放置在文件中包声明之前的行上。

构建标签使用布尔运算符(||&&!)和括号来指定针对架构、操作系统和 Go 版本的确切构建规则。构建标签 //go:build (!darwin && !linux) || (darwin && !go1.12) —— 这实际上出现在 Go 标准库中 —— 指定该文件不应在 Linux 或 macOS 上编译,除非在 macOS 上的 Go 版本是 1.11 或更早版本。

还可以使用一些元构建约束。约束unix匹配任何类 Unix 平台,而cgo则在当前平台支持且启用了cgo时匹配(我在“Cgo 用于集成而非性能”中讨论过cgo)。

问题在于你应该何时使用文件名来指示在哪里运行代码,何时应该使用构建标签。因为构建标签允许使用二进制运算符,你可以用它们来指定更具体的平台集。Go 标准库有时采用一种“双重保险”的方法。标准库中的internal/cpu包针对 CPU 特征检测具有特定于平台的源代码。文件internal/cpu/cpu_arm64_darwin.go的名称表明它仅适用于使用苹果 CPU 的计算机。该文件还在其内部使用了//go:build arm64 && darwin && !ios行,以指示它应仅在构建苹果 Silicon Macs 时编译,而不适用于 iPhone 或 iPad。构建标签能够更详细地指定目标平台,但遵循文件名约定使人们能够轻松找到适合特定平台的正确文件。

除了表示 Go 版本、操作系统和 CPU 架构的内置构建标签之外,您还可以使用任何字符串作为自定义构建标签。然后,您可以使用-tags命令行标志来控制该文件的编译。例如,如果在包声明前的行上放置了//go:build gopher,则除非在go buildgo rungo test命令中包含-tags gopher标志,否则它将不会被编译。

自定义构建标签非常方便。如果有一个源文件暂时不想构建(可能是因为它尚未编译或者是一个尚未准备好包含的实验),在包声明前的行上放置//go:build ignore是一种惯用方法。在查看“使用集成测试和构建标签”中的集成测试时,您将看到自定义构建标签的另一个用途。

警告

写你的构建标签时,请确保//go:build之间没有任何空格。如果有空格,Go 将不会将其视为构建标签。

测试 Go 版本

尽管 Go 提供了强大的向后兼容性保证,但仍会出现 bug。自然而然地希望确保新版本不会破坏您的程序。您还可能会收到用户反馈,称您的库在旧版本的 Go 上运行时出现了意外行为。一个选项是安装第二个 Go 环境。例如,如果您想尝试版本 1.19.2,可以使用以下命令:

$ go install golang.org/dl/go1.19.2@latest
$ go1.19.2 download

然后可以使用命令go1.19.2而不是go命令来查看版本 1.19.2 是否适用于您的程序:

$ go1.19.2 build

一旦验证了你的代码可以工作,你可以卸载辅助环境。Go 将辅助 Go 环境存储在你的主目录的sdk目录中。要卸载,请从sdk目录中删除该环境,并从go/bin目录中删除二进制文件。以下是如何在 macOS、Linux 和 BSD 上执行此操作的步骤:

$ rm -rf ~/sdk/go.19.2
$ rm ~/go/bin/go1.19.2

使用 go help 了解更多关于 Go 工具的信息

通过go help命令可以了解更多关于 Go 工具和运行时环境的信息。它包含了关于这里提到的所有命令的详尽信息,以及诸如模块、导入路径语法和处理非公开源码的内容。例如,你可以通过输入go help importpath获取有关导入路径语法的信息。

练习

这些练习涵盖了本章介绍的一些工具。你可以在第十一章存储库exercise_solutions目录中找到答案。

  1. 前往联合国《世界人权宣言》页面,并将《世界人权宣言》的文本复制到名为english_rights.txt的文本文件中。点击“其他语言”链接,并将该文件的文本用几种其他语言分别复制到名为LANGUAGE_rights.txt的文件中。创建一个程序,将这些文件嵌入包级别变量中。你的程序应接受一个命令行参数,即语言名称。然后,它应该打印出该语言的《世界人权宣言》。

  2. 使用go install安装staticcheck。运行它来检查你的程序并修复它找到的任何问题。

  3. 在 Windows 上为 ARM64 交叉编译你的程序。如果你使用的是 ARM64 Windows 计算机,则在 Linux 上为 AMD64 进行交叉编译。

总结

在本章中,你学习了 Go 提供的工具,以改进软件工程实践和第三方代码质量工具。在下一章中,你将探索 Go 中的一个标志性特性:并发。

¹“linter”这个术语源于贝尔实验室 Unix 团队成员史蒂夫·约翰逊编写的原始lint程序,并在他的1978 年论文中描述。这个名字来自于干衣机中衣物脱落的微小纤维被滤网捕捉的现象。他将他的程序比作捕捉小错误的滤网。

第十二章《Go 语言并发》

并发是计算机科学术语,用于将单个进程分解为独立组件,并指定这些组件如何安全地共享数据。大多数语言通过操作系统级线程提供并发,通过尝试获取锁来共享数据。但 Go 语言不同。它的主要并发模型,可以说是 Go 语言最著名的特性,基于通信顺序进程(CSP)。这种并发风格在 1978 年由发明快速排序算法的 Tony Hoare 在一篇paper中描述。使用 CSP 实现的模式与标准模式一样强大,但更易于理解。

在这一章中,您将快速回顾 Go 语言并发的核心特性:goroutines(协程)、channels(通道)和select关键字。然后,您将查看一些常见的 Go 并发模式,并了解在何种情况下使用底层技术是更好的方法。

何时使用并发

让我们先谨慎一点。确保您的程序从并发中获益。当新手 Go 开发人员开始尝试并发时,他们往往会经历一系列阶段:

  1. 这太神奇了;我要把一切都放进 goroutines 中!

  2. 我的程序并没有更快。我正在向我的 channels 添加缓冲区。

  3. 我的 channels 在阻塞,我遇到了死锁。我将使用带有非常大缓冲区的 buffered channels。

  4. 我的 channels 仍然在阻塞。我将使用互斥锁。

  5. 算了吧,我放弃并发了。

人们被并发吸引,因为他们认为并发程序运行更快。不幸的是,这并不总是如此。更多的并发并不自动意味着事情更快,它可能会使代码更难理解。关键在于理解并发不等于并行。并发是一个工具,用来更好地解构您试图解决的问题。

并发代码是否并行(同时运行)取决于硬件和算法是否允许。1967 年,计算机科学先驱之一 Gene Amdahl 推导出了 Amdahl 定律。这是一个用于确定并行处理可以提升性能多少的公式,考虑了有多少工作必须顺序执行。如果您想深入了解 Amdahl 定律的细节,您可以在并发的艺术一书中了解更多,作者是 Clay Breshears(O’Reilly)。对于我们的目的,您只需理解更多的并发并不意味着更快。

广义上讲,所有程序都遵循相同的三步过程:获取数据,转换数据,然后输出结果。您是否应在程序中使用并发取决于数据在程序步骤之间的流动方式。有时候两个步骤可以并发进行,因为一个步骤的数据不需要等待另一个步骤才能继续进行,而其他时候两个步骤必须按顺序进行,因为一个步骤依赖于另一个步骤的输出。当您想要结合多个可以独立操作的操作的数据时,请使用并发。

另一个重要的注意事项是,并发不值得使用,如果运行并发的进程所花费的时间不多。并发并非免费;许多常见的内存算法非常快速,通过并发传递值的开销超过了通过并行运行并发代码可能获得的任何潜在时间节省。这就是为什么并发操作通常用于 I/O 的原因;读取或写入磁盘或网络比除了最复杂的内存过程以外的所有过程慢数千倍。如果您不确定并发是否有帮助,请首先将代码串行化,然后编写基准测试来比较并发实现的性能。(参见“使用基准测试”了解如何对您的代码进行基准测试。)

让我们来考虑一个例子。假设您正在编写一个调用其他三个网络服务的 Web 服务。您的程序将数据发送到其中两个服务,然后将这两个调用的结果发送给第三个服务,并返回结果。整个过程必须在 50 毫秒内完成,否则应返回错误。这是并发的一个很好的使用案例,因为代码中有需要执行 I/O 的部分,可以独立运行而无需相互交互,还有一个部分用于合并结果,并且代码需要运行的时间有限。在本章末尾,您将看到如何实现此代码。

Goroutines

Go 语言并发模型的核心概念是goroutine。要理解 goroutine,让我们先定义几个术语。首先是进程。进程是计算机操作系统正在运行的程序的实例。操作系统会为进程分配一些资源,如内存,并确保其他进程无法访问这些资源。一个进程由一个或多个线程组成。线程是操作系统分配运行时间的执行单位。同一进程内的线程共享资源。根据处理器核心数量,CPU 可以同时执行一个或多个线程的指令。操作系统的一个任务是调度线程在 CPU 上运行,以确保每个进程(及其内部的每个线程)都有运行的机会。

将 Goroutine 看作是由 Go 运行时管理的轻量级线程。当 Go 程序启动时,Go 运行时会创建一些线程,并启动一个单独的 Goroutine 来运行您的程序。您的程序创建的所有 Goroutine(包括初始的)都会由 Go 运行时调度器自动分配到这些线程中,就像操作系统在 CPU 核心之间调度线程一样。这看起来可能是额外的工作,因为底层操作系统已经包含了管理线程和进程的调度器,但它有几个好处:

  • Goroutine 的创建速度比线程快,因为您不需要创建一个操作系统级别的资源。

  • Goroutine 的初始栈大小比线程栈大小小,并且可以根据需要增长。这使得 Goroutine 更加内存高效。

  • 在 Goroutine 之间切换比在线程之间切换更快,因为它完全在进程内部进行,避免了(相对而言)缓慢的操作系统调用。

  • Goroutine 调度器能够优化其决策,因为它是 Go 进程的一部分。调度器与网络轮询器一起工作,当 Goroutine 因 I/O 阻塞时检测到可以取消调度。它还与垃圾回收器集成,确保工作在分配给 Go 进程的所有操作系统线程之间得到适当平衡。

这些优势使得 Go 程序能够同时启动数百、数千甚至数万个 Goroutine。如果在具有本地线程的语言中尝试启动数千个线程,您的程序将变得非常缓慢。

提示

如果你对了解调度器如何工作感兴趣,请观看 Kavya Joshi 在 GopherCon 2018 上的演讲 “调度器传奇”

在函数调用前加上 go 关键字可以启动一个 Goroutine。与任何其他函数一样,您可以传递参数来初始化其状态。但是,函数返回的任何值都会被忽略。

任何函数都可以作为 Goroutine 启动。这与 JavaScript 不同,JavaScript 中只有在函数使用 async 关键字声明时,该函数才会异步运行。然而,在 Go 中,习惯上使用一个包装业务逻辑的闭包来启动 Goroutine。闭包负责并发的簿记工作。以下示例代码演示了这个概念:

func process(val int) int {
    // do something with val
}

func processConcurrently(inVals []int) []int {
    // create the channels
    in := make(chan int, 5)
    out := make(chan int, 5)
    // launch processing goroutines
    for i := 0; i < 5; i++ {
        go func() {
            for val := range in {
                out <- process(val)
            }
        }()
    }
    // load the data into the in channel in another goroutine
    // read the data from the out channel
    // return the data
}

在这段代码中,processConcurrently 函数创建了一个闭包,从通道中读取值,并将它们传递给 process 函数中的业务逻辑。process 函数完全不知道它是在一个 goroutine 中运行。闭包然后将 process 的结果写回到另一个通道中。(我将在下一节简要概述通道。)这种责任分离使得你的程序模块化和可测试,并将并发性从你的 API 中分离出去。选择使用类似线程的模型来进行并发意味着 Go 程序避免了 Bob Nystrom 在他著名博客文章 "你的函数是什么颜色?" 中描述的“函数着色”问题。

你可以在 The Go Playground 上或 第十二章存储库 中的 sample_code/goroutine 目录中找到完整的示例。

通道

Goroutine 使用 通道 进行通信。与切片和映射类似,通道是使用 make 函数创建的内置类型:

ch := make(chan int)

像映射一样,通道也是引用类型。当你将一个通道传递给一个函数时,你实际上是传递了一个指向该通道的指针。与映射和切片一样,通道的零值是 nil

读取、写入和缓冲

使用 <- 操作符与通道交互。通过将 <- 操作符放置在通道变量的左侧来从通道中读取,通过将其放置在右侧来向通道中写入:

a := <-ch // reads a value from ch and assigns it to a
ch <- b   // write the value in b to ch

每个写入到通道的值只能被读取一次。如果有多个 goroutine 从同一个通道读取,那么写入到通道的值只会被其中一个读取。

单个 goroutine 很少会同时从同一个通道读取和写入。当将通道分配给变量或字段,或者将其传递给函数时,请在 chan 关键字之前使用箭头(ch <-chan int)来指示该 goroutine 只从通道中 读取。在 chan 关键字之后使用箭头(ch chan<- int)来指示该 goroutine 只 写入 通道。这样做可以让 Go 编译器确保通道只能被函数读取或写入。

默认情况下,通道是 无缓冲 的。每次向打开的无缓冲通道写入时,写入 goroutine 都会暂停,直到另一个 goroutine 从同一通道读取。同样地,从打开的无缓冲通道读取时,读取 goroutine 也会暂停,直到另一个 goroutine 向同一通道写入。这意味着你不能在没有至少两个并发运行的 goroutine 的情况下写入或读取无缓冲通道。

Go 语言还拥有 缓冲 通道。这些通道可以缓冲有限数量的写入操作而不会阻塞。如果在从通道读取之前缓冲区已满,那么对通道的后续写入会使写入 goroutine 暂停,直到有读取操作发生。与写入到满缓冲区的通道会阻塞一样,从空缓冲区读取的通道也会阻塞。

创建缓冲通道时,可以在创建通道时指定缓冲区的容量:

ch := make(chan int, 10)

内置函数lencap返回有关缓冲通道的信息。使用len可以查找当前缓冲区中有多少个值,使用cap可以查找缓冲区的最大容量。缓冲区的容量不能更改。

注意

将无缓冲通道传递给lencap都将返回 0。这是有道理的,因为按定义,无缓冲通道没有缓冲区来存储值。

大多数情况下,应该使用无缓冲通道。在“了解何时使用缓冲和非缓冲通道”,我将讨论使用缓冲通道的情况。

使用for-range和通道

你也可以通过使用for-range循环从通道中读取:

for v := range ch {
    fmt.Println(v)
}

不像其他for-range循环,这里只声明了一个变量用于通道的值,即v。如果通道打开并且通道上有值可用,则将其赋给v并执行循环体。如果通道上没有可用值,则 goroutine 暂停,直到通道有值可用或通道关闭。循环会继续,直到通道关闭,或达到breakreturn语句。

关闭通道

当你完成向通道写入数据后,使用内置的close函数关闭它:

close(ch)

一旦通道关闭,任何尝试向其写入或再次关闭它的操作都会引发恐慌。有趣的是,尝试从关闭的通道读取始终成功。如果通道是带缓冲的,并且还有未读取的值,它们将按顺序返回。如果通道是无缓冲的或缓冲通道没有更多值,则通道的类型的零值将返回。

这引出了一个问题,这个问题在你使用映射时可能会很熟悉:当你的代码从通道中读取时,如何区分是因为通道关闭而返回的零值,还是因为写入的零值?由于 Go 语言试图保持一致性,这里有一个熟悉的答案——使用逗号-ok 惯用法来检测通道是否已关闭:

v, ok := <-ch

如果ok设置为true,则通道是打开的。如果设置为false,则通道已关闭。

提示

每当从可能已关闭的通道读取时,请使用逗号-ok 惯用法确保通道仍然打开。

关闭通道的责任属于写入通道的 goroutine。请注意,只有当有 goroutine 在等待通道关闭时(例如使用for-range循环从通道读取数据时),才需要关闭通道。由于通道只是另一个变量,Go 的运行时可以检测到不再被引用的通道并对其进行垃圾回收。

通道是 Go 并发模型的两大特色之一。它们引导你将代码视为一系列阶段,并清晰地表达数据依赖关系,这使得推理并发变得更容易。其他语言依赖于全局共享状态来在线程之间通信。这种可变的共享状态使得理解数据如何在程序中流动变得困难,进而难以确定两个线程是否真的是独立的。

理解通道的行为方式

通道有多种状态,在读取、写入或关闭时有不同的行为。使用 表 12-1 来理清它们。

表 12-1. 通道的行为方式

无缓冲,开放状态 无缓冲,关闭状态 带缓冲,开放状态 带缓冲,关闭状态
读取 等待直到有东西被写入 返回零值(使用 comma ok 来查看是否关闭) 如果缓冲区为空则等待 返回缓冲区中的剩余值;如果缓冲区为空则返回零值(使用 comma ok 来查看是否关闭) 永久挂起
写入 等待直到有东西被读取 PANIC 如果缓冲区满则等待 PANIC 永久挂起
关闭 可行 PANIC 可行,剩余值仍在 PANIC PANIC

你必须避免导致 Go 程序引发 panic 的情况。如前所述,标准模式是在写入 goroutine 写入完毕后关闭通道。当多个 goroutine 向同一通道写入时,情况会变得更复杂,因为在同一通道上调用两次 close 会导致 panic。此外,如果一个 goroutine 中关闭了通道,另一个 goroutine 中写入该通道也会触发 panic。解决这个问题的方法是使用 sync.WaitGroup,你将在 “使用 WaitGroups” 中看到一个示例。

nil 通道在某些情况下也可能存在风险,但在其他情况下也是有用的。你将在 “关闭 select 中的 case” 中了解更多相关内容。

select

select 语句是 Go 并发模型的另一大特色。它是 Go 中的并发控制结构,优雅地解决了一个常见问题:如果可以执行两个并发操作,那么应该先执行哪一个?你不能偏袒某个操作,否则有些情况将永远不会被处理。这被称为 饥饿 问题。

select 关键字允许一个 goroutine 从多个通道中读取或写入其中一个。它看起来与空的 switch 语句非常相似:

select {
case v := <-ch:
    fmt.Println(v)
case v := <-ch2:
    fmt.Println(v)
case ch3 <- x:
    fmt.Println("wrote", x)
case <-ch4:
    fmt.Println("got value on ch4, but ignored it")
}

select 中的每个 case 都是对通道的读取或写入。如果某个 case 可以读取或写入,它将与 case 的主体一起执行。与 switch 类似,select 中的每个 case 都创建了自己的代码块。

如果多个情况都有可以读取或写入的通道会发生什么?select算法很简单:它从可以继续的任何情况中随机选择;顺序不重要。这与switch语句非常不同,后者总是选择第一个解析为truecase。它还清楚地解决了饥饿问题,因为没有一个case优先于另一个,并且所有情况同时被检查。

select随机选择的另一个优势是它防止了最常见的死锁原因之一:以不一致的顺序获取锁。如果有两个 goroutine 都访问相同的两个通道,它们必须在两个 goroutine 中以相同的顺序访问,否则它们将死锁。这意味着两者都无法继续,因为它们正在等待对方。如果您的 Go 应用程序中的每个 goroutine 都死锁,Go 运行时将终止您的程序(参见示例 12-1)。

示例 12-1. 死锁的 goroutine
func main() {
    ch1 := make(chan int)
    ch2 := make(chan int)
    go func() {
        inGoroutine := 1
        ch1 <- inGoroutine
        fromMain := <-ch2
        fmt.Println("goroutine:", inGoroutine, fromMain)
    }()
    inMain := 2
    ch2 <- inMain
    fromGoroutine := <-ch1
    fmt.Println("main:", inMain, fromGoroutine)
}

如果您在Go Playground第十二章库的 sample_code/deadlock 目录中运行此程序,您将看到以下错误:

fatal error: all goroutines are asleep - deadlock!

请记住,main在启动时由 Go 运行时在一个 goroutine 中运行。显式启动的 goroutine 在ch1被读取之前无法继续,主 goroutine 在ch2被读取之前也无法继续。

如果主 goroutine 中的通道读取和通道写入被包裹在select中,则避免了死锁(参见示例 12-2)。

示例 12-2. 使用select避免死锁
func main() {
    ch1 := make(chan int)
    ch2 := make(chan int)
    go func() {
        inGoroutine := 1
        ch1 <- inGoroutine
        fromMain := <-ch2
        fmt.Println("goroutine:", inGoroutine, fromMain)
    }()
    inMain := 2
    var fromGoroutine int
    select {
    case ch2 <- inMain:
    case fromGoroutine = <-ch1:
    }
    fmt.Println("main:", inMain, fromGoroutine)
}

如果您在Go Playground第十二章库的 sample_code/select 目录中运行此程序,您将获得如下输出:

main: 2 1

因为select检查其所有情况是否能继续,所以避免了死锁。显式启动的 goroutine 将值 1 写入了ch1,因此主 goroutine 中对ch1的读取到fromGoroutine是成功的。

尽管此程序不会死锁,但仍未能完成正确操作。在启动的 goroutine 中,fmt.Println语句永远不会执行,因为该 goroutine 已暂停,等待从ch2读取值。当主 goroutine 退出时,程序也退出并终止所有剩余的 goroutine,这在技术上解决了暂停的问题。然而,您应确保所有 goroutine 都能正确退出,以免泄漏它们。我在“始终清理您的 goroutine”中详细讨论了这个问题。

注意

使此程序正常运行需要一些您将在本章后面学到的技术。您可以在Go Playground上找到一个有效的解决方案。

由于select负责在多个通道之间进行通信,因此通常嵌入在for循环中:

for {
    select {
    case <-done:
        return
    case v := <-ch:
        fmt.Println(v)
    }
}

这种组合通常被称为 for-select 循环。在使用 for-select 循环时,你必须包含一种退出循环的方法。你会在 “使用上下文终止 Goroutines” 中看到一种方法。

就像 switch 语句一样,select 语句也可以有一个 default 子句。和 switch 类似,当没有可以读取或写入的通道时,会选择 default。如果你想在通道上实现非阻塞读取或写入,使用带有 defaultselect。下面的代码在 ch 中没有可读取的值时不会等待,而是立即执行 default 的主体:

select {
case v := <-ch:
    fmt.Println("read from ch:", v)
default:
    fmt.Println("no value written to ch")
}

你将会在 “实现反压力” 中看到 default 的用法。

注意

for-select 循环内部有一个 default 案例几乎总是不正确的。当循环中任何一个案例没有读取或写入时,它会在每次循环时触发。这会使得你的 for 循环持续运行,消耗大量的 CPU。

并发实践和模式

现在你已经了解了 Go 提供的并发基本工具,让我们来看看一些并发的最佳实践和模式。

保持你的 API 不受并发的影响

并发是一个实现细节,良好的 API 设计应尽可能隐藏实现细节。这样可以在不改变代码调用方式的情况下改变代码的工作方式。

实际上,这意味着你不应该在 API 的类型、函数和方法中暴露通道或互斥锁(我将在 “何时使用互斥锁而不是通道” 中详细讨论互斥锁)。如果你暴露一个通道,你将把通道管理的责任放在 API 用户身上。用户则需要担心诸如通道是缓冲的、已关闭或 nil 的问题。他们也可能通过以意外的顺序访问通道或互斥锁来触发死锁。

注意

这并不意味着你不应该将通道作为函数参数或结构体字段。它意味着它们不应该被导出。

这条规则有一些例外。如果你的 API 是一个带有并发辅助函数的库,通道将成为其 API 的一部分。

Goroutines、for 循环和变量变化

大多数情况下,用于启动 Goroutine 的闭包没有参数。相反,它捕获了在声明它的环境中的值。在 Go 1.22 之前,有一个常见的情况是这种方式不起作用:尝试捕获 for 循环的索引或值。正如 “for-range 值是副本” 和 “使用 go.mod” 中提到的,Go 1.22 引入了一个破坏性变化,改变了 for 循环的行为,使其在每次迭代时为索引和值创建新变量,而不是重复使用单个变量。

以下代码演示了这个变更值得的原因。你可以在 GitHub 上的 goroutine_for_loop 仓库 找到它,这个仓库属于《学习 Go 第二版》组织。

如果你在 Go 1.21 或更早版本运行以下代码(或在 Go 1.22 或更新版本中,go.mod 文件的 go 指令设置为 1.21 或更早版本),你会看到一个微妙的 bug:

func main() {
    a := []int{2, 4, 6, 8, 10}
    ch := make(chan int, len(a))
    for _, v := range a {
        go func() {
            ch <- v * 2
        }()
    }
    for i := 0; i < len(a); i++ {
        fmt.Println(<-ch)
    }
}

对于 a 中的每个值,都会启动一个 goroutine。看起来每个 goroutine 接收到的值都不同,但实际运行代码显示的情况却不同:

20
20
20
20
20

在早期的 Go 版本中,每个 goroutine 都向 ch 写入 20 的原因是,每个 goroutine 的闭包捕获了相同的变量。for 循环中的索引和数值变量在每次迭代中都被重用。变量 v 最后被赋的值是 10。当 goroutine 运行时,它们看到的就是这个值。

升级到 Go 1.22 或更高版本,并将 go.mod 中的 go 指令值改为 1.22 或更高版本,将会改变 for 循环的行为,使其在每次迭代时创建新的索引和数值变量。这样会得到预期的结果,每个 goroutine 接收到不同的值:

20
8
4
12
16

如果你无法升级到 Go 1.22,可以通过两种方式解决这个问题。首先是在循环内部复制数值来遮蔽数值:

for _, v := range a {
    v := v
    go func() {
        ch <- v * 2
    }()
}

如果你想避免遮蔽并使数据流更加清晰,也可以将该值作为参数传递给 goroutine:

for _, v := range a {
    go func(val int) {
        ch <- val * 2
    }(v)
}

虽然 Go 1.22 可以避免 for 循环中索引和数值变量的问题,但对于闭包中捕获的其他变量仍需小心。每当闭包依赖可能会改变的变量值时,无论是否用作 goroutine,都必须将该值传递给闭包,或者确保为每个引用该变量的闭包创建一个独立的变量副本。

小贴士

每当闭包使用一个可能会改变的变量时,使用参数将该变量的当前值传递给闭包。

始终清理你的 Goroutines

每当启动一个 goroutine 函数时,一定要确保它最终会退出。与变量不同,Go 运行时无法检测出 goroutine 是否会再次被使用。如果一个 goroutine 没有退出,那么分配给其栈上变量的所有内存都将保留,任何根据 goroutine 栈上变量分配的堆上内存也无法被垃圾回收。这被称为 goroutine 泄漏

一个 goroutine 并不一定能保证退出。例如,假设你将 goroutine 用作生成器:

func countTo(max int) <-chan int {
    ch := make(chan int)
    go func() {
        for i := 0; i < max; i++ {
            ch <- i
        }
        close(ch)
    }()
    return ch
}

func main() {
    for i := range countTo(10) {
        fmt.Println(i)
    }
}
注意

这只是一个简短的示例;不要使用 goroutine 生成数字列表。这太简单了,违反了我们“何时使用并发”的指导原则之一。

在通常情况下,当你使用完所有值时,goroutine 会退出。但是,如果你提前退出循环,goroutine 就会永远阻塞,等待从通道中读取值:

func main() {
    for i := range countTo(10) {
        if i > 5 {
            break
        }
        fmt.Println(i)
    }
}

使用上下文终止 Goroutines

要解决countTo goroutine 泄漏的问题,你需要一种方法告诉 goroutine 现在是停止处理的时候了。在 Go 中,你通过使用上下文来解决这个问题。下面是一个重写的countTo示例,演示了这种技术。你可以在第十二章存储库sample_code/context_cancel目录中找到这段代码。

func countTo(ctx context.Context, max int) <-chan int {
    ch := make(chan int)
    go func() {
        defer close(ch)
        for i := 0; i < max; i++ {
            select {
            case <-ctx.Done():
                return
            case ch <- i:
            }
        }
    }()
    return ch
}

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()
    ch := countTo(ctx, 10)
    for i := range ch {
        if i > 5 {
            break
        }
        fmt.Println(i)
    }
}

countTo函数修改为除了max之外还接受一个context.Context参数。goroutine 中的for循环也已更改。现在是一个带有两个 case 的for-select循环。一个尝试向ch写入。另一个 case 检查上下文的Done方法返回的通道。如果它返回一个值,你退出for-select循环和 goroutine。现在,你有了一种方法可以在读取每个值时防止 goroutine 泄漏。

这就引出了一个问题,你如何让Done通道返回一个值?它通过上下文取消来触发。在main函数中,你使用context包中的WithCancel函数创建了一个上下文和一个取消函数。接下来,你使用defermain函数退出时调用cancel。这关闭了Done返回的通道,并且由于关闭的通道始终返回一个值,它确保运行countTo的 goroutine 退出。

使用上下文来终止 goroutine 是一个非常常见的模式。它允许你基于调用堆栈中较早的某些东西来停止 goroutine。在“取消”,你将详细了解如何使用上下文告诉一个或多个 goroutine 现在是时候关闭了。

了解何时使用有缓冲和无缓冲通道

在 Go 并发中掌握最复杂的技术之一是决定何时使用缓冲通道。默认情况下,通道是无缓冲的,并且很容易理解:一个 goroutine 写入并等待另一个 goroutine 接管它的工作,就像接力赛中的接力棒一样。缓冲通道则复杂得多。你必须选择一个大小,因为缓冲通道永远不会有无限的缓冲区。正确使用缓冲通道意味着你必须处理缓冲区已满并且你的写入 goroutine 因等待读取 goroutine 而阻塞的情况。那么,什么是正确使用缓冲通道的方式呢?

缓冲通道的情况很微妙。简单地说:当你知道自己启动了多少个 goroutine 时,想要限制你将要启动的 goroutine 数量,或者想要限制排队的工作量时,缓冲通道是有用的。

缓冲通道在你想要从启动的一组 goroutine 中收集数据或者想要限制并发使用时非常有效。它们还有助于管理系统排队的工作量,防止你的服务落后并变得不堪重负。以下是几个示例,展示了它们的使用方式。

在第一个示例中,您正在处理通道上的前 10 个结果。为此,您启动 10 个 goroutine,每个 goroutine 将其结果写入缓冲通道:

func processChannel(ch chan int) []int {
    const conc = 10
    results := make(chan int, conc)
    for i := 0; i < conc; i++ {
        go func() {
            v := <- ch
            results <- process(v)
        }()
    }
    var out []int
    for i := 0; i < conc; i++ {
        out = append(out, <-results)
    }
    return out
}

您确切地知道启动了多少个 goroutine,并且希望每个 goroutine 在完成其工作后立即退出。这意味着您可以为每个启动的 goroutine 创建一个带有一个空间的缓冲通道,并让每个 goroutine 向该 goroutine 写入数据而不阻塞。然后,您可以循环遍历缓冲通道,读取写入的值。当所有值都已读取时,您返回结果,知道没有泄漏任何 goroutine。

您可以在第十二章存储库sample_code/buffered_channel_work目录中找到此代码。

实现背压

另一种可以使用缓冲通道实现的技术是背压。这似乎违反直觉,但是当系统组件限制其愿意执行的工作量时,系统整体表现更佳。您可以使用缓冲通道和select语句来限制系统中同时请求的数量:

type PressureGauge struct {
    ch chan struct{}
}

func New(limit int) *PressureGauge {
    return &PressureGauge{
        ch: make(chan struct{}, limit),
    }
}

func (pg *PressureGauge) Process(f func()) error {
    select {
    case pg.ch <- struct{}{}:
        f()
        <-pg.ch
        return nil
    default:
        return errors.New("no more capacity")
    }
}

在此代码中,您创建一个包含可以容纳多个“令牌”的缓冲通道和要运行的函数的结构体。每次 goroutine 想要使用函数时,它调用Process。这是同一个 goroutine 读取和写入同一个通道的少数例子之一。select尝试向通道写入令牌。如果可以,函数运行,然后从缓冲通道读取一个令牌。如果无法写入令牌,则运行default case,并返回错误。以下是一个快速示例,使用此代码与内置 HTTP 服务器(您将在“服务器”中学习更多关于与 HTTP 的工作):

func doThingThatShouldBeLimited() string {
    time.Sleep(2 * time.Second)
    return "done"
}

func main() {
    pg := New(10)
    http.HandleFunc("/request", func(w http.ResponseWriter, r *http.Request) {
        err := pg.Process(func() {
            w.Write([]byte(doThingThatShouldBeLimited()))
        })
        if err != nil {
            w.WriteHeader(http.StatusTooManyRequests)
            w.Write([]byte("Too many requests"))
        }
    })
    http.ListenAndServe(":8080", nil)
}

您可以在第十二章存储库sample_code/backpressure目录中找到此代码。

关闭select中的 case

当您需要从多个并发源合并数据时,select关键字非常有用。但是,您需要正确处理关闭的通道。如果select中的某个 case 正在读取一个关闭的通道,则它总是成功的,返回零值。每次选择该 case 时,您需要检查确保该值有效并跳过该 case。如果读取被分散,您的程序将浪费大量时间读取垃圾值。即使非关闭通道上有大量活动,您的程序仍会花费一部分时间从关闭的通道读取,因为select随机选择一个 case。

当发生这种情况时,你依赖于看起来像是错误的东西:读取一个nil通道。正如你之前看到的,从或写入nil通道会导致你的代码永远挂起。虽然如果由于 bug 触发是不好的,但你可以使用nil通道来禁用select中的case。当检测到通道已关闭时,将通道的变量设置为nil。相关的 case 将不再运行,因为从nil通道读取永远不会返回值。这里是一个for-select循环,从两个通道中读取直到两个通道都关闭:

// in and in2 are channels
for count := 0; count < 2; {
    select {
    case v, ok := <-in:
        if !ok {
            in = nil // the case will never succeed again!
            count++
            continue
        }
        // process the v that was read from in
    case v, ok := <-in2:
        if !ok {
            in2 = nil // the case will never succeed again!
            count++
            continue
        }
        // process the v that was read from in2
    }
}

你可以在Go Playgroundsample_code/close_case目录中的第十二章存储库中尝试这段代码。

超时代码

大多数交互式程序必须在一定时间内返回响应。在 Go 语言中,使用并发可以管理请求(或请求的一部分)运行的时间。其他语言在 promise 或 future 之上引入了额外的功能来添加这种功能,但 Go 语言的超时习惯表明了如何从现有部件构建复杂功能。让我们来看一下:

func timeLimitT any T, limit time.Duration) (T, error) {
    out := make(chan T, 1)
    ctx, cancel := context.WithTimeout(context.Background(), limit)
    defer cancel()
    go func() {
        out <- worker()
    }()
    select {
    case result := <-out:
        return result, nil
    case <-ctx.Done():
        var zero T
        return zero, errors.New("work timed out")
    }
}

每当你需要在 Go 中限制操作花费的时间时,你会看到这种模式的变体。我在第十四章中讨论上下文,并详细介绍了如何使用超时在“带截止日期的上下文”中。现在,你只需要知道达到超时会取消上下文。上下文的Done方法返回一个通道,在上下文由于超时或调用上下文的取消方法而被取消时返回一个值。你可以通过使用context包中的WithTimeout函数创建一个定时上下文,并使用time包中的常量指定等待时间(我将在time中更多地讨论time包)。

一旦设置了上下文,就在一个 goroutine 中运行工作程序,然后使用select选择两种情况之间的一种。第一种情况在工作完成时从out通道读取值。第二种情况等待Done方法返回的通道返回一个值,就像在“使用上下文终止 Goroutines”中看到的那样。如果是这样,就返回超时错误。你可以写入一个大小为 1 的缓冲通道,以便即使Done首先触发,goroutine 中的通道写入也会完成。

你可以在Go Playgroundsample_code/time_out目录中的第十二章存储库中尝试这段代码。

注意

如果 timeLimit 在 goroutine 完成处理之前退出,那么 goroutine 会继续运行,最终将返回的值写入缓冲通道并退出。你只是不处理返回的结果。如果想在不再等待 goroutine 完成时停止其工作,可以使用上下文取消,我将在 “Cancellation” 中讨论。

使用 WaitGroups

有时一个 goroutine 需要等待多个 goroutine 完成它们的工作。如果你等待单个 goroutine,可以使用之前看到的上下文取消模式。但如果你正在等待多个 goroutine,则需要使用 WaitGroup,它位于标准库中的 sync 包中。这里是一个简单的例子,你可以在 The Go Playground 运行,或者在 第十二章仓库sample_code/waitgroup 目录中运行:

func main() {
    var wg sync.WaitGroup
    wg.Add(3)
    go func() {
        defer wg.Done()
        doThing1()
    }()
    go func() {
        defer wg.Done()
        doThing2()
    }()
    go func() {
        defer wg.Done()
        doThing3()
    }()
    wg.Wait()
}

sync.WaitGroup 不需要初始化,只需声明,因为它的零值是有用的。sync.WaitGroup 上有三个方法:Add,用于增加等待的 goroutine 计数器;Done,在 goroutine 完成时减少计数器,并且当它完成时调用;Wait,暂停其 goroutine 直到计数器为零。通常只调用一次 Add,并指定将要启动的 goroutine 数量。在 goroutine 内部调用 Done。为了确保即使 goroutine 恐慌也会调用 Done,可以使用 defer

你会注意到,你并没有显式地传递 sync.WaitGroup。有两个原因。第一个是你必须确保每个使用 sync.WaitGroup 的地方都使用同一个实例。如果将 sync.WaitGroup 传递给 goroutine 函数并且不使用指针,则该函数有一个副本,调用 Done 不会减少原始的 sync.WaitGroup。通过使用闭包捕获 sync.WaitGroup,你确保每个 goroutine 引用的是同一个实例。

第二个原因是设计。记住,你应该将并发性从你的 API 中分离出去。正如你之前在通道中看到的,通常的模式是使用一个闭包启动一个 goroutine,该闭包封装业务逻辑周围的并发问题,而函数则提供算法。

让我们看一个更现实的例子。正如我之前提到的,当你有多个 goroutine 向同一个通道写入时,你需要确保只关闭被写入的通道一次。sync.WaitGroup 就非常适合这种情况。让我们看看它在一个函数中的运作方式,该函数并发处理通道中的值,将结果收集到一个切片中,并返回该切片:

func processAndGatherT, R any R, num int) []R {
    out := make(chan R, num)
    var wg sync.WaitGroup
    wg.Add(num)
    for i := 0; i < num; i++ {
        go func() {
            defer wg.Done()
            for v := range in {
                out <- processor(v)
            }
        }()
    }
    go func() {
        wg.Wait()
        close(out)
    }()
    var result []R
    for v := range out {
        result = append(result, v)
    }
    return result
}

在这个示例中,你启动了一个监控 goroutine,等待所有处理 goroutine 退出。当它们退出时,监控 goroutine 在输出通道上调用close。当out关闭并且缓冲区为空时,for-range通道循环退出。最后,函数返回处理后的值。你可以在第十二章存储库sample_code/waitgroup_close_once目录中尝试此代码。

虽然WaitGroups很方便,但在协调 goroutine 时不应该是你的首选。仅在所有工作 goroutine 退出后需要进行清理(例如关闭它们写入的通道)时才使用它们。

仅运行代码一次

正如我在“避免 init 函数(如果可能的话)”中所述,init应该保留用于有效不可变的包级别状态的初始化。然而,有时候你想要延迟加载,或者在程序启动后仅调用某些初始化代码一次。这通常是因为初始化相对缓慢,并且可能并非每次程序运行都需要。sync包包含一个称为Once的方便类型,可以实现这种功能。让我们快速看一下它是如何工作的。假设你有一些需要长时间初始化的代码:

type SlowComplicatedParser interface {
    Parse(string) string
}

func initParser() SlowComplicatedParser {
    // do all sorts of setup and loading here
}

下面是如何使用sync.Once延迟初始化SlowComplicatedParser的方法:

var parser SlowComplicatedParser
var once sync.Once

func Parse(dataToParse string) string {
    once.Do(func() {
        parser = initParser()
    })
    return parser.Parse(dataToParse)
}

有两个包级别的变量:parser,类型为SlowComplicatedParser,以及once,类型为sync.Once。与sync.WaitGroup类似,你无需配置sync.Once的实例。这是一个例子,使零值变得有用,这是 Go 语言中的一种常见模式。

sync.WaitGroup类似,你必须确保不复制sync.Once的实例,因为每个副本都有自己的状态来指示它是否已经被使用过。通常在函数内声明sync.Once实例是错误的做法,因为每次函数调用都会创建一个新实例,并且不会记住先前的调用。

在这个示例中,你需要确保parser仅初始化一次,因此你将parser的值设置在传递给onceDo方法的闭包中。如果调用Parse超过一次,once.Do将不会再次执行闭包。

你可以在The Go Playground第十二章存储库中的sample_code/sync_once目录中尝试此代码。

Go 1.21 添加了一些辅助函数,使得执行函数仅一次变得更加容易:sync.OnceFuncsync.OnceValuesync.OnceValues。这三个函数之间唯一的区别是传入函数的返回值数量(分别为零、一个或两个)。sync.OnceValuesync.OnceValues函数是通用的,因此它们适应原始函数返回值的类型。

使用这些函数非常简单。你将原始函数传递给辅助函数,然后得到一个仅调用原始函数一次的函数。原始函数返回的值会被缓存。以下是如何使用sync.OnceValue重写上一个示例中的Parse函数:

var initParserCached func() SlowComplicatedParser = sync.OnceValue(initParser)

func Parse(dataToParse string) string {
    parser := initParserCached()
    return parser.Parse(dataToParse)
}

initParserCached变量在包级别被赋值为sync.OnceValue返回的函数时,说明initParser被传递给它了。第一次调用initParserCached时,也会调用initParser,并缓存它的返回值。每次后续调用initParserCached时,都会返回缓存的值。这意味着你可以去掉包级别的parser变量。

你可以在Go Playground上尝试这段代码,或者在第十二章的代码库sample_code/sync_value目录中尝试。

将你的并发工具整合起来

让我们回到本章第一节的示例中。你有一个调用三个 Web 服务的函数。你向其中两个服务发送数据,然后将这两次调用的结果发送给第三个服务,并返回结果。整个过程必须在 50 毫秒内完成,否则将返回错误。

你将从你调用的函数开始:

func GatherAndProcess(ctx context.Context, data Input) (COut, error) {
    ctx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
    defer cancel()

    ab := newABProcessor()
    ab.start(ctx, data)
    inputC, err := ab.wait(ctx)
    if err != nil {
        return COut{}, err
    }

    c := newCProcessor()
    c.start(ctx, inputC)
    out, err := c.wait(ctx)
    return out, err
}

首先要做的是设置一个超时为 50 毫秒的context.Context,就像你在“Time Out Code”中看到的一样。

创建完 context 后,使用defer确保调用 context 的cancel函数。正如我在“Cancellation”中会讨论的,你必须调用这个函数,否则资源会泄漏。

你将AB作为两个并行调用的服务名称,所以你将创建一个新的abProcessor来调用它们。然后你通过调用start方法开始处理,并通过调用wait方法等待结果。

wait返回时,你进行标准的错误检查。如果一切顺利,你调用第三个服务,称之为C。逻辑与之前相同。通过在cProcessor上调用start方法开始处理,然后通过在cProcessor上调用wait方法等待结果。然后返回wait方法调用的结果。

这看起来很像标准的顺序代码,没有并发。让我们看看abProcessorcProcessor中是如何进行并发的:

type abProcessor struct {
    outA chan aOut
    outB chan bOut
    errs chan error
}

func newABProcessor() *abProcessor {
    return &abProcessor{
        outA: make(chan aOut, 1),
        outB: make(chan bOut, 1),
        errs: make(chan error, 2),
    }
}

abProcessor有三个字段,全部都是通道。它们是outAoutBerrs。接下来你将看到如何使用这些通道。注意每个通道都是有缓冲的,这样写入它们的 goroutine 在写完后就可以退出,而不必等待读取。errs通道的缓冲大小为2,因为最多可能会有两个错误写入其中。

接下来是start方法的实现:

func (p *abProcessor) start(ctx context.Context, data Input) {
    go func() {
        aOut, err := getResultA(ctx, data.A)
        if err != nil {
            p.errs <- err
            return
        }
        p.outA <- aOut
    }()
    go func() {
        bOut, err := getResultB(ctx, data.B)
        if err != nil {
            p.errs <- err
            return
        }
        p.outB <- bOut
    }()
}

start 方法启动了两个 goroutine。第一个调用 getResultA 来与 A 服务通信。如果调用返回错误,就向 errs 通道写入。否则,向 outA 通道写入。由于这些通道是有缓冲的,所以无论写入哪个通道,goroutine 都不会挂起。同时注意,你将上下文传递给 getResultA,这允许它在超时时取消处理。

第二个 goroutine 与第一个完全相同,只是调用 getResultB 并在成功时向 outB 通道写入。

让我们看看 ABProcessorwait 方法是什么样的:

func (p *abProcessor) wait(ctx context.Context) (cIn, error) {
    var cData cIn
    for count := 0; count < 2; count++ {
        select {
        case a := <-p.outA:
            cData.a = a
        case b := <-p.outB:
            cData.b = b
        case err := <-p.errs:
            return cIn{}, err
        case <-ctx.Done():
            return cIn{}, ctx.Err()
        }
    }
    return cData, nil
}

abProcessor 上的 wait 方法是你需要实现的最复杂方法。它填充了一个 cIn 类型的结构体,该结构体保存从调用 A 服务和 B 服务返回的数据。你将输出变量 cData 定义为 cIn 类型。然后有一个 for 循环,计数到两个,因为你需要从两个通道读取以成功完成。在循环内部,有一个 select 语句。如果从 outA 通道读取到一个值,就设置 cDataa 字段。如果从 outB 通道读取到一个值,就设置 cDatab 字段。如果从 errs 通道读取到一个值,就立即返回错误。最后,如果上下文超时,就从上下文的 Err 方法立即返回错误。

一旦从 p.outA 通道和 p.outB 通道都读取到一个值,你就退出循环并返回输入,用于 cProcessor 使用。

cProcessor 看起来像是 abProcessor 的简化版本:

type cProcessor struct {
    outC chan COut
    errs chan error
}

func newCProcessor() *cProcessor {
    return &cProcessor{
        outC: make(chan COut, 1),
        errs: make(chan error, 1),
    }
}

func (p *cProcessor) start(ctx context.Context, inputC cIn) {
    go func() {
        cOut, err := getResultC(ctx, inputC)
        if err != nil {
            p.errs <- err
            return
        }
        p.outC <- cOut
    }()
}

func (p *cProcessor) wait(ctx context.Context) (COut, error) {
    select {
    case out := <-p.outC:
        return out, nil
    case err := <-p.errs:
        return COut{}, err
    case <-ctx.Done():
        return COut{}, ctx.Err()
    }
}

cProcessor 结构体有一个输出通道和一个错误通道。

cProcessorstart 方法看起来像是 abProcessorstart 方法。它启动一个 goroutine,调用 getResultC 处理输入数据,在错误时向 errs 通道写入,成功时向 outC 通道写入。

最后,cProcessor 上的 wait 方法是一个简单的 select 语句,检查是否有值可以从 outC 通道、errs 通道或上下文的 Done 通道读取。

通过使用 goroutine、通道和 select 语句来组织代码,你可以将各个步骤分离,允许独立部分以任何顺序运行和完成,并在依赖部分之间清晰地交换数据。此外,你确保程序的任何部分都不会挂起,并且正确处理既在此函数内设置的超时,也在调用历史中的较早函数内设置的超时。如果你不确信这是否是实现并发的更好方法,请尝试在另一种语言中实现它。你可能会惊讶地发现它有多么困难。

你可以在第十二章代码库sample_code/pipeline 目录中找到这个并发流水线的代码。

何时使用互斥锁而不是通道

如果你曾经在其他编程语言中协调线程间数据访问,你可能使用过互斥锁。这是互斥排除的缩写,互斥锁的作用是限制某些代码的并发执行或对共享数据的访问。受保护的部分称为临界区

Go 语言的创建者设计通道和select来管理并发有很好的原因。互斥锁的主要问题是它们会混淆程序中的数据流。当值通过一系列通道从 goroutine 传递到 goroutine 时,数据流是清晰的。对值的访问局限于一次只有一个 goroutine。当互斥锁用于保护一个值时,没有任何指示当前哪个 goroutine 拥有该值的方式,因为所有并发进程共享对值的访问。这使得理解处理顺序变得困难。在 Go 社区中有一句话来描述这种哲学:“通过通信共享内存,不要通过共享内存进行通信。”

有时候,使用互斥锁会更清晰一些,Go 标准库包含了这些情况的互斥锁实现。最常见的情况是当你的 goroutine 读取或写入共享值,但不处理该值。让我们以内存中的多人游戏积分板为例。首先看看如何使用通道来实现这一点。下面是一个函数,你可以启动它作为一个 goroutine 来管理积分板:

func scoreboardManager(ctx context.Context, in <-chan func(map[string]int)) {
    scoreboard := map[string]int{}
    for {
        select {
        case <-ctx.Done():
            return
        case f := <-in:
            f(scoreboard)
        }
    }
}

此函数声明了一个映射,然后监听一个通道以便接收一个读取或修改映射的函数,同时监听上下文的 Done 通道以知道何时关闭。让我们创建一个带有写入值到映射的方法的类型:

type ChannelScoreboardManager chan func(map[string]int)

func NewChannelScoreboardManager(ctx context.Context) ChannelScoreboardManager {
    ch := make(ChannelScoreboardManager)
    go scoreboardManager(ctx, ch)
    return ch
}

func (csm ChannelScoreboardManager) Update(name string, val int) {
    csm <- func(m map[string]int) {
        m[name] = val
    }
}

更新方法非常直接:只需传递一个将值放入映射中的函数即可。但是如何从积分板中读取?你需要返回一个值。这意味着创建一个在传入函数中被写入的通道:

func (csm ChannelScoreboardManager) Read(name string) (int, bool) {
    type Result struct {
        out int
        ok  bool
    }
    resultCh := make(chan Result)
    csm <- func(m map[string]int) {
        out, ok := m[name]
        resultCh <- Result{out, ok}
    }
    result := <-resultCh
    return result.out, result.ok
}

虽然这段代码可以工作,但它很繁琐,并且一次只允许一个读取者。更好的方法是使用互斥锁。标准库中有两种互斥锁实现,都在sync包中。第一种是Mutex,有两个方法,LockUnlock。调用Lock会导致当前 goroutine 暂停,直到另一个 goroutine 当前在临界区内为止。当临界区清除时,锁被当前 goroutine 获取,并执行临界区中的代码。在Mutex上调用Unlock方法标记临界区的结束。

第二种互斥锁实现称为RWMutex,允许同时拥有读锁和写锁。虽然每次只能有一个写锁进入临界区,但读锁是共享的;多个读者可以同时进入临界区。写锁由LockUnlock方法管理,而读锁由RLockRUnlock方法管理。

每当你获取一个互斥锁时,一定要确保释放锁。使用defer语句在调用LockRLock之后立即调用Unlock

type MutexScoreboardManager struct {
    l          sync.RWMutex
    scoreboard map[string]int
}

func NewMutexScoreboardManager() *MutexScoreboardManager {
    return &MutexScoreboardManager{
        scoreboard: map[string]int{},
    }
}

func (msm *MutexScoreboardManager) Update(name string, val int) {
    msm.l.Lock()
    defer msm.l.Unlock()
    msm.scoreboard[name] = val
}

func (msm *MutexScoreboardManager) Read(name string) (int, bool) {
    msm.l.RLock()
    defer msm.l.RUnlock()
    val, ok := msm.scoreboard[name]
    return val, ok
}

第十二章存储库sample_code/mutex目录中可以找到示例。

现在你已经看到了使用互斥锁的实现,再在使用之前慎重考虑你的选择。Katherine Cox-Buday 的优秀著作《Go 语言并发编程》(O’Reilly)包含一个决策树,帮助你决定是使用通道还是互斥锁:

  • 如果你在协调 goroutine 或跟踪值在一系列 goroutine 中的转换过程中,请使用通道。

  • 如果你正在共享结构体中的字段访问,请使用互斥锁。

  • 如果在使用通道时发现了关键性能问题(参见“使用基准测试”了解如何做到这一点),并且找不到其他解决方法来修复问题,请修改你的代码以使用互斥锁。

因为你的记分板是结构体中的一个字段,并且没有记分板的传递,使用互斥锁是合理的。只有在数据存储在内存中时,才是互斥锁的良好使用场景。当数据存储在外部服务(如 HTTP 服务器或数据库)中时,请勿使用互斥锁来保护系统访问。

互斥锁需要你进行更多的簿记。例如,你必须正确配对锁定和解锁,否则你的程序很可能会死锁。示例中在同一方法中同时获取和释放锁。另一个问题是 Go 中的互斥锁不是可重入的。如果一个 goroutine 尝试两次获取同一个锁,它将死锁,等待自己释放锁。这与像 Java 这样的语言不同,那里的锁是可重入的。

非可重入锁使得在调用自身递归的函数中获取锁变得棘手。你必须在递归函数调用之前释放锁。总体而言,在持有锁时调用函数时要小心,因为你不知道这些调用中会获取哪些锁。如果你的函数调用另一个尝试获取相同互斥锁的函数,则 goroutine 将死锁。

sync.WaitGroupsync.Once一样,互斥锁绝不能被复制。如果它们被传递给一个函数或作为结构体上的字段访问,必须通过指针。如果复制互斥锁,则其锁将不会被共享。

警告

永远不要尝试从多个 goroutine 访问一个变量,除非你首先为该变量获取互斥锁。这可能导致难以追踪的奇怪错误。参见“使用数据竞争检测器查找并发问题”了解如何检测这些问题。

原子操作——你可能不需要这些操作。

除了互斥锁,Go 还提供了另一种方式来在多个线程中保持数据一致性。sync/atomic包提供了访问现代 CPU 内置的原子变量操作,包括添加、交换、加载、存储或比较并交换(CAS)适合单个寄存器的值。

如果您需要尽可能提高性能,并且是编写并发代码的专家,您会很高兴知道 Go 包含原子支持。对于其他人,请使用 goroutine 和互斥锁来管理您的并发需求。

关于并发学习更多信息

我在这里介绍了一些简单的并发模式,但实际上还有很多。事实上,您可以撰写一本关于如何在 Go 中正确实现各种并发模式的完整书籍,幸运的是,Katherine Cox-Buday 已经做到了。在讨论互斥锁或通道如何选择时,我已经提到了Concurrency in Go,但它是关于 Go 和并发的优秀资源。如果您想了解更多,请查看她的书籍。

练习

有效地使用并发是 Go 开发人员最重要的技能之一。通过这些练习来检验您是否掌握了它们。解决方案可以在第十二章的存储库中的exercise_solutions目录中找到。

  1. 创建一个函数,启动三个使用通道进行通信的 goroutine。前两个 goroutine 各自向通道写入 10 个数字。第三个 goroutine 从通道中读取所有数字并打印出来。函数在所有值打印完毕后应退出。确保没有 goroutine 泄漏。如有需要,您可以创建额外的 goroutine。

  2. 创建一个函数,启动两个 goroutine。每个 goroutine 向自己的通道写入 10 个数字。使用for-select循环从两个通道读取数据,打印出数字及其写入该值的 goroutine。确保在所有值都被读取后函数退出,并且没有 goroutine 泄漏。

  3. 编写一个函数,构建一个map[int]float64,其中键是从 0(包括)到 100,000(不包括)的数字,而值是这些数字的平方根(使用math.Sqrt函数计算平方根)。使用sync.OnceValue生成一个函数,缓存此函数返回的map,并使用缓存值来查找从 0 到 100,000 每 1000 个数字的平方根。

总结

在本章中,您已经了解了并发性,并学习了为什么 Go 的方法比传统的并发机制更简单。在此过程中,您还学会了何时应该使用并发,以及一些并发规则和模式。在下一章中,您将快速浏览 Go 的标准库,这个库以现代计算的“一揽子”理念为核心。

第十三章:标准库

使用 Go 开发的最大优势之一是能够利用其标准库。像 Python 一样,Go 也有“电池包含”的哲学,提供了构建应用程序所需的许多工具。由于 Go 是一种相对较新的语言,它附带了一个专注于现代编程环境中遇到的问题的库。

我无法覆盖所有标准库包,幸运的是,我也不需要,因为有许多优秀的信息源涵盖了标准库,从文档开始。相反,我将重点放在几个最重要的包上,以及它们的设计和使用如何展示 Go 的成语风格的原则。一些包(errorssynccontexttestingreflectunsafe)在它们自己的章节中进行了介绍。在这一章中,你将看到 Go 对 I/O、时间、JSON 和 HTTP 的内置支持。

io 及其伙伴们

要使程序有用,它需要读取和写入数据。Go 的输入/输出哲学的核心可以在io包中找到。特别是在该包中定义的两个接口可能是 Go 中使用第二和第三最多的接口:io.Readerio.Writer

注意

第一名是什么?那将是error,你已经在第九章中看过了。

io.Readerio.Writer都定义了一个单一方法:

type Reader interface {
    Read(p []byte) (n int, err error)
}

type Writer interface {
    Write(p []byte) (n int, err error)
}

io.Writer接口上的Write方法接受一个字节切片,将其写入接口实现。它返回写入的字节数以及如果出现错误则返回错误。io.Reader上的Read方法更有趣。与通过返回参数返回数据不同,一个切片被传递到实现中并进行修改。最多会写入len(p)字节到切片中。该方法返回写入的字节数。这可能看起来有点奇怪。你可能期望这样:

type NotHowReaderIsDefined interface {
    Read() (p []byte, err error)
}

io.Reader被定义为它的样子有一个非常好的原因。让我们编写一个代表如何使用io.Reader的函数来说明:

func countLetters(r io.Reader) (map[string]int, error) {
    buf := make([]byte, 2048)
    out := map[string]int{}
    for {
        n, err := r.Read(buf)
        for _, b := range buf[:n] {
            if (b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z') {
                out[string(b)]++
            }
        }
        if err == io.EOF {
            return out, nil
        }
        if err != nil {
            return nil, err
        }
    }
}

有三件事需要注意。首先,你只需创建一次缓冲区,并在每次调用r.Read时重复使用它。这允许你使用单个内存分配从可能很大的数据源中读取。如果Read方法被编写为返回一个[]byte,则每次调用都需要一个新的分配。每个分配都将最终在堆上,这将为垃圾收集器造成相当多的工作。

如果你想进一步减少分配,可以在程序启动时创建一个缓冲池。然后,在函数开始时从池中取出一个缓冲区,在函数结束时归还它。通过将一个切片传递给io.Reader,内存分配就在开发者的控制之下。

第二,你使用从r.Read返回的n值来知道写入缓冲区的字节数,并迭代处理被读取的buf切片的子切片数据。

最后,当从r.Read返回的错误是io.EOF时,你知道已经完成从r的读取。这种错误有点奇怪,因为它并不是真正的错误。它表示从io.Reader中没有剩余可读取的内容。当返回io.EOF时,你完成处理并返回你的结果。

io.Reader中的Read方法有一个不寻常的方面。在大多数情况下,当函数或方法有一个错误返回值时,你在处理非错误返回值之前会先检查错误。但是对于Read来说,你要做相反的操作,因为字节可能已经被复制到缓冲区中,然后才会因数据流结束或意外条件而触发错误。

提示

如果意外地到达了io.Reader的末尾,会返回不同的哨兵错误(io.ErrUnexpectedEOF)。请注意,它以Err开头,表示这是一个意外的状态。

因为io.Readerio.Writer是如此简单的接口,它们可以有很多种实现方式。你可以使用strings.NewReader函数从字符串创建一个io.Reader

s := "The quick brown fox jumped over the lazy dog"
sr := strings.NewReader(s)
counts, err := countLetters(sr)
if err != nil {
    return err
}
fmt.Println(counts)

正如我在“接口是类型安全的鸭子类型”中讨论的那样,io.Readerio.Writer的实现通常在装饰器模式中链接在一起。因为countLetters依赖于io.Reader,你可以使用完全相同的countLetters函数来计算 gzip 压缩文件中的英文字母数。首先,编写一个函数,给定文件名后返回一个*gzip.Reader

func buildGZipReader(fileName string) (*gzip.Reader, func(), error) {
    r, err := os.Open(fileName)
    if err != nil {
        return nil, nil, err
    }
    gr, err := gzip.NewReader(r)
    if err != nil {
        return nil, nil, err
    }
    return gr, func() {
        gr.Close()
        r.Close()
    }, nil
}

此函数演示了正确包装实现io.Reader的类型的方法。你创建一个*os.File(符合io.Reader接口),在确保它有效后,将其传递给gzip.NewReader函数,该函数返回一个*gzip.Reader实例。如果有效,返回*gzip.Reader和一个闭包,当调用时会适当地清理你的资源。

因为*gzip.Reader实现了io.Reader,所以你可以像之前使用*strings.Reader一样使用它来与countLetters配合使用:

r, closer, err := buildGZipReader("my_data.txt.gz")
if err != nil {
    return err
}
defer closer()
counts, err := countLetters(r)
if err != nil {
    return err
}
fmt.Println(counts)

你可以在第十三章存储库sample_code/io_friends目录中找到countLettersbuildGZipReader的代码。

因为有用于读取和写入的标准接口,io包中有一个用于从io.Reader复制到io.Writer的标准函数,io.Copy。还有其他标准函数用于为现有的io.Readerio.Writer实例添加新功能。其中包括以下内容:

io.MultiReader

返回一个从多个io.Reader实例依次读取的io.Reader

io.LimitReader

返回一个从提供的io.Reader读取指定字节数的io.Reader

io.MultiWriter

返回一个 io.Writer,可以同时向多个 io.Writer 实例写入数据。

标准库中的其他包提供了它们自己的类型和函数来处理 io.Readerio.Writer。你已经看到了其中的一些,但还有更多。这些包括压缩算法、存档、加密、缓冲区、字节切片和字符串。

io 包中还定义了其他单方法接口,例如 io.Closerio.Seeker

type Closer interface {
        Close() error
}

type Seeker interface {
        Seek(offset int64, whence int) (int64, error)
}

io.Closer 接口由像 os.File 这样的类型实现,它们在读取或写入完成后需要进行清理。通常情况下,通过 defer 调用 Close

f, err := os.Open(fileName)
if err != nil {
    return nil, err
}
defer f.Close()
// use f
警告

如果在循环中打开资源,请不要使用 defer,因为它不会在函数退出之前运行。相反,应在循环迭代结束前调用 Close。如果存在可能导致退出的错误,则也必须在那里调用 Close

io.Seeker 接口用于对资源进行随机访问。whence 的有效值为常量 io.SeekStartio.SeekCurrentio.SeekEnd。如果使用自定义类型来明确此点会更好,但出人意料的设计疏忽,whence 的类型却是 int

io 包定义了几种方式将这四种接口组合起来的接口。它们包括 io.ReadCloserio.ReadSeekerio.ReadWriteCloserio.ReadWriteSeekerio.ReadWriterio.WriteCloserio.WriteSeeker。使用这些接口来指定你的函数将如何处理数据。例如,不要只将 os.File 作为参数传递,而是使用接口精确地指定你的函数将如何处理参数。这不仅使得你的函数更具通用性,也使你的意图更加清晰。此外,如果你正在编写自己的数据源和接收器,应使你的代码兼容这些接口。总的来说,应力求创建与 io 包定义的接口一样简单和解耦的接口。它们展示了简单抽象的力量。

除了 io 包中的接口外,还有几个用于常见操作的辅助函数。例如,io.ReadAll 函数从 io.Reader 中读取所有数据到一个字节切片中。io 中的一种更聪明的函数展示了一种向 Go 类型添加方法的模式。如果你有一个实现了 io.Reader 但没有实现 io.Closer 的类型(例如 strings.Reader),并且需要将其传递给期望 io.ReadCloser 的函数,则可以将你的 io.Reader 传递给 io.NopCloser,从而得到一个实现了 io.ReadCloser 的类型。如果查看其实现,你会发现它非常简单:

type nopCloser struct {
    io.Reader
}

func (nopCloser) Close() error { return nil }

func NopCloser(r io.Reader) io.ReadCloser {
    return nopCloser{r}
}

如果需要为某种类型添加额外的方法以满足某个接口,可以使用嵌入类型模式。

注意

io.NopCloser 函数违反了不从函数返回接口的一般规则,但它是一个简单的适配器,用于保证接口保持不变,因为它是标准库的一部分。

os包包含与文件交互的函数。函数os.ReadFileos.WriteFile分别将整个文件读入字节片段并将字节片段写入文件。这些函数(以及io.ReadAll)适用于小数据量,但不适合大数据源。处理较大数据源时,请使用os包中的CreateNewFileOpenOpenFile函数。它们返回一个实现了io.Readerio.Writer接口的*os.File实例。您可以将*os.File实例与bufio包中的Scanner类型一起使用。

时间

与大多数语言一样,Go 标准库包含时间支持,通常在time包中。用于表示时间的两个主要类型是time.Durationtime.Time

时间段由time.Duration表示,这是基于int64的类型。Go 能表示的最小时间单位是纳秒,但time包定义了time.Duration类型的常量,表示纳秒、微秒、毫秒、秒、分钟和小时。例如,表示 2 小时 30 分钟的持续时间如下:

d := 2 * time.Hour + 30 * time.Minute // d is of type time.Duration

这些常量使得使用time.Duration既可读性强又类型安全。它们展示了类型化常量的良好使用。

Go 定义了一种合理的字符串格式,一系列数字,可以使用time.ParseDuration函数解析为time.Duration。此格式在标准库文档中有描述:

时间段字符串是一系列可能带有可选小数部分和单位后缀的十进制数字,例如“300ms”、“-1.5h”或“2h45m”。有效的时间单位有“ns”、“us”(或“µs”)、“ms”、“s”、“m”、“h”。

Go 标准库文档

time.Duration上定义了几种方法。它符合fmt.Stringer接口,并通过String方法返回格式化的持续时间字符串。它还具有将值作为小时数、分钟数、秒数、毫秒数、微秒数或纳秒数返回的方法。TruncateRound方法将time.Duration截断或四舍五入到指定的time.Duration单位。

时间的瞬间由time.Time类型表示,带有时区信息。使用time.Now函数获取当前时间的引用,返回一个设置为当前本地时间的time.Time实例。

提示

time.Time实例包含时区信息,因此不应使用==来检查两个time.Time实例是否指向同一时刻。相反,请使用Equal方法,该方法会校正时区。

time.Parse函数将string转换为time.Time,而Format方法将time.Time转换为string。虽然 Go 通常采纳在过去表现良好的想法,但它使用自己的日期和时间格式语言。它依赖于格式化日期和时间为 2006 年 1 月 2 日下午 3:04:05PM MST(山区标准时间)来指定您的格式。

注意

为什么选择这个日期?因为它的每一部分都按照顺序代表从 1 到 7 的数字,也就是说,01/02 03:04:05PM ’06 -0700(MST 比 UTC 提前 7 小时)。

例如,以下代码

t, err := time.Parse("2006-01-02 15:04:05 -0700", "2023-03-13 00:00:00 +0000")
if err != nil {
    return err
}
fmt.Println(t.Format("January 2, 2006 at 3:04:05PM MST"))

打印出以下输出:

March 13, 2023 at 12:00:00AM UTC

尽管用于格式化的日期和时间旨在成为一个聪明的记忆技巧,但我发现很难记住,每次想要使用它时都必须查找。幸运的是,在time包中,最常用的日期和时间格式都已经被赋予了自己的常量。

就像在time.Duration上有提取部分的方法一样,在time.Time上也定义了类似的方法,包括DayMonthYearHourMinuteSecondWeekdayClock(返回time.Time的时间部分作为单独的小时、分钟和秒int值)和Date(返回年、月和日作为单独的int值)。您可以使用AfterBeforeEqual方法比较一个time.Time实例与另一个。

Sub方法返回一个表示两个time.Time实例之间经过的时间的time.Duration,而Add方法返回比当前时间晚time.Durationtime.TimeAddDate方法返回增加指定年、月和日数的新time.Time实例。与time.Duration一样,还定义了TruncateRound方法。所有这些方法都在值接收器上定义,因此它们不会修改time.Time实例。

单调时间

大多数操作系统会跟踪两种类型的时间:挂钟,它对应于当前时间,以及单调时钟,它从计算机启动时开始计数。跟踪两个时钟的原因是挂钟不会均匀地增长。夏令时、闰秒和网络时间协议(NTP)更新可能会使挂钟意外地前进或后退。这在设置定时器或查找经过的时间时可能会导致问题。

为了解决这个潜在问题,Go 语言使用单调时间来跟踪经过的时间,每当设置定时器或使用time.Now创建time.Time实例时,都会用到单调时间。这种支持是透明的;定时器会自动使用它。Sub方法使用单调时钟来计算time.Duration,如果两个time.Time实例都已设置。如果它们没有(因为一个或两个实例没有使用time.Now创建),那么Sub方法会使用实例中指定的时间来计算time.Duration

注意

如果你想了解在未正确处理单调时间时可能发生的问题的类型,请查看 Cloudflare 的博文,详细描述了 Go 早期版本中由于缺乏单调时间支持而导致的错误。

计时器和超时

正如我在“超时代码”中所介绍的,time包包含返回通道的函数,这些通道在指定时间后输出值。time.After函数返回一个仅输出一次的通道,而time.Tick返回的通道在每个指定的time.Duration经过后返回一个新值。这些函数与 Go 的并发支持一起使用,以实现超时或定期任务。

你还可以使用time.AfterFunc函数在指定的time.Duration后触发单个函数运行。不要在非平凡程序之外使用time.Tick,因为底层的time.Ticker无法关闭(因此无法被垃圾回收)。相反,请使用time.NewTicker函数,它返回一个*time.Ticker,该类型具有用于监听的通道以及重置和停止计时器的方法。

encoding/json

REST API 已经将 JSON 确立为服务之间通信的标准方式,而 Go 的标准库包含了将 Go 数据类型与 JSON 相互转换的支持。Marshaling一词表示从 Go 数据类型到编码的转换,unmarshaling则表示从编码到 Go 数据类型的转换。

使用结构体标签添加元数据

假设你正在构建一个订单管理系统,并且必须读取和写入以下 JSON:

{
    "id":"12345",
    "date_ordered":"2020-05-01T13:01:02Z",
    "customer_id":"3",
    "items":[{"id":"xyz123","name":"Thing 1"},{"id":"abc789","name":"Thing 2"}]
}

你定义了类型来映射这些数据:

type Order struct {
    ID            string        `json:"id"`
    DateOrdered   time.Time     `json:"date_ordered"`
    CustomerID    string        `json:"customer_id"`
    Items         []Item        `json:"items"`
}

type Item struct {
    ID   string `json:"id"`
    Name string `json:"name"`
}

你可以使用结构体标签来指定处理 JSON 的规则,这些标签写在结构体字段后面。尽管结构体标签是用反引号标记的字符串,但它们不能超过单行。结构体标签由一个或多个标签/值对组成,写成tagName:"tagValue",并用空格分隔。因为它们只是字符串,编译器无法验证它们的格式,但go vet可以。还要注意,所有这些字段都是可导出的。与任何其他包一样,encoding/json包中的代码无法访问另一个包中未导出的结构体字段。

对于 JSON 处理,使用标签json来指定应与结构体字段关联的 JSON 字段的名称。如果未提供json标签,则默认行为是假定 JSON 对象字段的名称与 Go 结构体字段的名称相匹配。尽管存在此默认行为,最好还是使用结构体标签显式指定字段的名称,即使字段名称相同。

注意

当将 JSON 解组到没有json标签的结构体字段时,名称匹配是不区分大小写的。当将没有json标签的结构体字段编组回 JSON 时,JSON 字段的首字母始终大写,因为该字段是可导出的。

如果在编组或解组时应忽略某个字段,请使用破折号(-)作为字段名。如果该字段在为空时应从输出中省略,则在字段名后添加,omitempty。例如,在Order结构中,如果不希望在输出中包含CustomerID,如果其设置为空字符串,则结构标记应为json:"customer_id,omitempty"

警告

不幸的是,“空”这一定义与零值并不完全对齐,正如你所期望的那样。结构体的零值并不算作空,但零长度的切片或映射则属于空。

结构标记允许您使用元数据来控制程序的行为。其他语言,尤其是 Java,鼓励开发者在各种程序元素上放置注解,以描述处理方式,而不显式指定处理内容。虽然声明性编程能够实现更简洁的程序,但元数据的自动处理使得理解程序行为变得困难。在一个大型 Java 项目中,有注解的开发者在出现问题时,往往会陷入一片茫然,不知道哪段代码处理了特定的注解,以及做了哪些改变。Go 更倾向于显式代码而不是短小的代码。结构标记永远不会自动评估;它们在将结构实例传递给函数时进行处理。

解组和编组

encoding/json包中的Unmarshal函数用于将字节切片转换为结构体。如果有一个名为data的字符串,则以下代码将data转换为类型为Order的结构体:

var o Order
err := json.Unmarshal([]byte(data), &o)
if err != nil {
    return err
}

json.Unmarshal函数将数据填充到输入参数中,就像io.Reader接口的实现一样。正如我在“指针是最后的选择”中所讨论的,这样可以有效地重复使用同一个结构体,从而控制内存使用。

使用encoding/json包中的Marshal函数将Order实例写回为 JSON,存储在字节切片中:

out, err := json.Marshal(o)

这引出了一个问题:你如何能够评估结构标记?你可能还想知道json.Marshaljson.Unmarshal如何能够读取和写入任何类型的结构体。毕竟,你编写的其他方法仅与程序编译时已知的类型一起工作(即使在类型开关中列出的类型也是预先枚举的)。对这两个问题的答案是反射。你可以在第十六章了解更多关于反射的信息。

JSON、读者和写者

json.Marshaljson.Unmarshal 函数在字节片上工作。正如你刚才看到的,Go 中的大多数数据源和汇合都实现了 io.Readerio.Writer 接口。虽然你可以使用 io.ReadAllio.Reader 的整个内容复制到字节片中,以便 json.Unmarshal 读取,但这是低效的。类似地,你可以使用 json.Marshal 写入到内存中的字节片缓冲区,然后将该字节片写入网络或磁盘,但最好直接写入到 io.Writer

encoding/json 包含两种类型,允许你处理这些情况。json.Decoderjson.Encoder 类型分别从符合 io.Readerio.Writer 接口的任何地方读取和写入。让我们快速看看它们的工作方式。

从你的数据 toFile 开始,它实现了一个简单的结构体:

type Person struct {
    Name string `json:"name"`
    Age  int    `json:"age"`
}
toFile := Person {
    Name: "Fred",
    Age:  40,
}

os.File 类型实现了 io.Readerio.Writer 接口,因此可以用来演示 json.Decoderjson.Encoder。首先,通过将临时文件传递给 json.NewEncodertoFile 写入临时文件,该方法返回一个临时文件的 json.Encoder。然后,将 toFile 传递给 Encode 方法:

tmpFile, err := os.CreateTemp(os.TempDir(), "sample-")
if err != nil {
    panic(err)
}
defer os.Remove(tmpFile.Name())
err = json.NewEncoder(tmpFile).Encode(toFile)
if err != nil {
    panic(err)
}
err = tmpFile.Close()
if err != nil {
    panic(err)
}

一旦 toFile 写入完成,你可以通过将临时文件的引用传递给 json.NewDecoder,然后在返回的 json.Decoder 上调用 Decode 方法,类型为 Person 的变量进行 JSON 读取:

tmpFile2, err := os.Open(tmpFile.Name())
if err != nil {
    panic(err)
}
var fromFile Person
err = json.NewDecoder(tmpFile2).Decode(&fromFile)
if err != nil {
    panic(err)
}
err = tmpFile2.Close()
if err != nil {
    panic(err)
}
fmt.Printf("%+v\n", fromFile)

你可以在 Go Playground 上看到完整的示例,或在 第十三章示例代码/json 目录 中找到。

编码和解码 JSON 流

当你有多个 JSON 结构需要一次性读取或写入时,我们的朋友 json.Decoderjson.Encoder 也可以用于这些情况。

假设你有以下数据:

{"name": "Fred", "age": 40}
{"name": "Mary", "age": 21}
{"name": "Pat", "age": 30}

为了本示例,假设它存储在一个名为 streamData 的字符串中,但它可以在文件中或甚至是传入的 HTTP 请求中(稍后你将看到 HTTP 服务器如何工作)。

你将这些数据逐个 JSON 对象地存储到你的 t 变量中。

就像之前一样,你用数据源初始化你的 json.Decoder,但这次你使用 for 循环并运行直到出现错误。如果错误是 io.EOF,则成功读取所有数据。如果不是,则 JSON 流存在问题。这让你能够逐个 JSON 对象地读取和处理数据:

var t struct {
    Name string `json:"name"`
    Age  int    `json:"age"`
}

dec := json.NewDecoder(strings.NewReader(streamData))
for {
    err := dec.Decode(&t)
    if err != nil {
        if errors.Is(err, io.EOF) {
            break
        }
        panic(err)
    }
    // process t
}

使用 json.Encoder 写出多个值的方式与使用它写出单个值的方式完全相同。在这个示例中,你将写入到一个 bytes.Buffer,但任何符合 io.Writer 接口的类型都可以工作:

var b bytes.Buffer
enc := json.NewEncoder(&b)
for _, input := range allInputs {
    t := process(input)
    err = enc.Encode(t)
    if err != nil {
        panic(err)
    }
}
out := b.String()

你可以在 Go Playground 上运行这个示例,或在 第十三章示例代码/encode_decode 目录 中找到。

此示例在数据流中有多个未包装在数组中的 JSON 对象,但你也可以使用json.Decoder从数组中读取单个对象,而无需一次性加载整个数组到内存中。这可以极大地提高性能并减少内存使用。在Go 文档中有一个示例。

自定义 JSON 解析

尽管默认功能通常足够使用,但有时你需要进行覆盖。虽然time.Time默认支持 RFC 3339 格式的 JSON 字段,但你可能需要处理其他时间格式。你可以通过创建一个实现json.Marshalerjson.Unmarshaler两个接口的新类型来处理这个问题:

type RFC822ZTime struct {
    time.Time
}

func (rt RFC822ZTime) MarshalJSON() ([]byte, error) {
    out := rt.Time.Format(time.RFC822Z)
    return []byte(`"` + out + `"`), nil
}

func (rt *RFC822ZTime) UnmarshalJSON(b []byte) error {
    if string(b) == "null" {
        return nil
    }
    t, err := time.Parse(`"`+time.RFC822Z+`"`, string(b))
    if err != nil {
        return err
    }
    *rt = RFC822ZTime{t}
    return nil
}

你将一个time.Time实例嵌入到一个称为RFC822ZTime的新结构体中,这样你仍然可以访问time.Time上的其他方法。正如在“指针接收器和值接收器”中讨论的那样,读取时间值的方法声明为值接收器,而修改时间值的方法声明为指针接收器。

然后你改变了DateOrdered字段的类型,并可以使用 RFC 822 格式化时间进行处理:

type Order struct {
    ID          string      `json:"id"`
    DateOrdered RFC822ZTime `json:"date_ordered"`
    CustomerID  string      `json:"customer_id"`
    Items       []Item      `json:"items"`
}

你可以在Go Playground上运行此代码,或在第十三章的代码库sample_code/custom_json目录中找到它。

这种方法存在一个哲学上的缺点:JSON 的日期格式决定了数据结构中字段的类型。这是encoding/json方法的一个缺点。你可以让Order实现json.Marshalerjson.Unmarshaler,但这需要你编写代码来处理所有字段,即使那些不需要定制支持的字段也是如此。结构体标签格式并没有提供一种指定解析特定字段的函数的方式。这使得你不得不为字段创建一个自定义类型。

另一种选择在 Ukiah Smith 的博客文章中有描述。它允许你仅重新定义那些不匹配默认编组行为的字段,通过利用结构体嵌入(在“使用嵌入进行组合”中介绍过)。如果嵌入结构体的字段与包含结构体的同名,那么在 JSON 编组或解组时,该字段将被忽略。

在这个例子中,Order的字段看起来像这样:

type Order struct {
    ID          string    `json:"id"`
    Items       []Item    `json:"items"`
    DateOrdered time.Time `json:"date_ordered"`
    CustomerID  string    `json:"customer_id"`
}

MarshalJSON方法看起来像这样:

func (o Order) MarshalJSON() ([]byte, error) {
    type Dup Order

    tmp := struct {
        DateOrdered string `json:"date_ordered"`
        Dup
    }{
        Dup: (Dup)(o),
    }
    tmp.DateOrdered = o.DateOrdered.Format(time.RFC822Z)
    b, err := json.Marshal(tmp)
    return b, err
}

对于OrderMarshalJSON方法,你定义了一个类型为Dup,其基础类型是Order。创建Dup的原因是,基于另一个类型的类型具有与基础类型相同的字段,但不具有方法。如果没有Dup,在调用json.Marshal时将会导致MarshalJSON的无限循环调用,最终导致堆栈溢出。

你定义了一个具有DateOrdered字段和嵌入式Dup的匿名结构体。然后将Order实例分配给tmp中的嵌入字段,在tmp中为DateOrdered字段分配 RFC822Z 格式的时间,并在tmp上调用json.Marshal。这会产生所需的 JSON 输出。

UnmarshalJSON中也有类似的逻辑:

func (o *Order) UnmarshalJSON(b []byte) error {
    type Dup Order

    tmp := struct {
        DateOrdered string `json:"date_ordered"`
        *Dup
    }{
        Dup: (*Dup)(o),
    }

    err := json.Unmarshal(b, &tmp)
    if err != nil {
        return err
    }

    o.DateOrdered, err = time.Parse(time.RFC822Z, tmp.DateOrdered)
    if err != nil {
        return err
    }
    return nil
}

UnmarshalJSON中,对json.Unmarshal的调用填充了o中的字段(除了DateOrdered),因为它被嵌入到tmp中。然后,你使用time.Parse处理tmp中的DateOrdered字段,并在o中填充DateOrdered

你可以在Go Playground上运行此代码,或在第十三章存储库sample_code/custom_json2目录中找到它。

这样做可以使Order不与 JSON 格式绑定,但Order上的MarshalJSONUnmarshalJSON方法与 JSON 中时间字段的格式耦合在一起。你无法重用Order来支持其他时间格式的 JSON。

为了限制关心 JSON 外观的代码量,定义两个结构体。使用一个结构体进行 JSON 的转换,另一个进行数据处理。将 JSON 读入你的 JSON-aware 类型,然后将其复制到另一个类型。当你想要写出 JSON 时,做相反的操作。这确实会产生一些重复,但它可以使你的业务逻辑不依赖于通信协议。

你可以将map[string]any传递给json.Marshaljson.Unmarshal,在 JSON 和 Go 之间进行双向转换,但在编码探索阶段保存它,并在理解正在处理的数据后用具体类型替换它。Go 使用类型是有原因的;它们文档化了预期数据和预期数据的类型。

尽管 JSON 可能是标准库中最常用的编码器,Go 还包含其他编码器,包括 XML 和 Base64。如果你有一种数据格式需要编码,而标准库或第三方模块中找不到支持,你可以自己编写一个。你将会学习如何在“使用反射编写数据编组器”中实现我们自己的编码器。

警告

标准库包括encoding/gob,这是 Go 特有的二进制表示,有点像 Java 中的序列化。就像 Java 序列化是 Enterprise Java Beans 和 Java RMI 的传输协议一样,gob 协议旨在成为net/rpc包中 Go 特有 RPC(远程过程调用)实现的传输格式。不要使用encoding/gobnet/rpc。如果你想要在 Go 中进行远程方法调用,请使用像GRPC这样的标准协议,这样你就不会被绑定到特定的语言。无论你有多么喜欢 Go,如果你希望你的服务有用,让其他语言的开发者能够调用它们。

net/http

每种语言都附带了一个标准库,但随着时间的推移,标准库应该包含的预期也在变化。作为在 2010 年代推出的语言,Go 的标准库包括一些其他语言分发版本认为应该由第三方负责的内容:一个高质量的 HTTP/2 客户端和服务器。

客户端

net/http包定义了一个Client类型,用于发出 HTTP 请求和接收 HTTP 响应。在net/http包中可以找到一个默认的客户端实例(巧妙地命名为DefaultClient),但是在生产应用程序中应避免使用它,因为它默认没有超时设置。相反,应实例化自己的客户端。对于整个程序,只需要创建一个http.Client,它可以正确处理跨 goroutine 的多个同时请求:

client := &http.Client{
    Timeout: 30 * time.Second,
}

当您想要发出请求时,可以使用http.NewRequestWithContext函数创建一个新的*http.Request实例,传递一个上下文、方法和要连接的 URL。如果要进行PUTPOSTPATCH请求,请将请求体作为最后一个参数作为io.Reader指定。如果没有请求体,请使用nil

req, err := http.NewRequestWithContext(context.Background(),
    http.MethodGet, "https://jsonplaceholder.typicode.com/todos/1", nil)
if err != nil {
    panic(err)
}
注意

在第十四章中,我将讨论上下文的概念。

一旦您有了*http.Request实例,您可以通过实例的Headers字段设置任何标头。使用http.ClientDo方法和您的http.Request,结果将返回为http.Response

req.Header.Add("X-My-Client", "Learning Go")
res, err := client.Do(req)
if err != nil {
    panic(err)
}

响应具有几个字段,其中包含有关请求的信息。响应状态的数值代码在StatusCode字段中,响应代码的文本在Status字段中,响应头在Header字段中,任何返回的内容在Body类型为io.ReadCloser的字段中。这使您可以与json.Decoder一起处理 REST API 响应:

defer res.Body.Close()
if res.StatusCode != http.StatusOK {
    panic(fmt.Sprintf("unexpected status: got %v", res.Status))
}
fmt.Println(res.Header.Get("Content-Type"))
var data struct {
    UserID    int    `json:"userId"`
    ID        int    `json:"id"`
    Title     string `json:"title"`
    Completed bool   `json:"completed"`
}
err = json.NewDecoder(res.Body).Decode(&data)
if err != nil {
    panic(err)
}
fmt.Printf("%+v\n", data)

您可以在第十三章的代码库sample_code/client目录中找到此代码。

警告

net/http包中有用于进行GETHEADPOST调用的函数。应避免使用这些函数,因为它们使用默认客户端,这意味着它们不会设置请求超时。

服务器

HTTP 服务器围绕着http.Serverhttp.Handler接口的概念构建。就像http.Client发送 HTTP 请求一样,http.Server负责监听 HTTP 请求。它是一个性能优越的支持 TLS 的 HTTP/2 服务器。

服务器对请求的处理由分配给Handler字段的http.Handler接口的实现来处理。此接口定义了一个方法:

type Handler interface {
    ServeHTTP(http.ResponseWriter, *http.Request)
}

*http.Request应该看起来很熟悉,因为它正是用于向 HTTP 服务器发送请求的类型。http.ResponseWriter是一个接口,有三个方法:

type ResponseWriter interface {
        Header() http.Header
        Write([]byte) (int, error)
        WriteHeader(statusCode int)
}

这些方法必须按特定顺序调用。首先,调用Header以获取http.Header的实例,并设置任何你需要的响应头。如果不需要设置任何头部,可以跳过此步骤。接下来,使用你的响应的 HTTP 状态码调用WriteHeader。(所有状态码都在net/http包中定义为常量。这里本应是定义自定义类型的好地方,但没有这样做;所有状态码常量都是无类型整数。)如果要发送的响应具有 200 状态码,可以跳过WriteHeader。最后,调用Write方法设置响应体。以下是一个简单处理程序的示例:

type HelloHandler struct{}

func (hh HelloHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("Hello!\n"))
}

实例化新的http.Server与实例化其他结构体一样简单:

s := http.Server{
    Addr:         ":8080",
    ReadTimeout:  30 * time.Second,
    WriteTimeout: 90 * time.Second,
    IdleTimeout:  120 * time.Second,
    Handler:      HelloHandler{},
}
err := s.ListenAndServe()
if err != nil {
    if err != http.ErrServerClosed {
        panic(err)
    }
}

Addr字段指定服务器监听的主机和端口。如果不指定,服务器将默认监听所有主机的标准 HTTP 端口 80。你可以使用time.Duration值设置服务器的读取、写入和空闲超时,以正确处理恶意或损坏的 HTTP 客户端,因为默认行为是根本不设置超时。最后,使用Handler字段为服务器指定http.Handler

你可以在第十三章代码库sample_code/server目录中找到这段代码。

一个只处理单个请求的服务器并不是非常有用,因此 Go 标准库包含一个请求路由器*http.ServeMux。你可以使用http.NewServeMux函数创建一个实例。它满足http.Handler接口,因此可以分配给http.ServerHandler字段。它还包含两个方法用于调度请求。第一个方法叫做Handle,接受两个参数,一个路径和一个http.Handler。如果路径匹配,就会调用http.Handler

虽然可以创建http.Handler的实现,但更常见的模式是在*http.ServeMux上使用HandleFunc方法:

mux.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("Hello!\n"))
})

此方法接受一个函数或闭包,并将其转换为http.HandlerFunc。你在“函数类型是接口的桥梁”中探讨了http.HandlerFunc类型。对于简单的处理程序,闭包足够了。对于依赖于其他业务逻辑的更复杂的处理程序,请使用结构体的方法,如“隐式接口使依赖注入更容易”中所示。

Go 1.22 扩展了路径语法,可选择允许 HTTP 动词和路径通配符变量。通配符变量的值通过http.RequestPathValue方法读取:

mux.HandleFunc("GET /hello/{name}", func(w http.ResponseWriter,
                                         r *http.Request) {
    name := r.PathValue("name")
    w.Write([]byte(fmt.Sprintf("Hello, %s!\n", name)))
})
警告

包级别的函数http.Handlehttp.HandleFunchttp.ListenAndServehttp.ListenAndServeTLS与名为http.DefaultServeMux的包级别实例一起工作。不要在非常简单的测试程序之外使用它们。http.Server实例是在http.ListenAndServehttp.ListenAndServeTLS函数中创建的,因此你无法配置服务器属性如超时。此外,第三方库可能已经使用http.DefaultServeMux注册了它们自己的处理程序,而没有扫描所有依赖项(直接和间接的)就无法知道这一点。通过避免共享状态,保持你的应用程序受控制。

因为*http.ServeMux分派请求给http.Handler实例,而且*http.ServeMux本身也实现了http.Handler接口,所以你可以创建一个*http.ServeMux实例,并注册它到父*http.ServeMux中:

person := http.NewServeMux()
person.HandleFunc("/greet", func(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("greetings!\n"))
})
dog := http.NewServeMux()
dog.HandleFunc("/greet", func(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("good puppy!\n"))
})
mux := http.NewServeMux()
mux.Handle("/person/", http.StripPrefix("/person", person))
mux.Handle("/dog/", http.StripPrefix("/dog", dog))

在这个例子中,请求/person/greet由附加到person的处理程序处理,而/dog/greet由附加到dog的处理程序处理。当你将persondog注册到mux时,使用http.StripPrefix辅助函数来移除已经被mux处理过的路径部分。你可以在第十三章代码库sample_code/server_mux目录中找到这段代码。

中间件

HTTP 服务器最常见的需求之一是在多个处理程序中执行一组操作,如检查用户是否已登录、计时请求或检查请求头。Go 使用中间件模式处理这些横切关注点。

中间件模式不是使用特殊类型,而是使用一个接受http.Handler实例并返回http.Handler实例的函数。通常,返回的http.Handler是一个转换为http.HandlerFunc的闭包。这里有两个中间件生成器,一个提供请求的定时,另一个使用可能是最糟糕的访问控制:

func RequestTimer(h http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        h.ServeHTTP(w, r)
        dur := time.Since(start)
        slog.Info("request time",
            "path", r.URL.Path,
            "duration", dur)
    })
}

var securityMsg = []byte("You didn't give the secret password\n")

func TerribleSecurityProvider(password string) func(http.Handler) http.Handler {
    return func(h http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            if r.Header.Get("X-Secret-Password") != password {
                w.WriteHeader(http.StatusUnauthorized)
                w.Write(securityMsg)
                return
            }
            h.ServeHTTP(w, r)
        })
    }
}

这两个中间件实现展示了中间件的作用。首先,进行设置操作或检查。如果检查未通过,则在中间件中编写输出(通常使用错误代码)并返回。如果一切正常,则调用处理程序的ServeHTTP方法。当返回时,运行清理操作。

TerribleSecurityProvider展示了如何创建可配置的中间件。你传递配置信息(在本例中是密码),函数返回使用该配置信息的中间件。这有点令人费解,因为它返回一个返回闭包的闭包。

注意

也许你想知道如何通过中间件层传递值。这通过上下文(context)完成,在第十四章中会详细介绍。

通过将中间件链接起来,你可以将中间件添加到请求处理程序中:

terribleSecurity := TerribleSecurityProvider("GOPHER")

mux.Handle("/hello", terribleSecurity(RequestTimer(
    http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("Hello!\n"))
    }))))

我们从 TerribleSecurityProvider 获取你的中间件,然后通过一系列函数调用包装你的处理程序。首先调用 terribleSecurity 闭包,然后调用 RequestTimer,然后调用你的实际请求处理程序。

因为 *http.ServeMux 实现了 http.Handler 接口,你可以将一组中间件应用于注册到单个请求路由器的所有处理程序:

terribleSecurity := TerribleSecurityProvider("GOPHER")
wrappedMux := terribleSecurity(RequestTimer(mux))
s := http.Server{
    Addr:    ":8080",
    Handler: wrappedMux,
}

你可以在 第十三章仓库sample_code/middleware 目录中找到这段代码。

使用第三方模块增强服务器功能。

仅仅因为服务器是生产质量的,并不意味着你不应该使用第三方模块来提升其功能。如果你不喜欢中间件的函数链,可以使用一个名为 alice 的第三方模块,它允许你使用以下语法:

helloHandler := func(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("Hello!\n"))
}
chain := alice.New(terribleSecurity, RequestTimer).ThenFunc(helloHandler)
mux.Handle("/hello", chain)

虽然在 Go 1.22 中 *http.ServeMux 增加了一些广受欢迎的功能,但其路由和变量支持仍然很基础。嵌套 *http.ServeMux 实例也有些笨拙。如果你发现自己需要更高级的功能,比如基于头部值进行路由、使用正则表达式指定路径变量或更好的处理程序嵌套,那么有许多第三方请求路由器可供选择。其中两个最受欢迎的是 gorilla muxchi。它们都被认为是惯用的,因为它们与 http.Handlerhttp.HandlerFunc 实例配合使用,展示了使用与标准库相容的可组合库的 Go 哲学。它们还与惯用的中间件一起工作,这两个项目还提供了常见问题的可选中间件实现。

几个流行的 Web 框架还实现了自己的处理程序和中间件模式。其中两个最受欢迎的是 EchoGin。它们通过包含自动绑定请求或响应数据到 JSON 等功能,简化了 Web 开发。它们还提供了适配器函数,使你可以使用 http.Handler 实现,提供了迁移路径。

ResponseController

在 “接受接口,返回结构体” 中,你学到了修改接口会破坏向后兼容性。你还学到了可以通过定义新接口并使用类型开关和类型断言来检查是否实现了新接口,随时间演变接口。创建这些额外接口的缺点是很难知道它们的存在,使用类型开关来检查它们也很冗长。

你可以在http包中找到此类示例。在设计该包时,选择将http.ResponseWriter设为接口。这意味着不能在未来的发布版本中向其添加额外的方法,否则将破坏 Go 兼容性保证。为了表示http.ResponseWriter实例的新可选功能,http包包含了几个可能由http.ResponseWriter实现的接口:http.Flusherhttp.Hijacker。这些接口上的方法用于控制响应的输出。

在 Go 1.20 中,http包新增了一个具体类型http.ResponseController。它展示了向现有 API 添加方法的另一种方式:

func handler(rw http.ResponseWriter, req *http.Request) {
    rc := http.NewResponseController(rw)
    for i := 0; i < 10; i++ {
        result := doStuff(i)
        _, err := rw.Write([]byte(result))
        if err != nil {
            slog.Error("error writing", "msg", err)
            return
        }
        err = rc.Flush()
        if err != nil && !errors.Is(err, http.ErrNotSupported) {
            slog.Error("error flushing", "msg", err)
            return
        }
    }
}

在这个示例中,如果http.ResponseWriter支持Flush,则需要将计算后的数据即时返回给客户端。否则,在所有数据都计算完毕后再返回。工厂函数http.NewResponseController接收一个http.ResponseWriter并返回一个指向http.ResponseController的指针。这个具体类型具有用于http.ResponseWriter可选功能的方法。通过将返回的错误与http.ErrNotSupported比较,使用errors.Is来检查底层http.ResponseWriter是否实现了可选方法。你可以在第十三章存储库sample_code/response_controller目录中找到这段代码。

因为http.ResponseController是一个具体类型,它包装了对http.ResponseWriter实现的访问,所以可以随着时间的推移向其添加新方法,而不会破坏现有的实现。这使得新功能可以被发现,并提供了一种标准错误检查的方法来检查可选方法的存在或不存在。这种模式是处理接口需要演变的情况的一种有趣方式。事实上,http.ResponseController包含两个没有对应接口的方法:SetReadDeadlineSetWriteDeadline。未来可能会通过这种技术向http.ResponseWriter添加新的可选方法。

结构化日志

自其首次发布以来,Go 标准库包含了一个简单的日志包log。虽然对于小型程序来说很好用,但它不容易生成结构化日志。现代 Web 服务可能有数百万同时在线用户,在这种规模下,您需要软件来处理日志输出以理解发生的情况。结构化日志使用每个日志条目的文档化格式,使得编写处理日志输出并发现模式和异常的程序变得更加容易。

JSON 通常用于结构化日志,但甚至是使用空格分隔的键值对比起不将值分隔为字段的非结构化日志更易处理。虽然您当然可以通过使用log包来编写 JSON,但它不提供任何简化结构化日志创建的支持。log/slog包解决了这个问题。

log/slog添加到标准库展示了几个良好的 Go 库设计实践。第一个良好的决定是在标准库中包含结构化记录。拥有标准化的结构化记录器使得编写协同工作的模块变得更加容易。已发布了几个第三方结构化记录器来解决log的不足,包括zaplogrusgo-kit log等等。碎片化的记录生态系统的问题在于您希望控制日志输出的位置以及记录的消息级别。如果您的代码依赖于使用不同记录器的第三方模块,这将变得不可能。避免记录分片的通常建议是不要在作为库的模块中记录,但这是不可强制执行的,并且使得监视第三方库中发生的情况更加困难。log/slog包在 Go 1.21 中是新推出的,但它解决了这些不一致性的事实使得它可能在未来几年内被广泛应用于大多数 Go 程序中。

第二个良好的决定是将结构化记录作为其自己的包,而不是log包的一部分。尽管这两个包有着类似的目的,但它们有着非常不同的设计哲学。试图将结构化记录添加到非结构化记录包中会混淆 API。通过将它们作为独立的包,您一眼就能知道slog.Info是结构化记录,而log.Print是非结构化记录,即使您记不清Info是用于结构化还是非结构化记录。

下一个良好的决定是使log/slog API 可扩展化。它从简单开始,通过函数提供默认记录器:

func main() {
    slog.Debug("debug log message")
    slog.Info("info log message")
    slog.Warn("warning log message")
    slog.Error("error log message")
}

这些函数允许您以各种记录级别记录简单消息。输出如下所示:

2023/04/20 23:13:31 INFO info log message
2023/04/20 23:13:31 WARN warning log message
2023/04/20 23:13:31 ERROR error log message

有两件事需要注意。首先,默认记录器默认抑制调试消息。稍后在讨论如何创建自己的记录器时,您将看到如何控制记录级别。

第二点则更加微妙。虽然这是纯文本输出,但它利用空白来生成结构化日志。第一列是年/月/日格式的日期。第二列是 24 小时制的时间。第三列是记录级别。最后是消息内容。

结构化记录的强大之处在于能够添加具有自定义值的字段。通过一些自定义字段更新您的日志:

userID := "fred"
loginCount := 20
slog.Info("user login",
    "id", userID,
    "login_count", loginCount)

你可以像之前一样使用相同的函数,但现在可以添加可选参数。可选参数成对出现。第一部分是键,应为字符串。第二部分是值。此日志行输出以下内容:

2023/04/20 23:36:38 INFO user login id=fred login_count=20

在消息之后,你有键-值对,再次以空格分隔。

虽然这种文本格式比非结构化日志更容易解析,但你可能希望使用类似 JSON 的东西。你可能还希望自定义日志的写入位置或日志级别。为此,你可以创建一个结构化日志实例:

options := &slog.HandlerOptions{Level: slog.LevelDebug}
handler := slog.NewJSONHandler(os.Stderr, options)
mySlog := slog.New(handler)
lastLogin := time.Date(2023, 01, 01, 11, 50, 00, 00, time.UTC)
mySlog.Debug("debug message",
    "id", userID,
    "last_login", lastLogin)

你正在使用slog.HandlerOptions结构来定义新日志记录器的最低日志级别。然后,你使用slog.HandlerOptions上的NewJSONHandler方法来创建一个slog.Handler,将日志使用 JSON 写入指定的io.Writer。在这种情况下,你使用标准错误输出。最后,你使用slog.New函数创建一个包装了slog.Handler*slog.Logger。然后,你创建一个lastLogin值来记录,还有一个用户 ID。这将产生以下输出:

{"time":"2023-04-22T23:30:01.170243-04:00","level":"DEBUG",
 "msg":"debug message","id":"fred","last_login":"2023-01-01T11:50:00Z"}

如果 JSON 和文本不能满足你的输出需求,你可以定义自己实现slog.Handler接口的实现,并将其传递给slog.New

最后,log/slog包考虑了性能问题。如果你不小心,你的程序可能会花更多时间写日志,而不是执行它设计进行的工作。你可以选择以多种方式将数据写入log/slog。你已经看到了最简单(但最慢)的方法,即在DebugInfoWarnError方法上交替使用键和值。为了提高性能并减少分配次数,建议使用LogAttrs方法:

mySlog.LogAttrs(ctx, slog.LevelInfo, "faster logging",
                slog.String("id", userID),
                slog.Time("last_login", lastLogin))

第一个参数是context.Context,接下来是日志级别,然后是零个或多个slog.Attr实例。对于最常用的类型,有工厂函数可用,对于没有提供函数的类型,你可以使用slog.Any

由于兼容性承诺,log包不会被移除。使用它的现有程序将继续工作,同样适用于使用第三方结构化日志记录器的程序。如果你的代码使用了log.Loggerslog.NewLogLogger函数提供了一个桥接到原始log包的方法。它创建一个使用slog.Handler来写输出的log.Logger实例:

myLog := slog.NewLogLogger(mySlog.Handler(), slog.LevelDebug)
myLog.Println("using the mySlog Handler")

这会产生以下输出:

{"time":"2023-04-22T23:30:01.170269-04:00","level":"DEBUG",
 "msg":"using the mySlog Handler"}

你可以在第十三章代码库sample_code/structured_logging目录中找到所有log/slog的编码示例。

log/slog API 包括更多功能,包括动态日志级别支持、上下文支持(上下文在第十四章中讨论),值分组和创建共同的值头。你可以通过查看其API 文档来了解更多信息。最重要的是,看看log/slog是如何组合的,以便学习如何构建自己的 API。

练习

现在你已经更多地了解了标准库,通过这些练习来巩固你所学的知识。解决方案在第十三章代码库exercise_solutions目录中。

  1. 编写一个小的 Web 服务器,当你发送GET命令时返回当前时间的 RFC 3339 格式。如果愿意,可以使用第三方模块。

  2. 编写一个小的中间件组件,使用 JSON 结构化日志记录每个传入请求到你的 Web 服务器的 IP 地址。

  3. 添加以 JSON 格式返回时间的功能。使用Accept头来控制返回 JSON 还是文本(默认为文本)。JSON 应按以下结构进行组织:

    {
        "day_of_week": "Monday",
        "day_of_month": 10,
        "month": "April",
        "year": 2023,
        "hour": 20,
        "minute": 15,
        "second": 20
    }
    

结语

在本章中,你看到了标准库中一些最常用的包,并了解了它们如何体现应在你的代码中效仿的最佳实践。你还看到了其他合理的软件工程原则:在经验丰富的情况下可能会做出不同的决策,以及如何尊重向后兼容性,从而可以在坚实的基础上构建应用程序。

在下一章中,你将看到上下文、通过 Go 代码传递状态和计时器的包和模式。

第十四章:上下文

服务器需要一种处理个别请求元数据的方式。这些元数据可以大致分为两类:一是正确处理请求所需的元数据,二是控制何时停止处理请求的元数据。例如,一个 HTTP 服务器可能希望使用跟踪 ID 标识一系列通过一组微服务的请求。它还可能希望设置一个计时器,以便在其他微服务请求时间过长时结束请求。

许多语言使用线程本地变量来存储这类信息,将数据关联到特定的操作系统线程执行上。这在 Go 语言中行不通,因为 goroutine 没有可以用来查找值的唯一标识。更重要的是,线程本地变量感觉像是魔术;值放在一个地方,却在其他地方出现。

Go 语言通过一种称为context的构造解决请求元数据问题。让我们看看如何正确使用它。

什么是上下文?

不是为语言添加新功能,上下文仅仅是符合context包中定义的Context接口的实例。正如你所知,Go 语言鼓励通过函数参数显式传递数据。对于上下文而言也是如此,它只是作为你的函数的另一个参数。

就像 Go 语言约定的最后一个返回值是error一样,Go 还约定上下文作为函数的第一个参数显式传递到你的程序中。上下文参数的通常名称是ctx

func logic(ctx context.Context, info string) (string, error) {
    // do some interesting stuff here
    return "", nil
}

除了定义Context接口外,context包还包含几个工厂函数,用于创建和包装上下文。当你没有现成的上下文,比如在命令行程序的入口点时,可以使用函数context.Background创建一个空的初始上下文。这将返回一个context.Context类型的变量。(是的,这与通常从函数调用中返回具体类型的模式不同。)

空上下文是一个起点;每次向上下文添加元数据时,都可以通过context包中的工厂函数包装现有的上下文。

注意

另一个函数context.TODO也创建一个空的context.Context。它用于开发过程中的临时使用。如果你不确定上下文将来自何处或将如何使用它,请使用context.TODO在代码中放置一个占位符。生产代码不应包含context.TODO

在编写 HTTP 服务器时,你使用稍微不同的模式来通过中间件层传递并获取上下文,最终传递给顶级的http.Handler。不幸的是,上下文是在net/http包创建之后很长时间才添加到 Go API 中的。由于兼容性承诺,无法修改http.Handler接口以添加context.Context参数。

兼容性承诺确实允许在现有类型中添加新方法,这也是 Go 团队所做的。http.Request 上有两个与上下文相关的方法:

  • Context 返回与请求关联的 context.Context

  • WithContext 接收一个 context.Context 并返回一个新的 http.Request,其中包含旧请求的状态和提供的 context.Context 的组合。

这是一般的模式:

func Middleware(handler http.Handler) http.Handler {
    return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
        ctx := req.Context()
        // wrap the context with stuff -- you'll see how soon!
        req = req.WithContext(ctx)
        handler.ServeHTTP(rw, req)
    })
}

在你的中间件中,第一件事情是通过使用 Context 方法从请求中提取现有的上下文。(如果你想要跳过,可以看看如何将值放入上下文中的“值”部分。)在将值放入上下文后,你可以使用 WithContext 方法基于旧请求和现在填充的上下文创建一个新请求。最后,调用 handler 并传递你的新请求和现有的 http.ResponseWriter

当你实现处理程序时,使用 Context 方法从请求中提取上下文,并将上下文作为第一个参数调用你的业务逻辑,就像之前看到的那样:

func handler(rw http.ResponseWriter, req *http.Request) {
    ctx := req.Context()
    err := req.ParseForm()
    if err != nil {
        rw.WriteHeader(http.StatusInternalServerError)
        rw.Write([]byte(err.Error()))
        return
    }
    data := req.FormValue("data")
    result, err := logic(ctx, data)
    if err != nil {
        rw.WriteHeader(http.StatusInternalServerError)
        rw.Write([]byte(err.Error()))
        return
    }
    rw.Write([]byte(result))
}

当你的应用程序从另一个 HTTP 服务进行 HTTP 调用时,请使用 net/http 包中的 NewRequestWithContext 函数来构造一个请求,其中包含现有的上下文信息:

type ServiceCaller struct {
    client *http.Client
}

func (sc ServiceCaller) callAnotherService(ctx context.Context, data string)
                                          (string, error) {
    req, err := http.NewRequestWithContext(ctx, http.MethodGet,
                "http://example.com?data="+data, nil)
    if err != nil {
        return "", err
    }
    resp, err := sc.client.Do(req)
    if err != nil {
        return "", err
    }
    defer resp.Body.Close()
    if resp.StatusCode != http.StatusOK {
        return "", fmt.Errorf("Unexpected status code %d",
                              resp.StatusCode)
    }
    // do the rest of the stuff to process the response
    id, err := processResponse(resp.Body)
    return id, err
}

你可以在第十四章的仓库中的 sample_code/context_patterns 目录中找到这些代码示例。

现在你知道如何获取和传递上下文了,让我们开始让它们有用起来。你将从传递值开始。

默认情况下,你应该优先通过显式参数传递数据。正如之前提到的,习惯上 Go 更偏向于显式而非隐式,包括显式数据传递。如果一个函数依赖于某些数据,它应该清楚地指出它需要什么数据以及数据来自何处。

然而,在某些情况下,你不能显式地传递数据。最常见的情况是 HTTP 请求处理程序及其关联的中间件。正如你所见,所有的 HTTP 请求处理程序都有两个参数,一个用于请求,一个用于响应。如果你想要在中间件中使一个值对处理程序可用,你需要将它存储在上下文中。可能的情况包括从 JWT(JSON Web Token)中提取用户或创建一个通过多层中间件传递到处理程序和业务逻辑的每个请求的 GUID。

有一个用于将值放入上下文的工厂方法,context.WithValue。它接收三个值:一个上下文,一个键来查找值以及值本身。键和值参数声明为 any 类型。context.WithValue 函数返回一个上下文,但它不是传入函数的同一个上下文。相反,它是一个包含键-值对并包裹传入的父 context.Context上下文。

注意

你会多次看到这种包装模式。上下文被视为一个不可变实例。每当向上下文添加信息时,都是通过将现有的父上下文包装为子上下文来实现的。这使你能够使用上下文将信息传递到代码的更深层。上下文永远不用于将信息从更深的层传递到更高的层。

context.Context上的Value方法检查一个值是否在上下文或其任何父上下文中。此方法接受一个键,并返回与该键关联的值。同样,键参数和值结果都声明为any类型。如果未找到提供的键的值,则返回nil。使用逗号-ok 惯用法将返回的值断言为正确的类型:

ctx := context.Background()
if myVal, ok := ctx.Value(myKey).(int); !ok {
    fmt.Println("no value")
} else {
    fmt.Println("value:", myVal)
}
注意

如果您熟悉数据结构,您可能会意识到在上下文链中搜索存储的值是线性搜索。当只有少量值时,这不会对性能造成严重影响,但如果在请求期间将几十个值存储在上下文中,性能会表现不佳。也就是说,如果您的程序正在创建具有几十个值的上下文链,那么您的程序可能需要进行一些重构。

上下文中存储的值可以是任何类型,但选择正确的键很重要。就像map的键一样,上下文值的键必须是可比较的。不要仅仅使用像"id"这样的string。如果你使用string或者另一个预定义或导出的类型作为键的类型,不同的包可能会创建相同的键,导致冲突。这会引起难以调试的问题,例如一个包向上下文写入数据,掩盖了另一个包写入的数据,或者从上下文读取由另一个包写入的数据。

有两种模式用于保证键是唯一且可比较的。第一种是基于int创建一个新的、未导出的键类型:

type userKey int

在声明您的未导出键类型后,然后声明该类型的未导出常量:

const (
    _ userKey = iota
    key
)

由于类型和键的类型常量都是未导出的,因此来自包外部的代码无法将数据放入上下文以引起冲突。如果您的包需要将多个值放入上下文,请为每个值定义相同类型的不同键,使用您在“iota 用于枚举——有时”中查看过的iota模式。由于您只关心常量的值作为区分多个键的一种方式,这是iota的一个完美用例。

接下来,构建一个 API 来将值放入上下文并从上下文中读取该值。仅在包外的代码应该能够读取和写入您的上下文值时,使这些函数公开。创建具有该值的上下文的函数的名称应以ContextWith开头。从上下文返回值的函数的名称应以FromContext结尾。以下是设置和从上下文中读取用户的函数实现:

func ContextWithUser(ctx context.Context, user string) context.Context {
    return context.WithValue(ctx, key, user)
}

func UserFromContext(ctx context.Context) (string, bool) {
    user, ok := ctx.Value(key).(string)
    return user, ok
}

另一个选项是通过使用空结构体定义未导出的键类型:

type userKey struct{}

然后更改用于管理上下文值访问的函数:

func ContextWithUser(ctx context.Context, user string) context.Context {
    return context.WithValue(ctx, userKey{}, user)
}

func UserFromContext(ctx context.Context) (string, bool) {
    user, ok := ctx.Value(userKey{}).(string)
    return user, ok
}

如何知道使用哪种键样式?如果您有一组用于在上下文中存储不同值的相关键,则使用intiota技术。如果只有一个单一的键,则任何一种都可以。重要的是,您希望使上下文键不可能发生冲突。

现在您已编写了用户管理代码,让我们看看如何使用它。您将编写中间件,从 cookie 中提取用户 ID:

// a real implementation would be signed to make sure
// the user didn't spoof their identity
func extractUser(req *http.Request) (string, error) {
    userCookie, err := req.Cookie("identity")
    if err != nil {
        return "", err
    }
    return userCookie.Value, nil
}

func Middleware(h http.Handler) http.Handler {
    return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
        user, err := extractUser(req)
        if err != nil {
            rw.WriteHeader(http.StatusUnauthorized)
            rw.Write([]byte("unauthorized"))
            return
        }
        ctx := req.Context()
        ctx = ContextWithUser(ctx, user)
        req = req.WithContext(ctx)
        h.ServeHTTP(rw, req)
    })
}

在中间件中,您首先获取用户值。接下来,使用Context方法从请求中提取上下文,并使用ContextWithUser函数创建一个包含用户的新上下文。当您包装上下文时,重用ctx变量名是惯用的。然后,您通过使用WithContext方法从旧请求和新上下文创建一个新请求。最后,您使用我们提供的http.ResponseWriter调用处理程序链中的下一个函数。

在大多数情况下,您希望在请求处理程序中从上下文中提取值,并将其显式传递给业务逻辑。Go 函数具有显式参数,不应将上下文用作通过 API 绕过的方式:

func (c Controller) DoLogic(rw http.ResponseWriter, req *http.Request) {
    ctx := req.Context()
    user, ok := identity.UserFromContext(ctx)
    if !ok {
        rw.WriteHeader(http.StatusInternalServerError)
        return
    }
    data := req.URL.Query().Get("data")
    result, err := c.Logic.BusinessLogic(ctx, user, data)
    if err != nil {
        rw.WriteHeader(http.StatusInternalServerError)
        rw.Write([]byte(err.Error()))
        return
    }
    rw.Write([]byte(result))
}

通过在请求上使用Context方法获取上下文,使用UserFromContext函数从上下文中提取用户,然后调用业务逻辑,您的处理程序会得到上下文。这段代码展示了关注点分离的价值;如何加载用户对Controller来说是未知的。一个真实的用户管理系统可以在中间件中实现,并且可以在不更改任何控制器代码的情况下进行交换。

此示例的完整代码位于第十四章代码库sample_code/context_user目录中。

在某些情况下,最好将值保留在上下文中。前面提到的跟踪 GUID 就是一个例子。此信息用于管理您的应用程序;它不是业务状态的一部分。通过将跟踪 GUID 明确地通过您的代码传递,可以添加额外的参数,并防止与不知道您元信息的第三方库集成。通过在上下文中留下跟踪 GUID,它将在不需要了解跟踪的业务逻辑中隐式传递,并在您的程序写日志消息或连接到另一个服务器时可用。

这是一个简单的上下文感知的 GUID 实现,用于跟踪服务之间的流动,并在日志中包含 GUID:

package tracker

import (
    "context"
    "fmt"
    "net/http"

    "github.com/google/uuid"
)

type guidKey int

const key guidKey = 1

func contextWithGUID(ctx context.Context, guid string) context.Context {
    return context.WithValue(ctx, key, guid)
}

func guidFromContext(ctx context.Context) (string, bool) {
    g, ok := ctx.Value(key).(string)
    return g, ok
}

func Middleware(h http.Handler) http.Handler {
    return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
        ctx := req.Context()
        if guid := req.Header.Get("X-GUID"); guid != "" {
            ctx = contextWithGUID(ctx, guid)
        } else {
            ctx = contextWithGUID(ctx, uuid.New().String())
        }
        req = req.WithContext(ctx)
        h.ServeHTTP(rw, req)
    })
}

type Logger struct{}

func (Logger) Log(ctx context.Context, message string) {
    if guid, ok := guidFromContext(ctx); ok {
        message = fmt.Sprintf("GUID: %s - %s", guid, message)
    }
    // do logging
    fmt.Println(message)
}

func Request(req *http.Request) *http.Request {
    ctx := req.Context()
    if guid, ok := guidFromContext(ctx); ok {
        req.Header.Add("X-GUID", guid)
    }
    return req
}

Middleware 函数从传入的请求中提取或生成一个新的 GUID。在这两种情况下,它都将 GUID 放入上下文中,创建一个带有更新后的上下文的新请求,并继续调用链。

接下来您可以看到如何使用这个 GUID。Logger 结构提供了一个通用的日志记录方法,接受上下文和字符串作为参数。如果上下文中有 GUID,则将其附加到日志消息的开头并输出。当此服务调用另一个服务时,会使用 Request 函数。它接受一个 *http.Request,如果上下文中存在 GUID,则添加一个带有 GUID 的头部,并返回更新后的 *http.Request

一旦您获得了这个包,您可以使用我在 “隐式接口使依赖注入更容易” 中讨论的依赖注入技术来创建完全不知道任何跟踪信息的业务逻辑。首先,声明一个接口来表示您的日志记录器,一个函数类型来表示请求装饰器,以及一个依赖于它们的业务逻辑结构:

type Logger interface {
    Log(context.Context, string)
}

type RequestDecorator func(*http.Request) *http.Request

type LogicImpl struct {
    RequestDecorator RequestDecorator
    Logger           Logger
    Remote           string
}

接下来,实现您的业务逻辑:

func (l LogicImpl) Process(ctx context.Context, data string) (string, error) {
    l.Logger.Log(ctx, "starting Process with "+data)
    req, err := http.NewRequestWithContext(ctx,
        http.MethodGet, l.Remote+"/second?query="+data, nil)
    if err != nil {
        l.Logger.Log(ctx, "error building remote request:"+err.Error())
        return "", err
    }
    req = l.RequestDecorator(req)
    resp, err := http.DefaultClient.Do(req)
    // process the response...
}

GUID 通过日志记录器和请求装饰器传递,而业务逻辑不知道它,从而将程序逻辑所需的数据与程序管理所需的数据分离。唯一知道关联的地方是在 main 中将您的依赖项连接起来的代码。

controller := Controller{
    Logic: LogicImpl{
        RequestDecorator: tracker.Request,
        Logger:           tracker.Logger{},
        Remote:           "http://localhost:4000",
    },
}

您可以在 第十四章存储库sample_code/context_guid 目录中找到 GUID 追踪器的完整代码。

小贴士

使用上下文通过标准 API 传递值。在处理业务逻辑时,将上下文中的值复制到显式参数中。系统维护信息可以直接从上下文中访问。

取消

虽然上下文值对于传递元数据并解决 Go 的 HTTP API 限制非常有用,但上下文还有第二个用途。上下文还允许您控制应用程序的响应性并协调并发的 goroutine。让我们看看如何实现。

我在 “使用上下文终止 Goroutines” 中简要讨论了这个问题。想象一下,您有一个请求启动了几个 goroutine,每个 goroutine 调用不同的 HTTP 服务。如果一个服务返回错误,导致您无法返回有效结果,则继续处理其他 goroutine 没有意义。在 Go 中,这被称为 取消,上下文提供了其实现的机制。

要创建一个可取消的上下文,使用context.WithCancel函数。它以context.Context作为参数,并返回一个context.Context和一个context.CancelFunc。就像context.WithValue一样,返回的context.Context是传入函数的上下文的子上下文。context.CancelFunc是一个不带参数的函数,用于取消上下文,告诉所有监听潜在取消事件的代码停止处理。

每当你创建一个带有关联取消函数的上下文时,无论处理是否以错误结束,必须调用该取消函数。如果不这样做,你的程序将泄露资源(内存和 goroutine),最终会变慢或崩溃。多次调用取消函数不会引发错误;第一次之后的每次调用都不会产生任何效果。

确保调用取消函数的最简单方法是使用defer在取消函数返回后立即调用它:

ctx, cancelFunc := context.WithCancel(context.Background())
defer cancelFunc()

这带来一个问题,你如何检测取消?context.Context接口有一个叫做Done的方法。它返回一个类型为struct{}的通道。(选择这种返回类型的原因是空结构体不使用内存。)当调用cancel函数时,这个通道会关闭。记住,关闭的通道在尝试读取时会立即返回其零值。

警告

如果在一个不可取消的上下文中调用Done,它将返回nil。正如在“在select语句中关闭一个case”中所述,从nil通道读取永远不会返回。如果这不是在select语句的一个case内完成,你的程序将挂起。

让我们看看这是如何工作的。假设你有一个程序从多个 HTTP 端点收集数据。如果其中任何一个失败,你希望结束所有的处理。上下文取消允许你这样做。

注意

在这个例子中,你将利用一个名为httpbin.org的优秀服务。你可以向它发送 HTTP 或 HTTPS 请求,以测试你的应用程序对各种情况的响应方式。你将使用它的两个端点:一个是延迟指定秒数后返回响应的端点,另一个会返回你发送的状态码之一。

首先,创建你的可取消上下文、一个用于从 goroutine 获取数据的通道,以及一个sync.WaitGroup以允许等待直到所有 goroutine 完成:

ctx, cancelFunc := context.WithCancel(context.Background())
defer cancelFunc()
ch := make(chan string)
var wg sync.WaitGroup
wg.Add(2)

接下来,启动两个 goroutine,一个调用 URL,随机返回一个错误的状态,另一个在延迟后发送一个预设的 JSON 响应。首先是随机状态的 goroutine:

    go func() {
        defer wg.Done()
        for {
            // return one of these status code at random
            resp, err := makeRequest(ctx,
                "http://httpbin.org/status/200,200,200,500")
            if err != nil {
                fmt.Println("error in status goroutine:", err)
                cancelFunc()
                return
            }
            if resp.StatusCode == http.StatusInternalServerError {
                fmt.Println("bad status, exiting")
                cancelFunc()
                return
            }
            select {
            case ch <- "success from status":
            case <-ctx.Done():
            }
            time.Sleep(1 * time.Second)
        }
    }()

makeRequest函数是一个辅助函数,用于使用提供的上下文和 URL 进行 HTTP 请求。如果返回 OK 状态,你将向通道写入一条消息并休眠一秒钟。当出现错误或者返回了一个错误的状态码时,你调用cancelFunc并退出 goroutine。

延迟 goroutine 类似:

    go func() {
        defer wg.Done()
        for {
            // return after a 1 second delay
            resp, err := makeRequest(ctx, "http://httpbin.org/delay/1")
            if err != nil {
                fmt.Println("error in delay goroutine:", err)
                cancelFunc()
                return
            }
            select {
            case ch <- "success from delay: " + resp.Header.Get("date"):
            case <-ctx.Done():
            }
        }
    }()

最后,您使用 for/select 模式从 goroutine 写入的通道中读取数据,并等待取消的发生:

loop:
    for {
        select {
        case s := <-ch:
            fmt.Println("in main:", s)
        case <-ctx.Done():
            fmt.Println("in main: cancelled!")
            break loop
        }
    }
    wg.Wait()

在你的select语句中,有两种情况。一种从消息通道读取,另一种等待完成通道关闭。当它关闭时,你退出循环并等待 goroutine 退出。你可以在第十四章仓库sample_code/cancel_http目录中找到此程序。

这是您运行代码时发生的情况(结果是随机的,所以请继续运行几次以查看不同的结果):

in main: success from status
in main: success from delay: Thu, 16 Feb 2023 03:53:57 GMT
in main: success from status
in main: success from delay: Thu, 16 Feb 2023 03:53:58 GMT
bad status, exiting
in main: cancelled!
error in delay goroutine: Get "http://httpbin.org/delay/1": context canceled

有一些有趣的事情需要注意。首先,您多次调用cancelFunc。正如前面提到的,这是完全可以的,不会引起问题。接下来,请注意,在触发取消后,您从延迟 goroutine 获取了一个错误。这是因为 Go 标准库中内置的 HTTP 客户端尊重取消。您使用可取消的上下文创建了请求,当取消时,请求结束。这会触发 goroutine 中的错误路径,并确保它不会泄漏。

你可能想知道导致取消的错误以及如何报告它。名为WithCancelCauseWithCancel的替代版本返回一个接受错误作为参数的取消函数。context包中的Cause函数返回传递给取消函数首次调用的错误。

注意

Causecontext包中的一个函数,而不是context.Context上的方法,因为在 Go 1.20 中将通过取消返回错误的功能添加到了context包中,这是在最初引入context之后很久的事情。如果在context.Context接口上添加了一个新方法,这将破坏任何实现它的第三方代码。另一个选项是定义一个包含此方法的新接口,但现有代码已经到处传递context.Context,并将其转换为具有Cause方法的新接口将需要类型断言或类型切换。添加一个函数是最简单的方法。随着时间的推移,有几种方式可以演化你的 API。你应该选择对用户影响最小的方式。

让我们重写程序以捕获错误。首先,您更改了上下文的创建:

ctx, cancelFunc := context.WithCancelCause(context.Background())
defer cancelFunc(nil)

接下来,您将对两个 goroutine 进行轻微修改。状态 goroutine 中for循环的主体现在如下所示:

resp, err := makeRequest(ctx, "http://httpbin.org/status/200,200,200,500")
if err != nil {
    cancelFunc(fmt.Errorf("in status goroutine: %w", err))
    return
}
if resp.StatusCode == http.StatusInternalServerError {
    cancelFunc(errors.New("bad status"))
    return
}
ch <- "success from status"
time.Sleep(1 * time.Second)

您已删除fmt.Println语句,并将错误传递给cancelFunc。延迟 goroutine 中for循环的主体现在如下所示:

resp, err := makeRequest(ctx, "http://httpbin.org/delay/1")
if err != nil {
    fmt.Println("in delay goroutine:", err)
    cancelFunc(fmt.Errorf("in delay goroutine: %w", err))
    return
}
ch <- "success from delay: " + resp.Header.Get("date")

fmt.Println仍然存在,因此您可以显示仍然生成并传递给cancelFunc的错误。

最后,您使用context.Cause在初始取消和等待 goroutine 完成后打印错误:

loop:
    for {
        select {
        case s := <-ch:
            fmt.Println("in main:", s)
        case <-ctx.Done():
            fmt.Println("in main: cancelled with error", context.Cause(ctx))
            break loop
        }
    }
    wg.Wait()
    fmt.Println("context cause:", context.Cause(ctx))

你可以在第十四章存储库sample_code/cancel_error_http目录中找到此代码。

运行新程序会生成以下输出:

in main: success from status
in main: success from delay: Thu, 16 Feb 2023 04:11:49 GMT
in main: cancelled with error bad status
in delay goroutine: Get "http://httpbin.org/delay/1": context canceled
context cause: bad status

当最初在switch语句中检测到取消时,你会看到来自状态 goroutine 的错误输出,以及在等待延迟 goroutine 完成后。请注意,延迟 goroutine 使用错误调用了cancelFunc,但该错误并未覆盖最初的取消错误。

当你的代码达到结束处理的逻辑状态时,手动取消非常有用。有时候你想取消是因为任务花费的时间太长。在这种情况下,你可以使用定时器。

带有截止时间的上下文。

服务器的最重要工作之一是管理请求。初学者程序员通常认为服务器应该尽可能多地接受请求,并在可能的情况下处理它们,直到为每个客户端返回结果。

问题在于这种方法不具备可扩展性。服务器是共享资源。像所有共享资源一样,每个用户都希望从中获取尽可能多的资源,并且并不十分关心其他用户的需求。共享资源的责任是自我管理,以便为所有用户提供公平的时间。

通常,服务器可以执行四项操作来管理其负载:

  • 限制同时请求。

  • 限制等待运行的排队请求数量。

  • 限制请求可以运行的时间量。

  • 限制请求可以使用的资源(如内存或磁盘空间)。

Go 提供了处理前三个问题的工具。在学习并发性时,你了解到如何处理前两个问题(参见第十二章)。通过限制 goroutine 的数量,服务器可以管理同时负载。通过缓冲通道处理等待队列的大小。

上下文提供了一种控制请求运行时间的方法。在构建应用程序时,你应该有一个性能范围的概念:在用户体验不佳之前,请求完成的最长时间。如果你知道请求可以运行的最长时间,可以使用上下文进行强制执行。

注意

虽然GOMEMLIMIT提供了限制 Go 程序使用的内存量的软性方法,但如果你想强制约束单个请求使用的内存或磁盘空间,你必须编写管理此类限制的代码。本书讨论此主题超出了范围。

你可以使用两个函数之一创建有时间限制的上下文。第一个是context.WithTimeout。它接受两个参数:一个现有的上下文和指定持续时间的time.Duration,在此持续时间后上下文将自动取消。它返回一个上下文,在指定持续时间后自动触发取消,并返回一个可立即调用以取消上下文的取消函数。

第二个函数是context.WithDeadline。此函数接收一个现有的上下文和一个指定上下文何时自动取消的time.Time。像context.WithTimeout一样,它返回一个上下文,在指定时间后自动触发取消以及一个取消函数。

提示

如果你将过去的时间传递给context.WithDeadline,上下文已经被创建取消。

就像从context.WithCancelcontext.WithCancelCause返回的取消函数一样,你必须确保context.WithTimeoutcontext.WithDeadline返回的取消函数至少被调用一次。

如果你想知道上下文何时会自动取消,请使用context.ContextDeadline方法。它返回一个time.Time,指示时间和一个bool,指示是否设置了超时。这与读取映射或通道时使用的 comma ok 习语类似。

当你为请求的总持续时间设置时间限制时,你可能希望将这段时间细分。如果你从你的服务调用另一个服务,你可能希望限制允许网络调用运行的时间,为其余处理或其他网络调用预留一些时间。通过使用context.WithTimeoutcontext.WithDeadline创建包装父上下文的子上下文,你可以控制每个单独调用所花费的时间。

你在子上下文设置的任何超时都受父上下文设置的超时的限制;如果父上下文在 2 秒钟后超时,你可以声明子上下文在 3 秒钟后超时,但当父上下文在 2 秒钟后超时时,子上下文也将超时。

你可以通过一个简单的程序看到这一点:

ctx := context.Background()
parent, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
child, cancel2 := context.WithTimeout(parent, 3*time.Second)
defer cancel2()
start := time.Now()
<-child.Done()
end := time.Now()
fmt.Println(end.Sub(start).Truncate(time.Second))

在这个示例中,你在父上下文上指定了 2 秒钟的超时,在子上下文上指定了 3 秒钟的超时。然后,你通过等待从子context.ContextDone方法返回的通道来等待子上下文完成。我将在下一节更多地讨论Done方法。

你可以在第十四章存储库sample_code/nested_timers目录中找到此代码,或在Go Playground上运行此代码。你将看到以下结果:

2s

由于带有计时器的上下文可以因超时或显式调用取消函数而取消,上下文 API 提供了一种告知取消原因的方法。Err方法返回nil,如果上下文仍处于活动状态,或者如果上下文已被取消,则返回两种哨兵错误之一:context.Canceledcontext.DeadlineExceeded。当显式取消时返回第一个错误,当超时触发取消时返回第二个错误。

让我们看看它们的用法。你将对你的 httpbin 程序进行一次修改。这一次,你要为控制 goroutine 运行时间的上下文添加一个超时:

ctx, cancelFuncParent := context.WithTimeout(context.Background(), 3*time.Second)
defer cancelFuncParent()
ctx, cancelFunc := context.WithCancelCause(ctx)
defer cancelFunc(nil)
注意

如果您希望返回取消原因的错误选项,则需要将由WithTimeoutWithDeadline创建的上下文包装在由WithCancelCause创建的上下文中。您必须推迟两个取消函数,以防止资源泄漏。如果希望在上下文超时时返回自定义哨兵错误,请改用context.WithTimeoutCausecontext.WithDeadlineCause函数。

现在,如果返回 500 状态代码或者在 3 秒内未获取 500 状态代码,您的程序将退出。您对程序的唯一其他更改是在取消发生时打印Err返回的值:

fmt.Println("in main: cancelled with cause:", context.Cause(ctx),
    "err:", ctx.Err())

您可以在第十四章存储库sample_code/timeout_error_http目录中找到该代码。

结果是随机的,因此运行程序多次以查看不同的结果。如果运行程序并达到超时,您将获得如下输出:

in main: success from status
in main: success from delay: Sun, 19 Feb 2023 04:36:44 GMT
in main: success from status
in main: success from status
in main: success from delay: Sun, 19 Feb 2023 04:36:45 GMT
in main: cancelled with cause: context deadline exceeded
    err: context deadline exceeded
in delay goroutine: Get "http://httpbin.org/delay/1":
    context deadline exceeded
context cause: context deadline exceeded

注意,context.Cause返回的错误与Err方法返回的错误相同:context.DeadlineExceeded

如果状态错误发生在 3 秒内,您将获得如下输出:

in main: success from status
in main: success from status
in main: success from delay: Sun, 19 Feb 2023 04:37:14 GMT
in main: cancelled with cause: bad status err: context canceled
in delay goroutine: Get "http://httpbin.org/delay/1": context canceled
context cause: bad status

现在context.Cause返回的错误是bad status,但Err返回context.Canceled错误。

您自己代码中的上下文取消

大多数情况下,您无需担心自己的代码超时或取消;它根本不会运行足够长时间。每当调用另一个 HTTP 服务或数据库时,应传递上下文;这些库通过上下文正确处理取消。

您应考虑处理两种情况的取消。第一种情况是当您的函数使用select语句读取或写入通道时。如“取消”中所示,包括检查上下文上的Done方法返回的通道的case。这允许您的函数在上下文取消时退出,即使 goroutine 未正确处理取消。

第二种情况是当您编写的代码运行时间足够长,应该被上下文取消中断时。在这种情况下,定期使用context.Cause检查上下文的状态。context.Cause函数如果上下文已被取消,则返回错误。

这是支持您代码中上下文取消的模式:

func longRunningComputation(ctx context.Context, data string) (string, error) {
    for {
        // do some processing
        // insert this if statement periodically
        // to check if the context has been cancelled
        if err := context.Cause(ctx); err != nil {
            // return a partial value if it makes sense,
            // or a default one if it doesn't
            return "", err
        }
        // do some more processing and loop again
    }
}

这是一个示例循环,通过使用低效的 Leibniz 算法计算π的函数。使用上下文取消允许您控制其运行时间:

i := 0
for {
    if err := context.Cause(ctx); err != nil {
        fmt.Println("cancelled after", i, "iterations")
        return sum.Text('g', 100), err
    }
    var diff big.Float
    diff.SetInt64(4)
    diff.Quo(&diff, &d)
    if i%2 == 0 {
        sum.Add(&sum, &diff)
    } else {
        sum.Sub(&sum, &diff)
    }
    d.Add(&d, two)
    i++
}

您可以查看完整的示例程序,演示了sample_code/own_cancellation目录中的这种模式,在第十四章存储库中找到。

练习

现在您已经看到如何使用上下文,请尝试实现这些练习。所有答案都可以在第十四章存储库中找到。

  1. 创建一个生成中间件的函数,它创建一个带有超时的上下文。该函数应该有一个参数,即请求允许运行的毫秒数。它应返回一个func(http.Handler) http.Handler

  2. 编写一个程序,将在生成的随机数介于 0(含)和 100,000,000(不含)之间的范围内随机生成,直到两件事中的一件发生为止:生成数字 1234 或经过 2 秒。打印出总和、迭代次数以及结束原因(超时或达到数字)。

  3. 假设你有一个简单的日志记录函数,看起来像这样:

    func Log(ctx context.Context, level Level, message string) {
        var inLevel Level
        // TODO get a logging level out of the context and assign it to inLevel
        if level == Debug && inLevel == Debug {
            fmt.Println(message)
        }
        if level == Info && (inLevel == Debug || inLevel == Info) {
            fmt.Println(message)
        }
    }
    

    定义一个名为Level的类型,其底层类型为string。定义该类型的两个常量,DebugInfo,分别设置为"debug""info"

    在上下文中创建函数来存储日志级别并提取它。

    创建一个中间件函数,从名为log_level的查询参数中获取日志级别。log_level 的有效值为 debuginfo

    最后,在Log中填写TODO,从上下文中正确提取日志级别。如果未分配日志级别或不是有效值,则不应打印任何内容。

总结

在本章中,您学习了如何使用上下文管理请求元数据。现在,您可以设置超时时间,执行显式取消操作,通过上下文传递值,并知道应该在何时执行这些操作。在下一章中,您将了解 Go 的内置测试框架,并学习如何使用它来查找程序中的错误并诊断性能问题。

第十五章:写测试

自 2000 年以来,自动化测试的广泛采用可能比任何其他软件工程技术更多地提高了代码质量。作为一个专注于提高软件质量的语言和生态系统,Go 将测试支持作为其标准库的一部分包含其中并不令人意外。Go 使得测试代码变得如此简单,没有理由不去做。

在本章中,您将看到如何测试 Go 代码,将测试分组为单元测试和集成测试,检查代码覆盖率,编写基准测试,并学习如何通过使用 Go 数据竞争检测器检查代码并发问题。在此过程中,我将讨论如何编写可测试的代码以及为什么这样可以提高我们的代码质量。

理解测试的基础知识

与许多其他语言不同,Go 将其测试放在与生产代码相同的目录和包中。由于测试位于同一包中,因此它们能够访问和测试未公开的函数和变量。稍后您将看到如何编写测试来确保仅测试公共 API。

注意

本章的完整代码示例可在 第十五章存储库 中找到。

让我们编写一个简单的函数,然后编写一个测试来确保该函数正常工作。在 sample_code/adder 目录中的 adder.go 文件中,您有以下内容:

func addNumbers(x, y int) int {
    return x + x
}

相应的测试位于 adder_test.go 中:

func Test_addNumbers(t *testing.T) {
    result := addNumbers(2,3)
    if result != 5 {
        t.Error("incorrect result: expected 5, got", result)
    }
}

每个测试都写在以 _test.go 结尾的文件中。如果您正在对 foo.go 进行测试,请将测试放在名为 foo_test.go 的文件中。

测试函数以单参数 *testing.T 开头。按照惯例,这个参数被命名为 t。测试函数不返回任何值。测试的名称(除了以单词Test开头之外)旨在说明您正在测试什么,请选择一个能够解释您正在测试内容的名称。当为单个函数编写单元测试时,惯例是将单元测试命名为 Test 后跟函数名。在测试未导出的函数时,有些人在单词Test和函数名之间使用下划线。

还要注意,您使用标准的 Go 代码调用正在测试的代码,并验证响应是否符合预期。当结果不正确时,使用 t.Error 方法报告错误,其工作方式类似于 fmt.Print 函数。稍后您将看到其他报告错误的方法。

Go 的测试支持分为两部分:库和工具。标准库中的 testing 包提供了编写测试所需的类型和函数,而随 Go 捆绑的 go test 工具则运行您的测试并生成报告。

$ go test
--- FAIL: Test_addNumbers (0.00s)
    adder_test.go:8: incorrect result: expected 5, got 4
FAIL
exit status 1
FAIL    test_examples/adder     0.006s

看起来你在代码中发现了一个 bug。仔细查看addNumbers,你会发现你正在将x加到x,而不是加到y。让我们更改代码并重新运行测试以验证 bug 是否已修复:

$ go test
PASS
ok      test_examples/adder     0.006s

go test命令允许你指定要测试的包。使用./...作为包名指定你想在当前目录及其所有子目录中运行测试。包含-v标志以获取详细的测试输出。

报告测试失败

*testing.T上有几种报告测试失败的方法。你已经看到了Error,它通过逗号分隔的值构建一个失败描述字符串。

如果你更喜欢使用Printf风格的格式化字符串来生成你的消息,请改用Errorf方法:

t.Errorf("incorrect result: expected %d, got %d", 5, result)

虽然ErrorErrorf标记一个测试为失败,但测试函数会继续运行。如果你认为一个测试函数应该在发现失败后立即停止处理,可以使用FatalFatalf方法。Fatal方法的工作方式类似于Error,而Fatalf方法的工作方式类似于Errorf。不同之处在于,在生成测试失败消息后,测试函数立即退出。请注意,这并不会退出所有测试;当前测试函数退出后,任何剩余的测试函数将继续执行。

何时应该使用Fatal/Fatalf,何时应该使用Error/Errorf?如果测试中的某个检查失败意味着后续检查总会失败或导致测试崩溃,请使用FatalFatalf。如果你正在测试几个独立的项目(例如验证结构体中的字段),则使用ErrorErrorf,这样你可以一次报告多个问题。这样可以更轻松地修复多个问题,而无需反复运行测试。

设置和拆除

有时你有一些通用状态需要在所有测试运行之前设置并在测试完成后移除。使用TestMain函数来管理这些状态并运行你的测试:

var testTime time.Time

func TestMain(m *testing.M) {
    fmt.Println("Set up stuff for tests here")
    testTime = time.Now()
    exitVal := m.Run()
    fmt.Println("Clean up stuff after tests here")
    os.Exit(exitVal)
}

func TestFirst(t *testing.T) {
    fmt.Println("TestFirst uses stuff set up in TestMain", testTime)
}

func TestSecond(t *testing.T) {
    fmt.Println("TestSecond also uses stuff set up in TestMain", testTime)
}

TestFirstTestSecond都引用了包级别变量testTime。假设需要初始化它以便测试正确运行。你声明一个名为TestMain的函数,参数类型为*testing.M。如果包中有一个名为TestMain的函数,go test将调用它而不是测试函数。TestMain函数的责任是设置任何必要的状态,以确保包中的测试正确运行。

一旦状态配置完成,TestMain函数调用*testing.M上的Run方法。这将在包中运行测试函数。Run方法返回退出码;0表示所有测试通过。最后,TestMain函数必须使用从Run返回的退出码调用os.Exit

运行go test会产生如下输出:

$ go test
Set up stuff for tests here
TestFirst uses stuff set up in TestMain 2020-09-01 21:42:36.231508 -0400 EDT
    m=+0.000244286
TestSecond also uses stuff set up in TestMain 2020-09-01 21:42:36.231508 -0400
    EDT m=+0.000244286
PASS
Clean up stuff after tests here
ok      test_examples/testmain  0.006s

注意

请注意,TestMain仅在测试前后各调用一次。还要注意,每个包只能有一个TestMain

TestMain在两种常见情况下很有用:

  • 当你需要在外部存储库(如数据库)中设置数据时

  • 当被测试的代码依赖于需要初始化的包级变量时

如前所述(并将再次提及!),你应该避免在程序中使用包级变量。这会使得理解数据如何在你的程序中流动变得困难。如果你因此而使用TestMain,请考虑重构你的代码。

*testing.T上的Cleanup方法用于清理为单个测试创建的临时资源。此方法有一个参数,即没有输入参数或返回值的函数。该函数在测试完成时运行。对于简单的测试,你可以通过使用defer语句达到相同的结果,但在测试依赖于设置示例数据的辅助函数时,Cleanup非常有用,就像你在 Example 15-1 中看到的那样。可以多次调用Cleanup是可以的。与defer一样,函数按照后添加的先调用的顺序执行。

示例 15-1. 使用t.Cleanup
// createFile is a helper function called from multiple tests
func createFile(t *testing.T) (_ string, err error) {
    f, err := os.Create("tempFile")
    if err != nil {
        return "", err
    }
    defer func() {
        err = errors.Join(err, f.Close())
    }()
    // write some data to f
    t.Cleanup(func() {
        os.Remove(f.Name())
    })
    return f.Name(), nil
}

func TestFileProcessing(t *testing.T) {
    fName, err := createFile(t)
    if err != nil {
        t.Fatal(err)
    }
    // do testing, don't worry about cleanup
}

如果你的测试使用临时文件,你可以通过利用*testing.T上的TempDir方法而避免编写清理代码。每次调用时,该方法都会创建一个新的临时目录,并返回目录的完整路径。它还会在Cleanup上注册一个处理程序,以便在测试完成时删除目录及其内容。你可以用它来重新编写前面的示例:

// createFile is a helper function called from multiple tests
func createFile(tempDir string) (_ string, err error) {
    f, err := os.CreateTemp(tempDir, "tempFile")
    if err != nil {
        return "", err
    }
    defer func() {
        err = errors.Join(err, f.Close())
    }()
    // write some data to f
    return f.Name(), nil
}

func TestFileProcessing(t *testing.T) {
    tempDir := t.TempDir()
    fName, err := createFile(tempDir)
    if err != nil {
        t.Fatal(err)
    }
    // do testing, don't worry about cleanup
}

使用环境变量进行测试

配置应用程序使用环境变量是一种常见(并且非常好的)做法。为了帮助你测试环境变量解析代码,Go 提供了testing.T上的一个辅助方法。调用t.Setenv()来为你的测试注册一个环境变量的值。在幕后,它调用Cleanup在测试结束时将环境变量恢复到先前的状态:

// assume ProcessEnvVars is a function that processes environment variables
// and returns a struct with an OutputFormat field
func TestEnvVarProcess(t *testing.T) {
    t.Setenv("OUTPUT_FORMAT", "JSON")
    cfg := ProcessEnvVars()
    if cfg.OutputFormat != "JSON" {
        t.Error("OutputFormat not set correctly")
    }
    // value of OUTPUT_FORMAT is reset when the test function exits
}
注意

尽管使用环境变量来配置你的应用程序是好的,但确保大部分代码完全不知道它们也是很重要的。确保在程序开始工作时,在你的main函数或之后尽快将环境变量的值复制到配置结构体中。这样做可以使代码更易于重用和测试,因为代码的配置方式已经从代码执行的内容中抽象出来。

与其自己编写此代码,你应该强烈考虑使用第三方配置库,比如Viperenvconfig。此外,查看GoDotEnv,它可以将环境变量存储在.env文件中,供开发或持续集成机器使用。

存储样本测试数据

go test遍历你的源代码树时,它将当前包目录作为当前工作目录。如果你想在一个包中使用样本数据来测试函数,请创建一个名为testdata的子目录来保存你的文件。Go 将此目录名称保留为一个用于保存测试文件的地方。在从testdata读取时,始终使用相对文件引用。由于go test将当前工作目录更改为当前包,因此每个包通过相对文件路径访问其自己的testdata

提示

第十五章的代码库sample_code目录中,text 包演示了如何使用testdata

缓存测试结果

正如你在第 10 章中学到的,如果 Go 编译的包没有改变,它会缓存这些编译后的包,同样地,当跨多个包运行测试并且它们通过且代码没有改变时,Go 也会缓存测试结果。如果你改变了包中的任何文件或testdata目录中的文件,则会重新编译并重新运行测试。如果你希望测试始终运行,请向go test传递-count=1标志。

测试你的公共 API

你编写的测试与生产代码位于同一个包中。这允许你测试导出和未导出的函数。

如果你只想测试包的公共 API,Go 有一种约定可以指定这样做。你仍然将测试源代码放在与生产源代码相同的目录中,但是包名使用packagename_test。让我们重新做初始测试用例,使用一个导出的函数来代替。你可以在第十五章的代码库sample_code/pubadder目录中找到这段代码。如果你的pubadder包中有如下函数:

func AddNumbers(x, y int) int {
    return x + y
}

然后,你可以在pubadder包的名为adder_public_test.go的文件中使用以下代码测试它:

package pubadder_test

import (
    "github.com/learning-go-book-2e/ch15/sample_code/pubadder"
    "testing"
)

func TestAddNumbers(t *testing.T) {
    result := pubadder.AddNumbers(2, 3)
    if result != 5 {
        t.Error("incorrect result: expected 5, got", result)
    }
}

注意测试文件的包名为pubadder_test。即使文件位于同一个目录中,你也必须导入github.com/learning-go-book-2e/ch15/sample_code/pubadder。为了遵循测试命名的约定,测试函数名与AddNumbers函数的名称匹配。还要注意,你要使用pubadder.AddNumbers,因为你在不同包中调用一个导出函数。

提示

如果你是手动输入这段代码,你需要创建一个带有模块声明的go.mod文件:

module github.com/learning-go-book-2e/ch15

这会将源代码放在模块内的sample_code/pubadder目录中。

就像您可以从包内调用导出函数一样,您可以从与源代码相同包中的测试中测试您的公共 API。在包名称中使用 _test 后缀的优势在于,它让您将测试的包视为“黑盒”。您只能通过其导出的函数、方法、类型、常量和变量与其交互。还要注意,您可以在同一源目录中混合使用包名称交错的测试源文件。

使用 go-cmp 进行比较测试结果

编写复合类型两个实例的彻底比较可能会很啰嗦。虽然您可以使用 reflect.DeepEqual 比较结构体、映射和切片,但有更好的方法。Google 发布了一个名为 go-cmp 的第三方模块,它可以为您进行比较,并返回不匹配之处的详细描述。让我们看看它是如何工作的,通过定义一个简单的 struct 和一个填充它的工厂函数。您可以在 第十五章仓库sample_code/cmp 目录中找到这段代码:

type Person struct {
    Name      string
    Age       int
    DateAdded time.Time
}

func CreatePerson(name string, age int) Person {
    return Person{
        Name:      name,
        Age:       age,
        DateAdded: time.Now(),
    }
}

在您的测试文件中,您需要导入 github.com/google/go-cmp/cmp,并且您的测试函数看起来像这样:

func TestCreatePerson(t *testing.T) {
    expected := Person{
        Name: "Dennis",
        Age:  37,
    }
    result := CreatePerson("Dennis", 37)
    if diff := cmp.Diff(expected, result); diff != "" {
        t.Error(diff)
    }
}

cmp.Diff 函数接受预期输出和由您测试的函数返回的输出。它返回一个描述两个输入之间任何不匹配的字符串。如果输入匹配,则返回空字符串。您将 cmp.Diff 函数的输出分配给名为 diff 的变量,然后检查 diff 是否为空字符串。如果不是,则发生了错误。

当您构建并运行测试时,您将看到 go-cmp 在两个结构实例不匹配时生成的输出:

$ go test
--- FAIL: TestCreatePerson (0.00s)
    ch13_cmp_test.go:16:   ch13_cmp.Person{
              Name:      "Dennis",
              Age:       37,
        -     DateAdded: s"0001-01-01 00:00:00 +0000 UTC",
        +     DateAdded: s"2020-03-01 22:53:58.087229 -0500 EST m=+0.001242842",
          }

FAIL
FAIL    ch13_cmp    0.006s

-+ 开头的行指示其值不同的字段。测试失败是因为日期不匹配。这是一个问题,因为您无法控制 CreatePerson 函数分配的日期。您需要忽略 DateAdded 字段。通过指定一个比较函数来实现。在测试中将该函数声明为局部变量:

comparer := cmp.Comparer(func(x, y Person) bool {
    return x.Name == y.Name && x.Age == y.Age
})

将函数传递给 cmp.Comparer 函数以创建一个自定义比较器。传入的函数必须有两个相同类型的参数,并返回一个 bool 值。它还必须是对称的(参数的顺序不重要)、确定性的(对于相同的输入始终返回相同的值)和纯粹的(不修改其参数)。在您的实现中,您正在比较 NameAge 字段,并忽略 DateAdded 字段。

然后将您的调用更改为 cmp.Diff,包括 comparer

if diff := cmp.Diff(expected, result, comparer); diff != "" {
    t.Error(diff)
}

这只是 go-cmp 中最有用的功能的快速预览。查看其 文档 了解更多关于如何控制比较和输出格式的信息。

运行表测试

大多数情况下,验证函数正确工作需要多个测试案例。你可以编写多个测试函数来验证函数,或者在同一个函数中编写多个测试,但你会发现大部分测试逻辑是重复的。你设置支持数据和函数,指定输入,检查输出,并比较它们是否符合你的预期。

而不是一遍又一遍地写这个,你可以利用称为表测试的模式。让我们看一个样例。你可以在第十五章存储库sample_code/table目录中找到这段代码。假设你在table包中有以下函数:

func DoMath(num1, num2 int, op string) (int, error) {
    switch op {
    case "+":
        return num1 + num2, nil
    case "-":
        return num1 - num2, nil
    case "*":
        return num1 + num2, nil
    case "/":
        if num2 == 0 {
            return 0, errors.New("division by zero")
        }
        return num1 / num2, nil
    default:
        return 0, fmt.Errorf("unknown operator %s", op)
    }
}

要测试这个函数,你需要检查不同的分支,尝试返回有效结果的输入,以及触发错误的输入。你可以编写这样的代码,但这样做非常重复:

func TestDoMath(t *testing.T) {
    result, err := DoMath(2, 2, "+")
    if result != 4 {
        t.Error("Should have been 4, got", result)
    }
    if err != nil {
        t.Error("Should have been nil error, got", err)
    }
    result2, err2 := DoMath(2, 2, "-")
    if result2 != 0 {
        t.Error("Should have been 0, got", result2)
    }
    if err2 != nil {
        t.Error("Should have been nil error, got", err2)
    }
    // and so on...
}

让我们用表测试替换这种重复。首先,你声明一个匿名结构体切片。结构体包含测试的名称、输入参数和返回值字段。切片中的每个条目代表另一个测试:

data := []struct {
    name     string
    num1     int
    num2     int
    op       string
    expected int
    errMsg   string
}{
    {"addition", 2, 2, "+", 4, ""},
    {"subtraction", 2, 2, "-", 0, ""},
    {"multiplication", 2, 2, "*", 4, ""},
    {"division", 2, 2, "/", 1, ""},
    {"bad_division", 2, 0, "/", 0, `division by zero`},
}

接下来,循环处理data中的每个测试案例,每次调用Run方法。这是实现魔法的地方。你向Run传递两个参数,子测试的名称和一个参数类型为*testing.T的函数。在函数内部,你使用data当前条目的字段调用DoMath,一遍又一遍地使用相同的逻辑。运行这些测试时,你会发现它们不仅通过了测试,而且当你使用-v标志时,每个子测试现在也有了名称:

for _, d := range data {
    t.Run(d.name, func(t *testing.T) {
        result, err := DoMath(d.num1, d.num2, d.op)
        if result != d.expected {
            t.Errorf("Expected %d, got %d", d.expected, result)
        }
        var errMsg string
        if err != nil {
            errMsg = err.Error()
        }
        if errMsg != d.errMsg {
            t.Errorf("Expected error message `%s`, got `%s`",
                d.errMsg, errMsg)
        }
    })
}
提示

比较错误消息可能不够稳定,因为消息文本可能没有兼容性保证。你正在测试的函数使用errors.Newfmt.Errorf生成错误,因此唯一的选择是比较消息。如果错误具有自定义类型或命名的哨兵错误,请使用errors.Iserrors.As来检查返回的正确错误。

并行运行测试

默认情况下,单元测试是顺序运行的。由于每个单元测试应该独立于其他单元测试,它们是并发的理想候选者。要使单元测试与其他测试同时运行,请在测试的第一行调用*testing.TParallel方法:

func TestMyCode(t *testing.T) {
    t.Parallel()
    // rest of test goes here
}

并行测试与其他标记为并行的测试同时运行。

并行测试的优势在于可以加快长时间运行的测试套件。但也有一些缺点。如果有多个测试依赖于相同的共享可变状态,请不要标记它们为并行,因为你会得到不一致的结果。(但在所有这些警告之后,你的应用程序中没有共享可变状态,对吧?)还要注意,如果你标记为并行并在测试函数中使用Setenv方法,你的测试将会 panic。

在并行运行表格测试时要小心。当表格测试并行运行时,情况就像你在 “Goroutines, for Loops, and Varying Variables” 中看到的一样,你在 for 循环内启动了多个 goroutine。如果你在 Go 1.21 或更早版本中运行此示例(或在 Go 1.22 或更高版本中,但在 go.mod 文件的 go 指令中设置了 Go 版本为 1.21 或更早版本),则所有并行测试共享变量 d 的引用,因此它们都看到相同的值:

func TestParallelTable(t *testing.T) {
    data := []struct {
        name   string
        input  int
        output int
    }{
        {"a", 10, 20},
        {"b", 30, 40},
        {"c", 50, 60},
    }
    for _, d := range data {
        t.Run(d.name, func(t *testing.T) {
            t.Parallel()
            fmt.Println(d.input, d.output)
            out := toTest(d.input)
            if out != d.output {
                t.Error("didn't match", out, d.output)
            }
        })
    }
}

你可以在 The Go Playground 上或者在 第十五章的代码库sample_code/parallel 目录中运行这段代码。看一下输出结果,你会发现测试表中的最后一个值被测试了三次:

=== CONT  TestParallelTable/a
50 60
=== CONT  TestParallelTable/c
50 60
=== CONT  TestParallelTable/b
50 60

这个问题很常见,以至于 Go 1.20 版本中添加了 go vet 检查此问题的功能。当你在这段代码上运行 go vet 时,会得到消息 loop variable d captured by func literal,其中每一行都捕获了变量 d

在 Go 1.22 或更高版本中,for 循环的变化解决了这个问题。如果你不能使用 Go 1.22,你可以通过在调用 t.Run 之前在 for 循环内部屏蔽 d 来避免这个 bug:

    for _, d := range data {
        d := d // THIS IS THE LINE THAT SHADOWS d!
        t.Run(d.name, func(t *testing.T) {
            t.Parallel()
            fmt.Println(d.input, d.output)
            out := toTest(d.input)
            if out != d.output {
                t.Error("didn't match", out, d.output)
            }
        })
    }

现在你有了运行大量测试的方法,你将了解代码覆盖率以查找你的测试是否完整。

检查你的代码覆盖率

代码覆盖率是一个非常有用的工具,可以帮助你了解是否遗漏了任何明显的情况。但是,达到 100% 的代码覆盖率并不保证你的代码对于某些输入没有 bug。首先,你会看到 go test 如何显示代码覆盖率,然后再看看仅依赖代码覆盖率的局限性。

go test 命令中添加 -cover 标志可以计算覆盖率信息,并在测试输出中包含摘要。如果你包含第二个标志 -coverprofile,你可以将覆盖率信息保存到一个文件中。让我们回到 第十五章代码库sample_code/table 目录中,收集代码覆盖率信息:

$ go test -v -cover -coverprofile=c.out

如果你用代码覆盖来运行你的表格测试,测试输出现在会包括一行指示测试代码覆盖率的信息,例如 87.5%。这是个好消息,但更有用的是你可以看到你漏掉了什么。Go 自带的 cover 工具会生成一个包含这些信息的源代码的 HTML 表示:

$ go tool cover -html=c.out

运行后,你的网页浏览器应该会打开,并显示一个看起来像是 Figure 15-1 的页面。

每个被测试的文件都显示在左上角的组合框中。源代码有三种颜色。灰色用于不可测试的代码行,绿色用于已被测试覆盖的代码,红色用于未被测试的代码。(依赖颜色对于打印版的读者和红绿色盲是不幸的。如果你无法看到颜色,浅灰色表示被覆盖的行。)通过查看这些内容,你会发现你没有编写测试来覆盖默认情况,即当函数传递给它一个错误的操作符时。在你的测试案例中添加这种情况:

{"bad_op", 2, 2, "?", 0, `unknown operator ?`},

当你重新运行 go test -v -cover -coverprofile=c.outgo tool cover -html=c.out 时,你会在 图 15-2 中看到最后一行被覆盖,并且你拥有 100%的测试代码覆盖率。

初始代码覆盖率

图 15-1. 初始代码覆盖率

最终代码覆盖率

图 15-2. 最终代码覆盖率

代码覆盖率是一件好事,但这并不足够。实际上,你的代码中有一个 bug,即使你已经达到了 100%的覆盖率。你注意到了吗?如果没有,添加另一个测试案例并重新运行你的测试:

{"another_mult", 2, 3, "*", 6, ""},

你应该看到错误:

table_test.go:57: Expected 6, got 5

你的乘法案例有一个拼写错误。它将数字相加而不是相乘。(小心复制粘贴编码的危险!)修复代码,重新运行 go test -v -cover -coverprofile=c.outgo tool cover -html=c.out,你会看到测试再次通过。

警告

代码覆盖率是必要的,但并不充分。你可以拥有 100%的代码覆盖率,但代码中仍然可能存在 bug!

模糊测试

每个开发者最终都会学到的最重要的一课是所有数据都是可疑的。无论数据格式有多么精确,你最终都将不得不处理不符合预期的输入。这并不仅仅是因为恶意原因。数据可能在传输、存储甚至内存中损坏。处理数据的程序可能存在 bug,而数据格式的规范总是会有一些边缘情况,不同的开发者可能会以不同的方式解释。

即使开发者编写了良好的单元测试,也不可能考虑到所有情况。正如你所见,即使单元测试代码达到 100%的覆盖率,也不能保证代码没有错误。你需要用可能会导致程序以意想不到的方式崩溃的生成数据来补充单元测试。这就是模糊测试的作用。

Fuzzing 是一种生成随机数据并将其提交给代码以查看其是否正确处理意外输入的技术。开发者可以提供一个种子语料库或一组已知的良好数据,模糊器将以此为基础生成有问题的输入。让我们看看如何利用 Go 的测试工具中的模糊支持来发现额外的测试用例。

假设您正在编写一个处理数据文件的程序。您可以在Github 上找到此示例的代码。您正在发送一个字符串列表,但希望有效地分配内存,因此文件中的字符串数量作为第一行发送,而剩余的行是文本行。以下是处理此数据的示例函数:

func ParseData(r io.Reader) ([]string, error) {
    s := bufio.NewScanner(r)
    if !s.Scan() {
        return nil, errors.New("empty")
    }
    countStr := s.Text()
    count, err := strconv.Atoi(countStr)
    if err != nil {
        return nil, err
    }
    out := make([]string, 0, count)
    for i := 0; i < count; i++ {
        hasLine := s.Scan()
        if !hasLine {
            return nil, errors.New("too few lines")
        }
        line := s.Text()
        out = append(out, line)
    }
    return out, nil
}

使用bufio.Scannerio.Reader逐行读取数据。如果没有数据可读,则返回错误。然后读取第一行,并尝试将其转换为名为count的整数。如果无法转换,则返回错误。接下来,为字符串切片分配内存,并从扫描器中读取count行。如果行数不足,则返回错误。如果一切顺利,返回读取的行数。

已编写单元测试以验证代码:

func TestParseData(t *testing.T) {
    data := []struct {
        name   string
        in     []byte
        out    []string
        errMsg string
    }{
        {
            name:   "simple",
            in:     []byte("3\nhello\ngoodbye\ngreetings\n"),
            out:    []string{"hello", "goodbye", "greetings"},
            errMsg: "",
        },
        {
            name:   "empty_error",
            in:     []byte(""),
            out:    nil,
            errMsg: "empty",
        },
        {
            name:   "zero",
            in:     []byte("0\n"),
            out:    []string{},
            errMsg: "",
        },
        {
            name:   "number_error",
            in:     []byte("asdf\nhello\ngoodbye\ngreetings\n"),
            out:    nil,
            errMsg: `strconv.Atoi: parsing "asdf": invalid syntax`,
        },
        {
            name:   "line_count_error",
            in:     []byte("4\nhello\ngoodbye\ngreetings\n"),
            out:    nil,
            errMsg: "too few lines",
        },
    }
    for _, d := range data {
        t.Run(d.name, func(t *testing.T) {
            r := bytes.NewReader(d.in)
            out, err := ParseData(r)
            var errMsg string
            if err != nil {
                errMsg = err.Error()
            }
            if diff := cmp.Diff(d.out, out); diff != "" {
                t.Error(diff)
            }
            if diff := cmp.Diff(d.errMsg, errMsg); diff != "" {
                t.Error(diff)
            }
        })
    }
}

单元测试已为ParseData实现了 100%的代码行覆盖率,处理了所有错误情况。您可能认为代码已准备好投入生产,但让我们看看模糊测试是否可以帮助您发现未考虑的错误。

注意

注意,模糊测试会消耗大量资源。模糊测试可以分配(或尝试分配)许多吉比字节的内存,并且可能向本地磁盘写入数吉比字节的数据。如果在进行模糊测试的同时在同一台机器上运行其他程序,如果速度变慢也不要感到惊讶。

首先编写一个模糊测试:

func FuzzParseData(f *testing.F) {
    testcases := [][]byte{
        []byte("3\nhello\ngoodbye\ngreetings\n"),
        []byte("0\n"),
    }
    for _, tc := range testcases {
        f.Add(tc)
    }
    f.Fuzz(func(t *testing.T, in []byte) {
        r := bytes.NewReader(in)
        out, err := ParseData(r)
        if err != nil {
            t.Skip("handled error")
        }
        roundTrip := ToData(out)
        rtr := bytes.NewReader(roundTrip)
        out2, err := ParseData(rtr)
        if diff := cmp.Diff(out, out2); diff != "" {
            t.Error(diff)
        }
    })
}

模糊测试看起来与标准单元测试类似。函数名称以Fuzz开头,唯一参数类型为*testing.F,并且没有返回值。

然后,设置种子语料库,它由一个或多个样本数据集组成。数据可以成功运行、出错,甚至崩溃。重要的是,您知道在提供这些数据时程序的行为方式,并且您的模糊测试已经考虑到了这种行为。这些样本由模糊器变异以生成错误输入。本示例每个条目仅使用单个数据字段(字节切片),但可以根据需要有多个字段。目前,语料库条目的字段仅限于某些类型:

  • 任何整数类型(包括无符号类型,runebyte

  • 任何浮点类型

  • bool

  • string

  • []byte

每个语料库条目都会传递给*testing.F实例的Add方法。在示例中,每个条目都有一个字节切片:

f.Add(tc)

如果被模糊化的函数需要一个int和一个string,则调用Add看起来像这样:

f.Add(1, "some text")

将无效类型的值传递给Add是运行时错误。

接下来,你在你的*testing.F实例上调用Fuzz方法。在编写标准单元测试中的表测试时,这看起来有点像对Run的调用。Fuzz接受一个参数,一个函数,其第一个参数类型为*testing.T,其余参数的类型、顺序和数量与传递给Add的值完全匹配。这还指定了在模糊测试期间由模糊引擎生成的数据类型。Go 编译器无法强制执行此约束,因此如果不遵循约定,这将是运行时错误。

最后,让我们看看模糊目标的内容。记住,模糊测试用于找到未正确处理坏输入的情况。由于输入是随机生成的,您不能编写对输出内容有了解的测试。相反,您必须使用对所有输入都为真的测试条件。对于ParseData,您可以检查两件事:

  • 当错误输入时,代码是否返回错误或会发生恐慌?

  • 如果您将字符串切片转换回字节切片并重新解析,您会得到相同的结果吗?

让我们看看运行模糊测试时会发生什么:

$ go test -fuzz=FuzzParseData
fuzz: elapsed: 0s, gathering baseline coverage: 0/243 completed
fuzz: elapsed: 0s, gathering baseline coverage: 243/243 completed,
    now fuzzing with 8 workers
fuzz: minimizing 289-byte failing input file
fuzz: elapsed: 3s, minimizing
fuzz: elapsed: 6s, minimizing
fuzz: elapsed: 9s, minimizing
fuzz: elapsed: 10s, minimizing
--- FAIL: FuzzParseData (10.48s)
    fuzzing process hung or terminated unexpectedly while minimizing: EOF
    Failing input written to testdata/fuzz/FuzzParseData/
        fedbaf01dc50bf41b40d7449657cdc9af9868b1be0421c98b2910071de9be3df
    To re-run:
    go test -run=FuzzParseData/
        fedbaf01dc50bf41b40d7449657cdc9af9868b1be0421c98b2910071de9be3df
FAIL
exit status 1
FAIL    file_parser     10.594s

如果您没有指定-fuzz标志,您的模糊测试将被视为单元测试,并将针对种子语料库运行。每次只能对一个模糊测试进行模糊处理。

注意

如果您想获得完整体验,请删除 testdata/fuzz/FuzzParseData 目录的内容。这将导致模糊器生成新的种子语料库条目。由于模糊器生成随机输入,您的样本可能与显示的不同。不过,不同的条目可能会产生类似的错误,尽管可能不是按相同顺序。

模糊测试运行几秒钟后失败。在这种情况下,go命令报告说它已崩溃。您不希望程序崩溃,所以让我们看看生成的输入。每次测试用例失败时,模糊器都会将其写入 testdata/fuzz/TESTNAME 子目录,该子目录与失败的测试位于同一软件包中,添加一个新条目到种子语料库。现在文件中的新种子语料库条目成为新的单元测试,这是由模糊器自动生成的。每当运行go test运行FuzzParseData函数时,它作为回归测试,一旦修复错误,就会执行。

这是文件的内容:

go test fuzz v1
[]byte("300000000000")

第一行是一个标题,指示这是模糊测试的测试数据。随后的行包含导致失败的数据。

失败消息告诉您在重新运行测试时如何隔离此失败案例:

$ go test -run=FuzzParseData/
    fedbaf01dc50bf41b40d7449657cdc9af9868b1be0421c98b2910071de9be3df
signal: killed
FAIL    file_parser     15.046s

问题在于您试图分配一个能够容纳 300,000,000,000 个字符串的切片。这比我的计算机(可能也比您的)的 RAM 要多得多。您需要限制预期文本元素的数量。通过在解析预期行数后向ParseData添加以下代码,将最大行数设置为 1,000:

    if count > 1000 {
        return nil, errors.New("too many")
    }

再次运行模糊器,查看是否发现更多错误:

$ go test -fuzz=FuzzParseData
fuzz: elapsed: 0s, gathering baseline coverage: 0/245 completed
fuzz: elapsed: 0s, gathering baseline coverage: 245/245 completed,
    now fuzzing with 8 workers
fuzz: minimizing 29-byte failing input file
fuzz: elapsed: 2s, minimizing
--- FAIL: FuzzParseData (2.20s)
    --- FAIL: FuzzParseData (0.00s)
        testing.go:1356: panic: runtime error: makeslice: cap out of range
            goroutine 23027 [running]:
            runtime/debug.Stack()
                /usr/local/go/src/runtime/debug/stack.go:24 +0x104
            testing.tRunner.func1()
                /usr/local/go/src/testing/testing.go:1356 +0x258
            panic({0x1003f9920, 0x10042a260})
                /usr/local/go/src/runtime/panic.go:884 +0x204
            file_parser.ParseData({0x10042a7c8, 0x14006c39bc0})
                file_parser/file_parser.go:24 +0x254
[...]
    Failing input written to testdata/fuzz/FuzzParseData/
        03f81b404ad91d092a482ad1ccb4a457800599ab826ec8dae47b49c01c38f7b1
    To re-run:
    go test -run=FuzzParseData/
        03f81b404ad91d092a482ad1ccb4a457800599ab826ec8dae47b49c01c38f7b1
FAIL
exit status 1
FAIL    file_parser     2.434s

这次您得到一个产生恐慌的模糊结果。查看由 go fuzz 生成的文件,您会看到这个:

go test fuzz v1
[]byte("-1")

显示产生恐慌的行在这里:

    out := make([]string, 0, count)

您正在尝试创建一个具有负容量的切片,这会导致恐慌。向您的代码添加另一个条件以查找负数:

    if count < 0 {
        return nil, errors.New("no negative numbers")
    }

再次运行您的模糊器,它生成了另一个错误:

$ go test -fuzz=FuzzParseData
fuzz: elapsed: 0s, gathering baseline coverage: 0/246 completed
fuzz: elapsed: 0s, gathering baseline coverage: 246/246 completed,
    now fuzzing with 8 workers
fuzz: elapsed: 3s, execs: 288734 (96241/sec), new interesting: 0 (total: 246)
fuzz: elapsed: 6s, execs: 418803 (43354/sec), new interesting: 0 (total: 246)
fuzz: minimizing 34-byte failing input file
fuzz: elapsed: 7s, minimizing
--- FAIL: FuzzParseData (7.43s)
    --- FAIL: FuzzParseData (0.00s)
        file_parser_test.go:89:   []string{
            -   "\r",
            +   "",
              }

    Failing input written to testdata/fuzz/FuzzParseData/
        b605c41104bf41a21309a13e90cfc6f30ecf133a2382759f2abc34d41b45ae79
    To re-run:
    go test -run=FuzzParseData/
        b605c41104bf41a21309a13e90cfc6f30ecf133a2382759f2abc34d41b45ae79
FAIL
exit status 1
FAIL    file_parser     7.558s

查看它创建的文件,您正在生成仅包含 \r(回车)字符的空白行。空白行不是您在输入中期望的内容,因此向从 Scanner 中读取文本行的循环中添加一些代码。您将检查一行是否只包含空白字符。如果是,则返回错误:

        line = strings.TrimSpace(line)
        if len(line) == 0 {
            return nil, errors.New("blank line")
        }

再次运行您的模糊器:

$ go test -fuzz=FuzzParseData
fuzz: elapsed: 0s, gathering baseline coverage: 0/247 completed
fuzz: elapsed: 0s, gathering baseline coverage: 247/247 completed,
    now fuzzing with 8 workers
fuzz: elapsed: 3s, execs: 391018 (130318/sec), new interesting: 2 (total: 249)
fuzz: elapsed: 6s, execs: 556939 (55303/sec), new interesting: 2 (total: 249)
fuzz: elapsed: 9s, execs: 622126 (21734/sec), new interesting: 2 (total: 249)
[...]
fuzz: elapsed: 2m0s, execs: 2829569 (0/sec), new interesting: 16 (total: 263)
fuzz: elapsed: 2m3s, execs: 2829569 (0/sec), new interesting: 16 (total: 263)
^Cfuzz: elapsed: 2m4s, execs: 2829569 (0/sec), new interesting: 16 (total: 263)
PASS
ok      file_parser     123.662s

几分钟后,没有发现更多错误,因此按下 Ctrl-C 结束模糊测试。

仅因为模糊器没有找到其他问题并不意味着代码现在没有错误。但是,模糊测试使您能够自动发现原始代码中的一些重大疏忽。编写模糊测试需要练习,因为它们需要比编写单元测试稍微不同的思维方式。一旦掌握它们,它们将成为验证代码如何处理意外用户输入的重要工具。

使用基准测试

确定代码运行速度快慢是非常困难的。与其自己试图弄清楚,不如使用内置在 Go 测试框架中的基准测试支持。让我们通过第十五章的代码库sample_code/bench 目录中的一个函数来探讨它:

func FileLen(f string, bufsize int) (int, error) {
    file, err := os.Open(f)
    if err != nil {
        return 0, err
    }
    defer file.Close()
    count := 0
    for {
        buf := make([]byte, bufsize)
        num, err := file.Read(buf)
        count += num
        if err != nil {
            break
        }
    }
    return count, nil
}

此函数计算文件中的字符数。它接受两个参数,文件名和用于读取文件的缓冲区大小(稍后您将看到第二个参数的原因)。

在查看其速度有多快之前,您应该测试您的库以确保它工作正常(确实如此)。这里是一个简单的测试:

func TestFileLen(t *testing.T) {
    result, err := FileLen("testdata/data.txt", 1)
    if err != nil {
        t.Fatal(err)
    }
    if result != 65204 {
        t.Error("Expected 65204, got", result)
    }
}

现在您可以看到运行文件长度函数需要多长时间。您的目标是找出应该用来从文件中读取的缓冲区大小。

注意

在您花时间深入优化之前,请确保您需要优化。如果您的程序已经足够快以满足响应要求并且使用的内存量是可接受的,则更好的选择是添加功能和修复错误。您的业务需求决定了“足够快”和“可接受的内存量”是什么意思。

在 Go 中,基准测试 是您的测试文件中以 Benchmark 开头并接受 *testing.B 类型的单个参数的函数。此类型包含 *testing.T 的所有功能,以及用于基准测试的额外支持。让我们先看一个使用 1 字节缓冲区的基准测试:

var blackhole int

func BenchmarkFileLen1(b *testing.B) {
    for i := 0; i < b.N; i++ {
        result, err := FileLen("testdata/data.txt", 1)
        if err != nil {
            b.Fatal(err)
        }
        blackhole = result
    }
}

blackhole 包级变量很有趣。你将 FileLen 的结果写入这个包级变量,以确保编译器不会太过聪明而决定优化掉对 FileLen 的调用,从而破坏你的基准测试。

每个 Go 基准测试必须有一个循环,从 0 迭代到 b.*N*。测试框架会反复调用你的基准函数,并增加 *N* 的值,直到确保时间结果准确为止。稍后的输出中你会看到这一点。

通过将 -bench 标志传递给 go test 来运行基准测试。此标志期望一个正则表达式来描述要运行的基准测试的名称。使用 -bench=. 来运行所有基准测试。第二个标志 -benchmem 在基准测试输出中包括内存分配信息。在运行基准测试之前会运行所有的测试,因此只有在测试通过时才能对代码进行基准测试。

以下是在我的计算机上进行基准测试的输出:

BenchmarkFileLen1-12  25  47201025 ns/op  65342 B/op  65208 allocs/op

运行带有内存分配信息的基准测试会生成包含五列的输出。以下是每一列的含义:

BenchmarkFileLen1-12

基准测试的名称,一个连字符,以及基准测试的 GOMAXPROCS 值。

25

测试稳定结果所需运行的次数

47201025 纳秒/操作

这个基准测试运行一次的时间,以纳秒为单位(每秒有 1,000,000,000 纳秒)。

65342 B/操作

在单次基准测试中分配的字节数。

65208 分配/操作

在单次基准测试中,从堆中分配字节的次数。这将始终小于或等于分配的字节的数量。

现在你已经有了 1 字节缓冲区的结果,让我们看看当你使用不同大小的缓冲区时的结果:

func BenchmarkFileLen(b *testing.B) {
    for _, v := range []int{1, 10, 100, 1000, 10000, 100000} {
        b.Run(fmt.Sprintf("FileLen-%d", v), func(b *testing.B) {
            for i := 0; i < b.N; i++ {
                result, err := FileLen("testdata/data.txt", v)
                if err != nil {
                    b.Fatal(err)
                }
                blackhole = result
            }
        })
    }
}

就像你使用 t.Run 启动表格测试一样,你使用 b.Run 来启动基准测试,这些测试仅基于输入而变化。以下是在我的计算机上进行此基准测试的结果:

BenchmarkFileLen/FileLen-1-12          25  47828842 ns/op   65342 B/op  65208 allocs/op
BenchmarkFileLen/FileLen-10-12        230   5136839 ns/op  104488 B/op   6525 allocs/op
BenchmarkFileLen/FileLen-100-12      2246    509619 ns/op   73384 B/op    657 allocs/op
BenchmarkFileLen/FileLen-1000-12    16491     71281 ns/op   68744 B/op     70 allocs/op
BenchmarkFileLen/FileLen-10000-12   42468     26600 ns/op   82056 B/op     11 allocs/op
BenchmarkFileLen/FileLen-100000-12  36700     30473 ns/op  213128 B/op      5 allocs/op

这些结果并不令人意外;随着缓冲区大小的增加,分配的次数减少,代码运行速度更快,直到缓冲区大于文件大小。当缓冲区大于文件大小时,额外的分配会减慢输出速度。如果你预期的文件大小大致相同,那么 10,000 字节的缓冲区效果最好。

但是你可以进行改进以进一步提高这些数字。你每次从文件获取下一组字节时重新分配缓冲区是不必要的。如果你在循环之前分配字节片段并重新运行基准测试,你会看到改善:

BenchmarkFileLen/FileLen-1-12          25  46167597 ns/op     137 B/op  4 allocs/op
BenchmarkFileLen/FileLen-10-12        261   4592019 ns/op     152 B/op  4 allocs/op
BenchmarkFileLen/FileLen-100-12      2518    478838 ns/op     248 B/op  4 allocs/op
BenchmarkFileLen/FileLen-1000-12    20059     60150 ns/op    1160 B/op  4 allocs/op
BenchmarkFileLen/FileLen-10000-12   62992     19000 ns/op   10376 B/op  4 allocs/op
BenchmarkFileLen/FileLen-100000-12  51928     21275 ns/op  106632 B/op  4 allocs/op

现在分配的数量是一致且较小的,每个缓冲区大小只有四次分配。有趣的是,你现在可以做出权衡。如果内存紧张,你可以使用较小的缓冲区大小,并在性能上牺牲内存。

在 Go 中使用存根

到目前为止,您已经为不依赖于其他代码的函数编写了测试。这并不典型,因为大多数代码都充满了依赖项。正如您在第七章中看到的那样,Go 允许您以两种方式抽象函数调用:定义函数类型和定义接口。这些抽象帮助您不仅编写模块化的生产代码,还能编写单元测试。

提示

当你的代码依赖于抽象时,编写单元测试会更容易!

让我们看一个例子,在 sample_code/solver 目录中的 第十五章存储库 中定义了一个名为Processor的类型:

type Processor struct {
    Solver MathSolver
}

它有一个类型为MathSolver的字段:

type MathSolver interface {
    Resolve(ctx context.Context, expression string) (float64, error)
}

您将稍后实现和测试MathSolver

Processor还有一个从io.Reader读取表达式并返回计算值的方法:

func (p Processor) ProcessExpression(ctx context.Context, r io.Reader)
                                    (float64, error) {
    curExpression, err := readToNewLine(r)
    if err != nil {
        return 0, err
    }
    if len(curExpression) == 0 {
        return 0, errors.New("no expression to read")
    }
    answer, err := p.Solver.Resolve(ctx, curExpression)
    return answer, err
}

让我们编写代码来测试ProcessExpression。首先,您需要一个简单的Resolve方法的实现来编写您的测试:

type MathSolverStub struct{}

func (ms MathSolverStub) Resolve(ctx context.Context, expr string)
                                (float64, error) {
    switch expr {
    case "2 + 2 * 10":
        return 22, nil
    case "( 2 + 2 ) * 10":
        return 40, nil
    case "( 2 + 2 * 10":
        return 0, errors.New("invalid expression: ( 2 + 2 * 10")
    }
    return 0, nil
}

接下来,你编写一个单元测试,使用这个存根(生产代码应该测试错误消息,但为了简洁起见,你会省略这些):

func TestProcessorProcessExpression(t *testing.T) {
    p := Processor{MathSolverStub{}}
    in := strings.NewReader(`2 + 2 * 10
( 2 + 2 ) * 10
( 2 + 2 * 10`)
    data := []float64{22, 40, 0}
    hasErr := []bool{false, false, true}
    for i, d := range data {
        result, err := p.ProcessExpression(context.Background(), in)
        if err != nil && !hasErr[i] {
            t.Error(err)
        }
        if result != d {
            t.Errorf("Expected result %f, got %f", d, result)
        }
    }
}

然后你可以运行你的测试,看看一切是否正常工作。

虽然大多数 Go 接口仅指定一个或两个方法,但并非总是如此。有时您会发现自己有一个具有许多方法的接口。让我们看一下 sample_code/stub 目录中的 第十五章存储库 中的代码。假设您有一个看起来像这样的接口:

type Entities interface {
    GetUser(id string) (User, error)
    GetPets(userID string) ([]Pet, error)
    GetChildren(userID string) ([]Person, error)
    GetFriends(userID string) ([]Person, error)
    SaveUser(user User) error
}

有两种模式可以测试依赖于大型接口的代码。第一种是将接口嵌入到结构体中。在结构体中嵌入接口会自动在结构体上定义接口的所有方法。它不提供这些方法的任何实现,因此您需要为当前测试关心的方法实现这些方法。假设Logic是一个具有Entities类型字段的结构体:

type Logic struct {
    Entities Entities
}

假设你想测试这种方法:

func (l Logic) GetPetNames(userId string) ([]string, error) {
    pets, err := l.Entities.GetPets(userId)
    if err != nil {
        return nil, err
    }
    out := make([]string, len(pets))
    for _, p := range pets {
        out = append(out, p.Name)
    }
    return out, nil
}

这种方法仅使用了Entities上声明的一个方法:GetPets。而不是创建一个实现Entities上的每一个方法来测试GetPets,你可以编写一个仅实现你需要测试这个方法的存根结构体:

type GetPetNamesStub struct {
    Entities
}

func (ps GetPetNamesStub) GetPets(userID string) ([]Pet, error) {
    switch userID {
    case "1":
        return []Pet{{Name: "Bubbles"}}, nil
    case "2":
        return []Pet{{Name: "Stampy"}, {Name: "Snowball II"}}, nil
    default:
        return nil, fmt.Errorf("invalid id: %s", userID)
    }
}

然后,您编写单元测试,将您的存根注入到Logic中:

func TestLogicGetPetNames(t *testing.T) {
    data := []struct {
        name     string
        userID   string
        petNames []string
    }{
        {"case1", "1", []string{"Bubbles"}},
        {"case2", "2", []string{"Stampy", "Snowball II"}},
        {"case3", "3", nil},
    }
    l := Logic{GetPetNamesStub{}}
    for _, d := range data {
        t.Run(d.name, func(t *testing.T) {
            petNames, err := l.GetPetNames(d.userID)
            if err != nil {
                t.Error(err)
            }
            if diff := cmp.Diff(d.petNames, petNames); diff != "" {
                t.Error(diff)
            }
        })
    }
}

(顺便说一句,GetPetNames方法有一个 bug。你看到了吗?即使是简单的方法有时也会有 bug。)

警告

如果您在存根结构体中嵌入一个接口,请确保为每个在测试期间调用的方法提供实现!如果调用未实现的方法,您的测试将会崩溃。

如果您只需要为接口中的一个或两个方法实现一个测试,这种技术就很有效。当需要在不同的测试中使用不同的输入和输出调用相同的方法时,这种方法就有缺陷。在这种情况下,您需要在同一实现中包括每个测试的每个可能结果,或者为每个测试重新实现结构。这很快就变得难以理解和维护。更好的解决方案是创建一个存根结构体,将方法调用代理到函数字段上。对于Entities定义的每个方法,您在存根结构体上定义一个具有匹配签名的函数字段:

type EntitiesStub struct {
    getUser     func(id string) (User, error)
    getPets     func(userID string) ([]Pet, error)
    getChildren func(userID string) ([]Person, error)
    getFriends  func(userID string) ([]Person, error)
    saveUser    func(user User) error
}

然后,通过定义方法使EntitiesStub满足Entities接口。在每个方法中,您调用相关的函数字段。例如:

func (es EntitiesStub) GetUser(id string) (User, error) {
    return es.getUser(id)
}

func (es EntitiesStub) GetPets(userID string) ([]Pet, error) {
    return es.getPets(userID)
}

一旦创建了这个存根,您可以通过数据结构中的字段为表测试的不同测试案例提供不同的方法实现:

func TestLogicGetPetNames(t *testing.T) {
    data := []struct {
        name     string
        getPets  func(userID string) ([]Pet, error)
        userID   string
        petNames []string
        errMsg   string
    }{
        {"case1", func(userID string) ([]Pet, error) {
            return []Pet{{Name: "Bubbles"}}, nil
        }, "1", []string{"Bubbles"}, ""},
        {"case2", func(userID string) ([]Pet, error) {
            return nil, errors.New("invalid id: 3")
        }, "3", nil, "invalid id: 3"},
    }
    l := Logic{}
    for _, d := range data {
        t.Run(d.name, func(t *testing.T) {
            l.Entities = EntitiesStub{getPets: d.getPets}
            petNames, err := l.GetPetNames(d.userID)
            if diff := cmp.Diff(d.petNames, petNames); diff != "" {
                t.Error(diff)
            }
            var errMsg string
            if err != nil {
                errMsg = err.Error()
            }
            if errMsg != d.errMsg {
                t.Errorf("Expected error `%s`, got `%s`", d.errMsg, errMsg)
            }
        })
    }
}

data的匿名结构体中添加一个函数类型的字段。在每个测试案例中,您指定一个返回GetPets应返回的数据的函数。当以这种方式编写您的测试存根时,很明显每个测试案例应该返回什么。随着每个测试的运行,您会实例化一个新的EntitiesStub,并将测试数据中的getPets函数字段分配给EntitiesStub中的getPets函数字段。

使用 httptest

要为调用 HTTP 服务的函数编写测试可能会很困难。传统上,这变成了一个集成测试,需要您启动一个测试实例来调用函数的服务。Go 标准库包含net/http/httptest包,以便更容易地存根 HTTP 服务。让我们回到第十五章代码库中的sample_code/solver目录,并提供一个调用 HTTP 服务来评估表达式的MathSolver的实现:

type RemoteSolver struct {
    MathServerURL string
    Client        *http.Client
}

func (rs RemoteSolver) Resolve(ctx context.Context, expression string)
                              (float64, error) {
    req, err := http.NewRequestWithContext(ctx, http.MethodGet,
        rs.MathServerURL+"?expression="+url.QueryEscape(expression),
        nil)
    if err != nil {
        return 0, err
    }
    resp, err := rs.Client.Do(req)
    if err != nil {
        return 0, err
    }
    defer resp.Body.Close()
    contents, err := io.ReadAll(resp.Body)
    if err != nil {
        return 0, err
    }
    if resp.StatusCode != http.StatusOK {
        return 0, errors.New(string(contents))
    }
    result, err := strconv.ParseFloat(string(contents), 64)
    if err != nil {
        return 0, err
    }
    return result, nil
}

现在让我们看看如何使用httptest库来测试这段代码,而无需启动服务器。代码位于第十五章代码库sample_code/solver/remote_solver_test.goTestRemoteSolver_Resolve函数中,但这里是重点。首先,您希望确保传递到函数的数据到达服务器。因此,在您的测试函数中,您定义了一个名为info的类型来保存输入和输出,以及一个名为io的变量,它被分配了当前的输入和输出:

type info struct {
    expression string
    code       int
    body       string
}
var io info

接下来,您设置假的远程服务器并使用它来配置RemoteSolver的实例:

server := httptest.NewServer(
    http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
        expression := req.URL.Query().Get("expression")
        if expression != io.expression {
            rw.WriteHeader(http.StatusBadRequest)
            fmt.Fprintf(rw, "expected expression '%s', got '%s'",
                io.expression, expression)
            return
        }
        rw.WriteHeader(io.code)
        rw.Write([]byte(io.body))
    }))
defer server.Close()
rs := RemoteSolver{
    MathServerURL: server.URL,
    Client:        server.Client(),
}

httptest.NewServer函数会在一个随机未使用的端口上创建并启动一个 HTTP 服务器。你需要提供一个http.Handler实现来处理请求。由于这是一个服务器,当测试完成时必须将其关闭。server实例的 URL 已经在server实例的URL字段中指定,并且为与测试服务器通信而预先配置了http.Client。你需要将这些传递给RemoteSolver

函数的其余部分和你见过的其他表格测试一样:

data := []struct {
    name   string
    io     info
    result float64
}{
    {"case1", info{"2 + 2 * 10", http.StatusOK, "22"}, 22},
    // remaining cases
}
for _, d := range data {
    t.Run(d.name, func(t *testing.T) {
        io = d.io
        result, err := rs.Resolve(context.Background(), d.io.expression)
        if result != d.result {
            t.Errorf("io `%f`, got `%f`", d.result, result)
        }
        var errMsg string
        if err != nil {
            errMsg = err.Error()
        }
        if errMsg != d.errMsg {
            t.Errorf("io error `%s`, got `%s`", d.errMsg, errMsg)
        }
    })
}

需要注意的是变量io被两个闭包捕获:一个用于存根服务器,另一个用于运行每个测试。你在一个闭包中向其写入,而在另一个闭包中读取。在生产代码中这样做是不好的,但在单个函数中的测试代码中却很有效。

使用集成测试和构建标签

即使httptest提供了一种避免针对外部服务进行测试的方法,你仍然应该编写集成测试,即连接到其他服务的自动化测试,以验证你对服务 API 的理解是否正确。挑战在于如何分组自动化测试;你希望只在支持环境存在时运行集成测试。此外,集成测试通常比单元测试慢,因此通常运行频率较低。

在“使用构建标签”中,我介绍了构建标签,这些标签由 Go 编译器用于控制何时编译文件。虽然它们主要用于允许开发人员编写仅适用于特定操作系统、CPU 或 Go 版本的代码,但你也可以利用指定自定义构建标签的能力来控制何时编译和运行集成测试。

让我们试试数学求解项目。使用Docker下载服务器实现:docker pull jonbodner/math-server,然后在本地端口 8080 上运行服务器:docker run -p 8080:8080 jonbodner/math-server

注意

如果你没有安装 Docker 或者想要自己构建代码,你可以在GitHub上找到它。

需要编写一个集成测试来确保你的Resolve方法能够与数学服务器正常通信。在第十五章代码库sample_code/solver/remote_solver_integration_test.go文件中,TestRemoteSolver_ResolveIntegration函数已经包含了一个完整的测试。这个测试看起来和你写过的其他表格测试一样。有趣的是文件的第一行,通过一个空行与包声明分隔开来:

//go:build integration

要将集成测试与你编写的其他测试一起运行,请使用以下命令:

$ go test -tags integration -v ./...

您应该知道,Go 社区中有些人更喜欢使用环境变量而不是构建标签来创建集成测试组。 Peter Bourgon 在博客文章中详细描述了这个概念,标题不含糊地为“不要使用构建标签进行集成测试”。 他认为,很难找出设置哪些构建标签来运行集成测试。 在每个集成测试中显式检查环境变量,并在t.Skip方法调用中添加详细消息,清楚地表明测试未运行以及如何运行它们,这是语法冗长和可发现性之间的权衡。 请随意使用任一技术。

使用数据竞争检测器查找并发问题

即使 Go 内置支持并发,错误仍会发生。 容易无意中从两个不同的 goroutine 引用变量而不获取锁。 计算机科学术语称为数据竞争。 为了帮助找到这些类型的错误,Go 包括一个竞争检查器。 它不能保证在您的代码中找到每一个数据竞争,但如果找到一个,您应该在找到的内容周围放置适当的锁。

第十五章存储库的文件sample_code/race/race.go中查看一个简单的示例:

func getCounter() int {
    var counter int
    var wg sync.WaitGroup
    wg.Add(5)
    for i := 0; i < 5; i++ {
        go func() {
            for i := 0; i < 1000; i++ {
                counter++
            }
            wg.Done()
        }()
    }
    wg.Wait()
    return counter
}

此代码启动五个 goroutine,每个 goroutine 更新共享的counter变量 1000 次,然后返回结果。 您希望它为 5000,因此让我们在sample_code/race/race_test.go中使用单元测试来验证:

func TestGetCounter(t *testing.T) {
    counter := getCounter()
    if counter != 5000 {
        t.Error("unexpected counter:", counter)
    }
}

如果您多次运行go test,您会看到有时它会通过,但大多数情况下会出现类似以下错误消息:

unexpected counter: 3673

问题在于代码中存在数据竞争。 在这么简单的程序中,原因是显而易见的:多个 goroutine 同时尝试更新counter,其中一些更新会丢失。 在更复杂的程序中,这些类型的竞争更难以看到。 让我们看看数据竞争检测器的作用。 使用go test-race标志来启用它:

$ go test -race
==================
WARNING: DATA RACE
Read at 0x00c000128070 by goroutine 10:
  test_examples/race.getCounter.func1()
      test_examples/race/race.go:12 +0x45

Previous write at 0x00c000128070 by goroutine 8:
  test_examples/race.getCounter.func1()
      test_examples/race/race.go:12 +0x5b

清楚迹象表明counter++是你问题的根源。

警告

一些人尝试通过向其代码插入“睡眠”来修复竞态条件,试图间隔访问由多个 goroutine 访问的变量。 这是一个坏主意。 这样做可能会在某些情况下消除问题,但代码仍然是错误的,并且在某些情况下将失败。

在构建程序时,还可以使用-race标志。 这将创建一个包含数据竞争检测器的二进制文件,并将其报告给控制台上发现的任何竞争。 这使您可以在没有测试的代码中找到数据竞争。

如果数据竞争检测器如此有用,为什么不总是在测试和生产中启用它?启用 -race 的二进制运行速度大约比正常二进制慢 10 倍。对于运行一秒钟的测试套件来说,这不是问题,但对于运行几分钟的大型测试套件来说,10 倍的减速会降低生产力。

要获取关于数据竞争检测器的更多信息,请查阅其官方文档

练习

现在你已经学会了如何编写测试并使用 Go 自带的代码质量工具,完成以下练习,将这些知识应用到一个示例应用程序中。

  1. 下载 Simple Web App 程序。为该程序编写单元测试,并尽可能接近 100% 的代码覆盖率。如果发现任何错误,请修复它们。

  2. 使用竞争检测器来找出程序中的并发问题并修复它。

  3. parser 函数编写一个模糊测试,并修复你找到的任何问题。

总结

在本章中,你学习了如何通过使用 Go 内置的测试支持、代码覆盖率、基准测试、模糊测试和数据竞争检查来编写测试并提高代码质量。在下一章中,你将探索一些允许你打破规则的 Go 特性:unsafe 包、反射和 cgo

第十六章:这里有龙:反射、不安全操作和 Cgo

已知世界的边缘是可怕的。古代的地图会在未探索的区域填上龙和狮子的图片。在前面的章节中,我强调过 Go 是一种安全的语言,有类型变量可以清楚地表明您使用的数据类型,有垃圾回收来管理内存。即使是指针也是温顺的;您无法像 C 和 C++ 那样滥用它们。

所有这些都是真实的,对于您将编写的大多数 Go 代码,您可以确信 Go 运行时会保护您。但是有逃逸通道。有时,您的 Go 程序需要涉足定义较少的领域。在本章中,您将了解如何处理无法通过普通 Go 代码解决的情况。例如,当编译时无法确定数据类型时,您可以使用 reflect 包中的反射支持来交互甚至构造数据。当您需要利用 Go 中数据类型的内存布局时,您可以使用 unsafe 包。如果只有使用 C 编写的库才能提供的功能,您可以使用 cgo 调用 C 代码。

您可能会想为什么这些高级概念会出现在一个针对 Go 初学者的书籍中。有两个原因。首先,寻找问题解决方案的开发人员有时会发现(并复制粘贴)他们不完全理解的技术。在将它们添加到代码库之前,最好了解一些可能会导致问题的高级技术。其次,这些工具很有趣。因为它们允许您使用 Go 通常不可能的功能,所以玩起来有点激动人心,看看您能做什么。

使用反射(reflection)允许您在运行时处理类型

使用 Go 的人喜欢它的一个原因是它是一种静态类型语言。大多数情况下,在 Go 中声明变量、类型和函数都很简单。当您需要一个类型、一个变量或一个函数时,您定义它:

type Foo struct {
  A int
  B string
}

var x Foo

func DoSomething(f Foo) {
  fmt.Println(f.A, f.B)
}

您在编写程序时使用类型来表示您知道需要的数据结构。由于类型是 Go 的核心部分,编译器使用它们来确保您的代码正确。但有时,仅依赖编译时信息是有限制的。您可能需要使用在程序编写时不存在的信息,在运行时使用变量。也许您正在尝试将文件或网络请求中的数据映射到变量中。在这些情况下,您需要使用 反射。反射允许您在运行时检查类型。它还提供了在运行时检查、修改和创建变量、函数和结构体的能力。

这引出了这种功能何时需要的问题。如果您查看 Go 标准库,您可以得到一个想法。它的用途分为几个一般类别之一:

  • 从数据库读写数据。database/sql 包使用反射将记录发送到数据库并读取数据。

  • Go 内置的模板库 text/templatehtml/template 使用反射来处理传递给模板的值。

  • fmt 包大量使用反射,因为所有对 fmt.Println 和其它友元函数的调用都依赖于反射来检测提供参数的类型。

  • errors 包使用反射来实现 errors.Iserrors.As

  • sort 包使用反射来实现对任何类型的切片进行排序和评估的函数:sort.Slicesort.SliceStablesort.SliceIsSorted

  • Go 标准库中反射的最后一个主要用途是将数据编组为 JSON 和 XML,以及在各种 encoding 包中定义的其他数据格式。结构标签(我将很快讨论)通过反射访问,并且结构体中的字段也使用反射进行读取和写入。

这些示例大多数有一个共同点:它们涉及访问和格式化导入到或导出出 Go 程序的数据。你经常会看到反射在你的程序和外部世界之间的边界处使用。

当你看看可以用反射实现的技术时,请记住这种力量是有代价的。使用反射比不使用它执行相同操作要慢得多。我将在 “仅在值得时使用反射” 中进一步讨论这一点。更重要的是,使用反射的代码更加脆弱和冗长。reflect 包中的许多函数和方法在传递错误类型的数据时会导致 panic。请确保在代码中留下注释,解释你正在做什么,以便将来的审阅者(包括你自己)能够清楚地理解。

注意

Go 标准库中 reflect 包的另一个用途是测试。在 “Slices” 中,我提到了一个在 reflect 包中可以找到的函数 DeepEqual。它在 reflect 包中是因为它利用反射来完成工作。reflect.DeepEqual 函数检查两个值是否“深度相等”。这比使用 == 进行比较得到的更彻底,它被用作标准库中验证测试结果的一种方式。它还可以比较使用 == 无法比较的东西,如切片和映射。大多数情况下,你不需要 DeepEqual。自 Go 1.21 发布以来,使用 slices.Equalmaps.Equal 来检查切片和映射的相等性更快。

类型、种类和值

现在你知道什么是反射及何时需要它了,让我们看看它是如何工作的。标准库中的 reflect 包是实现 Go 中反射的类型和函数的家园。反射围绕三个核心概念构建:类型、种类和值。

类型和种类

类型就是它听起来的样子。它定义了变量的属性,它可以持有什么,以及你如何与它交互。通过反射,您可以使用代码查询类型以了解这些属性。

您可以使用reflect包中的TypeOf函数获取变量类型的反射表示:

vType := reflect.TypeOf(v)

reflect.TypeOf函数返回一个类型为reflect.Type的值,表示传递给TypeOf函数的变量的类型。reflect.Type类型定义了有关变量类型信息的方法。我不能覆盖所有方法,但这里有一些。

Name方法返回的是类型的名称,毫不奇怪,这里有一个快速示例:

var x int
xt := reflect.TypeOf(x)
fmt.Println(xt.Name())     // returns int
f := Foo{}
ft := reflect.TypeOf(f)
fmt.Println(ft.Name())     // returns Foo
xpt := reflect.TypeOf(&x)
fmt.Println(xpt.Name())    // returns an empty string

你从一个类型为int的变量x开始。你将它传递给reflect.TypeOf,然后得到一个reflect.Type实例。对于像int这样的原始类型,Name返回类型的名称,在这种情况下是字符串int对于你的int。对于结构体,返回结构体的名称。某些类型,如切片或指针,没有名称;在这些情况下,Name返回一个空字符串。

reflect.Type上的Kind方法返回一个reflect.Kind类型的值,它是一个常量,指示类型由切片、映射、指针、结构体、接口、字符串、数组、函数、整数或其他一些原始类型组成。种类和类型之间的差异可能会令人困惑。记住这个规则:如果您定义了一个名为Foo的结构体,则种类是reflect.Struct,类型是“Foo”。

种类非常重要。在使用反射时需要注意的一件事是,几乎reflect包中的所有内容都假设您知道自己在做什么。在reflect.Type和其他reflect包中定义的某些方法仅对特定种类有效。例如,在reflect.Type上有一个名为NumIn的方法。如果您的reflect.Type实例表示一个函数,则返回函数的输入参数数量。如果您的reflect.Type实例不是函数,则调用NumIn将导致程序恐慌。

警告

一般来说,如果您调用对类型的种类无意义的方法,方法调用会引发恐慌。始终记住使用反射类型的种类来知道哪些方法可以工作,哪些方法会引发恐慌。

另一个reflect.Type上的重要方法是Elem。Go 中的一些类型具有对其他类型的引用,Elem是查找包含类型的方法。例如,让我们使用reflect.TypeOf来指向一个int的指针:

var x int
xpt := reflect.TypeOf(&x)
fmt.Println(xpt.Name())        // returns an empty string
fmt.Println(xpt.Kind())        // returns reflect.Pointer
fmt.Println(xpt.Elem().Name()) // returns "int"
fmt.Println(xpt.Elem().Kind()) // returns reflect.Int

这给您一个带有空名称和reflect.Pointer种类的reflect.Type实例。当reflect.Type表示指针时,Elem返回指针指向的类型的reflect.Type。在这种情况下,Name方法返回“int”,Kind返回reflect.IntElem方法还适用于切片、映射、通道和数组。

reflect.Type上有一些用于反射结构体的方法。使用NumField方法获取结构体中字段的数量,并使用Field方法按索引获取结构体中的字段。该方法返回一个reflect.StructField,它描述了每个字段的名称、顺序、类型和结构标签。让我们看一个快速示例,您可以在Go Playground上运行它,或者在第十六章仓库sample_code/struct_tag目录中运行它:

type Foo struct {
    A int    `myTag:"value"`
    B string `myTag:"value2"`
}

var f Foo
ft := reflect.TypeOf(f)
for i := 0; i < ft.NumField(); i++ {
    curField := ft.Field(i)
    fmt.Println(curField.Name, curField.Type.Name(),
        curField.Tag.Get("myTag"))
}

您创建了一个Foo类型的实例,并使用reflect.TypeOf获取freflect.Type。接下来,使用NumField方法设置一个for循环以获取f中每个字段的索引。然后使用Field方法获取表示字段的reflect.StructField结构体,然后可以使用reflect.StructField上的字段获取有关字段的更多信息。此代码打印如下内容:

A int value
B string value2

reflect.Type中还有许多其他方法,但它们都遵循相同的模式,允许您访问描述变量类型的信息。您可以查看标准库中的reflect.Type文档以获取更多信息。

除了检查变量的类型外,您还可以使用反射来读取变量的值、设置其值或从头开始创建新值。

您使用reflect.ValueOf函数创建一个表示变量值的reflect.Value实例:

vValue := reflect.ValueOf(v)

由于 Go 语言中的每个变量都有一个类型,reflect.Value有一个叫做Type的方法,用于返回reflect.Valuereflect.Typereflect.Type上也有一个Kind方法,就像在reflect.Type上一样。

就像reflect.Type有用于查找关于变量类型信息的方法一样,reflect.Value也有用于查找关于变量值信息的方法。我不会覆盖所有方法,但让我们看一下如何使用reflect.Value来获取变量的值。

我将从演示如何从reflect.Value中读取您的值开始。Interface方法将变量的值作为any返回。当您将Interface返回的值放入变量中时,您必须使用类型断言返回可用类型:

s := []string{"a", "b", "c"}
sv := reflect.ValueOf(s)        // sv is of type reflect.Value
s2 := sv.Interface().([]string) // s2 is of type []string

虽然Interface可以用于包含任何类型值的reflect.Value实例,但如果变量的类型是内置的基本类型之一:BoolComplexIntUintFloatString,则可以使用特殊的方法。如果变量的类型是字节切片,则Bytes方法也适用。如果使用不匹配reflect.Value类型的方法,您的代码将会恐慌。如果您不确定Value的类型,方法可以预先检查:CanComplexCanFloatCanIntCanUint方法验证Value是否属于数值类型之一,CanConvert检查其他类型。

您也可以使用反射来设置变量的值,但这是一个三步过程。

首先,您将变量的指针传递给reflect.ValueOf。这将返回一个代表指针的reflect.Value

i := 10
iv := reflect.ValueOf(&i)

接下来,您需要获取要设置的实际值。您使用reflect.Value上的Elem方法来获取由传递给reflect.ValueOf的指针指向的值。正如reflect.Type上的Elem返回包含类型指向的类型一样,reflect.Value上的Elem返回由指针指向的值或存储在接口中的值:

ivv := iv.Elem()

最后,您可以到达用于设置值的实际方法。正如有用于读取原始类型的特殊情况方法一样,还有用于设置原始类型的特殊情况方法:SetBoolSetIntSetFloatSetStringSetUint。在示例中,调用ivv.SetInt(20)会更改i的值。如果现在打印出i,您将得到 20:

ivv.SetInt(20)
fmt.Println(i) // prints 20

对于所有其他类型,您需要使用Set方法,该方法接受reflect.Value类型的变量。您设置的值不需要是指针,因为您只是读取该值,而不是更改它。正如您可以使用Interface()读取原始类型一样,您可以使用Set写入原始类型。

reflect.ValueOf传递给指针以更改输入参数的值的原因与 Go 语言中的任何其他函数类似。正如我在“指针指示可变参数”中讨论的那样,您使用指针类型的参数来指示您希望修改参数的值。当您修改该值时,您对指针进行取消引用,然后设置该值。以下两个函数遵循相同的过程:

func changeInt(i *int) {
    *i = 20
}

func changeIntReflect(i *int) {
    iv := reflect.ValueOf(i)
    iv.Elem().SetInt(20)
}

尝试将reflect.Value设置为错误类型的值将导致恐慌。

提示

如果未将变量的指针传递给reflect.ValueOf,您仍然可以使用反射读取变量的值。但是,如果尝试使用任何可以更改变量值的方法,方法调用将(不出所料地)导致恐慌。reflect.Value上的CanSet方法将告诉您调用Set是否会导致恐慌。

创建新值

在学习如何最佳使用反射之前,还有一件事需要介绍:如何创建值。reflect.New函数是new函数的反射模拟。它接受一个reflect.Type并返回一个指向指定类型的reflect.Value的指针。由于它是指针,您可以修改它,然后使用Interface方法将修改后的值分配给变量。

正如reflect.New创建标量类型的指针一样,您还可以使用反射来执行与make关键字相同的操作,具体方法如下:

func MakeChan(typ Type, buffer int) Value

func MakeMap(typ Type) Value

func MakeMapWithSize(typ Type, n int) Value

func MakeSlice(typ Type, len, cap int) Value

每个函数都接受一个代表复合类型而不是包含类型的reflect.Type

在构建reflect.Type时,必须始终从一个值开始。但是,有一个技巧可以让你创建一个变量来表示reflect.Type,如果没有现成的值的话:

var stringType = reflect.TypeOf((*string)(nil)).Elem()

var stringSliceType = reflect.TypeOf([]string(nil))

变量stringType包含一个表示stringreflect.Type,而变量stringSliceType包含一个表示[]stringreflect.Type。第一行可能需要花些功夫来理解。你所做的是将nil转换为指向string的指针,使用reflect.TypeOf来创建该指针类型的reflect.Type,然后在该指针的reflect.Type上调用Elem以获取底层类型。你必须将*string放在括号中,因为在 Go 的运算顺序中,没有括号的话,编译器会认为你正在将nil转换为string,这是非法的。

对于stringSliceType来说,情况稍微简单一些,因为nil是切片的有效值。你只需将nil转换为[]string类型并传递给reflect.Type即可。

现在你有了这些类型,可以看看如何使用reflect.Newreflect.MakeSlice

ssv := reflect.MakeSlice(stringSliceType, 0, 10)

sv := reflect.New(stringType).Elem()
sv.SetString("hello")

ssv = reflect.Append(ssv, sv)
ss := ssv.Interface().([]string)
fmt.Println(ss) // prints [hello]

你可以在Go Playground第十六章存储库sample_code/reflect_string_slice目录中自行尝试此代码。

使用反射检查接口值是否为 nil

正如我在“接口和 nil”中所讨论的,如果将具体类型的nil变量赋给接口类型的变量,则接口类型的变量不是nil。这是因为接口变量与类型相关联。如果要检查与接口关联的值是否为nil,可以使用反射的IsValidIsNil两种方法进行检查:

func hasNoValue(i any) bool {
    iv := reflect.ValueOf(i)
    if !iv.IsValid() {
        return true
    }
    switch iv.Kind() {
    case reflect.Pointer, reflect.Slice, reflect.Map, reflect.Func,
         reflect.Interface:
        return iv.IsNil()
    default:
        return false
    }
}

IsValid方法返回true,如果reflect.Value包含的是除nil接口之外的任何东西。你需要首先检查这一点,因为如果IsValidfalse,在reflect.Value上调用任何其他方法都会(毫不意外地)导致恐慌。IsNil方法返回true,如果reflect.Value的值是nil,但只能在reflect.Kind可以是nil的情况下调用。如果在其零值不是nil的类型上调用它,会(你猜对了)导致恐慌。

你可以在Go Playground第十六章存储库sample_code/no_value目录中看到此函数的使用。

即使可以检测到带有nil值的接口,也应努力编写代码,确保在与接口关联的值为nil时也能正常运行。仅在没有其他选择时使用此代码。

使用反射编写数据编组器

如前所述,反射是标准库用于实现编组和解组的方法。让我们看看通过构建自己的数据编组器来实现它的方法。Go 提供csv.NewReadercsv.NewWriter函数来将 CSV 文件读取到字符串的切片的切片中,并将字符串的切片的切片写入 CSV 文件,但是标准库中没有任何内容可以自动将该数据映射到结构体中的字段。以下代码将添加此缺失的功能。

注意

此处的示例已经削减了一些内容以适应,减少了支持的类型数量。您可以在The Go Playground第十六章代码示例/csv目录中找到完整的代码。

首先定义您的 API。与其他编组器一样,您将定义一个结构标签,指定要映射到结构体中字段的数据字段的名称:

type MyData struct {
    Name   string `csv:"name"`
    Age    int    `csv:"age"`
    HasPet bool   `csv:"has_pet"`
}

公共 API 包括两个函数:

// Unmarshal maps all of the rows of data in a slice of slice of strings
// into a slice of structs.
// The first row is assumed to be the header with the column names.
func Unmarshal(data [][]string, v any) error

// Marshal maps all of the structs in a slice of structs to a slice of slice
// of strings.
// The first row written is the header with the column names.
func Marshal(v any) ([][]string, error)

首先,您将从Marshal开始,编写该函数,然后查看它使用的两个辅助函数:

func Marshal(v any) ([][]string, error) {
    sliceVal := reflect.ValueOf(v)
    if sliceVal.Kind() != reflect.Slice {
        return nil, errors.New("must be a slice of structs")
    }
    structType := sliceVal.Type().Elem()
    if structType.Kind() != reflect.Struct {
        return nil, errors.New("must be a slice of structs")
    }
    var out [][]string
    header := marshalHeader(structType)
    out = append(out, header)
    for i := 0; i < sliceVal.Len(); i++ {
        row, err := marshalOne(sliceVal.Index(i))
        if err != nil {
            return nil, err
        }
        out = append(out, row)
    }
    return out, nil
}

由于可以编组任何类型的结构体,因此需要使用any类型的参数。这不是指向结构体切片的指针,因为您只是从您的切片中读取,而不是修改它。

您的 CSV 的第一行将是包含列名的标题,因此您从结构体的字段中的结构标签获取这些列名。您使用Type方法从reflect.Value获取切片的reflect.Type,然后调用Elem方法获取切片元素的reflect.Type。然后将其传递给marshalHeader并将响应追加到您的输出。

接下来,您通过反射迭代每个结构切片中的每个元素,将每个元素的reflect.Value传递给marshalOne,并将结果追加到您的输出中。完成迭代后,返回您的string切片的切片。

查看您的第一个辅助函数marshalHeader的实现:

func marshalHeader(vt reflect.Type) []string {
    var row []string
    for i := 0; i < vt.NumField(); i++ {
        field := vt.Field(i)
        if curTag, ok := field.Tag.Lookup("csv"); ok {
            row = append(row, curTag)
        }
    }
    return row
}

此函数简单地循环遍历reflect.Type的字段,读取每个字段上的csv标签,将其追加到string切片中,并返回该切片。

第二个辅助函数是marshalOne

func marshalOne(vv reflect.Value) ([]string, error) {
    var row []string
    vt := vv.Type()
    for i := 0; i < vv.NumField(); i++ {
        fieldVal := vv.Field(i)
        if _, ok := vt.Field(i).Tag.Lookup("csv"); !ok {
            continue
        }
        switch fieldVal.Kind() {
        case reflect.Int:
            row = append(row, strconv.FormatInt(fieldVal.Int(), 10))
        case reflect.String:
            row = append(row, fieldVal.String())
        case reflect.Bool:
            row = append(row, strconv.FormatBool(fieldVal.Bool()))
        default:
            return nil, fmt.Errorf("cannot handle field of kind %v",
                                   fieldVal.Kind())
        }
    }
    return row, nil
}

此函数接受一个reflect.Value并返回一个string切片。您创建string切片,并针对结构体中的每个字段,使用其reflect.Kind进行切换以确定如何将其转换为string,并将其追加到输出中。

您的简单编组器现在已经完成。让我们看看您需要做什么来解组:

func Unmarshal(data [][]string, v any) error {
    sliceValPointer := reflect.ValueOf(v)
    if sliceValPointer.Kind() != reflect.Pointer {
        return errors.New("must be a pointer to a slice of structs")
    }
    sliceVal := sliceValPointer.Elem()
    if sliceVal.Kind() != reflect.Slice {
        return errors.New("must be a pointer to a slice of structs")
    }
    structType := sliceVal.Type().Elem()
    if structType.Kind() != reflect.Struct {
        return errors.New("must be a pointer to a slice of structs")
    }

    // assume the first row is a header
    header := data[0]
    namePos := make(map[string]int, len(header))
    for i, name := range header {
        namePos[name] = i
    }

    for _, row := range data[1:] {
        newVal := reflect.New(structType).Elem()
        err := unmarshalOne(row, namePos, newVal)
        if err != nil {
            return err
        }
        sliceVal.Set(reflect.Append(sliceVal, newVal))
    }
    return nil
}

由于您正在将数据复制到任意类型的结构体切片中,因此需要使用any类型的参数。此外,由于您正在修改存储在此参数中的值,必须传递指向结构体切片的指针。Unmarshal函数将该结构体切片指针转换为reflect.Value,然后获取底层切片,并获取底层切片中结构体的类型。

如我先前所说,该代码假定数据的第一行是包含列名的标题。您可以利用这些信息构建映射,从而将csv结构标签的值与正确的数据元素关联起来。

然后,您循环遍历所有剩余的string切片,使用结构体的reflect.Type创建一个新的reflect.Value,调用unmarshalOne将当前string切片中的数据复制到结构体中,然后将结构体添加到您的切片中。在遍历完所有数据行后,返回。

唯一剩下的是查看unmarshalOne的实现:

func unmarshalOne(row []string, namePos map[string]int, vv reflect.Value) error {
    vt := vv.Type()
    for i := 0; i < vv.NumField(); i++ {
        typeField := vt.Field(i)
        pos, ok := namePos[typeField.Tag.Get("csv")]
        if !ok {
            continue
        }
        val := row[pos]
        field := vv.Field(i)
        switch field.Kind() {
        case reflect.Int:
            i, err := strconv.ParseInt(val, 10, 64)
            if err != nil {
                return err
            }
            field.SetInt(i)
        case reflect.String:
            field.SetString(val)
        case reflect.Bool:
            b, err := strconv.ParseBool(val)
            if err != nil {
                return err
            }
            field.SetBool(b)
        default:
            return fmt.Errorf("cannot handle field of kind %v",
                              field.Kind())
        }
    }
    return nil
}

此函数迭代新创建的reflect.Value中的每个字段,使用当前字段上的csv结构标签查找其名称,在data切片中使用namePos映射查找元素,将值从string转换为正确的类型,并设置当前字段的值。在填充完所有字段后,函数返回。

现在您已经编写了自己的编组器和解组器,可以与 Go 标准库中现有的 CSV 支持集成:

data := `name,age,has_pet
Jon,"100",true
"Fred ""The Hammer"" Smith",42,false
Martha,37,"true"
`
r := csv.NewReader(strings.NewReader(data))
allData, err := r.ReadAll()
if err != nil {
    panic(err)
}
var entries []MyData
Unmarshal(allData, &entries)
fmt.Println(entries)

//now to turn entries into output
out, err := Marshal(entries)
if err != nil {
    panic(err)
}
sb := &strings.Builder{}
w := csv.NewWriter(sb)
w.WriteAll(out)
fmt.Println(sb)

使用反射构建函数以自动化重复任务

Go 另一个使用反射的功能是创建函数。您可以使用此技术来为任何传入的函数添加常见功能,而无需编写重复的代码。例如,这是一个工厂函数的示例,它为任何传入的函数添加计时功能:

func MakeTimedFunction(f any) any {
    ft := reflect.TypeOf(f)
    fv := reflect.ValueOf(f)
    wrapperF := reflect.MakeFunc(ft, func(in []reflect.Value) []reflect.Value {
        start := time.Now()
        out := fv.Call(in)
        end := time.Now()
        fmt.Println(end.Sub(start))
        return out
    })
    return wrapperF.Interface()
}

此函数接受任何函数作为参数,因此参数的类型是any。然后,它将代表该函数的reflect.Type传递给reflect.MakeFunc,还传递一个捕获了起始时间的闭包,使用反射调用原始函数,捕获结束时间,打印出差异,并返回原始函数计算的值。从reflect.MakeFunc返回的值是一个reflect.Value,您调用其Interface方法以获取要返回的值。以下是如何使用它的示例:

func timeMe(a int) int {
    time.Sleep(time.Duration(a) * time.Second)
    result := a * 2
    return result
}

func main() {
    timed:= MakeTimedFunction(timeMe).(func(int) int)
    fmt.Println(timed(2))
}

您可以在Go Playground上或在第十六章存储库sample_code/timed_function目录中运行此程序的更完整版本。

尽管生成函数很聪明,但在使用此功能时要小心。确保清楚您何时使用生成的函数以及它添加了什么功能。否则,您将使程序中数据流的理解变得更加困难。此外,正如我将在“仅在值得时使用反射”中讨论的那样,反射会使您的程序变慢,因此,除非您正在生成的代码已经执行缓慢操作(如网络调用),否则使用它来生成和调用函数会严重影响性能。请记住,反射在最佳情况下用于将数据映射到程序的边缘。

遵循这些规则生成函数的一个项目是我的 SQL 映射库 Proteus。它通过从 SQL 查询和函数字段或变量生成函数来创建类型安全的数据库 API。你可以在我的 GopherCon 2017 演讲中了解更多关于 Proteus 的信息,“Runtime Generated, Typesafe, and Declarative: Pick Any Three,”,并且你可以在 GitHub 上找到源代码。

你可以使用反射构建结构体,但最好不要这样做

你可以用反射做一件更怪异的事情。reflect.StructOf 函数接受 reflect.StructField 切片并返回代表新结构体类型的 reflect.Type。这些结构体只能分配给 any 类型的变量,并且只能使用反射读取和写入它们的字段。

大多数情况下,这只是学术兴趣的一个特性。要查看 reflect.StructOf 如何工作的演示,请查看 The Go Playground 上的记忆函数或 Chapter 16 repositorysample_code/memoizer 目录。它使用动态生成的结构体作为映射的键来缓存函数的输出。

反射无法创建方法

你已经看到反射可以做的所有事情,但有一件事情它做不到。虽然你可以使用反射来创建新函数和新的结构体类型,但没有办法使用反射向类型添加方法。这意味着你不能使用反射来创建一个实现接口的新类型。

只有在值得时才使用反射

虽然反射在转换 Go 边界处的数据时至关重要,但在其他情况下使用时要小心。反射并非免费。为了演示,让我们使用反射来实现 Filter。你在 “Generic Functions Abstract Algorithms” 中看过一个通用实现,但现在让我们看看基于反射的版本是什么样子:

func Filter(slice any, filter any) any {
    sv := reflect.ValueOf(slice)
    fv := reflect.ValueOf(filter)

    sliceLen := sv.Len()
    out := reflect.MakeSlice(sv.Type(), 0, sliceLen)
    for i := 0; i < sliceLen; i++ {
        curVal := sv.Index(i)
        values := fv.Call([]reflect.Value{curVal})
        if values[0].Bool() {
            out = reflect.Append(out, curVal)
        }
    }
    return out.Interface()
}

你可以像这样使用它:

names := []string{"Andrew", "Bob", "Clara", "Hortense"}
longNames := Filter(names, func(s string) bool {
    return len(s) > 3
}).([]string)
fmt.Println(longNames)

ages := []int{20, 50, 13}
adults := Filter(ages, func(age int) bool {
    return age >= 18
}).([]int)
fmt.Println(adults)

这将打印出以下内容:

[Andrew Clara Hortense]
[20 50]

使用反射的过滤函数并不难理解,但它肯定比自定义编写的或通用函数更长。让我们看看在 Go 1.20 上,使用 16 GB RAM 的 Apple Silicon M1,当过滤包含 1,000 个字符串和int元素的切片时,与自定义编写的函数相比它的表现如何:

BenchmarkFilterReflectString-8     5870  203962 ns/op  46616 B/op  2219 allocs/op
BenchmarkFilterGenericString-8   294355    3920 ns/op  16384 B/op     1 allocs/op
BenchmarkFilterString-8          302636    3885 ns/op  16384 B/op     1 allocs/op
BenchmarkFilterReflectInt-8        5756  204530 ns/op  45240 B/op  2503 allocs/op
BenchmarkFilterGenericInt-8      439100    2698 ns/op   8192 B/op     1 allocs/op
BenchmarkFilterInt-8             443745    2677 ns/op   8192 B/op     1 allocs/op

你可以在 Chapter 16 repositorysample_code/reflection_filter 目录中找到此代码,因此你可以自行运行它。

此基准测试展示了在可以使用泛型时使用泛型的价值。反射的性能比自定义或通用函数慢 50 多倍,对于 int 来说大约慢 75 倍。它使用的内存显著更多,并执行数千次分配,这为垃圾收集器创建了额外工作。泛型版本提供了与自定义编写的函数相同的性能,而无需编写多个版本。

反射实现的一个更严重的缺点是,编译器无法阻止您为“切片”或“过滤器”参数传递错误类型。您可能不介意花费几千个纳秒的 CPU 时间,但如果有人向“过滤器”传递错误类型的函数或切片,则您的程序将在生产中崩溃。维护成本可能太高以致接受。

有些东西无法使用泛型编写,你需要回退到反射(reflection)机制。CSV 的编组和解组需要反射,内存化程序也需要。在这两种情况下,你需要处理不同(和未知)类型的未知数量的值。但在使用反射之前,请确保它是必不可少的。

unsafe即不安全

就像reflect包允许您操纵类型和值一样,unsafe包允许您操纵内存。unsafe包非常小,也非常奇怪。它定义了几个函数和一个类型,都不像其他包中的类型和函数。

鉴于 Go 对内存安全的重视,你可能会想为什么unsafe还存在。就像你使用reflect在外部世界和 Go 代码之间转换文本数据一样,你使用unsafe来转换二进制数据。使用unsafe的两个主要原因。Diego Elias Costa 等人 2020 年的一篇论文名为“Breaking Type-Safety in Go: An Empirical Study on the Usage of the unsafe Package”调查了 2,438 个流行的 Go 开源项目,并发现了以下问题:

  • 在研究的 Go 项目中,有 24%的项目至少在其代码库中使用了unsafe

  • 大多数unsafe用途是为了与操作系统和 C 代码集成。

  • 开发者经常使用unsafe来编写更高效的 Go 代码。

unsafe的多数用途是用于系统互操作性。Go 标准库使用unsafe从操作系统读取数据和向其写入数据。您可以在标准库的syscall包或更高级别的sys中看到示例。您可以通过 Matt Layher 撰写的一篇很棒的博文了解如何使用unsafe与操作系统通信的更多信息。

unsafe.Pointer类型是一个特殊类型,只有一个目的:可以将任何类型的指针转换为或从unsafe.Pointer。除了指针之外,unsafe.Pointer还可以转换为或从特殊整数类型uintptr。与任何其他整数类型一样,您可以对其进行数学运算。这允许您进入类型的实例,提取单个字节。您还可以执行指针算术运算,就像在 C 和 C++中可以对指针进行的一样。这种字节操作会改变变量的值。

unsafe代码中存在两种常见模式。第一种是在通常不可转换的两种变量类型之间进行转换。这通过在中间使用unsafe.Pointer进行一系列类型转换来完成。第二种是通过将变量转换为unsafe.Pointer,然后将unsafe.Pointer转换为指针,以读取或修改变量中的字节,并复制或操作其基础字节。这两种技术都要求你了解正在操作的数据的大小(以及可能的位置)。unsafe包中的SizeofOffsetof函数提供了这些信息。

使用 SizeofOffsetof

unsafe中的一些函数揭示了各种类型在内存中的布局方式。你将首先看到的是Sizeof。顾名思义,它返回传递给它的任何内容的字节大小。

尽管数值类型的大小相对明显(比如int16是 16 位或 2 字节,byte是 1 字节等),其他类型就复杂一些了。对于指针而言,你得到的是用于存储指针的内存大小(在 64 位系统上通常为 8 字节),而不是指针指向的数据的大小。这就是为什么在 64 位系统上,Sizeof 认为任何切片的长度为 24 字节;它实际上由两个int字段(用于长度和容量)和指向切片数据的指针组成。在 64 位系统上,任何字符串的长度为 16 字节(包括一个长度int和指向字符串内容的指针)。在 64 位系统上,任何map都是 8 字节,因为在 Go 运行时中,map被实现为指向一个相当复杂的数据结构的指针。

数组是值类型,因此其大小通过将数组长度乘以数组中每个元素的大小来计算。

对于结构体而言,其大小是字段大小的总和,加上对齐的一些调整。计算机喜欢以固定大小的块读写数据,而且它们确实不希望一个值从一个块开始到另一个块结束。为了实现这一点,编译器在字段之间添加填充,以便它们正确对齐。编译器还希望整个结构体能够正确对齐。在 64 位系统上,它将在结构体末尾添加填充,使其大小达到 8 字节的倍数。

unsafe中的另一个函数Offsetof告诉您结构体中字段的位置。让我们使用SizeofOffsetof来看看字段顺序对结构体大小的影响。假设你有两个结构体:

type BoolInt struct {
    b bool
    i int64
}

type IntBool struct {
    i int64
    b bool
}

运行这段代码

fmt.Println(unsafe.Sizeof(BoolInt{}),
    unsafe.Offsetof(BoolInt{}.b),
    unsafe.Offsetof(BoolInt{}.i))

fmt.Println(unsafe.Sizeof(IntBool{}),
    unsafe.Offsetof(IntBool{}.i),
    unsafe.Offsetof(IntBool{}.b))

产生以下输出:

16 0 8
16 0 8

两种类型的大小都是 16 字节。当bool放在前面时,编译器在bi之间放置 7 字节的填充。当int64放在前面时,编译器在b后面放置 7 字节的填充,以使结构体的大小成为 8 字节的倍数。

当存在更多字段时,你可以看到字段顺序的影响:

type BoolIntBool struct {
    b  bool
    i  int64
    b2 bool
}

type BoolBoolInt struct {
    b  bool
    b2 bool
    i  int64
}

type IntBoolBool struct {
    i  int64
    b  bool
    b2 bool
}

打印出这些类型的大小和偏移量将产生以下输出:

24 0 8 16
16 0 1 8
16 0 8 9

int64字段放在两个bool字段之间会产生一个 24 字节长的结构体,因为两个bool字段需要填充为 8 字节。而将bool字段分组在一起会产生一个 16 字节长的结构体,与仅有两个字段的结构体相同。你可以在The Go Playground第十六章代码库sample_code/sizeof_offsetof目录中验证这一点。

虽然大部分时间仅仅是学术兴趣,但这些信息在两种情况下都很有用。第一种情况是在管理大量数据的程序中。有时,通过重新排列结构体中经常使用的字段的顺序,以尽量减少对齐所需的填充量,可以显著节省内存使用。

第二种情况发生在你想要直接将一系列字节映射到一个结构体时。接下来你会看到这个情况。

使用 unsafe 转换外部二进制数据

正如前面提到的,人们使用unsafe的主要原因之一是为了性能,特别是在从网络读取数据时。如果你想将数据映射到或从 Go 数据结构中映射出来,unsafe.Pointer提供了一种非常快速的方式。你可以通过一个构造的例子来探索这一点。想象一个wire protocol(一个规范,指示在网络通信时以哪种顺序写入哪些字节)具有以下结构:

  • Value: 4 字节,表示无符号的大端 32 位整数

  • Label: 10 字节,值的 ASCII 名称

  • Active: 1 字节,布尔标志,指示字段是否激活

  • Padding: 1 字节,因为你希望所有内容都适应 16 字节

注意

通过网络发送的数据通常以大端格式发送(最重要的字节先),通常称为network byte order。由于今天使用的大多数 CPU 都是小端(或以小端模式运行的双端),因此在读取或写入数据到网络时需要小心。

这个例子的代码位于第十六章代码库中的sample_code/unsafe_data目录中。

你定义一个数据结构,其内存布局与 wire protocol 匹配:

type Data struct {
    Value  uint32   // 4 bytes
    Label  [10]byte // 10 bytes
    Active bool     // 1 byte
    // Go pads this with 1 byte to make it align
}

你可以使用unsafe.Sizeof定义一个代表其大小的常量:

const dataSize = unsafe.Sizeof(Data{}) // sets dataSize to 16

(关于unsafe.SizeofOffsetof的奇怪之处之一是,你可以在const表达式中使用它们。数据结构在内存中的大小和布局在编译时已知,因此这些函数的结果在编译时计算,就像一个常量表达式一样。)

假设你刚刚从网络上读取了以下字节:

[0 132 95 237 80 104 111 110 101 0 0 0 0 0 1 0]

你将把这些字节读入长度为 16 的数组中,然后将该数组转换为前面描述的struct

使用安全的 Go 代码,你可以这样映射它:

func DataFromBytes(b [dataSize]byte) Data {
    d := Data{}
    d.Value = binary.BigEndian.Uint32(b[:4])
    copy(d.Label[:], b[4:14])
    d.Active = b[14] != 0
    return d
}

或者,你可以使用unsafe.Pointer代替:

func DataFromBytesUnsafe(b [dataSize]byte) Data {
    data := *(*Data)(unsafe.Pointer(&b))
    if isLE {
        data.Value = bits.ReverseBytes32(data.Value)
    }
    return data
}

第一行有点令人困惑,但你可以分开来理解发生了什么。首先,你获取字节数组的地址并将其转换为unsafe.Pointer。然后将unsafe.Pointer转换为(*Data)(由于 Go 的操作顺序,你必须将(*Data)放在括号中)。你想要返回结构体而不是指向它的指针,因此你对指针进行解引用。接下来,你检查标志来看你是否处于小端平台。如果是,你会反转Value字段中的字节。最后,你返回该值。

为什么你在参数上使用数组而不是切片?记住,数组和结构体一样,都是值类型;字节直接分配。这意味着你可以直接将b中的值映射到data结构体中。切片由三部分组成:长度、容量和指向实际值的指针。稍后你会看到如何使用unsafe将切片映射到结构体中。

你怎么知道你在小端平台上?这是你正在使用的代码:

var isLE bool

func init() {
    var x uint16 = 0xFF00
    xb := *(*[2]byte)(unsafe.Pointer(&x))
    isLE = (xb[0] == 0x00)
}

正如我在“尽可能避免使用 init 函数”中所讨论的,除了初始化包级别的值(其值实际上是不可变的)之外,你应该避免使用init函数。由于处理器的字节序在程序运行时不会改变,这是一个很好的使用情况。

在小端平台上,表示x的字节将存储为[00 FF]。在大端平台上,x存储在内存中为[FF 00]。你使用unsafe.Pointer将数字转换为字节数组,并检查第一个字节来确定isLE的值。

同样,如果你想将你的Data写回网络,你可以使用安全的 Go:

func BytesFromData(d Data) [dataSize]byte {
    out := [dataSize]byte{}
    binary.BigEndian.PutUint32(out[:4], d.Value)
    copy(out[4:14], d.Label[:])
    if d.Active {
        out[14] = 1
    }
    return out
}

或者你可以使用unsafe

func BytesFromDataUnsafe(d Data) [dataSize]byte {
    if isLE {
        d.Value = bits.ReverseBytes32(d.Value)
    }
    b := *(*[dataSize]byte)(unsafe.Pointer(&d))
    return b
}

如果字节存储在切片中,你可以使用unsafe.Slice函数从Data的内容创建一个切片。unsafe.SliceData函数用于从切片中存储的数据创建Data实例:

func BytesFromDataUnsafeSlice(d Data) []byte {
    if isLE {
        d.Value = bits.ReverseBytes32(d.Value)
    }
    bs := unsafe.Slice((*byte)(unsafe.Pointer(&d)), dataSize)
    return bs
}

func DataFromBytesUnsafeSlice(b []byte) Data {
    data := *(*Data)((unsafe.Pointer)(unsafe.SliceData(b)))
    if isLE {
        data.Value = bits.ReverseBytes32(data.Value)
    }
    return data
}

unsafe.Slice的第一个参数需要两次转换。第一次转换将Data实例的指针转换为unsafe.Pointer。然后你需要再次转换为你想要切片保存的数据类型的指针。对于字节切片,你使用*byte。第二个参数是数据的长度。

unsafe.SliceData函数接受一个切片并返回指向切片中数据类型的指针。在本例中,你传入了[]byte,它返回一个*byte。然后你使用unsafe.Pointer作为*byte*Data之间的桥梁,将切片的内容转换为Data实例。

在 Apple Silicon M1(小端)上的计时情况如何?

BenchmarkBytesFromData-8             548607271  2.185 ns/op   0 B/op 0 allocs/op
BenchmarkBytesFromDataUnsafe-8      1000000000  0.8418 ns/op  0 B/op 0 allocs/op
BenchmarkBytesFromDataUnsafeSlice-8  91179056  13.14 ns/op   16 B/op 1 allocs/op
BenchmarkDataFromBytes-8             538443861  2.186 ns/op   0 B/op 0 allocs/op
BenchmarkDataFromBytesUnsafe-8      1000000000  1.160 ns/op   0 B/op 0 allocs/op
BenchmarkDataFromBytesUnsafeSlice-8 1000000000  0.9617 ns/op  0 B/op 0 allocs/op

这张图中有两件事情很突出。首先,从结构体到切片的转换明显是最慢的操作,而且它是唯一分配内存的操作。这并不奇怪,因为切片的数据需要在返回函数时逃逸到堆上。在堆上分配内存几乎总是比在栈上使用内存慢。不过,从切片到结构体的转换非常快。

如果你正在处理数组,使用unsafe比标准方法快约 2 到 2.5 倍。如果你的程序有许多这种类型的转换,或者你尝试映射一个非常大且复杂的数据结构,使用这些低级技术是值得的。但对于绝大多数程序来说,请坚持使用安全的代码。

访问未导出的字段

这里有另一种你可以使用unsafe的魔法,但这只能作为最后的手段使用。你可以结合反射和unsafe来读取和修改结构体中未导出的字段。让我们看看怎么做。

首先,在一个包中定义一个结构体:

type HasUnexportedField struct {
    A int
    b bool
    C string
}

通常,该包外的代码无法访问b。然而,让我们看看通过使用unsafe你能从另一个包中做些什么:

func SetBUnsafe(huf *one_package.HasUnexportedField) {
    sf, _ := reflect.TypeOf(huf).Elem().FieldByName("b")
    offset := sf.Offset
    start := unsafe.Pointer(huf)
    pos := unsafe.Add(start, offset)
    b := (*bool)(pos)
    fmt.Println(*b) // read the value
    *b = true       // write the value
}

你可以使用反射访问字段b的类型信息。FieldByName方法为结构体上的任何字段返回一个reflect.StructField实例,即使是未导出的字段也可以。该实例包括其关联字段的偏移量。然后,你将huf转换为unsafe.Pointer,并使用unsafe.Add方法将偏移量添加到指针中,以找到结构体中b的位置。最后,将Add返回的unsafe.Pointer强制转换为*bool。现在,你可以读取b的值或设置其值。你可以在第十六章的代码库sample_code/unexported_field_access目录中尝试此代码。

使用不安全工具

Go 是一个重视工具的语言,编译器标志可以帮助你找到Pointerunsafe.Pointer的误用。使用标志-gcflags=-d=checkptr在运行时添加额外的检查。与竞态检查器一样,它不能保证找到每一个unsafe问题,并且会减慢你的程序。然而,在测试代码时这是一个良好的实践。

如果你想了解更多关于unsafe的内容,请阅读文档

警告

unsafe包非常强大且底层!除非你知道你在做什么并且需要它提供的性能改进,否则请避免使用它。

Cgo 是用于集成,而非性能优化

就像反射和unsafe一样,cgo在 Go 程序与外界之间的边界上最为有用。反射帮助集成外部文本数据,unsafe最适用于操作系统和网络数据,而cgo则适合与 C 库集成。

尽管已经接近 50 年的历史,C 仍然是编程语言的通用语言。所有主要操作系统主要都是用 C 或 C++ 编写的,这意味着它们捆绑了用 C 编写的库。这也意味着几乎每种编程语言都提供了一种与 C 库集成的方式。Go 将其对 C 的外部函数接口(FFI)称为 cgo

正如你已经多次看到的那样,Go 是一种偏爱显式规范的语言。Go 开发人员有时会嘲笑其他语言中的自动行为为“魔法”。然而,使用cgo有点像与梅林共度时光。让我们来看看这段神奇的粘合代码。

你将从一个非常简单的程序开始,调用 C 代码来进行一些数学运算。源代码在 GitHub 的 sample_code/call_c_from_go 目录中,在第十六章存储库中。首先,这是 main.go 中的代码:

package main

import "fmt"

/*
 #cgo LDFLAGS: -lm
 #include <stdio.h>
 #include <math.h>
 #include "mylib.h"

 int add(int a, int b) {
 int sum = a + b;
 printf("a: %d, b: %d, sum %d\n", a, b, sum);
 return sum;
 }
*/
import "C"

func main() {
    sum := C.add(3, 2)
    fmt.Println(sum)
    fmt.Println(C.sqrt(100))
    fmt.Println(C.multiply(10, 20))
}

mylib.h 头文件与你的 main.go 在同一个目录中:

int multiply(int a, int b);

还有一个 mylib.c

#include "mylib.h"

int multiply(int a, int b) {
    return a * b;
}

假设你的计算机上已安装了 C 编译器,你只需使用go build编译你的程序:

$ go build
$ ./call_c_from_go
a: 3, b: 2, sum 5
5
10
200

这里发生了什么?标准库中没有一个真正的名为C的包。相反,C 是一个自动生成的包,其标识符大多来自于紧随其后的注释中嵌入的 C 代码。在这个例子中,你声明了一个名为add的 C 函数,cgo将其作为C.add的名称提供给你的 Go 程序。你还可以调用通过头文件从库中导入到注释块中的函数或全局变量,就像你从main中调用C.sqrt(从 math.h 导入)或C.multiply(从 mylib.h 导入)时所看到的那样。

除了出现在注释块中的标识符名称(或被导入到注释块中的名称)外,C 伪包还定义了像C.intC.char这样的类型,用于表示内置的 C 类型和函数,比如C.CString用于将 Go 字符串转换为 C 字符串。

你可以使用更多的魔法来从 C 函数调用 Go 函数。通过在函数前放置一个//export注释,可以将 Go 函数暴露给 C 代码。你可以在第十六章存储库sample_code/call_go_from_c 目录中看到这种用法。在 main.go 中,你导出了 doubler 函数:

//export doubler
func doubler(i int) int {
    return i * 2
}

当你导出一个 Go 函数时,在import "C"语句之前的注释中就不能再直接编写 C 代码了。你只能列出函数头部:

/*
 extern int add(int a, int b);
*/
import "C"

将你的 C 代码放入与你的 Go 代码相同目录的一个 .c 文件中,并包含神奇的头文件"cgo_export.h"。你可以在 example.c 文件中看到这一点:

#include "_cgo_export.h"

int add(int a, int b) {
    int doubleA = doubler(a);
    int sum = doubleA + b;
    return sum;
}

使用go build运行这个程序会得到以下输出:

$ go build
$ ./call_go_from_c
8

到目前为止,这似乎很简单,但在使用cgo时会遇到一个主要的障碍:Go 是一种垃圾收集语言,而 C 不是。这使得将复杂的 Go 代码与 C 集成变得困难。虽然你可以将指针传递给 C 代码,但不能直接传递包含指针的东西。这是非常限制性的,因为像字符串、切片和函数这样的东西都是用指针实现的,因此不能包含在传递给 C 函数的结构体中。

这还不是全部:C 函数不能存储在函数返回后持续存在的 Go 指针的副本。如果你违反了这些规则,你的程序会编译和运行,但在指针指向的内存被垃圾收集时可能会崩溃或表现不正确。

如果你发现自己需要从 Go 向 C 传递包含指针的类型实例,然后再次返回到 Go,你可以使用cgo.Handle来包装该实例。这里有一个简短的示例。你可以在第十六章的代码库sample_code/handle目录中找到源代码。

首先是你的 Go 代码:

package main

/*
 #include <stdint.h>

 extern void in_c(uintptr_t handle);
*/
import "C"

import (
    "fmt"
    "runtime/cgo"
)

type Person struct {
    Name string
    Age  int
}

func main() {
    p := Person{
        Name: "Jon",
        Age:  21,
    }
    C.in_c(C.uintptr_t(cgo.NewHandle(p)))
}

//export processor
func processor(handle C.uintptr_t) {
    h := cgo.Handle(handle)
    p := h.Value().(Person)
    fmt.Println(p.Name, p.Age)
    h.Delete()
}

这是 C 代码:

#include <stdint.h>
#include "_cgo_export.h"

void in_c(uintptr_t handle) {
    processor(handle);
}

在这段 Go 代码中,你将一个Person实例传递给 C 函数in_c。然后这个函数调用 Go 函数processor。通过cgo安全地将Person传递给 C 是不可能的,因为它的字段之一是一个string,而每个string都包含一个指针。为了使这个工作正常,使用cgo.NewHandle函数将p转换为cgo.Handle。然后将Handle转换为C.uintptr_t类型,以便将其传递给 C 函数in_c。该函数接受一个类型为uintptr_t的单一参数,这是一个类似于 Go 的uintptr类型的 C 类型。in_c函数调用 Go 函数process,也接受一个类型为C.uintptr_t的单一参数。它将此参数转换为cgo.Handle,然后使用类型断言将Handle转换为Person实例。你打印出p中的字段。现在你使用完Handle后,调用Delete方法来删除它。

除了处理指针之外,还存在其他cgo的限制。例如,你不能使用cgo调用可变参数的 C 函数(比如printf)。C 中的联合类型转换为字节数组。你不能调用 C 函数指针(但可以将其分配给一个 Go 变量并将其传递回 C 函数)。

这些规则使得使用cgo并不简单。如果你有 Python 或 Ruby 的背景,你可能认为出于性能原因使用cgo是值得的。这些开发者将其程序的性能关键部分写在 C 中。NumPy 的速度归功于 Python 代码封装的 C 库。

在大多数情况下,Go 代码比 Python 或 Ruby 快几倍,因此大大减少了需要用低级语言重写算法的必要性。你可能会认为在需要额外性能提升时可以保存cgo,但不幸的是,使用cgo使代码加速并不容易。由于处理和内存模型的不匹配,从 Go 调用 C 函数比从 C 函数调用另一个 C 函数慢大约 29 倍。

在 CapitalGo 2018 年,Filippo Valsorda 发表了名为“为什么 cgo 很慢”的演讲。不幸的是,演讲没有录制,但幻灯片可用。它们解释了为什么cgo很慢以及为什么未来不会显著加快其速度。在他的博客文章“Go 1.21 中 CGO 性能”中,Shane Hansen 测量了在 Intel Core i7-12700H 上使用 Go 1.21 进行cgo调用的开销大约为 40 纳秒。

提示

由于cgo速度不快,且对于复杂程序不易使用,使用cgo的唯一理由是必须使用 C 库且没有适当的 Go 替代品。与其自己编写cgo,不如看看是否有第三方模块提供了包装器。例如,如果要在 Go 应用程序中嵌入 SQLite,请查看GitHub。对于 ImageMagick,请查看此存储库

如果你发现自己需要使用一个没有包装器的内部 C 库或第三方库,可以在 Go 文档中找到更多详情。有关在使用cgo时可能遇到的性能和设计折衷的信息,请阅读 Tobias Grieger 的博客文章“Cgo 的成本和复杂性”

练习

现在是时候测试你自己是否可以使用反射、unsafecgo编写小程序了。练习的答案可以在第十六章存储库exercise_solutions目录中找到。

  1. 使用反射创建一个简单的最小字符串长度验证器来验证结构字段。编写一个ValidateStringLength函数,接受一个结构体并在字段是字符串、具有名为minStrlen的结构标签且字段值长度小于结构标签中指定的值时返回错误。忽略非字符串字段和没有minStrlen结构标签的字符串字段。使用errors.Join报告所有无效字段。确保验证传递的是结构体。如果所有字段长度正确,则返回nil

  2. 使用unsafe.Sizeofunsafe.Offsetof打印出ch16/tree/main/sample_code/orders中定义的OrderInfo结构体的大小和偏移量。创建一个新类型SmallOrderInfo,其字段相同,但重新排序以尽可能减少内存使用。

  3. 复制 ch16/tree/main/sample_code/mini_calc 中的 C 代码到你自己的模块,并使用cgo从 Go 程序中调用它。

结语

在本章中,你学习了反射(reflection)、unsafecgo。这些特性可能是 Go 语言最令人兴奋的部分,因为它们允许你打破 Go 语言无趣、类型安全、内存安全的规则。更重要的是,你学会了为什么要打破这些规则,以及为什么大多数时候应该避免这样做。

你已经完成了对 Go 语言及其惯用法的学习之旅。就像任何毕业典礼一样,现在是总结的时候了。让我们回顾一下序言中的内容。"[P]roperly written, Go is boring…​.Well-written Go programs tend to be straightforward and sometimes a bit repetitive." 希望你现在能明白为什么这会导致更好的软件工程。

惯用的 Go 语言是一组工具、实践和模式,使得随着时间和团队变化更容易维护软件。这并不是说其他语言的文化不重视可维护性;只是可能不是它们的首要考虑因素。它们更强调性能、新特性或简洁的语法。这些权衡是有存在的必要性,但从长远来看,我相信 Go 语言专注于打造持久软件的焦点最终会胜出。祝你在为未来 50 年的计算机软件创作中一切顺利。

posted @ 2024-06-18 18:05  绝不原创的飞龙  阅读(9)  评论(0编辑  收藏  举报