02. 复合类型(Composite Types)

1. 数组

像大多数编程语言一样,Go也有数组。然而,在Go中很少直接使用数组。
数组中的所有元素都必须是指定的类型(这并不意味着它们总是相同的类型)。有几种不同的声明风格。在第一个语句中,指定数组的大小和数组元素的类型:

 var x [3]int

这将创建一个包含三个整型数的数组。由于没有指定任何值,因此所有位置(x[0],x[1]和x[2])都被初始化为int类型的零值,当然是0。如果有数组的初始值,你可以用array literal来指定它们:

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

如果你有一个稀疏数组(一个大多数元素都被设置为零值的数组),你可以只指定数组文字中有值的下标:

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

当使用array literal初始化数组时,可以省略数字并使用…来代替:

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

可以使用==和!=来比较数组:

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

Go只有一维数组,但是可以模拟多维数组:

var x [2][3]int

这里声明x是一个长度为2的数组,其类型是一个长度为3的整型数组。这听起来很迂腐,但是有些语言具有真正的矩阵支持;go不是其中之一。

像大多数语言一样,Go中的数组是使用括号语法读写的:

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

内置函数len接受一个数组并返回其长度:

fmt.Println(len(x))

在Go中很少显式地使用数组。这是因为它们有一个不寻常的限制:Go认为数组的大小是数组类型的一部分。这使得声明为[3]int的数组与声明为[4]int的数组类型不同。这也意味着不能使用变量来指定数组的大小,因为类型必须在编译时解析,而不是在运行时解析。此外,不能使用类型转换将不同大小的数组转换为相同类型。因为你不能把不同大小的数组相互转换,所以你不能编写一个函数来处理任何大小的数组,你也不能把不同大小的数组赋值给同一个变量。

由于这些限制,除非事先知道所需的确切长度,否则不要使用数组。例如,标准库中的一些加密函数返回数组,因为校验和的大小是作为算法的一部分定义的。这是例外,不是惯例。

2. Slices

大多数情况下,需要一个保存值序列的数据结构时,应该使用切片。切片之所以如此有用,是因为长度不是切片类型的一部分。这消除了数组的限制。可以编写一个函数来处理任何大小的切片,并且可以根据需要增长切片。

使用切片看起来很像使用数组,但是有细微的区别。首先要注意的是,在声明slice时没有指定它的大小:

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

这将使用slice字面量创建一个包含3个int型的slice。就像数组一样,我们也可以在切片字面量中只指定带有值的下标:

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

可以模拟多维切片,制作切片的切片:

 var x [][]int

可以使用括号语法读写切片:

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

到目前为止,切片似乎与数组相同。当我们在不使用字面量的情况下声明切片时,我们开始看到数组和切片之间的区别:

var x []int

这将创建一个int类型的切片。这是我们以前从未见过的:nil。它与其他语言中的null略有不同。在Go中,nil是一个标识符,表示某些类型缺少值。就像我们在前一章看到的无类型数值常量一样,nil没有类型,所以它可以被赋值或与不同类型的值进行比较。nil切片不包含任何内容。nil 切片没有指向任何有效的底层数组,长度(len)和容量(cap)都是 0。但是 nil 切片和空切片(make([]int, 0) 或 []int{})是不同的。nil 切片在没有被分配空间之前不占用内存,而空切片虽然长度为0,但是已经有了一个指向底层数组的指针,这个数组的长度为 0。

切片是我们看到的第一种不可比较的类型。使用==来查看两个片是否相同或使用!=来查看它们是否不同是编译时错误。唯一可以比较slice的是nil:

 fmt.Println(x == nil) // prints true

2.1 len

Go提供了几个内置函数来处理其内置类型。切片也有几个内置函数。在查看数组时,我们已经看到了内置的len函数。它也适用于切片,当你传递一个nil切片给len时,它返回0。

2.2 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函数实际上是将切片的副本传递给该函数。该函数将值添加到切片的副本中,并返回副本。然后将返回的切片赋值给调用函数中的变量。

2.3 Capacity

切片是一个值序列。片中的每个元素都被分配到连续的内存位置,这使得读取或写入这些值的速度很快。每个片都有一个容量,即保留的连续内存位置的数量。这可以大于长度。每次追加到一个片时,一个或多个值将被添加到片的末尾。每增加一个值,长度增加一个。当长度达到容量时,没有更多的空间可以放置值。如果您尝试在长度等于容量时添加附加值,则append函数将使用Go runtime分配具有更大容量的新切片。将原始片中的值复制到新片中,将新值添加到末尾,并返回新片。

当一个切片通过append增长时,Go runtime需要花费时间来分配新内存并将现有数据从旧内存复制到新内存。旧内存也需要进行垃圾回收。由于这个原因,Go runtime通常在每次耗尽容量时将片增加一个以上。

就像内置的len函数返回切片的当前长度一样,内置的cap函数返回切片的当前容量。它的使用频率远低于len。大多数情况下,cap用于检查片是否大到足以容纳新数据,或者是否需要进行调用来创建新片。

向切片添加元素是如何改变长度和容量的。例如:

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))

结果如下图:

虽然切片自动增长很好,但一次调整它们的大小要有效得多。如果知道计划将多少东西放入一个片中,那么就使用正确的初始容量创建片。可以使用make函数来实现。

2.4 make

上述声明切片的方法不允许创建已经指定了长度或容量的空片。这是内置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(在添加第六个元素后,容量翻了一番)。

还可以使用make命令指定初始容量:

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

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

也可以创建一个长度为零,但容量大于零的切片:

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

在这种情况下,我们有一个非空切片,长度为0,但容量为10。由于长度为0,我们不能直接索引它,但我们可以向它添加值:

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

永远不要指定小于长度的容量

2.5 Declaring Your Slice

如何选择使用哪种片声明风格呢?主要目标是最小化片需要增长的次数。如果片可能根本不需要增长(因为你的函数可能什么都不返回),使用没有赋值的var声明来创建一个nil片,如下所示:

var data []int

如果有一些初始值,或者如果切片的值不会改变,那么 slice literal是一个很好的选择。

data := []int{2, 4, 6, 8}

如果很清楚切片需要多大,但在编写程序时不知道这些值是多少,则使用make。那么问题就变成是应该在调用make时指定非零长度,还是指定零长度和非零容量。有三种可能性:

  1. 如果您使用切片作为缓冲区,则指定一个非零长度。
  2. 如果确定知道所需的确切大小,则可以指定切片的长度和索引来设置值。这通常是在转换一个切片中的值并在第二个切片中存储它们时完成的。这种方法的缺点是,如果大小错误,将在切片的末尾得到零值,或者在尝试访问不存在的元素时产生恐慌。
  3. 在其他情况下,使用零长度和指定容量的make。这允许您使用append向切片添加项。如果项目的数量变小了,最后就不会有多余的零值。如果项目的数量较大,您的代码将不会出现panic。

Go社区分为第二种和第三种方法。我个人更喜欢对初始化为零长度的切片使用append。在某些情况下,它可能会慢一些,但它不太可能引入错误。

2.6 Slicing Slices

切片表达式(slice expression)从一个切片创建一个切片。它写在括号内,由开始偏移量和结束偏移量组成,中间用冒号(:)分隔。如果省略起始偏移量,则假定为0。同样,如果省略结束偏移量,则替换片的末尾。例如:

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)

y := x[startIndex:endIndex]
将 x 中从下标 startIndex 到 endIndex-1 下的元素创建为一个新的切片。
y := x[startIndex:]
默认 endIndex 时将表示一直到x的最后一个元素。
y := x[:endIndex]
默认 startIndex 时将表示从 arr 的第一个元素开始。

2.6.1 Slices share storage sometimes

当你从一个切片中取出一个切片时,你并不是在复制数据。相反,现在有两个共享内存的变量。这意味着对切片中某个元素的更改会影响共享该元素的所有切片。

    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会改变y和z,而改变y和z会改变x。

当与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)


每当从另一个片中取出一个片时,子片的容量被设置为原始片的容量,减去原始片中子片的偏移量。当你从一个切片中再切出一个子切片时,子切片的容量被设置为原始切片的容量减去子切片在原始切片中的偏移量。这意味着原始片中任何未使用的容量也将与任何子片共享。
当我们从x创建y切片时,长度被设置为2,但容量被设置为4,与x相同。由于容量为4,附加到y的末尾将值置于x的第三个位置。

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)

在 Go 中,子切片的容量是从它的起始位置到原始切片x底层数组的末尾。y的起始位置是 x[0],即从原始切片的开始位置截取。所以y的容量是5,z的起始位置是x[2],所以z的容量是5-2=3。

为了避免复杂的切片情况,永远不要在子切片中使用append,或者通过使用完整的切片表达式确保append不会导致覆盖。这有点奇怪,但它清楚地表明父片和子片之间共享了多少内存。完整切片表达式包括第三部分,它指示父切片容量中可供子切片使用的最后一个位置。从这个数字减去开始偏移量,得到子片的容量。

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

y和z的容量都是2。因为我们将子片的容量限制为它们的长度,所以在y和z上附加额外的元素会创建不与其他片交互的新片。这段代码运行后,x被设置为[1 2 3 4 60],y被设置为[1 2 30 40 50],z被设置为[3 4 70]。

2.7 Converting Arrays to Slices

切片并不是唯一可以切的东西。如果有一个数组,您可以使用切片表达式从中获取切片。这是一种将数组连接到只接受切片的函数的有用方法。但是,请注意,从数组中获取切片与从切片中获取切片具有相同的内存共享属性。例如:

 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)

2.8 copy

如果需要创建独立于原始切片的切片,请使用内置的复制函数。例如:

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

copy函数接受两个形参。第一个是目标片,第二个是源片。它将尽可能多的值从源复制到目标,受哪个片更小的限制,并返回复制的元素数量。x和y的容量无关紧要,长度才是最重要的。不需要复制整个切片。下面的代码将四元素切片的前两个元素复制到双元素切片中:

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

可以通过获取数组的切片来对数组使用复制。可以将数组作为复制的源或目标。例如下列代码:

 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中。这产生了输出:

3. Strings and Runes and Bytes

已经讨论了切片,可以回过头来再看字符串。可能有人认为go中的字符串是由runes组成的,但事实并非如此。在幕后,Go使用一个字节序列来表示字符串。这些字节不必采用任何特定的字符编码,但是一些Go库函数(以及我们将在下一章讨论的for范围循环)假设字符串由一系列utf-8编码的码点组成。

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

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

与数组和切片一样,字符串的索引也是从零开始的,在这个例子中,b被赋值为s中的第七个值,即t。
用于数组和切片的切片表达式表示法也适用于字符串:

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

这将“o t”分配给s2,“Hello”分配给s3,“there”分配给s4。

虽然Go允许我们使用切片表示法来创建子字符串,并使用索引表示法从字符串中提取单个条目,这很方便,但在这样做时应该非常小心。由于字符串是不可变的,所以它们不存在切片的切片所存在的修改问题。不过,还有一个不同的问题。字符串由字节序列组成,而UTF-8中的代码点可以是1到4个字节长的任何地方。我们前面的示例完全由UTF-8中一个字节长的代码点组成,所以一切都如预期的那样进行。但在处理英语或表情符号以外的语言时,你会遇到在UTF-8中有多个字节长的代码点:

Go允许将字符串传递给内置的len函数来查找字符串的长度。考虑到字符串索引和切片表达式以字节为单位计算位置,返回的长度以字节为单位,而不是以代码点为单位,这并不奇怪:

这段代码输出10,而不是7,因为在UTF-8中用笑脸表情符号表示太阳需要4个字节。

由于符文、字符串和字节之间的这种复杂关系,Go在这些类型之间有一些有趣的类型转换。单个符文或字节可以转换为字符串:

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

一个常见的错误是试图通过使用类型转换将int转换为字符串:

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

这导致y的值为“A”,而不是“65”。从Go 1.15开始,Go vet将阻止从rune或byte以外的任何整数类型转换为字符串。

字符串可以来回转换为slice of bytes 或 slice of runes。

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


第一个输出行将字符串转换为UTF-8字节。第二个将字符串转换为runes。

Go中的大多数数据都是作为字节序列读写的,因此最常见的字符串类型转换是使用字节片进行来回转换。runes的切片并不常见。

不应该对字符串使用切片和索引表达式,而应该使用标准库中的字符串和unicode/utf8包中的函数从字符串中提取子字符串和代码点。

4. Maps

切片在具有顺序数据时非常有用。像大多数语言一样,Go提供了一个内置的数据类型,用于将一个值关联到另一个值的情况。映射类型写为map[keyType]valueType。让我们看一下几种声明映射的方法。首先,你可以使用var声明来创建一个map变量,它的值被设置为0:

var nilMap map[string]int

在这种情况下,nilMap被声明为具有字符串键和int值的映射。映射的零值是nil。nil映射的长度为0。尝试读取nil映射总是返回映射值类型的零值。但是,试图写入nil映射变量会导致panic。
我们可以使用:=声明通过为map变量赋值一个map字面量来创建一个map变量:

 totalWins := map[string]int{}

在本例中,我们使用的是空map字面量。这和nil映射不一样。它的长度为0,但您可以读写分配给空map文字的map。下面是一个非空的map字面量:

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

map literal 的主体被写成键,后面跟着冒号(😃,然后是值。映射中的每个键值对之间用逗号分隔,甚至在最后一行也是如此。在本例中,该值是一个字符串切片。映射中值的类型可以是任何东西。对键的类型有一些限制,我们稍后会讨论。
如果你知道你打算在映射中放入多少键值对,但不知道确切的值,你可以使用make来创建一个默认大小的映射:

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

使用make创建的map的长度仍然为0,并且它们可以超过最初指定的大小。
map在几个方面类似于切片:

  1. 当向map添加键值对时,map会自动增长。
  2. 如果知道计划在map中插入多少个键值对,则可以使用make创建具有特定初始大小的映射。
  3. 将map传递给len函数告诉您map中的键值对的数量。
  4. 映射的零值是nil。
  5. map没有可比性。您可以检查它们是否等于nil,但是您不能使用==检查两个映射是否具有相同的键和值,或者使用!=检查两个map是否具有不同的键和值。
posted @ 2024-09-15 15:12  yyyyyllll  阅读(24)  评论(0)    收藏  举报