Go in action-笔记

一、介绍

  • Go 语言是现代的、快速的,带有一个强大的标准库
  • Go 语言内置对并发的支持。
  • Go 语言使用接口作为代码复用的基础模块。

开发速度

编译 Go 程序时,编译器只会关注那些直接被引用的库,而不是像 Java、C 和 C++ 那样,要遍历依赖链中所有依赖的库。

因为没有从编译代码到执行代码的中间过程,用动态语言编写应用程序可以快速看到输出。代价是,动态语言不提供静态语言提供的类型安全特性,不得不经常用大量的测试套件来避免在运行的时候出现类型错误这类 bug。

并发

现代计算机都拥有多个核,但是大部分编程语言都没有有效的工具让程序可以轻易利用这些资源。这些语言需要 写大量的线程同步代码来利用多个核,很容易导致错误。

Go 语言对并发的支持是这门语言最重要的特性之一。goroutine 很像线程,但是它占用的内存远少于线程,使用它需要的代码更少。通道(channel)是一种内置的数据结构,可以让用户在不同的 goroutine 之间同步发送具有类型的消息。这让编程模型更倾向于在 goroutine之间发送消息,而不是让多个 goroutine 争夺同一个数据的使用权。

goroutine

goroutine 是可以与其他 goroutine 并行执行的函数,同时也会与主程序(程序的入口)并行执行。在其他编程语言中,需要用线程来完成同样的事情,而在 Go 语言中会使用同一个线程来执行多个 goroutine

例如,用户在写一个 Web 服务器,希望同时处理不同的 Web 请求,如果使用 C 或者 Java,不得不写大量的额外代码来使用线程。在 Go 语言中,net/http 库直接使用了内置的 goroutine。每个接收到的请求都自动在其自己的 goroutine 里处理。goroutine 使用的内存比线程更少,Go 语言运行时会自动在配置的一组逻辑处理器上调度执行 goroutine。每个逻辑处理器绑定到一个操作系统线程上(下图)。这让用户的应用程序执行效率更高,而开发工作量显著减少。

image-20200611094338474

如果想在执行一段代码的同时,并行去做另外一些事情,goroutine 是很好的选择。下面是一个简单的例子:

func log(msg string) {
	// ...这里是一些记录日志的代码
}
// 代码里有些地方检测到了错误
go log("发生了可怕的事情")

关键字 go 是唯一需要去编写的代码,调度 log 函数作为独立的 goroutine 去运行,以便与其他 goroutine 并行执行。这意味着应用程序的其余部分会与记录日志并行执行,通常这种并行能让最终用户觉得性能更好。goroutine 占用的资源更少,所以常常能启动成千上万个 goroutine。

通道

通道是一种数据结构,可以让 goroutine 之间进行安全的数据通信。通道可以帮用户避免其他语言里常见的共享内存访问的问题。

并发的最难的部分就是要确保其他并发运行的进程、线程或 goroutine 不会意外修改用户的数据。当不同的线程在没有同步保护的情况下修改同一个数据时,总会发生灾难。在其他语言中,如果使用全局变量或者共享内存,必须使用复杂的锁规则来防止对同一个变量的不同步修改。

为了解决这个问题,通道提供了一种新模式,从而保证并发修改时的数据安全。通道这一模式保证同一时刻只会有一个 goroutine 修改数据。通道用于在几个运行的 goroutine 之间发送数据。

通道并不提供跨 goroutine 的数据访问保护机制。如果通过通道传输数据的一份副本,那么每个 goroutine 都持有一份副本,各自对自己的副本做修改是安全的。当传输的是指向数据的指针时,如果读和写是由不同的 goroutine 完成的,每个 goroutine 依旧需要额外的同步动作。

类型系统

组合模式

Go 开发者使用组合(composition)设计模式,只需简单地将一个类型嵌入到另一个类型,就能复用所有的功能。其他语言也能使用组合,但是不得不和继承绑在一起使用,结果使整个用法非常复杂。在 Go 语言中,一个类型由其他更微小的类型组合而成,避免了传统的基于继承的模型。

接口

Go 语言还具有独特的接口实现机制,允许用户对行为进行建模,而不是对类型进行建模。在 Go 语言中,不需要声明某个类型实现了某个接口编译器会判断一个类型的实例是否符合正在使用的接口。在 Go 语言中,如果一个类型实现了一个接口的所有方法,那么这个类型的实例就可以存储在这个接口类型的实例中,不需要额外声明。Go 标准库里的很多接口都非常简单,只开放几个函数。

类型

Go 语言不仅有类似 int 和 string 这样的内置类型,还支持用户定义的类型。在 Go 语言中,用户定义的类型通常包含一组带类型的字段,用于存储数据。 Go 语言的用户定义的类型看起来和 C 语言的结构很像,用起来也很相似。不过 Go 语言的类型可以声明操作该类型数据的方法

内存管理

Go 语言拥有现代化的垃圾回收机制。在其他系统语言(如 C 或者 C++)中,使用内存前要先分配这段内存,而且使用完毕后要将其释放掉。哪怕只做错了一件事,都可能导致程序崩溃或者内存泄漏。可惜,追踪内存是否还被使用本身就是十分艰难的事情,而要想支持多线程和高并发,更是让这件事难上加难。虽然 Go 语言的垃圾回收会有一些额外的开销,但是编程时,能显著降低开发难度。 Go 语言把无趣的内存管理交给专业的编译器去做,而让程序员专注于更有趣的事情。

二、快速开始

  • 每个代码文件都属于一个包,而包名应该与代码文件所在的文件夹同名。
  • Go 语言提供了多种声明和初始化变量的方式。如果变量的值没有显式初始化,编译器会将变量初始化为零值。
  • 使用指针可以在函数间或者 goroutine 间共享数据。
  • 通过启动 goroutine 和使用通道完成并发同步
  • Go 语言提供了内置函数来支持 Go 语言内部的数据结构。
  • 标准库包含很多包,能做很多很有用的事情。
  • 使用 Go 接口可以编写通用的代码和框架。

2.1 main 包

main 函数保存在名为 main 的包里。如果 main 函数不在 main 包里,构建工具就不会生成可执行的文件。

Go 语言的每个代码文件都属于一个包, main.go 也不例外。一个包定义一组编译过的代码,包的名字类似命名空间,可以用来间接访问包内声明的标识符。这个特性可以把不同包中定义的同名标识符区别开。

关键字 import 就是导入一段代码,让用户可以访问其中的标识符,如类型、函数、常量和接口。

所有处于同一个文件夹里的代码文件,必须使用同一个包名。按照惯例,包和文件夹同名。就像之前说的,一个包定义一组编译后的代码,每段代码都描述包的一部分。

导包的时候可以在导入的路径前面加一个下划线,如:

import (
    "os"
    _ "github.com/goinaction/code/chapter2/sample/matchers"
)

这个技术是为了让 Go 语言对包做初始化操作,但是并不使用包里的标识符。为了让程序的可读性更强, Go 编译器不允许声明导入某个包却不使用。下划线让编译器接受这类导入,并且调用对应包内的所有代码文件里定义的 init 函数

(在 main.go 中导入的)程序中每个代码文件里的 init 函数都会在 main 函数执行前调用。

2.2 search 包

与第三方包不同,从标准库中导入代码时,只需要给出要导入的包名。编译器查找包的时候,总是会到 GOROOT 和 GOPATH 环境变量引用的位置去查找。

变量没有定义在任何函数作用域内,所以会被当成包级变量

在 Go 语言里,标识符要么从包里公开,要么不从包里公开。当代码导入了一个包时,程序可以直接访问这个包中任意一个公开的标识符。这些标识符以大写字母开头。

以小写字母开头的标识符是不公开的,不能被其他包中的代码直接访问。但是,其他包可以间接访问不公开的标识符。例如,一个函数可以返回一个未公开类型的值,那么这个函数的任何调用者,哪怕调用者不是在这个包里声明的,都可以访问这个值。有疑问

map 是 Go 语言里的一个引用类型,需要使用 make 来构造。如果不先构造 map 并将构造后的值赋值给变量,会在试图使用这个 map 变量时收到出错信息。这是因为 map 变量默认的零值是 nil。

在 Go 语言中,所有变量都被初始化为其零值。对于数值类型,零值是 0;对于字符串类型,零值是空字符串;对于布尔类型,零值是 false;对于指针,零值是 nil。对于引用类型来说,所引用的底层数据结构会被初始化为对应的零值。但是被声明为其零值的引用类型的变量,会返回 nil 作为其值。有疑问

切片是一种实现了一个动态数组的引用类型。在 Go 语言里可以用切片来操作一组数据。

简化变量声明运算符 := 用于声明一个变量,同时给这个变量赋予初始值。简化变量声明运算符只是一 种简化记法,让代码可读性更高。这个运算符声明的变量和其他使用关键字 var 声明的变量没有任何区别。

根据经验,如果需要声明初始值为零值的变量,应该使用 var 关键字声明变量;如果提供确切的非零值初始化变量或者使用函数返回值创建变量,应该使用简化变量声明运算符。

在 Go 语言中,通道(channel)和映射(map)与切片(slice)一样,也是引用类型,不过通道本身实现的是一组带类型的值,这组值用于在 goroutine 之间传递数据。通道内置同步机制,从而保证通信安全。

在 Go 语言中,如果 main 函数返回,整个程序也就终止了。 Go 程序终止时,还会关闭所有之前启动且还在运行的 goroutine。写并发程序的时候,最佳做法是,在 main 函数返回前,清理并终止所有之前启动的 goroutine。编写启动和终止时的状态都很清晰的程序,有助减少 bug,防止资源异常。

查找 map 里的键时,有两个选择:要么赋值给一个变量,要么为了精确查找,赋值给两个变量。赋值给两个变量时第一个值和赋值给一个变量时的值一样,是 map 查找的结果值。如果指定了第二个值,就会返回一个布尔标志,来表示查找的键是否存在于 map 里。如果这个键不存在, map 会返回其值类型的零值作为返回值,如果这个键存在, map 会返回键所对应值的副本。

一个 goroutine 是一个独立于其他函数运行的函数。使用关键字 go 启动一个 goroutine,并对这个 goroutine 做并发调度。

在 Go 语言中,所有的变量都以值的方式传递。因为指针变量的值是所指向的内存地址,在函数间传递指针变量,是在传递这个地址值,所以依旧被看作以值的方式在传递。

Go 语言支持闭包,有了闭包,函数可以直接访问到那些没有作为参数传入的变量。匿名函数并没有拿到这些变量的副本,而是直接访问外层函数作用域中声明的这些变量本身

10 // Feed 包含我们需要处理的数据源的信息
11 type Feed struct {
12 Name string `json:"site"`
13 URI string `json:"link"`
14 Type string `json:"type"`
15 }

在第 11 行到第 15 行声明了一个名叫 Feed 的结构类型。这个类型会对外暴露。这个类型里面声明了 3 个字段,每个字段的类型都是字符串,对应于数据文件中各个文档的不同字段。每个字段的声明最后 ` 引号里的部分被称作标记(tag)。这个标记里描述了 JSON 解码的元数据,用于创建 Feed 类型值的切片。每个标记将结构类型里字段对应到 JSON 文档里指定名字的字段。

通过返回 error 类型值来表示函数是否调用成功。这种用法在标准库里也很常见。

func RetrieveFeeds() ([]*Feed, error) {
    // ...
    // 当函数返回时
	// 关闭文件
	defer file.Close()
    // ...
}

关键字 defer 会安排随后的函数调用在函数返回时才执行。在使用完文件后,需要主动关闭文件。使用关键字 defer 来安排调用 Close 方法,可以保证这个函数一定会被调用。哪怕函数意外崩溃终止,也能保证关键字 defer 安排调用的函数会被执行。关键字 defer 可以缩短打开文件和关闭文件之间间隔的代码行数,有助提高代码可读性,减少错误。

interface 关键字声明了一个接口,这个接口声明了结构类型或者具名类型需要实现的行为。一个接口的行为最终由在这个接口类型中声明的方法决定。

命名接口的时候,也需要遵守 Go 语言的命名惯例。如果接口类型只包含一个方法,那么这个类型的名字以 er 结尾。我们的例子里就是这么做的,所以这个接口的名字叫作 Matcher。如果接口类型内部声明了多个方法,其名字需要与其行为关联。

空结构在创建实例时,不会分配任何内存。这种结构很适合创建没有任何状态的类型。

func (m defaultMatcher) Search () {}

Search 方法的声明也声明了 defaultMatcher 类型的值的接收者。如果声明函数的时候带有接收者,则意味着声明了一个方法。这个方法会和指定的接收者的类型绑在一起。在例子里, Search 方法与 defaultMatcher 类型的值绑在一起。这意味着可以使用 defaultMatcher 类型的值或者指向这个类型值的指针来调用 Search 方法。无论是使用接收者类型的值来调用这个方,还是使用接收者类型值的指针来调用这个方法,编译器都会正确地引用或者解引用对应的值,作为接收者传递给 Search 方法。

如:

// 方法声明为使用 defaultMatcher 类型的值作为接收者
func (m defaultMatcher) Search(feed *Feed, searchTerm string)
// 声明一个指向 defaultMatcher 类型值的指针
dm := new(defaultMatch)
// 编译器会解开 dm 指针的引用,使用对应的值调用方法
dm.Search(feed, "test")
// 方法声明为使用指向 defaultMatcher 类型值的指针作为接收者
func (m *defaultMatcher) Search(feed *Feed, searchTerm string)
// 声明一个 defaultMatcher 类型的值
var dm defaultMatch
// 编译器会自动生成指针引用 dm 值,使用指针调用方法
dm.Search(feed, "test")

因为大部分方法在被调用后都需要维护接收者的值的状态,所以一个最佳实践是将方法的接收者声明为指针。

三、打包和工具链

  • 在 Go 语言中包是组织代码的基本单位。
  • 环境变量 GOPATH 决定了 Go 源代码在磁盘上被保存、编译和安装的位置。
  • 可以为每个工程设置不同的 GOPATH,以保持源代码和依赖的隔离。
  • go 工具是在命令行上工作的最好工具。
  • 开发人员可以使用 go get 来获取别人的包并将其安装到自己的 GOPATH 指定的目录。
  • 想要为别人创建包很简单,只要把源代码放到公用代码库,并遵守一些简单规则就可以了。
  • Go 语言在设计时将分享代码作为语言的核心特性和驱动力。
  • 推荐使用依赖管理工具来管理依赖。
  • 有很多社区开发的依赖管理工具,如 godep、vender 和 gb。

3.1 包

在 Go 语言里,包是个非常重要的概念。其设计理念是使用包来封装不同语义单元的功能。这样做,能够更好地复用代码,并对每个包内的数据的使用有更好的控制。

所有 Go 语言的程序都会组织成若干组文件,每组文件被称为一个包。

所有的 .go 文件,除了空行和注释,都应该在第一行声明自己所属的包。每个包都在一个单独的目录里。不能把多个包放到同一个目录中,也不能把同一个包的文件分拆到多个不同目录中。这意味着,同一个目录下的所有 .go 文件必须声明同一个包名

包名惯例

给包命名的惯例是使用包所在目录的名字。这让用户在导入包的时候,就能清晰地知道包名。给包及其目录命名时,应该使用简洁、清晰且全小写的名字。

记住,并不需要所有包的名字都与别的包不同,因为导入包时是使用全路径的,所以可以区分同名的不同包。一般情况下,包被导入后会使用你的包名作为默认的名字,不过这个导入后的名字可以修改。这个特性在需要导入不同目录的同名包时很有用。

main 包

在 Go 语言里,命名为 main 的包具有特殊的含义。 Go 语言的编译程序会试图把这种名字的包编译为二进制可执行文件。所有用 Go 语言编译的可执行程序都必须有一个名叫 main 的包。

当编译器发现某个包的名字为 main 时,它一定也会发现名为 main() 的函数,否则不会创建可执行文件。 main()函数是程序的入口,所以如果没有这个函数,程序就没有办法开始执行。程序编译时,会使用声明 main 包的代码所在的目录的目录名作为二进制可执行文件的文件名

命令和包

Go 文档里经常使用命令(command)这个词来指代可执行程序,如命令行应用程序。这会让新手在阅读文档时产生困惑。记住,在 Go 语言里,命令是指任何可执行程序。作为对比,包更常用来指语义上可导入的功能单元。

获取包的文档

例如:可以访问 http://golang.org/pkg/fmt/ 或者在终端输入 go doc fmt 来了解更多关于 fmt 包的细节。

执行命令 go build,会生成一个二进制文件。在 UNIX、Linux 和 Mac OS X 系统上,这个文件会命名为 hello,而在 Windows 系统上会命名为 hello.exe。

3.2 导入

如果需要导入多个包,习惯上是将 import 语句包装在一个导入块。例如:

import (
    "fmt"
    "strings"
)

) 编译器会使用 Go 环境变量设置的路径,通过引入的相对路径来查找磁盘上的包。标准库中的包会在安装 Go 的位置找到。 Go 开发者创建的包会在 GOPATH 环境变量指定的目录里查找。GOPATH 指定的这些目录就是开发者的个人工作空间。

举个例子。如果 Go 安装在 /usr/local/go,并且环境变量 GOPATH 设置为 /home/myproject:/home/ mylibraries,编译器就会按照下面的顺序查找 net/http 包:注意顺序。

/usr/local/go/src/pkg/net/http
/home/myproject/src/net/http
/home/mylibraries/src/net/http

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

如果编译器查遍 GOPATH 也没有找到要导入的包,那么在试图对程序执行 run 或者 build 的时候就会出错。可以通过 go get 命令来修正这种错误。

3.2.1 远程导入

目前的大势所趋是,使用分布式版本控制系统(Distributed Version Control Systems, DVCS)来分享代码,如 GitHub、 Launchpad 还有 Bitbucket。 Go 语言的工具链本身就支持从这些网站及类似网站获取源代码。 Go 工具链会使用导入路径确定需要获取的代码在网络的什么地方。例如:

import "github.com/spf13/viper"

用导入路径编译程序时, go build 命令会使用 GOPATH 的设置,在磁盘上搜索这个包。事实上,这个导入路径代表一个 URL,指向 GitHub 上的代码库。如果路径包含 URL,可以使用 Go 工具链从DVCS 获取包,并把包的源代码保存在 GOPATH 指向的路径里与 URL 匹配的目录里。这个获取过程使用 go get 命令完成。 go get 将获取任意指定的 URL 的包,或者一个已经导入的包所依赖的其他包。由于 go get 的这种递归特性,这个命令会扫描某个包的源码树,获取能找到的所有依赖包。

3.2.2 命名导入

如果要导入的多个包具有相同的名字,会发生什么?例如,既需要 network/convert 包来转换从网络读取的数据,又需要 file/convert 包来转换从文本文件读取的数据时,就会同时导入两个名叫 convert 的包。这种情况下,重名的包可以通过命名导入来导入。命名导入是指,在 import 语句给出的包路径的左侧定义一个名字,将导入的包命名为新名字。

例如,若用户已经使用了标准库里的 fmt 包,现在要导入自己项目里名叫 fmt 的包,就可以如下所示的命名导入方式,在导入时重新命名自己的包:

package main

import (
    "fmt"
    myfmt "mylib/fmt"
)

func main() {
    fmt.Println("Standard Library")
    myfmt.Println("mylib/fmt")
}

当导入了一个不在代码里使用的包时,Go 编译器会编译失败,并输出一个错误。 Go 开发团队认为,这个特性可以防止导入了未被使用的包,避免代码变得臃肿。虽然这个特性会让人觉得很烦,但 Go 开发团队仍然花了很大的力气说服自己,决定加入这个特性,用来避免其他编程语言里常常遇到的一些问题,如得到一个塞满未使用库的超大可执行文件。很多语言在这种情况会使用警告做提示,而 Go 开发团队认为,与其让编译器告警,不如直接失败更有意义。

每个编译过大型 C 程序的人都知道,在浩如烟海的编译器警告里找到一条有用的信息是多么困难的一件事。这种情况下编译失败会更加明确。有时,用户可能需要导入一个包,但是不需要引用这个包的标识符。在这种情况,可以使用空白标识符 _ 来重命名这个导入。

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

3.3 init 函数

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

以数据库驱动为例, database 下的驱动在启动时执行 init 函数会将自身注册到 sql 包里,因为 sql 包在编译时并不知道这些驱动的存在,等启动之后 sql 才能调用这些驱动。

3.4 Go 的工具

build 和 clean 命令会执行编译和清理的工作。当用户将代码签入源码库里的时候,开发人员可能并不想签入编译生成的文件。可以用 clean 命令解决这个问题调用 clean 后会删除编译生成的可执行文件。

大部分 Go 工具的命令都会接受一个包名作为参数。build 命令可以简写。在不包含文件名时,go 工具会默认使用当前目录来编译。

go build

因为构建包是很常用的动作,所以也可以直接指定包:

go build github.com/goinaction/code/chapter3/wordcount

也可以在指定包的时候使用通配符。3 个点表示匹配所有的字符串。

例如,下面的命令会编译 chapter3 目录下的所有包:

go build github.com/goinaction/code/chapter3/...

除了指定包,大部分 Go 命令使用短路径作为参数。

例如,下面两条命令的效果相同:

go build wordcount.go
go build .

要执行程序,需要首先编译,然后执行编译创建的 wordcount 或者 wordcount.exe 程序。不过这里有一个命令可以在一次调用中完成这两个操作:

go run wordcount.go

go run 命令会先构建 wordcount.go 里包含的程序,然后执行构建后的程序。这样可以节省好多录入工作量。

3.4.1 go vet

这个命令不会帮开发人员写代码,但如果开发人员已经写了一些代码,vet 命令会帮开发人员检测代码的常见错误:

  • Printf 类函数调用时,类型匹配错误的参数。
  • 定义常用的方法时,方法签名的错误。
  • 错误的结构标签。
  • 没有指定字段名的结构字面量。

go vet 工具不能让开发者避免严重的逻辑错误,或者避免编写充满小错的代码。不过这个工具可以很好地捕获一部分常见错误。每次对代码先执行 govet 再将其签入源代码库是一个很好的习惯。

3.4.2 go fmt

fmt 是 Go 语言社区很喜欢的一个命令。fmt 工具会将开发人员的代码布局成和 Go 源代码类似的风格,不用再为了大括号是不是要放到行尾,或者用 tab(制表符)还是空格来做缩进而争论不休。使用 go fmt 后面跟文件名或者包名,就可以调用这个代码格式化工具。fmt 命令会自动格式化开发人员指定的源代码文件并保存。下面是一个代码执行 go fmt 前和执行 go fmt 后几行代码的对比:

if err != nil { return err }

在对这段代码执行 go fmt 后,会得到:

if err != nil {
	return err
}

很多 Go 开发人员会配置他们的开发环境,在保存文件或者提交到代码库前执行 go fmt。

3.4.3 go doc

Go 语言有两种方法为开发者生成文档。如果开发人员使用命令行提示符工作,可以在终端上直接使用 go doc 命令来打印文档。无需离开终端,即可快速浏览命令或者包的帮助。不过,如果开发人员认为一个浏览器界面会更有效率,可以使用 godoc 程序来启动一个 Web 服务器,通过点击的方式来查看 Go 语言的包的文档。Web 服务器 godoc 能让开发人员以网页的方式浏览自己的系统里的所有 Go 语言源代码的文档。

1. 从命令行获取文档 对那种总会打开一个终端和一个文本编辑器(或者在终端内打开文本编辑器)的开发人员来说,go doc 是很好的选择。假设要用 Go 语言第一次开发读取 UNIX tar 文件的应用程序,想要看看 archive/tar 包的相关文档,就可以输入:

go doc tar

2. 浏览文档 (未找到 godoc) Go 语言的文档也提供了浏览器版本。有时候,通过跳转到文档,查阅相关的细节,能更容易理解整个包或者某个函数。在这种情况下,会想使用 godoc 作为 Web 服务器。如果想通过 Web浏览器查看可以点击跳转的文档,下面就是得到这种文档的好方式。开发人员启动自己的文档服务器,只需要在终端会话中输入如下命令:

godoc -http=:6060

这个命令通知 godoc 在端口 6060 启动 Web 服务器。如果浏览器已经打开,导航到http://localhost:6060 可以看到一个页面,包含所有 Go 标准库和你的 GOPATH 下的 Go 源代码的文档。

Go 官网就是通过一个略微修改过的 godoc 来提供文档服务的。要进入某个特定包的文档,只需要点击页面顶端的Packages。

Go 文档工具最棒的地方在于,它也支持开发人员自己写的代码。如果开发人员遵从一个简单的规则来写代码,这些代码就会自动包含在 godoc 生成的文档里。为了在 godoc 生成的文档里包含自己的代码文档,开发人员需要用一些规则来写代码和注释。

四、数组、切片和映射

  • 数组是构造切片和映射的基石。
  • Go 语言里切片经常用来处理数据的集合,映射用来处理具有键值对结构的数据。
  • 内置函数 make 可以创建切片和映射,并指定原始的长度和容量。也可以直接使用切片和映射字面量,或者使用字面量作为变量的初始值。
  • 切片有容量限制,不过可以使用内置的 append 函数扩展容量。
  • 映射的增长没有容量或者任何限制(有疑问)
  • 内置函数 len 可以用来获取切片或者映射的长度。
  • 内置函数 cap 只能用于切片
  • 通过组合,可以创建多维数组和多维切片。也可以使用切片或者其他映射作为映射的值。但是切片不能用作映射的键。
  • 将切片或者映射传递给函数成本很小,并且不会复制底层的数据结构。

Go 语言有 3 种数据结构可以让用户管理集合数据:数组、切片和映射。这 3 种数据结构是语言核心的一部分,在标准库里被广泛使用。

4.1 数组

数组是切片和映射的基础数据结构。理解了数组的工作原理,有助于理解切片和映射提供的优雅和强大的功能。

4.1.1 内部实现

在 Go 语言里,数组是一个长度固定的数据类型,用于存储一段具有相同类型的元素的连续块。数组存储的类型可以是内置类型,如整型或者字符串,也可以是某种结构类型。

数组占用的内存是连续分配的。由于内存连续,CPU 能把正在使用的数据缓存更久的时间**(有疑问)**。而且内存连续很容易计算索引,可以快速迭代数组里的所有元素。数组的类型信息可以提供每次访问一个元素时需要在内存中移动的距离。既然数组的每个元素类型相同,又是连续分配,就可以以固定速度索引数组中的任意数据,速度非常快。

4.1.2 声明和初始化

声明数组时需要指定内部存储的数据的类型,以及需要存储的元素的数量,这个数量也称为数组的长度。

声明一个数组,并设置为零值。

// 声明一个包含 5 个元素的整型数组
var array [5]int

一旦声明,数组里存储的数据类型和数组长度就都不能改变了。如果需要存储更多的元素,就需要先创建一个更长的数组,再把原来数组里的值复制到新数组里。

在 Go 语言中声明变量时,总会使用对应类型的零值来对变量进行初始化。数组也不例外。当数组初始化时,数组内每个元素都初始化为对应类型的零值。

一种快速创建数组并初始化的方式是使用数组字面量。数组字面量允许声明数组里元素的数 量同时指定每个元素的值。

// 声明一个包含 5 个元素的整型数组
// 用具体值初始化每个元素
array := [5]int{10, 20, 30, 40, 50}

如果使用…替代数组的长度,Go 语言会根据初始化时数组元素的数量来确定该数组的长度。

// 声明一个整型数组
// 用具体值初始化每个元素
// 容量由初始化值的数量决定
array := [...]int{10, 20, 30, 40, 50}

:也可以直接不写 ‘…’

如果知道数组的长度而且准备给某些值指定具体值,可以使用代码以下语法。

// 声明一个有 5 个元素的数组
// 用具体值初始化索引为 1 和 2 的元素
// 其余元素保持零值
array := [5]int{1: 10, 2: 20}

:也可以不写数组长度,此时数组的长度为索引最大值 + 1。

声明一个所有元素都是指针的数组。使用 * 运算符就可以访问元素指针所指向的值。

// 声明包含 5 个元素的指向整数指针的数组
// 用整型指针初始化索引为 0 和 1 的数组元素
array := [5]*int{0: new(int), 1: new(int)}
// 为索引为 0 和 1 的元素赋值
*array[0] = 10
*array[1] = 20

4.1.3 复制

在 Go 语言里,数组是一个值。这意味着数组可以用在赋值操作中。变量名代表整个数组,因此,同样类型的数组可以赋值给另一个数组。

// 声明第一个包含 5 个元素的字符串数组
var array1 [5]string
// 声明第二个包含 5 个元素的字符串数组
// 用颜色初始化数组
array2 := [5]string{"Red", "Blue", "Green", "Yellow", "Pink"}
// 把 array2 的值复制到 array1
array1 = array2

复制之后,两个数组的值完全一样。

数组变量的类型包括数组长度和每个元素的类型。只有这两部分都相同的数组,才是类型相同的数组,才能互相赋值。

复制数组指针,只会复制指针的值,而不会复制指针所指向的值

// 声明第一个包含 3 个元素的指向字符串的指针数组
var array1 [3]*string
// 声明第二个包含 3 个元素的指向字符串的指针数组
// 使用字符串指针初始化这个数组
array2 := [3]*string{new(string), new(string), new(string)}
// 使用颜色为每个元素赋值
*array2[0] = "Red"
*array2[1] = "Blue"
*array2[2] = "Green"
// 将 array2 复制给 array1
array1 = array2

复制之后,两个数组指向同一组字符串

4.1.4 多维数组

数组本身只有一个维度,不过可以组合多个数组创建多维数组。多维数组很容易管理具有父子关系的数据或者与坐标系相关联的数据。

// 声明一个二维整型数组,两个维度分别存储 4 个元素和 2 个元素
var array [4][2]int
// 使用数组字面量来声明并初始化一个二维整型数组
array := [4][2]int{{10, 11}, {20, 21}, {30, 31}, {40, 41}}
// 声明并初始化外层数组中索引为 1 个和 3 的元素
array := [4][2]int{1: {20, 21}, 3: {40, 41}}
// 声明并初始化外层数组和内层数组的单个元素
array := [4][2]int{1: {0: 20}, 3: {1: 41}}

只要类型一致,就可以将多维数组互相赋值。多维数组的类型包括每一维度的长度以及最终存储在元素中的数据的类型。

下面是同样类型的多维数组赋值。

// 声明两个不同的二维整型数组
var array1 [2][2]int
var array2 [2][2]int
// 为每个元素赋值
array2[0][0] = 10
array2[0][1] = 20
array2[1][0] = 30
array2[1][1] = 40
// 将 array2 的值复制给 array1
array1 = array2

因为每个数组都是一个值,所以可以独立复制某个维度

// 将 array1 的索引为 1 的维度复制到一个同类型的新数组里
var array3 [2]int = array1[1]

4.1.5 在函数间传递数组

根据内存和性能来看,在函数间传递数组是一个开销很大的操作。在函数之间传递变量时,总是以值的方式传递的。如果这个变量是一个数组,意味着整个数组,不管有多长,都会完整复制,并传递给函数。

为了考察这个操作,我们来创建一个包含 100 万个 int 类型元素的数组。在 64 位架构上, 这将需要 800 万字节,即 8 MB 的内存。

使用值传递,在函数间传递大数组。

// 声明一个需要 8 MB 的数组
var array [1e6]int

// 将数组传递给函数 foo
foo(array)

// 函数 foo 接受一个 100 万个整型值的数组
func foo(array [1e6]int) {
	...
}

:foo 函数声明的参数列表里的 1e6 必须要写。

每次函数 foo 被调用时,必须在栈上分配 8 MB 的内存。之后,整个数组的值(8 MB 的内存)被复制到刚分配的内存里。虽然 Go 语言自己会处理这个复制操作,不过还有一种更好且更有效的方法来处理这个操作。

可以只传入指向数组的指针,这样只需要复制 8 字节的数据而不是 8 MB 的内存数据到栈上。

// 分配一个需要 8 MB 的数组
var array [1e6]int

// 将数组的地址传递给函数 foo
foo(&array)

// 函数 foo 接受一个指向 100 万个整型值的数组的指针
func foo(array *[1e6]int) {
	...
}

这次函数 foo 接受一个指向 100 万个整型值的数组的指针。现在将数组的地址传入函数,只需要在栈上分配 8 字节的内存给指针就可以。

这个操作会更有效地利用内存,性能也更好。不过要意识到,因为现在传递的是指针,所以如果改变指针指向的值,会改变共享的内存。此时使用切片能更好地处理这类共享问题。

4.2 切片

切片是一种数据结构,这种数据结构便于使用和管理数据集合。切片是围绕动态数组的概念构建的,可以按需自动增长和缩小

切片的动态增长是通过内置函数 append 来实现的。这个函数可以快速且高效地增长切片。还可以通过对切片再次切片来缩小一个切片的大小。因为切片的底层内存也是在连续块中分配的,所以切片还能获得索引迭代以及为垃圾回收优化的好处。

4.2.1 内部实现

切片是一个很小的对象,对底层数组进行了抽象,并提供相关的操作方法。切片有 3 个字段,包含了 Go 语言需要操作底层数组的元数据。

这 3 个字段分别是指向底层数组的指针、切片访问的元素的个数(即长度)和切片允许增长到的元素个数(即容量)。

4.2.2 创建和初始化

Go 语言中有几种方法可以创建和初始化切片。是否能提前知道切片需要的容量通常会决定要如何创建切片。

1. make 和切片字面量

一种创建切片的方法是使用内置的 make 函数。当使用 make 时,需要传入一个参数,指定切片的长度。

// 创建一个字符串切片
// 其长度和容量都是 5 个元素
slice := make([]string, 5)

如果只指定长度,那么切片的容量和长度相等。也可以分别指定长度和容量。

// 创建一个整型切片
// 其长度为 3 个元素,容量为 5 个元素
slice := make([]int, 3, 5)

分别指定长度和容量时创建的切片,底层数组的长度是指定的容量,但是初始化后并不能访问所有的数组元素。下图描述了上述代码里声明的整型切片在初始化并存入一些值后的样子。

上述代码的切片可以访问 3 个元素,而底层数组拥有 5 个元素。剩余的 2 个元素可以在后期操作中合并到切片,可以通过切片访问这些元素。如果基于这个切片创建新的切片,新切片会和原有切片共享底层数组,也能通过后期操作来访问多余容量的元素有疑问

不允许创建容量小于长度的切片。

另一种常用的创建切片的方法是使用切片字面量。这种方法和创建数组类似,只是不需要指定[]运算符里的值。初始的长度和容量会基于初始化时提供的元素的个数确定

// 创建字符串切片
// 其长度和容量都是 5 个元素
slice := []string{"Red", "Blue", "Green", "Yellow", "Pink"}
// 创建一个整型切片
// 其长度和容量都是 3 个元素
slice := []int{10, 20, 30}

当使用切片字面量时,可以设置初始长度和容量。要做的就是在初始化时给出所需的长度和容量作为索引。下面的语法展示了如何创建长度和容量都是 100 个元素的切片。

// 创建字符串切片
// 使用空字符串初始化第 100 个元素
slice := []string{99: ""}

记住,如果在 [] 运算符里指定了一个值,那么创建的就是数组而不是切片。只有不指定值的时候,才会创建切片。

// 创建有 3 个元素的整型数组
array := [3]int{10, 20, 30}

// 创建长度和容量都是 3 的整型切片
slice := []int{10, 20, 30}

2. nil 和空切片

有时,程序可能需要声明一个值为 nil 的切片(也称 nil 切片)。只要在声明时不做任何初始化,就会创建一个 nil 切片。

// 创建 nil 整型切片
var slice []int

在 Go 语言里,nil 切片是很常见的创建切片的方法。nil 切片可以用于很多标准库和内置函数。在需要描述一个不存在的切片时,nil 切片会很好用。例如,函数要求返回一个切片但是发生异常的时候。

利用初始化,通过声明一个切片可以创建一个空切片。

// 使用 make 创建空的整型切片
slice := make([]int, 0)

// 使用切片字面量创建空的整型切片
slice := []int{}

空切片在底层数组包含 0 个元素,也没有分配任何存储空间。想表示空集合时空切片很有用,比如数据库查询返回 0 个查询结果时。

不管是使用 nil 切片还是空切片,对其调用内置函数 append、len 和 cap 的效果都是一样的。

4.2.3 使用切片

1.赋值和切片

对切片里某个索引指向的元素赋值和对数组里某个索引指向的元素赋值的方法完全一样。使用 [] 操作符就可以改变某个元素的值。

// 创建一个整型切片
// 其容量和长度都是 5 个元素
slice := []int{10, 20, 30, 40, 50}
// 改变索引为 1 的元素的值
slice[1] = 25

切片之所以被称为切片,是因为创建一个新的切片就是把底层数组切出一部分

// 创建一个整型切片
// 其长度和容量都是 5 个元素
slice := []int{10, 20, 30, 40, 50}
// 创建一个新切片
// 其长度为 2 个元素,容量为 4 个元素
newSlice := slice[1:3]

执行完上面的切片动作后,我们有了两个切片,它们共享同一段底层数组,但通过不同的切片会看到底层数组的不同部分。

第一个切片 slice 能够看到底层数组全部 5 个元素的容量,不过之后的 newSlice 就看不到。对于 newSlice ,底层数组的容量只有 4 个元素。newSlice 无法访问到它所指向的底层数组的第一个元素之前的部分。所以,对 newSlice 来说,之前的那些元素就是不存在的。

使用下面的公式,可以计算出任意切片的长度和容量。

  • 对底层数组容量是 k 的切片 slice[i : j] 来说: 长度 = j - i 容量 = k - i

例如,对 newSlice 应用这个公式。对底层数组容量是 5 的切片 slice[1:3] 来说 长度: 3 - 1 = 2 容量: 5 - 1 = 4

需要记住的是,现在两个切片共享同一个底层数组。如果一个切片修改了该底层数组的共享部分,另一个切片也能感知到。

// 创建一个整型切片
// 其长度和容量都是 5 个元素
slice := []int{10, 20, 30, 40, 50}
// 创建一个新切片
// 其长度是 2 个元素,容量是 4 个元素
newSlice := slice[1:3]
// 修改 newSlice 索引为 1 的元素
// 同时也修改了原来的 slice 的索引为 2 的元素
newSlice[1] = 35

把 35 赋值给 newSlice 的第二个元素(索引为 1 的元素)的同时也是在修改原来的 slice的第 3 个元素(索引为 2 的元素)。

切片只能访问到其长度内的元素。试图访问超出其长度的元素将会导致语言运行时异常。与切片的容量相关联的元素只能用于增长切片。在使用这部分元素前,必须将其合并到切片的长度里。

切片有额外的容量是很好,但是如果不能把这些容量合并到切片的长度里,这些容量就没有用处。好在可以用 Go 语言的内置函数 append 来做这种合并很容易。

2.切片增长

相对于数组而言,使用切片的一个好处是,可以按需增加切片的容量。Go 语言内置的 append 函数会处理增加长度时的所有操作细节。

要使用 append,需要一个被操作的切片和一个要追加的值。当 append 调用返回时,会返回一个包含修改结果的新切片。函数 append 总是会增加新切片的长度,而容量有可能会改变,也可能不会改变,这取决于被操作的切片的可用容量。

// 创建一个整型切片
// 其长度和容量都是 5 个元素
slice := []int{10, 20, 30, 40, 50}
// 创建一个新切片
// 其长度为 2 个元素,容量为 4 个元素
newSlice := slice[1:3]
// 使用原有的容量来分配一个新元素
// 将新元素赋值为 60
newSlice = append(newSlice, 60)

append 操作完成后,两个切片和底层数组的布局如图所示。

因为 newSlice 在底层数组里还有额外的容量可用,append 操作将可用的元素合并到切片的长度,并对其进行赋值。由于和原始的 slice 共享同一个底层数组,slice 中索引为 3 的元素的值也被改动了

如果切片的底层数组没有足够的可用容量,append 函数会创建一个新的底层数组,将被引用的现有的值复制到新数组里,再追加新的值。

// 创建一个整型切片
// 其长度和容量都是 4 个元素
slice := []int{10, 20, 30, 40}
// 向切片追加一个新元素
// 将新元素赋值为 50
newSlice := append(slice, 50)

当这个 append 操作完成后,newSlice 拥有一个全新的底层数组,这个数组的容量是原来的两倍。

:注意新的长度和容量并不是原来的两倍。

函数 append 会智能地处理底层数组的容量增长。在切片的容量小于 1000 个元素时,总是会成倍地增加容量。一旦元素个数超过 1000,容量的增长因子会设为 1.25,也就是会每次增加 25%的容量。随着语言的演化,这种增长算法可能会有所改变。

3. 创建切片时的 第 3 个索引

其中一些重要的:

如果在创建切片时设置切片的容量和长度一样,就可以强制让新切片的第一个 append 操作创建新的底层数组,与原有的底层数组分离。新切片与原有的底层数组分离后,可以安全地进行后续修改。

内置函数 append 是一个可变参数的函数。这意味着可以在一次调用传递多个追加的值。如果使用 …运算符,可以将一个切片的所有元素追加到另一个切片里。

// 创建两个切片,并分别用两个整数进行初始化
s1 := []int{1, 2}
s2 := []int{3, 4}
// 将两个切片追加在一起,并显示结果
fmt.Printf("%v\n", append(s1, s2...))

Output:

[1 2 3 4]

4. 迭代切片

既然切片是一个集合,可以迭代其中的元素。Go 语言有个特殊的关键字 range,它可以配合关键字 for 来迭代切片里的元素。

// 创建一个整型切片
// 其长度和容量都是 4 个元素
slice := []int{10, 20, 30, 40}
// 迭代每一个元素,并显示其值
for index, value := range slice {
	fmt.Printf("Index: %d Value: %d\n", index, value)
}

Output:

Index: 0 Value: 10
Index: 1 Value: 20
Index: 2 Value: 30
Index: 3 Value: 40

当迭代切片时,关键字 range 会返回两个值。第一个值是当前迭代到的索引位置,第二个值是该位置对应元素值的一份副本

需要强调的是,range 创建了每个元素的副本,而不是直接返回对该元素的引用。如果使用该值变量的地址作为指向每个元素的指针,就会造成错误

// 创建一个整型切片
// 其长度和容量都是 4 个元素
slice := []int{10, 20, 30, 40}
// 迭代每个元素,并显示值和地址
for index, value := range slice {
	fmt.Printf("Value: %d Value-Addr: %X ElemAddr: %X\n",
        value, &value, &slice[index])
}

Output:

Value: 10 Value-Addr: C00000A1A0 ElemAddr: C000010440
Value: 20 Value-Addr: C00000A1A0 ElemAddr: C000010448
Value: 30 Value-Addr: C00000A1A0 ElemAddr: C000010450
Value: 40 Value-Addr: C00000A1A0 ElemAddr: C000010458

因为迭代返回的变量是一个迭代过程中根据切片依次赋值的新变量,所以 value 的地址总是相同的。要想获取每个元素的地址,可以使用切片变量和索引值

如果不需要索引值,可以使用占位字符来忽略这个值。

// 创建一个整型切片
// 其长度和容量都是 4 个元素
slice := []int{10, 20, 30, 40}
// 迭代每个元素,并显示其值
for _, value := range slice {
	fmt.Printf("Value: %d\n", value)
}

Output:

Value: 10
Value: 20
Value: 30
Value: 40

关键字 range 总是会从切片头部开始迭代。如果想对迭代做更多的控制,依旧可以使用传统的 for 环。

// 创建一个整型切片
// 其长度和容量都是 4 个元素
slice := []int{10, 20, 30, 40}
// 从第三个元素开始迭代每个元素
for index := 2; index < len(slice); index++ {
	fmt.Printf("Index: %d Value: %d\n", index, slice[index])
}

Output:

Index: 2 Value: 30
Index: 3 Value: 40

有两个特殊的内置函数 len 和 cap,可以用于处理数组切片通道。对于切片,函数 len 返回切片的长度,函数 cap 返回切片的容量

4.2.4 多维切片

和数组一样,切片是一维的。不过,可以组合多个切片形成多维切片。

// 创建一个整型切片的切片
slice := [][]int{{10}, {100, 200}}

内置函数 append 也可以应用到组合后的切片上。

// 创建一个整型切片的切片
slice := [][]int{{10}, {100, 200}}
// 为第一个切片追加值为 20 的元素
slice[0] = append(slice[0], 20)

当上述代码操作完成后,会为新的整型切片分配新的底层数组,然后将切片复制到外层切片的索引为 0 的元素。

即便是这么简单的多维切片,操作时也会涉及众多布局和值。看起来在函数间像这样传递数据结构也会很复杂。不过切片本身结构很简单,可以以很小的成本在函数间传递。

4.2.5 在函数间传递切片

在函数间传递切片就是要在函数间以值的方式传递切片。由于切片的尺寸很小,在函数间复制和传递切片成本也很低。

下例创建一个大切片,并将这个切片以值的方式传递给函数 foo。函数调用之后两个切片指向同一个底层数组。

// 分配包含 100 万个整型值的切片
slice := make([]int, 1e6)

// 将 slice 传递到函数 foo
slice = foo(slice)

// 函数 foo 接收一个整型切片,并返回这个切片
func foo(slice []int) []int {
    ...
    return slice
}

在 64 位架构的机器上,一个切片需要 24 字节的内存:指针字段需要 8 字节,长度和容量字段分别需要 8 字节。由于与切片关联的数据包含在底层数组里,不属于切片本身,所以将切片复制到任意函数的时候,对底层数组大小都不会有影响。复制时只会复制切片本身,不会涉及底层数组。

在函数间传递 24 个字节的数据会非常快速、简单。这也是切片效率高的地方。不需要传递指针和处理复杂的语法,只需要复制切片,按想要的方式修改数据,然后传递回一份新的切片副本。

4.3 映射

映射是一个存储键值对的无序集合。

映射是一种数据结构,用于存储一系列无序的键值对。映射里基于键来存储值。映射功能强大的地方是,能够基于键快速检索数据。键就像索引一样,指向与该键关联的值。

4.3.1 内部实现

映射是一个集合,可以使用类似处理数组和切片的方式迭代映射中的元素。但映射是无序的集合,意味着没有办法预测键值对被返回的顺序即便使用同样的顺序保存键值对,每次迭代映射的时候顺序也可能不一样。(有疑问,事实确实如此)无序的原因是映射的实现使用了散列表

映射的散列表包含一组桶。在存储、删除或者查找键值对的时候,所有操作都要先选择一个桶。把操作映射时指定的键传给映射的散列函数,就能选中对应的桶。这个散列函数的目的是生成一个索引,这个索引最终将键值对分布到所有可用的桶里。

随着映射存储的增加,索引分布越均匀,访问键值对的速度就越快。如果你在映射里存储了 10 000 个元素,你不希望每次查找都要访问 10 000 个键值对才能找到需要的元素,你希望查找键值对的次数越少越好。对于有 10000 个元素的映射,每次查找只需要查找 8 个键值对才是一个分布得比较好的映射。映射通过合理数量的桶来平衡键值对的分布。

Go 语言的映射生成散列键的过程比图 4-25 展示的过程要稍微长一些,不过大体过程是类似的。在我们的例子里,键是字符串,代表颜色。这些字符串会转换为一个数值(散列值)。这个数值落在映射已有桶的序号范围内表示一个可以用于存储的桶的序号。之后,这个数值就被用于选择桶,用于存储或者查找指定的键值对。对 Go 语言的映射来说,生成的散列键的一部分,具体来说是低位(LOB),被用来选择桶

如果再仔细看看图 4-24,就能看出桶的内部实现。映射使用两个数据结构来存储数据。第一个数据结构是一个数组,内部存储的是用于选择桶的散列键的高八位值。这个数组用于区分每个键值对要存在哪个桶里。第二个数据结构是一个字节数组,用于存储键值对。该字节数组先依次存储了这个桶里所有的键,之后依次存储了这个桶里所有的值。实现这种键值对的存储方式目的在于减少每个桶所需的内存。有疑问

4.3.2 创建和初始化

Go 语言中有很多种方法可以创建并初始化映射,可以使用内置的 make 函数,也可以使用映射字面量

// 创建一个映射,键的类型是 string,值的类型是 int
dict := make(map[string]int)

// 创建一个映射,键和值的类型都是 string
// 使用两个键值对初始化映射
dict := map[string]string{"Red": "#da1337", "Orange": "#e95a22"}

创建映射时更常用的方法是使用映射字面量。映射的初始长度会根据初始化时指定的键值对的数量来确定。

映射的键可以是任何值。这个值的类型可以是内置的类型,也可以是结构类型,只要这个值可以使用 == 运算符做比较

切片、函数以及包含切片的结构类型这些类型由于具有引用语义不能作为映射的键,使用这些类型会造成编译错误。有疑问

dict := map[[]string]int{}

Output:

Compiler Exception:
invalid map key type []string

可以使用切片作为映射的值。这个在使用一个映射键对应一组数据时,会非常有用。

// 创建一个映射,使用字符串切片作为值
dict := map[int][]string{}

4.3.3 使用映射

键值对赋值给映射,是通过指定适当类型的键并给这个键赋一个值来完成的。

// 创建一个空映射,用来存储颜色以及颜色对应的十六进制代码
colors := map[string]string{}

// 将 Red 的代码加入到映射
colors["Red"] = "#da1337"

可以通过声明一个未初始化的映射来创建一个值为 nil 的映射(称为 nil 映射 )。nil 映射不能用于存储键值对,否则,会产生一个语言运行时错误。

// 通过声明映射创建一个 nil 映射
var colors map[string]string

// 将 Red 的代码加入到映射
colors["Red"] = "#da1337"

Output:

Runtime Error:
panic: runtime error: assignment to entry in nil map

测试映射里是否存在某个键是映射的一个重要操作。这个操作允许用户写一些逻辑来确定是否完成了某个操作或者是否在映射里缓存了特定数据。这个操作也可以用来比较两个映射,来确定哪些键值对互相匹配,哪些键值对不匹配。

从映射取值时有两个选择。第一个选择是,可以同时获得值,以及一个表示这个键是否存在的标志。

// 获取键 Blue 对应的值
value, exists := colors["Blue"]

// 这个键存在吗?
if exists {
	fmt.Println(value)
}

在 Go 语言里,通过键来索引映射时,即便这个键不存在也总会返回一个值。在这种情况下,返回的是该值对应的类型的零值。

迭代映射里的所有值和迭代数组或切片一样,使用关键字 range。但对映射来说,range 返回的不是索引和值,而是键值对

// 创建一个映射,存储颜色以及颜色对应的十六进制代码
colors := map[string]string{
    "AliceBlue": "#f0f8ff",
    "Coral": "#ff7F50",
    "DarkGray": "#a9a9a9",
    "ForestGreen": "#228b22",
}

// 显示映射里的所有颜色
for key, value := range colors {
	fmt.Printf("Key: %s Value: %s\n", key, value)
}

如果想把一个键值对从映射里删除,就使用内置的 delete 函数

// 删除键为 Coral 的键值对
delete(colors, "Coral")

4.3.4 在函数间传递映射

在函数间传递映射并不会制造出该映射的一个副本。实际上,当传递映射给一个函数,并对这个映射做了修改时,所有对这个映射的引用都会察觉到这个修改。这个特性和切片类似,保证可以用很小的成本来复制映射。

五、Go 语言的类型系统

Go 语言是一种静态类型的编程语言。这意味着,编译器需要在编译时知晓程序里每个值的类型。如果提前知道类型信息,编译器就可以确保程序合理地使用值。这有助于减少潜在的内存异常和 bug,并且使编译器有机会对代码进行一些性能优化,提高执行效率。

值的类型给编译器提供两部分信息:第一部分,需要分配多少内存给这个值(即值的规模);第二部分,这段内存表示什么。对于许多内置类型的情况来说,规模和表示是类型名的一部分。int64 类型的值需要 8 字节(64 位),表示一个整数值;float32 类型的值需要 4 字节(32 位),表示一个 IEEE-754 定义的二进制浮点数;bool 类型的值需要 1 字节(8 位),表示布尔值 true 和 false。

有些类型的内部表示与编译代码的机器的体系结构有关。例如,根据编译所在的机器的体系结构,一个 int 值的大小可能是 8 字节(64 位),也可能是 4 字节(32 位)。还有一些与体系结构相关的类型,如 Go 语言里的所有引用类型。好在创建和使用这些类型的值的时候,不需要了解这些与体系结构相关的信息。但是,如果编译器不知道这些信息,就无法阻止用户做一些导致程序受损甚至机器故障的事情。

5.1 用户定义的类型

Go 语言允许用户定义类型。当用户声明一个新类型时,这个声明就给编译器提供了一个框架,告知必要的内存大小和表示信息。声明后的类型与内置类型的运作方式类似。Go 语言里声明用户定义的类型有两种方法。最常用的方法是使用关键字 struct,它可以让用户创建一个结构类型。

type user struct {
	name string
	email string
	ext int
	privileged bool
}

结构类型通过组合一系列固定且唯一的字段来声明。结构里每个字段都会用一个已知类型声明。这个已知类型可以是内置类型,也可以是其他用户定义的类型。

一旦声明了类型,就可以使用这个类型创建值。当声明变量时,这个变量对应的值总是会被初始化。这个值要么用指定的值初始化,要么用零值(即变量类型的默认值)做初始化。对数值类型来说,零值是 0;对字符串来说,零值是空字符串;对布尔类型,零值是 false。

任何时候,创建一个变量并初始化为其零值,习惯是使用关键字 var。这种用法是为了更明确地表示一个变量被设置为零值。

var bill user

如果变量被初始化为某个非零值,就配合结构字面量短变量声明操作符来创建变量。

一个短变量声明操作符在一次操作中完成两件事情:声明一个变量,并初始化。短变量声明操作符会使用右侧给出的类型信息作为声明变量的类型。

结构字面量可以对结构类型采用两种形式。第一种形式在不同行声明每个字段的名字以及对应的值。字段名与值用冒号分隔,每一行以逗号结尾。这种形式对字段的声明顺序没有要求。

alice := user{
	name: "Alice",
	email: "[email protected]",
	ext: 123,
	privileged: true,
}

第二种形式没有字段名,只声明对应的值。

lisa := user{"Lisa", "[email protected]", 123, true}

每个值也可以分别占一行,不过习惯上这种形式会写在一行里,结尾不需要逗号。这种形式下,值的顺序很重要,必须要和结构声明中字段的顺序一致。

另一种声明用户定义的类型的方法是,基于一个已有的类型,将其作为新类型的类型说明。当需要一个可以用已有类型表示的新类型的时候,这种方法会非常好用。

type Duration int64

标准库使用这种声明类型的方法,从内置类型创建出很多更加明确的类型,并赋予更高级的功能。

上述代码展示的是标准库的 time 包里的一个类型的声明。Duration 是一种描述时间间隔的类型,单位是纳秒(ns)。这个类型使用内置的 int64 类型作为其表示。在 Duration 类型的声明中,把 int64 类型叫作 Duration 的基础类型。

不过,虽然 int64 是基础类型,Go 并不认为 Duration 和 int64 是同一种类型。这两个类型是完全不同的有区别的类型。类型 int64 的值不能作为类型 Duration 的值来用。换句话说,虽然 int64 类型是基础类型,Duration 类型依然是一个独立的类型。两种不同类型的值即便互相兼容,也不能互相赋值。编译器不会对不同类型的值做隐式转换

5.2 方法

方法能给用户定义的类型添加新的行为。方法实际上也是函数,只是在声明时,在关键字 func 和方法名之间增加了一个参数

// user 在程序里定义一个用户类型
type user struct {
	name string
	email string
}

// notify 使用值接收者实现了一个方法
func (u user) notify() {
	fmt.Printf("Sending User Email To %s<%s>\n",u.name,u.email)
}

// changeEmail 使用指针接收者实现了一个方法
func (u *user) changeEmail(email string) {
	u.email = email
}

func main()  {
	// user 类型的值可以用来调用使用值接收者声明的方法
	bill := user{"Bill", "[email protected]"}
	bill.notify()

	// 指向 user 类型值的指针也可以用来调用使用值接收者声明的方法
	lisa := &user{"Lisa", "[email protected]"}
	lisa.notify()

	// user 类型的值可以用来调用使用指针接收者声明的方法
	bill.changeEmail("[email protected]")
	bill.notify()

	// 指向 user 类型值的指针可以用来调用使用指针接收者声明的方法
	lisa.changeEmail("[email protected]")
	lisa.notify()
}

上述代码展示了两种类型的方法。关键字 func 和函数名之间的参数被称作接收者,将函数与接收者的类型绑在一起。如果一个函数有接收者,这个函数就被称为方法

Go 语言里有两种类型的接收者:值接收者指针接收者。如果使用值接收者声明方法,调用时会使用这个值的一个副本来执行。

也可以使用指针来调用使用值接收者声明的方法,为了支持这种方法调用,Go 语言调整了指针的值,来符合方法接收者的定义。可以认为 Go 语言执行了下面的操作。

(*lisa).notify()

指针被解引用为值,这样就符合了值接收者的要求。再强调一次,notify 操作的是一个副本,只不过这次操作的是从 lisa 指针指向的值的副本。

也可以使用指针接收者声明方法,当调用使用指针接收者声明的方法时,这个方法会共享调用方法时接收者所指向的值。总结一下,值接收者使用值的副本来调用方法,而指针接受者使用实际值来调用方法。

也可以使用一个值来调用使用指针接收者声明的方法,Go 语言再一次对值做了调整,使之符合函数的接收者,进行调用。

(&bill).changeEmail("[email protected]")

Go语言既允许使用值,也允许使用指针来调用方法,不必严格符合接收者的类型。这个支持非常方便开发者编写程序。

应该使用值接收者,还是应该使用指针接收者,这个问题有时会比较迷惑人。可以遵从标准库里一些基本的指导方针来做决定。

5.3 类型的本质

在声明一个新类型之后,声明一个该类型的方法之前,需要先回答一个问题:这个类型的本质是什么。如果给这个类型增加或者删除某个值,是要创建一个新值,还是要更改当前的值?如果是要创建一个新值,该类型的方法就使用值接收者。如果是要修改当前值,就使用指针接收者。这个答案也会影响程序内部传递这个类型的值的方式:是按值做传递,还是按指针做传递。保持传递的一致性很重要。这个背后的原则是,不要只关注某个方法是如何处理这个值,而是要关注这个值的本质是什么

5.3.1 内置类型

内置类型是由语言提供的一组类型,它们分别是数值类型字符串类型布尔类型。这些类型本质上是原始的类型。因此,当对这些值进行增加或者删除的时候,会创建一个新值。基于这个结论,当把这些类型的值传递给方法或者函数时,应该传递一个对应值的副本

5.3.2 引用类型

Go 语言里的引用类型有如下几个:切片映射通道接口函数类型。当声明上述类型的变量时,创建的变量被称作标头 (header) 值从技术细节上说,字符串也是一种引用类型。每个引用类型创建的标头值是一个指向底层数据结构的指针。每个引用类型还包含一组独特的字段,用于管理底层数据结构。因为标头值是为复制而设计的,所以永远不需要共享一个引用类型的值。标头值里包含一个指针,因此通过复制来传递一个引用类型的值的副本,本质上就是在共享底层数据结构

type IP []byte

上述代码展示了一个名为 IP 的类型,这个类型被声明为字节切片。当要围绕相关的内置类型或者引用类型来声明用户定义的行为时,直接基于已有类型来声明用户定义的类型会很好用。编译器只允许为命名的用户定义的类型声明方法

func (ip IP) MarshalText() ([]byte, error) {
	if len(ip) == 0 {
		return []byte(""), nil
	}
	if len(ip) != IPv4len && len(ip) != IPv6len {
		return nil, errors.New("invalid IP address")
	}
	return []byte(ip.String()), nil
}

上述代码里定义的 MarshalText 方法是用 IP 类型的值接收者声明的。一个值接收者,正像预期的那样通过复制来传递引用,从而不需要通过指针来共享引用类型的值。这种传递方法也可以应用到函数或者方法的参数传递:

// ipEmptyString 像 ip.String 一样,
// 只不过在没有设置 ip 时会返回一个空字符串
func ipEmptyString(ip IP) string {
	if len(ip) == 0 {
		return ""
	}
	return ip.String()
}

ipEmptyString 函数。这个函数需要传入一个 IP 类型的值。再一次可以看到调用者传入的是这个引用类型的值,而不是通过引用共享给这个函数。调用者将引用类型的值的副本传入这个函数。这种方法也适用于函数的返回值。

最后要说的是,引用类型的值在其他方面像原始的数据类型的值一样对待。

5.3.3 结构类型 (以下简略)

结构类型可以用来描述一组数据值,这组值的本质即可以是原始的,也可以是非原始的。如果决定在某些东西需要删除或者添加某个结构类型的值时该结构类型的值不应该被更改,那么需要遵守之前提到的内置类型和引用类型的规范。

如果一个创建用的工厂函数返回了一个指针,就表示这个被返回的值的本质是非原始的。即便函数或者方法没有直接改变非原始的值的状态,依旧应该使用共享的方式传递。非原始的总是应该被共享,而不是被复制

是使用值接收者还是指针接收者,不应该由该方法是否修改了接收到的值来决定。这个决策应该基于该类型的本质。这条规则的一个例外是,需要让类型值符合某个接口的时候,即便类型的本质是非原始本质的,也可以选择使用值接收者声明方法。这样做完全符合接口值调用方法的机制。

5.4 接口

多态是指代码可以根据类型的具体实现采取不同行为的能力。如果一个类型实现了某个接口,所有使用这个接口的地方,都可以支持这种类型的值。标准库里有很好的例子,如 io 包里实现的流式处理接口。io 包提供了一组构造得非常好的接口和函数,来让代码轻松支持流式数据处理。只要实现两个接口,就能利用整个 io 包背后的所有强大能力。

六、并发

通常程序会被编写为一个顺序执行并完成一个独立任务的代码。如果没有特别的需求,最好总是这样写代码,因为这种类型的程序通常很容易写,也很容易维护。不过也有一些情况下,并行执行多个任务会有更大的好处。

一个例子是,Web 服务需要在各自独立的套接字(socket)上同时接收多个数据请求。每个套接字请求都是独立的,可以完全独立于其他套接字进行处理。具有并行执行多个请求的能力可以显著提高这类系统的性能。考虑到这一点,Go 语言的语法和运行时直接内置了对并发的支持。

Go 语言里的并发指的是能让某个函数独立于其他函数运行的能力。当一个函数创建为 goroutine时,Go 会将其视为一个独立的工作单元。这个单元会被调度到可用的逻辑处理器上执行。Go 语言运行时的调度器是一个复杂的软件,能管理被创建的所有 goroutine 并为其分配执行时间。这个调度器在操作系统之上,将操作系统的线程与语言运行时的逻辑处理器绑定,并在逻辑处理器上运行goroutine。调度器在任何给定的时间,都会全面控制哪个 goroutine 要在哪个逻辑处理器上运行。

Go 语言的并发同步模型来自一个叫作通信顺序进程(Communicating Sequential Processes,CSP)的范型(paradigm)。CSP 是一种消息传递模型,通过在 goroutine 之间传递数据来传递消息而不是对数据进行加锁来实现同步访问。用于在 goroutine 之间同步和传递数据的关键数据类型叫作通道(channel)。

6.1 并发与并行

当运行一个应用程序(如一个 IDE 或者编辑器)的时候,操作系统会为这个应用程序启动一个进程(process)。可以将这个进程看作一个包含了应用程序在运行中需要用到和维护的各种资源的容器。

图 6-1 展示了一个包含所有可能分配的常用资源的进程。这些资源包括但不限于内存地址空间、文件和设备的句柄以及线程。一个线程(thread)是一个执行空间,这个空间会被操作系统调度来运行函数中所写的代码。每个进程至少包含一个线程,每个进程的初始线程被称作主线程。因为执行这个线程的空间是应用程序的本身的空间,所以当主线程终止时,应用程序也会终止。操作系统将线程调度到某个处理器上运行,这个处理器并不一定是进程所在的处理器。不同操作系统使用的线程调度算法一般都不一样,但是这种不同会被操作系统屏蔽,并不会展示给程序员。

操作系统会在物理处理器上调度线程来运行,而 Go 语言的运行时会在逻辑处理器上调度 goroutine 来运行。每个逻辑处理器都分别绑定到单个操作系统线程。在 1.5 版本上,Go语言的运行时默认会为每个可用的物理处理器分配一个逻辑处理器。在 1.5 版本之前的版本中,默认给整个应用程序只分配一个逻辑处理器。这些逻辑处理器会用于执行所有被创建 goroutine。即便只有一个逻辑处理器,Go也可以以神奇的效率和性能,并发调度无数个goroutine。

在图 6-2 中,可以看到操作系统线程、逻辑处理器和本地运行队列之间的关系。如果创建一 个 goroutine 并准备运行,这个 goroutine 就会被放到调度器的全局运行队列中。之后,调度器就将这些队列中的 goroutine 分配给一个逻辑处理器,并放到这个逻辑处理器对应的本地运行队列中。本地运行队列中的 goroutine 会一直等待直到自己被分配的逻辑处理器执行。

有时,正在运行的 goroutine 需要执行一个阻塞的系统调用,如打开一个文件。当这类调用 发生时,线程和 goroutine 会从逻辑处理器上分离,该线程会继续阻塞,等待系统调用的返回。与此同时,这个逻辑处理器就失去了用来运行的线程。所以,调度器会创建一个新线程,并将其绑定到该逻辑处理器上。之后,调度器会从本地运行队列里选择另一个 goroutine 来运行。一旦被阻塞的系统调用执行完成并返回,对应的 goroutine 会放回到本地运行队列,而之前的线程会保存好,以便之后可以继续使用。

如果一个 goroutine 需要做一个网络 I/O 调用,流程上会有些不一样。在这种情况下,goroutine会和逻辑处理器分离,并移到集成了网络轮询器的运行时。一旦该轮询器指示某个网络读或者写操作已经就绪,对应的 goroutine 就会重新分配到逻辑处理器上来完成操作。调度器对可以创建的逻辑处理器的数量没有限制,但语言运行时默认限制每个程序最多创建 10 000 个线程。这个限制值可以通过调用 runtime/debug 包的 SetMaxThreads 方法来更改。如果程序试图使用更多的线程,就会崩溃。

并发(concurrency)不是并行(parallelism)。并行是让不同的代码片段同时在不同的物理处理器上执行。并行的关键是同时很多事情,而并发是指同时管理很多事情,这些事情可能只做了一半就被暂停去做别的事情了。在很多情况下,并发的效果比并行好,因为操作系统和硬件的总资源一般很少,但能支持系统同时做很多事情。这种“使用较少的资源做更多的事情”的哲学,也是指导 Go 语言设计的哲学。

如果希望让 goroutine 并行,必须使用多于一个逻辑处理器。当有多个逻辑处理器时,调度器会将 goroutine 平等分配到每个逻辑处理器上。这会让 goroutine 在不同的线程上运行。不过要想真的实现并行的效果,用户需要让自己的程序运行在有多个物理处理器的机器上。否则,哪怕 Go 语言运行时使用多个线程,goroutine 依然会在同一个物理处理器上并发运行,达不到并行的效果。

图 6-3 展示了在一个逻辑处理器上并发运行 goroutine 和在两个逻辑处理器上并行运行两个并 发的 goroutine 之间的区别。调度器包含一些聪明的算法,这些算法会随着 Go 语言的发布被更新和改进,所以不推荐盲目修改语言运行时对逻辑处理器的默认设置。如果真的认为修改逻辑处理器的数量可以改进性能,也可以对语言运行时的参数进行细微调整。后面会介绍如何做这种修改。

6.2 goroutine

runtime 包的 GOMAXPROCS 函数允许程序更改调度器可以使用的逻辑处理器的数量。如果不想在代码里做这个调用,也可以通过修改和这个函数名字一样的环境变量的值来更改逻辑处理器的数量。给这个函数传入 1,是通知调度器只能为该程序使用一个逻辑处理器。

WaitGroup 是一个计数信号量,可以用来记录并维护运行的 goroutine。如果 WaitGroup 的值大于 0,Wait 方法就会阻塞。为了减小WaitGroup 的值并最终释放 main 函数,使用 defer 声明在函数退出时调用 Done 方法。

关键字 defer 会修改函数调用时机,在正在执行的函数返回时才真正调用 defer 声明的函数。

基于调度器的内部算法,一个正运行的 goroutine 在工作结束前,可以被停止并重新调度。 调度器这样做的目的是防止某个 goroutine 长时间占用逻辑处理器。当 goroutine 占用时间过长时,调度器会停止当前正运行的 goroutine,并给其他可运行的 goroutine 运行的机会。

图 6-4 从逻辑处理器的角度展示了这一场景。在第 1 步,调度器开始运行 goroutine A,而 goroutine B 在运行队列里等待调度。之后,在第 2 步,调度器交换了 goroutine A 和 goroutine B。由于 goroutine A 并没有完成工作,因此被放回到运行队列。之后,在第 3 步,goroutine B 完成了它的工作并被系统销毁。这也让 goroutine A 继续之前的工作。

和其他函数调用一样,创建为 goroutine 的函数调用时可以传入参数。不过 goroutine 终止时无法获取函数的返回值。

Go 标准库的 runtime 包里有一个名为 GOMAXPROCS 的函数,通过它可以指定调度器可用的逻辑处理器的数量。用这个函数,可以给每个可用的物理处理器在运行的时候分配一个逻辑处理器。

import "runtime"

// 给每个可用的核心分配一个逻辑处理器
runtime.GOMAXPROCS(runtime.NumCPU())

包 runtime 提供了修改 Go 语言运行时配置参数的能力。上述代码我们使用两个 runtime 包的函数来修改调度器使用的逻辑处理器的数量。函数 NumCPU 返回可以使用的物理处理器的数量。因此,调用 GOMAXPROCS 函数就为每个可用的物理处理器创建一个逻辑处理器。需要强调的是,使用多个逻辑处理器并不意味着性能更好。在修改任何语言运行时配置参数的时候,都需要配合基准测试来评估程序的运行效果。

Built with Hugo
Theme Stack designed by Jimmy