# Go ## 0. 目录 ## 1. 背景 ### 1.1 历史和现状 - Go 语言是怎样诞生的? - Go 语言的创始人有三位,分别是图灵奖获得者、C 语法联合发明人、Unix 之父**肯·汤普森 (Ken Thompson)**、Plan 9 操作系统领导者、UTF-8 编码的最初设计者**罗伯·派克(Rob Pike)** ,以及 Java 的 HotSpot 虚拟机和 Chrome 浏览器的 JavaScript V8 引擎的设计者之一**罗伯特·格瑞史莫(Robert Griesemer)** - ![Go语言之父们](pic/Go语言之父们.png) - Go语言第一版特性设计稿 - ![Go语言第一版特性设计稿](pic/Go语言第一版特性设计稿.png) - 主要思路是,在 C 语言的基础上, **修正一些明显的缺陷,删除一些被诟病较多的特性,增加一些缺失的功能** - 很多 Go 语言初学者经常称这门语言为 golang,其实这是不对的:“golang”仅应用于命名 Go 语言官方网站,而且当时没有用 go.com 纯粹是**这个域名被占用了**而已 - 在 Go 语言项目开源后,Go 语言也迎来了自己的“吉祥物”,是一只由罗伯·派克夫人**芮妮·弗伦奇(Renee French)**设计的地鼠,从此地鼠(gopher)也就成为了世界各地 Go 程序员的象征,Go 程序员也被昵称为 **Gopher** - ![Go语言的吉祥物](pic/Go语言的吉祥物.png) - 2012 年 3 月 28 日,Go 1.0 版本正式发布 - 只要符合 Go1 语言规范的源代码,Go 编译器将保证向后兼容(backwards compatible),也就是说我们使用**新版编译器也可以正确编译用老版本语法编写的代码**。 - Go语言大事记 - ![Go语言大事记](pic/Go语言大事记.png) - Go 是否值得我们学习? - Go 语言已经逐渐成为了**云计算时代基础设施的编程语言** - Docker、Kubernetes、Prometheus、Ethereum(以太坊)、Istio、CockroachDB、InfluxDB、Terraform、Etcd、Consul - Gartner 的技术成熟度曲线(The Hype Cycle) - Gartner 的技术成熟度曲线又叫技术循环曲线,是企业用来评估新科技是否要采用或采用时机的一种可视化方法,它利用时间轴与该技术在市面上的可见度(媒体曝光度)决定要 不要采用,以及什么时候采用这种新科技 - ![Gartner技术成熟度曲线](pic/Gartner技术成熟度曲线.png) - ![Go语言的技术成熟度曲线](pic/Go语言的技术成熟度曲线.png) - 预计在 2022 年初发布的支持 Go 泛型特性的 Go 1.18 版本,会是继 Go 1.5 版本之后又一“爆款”,很可能会快速推动 Go 迈入更高的发展阶段。 ### 1.2 拒绝“Hello and Bye”:Go语言的设计哲学是怎么一回事 - 根据“绝大多数主流编 程语言将在其 15 至 20 年间大步前进”这个依据,我们给出了一个结论:Go 语言即将进入自己的**黄金 5~10 年**。 - 所谓编程语言的设计哲学,就是指决定这门语言演化进程的高级原则和依据。 - Go 语言的设计哲学总结为五点:简单、显式、组合、并发和面向工程。 - **简单** - Go 语言的设计者们在语言设计之初,就拒绝了走语言特性融合的道路,选择了“做减法”并致力于打造一门**简单的编程语言** - 复杂性被 Go 语言的设计者们“隐藏”了,所以 Go 语法层面上呈现了这样的状态: - 仅有 25 个关键字,主流编程语言最少; - 内置垃圾收集,降低开发人员内存管理的心智负担; - 首字母大小写决定可见性,无需通过额外关键字修饰; - 变量初始为类型零值,避免以随机值作为初值的问题; - 内置数组边界检查,极大减少越界访问带来的安全隐患; - 内置并发支持,简化并发程序设计; - 内置接口类型,为组合的设计哲学奠定基础; - 原生提供完善的工具链,开箱即用; - Go 设计者选择的“简单”,其实是站在巨人肩膀上,去除或优化了以往语言中,已经被开发者证明为体验不好或难以驾驭的语法元素和语言机制,并提出了自己的一些创新性的设 计。比如,首字母大小写决定可见性、变量初始为类型零值、内置以 go 关键字实现的并发支持等 - **显式** - Go 不允许不同类型的整型变量进行混合计算,它同样也不会对其进行隐式的自动转换。 - 在 Go 语言中,不同类型变量是不能在一起进行混合计算的,这是因为 Go 希望**开发人员明确知道自己在做什么**,这与 C 语言的“信任程序员”原则完全不同,因此你需要以显式 的方式通过转型统一参与计算各个变量的类型。 - 除此之外,Go 设计者所崇尚的显式哲学还直接决定了 Go 语言错误处理的形态:Go 语言采用了**显式的基于值比较的错误处理方案**,函数 / 方法中的错误都会通过 return 语句显式 地返回,并且通常调用者不能忽略对返回的错误的处理。 - **组合** - Go 语言不像 C++、Java 等主流面向对象语言,我们在 Go 中是找不到经典的面向对象语法元素、类型体系和继承机制的,Go 推崇的是**组合**的设计哲学。 - 在 Go 语言设计层面,Go 设计者为开发者们提供了正交的语法元素,以供后续组合使用,包括: - Go 语言无类型层次体系,各类型之间是相互独立的,没有子类型的概念; - 每个类型都可以有自己的方法集合,类型定义与方法实现是正交独立的; - 实现某个接口时,无需像 Java 那样采用特定关键字修饰; - 包之间是相对独立的,没有子包的概念。 - 无论是包、接口还是一个个具体的类型定义,Go 语言其实是为我们呈现了这样的一幅图景:一座座没有关联的“孤岛”,但每个岛内又都很精彩。那么现在摆在面 前的工作,就是在这些孤岛之间以最适当的方式建立关联,并形成一个整体。而 **Go 选择采用的组合方式,也是最主要的方式**。 - Go 语言为支撑组合的设计提供了类型嵌入(Type Embedding)。通过类型嵌入,我们可以将**已经实现的功能嵌入到新类型中**,以快速满足新类型的功能需求,这种方式有些类似 经典面向对象语言中的“继承”机制,但在原理上却与面向对象中的继承完全不同,这是一种 Go 设计者们精心设计的“语法糖”。 - 被嵌入的类型和新类型两者之间没有任何关系,甚至相互完全不知道对方的存在,更没有经典面向对象语言中的那种父类、子类的关系,以及向上、向下转型(Type Casting)。 通过新类型实例调用方法时,方法的匹配主要取决于方法名字,而不是类型。这种组合方式,我称之为垂直组合,即**通过类型嵌入,快速让一个新类型“复用”其他类型已经实现** **的能力**,实现功能的垂直扩展。 - 垂直组合本质上是一种“能力继承”,采用嵌入方式定义的新类型继承了嵌入类型的能力。Go 还有一种常见的组合方式,叫水平组合。和垂直组合的能力继承不同,水平组合是 一种**能力委托(Delegate)**,我们通常使用**接口类型**来实现水平组合。 - Go 语言中的接口是一个创新设计,它只是方法集合,并且它与实现者之间的关系无需通过显式关键字修饰,它让程序内部各部分之间的耦合降至最低,同时它也是连接程序各个部 分之间“**纽带**”。 - 水平组合的模式有很多,比如一种常见方法就是,通过接受接口类型参数的普通函数进行组合 - func ReadAll(r io.Reader)([]byte, error) // $GOROOT/src/io/ioutil/ioutil.go - func Copy(dst Writer, src Reader)(written int64, err error) // $GOROOT/src/io/io.go - 还可以将 Go 语言内置的并发能力进行灵活组合以实现,比如,通过goroutine+channel 的组合,可以实现类似 Unix Pipe 的能力。 - 组合也让遵循“简单”原则的 Go 语言,在表现力上丝毫不逊色于其他复杂的主流编程语言。 - **并发** - 并发”这个设计哲学的出现有它的背景,你也知道 CPU 都是靠**提高主频**来改进性能的,但是现在这个做法已经遇到了瓶颈。主频提高导致 CPU 的功耗和发热量剧增,反过来制约 了 CPU 性能的进一步提高。2007 年开始,处理器厂商的竞争焦点**从主频转向了多核**。 - 在这种大背景下,Go 的设计者在决定去创建一门新语言的时候,果断将面向**多核、原生支持并发**作为了新语言的设计原则之一。并且,Go 放弃了传统的基于操作系统线程的并发模 型,而采用了用户层轻量级线程,Go 将之称为 **goroutine**。 - goroutine 占用的资源非常小,Go 运行时默认为每个 goroutine 分配的栈空间仅 **2KB**。goroutine 调度的切换也不用陷入(trap)操作系统内核层完成,代价很低。因此,一个 Go 程序中可以创建成千上万个并发的 goroutine。而且,所有的 Go 代码都在 goroutine 中执行,哪怕是 go 运行时的代码也不例外。 - 在提供了开销较低的 goroutine 的同时,Go 还在语言层面内置了辅助并发设计的原语:**channel 和 select**。开发者可以通过语言内置的 channel 传递消息或实现同步,并通过 select 实现多路 channel 的并发控制。相较于传统复杂的线程并发模型,Go 对并发的原生支持将大大降低开发人员在开发并发程序时的心智负担。 - 此外,并发的设计哲学不仅仅让 Go 在语法层面提供了并发原语支持,其对 Go 应用程序设计的影响更为重要。并发是一种程序结构设计的方法,它使得并行成为可能 - 采用并发方案设计的程序在单核处理器上也是可以正常运行的,也许在单核上的处理性能可能不如非并发方案。但随着处理器核数的增多,并发方案可以自然地提高处理性能 - 而且,并发与组合的哲学是一脉相承的,并发是一个更大的组合的概念,它在程序设计的全局层面对程序进行拆解组合,再映射到程序执行层面上:goroutines 各自执行特定的工 作,通过 channel+select 将 goroutines 组合连接起来。并发的存在鼓励程序员在程序设计时进行独立计算的分解,而对并发的原生支持让 Go 语言也更适应现代计算环境 - **面向工程** - Go 语言设计的初衷,就是面向解决真实世界中 Google 内部大规模软件开发存在的各种问题,为这些问题提供答案,这些问题包括:程序构建慢、依赖管理失控、代码难于理 解、跨语言构建难等 - 在 Go 语言最初设计阶段就将解决工程问题作为 Go 的设计原则之一去考虑 Go 语法、工具链与标准库的设计,这也是 Go 与其他偏学院派、偏 研究型的编程语言在设计思路上的一个重大差异 - 在面向工程设计哲学的驱使下,Go 在语法设计细节上做了精心的打磨。比如: - 重新设计编译单元和目标文件格式,实现 Go 源码快速构建,让大工程的构建时间缩短到类似动态语言的交互式解释的编译速度; - 如果源文件导入它不使用的包,则程序将无法编译。这可以充分保证任何 Go 程序的依赖树是精确的。这也可以保证在构建程序时不会编译额外的代码,从而最大限度地缩短编译时间; - 去除包的循环依赖,循环依赖会在大规模的代码中引发问题,因为它们要求编译器同时处理更大的源文件集,这会减慢增量构建; - 包路径是唯一的,而包名不必唯一的。导入路径必须唯一标识要导入的包,而名称只是包的使用者如何引用其内容的约定。“包名称不必是唯一的”这个约定,大大降低了开发人员给包起唯一名字的心智负担; - 故意不支持默认函数参数。因为在规模工程中,很多开发者利用默认函数参数机制,向函数添加过多的参数以弥补函数 API 的设计缺陷,这会导致函数拥有太多的参数,降低清晰度和可读性; - 增加类型别名(type alias),支持大规模代码库的重构 - 多数功能不需要依赖外部的第三方包或库 - Go 开发者可以直接基于标准库提供的这些包实现一个满足生产要求的 API 服务,从而减少对外部第三方包或库的依赖,降低工程代码依赖管理的复杂性 - Go 语言就提供了足以让所有其它主流语言开发人员羡慕的工具链,工具链涵盖了编译构建、代码格式化、包依赖管理、静态代码检查、测试、文档生成与查看、性能剖析、语言服务器、运行时程序跟踪等方方面面。 - gofmt,它统一了 Go 语言的代码风格,在其他语言开发者还在为代码风格争论不休的时候,Go 开发者可以更加专注于领域业务中。 - 在提供丰富的工具链的同时,Go 在标准库中提供了官方的词法分析器、语法解析器和类型检查器相关包,开发者可以基于这些包快速构建并扩展 Go 工具链。 ## 2. 环境 ### 2.1 配好环境:选择一种最适合你的Go安装方法 - 选择 Go 版本 - Go 语言的版本发布策略 - Go 团队已经将版本发布节奏稳定在每年发布两次大版本上,一般是在二月份和八月份发布 - 一般情况下,我建议你采用最新版本。因为 Go 团队发布的 Go 语言稳定版本的平均质量一直是很高的,少有影响使用的重大 bug。 - 使用次新版,即最新版本之前的那个版本比如,当前最新版本为 Go 1.17,那么这些项目会使用 Go 1.16 版本的最新补丁版本(Go 1.16.x),直到发布 Go 1.18 后,这些项目才会切换到 Go 1.17 的最新补丁版本(Go 1.17.x)。 - 安装 Go - **在 Linux 上安装 Go** - 需要下载并解压 Go Linux 安装包: - $wget -c https://golang.google.cn/dl/go1.16.5.linux-amd64.tar.gz - 虽然 Go 官方下载站点是 golang.org/dl,但我们可以用针对中国大陆的镜像站点 golang.google.cn/dl 来下载 - 第二步,将下载完毕的 Go 安装包解压到安装目录中: - $tar -C /usr/local -xzf go1.16.5.linux-amd64.tar.gz - 执行完上面解压缩命令后,我们将在 /usr/local 下面看到名为 go 的目录,这个目录就是Go 的安装目录,也是 Go 官方推荐的 Go 安装目录。我们执行下面命令可以查看该安装目 录下的组成: - > $ls -F /usr/local/go
AUTHORS CONTRIBUTORS PATENTS SECURITY.md api/ doc/ lib/
CONTRIBUTING.md LICENSE README.md VERSION bin/ favicon.ico misc
- 将 Go 二进制文件所在路径加入到用户环境变量 PATH 中,具体操作是将下面这行环境变量设置语句添加到 $HOME/.profile 文件的末尾: - export PATH=$PATH:/usr/local/go/bin - 然后执行下面命令使上述环境变量的设置立即生效: - $source ~/.profile - 最后,我们可以通过下面命令验证此次安装是否成功: - $go version - 如果这个命令输出了“go version go1.16.5 linux/amd64”,那么说明我们这次的 Go 安装是成功的。 - **在 Mac 上安装 Go** - 在 Mac 上我们可以在图形界面的引导下进行 Go 的安装。不过,我们先要下载适用于Mac 的 Go 安装包: - $wget -c https://golang.google.cn/dl/go1.16.5.darwin-amd64.pkg - 安装包下载完毕后,我们可以双击安装包,和 Linux 一样,Mac 上的 Go 安装包默认也会将 Go 安装到 /usr/local/go 路径下面,将这个路径加入到用户的环境变量 PATH中。具体操作方法与上面 Linux 中的步骤一样,也是将下面环境变量设置语句加入到$HOME/.profile 中,然后执行 source 命令让它生效就可以了: - PS: 如果你安装了zsh等,就要换一下环境变量,使其生效 - export PATH=$PATH:/usr/local/go/bin - 通过 go version 命令验证一下这次安装是否成功。 - **在 Windows 上安装 Go** - 在页面上找到 go 1.16.5 版本的 Windows msi 安装包, 安装程序默认会把 Go 安装在 C:\ProgramFiles\Go 下面 - Go 安装程序还会自动为你设置好 Go 使用所需的环境变量,包括在用户环境变量中增加 GOPATH,它的值默认为 C:\Users[用户名]\go,在系统变量中也会为 Path 变量增加一个值:C:\Program Files\Go\bin,这样我们就可以在任意路径下使用 Go 了。 - 安装多个 Go 版本 - 首先,你需要按照标准步骤将 go 其他版本 安装到事先建好的 /usr/local/go.其他版本 路径 下: - **方法一:重新设置 PATH 环境变量** ```shell $mkdir /usr/local/go1.15.13 $wget -c https://golang.google.cn/dl/go1.15.13.linux-amd64.tar.gz $tar -C /usr/local/go1.15.13 -xzf go1.15.13.linux-amd64.tar.gz ``` - 接下来,我们来设置 PATH 环境变量,将原先 $HOME/.profile 中的 PATH 变量的值由: ```shell export PATH=$PATH:/usr/local/go/bin ``` - 改为 ```shell export PATH=$PATH:/usr/local/go1.15.13/go/bin ``` - 这样通过执行 source 命令重新使 PATH 环境变量生效后,我们再执行 go version 命令 ```shell $go version go version go1.15.13 linux/amd64 ``` - Go 官方也提供了一种在系统中安装多个 Go 版本的方法,下面我们就来看一下第二种方法。 - **方法二:go get 命令** - 这种方法有一个前提,那就是当前系统中已经通过标准方法安装过某个版本的 Go 了。 - HOME/go/bin加入到P ATH环境变量中并生效,即便HOME/go/bin 这个目录当前不存在也没关系: ```shell export PATH=$PATH:/usr/local/go/bin:~/go/bin ``` - 然后,我们要执行下面这个命令安装 Go 1.15.13 版本的下载器: ```shell $go get golang.org/dl/go1.15.13 ``` - 这个命令会将名为 go1.15.13 的可执行文件安装到 $HOME/go/bin 这个目录下,它是Go 1.15.13 版本的专用下载器,下面我们再来执行 Go 1.15.13 的下载安装命令: ```shell $go1.15.13 download Downloaded 0.0% ( 16384 / 121120420 bytes) ... Downloaded 1.8% ( 2129904 / 121120420 bytes) ... Downloaded 84.9% (102792432 / 121120420 bytes) ... Downloaded 100.0% (121120420 / 121120420 bytes) Unpacking /root/sdk/go1.15.13/go1.15.13.linux-amd64.tar.gz ... Success. You may now run 'go1.15.13' ``` - 现在,我们看到这个命令下载了 go1.15.13.linux-amd64.tar.gz 安装包,也将它安装到 $HOME/sdk/go1.15.13 下面了。下载安装结束后,我们就可以利用带有版本号的 go 命 令来使用特定版本的 Go 了: ```shell $go1.15.13 version go version go1.15.13 linux/amd64 ``` - 同样的,我们也可以通过下面这个命令查看特定 Go 版本的安装位置: ```shell $go1.15.13 env GOROOT /root/sdk/go1.15.13 ``` - 配置 Go - Go 的配置项是以环境变量的形式存在的,我们可以通过下面这个命令查看 Go 的这些配置项: ```shell $go env ``` - 常用配置项: - ![常用配置项](pic/常用配置项.png) - 了解更多关于 Go 配置项的说明,你可以通过 go help environment 命令查看。 ### 2.2 初窥门径:一个Go程序的结构是怎样的? - 编写并运行第一个 Go 程序 - 首先,我们需要创建一个名为 main.go 的源文件。 - Go 的命名规则。Go 源文件总是用全小写字母形式的短小单词命名,并且以.go 扩展名结尾。 - 如果要在源文件的名字中使用多个单词,我们通常直接是将多个单词连接起来作为源文件名,而不是使用其他分隔符,比如下划线。也就是说,我们通常使用 helloworld.go 作为文件名而不是 hello_world.go。 - 这是因为下划线这种分隔符,在 Go 源文件命名中有特殊作用 - 尽量不要用两个以上的单词组合作为文件名,否则就很难分辨了 - main.go ```go package main import "fmt" func main() { fmt.Println("hello, world") } ``` - 编译和运行这个文件 - Linux ```shell $go build main.go $./main hello, world ``` - 编译和运行这个文件 - Windows ```shell >go build main.go >.\main.exe hello, world ``` - “hello,world”示例程序的结构 - package 定义了 Go 中的一个包 package。包是 Go 语言的基本组成单元,通常使用单个的小写单词命名,一个 Go 程序本质上就是一组包的集合。所有 Go 代码都有自己隶属的包 - main 包在 Go 中是一个特殊的包,整个 Go 程序中**仅允许存在一个名为 main 的包**。 - main 函数会比较特殊:当你运行一个可执行的 Go 程序的时候,所有的代码都会从这个入口函数开始运行。 - Gofmt 是 Go 语言在解决规模化(scale)问题上的一个最佳实践,并成为了 Go 语言吸引其他语言开发者的一大卖点。作为 Go 开发人员,请在提交你的代码前使用 **Gofmt 格式化你的 Go 源码**。 - 注意点 - 注意点 1:标准 Go 代码风格使用 Tab 而不是空格来实现缩进的,当然这个代码风格的格式化工作也可以交由 gofmt 完成。 - 注意点 2:我们调用了一个名为 Println 的函数,这个函数位于 Go 标准库的 fmt 包中。为了在我们的示例程序中使用 fmt 包定义的 Println 函数,我们其实做了两步操作。 - 注意点 3:我们传入的字符串也就是我们执行程序后在终端的标准输出上看到的字符串。这种“所见即所得”得益于 Go 源码文件本身采用的是 Unicode 字符集,而且用的是UTF-8 标准的字符编码方式 - 其实 Go 语言的正式语法规范是使用分号“;”来做结尾标识符的, gofmt 在按约定格式化代码时,会自动删除这些被我们手工加入的分号的 - Go 语言中程序是怎么编译的? - Go 是一种编译型语言,这意味着只有你编译完Go 程序之后,才可以将生成的可执行文件交付于其他人,并运行在没有安装 Go 的环境中。 - Go 提供了 run 命令可以直接运行 Go 源码文件 - 当然像 go run 这类命令更多用于开发调试阶段,真正的交付成果还是需要使用 go build - 复杂项目下 Go 程序的编译是怎样的 - Go module 构建模式是在 Go 1.11 版本正式引入的,为的是彻底解决 Go 项目复杂版本依赖的问题,在 Go 1.16 版本中,Go module 已经成为了 Go 默认的包依赖管理机制和Go 源码构建机制。 - Go Module 的核心是一个名为 go.mod 的文件,在这个文件中存储了这个 module 对第三方依赖的全部信息。 - 其实,一个 module 就是一个包的集合,这些包和 module 一起打版本、发布和分发。go.mod 所在的目录被我们称为它声明的 module 的根目录 - 我们也可以使用 go mod tidy 命令,让 Go 工具自动添加第三方包 - go.mod 已经记录了 hellomodule 直接依赖的包的信息。不仅如此,hellomodule 目录下还多了一个名为 go.sum 的文件,这个文件记录了 hellomodule 的直接依赖和间接依赖包的相关版本的 hash 值,用来校验本地包的真实性。 ### 2.3 标准先行:Go项目的布局标准是什么? - Go 语言“创世项目”结构是怎样的? - 用loccount 工具对 Go 语言发布的第一个Go 1.0 版本分析看看: - ![Go项目的布局标准](pic/Go项目的布局标准.png) - 现在的 Go 项目的典型结构布局是怎样的? - 可执行程序项目是以构建可执行程序为目的的项目,Go 社区针对这类 Go 项目所形成的典型结构布局是这样的: ```txt $tree -F exe-layout exe-layout ├── cmd/ │ ├── app1/ │ │ └── main.go │ └── app2/ │ └── main.go ├── go.mod ├── go.sum ├── internal/ │ ├── pkga/ │ │ └── pkg_a.go │ └── pkgb/ │ └── pkg_b.go ├── pkg1/ │ └── pkg1.go ├── pkg2/ │ └── pkg2.go └── vendor/ ``` - 先来看 cmd 目录。cmd 目录就是存放项目要编译构建的可执行文件对应的 main 包的源文件。如果你的项目中有多个可执行文件需要构建,每个可执行 文件的 main 包单独放在一个子目录中,比如图中的 app1、app2,cmd 目录下的各 app 的 main 包将整个项目的依赖连接在一起。 - 而且通常来说,main 包应该很简洁。我们在 main 包中会做一些命令行参数解析、资源初始化、日志设施初始化、数据库连接初始化等工作,之后就会将程序的执行权限交给更高 级的执行控制对象。另外,也有一些 Go 项目将 cmd 这个名字改为 app 或其他名字,但它的功能其实并没有变。 - 接着我们来看 pkgN 目录,这是一个存放项目自身要使用、同样也是可执行文件对应 main 包所要依赖的库文件,同时这些目录下的包还可以被外部项目引用。 - 然后是 go.mod 和 go.sum,它们是 Go 语言包依赖管理使用的配置文件。我们前面说过,Go 1.11 版本引入了 Go Module 构建机制,这里我建议你所有新项目都基于 Go Module 来进行包依赖管理,因为这是目前 Go 官方推荐的标准构建模式 - 对于还没有使用 Go Module 进行包依赖管理的遗留项目,比如之前采用 dep、glide 等作为包依赖管理工具的,建议尽快迁移到 Go Module 模式。Go 命令支持直接将 dep 的 Gopkg.toml/Gopkg.lock 或 glide 的 glide.yaml/glide.lock 转换为 go.mod。 - 最后我们再来看看 vendor 目录。vendor 是 Go 1.5 版本引入的用于在项目本地缓存特定版本依赖包的机制,在 Go Modules 机制引入前,基于 vendor 可以实现可重现构建,保 证基于同一源码构建出的可执行程序是等价的。 - 不过呢,我们这里将 vendor 目录视为一个可选目录。原因在于,Go Module 本身就支持可再现构建,而无需使用 vendor。 当然 Go Module 机制也保留了 vendor 目录(通过 go mod vendor 可以生成 vendor 下的依赖包,通过 go build -mod=vendor 可以实现基于 vendor 的构建)。一般我们仅保留项目根目录下的 vendor 目录, 否则会造成不必要的依赖选择的复杂性。 - 当然了,有些开发者喜欢借助一些第三方的构建工具辅助构建,比如:make、bazel 等。你可以将这类外部辅助构建工具涉及的诸多脚本文件(比如 Makefile)放置在项目的顶层 目录下,就像 Go 创世项目中的 all.bash 那样。 - 当然如果你非要在一个代码仓库中存放多个 module,那么新版 Go 命令也提供了很好的支持。比如下面代码仓库 multi-modules 下面有三个 module:mainmodule、 module1 和 module2: ```txt $tree multi-modules multi-modules ├── go.mod // mainmodule ├── module1 │ └── go.mod // module1 └── module2 └── go.mod // module2 ``` - 我们可以通过 git tag 名字来区分不同 module 的版本。其中 vX.Y.Z 形式的 tag 名字用于代码仓库下的 mainmodule;而 module1/vX.Y.Z 形式的 tag 名字用于指示 module1 的 版本;同理,module2/vX.Y.Z 形式的 tag 名字用于指示 module2 版本。 - 如果 Go 可执行程序项目有一个且只有一个可执行程序要构建,那就比较好办了,我们可以将上面项目布局进行简化: ```txt $tree -F -L 1 single-exe-layout single-exe-layout ├── go.mod ├── internal/ ├── main.go ├── pkg1/ ├── pkg2/ └── vendor/ ``` - 你可以看到,我们删除了 cmd 目录,将唯一的可执行程序的 main 包就放置在项目根目录下,而其他布局元素的功用不变。 - 好了到这里,我们已经了解了 Go 可执行程序项目的典型布局,现在我们再来看看 **Go 库项目**的典型结构布局是怎样的。 - Go 库项目仅对外暴露 Go 包,这类项目的典型布局形式是这样的: ```txt $tree -F lib-layout lib-layout ├── go.mod ├── internal/ │ ├── pkga/ │ │ └── pkg_a.go │ └── pkgb/ │ └── pkg_b.go ├── pkg1/ │ └── pkg1.go └──pkg2/ └──pkg2.go ``` - Go 库项目的初衷是为了对外部(开源或组织内部公开)暴露 API,对于仅限项目内部使用而不想暴露到外部的包,可以放在项目顶层的 internal 目录下面。当然 internal 也可以有 多个并存在于项目结构中的任一目录层级中,关键是项目结构设计人员要明确各级 internal 包的应用层次和范围。 - 对于有一个且仅有一个包的 Go 库项目来说,我们也可以将上面的布局做进一步简化,简化的布局如下所示: ```txt $tree -L 1 -F single-pkg-lib-layout single-pkg-lib-layout ├── feature1.go ├── feature2.go ├── go.mod └──internal/ ``` - 简化后,我们将这唯一包的所有源文件放置在项目的顶层目录下(比如上面的 feature1.go和 feature2.go),其他布局元素位置和功用不变。 ### 2.4 构建模式:Go是怎么解决包依赖管理问题的? - Go 构建模式是怎么演化的? - Go 程序由 Go 包组合而成的,Go 程序的构建过程就是确定包版本、编译包以及将编译后得到的目标文件链接在一起的过程。 - Go 语言的构建模式历经了三个迭代和演化过程,分别是最初期的 GOPATH、1.5 版本的 Vendor 机制,以及现在的 Go Module。 - 首先我们来看 GOPATH - Go 编译器可以在本地 GOPATH 环境变量配置的路径下,搜寻 Go 程序依赖的第三方包。如果存在,就使用这个本地包进行编译;如果不存在,就会报编译错误。 - GOPATH 构建模式下编写的代码 ```go package main import "github.com/sirupsen/logrus" func main() { logrus.Println("hello, gopath mode") } ``` - 你可以看到,这段代码依赖了第三方包 logrus(logrus 是 Go 社区使用最为广泛的第三方 log 包)。 - Go 编译器在 GOPATH 构建模式下,究竟怎么在 GOPATH 配置的路径下搜寻第三方依赖包呢? - 可以通过 go get 命令将本地缺失的第三方依赖包下载到本地 ```shell $go get github.com/sirupsen/logrus ``` - 这里的 go get 命令,不仅能将 logrus 包下载到 GOPATH 环境变量配置的目录下,它还会检查 logrus 的依赖包在本地是否存在,如果不存在,go get 也会一并将它们下载到本地。 - 也就是说,在 GOPATH 构建模式下,Go 编译器实质上并没有关注 Go 项目所依赖的第三方包的版本。但 Go 开发者希望自己的 Go 项目所依赖的第三方包版本能受到自己的控 制,而不是随意变化。于是 Go 核心开发团队引入了 Vendor 机制试图解决上面的问题。 - 要想开启 vendor 机制,你的 Go 项目必须位于 GOPATH 环境变量配置的某个路径的 src 目录下面。如果不满足这一路径要求,那么 Go 编译器是不会理会 Go 项目目录下的 vendor 目录的。 - 就在 Go 社区为包依赖管理焦虑并抱怨没有官方工具的时候,Go 核心团队基于社区实践的经验和教训,推出了 Go 官方的解决方案:Go Module。 - 创建你的第一个 Go Module - 在 Go Module 模式下,通常一个代码仓库对应一个 Go Module。一个 Go Module 的顶层目录下会放置一个 go.mod 文件,每个 go.mod 文件会定义唯一一个 module,也就是 说 Go Module 与 go.mod 是一一对应的。 - go.mod 文件所在的顶层目录也被称为 module 的根目录,module 根目录以及它子目录下的所有 Go 包均归属于这个 Go Module,这个 module 也被称为 main module - **创建一个 Go Module** - 第一步,通过 go mod init 创建 go.mod 文件,将当前项目变为一个 Go Module; - 第二步,通过 go mod tidy 命令自动更新当前 module 的依赖信息; - 第三步,执行 go build,执行新 module 的构建。 - ## 3. 语法 ### 3.1 **变量声明** :静态语言有别于动态语言的重要特征 - 在编程语言中,为了方便操作内存特定位置的数据,我们**用一个特定的名字与位于特定位置的内存块绑定在一起**,这个名字被称为**变量**。 - 但这并不代表我们可以通过变量随意引用或修改内存,变量所绑定的内存区域是要有**一个明确的边界的**。 - 动态语言(比如 Python、Ruby 等)的解释器可以在运行时通过对变量赋值的分析,**自动确定变量的边界**。并且在动态语言中,一个变量可以在运行时被赋予大小不同的边界。 - Go 语言,它沿袭了静态语言的这一要求:**使用变量之前需要先进行变量声明** - Go 语言的变量声明方法 - 通用的变量声明方法 - ![通用的变量声明方法](pic/通用的变量声明方法.png) - 这个变量声明分为四个部分: - var 是修饰变量声明的关键字; - a 为变量名; - int 为该变量的类型; - 10 是变量的初值。 - 其实 Go 语言的变量声明形式与其他主流静态语言有一个显著的差异,那就是它将**变量名放在了类型的前面** - 如果你没有**显式**为变量赋予初值,Go 编译器会为变量**赋予这个类型的零值**: - var a int // a的初值为int类型的零值:0 - Go 语言的每种原生类型都有它的默认值,这个默认值就是这个类型的零值。 - Go 规范定义的内置原生类型的默认值(即零值) - ![内置原生类型的默认值](pic/内置原生类型的默认值.png) - 像数组、结构体这样复合类型变量的零值就是它们**组成元素都为零值时的结果** - 除了单独声明每个变量外,Go 语言还提供了变量声明块(block)的语法形式,可以**用一个 var 关键字将多个变量声明放在一起**,像下面代码这样: ```go var ( a int = 128 b int8 = 6 s string = "hello" c rune = 'A' t bool = true ) ``` - 而且,Go 语言还支持在**一行变量声明中同时声明多个变量**: ```go var a, b, c int = 5, 6, 7 ``` - 这样的多变量声明同样也可以用在变量声明块中,像下面这样: ```go var ( a, b, c int = 5, 6, 7 c, d, e rune = 'C', 'D', 'E' ) ``` - 当然了,虽然我们现在写的多变量声明都是在声明同一类型的变量,**但是它也适用于声明不同类型的变量** - 为了给开发者带来更好的使用体验,Go 语言还提供了两种变量声明的 **“语法糖”** - 省略类型信息的声明: - 在通用的变量声明的基础上,Go 编译器允许我们省略变量声明中的类型信息,它的标准范式是 **“var varName = initExpression”**, 省略了类型信息的变量声明: ```go var b = 13 ``` - Go 编译器**会根据右侧变量初值自动推导出变量的类型**,并给这个变量赋予初值所对应的默认类型。比如,整型值的默认类型 int,浮点值的默认类型为 float64,复数 值的默认类型为 complex128。其他类型值的默认类型就更好分辨了,在 Go 语言中仅有唯一与之对应的类型,比如布尔值的默认类型只能是 bool,字符值默认类型只能是 true,字符串值的默认类型只能是 string 等。 - 如果我们不接受默认类型,而是要显式地为变量指定类型,除了通用的声明形式,我们还可以通过**显式类型转型**达到我们的目的: ```go var b = int32(13) ``` - 显然这种省略类型信息声明的“语法糖”仅**适用于在变量声明的同时显式赋予变量初值的情况** - 结合多变量声明,我们可以使用这种变量声明“语法糖”**声明多个不同类型的变量**: ```go var a, b, c = 12, 'A', "hello" ``` - 我们声明了三个变量 a、b 和 c,但它们分别具有不同的类型,分别为 int、rune 和 string。 - 是否还有更简化的变量声明形式呢?答案是有的。下面我们就来看看短变量声明。 - 短变量声明: - 使用短变量声明时,我们甚至可以省去 var 关键字以及类型信息,它的标准范式是 **“varName := initExpression”** ```go a := 12 b := 'A' c := "hello" ``` - 而且,短变量声明也支持一次声明多个变量,而且形式更为简洁,是这个样子的: ```go a, b, c := 12, 'A', "hello" ``` - Go 语言的两类变量 - Go 语言的变量可以分为两类: - 一类称为包级变量 (package varible),也就是在包级别可见的变量。 - 如果是导出变量(大写字母开头),那么这个包级变量也可以被视为全局变量; - 另一类则是局部变量 (local varible),也就是 Go 函数或方法体内声明的变量,仅在函数或方法体内可见。 - 而我们声明的所有变量都逃不开这两种。 - 包级变量的声明形式 - 先下个结论:**包级变量只能使用带有 var 关键字的变量声明形式**,不能使用短变量声明形式,但在形式细节上可以有一定灵活度。 - 我们可以从 **“变量声明时是否延迟初始化”** 这个角度,对包级变量的声明形式进行一次分类。 - **第一类:声明并同时显式初始化。** ```go // $GOROOT/src/io/io.go var ErrShortWrite = errors.New("short write") var ErrShortBuffer = errors.New("short buffer") var EOF = errors.New("EOF") ``` - 这个代码块里声明的变量都是 io 包的包级变量。在 Go 标准库中,对于变量声明的同时进行显式初始化的这类包级变量,实践中多使用这种省略类型信息的“语法糖”格式: ```go var varName = initExpression ``` - Go 编译器会自动根据等号右侧 InitExpression 结果值的类型,来确定左侧声明的变量的类型,这个类型会是结果值对应类型的默认类型。 - 当然,如果我们不接受默认类型,而是要显式地为包级变量指定类型,那么我们有两种方式,我这里给出了**两种包级变量的声明形式**的对比示例。 ```go //第一种: plain var a = 13 // 使用默认类型 var b int32 = 17 // 显式指定类型 var f float32 = 3.14 // 显式指定类型 //第二种: var a = 13 // 使用默认类型 var b = int32(17) // 显式指定类型 var f = float32(3.14) // 显式指定类型 ``` - 虽然这两种方式都是可以使用的,但从声明一致性的角度出发,Go 更推荐我们**使用后者**,这样能统一接受默认类型和显式指定类型这两种声明形式,尤其是在将这些变量放在一个 var 块中声明时,你会更明显地看到这一点。 - 所以我们更青睐下面这样的形式: ```go var ( a = 13 b = int32(17) f = float32(3.14) ) ``` - **第二类:声明但延迟初始化。** - 对于声明时并不立即显式初始化的包级变量,我们可以使用下面这种通用变量声明形式: ```go var a int32 var f float64 ``` - 我们知道,虽然没有显式初始化,Go 语言也会让这些变量拥有初始的“零值”。如果是自定义的类型,我也建议你**尽量保证它的零值是可用的**。 - 这里还有一个注意事项,就是**声明聚类与就近原则**。 - 正好,Go 语言提供了**变量声明块**用来把多个的变量声明放在一起,并且在语法上也不会限制放置在 var 块中的声明类型,那我们就应该学会充分利用 var 变量声明块,让我们变量 声明更规整,更具可读性,现在我们就来试试看。 - 其实,我们**可以将延迟初始化的变量声明放在一个 var 声明块** (比如上面的第一个 var 声明块),然后将声明且**显式初始化的变量放在另一个 var 块中**(比如上面的第二个 var 声明 块),这里我称这种方式为“声明聚类”,声明聚类可以提升代码可读性。 - 我们是否应该将包级变量的声明全部集中放在源文件头部呢?答案不能一概而论。 - 使用静态编程语言的开发人员都知道,变量声明最佳实践中还有一条:**就近原则**。 - 尽可能在靠近第一次使用变量的位置声明这个变量,就近原则实际上也是对变量的作用域最小化的一种实现手段。 ```go // $GOROOT/src/net/http/request.go var ErrNoCookie = errors.New("http: named cookie not present") func (r *Request) Cookie(name string) (*Cookie, error) { for _, c := range readCookies(r.Header, name) { return c, nil }r eturn nil, ErrNoCookie } ``` - 在这个代码块里,ErrNoCookie 这个变量在整个包中仅仅被用在了 Cookie 方法中,因此它被声明在紧邻 Cookie 方法定义的地方。当然了,如果一个包级变量在**包内部被多处**使 用,那么这个变量还是放在**源文件头部**声明比较适合的。 - **局部变量的声明形式** - 这里我们也从“变量声明的时候是否延迟初始化”这个角度,对本地变量的声明形式进行分类说明。 - 第一类:**对于延迟初始化的局部变量声明,我们采用通用的变量声明形式** - 省略类型信息的声明和短变量声明这两种“语法糖”变量声明形式都不支持变量的延迟初始化,因此对于这类局部变量,和包级变量一样,我们只能采用通用的变量声明形式: ```go var err error ``` - 第二类:**对于声明且显式初始化的局部变量,建议使用短变量声明形式** - 短变量声明形式是局部变量最常用的声明形式,它遍布在 Go 标准库代码中。对于接受默认类型的变量,我们使用下面这种形式: ```go a := 17 f := 3.14 s := "hello, gopher!" ``` - 对于不接受默认类型的变量,我们依然可以使用短变量声明形式,只是在":="右侧要做一个显式转型,以保持声明的一致性: ```go a := int32(17) f := float32(3.14) s := []byte("hello, gopher!") ``` - 这里我们还要注意:**尽量在分支控制时使用短变量声明形式**。 - strings 包的 LastIndexAny 方法为我们很好地诠释了如何**将短变量声明形式与分支控制语句融合在一起使用**: ```go // $GOROOT/src/strings/strings.go func LastIndexAny(s, chars string) int { if chars == "" { // Avoid scanning all of s. return -1 } if len(s) > 8 { // 作者注:在if条件控制语句中使用短变量声明形式声明了if代码块中要使用的变量 as和 isASCII if as, isASCII := makeASCIISet(chars); isASCII { for i := len(s) - 1; i >= 0; i-- { if as.contains(s[i]) { return i } } return -1 } } for i := len(s); i > 0; { // 作者注:在for循环控制语句中使用短变量声明形式声明了for代码块中要使用的变量c r, size := utf8.DecodeLastRuneInString(s[:i]) i -= size for _, c := range chars { if r == c { return i } } } return -1 } ``` - 而且,短变量声明的这种融合的使用方式也体现出“就近”原则,**让变量的作用域最小化**。 - 但是如果你在声明局部变量时遇到了适合聚类的应用场景,你也应该毫不犹豫地使用 var 声明块来声明多于一个的局部变量,具体写法你可以参考 Go 标准库 net 包中 resolveAddrList 方法: ```go // $GOROOT/src/net/dial.go func (r *Resolver) resolveAddrList(ctx context.Context, op, network, addr string, hint Addr) (addrList, error) { ... ... var ( tcp *TCPAddr udp *UDPAddr ip *IPAddr wildcard bool ) ... ... } ``` - 总结: - ![变量声明方式总结](pic/变量声明方式总结.png) ### 3.2 代码块与作用域:如何保证变量不会被遮蔽? - Go 变量遮蔽(Variable Shadowing)的问题 ```go var a = 11 func foo(n int) { a := 1 a += n } func main() { fmt.Println("a =", a) // 11 foo(5) fmt.Println("after calling foo, a =", a) // 11 } ``` - 在这段代码中,函数 foo 调用前后,包级变量 a 的值都没有发生变化。这是因为,虽然 foo 函数中也使用了变量 a,但是 foo 函数中的变量 a 遮蔽了外面的包级变量 a,这使得包级变量 a 没有参与到 foo 函数的逻辑中,所以就没有发生变化了。 - 变量遮蔽只是个引子,我真正想跟你说的是 **代码块(Block,也可译作词法块)和作用域(Scope)** 这两个概念,因为要想彻底保证不出现变量遮蔽问题,我们需要深入了解这两 个概念以及其背后的规则。 - 代码块与作用域 - Go 语言中的代码块是包裹在一对大括号内部的声明和语句序列,如果一对大括号内部**没有任何声明或其他语句**,我们就把它叫做**空代码块**。 - Go 代码块支持嵌套,我们**可以在一个代码块中嵌入多个层次的代码块**,如下面示例代码所示: ```go func foo() { //代码块1 { // 代码块2 { // 代码块3 { // 代码块4 } } } } ``` - 像代码块 1 到代码块 4 这样的代码块,我们称这样的代码块为显式代码块(Explicit Blocks) - 虽然隐式代码块身着“隐身衣”,但我们也不是没有方法来识别它,因为 Go 语言规范对现存的几类隐式代码块做了明确的定义 - ![隐式代码块](pic/隐式代码块.png) - 位于最外层的**宇宙代码块(Universe Block)**,它囊括的范围最大,所有 Go 源码都在这个隐式代码块中,你也可以将该隐式代码块想象为在所有 Go 代码的最外层 加一对大括号,就像图中最外层的那对大括号那样。 - 在宇宙代码块内部嵌套了**包代码块(Package Block)**,每个 Go 包都对应一个隐式包代码块,每个包代码块包含了该包中的所有 Go 源码,不管这些代码分布在这个包里的多少 个的源文件中。 - 我们再往里面看,在包代码块的内部嵌套着若干**文件代码块(File Block)**,每个 Go 源文件都对应着一个文件代码块,也就是说一个 Go 包如果有多个源文件,那么就会有多个对 应的文件代码块。 - 再下一个级别的隐式代码块就在控制语句层面了,包括 if、for 与 switch。**我们可以把每个控制语句都视为在它自己的隐式代码块里**。不过你要注意,这里的控制语句隐式代码块 与控制语句使用大括号包裹的显式代码块并不是一个代码块。你再看一下前面的图,switch **控制语句的隐式代码块的位置是在它显式代码块的外面的**。 - 最后,位于最内层的隐式代码块是 switch 或 select 语句的每个 case/default 子句中,虽然没有大括号包裹,但实质上,**每个子句都自成一个代码块**。 - 作用域的概念是针对标识符的,不局限于变量。每个标识符都有自己的作用域,而**一个标识符的作用域就是指这个标识符在被声明后可以被有效使用的源码区域**。 - 显然,作用域是一个编译期的概念,也就是说,编译器在编译过程中会对每个标识符的作用域进行检查,对于在标识符作用域外使用该标识符的行为会给出编译错误的报错 - 划定原则是什么呢? - 原则就是声明于外层代码块中的标识符,其作用域包括所有内层代码块。而且,这一原则同时适于显式代码块与隐式代码块。 - Go 语言当前版本定义里的所有预定义标识符 - ![预定义标识符](pic/预定义标识符.png) - 避免变量遮蔽的原则 - 变量遮蔽问题的根本原因,就是**内层代码块中声明了一个与外层代码块同名且同类型的变量,这样,内层代码块中的同名变量就会替代那个外层变量**,参与此层代码块内的相关计 算,我们也就说内层变量遮蔽了外层同名变量。 - 看一下这个示例代码,它就存在着多种变量遮蔽的问题: ```go ... ... var a int = 2020 func checkYear() error { err := errors.New("wrong year") switch a, err := getYear(); a { case 2020: fmt.Println("it is", a, err) case 2021: fmt.Println("it is", a) err = nil } fmt.Println("after check, it is", a) return err } type new int // 第一个问题:遮蔽预定义标识符。 func getYear() (new, error) { var b int16 = 2021 return new(b), nil } func main() { err := checkYear() if err != nil { fmt.Println("call checkYear error:", err) return } fmt.Println("call checkYear ok") } ``` - 运行结果 ```shell $go run complex.go it is 2021 after check, it is 2020 call checkYear error: wrong year ``` - getYear 函数返回了正确的年份 (2021),但是 checkYear 在结尾却输出“after check, it is 2020”,并且返回的 err 并非为 nil,这显然是变量遮蔽的“锅”! - 上面这段代码究竟有几处变量遮蔽问题(包括标识符遮蔽问题)。 - 第一个问题:遮蔽预定义标识符。 - 这本是 Go 语言的一个预定义标识符,但上面示例代码呢,却用 new 这个名字定义了一个新类型,于是 new 这个标识符就被遮蔽了。 - 第二个问题:遮蔽包代码块中的变量。 - switch 语句在它自身的隐式代码块中,通过短变量声明形式重新声明了一个变量 a,这个变量 a 就遮蔽了外层包代码块中的包级变量 a,这就是打印“after check, it is 2020”的原因。包级变量 a 没有如预期那样被 getYear 的返回值赋值为正确的年份 2021,2021 被赋值给了遮蔽它的 switch 语句隐式代码块中的那个新声明的 a。 - 第三个问题:遮蔽外层显式代码块中的变量 - switch 语句,除了声明一个新的变量 a 之外,它还声明了一个名为 err 的变量,这个变量就遮蔽了第 4 行 checkYear 函数在显式代码块中声明的 err 变量,这导 致第 12 行的 nil 赋值动作作用到了 switch 隐式代码块中的 err 变量上,而不是外层checkYear 声明的本地变量 err 变量上,后者并非 nil,这样 checkYear 虽然从 getYear得到了正确的年份值,但却返回了一个错误给 main 函数,这直接导致了 main 函数打印了错误:“call checkYear error: wrong year”。 - **短变量声明与控制语句的结合十分容易导致变量遮蔽问题**,并且很不容易识别,因此在日常 go 代码开发中你要尤其注意两者结合使用的地方。 - 利用工具检测变量遮蔽问题 - Go 官方提供了 go vet 工具可以用于对 Go 源码做一系列静态检查,在 Go 1.14 版以前默认支持变量遮蔽检查,Go 1.14 版之后,变量遮蔽检查的插件就需要我们单独安装了,安装方法如下: ```shell $go install golang.org/x/tools/go/analysis/passes/shadow/cmd/shadow@latest go: downloading golang.org/x/tools v0.1.5 go: downloading golang.org/x/mod v0.4.2 ``` - 安装成功,我们就可以通过 go vet 扫描代码并检查这里面有没有变量遮蔽的问题了。 ```shell $go vet -vettool=$(which shadow) -strict complex.go ./complex.go:13:12: declaration of "err" shadows declaration at line 11 ``` - 看到,go vet 只给出了 err 变量被遮蔽的提示,变量 a 以及预定义标识符 new 被遮蔽的情况并没有给出提示。可以看到,工具确实可以辅助检测,但也不是万能的,不能穷 尽找出代码中的所有问题,所以你还是要深入理解代码块与作用域的概念,尽可能在日常编码时就主动规避掉所有遮蔽问题。 ### 3.3 基本数据类型:Go原生支持的数值类型有哪些? - Go 语言的类型大体可分为**基本数据类型、复合数据类型和接口类型**这三种。其中,我们日常 Go 编码中使用最多的就是基本数据类型,而基本数据类型中使用占比最大的又是数值类型。 - Go 语言原生支持的数值类型包括**整型、浮点型以及复数类型** - 被广泛使用的整型 - Go 语言的整型,主要用来表示现实世界中整型数量,比如:人的年龄、班级人数等。它可以分为**平台无关整型和平台相关整型这两种**,它们的区别主要就在,这些整数类型在不同 CPU 架构或操作系统下面,它们的**长度是否是一致的**。 - 我们先来看平台无关整型,它们在任何 CPU 架构或任何操作系统下面,**长度都是固定不变的**。 - ![平台无关整型](pic/平台无关整型.png) - 这些平台无关的整型也可以分成两类:有符号整型(int8~int64)和无符号整型(uint8~uint64)。两者的本质差别在于最高二进制位(bit 位)是否被解释为符号 位,这点会影响到无符号整型与有符号整型的取值范围。 - 我们以下图中的这个 8 比特(一个字节)的整型值为例,当它被解释为无符号整型 uint8 时,和它被解释为有符号整型 int8 时表示的值是不同的: - ![符号位](pic/符号位.png) - 在同样的比特位表示下,当最高比特位被解释为符号位时,它代表一个有符号整型 (int8),它表示的值为 -127;当最高比特位不被解释为符号位时,它代表一个无符号整 型 (uint8),它表示的值为 129。 - 这里你可能就会问了:即便最高比特位被解释为符号位,上面的有符号整型所表示值也应该为 -1 啊,怎么会是 -127 呢? - 因为 Go 采用 2 的补码(Two’s Complement)作为整型的比特位编码方法。因此,我们不能简单地将最高比特位看成负号,把其余比特位表示的值看成负号后面的数 值。Go 的补码是通过原码逐位取反后再加 1 得到的 - 127 的转换 - ![127转换](pic/127转换.png) - 与平台无关整型对应的就是平台相关整型,它们的长度会根据运行平台的改变而改变。Go 语言原生提供了三个平台相关整型,它们是 int、uint 与 uintptr - ![平台相关整型](pic/平台相关整型.png) - 由于这三个类型的长度是平台相关的,所以我们在编写有移植性要求的代码时,千万不要强依赖这些类型的长度。如果你不知道这三个类型在目标运 行平台上的长度,可以通过 unsafe 包提供的 SizeOf 函数来获取,比如在 x86-64 平台上,它们的长度均为 8: ```go var a, b = int(5), uint(6) var p uintptr = 0x12345678 fmt.Println("signed integer a's length is", unsafe.Sizeof(a)) // 8 fmt.Println("unsigned integer b's length is", unsafe.Sizeof(b)) // 8 fmt.Println("uintptr's length is", unsafe.Sizeof(p)) // 8 ``` - 整型的溢出问题 - 一个无符号整型与一个有符号整型的溢出情况: ```go var s int8 = 127 s += 1 // 预期128,实际结果-128 var u uint8 = 1 u -= 2 // 预期-1,实际结果255 ``` - 有符号整型变量 s 初始值为 127,在加 1 操作后,我们预期得到 128,但由于 128 超出了 int8 的取值边界,其实际结果变成了 -128。无符号整型变量 u 也是一样的道理, 它的初值为 1,在进行减 2 操作后,我们预期得到 -1,但由于 -1 超出了 uint8 的取值边界,它的实际结果变成了 255。 - 这个问题最容易发生在循环语句的结束条件判断中,因为这也是经常使用整型变量的地方。无论无符号整型,还是有符号整型都存在溢出的问题,所以我们要十分小心地选择参 与循环语句结束判断的整型变量类型,以及与之比较的边界值。 - 字面值与格式化输出 - Go 语言在设计开始,就继承了 C 语言关于数值字面值(Number Literal)的语法形式。早期 Go 版本支持十进制、八进制、十六进制的数值字面值形式 ```go a := 53 // 十进制 b := 0700 // 八进制,以"0"为前缀 c1 := 0xaabbcc // 十六进制,以"0x"为前缀 c2 := 0Xddeeff // 十六进制,以"0X"为前缀 ``` - Go 1.13 版本中,Go 又增加了对二进制字面值的支持和两种八进制字面值的形式,比如: ```go d1 := 0b10000001 // 二进制,以"0b"为前缀 d2 := 0B10000001 // 二进制,以"0B"为前缀 e1 := 0o700 // 八进制,以"0o"为前缀 e2 := 0O700 // 八进制,以"0O"为前缀 ``` - 为提升字面值的可读性,Go 1.13 版本还支持在字面值中增加数字分隔符“_”,分隔符可以用来将数字分组以提高可读性。比如每 3 个数字一组,也可以用来分隔前缀与字面值中 的第一个数字: ```go a := 5_3_7 // 十进制: 537 b := 0b_1000_0111 // 二进制位表示为10000111 c1 := 0_700 // 八进制: 0700 c2 := 0o_700 // 八进制: 0700 d1 := 0x_5c_6d // 十六进制:0x5c6d ``` - 这里你要注意一下,Go 1.13 中增加的二进制字面值以及数字分隔符,只在 go.mod 中的 go version 指示字段为 Go 1.13 以及以后版本的时候,才会生效,否则编 译器会报错。 - 反过来,我们也可以通过标准库 fmt 包的格式化输出函数,将一个整型变量输出为不同进制的形式。比如下面就是将十进制整型值 59,格式化输出为二进制、八进制和十六进制的代码: ```go var a int8 = 59 fmt.Printf("%b\n", a) //输出二进制:111011 fmt.Printf("%d\n", a) //输出十进制:59 fmt.Printf("%o\n", a) //输出八进制:73 fmt.Printf("%O\n", a) //输出八进制(带0o前缀):0o73 fmt.Printf("%x\n", a) //输出十六进制(小写):3b fmt.Printf("%X\n", a) //输出十六进制(大写):3B ``` - 浮点型 - 主要是讲解 Go 语言中浮点类型在内存中的表示方法 - 浮点型的二进制表示 - Go 语言中的浮点类型的二进制表示是怎样的,我们首先要来了解IEEE 754 标准 - IEEE 754 是 IEEE 制定的二进制浮点数算术标准,它是 20 世纪 80 年代以来最广泛使用的浮点数运算标准,被许多 CPU 与浮点运算器采用。 - IEEE 754 标准规定了四种表示浮点数值的方式:单精度(32 位)、双精度(64 位)、扩展单精度(43 比特以上)与扩展双精度(79 比特以上,通常以 80 位实现)。后两种其实 很少使用,我们重点关注前面两个就好了。 - Go 语言提供了 float32 与 float64 两种浮点类型,它们分别对应的就是 IEEE 754 中的单精度与双精度浮点数值类型。 - Go 提供的浮点类型都是平台无关的。 - float32 与 float64 这两种浮点类型有什么异同点呢? - 无论是 float32 还是 float64,它们的变量的默认值都为 0.0,不同的是它们占用的内存空间大小是不一样的,可以表示的浮点数的范围与精度也不同。 - 浮点数在内存中的二进制表示(Bit Representation)要比整型复杂得多,IEEE 754 规范给出了在内存中存储和表示一个浮点数的标准形式 - ![浮点数的标准表示](pic/浮点数的标准表示.png) - 我们看到浮点数在内存中的二进制表示分三个部分:符号位、阶码(即经过换算的指数),以及尾数。 - 这样表示的一个浮点数,它的值等于: - ![浮点数内存值](pic/浮点数内存值.png) - 其中浮点值的符号由符号位决定:当符号位为 1 时,浮点值为负值;当符号位为 0 时,浮点值为正值。公式中 offset 被称为阶码偏移值 - 单精度(float32)与双精度(float64)浮点数在阶码和尾数上的不同 - ![单双精度尾阶](pic/单双精度尾阶.png) - 单精度浮点类型(float32)为符号位分配了 1 个 bit,为阶码分配了 8 个bit,剩下的 23 个 bit 分给了尾数。 - 而双精度浮点类型,除了符号位的长度与单精度一样之外,其余两个部分的长度都要远大于单精度浮点型,阶码可用的 bit 位数量为 11之外, 其余两个部分的长度都要远大于单精度浮点型,阶码可用的 bit 位数量为 11,尾数则更是拥有了 52 个 bit 位。 - “阶码偏移值” - 浮点值 139.8125,转换为 IEEE 754 规定中的那种单精度二进制表示 - 步骤一:我们要把这个浮点数值的整数部分和小数 部分,分别转换为二进制形式(后缀 d 表示十进制数,后缀 b 表示二进制数): - 整数部分:139d => 10001011b - 小数部分:0.8125d => 0.1101b(十进制小数转换为二进制可采用“乘 2 取整”的竖式计算) - 这样,原浮点值 139.8125d 进行二进制转换后,就变成 10001011.1101b。 - 步骤二:移动小数点,直到整数部分仅有一个 1 - 也就是 10001011.1101b => 1.00010111101b。我们看到,为了整数部分仅保留一个 1,小数点向左移了 7 位,这样 指数就为 7,尾数为 00010111101b。 - 步骤三:计算阶码 - IEEE754 规定不能将小数点移动而得到的指数,一直填到阶码部分,指数到阶码还需要一个转换过程。对于 float32 的单精度浮点数而言,阶码 = 指数 + 偏移值。 - 偏移值的计算公式为 2^(e-1)-1,其中 e 为阶码部分的 bit 位数,这里为 8,于是单精度浮点数的阶码偏移 值就为 2^(8-1)-1 = 127。这样在这个例子中,阶码 = 7 + 127 = 134d = 10000110b。 float64 的双精度浮点数的阶码计算也是这样的。 - 步骤四:将符号位、阶码和尾数填到各自位置,得到最终浮点数的二进制表示。 - 尾数位数不足 23 位,可在后面补 0。 - ![计算阶码](pic/计算阶码.png) - 这样,最终浮点数 139.8125d 的二进制表示就为 0b_0_10000110_00010111101_000000000000。 - 最后,我们再通过 Go 代码输出浮点数 139.8125d 的二进制表示,和前面我们手工转换的做一下比对,看是否一致。 ```go func main() { var f float32 = 139.8125 bits := math.Float32bits(f) fmt.Printf("%b\n", bits) } // 1000011000010111101000000000000 ``` - 字面值与格式化输出 - Go 浮点类型字面值大体可分为两类,一类是直白地用十进制表示的浮点值形式。这一类,我们通过字面值就可直接确定它的浮点值,比如: ### 3.4 基本数据类型:为什么Go要原生支持字符串类型? - 原生支持字符串有什么好处? - 定义的非原生字符串在使用过程中会有很多问题,比如: - 不是原生类型,编译器不会对它进行类型校验,导致类型安全性差; - 字符串操作时要时刻考虑结尾的’\0’,防止缓冲区溢出; - 以字符数组形式定义的“字符串”,它的值是可变的,在并发场景中需要考虑同步问题; - 获取一个字符串的长度代价较大,通常是 O(n) 时间复杂度; - C 语言没有内置对非 ASCII 字符(如中文字符)的支持。 - Go 原生支持 string 的做法是对前辈语言的改进 - 第一点:string 类型的数据是不可变的,提高了字符串的并发安全性和存储利用率。 ```go var s string = "hello" s[0] = 'k' // 错误:字符串的内容是不可改变的 s = "gopher" // ok ``` - 是我们不用再 担心字符串的并发安全问题。这样,Go 字符串可以被多个 Goroutine 共享,开发者不用因为担心并发安全问题,使用会带来 一定开销的同步机制。 - 另外,也由于字符串的不可变性,针对同一个字符串值,无论它在程序的几个位置被使 用,Go 编译器只需要为它分配一块存储就好了,大大提高了存储利用率。 - 第二点:没有结尾’\0’,而且获取长度的时间复杂度是常数时间,消除了获取字符串长 度的开销。 - Go 语言修正了这个缺陷,Go 字符串中没有结尾’\0’,获取字符串长度更不需要结 尾’\0’作为结束标志。并且,Go 获取字符串长度是一个常数级时间复杂度,无论字符串 中字符个数有多少,我们都可以快速得到字符串的长度值。 - 第三点:原生支持“所见即所得”的原始字符串,大大降低构造多行字符串时的心智负担。 - 通过一对反引号原生支持构造“所见即所得”的原始字符串(Raw String)。而且,Go 语言原始字符串中的任意转义字符都不会起到转义的作用。比如下面 这段代码: ```go var s string = ` ,_---~~~~~----._ _,,_,*^____ _____*g*\"*,--, / __/ /' ^. / \ ^@q f [ @f | @)) | | @)) l 0 _/ \/ \~____ / __ \_____/ \ | _l__l_ I } [______] I ] | | | | ] ~ ~ | | | | | ` fmt.Println(s) ``` - 这个 Gopher 图案由诸多 ASCII 字符组成,其中就包括了转义字符。这个时候,如果我们通过 Println 函数输出这个字符串,得到的图案和上面的图案并无二致。 - 第四点:对非 ASCII 字符提供原生支持,消除了源码在不同环境下显示乱码的可能。 - Go 语言源文件默认采用的是 Unicode 字符集,Unicode 字符集是目前市面上最流行的字 符集,它囊括了几乎所有主流非 ASCII 字符(包括中文字符)。Go 字符串中的每个字符都 是一个 Unicode 字符,并且这些 Unicode 字符是以 UTF-8 编码格式存储在内存当中的。 - 知道了 Go 原生支持字符串类型带来的诸多变化和好处后,我们接下来就要深入到 Go 字 符串的机制里看看,看看 Go 字符串是由什么组成的。 - Go 字符串的组成 - 一种是字节视角,也就是和所有 其它支持字符串的主流语言一样,Go 语言中的字符串值也是一个可空的字节序列,字节序 列中的字节个数称为该字符串的长度。一个个的字节只是孤立数据,不表意。 - 比如在下面代码中,我们输出了字符串中的每个字节,以及整个字符串的长度: ```go var s = "中国人" fmt.Printf("the length of s = %d\n", len(s)) // 9 for i := 0; i < len(s); i++ { fmt.Printf("0x%x ", s[i]) // 0xe4 0xb8 0xad 0xe5 0x9b 0xbd 0xe4 0xba 0xba } fmt.Printf("\n") ``` - 我们看到,由“中国人”构成的字符串的字节序列长度为 9。并且,仅从某一个输出的字 节来看,它是不能与字符串中的任一个字符对应起来的。 - 如果要表意,我们就需要从字符串的另外一个视角来看,也就是**字符串是由一个可空的字 符序列构成**。这个时候我们再看下面代码: ```go var s = "中国人" fmt.Println("the character count in s is", utf8.RuneCountInString(s)) // 3 for _, c := range s { fmt.Printf("0x%x ", c) // 0x4e2d 0x56fd 0x4eba } fmt.Printf("\n") ``` - 0x4e2d、0x56fd 和 0x4eba 就应该是某种 Unicode 字符的表示了 - 那么,什么是 Unicode 码点呢? - Unicode 字符集中的每个字符,都被分配了统一且唯一的字符编号。所谓 Unicode 码 点,就是指将 Unicode 字符集中的所有字符“排成一队”, **字符在这个“队伍”中的位 次**,就是它在 Unicode 字符集中的码点。也就说,一个码点唯一对应一个字符。“码 点”的概念和我们马上要讲的 rune 类型有很大关系。 - rune 类型与字符字面值 - Go 使用 rune 这个类型来表示一个 Unicode 码点。**rune 本质上是 int32 类型的别名类 型**,它与 int32 类型是完全等价的,在 Go 源码中我们可以看到它的定义是这样的: ```go // $GOROOT/src/builtin.go type rune = int32 ``` - 由于一个 Unicode 码点唯一对应一个 Unicode 字符。所以我们可以说,**一个 rune 实例 就是一个 Unicode 字符,一个 Go 字符串也可以被视为 rune 实例的集合。我们可以通过 字符字面值来初始化一个 rune 变量。** - 在 Go 中,字符字面值有多种表示法,最常见的是通过单引号括起的字符字面值,比如: ```go 'a' // ASCII字符 '中' // Unicode字符集中的中文字符 '\n' // 换行字符 '\'' // 单引号字符 ``` - 我们还可以使用 **Unicode 专用的转义字符\u 或\U 作为前缀**,来表示一个 Unicode 字 符,比如: ```go '\u4e2d' // 字符:中 '\U00004e2d' // 字符:中 '\u0027' // 单引号字符 ``` - 这里,我们要注意,\u 后面接两个十六进制数。如果是用两个十六进制数无法表示的 Unicode 字符,我们可以使用\U,\U 后面可以接四个十六进制数来表示一个 Unicode 字 符。 - 而且,由于表示码点的 rune 本质上就是一个整型数,所以我们还可用整型值来直接作为字 符字面值给 rune 变量赋值,比如下面代码: ```go '\x27' // 使用十六进制表示的单引号字符 '\047' // 使用八进制表示的单引号字符 ``` - 字符串字面值 - 只不过字符 串是多个字符,所以我们需要把表示单个字符的单引号,换为表示多个字符组成的字符串 的双引号就可以了。我们可以看下面这些例子: ```go "abc\n" "中国人" "\u4e2d\u56fd\u4eba" // 中国人 "\U00004e2d\U000056fd\U00004eba" // 中国人 "中\u56fd\u4eba" // 中国人,不同字符字面值形式混合在一起 "\xe4\xb8\xad\xe5\x9b\xbd\xe4\xba\xba" // 十六进制表示的字符串字面值:中国人 ``` - 我们看到,将单个 Unicode 字符字面值一个接一个地连在一起,并用双引号包裹起来就构 成了字符串字面值。甚至,我们也可以像倒数第二行那样,将不同字符字面值形式混合在 一起,构成一个字符串字面值。 - 不过,这里你可能发现了一个问题,上面示例代码的最后一行使用的是十六进制形式的字 符串字面值,但每个字节的值与前面几行的码点值完全对应不上啊,这是为什么呢? - 这个字节序列实际上是“中国人”这个 Unicode 字符串的 UTF-8 编码值。 - UTF-8 编码方案 - UTF-8 编码解决的是 Unicode 码点值在计算机中如何存储和表示(位模式)的问题。那你 可能会说,码点唯一确定一个 Unicode 字符,直接用码点值不行么? - 这的确是可以的,并且 UTF-32 编码标准就是采用的这个方案。UTF-32 编码方案固定使用 4 个字节表示每个 Unicode 字符码点,这带来的好处就是编解码简单,但缺点也很明显, 主要有下面几点: - 这种编码方案使用 4 个字节存储和传输一个整型数的时候,需要考虑不同平台的字节序 问题 ; - 由于采用 4 字节的固定长度编码,与采用 1 字节编码的 ASCII 字符集无法兼容; - 所有 Unicode 字符码点都用 4 字节编码,显然空间利用率很差。 - 针对这些问题,Go 语言之父 Rob Pike 发明了 UTF-8 编码方案。和 UTF-32 方案不同, UTF-8 方案使用变长度字节,对 Unicode 字符的码点进行编码。 编码采用的字节数量与 Unicode 字符在码点表中的序号有关:表示序号(码点)小的字符使用的字节数量少,表 示序号(码点)大的字符使用的字节数多。 - 那么现在我们就使用 Go 在标准库中提供的 UTF-8 包,对 Unicode 字符(rune)进行编 解码试试看: ```go // rune -> []byte func encodeRune() { var r rune = 0x4E2D fmt.Printf("the unicode charactor is %c\n", r) // 中 buf := make([]byte, 3) _ = utf8.EncodeRune(buf, r) // 对rune进行utf-8编码 fmt.Printf("utf-8 representation is 0x%X\n", buf) // 0xE4B8AD } // []byte -> rune func decodeRune() { var buf = []byte{0xE4, 0xB8, 0xAD} r, _ := utf8.DecodeRune(buf) // 对buf进行utf-8解码 fmt.Printf("the unicode charactor after decoding [0xE4, 0xB8, 0xAD] is %s\ } ``` - Go 字符串类型的内部表示 - 其实,我们前面提到的 Go 字符串类型的这些优秀的性质,Go 字符串在编译器和运行时中的内部表示是分不开的。Go 字符串类型的内部表示究竟是什么样的呢?在标准库的 reflect 包中,我们找到了答案,你可以看看下面代码: ```go // $GOROOT/src/reflect/value.go // StringHeader是一个string的运行时表示 type StringHeader struct { Data uintptr Len int } ``` - 我们可以看到,**string 类型其实是一个“描述符”,它本身并不真正存储字符串数据,而仅是由一个指向底层存储的指针和字符串的长度字段组成的** ### 3.5 ## 并发 ## 实战