《Go 语言程序设计》 读书笔记 (八) 包

Go 语言有超过 100 个的标准包(可以用 go list std | wc -l 命令查看标准包的具体数目),标准库为大多数的程序提供了必要的基础构件。在 Go 的社区,有很多成熟的包被设计、共享、重用和改进,目前互联网上已经发布了非常多的 Go 语言开源包,它们可以通过 http://godoc.org 检索。在本章,我们将演示如果使用已有的包和创建新的包。

包简介#

任何包系统设计的目的都是为了简化大型程序的设计和维护工作,通过将一组相关的特性放进一个独立的单元以便于理解和更新,在每个单元更新的同时保持和程序中其它单元的相对独立性。这种模块化的特性允许每个包可以被其它的不同项目共享和重用,在项目范围内、甚至全球范围统一地分发和复用。

每个包一般都定义了一个不同的命名空间用于它内部的每个标识符的访问。每个命名空间关联到一个特定的包,让我们给类型、函数等选择简短明了的名字,这样可以在我们使用它们的时候减少和其它部分名字的冲突。

每个包还通过控制包内名字的可见性和是否导出来实现封装特性。通过限制包成员的可见性并隐藏包 API 的具体实现,将允许包的维护者在不影响外部包用户的前提下调整包的内部实现。通过限制包内变量的可见性,还可以强制用户通过某些特定函数来访问和更新内部变量,这样可以保证内部变量的一致性和并发时的互斥约束。

当我们修改了一个源文件,我们必须重新编译该源文件对应的包和所有依赖该包的其他包。即使是从头构建,Go 语言编译器的编译速度也明显快于其它编译语言。Go 语言的闪电般的编译速度主要得益于三个语言特性。第一点,所有导入的包必须在每个文件的开头显式声明,这样的话编译器就没有必要读取和分析整个源文件来判断包的依赖关系。第二点,禁止包的环状依赖,因为没有循环依赖,包的依赖关系形成一个有向无环图,每个包可以被独立编译,而且很可能是被并发编译。第三点,编译后包的目标文件不仅仅记录包本身的导出信息,目标文件同时还记录了包的依赖关系。因此,在编译一个包的时候,编译器只需要读取每个直接导入包的目标文件,而不需要遍历所有依赖的的文件(很多都是重复的间接依赖)。

导入路径#

每个包是由一个全局唯一的字符串所标识的导入路径来定位的。

import (
    "fmt"
    "math/rand"
    "encoding/json"

    "golang.org/x/net/html"

    "github.com/go-sql-driver/mysql"
)

Go 语言的规范并没有指明包的导入路径字符串的具体含义,导入路径的具体含义是由构建工具来解释的。

如果你计划分享或发布包,那么导入路径必须是全球唯一的。为了避免冲突,所有非标准库包的导入路径建议以所在组织的互联网域名为前缀;而且这样也有利于包的检索。例如,上面的 import 语句导入了 Go 团队维护的 HTML 解析器和一个流行的第三方维护的 MySQL 驱动。

包声明#

在每个 Go 源文件的开头都必须有包声明语句。包声明语句的主要目的是确定当前包被其它包导入时默认的包名。

例如,math/rand 包的每个源文件的开头都包含 package rand 包声明语句,所以当你导入这个包,你就可以用 rand.Int、rand.Float64 类似的方式访问包的成员。

通常来说,默认的包名就是包导入路径名的最后一段,因此即使两个包的导入路径不同,它们依然可能有一个相同的包名。例如,math/rand 包和 crypto/rand 包的包名都是 rand。稍后我们将看到如何同时导入两个有相同包名的包。

关于默认包名一般采用导入路径名的最后一段的约定也有三种例外情况:

第一个例外,包对应一个可执行程序,也就是 main 包,这时候 main 包本身的导入路径是无关紧要的。名字为 main 的包是给 go build 构建命令一个信息,这个包编译完之后必须调用连接器生成一个可执行程序。

第二个例外,包所在的目录中可能有一些文件名是以 test.go 为后缀的 Go 源文件,并且这些源文件声明的包名也是以_test 为后缀名的。这种目录可以包含两种包:一种普通包,加一种则是测试的外部扩展包。所有以_test 为后缀包名的测试外部扩展包都由 go test 命令独立编译,普通包和测试的外部扩展包是相互独立的。测试的外部扩展包一般用来避免测试代码中的循环导入依赖。

第三个例外,一些依赖版本号的管理工具会在导入路径后追加版本号信息,例如”gopkg.in/yaml.v2”。这种情况下包的名字并不包含版本号后缀,而是 yaml。

导入包#

可以在一个 Go 语言源文件包声明语句之后,其它非导入声明语句之前,包含零到多个导入包声明语句。每个导入声明可以单独指定一个导入路径,也可以通过圆括号同时导入多个导入路径。下面两个导入形式是等价的,但是第二种形式更为常见。

import "fmt"
import "os"

import (
    "fmt"
    "os"
)

导入的包之间可以通过添加空行来分组;通常将来自不同组织的包独自分组。包的导入顺序无关紧要,但是在每个分组中一般会根据字符串顺序排列。(gofmt 和 goimports 工具都可以将不同分组导入的包独立排序。)

import (
    "fmt"
    "html/template"
    "os"

    "golang.org/x/net/html"
    "golang.org/x/net/ipv4"
)

如果我们想同时导入两个有着名字相同的包,例如 math/rand 包和 crypto/rand 包,那么导入声明必须至少为一个同名包指定一个新的包名以避免冲突。这叫做导入包的重命名。

import (
    "crypto/rand"
    mrand "math/rand" // alternative name mrand avoids conflict
)

导入包的重命名只影响当前的源文件。其它的源文件如果导入了相同的包,可以用导入包原本默认的名字或重命名为另一个完全不同的名字。

导入包重命名是一个有用的特性,它不仅仅只是为了解决名字冲突。如果导入的一个包名很笨重,特别是在一些自动生成的代码中,这时候用一个简短名称会更方便。选择用简短名称重命名导入包时候最好统一,以避免包名混乱。选择另一个包名称还可以帮助避免和本地普通变量名产生冲突。例如,如果文件中已经有了一个名为 path 的变量,那么我们可以将”path” 标准包重命名为 pathpkg。

每个导入声明语句都明确指定了当前包和被导入包之间的依赖关系。如果遇到包循环导入的情况,Go 语言的构建工具将报告错误。

匿名包导入#

如果只是导入一个包而并不使用导入的包将会导致一个编译错误。但是有时候我们只是想利用导入包而产生的副作用:它会计算包级变量的初始化表达式和执行导入包的 init 初始化函数。这时候我们需要抑制 “unused import” 编译错误,我们可以用下划线_来重命名导入的包。像往常一样,下划线_为空白标识符,并不能被访问。

import _ "image/png" // 注册 PNG 解码器

这个被称为包的匿名导入。

标准库提供了 GIF、PNG 和 JPEG 等格式图像的解码器,用户也可以提供自己的解码器,但是为了保持程序体积较小,很多解码器并没有被全部包含,除非是明确需要支持的格式。image.Decode 函数在解码时会依次查询支持的格式列表。每个格式解码器包的入口指定了四件事情:格式的名称;一个用于描述这种图像格式类型的字符串,用于解码器检测识别;一个 Decode 函数用于完成解码图像工作;一个 DecodeConfig 函数用于解码图像的大小和颜色空间的信息。每个解码器包的入口通过调用 image.RegisterFormat 函数注册解码器,一般是在每个格式包的 init 初始化函数中调用,例如 image/png 包是这样注册的:

package png // image/png

func Decode(r io.Reader) (image.Image, error)
func DecodeConfig(r io.Reader) (image.Config, error)

func init() {
    const pngHeader = "\x89PNG\r\n\x1a\n"
    image.RegisterFormat("png", pngHeader, Decode, DecodeConfig)
}

如果没有这一行匿名导入语句,程序依然可以编译和运行,但是它将不能正确识别和解码 PNG 格式的图像。

数据库包 database/sql 也是采用了类似的技术,让用户可以根据自己需要选择导入必要的数据库驱动。例如:

import (
    "database/sql"
    _ "github.com/lib/pq"              // enable support for Postgres
    _ "github.com/go-sql-driver/mysql" // enable support for MySQL
)

db, err = sql.Open("postgres", dbname) // OK
db, err = sql.Open("mysql", dbname)    // OK
db, err = sql.Open("sqlite3", dbname)  // returns error: unknown driver "sqlite3"

包的命名#

下面是一些关于 Go 语言软件包和包成员命名的约定。

当创建一个包,一般要简洁明了的包名,但也不能太简短导致难以理解。标准库中最常用的包有 bufio、bytes、flag、fmt、http、io、json、os、sort、sync 和 time 等包,它们的名字都简洁明了。

要尽量避免包名与经常用于局部变量的名字发生冲突,否则可能导致用户重命名导入包,例如前面看到的 path 包。

包名一般采用单数的形式。标准库的 bytes、errors 和 strings 使用了复数形式,这是为了避免和预定义的类型冲突,同样还有 go/types 是为了避免和 type 关键字冲突。

要避免包名有其它的含义。例如,2.5 节中我们的温度转换包最初使用了 temp 包名,虽然并没有持续多久。但这是一个糟糕的尝试,因为 temp 几乎是临时变量的同义词。然后我们有一段时间使用了 temperature 作为包名,但是这个名字并没有表达包的真实用途。最后我们改成了和 strconv 标准包类似的 tempconv 包名,这个名字比之前的就好多了。

当设计一个包的时候,需要考虑包名和成员名两个部分如何很好地配合。成员名不必再包含包名,下面有一些例子:

bytes.Equal    flag.Int    http.Get    json.Marshal

我们可以看到一些常用的命名模式。strings 包提供了和字符串相关的诸多操作:

package strings

func Index(needle, haystack string) int

type Replacer struct{ /* ... */ }
func NewReplacer(oldnew ...string) *Replacer

type Reader struct{ /* ... */ }
func NewReader(s string) *Reader

字符串单词 string 本身并没有出现在每个成员名字中。因为用户会这样引用这些成员 strings.Indexstrings.Replacer 等。

本作品采用《CC 协议》,转载必须注明作者和本文链接
公众号:网管叨 bi 叨 | Golang、Laravel、Docker、K8s 等学习经验分享
讨论数量: 2
Summer

亲测:

$ go list std | wc -l
     199
$ go version
go version go1.13.6 darwin/amd64
5年前 评论

@Summer 嗯,1.12 版本下是 194 个

5年前 评论

未填写
文章
113
粉丝
368
喜欢
487
收藏
317
排名:34
访问:20.4 万
私信
所有博文
社区赞助商