七叶笔记 » golang编程 » 从源码开始分析Go语言的切片

从源码开始分析Go语言的切片

本文约3439字,建议收藏后阅读。

我的个人博客:www.godeamon.com。博客看代码更加方便。

Go

1.切片是啥

Go 的 Slice(切片)类型提供了一种方便有效的方法来处理类型化数据序列。slice 与其他语言的数组类似却又不同,简单来说,切片更加灵活,用起来更方便,其原因就是可以扩容。

2.举例分析

2.1. 认识切片第一步

 package main

import "fmt"

func main() {
sliceExample()
}

func sliceExample() {
slc := make([]int, 0)
slc = append(slc, 1)
fmt.Println(slc)

var slc1 []int
slc1 = append(slc1, 1)
fmt.Println(slc1)

var slc2 []int
slc2 = append(slc2, 1)
fmt.Println(slc2)
}  

上面的代码是最基本的使用方式,首先看一下上面的代码在底层都做了哪些东西(不要害怕汇编,其实很简单,我们主要明白部分汇编代码即可)

命令:go tool compile -S slice.go 我的 go 文件为 main/slice.go

 "".main STEXT size=48 args=0x0 locals=0x8
0x0000 00000 (slice.go:5)TEXT"".main(SB), ABIInternal, $8-0
......
0x0021 00033 (slice.go:10)PCDATA$2, $1
0x0021 00033 (slice.go:10)PCDATA$0, $0
0x0021 00033 (slice.go:10)LEAQtype.int(SB), AX
0x0028 00040 (slice.go:10)PCDATA$2, $0
0x0028 00040 (slice.go:10)MOVQAX, (SP)
0x002c 00044 (slice.go:10)XORPSX0, X0
0x002f 00047 (slice.go:10)MOVUPSX0, 8(SP)
0x0034 00052 (slice.go:10)CALL runtime .makeslice(SB)
0x0039 00057 (slice.go:10)PCDATA$2, $1
0x0039 00057 (slice.go:10)MOVQ24(SP), AX
0x003e 00062 (slice.go:11)PCDATA$2, $2
0x003e 00062 (slice.go:11)LEAQtype.int(SB), CX
0x0045 00069 (slice.go:11)PCDATA$2, $1
0x0045 00069 (slice.go:11)MOVQCX, (SP)
0x0049 00073 (slice.go:11)PCDATA$2, $0
0x0049 00073 (slice.go:11)MOVQAX, 8(SP)
0x004e 00078 (slice.go:11)XORPSX0, X0
0x0051 00081 (slice.go:11)MOVUPSX0, 16(SP)
0x0056 00086 (slice.go:11)MOVQ$1, 32(SP)
0x005f 00095 (slice.go:11)CALLruntime.growslice(SB)
0x0064 00100 (slice.go:11)PCDATA$2, $1
0x0064 00100 (slice.go:11)MOVQ40(SP), AX
0x0069 00105 (slice.go:11)MOVQ48(SP), CX
0x006e 00110 (slice.go:11)MOVQ56(SP), DX
0x0073 00115 (slice.go:11)MOVQ$1, (AX)
0x007a 00122 (slice.go:12)PCDATA$2, $0
0x007a 00122 (slice.go:12)MOVQAX, (SP)
0x007e 00126 (slice.go:11)LEAQ1(CX), AX
0x0082 00130 (slice.go:12)MOVQAX, 8(SP)
0x0087 00135 (slice.go:12)MOVQDX, 16(SP)
0x008c 00140 (slice.go:12)CALLruntime.convTslice(SB)
......
0x00e8 00232 (slice.go:15)PCDATA$2, $1
0x00e8 00232 (slice.go:15)LEAQtype.int(SB), AX
0x00ef 00239 (slice.go:15)PCDATA$2, $0
0x00ef 00239 (slice.go:15)MOVQAX, (SP)
0x00f3 00243 (slice.go:15)XORPSX0, X0
0x00f6 00246 (slice.go:15)MOVUPSX0, 8(SP)
0x00fb 00251 (slice.go:15)MOVQ$0, 24(SP)
0x0104 00260 (slice.go:15)MOVQ$1, 32(SP)
0x010d 00269 (slice.go:15)CALLruntime.growslice(SB)
0x0112 00274 (slice.go:15)PCDATA$2, $1
0x0112 00274 (slice.go:15)MOVQ40(SP), AX
0x0117 00279 (slice.go:15)MOVQ48(SP), CX
0x011c 00284 (slice.go:15)MOVQ56(SP), DX
0x0121 00289 (slice.go:15)MOVQ$1, (AX)
0x0128 00296 (slice.go:16)PCDATA$2, $0
0x0128 00296 (slice.go:16)MOVQAX, (SP)
0x012c 00300 (slice.go:15)LEAQ1(CX), AX
0x0130 00304 (slice.go:16)MOVQAX, 8(SP)
0x0135 00309 (slice.go:16)MOVQDX, 16(SP)
0x013a 00314 (slice.go:16)CALLruntime.convTslice(SB)
......
0x0196 00406 (slice.go:18)PCDATA$2, $1
0x0196 00406 (slice.go:18)LEAQtype.[0]int(SB), AX
0x019d 00413 (slice.go:18)PCDATA$2, $0
0x019d 00413 (slice.go:18)MOVQAX, (SP)
0x01a1 00417 (slice.go:18)CALLruntime.newobject(SB)
0x01a6 00422 (slice.go:19)PCDATA$2, $1
0x01a6 00422 (slice.go:19)LEAQtype.int(SB), AX
0x01ad 00429 (slice.go:19)PCDATA$2, $0
0x01ad 00429 (slice.go:19)MOVQAX, (SP)
0x01b1 00433 (slice.go:19)XORPSX0, X0
0x01b4 00436 (slice.go:19)MOVUPSX0, 16(SP)
0x01b9 00441 (slice.go:19)MOVQ$1, 32(SP)
0x01c2 00450 (slice.go:19)CALLruntime.growslice(SB)
0x01c7 00455 (slice.go:19)PCDATA$2, $1
0x01c7 00455 (slice.go:19)MOVQ40(SP), AX
0x01cc 00460 (slice.go:19)MOVQ48(SP), CX
0x01d1 00465 (slice.go:19)MOVQ56(SP), DX
0x01d6 00470 (slice.go:19)MOVQ$1, (AX)
0x01dd 00477 (slice.go:20)PCDATA$2, $0
0x01dd 00477 (slice.go:20)MOVQAX, (SP)
0x01e1 00481 (slice.go:19)LEAQ1(CX), AX
0x01e5 00485 (slice.go:20)MOVQAX, 8(SP)
0x01ea 00490 (slice.go:20)MOVQDX, 16(SP)
0x01ef 00495 (slice.go:20)CALLruntime.convTslice(SB)  

上面汇编我们不需要全部看明白只需要看懂6、11、15、23、34、37、44、55、58、61、69、80这几行就可以,也就是下面的代码:

 slc := make([]int, 0)
slc = append(slc, 1)
fmt.Println(slc)

0x0021 00033 (slice.go:10)LEAQtype.int(SB), AX
0x0034 00052 (slice.go:10)CALLruntime.makeslice(SB)
0x003e 00062 (slice.go:11)LEAQtype.int(SB), CX
0x005f 00095 (slice.go:11)CALLruntime.growslice(SB)
0x008c 00140 (slice.go:12)CALLruntime.convTslice(SB)
============================
var slc1 []int
slc1 = append(slc1, 1)
fmt.Println(slc1)

0x00e8 00232 (slice.go:15)LEAQtype.int(SB), AX
0x010d 00269 (slice.go:15)CALLruntime.growslice(SB)
0x013a 00314 (slice.go:16)CALLruntime.convTslice(SB)
0x0196 00406 (slice.go:18)LEAQtype.[0]int(SB), AX
============================
slc2 := []int{}
slc2 = append(slc2, 1)
fmt.Println(slc2)

0x01a1 00417 (slice.go:18)CALLruntime.newobject(SB)
0x01c2 00450 (slice.go:19)CALLruntime.growslice(SB)
0x01ef 00495 (slice.go:20)CALLruntime.convTslice(SB)  

为什么要看这些汇编代码呢?go 里面很多内置的方法我们是不能直接找到对应的实现函数的,所以我们通过这个 go tool compile 工具来看一下,就知道了。上面代码的第5、6行,就是 make slice 的实现,也就是说调用的函数就是 runtime 包中的 makeslice 函数。接下来的7、8行就是 appen 的具体实现,同样是 runtime 包,growslice 函数。第9行就是 fmt.Println 的具体实现,也就是 runtime.convTslice 函数。这样子我们就知道该去哪里看对应代码了。其实通过汇编能看到很多东西,这里我们只说 slice,以后有机会会继续和大家分享。

上面的汇编代码我分为了三部分,也就是对应三种 slice 的声明,估计大家都看出来了,每种声明方式对应的实现函数都是不一样的,第一个是 runtime.makeslice,第二个没有对应的实现,第三个是 runtime.newobject,这三种方式区别还是有的,但是最终都可以实现我们的目标,因为最终都是调用了 runtime.mallocgc 函数,也就是分配内存,看到这有同学就会问了,第二种方式我们并没有对应的实现啊,也就是说并没有分配内存啊。是的,第二种方式我们确实没有直接分配内存,并且我们直接 append 了,其实是因为 append 操作会对没有分配内存的切片再分配一下内存(感兴趣的同学可以仔细看一下 growslice 源码)。所以我们在写代码时,使用 var 关键字声明切片是完全可以的,并且对于长度为0的切片我们非常建议这样声明。

我们简单看一下 makeslice 代码:

 func makeslice(et *_type, len, cap int) unsafe.Pointer {
    // 判断 cap 是否溢出mem, overflow := math.MulUintptr(et.size, uintptr(cap))
    
    // 溢出或者 len、cap 不符合要求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)
}  

再看一下 newobject 代码:

 func newobject(typ *_type) unsafe.Pointer {
return mallocgc(typ.size, typ, true)
}  

没错,上面两个的最终都是调用 mallocgc 函数。只不过 makeslice 先做了一些判断。

2.2. 切片源码

我们看一下切片的源码(go/src/runtime/slice.go):

 type slice struct {
array unsafe.Pointer
len   int
cap   int
}  

就是这么简单,一个切片就是三个字段,第一个是内存开始的指针,第二个是切片的长度,最后一个就是切片的容量。slice.go 文件中其他函数都是针对切片的函数:

  • makeslice:初始化切片。
  • makeslice64:初始化长度和容量为 int64 类型的切片,最终还是调用 makeslice。
  • growslice:切片扩容(内置 append 函数)。
  • slicecopy:切片的拷贝(内置 copy 函数)。

2.3. 切片和数组

切片是数组的片段,也就是说从数组上截取一段,然后我们切片可以在这个数组上“活动”,同样我们都可以使用下标来访问某个元素,但是如果我们给切片添加元素时,如果长度超过了当前数组的长度,那我们就再寻找一个新的内存,同样是新的数组、新的切片,但是对于我们代码使用者来说,切片还是不变的,但是它在内存的位置改变了。

2.4. append

append 应该是我们最常用的操作了,在文章开始部分我们已经看过简单的汇编语言了,并且已经知道其源码就是 runtime/slice.go 中的 growslice 函数。估计大家都知道切片是如何扩容的,网上大多数资料都是当原 slice 容量小于 1024 的时候,新 slice 容量变成原来的 2 倍;原 slice 容量超过 1024,新 slice 容量变成原来的1.25倍。这样说我们不能说完全正确,这样的说法还不算严谨。为啥不严谨呢,我们接着往下看。

append 参数可以是多个,那么我们就有以下两种写法:

 s := []string{"a", "b"}

// append 多个元素s := append(s,"a", "b", "c")

// append 单个元素s = append(s, "c")
s = append(s, "d")
s = append(s, "e")  

那么这两种写法最后的切片 s 的长度和容量都是多少呢?

 s := []string{"a", "b"} // 此时切片长度为2,容量也为2。s = append(s, "c")
s = append(s, "d")
s = append(s, "e")
fmt.Printf("len=%d, cap=%d", len(s), cap(s)) // 结果:len=5, cap=8  
 s := []string{"a", "b"} // 此时切片长度为2,容量也为2。s = append(s, "c", "d", "e")
fmt.Printf("len=%d, cap=%d", len(s), cap(s)) // 结果:len=5, cap=5  

事实证明,不同的写法对切片最终的容量是有影响的。也就是说,对于 append 多个参数时,并不会对每一个元素添加是都会进行扩容,而是对整体的所有元素来进行扩容,并且在元素类型不同时,最终的容量也是不同的。

 s := []int{1, 2} // 此时切片长度为2,容量也为2。s = append(s, 3, 4, 5)
fmt.Printf("len=%d, cap=%d", len(s), cap(s)) // 结果:len=5, cap=6  

其原因是元素类型所占的内存大小是不一样的,从而导致 append 操作时进行 内存对齐 的结果也不一样(内存对齐这里不再赘述,以后有时间也会写类似文章)。所以我们上面三种代码最终的结果都是不同的。

结论:在 appen 单个元素时,扩容规律确实是2倍或者1.25倍,但是appen 多个元素时,结果和元素类型是相关的,容量最小和长度相同。

2.5. 切片截取

我们还要注意的是,扩容时如果容量大于原有数据的长度,我们重新分配内存,其操作不会影响原有的数据。但是没有分配新的内存,也就是说还是原来数组的基础上添加元素,那么新的切片操作就会影响原有的数组。这部分依然不再赘述,看一下下面代码,大家都明白了:

 s := []int{1, 2, 3, 4}
s1 := s[1:2]
fmt.Printf("len=%d, cap=%d\n", len(s), cap(s)) // len=4, cap=4fmt.Println(s) // [1 2 3 4]fmt.Println(s1) // [2]fmt.Printf("len=%d, cap=%d\n", len(s1), cap(s1)) // len=1, cap=3

s1[0] = 5
fmt.Println(s) // [1 5 3 4]fmt.Println(s1) // [5]

s1 = append(s1, 1)
fmt.Printf("len=%d, cap=%d\n", len(s1), cap(s1)) // len=2, cap=3s1[0] = 6
fmt.Println(s) // [1 6 1 4]fmt.Println(s1) // [6 1]

s1 = append(s1, 1, 2, 3)
fmt.Printf("len=%d, cap=%d\n", len(s1), cap(s1)) // len=5, cap=6s1[0] = 7
fmt.Println(s) // [1 6 1 4]fmt.Println(s1) // [7 1 1 2 3]  

另外还有一点,切片截取也是可以截取 cap 的:

 s := []int{1, 2, 3, 4}
fmt.Printf("len=%d, cap=%d\n", len(s), cap(s)) // len=4, cap=4

s1 := s[1:2:3]
fmt.Printf("len=%d, cap=%d\n", len(s1), cap(s1)) // len=1, cap=2

 s2  := s[1:2]
fmt.Printf("len=%d, cap=%d\n", len(s2), cap(s2)) // len=1, cap=3  

2.6. 拷贝

切片的拷贝可以使用内置 copy 函数:

 s := []int{1, 2, 3, 4}
var s1 []int
copy(s1, s)
fmt.Println(s1) // []fmt.Println(s) // [1 2 3 4]

s1 = make([]int, 2)
count := copy(s1, s)
fmt.Println(s1) // [1 2]fmt.Println(s) // [1 2 3 4]fmt.Println(count) // 2

s1[0] = 5
fmt.Println(s1) // [5 2]fmt.Println(s) // [1 2 3 4]  

上面的例子可以看出来,copy 只会拷贝目标切片长度个元素,并且 copy 后两个切片是没有影响的。

还有一种更加高效的实现方式:

 s2 := append(s[:0:0], s...)
fmt.Println(s2) // [1 2 3 4]s2[0] = 5
fmt.Println(s2) // [5 2 3 4]fmt.Println(s) // [1 2 3 4]  

上面这种方式算是一个比较取巧的方法,并且可以达到 copy 的效果,并且速度要比 copy 快很多。需要注意的是第一行中 cap 的下标是一定要写的,并且建议写0,这样效率是最高的。

2.7. 传递

Go 语言函数参数都是值传递,在函数内部会拷贝一份,所以 slice 传递后拷贝一份都是新的 slice,但是 map 这种初始化之后就是一个 *hmap,所以参数传递后还是指向同一个 map(map 以后再详解)。

2.8. 总结

切片暂时告一段落,最后总结一下:

  • 切片是对数组的封装,切片可以自动扩容。
  • 切片添加后,新的容量小于之前的容量,那么还是使用原有的数组,并且两者之间的元素修改有影响,因为底层是同一块内存。
  • 切片的扩容并不一定总是原有容量的2倍或者1.25倍,当 append 多个元素时,会有内存对齐 ,最终的容量大于等于长度(这是一句废话,容量小于长度就 panic 了)。
  • 使用 var 声明的切片可以直接 append,并且我们建议这样,因为在声明时不会分配内存。
  • 已知切片容量或者长度时,声明时最好也指定容量或者长度,因为扩容导致重新分配内存消耗太大了。

参考链接:

  • (饶大)
  • (曹大)
  • (需要梯子)
  • (需要梯子)
  • (需要梯子)

相关文章