包和文件

Go 语言中的包和其他语言的库或模块的概念类似,目的都是为了支持模块化、封装、单独编译和代码重用。一个包的源代码保存在一个或多个以 .go 为文件后缀名的源文件中,通常一个包所在目录路径的后缀是包的导入路径;例如包 gopl.io/ch1/helloworld 对应的目录路径是 $GOPATH/src/gopl.io/ch1/helloworld 。

每个包都对应一个独立的名字空间。例如,在 image 包中的 Decode 函数和在 unicode/utf16 包中的 Decode 函数是不同的。要在外部引用该函数,必须显式使用 image.Decode 或 utf16.Decode 形式访问。

包还可以让我们通过控制哪些名字是外部可见的来隐藏内部实现信息。在 Go 语言中,一个简单的规则是:如果一个名字是大写字母开头的,那么该名字是导出的(译注:因为汉字不区分大小写,因此汉字开头的名字是没有导出的)。

为了演示包基本的用法,先假设我们的温度转换软件已经很流行,我们希望到 Go 语言社区也能使用这个包。我们该如何做呢?

让我们创建一个名为 gopl.io/ch2/tempconv 的包,这是前面例子的一个改进版本。(这里我们没有按照惯例按顺序对例子进行编号,因此包路径看起来更像一个真实的包)包代码存储在两个源文件中,用来演示如何在一个源文件声明然后在其他的源文件访问;虽然在现实中,这样小的包一般只需要一个文件。

我们把变量的声明、对应的常量,还有方法都放到 tempconv.go 源文件中:

ch2/tempconv
Unresolved include directive in modules/ROOT/pages/ch2/ch2-06.adoc - include::example$/ch2/tempconv/tempconv.go[]

转换函数则放在另一个 conv.go 源文件中:

ch2/tempconv
Unresolved include directive in modules/ROOT/pages/ch2/ch2-06.adoc - include::example$/ch2/tempconv/conv.go[]

每个源文件都是以包的声明语句开始,用来指明包的名字。当包被导入的时候,包内的成员将通过类似 tempconv.CToF 的形式访问。而包级别的名字,例如在一个文件声明的类型和常量,在同一个包的其他源文件也是可以直接访问的,就好像所有代码都在一个文件一样。要注意的是 tempconv.go 源文件导入了 fmt 包,但是 conv.go 源文件并没有,因为这个源文件中的代码并没有用到 fmt 包。

因为包级别的常量名都是以大写字母开头,它们可以像 tempconv.AbsoluteZeroC 这样被外部代码访问:

fmt.Printf("Brrrr! %v\n", tempconv.AbsoluteZeroC) // "Brrrr! -273.15°C"

要将摄氏温度转换为华氏温度,需要先用 import 语句导入 gopl.io/ch2/tempconv 包,然后就可以使用下面的代码进行转换了:

fmt.Println(tempconv.CToF(tempconv.BoilingC)) // "212°F"

在每个源文件的包声明前紧跟着的注释是包注释(§10.7.4)。通常,包注释的第一句应该先是包的功能概要说明。一个包通常只有一个源文件有包注释(译注:如果有多个包注释,目前的文档工具会根据源文件名的先后顺序将它们链接为一个包注释)。如果包注释很大,通常会放到一个独立的 doc.go 文件中。


练习 2.1: 向 tempconv 包添加类型、常量和函数用来处理 Kelvin 绝对温度的转换,Kelvin 绝对零度是 −273.15°C,Kelvin 绝对温度 1K 和摄氏度 1°C 的单位间隔是一样的。

导入包

在 Go 语言程序中,每个包都有一个全局唯一的导入路径。导入语句中类似 "gopl.io/ch2/tempconv" 的字符串对应包的导入路径。Go 语言的规范并没有定义这些字符串的具体含义或包来自哪里,它们是由构建工具来解释的。当使用 Go 语言自带的 go 工具箱时(第十章),一个导入路径代表一个目录中的一个或多个 Go 源文件。

除了包的导入路径,每个包还有一个包名,包名一般是短小的名字(并不要求包名是唯一的),包名在包的声明处指定。按照惯例,一个包的名字和包的导入路径的最后一个字段相同,例如 gopl.io/ch2/tempconv 包的名字一般是 tempconv 。

要使用 gopl.io/ch2/tempconv 包,需要先导入:

ch2/cf
Unresolved include directive in modules/ROOT/pages/ch2/ch2-06.adoc - include::example$/ch2/cf/main.go[]

导入语句将导入的包绑定到一个短小的名字,然后通过该短小的名字就可以引用包中导出的全部内容。上面的导入声明将允许我们以 tempconv.CToF 的形式来访问 gopl.io/ch2/tempconv 包中的内容。在默认情况下,导入的包绑定到 tempconv 名字(译注:指包声明语句指定的名字),但是我们也可以绑定到另一个名称,以避免名字冲突(§10.4)。

cf 程序将命令行输入的一个温度在 Celsius 和 Fahrenheit 温度单位之间转换:

$ go build gopl.io/ch2/cf
$ ./cf 32
32°F = 0°C, 32°C = 89.6°F
$ ./cf 212
212°F = 100°C, 212°C = 413.6°F
$ ./cf -40
-40°F = -40°C, -40°C = -40°F

如果导入了一个包,但是又没有使用该包将被当作一个编译错误处理。这种强制规则可以有效减少不必要的依赖,虽然在调试期间可能会让人讨厌,因为删除一个类似 log.Print("got here!") 的打印语句可能导致需要同时删除 log 包导入声明,否则,编译器将会发出一个错误。在这种情况下,我们需要将不必要的导入删除或注释掉。

不过有更好的解决方案,我们可以使用 golang.org/x/tools/cmd/goimports 导入工具,它可以根据需要自动添加或删除导入的包;许多编辑器都可以集成 goimports 工具,然后在保存文件的时候自动运行。类似的还有 gofmt 工具,可以用来格式化 Go 源文件。


练习 2.2: 写一个通用的单位转换程序,用类似 cf 程序的方式从命令行读取参数,如果缺省的话则是从标准输入读取参数,然后做类似 Celsius 和 Fahrenheit 的单位转换,长度单位可以对应英尺和米,重量单位可以对应磅和公斤等。

包的初始化

包的初始化首先是解决包级变量的依赖顺序,然后按照包级变量声明出现的顺序依次初始化:

var a = b + c // a 第三个初始化, 为 3
var b = f()   // b 第二个初始化, 为 2, 通过调用 f (依赖c)
var c = 1     // c 第一个初始化, 为 1

func f() int { return c + 1 }

如果包中含有多个 .go 源文件,它们将按照发给编译器的顺序进行初始化,Go 语言的构建工具首先会将 .go 文件根据文件名排序,然后依次调用编译器编译。

对于在包级别声明的变量,如果有初始化表达式则用表达式初始化,还有一些没有初始化表达式的,例如某些表格数据初始化并不是一个简单的赋值过程。在这种情况下,我们可以用一个特殊的 init 初始化函数来简化初始化工作。每个文件都可以包含多个 init 初始化函数

func init() { /* ... */ }

这样的 init 初始化函数除了不能被调用或引用外,其他行为和普通函数类似。在每个文件中的 init 初始化函数,在程序开始执行时按照它们声明的顺序被自动调用。

每个包在解决依赖的前提下,以导入声明的顺序初始化,每个包只会被初始化一次。因此,如果一个 p 包导入了 q 包,那么在 p 包初始化的时候可以认为 q 包必然已经初始化过了。初始化工作是自下而上进行的,main 包最后被初始化。以这种方式,可以确保在main 函数执行之前,所有依赖的包都已经完成初始化工作了。

下面的代码定义了一个 PopCount 函数,用于返回一个数字中含二进制 1bit 的个数。它使用 init 初始化函数来生成辅助表格 pc ,pc 表格用于处理每个 8bit 宽度的数字含二进制的 1bit 的 bit 个数,这样的话在处理 64bit 宽度的数字时就没有必要循环 64 次,只需要 8 次查表就可以了。(这并不是最快的统计 1bit 数目的算法,但是它可以方便演示 init 函数的用法,并且演示了如何预生成辅助表格,这是编程中常用的技术)。

Unresolved include directive in modules/ROOT/pages/ch2/ch2-06.adoc - include::example$/ch2/popcount/main.go[]

译注:对于 pc 这类需要复杂处理的初始化,可以通过将初始化逻辑包装为一个匿名函数处理,像下面这样:

// pc[i] is the population count of i.
var pc [256]byte = func() (pc [256]byte) {
    for i := range pc {
        pc[i] = pc[i/2] + byte(i&1)
    }
    return
}()

要注意的是在 init 函数中,range 循环只使用了索引,省略了没有用到的值部分。循环也可以这样写:

for i, _ := range pc {

我们在下一节和10.5节还将看到其它使用 init 函数的地方。


练习 2.3: 重写 PopCount 函数,用一个循环代替单一的表达式。比较两个版本的性能。(11.4节将展示如何系统地比较两个不同实现的性能。)

练习 2.4: 用移位算法重写 PopCount 函数,每次测试最右边的 1bit,然后统计总数。比较和查表算法的性能差异。

练习 2.5: 表达式 x&(x-1) 用于将 x 的最低的一个非零的 bit 位清零。使用这个算法重写 PopCount 函数,然后比较性能。