Go 数组、字符串和切片
数组
定义方式
1 | var a [3]int // 定义长度为3的int型数组, 元素全部为0 |
- 方式a:长度确定,数组中每个元素都以零值初始化
- 方式b:顺序指定全部元素的初始化值,数组的长度根据元素个数自动计算
- 方式c:以索引的方式来指定数组初始化元素,数组长度为最大指定索引,没有明确值的元素用零值初始化
- 方式d:混合了方式b和方式c
访问数组
Go 中数组是值语义,一个数组变量就是整个数组,并不是隐式的指向第一个元素的指针(C语言)。所以当数组变量被赋值或者被传递的时候,实际上会复制整个数组。大数组会造成大的开销,为了避免复制数组带来的开销,可以传递一个指向数组的指针,但是数组指针并不是数组。
1 | var a = [...]int{1, 2, 3} // a 是一个数组 |
但是数组指针类型依然不够灵活,因为数组的长度是数组类型的组成部分,指向不同长度数组的数组指针类型也是完全不同的。可以将数组看作一个特殊的结构体,结构的字段名对应数组的索引,同时结构体成员的数目是固定的。内置函数 len
返回数组长度,cap
返回数组容量。不过对于数组来说,这两个的返回结果是一样的。
我们还可以用 for 循环来遍历数组:
1 | for i := range a { |
for range
的性能会好一些,因为这种迭代保证不会出现数组越界的情况,每轮迭代对数组元素的访问可以省去对下标越界的判断。另外 for range
可以忽略迭代时的下标:
1 | var times [5][0]int |
在上述使用中,尽管数组第一维有长度,但是 [0]int
大小是0,因此整个数组占用内存大小依然是0。没有额外的内存代价,我们就通过 for range
实现了 times
次快速迭代。
数组类型
除了数值型数组,还可以定义字符串数组、结构体数组、函数数组、接口数组、管道数组等等。另外还有不常用的空数组,因为一般我们更倾向于去使用空结构体来用于管道同步。
字符串
字符串是一个不可改变的字节序列,和数组不同,字符串的元素不可修改,是一个只读的字节数组,且长度固定。由于 Go 的源代码要求 UTF8 编码,导致 Go 源代码中出现的字符串字面量常量一般也是 UTF8 编码(对于转义字符,则没有这个限制)。源代码中的文本字符串通常被解释为采用 UTF8 编码的 Unicode 码点(rune)序列。字符串是只读的字节序列,可以包含包括byte值0的任意数据。我们也可以用字符串表示 GBK 等非 UTF8 编码的数据,但此时字符串可以看做是一个只读的二进制数组,因为 for range
等语法并不能支持非 UTF8 编码的字符串的遍历。
Go 语言字符串的底层结构在 reflect.StringHeader
中定义:
1 | type StringHeader struct { |
字符串结构包括了字符串指向的底层数组和字符串的字节长度。字符串其实就是一个结构体,因此字符串的赋值操作也就是 reflect.StringHeader
结构体的复制过程,并不会涉及底层字节数组的复制。[2]string
字符串数组对应的底层结构和 [2]reflect.StringHeader
对应的底层结构是一样的,可以将字符串数组看作一个结构体数组。举例来说 “Hello, world” 字符串底层数据和以下数组是完全一致的:
1 | var data = [...]byte{ |
字符串虽然不是切片,但是支持切片操作,不同位置的切片底层也访问的同一块内存数据(因为字符串是只读的,相同的字符串面值常量通常是对应同一个字符串常量):
1 | s := "hello, world" |
字符串和数组类似,内置的len
函数返回字符串的长度。或通过 reflect.StringHeader
结构访问字符串的长度(不推荐)。
1 | len(s) |
提到 Go 字符串时,我们一般都会假设字符串对应的是一个合法的 UTF8 编码的字符序列。可以用内置的 print
调试函数或 fmt.Print
函数直接打印,也可以用 for range
循环直接遍历 UTF8 解码后的 Unicode 码点值。我们也可以在字符串面值中直指定UTF8编码后的值(源文件中全部是ASCII码,可以避免出现多字节的字符):
1 | fmt.Println("\xe4\xb8\x96") // 打印: 世 |
Go 语言的字符串中可以存放任意的二进制字节序列,而且即使是 UTF8 字符序列也可能会遇到坏的编码。如果遇到一个错误的 UTF8 编码输入,将生成一个特别的 Unicode 字符 ‘\uFFFD’,通常显示为 ‘�’ 。错误编码不会向后扩散是 UTF8 编码的优秀特性之一:
1 | fmt.Println("\xe4\x00\x00\xe7\x95\x8cabc") // �界abc |
不过在 for range
迭代这个含有损坏的UTF8字符串时,第一字符的第二和第三字节依然会被单独迭代到,此时迭代的值是损坏后的0:
1 | for i, c := range "\xe4\x00\x00\xe7\x95\x8cabc" { |
Go 语言除了 for range
语法对 UTF8 字符串提供了特殊支持外,还对字符串和 []rune
类型的相互转换提供了特殊的支持。
1 | fmt.Printf("%#v\n", []rune("世界")) // []int32{19990, 30028} |
上面可以看出 rune
只是 int32
的别名,用于表示每个 Unicode 码点,目前只使用了21个 bit 位。
字符串的强制类型转换涉及 []byte
和 []rune
两种类型。在将字符串转为 []byte
时,如果转换后的变量并没有被修改的情形,编译器可能会直接返回原始的字符串对应的底层数据。而在将字符串转为 []rune
时,由于字符串底层的 []byte
和 []int32
类型的内部布局完全不同,所以这种转换可能隐含重新分配内存的操作(重构字符串),最差的情况是时间复杂度达到 O(n)。
切片
切片简单来说就是一种简化版的动态数组,实际使用中,切片比数组的使用范围广泛很多。下面是切片的结构定义,reflect.SliceHeader
:
1 | type SliceHeader struct { |
切片的开头部分和 Go 字符串是一样的,但是切片多了一个 Cap
成员表示切片指向的内存空间的最大容量(对应元素的个数,而不是字节数)。和数组一样,内置的 len
函数返回切片中有效元素的长度,内置的 cap
函数返回切片容量大小,容量必须大于或等于切片的长度。切片可以和 nil
进行比较,只有当切片底层数据指针为空时切片本身为 nil
,这时候切片的长度和容量信息将是无效的。如果有切片的底层数据指针为空,但是长度和容量不为0的情况,那么说明切片本身已经被损坏了(比如直接通过 reflect.SliceHeader
或 unsafe
包对切片作了不正确的修改)。
切片的定义方式如下:
1 | var ( |
切片的遍历、读取和修改都和数组一致。在对切片本身赋值或参数传递时,和数组指针的操作方式类似,只是复制切片头信息(reflect.SliceHeader
),并不会复制底层的数据。对于类型,和数组的最大不同是,切片的类型和长度信息无关,只要是相同类型元素构成的切片均对应相同的切片类型。
如前所说,切片是一种简化版的动态数组,这是切片类型的灵魂。除了构造切片和遍历切片之外,添加切片元素、删除切片元素都是切片处理中经常遇到的问题。
添加切片元素
内置的泛型函数 append
可以在切片的尾部追加 N
个元素:
1 | var a []int |
不过要注意的是,在容量不足的情况下,append
的操作会导致重新分配内存,可能导致巨大的内存分配和复制数据代价。即使容量足够,依然需要用 append
函数的返回值来更新切片本身,因为新切片的长度已经发生了变化。
除了在切片的尾部追加,我们还可以在切片的开头添加元素:
1 | var a = []int{1,2,3} |
在开头一般都会导致内存的重新分配,而且会导致已有的元素全部复制1次。因此,从切片的开头添加元素的性能一般要比从尾部追加元素的性能差很多。
由于 append
函数返回新的切片,也就是它支持链式操作:
1 | var a []int |
或者用 copy
和 append
组合可以避免创建中间的临时切片:
1 | //在第i个位置添加一个元素 |
人为扩容属于副作用,有违切片本身的设计思想。
删除切片元素
删除尾部元素:
1 | a = []int{1, 2, 3} |
删除头部元素:
1 | //移动指针 |
删除中间元素:
1 | //使用copy和append组合原地完成 |
删除开头的元素和删除尾部的元素都可以认为是删除中间元素操作的特殊情况。
切片内存技巧
对于切片来说, len
为0但是 cap
容量不为0
的切片则是非常有用的特性。当然,如果 len
和 cap
都为 0 的话,则变成一个真正的空切片,虽然它并不是一个 nil
值的切片。在判断一个切片是否为空时,一般通过 len
获取切片的长度来判断,一般很少将切片和 nil
值做直接的比较。
0长切片特性使用案例:
1 | //删除[]byte中的空格 |
切片高效操作的要点是要降低内存分配的次数,尽量保证 append
操作不会超出 cap
的容量,降低触发内存分配的次数和每次分配内存大小。
避免切片内存泄露
如前面所说,切片操作并不会复制底层的数据。底层的数组会被保存在内存中,直到它不再被引用。但是有时候可能会因为一个小的内存引用而导致底层整个数组处于被使用的状态,这会延迟自动内存回收器对底层数组的回收。
例如,FindPhoneNumber
函数加载整个文件到内存,然后搜索第一个出现的电话号码,最后结果以切片方式返回。
1 | func FindPhoneNumber(filename string) []byte { |
这段代码返回的 []byte
指向保存整个文件的数组。因为切片引用了整个原始数组,导致自动垃圾回收器不能及时释放底层数组的空间。一个小的需求可能导致需要长时间保存整个文件数据。这虽然这并不是传统意义上的内存泄漏,但是可能会拖慢系统的整体性能。
要修复这个问题,可以将感兴趣的数据复制到一个新的切片中(数据的传值是Go语言编程的一个哲学,虽然传值有一定的代价,但是换取的好处是切断了对原始数据的依赖):
1 | func FindPhoneNumber(filename string) []byte { |
类似的问题,在删除切片元素时可能会遇到。假设切片里存放的是指针对象,那么下面删除末尾的元素后,被删除的元素依然被切片底层数组引用,从而导致不能及时被自动垃圾回收器回收(这要依赖回收器的实现方式):
1 | var a []*int{ ... } |
保险的方式是先将需要自动内存回收的元素设置为 nil
,保证自动回收器可以发现需要回收的对象,然后再进行切片的删除操作:
1 | var a []*int{ ... } |
当然,如果切片本身生命周期短的话完全不需要这样,等待整个切片被 GC 回收即可。
切片强制类型转换
为了安全,当两个切片类型 []T
和 []Y
的底层原始切片类型不同时,Go语言是无法直接转换类型的。但是有时候转换有简化编码或者提升性能的价值,比如在64位系统上,需要对一个 []float64
切片进行高速排序,我们可以将它强制转为 []int
整数切片,然后以整数的方式进行排序(因为 float64
遵循 IEEE754 浮点数标准特性,当浮点数有序时对应的整数也必然是有序的)。下面的代码通过两种方法将 []float64
类型的切片转换为 []int
类型的切片:
1 | // +build amd64 arm64 |
第一种强制转换是先将切片数据的开始地址转换为一个较大的数组的指针,然后对数组指针对应的数组重新做切片操作。中间需要 unsafe.Pointer
来连接两个不同类型的指针传递。需要注意的是,Go 语言实现中非0大小数组的长度不得超过2GB,因此需要针对数组元素的类型大小计算数组的最大长度范围([]uint8
最大2GB,[]uint16
最大1GB,以此类推,但是 []struct{}
数组的长度可以超过2GB)。
第二种转换操作是分别取到两个不同类型的切片头信息指针,任何类型的切片头部信息底层都是对应 reflect.SliceHeader
结构,然后通过更新结构体方式来更新切片信息,从而实现 a
对应的 []float64
切片到 c
对应的 []int
类型切片的转换。
不过需要注意的是,这个方法可行的前提是要保证 []float64
中没有 NaN 和 Inf 等非规范的浮点数(因为浮点数中 NaN 不可排序,正0和负0相等,但是整数中没有这类情形)。
Other
数组和切片在传参上的差异
直接看例子:
1 | func test() { |
1 | //output |
可以看出数组是值传递,切片是引用传递,而且数组形参在定义时必须准确定义长度。
切片在传参上还有一个坑,就是 append
操作造成的引用丢失:
1 | func appendTest() { |
1 | //output |
由于 append
返回的是一个新的切片引用,因此在函数内的添加变更只体现在新切片引用上。除非函数返回新的切片引用,否则原上下文无法让旧引用重新赋值为新切片的引用。要解决这个问题除了直接用函数返回值更新外,还可以用引用的引用来解决:
1 | func appendTest() { |
1 | //output |
下面用一个递归例子在展示两种方式:
1 | func recursiveTest() { |
1 | //output |