代码包和包引入

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

包引入

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

import "fmt"

func main() {
	fmt.Println("Go has", 25, "keywords.")
}
对此程序的一些解释: 下面是上面这个程序的运行结果:
$ go run simple-import-demo.go
Go has 25 keywords.

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

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

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

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

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

另外一个例子:
package main

import "fmt"
import "math/rand"

func main() {
	fmt.Printf("下一个伪随机数总是%v。\n", rand.Uint32())
}

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

一些解释: 上面这个程序的输出如下:
下一个伪随机数总是2596996162。

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

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

package main

// 一条包引入语句引入了三个代码包。
import (
	"fmt"
	"math/rand"
	"time"
)

func main() {
	rand.Seed(time.Now().UnixNano()) // 设置随机数种子
	fmt.Printf("下一个伪随机数总是%v。\n", rand.Uint32())
}
一些解释:

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

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

import "fmt"

func main() {
	a, b := 123, "Go"
	fmt.Printf("a == %v == 0x%x, b == %s\n", a, a, b)
	fmt.Printf("type of a: %T, type of b: %T\n", a, b)
}
输出:
a == 123 == 0x7b, b == Go
type of a: int, type of b: string
1% 50% 99%

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

代码包文件夹、代码包引入路径、和代码包依赖关系

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

根据不同的情形,名称为vendor的文件夹可能被视为特殊的文件夹。下面的段落将会解释其特殊性。

Go SDK 1.11版本引入了包模块(module)的概念。 此概念对代码包的引入路径产生了一些影响。 一个Go代码包可以选择是否支持模块模式。 Go SDK 1.11引入了一个环境变量GO111MODULE。这个环境变量的默认值是auto,它的其它两个可能取值为onoff。 如果此环境变量取值为on,则所有的代码包都支持模块模式; 如果此环境变量取值为off,则所有的代码包都不支持模块模式; 如果此环境变量取值为auto,则只有同时满足下面两个条件的代码包才支持模块模式:
  1. 代码包文件夹必须处于GOPATH环境变量指定的所有路径的src子目录以外。
  2. 代码包文件夹或者它的某个上级目录文件夹中含有一个名为go.mod文件。

对于一个不支持模块模式的代码包,通常它应该处于某个GOPATH/src路径之下。 它的包引入路径为此包的文件夹相对于GOPATH/src或者某个vendor目录的相对路径。

在不支持模块模式的情形下,假设有一个如下的包层级结构,则: 注意:
_ GOPATH
  |_ src
     |_ x
        |_ vendor
        |  |_ w
        |     |_ foo
        |        |_ foo.go    // package foo
        |_ y
        |  |_ vendor
        |  |  |_ w
        |  |     |_ foo
        |  |        |_ foo.go // package foo
        |  |_ y.go            // package y
        |_ z
        |  |_ z.go            // package z
        |_ x.go               // package x

对于一个支持模块模式的代码包,其对应的go.mod文件中必须指定此go.mod文件所处的最内层文件夹对应的代码包的引入路径。 指定引入路径的格式为module ImportPath。 在这种模式下,只有和与此go.mod文件处于同一文件夹下的vendor文件夹才被视为特殊的文件夹。 并且请注意,如果go命令的选项-mod=vendor未提供,则此vendor文件夹的包将被忽略,而下载缓存(处于项目代码包之外)中相应的代码包将被使用。

在支持模块模式的情形下,假设有一个如下的包层级结构,则: 注意:当在x.goy.go或者z.go文件中引入一个引入路径均为w/foo的包的时候,被引入的包均为MyProject/vendor/w/foo文件夹中的包。
_ MyProject
     |_ go.mod                // module example.com/mypkg
     |_ vendor
     |  |_ w
     |     |_ foo
     |        |_ foo.go       // package foo
     |_ x
        |_ y
        |  |_ vendor
        |  |  |_ w
        |  |     |_ foo
        |  |        |_ foo.go // package foo
        |  |_ y.go            // package y
        |_ z
        |  |_ z.go            // package z
        |_ x.go               // package x

如果一个包文件夹含有一个go.mod文件,则请不要在它的任何(直接或间接)子包文件夹中也放置一个go.mod文件。

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

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

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

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

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

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

init函数

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

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

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

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

import "fmt"

func init() {
	fmt.Println("hi,", bob)
}

func main() {
	fmt.Println("bye")
}

func init() {
	fmt.Println("hello,", smith)
}

func titledName(who string) string {
	return "Mr. " + who
}

var bob, smith = titledName("Bob"), titledName("Smith")
此程序的运行结果:
hi, Mr. Bob
hello, Mr. Smith
bye

程序资源初始化顺序

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

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

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

在加载一个代码包的时候,此代码包中声明的所有包级变量都将在此包中的任何一个init函数执行之前初始化完毕。

在同一个包内,包级变量将尽量按照它们在代码中的出现顺序被初始化,但是一个包级变量的初始化肯定晚于它所依赖的其它包级变量。 比如,在下面的代码片段中,四个包级变量的初始化顺序依次为yzxw
func f() int {
	return z + y
}

func g() int {
	return y/2
}

var (
	w       = x
	x, y, z = f(), 123, g()
)

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

完整的引入声明语句形式

事实上,一个引入声明语句的完整形式为:
import importname "path/to/package"

其中引入名importname是可选的,它的默认值为被引入的包的包名(不是文件夹名)。

事实上,在本文上面的例子中的包引入声明中,importname部分都被省略掉了,因为它们都分别和引入的代码包的包名相同。 这些引入声明等价于下面这些:
import fmt "fmt"        // <=> import "fmt"
import rand "math/rand" // <=> import "math/rand"
import time "time"      // <=> import "time"

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

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

下面是一个使用了完整引入声明语句形式的例子。
package main

import (
	format "fmt"
	random "math/rand"
	"time"
)

func main() {
	random.Seed(time.Now().UnixNano())
	format.Print("一个随机数:", random.Uint32(), "\n")

	// 下面这两行编译不通过,因为rand不可识别。
	/*
	rand.Seed(time.Now().UnixNano())
	fmt.Print("一个随机数:", rand.Uint32(), "\n")
	*/
}
一些解释:

一个完整引入声明语句形式的引入名importname可以是一个句点(.)。 这样的引入称为句点引入。使用被句点引入的包中的导出资源时,限定标识符的前缀必须省略。

例子:
package main

import (
	. "fmt"
	. "time"
)

func main() {
	Println("Current time:", Now())
}

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

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

一个完整引入声明语句形式的引入名importname可以是一个空标识符(_)。 这样的引入称为匿名引入。一个包被匿名引入的目的主要是为了加载这个包,从而使得这个包中的资源得以初始化。 被匿名引入的包中的init函数将被执行并且仅执行一遍。

在下面这个例子中,net/http/pprof标准库包中的所有init函数将在main入口函数开始执行之前全部执行一遍。
package main

import _ "net/http/pprof"

func main() {
	... // 做一些事情
}

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

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

import (
	"net/http" // error: 引入未被使用
	. "time"   // error: 引入未被使用
)

import (
	format "fmt"  // okay: 下面被使用了一次
	_ "math/rand" // okay: 匿名引入
)

func main() {
	format.Println() // 使用"fmt"包
}

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

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

赞赏