代码包和包引入

和很多现代编程语言一样,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。

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

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

package main

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

func main() {
	// 设置随机数种子(仅在Go 1.20之前需要)。
	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)
	fmt.Printf("1%% 50%% 99%%\n")
}
输出:
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语句必须一致。 所以,一个代码包对应着一个目录(不包含子目录),反之亦然。 对应着一个代码包的目录称为此代码包的目录。 一个代码包目录下的每个子目录对应的都是另外一个独立的代码包。

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

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

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不可识别。
	/*
	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"包
}

模块

一个模块(module)为的若干代码包的集合。当被下载至本地后,这些代码包处于同一个目录(此模块的根目录)下。 一个模块可以有很多版本(版本号遵从Semantic Versioning规范)。 更多关于模块的概念和使用,请阅读官方文档
目录↡

Go101.org网站内容包括Go编程各种相关知识(比如Go基础、Go优化、Go细节、Go实战、Go测验、Go工具等)。后续将不断有新的内容加入。敬请收藏关注期待。

本丛书微信公众号(联系方式一)名称为"Go 101"。二维码在网站首页。此公众号将时不时地发表一些Go语言相关的原创短文。各位如果感兴趣,可以搜索关注一下。

《Go语言101》系列丛书项目目前托管在Github上(联系方式二)。欢迎各位在此项目中通过提交bug和PR的方式来改进完善《Go语言101》丛书中的各篇文章。我们可以在项目目录下运行go run .来浏览和确认各种改动。

本书的twitter帐号为@Golang_101(联系方式三)。玩推的Go友可以适当关注。

你或许对本书作者老貘开发的一些App感兴趣。

The English version of this book is here.
赞赏
(《Go语言101》系列丛书由老貘从2016年7月开始编写。目前此系列丛书仍在不断改进和增容中。你的赞赏是本系列丛书和此Go101.org网站不断增容和维护的动力。)

目录: