Go语言 数组、切片和映射 - Go中的首要容器类型

2023-02-16 17:37 更新

在严格意义上,Go中有三种一等公民容器类型:数组、切片和映射。 有时候,我们可以认为字符串类型和通道类型也属于容器类型。 但是,此篇文章只谈及数组、切片和映射类型。

Go中有很多和容器类型相关的细节,本文将逐一列出这些细节。

容器类型和容器值概述

每个容器(值)用来表示和存储一个元素(element)序列或集合。一个容器中的所有元素的类型是相同的。此相同的类型称为此容器的类型的元素类型(或简称此容器的元素类型)。

存储在一个容器中的每个元素值都关联着一个键值(key)。每个元素可以通过它的键值而被访问到。 一个映射类型的键值类型必须为一个可比较类型。 数组和切片类型的键值类型均为内置类型int。 一个数组或切片的一个元素对应的键值总是一个非负整数下标,此非负整数表示该元素在该数组或切片所有元素中的顺序位置。此非负整数下标亦常称为一个元素索引(index)。

每个容器值有一个长度属性,用来表明此容器中当前存储了多少个元素。 一个数组或切片中的每个元素所关联的非负整数索引键值的合法取值范围为左闭右开区间[0, 此数组或切片的长度)。 一个映射值类型的容器值中的元素关联的键值可以是任何此映射类型的键值类型的任何值。

这三种容器类型的值在使用上有很多的差别。这些差别多源于它们的内存结构的差异。 通过上一篇文章值部,我们得知每个数组值仅由一个直接部分组成,而一个切片或者映射值是由一个直接部分和一个可能的被此直接部分引用着的间接部分组成。

一个数组或者切片的所有元素紧挨着存放在一块连续的内存中。一个数组中的所有元素均存放在此数组值的直接部分,一个切片中的所有元素均存放在此切片值的间接部分。 在官方标准编译器和运行时中,映射是使用哈希表算法来实现的。所以一个映射中的所有元素也均存放在一块连续的内存中,但是映射中的元素并不一定紧挨着存放。 另外一种常用的映射实现算法是二叉树算法。无论使用何种算法,一个映射中的所有元素的键值也存放在此映射值(的间接部分)中。

我们可以通过一个元素的键值来访问此元素。 对于这三种容器,元素访问的时间复杂度均为O(1)。 但是一般来说,映射元素访问消耗的时长要数倍于数组和切片元素访问消耗的时长。 但是映射相对于数组和切片有两个优点:

  • 映射的键值类型可以是任何可比较类型。
  • 对于大多数元素为零值的情况,使用映射可以节省大量的内存。

从上一篇文章中,我们已经了解到,在任何赋值中,源值的底层间接部分不会被复制。 换句话说,当一个赋值结束后,一个含有间接部分的源值和目标值将共享底层间接部分。 这就是数组和切片/映射值会有很多行为差异(将在下面逐一介绍)的原因。

无名容器类型的字面表示形式

无名容器类型的字面表示形式如下:

  • 数组类型:[N]T
  • 切片类型:[]T
  • 映射类型:map[K]T

其中,

  • T可为任意类型。它表示一个容器类型的元素类型。某个特定容器类型的值中只能存储此容器类型的元素类型的值。
  • N必须为一个非负整数常量。它指定了一个数组类型的长度,或者说它指定了此数组类型的任何一个值中存储了多少个元素。 一个数组类型的长度是此数组类型的一部分。比如[5]int[8]int是两个不同的类型。
  • K必须为一个可比较类型。它指定了一个映射类型的键值类型。

下面列出了一些无名容器类型的字面表示:

const Size = 32

type Person struct {
	name string
	age  int
}

// 数组类型
[5]string
[Size]int
[16][]byte  // 元素类型为一个切片类型:[]byte
[100]Person // 元素类型为一个结构体类型:Person

// 切片类型
[]bool
[]int64
[]map[int]bool // 元素类型为一个映射类型:map[int]bool
[]*int         // 元素类型为一个指针类型:*int

// 映射类型
map[string]int
map[int]bool
map[int16][6]string     // 元素类型为一个数组类型:[6]string
map[bool][]string       // 元素类型为一个切片类型:[]string
map[struct{x int}]*int8 // 元素类型为一个指针类型:*int8;
                        // 键值类型为一个结构体类型。

所有切片类型的尺寸都是一致的,所有映射类型的尺寸也都是一致的。 一个数组类型的尺寸等于它的元素类型的尺寸和它的长度的乘积。长度为零的数组的尺寸为零;元素类型尺寸为零的任意长度的数组类型的尺寸也为零。

容器字面量的表示形式

和结构体值类似,容器值的文字表示也可以用组合字面量形式(composite literal)来表示。 比如对于一个容器类型T,它的值可以用形式T{...}来表示(除了切片和映射的零值外)。 下面是一些容器字面量:

// 一个含有4个布尔元素的数组值。
[4]bool{false, true, true, false}

// 一个含有三个字符串值的切片值。
[]string{"break", "continue", "fallthrough"}

// 一个映射值。
map[string]int{"C": 1972, "Python": 1991, "Go": 2009}

映射组合字面量中大括号中的每一项称为一个键值对(key-value pair),或者称为一个条目(entry)。

数组和切片组合字面量有一些微小的变种:

// 下面这些切片字面量都是等价的。
[]string{"break", "continue", "fallthrough"}
[]string{0: "break", 1: "continue", 2: "fallthrough"}
[]string{2: "fallthrough", 1: "continue", 0: "break"}
[]string{2: "fallthrough", 0: "break", "continue"}

// 下面这些数组字面量都是等价的。
[4]bool{false, true, true, false}
[4]bool{0: false, 1: true, 2: true, 3: false}
[4]bool{1: true, true}
[4]bool{2: true, 1: true}
[...]bool{false, true, true, false}
[...]bool{3: false, 1: true, true}

上例中最后两行中的...表示让编译器推断出相应数组值的类型的长度。

从上面的例子中,我们可以看出数组和切片组合字面量中的索引下标(即数组和切片的键值)是可选的。 在一个数组或者切片组合字面量中:

  • 如果一个索引下标出现,它的类型不必是数组和切片类型的键值类型int,但它必须是一个可以表示为int值的非负常量; 如果它是一个类型确定值,则它的类型必须为一个基本整数类型。
  • 在一个数组或切片组合字面量中,如果一个元素的索引下标缺失,则编译器认为它的索引下标为出现在它之前的元素的索引下标加一。
  • 如果出现的第一个元素的索引下标缺失,则它的索引下标被认为是0。

映射组合字面量中元素对应的键值不可缺失,并且它们可以为非常量。

var a uint = 1
var _ = map[uint]int {a : 123} // 没问题
var _ = []int{a: 100}          // error: 下标必须为常量
var _ = [5]int{a: 100}         // error: 下标必须为常量

一个容器组合字面量中的常量键值(包括索引下标)不可重复

容器类型零值的字面量表示形式

和结构体类似,一个数组类型A的零值可以表示为A{}。 比如,数组类型[100]int的零值可以表示为[100]int{}。 一个数组零值中的所有元素均为对应数组元素类型的零值。

和指针一样,所有切片和映射类型的零值均用预声明的标识符nil来表示。

顺便说一句,除了刚提到的三种类型,以后将介绍的函数、通道和接口类型的零值也用预声明的标识符nil来表示。

在运行时刻,即使一个数组变量在声明的时候未指定初始值,它的元素所占的内存空间也已经被开辟出来。 但是一个nil切片或者映射值的元素的内存空间尚未被开辟出来。

注意:[]T{}表示类型[]T的一个空切片值,它和[]T(nil)是不等价的。 同样,map[K]T{}map[K]T(nil)也是不等价的。

容器字面量是不可寻址的但可以被取地址

我们已经了解到结构体(组合)字面量是不可寻址的但却是可以被取地址的。 容器字面量也不例外。

一个例子:

package main

import "fmt"

func main() {
	pm := &map[string]int{"C": 1972, "Go": 2009}
	ps := &[]string{"break", "continue"}
	pa := &[...]bool{false, true, true, false}
	fmt.Printf("%T\n", pm) // *map[string]int
	fmt.Printf("%T\n", ps) // *[]string
	fmt.Printf("%T\n", pa) // *[4]bool
}

内嵌组合字面量可以被简化

在某些情形下,内嵌在其它组合字面量中的组合字面量可以简化为{...}(即类型部分被省略掉了)。 内嵌组合字面量前的取地址操作符&有时也可以被省略。

比如,下面的组合字面量

// heads为一个切片值。它的类型的元素类型为*[4]byte。
// 此元素类型为一个基类型为[4]byte的指针类型。
// 此指针基类型为一个元素类型为byte的数组类型。
var heads = []*[4]byte{
	&[4]byte{'P', 'N', 'G', ' '},
	&[4]byte{'G', 'I', 'F', ' '},
	&[4]byte{'J', 'P', 'E', 'G'},
}

可以被简化为

var heads = []*[4]byte{
	{'P', 'N', 'G', ' '},
	{'G', 'I', 'F', ' '},
	{'J', 'P', 'E', 'G'},
}

下面这个数组组合字面量

type language struct {
	name string
	year int
}

var _ = [...]language{
	language{"C", 1972},
	language{"Python", 1991},
	language{"Go", 2009},
}

可以被简化为

var _ = [...]language{
	{"C", 1972},
	{"Python", 1991},
	{"Go", 2009},
}

下面这个映射组合字面量

type LangCategory struct {
	dynamic bool
	strong  bool
}

// 此映射值的类型的键值类型为一个结构体类型,
// 元素类型为另一个映射类型:map[string]int。
var _ = map[LangCategory]map[string]int{
	LangCategory{true, true}: map[string]int{
		"Python": 1991,
		"Erlang": 1986,
	},
	LangCategory{true, false}: map[string]int{
		"JavaScript": 1995,
	},
	LangCategory{false, true}: map[string]int{
		"Go":   2009,
		"Rust": 2010,
	},
	LangCategory{false, false}: map[string]int{
		"C": 1972,
	},
}

可以被简化为

var _ = map[LangCategory]map[string]int{
	{true, true}: {
		"Python": 1991,
		"Erlang": 1986,
	},
	{true, false}: {
		"JavaScript": 1995,
	},
	{false, true}: {
		"Go":   2009,
		"Rust": 2010,
	},
	{false, false}: {
		"C": 1972,
	},
}

注意,在上面的几个例子中,最后一个元素后的逗号不能被省略。原因详见后面的断行规则一文。

容器值的比较

Go类型系统概述一文中,我们已经了解到映射和切片类型都属于不可比较类型。 所以任意两个映射值(或切片值)是不能相互比较的。

尽管两个映射值和切片值是不能比较的,但是一个映射值或者切片值可以和预声明的nil标识符进行比较以检查此映射值或者切片值是否为一个零值。

大多数数组类型都是可比较类型,除了元素类型为不可比较类型的数组类型。

当比较两个数组值时,它们的对应元素将按照逐一被比较(可以认为按照下标顺序比较)。这两个数组只有在它们的对应元素都相等的情况下才相等;当一对元素被发现不相等的或者在比较中产生恐慌的时候,对数组的比较将提前结束。

一个例子:

package main

import "fmt"

func main() {
	var a [16]byte
	var s []int
	var m map[string]int

	fmt.Println(a == a)   // true
	fmt.Println(m == nil) // true
	fmt.Println(s == nil) // true
	fmt.Println(nil == map[string]int{}) // false
	fmt.Println(nil == []int{})          // false

	// 下面这些行编译不通过。
	/*
	_ = m == m
	_ = s == s
	_ = m == map[string]int(nil)
	_ = s == []int(nil)
	var x [16][]int
	_ = x == x
	var y [16]map[int]bool
	_ = y == y
	*/
}

查看容器值的长度和容量

除了上面已提到的容器长度属性(此容器中含有有多少个元素),每个容器值还有一个容量属性。 一个数组值的容量总是和它的长度相等;一个非零映射值的容量可以被认为是无限大的。切片值的容量的含义将在后续章节介绍。 一个切片值的容量总是不小于此切片值的长度。在编程中,只有切片值的容量有实际意义。

我们可以调用内置函数len来获取一个容器值的长度,或者调用内置函数cap来获取一个容器值的容量。 这两个函数都返回一个int类型确定结果值或者一个默认类型为int的类型不确定结果,具体取决于传递给它们的实参是否为常量表达式。 因为非零映射值的容量是无限大,所以cap并不适用于映射值。

一个数组值的长度和容量永不改变。同一个数组类型的所有值的长度和容量都总是和此数组类型的长度相等。 切片值的长度和容量可在运行时刻改变(一般只能通过被赋值的途径来修改,两者一般不可单独被修改)。 因为此原因,切片可以被认为是动态数组。 切片在使用上相比数组更为灵活,所以切片(相对数组)在编程用得更为广泛。

一个例子:

package main

import "fmt"

func main() {
	var a [5]int
	fmt.Println(len(a), cap(a)) // 5 5
	var s []int
	fmt.Println(len(s), cap(s)) // 0 0
	s, s2 := []int{2, 3, 5}, []bool{}
	fmt.Println(len(s), cap(s), len(s2), cap(s2)) // 3 3 0 0
	var m map[int]bool
	fmt.Println(len(m)) // 0
	m, m2 := map[int]bool{1: true, 0: false}, map[int]int{}
	fmt.Println(len(m), len(m2)) // 2 0
}

上面这个特定的例子中的每个切片值的长度和容量都相等,但这并不是一个普遍定律。 我们将在后面的章节中展示一些长度和容量不相等的切片值。

读取和修改容器的元素

一个容器值v中存储的对应着键值k的元素用语法形式v[k]来表示。 今后我们称v[k]为一个元素索引表达式。

假设v是一个数组或者切片,在v[k]中,

  • 如果k是一个常量,则它必须满足上面列出的对出现在组合字面量中的索引的要求。 另外,如果v是一个数组,则k必须小于此数组的长度。
  • 如果k不是一个常量,则它必须为一个整数。 另外它必须为一个非负数并且小于len(v),否则,在运行时刻将产生一个恐慌。
  • 如果v是一个零值切片,则在运行时刻将产生一个恐慌。

假设v是一个映射值,在v[k]中,k的类型必须为(或者可以隐式转换为)v的类型的元素类型。

另外,

  • 如果k是一个动态类型为不可比较类型的接口值,则v[k]在运行时刻将造成一个恐慌;
  • 如果v[k]被用做一个赋值语句中的目标值并且v是一个零值nil映射,则v[k]在运行时刻将造成一个恐慌;
  • 如果v[k]用来表示读取映射值v中键值k对应的元素,则它无论如何都不会产生一个恐慌,即使v是一个零值nil映射(假设k的估值没有造成恐慌);
  • 如果v[k]用来表示读取映射值v中键值k对应的元素,并且映射值v中并不含有对应着键值k的条目,则v[k]返回一个此映射值的类型的元素类型的零值。 一般情况下,v[k]被认为是一个单值表达式。但是在一个v[k]被用为唯一源值的赋值语句中,v[k]可以返回一个可选的第二个返回值。 此第二个返回值是一个类型不确定布尔值,用来表示是否有对应着键值k的条目存储在映射值v中。

一个展示了容器元素修改和读取的例子:

package main

import "fmt"

func main() {
	a := [3]int{-1, 0, 1}
	s := []bool{true, false}
	m := map[string]int{"abc": 123, "xyz": 789}
	fmt.Println (a[2], s[1], m["abc"])    // 读取
	a[2], s[1], m["abc"] = 999, true, 567 // 修改
	fmt.Println (a[2], s[1], m["abc"])    // 读取

	n, present := m["hello"]
	fmt.Println(n, present, m["hello"]) // 0 false 0
	n, present = m["abc"]
	fmt.Println(n, present, m["abc"]) // 567 true 567
	m = nil
	fmt.Println(m["abc"]) // 0

	// 下面这两行编译不通过。
	/*
	_ = a[3]  // 下标越界
	_ = s[-1] // 下标越界
	*/

	// 下面这几行每行都会造成一个恐慌。
	_ = a[n]         // panic: 下标越界
	_ = s[n]         // panic: 下标越界
	m["hello"] = 555 // panic: m为一个零值映射
}

重温一下切片的内部结构

为了更好的理解和解释切片类型和切片值,我们最好对切片的内部结构有一个基本的印象。 在上一篇文章值部中,我们已经了解到官方标准编译器对切片类型的内部定义大致如下:

type _slice struct {
	elements unsafe.Pointer // 引用着底层存储在间接部分上的元素
	len      int            // 长度
	cap      int            // 容量
}

虽然其它编译器中切片类型的内部结构可能并不完全和官方标准编译器一致,但应该大体上是相似的。 下面的解释均基于官方标准编译器对切片类型的内部定义。

上面展示的切片的内部定义为切片的直接部分的定义。直接部分的len字段表示一个切片当前存储了多少个元素;直接部分的cap表示一个切片的容量。 下面这张图描绘了一个切片值的内存布局。


尽管一个切片值的底层元素部分可能位于一个比较大的内存片段上,但是此切片值只能感知到此内存片段上的一个子片段。 比如,上图中的切片值只能感知到灰色的子片段。

在上图中,从下标len(包含)到下标cap(不包含)对应的元素并不属于图中所示的切片值。 它们只是此切片之中的一些冗余元素槽位,但是它们可能是其它切片(或者数组)值中的有效元素。

下一节将要介绍如何通过调用内置append函数来向一个基础切片添加元素而得到一个新的切片。 这个新的结果切片可能和这个基础切片共享起始元素,也可能不共享,具体取决于基础切片的容量(以及长度)和添加的元素数量。

当一个切片被用做一个append函数调用中的基础切片,

  • 如果添加的元素数量大于此(基础)切片的冗余元素槽位的数量,则一个新的底层内存片段将被开辟出来并用来存放结果切片的元素。 这时,基础切片和结果切片不共享任何底层元素。
  • 否则,不会有底层内存片段被开辟出来。这时,基础切片中的所有元素也同时属于结果切片。两个切片的元素都存放于同一个内存片段上。

下下一节将展示一张包含了上述两种情况的图片。

一些其它切片操作也可能会造成两个切片共享底层内存片段的情况。这些操作将在后续章节逐一介绍。

注意,一般我们不能单独修改一个切片值的某个内部字段,除非使用反射或者非类型安全指针。 换句话说,一般我们只能通过将其它切片赋值给一个切片来同时修改这个切片的三个字段。

容器赋值

当一个映射赋值语句执行完毕之后,目标映射值和源映射值将共享底层的元素。 向其中一个映射中添加(或从中删除)元素将体现在另一个映射中。

和映射一样,当一个切片赋值给另一个切片后,它们将共享底层的元素。它们的长度和容量也相等。 但是和映射不同,如果以后其中一个切片改变了长度或者容量,此变化不会体现到另一个切片中。

当一个数组被赋值给另一个数组,所有的元素都将被从源数组复制到目标数组。赋值完成之后,这两个数组不共享任何元素。

一个例子:

package main

import "fmt"

func main() {
	m0 := map[int]int{0:7, 1:8, 2:9}
	m1 := m0
	m1[0] = 2
	fmt.Println(m0, m1) // map[0:2 1:8 2:9] map[0:2 1:8 2:9]

	s0 := []int{7, 8, 9}
	s1 := s0
	s1[0] = 2
	fmt.Println(s0, s1) // [2 8 9] [2 8 9]

	a0 := [...]int{7, 8, 9}
	a1 := a0
	a1[0] = 2
	fmt.Println(a0, a1) // [7 8 9] [2 8 9]
}

添加和删除容器元素

向一个映射中添加一个条目的语法和修改一个映射元素的语法是一样的。 比如,对于一个非零映射值m,如果当前m中尚未存储条目(k, e),则下面的语法形式将把此条目存入m;否则,下面的语法形式将把键值k对应的元素值更新为e

m[k] = e

内置函数delete用来从一个映射中删除一个条目。比如,下面的delete调用将把键值k对应的条目从映射m中删除。 如果映射m中未存储键值为k的条目,则此调用为一个空操作,它不会产生一个恐慌,即使m是一个nil零值映射。

delete(m, k)

下面的例子展示了如何向一个映射添加和从一个映射删除条目。

package main

import "fmt"

func main() {
	m := map[string]int{"Go": 2007}
	m["C"] = 1972     // 添加
	m["Java"] = 1995  // 添加
	fmt.Println(m)    // map[C:1972 Go:2007 Java:1995]
	m["Go"] = 2009    // 修改
	delete(m, "Java") // 删除
	fmt.Println(m)    // map[C:1972 Go:2009]
}

注意,在Go 1.12之前,映射打印结果中的条目顺序并不固定,两次打印结果可能并不相同。

一个数组中的元素个数总是恒定的,我们无法向其中添加元素,也无法从其中删除元素。但是可寻址的数组值中的元素是可以被修改的。

我们可以通过调用内置append函数,以一个切片为基础,来添加不定数量的元素并返回一个新的切片。 此新的结果切片包含着基础切片中所有的元素和所有被添加的元素。 注意,基础切片并未被此append函数调用所修改。 当然,如果我们愿意(事实上在实践中常常如此),我们可以将结果切片赋值给基础切片以修改基础切片。

Go中并未提供一个内置方式来从一个切片中删除一个元素。 我们必须使用append函数和后面将要介绍的子切片语法一起来实现元素删除操作。 切片元素的删除和插入将在后面的更多切片操作一节中介绍。 本节仅展示如何使用append内置函数。

下面是一个如何使用append内置函数的例子。

package main

import "fmt"

func main() {
	s0 := []int{2, 3, 5}
	fmt.Println(s0, cap(s0)) // [2 3 5] 3
	s1 := append(s0, 7)      // 添加一个元素
	fmt.Println(s1, cap(s1)) // [2 3 5 7] 6
	s2 := append(s1, 11, 13) // 添加两个元素
	fmt.Println(s2, cap(s2)) // [2 3 5 7 11 13] 6
	s3 := append(s0)         // <=> s3 := s0
	fmt.Println(s3, cap(s3)) // [2 3 5] 3
	s4 := append(s0, s0...)  // 以s0为基础添加s0中所有的元素
	fmt.Println(s4, cap(s4)) // [2 3 5 2 3 5] 6

	s0[0], s1[0] = 99, 789
	fmt.Println(s2[0], s3[0], s4[0]) // 789 99 2
}

注意,内置append函数是一个变长参数函数(下下篇文章中介绍)。 它有两个参数,其中第二个参数(形参)为一个变长参数

变长参数函数将在下下篇文章中解释。目前,我们只需知道变长参数函数调用中的实参有两种传递方式。 在上面的例子中,第8行、第10行和第12行使用了同一种方式,第14行使用了另外一种方式。 在第一种方式中,零个或多个实参元素值可以传递给append函数的第二个形参。 在第二种方式中,一个(和第一个实参同元素类型的)实参切片传递给了第二个形参,此切片实参必须跟随三个点...。 关于变长参数函数调用,详见 下下篇文章

在上例中,第14行等价于

s4 := append(s0, s0[0], s0[1], s0[2])

8行等价于

s1 := append(s0, []int{7}...)

10行等价于

s2 := append(s1, []int{11, 13}...)

对于三个点方式,append函数并不要求第二个实参的类型和第一个实参一致,但是它们的元素类型必须一致。 换句话说,它们的底层类型必须一致。

在上面的程序中,

  • 8行的append函数调用将为结果切片s1开辟一段新的内存。 原因是切片s0中没有足够的冗余元素槽位来容纳新添加的元素。 第14行的append函数调用也是同样的情况。
  • 10行的append函数调用不会为结果切片s2开辟新的内存片段。 原因是切片s1中的冗余元素槽位足够容纳新添加的元素。

所以,上面的程序中在退出之前,切片s1s2共享一些元素,切片s0s3共享所有的元素。 下面这张图描绘了在上面的程序结束之前各个切片的状态。


请注意,当一个append函数调用需要为结果切片开辟内存时,结果切片的容量取决于具体编译器实现。 在这种情况下,对于官方标准编译器,如果基础切片的容量较小,则结果切片的容量至少为基础切片的两倍。 这样做的目的是使结果切片有足够多的冗余元素槽位,以防止此结果切片被用做后续其它append函数调用的基础切片时再次开辟内存。

上面提到了,在实际编程中,我们常常将append函数调用的结果赋值给基础切片。 比如:

package main

import "fmt"

func main() {
	var s = append([]string(nil), "array", "slice")
	fmt.Println(s)      // [array slice]
	fmt.Println(cap(s)) // 2
	s = append(s, "map")
	fmt.Println(s)      // [array slice map]
	fmt.Println(cap(s)) // 4
	s = append(s, "channel")
	fmt.Println(s)      // [array slice map channel]
	fmt.Println(cap(s)) // 4
}

截至目前(Go 1.19),append函数调用的第一个实参不能为类型不确定的nil

使用内置make函数来创建切片和映射

除了使用组合字面量来创建映射和切片,我们还可以使用内置make函数来创建映射和切片。 数组不能使用内置make函数来创建。

顺便说一句,内置make函数也可以用来创建以后将要介绍的通道值。

假设M是一个映射类型并且n是一个整数,我们可以用下面的两种函数调用来各自生成一个类型为M的映射值。

make(M, n)
make(M)

第一个函数调用形式创建了一个可以容纳至少n个条目而无需再次开辟内存的空映射值。 第二个函数调用形式创建了一个可以容纳一个小数目的条目而无需再次开辟内存的空映射值。此小数目的值取决于具体编译器实现。

注意:第二个参数n可以为负或者零,这时对应的调用将被视为上述第二种调用形式。

假设S是一个切片类型,lengthcapacity是两个非负整数,并且length小于等于capacity,我们可以用下面的两种函数调用来各自生成一个类型为S的切片值。lengthcapacity的类型必须均为整数类型(两者可以不一致)。

make(S, length, capacity)
make(S, length) // <=> make(S, length, length)

第一个函数调用创建了一个长度为length并且容量为capacity的切片。 第二个函数调用创建了一个长度为length并且容量也为length的切片。

使用make函数创建的切片中的所有元素值均被初始化为(结果切片的元素类型的)零值。

下面是一个展示了如何使用make函数来创建映射和切片的例子:

package main

import "fmt"

func main() {
	// 创建映射。
	fmt.Println(make(map[string]int)) // map[]
	m := make(map[string]int, 3)
	fmt.Println(m, len(m)) // map[] 0
	m["C"] = 1972
	m["Go"] = 2009
	fmt.Println(m, len(m)) // map[C:1972 Go:2009] 2

	// 创建切片。
	s := make([]int, 3, 5)
	fmt.Println(s, len(s), cap(s)) // [0 0 0] 3 5
	s = make([]int, 2)
	fmt.Println(s, len(s), cap(s)) // [0 0] 2 2
}

使用内置new函数来创建容器值

在前面的指针一文中,我们已经了解到内置new函数可以用来为一个任何类型的值开辟内存并返回一个存储有此值的地址的指针。 用new函数开辟出来的值均为零值。因为这个原因,new函数对于创建映射和切片值来说没有任何价值。

使用new函数来用来创建数组值并非是完全没有意义的,但是在实践中很少这么做,因为使用组合字面量来创建数组值更为方便。

一个使用new函数创建容器值的例子:

package main

import "fmt"

func main() {
	m := *new(map[string]int)   // <=> var m map[string]int
	fmt.Println(m == nil)       // true
	s := *new([]int)            // <=> var s []int
	fmt.Println(s == nil)       // true
	a := *new([5]bool)          // <=> var a [5]bool
	fmt.Println(a == [5]bool{}) // true
}

容器元素的可寻址性

一些关于容器元素的可寻址性的事实:

  • 如果一个数组是可寻址的,则它的元素也是可寻址的;反之亦然,即如果一个数组是不可寻址的,则它的元素也是不可寻址的。 原因很简单,因为一个数组只含有一个(直接)值部,并且它的所有元素和此直接值部均承载在同一个内存块上。
  • 一个切片值的任何元素都是可寻址的,即使此切片本身是不可寻址的。 这是因为一个切片的底层元素总是存储在一个被开辟出来的内存片段(间接值部)上。
  • 任何映射元素都是不可寻址的。原因详见此条问答

一个例子:

package main

import "fmt"

func main() {
	a := [5]int{2, 3, 5, 7}
	s := make([]bool, 2)
	pa2, ps1 := &a[2], &s[1]
	fmt.Println(*pa2, *ps1) // 5 false
	a[2], s[1] = 99, true
	fmt.Println(*pa2, *ps1) // 99 true
	ps0 := &[]string{"Go", "C"}[0]
	fmt.Println(*ps0) // Go

	m := map[int]bool{1: true}
	_ = m
	// 下面这几行编译不通过。
	/*
	_ = &[3]int{2, 3, 5}[0]
	_ = &map[int]bool{1: true}[1]
	_ = &m[1]
	*/
}

一般来说,一个不可寻址的值的直接部分是不可修改的。但是映射元素是个例外。 映射元素虽然不可寻址,但是每个映射元素可以被整个修改(但不能被部分修改)。 对于大多数做为映射元素类型的类型,在修改它们的值的时候,一般体现不出来整个修改和部分修改的差异。 但是如果一个映射的元素类型为数组或者结构体类型,这个差异是很明显的。

在上一篇文章值部中,我们了解到每个数组或者结构体值都是仅含有一个直接部分。所以

  • 如果一个映射类型的元素类型为一个结构体类型,则我们无法修改此映射类型的值中的每个结构体元素的单个字段。 我们必须整体地同时修改所有结构体字段。
  • 如果一个映射类型的元素类型为一个数组类型,则我们无法修改此映射类型的值中的每个数组元素的单个元素。 我们必须整体地同时修改所有数组元素。

一个例子:

package main

import "fmt"

func main() {
	type T struct{age int}
	mt := map[string]T{}
	mt["John"] = T{age: 29} // 整体修改是允许的
	ma := map[int][5]int{}
	ma[1] = [5]int{1: 789} // 整体修改是允许的

	// 这两个赋值编译不通过,因为部分修改一个映射
	// 元素是非法的。这看上去确实有些反直觉。
	/*
	ma[1][1] = 123      // error
	mt["John"].age = 30 // error
	*/

	// 读取映射元素的元素或者字段是没问题的。
	fmt.Println(ma[1][1])       // 789
	fmt.Println(mt["John"].age) // 29
}

为了让上例中的两行编译不通过的两行赋值语句编译通过,欲修改的映射元素必须先存放在一个临时变量中,然后修改这个临时变量,最后再用这个临时变量整体覆盖欲修改的映射元素。比如:

package main

import "fmt"

func main() {
	type T struct{age int}
	mt := map[string]T{}
	mt["John"] = T{age: 29}
	ma := map[int][5]int{}
	ma[1] = [5]int{1: 789}

	t := mt["John"] // 临时变量
	t.age = 30
	mt["John"] = t // 整体修改

	a := ma[1] // 临时变量
	a[1] = 123
	ma[1] = a // 整体修改

	fmt.Println(ma[1][1], mt["John"].age) // 123 30
}

注意:刚提到的这个限制可能会在以后被移除

从数组或者切片派生切片(取子切片)

我们可以从一个基础切片或者一个可寻址的基础数组派生出另一个切片。此派生操作也常称为一个取子切片操作。 派生出来的切片的元素和基础切片(或者数组)的元素位于同一个内存片段上。或者说,派生出来的切片和基础切片(或者数组)将共享一些元素。

Go中有两种取子切片的语法形式(假设baseContainer是一个切片或者数组):

baseContainer[low : high]       // 双下标形式
baseContainer[low : high : max] // 三下标形式

上面所示的双下标形式等价于下面的三下标形式:

baseContainer[low : high : cap(baseContainer)]

所以双下标形式是三下标形式的特例。在实践中,双下标形式使用得相对更为广泛。

(注意:三下标形式是从Go 1.2开始支持的。)

上面所示的取子切片表达式的语法形式中的下标必须满足下列关系,否则代码要么编译不通过,要么在运行时刻将造成恐慌。

// 双下标形式
0 <= low <= high <= cap(baseContainer)

// 三下标形式
0 <= low <= high <= max <= cap(baseContainer)

不满足上述关系的取子切片表达式要么编译不通过,要么在运行时刻将导致一个恐慌。

注意:

  • 只要上述关系均满足,下标lowhigh都可以大于len(baseContainer)。但是它们一定不能大于cap(baseContainer)
  • 如果baseContainer是一个零值nil切片,只要上面所示的子切片表达式中下标的值均为0,则这两个子切片表达式不会造成恐慌。 在这种情况下,结果切片也是一个nil切片。

子切片表达式的结果切片的长度为high - low、容量为max - low。 派生出来的结果切片的长度可能大于基础切片的长度,但结果切片的容量绝不可能大于基础切片的容量。

在实践中,我们常常在子切片表达式中省略若干下标,以使代码看上去更加简洁。省略规则如下:

  • 如果下标low为零,则它可被省略。此条规则同时适用于双下标形式和三下标形式。
  • 如果下标high等于len(baseContainer),则它可被省略。此条规则同时只适用于双下标形式。
  • 三下标形式中的下标max在任何情况下都不可被省略。

比如,下面的子切片表达式都是相互等价的:

baseContainer[0 : len(baseContainer)]
baseContainer[: len(baseContainer)]
baseContainer[0 :]
baseContainer[:]
baseContainer[0 : len(baseContainer) : cap(baseContainer)]
baseContainer[: len(baseContainer) : cap(baseContainer)]

一个使用了子切片语法的例子:

package main

import "fmt"

func main() {
	a := [...]int{0, 1, 2, 3, 4, 5, 6}
	s0 := a[:]     // <=> s0 := a[0:7:7]
	s1 := s0[:]    // <=> s1 := s0
	s2 := s1[1:3]  // <=> s2 := a[1:3]
	s3 := s1[3:]   // <=> s3 := s1[3:7]
	s4 := s0[3:5]  // <=> s4 := s0[3:5:7]
	s5 := s4[:2:2] // <=> s5 := s0[3:5:5]
	s6 := append(s4, 77)
	s7 := append(s5, 88)
	s8 := append(s7, 66)
	s3[1] = 99
	fmt.Println(len(s2), cap(s2), s2) // 2 6 [1 2]
	fmt.Println(len(s3), cap(s3), s3) // 4 4 [3 99 77 6]
	fmt.Println(len(s4), cap(s4), s4) // 2 4 [3 99]
	fmt.Println(len(s5), cap(s5), s5) // 2 2 [3 99]
	fmt.Println(len(s6), cap(s6), s6) // 3 4 [3 99 77]
	fmt.Println(len(s7), cap(s7), s7) // 3 4 [3 4 88]
	fmt.Println(len(s8), cap(s8), s8) // 4 4 [3 4 88 66]
}

下面这张图描绘了上面的程序在退出之前各个数组和切片的状态。


从这张图片可以看出,切片s7s8共享存储它们的元素的底层内存片段,其它切片和数组a共享同一个存储元素的内存片段。

请注意,子切片操作有可能会造成暂时性的内存泄露。 比如,下面在这个函数中开辟的内存块中的前50个元素槽位在它的调用返回之后将不再可见。 这50个元素槽位所占内存浪费了,这属于暂时性的内存泄露。 当这个函数中开辟的内存块今后不再被任何切片所引用,此内存块将被回收,这时内存才不再继续泄漏。

func f() []int {
	s := make([]int, 10, 100)
	return s[50:60]
}

请注意,在上面这个函数中,子切片表达式中的起始下标(50)比s的长度(10)要大,这是允许的。

切片转化为数组指针

从Go 1.17开始,一个切片可以被转化为一个相同元素类型的数组的指针类型。 但是如果数组的长度大于被转化切片的长度,则将导致恐慌产生。 转换结果和被转化切片将共享底层元素。 一个例子:

package main

type S []int
type A [2]int
type P *A

func main() {
	var x []int
	var y = make([]int, 0)
	var x0 = (*[0]int)(x) // okay, x0 == nil
	var y0 = (*[0]int)(y) // okay, y0 != nil
	_, _ = x0, y0

	var z = make([]int, 3, 5)
	var _ = (*[3]int)(z) // okay
	var _ = (*[2]int)(z) // okay
	var _ = (*A)(z)      // okay
	var _ = P(z)         // okay

	var w = S(z)
	var _ = (*[3]int)(w) // okay
	var _ = (*[2]int)(w) // okay
	var _ = (*A)(w)      // okay
	var _ = P(w)         // okay

	var _ = (*[4]int)(z) // 会产生恐慌
}

切片转化为数组

从Go 1.20开始,一个切片可以被转化为一个相同元素类型的数组。 但是如果数组的长度大于被转化切片的长度,则将导致恐慌产生。 转换过程中将复制所需的元素,因此结果数组和被转化切片不共享底层元素。 一个例子:

package main

import "fmt"

func main() {
	var s = []int{0, 1, 2, 3}
	var a = [3]int(s[1:])
	s[2] = 9
	fmt.Println(s) // [0 1 9 3]
	fmt.Println(a) // [1 2 3]
	
	_ = [3]int(s[:2]) // panic
}

使用内置copy函数来复制切片元素

我们可以使用内置copy函数来将一个切片中的元素复制到另一个切片。 这两个切片的类型可以不同,但是它们的元素类型必须相同。 换句话说,这两个切片的类型的底层类型必须相同。 copy函数的第一个参数为目标切片,第二个参数为源切片。 传递给一个copy函数调用的两个实参可以共享一些底层元素。 copy函数返回复制了多少个元素,此值(int类型)为这两个切片的长度的较小值。

结合上一节介绍的子切片语法,我们可以使用copy函数来在两个数组之间或者一个数组与一个切片之间复制元素。

一个例子:

package main

import "fmt"

func main() {
	type Ta []int
	type Tb []int
	dest := Ta{1, 2, 3}
	src := Tb{5, 6, 7, 8, 9}
	n := copy(dest, src)
	fmt.Println(n, dest) // 3 [5 6 7]
	n = copy(dest[1:], dest)
	fmt.Println(n, dest) // 2 [5 5 6]

	a := [4]int{} // 一个数组
	n = copy(a[:], src)
	fmt.Println(n, a) // 4 [5 6 7 8]
	n = copy(a[:], a[2:])
	fmt.Println(n, a) // 2 [7 8 7 8]
}

事实上,copy并不是一个基本函数。我们可以用append来实现它。

// 假设元素类型为T。
func Copy(dest, src []T) int {
	if len(dest) < len(src) {
		_ = append(dest[:0], src[:len(dest)]...)
		return len(dest)
	} else {
		_ = append(dest[:0], src...)
		return len(src)
	}
}

尽管copy函数不是一个基本函数,它比上面的用append的实现使用起来要方便得多。

从另外一个角度,我们也可以认为append不是一个基本函数,因为我们可以用makecopy函数来实现它。

注意,做为一个特例,copy函数可以用来将一个字符串中的字节复制到一个字节切片

截至目前(Go 1.19),copy函数调用的两个实参均不能为类型不确定的nil

遍历容器元素

在Go中,我们可以使用下面的语法形式来遍历一个容器中的键值和元素:

for key, element = range aContainer {
	// 使用key和element ...
}

在此语法形式中,forrange为两个关键字,keyelement称为循环变量。 如果aContainer是一个切片或者数组(或者数组指针,见后),则key的类型必须为内置类型int

上面所示的for-range语法形式中的等号=也可以是一个变量短声明符号:=。 当短声明符号被使用的时候,keyelement总是两个新声明的变量,这时如果aContainer是一个切片或者数组(或者数组指针),则key的类型被推断为内置类型int

和传统的for循环流程控制一样,每个for-range循环流程控制形成了两个代码块,其中一个是隐式的,另一个是显式的(花括号之间的部分)。 此显式的代码块内嵌在隐式的代码块之中。

for循环流程控制一样,breakcontinue也可以使用在一个for-range循环流程控制中的显式代码块中。

一个例子:

package main

import "fmt"

func main() {
	m := map[string]int{"C": 1972, "C++": 1983, "Go": 2009}
	for lang, year := range m {
		fmt.Printf("%v: %v \n", lang, year)
	}

	a := [...]int{2, 3, 5, 7, 11}
	for i, prime := range a {
		fmt.Printf("%v: %v \n", i, prime)
	}

	s := []string{"go", "defer", "goto", "var"}
	for i, keyword := range s {
		fmt.Printf("%v: %v \n", i, keyword)
	}
}

for-range循环代码块有一些变种形式:

// 忽略键值循环变量。
for _, element = range aContainer {
	// ...
}

// 忽略元素循环变量。
for key, _ = range aContainer {
	element = aContainer[key]
	// ...
}

// 舍弃元素循环变量。此形式和上一个变种等价。
for key = range aContainer {
	element = aContainer[key]
	// ...
}

// 键值和元素循环变量均被忽略。
for _, _ = range aContainer {
	// 这个变种形式没有太大实用价值。
}

// 键值和元素循环变量均被舍弃。此形式和上一个变种等价。
for range aContainer {
	// 这个变种形式没有太大实用价值。
}

遍历一个nil映射或者nil切片是允许的。这样的遍历可以看作是一个空操作。

一些关于遍历映射条目的细节:

  • 映射中的条目的遍历顺序是不确定的(可以认为是随机的)。或者说,同一个映射中的条目的两次遍历中,条目的顺序很可能是不一致的,即使在这两次遍历之间,此映射并未发生任何改变。
  • 如果在一个映射中的条目的遍历过程中,一个还没有被遍历到的条目被删除了,则此条目保证不会被遍历出来。
  • 如果在一个映射中的条目的遍历过程中,一个新的条目被添加入此映射,则此条目并不保证将在此遍历过程中被遍历出来。

如果可以确保没有其它协程操纵一个映射m,则下面的代码保证将清空m中所有条目。

for key := range m {
	delete(m, key)
}

当然,数组和切片元素也可以用传统的for循环来遍历。

for i := 0; i < len(anArrayOrSlice); i++ {
	element := anArrayOrSlice[i]
	// ...
}

对一个for-range循环代码块

for key, element = range aContainer {...}

有三个重要的事实存在:

  1. 被遍历的容器值是aContainer一个副本。 注意,只有aContainer的直接部分被复制了。 此副本是一个匿名的值,所以它是不可被修改的。
    • 如果aContainer是一个数组,那么在遍历过程中对此数组元素的修改不会体现到循环变量中。 原因是此数组的副本(被真正遍历的容器)和此数组不共享任何元素。
    • 如果aContainer是一个切片(或者映射),那么在遍历过程中对此切片(或者映射)元素的修改将体现到循环变量中。 原因是此切片(或者映射)的副本和此切片(或者映射)共享元素(或条目)。
  2. 在遍历中的每个循环步,aContainer副本中的一个键值元素对将被赋值(复制)给循环变量。 所以对循环变量的直接部分的修改将不会体现在aContainer中的对应元素中。 (因为这个原因,并且for-range循环是遍历映射条目的唯一途径,所以最好不要使用大尺寸的映射键值和元素类型,以避免较大的复制负担。)
  3. 所有被遍历的键值对将被赋值给同一对循环变量实例。

下面这个例子验证了上述第一个和第二个事实。

package main

import "fmt"

func main() {
	type Person struct {
		name string
		age  int
	}
	persons := [2]Person {{"Alice", 28}, {"Bob", 25}}
	for i, p := range persons {
		fmt.Println(i, p)
		// 此修改将不会体现在这个遍历过程中,
		// 因为被遍历的数组是persons的一个副本。
		persons[1].name = "Jack"

		// 此修改不会反映到persons数组中,因为p
		// 是persons数组的副本中的一个元素的副本。
		p.age = 31
	}
	fmt.Println("persons:", &persons)
}

输出结果:

0 {Alice 28}
1 {Bob 25}
persons: &[{Alice 28} {Jack 25}]

如果我们将上例中的数组改为一个切片,则在循环中对此切片的修改将在循环过程中体现出来。 但是对循环变量的修改仍然不会体现在此切片中。

...

	// 改为一个切片。
	persons := []Person {{"Alice", 28}, {"Bob", 25}}
	for i, p := range persons {
		fmt.Println(i, p)
		// 这次,此修改将反映在此次遍历过程中。
		persons[1].name = "Jack"
		// 这个修改仍然不会体现在persons切片容器中。
		p.age = 31
	}
	fmt.Println("persons:", &persons)
}

输出结果变成了:

0 {Alice 28}
1 {Jack 25}
persons: &[{Alice 28} {Jack 25}]

下面这个例子验证了上述的第二个和第三个事实:

package main

import "fmt"

func main() {
	langs := map[struct{ dynamic, strong bool }]map[string]int{
		{true, false}:  {"JavaScript": 1995},
		{false, true}:  {"Go": 2009},
		{false, false}: {"C": 1972},
	}
	// 此映射的键值和元素类型均为指针类型。
	// 这有些不寻常,只是为了讲解目的。
	m0 := map[*struct{ dynamic, strong bool }]*map[string]int{}
	for category, langInfo := range langs {
		m0[&category] = &langInfo
		// 下面这行修改对映射langs没有任何影响。
		category.dynamic, category.strong = true, true
	}
	for category, langInfo := range langs {
		fmt.Println(category, langInfo)
	}

	m1 := map[struct{ dynamic, strong bool }]map[string]int{}
	for category, langInfo := range m0 {
		m1[*category] = *langInfo
	}
	// 映射m0和m1中均只有一个条目。
	fmt.Println(len(m0), len(m1)) // 1 1
	fmt.Println(m1) // map[{true true}:map[C:1972]]
}

上面已经提到了,映射条目的遍历顺序是随机的。所以下面前三行的输出顺序可能会略有不同:

{false true} map[Go:2009]
{false false} map[C:1972]
{true false} map[JavaScript:1995]
1 1
map[{true true}:map[Go:2009]]

复制一个切片或者映射的代价很小,但是复制一个大尺寸的数组的代价比较大。 所以,一般来说,range关键字后跟随一个大尺寸数组不是一个好主意。 如果我们要遍历一个大尺寸数组中的元素,我们以遍历从此数组派生出来的一个切片,或者遍历一个指向此数组的指针(详见下一节)。

对于一个数组或者切片,如果它的元素类型的尺寸较大,则一般来说,用第二个循环变量来存储每个循环步中被遍历的元素不是一个好主意。 对于这样的数组或者切片,我们最好忽略或者舍弃for-range代码块中的第二个循环变量,或者使用传统的for循环来遍历元素。 比如,在下面这个例子中,函数fa中的循环效率比函数fb中的循环低得多。

type Buffer struct {
	start, end int
	data       [1024]byte
}

func fa(buffers []Buffer) int {
	numUnreads := 0
	for _, buf := range buffers {
		numUnreads += buf.end - buf.start
	}
	return numUnreads
}

func fb(buffers []Buffer) int {
	numUnreads := 0
	for i := range buffers {
		numUnreads += buffers[i].end - buffers[i].start
	}
	return numUnreads
}

把数组指针当做数组来使用

对于某些情形,我们可以把数组指针当做数组来使用。

我们可以通过在range关键字后跟随一个数组的指针来遍历此数组中的元素。 对于大尺寸的数组,这种方法比较高效,因为复制一个指针比复制一个大尺寸数组的代价低得多。 下面的例子中的两个循环是等价的,它们的效率也基本相同。

package main

import "fmt"

func main() {
	var a [100]int

	for i, n := range &a { // 复制一个指针的开销很小
		fmt.Println(i, n)
	}

	for i, n := range a[:] { // 复制一个切片的开销很小
		fmt.Println(i, n)
	}
}

如果一个for-range循环中的第二个循环变量既没有被忽略,也没有被舍弃,并且range关键字后跟随一个nil数组指针,则此循环将造成一个恐慌。 在下面这个例子中,前两个循环都将打印出5个下标,但最后一个循环将导致一个恐慌。

package main

import "fmt"

func main() {
	var p *[5]int // nil

	for i, _ := range p { // okay
		fmt.Println(i)
	}

	for i := range p { // okay
		fmt.Println(i)
	}

	for i, n := range p { // panic
		fmt.Println(i, n)
	}
}

我们可以通过数组的指针来访问和修改此数组中的元素。如果此指针是一个nil指针,将导致一个恐慌。

package main

import "fmt"

func main() {
	a := [5]int{2, 3, 5, 7, 11}
	p := &a
	p[0], p[1] = 17, 19
	fmt.Println(a) // [17 19 5 7 11]
	p = nil
	_ = p[0] // panic
}

我们可以从一个数组的指针派生出一个切片。从一个nil数组指针派生切片将导致一个恐慌。

package main

import "fmt"

func main() {
	pa := &[5]int{2, 3, 5, 7, 11}
	s := pa[1:3]
	fmt.Println(s) // [3 5]
	pa = nil
	s = pa[0:0] // panic
	// 如果下一行能被执行到,则它也会产生恐慌。
	_ = (*[0]byte)(nil)[:]
}

内置lencap函数调用接受数组指针做为实参。 nil数组指针实参不会导致恐慌。

var pa *[5]int // == nil
fmt.Println(len(pa), cap(pa)) // 5 5

memclr优化

假设t0是一个类型T的零值字面量,并且a是一个元素类型为T的数组或者切片,则官方标准编译器将把下面的单循环变量for-range代码块优化为一个内部的memclr调用。 大多数情况下,此memclr调用比一个一个地重置元素要快。

for i := range a {
	a[i] = t0
}

此优化在官方标准编译器1.5版本中被引入。

从官方Go工具链1.19开始,此优化也适用于a为一个数组指针的情形。

内置函数len和cap的调用可能会在编译时刻被估值

如果传递给内置函数len或者cap的一个调用的实参是一个数组或者数组指针,则此调用将在编译时刻被估值。 此估值结果是一个类型为内置类型int的类型确定常量值。

一个例子:

package main

import "fmt"

var a [5]int
var p *[7]string

// N和M都是类型为int的类型确定值。
const N = len(a)
const M = cap(p)

func main() {
	fmt.Println(N) // 5
	fmt.Println(M) // 7
}

单独修改一个切片的长度或者容量

上面已经提到了,一般来说,一个切片的长度和容量不能被单独修改。一个切片只有通过赋值的方式被整体修改。 但是,事实上,我们可以通过反射的途径来单独修改一个切片的长度或者容量。 反射将在后面的一篇文章中详解。

一个例子:

package main

import (
	"fmt"
	"reflect"
)

func main() {
	s := make([]int, 2, 6)
	fmt.Println(len(s), cap(s)) // 2 6

	reflect.ValueOf(&s).Elem().SetLen(3)
	fmt.Println(len(s), cap(s)) // 3 6

	reflect.ValueOf(&s).Elem().SetCap(5)
	fmt.Println(len(s), cap(s)) // 3 5
}

传递给函数reflect.SetLen调用的第二个实参值必须不大于第一个实参切片值的容量。 传递给函数reflect.SetCap调用的第二个实参值必须不小于第一个实参切片值的长度并且须不大于第一个实参切片值的容量。 否则,在运行时刻将产生一个恐慌。

此反射方法的效率很低,远低于一个切片的赋值。

更多切片操作

Go不支持更多的内置切片操作,比如切片克隆、元素删除和插入。 我们必须用上面提到的各种内置操作来实现这些操作。

在下面当前大节中的例子中,假设s是被谈到的切片、T是它的元素类型、t0是类型T的零值字面量。

切片克隆

对于当前的Go版本(1.19),最简单的克隆一个切片的方法为:

sClone := append(s[:0:0], s...)

我们也可以使用下面这种实现。但是和上面这个实现相比,它有一个不完美之处:如果源切片s是一个空切片(但是非nil),则结果切片是一个nil切片。

sClone := append([]T(nil), s...)

上面这两种append实现都有一个缺点:它们开辟的内存块常常会比需要的略大一些从而可能造成一点小小的不必要的性能损失。 我们可以使用这两种方法来避免这个缺点:

// 两行make+copy实现:
sClone := make([]T, len(s))
copy(sClone, s)

// 或者下面的make+append实现。
// 对于目前的官方Go工具链v1.19来说,这种
// 实现比上面的make+copy实现略慢一点。
sClone := append(make([]T, 0, len(s)), s...)

上面这两种make方法都有一个缺点:如果s是一个nil切片,则使用此方法将得到一个非nil切片。 不过,在编程实践中,我们常常并不需要追求克隆的完美性。如果我们确实需要,则需要多写几行:

var sClone []T
if s != nil {
	sClone = make([]T, len(s))
	copy(sClone, s)
}

在Go官方工具链1.15版本之前,对于一些常见的使用场景,使用append来克隆切片比使用makecopy高效得多。但是从1.15版本开始,官方标准编译器对make+copy这种方法做了特殊的优化,从而使得此方法总是比使用append来克隆切片高效。 但是请注意:此优化只在被克隆的切片呈现为一个标识符(包括限定标识符)并且make调用只有两个实参的时候才有效。 比如,在下面的代码中,它只在第一种情况中才有效:

	// 情况一:
	var s = make([]byte, 10000)
	y = make([]T, len(s)) // works
	copy(y, s)

	// 情况二:
	var s = make([]byte, 10000)
	y = make([]T, len(s), len(s)) // not work
	copy(y, s)

	// 情况三:
	var a = [1][]byte{s}
	y = make([]T, len(a[0])) // not work
	copy(y, a[0])

	// 情况四:
	type T struct {x []byte}
	var t = T{x: s}
	y = make([]T, len(t.x)) // not work
	copy(y, t.x)

删除一段切片元素

前面已经提到了切片的元素在内存中是连续存储的,相邻元素之间是没有间隙的。所以,当切片的一个元素段被删除时,

  • 如果剩余元素的次序必须保持原样,则被删除的元素段后面的每个元素都得前移。
  • 如果剩余元素的次序不需要保持原样,则我们可以将尾部的一些元素移到被删除的元素的位置上。

在下面的例子中,假设from(包括)和to(不包括)是两个合法的下标,并且from不大于to

// 第一种方法(保持剩余元素的次序):
s = append(s[:from], s[to:]...)

// 第二种方法(保持剩余元素的次序):
s = s[:from + copy(s[from:], s[to:])]

// 第三种方法(不保持剩余元素的次序):
if n := to-from; len(s)-to < n {
	copy(s[from:to], s[to:])
} else {
	copy(s[from:to], s[len(s)-n:])
}
s = s[:len(s)-(to-from)]

如果切片的元素可能引用着其它值,则我们应该重置因为删除元素而多出来的元素槽位上的元素值,以避免暂时性的内存泄露:

// "len(s)+to-from"是删除操作之前切片s的长度。
temp := s[len(s):len(s)+to-from]
for i := range temp {
	temp[i] = t0 // t0是类型T的零值字面量
}

前面已经提到了,上面这个for-range循环将被官方标准编译器优化为一个memclr调用。

删除一个元素

删除一个元素是删除一个元素段的特例。在实现上可以简化一些。

在下面的例子中,假设i将被删除的元素的下标,并且它是一个合法的下标。

// 第一种方法(保持剩余元素的次序):
s = append(s[:i], s[i+1:]...)

// 第二种方法(保持剩余元素的次序):
s = s[:i + copy(s[i:], s[i+1:])]

// 上面两种方法都需要复制len(s)-i-1个元素。

// 第三种方法(不保持剩余元素的次序):
s[i] = s[len(s)-1]
s = s[:len(s)-1]

如果切片的元素可能引用着其它值,则我们应该重置刚多出来的元素槽位上的元素值,以避免暂时性的内存泄露:

s[len(s):len(s)+1][0] = t0
// 或者
s[:len(s)+1][len(s)] = t0

条件性地删除切片元素

有时,我们需要删除满足某些条件的切片元素。

// 假设T是一个小尺寸类型。
func DeleteElements(s []T, keep func(T) bool, clear bool) []T {
	// result := make([]T, 0, len(s))
	result := s[:0] // 无须开辟内存
	for _, v := range s {
		if keep(v) {
			result = append(result, v)
		}
	}
	if clear { // 避免暂时性的内存泄露。
		temp := s[len(result):]
		for i := range temp {
			temp[i] = t0 // t0是类型T的零值
		}
	}
	return result
}

注意:如果T是一个大尺寸类型,请慎用T做为参数类型和使用双循环变量for-range代码块遍历元素类型为T的切片。

将一个切片中的所有元素插入到另一个切片中

假设插入位置i是一个合法的下标并且切片elements中的元素将被插入到另一个切片s中。

// 第一种方法:单行实现。
s = append(s[:i], append(elements, s[i:]...)...)

// 上面这种单行实现把s[i:]中的元素复制了两次,并且它可能
// 最多导致两次内存开辟(最少一次)。
// 下面这种繁琐的实现只把s[i:]中的元素复制了一次,并且
// 它最多只会导致一次内存开辟(最少零次)。
// 但是,在当前的官方标准编译器实现中(1.19版本),此
// 繁琐实现中的make调用将会把部分刚开辟出来的元素清零。
// 这其实是没有必要的。所以此繁琐实现并非总是比上面的
// 单行实现效率更高。事实上,它仅在处理小切片时更高效。

if cap(s) >= len(s) + len(elements) {
	s = s[:len(s)+len(elements)]
	copy(s[i+len(elements):], s[i:])
	copy(s[i:], elements)
} else {
	x := make([]T, 0, len(elements)+len(s))
	x = append(x, s[:i]...)
	x = append(x, elements...)
	x = append(x, s[i:]...)
	s = x
}

// Push(插入到结尾)。
s = append(s, elements...)

// Unshift(插入到开头)。
s = append(elements, s...)

插入若干独立的元素

插入若干独立的元素和插入一个切片中的所有元素类似。 我们可以使用切片组合字面量构建一个临时切片,然后使用上面的方法插入这些元素。

特殊的插入和删除:前推/后推,前弹出/后弹出

假设被推入和弹出的元素为e并且切片s拥有至少一个元素。

// 前弹出(pop front,又称shift)
s, e = s[1:], s[0]
// 后弹出(pop back)
s, e = s[:len(s)-1], s[len(s)-1]
// 前推(push front)
s = append([]T{e}, s...)
// 后推(push back)
s = append(s, e)

请注意:使用append函数来插入元素常常是比较低效的,因为插入点后的所有元素都要向后挪,并且当空余容量不足时还需要开辟一个更大的内存空间来容纳插入完成后所有的元素。 对于元素个数不多的切片来说,这些可能并不是严重的问题;但是在元素个数很多的切片上进行如上的插入操作常常是耗时的。所以如果元素个数很多,最好使用链表来实现元素插入操作。

关于上面各种切片操控的例子

在实践中,需求是各种各样的。对于某些特定的情形,上面的例子中的代码实现可能并非是最优化的,甚至是不满足要求的。 所以,请在实践中根据具体情况来实现代码。或许,这就是Go没有支持更多的内置切片操作的原因。

用映射来模拟集合(set)

Go不支持内置集合(set)类型。但是,集合类型可以用轻松使用映射类型来模拟。 在实践中,我们常常使用映射类型map[K]struct{}来模拟一个元素类型为K的集合类型。 类型struct{}的尺寸为零,所以此映射类型的值中的元素不消耗内存。

上述各种容器操作内部都未同步

请注意,上述所有各种容器操作的内部实现都未进行同步。如果不使用今后将要介绍的各种并发同步技术,在没有协程修改一个容器值和它的元素的时候,多个协程并发读取此容器值和它的元素是安全的。但是并发修改同一个容器值则是不安全的。 不使用并发同步技术而并发修改同一个容器值将会造成数据竞争。请阅读以后的并发同步概述一文以了解Go支持的各种并发同步技术。


以上内容是否对您有帮助:
在线笔记
App下载
App下载

扫描二维码

下载编程狮App

公众号
微信公众号

编程狮公众号