云原生系列Go语言篇-复合类型

本文来自正在规划的Go语言&云原生自我提升系列,欢迎关注后续文章。

在上一篇文章中,我们学习了一些简单类型:数值、布尔值和字符串。本文中我们会学习 Go 中的复合类型、它们所支持的内置函数以及使用的最佳实践。

数组-古板不宜直接使用

和大部分编程语言一样,Go语言也有数组。但在 Go 中很少直接使用数组。一会我们就知道个中缘由了,但我们首先来快速讲解数组的声明语法及使用。

数组中的所有元素都必须是指定的类型(这并不表示一定是同一类型)。有一些声明方式。第一种是指定数组大小及数组中元素的类型:

var x [3]int

这会创建包含3个int类型元素的数组。因为我们并未指定值,所有位置(x[0]x[1]x[2]) 都使用int的零值初始化,当然也就是0了。如果数组有初始化值,可以通过数组字面量进行指定:

var x = [3]int{10, 20, 30}

如果有一个稀疏数组(数组中大部分元素都设为零值),可以在数组字面量中仅指定有值元素的索引:

var x = [12]int{1, 5: 4, 6, 10: 100, 15}

这会创建一个包含以下12个int值的数组:[1, 0, 0, 0, 0, 4, 6, 0, 0, 0, 100, 15]。

在使用数组字面量初始化数组时,可以不写数量,直接使用

var x = [...]int{10, 20, 30}

可使用==!=比较数组:

var x = [...]int{1, 2, 3}
var y = [3]int{1, 2, 3}
fmt.Println(x == y) // 打印true

Go仅包含一维数组,但可以模拟出多维数组:

var x [2][3]int

以上声明了一个长度为2的数组,数组类型为长度是3的int数组。听起来有些绕,但有些语言是支持矩阵的,只是 Go 并不是这种语言。

和大部分语言一样,Go中的数组通过中括号语法进行读写:

x[0] = 10
fmt.Println(x[2])

无法读写到数组以外或是使用负数索引。如果使用常量或字面量这么做的话,会报编译时错误。使用变量越界读写编译可通过但在运行时会失败报panic(我们会在错误处理一文的panic和recover一节中深入讨论 panic)。

最后,内置函数len接收数组并返回其长度:

fmt.Println(len(x))

之前说过在 Go 中很少显式使用数组。这是因为它存在着不一般的限制:Go 认为数组的大小是数组类型的一部分。这样声明为[3]int的数组与声明为[4]int的数组属于不同类型。也就是说不能使用变量来指定数组大小,因为类型需要在编译期就解析出来,不能等到运行时。

还有就是不同对同类型不同大小的数组进行类型转换。因为无法转换不同大小的数组,也就无法编写函数操作各种大小的数组或是对同一变量赋不同大小的数组。

注:我们会在指针那一篇文章中讨论数组背后的内存布局。

由于有这么多限制,除非提前知道具体长度否则请不要使用数组。比如标准库中的某些加密函数会返回数组,那是因为校验和的大小也在算法中做了定义。这是例外,而不是规则。

这就抛出了那个问题:为什么在 Go要有这么多限制呢?主要原因是在 Go 中数组主要是为切片提供背后的存储,切片可以说是 Go中最重要的特性之一。

切片

大部分想用到存储值序列的数据结构时,都应当使用切片。切片好用之处在于大小并不是类型的一部分。这样就没有了数组的限制。我们可以编写一个函数处理任意大小的切片(在函数一文中会讲到函数),切片可按需扩容。在讲解完Go中切片的基本用法之后,我们会聊到使用它的最佳方式。

操作切片和数组有些像,但有一些细微差别。首先要注意我们在声明时没有指定切片的大小:

var x = []int{10, 20, 30}

小贴士:使用[…]会产生数组。使用[]会产生切片。

以上创建的切片使用了包含3个int的切片字面量。类似数组,我们也可以在切片字面量中仅指定索引和值:

var x = []int{1, 5: 4, 6, 10: 100, 15}

以上创建的是12个int的切片,值为[1, 0, 0, 0, 0, 4, 6, 0, 0, 0, 100, 15]。

可以模拟出多维切片创建切片的切片:

var x [][]int

我们可以使用中括号语法读取和写入切片,同时和数组一样,不能越界读写也不能使用负值索引:

x[0] = 10
fmt.Println(x[2])

到这里切片似乎和数组并不没有什么差别。我们先通过不使用字面量声明切片来看数组和切片的差别:

var x []int

它会创建一个int切片。因为没有赋值,x被赋了切片的零值nil,这个我们前面不没有讲过。我们会在指针这一章讨论nil,它与其它语言中的null有些微不同。在Go中,nil是在一些类型缺少值时用于表示的标识符。和前面文章中学习无类型数值常量不同,nil不存丰类型,因此可以将其赋值给其它类型或与其它类型比较。nil切片中什么都没有。

切片是我们所学的第一种无法比较的类型。对两个切片使用==比较是否相同或!=比较是否不同会报编译时错误。唯一能和切片进行比较的是nil

fmt.Println(x == nil) // 打印true

小贴士:reflect包中包含一个名为DeepEqual的函数,可对任何类型进行比较,包括切片。这主要用于测试,但在需要时也可以使用它比较切片。我们会在恶龙三剑客:反射、Unsafe 和 Cgo一文中讨论反射。

len

Go 提供了一些内置函数可操作内置类型。我们已经用到过complexrealimag内置函数对复数进行构建或提取元素。切片也有一些内置函数。我们在学习数组时已经用到过len函数。切片也可以使用它,将nil切片传递给len时,会返回0.

注:Go中内置了len这样的函数,是因为在手写的代码中无法实现这一功能。我们已经知道len的参数可为任意数组或切片。很快就会学到它也可用于字符串和map。在并发一文中,我们会学习如何对通道使用它。将其它类型变量传递给len会报编译时错误。我们会在函数一文中学到,Go不允许开发者编写这样的函数。

append

内置的append函数用于对切片追加内容:

var x []int
x = append(x, 10)

append函数至少接收两个参数,任意类型的切片以及该类型的值。它返回同类型的切片。返回的切片赋值回所传入的切片。本例中,我们对nil切片进行追加,但也可以对已有元素的切片进行追加:

var x = []int{1, 2, 3}
x = append(x, 4)

可以同时追加多个值:

x = append(x, 5, 6, 7)

将切片追加到另一个切片可使用运算符将源切片展开为各个值(我们会在函数一文中的可变输入参数和切片会深入讲解运算符):

y := []int{20, 30, 40}
x = append(x, y...)

如果忘记对append返回的值赋值会报编译时错误。读者可能会想为什么看起来有些重复。我们会在函数一文中进下讲解,但 Go 是一种值传递的语言。每次对函数传参时,Go会复制一份所传入的值。将切片传入append函数实际上是传递的是切片的拷贝。该函数将值添加到切片的拷贝中,再返回这个拷贝。然后将返回的切片赋回给调用函数中的变量。

容量

我们已经知道切片就是一个值序列。切片中的每个元素都分配在一段连续的内存地址上,这样读写值都非常快速。每个切片都有一个容量,这正是所保留的连续内存地址。容量可大于长度。每次对切片进行追加时,都会在切片的最后添加一个或多个值。每个值都会让切片的长度增加1。在长度到达容量时,就没有剩余空间放值了。如果在长度等于容量时再增加值,append函数会使用 Go运行时分配一段容量更大的新切片。原切片中的值会拷贝到新切片中,新增值放在切片其最后,返回新切片。

GO运行时

每种高阶编程语言都依赖一组库来让这种语言编写的程序可以运行,Go也不例外。Go运行时提供了像内存分配、垃圾回收、并发支持、网络、内置类型和函数实现等服务。

Go运行时编译成一个个Go二进制。这与使用虚拟机的编程语言不同,那会需要单独安装虚拟机才能上编写的程序运行。在二进制文件中包含运行时让 Go 程序的分发更容易,不必担心运行时和程序之间的兼容性问题。

在使用append追加切片时,Go运行时会花费时间分配新内存,将已有数据从老内存拷贝到新内存上。老内存同样需要进行垃圾回收。因此,Go 运行时通常会在每次超出容量时新增1个以上。目前Go的规则是在容量小于1,024时会增至双倍,之后每次至少新增25%。

就像内置len函数返回的是切片的当前长度,内置cap函数返回的是切片的当前容量。它的使用频率远低于len。大多数时候,cap用于检测切片是否够存储新数据,或是在调用make时用于新建切片。

我们也可以对cap函数传数组,但cap返回的值和len的值总是一致的。这个不要在代码中使用,炫技时再用吧。

我们来学习对切片添加元素是如何修改其升度和容量的。在The Go Playground 中运行例3-1中的代码。

例3-1 理解容量

var x []int
fmt.Println(x, len(x), cap(x))
x = append(x, 10)
fmt.Println(x, len(x), cap(x))
x = append(x, 20)
fmt.Println(x, len(x), cap(x))
x = append(x, 30)
fmt.Println(x, len(x), cap(x))
x = append(x, 40)
fmt.Println(x, len(x), cap(x))
x = append(x, 50)
fmt.Println(x, len(x), cap(x))

构建运行以上代码,就会看到如下的输出。注意容量是在何时以及如何增加的:

[] 0 0
[10] 1 1
[10 20] 2 2
[10 20 30] 3 4
[10 20 30 40] 4 4
[10 20 30 40 50] 5 8

虽然切片自动扩容很不错,但只进行一次大小设定会更高效。如果知道要在切片中放多少内容的话,可使用对应的初始容量创建切片。我们通过make函数实现。

make

我们已经学习了两种声明切片的方法,即使用切片字面量或nil零值。虽然可用,但这两种方式都无法创建指定长度或容量的空切片。make内置函数就是用来完成这一任务的。可通过它指定类型、长度或是容量(可选)。如下:

x := make([]int, 5)

这会创建一个长度为5、容量也为5的int切片。因其长度为5,x[0]x[4]都是有效元素,全部初始化为0.

初学者常见的错误是会尝试使用append来对其添加初始元素:

x := make([]int, 5)
x = append(x, 10)

下面的10会放到切片尾部,在0-4位置的0零值之后,因为append总是会增加切片的长度。现在x的值为[0 0 0 0 0 10],长度是6,容量是10(在追加第6个元素时容量会加倍)。

我们还可以通过make指定初始容量:

x := make([]int, 5, 10)

这会创建一个长度为5、容量为10的int切片。

还可创建长度为0、容量大于0的切片:

x := make([]int, 0, 10)

这时,我们拥有一个长度为0的非nil切片,容量为10。因其长度为0,不能直接使用索引访问,但可以对其追加值:

x := make([]int, 0, 10)
x = append(x, 5,6,7,8)

此时x的值是[5 6 7 8],长度为4,容量为10.

警告:不要把容量指定小于长度。使用常量或数值字面量指定时会报编译时错误。而如果使用变量指定小于长度的容量时,程序会在运行时panic。

声明切片

我们已经学习了所有这些创建切片的方式,那么该选择哪种切片声明方式呢?主要目标是让切片扩容的次数降至最低。如果切片完全不会有新增(可能是因为函数不返回内容),使用var不带值声明来创建一个nil切片,就像例3-2中那样。

例3-2 声明可能保持为nil的切片

var data []int

注:可以使用空切片字面量来声明切片:

var x = []int{}

这会创建一个长度为0的切片,它不是nil(与nil进行比对会返回false)。除此之外零长切片与nil切片并没有不同。零长切片唯一有用的场景是在将切片转化为JSON时。我们会在标准库一文中的encoding/json中深入讲解。

如果有一些初始值或是切片值不会发生改变,那么切片字面量会是一个不错的选择(见例3-3)。

例3-3 使用默认值声明切片

data := []int{2, 4, 6, 8} // 用到的值

如果清楚需要的切片有多大,但在编写程序时尚不知道值是什么,使用make。接下来的问题是是否应在调用make时指定非零长度或指定零长和非零容量。有3种可能:

  • 如果将切片用作缓冲(参见标准库一文中的io和它的朋友们),则指定非零长度。
  • 如果确定知道具体大小,可指定长度并按索引对切片设置值。通常这用于在一个切片中 转换值并存储在第二个切片中。这种方法的问题是如果大小错误,最终会在切片末尾得到的是零值或是因访问不存在的元素而panic。
  • 其它场景使用零长和指定容量的make。这样我们可以使用append对切片追加内容。如果内容量更少,就不会在末端有大量的零值。如果内容量更多,代码也不会panic。

在第二种和第三种方法上 Go 社区存在分歧。我个人偏向于使用append对初始化为零长的切片追加内容。在某些场景下可能会更慢,但通常不会产生bug。

警告:append必会增加切片的长度。如果使用make指定了切片的长度,请确保目的是要对它们追加内容,否则可能会在切片的一开始有一堆预想外的零值。

对切片进行切片

切片表达式会对切片创建切片。写法是在中括号中包含起始偏移量和结束偏移量,中间用英文冒号(:)分隔。如果没写初始偏移量,则默认为0。类似地,如果省掉了结束偏移量,则替换为切片的结尾。可在The Go Playground运行例3-4中的代码查看。

例3-4 对切片进行切片

x := []int{1, 2, 3, 4}
y := x[:2]
z := x[1:]
d := x[1:3]
e := x[:]
fmt.Println("x:", x)
fmt.Println("y:", y)
fmt.Println("z:", z)
fmt.Println("d:", d)
fmt.Println("e:", e)

输出的结果为:

x: [1 2 3 4]
y: [1 2]
z: [2 3 4]
d: [2 3]
e: [1 2 3 4]

切片有时会共享内存

对切片进行切片时,并不是拷贝该数据。此时两个变量会共享内存。也就是说修改一个切片中的元素会影响到共享到该元素的所有切片。我们来试下修改值会发生什么。可以在The Go Playground运行例3-5中的代码。

例3-5 存储有重叠的切片

x := []int{1, 2, 3, 4}
y := x[:2]
z := x[1:]
x[1] = 20
y[0] = 10
z[1] = 30
fmt.Println("x:", x)
fmt.Println("y:", y)
fmt.Println("z:", z)

输出结果为:

x: [10 20 30 4]
y: [10 20]
z: [20 30 4]

修改x同时会修改和yz,而修改yz也会修改x

对切片进行切片在配合append使用时又多了一层困扰。在 The Go Playground.中测试例3-6中的代码。

例3-6 append使重叠的切片变复杂

x := []int{1, 2, 3, 4}
y := x[:2]
fmt.Println(cap(x), cap(y))
y = append(y, 30)
fmt.Println("x:", x)
fmt.Println("y:", y)

运行以上代码得到的结果如下:

4 4
x: [1 2 30 4]
y: [1 2 30]

怎么解释呢?在对切片取切片时,子切片的容量为原切片容量减去子切片在原切片中的偏移量。也就是说原切片中所有未使用的容量都会被其子切片所共享。

在我们从x中取切片y时,长度为2,但容量为4,与x相同。因为容量是4,对y的结尾追加值时会放x的第三个位置上。

这种行为会导致在多切片追加和重写数据时产生一些非常奇怪的场景。试试能不能猜到例3-7中代码的打印结果,然后在The Go Playground中运行看猜的是否正确。

例3-7 更迷的切片

x := make([]int, 0, 5)
x = append(x, 1, 2, 3, 4)
y := x[:2]
z := x[2:]
fmt.Println(cap(x), cap(y), cap(z))
y = append(y, 30, 40, 50)
x = append(x, 60)
z = append(z, 70)
fmt.Println("x:", x)
fmt.Println("y:", y)
fmt.Println("z:", z)

为避免出现复杂的切片场景,要么永不对子切片使用append要么保证append使用全切片表达式从而不会导致重写。这有点奇怪,但它可以让我们很清楚父切片和子切片共享了多少内存。全切片表达包含一个第三部分,表示父切片容量中子切片可用的最后一个位置。用这个数字减去子切片的起始位置可得子切片的容量。例3-8中展示了使用全切片表达式修改上例中第三、四行后的结果。

例3-8 全切片表达式形式了对append操作的保护

y := x[:2:2]
z := x[2:4:4]

可以在The Go Playground上测试这段代码。yz的容量都是2。因为我们将子切片的容量限制为其长度,对yz追加元素都会创建新的切片,不与其它切片相交叉。运行该段代码后,x为[1 2 3 4 60],y为[1 2 30 40 50],z为[3 4 70]。

警告:在从切片中取切片时要相当小心!两个切片共享内存,对一个修改会体现在另一个切片上。避免修改做过切片的切片或子切片。使用三段切片表达式防止对共享容量的切片的append

将数组转为切片

并不是只能对切片做切片。同样可以使用切片表达式对数组做切片。通过这种方式对只接收切片的函数也可使用数组。但要注意对数组切片同样存在内存共享的问题。在The Go Playground中运行如下代码:

x := [4]int{5, 6, 7, 8}
y := x[:2]
z := x[2:]
x[0] = 10
fmt.Println("x:", x)
fmt.Println("y:", y)
fmt.Println("z:", z)

会得到如下输出:

x: [10 6 7 8]
y: [10 6]
z: [7 8]

copy

如需创建独立于原切片的切片,可使用内置的copy函数。我们一起来看个简单的例子,可在The Go Playground:中运行:

x := []int{1, 2, 3, 4}
y := make([]int, 4)
num := copy(y, x)
fmt.Println(y, num)

得到的结果是:

[1 2 3 4] 4

copy函数接收两个参数。第一个为目标切片,第二个是源切片。它从源切片将尽可能多的值拷贝到目标切片,上限为切片较小的那个,返回所拷贝到元素数。xy的容量并不产生影响,重要的是其长度。

不要求拷贝整个切片。以下代码将4个元素的切片的前两个拷贝到了一个两元素切片中:

x := []int{1, 2, 3, 4}
y := make([]int, 2)
num = copy(y, x)

变量y为[1 2] ,num为2。

也可以从源切片的中间进行拷贝:

x := []int{1, 2, 3, 4}
y := make([]int, 2)
copy(y, x[2:])

我们通过对x取切片拷贝了其第三和第四个元素。同时注意我们并没有将copy的输出赋值给变量。如果不需要所拷贝的元素数,就不需要进行赋值。

copy函数允许对底层切片重叠的两个切片执行拷贝:

x := []int{1, 2, 3, 4}
num = copy(x[:3], x[1:])
fmt.Println(x, num)

上例中,我们是将x的后三个值拷贝到x的前三个位置上。打印结果为[2 3 4 4] 3。

可使用对提取了切片的数组使用copy。可以把数组作为拷贝的源或目标。在The Go Playground中运行以下代码看看效果:

x := []int{1, 2, 3, 4}
d := [4]int{5, 6, 7, 8}
y := make([]int, 2)
copy(y, d[:])
fmt.Println(y)
copy(d[:], x)
fmt.Println(d)

第一次copy调用将数组d中的前两个值拷贝到切片y中。第二次将切片x中的所有值拷贝到数组d中。得到的结果是:

[5 6]
[1 2 3 4]

字符串、符文和字节

我们已经讨论完了切片,那么再回到字符串。读者可能会觉得Go中的字符串由符文组成,但并非如此。深入下去会发现Go使用了字节序列来表示字符串。这些字节不限定为某一种编码,但很多Go的库函数(以及下一篇文章中会讨论的for-range)会假定字符串由UTF-8编码的代码点序列组成。

注:根据语言规范,Go的源代码使用UTF-8编写。除非使用了十六进制在字符串字面量中进行了转义,字符串字面量都是使用UTF-8进行编写。

就像从数组或切片中取某个值一样,可以通过索引表达式从字符串取一个值:

var s string = "Hello there"
var b byte = s[6]

字符串和数组、切片一样,索引从0开始,上例中对b所赋的值为s的第7个值,即字母t

我们在数组和切片中使用的切片表达式标记法同样可用在字符串中:

var s string = "Hello there"
var s2 string = s[4:7]
var s3 string = s[:5]
var s4 string = s[6:]

这里s2对赋值o ts3赋值Hellos4赋值there

虽然Go中允许使用切片标记获取子字符串以及使用索引标记提取单个值很方便,但在使用时应保持谨慎。因为字符串是不可变的,并不存在像切片那样的修改问题。不过存在另一个问题。字符串由字节序列组成,而UTF-8中的代码点长度可能为1到4个字节。上例中的字符串是由长度都是1字节的UTF-8代码点组成,一切都和设想一样。但在处理英文以外的语言或是表情符号时,可能会存在多字节UTF-8代码点的情况:

var s string = "Hello 🌞"
var s2 string = s[4:7]
var s3 string = s[:5]
var s4 string = s[6:]

这个例子中,s3还是会等于Hello。变量s4设置为太阳表情符号。但s2并不是o 🌞,而是o �。这是因为我们只拷贝了太阳表情的第一个字节,这是一个无效字节。

Go允许对字符串使用内置的len函数获取字符串长度。既然字符串的索引和切片表达式是以字节计算位置的,那么返回的是字节长度而非代码点长度也就很自然了:

var s string = "Hello 🌞"
fmt.Println(len(s))

这段代码打印的结果是10,而不是7,因为UTF-8中的太阳表情符号占用4个字节。

警告:虽然Go中允许对字符串使用切片和索引语法,请在知晓其中字符都为1字节时再使用。

因为符文、字符串和字节之间存在着复杂的关系,在Go中进行相互类型转换时会有一些有趣的事情。单个符文或字节可转换为字符串:

var a rune    = 'x'
var s string  = string(a)
var b byte    = 'y'
var s2 string = string(b)

警告:Go新手中常见的bug是使用类型转换将int变成string

var x int = 65
var y = string(x)
fmt.Println(y)

这会导致y的值为A,而不是65。从Go 1.15开始,go vet不再允许runebyte之外的整型转换为字符串。

字符串可与字节切片或符文切片之间相互转换。在The Go Playground中试试例3-9中的代码。

例3-9 将字符串转换为切片

var s string = "Hello, 🌞"
var bs []byte = []byte(s)
var rs []rune = []rune(s)
fmt.Println(bs)
fmt.Println(rs)

运行这段代码,得到的结果是:

[72 101 108 108 111 44 32 240 159 140 158]
[72 101 108 108 111 44 32 127774]

第一段中是将字符串转换为UTF-8字节。第二段中将字符串转换为符文。

Go中的大部分数据都是按字节序列进行读写,因此大部分常见的字符串类型转换都是与字节切片之间的转换。符文切片不太常见。

UTF-8

UTF-8是最常用的Unicode编码。Unicode使用4个字节(32 bits)表示每个代码点,代码点为每个字符和修饰符的技术名称。因此,表示Unicode代码点最简单的方式是用4个字节存储每个代码点。这称为UTF-32。是最不常见的,因为它浪费了大量空间。按照Unicode的实现细节,32比特中的11个保持为0。另一种常见的编码是UTF-16,使用一个或两个16比特(2字节)序列来表示每个代码点。这也有些浪费,大部分的内容(主要指英文)都可以放到只占用一个字节的代码点中。这时便有了UTF-8。

UTF-8的做法很聪明,可以使用单个字节表示值在128以下的Unicode字符(包含所有英文中常用的字母、数字和标点符号),但对更大的值可扩展至最大4个字节用于表示Unicode代码点。UTF-8中最差的情况是使用UTF-32相同。UTF-8还有一些其它的优秀之处。与UTF-32和UTF-16不同,无需担心使用的是大端序还是小端序。通过序列中的任意字节可以知道它是在UTF-8序列的开头还是中间。也就是说不会在读取字节时出错。

唯一的不足是无法随机读取UTF-8编码的字符串。虽然可以知道是否在字符中间,但无法知道一共有多少个字符。需要从字符串的开头计算。Go并没有要求用UTF-8写入字符串,但强烈建议这么做。我们会在接下来的文章中使用UTF-8字符串。

轶事:UTF-8由Go的两个创始人Ken Thompson和Rob Pike于1992年发明。

从字符串中提取子字符串和代码点一般不使用切片和索引表达式,而是使用标准库中stringsunicode/utf8包中的函数。下一篇文章中,我们学习如何使用for-range循环遍历字符串中的代码点。

字典(map)

切片对于有序数据很有用。同大部分编程言一样,Go提供了内置数据类型处理一个值与另一个值关联的场景。map类型写为map[keyType]valueType。我们来学习几种声明map的方式。首先,可以使用var创建一个字典变量,并将其设为零值:

var nilMap map[string]int

这里声明nilMap为一个string键和int值的字典。map的零值是nilnil字典的长度为0。尝试从nil字典中读取返回的总是字典值类型的零值。但对nil字典变量写入会报panic。

我们也可以通过通过赋字典字面量来使用:=创建字典变量:

totalWins := map[string]int{}

这里我们使用的是空字典字面量。它与nil字典不同。它的长度为0,但可对空字典字面量的字典读取及写入。下面是一个非空字典字面量的示例:

teams := map[string][]string {
    "Orcas": []string{"Fred", "Ralph", "Bijou"},
    "Lions": []string{"Sarah", "Peter", "Billie"},
    "Kittens": []string{"Waldo", "Raul", "Ze"},
}

字典字面量内容体先写键,再接冒号(:),再后是值。每个键值对使用逗号分隔,最后一行同样如此。在本例中,值为字符串切片。字典中的值可为任意类型。关于键的类型限制稍后会进行讨论。

如果知道准备装进字典有多少个键值对,但不知道具体值,可使用make创建一个带有默认大小的字典:

ages := make(map[int][]string, 10)

通过make创建的字典长度仍为0,可以增长至所指定大小以上。

字典和切片有几方面很像:

  • 在不断添加键值对时字典会自增长。
  • 如果事先知道要在字典中插入多少个键值对,可以使用make创建指定大小的字典。
  • 对字典使用len可获取字典中的键值对数。
  • 字典的零值是nil
  • 字典不可比较。可以查看字典是否为nil,但无法使用==!=查看两个字典是否相同或不同。

字典中的键可为任意一种可比较类型。这表示字典中的键不能使用切片或字典。

那什么时候使用字典、什么时候使用切片呢?切片是数据列表,尤其用于处理有序的数据。字典对于值无需严格进行排序的数据比较适用。

小贴士:在元素排序不重要进使用字典。在元素排序比较重要时使用切片。

什么是哈希字典?

在计算机科学领域,map是一种将一个值与另一个值关联的数据结构。字典有多种实现方式,各有利弊。Go中内置的字典为hash map。对于不熟悉这一概念的读者,下面简述一下。

哈希字典根据键可快速查找值。在内部这使用数组实现。在插入一对键和值时,键使用哈希算法转为数字。这些数字对每个键并不唯一。哈希算法可将不同键转为同一数字。该数字会用做数组的索引。数组中的每个元素称为一个桶(bucket)。键值对会存储在桶中。如果桶中存在相同的键,老值会被新值替换掉。

每个桶也是一个数组,可存储多个值。在两个键映射到相同的桶时,这称为碰撞,两者的键值都存储在这个桶中。

对哈希字典的读者相同。拿到键,运行哈希算法将其转为数字,找到关联桶,对后对桶中的每个键进行遍历,查看其是否与所提供的键相同。找到后就返回其值。

我们不希望出现太多碰撞,因为碰撞越多,哈希字典就越慢。智能的哈希算法设计时会让碰撞保持为最少。如果添加了足够的元素,哈希字典会重置以使用桶重新平衡,允许添加更多条目。

哈希字典很有用,但要自己正确地构建并不简单。如果想要了解Go是怎么做的,可以看GopherCon 2016中的演讲:Map的内部实现

Go并不要求(也不允许)用户自己定义哈希算法或做等式定义。而是在编译为每个Go程序的Go运行时带上实现键所支持的所有类型哈希算法的代码。

读取和写入字典

我们来看一个声明、写入和读取字典的简短程序。可以在The Go Playground中运行例3-10中的代码。

例3-10 使用字典

totalWins := map[string]int{}
totalWins["Orcas"] = 1
totalWins["Lions"] = 2
fmt.Println(totalWins["Orcas"])
fmt.Println(totalWins["Kittens"])
totalWins["Kittens"]++
fmt.Println(totalWins["Kittens"])
totalWins["Lions"] = 3
fmt.Println(totalWins["Lions"])

运行这段程序,会输出:

1
0
1
3

我们通过在字典的方括号中定义键,在=后指定值,然后通过在方括号中放入键来读取值。注意不能使用:=对字典的键赋值。

在读取未设置过的字典键时,会返回字典值类型对应的零值。本例中值的类型为int,所以得到的结果是0。可以使用++ 运算符来递增某个键对应的值。因为字典默认返回的是零值,即便在键没有关联值时也可以使用。

逗号ok语句

我们已经知道字典中没有相应键的关联值时会返回零值。这对于实现前面看到的计数器非常方便。但有时确实需要知道字典中是否存在某个键。Go提供了一个逗号ok语句可分辨字典中相应键是对应的值为零值还是压根不存在:

m := map[string]int{
    "hello": 5,
    "world": 0,
}
v, ok := m["hello"]
fmt.Println(v, ok)

v, ok = m["world"]
fmt.Println(v, ok)

v, ok = m["goodbye"]
fmt.Println(v, ok)

逗号ok语句返回的不只是字典的值,而是包含是否存在的结果共可赋给两个变量。第一个变量获取的是与键相关联的值。第二个变量值是一个布尔值,通常使用ok。如果oktrue,表示字典中存在这个键。如果okfalse,表示键不存在。本例输出的结果为5 true, 0 true, and 0 false。

注:逗号ok语句在Go中用于区分读取值还是返回了零值。我们会在并发一文中读取通道以及在类型、方法和接口一文中进行类型断言时才会再次用到。

从字典中删除

字典中的键值对通过内置的delete函数进行删除:

m := map[string]int{
    "hello": 5,
    "world": 10,
}
delete(m, "hello")

delete函数接收字典和键,然后删除指定键对应的键值对。如果字典中不存在该键或是字典为nil,什么也不会发生。delete函数没有返回值。

将字典用作集合

很多语言的标准库中都包含集合(set)。集合数据类型可保障一个值最多出现一次,但并不会保持排序。不管集合中有多少个元素,查看元素是否存在很快速。(查看切片中是否存在元素随着不断加入元素会越来越慢。)

Go中并没有集合,但可使用字典模拟它的一些功能。将想要放入集合的类型设为字典键的类型,值统一使用bool类型。例3-11中的代码演示了这一概念。可在The Go Playground中运行。

例3-11 将字典用作集合

intSet := map[int]bool{}
vals := []int{5, 10, 2, 5, 8, 7, 3, 9, 1, 2, 10}
for _, v := range vals {
    intSet[v] = true
}
fmt.Println(len(vals), len(intSet))
fmt.Println(intSet[5])
fmt.Println(intSet[500])
if intSet[100] {
    fmt.Println("100 is in the set")
}

我们需要的是一个int类型的集合,所以创建了一个键为int类型、值为bool类型的字典。使用for-range循环对vals中的值进行遍历,放入intSet,所有的值都设置为布尔类型true

我们在intSet中写入了11个值,但intSet的长度为8,因为字典中不能有重复的键。如果在intSet中查找5,会返回true,因为其中存在为5的键。但如若在intSet中查找500或100,返回的是false。这是因为在intSet中这两个值都不存在,也就会导致字典返回零值,而bool的零值为false

如果要对集合做并集、交集和或差集运算,要么自己写,要么使用提供这一功能的第三方库。(在模块、包和导入一文中会学习使用第三方库。)

注:有些人在将字典实现为集合时喜欢使用struct{}作为值。(在下一节中会讲到结构体。)好处是空结构体占零字节,而布尔类型使用1个字节。

使用struct{}的坏处是代码有些冗长。赋值不清晰,还要使用逗号ok语句来判断值在集合中是否存在:

intSet := map[int]struct{}{}
vals := []int{5, 10, 2, 5, 8, 7, 3, 9, 1, 2, 10}
for _, v := range vals {
    intSet[v] = struct{}{}
}
if _, ok := intSet[5]; ok {
    fmt.Println("5 is in the set")
}

除非使用的是非常大的集合,内存占用的细微差别好过上面所述的坏处。

结构体

字典对于存在某类数据很便捷,但存一些限制。不适合定义API,因为无法限制字典允许某些键。并且字典中的所有值都必须是同一类型。出于这些原因,在将数据从一个函数传入另一个函数时字典不是一种理想的方式。在有一些关联数据希望进行分组时,应当定义一个结构体。

注:如果读者已经熟悉面向对象语言,可能会想类和结构体有什么分别。区别很简单:Go中没有类,因为它没有继承。这不是说Go中没有面向对象的某些特性,只是做法不同。我们会在类型、方法和接口一文学习一些面向对象特性。

大部分语言都有类似于结构体的概念,Go中读写结构体的语法读者应该不陌生:

type person struct {
    name string
    age  int
    pet  string
}

结构体定义方式为关键字type、结构体类型的名称、关键字struct以及一对花括号({})。在花括号内列出结构体中的字段。就像在var声明中一样,结构体字段名在前、类型在后。还要注意不同于字典字面量,结构体声明的字段之间不用逗号进行分隔。可以在函数内或外定义结构体类型。函数内定义的结构体类型只能在函数内使用。(我们会在函数一文学习函数。)

注:从技术上讲,可以把结构体定义的作用域放到任意级代码块中。我们会在代码块,遮蔽和控制结构一文中讲解代码块。

声明好结构体类型后,我们就可以定义该类型的变量:

var fred person

此处我们使用var进行声明。因为没有对fred赋值,它的值为person结构体类型零值。结构体的零值每个字段都为该字段的零值。

也可将结构体字面量赋值给变量:

bob := person{}

不同于字典,赋空结构体字面量和不赋值没有差别。两者都会将结构体中的所有字段初始化为其零值。非空结构体字面量有两种形式。结构体字面量可指定为花括号内逗号分隔的字段值列表:

julia := person{
    "Julia",
    40,
    "cat",
}

使用这种结构体字面量格式时,必须指定结构体中每个字段的值,赋值的顺序需要与结构体中定义字段的顺序相同。

第二种结构体字面量格式类似字典字面量:

beth := person{
    age:  30,
    name: "Beth",
}

我们使用结构体中的字段名来指定值。使用这种格式时,可以省去一些键,也不用按顺序指定字段。没指定的字段为零值。不能滥用这两种赋值样式:要么所有字段都通过键指定,要么就不用写字段名。对于总是指定所有字段的小结构体,使用第一种。其它时候使用键名。会更长,但不需查看结构体定义就很清楚哪个字段指定的是什么值。也更易于维护。如果初始化结构体时没有使用字段名,后来结构体又新增了字段,代码就无法通过编译。

结构体中的字段通过点号标记访问:

bob.name = "Bob"
fmt.Println(beth.name)

就像是使用方括号读写字典一样,我们使用点号标记来读写结构体字段。

匿名结构体

我可以先不给结构体类型名称,直接声明一个实现某一结构体类型的变量。这称为匿名结构体:

var person struct {
    name string
    age  int
    pet  string
}

person.name = "bob"
person.age = 50
person.pet = "dog"

pet := struct {
    name string
    kind string
}{
    name: "Fido",
    kind: "dog",
}

本例中,变量personpet的类型都是匿名结构体。可以具名结构体一样在匿名结构体中赋值(读取)字段。就像可以使用结构体字面量初始化具名结构体的实例一样,对匿名结构体也可进行同样操作。

你可能会想只关联了单个实例的数据类型有什么用。有两个通用场景会使用到匿名结构体。第一个是在将外部数据与结构体相互转换时(如JSON或Protobuf)。这称为序列化和反序列化数据。我们会在标准库一文的encoding/json中讲到。

单元测试中也会用到匿名结构体。我们会在编写测试一文中编写表格驱动测试时用到匿名结构体切片。

比较和转换结构体

结构体是否可比较取决于结构体的字段。全部由可比较类型组成的结构体可比较,带有切片或字典字段的结构体不可比较(在后面的文章中我们会学到函数和通道也会让结构体不可比较)。

和Python或Ruby不同,Go中没有可重载的魔术方法来重新定义等式让==!=用于不可比较的结构体。当然我们可以自己编写函数来比较结构体。

就像Go不允许对不同基础类型变量进行比较一样,它也不允许对不同类型结构体的变量进行比较。但Go中可以对名称、顺序和类型相同的结构体执行类型转换。下面来看是什么意思。假定有结构体:

type firstPerson struct {
    name string
    age  int
}

我们使用类型转换将firstPerson的实例转换为secondPerson,但无法使用==比较firstPerson的实例和secondPerson的实例,因为两者类型不同:

type secondPerson struct {
    name string
    age  int
}

无法将firstPerson实例转换为thirdPerson,因为其字段顺序不同:

type thirdPerson struct {
    age  int
    name string
}

无法将firstPerson的实例转换为fourthPerson,因为其字段名不匹配:

type fourthPerson struct {
    firstName string
    age       int
}

最后,我们也无法将firstPerson的实例转换为fifthPerson,因为多一个字段:

type fifthPerson struct {
    name          string
    age           int
    favoriteColor string
}

匿名结构体又有些不同:如果比较两个结构体变量,而其中至少有一个匿名结构体,如果两个结构体的名称、顺序和类型都相同,无需进行类型转换就可以比较。而如果名称、顺序和类型相同可使用具名和匿名结构体为彼此赋值:

type firstPerson struct {
    name string
    age  int
}
f := firstPerson{
    name: "Bob",
    age:  50,
}
var g struct {
    name string
    age  int
}

// 可通过编译:对相同的具名和匿名结构体可使用 = 和 ==
g = f
fmt.Println(f == g)

小结

我们学习了Go中的容器类型。不仅更深入地学习了字符串,我们会使用了内置的常用容器类型,切片和字典。还可通过结构体构造自己的复合类型。在下一篇文章中,我们会学习Go中的控制结构forif/elseswitch。我们还会学习Go如何将代码组织到代码块中,以及不同级别的代码块会产生哪些意外的行为。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,189评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,577评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,857评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,703评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,705评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,620评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,995评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,656评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,898评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,639评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,720评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,395评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,982评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,953评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,195评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,907评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,472评论 2 342

推荐阅读更多精彩内容