深入解析 Go 中 Slice

在这里插入图片描述

0 前言

slice 是一种灵活且强大的数据结构,它在功能上类似于其他编程语言中的数组,但提供了更多的灵活性。与数组不同,slice 允许动态调整长度,使其在大多数场景中更加适用。本文将深入解析 slice 的基本概念及底层实现原理,并通过分析一些面试中常见的易错题,加深对 slice 的理解。使用的go版本1.22

1 基本概念

1.1 定义和初始化

slice 是基于数组构建的一个轻量级数据结构,拥有长度和容量两个属性,可以通过多种方式进行初始化。

// 直接初始化
s := []int{1, 2, 3, 4, 5}

// 使用make创建,指定长度和容量
s := make([]int, 3, 5)  // [0 0 0]

// 使用数组切片 包含arr[1]到arr[3],但不包括arr[4]
arr := [5]int{1, 2, 3, 4, 5}
s := arr[1:4]  // [2 3 4] 

1.2 长度和容量

  • 长度 (len): 当前 slice 中的元素个数。
  • 容量 (cap): 从 slice 的起始位置到底层数组末尾的元素个数。
	s := []int{1, 2, 3, 4, 5}
	fmt.Println(len(s)) // 输出: 5
	fmt.Println(cap(s)) // 输出: 5

	s2 := s[1:3]
	fmt.Println(len(s2)) // 输出: 2
	fmt.Println(cap(s2)) // 输出: 4 (底层数组从s[1]开始的元素数)

1.3 切片基本操作

1.3.1 访问和修改元素

与数组类似,slice 可以通过索引访问和修改元素

	s := []int{1, 2, 3}
	fmt.Println(s[1]) // 输出: 2

	s[2] = 4
	fmt.Println(s) // 输出: [1 2 4]
1.3.2 追加

使用内置函数 append 可以动态向 slice 追加元素。如果 slice 的容量不足以容纳新元素,append 会创建一个新的底层数组,并将原有数据复制到新数组中。

	s := []int{1, 2, 3}
	s = append(s, 4, 5)
	fmt.Println(s) // 输出: [1 2 3 4 5]

1.4 总结

相信大家对切片这一数据结构已经不再陌生。前面介绍的只是 slice 的一些基本概念和操作。对于更高级的功能,或者使用中可能遇到的问题,比如扩容策略、函数内部对切片的修改是否会影响外部等,本文将在后面进行详细探讨。

2 底层实现原理

2.1 slice 数据结构

type slice struct {  
    // 指向了内存空间地址的起点. 由于 slice 数据存放在连续的内存空间中,后续可以根据索引 index,在起点的基础上快速进行地址偏移
    array unsafe.Pointer
    // 切片长度
    len   int
    // 切片容量 >=len
    cap   int
}

2.2 创建切片

2.2.1 源码

先走一遍源码runtime/slice.go再图解各种初始化的区别。

func makeslice(et *_type, len, cap int) unsafe.Pointer {
  // 根据cap和每个元素类型大小,计算总容量
	mem, overflow := math.MulUintptr(et.Size_, uintptr(cap))
  // 容量溢出或超限或长度小于0或长度大于容量,直接panic
	if overflow || mem > maxAlloc || len < 0 || len > cap {
		// NOTE: Produce a 'len out of range' error instead of a
		// 'cap out of range' error when someone does make([]T, bignumber).
		// 'cap out of range' is true too, but since the cap is only being
		// supplied implicitly, saying len is clearer.
		// See golang.org/issue/4085.
		mem, overflow := math.MulUintptr(et.Size_, uintptr(len))
		if overflow || mem > maxAlloc || len < 0 {
			panicmakeslicelen()
		}
		panicmakeslicecap()
	}
  // 进行内存分配和初始化
	return mallocgc(mem, et, true)
}
2.2.2 make 指定len和cap 创建

在这里插入图片描述

通过make函数创建了一个len=3cap=5的切片。内存申请了5个int大小的连续内存空间。但是后面两个暂时是访问不到的,slice[3]会直接panic的。index out of range [3] with length 3

2.2.3 直接初始化

在这里插入图片描述

直接初始化创建的是一个 len = 5cap = 5的切片,这时候数组里面每个元素的值都初始化完成了。

2.2.4 使用数组切片

在这里插入图片描述

2.3 切片追加

在 Go 中,append 是一个用于向切片(slice)中追加元素的内置函数。

	s := []int{1, 2, 3}
	s = append(s, 4, 5) //[1,2,3,4,5]

动态扩容

append 会根据需要自动扩展 slice 的容量。如果当前的 slice 容量足够大,append 会将新元素直接添加到现有的 slice 中;如果容量不足,append 会分配一个更大的底层数组,并将现有的元素复制到新的数组中,再添加新元素。扩容策略后面展开讲

	s := make([]int, 3, 5) 
	s = append(s, 4, 5)
	fmt.Println(s)      // [0 0 0 4 5]
	fmt.Println(cap(s)) // 5

	s = append(s, 6)
	fmt.Println(s)      // [0 0 0 4 5 6]
	fmt.Println(cap(s)) // 10 

追加另一个切片

	s1 := []int{1, 2, 3}
	s2 := []int{4, 5, 6}
	s1 = append(s1, s2...) // 将s2展开并追加到s1 [1,2,3,4,5,6]

函数内append对外部slice的影响

func modifySlice(s []int) []int {
	s = append(s, 4) // 触发扩容了
	s[0] = 100
	fmt.Println(cap(s)) // 6
	return s
}

func main() {
	s := []int{1, 2, 3}
	fmt.Println(s) // [1 2 3]

	s2 := modifySlice(s)
	fmt.Println(s)  //  [1 2 3]
	fmt.Println(s2) //  [100 2 3 4]
}

2.4 切片扩容

func nextslicecap(newLen, oldCap int) int {
	newcap := oldCap
	doublecap := newcap + newcap
  // 新的容量大于原容量的2倍,直接返回新的容量
	if newLen > doublecap {
		return newLen
	}
	const threshold = 256
  // 原来容量小于256,则扩容后的容量为原容量的2倍
	if oldCap < threshold {
		return doublecap
	}
	for {
    // 原容量*1.25+192
    // 一直循环 直到满足
		newcap += (newcap + 3*threshold) >> 2
		if uint(newcap) >= uint(newLen) {
			break
		}
	}
  // 溢出直接取预期的新容量
	if newcap <= 0 {
		return newLen
	}
	return newcap
}
func growslice(oldPtr unsafe.Pointer, newLen, oldCap, num int, et *_type) slice {
	oldLen := newLen - num
	if raceenabled {
		callerpc := getcallerpc()
		racereadrangepc(oldPtr, uintptr(oldLen*int(et.Size_)), callerpc, abi.FuncPCABIInternal(growslice))
	}
	if msanenabled {
		msanread(oldPtr, uintptr(oldLen*int(et.Size_)))
	}
	if asanenabled {
		asanread(oldPtr, uintptr(oldLen*int(et.Size_)))
	}

	if newLen < 0 {
		panic(errorString("growslice: len out of range"))
	}

	if et.Size_ == 0 {
    // 当前切片大小为0,直接返回一个新的容量的切片
		return slice{unsafe.Pointer(&zerobase), newLen, newLen}
	}

  // 具体代码上面解释了
	newcap := nextslicecap(newLen, oldCap)

  //根据容量,计算所需的内存空间大小
	var overflow bool
  // lenmem 旧切片长度所占用的内存大小
  // newlenmem 新切片长度所占用的内存大小
  // capmem 新切片容量所需的内存大小
  // noscan 切片元素是否包含指针
	var lenmem, newlenmem, capmem uintptr
	noscan := et.PtrBytes == 0
	switch {
	case et.Size_ == 1:
    // 数组元素大小为1
		lenmem = uintptr(oldLen)
		newlenmem = uintptr(newLen)
    // 函数计算需要的容量,并调整到合适的大小,将大小对齐到内存分配的 span class
		capmem = roundupsize(uintptr(newcap), noscan)
		overflow = uintptr(newcap) > maxAlloc
		newcap = int(capmem)
	case et.Size_ == goarch.PtrSize:
    // 数组元素为指针类型,则根据指针占用空间结合元素个数计算空间大小
		lenmem = uintptr(oldLen) * goarch.PtrSize
		newlenmem = uintptr(newLen) * goarch.PtrSize
		capmem = roundupsize(uintptr(newcap)*goarch.PtrSize, noscan)
		overflow = uintptr(newcap) > maxAlloc/goarch.PtrSize
		newcap = int(capmem / goarch.PtrSize)
	case isPowerOfTwo(et.Size_):
    // 数组元素大小是2的指数,通过位运算进行空间大小的计算 
		var shift uintptr
		if goarch.PtrSize == 8 {
			shift = uintptr(sys.TrailingZeros64(uint64(et.Size_))) & 63
		} else {
			shift = uintptr(sys.TrailingZeros32(uint32(et.Size_))) & 31
		}
		lenmem = uintptr(oldLen) << shift
		newlenmem = uintptr(newLen) << shift
		capmem = roundupsize(uintptr(newcap)<<shift, noscan)
		overflow = uintptr(newcap) > (maxAlloc >> shift)
		newcap = int(capmem >> shift)
		capmem = uintptr(newcap) << shift
	default:
    // 其他情况,直接使用乘法计算
		lenmem = uintptr(oldLen) * et.Size_
		newlenmem = uintptr(newLen) * et.Size_
		capmem, overflow = math.MulUintptr(et.Size_, uintptr(newcap))
		capmem = roundupsize(capmem, noscan)
		newcap = int(capmem / et.Size_)
		capmem = uintptr(newcap) * et.Size_
	}

  // 内存超出了可分配的范围 直接panic
	if overflow || capmem > maxAlloc {
		panic(errorString("growslice: len out of range"))
	}

	var p unsafe.Pointer
	if et.PtrBytes == 0 {
    // 无指针类型 直接mallocgc 分配内存
		p = mallocgc(capmem, nil, false)
		memclrNoHeapPointers(add(p, newlenmem), capmem-newlenmem)
	} else {
		p = mallocgc(capmem, et, true)
		if lenmem > 0 && writeBarrier.enabled {
      // 触发写屏障
			bulkBarrierPreWriteSrcOnly(uintptr(p), uintptr(oldPtr), lenmem-et.Size_+et.PtrBytes, et)
		}
	}
  // 将原切片内容拷贝到新的
	memmove(p, oldPtr, lenmem)

	return slice{p, newLen, newcap}
}

扩容流程如下:

在这里插入图片描述

2.5 切片拷贝

切片拷贝(Slice Copy)指的是将一个切片的内容复制到另一个切片中。Go 提供了一些简单而高效的方式来进行切片的拷贝,主要通过内置的 copy 函数来实现

src := []int{1, 2, 3, 4, 5}
dst := make([]int, 3)

n := copy(dst, src)
	
 // 两个切片是独立的,修改不会相互影响
src[0]=100 
dst[0]=10

fmt.Println("Copied elements:", n)     // Copied elements: 3
fmt.Println("Source slice:", src)      // Source slice: [100 2 3 4 5]
fmt.Println("Destination slice:", dst) // Destination slice: [10 2 3]

还有一种方式就是直接赋值的方式,这种方式实际底层还是指向的同一个地址,除非进行了扩容,否则修改原切片,复制的切片也是跟着修改

src := []int{1, 2, 3, 4, 5}
dst := src[1:]

src[1] = 100

fmt.Println("Source slice:", src)      // 输出:Source slice: [1 100 3 4 5]
fmt.Println("Destination slice:", dst) // 输出:Destination slice: [100 3 4 5]

3 常见面试题案例

3.1 切片扩容和底层数组共享

修改一个切片的元素可能会影响其他共享相同底层数组的切片。

arr := []int{1, 2, 3, 4, 5}
s1 := arr[1:4] // s1 = [2, 3, 4]
s1[0] = 10
fmt.Println("Array:", arr) //[1 10 3 4 5]

问题: s1arr 共享相同的底层数组,因此 s1 的修改会反映到 arr

3.2 切片扩容导致的不可预期行为

切片在扩容时,可能会重新分配底层数组,导致指向旧底层数组的切片失效。

s1 := []int{1, 2, 3}
s2 := s1
s1 = append(s1, 4, 5, 6)

fmt.Println("s1:", s1) // [1 2 3 4 5 6]
fmt.Println("s2:", s2) // [1 2 3]

问题:s1 扩容后,s2 仍指向旧的底层数组,因此 s2 不会看到扩容后的数据。

3.3 切片的容量和长度
s := []int{1, 2, 3}
s = append(s, 4)
fmt.Println("Length:", len(s)) // Length: 4
fmt.Println("Capacity:", cap(s)) // Capacity: 6 (根据扩容策略)
3.4 空切片的判定
var s1 []int
s2 := []int{}
fmt.Println(s1 == nil) //  true
fmt.Println(s2 == nil) //  false
3.5 切片切割

切片切割时,使用错误的索引或边界导致运行时错误。

s := []int{1, 2, 3, 4, 5}
fmt.Println(s[1:10]) // panic :slice bounds out of range [:10] with capacity 5

问题: 切片的切割操作超出了原切片的边界

3.6 切片传递到函数

修改切片的函数参数会影响原切片,扩容之后会指向一个新的底层数组。

func modifySlice(s []int) {
	s = append(s, 20)
	s[0] = 10
}

func main() {
	s := []int{1, 2, 3}
	modifySlice(s)
	fmt.Println("Slice after function:", s) // Slice after function: [1 2 3]
}

问题:

append 操作没有影响到原切片的容量,因为 s 在函数内重新指向了一个新的底层数组。

3.7 越界访问
s := make([]int, 10, 12)
v := s[10]

问题: 会发生 panic,切片长度为10,容量为 12,判断是否越界以长度为准, index = 10 已经越界.

3.8 扩容策略
s := make([]int, 512)
s = append(s, 1)
fmt.Printf("len of s: %d, cap of s: %d", len(s), cap(s))// len of s: 513, cap of s: 848

这道题目实际上考察的是切片的扩容策略以及内存对齐的知识。我们可以先回顾一下之前提到的扩容流程。

当切片 s 的原始容量为 512 时,由于已经超过了 256 的阈值,因此扩容后的新容量计算为 512 * 1.25 + 192 = 832

在申请内存时,依据切片元素的大小乘以容量来计算所需的内存空间,这里的结果是 8 字节 * 832 = 6656

随后,根据内存对齐的原则,内存会被补齐到 6784 这个档次。这样计算后,6784 / 8 = 848,因此最终的容量为 848。

如果您觉得有帮助,请关注我,另公众号【小张的编程世界】,如有任何错误或建议,欢迎指出。感谢您的阅读!

  • 23
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

小张的编程旅途

您的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值