Golang 切片作為函式引數傳遞的陷阱與解答

指尖下的幽灵發表於2024-07-09

作者:林冠宏 / 指尖下的幽靈。轉載者,請: 務必標明出處。

GitHub : https://github.com/af913337456/

出版的書籍:

  • 《1.0-區塊鏈DApp開發實戰》
  • 《2.0-區塊鏈DApp開發:基於公鏈》

  • 例子
    • 切片作為函式引數傳遞的是值
    • 用來誤導切片作為函式引數傳遞的是引用
    • 函式內切片 append 引起擴容的修改將無效
    • 不引起切片底層陣列擴容,驗證沒指向新陣列
    • 腳踏實地讓切片在函式內的修改
  • 彩蛋

切片 slice 幾乎是每個 Go 開發者以及專案中 100% 會高頻使用到的,Go 語言的知識很廣,唯獨 slice 我個人認為是必須要深入瞭解的。

乃至於今,網上還有很多關於切片 slice 技術文章一直存在的錯誤內容:切片作為函式引數傳遞的是引用,這是錯誤的。

無論是官方說明還是實踐操作都表明:切片作為函式引數傳遞的是值,和陣列一樣。


接下來我們直接看例子以加深印象。

切片作為函式引數傳遞的是值的例子:
func main() {
    mSlice := []int{1, 2, 3}
    fmt.Printf("main-1: %p \n", &mSlice) // 0x140000b2000
    mAppend(mSlice)
    fmt.Printf("main-2: %p \n", &mSlice) // 0x140000b2000
}

func mAppend(slice []int) {
    fmt.Printf("append func: %p \n", &slice) // 0x140000b2018 和外部的不一樣
}
錯覺例子,也是現在用來誤導切片作為函式引數傳遞的是引用的錯誤文章常用的
func main() {
    mSlice := []int{1, 2, 3}
    fmt.Printf("main-1: %v \n", mSlice) // [1,2,3]
    mAppend(mSlice)
    fmt.Printf("main-2: %v \n", mSlice) // [1,9,3],這裡2被修改了,但不是引用傳遞導致的
}

func mAppend(slice []int) {
    slice[2] = 9 // 修改
}
切片的內部結構:
// 原始碼路徑:go/src/runtime/slice.go
type slice struct {
    array unsafe.Pointer // 指標
    len   int
    cap   int
}

切片的本質是 struct,作為函式引數傳遞時候遵循 struct 性質,array 是指標指向一個陣列,len 是陣列的元素個數,cap 是陣列的的長度。當 len > cap,將觸發陣列擴容。

解析: 為什麼上面的 錯覺例子 能在函式內部改變值且在外部生效。這是因為當切片作為引數傳遞到函式里,雖然是值傳遞,但函式內複製出的新切片的 array 指標所指向的陣列和外部的舊切片是一樣的,那麼在沒引起擴容情況下進行值的修改就生效了。

舊切片 array 指標 ---> 陣列-1

新切片 array 指標 ---> 陣列-1,函式內發生改變


函式內切片 append 引起擴容的修改將無效的例子:
func main() {
    mSlice := []int{1, 2, 3}
    fmt.Printf("main-1: %v \n", mSlice) // [1,2,3]
    mAppend(mSlice)
    fmt.Printf("main-2: %v \n", mSlice) // [1,2,3] 沒生效
}

func mAppend(slice []int) {
    // slice[2] = 9 // 修改
    slice = append(slice, 4)
    fmt.Printf("append: %v \n", slice) // [1,2,3,4]
}

解析:切片初始化時候新增了3個數,導致其 len 和 cap 都是3,函式內新增第四個數的時候,觸發擴容,而擴容會導致擴容,array 指標指向新的陣列,在函式結束後,舊切片陣列並沒修改。

舊切片 array 指標 ---> 陣列-1 值 [1,2,3]

新切片 array 指標 ---> 陣列-2 值 [1,2,3,4]


不引起切片底層陣列擴容,驗證沒指向新陣列例子:
func main() {
    mSlice := make([]int, 3, 4) // len = 3, cap = 4, cap > len
    fmt.Printf("main-1: %v, 陣列地址: %p \n", mSlice, mSlice) // [0,0,0], 0x14000120000
    mAppend(mSlice)
    fmt.Printf("main-2: %v, 陣列地址: %p \n", mSlice, mSlice) // [0,0,0], 0x14000120000 
}

func mAppend(slice []int) {
    slice = append(slice, 4)
    fmt.Printf("append: %v, 陣列地址: %p \n", slice, slice) // [0,0,0,4], 0x14000120000
}

解析:可以看到切片的底層陣列地址並沒改變,但是陣列的值依然沒改變。這是因為切片是值傳遞到函式內部的,此時的 len 依然是值傳遞,當列印的時候,就只列印 len 以內的資料。

舊切片 len = 3

新切片 len = 4,函式內改變


至此,我們應該如何讓切片在函式內的修改生效?答案就是規規矩矩使用指標傳參

func main() {
    mSlice := []int{1, 2, 3}
    fmt.Printf("main-1: %v, 陣列地址: %p \n", mSlice, mSlice) // [1,2,3], 0x1400001a0a8
    mAppend(&mSlice)
    fmt.Printf("main-2: %v, 陣列地址: %p \n", mSlice, mSlice) // [1,2,3,4], 0x1400001a0a8
}

func mAppend(slice *[]int) {
    *slice = append(*slice, 4)
    fmt.Printf("append: %v, 陣列地址: %p \n", *slice, slice) // [1,2,3,4], 0x140000181b0
}

上面例子成功在函式內使用 append 修改了切片,也可以看到切片陣列地址變了,這是因為引起了擴容。但 array 指標沒變,所以擴容後,指向了新的。

切片 array 指標 ---> 陣列-1 值 [1,2,3]

切片 array 指標 ---> 陣列-2 值 [1,2,3,4]

彩蛋

切片的擴容:

  • go1.18之前,臨界值為1024,len 小於1024時,切片先2倍 len 擴容。大於 1024,每次增加 25% 的容量,直到新容量大於期望容量;

  • go1.18之後,臨界值為256,len 小於256,依然2倍 len 擴容。大於256走演算法:newcap += (newcap + 3*threshold) / 4,直到滿足。(threshold = 256)

相關文章