面試官問:Go 中的引數傳遞是值傳遞還是引用傳遞?

雲叔_又拍雲發表於2022-05-19

一個程式中,變數分為變數名和變數內容,變數內容的儲存一般會被分配到堆和棧上。而在 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 常見設計模式之裝飾模式

相關文章