Go语言实践[回顾]教程19--详解Go语言复合数据类型之切片 []

  • 切片的概念
  • 切片的创建(声明)
  • 通过数组或切片创建切片
  • 直接声明空切片
  • 直接声明有数据的切片
  • 使用 make 声明有长度的切片
  • 三种创建方式的区别
  • 切片的相关操作
  • 切片元素的获取及遍历
  • 切片的复制 copy()
  • 切片元素的增加 append()
  • 切片元素的删除
  • 切片的长度和容量
  • 切片与字符串


  上一节我们了解了 Go 语言的数组,发现数组是固定长度的,一旦声明就不可更改元素数量。这个特性就使数组无法在程序运行阶段动态增减元素,使用场景受到很大制约。而且数组变量整体赋值时,也是值传递,这也影响了对这种集合类数据操作的性能。因此 Go 语言提供了另一种数据类型,就是切片(slice)。

切片的概念

  对于切片的理解,有官方的定义,也有各种技术文章的解释。如:切片是对数组的一个连续片段的引用,所以切片是一个引用类型。我们既然前面熟悉了数组,那么可以用数组的概念来进一步理解切片。切片,是数组类型的升华,近似为可以动态增加和删除元素的数组。切片多数情况是从数组引用定义的(当然也可以直接声明切片),所以其内部有指向数组的指针,针对数组等同于引用(局部或全部引用)。

切片的创建(声明)

  创建切片有通过数组或切片创建、直接声明空切片、直接声明有数据切片、使用make声明有长度的切片等四种方式。

通过数组或切片创建切片

// test01 项目的 main 包,文件名 main.go
package main

import (
	"fmt"
)

// 主函数,程序入口
func main() {
	arr := [4]int{10, 56, 72, 31}

	c01 := arr[0:4]   // 创建一个引用数组 arr 的 0 ~ 3 号元素的切片 c01,元素数量 4 个
	c02 := arr[:]     // 创建一个引用数组 arr 所有元素的切片 c02,元素数量 4 个
	c03 := arr[:2]    // 创建一个引用数组 arr 的从第1个元素到 1 号元素为止的切片 c03,元素数量 2 个
	c04 := arr[1:]    // 创建一个引用数组 arr 的从 1 号元素到最后1个元素为止的切片 c04,元素数量 3 个
	c05 := arr[1:3]   // 创建一个引用数组 arr 的 1 ~ 2 号元素的切片 c01,元素数量 2 个
	c06 := c01[:]     // 创建一个引用切片 c01 所有元素的切片 c06,元素数量 4 个

	fmt.Println("数组 arr:", arr)
	fmt.Println("切片 c01:", c01)
	fmt.Println("切片 c02:", c02)
	fmt.Println("切片 c03:", c03)
	fmt.Println("切片 c04:", c04)
	fmt.Println("切片 c05:", c05)
	fmt.Println("切片 c06:", c06)
}

  上述代码编译运行结果如下:

数组 arr: [10 56 72 31]
切片 c01: [10 56 72 31]
切片 c02: [10 56 72 31]
切片 c03: [10 56]
切片 c04: [56 72 31]
切片 c05: [56 72]
切片 c06: [10 56 72 31]

  通过上述代码可以看出,使用数组或切片创建切片,语法格式为:
  源数组或切片名 [ 开始位置(含) : 结束位置(不含) ]

  数组本来就是在内存中的一块连续区域,切片就是要引用数组的全部和部分,但是只能连续引用。因此就等于将指针指向数组的某个元素作为开始,然后连续取到哪个元素为止。根据上面代码看出,位置都是索引号(不支持负数)。

  ● 源数组或切片名:新切片要获取数据的原始数组或切片。

  ● 开始位置:新切片的第一个元素在数组中的位置,如果省略表示从第一个开始。

  ● 结束位置:连续取到数组中的哪个位置停止,注意,标注的位置不被获取,如果省略表示一直取到数组最后一个元素为止。

  根据上述代码可以总结出:结束位置 - 开始位置 = 切片长度

直接声明空切片

// test01 项目的 main 包,文件名 main.go
package main

import (
	"fmt"
)

// 主函数,程序入口
func main() {
	var c []int

	fmt.Println("切片 c:", c)
}

  上述代码编译运行结果如下:

切片 c: []

  这种方式声明的切片,是没有任何原始数据的,与声明数组极其相似,仅仅是方括号内没有长度数字。但是这个切片可以动态增加和删除数据了。

直接声明有数据的切片

// test01 项目的 main 包,文件名 main.go
package main

import (
	"fmt"
)

// 主函数,程序入口
func main() {
	var c1 = []int{1, 2, 3}
	c2 := []int{1, 2, 3}

	fmt.Println("切片 c1:", c1)
	fmt.Println("切片 c2:", c2)
}

  上述代码编译运行结果如下:

切片 c1: [1 2 3]
切片 c2: [1 2 3]

  这种方式与声明数组更为相似,仅仅是方括号内没有长度数字。实际就是在内存中全新开辟了一块区域,并给各元素初始化的值。

使用 make 声明有长度的切片

// test01 项目的 main 包,文件名 main.go
package main

import (
	"fmt"
)

// 主函数,程序入口
func main() {
	c1 := make([]int, 2)
	c2 := make([]int, 2, 8)

	fmt.Println("切片 c1:", c1, " 长度:", len(c1))
	fmt.Println("切片 c2:", c2, " 长度:", len(c2))
}

  上述代码编译运行结果如下:

切片 c1: [0 0]  长度: 2
切片 c2: [0 0]  长度: 2

  这种创建方式使用了内置 make() 函数:
  第一个参数是要创建的数据类型,[]int 表示 int 类型的切片;
  第二个参数是元素与的个数,也就是切片长度;
  第三个参数是内存为这个切片预留的最大空间,也就是预留多少个元素的位置。这个参数不影响切片长度,只是在未超过预留空间的动态增加操作都不用重新分配内存,为提高运行效率而设立。可以省略此参数,那么动态增加元素超过创建时声明的个数时将会触发内存分配(自动完成)。

  同时我们看到切片的元素都被初始化为相应类型的零值。

三种创建方式的区别

  ● 通过数组或切片创建切片:创建后没有重新分配内存,是引用的源数组或切片的数据,所以不是空的,对切片操作也会影响源数组(引用的部分)。

  ● 直接声明空切片:是个全新的切片变量,在内存中有新地址,但是没有任何元素,只能使用相关函数动态增加。

  ● 直接声明有数据的切片:是个全新的切片变量,在内存中有新地址,有长度,各元素初始化有值。

  ● 使用 make 声明有长度的切片:是个全新的切片变量,在内存中有新地址,有长度,元素会被初始化为对应类型的零值。可预留内存空间减少内存重新分配的触发几率。

切片的相关操作

  对切片的操作,数据具备的切片也具备,而切片还增加了动态操作特性和方法。

切片元素的获取及遍历

// test01 项目的 main 包,文件名 main.go
package main

import (
	"fmt"
)

// 主函数,程序入口
func main() {
	c := []int{1, 2, 3}

	fmt.Println("切片 c:", c)

	c[1] = 8                            // 给切片 c 的 1 号索引元素赋值
	fmt.Println("切片元素 c[1]:", c[1]) // 输出切片 c 的1号索引元素的值
	fmt.Println("切片 c:", c)

	for k, v := range c {               // 遍历切片 C
		fmt.Println("切片元素", k, "->", v)
	}
}

  上述代码编译运行结果如下:

切片 c: [1 2 3]
切片元素 c[1]: 8
切片 c: [1 8 3]
切片元素 0 -> 1
切片元素 1 -> 8
切片元素 2 -> 3

  切片元素的获取及遍历与数组的元素获取及遍历是一样的,这里就不过多描述了,看参看数组章节的描述。

切片的复制 copy()

  复制切片使用 copy() 内置函数,语法格式:
  copy( 目标切片, 来源切片) int
  将 来源切片 复制到 目标切片,如果两个切片元素数量不一致,则按照最小的数量从切片头部开始依次复制,到达最小切片的数量即停止复制。copy 函数会返回已复制的元素数量,通常可以不接收这个返回值。目标切片和来源切片可以是来自引用的同一个底层数组,复制元素有重叠也没有问题,copy 复制过程中会临时预先保存原值以完成复制。

  ● 大切片向小切片里复制:

// test01 项目的 main 包,文件名 main.go
package main

import (
	"fmt"
)

// 主函数,程序入口
func main() {
	c1 := []int{1, 2}
	c2 := []int{11, 22, 33}

	fmt.Println("复制前切片 c1:", c1)
	fmt.Println("复制前切片 c2:", c2)

	copy(c1, c2)

	fmt.Println("复制后切片 c1:", c1)
	fmt.Println("复制后切片 c2:", c2)
}

  上述代码编译运行结果如下:

复制前切片 c1: [1 2]
复制前切片 c2: [11 22 33]
复制后切片 c1: [11 22]
复制后切片 c2: [11 22 33]

  ● 小切片向大切片里复制:

  将上述代码第16行中 copy 函数的两个参数位置对调,然后再编译执行,会得到如下结果:

复制前切片 c1: [1 2]
复制前切片 c2: [11 22 33]
复制后切片 c1: [1 2]
复制后切片 c2: [1 2 33]

  这充分验证了,copy 函数执行复制的时候,总是以最小的长度复制,并且是从前向后的,剩余的元素不会受到影响。

切片元素的增加 append()

  ▲ 在切片尾部追加元素

// test01 项目的 main 包,文件名 main.go
package main

import (
	"fmt"
)

// 主函数,程序入口
func main() {
	var c []int
	
	fmt.Println("切片 c:", c)

	c = append(c, 1)                // 追加 1 个元素
	c = append(c, 2, 3)             // 追加多个元素, 待追加的元素依次写在后面,每个是一个参数
	c = append(c, []int{4, 5}...)   // 追加一个切片, 切片需要先解包,所以要使用 ... 标识

	fmt.Println("切片 c:", c)
}

  上述代码编译运行结果如下:

切片 c: []
切片 c: [1 2 3 4 5]

  append() 函数可以给切片增加元素,最佳一个、多个、切片都可以,但是注意追加切片时一定要使用 解包。需要特别注意的是,每次执行 append() 函数都会返回一个新切片

  ▲ 在切片头部增加元素

// test01 项目的 main 包,文件名 main.go
package main

import (
	"fmt"
)

// 主函数,程序入口
func main() {
	c := []int{8, 9}

	fmt.Println("切片 c:", c)

	c = append([]int{7}, c...)    // 头部增加 1 个元素,原切片需要解包
	c = append([]int{5, 6}, c...) // 头部增加 1 个切片, 原切片需要解包

	fmt.Println("切片 c:", c)
}

  上述代码编译运行结果如下:

切片 c: [8 9]
切片 c: [5 6 7 8 9]

  从上面两个示例代码可以看出,append() 函数的第一个参数必需是切片类型,后面的参数是要追加的数据。这个函数其实一直执行的就是向第一个切片参数追加其余参数的这个功能。这个在头部增加是巧妙了利用了这个原理,将要添加的数据写成切片格式,这样就满足第一个参数的条件,然后把原切片放在第二个参数上解包,就会把原切片的所有数据解析出来N个元素当做参数,也满足其余参数只能是元素对应的值的要求。最后执行函数,追加到了一个新的切片,从效果上看就是在原切片头部增加了数据。

  因切片指针默认都是指向第一个元素,因此在切片头部增加元素就会导致内存的重新分配,并导致已有元素全部被复制 1 次。这也就导致从切片头部增加数据的性能要比从尾部追加数据的性能差很多

  ▲ 在切片任一位置插入元素

  ● 通过 append 链式写法实现

// test01 项目的 main 包,文件名 main.go
package main

import (
	"fmt"
)

// 主函数,程序入口
func main() {
	c := []int{8, 9}

	fmt.Println("切片 c:", c)

	c = append(c[:1], append([]int{7}, c[1:]...)...) // 在 1 号位置前插入1个新元素 7

	fmt.Println("切片 c:", c)
}

  上述代码编译运行结果如下:

切片 c: [8 9]
切片 c: [8 7 9]

  这是对 append() 函数的链式写法(参数中还有本函数),实现原理:
  ● 将欲插入的数据 7 写成切片格式作为里面的 append 函数的第一个参数([]int{7});
  ● 将原切片待插入元素位置到最后的数据形成一个新的切片,作为第二个参数并解包(c[1:]…);
  ● 执行里面的 append 函数返回一个新的切片,这个切片是以新添加元素为第一个元素,并包含原切片插入点之后所有元素的新切片;
  ● 新返回的切片被放在了外面这个 append 函数的第二个参数,并被解包(append([]int{7}, c[1:]…)…);
  ● 外面这个 append 函数的第一个参数,是原切片插入点之前的所有元素形成的新切片;
  ● 执行外面的 append 函数,返回的就是将里面 append 形成的新切片追加到外面这个第一个参数后形成的最终新切片;

  这个逻辑通俗点讲,就是先把原切片在插入点先一分为二,然后把插入数据与后面的先合成一个切片,然后再与前面的切片合并成一个,就完成了插入功能。根据这个分析,为什么不可以在一分为二后的前面的切片尾部追加呢?看下面代码:

c = append(append(c[:1], 7), c[1:]...)

  如果用这行代码替换上面的第14行代码,执行结果如下:

切片 c: [8 9]
切片 c: [8 7 7]

  产生这个的结果是因为代码的基本执行顺序是从从上至下和从左至右(有分支除外)的,所以 append(c[:1], 7) 会先被执行,然后第二个参数的 c 已经是被修改后的 c 了(切片是引用,所以原切片也变了),因此数据就不对了。

  ● 通过 append + copy 组合实现

// test01 项目的 main 包,文件名 main.go
package main

import (
	"fmt"
)

// 主函数,程序入口
func main() {
	c := []int{1, 2, 3}
	i := 2                 // 欲插入的位置,切片的索引号
	d := 8                 // 欲插入的数据

	fmt.Println("插入前切片 c:", c)

	c = append(c, 0)       // 追加一个 0 值元素,实际是为切片扩展 1 个空间位置
	copy(c[i+1:], c[i:])   // 将欲插入位置到最后的所有元素依次向后移动 1 个位置
	c[i] = d               // 移动后欲插入位置就是空出来的新位置,给这里赋值欲插入的数据

	fmt.Println("插入后切片 c:", c)
}

  上述代码编译运行结果如下:

插入前切片 c: [1 2 3]
复制后切片 c: [1 2 8 3]

  这里巧妙的利用了 copy 函数在同底层数据来源也可以实现复制的特性,采用先扩充位置,然后欲插入位置向后的数据,复制到依次向后移动一位的位置,这样就把欲插入数据的位置空出来可以直接赋值了。

切片元素的删除

  Go 语言没有提供删除切片元素可以直接使用的内置函数,还是需要使用 append 或 copy 函数变通实现。

  ▲ 删除切片头部的元素

// test01 项目的 main 包,文件名 main.go
package main

import (
	"fmt"
)

// 主函数,程序入口
func main() {
	c1 := []int{1, 2, 3}
	c2 := []int{1, 2, 3}
	c3 := []int{1, 2, 3}
	i := 2 // 欲删除的元素个数

	fmt.Println("删除前切片 c1:", c1)
	fmt.Println("删除前切片 c2:", c2)
	fmt.Println("删除前切片 c3:", c3)

	c1 = c1[i:]                    // 移动数据指针直接从头部删除
	c2 = append(c2[:0], c2[i:]...) // 不移动数据指针,内存结构不变化
	c3 = c3[:copy(c3, c3[i:])]     // 不移动数据指针,内存结构不变化

	fmt.Println("删除后切片 c1:", c1)
	fmt.Println("删除后切片 c2:", c2)
	fmt.Println("删除后切片 c3:", c3)
}

  上述代码编译运行结果如下:

删除前切片 c1: [1 2 3]
删除前切片 c2: [1 2 3]
删除前切片 c3: [1 2 3]
删除后切片 c1: [3]
删除后切片 c2: [3]
删除后切片 c3: [3]

  第19行,最简单,但是会造成内存结构变化,切边的数据指针不在原来位置了(就是代表这个切片的内存区块起始位置发生变化了)。

  第20行,使用 append 函数,先将切片 c2 切成空切片(c2[:0]),再将 c2 保留的元素(c2[i:],在头部欲删除几个,那需要保留的索引开头就是几)解包后追加上去。较复杂,但保证了原数据指针没变,内存区块结构没变化。

  第21行,使用 copy 函数,先将保留的元素(c3[i:])复制到切片 c3 ,这样欲删除的元素就会被覆盖掉。然后 copy 函数返回复制的个数刚好做为新的 c3 的结束位置,再切出来就是最后需要的切片了。这个也比较复杂,与第20行性能上有些差异,目的一样。

  ▲ 删除切片尾部的元素

// test01 项目的 main 包,文件名 main.go
package main

import (
	"fmt"
)

// 主函数,程序入口
func main() {
	c := []int{1, 2, 3}
	i := 2 // 欲删除的元素个数

	fmt.Println("删除前切片 c:", c)

	c = c[:len(c)-i]     // 移动数据指针直接从头部删除

	fmt.Println("删除后切片 c:", c)
}

  上述代码编译运行结果如下:

删除前切片 c: [1 2 3]
删除后切片 c: [1]

  在尾部删除元素比较简单,直接使用位置计算切出来就可以了。也不会引起数据指针移动,内存区块结构也不会变化。

  ▲ 删除切片中间的元素

// test01 项目的 main 包,文件名 main.go
package main

import (
	"fmt"
)

// 主函数,程序入口
func main() {
	c1 := []int{1, 2, 3, 4, 5}
	c2 := []int{1, 2, 3, 4, 5}
	i := 1                               // 从哪个元素开始删(索引号)
	n := 2                               // 欲删除的元素个数

	fmt.Println("删除前切片 c1:", c1)
	fmt.Println("删除前切片 c2:", c2)

	c1 = append(c1[:i], c1[i+n:]...)    // 使用 append 实现的中间元素删除
	c2 = c2[:i+copy(c2[i:], c2[i+n:])]  // 使用 copy 实现的中间元素删除

	fmt.Println("删除后切片 c1:", c1)
	fmt.Println("删除前切片 c2:", c2)
}

  上述代码编译运行结果如下:

删除前切片 c1: [1 2 3 4 5]
删除前切片 c2: [1 2 3 4 5]
删除后切片 c1: [1 4 5]
删除前切片 c2: [1 4 5]

  第18行,使用 append 函数,先将头部侧需要保留的切出来作为待追加的切片(c1[:i]),再通过计数运算将尾部侧需要保留的切出来并解包(c1[i+n:]…),最后追加到头部侧的切片后面。

  第19行,使用 copy 函数,先将欲删除位置到最后的所有元素切出来(c2[i:])作为复制的目标切片,再通过计数运算将尾部侧需要保留的切出来(c1[i+n:]),这样执行 copy 欲删除的元素就会被覆盖掉。然后 copy 函数返回复制的个数刚好做为新的 c2 的结束位置,再切出来就是最后需要的切片了。

切片的长度和容量

  切片的长度,就是只包含元素的个数;切片的容量,是指切片的内存区块总计保留可以容纳多少个元素的位置,超过这些元素就会触发内存重新分配。Go 语言提供了 len() 和 cap() 两个内置函数来获取切片的长度和容量。

// test01 项目的 main 包,文件名 main.go
package main

import (
	"fmt"
)

// 主函数,程序入口
func main() {
	c1 := []int{1, 2, 3, 4, 5}
	c2 := make([]int, 5)
	c3 := make([]int, 5, 12)

	fmt.Println("c1长度:", len(c1), " 容量:", cap(c1))
	fmt.Println("c2长度:", len(c2), " 容量:", cap(c2))
	fmt.Println("c3长度:", len(c3), " 容量:", cap(c3))

	c1 = append(c1, 6)

	fmt.Println("追加数据后")
	fmt.Println("c1长度:", len(c1), " 容量:", cap(c1))

	c1 = append(c1, 7, 8, 9, 10)

	fmt.Println("扩容后再追加数据后")
	fmt.Println("c1长度:", len(c1), " 容量:", cap(c1))

	c1 = append(c1, 11)

	fmt.Println("再次扩容")
	fmt.Println("c1长度:", len(c1), " 容量:", cap(c1))
}

  上述代码编译运行结果如下:

c1长度: 5  容量: 5
c2长度: 5  容量: 5
c3长度: 5  容量: 12
追加数据后
c1长度: 6  容量: 10
扩容后再追加数据后
c1长度: 10  容量: 10
再次扩容
c1长度: 11  容量: 20

  通过这段示例代码,可以清楚的展示切片的长度与容量是不同的。也看到当增加元素个数超过容量会触发重新内存分配,而自动分配时会自动扩容,扩容的标准是之前容量的2倍。示例中 c1 的容量经过两次扩容,由 5 括到 10,又由 10 括到 20。

切片与字符串

  字符串其实就是字符序列,所以字符串也可以生成切片:

// test01 项目的 main 包,文件名 main.go
package main

import (
	"fmt"
)

// 主函数,程序入口
func main() {
	s := "hello, 中国!"

	a := s[0]       // 直接按切片获取元素(单个字节)
	b := []byte(s)  // 将字符串转为 []byte 类型切片
	c := []rune(s)  // 将字符串转为 []rune 类型切片

	fmt.Println(a)
	fmt.Println(b)
	fmt.Println(c)
}

  上述代码编译运行结果如下:

104
[104 101 108 108 111 44 32 228 184 173 229 155 189 239 188 129]
[104 101 108 108 111 44 32 20013 22269 65281]

  在打印输出结果中,前两行输出的是各字符的 ascii 码值,中文部分被分解了。而在最后一行结果,对应的是第14行转换得到的,就可以输出中文的 Unicode 编码值了。
.
.