This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,201 @@
<audio id="audio" title="01 | 工作区和GOPATH" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/28/07/28558270d89996af78b58fb5ce15f807.mp3"></audio>
### 【Go语言代码较多建议配合文章收听音频。】
你好我是郝林。从今天开始我将和你一起梳理Go语言的整个知识体系。
在过去的几年里我与广大爱好者一起见证了Go语言的崛起。
从Go 1.5版本的自举即用Go语言编写程序来实现Go语言自身到Go 1.7版本的极速GC也称垃圾回收器再到2018年2月发布的Go 1.10版本对其自带工具的全面升级,以及可预见的后续版本关键特性(比如用来做程序依赖管理的`go mod`命令这一切都令我们欢欣鼓舞。Go语言在一步步走向辉煌的同时显然已经成为软件工程师们最喜爱的编程语言之一。
我开办这个专栏的主要目的是要与你一起探索Go语言的奥秘并帮助你在学习和实践的过程中获取更多。
我假设本专栏的读者已经具备了一定的计算机基础,比如,你要知道操作系统是什么、环境变量怎么设置、怎样正确使用命令行,等等。
当然了如果你已经有了编程经验尤其是一点点Go语言编程经验那就更好了毕竟我想教给你的都是Go语言中非常核心的技术。
如果你对Go语言中最基本的概念和语法还不够了解那么可能需要在学习本专栏的过程中去查阅[Go语言规范文档](https://golang.google.cn/ref/spec),也可以把预习篇的基础知识图拿出来好好研究一下。
最后我来说一下专栏的讲述模式。我总会以一道Go语言的面试题开始针对它进行解答我会告诉你为什么我要关注这道题这道题的背后隐藏着哪些知识并且我会对这部分的内容进行相关的知识扩展。
好了,准备就绪,我们一起开始。
我们学习Go语言时要做的第一件事都是根据自己电脑的计算架构比如是32位的计算机还是64位的计算机以及操作系统比如是Windows还是Linux从[Go语言官网](https://golang.google.cn)下载对应的二进制包,也就是可以拿来即用的安装包。
随后,我们会解压缩安装包、放置到某个目录、配置环境变量,并通过在命令行中输入`go version`来验证是否安装成功。
在这个过程中我们还需要配置3个环境变量也就是GOROOT、GOPATH和GOBIN。这里我可以简单介绍一下。
- GOROOTGo语言安装根目录的路径也就是GO语言的安装路径。
- GOPATH若干工作区目录的路径。是我们自己定义的工作空间。
- GOBINGO程序生成的可执行文件executable file的路径。
其中GOPATH背后的概念是最多的也是最重要的。那么**今天我们的面试问题是你知道设置GOPATH有什么意义吗**
关于这个问题,它的**典型回答**是这样的:
你可以把GOPATH简单理解成Go语言的工作目录它的值是一个目录的路径也可以是多个目录路径每个目录都代表Go语言的一个工作区workspace
我们需要利于这些工作区去放置Go语言的源码文件source file以及安装install后的归档文件archive file也就是以“.a”为扩展名的文件和可执行文件executable file
事实上由于Go语言项目在其生命周期内的所有操作编码、依赖管理、构建、测试、安装等基本上都是围绕着GOPATH和工作区进行的。所以它的背后至少有3个知识点分别是
**1. Go语言源码的组织方式是怎样的**
**2.你是否了解源码安装后的结果只有在安装后Go语言源码才能被我们或其他代码使用**
**3.你是否理解构建和安装Go程序的过程这在开发程序以及查找程序问题的时候都很有用否则你很可能会走弯路。**
下面我就重点来聊一聊这些内容。
## 知识扩展
## 1. Go语言源码的组织方式
与许多编程语言一样Go语言的源码也是以代码包为基本组织单位的。在文件系统中这些代码包其实是与目录一一对应的。由于目录可以有子目录所以代码包也可以有子包。
一个代码包中可以包含任意个以.go为扩展名的源码文件这些源码文件都需要被声明属于同一个代码包。
代码包的名称一般会与源码文件所在的目录同名。如果不同名,那么在构建、安装的过程中会以代码包名称为准。
每个代码包都会有导入路径。代码包的导入路径是其他代码在使用该包中的程序实体时,需要引入的路径。在实际使用程序实体之前,我们必须先导入其所在的代码包。具体的方式就是`import`该代码包的导入路径。就像这样:
```
import &quot;github.com/labstack/echo&quot;
```
在工作区中一个代码包的导入路径实际上就是从src子目录到该包的实际存储位置的相对路径。
所以说Go语言源码的组织方式就是以环境变量GOPATH、工作区、src目录和代码包为主线的。一般情况下Go语言的源码文件都需要被存放在环境变量GOPATH包含的某个工作区目录中的src目录下的某个代码包目录中。
## 2. 了解源码安装后的结果
了解了Go语言源码的组织方式后我们很有必要知道Go语言源码在安装后会产生怎样的结果。
源码文件以及安装后的结果文件都会放到哪里呢我们都知道源码文件通常会被放在某个工作区的src子目录下。
那么在安装后如果产生了归档文件(以“.a”为扩展名的文件就会放进该工作区的pkg子目录如果产生了可执行文件就可能会放进该工作区的bin子目录。
我再讲一下归档文件存放的具体位置和规则。
源码文件会以代码包的形式组织起来,一个代码包其实就对应一个目录。安装某个代码包而产生的归档文件是与这个代码包同名的。
放置它的相对目录就是该代码包的导入路径的直接父级。比如,一个已存在的代码包的导入路径是
```
github.com/labstack/echo
```
那么执行命令
```
go install github.com/labstack/echo
```
生成的归档文件的相对目录就是 [github.com/labstack](http://github.com/labstack%EF%BC%8C) 文件名为echo.a。
顺便说一下上面这个代码包导入路径还有另外一层含义那就是该代码包的源码文件存在于GitHub网站的labstack组的代码仓库echo中。
再说回来归档文件的相对目录与pkg目录之间还有一级目录叫做平台相关目录。平台相关目录的名称是由build也称“构建”的目标操作系统、下划线和目标计算架构的代号组成的。
比如构建某个代码包时的目标操作系统是Linux目标计算架构是64位的那么对应的平台相关目录就是linux_amd64。
因此上述代码包的归档文件就会被放置在当前工作区的子目录pkg/linux_amd64/github.com/labstack中。
<img src="https://static001.geekbang.org/resource/image/2f/3c/2fdfb5620e072d864907870e61ae5f3c.png" alt=""><br>
GOPATH与工作区
总之你需要记住的是某个工作区的src子目录下的源码文件在安装后一般会被放置到当前工作区的pkg子目录下对应的目录中或者被直接放置到该工作区的bin子目录中。
## 3. 理解构建和安装Go程序的过程
我们再来说说构建和安装Go程序的过程都是怎样的以及它们的异同点。
构建使用命令`go build`,安装使用命令`go install`。构建和安装代码包的时候都会执行编译、打包等操作,并且,这些操作生成的任何文件都会先被保存到某个临时的目录中。
如果构建的是库源码文件,那么操作后产生的结果文件只会存在于临时目录中。这里的构建的主要意义在于检查和验证。
如果构建的是命令源码文件,那么操作的结果文件会被搬运到源码文件所在的目录中。(这里讲到的两种源码文件我在[“预习篇”的基础知识图](https://time.geekbang.org/column/article/13540?utm_source=weibo&amp;utm_medium=xuxiaoping&amp;utm_campaign=promotion&amp;utm_content=columns)中提到过,在后面的文章中我也会带你详细了解。)
安装操作会先执行构建,然后还会进行链接操作,并且把结果文件搬运到指定目录。
进一步说如果安装的是库源码文件那么结果文件会被搬运到它所在工作区的pkg目录下的某个子目录中。
如果安装的是命令源码文件那么结果文件会被搬运到它所在工作区的bin目录中或者环境变量`GOBIN`指向的目录中。
这里你需要记住的是,构建和安装的不同之处,以及执行相应命令后得到的结果文件都会出现在哪里。
## 总结
工作区和GOPATH的概念和含义是每个Go工程师都需要了解的。虽然它们都比较简单但是说它们是Go程序开发的核心知识并不为过。
然而我在招聘面试的过程中仍然发现有人忽略掉了它们。Go语言提供的很多工具都是在GOPATH和工作区的基础上运行的比如上面提到的`go build``go install``go get`,这三个命令也是我们最常用到的。
## 思考题
说到Go程序中的依赖管理其实还有很多问题值得我们探索。我在这里留下两个问题供你进一步思考。
1. Go语言在多个工作区中查找依赖包的时候是以怎样的顺序进行的
1. 如果在多个工作区中都存在导入路径相同的代码包会产生冲突吗?
这两个问题之间其实是有一些关联的。答案并不复杂你做几个试验几乎就可以找到它了。你也可以看一下Go语言标准库中`go build`包及其子包的源码。那里面的宝藏也很多可以助你深刻理解Go程序的构建过程。
## 补充阅读
## go build命令一些可选项的用途和用法
在运行`go build`命令的时候,默认不会编译目标代码包所依赖的那些代码包。当然,如果被依赖的代码包的归档文件不存在,或者源码文件有了变化,那它还是会被编译。
如果要强制编译它们,可以在执行命令的时候加入标记`-a`。此时,不但目标代码包总是会被编译,它依赖的代码包也总会被编译,即使依赖的是标准库中的代码包也是如此。
另外,如果不但要编译依赖的代码包,还要安装它们的归档文件,那么可以加入标记`-i`
那么我们怎么确定哪些代码包被编译了呢?有两种方法。
1. 运行`go build`命令时加入标记`-x`,这样可以看到`go build`命令具体都执行了哪些操作。另外也可以加入标记`-n`,这样可以只查看具体操作而不执行它们。
1. 运行`go build`命令时加入标记`-v`,这样可以看到`go build`命令编译的代码包的名称。它在与`-a`标记搭配使用时很有用。
下面再说一说与Go源码的安装联系很紧密的一个命令`go get`
命令`go get`会自动从一些主流公用代码仓库比如GitHub下载目标代码包并把它们安装到环境变量`GOPATH`包含的第1工作区的相应目录中。如果存在环境变量`GOBIN`,那么仅包含命令源码文件的代码包会被安装到`GOBIN`指向的那个目录。
最常用的几个标记有下面几种。
- `-u`:下载并安装代码包,不论工作区中是否已存在它们。
- `-d`:只下载代码包,不安装代码包。
- `-fix`在下载代码包后先运行一个用于根据当前Go语言版本修正代码的工具然后再安装代码包。
- `-t`:同时下载测试所需的代码包。
- `-insecure`允许通过非安全的网络协议下载和安装代码包。HTTP就是这样的协议。
Go语言官方提供的`go get`命令是比较基础的其中并没有提供依赖管理的功能。目前GitHub上有很多提供这类功能的第三方工具比如`glide``gb`以及官方出品的`dep``vgo`等等,它们在内部大都会直接使用`go get`
有时候,我们可能会出于某种目的变更存储源码的代码仓库或者代码包的相对路径。这时,为了让代码包的远程导入路径不受此类变更的影响,我们会使用自定义的代码包导入路径。
对代码包的远程导入路径进行自定义的方法是:在该代码包中的库源码文件的包声明语句的右边加入导入注释,像这样:
```
package semaphore // import &quot;golang.org/x/sync/semaphore&quot;
```
这个代码包原本的完整导入路径是`github.com/golang/sync/semaphore`。这与实际存储它的网络地址对应的。该代码包的源码实际存在GitHub网站的golang组的sync代码仓库的semaphore目录下。而加入导入注释之后用以下命令即可下载并安装该代码包了
```
go get golang.org/x/sync/semaphore
```
而Go语言官网golang.org下的路径/x/sync/semaphore并不是存放`semaphore`包的真实地址。我们称之为代码包的自定义导入路径。
不过这还需要在golang.org这个域名背后的服务端程序上添加一些支持才能使这条命令成功。
关于自定义代码包导入路径的完整说明可以参看[这里](https://github.com/hyper0x/go_command_tutorial/blob/master/0.3.md)。
好了,对于`go build`命令和`go get`命令的简短介绍就到这里。如果你想查阅更详细的文档那么可以访问Go语言官方的[命令文档页面](https://golang.google.cn/cmd/go),或者在命令行下输入诸如`go help build`这类的命令。
[戳此查看Go语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers)

View File

@@ -0,0 +1,282 @@
<audio id="audio" title="02 | 命令源码文件" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/61/b9/610311fdbc898ade739f9d43eb7a1ab9.mp3"></audio>
我们已经知道环境变量GOPATH指向的是一个或多个工作区每个工作区中都会有以代码包为基本组织形式的源码文件。
**这里的源码文件又分为三种,即:命令源码文件、库源码文件和测试源码文件,它们都有着不同的用途和编写规则。(** 我在[“预习篇”的基础知识图](https://time.geekbang.org/column/article/13540?utm_source=weibo&amp;utm_medium=xuxiaoping&amp;utm_campaign=promotion&amp;utm_content=columns)介绍过这三种文件的基本情况。)
<img src="https://static001.geekbang.org/resource/image/9d/cb/9d08647d238e21e7184d60c0afe5afcb.png" alt="">
(长按保存大图查看)
今天,我们就沿着**命令源码文件**的知识点,展开更深层级的学习。
一旦开始学习用编程语言编写程序,我们就一定希望在编码的过程中及时地得到反馈,只有这样才能清楚对错。实际上,我们的有效学习和进步,都是通过不断地接受反馈和执行修正实现的。
对于Go语言学习者来说你在学习阶段中也一定会经常编写可以直接运行的程序。这样的程序肯定会涉及命令源码文件的编写而且命令源码文件也可以很方便地用`go run`命令启动。
那么,**我今天的问题就是:命令源码文件的用途是什么,怎样编写它?**
这里,我给出你一个**参考的回答**:命令源码文件是程序的运行入口,是每个可独立运行的程序必须拥有的。我们可以通过构建或安装,生成与其对应的可执行文件,后者一般会与该命令源码文件的直接父目录同名。
**如果一个源码文件声明属于`main`包,并且包含一个无参数声明且无结果声明的`main`函数,那么它就是命令源码文件。** 就像下面这段代码:
```
package main
import &quot;fmt&quot;
func main() {
fmt.Println(&quot;Hello, world!&quot;)
}
```
如果你把这段代码存成demo1.go文件那么运行`go run demo1.go`命令后就会在屏幕(标准输出)中看到`Hello, world!`
>
当需要模块化编程时,我们往往会将代码拆分到多个文件,甚至拆分到不同的代码包中。但无论怎样,对于一个独立的程序来说,命令源码文件永远只会也只能有一个。如果有与命令源码文件同包的源码文件,那么它们也应该声明属于`main`包。
## 问题解析
命令源码文件如此重要以至于它毫无疑问地成为了我们学习Go语言的第一助手。不过只会打印`Hello, world`是远远不够的咱们千万不要成为“Hello, world”党。既然决定学习Go语言你就应该从每一个知识点深入下去。
无论是Linux还是Windows如果你用过命令行command line的话肯定就会知道几乎所有命令command都是可以接收参数argument的。通过构建或安装命令源码文件生成的可执行文件就可以被视为“命令”既然是命令那么就应该具备接收参数的能力。
下面,我就带你深入了解一下与命令参数的接收和解析有关的一系列问题。
## 知识精讲
### 1. 命令源码文件怎样接收参数
我们先看一段不完整的代码:
```
package main
import (
// 需在此处添加代码。[1]
&quot;fmt&quot;
)
var name string
func init() {
// 需在此处添加代码。[2]
}
func main() {
// 需在此处添加代码。[3]
fmt.Printf(&quot;Hello, %s!\n&quot;, name)
}
```
**如果邀请你帮助我,在注释处添加相应的代码,并让程序实现”根据运行程序时给定的参数问候某人”的功能,你会打算怎样做?**
如果你知道做法,请现在就动手实现它。如果不知道也不要着急,咱们一起来搞定。
首先Go语言标准库中有一个代码包专门用于接收和解析命令参数。这个代码包的名字叫`flag`
我之前说过,如果想要在代码中使用某个包中的程序实体,那么应该先导入这个包。因此,我们需要在`[1]`处添加代码`"flag"`。注意,这里应该在代码包导入路径的前后加上英文半角的引号。如此一来,上述代码导入了`flag``fmt`这两个包。
其次,人名肯定是由字符串代表的。所以我们要在`[2]`处添加调用`flag`包的`StringVar`函数的代码。就像这样:
```
flag.StringVar(&amp;name, &quot;name&quot;, &quot;everyone&quot;, &quot;The greeting object.&quot;)
```
函数`flag.StringVar`接受4个参数。
第1个参数是用于存储该命令参数值的地址具体到这里就是在前面声明的变量`name`的地址了,由表达式`&amp;name`表示。
第2个参数是为了指定该命令参数的名称这里是`name`
第3个参数是为了指定在未追加该命令参数时的默认值这里是`everyone`
至于第4个函数参数即是该命令参数的简短说明了这在打印命令说明时会用到。
顺便说一下,还有一个与`flag.StringVar`函数类似的函数,叫`flag.String`。这两个函数的区别是,后者会直接返回一个已经分配好的用于存储命令参数值的地址。如果使用它的话,我们就需要把
```
var name string
```
改为
```
var name = flag.String(&quot;name&quot;, &quot;everyone&quot;, &quot;The greeting object.&quot;)
```
所以,如果我们使用`flag.String`函数就需要改动原有的代码。这样并不符合上述问题的要求。
再说最后一个填空。我们需要在`[3]`处添加代码`flag.Parse()`。函数`flag.Parse`用于真正解析命令参数,并把它们的值赋给相应的变量。
对该函数的调用必须在所有命令参数存储载体的声明(这里是对变量`name`的声明)和设置(这里是在`[2]`处对`flag.StringVar`函数的调用)之后,并且在读取任何命令参数值之前进行。
正因为如此,我们最好把`flag.Parse()`放在`main`函数的函数体的第一行。
### 2. 怎样在运行命令源码文件的时候传入参数,又怎样查看参数的使用说明
如果我们把上述代码存成名为demo2.go的文件那么运行如下命令就可以为参数`name`传值:
```
go run demo2.go -name=&quot;Robert&quot;
```
运行后打印到标准输出stdout的内容会是
```
Hello, Robert!
```
另外,如果想查看该命令源码文件的参数说明,可以这样做:
```
$ go run demo2.go --help
```
其中的`$`表示我们是在命令提示符后运行`go run`命令的。运行后输出的内容会类似:
```
Usage of /var/folders/ts/7lg_tl_x2gd_k1lm5g_48c7w0000gn/T/go-build155438482/b001/exe/demo2:
 -name string
    The greeting object. (default &quot;everyone&quot;)
exit status 2
```
你可能不明白下面这段输出代码的意思。
```
/var/folders/ts/7lg_tl_x2gd_k1lm5g_48c7w0000gn/T/go-build155438482/b001/exe/demo2
```
这其实是`go run`命令构建上述命令源码文件时临时生成的可执行文件的完整路径。
如果我们先构建这个命令源码文件再运行生成的可执行文件,像这样:
```
$ go build demo2.go
$ ./demo2 --help
```
那么输出就会是
```
Usage of ./demo2:
 -name string
    The greeting object. (default &quot;everyone&quot;)
```
### 3. 怎样自定义命令源码文件的参数使用说明
这有很多种方式,最简单的一种方式就是对变量`flag.Usage`重新赋值。`flag.Usage`的类型是`func()`,即一种无参数声明且无结果声明的函数类型。
`flag.Usage`变量在声明时就已经被赋值了,所以我们才能够在运行命令`go run demo2.go --help`时看到正确的结果。
注意,对`flag.Usage`的赋值必须在调用`flag.Parse`函数之前。
现在我们把demo2.go另存为demo3.go然后在`main`函数体的开始处加入如下代码。
```
flag.Usage = func() {
 fmt.Fprintf(os.Stderr, &quot;Usage of %s:\n&quot;, &quot;question&quot;)
 flag.PrintDefaults()
}
```
那么当运行
```
$ go run demo3.go --help
```
后,就会看到
```
Usage of question:
 -name string
    The greeting object. (default &quot;everyone&quot;)
exit status 2
```
现在再深入一层,我们在调用`flag`包中的一些函数(比如`StringVar``Parse`等等)的时候,实际上是在调用`flag.CommandLine`变量的对应方法。
`flag.CommandLine`相当于默认情况下的命令参数容器。所以,通过对`flag.CommandLine`重新赋值,我们可以更深层次地定制当前命令源码文件的参数使用说明。
现在我们把`main`函数体中的那条对`flag.Usage`变量的赋值语句注销掉,然后在`init`函数体的开始处添加如下代码:
```
flag.CommandLine = flag.NewFlagSet(&quot;&quot;, flag.ExitOnError)
flag.CommandLine.Usage = func() {
fmt.Fprintf(os.Stderr, &quot;Usage of %s:\n&quot;, &quot;question&quot;)
flag.PrintDefaults()
}
```
再运行命令`go run demo3.go --help`后,其输出会与上一次的输出的一致。不过后面这种定制的方法更加灵活。比如,当我们把为`flag.CommandLine`赋值的那条语句改为
```
flag.CommandLine = flag.NewFlagSet(&quot;&quot;, flag.PanicOnError)
```
后,再运行`go run demo3.go --help`命令就会产生另一种输出效果。这是由于我们在这里传给`flag.NewFlagSet`函数的第二个参数值是`flag.PanicOnError``flag.PanicOnError``flag.ExitOnError`都是预定义在`flag`包中的常量。
`flag.ExitOnError`的含义是,告诉命令参数容器,当命令后跟`--help`或者参数设置的不正确的时候,在打印命令参数使用说明后以状态码`2`结束当前程序。
状态码`2`代表用户错误地使用了命令,而`flag.PanicOnError`与之的区别是在最后抛出“运行时恐慌panic”。
上述两种情况都会在我们调用`flag.Parse`函数时被触发。顺便提一句“运行时恐慌”是Go程序错误处理方面的概念。关于它的抛出和恢复方法我在本专栏的后续部分中会讲到。
下面再进一步,我们索性不用全局的`flag.CommandLine`变量,转而自己创建一个私有的命令参数容器。我们在函数外再添加一个变量声明:
```
var cmdLine = flag.NewFlagSet(&quot;question&quot;, flag.ExitOnError)
```
然后,我们把对`flag.StringVar`的调用替换为对`cmdLine.StringVar`调用,再把`flag.Parse()`替换为`cmdLine.Parse(os.Args[1:])`
其中的`os.Args[1:]`指的就是我们给定的那些命令参数。这样做就完全脱离了`flag.CommandLine``*flag.FlagSet`类型的变量`cmdLine`拥有很多有意思的方法。你可以去探索一下。我就不在这里一一讲述了。
这样做的好处依然是更灵活地定制命令参数容器。但更重要的是,你的定制完全不会影响到那个全局变量`flag.CommandLine`
**总结**
恭喜你你现在已经走出了Go语言编程的第一步。你可以用Go编写命令并可以让它们像众多操作系统命令那样被使用甚至可以把它们嵌入到各种脚本中。
虽然我为你讲解了命令源码文件的基本编写方法,并且也谈到了为了让它接受参数而需要做的各种准备工作,但这并不是全部。
别担心,我在后面会经常提到它的。另外,如果你想详细了解`flag`包的用法,可以到[这个网址](https://golang.google.cn/pkg/flag/)查看文档。或者直接使用`godoc`命令在本地启动一个Go语言文档服务器。怎样使用`godoc`命令?你可以参看[这里](https://github.com/hyper0x/go_command_tutorial/blob/master/0.5.md)。
## 思考题
我们已经见识过为命令源码文件传入字符串类型的参数值的方法,那还可以传入别的吗?这就是今天我留下的思考题。
1. 默认情况下,我们可以让命令源码文件接受哪些类型的参数值?
1. 我们可以把自定义的数据类型作为参数值的类型吗?如果可以,怎样做?
你可以通过查阅文档获得第一个问题的答案。记住,快速查看和理解文档是一项必备的技能。
至于第二个问题,你回答起来可能会有些困难,因为这涉及了另一个问题:“怎样声明自己的数据类型?”这个问题我在专栏的后续部分中也会讲到。如果是这样,我希望你记下它和这里说的另一问题,并在能解决后者之后再来回答前者。
[戳此查看Go语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers)

View File

@@ -0,0 +1,218 @@
<audio id="audio" title="03 | 库源码文件" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/25/55/25aad0cd981f741a90f74d2f9c093f55.mp3"></audio>
你已经使用过Go语言编写了小命令或者说微型程序
当你在编写“Hello, world”的时候一个源码文件就足够了虽然这种小玩意儿没什么用最多能给你一点点莫名的成就感。如果你对这一点点并不满足别着急跟着学我肯定你也可以写出很厉害的程序。
我们在上一篇的文章中学到了命令源码文件的相关知识那么除了命令源码文件你还能用Go语言编写库源码文件。那么什么是库源码文件呢
在我的定义中,**库源码文件是不能被直接运行的源码文件它仅用于存放程序实体这些程序实体可以被其他代码使用只要遵从Go语言规范的话。**
这里的“其他代码”可以与被使用的程序实体在同一个源码文件内,也可以在其他源码文件,甚至其他代码包中。
>
那么程序实体是什么呢在Go语言中程序实体是变量、常量、函数、结构体和接口的统称。
我们总是会先声明(或者说定义)程序实体,然后再去使用。比如在上一篇的例子中,我们先定义了变量`name`,然后在`main`函数中调用`fmt.Printf`函数的时候用到了它。
再多说一点程序实体的名字被统称为标识符。标识符可以是任何Unicode编码可以表示的字母字符、数字以及下划线“_”但是其首字母不能是数字。
从规则上说,我们可以用中文作为变量的名字。但是,我觉得这种命名方式非常不好,自己也会在开发团队中明令禁止这种做法。作为一名合格的程序员,我们应该向着编写国际水准的程序无限逼近。
回到正题。
我们今天的**问题是:怎样把命令源码文件中的代码拆分到其他库源码文件?**
我们用代码演示,把这个问题说得更具体一些。
如果在某个目录下有一个命令源码文件demo4.go如下
```
package main
import (
&quot;flag&quot;
)
var name string
func init() {
flag.StringVar(&amp;name, &quot;name&quot;, &quot;everyone&quot;, &quot;The greeting object.&quot;)
}
func main() {
flag.Parse()
hello(name)
}
```
其中的代码你应该比较眼熟了。我在讲命令源码文件的时候贴过很相似的代码那个源码文件名为demo2.go。
这两个文件的不同之处在于demo2.go直接通过调用`fmt.Printf`函数打印问候语而当前的demo4.go在同样位置调用了一个叫作`hello`的函数。
函数`hello`被声明在了另外一个源码文件中我把它命名为demo4_lib.go并且放在与demo4.go相同的目录下。如下
```
// 需在此处添加代码。[1]
import &quot;fmt&quot;
func hello(name string) {
fmt.Printf(&quot;Hello, %s!\n&quot;, 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 &quot;fmt&quot;
func Hello(name string) {
fmt.Printf(&quot;Hello, %s!\n&quot;, 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: &quot;puzzlers/article3/q2/lib&quot; 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/flag``flag`,那么会产生冲突吗?
1. 如果会产生冲突,那么怎样解决这种冲突,有几种方式?
第一个问题比较简单,你一试便知。强烈建议你编写个例子,然后运行`go`命令构建它,并看看会有什么样的提示。
而第二个问题涉及了代码包导入语句的高级写法你可能需要去查阅一下Go语言规范。不过也不难。你最多能想出几种解决办法呢你可以给我留言我们一起讨论。
[戳此查看Go语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers)

View File

@@ -0,0 +1,206 @@
<audio id="audio" title="04 | 程序实体的那些事儿(上)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d2/6d/d2579157308834217ab539bf1cc2296d.mp3"></audio>
我已经为你打开了Go语言编程之门并向你展示了“程序从初建到拆分再到模块化”的基本演化路径。
一个编程老手让程序完成基本演化,可能也就需要几十分钟甚至十几分钟,因为他们一开始就会把车开到模块化编程的道路上。我相信,等你真正理解了这个过程之后,也会驾轻就熟的。
上述套路是通用的不是只适用于Go语言。但从本篇开始我会开始向你介绍Go语言中的各种特性以及相应的编程方法和思想。
我在讲解那两种源码文件基本编写方法的时候,声明和使用了一些程序实体。你也许已经若有所觉,也许还在云里雾里。没关系,我现在就与你一起梳理这方面的重点。
还记得吗?**Go语言中的程序实体包括变量、常量、函数、结构体和接口。** Go语言是静态类型的编程语言所以我们在声明变量或常量的时候都需要指定它们的类型或者给予足够的信息这样才可以让Go语言能够推导出它们的类型。
>
在Go语言中变量的类型可以是其预定义的那些类型也可以是程序自定义的函数、结构体或接口。常量的合法类型不多只能是那些Go语言预定义的基本类型。它的声明方式也更简单一些。
好了,下面这个简单的问题你需要了解一下。
## **问题:声明变量有几种方式?**
先看段代码。
```
package main
import (
&quot;flag&quot;
&quot;fmt&quot;
)
func main() {
var name string // [1]
flag.StringVar(&amp;name, &quot;name&quot;, &quot;everyone&quot;, &quot;The greeting object.&quot;) // [2]
flag.Parse()
fmt.Printf(&quot;Hello, %v!\n&quot;, name)
}
```
这是一个很简单的命令源码文件我把它命名为demo7.go。它是demo2.go的微调版。我只是把变量`name`的声明和对`flag.StringVar`函数的调用,都移动到了`main`函数中,这分别对应代码中的注释`[1]``[2]`
具体的问题是,除了`var name string`这种声明变量`name`的方式,还有其他方式吗?你可以选择性地改动注释`[1]``[2]`处的代码。
## **典型回答**
这有几种做法,我在这里只说最典型的两种。
**第一种方式**需要先对注释`[2]`处的代码稍作改动,把被调用的函数由`flag.StringVar`改为`flag.String`,传参的列表也需要随之修改,这是为了`[1]``[2]`处代码合并的准备工作。
```
var name = flag.String(&quot;name&quot;, &quot;everyone&quot;, &quot;The greeting object.&quot;)
```
合并后的代码看起来更简洁一些。我把注释`[1]`处的代码中的`string`去掉了,右边添加了一个`=`,然后再拼接上经过修改的`[2]`处代码。
注意,`flag.String`函数返回的结果值的类型是`*string`而不是`string`。类型`*string`代表的是字符串的指针类型,而不是字符串类型。因此,这里的变量`name`代表的是一个指向字符串值的指针。
关于Go语言中的指针我在后面会有专门的介绍。你在这里只需要知道我们可以通过操作符`*`把这个指针指向的字符串值取出来了。因此,在这种情况下,那个被用来打印内容的函数调用就需要微调一下,把其中的参数`name`改为`*name`,即:`fmt.Printf("Hello, %v!\n", *name)`
好了,我想你已经基本理解了这行代码中的每一个部分。
**下面我接着说第二种方式。**第二种方式与第一种方式非常类似,它基于第一种方式的代码,赋值符号`=`右边的代码不动,左边只留下`name`,再把`=`变成`:=`
```
name := flag.String(&quot;name&quot;, &quot;everyone&quot;, &quot;The greeting object.&quot;)
```
## **问题解析**
这个问题的基本考点有两个。**一个是你要知道Go语言中的类型推断以及它在代码中的基本体现另一个是短变量声明的用法。**
第一种方式中的代码在声明变量`name`的同时,还为它赋了值,而这时声明中并没有显式指定`name`的类型。
还记得吗?之前的变量声明语句是`var name string`。这里利用了Go语言自身的类型推断而省去了对该变量的类型的声明。
>
简单地说类型推断是一种编程语言在编译期自动解释表达式类型的能力。什么是表达式详细的解释你可以参看Go语言规范中的[表达式](https://golang.google.cn/ref/spec#Expressions)和[表达式语句](https://golang.google.cn/ref/spec#Expression_statements)章节。我在这里就不赘述了。
你可以认为表达式类型就是对表达式进行求值后得到结果的类型。Go语言中的类型推断是很简约的这也是Go语言整体的风格。
它只能用于对变量或常量的初始化,就像上述回答中描述的那样。对`flag.String`函数的调用其实就是一个调用表达式,而这个表达式的类型是`*string`,即字符串的指针类型。
这也是调用`flag.String`函数后得到结果的类型。随后Go语言把这个调用了`flag.String`函数的表达式类型,直接作为了变量`name`的类型,这就是“推断”一词所指代的操作了。
至于第二种方式所用的短变量声明实际上就是Go语言的类型推断再加上一点点语法糖。
我们只能在函数体内部使用短变量声明。在编写`if``for``switch`语句的时候,我们经常把它安插在初始化子句中,并用来声明一些临时的变量。而相比之下,第一种方式更加通用,它可以被用在任何地方。
<img src="https://static001.geekbang.org/resource/image/b7/bc/b7d73fdce13a3a5f2d56d0b95f2c8cbc.png" alt="">
(变量的多种声明方式)
短变量声明还有其他的玩法,我稍后就会讲到。
## **知识扩展**
### **1. Go语言的类型推断可以带来哪些好处**
如果面试官问你这个问题,你应该怎样回答?
当然在写代码时我们通过使用Go语言的类型推断而节省下来的键盘敲击次数几乎可以忽略不计。但它真正的好处往往会体现在我们写代码之后的那些事情上比如代码重构。
为了更好的演示,我们先要做一点准备工作。我们依然通过调用一个函数在声明`name`变量的同时为它赋值,但是这个函数不是`flag.String`,而是由我们自己定义的某个函数,比如叫`getTheFlag`
```
package main
import (
&quot;flag&quot;
&quot;fmt&quot;
)
func main() {
var name = getTheFlag()
flag.Parse()
fmt.Printf(&quot;Hello, %v!\n&quot;, *name)
}
func getTheFlag() *string {
return flag.String(&quot;name&quot;, &quot;everyone&quot;, &quot;The greeting object.&quot;)
}
```
我们可以用`getTheFlag`函数包裹(或者说包装)那个对`flag.String`函数的调用,并把其结果直接作为`getTheFlag`函数的结果,结果的类型是`*string`
这样一来,`var name =`右边的表达式,可以变为针对`getTheFlag`函数的调用表达式了。这实际上是对“声明并赋值`name`变量的那行代码”的重构。
>
我们通常把不改变某个程序与外界的任何交互方式和规则,而只改变其内部实现”的代码修改方式,叫做对该程序的重构。重构的对象可以是一行代码、一个函数、一个功能模块,甚至一个软件系统。
好了,在准备工作做完之后,你会发现,你可以随意改变`getTheFlag`函数的内部实现,及其返回结果的类型,而不用修改`main`函数中的任何代码。
这个命令源码文件依然可以通过编译,并且构建和运行也都不会有问题。也许你能感觉得到,这是一个关于程序灵活性的质变。
我们不显式地指定变量`name`的类型,使得它可以被赋予任何类型的值。也就是说,变量`name`的类型可以在其初始化时,由其他程序动态地确定。
在你改变`getTheFlag`函数的结果类型之后Go语言的编译器会在你再次构建该程序的时候自动地更新变量`name`的类型。如果你使用过`Python``Ruby`这种动态类型的编程语言的话,一定会觉得这情景似曾相识。
没错,通过这种类型推断,你可以体验到动态类型编程语言所带来的一部分优势,即程序灵活性的明显提升。但在那些编程语言中,这种提升可以说是用程序的可维护性和运行效率换来的。
Go语言是静态类型的所以一旦在初始化变量时确定了它的类型之后就不可能再改变。这就避免了在后面维护程序时的一些问题。另外请记住这种类型的确定是在编译期完成的因此不会对程序的运行效率产生任何影响。
现在,你应该已经对这个问题有一个比较深刻的理解了。
如果只用一两句话回答这个问题的话我想可以是这样的Go语言的类型推断可以明显提升程序的灵活性使得代码重构变得更加容易同时又不会给代码的维护带来额外负担实际上它恰恰可以避免散弹式的代码修改更不会损失程序的运行效率。
### **2. 变量的重声明是什么意思?**
这涉及了短变量声明。通过使用它,我们可以对同一个代码块中的变量进行重声明。
>
既然说到了代码块我先来解释一下它。在Go语言中代码块一般就是一个由花括号括起来的区域里面可以包含表达式和语句。Go语言本身以及我们编写的代码共同形成了一个非常大的代码块也叫全域代码块。
这主要体现在,只要是公开的全局变量,都可以被任何代码所使用。相对小一些的代码块是代码包,一个代码包可以包含许多子代码包,所以这样的代码块也可以很大。
接下来,每个源码文件也都是一个代码块,每个函数也是一个代码块,每个`if`语句、`for`语句、`switch`语句和`select`语句都是一个代码块。甚至,`switch``select`语句中的`case`子句也都是独立的代码块。
走个极端,我就在`main`函数中写一对紧挨着的花括号算不算一个代码块?当然也算,这甚至还有个名词,叫“空代码块”。
回到变量重声明的问题上。其含义是对已经声明过的变量再次声明。变量重声明的前提条件如下。
<li>
由于变量的类型在其初始化时就已经确定了,所以对它再次声明时赋予的类型必须与其原本的类型相同,否则会产生编译错误。
</li>
<li>
变量的重声明只可能发生在某一个代码块中。如果与当前的变量重名的是外层代码块中的变量,那么就是另外一种含义了,我在下一篇文章中会讲到。
</li>
<li>
变量的重声明只有在使用短变量声明时才会发生,否则也无法通过编译。如果要在此处声明全新的变量,那么就应该使用包含关键字`var`的声明语句,但是这时就不能与同一个代码块中的任何变量有重名了。
</li>
<li>
被“声明并赋值”的变量必须是多个,并且其中至少有一个是新的变量。这时我们才可以说对其中的旧变量进行了重声明。
</li>
这样来看,变量重声明其实算是一个语法糖(或者叫便利措施)。它允许我们在使用短变量声明时不用理会被赋值的多个变量中是否包含旧变量。可以想象,如果不这样会多写不少代码。
我把一个简单的例子写在了“Golang_Puzzlers”项目的`puzzlers/article4/q3`包中的demo9.go文件中你可以去看一下。
这其中最重要的两行代码如下:
```
var err error
n, err := io.WriteString(os.Stdout, &quot;Hello, everyone!\n&quot;)
```
我使用短变量声明对新变量`n`和旧变量`err`进行了“声明并赋值”,这时也是对后者的重声明。
## **总结**
在本篇中我们聚焦于最基本的Go语言程序实体变量。并详细解说了变量声明和赋值的基本方法及其背后的重要概念和知识。我们使用关键字`var`和短变量声明,都可以实现对变量的“声明并赋值”。
这两种方式各有千秋,有着各自的特点和适用场景。前者可以被用在任何地方,而后者只能被用在函数或者其他更小的代码块中。
不过通过前者我们无法对已有的变量进行重声明也就是说它无法处理新旧变量混在一起的情况。不过它们也有一个很重要的共同点基于类型推断Go语言的类型推断只应用在了对变量或常量的初始化方面。
## **思考题**
本次的思考题只有一个:如果与当前的变量重名的是外层代码块中的变量,那么这意味着什么?
这道题对于你来说可能有些难,不过我鼓励你多做几次试验试试,你可以在代码中多写一些打印语句,然后运行它,并记录下每次试验的结果。如果有疑问也一定要写下来,答案将在下篇文章中揭晓。
[戳此查看Go语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers)

View File

@@ -0,0 +1,153 @@
<audio id="audio" title="05 | 程序实体的那些事儿(中)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/de/94/de1e01966e7c9f19eec3710e172be494.mp3"></audio>
在前文中我解释过代码块的含义。Go语言的代码块是一层套一层的就像大圆套小圆。
一个代码块可以有若干个子代码块;但对于每个代码块,最多只会有一个直接包含它的代码块(后者可以简称为前者的外层代码块)。
这种代码块的划分,也间接地决定了程序实体的作用域。我们今天就来看看它们之间的关系。
我先说说作用域是什么?大家都知道,一个程序实体被创造出来,是为了让别的代码引用的。那么,哪里的代码可以引用它呢,这就涉及了它的作用域。
我在前面说过程序实体的访问权限有三种包级私有的、模块级私有的和公开的。这其实就是Go语言在语言层面依据代码块对程序实体作用域进行的定义。
包级私有和模块级私有访问权限对应的都是代码包代码块,公开的访问权限对应的是全域代码块。然而,这个颗粒度是比较粗的,我们往往需要利用代码块再细化程序实体的作用域。
比如,我在一个函数中声明了一个变量,那么在通常情况下,这个变量是无法被这个函数以外的代码引用的。这里的函数就是一个代码块,而变量的作用域被限制在了该代码块中。当然了,还有例外的情况,这部分内容,我留到讲函数的时候再说。
总之,请记住,**一个程序实体的作用域总是会被限制在某个代码块中,而这个作用域最大的用处,就是对程序实体的访问权限的控制。**对“高内聚,低耦合”这种程序设计思想的实践,恰恰可以从这里开始。
你应该可以通过下面的问题进一步感受代码块和作用域的魅力。
**今天的问题是:如果一个变量与其外层代码块中的变量重名会出现什么状况?**
我把此题的代码存到了demo10.go文件中了。你可以在“Golang_Puzzlers”项目的`puzzlers/article5/q1`包中找到它。
```
package main
import &quot;fmt&quot;
var block = &quot;package&quot;
func main() {
block := &quot;function&quot;
{
block := &quot;inner&quot;
fmt.Printf(&quot;The block is %s.\n&quot;, block)
}
fmt.Printf(&quot;The block is %s.\n&quot;, block)
}
```
这个命令源码文件中有四个代码块,它们是:全域代码块、`main`包代表的代码块、`main`函数代表的代码块,以及在`main`函数中的一个用花括号包起来的代码块。
我在后三个代码块中分别声明了一个名为`block`的变量,并分别把字符串值`"package"``"function"``"inner"`赋给了它们。此外,我在后两个代码块的最后分别尝试用`fmt.Printf`函数打印出“The block is %s.”。这里的“%s”只是为了占位程序会用`block`变量的实际值替换掉。
具体的问题是:该源码文件中的代码能通过编译吗?如果不能,原因是什么?如果能,运行它后会打印出什么内容?
## 典型回答
能通过编译。运行后打印出的内容是:
```
The block is inner.
The block is function.
```
## 问题解析
初看这道题,你可能会认为它无法通过编译,因为三处代码都声明了相同名称的变量。的确,声明重名的变量是无法通过编译的,用短变量声明对已有变量进行重声明除外,但这只是对于同一个代码块而言的。
对于不同的代码块来说其中的变量重名没什么大不了照样可以通过编译。即使这些代码块有直接的嵌套关系也是如此就像demo10.go中的`main`包代码块、`main`函数代码块和那个最内层的代码块那样。
这样规定显然很方便也很合理,否则我们会每天为了选择变量名而烦恼。但是这会导致另外一个问题,我引用变量时到底用的是哪一个?这也是这道题的第二个考点。
这其实有一个很有画面感的查找过程。这个查找过程不只针对于变量,还适用于任何程序实体。如下面所示。
- 首先,代码引用变量的时候总会最优先查找当前代码块中的那个变量。注意,这里的“当前代码块”仅仅是引用变量的代码所在的那个代码块,并不包含任何子代码块。
- 其次,如果当前代码块中没有声明以此为名的变量,那么程序会沿着代码块的嵌套关系,从直接包含当前代码块的那个代码块开始,一层一层地查找。
- 一般情况下程序会一直查到当前代码包代表的代码块。如果仍然找不到那么Go语言的编译器就会报错了。
还记得吗?如果我们在当前源码文件中导入了其他代码包,那么引用其中的程序实体时,是需要以限定符为前缀的。所以程序在找代表变量未加限定符的名字(即标识符)的时候,是不会去被导入的代码包中查找的。
>
但有个特殊情况,如果我们把代码包导入语句写成`import . "XXX"`的形式(注意中间的那个“.”那么就会让这个“XXX”包中公开的程序实体被当前源码文件中的代码视为当前代码包中的程序实体。
比如,如果有代码包导入语句`import . fmt`,那么我们在当前源码文件中引用`fmt.Printf`函数的时候直接用`Printf`就可以了。在这个特殊情况下,程序在查找当前源码文件后会先去查用这种方式导入的那些代码包。
好了当你明白了上述过程之后再去看demo10.go中的代码。是不是感觉清晰了很多
从作用域的角度也可以说,虽然通过`var block = "package"`声明的变量作用域是整个`main`代码包,但是在`main`函数中,它却被那两个同名的变量“屏蔽”了。
相似的,虽然`main`函数首先声明的`block`的作用域,是整个`main`函数,但是在最内层的那个代码块中,它却是不可能被引用到的。反过来讲,最内层代码块中的`block`也不可能被该块之外的代码引用到这也是打印内容的第二行是“The block is function.”的另一半原因。
你现在应该知道了,这道题看似简单,但是它考察以及可延展的范围并不窄。
## 知识扩展
**不同代码块中的重名变量与变量重声明中的变量区别到底在哪儿?**
**为了方便描述,我就把不同代码块中的重名变量叫做“可重名变量”吧。**注意在同一个代码块中不允许出现重名的变量这违背了Go语言的语法。关于这两者的表象和机理我们已经讨论得足够充分了。你现在可以说出几条区别请想一想然后再看下面的列表。
1. 变量重声明中的变量一定是在某一个代码块内的。注意,这里的“某一个代码块内”并不包含它的任何子代码块,否则就变成了“多个代码块之间”。而可重名变量指的正是在多个代码块之间由相同的标识符代表的变量。
1. 变量重声明是对同一个变量的多次声明,这里的变量只有一个。而可重名变量中涉及的变量肯定是有多个的。
1. 不论对变量重声明多少次,其类型必须始终一致,具体遵从它第一次被声明时给定的类型。而可重名变量之间不存在类似的限制,它们的类型可以是任意的。
1. 如果可重名变量所在的代码块之间,存在直接或间接的嵌套关系,那么它们之间一定会存在“屏蔽”的现象。但是这种现象绝对不会在变量重声明的场景下出现。
<img src="https://static001.geekbang.org/resource/image/5e/89/5e68210d5639f9e42738f21bd9eb1e89.png" alt="">
当然了,我们之前谈论过,对变量进行重声明还有一些前提条件,不过在这里并不是重点。我就不再赘述了。
以上4大区别中的第3条需要你再注意一下。既然可重名变量的类型可以是任意的那么当它们之间存在“屏蔽”时你就更需要注意了。
不同类型的值大都有着不同的特性和用法。当你在某一种类型的值上施加只有在其他类型值上才能做的操作时Go语言编译器一定会告诉你“这不可以”。
这种情况很好,甚至值得庆幸,因为你的程序存在的问题被提前发现了。如若不然,程序没准儿会在运行过程中由此引发很隐晦的问题,让你摸不着头脑。
相比之下那时候排查问题的成本可就太高了。所以我们应该尽量利用Go语言的语法、规范和命令来约束我们的程序。
具体到不同类型的可重名变量的问题上,让我们先来看一下`puzzlers/article5/q2`包中的源码文件demo11.go。它是一个很典型的例子。
```
package main
import &quot;fmt&quot;
var container = []string{&quot;zero&quot;, &quot;one&quot;, &quot;two&quot;}
func main() {
container := map[int]string{0: &quot;zero&quot;, 1: &quot;one&quot;, 2: &quot;two&quot;}
fmt.Printf(&quot;The element is %q.\n&quot;, container[1])
}
```
在demo11.go中有两个都叫做`container`的变量,分别位于`main`包代码块和`main`函数代码块。`main`包代码块中的变量是切片slice类型的另一个是字典map类型的。在`main`函数的最后,我试图打印出`container`变量的值中索引为`1`的那个元素。
如果你熟悉这两个类型肯定会知道,在它们的值上我们都可以施加索引表达式,比如`container[0]`。只要中括号里的整数在有效范围之内(这里是[0, 2]),它就可以把值中的某一个元素取出来。
如果`container`的类型不是数组、切片或字典类型那么索引表达式就会引发编译错误。这正是利用Go语言语法帮我们约束程序的一个例子但是当我们想知道container确切类型的时候利用索引表达式的方式就不够了。
当可重名变量的值被转换成某个接口类型值,或者它们的类型本身就是接口类型的时候,严格的类型检查就很有必要了。至于怎么检查,我们在下篇文章中再讨论。
## 总结
我们先讨论了代码块并且也谈到了它与程序实体的作用域以及访问权限控制之间的巧妙关系。Go语言本身对程序实体提供了相对粗粒度的访问控制。但我们自己可以利用代码块和作用域精细化控制它们。
如果在具有嵌套关系的不同代码块中存在重名的变量那么我们应该特别小心它们之间可能会发生“屏蔽”的现象。这样你在不同代码块中引用到变量很可能是不同的。具体的鉴别方式需要参考Go语言查找代表了程序实体的标识符的过程。
另外请记住变量重声明与可重名变量之间的区别以及它们的重要特征。其中最容易产生隐晦问题的一点是可重名变量可以各有各的类型。这时候我们往往应该在真正使用它们之前先对其类型进行检查。利用Go语言的语法、规范和命令做辅助的检查是很好的办法但有些时候并不充分。
## 思考题
我们在讨论Go语言查找标识符时的范围的时候提到过`import . XXX`这种导入代码包的方式。这里有个思考题:
如果通过这种方式导入的代码包中的变量与当前代码包中的变量重名了那么Go语言是会把它们当做“可重名变量”看待还是会报错呢
其实我们写个例子一试便知,但重点是为什么?请你尝试从代码块和作用域的角度解释试验得到的答案。
[戳此查看Go语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers)

View File

@@ -0,0 +1,227 @@
<audio id="audio" title="06 | 程序实体的那些事儿 (下)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/bc/e3/bcb905f1f40ec80bfea09a834edf96e3.mp3"></audio>
在上一篇文章,我们一直都在围绕着可重名变量,也就是不同代码块中的重名变量,进行了讨论。
还记得吗?最后我强调,如果可重名变量的类型不同,那么就需要引起我们的特别关注了,它们之间可能会存在“屏蔽”的现象。
必要时,我们需要严格地检查它们的类型,但是怎样检查呢?咱们现在就说。
**我今天的问题是:怎样判断一个变量的类型?**
我们依然以在上一篇文章中展示过的demo11.go为基础。
```
package main
import &quot;fmt&quot;
var container = []string{&quot;zero&quot;, &quot;one&quot;, &quot;two&quot;}
func main() {
container := map[int]string{0: &quot;zero&quot;, 1: &quot;one&quot;, 2: &quot;two&quot;}
fmt.Printf(&quot;The element is %q.\n&quot;, container[1])
}
```
那么,怎样在打印其中元素之前,正确判断变量`container`的类型?
## 典型回答
答案是使用“类型断言”表达式。具体怎么写呢?
```
value, ok := interface{}(container).([]string)
```
这里有一条赋值语句。在赋值符号的右边,是一个类型断言表达式。
它包括了用来把`container`变量的值转换为空接口值的`interface{}(container)`
以及一个用于判断前者的类型是否为切片类型 `[]string``.([]string)`
这个表达式的结果可以被赋给两个变量,在这里由`value``ok`代表。变量`ok`是布尔bool类型的它将代表类型判断的结果`true``false`
如果是`true`,那么被判断的值将会被自动转换为`[]string`类型的值,并赋给变量`value`,否则`value`将被赋予`nil`(即“空”)。
顺便提一下,这里的`ok`也可以没有。也就是说,类型断言表达式的结果,可以只被赋给一个变量,在这里是`value`
但是这样的话,当判断为否时就会引发异常。
这种异常在Go语言中被叫做`panic`我把它翻译为运行时恐慌。因为它是一种在Go程序运行期间才会被抛出的异常而“恐慌”二字是英文Panic的中文直译。
除非显式地“恢复”这种“恐慌”否则它会使Go程序崩溃并停止。所以在一般情况下我们还是应该使用带`ok`变量的写法。
## 问题解析
正式说明一下,类型断言表达式的语法形式是`x.(T)`。其中的`x`代表要被判断类型的值。这个值当下的类型必须是接口类型的,不过具体是哪个接口类型其实是无所谓的。
所以,当这里的`container`变量类型不是任何的接口类型时,我们就需要先把它转成某个接口类型的值。
如果`container`是某个接口类型的,那么这个类型断言表达式就可以是`container.([]string)`。这样看是不是清晰一些了?
在Go语言中`interface{}`代表空接口,任何类型都是它的实现类型。我在下个模块,会再讲接口及其实现类型的问题。现在你只要知道,任何类型的值都可以很方便地被转换成空接口的值就行了。
这里的具体语法是`interface{}(x)`,例如前面展示的`interface{}(container)`
你可能会对这里的`{}`产生疑惑,为什么在关键字`interface`的右边还要加上这个东西?
请记住,一对不包裹任何东西的花括号,除了可以代表空的代码块之外,还可以用于表示不包含任何内容的数据结构(或者说数据类型)。
比如你今后肯定会遇到的`struct{}`,它就代表了不包含任何字段和方法的、空的结构体类型。
而空接口`interface{}`则代表了不包含任何方法定义的、空的接口类型。
当然了,对于一些集合类的数据类型来说,`{}`还可以用来表示其值不包含任何元素,比如空的切片值`[]string{}`,以及空的字典值`map[int]string{}`
<img src="https://static001.geekbang.org/resource/image/b5/15/b5f16bf3ad8f416fb151aed8df47a515.png" alt="">
(类型断言表达式)
我们再向答案的最右边看。圆括号中`[]string`是一个类型字面量。所谓类型字面量,就是用来表示数据类型本身的若干个字符。
比如,`string`是表示字符串类型的字面量,`uint8`是表示8位无符号整数类型的字面量。
再复杂一些的就是我们刚才提到的`[]string`,用来表示元素类型为`string`的切片类型,以及`map[int]string`,用来表示键类型为`int`、值类型为`string`的字典类型。
还有更复杂的结构体类型字面量、接口类型字面量,等等。这些描述起来占用篇幅较多,我在后面再说吧。
针对当前的这个问题我写了demo12.go。它是demo11.go的修改版。我在其中分别使用了两种方式来实施类型断言一种用的是我上面讲到的方式另一种用的是我们还没讨论过的`switch`语句,先供你参考。
可以看到,当前问题的答案可以只有一行代码。你可能会想,这一行代码解释起来也太复杂了吧?
**千万不要为此烦恼,这其中很大一部分都是一些基本语法和概念,你只要记住它们就好了。但这也正是我要告诉你的,一小段代码可以隐藏很多细节。面试官可以由此延伸到几个方向继续提问。这有点儿像泼墨,可以迅速由点及面。**
## 知识扩展
**问题1. 你认为类型转换规则中有哪些值得注意的地方?**
类型转换表达式的基本写法我已经在前面展示过了。它的语法形式是`T(x)`
其中的`x`可以是一个变量,也可以是一个代表值的字面量(比如`1.23``struct{}{}`),还可以是一个表达式。
注意,如果是表达式,那么该表达式的结果只能是一个值,而不能是多个值。在这个上下文中,`x`可以被叫做源值,它的类型就是源类型,而那个`T`代表的类型就是目标类型。
如果从源类型到目标类型的转换是不合法的那么就会引发一个编译错误。那怎样才算合法具体的规则可参见Go语言规范中的[转换](https://golang.google.cn/ref/spec#Conversions)部分。
我们在这里要关心的并不是那些Go语言编译器可以检测出的问题。恰恰相反那些在编程语言层面很难检测的东西才是我们应该关注的。
**很多初学者所说的陷阱(或者说坑),大都源于他们需要了解但却不了解的那些知识和技巧。因此,在这些规则中,我想抛出三个我认为很常用并且非常值得注意的知识点,提前帮你标出一些“陷阱”。**
**首先,对于整数类型值、整数常量之间的类型转换,原则上只要源值在目标类型的可表示范围内就是合法的。**
比如,之所以`uint8(255)`可以把无类型的常量`255`转换为`uint8`类型的值,是因为`255`在[0, 255]的范围内。
但需要特别注意的是,源整数类型的可表示范围较大,而目标类型的可表示范围较小的情况,比如把值的类型从`int16`转换为`int8`。请看下面这段代码:
```
var srcInt = int16(-255)
dstInt := int8(srcInt)
```
变量`srcInt`的值是`int16`类型的`-255`,而变量`dstInt`的值是由前者转换而来的,类型是`int8``int16`类型的可表示范围可比`int8`类型大了不少。问题是,`dstInt`的值是多少?
首先你要知道整数在Go语言以及计算机中都是以补码的形式存储的。这主要是为了简化计算机对整数的运算过程。负数的补码其实就是原码各位求反再加1。
比如,`int16`类型的值`-255`的补码是`1111111100000001`。如果我们把该值转换为`int8`类型的值那么Go语言会把在较高位置或者说最左边位置上的8位二进制数直接截掉从而得到`00000001`
又由于其最左边一位是`0`,表示它是个正整数,以及正整数的补码就等于其原码,所以`dstInt`的值就是`1`
一定要记住,当整数值的类型的有效范围由宽变窄时,只需在补码形式下截掉一定数量的高位二进制数即可。
类似的快刀斩乱麻规则还有:当把一个浮点数类型的值转换为整数类型值时,前者的小数部分会被全部截掉。
**第二,虽然直接把一个整数值转换为一个`string`类型的值是可行的但值得关注的是被转换的整数值应该可以代表一个有效的Unicode代码点否则转换的结果将会是`"<22>"`(仅由高亮的问号组成的字符串值)。**
字符`'<27>'`的Unicode代码点是`U+FFFD`。它是Unicode标准中定义的Replacement Character专用于替换那些未知的、不被认可的以及无法展示的字符。
我肯定不会去问“哪个整数值转换后会得到哪个字符串”,这太变态了!但是我会写下:
```
string(-1)
```
并询问会得到什么?这可是完全不同的问题啊。由于`-1`肯定无法代表一个有效的Unicode代码点所以得到的总会是`"<22>"`。在实际工作中,我们在排查问题时可能会遇到`<60>`,你需要知道这可能是由于什么引起的。
**第三个知识点是关于`string`类型与各种切片类型之间的互转的。**
你先要理解的是,一个值在从`string`类型向`[]byte`类型转换时代表着以UTF-8编码的字符串会被拆分成零散、独立的字节。
除了与ASCII编码兼容的那部分字符集以UTF-8编码的某个单一字节是无法代表一个字符的。
```
string([]byte{'\xe4', '\xbd', '\xa0', '\xe5', '\xa5', '\xbd'}) // 你好
```
比如UTF-8编码的三个字节`\xe4``\xbd``\xa0`合在一起才能代表字符`'你'`,而`\xe5``\xa5``\xbd`合在一起才能代表字符`'好'`
其次,一个值在从`string`类型向`[]rune`类型转换时代表着字符串会被拆分成一个个Unicode字符。
```
string([]rune{'\u4F60', '\u597D'}) // 你好
```
当你真正理解了Unicode标准及其字符集和编码方案之后上面这些内容就会显得很容易了。什么是Unicode标准我会首先推荐你去它的[官方网站](http://www.unicode.org)一探究竟。
**问题2. 什么是别名类型?什么是潜在类型?**
我们可以用关键字`type`声明自定义的各种类型。当然了这些类型必须在Go语言基本类型和高级类型的范畴之内。在它们当中有一种被叫做“别名类型”的类型。我们可以像下面这样声明它
```
type MyString = string
```
这条声明语句表示,`MyString``string`类型的别名类型。顾名思义,别名类型与其源类型的区别恐怕只是在名称上,它们是完全相同的。
源类型与别名类型是一对概念是两个对立的称呼。别名类型主要是为了代码重构而存在的。更详细的信息可参见Go语言官方的文档[Proposal: Type Aliases](https://golang.org/design/18130-type-alias)。
Go语言内建的基本类型中就存在两个别名类型。`byte``uint8`的别名类型,而`rune``int32`的别名类型。
一定要注意,如果我这样声明:
```
type MyString2 string // 注意,这里没有等号。
```
`MyString2``string`就是两个不同的类型了。这里的`MyString2`是一个新的类型,不同于其他任何类型。
这种方式也可以被叫做对类型的再定义。我们刚刚把`string`类型再定义成了另外一个类型`MyString2`
<img src="https://static001.geekbang.org/resource/image/4f/f2/4f113b74b564ad3b4b4877abca7b6bf2.png" alt=""><br>
(别名类型、类型再定义与潜在类型)
对于这里的类型再定义来说,`string`可以被称为`MyString2`的潜在类型。潜在类型的含义是,某个类型在本质上是哪个类型。
潜在类型相同的不同类型的值之间是可以进行类型转换的。因此,`MyString2`类型的值与`string`类型的值可以使用类型转换表达式进行互转。
但对于集合类的类型`[]MyString2``[]string`来说这样做却是不合法的,因为`[]MyString2``[]string`的潜在类型不同,分别是`[]MyString2``[]string`。另外,即使两个不同类型的潜在类型相同,它们的值之间也不能进行判等或比较,它们的变量之间也不能赋值。
## 总结
在本篇文章中我们聚焦于类型。Go语言中的每个变量都是有类型的我们可以使用类型断言表达式判断变量是哪个类型的。
正确使用该表达式需要一些小技巧,比如总是应该把结果赋给两个变量。另外还要保证被判断的变量是接口类型的,这可能会用到类型转换表达式。
我们在使用类型转换表达式对变量的类型进行转换的时候,会受到一套规则的严格约束。
我们必须关注这套规则中的一些细节尤其是那些Go语言命令不会帮你检查的细节否则就会踩进所谓的“陷阱”中。
此外,你还应该搞清楚别名类型声明与类型再定义之间的区别,以及由此带来的它们的值在类型转换、判等、比较和赋值操作方面的不同。
## 思考题
本篇文章的思考题有两个。
1. 除了上述提及的那些,你还认为类型转换规则中有哪些值得注意的地方?
1. 你能具体说说别名类型在代码重构过程中可以起到哪些作用吗?
这些问题的答案都在文中提到的官方文档之中。
[戳此查看Go语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers)