本章主要内容
很难遇到要编写一个不需要存储和读取集合数据的程序的情况。如果使用数据库或者文件,或者访问网络,总需要一种方法来处理接收和发送的数据。Go语言有3种数据结构可以让用户管理集合数据:数组、切片和映射。这3种数据结构是语言核心的一部分,在标准库里被广泛使用。一旦学会如何使用这些数据结构,用Go语言编写程序会变得快速、有趣且十分灵活。
了解这些数据结构,一般会从数组开始,因为数组是切片和映射的基础数据结构。理解了数组的工作原理,有助于理解切片和映射提供的优雅和强大的功能。
在Go语言里,数组是一个长度固定的数据类型,用于存储一段具有相同的类型的元素的连续块。数组存储的类型可以是内置类型,如整型或者字符串,也可以是某种结构类型。
在图4-1中可以看到数组的表示。灰色格子代表数组里的元素,每个元素都紧邻另一个元素。每个元素包含相同的类型,这个例子里是整数,并且每个元素可以用一个唯一的索引(也称下标或标号)来访问。

图4-1 数组的内部实现
数组是一种非常有用的数据结构,因为其占用的内存是连续分配的。由于内存连续,CPU能把正在使用的数据缓存更久的时间。而且内存连续很容易计算索引,可以快速迭代数组里的所有元素。数组的类型信息可以提供每次访问一个元素时需要在内存中移动的距离。既然数组的每个元素类型相同,又是连续分配,就可以以固定速度索引数组中的任意数据,速度非常快。
声明数组时需要指定内部存储的数据的类型,以及需要存储的元素的数量,这个数量也称为数组的长度,如代码清单4-1所示。
代码清单4-1 声明一个数组,并设置为零值
// 声明一个包含5个元素的整型数组
var array [5]int一旦声明,数组里存储的数据类型和数组长度就都不能改变了。如果需要存储更多的元素,就需要先创建一个更长的数组,再把原来数组里的值复制到新数组里。
在Go语言中声明变量时,总会使用对应类型的零值来对变量进行初始化。数组也不例外。当数组初始化时,数组内每个元素都初始化为对应类型的零值。在图4-2里,可以看到整型数组里的每个元素都初始化为0,也就是整型的零值。

图4-2 声明数组变量后数组的值
一种快速创建数组并初始化的方式是使用数组字面量。数组字面量允许声明数组里元素的数量同时指定每个元素的值,如代码清单4-2所示。
代码清单4-2 使用数组字面量声明数组
// 声明一个包含5个元素的整型数组
// 用具体值初始化每个元素
array := [5]int{10, 20, 30, 40, 50}如果使用...替代数组的长度,Go语言会根据初始化时数组元素的数量来确定该数组的长度,如代码清单4-3所示。
代码清单4-3 让Go自动计算声明数组的长度
// 声明一个整型数组
// 用具体值初始化每个元素
// 容量由初始化值的数量决定
array := [...]int{10, 20, 30, 40, 50}如果知道数组的长度而是准备给每个值都指定具体值,就可以使用代码清单4-4所示的这种语法。
代码清单4-4 声明数组并指定特定元素的值
// 声明一个有5个元素的数组
// 用具体值初始化索引为1和2的元素
// 其余元素保持零值
array := [5]int{1: 10, 2: 20}代码清单4-4中声明的数组在声明和初始化后,会和图4-3所展现的一样。

图4-3 声明之后数组的值
正像之前提到的,因为内存布局是连续的,所以数组是效率很高的数据结构。在访问数组里任意元素的时候,这种高效都是数组的优势。要访问数组里某个单独元素,使用[]运算符,如代码清单4-5所示。
代码清单4-5 访问数组元素
// 声明一个包含5个元素的整型数组
// 用具体值初始为每个元素
array := [5]int{10, 20, 30, 40, 50}
// 修改索引为2的元素的值
array[2] = 35代码清单4-5中声明的数组的值在操作完成后,会和图4-4所展现的一样。

图4-4 修改索引为2的值之后数组的值
可以像第2章一样,声明一个所有元素都是指针的数组。使用*运算符就可以访问元素指针所指向的值,如代码清单4-6所示。
代码清单4-6 访问指针数组的元素
// 声明包含5个元素的指向整数的数组
// 用整型指针初始化索引为0和1的数组元素
array := [5]*int{0: new(int), 1: new(int)}
// 为索引为0和1的元素赋值
*array[0] = 10
*array[1] = 20代码清单4-6中声明的数组的值在操作完毕后,会和图4-5所展现的一样。

图4-5 指向整数的指针数组
在Go语言里,数组是一个值。这意味着数组可以用在赋值操作中。变量名代表整个数组,因此,同样类型的数组可以赋值给另一个数组,如代码清单4-7所示。
代码清单4-7 把同样类型的一个数组赋值给另外一个数组
// 声明第一个包含5个元素的字符串数组
var array1 [5]string
// 声明第二个包含5个元素的字符串数组
// 用颜色初始化数组
array2 := [5]string{"Red", "Blue", "Green", "Yellow", "Pink"}
// 把array2的值复制到array1
array1 = array2复制之后,两个数组的值完全一样,如图4-6所示。

图4-6 复制之后的两个数组
数组变量的类型包括数组长度和每个元素的类型。只有这两部分都相同的数组,才是类型相同的数组,才能互相赋值,如代码清单4-8所示。
代码清单4-8 编译器会阻止类型不同的数组互相赋值
// 声明第一个包含4个元素的字符串数组
var array1 [4]string
// 声明第二个包含5个元素的字符串数组
// 使用颜色初始化数组
array2 := [5]string{"Red", "Blue", "Green", "Yellow", "Pink"}
// 将array2复制给array1
array1 = array2
Compiler Error:
cannot use array2 (type [5]string) as type [4]string in assignment复制数组指针,只会复制指针的值,而不会复制指针所指向的值,如代码清单4-9所示。
代码清单4-9 把一个指针数组赋值给另一个
// 声明第一个包含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-7所示。

图4-7 两组指向同样字符串的数组
数组本身只有一个维度,不过可以组合多个数组创建多维数组。多维数组很容易管理具有父子关系的数据或者与坐标系相关联的数据。声明二维数组的示例如代码清单4-10所示。
代码清单4-10 声明二维数组
// 声明一个二维整型数组,两个维度分别存储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}}图4-8展示了代码清单4-10中声明的二维数组在每次声明并初始化后包含的值。

图4-8 二维数组及其外层数组和内层数组的值
为了访问单个元素,需要反复组合使用[]运算符,如代码清单4-11所示。
代码清单4-11 访问二维数组的元素
// 声明一个2×2的二维整型数组
var array [2][2]int
// 设置每个元素的整型值
array[0][0] = 10
array[0][1] = 20
array[1][0] = 30
array[1][1] = 40只要类型一致,就可以将多维数组互相赋值,如代码清单4-12所示。多维数组的类型包括每一维度的长度以及最终存储在元素中的数据的类型。
代码清单4-12 同样类型的多维数组赋值
// 声明两个不同的二维整型数组
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因为每个数组都是一个值,所以可以独立复制某个维度,如代码清单4-13所示。
代码清单4-13 使用索引为多维数组赋值
// 将array1的索引为1的维度复制到一个同类型的新数组里
var array3 [2]int = array1[1]
// 将外层数组的索引为1、内层数组的索引为0的整型值复制到新的整型变量里
var value int = array1[1][0]根据内存和性能来看,在函数间传递数组是一个开销很大的操作。在函数之间传递变量时,总是以值的方式传递的。如果这个变量是一个数组,意味着整个数组,不管有多长,都会完整复制,并传递给函数。
为了考察这个操作,我们来创建一个包含100万个int类型元素的数组。在64位架构上,这将需要800万字节,即8 MB的内存。如果声明了这种大小的数组,并将其传递给函数,会发生什么呢?如代码清单4-14所示。
代码清单4-14 使用值传递,在函数间传递大数组
// 声明一个需要8 MB的数组
var array [1e6]int
// 将数组传递给函数foo
foo(array)
// 函数foo接受一个100万个整型值的数组
func foo(array [1e6]int) {
...
}每次函数foo被调用时,必须在栈上分配8 MB的内存。之后,整个数组的值(8 MB的内存)被复制到刚分配的内存里。虽然Go语言自己会处理这个复制操作,不过还有一种更好且更有效的方法来处理这个操作。可以只传入指向数组的指针,这样只需要复制8字节的数据而不是8 MB的内存数据到栈上,如代码清单4-15所示。
代码清单4-15 使用指针在函数间传递大数组
// 分配一个需要8 MB的数组
var array [1e6]int
// 将数组的地址传递给函数foo
foo(&array)
// 函数foo接受一个指向100万个整型值的数组的指针
func foo(array *[1e6]int) {
...
}这次函数foo接受一个指向100万个整型值的数组的指针。现在将数组的地址传入函数,只需要在栈上分配8字节的内存给指针就可以。
这个操作会更有效地利用内存,性能也更好。不过要意识到,因为现在传递的是指针,所以如果改变指针指向的值,会改变共享的内存。如你所见,使用切片能更好地处理这类共享问题。
切片是一种数据结构,这种数据结构便于使用和管理数据集合。切片是围绕动态数组的概念构建的,可以按需自动增长和缩小。切片的动态增长是通过内置函数append来实现的。这个函数可以快速且高效地增长切片。还可以通过对切片再次切片来缩小一个切片的大小。因为切片的底层内存也是在连续块中分配的,所以切片还能获得索引、迭代以及为垃圾回收优化的好处。
切片是一个很小的对象,对底层数组进行了抽象,并提供相关的操作方法。切片有3个字段的数据结构,这些数据结构包含Go语言需要操作底层数组的元数据(见图4-9)。

图4-9 切片内部实现:底层数组
这3个字段分别是指向底层数组的指针、切片访问的元素的个数(即长度)和切片允许增长到的元素个数(即容量)。后面会进一步讲解长度和容量的区别。
Go语言中有几种方法可以创建和初始化切片。是否能提前知道切片需要的容量通常会决定要如何创建切片。
一种创建切片的方法是使用内置的make函数。当使用make时,需要传入一个参数,指定切片的长度,如代码清单4-16所示。
代码清单4-16 使用长度声明一个字符串切片
// 创建一个字符串切片
// 其长度和容量都是5个元素
slice := make([]string, 5)如果只指定长度,那么切片的容量和长度相等。也可以分别指定长度和容量,如代码清单4-17所示。
代码清单4-17 使用长度和容量声明整型切片
// 创建一个整型切片
// 其长度为3个元素,容量为5个元素
slice := make([]int, 3, 5)分别指定长度和容量时,创建的切片,底层数组的长度是指定的容量,但是初始化后并不能访问所有的数组元素。图4-9描述了代码清单4-17里声明的整型切片在初始化并存入一些值后的样子。
代码清单4-17中的切片可以访问3个元素,而底层数组拥有5个元素。剩余的2个元素可以在后期操作中合并到切片,可以通过切片访问这些元素。如果基于这个切片创建新的切片,新切片会和原有切片共享底层数组,也能通过后期操作来访问多余容量的元素。
不允许创建容量小于长度的切片,如代码清单4-18所示。
代码清单4-18 容量小于长度的切片会在编译时报错
// 创建一个整型切片
// 使其长度大于容量
slice := make([]int, 5, 3)
Compiler Error:
len larger than cap in make([]int)另一种常用的创建切片的方法是使用切片字面量,如代码清单 4-19 所示。这种方法和创建数组类似,只是不需要指定[]运算符里的值。初始的长度和容量会基于初始化时提供的元素的个数确定。
代码清单4-19 通过切片字面量来声明切片
// 创建字符串切片
// 其长度和容量都是5个元素
slice := []string{"Red", "Blue", "Green", "Yellow", "Pink"}
// 创建一个整型切片
// 其长度和容量都是3个元素
slice := []int{10, 20, 30}当使用切片字面量时,可以设置初始长度和容量。要做的就是在初始化时给出所需的长度和容量作为索引。代码清单4-20中的语法展示了如何创建长度和容量都是100个元素的切片。
代码清单4-20 使用索引声明切片
// 创建字符串切片
// 使用空字符串初始化第 100 个元素
slice := []string{99: ""}记住,如果在[]运算符里指定了一个值,那么创建的就是数组而不是切片。只有不指定值的时候,才会创建切片,如代码清单4-21所示。
代码清单4-21 声明数组和声明切片的不同
// 创建有3个元素的整型数组
array := [3]int{10, 20, 30}
// 创建长度和容量都是3的整型切片
slice := []int{10, 20, 30}有时,程序可能需要声明一个值为nil的切片(也称nil切片)。只要在声明时不做任何初始化,就会创建一个nil切片,如代码清单4-22所示。
代码清单4-22 创建nil切片
// 创建nil整型切片
var slice []int在Go语言里,nil切片是很常见的创建切片的方法。nil切片可以用于很多标准库和内置函数。在需要描述一个不存在的切片时,nil切片会很好用。例如,函数要求返回一个切片但是发生异常的时候(见图4-10)。

图4-10 nil切片的表示
利用初始化,通过声明一个切片可以创建一个空切片,如代码清单4-23所示。
代码清单4-23 声明空切片
// 使用make创建空的整型切片
slice := make([]int, 0)
// 使用切片字面量创建空的整型切片
slice := []int{}空切片在底层数组包含0个元素,也没有分配任何存储空间。想表示空集合时空切片很有用,例如,数据库查询返回0个查询结果时(见图4-11)。

图4-11 空切片的表示
不管是使用nil切片还是空切片,对其调用内置函数append、len和cap的效果都是一样的。
现在知道了什么是切片,也知道如何创建切片,来看看如何在程序里使用切片。
对切片里某个索引指向的元素赋值和对数组里某个索引指向的元素赋值的方法完全一样。使用[]操作符就可以改变某个元素的值,如代码清单4-24所示。
代码清单4-24 使用切片字面量来声明切片
// 创建一个整型切片
// 其容量和长度都是5个元素
slice := []int{10, 20, 30, 40, 50}
// 改变索引为1的元素的值
slice[1] = 25切片之所以被称为切片,是因为创建一个新的切片就是把底层数组切出一部分,如代码清单4-25所示。
代码清单4-25 使用切片创建切片
// 创建一个整型切片
// 其长度和容量都是5个元素
slice := []int{10, 20, 30, 40, 50}
// 创建一个新切片
// 其长度为2个元素,容量为4个元素
newSlice := slice[1:3]执行完代码清单 4-25 中的切片动作后,我们有了两个切片,它们共享同一段底层数组,但通过不同的切片会看到底层数组的不同部分(见图4-12)。

图4-12 共享同一底层数组的两个切片
第一个切片slice能够看到底层数组全部5个元素的容量,不过之后的newSlice就看不到。对于newSlice,底层数组的容量只有4个元素。newSlice无法访问到它所指向的底层数组的第一个元素之前的部分。所以,对newSlice来说,之前的那些元素就是不存在的。
使用代码清单4-26所示的公式,可以计算出任意切片的长度和容量。
代码清单4-26 如何计算长度和容量
对底层数组容量是k的切片slice[i:j]来说
长度: j - i
容量: k - i对newSlice应用这个公式就能得到代码清单4-27所示的数字。
代码清单4-27 计算新的长度和容量
对底层数组容量是5的切片slice[1:3]来说
长度: 3 - 1 = 2
容量: 5 - 1 = 4可以用另一种方法来描述这几个值。第一个值表示新切片开始的元素的索引位置,这个例子中是1。第二个值表示开始的索引位置(1),加上希望包含的元素的个数(2),1+2的结果是3,所以第二个值就是3。容量是该与切片相关联的所有元素的数量。
需要记住的是,现在两个切片共享同一个底层数组。如果一个切片修改了该底层数组的共享部分,另一个切片也能感知到,如代码清单4-28所示。
代码清单4-28 修改切片内容可能导致的结果
// 创建一个整型切片
// 其长度和容量都是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的元素)(见图4-13)。

图4-13 赋值操作之后的底层数组
切片只能访问到其长度内的元素。试图访问超出其长度的元素将会导致语言运行时异常,如代码清单4-29所示。与切片的容量相关联的元素只能用于增长切片。在使用这部分元素前,必须将其合并到切片的长度里。
代码清单4-29 表示索引越界的语言运行时错误
// 创建一个整型切片
// 其长度和容量都是5个元素
slice := []int{10, 20, 30, 40, 50}
// 创建一个新切片
// 其长度为2个元素,容量为4个元素
newSlice := slice[1:3]
// 修改newSlice索引为3的元素
// 这个元素对于newSlice来说并不存在
newSlice[3] = 45
Runtime Exception:
panic: runtime error: index out of range切片有额外的容量是很好,但是如果不能把这些容量合并到切片的长度里,这些容量就没有用处。好在可以用Go语言的内置函数append来做这种合并很容易。
相对于数组而言,使用切片的一个好处是,可以按需增加切片的容量。Go语言内置的append函数会处理增加长度时的所有操作细节。
要使用append,需要一个被操作的切片和一个要追加的值,如代码清单4-30所示。当append调用返回时,会返回一个包含修改结果的新切片。函数append总是会增加新切片的长度,而容量有可能会改变,也可能不会改变,这取决于被操作的切片的可用容量。
代码清单4-30 使用append向切片增加元素
// 创建一个整型切片
// 其长度和容量都是5个元素
slice := []int{10, 20, 30, 40, 50}
// 创建一个新切片
// 其长度为2个元素,容量为4个元素
newSlice := slice[1:3]
// 使用原有的容量来分配一个新元素
// 将新元素赋值为60
newSlice = append(newSlice, 60)当代码清单4-30中的append操作完成后,两个切片和底层数组的布局如图4-14所示。

图4-14 append操作之后的底层数组
因为newSlice在底层数组里还有额外的容量可用,append操作将可用的元素合并到切片的长度,并对其进行赋值。由于和原始的slice共享同一个底层数组,slice中索引为3的元素的值也被改动了。
如果切片的底层数组没有足够的可用容量,append函数会创建一个新的底层数组,将被引用的现有的值复制到新数组里,再追加新的值,如代码清单4-31所示。
代码清单4-31 使用append同时增加切片的长度和容量
// 创建一个整型切片
// 其长度和容量都是4个元素
slice := []int{10, 20, 30, 40}
// 向切片追加一个新元素
// 将新元素赋值为50
newSlice := append(slice, 50)当这个append操作完成后,newSlice拥有一个全新的底层数组,这个数组的容量是原来的两倍(见图4-15)。

图4-15 append操作之后的新的底层数组
函数append会智能地处理底层数组的容量增长。在切片的容量小于1000个元素时,总是会成倍地增加容量。一旦元素个数超过1000,容量的增长因子会设为1.25,也就是会每次增加25%的容量。随着语言的演化,这种增长算法可能会有所改变。
在创建切片时,还可以使用之前我们没有提及的第三个索引选项。第三个索引可以用来控制新切片的容量。其目的并不是要增加容量,而是要限制容量。可以看到,允许限制新切片的容量为底层数组提供了一定的保护,可以更好地控制追加操作。
让我们看看一个包含5个元素的字符串切片。这个切片包含了本地超市能找到的水果名字,如代码清单4-32所示。
代码清单4-32 使用切片字面量声明一个字符串切片
// 创建字符串切片
// 其长度和容量都是5个元素
source := []string{"Apple", "Orange", "Plum", "Banana", "Grape"}如果查看这个包含水果的切片的值,就像图4-16所展示的样子。

图4-16 字符串切片的表示
现在,让我们试着用第三个索引选项来完成切片操作,如代码清单4-33所示。
代码清单4-33 使用3个索引创建切片
// 将第三个元素切片,并限制容量
// 其长度为1个元素,容量为2个元素
slice := source[2:3:4]这个切片操作执行后,新切片里从底层数组引用了1个元素,容量是2个元素。具体来说,新切片引用了Plum元素,并将容量扩展到Banana元素,如图4-17所示。

图4-17 操作之后的新切片的表示
我们可以应用之前定义的公式来计算新切片的长度和容量,如代码清单4-34所示。
代码清单4-34 如何计算长度和容量
对于slice[i:j:k] 或 [2:3:4]
长度: j – i 或 3 - 2 = 1
容量: k – i 或 4 - 2 = 2和之前一样,第一个值表示新切片开始的元素的索引位置,这个例子中是2。第二个值表示开始的索引位置(2)加上希望包括的元素的个数(1),2+1的结果是3,所以第二个值就是3。为了设置容量,从索引位置2开始,加上希望容量中包含的元素的个数(2),就得到了第三个值4。
如果试图设置的容量比可用的容量还大,就会得到一个语言运行时错误,如代码清单4-35所示。
代码清单4-35 设置容量大于已有容量的语言运行时错误
// 这个切片操作试图设置容量为4
// 这比可用的容量大
slice := source[2:3:6]
Runtime Error:
panic: runtime error: slice bounds out of range我们之前讨论过,内置函数append会首先使用可用容量。一旦没有可用容量,会分配一个新的底层数组。这导致很容易忘记切片间正在共享同一个底层数组。一旦发生这种情况,对切片进行修改,很可能会导致随机且奇怪的问题。对切片内容的修改会影响多个切片,却很难找到问题的原因。
如果在创建切片时设置切片的容量和长度一样,就可以强制让新切片的第一个append操作创建新的底层数组,与原有的底层数组分离。新切片与原有的底层数组分离后,可以安全地进行后续修改,如代码清单4-36所示。
代码清单4-36 设置长度和容量一样的好处
// 创建字符串切片
// 其长度和容量都是5个元素
source := []string{"Apple", "Orange", "Plum", "Banana", "Grape"}
// 对第三个元素做切片,并限制容量
// 其长度和容量都是1个元素
slice := source[2:3:3]
// 向slice追加新字符串
slice = append(slice, "Kiwi")如果不加第三个索引,由于剩余的所有容量都属于slice,向slice追加Kiwi会改变原有底层数组索引为3的元素的值Banana。不过在代码清单4-36中我们限制了slice的容量为1。当我们第一次对slice调用append的时候,会创建一个新的底层数组,这个数组包括2个元素,并将水果Plum复制进来,再追加新水果Kiwi,并返回一个引用了这个底层数组的新切片,如图4-18所示。

图4-18 append操作之后的新切片的表示
因为新的切片slice拥有了自己的底层数组,所以杜绝了可能发生的问题。我们可以继续向新切片里追加水果,而不用担心会不小心修改了其他切片里的水果。同时,也保持了为切片申请新的底层数组的简洁。
内置函数append也是一个可变参数的函数。这意味着可以在一次调用传递多个追加的值。如果使用...运算符,可以将一个切片的所有元素追加到另一个切片里,如代码清单4-37所示。
代码清单4-37 将一个切片追加到另一个切片
// 创建两个切片,并分别用两个整数进行初始化
s1 := []int{1, 2}
s2 := []int{3, 4}
// 将两个切片追加在一起,并显示结果
fmt.Printf("%v\n", append(s1, s2...))
Output:
[1 2 3 4]就像通过输出看到的那样,切片s2里的所有值都追加到了切片s1的后面。使用Printf时用来显示append函数返回的新切片的值。
既然切片是一个集合,可以迭代其中的元素。Go语言有个特殊的关键字range,它可以配合关键字for来迭代切片里的元素,如代码清单4-38所示。
代码清单4-38 使用for range迭代切片
// 创建一个整型切片
// 其长度和容量都是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会返回两个值。第一个值是当前迭代到的索引位置,第二个值是该位置对应元素值的一份副本(见图4-19)。

图4-19 使用range迭代切片会创建每个元素的副本
需要强调的是,range创建了每个元素的副本,而不是直接返回对该元素的引用,如代码清单4-39所示。如果使用该值变量的地址作为指向每个元素的指针,就会造成错误。让我们看看是为什么。
代码清单4-39 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: 10500168 ElemAddr: 1052E100
Value: 20 Value-Addr: 10500168 ElemAddr: 1052E104
Value: 30 Value-Addr: 10500168 ElemAddr: 1052E108
Value: 40 Value-Addr: 10500168 ElemAddr: 1052E10C因为迭代返回的变量是一个迭代过程中根据切片依次赋值的新变量,所以value的地址总是相同的。要想获取每个元素的地址,可以使用切片变量和索引值。
如果不需要索引值,可以使用占位字符来忽略这个值,如代码清单4-40所示。
代码清单4-40 使用空白标识符(下划线)来忽略索引值
// 创建一个整型切片
// 其长度和容量都是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-41所示。
代码清单4-41 使用传统的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-41里,我们使用函数len来决定什么时候停止对切片的迭代。
现在知道了如何创建和使用切片。可以组合多个切片成为多维切片,并对其进行迭代。
和数组一样,切片是一维的。不过,和之前对数组的讨论一样,可以组合多个切片形成多维切片,如代码清单4-42所示。
代码清单4-42 声明多维切片
// 创建一个整型切片的切片
slice := [][]int{{10}, {100, 200}}我们有了一个包含两个元素的外层切片,每个元素包含一个内层的整型切片。切片slice的值看起来像图4-20展示的样子。

图4-20 整型切片的切片的值
在图4-20里,可以看到组合切片的操作是如何将一个切片嵌入到另一个切片中的。外层的切片包括两个元素,每个元素都是一个切片。第一个元素中的切片使用单个整数10来初始化,第二个元素中的切片包括两个整数,即100和200。
这种组合可以让用户创建非常复杂且强大的数据结构。已经学过的关于内置函数append的规则也可以应用到组合后的切片上,如代码清单4-43所示。
代码清单4-43 组合切片的切片
// 创建一个整型切片的切片
slice := [][]int{{10}, {100, 200}}
// 为第一个切片追加值为20的元素
slice[0] = append(slice[0], 20)Go语言里使用append函数处理追加的方式很简明:先增长切片,再将新的整型切片赋值给外层切片的第一个元素。当代码清单4-43中的操作完成后,会为新的整型切片分配新的底层数组,然后将切片复制到外层切片的索引为0的元素,如图4-21所示。

图4-21 append操作之后外层切片索引为0的元素的布局
即便是这么简单的多维切片,操作时也会涉及众多布局和值。看起来在函数间像这样传递数据结构也会很复杂。不过切片本身结构很简单,可以以很小的成本在函数间传递。
在函数间传递切片就是要在函数间以值的方式传递切片。由于切片的尺寸很小,在函数间复制和传递切片成本也很低。让我们创建一个大切片,并将这个切片以值的方式传递给函数foo,如代码清单4-44所示。
代码清单4-44 在函数间传递切片
// 分配包含100万个整型值的切片
slice := make([]int, 1e6)
// 将slice传递到函数foo
slice = foo(slice)
// 函数foo接收一个整型切片,并返回这个切片
func foo(slice []int) []int {
...
return slice
}在64位架构的机器上,一个切片需要24字节的内存:指针字段需要8 字节,长度和容量字段分别需要8字节。由于与切片关联的数据包含在底层数组里,不属于切片本身,所以将切片复制到任意函数的时候,对底层数组大小都不会有影响。复制时只会复制切片本身,不会涉及底层数组(见图4-22)。

图4-22 函数调用之后两个切片指向同一个底层数组
在函数间传递24字节的数据会非常快速、简单。这也是切片效率高的地方。不需要传递指针和处理复杂的语法,只需要复制切片,按想要的方式修改数据,然后传递回一份新的切片副本。
映射是一种数据结构,用于存储一系列无序的键值对。
映射里基于键来存储值。图4-23通过一个例子展示了映射里键值对是如何存储的。映射功能强大的地方是,能够基于键快速检索数据。键就像索引一样,指向与该键关联的值。

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

图4-24 映射的内部结构的简单表示
映射的散列表包含一组桶。在存储、删除或者查找键值对的时候,所有操作都要先选择一个桶。把操作映射时指定的键传给映射的散列函数,就能选中对应的桶。这个散列函数的目的是生成一个索引,这个索引最终将键值对分布到所有可用的桶里。
随着映射存储的增加,索引分布越均匀,访问键值对的速度就越快。如果你在映射里存储了10 000个元素,你不希望每次查找都要访问10 000个键值对才能找到需要的元素,你希望查找键值对的次数越少越好。对于有10 000个元素的映射,每次查找只需要查找8个键值对才是一个分布得比较好的映射。映射通过合理数量的桶来平衡键值对的分布。
Go语言的映射生成散列键的过程比图4-25展示的过程要稍微长一些,不过大体过程是类似的。在我们的例子里,键是字符串,代表颜色。这些字符串会转换为一个数值(散列值)。这个数值落在映射已有桶的序号范围内表示一个可以用于存储的桶的序号。之后,这个数值就被用于选择桶,用于存储或者查找指定的键值对。对Go语言的映射来说,生成的散列键的一部分,具体来说是低位(LOB),被用来选择桶。

图4-25 简单描述散列函数是如何工作的
如果再仔细看看图4-24,就能看出桶的内部实现。映射使用两个数据结构来存储数据。第一个数据结构是一个数组,内部存储的是用于选择桶的散列键的高八位值。这个数组用于区分每个键值对要存在哪个桶里。第二个数据结构是一个字节数组,用于存储键值对。该字节数组先依次存储了这个桶里所有的键,之后依次存储了这个桶里所有的值。实现这种键值对的存储方式目的在于减少每个桶所需的内存。
映射底层的实现还有很多细节,不过这些细节超出了本书的范畴。创建并使用映射并不需要了解所有的细节,只要记住一件事:映射是一个存储键值对的无序集合。
Go语言中有很多种方法可以创建并初始化映射,可以使用内置的make函数(如代码清单4-45所示),也可以使用映射字面量。
代码清单4-45 使用make声明映射
// 创建一个映射,键的类型是string,值的类型是int
dict := make(map[string]int)
// 创建一个映射,键和值的类型都是string
// 使用两个键值对初始化映射
dict := map[string]string{"Red": "#da1337", "Orange": "#e95a22"}创建映射时,更常用的方法是使用映射字面量。映射的初始长度会根据初始化时指定的键值对的数量来确定。
映射的键可以是任何值。这个值的类型可以是内置的类型,也可以是结构类型,只要这个值可以使用==运算符做比较。切片、函数以及包含切片的结构类型这些类型由于具有引用语义,不能作为映射的键,使用这些类型会造成编译错误,如代码清单4-46所示。
代码清单4-46 使用映射字面量声明空映射
// 创建一个映射,使用字符串切片作为映射的键
dict := map[[]string]int{}
Compiler Exception:
invalid map key type []string没有任何理由阻止用户使用切片作为映射的值,如代码清单4-47所示。这个在使用一个映射键对应一组数据时,会非常有用。
代码清单4-47 声明一个存储字符串切片的映射
// 创建一个映射,使用字符串切片作为值
dict := map[int][]string{}键值对赋值给映射,是通过指定适当类型的键并给这个键赋一个值来完成的,如代码清单4-48所示。
代码清单4-48 为映射赋值
// 创建一个空映射,用来存储颜色以及颜色对应的十六进制代码
colors := map[string]string{}
// 将Red的代码加入到映射
colors["Red"] = "#da1337"可以通过声明一个未初始化的映射来创建一个值为nil的映射(称为nil映射)。nil映射不能用于存储键值对,否则,会产生一个语言运行时错误,如代码清单4-49所示。
代码清单4-49 对nil映射赋值时的语言运行时错误
// 通过声明映射创建一个nil映射
var colors map[string]string
// 将Red的代码加入到映射
colors["Red"] = "#da1337"
Runtime Error:
panic: runtime error: assignment to entry in nil map测试映射里是否存在某个键是映射的一个重要操作。这个操作允许用户写一些逻辑来确定是否完成了某个操作或者是否在映射里缓存了特定数据。这个操作也可以用来比较两个映射,来确定哪些键值对互相匹配,哪些键值对不匹配。
从映射取值时有两个选择。第一个选择是,可以同时获得值,以及一个表示这个键是否存在的标志,如代码清单4-50所示。
代码清单4-50 从映射获取值并判断键是否存在
// 获取键Blue对应的值
value, exists := colors["Blue"]
// 这个键存在吗?
if exists {
fmt.Println(value)
}另一个选择是,只返回键对应的值,然后通过判断这个值是不是零值来确定键是否存在,如代码清单4-51所示。
代码清单4-51 从映射获取值,并通过该值判断键是否存在
// 获取键Blue对应的值
value := colors["Blue"]
// 这个键存在吗?
if value != "" {
fmt.Println(value)
}在Go语言里,通过键来索引映射时,即便这个键不存在也总会返回一个值。在这种情况下,返回的是该值对应的类型的零值。
迭代映射里的所有值和迭代数组或切片一样,使用关键字range,如代码清单4-52所示。但对映射来说,range返回的不是索引和值,而是键值对。
代码清单4-52 使用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函数,如代码清单4-53所示。
代码清单4-53 从映射中删除一项
// 删除键为Coral的键值对
delete(colors, "Coral")
// 显示映射里的所有颜色
for key, value := range colors {
fmt.Printf("Key: %s Value: %s\n", key, value)
}这次在迭代映射时,颜色Coral不会显示在屏幕上。
在函数间传递映射并不会制造出该映射的一个副本。实际上,当传递映射给一个函数,并对这个映射做了修改时,所有对这个映射的引用都会察觉到这个修改,如代码清单4-54所示。
代码清单4-54 在函数间传递映射
func main() {
// 创建一个映射,存储颜色以及颜色对应的十六进制代码
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)
}
// 调用函数来移除指定的键
removeColor(colors, "Coral")
// 显示映射里的所有颜色
for key, value := range colors {
fmt.Printf("Key: %s Value: %s\n", key, value)
}
}
// removeColor将指定映射里的键删除
func removeColor(colors map[string]string, key string) {
delete(colors, key)
}如果运行这个程序,会得到代码清单4-55所示的输出。
代码清单4-55 代码清单4-54的输出
Key: AliceBlue Value: #F0F8FF
Key: Coral Value: #FF7F50
Key: DarkGray Value: #A9A9A9
Key: ForestGreen Value: #228B22
Key: AliceBlue Value: #F0F8FF
Key: DarkGray Value: #A9A9A9
Key: ForestGreen Value: #228B22可以看到,在调用了removeColor之后,main函数里引用的映射中不再有Coral颜色了。这个特性和切片类似,保证可以用很小的成本来复制映射。
make可以创建切片和映射,并指定原始的长度和容量。也可以直接使用切片和映射字面量,或者使用字面量作为变量的初始值。append函数扩展容量。len可以用来获取切片或者映射的长度。cap只能用于切片。这种方法只能用在映射存储的值都是非零值的情况。——译者注