Go语言包相关概念

1、包的相关概念

     Go 语言是使用包来组织源代码的,并实现命名空间的管理。任何源代码文件必须属于某个包。源码文件的第一行有效代码必须是 package pacakgeName 语句,通过该语句声明自己所在的包。

2、包的特征

     所有的 .go 文件,除了空行和注释,都应该在第一行声明自己所属的包。即所有代码都必须组织在 package 中。包的结构特点有:

  • 源文件头部以 package 声明包名称;
  • 包由同一目录下的多个源码文件组成,即一个目录下的同级文件属于同一个包;
  • 每个包都在一个单独的目录里;
  • 包所在的目录名最好不用 main 、 all 、 std 这三个保留名称;
  • 可执行文件必须包含 package main 和入口函数 main , main 包是 Go 语言程序的入口包,一个 Go 语言程序必须有且仅有一个 main 包,并且,一个 main 包中也必须有且仅有一个 main 函数。如果一个程序没有 main 包,那么编译时将会出错,无法生成可执行文件;
  • 不能把多个包放到同一个目录中,也不能把同一个包的文件分拆到多个不同目录中。这意味着,同一个目录下的所有 .go 文件必须声明同一个包名;

     包中成员以名称首字母大小写决定访问权限。该规则适用于全局变量、全局常量、类型、结构字段、函数、方法等。

  • Public : 首字母大写,可被包外访问;
  • internal : 首字母小写,仅包内成员可以访问;

3、包名约束

     给包命名的惯例是使用包所在目录的名字。给包及其目录命名时,应该使用简洁、清晰且全小写的名字,这有利于开发时频繁输入包名。
     记住,并不需要所有包的名字都与别的包不同,因为导入包时是使用全路径的,所以可以区分同名的不同包。
     一般情况下,包被导入后会使用你的包名作为默认的名字,不过这个导入后的名字可以修改。这个特性在需要导入不同目录的同名包时很有用。

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

  • 包对应一个可执行程序,也就是 main 包,这时候 main 包本身的导入路径是无关紧要的。名字为 main 的包是给 go build 构建命令一个信息,这个包编译完之后必须调用连接器生成一个可执行程序。
  • 包所在的目录中可能有一些文件名是以 _test.go 为后缀的 Go 源文件(译注:前面必须有其它的字符,因为以 _ 或 . 开头的源文件会被构建工具忽略),并且这些源文件声明的包名也是以 _test 为后缀名的。这种目录可以包含两种包:一种是普通包,另一种则是测试的外部扩展包。所有以 _test 为后缀包名的测试外部扩展包都由 go test 命令独立编译,普通包和测试的外部扩展包是相互独立的。测试的外部扩展包一般用来避免测试代码中的循环导入依赖。
  • 一些依赖版本号的管理工具会在导入路径后追加版本号信息,例如“gopkg.in/yaml.v2” 这种情况下包的名字并不包含版本号后缀,而是 yaml 。

4、main 包

     在 Go 语言里,命名为 main 的包具有特殊的含义。 Go 语言的编译程序会试图把这种名字的包编译为二进制可执行文件。所有用 Go 语言编译的可执行程序都必须有一个名叫 main 的包。
     当编译器发现某个包的名字为 main 时,它一定也会发现名为 main() 的函数,否则不会创建可执行文件。 main() 函数是程序的入口,所以,如果没有这个函数,程序就没有办法开始执行。程序编译时,会使用声明 main 包代码所在目录的目录名作为二进制可执行文件的文件名。

     而且通常来说,main 包应该很简洁。我们在 main 包中会做一些命令行参数解析、资源初始化、日志设施初始化、数据库连接初始化等工作,之后就会将程序的执行权限交给更高级的执行控制对象。

5、包的声明

     在 Go 语言中,代码包中的源码文件名可以是任意的,这些任意名称的源码文件都必须以包声明语句作为文件中代码的第一行。比如 src 目录下的代码包 common/upload 包中的所有源码文件都要先声明自己属于common/upload 包:

1
package upload

     package 是 Go 语言中用于包声明语句的关键字。 Go 语言规定包声明中的包名为代码包路径的最后一个元素。如上,common/upload 包的包路径为 common/upload ,而包声明中的包名则为 upload 。而针对源码文件(即包含 main 函数的 .go 文件),无论存放在哪个包中,它都必须声明为属于 main 包。

6、包的引入

     标准包的源码位于 $GOROOT/src/ 下面,标准包可以直接引用。自定义的包和第三方包的源码必须放到 $GOPATH/src/ 目录下才能被引用。导入包需要使用关键字 import ,它会告诉编译器你想引用该位置的包内的代码。如果需要导入多个包,习惯上是将 import 语句包装在一个导入块中。包的引用路径有两种写法, 一种是绝对路径,另一种是相对路径。要在代码中引用其他包的内容,需要使用 import 关键字导入使用的包。具体语法如下:

1
import "包的路径"

     注意事项:

  • import 导入语句通常放在源码文件开头包声明语句的下面;
  • 导入的包名需要使用双引号包裹起来;
  • 包名是从GOPATH/src/ 后开始计算的,使用/ 进行路径分隔。

     包的导入有两种写法,分别是单行导入和多行导入。

  • 单行导入:

    1
    2
    import "包 1 的路径"
    import "包 2 的路径"
  • 多行导入

    1
    2
    3
    4
    import (
    "包 1 的路径"
    "包 2 的路径"
    )

6.1、包的绝对路径

     包的绝对路径就是 $GOROOT/src 或 $GOPATH/src 后面包的源码的全路径,比如下面的包引用:

1
2
3
import "common/upload"
import "database/sql/driver"
import "database/sql"

     upload 包是自定义的包,其源码位于 $GOPATH/src/common/upload 目录下,代码包导入使用的路径就是代码包在工作区的 src 目录下的相对路径,比如 upload 的绝对路径为 /home/setsunayang/gocode/src/common/upload ,而 /home/setsunayang/gocode 是被包含在环境变量 GOPATH 中的工作区目录路径,则其代码包导入路径就是 common/upload。

     sql 和 driver 包的源码分别位于 $GOROOT/src/database/sql 和 $GOROOT/src/database/sql/driver 下。

     编译器会首先查找 Go 的安装目录,然后才会按顺序查找 GOPATH 变量里列出的目录。一旦编译器找到一个满足 import 语句的包,就停止进一步查找。

6.2、相对路径引用

     相对路径只能用于引用 $GOPATH 下的包,标准包的引用只能使用全路径引用。比如下面两个包:包 a 的路径是 $GOPATH/src/lab/a ,包 b 的源码路径为 $GOPATH/src/lab/b ,假设 b 引用了 a 包,则可以使用相对路径引用方式。示例如下:

1
2
3
4
5
// 相对路径引用
import "../a"

// 绝对路径引用
import "lab/a"

6.3、引用格式

     常用的包引用有以下 4 种格式,我们以 fmt 包为例进行说明。

  • 标准引用方式
1
import "fmt”

     此时可以用 fmt.

  • 别名引用方式
1
import F "fmt”

     此时相当于给包 fmt 起了个别名 F ,用 F. 代替标准的 fmt.作为前缀引用 fmt 包内可导出元素。

  • 省略引用方式
1
import . "fmt"

     此时相当于把包 fmt 的命名空间直接合并到当前程序的命名空间中,使用 fmt 包内可导出元素可以不用前缀 fmt. ,直接引用。示例如下:

1
2
3
4
5
6
package main
import . "fmt"
func main() {
// 不需要加前级fmt.
Println("hello , world”)
}
  • 仅执行包初始化 init 函数

     使用标准格式引用包,但是代码中却没有使用包,编译器会报错。如果包中有 init 初始化函数,则通过 import packageName 这种方式引用包,仅执行包的初始化函数,即使包没有 init 初始化函数,也不会引发编译器报错。示例如下:

1
import  _ "fmt"

     下划线字符 _ 在 Go 语言里称为空白标识符,这个标识符用来抛弃不想继续使用的值,如给导入的包赋予一个空名字,或者忽略函数返回的你不感兴趣的值。

  • 远程导入

     Go 工具链会使用导入路径确定需要获取的代码在网络的什么地方。

1
import "github.com/net/http"

     用导入路径编译程序时, go build 命令会使用 GOPATH 的设置,在磁盘上搜索这个包。

     事实上,这个导入路径代表一个 URL ,指向 GitHub 上的代码库。如果路径包含 URL ,可以使用 Go 工具链从分布式版本控制系统获取包,并把包的源代码保存在 GOPATH 指向的路径里与 URL 匹配的目录里。

     这个获取过程使用 go get 命令完成。go get 将获取任意指定的 URL 的包,或者一个已经导入的包所依赖的其它包。由于 go get 的这种递归特性,这个命令会扫描某个包的源码树,获取能找到的所有依赖包。

6.3、综合实践

     当导入多个代码包时,需要用圆括号括起它们,且每个代码包名独占一行。在调用被导入代码包中的函数或使用其中的结构体、变量或常量时,需要使用包路径的最后一个元素加 . 的方式指定代码所在的包。

     例如,如果我们有两个包 logging 和 go_lib/logging , 并且有相同的方法 logging_print() ,且有一个源码文件需要导入这两个包(标准引用):

1
2
3
4
import (
"logging"
"go_lib/logging"
)

     则这句代码 logging.logging_print() 就会引起冲突, Go 语言无法知道 logging.Xxx() 代表的是哪一个包。所以,在 Go 语言中,如果在同一个源码文件中使用上述方法导入多个代码包,那么代码包路径的最后一个元素不可以重复。

     如果用这段代码包导入代码,在编译代码时,Go 语言会抛出:

1
”logging redeclared as imported package name”

的错误。如果确实需要导入,当有这类重复时,我们可以给它们起个别名来区别(别名引用):

1
2
3
4
import (
la "logging"
lb "go_lib/logging"
)

调用包中的代码以如下方式:

1
var logger la.Logger = la.logging_print()

     这里不必给每个引起冲突的代码包都起一个别名,只要能够区分它们就可以了。如果我们想直接调用某个依赖包的程序,就可以用 . 来代替别名(省略引用)。

1
2
3
4
import (
. "logging"
lb "go_lib/logging"
)

在当前源码文件中,可以直接进行代码调用了:

1
var logger Logger = logging_print()

     Go 语言把变量、常量、函数、结构体和接口统称为程序实体,而把它们的名字统称为标识符。标识符可以是任何 Unicode 编码可以表示的字母字符、数字以及下划线 ”_”,并且,首字母不能是数字。标识符的首字母的大小写控制着对应程序实体的访问权限。

     如果标识符的首字母是大写的,那么它对应的程序实体就可以被本代码包之外的代码访问到,也可以称其为可导出的。否则对应的程序实体就只能被本包内的代码访问。当然,还需要有以下两个额外条件:

  • (1)、程序实体必须是非局部的。局部程序实体是被定义在函数或结构体的内部。
  • (2)、代码包所在的目录必须被包含在环境变量 GOPATH 中的工作区目录中。

     如果代码包 logging 中有一个叫做 getSimpleLogger 的函数,那么光从这个函数的名字上我们就可以看出,这个函数是不能被包外代码调用的。

     如果我们只想初始化某个代码包而不需要在当前源码文件中使用那个代码包中的任何代码,即可以用 _ 来代替别名(仅执行包初始化 init 函数的引用方式)。

1
2
3
import (
_ "logging"
)

6.5、注意事项

     一个包可以有多个 init 函数,包加载会执行全部的 init 函数,但并不能保证执行顺序,所以不建议在一个包中放入多个 init 函数,将需要初始化的逻辑放到一个 init 函数里面。

     包不能出现循环引用。比如包 a 引用了包 b ,包 b 引用了包 c,如果包 c 又引用了包 a,则编译不能通过。

     包的重复引用是允许的。比如包 a 引用了包 b 和包 c ,包 b 和包 c 都引用了包 d 。这种场景相当于重复引用了d,这种情况是允许的, 并且 Go 编译器保证 d 的 init 函数只会执行一次。

7、包初始化

     在 Go 语言中,可以有专门的函数负责代码包初始化。这个函数需要无参数声明和结果声明,且名称必须为 init ,如下:

1
2
3
func init() {
println("Initialize")
}

     Go 语言会在程序真正执行前对整个程序的依赖进行分析,并初始化相关的代码包。也就是说,所有的代码包初始化函数都会在 main 函数(命令源码文件中的入口函数)之前执行完成,而且只会执行一次。并且,当前代码包中的所有全局变量的初始化都会在代码包初始化函数执行前完成。这就避免了在代码包初始化函数对某个变量进行赋值之后又被该变量声明中赋予的值覆盖掉的问题。

     每个包可以包含任意多个 init 函数,这些函数都会在程序执行开始的时候被调用。所有被编译器发现的 init 函数都会安排在 main 函数之前执行。 init 函数用在设置包、初始化变量或者其他要在程序运行前优先完成的引导工作。

     Go 里面有两个保留的函数: init 函数(能够应用于所有的 package )和 main 函数(只能应用于 package main )。这两个函数在定义时不能有任何的参数和返回值。

     虽然一个 package 里面可以写任意多个 init 函数,但这无论是对于可读性还是以后的可维护性来说,我们都强烈建议用户在一个 package 中每个文件只写一个 init 函数。

     Go 程序会自动调用 init() 和 main() ,所以不需要在任何地方调用这两个函数。每个 package 中的 init 函数都是可选的,但 package main 只能包含一个 main 函数。

     程序的初始化和执行都起始于 main 包。如果 main 包还导入了其它的包,那么就会在编译时将它们依次导入。有时一个包会被多个包同时导入,那么它只会被导入一次(例如很多包可能都会用到 fmt 包,但它只会被导入一次,因为没有必要导入多次)。

     当一个包被导入时,如果该包还导入了其它的包,那么会先将其它包导入进来,然后再对这些包中的包级常量和变量进行初始化,接着执行 init 函数(如果有的话),依次类推。等所有被导入的包都加载完毕了,就会开始对 main 包中的包级常量和变量进行初始化,然后执行 main 包中的 init 函数(如果存在的话),最后执行 main 函数。下图详细地解释了整个执行过程:

包的初始化过程

     init 函数特征总结:

  • 每个源文件都可以定义一个或多个初始化函数,但强烈建议只定义一个。
  • 编译器不保证多个初始化函数执行次序。
  • 初始化函数在单一线程被用,仅执行一次。
  • 初始化函数在包所有全局变量初始化后执行。
  • 在所有初始化函数结束后才执行 main.main。
  • init() 函数不能被其他函数调用。

init执行过程

     所以简而言之,你只需要记住这三点就可以了:

  • 依赖包按“深度优先”的次序进行初始化;
  • 每个包内按以“常量 -> 变量 -> init 函数”的顺序进行初始化;
  • 包内的多个 init 函数按出现次序进行自动调用;

     重点关注 init 函数具备的几种行为特征:

  • 执行顺位排在包内其他语法元素常量、变量的后面;
  • 每个 init 函数在整个 Go 程序生命周期内仅会被执行一次;
  • init 函数是顺序执行的,只有当一个 init 函数执行完毕后,才会去执行下一个 init 函数。

     这里举出《Go并发编程实战》中的例子,帮助理解上面的包初始化,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package main // 命令源码文件必须在这里声明自己属于main包

import ( // 引入了代码包fmt和runtime
"fmt"
"runtime"
)

func init() { // 包初始化函数
fmt.Printf("Map: %v\n", m) // 先格式化再打印
// 通过调用runtime包的代码获取当前机器所运行的操作系统以及计算架构
// 而后通过fmt包的Sprintf方法进行字符串格式化并赋值给变量info
info = fmt.Sprintf("OS: %s, Arch: %s", runtime.GOOS, runtime.GOARCH)
}

// 非局部变量,map类型,且已初始化
var m map[int]string = map[int]string{1: "A", 2: "B", 3: "C"}
var info string // 非局部变量,string类型,未被初始化

func main() { // 命令源码文件必须有的入口函数
fmt.Println(info) // 打印变量info
}

     输出:

1
2
Map: map[1:A 2:B 3:C]
OS: windows, Arch: amd64

     在同一个代码包中,可以存在多个代码包初始化函数,甚至代码包内的每一个源码文件都可以定义多个代码包初始化函数。Go 语言编译器不能保证同一个代码包中的多个代码包初始化函数的执行顺序。如果要求按特定顺序执行的话,可以考虑使用 Channel 。

8、编译速度

     当我们修改了一个源文件,我们必须重新编译该源文件对应的包和所有依赖该包的其他包。即使是从头构建, Go 语言编译器的编译速度也明显快于其它编译语言。 Go 语言的闪电般的编译速度主要得益于三个语言特性。

  • 所有导入的包必须在每个文件的开头显式声明,这样的话编译器就没有必要读取和分析整个源文件来判断包的依赖关系。
  • 禁止包的环状依赖,因为没有循环依赖,包的依赖关系形成一个有向无环图,每个包可以被独立编译,而且很可能是被并发编译。
  • 编译后包的目标文件不仅仅记录包本身的导出信息,目标文件同时还记录了包的依赖关系。因此,在编译一个包的时候,编译器只需要读取每个直接导入包的目标文件,而不需要遍历所有依赖的的文件(译注:很多都是重复的间接依赖)。
打赏
  • Copyrights © 2023-2024 杨海波
  • 访问人数: | 浏览次数:

请我喝杯咖啡吧~

支付宝
微信