深度解密Go语言之Slice

什么是slice

slice 翻译成中文就是切片,它和数组(array)很类似,可以用下标的方式进行访问,如果越界,就会产生panic。但是它比数组更灵活,可以自动地进行扩容

源码:
//runtime/slice.go
type slice struct{
array unsafe.Pointer //元素指针
len int //长度
cap int //容量
}
  • 看到了吗,slice共有是哪个属性;指针,指向底层数组;长度,表示切片可用元素的个数,也就是使用下标对slice的元素进行访问时下标不能超过slice的长度;容量,底层数组的元素个数,容量>=长度。在底层数组不进行扩容的的情况下,容量也是slice可以扩张的最大限度在这里插入图片描述
*注意,底层数组是可以被多个slice同时指向,因此对一个slice的元素进行操作是有可能影响其他slice的。

slice的创建

序号方式代码示例
1直接声明var slice []int
2newslice :=*new([]int)
3字面量slice :=[]int{1,2,3,4,5}
4makeslice :=make([]int,5,10)
5从切片或数组中截取slice :=array[1:5]或slice :=sourceSlice[1:5]
直接声明
  • 第一种创建出来的slice其实是一个nil slice 。它的长度和容量都为0。和nil比较的结果为true
  • 这里比较混淆的是empty slice,它的长度和容量也都为0,但是所有的空切片的数据指针都指向一个地址0xc42003bda0 。空切片和nil比较结果为false
它们的内容结构如下图:

在这里插入图片描述

创建方式nil切片empty slice (空切片)
方式一var s1 []intvar s2[]int{}
方式二var s4=*new([]int)var s3 =make([]int,0)
长度00
容量00
nil比较truefalse
  • nil切片和空切片很相似,长度和容量都是0,官方建议尽量使用nil切片

关于nil slice和empty slice

字面量
  • 比较简单,直接用初始化表达式创建。

package main

import "fmt"

func main() {
    s1 := []int{0, 1, 2, 3, 8: 100}
    fmt.Println(s1, len(s1), cap(s1))
}

运行结果:

[0 1 2 3 0 0 0 0 100] 9 9
  • 唯一值得注意的是上面的代码例子中使用索引号,直接赋值,这样,其他未注明的元素则默认0值
make
  • make函数需要传入三个参数:切片类型,长度,容量。当然,容量可以不传,默认和长度相等。

package main

import "fmt"

func main() {
    slice := make([]int, 5, 10) // 长度为5,容量为10
    slice[2] = 2 // 索引为2的元素赋值为2
    fmt.Println(slice)
}

执行如下命令,得到 Go 汇编代码:

go tool compile -S main.go

我们只关注main函数:

0x0000 00000 (main.go:5)TEXT    "".main(SB), $96-0
0x0000 00000 (main.go:5)MOVQ    (TLS), CX
0x0009 00009 (main.go:5)CMPQ    SP, 16(CX)
0x000d 00013 (main.go:5)JLS     228
0x0013 00019 (main.go:5)SUBQ    $96, SP
0x0017 00023 (main.go:5)MOVQ    BP, 88(SP)
0x001c 00028 (main.go:5)LEAQ    88(SP), BP
0x0021 00033 (main.go:5)FUNCDATA    $0, gclocals·69c1753bd5f81501d95132d08af04464(SB)
0x0021 00033 (main.go:5)FUNCDATA    $1, gclocals·57cc5e9a024203768cbab1c731570886(SB)
0x0021 00033 (main.go:5)LEAQ    type.int(SB), AX
0x0028 00040 (main.go:6)MOVQ    AX, (SP)
0x002c 00044 (main.go:6)MOVQ    $5, 8(SP)
0x0035 00053 (main.go:6)MOVQ    $10, 16(SP)
0x003e 00062 (main.go:6)PCDATA  $0, $0
0x003e 00062 (main.go:6)CALL    runtime.makeslice(SB)
0x0043 00067 (main.go:6)MOVQ    24(SP), AX
0x0048 00072 (main.go:6)MOVQ    32(SP), CX
0x004d 00077 (main.go:6)MOVQ    40(SP), DX
0x0052 00082 (main.go:7)CMPQ    CX, $2
0x0056 00086 (main.go:7)JLS     221
0x005c 00092 (main.go:7)MOVQ    $2, 16(AX)
0x0064 00100 (main.go:8)MOVQ    AX, ""..autotmp_2+64(SP)
0x0069 00105 (main.go:8)MOVQ    CX, ""..autotmp_2+72(SP)
0x006e 00110 (main.go:8)MOVQ    DX, ""..autotmp_2+80(SP)
0x0073 00115 (main.go:8)MOVQ    $0, ""..autotmp_1+48(SP)
0x007c 00124 (main.go:8)MOVQ    $0, ""..autotmp_1+56(SP)
0x0085 00133 (main.go:8)LEAQ    type.[]int(SB), AX
0x008c 00140 (main.go:8)MOVQ    AX, (SP)
0x0090 00144 (main.go:8)LEAQ    ""..autotmp_2+64(SP), AX
0x0095 00149 (main.go:8)MOVQ    AX, 8(SP)
0x009a 00154 (main.go:8)PCDATA  $0, $1
0x009a 00154 (main.go:8)CALL    runtime.convT2Eslice(SB)
0x009f 00159 (main.go:8)MOVQ    16(SP), AX
0x00a4 00164 (main.go:8)MOVQ    24(SP), CX
0x00a9 00169 (main.go:8)MOVQ    AX, ""..autotmp_1+48(SP)
0x00ae 00174 (main.go:8)MOVQ    CX, ""..autotmp_1+56(SP)
0x00b3 00179 (main.go:8)LEAQ    ""..autotmp_1+48(SP), AX
0x00b8 00184 (main.go:8)MOVQ    AX, (SP)
0x00bc 00188 (main.go:8)MOVQ    $1, 8(SP)
0x00c5 00197 (main.go:8)MOVQ    $1, 16(SP)
0x00ce 00206 (main.go:8)PCDATA  $0, $1
0x00ce 00206 (main.go:8)CALL    fmt.Println(SB)
0x00d3 00211 (main.go:9)MOVQ    88(SP), BP
0x00d8 00216 (main.go:9)ADDQ    $96, SP
0x00dc 00220 (main.go:9)RET
0x00dd 00221 (main.go:7)PCDATA  $0, $0
0x00dd 00221 (main.go:7)CALL    runtime.panicindex(SB)
0x00e2 00226 (main.go:7)UNDEF
0x00e4 00228 (main.go:7)NOP
0x00e4 00228 (main.go:5)PCDATA  $0, $-1
0x00e4 00228 (main.go:5)CALL    runtime.morestack_noctxt(SB)
0x00e9 00233 (main.go:5)JMP     0

先说明一下,Go 语言汇编 FUNCDATA 和 PCDATA 是编译器产生的,用于保存一些和垃圾收集相关的信息,我们先不用 care。

以上汇编代码行数比较多,没关系,因为命令都比较简单,而且我们的 Go 源码也足够简单,没有理由看不明白。

我们先从上到下扫一眼,看到几个关键函数:

CALL    runtime.makeslice(SB)
CALL    runtime.convT2Eslice(SB)
CALL    fmt.Println(SB)
CALL    runtime.morestack_noctxt(SB)
序号功能
1创建slice
2类型转换
3打印函数
4栈空间扩容
  • 1是创建 slice 相关的;2是类型转换;调用 fmt.Println需要将 slice 作一个转换; 3是打印语句;4是栈空间扩容函数,在函数开始处,会检查当前栈空间是否足够,不够的话需要调用它来进行扩容。暂时可以忽略。
  • 调用函数就会涉及到参数传递,Go的参数传递都是通过栈空间完成的。接下来,我们详细分析这整个过程。

以上省略了好大一部分的讲解~~
因为看不懂,后续再来补充~
原文地址

截取
  • 截取也是比较常见的一种创建 slice 的方法,可以从数组或者 slice 直接截取,当然需要指定起止索引位置。
  • 基于已有 slice 创建新 slice 对象,被称为 reslice。新 slice 和老 slice 共用底层数组,新老 slice 对底层数组的更改都会影响到彼此。基于数组创建的新 slice 对象也是同样的效果:对数组或 slice 元素作的更改都会影响到彼此。
  • 值得注意的是,新老slice或者新slice老数组互相影响的前提是两者公用底层数组,如果因为执行append操作使得新slice底层数组扩容,移动到了新的位置,两者就不会互相影响了。所以问题的关键在于两者是否会共用底层数组

截取操作采用如下方式:

 data := [...]int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
 slice := data[2:4:6] // data[low, high, max]

data使用3个索引值,截取出新的slice。这里data可以是数组或者slicelow是最低索引值,这里是闭区间,也就是说第一个元素是data位于low索引处的元素,而highmax则是开区间,表示最后一个元素只能是索引high-1处的元素,而最大容量则是能是索引max-1出的元素。(左含右不含)

max >=high>=low

  • high==low时,新slice为空
  • 还有一点,highmax必须在老数组或者老slice的容量(cap)范围内

来看一个例子,来自雨痕大佬《Go学习笔记》第四版,P43页,参考资料里有开源书籍地址。这里我会进行扩展,并会作详细说明:


package main

import "fmt"

func main() {
    slice := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
    s1 := slice[2:5]
    s2 := s1[2:6:7]

    s2 = append(s2, 100)
    s2 = append(s2, 200)

    s1[2] = 20

    fmt.Println(s1)
    fmt.Println(s2)
    fmt.Println(slice)
}

先看下代码运行的结果:


[2 3 20]
[4 5 6 7 100 200]
[0 1 2 3 20 5 6 7 100 9]

我们来走一遍代码,初始状态如下:

slice := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
s1 := slice[2:5]
s2 := s1[2:6:7]
  • s1slice索引2(闭区间)到索引5(开区间,元素真正取到索引4),长度为3,容量默认到数组结尾,为8。s2s1到索引2(闭区间)到索引6(开区间元素真正取到索引5),容量索引7(开区间,真正到索引6),为5
    在这里插入图片描述
    接着,向 s2 尾部追加一个元素 100:
s2 = append(s2, 100)

s2 容量刚好够,直接追加。不过,这会修改原始数组对应位置的元素。这一改动,数组和 s1 都可以看得到。
在这里插入图片描述
再次向 s2 追加元素200:

s2 = append(s2, 200)

这时,s2 的容量不够用,该扩容了。于是,s2 另起炉灶,将原来的元素复制新的位置,扩大自己的容量。并且为了应对未来可能的 append 带来的再一次扩容,s2 会在此次扩容的时候多留一些 buffer,将新的容量将扩大为原始容量的2倍,也就是10了。在这里插入图片描述
最后,修改 s1 索引为2位置的元素:

s1[2] = 20

这次只会影响原始数组相应位置的元素。它影响不到 s2 了,人家已经远走高飞了。
在这里插入图片描述
再提一点,打印 s1 的时候,只会打印出 s1 长度以内的元素。所以,只会打印出3个元素,虽然它的底层数组不止3个元素。

至于,我们想在汇编层面看看到底它们是如何共享底层数组的,限于篇幅,这里不再展开。感兴趣的同学可以在公众号后台回复:切片截取

原文地址

我会给你详细分析函数调用关系,对共享底层数组的行为也会一目了然。

slice 和数组的区别在哪
  • slice的底层数据是数组,slice是对数组的封装,它描述了一个数组的片段,两则都可以通过下标来访问单个元素。
  • 数组是的长度是固定的,长度定义好之后,不能再更改。在Go中,数组是不常见的,因为其长度是类型的一部分,限制了它的表达能力,比如[3]int[4]int就是不同类型的。
  • 而切片则非常灵活,它可以动态的扩容。切片的类型和长度无关。
append 到底做了什么

先来看看 append 函数的原型:

func append(slice []Type,elems ...Type)[]Type
  • append函数的参数长度可变,因此可追加多个值到slice中,可以用...传入slice,直接追加一个切片
slice = append(slice,elem1,elem2)
slice = append(slice,anotherSlice...)
  • append函数返回值是一个新的slice,Go编译器不允许调用了append函数后不使用返回值
append(slice, elem1, elem2)
append(slice, anotherSlice...)

所以上面的用法是错的,不能编译通过。

  • 使用 append 可以向 slice 追加元素,实际上是往底层数组添加元素。但是底层数组的长度是固定的,如果索引 len-1 所指向的元素已经是底层数组的最后一个元素,就没法再添加了。这时,slice 会迁移到新的内存位置,新底层数组的长度也会增加,这样就可以放置新增的元素。同时,为了应对未来可能再次发生的 append 操作,新的底层数组的长度,也就是新 slice 的容量是留了一定的 buffer 的。否则,每次添加元素的时候,都会发生迁移,成本太高。

新 slice 预留的 buffer 大小是有一定规律的。网上大多数的文章都是这样描述的:

当原 slice 容量小于 1024 的时候,新 slice 容量变成原来的 2 倍;原 slice 容量超过 1024,新 slice 容量变成原来的1.25倍。

我在这里先说结论:以上描述是错误的。

为了说明上面的规律是错误的,我写了一小段玩具代码:

package main

import "fmt"

func main() {
    s := make([]int, 0)

    oldCap := cap(s)

    for i := 0; i < 2048; i++ {
        s = append(s, i)

        newCap := cap(s)

        if newCap != oldCap {
            fmt.Printf("[%d -> %4d] cap = %-4d  |  after append %-4d  cap = %-4d\n", 0, i-1, oldCap, i, newCap)
            oldCap = newCap
        }
    }
}

运行结果:


[0 ->   -1] cap = 0     |  after append 0     cap = 1   
[0 ->    0] cap = 1     |  after append 1     cap = 2   
[0 ->    1] cap = 2     |  after append 2     cap = 4   
[0 ->    3] cap = 4     |  after append 4     cap = 8   
[0 ->    7] cap = 8     |  after append 8     cap = 16  
[0 ->   15] cap = 16    |  after append 16    cap = 32  
[0 ->   31] cap = 32    |  after append 32    cap = 64  
[0 ->   63] cap = 64    |  after append 64    cap = 128 
[0 ->  127] cap = 128   |  after append 128   cap = 256 
[0 ->  255] cap = 256   |  after append 256   cap = 512 
[0 ->  511] cap = 512   |  after append 512   cap = 1024
[0 -> 1023] cap = 1024  |  after append 1024  cap = 1280
[0 -> 1279] cap = 1280  |  after append 1280  cap = 1696
[0 -> 1695] cap = 1696  |  after append 1696  cap = 2304
  • 在老 slice 容量小于1024的时候,新 slice 的容量的确是老 slice 的2倍。目前还算正确。
  • 但是,当老 slice 容量大于等于 1024 的时候,情况就有变化了。当向 slice 中添加元素 1280 的时候,老 slice 的容量为 1280,之后变成了 1696,两者并不是 1.25 倍的关系(1696/1280=1.325)。添加完 1696 后,新的容量 2304 当然也不是 1696 的 1.25 倍。

可见,现在网上各种文章中的扩容策略并不正确。我们直接搬出源码:源码面前,了无秘密。

从前面汇编代码我们也看到了,向 slice 追加元素的时候,若容量不够,会调用 growslice 函数,所以我们直接看它的代码。


// go 1.9.5 src/runtime/slice.go:82
func growslice(et *_type, old slice, cap int) slice {
    // ……
    newcap := old.cap
    doublecap := newcap + newcap
    if cap > doublecap {
        newcap = cap
    } else {
        if old.len < 1024 {
            newcap = doublecap
        } else {
            for newcap < cap {
                newcap += newcap / 4
            }
        }
    }
    // ……

    capmem = roundupsize(uintptr(newcap) * ptrSize)
    newcap = int(capmem / ptrSize)
}

看到了吗?如果只看前半部分,现在网上各种文章里说的 newcap 的规律是对的。现实是,后半部分还对 newcap 作了一个内存对齐,这个和内存分配策略相关。进行内存对齐之后,新 slice 的容量是要 大于等于 老 slice 容量的 2倍或者1.25倍。

之后,向 Go 内存管理器申请内存,将老 slice 中的数据复制过去,并且将 append 的元素添加到新的底层数组中。

最后,向 growslice 函数调用者返回一个新的 slice,这个 slice 的长度并没有变化,而容量却增大了。

以上省略了好大一部分的讲解~~
因为看不懂,后续再来补充~
原文地址

为什么 nil slice 可以直接 append
  • 其实 nil slice 或者 empty slice 都是可以通过调用 append 函数来获得底层数组的扩容。最终都是调用 mallocgc 来向 Go 的内存管理器申请到一块内存,然后再赋给原来的nil sliceempty slice,然后摇身一变,成为“真正”的 slice 了。
传 slice 和 slice 指针有什么区别
  • 前面我们说到,slice 其实是一个结构体,包含了三个成员:len, cap, array。分别表示切片长度,容量,底层数据的地址。
  • 当 slice 作为函数参数时,就是一个普通的结构体。其实很好理解:若直接传 slice,在调用者看来,实参 slice 并不会被函数中的操作改变;若传的是 slice 的指针,在调用者看来,是会被改变原 slice 的。
  • 值得注意的是,不管是slice还是slice指针,如果改变了slice底层数组的数据,会反应到实参slice的底层数据。为什么能改变底层数组的数据?很好理解:底层数据在slice结构体里是一个指针,尽管slice结构体自身不会改变,但是通过指向底层数据的指针可以改变底层数据。
  • 通过slice的array字段就可以拿到数组的地址。在代码里,是直接通过类似s[i]=10这种操作改变slice底层数组元素值。

另外Go语言的函数参数传递,只有值传递,没有引用传递。
再来看一个年幼无知的代码片段:


package main

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

func f(s []int) {
    // i只是一个副本,不能改变s中元素的值
    /*for _, i := range s {
        i++
    }
    */

    for i := range s {
        s[i] += 1
    }
}

运行一下,程序输出:

[2 2 2]

果真改变原始slice的底层数据。这里传递的是一个slice的副本,在f函数中,s只是main函数中s的一个拷贝。在f函数内部,对s的作用并不会改变外层main

要是真想改变外层slice只有将返回的心slice赋值到原始slice,或者向函数传递一个指向slice的指针。我们再来看一个例子:


package main

import "fmt"

func myAppend(s []int) []int {
    // 这里 s 虽然改变了,但并不会影响外层函数的 s
    s = append(s, 100)
    return s
}

func myAppendPtr(s *[]int) {
    // 会改变外层 s 本身
    *s = append(*s, 100)
    return
}

func main() {
    s := []int{1, 1, 1}
    newS := myAppend(s)

    fmt.Println(s)
    fmt.Println(newS)

    s = newS

    myAppendPtr(&s)
    fmt.Println(s)
}

运行结果:

[1 1 1]
[1 1 1 100]
[1 1 1 100 100]

myAppend 函数里,虽然改变了 s,但它只是一个值传递,并不会影响外层的 s,因此第一行打印出来的结果仍然是 [1 1 1]

newS 是一个新的 slice,它是基于 s 得到的。因此它打印的是追加了一个 100 之后的结果: [1 1 1 100]

最后,将 newS 赋值给了 ss 这时才真正变成了一个新的slice。之后,再给 myAppendPtr 函数传入一个 s 指针,这回它真的被改变了:[1 1 1 100 100]

总结
  • 切片是对底层数组的一个抽象,描述了它的一个片段
  • 切片实际上是一个结构体,它有三个字段:长度len int,容量cap int,底层数据的地址array unsafe.Pointer
  • 切片可能共享同一个底层数组,这种情况下,对其中一个切片或者底层数组的更改,会影响到其他切片
  • append函数会在切片容量不够的情况下,调用growslice函数获取所需要的内存,这称为扩容,扩容会改变元素原来的位置。
  • 扩容策略并不是简单的扩为原来容量的2倍或1.25倍,还有内存对齐的操作。扩容后的容量>=原来容量的2倍或1.25倍。
  • 当直接用切片作为函数参数时,可以改变切片的元素,不能直接改变切片本身;想要改变切片本身,可以将改变后的切片返回,函数调用者接收改变后的切片或者将切片指针作为函数的参数。
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值