美文网首页
go 语言slice结构深度剖析

go 语言slice结构深度剖析

作者: 我爱张智容 | 来源:发表于2021-05-25 09:30 被阅读0次

最近踩了一个坑,在go语言值传递上遇到了一个问题,问题如下:

func main() {
    s := []int{1,2,3,4}
    s1 := s // 对象的深拷贝,将 s 赋值给 s1,s和s1的地址不一样
    fmt.Printf("s 的地址:%p,s1 的地址:%p\n",&s,&s1) // s 的地址:0xc0000a6020,s1 的地址:0xc0000a6040
    s1[0] = 12
    s[2] = 1232
    fmt.Println(s1) // [12 2 1232 4]
    fmt.Println(s) // [12 2 1232 4]
}

以上问题,产生的原因很简单,:
新的 slice 仍然指向了原来的底层数组,导致后面对新slice做的修改都是在原来指向的底层数组上进行的,即两者指向的是同一个数组

下面深度分析一下go语言的slice结构,以便于更清晰认识该问题并合理避坑。

go slice 解析
基础结构

先看看 go slice 的结构体

type slice struct {
    array unsafe.Pointer // 指针,指向底层数组
    len   int // slice长度,即当前slice可以访问的范围
    cap   int // slice容量,当前slice可访问底层数组的最大范围,如果cap不够,则会执行扩容操作
}

我们看到 slice 的底层就只是一个指向数组的指针 array 而已,其实就是底层数组的地址。

这么看来,slice 用 len 来动态维护slice的大小,通过 cap 来动态维护底层数组的大小。
我画一个简图就可以清晰看到如下内容:

数组的使用,有一个很大的制约,就是数组长度很多时候无法确定,需要扩容就要手动复制到一个更长的数组上去。为了方便,slice 替我们做了这样的操作。下面看看 slice 的扩容机制。

扩容
当 cap > len,执行增加元素操作,如果 cap 满足要求就不需要执行扩容操作,只需要将数组访问范围(len)扩大即可。因为cap就是底层数组的大小,go 在声明slice时,就保留了这部分空间,即预先保留了cap大小的空间,只是slice只能访问len大小的内容,超出部分访问不了。 源码如下:

// 计算a*b的大小并判断是否会溢出
// MulUintptr returns a * b and whether the multiplication overflowed.
// On supported platforms this is an intrinsic lowered by the compiler.
func MulUintptr(a, b uintptr) (uintptr, bool) {
    if a|b < 1<<(4*sys.PtrSize) || a == 0 {
        return a * b, false
    }
    overflow := b > MaxUintptr/a
    return a * b, overflow
}

创建slice

func makeslice(et *_type, len, cap int) unsafe.Pointer {
    mem, overflow := math.MulUintptr(et.size, uintptr(cap)) // 计算slice底层数组的基本类型的size*cap,并判断是否会溢出,即会申请一块size*cap大小的内存空间mem
    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)
}

所以,当cap满足要求时,slice就不必要执行扩容操作了。那么,什么时候会执行扩容操作呢?性能如何? 例如如下操作,底层数组的大小已经不满足要求了,所以slice就会执行扩容操作。

s := []int{1,2,3,4,5} // cap = 5
s = append(s, 10,12) // 原来的容量不满足要求,会执行扩容操作
扩容机制: 1. 如果新申请容量(cap)大于2倍的旧容量(old.cap),最终容量(newcap)就是新申请的容量(cap) 2. 如果旧切片的长度小于1024,则最终容量(newcap)就是旧容量(old.cap)的两倍,即(newcap=doublecap) 3. 如果旧切片长度大于等于1024,则最终容量(newcap)从旧容量(old.cap)开始循环增加原来的 1/4,即(newcap=old.cap,for {newcap += newcap/4})直到最终容量(newcap)大于等于新申请的容量(cap),即(newcap >= cap) 4. 如果最终容量(cap)计算值溢出,则最终容量(cap)就是新申请容量(cap)

我们可以看看具体的策略的实现:

newcap := old.cap
    doublecap := newcap + newcap // 两倍扩容
    if cap > doublecap { // 待扩容大小大于原切片的两倍,则按照待扩容大小处理
        newcap = cap
    } else { 
        if old.len < 1024 { // 当原切片长度小于1024时,新切片的容量会直接翻倍。
            newcap = doublecap
        } else { // 当原切片的容量大于等于1024时,会反复地增加25%,直到新容量超过所需要的容量。
            // Check 0 < newcap to detect overflow
            // and prevent an infinite loop.
            for 0 < newcap && newcap < cap {
                newcap += newcap / 4
            }
            // Set newcap to the requested cap when
            // the newcap calculation overflowed.
            if newcap <= 0 {
                newcap = cap
            }
        }
    }

扩容踩坑
以上扩容机制也都熟悉了,整个切片可以总结为一个可以动态扩容的指向数组的指针。概念不难,但是使用起来,经常会遇到坑,例如,我们在拷贝slice的时候,经常会只拷贝切片的地址和其包含的值,但却没有注意到新的slice其实和旧的slice指向的同一个地址。所以在拷贝slice的时候,一定要注意拷贝其底层数组。 坑1:由于原数组还有容量可以扩容,所以执行 append() 操作以后,会在原数组上直接操作,所以这种情况下,扩容以后的数组还是指向原来的数组。

看一个demo:

func main() {
    s := make([]int, 10,15)
    array := [6]int{1,2,3,4,5,6}
    s = array[2:6]
    // s 没有容量扩容了,新slice和s指向不同数组(可以理解为执行了slice的深拷贝)
    s1 := append(s,1,2)
    fmt.Printf("s 的地址:%p, s1 的地址:%p\n",&s,&s1)
    fmt.Println(s)

    s[1] = 123
    fmt.Println(s1)

    // 指向同一数组(s2还有容量可以扩容,所以其实s2和s3其实是都指向同一数组的,可以理解为浅拷贝)
    s2 := make([]int, 10,15)
    s2 = append(s,1,2,3,4)
    s3 := append(s2, 4,5)
    fmt.Println(s2)
    s2[len(s2)-1] = 123
    fmt.Println(s3)
    fmt.Printf("s2 的地址:%p, s3 的地址:%p\n",&s2,&s3)

}

切片拷贝

func main() {
    // deep copy
    array := []int{1,2,3,4,5,6}

    s := array[0:]
    // 拷贝s
    s1 := make([]int, 6)
    copy(s1,s) // 有坑,要等长,不然以更小的为准
    s[5] = 123
    fmt.Printf("s 的地址:%p, s1 的地址:%p\n",&s,&s1)
    fmt.Println(s1)

    // 更优雅的深拷贝
    _ = Clone(s1, s)
    s[5] = 12311
    fmt.Printf("s 的地址:%p, s1 的地址:%p\n",&s,&s1)
    fmt.Println(s1)
    // 等号复制,浅拷贝
    s2 := s
    fmt.Printf("s 的地址:%p, s2 的地址:%p\n",&s,&s2)
    s[1] = 1111
    fmt.Println(s2)
}

func Clone(a, b interface{}) error {
    buff := new(bytes.Buffer)
    enc := gob.NewEncoder(buff)
    dec := gob.NewDecoder(buff)
    if err := enc.Encode(a); err != nil {
        return err
    }
    if err := dec.Decode(b); err != nil {
        return err
    }
    return nil
}

相关文章

  • go 语言slice结构深度剖析

    最近踩了一个坑,在go语言值传递上遇到了一个问题,问题如下: 以上问题,产生的原因很简单,:新的 slice 仍然...

  • golang数据类型

    Go语言中有四种复合数据类型:数组,slice,map,结构体 数组和结构体都是聚合类型,长度固定。而slice和...

  • Go语言——Slice分析

    Go语言——Slice分析 源码很不好找,在go\src\runtime\slice.go。 根据容量cap*元素...

  • Go slice 剖析

    首先,我们得区分数组和slice的概念数组是值类型,赋值和传参会复制整个数组,⽽而不是指针slice 并不是数组或...

  • golang slice结构

    使用例子 内存结构 slice在goalng中的结构定义, 在源码src/runtime/slice.go中 一下...

  • Go语言数据结构和算法-使用Slice实现栈

    Go语言数据结构和算法-使用Slice实现栈 栈是Last-In-First-Out (LIFO)(后进先出)的数...

  • Go语言微服务开发框架实践-go chassis(上篇)

    Go chassis是一个go语言微服务开发框架 通过这篇文章中,我将从设计思路到源码剖析来深度分析Go Chas...

  • Go slice扩容深度分析(来自掘金文章)

    Go slice扩容深度分析 本文主要是对go slice的扩容机制进行了一些分析。环境,64位centos的do...

  • Go-数组与slice

    本文将讲解Go语言中的数组与slice。之前看到网上好多 《深入理解slice》、《深入解析slice》... 的...

  • golang的string、map、sclie

    slice 切片的原理 切⽚ ( slice ) 是Go中⼀种⽐较特殊的数据结构,这种数据结构更便于使⽤和管理数据...

网友评论

      本文标题:go 语言slice结构深度剖析

      本文链接:https://www.haomeiwen.com/subject/xiaujltx.html