Go 编程学习手册(全)
原文:
zh.annas-archive.org/md5/5FC2C8948F5CEA11C4D0D293DBBCA039
译者:飞龙
前言
Go 是一种开源编程语言,让程序员可以轻松构建可靠且可扩展的程序。它通过提供简单的语法来实现这一点,使得使用并发习语和强大的标准库编写正确且可预测的代码变得有趣。
Go 拥有庞大而活跃的在线社区,全球每年都会举办几次 Go 大会。从golang.org/
开始,你会发现网络上有许多提供文档、博客、视频和幻灯片的地方,涵盖了各种与 Go 相关的主题。在 GitHub 上也是如此;一些最知名的项目,例如驱动云计算未来的项目,都是用 Go 编写的,并且项目列表还在不断增长。
正如你所期望的那样,开始使用 Go 简单、快速且有很好的文档支持。然而,“深入”Go 可能更具挑战性,特别是对于来自其他语言的新手。我的第一次尝试 Go 失败了。即使阅读了规定的文档并完成了教程,由于我自己以前的编程经验所带来的偏见,理解上还是有差距。几个月后,我重新开始学习 Go 并且深入其中。这一次我阅读了语言规范,阅读了博客,观看了视频,并在网络上搜索任何提供设计动机和语言深入解释的讨论。
学习 Go 是一本旨在帮助新手和经验丰富的程序员学习 Go 编程语言的书。通过这本书,我试图写出我在开始学习 Go 时希望能够阅读的书。它将语言规范、文档、博客、视频、幻灯片以及我自己编写 Go 的经验融合在一起,提供了恰到好处的深度和见解,帮助你理解这门语言及其设计。
希望你喜欢它。
本书涵盖内容
第一章,Go 的第一步,读者将以高层次介绍 Go,并参观使该语言成为受欢迎的特点。
第二章,Go 语言基础,本章从更深入地探索 Go 的语法和其他语言元素开始,如源文件、变量和运算符。
第三章,Go 控制流,检查了 Go 程序的控制流元素,包括 if、循环和 switch 语句。
第四章,数据类型,向读者介绍了 Go 的类型系统,包括内置类型、类型声明和转换的详细信息。
第五章,Go 中的函数,讨论了 Go 函数类型的特点,包括定义、赋值、可变参数和闭包。
第六章,Go 包和程序结构,向读者介绍了将函数组织为逻辑分组(称为包和程序)的方式。
第七章,复合类型,本章继续讨论 Go 类型,向读者介绍了 Go 的复合类型,如数组、切片、映射和结构体。
第八章,方法、接口和对象,向读者介绍了可以用于创建和组合对象结构的 Go 习语和特性。
第九章,并发,介绍了使用诸如 goroutines 和 channels 等语言构造在 Go 中编写并发程序的主题。
第十章,Go 中的数据 IO,介绍了用于实现数据流输入、输出和编码的内置接口和 API。
第十一章,编写网络服务,探讨了 Go 标准库用于创建连接应用程序的功能,涵盖了从低级 TCP 协议到 HTTP 和 RPC 的主题。
第十二章,代码测试,在这里读者将介绍 Go 对代码测试和基准测试的固有支持和工具。
本书所需内容
要按照本书中的示例,您需要 Go 版本 1.6 或更高版本。 Go 支持包括 AMD64、x386 和 ARM 在内的多种架构,以及以下操作系统:
-
Windows XP(或更高版本)
-
Mac OSX 10.7(或更高版本)
-
Linux 2.6(或更高版本)
-
FreeBSD 8(或更高版本)
本书的读者
如果您之前有编程经验,并且有兴趣学习 Go,那么这本书就是为您设计的。虽然它假设您熟悉变量、数据类型、数组、方法和函数等概念,但本书旨在让您可以按章节顺序阅读,或者跳到您想学习的主题。
惯例
在本书中,您将找到许多文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。
文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄显示如下:“将源代码保存在名为helloworld.go
的文件中,放在 GOPATH 的任何位置。”
代码块设置如下:
package main
import "fmt"
func main() {
fmt.Println("Hello, World!")
}
任何命令行输入或输出都是这样写的:
$> go version
go version go1.6.1 linux/amd64
新术语和重要单词以粗体显示。例如,屏幕上看到的单词,比如菜单或对话框中的单词,会在文本中显示为:“如果一切顺利,您应该在屏幕上看到Hello, World!的输出。”
注意
警告或重要提示会以这样的方式显示在框中。
提示
提示和技巧看起来像这样。
第一章:Go 的第一步
在本书的第一章中,您将介绍 Go 并了解使该语言成为受欢迎的特点。本章的开头部分介绍了 Go 编程语言背后的动机。然而,如果您感到不耐烦,可以跳到其他主题并学习如何编写您的第一个 Go 程序。最后,“Go 简介”部分提供了对该语言特性的高级摘要。
本章涵盖以下主题:
-
Go 编程语言
-
使用 Go
-
安装 Go
-
您的第一个 Go 程序
-
Go 简介
Go 编程语言
自从贝尔实验室的Dennis Ritchie在 1970 年代初发明了 C 语言以来,计算机行业已经产生了许多流行的语言,它们直接基于(或借鉴了)C 语言的语法。通常被称为 C 语言家族的语言,它们可以分为两个广泛的演变分支。在一个分支中,派生语言如 C++、C#和 Java 已经发展出采用了强类型系统、面向对象和使用编译二进制的特点。然而,这些语言往往具有较慢的构建部署周期,程序员被迫采用复杂的面向对象类型系统来获得运行时安全性和执行速度:
在另一个演变的语言分支中,有诸如 Perl、Python 和 JavaScript 等语言,它们被描述为动态语言,因为它们缺乏类型安全形式,使用轻量级脚本语法,并且代码解释而非编译。动态语言已成为 Web 和云规模开发的首选工具,速度和部署便利性被重视胜过运行时安全性。然而,动态语言的解释性质意味着它们通常运行速度比编译语言慢。此外,运行时缺乏类型安全意味着系统的正确性随着应用程序的增长而变得不稳定。
Go 是由Robert Griesemer、Rob Pike和Ken Thomson于 2007 年在 Google 创建的系统语言,用于处理应用程序开发的需求。Go 的设计者们希望在创建一种新语言的同时,减轻前述语言的问题,使其简单、安全、一致和可预测。正如 Rob Pike 所说:
“Go 试图将静态类型语言的安全性和性能与动态类型解释语言的表现力和便利性相结合。”
Go 从之前的不同语言中借鉴了一些想法,包括:
-
简化但简洁的语法,有趣且易于使用
-
一种更像动态语言的系统类型
-
支持面向对象编程
-
静态类型用于编译和运行时安全
-
编译为本机二进制以实现快速运行时执行
-
几乎零编译时间,更像解释型语言
-
一种简单的并发习语,以利用多核、多芯片机器
-
用于安全和自动内存管理的垃圾收集器
本章的其余部分将带您走过一系列入门步骤,让您预览该语言并开始构建和运行您的第一个 Go 程序。这是本书其余章节中详细讨论的主题的前奏。如果您已经对 Go 有基本的了解,可以跳到其他章节。欢迎您跳到其他章节。
使用 Go
在我们首先安装和运行 Go 工具之前,让我们先来看看Go Playground。语言的创建者提供了一种简单的方式来熟悉语言,而无需安装任何工具。Go Playground 是一个基于 Web 的工具,可从play.golang.org/
访问,它使用编辑器的比喻,让开发人员可以直接在 Web 浏览器窗口中编写代码来测试他们的 Go 技能。Playground 让用户能够在 Google 的远程服务器上编译和运行他们的代码,并立即获得结果,如下面的截图所示:
编辑器很基础,因为它旨在作为学习工具和与他人分享代码的方式。Playground 包括实用功能,如行号和格式化,以确保您的代码在超过几行时仍然可读。由于这是一个消耗实际计算资源的免费服务,Google 可以理解地对 Playground 可以做什么施加一些限制:
-
你的代码将消耗的内存量受到限制
-
长时间运行的程序将被终止
-
文件访问是通过内存文件系统模拟的。
-
网络访问仅模拟对回环接口的访问
无需 IDE
除了 Go Playground,有什么其他方法可以编写 Go 代码呢?编写 Go 并不需要一个花哨的集成开发环境(IDE)。事实上,您可以使用捆绑在您的操作系统中的喜爱的纯文本编辑器开始编写简单的 Go 程序。但是,大多数主要文本编辑器(和完整的 IDE)都有针对 Go 的插件,如 Atom、Vim、Emacs、Microsoft Code、IntelliJ 等。可以在github.com/golang/go/wiki/IDEsAndTextEditorPlugins
找到完整的编辑器和 IDE 插件列表。
安装 Go
要在本地计算机上开始使用 Go 进行编程,您需要在计算机上安装Go 工具链。目前,Go 已准备好在以下主要操作系统平台上安装:
-
Linux
-
FreeBSD Unix
-
Mac OSX
-
Windows
官方安装包都适用于 32 位和 64 位的基于英特尔的架构。还有官方的二进制发布版本适用于 ARM 架构。随着 Go 的流行,未来肯定会提供更多的二进制发行选择。
让我们跳过详细的安装说明,因为当您阅读此文时,这些说明肯定会发生变化。相反,您可以访问golang.org/doc/install
并按照针对您特定平台的说明进行操作。完成后,请确保在继续使用以下命令之前测试您的安装是否正常:
$> go version
go version go1.6.1 linux/amd64
前面的命令应该打印出版本号、目标操作系统以及安装了 Go 及其工具的机器架构。如果您没有得到类似于前面命令的输出,请确保将 Go 二进制文件的路径添加到您的操作系统的执行PATH
环境变量中。
在开始编写自己的代码之前,请确保已正确设置了GOPATH
。这是一个本地目录,您在使用 Go 工具链时保存 Go 源文件和编译后的构件的地方。请按照golang.org/doc/install#testing
中的说明设置您的 GOPATH。
源代码示例
本书中提供的编程示例都可以在 GitHub 源代码存储库上找到。在那里,你将找到所有按章节分组的源文件,存储在存储库中的github.com/vladimirvivien/learning-go/
。为了节省读者一些按键次数,示例使用了一个缩短的 URL,以golang.fyi
开头,直接指向 GitHub 中的相应文件。
或者,你可以通过下载和解压(或克隆)本地存储库来跟随。在你的GOPATH
中创建一个目录结构,使得源文件的根目录位于$GOPATH/src/github.com/vladimirvivien/learning-go/
。
你的第一个 Go 程序
在你的本地机器上成功安装了 Go 工具之后,你现在可以准备编写和执行你的第一个 Go 程序了。为此,只需打开你喜欢的文本编辑器,输入下面代码中显示的简单的 Hello World 程序:
package main
import "fmt"
func main() {
fmt.Println("Hello, World!")
}
golang.fyi/ch01/helloworld.go
将源代码保存在名为helloworld.go
的文件中,放在你的 GOPATH 的任何位置。然后使用以下 Go 命令来编译和运行程序:
$> go run helloworld.go
Hello, World!
如果一切顺利,你应该在屏幕上看到消息Hello, World!的输出。恭喜,你刚刚编写并执行了你的第一个 Go 程序。现在,让我们以高层次来探索 Go 语言的属性和特性。
Go 简介
按设计,Go 具有简单的语法。它的设计者希望创建一种清晰、简洁、一致的语言,减少语法上的惊喜。阅读 Go 代码时,要记住这句口号:你看到的就是它的样子。Go 避免了巧妙而简洁的编码风格,而更倾向于清晰易读的代码,正如下面的程序所示:
// This program prints molecular information for known metalloids
// including atomic number, mass, and atom count found
// in 100 grams of each element using the mole unit.
// See http://en.wikipedia.org/wiki/Mole_(unit)
package main
import "fmt"
const avogadro float64 = 6.0221413e+23
const grams = 100.0
type amu float64
func (mass amu) float() float64 {
return float64(mass)
}
type metalloid struct {
name string
number int32
weight amu
}
var metalloids = []metalloid{
metalloid{"Boron", 5, 10.81},
metalloid{"Silicon", 14, 28.085},
metalloid{"Germanium", 32, 74.63},
metalloid{"Arsenic", 33, 74.921},
metalloid{"Antimony", 51, 121.760},
metalloid{"Tellerium", 52, 127.60},
metalloid{"Polonium", 84, 209.0},
}
// finds # of moles
func moles(mass amu) float64 {
return float64(mass) / grams
}
// returns # of atoms moles
func atoms(moles float64) float64 {
return moles * avogadro
}
// return column headers
func headers() string {
return fmt.Sprintf(
"%-10s %-10s %-10s Atoms in %.2f Grams\n",
"Element", "Number", "AMU", grams,
)
}
func main() {
fmt.Print(headers())
for _, m := range metalloids {
fmt.Printf(
"%-10s %-10d %-10.3f %e\n",
m.name, m.number, m.weight.float(), atoms(moles(m.weight)),
)
}
}
golang.fyi/ch01/metalloids.go
当代码被执行时,它将给出以下输出:
$> go run metalloids.go
Element Number AMU Atoms in 100.00 Grams
Boron 5 10.810 6.509935e+22
Silicon 14 28.085 1.691318e+23
Germanium 32 74.630 4.494324e+23
Arsenic 33 74.921 4.511848e+23
Antimony 51 121.760 7.332559e+23
Tellerium 52 127.600 7.684252e+23
Polonium 84 209.000 1.258628e+24
如果你以前从未见过 Go,你可能不理解前一个程序中使用的语法和习惯用法的一些细节。然而,当你阅读代码时,你很有可能能够跟上逻辑并形成程序流的心智模型。这就是 Go 简单之美的所在,也是为什么有这么多程序员使用它的原因。如果你完全迷失了,不用担心,后续章节将涵盖语言的所有方面,让你上手。
函数
Go 程序由函数组成,函数是语言中最小的可调用代码单元。在 Go 中,函数是有类型的实体,可以是命名的(如前面的示例所示),也可以被赋值给一个变量作为值:
// a simple Go function
func moles(mass amu) float64 {
return float64(mass) / grams
}
关于 Go 函数的另一个有趣特性是它们能够返回多个值作为调用的结果。例如,前面的函数可以重写为返回error
类型的值,以及计算出的float64
值:
func moles(mass amu) (float64, error) {
if mass < 0 {
return 0, error.New("invalid mass")
}
return (float64(mass) / grams), nil
}
前面的代码使用了 Go 函数的多返回能力来返回质量和错误值。你将在整本书中遇到这种习惯用法,作为向函数的调用者正确地传递错误的一种方式。在第五章 Go 中的函数中将进一步讨论多返回值函数。
包
包含 Go 函数的源文件可以进一步组织成称为包的目录结构。包是逻辑模块,用于在 Go 中共享代码作为库。你可以创建自己的本地包,或者使用 Go 提供的工具自动从源代码存储库中拉取和使用远程包。你将在第六章 Go 包和程序中学到更多关于 Go 包的知识。
工作空间
Go 遵循简单的代码布局约定,可靠地组织源代码包并管理其依赖关系。您的本地 Go 源代码存储在工作区中,这是一个包含源代码和运行时工件的目录约定。这使得 Go 工具可以自动找到、构建和安装已编译的二进制文件。此外,Go 工具依赖于workspace
设置来从远程存储库(如 Git、Mercurial 和 Subversion)中拉取源代码包,并满足其依赖关系。
强类型
Go 中的所有值都是静态类型的。但是,该语言提供了一个简单但富有表现力的类型系统,可以具有动态语言的感觉。例如,类型可以像下面的代码片段中那样被安全地推断出来:
const grams = 100.0
正如您所期望的,常量克会被 Go 类型系统分配一个数值类型,准确地说是float64
。这不仅适用于常量,而且任何变量都可以使用声明和赋值的简写形式,就像下面的示例中所示的那样:
package main
import "fmt"
func main() {
var name = "Metalloids"
var triple = [3]int{5,14,84}
elements := []string{"Boron","Silicon", "Polonium"}
isMetal := false
fmt.Println(name, triple, elements, isMetal)
}
请注意,在前面的代码片段中,变量没有明确分配类型。相反,类型系统根据赋值中的文字值为每个变量分配类型。第二章Go 语言基础和第四章数据类型更详细地介绍了 Go 类型。
复合类型
除了简单值的类型之外,Go 还支持复合类型,如array
、slice
和map
。这些类型旨在存储指定类型的索引元素的值。例如,前面显示的metalloid
示例使用了一个slice
,它是一个可变大小的数组。变量metalloid
被声明为一个slice
,用于存储类型为metalloid
的集合。该代码使用文字语法来组合声明和赋值一个slice
类型的metalloid
:
var metalloids = []metalloid{
metalloid{"Boron", 5, 10.81},
metalloid{"Silicon", 14, 28.085},
metalloid{"Germanium", 32, 74.63},
metalloid{"Arsenic", 33, 74.921},
metalloid{"Antimony", 51, 121.760},
metalloid{"Tellerium", 52, 127.60},
metalloid{"Polonium", 84, 209.0},
}
Go 还支持struct
类型,它是一个存储名为字段的命名元素的复合类型,如下面的代码所示:
func main() {
planet := struct {
name string
diameter int
}{"earth", 12742}
}
前面的示例使用文字语法声明了struct{name string; diameter int}
,其值为{"earth", 12742}
。您可以在第七章复合类型中了解有关复合类型的所有信息。
命名类型
正如讨论的那样,Go 提供了一组健全的内置类型,包括简单类型和复合类型。Go 程序员还可以根据现有基础类型定义新的命名类型,就像在前面的示例中从metalloid
中提取的代码片段所示的那样:
type amu float64
type metalloid struct {
name string
number int32
weight amu
}
前面的代码片段显示了两个命名类型的定义,一个称为amu
,它使用float64
类型作为其基础类型。另一方面,类型metalloid
使用struct
复合类型作为其基础类型,允许它在索引数据结构中存储值。您可以在第四章数据类型中了解更多关于声明新命名类型的信息。
方法和对象
Go 并不是传统意义上的面向对象语言。Go 类型不使用类层次结构来模拟世界,这与其他面向对象的语言不同。但是,Go 可以支持基于对象的开发习惯,允许数据接收行为。这是通过将函数(称为方法)附加到命名类型来实现的。
从 metalloid 示例中提取的以下代码片段显示了类型amu
接收了一个名为float()
的方法,该方法返回float64
值作为质量:
type amu float64
func (mass amu) float() float64 {
return float64(mass)
}
这个概念的强大之处在第八章方法、接口和对象中得到了详细探讨。
接口
Go 支持程序接口的概念。但是,正如您将在第八章,“方法、接口和对象”中看到的,Go 接口本身是一种类型,它聚合了一组可以将能力投射到其他类型值上的方法。忠实于其简单的本质,实现 Go 接口不需要使用关键字显式声明接口。相反,类型系统通过附加到类型的方法隐式解析实现的接口。
例如,Go 包括名为Stringer
的内置接口,定义如下:
type Stringer interface {
String() string
}
任何具有附加String()
方法的类型都会自动实现Stringer
接口。因此,修改前一个程序中类型metalloid
的定义,以附加String()
方法将自动实现Stringer
接口:
type metalloid struct {
name string
number int32
weight amu
}
func (m metalloid) String() string {
return fmt.Sprintf(
"%-10s %-10d %-10.3f %e",
m.name, m.number, m.weight.float(), atoms(moles(m.weight)),
)
}
golang.fyi/ch01/metalloids2.go
String()
方法返回表示metalloid
值的预格式化字符串。标准库包fmt
中的Print()
函数将自动调用方法String()
,如果其参数实现了stringer
。因此,我们可以利用这一点将metalloid
值打印如下:
func main() {
fmt.Print(headers())
for _, m := range metalloids {
fmt.Print(m, "\n")
}
}
再次参考第八章,“方法、接口和对象”,对接口主题进行深入讨论。
并发和通道
将 Go 推向当前采用水平的主要特性之一是其固有支持简单并发习语。该语言使用一种称为goroutine
的并发单元,它允许程序员使用独立和高度并发的代码结构化程序。
正如您将在以下示例中看到的,Go 还依赖于一种称为通道的构造,用于独立运行的goroutine
之间的通信和协调。这种方法避免了通过共享内存进行线程通信的危险和(有时脆弱的)传统方法。相反,Go 通过使用通道促进了通过通信共享的方法。下面的示例说明了使用goroutine
和通道作为处理和通信原语:
// Calculates sum of all multiple of 3 and 5 less than MAX value.
// See https://projecteuler.net/problem=1
package main
import (
"fmt"
)
const MAX = 1000
func main() {
work := make(chan int, MAX)
result := make(chan int)
// 1\. Create channel of multiples of 3 and 5
// concurrently using goroutine
go func(){
for i := 1; i < MAX; i++ {
if (i % 3) == 0 || (i % 5) == 0 {
work <- i // push for work
}
}
close(work)
}()
// 2\. Concurrently sum up work and put result
// in channel result
go func(){
r := 0
for i := range work {
r = r + i
}
result <- r
}()
// 3\. Wait for result, then print
fmt.Println("Total:", <- result)
}
golang.fyi/ch01/euler1.go
前面示例中的代码将要做的工作分成了两个并发运行的goroutine
(使用go
关键字声明),如代码注释所示。每个goroutine
都独立运行,并使用 Go 通道work
和result
来通信和协调计算最终结果。再次强调,如果这段代码一点也不清楚,放心,整个第九章,“并发”都专门讨论了并发。
内存管理和安全性
与其他编译和静态类型语言(如 C 和 C++)类似,Go 允许开发人员直接影响内存分配和布局。例如,当开发人员创建字节的slice
(类似array
)时,这些字节在机器的底层物理内存中有直接的表示。此外,Go 借用指针的概念来表示存储值的内存地址,使得 Go 程序可以支持通过值和引用传递函数参数。
Go 在内存管理周围设定了高度主观的安全屏障,几乎没有可配置的参数。Go 使用运行时垃圾收集器自动处理内存分配和释放的繁琐工作。指针算术在运行时不被允许;因此,开发人员不能通过增加或减少基本内存地址来遍历内存块。
快速编译
Go 的另一个吸引力是对中等规模项目的毫秒级构建时间。这得益于诸如简单的语法、无冲突的语法和严格的标识符解析等功能,这些功能禁止未使用的声明资源,如导入的包或变量。此外,构建系统使用依赖树中最近的源节点中存储的传递性信息来解析包。这再次使得代码-编译-运行周期更像是动态语言而不是编译语言。
测试和代码覆盖
虽然其他语言通常依赖于第三方工具进行测试,但 Go 包括专门用于自动化测试、基准测试和代码覆盖的内置 API 和工具。与 Go 中的其他功能类似,测试工具使用简单的约定自动检查和检测代码中找到的测试函数。
以下函数是欧几里德除法算法的简单实现,返回正整数的商和余数值(作为变量q
和r
):
func DivMod(dvdn, dvsr int) (q, r int) {
r = dvdn
for r >= dvsr {
q += 1
r = r - dvsr
}
return
}
golang.fyi/ch01/testexample/divide.go
在单独的源文件中,我们可以编写一个测试函数,通过使用 Go 测试 API 检查被测试函数返回的余数值来验证算法,如下面的代码所示:
package testexample
import "testing"
func TestDivide(t *testing.T) {
dvnd := 40
for dvsor := 1; dvsor < dvnd; dvsor++ {
q, r := DivMod(dvnd, dvsor)
if (dvnd % dvsor) != r {
t.Fatalf("%d/%d q=%d, r=%d, bad remainder.", dvnd, dvsor, q, r)
}
}
}
golang.fyi/ch01/testexample/divide_test.go
要运行测试源代码,只需按照以下示例运行 Go 的测试工具:
$> go test .
ok github.com/vladimirvivien/learning-go/ch01/testexample 0.003s
测试工具报告测试结果的摘要,指示已测试的包及其通过/失败的结果。Go 工具链配备了许多其他功能,旨在帮助程序员创建可测试的代码,包括:
-
在测试期间自动检测代码以收集覆盖统计信息
-
生成覆盖代码和测试路径的 HTML 报告
-
一个基准 API,允许开发人员从测试中收集性能指标
-
具有有价值的指标的基准报告,用于检测性能问题
您可以在第十二章代码测试中了解有关测试及其相关工具的所有信息。
文档
文档在 Go 中是一流的组件。可以说,该语言的流行部分原因是其广泛的文档(参见golang.org/pkg
)。Go 配备了 Godoc 工具,可以轻松地从源代码中直接嵌入的注释文本中提取文档。例如,要为上一节中的函数编写文档,我们只需在DivMod
函数上方直接添加注释行,如下例所示:
// DivMod performs a Eucledan division producing a quotient and remainder.
// This version only works if dividend and divisor > 0\.
func DivMod(dvdn, dvsr int) (q, r int) {
...
}
Go 文档工具可以自动提取和创建 HTML 格式的页面。例如,以下命令将在localhost 端口 6000
上启动 Godoc 工具作为服务器:
$> godoc -http=":6001"
然后,您可以直接从 Web 浏览器访问代码的文档。例如,以下图显示了位于http://localhost:6001/pkg/github.com/vladimirvivien/learning-go/ch01/testexample/
的先前函数的生成文档片段:
一个广泛的库
在其短暂的存在中,Go 迅速发展了一套高质量的 API 集合,作为其标准库的一部分,这些 API 与其他流行和更成熟的语言相媲美。以下列出了一些核心 API 的列表,当然这并不是详尽无遗的:
-
完全支持具有搜索和替换功能的正则表达式
-
用于读写字节的强大 IO 原语
-
完全支持网络编程,包括套接字、TCP/UDP、IPv4 和 IPv6
-
用于编写生产就绪的 HTTP 服务和客户端的 API
-
支持传统的同步原语(互斥锁、原子等)
-
具有 HTML 支持的通用模板框架
-
支持 JSON/XML 序列化
-
具有多种传输格式的 RPC
-
存档和压缩算法的 API:
tar
,zip
/gzip
,zlib
等 -
大多数主要算法和哈希函数的加密支持
-
访问操作系统级别的进程、环境信息、信号等等
Go 工具链
在我们结束本章之前,应该强调 Go 的一个方面,那就是它的工具集。虽然本章的前几节已经提到了一些工具,但其他工具在这里列出以供您了解:
-
fmt
:重新格式化源代码以符合标准 -
vet
:报告源代码构造的不当使用 -
lint
:另一个源代码工具,报告 flagrant 风格违规 -
goimports
:分析和修复源代码中的包导入引用 -
godoc
:生成和组织源代码文档 -
generate
:从存储在源代码中的指令生成 Go 源代码 -
get
:远程检索和安装包及其依赖项 -
build
:编译指定包及其依赖项中的代码 -
run
:提供编译和运行您的 Go 程序的便利 -
test
:执行单元测试,并支持基准和覆盖率报告 -
oracle
静态分析工具:查询源代码结构和元素 -
cgo
:生成用于 Go 和 C 之间互操作性的源代码
总结
在其相对较短的存在期内,Go 已经赢得了许多重视简单性的采用者的心。正如您从本章的前几节中所看到的,很容易开始编写您的第一个 Go 程序。
本章还向读者介绍了 Go 最重要特性的高级摘要,包括其简化的语法、对并发性的强调以及使 Go 成为软件工程师首选的工具,为数据中心计算时代创建系统。正如您所想象的那样,这只是即将到来的一小部分。
在接下来的章节中,本书将继续详细探讨使 Go 成为一个很棒的学习语言的语法元素和语言概念。让我们开始吧!
第二章:Go 语言基础
在前一章中,我们确定了使 Go 成为一个用于创建现代系统程序的优秀语言的基本特征。在本章中,我们将深入探讨语言的语法,以探索其组件和特性。
我们将涵盖以下主题:
-
Go 源文件
-
标识符
-
变量
-
常量
-
运算符
Go 源文件
我们在第一章中看到了一些 Go 程序的例子。在本节中,我们将研究 Go 源文件。让我们考虑以下源代码文件(它以不同的语言打印了"Hello World"
问候):
golang.fyi/ch02/helloworld2.go
一个典型的 Go 源文件,比如前面列出的那个,可以分为三个主要部分,如下所示:
- 包声明:
//1 Package Clause
package main
- 导入声明:
//2 Import Declaration
import "fmt"
import "math/rand"
import "time"
- 源代码主体:
//3 Source Body
var greetings = [][]string{
{"Hello, World!","English"},
...
}
func greeting() [] string {
...
}
func main() {
...
}
包声明指示了这个源文件所属的包的名称(参见第六章,Go 包和程序中对包组织的详细讨论)。导入声明列出了源代码希望使用的任何外部包。Go 编译器严格执行包声明的使用。在你的源文件中包含一个未使用的包被认为是一个错误(编译)。源文件的最后部分被认为是源文件的主体。在这里你声明变量、常量、类型和函数。
所有的 Go 源文件都必须以.go
后缀结尾。一般来说,你可以随意命名一个 Go 源文件。与 Java 不同,例如,Go 文件名和其内容中声明的类型之间没有直接关联。然而,将文件命名为与其内容相关的名称被认为是一个良好的做法。
在我们更详细地探讨 Go 的语法之前,了解语言的一些基本结构元素是很重要的。虽然其中一些元素在语法上被固定在语言中,但其他一些只是简单的习惯和约定,你应该了解这些以使你对 Go 的介绍简单而愉快。
可选的分号
你可能已经注意到,Go 不需要分号作为语句分隔符。这是从其他更轻量级和解释性语言中借鉴来的特点。以下两个程序在功能上是等价的。第一个程序使用了典型的 Go,并省略了分号:
程序的第二个版本,如下所示,使用了多余的分号来显式终止其语句。虽然编译器可能会感谢你的帮助,但这在 Go 中并不是惯用法:
尽管 Go 中的分号是可选的,但 Go 的正式语法仍要求它们作为语句终止符。因此,Go 编译器会在以下以以下结尾的源代码行末尾插入分号:
-
一个标识符
-
字符串、布尔、数字或复数的文字值
-
控制流指令,比如 break、continue 或 return
-
一个闭括号,比如
)
、}
或]
-
增量
++
或减量--
运算符
由于这些规则,编译器强制执行严格的语法形式,这严重影响了 Go 中源代码的风格。例如,所有的代码块必须以与其前一个语句相同行的开放大括号{
开始。否则,编译器可能会在破坏代码的位置插入分号,如下面的if
语句所示:
func main() {
if "a" == "a"
{
fmt.Println("Hello, World!")
}
}
将大括号移到下一行会导致编译器过早地插入分号,这将导致以下语法错误:
$> ... missing condition in if statement ...
这是因为编译器在 if
语句之后插入了分号(if "a"=="a";
),使用了本节讨论的分号插入规则。您可以通过在 if
条件语句之后手动插入分号来验证这一点;您将得到相同的错误。这是一个很好的过渡到下一节的地方,讨论代码块中的尾随逗号。
多行
将表达式分解为多行必须遵循前一节讨论的分号规则。主要是,在多行表达式中,每一行必须以一个标记结尾,以防止过早插入分号,如下表所示。应该注意的是,表中具有无效表达式的行将无法编译:
表达式 | 有效 |
---|
|
lonStr := "Hello World! " +
"How are you?"
是的,+ 运算符阻止了过早插入分号。 |
---|
|
lonStr := "Hello World! "
+ "How are you?"
不,第一行后会插入一个分号,语义上会断开这一行。 |
---|
|
fmt.Printf("[%s] %d %d %v",
str,
num1,
num2,
nameMap)
是的,逗号正确地断开了表达式。 |
---|
|
fmt.Printf("[%s] %d %d %v",
str,
num1,
num2,
nameMap)
是的,编译器只在最后一行后插入了一个分号。 |
---|
|
weekDays := []string{
"Mon", "Tue",
"Wed", "Thr",
"Fri"
}
不,Fri 行导致了过早插入分号。 |
---|
|
weekDays2 := []string{
"Mon", "Tue",
"Wed", "Thr",
"Fri",
}
是的,Fri 行包含了一个尾随逗号,这导致编译器在下一行插入了一个分号。 |
---|
weekDays1 := []string{``"Mon", "Tue",``"Wed", "Thr",``"Fri"} |
您可能会想为什么 Go 编译器要求开发人员提供换行提示来指示语句的结束。当然,Go 的设计者本可以设计一个复杂的算法来自动解决这个问题。是的,他们可以。然而,通过保持语法简单和可预测,编译器能够快速解析和编译 Go 源代码。
注意
Go 工具链包括 gofmt 工具,可以用于一致地应用正确的格式规则到您的源代码。还有 govet
工具,它通过分析代码元素的结构问题,可以更深入地分析您的代码。
Go 标识符
Go 标识符用于命名程序元素,包括包、变量、函数和类型。以下总结了 Go 中标识符的一些属性:
-
标识符支持 Unicode 字符集
-
标识符的第一个位置必须是字母或下划线
-
惯用的 Go 喜欢混合大小写(驼峰命名)
-
包级别的标识符必须在给定包中是唯一的
-
标识符必须在代码块(函数、控制语句)内是唯一的
空白标识符
Go 编译器对于变量或包的声明标识符的使用特别严格。基本规则是:你声明了它,你必须使用它。如果您尝试编译带有未使用的标识符(如变量或命名包)的代码,编译器将不会满意并且编译失败。
Go 允许您使用空白标识符(表示为 _
(下划线)字符)关闭此行为。使用空白标识符的任何声明或赋值都不绑定到任何值,并且在编译时会被忽略。空白标识符通常用于以下两个上下文中,如下一小节中所列出的。
消除包导入
当包声明之前有一个下划线时,编译器允许声明该包而不需要进一步引用:
import "fmt"
import "path/filepath"
import _ "log"
在前面的代码片段中,包 log
将在代码中没有进一步引用的情况下被消除。这在开发新代码时可能是一个方便的功能,开发人员可能希望尝试新的想法,而不必不断地注释或删除声明。尽管具有空白标识符的包不绑定到任何引用,但 Go 运行时仍会初始化它。第六章,Go 包和程序,讨论了包初始化的生命周期。
消除不需要的函数结果
当 Go 函数调用返回多个值时,返回列表中的每个值都必须分配给一个变量标识符。然而,在某些情况下,可能希望消除返回列表中不需要的结果,同时保留其他结果,如下所示:
_, execFile := filepath.Split("/opt/data/bigdata.txt")
先前对函数filepath.Split("/opt/data/bigdata.txt")
的调用接受一个路径并返回两个值:第一个是父路径(/opt/data
),第二个是文件名(bigdata.txt
)。第一个值被分配给空白标识符,因此未绑定到命名标识符,这导致编译器忽略它。在未来的讨论中,我们将探讨这种习惯用法在其他上下文中的其他用途,比如错误处理和for
循环。
内置标识符
Go 带有许多内置标识符。它们属于不同的类别,包括类型、值和内置函数。
类型
以下标识符用于 Go 的内置类型:
类别 | 标识符 |
---|---|
数字 | byte ,int ,int8 ,int16 ,int32 ,int64 ,rune ,uint ,uint8 ,uint16 ,uint32 ,uint64 ,float32 ,float64 ,complex64 ,complex128 ,uintptr |
字符串 | string |
布尔 | bool |
错误 | error |
值
这些标识符具有预分配的值:
类别 | 标识符 |
---|---|
布尔常量 | true ,false |
常量计数器 | iota |
未初始化值 | nil |
函数
以下函数作为 Go 的内置预声明标识符的一部分可用:
类别 | 标识符 |
---|---|
初始化 | make() ,new() |
集合 | append() ,cap() ,copy() ,delete() |
复数 | complex() ,imag() ,real() |
错误处理 | panic() ,recover() |
Go 变量
Go 是一种严格类型的语言,这意味着所有变量都是绑定到值和类型的命名元素。正如你将看到的,它的语法的简单性和灵活性使得在 Go 中声明和初始化变量更像是一种动态类型的语言。
变量声明
在 Go 中使用变量之前,必须使用命名标识符声明它以便在代码中将来引用。在 Go 中变量声明的长格式遵循以下格式:
*var <identifier list> <type>*
var
关键字用于声明一个或多个变量标识符,后面跟着变量的类型。以下源代码片段显示了一个缩写程序,其中声明了几个变量,这些变量在main()
函数之外声明:
package main
import "fmt"
var name, desc string
var radius int32
var mass float64
var active bool
var satellites []string
func main() {
name = "Sun"
desc = "Star"
radius = 685800
mass = 1.989E+30
active = true
satellites = []string{
"Mercury",
"Venus",
"Earth",
"Mars",
"Jupiter",
"Saturn",
"Uranus",
"Neptune",
}
fmt.Println(name)
fmt.Println(desc)
fmt.Println("Radius (km)", radius)
fmt.Println("Mass (kg)", mass)
fmt.Println("Satellites", satellites)
}
golang.fyi/ch02/vardec1.go
零值
先前的源代码显示了使用各种类型声明变量的几个示例。然后在main()
函数内为变量赋值。乍一看,这些声明的变量在声明时似乎没有被赋值。这将与我们先前的断言相矛盾,即所有 Go 变量都绑定到类型和值。
我们如何声明一个变量而不将值绑定到它?在变量声明期间,如果没有提供值,Go 将自动将默认值(或零值)绑定到变量以进行适当的内存初始化(我们稍后将看到如何在一个表达式中进行声明和初始化)。
以下表格显示了 Go 类型及其默认零值:
类型 | 零值 |
---|---|
string |
"" (空字符串) |
数字 - 整数:byte ,int ,int8 ,int16 ,int32 ,int64 ,rune ,uint ,uint8 ,uint16 ,uint32 ,uint64 ,uintptr |
0 |
数字 - 浮点数:float32 ,float64 |
0.0 |
bool |
false |
Array |
每个索引位置都有一个与数组元素类型相对应的零值。 |
Struct |
一个空的struct ,每个成员都具有其相应的零值。 |
其他类型:接口、函数、通道、切片、映射和指针 | nil |
初始化声明
如前所述,Go 还支持使用以下格式将变量声明和初始化组合为一个表达式:
var <标识符列表> <类型> = <值列表或初始化表达式>
这种声明格式具有以下特性:
-
等号左侧提供的标识符列表(后跟类型)
-
右侧有匹配的逗号分隔值列表
-
赋值按标识符和值的相应顺序进行
-
初始化表达式必须产生匹配的值列表
以下是声明和初始化组合的简化示例:
var name, desc string = "Earth", "Planet"
var radius int32 = 6378
var mass float64 = 5.972E+24
var active bool = true
var satellites = []string{
"Moon",
}
golang.fyi/ch02/vardec2.go
省略变量类型
到目前为止,我们已经讨论了 Go 的变量声明和初始化的长格式。为了使语言更接近其动态类型的表亲,可以省略类型规范,如下所示:
var <标识符列表> = <值列表或初始化表达式>
在编译期间,编译器根据等号右侧的赋值或初始化表达式推断变量的类型,如下例所示。
var name, desc = "Mars", "Planet"
var radius = 6755
var mass = 641693000000000.0
var active = true
var satellites = []string{
"Phobos",
"Deimos",
}
golang.fyi/ch02/vardec3.go
如前所述,当变量被赋值时,必须同时接收一个类型和该值。当省略变量的类型时,类型信息是从分配的值或表达式的返回值中推断出来的。以下表格显示了给定文字值时推断出的类型:
文字值 | 推断类型 |
---|---|
双引号或单引号(原始)文本:"火星行星"``"所有行星都围绕太阳运转。" |
string |
整数:-76 01244``1840 |
int |
小数:-0.25``4.0``3.1e4``7e-12 |
float64 |
复数:-5.0i``3i``(0+4i) |
complex128 |
布尔值:true``false |
bool |
数组值:[2]int{-76, 8080} |
在文字值中定义的数组 类型。在这种情况下是:[2]int |
映射值:map[string]int{`` "Sun": 685800,`` "Earth": 6378,`` "Mars": 3396,``} |
在文字值中定义的映射类型。在这种情况下是:map[string]int |
切片值:[]int{-76, 0, 1244, 1840} |
在文字值中定义的切片 类型:[]int |
结构值:struct{`` name string`` diameter int}``{`` "Mars", 3396,``} |
在文字值中定义的结构 类型。在这种情况下,类型是:struct{name string; diameter int} |
函数值:var sqr = func (v int) int {`` return v * v``} |
在函数定义文字中定义的函数类型。在这种情况下,变量sqr 的类型将是:func (v int) int |
短变量声明
Go 可以进一步减少变量声明语法,使用短变量声明格式。在这种格式中,声明不再使用 var 关键字和类型规范,而是使用赋值运算符:=
(冒号等于),如下所示:
<标识符列表> := <值列表或初始化表达式>
这是一个简单而清晰的习惯用语,在 Go 中声明变量时通常使用。以下代码示例显示了短变量声明的用法:
func main() {
name := "Neptune"
desc := "Planet"
radius := 24764
mass := 1.024e26
active := true
satellites := []string{
"Naiad", "Thalassa", "Despina", "Galatea", "Larissa",
"S/2004 N 1", "Proteus", "Triton", "Nereid", "Halimede",
"Sao", "Laomedeia", "Neso", "Psamathe",
}
...
}
golang.fyi/ch02/vardec4.go
请注意,关键字var
和变量类型在声明中被省略。短变量声明使用了先前讨论的相同机制来推断变量的类型。
短变量声明的限制
为了方便起见,变量声明的简短形式确实带有一些限制,您应该注意以避免混淆:
-
首先,它只能在函数块内使用
-
赋值运算符
:=
,声明变量并赋值 -
:=
不能用于更新先前声明的变量 -
变量的更新必须使用等号
尽管这些限制可能有其根源于 Go 语法的简单性的理由,但它们通常被视为对语言新手的一个困惑来源。例如,冒号等号运算符不能与包级别的变量赋值一起使用。学习 Go 的开发人员可能会发现使用赋值运算符来更新变量是一种诱人的方式,但这将导致编译错误。
变量作用域和可见性
Go 使用基于代码块的词法作用域来确定包内变量的可见性。根据变量声明的位置在源文本中,将确定其作用域。一般规则是,变量只能在声明它的块内访问,并对所有嵌套的子块可见。
以下截图说明了在源文本中声明的几个变量的作用域(package
,function
,for
循环和if...else
块):
golang.fyi/ch02/makenums.go
如前所述,变量的可见性是自上而下的。包范围的变量,如mapFile
和numbersFile
,对包中的所有其他元素都是全局可见的。向下移动作用域梯级,函数块变量,如data
和err
,对函数中的所有元素以及包括子块在内的所有元素都是可见的。内部for
循环块中的变量i
和b
只在该块内可见。一旦循环结束,i
和b
就会超出作用域。
注意
对于 Go 的新手来说,包范围变量的可见性是一个令人困惑的问题。当一个变量在包级别(在函数或方法块之外)声明时,它对整个包都是全局可见的,而不仅仅是变量声明的源文件。这意味着包范围的变量标识符只能在组成包的文件组中声明一次,这一点对于刚开始使用 Go 的开发人员可能并不明显。有关包组织的详细信息,请参阅第六章,“Go 包和程序”。
变量声明块
Go 的语法允许将顶级变量的声明组合到块中,以提高可读性和代码组织性。以下示例展示了使用变量声明块重写先前示例的方式:
var (
name string = "Earth"
desc string = "Planet"
radius int32 = 6378
mass float64 = 5.972E+24
active bool = true
satellites []string
)
golang.fyi/ch02/vardec5.go
Go 常量
在 Go 中,常量是具有文字表示的值,例如文本字符串,布尔值或数字。常量的值是静态的,不能在初始赋值后更改。尽管它们所代表的概念很简单,但常量具有一些有趣的属性,使它们在处理数值时特别有用。
常量文字
常量是可以用语言中的文本文字表示的值。常量最有趣的一个属性是它们的文字表示可以被视为有类型或无类型的值。与变量不同,常量可以以无类型值的形式存储在内存空间中。没有类型约束,例如,数值常量值可以以极高的精度存储。
以下是可以在 Go 中表示的有效常量文字值的示例:
"Mastering Go"
'G'
false
111009
2.71828
94314483457513374347558557572455574926671352 1e+500
5.0i
有类型的常量
Go 常量值可以使用常量声明绑定到命名标识符。与变量声明类似,Go 使用const
关键字来指示常量的声明。但是,与变量不同,声明必须包括要绑定到标识符的文字值,如下所示:
const <标识符列表> 类型 = <值列表或初始化表达式>
常量不能有任何需要运行时解析的依赖关系。编译器必须能够在编译时解析常量的值。这意味着所有常量必须声明并用值文字(或导致常量值的表达式)初始化。
以下代码片段显示了一些已声明的有类型常量:
const a1, a2 string = "Mastering", "Go"
const b rune = 'G'
const c bool = false
const d int32 = 111009
const e float32 = 2.71828
const f float64 = math.Pi * 2.0e+3
const g complex64 = 5.0i
const h time.Duration = 4 * time.Second
golang.fyi/ch02/const.go
请注意在前面的源代码片段中,每个声明的常量标识符都明确给出了一个类型。正如您所期望的那样,这意味着常量标识符只能在与其类型兼容的上下文中使用。然而,下一节将解释当常量声明中省略类型时,这是如何工作的。
无类型常量
当无类型常量时,常量声明如下:
const <标识符列表> = <值列表或初始化表达式>
与以前一样,关键字const
用于声明一系列标识符作为常量以及它们的相应的边界值。然而,在这种格式中,类型规范在声明中被省略。作为一个无类型实体,常量只是内存中的一块字节,没有任何类型精度限制。以下显示了一些无类型常量的示例声明:
const i = "G is" + " for Go "
const j = 'V'
const k1, k2 = true, !k1
const l = 111*100000 + 9
const m1 = math.Pi / 3.141592
const m2 = 1.414213562373095048801688724209698078569671875376...
const m3 = m2 * m2
const m4 = m3 * 1.0e+400
const n = -5.0i * 3
const o = time.Millisecond * 5
golang.fyi/ch02/const.go
从前面的代码片段中,无类型常量m2
被分配了一个长的十进制值(截断以适应打印页面,因为它还有另外 17 位数字)。常量m4
被分配了一个更大的数字m3 x 1.0e+400
。生成常量的整个值存储在内存中,没有任何精度损失。这对于对精度要求很高的计算感兴趣的开发人员来说可能是一个非常有用的工具。
分配无类型常量
无类型常量值在分配给变量、用作函数参数或作为分配给变量的表达式的一部分之前是有限的。在像 Go 这样的强类型语言中,这意味着可能需要进行一些类型调整,以确保存储在常量中的值可以正确地分配给目标变量。使用无类型常量的一个优点是,类型系统放宽了对类型检查的严格应用。无类型常量可以被分配给不同但兼容的不同精度的类型,而不会引起编译器的任何投诉,如下例所示:
const m2 = 1.414213562373095048801688724209698078569671875376...
var u1 float32 = m2
var u2 float64 = m2
u3 := m2
前面的代码片段显示了无类型常量m2
被分配给两个不同浮点精度的变量u1
和u2
,以及一个无类型变量u3
。这是可能的,因为常量m2
被存储为一个原始的无类型值,因此可以分配给与其表示兼容的任何变量(一个浮点数)。
虽然类型系统将容纳m2
分配给不同精度的变量,但所得到的分配将被调整以适应变量类型,如下所示:
u1 = 1.4142135 //float32
u2 = 1.4142135623730951 //float64
那么变量u3
呢,它本身是一个无类型变量?由于u3
没有指定类型,它将依赖于常量值的类型推断来接收类型分配。回想一下之前在省略变量类型部分的讨论,常量文字根据它们的文本表示映射到基本的 Go 类型。由于常量m2
表示一个十进制值,编译器将推断其默认为float64
,这将自动分配给变量u3
,如下所示:
U3 = 1.4142135623730951 //float64
正如您所看到的,Go 对无类型原始常量文字的处理通过自动应用一些简单但有效的类型推断规则,增加了语言的可用性,而不会牺牲类型安全性。与其他语言不同,开发人员不必在值文字中明确指定类型或执行某种类型转换来使其工作。
常量声明块
正如您可能已经猜到的那样,常量声明可以组织为代码块以增加可读性。前面的示例可以重写如下:
const (
a1, a2 string = "Mastering", "Go"
b rune = 'G'
c bool = false
d int32 = 111009
e float32 = 2.71828
f float64 = math.Pi * 2.0e+3
g complex64 = 5.0i
h time.Duration = 4 * time.Second
...
)
golang.fyi/ch02/const2.go
常量枚举
常量的一个有趣用法是创建枚举值。使用声明块格式(在前面的部分中显示),您可以轻松地创建数字递增的枚举整数值。只需将预先声明的常量值iota
分配给声明块中的常量标识符,如下面的代码示例所示:
const (
StarHyperGiant = iota
StarSuperGiant
StarBrightGiant
StarGiant
StarSubGiant
StarDwarf
StarSubDwarf
StarWhiteDwarf
StarRedDwarf
StarBrownDwarf
)
golang.fyi/ch02/enum0.go
然后编译器会自动执行以下操作:
-
将块中的每个成员声明为无类型整数常量值
-
用值 0 初始化
iota
-
将
iota
或零分配给第一个常量成员(StarHyperGiant
) -
每个后续常量都被分配一个增加了一的
int
值
因此,以前的常量列表将被分配一个从零到九的值序列。每当const
出现为声明块时,它将计数器重置为零。在下面的代码片段中,每组常量都分别从零到四进行枚举:
const (
StarHyperGiant = iota
StarSuperGiant
StarBrightGiant
StarGiant
StarSubGiant
)
const (
StarDwarf = iota
StarSubDwarf
StarWhiteDwarf
StarRedDwarf
StarBrownDwarf
)
golang.fyi/ch02/enum1.go
覆盖默认枚举类型
默认情况下,枚举常量被声明为无类型整数值。但是,您可以通过为枚举常量提供显式数字类型来覆盖枚举值的默认类型,如下面的代码示例所示:
const (
StarDwarf byte = iota
StarSubDwarf
StarWhiteDwarf
StarRedDwarf
StarBrownDwarf
)
您可以指定可以表示整数或浮点值的任何数字类型。例如,在前面的代码示例中,每个常量将被声明为类型byte
。
在表达式中使用 iota
当iota
出现在表达式中时,相同的机制会按预期工作。编译器将对每个递增的iota
值应用表达式。以下示例将偶数分配给常量声明块的枚举成员:
const (
StarHyperGiant = 2.0*iota
StarSuperGiant
StarBrightGiant
StarGiant
StarSubGiant
)
golang.fyi/ch02/enum2.go
正如您所期望的那样,前面的示例为每个枚举常量分配了一个偶数值,从 0 开始,如下面的输出所示:
StarHyperGiant = 0 [float64]
StarSuperGiant = 2 [float64]
StarBrightGiant = 4 [float64]
StarGiant = 6 [float64]
StarSubGiant = 8 [float64]
跳过枚举值
在使用枚举常量时,您可能希望丢弃不应成为枚举一部分的某些值。这可以通过将 iota 分配给枚举中所需位置的空白标识符来实现。例如,以下内容跳过了值 0 和64
:
_ = iota // value 0
StarHyperGiant = 1 << iota
StarSuperGiant
StarBrightGiant
StarGiant
StarSubGiant
_ // value 64
StarDwarf
StarSubDwarf
StarWhiteDwarf
StarRedDwarf
StarBrownDwarf
golang.fyi/ch02/enum3.go
由于我们跳过了iota
位置0
,第一个分配的常量值位于位置1
。这导致表达式1 << iota
解析为1 << 1 = 2
。在第六个位置也是同样的情况,表达式1 << iota
返回64
。该值将被跳过,不会被分配给任何常量,如下面的输出所示:
StarHyperGiant = 2
StarSuperGiant = 4
StarBrightGiant = 8
StarGiant = 16
StarSubGiant = 32
StarDwarf = 128
StarSubDwarf = 256
StarWhiteDwarf = 512
StarRedDwarf = 1024
StarBrownDwarf = 2048
Go 运算符
忠实于其简单的本质,Go 中的运算符确切地执行您所期望的操作,主要是允许操作数组合成表达式。与 C++或 Scala 中发现的运算符重载不同,Go 运算符没有隐藏的意外行为。这是设计者故意做出的决定,以保持语言的语义简单和可预测。
本节探讨了您在开始使用 Go 时会遇到的最常见的运算符。其他运算符将在本书的其他章节中介绍。
算术运算符
以下表总结了 Go 中支持的算术运算符。
运算符 | 操作 | 兼容类型 |
---|---|---|
* ,/ ,- |
乘法,除法和减法 | 整数,浮点数和复数 |
% |
余数 | 整数 |
加法 | 整数,浮点数,复数和字符串(连接) |
请注意,加法运算符+
可以应用于字符串,例如表达式var i = "G is" + " for Go"
。这两个字符串操作数被连接以创建一个新的字符串,该字符串被分配给变量i
。
增量和减量运算符
与其他类似 C 的语言一样,Go 支持++
(增量)和--
(减量)运算符。当应用时,这些运算符分别增加或减少操作数的值。以下是一个使用减量运算符以相反顺序遍历字符串 s 中的字母的函数示例:
func reverse(s string) {
for i := len(s) - 1; i >= 0; {
fmt.Print(string(s[i]))
i--
}
}
重要的是要注意,增量和减量运算符是语句,而不是表达式,如下面的示例所示:
nextChar := i++ // syntax error
fmt.Println("Current char", i--) // syntax error
nextChar++ // OK
在前面的示例中,值得注意的是增量和减量语句只支持后缀表示法。以下代码段不会编译,因为有语句-i
:
for i := len(s) - 1; i >= 0; {
fmt.Print(string(s[i]))
--i //syntax error
}
Go 赋值运算符
运算符 | 描述 |
---|---|
= |
简单赋值按预期工作。它使用右侧的值更新左侧的操作数。 |
:= |
冒号等号运算符声明一个新变量,左侧操作数,并将其赋值为右侧操作数的值(和类型)。 |
+= , -= , *= , /= , %= |
使用左操作数和右操作数应用指定的操作,并将结果存储在左操作数中。例如,a *= 8 意味着a = a * 8 。 |
位运算符
Go 包括对操作值的最基本形式的完全支持。以下总结了 Go 支持的位运算符:
运算符 | 描述 |
---|---|
& |
位与 |
| |
位或 |
a ^ b |
位异或 |
&^ |
位清空 |
^a |
一元位补码 |
<< |
左移 |
右移 |
在移位操作中,右操作数必须是无符号整数或能够转换为无符号值。当左操作数是无类型常量值时,编译器必须能够从其值中推导出有符号整数类型,否则将无法通过编译。
Go 中的移位运算符也支持算术和逻辑移位。如果左操作数是无符号的,Go 会自动应用逻辑移位,而如果它是有符号的,Go 将应用算术移位。
逻辑运算符
以下是关于布尔值的 Go 逻辑操作的列表:
运算符 | 操作 |
---|---|
&& |
逻辑与 |
|| |
逻辑或 |
! |
逻辑非 |
比较运算符
所有 Go 类型都可以进行相等性测试,包括基本类型和复合类型。然而,只有字符串、整数和浮点值可以使用排序运算符进行比较,如下表所总结的:
运算符 | 操作 | 支持的类型 |
---|---|---|
== |
相等 | 字符串、数字、布尔、接口、指针和结构类型 |
!= |
不等 | 字符串、数字、布尔、接口、指针和结构类型 |
< , <= , > , >= |
排序运算符 | 字符串、整数和浮点数 |
运算符优先级
由于 Go 的运算符比 C 或 Java 等语言中的运算符要少,因此其运算符优先级规则要简单得多。以下表格列出了 Go 的运算符优先级,从最高开始:
操作 | 优先级 |
---|---|
乘法 | * , / , % , << , >> , & , &^ |
加法 | + , - , | , ^ |
比较 | == , != , < , <= , > , >= |
逻辑与 | && |
逻辑或 | || |
总结
本章涵盖了 Go 语言的基本构造的许多内容。它从 Go 源代码文本文件的结构开始,并逐步介绍了变量标识符、声明和初始化。本章还广泛介绍了 Go 常量、常量声明和运算符。
此时,您可能会对语言及其语法的如此多的基本信息感到有些不知所措。好消息是,您不必了解所有这些细节才能有效地使用该语言。在接下来的章节中,我们将继续探讨关于 Go 的一些更有趣的部分,包括数据类型、函数和包。
第三章:Go 控制流
Go 从 C 语言家族中借用了几种控制流语法。它支持所有预期的控制结构,包括 if...else、switch、for 循环,甚至 goto。然而,明显缺少的是 while 或 do...while 语句。本章中的以下主题将讨论 Go 的控制流元素,其中一些您可能已经熟悉,而其他一些则带来了其他语言中没有的一组新功能:
-
if 语句
-
switch 语句
-
类型 Switch
-
for 语句
if 语句
在 Go 中,if 语句从其他类似 C 的语言中借用了其基本结构形式。当跟随 if 关键字的布尔表达式求值为 true 时,该语句有条件地执行代码块,如下面简化的程序所示,该程序显示有关世界货币的信息:
import "fmt"
type Currency struct {
Name string
Country string
Number int
}
var CAD = Currency{
Name: "Canadian Dollar",
Country: "Canada",
Number: 124}
var FJD = Currency{
Name: "Fiji Dollar",
Country: "Fiji",
Number: 242}
var JMD = Currency{
Name: "Jamaican Dollar",
Country: "Jamaica",
Number: 388}
var USD = Currency{
Name: "US Dollar",
Country: "USA",
Number: 840}
func main() {
num0 := 242
if num0 > 100 || num0 < 900 {
fmt.Println("Currency: ", num0)
printCurr(num0)
} else {
fmt.Println("Currency unknown")
}
if num1 := 388; num1 > 100 || num1 < 900 {
fmt.Println("Currency:", num1)
printCurr(num1)
}
}
func printCurr(number int) {
if CAD.Number == number {
fmt.Printf("Found: %+v\n", CAD)
} else if FJD.Number == number {
fmt.Printf("Found: %+v\n", FJD)
} else if JMD.Number == number {
fmt.Printf("Found: %+v\n", JMD)
} else if USD.Number == number {
fmt.Printf("Found: %+v\n", USD)
} else {
fmt.Println("No currency found with number", number)
}
}
golang.fyi/ch03/ifstmt.go
Go 中的 if 语句看起来与其他语言相似。但是,它摒弃了一些语法规则,同时强制执行了一些新规则:
- 在测试表达式周围的括号是不必要的。虽然以下 if 语句将编译,但这不是惯用法:
if (num0 > 100 || num0 < 900) {
fmt.Println("Currency: ", num0)
printCurr(num0)
}
- 使用以下代替:
if num0 > 100 || num0 < 900 {
fmt.Println("Currency: ", num0)
printCurr(num0)
}
- 代码块的大括号始终是必需的。以下代码片段将无法编译:
if num0 > 100 || num0 < 900 printCurr(num0)
- 然而,这将编译通过:
if num0 > 100 || num0 < 900 {printCurr(num0)}
- 然而,惯用的、更清晰的编写 if 语句的方式是使用多行(无论语句块有多简单)。以下代码片段将无问题地编译通过:
if num0 > 100 || num0 < 900 {printCurr(num0)}
- 然而,语句的首选惯用布局是使用多行,如下所示:
if num0 > 100 || num0 < 900 {
printCurr(num0)
}
- if 语句可以包括一个可选的 else 块,当 if 块中的表达式求值为 false 时执行。else 块中的代码必须使用多行用大括号括起来,如下面的代码片段所示:
if num0 > 100 || num0 < 900 {
fmt.Println("Currency: ", num0)
printCurr(num0)
} else {
fmt.Println("Currency unknown")
}
- else 关键字后面可以紧接着另一个 if 语句,形成 if...else...if 链,就像前面列出的源代码中的 printCurr()函数中使用的那样:
if CAD.Number == number {
fmt.Printf("Found: %+v\n", CAD)
} else if FJD.Number == number {
fmt.Printf("Found: %+v\n", FJD)
}
if...else...if 语句链可以根据需要增加,并且可以通过可选的 else 语句来终止,以表达所有其他未经测试的条件。同样,这是在 printCurr()函数中完成的,该函数使用 if...else...if 块测试四个条件。最后,它包括一个 else 语句块来捕获任何其他未经测试的条件:
func printCurr(number int) {
if CAD.Number == number {
fmt.Printf("Found: %+v\n", CAD)
} else if FJD.Number == number {
fmt.Printf("Found: %+v\n", FJD)
} else if JMD.Number == number {
fmt.Printf("Found: %+v\n", JMD)
} else if USD.Number == number {
fmt.Printf("Found: %+v\n", USD)
} else {
fmt.Println("No currency found with number", number)
}
}
然而,在 Go 中,编写这样深层 if...else...if 代码块的惯用且更清晰的方式是使用无表达式的 switch 语句。这将在Switch 语句部分中介绍。
if 语句初始化
if 语句支持复合语法,其中被测试的表达式前面有一个初始化语句。在运行时,初始化在评估测试表达式之前执行,如前面列出的程序中所示:
if num1 := 388; num1 > 100 || num1 < 900 {
fmt.Println("Currency:", num1)
printCurr(num1)
}
初始化语句遵循正常的变量声明和初始化规则。初始化变量的作用域绑定到 if 语句块,超出该范围后就无法访问。这是 Go 中常用的习惯用法,并且在本章中涵盖的其他流程控制结构中也得到支持。
Switch 语句
Go 还支持类似于 C 或 Java 等其他语言中的 switch 语句。Go 中的 switch 语句通过评估 case 子句中的值或表达式来实现多路分支,如下面简化的源代码所示:
import "fmt"
type Curr struct {
Currency string
Name string
Country string
Number int
}
var currencies = []Curr{
Curr{"DZD", "Algerian Dinar", "Algeria", 12},
Curr{"AUD", "Australian Dollar", "Australia", 36},
Curr{"EUR", "Euro", "Belgium", 978},
Curr{"CLP", "Chilean Peso", "Chile", 152},
Curr{"EUR", "Euro", "Greece", 978},
Curr{"HTG", "Gourde", "Haiti", 332},
...
}
func isDollar(curr Curr) bool {
var bool result
switch curr {
default:
result = false
case Curr{"AUD", "Australian Dollar", "Australia", 36}:
result = true
case Curr{"HKD", "Hong Kong Dollar", "Hong Koong", 344}:
result = true
case Curr{"USD", "US Dollar", "United States", 840}:
result = true
}
return result
}
func isDollar2(curr Curr) bool {
dollars := []Curr{currencies[2], currencies[6], currencies[9]}
switch curr {
default:
return false
case dollars[0]:
fallthrough
case dollars[1]:
fallthrough
case dollars[2]:
return true
}
return false
}
func isEuro(curr Curr) bool {
switch curr {
case currencies[2], currencies[4], currencies[10]:
return true
default:
return false
}
}
func main() {
curr := Curr{"EUR", "Euro", "Italy", 978}
if isDollar(curr) {
fmt.Printf("%+v is Dollar currency\n", curr)
} else if isEuro(curr) {
fmt.Printf("%+v is Euro currency\n", curr)
} else {
fmt.Println("Currency is not Dollar or Euro")
}
dol := Curr{"HKD", "Hong Kong Dollar", "Hong Koong", 344}
if isDollar2(dol) {
fmt.Println("Dollar currency found:", dol)
}
}
golang.fyi/ch03/switchstmt.go
Go 中的 switch 语句具有一些有趣的属性和规则,使其易于使用和理解:
-
从语义上讲,Go 的 switch 语句可以在两个上下文中使用:
-
表达式 switch 语句
-
类型 switch 语句
-
break 语句可以用于提前跳出 switch 代码块。
-
当没有其他 case 表达式评估为匹配时,
switch
语句可以包括一个默认 case。只能有一个默认 case,并且可以放置在 switch 块的任何位置。
使用表达式开关
表达式开关是灵活的,可以在程序控制流需要遵循多个路径的许多上下文中使用。表达式开关支持许多属性,如下面的要点所述:
- 表达式开关可以测试任何类型的值。例如,以下代码片段(来自前面的程序清单)测试了类型为
struct
的变量Curr
:
func isDollar(curr Curr) bool {
var bool result
switch curr {
default:
result = false
case Curr{"AUD", "Australian Dollar", "Australia", 36}:
result = true
case Curr{"HKD", "Hong Kong Dollar", "Hong Koong", 344}:
result = true
case Curr{"USD", "US Dollar", "United States", 840}:
result = true
}
return result
}
-
case
子句中的表达式从左到右、从上到下进行评估,直到找到与switch
表达式相等的值(或表达式)为止。 -
遇到与
switch
表达式匹配的第一个 case 时,程序将执行case
块的语句,然后立即退出switch
块。与其他语言不同,Go 的case
语句不需要使用break
来避免下一个 case 的穿透(参见Fallthrough cases部分)。例如,调用isDollar(Curr{"HKD", "Hong Kong Dollar", "Hong Kong", 344})
将匹配前面函数中的第二个case
语句。代码将将结果设置为true
并立即退出switch
代码块。 -
Case
子句可以有多个值(或表达式),用逗号分隔,它们之间隐含着逻辑OR
运算符。例如,在以下片段中,switch
表达式curr
被测试与值currencies[2]
、currencies[4]
或currencies[10]
,使用一个 case 子句,直到找到匹配:
func isEuro(curr Curr) bool {
switch curr {
case currencies[2], currencies[4], currencies[10]:
return true
default:
return false
}
}
switch
语句是在 Go 中编写复杂条件语句的更清晰和首选的惯用方法。当前面的片段与使用if
语句进行相同比较时,这一点是明显的:
func isEuro(curr Curr) bool {
if curr == currencies[2] || curr == currencies[4],
curr == currencies[10]{
return true
}else{
return false
}
}
穿透案例
在 Go 的case
子句中没有自动的穿透,就像 C 或 Java 的switch
语句中一样。回想一下,一个switch
块在执行完第一个匹配的 case 后会退出。代码必须明确地将fallthrough
关键字放在case
块的最后一个语句,以强制执行流程穿透到连续的case
块。以下代码片段显示了一个switch
语句,其中每个 case 块都有一个fallthrough
:
func isDollar2(curr Curr) bool {
switch curr {
case Curr{"AUD", "Australian Dollar", "Australia", 36}:
fallthrough
case Curr{"HKD", "Hong Kong Dollar", "Hong Kong", 344}:
fallthrough
case Curr{"USD", "US Dollar", "United States", 840}:
return true
default:
return false
}
}
golang.fyi/ch03/switchstmt.go
当匹配到一个 case 时,fallthrough
语句会级联到连续case
块的第一个语句。因此,如果curr = Curr{"AUD", "Australian Dollar", "Australia", 36}
,第一个 case 将被匹配。然后流程级联到第二个 case 块的第一个语句,这也是一个fallthrough
语句。这导致第三个 case 块的第一个语句执行返回true
。这在功能上等同于以下片段:
switch curr {
case Curr{"AUD", "Australian Dollar", "Australia", 36},
Curr{"HKD", "Hong Kong Dollar", "Hong Kong", 344},
Curr{"USD", "US Dollar", "United States", 840}:
return true
default:
return false
}
无表达式的开关
Go 支持一种不指定表达式的switch
语句形式。在这种格式中,每个case
表达式必须评估为布尔值true
。以下简化的源代码示例说明了无表达式switch
语句的用法,如find()
函数中所列。该函数循环遍历Curr
值的切片,以根据传入的struct
函数中的字段值搜索匹配项:
import (
"fmt"
"strings"
)
type Curr struct {
Currency string
Name string
Country string
Number int
}
var currencies = []Curr{
Curr{"DZD", "Algerian Dinar", "Algeria", 12},
Curr{"AUD", "Australian Dollar", "Australia", 36},
Curr{"EUR", "Euro", "Belgium", 978},
Curr{"CLP", "Chilean Peso", "Chile", 152},
...
}
func find(name string) {
for i := 0; i < 10; i++ {
c := currencies[i]
switch {
case strings.Contains(c.Currency, name),
strings.Contains(c.Name, name),
strings.Contains(c.Country, name):
fmt.Println("Found", c)
}
}
}
golang.fyi/ch03/switchstmt2.go
请注意,在前面的示例中,函数find()
中的switch
语句不包括表达式。每个case
表达式用逗号分隔,并且必须被评估为布尔值,每个之间隐含着OR
运算符。前面的switch
语句等同于以下使用if
语句实现相同逻辑:
func find(name string) {
for I := 0; i < 10; i++ {
c := currencies[i]
if strings.Contains(c.Currency, name) ||
strings.Contains(c.Name, name) ||
strings.Contains(c.Country, name){
fmt.Println""Foun"", c)
}
}
}
开关初始化器
switch
关键字后面可以紧跟一个简单的初始化语句,在其中可以声明和初始化switch
代码块中的局部变量。这种方便的语法使用分号在初始化语句和switch
表达式之间声明变量,这些变量可以出现在switch
代码块的任何位置。以下代码示例显示了如何通过初始化两个变量name
和curr
来完成这个操作:
func assertEuro(c Curr) bool {
switch name, curr := "Euro", "EUR"; {
case c.Name == name:
return true
case c.Currency == curr:
return true
}
return false
}
golang.fyi/ch03/switchstmt2.go
前面的代码片段使用了一个没有表达式的switch
语句和一个初始化程序。注意分号表示初始化语句和switch
表达式区域之间的分隔。然而,在这个例子中,switch
表达式是空的。
类型开关
考虑到 Go 对强类型的支持,也许不足为奇的是,该语言支持查询类型信息的能力。类型switch
是一种语句,它使用 Go 接口类型来比较值(或表达式)的底层类型信息。关于接口类型和类型断言的详细讨论超出了本节的范围。你可以在第八章方法、接口和对象中找到更多关于这个主题的细节。
尽管如此,为了完整起见,这里提供了关于类型开关的简短讨论。目前,你只需要知道的是,Go 提供了类型interface{}
或空接口作为一个超类型,它由类型系统中的所有其他类型实现。当一个值被赋予类型interface{}
时,可以使用类型switch
来查询关于其底层类型的信息,如下面的代码片段中的函数findAny()
所示:
func find(name string) {
for i := 0; i < 10; i++ {
c := currencies[i]
switch {
case strings.Contains(c.Currency, name),
strings.Contains(c.Name, name),
strings.Contains(c.Country, name):
fmt.Println("Found", c)
}
}
}
func findNumber(num int) {
for _, curr := range currencies {
if curr.Number == num {
fmt.Println("Found", curr)
}
}
}
func findAny(val interface{}) {
switch i := val.(type) {
case int:
findNumber(i)
case string:
find(i)
default:
fmt.Printf("Unable to search with type %T\n", val)
}
}
func main() {
findAny("Peso")
findAny(404)
findAny(978)
findAny(false)
}
golang.fyi/ch03/switchstmt2.go
函数findAny()
以interface{}
作为其参数。类型switch
用于使用类型断言表达式确定变量val
的底层类型和值:
switch i := val.(type)
请注意在前面的类型断言表达式中使用了关键字type
。每个 case 子句将根据从val.(type)
查询到的类型信息进行测试。变量i
将被赋予底层类型的实际值,并用于调用具有相应值的函数。默认块被调用来防范对参数val
分配的任何意外类型。然后,函数findAny
可以使用不同类型的值进行调用,如下面的代码片段所示:
findAny("Peso")
findAny(404)
findAny(978)
findAny(false)
for 语句
作为与 C 家族相关的语言,Go 也支持for
循环风格的控制结构。然而,正如你现在可能已经预料到的那样,Go 的for
语句工作方式有趣地不同而简单。Go 中的for
语句支持四种不同的习语,如下表所总结的:
For 语句 | 用法 |
---|
条件为|用于语义上替代while
和do...while
循环:
for x < 10 {
...
}
|
| 无限循环 | 可以省略条件表达式创建无限循环:
for {
...
}
|
| 传统的 | 这是 C 家族for
循环的传统形式,包括初始化、测试和更新子句:
for x:=0; x < 10; x++ {
...
}
|
| For 范围 | 用于遍历表示存储在数组、字符串(rune 数组)、切片、映射和通道中的项目集合的表达式:
for i, val := range values {
...
}
|
请注意,与 Go 中的所有其他控制语句一样,for
语句不使用括号括住它们的表达式。循环代码块的所有语句必须用大括号括起来,否则编译器会产生错误。
对于条件
for
条件使用了一个在其他语言中等价于while
循环的构造。它使用关键字for
,后面跟着一个布尔表达式,允许循环在评估为 true 时继续进行。以下是这种形式的for
循环的缩写源代码清单示例:
type Curr struct {
Currency string
Name string
Country string
Number int
}
var currencies = []Curr{
Curr{"KES", "Kenyan Shilling", "Kenya", 404},
Curr{"AUD", "Australian Dollar", "Australia", 36},
...
}
func listCurrs(howlong int) {
i := 0
for i < len(currencies) {
fmt.Println(currencies[i])
i++
}
}
golang.fyi/ch03/forstmt.go
在函数listCurrs()
中,for
语句循环迭代,只要条件表达式i < len(currencencies)
返回true
。必须小心确保i
的值在每次迭代中都得到更新,以避免创建意外的无限循环。
无限循环
当for
语句中省略布尔表达式时,循环将无限运行,如下例所示:
for {
// statements here
}
这相当于在其他语言(如 C 或 Java)中找到的for(;;)
或while(true)
。
传统的 for 语句
Go 还支持传统形式的for
语句,其中包括初始化语句、条件表达式和更新语句,所有这些都由分号分隔。这是传统上在其他类 C 语言中找到的语句形式。以下源代码片段说明了在函数sortByNumber
中使用传统的 for 语句:
type Curr struct {
Currency string
Name string
Country string
Number int
}
var currencies = []Curr{
Curr{"KES", "Kenyan Shilling", "Kenya", 404},
Curr{"AUD", "Australian Dollar", "Australia", 36},
...
}
func sortByNumber() {
N := len(currencies)
for i := 0; i < N-1; i++ {
currMin := i
for k := i + 1; k < N; k++ {
if currencies[k].Number < currencies[currMin].Number {
currMin = k
}
}
// swap
if currMin != i {
temp := currencies[i]
currencies[i] = currencies[currMin]
currencies[currMin] = temp
}
}
}
golang.fyi/ch03/forstmt.go
前面的例子实现了一个选择排序,它通过比较每个struct
值的Number
字段来对slice
currencies 进行排序。for
语句的不同部分使用以下代码片段进行了突出显示(来自前面的函数):
事实证明,传统的for
语句是迄今为止讨论的循环形式的超集,如下表所总结的那样:
For 语句 | 描述 |
---|
|
k:=initialize()
for ; k < 10;
++{
...
}
初始化语句被省略。变量k 在for 语句之外被初始化。然而,惯用的方式是用for 语句初始化你的变量。 |
---|
|
for k:=0; k < 10;{
...
}
这里省略了update 语句(在最后的分号之后)。开发人员必须在其他地方提供更新逻辑,否则会产生无限循环。 |
---|
|
for ; k < 10;{
...
}
这相当于for 条件形式(前面讨论过的)for k < 10 { ... } 。再次强调,变量k 预期在循环之前声明。必须小心更新k ,否则会产生无限循环。 |
---|
|
for k:=0; ;k++{
...
}
这里省略了条件表达式。与之前一样,如果在循环中没有引入适当的终止逻辑,这将评估为true ,将产生无限循环。 |
---|
|
for ; ;{ ... }
这相当于形式for{ ... } ,会产生无限循环。 |
---|
在for
循环中的初始化和update
语句是常规的 Go 语句。因此,它们可以用于初始化和更新多个变量,这是 Go 支持的。为了说明这一点,下一个例子在语句子句中同时初始化和更新两个变量w1
和w2
:
import (
"fmt"
"math/rand"
)
var list1 = []string{
"break", "lake", "go",
"right", "strong",
"kite", "hello"}
var list2 = []string{
"fix", "river", "stop",
"left", "weak", "flight",
"bye"}
func main() {
rand.Seed(31)
for w1, w2:= nextPair();
w1 != "go" && w2 != "stop";
w1, w2 = nextPair() {
fmt.Printf("Word Pair -> [%s, %s]\n", w1, w2)
}
}
func nextPair() (w1, w2 string) {
pos := rand.Intn(len(list1))
return list1[pos], list2[pos]
}
golang.fyi/ch03/forstmt2.go
初始化语句通过调用函数nextPair()
初始化变量w1
和w2
。条件使用一个复合逻辑表达式,只要它被评估为 true,循环就会继续运行。最后,变量w1
和w2
通过调用nextPair()
在每次循环迭代中都会被更新。
for range
最后,for
语句支持使用关键字range
的另一种形式,用于迭代求值为数组、切片、映射、字符串或通道的表达式。for-range 循环具有以下通用形式:
for [
根据range
表达式产生的类型,每次迭代可能会产生多达两个变量,如下表所总结的那样:
Range 表达式 | Range 变量 |
---|
| 循环遍历数组或切片:
for i, v := range []V{1,2,3} {
...
}
range 产生两个值,其中i 是循环索引,v 是集合中的值v[i] 。有关数组和切片的进一步讨论在第七章中有所涵盖,复合类型。 |
---|
| 循环遍历字符串值:
for i, v := range "Hello" {
...
}
range 产生两个值,其中i 是字符串中字节的索引,v 是在v[i] 处返回的 UTF-8 编码字节的值作为 rune。有关字符串类型的进一步讨论在第四章中有所涵盖,数据类型。 |
---|
| 循环地图:
for k, v := range map[K]V {
...
}
range 产生两个值,其中k 被赋予类型为K 的地图键的值,v 被存储在类型为V 的map[k] 中。有关地图的进一步讨论在第七章中有所涵盖,复合类型。 |
---|
| 循环通道值:
var ch chan T
for c := range ch {
...
}
有关通道的充分讨论在第九章中有所涵盖,并发。通道是一个能够接收和发出值的双向导管。for...range 语句将从通道接收到的每个值分配给变量c ,每次迭代。 |
---|
您应该知道,每次迭代发出的值都是源中存储的原始项目的副本。例如,在以下程序中,循环完成后,切片中的值不会被更新:
import "fmt"
func main() {
vals := []int{4, 2, 6}
for _, v := range vals {
v--
}
fmt.Println(vals)
}
要使用for...range
循环更新原始值,使用索引表达式访问原始值,如下所示。
func main() {
vals := []int{4, 2, 6}
for i, v := range vals {
vals[i] = v - 1
}
fmt.Println(vals)
}
在前面的示例中,值i
用于切片索引表达式vals[i]
来更新存储在切片中的原始值。如果您只需要访问数组、切片或字符串(或地图的键)的索引值,则可以省略迭代值(赋值中的第二个变量)。例如,在以下示例中,for...range
语句只在每次迭代中发出当前索引值:
func printCurrencies() {
for i := range currencies {
fmt.Printf("%d: %v\n", i, currencies[i])
}
}
golang.fyi/ch03/for-range-stmt.go
最后,有些情况下,您可能对迭代生成的任何值都不感兴趣,而是对迭代机制本身感兴趣。引入了 for 语句的下一形式(截至 Go 的 1.4 版本)来表达不带任何变量声明的 for 范围,如下面的代码片段所示:
func main() {
for range []int{1,1,1,1} {
fmt.Println("Looping")
}
}
前面的代码将在标准输出上打印四次"Looping"
。当范围表达式在通道上时,这种形式的for...range
循环有时会被使用。它用于简单地通知通道中存在值。
break
,continue
和goto
语句
Go 支持一组专门设计用于突然退出运行中的代码块的语句,例如switch
和for
语句,并将控制转移到代码的不同部分。所有三个语句都可以接受一个标签标识符,该标识符指定了代码中要转移控制的目标位置。
标签标识符
在深入本节的核心之前,值得看一下这些语句使用的标签。在 Go 中声明标签需要一个标识符,后面跟着一个冒号,如下面的代码片段所示:
DoSearch:
给标签命名是一种风格问题。但是,应该遵循前一章中介绍的标识符命名指南。标签必须包含在函数内。与变量类似,如果声明了标签,则必须在代码中引用它,否则 Go 编译器将不允许未使用的标签在代码中悬挂。
break
语句
与其他类似 C 的语言一样,Go 的break
语句终止并退出最内层的包围switch
或for
语句代码块,并将控制转移到运行程序的其他部分。break
语句可以接受一个可选的标签标识符,指定在包围函数中程序流将恢复的标记位置。以下是要记住break
语句标签的一些属性:
-
标签必须在与
break
语句所在的运行函数内声明 -
声明的标签必须紧随着包围控制语句(
for
循环或switch
语句)的位置,其中break
被嵌套
如果break
语句后面跟着一个标签,控制将被转移到标签所在的位置,而不是紧接着标记块后面的语句。如果没有提供标签,break
语句会突然退出并将控制转移到其封闭的for
语句(或switch
语句)块后面的下一个语句。
以下代码是一个过度夸张的线性搜索,用于说明break
语句的工作原理。它进行单词搜索,并在找到单词的第一个实例后退出切片:
import (
"fmt"
)
var words = [][]string{
{"break", "lake", "go", "right", "strong", "kite", "hello"},
{"fix", "river", "stop", "left", "weak", "flight", "bye"},
{"fix", "lake", "slow", "middle", "sturdy", "high", "hello"},
}
func search(w string) {
DoSearch:
for i := 0; i < len(words); i++ {
for k := 0; k < len(words[i]); k++ {
if words[i][k] == w {
fmt.Println("Found", w)
break DoSearch
}
}
}
}
golang.fyi/ch03/breakstmt.go
在前面的代码片段中,break DoSearch
语句实质上将退出最内层的for
循环,并导致执行流在最外层的带标签的for
语句之后继续,这个例子中,将简单地结束程序。
继续语句
continue
语句导致控制流立即终止封闭的for
循环的当前迭代,并跳转到下一次迭代。continue
语句也可以带有可选的标签。标签具有与break
语句类似的属性:
-
标签必须在
continue
语句所在的运行函数内声明 -
声明的标签必须紧随着一个封闭的
for
循环语句,在其中continue
语句被嵌套
当continue
语句在for
语句块内部到达时,for
循环将被突然终止,并且控制将被转移到最外层的带标签的for
循环块以进行继续。如果未指定标签,continue
语句将简单地将控制转移到其封闭的for
循环块的开始,以进行下一次迭代的继续。
为了说明,让我们重新访问单词搜索的先前示例。这个版本使用了continue
语句,导致搜索在切片中找到搜索词的多个实例:
func search(w string) {
DoSearch:
for i := 0; i < len(words); i++ {
for k := 0; k < len(words[i]); k++ {
if words[i][k] == w {
fmt.Println("Found", w)
continue DoSearch
}
}
}
}
golang.fyi/ch03/breakstmt2.go
continue DoSearch
语句导致最内层循环的当前迭代停止,并将控制转移到带标签的外部循环,导致它继续下一次迭代。
goto 语句
goto
语句更灵活,因为它允许将流控制转移到函数内定义目标标签的任意位置。goto
语句会突然转移控制到goto
语句引用的标签。以下是 Go 中goto
语句在一个简单但功能性示例中的示例:
import "fmt"
func main() {
var a string
Start:
for {
switch {
case a < "aaa":
goto A
case a >= "aaa" && a < "aaabbb":
goto B
case a == "aaabbb":
break Start
}
A:
a += "a"
continue Start
B:
a += "b"
continue Start
}
fmt.Println(a)
}
golang.fyi/ch03/gotostmt.go
该代码使用goto
语句跳转到main()
函数的不同部分。请注意,goto
语句可以定位到代码中任何地方定义的标签。在这种情况下,代码中留下了多余使用Start:
标签的部分,这在这种情况下是不必要的(因为没有标签的continue
会产生相同的效果)。以下是在使用goto
语句时提供一些指导的内容:
-
除非实现的逻辑只能使用
goto
分支,否则应避免使用goto
语句。这是因为过度使用goto
语句会使代码更难以理解和调试。 -
尽可能将
goto
语句及其目标标签放在同一个封闭的代码块中。 -
避免在
goto
语句将流程跳过新变量声明或导致它们被重新声明的地方放置标签。 -
Go 允许您从内部跳转到外部封闭的代码块。
-
如果尝试跳转到对等或封闭的代码块,这将是一个编译错误。
摘要
本章介绍了 Go 语言中控制流的机制,包括if
、switch
和for
语句。虽然 Go 的流程控制结构看起来简单易用,但它们功能强大,实现了现代语言所期望的所有分支原语。读者通过丰富的细节和示例介绍了每个概念,以确保主题的清晰度。下一章将继续介绍 Go 基础知识,向读者介绍 Go 类型系统。
第四章:- 第四章:数据类型
- Go 是一种强类型语言,这意味着存储(或产生)值的任何语言元素都与其关联一个类型。在本章中,读者将了解类型系统的特性,因为他们将探索语言支持的常见数据类型,如下所述:
-
- Go 类型
-
- 数值类型
-
- 布尔类型
-
- 指针
-
- 类型声明
-
- 类型转换
- Go 类型
- 为了帮助启动关于类型的讨论,让我们来看看可用的类型。Go 实现了一个简单的类型系统,为程序员提供了直接控制内存分配和布局的能力。当程序声明一个变量时,必须发生两件事:
-
- 变量必须接收一个类型
-
- 变量也将绑定到一个值(即使没有分配任何值)
-
这使得类型系统能够分配存储已声明值所需的字节数。已声明变量的内存布局直接映射到它们声明的类型。没有类型装箱或自动类型转换发生。分配的空间实际上就是在内存中保留的空间。
-
为了证明这一事实,以下程序使用一个名为
unsafe
的特殊包来规避类型系统,并提取已声明变量的内存大小信息。重要的是要注意,这纯粹是为了说明,因为大多数程序通常不常使用unsafe
包。
package main
import (
"fmt"
"unsafe"
)
var (
a uint8 = 72
b int32 = 240
c uint64 = 1234564321
d float32 = 12432345.232
e int64 = -1233453443434
f float64 = -1.43555622362467
g int16 = 32000
h [5]rune = [5]rune{'O', 'n', 'T', 'o', 'p'}
)
func main() {
fmt.Printf("a = %v [%T, %d bits]\n", a, a, unsafe.Sizeof(a)*8)
fmt.Printf("b = %v [%T, %d bits]\n", b, b, unsafe.Sizeof(b)*8)
fmt.Printf("c = %v [%T, %d bits]\n", c, c, unsafe.Sizeof(c)*8)
fmt.Printf("d = %v [%T, %d bits]\n", d, d, unsafe.Sizeof(d)*8)
fmt.Printf("e = %v [%T, %d bits]\n", e, e, unsafe.Sizeof(e)*8)
fmt.Printf("f = %v [%T, %d bits]\n", f, f, unsafe.Sizeof(f)*8)
fmt.Printf("g = %v [%T, %d bits]\n", g, g, unsafe.Sizeof(g)*8)
fmt.Printf("h = %v [%T, %d bits]\n", h, h, unsafe.Sizeof(h)*8)
}
-
golang.fyi/ch04/alloc.go
-
当程序执行时,它会打印出每个已声明变量消耗的内存量(以位为单位):
$>go run alloc.go
a = 72 [uint8, 8 bits]
b = 240 [int32, 32 bits]
c = 1234564321 [uint64, 64 bits]
d = 1.2432345e+07 [float32, 32 bits]
e = -1233453443434 [int64, 64 bits]
f = -1.43555622362467 [float64, 64 bits]
g = 32000 [int16, 16 bits]
h = [79 110 84 111 112] [[5]int32, 160 bits]
-
从前面的输出中,我们可以看到变量
a
(类型为uint8
)将使用 8 位(或 1 字节)存储,变量b
将使用 32 位(或 4 字节)存储,依此类推。通过影响内存消耗的能力以及 Go 对指针类型的支持,程序员能够强力控制内存在其程序中的分配和消耗。 -
本章将介绍下表中列出的类型。它们包括基本类型,如数值、布尔和字符串:
- 类型 | 描述 |
---|---|
- string |
用于存储文本值的类型。 |
- rune |
用于表示字符的整数类型(int32)。 |
- byte , int , int8 , int16 , int32 , int64 , rune , uint , uint8 , uint16 , uint32 , uint64 , uintptr |
用于存储整数值的类型。 |
- float32 , float64 |
用于存储浮点十进制值的类型。 |
- complex64 , complex128 |
可以表示具有实部和虚部的复数的类型。 |
- bool |
用于布尔值的类型。 |
- *T ,指向类型 T 的指针 |
代表存储类型为 T 的值的内存地址的类型。 |
- Go 支持的其余类型,如下表中列出的类型,包括复合类型、接口、函数和通道。它们将在专门讨论它们的章节中进行介绍。
- 类型 | 描述 |
---|---|
- 数组 [n]T |
由类型 T 的元素组成的具有固定大小 n 的有序集合。 |
- 切片[]T |
由类型 T 的元素组成的未指定大小的有序集合。 |
- struct{} |
结构是由称为字段的元素组成的复合类型(类似于对象)。 |
- map[K]T |
由任意类型 K 的键索引的类型为 T 的元素的无序序列。 |
- interface{} |
一组命名的函数声明,定义了其他类型可以实现的一组操作。 |
- func (T) R |
代表具有给定参数类型 T 和返回类型 R 的所有函数的类型。 |
- chan T |
用于内部通信通道的类型,用于发送或接收类型为 T 的值。 |
- 数值类型
Go 的数字类型包括对从 8 位到 64 位的各种大小的整数和小数值的支持。 每种数字类型在内存中都有自己的布局,并且被类型系统视为独特的。 为了强制执行这一点,并且避免在不同平台上移植 Go 时出现任何混淆,数字类型的名称反映了其大小要求。 例如,类型*int16*
表示使用 16 位进行内部存储的整数类型。 这意味着在赋值、表达式和操作中跨类型边界时,必须明确地转换数值。
以下程序并不是非常实用,因为所有值都被分配给了空白标识符。 但是,它展示了 Go 中支持的所有数字数据类型。
package main
import (
"math"
"unsafe"
)
var _ int8 = 12
var _ int16 = -400
var _ int32 = 12022
var _ int64 = 1 << 33
var _ int = 3 + 1415
var _ uint8 = 18
var _ uint16 = 44
var _ uint32 = 133121
var i uint64 = 23113233
var _ uint = 7542
var _ byte = 255
var _ uintptr = unsafe.Sizeof(i)
var _ float32 = 0.5772156649
var _ float64 = math.Pi
var _ complex64 = 3.5 + 2i
var _ complex128 = -5.0i
func main() {
fmt.Println("all types declared!")
}
golang.fyi/ch04/nums.go
无符号整数类型
以下表格列出了 Go 中可以表示无符号整数及其存储要求的所有可用类型:
类型 | 大小 | 描述 |
---|---|---|
uint8 |
无符号 8 位 | 范围 0-255 |
uint16 |
无符号 16 位 | 范围 0-65535 |
uint32 |
无符号 32 位 | 范围 0-4294967295 |
uint64 |
无符号 64 位 | 范围 0-18446744073709551615 |
uint |
实现特定 | 预先声明的类型,旨在表示 32 位或 64 位整数。 截至 Go 的 1.x 版本,uint 表示 32 位无符号整数。 |
byte |
无符号 8 位 | unit8 类型的别名。 |
uintptr |
无符号 | 一种设计用于存储底层机器体系结构的指针(内存地址)的无符号整数类型。 |
有符号整数类型
以下表格列出了 Go 中可以表示有符号整数及其存储要求的所有可用类型:
类型 | 大小 | 描述 |
---|---|---|
int8 |
有符号 8 位 | 范围-128 - 127 |
int16 |
有符号 16 位 | 范围-32768 - 32767 |
int32 |
有符号 32 位 | 范围-2147483648 - 2147483647 |
int64 |
有符号 64 位 | 范围-9223372036854775808 - 9223372036854775807 |
int |
实现特定 | 预先声明的类型,旨在表示 32 位或 64 位整数。 截至 Go 的 1.x 版本,int 表示 32 位有符号整数。 |
浮点类型
Go 支持以下类型来表示使用 IEEE 标准的十进制值:
类型 | 大小 | 描述 |
---|---|---|
float32 |
有符号 32 位 | 单精度浮点值的 IEEE-754 标准表示。 |
float64 |
有符号 64 位 | 双精度浮点值的 IEEE-754 标准表示。 |
复数类型
Go 还支持表示具有虚部和实部的复数,如下表所示:
类型 | 大小 | 描述 |
---|---|---|
complex64 |
float32 | 以float32 值存储的实部和虚部表示复数。 |
complex128 |
float64 | 以float64 值存储的实部和虚部表示复数。 |
数字文字
Go 支持使用数字序列和符号以及小数点的组合来自然表示整数值(如前面的例子所示)。 可选地,Go 整数文字也可以表示十六进制和八进制数字,如下面的程序所示:
package main
import "fmt"
func main() {
vals := []int{
1024,
0x0FF1CE,
0x8BADF00D,
0xBEEF,
0777,
}
for _, i := range vals {
if i == 0xBEEF {
fmt.Printf("Got %d\n", i)
break
}
}
}
golang.fyi/ch04/intslit.go
十六进制值以0x
或(0X
)前缀开头,而八进制值以前面示例中显示的数字 0 开头。 浮点值可以使用十进制和指数表示法表示,如下面的示例所示:
package main
import "fmt"
func main() {
p := 3.1415926535
e := .5772156649
x := 7.2E-5
y := 1.616199e-35
z := .416833e32
fmt.Println(p, e, x, y, z)
}
golang.fyi/ch04/floats.go
前面的程序展示了 Go 中浮点文字的几种表示。 数字可以包括一个可选的指数部分,该部分由数字末尾的e
(或E
)表示。 例如,代码中的1.616199e-35
表示数值 1.616199 x 10^(-35)。 最后,Go 支持用于表示复数的文字,如下面的示例所示:
package main
import "fmt"
func main() {
a := -3.5 + 2i
fmt.Printf("%v\n", a)
fmt.Printf("%+g, %+g\n", real(a), imag(a))
}
golang.fyi/ch04/complex.go
在上一个示例中,变量a
被分配了一个具有实部和虚部的复数。虚部文字是一个浮点数,后面跟着字母i
。请注意,Go 还提供了两个内置函数,real()
和imag()
,分别用于将复数分解为其实部和虚部。
布尔类型
在 Go 中,布尔二进制值使用bool
类型存储。虽然bool
类型的变量存储为 1 字节值,但它并不是数值的别名。Go 提供了两个预声明的文字,true
和false
,用于表示布尔值,如下例所示:
package main
import "fmt"
func main() {
var readyToGo bool = false
if !readyToGo {
fmt.Println("Come on")
} else {
fmt.Println("Let's go!")
}
}
golang.fyi/ch04/bool.go
符文和字符串类型
为了开始我们关于rune
和string
类型的讨论,需要一些背景知识。Go 可以将其源代码中的字符和字符串文字常量视为 Unicode。这是一个全球标准,其目标是通过为每个字符分配一个数值(称为代码点)来记录已知书写系统的符号。
默认情况下,Go 本身支持 UTF-8,这是一种有效的编码和存储 Unicode 数值的方式。这就是继续这个主题所需的所有背景。不会讨论更多细节,因为这超出了本书的范围。
符文
那么,rune
类型与 Unicode 有什么关系呢?rune
是int32
类型的别名。它专门用于存储以 UTF-8 编码的 Unicode 整数值。让我们在下面的程序中看一些rune
文字:
!符文
golang.fyi/ch04/rune.go
上一个程序中的每个变量都存储一个 Unicode 字符作为rune
值。在 Go 中,rune
可以被指定为由单引号括起来的字符串文字常量。文字可以是以下之一:
-
可打印字符(如变量
char1
、char2
和char3
所示) -
用反斜杠转义的单个字符,用于不可打印的控制值,如制表符、换行符、换行符等
-
\u
后直接跟 Unicode 值(\u0369
) -
\x
后跟两个十六进制数字 -
反斜杠后跟三个八进制数字(
\045
)
无论单引号内的rune
文字值如何,编译器都会编译并分配一个整数值,如上一个变量的打印输出所示:
$>go run runes.go
8
9
10
632
2438
35486
873
250
37
字符串
在 Go 中,字符串被实现为不可变字节值的切片。一旦将字符串值分配给变量,该字符串的值就不会改变。通常,字符串值被表示为双引号括起来的常量文字,如下例所示:
!字符串
golang.fyi/ch04/string.go
上一个片段显示了变量txt
被分配了一个包含七个字符的字符串文字,其中包括两个嵌入的中文字符。正如前面提到的,Go 编译器会自动将字符串文字值解释为 Unicode 字符,并使用 UTF-8 对其进行编码。这意味着在底层,每个文字字符都被存储为rune
,并且可能需要多于一个字节的存储空间来存储每个可见字符。事实上,当程序执行时,它打印出txt
的长度为11
,而不是预期的字符串的七个字符,这考虑到了用于中文符号的额外字节。
解释和原始字符串文字
以下片段(来自上一个示例)包括分配给变量txt2
和txt3
的两个字符串文字。正如你所看到的,这两个文字具有完全相同的内容,然而,编译器会对它们进行不同的处理:
var (
txt2 = "\u6C34\x20brings\x20\x6c\x69\x66\x65."
txt3 = `
\u6C34\x20
brings\x20
\x6c\x69\x66\x65\.
`
)
golang.fyi/ch04/string.go
变量txt2
分配的文字值用双引号括起来。这被称为解释字符串。解释字符串可以包含普通的可打印字符,也可以包含反斜杠转义值,这些值被解析并解释为rune
文字。因此,当打印txt2
时,转义值被翻译为以下字符串:
在解释字符串中,每个符号对应一个转义值或可打印符号,如下表所总结的:
带来 | 生命 | . | |||
---|---|---|---|---|---|
\u6C34 | \x20 | 带来 | \x20 | \x6c\x69\x66\x65 | . |
另一方面,变量txt3
分配的文字值被反引号字符`
包围。这在Go中创建了所谓的原始字符串。原始字符串值未被解释,其中转义序列被忽略,所有有效字符都按照它们在文本中出现的方式进行编码。
打印txt3
变量时,将产生以下输出:
\u6C34\x20 brings\x20\x6c\x69\x66\x65。
注意,打印的字符串包含所有反斜杠转义值,就像它们出现在原始字符串文本中一样。未解释的字符串文本是在不破坏语法的情况下将大型多行文本内容嵌入源代码主体中的一种有效方式。
指针
在 Go 中,当一段数据存储在内存中时,可以直接访问该数据的值,也可以使用指针来引用存储数据位置的内存地址。与其他 C 家族语言一样,Go 中的指针提供了一种间接的方式,让程序员可以更高效地处理数据,而不必每次需要时都复制实际数据值。
然而,与 C 不同,Go 运行时在运行时管理指针的控制。程序员不能将任意整数值添加到指针中生成新的指针地址(一种称为指针算术的做法)。一旦一个指针引用了内存区域,该区域中的数据将保持可访问状态,直到不再有任何指针变量引用。在那时,未引用的值将变得可供垃圾收集。
指针类型
类似于 C/C++,Go 使用*
运算符指定类型为指针。以下代码片段显示了几个具有不同底层类型的指针:
package main
import "fmt"
var valPtr *float32
var countPtr *int
var person *struct {
name string
age int
}
var matrix *[1024]int
var row []*int64
func main() {
fmt.Println(valPtr, countPtr, person, matrix, row)
}
给定类型T
的变量,Go 使用表达式*T
作为其指针类型。类型系统将T
和*T
视为不同且不可互换。指针的零值,当它不指向任何内容时,是地址 0,表示为常数 nil。
地址运算符
指针值只能分配给它们声明类型的地址。在 Go 中,一种方法是使用地址运算符&
(和号)获取变量的地址值,如下例所示:
package main
import "fmt"
func main() {
var a int = 1024
var aptr *int = &a
fmt.Printf("a=%v\n", a)
fmt.Printf("aptr=%v\n", aptr)
}
变量aptr
,指针类型为*int
,使用表达式&a
进行初始化,并将变量a
的地址值分配给它,如下所示:
var a int = 1024
var aptr *int = &a
虽然变量a
存储实际值,我们说aptr
指向a
。以下显示了程序输出,其中变量a
的值和其内存位置被分配给aptr
:
a=1024
aptr=0xc208000150
分配的地址值将始终相同(始终指向a
),无论在代码中何处访问aptr
。值得注意的是,Go 不允许在数字、字符串和布尔类型的文本常量中使用地址运算符。因此,以下代码不会编译:
var aptr *int = &1024
fmt.Printf("a ptr1 = %v\n", aptr)
然而,有一个语法例外情况,当用文本常量初始化结构体和数组等复合类型时。以下程序说明了这样的情况:
package main
import "fmt"
func main() {
structPtr := &struct{ x, y int }{44, 55}
pairPtr := &[2]string{"A", "B"}
fmt.Printf("struct=%#v, type=%T\n", structPtr, structPtr)
fmt.Printf("pairPtr=%#v, type=%T\n", pairPtr, pairPtr)
}
在前面的代码片段中,地址运算符直接与复合字面量&struct{ x, y int }{44, 55}
和&[2]string{"A", "B"}
一起使用,返回指针类型*struct { x int; y int }
和*[2]string
。这是一种语法糖,消除了将值分配给变量,然后检索其分配地址的中间步骤。
new()函数
使用内置函数new(new()
函数初始化变量intptr
和p
:
package main
import "fmt"
func main() {
intptr := new(int)
*intptr = 44
p := new(struct{ first, last string })
p.first = "Samuel"
p.last = "Pierre"
fmt.Printf("Value %d, type %T\n", *intptr, intptr)
fmt.Printf("Person %+v\n", p)
}
golang.fyi/ch04/newptr.go
变量intptr
初始化为*int
,p
初始化为*struct{first, last string}
。一旦初始化,两个值在代码中稍后会相应更新。当实际值在初始化时不可用时,您可以使用new()
函数以零值初始化指针变量。
指针间接引用 - 访问引用的值
如果你只有地址,你可以通过将*
运算符应用到指针值本身(或解引用)来访问它指向的值。以下程序在函数double()
和cap()
中演示了这一理念:
package main
import (
"fmt"
"strings"
)
func main() {
a := 3
double(&a)
fmt.Println(a)
p := &struct{ first, last string }{"Max", "Planck"}
cap(p)
fmt.Println(p)
}
func double(x *int) {
*x = *x * 2
}
func cap(p *struct{ first, last string }) {
p.first = strings.ToUpper(p.first)
p.last = strings.ToUpper(p.last)
}
golang.fyi/ch04/derefptr.go
在前面的代码中,在函数double()
中,表达式*x = *x * 2
可以分解如下以了解其工作原理:
表达式 | 步骤 |
---|
*x * 2
x 是*int 类型的原始表达式。 |
---|
*(*x) * 2
通过对地址值应用* 进行指针解引用。 |
---|
3 * 2 = 6
*(*x) = 3 的解引用值。 |
---|
*(*x) = 6
此表达式的右侧解引用了x 的值。它被更新为结果 6。 |
---|
在函数cap()
中,使用类似的方法来访问和更新类型为struct{first, last string}
的复合变量p
中的字段。然而,处理复合类型时,这种习惯用法更加宽容。不需要写*p.first
来访问指针的字段值。我们可以去掉*
,直接使用p.first = strings.ToUpper(p.first)
。
类型声明
在 Go 语言中,可以将类型绑定到标识符以创建一个新的命名类型,可以在需要该类型的任何地方引用和使用它。声明类型的通用格式如下:
type <名称标识符> <基础类型名称>
类型声明以关键字type
开始,后跟名称标识符和现有基础类型的名称。基础类型可以是内置命名类型,如数字类型之一,布尔值,或字符串类型,如下面的类型声明片段所示:
type truth bool
type quart float64
type gallon float64
type node string
注意
类型声明也可以使用复合类型字面值作为其基础类型。复合类型包括数组、切片、映射和结构体。本节侧重于非复合类型。有关复合类型的更多详细信息,请参阅第七章复合类型。
以下示例说明了命名类型在其最基本形式中的工作方式。示例中的代码将温度值转换。每个温度单位都由一个声明类型表示,包括fahrenheit
、celsius
和kelvin
。
package main
import "fmt"
type fahrenheit float64
type celsius float64
type kelvin float64
func fharToCel(f fahrenheit) celsius {
return celsius((f - 32) * 5 / 9)
}
func fharToKel(f fahrenheit) celsius {
return celsius((f-32)*5/9 + 273.15)
}
func celToFahr(c celsius) fahrenheit {
return fahrenheit(c*5/9 + 32)
}
func celToKel(c celsius) kelvin {
return kelvin(c + 273.15)
}
func main() {
var c celsius = 32.0
f := fahrenheit(122)
fmt.Printf("%.2f \u00b0C = %.2f \u00b0K\n", c, celToKel(c))
fmt.Printf("%.2f \u00b0F = %.2f \u00b0C\n", f, fharToCel(f))
}
golang.fyi/ch04/typedef.go
在上述代码片段中,新声明的类型都基于基础的内置数值类型float64
。一旦新类型已声明,它可以被赋值给变量,并像其基础类型一样参与表达式。新声明的类型将具有相同的零值,并且可以与其基础类型进行相互转换。
类型转换
通常情况下,Go 认为每种类型都是不同的。这意味着在正常情况下,不同类型的值在赋值、函数参数和表达式上下文中不可互换。这对于内置类型和声明的类型都适用。例如,以下代码会因类型不匹配而导致构建错误:
package main
import "fmt"
type signal int
func main() {
var count int32
var actual int
var test int64 = actual + count
var sig signal
var event int = sig
fmt.Println(test)
fmt.Println(event)
}
golang.fyi/ch04/type_conv.go
表达式actual + count
会导致构建时错误,因为两个变量的类型不同。即使变量actual
和count
都是数值类型,并且int32
和int
具有相同的内存表示,编译器仍然会拒绝这个表达式。
声明的命名类型及其基础类型也是如此。编译器将拒绝赋值var event int = sig
,因为类型signal
被视为与类型int
不同。即使signal
使用int
作为其基础类型,这也是正确的。
要跨越类型边界,Go 支持一种类型转换表达式,用于将一个类型的值转换为另一个类型。类型转换使用以下格式进行:
<目标类型>(<值或表达式>)
以下代码片段通过将变量转换为适当的类型来修复先前的示例:
type signal int
func main() {
var count int32
var actual int
var test int32 = int32(actual) + count
var sig signal
var event int = int(sig)
}
golang.fyi/ch04/type_conv2.go
请注意,在上述代码中,赋值表达式var test int32 = int32(actual) + count
将变量actual
转换为相应的类型,以匹配表达式的其余部分。类似地,表达式var event int = int(sig)
将变量sig
转换为匹配赋值中的目标类型int
。
转换表达式通过显式更改封闭值的类型来满足赋值。显然,并非所有类型都可以互相转换。以下表总结了类型转换适合和允许的常见情况:
描述 | 代码 |
---|---|
目标类型和转换值都是简单的数值类型。 |
var i int
var i2 int32 = int32(i)
var re float64 = float64(i + int(i2))
目标类型和转换值都是复数数值类型。 |
---|
var cn64 complex64
var cn128 complex128 = complex128(cn64)
目标类型和转换值具有相同的基础类型。 |
---|
type signal int
var sig signal
var event int = int(sig)
目标类型是字符串,转换值是有效的整数类型。 |
---|
a := string(72)
b := string(int32(101))
c := string(rune(108))
目标类型是字符串,转换值是字节片、int32 或符文。 |
---|
msg0 := string([]byte{'H','i'})
msg1 := string([]rune{'Y','o','u','!'})
目标类型是字节、int32 或符文值的片,转换值是一个字符串。 |
---|
data0 := []byte("Hello")
data0 := []int32("World!")
此外,当目标类型和转换值是引用相同类型的指针时,转换规则也适用。除了上表中的这些情况外,Go 类型不能被显式转换。任何尝试这样做都将导致编译错误。
总结
本章向读者介绍了 Go 类型系统。本章以类型概述开篇,深入全面地探讨了基本内置类型,如数字、布尔、字符串和指针类型。讨论继续暴露读者对其他重要主题,如命名类型定义。本章以类型转换的机制结束。在接下来的章节中,您将有机会了解其他类型,如复合类型、函数类型和接口类型。
第五章:Go 中的函数
Go 的语法绝活之一是通过支持高阶函数,就像在 Python 或 Ruby 等动态语言中一样。正如我们将在本章中看到的,函数也是一个具有值的类型实体,可以赋值给变量。在本章中,我们将探讨 Go 中的函数,涵盖以下主题:
-
Go 函数
-
传递参数值
-
匿名函数和闭包
-
高阶函数
-
错误信号处理
-
延迟函数调用
-
函数恐慌和恢复
Go 函数
在 Go 中,函数是第一类的、有类型的编程元素。声明的函数文字始终具有类型和值(定义的函数本身),并且可以选择地绑定到命名标识符。因为函数可以被用作数据,它们可以被分配给变量或作为其他函数的参数传递。
函数声明
在 Go 中声明函数的一般形式如下图所示。这种规范形式用于声明命名和匿名函数。
在 Go 中,最常见的函数定义形式包括函数文字中的函数分配标识符。为了说明这一点,下表显示了几个程序的源代码,其中定义了具有不同参数和返回类型组合的命名函数。
代码 | 描述 |
---|
|
package main import (
"fmt"
"math"
)func printPi() {
fmt.Printf("printPi()
%v\n", math.Pi)
} func main() {
printPi() } ("fmt" "math" ) func
printPi() {
fmt.Printf("printPi()
%v\n", math.Pi)
}
func main() { printPi() }
golang.fyi/ch05/func0.go | 一个名为printPi
的函数。它不接受参数,也不返回任何值。请注意,当没有要返回的内容时,return
语句是可选的。|
|
package main
import "fmt"
func avogadro() float64 {
return 6.02214129e23
}
func main() {
fmt.Printf("avogadro()
= %e 1/mol\n",
avogadro())
}
golang.fyi/ch05/func1.go | 一个名为avogadro
的函数。它不接受参数,但返回一个float64
类型的值。请注意,当返回值在函数签名中声明时,return
语句是必需的。|
|
package main
import "fmt"
func fib(n int) {
fmt.Printf("fib(%d):
[", n)
var p0, p1 uint64 = 0,
1
fmt.Printf("%d %d ",
p0, p1)
for i := 2; i <= n; i++
{
p0, p1 = p1, p0+p1
fmt.Printf("%d ",p1)
}
fmt.Println("]")
}
func main() {
fib(41)
}
golang.fyi/ch05/func2.go | 这定义了fib
函数。它接受类型为int
的参数n
,并打印出最多n
的斐波那契数列。同样,没有要返回的内容,因此省略了return
语句。|
|
package main
import (
"fmt"
"math"
)
func isPrime(n int) bool {
lim :=
int(math.Sqrt
(float64(n)))
for p := 2; p <= lim;
p++ {
if (n % p) == 0 {
return false
} }
return true
}
func main() {
prime := 37
fmt.Printf
("isPrime(%d) =
%v\n", prime,
isPrime(prime))
}
golang.fyi/ch05/func3.go | 最后一个示例定义了isPrime
函数。它接受一个int
类型的参数,并返回一个bool
类型的值。由于函数声明要返回一个bool
类型的值,因此执行流程中的最后一个逻辑语句必须是一个return
语句,返回声明类型的值。|
注意
函数签名
指定的参数类型、结果类型和这些类型声明的顺序被称为函数的签名。这是另一个帮助识别函数的独特特征。两个函数可能具有相同数量的参数和结果值;然而,如果这些元素的顺序不同,那么这些函数就具有不同的签名。
函数类型
通常,函数文字中声明的名称标识符用于使用调用表达式调用函数,其中函数标识符后面跟着参数列表。这是我们迄今为止在整本书中看到的,并且在下面的示例中调用fib
函数中有所说明:
func main() {
fib(41)
}
然而,当函数的标识符出现时,没有括号,它被视为一个具有类型和值的常规变量,如下面的程序所示:
package main
import "fmt"
func add(op0 int, op1 int) int {
return op0 + op1
}
func sub(op0, op1 int) int {
return op0 - op1
}
func main() {
var opAdd func(int, int) int = add
opSub := sub
fmt.Printf("op0(12,44)=%d\n", opAdd(12, 44))
fmt.Printf("sub(99,13)=%d\n", opSub(99, 13))
}
golang.fyi/ch05/functype.go
函数的类型由其签名确定。当具有相同数量的参数、相同类型和相同顺序的参数时,函数被认为是相同类型的。在前面的示例中,opAdd
变量被声明为func (int, int) int
类型。这与声明的add
和sub
函数相同。因此,opAdd
变量被赋予add
函数变量。这允许像调用add
函数一样调用opAdd
。
对于opAdd
变量也是同样的操作。它被赋予了由函数标识符add
和类型func(int, int)
表示的值。因此,opAdd(3,5)
调用了第一个函数,返回了加法的结果。
可变参数
函数的最后一个参数可以通过在参数类型之前添加省略号(…
)来声明为可变参数(可变长度参数)。这表示在调用函数时可以传递零个或多个该类型的值。
以下示例实现了两个接受可变参数的函数。第一个函数计算传入值的平均值,第二个函数对传入的数字进行求和:
package main
import "fmt"
func avg(nums ...float64) float64 {
n := len(nums)
t := 0.0
for _, v := range nums {
t += v
}
return t / float64(n)
}
func sum(nums ...float64) float64 {
var sum float64
for _, v := range nums {
sum += v
}
return sum
}
func main() {
fmt.Printf("avg([1, 2.5, 3.75]) =%.2f\n", avg(1, 2.5, 3.75))
points := []float64{9, 4, 3.7, 7.1, 7.9, 9.2, 10}
fmt.Printf("sum(%v) = %.2f\n", points, sum(points...))
}
golang.fyi/ch05/funcvariadic.go
编译器在前述两个函数中将可变参数解析为[]float64
类型的切片。然后可以使用切片表达式来访问参数值,就像前面的例子中所示。要调用具有可变参数的函数,只需提供一个逗号分隔的值列表,与指定类型匹配,如下面的代码片段所示:
fmt.Printf("avg([1, 2.5, 3.75]) =%.2f\n", avg(1, 2.5, 3.75)))
当没有提供参数时,函数接收到一个空切片。敏锐的读者可能会想,“是否可以将现有值的切片作为可变参数传递进去?”幸运的是,Go 提供了一个简单的习语来处理这种情况。让我们来看下面代码片段中对sum
函数的调用:
points := []float64{9, 4, 3.7, 7.1, 7.9, 9.2, 10}
fmt.Printf("sum(%v) = %f\n", points, sum(points...))
声明了一个浮点数值的切片,并将其存储在变量points
中。通过在sum(points...)
函数调用中的参数中添加省略号,可以将切片作为可变参数传递。
函数结果参数
Go 函数可以定义返回一个或多个结果值。到目前为止,在本书中,我们遇到的大多数函数都被定义为返回单个结果值。一般来说,一个函数能够返回一个由逗号分隔的不同类型的结果值列表(参见前一节,函数声明)。
为了说明这个概念,让我们来看下面的简单程序,它定义了一个实现欧几里得除法算法的函数(参见en.wikipedia.org/wiki/Division_algorithm
)。div
函数返回商和余数作为其结果:
package main
import "fmt"
func div(op0, op1 int) (int, int) {
r := op0
q := 0
for r >= op1 {
q++
r = r - op1
}
return q, r
}
func main() {
q, r := div(71, 5)
fmt.Printf("div(71,5) -> q = %d, r = %d\n", q, r)
}
golang.fyi/ch05/funcret0.go
**return**
关键字后面跟着与函数签名中声明的结果匹配的结果值的数量。在前面的例子中,div
函数的签名指定了两个int
值作为结果值返回。在内部,函数定义了int
变量p
和r
,它们在函数完成时作为结果值返回。这些返回的值必须与函数签名中定义的类型匹配,否则会出现编译错误。
具有多个结果值的函数必须在适当的上下文中调用:
-
它们必须分别分配给相同类型的标识符列表
-
它们只能包含在期望相同数量的返回值的表达式中
这在下面的源代码片段中有所说明:
q, r := div(71, 5)
fmt.Printf("div(71,5) -> q = %d, r = %d\n", q, r)
命名结果参数
一般来说,函数签名的结果列表可以使用变量标识符及其类型来指定。使用命名标识符时,它们被传递给函数作为常规声明的变量,并且可以根据需要访问和修改。在遇到return
语句时,将返回最后分配的结果值。这在下面的源代码片段中有所说明,它是对前一个程序的重写:
func div(dvdn, dvsr int) (q, r int) {
r = dvdn
for r >= dvsr {
q++
r = r - dvsr
}
return
}
golang.fyi/ch05/funcret1.go
请注意return
语句是裸的;它省略了所有标识符。如前所述,q
和r
中分配的值将返回给调用者。为了可读性、一致性或风格,您可以选择不使用裸return
语句。可以像以前一样将标识符的名称与return
语句(例如return q, r
)结合使用是完全合法的。
传递参数值
在 Go 中,所有传递给函数的参数都是按值传递的。这意味着在被调用的函数内部创建了传递值的本地副本。没有固有的按引用传递参数值的概念。以下代码通过修改dbl
函数内的传递参数val
的值来说明这种机制:
package main
import (
"fmt"
"math"
)
func dbl(val float64) {
val = 2 * val // update param
fmt.Printf("dbl()=%.5f\n", val)
}
func main() {
p := math.Pi
fmt.Printf("before dbl() p = %.5f\n", p)
dbl(p)
fmt.Printf("after dbl() p = %.5f\n", p)
}
golang.fyi/ch05/funcpassbyval.go
当程序运行时,它产生以下输出,记录了传递给dbl
函数之前p
变量的状态。更新是在dbl
函数内部对传递参数变量进行本地更新的,最后是在调用dbl
函数之后的p
变量的值:
$> go run funcpassbyval.go
before dbl() p = 3.14159
dbl()=6.28319
after dbl() p = 3.14159
前面的输出显示,分配给变量p
的原始值保持不变,即使它被传递给一个似乎在内部更新其值的函数。这是因为dbl
函数中的val
参数接收传递参数的本地副本。
实现按引用传递
虽然按值传递在许多情况下是合适的,但重要的是要注意,Go 可以使用指针参数值实现按引用传递的语义。这允许被调用的函数超出其词法范围并更改指针参数引用的位置存储的值,就像在以下示例中的half
函数中所做的那样:
package main
import "fmt"
func half(val *float64) {
fmt.Printf("call half(%f)\n", *val)
*val = *val / 2
}
func main() {
num := 2.807770
fmt.Printf("num=%f\n", num)
half(&num)
fmt.Printf("half(num)=%f\n", num)
}
golang.fyi/ch05/funcpassbyref.go
在前面的例子中,在main()
中对half(&num)
函数的调用会直接更新其num
参数引用的原始值。因此,当代码执行时,它显示了num
的原始值以及调用half
函数后的值:
$> go run funcpassbyref.go
num=2.807770
call half(2.807770)
half(num)=1.403885
正如前面所述,Go 函数参数是按值传递的。即使函数以指针值作为参数,这也是正确的。Go 仍然创建并传递指针值的本地副本。在前面的例子中,half
函数接收通过val
参数传递的指针值的副本。代码使用指针操作符(*
)来取消引用和就地操作val
引用的值。当half
函数退出并超出范围时,通过调用main
函数可以访问其更改。
匿名函数和闭包
函数可以被写成没有命名标识符的文字。这些被称为匿名函数,可以被分配给一个变量,以便稍后调用,就像下面的例子所示:
package main
import "fmt"
var (
mul = func(op0, op1 int) int {
return op0 * op1
}
sqr = func(val int) int {
return mul(val, val)
}
)
func main() {
fmt.Printf("mul(25,7) = %d\n", mul(25, 7))
fmt.Printf("sqr(13) = %d\n", sqr(13))
}
golang.fyi/ch05/funcs.go
前面的程序显示了两个匿名函数声明并绑定到mul
和sqr
变量。在这两种情况下,函数都接受参数并返回一个值。稍后在main()
中,变量被用来调用与它们绑定的函数代码。
调用匿名函数文字
值得注意的是,匿名函数不一定要绑定到标识符。函数文字可以在现场评估为返回函数结果的表达式。通过在括号中结束函数文字的方式,传递参数值的列表,如下面的程序所示:
package main
import "fmt"
func main() {
fmt.Printf(
"94 (°F) = %.2f (°C)\n",
func(f float64) float64 {
return (f - 32.0) * (5.0 / 9.0)
}(94),
)
}
golang.fyi/ch05/funcs.go
文字格式不仅定义了匿名函数,还调用了它。例如,在以下片段(来自前面的程序)中,匿名函数文字被嵌套为fmt.Printf()
的参数。函数本身被定义为接受一个参数并返回float64
类型的值。
fmt.Printf(
"94 (°F) = %.2f (°C)\n",
func(f float64) float64 {
return (f - 32.0) * (5.0 / 9.0)
}(94),
)
由于函数文字以括号括起的参数列表结束,因此该函数被调用为表达式。
闭包
Go 函数文字是闭包。这意味着它们在封闭的代码块之外声明的非局部变量具有词法可见性。以下示例说明了这一事实:
package main
import (
"fmt"
"math"
)
func main() {
for i := 0.0; i < 360.0; i += 45.0 {
rad := func() float64 {
return i * math.Pi / 180
}()
fmt.Printf("%.2f Deg = %.2f Rad\n", i, rad)
}
}
github.com/vladimirvivien/learning-go/ch05/funcs.go
在上一个程序中,函数文字代码块func() float64 {return deg * math.Pi / 180}()
被定义为将度数转换为弧度的表达式。在每次循环迭代时,闭包在封闭的函数文字和外部非局部变量i
之间形成。这提供了一种更简单的习语,其中函数自然地访问非局部值,而不需要诸如指针之类的其他手段。
注意
在 Go 中,词法闭包的值可以在创建闭包的外部函数已经超出范围之后仍然保持与它们的闭包绑定。垃圾收集器将在这些闭合值变得无限制时处理清理工作。
高阶函数
我们已经确定 Go 函数是绑定到类型的值。因此,Go 函数可以接受另一个函数作为参数,并且还可以返回一个函数作为结果值,这应该不足为奇。这描述了一个被称为高阶函数的概念,这是从数学中采用的概念。虽然诸如struct
之类的类型让程序员抽象数据,但高阶函数提供了一种机制,用于封装和抽象可以组合在一起形成更复杂行为的行为。
为了使这个概念更清晰,让我们来看一下下面的程序,它使用了一个高阶函数apply
来做三件事。它接受一个整数切片和一个函数作为参数。它将指定的函数应用于切片中的每个元素。最后,apply
函数还返回一个函数作为其结果:
package main
import "fmt"
func apply(nums []int, f func(int) int) func() {
for i, v := range nums {
nums[i] = f(v)
}
return func() {
fmt.Println(nums)
}
}
func main() {
nums := []int{4, 32, 11, 77, 556, 3, 19, 88, 422}
result := apply(nums, func(i int) int {
return i / 2
})
result()
}
golang.fyi/ch05/funchighorder.go
在程序中,apply
函数被调用,并使用匿名函数对切片中的每个元素进行减半,如下面的代码段所示:
nums := []int{4, 32, 11, 77, 556, 3, 19, 88, 422}
result := apply(nums, func(i int) int {
return i / 2
})
result()
作为高阶函数,apply
抽象了可以由任何类型为func(i int) int
的函数提供的转换逻辑,如下所示。由于apply
函数返回一个函数,因此变量result
可以像前面的代码段中所示那样被调用。
当您探索本书和 Go 语言时,您将继续遇到高阶函数的使用。这是一种在标准库中广泛使用的习语。您还将发现高阶函数在一些并发模式中被用于分发工作负载(参见第九章,“并发性”)。
错误信号和处理
在这一点上,让我们来看看如何在进行函数调用时惯用地发出和处理错误。如果您曾经使用过 Python、Java 或 C#等语言,您可能熟悉在不良状态出现时通过抛出异常来中断执行代码流的做法。
正如我们将在本节中探讨的,Go 对错误信号和错误处理采用了简化的方法,这使得程序员需要在调用函数返回后立即处理可能的错误。Go 不鼓励通过在执行程序中不加区别地中断执行来短路执行程序,并希望异常能够在调用堆栈的更高位置得到适当处理的概念。在 Go 中,信号错误的传统方式是在函数执行过程中出现问题时返回error
类型的值。因此,让我们更仔细地看看这是如何完成的。
错误信号
为了更好地理解前面段落中所描述的内容,让我们从一个例子开始。以下源代码实现了一个变位词程序,如 Jon Bentley 的流行书籍《编程珠玑》(第二版)中的第 2 列所述。该代码读取一个字典文件(dict.txt
),并将所有具有相同变位词的单词分组。如果代码不太容易理解,请参阅golang.fyi/ch05/anagram1.go以获取程序各部分如何工作的注释解释。
package main
import (
"bufio"
"bytes"
"fmt"
"os"
"errors"
)
// sorts letters in a word (i.e. "morning" -> "gimnnor")
func sortRunes(str string) string {
runes := bytes.Runes([]byte(str))
var temp rune
for i := 0; i < len(runes); i++ {
for j := i + 1; j < len(runes); j++ {
if runes[j] < runes[i] {
temp = runes[i]
runes[i], runes[j] = runes[j], temp
}
}
}
return string(runes)
}
// load loads content of file fname into memory as []string
func load(fname string) ([]string, error) {
if fname == "" {
return nil, errors.New(
"Dictionary file name cannot be empty.")
}
file, err := os.Open(fname)
if err != nil {
return nil, err
}
defer file.Close()
var lines []string
scanner := bufio.NewScanner(file)
scanner.Split(bufio.ScanLines)
for scanner.Scan() {
lines = append(lines, scanner.Text())
}
return lines, scanner.Err()
}
func main() {
words, err := load("dict.txt")
if err != nil {
fmt.Println("Unable to load file:", err)
os.Exit(1)
}
anagrams := make(map[string][]string)
for _, word := range words {
wordSig := sortRunes(word)
anagrams[wordSig] = append(anagrams[wordSig], word)
}
for k, v := range anagrams {
fmt.Println(k, "->", v)
}
}
golang.fyiy/ch05/anagram1.go
同样,如果您想要更详细的解释前面的程序,请查看之前提供的链接。这里的重点是前面程序中使用的错误信号。作为惯例,Go 代码使用内置类型error
来表示在函数执行过程中发生错误。因此,函数必须返回一个error
类型的值,以指示给其调用者发生了错误。这在前面示例中的load
函数的以下片段中有所说明:
func load(fname string) ([]string, error) {
if fname == "" {
return nil, errors.New(
"Dictionary file name cannot be empty.")
}
file, err := os.Open(fname)
if err != nil {
return nil, err
}
...
}
请注意,load
函数返回多个结果参数。一个是预期值,本例中为[]string
,另一个是错误值。惯用的 Go 规定程序员应该返回一个非 nil 值作为error
类型的结果,以指示在函数执行过程中发生了异常情况。在前面的片段中,load
函数在两种可能的情况下向其调用者发出错误发生的信号:
-
当预期的文件名(
fname
)为空时 -
当调用
os.Open()
失败时(例如,权限错误,或其他情况)
在第一种情况下,当未提供文件名时,代码使用errors.New()
返回一个error
类型的值来退出函数。在第二种情况下,os.Open
函数返回一个代表文件的指针,并将错误分配给file
和err
变量。如果err
不是nil
(表示生成了错误),则load
函数的执行会过早终止,并将err
的值返回给调用函数处理调用堆栈中更高的位置。
注意
当为具有多个结果参数的函数返回错误时,习惯上会返回其他(非错误类型)参数的零值。在这个例子中,对于类型为[]string
的结果,返回了nil
值。虽然这并非必需,但它简化了错误处理,并避免了对函数调用者造成任何困惑。
错误处理
如前所述,在函数执行过程中,只需返回一个非 nil 值,类型为error
,即可简单地表示错误状态的发生。调用者可以选择处理error
或将其return
以供调用堆栈上进一步评估,就像在load
函数中所做的那样。这种习惯强制错误向上传播,直到某个地方处理它们。下一个片段展示了load
函数生成的错误在main
函数中是如何处理的:
func main() {
words, err := load("dict.txt")
if err != nil {
fmt.Println("Unable to load file:", err)
os.Exit(1)
}
...
}
由于main
函数是调用堆栈中最顶层的调用者,它通过终止整个程序来处理错误。
这就是 Go 中错误处理的机制。语言强制程序员始终测试每个返回error
类型值的函数调用是否处于错误状态。if…not…nil error
处理习惯可能对一些人来说过于冗长,特别是如果你来自一个具有正式异常机制的语言。然而,这里的好处在于程序可以构建一个健壮的执行流程,程序员总是知道错误可能来自哪里,并适当地处理它们。
错误类型
error
类型是一个内置接口,因此必须在使用之前实现。幸运的是,Go 标准库提供了准备好的实现。我们已经使用了来自errors
包的一个实现:
errors.New("Dictionary file name cannot be empty.")
您还可以使用fmt.Errorf
函数创建参数化的错误值,如下面的代码片段所示:
func load(fname string) ([]string, error) {
if fname == "" {
return nil, errors.New(
"Dictionary file name cannot be emtpy.")
}
file, err := os.Open(fname)
if err != nil {
return nil, fmt.Errorf(
"Unable to open file %s: %s", fname, err)
}
...
}
golang.fyi/ch05/anagram2.go
将错误值分配给高级变量,以便根据需要在整个程序中重复使用,也是惯用的做法。以下摘录自golang.org/src/os/error.go
显示了与 OS 文件操作相关的可重用错误的声明:
var (
ErrInvalid = errors.New("invalid argument")
ErrPermission = errors.New("permission denied")
ErrExist = errors.New("file already exists")
ErrNotExist = errors.New("file does not exist")
)
您还可以创建自己的error
接口实现来创建自定义错误。这个主题在第七章中重新讨论,方法,接口和对象,在这本书中讨论了扩展类型的概念。
推迟函数调用
Go 支持推迟函数调用的概念。在函数调用之前放置关键字defer
会有一个有趣的效果,将函数推入内部堆栈,延迟其执行直到封闭函数返回之前。为了更好地解释这一点,让我们从以下简单的程序开始,它演示了defer
的用法:
package main
import "fmt"
func do(steps ...string) {
defer fmt.Println("All done!")
for _, s := range steps {
defer fmt.Println(s)
}
fmt.Println("Starting")
}
func main() {
do(
"Find key",
"Aplly break",
"Put key in ignition",
"Start car",
)
}
golang.fyi/ch05/defer1.go
前面的示例定义了do
函数,该函数接受可变参数steps
。该函数使用defer fmt.Println("All done!")
推迟语句。接下来,函数循环遍历切片steps
,并推迟每个元素的输出,使用defer fmt.Println(s)
。函数do
中的最后一个语句是一个非延迟调用fmt.Println("Starting")
。当程序执行时,请注意打印的字符串值的顺序,如下面的输出所示:
$> go run defer1.go
Starting
Start car
Put key in ignition
Aplly break
Find key
All done!
有几个事实可以解释打印顺序的反向顺序。首先,回想一下,延迟函数在其封闭函数返回之前执行。因此,第一个打印的值是由最后一个非延迟方法调用生成的。接下来,如前所述,延迟语句被推入堆栈。因此,延迟调用使用后进先出的顺序执行。这就是为什么输出中的最后一个字符串值是"All done!"
。
使用 defer
defer
关键字通过延迟函数调用修改程序的执行流程。这一特性的惯用用法之一是进行资源清理。由于 defer 总是在封闭函数返回时执行,因此它是一个很好的地方来附加清理代码,比如:
-
关闭打开的文件
-
释放网络资源
-
关闭 Go 通道
-
提交数据库事务
-
等等
为了说明,让我们回到之前的变位词示例。下面的代码片段显示了在加载文件后使用 defer 关闭文件的代码版本。load
函数在返回之前调用file.Close()
:
func load(fname string) ([]string, error) {
...
file, err := os.Open(fname)
if err != nil {
return nil, err
}
defer file.Close()
...
}
golang.fyi/ch05/anagram2.go
打开-推迟-关闭资源的模式在 Go 中被广泛使用。在打开或创建资源后立即放置延迟意图的做法使得代码读起来更自然,并减少了资源泄漏的可能性。
函数 panic 和恢复
在本章的前面提到,Go 没有其他语言提供的传统异常机制。尽管如此,在 Go 中,有一种称为函数 panic 的突然退出执行函数的方法。相反,当程序处于 panic 状态时,Go 提供了一种恢复并重新控制执行流程的方法。
函数 panic
在执行过程中,函数可能因为以下任何一个原因而 panic:
-
显式调用panic内置函数
-
使用由于异常状态而引发 panic 的源代码包
-
访问 nil 值或超出数组范围的元素
-
并发死锁
当函数 panic 时,它会中止并执行其延迟调用。然后它的调用者 panic,导致如下图所示的连锁反应:
panic 序列一直沿着调用堆栈一直到达main
函数并且程序退出(崩溃)。以下源代码片段显示了一个版本的 anagram 程序,如果尝试创建一个输出 anagram 文件时已经存在,则会导致显式 panic。这是为了导致write
函数在出现文件错误时引发 panic:
package main
...
func write(fname string, anagrams map[string][]string) {
file, err := os.OpenFile(
fname,
os.O_WRONLY+os.O_CREATE+os.O_EXCL,
0644,
)
if err != nil {
msg := fmt.Sprintf(
"Unable to create output file: %v", err,
)
panic(msg)
}
...
}
func main() {
words, err := load("dict.txt")
if err != nil {
fmt.Println("Unable to load file:", err)
os.Exit(1)
}
anagrams := mapWords(words)
write("out.txt", anagrams)
}
golang.fyi/ch05/anagram2.go
在上面的片段中,如果os.OpenFile()
方法出错,write
函数调用panic
函数。当程序调用main
函数时,如果工作目录中已经存在输出文件,程序将会引发 panic 并像下面的堆栈跟踪所示一样崩溃,指示导致崩溃的调用序列:
> go run anagram2.go
panic: Unable to create output file: open out.txt: file exists
goroutine 1 [running]:
main.write(0x4e7b30, 0x7, 0xc2080382a0)
/Go/src/github.com/vladimirvivien/learning-go/ch05/anagram2.go:72 +0x1a3
main.main()
Go/src/github.com/vladimirvivien/learning-go/ch05/anagram2.go:103 +0x1e9
exit status 2
函数 panic 恢复
当一个函数引发 panic 时,正如前面所解释的,它可能会导致整个程序崩溃。根据您的需求,这可能是期望的结果。然而,可以在 panic 序列开始后重新获得控制。为此,Go 提供了名为recover
的内置函数。
recover 与 panic 协同工作。对 recover 函数的调用会返回作为参数传递给 panic 的值。以下代码展示了如何从前面的示例中引入的 panic 调用中恢复。在这个版本中,write 函数被移动到makeAnagram()
中以提高清晰度。当从makeAnagram()
调用write
函数并且无法打开文件时,它会引发 panic。然而,现在添加了额外的代码来进行恢复:
package main
...
func write(fname string, anagrams map[string][]string) {
file, err := os.OpenFile(
fname,
os.O_WRONLY+os.O_CREATE+os.O_EXCL,
0644,
)
if err != nil {
msg := fmt.Sprintf(
"Unable to create output file: %v", err,
)
panic(msg)
}
...
}
func makeAnagrams(words []string, fname string) {
defer func() {
if r := recover(); r != nil {
fmt.Println("Failed to make anagram:", r)
}
}()
anagrams := mapWords(words)
write(fname, anagrams)
}
func main() {
words, err := load("")
if err != nil {
fmt.Println("Unable to load file:", err)
os.Exit(1)
}
makeAnagrams(words, "")
}
golang.fyi/ch05/anagram3.go
为了能够从一个展开的 panic 序列中恢复,代码必须对 recover 函数进行延迟调用。在前面的代码中,这是在makeAnagrams
函数中通过将recover()
包装在一个匿名函数文字中完成的,如下面的片段所示:
defer func() {
if r := recover(); r != nil {
fmt.Println("Failed to make anagram:", r)
}
}()
当执行延迟的recover
函数时,程序有机会重新获得控制并阻止 panic 导致程序崩溃。如果recover()
返回nil
,这意味着当前没有 panic 在调用堆栈上展开,或者 panic 已经在下游处理过了。
因此,现在当程序执行时,不会崩溃并显示堆栈跟踪,而是会恢复并优雅地显示问题,如下面的输出所示:
> go run anagram3.go
Failed to make anagram: Unable to open output file for creation: open out.txt: file exists
注意
您可能想知道为什么我们在测试recover
函数返回的值时使用nil
,而在调用panic
时传递了一个字符串。这是因为 panic 和 recover 都采用了空接口类型。正如您将了解的那样,空接口类型是一个通用类型,具有表示 Go 类型系统中的任何类型的能力。在第七章方法、接口和对象中关于接口的讨论中,我们将更多地了解空接口。
总结
本章向读者介绍了 Go 函数的探索。它从命名函数声明的概述开始,然后讨论了函数参数。本章深入讨论了函数类型和函数值。本章的最后部分讨论了错误处理、panic 和恢复的语义。下一章将继续讨论函数;然而,它是在 Go 包的上下文中进行的。它解释了包作为 Go 函数(和其他代码元素)的逻辑分组形成可共享和可调用的代码模块的角色。
第六章:Go 包和程序
第五章, Go 中的函数涵盖了函数,这是代码组织的基本抽象级别,使代码可寻址和可重用。本章将继续讨论围绕 Go 包展开的抽象层次。正如将在这里详细介绍的那样,包是存储在源代码文件中的语言元素的逻辑分组,可以共享和重用,如下面的主题所涵盖的:
-
Go 包
-
创建包
-
构建包
-
包可见性
-
导入包
-
包初始化
-
创建程序
-
远程包
Go 包
与其他语言类似,Go 源代码文件被分组为可编译和可共享的单元,称为包。但是,所有 Go 源文件必须属于一个包(没有默认包的概念)。这种严格的方法使得 Go 可以通过偏爱惯例而不是配置来保持其编译规则和包解析规则简单。让我们深入了解包的基础知识,它们的创建、使用和推荐做法。
理解 Go 包
在我们深入讨论包的创建和使用之前,至关重要的是从高层次上理解包的概念,以帮助引导后续的讨论。Go 包既是代码组织的物理单元,也是逻辑单元,用于封装可以重用的相关概念。按照惯例,存储在同一目录中的一组源文件被认为是同一个包的一部分。以下是一个简单的目录树示例,其中每个目录代表一个包,包含一些源代码:
foo
├── blat.go
└── bazz
├── quux.go
└── qux.go
golang.fyi/ch06-foo
虽然不是必需的,但是建议按照惯例,在每个源文件中设置包的名称与文件所在目录的名称相匹配。例如,源文件blat.go
被声明为foo
包的一部分,因为它存储在名为foo
的目录中,如下面的代码所示:
package foo
import (
"fmt"
"foo/bar/bazz"
)
func fooIt() {
fmt.Println("Foo!")
bazz.Qux()
}
golang.fyi/ch06-foo/foo/blat.go
文件quux.go
和qux.go
都是bazz
包的一部分,因为它们位于具有该名称的目录中,如下面的代码片段所示:
|
package bazz
import "fmt"
func Qux() {
fmt.Println("bazz.Qux")
}
golang.fyi/ch06-foo/foo/bazz/quux.go |
package bazz
import "fmt"
func Quux() {
Qux()fmt.Println("gazz.Quux")
}
golang.fyi/ch06-foo/foo/bazz/qux.go |
工作区
在讨论包时理解的另一个重要概念是Go 工作区。工作区只是一个任意的目录,用作在某些任务(如编译)期间解析包的命名空间。按照惯例,Go 工具期望工作区目录中有三个特定命名的子目录:src
、pkg
和bin
。这些子目录分别存储 Go 源文件以及所有构建的包构件。
建立一个静态目录位置,将 Go 包放在一起具有以下优势:
-
简单设置,几乎没有配置
-
通过将代码搜索减少到已知位置来实现快速编译
-
工具可以轻松创建代码和包构件的源图
-
从源代码自动推断和解析传递依赖关系
-
项目设置可以是可移植的,并且易于分发
以下是我笔记本电脑上 Go 工作区的部分(和简化的)树状布局,其中突出显示了三个子目录bin
、pkg
和src
:
|
/home/vladimir/Go/
├── bin
│ ├── circ
│ ├── golint
│ ...
├── pkg
│ └── linux_amd64
│ ├── github.com
│ │ ├── golang
│ │ │ └── lint.a
│ │ └── vladimirvivien
│ │ └── learning-go
│ │ └── ch06
│ │ ├── current.a
│ ... ...
└── src
├── github.com
│ ├── golang
│ │ └── lint
│ │ ├── golint
│ │ │ ├── golint.go
│ ... ... ...
│ └── vladimirvivien
│ └── learning-go
│ ├── ch01
│ ...
│ ├── ch06
│ │ ├── current
│ │ │ ├── doc.go
│ │ │ └── lib.go
... ...
|
示例工作区目录
-
bin
:这是一个自动生成的目录,用于存储编译的 Go 可执行文件(也称为程序或命令)。当 Go 工具编译和安装可执行包时,它们被放置在此目录中。前面的示例工作区显示了两个列出的二进制文件circ
和golint
。建议将此目录添加到操作系统的PATH
环境变量中,以使您的命令在本地可用。 -
pkg
:这个目录也是自动生成的,用于存储构建的包构件。当 Go 工具构建和安装非可执行包时,它们被存储为对象文件(带有.a
后缀)在子目录中,子目录的名称模式基于目标操作系统和架构。在示例工作区中,对象文件被放置在linux_amd64
子目录下,这表明该目录中的对象文件是为运行在 64 位架构上的 Linux 操作系统编译的。 -
src
:这是一个用户创建的目录,用于存储 Go 源代码文件。src
下的每个子目录都映射到一个包。src是解析所有导入路径的根目录。Go 工具搜索该目录以解析代码中引用的包,这些引用在编译或其他依赖源路径的活动中。上图中的示例工作区显示了两个包:github.com/golang/lint/golint/
和github.com/vladimirvivien/learning-go/ch06/current
。
注意
您可能会对工作区示例中显示的包路径中的github.com
前缀感到疑惑。值得注意的是,包目录没有命名要求(请参阅命名包部分)。包可以有任意的名称。但是,Go 建议遵循一些约定,这有助于全局命名空间解析和包组织。
创建工作区
创建工作区就像设置一个名为GOPATH
的操作系统环境一样简单,并将其分配给工作区目录的根路径。例如,在 Linux 机器上,工作区的根目录为/home/username/Go
,工作区将被设置为:
$> export GOPATH=/home/username/Go
在设置GOPATH
环境变量时,可以指定存储包的多个位置。每个目录由操作系统相关的路径分隔符分隔(换句话说,Linux/Unix 使用冒号,Windows 使用分号),如下所示:
$> export GOPATH=/home/myaccount/Go;/home/myaccount/poc/Go
当解析包名称时,Go 工具将搜索GOPATH
中列出的所有位置。然而,Go 编译器只会将编译后的文件,如对象和二进制文件,存储在分配给GOPATH
的第一个目录位置中。
注意
通过简单设置操作系统环境变量来配置工作区具有巨大的优势。它使开发人员能够在编译时动态设置工作区,以满足某些工作流程要求。例如,开发人员可能希望在合并代码之前测试未经验证的代码分支。他或她可能希望设置一个临时工作区来构建该代码,方法如下(Linux):$> GOPATH=/temporary/go/workspace/path go build
导入路径
在继续设置和使用包的详细信息之前,最后一个重要概念要涵盖的是导入路径的概念。每个包的相对路径,位于工作区路径$GOPATH/src
下,构成了一个全局标识符,称为包的导入路径
。这意味着在给定的工作区中,没有两个包可以具有相同的导入路径值。
让我们回到之前的简化目录树。例如,如果我们将工作区设置为某个任意路径值,如GOPATH=/home/username/Go
:
/home/username/Go
└── foo
├── ablt.go
└── bazz
├── quux.go
└── qux.go
从上面示例的工作区中,包的目录路径映射到它们各自的导入路径,如下表所示:
目录路径 | 导入路径 |
---|---|
/home/username/Go/foo |
"foo"
|
/home/username/Go/foo/bar |
---|
"foo/bar"
|
/home/username/Go/foo/bar/bazz |
---|
"foo/bar/bazz"
|
创建包
到目前为止,本章已经涵盖了 Go 包的基本概念;现在是时候深入了解并查看包含在包中的 Go 代码的创建。Go 包的主要目的之一是将常见逻辑抽象出来并聚合到可共享的代码单元中。在本章的前面提到,一个目录中的一组 Go 源文件被认为是一个包。虽然这在技术上是正确的,但是关于 Go 包的概念还不仅仅是将一堆文件放在一个目录中。
为了帮助说明我们的第一个包的创建,我们将利用在github.com/vladimirvivien/learning-go/ch06中找到的示例源代码。该目录中的代码定义了一组函数,用于使用欧姆定律计算电气值。以下显示了组成示例包的目录布局(假设它们保存在某个工作区目录$GOPATH/src
中):
|
github.com/vladimirvivien/learning-go/ch06
├── current
│ ├── curr.go
│ └── doc.go
├── power
│ ├── doc.go
│ ├── ir
│ │ └── power.go
│ ├── powlib.go
│ └── vr
│ └── power.go
├── resistor
│ ├── doc.go
│ ├── lib.go
│ ├── res_equivalence.go
│ ├── res.go
│ └── res_power.go
└── volt
├── doc.go
└── volt.go
|
Ohm's Law 示例的包布局
在上述目录中,每个目录都包含一个或多个 Go 源代码文件,用于定义和实现函数以及其他源代码元素,这些元素将被组织成包并可重复使用。以下表格总结了从前面的工作区布局中提取的导入路径和包信息:
导入路径 | 包 |
---|---|
"github.com/vladimirvivien/learning-go/ch06/current" | current |
"github.com/vladimirvivien/learning-go/ch06/power" | power |
"github.com/vladimirvivien/learning-go/ch06/power/ir" | ir |
"github.com/vladimirvivien/learning-go/ch06/power/vr" | vr |
"github.com/vladimirvivien/learning-go/ch06/resistor" | resistor |
"github.com/vladimirvivien/learning-go/ch06/volt" | volt |
虽然没有命名要求,但是将包目录命名为反映其各自目的的名称是明智的。从前面的表格中,每个示例中的包都被命名为代表电气概念的名称,例如 current、power、resistor 和 volt。包命名部分将详细介绍包命名约定。
声明包
Go 源文件必须声明自己属于一个包。这是使用package
子句完成的,作为 Go 源文件中的第一个合法语句。声明的包由package
关键字后跟一个名称标识符组成。以下显示了volt
包中的源文件volt.go
:
package volt
func V(i, r float64) float64 {
return i * r
}
func Vser(volts ...float64) (Vtotal float64) {
for _, v := range volts {
Vtotal = Vtotal + v
}
return
}
func Vpi(p, i float64) float64 {
return p / i
}
golang.fyi/ch06/volt/volt.go
源文件中的包标识符可以设置为任意值。与 Java 不同,包的名称不反映源文件所在的目录结构。虽然对于包名称没有要求,但是将包标识符命名为与文件所在目录相同的约定是被接受的。在我们之前的源代码清单中,包被声明为标识符volt
,因为该文件存储在volt目录中。
多文件包
一个包的逻辑内容(源代码元素,如类型、函数、变量和常量)可以在多个 Go 源文件中物理扩展。一个包目录可以包含一个或多个 Go 源文件。例如,在下面的示例中,包resistor
被不必要地分割成几个 Go 源文件,以说明这一点:
|
package resistor
func recip(val float64) float64 {
return 1 / val
}
golang.fyi/ch06/resistor/lib.go |
|
package resistor
func Rser(resists ...float64) (Rtotal float64) {
for _, r := range resists {
Rtotal = Rtotal + r
}
return
}
func Rpara(resists ...float64) (Rtotal float64) {
for _, r := range resists {
Rtotal = Rtotal + recip(r)
}
return
}
golang.fyi/ch06/resistor/res_equivalance.go |
|
package resistor
func R(v, i float64) float64 {
return v / i
}
golang.fyi/ch06/resistor/res.go |
|
package resistor
func Rvp(v, p float64) float64 {
return (v * v) / p
}
golang.fyi/ch06/resistor/res_power.go |
包中的每个文件必须具有相同的名称标识符的包声明(在本例中为resistor
)。Go 编译器将从所有源文件中的所有元素中拼接出一个逻辑单元,形成一个可以被其他包使用的单一范围内的逻辑单元。
需要指出的是,如果给定目录中所有源文件的包声明不相同,编译将失败。这是可以理解的,因为编译器期望目录中的所有文件都属于同一个包。
命名包
如前所述,Go 期望工作区中的每个包都有一个唯一的完全限定的导入路径。您的程序可以拥有任意多的包,您的包结构可以在工作区中深入到您喜欢的程度。然而,惯用的 Go 规定了一些关于包的命名和组织的规则,以使创建和使用包变得简单。
使用全局唯一的命名空间
首先,在全局上下文中,完全限定您的包的导入路径是一个好主意,特别是如果您计划与他人共享您的代码。考虑以唯一标识您或您的组织的命名空间方案开始您的导入路径的名称。例如,公司Acme, Inc.可能选择以acme.com/apps
开头命名他们所有的 Go 包名称。因此,一个包的完全限定导入路径将是"acme.com/apps/foo/bar"
。
注意
在本章的后面,我们将看到如何在集成 Go 与 GitHub 等源代码存储库服务时使用包导入路径。
为路径添加上下文
接下来,当您为您的包设计一个命名方案时,使用包的路径为您的包名称添加上下文。名称中的上下文应该从左到右开始通用,然后变得更具体。例如,让我们参考电源包的导入路径(来自之前的示例)。电源值的计算分为三个子包,如下所示:
-
github.com/vladimirvivien/learning-go/ch06/**power**
-
github.com/vladimirvivien/learning-go/ch06/**power/ir**
-
github.com/vladimirvivien/learning-go/ch06/**power/vr**
父路径power
包含具有更广泛上下文的包成员。子包ir
和vr
包含更具体的成员,具有更窄的上下文。这种命名模式在 Go 中被广泛使用,包括内置包,如以下所示:
-
crypto/md5
-
net/http
-
net/http/httputil
-
reflect
请注意,一个包深度为一是一个完全合法的包名称(参见reflect
),只要它能捕捉上下文和它所做的本质。同样,保持简单。避免在您的命名空间内将您的包嵌套超过三层的诱惑。如果您是一个习惯于长嵌套包名称的 Java 开发人员,这种诱惑将特别强烈。
使用简短的名称
当审查内置的 Go 包名称时,您会注意到一个事实,即与其他语言相比,名称的简洁性。在 Go 中,包被认为是实现一组紧密相关功能的代码集合。因此,您的包的导入路径应该简洁,并反映出它们的功能,而不会过长。我们的示例源代码通过使用诸如 volt、power、resistance、current 等简短名称来命名包目录,充分体现了这一点。在各自的上下文中,每个目录名称都准确说明了包的功能。
在 Go 的内置包中严格遵守了简短名称规则。例如,以下是 Go 内置包中的几个包名称:log
、http
、xml
和zip
。每个名称都能够清楚地识别包的目的。
注意
短包名称有助于减少在较大代码库中的击键次数。然而,拥有短而通用的包名称也有一个缺点,即容易发生导入路径冲突,即在大型项目中的开发人员(或开源库的开发人员)可能最终在他们的代码中使用相同的流行名称(换句话说,log
、util
、db
等)。正如我们将在本章后面看到的那样,这可以通过使用命名
导入路径来处理。
构建包
通过应用某些约定和合理的默认值,Go 工具减少了编译代码的复杂性。虽然完整讨论 Go 的构建工具超出了本节(或本章)的范围,但了解build
和install
工具的目的和用法是有用的。一般来说,使用构建和安装工具的方式如下:
$> go build [
import path
可以明确提供或完全省略。build
工具接受import path
,可以表示为完全限定或相对路径。在正确设置的工作区中,以下是从前面的示例中编译包volt
的等效方式:
$> cd $GOPATH/src/github.com/vladimirvivien/learning-go
$> go build ./ch06/volt
$> cd $GOPATH/src/github.com/vladimirvivien/learning-go/ch06
$> go build ./volt
$> cd $GOPATH/src/github.com/vladimirvivien/learning-go/ch06/volt
$> go build .
$> cd $GOPATH/src/
$> go build github.com/vladimirvivien/learning-go/ch06/current /volt
上面的go build
命令将编译在目录volt
中找到的所有 Go 源文件及其依赖项。此外,还可以使用通配符参数构建给定目录中的所有包和子包,如下所示:
$> cd $GOPATH/src/github.com/vladimirvivien/learning-go/ch06
$> go build ./...
前面的内容将构建在目录$GOPATH/src/github.com/vladimirvivien/learning-go/ch06
中找到的所有包和子包。
安装一个包
默认情况下,构建命令将其结果输出到一个工具生成的临时目录中,在构建过程完成后会丢失。要实际生成可用的构件,必须使用install
工具来保留已编译的对象文件的副本。
install
工具与构建工具具有完全相同的语义:
$> cd $GOPATH/src/github.com/vladimirvivien/learning-go/ch06
$> go install ./volt
除了编译代码,它还将结果保存并输出到工作区位置$GOPATH/pkg
,如下所示:
$GOPATH/pkg/linux_amd64/github.com/vladimirvivien/learning-go/
└── ch06
└── volt.a
生成的对象文件(带有.a
扩展名)允许包在工作区中被重用和链接到其他包中。在本章的后面,我们将讨论如何编译可执行程序。
包可见性
无论声明为包的一部分的源文件数量如何,所有在包级别声明的源代码元素(类型、变量、常量和函数)都共享一个公共作用域。因此,编译器不允许在整个包中重新声明元素标识符超过一次。让我们使用以下代码片段来说明这一点,假设两个源文件都是同一个包$GOPATH/src/foo
的一部分:
|
package foo
var (
bar int = 12
)
func qux () {
bar += bar
}
foo/file1.go |
package foo
var bar struct{
x, y int
}
func quux() {
bar = bar * bar
}
foo/file2.go |
非法的变量标识符重新声明
尽管它们在两个不同的文件中,但在 Go 中使用标识符bar
声明变量是非法的。由于这些文件是同一个包的一部分,两个标识符具有相同的作用域,因此会发生冲突。
函数标识符也是如此。Go 不支持在相同作用域内重载函数名称。因此,无论函数的签名如何,使用函数标识符超过一次都是非法的。如果我们假设以下代码出现在同一包内的两个不同源文件中,则以下代码片段将是非法的:
|
package foo
var (
bar int = 12
)
func qux () {
bar += bar
}
foo/file1.go |
package foo
var (
fooVal int = 12
)
func qux (inc int) int {
return fooVal += inc
}
foo/file1.go |
非法的函数标识符重新声明
在前面的代码片段中,函数名标识符qux
被使用了两次。即使这两个函数具有不同的签名,编译器也会失败。唯一的解决方法是更改名称。
包成员可见性
包的有用性在于其能够将其源元素暴露给其他包。控制包元素的可见性很简单,遵循这个规则:大写标识符会自动导出。这意味着任何具有大写标识符的类型、变量、常量或函数都会自动从声明它的包之外可见。
参考之前描述的欧姆定律示例,以下说明了来自包resistor
(位于github.com/vladimirvivien/learning-go/ch06/resistor)的功能:
代码 | 描述 |
---|
|
package resistor
func R(v, i float64) float64 {
return v / i
}
函数R 自动导出,并且可以从其他包中访问:resistor.R() |
---|
|
package resistor
func recip(val float64) float64 {
return 1 / val
}
函数标识符recip 全部小写,因此未导出。虽然在其自己的范围内可访问,但该函数将无法从其他包中可见。 |
---|
值得重申的是,同一个包内的成员始终对彼此可见。在 Go 中,没有复杂的可见性结构,比如私有、友元、默认等,这使得开发人员可以专注于正在实现的解决方案,而不是对可见性层次进行建模。
导入包
到目前为止,您应该对包是什么,它的作用以及如何创建包有了很好的理解。现在,让我们看看如何使用包来导入和重用其成员。正如您在其他几种语言中所发现的那样,关键字import
用于从外部包中导入源代码元素。它允许导入源访问导入包中的导出元素(请参阅本章前面的包范围和可见性部分)。导入子句的一般格式如下:
import [包名称标识符] "<导入路径>"
请注意,导入路径必须用双引号括起来。import
语句还支持可选的包标识符,可用于显式命名导入的包(稍后讨论)。导入语句也可以写成导入块的形式,如下所示。这在列出两个或更多导入包的情况下很有用:
import (
[包名称标识符] "<导入路径>"
)
以下源代码片段显示了先前介绍的欧姆定律示例中的导入声明块:
import (
"flag"
"fmt"
"os"
"github.com/vladimirvivien/learning-go/ch06/current"
"github.com/vladimirvivien/learning-go/ch06/power"
"github.com/vladimirvivien/learning-go/ch06/power/ir"
"github.com/vladimirvivien/learning-go/ch06/power/vr"
"github.com/vladimirvivien/learning-go/ch06/volt"
)
golang.fyi/ch06/main.go
通常省略导入包的名称标识符,如上所示。然后,Go 将导入路径的最后一个目录的名称作为导入包的名称标识符,如下表所示,对于某些包:
导入路径 | 包名称 |
---|---|
flag |
flag |
github.com/vladimirvivien/learning-go/ch06/current |
current |
github.com/vladimirvivien/learning-go/ch06/power/ir |
ir |
github.com/vladimirvivien/learning-go/ch06/volt |
volt |
点符号用于访问导入包的导出成员。例如,在下面的源代码片段中,从导入包"github.com/vladimirvivien/learning-go/ch06/volt"
调用了方法volt.V()
:
...
import "github.com/vladimirvivien/learning-go/ch06/volt"
func main() {
...
switch op {
case "V", "v":
val := volt.V(i, r)
...
}
golang.fyi/ch06/main.go
指定包标识符
如前所述,import
声明可以显式为导入声明一个名称标识符,如下面的导入片段所示:
import res "github.com/vladimirvivien/learning-go/ch06/resistor"
按照前面描述的格式,名称标识符放在导入路径之前,如前面的片段所示。命名包可以用作缩短或自定义包名称的一种方式。例如,在一个大型源文件中,有大量使用某个包的情况下,这可以是一个很好的功能,可以减少按键次数。
给包分配一个名称也是避免给定源文件中的包标识符冲突的一种方式。可以想象导入两个或更多的包,具有不同的导入路径,解析为相同的包名称。例如,您可能需要使用来自不同库的两个不同日志系统记录信息,如下面的代码片段所示:
package foo
import (
flog "github.com/woom/bat/logger"
hlog "foo/bar/util/logger"
)
func main() {
flog.Info("Programm started")
err := doSomething()
if err != nil {
hlog.SubmitError("Error - unable to do something")
}
}
如前面的片段所示,两个日志包默认都将解析为名称标识符"logger"
。为了解决这个问题,至少其中一个导入的包必须分配一个名称标识符来解决名称冲突。在上面的例子中,两个导入路径都被命名为有意义的名称,以帮助代码理解。
点标识符
一个包可以选择将点(句号)分配为它的标识符。当一个import
语句使用点标识符(.
)作为导入路径时,它会导致导入包的成员与导入包的作用域合并。因此,导入的成员可以在不添加额外限定符的情况下被引用。因此,如果在以下源代码片段中使用点标识符导入了包logger
,那么在访问 logger 包的导出成员函数SubmitError
时,包名被省略了:
package foo
import (
. "foo/bar/util/logger"
)
func main() {
err := doSomething()
if err != nil {
SubmitError("Error - unable to do something")
}
}
虽然这个特性可以帮助减少重复的按键,但这并不是一种鼓励的做法。通过合并包的作用域,更有可能遇到标识符冲突。
空白标识符
当导入一个包时,要求在导入的代码中至少引用其成员之一。如果未能这样做,将导致编译错误。虽然这个特性有助于简化包依赖关系的解析,但在开发代码的早期阶段,这可能会很麻烦。
使用空白标识符(类似于变量声明)会导致编译器绕过此要求。例如,以下代码片段导入了内置包fmt
;但是,在随后的源代码中从未使用过它:
package foo
import (
_ "fmt"
"foo/bar/util/logger"
)
func main() {
err := doSomething()
if err != nil {
logger.Submit("Error - unable to do something")
}
}
空白标识符的一个常见用法是为了加载包的副作用。这依赖于包在导入时的初始化顺序(请参阅下面的包初始化部分)。使用空白标识符将导致导入的包在没有引用其成员的情况下被初始化。这在需要在不引起注意的情况下运行某些初始化序列的代码中使用。
包初始化
当导入一个包时,它会在其成员准备好被使用之前经历一系列的初始化序列。包级变量的初始化是使用依赖分析来进行的,依赖于词法作用域解析,这意味着变量是基于它们的声明顺序和它们相互引用的解析来初始化的。例如,在以下代码片段中,包foo
中的解析变量声明顺序将是a
、y
、b
和x
:
package foo
var x = a + b(a)
var a = 2
var b = func(i int) int {return y * i}
var y = 3
Go 还使用了一个名为init
的特殊函数,它不接受任何参数,也不返回任何结果值。它用于封装在导入包时调用的自定义初始化逻辑。例如,以下源代码显示了在resistor
包中使用的init
函数来初始化函数变量Rpi
:
package resistor
var Rpi func(float64, float64) float64
func init() {
Rpi = func(p, i float64) float64 {
return p / (i * i)
}
}
func Rvp(v, p float64) float64 {
return (v * v) / p
}
golang.fyi/ch06/resistor/res_power.go
在前面的代码中,init
函数在包级变量初始化之后被调用。因此,init
函数中的代码可以安全地依赖于声明的变量值处于稳定状态。init
函数在以下方面是特殊的:
-
一个包可以定义多个
init
函数 -
您不能直接在运行时访问声明的
init
函数 -
它们按照它们在每个源文件中出现的词法顺序执行
-
init
函数是将逻辑注入到在任何其他函数或方法之前执行的包中的一种很好的方法。
创建程序
到目前为止,在本书中,您已经学会了如何创建和捆绑 Go 代码作为可重用的包。但是,包本身不能作为独立的程序执行。要创建一个程序(也称为命令),您需要取一个包,并定义一个执行入口,如下所示:
-
声明(至少一个)源文件作为名为
main
的特殊包的一部分 -
声明一个名为
main()
的函数作为程序的入口点
函数main
不接受任何参数,也不返回任何值。以下是main
包的缩写源代码,用于之前的 Ohm 定律示例中。它使用了 Go 标准库中的flag
包来解析格式为flag
的程序参数:
package main
import (
"flag"
"fmt"
"os"
"github.com/vladimirvivien/learning-go/ch06/current"
"github.com/vladimirvivien/learning-go/ch06/power"
"github.com/vladimirvivien/learning-go/ch06/power/ir"
"github.com/vladimirvivien/learning-go/ch06/power/vr"
res "github.com/vladimirvivien/learning-go/ch06/resistor"
"github.com/vladimirvivien/learning-go/ch06/volt"
)
var (
op string
v float64
r float64
i float64
p float64
usage = "Usage: ./circ <command> [arguments]\n" +
"Valid command { V | Vpi | R | Rvp | I | Ivp |"+
"P | Pir | Pvr }"
)
func init() {
flag.Float64Var(&v, "v", 0.0, "Voltage value (volt)")
flag.Float64Var(&r, "r", 0.0, "Resistance value (ohms)")
flag.Float64Var(&i, "i", 0.0, "Current value (amp)")
flag.Float64Var(&p, "p", 0.0, "Electrical power (watt)")
flag.StringVar(&op, "op", "V", "Command - one of { V | Vpi |"+
" R | Rvp | I | Ivp | P | Pir | Pvr }")
}
func main() {
flag.Parse()
// execute operation
switch op {
case "V", "v":
val := volt.V(i, r)
fmt.Printf("V = %0.2f * %0.2f = %0.2f volts\n", i, r, val)
case "Vpi", "vpi":
val := volt.Vpi(p, i)
fmt.Printf("Vpi = %0.2f / %0.2f = %0.2f volts\n", p, i, val)
case "R", "r":
val := res.R(v, i))
fmt.Printf("R = %0.2f / %0.2f = %0.2f Ohms\n", v, i, val)
case "I", "i":
val := current.I(v, r))
fmt.Printf("I = %0.2f / %0.2f = %0.2f amps\n", v, r, val)
...
default:
fmt.Println(usage)
os.Exit(1)
}
}
golang.fyi/ch06/main.go
前面的清单显示了main
包的源代码以及main
函数的实现,当程序运行时将执行该函数。Ohm's Law 程序接受指定要执行的电气操作的命令行参数(请参阅下面的访问程序参数部分)。init
函数用于初始化程序标志值的解析。main
函数设置为一个大的开关语句块,以根据所选的标志选择要执行的适当操作。
访问程序参数
当程序被执行时,Go 运行时将所有命令行参数作为一个切片通过包变量os.Args
提供。例如,当执行以下程序时,它会打印传递给程序的所有命令行参数:
package main
import (
"fmt"
"os"
)
func main() {
for _, arg := range os.Args {
fmt.Println(arg)
}
}
golang.fyi/ch06-args/hello.go
当使用显示的参数调用程序时,以下是程序的输出:
$> go run hello.go hello world how are you?
/var/folders/.../exe/hello
hello
world
how
are
you?
请注意,程序名称后面放置的命令行参数"hello world how are you?"
被拆分为一个以空格分隔的字符串。切片os.Args
中的位置 0 保存了程序二进制路径的完全限定名称。切片的其余部分分别存储了字符串中的每个项目。
Go 标准库中的flag
包在内部使用此机制来提供已知为标志的结构化命令行参数的处理。在前面列出的 Ohm's Law 示例中,flag
包用于解析几个标志,如以下源代码片段中所示(从前面的完整清单中提取):
var (
op string
v float64
r float64
i float64
p float64
)
func init() {
flag.Float64Var(&v, "v", 0.0, "Voltage value (volt)")
flag.Float64Var(&r, "r", 0.0, "Resistance value (ohms)")
flag.Float64Var(&i, "i", 0.0, "Current value (amp)")
flag.Float64Var(&p, "p", 0.0, "Electrical power (watt)")
flag.StringVar(&op, "op", "V", "Command - one of { V | Vpi |"+
" R | Rvp | I | Ivp | P | Pir | Pvr }")
}
func main(){
flag.Parse()
...
}
代码片段显示了init
函数用于解析和初始化预期的标志"v"
、"i"
、"p"
和"op"
(在运行时,每个标志都以减号开头)。flag
包中的初始化函数设置了预期的类型、默认值、标志描述以及用于存储标志解析值的位置。flag
包还支持特殊标志"help",用于提供有关每个标志的有用提示。
flag.Parse()
在main
函数中用于开始解析作为命令行提供的任何标志的过程。例如,要计算具有 12 伏特和 300 欧姆的电路的电流,程序需要三个标志,并产生如下输出:
$> go run main.go -op I -v 12 -r 300
I = 12.00 / 300.00 = 0.04 amps
构建和安装程序
构建和安装 Go 程序遵循与构建常规包相同的程序(如在构建和安装包部分中讨论的)。当您构建可执行的 Go 程序的源文件时,编译器将通过传递链接main
包中声明的所有依赖项来生成可执行的二进制文件。构建工具将默认使用与包含 Go 程序源文件的目录相同的名称命名输出二进制文件。
例如,在 Ohm's Law 示例中,位于目录github.com/vladimirvivien/learning-go/ch06
中的文件main.go
被声明为main
包的一部分。程序可以按以下方式构建:
$> cd $GOPATH/src/github.com/vladimirvivien/learning-go/ch06
$> go build .
当构建main.go
源文件时,构建工具将生成一个名为ch06
的二进制文件,因为程序的源代码位于具有该名称的目录中。您可以使用输出标志-o
来控制二进制文件的名称。在以下示例中,构建工具将创建一个名为ohms
的二进制文件。
$> cd $GOPATH/src/github.com/vladimirvivien/learning-go/ch06
$> go build -o ohms
最后,安装 Go 程序的方法与使用 Go install
命令安装常规包的方法完全相同:
$> cd $GOPATH/src/github.com/vladimirvivien/learning-go/ch06
$> go install .
使用 Go install 命令安装程序时,如果需要,将构建该程序,并将生成的二进制文件保存在$GOPAHT/bin
目录中。将工作区bin
目录添加到您的操作系统的$PATH
环境变量中,将使您的 Go 程序可供执行。
注意
Go 生成的程序是静态链接的二进制文件。它们不需要满足任何额外的依赖关系就可以运行。但是,Go 编译的二进制文件包括 Go 运行时。这是一组处理功能的操作,如垃圾回收、类型信息、反射、goroutines 调度和 panic 管理。虽然可比的 C 程序会小上几个数量级,但 Go 的运行时带有使 Go 变得愉快的工具。
远程软件包
Go 附带的工具之一允许程序员直接从远程源代码存储库检索软件包。默认情况下,Go 可以轻松支持与以下版本控制系统的集成:
-
Git(
git
,git-scm.com/
) -
Mercurial(
hg
,www.mercurial-scm.org/
) -
Subversion(
svn
,subversion.apache.org/
) -
Bazaar(
bzr
,bazaar.canonical.com/
)
注意
为了让 Go 从远程存储库中拉取软件包源代码,您必须在操作系统的执行路径上安装该版本控制系统的客户端作为命令。在幕后,Go 启动客户端与源代码存储库服务器进行交互。
get
命令行工具允许程序员使用完全合格的项目路径作为软件包的导入路径来检索远程软件包。一旦软件包被下载,就可以在本地源文件中导入以供使用。例如,如果您想要包含前面片段中 Ohm's Law 示例中的一个软件包,您可以从命令行发出以下命令:
$> go get github.com/vladimirvivien/learning-go/ch06/volt
go get
工具将下载指定的导入路径以及所有引用的依赖项。然后,该工具将在$GOPATH/pkg
中构建和安装软件包工件。如果import
路径恰好是一个程序,go get 还将在$GOPATH/bin
中生成二进制文件,以及在$GOPATH/pkg
中引用的任何软件包。
总结
本章详细介绍了源代码组织和软件包的概念。读者了解了 Go 工作区和导入路径。读者还了解了如何创建软件包以及如何导入软件包以实现代码的可重用性。本章介绍了诸如导入成员的可见性和软件包初始化之类的机制。本章的最后部分讨论了从打包代码创建可执行 Go 程序所需的步骤。
这是一个冗长的章节,理所当然地对 Go 中软件包创建和管理这样一个广泛的主题进行了公正的处理。下一章将详细讨论复合类型,如数组、切片、结构和映射,回到 Go 类型讨论。
第七章:复合类型
在之前的章节中,您可能已经在一些示例代码中看到了复合类型(如数组、切片、映射和结构体)的使用。尽管对这些类型的早期接触可能让您感到好奇,但请放心,在本章中,您将有机会了解所有这些复合类型。本章继续了第四章数据类型中开始的内容,讨论了以下主题:
-
数组类型
-
切片类型
-
映射类型
-
结构类型
数组类型
正如您在其他语言中所看到的那样,Go 数组是用于存储相同类型的序列化值的容器,这些值是按数字索引的。以下代码片段显示了分配了数组类型的变量的示例:
var val [100]int
var days [7]string
var truth [256]bool
var histogram [5]map[string]int
golang.fyi/ch07/arrtypes.go
请注意,前面示例中分配给每个变量的类型是使用以下类型格式指定的:
[<长度>]<元素类型>
数组的类型定义由其长度组成,用括号括起来,后跟其存储元素的类型。例如,days
变量被分配了类型[7]string
。这是一个重要的区别,因为 Go 的类型系统认为存储相同类型元素但长度不同的两个数组是不同类型。以下代码说明了这种情况:
var days [7]string
var weekdays [5]string
尽管这两个变量都是具有string
类型元素的数组,但类型系统将days
和weekdays
变量视为不同类型。
注意
在本章的后面,您将看到如何使用切片类型而不是数组来缓解这种类型限制。
数组类型可以定义为多维的。这是通过将一维数组类型的定义组合和嵌套来实现的,如下面的代码片段所示:
var board [4][2]int
var matrix [2][2][2][2] byte
golang.fyi/ch07/arrtypes.go
Go 没有单独的多维数组类型。具有多个维度的数组由相互嵌套的一维数组组成。下一节将介绍如何初始化单维和多维数组。
数组初始化
当数组变量没有明确初始化时,所有元素将被分配为元素声明类型的零值。数组可以使用复合文字值进行初始化,其一般格式如下:
<数组类型>{<逗号分隔的元素值列表>}
数组的文字值由数组类型定义(在前一节中讨论)组成,后跟一组逗号分隔的值,用大括号括起来,如下面的代码片段所示,其中显示了声明和初始化了几个数组:
var val [100]int = [100]int{44,72,12,55,64,1,4,90,13,54}
var days [7]string = [7]string{
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
"Sunday",
}
var truth = [256]bool{true}
var histogram = [5]map[string]int {
map[string]int{"A":12,"B":1, "D":15},
map[string]int{"man":1344,"women":844, "children":577,...},
}
golang.fyi/ch07/arrinit.go
文字值中的元素数量必须小于或等于数组类型中声明的大小。如果定义的数组是多维的,可以通过将每个维度嵌套在另一个括号的括号中,使用文字值进行初始化,如下面的示例代码片段所示:
var board = [4][2]int{
{33, 23},
{62, 2},
{23, 4},
{51, 88},
}
var matrix = [2][2][2][2]byte{
{{{4, 4}, {3, 5}}, {{55, 12}, {22, 4}}},
{{{2, 2}, {7, 9}}, {{43, 0}, {88, 7}}},
}
golang.fyi/ch07/arrinit.go
以下代码片段显示了指定数组文字的另外两种方式。在初始化期间,数组的长度可以被省略并用省略号替换。以下将类型[5]string
分配给变量weekdays
:
var weekdays = [...]string{
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
}
数组的文字值也可以被索引。如果您只想初始化某些数组元素,同时允许其他元素以它们的自然零值进行初始化,这将非常有用。以下指定了位置 0、2
、4
、6
、8
的元素的初始值。其余元素将被分配为空字符串:
var msg = [12]rune{0: 'H', 2: 'E', 4: 'L', 6: 'O', 8: '!'}
声明命名数组类型
数组的类型可能会变得难以重用。对于每个声明,需要重复声明,这可能会出错。处理这种习惯用法的方法是使用类型声明别名数组类型。为了说明这是如何工作的,以下代码片段声明了一个新的命名类型matrix
,使用多维数组作为其基础类型:
type matrix [2][2][2][2]byte
func main() {
var mat1 matrix
mat1 = initMat()
fmt.Println(mat1)
}
func initMat() matrix {
return matrix{
{{{4, 4}, {3, 5}}, {{55, 12}, {22, 4}}},
{{{2, 2}, {7, 9}}, {{43, 0}, {88, 7}}},
}
}
golang.fyi/ch07/arrtype_dec.go
声明的命名类型matrix
可以在使用其基础数组类型的所有上下文中使用。这允许使用简化的语法,促进复杂数组类型的重用。
使用数组
数组是静态实体,一旦使用指定的长度声明,就无法增长或缩小。当程序需要分配预定义大小的连续内存块时,数组是一个很好的选择。当声明数组类型的变量时,它已经准备好在没有任何进一步分配语义的情况下使用。
因此,image
变量的以下声明将分配一个由 256 个相邻的int
值组成的内存块,并用零初始化,如下图所示:
var image [256]byte
与 C 和 Java 类似,Go 使用方括号索引表达式来访问存储在数组变量中的值。这是通过指定变量标识符,后跟方括号括起来的元素的索引来完成的,如下面的代码示例所示:
p := [5]int{122,6,23,44,6}
p[4] = 82
fmt.Println(p[0])
前面的代码更新了数组中的第五个元素,并打印了数组中的第一个元素。
数组长度和容量
内置的len
函数返回数组类型的声明长度。内置的cap
函数可以用于返回数组的容量。例如,在以下源代码片段中,类型为[7]string
的数组seven
将返回7
作为其长度和容量:
func main() {
seven := [7]string{"grumpy", "sleepy", "bashful"}
fmt.Println(len(seven), cap(seven))
}
对于数组,cap()
函数始终返回与len()
相同的值。这是因为数组值的最大容量是其声明的长度。容量函数更适合与切片类型一起使用(稍后在本章中讨论)。
数组遍历
数组遍历可以使用传统的for
语句或更符合习惯的for…range
语句。以下代码片段显示了使用for
语句进行数组遍历,以在init()
中使用随机数初始化数组,并使用for
范围语句实现max()
函数:
const size = 1000
var nums [size]int
func init() {
rand.Seed(time.Now().UnixNano())
for i := 0; i < size; i++ {
nums[i] = rand.Intn(10000)
}
}
func max(nums [size]int) int {
temp := nums[0]
for _, val := range nums {
if val > temp {
temp = val
}
}
return temp
}
golang.fyi/ch07/arrmax_iter.go
在传统的for
语句中,循环的索引变量i
用于使用索引表达式num[i]
访问数组的值。在for…range
语句中,在max
函数中,迭代的值存储在val
变量中,每次循环都会忽略索引(分配给空白标识符)。如果您不了解for语句的工作原理,请参阅第三章,Go 控制流,详细解释 Go 中循环的机制。
数组作为参数
数组值被视为单个单元。数组变量不是指向内存中的位置的指针,而是表示包含数组元素的整个内存块。当重新分配数组变量或将其作为函数参数传递时,这意味着创建数组值的新副本。
这可能会对程序的内存消耗产生不良的副作用。一个解决方法是使用指针类型来引用数组值。在以下示例中,声明了一个命名类型numbers
,表示数组类型[1024 * 1024]]int
。函数initialize()
和max()
不直接接受数组值作为参数,而是接受*numbers
类型的指针,如下面的源代码片段所示:
type numbers [1024 * 1024]int
func initialize(nums *numbers) {
rand.Seed(time.Now().UnixNano())
for i := 0; i < size; i++ {
nums[i] = rand.Intn(10000)
}
}
func max(nums *numbers) int {
temp := nums[0]
for _, val := range nums {
if val > temp {
temp = val
}
}
return temp
}
func main() {
var nums *numbers = new(numbers)
initialize(nums)
}
golang.fyi/ch07/arrptr.go
前面的代码使用内置函数new(numbers)
来初始化数组元素为它们的零值,并在main()
中获取指向该数组的指针。因此,当调用initialize
和max
函数时,它们将接收到数组的地址(其副本),而不是整个大小为 100K 的数组。
在改变主题之前,应该注意到复合文字数组值可以使用地址运算符&
初始化并返回数组的指针,如下例所示。在代码片段中,复合文字&galaxies{...}
返回指针*galaxies
,并用指定的元素值初始化:
type galaxies [14]string
func main() {
namedGalaxies = &galaxies{
"Andromeda",
"Black Eye",
"Bode's",
...
}
printGalaxies(namedGalaxies)
}
golang.fyi/ch07/arraddr.go
数组类型是 Go 中的低级存储构造。例如,数组通常用作存储原语的基础,其中有严格的内存分配要求以最小化空间消耗。然而,在更常见的情况下,切片,下一节中介绍的,通常被用作处理序列化索引集合的更成语化的方式。
切片类型
切片类型通常用作 Go 中索引数据的成语构造。切片比数组更灵活,具有许多更有趣的特性。切片本身是一种具有类似数组语义的复合类型。实际上,切片使用数组作为其底层数据存储机制。切片类型的一般形式如下所示:
[ ]<element_type>
切片和数组类型之间一个明显的区别是在类型声明中省略了大小,如下面的例子所示:
var (
image []byte
ids []string
vector []float64
months []string
q1 []string
histogram []map[string]int // slice of map (see map later)
)
golang.fyi/ch07/slicetypes.go
切片类型中缺少的大小属性表示以下内容:
-
与数组不同,切片的大小是不固定的
-
切片类型表示指定元素类型的所有集合
这意味着切片在理论上可以无限增长(尽管在实践中这并不是真的,因为切片由底层有界数组支持)。给定元素类型的切片被认为是相同类型,而不管其底层大小如何。这消除了数组中大小决定类型的限制。
例如,以下变量months
和q1
具有相同的[]string
类型,并且将编译没有问题:
var (
months []string
q1 []string
)
func print(strs []string){ ... }
func main() {
print(months)
print(q1)
}
golang.fyi/ch07/slicetypes.go
与数组类似,切片类型可以嵌套以创建多维切片,如下面的代码片段所示。每个维度可以独立地具有自己的大小,并且必须单独初始化:
var(
board [][]int
graph [][][][]int
)
切片初始化
切片在类型系统中表示为一个值(下一节将探讨切片的内部表示)。然而,与数组类型不同,未初始化的切片具有nil的零值,这意味着任何尝试访问未初始化切片的元素都会导致程序恐慌。
初始化切片的最简单方法之一是使用以下格式的复合文字值(类似于数组):
<slice_type>{
切片的文字值由切片类型和一组逗号分隔的值组成,这些值被分配给切片的元素,并用大括号括起来。以下代码片段说明了用复合文字值初始化的几个切片变量:
var (
ids []string = []string{"fe225", "ac144", "3b12c"}
vector = []float64{12.4, 44, 126, 2, 11.5}
months = []string {
"Jan", "Feb", "Mar", "Apr",
"May", "Jun", "Jul", "Aug",
"Sep", "Oct", "Nov", "Dec",
}
// slice of map type (maps are covered later)
tables = []map[string][]int {
{
"age":{53, 13, 5, 55, 45, 62, 34, 7},
"pay":{124, 66, 777, 531, 933, 231},
},
}
graph = [][][][]int{
{{{44}, {3, 5}}, {{55, 12, 3}, {22, 4}}},
{{{22, 12, 9, 19}, {7, 9}}, {{43, 0, 44, 12}, {7}}},
}
)
golang.fyi/ch07/sliceinit.go
如前所述,切片的复合文字值使用与数组类似的形式表示。但是,文字中提供的元素数量不受固定大小的限制。这意味着文字可以根据需要很大。尽管如此,Go 在幕后创建和管理一个适当大小的数组来存储文字中表达的值。
切片表示
之前提到切片值使用基础数组来存储数据。实际上,切片这个名称是指数组中的数据段的引用。在内部,切片由以下三个属性表示:
属性 | 描述 |
---|---|
a 指针 | 指针是存储在基础数组中的切片的第一个元素的地址。当切片值未初始化时,其指针值为 nil,表示它尚未指向数组。Go 使用指针作为切片本身的零值。未初始化的切片将返回 nil 作为其零值。但是,切片值在类型系统中不被视为引用值。这意味着某些函数可以应用于 nil 切片,而其他函数将导致恐慌。一旦创建了切片,指针就不会改变。要指向不同的起始点,必须创建一个新的切片。 |
a 长度 | 长度表示可以从第一个元素开始访问的连续元素的数量。它是一个动态值,可以增长到切片的容量(见下文)。切片的长度始终小于或等于其容量。尝试访问超出切片长度的元素,而不进行调整大小,将导致恐慌。即使容量大于长度,这也是真的。 |
a 容量 | 切片的容量是可以从其第一个元素开始存储的最大元素数量。切片的容量受基础数组的长度限制。 |
因此,当初始化以下变量halfyr
时如下所示:
halfyr := []string{"Jan","Feb","Mar","Apr","May","Jun"}
它将存储在类型为[6]string
的数组中,具有指向第一个元素的指针,长度和容量为6
,如下图形式地表示:
切片
另一种创建切片值的方法是通过对现有数组或另一个切片值(或指向这些值的指针)进行切片。Go 提供了一种索引格式,使得表达切片操作变得容易,如下所示:
<切片或数组值>[<低索引>:<高索引>]
切片表达式使用[:
]运算符来指定切片段的低和高边界索引,用冒号分隔。
-
低值是切片段开始的从零开始的索引
-
高值是段停止的第n个元素偏移量
下表显示了通过重新切片以下值的切片表达式的示例:halfyr := []string{"Jan","Feb","Mar","Apr","May","Jun"}
。
表达式 | 描述 |
---|---|
all := halfyr[:] |
省略表达式中的低和高索引相当于以下操作:all := halfyr[0 : 6] 这将产生一个新的切片段,与原始切片相等,从索引位置 0 开始,停在偏移位置6 :["Jan","Feb","Mar","Apr","May","Jun"] |
q1 := halfyr[:3] |
这里的切片表达式省略了低索引值,并指定了长度为3 的切片段。它返回新的切片,["Jan","Feb","Mar"] 。 |
q2 := halfyr[3:] |
这将通过指定起始索引位置为3 并省略高边界索引值(默认为6 )创建一个新的切片段,其中包含最后三个元素。 |
mapr := halfyr[2:4] |
为了消除对切片表达式的任何困惑,这个例子展示了如何创建一个包含月份"Mar" 和"Apr" 的新切片。这将返回一个值为["Mar","Apr"] 的切片。 |
切片切片
对现有切片或数组值进行切片操作不会创建新的基础数组。新的切片会创建指向基础数组的新指针位置。例如,以下代码显示了将切片值halfyr
切片成两个额外切片的操作:
var (
halfyr = []string{
"Jan", "Feb", "Mar",
"Apr", "May", "Jun",
}
q1 = halfyr[:3]
q2 = halfyr[3:]
)
golang.fyi/ch07/slice_reslice.go
支持数组可能有许多投影其数据的切片。以下图示说明了在前面的代码中切片可能如何在视觉上表示:
请注意,切片q1
和q2
都指向同一基础数组中的不同元素。切片q1
的初始长度为3
,容量为6
。这意味着q1
可以调整大小,最多达到6
个元素。然而,切片q2
的大小为3
,容量为3
,不能超出其初始大小(切片调整大小将在后面介绍)。
切片数组
如前所述,数组也可以直接进行切片。在这种情况下,提供的数组值将成为基础数组。使用提供的数组将计算切片的容量和长度。以下源代码片段显示了对名为 months 的现有数组值进行切片:
var (
months [12]string = [12]string{
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
}
halfyr = months[:6]
q1 = halfyr[:3]
q2 = halfyr[3:6]
q3 = months[6:9]
q4 = months[9:]
)
golang.fyi/ch07/slice_reslice_arr.go
具有容量的切片表达式
最后,Go 的切片表达式支持更长的形式,其中包括切片的最大容量,如下所示:
<slice_or_array_value>[<low_index>:<high_index>:max]
max属性指定要用作新切片的最大容量的索引值。该值可以小于或等于基础数组的实际容量。以下示例对包含最大值的数组进行切片:
var (
months [12]string = [12]string{
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
}
summer1 = months[6:9:9]
)
golang.fyi/ch07/slice_reslice_arr.go
前面的代码片段创建了一个新的切片值summer1
,大小为3
(从索引位置6
到9
)。最大索引设置为位置9
,这意味着切片的容量为3
。如果未指定最大值,则最大容量将自动设置为基础数组的最后一个位置,与以前一样。
创建切片
切片可以在运行时使用内置函数make
进行初始化。此函数创建一个新的切片值,并使用元素类型的零值初始化其元素。未初始化的切片具有零值 nil,表示它不指向基础数组。如果没有显式初始化,使用复合文字值或使用make()
函数,尝试访问切片的元素将导致恐慌。以下代码片段重新使用make()
函数初始化切片的示例:
func main() {
months := make([]string, 6)
...
}
golang.fyi/ch07/slicemake.go
make()
函数以切片的类型作为参数进行初始化,并为切片设置初始大小。然后返回一个切片值。在前面的代码片段中,make()
执行以下操作:
-
创建类型为
[6]string
的基础数组 -
创建长度和容量为
6
的切片值 -
返回切片值(而不是指针)
使用make()
函数初始化后,访问合法的索引位置将返回切片元素的零值,而不会导致程序恐慌。make()
函数可以接受一个可选的第三个参数,指定切片的最大容量,如下例所示:
func main() {
months := make([]string, 6, 12)
...
}
golang.fyi/ch07/slicemake2.go
前面的代码片段将使用初始长度为6
和最大容量为12
的切片值初始化months
变量。
使用切片
切片值最简单的操作是访问其元素。正如前面提到的,切片使用索引表示法来访问其元素,类似于数组。以下示例访问索引位置 0 的元素并更新为15
:
func main () {
h := []float64{12.5, 18.4, 7.0}
h[0] = 15
fmt.Println(h[0])
...
}
golang.fyi/ch07/slice_use.go
程序运行时,使用索引表达式h[0]
打印更新后的值。请注意,仅使用索引号的切片表达式,例如h[0]
,将返回该位置的项目的值。然而,如果表达式包括冒号,比如h[2:]
或h[:6]
,该表达式将返回一个新的切片。
切片遍历可以使用传统的for
语句,也可以使用更符合惯例的for…range
语句,如下面的代码片段所示:
func scale(factor float64, vector []float64) []float64 {
for i := range vector {
vector[i] *= factor
}
return vector
}
func contains(val float64, numbers []float64) bool {
for _, num := range numbers {
if num == val {
return true
}
}
return false
}
golang.fyi/ch07/slice_loop.go
在上面的代码片段中,函数scale
使用索引变量i
直接更新切片factor
中的值,而函数contains
使用存储在num
中的迭代产生的值来访问切片元素。如果您需要关于for…range
语句的更多细节,请参阅第三章Go 控制流。
切片作为参数
当函数接收切片作为其参数时,该切片的内部指针将指向切片的基础数组。因此,在函数内部对切片的所有更新都将被函数的调用者看到。例如,在下面的代码片段中,对vector
参数的所有更改都将被scale
函数的调用者看到:
func scale(factor float64, vector []float64) {
for i := range vector {
vector[i] *= factor
}
}
golang.fyi/ch07/slice_loop.go
长度和容量
Go 提供了两个内置函数来查询切片的长度和容量属性。给定一个切片,可以使用len
和cap
函数分别查询其长度和最大容量,如下例所示:
func main() {
var vector []float64
fmt.Println(len(vector)) // prints 0, no panic
h := make([]float64, 4, 10)
fmt.Println(len(h), ",", cap(h))
}
请记住,切片是一个值(而不是指针),其零值为 nil。因此,代码能够查询未初始化切片的长度(和容量),而不会在运行时引发恐慌。
向切片添加元素
切片类型的一个不可或缺的特性是它们的动态增长能力。默认情况下,切片具有静态长度和容量。任何尝试访问超出该限制的索引都将引发恐慌。Go 提供了内置的可变参数函数append
,用于动态向指定的切片添加新值,根据需要增加其长度和容量。以下代码片段显示了如何实现这一点:
func main() {
months := make([]string, 3, 3)
months = append(months, "Jan", "Feb", "March",
"Apr", "May", "June")
months = append(months, []string{"Jul", "Aug", "Sep"}...)
months = append(months, "Oct", "Nov", "Dec")
fmt.Println(len(months), cap(months), months)
}
golang.fyi/ch07/slice_append.go
上面的代码片段以大小和容量为3
的切片开始。append
函数用于动态向切片添加新值,超出其初始大小和容量。在内部,append
将尝试将附加的值适应目标切片。如果切片尚未初始化或容量不足,append
将分配一个新的基础数组,以存储更新后的切片的值。
复制切片
请记住,分配或切片现有切片值只是创建一个指向相同基础数组结构的新切片值。Go 提供了copy
函数,它返回切片的深层副本以及一个新的基础数组。以下代码片段显示了一个clone()
函数,它创建一个数字切片的新副本:
func clone(v []float64) (result []float64) {
result = make([]float64, len(v), cap(v))
copy(result, v)
return
}
golang.fyi/ch07/slice_use.go
在上面的代码片段中,copy
函数将v
切片的内容复制到result
中。源切片和目标切片必须具有相同的大小和相同的类型,否则复制操作将失败。
字符串作为切片
在内部,字符串类型是使用指向 rune 的基础数组的复合值实现的切片。这使得字符串类型能够像切片一样进行惯用处理。例如,以下代码片段使用索引表达式从给定的字符串值中提取字符串切片:
func main() {
msg := "Bobsayshelloworld!"
fmt.Println(
msg[:3], msg[3:7], msg[7:12],
msg[12:17], msg[len(msg)-1:],
)
}
golang.fyi/ch07/slice_string.go
对字符串的切片表达式将返回一个指向其基础 rune 数组的新字符串值。可以将字符串值转换为字节切片(或 rune 切片),如下面的函数片段所示,该函数对给定字符串的字符进行排序:
func sort(str string) string {
bytes := []byte(str)
var temp byte
for i := range bytes {
for j := i + 1; j < len(bytes); j++ {
if bytes[j] < bytes[i] {
temp = bytes[i]
bytes[i], bytes[j] = bytes[j], temp
}
}
}
return string(bytes)
}
golang.fyi/ch07/slice_string.go
上面的代码显示了将字节切片显式转换为字符串值。请注意,可以使用索引表达式访问每个字符。
映射类型
Go 映射是一种复合类型,用作存储相同类型的无序元素的容器,由任意键值索引。以下代码片段显示了使用各种键类型声明的各种映射变量:
var (
legends map[int]string
histogram map[string]int
calibration map[float64]bool
matrix map[[2][2]int]bool // map with array key type
table map[string][]string // map of string slices
// map (with struct key) of map of string
log map[struct{name string}]map[string]string
)
golang.fyi/ch07/maptypes.go
上面的代码片段显示了几个变量声明为不同类型的映射,具有各种键类型。一般来说,映射类型的指定如下:
map[<键类型>]<元素类型>
键指定了将用于索引映射存储元素的值的类型。与数组和切片不同,映射键可以是任何类型,而不仅仅是int
。然而,映射键必须是可比较的类型,包括数字、字符串、布尔、指针、数组、结构和接口类型(参见第四章,数据类型,讨论可比较类型)。
映射初始化
与切片类似,映射管理一个底层数据结构,对其用户来说是不透明的,用于存储其值。未初始化的映射也具有零值为 nil。尝试向未初始化的映射中插入值将导致程序恐慌。然而,与切片不同的是,可以从 nil 映射中访问元素,这将返回元素的零值。
与其他复合类型一样,映射可以使用以下形式的复合文字值进行初始化:
<map 类型>{<逗号分隔的键:值对列表>}
以下代码片段显示了使用映射复合文字进行变量初始化:
var (
histogram map[string]int = map[string]int{
"Jan":100, "Feb":445, "Mar":514, "Apr":233,
"May":321, "Jun":644, "Jul":113, "Aug":734,
"Sep":553, "Oct":344, "Nov":831, "Dec":312,
}
table = map[string][]int {
"Men":[]int{32, 55, 12, 55, 42, 53},
"Women":[]int{44, 42, 23, 41, 65, 44},
}
)
golang.fyi/ch07/mapinit.go
如前面的例子所示,使用以冒号分隔的键值对指定了文本映射的值。每个键和值对的类型必须与映射中声明的元素的类型匹配。
创建映射
与切片类似,映射值也可以使用make函数进行初始化。使用 make 函数初始化底层存储,允许数据被插入到映射中,如下简短的代码片段所示:
func main() {
hist := make(map[int]string)
hist["Jan"] = 100
hist["Feb"] = 445
hist["Mar"] = 514
...
}
golang.fyi/ch07/maptypes.go
make
函数以映射的类型作为参数,并返回一个初始化的映射。在前面的例子中,make
函数将初始化一个类型为map[int]string
的映射。make
函数还可以选择接受第二个参数来指定映射的容量。然而,映射将根据需要继续增长,忽略指定的初始容量。
使用映射
与切片和数组一样,索引表达式用于访问和更新映射中存储的元素。要设置或更新map
元素,请使用索引表达式,在赋值的左侧,指定要更新的元素的键。以下代码片段显示了使用值100
更新具有"Jan"
键的元素:
hist := make(map[int]string)
hist["Jan"] = 100
使用索引表达式访问具有给定键的元素,该表达式放置在赋值的右侧,如下例所示,在这个例子中,使用"Mar"
键索引的值被赋给了val
变量:
val := hist["Mar"]
之前提到访问不存在的键将返回该元素的零值。例如,如果映射中不存在具有键"Mar"
的元素,则前面的代码将返回 0。可以想象,这可能是一个问题。你怎么知道你得到的是实际值还是零值?幸运的是,Go 提供了一种明确测试元素缺失的方法,通过在索引表达式的结果中返回一个可选的布尔值,如下代码片段所示:
func save(store map[string]int, key string, value int) {
val, ok := store[key]
if !ok {
store[key] = value
}else{
panic(fmt.Sprintf("Slot %d taken", val))
}
}
golang.fyi/ch07/map_use.go
在前面的代码片段中,函数在更新值之前测试键的存在。称为逗号-ok习语,存储在ok
变量中的布尔值在实际未找到值时设置为 false。这允许代码区分键的缺失和元素的零值。
映射遍历
for…range
循环语句可以用来遍历映射值的内容。range
表达式在每次迭代中发出键和元素值。以下代码片段显示了对映射hist
的遍历:
for key, val := range hist {
adjVal := int(float64(val) * 0.100)
fmt.Printf("%s (%d):", key, val)
for i := 0; i < adjVal; i++ {
fmt.Print(".")
}
fmt.Println()
}
golang.fyi/ch07/map_use.go
每次迭代都会返回一个键及其关联的元素值。然而,迭代顺序并不保证。内部映射迭代器可能会在程序的每次运行中以不同的顺序遍历映射。为了保持可预测的遍历顺序,保留(或生成)键的副本在一个单独的结构中,比如一个切片。在遍历过程中,使用键的切片以可预测的方式进行遍历。
注意
您应该知道,在迭代期间对发出的值进行的更新将会丢失。而是使用索引表达式,比如hist[key]
来在迭代期间更新元素。有关for…range
循环的详细信息,请参阅第三章Go 控制流,对 Gofor
循环进行彻底的解释。
映射函数
除了之前讨论的make
函数,映射类型还支持以下表中讨论的两个附加函数:
函数 | 描述 |
---|
| len(map) | 与其他复合类型一样,内置的len()
函数返回映射中条目的数量。例如,以下内容将打印3:
h := map[int]bool{3:true, 7:false, 9:false}
fmt.Println(len(h))
对于未初始化的映射,len
函数将返回零。|
| delete(map, key) | 内置的delete
函数从给定的映射中删除与提供的键关联的元素。以下代码片段将打印2:
h := map[int]bool{3:true, 7:false, 9:false}
delete(h,7)
fmt.Println(len(h))
|
作为参数的映射
因为映射维护了一个指向其后备存储结构的内部指针,所以在调用函数内对映射参数的所有更新将在函数返回后被调用者看到。下面的示例显示了调用remove
函数来改变映射内容。传递的变量hist
将在remove
函数返回后反映出这一变化:
func main() {
hist := make(map[string]int)
hist["Jun"] = 644
hist["Jul"] = 113
remove(hit, "Jun")
len(hist) // returns 1
}
func remove(store map[string]int, key string) error {
_, ok := store[key]
if !ok {
return fmt.Errorf("Key not found")
}
delete(store, key)
return nil
}
golang.fyi/ch07/map_use.go
结构体类型
本章讨论的最后一种类型是 Go 的struct
。它是一种复合类型,用作其他命名类型(称为字段)的容器。以下代码片段显示了几个声明为结构体的变量:
var(
empty struct{}
car struct{make, model string}
currency struct{name, country string; code int}
node struct{
edges []string
weight int
}
person struct{
name string
address struct{
street string
city, state string
postal string
}
}
)
golang.fyi/ch07/structtypes.go
请注意,结构体类型具有以下一般格式:
struct{
struct
类型是通过指定关键字struct
后跟在花括号内的一组字段声明来构造的。在其最常见的形式中,字段是一个具有分配类型的唯一标识符,遵循 Go 的变量声明约定,如前面的代码片段所示(struct
也支持匿名字段,稍后讨论)。
重要的是要理解struct
的类型定义包括其声明的所有字段。例如,person 变量的类型(见前面的代码片段)是声明struct { name string; address struct { street string; city string; state string; postal string }}
中的所有字段。因此,任何需要该类型的变量或表达式都必须重复这个长声明。我们将在后面看到,通过使用struct
的命名类型来减轻这个问题。
访问结构字段
结构体使用选择器表达式(或点表示法)来访问字段中存储的值。例如,以下内容将打印出先前代码片段中的 person 结构变量的name
字段的值:
fmt.Pritnln(person.name)
选择器可以链接以访问嵌套在结构体内部的字段。以下代码片段将打印出person
变量的嵌套地址值的街道和城市:
fmt.Pritnln(person.address.street)
fmt.Pritnln(person.address.city)
结构初始化
与数组类似,结构体是纯值,没有额外的底层存储结构。未初始化的结构体的字段被分配它们各自的零值。这意味着未初始化的结构体不需要进一步分配,可以直接使用。
尽管如此,结构体变量可以使用以下形式的复合字面量进行显式初始化:
<struct_type>{
结构的复合文字值可以通过它们各自位置指定的一组字段值进行初始化。使用这种方法,必须提供所有字段值,以匹配它们各自声明的类型,如下面的片段所示:
var(
currency = struct{
name, country string
code int
}{
"USD", "United States",
840,
}
...
)
golang.fyi/ch07/structinit.go
在以前的结构文字中,提供了struct
的所有字段值,与其声明的字段类型匹配。或者,可以使用字段索引及其关联值指定struct
的复合文字值。与以前一样,索引(字段名称)及其值由冒号分隔,如下面的片段所示:
var(
car = struct{make, model string}{make:"Ford", model:"F150"}
node = struct{
edges []string
weight int
}{
edges: []string{"north", "south", "west"},
}
...
)
golang.fyi/ch07/structinit.go
正如您所看到的,当提供索引及其值时,结构文字的字段值可以被选择性地指定。例如,在初始化node
变量时,edge
字段被初始化,而weight
被省略。
声明命名结构类型
尝试重用结构类型可能会变得难以控制。例如,每次需要时都必须编写struct { name string; address struct { street string; city string; state string; postal string }}
来表示结构类型,这样做不会扩展,容易出错,并且会让 Go 开发人员感到不快。幸运的是,修复这个问题的正确习惯是使用命名类型,如下面的源代码片段所示:
type person struct {
name string
address address
}
type address struct {
street string
city, state string
postal string
}
func makePerson() person {
addr := address{
city: "Goville",
state: "Go",
postal: "12345",
}
return person{
name: "vladimir vivien",
address: addr,
}
}
golang.fyi/ch07/structtype_dec.go
前面的示例将结构类型定义绑定到标识符person
和address
。这允许在不需要携带类型定义的长形式的情况下,在不同的上下文中重用结构类型。您可以参考第四章,数据类型,了解更多有关命名类型的信息。
匿名字段
以前的结构类型定义涉及使用命名字段。但是,还可以定义仅具有其类型的字段,省略标识符。这称为匿名字段。它的效果是将类型直接嵌入结构中。
这个概念在下面的代码片段中得到了演示。diameter
和name
两种类型都作为planet
类型的anonymous
字段嵌入:
type diameter int
type name struct {
long string
short string
symbol rune
}
type planet struct {
diameter
name
desc string
}
func main() {
earth := planet{
diameter: 7926,
name: name{
long: "Earth",
short: "E",
symbol: '\u2641',
},
desc: "Third rock from the Sun",
}
...
}
golang.fyi/ch07/struct_embed.go
前面片段中的main
函数展示了如何访问和更新匿名字段,就像在planet
结构中所做的那样。请注意,嵌入类型的名称成为结构的复合文字值中的字段标识符。
为了简化字段名称解析,Go 在使用匿名字段时遵循以下规则:
-
类型的名称成为字段的名称
-
匿名字段的名称可能与其他字段名称冲突
-
仅使用导入类型的未限定(省略包)类型名称
在直接使用选择器表达式访问嵌入结构的字段时,这些规则也适用,就像下面的代码片段中所示的那样。请注意,嵌入类型的名称被解析为字段名称:
func main(){
jupiter := planet{}
jupiter.diameter = 88846
jupiter.name.long = "Jupiter"
jupiter.name.short = "J"
jupiter.name.symbol = '\u2643'
jupiter.desc = "A ball of gas"
...
}
golang.fyi/ch07/struct_embed.go
提升的字段
嵌入结构的字段可以提升到其封闭类型。提升的字段出现在选择器表达式中,而不带有它们类型的限定名称,如下面的示例所示:
func main() {
...
saturn := planet{}
saturn.diameter = 120536
saturn.long = "Saturn"
saturn.short = "S"
saturn.symbol = '\u2644'
saturn.desc = "Slow mover"
...
}
golang.fyi/ch07/struct_embed.go
在前面的片段中,通过省略选择器表达式中的name
,突出显示的字段是从嵌入类型name
中提升的。字段long
,short
和symbol
的值来自嵌入类型name
。同样,只有在提升不会导致任何标识符冲突时才会起作用。在有歧义的情况下,可以使用完全限定的选择器表达式。
结构作为参数
请记住,结构变量存储实际值。这意味着每当重新分配或作为函数参数传递struct
变量时,都会创建结构值的新副本。例如,在调用updateName()
之后,以下内容将不会更新名称的值:
type person struct {
name string
title string
}
func updateName(p person, name string) {
p.name = name
}
func main() {
p := person{}
p.name = "uknown"
...
updateName(p, "Vladimir Vivien")
}
golang.fyi/ch07/struct_ptr.go
这可以通过将指针传递给 person 类型的 struct 值来解决,如下面的代码片段所示:
type person struct {
name string
title string
}
func updateName(p *person, name string) {
p.name = name
}
func main() {
p := new(person)
p.name = "uknown"
...
updateName(p, "Vladimir Vivien")
}
golang.fyi/ch07/struct_ptr2.go
在这个版本中,变量p
声明为*person
,并使用内置的new()
函数进行初始化。在updateName()
返回后,其更改将被调用函数看到。
字段标签
关于结构的最后一个主题与字段标签有关。在定义struct
类型时,可以在每个字段声明中添加可选的string
值。字符串的值是任意的,它可以作为提示,供使用反射消费标签的工具或其他 API 使用。
以下显示了 Person 和 Address 结构的定义,它们带有 JSON 注释,可以被 Go 的 JSON 编码器和解码器解释(在标准库中找到):
type Person struct {
Name string `json:"person_name"`
Title string `json:"person_title"`
Address `json:"person_address_obj"`
}
type Address struct {
Street string `json:"person_addr_street"`
City string `json:"person_city"`
State string `json:"person_state"`
Postal string `json:"person_postal_code"`
}
func main() {
p := Person{
Name: "Vladimir Vivien",
Title : "Author",
...
}
...
b, _ := json.Marshal(p)
fmt.Println(string(b))
}
golang.fyi/ch07/struct_ptr2.go
请注意,标签被表示为原始字符串值(包裹在一对``中)。标签在正常的代码执行中被忽略。但是,它们可以使用 Go 的反射 API 收集,就像 JSON 库所做的那样。当本书讨论输入和输出流时,您将在第十章中遇到更多关于这个主题的内容,Go 中的数据 IO。
摘要
本章涵盖了 Go 中找到的每种复合类型,以提供对它们特性的深入覆盖。本章以数组类型的覆盖开篇,读者学习了如何声明、初始化和使用数组值。接下来,读者学习了关于切片类型的所有内容,特别是声明、初始化和使用切片索引表达式来创建新的或重新切片现有切片的实际示例。本章涵盖了映射类型,其中包括有关映射初始化、访问、更新和遍历的信息。最后,本章提供了有关结构类型的定义、初始化和使用的信息。
不用说,这可能是本书中最长的章节之一。然而,这里涵盖的信息将在书中继续探讨新主题时被证明是非常宝贵的。下一章将介绍使用 Go 支持对象式习语的想法,使用方法和接口。
第八章:方法、接口和对象
使用您目前的技能,您可以编写一个使用到目前为止涵盖的基本概念的有效的 Go 程序。正如您将在本章中看到的,Go 类型系统可以支持超出简单函数的习语。虽然 Go 的设计者并不打算创建一个具有深层类层次结构的面向对象的语言,但该语言完全能够支持类型组合,具有高级特性来表达复杂对象结构的创建,如下面的主题所涵盖的那样:
-
Go 方法
-
Go 中的对象
-
接口类型
-
类型断言
Go 方法
可以将 Go 函数定义为仅限于特定类型的范围。当函数范围限定为类型或附加到类型时,它被称为方法。方法的定义与任何其他 Go 函数一样。但是,它的定义包括方法接收器,它是放置在方法名称之前的额外参数,用于指定方法附加到的主机类型。
为了更好地说明这个概念,以下图示了定义方法涉及的不同部分。它显示了quart
方法附加到“类型加仑”基于接收器参数“g 加仑”的接收器:
如前所述,方法具有类型的范围。因此,它只能通过已声明的值(具体或指针)使用点表示法来访问。以下程序显示了如何使用此表示法访问已声明的方法quart
:
package main
import "fmt"
type gallon float64
func (g gallon) quart() float64 {
return float64(g * 4)
}
func main(){
gal := gallon(5)
fmt.Println(gal.quart())
}
golang.fyi/ch08/method_basic.go
在前面的示例中,gal
变量被初始化为gallon
类型。因此,可以使用gal.quart()
访问quart
方法。
在运行时,接收器参数提供对方法的基本类型分配的值的访问。在示例中,quart
方法接收g
参数,该参数传递了声明类型的值的副本。因此,当gal
变量初始化为值5
时,调用gal.quart()
会将接收器参数g
设置为5
。因此,接下来将打印出值20
:
func main(){
gal := gallon(5)
fmt.Println(gal.quart())
}
重要的是要注意,方法接收器的基本类型不能是指针(也不能是接口)。例如,以下内容将无法编译:
type gallon *float64
func (g gallon) quart() float64 {
return float64(g * 4)
}
以下显示了实现更通用的液体体积转换程序的源代码的较长版本。每种容积类型都接收其各自的方法,以公开与该类型相关的行为:
package main
import "fmt"
type ounce float64
func (o ounce) cup() cup {
return cup(o * 0.1250)
}
type cup float64
func (c cup) quart() quart {
return quart(c * 0.25)
}
func (c cup) ounce() ounce {
return ounce(c * 8.0)
}
type quart float64
func (q quart) gallon() gallon {
return gallon(q * 0.25)
}
func (q quart) cup() cup {
return cup(q * 4.0)
}
type gallon float64
func (g gallon) quart() quart {
return quart(g * 4)
}
func main() {
gal := gallon(5)
fmt.Printf("%.2f gallons = %.2f quarts\n", gal, gal.quart())
ozs := gal.quart().cup().ounce()
fmt.Printf("%.2f gallons = %.2f ounces\n", gal, ozs)
}
github.com/vladimirvivien/learning-go/ch08/methods.go
例如,将5
加仑转换为盎司可以通过在给定值上调用适当的转换方法来完成,如下所示:
gal := gallon(5)
ozs := gal.quart().cup().ounce()
整个实现使用了一个简单但有效的典型结构来表示数据类型和行为。阅读代码,它清晰地表达了其预期含义,而不依赖于繁重的类结构。
注意
方法集
通过接收器参数附加到类型的方法数量被称为类型的方法集。这包括具体和指针值接收器。方法集的概念在确定类型相等性、接口实现和空接口的空方法集的支持方面非常重要(本章中都有讨论)。
值和指针接收器
到目前为止,逃脱讨论的方法的一个方面是接收器是普通函数参数。因此,它们遵循 Go 函数的传值机制。这意味着调用的方法会得到从声明类型中的原始值的副本。
接收器参数可以作为基本类型的值或指针传递。例如,以下程序显示了两种方法,half
和double
;两者都直接更新其各自的方法接收器参数g
的值:
package main
import "fmt"
type gallon float64
func (g gallon) quart() float64 {
return float64(g * 4)
}
func (g gallon) half() {
g = gallon(g * 0.5)
}
func (g *gallon) double() {
*g = gallon(*g * 2)
}
func main() {
var gal gallon = 5
gal.half()
fmt.Println(gal)
gal.double()
fmt.Println(gal)
}
golang.fyi/ch08/receiver_ptr.go
在half
方法中,代码使用g = gallon(g * 0.5)
更新接收器参数。正如您所期望的那样,这不会更新原始声明的值,而是存储在g
参数中的副本。因此,当在main
中调用gal.half()
时,原始值保持不变,以下内容将打印5
:
func main() {
var gal gallon = 5
gal.half()
fmt.Println(gal)
}
与常规函数参数类似,使用指针作为接收器参数来引用其基础值的参数允许代码对原始值进行解引用以更新它。这在以下代码片段中的double
方法中得到了突出显示。它使用了*gallon
类型的方法接收器,该接收器使用*g = gallon(*g * 2)
进行更新。因此,当在main
中调用以下内容时,它将打印出10的值:
func main() {
var gal gallon = 5
gal.double()
fmt.Println(gal)
}
指针接收器参数在 Go 中被广泛使用。这是因为它们可以表达类似对象的原语,可以携带状态和行为。正如下一节所示,指针接收器以及其他类型特性是在 Go 中创建对象的基础。
Go 中的对象
前几节的冗长介绍材料是为了引出讨论 Go 中的对象。已经提到 Go 并不是设计成传统的面向对象语言。Go 中没有定义对象或类关键字。那么,为什么我们要讨论 Go 中的对象呢?事实证明,Go 完全支持对象习语和面向对象编程实践,而不需要其他面向对象语言中复杂的继承结构。
让我们在下表中回顾一些通常归因于面向对象语言的原始特性。
对象特性 | Go | 评论 |
---|---|---|
对象:存储状态并公开行为的数据类型 | 是 | 在 Go 中,所有类型都可以实现这一点。没有称为类或对象的特殊类型来做到这一点。任何类型都可以接收一组方法来定义其行为,尽管struct 类型最接近其他语言中通常称为对象的内容。 |
组合 | 是 | 使用诸如struct 或interface (稍后讨论)的类型,可以通过组合创建对象并表达它们的多态关系。 |
通过接口进行子类型化 | 是 | 定义一组其他类型可以实现的行为(方法)的类型。稍后您将看到它是如何用于实现对象子类型化的。 |
模块化和封装 | 是 | Go 在其核心支持物理和逻辑模块化,包括包和可扩展的类型系统,以及代码元素的可见性。 |
类型继承 | 否 | Go 不支持通过继承实现多态性。新声明的命名类型不会继承其基础类型的所有属性,并且在类型系统中会被不同对待。因此,通过类型谱系实现继承在其他语言中很难实现。 |
类 | 无 | Go 中没有作为对象基础的类类型概念。Go 中的任何数据类型都可以用作对象。 |
正如前面的表所示,Go 支持通常归因于面向对象编程的大多数概念。本章的其余部分涵盖了如何将 Go 用作面向对象编程语言的主题和示例。
结构体作为对象
几乎所有的 Go 类型都可以通过存储状态和公开能够访问和修改这些状态的方法来扮演对象的角色。然而,struct
类型提供了传统上归因于其他语言中对象的所有特性,例如:
-
能够承载方法
-
能够通过组合进行扩展
-
能够被子类型化(借助 Go 的
interface
类型)
本章的其余部分将基于使用struct
类型来讨论对象。
对象组合
让我们从以下简单的示例开始,演示struct
类型如何作为一个可以实现多态组合的对象。以下源代码片段实现了一个典型的结构,模拟了包括fuel, engine
, vehicle
, truck
和plane
在内的机动交通组件:
type fuel int
const (
GASOLINE fuel = iota
BIO
ELECTRIC
JET
)
type vehicle struct {
make string
model string
}
type engine struct {
fuel fuel
thrust int
}
func (e *engine) start() {
fmt.Println ("Engine started.")
}
type truck struct {
vehicle
engine
axels int
wheels int
class int
}
func (t *truck) drive() {
fmt.Printf("Truck %s %s, on the go!\n", t.make, t.model)
}
type plane struct {
vehicle
engine
engineCount int
fixedWings bool
maxAltitude int
}
func (p *plane) fly() {
fmt.Printf(
"Aircraft %s %s clear for takeoff!\n",
p.make, p.model,
)
}
golang.fyi/ch08/structobj.go
在前面的代码片段中声明的组件及其关系在下图中进行了说明,以可视化类型映射及其组成:
Go 使用组合优于继承原则,通过struct
类型支持的类型嵌入机制实现多态性。在 Go 中,没有通过类型继承支持多态性。请记住,每种类型都是独立的,被认为与所有其他类型都不同。实际上,上面的模型中的语义略有问题。类型truck
和plane
被显示为由vehicle
类型组成(或拥有),这听起来不正确。相反,正确的,或者至少更正确的表示应该是显示类型truck
和plane
是通过子类型关系vehicle
。在本章的后面,我们将看到如何使用interface
类型来实现这一点。
字段和方法提升
现在在前面的部分中已经建立了对象,让我们花一些时间讨论结构体内部字段、方法和嵌入类型的可见性。以下源代码片段显示了前面示例的延续。它声明并初始化了一个类型为truck
的变量t
和一个类型为plane
的变量p
。前者使用结构字面量进行初始化,后者使用点符号进行更新:
func main() {
t := &truck {
vehicle:vehicle{"Ford", "F750"},
engine:engine{GASOLINE+BIO,700},
axels:2,
wheels:6,
class:3,
}
t.start()
t.drive()
p := &plane{}
p.make = "HondaJet"
p.model = "HA-420"
p.fuel = JET
p.thrust = 2050
p.engineCount = 2
p.fixedWings = true
p.maxAltitude = 43000
p.start()
p.fly()
}
golang.fyi/ch08/structobj.go
在前面的代码片段中,一个更有趣的细节是struct
类型嵌入机制如何在使用点符号访问时提升字段和方法。例如,以下字段(make
, mode
, fuel
, 和 thrust
)都声明在plane
类型内部嵌入的类型中:
p.make = "HondaJet"
p.model = "HA-420"
p.fuel = JET
p.thrust = 2050
前面的字段是从它们的嵌入类型中提升出来的。当访问它们时,就好像它们是plane
类型的成员一样,但实际上它们分别来自vehicle
和engine
类型。为了避免歧义,字段的名称可以被限定,如下所示:
p.vehicle.make = "HondaJet"
p.vehicle.model = "HA-420"
p.engine.fuel = JET
p.engine.thrust = 2050
方法也可以以类似的方式提升。例如,在前面的代码中,我们看到了方法t.start()
和p.start()
被调用。然而,类型truck
和plane
都不是名为start()
的方法的接收者。就像之前的程序中所示的那样,start()
方法是为engine
类型定义的。由于engine
类型被嵌入到truck
和plane
类型中,start()
方法在范围上被提升到这些封闭类型中,因此可以访问。
构造函数
由于 Go 不支持类,因此没有构造函数的概念。然而,在 Go 中你会遇到的一个常规习语是使用工厂函数来创建和初始化类型的值。以下代码片段显示了前面示例的一部分,已更新为使用构造函数来创建plane
和truck
类型的新值:
type truck struct {
vehicle
engine
axels int
wheels int
class int
}
func newTruck(mk, mdl string) *truck {
return &truck {vehicle:vehicle{mk, mdl}}
}
type plane struct {
vehicle
engine
engineCount int
fixedWings bool
maxAltitude int
}
func newPlane(mk, mdl string) *plane {
p := &plane{}
p.make = mk
p.model = mdl
return p
}
golang.fyi/ch08/structobj2.go
尽管不是必需的,但提供一个函数来帮助初始化复合值,比如一个结构体,会增加代码的可用性。它提供了一个地方来封装可重复的初始化逻辑,可以强制执行验证要求。在前面的例子中,构造函数newTruck
和newPlane
都传递了制造和型号信息来创建和初始化它们各自的值。
接口类型
当您与已经使用 Go 一段时间的人交谈时,他们几乎总是将接口列为他们最喜欢的语言特性之一。Go 中的接口概念,类似于 Java 等其他语言,是一组方法,用作描述行为的模板。然而,Go 接口是由interface{}
文字指定的类型,用于列出满足接口的一组方法。以下示例显示了将shape
变量声明为接口:
var shape interface {
area() float64
perim() float64
}
在先前的代码片段中,shape
变量被声明并分配了一个未命名类型,interface{area()float64; perim()float64}
。使用未命名的interface
文字类型声明变量并不是很实用。使用惯用的 Go 方式,几乎总是将interface
类型声明为命名的type
。可以重写先前的代码片段以使用命名的接口类型,如以下示例所示:
type shape interface {
area() float64
perim() float64
}
var s shape
实现接口
Go 中接口的有趣之处在于它们是如何实现和最终使用的。实现 Go 接口是隐式完成的。不需要单独的元素或关键字来指示实现的意图。任何定义了interface
类型的方法集的类型都会自动满足其实现。
以下源代码显示了rect
类型作为shape
接口类型的实现。rect
类型被定义为具有接收器方法area
和perim
的struct
。这一事实自动使rect
成为shape
的实现:
type shape interface {
area() float64
perim() float64
}
type rect struct {
name string
length, height float64
}
func (r *rect) area() float64 {
return r.length * r.height
}
func (r *rect) perim() float64 {
return 2*r.length + 2*r.height
}
golang.fyi/ch08/interface_impl.go
使用 Go 接口进行子类型化
在讨论对象时,曾提到 Go 在构建对象时更青睐组合(具有)关系。虽然如此,Go 也可以使用接口通过子类型化来表达对象之间的“是一个”关系。在我们先前的示例中,可以认为rect
类型(以及实现area
和perim
方法的任何其他类型)可以被视为shape
的子类型,如下图所示:
正如您可能期望的那样,shape
的任何子类型都可以参与表达式或作为函数(或方法)参数传递,其中期望shape
类型。在以下代码片段中,先前定义的rect
和triangle
类型都能够传递给shapeInfo(shape)
函数,以返回包含形状计算的string
值:
type triangle struct {
name string
a, b, c float64
}
func (t *triangle) area() float64 {
return 0.5*(t.a * t.b)
}
func (t *triangle) perim() float64 {
return t.a + t.b + math.Sqrt((t.a*t.a) + (t.b*t.b))
}
func (t *triangle) String() string {
return fmt.Sprintf(
"%s[sides: a=%.2f b=%.2f c=%.2f]",
t.name, t.a, t.b, t.c,
)
}
func shapeInfo(s shape) string {
return fmt.Sprintf(
"Area = %.2f, Perim = %.2f",
s.area(), s.perim(),
)
}
func main() {
r := & rect{"Square", 4.0, 4.0}
fmt.Println(r, "=>", shapeInfo(r))
t := & triangle{"Right Triangle", 1,2,3}
fmt.Println(t, "=>", shapeInfo(t))
}
golang.fyi/ch08/interface_impl.go
实现多个接口
接口的隐式机制允许任何命名类型同时满足多个接口类型。这只需让给定类型的方法集与要实现的每个interface
类型的方法相交即可实现。让我们重新实现先前的代码以展示如何实现这一点。引入了两个新接口,polygon
和curved
,以更好地捕获和分类形状的信息和行为,如以下代码片段所示:
type shape interface {
area() float64
}
type polygon interface {
perim()
}
type curved interface {
circonf()
}
type rect struct {...}
func (r *rect) area() float64 {
return r.length * r.height
}
func (r *rect) perim() float64 {
return 2*r.length + 2*r.height
}
type triangle struct {...}
func (t *triangle) area() float64 {
return 0.5*(t.a * t.b)
}
func (t *triangle) perim() float64 {
return t.a + t.b + math.Sqrt((t.a*t.a) + (t.b*t.b))
}
type circle struct { ... }
func (c *circle) area() float64 {
return math.Pi * (c.rad*c.rad)
}
func (c *circle) circonf() float64 {
return 2 * math.Pi * c.rad
}
golang.fyi/ch08/interface_impl2.go
先前的源代码片段显示了类型如何通过简单声明满足接口的方法集来自动满足多个接口。如下图所示:
接口嵌入
interface
类型的另一个有趣方面是它支持类型嵌入(类似于struct
类型)。这使您可以以最大程度地重用类型的方式来构造您的类型。继续使用形状示例,以下代码片段通过将形状嵌入到其他两种类型中,重新组织并将先前的接口数量从三个减少到两个:
type shape interface {
area() float64
}
type polygon interface {
shape
perim()
}
type curved interface {
shape
circonf()
}
golang.fyi/ch08/interface_impl3.go
以下插图显示了如何组合接口类型,以便是一个关系仍然满足代码组件之间的关系:
在嵌入接口类型时,封闭类型将继承嵌入类型的方法集。如果嵌入类型导致方法签名冲突,编译器将发出警告。嵌入成为一个至关重要的特性,特别是当代码应用类型检查时。它允许类型汇总类型信息,从而减少不必要的断言步骤(类型断言稍后讨论)。
空接口类型
interface{}
类型,或空 interface
类型,是具有空方法集的 interface
类型的文字表示。根据我们迄今为止的讨论,可以推断出 所有类型都实现了空接口,因为所有类型都可以具有零个或多个成员的方法集。
当一个变量被赋予 interface{}
类型时,编译器会放松其构建时的类型检查。然而,该变量仍然携带可以在运行时查询的类型信息。下面的代码说明了这是如何工作的:
func main() {
var anyType interface{}
anyType = 77.0
anyType = "I am a string now"
fmt.Println(anyType)
printAnyType("The car is slow")
m := map[string] string{"ID":"12345", "name":"Kerry"}
printAnyType(m)
printAnyType(1253443455)
}
func printAnyType(val interface{}) {
fmt.Println(val)
}
golang.fyi/ch08/interface_empty.go
在前面的代码中,anyType
变量被声明为 interface{}
类型。它能够被赋予不同类型的值,而不会受到编译器的投诉:
anyType = 77.0
anyType = "I am a string now"
printAnyType()
函数以 interface{}
类型的参数。这意味着该函数可以传递任何有效类型的值,如下所示:
printAnyType("The car is slow")
m := map[string] string{"ID":"12345", "name":"Kerry"}
printAnyType(m)
printAnyType(1253443455)
空接口对于 Go 语言的习惯用法至关重要。将类型检查延迟到运行时使得语言更具动态性,而不完全牺牲强类型。Go 语言提供了诸如类型断言(下文介绍)的机制,以在运行时查询接口所携带的类型信息。
类型断言
当将接口(空或其他)分配给变量时,它携带可以在运行时查询的类型信息。类型断言是 Go 语言中可用的一种机制,用于将变量(interface
类型)习惯上缩小到存储在变量中的具体类型和值。下面的示例使用类型断言在 eat
函数中选择要在 eat
函数中选择的 food
类型:
type food interface {
eat()
}
type veggie string
func (v veggie) eat() {
fmt.Println("Eating", v)
}
type meat string
func (m meat) eat() {
fmt.Println("Eating tasty", m)
}
func eat(f food) {
veg, ok := f.(veggie)
if ok {
if veg == "okra" {
fmt.Println("Yuk! not eating ", veg)
}else{
veg.eat()
}
return
}
mt, ok := f.(meat)
if ok {
if mt == "beef" {
fmt.Println("Yuk! not eating ", mt)
}else{
mt.eat()
}
return
}
fmt.Println("Not eating whatever that is: ", f)
}
golang.fyi/interface_assert.go
eat
函数以 food
接口类型作为参数。代码展示了如何使用习惯用法的 Go 语言来使用断言提取存储在 f
接口参数中的静态类型和值。类型断言表达式的一般形式如下所示:
<interface_variable>.(具体类型名称)
表达式以接口类型的变量开头。然后跟着一个点和括号括起来的具体断言的类型。类型断言表达式可以返回两个值:一个是具体值(从接口中提取),第二个是一个布尔值,指示断言的成功,如下所示:
value, boolean := <interface_variable>.(具体类型名称)
这是在下面的代码片段中显示的断言形式(从之前的示例中提取),用于将 f
参数缩小到特定类型的 food
。如果断言的类型是 meat
,则代码将继续测试 mt
变量的值:
mt, ok := f.(meat)
if ok {
if mt == "beef" {
fmt.Println("Yuk! not eating ", mt)
}else{
mt.eat()
}
return
}
类型断言表达式也可以只返回值,如下所示:
value := <interface_variable>.(具体类型名称)
这种形式的断言是有风险的,因为如果接口变量中存储的值不是所断言的类型,运行时将导致程序崩溃。只有在有其他保障可以防止或优雅地处理崩溃时才使用这种形式。
最后,当您的代码需要多个断言来在运行时测试多种类型时,更好的断言习惯是使用类型 switch
语句。它使用 switch
语句语义来使用 case 子句从接口值中查询静态类型信息。前面与食品相关的示例中的 eat
函数可以更新为使用类型 switch
而不是 if
语句,如下面的代码片段所示:
func eat(f food) {
swtich morsel := f.(type){
case veggie:
if morsel == "okra" {
fmt.Println("Yuk! not eating ", mosel)
}else{
mosel.eat()
}
case meat:
if morsel == "beef" {
fmt.Println("Yuk! not eating ", mosel)
}else{
mosel.eat()
}
default:
fmt.Println("Not eating whatever that is: ", f)
}
}
golang.fyi/interface_assert2.go
请注意,代码的可读性大大提高。它可以支持任意数量的情况,并且清晰地布局,具有视觉线索,使人们能够轻松推理。switch
类型还通过简单指定一个默认情况来消除了恐慌问题,该默认情况可以处理在情况子句中没有明确处理的任何类型。
总结
本章试图以广泛且在某种程度上全面的视角来介绍几个重要主题,包括在 Go 中的方法、接口和对象。本章首先介绍了如何使用接收器参数将方法附加到类型。接下来介绍了对象以及如何在 Go 中创建符合惯例的基于对象的编程。最后,本章全面概述了接口类型以及它在支持 Go 中对象语义方面的应用。下一章将引导读者了解 Go 中最基本的概念之一,这也是 Go 在开发者中引起轰动的原因:并发!
第九章:并发性
并发被认为是 Go 最吸引人的特性之一。语言的采用者沉迷于使用其原语来表达正确的并发实现的简单性,而不会出现通常伴随此类努力的陷阱。本章涵盖了理解和创建并发 Go 程序的必要主题,包括以下内容:
-
Goroutines
-
通道
-
编写并发程序
-
sync 包
-
检测竞争条件
-
Go 中的并行性
Goroutines
如果您在其他语言中工作过,比如 Java 或 C/C++,您可能熟悉并发的概念。这是程序能够独立运行两个或多个执行路径的能力。通常通过直接向程序员公开线程原语来创建和管理并发来实现这一点。
Go 有自己的并发原语,称为goroutine,它允许程序启动一个函数(例程)以独立于其调用函数执行。Goroutines 是轻量级的执行上下文,它们在少量 OS 支持的线程中进行多路复用,并由 Go 的运行时调度程序进行调度。这使它们可以在不需要真正的内核线程的开销要求的情况下轻松创建。因此,Go 程序可以启动数千(甚至数十万)个 goroutine,对性能和资源降级的影响很小。
go 语句
使用go
语句启动 goroutines 如下所示:
go
使用go
关键字后跟要安排执行的函数创建 goroutine。指定的函数可以是现有函数、匿名函数或调用函数的表达式。以下代码片段显示了 goroutines 的使用示例:
func main() {
go count(10, 50, 10)
go count(60, 100, 10)
go count(110, 200, 20)
}
func count(start, stop, delta int) {
for i := start; i <= stop; i += delta {
fmt.Println(i)
}
}
golang.fyi/ch09/goroutine0.go
在前面的代码示例中,当在main
函数中遇到go count()
语句时,它会在独立的执行上下文中启动count
函数。main
和count
函数将同时执行。作为副作用,main
将在任何count
函数有机会向控制台打印任何内容之前完成。
在本章的后面,我们将看到如何在 goroutines 之间以惯用方式处理同步。现在,让我们使用fmt.Scanln()
来阻塞并等待键盘输入,如下示例所示。在这个版本中,同时运行的函数有机会在等待键盘输入时完成:
func main() {
go count(10, 30, 10)
go count(40, 60, 10)
go count(70, 120, 20)
fmt.Scanln() // blocks for kb input
}
golang.fyi/ch09/goroutine1.go
Goroutines 也可以直接在go
语句中定义为函数文字,如下面代码片段中所示的示例的更新版本:
func main() {
go count(10, 30, 10)
go func() {
count(40, 60, 10)
}()
...
}
golang.fyi/ch09/goroutine2.go
函数文字提供了一个方便的习语,允许程序员直接在go
语句的位置组装逻辑。当使用带有函数文字的go
语句时,它被视为具有对非局部变量的词法访问权限的常规闭包,如下例所示:
func main() {
start := 0
stop := 50
step := 5
go func() {
count(start, stop, step)
}()
}
golang.fyi/ch09/goroutine3.go
在前面的代码中,goroutine 能够访问和使用变量start
、stop
和step
。只要在闭包中捕获的变量在 goroutine 启动后不会发生更改,这是安全的。如果这些值在闭包之外更新,可能会导致竞争条件,从而导致 goroutine 在计划运行时读取意外值。
以下片段显示了一个示例,其中 goroutine 闭包捕获了循环中的变量j
:
func main() {
starts := []int{10,40,70,100}
for _, j := range starts{
go func() {
count(j, j+20, 10)
}()
}
}
golang.fyi/ch09/goroutine4.go
由于j
在每次迭代中都会更新,所以不可能确定闭包将读取什么值。在大多数情况下,goroutine 闭包将在执行时看到j
的最后更新值。可以通过在 goroutine 的函数文字中将变量作为参数传递来轻松解决这个问题,如下所示:
func main() {
starts := []int{10,40,70,100}
for _, j := range starts{
go func(s int) {
count(s, s+20, 10)
}(j)
}
}
golang.fyi/ch09/goroutine5.go
每次循环迭代时调用的 goroutine 闭包通过函数参数接收j
变量的副本。这将创建j
值的本地副本,并在调度运行 goroutine 时使用正确的值。
Goroutine 调度
总的来说,所有的 goroutine 都是独立运行的,如下图所示。创建 goroutine 的函数不会等待它返回,除非有阻塞条件,它会继续执行自己的执行流。本章后面将介绍协调 goroutine 的同步习语:
Go 的运行时调度程序使用一种协作调度形式来调度 goroutine。默认情况下,调度程序将允许运行的 goroutine 执行完成。但是,如果发生以下事件之一,调度程序将自动让出执行权给另一个 goroutine:
-
在执行 goroutine 中遇到
go
语句 -
遇到通道操作(通道稍后会介绍)
-
遇到阻塞的系统调用(例如文件或网络 IO)
-
在垃圾回收周期完成后
调度程序将调度排队的 goroutine,准备在运行的 goroutine 中遇到前面的事件之一时进入执行。重要的是要指出,调度程序不保证 goroutine 的执行顺序。例如,当执行以下代码片段时,输出将以任意顺序打印每次运行:
func main() {
go count(10, 30, 10)
go count(40, 60, 10)
go count(70, 120, 20)
fmt.Scanln() // blocks for kb input
}
func count(start, stop, delta int) {
for i := start; i <= stop; i += delta {
fmt.Println(i)
}
}
golang.fyi/ch09/goroutine1.go
以下显示了前一个程序的可能输出:
10
70
90
110
40
50
60
20
30
通道
谈论并发时,一个自然的关注点是数据的安全性和并发执行代码之间的同步。如果您在诸如 Java 或 C/C++等语言中进行并发编程,您可能熟悉确保运行线程可以安全访问共享内存值以实现线程之间通信和同步所需的有时脆弱的协调。
这是 Go 与其 C 血统不同的地方之一。Go 不是通过使用共享内存位置让并发代码进行通信,而是使用通道作为运行的 goroutine 之间通信和共享数据的通道。博客文章Effective Go(golang.org/doc/effective_go.html
)将这个概念简化为以下口号:
不要通过共享内存进行通信;相反,通过通信共享内存。
注意
通道的概念源于著名计算机科学家 C.A. Hoare 的通信顺序进程(CSP)工作,用于使用通信原语对并发进行建模。正如本节将讨论的那样,通道提供了在运行的 goroutine 之间同步和安全地通信数据的手段。
本节讨论了 Go 通道类型,并深入了解了其特性。稍后,您将学习如何使用通道来创建并发程序。
通道类型
通道类型声明了一个通道,其中只能通过通道发送或接收给定元素类型的值。chan
关键字用于指定通道类型,如以下声明格式所示:
chan
以下代码片段声明了一个双向通道类型chan int
,分配给变量ch
,用于通信整数值:
func main() {
var ch chan int
...
}
在本章后面,我们将学习如何使用通道在运行程序的并发部分之间发送数据。
发送和接收操作
Go 使用<-
(箭头)运算符来指示通道内的数据移动。以下表总结了如何从通道发送或接收数据:
示例 | 操作 | 描述 |
---|---|---|
intCh <- 12 |
发送 | 当箭头放置在值、变量或表达式的左侧时,表示向指向的通道进行发送操作。在这个例子中,12 被发送到intCh 通道中。 |
value := <- intCh |
接收 | 当<- 操作符放置在通道的左侧时,表示从通道接收操作。value 变量被赋予从intCh 通道接收到的值。 |
未初始化的通道具有nil零值,并且必须使用内置的make函数进行初始化。正如将在接下来的章节中讨论的那样,通道可以根据指定的容量初始化为无缓冲或带缓冲。每种类型的通道都有不同的特性,在不同的并发构造中得到利用。
无缓冲通道
当make
函数在没有容量参数的情况下被调用时,它会返回一个双向无缓冲通道。以下代码片段展示了创建类型为chan int
的无缓冲通道:
func main() {
ch := make(chan int) // unbuffered channel
...
}
无缓冲通道的特性如下图所示:
在前面的图中(从左到右),显示了无缓冲通道的工作原理:
-
如果通道为空,接收方会阻塞,直到有数据
-
发送方只能向空通道发送数据,并且会阻塞,直到下一个接收操作
-
当通道有数据时,接收方可以继续接收数据。
向无缓冲通道发送数据,如果操作没有包装在 goroutine 中,很容易导致死锁。以下代码在向通道发送12
后将会阻塞:
func main() {
ch := make(chan int)
ch <- 12 // blocks
fmt.Println(<-ch)
}
golang.fyi/ch09/chan-unbuff0.go
当运行前面的程序时,将得到以下结果:
$> go run chan-unbuff0.go
fatal error: all goroutines are asleep - deadlock!
请记住,向无缓冲通道发送数据时,发送方会立即阻塞。这意味着任何后续的语句,例如接收通道的操作,都将无法到达,导致死锁。以下代码展示了向无缓冲通道发送数据的正确方式:
func main() {
ch := make(chan int)
go func() { ch <- 12 }()
fmt.Println(<-ch)
}
golang.fyi/ch09/chan-unbuff1.go
请注意,发送操作被包装在一个匿名函数中,作为一个单独的 goroutine 调用。这允许main
函数在不阻塞的情况下进行接收操作。正如您将在后面看到的,无缓冲通道的这种阻塞特性被广泛用作 goroutine 之间的同步和协调习语。
带缓冲通道
当make
函数使用容量参数时,它会返回一个双向带缓冲通道,如下面的代码片段所示:
func main
ch := make(chan int, 3) // buffered channel
}
前面的代码将创建一个容量为3
的带缓冲通道。带缓冲通道作为先进先出的阻塞队列进行操作,如下图所示:
在前面的图中所示的带缓冲通道具有以下特性:
-
当通道为空时,接收方会阻塞,直到至少有一个元素
-
只要通道未达到容量,发送方就会成功
-
当通道达到容量时,发送方会阻塞,直到至少接收到一个元素
使用带缓冲的通道,可以在同一个 goroutine 中发送和接收值而不会导致死锁。以下是使用容量为4
的带缓冲通道进行发送和接收的示例:
func main() {
ch := make(chan int, 4)
ch <- 2
ch <- 4
ch <- 6
ch <- 8
fmt.Println(<-ch)
fmt.Println(<-ch)
fmt.Println(<-ch)
fmt.Println(<-ch)
}
golang.fyi/ch09/chan0.go
在前面的示例中,该代码能够将值2
、4
、6
和8
发送到ch
通道,而不会出现阻塞的风险。四个fmt.Println(<-ch)
语句用于依次接收通道中的值。然而,如果在第一个接收操作之前添加第五个发送操作,代码将会出现死锁,如下面的代码片段所示:
func main() {
ch := make(chan int, 4)
ch <- 2
ch <- 4
ch <- 6
ch <- 8
ch <- 10
fmt.Println(<-ch)
...
}
在本章的后面,您将会了解更多关于使用通道进行通信的惯用且安全的方法。
单向通道
在声明时,通道类型还可以包括单向操作符(再次使用 <-
箭头)来指示通道是只发送还是只接收的,如下表所示:
声明 | 操作 |
---|
| <-
chan
var inCh chan<- int
|
| chan <-
var outCh <-chan int
|
下面的代码片段显示了函数 makeEvenNums
,它具有一个类型为 chan <- int
的只发送通道参数:
func main() {
ch := make(chan int, 10)
makeEvenNums(4, ch)
fmt.Println(<-ch)
fmt.Println(<-ch)
fmt.Println(<-ch)
fmt.Println(<-ch)
}
func makeEvenNums(count int, in chan<- int) {
for i := 0; i < count; i++ {
in <- 2 * i
}
}
golang.fyi/ch09/chan1.go
由于通道的方向性已经在类型中确定,访问违规将在编译时被检测到。因此,在上一个示例中,in
通道只能用于接收操作。
双向通道可以显式或自动地转换为单向通道。例如,当从 main()
调用 makeEvenNums()
时,它接收双向通道 ch
作为参数。编译器会自动将通道转换为适当的类型。
通道长度和容量
len
和 cap
函数可以分别用于返回通道的长度和容量。len
函数返回接收者读取通道之前通道中排队的元素的当前数量。例如,以下代码片段将打印 2:
func main() {
ch := make(chan int, 4)
ch <- 2
ch <- 2
fmt.Println(len(ch))
}
cap
函数返回通道类型的声明容量,与长度不同,容量在通道的整个生命周期中保持不变。
注意
非缓冲通道的长度和容量均为零。
关闭通道
一旦通道初始化,它就准备好进行发送和接收操作。通道将保持在打开状态,直到使用内置的 close 函数强制关闭,如下例所示:
func main() {
ch := make(chan int, 4)
ch <- 2
ch <- 4
close(ch)
// ch <- 6 // panic, send on closed channel
fmt.Println(<-ch)
fmt.Println(<-ch)
fmt.Println(<-ch) // closed, returns zero value for element
}
golang.fyi/ch09/chan2.go
一旦通道关闭,它具有以下属性:
-
后续的发送操作将导致程序恐慌
-
接收操作永远不会阻塞(无论是缓冲还是非缓冲)
-
所有接收操作都返回通道元素类型的零值
在上面的片段中,ch
通道在两次发送操作后关闭。如注释中所示,第三次发送操作将导致恐慌,因为通道已关闭。在接收端,代码在通道关闭之前获取了两个元素。第三次接收操作返回 0
,即通道元素的零值。
Go 提供了接收操作的长形式,它返回从通道读取的值,后面跟着一个布尔值,指示通道的关闭状态。这可以用于正确处理从关闭通道中的零值,如下例所示:
func main() {
ch := make(chan int, 4)
ch <- 2
ch <- 4
close(ch)
for i := 0; i < 4; i++ {
if val, opened := <-ch; opened {
fmt.Println(val)
} else {
fmt.Println("Channel closed!")
}
}
}
golang.fyi/ch09/chan3.go
编写并发程序
到目前为止,关于 goroutines 和通道的讨论一直故意分开,以确保每个主题都得到适当的覆盖。然而,当它们结合起来创建并发程序时,通道和 goroutines 的真正力量才得以实现,正如本节所介绍的。
同步
通道的主要用途之一是在运行的 goroutines 之间进行同步。为了说明这个用例,让我们来看一下下面的代码,它实现了一个单词直方图。该程序从 data
切片中读取单词,然后在一个单独的 goroutine 中收集每个单词的出现次数:
func main() {
data := []string{
"The yellow fish swims slowly in the water",
"The brown dog barks loudly after a drink ...",
"The dark bird bird of prey lands on a small ...",
}
histogram := make(map[string]int)
done := make(chan bool)
// splits and count words
go func() {
for _, line := range data {
words := strings.Split(line, " ")
for _, word := range words {
word = strings.ToLower(word)
histogram[word]++
}
}
done <- true
}()
if <-done {
for k, v := range histogram {
fmt.Printf("%s\t(%d)\n", k, v)
}
}
}
golang.fyi/ch09/pattern0.go
在上一个示例中的代码中,使用 done := make(chan bool)
创建了一个通道,该通道将用于同步程序中运行的两个 goroutines。main
函数启动了一个次要的 goroutine,它执行单词计数,然后继续执行,直到在 <-done
表达式处阻塞,导致它等待。
与此同时,次要的 goroutine 运行直到完成其循环。然后,它向 done
通道发送一个值,使用 done <- true
,导致被阻塞的 main
例程变得不再阻塞,并继续执行。
注意
前面的代码存在一个可能导致竞争条件的错误。在本章后面将介绍修正方法。
在前一个示例中,代码分配并实际发送了一个布尔值,用于同步。经过进一步检查,可以清楚地看到通道中的值是无关紧要的,我们只是希望它发出信号。因此,我们可以将同步习语进一步简化为一个俗语形式,如下面的代码片段所示:
func main() {
...
histogram := make(map[string]int)
done := make(chan struct{})
// splits and count
go func() {
defer close(done) // closes channel upon fn return
for _, line := range data {
words := strings.Split(line, " ")
for _, word := range words {
word = strings.ToLower(word)
histogram[word]++
}
}
}()
<-done // blocks until closed
for k, v := range histogram {
fmt.Printf("%s\t(%d)\n", k, v)
}
}
golang.fyi/ch09/pattern1.go
这个代码版本通过以下方式实现了 goroutine 同步:
-
done 通道,声明为类型
chan struct{}
-
主 goroutine 在接收表达式
<-done
处阻塞 -
当 done 通道关闭时,所有接收方都能成功接收,而不会阻塞。
尽管信令是使用不同的结构完成的,但这个代码版本等同于第一个版本(pattern0.go
)。空的struct{}
类型不存储任何值,严格用于信令。这个代码版本关闭了done
通道(而不是发送一个值)。这样做的效果是允许主 goroutine 解除阻塞并继续执行。
数据流
通道的一个自然用途是从一个 goroutine 流式传输数据到另一个。这种模式在 Go 代码中非常常见,为了使其工作,必须完成以下工作:
-
不断在通道上发送数据
-
不断接收来自该通道的传入数据
-
发出流的结束信号,以便接收方可以停止
正如你将看到的,所有这些都可以使用一个单一的通道完成。以下代码片段是前一个示例的重写。它展示了如何使用单一通道从一个 goroutine 流式传输数据到另一个。同一个通道也被用作信令设备来指示流的结束:
func main(){
...
histogram := make(map[string]int)
wordsCh := make(chan string)
// splits lines and sends words to channel
go func() {
defer close(wordsCh) // close channel when done
for _, line := range data {
words := strings.Split(line, " ")
for _, word := range words {
word = strings.ToLower(word)
wordsCh <- word
}
}
}()
// process word stream and count words
// loop until wordsCh is closed
for {
word, opened := <-wordsCh
if !opened {
break
}
histogram[word]++
}
for k, v := range histogram {
fmt.Printf("%s\t(%d)\n", k, v)
}
}
golang.fyi/ch09/pattern2.go
这个代码版本与以前一样生成了单词直方图,但引入了不同的方法。这是通过下表中显示的代码部分实现的:
代码 | 描述 |
---|
|
wordsCh := make(chan string)
数据流使用的通道。 |
---|
|
wordsCh <- word
发送 goroutine 循环遍历文本行并逐个发送单词。然后它会阻塞,直到单词被接收(主)goroutine 接收到。 |
---|
|
defer close(wordsCh)
当单词不断被接收(见后文)时,发送 goroutine 在完成时关闭通道。这将是接收方应该停止的信号。 |
---|
|
for {
word, opened := <-wordsCh
if !opened {
break
}
histogram[word]++
}
| 这是接收方的代码。它被放在一个循环中,因为它不知道要预期多少数据。在每次循环迭代中,代码执行以下操作:
-
从通道中拉取数据
-
检查通道的开放状态
-
如果关闭了,就跳出循环
-
否则记录直方图
|
使用for…range
接收数据
前一个模式在 Go 中非常常见,这种习语已经内置到语言中,以for…range
语句的形式存在:
for
在每次迭代中,这个for…range
语句将阻塞,直到它从指定的通道接收到传入的数据,就像下面的代码片段所示:
func main(){
...
go func() {
defer close(wordsCh)
for _, line := range data {
words := strings.Split(line, " ")
for _, word := range words {
word = strings.ToLower(word)
wordsCh <- word
}
}
}()
for word := range wordsCh {
histogram[word]++
}
...
}
golang.fyi/ch09/pattern3.go
前面的代码展示了使用for-range
语句的更新版本,for word := range wordsCh
。它会连续地从wordsCh
通道接收到值。当通道被关闭(来自 goroutine),循环会自动中断。
注意
始终记得关闭通道,以便接收方得到适当的信号。否则,程序可能会陷入死锁,导致恐慌。
生成器函数
通道和 goroutine 提供了一种自然的基础,用于使用生成器函数实现一种生产者/生产者模式。在这种方法中,一个 goroutine 被包装在一个函数中,该函数生成通过函数返回的通道发送的值。消费者 goroutine 接收这些值,因为它们被生成。
单词直方图已经更新为使用这种模式,如下面的代码片段所示:
func main() {
data := []string{"The yellow fish swims...", ...}
histogram := make(map[string]int)
words := words(data) // returns handle to data channel
for word := range words {
histogram[word]++
}
...
}
// generator function that produces data
func words(data []string) <-chan string {
out := make(chan string)
go func() {
defer close(out) // closes channel upon fn return
for _, line := range data {
words := strings.Split(line, " ")
for _, word := range words {
word = strings.ToLower(word)
out <- word
}
}
}()
return out
}
golang.fyi/ch09/pattern4.go
在这个例子中,生成器函数声明为func words(data []string) <-chan string
,返回一个只接收字符串元素的通道。消费者函数,在这种情况下是main()
,接收生成器函数发出的数据,并使用for…range
循环进行处理。
从多个通道选择
有时,并发程序需要同时处理多个通道的发送和接收操作。为了方便这样的努力,Go 语言支持select
语句,它可以在多个发送和接收操作之间进行选择:
select {
case <send_ or_receive_expression>:
default:
}
case
语句类似于switch
语句,具有case
子句。但是,select
语句会选择成功的发送或接收情况之一。如果两个或更多通信情况恰好在同一时间准备就绪,将随机选择一个。当没有其他情况成功时,默认情况总是被选择。
以下代码片段更新了直方图代码,以说明select
语句的使用。生成器函数words
在两个通道out
之间进行选择,以前发送数据的通道,以及作为参数传递的新通道stopCh
,用于检测停止发送数据的中断信号:
func main() {
...
histogram := make(map[string]int)
stopCh := make(chan struct{}) // used to signal stop
words := words(stopCh, data) // returns handle to channel
for word := range words {
if histogram["the"] == 3 {
close(stopCh)
}
histogram[word]++
}
...
}
func words(stopCh chan struct{}, data []string) <-chan string {
out := make(chan string)
go func() {
defer close(out) // closes channel upon fn return
for _, line := range data {
words := strings.Split(line, " ")
for _, word := range words {
word = strings.ToLower(word)
select {
case out <- word:
case <-stopCh: // succeeds first when close
return
}
}
}
}()
return out
}
golang.fyi/ch09/pattern5.go
在前面的代码片段中,words
生成器函数将选择成功的第一个通信操作:out <- word
或<-stopCh
。只要main()
中的消费者代码继续从out
通道接收数据,发送操作就会首先成功。但是请注意,当main()
中的代码遇到第三个"the"
实例时,它会关闭stopCh
通道。当这种情况发生时,它将导致选择语句中的接收情况首先进行,从而导致 goroutine 返回。
通道超时
Go 并发中常见的一种习语是使用之前介绍的select
语句来实现超时。这通过使用select
语句在给定的时间段内等待通道操作成功来实现,使用time
包的 API(golang.org/pkg/time/
)。
以下代码片段显示了一个单词直方图示例的版本,如果程序计算和打印单词的时间超过 200 微秒,则会超时:
func main() {
data := []string{...}
histogram := make(map[string]int)
done := make(chan struct{})
go func() {
defer close(done)
words := words(data) // returns handle to channel
for word := range words {
histogram[word]++
}
for k, v := range histogram {
fmt.Printf("%s\t(%d)\n", k, v)
}
}()
select {
case <-done:
fmt.Println("Done counting words!!!!")
case <-time.After(200 * time.Microsecond):
fmt.Println("Sorry, took too long to count.")
}
}
func words(data []string) <-chan string {...}
golang.fyi/ch09/pattern6.go
这个直方图示例的版本引入了done
通道,用于在处理完成时发出信号。在select
语句中,接收操作case``<-done:
会阻塞,直到 goroutine 关闭done
通道。同样在select
语句中,time.After()
函数返回一个通道,该通道将在指定的持续时间后关闭。如果在done
关闭之前经过了 200 微秒,那么来自time.After()
的通道将首先关闭,导致超时情况首先成功。
sync 包
有时,使用传统方法访问共享值比使用通道更简单和更合适。sync包(golang.org/pkg/sync/
)提供了几种同步原语,包括互斥锁和同步屏障,用于安全访问共享值,如本节所讨论的。
使用互斥锁进行同步
互斥锁允许通过导致 goroutine 阻塞和等待直到锁被释放来串行访问共享资源。以下示例说明了具有Service
类型的典型代码场景,必须在准备好使用之前启动。服务启动后,代码会更新内部布尔变量started
,以存储其当前状态:
type Service struct {
started bool
stpCh chan struct{}
mutex sync.Mutex
}
func (s *Service) Start() {
s.stpCh = make(chan struct{})
go func() {
s.mutex.Lock()
s.started = true
s.mutex.Unlock()
<-s.stpCh // wait to be closed.
}()
}
func (s *Service) Stop() {
s.mutex.Lock()
defer s.mutex.Unlock()
if s.started {
s.started = false
close(s.stpCh)
}
}
func main() {
s := &Service{}
s.Start()
time.Sleep(time.Second) // do some work
s.Stop()
}
golang.fyi/ch09/sync2.go
前面的代码片段使用了类型为sync.Mutex
的变量mutex
来同步访问共享变量started
。为了使其有效工作,所有争议的区域,在这些区域中started
变量被更新,必须使用相同的锁,连续调用mutex.Lock()
和mutex.Unlock()
,如代码所示。
你经常会遇到的一种习惯用法是直接在结构体中嵌入sync.Mutex
类型,如下面的代码片段所示。这样做的效果是将Lock()
和Unlock()
方法作为结构体本身的一部分:
type Service struct {
...
sync.Mutex
}
func (s *Service) Start() {
s.stpCh = make(chan struct{})
go func() {
s.Lock()
s.started = true
s.Unlock()
<-s.stpCh // wait to be closed.
}()
}
func (s *Service) Stop() {
s.Lock()
defer s.Unlock()
...
}
golang.fyi/ch09/sync3.go
sync
包还提供了 RWMutex(读写互斥锁),可以在有一个写入者更新共享资源的情况下使用,同时可能有多个读取者。写入者会像以前一样使用完全锁定来更新资源。然而,读取者在读取共享资源时使用RLock()
/RUnlock()
方法对其进行只读锁定。RWMutex 类型在下一节同步访问复合值中使用。
同步访问复合值
前面的章节讨论了在共享对简单值的访问时的并发安全性。在共享对复合类型值的访问时,必须应用相同程度的小心,比如映射和切片,因为 Go 语言没有提供这些类型的并发安全版本,如下面的例子所示:
type Service struct {
started bool
stpCh chan struct{}
mutex sync.RWMutex
cache map[int]string
}
func (s *Service) Start() {
...
go func() {
s.mutex.Lock()
s.started = true
s.cache[1] = "Hello World"
...
s.mutex.Unlock()
<-s.stpCh // wait to be closed.
}()
}
...
func (s *Service) Serve(id int) {
s.mutex.RLock()
msg := s.cache[id]
s.mutex.RUnlock()
if msg != "" {
fmt.Println(msg)
} else {
fmt.Println("Hello, goodbye!")
}
}
golang.fyi/ch09/sync4.go
前面的代码使用了sync.RWMutex
变量(参见前面的章节,使用 Mutex Locks 进行同步)来管理访问cache
映射变量时的锁。代码将对cache
变量的更新操作包装在一对方法调用mutex.Lock()
和mutex.Unlock()
中。然而,当从cache
变量中读取值时,使用mutex.RLock()
和mutex.RUnlock()
方法来提供并发安全性。
使用 sync.WaitGroup 进行并发障碍
有时在使用 goroutine 时,您可能需要创建一个同步障碍,希望在继续之前等待所有正在运行的 goroutine 完成。sync.WaitGroup
类型就是为这种情况设计的,允许多个 goroutine 在代码中的特定点会合。使用 WaitGroup 需要三件事:
-
通过 Add 方法设置组中的参与者数量
-
每个 goroutine 调用 Done 方法来表示完成
-
使用 Wait 方法阻塞,直到所有 goroutine 完成
WaitGroup 经常被用来实现工作分配模式。下面的代码片段演示了工作分配,计算3
和5
的倍数的和,直到MAX
。代码使用WaitGroup
变量wg
创建并发障碍,等待两个 goroutine 计算数字的部分和,然后在所有 goroutine 完成后收集结果:
const MAX = 1000
func main() {
values := make(chan int, MAX)
result := make(chan int, 2)
var wg sync.WaitGroup
wg.Add(2)
go func() { // gen multiple of 3 & 5 values
for i := 1; i < MAX; i++ {
if (i%3) == 0 || (i%5) == 0 {
values <- i // push downstream
}
}
close(values)
}()
work := func() { // work unit, calc partial result
defer wg.Done()
r := 0
for i := range values {
r += i
}
result <- r
}
// distribute work to two goroutines
go work()
go work()
wg.Wait() // wait for both groutines
total := <-result + <-result // gather partial results
fmt.Println("Total:", total)
}
golang.fyi/ch09/sync5.go
在前面的代码中,方法调用wg.Add(2)
配置了WaitGroup
变量wg
,因为工作在两个 goroutine 之间分配。work
函数调用defer wg.Done()
在每次完成时将 WaitGroup 计数器减一。
最后,wg.Wait()
方法调用会阻塞,直到其内部计数器达到零。如前所述,当两个 goroutine 的work
运行函数都成功完成时,这将发生。当发生这种情况时,程序将解除阻塞并收集部分结果。重要的是要记住,如果内部计数器永远不达到零,wg.Wait()
将无限期地阻塞。
检测竞争条件
使用带有竞争条件的并发代码进行调试可能是耗时且令人沮丧的。当竞争条件发生时,通常是不一致的,并且显示很少或没有可辨认的模式。幸运的是,自从 1.1 版本以来,Go 已经将竞争检测器作为其命令行工具链的一部分。在构建、测试、安装或运行 Go 源代码时,只需添加-race
命令标志即可启用代码的竞争检测器。
例如,当使用-race
标志执行源文件golang.fyi/ch09/sync1.go
(一个带有竞争条件的代码)时,编译器的输出显示了导致竞争条件的冒犯性 goroutine 位置,如下面的输出所示:
$> go run -race sync1.go
==================
WARNING: DATA RACE
Read by main goroutine:
main.main()
/github.com/vladimirvivien/learning-go/ch09/sync1.go:28 +0x8c
Previous write by goroutine 5:
main.(*Service).Start.func1()
/github.com/vladimirvivien/learning-go/ch09/sync1.go:13 +0x2e
Goroutine 5 (running) created at:
main.(*Service).Start()
/github.com/vladimirvivien/learning-go/ch09/sync1.go:15 +0x99
main.main()
/github.com/vladimirvivien/learning-go/ch09/sync1.go:26 +0x6c
==================
Found 1 data race(s)
exit status 66
竞争检测器列出了共享值的并发访问的行号。它列出了读取操作,然后是可能同时发生写入操作的位置。即使在经过充分测试的代码中,代码中的竞争条件也可能被忽略,直到它随机地显现出来。如果您正在编写并发代码,强烈建议您将竞争检测器作为测试套件的一部分集成进去。
Go 中的并行性
到目前为止,本章的讨论重点是同步并发程序。正如本章前面提到的,Go 运行时调度器会自动在可用的 OS 管理线程上多路复用和调度 goroutine。这意味着可以并行化的并发程序可以利用底层处理器核心,几乎不需要配置。例如,以下代码通过启动workers
数量的 goroutine 来清晰地分隔其工作单元(计算 3 和 5 的倍数的和):
const MAX = 1000
const workers = 2
func main() {
values := make(chan int)
result := make(chan int, workers)
var wg sync.WaitGroup
go func() { // gen multiple of 3 & 5 values
for i := 1; i < MAX; i++ {
if (i%3) == 0 || (i%5) == 0 {
values <- i // push downstream
}
}
close(values)
}()
work := func() { // work unit, calc partial result
defer wg.Done()
r := 0
for i := range values {
r += i
}
result <- r
}
//launch workers
wg.Add(workers)
for i := 0; i < workers; i++ {
go work()
}
wg.Wait() // wait for all groutines
close(result)
total := 0
// gather partial results
for pr := range result {
total += pr
}
fmt.Println("Total:", total)
}
golang.fyi/ch09/sync6.go
在多核机器上执行时,上述代码将自动并行启动每个 goroutine,使用go work()
。默认情况下,Go 运行时调度器将为调度创建一定数量的 OS 支持的线程,该数量等于 CPU 核心数。这个数量由运行时值GOMAXPROCS确定。
GOMAXPROCS 值可以被显式更改以影响可用于调度的线程数。该值可以使用相同名称的命令行环境变量进行更改。GOMAXPROCS 也可以在runtime包的GOMAXPROCS()
函数中进行更新(golang.org/pkg/runtime
)。任何一种方法都允许程序员微调将参与调度 goroutine 的线程数。
总结
并发在任何语言中都可能是一个复杂的话题。本章介绍了主要内容,以指导读者如何在 Go 语言中使用并发原语。本章的第一部分概述了 goroutine 的关键属性,包括go语句的创建和使用。接下来,本章介绍了 Go 运行时调度器的机制,以及用于在运行的 goroutine 之间进行通信的通道的概念。最后,用户被介绍了几种使用 goroutine、通道和 sync 包中的同步原语创建并发程序的并发模式。
接下来,您将介绍在 Go 中进行数据输入和输出的标准 API。
第十章:Go 中的数据 IO
本书的前几章主要关注基础知识。在本章和以后的章节中,读者将介绍 Go 标准库提供的一些强大 API。本章详细讨论了如何使用标准库及其各自的包的 API 输入、处理、转换和输出数据的主题:
-
使用读取器和写入器的 IO
-
io.Reader 接口
-
io.Writer 接口
-
使用 io 包
-
使用文件
-
使用 fmt 进行格式化 IO
-
缓冲 IO
-
内存 IO
-
编码和解码数据
使用读取器和写入器的 IO
与其他语言类似,如 Java,Go 将数据输入和输出建模为从源到目标的流。数据资源,如文件、网络连接,甚至一些内存对象,都可以被建模为字节流,从中可以读取或写入数据,如下图所示:
数据流表示为可以访问以进行读取或写入的字节切片([]byte)。正如我们将在本章中探讨的,*io*
包提供了io.Reader
接口来实现从源到字节流的数据传输和读取的代码。相反,io.Writer
接口让实现者创建从提供的字节流中读取数据并将其作为输出写入到目标资源的代码。这两个接口在 Go 中被广泛使用,作为表达 IO 操作的标准习语。这使得可以在不同实现和上下文中交换读取器和写入器,并获得可预测的结果。
io.Reader 接口
如下列表所示,io.Reader
接口很简单。它由一个方法Read([]byte)(int, error)
组成,旨在让程序员实现从任意源读取数据,并将其传输到提供的字节切片中。
type Reader interface {
Read(p []byte) (n int, err error)
}
Read
方法返回传输到提供的切片中的总字节数和错误值(如果有必要)。作为指导,io.Reader
的实现应在读取器没有更多数据传输到流p
中时返回io.EOF
的错误值。以下显示了类型alphaReader
,这是io.Reader
的一个简单实现,它从其字符串源中过滤掉非字母字符:
type alphaReader string
func (a alphaReader) Read(p []byte) (int, error) {
count := 0
for i := 0; i < len(a); i++ {
if (a[i] >= 'A' && a[i] <= 'Z') ||
(a[i] >= 'a' && a[i] <= 'z') {
p[i] = a[i]
}
count++
}
return count, io.EOF
}
func main() {
str := alphaReader("Hello! Where is the sun?")
io.Copy(os.Stdout, &str)
fmt.Println()
}
golang.fyi/ch10/reader0.go
由于alphaReader
类型的值实现了io.Reader
接口,它们可以在需要读取器的任何地方参与,如在对io.Copy(os.Stdout, &str)
的调用中所示。这将alphaReader
变量发出的字节流复制到写入器接口os.Stdout
(稍后介绍)。
链接读取器
标准库中很可能已经有一个可以重用的读取器,因此常见的做法是包装现有的读取器,并使用其流作为新实现的源。以下代码片段显示了alphaReader
的更新版本。这次,它以io.Reader
作为其源,如下所示:
type alphaReader struct {
src io.Reader
}
func NewAlphaReader(source io.Reader) *alphaReader {
return &alphaReader{source}
}
func (a *alphaReader) Read(p []byte) (int, error) {
if len(p) == 0 {
return 0, nil
}
count, err := a.src.Read(p) // p has now source data
if err != nil {
return count, err
}
for i := 0; i < len(p); i++ {
if (p[i] >= 'A' && p[i] <= 'Z') ||
(p[i] >= 'a' && p[i] <= 'z') {
continue
} else {
p[i] = 0
}
}
return count, io.EOF
}
func main() {
str := strings.NewReader("Hello! Where is the sun?")
alpha := NewAlphaReader(str)
io.Copy(os.Stdout, alpha)
fmt.Println()
}
golang.fyi/ch10/reader1.go
此版本代码的主要变化是alphaReader
类型现在是一个嵌入了io.Reader
值的结构。当调用alphaReader.Read()
时,它会调用包装的读取器,如a.src.Read(p)
,这将把源数据注入到字节切片p
中。然后该方法循环遍历p
并对数据应用过滤器。现在,要使用alphaReader
,必须首先提供一个现有的读取器,这由NewAlphaReader()
构造函数来实现。
这种方法的优点一开始可能并不明显。然而,通过使用io.Reader
作为底层数据源,alphaReader
类型能够从任何读取器实现中读取。例如,以下代码片段显示了如何将alphaReader
类型与os.File
结合使用,以过滤文件中的非字母字符(Go 源代码本身):
...
func main() {
file, _ := os.Open("./reader2.go")
alpha := NewAlphaReader(file)
io.Copy(os.Stdout, alpha)
fmt.Println()
}
golang.fyi/ch10/reader2.go
io.Writer 接口
io.Writer
接口,如下代码所示,与其读取器对应的接口一样简单:
type Writer interface {
Write(p []byte) (n int, err error)
}
该接口要求实现一个单一方法,即Write(p []byte)(c int, e error)
,该方法从提供的流p
中复制数据并将该数据写入到诸如内存结构、标准输出、文件、网络连接或任何 Go 标准库提供的io.Writer
实现等汇聚资源。Write
方法返回从p
中复制的字节数,然后是遇到的error
值。
以下代码片段显示了channelWriter
类型的实现,它是一个将其流分解并序列化为连续字节发送到 Go 通道的写入器:
type channelWriter struct {
Channel chan byte
}
func NewChannelWriter() *channelWriter {
return &channelWriter{
Channel: make(chan byte, 1024),
}
}
func (c *channelWriter) Write(p []byte) (int, error) {
if len(p) == 0 {
return 0, nil
}
go func() {
defer close(c.Channel) // when done
for _, b := range p {
c.Channel <- b
}
}()
return len(p), nil
}
golang.fyi/ch10/writer1.go
Write
方法使用 goroutine 从p
中复制每个字节,并将其发送到c.Channel
。完成后,goroutine 关闭通道,以便在何时停止从通道中消耗时通知消费者。作为实现约定,写入器不应修改切片p
或保留它。发生错误时,写入器应返回已处理的当前字节数和错误。
使用channelWriter
类型很简单。您可以直接调用Write()
方法,或者更常见的是,使用 API 中的其他 IO 原语与channelWriter
一起使用。例如,以下代码片段使用fmt.Fprint
函数将"Stream me!"
字符串序列化为一系列字节,并使用channelWriter
将其发送到通道:
func main() {
cw := NewChannelWriter()
go func() {
fmt.Fprint(cw, "Stream me!")
}()
for c := range cw.Channel {
fmt.Printf("%c\n", c)
}
}
golang.fyi/ch10/writer1.go
在前面的代码片段中,通过for…range
语句连续打印序列化的字节,排队在通道中。以下代码片段显示了另一个示例,其中文件的内容使用相同的channelWriter
序列化到通道上。在此实现中,使用io.File
值和io.Copy
函数来源数据,而不是使用fmt.Fprint
函数:
func main() {
cw := NewChannelWriter()
file, err := os.Open("./writer2.go")
if err != nil {
fmt.Println("Error reading file:", err)
os.Exit(1)
}
_, err = io.Copy(cw, file)
if err != nil {
fmt.Println("Error copying:", err)
os.Exit(1)
}
// consume channel
for c := range cw.Channel {
fmt.Printf("%c\n", c)
}
}
golang.fyi/ch10/writer2.go.
使用 io 包
IO 的明显起点是,嗯,io
包(golang.org/pkg/io
)。正如我们已经看到的,io
包定义了输入和输出原语,如io.Reader
和io.Writer
接口。以下表格总结了io
包中可用的其他函数和类型,这些函数和类型有助于流式 IO 操作。
功能 | 描述 |
---|
| io.Copy()
| io.Copy
函数(以及其变体io.CopyBuffer
和io.CopyN
)使得从任意io.Reader
源复制数据到同样任意的io.Writer
汇聚变得容易,如下代码片段所示:
data := strings.NewReader("Write me down.")
file, _ := os.Create("./iocopy.data")
io.Copy(file, data)
golang.fyi/ch10/iocopy.go |
| PipeReader PipeWriter
| io
包包括PipeReader和PipeWriter类型,将 IO 操作建模为内存管道。数据被写入管道的io.Writer
,并且可以独立地从管道的io.Reader
读取。以下简略代码片段说明了一个简单的管道,将字符串写入写入器pw
。然后,数据通过读取器pr
消耗,并复制到文件中:
file, _ := os.Create("./iopipe.data")
pr, pw := io.Pipe()
go func() {
fmt.Fprint(pw, "Pipe streaming")
pw.Close()
}()
wait := make(chan struct{})
go func() {
io.Copy(file, pr)
pr.Close()
close(wait)
}()
<-wait //wait for pr to finish
golang.fyi/ch10/iopipe.go 请注意,管道写入器将阻塞,直到读取器完全消耗管道内容或遇到错误。因此,读取器和写入器都应包装在 goroutine 中,以避免死锁。|
| io.TeeReader()
| 与io.Copy
函数类似,io.TeeReader
将内容从读取器传输到写入器。但是,该函数还通过返回的io.Reader
发出复制的字节(未更改)。TeeReader 非常适用于组合多步 IO 流处理。以下简略代码片段首先使用TeeReader
计算文件内容的 SHA-1 哈希。然后,结果读取器data
被流式传输到 gzip 写入器zip
:
fin, _ := os.Open("./ioteerdr.go")
defer fin.Close()
fout, _ := os.Create("./teereader.gz")
defer fout.Close()
zip := gzip.NewWriter(fout)
defer zip.Close()
sha := sha1.New()
data := io.TeeReader(fin, sha)
io.Copy(zip, data)
fmt.Printf("SHA1 hash %x\n", sha.Sum(nil))
golang.fyi/ch10/ioteerdr0.go 如果我们想要计算 SHA-1 和 MD5,可以更新代码以嵌套两个 TeeReader
值,如下面的代码片段所示:
sha := sha1.New()
md := md5.New()
data := io.TeeReader(
io.TeeReader(fin, md), sha,
)
io.Copy(zip, data)
golang.fyi/ch10/ioteerdr1.go |
| io.WriteString()
| io.WriteString
函数将字符串的内容写入指定的写入器。以下代码将字符串的内容写入文件:
fout, err := os.Create("./iowritestr.data")
if err != nil {
fmt.Println(err)
os.Exit(1)
}
defer fout.Close()
io.WriteString(fout, "Hello there!\n")
golang.fyi/ch10/iowritestr.go |
| io.LimitedReader
| 正如其名称所示,io.LimitedReader
结构是一个从指定的 io.Reader
中仅读取 N 个字节的读取器。以下代码段将打印字符串的前 19 个字节:
str := strings.NewReader("The quick brown " +
"fox jumps over the lazy dog")
limited := &io.LimitedReader{R: str, N: 19}
io.Copy(os.Stdout, limited)
golang.fyi/ch10/iolimitedrdr.go
$> go run iolimitedrd.go
The quick brown fox
|
| io.SectionReader
| io.SectionReader
类型通过指定索引(从零开始)来实现 seek 和 skip 原语,指示从哪里开始读取和偏移值,指示要读取的字节数,如下面的代码片段所示:
str := strings.NewReader("The quick brown"+
"fox jumps over the lazy dog")
section := io.NewSectionReader(str, 19, 23)
io.Copy(os.Stdout, section)
golang.fyi/ch10/iosectionrdr.go 这个例子将打印 jumps over the lazy dog
。
包 io/ioutil |
io/ioutil 子包实现了一小部分函数,提供了 IO 原语的实用快捷方式,如文件读取、目录列表、临时目录创建和文件写入。 |
---|
处理文件
os
包 (golang.org/pkg/os/
) 暴露了 os.File
类型,它表示系统上的文件句柄。os.File
类型实现了几个 IO 原语,包括 io.Reader
和 io.Writer
接口,允许使用标准的流式 IO API 处理文件内容。
创建和打开文件
os.Create
函数创建具有指定路径的新文件。如果文件已经存在,os.Create
将覆盖它。另一方面,os.Open
函数打开现有文件进行读取。
以下源代码片段打开现有文件并使用 io.Copy
函数创建其内容的副本。一个常见且推荐的做法是在文件上调用 Close
方法的延迟调用。这确保了在函数退出时对 OS 资源的优雅释放:
func main() {
f1, err := os.Open("./file0.go")
if err != nil {
fmt.Println("Unable to open file:", err)
os.Exit(1)
}
defer f1.Close()
f2, err := os.Create("./file0.bkp")
if err != nil {
fmt.Println("Unable to create file:", err)
os.Exit(1)
}
defer f2.Close()
n, err := io.Copy(f2, f1)
if err != nil {
fmt.Println("Failed to copy:", err)
os.Exit(1)
}
fmt.Printf("Copied %d bytes from %s to %s\n",
n, f1.Name(), f2.Name())
}
golang.fyi/ch10/file0.go
函数 os.OpenFile
os.OpenFile
函数提供了通用的低级功能,用于创建新文件或以细粒度控制文件的行为和权限打开现有文件。然而,通常使用 os.Open
和 os.Create
函数,因为它们提供了比 os.OpenFile
函数更简单的抽象。
os.OpenFile
函数有三个参数。第一个是文件的路径,第二个参数是一个掩码位字段值,用于指示操作的行为(例如,只读、读写、截断等),最后一个参数是文件的 posix 兼容权限值。
以下缩写的源代码片段重新实现了之前的文件复制代码。然而,这次它使用 os.FileOpen
函数来演示它的工作原理:
func main() {
f1, err := os.OpenFile("./file0.go", os.O_RDONLY, 0666)
if err != nil {...}
defer f1.Close()
f2, err := os.OpenFile("./file0.bkp", os.O_WRONLY, 0666)
if err != nil {...}
defer f2.Close()
n, err := io.Copy(f2, f1)
if err != nil {...}
fmt.Printf("Copied %d bytes from %s to %s\n",
n, f1.Name(), f2.Name())
}
golang.fyi/ch10/file1.go
注意
如果您已经有一个对操作系统文件描述符的引用,还可以使用 os.NewFile
函数在程序中创建文件句柄。os.NewFile
函数很少使用,因为文件通常是使用前面讨论过的文件函数进行初始化的。
文件写入和读取
我们已经看到如何使用 os.Copy
函数将数据移入或移出文件。然而,有时需要完全控制写入或读取文件数据的逻辑。例如,以下代码片段使用 os.File
变量 fout
的 WriteString
方法创建文本文件:
func main() {
rows := []string{
"The quick brown fox",
"jumps over the lazy dog",
}
fout, err := os.Create("./filewrite.data")
if err != nil {
fmt.Println(err)
os.Exit(1)
}
defer fout.Close()
for _, row := range rows {
fout.WriteString(row)
}
}
golang.fyi/ch10/filewrite0.go
然而,如果您的数据源不是文本,可以直接将原始字节写入文件,如下面的源代码片段所示:
func main() {
data := [][]byte{
[]byte("The quick brown fox\n"),
[]byte("jumps over the lazy dog\n"),
}
fout, err := os.Create("./filewrite.data")
if err != nil { ... }
defer fout.Close()
for _, out := range data {
fout.Write(out)
}
}
golang.fyi/ch10/filewrite0.go
作为io.Reader
,可以直接使用Read方法从io.File
类型读取。这样可以访问文件的内容,将其作为原始的字节片流。以下代码片段将文件../ch0r/dict.txt
的内容作为原始字节读取,并分配给切片p
,每次最多 1024 字节:
func main() {
fin, err := os.Open("../ch05/dict.txt")
if err != nil {
fmt.Println(err)
os.Exit(1)
}
defer fin.Close()
p := make([]byte, 1024)
for {
n, err := fin.Read(p)
if err == io.EOF {
break
}
fmt.Print(string(p[:n]))
}
}
golang.fyi/ch10/fileread.go
标准输入、输出和错误
os
包包括三个预声明变量,os.Stdin
、os.Stdout
和os.Stderr
,它们分别表示操作系统的标准输入、输出和错误的文件句柄。以下代码片段读取文件f1
,并使用os.Copy
函数将其内容写入io.Stdout
,即标准输出(标准输入稍后介绍):
func main() {
f1, err := os.Open("./file0.go")
if err != nil {
fmt.Println("Unable to open file:", err)
os.Exit(1)
}
defer f1.Close()
n, err := io.Copy(os.Stdout, f1)
if err != nil {
fmt.Println("Failed to copy:", err)
os.Exit(1)
}
fmt.Printf("Copied %d bytes from %s \n", n, f1.Name())
}
golang.fyi/ch10/osstd.go
使用fmt
进行格式化 IO
用于 IO 的最常用的包之一是fmt
(golang.org/pkg/fmt
)。它带有一系列函数,用于格式化输入和输出。fmt
包最常见的用法是写入标准输出和从标准输入读取。本节还突出了使fmt
成为 IO 工具的其他函数。
向io.Writer
接口打印
fmt
包提供了几个函数,用于将文本数据写入任意io.Writer
的实现。fmt.Fprint
和fmt.Fprintln
函数使用默认格式写入文本,而fmt.Fprintf
支持格式说明符。以下代码片段使用fmt.Fprintf
函数将metalloid
数据的列格式化列表写入指定的文本文件:
type metalloid struct {
name string
number int32
weight float64
}
func main() {
var metalloids = []metalloid{
{"Boron", 5, 10.81},
...
{"Polonium", 84, 209.0},
}
file, _ := os.Create("./metalloids.txt")
defer file.Close()
for _, m := range metalloids {
fmt.Fprintf(
file,
"%-10s %-10d %-10.3f\n",
m.name, m.number, m.weight,
)
}
}
golang.fyi/ch10/fmtfprint0.go
在先前的示例中,fmt.Fprintf
函数使用格式说明符将格式化文本写入io.File
变量file
。fmt.Fprintf
函数支持大量格式说明符,其正确处理超出了本文的范围。请参阅在线文档,了解这些说明符的完整覆盖范围。
打印到标准输出
fmt.Print
、fmt.Printf
和fmt.Println
具有与先前Fprint
系列函数完全相同的特性。但是,它们不是向任意的io.Writer
写入文本,而是将文本写入标准输出文件句柄os.Stdout
(请参阅前面介绍的标准输出、输入和错误部分)。
以下是更新后的代码片段,显示了先前示例的更新版本,它将 metalloid 的列表写入标准输出而不是常规文件。请注意,除了使用fmt.Printf
而不是fmt.Fprintf
函数之外,它与相同的代码:
type metalloid struct { ... }
func main() {
var metalloids = []metalloid{
{"Boron", 5, 10.81},
...
{"Polonium", 84, 209.0},
}
for _, m := range metalloids {
fmt.Printf(
"%-10s %-10d %-10.3f\n",
m.name, m.number, m.weight,
)
}
}
golang.fyi/ch10/fmtprint0.go
从io.Reader
读取
fmt
包还支持从io.Reader
接口格式化读取文本数据。fmt.Fscan
和fmt.Fscanln
函数可用于将多个值(以空格分隔)读入指定的参数。fmt.Fscanf
函数支持格式说明符,用于从io.Reader
实现中解析数据输入。
以下是使用函数fmt.Fscanf
对包含行星数据的以空格分隔的文件(planets.txt
)进行格式化输入的缩写代码片段:
func main() {
var name, hasRing string
var diam, moons int
// read data
data, err := os.Open("./planets.txt")
if err != nil {
fmt.Println("Unable to open planet data:", err)
return
}
defer data.Close()
for {
_, err := fmt.Fscanf(
data,
"%s %d %d %s\n",
&name, &diam, &moons, &hasRing,
)
if err != nil {
if err == io.EOF {
break
} else {
fmt.Println("Scan error:", err)
return
}
}
fmt.Printf(
"%-10s %-10d %-6d %-6s\n",
name, diam, moons, hasRing,
)
}
golang.fyi/ch10/fmtfscan0.go
该代码从io.File
变量data
读取,直到遇到表示文件结束的io.EOF
错误。它读取的每行文本都使用格式说明符"%s %d %d %s\n"
进行解析,该格式与文件中存储的记录的以空格分隔的布局匹配。然后,将每个解析的标记分配给其相应的变量name
、diam
、moons
和hasRing
,并使用fm.Printf
函数将其打印到标准输出。
从标准输入读取
不是从任意的io.Reader
读取,而是使用fmt.Scan
、fmt.Scanf
和fmt.Scanln
从标准输入文件句柄os.Stdin
读取数据。以下代码片段显示了从控制台读取文本输入的简单程序:
func main() {
var choice int
fmt.Println("A square is what?")
fmt.Print("Enter 1=quadrilateral 2=rectagonal:")
n, err := fmt.Scanf("%d", &choice)
if n != 1 || err != nil {
fmt.Println("Follow directions!")
return
}
if choice == 1 {
fmt.Println("You are correct!")
} else {
fmt.Println("Wrong, Google it.")
}
}
golang.fyi/ch10/fmtscan1.go
在前面的程序中,fmt.Scanf
函数使用格式说明符"%d"
从标准输入中读取整数值。如果读取的值与指定的格式不完全匹配,该函数将抛出错误。例如,以下显示了当读取字符D
而不是整数时会发生什么:
$> go run fmtscan1.go
A square is what?
Enter 1=quadrilateral 2=rectagonal: D
Follow directions!
缓冲 IO
到目前为止,大多数 IO 操作都是无缓冲的。这意味着每个读取和写入操作都可能受到底层操作系统处理 IO 请求的延迟的负面影响。另一方面,缓冲操作通过在 IO 操作期间在内部存储器中缓冲数据来减少延迟。bufio
包(golang.org/pkg/bufio
/)提供了用于缓冲读写 IO 操作的函数。
缓冲写入器和读取器
bufio
包提供了几个函数,使用io.Writer
接口对 IO 流进行缓冲写入。以下代码片段创建一个文本文件,并使用缓冲 IO 进行写入:
func main() {
rows := []string{
"The quick brown fox",
"jumps over the lazy dog",
}
fout, err := os.Create("./filewrite.data")
writer := bufio.NewWriter(fout)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
defer fout.Close()
for _, row := range rows {
writer.WriteString(row)
}
writer.Flush()
}
golang.fyi/ch10/bufwrite0.go
通常,bufio
包中的构造函数通过包装现有的io.Writer
来创建缓冲写入器。例如,前面的代码使用bufio.NewWriter
函数通过包装 io.File 变量fout
创建了一个缓冲写入器。
要影响内部缓冲区的大小,可以使用构造函数bufio.NewWriterSize(w io.Writer, n int)
来指定内部缓冲区的大小。bufio.Writer
类型还提供了Write
和WriteByte
方法用于写入原始字节,以及WriteRune
方法用于写入 Unicode 编码字符。
通过调用构造函数bufio.NewReader简单地对缓冲流进行读取,以包装现有的io.Reader
。以下代码片段通过包装file
变量作为其底层源创建了一个bufio.Reader
变量reader
:
func main() {
file, err := os.Open("./bufread0.go")
if err != nil {
fmt.Println("Unable to open file:", err)
return
}
defer file.Close()
reader := bufio.NewReader(file)
for {
line, err := reader.ReadString('\n')
if err != nil {
if err == io.EOF {
break
} else {
fmt.Println("Error reading:, err")
return
}
}
fmt.Print(line)
}
}
golang.fyi/ch10/bufread0.go
前面的代码使用reader.ReadString
方法使用'\n'
字符作为内容分隔符读取文本文件。要影响内部缓冲区的大小,可以使用构造函数bufio.NewReaderSize(w io.Reader, n int)
来指定内部缓冲区的大小。bufio.Reader
类型还提供了Read、ReadByte和ReadBytes方法用于从流中读取原始字节,以及ReadRune方法用于读取 Unicode 编码字符。
扫描缓冲区
bufio
包还提供了用于从io.Reader
源扫描和标记缓冲输入数据的原语。bufio.Scanner
类型使用Split方法扫描输入数据以定义标记化策略。以下代码片段显示了对行星示例(之前的示例)的重新实现。这次,代码使用bufio.Scanner
(而不是fmt.Fscan
函数)来扫描文本文件的内容,使用bufio.ScanLines
函数:
func main() {
file, err := os.Open("./planets.txt")
if err != nil {
fmt.Println("Unable to open file:", err)
return
}
defer file.Close()
fmt.Printf(
"%-10s %-10s %-6s %-6s\n",
"Planet", "Diameter", "Moons", "Ring?",
)
scanner := bufio.NewScanner(file)
scanner.Split(bufio.ScanLines)
for scanner.Scan() {
fields := strings.Split(scanner.Text(), " ")
fmt.Printf(
"%-10s %-10s %-6s %-6s\n",
fields[0], fields[1], fields[2], fields[3],
)
}
}
golang.fyi/ch10/bufscan0.go
使用bufio.Scanner
有四个步骤,如前面的示例所示:
-
首先,使用
bufio.NewScanner(io.Reader)
创建一个扫描器 -
调用
scanner.Split
方法来配置内容的标记化方式 -
使用
scanner.Scan
方法遍历生成的标记 -
使用
scanner.Text
方法读取标记化数据
该代码使用预定义的函数bufio.ScanLines
来使用行分隔符解析缓冲内容。bufio
包提供了几个预定义的分隔函数,包括ScanBytes用于将每个字节作为标记扫描,ScanRunes用于扫描 UTF-8 编码的标记,以及ScanWords用于将每个以空格分隔的单词作为标记扫描。
内存 IO
bytes
包提供了常见的原语,用于在内存中存储的字节块上进行流式 IO,由bytes.Buffer
类型表示。由于bytes.Buffer
类型实现了io.Reader
和io.Writer
接口,因此它是将数据流入或流出内存的流式 IO 原语的绝佳选择。
以下代码片段将几个字符串值存储在byte.Buffer
变量book
中,然后将缓冲区流式传输到os.Stdout
:
func main() {
var books bytes.Buffer
books.WriteString("The Great Gatsby")
books.WriteString("1984")
books.WriteString("A Tale of Two Cities")
books.WriteString("Les Miserables")
books.WriteString("The Call of the Wild")
books.WriteTo(os.Stdout)
}
golang.fyi/ch10/bytesbuf0.go
同样的示例很容易更新,以将内容流式传输到常规文件,如下面的简短代码片段所示:
func main() {
var books bytes.Buffer
books.WriteString("The Great Gatsby\n")
books.WriteString("1984\n")
books.WriteString("A Take of Two Cities\n")
books.WriteString("Les Miserables\n")
books.WriteString("The Call of the Wild\n")
file, err := os.Create("./books.txt")
if err != nil {
fmt.Println("Unable to create file:", err)
return
}
defer file.Close()
books.WriteTo(file)
}
golang.fyi/ch10/bytesbuf1.go
编码和解码数据
Go 中 IO 的另一个常见方面是对数据进行编码,从一种表示形式转换为另一种表示形式,因为它正在被流式传输。标准库中的编码器和解码器,位于encoding包中(golang.org/pkg/encoding/
),使用io.Reader
和io.Writer
接口来利用 IO 原语作为在编码和解码过程中流式传输数据的一种方式。
Go 支持多种编码格式,用于各种目的,包括数据转换、数据压缩和数据加密。本章将重点介绍使用Gob和JSON格式进行数据转换的编码和解码。在第十一章中,编写网络程序,我们将探讨使用编码器将数据转换为客户端和服务器通信的远程过程调用(RPC)。
使用 gob 进行二进制编码
gob
包(https://golang.org/pkg/encoding/gob)提供了一种编码格式,可用于将复杂的 Go 数据类型转换为二进制数据。Gob 是自描述的,这意味着每个编码的数据项都附带有类型描述。编码过程涉及将 gob 编码的数据流式传输到 io.Writer,以便将其写入资源以供将来使用。
以下代码片段显示了一个示例代码,将变量books
(一个包含嵌套值的Book
类型的切片)编码为gob
格式。编码器将其生成的二进制数据写入到一个 os.Writer 实例,本例中是*os.File
类型的变量file
:
type Name struct {
First, Last string
}
type Book struct {
Title string
PageCount int
ISBN string
Authors []Name
Publisher string
PublishDate time.Time
}
func main() {
books := []Book{
Book{
Title: "Leaning Go",
PageCount: 375,
ISBN: "9781784395438",
Authors: []Name{{"Vladimir", "Vivien"}},
Publisher: "Packt",
PublishDate: time.Date(
2016, time.July,
0, 0, 0, 0, 0, time.UTC,
),
},
Book{
Title: "The Go Programming Language",
PageCount: 380,
ISBN: "9780134190440",
Authors: []Name{
{"Alan", "Donavan"},
{"Brian", "Kernighan"},
},
Publisher: "Addison-Wesley",
PublishDate: time.Date(
2015, time.October,
26, 0, 0, 0, 0, time.UTC,
),
},
...
}
// serialize data structure to file
file, err := os.Create("book.dat")
if err != nil {
fmt.Println(err)
return
}
enc := gob.NewEncoder(file)
if err := enc.Encode(books); err != nil {
fmt.Println(err)
}
}
golang.fyi/ch10/gob0.go
尽管前面的示例很长,但它主要是由分配给变量books
的嵌套数据结构的定义组成。最后的半打行或更多行是编码发生的地方。gob 编码器是通过enc := gob.NewEncoder(file)
创建的。通过简单调用enc.Encode(books)
来对数据进行编码,这将将编码的数据流式传输到提供的文件。
解码过程通过使用io.Reader
流式传输 gob 编码的二进制数据,并自动将其重构为强类型的 Go 值来进行反向操作。以下代码片段解码了在上一个示例中编码并存储在books.data
文件中的 gob 数据。解码器从io.Reader
读取数据,在本例中是*os.File
类型的变量file
:
type Name struct {
First, Last string
}
type Book struct {
Title string
PageCount int
ISBN string
Authors []Name
Publisher string
PublishDate time.Time
}
func main() {
file, err := os.Open("book.dat")
if err != nil {
fmt.Println(err)
return
}
var books []Book
dec := gob.NewDecoder(file)
if err := dec.Decode(&books); err != nil {
fmt.Println(err)
return
}
}
golang.fyi/ch10/gob1.go
解码以前编码的 gob 数据是通过使用dec := gob.NewDecoder(file)
创建解码器来完成的。下一步是声明将存储解码数据的变量。在我们的示例中,books
变量,类型为[]Book
,被声明为解码数据的目标。实际解码是通过调用dec.Decode(&books)
来完成的。请注意,Decode()
方法将其目标变量的地址作为参数。一旦解码完成,books
变量将包含从文件流式传输的重构数据结构。
注意
截至目前,gob 编码器和解码器 API 仅在 Go 编程语言中可用。这意味着以 gob 编码的数据只能被 Go 程序使用。
将数据编码为 JSON
编码包还带有一个json编码器子包(golang.org/pkg/encoding/json/
),用于支持 JSON 格式的数据。这极大地扩展了 Go 程序可以交换复杂数据结构的语言数量。JSON 编码与 gob 包的编码器和解码器类似。不同之处在于生成的数据采用明文 JSON 编码格式,而不是二进制。以下代码更新了前一个示例,将数据编码为 JSON:
type Name struct {
First, Last string
}
type Book struct {
Title string
PageCount int
ISBN string
Authors []Name
Publisher string
PublishDate time.Time
}
func main() {
books := []Book{
Book{
Title: "Leaning Go",
PageCount: 375,
ISBN: "9781784395438",
Authors: []Name{{"Vladimir", "Vivien"}},
Publisher: "Packt",
PublishDate: time.Date(
2016, time.July,
0, 0, 0, 0, 0, time.UTC),
},
...
}
file, err := os.Create("book.dat")
if err != nil {
fmt.Println(err)
return
}
enc := json.NewEncoder(file)
if err := enc.Encode(books); err != nil {
fmt.Println(err)
}
}
golang.fyi/ch10/json0.go
代码与之前完全相同。它使用分配给books
变量的相同嵌套结构的切片。唯一的区别是创建了一个编码器enc := json.NewEncoder(file)
,它创建一个 JSON 编码器,将file
变量作为其io.Writer
目标。当执行enc.Encode(books)
时,变量books
的内容将被序列化为 JSON,显示在以下代码中(格式化以便阅读):
[
{
"Title":"Leaning Go",
"PageCount":375,
"ISBN":"9781784395438",
"Authors":[{"First":"Vladimir","Last":"Vivien"}],
"Publisher":"Packt",
"PublishDate":"2016-06-30T00:00:00Z"
},
{
"Title":"The Go Programming Language",
"PageCount":380,
"ISBN":"9780134190440",
"Authors":[
{"First":"Alan","Last":"Donavan"},
{"First":"Brian","Last":"Kernighan"}
],
"Publisher":"Addison-Wesley",
"PublishDate":"2015-10-26T00:00:00Z"
},
...
]
文件 books.dat(格式化)
默认情况下,生成的 JSON 编码内容使用结构字段的名称作为 JSON 对象键的名称。这种行为可以使用结构标签来控制(参见使用结构标签控制 JSON 映射部分)。
在 Go 中使用 JSON 解码器从io.Reader
流式传输其源来消耗 JSON 编码的数据。以下代码片段解码了在前一个示例中生成的 JSON 编码数据,存储在文件book.dat
中。请注意,数据结构(未在以下代码中显示)与之前相同:
func main() {
file, err := os.Open("book.dat")
if err != nil {
fmt.Println(err)
return
}
var books []Book
dec := json.NewDecoder(file)
if err := dec.Decode(&books); err != nil {
fmt.Println(err)
return
}
}
golang.fyi/ch10/json1.go
books.dat 文件中的数据存储为 JSON 对象的数组。因此,代码必须声明一个能够存储嵌套结构值的索引集合的变量。在前一个示例中,类型为[]Book
的books
变量被声明为解码数据的目标。实际解码是通过调用dec.Decode(&books)
来完成的。请注意,Decode()
方法将其目标变量的地址作为参数。一旦解码完成,books
变量将包含从文件流式传输的重构数据结构。
使用结构标签控制 JSON 映射
默认情况下,结构字段的名称用作生成的 JSON 对象的键。这可以使用struct
类型标签来控制,以指定在编码和解码数据时如何映射 JSON 对象键名称。例如,以下代码片段声明了带有json:
标签前缀的结构字段,以指定如何对对象键进行编码和解码:
type Book struct {
Title string `json:"book_title"`
PageCount int `json:"pages,string"`
ISBN string `json:"-"`
Authors []Name `json:"auths,omniempty"`
Publisher string `json:",omniempty"`
PublishDate time.Time `json:"pub_date"`
}
golang.fyi/ch10/json2.go
标签及其含义总结如下表:
标签 | 描述 |
---|---|
Title string json:"book_title"`` |
将Title 结构字段映射到 JSON 对象键"book_title" 。 |
PageCount int json:"pages,string"`` |
将PageCount 结构字段映射到 JSON 对象键"pages" ,并将值输出为字符串而不是数字。 |
ISBN string json:"-"`` |
破折号导致在编码和解码过程中跳过ISBN 字段。 |
Authors []Name json:"auths,omniempty"`` |
将Authors 字段映射到 JSON 对象键"auths" 。注释omniempty 导致如果其值为 nil,则省略该字段。 |
Publisher string json:",omniempty"`` |
将结构字段名Publisher 映射为 JSON 对象键名。注释omniempty 导致字段在为空时被省略。 |
PublishDate time.Time json:"pub_date"`` |
将字段名PublishDate 映射到 JSON 对象键"pub_date" 。 |
当编码前一个结构时,在books.dat
文件中生成以下 JSON 输出(格式化以便阅读):
...
{
"book_title":"The Go Programming Language",
"pages":"380",
"auths":[
{"First":"Alan","Last":"Donavan"},
{"First":"Brian","Last":"Kernighan"}
],
"Publisher":"Addison-Wesley",
"pub_date":"2015-10-26T00:00:00Z"
}
...
请注意,JSON 对象键的标题与struct
标签中指定的相同。对象键"pages"
(映射到结构字段PageCount
)被编码为字符串。最后,结构字段“ISBN”被省略,如在struct
标签中注释的那样。
自定义编码和解码
JSON 包使用两个接口,“Marshaler”和“Unmarshaler”,分别用于编码和解码事件。当编码器遇到一个类型实现了json.Marshaler
的值时,它将值的序列化委托给MarshalJSON
方法,在 Marshaller 接口中定义。以下是一个缩写的代码片段,其中类型Name
更新为实现json.Marshaller
的示例:
type Name struct {
First, Last string
}
func (n *Name) MarshalJSON() ([]byte, error) {
return []byte(
fmt.Sprintf(""%s, %s"", n.Last, n.First)
), nil
}
type Book struct {
Title string
PageCount int
ISBN string
Authors []Name
Publisher string
PublishDate time.Time
}
func main(){
books := []Book{
Book{
Title: "Leaning Go",
PageCount: 375,
ISBN: "9781784395438",
Authors: []Name{{"Vladimir", "Vivien"}},
Publisher: "Packt",
PublishDate: time.Date(
2016, time.July,
0, 0, 0, 0, 0, time.UTC),
},
...
}
...
enc := json.NewEncoder(file)
if err := enc.Encode(books); err != nil {
fmt.Println(err)
}
}
golang.fyi/ch10/json3.go
在前面的例子中,Name
类型的值被序列化为 JSON 字符串(而不是之前的对象)。序列化由方法Name.MarshallJSON
处理,该方法返回一个包含姓和名用逗号分隔的字节数组。前面的代码生成以下 JSON 输出:
[
...
{
"Title":"Leaning Go",
"PageCount":375,
"ISBN":"9781784395438",
"Authors":["Vivien, Vladimir"],
"Publisher":"Packt",
"PublishDate":"2016-06-30T00:00:00Z"
},
...
]
对于反向操作,当解码器遇到映射到实现json.Unmarshaler
的类型的 JSON 文本时,它将解码委托给类型的UnmarshalJSON
方法。例如,以下是实现json.Unmarshaler
以处理Name
类型的 JSON 输出的缩写代码片段:
type Name struct {
First, Last string
}
func (n *Name) UnmarshalJSON(data []byte) error {
var name string
err := json.Unmarshal(data, &name)
if err != nil {
fmt.Println(err)
return err
}
parts := strings.Split(name, ", ")
n.Last, n.First = parts[0], parts[1]
return nil
}
golang.fyi/ch10/json4.go
Name
类型是json.Unmarshaler
的实现。当解码器遇到具有键"Authors"
的 JSON 对象时,它使用方法Name.Unmarshaler
从 JSON 字符串重新构建 Go 结构Name
类型。
注意
Go 标准库提供了其他编码器(此处未涵盖),包括base32
、bas364
、binary
、csv
、hex
、xml
、gzip
和众多加密格式编码器。
摘要
本章提供了 Go 数据输入和输出习惯用法的高层视图,以及实现 IO 原语的包。本章首先介绍了 Go 中基于流的 IO 的基础知识,包括io.Reader
和io.Writer
接口。读者将了解io.Reader
和io.Writer
的实现策略和示例。
本章继续介绍支持流式 IO 机制的包、类型和函数,包括处理文件、格式化 IO、缓冲和内存 IO。本章的最后部分涵盖了在数据流传输过程中转换数据的编码器和解码器。在下一章中,当讨论转向使用 IO 通过网络进行通信的程序时,IO 主题将进一步展开。
第十一章:编写网络服务
作为系统语言,Go 流行的原因之一是它固有的支持创建网络程序。标准库提供了从低级套接字原语到更高级服务抽象(如 HTTP 和 RPC)的 API。本章探讨了创建连接应用程序的基本主题,包括以下内容:
-
网络包
-
TCP API 服务器
-
HTTP 包
-
JSON API 服务器
网络包
Go 中所有网络程序的起点是net包(golang.org/pkg/net
)。它提供了丰富的 API 来处理低级网络原语以及应用级协议,如 HTTP。网络的每个逻辑组件都由 Go 类型表示,包括硬件接口、网络、数据包、地址、协议和连接。此外,每种类型都公开了大量方法,使得 Go 成为支持 IPv4 和 IPv6 的最完整的网络编程标准库之一。
无论是创建客户端还是服务器程序,Go 程序员至少需要以下部分涵盖的网络原语。这些原语作为函数和类型提供,以便客户端连接到远程服务和服务器处理传入请求。
寻址
在进行网络编程时,基本原语之一是地址。net
包的类型和函数使用字符串文字表示地址,例如"127.0.0.1"
。地址还可以包括由冒号分隔的服务端口,例如"74.125.21.113:80"
。net
包中的函数和方法还支持 IPv6 地址的字符串文字表示,例如"::1"
或"[2607:f8b0:4002:c06::65]:80"
,用于带有服务端口 80 的地址。
net.Conn 类型
net.Conn
接口表示在网络上建立的两个节点之间的通用连接。它实现了io.Reader
和io.Writer
接口,允许连接的节点使用流式 IO 原语交换数据。net
包提供了net.Conn
接口的网络协议特定实现,如IPConn、UDPConn和TCPConn。每个实现都公开了特定于其各自网络和协议的附加方法。然而,正如我们将在本章中看到的,net.Conn 中定义的默认方法集对于大多数用途都是足够的。
拨号连接
客户端程序使用net.Dial
函数连接到网络上的主机服务,该函数具有以下签名:
func Dial(network, address string) (Conn, error)
该函数接受两个参数,其中第一个参数network指定连接的网络协议,可以是:
-
tcp
,tcp4
,tcp6
:tcp
默认为tcp4
-
udp
,udp4
,udp6
:udp
默认为udp4
-
ip
,ip4
,ip6
:ip
默认为ip4
-
unix
,unixgram
,unixpacket
:用于 Unix 域套接字
net.Dial
函数的后一个参数指定要连接的主机地址的字符串值。如前所述,地址可以提供为 IPv4 或 IPv6 地址。net.Dial
函数返回与指定网络参数匹配的net.Conn
接口的实现。
例如,以下代码片段拨号到主机地址的"tcp"
网络,www.gutenberg.org:80,返回*net.TCPConn
类型的 TCP 连接。简写代码使用 TCP 连接发出"HTTP GET"
请求,以从 Project Gutenberg 的网站(gutenberg.org/
)检索文学经典《贝奥武夫》的完整文本。然后将原始和未解析的 HTTP 响应写入本地文件beowulf.txt
:
func main() {
host, port := "www.gutenberg.org", "80"
addr := net.JoinHostPort(host, port)
httpRequest:="GET /cache/epub/16328/pg16328.txt HTTP/1.1\n" +
"Host: " + host + "\n\n"
conn, err := net.Dial("tcp", addr)
if err != nil {
fmt.Println(err)
return
}
defer conn.Close()
if _, err = conn.Write([]byte(httpRequest)); err != nil {
fmt.Println(err)
return
}
file, err := os.Create("beowulf.txt")
if err != nil {
fmt.Println(err)
return
}
defer file.Close()
io.Copy(file, conn)
fmt.Println("Text copied to file", file.Name())
}
golang.fyi/ch11/dial0.go
因为net.Conn
类型实现了io.Reader
和io.Writer
,它可以用于使用流式 IO 语义发送数据和接收数据。在前面的例子中,conn.Write([]byte(httpRequest))
将 HTTP 请求发送到服务器。主机返回的响应从conn
变量复制到file
变量,使用io.Copy(file, conn)
。
注意
请注意,前面的例子说明了如何使用原始 TCP 连接到 HTTP 服务器。Go 标准库提供了一个专门设计用于 HTTP 编程的单独包,它抽象了低级协议细节(在本章后面介绍)。
net
包还提供了网络特定的拨号函数,如DialUDP
,DiapTCP
或DialIP
,每个函数返回其相应的连接实现。在大多数情况下,net.Dial
函数和net.Conn
接口提供了连接和管理远程主机连接的足够能力。
监听传入的连接
创建服务程序时,首先要做的一步是宣布服务将用于监听来自网络的传入请求的端口。这是通过调用net.Listen
函数来完成的,该函数具有以下签名:
func Listen(network, laddr string) (net.Listener, error)
它需要两个参数,第一个参数指定了一个协议,有效的值为"tcp"
, "tcp4"
, "tcp6"
, "unix"
, 或 "unixpacket"
。
第二个参数是服务的本地主机地址。本地地址可以不带 IP 地址指定,如":4040"
。省略主机的 IP 地址意味着服务绑定到主机上安装的所有网络卡接口。作为替代,服务可以绑定到主机上特定的网络硬件接口,通过在网络上指定其 IP 地址,即"10.20.130.240:4040"
。
对net.Listen
函数的成功调用返回一个net.Listener
类型的值(或者如果失败,则返回一个非 nil 的错误)。net.Listener
接口公开了用于管理传入客户端连接的生命周期的方法。根据network
参数的值("tcp"
, "tcp4"
, "tcp6"
等),net.Listen
将返回net.TCPListener
或net.UnixListener
,它们都是net.Listener
接口的具体实现。
接受客户端连接
net.Listener
接口使用Accept方法无限期地阻塞,直到从客户端接收到一个新的连接。下面的简化代码片段显示了一个简单的服务器,它向每个客户端连接返回字符串"Nice to meet you!",然后立即断开连接:
func main() {
listener, err := net.Listen("tcp", ":4040")
if err != nil {
fmt.Println(err)
return
}
defer listener.Close()
for {
conn, err := listener.Accept()
if err != nil {
fmt.Println(err)
return
}
conn.Write([]byte("Nice to meet you!"))
conn.Close()
}
}
golang.fyi/ch11/listen0.go
在代码中,listener.Accept
方法返回一个net.Conn
类型的值,用于处理服务器和客户端之间的数据交换(或者如果失败,则返回一个非 nil 的error
)。conn.Write([]byte("Nice to meet you!"))
方法调用用于向客户端写入响应。当服务器程序正在运行时,可以使用telnet客户端进行测试,如下面的输出所示:
$> go run listen0.go &
[1] 83884
$> telnet 127.0.0.1 4040
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
Nice to meet you! Connection closed by foreign host.
为了确保服务器程序继续运行并处理后续的客户端连接,Accept
方法的调用被包裹在一个无限的 for 循环中。一旦连接关闭,循环重新开始等待下一个客户端连接。还要注意,当服务器进程关闭时,最好调用Listener.Close()
关闭监听器。
注意
敏锐的读者可能会注意到,这个简单的服务器无法扩展,因为它一次无法处理多个客户端请求。在下一节中,我们将看到创建可扩展服务器的技术。
一个 TCP API 服务器
到目前为止,本章已经涵盖了创建客户端和服务程序所需的最小网络组件。本章的其余部分将讨论实现货币信息服务的服务器的不同版本。该服务在每个请求中返回 ISO 4217 货币信息。目的是展示使用不同应用级协议创建网络服务及其客户端的影响。
之前我们介绍了一个非常简单的服务器,以演示设置网络服务所需的必要步骤。本节通过创建一个能够处理许多并发连接的 TCP 服务器,深入探讨了网络编程。本节中呈现的服务器代码具有以下设计目标:
-
使用原始 TCP 在客户端和服务器之间进行通信
-
开发一个简单的基于文本的协议,通过 TCP 进行通信
-
客户端可以使用文本命令查询全球货币信息
-
使用 goroutine 处理每个连接以处理连接并发
-
保持连接直到客户端断开连接
以下是服务器代码的简化版本列表。该程序使用curr
包(在github.com/vladimirvivien/learning-go/ch11/curr0
找到),这里不讨论,用于将货币数据从本地 CSV 文件加载到切片currencies
中。
成功连接到客户端后,服务器使用简单的文本协议解析传入的客户端命令,格式为GET
import (
"net"
...
curr "https://github.com/vladimirvivien/learning-go/ch11/curr0"
)
var currencies = curr.Load("./data.csv")
func main() {
ln, _ := net.Listen("tcp", ":4040")
defer ln.Close()
// connection loop
for {
conn, err := ln.Accept()
if err != nil {
fmt.Println(err)
conn.Close()
continue
}
go handleConnection(conn)
}
}
// handle client connection
func handleConnection(conn net.Conn) {
defer conn.Close()
// loop to stay connected with client
for {
cmdLine := make([]byte, (1024 * 4))
n, err := conn.Read(cmdLine)
if n == 0 || err != nil {
return
}
cmd, param := parseCommand(string(cmdLine[0:n]))
if cmd == "" {
continue
}
// execute command
switch strings.ToUpper(cmd) {
case "GET":
result := curr.Find(currencies, param)
// stream result to client
for _, cur := range result {
_, err := fmt.Fprintf(
conn,
"%s %s %s %s\n",
cur.Name, cur.Code,
cur.Number, cur.Country,
)
if err != nil {
return
}
// reset deadline while writing,
// closes conn if client is gone
conn.SetWriteDeadline(
time.Now().Add(time.Second * 5))
}
// reset read deadline for next read
conn.SetReadDeadline(
time.Now().Add(time.Second * 300))
default:
conn.Write([]byte("Invalid command\n"))
}
}
}
func parseCommand(cmdLine string) (cmd, param string) {
parts := strings.Split(cmdLine, " ")
if len(parts) != 2 {
return "", ""
}
cmd = strings.TrimSpace(parts[0])
param = strings.TrimSpace(parts[1])
return
}
golang.fyi/ch11/tcpserv0.go
与上一节介绍的简单服务器不同,这个服务器能够同时为多个客户端连接提供服务。在接受新连接时,使用ln.Accept()
委托新客户端连接的处理给一个 goroutine,使用go handleConnection(conn)
。连接循环立即继续,并等待下一个客户端连接。
handleConnection
函数管理与连接的客户端的服务器通信。它首先读取并解析来自客户端的字节片段,将其转换为命令字符串,使用cmd, param := parseCommand(string(cmdLine[0:n]))
。接下来,代码使用switch
语句测试命令。如果cmd
等于"GET"
,则代码使用curr.Find(currencies, param)
搜索切片currencies
以匹配param
的值。最后,它使用fmt.Fprintf(conn, "%s %s %s %s\n", cur.Name, cur.Code, cur.Number, cur.Country)
将搜索结果流式传输到客户端的连接。
服务器支持的简单文本协议不包括任何会话控制或控制消息。因此,代码使用conn.SetWriteDeadline
方法来确保与客户端的连接不会在长时间内不必要地挂起。该方法在向客户端流出响应的循环中调用。它设置了一个 5 秒的截止期限,以确保客户端始终准备好在该时间内接收下一块字节,否则它会超时连接。
使用 telnet 连接到 TCP 服务器
因为之前介绍的货币服务器使用了简单的基于文本的协议,所以可以使用 telnet 客户端进行测试,假设服务器代码已经编译并运行(并监听在端口4040
上)。以下是 telnet 会话查询服务器货币信息的输出:
$> telnet localhost 4040
Trying ::1...
Connected to localhost.
Escape character is '^]'.
GET Gourde
Gourde HTG 332 HAITI
GET USD
US Dollar USD 840 AMERICAN SAMOA
US Dollar USD 840 BONAIRE, SINT EUSTATIUS AND SABA
US Dollar USD 840 GUAM
US Dollar USD 840 HAITI
US Dollar USD 840 MARSHALL ISLANDS (THE)
US Dollar USD 840 UNITED STATES OF AMERICA (THE)
...
get india
Indian Rupee INR 356 BHUTAN
US Dollar USD 840 BRITISH INDIAN OCEAN TERRITORY (THE)
Indian Rupee INR 356 INDIA
如您所见,您可以使用get
命令查询服务器,后面跟随一个过滤参数,如前面所述。telnet 客户端将原始文本发送到服务器,服务器解析后以原始文本作为响应发送回来。您可以打开多个 telnet 会话与服务器连接,并且所有请求都在各自的 goroutine 中同时处理。
使用 Go 连接到 TCP 服务器
可以使用 Go 编写一个简单的 TCP 客户端来连接 TCP 服务器。客户端从控制台的标准输入中捕获命令,并将其发送到服务器,如下面的代码片段所示:
var host, port = "127.0.0.1", "4040"
var addr = net.JoinHostPort(host, port)
const prompt = "curr"
const buffLen = 1024
func main() {
conn, err := net.Dial("tcp", addr)
if err != nil {
fmt.Println(err)
return
}
defer conn.Close()
var cmd, param string
// repl - interactive shell for client
for {
fmt.Print(prompt, "> ")
_, err = fmt.Scanf("%s %s", &cmd, ¶m)
if err != nil {
fmt.Println("Usage: GET <search string or *>")
continue
}
// send command line
cmdLine := fmt.Sprintf("%s %s", cmd, param)
if n, err := conn.Write([]byte(cmdLine));
n == 0 || err != nil {
fmt.Println(err)
return
}
// stream and display response
conn.SetReadDeadline(
time.Now().Add(time.Second * 5))
for {
buff := make([]byte, buffLen)
n, err := conn.Read(buff)
if err != nil { break }
fmt.Print(string(buff[0:n]))
conn.SetReadDeadline(
time.Now().Add(time.Millisecond * 700))
}
}
}
golang.fyi/ch11/tcpclient0.go
Go 客户端的源代码遵循与之前客户端示例中相同的模式。代码的第一部分使用net.Dial()
拨号到服务器。一旦获得连接,代码设置了一个事件循环来捕获标准输入中的文本命令,解析它,并将其作为请求发送到服务器。
设置了一个嵌套循环来处理从服务器接收的响应(参见代码注释)。它不断将传入的字节流到buff
变量中,使用conn.Read(buff)
。这将一直持续,直到Read
方法遇到错误。以下列出了客户端执行时产生的示例输出:
$> Connected to Global Currency Service
curr> get pound
Egyptian Pound EGP 818 EGYPT
Gibraltar Pound GIP 292 GIBRALTAR
Sudanese Pound SDG 938 SUDAN (THE)
...
Syrian Pound SYP 760 SYRIAN ARAB REPUBLIC
Pound Sterling GBP 826 UNITED KINGDOM OF GREAT BRITAIN (THE)
curr>
从服务器流式传输传入的字节的更好方法是使用缓冲 IO,就像下面的代码片段中所做的那样。在更新的代码中,conbuf
变量,类型为bufio.Buffer
,用于使用conbuf.ReadString
方法读取和拆分从服务器传入的流:
conbuf := bufio.NewReaderSize(conn, 1024)
for {
str, err := conbuf.ReadString('\n')
if err != nil {
break
}
fmt.Print(str)
conn.SetReadDeadline(
time.Now().Add(time.Millisecond * 700))
}
golang.fyi/ch11/tcpclient1.go
正如您所看到的,直接在原始 TCP 之上编写网络服务会产生一些成本。虽然原始 TCP 使程序员完全控制应用程序级协议,但它也要求程序员仔细处理所有数据处理,这可能容易出错。除非绝对必要实现自定义协议,否则更好的方法是利用现有和经过验证的协议来实现服务器程序。本章的其余部分将继续探讨这个主题,使用基于 HTTP 的服务作为应用级协议。
HTTP 包
由于其重要性和普遍性,HTTP 是 Go 中直接实现的少数协议之一。net/http
包(golang.org/pkg/net/http/
)提供了实现 HTTP 客户端和 HTTP 服务器的代码。本节探讨了使用net/http
包创建 HTTP 客户端和服务器的基础知识。稍后,我们将把注意力转回使用 HTTP 构建货币服务的版本。
http.Client 类型
http.Client
结构表示一个 HTTP 客户端,用于创建 HTTP 请求并从服务器检索响应。以下说明了如何使用http.Client
类型的client
变量从 Project Gutenberg 网站的gutenberg.org/cache/epub/16328/pg16328.txt
检索 Beowulf 的文本内容,并将其内容打印到标准输出:
func main() {
client := http.Client{}
resp, err := client.Get(
" http://gutenberg.org/cache/epub/16328/pg16328.txt")
if err != nil {
fmt.Println(err)
return
}
defer resp.Body.Close()
io.Copy(os.Stdout, resp.Body)
}
golang.fyi/ch11/httpclient1.go
前面的示例使用client.Get
方法使用 HTTP 协议的GET
方法从远程服务器检索内容。GET
方法是Client
类型提供的几种方便方法之一,用于与 HTTP 服务器交互,如下表所总结的。请注意,所有这些方法都返回*http.Response
类型的值(稍后讨论),以处理 HTTP 服务器返回的响应。
方法 | 描述 |
---|
| Client.Get
| 正如前面讨论的,Get
是一个方便的方法,用于向服务器发出 HTTP GET
方法,以从服务器检索由url
参数指定的资源:
Get(url string,
) (resp *http.Response, err error)
|
| Client.Post
| Post
方法是一个方便的方法,用于向服务器发出 HTTP POST
方法,以将body
参数指定的内容发送到url
参数指定的服务器:
Post(
url string,
bodyType string,
body io.Reader,
) (resp *http.Response, err error)
|
| Client.PostForm
| PostForm
方法是一个方便的方法,使用 HTTP POST
方法将表单data
作为映射的键/值对发送到服务器:
PostForm(
url string,
data url.Values,
) (resp *http.Response, err error)
|
| Client.Head
| Head
方法是一个方便的方法,用于向由url
参数指定的远程服务器发出 HTTP 方法HEAD
:
Head(url string,
)(resp *http.Response, err error)
|
Client.Do |
该方法概括了与远程 HTTP 服务器的请求和响应交互。它在内部被表中列出的方法包装。处理客户端请求和响应部分讨论了如何使用该方法与服务器通信。 |
---|
应该注意的是,HTTP 包使用内部的http.Client
变量,设计为将前述方法作为包函数进一步方便地进行镜像。它们包括http.Get
、*http.Post*
、http.PostForm
和http.Head
。以下代码片段显示了前面的示例,使用http.Get
而不是http.Client
的方法:
func main() {
resp, err := http.Get(
"http://gutenberg.org/cache/epub/16328/pg16328.txt")
if err != nil {
fmt.Println(err)
return
}
defer resp.Body.Close()
io.Copy(os.Stdout, resp.Body)
}
golang.fyi/ch11/httpclient1a.go
配置客户端
除了与远程服务器通信的方法之外,http.Client
类型还公开了其他属性,可用于修改和控制客户端的行为。例如,以下源代码片段使用Client
类型的Timeout
属性将超时设置为 21 秒,以处理客户端请求的完成:
func main() {
client := &http.Client{
Timeout: 21 * time.Second
}
resp, err := client.Get(
"http://tools.ietf.org/rfc/rfc7540.txt")
if err != nil {
fmt.Println(err)
return
}
defer resp.Body.Close()
io.Copy(os.Stdout, resp.Body)
}
golang.fyi/ch11/httpclient2.go
Client
类型的Transport
字段提供了进一步控制客户端设置的手段。例如,以下代码片段创建了一个禁用连续 HTTP 请求之间连接重用的客户端,使用了DisableKeepAlive
字段。该代码还使用Dial
函数来进一步精细控制底层客户端使用的 HTTP 连接,将其超时值设置为 30 秒:
func main() {
client := &http.Client{
Transport: &http.Transport{
DisableKeepAlives: true,
Dial: (&net.Dialer{
Timeout: 30 * time.Second,
}).Dial,
},
}
...
}
处理客户端请求和响应
可以使用http.NewRequest
函数显式创建一个http.Request
值。请求值可用于配置 HTTP 设置,添加头部并指定请求的内容主体。以下源代码片段使用http.Request
类型创建一个新请求,该请求用于指定发送到服务器的头部:
func main() {
client := &http.Client{}
req, err := http.NewRequest(
"GET", "http://tools.ietf.org/rfc/rfc7540.txt", nil,
)
req.Header.Add("Accept", "text/plain")
req.Header.Add("User-Agent", "SampleClient/1.0")
resp, err := client.Do(req)
if err != nil {
fmt.Println(err)
return
}
defer resp.Body.Close()
io.Copy(os.Stdout, resp.Body)
}
golang.fyi/ch11/httpclient3.go
http.NewRequest
函数具有以下签名:
func NewRequest(method, uStr string, body io.Reader) (*http.Request, error)
它以一个字符串作为第一个参数,该字符串指定了 HTTP 方法。下一个参数指定了目标 URL。最后一个参数是一个io.Reader
,用于指定请求的内容(如果请求没有内容,则设置为 nil)。该函数返回一个指向http.Request
结构值的指针(如果发生错误,则返回非 nil 的error
)。一旦请求值被创建,代码就可以使用Header
字段向请求添加 HTTP 头,以便发送到服务器。
一旦请求准备好(如前面源代码片段所示),就可以使用http.Client
类型的Do方法将其发送到服务器,该方法具有以下签名:
Do(req *http.Request) (*http.Response, error)
该方法接受一个指向http.Request
值的指针,如前一节所述。然后返回一个指向http.Response
值的指针,或者如果请求失败则返回一个错误。在前面的源代码中,使用resp, err := client.Do(req)
将请求发送到服务器,并将响应分配给resp
变量。
服务器的响应封装在http.Response
结构中,其中包含几个字段来描述响应,包括 HTTP 响应状态、内容长度、头部和响应体。响应体作为http.Response.Body
字段公开,实现了io.Reader
,可以使用流式 IO 原语来消耗响应内容。
Body
字段还实现了*io.Closer*
,允许关闭 IO 资源。前面的源代码使用defer resp.Body.Close()
来关闭与响应体关联的 IO 资源。当服务器预期返回非 nil 主体时,这是一个推荐的习惯用法。
一个简单的 HTTP 服务器
HTTP 包提供了两个主要组件来接受 HTTP 请求和提供响应:
-
http.Handler
接口 -
http.Server
类型
http.Server
类型使用http.Handler
接口类型,如下列表所示,用于接收请求和服务器响应:
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
任何实现http.Handler
的类型都可以注册(下面会解释)为有效的处理程序。Go http.Server
类型用于创建一个新的服务器。它是一个结构体,其值可以被配置,至少包括服务的 TCP 地址和一个将响应传入请求的处理程序。以下代码片段显示了一个简单的 HTTP 服务器,将msg
类型定义为注册的处理程序来处理传入的客户端请求:
type msg string
func (m msg) ServeHTTP(
resp http.ResponseWriter, req *http.Request) {
resp.Header().Add("Content-Type", "text/html")
resp.WriteHeader(http.StatusOK)
fmt.Fprint(resp, m)
}
func main() {
msgHandler := msg("Hello from high above!")
server := http.Server{Addr: ":4040", Handler: msgHandler}
server.ListenAndServe()
}
golang.fyi/ch11/httpserv0.go
在前面的代码中,msg
类型使用字符串作为其基础类型,实现了ServeHTTP()
方法,使其成为有效的 HTTP 处理程序。它的ServeHTTP
方法使用响应参数resp
来打印响应头"200 OK"
和"Content-Type: text/html"
。该方法还使用fmt.Fprint(resp, m)
将字符串值m
写入响应变量,然后发送回客户端。
在代码中,变量server
被初始化为http.Server{Addr: ":4040", Handler: msgHandler}
。这意味着服务器将在端口4040
上监听所有网络接口,并将使用变量msgHandler
作为其http.Handler
实现。一旦初始化,服务器就会使用server.ListenAndServe()
方法调用来启动,该方法用于阻塞并监听传入的请求。
除了Addr
和Handler
之外,http.Server
结构还公开了几个额外的字段,可以用来控制 HTTP 服务的不同方面,例如连接、超时值、标头大小和 TLS 配置。例如,以下代码片段显示了一个更新后的示例,其中指定了服务器的读取和写入超时:
type msg string
func (m msg) ServeHTTP(
resp http.ResponseWriter, req *http.Request) {
resp.Header().Add("Content-Type", "text/html")
resp.WriteHeader(http.StatusOK)
fmt.Fprint(resp, m)
}
func main() {
msgHandler := msg("Hello from high above!")
server := http.Server{
Addr: ":4040",
Handler: msgHandler,
ReadTimeout: time.Second * 5,
WriteTimeout: time.Second * 3,
}
server.ListenAndServe()
}
golang.fyi/ch11/httpserv1.go
默认服务器
值得注意的是,HTTP 包包括一个默认服务器,可以在不需要配置服务器的简单情况下使用。以下简化的代码片段启动了一个简单的服务器,而无需显式创建服务器变量:
type msg string
func (m msg) ServeHTTP(
resp http.ResponseWriter, req *http.Request) {
resp.Header().Add("Content-Type", "text/html")
resp.WriteHeader(http.StatusOK)
fmt.Fprint(resp, m)
}
func main() {
msgHandler := msg("Hello from high above!")
http.ListenAndServe(":4040", msgHandler)
}
golang.fyi/ch11/httpserv2.go
在代码中,使用http.ListenAndServe(":4040", msgHandler)
函数来启动一个服务器,该服务器被声明为 HTTP 包中的一个变量。服务器配置为使用本地地址":4040"
和处理程序msgHandler
(与之前一样)来处理所有传入的请求。
使用 http.ServeMux 路由请求
在上一节介绍的http.Handler
实现并不复杂。无论请求中发送了什么 URL 路径,它都会向客户端发送相同的响应。这并不是很有用。在大多数情况下,您希望将请求 URL 的每个路径映射到不同的响应。
幸运的是,HTTP 包带有http.ServeMux
类型,它可以根据 URL 模式复用传入的请求。当http.ServeMux
处理程序接收到与 URL 路径关联的请求时,它会分派一个映射到该 URL 的函数。以下简化的代码片段显示了http.ServeMux
变量mux
配置为处理两个 URL 路径"/hello"
和"/goodbye"
:
func main() {
mux := http.NewServeMux()
hello := func(resp http.ResponseWriter, req *http.Request) {
resp.Header().Add("Content-Type", "text/html")
resp.WriteHeader(http.StatusOK)
fmt.Fprint(resp, "Hello from Above!")
}
goodbye := func(resp http.ResponseWriter, req *http.Request) {
resp.Header().Add("Content-Type", "text/html")
resp.WriteHeader(http.StatusOK)
fmt.Fprint(resp, "Goodbye, it's been real!")
}
mux.HandleFunc("/hello", hello)
mux.HandleFunc("/goodbye", goodbye)
http.ListenAndServe(":4040", mux)
}
golang.fyi/ch11/httpserv3.go
该代码声明了两个分配给变量hello
和goodbye
的函数。每个函数分别映射到路径"/hello"
和"/goodbye"
,使用mux.HandleFunc("/hello", hello)
和mux.HandleFunc("/goodbye", goodbye)
方法调用。当服务器启动时,使用http.ListenAndServe(":4040", mux)
,其处理程序将将请求"http://localhost:4040/hello"
路由到hello
函数,并将路径为"http://localhost:4040/goodbye"
的请求路由到goodbye
函数。
默认的 ServeMux
值得指出的是,HTTP 包在内部提供了一个默认的 ServeMux。当使用时,不需要显式声明 ServeMux 变量。相反,代码使用包函数http.HandleFunc
将路径映射到处理程序函数,如下面的代码片段所示:
func main() {
hello := func(resp http.ResponseWriter, req *http.Request) {
...
}
goodbye := func(resp http.ResponseWriter, req *http.Request) {
...
}
http.HandleFunc("/hello", hello)
http.HandleFunc("/goodbye", goodbye)
http.ListenAndServe(":4040", nil)
}
golang.fyi/ch11/httpserv4.go
要启动服务器,代码调用http.ListenAndServe(":4040", nil)
,其中 ServerMux 参数设置为nil
。这意味着服务器将默认使用预声明的 http.ServeMux 包实例来处理传入的请求。
一个 JSON API 服务器
有了上一节的信息,可以使用 HTTP 包在 HTTP 上创建服务。早些时候,我们讨论了使用原始 TCP 直接创建服务的危险,当时我们为全球货币服务创建了一个服务器。在本节中,我们将探讨如何使用 HTTP 作为底层协议为相同的服务创建 API 服务器。新的基于 HTTP 的服务具有以下设计目标:
-
使用 HTTP 作为传输协议
-
使用 JSON 进行客户端和服务器之间的结构化通信
-
客户端使用 JSON 格式的请求查询服务器的货币信息
-
服务器使用 JSON 格式的响应
以下显示了实现新服务所涉及的代码。这次,服务器将使用curr1
包(参见github.com/vladimirvivien/learning-go/ch11/curr1)从本地 CSV 文件加载和查询 ISO 4217 货币数据。
curr1 包中的代码定义了两种类型,CurrencyRequest
和Currency
,分别用于表示客户端请求和服务器返回的货币数据,如下所示:
type Currency struct {
Code string `json:"currency_code"`
Name string `json:"currency_name"`
Number string `json:"currency_number"`
Country string `json:"currency_country"`
}
type CurrencyRequest struct {
Get string `json:"get"`
Limit int `json:limit`
}
golang.fyi/ch11/curr1/currency.go
请注意,上述显示的结构类型带有标签,描述了每个字段的 JSON 属性。这些信息由 JSON 编码器用于编码 JSON 对象的键名(有关编码的详细信息,请参见第十章,“Go 中的数据 IO”)。以下代码的其余部分定义了设置服务器和处理传入请求的函数:
import (
"encoding/json"
"fmt"
"net/http"
" github.com/vladimirvivien/learning-go/ch11/curr1"
)
var currencies = curr1.Load("./data.csv")
func currs(resp http.ResponseWriter, req *http.Request) {
var currRequest curr1.CurrencyRequest
dec := json.NewDecoder(req.Body)
if err := dec.Decode(&currRequest); err != nil {
resp.WriteHeader(http.StatusBadRequest)
fmt.Println(err)
return
}
result := curr1.Find(currencies, currRequest.Get)
enc := json.NewEncoder(resp)
if err := enc.Encode(&result); err != nil {
fmt.Println(err)
resp.WriteHeader(http.StatusInternalServerError)
return
}
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/currency", get)
if err := http.ListenAndServe(":4040", mux); err != nil {
fmt.Println(err)
}
}
golang.fyi/ch11/jsonserv0.go
由于我们正在利用 HTTP 作为服务的传输协议,可以看到代码现在比之前使用纯 TCP 的实现要小得多。currs
函数实现了处理传入请求的处理程序。它设置了一个解码器,用于将传入的 JSON 编码请求解码为curr1.CurrencyRequest
类型的值,如下面的代码片段所示:
var currRequest curr1.CurrencyRequest
dec := json.NewDecoder(req.Body)
if err := dec.Decode(&currRequest); err != nil { ... }
接下来,该函数通过调用curr1.Find(currencies, currRequest.Get)
执行货币搜索,该函数返回分配给result
变量的[]Currency
切片。然后,代码创建一个编码器,将result
编码为 JSON 有效载荷,如下面的代码片段所示:
result := curr1.Find(currencies, currRequest.Get)
enc := json.NewEncoder(resp)
if err := enc.Encode(&result); err != nil { ... }
最后,处理程序函数在main
函数中通过调用mux.HandleFunc("/currency", currs)
映射到"/currency"
路径。当服务器收到该路径的请求时,它会自动执行currs
函数。
使用 cURL 测试 API 服务器
由于服务器是通过 HTTP 实现的,因此可以使用支持 HTTP 的任何客户端工具轻松测试。例如,以下显示了如何使用cURL
命令行工具(curl.haxx.se/
)连接到 API 端点并检索有关Euro
的货币信息:
$> curl -X POST -d '{"get":"Euro"}' http://localhost:4040/currency
[
...
{
"currency_code": "EUR",
"currency_name": "Euro",
"currency_number": "978",
"currency_country": "BELGIUM"
},
{
"currency_code": "EUR",
"currency_name": "Euro",
"currency_number": "978",
"currency_country": "FINLAND"
},
{
"currency_code": "EUR",
"currency_name": "Euro",
"currency_number": "978",
"currency_country": "FRANCE"
},
...
]
cURL
命令使用-X POST -d '{"get":"Euro"}'
参数向服务器发布 JSON 格式的请求对象。服务器的输出(格式化以便阅读)由前述货币项目的 JSON 数组组成。
Go 中的 API 服务器客户端
HTTP 客户端也可以在 Go 中构建,以最小的努力来消耗服务。如下面的代码片段所示,客户端代码使用http.Client
类型与服务器通信。它还使用encoding/json
子包来解码传入的数据(请注意,客户端还使用了之前显示的curr1
包,其中包含与服务器通信所需的类型):
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
" github.com/vladimirvivien/learning-go/ch11/curr1"
)
func main() {
var param string
fmt.Print("Currency> ")
_, err := fmt.Scanf("%s", ¶m)
buf := new(bytes.Buffer)
currRequest := &curr1.CurrencyRequest{Get: param}
err = json.NewEncoder(buf).Encode(currRequest)
if err != nil {
fmt.Println(err)
return
}
// send request
client := &http.Client{}
req, err := http.NewRequest(
"POST", "http://127.0.0.1:4040/currency", buf)
if err != nil {
fmt.Println(err)
return
}
resp, err := client.Do(req)
if err != nil {
fmt.Println(err)
return
}
defer resp.Body.Close()
// decode response
var currencies []curr1.Currency
err = json.NewDecoder(resp.Body).Decode(¤cies)
if err != nil {
fmt.Println(err)
return
}
fmt.Println(currencies)
}
golang.fyi/ch11/jsonclient0.go
在前面的代码中,创建了一个 HTTP 客户端以将 JSON 编码的请求值发送为currRequest := &curr1.CurrencyRequest{Get: param}
,其中param
是要检索的货币字符串。服务器的响应是表示 JSON 编码对象数组的有效负载(请参见使用 cURL 测试 API 服务器部分中的 JSON 数组)。然后代码使用 JSON 解码器json.NewDecoder(resp.Body).Decode(¤cies)
将响应体中的有效负载解码为切片[]curr1.Currency
。
JavaScript API 服务器客户端
到目前为止,我们已经看到了如何使用cURL
命令行工具和本机 Go 客户端使用 API 服务。本节展示了使用 HTTP 实现网络服务的多功能性,通过展示基于 Web 的 JavaScript 客户端。在这种方法中,客户端是一个基于 Web 的 GUI,使用现代 HTML、CSS 和 JavaScript 创建一个与 API 服务器交互的界面。
首先,服务器代码更新为添加一个处理程序,以提供在浏览器上呈现 GUI 的静态 HTML 文件。以下是示例代码:
// serves HTML gui
func gui(resp http.ResponseWriter, req *http.Request) {
file, err := os.Open("./currency.html")
if err != nil {
resp.WriteHeader(http.StatusInternalServerError)
fmt.Println(err)
return
}
io.Copy(resp, file)
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/", gui)
mux.HandleFunc("/currency", currs)
if err := http.ListenAndServe(":4040", mux); err != nil {
fmt.Println(err)
}
}
golang.fyi/ch11/jsonserv1.go
前面的代码片段显示了gui
处理程序函数的声明,负责提供用于为客户端呈现 GUI 的静态 HTML 文件。然后将根 URL 路径映射到该函数,使用mux.HandleFunc("/", gui)
。因此,除了"/currency"
路径(托管 API 端点),"/"
路径将返回以下截图中显示的网页:
下一个 HTML 页面(golang.fyi/ch11/currency.html)负责显示货币搜索结果。它使用 JavaScritpt 函数以及jQuery.js
库(此处未涵盖)来将 JSON 编码的请求发送到后端 Go 服务,如下所示的缩写 HTML 和 JavaScript 片段:
<body>
<div class="container">
<h2>Global Currency Service</h2>
<p>Enter currency search string: <input id="in">
<button type="button" class="btn btn-primary" onclick="doRequest()">Search</button>
</p>
<table id="tbl" class="table table-striped">
<thead>
<tr>
<th>Code</th>
<th>Name</th>
<th>Number</th>
<th>Country</th>
</tr>
</thead>
<tbody/>
</table>
</div>
<script>
var tbl = document.getElementById("tbl");
function addRow(code, name, number, country) {
var rowCount = tbl.rows.length;
var row = tbl.insertRow(rowCount);
row.insertCell(0).innerHTML = code;
row.insertCell(1).innerHTML = name;
row.insertCell(2).innerHTML = number;
row.insertCell(3).innerHTML = country;
}
function doRequest() {
param = document.getElementById("in").value
$.ajax('/currency', {
method: 'PUT',
contentType: 'application/json',
processData: false,
data: JSON.stringify({get:param})
}).then(
function success(currencies) {
currs = JSON.parse(currencies)
for (i=0; i < currs.length; i++) {
addRow(
currs[i].currency_code,
currs[i].currency_name,
currs[i].currency_number,
currs[i].currency_country
);
}
});
}
</script>
golang.fyi/ch11/currency.html
对于本示例中 HTML 和 JavaScript 代码的逐行分析超出了本书的范围;然而,值得指出的是,JavaScript 的doRequest
函数是客户端和服务器之间交互发生的地方。它使用 jQuery 的$.ajax
函数构建一个带有PUT
方法的 HTTP 请求,并指定一个 JSON 编码的货币请求对象JSON.stringify({get:param})
发送到服务器。then
方法接受回调函数success(currencies)
,处理来自服务器的响应并在 HTML 表格中显示。
当在 GUI 的文本框中提供搜索值时,页面会动态地在表格中显示其结果,如下截图所示:
摘要
本章节总结了关于在 Go 中创建网络服务的几个重要概念。它从 Go 的net
包开始,包括net.Conn
类型用于在网络节点之间创建连接,net.Dial
函数用于连接到远程服务,以及net.Listen
函数用于处理来自客户端的传入连接。本章继续涵盖了客户端和服务器程序的不同实现,并展示了直接在原始 TCP 上创建自定义协议与使用现有协议(如带有 JSON 数据格式的 HTTP)的影响。
下一章采取了不同的方向。它探讨了在 Go 中可用的包、类型、函数和工具,以便进行源代码测试。
第十二章:代码测试
测试是现代软件开发实践的关键仪式。Go 通过提供 API 和命令行工具,将测试直接融入开发周期,无缝创建和集成自动化测试代码。在这里,我们将介绍 Go 测试套件,包括以下内容:
-
Go 测试工具
-
编写 Go 测试
-
HTTP 测试
-
测试覆盖率
-
代码基准
Go 测试工具
在编写任何测试代码之前,让我们先讨论一下 Go 中自动化测试的工具。与go build
命令类似,go test
命令旨在编译和执行指定包中的测试源文件,如下命令所示:
$> go test .
上述命令将执行当前包中的所有测试函数。尽管看起来很简单,但上述命令完成了几个复杂的步骤,包括:
-
编译当前包中找到的所有测试文件
-
从测试文件生成一个带有仪器的二进制文件
-
执行代码中的测试函数
当go test
命令针对多个包时,测试工具将生成多个测试二进制文件,这些二进制文件将独立执行和测试,如下所示:
$> go test ./...
测试文件名称
测试命令使用导入路径标准(请参阅第六章,Go 包和程序)来指定要测试的包。在指定的包内,测试工具将编译所有具有*_test.go
名称模式的文件。例如,假设我们有一个项目,在名为vec.go
的文件中有一个简单的数学向量类型的实现,那么它的测试文件的合理名称将是vec_test.go
。
测试组织
传统上,测试文件保存在与被测试的代码相同的包(目录)中。这是因为不需要将测试文件分开,因为它们被排除在编译的程序二进制文件之外。以下显示了典型 Go 包的目录布局,本例中是标准库中的fmt
包。它显示了包的所有测试文件与常规源代码在同一目录中:
$>tree go/src/fmt/
├── doc.go
├── export_test.go
├── fmt_test.go
├── format.go
├── norace_test.go
├── print.go
├── race_test.go
├── scan.go
├── scan_test.go
└── stringer_test.go
除了拥有更简单的项目结构外,将文件放在一起使测试函数完全可见被测试的包。这有助于访问和验证否则对测试代码不透明的包元素。当您的函数放在与要测试的代码不同的包中时,它们将失去对代码的非导出元素的访问权限。
编写 Go 测试
Go 测试文件只是一组具有以下签名的函数:
func Test
在这里,
在编写测试函数之前,让我们回顾一下将要测试的代码。以下源代码片段显示了一个简单的数学向量的实现,具有Add
、Sub
和Scale
方法(请参阅github.com/vladimirvivien/learning-go/ch12/vector/vec.go
上列出的完整源代码)。请注意,每个方法都实现了特定的行为作为功能单元,这将使测试变得容易:
type Vector interface {
Add(other Vector) Vector
Sub(other Vector) Vector
Scale(factor float64)
...
}
func New(elems ...float64) SimpleVector {
return SimpleVector(elems)
}
type SimpleVector []float64
func (v SimpleVector) Add(other Vector) Vector {
v.assertLenMatch(other)
otherVec := other.(SimpleVector)
result := make([]float64, len(v))
for i, val := range v {
result[i] = val + otherVec[i]
}
return SimpleVector(result)
}
func (v SimpleVector) Sub(other Vector) Vector {
v.assertLenMatch(other)
otherVec := other.(SimpleVector)
result := make([]float64, len(v))
for i, val := range v {
result[i] = val - otherVec[i]
}
return SimpleVector(result)
}
func (v SimpleVector) Scale(scale float64) {
for i := range v {
v[i] = v[i] * scale
}
}
...
golang.fyi/ch12/vector/vec.go
测试函数
文件vec_test.go
中的测试源代码定义了一系列函数,通过独立测试其每个方法来执行SimpleVector
类型(请参见前一节)的行为:
import "testing"
func TestVectorAdd(t *testing.T) {
v1 := New(8.218, -9.341)
v2 := New(-1.129, 2.111)
v3 := v1.Add(v2)
expect := New(
v1[0]+v2[0],
v1[1]+v2[1],
)
if !v3.Eq(expect) {
t.Logf("Addition failed, expecting %s, got %s",
expect, v3)
t.Fail()
}
t.Log(v1, "+", v2, v3)
}
func TestVectorSub(t *testing.T) {
v1 := New(7.119, 8.215)
v2 := New(-8.223, 0.878)
v3 := v1.Sub(v2)
expect := New(
v1[0]-v2[0],
v1[1]-v2[1],
)
if !v3.Eq(expect) {
t.Log("Subtraction failed, expecting %s, got %s",
expect, v3)
t.Fail()
}
t.Log(v1, "-", v2, "=", v3)
}
func TestVectorScale(t *testing.T) {
v := New(1.671, -1.012, -0.318)
v.Scale(7.41)
expect := New(
7.41*1.671,
7.41*-1.012,
7.41*-0.318,
)
if !v.Eq(expect) {
t.Logf("Scalar mul failed, expecting %s, got %s",
expect, v)
t.Fail()
}
t.Log("1.671,-1.012, -0.318 Scale", 7.41, "=", v)
}
golang.fyi/ch12/vector/vec_test.go
如前面的代码所示,所有测试源代码必须导入"testing"
包。这是因为每个测试函数都接收一个类型为*testing.T
的参数。正如本章中进一步讨论的那样,这允许测试函数与 Go 测试运行时进行交互。
至关重要的是要意识到每个测试函数都应该是幂等的,并且不依赖于任何先前保存或共享的状态。 在前面的源代码片段中,每个测试函数都作为独立的代码片段执行。 您的测试函数不应该假设执行的顺序,因为 Go 测试运行时不会做出这样的保证。
测试函数的源代码通常设置了一个预期值,该值是根据对被测试代码的了解而预先确定的。 然后将该值与被测试代码返回的计算值进行比较。 例如,当添加两个向量时,我们可以使用向量加法规则计算预期结果,如下面的代码片段所示:
v1 := New(8.218, -9.341)
v2 := New(-1.129, 2.111)
v3 := v1.Add(v2)
expect := New(
v1[0]+v2[0],
v1[1]+v2[1],
)
在前面的源代码片段中,使用两个简单的向量值v1
和v2
计算预期值,并存储在变量expect
中。 另一方面,变量v3
存储了向量的实际值,由被测试代码计算得出。 这使我们可以测试实际值与预期值,如下所示:
if !v3.Eq(expect) {
t.Log("Addition failed, expecting %s, got %s", expect, v3)
t.Fail()
}
在前面的源代码片段中,如果测试的条件是false
,那么测试就失败了。 代码使用t.Fail()
来表示测试函数的失败。 关于信号失败的讨论将在报告失败部分详细讨论。
运行测试
如本章的介绍部分所述,使用go test
命令行工具执行测试函数。 例如,如果我们在包向量中运行以下命令,它将自动运行该包的所有测试函数:
$> cd vector
$> go test .
ok github.com/vladimirvivien/learning-go/ch12/vector 0.001s
还可以通过指定子包(或使用包通配符“./…”指定所有包)来执行测试,如下所示:
$> cd $GOPATH/src/github.com/vladimirvivien/learning-go/ch12/
$> go test ./vector
ok github.com/vladimirvivien/learning-go/ch12/vector 0.005s
过滤执行的测试
在开发大量测试函数时,通常希望在调试阶段专注于一个函数(或一组函数)。 Go 测试命令行工具支持-run
标志,该标志指定一个正则表达式,只执行其名称与指定表达式匹配的函数。 以下命令将只执行测试函数TestVectorAdd
:
$> go test -run=VectorAdd -v
=== RUN TestVectorAdd
--- PASS: TestVectorAdd (0.00s)
PASS
ok github.com/vladimirvivien/learning-go/ch12/vector 0.025s
使用-v
标志确认只执行了一个测试函数TestVectorAdd
。 例如,以下示例执行所有以VectorA.*$
结尾或匹配函数名TestVectorMag
的测试函数,同时忽略其他所有内容:
> go test -run="VectorA.*$|TestVectorMag" -v
=== RUN TestVectorAdd
--- PASS: TestVectorAdd (0.00s)
=== RUN TestVectorMag
--- PASS: TestVectorMag (0.00s)
=== RUN TestVectorAngle
--- PASS: TestVectorAngle (0.00s)
PASS
ok github.com/vladimirvivien/learning-go/ch12/vector 0.043s
测试日志
在编写新的或调试现有的测试函数时,将信息打印到标准输出通常是有帮助的。 类型testing.T
提供了两种日志记录方法:Log
使用默认格式化程序,Logf
使用格式化动词(如在fmt
包中定义)。 例如,向量示例中的以下测试函数片段显示了使用t.Logf("Vector = %v; Unit vector = %v\n", v, expect)
记录信息的代码:
func TestVectorUnit(t *testing.T) {
v := New(5.581, -2.136)
mag := v.Mag()
expect := New((1/mag)*v[0], (1/mag)*v[1])
if !v.Unit().Eq(expect) {
t.Logf("Vector Unit failed, expecting %s, got %s",
expect, v.Unit())
t.Fail()
}
t.Logf("Vector = %v; Unit vector = %v\n", v, expect)
}
golang.fyi/ch12/vector/vec_test.go
如前所述,除非有测试失败,否则 Go 测试工具会以最小的输出运行测试。 但是,当提供详细标志*-v*
时,该工具将输出测试日志。 例如,在包向量中运行以下命令将静音所有日志记录语句:
> go test -run=VectorUnit
PASS
ok github.com/vladimirvivien/learning-go/ch12/vector 0.005s
当提供了详细标志-v
,如下命令所示,测试运行时会打印日志的输出,如下所示:
$> go test -run=VectorUnit -v
=== RUN TestVectorUnit
--- PASS: TestVectorUnit (0.00s)
vec_test.go:100: Vector = [5.581,-2.136]; Unit vector =
[0.9339352140866403,-0.35744232526233]
PASS
ok github.com/vladimirvivien/learning-go/ch12/vector 0.001s
报告失败
默认情况下,如果测试函数正常运行并返回而没有发生 panic,Go 测试运行时将认为测试是成功的。 例如,以下测试函数是错误的,因为其预期值没有正确计算。 但是,测试运行时将始终报告它通过,因为它不包括任何报告失败的代码:
func TestVectorDotProd(t *testing.T) {
v1 := New(7.887, 4.138).(SimpleVector)
v2 := New(-8.802, 6.776).(SimpleVector)
actual := v1.DotProd(v2)
expect := v1[0]*v2[0] - v1[1]*v2[1]
if actual != expect {
t.Logf("DotPoduct failed, expecting %d, got %d",
expect, actual)
}
}
golang.fyi/ch12/vec_test.go
这种误报的情况可能会被忽视,特别是在关闭了详细标志的情况下,最大程度地减少了任何视觉线索表明它已经损坏:
$> go test -run=VectorDot
PASS
ok github.com/vladimirvivien/learning-go/ch12/vector 0.001s
修复前面的测试的一种方法是使用 testing.T
类型的 Fail
方法来表示失败,如下面的代码片段所示:
func TestVectorDotProd(t *testing.T) {
...
if actual != expect {
t.Logf("DotPoduct failed, expecting %d, got %d",
expect, actual)
t.Fail()
}
}
因此,当执行测试时,它会正确报告它是有问题的,如下面的输出所示:
$> go test -run=VectorDot
--- FAIL: TestVectorDotProd (0.00s)
vec_test.go:109: DotPoduct failed, expecting -97.460462, got -41.382286
FAIL
exit status 1
FAIL github.com/vladimirvivien/learning-go/ch12/vector 0.002s
重要的是要理解,Fail
方法只报告失败,不会终止测试函数的执行。另一方面,当在失败条件下实际上需要退出函数时,测试 API 提供了 FailNow
方法,它表示失败并退出当前正在执行的测试函数。
testing.T
类型提供了方便的 Logf
和 Errorf
方法,结合了日志记录和失败报告。例如,以下代码片段使用了 Errorf
方法,它相当于调用 Logf
和 Fail
方法:
func TestVectorMag(t *testing.T) {
v := New(-0.221, 7.437)
expected := math.Sqrt(v[0]*v[0] + v[1]*v[1])
if v.Mag() != expected {
t.Errorf("Magnitude failed, execpted %d, got %d",
expected, v.Mag())
}
}
golang.fyi/ch12/vector/vec.go
testing.T
类型还提供了 Fatal
和 Formatf
方法,用于将消息记录和测试函数的立即终止结合在一起。
跳过测试
有时由于一些因素,如环境限制、资源可用性或不合适的环境设置,有必要跳过测试函数。测试 API 可以使用 testing.T
类型的 SkipNow
方法来跳过测试函数。以下源代码片段只有在设置了名为 RUN_ANGLE
的任意操作系统环境变量时才会运行测试函数。否则,它将跳过测试:
func TestVectorAngle(t *testing.T) {
if os.Getenv("RUN_ANGLE") == "" {
t.Skipf("Env variable RUN_ANGLE not set, skipping:")
}
v1 := New(3.183, -7.627)
v2 := New(-2.668, 5.319)
actual := v1.Angle(v2)
expect := math.Acos(v1.DotProd(v2) / (v1.Mag() * v2.Mag()))
if actual != expect {
t.Logf("Vector angle failed, expecting %d, got %d",
expect, actual)
t.Fail()
}
t.Log("Angle between", v1, "and", v2, "=", actual)
}
请注意,代码使用了 Skipf
方法,这是 testing.T
类型的 SkipNow
和 Logf
方法的组合。当测试在没有环境变量的情况下执行时,输出如下:
$> go test -run=Angle -v
=== RUN TestVectorAngle
--- SKIP: TestVectorAngle (0.00s)
vec_test.go:128: Env variable RUN_ANGLE not set, skipping:
PASS
ok github.com/vladimirvivien/learning-go/ch12/vector 0.006s
当提供环境变量时,如下面的 Linux/Unix 命令所示,测试会按预期执行(请参考您的操作系统如何设置环境变量):
> RUN_ANGLE=1 go test -run=Angle -v
=== RUN TestVectorAngle
--- PASS: TestVectorAngle (0.00s)
vec_test.go:138: Angle between [3.183,-7.627] and [-2.668,5.319] = 3.0720263098372476
PASS
ok github.com/vladimirvivien/learning-go/ch12/vector 0.005s
表驱动测试
在 Go 中经常遇到的一种技术是使用表驱动测试。这是指一组输入和期望的输出存储在一个数据结构中,然后用于循环执行不同的测试场景。例如,在下面的测试函数中,cases
变量的类型为 []struct{vec SimpleVector; expected float64}
,用于存储多个矢量值及其预期的大小值,以测试矢量方法 Mag
:
func TestVectorMag(t *testing.T) {
cases := []struct{
vec SimpleVector
expected float64
}{
{New(1.2, 3.4), math.Sqrt(1.2*1.2 + 3.4*3.4)},
{New(-0.21, 7.47), math.Sqrt(-0.21*-0.21 + 7.47*7.47)},
{New(1.43, -5.40), math.Sqrt(1.43*1.43 + -5.40*-5.40)},
{New(-2.07, -9.0), math.Sqrt(-2.07*-2.07 + -9.0*-9.0)},
}
for _, c := range cases {
mag := c.vec.Mag()
if mag != c.expected {
t.Errorf("Magnitude failed, execpted %d, got %d",
c.expected, mag)
}
}
}
golang.fyi/ch12/vector/vec.go
在循环的每次迭代中,代码都会测试 Mag
方法计算出的值与预期值。使用这种方法,我们可以测试多种输入组合及其相应的输出,就像前面的代码所做的那样。这种技术可以根据需要扩展,以包括更多的参数。例如,可以使用一个名称字段来为每个案例命名,在测试案例数量较多时非常有用。或者,更加花哨的是,可以在测试案例结构中包含一个函数字段,用于指定每个相应案例使用的自定义逻辑。
HTTP 测试
在第十一章中,编写网络服务,我们看到 Go 提供了一流的 API 来使用 HTTP 构建客户端和服务器程序。net/http/httptest
子包是 Go 标准库的一部分,它便于对 HTTP 服务器和客户端代码进行测试自动化,正如本节所讨论的那样。
为了探索这个领域,我们将实现一个简单的 API 服务,该服务将矢量操作(在前面的章节中介绍)作为 HTTP 端点暴露出来。例如,以下源代码片段部分显示了构成服务器的方法(完整列表请参见 github.com/vladimirvivien/learning-go/ch12/service/serv.go
):
package main
import (
"encoding/json"
"fmt"
"net/http"
"github.com/vladimirvivien/learning-go/ch12/vector"
)
func add(resp http.ResponseWriter, req *http.Request) {
var params []vector.SimpleVector
if err := json.NewDecoder(req.Body).Decode(¶ms);
err != nil {
resp.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(resp, "Unable to parse request: %s\n", err)
return
}
if len(params) != 2 {
resp.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(resp, "Expected 2 or more vectors")
return
}
result := params[0].Add(params[1])
if err := json.NewEncoder(resp).Encode(&result); err != nil {
resp.WriteHeader(http.StatusInternalServerError)
fmt.Fprintf(resp, err.Error())
return
}
}
...
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/vec/add", add)
mux.HandleFunc("/vec/sub", sub)
mux.HandleFunc("/vec/dotprod", dotProd)
mux.HandleFunc("/vec/mag", mag)
mux.HandleFunc("/vec/unit", unit)
if err := http.ListenAndServe(":4040", mux); err != nil {
fmt.Println(err)
}
}
golang.fyi/ch12/service/serv.go
每个函数(add
、sub
、dotprod
、mag
和unit
)都实现了http.Handler
接口。这些函数用于处理来自客户端的 HTTP 请求,以计算vector
包中的相应操作。请求和响应都使用 JSON 格式进行格式化,以简化操作。
测试 HTTP 服务器代码
编写 HTTP 服务器代码时,您无疑会遇到需要以稳健且可重复的方式测试代码的需求,而无需设置一些脆弱的代码来模拟端到端测试。httptest.ResponseRecorder
类型专门设计用于通过检查测试函数中对http.ResponseWriter
的状态更改来提供单元测试功能。例如,以下代码片段使用httptest.ResponseRecorder
来测试服务器的add
方法:
import (
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"github.com/vladimirvivien/learning-go/ch12/vector"
)
func TestVectorAdd(t *testing.T) {
reqBody := "[[1,2],[3,4]]"
req, err := http.NewRequest(
"POST", "http://0.0.0.0/", strings.NewReader(reqBody))
if err != nil {
t.Fatal(err)
}
actual := vector.New(1, 2).Add(vector.New(3, 4))
w := httptest.NewRecorder()
add(w, req)
if actual.String() != strings.TrimSpace(w.Body.String()) {
t.Fatalf("Expecting actual %s, got %s",
actual.String(), w.Body.String(),
)
}
}
代码使用reg, err := http.NewRequest("POST", "http://0.0.0.0/", strings.NewReader(reqBody))
创建一个新的*http.Request
值,其中包括"POST"
方法、一个虚假的 URL 和一个请求主体reqBody
,编码为 JSON 数组。代码中后来使用w := httptest.NewRecorder()
创建一个httputil.ResponseRecorder
值,用于调用add(w, req)
函数以及创建的请求。在执行add
函数期间记录在w
中的值与存储在atual
中的预期值进行比较,if actual.String() != strings.TrimSpace(w.Body.String()){...}
。
测试 HTTP 客户端代码
为 HTTP 客户端创建测试代码更为复杂,因为实际上需要运行服务器进行适当的测试。幸运的是,httptest
包提供了类型httptest.Server
,可以以编程方式创建服务器,以测试客户端请求并向客户端发送模拟响应。
为了举例说明,让我们考虑以下代码,它部分展示了早些时候介绍的向矢量服务器实现 HTTP 客户端的代码(请参阅完整的代码清单github.com/vladimirvivien/learning-go/ch12/client/client.go
)。add
方法将类型为vector.SimpleVector
的参数vec0
和vec2
编码为 JSON 对象,然后使用c.client.Do(req)
将其发送到服务器。响应从 JSON 数组解码为类型为vector.SimpleVector
的变量result
:
type vecClient struct {
svcAddr string
client *http.Client
}
func (c *vecClient) add(
vec0, vec1 vector.SimpleVector) (vector.SimpleVector, error) {
uri := c.svcAddr + "/vec/add"
// encode params
var body bytes.Buffer
params := []vector.SimpleVector{vec0, vec1}
if err := json.NewEncoder(&body).Encode(¶ms); err != nil {
return []float64{}, err
}
req, err := http.NewRequest("POST", uri, &body)
if err != nil {
return []float64{}, err
}
// send request
resp, err := c.client.Do(req)
if err != nil {
return []float64{}, err
}
defer resp.Body.Close()
// handle response
var result vector.SimpleVector
if err := json.NewDecoder(resp.Body).
Decode(&result); err != nil {
return []float64{}, err
}
return result, nil
}
golang.fyi/ch12/client/client.go
我们可以使用类型httptest.Server
创建用于测试客户端发送的请求并将数据返回给客户端代码以进行进一步检查的代码。函数httptest.NewServer
接受类型为http.Handler
的值,其中封装了服务器的测试逻辑。然后该函数返回一个新的运行中的 HTTP 服务器,准备在系统选择的端口上提供服务。
以下测试函数显示了如何使用httptest.Server
来执行先前介绍的客户端代码中的add
方法。请注意,创建服务器时,代码使用了类型http.HandlerFunc
,这是一个适配器,它接受函数值以生成http.Handler
。这种便利性使我们能够跳过创建一个单独的类型来实现新的http.Handler
:
import(
"net/http"
"net/http/httptest"
...
)
func TestClientAdd(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(
func(resp http.ResponseWriter, req *http.Request) {
// test incoming request path
if req.URL.Path != "/vec/add" {
t.Errorf("unexpected request path %s",
req.URL.Path)
return
}
// test incoming params
body, _ := ioutil.ReadAll(req.Body)
params := strings.TrimSpace(string(body))
if params != "[[1,2],[3,4]]" {
t.Errorf("unexpected params '%v'", params)
return
}
// send result
result := vector.New(1, 2).Add(vector.New(3, 4))
err := json.NewEncoder(resp).Encode(&result)
if err != nil {
t.Fatal(err)
return
}
},
))
defer server.Close()
client := newVecClient(server.URL)
expected := vector.New(1, 2).Add(vector.New(3, 4))
result, err := client.add(vector.New(1, 2), vector.New(3, 4))
if err != nil {
t.Fatal(err)
}
if !result.Eq(expected) {
t.Errorf("Expecting %s, got %s", expected, result)
}
}
golang.fyi/ch12/client/client_test.go
测试函数首先设置server
及其处理函数。在http.HandlerFunc
的函数内部,代码首先确保客户端请求了"/vec/add"
的正确路径。接下来,代码检查来自客户端的请求主体,确保 JSON 格式正确,并且对于添加操作是有效的参数。最后,处理程序函数将期望的结果编码为 JSON,并将其作为响应发送给客户端。
代码使用系统生成的server
地址创建一个新的client
,并使用newVecClient(server.URL)
。方法调用client.add(vector.New(1, 2), vector.New(3, 4))
发送一个请求到测试服务器,计算其参数列表中两个值的向量加法。如前所示,测试服务器仅模拟真实服务器代码,并返回计算的向量值。检查result
与expected
值以确保add
方法的正常工作。
测试覆盖率
在编写测试时,了解实际代码有多少被测试覆盖通常很重要。这个数字是测试逻辑对源代码的渗透程度的指示。无论您是否同意,在许多软件开发实践中,测试覆盖率都是一个关键指标,因为它衡量了代码测试的程度。
幸运的是,Go 测试工具自带了一个内置的覆盖工具。使用-cover
标志运行 Go 测试命令会为原始源代码添加覆盖逻辑。然后运行生成的测试二进制文件,提供包的整体覆盖率概要,如下所示:
$> go test -cover
PASS
coverage: 87.8% of statements
ok github.com/vladimirvivien/learning-go/ch12/vector 0.028s
结果显示了一个覆盖率为87.8%
的经过充分测试的代码。我们可以使用测试工具提取有关已测试代码部分的更多详细信息。为此,我们使用-coverprofile
标志将覆盖率指标记录到文件中,如下所示:
$> go test -coverprofile=cover.out
覆盖工具
保存覆盖数据后,可以使用go tool cover
命令以文本制表格式表格的形式呈现。以下显示了先前生成的覆盖文件中每个测试函数的覆盖指标的部分输出:
$> go tool cover -func=cover.out
...
learning-go/ch12/vector/vec.go:52: Eq 100.0%
learning-go/ch12/vector/vec.go:57: Eq2 83.3%
learning-go/ch12/vector/vec.go:74: Add 100.0%
learning-go/ch12/vector/vec.go:85: Sub 100.0%
learning-go/ch12/vector/vec.go:96: Scale 100.0%
...
cover
工具可以将覆盖率指标叠加在实际代码上,提供视觉辅助,显示代码的覆盖(和未覆盖)部分。使用-html
标志生成使用先前收集的覆盖数据的 HTML 页面:
$> go tool cover -html=cover.out
该命令打开已安装的默认 Web 浏览器,并显示覆盖数据,如下截图所示:
前面的截图只显示了生成的 HTML 页面的一部分。它显示覆盖的代码为绿色,未覆盖的代码为红色。其他任何内容显示为灰色。
代码基准
基准测试的目的是衡量代码的性能。Go 测试命令行工具支持自动生成和测量基准指标。与单元测试类似,测试工具使用基准函数来指定要测量的代码部分。基准函数使用以下函数命名模式和签名:
func Benchmark
基准函数的名称应以benchmark开头,并接受类型为*testing.B
的指针值。以下显示了一个基准SimpleVector
类型的Add
方法的函数(之前介绍过):
import (
"math/rand"
"testing"
"time"
)
...
func BenchmarkVectorAdd(b *testing.B) {
r := rand.New(rand.NewSource(time.Now().UnixNano()))
for i := 0; i < b.N; i++ {
v1 := New(r.Float64(), r.Float64())
v2 := New(r.Float64(), r.Float64())
v1.Add(v2)
}
}
golang.fyi/ch12/vector/vec_bench_test.go
Go 的测试运行时通过将指针*testing.B
注入为参数来调用基准函数。该值定义了与基准框架交互的方法,如日志记录、失败信号和其他类似于类型testing.T
的功能。类型testing.B
还提供了额外的基准特定元素,包括整数字段N
。它旨在是基准函数应使用的迭代次数的数量。
被基准测试的代码应放在一个由N
限定的for
循环中,如前面的示例所示。为了使基准有效,每次循环迭代的输入大小不应有变化。例如,在前面的基准测试中,每次迭代始终使用大小为2
的向量(虽然向量的实际值是随机的)。
运行基准
除非测试命令行工具接收到 -bench
标志,否则不会执行基准测试函数。以下命令在当前包中运行所有基准测试函数:
$> go test -bench=.
PASS
BenchmarkVectorAdd-2 2000000 761 ns/op
BenchmarkVectorSub-2 2000000 788 ns/op
BenchmarkVectorScale-2 5000000 269 ns/op
BenchmarkVectorMag-2 5000000 243 ns/op
BenchmarkVectorUnit-2 3000000 507 ns/op
BenchmarkVectorDotProd-2 3000000 549 ns/op
BenchmarkVectorAngle-2 2000000 659 ns/op
ok github.com/vladimirvivien/learning-go/ch12/vector 14.123s
在剖析基准测试结果之前,让我们了解先前发出的命令。go test -bench=.
命令首先执行包中的所有测试函数,然后执行所有基准测试函数(您可以通过在命令中添加 -v
标志来验证这一点)。
与 -run
标志类似,-bench
标志指定了一个正则表达式,用于选择要执行的基准测试函数。-bench=.
标志匹配所有基准测试函数的名称,就像前面的示例中所示的那样。然而,以下示例只运行包含其名称中包含模式 "VectorA"
的基准测试函数。这包括 BenchmarkVectroAngle()
和 BenchmarkVectorAngle()
函数:
$> go test -bench="VectorA"
PASS
BenchmarkVectorAdd-2 2000000 764 ns/op
BenchmarkVectorAngle-2 2000000 665 ns/op
ok github.com/vladimirvivien/learning-go/ch12/vector 4.396s
跳过测试函数
如前所述,当执行基准测试时,测试工具还将运行所有测试函数。这可能是不希望的,特别是如果您的包中有大量测试。在基准测试执行期间跳过测试函数的一个简单方法是将 -run
标志设置为与没有测试函数匹配的值,如下所示:
> go test -bench=. -run=NONE -v
PASS
BenchmarkVectorAdd-2 2000000 791 ns/op
BenchmarkVectorSub-2 2000000 777 ns/op
...
BenchmarkVectorAngle-2 2000000 653 ns/op
ok github.com/vladimirvivien/learning-go/ch12/vector 14.069s
前面的命令只执行基准测试函数,如部分冗长输出所示。-run
标志的值是完全任意的,可以设置为任何值,使其跳过执行测试函数。
基准测试报告
与测试不同,基准测试报告始终是冗长的,并显示了几列度量标准,如下所示:
$> go test -run=NONE -bench="Add|Sub|Scale"
PASS
BenchmarkVectorAdd-2 2000000 800 ns/op
BenchmarkVectorSub-2 2000000 798 ns/op
BenchmarkVectorScale-2 5000000 266 ns/op
ok github.com/vladimirvivien/learning-go/ch12/vector 6.473s
第一列包含基准测试函数的名称,每个名称都以一个反映 GOMAXPROCS 值的数字作为后缀,可以使用 -cpu
标志在测试时设置(用于并行运行基准测试)。
下一列显示了每个基准测试循环的迭代次数。例如,在前面的报告中,前两个基准测试函数循环了 200 万次,而最后一个基准测试函数迭代了 500 万次。报告的最后一列显示了执行测试函数所需的平均时间。例如,对 Scale
方法的 500 万次调用在基准测试函数 BenchmarkVectorScale
中平均需要 266 纳秒才能完成。
调整 N
默认情况下,测试框架会逐渐调整 N
的大小,以便在 一秒 的时间内获得稳定和有意义的度量标准。您不能直接更改 N
。但是,您可以使用 -benchtime
标志指定基准测试运行时间,从而影响基准测试期间的迭代次数。例如,以下示例运行基准测试 5
秒:
> go test -run=Bench -bench="Add|Sub|Scale" -benchtime 5s
PASS
BenchmarkVectorAdd-2 10000000 784 ns/op
BenchmarkVectorSub-2 10000000 810 ns/op
BenchmarkVectorScale-2 30000000 265 ns/op
ok github.com/vladimirvivien/learning-go/ch12/vector 25.877s
请注意,即使每个基准测试的迭代次数有很大的跳跃(每个基准测试的迭代次数增加了五倍或更多),每个基准测试函数的平均性能时间仍然保持相当一致。这些信息为您的代码的性能提供了宝贵的见解。这是观察代码或负载变化对性能的影响的好方法,如下一节所讨论的。
比较基准测试
基准测试代码的另一个有用方面是比较实现类似功能的不同算法的性能。使用性能基准测试来运行算法将表明哪种实现可能更具计算和内存效率。
例如,如果两个向量的大小和方向相同(或它们之间的角度值为零),则它们被认为是相等的。我们可以使用以下源代码片段来实现这个定义:
const zero = 1.0e-7
...
func (v SimpleVector) Eq(other Vector) bool {
ang := v.Angle(other)
if math.IsNaN(ang) {
return v.Mag() == other.Mag()
}
return v.Mag() == other.Mag() && ang <= zero
}
golang.fyi/ch12/vector/vec.go
当对前述方法进行基准测试时,将得到以下结果。它的 300 万次迭代平均需要半毫秒才能运行:
$> go test -run=Bench -bench=Equal1
PASS
BenchmarkVectorEqual1-2 3000000 454 ns/op
ok github.com/vladimirvivien/learning-go/ch12/vector 1.849s
基准结果并不差,特别是与我们之前看到的其他基准方法相比。然而,假设我们想要改进Eq
方法的性能(也许因为它是程序的关键部分)。我们可以使用-benchmem
标志来获取有关基准测试的额外信息:
$> go test -run=bench -bench=Equal1 -benchmem
PASS
BenchmarkVectorEqual1-2 3000000 474 ns/op 48 B/op 2 allocs/op
-benchmem
标志会导致测试工具显示两个额外的列,提供内存分配指标,如前面的输出所示。我们看到Eq
方法总共分配了 48 字节,每个操作调用两次分配。
这并没有告诉我们太多,直到我们有其他东西可以进行比较。幸运的是,我们还有另一种相等算法可以尝试。它基于这样一个事实,即如果两个向量具有相同数量的元素,并且每个元素相等,那么它们也是相等的。这个定义可以通过遍历向量并比较其元素来实现,就像在下面的代码中所做的那样:
func (v SimpleVector) Eq2(other Vector) bool {
v.assertLenMatch(other)
otherVec := other.(SimpleVector)
for i, val := range v {
if val != otherVec[i] {
return false
}
}
return true
}
golang.fyi/ch12/vector/vec.go
现在让我们对Eq
和Eq2
相等方法进行基准测试,看看哪个性能更好,就像下面所做的那样:
$> go test -run=bench -bench=Equal -benchmem
PASS
BenchmarkVectorEqual1-2 3000000 447 ns/op 48 B/op 2 allocs/op
BenchmarkVectorEqual2-2 5000000 265 ns/op 32 B/op 1 allocs/op
根据基准报告,方法Eq2
是这两种相等方法中性能更好的。它的运行时间大约是原始方法的一半,分配的内存要少得多。由于两个基准测试使用类似的输入数据,我们可以自信地说第二种方法比第一种更好。
注意
根据 Go 版本和机器大小和架构,这些基准数字会有所不同。然而,结果总是会显示 Eq2 方法的性能更好。
这次讨论只是对比基准的皮毛。例如,先前的基准测试使用相同大小的输入。有时观察性能随输入大小变化的变化是有用的。我们可以比较相等方法的性能配置文件,比如当输入大小从 3、10、20 或 30 个元素变化时。如果算法对大小敏感,扩展基准测试使用这些属性将揭示任何瓶颈。
总结
本章介绍了在 Go 中编写测试的实践的广泛介绍。它讨论了几个关键主题,包括使用go test
工具来编译和执行自动化测试。读者学会了如何编写测试函数来确保他们的代码得到适当的测试和覆盖。本章还讨论了测试 HTTP 客户端和服务器的主题。最后,本章介绍了使用内置的 Go 工具自动化、分析和衡量代码性能的基准测试主题。