Go 語言傳值和深淺複製問題

astaxie發表於2020-02-12

關於傳值還是傳引用的問題,在go 的呼叫規範中有提到,在函式呼叫中是傳值的(是否有其他的例外情況,我們後面再來考察),但是在下面的例子中,我們將看到大部分情況下,這句話是好理解的,但是還是會有意外。那麼這句話應該怎麼理解呢?

先來看看這個例子:

1. 一個例子

type Color int

const (
  black = iota
  green
  blue
)

type Cat struct {
  name string
  age  int
  legs Legs
  eye  map[string]Eye
  tail Tail
}

type Eye struct {
  color Color
}

type Tail struct {
  length float64
}

type Legs struct {
  count  int
  length []int
}

這隻貓融合了好幾種型別的資料,包括基本型別,內嵌自定義物件,map,slice。將 Legs 而不是 Leg 定義為一個物件是有意而為之,是為了測試一個內嵌物件中包含的基本型別和 slice 型別時的複製行為。

func ModifyCat(cat Cat) {
  fmt.Printf("enter value cat:%v\n", cat)
  fmt.Printf("in modify: cat:%p, cat.name:%p, cat.tail:%p\n", &cat, &cat.name, &cat.tail)
  fmt.Printf("in modify: cat.legs.length:%p, cat.legs.count:%p\n", &cat.legs.length, &cat.legs.count)
  cat.name = "Ben"
  cat.eye["left"] = Eye{blue}
  cat.tail = Tail{234.56}
  cat.legs.count = 3
  cat.legs.length[0] = 0
  fmt.Printf("exit value cat:%v\n\n", cat)
}

func main() {
  catA := Cat{
    name: "tom",
    age:  1,
    legs: Legs{count: 4, length: []int{10, 10, 10, 10}},
    eye:  map[string]Eye{"left":{black}, "right":{green}},
    tail: Tail{123.45},
  }

  fmt.Printf("value catA:%v, catB:%v\n", catA, catB)
  fmt.Printf("address catA:%p, catB:%p\n", &catA, &catB)
  fmt.Printf("address catA.eye:%p, catB.eye:%p,\n", &catA.eye, &catB.eye)
  fmt.Printf("address catA.name:%p, catB.name:%p\n", &catA.name, &catB.name)
  fmt.Printf("address catA.tail:%p, catB.tail:%p\n", &catA.tail, &catB.tail)
  fmt.Printf("address catA.legs.count:%p, catB.legs.count:%p\n", &catA.legs.count, &catB.legs.count)
  fmt.Printf("address catA.legs.length:%p, catB.legs.length:%p\n", &catA.legs.length, &catB.legs.length)
  fmt.Println()

  ModifyCat(catB)
  fmt.Printf("value catA:%v, catB:%v\n", catA, catB)
  fmt.Printf("address catA:%p, catB:%p\n", &catA, &catB)
  fmt.Printf("address catA.eye:%p, catB.eye:%p,\n", &catA.eye, &catB.eye)
  fmt.Printf("address catA.name:%p, catB.name:%p\n", &catA.name, &catB.name)
  fmt.Printf("address catA.tail:%p, catB.tail:%p\n", &catA.tail, &catB.tail)
  fmt.Printf("address catA.legs.count:%p, catB.legs.count:%p\n", &catA.legs.count, &catB.legs.count)
  fmt.Printf("address catA.legs.length:%p, catB.legs.length:%p\n\n", &catA.legs.length, &catB.legs.length)

  catB.name = "Ben"
  fmt.Printf("in modify: cat: %p\n", &catB)
  catB.eye["right"] = Eye{black}
  catB.tail = Tail{234.56}
  catB.legs.count = 3
  catB.legs.length[1] = 0
  fmt.Printf("value catA:%v, catB:%v\n", catA, catB)
  fmt.Printf("address catA:%p, catB:%p\n", &catA, &catB)
  fmt.Printf("address catA.eye:%p, catB.eye:%p,\n", &catA.eye, &catB.eye)
  fmt.Printf("address catA.name:%p, catB.name:%p\n", &catA.name, &catB.name)
  fmt.Printf("address catA.tail:%p, catB.tail:%p\n", &catA.tail, &catB.tail)
  fmt.Printf("address catA.legs.count:%p, catB.legs.count:%p\n", &catA.legs.count, &catB.legs.count)
  fmt.Printf("address catA.legs.length:%p, catB.legs.length:%p\n", &catA.legs.length, &catB.legs.length)
}

我們首先建立了一直貓 catA,然後建立一個 catB,並直接將 catA 賦值給 catB,然後將 catB 傳給 ModifyCat(Cat) 函式修改這隻新貓的模樣。並列印出前後的過程。

我們看到的具體輸出如下:

value catA:{tom 1 {4 [10 10 10 10]} map[left:{0} right:{1}] {123.45}}, catB:{tom 1 {4 [10 10 10 10]} map[left:{0} right:{1}] {123.45}}
address catA:0xc820016190, catB:0xc8200161e0
address catA.eye:0xc8200161c8, catB.eye:0xc820016218,
address catA.name:0xc820016190, catB.name:0xc8200161e0
address catA.tail:0xc8200161d0, catB.tail:0xc820016220
address catA.legs.count:0xc8200161a8, catB.legs.count:0xc8200161f8
address catA.legs.length:0xc8200161b0, catB.legs.length:0xc820016200

enter value cat:{tom 1 {4 [10 10 10 10]} map[right:{1} left:{0}] {123.45}}
in modify: cat:0xc8200162d0, cat.name:0xc8200162d0, cat.tail:0xc820016310
in modify: cat.legs.length:0xc8200162f0, cat.legs.count:0xc8200162e8
exit value cat:{Ben 1 {3 [0 10 10 10]} map[left:{2} right:{1}] {234.56}}

value catA:{tom 1 {4 [0 10 10 10]} map[left:{2} right:{1}] {123.45}}, catB:{tom 1 {4 [0 10 10 10]} map[left:{2} right:{1}] {123.45}}
address catA:0xc820016190, catB:0xc8200161e0
address catA.eye:0xc8200161c8, catB.eye:0xc820016218,
address catA.name:0xc820016190, catB.name:0xc8200161e0
address catA.tail:0xc8200161d0, catB.tail:0xc820016220
address catA.legs.count:0xc8200161a8, catB.legs.count:0xc8200161f8
address catA.legs.length:0xc8200161b0, catB.legs.length:0xc820016200

value catA:{tom 1 {4 [0 0 10 10]} map[left:{2} right:{0}] {123.45}}, catB:{Ben 1 {3 [0 0 10 10]} map[right:{0} left:{2}] {234.56}}  
address catA:0xc820016190, catB:0xc8200161e0
address catA.eye:0xc8200161c8, catB.eye:0xc820016218,
address catA.name:0xc820016190, catB.name:0xc8200161e0
address catA.tail:0xc8200161d0, catB.tail:0xc820016220
address catA.legs.count:0xc8200161a8, catB.legs.count:0xc8200161f8
address catA.legs.length:0xc8200161b0, catB.legs.length:0xc820016200

根據輸出可以看到幾點:

  1. catB 進行賦值之後,不管是 cat 本身,還是 cat 內部的各個屬性的地址都已經改變了。
  2. 在函式內列印 cat 的幾個屬性的地址,可以看到和傳入之前的 catB 是不同的。
  3. 在函式內對 cat 的名字修改後,並沒有影響 catB,cat 是傳值的。
  4. 通過賦值得到的 catB,直接修改 catB 的名字,也沒有影響 catA 的名字。
  5. 這些看似符合 go 規範的說法。

幾點意外:

  1. 我們在函式內對 cat 的左眼的修改,影響到了外部的 catB,甚至影響到了 catA!
  2. 在外部對 catB 的右眼的修改,影響到了 catA
  3. 在函式內外對 cat 的腿部的長度 (slice 型別) 的修改,都和眼睛有一樣的效果。但是對腿部的數量 (int 型別) 的修改則沒有。
  4. 這些看起來可不像傳值該有的表現!!!

要理解這個問題,先來了解 slice 的底層結構。

2. 理解 Slice

slice 包括三個部分,一個指標 ptr,指向 slice 的第一個元素;一個長度 len 表示 slice 的長度,一個容量 cap 表示 slice 的容量。如下圖:

go-slices-usage-and-internals_slice-struct

而 slice 的指標所指向 “第一個元素” 實際上是一個底層陣列的第一個元素。可能會有多個 slice 共享著同一個底層陣列。這種方式導致對 slice 的擷取,拼接等操作都異常高效,可以在常數時間內完成。

考慮下面的陣列,這樣的陣列可以通過make([]byte, 5)來建立:

go-slices-usage-and-internals_slice-1

然後對該陣列執行s = s[2:4]操作:

go-slices-usage-and-internals_slice-2

對於底層陣列而言一次都沒變,僅僅是 slice 的頭部三個元素變發生了變化。

容量和長度的關係是 len<=cap。一個 slice 的 cap 就是底層陣列的長度。當 len>cap(比如做了一次很長的 append) 的時候,就需要構造一個容量更長的底層陣列。

所以 slice 並不是簡單的傳值關係,就像指標和 chan 這些引用型別一樣,map 和 slice 對底層元素的修改都是引用型別的,map 和 slice 的頭部地址可以發生改變,但是他們引用到的底層陣列可能公共的。

3. 閉包 (closure)

需要注意的是,go 的閉包也是引用型別。考慮下面的程式碼:

for i := 0; i < 5; i++ {
  defer fmt.Printf("%d ", i)
  // Output: 4 3 2 1 0
}

fmt.Printf("\n")
  for i := 0; i < 5; i++ {
  defer func(){ 
    fmt.Printf("%d ", i) 
  } () // Output: 5 5 5 5 5
}

第二個函式是外部變數在閉包內通過被改變了的情況,defer 函式內的表示式會在它出現的地方就已經被求值,然後在退出函式之前,按照 defer 出現的順序逆向執行。也就是說當第一條 defer 求值的時候,i=1,第五條 defer 求值的時候,i=5,對於兩個函式都是如此。區別在於第一個函式的 i 在每次 defer 是傳值進 printf 函式的,所以在 defer 中,i 等於有 5 份拷貝,而第二個函式使用閉包的方式引用了外部變數 i 其實只有一份!

要在閉包中避免上面的問題,可以有兩種方式。

// 方法1: 每次迴圈構造一個臨時變數 i
for i := 0; i < 5; i++ {
  i := i
  defer func(){ fmt.Printf("%d ", i) } ()
  // Output: 4 3 2 1 0
}
// 方法2: 通過函式引數傳參
for i := 0; i < 5; i++ {
  defer func(i int){ fmt.Printf("%d ", i) } (i)
  // Output: 4 3 2 1 0
}

4. 深度拷貝

關於深度拷貝,這裡有個使用 gob 序列化反序列化的例子:

func deepCopy(dst, src interface{}) error {
  var buf bytes.Buffer
  if err := gob.NewEncoder(&buf).Encode(src); err != nil {
    return err
  }
  return gob.NewDecoder(bytes.NewBuffer(buf.Bytes())).Decode(dst)
}

另外也有一些利用反射進行實現的方式:

5. 最佳實踐

slice 和 map 都不支援==操作符,判斷兩個 slice 相等需要自己寫迴圈判斷,這種迴圈判斷的方式效率並不會很低; map 不支援對元素取地址,如果這樣做,編輯器會拒絕編譯,原因是隨著 map 容量的擴張,底層資料結構可能改變,導致所取得的地址無效。 對 slice 的元素取地址編譯器是不禁止的,但是我們仍應該避免這樣做,因為 slice 擴張也會導致在新的記憶體空間重新構造底層陣列,而如果操作之前儲存的地址值可能會導致無法預料的結果。

for…range…操作中,如果取了值,而不是通過去下標,像這樣:for i,v := range s,其中 v 並不是 s 內元素的一個引用,改變 v 的值,並不能改變 s 中對應位置的元素。如果要這樣做必須通過下標 s[i] 進行操作。

  1. 參考
  2. Go Slices: usage and internals
  3. effective_go.html#maps
  4. go spec#Calls

原文地址:http://kchu.me/2016/03/27/Go%E8%AF%AD%E8%A8%80%E4%BC%A0%E5%80%BC%E5%92%8C%E6%B7%B1%E6%B5%85%E5%A4%8D%E5%88%B6%E9%97%AE%E9%A2%98/

更多原創文章乾貨分享,請關注公眾號
  • Go 語言傳值和深淺複製問題
  • 加微信實戰群請加微信(註明:實戰群):gocnio

相關文章