Go 语言中数组与切片有什么异同?

八股文一网打尽,更多面试题请看程序员面试刷题神器 - 面试鸭open in new window

回答重点

数组

  • 大小:数组的长度是固定的,编译时确定,无法在运行时改变。
  • 内存:数组是值类型,存储在栈上(或在堆上,根据上下文)。整个数组在内存中是连续的。
  • 传递方式:数组作为参数传递时会进行值传递,复制整个数组的内容。
  • 操作:对数组的任何修改都会影响当前的数组副本。

切片

  • 大小:切片是动态大小的,支持在运行时扩展。由三部分组成:指向底层数组的指针、切片的长度和切片的容量。
  • 内存:切片是引用类型,存储的是对底层数组的引用。当切片扩容时,会创建新的底层数组,并将数据复制到新数组中。
  • 传递方式:切片作为参数传递时是引用传递,传递的是切片的元数据(指针、长度、容量)。
  • 操作:切片可以动态扩展、裁剪等操作,不会改变原数组。
特性数组切片
长度固定,编译时确定动态,可以扩展
类型数组的类型包括元素类型和长度(如 [3]int切片是引用类型,类型为 []T(无长度)
内存管理数组在内存中是连续的,存储在栈上或堆上切片是对底层数组的引用
传递方式值传递,复制整个数组内容引用传递,传递的是切片的元数据(指针、长度、容量)
灵活性不灵活,大小固定灵活,可以通过 append 动态增长
扩展性不支持扩展,大小在编译时固定支持动态扩展,append 会扩展切片容量
性能性能较好,尤其在不需要扩展时扩展切片时会有一定的性能开销,但更适应动态数据操作
操作简便性不能直接扩展或裁剪提供了丰富的内建函数(如 appendcopy

小结

  • 数组:适用于固定大小的集合,不需要扩展或修改大小的场景。内存管理较为简单,但不灵活,且会带来值传递的性能开销。
  • 切片:适用于动态大小的集合,灵活性高,支持扩展和裁剪。需要注意扩容带来的性能损失,使用时需要谨慎管理容量和内存。

扩展知识

数组和切片的实现进一步分析

数组的实现

数组是值类型,其元素在内存中是紧凑且连续的。当数组作为参数传递时,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 capgrowth factor
2562.0
5121.63
10241.44
20481.35
40961.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个容量,减少扩容

八股文一网打尽,更多面试题请看程序员面试刷题神器 - 面试鸭open in new window

最近更新:
Contributors: weave