Go 语言中数组与切片有什么异同?
八股文一网打尽,更多面试题请看程序员面试刷题神器 - 面试鸭
回答重点
数组
- 大小:数组的长度是固定的,编译时确定,无法在运行时改变。
- 内存:数组是值类型,存储在栈上(或在堆上,根据上下文)。整个数组在内存中是连续的。
- 传递方式:数组作为参数传递时会进行值传递,复制整个数组的内容。
- 操作:对数组的任何修改都会影响当前的数组副本。
切片
- 大小:切片是动态大小的,支持在运行时扩展。由三部分组成:指向底层数组的指针、切片的长度和切片的容量。
- 内存:切片是引用类型,存储的是对底层数组的引用。当切片扩容时,会创建新的底层数组,并将数据复制到新数组中。
- 传递方式:切片作为参数传递时是引用传递,传递的是切片的元数据(指针、长度、容量)。
- 操作:切片可以动态扩展、裁剪等操作,不会改变原数组。
| 特性 | 数组 | 切片 |
|---|---|---|
| 长度 | 固定,编译时确定 | 动态,可以扩展 |
| 类型 | 数组的类型包括元素类型和长度(如 [3]int) | 切片是引用类型,类型为 []T(无长度) |
| 内存管理 | 数组在内存中是连续的,存储在栈上或堆上 | 切片是对底层数组的引用 |
| 传递方式 | 值传递,复制整个数组内容 | 引用传递,传递的是切片的元数据(指针、长度、容量) |
| 灵活性 | 不灵活,大小固定 | 灵活,可以通过 append 动态增长 |
| 扩展性 | 不支持扩展,大小在编译时固定 | 支持动态扩展,append 会扩展切片容量 |
| 性能 | 性能较好,尤其在不需要扩展时 | 扩展切片时会有一定的性能开销,但更适应动态数据操作 |
| 操作简便性 | 不能直接扩展或裁剪 | 提供了丰富的内建函数(如 append、copy) |
小结:
- 数组:适用于固定大小的集合,不需要扩展或修改大小的场景。内存管理较为简单,但不灵活,且会带来值传递的性能开销。
- 切片:适用于动态大小的集合,灵活性高,支持扩展和裁剪。需要注意扩容带来的性能损失,使用时需要谨慎管理容量和内存。
扩展知识
数组和切片的实现进一步分析
数组的实现
数组是值类型,其元素在内存中是紧凑且连续的。当数组作为参数传递时,Go 会通过值传递进行完整的复制,复制的是整个数组的内容。数组长度是数组类型的一部分,例如 [3]int 和 [4]int 是不同类型,编译器在编译时就会决定数组的大小。
切片的实现
切片的底层结构包含三个元素:指针、长度和容量(array, len, cap)。切片的指针指向底层数组的起始位置。切片的长度是当前有效元素的数量,容量是底层数组的总大小。由于切片是引用类型,因此切片本身并不存储数据,只是一个对底层数组的视图。
当切片的容量不足时,Go 会通过 append 操作扩容。扩容时,Go 会分配一个新的更大的底层数组,并将原有数据复制到新数组中。通常,扩容的容量会翻倍,直到内存不足时会按一定策略调整容量。在源码层面,append 函数中会检查切片的当前容量,若不足则调用 grow 函数扩展容量:
func grow(slice []T, newCap int) []T {
if newCap <= cap(slice) {
return slice
}
newSlice := make([]T, len(slice), newCap)
copy(newSlice, slice)
return newSlice
}
grow 函数通过 make 创建了一个新的切片并复制数据。这一扩容操作带来了内存开销。
数组的常见用法
常用于需要固定大小、无法变化的情况。比如,实现固定大小的缓冲区或储存特定数量的数据。
var arr [5]int // 定义一个长度为5的数组
arr[0] = 10 // 可以直接访问和修改元素
由于数组是值类型,传递时会复制整个数组。在函数参数中使用数组时,需要考虑到性能开销。如果数组非常大,可能会带来不必要的复制开销。
func modifyArray(arr [5]int) {
arr[0] = 100 // 修改数组不会影响原数组
}
切片的常见用法
切片的最大优势之一是它的动态扩展。可以通过 append 函数向切片添加元素,Go 会自动处理底层数组的扩容。
slice := []int{1, 2, 3}
slice = append(slice, 4, 5) // 切片会自动扩容
通过切片操作,可以从一个切片中获取子切片。这不会复制底层数据,只是创建了一个新的切片指向原数组的某一部分。
slice := []int{1, 2, 3, 4, 5}
subSlice := slice[1:4] // 获取一个子切片,指向原数组的一部分
切片的内存泄漏问题:由于切片引用底层数组,如果切片被泄露或超出了预期范围,底层数组可能会占用不必要的内存。要避免这种情况,应及时切割掉不需要的部分,或者通过手动管理内存来避免泄漏。
切片扩容机制
当扩容时,Go 会为新的切片分配一个更大的底层数组,并将原数据复制过去。这个操作可能会带来性能上的开销,特别是在处理大量数据时。
扩容逻辑示意代码:
func append(slice []int, value int) []int {
if len(slice) == cap(slice) {
// 如果切片满了,则扩容
newSlice := make([]int, len(slice), 2*cap(slice)) // 容量翻倍
copy(newSlice, slice)
slice = newSlice
}
slice = append(slice, value)
return slice
}
扩容的大小:
- 在 1.18 以前:容量小于 1024 时为 2 倍扩容,大于等于 1024 时为 1.25 倍扩容。
- 在 1.18 及以后:容量小于 256 时为 2 倍扩容,大于等于 256 时的扩容因子逐渐从 2 减低为 1.25。
| starting cap | growth factor |
|---|---|
| 256 | 2.0 |
| 512 | 1.63 |
| 1024 | 1.44 |
| 2048 | 1.35 |
| 4096 | 1.30 |
// 1.18 及之后版本
// nextslicecap 计算下一个合适的切片容量。
// 参数 newLen 是目标切片的长度,oldCap 是当前切片的容量。
// 返回值是根据新的长度和当前容量计算出的最适合的容量。
func nextslicecap(newLen, oldCap int) int {
newcap := oldCap // 初始化新的容量为当前容量
doublecap := newcap + newcap // 计算当前容量的两倍
if newLen > doublecap { // 如果目标长度大于两倍当前容量,则返回目标长度
return newLen
}
const threshold = 256 // 定义一个阈值,当容量小于阈值时,采用2倍增长
if oldCap < threshold { // 如果当前容量小于阈值,则返回当前容量的2倍
return doublecap
}
for {
// 对于较小的切片,容量增长为2倍;对于较大的切片,容量增长为1.25倍。
// 这个公式实现了在两者之间的平滑过渡。
newcap += (newcap + 3*threshold) >> 2 // 增长1.25倍
// 需要检查 newcap 是否大于等于 newLen,并且检查 newcap 是否溢出。
// 因为 newLen 保证大于零,因此当 newcap 溢出时,uint(newcap) 会大于 uint(newLen)。
// 这样我们可以通过相同的比较来检查这两个条件。
if uint(newcap) >= uint(newLen) {
break // 如果新容量大于等于目标长度,跳出循环
}
}
// 如果 newcap 计算溢出,设置 newcap 为目标长度。
// 因为容量溢出时,newcap 可能变为负数或 0,因此返回目标长度。
if newcap <= 0 {
return newLen
}
return newcap // 返回计算出的合适容量
}
延伸问题:如何避免切片频繁扩容?
可以通过预先分配合适的容量来减少扩容的次数。
slice := make([]int, 0, 1000) // 预分配1000个容量,减少扩容
八股文一网打尽,更多面试题请看程序员面试刷题神器 - 面试鸭
