Go语言中的一等公民容器类型:Array、Slice和Map

Go语言中的一等公民容器类型:Array、Slice和Map

在严格意义上,Go中有三种一等公民容器类型:Array、Slice和Map。 有些情况下,String和Channel也被认为是容器,但因其使用方式或原理与另外三种容器存在区别,本文暂不讨论。

本文默认读者具有一定的编程基础,因此本文并未详细的梳理全部相关知识点,而是选择了重点或者易错点的内容进行分析。

1. 概述

容器
容器用来表示和存储一个元素的序列或集合。

容器的元素类型

容器类型中包含多种类型:容器类型的键值类型、容器类型的元素类型等。一般情况下,容器类型的元素类型可以简称为容器的元素类型。

一个容器中的所有元素的类型是相同的,此相同的类型称为容器的元素类型。

容器的内存定义

  • 数组的所有元素紧挨着存放在一块连续的内存上
    • 数组中所有元素均存放在此数组值的直接部分
  • 切片的所有元素紧挨着存放在一块连续的内存上
    • 切片中所有元素存放在此切片值的间接部分
  • 映射中所有元素存放在一块连续的内存上
    • 官方编译器中映射由哈希表实现,此外还有使用二叉树的实现方案,因此映射中所有元素虽然存放在一块连续的内存上,但不一定紧挨着
    • 映射中所有元素的键值存放在此映射值的间接部分

2. 容器的字面量表示

非定义容器类型的字面量表示形式:

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

其中:

  • T:可以是任何类型,他表示一个容器的元素类型。
  • N:必须为非负整数常量,指定了该数组类型的长度,长度是此数组类型定义的一部分,因此[2]int[3]int是两种不同的类型。
  • K:必须是一个可比较类型,指定了一个映射的键值类型。
  • 容器字面量是不可寻址的但可以被取地址

3. 切片的内部结构

官方编译器对切片类型的内部定义为:

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

elements表示其底层内存上的指针,len表示切片当前存储的元素数量,cap表示切片的容量,下面这张图描绘了一个切片值的内存布局。

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

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

一般来说,不能直接修改切片中的len或cap,除非使用反射的方式,但这种方式非常不优雅。

4. 切片的赋值、复制、扩容

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
}
  • 第8行的append函数调用将为结果切片s1开辟一段新的内存。 原因是切片s0中没有足够的冗余元素槽位来容纳新添加的元素。 第14行的append函数调用也是同样的情况。
  • 当一个append函数调用需要为结果切片开辟内存时,结果切片的容量取决于具体编译器实现。 在这种情况下,对于官方标准编译器,如果基础切片的容量较小,则结果切片的容量至少为基础切片的两倍。

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

在这里插入图片描述

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

5.1 make和new的区别

  • 返回值
    • new(T) 返回 T 的指针 *T 并指向 T 的零值
    • make(T) 返回的初始化的 T
  • 适用范围
    • make只能用于 slicemapchannel
    • new可以用来为一个任何类型的值开辟内存并返回一个存储有此值的地址的指针

从底层的详细分析可以参考:golang-make-and-new

5.2 使用内置make函数来创建映射

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

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

5.3 使用内置make函数来创建切片

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

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

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

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

举例解释上边这句话:

package main

import "fmt"

func main() {
	c := make([]int, 3, 6) // [0 0 0]
	c = append(c, 1)       // [0 0 0 1]
	c = append(c, 2)       // [0 0 0 1 2]
	c = append(c, 3)       // [0 0 0 1 2 3]
	fmt.Println(c)           // [0 0 0 1 2 3]
}

// 输出为:
// [0 0 0 1 2 3]

6. 容器元素的可寻址性

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

  • 数组:可寻址的数组的元素也是可寻址的。不可寻址的数组的元素也是不可寻址的。因为一个数组中的所有元素均处于此数组的直接部分。
  • 切片:一个切片值的任何元素都是可寻址的,即使此切片本身是不可寻址的。 这是因为一个切片的底层元素总是存储在一个被开辟出来的内存片段上。
  • 映射:任何映射元素都是不可寻址的。原因详见:maps-are-unaddressable

一个例子:

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]
	*/
}

其中,第19行与第8行不同,第8行是对变量尝试寻址,而第19行尝试对字面量寻址,而上文已经提到了,容器的字面量是不可以寻址的,而不可寻址的容器的元素也是不可寻址的,因此19行编译不通过。

每个数组或者结构体值都是仅含有一个直接部分。所以:

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

例子如下:

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
}

7. 切片派生

7.1 子切片表达式

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

sl[low : high]       // 双下标形式  <=> sl[low : high : cap(baseContainer)]
sl[low : high : max] // 三下标形式

上面所示的取子切片表达式的语法形式中得下标必须满足下列关系:

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

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

从一个nil数组指针派生切片将导致panic。

7.2 子切片表达式下标省略规则

对于sl[low : high]sl[low : high : max] 两个子切片表达式,有以下下标省略规则:

  • sl[low : high]
    • 如果下标low为零,则它可被省略。
    • 如果下标high等于len(sl),则它可被省略。
  • sl[low : high : max]
    • 如果下标low为零,则它可被省略。
    • 下标max在任何情况下都不可被省略。

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

sl[0 : len(sl)]
sl[   : len(sl)]
sl[0 :]
sl[   :]

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

7.3 子切片语法内存状态示例

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

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]
}

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

在这里插入图片描述

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

7.4 子切片操作中的内存泄漏问题

子切片操作有可能会造成暂时性的内存泄露。

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

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

8. 容器元素的遍历

8.1 for-range语句

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

// 基础写法:
for key, element = range aContainer {
	// 使用key和element ...
}

// --------------

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

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

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

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

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

8.2 一些重要事实和细节

一些重要事实:

  • 在遍历中的每个循环步,aContainer副本中的一个键值元素对将被深拷贝给循环变量。
    • 对循环变量的直接部分的修改将不会体现在aContainer中的对应元素中。
    • 如果映射键值尺寸太大,或类型复杂,在复制时可能成为性能瓶颈。
  • 被遍历的容器值是aContainer的一个副本。 注意,只有aContainer的直接部分被复制了。 此副本是一个匿名的值,所以它是不可被修改的。
    • 如果aContainer是一个数组,那么在遍历过程中对此数组元素的修改不会体现到循环变量中。 原因是此数组的副本(被真正遍历的容器)和此数组不共享任何元素。
    • 如果aContainer是一个切片(或者映射),那么在遍历过程中对此切片(或者映射)元素的修改将体现到循环变量中。 原因是此切片(或者映射)的副本和此切片(或者映射)共享元素(或条目)。

关于遍历映射,有一些细节需要注意:

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

8.3 性能优化

8.3.1 基础遍历的优化

两个优化思路:

  • 如果我们要遍历一个大尺寸数组中的元素,我们以遍历从此数组派生出来的一个切片,或者遍历一个指向此数组的指针
  • 对于一个数组或者切片,如果它的元素类型的尺寸较大,我们最好忽略或者舍弃for-range代码块中的第二个循环变量,或者使用传统的for循环来遍历元素。
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 i, n := range a[:] { // 复制一个切片的开销很小
		fmt.Println(i, n)
	}
}

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

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)
	}
}
8.3.2 数组重置中的memclr优化

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

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

此优化也适用于a为一个切片的情形。但是,有点遗憾,此优化不适用于a为一个数组指针的情形。
所以,如果你打算重置一个数组,最好不要在range关键字后跟随此数组的指针。 特别地,推荐在range关键字后跟随一个从此数组派生出来的切片,如下:

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

不推荐在range关键字后直接此数组的原因是其它可能的第三方编译器可能并没有实现此优化,导致range关键字后跟随的数组将被复制,从而影响性能。

9. 切片的常用操作

9.1 切片克隆

对于目前的标准编译器(1.14版本),最有效的克隆一个切片的方法为:

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

对于长度较大(并且元素值的直接部分不含有指针)的切片,上面这种方法比下面这种方法效率更高。

sClone := make([]T, len(s))
copy(sClone, s)

刚提到的第二种方法有一个缺点:如果s是一个nil切片,此第二种方法将得到一个非nil切片。

9.2 切片删除

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

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

同时,我们应该重置刚多出来的元素槽位上的元素值,以避免暂时性的内存泄露。

9.2.1 删除一个元素

在下面的例子中,假设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
9.2.2 删除一段连续的切片元素

在下面的例子中,假设将删除[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
}
9.2.3 条件性地删除切片元素

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

func DeleteElements(s []int, keep func(int) bool) []int {
	// result := make([]int, 0, len(s))
	result := s[:0] // 无须开辟内存
	for _, v := range s {
		if keep(v) {
			result = append(result, v)
		}
	}
	temp := s[len(result):]
	for i := range temp {
		temp[i] = 0 // t0是类型T的零值
	}
	return result
}
9.2.4 将一个切片中的所有元素插入到另一个切片中

假设需要将elements中的元素插入到切片si下边处。

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

// 另一种效率更高的但较为繁琐的实现。
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...)
9.2.5 特殊的插入和删除:前推/后推,前弹出/后弹出
// 前弹出(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)

参考及书籍推荐

本文写作过程中参考了go101项目,go101项目是一本开源的Go语法和语义的编程指导书,搜集了很多Go编程中的细节和讲解了一些底层实现原理,推荐给大家。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值