2.6. 包和文件
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 文件:
gopl.io/ch2/tempconv
// Package tempconv performs Celsius and Fahrenheit conversions.
package tempconv
import "fmt"
type Celsius float64
type Fahrenheit float64
const (
AbsoluteZeroC Celsius = -273.15
FreezingC Celsius = 0
BoilingC Celsius = 100
)
func (c Celsius) String() string { return fmt.Sprintf("%g°C", c) }
func (f Fahrenheit) String() string { return fmt.Sprintf("%g°F", f) }
转换函数放在 conv.go 文件中:
package tempconv
// CToF converts a Celsius temperature to Fahrenheit.
func CToF(c Celsius) Fahrenheit { return Fahrenheit(c*9/5 + 32) }
// FToC converts a Fahrenheit temperature to Celsius.
func FToC(f Fahrenheit) Celsius { return Celsius((f - 32) * 5 / 9) }
每个文件都是以包的声明语句开始, 用来指定包的名字. 当包被导入的时候, 包内部的成员将通过类似 tempconv.CToF 的方式访问. 包级别的名字, 例如在一个文件声明的类型和常量, 在同一个包的其他文件也是可以直接访问的, 就好像所有代码都在一个文件一样. 要注意的是 tempconv.go 文件导入了 fmt 包, 但是 conv.go 文件并没有, 因为它并没有用到 fmt 包.
因为包级别的常量名都是以大写字母开头, 它们也是可以像 tempconv.AbsoluteZeroC 这样被访问的:
fmt.Printf("Brrrr! %v\n", tempconv.AbsoluteZeroC) // "Brrrr! -273.15°C"
要将 摄氏温度转换为 华氏温度, 需要先导入 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的单位间隔是一样的.
2.6.1. 导入包
在Go程序中, 每个包都是有一个全局唯一的导入路径. 声明中类似 "gopl.io/ch2/tempconv" 的字符串对应导入路径. 语言的规范并没有定义这些字符串的具体含义或包来自哪里, 它们是由工具来解释. 当使用 go 工具箱时(第十章), 一个导入路径代表一个目录中的一个或多个Go源文件.
除了到导入路径, 每个包还有一个包名, 包名一般是短小的(也不要求是是唯一的), 包名在包的声明处指定. 按照惯例, 一个包的名字和包的导入路径的最后一个字段相同, 例如 gopl.io/ch2/tempconv 包的名字是 tempconv.
要使用 gopl.io/ch2/tempconv 包, 需要先导入:
gopl.io/ch2/cf
// Cf converts its numeric argument to Celsius and Fahrenheit.
package main
import (
"fmt"
"os"
"strconv"
"gopl.io/ch2/tempconv"
)
func main() {
for _, arg := range os.Args[1:] {
t, err := strconv.ParseFloat(arg, 64)
if err != nil {
fmt.Fprintf(os.Stderr, "cf: %v\n", err)
os.Exit(1)
}
f := tempconv.Fahrenheit(t)
c := tempconv.Celsius(t)
fmt.Printf("%s = %s, %s = %s\n",
f, tempconv.FToC(f), c, tempconv.CToF(c))
}
}
导入声明将导入的包绑定到一个短小的名字, 然后通过该名字就可以引用包中导出的全部内容. 上面的导入声明将允许我们以 tempconv.CToF 的方式来访问 gopl.io/ch2/tempconv 包中的内容. 默认情况下, 导入的包绑定到 tempconv 名字, 但是我们也可以绑定到另一个名称, 以避免名字冲突(§10.3).
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 的转换, 长度单位对应英尺和米, 重量单位对应磅和公斤 等等.
2.6.2. 包的初始化
包的初始化首先是解决包级变量的依赖顺序, 然后安装包级变量声明出现的顺序依次初始化:
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的个数, 这样的话在处理64bit宽度的数字时就没有必要循环64次, 只需要8次查表就可以了. (这并不是最快的统计1bit数目的算法, 但是他可以方便演示init函数的用法, 并且演示了如果预生成辅助表格, 这是编程中常用的技术.)
gopl.io/ch2/popcount
package popcount
// pc[i] is the population count of i.
var pc [256]byte
func init() {
for i := range pc {
pc[i] = pc[i/2] + byte(i&1)
}
}
// PopCount returns the population count (number of set bits) of x.
func PopCount(x uint64) int {
return int(pc[byte(x>>(0*8))] +
pc[byte(x>>(1*8))] +
pc[byte(x>>(2*8))] +
pc[byte(x>>(3*8))] +
pc[byte(x>>(4*8))] +
pc[byte(x>>(5*8))] +
pc[byte(x>>(6*8))] +
pc[byte(x>>(7*8))])
}
要注意的是 init 函数中, range 循环只使用了索引, 省略了没有用到的值部分. 循环也可以这样写:
for i, _ := range pc {
我们在下一节和10.5节还将看到其它使用init函数的地方.
练习2.3: 重写 PopCount 函数, 用一个循环代替单一的表达式. 比较两个版本的性能. (11.4节将展示如何系统地比较两个不同实现的性能.)
练习2.4: 用移位的算法重写 PopCount 函数, 每次测试最右边的1bit, 然后统计总数. 比较和查表算法的性能差异.
练习2.5: 表达式 x&(x-1)
用于将 x 的最低的一个1bit位清零. 使用这个格式重写 PopCount 函数, 然后比较性能.