这次一定要搞懂Go切片

2024-08-15 22:56:41 浏览数 (2)

Go语言中的切片(slice)是一种动态数组,那么第一个问题来了:

什么是动态数组?它和静态数组有什么区别?

(1)动态数组

动态数组是一种在程序运行时可以根据需要自动调整其大小的数组。与传统的静态数组不同,动态数组不需要在编译时指定其大小,而是在运行时根据需要动态地分配和释放内存空间。 这种灵活性使得动态数组非常适合处理大小未知或大小可能变化的数据集合。

动态数组通常由一些高级编程语言的标准库提供,如C 中的std::vector,Java中的ArrayList,Python中的列表(list)等。这些实现通常提供了自动内存管理功能,包括在添加元素时自动扩展数组容量,以及在删除元素时可能进行的内存收缩(尽管一些实现可能为了效率而保留额外的空间)。

(2)静态数组

静态数组是在编译时确定大小,并在程序的生命周期内保持不变的数组。它们的大小在定义时就已经确定,并且在整个程序执行过程中都保持不变。如果尝试访问数组界限之外的元素,通常会导致未定义行为,比如程序崩溃。

静态数组通常存储在栈(stack)上,或者作为结构体或类的成员时,与结构体或类一起存储在堆(heap)上,其大小都是固定的。

(3)动态数组与静态数组的区别

  • 大小可变性:动态数组的大小可以随着元素的添加或删除而动态变化;静态数组的大小在编译时确定,且在程序运行期间保持不变。
  • 内存管理:动态数组通常会自动管理内存,包括分配和释放;静态数组的内存管理相对简单,因为它们的大小固定,但程序员需要确保不会越界访问。
  • 使用场景:动态数组适合处理大小未知或可能变化的数据集合;静态数组则适合处理大小固定且已知的数据集合。
  • 性能差异:动态数组在添加或删除元素时可能需要重新分配内存(特别是当数组容量不足以容纳更多元素时),这可能会导致一定的性能开销;静态数组则没有这个问题,因为它们的大小固定。然而,在一些情况下,由于动态数组能够避免不必要的内存浪费,因此可能具有更好的整体性能。

Go slice的属性

Go slice包括三个关键的属性:指针、长度和容量。

  • 指针(Pointer):指向数组的第一个元素。
  • 长度(Length):当前切片的长度,即包含的元素数量。
  • 容量(Capacity):从切片的开始位置到底层数组的结尾位置的元素数量。

Go语言的切片是引用类型,它们不存储任何数据,只描述底层数组的一段。更改切片的元素会修改其底层数组中的对应元素。

切片的长度和容量可以通过内置的 len() 和 cap() 函数获取,比如我们执行如下代码

代码语言:go复制
func TestSlice(t *testing.T) {
	var nums []int
	for i := 0; i < 10; i   {
		nums = append(nums, i)
	}
	fmt.Println("nums = ", nums)          // nums =  [0 1 2 3 4 5 6 7 8 9]
	fmt.Println("nums cap = ", cap(nums)) // nums cap =  16
	fmt.Println("nums len = ", len(nums)) // nums len =  10
}

为什么10个元素的数组,容量是16?

因为Go slice的扩容机制:

Go语言的slice扩容策略在不同的版本和slice容量大小下有所不同,但总体思路是相似的,即创建一个更大的底层数组,并将原始数据复制到新数组中。

Go 1.7及之前版本,如果当前容量小于1024,每次扩容后的容量都会翻倍。如果当前容量大于等于1024,扩容后的容量会按照增长因子(默认为1.25)来计算,即新的容量为原容量的1.25倍。

Go 1.8及之后版本,如果当前容量小于256,每次扩容后的容量都会翻倍。如果当前容量大于等于256且小于4096,扩容后的容量会按照增长因子(这里为1.5)来计算。如果当前容量大于等于4096,扩容后的容量会按照增长因子(默认为1.25)来计算。

因为我用的Go版本是1.21,因此容量的走势就是 1、2、4、8、16。

Go slice 扩容的基本流程

Go 语言中 slice 的扩容机制主要通过 growslice 函数实现,该函数位于 Go 语言的 runtime/slice.go 文件中。由于源代码的具体实现可能会随着 Go 语言版本的更新而有所变化,这里将基于通用的理解和较新版本的 Go 语言(如 Go 1.18 及以后)来概述 slice 的扩容机制。

当 slice 的容量不足以容纳新添加的元素时,Go 语言会自动触发扩容机制。扩容的基本流程包括以下几个步骤:

1) 判断当前容量是否足够:首先检查当前 slice 的容量是否足够容纳新添加的元素。如果足够,则直接添加新元素;如果不足,则进行扩容。

2) 计算新的容量:按照上面说的规则。

3) 分配新的内存空间:根据计算出的新容量,分配足够的内存空间来存储新的 slice 底层数组。

4) 拷贝旧数据:将旧 slice 中的数据拷贝到新的内存空间中。

5) 更新 slice 信息:更新 slice 的长度和容量,并调整其底层数组指针指向新的内存空间。

扩容机制的源代码概述

由于直接引用 runtime/slice.go 文件中的 growslice 函数的完整源代码可能涉及大量的内部实现细节和版本特定的代码,这里仅提供该函数的一个大致框架和关键步骤的概述:

代码语言:go复制
// 假设的伪代码,用于说明扩容机制
func growslice(et *_type, old slice, cap int) slice {
    // ... 省略一些细节 ...

    oldCap := old.cap // 旧的容量
    newCap := oldCap  // 初始时,新容量等于旧容量

    // 计算新的容量
    if oldCap < 256 {
        newCap = oldCap * 2 // 容量小于 256 时,通常翻倍
    } else {
        // 容量大于等于 256 时,使用更平滑的增长因子
        // 这里只是一个示例,实际实现可能更复杂
        newCap = oldCap   oldCap/4 // 例如,增加 25%
        // 或者使用更复杂的公式,如 newCap  = (newCap   3*threshold) / 4,其中 threshold 可能是 256 或其他值

        // 确保新容量足够大
        if newCap < cap {
            newCap = cap
        }
    }

    // ... 省略内存分配和拷贝旧数据的细节 ...

    // 返回一个新的 slice,其底层数组指向新分配的内存空间
    return slice{p: newPtr, len: old.len   numNewElems, cap: newCap}
}

Go为什么如此设计slice的扩容机制?

Go 语言中 slice 的扩容机制之所以设计成这样,主要是基于以下几个方面的考虑:

1)性能优化:

  • 减少内存分配次数:通过扩容机制,slice 能够根据需要动态地调整其底层数组的大小,从而减少了因频繁内存分配和释放而产生的性能开销。
  • 空间局部性:在大多数情况下,slice 的扩容策略(如容量翻倍)能够保持数据的空间局部性,即新添加的元素通常位于内存中已分配空间的附近,这有助于提升缓存命中率,进而提高程序的执行效率。

2)内存使用效率

  • 平滑增长:对于较大的 slice,扩容策略会逐渐减缓增长的速度(如从翻倍到增加 25%),以避免一次性分配过多内存导致的浪费。这种平滑增长的方式有助于更好地利用系统内存资源。
  • 减少内存碎片:通过合理的扩容策略,可以减少因频繁分配和释放小块内存而产生的内存碎片,从而提高整个程序的内存使用效率。

3)简化开发者负担

  • 自动管理:slice 的扩容机制对开发者是透明的,开发者无需手动管理底层数组的大小和内存分配,这大大降低了编写高效、安全代码的难度。
  • 灵活性:虽然 slice 的扩容是自动进行的,但开发者仍然可以通过 make 函数在创建 slice 时指定其初始容量,以更好地控制内存的使用和性能表现。

4)平衡扩容与内存消耗

  • 权衡扩容开销与内存占用:扩容过程中需要复制旧 slice 中的数据到新分配的数组中,这会产生一定的性能开销。然而,通过合理的扩容策略(如容量翻倍或平滑增长),可以在保证性能的同时,尽量减少内存占用的浪费。

5)兼容性与未来优化

  • 向后兼容:随着 Go 语言的不断发展,slice 的扩容机制可能会根据实际需要进行调整和优化。然而,这些调整通常会保持向后兼容性,以确保旧代码能够继续在新版本的 Go 语言中正常运行。
  • 持续优化:Go 语言的开发者团队会持续关注 slice 扩容机制的性能表现,并根据用户反馈和内部测试结果进行持续优化,以提升整个语言的性能和用户体验。

综上所述,Go 语言中 slice 的扩容机制是经过精心设计的,旨在通过合理的内存分配和扩容策略来优化程序的性能、内存使用效率和开发者体验。

0 人点赞