[Golang] slice 作為參數傳入 func 的注意事項


Posted by kitecloud213 on 2020-08-15

Golang 傳遞參數進 func 的方式分為 pass by value 及 pass by reference,
當 pass by value 時,會在進入 func 時複製一份。
以下面操作 array 的 code 為例:

package main

import (
    "fmt"
)

func main() {
    a1 := [5]int{0, 0, 0, 0, 0}
    fmt.Println("array", a1, fmt.Sprintf("%p", &a1))
    modifyArray(a1)
    fmt.Println("array", a1, fmt.Sprintf("%p", &a1))
}

func modifyArray(array [5]int) {
    array[3] = 3
    fmt.Println("modifyArray", array, fmt.Sprintf("%p", &array))
}

輸出結果:

array [0 0 0 0 0] 0xc00010c000
modifyArray [0 0 0 3 0] 0xc00010c060
array [0 0 0 0 0] 0xc00010c000

可以看到傳入 func 的 array 記憶體位址改變了,
modifyArray() 只有修改到傳進去的副本,
必須將程式修改成傳 pointer 才能修改到原始 array。

同樣的情況放到 slice 就稍微有點不同:

package main

import (
    "fmt"
)

func main() {
    s1 := make([]int, 5)
    fmt.Println("slice", s1, fmt.Sprintf("%p", &s1))
    modifySlice(s1)
    fmt.Println("slice", s1, fmt.Sprintf("%p", &s1))
}

func modifySlice(slice []int) {
    slice[3] = 3
    fmt.Println("modifySlice", slice, fmt.Sprintf("%p", &slice))
}

輸出結果:

slice [0 0 0 0 0] 0xc0000ae040
modifySlice [0 0 0 3 0] 0xc0000ae080
slice [0 0 0 3 0] 0xc0000ae040

一樣可以發現傳進去的 slice 記憶體位址改變了,
但最後把 slice 內容印出來顯示 slice[3] 有正確改變成 3
這是因為 slice 的資料其實不是儲存在它自己本身上的。
所以看起來很像 pass by value 可以修改到同一個 slice,
實際上還是在操作副本。


一個 slice 的結構包含了:

  • Data pointer: 指向 array 其中一個 element 的 pointer
  • Len:slice 的長度
  • Cap:slice 的容量 (背後指向的 array 的長度)

可以理解成 slice 其實只是告訴你以哪個 array 的哪個位址作為起點,
再告訴你這個 slice 可以看到多長的資料,有一點點像是一個窗戶的感覺。
用下面的例子可以看出這個現象:

package main

import (
    "fmt"
)

func main() {
    arr := [5]int{0, 1, 2, 3, 4}
    slice := arr[:4]
    fmt.Println(fmt.Sprintf("%+v %p, len:%d", arr, &arr, len(arr)))
    fmt.Println(fmt.Sprintf("%+v %p, len:%d, cap:%d", slice, slice, len(slice), cap(slice)))
    fmt.Println("----")

    slice = append(slice, 5)
    fmt.Println(fmt.Sprintf("%+v %p, len:%d", arr, &arr, len(arr)))
    fmt.Println(fmt.Sprintf("%+v %p, len:%d, cap:%d", slice, slice, len(slice), cap(slice)))
    fmt.Println("----")

    slice = append(slice, 6)
    fmt.Println(fmt.Sprintf("%+v %p, len:%d", arr, &arr, len(arr)))
    fmt.Println(fmt.Sprintf("%+v %p, len:%d, cap:%d", slice, slice, len(slice), cap(slice)))
    fmt.Println("----")

    arr[4] = 4
    fmt.Println(fmt.Sprintf("%+v %p, len:%d", arr, &arr, len(arr)))
    fmt.Println(fmt.Sprintf("%+v %p, len:%d, cap:%d", slice, slice, len(slice), cap(slice)))
    fmt.Println("----")
}

輸出結果:

[0 1 2 3 4] 0xc000078030, len:5
[0 1 2 3] 0xc000078030, len:4, cap:5
----
[0 1 2 3 5] 0xc000078030, len:5
[0 1 2 3 5] 0xc000078030, len:5, cap:5
----
[0 1 2 3 5] 0xc000078030, len:5
[0 1 2 3 5 6] 0xc000012050, len:6, cap:10
----
[0 1 2 3 4] 0xc000078030, len:5
[0 1 2 3 5 6] 0xc000012050, len:6, cap:10
----

可以觀察到 slice 還沒超過 cap 時,你操作 slice 等於影響原始資料 array;
當 slice 持續 append 導致 cap 超過 array 長度時,
會自動幫你生成一個 2倍大的 array 、複製資料過去、並把 slice 指向這個新的 slice。此時你操作 slice 影響到的不是原本的 array 而是 2倍大的 array
所以在使用 slice 時要注意是否超過 cap 導致生成新的 array,
常見的陷阱是資料小於 cap 時可以正常透過 slice 修改 array,
一旦超過 cap 就會發現怎麼修改都無法改變原始的 array。

回到我們一開始的例子:

package main

import (
    "fmt"
)

func main() {
    s1 := make([]int, 5)
    fmt.Println("slice", s1, fmt.Sprintf("%p", &s1))
    modifySlice(s1)
    fmt.Println("slice", s1, fmt.Sprintf("%p", &s1))
}

func modifySlice(slice []int) {
    slice[3] = 3
    fmt.Println("modifySlice", slice, fmt.Sprintf("%p", &slice))
}

我們宣告 s1 時有指定 cap 為 5,而且我只有修改其中的值而沒有改變 cap,
所以雖然在 func 內操作的 slice 是副本,但指向的 array 是同一個,
所以即便是 pass by value 也可以改變裡面的資料,
因為資料是存在 array 而不是 slice 裡。


雖然副本 slice 可以操作同一個 array,
但如果是使用 append() 這種會改變 slice len 的操作,
就只會改到副本 slice 而不是原先的 slice,
例如像這樣:

package main

import (
    "fmt"
)

func main() {
    arr := [5]int{0, 0, 0, 0, 0}
    slice := arr[0:0]
    fmt.Println("slice", slice , fmt.Sprintf("%p %p", &slice , slice), arr)
    modifySlice(slice)
    fmt.Println("slice", slice , fmt.Sprintf("%p %p", &slice , slice), arr)
}

func modifySlice(slice []int) {
    slice = append(slice, 1, 2, 3)
    fmt.Println("modifySlice", slice, fmt.Sprintf("%p %p", &slice, slice))

    slice[1] = -2
    fmt.Println("modifySlice", slice, fmt.Sprintf("%p %p", &slice, slice))
}

輸出結果:

slice [] 0xc0000ae040 0xc0000b2030 [0 0 0 0 0]
modifySlice [1 2 3] 0xc0000ae0a0 0xc0000b2030
modifySlice [1 -2 3] 0xc0000ae0a0 0xc0000b2030
slice [] 0xc0000ae040 0xc0000b2030 [1 -2 3 0 0]

可以看到 func 內的 slice 操作完後因為 len 有改變,所以可以 觀測 到3個數值;
離開 func 後,原始的 slice 的 len 還是 0,所以 觀測 不到任何值,
但原始資料 array 確實值已經被改變了。
這部分若沒有弄清楚 slice 的運作原理的話,
會誤以為 slice 可以用 pass by value 取代 pass by reference,
實際上要取得完整的 slice 控制還是得傳 pointer 進去才能做到。

Golang 的 slice 用起來很方便,但也有一些小陷阱需要注意,
弄清楚背後的原理就可以避開這些問題囉!


#golang







Related Posts

筆記、物件導向 ( 程式導師實驗計畫 )

筆記、物件導向 ( 程式導師實驗計畫 )

[8] 進階資料型別 part3 - Set、Dict

[8] 進階資料型別 part3 - Set、Dict

Promise 筆記

Promise 筆記


Comments