Ruby 面试官问:Go 中的参数传递是值传递还是引用传递?

upyun · May 19, 2022 · 384 hits

一个程序中,变量分为变量名和变量内容,变量内容的存储一般会被分配到堆和栈上。而在 Go 语言中有两种传递变量的方式值传递和引用传递。其中值传递会直接将变量内容附在变量名上传递,而引用传递会将变量内容的地址附在变量名上传递。

Golang 中是如何做到

如果在面试时有面试官提问你:“Go 的参数是如何传递的?”你会怎么回答呢?

这个问题其实只有一个答案。因为在 Golang 中所有的类型传递都是通过值传递实现的,而不是引用传递,即使是指针的传递也是通过 copy 指针的方式进行。另外对于一些包裹了底层数据的数据结构,其值传递的过程中,复制的也只是实例的指针,而不是底层数据所暴露出来的指针。

下面以 Go 版本 1.8 的 slice 为例来简单了解一下:

func makeslice(et *_type, len, cap int) unsafe.Pointer {
   mem, overflow := math.MulUintptr(et.size, uintptr(cap))
   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) // 申请内存
}

可以看到 slice 在初始化的过程中调用了 runtime 中的 makeslice 函数,这个函数会将 slice 的地址返回给接受的变量。

type slice struct {
        array unsafe.Pointer // 底层数组的地址
        len   int
        cap   int
}

// 初始化过程    
p := make([]int,0)
fmt.Printf("变量p的地址%p", &p)
fmt.Printf("slice的地址%p\n", p)

上面打印时出现的是的内容,这是因为 Go 内部实现了自动解引用(即 Go 内部实现的解引用操作)。自动解引用时 receive 会从指针类型转变为值类型。顺带一提自动取引用时 receiver 会从值类型转变为指针类型。

如果未实现自动解引用时会怎样呢?下面是未实现自动解引用的情况:

// 当我们打印变量p的时候,实际过程是发生了这样的变化
// 只是猜测,当然发生解引用是一定的
// & 取地址操作符
// * 根据地址取值操作 也称之为解引用运算法,间址运算符
// 1. 获取指针地址  &p
// 2. 获取array的地址 &((&p).array) 
// 3. 获取底层数组实际内容 *&((&p).array)

未实现自动借用的函数传递过程,也是通过复制指针的方式来传递的,内容如下:

package main

import (
        "fmt"
)

func change(p1 []int) {
        fmt.Printf("p1的内存地址是: %p\n", &p1)  // p1的内存地址是: 0xc0000a6048
        fmt.Printf("函数里接收到slice的内存地址是:%p\n", p1)  // 函数里接收到slice的内存地址是:0xc00008c030

        p1 = append(p1, 30)
}

func main() {
        p := make([]int, 3) // 抛出一个指针
        p = append(p, 20)
        fmt.Printf("p的内存地址是: %p\n", &p) // p的内存地址是: 0xc00009a018
        fmt.Printf("slice的内存地址是:%p\n", p) // slice的内存地址是:0xc00008c030

        change(p) // 重新生成一份地址p1 指向slice地址 
        fmt.Printf("修改之后p的内存地址 %p\n", &p) // 修改之后p的内存地址 0xc00009a018
        fmt.Printf("修改之后slice的内存地址 %p\n", p) // 修改之后slice的内存地址 0xc00008c030
        fmt.Println("修改之后的slice:", p) // 修改之后的slice [0 0 0 20]
        fmt.Println(*&p) // [0 0 0 20]
}

需要注意的是,在函数传递的过程中 copy 的不是 slice 内部指向底层数组的指针,而是在 makeslice 函数所返回的指针。

源码实现

大家在看一些老旧的文章的时候,可能看到过这样的说法:make 返回的是 slice 的实例。但其实这种说法已经过时了,在 Golang 1.2 版本之后 make 返回的就是实例的指针。

github pr 地址:https://github.com/golang/go/commits/dev.boringcrypto.go1.12/src/runtime/slice.go

扩展

其实和 slice 类似的还有 map,chan。

先说 map,map 的官网定义:“Go provides a built-in map type that implements a hash table.Map types are reference types, like pointers or slices.”而 chan 和 map 一样也是一个指针,也就是说二者和 slice 的原理相似。

func makemap(t *maptype, hint int, h *hmap) *hmap {
   mem, overflow := math.MulUintptr(uintptr(hint), t.bucket.size)
   if overflow || mem > maxAlloc {
      hint = 0
   }
   ...
}
func makechan(t *chantype, size int) *hchan {
   ...
   mem, overflow := math.MulUintptr(elem.size, uintptr(size))
   if overflow || mem > maxAlloc-hchanSize || size < 0 {
      panic(plainError("makechan: size out of range"))
   }
   ...
}

如果平时的使用中不注意,会出现一些不必要的麻烦,如:

package main

import "fmt"

type InfoIns struct {
        Name string
        info []string
}

func NewInfoIns() InfoIns{
        return InfoIns{
                Name: "",
                info: nil,
        }
}

func (n *InfoIns) SetInfo(info []string){
        n.info = info
}

func main(){
        infoIns := NewInfoIns()

        info := []string{"p1", "p2", "p3"}
        infoIns.SetInfo(info)
        info[1] = "p4"
        fmt.Println(infoIns.info) // [p1 p4 p3]
}

这里的 InfoIns 在 SetInfo 之后存的是 info 的地址。一旦 info 在后续有改动 InfoIns 中的内容也随之会被改动。解决的方法是在 SetInfo 的时候重新申请一份地址。

func (n *InfoIns) SetInfo(info []string){
        n.info = make([]string, len(info))
        copy(n.info, info)
}

脚注

借助 Goland 查看 Go 源码的方式:Ctrl+Shift+f 全局搜索,选择 Scope 中的 ALL Place。

推荐阅读

一文聊透 IP 地址的那些事

Golang 常见设计模式之装饰模式

No Reply at the moment.
You need to Sign in before reply, if you don't have an account, please Sign up first.