2.6. 包和文件

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源文件中:

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"

要将摄氏温度转换为华氏温度,需要先用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的单位间隔是一样的。

2.6.1. 导入包

在Go语言程序中,每个包都有一个全局唯一的导入路径。导入语句中类似"gopl.io/ch2/tempconv"的字符串对应包的导入路径。Go语言的规范并没有定义这些字符串的具体含义或包来自哪里,它们是由构建工具来解释的。当使用Go语言自带的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.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的单位转换,长度单位可以对应英尺和米,重量单位可以对应磅和公斤等。

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的bit个数,这样的话在处理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))])
}

译注:对于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函数,然后比较性能。

交流学习

如果有疑问或想和我交流,欢迎扫码下方二维码

polarisxu

polarisxu

gopherstudio

gopherstudio