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.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"
在每个源文件的包声明前是注释,通常用于包功能概要说明。一个包通常只有一个源文件有包注释(译注:如果有多个包注释,目前的文档工具会根据源文件名的先后顺序将它们链接为一个包注释)。如果包注释很大,通常会放到一个独立的doc.go文件中。
导入包
在GO语言程序中,每个包都有一个全局唯一的导入路径。导入语句类似“gopl.io/ch2/tempconv”的字符串对应包的导入路径。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这个名字,但是我们也可以绑定到另一个名字,以避免冲突。
如果导入一个包,但是没有使用该包中的任何导出成员,那么将被当作一个编译错误处理。这种强制规则可以有效减少不必要的依赖,虽然在调试期间可能会让人讨厌,因为删除一个类似 log.Print("got here!")的打印语句可能导致需要同时删除log包导入声明,否则,编译器将会报错。在这种情况下,我们需要将不必要的包删除会注释掉。
不过有更好的解决方案,我们可以使用golang.org/x/tools/cmd/goimports导入工具,它可以根据需要自动删除或添加导入包。许多编辑器可以集成goimports工具,然后在保存文件的时候自动运行。类似的还有gofmt工具,可用来格式化GO源文件。
包的初始化
包的初始化首先是解决包级别变量的依赖顺序,然后按照包级别变量声明出现的顺序依次初始化:
ar 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函数,用于返回一个数字中含有二进制1的bit个数。它使用init初始化函数来生成辅助表格pc,pc表格用于处理每个8bit宽度的数字韩二进制1的bit个数。这样的话,在处理64bit宽度的数字时就没有必要循环64次,只需要8次查表就可以了。
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 {
译注:对于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
}()
包是结构化代码的一种方式:每个程序都由包(通常简称为 pkg)的概念组成,可以使用自身的包或者从其它包中导入内容。
如同其它一些编程语言中的类库或命名空间的概念,每个 Go 文件都属于且仅属于一个包。一个包可以由许多以.go
为扩展名的源文件组成,因此文件名和包名一般来说都是不相同的。
你必须在源文件中非注释的第一行指明这个文件属于哪个包,如:package main
。package main
表示一个可独立执行的程序,每个 Go 应用程序都包含一个名为 main
的包。
一个应用程序可以包含不同的包,而且即使你只使用 main 包也不必把所有的代码都写在一个巨大的文件里:你可以用一些较小的文件,并且在每个文件非注释的第一行都使用 package main
来指明这些文件都属于 main 包。如果你打算编译包名不是为 main 的源文件,如 pack1
,编译后产生的对象文件将会是 pack1.a
而不是可执行程序。另外要注意的是,所有的包名都应该使用小写字母。
标准库
在 Go 的安装文件里包含了一些可以直接使用的包,即标准库。在 Windows 下,标准库的位置在 Go 根目录下的子目录 pkg\windows_386
中;在 Linux 下,标准库在 Go 根目录下的子目录 pkg\linux_amd64
中(如果是安装的是 32 位,则在 linux_386
目录中)。一般情况下,标准包会存放在 $GOROOT/pkg/$GOOS_$GOARCH/
目录下。
Go 的标准库包含了大量的包(如:fmt 和 os),但是你也可以创建自己的包。
如果想要构建一个程序,则包和包内的文件都必须以正确的顺序进行编译。包的依赖关系决定了其构建顺序。
属于同一个包的源文件必须全部被一起编译,一个包即是编译时的一个单元,因此根据惯例,每个目录都只包含一个包。
如果对一个包进行更改或重新编译,所有引用了这个包的客户端程序都必须全部重新编译。
Go 中的包模型采用了显式依赖关系的机制来达到快速编译的目的,编译器会从后缀名为 .o
的对象文件(需要且只需要这个文件)中提取传递依赖类型的信息。
如果 A.go
依赖 B.go
,而 B.go
又依赖 C.go
:
- 编译
C.go
,B.go
, 然后是A.go
. - 为了编译
A.go
, 编译器读取的是B.o
而不是C.o
.
这种机制对于编译大型的项目时可以显著地提升编译速度。
每一段代码只会被编译一次
一个 Go 程序是通过 import
关键字将一组包链接在一起。
import "fmt"
告诉 Go 编译器这个程序需要使用 fmt
包(的函数,或其他元素),fmt
包实现了格式化 IO(输入/输出)的函数。包名被封闭在半角双引号 ""
中。如果你打算从已编译的包中导入并加载公开声明的方法,不需要插入已编译包的源代码。
如果需要多个包,它们可以被分别导入:
import "fmt"
import "os"
或:
import "fmt"; import "os"
但是还有更短且更优雅的方法(被称为因式分解关键字,该方法同样适用于 const、var 和 type 的声明或定义):
import (
"fmt"
"os" )
它甚至还可以更短的形式,但使用 gofmt 后将会被强制换行:
import ("fmt"; "os")
当你导入多个包时,导入的顺序会按照字母排序。
如果包名不是以 .
或 /
开头,如 "fmt"
或者 "container/list"
,则 Go 会在全局文件进行查找;如果包名以 ./
开头,则 Go 会在相对目录中查找;如果包名以 /
开头(在 Windows 下也可以这样使用),则会在系统的绝对路径中查找。
导入包即等同于包含了这个包的所有的代码对象。
除了符号 _
,包中所有代码对象的标识符必须是唯一的,以避免名称冲突。但是相同的标识符可以在不同的包中使用,因为可以使用包名来区分它们。
包通过下面这个被编译器强制执行的规则来决定是否将自身的代码对象暴露给外部文件:
可见性规则
当标识符(包括常量、变量、类型、函数名、结构字段等等)以一个大写字母开头,如:Group1,那么使用这种形式的标识符的对象就可以被外部包的 代码所使用(客户端程序需要先导入这个包),这被称为导出(像面向对象语言中的 public);标识符如果以小写字母开头,则对包外是不可见的,但是他们在整个包的内部是可见并且可用的(像面向对象语言中的 private )。
(大写字母可以使用任何 Unicode 编码的字符,比如希腊文,不仅仅是 ASCII 码中的大写字母)。
因此,在导入一个外部包后,能够且只能够访问该包中导出的对象。
假设在包 pack1 中我们有一个变量或函数叫做 Thing(以 T 开头,所以它能够被导出),那么在当前包中导入 pack1 包,Thing 就可以像面向对象语言那样使用点标记来调用:pack1.Thing
(pack1 在这里是不可以省略的)。
因此包也可以作为命名空间使用,帮助避免命名冲突(名称冲突):两个包中的同名变量的区别在于他们的包名,例如 pack1.Thing
和 pack2.Thing
。
你可以通过使用包的别名来解决包名之间的名称冲突,或者说根据你的个人喜好对包名进行重新设置,如:import fm "fmt"
。下面的代码展示了如何使用包的别名:
示例 4.2 alias.go
package main
import fm "fmt" // alias3 func main() { fm.Println("hello, world") }
注意事项
如果你导入了一个包却没有使用它,则会在构建程序时引发错误,如 imported and not used: os
,这正是遵循了 Go 的格言:“没有不必要的代码!“。
包的分级声明和初始化
你可以在使用 import
导入包之后定义或声明 0 个或多个常量(const)、变量(var)和类型(type),这些对象的作用域都是全局的(在本包范围内),所以可以被本包中所有的函数调用(如 gotemplate.go 源文件中的 c 和 v),然后声明一个或多个函数(func)。
Go 程序的一般结构
下面的程序可以被顺利编译但什么都做不了,不过这很好地展示了一个 Go 程序的首选结构。这种结构并没有被强制要求,编译器也不关心 main 函数在前还是变量的声明在前,但使用统一的结构能够在从上至下阅读 Go 代码时有更好的体验。
所有的结构将在这一章或接下来的章节中进一步地解释说明,但总体思路如下:
- 在完成包的 import 之后,开始对常量、变量和类型的定义或声明。
- 如果存在 init 函数的话,则对该函数进行定义(这是一个特殊的函数,每个含有该函数的包都会首先执行这个函数)。
- 如果当前包是 main 包,则定义 main 函数。
- 然后定义其余的函数,首先是类型的方法,接着是按照 main 函数中先后调用的顺序来定义相关函数,如果有很多函数,则可以按照字母顺序来进行排序。
示例 4.4 gotemplate.go
package main
import (
"fmt"
)
const c = "C" var v int = 5 type T struct{} func init() { // initialization of package } func main() { var a int Func1() // ... fmt.Println(a) } func (t T) Method1() { //... } func Func1() { // exported function Func1 //... }
Go 程序的执行(程序启动)顺序如下:
- 按顺序导入所有被 main 包引用的其它包,然后在每个包中执行如下流程:
- 如果该包又导入了其它的包,则从第一步开始递归执行,但是每个包只会被导入一次。
- 然后以相反的顺序在每个包中初始化常量和变量,如果该包含有 init 函数的话,则调用该函数。
- 在完成这一切之后,main 也执行同样的过程,最后调用 main 函数开始执行程序。