一、前言
在 Go 語言中,切片是一個非常常用的資料結構,很多開發者在編寫程式碼時都會頻繁使用它。儘管切片很方便,但有一個問題常常讓人感到困惑:當我們把切片作為引數傳遞給函式時,為什麼有時候切片的內容會發生變化?這讓很多人一頭霧水,甚至在除錯時浪費了不少時間。
這篇文章簡單明瞭地探討這個問題,揭示切片按值傳遞時發生變化的原因。我們透過一些簡單的示例,幫助大家理解這一現象是如何發生的,以及如何在實際開發中避免相關的坑。希望這篇文章能讓你對 Go 切片有更清晰的認識,少走一些彎路!
二、思考
在開始之前我們先來看幾則單測,思考一下切片呼叫 reverse 之後會發生什麼樣的變化?為什麼會有這樣的變化?
func TestReverse(t *testing.T) {
var s []int
for i := 1; i <= 3; i++ {
s = append(s, i)
}
reverse(s)
}
func reverse(s []int) {
for i, j := 0, len(s)-1; i < j; i++ {
j = len(s) - (i + 1)
s[i], s[j] = s[j], s[i]
}
}
func TestReverse2(t *testing.T) {
var s []int
for i := 1; i <= 3; i++ {
s = append(s, i)
}
reverse2(s)
}
func reverse2(s []int) {
s = append(s, 4)
for i, j := 0, len(s)-1; i < j; i++ {
j = len(s) - (i + 1)
s[i], s[j] = s[j], s[i]
}
}
func TestReverse3(t *testing.T) {
var s []int
for i := 1; i <= 3; i++ {
s = append(s, i)
}
reverse3(s)
}
func reverse3(s []int) {
s = append(s, 4, 5)
for i, j := 0, len(s)-1; i < j; i++ {
j = len(s) - (i + 1)
s[i], s[j] = s[j], s[i]
}
}
帶著上面的疑問,接下來我們回顧一下切片的基礎知識點。
三、切片的結構
type slice struct {
array unsafe.Pointer
len int
cap int
}
Go 切片的底層結構由以下三個部分組成:
指標(unsafe.Pointer):指向底層陣列的第一個元素。如果切片的長度為 0,那麼指標可以是 nil。這個指標允許切片訪問底層陣列中的元素。
長度(len):切片中實際包含的元素個數。透過 len(slice)可以獲取切片的長度。長度決定了切片在進行迭代或訪問元素時的範圍。
容量(cap):切片底層陣列的大小,表示切片可以增長的最大長度。可以透過
cap(slice)獲取容量。當切片的長度達到容量時,使用 append 函式新增更多元素時,Go 會新分配一個更大的陣列並複製現有元素。
一個切片的示意圖如下:
四、切片的建立
直接建立切片
func TestCreate(t *testing.T) {
slice := []int{1, 2, 3}
fmt.Printf("len=%d, cap=%d, slicePointer=%p, sliceArrayPointer=%p\n",
len(slice), cap(slice), &slice, slice)
}
上述示例程式碼執行輸出如下:
len=3, cap=3, slicePointer=0xc0000980d8, sliceArrayPointer=0xc0000b2048
此時建立出來的切片對應圖示如下:
直接建立切片時,會為切片的底層陣列開闢記憶體空間並使用指定的元素對陣列完成初始化,且建立出來的切片的 len 等於 cap 等於初始化元素的個數。
從整個陣列切得到切片
func TestCreate(t *testing.T) {
originArray := [3]int{1, 2, 3}
slice := originArray[:]
fmt.Printf("originArrayPointer=%p\n", &originArray)
fmt.Printf("len=%d, cap=%d, slicePointer=%p, sliceArrayPointer=%p\n",
len(slice), cap(slice), &slice, slice)
}
上述示例程式碼執行列印如下:
originArrayPointer=0xc000010198
len=3, cap=3, slicePointer=0xc0000080f0, sliceArrayPointer=0xc000010198
此時建立出來的切片對應圖示如下:
從整個陣列切,實際就是切片直接使用了這個陣列作為底層的陣列。
從前到後切陣列得到切片
func TestCreate(t *testing.T) {
originArray := [6]int{1, 2, 3, 4, 5, 6}
slice := originArray[:3]
fmt.Printf("originArrayPointer=%p\n", &originArray)
fmt.Printf("len=%d, cap=%d, slicePointer=%p, sliceArrayPointer=%p\n",
len(slice), cap(slice), &slice, slice)
}
上述在切陣列時,沒有指定陣列的開始索引,表示從索引 0 開始切(inclusive),指定了陣列的結束索引,表示切到結束索引的位置(exclusive),執行程式碼輸出如下:
originArrayPointer=0xc0000144c0
len=3, cap=6, slicePointer=0xc0000080f0, sliceArrayPointer=0xc0000144c0
此時建立出來的切片對應圖示如下:
從前到後切陣列得到的切片,len 等於切的範圍的長度,對應示例中索引 0(inclusive)到索引 2(exclusive)的長度 3,而 cap 等於切的開始位置(inclusive)到陣列末尾(inclusive)的長度 6。
從陣列中間切到最後得到切片
func TestCreate(t *testing.T) {
originArray := [6]int{1, 2, 3, 4, 5, 6}
slice := originArray[3:]
fmt.Printf("originArrayPointer=%p\n", &originArray)
fmt.Printf("len=%d, cap=%d, slicePointer=%p, sliceArrayPointer=%p\n",
len(slice), cap(slice), &slice, slice)
}
上述在切陣列時,指定了陣列的開始索引,表示從索引 3(inclusive)開始切,沒有指定陣列的結束索引,表示切到陣列的末尾(inclusive),執行程式碼輸出如下:
originArrayPointer=0xc0000bc060
len=3, cap=3, slicePointer=0xc0000980d8, sliceArrayPointer=0xc0000bc078
此時建立出來的切片對應圖示如下:
從陣列中間切到最後得到的切片,len 等於 cap 等於切的範圍的長度,對應示例中索引 3(inclusive)到陣列末尾(inclusive)的長度 3。並且由上述圖示可以看出,切片使用的底層陣列其實還是被切的陣列,只不過使用的是被切陣列的一部分。
從陣列切一段得到切片
func TestCreate(t *testing.T) {
originArray := [6]int{1, 2, 3, 4, 5, 6}
slice := originArray[2:5]
fmt.Printf("originArrayPointer=%p\n", &originArray)
fmt.Printf("len=%d, cap=%d, slicePointer=%p, sliceArrayPointer=%p\n",
len(slice), cap(slice), &slice, slice)
}
上述在切陣列時,指定了陣列的開始索引,表示從索引 2(inclusive)開始切,也指定了陣列的結束索引,表示切到陣列的索引 5 的位置(exclusive),執行程式碼輸出如下:
originArrayPointer=0xc0000bc060
len=3, cap=4, slicePointer=0xc0000980d8, sliceArrayPointer=0xc0000bc070
此時建立出來的切片對應圖示如下:
從陣列切一段得到的切片,len 等於切的範圍的長度,對應示例中索引 2(inclusive)到索引 5(exclusive)的長度 3,cap 等於切的開始位置(inclusive)到陣列末尾(inclusive)的長度 4。切片使用的底層陣列還是被切陣列的一部分。
從切片切得到切片
func TestCreate(t *testing.T) {
originArray := [6]int{1, 2, 3, 4, 5, 6}
originSlice := originArray[:]
derivedSlice := originSlice[2:4]
fmt.Printf("originArrayPointer=%p\n", &originArray)
fmt.Printf("originSlice: len=%d, cap=%d, slicePointer=%p, sliceArrayPointer=%p\n",
len(originSlice), cap(originSlice), &originSlice, originSlice)
fmt.Printf("derivedSlice: len=%d, cap=%d, slicePointer=%p, sliceArrayPointer=%p\n",
len(derivedSlice), cap(derivedSlice), &derivedSlice, derivedSlice)
}
上述示例程式碼中,originSlice 是切陣列 originArray 得到的切片,derivedSlice 是切切片 originSlice 得到的切片,執行程式碼輸出如下:
func TestCreate(t *testing.T) {
slice := make([]int, 3, 5)
fmt.Printf("slice: len=%d, cap=%d, slicePointer=%p, sliceArrayPointer=%p, slice=%v\n",
len(slice), cap(slice), &slice, slice, slice)
}
此時建立出來的切片對應圖示如下:
從切片切得到切片後,兩個切片會使用同一個底層陣列,區別就是可能使用的是底層陣列的不同區域,因此如果其中一個切片更改了資料,而這個資料恰好另一個切片可用訪問,那麼另一個切片訪問該資料時就會發現資料發生了更改。但是請注意,雖然兩個切片使用同一個底層陣列,但是切片的 len 和 cap 都是獨立的,也就是假如其中一個切片透過類似於 append() 函式導致 len 或者 cap 發生了更改,此時另一個切片的 len 或者 cap 是不會受影響的。
使用 make 函式得到切片
make() 函式專門用於為 slice,map 和 chan 這三種引用型別分配記憶體並完成初始化,make() 函式返回的就是引用型別對應的底層結構體本身,使用 make() 函式建立 slice 的示例程式碼如下所示:
func TestCreate(t *testing.T) {
slice := make([]int, 3, 5)
fmt.Printf("slice: len=%d, cap=%d, slicePointer=%p, sliceArrayPointer=%p, slice=%v\n",
len(slice), cap(slice), &slice, slice, slice)
}
上述示例程式碼中,會使用 make() 函式建立一個 int 型別的切片,並指定 len 為 3(第二個引數指定),cap 為 5(第三個引數指定),其中可以不指定 cap,此時 cap 會取值為 len。執行程式碼輸出如下:
slice: len=3, cap=5, slicePointer=0xc0000980d8, sliceArrayPointer=0xc0000bc060, slice=[0 0 0]
此時訪問索引 3 或索引 4 的元素,會引發 panic:
func TestCreate(t *testing.T) {
slice := make([]int, 3, 5)
fmt.Printf("slice: len=%d, cap=%d, slicePointer=%p, sliceArrayPointer=%p, slice=%v\n",
len(slice), cap(slice), &slice, slice, slice)
fmt.Printf("%p\n", &slice[3])
fmt.Printf("%p\n", &slice[4])
}
panic: runtime error: index out of range [3] with length 3
五、切片的擴容
在 Go 語言中,當使用 append()函式向切片新增元素時,如果切片的當前長度達到了它的容量,Go 會自動觸發擴容。擴容是指建立一個新的更大的底層陣列,並將原有元素複製到新陣列中。以下是關於切片觸發擴容的詳細說明。
觸發擴容的條件
當呼叫 append()函式,如果當前長度小於容量,可以直接在底層陣列中新增新元素;當切片的長度(len)達到或超過它的容量(cap)時,就會觸發擴容。
擴容操作
Go 會分配一個新的底層陣列。
原有的元素會被複制到新的陣列中。
切片的指標會更新為指向新的底層陣列,長度和容量也會相應更新。
最新的擴容規則在 1.18 版本中就已經發生改變了,具體可以參考一下這個 commit:runtime: make slice growth formula a bit smoother。
在之前的版本中:對於<1024 個元素,增加 2 倍,對於>=1024 個元素,則增加 1.25 倍。而現在,使用更平滑的增長因子公式。在 256 個元素後開始降低增長因子,但要緩慢。
它還給了個表格,寫明瞭不同容量下的增長因子:
從這個表格中,我們可以看到,新版本的切片庫容,並不是在容量小於 1024 的時候嚴格按照 2 倍擴容,大於 1024 的時候也不是嚴格地按照 1.25 倍來擴容;在 slice.go 原始碼中也驗證了這一點。
// nextslicecap computes the next appropriate slice length.
func nextslicecap(newLen, oldCap int) int {
newcap := oldCap
doublecap := newcap + newcap
if newLen > doublecap {
return newLen
}
cqgn.yjh9988.com,cqgn.jyh01.com,cqgn.gzysart.com
cqgn.kfamaw.com,cqgn.shplcchina.com
const threshold = 256
if oldCap < threshold {
return doublecap
}
for {
// Transition from growing 2x for small slices
// to growing 1.25x for large slices. This formula
// gives a smooth-ish transition between the two.
newcap += (newcap + 3*threshold) >> 2
// We need to check `newcap >= newLen` and whether `newcap` overflowed.
// newLen is guaranteed to be larger than zero, hence
// when newcap overflows then `uint(newcap) > uint(newLen)`.
// This allows to check for both with the same comparison.
if uint(newcap) >= uint(newLen) {
break
}
}
// Set newcap to the requested cap when
// the newcap calculation overflowed.
if newcap <= 0 {
return newLen
}
return newcap
}
上面說到:一旦觸發擴容,會建立新容量大小的陣列,然後將老陣列的資料複製到新陣列上,再然後將附加元素新增到新陣列中,最後切片的 array 指向新陣列。也就是說,切片擴容會導致切片使用的底層陣列地址發生變更,我們透過程式碼來了解這一過程:
func TestSliceGrow(t *testing.T) {
// 原始陣列
originArray := [6]int{1, 2, 3, 4, 5, 6}
// 原始切片
originSlice := originArray[0:5]
// 列印原始切片和原始陣列的資訊
fmt.Printf("len=%d, cap=%d, slicePointer=%p, sliceArrayPointer=%p, originArrayPointer=%p\n",
len(originSlice), cap(originSlice), &originSlice, originSlice, &originArray)
// 第一次append不會觸發擴容
firstAppendSlice := append(originSlice, 7)
// 列印第一次Append後的切片和原始陣列的資訊
fmt.Printf("len=%d, cap=%d, slicePointer=%p, sliceArrayPointer=%p, originArrayPointer=%p\n",
len(firstAppendSlice), cap(firstAppendSlice), &firstAppendSlice, firstAppendSlice, &originArray)
// 第二次append會觸發擴容
secondAppendSlice := append(firstAppendSlice, 8)
// 列印第二次Append後的切片和原始陣列的資訊
fmt.Printf("len=%d, cap=%d, slicePointer=%p, sliceArrayPointer=%p, originArrayPointer=%p\n",
len(secondAppendSlice), cap(secondAppendSlice), &secondAppendSlice, secondAppendSlice, &originArray)
}
cqgn.shuixitech.com,cqgn.huanbao580.com,cqgn.szlcdpq.com
cqgn.sdymsxfh.com,cqgn.tanjiuspace.com
執行上面程式碼輸出如下:
len=5, cap=6, slicePointer=0xc0000980d8, sliceArrayPointer=0xc0000bc060, originArrayPointer=0xc0000bc060
len=6, cap=6, slicePointer=0xc000098108, sliceArrayPointer=0xc0000bc060, originArrayPointer=0xc0000bc060
len=7, cap=12, slicePointer=0xc000098138, sliceArrayPointer=0xc0000862a0, originArrayPointer=0xc0000bc060
在示例程式碼中,切陣列 originArray 得到的切片如下所示:
第一次 append 元素後,切片如下所示:
第二次 append 元素時,會觸發擴容,擴容後的切片如下所示:
可見,擴容後切片使用了另外一個陣列作為了底層陣列。對擴容之後的切片任何操作將不再影響原切片;反之:擴容之前,對新切片的新增和修改影響的是底層陣列,同時也會影響引用了該陣列的任何切片。
現在,讓我們回顧一下文章開頭提到的三個單元測試,執行它們後得到的結果是否符合你的預期?結合我們對切片建立、初始化和擴容的基礎知識,你是否能理解為何切片在傳遞時是值傳遞,但原始切片中的元素卻可能會發生變化?
六、總結
這篇文章透過簡單明瞭的示例,深入分析了 Go 語言中切片作為引數傳遞時值變化的問題。揭示了切片的執行機制,幫助開發者理解為什麼在函式內部對切片的修改會影響到原始切片的內容。這樣的分析旨在消除開發中遇到的困惑,為實際開發提供更清晰的指導。
最重要的是,希望這篇文章能夠傳達一個資訊:當你對某個現象的原因尚不完全理解時,花時間去深入探究是非常值得的。這種探究不僅能提升你的程式設計能力,更能培養解決問題的能力。