03 | 库源码文件

你已经使用过Go语言编写了小命令(或者说微型程序)吗?

当你在编写“Hello, world”的时候,一个源码文件就足够了,虽然这种小玩意儿没什么用,最多能给你一点点莫名的成就感。如果你对这一点点并不满足,别着急,跟着学,我肯定你也可以写出很厉害的程序。


我们在上一篇的文章中学到了命令源码文件的相关知识,那么除了命令源码文件,你还能用Go语言编写库源码文件。那么什么是库源码文件呢?

在我的定义中,库源码文件是不能被直接运行的源码文件,它仅用于存放程序实体,这些程序实体可以被其他代码使用(只要遵从Go语言规范的话)。

这里的“其他代码”可以与被使用的程序实体在同一个源码文件内,也可以在其他源码文件,甚至其他代码包中。

那么程序实体是什么呢?在Go语言中,程序实体是变量、常量、函数、结构体和接口的统称。

我们总是会先声明(或者说定义)程序实体,然后再去使用。比如在上一篇的例子中,我们先定义了变量name,然后在main函数中调用fmt.Printf函数的时候用到了它。

再多说一点,程序实体的名字被统称为标识符。标识符可以是任何Unicode编码可以表示的字母字符、数字以及下划线“_”,但是其首字母不能是数字。

从规则上说,我们可以用中文作为变量的名字。但是,我觉得这种命名方式非常不好,自己也会在开发团队中明令禁止这种做法。作为一名合格的程序员,我们应该向着编写国际水准的程序无限逼近。

回到正题。

我们今天的问题是:怎样把命令源码文件中的代码拆分到其他库源码文件?

我们用代码演示,把这个问题说得更具体一些。

如果在某个目录下有一个命令源码文件demo4.go,如下:

package main

import (
	"flag"
)

var name string

func init() {
	flag.StringVar(&name, "name", "everyone", "The greeting object.")
}

func main() {
	flag.Parse()
	hello(name)
}

其中的代码你应该比较眼熟了。我在讲命令源码文件的时候贴过很相似的代码,那个源码文件名为demo2.go。

这两个文件的不同之处在于,demo2.go直接通过调用fmt.Printf函数打印问候语,而当前的demo4.go在同样位置调用了一个叫作hello的函数。

函数hello被声明在了另外一个源码文件中,我把它命名为demo4_lib.go,并且放在与demo4.go相同的目录下。如下:

// 需在此处添加代码。[1]

import "fmt"

func hello(name string) {
	fmt.Printf("Hello, %s!\n", name)
}

那么问题来了:注释1处应该填入什么代码?

典型回答

答案很简单,填入代码包声明语句package main。为什么?我之前说过,在同一个目录下的源码文件都需要被声明为属于同一个代码包。

如果该目录下有一个命令源码文件,那么为了让同在一个目录下的文件都通过编译,其他源码文件应该也声明属于main包。

如此一来,我们就可以运行它们了。比如,我们可以在这些文件所在的目录下运行如下命令并得到相应的结果。

$ go run demo4.go demo4_lib.go 
Hello, everyone!

或者,像下面这样先构建当前的代码包再运行。

$ go build puzzlers/article3/q1
$ ./q1            
Hello, everyone!

在这里,我把demo4.go和demo4_lib.go都放在了一个相对路径为puzzlers/article3/q1的目录中。

在默认情况下,相应的代码包的导入路径会与此一致。我们可以通过代码包的导入路径引用其中声明的程序实体。但是,这里的情况是不同的。

注意,demo4.go和demo4_lib.go都声明自己属于main包。我在前面讲Go语言源码的组织方式的时候提到过这种用法,即:源码文件声明的包名可以与其所在目录的名称不同,只要这些文件声明的包名一致就可以。

顺便说一下,我为本专栏创建了一个名为“Golang_Puzzlers”的项目。该项目的src子目录下会存有我们涉及的所有代码和相关文件。

也就是说,正确的用法是,你需要把该项目的打包文件下载到本地的任意目录下,然后经解压缩后把“Golang_Puzzlers”目录加入到环境变量GOPATH中。还记得吗?这会使“Golang_Puzzlers”目录成为工作区之一。

问题解析

这个问题考察的是代码包声明的基本规则。这里再总结一下。

第一条规则,同目录下的源码文件的代码包声明语句要一致。也就是说,它们要同属于一个代码包。这对于所有源码文件都是适用的。

如果目录中有命令源码文件,那么其他种类的源码文件也应该声明属于main包。这也是我们能够成功构建和运行它们的前提。

第二条规则,源码文件声明的代码包的名称可以与其所在的目录的名称不同。在针对代码包进行构建时,生成的结果文件的主名称与其父目录的名称一致。

对于命令源码文件而言,构建生成的可执行文件的主名称会与其父目录的名称相同,这在我前面的回答中也验证过了。

好了,经过我的反复强调,相信你已经记住这些规则了。下面的内容也将会与它们相关。

在编写真正的程序时,我们仅仅把代码拆分到几个源码文件中是不够的。我们往往会用模块化编程的方式,根据代码的功能和用途把它们放置到不同的代码包中。不过,这又会牵扯进一些Go语言的代码组织规则。我们一起来往下看。

知识精讲

1. 怎样把命令源码文件中的代码拆分到其他代码包?

我们先不用关注拆分代码的技巧。我在这里仍然依从前面的拆分方法。我把demo4.go另存为demo5.go,并放到一个相对路径为puzzlers/article3/q2的目录中。

然后我再创建一个相对路径为puzzlers/article3/q2/lib的目录,再把demo4_lib.go复制一份并改名为demo5_lib.go放到该目录中。

现在,为了让它们通过编译,我们应该怎样修改代码?你可以先思考一下。我在这里给出一部分答案,我们一起来看看已经过修改的demo5_lib.go文件。

package lib5

import "fmt"

func Hello(name string) {
	fmt.Printf("Hello, %s!\n", name)
}

可以看到,我在这里修改了两个地方。第一个改动是,我把代码包声明语句由package main改为了package lib5。注意,我故意让声明的包名与其所在的目录的名称不同。第二个改动是,我把全小写的函数名hello改为首字母大写的Hello

基于以上改动,我们再来看下面的几个问题。

2. 代码包的导入路径总会与其所在目录的相对路径一致吗?

库源码文件demo5_lib.go所在目录的相对路径是puzzlers/article3/q2/lib,而它却声明自己属于lib5包。在这种情况下,该包的导入路径是puzzlers/article3/q2/lib,还是puzzlers/article3/q2/lib5

这个问题往往会让Go语言的初学者们困惑,就算是用Go开发过程序的人也不一定清楚。我们一起来看看。

首先,我们在构建或者安装这个代码包的时候,提供给go命令的路径应该是目录的相对路径,就像这样:

go install puzzlers/article3/q2/lib 

该命令会成功完成。之后,当前工作区的pkg子目录下会产生相应的归档文件,具体的相对路径是:

pkg/darwin_amd64/puzzlers/article3/q2/lib.a

其中的darwin_amd64就是我在讲工作区时提到的平台相关目录。可以看到,这里与源码文件所在目录的相对路径是对应的。

为了进一步说明问题,我需要先对demo5.go做两个改动。第一个改动是,在以import为前导的代码包导入语句中加入puzzlers/article3/q2/lib,也就是试图导入这个代码包。

第二个改动是,把对hello函数的调用改为对lib.Hello函数的调用。其中的lib.叫做限定符,旨在指明右边的程序实体所在的代码包。不过这里与代码包导入路径的完整写法不同,只包含了路径中的最后一级lib,这与代码包声明语句中的规则一致。

现在,我们可以通过运行go run demo5.go命令试一试。错误提示会类似于下面这种。

./demo5.go:5:2: imported and not used: "puzzlers/article3/q2/lib" as lib5
./demo5.go:16:2: undefined: lib

第一个错误提示的意思是,我们导入了puzzlers/article3/q2/lib包,但没有实际使用其中的任何程序实体。这在Go语言中是不被允许的,在编译时就会导致失败。

注意,这里还有另外一个线索,那就是“as lib5”。这说明虽然导入了代码包puzzlers/article3/q2/lib,但是使用其中的程序实体的时候应该以lib5.为限定符。这也就是第二个错误提示的原因了。Go命令找不到lib.这个限定符对应的代码包。

为什么会是这样?根本原因就是,我们在源码文件中声明所属的代码包与其所在目录的名称不同。请记住,源码文件所在的目录相对于src目录的相对路径就是它的代码包导入路径,而实际使用其程序实体时给定的限定符要与它声明所属的代码包名称对应。

有两个方式可以使上述构建成功完成。我在这里选择把demo5_lib.go文件中的代码包声明语句改为package lib。理由是,为了不让该代码包的使用者产生困惑,我们总是应该让声明的包名与其父目录的名称一致。

3. 什么样的程序实体才可以被当前包外的代码引用?

你可能会有疑问,我为什么要把demo5_lib.go文件中的那个函数名称hello的首字母大写?实际上这涉及了Go语言中对于程序实体访问权限的规则。

超级简单,名称的首字母为大写的程序实体才可以被当前包外的代码引用,否则它就只能被当前包内的其他代码引用。

通过名称,Go语言自然地把程序实体的访问权限划分为了包级私有的和公开的。对于包级私有的程序实体,即使你导入了它所在的代码包也无法引用到它。

4. 对于程序实体,还有其他的访问权限规则吗?

答案是肯定的。在Go 1.5及后续版本中,我们可以通过创建internal代码包让一些程序实体仅仅能被当前模块中的其他代码引用。这被称为Go程序实体的第三种访问权限:模块级私有。

具体规则是,internal代码包中声明的公开程序实体仅能被该代码包的直接父包及其子包中的代码引用。当然,引用前需要先导入这个internal包。对于其他代码包,导入该internal包都是非法的,无法通过编译。

“Golang_Puzzlers”项目的puzzlers/article3/q4包中有一个简单的示例,可供你查看。你可以改动其中的代码并体会internal包的作用。

总结

我们在本篇文章中详细讨论了把代码从命令源码文件中拆分出来的方法,这包括拆分到其他库源码文件,以及拆分到其他代码包。

这里涉及了几条重要的Go语言基本编码规则,即:代码包声明规则、代码包导入规则以及程序实体的访问权限规则。在进行模块化编程时,你必须记住这些规则,否则你的代码很可能无法通过编译。

思考题

这次的思考题都是关于代码包导入的,如下。

  1. 如果你需要导入两个代码包,而这两个代码包的导入路径的最后一级是相同的,比如:dep/lib/flagflag,那么会产生冲突吗?
  2. 如果会产生冲突,那么怎样解决这种冲突,有几种方式?

第一个问题比较简单,你一试便知。强烈建议你编写个例子,然后运行go命令构建它,并看看会有什么样的提示。

而第二个问题涉及了代码包导入语句的高级写法,你可能需要去查阅一下Go语言规范。不过也不难。你最多能想出几种解决办法呢?你可以给我留言,我们一起讨论。

戳此查看Go语言专栏文章配套详细代码。

精选留言

  • daydayyiday

    2018-08-17 00:16:44

    注意是核心36讲,不是三个月从入门到精通,建议可以先从教程预习一下https://tour.go-zh.org/welcome/1
    作者回复

    好,你这个留言可以作为精选了:)。从零基础怎么一步步走我已经画好图了,择日发布。

    2018-08-17 08:51:31

  • Dylan

    2018-08-15 23:16:18

    讲得很到位呢,之前因为想看以太坊的源码,自己已经啃了一遍Go语言,现在回过头来在跟着老师学习,受益匪浅呀~
    作者回复

    对大家有帮助就好!

    2018-08-16 13:28:54

  • 松烽

    2018-08-15 20:56:13

    可以先看看郝爷的Go并发编程,真不错,很体系
  • 婺华

    2018-08-16 19:17:21

    我也觉得这种学习方式挺好,是新的尝试,因为对于有点基础的同学,总比去看太过基础的东西来的有效率,讲到点子上,也是不错的
  • 咖啡色的羊驼

    2018-08-15 01:01:31

    看完本文加深的关键点:
    1.同一个文件夹下,包的声明语句需要相同,代表同一个包。
    2.包名不需要和其所在的文件夹名相同。
    3.首字母大小写来代表可见性,大写public/小写private
    4.模块级私有internal的使用姿势✔✔✔(这个厉害了,之前还不知道这玩意儿)

    回答下问题:
    1.import后路径最后一级相同,不一定会冲突。
    分为两种情况:
    a.如果文件夹下文件声明的包名相同,则肯定冲突,会报错redeclared。
    b.如果文件夹下文件声明的包名不同,也不会冲突。

    2.如果冲突,我能想到的解决方式:
    a.给包设置别名,调用的时候来区分开不同的package,比如:import(b "bbbb")

    b.导入的点操作,import(. "bbbb")。这样就可以直接调用bbbb下面的函数而不用再bbbb.funcname的方式调用。

    c.如果只是想引入某包并没有在代码中实际调用则可以这么处理来避免冲突:import(_ "bbbb")

    d.像第一问一样采取不同的包名声明,毕竟包名不一定要和文件夹名一样

    不过总的推荐还是方法a。
  • dittolÖk

    2018-08-15 01:28:30

    还是不太适应这种学习方式
    作者回复

    你好,具体说说怎么不适应呢?

    2018-08-15 20:14:00

  • 郝林

    2018-08-16 07:50:36

    一些非核心的知识我在本专栏里其实是一笔带过甚至舍弃的,我相信抓问题只要抓重点就能解决80%甚至95%,剩下的大家再稍微自己弥补一下就OK了,这样有自己的努力在里面效果也会更好。不过,为了让大家更好的通过本专栏学习,我会再出一篇指引性的文章供大家参考。
  • hb

    2018-09-09 11:24:16

    有一个疑惑,go install生成的一个.a文件的具体作用是什么?我在demo中导入的还是源码文件的Hello函数,我尝试把pkg目录下的.a删除,还是可以正常运行和编译
  • 云学

    2018-08-15 13:19:36

    go语言有些地方设计的太坑了,感觉没有审美观,首字母大写这个在其他语言是用来表示类型的,它偏要独树一帜,看来还是python的哲学好!
    建议作者多给出一些最佳实践,不太习惯这种踩坑式教学,语言的第一感觉很重要,这些坑以附录的形式给出比较合适。
    作者回复

    你好,每个语言几乎都有自己风格和编程哲学,学以致用最重要,一些细节不用太在意。

    2018-08-15 20:08:46

  • 々雪虎々_卍

    2018-09-27 11:14:24

    1. 导入的包会出现冲突
    2. 从go语言的规范来看有几种避免的方法:
    Import declaration Local name of Sin

    import "lib/math" math.Sin
    import m "lib/math" m.Sin
    import . "lib/math" Sin
    import _ "lib/math"

    可以看出, 别名的方法比较好
  • 萧末

    2018-08-15 08:56:36

    可以通过包别名的方式解决冲突,如果导入的包不显式使用可以采用匿名的方式导入包
    作者回复

    答得很好。

    2018-08-15 20:12:18

  • Dkrxs

    2018-08-15 18:33:19

    我觉得这种学习方式挺好的,要是分享的在网上一搜一大把的那种demo什么的,买这个这个还有什么意义

  • 金阳

    2018-08-15 16:12:51

    go语言学习从入门到放弃(开玩笑)…学了这几篇,有些概念还不是很熟悉,主要是之间的联系,希望后面能给出解答。这种教学方式,就像小时候背古诗,你们先死记硬背,意思怎么样先不要理解,背会再说,霸王硬上弓。
  • Realm

    2018-08-15 07:58:01

    1 导入包时,import的是相对src的相对文件路径,使用包内的函数时,其限定符是:包名.函数名(),压根与程序的文件名没有啥关系.
    2 大写:Prubic;小写:private.
    作者回复

    说得很多,源码文件名是代码包内部的细节,不对外暴露。

    2018-08-15 20:13:40

  • 爱如少年

    2021-07-11 16:27:38

    我看过作者的第一版《go并发编程实战》,总体上看,作者的书的内容还是很棒的,但有一个感觉,就是书和本栏目的语言表达很有必要再推敲,感觉作者很喜欢使用很长的表达语句,看起来真的很费劲,看起来真的很别扭。比如本节这里:
    ”为什么会是这样?根本原因就是,我们在源码文件中声明所属的代码包与其所在目录的名称不同。请记住,源码文件所在的目录相对于 src 目录的相对路径就是它的代码包导入路径,而实际使用其程序实体时给定的限定符要与它声明所属的代码包名称对应。“
    如果是我来表达,我会这样:

    “产生这样的问题的原因是:当源码文件声明其所属的代码包与其所在的目录名称不同时,导入路径要使用源码文件目录与src 目录的相对路径,而限定符要使用其声明的代码包名称。”

    这是个人浅见。
  • 飞吧蛐蛐

    2018-10-15 15:28:53

    会有冲突,解决方法:
    1、包的别名。
    2、如果第二个导入的包不被显示使用,可以使用匿名导入( 包的 _操作,只调用包的init()函数 )。
    3、可以使用 包的点操作。(使用点操作引入的包,调用函数时直接使用函数名即可)。
    4、修改包名。(如果库源码文件比较多,比较费劲)
  • dlili

    2019-11-28 10:47:04

    总结一下前三章的内容:
    1. go的组织结构:GOROOT,GOPATH,GOBIN其中日常接触最多的是GOPATH,它是go的工作空间,主要有三个目录,src存储我们编写的工程源码(go 以包为代码组织单位,因为包名和目录名同名,因此我们可以认为一个目录就是一个包),bin是存放可执行文件的目录,比如beego框架下的脚手架bee.exe,这是由go get/install之后编译生成的。pkg是归档文件(静态链接库)存储的地方,我们使用go get/install之后会在此目录下生成相应的文件,以便我们在编写自己的程序时引用这些代码。

    2. 命令源码文件:其实就是程序的入口,这个入口可以理解为我们编写,组织程序的入口;也是go run的入口,在程序编译时会依据此命令源文件查找依赖的库文件(包)

    3. 库源码文件:可以理解成两部分,一部分是我们自己写的一些包,另一部分是我们go get获取的包;区别是我们在go build/run我们的命令源文件时,是否对这些包进行编译。

    对于包的导入规则,internal的用法需要掌握,这样更有利于组织代码,使程序体的访问权限得到更好的规范,有利于代码的安全稳定

    ps:老师可以把代码放到github上吗?这样更方便查看对比
    作者回复

    早就有了呀:https://github.com/hyper0x/Golang_Puzzlers

    2019-11-28 16:50:22

  • Erico.Len

    2018-08-16 01:02:05

    第一次接触go,虽然大学学过C,第一次看的很焖不知道联系 照着敲代码 看到报错后分析问题 疑问就会豁然开朗 建议以后可以举例一些实际项目中会关联的问题 ,通过这节课我觉得可以体会到以后搭建项目中包和目录以及程序实体之间的联系。比如MVC这个怎么布置会更合理一些 本人刚学意见不知道是否合理 望老师谅解
    作者回复

    MVC其实是一种设计模式组合,这在Java盛行的时代很流行。Java的很多Web框架都把这个组合模式实现了,然后提供完整的API给你。但是现在互联网尤其是移动互联网时代这就有些过时了,因为前端面向的端不只网页浏览器一个了,不适合再由写后端的人去搞了,所以前后端分离的做法开始流行起来,MVC这种前后端通吃的模式用的人就少了。建议你即使自己搞网站也不要用完整的MVC,这种模式现在看起来太重了,不太适合快节奏开发。

    2018-08-16 07:42:21

  • leo

    2018-08-15 08:49:25

    老师好,对于包管理的问题,之前会遇到cycle的问题,报错信息也很少,是否有什么定位方法?
    作者回复

    你好,我记得现在go命令会报错的吧。

    2018-08-15 20:18:41

  • ithunter

    2018-09-02 18:05:20

    为什么我在~/.zshrc里设置了
    export GOPATH=$HOME/GoLang:$HOME/git/go/Golang_Puzzlers
    然后执行了 source .zshrc,在执行go build puzzlers/article3/q1后,提示can't load package。只认到了$HOME/GoLang,认不到$HOME/git/go/Golang_Puzzlers
    作者回复

    先在当前的命令行下echo $GOPATH看看

    2018-09-03 15:04:06