从一道经典面试题了解slice

149 阅读1分钟

这是我参与11月更文挑战的第5天,活动详情查看:2021最后一次更文挑战

这道题你做对了吗?

package main
​
import "fmt"func main() {
    var s []intfor i := 0; i < 3; i++ {
        s = append(s, i)
    }
​
    modifySlice(s)
    fmt.Println(s)
}
​
func modifySlice(s []int) {
    s = append(s, 2048)
    s[0] = 1024
}

猜猜看这段代码输出的是什么?

正确答案是[1024 1 2]

这道题主要考了对Go传参值传递的理解和slice引用类型的理解,我们先来看一下值传递的定义。

值传递

值传递是指在调用函数时将实际参数复制一份传递到函数中,这样在函数中如果对参数进行修改,将不会影响到实际参数

go中所有的传参都是值传递

因为是值传递,所以main中的smodifySlice中的参数s,不是同一个s,我们可以通过打印地址来验证一下

package main
​
import "fmt"func main() {
    var s []intfor i := 0; i < 3; i++ {
        s = append(s, i)
    }
​
    fmt.Printf("main中s的地址: %p\n", &s)
    modifySlice(s)
    fmt.Println(s)
}
​
func modifySlice(s []int) {
    fmt.Printf("modifySlice中s的地址: %p\n", &s)
    s = append(s, 2048)
    s[0] = 1024
}
​
//打印结果
main中s的地址: 0xc000096060
modifySlice中s的地址: 0xc000096078

地址是不一样的,果然是值复制,但既然说不会影响到实际参数,那么为什么在modifySlices[0] = 1024却影响到了打印结果呢?我们看一下slice的底层结构

slice的底层结构

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

slice本质就是一个结构体,他的第一个元素是指针类型,这个指针指向底层数组的第一个元素。所以在进行值复制的时候,新的slice中的指针,还是指向相同的底层数组,说白了复制的只是指针而已。所以在执行s[0] = 1024时,指向的底层数组发生了改变。

你真的会了吗

我们对之前的代码略做改动

package main
​
import "fmt"func main() {
    var s []intfor i := 0; i < 3; i++ {
        s = append(s, i)
    }
​
    modifySlice(s)
    fmt.Println(s)
}
​
func modifySlice(s []int) {
    s = append(s, 2048)
    s = append(s, 4096)
    s[0] = 1024
}
​

猜猜看打印结果是什么?还是1024 1 2吗?

答案是否定的,实际结果是0 1 2,即然复制的是指针,s[0] = 1024不是修改了底层数组吗?为什么又不生效了呢?这个就跟slice grow有关了。

slice grow

触发条件

在刚刚slice的底层结构中,我们有看到cap这个元素,即slice的容量,在要放入的元素超过slice容量时,slice会发生扩容,也就是slice grow。那么slice grow实际究竟执行了什么逻辑呢?

扩容规则

我们先来看一下slice grow的源码

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)
}
  • slice grow会创建一个新的底层数组

  • 如果新旧slice容量之和大于旧容量*2,则新slice容量为新旧之和

  • 如果小于 且 长度小于1024,则扩容为2倍

  • 如果小于 且 长度大于1024,则扩容为1.25倍

  • 在以上操作后,还要进行内存对齐(之前计算后的结果,不是最终结果)

    为啥搞这么麻烦,说白了,为了节省珍贵的内存资源呗

答案解析

modifySlice中,s的容量为4,在append4096后,发生了扩容,底层数组发生了复制,所以执行s[0] = 1024时左右到了新的底层数组上,main中的s不会执行旧的底层数组,不会受影响,打印结果为[0 1 2]

总结

slice的两个特性

共享

slice允许多个slice执行同一个底层数组。在很多场景下都能通过这个特性实现 no copy 而提高效率。但共享同时意味着不安全。使用时需要小心!

go对no copy是真的执着呀

扩容

并不是粗暴的小于1024翻倍,大于1024翻1.25呗,还有其它的判断,尤其最后还有个内存对齐需要注意