Golang Slice 詳解
什麼是切片
Go 語言中的切片 (slice) 的本質是對數組的封裝, 其描述了一個數組的片段。切片實際上是一個結構體,其包含了三個字段,如下圖所示
type slice struct {
array unsafe.Pointer
len int
cap int
}
1.array: 是一個非安全類型的指針,指向底層數組,是一個連續的內存塊。
2.len: 指 slice 的實際長度,既 slice 中含有的元素個數。
3.cap: 指 slice 的底層數組長度,即 slice 的容量,slice 可以容納多少元素。
tips:底層數組可以被多個 slice 同時指向,因此對一個 slice 的元素進行操作,有可能會影響到其他切片。
如何定義切片
-
使用 “截取” 的方法:
截取是常見的一種創建 slice 的方法,可以從數組和 slice 直接截取,需要指定起始位置。
值得注意的是,截取之後的 slice,和被截取的 slice 共享同一個底層數組,新老 slice 對底層數組的更改都會影響到彼此。所以,問題的關鍵在於是否共享同一個底層數組。 -
使用 make 方法定義切片:
//創建一個長度爲3,容量爲4的int類型切片
slice := make([]int, 3, 4)
如果後面的長度和容量只填一個,那麼會默認指定該切片的長度和容量是相等的。
切片的容量
切片帶有自動擴容機制,一般是在使用了 append 函數向切片追加了元素之後,切片的容量不足,引起了擴容。那麼擴容機制的規律是什麼呢,讓我們繼續往下看。
通過查詢官網資料,得到了一個結論,當切片進行自動擴容時,會調用 growSlice 函數,讓我們看下這個函數的源碼。
func growslice(et *_type, old slice, cap int) slice {
// ……
newcap := old.cap
doublecap := newcap + newcap
if cap > doublecap {
newcap = cap
} else {
const threshold = 256
if old.cap < threshold {
newcap = doublecap
} else {
for 0 < newcap && newcap < cap {
// 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) / 4
}
if newcap <= 0 {
newcap = cap
}
}
// ……
capmem = roundupsize(uintptr(newcap) * ptrSize)
newcap = int(capmem / ptrSize)
該函數的主要邏輯是根據當前切片的容量和需要擴展到的容量計算出新的容量 newcap。在計算新的容量時,函數採用了一種漸進式的擴容策略,即當切片的容量小於一個閾值 threshold(256) 時,每次擴容將容量翻倍;當切片的容量大於等於 threshold 時,每次擴容將容量增加原來容量的 1.25 倍。
在計算出新的容量後,函數根據新的容量計算出需要分配的內存大小 capmem,並通過 roundupsize 函數進行內存對齊。最後,函數將 capmem 轉換爲新的容量 newcap,並返回新的切片。
切片作爲函數參數會被改變嗎
前面我們說到,slice 其實是一個結構體,當 slice 作爲函數參數時,就是一個普通的結構體。若直接傳 slice,在調用者看來,實參 slice 並不會被函數中的操作改變;若傳的是 slice 的指針,在調用者看來,是會被改變原 slice 的。
需要注意的是,不管傳的是 slice 還是 slice 指針,如果改變了 slice 底層數組的數據,會反應到實參 slice 的底層數據。
那麼問題來了,爲什麼能改變底層數組的數據?很好理解:底層數據在 slice 結構體裏是一個指針,儘管 slice 結構體自身不會被改變,也就是說底層數據地址不會被改變。但是通過指向底層數據的指針,可以改變切片的底層數據。通過 slice 的 array 字段就可以拿到數組的地址。另外在 Go 語言裏,函數參數傳遞,只有值傳遞,沒有引用傳遞。
測試例子
接下來讓我們看幾個例題。
1. 請問下面代碼輸出的是什麼?
func main() {
slice := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
s1 := slice[2:5]
s2 := s1[2:6:7]
s2 = append(s2, 100)
s2 = append(s2, 200)
s1[2] = 20
fmt.Println(s1)
fmt.Println(s2)
fmt.Println(slice)
}
輸出結果爲:
s2 是通過從 s1 的索引 2 到索引 6(不包括 6),並且其容量爲 7(不包含 7)進行切片創建的。在 slice 表達式中的容量參數指定了在切片後可以訪問的底層數組的最大長度。在這種情況下,s2 的容量爲 7,這意味着 s2 的底層數組長度爲 7,即 s2 只包含元素 [4,5,6,7]。
接着向 s2 追加一個元素 100,因爲 s2 容量剛好夠,直接追加。不過,這會修改原始數組對應位置的元素。這一改動,數組和 s1 都同步進行了變化。
接着再次向 S2 追加元素 200,此時,s2 的容量不夠用,進行自動擴容了。於是,s2 就另起爐竈了,將原來的元素複製到了新的位置,擴大自己的容量。
最後,修改 s1 索引爲 2 位置的元素,s1[2] = 20。但是隻會影響原始數組相應位置的,元素了,不會影響 s2,因爲人家已經跑路了。最後結果如上圖所示。
讓我們來看最後一個例子,請問這段代碼最後輸出的是什麼?
package main
import "fmt"
func myAppend(s []int) []int {
// 這裏 s 雖然改變了,但並不會影響外層函數的 s
s = append(s, 100)
return s
}
func myAppendPtr(s *[]int) {
// 會改變外層 s 本身
*s = append(*s, 100)
return
}
func main() {
s := []int{1, 1, 1}
newS := myAppend(s)
fmt.Println(s)
fmt.Println(newS)
s = newS
myAppendPtr(&s)
fmt.Println(s)
}
運行結果:
myAppendPtr 函數接收一個 *[]int 類型的 slice 指針,向其中添加一個元素 100。這個函數內部使用了 append 函數來修改指針指向的 slice,並且這個修改會影響到外層函數中指針指向的 slice。
main 函數首先定義了一個 []int 類型的 slice s,並向其中添加了三個元素 {1, 1, 1}。然後調用 myAppend 函數來向 s 中添加一個元素 100,並將返回的新的 slice 賦值給 newS。此時,s 和 newS 分別指向兩個不同的 slice,因此輸出時會發現 s 中仍然只包含三個元素 {1, 1, 1},而 newS 中包含四個元素 {1, 1, 1, 100}。
接着,s 被賦值爲 newS,即 s 和 newS 指向同一個 slice。然後調用 myAppendPtr 函數來向 s 中添加一個元素 100。由於 myAppendPtr 函數是按照指針傳遞的,所以函數內部對 s 的修改會影響到外層函數中的 s,因此輸出時會發現 s 中包含了五個元素 {1, 1, 1, 100,100}。
加餐 - 手動代碼實現 slice 的增加和刪除操作
//增加
func Add(s []int, index int, value int) []int {
//如果插入的長度超過了切片的長度,則直接在末尾插入元素
if index > len(s) {
return append(s, value)
}
//如果插入的位置在切片中間,則需要講該位置後面的元素全部向後移
s = append(s[:index+1], s[index:len(s)-1]...)
s[index] = value
return s
}
//刪除
func Delete(s []int, index int, value int) []int {
//如果刪除的位置超出了切片的長度,則直接返回原切片。
if index >= len(s) {
return s
}
// 如果刪除的位置在切片中間,則需要將該位置後面的元素全部向前移動一位
copy(s[index:], s[index+1:])
return s[:len(s)-1]
}
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/U8WEyl3YKnvKGbYMSYfZDg