前端开发转 Go 全栈(七):Slice 进阶——make、copy 与删除元素

73 阅读16分钟

前言:理解了 len 和 cap,然后呢?

上一篇学习 Go Array 和 Slice 时,我通过几组实验理解了:

Array 固定长度
Slice 可以动态扩展
Slice 有 len 和 cap
多个 Slice 可能共享底层数组
append 可能复用原来的底层数组
容量不足时才会申请新的存储空间

其中最让我意外的,是下面这段代码:

a := []int{10, 20, 30, 40, 50}
b := a[1:3]

b = append(b, 999)

最终可能得到:

a:[10 20 30 999 50]
b:[20 30 999]

我明明只操作了 b,为什么 a 也发生了变化?

原因是 a 和 b 仍然共享同一个底层数组,而且 b 还有剩余容量,所以 append 直接复用了原来的空间。

理解到这里以后,新的问题自然就出现了:

make() 是做什么的?
为什么可以提前设置 Slice 的容量?
copy() 是否会复制出独立数据?
如果明确不想共享底层数组,应该怎么做?
Go 的 Slice 又该怎么删除元素?

这一篇,就继续通过实际代码把这些问题解决掉。


一、使用 make 创建 Slice

前面创建 Slice,主要使用的是字面量:

numbers := []int{10, 20, 30}

Go 还提供了另外一种非常常见的创建方式:

numbers := make([]int, 3)

完整代码:

package main

import "fmt"

func main() {
	numbers := make([]int, 3)

	fmt.Println("numbers:", numbers)
	fmt.Println("len:", len(numbers))
	fmt.Println("cap:", cap(numbers))
}

运行结果:

numbers: [0 0 0]
len: 3
cap: 3

make([]int, 3) 的结构是:

make([]元素类型, 长度)

所以:

make([]int, 3)

表示创建一个长度为 3 的 int Slice。

因为没有手动设置元素,所以三个位置都使用 int 的零值:

0

这三个元素已经真实存在,可以直接通过下标修改:

numbers[0] = 10
numbers[1] = 20
numbers[2] = 30

但下面这段代码仍然会越界:

numbers[3] = 40

因为当前:

len(numbers) = 3

合法下标只有:

0、1、2

从前端视角看:它和 new Array(3) 并不完全一样

JavaScript 中可以这样创建数组:

const numbers = new Array(3);

console.log(numbers);
// [empty × 3]

虽然:

numbers.length === 3

但这里的三个位置是空槽。

Go:

numbers := make([]int, 3)

得到的是:

[0 0 0]

三个元素已经被初始化成 int 的零值。

目前可以这样对比:

GoJavaScript
make([]int, 3)new Array(3)
长度都是 3长度都是 3
三个元素已经是 0三个位置是空槽
读取元素得到 0读取元素通常得到 undefined
元素类型固定为 int元素类型不固定

所以两者看起来相似,但初始化行为并不相同。


二、同时设置长度和容量

make 创建 Slice 时,还可以传入第三个参数:

numbers := make([]int, 0, 5)

完整结构是:

make([]元素类型, 长度, 容量)

所以:

make([]int, 0, 5)

表示:

当前长度:0
提前准备的容量:5

运行下面的代码:

package main

import "fmt"

func main() {
	numbers := make([]int, 0, 5)

	fmt.Println("创建后:", numbers)
	fmt.Println("len:", len(numbers))
	fmt.Println("cap:", cap(numbers))

	numbers = append(numbers, 10)

	fmt.Println("追加 10 后:", numbers)
	fmt.Println("len:", len(numbers))
	fmt.Println("cap:", cap(numbers))

	numbers = append(numbers, 20, 30)

	fmt.Println("继续追加后:", numbers)
	fmt.Println("len:", len(numbers))
	fmt.Println("cap:", cap(numbers))
}

运行结果:

创建后: []
len: 0
cap: 5

追加 10 后: [10]
len: 1
cap: 5

继续追加后: [10 20 30]
len: 3
cap: 5

这里能够明显看到:

len:0 → 1 → 3
cap:5 → 5 → 5

随着元素不断添加,len 会发生变化。

但因为提前准备的容量还没有用完,所以 cap 一直保持为 5。


有容量,不代表可以直接使用下标

下面这段代码会发生越界:

numbers := make([]int, 0, 5)

numbers[0] = 10

虽然:

cap = 5

但是:

len = 0

能否通过下标访问元素,判断的是 len,不是 cap。

所以此时必须使用:

numbers = append(numbers, 10)

可以先这样理解:

len:当前真正存在并且可以访问的元素数量
cap:从当前起点开始,底层已经准备好的空间

有空间,不代表这些元素已经进入当前 Slice。


从前端视角看:它也不是 new Array(5)

JavaScript:

const numbers = new Array(5);

创建以后:

numbers.length === 5;

Go:

numbers := make([]int, 0, 5)

创建以后:

len = 0
cap = 5

它在使用体验上更接近:

const numbers = [];

只是 Go 在底层提前准备了可以容纳若干元素的空间。

JavaScript 业务代码通常只关注:

numbers.length

底层数组什么时候扩容、准备了多少空间,通常由 JavaScript 引擎处理。

Go 则通过:

len(numbers)
cap(numbers)

把这两个概念直接暴露给了开发者。


三、容量用完以后会发生什么?

接下来从容量为 5 的空 Slice 开始,连续追加六个元素:

package main

import "fmt"

func main() {
	numbers := make([]int, 0, 5)

	for i := 1; i <= 6; i++ {
		numbers = append(numbers, i*10)

		fmt.Printf(
			"numbers=%v len=%d cap=%d\n",
			numbers,
			len(numbers),
			cap(numbers),
		)
	}
}

我的运行结果是:

numbers=[10] len=1 cap=5
numbers=[10 20] len=2 cap=5
numbers=[10 20 30] len=3 cap=5
numbers=[10 20 30 40] len=4 cap=5
numbers=[10 20 30 40 50] len=5 cap=5
numbers=[10 20 30 40 50 60] len=6 cap=10

前五次 append 时,原来的空间还够,所以:

cap 一直是 5

追加第六个元素时:

len = 5
cap = 5

原来的空间已经用满。

Go 需要准备更大的底层存储空间,所以这次实验中:

cap:5 → 10

不过这里不能死记:

容量每次一定翻倍

Slice 的容量增长策略属于 Go 运行时的实现细节,业务代码不应该依赖某个固定倍数。

真正需要理解的是:

len < cap
→ append 可能继续复用当前底层数组

len == cap 后继续 append
→ 需要准备更大的底层空间

四、为什么要提前设置容量?

假设已经知道接下来大约会添加 1000 条数据,可以这样创建 Slice:

numbers := make([]int, 0, 1000)

这样 Go 可以提前准备一部分空间。

如果写成:

numbers := []int{}

然后连续 append 1000 次,过程中可能多次发生:

容量不足
→ 申请更大的底层数组
→ 复制原来的元素
→ 继续追加

提前设置一个合理的容量,可以减少扩容和数据复制。

不过下面这两种写法完全不同:

a := make([]int, 1000)
b := make([]int, 0, 1000)

第一种:

a := make([]int, 1000)

表示 Slice 中已经存在 1000 个零值元素。

第二种:

b := make([]int, 0, 1000)

表示当前没有元素,只是提前准备了 1000 的容量。

如果后续准备使用 append 添加数据,通常更符合需求的是:

b := make([]int, 0, 1000)

否则:

a := make([]int, 1000)
a = append(a, 10)

10 会成为第 1001 个元素,前面已经存在 1000 个 0。

这是前端开发刚接触 make 时非常容易踩的坑。


五、直接赋值并不等于复制数据

接下来解决另外一个问题:

怎样得到一份真正独立的 Slice?

先看下面的代码:

package main

import "fmt"

func main() {
	original := []int{10, 20, 30}
	copied := original

	copied[0] = 999

	fmt.Println("original:", original)
	fmt.Println("copied:", copied)
}

运行结果:

original: [999 20 30]
copied: [999 20 30]

这一句:

copied := original

并没有复制出一份完全独立的数据。

可以暂时理解为,它复制了 Slice 的描述信息,但两个 Slice 仍然指向同一个底层数组:

original ─┐
          ├─→ [10 20 30]
copied ───┘

所以:

copied[0] = 999

也会让:

original[0]

变成 999。

从前端角度看,它很像:

const original = [10, 20, 30];
const copied = original;

copied[0] = 999;

console.log(original);
// [999, 20, 30]

两个变量引用的是同一个数组对象。


六、使用 make 和 copy 创建独立数据

如果希望复制出独立的 Slice,可以先创建目标空间:

copied := make([]int, len(original))

然后使用:

copy(copied, original)

完整代码:

package main

import "fmt"

func main() {
	original := []int{10, 20, 30}

	copied := make([]int, len(original))
	count := copy(copied, original)

	fmt.Println("复制数量:", count)
	fmt.Println("修改前 original:", original)
	fmt.Println("修改前 copied:", copied)

	copied[0] = 999

	fmt.Println("修改后 original:", original)
	fmt.Println("修改后 copied:", copied)
}

运行结果:

复制数量: 3
修改前 original: [10 20 30]
修改前 copied: [10 20 30]
修改后 original: [10 20 30]
修改后 copied: [999 20 30]

这次修改 copied 后,original 没有发生变化。

因为:

copied := make([]int, len(original))

先创建了新的底层存储。

然后:

copy(copied, original)

把元素复制了进去。

现在两者的关系变成:

original → [10 20 30]

copied   → [999 20 30]

数据一开始相同,但底层数组已经分开。


七、copy 到底会复制多少个元素?

copy 的语法是:

copy(目标Slice, 来源Slice)

例如:

copy(copied, original)

它还会返回实际复制的元素数量:

count := copy(copied, original)

需要特别注意:

实际复制数量取决于目标和来源中较短的一方。

也就是:

复制数量 = min(目标长度, 来源长度)

运行下面的实验:

package main

import "fmt"

func main() {
	source := []int{10, 20, 30, 40}

	shortTarget := make([]int, 2)
	shortCount := copy(shortTarget, source)

	fmt.Println("较短目标:", shortTarget)
	fmt.Println("复制数量:", shortCount)

	longTarget := make([]int, 6)
	longCount := copy(longTarget, source)

	fmt.Println("较长目标:", longTarget)
	fmt.Println("复制数量:", longCount)

	emptyTarget := make([]int, 0, 5)
	emptyCount := copy(emptyTarget, source)

	fmt.Println("只有容量的目标:", emptyTarget)
	fmt.Println("len:", len(emptyTarget))
	fmt.Println("cap:", cap(emptyTarget))
	fmt.Println("复制数量:", emptyCount)
}

运行结果:

较短目标: [10 20]
复制数量: 2

较长目标: [10 20 30 40 0 0]
复制数量: 4

只有容量的目标: []
len: 0
cap: 5
复制数量: 0

最值得注意的是:

emptyTarget := make([]int, 0, 5)
copy(emptyTarget, source)

虽然:

cap = 5

但是:

len = 0

目标 Slice 中没有可以直接访问的位置,所以一个元素都不会被复制。

这再次证明:

是否能够访问和复制元素,取决于 len
不是 cap

因此,复制整个 Slice 时通常写成:

target := make([]int, len(source))
copy(target, source)

八、使用 append 快速复制 Slice

除了 make + copy,还可以这样复制整个 Slice:

copied := append([]int{}, original...)

完整代码:

package main

import "fmt"

func main() {
	original := []int{10, 20, 30}

	copied := append([]int{}, original...)

	copied[0] = 999

	fmt.Println("original:", original)
	fmt.Println("copied:", copied)
}

运行结果:

original: [10 20 30]
copied: [999 20 30]

这里的:

original...

表示把 original 中的元素展开。

下面这句:

append([]int{}, original...)

可以暂时理解成:

append([]int{}, 10, 20, 30)

前面的:

[]int{}

是一个新的空 Slice。

原来的容量不足以容纳这些元素,所以 append 会准备新的底层存储。


从前端视角看:很像展开运算符

JavaScript:

const original = [10, 20, 30];
const copied = [...original];

copied[0] = 999;

console.log(original);
// [10, 20, 30]

console.log(copied);
// [999, 20, 30]

Go:

original := []int{10, 20, 30}
copied := append([]int{}, original...)

两者在当前数字数组的场景下非常接近。

可以先这样选择:

需要明确控制目标长度、获取复制数量
→ make + copy

只是想快速复制整个 Slice
→ append + ...

不过这里要保留一个提醒:

copy 和 append(..., source...)
复制的是 Slice 中的元素
不代表任何情况下都是深拷贝

当前使用的是:

[]int

元素只是整数,所以复制以后已经独立。

如果以后出现:

[][]int

或者 Slice 中存放的是 Map、指针等类型,内部数据仍然可能共享。

这一点等学到嵌套 Slice、Map 和指针以后再继续验证。


九、Go 的 Slice 怎么删除元素?

Go 的 Slice 没有直接对应的:

remove()
splice()

常见删除方式是把目标元素前后的两段重新拼接。

例如删除索引 2,也就是数字 30:

package main

import "fmt"

func main() {
	numbers := []int{10, 20, 30, 40, 50}
	index := 2

	fmt.Println("删除前:", numbers)

	numbers = append(
		numbers[:index],
		numbers[index+1:]...,
	)

	fmt.Println("删除后:", numbers)
	fmt.Println("len:", len(numbers))
	fmt.Println("cap:", cap(numbers))
}

运行结果:

删除前: [10 20 30 40 50]
删除后: [10 20 40 50]
len: 4
cap: 5

删除过程可以拆成两部分。

目标位置左边:

numbers[:index]

结果:

[10 20]

目标位置右边:

numbers[index+1:]

结果:

[40 50]

然后把右边的元素展开,追加到左边:

append([]int{10, 20}, 40, 50)

最终得到:

[10 20 40 50]

可以记成:

删除位置左边的数据
+
删除位置右边的数据

中间的目标元素自然被跳过。


从前端视角看:类似 splice,但底层表现不同

JavaScript:

const numbers = [10, 20, 30, 40, 50];

numbers.splice(2, 1);

console.log(numbers);
// [10, 20, 40, 50]

Go:

numbers = append(
	numbers[:2],
	numbers[3:]...,
)

使用目的比较接近,但 Go 的写法更直接地暴露了切片和底层数组之间的关系。

删除以后:

len:5 → 4
cap:仍然是 5

因为这个操作只是缩短了当前 Slice 的长度,并没有让底层数组的存储空间也跟着缩小。


十、删除操作会改写原来的底层数组

接下来又出现了一个很有意思的实验:

package main

import "fmt"

func main() {
	original := []int{10, 20, 30, 40, 50}
	numbers := original

	index := 2

	numbers = append(
		numbers[:index],
		numbers[index+1:]...,
	)

	fmt.Println("numbers:", numbers)
	fmt.Println("original:", original)

	fmt.Println("numbers len:", len(numbers))
	fmt.Println("numbers cap:", cap(numbers))
}

运行结果:

numbers: [10 20 40 50]
original: [10 20 40 50 50]
numbers len: 4
numbers cap: 5

为什么 original 也发生了变化?

因为:

numbers := original

两个 Slice 仍然共享底层数组。

删除操作:

append(numbers[:2], numbers[3:]...)

会把后面的:

40、50

向前覆盖。

底层数组大概经历了:

原来:[10 20 30 40 50]

覆盖:[10 20 40 50 50]

然后 numbers 的长度变成了 4,所以它只能看到:

[10 20 40 50]

但 original 的长度仍然是 5,所以它看到:

[10 20 40 50 50]

这说明:

Slice 删除元素并不是在底层数组中挖掉一个位置,而是覆盖元素,再缩短当前 Slice 的长度。


十一、不想影响原始数据,就先复制

如果希望删除 numbers 中的数据,但保留 original,可以先创建独立副本:

package main

import "fmt"

func main() {
	original := []int{10, 20, 30, 40, 50}

	numbers := append([]int{}, original...)

	index := 2

	numbers = append(
		numbers[:index],
		numbers[index+1:]...,
	)

	fmt.Println("numbers:", numbers)
	fmt.Println("original:", original)
}

运行结果:

numbers: [10 20 40 50]
original: [10 20 30 40 50]

从前端角度看,这和先使用展开运算符复制数组,再调用 splice 很像:

const original = [10, 20, 30, 40, 50];

const numbers = [...original];

numbers.splice(2, 1);

console.log(numbers);
// [10, 20, 40, 50]

console.log(original);
// [10, 20, 30, 40, 50]

Array、Slice 与 JavaScript Array 对照补充

经过这一轮实验,目前可以整理成:

GoJavaScript
make([]int, 3)类似 new Array(3),但 Go 会填充零值
make([]int, 0, 5)类似空数组,但提前准备了容量
len(slice)array.length
cap(slice)没有直接对应的业务 API
copy(target, source)类似把元素复制到另一个数组
append([]int{}, source...)[...source]
slice2 := slice1两个变量可能共享底层数据
append(slice[:i], slice[i+1:]...)用途类似 splice(i, 1)
Slice 删除后 cap 可能不变JS 业务代码通常不关注底层容量
修改共享底层数组会影响其他 Slice修改同一个数组引用会影响其他变量

这一阶段最容易踩的几个坑

make 的第二个参数是长度

make([]int, 5)

表示已经存在五个零值元素,不只是提前预留五个位置。

如果准备通过 append 添加数据,更可能需要:

make([]int, 0, 5)

有 cap 不代表可以使用下标

numbers := make([]int, 0, 5)

numbers[0] = 10

仍然会越界,因为:

len = 0

能否使用下标,看的是 len。


copy 不会自动创建目标空间

var target []int

copy(target, source)

此时目标长度是 0,所以复制数量也是 0。

正确复制整个 Slice:

target := make([]int, len(source))
copy(target, source)

Slice 直接赋值不会复制底层数据

copied := original

两个 Slice 很可能仍然共享底层数组。

修改其中一个,也可能影响另一个。


删除元素可能影响其他 Slice

numbers = append(
	numbers[:index],
	numbers[index+1:]...,
)

这个操作可能复用并改写原来的底层数组。

如果需要保留原始数据,应先复制:

numbers := append([]int{}, original...)

copy 不是任何情况下的深拷贝

对于:

[]int

复制元素以后基本已经独立。

但如果 Slice 中的元素本身还包含引用关系,内部数据仍然可能共享。

不能看到 copy 就直接理解成“所有数据都完全独立”。


写在最后

这一阶段实际完成了:

make 创建 Slice
→ 设置长度
→ 设置容量
→ 提前分配空间
→ append 使用预留容量
→ 容量不足后扩容
→ Slice 直接赋值
→ make + copy 独立复制
→ copy 的复制数量
→ append + ... 快速复制
→ 删除 Slice 元素
→ 删除操作改写底层数组
→ 复制后再删除,保护原始数据

从前端开发的视角看,make 是一个非常容易产生误解的地方。

刚开始可能会把:

make([]int, 0, 5)

简单理解成:

new Array(5)

但实际运行以后才发现:

Go 的 len 是 0
JavaScript 的 length 是 5

Go 只是提前准备了容量,并没有让这五个元素进入当前 Slice。

copy 也让我进一步确认:

Slice 相等或赋值
不代表底层数据已经复制

如果明确不想共享数据,就需要主动创建新的底层空间。

而删除元素的实验则再次提醒我:

numbers = append(
	numbers[:index],
	numbers[index+1:]...,
)

看起来只是删掉了一个元素,实际上可能直接覆盖原来的底层数组。

这也是学习 Go Slice 后越来越明显的一种感觉:

Slice 表面上很像 JavaScript Array,但 Go 会让开发者更直接地面对长度、容量、底层存储和数据共享关系。

当这些概念真正串起来以后,再回头看:

len
cap
make
append
copy

它们就不再是几个零散的 API,而是一整套围绕底层数组工作的机制。

下一阶段准备进入 Go 的另一个常用集合类型:

Map

接下来要解决的问题是:

Go 的 Map 怎么创建?
怎样新增、修改和删除键值?
访问不存在的键时会发生什么?
怎样判断一个键到底存不存在?
它和 JavaScript Object、Map 又有什么区别?

下一篇见。