• 发生时间:2022-09-08

  • 问题描述:

    • 在开发产线平台时,定义了函数 GetEntriesByName,其作用是在数据库中查询所有与指定名字匹配的条目并返回。
    • 在使用此函数时,我传入了一个空的 slice 给 entries 参数。函数返回后,发现 slice 依然为空,没有保存数据库的查询结果。
    // GetEntriesByName queries all entries in the database by name.
    func GetEntriesByName(entries interface{}, r client.PagedRequester) (int64, error) {
        var total int64
    
        db := GetDatabaseConnection()
        table := GetTableName(entries)
        limit := r.GetLimit()
        offset := (r.GetPage() - 1) * limit
        result := db.Table(table).Where(r.GetQuery(), r.GetName()).Limit(limit).Offset(offset).Find(entries)
        if result.Error != nil {
            logs.Errorf("db.Table.Find error: %s", result.Error)
            return 0, result.Error
        }
    
        db.Model(&entries).Count(&total)
    
        return total, nil
    }
    
  • 问题类别:软件开发

  • 原因分析:

    • 首先手动使用 SQL 语句查询 MySQL 数据库,能够返回相应的条目,说明数据库本身是正常的。
    • 然后在 GetEntriesByName 函数中调用完 db.Find 后打印 entries 的值,发现保存有相应的查询结果。
    • 此时,开始怀疑是输入参数的写法有问题,可能 slice 是值传递,导致函数内对 slice 的修改在函数外不会被看到。
    • 于是重新查阅了之前看过的介绍 Go slice 原理的官方博客,发现自己的猜测基本正确。
    • 为什么说基本正确呢? 虽然 slice 是值传递,但有时函数内对 slice 的修改在函数外也能被看到,详见下文解释。
  • 解决方案:传入参数时,改为传入 slice 的指针。

  • 实施结果:修改后,函数返回后可以正常获取数据库的查询结果。

  • 经验总结:

    • Go slice 作为函数输入参数时,是值传递的。但 slice 的值其实是一个 header,其定义如下所示。 可见,Go slice 虽然是值传递,但是这个值里面包含有一个指针,指向内部的数组。
    type SliceHeader struct {
        Data uintptr
        Len  int
        Cap  int
    }
    
    • 这导致 Go slice 在传递给函数时有个神奇的现象:在函数内修改 slice 后,函数外能否看到这个修改,取决于你做了什么修改:
      • 如果你只是修改 slice 元素的取值,或者是增删元素但没导致重新分配内存,那么函数外能够看到这个修改。(因为内部数组地址没有改变)
      • 如果你增删元素,并导致重新分配内存,那么函数外不能看到这个修改。(因为内部数组地址发生了改变!)
    • 使用 Go slice 作为函数输入参数时,需要了解上述原理,以选择使用 slice 或者 slice 的指针。
    • 另外,Go 数组则是普通意义上的值传递。函数内修改 Go 数组,函数外永远无法看到其修改。 如果你想在函数外看到这个修改,那么需要传入数组的指针。

探索使用 Go slice 作为函数输入参数的几种场景

场景1:函数内仅修改元素的取值

package main

import "fmt"

func addOne(arr []int) {
    for i := 0; i < len(arr); i++ {
        arr[i]++
    }
}

func main() {
    arr := []int{1, 2, 3, 4, 5}
    fmt.Printf("before addOne, arr = %v\n", arr)
    addOne(arr)
    fmt.Printf("after addOne, arr = %v\n", arr)
}

运行上述代码,输出结果如下所示:

along:/tmp/go$ go run slice1.go

before addOne, arr = [1 2 3 4 5]
after addOne, arr = [2 3 4 5 6]

可见,如果函数内仅仅修改元素的取值,函数外能够看到这个修改。

原因:函数内修改元素的取值,slice 所指向的内部数组大小不变,因此不需要重新分配内存。 因此函数内外的 slice 都指向相同的数组地址,在函数内修改了数组的内容,函数外也能看到。

场景2:函数内增加元素,但没有重新分配内存

我们可以这样构造这种场景:

  • 首先创建一个长度等于容量的 slice,
  • 然后切片得到一个长度小于容量的 slice,
  • 并且,我们在函数内只添加一个元素,这时就不会重新分配内存。
package main

import "fmt"

func push(arr []int) {
    arr = append(arr, 2022)
}

func main() {
    arr := []int{1, 2, 3, 4, 5}
    fmt.Printf("before push, arr = %v\n", arr)
    push(arr[:3])
    fmt.Printf("after push, arr = %v\n", arr)
}

运行上述代码,输出结果如下所示:

along:/tmp/go$ go run slice2.go
before push, arr = [1 2 3 4 5]
after push, arr = [1 2 3 2022 5]

可见,如果函数内增加元素但没有重新分配内存,函数外也能看到这个修改。

其原因跟场景1类似,都是因为内部数组的地址并没发生变化。

场景3:函数内增加元素,并且导致重新分配内存

这种场景比较容易构造,给函数传入一个长度等于容量的 slice 即可。 这时,由于函数内给 slice 增加元素,其长度将超过容量,因此导致重新分配内存。

package main

import "fmt"

func push(arr []int) {
    arr = append(arr, 2022)
}

func main() {
    arr := []int{1, 2, 3, 4, 5}
    fmt.Printf("before push, arr = %v\n", arr)
    push(arr)
    fmt.Printf("after push, arr = %v\n", arr)
}

运行上述代码,输出结果如下所示:

along:/tmp/go$ go run slice3.go
before push, arr = [1 2 3 4 5]
after push, arr = [1 2 3 4 5]

可见,如果函数内增加元素并且导致重新分配内存,函数外无法看到这个修改。

探究使用 Go Array 作为函数输入参数的场景

package main

import "fmt"

func addOne(arr [5]int) {
    for i := 0; i < 5; i++ {
        arr[i]++
    }
}

func main() {
    arr := [5]int{1, 2, 3, 4, 5}
    fmt.Printf("before addOne, arr = %v\n", arr)
    addOne(arr)
    fmt.Printf("after addOne, arr = %v\n", arr)
}

运行上述代码,输出结果如下所示:

along:/tmp/go$ go run array.go
before addOne, arr = [1 2 3 4 5]
after addOne, arr = [1 2 3 4 5]

可见,在函数内修改了 Go Array,函数外无法看到这个修改。

以下是《The Go Programming Language》第4.1节「Arrays」对 Go 数组作为输入参数的描述:

When a function is called, a copy of each argument value is assigned to the corresponding parameter variable, so the function receives a copy, not the original. Passing large arrays in this way can be inefficient, and any changes that the function makes to array elements affect only the copy, not the original. In this regard, Go treats arrays like any other type, but this behavior is different from languages that implicitly pass arrays by reference.