The Go Programming Language(10-13)

The Go Programming Language(10-13)
说明
本文为Go语言编程圣经中文版内容,本人在阅读时将其制作为思维导图及博客文章形式,仅供学习,若侵权请及时与我联系。
源码、PDF版、Markdown、xmind版下载链接
https://1tnt1.lanzous.com/b00o36ytc
密码:
1ch0
ch10 包和工具
ch10.0 简介
- 现在随便一个小程序的实现都可能包含超过10000个函数。然而作者一般只需要考虑其中很小的一部分和做很少的设计,因为绝大部分代码都是由他人编写的,它们通过类似包或模块的方式被重用。
- Go语言有超过100个的标准包(译注:可以用
go list std | wc -l命令查看标准包的具体数目),标准库为大多数的程序提供了必要的基础构件。在Go的社区,有很多成熟的包被设计、共享、重用和改进,目前互联网上已经发布了非常多的Go语言开源包,它们可以通过 http://godoc.org 检索。在本章,我们将演示如何使用已有的包和创建新的包。 - Go还自带了工具箱,里面有很多用来简化工作区和包管理的小工具。在本书开始的时候,我们已经见识过如何使用工具箱自带的工具来下载、构建和运行我们的演示程序了。在本章,我们将看看这些工具的基本设计理论和尝试更多的功能,例如打印工作区中包的文档和查询相关的元数据等。在下一章,我们将探讨testing包的单元测试用法。
ch10.1 包简介
任何包系统设计的目的都是为了简化大型程序的设计和维护工作,通过将一组相关的特性放进一个独立的单元以便于理解和更新,在每个单元更新的同时保持和程序中其它单元的相对独立性。这种模块化的特性允许每个包可以被其它的不同项目共享和重用,在项目范围内、甚至全球范围统一的分发和复用。
每个包一般都定义了一个不同的名字空间用于它内部的每个标识符的访问。每个名字空间关联到一个特定的包,让我们给类型、函数等选择简短明了的名字,这样可以在使用它们的时候减少和其它部分名字的冲突。
每个包还通过控制包内名字的可见性和是否导出来实现封装特性。通过限制包成员的可见性并隐藏包API的具体实现,将允许包的维护者在不影响外部包用户的前提下调整包的内部实现。通过限制包内变量的可见性,还可以强制用户通过某些特定函数来访问和更新内部变量,这样可以保证内部变量的一致性和并发时的互斥约束。
当我们修改了一个源文件,我们必须重新编译该源文件对应的包和所有依赖该包的其他包。即使是从头构建,Go语言编译器的编译速度也明显快于其它编译语言。Go语言的闪电般的编译速度主要得益于三个语言特性。
- 第一点,所有导入的包必须在每个文件的开头显式声明,这样的话编译器就没有必要读取和分析整个源文件来判断包的依赖关系。
- 第二点,禁止包的环状依赖,因为没有循环依赖,包的依赖关系形成一个有向无环图,每个包可以被独立编译,而且很可能是被并发编译。
- 第三点,编译后包的目标文件不仅仅记录包本身的导出信息,目标文件同时还记录了包的依赖关系。
- 因此,在编译一个包的时候,编译器只需要读取每个直接导入包的目标文件,而不需要遍历所有依赖的的文件
ch10.2 导入路径
每个包是由一个全局唯一的字符串所标识的导入路径定位。出现在import语句中的导入路径也是字符串。
import ( "fmt" "math/rand" "encoding/json" "golang.org/x/net/html" "github.com/go-sql-driver/mysql" )就像我们在2.6.1节提到过的,Go语言的规范并没有指明包的导入路径字符串的具体含义,导入路径的具体含义是由构建工具来解释的。在本章,我们将深入讨论Go语言工具箱的功能,包括大家经常使用的构建测试等功能。当然,也有第三方扩展的工具箱存在。例如,Google公司内部的Go语言码农,他们就使用内部的多语言构建系统,用不同的规则来处理包名字和定位包,用不同的规则来处理单元测试等等,因为这样可以更紧密适配他们内部环境。
- 译注:Google公司使用的是类似Bazel的构建系统,支持多种编程语言,目前该构件系统还不能完整支持Windows环境
如果你计划分享或发布包,那么导入路径最好是全球唯一的。为了避免冲突,所有非标准库包的导入路径建议以所在组织的互联网域名为前缀;而且这样也有利于包的检索。例如,上面的import语句导入了Go团队维护的HTML解析器和一个流行的第三方维护的MySQL驱动。
ch10.3 包声明
在每个Go语言源文件的开头都必须有包声明语句。包声明语句的主要目的是确定当前包被其它包导入时默认的标识符(也称为包名)。
例如,math/rand包的每个源文件的开头都包含
package rand包声明语句,所以当你导入这个包,你就可以用rand.Int、rand.Float64类似的方式访问包的成员。package main import ( "fmt" "math/rand" ) func main() { fmt.Println(rand.Int()) }通常来说,默认的包名就是包导入路径名的最后一段,因此即使两个包的导入路径不同,它们依然可能有一个相同的包名。例如,math/rand包和crypto/rand包的包名都是rand。稍后我们将看到如何同时导入两个有相同包名的包。
关于默认包名一般采用导入路径名的最后一段的约定也有三种例外情况。第一个例外,包对应一个可执行程序,也就是main包,这时候main包本身的导入路径是无关紧要的。名字为main的包是给go build(§10.7.3)构建命令一个信息,这个包编译完之后必须调用连接器生成一个可执行程序。
第二个例外,包所在的目录中可能有一些文件名是以
_test.go为后缀的Go源文件(译注:前面必须有其它的字符,因为以_或.开头的源文件会被构建工具忽略),并且这些源文件声明的包名也是以_test为后缀名的。这种目录可以包含两种包:一种是普通包,另一种则是测试的外部扩展包。所有以_test为后缀包名的测试外部扩展包都由go test命令独立编译,普通包和测试的外部扩展包是相互独立的。测试的外部扩展包一般用来避免测试代码中的循环导入依赖,具体细节我们将在11.2.4节中介绍。第三个例外,一些依赖版本号的管理工具会在导入路径后追加版本号信息,例如“gopkg.in/yaml.v2”。这种情况下包的名字并不包含版本号后缀,而是yaml。
ch10.4 导入声明
可以在一个Go语言源文件包声明语句之后,其它非导入声明语句之前,包含零到多个导入包声明语句。每个导入声明可以单独指定一个导入路径,也可以通过圆括号同时导入多个导入路径。下面两个导入形式是等价的,但是第二种形式更为常见。
import "fmt" import "os" import ( "fmt" "os" )导入的包之间可以通过添加空行来分组;通常将来自不同组织的包独自分组。包的导入顺序无关紧要,但是在每个分组中一般会根据字符串顺序排列。(gofmt和goimports工具都可以将不同分组导入的包独立排序。)
import ( "fmt" "html/template" "os" "golang.org/x/net/html" "golang.org/x/net/ipv4" )如果我们想同时导入两个有着名字相同的包,例如math/rand包和crypto/rand包,那么导入声明必须至少为一个同名包指定一个新的包名以避免冲突。这叫做导入包的重命名。
import ( "crypto/rand" mrand "math/rand" // alternative name mrand avoids conflict )导入包的重命名只影响当前的源文件。其它的源文件如果导入了相同的包,可以用导入包原本默认的名字或重命名为另一个完全不同的名字。
导入包重命名是一个有用的特性,它不仅仅只是为了解决名字冲突。如果导入的一个包名很笨重,特别是在一些自动生成的代码中,这时候用一个简短名称会更方便。选择用简短名称重命名导入包时候最好统一,以避免包名混乱。选择另一个包名称还可以帮助避免和本地普通变量名产生冲突。例如,如果文件中已经有了一个名为path的变量,那么我们可以将“path”标准包重命名为pathpkg。
每个导入声明语句都明确指定了当前包和被导入包之间的依赖关系。如果遇到包循环导入的情况,Go语言的构建工具将报告错误。
ch10.5 包的匿名导入
如果只是导入一个包而并不使用导入的包将会导致一个编译错误。但是有时候我们只是想利用导入包而产生的副作用:它会计算包级变量的初始化表达式和执行导入包的init初始化函数(§2.6.2)。这时候我们需要抑制“unused import”编译错误,我们可以用下划线
_来重命名导入的包。像往常一样,下划线_为空白标识符,并不能被访问。import _ "image/png" // register PNG decoder这个被称为包的匿名导入。它通常是用来实现一个编译时机制,然后通过在main主程序入口选择性地导入附加的包。首先,让我们看看如何使用该特性,然后再看看它是如何工作的。
标准库的image图像包包含了一个
Decode函数,用于从io.Reader接口读取数据并解码图像,它调用底层注册的图像解码器来完成任务,然后返回image.Image类型的图像。使用image.Decode很容易编写一个图像格式的转换工具,读取一种格式的图像,然后编码为另一种图像格式:gopl.io/ch10/jpeg
// The jpeg command reads a PNG image from the standard input // and writes it as a JPEG image to the standard output. package main import ( "fmt" "image" "image/jpeg" _ "image/png" // register PNG decoder "io" "os" ) func main() { if err := toJPEG(os.Stdin, os.Stdout); err != nil { fmt.Fprintf(os.Stderr, "jpeg: %v\n", err) os.Exit(1) } } func toJPEG(in io.Reader, out io.Writer) error { img, kind, err := image.Decode(in) if err != nil { return err } fmt.Fprintln(os.Stderr, "Input format =", kind) return jpeg.Encode(out, img, &jpeg.Options{Quality: 95}) }如果我们将
gopl.io/ch3/mandelbrot(§3.3)的输出导入到这个程序的标准输入,它将解码输入的PNG格式图像,然后转换为JPEG格式的图像输出(图3.3)。$ go build gopl.io/ch3/mandelbrot $ go build gopl.io/ch10/jpeg $ ./mandelbrot | ./jpeg >mandelbrot.jpg Input format = png要注意image/png包的匿名导入语句。如果没有这一行语句,程序依然可以编译和运行,但是它将不能正确识别和解码PNG格式的图像:
$ go build gopl.io/ch10/jpeg $ ./mandelbrot | ./jpeg >mandelbrot.jpg jpeg: image: unknown format下面的代码演示了它的工作机制。标准库还提供了GIF、PNG和JPEG等格式图像的解码器,用户也可以提供自己的解码器,但是为了保持程序体积较小,很多解码器并没有被全部包含,除非是明确需要支持的格式。image.Decode函数在解码时会依次查询支持的格式列表。
每个格式驱动列表的每个入口指定了四件事情:格式的名称;一个用于描述这种图像数据开头部分模式的字符串,用于解码器检测识别;一个Decode函数用于完成解码图像工作;一个DecodeConfig函数用于解码图像的大小和颜色空间的信息。每个驱动入口是通过调用image.RegisterFormat函数注册,一般是在每个格式包的init初始化函数中调用,例如image/png包是这样注册的:
package png // image/png func Decode(r io.Reader) (image.Image, error) func DecodeConfig(r io.Reader) (image.Config, error) func init() { const pngHeader = "\x89PNG\r\n\x1a\n" image.RegisterFormat("png", pngHeader, Decode, DecodeConfig) }最终的效果是,主程序只需要匿名导入特定图像驱动包就可以用image.Decode解码对应格式的图像了。
数据库包database/sql也是采用了类似的技术,让用户可以根据自己需要选择导入必要的数据库驱动。例如:
import ( "database/sql" _ "github.com/lib/pq" // enable support for Postgres _ "github.com/go-sql-driver/mysql" // enable support for MySQL ) db, err = sql.Open("postgres", dbname) // OK db, err = sql.Open("mysql", dbname) // OK db, err = sql.Open("sqlite3", dbname) // returns error: unknown driver "sqlite3"
ch10.6 包和命名
在本节中,我们将提供一些关于Go语言独特的包和成员命名的约定。
当创建一个包,一般要用短小的包名,但也不能太短导致难以理解。标准库中最常用的包有bufio、bytes、flag、fmt、http、io、json、os、sort、sync和time等包。
尽可能让命名有描述性且无歧义。例如,类似imageutil或ioutilis的工具包命名已经足够简洁了,就无须再命名为util了。要尽量避免包名使用可能被经常用于局部变量的名字,这样可能导致用户重命名导入包,例如前面看到的path包。
包名一般采用单数的形式。标准库的bytes、errors和strings使用了复数形式,这是为了避免和预定义的类型冲突,同样还有go/types是为了避免和type关键字冲突。
要避免包名有其它的含义。例如,2.5节中我们的温度转换包最初使用了temp包名,虽然并没有持续多久。但这是一个糟糕的尝试,因为temp几乎是临时变量的同义词。然后我们有一段时间使用了temperature作为包名,显然名字并没有表达包的真实用途。最后我们改成了和strconv标准包类似的tempconv包名,这个名字比之前的就好多了。
现在让我们看看如何命名包的成员。由于是通过包的导入名字引入包里面的成员,例如fmt.Println,同时包含了包名和成员名信息。因此,我们一般并不需要关注Println的具体内容,因为fmt包名已经包含了这个信息。当设计一个包的时候,需要考虑包名和成员名两个部分如何很好地配合。下面有一些例子:
bytes.Equal flag.Int http.Get json.Marshal我们可以看到一些常用的命名模式。strings包提供了和字符串相关的诸多操作:
package strings func Index(needle, haystack string) int type Replacer struct{ /* ... */ } func NewReplacer(oldnew ...string) *Replacer type Reader struct{ /* ... */ } func NewReader(s string) *Reader包名strings并没有出现在任何成员名字中。因为用户会这样引用这些成员strings.Index、strings.Replacer等。
其它一些包,可能只描述了单一的数据类型,例如html/template和math/rand等,只暴露一个主要的数据结构和与它相关的方法,还有一个以New命名的函数用于创建实例。
package rand // "math/rand" type Rand struct{ /* ... */ } func New(source Source) *Rand这可能导致一些名字重复,例如template.Template或rand.Rand,这就是为什么这些种类的包名往往特别短的原因之一。
在另一个极端,还有像net/http包那样含有非常多的名字和种类不多的数据类型,因为它们都是要执行一个复杂的复合任务。尽管有将近二十种类型和更多的函数,但是包中最重要的成员名字却是简单明了的:Get、Post、Handle、Error、Client、Server等。
ch10.7 工具
ch10.7.0 简介
本章剩下的部分将讨论Go语言工具箱的具体功能,包括如何下载、格式化、构建、测试和安装Go语言编写的程序。
Go语言的工具箱集合了一系列功能的命令集。它可以看作是一个包管理器(类似于Linux中的apt和rpm工具),用于包的查询、计算包的依赖关系、从远程版本控制系统下载它们等任务。它也是一个构建系统,计算文件的依赖关系,然后调用编译器、汇编器和链接器构建程序,虽然它故意被设计成没有标准的make命令那么复杂。它也是一个单元测试和基准测试的驱动程序,我们将在第11章讨论测试话题。
Go语言工具箱的命令有着类似“瑞士军刀”的风格,带着一打的子命令,有一些我们经常用到,例如get、run、build和fmt等。你可以运行go或go help命令查看内置的帮助文档,为了查询方便,我们列出了最常用的命令:
$ go ... build compile packages and dependencies clean remove object files doc show documentation for package or symbol env print Go environment information fmt run gofmt on package sources get download and install packages and dependencies install compile and install packages and dependencies list list packages run compile and run Go program test test packages version print Go version vet run go tool vet on packages Use "go help [command]" for more information about a command. ...为了达到零配置的设计目标,Go语言的工具箱很多地方都依赖各种约定。例如,根据给定的源文件的名称,Go语言的工具可以找到源文件对应的包,因为每个目录只包含了单一的包,并且包的导入路径和工作区的目录结构是对应的。给定一个包的导入路径,Go语言的工具可以找到与之对应的存储着实体文件的目录。它还可以根据导入路径找到存储代码的仓库的远程服务器URL。
ch10.7.1 工作区结构
对于大多数的Go语言用户,只需要配置一个名叫GOPATH的环境变量,用来指定当前工作目录即可。当需要切换到不同工作区的时候,只要更新GOPATH就可以了。例如,我们在编写本书时将GOPATH设置为
$HOME/gobook:$ export GOPATH=$HOME/gobook $ go get gopl.io/...当你用前面介绍的命令下载本书全部的例子源码之后,你的当前工作区的目录结构应该是这样的:
GOPATH/ src/ gopl.io/ .git/ ch1/ helloworld/ main.go dup/ main.go ... golang.org/x/net/ .git/ html/ parse.go node.go ... bin/ helloworld dup pkg/ darwin_amd64/ ...GOPATH对应的工作区目录有三个子目录。其中src子目录用于存储源代码。每个包被保存在与$GOPATH/src的相对路径为包导入路径的子目录中,例如gopl.io/ch1/helloworld相对应的路径目录。我们看到,一个GOPATH工作区的src目录中可能有多个独立的版本控制系统,例如gopl.io和golang.org分别对应不同的Git仓库。其中pkg子目录用于保存编译后的包的目标文件,bin子目录用于保存编译后的可执行程序,例如helloworld可执行程序。
第二个环境变量GOROOT用来指定Go的安装目录,还有它自带的标准库包的位置。GOROOT的目录结构和GOPATH类似,因此存放fmt包的源代码对应目录应该为$GOROOT/src/fmt。用户一般不需要设置GOROOT,默认情况下Go语言安装工具会将其设置为安装的目录路径。
其中
go env命令用于查看Go语言工具涉及的所有环境变量的值,包括未设置环境变量的默认值。GOOS环境变量用于指定目标操作系统(例如android、linux、darwin或windows),GOARCH环境变量用于指定处理器的类型,例如amd64、386或arm等。虽然GOPATH环境变量是唯一必须要设置的,但是其它环境变量也会偶尔用到。$ go env GOPATH="/home/gopher/gobook" GOROOT="/usr/local/go" GOARCH="amd64" GOOS="darwin" ...
ch10.7.2 下载包
使用Go语言工具箱的go命令,不仅可以根据包导入路径找到本地工作区的包,甚至可以从互联网上找到和更新包。
使用命令
go get可以下载一个单一的包或者用...下载整个子目录里面的每个包。Go语言工具箱的go命令同时计算并下载所依赖的每个包,这也是前一个例子中golang.org/x/net/html自动出现在本地工作区目录的原因。一旦
go get命令下载了包,然后就是安装包或包对应的可执行的程序。我们将在下一节再关注它的细节,现在只是展示整个下载过程是如何的简单。第一个命令是获取golint工具,它用于检测Go源代码的编程风格是否有问题。第二个命令是用golint命令对2.6.2节的gopl.io/ch2/popcount包代码进行编码风格检查。它友好地报告了忘记了包的文档:$ go get github.com/golang/lint/golint $ $GOPATH/bin/golint gopl.io/ch2/popcount src/gopl.io/ch2/popcount/main.go:1:1: package comment should be of the form "Package popcount ..."go get命令支持当前流行的托管网站GitHub、Bitbucket和Launchpad,可以直接向它们的版本控制系统请求代码。对于其它的网站,你可能需要指定版本控制系统的具体路径和协议,例如 Git或Mercurial。运行go help importpath获取相关的信息。go get命令获取的代码是真实的本地存储仓库,而不仅仅只是复制源文件,因此你依然可以使用版本管理工具比较本地代码的变更或者切换到其它的版本。例如golang.org/x/net包目录对应一个Git仓库:$ cd $GOPATH/src/golang.org/x/net $ git remote -v origin https://go.googlesource.com/net (fetch) origin https://go.googlesource.com/net (push)需要注意的是导入路径含有的网站域名和本地Git仓库对应远程服务地址并不相同,真实的Git地址是go.googlesource.com。这其实是Go语言工具的一个特性,可以让包用一个自定义的导入路径,但是真实的代码却是由更通用的服务提供,例如googlesource.com或github.com。因为页面 https://golang.org/x/net/html 包含了如下的元数据,它告诉Go语言的工具当前包真实的Git仓库托管地址:
$ go build gopl.io/ch1/fetch $ ./fetch https://golang.org/x/net/html | grep go-import <meta name="go-import" content="golang.org/x/net git https://go.googlesource.com/net">如果指定
-u命令行标志参数,go get命令将确保所有的包和依赖的包的版本都是最新的,然后重新编译和安装它们。如果不包含该标志参数的话,而且如果包已经在本地存在,那么代码将不会被自动更新。go get -u命令只是简单地保证每个包是最新版本,如果是第一次下载包则是比较方便的;但是对于发布程序则可能是不合适的,因为本地程序可能需要对依赖的包做精确的版本依赖管理。通常的解决方案是使用vendor的目录用于存储依赖包的固定版本的源代码,对本地依赖的包的版本更新也是谨慎和持续可控的。在Go1.5之前,一般需要修改包的导入路径,所以复制后golang.org/x/net/html导入路径可能会变为gopl.io/vendor/golang.org/x/net/html。最新的Go语言命令已经支持vendor特性,但限于篇幅这里并不讨论vendor的具体细节。不过可以通过go help gopath命令查看Vendor的帮助文档。
ch10.7.3 构建包
go build命令编译命令行参数指定的每个包。如果包是一个库,则忽略输出结果;这可以用于检测包是可以正确编译的。如果包的名字是main,go build将调用链接器在当前目录创建一个可执行程序;以导入路径的最后一段作为可执行程序的名字。由于每个目录只包含一个包,因此每个对应可执行程序或者叫Unix术语中的命令的包,会要求放到一个独立的目录中。这些目录有时候会放在名叫cmd目录的子目录下面,例如用于提供Go文档服务的golang.org/x/tools/cmd/godoc命令就是放在cmd子目录(§10.7.4)。
每个包可以由它们的导入路径指定,就像前面看到的那样,或者用一个相对目录的路径名指定,相对路径必须以
.或..开头。如果没有指定参数,那么默认指定为当前目录对应的包。下面的命令用于构建同一个包,虽然它们的写法各不相同:$ cd $GOPATH/src/gopl.io/ch1/helloworld $ go build或者:
$ cd anywhere $ go build gopl.io/ch1/helloworld或者:
$ cd $GOPATH $ go build ./src/gopl.io/ch1/helloworld但不能这样:
$ cd $GOPATH $ go build src/gopl.io/ch1/helloworld Error: cannot find package "src/gopl.io/ch1/helloworld".
也可以指定包的源文件列表,这一般只用于构建一些小程序或做一些临时性的实验。如果是main包,将会以第一个Go源文件的基础文件名作为最终的可执行程序的名字。
$ cat quoteargs.go package main import ( "fmt" "os" ) func main() { fmt.Printf("%q\n", os.Args[1:]) } $ go build quoteargs.go $ ./quoteargs one "two three" four\ five ["one" "two three" "four five"]特别是对于这类一次性运行的程序,我们希望尽快的构建并运行它。
go run命令实际上是结合了构建和运行的两个步骤:$ go run quoteargs.go one "two three" four\ five ["one" "two three" "four five"]- 译注:其实也可以偷懒,直接go run
*.go - 第一行的参数列表中,第一个不是以
.go结尾的将作为可执行程序的参数运行。
- 译注:其实也可以偷懒,直接go run
默认情况下,
go build命令构建指定的包和它依赖的包,然后丢弃除了最后的可执行文件之外所有的中间编译结果。依赖分析和编译过程虽然都是很快的,但是随着项目增加到几十个包和成千上万行代码,依赖关系分析和编译时间的消耗将变的可观,有时候可能需要几秒种,即使这些依赖项没有改变。go install命令和go build命令很相似,但是它会保存每个包的编译成果,而不是将它们都丢弃。被编译的包会被保存到$GOPATH/pkg目录下,目录路径和 src目录路径对应,可执行程序被保存到$GOPATH/bin目录。(很多用户会将$GOPATH/bin添加到可执行程序的搜索列表中。)还有,go install命令和go build命令都不会重新编译没有发生变化的包,这可以使后续构建更快捷。为了方便编译依赖的包,go build -i命令将安装每个目标所依赖的包。因为编译对应不同的操作系统平台和CPU架构,
go install命令会将编译结果安装到GOOS和GOARCH对应的目录。例如,在Mac系统,golang.org/x/net/html包将被安装到$GOPATH/pkg/darwin_amd64目录下的golang.org/x/net/html.a文件。针对不同操作系统或CPU的交叉构建也是很简单的。只需要设置好目标对应的GOOS和GOARCH,然后运行构建命令即可。下面交叉编译的程序将输出它在编译时的操作系统和CPU类型:
gopl.io/ch10/cross
func main() { fmt.Println(runtime.GOOS, runtime.GOARCH) }下面以64位和32位环境分别编译和执行:
$ go build gopl.io/ch10/cross $ ./cross darwin amd64 $ GOARCH=386 go build gopl.io/ch10/cross $ ./cross darwin 386
有些包可能需要针对不同平台和处理器类型使用不同版本的代码文件,以便于处理底层的可移植性问题或为一些特定代码提供优化。如果一个文件名包含了一个操作系统或处理器类型名字,例如net_linux.go或asm_amd64.s,Go语言的构建工具将只在对应的平台编译这些文件。还有一个特别的构建注释参数可以提供更多的构建过程控制。例如,文件中可能包含下面的注释:
// +build linux darwin在包声明和包注释的前面,该构建注释参数告诉
go build只在编译程序对应的目标操作系统是Linux或Mac OS X时才编译这个文件。下面的构建注释则表示不编译这个文件:// +build ignore更多细节,可以参考go/build包的构建约束部分的文档。
$ go doc go/build
ch10.7.4 包文档
Go语言的编码风格鼓励为每个包提供良好的文档。包中每个导出的成员和包声明前都应该包含目的和用法说明的注释。
Go语言中的文档注释一般是完整的句子,第一行通常是摘要说明,以被注释者的名字开头。注释中函数的参数或其它的标识符并不需要额外的引号或其它标记注明。例如,下面是fmt.Fprintf的文档注释。
// Fprintf formats according to a format specifier and writes to w. // It returns the number of bytes written and any write error encountered. func Fprintf(w io.Writer, format string, a ...interface{}) (int, error)Fprintf函数格式化的细节在fmt包文档中描述。如果注释后紧跟着包声明语句,那注释对应整个包的文档。包文档对应的注释只能有一个(译注:其实可以有多个,它们会组合成一个包文档注释),包注释可以出现在任何一个源文件中。如果包的注释内容比较长,一般会放到一个独立的源文件中;fmt包注释就有300行之多。这个专门用于保存包文档的源文件通常叫doc.go。
好的文档并不需要面面俱到,文档本身应该是简洁但不可忽略的。事实上,Go语言的风格更喜欢简洁的文档,并且文档也是需要像代码一样维护的。对于一组声明语句,可以用一个精炼的句子描述,如果是显而易见的功能则并不需要注释。
在本书中,只要空间允许,我们之前很多包声明都包含了注释文档,但你可以从标准库中发现很多更好的例子。有两个工具可以帮到你。
首先是
go doc命令,该命令打印其后所指定的实体的声明与文档注释,该实体可能是一个包:$ go doc time package time // import "time" Package time provides functionality for measuring and displaying time. const Nanosecond Duration = 1 ... func After(d Duration) <-chan Time func Sleep(d Duration) func Since(t Time) Duration func Now() Time type Duration int64 type Time struct { ... } ...many more...或者是某个具体的包成员:
$ go doc time.Since func Since(t Time) Duration Since returns the time elapsed since t. It is shorthand for time.Now().Sub(t).或者是一个方法:
$ go doc time.Duration.Seconds func (d Duration) Seconds() float64 Seconds returns the duration as a floating-point number of seconds.该命令并不需要输入完整的包导入路径或正确的大小写。下面的命令将打印encoding/json包的
(*json.Decoder).Decode方法的文档:$ go doc json.decode func (dec *Decoder) Decode(v interface{}) error Decode reads the next JSON-encoded value from its input and stores it in the value pointed to by v.
第二个工具,名字也叫godoc,它提供可以相互交叉引用的HTML页面,但是包含和
go doc命令相同以及更多的信息。图10.1演示了time包的文档,11.6节将看到godoc演示可以交互的示例程序。godoc的在线服务 https://godoc.org ,包含了成千上万的开源包的检索工具。你也可以在自己的工作区目录运行godoc服务。运行下面的命令,然后在浏览器查看 http://localhost:8000/pkg 页面:
$ godoc -http :8000其中
-analysis=type和-analysis=pointer命令行标志参数用于打开文档和代码中关于静态分析的结果。
ch10.7.5 内部包
在Go语言程序中,包是最重要的封装机制。没有导出的标识符只在同一个包内部可以访问,而导出的标识符则是面向全宇宙都是可见的。
有时候,一个中间的状态可能也是有用的,标识符对于一小部分信任的包是可见的,但并不是对所有调用者都可见。例如,当我们计划将一个大的包拆分为很多小的更容易维护的子包,但是我们并不想将内部的子包结构也完全暴露出去。同时,我们可能还希望在内部子包之间共享一些通用的处理包,或者我们只是想实验一个新包的还并不稳定的接口,暂时只暴露给一些受限制的用户使用。
为了满足这些需求,Go语言的构建工具对包含internal名字的路径段的包导入路径做了特殊处理。这种包叫internal包,一个internal包只能被和internal目录有同一个父目录的包所导入。例如,net/http/internal/chunked内部包只能被net/http/httputil或net/http包导入,但是不能被net/url包导入。不过net/url包却可以导入net/http/httputil包。
net/http net/http/internal/chunked net/http/httputil net/url
ch10.7.6 查询包
go list命令可以查询可用包的信息。其最简单的形式,可以测试包是否在工作区并打印它的导入路径:$ go list github.com/go-sql-driver/mysql github.com/go-sql-driver/mysqlgo list命令的参数还可以用"..."表示匹配任意的包的导入路径。我们可以用它来列出工作区中的所有包:$ go list ... archive/tar archive/zip bufio bytes cmd/addr2line cmd/api ...many more...或者是特定子目录下的所有包:
$ go list gopl.io/ch3/... gopl.io/ch3/basename1 gopl.io/ch3/basename2 gopl.io/ch3/comma gopl.io/ch3/mandelbrot gopl.io/ch3/netflag gopl.io/ch3/printints gopl.io/ch3/surface或者是和某个主题相关的所有包:
$ go list ...xml... encoding/xml gopl.io/ch7/xmlselectgo list命令还可以获取每个包完整的元信息,而不仅仅只是导入路径,这些元信息可以以不同格式提供给用户。其中-json命令行参数表示用JSON格式打印每个包的元信息。$ go list -json hash { "Dir": "/home/gopher/go/src/hash", "ImportPath": "hash", "Name": "hash", "Doc": "Package hash provides interfaces for hash functions.", "Target": "/home/gopher/go/pkg/darwin_amd64/hash.a", "Goroot": true, "Standard": true, "Root": "/home/gopher/go", "GoFiles": [ "hash.go" ], "Imports": [ "io" ], "Deps": [ "errors", "io", "runtime", "sync", "sync/atomic", "unsafe" ] }命令行参数
-f则允许用户使用text/template包(§4.6)的模板语言定义输出文本的格式。下面的命令将打印strconv包的依赖的包,然后用join模板函数将结果链接为一行,连接时每个结果之间用一个空格分隔:{% raw %}
$ go list -f '{{join .Deps " "}}' strconv errors math runtime unicode/utf8 unsafe{% endraw %}
上面的命令在Windows的命令行运行会遇到
template: main:1: unclosed action的错误。产生这个错误的原因是因为命令行对命令中的" "参数进行了转义处理。可以按照下面的方法解决转义字符串的问题:{% raw %}
$ go list -f "{{join .Deps \" \"}}" strconv{% endraw %}
下面的命令打印compress子目录下所有包的导入包列表:
{% raw %}
$ go list -f '{{.ImportPath}} -> {{join .Imports " "}}' compress/... compress/bzip2 -> bufio io sort compress/flate -> bufio fmt io math sort strconv compress/gzip -> bufio compress/flate errors fmt hash hash/crc32 io time compress/lzw -> bufio errors fmt io compress/zlib -> bufio compress/flate errors fmt hash hash/adler32 io{% endraw %}
译注:Windows下有同样有问题,要避免转义字符串的干扰:
{% raw %}
$ go list -f "{{.ImportPath}} -> {{join .Imports \" \"}}" compress/...{% endraw %}
go list命令对于一次性的交互式查询或自动化构建或测试脚本都很有帮助。我们将在11.2.4节中再次使用它。每个子命令的更多信息,包括可设置的字段和意义,可以用go help list命令查看。在本章,我们解释了Go语言工具中除了测试命令之外的所有重要的子命令。在下一章,我们将看到如何用
go test命令去运行Go语言程序中的测试代码。
ch11 测试
ch11.0 简介
- Maurice Wilkes,第一个存储程序计算机EDSAC的设计者,1949年他在实验室爬楼梯时有一个顿悟。在《计算机先驱回忆录》(Memoirs of a Computer Pioneer)里,他回忆到:“忽然间有一种醍醐灌顶的感觉,我整个后半生的美好时光都将在寻找程序BUG中度过了”。肯定从那之后的大部分正常的码农都会同情Wilkes过分悲观的想法,虽然也许会有人困惑于他对软件开发的难度的天真看法。
- 现在的程序已经远比Wilkes时代的更大也更复杂,也有许多技术可以让软件的复杂性可得到控制。其中有两种技术在实践中证明是比较有效的。第一种是代码在被正式部署前需要进行代码评审。第二种则是测试,也就是本章的讨论主题。
- 我们说测试的时候一般是指自动化测试,也就是写一些小的程序用来检测被测试代码(产品代码)的行为和预期的一样,这些通常都是精心设计的执行某些特定的功能或者是通过随机性的输入待验证边界的处理。
- 软件测试是一个巨大的领域。测试的任务可能已经占据了一些程序员的部分时间和另一些程序员的全部时间。和软件测试技术相关的图书或博客文章有成千上万之多。对于每一种主流的编程语言,都会有一打的用于测试的软件包,同时也有大量的测试相关的理论,而且每种都吸引了大量技术先驱和追随者。这些都足以说服那些想要编写有效测试的程序员重新学习一套全新的技能。
- Go语言的测试技术是相对低级的。它依赖一个go test测试命令和一组按照约定方式编写的测试函数,测试命令可以运行这些测试函数。编写相对轻量级的纯测试代码是有效的,而且它很容易延伸到基准测试和示例文档。
- 在实践中,编写测试代码和编写程序本身并没有多大区别。我们编写的每一个函数也是针对每个具体的任务。我们必须小心处理边界条件,思考合适的数据结构,推断合适的输入应该产生什么样的结果输出。编写测试代码和编写普通的Go代码过程是类似的;它并不需要学习新的符号、规则和工具。
ch11.1 go test
go test命令是一个按照一定的约定和组织来测试代码的程序。在包目录内,所有以
_test.go为后缀名的源文件在执行go build时不会被构建成包的一部分,它们是go test测试的一部分。在
*_test.go文件中,有三种类型的函数:测试函数、基准测试(benchmark)函数、示例函数。- 一个测试函数是以Test为函数名前缀的函数,用于测试程序的一些逻辑行为是否正确;go test命令会调用这些测试函数并报告测试结果是PASS或FAIL。
- 基准测试函数是以Benchmark为函数名前缀的函数,它们用于衡量一些函数的性能;go test命令会多次运行基准测试函数以计算一个平均的执行时间。
- 示例函数是以Example为函数名前缀的函数,提供一个由编译器保证正确性的示例文档。
我们将在11.2节讨论测试函数的所有细节,并在11.4节讨论基准测试函数的细节,然后在11.6节讨论示例函数的细节。
go test命令会遍历所有的
*_test.go文件中符合上述命名规则的函数,生成一个临时的main包用于调用相应的测试函数,接着构建并运行、报告测试结果,最后清理测试中生成的临时文件。
ch11.2 测试函数
ch11.2.0 简介
每个测试函数必须导入testing包。测试函数有如下的签名:
func TestName(t *testing.T) { // ... }测试函数的名字必须以Test开头,可选的后缀名必须以大写字母开头:
func TestSin(t *testing.T) { /* ... */ } func TestCos(t *testing.T) { /* ... */ } func TestLog(t *testing.T) { /* ... */ }- 其中t参数用于报告测试失败和附加的日志信息。让我们定义一个实例包gopl.io/ch11/word1,其中只有一个函数IsPalindrome用于检查一个字符串是否从前向后和从后向前读都是一样的。
下面这个实现对于一个字符串是否是回文字符串前后重复测试了两次;我们稍后会再讨论这个问题。
gopl.io/ch11/word1
// Package word provides utilities for word games. package word // IsPalindrome reports whether s reads the same forward and backward. // (Our first attempt.) func IsPalindrome(s string) bool { for i := range s { if s[i] != s[len(s)-1-i] { return false } } return true }在相同的目录下,word_test.go测试文件中包含了TestPalindrome和TestNonPalindrome两个测试函数。每一个都是测试IsPalindrome是否给出正确的结果,并使用t.Error报告失败信息:
package word import "testing" func TestPalindrome(t *testing.T) { if !IsPalindrome("detartrated") { t.Error(`IsPalindrome("detartrated") = false`) } if !IsPalindrome("kayak") { t.Error(`IsPalindrome("kayak") = false`) } } func TestNonPalindrome(t *testing.T) { if IsPalindrome("palindrome") { t.Error(`IsPalindrome("palindrome") = true`) } }go test命令如果没有参数指定包那么将默认采用当前目录对应的包(和go build命令一样)。我们可以用下面的命令构建和运行测试。$ cd $GOPATH/src/gopl.io/ch11/word1 $ go test ok gopl.io/ch11/word1 0.008s结果还比较满意,我们运行了这个程序,不过没有提前退出是因为还没有遇到BUG报告。不过一个法国名为“Noelle Eve Elleon”的用户会抱怨IsPalindrome函数不能识别“été”。另外一个来自美国中部用户的抱怨则是不能识别“A man, a plan, a canal: Panama.”。执行特殊和小的BUG报告为我们提供了新的更自然的测试用例。
func TestFrenchPalindrome(t *testing.T) { if !IsPalindrome("été") { t.Error(`IsPalindrome("été") = false`) } } func TestCanalPalindrome(t *testing.T) { input := "A man, a plan, a canal: Panama" if !IsPalindrome(input) { t.Errorf(`IsPalindrome(%q) = false`, input) } }为了避免两次输入较长的字符串,我们使用了提供了有类似Printf格式化功能的 Errorf函数来汇报错误结果。
当添加了这两个测试用例之后,
go test返回了测试失败的信息。$ go test --- FAIL: TestFrenchPalindrome (0.00s) word_test.go:28: IsPalindrome("été") = false --- FAIL: TestCanalPalindrome (0.00s) word_test.go:35: IsPalindrome("A man, a plan, a canal: Panama") = false FAIL FAIL gopl.io/ch11/word1 0.014s先编写测试用例并观察到测试用例触发了和用户报告的错误相同的描述是一个好的测试习惯。只有这样,我们才能定位我们要真正解决的问题。
先写测试用例的另外的好处是,运行测试通常会比手工描述报告的处理更快,这让我们可以进行快速地迭代。如果测试集有很多运行缓慢的测试,我们可以通过只选择运行某些特定的测试来加快测试速度。
参数
-v可用于打印每个测试函数的名字和运行时间:$ go test -v === RUN TestPalindrome --- PASS: TestPalindrome (0.00s) === RUN TestNonPalindrome --- PASS: TestNonPalindrome (0.00s) === RUN TestFrenchPalindrome --- FAIL: TestFrenchPalindrome (0.00s) word_test.go:28: IsPalindrome("été") = false === RUN TestCanalPalindrome --- FAIL: TestCanalPalindrome (0.00s) word_test.go:35: IsPalindrome("A man, a plan, a canal: Panama") = false FAIL exit status 1 FAIL gopl.io/ch11/word1 0.017s参数
-run对应一个正则表达式,只有测试函数名被它正确匹配的测试函数才会被go test测试命令运行:$ go test -v -run="French|Canal" === RUN TestFrenchPalindrome --- FAIL: TestFrenchPalindrome (0.00s) word_test.go:28: IsPalindrome("été") = false === RUN TestCanalPalindrome --- FAIL: TestCanalPalindrome (0.00s) word_test.go:35: IsPalindrome("A man, a plan, a canal: Panama") = false FAIL exit status 1 FAIL gopl.io/ch11/word1 0.014s
当然,一旦我们已经修复了失败的测试用例,在我们提交代码更新之前,我们应该以不带参数的
go test命令运行全部的测试用例,以确保修复失败测试的同时没有引入新的问题。我们现在的任务就是修复这些错误。简要分析后发现第一个BUG的原因是我们采用了 byte而不是rune序列,所以像“été”中的é等非ASCII字符不能正确处理。第二个BUG是因为没有忽略空格和字母的大小写导致的。
针对上述两个BUG,我们仔细重写了函数:
gopl.io/ch11/word2
// Package word provides utilities for word games. package word import "unicode" // IsPalindrome reports whether s reads the same forward and backward. // Letter case is ignored, as are non-letters. func IsPalindrome(s string) bool { var letters []rune for _, r := range s { if unicode.IsLetter(r) { letters = append(letters, unicode.ToLower(r)) } } for i := range letters { if letters[i] != letters[len(letters)-1-i] { return false } } return true }同时我们也将之前的所有测试数据合并到了一个测试中的表格中。
func TestIsPalindrome(t *testing.T) { var tests = []struct { input string want bool }{ {"", true}, {"a", true}, {"aa", true}, {"ab", false}, {"kayak", true}, {"detartrated", true}, {"A man, a plan, a canal: Panama", true}, {"Evil I did dwell; lewd did I live.", true}, {"Able was I ere I saw Elba", true}, {"été", true}, {"Et se resservir, ivresse reste.", true}, {"palindrome", false}, // non-palindrome {"desserts", false}, // semi-palindrome } for _, test := range tests { if got := IsPalindrome(test.input); got != test.want { t.Errorf("IsPalindrome(%q) = %v", test.input, got) } } }现在我们的新测试都通过了:
$ go test gopl.io/ch11/word2 ok gopl.io/ch11/word2 0.015s
这种表格驱动的测试在Go语言中很常见。我们可以很容易地向表格添加新的测试数据,并且后面的测试逻辑也没有冗余,这样我们可以有更多的精力去完善错误信息。
失败测试的输出并不包括调用t.Errorf时刻的堆栈调用信息。和其他编程语言或测试框架的assert断言不同,t.Errorf调用也没有引起panic异常或停止测试的执行。即使表格中前面的数据导致了测试的失败,表格后面的测试数据依然会运行测试,因此在一个测试中我们可能了解多个失败的信息。
如果我们真的需要停止测试,或许是因为初始化失败或可能是早先的错误导致了后续错误等原因,我们可以使用t.Fatal或t.Fatalf停止当前测试函数。它们必须在和测试函数同一个goroutine内调用。
测试失败的信息一般的形式是“f(x) = y, want z”,其中f(x)解释了失败的操作和对应的输入,y是实际的运行结果,z是期望的正确的结果。就像前面检查回文字符串的例子,实际的函数用于f(x)部分。显示x是表格驱动型测试中比较重要的部分,因为同一个断言可能对应不同的表格项执行多次。要避免无用和冗余的信息。在测试类似IsPalindrome返回布尔类型的函数时,可以忽略并没有额外信息的z部分。如果x、y或z是y的长度,输出一个相关部分的简明总结即可。测试的作者应该要努力帮助程序员诊断测试失败的原因。
ch11.2.1 随机测试
表格驱动的测试便于构造基于精心挑选的测试数据的测试用例。另一种测试思路是随机测试,也就是通过构造更广泛的随机输入来测试探索函数的行为。
那么对于一个随机的输入,我们如何能知道希望的输出结果呢?这里有两种处理策略。第一个是编写另一个对照函数,使用简单和清晰的算法,虽然效率较低但是行为和要测试的函数是一致的,然后针对相同的随机输入检查两者的输出结果。第二种是生成的随机输入的数据遵循特定的模式,这样我们就可以知道期望的输出的模式。
下面的例子使用的是第二种方法:randomPalindrome函数用于随机生成回文字符串。
import "math/rand" // randomPalindrome returns a palindrome whose length and contents // are derived from the pseudo-random number generator rng. func randomPalindrome(rng *rand.Rand) string { n := rng.Intn(25) // random length up to 24 runes := make([]rune, n) for i := 0; i < (n+1)/2; i++ { r := rune(rng.Intn(0x1000)) // random rune up to '\u0999' runes[i] = r runes[n-1-i] = r } return string(runes) } func TestRandomPalindromes(t *testing.T) { // Initialize a pseudo-random number generator. seed := time.Now().UTC().UnixNano() t.Logf("Random seed: %d", seed) rng := rand.New(rand.NewSource(seed)) for i := 0; i < 1000; i++ { p := randomPalindrome(rng) if !IsPalindrome(p) { t.Errorf("IsPalindrome(%q) = false", p) } } }虽然随机测试会有不确定因素,但是它也是至关重要的,我们可以从失败测试的日志获取足够的信息。在我们的例子中,输入IsPalindrome的p参数将告诉我们真实的数据,但是对于函数将接受更复杂的输入,不需要保存所有的输入,只要日志中简单地记录随机数种子即可(像上面的方式)。有了这些随机数初始化种子,我们可以很容易修改测试代码以重现失败的随机测试。
通过使用当前时间作为随机种子,在整个过程中的每次运行测试命令时都将探索新的随机数据。如果你使用的是定期运行的自动化测试集成系统,随机测试将特别有价值。
ch11.2.2 测试一个命令
对于测试包
go test是一个有用的工具,但是稍加努力我们也可以用它来测试可执行程序。如果一个包的名字是 main,那么在构建时会生成一个可执行程序,不过main包可以作为一个包被测试器代码导入。让我们为2.3.2节的echo程序编写一个测试。我们先将程序拆分为两个函数:echo函数完成真正的工作,main函数用于处理命令行输入参数和echo可能返回的错误。
gopl.io/ch11/echo
// Echo prints its command-line arguments. package main import ( "flag" "fmt" "io" "os" "strings" ) var ( n = flag.Bool("n", false, "omit trailing newline") s = flag.String("s", " ", "separator") ) var out io.Writer = os.Stdout // modified during testing func main() { flag.Parse() if err := echo(!*n, *s, flag.Args()); err != nil { fmt.Fprintf(os.Stderr, "echo: %v\n", err) os.Exit(1) } } func echo(newline bool, sep string, args []string) error { fmt.Fprint(out, strings.Join(args, sep)) if newline { fmt.Fprintln(out) } return nil }在测试中我们可以用各种参数和标志调用echo函数,然后检测它的输出是否正确,我们通过增加参数来减少echo函数对全局变量的依赖。我们还增加了一个全局名为out的变量来替代直接使用os.Stdout,这样测试代码可以根据需要将out修改为不同的对象以便于检查。下面就是echo_test.go文件中的测试代码:
package main import ( "bytes" "fmt" "testing" ) func TestEcho(t *testing.T) { var tests = []struct { newline bool sep string args []string want string }{ {true, "", []string{}, "\n"}, {false, "", []string{}, ""}, {true, "\t", []string{"one", "two", "three"}, "one\ttwo\tthree\n"}, {true, ",", []string{"a", "b", "c"}, "a,b,c\n"}, {false, ":", []string{"1", "2", "3"}, "1:2:3"}, } for _, test := range tests { descr := fmt.Sprintf("echo(%v, %q, %q)", test.newline, test.sep, test.args) out = new(bytes.Buffer) // captured output if err := echo(test.newline, test.sep, test.args); err != nil { t.Errorf("%s failed: %v", descr, err) continue } got := out.(*bytes.Buffer).String() if got != test.want { t.Errorf("%s = %q, want %q", descr, got, test.want) } } }要注意的是测试代码和产品代码在同一个包。虽然是main包,也有对应的main入口函数,但是在测试的时候main包只是TestEcho测试函数导入的一个普通包,里面main函数并没有被导出,而是被忽略的。
通过将测试放到表格中,我们很容易添加新的测试用例。让我通过增加下面的测试用例来看看失败的情况是怎么样的:
{true, ",", []string{"a", "b", "c"}, "a b c\n"}, // NOTE: wrong expectation!go test输出如下:$ go test gopl.io/ch11/echo --- FAIL: TestEcho (0.00s) echo_test.go:31: echo(true, ",", ["a" "b" "c"]) = "a,b,c", want "a b c\n" FAIL FAIL gopl.io/ch11/echo 0.006s错误信息描述了尝试的操作(使用Go类似语法),实际的结果和期望的结果。通过这样的错误信息,你可以在检视代码之前就很容易定位错误的原因。
要注意的是在测试代码中并没有调用log.Fatal或os.Exit,因为调用这类函数会导致程序提前退出;调用这些函数的特权应该放在main函数中。如果真的有意外的事情导致函数发生panic异常,测试驱动应该尝试用recover捕获异常,然后将当前测试当作失败处理。如果是可预期的错误,例如非法的用户输入、找不到文件或配置文件不当等应该通过返回一个非空的error的方式处理。幸运的是(上面的意外只是一个插曲),我们的echo示例是比较简单的也没有需要返回非空error的情况。
ch11.2.3 白盒测试
一种测试分类的方法是基于测试者是否需要了解被测试对象的内部工作原理。黑盒测试只需要测试包公开的文档和API行为,内部实现对测试代码是透明的。相反,白盒测试有访问包内部函数和数据结构的权限,因此可以做到一些普通客户端无法实现的测试。例如,一个白盒测试可以在每个操作之后检测不变量的数据类型。
- 白盒测试只是一个传统的名称,其实称为clear box测试会更准确。
黑盒和白盒这两种测试方法是互补的。黑盒测试一般更健壮,随着软件实现的完善测试代码很少需要更新。它们可以帮助测试者了解真实客户的需求,也可以帮助发现API设计的一些不足之处。相反,白盒测试则可以对内部一些棘手的实现提供更多的测试覆盖。
我们已经看到两种测试的例子。TestIsPalindrome测试仅仅使用导出的IsPalindrome函数,因此这是一个黑盒测试。TestEcho测试则调用了内部的echo函数,并且更新了内部的out包级变量,这两个都是未导出的,因此这是白盒测试。
当我们准备TestEcho测试的时候,我们修改了echo函数使用包级的out变量作为输出对象,因此测试代码可以用另一个实现代替标准输出,这样可以方便对比echo输出的数据。使用类似的技术,我们可以将产品代码的其他部分也替换为一个容易测试的伪对象。使用伪对象的好处是我们可以方便配置,容易预测,更可靠,也更容易观察。同时也可以避免一些不良的副作用,例如更新生产数据库或信用卡消费行为。
下面的代码演示了为用户提供网络存储的web服务中的配额检测逻辑。当用户使用了超过90%的存储配额之后将发送提醒邮件。
gopl.io/ch11/storage1
package storage import ( "fmt" "log" "net/smtp" ) func bytesInUse(username string) int64 { return 0 /* ... */ } // Email sender configuration. // NOTE: never put passwords in source code! const sender = "notifications@example.com" const password = "correcthorsebatterystaple" const hostname = "smtp.example.com" const template = `Warning: you are using %d bytes of storage, %d%% of your quota.` func CheckQuota(username string) { used := bytesInUse(username) const quota = 1000000000 // 1GB percent := 100 * used / quota if percent < 90 { return // OK } msg := fmt.Sprintf(template, used, percent) auth := smtp.PlainAuth("", sender, password, hostname) err := smtp.SendMail(hostname+":587", auth, sender, []string{username}, []byte(msg)) if err != nil { log.Printf("smtp.SendMail(%s) failed: %s", username, err) } }- 译注:一般在实现业务机器监控,包括磁盘、cpu、网络等的时候,需要类似的到达阈值=>触发报警的逻辑,所以是很实用的案例。
我们想测试这段代码,但是我们并不希望发送真实的邮件。因此我们将邮件处理逻辑放到一个私有的notifyUser函数中。
gopl.io/ch11/storage2
var notifyUser = func(username, msg string) { auth := smtp.PlainAuth("", sender, password, hostname) err := smtp.SendMail(hostname+":587", auth, sender, []string{username}, []byte(msg)) if err != nil { log.Printf("smtp.SendEmail(%s) failed: %s", username, err) } } func CheckQuota(username string) { used := bytesInUse(username) const quota = 1000000000 // 1GB percent := 100 * used / quota if percent < 90 { return // OK } msg := fmt.Sprintf(template, used, percent) notifyUser(username, msg) }现在我们可以在测试中用伪邮件发送函数替代真实的邮件发送函数。它只是简单记录要通知的用户和邮件的内容。
package storage import ( "strings" "testing" ) func TestCheckQuotaNotifiesUser(t *testing.T) { var notifiedUser, notifiedMsg string notifyUser = func(user, msg string) { notifiedUser, notifiedMsg = user, msg } // ...simulate a 980MB-used condition... const user = "joe@example.org" CheckQuota(user) if notifiedUser == "" && notifiedMsg == "" { t.Fatalf("notifyUser not called") } if notifiedUser != user { t.Errorf("wrong user (%s) notified, want %s", notifiedUser, user) } const wantSubstring = "98% of your quota" if !strings.Contains(notifiedMsg, wantSubstring) { t.Errorf("unexpected notification message <<%s>>, "+ "want substring %q", notifiedMsg, wantSubstring) } }这里有一个问题:当测试函数返回后,CheckQuota将不能正常工作,因为notifyUsers依然使用的是测试函数的伪发送邮件函数(当更新全局对象的时候总会有这种风险)。 我们必须修改测试代码恢复notifyUsers原先的状态以便后续其他的测试没有影响,要确保所有的执行路径后都能恢复,包括测试失败或panic异常的情形。在这种情况下,我们建议使用defer语句来延后执行处理恢复的代码。
func TestCheckQuotaNotifiesUser(t *testing.T) { // Save and restore original notifyUser. saved := notifyUser defer func() { notifyUser = saved }() // Install the test's fake notifyUser. var notifiedUser, notifiedMsg string notifyUser = func(user, msg string) { notifiedUser, notifiedMsg = user, msg } // ...rest of test... }这种处理模式可以用来暂时保存和恢复所有的全局变量,包括命令行标志参数、调试选项和优化参数;安装和移除导致生产代码产生一些调试信息的钩子函数;还有有些诱导生产代码进入某些重要状态的改变,比如超时、错误,甚至是一些刻意制造的并发行为等因素。
以这种方式使用全局变量是安全的,因为go test命令并不会同时并发地执行多个测试。
ch11.2.4 外部测试包
考虑下这两个包:net/url包,提供了URL解析的功能;net/http包,提供了web服务和HTTP客户端的功能。如我们所料,上层的net/http包依赖下层的net/url包。然后,net/url包中的一个测试是演示不同URL和HTTP客户端的交互行为。也就是说,一个下层包的测试代码导入了上层的包。
这样的行为在net/url包的测试代码中会导致包的循环依赖,正如图11.1中向上箭头所示,同时正如我们在10.1节所讲的,Go语言规范是禁止包的循环依赖的。
不过我们可以通过外部测试包的方式解决循环依赖的问题,也就是在net/url包所在的目录声明一个独立的url_test测试包。其中包名的
_test后缀告诉go test工具它应该建立一个额外的包来运行测试。我们将这个外部测试包的导入路径视作是net/url_test会更容易理解,但实际上它并不能被其他任何包导入。因为外部测试包是一个独立的包,所以能够导入那些
依赖待测代码本身的其他辅助包;包内的测试代码就无法做到这点。在设计层面,外部测试包是在所有它依赖的包的上层,正如图11.2所示。通过避免循环的导入依赖,外部测试包可以更灵活地编写测试,特别是集成测试(需要测试多个组件之间的交互),可以像普通应用程序那样自由地导入其他包。
我们可以用go list命令查看包对应目录中哪些Go源文件是产品代码,哪些是包内测试,还有哪些是外部测试包。我们以fmt包作为一个例子:GoFiles表示产品代码对应的Go源文件列表;也就是go build命令要编译的部分。
{% raw %}
$ go list -f={{.GoFiles}} fmt [doc.go format.go print.go scan.go]{% endraw %}
TestGoFiles表示的是fmt包内部测试代码,以_test.go为后缀文件名,不过只在测试时被构建:
{% raw %}
$ go list -f={{.TestGoFiles}} fmt [export_test.go]{% endraw %}
包的测试代码通常都在这些文件中,不过fmt包并非如此;稍后我们再解释export_test.go文件的作用。
XTestGoFiles表示的是属于外部测试包的测试代码,也就是fmt_test包,因此它们必须先导入fmt包。同样,这些文件也只是在测试时被构建运行:
{% raw %}
$ go list -f={{.XTestGoFiles}} fmt [fmt_test.go scan_test.go stringer_test.go]{% endraw %}
有时候外部测试包也需要访问被测试包内部的代码,例如在一个为了避免循环导入而被独立到外部测试包的白盒测试。在这种情况下,我们可以通过一些技巧解决:我们在包内的一个_test.go文件中导出一个内部的实现给外部测试包。因为这些代码只有在测试时才需要,因此一般会放在export_test.go文件中。
例如,fmt包的fmt.Scanf函数需要unicode.IsSpace函数提供的功能。但是为了避免太多的依赖,fmt包并没有导入包含巨大表格数据的unicode包;相反fmt包有一个叫isSpace内部的简易实现。
为了确保fmt.isSpace和unicode.IsSpace函数的行为保持一致,fmt包谨慎地包含了一个测试。一个在外部测试包内的白盒测试,是无法直接访问到isSpace内部函数的,因此fmt通过一个后门导出了isSpace函数。export_test.go文件就是专门用于外部测试包的后门。
package fmt var IsSpace = isSpace这个测试文件并没有定义测试代码;它只是通过fmt.IsSpace简单导出了内部的isSpace函数,提供给外部测试包使用。这个技巧可以广泛用于位于外部测试包的白盒测试。
ch11.2.5 编写有效的测试
许多Go语言新人会惊异于Go语言极简的测试框架。很多其它语言的测试框架都提供了识别测试函数的机制(通常使用反射或元数据),通过设置一些“setup”和“teardown”的钩子函数来执行测试用例运行的初始化和之后的清理操作,同时测试工具箱还提供了很多类似assert断言、值比较函数、格式化输出错误信息和停止一个失败的测试等辅助函数(通常使用异常机制)。虽然这些机制可以使得测试非常简洁,但是测试输出的日志却会像火星文一般难以理解。此外,虽然测试最终也会输出PASS或FAIL的报告,但是它们提供的信息格式却非常不利于代码维护者快速定位问题,因为失败信息的具体含义非常隐晦,比如“assert: 0 == 1”或成页的海量跟踪日志。
Go语言的测试风格则形成鲜明对比。它期望测试者自己完成大部分的工作,定义函数避免重复,就像普通编程那样。编写测试并不是一个机械的填空过程;一个测试也有自己的接口,尽管它的维护者也是测试仅有的一个用户。一个好的测试不应该引发其他无关的错误信息,它只要清晰简洁地描述问题的症状即可,有时候可能还需要一些上下文信息。在理想情况下,维护者可以在不看代码的情况下就能根据错误信息定位错误产生的原因。一个好的测试不应该在遇到一点小错误时就立刻退出测试,它应该尝试报告更多的相关的错误信息,因为我们可能从多个失败测试的模式中发现错误产生的规律。
下面的断言函数比较两个值,然后生成一个通用的错误信息,并停止程序。它很好用也确实有效,但是当测试失败的时候,打印的错误信息却几乎是没有价值的。它并没有为快速解决问题提供一个很好的入口。
import ( "fmt" "strings" "testing" ) // A poor assertion function. func assertEqual(x, y int) { if x != y { panic(fmt.Sprintf("%d != %d", x, y)) } } func TestSplit(t *testing.T) { words := strings.Split("a:b:c", ":") assertEqual(len(words), 3) // ... }从这个意义上说,断言函数犯了过早抽象的错误:仅仅测试两个整数是否相同,而没能根据上下文提供更有意义的错误信息。我们可以根据具体的错误打印一个更有价值的错误信息,就像下面例子那样。只有在测试中出现重复模式时才采用抽象。
func TestSplit(t *testing.T) { s, sep := "a:b:c", ":" words := strings.Split(s, sep) if got, want := len(words), 3; got != want { t.Errorf("Split(%q, %q) returned %d words, want %d", s, sep, got, want) } // ... }现在的测试不仅报告了调用的具体函数、它的输入和结果的意义;并且打印的真实返回的值和期望返回的值;并且即使断言失败依然会继续尝试运行更多的测试。一旦我们写了这样结构的测试,下一步自然不是用更多的if语句来扩展测试用例,我们可以用像IsPalindrome的表驱动测试那样来准备更多的s和sep测试用例。
前面的例子并不需要额外的辅助函数,如果有可以使测试代码更简单的方法我们也乐意接受。(我们将在13.3节看到一个类似reflect.DeepEqual辅助函数。)一个好的测试的关键是首先实现你期望的具体行为,然后才是考虑简化测试代码、避免重复。如果直接从抽象、通用的测试库着手,很难取得良好结果。
ch11.2.6 避免脆弱的测试
- 如果一个应用程序对于新出现的但有效的输入经常失败说明程序容易出bug(不够稳健);同样,如果一个测试仅仅对程序做了微小变化就失败则称为脆弱。就像一个不够稳健的程序会挫败它的用户一样,一个脆弱的测试同样会激怒它的维护者。最脆弱的测试代码会在程序没有任何变化的时候产生不同的结果,时好时坏,处理它们会耗费大量的时间但是并不会得到任何好处。
- 当一个测试函数会产生一个复杂的输出如一个很长的字符串、一个精心设计的数据结构或一个文件时,人们很容易想预先写下一系列固定的用于对比的标杆数据。但是随着项目的发展,有些输出可能会发生变化,尽管很可能是一个改进的实现导致的。而且不仅仅是输出部分,函数复杂的输入部分可能也跟着变化了,因此测试使用的输入也就不再有效了。
- 避免脆弱测试代码的方法是只检测你真正关心的属性。保持测试代码的简洁和内部结构的稳定。特别是对断言部分要有所选择。不要对字符串进行全字匹配,而是针对那些在项目的发展中是比较稳定不变的子串。很多时候值得花力气来编写一个从复杂输出中提取用于断言的必要信息的函数,虽然这可能会带来很多前期的工作,但是它可以帮助迅速及时修复因为项目演化而导致的不合逻辑的失败测试。
ch11.3 测试覆盖率
就其性质而言,测试不可能是完整的。计算机科学家Edsger Dijkstra曾说过:“测试能证明缺陷存在,而无法证明没有缺陷。”再多的测试也不能证明一个程序没有BUG。在最好的情况下,测试可以增强我们的信心:代码在很多重要场景下是可以正常工作的。
对待测程序执行的测试的程度称为测试的覆盖率。测试覆盖率并不能量化——即使最简单的程序的动态也是难以精确测量的——但是有启发式方法来帮助我们编写有效的测试代码。
这些启发式方法中,语句的覆盖率是最简单和最广泛使用的。语句的覆盖率是指在测试中至少被运行一次的代码占总代码数的比例。在本节中,我们使用
go test命令中集成的测试覆盖率工具,来度量下面代码的测试覆盖率,帮助我们识别测试和我们期望间的差距。下面的代码是一个表格驱动的测试,用于测试第七章的表达式求值程序:
gopl.io/ch7/eval
func TestCoverage(t *testing.T) { var tests = []struct { input string env Env want string // expected error from Parse/Check or result from Eval }{ {"x % 2", nil, "unexpected '%'"}, {"!true", nil, "unexpected '!'"}, {"log(10)", nil, `unknown function "log"`}, {"sqrt(1, 2)", nil, "call to sqrt has 2 args, want 1"}, {"sqrt(A / pi)", Env{"A": 87616, "pi": math.Pi}, "167"}, {"pow(x, 3) + pow(y, 3)", Env{"x": 9, "y": 10}, "1729"}, {"5 / 9 * (F - 32)", Env{"F": -40}, "-40"}, } for _, test := range tests { expr, err := Parse(test.input) if err == nil { err = expr.Check(map[Var]bool{}) } if err != nil { if err.Error() != test.want { t.Errorf("%s: got %q, want %q", test.input, err, test.want) } continue } got := fmt.Sprintf("%.6g", expr.Eval(test.env)) if got != test.want { t.Errorf("%s: %v => %s, want %s", test.input, test.env, got, test.want) } } }首先,我们要确保所有的测试都正常通过:
$ go test -v -run=Coverage gopl.io/ch7/eval === RUN TestCoverage --- PASS: TestCoverage (0.00s) PASS ok gopl.io/ch7/eval 0.011s下面这个命令可以显示测试覆盖率工具的使用用法:
$ go tool cover Usage of 'go tool cover': Given a coverage profile produced by 'go test': go test -coverprofile=c.out Open a web browser displaying annotated source code: go tool cover -html=c.out ...
现在我们可以用
-coverprofile标志参数重新运行测试:$ go test -run=Coverage -coverprofile=c.out gopl.io/ch7/eval ok gopl.io/ch7/eval 0.032s coverage: 68.5% of statements- 这个标志参数通过在测试代码中插入生成钩子来统计覆盖率数据。也就是说,在运行每个测试前,它将待测代码拷贝一份并做修改,在每个词法块都会设置一个布尔标志变量。当被修改后的被测试代码运行退出时,将统计日志数据写入c.out文件,并打印一部分执行的语句的一个总结。(如果你需要的是摘要,使用
go test -cover。) - 如果使用了
-covermode=count标志参数,那么将在每个代码块插入一个计数器而不是布尔标志量。在统计结果中记录了每个块的执行次数,这可以用于衡量哪些是被频繁执行的热点代码。
- 这个标志参数通过在测试代码中插入生成钩子来统计覆盖率数据。也就是说,在运行每个测试前,它将待测代码拷贝一份并做修改,在每个词法块都会设置一个布尔标志变量。当被修改后的被测试代码运行退出时,将统计日志数据写入c.out文件,并打印一部分执行的语句的一个总结。(如果你需要的是摘要,使用
为了收集数据,我们运行了测试覆盖率工具,打印了测试日志,生成一个HTML报告,然后在浏览器中打开(图11.3)。
$ go tool cover -html=c.out绿色的代码块被测试覆盖到了,红色的则表示没有被覆盖到。为了清晰起见,我们将背景红色文本的背景设置成了阴影效果。我们可以马上发现unary操作的Eval方法并没有被执行到。如果我们针对这部分未被覆盖的代码添加下面的测试用例,然后重新运行上面的命令,那么我们将会看到那个红色部分的代码也变成绿色了:
{"-x * -x", eval.Env{"x": 2}, "4"}
不过两个panic语句依然是红色的。这是没有问题的,因为这两个语句并不会被执行到。
实现100%的测试覆盖率听起来很美,但是在具体实践中通常是不可行的,也不是值得推荐的做法。因为那只能说明代码被执行过而已,并不意味着代码就是没有BUG的;因为对于逻辑复杂的语句需要针对不同的输入执行多次。有一些语句,例如上面的panic语句则永远都不会被执行到。
另外,还有一些隐晦的错误在现实中很少遇到也很难编写对应的测试代码。测试从本质上来说是一个比较务实的工作,编写测试代码和编写应用代码的成本对比是需要考虑的。测试覆盖率工具可以帮助我们快速识别测试薄弱的地方,但是设计好的测试用例和编写应用代码一样需要严密的思考。
ch11.4 基准测试
基准测试是测量一个程序在固定工作负载下的性能。在Go语言中,基准测试函数和普通测试函数写法类似,但是以Benchmark为前缀名,并且带有一个
*testing.B类型的参数;*testing.B参数除了提供和*testing.T类似的方法,还有额外一些和性能测量相关的方法。它还提供了一个整数N,用于指定操作执行的循环次数。下面是IsPalindrome函数的基准测试,其中循环将执行N次。
import "testing" func BenchmarkIsPalindrome(b *testing.B) { for i := 0; i < b.N; i++ { IsPalindrome("A man, a plan, a canal: Panama") } }我们用下面的命令运行基准测试。和普通测试不同的是,默认情况下不运行任何基准测试。我们需要通过
-bench命令行标志参数手工指定要运行的基准测试函数。该参数是一个正则表达式,用于匹配要执行的基准测试函数的名字,默认值是空的。其中“.”模式将可以匹配所有基准测试函数,但因为这里只有一个基准测试函数,因此和-bench=IsPalindrome参数是等价的效果。$ cd $GOPATH/src/gopl.io/ch11/word2 $ go test -bench=. PASS BenchmarkIsPalindrome-8 1000000 1035 ns/op ok gopl.io/ch11/word2 2.179s结果中基准测试名的数字后缀部分,这里是8,表示运行时对应的GOMAXPROCS的值,这对于一些与并发相关的基准测试是重要的信息。
报告显示每次调用IsPalindrome函数花费1.035微秒,是执行1,000,000次的平均时间。因为基准测试驱动器开始时并不知道每个基准测试函数运行所花的时间,它会尝试在真正运行基准测试前先尝试用较小的N运行测试来估算基准测试函数所需要的时间,然后推断一个较大的时间保证稳定的测量结果。
循环在基准测试函数内实现,而不是放在基准测试框架内实现,这样可以让每个基准测试函数有机会在循环启动前执行初始化代码,这样并不会显著影响每次迭代的平均运行时间。如果还是担心初始化代码部分对测量时间带来干扰,那么可以通过testing.B参数提供的方法来临时关闭或重置计时器,不过这些一般很少会用到。
现在我们有了一个基准测试和普通测试,我们可以很容易测试改进程序运行速度的想法。也许最明显的优化是在IsPalindrome函数中第二个循环的停止检查,这样可以避免每个比较都做两次:
n := len(letters)/2 for i := 0; i < n; i++ { if letters[i] != letters[len(letters)-1-i] { return false } } return true不过很多情况下,一个显而易见的优化未必能带来预期的效果。这个改进在基准测试中只带来了4%的性能提升。
$ go test -bench=. PASS BenchmarkIsPalindrome-8 1000000 992 ns/op ok gopl.io/ch11/word2 2.093s
另一个改进想法是在开始为每个字符预先分配一个足够大的数组,这样就可以避免在append调用时可能会导致内存的多次重新分配。声明一个letters数组变量,并指定合适的大小,像下面这样,
letters := make([]rune, 0, len(s)) for _, r := range s { if unicode.IsLetter(r) { letters = append(letters, unicode.ToLower(r)) } }这个改进提升性能约35%,报告结果是基于2,000,000次迭代的平均运行时间统计。
$ go test -bench=. PASS BenchmarkIsPalindrome-8 2000000 697 ns/op ok gopl.io/ch11/word2 1.468s
如这个例子所示,快的程序往往是伴随着较少的内存分配。
-benchmem命令行标志参数将在报告中包含内存的分配数据统计。我们可以比较优化前后内存的分配情况:$ go test -bench=. -benchmem PASS BenchmarkIsPalindrome 1000000 1026 ns/op 304 B/op 4 allocs/op这是优化之后的结果:
$ go test -bench=. -benchmem PASS BenchmarkIsPalindrome 2000000 807 ns/op 128 B/op 1 allocs/op用一次内存分配代替多次的内存分配节省了75%的分配调用次数和减少近一半的内存需求。
这个基准测试告诉了我们某个具体操作所需的绝对时间,但我们往往想知道的是两个不同的操作的时间对比。
- 例如,如果一个函数需要1ms处理1,000个元素,那么处理10000或1百万将需要多少时间呢?这样的比较揭示了渐近增长函数的运行时间。
- 另一个例子:I/O缓存该设置为多大呢?基准测试可以帮助我们选择在性能达标情况下所需的最小内存。
- 第三个例子:对于一个确定的工作哪种算法更好?基准测试可以评估两种不同算法对于相同的输入在不同的场景和负载下的优缺点。
比较型的基准测试就是普通程序代码。它们通常是单参数的函数,由几个不同数量级的基准测试函数调用,就像这样:
func benchmark(b *testing.B, size int) { /* ... */ } func Benchmark10(b *testing.B) { benchmark(b, 10) } func Benchmark100(b *testing.B) { benchmark(b, 100) } func Benchmark1000(b *testing.B) { benchmark(b, 1000) }- 通过函数参数来指定输入的大小,但是参数变量对于每个具体的基准测试都是固定的。要避免直接修改b.N来控制输入的大小。除非你将它作为一个固定大小的迭代计算输入,否则基准测试的结果将毫无意义。
比较型的基准测试反映出的模式在程序设计阶段是很有帮助的,但是即使程序完工了也应当保留基准测试代码。因为随着项目的发展,或者是输入的增加,或者是部署到新的操作系统或不同的处理器,我们可以再次用基准测试来帮助我们改进设计。
ch11.5 剖析
基准测试(Benchmark)对于衡量特定操作的性能是有帮助的,但是当我们试图让程序跑的更快的时候,我们通常并不知道从哪里开始优化。每个码农都应该知道Donald Knuth在1974年的“Structured Programming with go to Statements”上所说的格言。虽然经常被解读为不重视性能的意思,但是从原文我们可以看到不同的含义:
毫无疑问,对效率的片面追求会导致各种滥用。程序员会浪费大量的时间在非关键程序的速度上,实际上这些尝试提升效率的行为反倒可能产生很大的负面影响,特别是当调试和维护的时候。我们不应该过度纠结于细节的优化,应该说约97%的场景:过早的优化是万恶之源。
当然我们也不应该放弃对那关键3%的优化。一个好的程序员不会因为这个比例小就裹足不前,他们会明智地观察和识别哪些是关键的代码;但是仅当关键代码已经被确认的前提下才会进行优化。对于很多程序员来说,判断哪部分是关键的性能瓶颈,是很容易犯经验上的错误的,因此一般应该借助测量工具来证明。
当我们想仔细观察我们程序的运行速度的时候,最好的方法是性能剖析。剖析技术是基于程序执行期间一些自动抽样,然后在收尾时进行推断;最后产生的统计结果就称为剖析数据。
Go语言支持多种类型的剖析性能分析,每一种关注不同的方面,但它们都涉及到每个采样记录的感兴趣的一系列事件消息,每个事件都包含函数调用时函数调用堆栈的信息。内建的
go test工具对几种分析方式都提供了支持。CPU剖析数据标识了最耗CPU时间的函数。在每个CPU上运行的线程在每隔几毫秒都会遇到操作系统的中断事件,每次中断时都会记录一个剖析数据然后恢复正常的运行。
堆剖析则标识了最耗内存的语句。剖析库会记录调用内部内存分配的操作,平均每512KB的内存申请会触发一个剖析数据。
阻塞剖析则记录阻塞goroutine最久的操作,例如系统调用、管道发送和接收,还有获取锁等。每当goroutine被这些操作阻塞时,剖析库都会记录相应的事件。
只需要开启下面其中一个标志参数就可以生成各种分析文件。当同时使用多个标志参数时需要当心,因为一项分析操作可能会影响其他项的分析结果。
$ go test -cpuprofile=cpu.out $ go test -blockprofile=block.out $ go test -memprofile=mem.out对于一些非测试程序也很容易进行剖析,具体的实现方式,与程序是短时间运行的小工具还是长时间运行的服务会有很大不同。剖析对于长期运行的程序尤其有用,因此可以通过调用Go的runtime API来启用运行时剖析。
一旦我们已经收集到了用于分析的采样数据,我们就可以使用pprof来分析这些数据。这是Go工具箱自带的一个工具,但并不是一个日常工具,它对应
go tool pprof命令。该命令有许多特性和选项,但是最基本的是两个参数:生成这个概要文件的可执行程序和对应的剖析数据。为了提高分析效率和减少空间,分析日志本身并不包含函数的名字;它只包含函数对应的地址。也就是说pprof需要对应的可执行程序来解读剖析数据。虽然
go test通常在测试完成后就丢弃临时用的测试程序,但是在启用分析的时候会将测试程序保存为foo.test文件,其中foo部分对应待测包的名字。下面的命令演示了如何收集并展示一个CPU分析文件。我们选择
net/http包的一个基准测试为例。通常最好是对业务关键代码的部分设计专门的基准测试。因为简单的基准测试几乎没法代表业务场景,因此我们用-run=NONE参数禁止那些简单测试。$ go test -run=NONE -bench=ClientServerParallelTLS64 \ -cpuprofile=cpu.log net/http PASS BenchmarkClientServerParallelTLS64-8 1000 3141325 ns/op 143010 B/op 1747 allocs/op ok net/http 3.395s $ go tool pprof -text -nodecount=10 ./http.test cpu.log 2570ms of 3590ms total (71.59%) Dropped 129 nodes (cum <= 17.95ms) Showing top 10 nodes out of 166 (cum >= 60ms) flat flat% sum% cum cum% 1730ms 48.19% 48.19% 1750ms 48.75% crypto/elliptic.p256ReduceDegree 230ms 6.41% 54.60% 250ms 6.96% crypto/elliptic.p256Diff 120ms 3.34% 57.94% 120ms 3.34% math/big.addMulVVW 110ms 3.06% 61.00% 110ms 3.06% syscall.Syscall 90ms 2.51% 63.51% 1130ms 31.48% crypto/elliptic.p256Square 70ms 1.95% 65.46% 120ms 3.34% runtime.scanobject 60ms 1.67% 67.13% 830ms 23.12% crypto/elliptic.p256Mul 60ms 1.67% 68.80% 190ms 5.29% math/big.nat.montgomery 50ms 1.39% 70.19% 50ms 1.39% crypto/elliptic.p256ReduceCarry 50ms 1.39% 71.59% 60ms 1.67% crypto/elliptic.p256Sum- 参数
-text用于指定输出格式,在这里每行是一个函数,根据使用CPU的时间长短来排序。其中-nodecount=10参数限制了只输出前10行的结果。对于严重的性能问题,这个文本格式基本可以帮助查明原因了。 - 这个概要文件告诉我们,HTTPS基准测试中
crypto/elliptic.p256ReduceDegree函数占用了将近一半的CPU资源,对性能占很大比重。相比之下,如果一个概要文件中主要是runtime包的内存分配的函数,那么减少内存消耗可能是一个值得尝试的优化策略。
- 参数
对于一些更微妙的问题,你可能需要使用pprof的图形显示功能。这个需要安装GraphViz工具,可以从 http://www.graphviz.org 下载。参数
-web用于生成函数的有向图,标注有CPU的使用和最热点的函数等信息。这一节我们只是简单看了下Go语言的数据分析工具。如果想了解更多,可以阅读Go官方博客的“Profiling Go Programs”一文。
ch11.6 示例函数
第三种被
go test特别对待的函数是示例函数,以Example为函数名开头。示例函数没有函数参数和返回值。下面是IsPalindrome函数对应的示例函数:func ExampleIsPalindrome() { fmt.Println(IsPalindrome("A man, a plan, a canal: Panama")) fmt.Println(IsPalindrome("palindrome")) // Output: // true // false }示例函数有三个用处。
最主要的一个是作为文档:一个包的例子可以更简洁直观的方式来演示函数的用法,比文字描述更直接易懂,特别是作为一个提醒或快速参考时。一个示例函数也可以方便展示属于同一个接口的几种类型或函数之间的关系,所有的文档都必须关联到一个地方,就像一个类型或函数声明都统一到包一样。同时,示例函数和注释并不一样,示例函数是真实的Go代码,需要接受编译器的编译时检查,这样可以保证源代码更新时,示例代码不会脱节。
- 根据示例函数的后缀名部分,godoc这个web文档服务器会将示例函数关联到某个具体函数或包本身,因此ExampleIsPalindrome示例函数将是IsPalindrome函数文档的一部分,Example示例函数将是包文档的一部分。
示例函数的第二个用处是,在
go test执行测试的时候也会运行示例函数测试。如果示例函数内含有类似上面例子中的// Output:格式的注释,那么测试工具会执行这个示例函数,然后检查示例函数的标准输出与注释是否匹配。示例函数的第三个目的提供一个真实的演练场。 http://golang.org 就是由godoc提供的文档服务,它使用了Go Playground让用户可以在浏览器中在线编辑和运行每个示例函数,就像图11.4所示的那样。这通常是学习函数使用或Go语言特性最快捷的方式。
本书最后的两章是讨论reflect和unsafe包,一般的Go程序员很少使用它们,事实上也很少需要用到。因此,如果你还没有写过任何真实的Go程序的话,现在可以先去写些代码了。
ch12 反射
ch12.0 简介
- Go语言提供了一种机制,能够在运行时更新变量和检查它们的值、调用它们的方法和它们支持的内在操作,而不需要在编译时就知道这些变量的具体类型。这种机制被称为反射。反射也可以让我们将类型本身作为第一类的值类型处理。
- 在本章,我们将探讨Go语言的反射特性,看看它可以给语言增加哪些表达力,以及在两个至关重要的API是如何使用反射机制的:一个是fmt包提供的字符串格式化功能,另一个是类似encoding/json和encoding/xml提供的针对特定协议的编解码功能。对于我们在4.6节中看到过的text/template和html/template包,它们的实现也是依赖反射技术的。然后,反射是一个复杂的内省技术,不应该随意使用,因此,尽管上面这些包内部都是用反射技术实现的,但是它们自己的API都没有公开反射相关的接口。
ch12.1 为何需要反射
有时候我们需要编写一个函数能够处理一类并不满足普通公共接口的类型的值,也可能是因为它们并没有确定的表示方式,或者是在我们设计该函数的时候这些类型可能还不存在。
个大家熟悉的例子是fmt.Fprintf函数提供的字符串格式化处理逻辑,它可以用来对任意类型的值格式化并打印,甚至支持用户自定义的类型。让我们也来尝试实现一个类似功能的函数。为了简单起见,我们的函数只接收一个参数,然后返回和fmt.Sprint类似的格式化后的字符串。我们实现的函数名也叫Sprint。
我们首先用switch类型分支来测试输入参数是否实现了String方法,如果是的话就调用该方法。然后继续增加类型测试分支,检查这个值的动态类型是否是string、int、bool等基础类型,并在每种情况下执行相应的格式化操作。
func Sprint(x interface{}) string { type stringer interface { String() string } switch x := x.(type) { case stringer: return x.String() case string: return x case int: return strconv.Itoa(x) // ...similar cases for int16, uint32, and so on... case bool: if x { return "true" } return "false" default: // array, chan, func, map, pointer, slice, struct return "???" } }但是我们如何处理其它类似[]float64、map[string][]string等类型呢?我们当然可以添加更多的测试分支,但是这些组合类型的数目基本是无穷的。还有如何处理类似url.Values这样的具名类型呢?即使类型分支可以识别出底层的基础类型是map[string][]string,但是它并不匹配url.Values类型,因为它们是两种不同的类型,而且switch类型分支也不可能包含每个类似url.Values的类型,这会导致对这些库的依赖。
没有办法来检查未知类型的表示方式,我们被卡住了。这就是我们为何需要反射的原因。
ch12.2 reflect.Type和reflect.Value
反射是由 reflect 包提供的。它定义了两个重要的类型,Type 和 Value。一个 Type 表示一个Go类型。它是一个接口,有许多方法来区分类型以及检查它们的组成部分,例如一个结构体的成员或一个函数的参数等。唯一能反映 reflect.Type 实现的是接口的类型描述信息(§7.5),也正是这个实体标识了接口值的动态类型。
函数 reflect.TypeOf 接受任意的 interface{} 类型,并以 reflect.Type 形式返回其动态类型:
t := reflect.TypeOf(3) // a reflect.Type fmt.Println(t.String()) // "int" fmt.Println(t) // "int"其中 TypeOf(3) 调用将值 3 传给 interface{} 参数。回到 7.5节 的将一个具体的值转为接口类型会有一个隐式的接口转换操作,它会创建一个包含两个信息的接口值:操作数的动态类型(这里是 int)和它的动态的值(这里是 3)。
因为 reflect.TypeOf 返回的是一个动态类型的接口值,它总是返回具体的类型。因此,下面的代码将打印 “*os.File” 而不是 “io.Writer”。稍后,我们将看到能够表达接口类型的 reflect.Type。
var w io.Writer = os.Stdout fmt.Println(reflect.TypeOf(w)) // "*os.File"要注意的是 reflect.Type 接口是满足 fmt.Stringer 接口的。因为打印一个接口的动态类型对于调试和日志是有帮助的, fmt.Printf 提供了一个缩写 %T 参数,内部使用 reflect.TypeOf 来输出:
fmt.Printf("%T\n", 3) // "int"reflect 包中另一个重要的类型是 Value。一个 reflect.Value 可以装载任意类型的值。函数 reflect.ValueOf 接受任意的 interface{} 类型,并返回一个装载着其动态值的 reflect.Value。和 reflect.TypeOf 类似,reflect.ValueOf 返回的结果也是具体的类型,但是 reflect.Value 也可以持有一个接口值。
v := reflect.ValueOf(3) // a reflect.Value fmt.Println(v) // "3" fmt.Printf("%v\n", v) // "3" fmt.Println(v.String()) // NOTE: "<int Value>"和 reflect.Type 类似,reflect.Value 也满足 fmt.Stringer 接口,但是除非 Value 持有的是字符串,否则 String 方法只返回其类型。而使用 fmt 包的 %v 标志参数会对 reflect.Values 特殊处理。
对 Value 调用 Type 方法将返回具体类型所对应的 reflect.Type:
t := v.Type() // a reflect.Type fmt.Println(t.String()) // "int"reflect.ValueOf 的逆操作是 reflect.Value.Interface 方法。它返回一个 interface{} 类型,装载着与 reflect.Value 相同的具体值:
v := reflect.ValueOf(3) // a reflect.Value x := v.Interface() // an interface{} i := x.(int) // an int fmt.Printf("%d\n", i) // "3"reflect.Value 和 interface{} 都能装载任意的值。所不同的是,一个空的接口隐藏了值内部的表示方式和所有方法,因此只有我们知道具体的动态类型才能使用类型断言来访问内部的值(就像上面那样),内部值我们没法访问。相比之下,一个 Value 则有很多方法来检查其内容,无论它的具体类型是什么。让我们再次尝试实现我们的格式化函数 format.Any。
我们使用 reflect.Value 的 Kind 方法来替代之前的类型 switch。虽然还是有无穷多的类型,但是它们的 kinds 类型却是有限的:Bool、String 和 所有数字类型的基础类型;Array 和 Struct 对应的聚合类型;Chan、Func、Ptr、Slice 和 Map 对应的引用类型;interface 类型;还有表示空值的 Invalid 类型。(空的 reflect.Value 的 kind 即为 Invalid。)
gopl.io/ch12/format
package format import ( "reflect" "strconv" ) // Any formats any value as a string. func Any(value interface{}) string { return formatAtom(reflect.ValueOf(value)) } // formatAtom formats a value without inspecting its internal structure. func formatAtom(v reflect.Value) string { switch v.Kind() { case reflect.Invalid: return "invalid" case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: return strconv.FormatInt(v.Int(), 10) case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: return strconv.FormatUint(v.Uint(), 10) // ...floating-point and complex cases omitted for brevity... case reflect.Bool: return strconv.FormatBool(v.Bool()) case reflect.String: return strconv.Quote(v.String()) case reflect.Chan, reflect.Func, reflect.Ptr, reflect.Slice, reflect.Map: return v.Type().String() + " 0x" + strconv.FormatUint(uint64(v.Pointer()), 16) default: // reflect.Array, reflect.Struct, reflect.Interface return v.Type().String() + " value" } }到目前为止,我们的函数将每个值视作一个不可分割没有内部结构的物品,因此它叫 formatAtom。对于聚合类型(结构体和数组)和接口,只是打印值的类型,对于引用类型(channels、functions、pointers、slices 和 maps),打印类型和十六进制的引用地址。虽然还不够理想,但是依然是一个重大的进步,并且 Kind 只关心底层表示,format.Any 也支持具名类型。例如:
var x int64 = 1 var d time.Duration = 1 * time.Nanosecond fmt.Println(format.Any(x)) // "1" fmt.Println(format.Any(d)) // "1" fmt.Println(format.Any([]int64{x})) // "[]int64 0x8202b87b0" fmt.Println(format.Any([]time.Duration{d})) // "[]time.Duration 0x8202b87e0"
ch12.3 Display递归打印
接下来,让我们看看如何改善聚合数据类型的显示。我们并不想完全克隆一个fmt.Sprint函数,我们只是构建一个用于调试用的Display函数:给定任意一个复杂类型 x,打印这个值对应的完整结构,同时标记每个元素的发现路径。让我们从一个例子开始。
e, _ := eval.Parse("sqrt(A / pi)") Display("e", e)在上面的调用中,传入Display函数的参数是在7.9节一个表达式求值函数返回的语法树。Display函数的输出如下:
Display e (eval.call): e.fn = "sqrt" e.args[0].type = eval.binary e.args[0].value.op = 47 e.args[0].value.x.type = eval.Var e.args[0].value.x.value = "A" e.args[0].value.y.type = eval.Var e.args[0].value.y.value = "pi"你应该尽量避免在一个包的API中暴露涉及反射的接口。我们将定义一个未导出的display函数用于递归处理工作,导出的是Display函数,它只是display函数简单的包装以接受interface{}类型的参数:
gopl.io/ch12/display
func Display(name string, x interface{}) { fmt.Printf("Display %s (%T):\n", name, x) display(name, reflect.ValueOf(x)) }在display函数中,我们使用了前面定义的打印基础类型——基本类型、函数和chan等——元素值的formatAtom函数,但是我们会使用reflect.Value的方法来递归显示复杂类型的每一个成员。在递归下降过程中,path字符串,从最开始传入的起始值(这里是“e”),将逐步增长来表示是如何达到当前值(例如“e.args[0].value”)的。
因为我们不再模拟fmt.Sprint函数,我们将直接使用fmt包来简化我们的例子实现。
func display(path string, v reflect.Value) { switch v.Kind() { case reflect.Invalid: fmt.Printf("%s = invalid\n", path) case reflect.Slice, reflect.Array: for i := 0; i < v.Len(); i++ { display(fmt.Sprintf("%s[%d]", path, i), v.Index(i)) } case reflect.Struct: for i := 0; i < v.NumField(); i++ { fieldPath := fmt.Sprintf("%s.%s", path, v.Type().Field(i).Name) display(fieldPath, v.Field(i)) } case reflect.Map: for _, key := range v.MapKeys() { display(fmt.Sprintf("%s[%s]", path, formatAtom(key)), v.MapIndex(key)) } case reflect.Ptr: if v.IsNil() { fmt.Printf("%s = nil\n", path) } else { display(fmt.Sprintf("(*%s)", path), v.Elem()) } case reflect.Interface: if v.IsNil() { fmt.Printf("%s = nil\n", path) } else { fmt.Printf("%s.type = %s\n", path, v.Elem().Type()) display(path+".value", v.Elem()) } default: // basic types, channels, funcs fmt.Printf("%s = %s\n", path, formatAtom(v)) } }让我们针对不同类型分别讨论。
Slice和数组: 两种的处理逻辑是一样的。Len方法返回slice或数组值中的元素个数,Index(i)获得索引i对应的元素,返回的也是一个reflect.Value;如果索引i超出范围的话将导致panic异常,这与数组或slice类型内建的len(a)和a[i]操作类似。display针对序列中的每个元素递归调用自身处理,我们通过在递归处理时向path附加“[i]”来表示访问路径。
- 虽然reflect.Value类型带有很多方法,但是只有少数的方法能对任意值都安全调用。例如,Index方法只能对Slice、数组或字符串类型的值调用,如果对其它类型调用则会导致panic异常。
结构体: NumField方法报告结构体中成员的数量,Field(i)以reflect.Value类型返回第i个成员的值。成员列表也包括通过匿名字段提升上来的成员。为了在path添加“.f”来表示成员路径,我们必须获得结构体对应的reflect.Type类型信息,然后访问结构体第i个成员的名字。
Maps: MapKeys方法返回一个reflect.Value类型的slice,每一个元素对应map的一个key。和往常一样,遍历map时顺序是随机的。MapIndex(key)返回map中key对应的value。我们向path添加“[key]”来表示访问路径。(我们这里有一个未完成的工作。其实map的key的类型并不局限于formatAtom能完美处理的类型;数组、结构体和接口都可以作为map的key。针对这种类型,完善key的显示信息是练习12.1的任务。)
指针: Elem方法返回指针指向的变量,依然是reflect.Value类型。即使指针是nil,这个操作也是安全的,在这种情况下指针是Invalid类型,但是我们可以用IsNil方法来显式地测试一个空指针,这样我们可以打印更合适的信息。我们在path前面添加“*”,并用括弧包含以避免歧义。
接口: 再一次,我们使用IsNil方法来测试接口是否是nil,如果不是,我们可以调用v.Elem()来获取接口对应的动态值,并且打印对应的类型和值。
现在我们的Display函数总算完工了,让我们看看它的表现吧。下面的Movie类型是在4.5节的电影类型上演变来的:
type Movie struct { Title, Subtitle string Year int Color bool Actor map[string]string Oscars []string Sequel *string }让我们声明一个该类型的变量,然后看看Display函数如何显示它:
strangelove := Movie{ Title: "Dr. Strangelove", Subtitle: "How I Learned to Stop Worrying and Love the Bomb", Year: 1964, Color: false, Actor: map[string]string{ "Dr. Strangelove": "Peter Sellers", "Grp. Capt. Lionel Mandrake": "Peter Sellers", "Pres. Merkin Muffley": "Peter Sellers", "Gen. Buck Turgidson": "George C. Scott", "Brig. Gen. Jack D. Ripper": "Sterling Hayden", `Maj. T.J. "King" Kong`: "Slim Pickens", }, Oscars: []string{ "Best Actor (Nomin.)", "Best Adapted Screenplay (Nomin.)", "Best Director (Nomin.)", "Best Picture (Nomin.)", }, }Display(“strangelove”, strangelove)调用将显示(strangelove电影对应的中文名是《奇爱博士》):
Display strangelove (display.Movie): strangelove.Title = "Dr. Strangelove" strangelove.Subtitle = "How I Learned to Stop Worrying and Love the Bomb" strangelove.Year = 1964 strangelove.Color = false strangelove.Actor["Gen. Buck Turgidson"] = "George C. Scott" strangelove.Actor["Brig. Gen. Jack D. Ripper"] = "Sterling Hayden" strangelove.Actor["Maj. T.J. \"King\" Kong"] = "Slim Pickens" strangelove.Actor["Dr. Strangelove"] = "Peter Sellers" strangelove.Actor["Grp. Capt. Lionel Mandrake"] = "Peter Sellers" strangelove.Actor["Pres. Merkin Muffley"] = "Peter Sellers" strangelove.Oscars[0] = "Best Actor (Nomin.)" strangelove.Oscars[1] = "Best Adapted Screenplay (Nomin.)" strangelove.Oscars[2] = "Best Director (Nomin.)" strangelove.Oscars[3] = "Best Picture (Nomin.)" strangelove.Sequel = nil我们也可以使用Display函数来显示标准库中类型的内部结构,例如
*os.File类型:Display("os.Stderr", os.Stderr) // Output: // Display os.Stderr (*os.File): // (*(*os.Stderr).file).fd = 2 // (*(*os.Stderr).file).name = "/dev/stderr" // (*(*os.Stderr).file).nepipe = 0
可以看出,反射能够访问到结构体中未导出的成员。需要当心的是这个例子的输出在不同操作系统上可能是不同的,并且随着标准库的发展也可能导致结果不同。(这也是将这些成员定义为私有成员的原因之一!)我们甚至可以用Display函数来显示reflect.Value 的内部构造(在这里设置为
*os.File的类型描述体)。Display("rV", reflect.ValueOf(os.Stderr))调用的输出如下,当然不同环境得到的结果可能有差异:Display rV (reflect.Value): (*rV.typ).size = 8 (*rV.typ).hash = 871609668 (*rV.typ).align = 8 (*rV.typ).fieldAlign = 8 (*rV.typ).kind = 22 (*(*rV.typ).string) = "*os.File" (*(*(*rV.typ).uncommonType).methods[0].name) = "Chdir" (*(*(*(*rV.typ).uncommonType).methods[0].mtyp).string) = "func() error" (*(*(*(*rV.typ).uncommonType).methods[0].typ).string) = "func(*os.File) error" ...观察下面两个例子的区别:
var i interface{} = 3 Display("i", i) // Output: // Display i (int): // i = 3 Display("&i", &i) // Output: // Display &i (*interface {}): // (*&i).type = int // (*&i).value = 3在第一个例子中,Display函数调用reflect.ValueOf(i),它返回一个Int类型的值。正如我们在12.2节中提到的,reflect.ValueOf总是返回一个具体类型的 Value,因为它是从一个接口值提取的内容。
在第二个例子中,Display函数调用的是reflect.ValueOf(&i),它返回一个指向i的指针,对应Ptr类型。在switch的Ptr分支中,对这个值调用 Elem 方法,返回一个Value来表示变量 i 本身,对应Interface类型。像这样一个间接获得的Value,可能代表任意类型的值,包括接口类型。display函数递归调用自身,这次它分别打印了这个接口的动态类型和值。
对于目前的实现,如果遇到对象图中含有回环,Display将会陷入死循环,例如下面这个首尾相连的链表:
// a struct that points to itself type Cycle struct{ Value int; Tail *Cycle } var c Cycle c = Cycle{42, &c} Display("c", c)Display会永远不停地进行深度递归打印:
Display c (display.Cycle): c.Value = 42 (*c.Tail).Value = 42 (*(*c.Tail).Tail).Value = 42 (*(*(*c.Tail).Tail).Tail).Value = 42 ...ad infinitum...
许多Go语言程序都包含了一些循环的数据。让Display支持这类带环的数据结构需要些技巧,需要额外记录迄今访问的路径;相应会带来成本。通用的解决方案是采用 unsafe 的语言特性,我们将在13.3节看到具体的解决方案。
带环的数据结构很少会对fmt.Sprint函数造成问题,因为它很少尝试打印完整的数据结构。例如,当它遇到一个指针的时候,它只是简单地打印指针的数字值。在打印包含自身的slice或map时可能卡住,但是这种情况很罕见,不值得付出为了处理回环所需的开销。
ch12.4 示例:编码S表达式
Display是一个用于显示结构化数据的调试工具,但是它并不能将任意的Go语言对象编码为通用消息然后用于进程间通信。
正如我们在4.5节中中看到的,Go语言的标准库支持了包括JSON、XML和ASN.1等多种编码格式。还有另一种依然被广泛使用的格式是S表达式格式,采用Lisp语言的语法。但是和其他编码格式不同的是,Go语言自带的标准库并不支持S表达式,主要是因为它没有一个公认的标准规范。
在本节中,我们将定义一个包用于将任意的Go语言对象编码为S表达式格式,它支持以下结构:
42 integer "hello" string(带有Go风格的引号) foo symbol(未用引号括起来的名字) (1 2 3) list (括号包起来的0个或多个元素)布尔型习惯上使用t符号表示true,空列表或nil符号表示false,但是为了简单起见,我们暂时忽略布尔类型。同时忽略的还有chan管道和函数,因为通过反射并无法知道它们的确切状态。我们忽略的还有浮点数、复数和interface。支持它们是练习12.3的任务。
我们将Go语言的类型编码为S表达式的方法如下。整数和字符串以显而易见的方式编码。空值编码为nil符号。数组和slice被编码为列表。
构体被编码为成员对象的列表,每个成员对象对应一个有两个元素的子列表,子列表的第一个元素是成员的名字,第二个元素是成员的值。Map被编码为键值对的列表。传统上,S表达式使用点状符号列表(key . value)结构来表示key/value对,而不是用一个含双元素的列表,不过为了简单我们忽略了点状符号列表。
编码是由一个encode递归函数完成,如下所示。它的结构本质上和前面的Display函数类似:
gopl.io/ch12/sexpr
func encode(buf *bytes.Buffer, v reflect.Value) error { switch v.Kind() { case reflect.Invalid: buf.WriteString("nil") case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: fmt.Fprintf(buf, "%d", v.Int()) case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: fmt.Fprintf(buf, "%d", v.Uint()) case reflect.String: fmt.Fprintf(buf, "%q", v.String()) case reflect.Ptr: return encode(buf, v.Elem()) case reflect.Array, reflect.Slice: // (value ...) buf.WriteByte('(') for i := 0; i < v.Len(); i++ { if i > 0 { buf.WriteByte(' ') } if err := encode(buf, v.Index(i)); err != nil { return err } } buf.WriteByte(')') case reflect.Struct: // ((name value) ...) buf.WriteByte('(') for i := 0; i < v.NumField(); i++ { if i > 0 { buf.WriteByte(' ') } fmt.Fprintf(buf, "(%s ", v.Type().Field(i).Name) if err := encode(buf, v.Field(i)); err != nil { return err } buf.WriteByte(')') } buf.WriteByte(')') case reflect.Map: // ((key value) ...) buf.WriteByte('(') for i, key := range v.MapKeys() { if i > 0 { buf.WriteByte(' ') } buf.WriteByte('(') if err := encode(buf, key); err != nil { return err } buf.WriteByte(' ') if err := encode(buf, v.MapIndex(key)); err != nil { return err } buf.WriteByte(')') } buf.WriteByte(')') default: // float, complex, bool, chan, func, interface return fmt.Errorf("unsupported type: %s", v.Type()) } return nil }Marshal函数是对encode的包装,以保持和encoding/…下其它包有着相似的API:
// Marshal encodes a Go value in S-expression form. func Marshal(v interface{}) ([]byte, error) { var buf bytes.Buffer if err := encode(&buf, reflect.ValueOf(v)); err != nil { return nil, err } return buf.Bytes(), nil }下面是Marshal对12.3节的strangelove变量编码后的结果:
((Title "Dr. Strangelove") (Subtitle "How I Learned to Stop Worrying and Lo ve the Bomb") (Year 1964) (Actor (("Grp. Capt. Lionel Mandrake" "Peter Sell ers") ("Pres. Merkin Muffley" "Peter Sellers") ("Gen. Buck Turgidson" "Geor ge C. Scott") ("Brig. Gen. Jack D. Ripper" "Sterling Hayden") ("Maj. T.J. \ "King\" Kong" "Slim Pickens") ("Dr. Strangelove" "Peter Sellers"))) (Oscars ("Best Actor (Nomin.)" "Best Adapted Screenplay (Nomin.)" "Best Director (N omin.)" "Best Picture (Nomin.)")) (Sequel nil))整个输出编码为一行中以减少输出的大小,但是也很难阅读。下面是对S表达式手动格式化的结果。编写一个S表达式的美化格式化函数将作为一个具有挑战性的练习任务;不过 http://gopl.io 也提供了一个简单的版本。
((Title "Dr. Strangelove") (Subtitle "How I Learned to Stop Worrying and Love the Bomb") (Year 1964) (Actor (("Grp. Capt. Lionel Mandrake" "Peter Sellers") ("Pres. Merkin Muffley" "Peter Sellers") ("Gen. Buck Turgidson" "George C. Scott") ("Brig. Gen. Jack D. Ripper" "Sterling Hayden") ("Maj. T.J. \"King\" Kong" "Slim Pickens") ("Dr. Strangelove" "Peter Sellers"))) (Oscars ("Best Actor (Nomin.)" "Best Adapted Screenplay (Nomin.)" "Best Director (Nomin.)" "Best Picture (Nomin.)")) (Sequel nil))和fmt.Print、json.Marshal、Display函数类似,sexpr.Marshal函数处理带环的数据结构也会陷入死循环。
ch12.5 通过reflect.Value修改值
目前为止,反射还只是程序中变量的另一种读取方式。然而,在本节中我们将重点讨论如何通过反射机制来修改变量。
回想一下,Go语言中类似x、x.f[1]和*p形式的表达式都可以表示变量,但是其它如x + 1和f(2)则不是变量。一个变量就是一个可寻址的内存空间,里面存储了一个值,并且存储的值可以通过内存地址来更新。
对于reflect.Values也有类似的区别。有一些reflect.Values是可取地址的;其它一些则不可以。考虑以下的声明语句:
x := 2 // value type variable? a := reflect.ValueOf(2) // 2 int no b := reflect.ValueOf(x) // 2 int no c := reflect.ValueOf(&x) // &x *int no d := c.Elem() // 2 int yes (x)其中a对应的变量不可取地址。因为a中的值仅仅是整数2的拷贝副本。b中的值也同样不可取地址。c中的值还是不可取地址,它只是一个指针
&x的拷贝。实际上,所有通过reflect.ValueOf(x)返回的reflect.Value都是不可取地址的。但是对于d,它是c的解引用方式生成的,指向另一个变量,因此是可取地址的。我们可以通过调用reflect.ValueOf(&x).Elem(),来获取任意变量x对应的可取地址的Value。我们可以通过调用reflect.Value的CanAddr方法来判断其是否可以被取地址:
fmt.Println(a.CanAddr()) // "false" fmt.Println(b.CanAddr()) // "false" fmt.Println(c.CanAddr()) // "false" fmt.Println(d.CanAddr()) // "true"每当我们通过指针间接地获取的reflect.Value都是可取地址的,即使开始的是一个不可取地址的Value。在反射机制中,所有关于是否支持取地址的规则都是类似的。例如,slice的索引表达式e[i]将隐式地包含一个指针,它就是可取地址的,即使开始的e表达式不支持也没有关系。以此类推,reflect.ValueOf(e).Index(i)对应的值也是可取地址的,即使原始的reflect.ValueOf(e)不支持也没有关系。
要从变量对应的可取地址的reflect.Value来访问变量需要三个步骤。第一步是调用Addr()方法,它返回一个Value,里面保存了指向变量的指针。然后是在Value上调用Interface()方法,也就是返回一个interface{},里面包含指向变量的指针。最后,如果我们知道变量的类型,我们可以使用类型的断言机制将得到的interface{}类型的接口强制转为普通的类型指针。这样我们就可以通过这个普通指针来更新变量了:
x := 2 d := reflect.ValueOf(&x).Elem() // d refers to the variable x px := d.Addr().Interface().(*int) // px := &x *px = 3 // x = 3 fmt.Println(x) // "3"或者,不使用指针,而是通过调用可取地址的reflect.Value的reflect.Value.Set方法来更新对应的值:
d.Set(reflect.ValueOf(4)) fmt.Println(x) // "4"Set方法将在运行时执行和编译时进行类似的可赋值性约束的检查。以上代码,变量和值都是int类型,但是如果变量是int64类型,那么程序将抛出一个panic异常,所以关键问题是要确保改类型的变量可以接受对应的值:
d.Set(reflect.ValueOf(int64(5))) // panic: int64 is not assignable to int同样,对一个不可取地址的reflect.Value调用Set方法也会导致panic异常:
x := 2 b := reflect.ValueOf(x) b.Set(reflect.ValueOf(3)) // panic: Set using unaddressable value这里有很多用于基本数据类型的Set方法:SetInt、SetUint、SetString和SetFloat等。
d := reflect.ValueOf(&x).Elem() d.SetInt(3) fmt.Println(x) // "3"
从某种程度上说,这些Set方法总是尽可能地完成任务。以SetInt为例,只要变量是某种类型的有符号整数就可以工作,即使是一些命名的类型、甚至只要底层数据类型是有符号整数就可以,而且如果对于变量类型值太大的话会被自动截断。但需要谨慎的是:对于一个引用interface{}类型的reflect.Value调用SetInt会导致panic异常,即使那个interface{}变量对于整数类型也不行。
x := 1 rx := reflect.ValueOf(&x).Elem() rx.SetInt(2) // OK, x = 2 rx.Set(reflect.ValueOf(3)) // OK, x = 3 rx.SetString("hello") // panic: string is not assignable to int rx.Set(reflect.ValueOf("hello")) // panic: string is not assignable to int var y interface{} ry := reflect.ValueOf(&y).Elem() ry.SetInt(2) // panic: SetInt called on interface Value ry.Set(reflect.ValueOf(3)) // OK, y = int(3) ry.SetString("hello") // panic: SetString called on interface Value ry.Set(reflect.ValueOf("hello")) // OK, y = "hello"当我们用Display显示os.Stdout结构时,我们发现反射可以越过Go语言的导出规则的限制读取结构体中未导出的成员,比如在类Unix系统上os.File结构体中的fd int成员。然而,利用反射机制并不能修改这些未导出的成员:
stdout := reflect.ValueOf(os.Stdout).Elem() // *os.Stdout, an os.File var fmt.Println(stdout.Type()) // "os.File" fd := stdout.FieldByName("fd") fmt.Println(fd.Int()) // "1" fd.SetInt(2) // panic: unexported field一个可取地址的reflect.Value会记录一个结构体成员是否是未导出成员,如果是的话则拒绝修改操作。因此,CanAddr方法并不能正确反映一个变量是否是可以被修改的。另一个相关的方法CanSet是用于检查对应的reflect.Value是否是可取地址并可被修改的:
fmt.Println(fd.CanAddr(), fd.CanSet()) // "true false"
ch12.6 示例:编码S表达式
标准库中encoding/…下每个包中提供的Marshal编码函数都有一个对应的Unmarshal函数用于解码。例如,我们在4.5节中看到的,要将包含JSON编码格式的字节slice数据解码为我们自己的Movie类型(§12.3),我们可以这样做:
data := []byte{/* ... */} var movie Movie err := json.Unmarshal(data, &movie)Unmarshal函数使用了反射机制类修改movie变量的每个成员,根据输入的内容为Movie成员创建对应的map、结构体和slice。
现在让我们为S表达式编码实现一个简易的Unmarshal,类似于前面的json.Unmarshal标准库函数,对应我们之前实现的sexpr.Marshal函数的逆操作。我们必须提醒一下,一个健壮的和通用的实现通常需要比例子更多的代码,为了便于演示我们采用了精简的实现。我们只支持S表达式有限的子集,同时处理错误的方式也比较粗暴,代码的目的是为了演示反射的用法,而不是构造一个实用的S表达式的解码器。
词法分析器lexer使用了标准库中的text/scanner包将输入流的字节数据解析为一个个类似注释、标识符、字符串面值和数字面值之类的标记。输入扫描器scanner的Scan方法将提前扫描和返回下一个记号,对于rune类型。大多数记号,比如“(”,对应一个单一rune可表示的Unicode字符,但是text/scanner也可以用小的负数表示记号标识符、字符串等由多个字符组成的记号。调用Scan方法将返回这些记号的类型,接着调用TokenText方法将返回记号对应的文本内容。
因为每个解析器可能需要多次使用当前的记号,但是Scan会一直向前扫描,所以我们包装了一个lexer扫描器辅助类型,用于跟踪最近由Scan方法返回的记号。
gopl.io/ch12/sexpr
type lexer struct { scan scanner.Scanner token rune // the current token } func (lex *lexer) next() { lex.token = lex.scan.Scan() } func (lex *lexer) text() string { return lex.scan.TokenText() } func (lex *lexer) consume(want rune) { if lex.token != want { // NOTE: Not an example of good error handling. panic(fmt.Sprintf("got %q, want %q", lex.text(), want)) } lex.next() }现在让我们转到语法解析器。它主要包含两个功能。第一个是read函数,用于读取S表达式的当前标记,然后根据S表达式的当前标记更新可取地址的reflect.Value对应的变量v。
func read(lex *lexer, v reflect.Value) { switch lex.token { case scanner.Ident: // The only valid identifiers are // "nil" and struct field names. if lex.text() == "nil" { v.Set(reflect.Zero(v.Type())) lex.next() return } case scanner.String: s, _ := strconv.Unquote(lex.text()) // NOTE: ignoring errors v.SetString(s) lex.next() return case scanner.Int: i, _ := strconv.Atoi(lex.text()) // NOTE: ignoring errors v.SetInt(int64(i)) lex.next() return case '(': lex.next() readList(lex, v) lex.next() // consume ')' return } panic(fmt.Sprintf("unexpected token %q", lex.text())) }我们的S表达式使用标识符区分两个不同类型,结构体成员名和nil值的指针。read函数值处理nil类型的标识符。当遇到scanner.Ident为“nil”是,使用reflect.Zero函数将变量v设置为零值。而其它任何类型的标识符,我们都作为错误处理。后面的readList函数将处理结构体的成员名。
一个“(”标记对应一个列表的开始。第二个函数readList,将一个列表解码到一个聚合类型中(map、结构体、slice或数组),具体类型依然于传入待填充变量的类型。每次遇到这种情况,循环继续解析每个元素直到遇到于开始标记匹配的结束标记“)”,endList函数用于检测结束标记。
最有趣的部分是递归。最简单的是对数组类型的处理。直到遇到“)”结束标记,我们使用Index函数来获取数组每个元素的地址,然后递归调用read函数处理。和其它错误类似,如果输入数据导致解码器的引用超出了数组的范围,解码器将抛出panic异常。slice也采用类似方法解析,不同的是我们将为每个元素创建新的变量,然后将元素添加到slice的末尾。
在循环处理结构体和map每个元素时必须解码一个(key value)格式的对应子列表。对于结构体,key部分对于成员的名字。和数组类似,我们使用FieldByName找到结构体对应成员的变量,然后递归调用read函数处理。对于map,key可能是任意类型,对元素的处理方式和slice类似,我们创建一个新的变量,然后递归填充它,最后将新解析到的key/value对添加到map。
func readList(lex *lexer, v reflect.Value) { switch v.Kind() { case reflect.Array: // (item ...) for i := 0; !endList(lex); i++ { read(lex, v.Index(i)) } case reflect.Slice: // (item ...) for !endList(lex) { item := reflect.New(v.Type().Elem()).Elem() read(lex, item) v.Set(reflect.Append(v, item)) } case reflect.Struct: // ((name value) ...) for !endList(lex) { lex.consume('(') if lex.token != scanner.Ident { panic(fmt.Sprintf("got token %q, want field name", lex.text())) } name := lex.text() lex.next() read(lex, v.FieldByName(name)) lex.consume(')') } case reflect.Map: // ((key value) ...) v.Set(reflect.MakeMap(v.Type())) for !endList(lex) { lex.consume('(') key := reflect.New(v.Type().Key()).Elem() read(lex, key) value := reflect.New(v.Type().Elem()).Elem() read(lex, value) v.SetMapIndex(key, value) lex.consume(')') } default: panic(fmt.Sprintf("cannot decode list into %v", v.Type())) } } func endList(lex *lexer) bool { switch lex.token { case scanner.EOF: panic("end of file") case ')': return true } return false }最后,我们将解析器包装为导出的Unmarshal解码函数,隐藏了一些初始化和清理等边缘处理。内部解析器以panic的方式抛出错误,但是Unmarshal函数通过在defer语句调用recover函数来捕获内部panic(§5.10),然后返回一个对panic对应的错误信息。
// Unmarshal parses S-expression data and populates the variable // whose address is in the non-nil pointer out. func Unmarshal(data []byte, out interface{}) (err error) { lex := &lexer{scan: scanner.Scanner{Mode: scanner.GoTokens}} lex.scan.Init(bytes.NewReader(data)) lex.next() // get the first token defer func() { // NOTE: this is not an example of ideal error handling. if x := recover(); x != nil { err = fmt.Errorf("error at %s: %v", lex.scan.Position, x) } }() read(lex, reflect.ValueOf(out).Elem()) return nil }生产实现不应该对任何输入问题都用panic形式报告,而且应该报告一些错误相关的信息,例如出现错误输入的行号和位置等。尽管如此,我们希望通过这个例子来展示类似encoding/json等包底层代码的实现思路,以及如何使用反射机制来填充数据结构。
ch12.7 获取结构体字段标识
在4.5节我们使用构体成员标签用于设置对应JSON对应的名字。其中json成员标签让我们可以选择成员的名字和抑制零值成员的输出。在本节,我们将看到如何通过反射机制类获取成员标签。
对于一个web服务,大部分HTTP处理函数要做的第一件事情就是展开请求中的参数到本地变量中。我们定义了一个工具函数,叫params.Unpack,通过使用结构体成员标签机制来让HTTP处理函数解析请求参数更方便。
首先,我们看看如何使用它。下面的search函数是一个HTTP请求处理函数。它定义了一个匿名结构体类型的变量,用结构体的每个成员表示HTTP请求的参数。其中结构体成员标签指明了对于请求参数的名字,为了减少URL的长度这些参数名通常都是神秘的缩略词。Unpack将请求参数填充到合适的结构体成员中,这样我们可以方便地通过合适的类型类来访问这些参数。
gopl.io/ch12/search
import "gopl.io/ch12/params" // search implements the /search URL endpoint. func search(resp http.ResponseWriter, req *http.Request) { var data struct { Labels []string `http:"l"` MaxResults int `http:"max"` Exact bool `http:"x"` } data.MaxResults = 10 // set default if err := params.Unpack(req, &data); err != nil { http.Error(resp, err.Error(), http.StatusBadRequest) // 400 return } // ...rest of handler... fmt.Fprintf(resp, "Search: %+v\n", data) }下面的Unpack函数主要完成三件事情。第一,它调用req.ParseForm()来解析HTTP请求。然后,req.Form将包含所有的请求参数,不管HTTP客户端使用的是GET还是POST请求方法。
下一步,Unpack函数将构建每个结构体成员有效参数名字到成员变量的映射。如果结构体成员有成员标签的话,有效参数名字可能和实际的成员名字不相同。reflect.Type的Field方法将返回一个reflect.StructField,里面含有每个成员的名字、类型和可选的成员标签等信息。其中成员标签信息对应reflect.StructTag类型的字符串,并且提供了Get方法用于解析和根据特定key提取的子串,例如这里的http:"…“形式的子串。
gopl.io/ch12/params
// Unpack populates the fields of the struct pointed to by ptr // from the HTTP request parameters in req. func Unpack(req *http.Request, ptr interface{}) error { if err := req.ParseForm(); err != nil { return err } // Build map of fields keyed by effective name. fields := make(map[string]reflect.Value) v := reflect.ValueOf(ptr).Elem() // the struct variable for i := 0; i < v.NumField(); i++ { fieldInfo := v.Type().Field(i) // a reflect.StructField tag := fieldInfo.Tag // a reflect.StructTag name := tag.Get("http") if name == "" { name = strings.ToLower(fieldInfo.Name) } fields[name] = v.Field(i) } // Update struct field for each parameter in the request. for name, values := range req.Form { f := fields[name] if !f.IsValid() { continue // ignore unrecognized HTTP parameters } for _, value := range values { if f.Kind() == reflect.Slice { elem := reflect.New(f.Type().Elem()).Elem() if err := populate(elem, value); err != nil { return fmt.Errorf("%s: %v", name, err) } f.Set(reflect.Append(f, elem)) } else { if err := populate(f, value); err != nil { return fmt.Errorf("%s: %v", name, err) } } } } return nil }最后,Unpack遍历HTTP请求的name/valu参数键值对,并且根据更新相应的结构体成员。回想一下,同一个名字的参数可能出现多次。如果发生这种情况,并且对应的结构体成员是一个slice,那么就将所有的参数添加到slice中。其它情况,对应的成员值将被覆盖,只有最后一次出现的参数值才是起作用的。
populate函数小心用请求的字符串类型参数值来填充单一的成员v(或者是slice类型成员中的单一的元素)。目前,它仅支持字符串、有符号整数和布尔型。其中其它的类型将留做练习任务。
func populate(v reflect.Value, value string) error { switch v.Kind() { case reflect.String: v.SetString(value) case reflect.Int: i, err := strconv.ParseInt(value, 10, 64) if err != nil { return err } v.SetInt(i) case reflect.Bool: b, err := strconv.ParseBool(value) if err != nil { return err } v.SetBool(b) default: return fmt.Errorf("unsupported kind %s", v.Type()) } return nil }如果我们上上面的处理程序添加到一个web服务器,则可以产生以下的会话:
$ go build gopl.io/ch12/search $ ./search & $ ./fetch 'http://localhost:12345/search' Search: {Labels:[] MaxResults:10 Exact:false} $ ./fetch 'http://localhost:12345/search?l=golang&l=programming' Search: {Labels:[golang programming] MaxResults:10 Exact:false} $ ./fetch 'http://localhost:12345/search?l=golang&l=programming&max=100' Search: {Labels:[golang programming] MaxResults:100 Exact:false} $ ./fetch 'http://localhost:12345/search?x=true&l=golang&l=programming' Search: {Labels:[golang programming] MaxResults:10 Exact:true} $ ./fetch 'http://localhost:12345/search?q=hello&x=123' x: strconv.ParseBool: parsing "123": invalid syntax $ ./fetch 'http://localhost:12345/search?q=hello&max=lots' max: strconv.ParseInt: parsing "lots": invalid syntax
ch12.8 显示一个类型的方法集
我们的最后一个例子是使用reflect.Type来打印任意值的类型和枚举它的方法:
gopl.io/ch12/methods
// Print prints the method set of the value x. func Print(x interface{}) { v := reflect.ValueOf(x) t := v.Type() fmt.Printf("type %s\n", t) for i := 0; i < v.NumMethod(); i++ { methType := v.Method(i).Type() fmt.Printf("func (%s) %s%s\n", t, t.Method(i).Name, strings.TrimPrefix(methType.String(), "func")) } }reflect.Type和reflect.Value都提供了一个Method方法。每次t.Method(i)调用将一个reflect.Method的实例,对应一个用于描述一个方法的名称和类型的结构体。每次v.Method(i)方法调用都返回一个reflect.Value以表示对应的值(§6.4),也就是一个方法是帮到它的接收者的。使用reflect.Value.Call方法(我们这里没有演示),将可以调用一个Func类型的Value,但是这个例子中只用到了它的类型。
这是属于time.Duration和
*strings.Replacer两个类型的方法:methods.Print(time.Hour) // Output: // type time.Duration // func (time.Duration) Hours() float64 // func (time.Duration) Minutes() float64 // func (time.Duration) Nanoseconds() int64 // func (time.Duration) Seconds() float64 // func (time.Duration) String() string methods.Print(new(strings.Replacer)) // Output: // type *strings.Replacer // func (*strings.Replacer) Replace(string) string // func (*strings.Replacer) WriteString(io.Writer, string) (int, error)
ch12.9 几点忠告
虽然反射提供的API远多于我们讲到的,我们前面的例子主要是给出了一个方向,通过反射可以实现哪些功能。反射是一个强大并富有表达力的工具,但是它应该被小心地使用,原因有三。
第一个原因是,基于反射的代码是比较脆弱的。对于每一个会导致编译器报告类型错误的问题,在反射中都有与之相对应的误用问题,不同的是编译器会在构建时马上报告错误,而反射则是在真正运行到的时候才会抛出panic异常,可能是写完代码很久之后了,而且程序也可能运行了很长的时间。
以前面的readList函数(§12.6)为例,为了从输入读取字符串并填充int类型的变量而调用的reflect.Value.SetString方法可能导致panic异常。绝大多数使用反射的程序都有类似的风险,需要非常小心地检查每个reflect.Value的对应值的类型、是否可取地址,还有是否可以被修改等。
避免这种因反射而导致的脆弱性的问题的最好方法,是将所有的反射相关的使用控制在包的内部,如果可能的话避免在包的API中直接暴露reflect.Value类型,这样可以限制一些非法输入。如果无法做到这一点,在每个有风险的操作前指向额外的类型检查。以标准库中的代码为例,当fmt.Printf收到一个非法的操作数时,它并不会抛出panic异常,而是打印相关的错误信息。程序虽然还有BUG,但是会更加容易诊断。
fmt.Printf("%d %s\n", "hello", 42) // "%!d(string=hello) %!s(int=42)"反射同样降低了程序的安全性,还影响了自动化重构和分析工具的准确性,因为它们无法识别运行时才能确认的类型信息。
避免使用反射的第二个原因是,即使对应类型提供了相同文档,但是反射的操作不能做静态类型检查,而且大量反射的代码通常难以理解。总是需要小心翼翼地为每个导出的类型和其它接受interface{}或reflect.Value类型参数的函数维护说明文档。
第三个原因,基于反射的代码通常比正常的代码运行速度慢一到两个数量级。对于一个典型的项目,大部分函数的性能和程序的整体性能关系不大,所以当反射能使程序更加清晰的时候可以考虑使用。测试是一个特别适合使用反射的场景,因为每个测试的数据集都很小。但是对于性能关键路径的函数,最好避免使用反射。
ch13 底层编程
ch13.0 简介
- Go语言的设计包含了诸多安全策略,限制了可能导致程序运行出错的用法。编译时类型检查可以发现大多数类型不匹配的操作,例如两个字符串做减法的错误。字符串、map、slice和chan等所有的内置类型,都有严格的类型转换规则。
- 对于无法静态检测到的错误,例如数组访问越界或使用空指针,运行时动态检测可以保证程序在遇到问题的时候立即终止并打印相关的错误信息。自动内存管理(垃圾内存自动回收)可以消除大部分野指针和内存泄漏相关的问题。
- Go语言的实现刻意隐藏了很多底层细节。我们无法知道一个结构体真实的内存布局,也无法获取一个运行时函数对应的机器码,也无法知道当前的goroutine是运行在哪个操作系统线程之上。事实上,Go语言的调度器会自己决定是否需要将某个goroutine从一个操作系统线程转移到另一个操作系统线程。一个指向变量的指针也并没有展示变量真实的地址。因为垃圾回收器可能会根据需要移动变量的内存位置,当然变量对应的地址也会被自动更新。
- 总的来说,Go语言的这些特性使得Go程序相比较低级的C语言来说更容易预测和理解,程序也不容易崩溃。通过隐藏底层的实现细节,也使得Go语言编写的程序具有高度的可移植性,因为语言的语义在很大程度上是独立于任何编译器实现、操作系统和CPU系统结构的(当然也不是完全绝对独立:例如int等类型就依赖于CPU机器字的大小,某些表达式求值的具体顺序,还有编译器实现的一些额外的限制等)。
- 有时候我们可能会放弃使用部分语言特性而优先选择具有更好性能的方法,例如需要与其他语言编写的库进行互操作,或者用纯Go语言无法实现的某些函数。
- 在本章,我们将展示如何使用unsafe包来摆脱Go语言规则带来的限制,讲述如何创建C语言函数库的绑定,以及如何进行系统调用。
- 本章提供的方法不应该轻易使用(译注:属于黑魔法,虽然功能很强大,但是也容易误伤到自己)。如果没有处理好细节,它们可能导致各种不可预测的并且隐晦的错误,甚至连有经验的C语言程序员也无法理解这些错误。使用unsafe包的同时也放弃了Go语言保证与未来版本的兼容性的承诺,因为它必然会有意无意中使用很多非公开的实现细节,而这些实现的细节在未来的Go语言中很可能会被改变。
- 要注意的是,unsafe包是一个采用特殊方式实现的包。虽然它可以和普通包一样的导入和使用,但它实际上是由编译器实现的。它提供了一些访问语言内部特性的方法,特别是内存布局相关的细节。将这些特性封装到一个独立的包中,是为在极少数情况下需要使用的时候,同时引起人们的注意(译注:因为看包的名字就知道使用unsafe包是不安全的)。此外,有一些环境因为安全的因素可能限制这个包的使用。
- 不过unsafe包被广泛地用于比较低级的包,例如runtime、os、syscall还有net包等,因为它们需要和操作系统密切配合,但是对于普通的程序一般是不需要使用unsafe包的。
ch13.1 unsafe.Sizeof, Alignof 和 Offsetof
unsafe.Sizeof函数返回操作数在内存中的字节大小,参数可以是任意类型的表达式,但是它并不会对表达式进行求值。一个Sizeof函数调用是一个对应uintptr类型的常量表达式,因此返回的结果可以用作数组类型的长度大小,或者用作计算其他的常量。
import "unsafe" fmt.Println(unsafe.Sizeof(float64(0))) // "8"Sizeof函数返回的大小只包括数据结构中固定的部分,例如字符串对应结构体中的指针和字符串长度部分,但是并不包含指针指向的字符串的内容。Go语言中非聚合类型通常有一个固定的大小,尽管在不同工具链下生成的实际大小可能会有所不同。考虑到可移植性,引用类型或包含引用类型的大小在32位平台上是4个字节,在64位平台上是8个字节。
计算机在加载和保存数据时,如果内存地址合理地对齐的将会更有效率。例如2字节大小的int16类型的变量地址应该是偶数,一个4字节大小的rune类型变量的地址应该是4的倍数,一个8字节大小的float64、uint64或64-bit指针类型变量的地址应该是8字节对齐的。但是对于再大的地址对齐倍数则是不需要的,即使是complex128等较大的数据类型最多也只是8字节对齐。
由于地址对齐这个因素,一个聚合类型(结构体或数组)的大小至少是所有字段或元素大小的总和,或者更大因为可能存在内存空洞。内存空洞是编译器自动添加的没有被使用的内存空间,用于保证后面每个字段或元素的地址相对于结构或数组的开始地址能够合理地对齐(译注:内存空洞可能会存在一些随机数据,可能会对用unsafe包直接操作内存的处理产生影响)。
类型 大小 bool1个字节 intN, uintN, floatN, complexNN/8个字节(例如float64是8个字节) int, uint, uintptr1个机器字 *T1个机器字 string2个机器字(data、len) []T3个机器字(data、len、cap) map1个机器字 func1个机器字 chan1个机器字 interface2个机器字(type、value) Go语言的规范并没有要求一个字段的声明顺序和内存中的顺序是一致的,所以理论上一个编译器可以随意地重新排列每个字段的内存位置,虽然在写作本书的时候编译器还没有这么做。下面的三个结构体虽然有着相同的字段,但是第一种写法比另外的两个需要多50%的内存。
// 64-bit 32-bit struct{ bool; float64; int16 } // 3 words 4words struct{ float64; int16; bool } // 2 words 3words struct{ bool; int16; float64 } // 2 words 3words关于内存地址对齐算法的细节超出了本书的范围,也不是每一个结构体都需要担心这个问题,不过有效的包装可以使数据结构更加紧凑(译注:未来的Go语言编译器应该会默认优化结构体的顺序,当然应该也能够指定具体的内存布局,相同讨论请参考 Issue10014 ),内存使用率和性能都可能会受益。
unsafe.Alignof函数返回对应参数的类型需要对齐的倍数。和 Sizeof 类似, Alignof 也是返回一个常量表达式,对应一个常量。通常情况下布尔和数字类型需要对齐到它们本身的大小(最多8个字节),其它的类型对齐到机器字大小。unsafe.Offsetof函数的参数必须是一个字段x.f,然后返回f字段相对于x起始地址的偏移量,包括可能的空洞。图 13.1 显示了一个结构体变量 x 以及其在32位和64位机器上的典型的内存。灰色区域是空洞。
var x struct { a bool b int16 c []int }32位系统:
Sizeof(x) = 16 Alignof(x) = 4 Sizeof(x.a) = 1 Alignof(x.a) = 1 Offsetof(x.a) = 0 Sizeof(x.b) = 2 Alignof(x.b) = 2 Offsetof(x.b) = 2 Sizeof(x.c) = 12 Alignof(x.c) = 4 Offsetof(x.c) = 464位系统:
Sizeof(x) = 32 Alignof(x) = 8 Sizeof(x.a) = 1 Alignof(x.a) = 1 Offsetof(x.a) = 0 Sizeof(x.b) = 2 Alignof(x.b) = 2 Offsetof(x.b) = 2 Sizeof(x.c) = 24 Alignof(x.c) = 8 Offsetof(x.c) = 8
虽然这几个函数在不安全的unsafe包,但是这几个函数调用并不是真的不安全,特别在需要优化内存空间时它们返回的结果对于理解原生的内存布局很有帮助。
ch13.2 unsafe.Pointer
大多数指针类型会写成
*T,表示是“一个指向T类型变量的指针”。unsafe.Pointer是特别定义的一种指针类型(译注:类似C语言中的void*类型的指针),它可以包含任意类型变量的地址。当然,我们不可以直接通过*p来获取unsafe.Pointer指针指向的真实变量的值,因为我们并不知道变量的具体类型。和普通指针一样,unsafe.Pointer指针也是可以比较的,并且支持和nil常量比较判断是否为空指针。一个普通的
*T类型指针可以被转化为unsafe.Pointer类型指针,并且一个unsafe.Pointer类型指针也可以被转回普通的指针,被转回普通的指针类型并不需要和原始的*T类型相同。通过将*float64类型指针转化为*uint64类型指针,我们可以查看一个浮点数变量的位模式。package math func Float64bits(f float64) uint64 { return *(*uint64)(unsafe.Pointer(&f)) } fmt.Printf("%[[016x]]\n", Float64bits(1.0)) // "0x3ff0000000000000"通过转为新类型指针,我们可以更新浮点数的位模式。通过位模式操作浮点数是可以的,但是更重要的意义是指针转换语法让我们可以在不破坏类型系统的前提下向内存写入任意的值。
一个unsafe.Pointer指针也可以被转化为uintptr类型,然后保存到指针型数值变量中(译注:这只是和当前指针相同的一个数字值,并不是一个指针),然后用以做必要的指针数值运算。(第三章内容,uintptr是一个无符号的整型数,足以保存一个地址)这种转换虽然也是可逆的,但是将uintptr转为unsafe.Pointer指针可能会破坏类型系统,因为并不是所有的数字都是有效的内存地址。
许多将unsafe.Pointer指针转为原生数字,然后再转回为unsafe.Pointer类型指针的操作也是不安全的。比如下面的例子需要将变量x的地址加上b字段地址偏移量转化为
*int16类型指针,然后通过该指针更新x.b:gopl.io/ch13/unsafeptr
var x struct { a bool b int16 c []int } // 和 pb := &x.b 等价 pb := (*int16)(unsafe.Pointer( uintptr(unsafe.Pointer(&x)) + unsafe.Offsetof(x.b))) *pb = 42 fmt.Println(x.b) // "42"上面的写法尽管很繁琐,但在这里并不是一件坏事,因为这些功能应该很谨慎地使用。不要试图引入一个uintptr类型的临时变量,因为它可能会破坏代码的安全性(译注:这是真正可以体会unsafe包为何不安全的例子)。下面段代码是错误的:
// NOTE: subtly incorrect! tmp := uintptr(unsafe.Pointer(&x)) + unsafe.Offsetof(x.b) pb := (*int16)(unsafe.Pointer(tmp)) *pb = 42产生错误的原因很微妙。有时候垃圾回收器会移动一些变量以降低内存碎片等问题。这类垃圾回收器被称为移动GC。当一个变量被移动,所有的保存该变量旧地址的指针必须同时被更新为变量移动后的新地址。从垃圾收集器的视角来看,一个unsafe.Pointer是一个指向变量的指针,因此当变量被移动时对应的指针也必须被更新;但是uintptr类型的临时变量只是一个普通的数字,所以其值不应该被改变。上面错误的代码因为引入一个非指针的临时变量tmp,导致垃圾收集器无法正确识别这个是一个指向变量x的指针。当第二个语句执行时,变量x可能已经被转移,这时候临时变量tmp也就不再是现在的
&x.b地址。第三个向之前无效地址空间的赋值语句将彻底摧毁整个程序!还有很多类似原因导致的错误。例如这条语句:
pT := uintptr(unsafe.Pointer(new(T))) // 提示: 错误!这里并没有指针引用
new新创建的变量,因此该语句执行完成之后,垃圾收集器有权马上回收其内存空间,所以返回的pT将是无效的地址。虽然目前的Go语言实现还没有使用移动GC(译注:未来可能实现),但这不该是编写错误代码侥幸的理由:当前的Go语言实现已经有移动变量的场景。在5.2节我们提到goroutine的栈是根据需要动态增长的。当发生栈动态增长的时候,原来栈中的所有变量可能需要被移动到新的更大的栈中,所以我们并不能确保变量的地址在整个使用周期内是不变的。
在编写本文时,还没有清晰的原则来指引Go程序员,什么样的unsafe.Pointer和uintptr的转换是不安全的(参考 Issue7192 ). 译注: 该问题已经关闭),因此我们强烈建议按照最坏的方式处理。将所有包含变量地址的uintptr类型变量当作BUG处理,同时减少不必要的unsafe.Pointer类型到uintptr类型的转换。在第一个例子中,有三个转换——字段偏移量到uintptr的转换和转回unsafe.Pointer类型的操作——所有的转换全在一个表达式完成。
当调用一个库函数,并且返回的是uintptr类型地址时(译注:普通方法实现的函数尽量不要返回该类型。下面例子是reflect包的函数,reflect包和unsafe包一样都是采用特殊技术实现的,编译器可能给它们开了后门),比如下面反射包中的相关函数,返回的结果应该立即转换为unsafe.Pointer以确保指针指向的是相同的变量。
package reflect func (Value) Pointer() uintptr func (Value) UnsafeAddr() uintptr func (Value) InterfaceData() [2]uintptr // (index 1)
ch13.3 示例:深度相等判断
来自reflect包的DeepEqual函数可以对两个值进行深度相等判断。DeepEqual函数使用内建的比较操作符对基础类型进行相等判断,对于复合类型则递归该变量的每个基础类型然后做类似的比较判断。因为它可以工作在任意的类型上,甚至对于一些不支持操作运算符的类型也可以工作,因此在一些测试代码中广泛地使用该函数。比如下面的代码是用DeepEqual函数比较两个字符串slice是否相等。
func TestSplit(t *testing.T) { got := strings.Split("a:b:c", ":") want := []string{"a", "b", "c"}; if !reflect.DeepEqual(got, want) { /* ... */ } }尽管DeepEqual函数很方便,而且可以支持任意的数据类型,但是它也有不足之处。例如,它将一个nil值的map和非nil值但是空的map视作不相等,同样nil值的slice 和非nil但是空的slice也视作不相等。
var a, b []string = nil, []string{} fmt.Println(reflect.DeepEqual(a, b)) // "false" var c, d map[string]int = nil, make(map[string]int) fmt.Println(reflect.DeepEqual(c, d)) // "false"我们希望在这里实现一个自己的Equal函数,用于比较类型的值。和DeepEqual函数类似的地方是它也是基于slice和map的每个元素进行递归比较,不同之处是它将nil值的slice(map类似)和非nil值但是空的slice视作相等的值。基础部分的比较可以基于reflect包完成,和12.3章的Display函数的实现方法类似。同样,我们也定义了一个内部函数equal,用于内部的递归比较。读者目前不用关心seen参数的具体含义。对于每一对需要比较的x和y,equal函数首先检测它们是否都有效(或都无效),然后检测它们是否是相同的类型。剩下的部分是一个巨大的switch分支,用于相同基础类型的元素比较。因为页面空间的限制,我们省略了一些相似的分支。
gopl.io/ch13/equal
func equal(x, y reflect.Value, seen map[comparison]bool) bool { if !x.IsValid() || !y.IsValid() { return x.IsValid() == y.IsValid() } if x.Type() != y.Type() { return false } // ...cycle check omitted (shown later)... switch x.Kind() { case reflect.Bool: return x.Bool() == y.Bool() case reflect.String: return x.String() == y.String() // ...numeric cases omitted for brevity... case reflect.Chan, reflect.UnsafePointer, reflect.Func: return x.Pointer() == y.Pointer() case reflect.Ptr, reflect.Interface: return equal(x.Elem(), y.Elem(), seen) case reflect.Array, reflect.Slice: if x.Len() != y.Len() { return false } for i := 0; i < x.Len(); i++ { if !equal(x.Index(i), y.Index(i), seen) { return false } } return true // ...struct and map cases omitted for brevity... } panic("unreachable") }和前面的建议一样,我们并不公开reflect包相关的接口,所以导出的函数需要在内部自己将变量转为reflect.Value类型。
// Equal reports whether x and y are deeply equal. func Equal(x, y interface{}) bool { seen := make(map[comparison]bool) return equal(reflect.ValueOf(x), reflect.ValueOf(y), seen) } type comparison struct { x, y unsafe.Pointer treflect.Type }为了确保算法对于有环的数据结构也能正常退出,我们必须记录每次已经比较的变量,从而避免进入第二次的比较。Equal函数分配了一组用于比较的结构体,包含每对比较对象的地址(unsafe.Pointer形式保存)和类型。我们要记录类型的原因是,有些不同的变量可能对应相同的地址。例如,如果x和y都是数组类型,那么x和x[0]将对应相同的地址,y和y[0]也是对应相同的地址,这可以用于区分x与y之间的比较或x[0]与y[0]之间的比较是否进行过了。
// cycle check if x.CanAddr() && y.CanAddr() { xptr := unsafe.Pointer(x.UnsafeAddr()) yptr := unsafe.Pointer(y.UnsafeAddr()) if xptr == yptr { return true // identical references } c := comparison{xptr, yptr, x.Type()} if seen[c] { return true // already seen } seen[c] = true }这是Equal函数用法的例子:
fmt.Println(Equal([]int{1, 2, 3}, []int{1, 2, 3})) // "true" fmt.Println(Equal([]string{"foo"}, []string{"bar"})) // "false" fmt.Println(Equal([]string(nil), []string{})) // "true" fmt.Println(Equal(map[string]int(nil), map[string]int{})) // "true"Equal函数甚至可以处理类似12.3章中导致Display陷入死循环的带有环的数据。
// Circular linked lists a -> b -> a and c -> c. type link struct { value string tail *link } a, b, c := &link{value: "a"}, &link{value: "b"}, &link{value: "c"} a.tail, b.tail, c.tail = b, a, c fmt.Println(Equal(a, a)) // "true" fmt.Println(Equal(b, b)) // "true" fmt.Println(Equal(c, c)) // "true" fmt.Println(Equal(a, b)) // "false" fmt.Println(Equal(a, c)) // "false"
ch13.4 通过cgo调用C代码
Go程序可能会遇到要访问C语言的某些硬件驱动函数的场景,或者是从一个C++语言实现的嵌入式数据库查询记录的场景,或者是使用Fortran语言实现的一些线性代数库的场景。C语言作为一个通用语言,很多库会选择提供一个C兼容的API,然后用其他不同的编程语言实现(译者:Go语言需要也应该拥抱这些巨大的代码遗产)。
在本节中,我们将构建一个简易的数据压缩程序,使用了一个Go语言自带的叫cgo的用于支援C语言函数调用的工具。这类工具一般被称为 foreign-function interfaces (简称ffi),并且在类似工具中cgo也不是唯一的。SWIG(http://swig.org)是另一个类似的且被广泛使用的工具,SWIG提供了很多复杂特性以支援C++的特性,但SWIG并不是我们要讨论的主题。
在标准库的
compress/...子包有很多流行的压缩算法的编码和解码实现,包括流行的LZW压缩算法(Unix的compress命令用的算法)和DEFLATE压缩算法(GNU gzip命令用的算法)。这些包的API的细节虽然有些差异,但是它们都提供了针对 io.Writer类型输出的压缩接口和提供了针对io.Reader类型输入的解压缩接口。例如:package gzip // compress/gzip func NewWriter(w io.Writer) io.WriteCloser func NewReader(r io.Reader) (io.ReadCloser, error)bzip2压缩算法,是基于优雅的Burrows-Wheeler变换算法,运行速度比gzip要慢,但是可以提供更高的压缩比。标准库的compress/bzip2包目前还没有提供bzip2压缩算法的实现。完全从头开始实现一个压缩算法是一件繁琐的工作,而且 http://bzip.org 已经有现成的libbzip2的开源实现,不仅文档齐全而且性能又好。
如果是比较小的C语言库,我们完全可以用纯Go语言重新实现一遍。如果我们对性能也没有特殊要求的话,我们还可以用os/exec包的方法将C编写的应用程序作为一个子进程运行。只有当你需要使用复杂而且性能更高的底层C接口时,就是使用cgo的场景了(译注:用os/exec包调用子进程的方法会导致程序运行时依赖那个应用程序)。下面我们将通过一个例子讲述cgo的具体用法。
译注:本章采用的代码都是最新的。因为之前已经出版的书中包含的代码只能在Go1.5之前使用。从Go1.6开始,Go语言已经明确规定了哪些Go语言指针可以直接传入C语言函数。新代码重点是增加了bz2alloc和bz2free的两个函数,用于bz_stream对象空间的申请和释放操作。下面是新代码中增加的注释,说明这个问题:
// The version of this program that appeared in the first and second // printings did not comply with the proposed rules for passing // pointers between Go and C, described here: // https://github.com/golang/proposal/blob/master/design/12416-cgo-pointers.md // // The rules forbid a C function like bz2compress from storing 'in' // and 'out' (pointers to variables allocated by Go) into the Go // variable 's', even temporarily. // // The version below, which appears in the third printing, has been // corrected. To comply with the rules, the bz_stream variable must // be allocated by C code. We have introduced two C functions, // bz2alloc and bz2free, to allocate and free instances of the // bz_stream type. Also, we have changed bz2compress so that before // it returns, it clears the fields of the bz_stream that contain // pointers to Go variables.要使用libbzip2,我们需要先构建一个bz_stream结构体,用于保持输入和输出缓存。然后有三个函数:BZ2_bzCompressInit用于初始化缓存,BZ2_bzCompress用于将输入缓存的数据压缩到输出缓存,BZ2_bzCompressEnd用于释放不需要的缓存。(目前不要担心包的具体结构,这个例子的目的就是演示各个部分如何组合在一起的。)
我们可以在Go代码中直接调用BZ2_bzCompressInit和BZ2_bzCompressEnd,但是对于BZ2_bzCompress,我们将定义一个C语言的包装函数,用它完成真正的工作。下面是C代码,对应一个独立的文件。
gopl.io/ch13/bzip
/* This file is gopl.io/ch13/bzip/bzip2.c, */ /* a simple wrapper for libbzip2 suitable for cgo. */ [[include]] <bzlib.h> int bz2compress(bz_stream *s, int action, char *in, unsigned *inlen, char *out, unsigned *outlen) { s->next_in = in; s->avail_in = *inlen; s->next_out = out; s->avail_out = *outlen; int r = BZ2_bzCompress(s, action); *inlen -= s->avail_in; *outlen -= s->avail_out; s->next_in = s->next_out = NULL; return r; }现在让我们转到Go语言部分,第一部分如下所示。其中
import "C"的语句是比较特别的。其实并没有一个叫C的包,但是这行语句会让Go编译程序在编译之前先运行cgo工具。// Package bzip provides a writer that uses bzip2 compression (bzip.org). package bzip /* [[cgo]] CFLAGS: -I/usr/include [[cgo]] LDFLAGS: -L/usr/lib -lbz2 [[include]] <bzlib.h> [[include]] <stdlib.h> bz_stream* bz2alloc() { return calloc(1, sizeof(bz_stream)); } int bz2compress(bz_stream *s, int action, char *in, unsigned *inlen, char *out, unsigned *outlen); void bz2free(bz_stream* s) { free(s); } */ import "C" import ( "io" "unsafe" ) type writer struct { w io.Writer // underlying output stream stream *C.bz_stream outbuf [64 * 1024]byte } // NewWriter returns a writer for bzip2-compressed streams. func NewWriter(out io.Writer) io.WriteCloser { const blockSize = 9 const verbosity = 0 const workFactor = 30 w := &writer{w: out, stream: C.bz2alloc()} C.BZ2_bzCompressInit(w.stream, blockSize, verbosity, workFactor) return w }在预处理过程中,cgo工具生成一个临时包用于包含所有在Go语言中访问的C语言的函数或类型。例如C.bz_stream和C.BZ2_bzCompressInit。cgo工具通过以某种特殊的方式调用本地的C编译器来发现在Go源文件导入声明前的注释中包含的C头文件中的内容(译注:
import "C"语句前紧挨着的注释是对应cgo的特殊语法,对应必要的构建参数选项和C语言代码)。在cgo注释中还可以包含[[cgo指令,用于给C语言工具链指定特殊的参数。例如CFLAGS和LDFLAGS分别对应传给C语言编译器的编译参数和链接器参数,使它们可以从特定目录找到bzlib]].h头文件和libbz2.a库文件。这个例子假设你已经在/usr目录成功安装了bzip2库。如果bzip2库是安装在不同的位置,你需要更新这些参数(译注:这里有一个从纯C代码生成的cgo绑定,不依赖bzip2静态库和操作系统的具体环境,具体请访问 https://github.com/chai2010/bzip2 )。
NewWriter函数通过调用C语言的BZ2_bzCompressInit函数来初始化stream中的缓存。在writer结构中还包括了另一个buffer,用于输出缓存。
下面是Write方法的实现,返回成功压缩数据的大小,主体是一个循环中调用C语言的bz2compress函数实现的。从代码可以看到,Go程序可以访问C语言的bz_stream、char和uint类型,还可以访问bz2compress等函数,甚至可以访问C语言中像BZ_RUN那样的宏定义,全部都是以C.x语法访问。其中C.uint类型和Go语言的uint类型并不相同,即使它们具有相同的大小也是不同的类型。
func (w *writer) Write(data []byte) (int, error) { if w.stream == nil { panic("closed") } var total int // uncompressed bytes written for len(data) > 0 { inlen, outlen := C.uint(len(data)), C.uint(cap(w.outbuf)) C.bz2compress(w.stream, C.BZ_RUN, (*C.char)(unsafe.Pointer(&data[0])), &inlen, (*C.char)(unsafe.Pointer(&w.outbuf)), &outlen) total += int(inlen) data = data[inlen:] if _, err := w.w.Write(w.outbuf[:outlen]); err != nil { return total, err } } return total, nil }在循环的每次迭代中,向bz2compress传入数据的地址和剩余部分的长度,还有输出缓存w.outbuf的地址和容量。这两个长度信息通过它们的地址传入而不是值传入,因为bz2compress函数可能会根据已经压缩的数据和压缩后数据的大小来更新这两个值。每个块压缩后的数据被写入到底层的io.Writer。
Close方法和Write方法有着类似的结构,通过一个循环将剩余的压缩数据刷新到输出缓存。
// Close flushes the compressed data and closes the stream. // It does not close the underlying io.Writer. func (w *writer) Close() error { if w.stream == nil { panic("closed") } defer func() { C.BZ2_bzCompressEnd(w.stream) C.bz2free(w.stream) w.stream = nil }() for { inlen, outlen := C.uint(0), C.uint(cap(w.outbuf)) r := C.bz2compress(w.stream, C.BZ_FINISH, nil, &inlen, (*C.char)(unsafe.Pointer(&w.outbuf)), &outlen) if _, err := w.w.Write(w.outbuf[:outlen]); err != nil { return err } if r == C.BZ_STREAM_END { return nil } } }压缩完成后,Close方法用了defer函数确保函数退出前调用C.BZ2_bzCompressEnd和C.bz2free释放相关的C语言运行时资源。此刻w.stream指针将不再有效,我们将它设置为nil以保证安全,然后在每个方法中增加了nil检测,以防止用户在关闭后依然错误使用相关方法。
上面的实现中,不仅仅写是非并发安全的,甚至并发调用Close和Write方法也可能导致程序的的崩溃。修复这个问题是练习13.3的内容。
下面的bzipper程序,使用我们自己包实现的bzip2压缩命令。它的行为和许多Unix系统的bzip2命令类似。
gopl.io/ch13/bzipper
// Bzipper reads input, bzip2-compresses it, and writes it out. package main import ( "io" "log" "os" "gopl.io/ch13/bzip" ) func main() { w := bzip.NewWriter(os.Stdout) if _, err := io.Copy(w, os.Stdin); err != nil { log.Fatalf("bzipper: %v\n", err) } if err := w.Close(); err != nil { log.Fatalf("bzipper: close: %v\n", err) } }在上面的场景中,我们使用bzipper压缩了/usr/share/dict/words系统自带的词典,从938,848字节压缩到335,405字节。大约是原始数据大小的三分之一。然后使用系统自带的bunzip2命令进行解压。压缩前后文件的SHA256哈希码是相同了,这也说明了我们的压缩工具是正确的。(如果你的系统没有sha256sum命令,那么请先按照练习4.2实现一个类似的工具)
$ go build gopl.io/ch13/bzipper $ wc -c < /usr/share/dict/words 938848 $ sha256sum < /usr/share/dict/words 126a4ef38493313edc50b86f90dfdaf7c59ec6c948451eac228f2f3a8ab1a6ed - $ ./bzipper < /usr/share/dict/words | wc -c 335405 $ ./bzipper < /usr/share/dict/words | bunzip2 | sha256sum 126a4ef38493313edc50b86f90dfdaf7c59ec6c948451eac228f2f3a8ab1a6ed -我们演示了如何将一个C语言库链接到Go语言程序。相反,将Go编译为静态库然后链接到C程序,或者将Go程序编译为动态库然后在C程序中动态加载也都是可行的(译注:在Go1.5中,Windows系统的Go语言实现并不支持生成C语言动态库或静态库的特性。不过好消息是,目前已经有人在尝试解决这个问题,具体请访问 Issue11058 )。这里我们只展示的cgo很小的一些方面,更多的关于内存管理、指针、回调函数、中断信号处理、字符串、errno处理、终结器,以及goroutines和系统线程的关系等,有很多细节可以讨论。特别是如何将Go语言的指针传入C函数的规则也是异常复杂的(译注:简单来说,要传入C函数的Go指针指向的数据本身不能包含指针或其他引用类型;并且C函数在返回后不能继续持有Go指针;并且在C函数返回之前,Go指针是被锁定的,不能导致对应指针数据被移动或栈的调整),部分的原因在13.2节有讨论到,但是在Go1.5中还没有被明确(译注:Go1.6将会明确cgo中的指针使用规则)。如果要进一步阅读,可以从 https://golang.org/cmd/cgo 开始。
ch13.5 几点忠告
- 我们在前一章结尾的时候,我们警告要谨慎使用reflect包。那些警告同样适用于本章的unsafe包。
- 高级语言使得程序员不用再关心真正运行程序的指令细节,同时也不再需要关注许多如内存布局之类的实现细节。因为高级语言这个绝缘的抽象层,我们可以编写安全健壮的,并且可以运行在不同操作系统上的具有高度可移植性的程序。
- 但是unsafe包,它让程序员可以透过这个绝缘的抽象层直接使用一些必要的功能,虽然可能是为了获得更好的性能。但是代价就是牺牲了可移植性和程序安全,因此使用unsafe包是一个危险的行为。我们对何时以及如何使用unsafe包的建议和我们在11.5节提到的Knuth对过早优化的建议类似。大多数Go程序员可能永远不会需要直接使用unsafe包。当然,也永远都会有一些需要使用unsafe包实现会更简单的场景。如果确实认为使用unsafe包是最理想的方式,那么应该尽可能将它限制在较小的范围,这样其它代码就可以忽略unsafe的影响。
- 现在,赶紧将最后两章抛入脑后吧。编写一些实实在在的应用是真理。请远离reflect和unsafe包,除非你确实需要它们。
- 最后,用Go快乐地编程。我们希望你能像我们一样喜欢Go语言。
XMind: ZEN - Trial Version