代码包和包引入

和很多现代编程语言一样,Go代码包(package)来组织管理代码。我们必须先引入一个代码包(除了builtin标准库包)才能使用其中导出的资源(比如函数、类型、变量和有名常量等)。此篇文章将讲解Go代码包和代码包引入(import)。

包引入

下面这个简短的程序(假设它存在一个名为simple-import-demo.go的源文件中)引入了一个标准库包。

  1. package main
  2. import "fmt"
  3. func main() {
  4. fmt.Println("Go has", 25, "keywords.")
  5. }

对此程序的一些解释:

  • 第一行指定了源文件simple-import-demo.go所处的包名为main。程序入口main函数必须处于一个名为main的代码包中。
  • 第三行通过使用import关键字引入了fmt标准库包。在此源文件中,fmt标准库包将用fmt标识符来表示。标识符fmt称为fmt标准库包的引入名称。(后续某节将详述代码包的引入名称)。
  • fmt标准库包中声明了很多终端打印函数供其它代码包使用。Println函数是其中之一。它可以将不定数量参数的字符串表示形式输出到标准输出中。第六行调用了此Println函数。注意在此调用中,函数名之前需要带上前缀fmt.,其中fmtPrintln函数所处的代码包的引入名称。aImportName.AnExportedIdentifier这种形式称为一个限定标识符(qualified identifier)。
  • fmt.Println函数调用接受任意数量的实参并且对实参的类型没有任何限制。所以此程序中的此函数调用的三个实参的类型将被推断为它们各自的默认类型:stringintstring
  • 对于一个fmt.Println函数调用,任何两个相邻的实参的输出之间将被插入一个空格字符,并且在最后将输出一个空行字符。下面是上面这个程序的运行结果:
  1. $ go run simple-import-demo.go
  2. Go has 25 keywords.

当一个代码包被引入一个Go源文件时,只有此代码包中的导出资源(名称为大写字母的变量、常量、函数、定义类型和类型别名等)可以在此源文件被使用。比如上例中的Println函数即为一个导出资源,所以它可以在上面的程序源文件中使用。

前面几篇文章中使用的内置函数printprintln提供了和fmt标准库包中的对应函数相似的功能。内置函数可以不用引入任何代码包而直接使用。

注意:printprintln这两个内置函数不推荐使用在生产环境,因为它们不保证一定会出现在以后的Go版本中。

我们可以访问Go官网墙内版)来查看各个标准库包的文档,我们也可以开启一个本地文档服务器来查看这些文档。

一个包引入也可称为一个包声明。一个包声明只在当前包含此声明的源文件内可见。 另外一个例子:

  1. package main
  2. import "fmt"
  3. import "math/rand"
  4. func main() {
  5. fmt.Printf("下一个伪随机数总是%v。\n", rand.Uint32())
  6. }

这个例子多引入了一个math/rand标准库包。此包是math标准库包中的一个子包。此包提供了一些函数来产生伪随机数序列。 一些解释:

  • 在此例中,math/rand标准库包的引入名是randrand.Uint32()函数调用将返回一个uint32类型的随机数。
  • Printf函数是fmt标准库包中提供的另外一个常用终端打印函数。一个Printf函数调用必须带有至少一个实参,并且第一个实参的类型必须为string。此第一个实参指定了此调用的打印格式。此格式中的%v在打印结果将被对应的后续实参的字符串表示形式所取代。比如上列中的%v在打印结果中将被rand.Uint32()函数调用所返回的随机数所取代。打印格式中的\n表示一个换行符,这在基本类型和它们的字面表示形式一文中已经解释过。上面这个程序的输出如下:
  1. 下一个伪随机数总是2596996162

如果我们希望上面的程序每次运行的时候输出一个不同的随机数,我们需要在程序启动的时候使用调用rand.Seed函数来设置一个不同的随机数种子。

多个包引入语句可以用一对小括号来合并成一个包引入语句。比如下面这例。

  1. package main
  2. // 一条包引入语句引入了三个代码包。
  3. import (
  4. "fmt"
  5. "math/rand"
  6. "time"
  7. )
  8. func main() {
  9. rand.Seed(time.Now().UnixNano()) // 设置随机数种子
  10. fmt.Printf("下一个伪随机数总是%v。\n", rand.Uint32())
  11. }

一些解释:

  • 此例多引入了一个time标准库包。此包提供了很多和时间相关的函数和类型。其中time.Timetime.Duration是两个最常用的类型。
  • 函数调用time.Now()将返回一个表示当前时间的类型为time.Time的值。
  • UnixNano是类型time.Time的一个方法。我们可以把方法看作是特殊的函数。方法将在Go中的方法一文中详述。方法调用aTime.UnixNano()将返回从UTC时间的1970年一月一日到aTime所表示的时间之间的纳秒数。返回结果的类型为int64。在上例中,此方法调用的结果用来设置随机数种子。

更多关于fmt.Printf函数调用的输出格式

从上面的例子中,我们已经了解到fmt.Printf函数调用的第一个实参中的%v在输出中将替换为后续的实参的字符串表示形式。实际上,这种百分号开头的占位字符组合还有很多。下面是一些常用的占位字符组合:

  • %v:将被替换为对应实参字符串表示形式。
  • %T:将替换为对应实参的类型的字符串表示形式。
  • %x:将替换为对应实参的十六进制表示。实参的类型必须为整数,整数数组(array)或者整数切片(slice)等。(数组和切片将在以后的文章中讲解。)
  • %s:将被替换为对应实参的字符串表示形式。实参的类型必须为字符串或者字节切片(byte slice)类型。
  • %%:将被替换为一个百分号。一个例子:
  1. package main
  2. import "fmt"
  3. func main() {
  4. a, b := 123, "Go"
  5. fmt.Printf("a == %v == 0x%x, b == %s\n", a, a, b)
  6. fmt.Printf("type of a: %T, type of b: %T\n", a, b)
  7. }

输出:

  1. a == 123 == 0x7b, b == Go
  2. type of a: int, type of b: string
  3. 1% 50% 99%

请阅读fmt标准库包的文档以了解更多的占位字符组合。我们也可以运行go doc fmt命令来在终端中查看fmt标准库包的文档。运行go doc fmt.Printf命令可以查看fmt.Printf函数的文档。

代码包目录、代码包引入路径、和代码包依赖关系

一个代码包可以由若干Go源文件组成。一个代码包的源文件须都处于同一个目录下。一个目录(不包含子目录)下的所有源文件必须都处于同一个代码包中,亦即这些源文件开头的package pkgname语句必须一致。所以,一个代码包对应着一个目录(不包含子目录),反之亦然。对应着一个代码包的目录称为此代码包的目录。一个代码包目录下的每个子目录对应的都是另外一个独立的代码包。

对于官方Go SDK来说,一个引入路径中包含有internal目录名的代码包被视为一个特殊的代码包。它只能被此internal目录的直接父目录中的代码包所引入。比如,代码包…/a/b/c/internal/d/e/f…/a/b/c/internal只能被引入路径含有…/a/b/c前缀的代码包引入。

根据不同的情形,名称为vendor的目录也可能被视为特殊的代码包目录。下面的段落将会解释哪些vendor目录将被视为特殊的目录。

Go SDK 1.11版本引入了模块(modules)特性的概念。一个模块可以被看做是一些拥有一个共同的根目录的代码包集合,即一个代码包树。每个模块关联着一个根引入路径和一个语义化版本号。语义化版本号的主版本须体现在根引入路径中,除了v0v1主版本号。两个关联着不同的根引入路径的模块被视为两个不同的模块。

Go SDK 1.11也引入了一个GO111MODULE环境变量,它的值可以为autoonoff。截至到目前(Go SDK v1.13),它的默认值为auto。根据具体情景,不同的SDK版本将根据不同的规则把auto解读为on或者off。请阅读官方维基以获取详情。

如果一个代码包处于某个GOPATH/src目录之下(间接或直接),并且模块特性为关闭状态(off),则它的引入路径或者为相对于此GOPATH/src目录的相对路径,或者为相对于包含此代码包的最内层vendor目录的相对路径。 举个例子,当模块特性为关闭状态时,假设有一个如下的包层级结构,则:

  • 两个foo包的引入路径均为w/foo
  • x包、y包和z包的引入路径均分别为xx/yx/z。注意:
  • 当在y.go文件中引入一个引入路径均为w/foo的包的时候,被引入的包为GOPATH/src/x/y/vendor/w/foo目录中的包。
  • 当在x.go或者z.go文件中引入一个引入路径为w/foo的包的时候,被引入的包为GOPATH/src/x/vendor/w/foo目录中的包。
  1. _ GOPATH
  2. |_ src
  3. |_ x
  4. |_ vendor
  5. | |_ w
  6. | |_ foo
  7. | |_ foo.go // package foo
  8. |_ y
  9. | |_ vendor
  10. | | |_ w
  11. | | |_ foo
  12. | | |_ foo.go // package foo
  13. | |_ y.go // package y
  14. |_ z
  15. | |_ z.go // package z
  16. |_ x.go // package x

当模块特性为打开状态时,一个模块的根引入路径经常(但不是必须地)被指定在此模块的根目录下的一个go.mod文件中。我们常用一个模块的根引入路径来标识此模块。一个模块的根引入路径是此模块中的所有代码包的引入路径的前缀。

只有直接位于一个模块的根目录下的的vendor目录才会被视为特殊的目录。 当模块特性为打开状态时,假设有一个如下的被标识为example.com/mypkg的模块,则:

  • 第一个foo包的引入路径均为w/foo。此包的父目录vendor目录被视为一个特殊的目录。
  • 另外一个foo包的引入路径均为example.com/mypkg/x/y/vendor/w/foo。注意这里的vendor目录被视为一个普通的包目录。
  • x包、y包和z包的引入路径均分别为example.com/mypkg/xexample.com/mypkg/x/yexample.com/mypkg/x/z。注意:当在x.goy.go或者z.go文件中引入一个引入路径均为w/foo的包的时候,被引入的包均为MyProject/vendor/w/foo目录中的包。
  1. _ MyProject
  2. |_ go.mod // module example.com/mypkg
  3. |_ vendor
  4. | |_ w
  5. | |_ foo
  6. | |_ foo.go // package foo
  7. |_ x
  8. |_ y
  9. | |_ vendor
  10. | | |_ w
  11. | | |_ foo
  12. | | |_ foo.go // package foo
  13. | |_ y.go // package y
  14. |_ z
  15. | |_ z.go // package z
  16. |_ x.go // package x

当一个代码包中的某个文件引入了另外一个代码包,则我们说前者代码包依赖于后者代码包。

Go不支持循环引用(依赖)。如果一个代码包a依赖于代码包b,同时代码包b依赖于代码包c,则代码包c中的源文件不能引入代码包a和代码包b,代码包b中的源文件也不能引入代码包a

当然,一个代码包中的源文件不能也没必要引入此代码包本身。

和包依赖类似,一个模块也可能依赖于一些其它模块。此模块的直接依赖模块和这些依赖模块的版本在此模块中的go.mod文件中指定。模块循环依赖是允许的,但模块循环依赖这种情况在实践中很少见。

今后,我们称一个程序中含有main入口函数的名称为main的代码包为程序代码包,称其它代码包为库代码包。一个程序只能有一个程序代码包。程序代码包不能被其它代码包引入。

代码包目录的名称并不要求一定要和其对应的代码包的名称相同。但是,库代码包目录的名称最好设为和其对应的代码包的名称相同。因为一个代码包的引入路径中包含的是此包的目录名,但是此包的默认引入名为此包的名称。如果两者不一致,会使人感到困惑。

另一方面,最好给每个程序代码包指定一个有意义的名字,而不是它的包名main

init函数

在一个代码包中,甚至一个源文件中,可以声明若干名为init的函数。这些init函数必须不带任何输入参数和返回结果。

注意,我们不能声明名为init的包级变量、常量或者类型。

在程序运行时刻,在进入main入口函数之前,每个init函数在此包加载的时候将被(串行)执行并且只执行一遍。

下面这个简单的程序中有两个init函数:

  1. package main
  2. import "fmt"
  3. func init() {
  4. fmt.Println("hi,", bob)
  5. }
  6. func main() {
  7. fmt.Println("bye")
  8. }
  9. func init() {
  10. fmt.Println("hello,", smith)
  11. }
  12. func titledName(who string) string {
  13. return "Mr. " + who
  14. }
  15. var bob, smith = titledName("Bob"), titledName("Smith")

此程序的运行结果:

  1. hi, Mr. Bob
  2. hello, Mr. Smith
  3. bye

程序资源初始化顺序

一个程序中所涉及到的所有的在运行时刻要用到的代码包的加载是串行执行的。在一个程序启动时,每个包中总是在它所有依赖的包都加载完成之后才开始加载。程序代码包总是最后一个被加载的代码包。每个被用到的包会被而且仅会被加载一次。

在加载一个代码包的过程中,所有的声明在此包中的init函数将被串行调用并且仅调用执行一次。一个代码包中声明的init函数的调用肯定晚于此代码包所依赖的代码包中声明的init函数。所有的init函数都将在调用main入口函数之前被调用执行。

在同一个源文件中声明的init函数将按从上到下的顺序被调用执行。对于声明在同一个包中的两个不同源文件中的两个init函数,Go语言白皮书推荐(但不强求)按照它们所处于的源文件的名称的词典序列(对英文来说,即字母顺序)来调用。所以最好不要让声明在同一个包中的两个不同源文件中的两个init函数存在依赖关系。

在加载一个代码包的时候,此代码包中声明的所有包级变量都将在此包中的任何一个init函数执行之前初始化完毕。 在同一个包内,包级变量将尽量按照它们在代码中的出现顺序被初始化,但是一个包级变量的初始化肯定晚于它所依赖的其它包级变量。比如,在下面的代码片段中,四个包级变量的初始化顺序依次为yzxw

  1. func f() int {
  2. return z + y
  3. }
  4. func g() int {
  5. return y/2
  6. }
  7. var (
  8. w = x
  9. x, y, z = f(), 123, g()
  10. )

关于更具体的包级变量的初始化顺序,请阅读表达式估值顺序规则一文。

完整的引入声明语句形式

事实上,一个引入声明语句的完整形式为:

  1. import importname "path/to/package"

其中引入名importname是可选的,它的默认值为被引入的包的包名(不是目录名)。 事实上,在本文上面的例子中的包引入声明中,importname部分都被省略掉了,因为它们都分别和引入的代码包的包名相同。这些引入声明等价于下面这些:

  1. import fmt "fmt" // <=> import "fmt"
  2. import rand "math/rand" // <=> import "math/rand"
  3. import time "time" // <=> import "time"

如果一个包引入声明中的importname没有省略,则限定标识符使用的前缀必须为importname,而不是被引入的包的名称。

引入声明语句的完整形式在日常编程中使用的频率不是很高。但是在某些情况下,完整形式必须被使用。比如,如果一个源文件引入的两个代码包的包名一样,为了防止使编译器产生困惑,我们至少需要用完整形式为其中一个包指定一个不同的引入名以区分这两个包。 下面是一个使用了完整引入声明语句形式的例子。

  1. package main
  2. import (
  3. format "fmt"
  4. random "math/rand"
  5. "time"
  6. )
  7. func main() {
  8. random.Seed(time.Now().UnixNano())
  9. format.Print("一个随机数:", random.Uint32(), "\n")
  10. // 下面这两行编译不通过,因为rand不可识别。
  11. /*
  12. rand.Seed(time.Now().UnixNano())
  13. fmt.Print("一个随机数:", rand.Uint32(), "\n")
  14. */
  15. }

一些解释:

  • 我们必须使用formatrandom,而不是fmtrand,来做为限定标识符的前缀。
  • Printfmt标准库包中的另外一个函数。和Println函数调用一样,一个Print函数调用也接受任意数量实参。它将逐个打印出每个实参的字符串表示形式。如果相邻的的两个实参都不是字符串类型,则在它们中间会打印一个空格字符。 一个完整引入声明语句形式的引入名importname可以是一个句点(.)。这样的引入称为句点引入。使用被句点引入的包中的导出资源时,限定标识符的前缀必须省略。 例子:
  1. package main
  2. import (
  3. . "fmt"
  4. . "time"
  5. )
  6. func main() {
  7. Println("Current time:", Now())
  8. }

在上面这个例子中,PrintlnNow函数调用不需要带任何前缀。

一般来说,句点引入不推荐使用,因为它们会导致较低的代码可读性。

一个完整引入声明语句形式的引入名importname可以是一个空标识符(_)。这样的引入称为匿名引入。一个包被匿名引入的目的主要是为了加载这个包,从而使得这个包中的资源得以初始化。被匿名引入的包中的init函数将被执行并且仅执行一遍。 在下面这个例子中,net/http/pprof标准库包中的所有init函数将在main入口函数开始执行之前全部执行一遍。

  1. package main
  2. import _ "net/http/pprof"
  3. func main() {
  4. ... // 做一些事情
  5. }

每个非匿名引入必须至少被使用一次

除了匿名引入,其它引入必须在代码中被使用一次。比如,下面的程序编译不通过。

  1. package main
  2. import (
  3. "net/http" // error: 引入未被使用
  4. . "time" // error: 引入未被使用
  5. )
  6. import (
  7. format "fmt" // okay: 下面被使用了一次
  8. _ "math/rand" // okay: 匿名引入
  9. )
  10. func main() {
  11. format.Println() // 使用"fmt"包
  12. }

Go语言101项目目前同时托管在GithubGitlab上。欢迎各位在这两个项目中通过提交bug和PR的方式来改进完善Go语言101中的各篇文章。

本书微信公众号名称为"Go 101"。每个工作日此公众号将尽量发表一篇和Go语言相关的原创短文。各位如果感兴趣,可以搜索关注一下。

赞赏