slice作为函数参数时何时需要传地址?

2023-12-05 18:42:18 Go Go小卡片

引言:面对整数时传地址有什么用

所有的函数参数传参都会先进行参数拷贝,拿下面这个案例,也就是在传参时,a会先拷贝一份副本,然后再将副本传入modifyInt函数

func modifyInt(a int) {
    // 此时传入的参数a是独立copy的a,拥有变量a的值,但是所属地址不相同
    // 进而无法通过修改此处a的值影响传入的参数a
    a++                 
}

func main() {
    a := 0              // 先拷贝一份a
    modifyInt(a)        // 然后再传递拷贝的副本a给函数
}

这意味什么呢?

这就意味着不仅原本的变量a在计算机中占有一块内存空间,拷贝后传入modifyInt参数a会再在内存空间中重新开辟一块内存,因为两者地址不同,所以函数里的参数a发生改变,自然就不会影响到函数外的变量a

思考:如果我想在函数里面修改传过来的参数a,进而修改函数外的变量a呢?

很简单,只需要将变量a的指针传入函数,那么我们对参数的操作都是基于参数地址进行修改了

说人话就是,通过传进函数中的变量a的地址,对变量a原有内容修改(说了,但好像又没说-_-||)

func modifyInt(a *int) {
    (*a)++  // 此时就是在a的地址上,通过解引用(或则说解析a的地址),就能精准定位对a的值进行修改
}

func main() {
    a := 0
    modifyInt(&a)       // 将a的地址传入函数,通过地址精准定位进而修改a的值
}

很显然,如果我们企图通过函数,对原本的变量a做出一定修改,那我们在进行函数传参时就得考虑传入地址了。当然接受返回的新结果也未曾不可

切片什么时候需要传入地址?

省流(实则是后面原理讲的感觉挺烂的做出的一个总结):对于切片,我们如果需要对原切片进行扩容删除的会对切片长度有影响的操作时,就需要我们传入地址;当我们只是希望对切片中某个元素进行修改,因为本身传入切片时,会传入切片的底层数组首地址,所以可以直接对切片元素进行访问修改

通过前面的引言案例,我们知道要修改传进来的原参数,需要传入这个参数的指针,然后通过这个指针定位数据存储的地址,进而通过解引用修改这块地址所存放的值

那么切片是否也能通过传入切片的地址(指针),进而修改呢?

我们先来试试不传指针可不可以

func modifyInt(s []int) {
    s[0] = 9        // 修改切片第一个元素值为9
}

func main() {
    var s []int     // 创建一个空切片
    // s := make([]int, 3)  // 或则通过make创建一个初始长度为3的空切片
    modifyInt(s)    // 传入切片
}

当我们执行这段代码的时候,会惊讶的发现,和之前不同,这次居然可以修改,切片的第一个元素成功被赋值为了9!

为什么这个时候又不需要传入地址就能对原参数操作了呢?

这就不得不提一下切片的本质了

切片的本质

在Go语言中,切片(slice)是一种动态数组的抽象。切片提供了对数组的部分或全部元素的访问,并且可以通过对切片进行操作来动态改变其大小。切片的本质可以从以下几个方面来理解:

  1. 底层数组

    • 切片本质上是对底层数组的一个引用。底层数组存储了实际的元素序列,而切片提供了对这些元素的动态访问。当我们对切片进行操作时,实际上是在操作底层数组。
  2. 长度和容量

    • 切片包含长度和容量两个属性。长度表示切片当前包含的元素个数,容量表示底层数组中可以容纳的元素个数。切片的长度可以动态变化,但其容量是固定不变的。
  3. 动态调整

    • 切片可以通过追加元素来动态增长,当切片的长度超过容量时,切片会重新分配更大的底层数组,并将原有的元素复制到新的数组中。
  4. 引用语义

    • 切片是引用类型,当我们将一个切片赋值给另一个切片时,它们实际上引用的是同一个底层数组。因此,对一个切片的修改可能会影响到其他引用同一底层数组的切片。

总的来说,切片可以看作是对底层数组的一个动态窗口,它提供了便捷的方式来操作数组的子序列,并且具有动态调整大小的能力。理解切片的本质有助于更好地利用它们来管理和操作数据。

说人话,可以把切片看做一个特殊的结构体,这个特殊的结构体披着能给底层数组逻辑实现扩容的外衣,并且有三个成员(或则说特性):

  • 成员(特性)一:指针 -> 指向的是底层数组的首地址
  • 成员(特性)二:长度 -> 目前可使用的操作空间
  • 成员(特性)三:容量 -> 底层数组的最大容量

解释为什么修改首元素内容时不需要传入切片地址

当我们向modifyInt传入切片参数时,事实上是传入的三个信息:指针,长度和容量

而当我们对切片进行修改时,就会通过指针指向的底层数组的地址,来对原切片所包含的底层数组内容进行修改

所以当我们期望用s[0] = 9的形式,修改切片首元素为9时,事实上是通过传入的切片的指针这一“特性”,找到切片所指向的底层数组并对内容进行修改。所以当我们对切片首元素进行修改时,会影响到原来的切片。

毕竟此时就是在原来切片所指向底层数组的首元素地址上进行操作的

深入理解一下append等增删原理

当我们打算用append为切片新增元素时,或则删除原本某个切片元素时

func appendEle(s []int) {
    s = append(s, 9)       // 新增元素9
}

func removeEle(s []int) {
    n := len(s)     // 计算传进来的切片长度
    s = (s)[0 : n-1]    // 截取掉最后一个元素
}

func main() {
func main() {
    s := make([]int, 3) // [0 0 0]
    appendEle(s)
    fmt.Println(s) // [0 0 0]
    removeEle(s)
    fmt.Println(s) // [0 0 0]
}

上面这个案例清晰的反馈了,如果想追加元素或删除尾部元素都不行

让我们回到append本质上

1700747635034

最开始我们通过arr := make([]int, 3)创建出arr切片后,此时会产生一个底层数组,当我们通过append(arr, 9)希望为切片arr新增一个元素9,此时在底层数组中也会新增一个元素9(毕竟,切片实际上就是为底层数组扩容的一层规则外衣)

我们通过创建brr := append(arr, 9)而不是arr := append(arr, 9)的形式,是为了清晰的知道,事实上通过append扩容的过程,就是给切片进行长度加一,此时的arrbrr是完全不同切片!只不过他们的内核都是基于同一个底层数组

当我们通过下面这段代码时,其实是开辟了一个新的空间,这个新的空间内核还是原来切片的底层数组,但是换了件衣服,系统就认不出来了

func appendEle(s []int) {
    s = append(s, 9)       // 新增元素9
}

函数内部的s是一个新的,就像是前面修改函数内参数a不会影响函数外的变量a一样,都不是一个东西,如何能修改成功呢?

所以这里我们就需要通过传入切片的地址,让系统知道我们需要扩容的是原来的切片

// 此时传入的是切片的地址
func appendEle(s *[]int) {
    *s = append(*s, 9)       // 对切片解引用,获得传入切片地址所指向的值新增元素9
}

// 同理
func removeEle(s *[]int) {
    n := len(*s)     // 计算传进来的切片长度
    *s = (s)[0 : n-1]    // 截取掉最后一个元素
}

func main() {
func main() {
    s := make([]int, 3) // [0 0 0]
    appendEle(&s)       // 传入切片的地址
    fmt.Println(s) // [0 0 0 9]
    removeEle(&s)       // 传入切片的地址
    fmt.Println(s) // [0 0 0]
}