這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
切片是 Go 中的一種基本的資料結構,使用這種結構可以用來管理資料集合。切片的設計想法是由動態數組概念而來,為了開發人員可以更加方便的使一個資料結構可以自動增加和減少。但是切片本身並不是動態資料或者數組指標。切片常見的操作有 reslice、append、copy。與此同時,切片還具有可索引,可迭代的優秀特性。
一. 切片和數組
關於切片和數組怎麼選擇?接下來好好討論討論這個問題。
在 Go 中,與 C 陣列變數隱式作為指標使用不同,Go 數組是實值型別,賦值和函數傳參操作都會複製整個數組資料。
func main() { arrayA := [2]int{100, 200} var arrayB [2]int arrayB = arrayA fmt.Printf("arrayA : %p , %v\n", &arrayA, arrayA) fmt.Printf("arrayB : %p , %v\n", &arrayB, arrayB) testArray(arrayA)}func testArray(x [2]int) { fmt.Printf("func Array : %p , %v\n", &x, x)}
列印結果:
arrayA : 0xc4200bebf0 , [100 200] arrayB : 0xc4200bec00 , [100 200] func Array : 0xc4200bec30 , [100 200]
可以看到,三個記憶體位址都不同,這也就驗證了 Go 中數組賦值和函數傳參都是值複製的。那這會導致什麼問題呢?
假想每次傳參都用數組,那麼每次數組都要被複製一遍。如果數組大小有 100萬,在64位機器上就需要花費大約 800W 位元組,即 8MB 記憶體。這樣會消耗掉大量的記憶體。於是乎有人想到,函數傳參用數組的指標。
func main() { arrayA := [2]int{100, 200} testArrayPoint(&arrayA) // 1.傳數組指標 arrayB := arrayA[:] testArrayPoint(&arrayB) // 2.傳切片 fmt.Printf("arrayA : %p , %v\n", &arrayA, arrayA)}func testArrayPoint(x *[]int) { fmt.Printf("func Array : %p , %v\n", x, *x) (*x)[1] += 100}
列印結果:
func Array : 0xc4200b0140 , [100 200] func Array : 0xc4200b0180 , [100 300] arrayA : 0xc4200b0140 , [100 400]
這也就證明了數組指標確實到達了我們想要的效果。現在就算是傳入10億的數組,也只需要再棧上分配一個8個位元組的記憶體給指標就可以了。這樣更加高效的利用記憶體,效能也比之前的好。
不過傳指標會有一個弊端,從列印結果可以看到,第一行和第三行指標地址都是同一個,萬一原數組的指標指向更改了,那麼函數裡面的指標指向都會跟著更改。
切片的優勢也就表現出來了。用切片傳數組參數,既可以達到節約記憶體的目的,也可以達到合理處理好共用記憶體的問題。列印結果第二行就是切片,切片的指標和原來數組的指標是不同的。
由此我們可以得出結論:
把第一個大數組傳遞給函數會消耗很多記憶體,採用切片的方式傳參可以避免上述問題。切片是引用傳遞,所以它們不需要使用額外的記憶體並且比使用數組更有效率。
但是,依舊有反例。
package mainimport "testing"func array() [1024]int { var x [1024]int for i := 0; i < len(x); i++ { x[i] = i } return x}func slice() []int { x := make([]int, 1024) for i := 0; i < len(x); i++ { x[i] = i } return x}func BenchmarkArray(b *testing.B) { for i := 0; i < b.N; i++ { array() }}func BenchmarkSlice(b *testing.B) { for i := 0; i < b.N; i++ { slice() }}
我們做一次效能測試,並且禁用內聯和最佳化,來觀察切片的堆上記憶體配置的情況。
go test -bench . -benchmem -gcflags "-N -l"
輸出結果比較“令人意外”:
BenchmarkArray-4 500000 3637 ns/op 0 B/op 0 alloc s/op BenchmarkSlice-4 300000 4055 ns/op 8192 B/op 1 alloc s/op
解釋一下上述結果,在測試 Array 的時候,用的是4核,迴圈次數是500000,平均每次執行時間是3637 ns,每次執行堆上分配記憶體總量是0,分配次數也是0 。
而切片的結果就“差”一點,同樣也是用的是4核,迴圈次數是300000,平均每次執行時間是4055 ns,但是每次執行一次,堆上分配記憶體總量是8192,分配次數也是1 。
這樣對比看來,並非所有時候都適合用切片代替數組,因為切片底層數組可能會在堆上分配記憶體,而且小數組在棧上拷貝的消耗也未必比 make 消耗大。
二. 切片的資料結構
切片本身並不是動態數組或者數組指標。它內部實現的資料結構通過指標引用底層數組,設定相關屬性將資料讀寫操作限定在指定的地區內。切片本身是一個唯讀對象,其工作機制類似數組指標的一種封裝。
切片(slice)是對數組一個連續片段的引用,所以切片是一個參考型別(因此更類似於 C/C++ 中的數群組類型,或者 Python 中的 list 類型)。這個片段可以是整個數組,或者是由起始和終止索引標識的一些項的子集。需要注意的是,終止索引標識的項不包括在切片內。切片提供了一個與指向數組的動態視窗。
給定項的切片索引可能比相關數組的相同元素的索引小。和數組不同的是,切片的長度可以在運行時修改,最小為 0 最大為相關數組的長度:切片是一個長度可變的數組。
Slice 的資料結構定義如下:
type slice struct { array unsafe.Pointer len int cap int}
切片的結構體由3部分構成,Pointer 是指向一個數組的指標,len 代表當前切片的長度,cap 是當前切片的容量。cap 總是大於等於 len 的。
如果想從 slice 中得到一塊記憶體位址,可以這樣做:
s := make([]byte, 200) ptr := unsafe.Pointer(&s[0])
如果反過來呢?從 Go 的記憶體位址中構造一個 slice。
var ptr unsafe.Pointer var s1 = struct { addr uintptr len int cap int}{ptr, length, length}s := *(*[]byte)(unsafe.Pointer(&s1))
構造一個虛擬結構體,把 slice 的資料結構拼出來。
當然還有更加直接的方法,在 Go 的反射中就存在一個與之對應的資料結構 SliceHeader,我們可以用它來構造一個 slice
var o []byte sliceHeader := (*reflect.SliceHeader)((unsafe.Pointer(&o))) sliceHeader.Cap = length sliceHeader.Len = length sliceHeader.Data = uintptr(ptr)
三. 建立切片
make 函數允許在運行期動態指定數組長度,繞開了數群組類型必須使用編譯期常量的限制。
建立切片有兩種形式,make 建立切片,空切片。
1. make 和切片字面量
func makeslice(et *_type, len, cap int) slice { // 根據切片的資料類型,擷取切片的最大容量 maxElements := maxSliceCap(et.size) // 比較切片的長度,長度範圍應該在[0,maxElements]之間 if len < 0 || uintptr(len) > maxElements { panic(errorString("makeslice: len out of range")) } // 比較切片的容量,容量範圍應該在[len,maxElements]之間 if cap < len || uintptr(cap) > maxElements { panic(errorString("makeslice: cap out of range")) } // 根據切片的容量申請記憶體 p := mallocgc(et.size*uintptr(cap), et, true) // 返回申請好記憶體的切片的首地址 return slice{p, len, cap}}
還有一個 int64 的版本:
func makeslice64(et *_type, len64, cap64 int64) slice { len := int(len64) if int64(len) != len64 { panic(errorString("makeslice: len out of range")) } cap := int(cap64) if int64(cap) != cap64 { panic(errorString("makeslice: cap out of range")) } return makeslice(et, len, cap)}
實現原理和上面的是一樣的,只不過多了把 int64 轉換成 int 這一步罷了。
是用 make 函數建立的一個 len = 4, cap = 6 的切片。記憶體空間申請了6個 int 類型的記憶體大小。由於 len = 4,所以後面2個暫時訪問不到,但是容量還是在的。這時候數組裡面每個變數都是0 。
除了 make 函數可以建立切片以外,字面量也可以建立切片。
這裡是用字面量建立的一個 len = 6,cap = 6 的切片,這時候數組裡面每個元素的值都初始化完成了。需要注意的是 [ ] 裡面不要寫數組的容量,因為如果寫了個數以後就是數組了,而不是切片了。
還有一種簡單的字面量建立切片的方法。如。就 Slice A 建立出了一個 len = 3,cap = 3 的切片。從原數組的第二位元素(0是第一位)開始切,一直切到第四位為止(不包括第五位)。同理,Slice B 建立出了一個 len = 2,cap = 4 的切片。
2. nil 和空切片
nil 切片和空切片也是常用的。
var slice []int
nil 切片被用在很多標準庫和內建函數中,描述一個不存在的切片的時候,就需要用到 nil 切片。比如函數在發生異常的時候,返回的切片就是 nil 切片。nil 切片的指標指向 nil。
空切片一般會用來表示一個空的集合。比如資料庫查詢,一條結果也沒有查到,那麼就可以返回一個空切片。
silce := make( []int , 0 ) slice := []int{ }
空切片和 nil 切片的區別在於,空切片指向的地址不是nil,指向的是一個記憶體位址,但是它沒有分配任何記憶體空間,即底層元素包含0個元素。
最後需要說明的一點是。不管是使用 nil 切片還是空切片,對其調用內建函數 append,len 和 cap 的效果都是一樣的。
四. 切片擴容
當一個切片的容量滿了,就需要擴容了。怎麼擴,策略是什嗎?
func growslice(et *_type, old slice, cap int) slice { if raceenabled { callerpc := getcallerpc(unsafe.Pointer(&et)) racereadrangepc(old.array, uintptr(old.len*int(et.size)), callerpc, funcPC(growslice)) } if msanenabled { msanread(old.array, uintptr(old.len*int(et.size))) } if et.size == 0 { // 如果新要擴容的容量比原來的容量還要小,這代表要縮容了,那麼可以直接報panic了。 if cap < old.cap { panic(errorString("growslice: cap out of range")) } // 如果當前切片的大小為0,還調用了擴容方法,那麼就新產生一個新的容量的切片返回。 return slice{unsafe.Pointer(&zerobase), old.len, cap} } // 這裡就是擴容的策略 newcap := old.cap doublecap := newcap + newcap if cap > doublecap { newcap = cap } else { if old.len < 1024 { newcap = doublecap } else { for newcap < cap { newcap += newcap / 4 } } } // 計算新的切片的容量,長度。 var lenmem, newlenmem, capmem uintptr const ptrSize = unsafe.Sizeof((*byte)(nil)) switch et.size { case 1: lenmem = uintptr(old.len) newlenmem = uintptr(cap) capmem = roundupsize(uintptr(newcap)) newcap = int(capmem) case ptrSize: lenmem = uintptr(old.len) * ptrSize newlenmem = uintptr(cap) * ptrSize capmem = roundupsize(uintptr(newcap) * ptrSize) newcap = int(capmem / ptrSize) default: lenmem = uintptr(old.len) * et.size newlenmem = uintptr(cap) * et.size capmem = roundupsize(uintptr(newcap) * et.size) newcap = int(capmem / et.size) } // 判斷非法的值,保證容量是在增加,並且容量不超過最大容量 if cap < old.cap || uintptr(newcap) > maxSliceCap(et.size) { panic(errorString("growslice: cap out of range")) } var p unsafe.Pointer if et.kind&kindNoPointers != 0 { // 在老的切片後面繼續擴充容量 p = mallocgc(capmem, nil, false) // 將 lenmem 這個多個 bytes 從 old.array地址 拷貝到 p 的地址處 memmove(p, old.array, lenmem) // 先將 P 地址加上新的容量得到新切片容量的地址,然後將新切片容量地址後面的 capmem-newlenmem 個 bytes 這塊記憶體初始化。為之後繼續 append() 操作騰出空間。 memclrNoHeapPointers(add(p, newlenmem), capmem-newlenmem) } else { // 重新申請新的數組給新切片 // 重新申請 capmen 這個大的記憶體位址,並且初始化為0值 p = mallocgc(capmem, et, true) if !writeBarrier.enabled { // 如果還不能開啟寫鎖,那麼只能把 lenmem 大小的 bytes 位元組從 old.array 拷貝到 p 的地址處 memmove(p, old.array, lenmem) } else { // 迴圈拷貝老的切片的值 for i := uintptr(0); i < lenmem; i += et.size { typedmemmove(et, add(p, i), add(old.array, i)) } } } // 返回最終新切片,容量更新為最新擴容之後的容量 return slice{p, old.len, newcap}}
上述就是擴容的實現。主要需要關注的有兩點,一個是擴容時候的策略,還有一個就是擴容是產生全新的記憶體位址還是在原來的地址後追加。
1. 擴容策略
先看看擴容策略。
func main() { slice := []int{10, 20, 30, 40} newSlice := append(slice, 50) fmt.Printf("Before slice = %v, Pointer = %p, len = %d, cap = %d\n", slice, &slice, len(slice), cap(slice)) fmt.Printf("Before newSlice = %v, Pointer = %p, len = %d, cap = %d\n", newSlice, &newSlice, len(newSlice), cap(newSlice)) newSlice[1] += 10 fmt.Printf("After slice = %v, Pointer = %p, len = %d, cap = %d\n", slice, &slice, len(slice), cap(slice)) fmt.Printf("After newSlice = %v, Pointer = %p, len = %d, cap = %d\n", newSlice, &newSlice, len(newSlice), cap(newSlice))}
輸出結果:
Before slice = [10 20 30 40], Pointer = 0xc4200b0140, len = 4, cap = 4 Before newSlice = [10 20 30 40 50], Pointer = 0xc4200b0180, len = 5, cap = 8 After slice = [10 20 30 40], Pointer = 0xc4200b0140, len = 4, cap = 4 After newSlice = [10 30 30 40 50], Pointer = 0xc4200b0180, len = 5, cap = 8
用圖表示出上述過程。
從圖上我們可以很容易的看出,新的切片和之前的切片已經不同了,因為新的切片更改了一個值,並沒有影響到原來的數組,新切片指向的數組是一個全新的數組。並且 cap 容量也發生了變化。這之間究竟發生了什麼呢?
Go 中切片擴容的策略是這樣的:
如果切片的容量小於 1024 個元素,於是擴容的時候就翻倍增加容量。上面那個例子也驗證了這一情況,總容量從原來的4個翻倍到現在的8個。
一旦元素個數超過 1024 個元素,那麼增長因子就變成 1.25 ,即每次增加原來容量的四分之一。
注意:擴容擴大的容量都是針對原來的容量而言的,而不是針對原來數組的長度而言的。
2. 新數組 or 老數組 ?
再談談擴容之後的數組一定是新的嗎?這個不一定,分兩種情況。
情況一:
func main() { array := [4]int{10, 20, 30, 40} slice := array[0:2] newSlice := append(slice, 50) fmt.Printf("Before slice = %v, Pointer = %p, len = %d, cap = %d\n", slice, &slice, len(slice), cap(slice)) fmt.Printf("Before newSlice = %v, Pointer = %p, len = %d, cap = %d\n", newSlice, &newSlice, len(newSlice), cap(newSlice)) newSlice[1] += 10 fmt.Printf("After slice = %v, Pointer = %p, len = %d, cap = %d\n", slice, &slice, len(slice), cap(slice)) fmt.Printf("After newSlice = %v, Pointer = %p, len = %d, cap = %d\n", newSlice, &newSlice, len(newSlice), cap(newSlice)) fmt.Printf("After array = %v\n", array)}
列印輸出:
Before slice = [10 20], Pointer = 0xc4200c0040, len = 2, cap = 4 Before newSlice = [10 20 50], Pointer = 0xc4200c0060, len = 3, cap = 4 After slice = [10 30], Pointer = 0xc4200c0040, len = 2, cap = 4 After newSlice = [10 30 50], Pointer = 0xc4200c0060, len = 3, cap = 4 After array = [10 30 50 40]
把上述過程用圖表示出來,如。
通過列印的結果,我們可以看到,在這種情況下,擴容以後並沒有建立一個新的數組,擴容前後的數組都是同一個,這也就導致了新的切片修改了一個值,也影響到了老的切片了。並且 append() 操作也改變了原來數組裡面的值。一個 append() 操作影響了這麼多地方,如果原數組上有多個切片,那麼這些切片都會被影響!無意間就產生了莫名的 bug!
這種情況,由於原數組還有容量可以擴容,所以執行 append() 操作以後,會在原數組上直接操作,所以這種情況下,擴容以後的數組還是指向原來的數組。
這種情況也極容易出現在字面量建立切片時候,第三個參數 cap 傳值的時候,如果用字面量建立切片,cap 並不等於指向數組的總容量,那麼這種情況就會發生。
slice := array[1:2:3]
上面這種情況非常危險,極度容易產生 bug 。
建議用字面量建立切片的時候,cap 的值一定要保持清醒,避免共用原數組導致的 bug。
情況二:
情況二其實就是在擴容策略裡面舉的例子,在那個例子中之所以產生了新的切片,是因為原來數組的容量已經達到了最大值,再想擴容, Go 預設會先開一片記憶體地區,把原來的值拷貝過來,然後再執行 append() 操作。這種情況絲毫不影響原數組。
所以建議盡量避免情況一,盡量使用方式二,避免 bug 產生。
五. 切片拷貝
Slice 中拷貝方法有2個。
func slicecopy(to, fm slice, width uintptr) int { // 如果源切片或者目標切片有一個長度為0,那麼就不需要拷貝,直接 return if fm.len == 0 || to.len == 0 { return 0 } // n 記錄下源切片或者目標切片較短的那一個的長度 n := fm.len if to.len < n { n = to.len } // 如果入參 width = 0,也不需要拷貝了,返回較短的切片的長度 if width == 0 { return n } // 如果開啟了競爭檢測 if raceenabled { callerpc := getcallerpc(unsafe.Pointer(&to)) pc := funcPC(slicecopy) racewriterangepc(to.array, uintptr(n*int(width)), callerpc, pc) racereadrangepc(fm.array, uintptr(n*int(width)), callerpc, pc) } // 如果開啟了 The memory sanitizer (msan) if msanenabled { msanwrite(to.array, uintptr(n*int(width))) msanread(fm.array, uintptr(n*int(width))) } size := uintptr(n) * width if size == 1 { // TODO: is this still worth it with new memmove impl? // 如果只有一個元素,那麼指標直接轉換即可 *(*byte)(to.array) = *(*byte)(fm.array) // known to be a byte pointer } else { // 如果不止一個元素,那麼就把 size 個 bytes 從 fm.array 地址開始,拷貝到 to.array 地址之後 memmove(to.array, fm.array, size) } return n}
在這個方法中,slicecopy 方法會把源切片值(即 fm Slice )中的元素複製到目標切片(即 to Slice )中,並返回被複製的元素個數,copy 的兩個類型必須一致。slicecopy 方法最終的複製結果取決於較短的那個切片,當較短的切片複製完成,整個複製過程就全部完成了。
舉個例子,比如:
func main() { array := []int{10, 20, 30, 40} slice := make([]int, 6) n := copy(slice, array) fmt.Println(n,slice)}
還有一個拷貝的方法,這個方法原理和 slicecopy 方法類似,不在贅述了,注釋寫在代碼裡面了。
func slicestringcopy(to []byte, fm string) int { // 如果源切片或者目標切片有一個長度為0,那麼就不需要拷貝,直接 return if len(fm) == 0 || len(to) == 0 { return 0 } // n 記錄下源切片或者目標切片較短的那一個的長度 n := len(fm) if len(to) < n { n = len(to) } // 如果開啟了競爭檢測 if raceenabled { callerpc := getcallerpc(unsafe.Pointer(&to)) pc := funcPC(slicestringcopy) racewriterangepc(unsafe.Pointer(&to[0]), uintptr(n), callerpc, pc) } // 如果開啟了 The memory sanitizer (msan) if msanenabled { msanwrite(unsafe.Pointer(&to[0]), uintptr(n)) } // 拷貝字串至位元組數組 memmove(unsafe.Pointer(&to[0]), stringStructOf(&fm).str, uintptr(n)) return n}
再舉個例子,比如:
func main() { slice := make([]byte, 3) n := copy(slice, "abcdef") fmt.Println(n,slice)}
輸出:
3 [97,98,99]
說到拷貝,切片中有一個需要注意的問題。
func main() { slice := []int{10, 20, 30, 40} for index, value := range slice { fmt.Printf("value = %d , value-addr = %x , slice-addr = %x\n", value, &value, &slice[index]) }}
輸出:
value = 10 , value-addr = c4200aedf8 , slice-addr = c4200b0320 value = 20 , value-addr = c4200aedf8 , slice-addr = c4200b0328 value = 30 , value-addr = c4200aedf8 , slice-addr = c4200b0330 value = 40 , value-addr = c4200aedf8 , slice-addr = c4200b0338
從上面結果我們可以看到,如果用 range 的方式去遍曆一個切片,拿到的 Value 其實是切片裡面的值拷貝。所以每次列印 Value 的地址都不變。
由於 Value 是值拷貝的,並非引用傳遞,所以直接改 Value 是達不到更改原切片值的目的的,需要通過 &slice[index]
擷取真實的地址。
Reference:
《Go in action》
《Go 語言學習筆記》
GitHub Repo:Halfrost-Field
Follow: halfrost · GitHub
Source: https://halfrost.com/go_slice/
掃描二維碼,分享此文章