Go語言(Golang)中切片(slice)的相關知識、包括切片與數組的關系、底層結構、擴容機制、以及切片在函數傳遞、截取、增刪元素、拷貝等操作中的特性。并給出了相關代碼示例和一道面試題。關鍵要點包括:
-
數組特性:Go語言中數組是一個值、數組變量表示整個數組、不同于C語言中指向第一個元素的指針。傳遞數組到函數或拷貝數組時、會有不同的內存地址和數據獨立性表現。
-
切片定義:切片是建立在Go數組之上的抽象類型、其底層結構包含指向底層數組的指針、長度和容量。
-
切片擴容:
-
新切片長度大于舊切片容量兩倍時、新容量為新長度
-
舊容量小于256時、新容量為舊容量兩倍
-
否則按1.25倍增速擴容、還會進行內存對齊。
-
函數傳遞:切片通過函數傳遞時、傳的是切片結構、在函數內改變切片可能影響函數外的切片、取決于底層數組是否變化。
-
切片操作:
-
通過?
“:”?
作截取切片、新切片與原切片共享底層數組 -
刪除元素可通過拼接切片實現
-
新增元素使用append操作
-
深度拷貝可使用copy函數。
1.切片是什么
在Go語言中 切片(slice)是建立在數組之上的一種抽象類型。切片提供了一種更靈活的方式來處理數組、它允許動態地改變數組的大小、并且可以方便地進行切片操作。理解切片之前、我們需要先了解數組。
Go的數組 在Go語言中、數組的長度是類型的一部分、這意味著數組的長度是固定的、不能改變。
數組的傳遞和拷貝行為與C語言不同、Go語言中的數組是值類型、傳遞數組時會進行值拷貝。
1.1 示例一:
將數組傳遞到函數中 數組的地址不一樣
package mainimport "fmt"func main() {array := [3]int{1, 2, 3}// 數組傳遞到函數中test(array)fmt.Printf("array 外: %p\n", &array)
}func test(array [3]int) {fmt.Printf("array 內: %p\n", &array)}
-
由于數組是值類型、傳遞數組時會進行值拷貝、因此在
test
函數中打印的地址與main
函數中打印的地址不同。
1.2 值拷貝
值拷貝意味著拷貝的是變量的內容、而不是內存地址。因此拷貝出來的變量有自己的獨立副本、內容相同、但它們存儲在不同的內存地址中。
Go 語言中的切片(slice)是動態擴容的。當你向切片中添加元素時、Go 會自動管理切片的大小、并在需要時進行擴容。
具體行為:
-
初始容量:當你創建一個切片時、Go 會為切片分配一個初始容量。如果你添加的元素超過了切片當前的容量Go 會自動擴容。
-
擴容規則:Go 會根據當前切片的容量自動擴展切片的大小、通常是原來容量的2倍。擴容后、切片的長度和容量都會增加。
-
內部機制:當切片擴容時、Go 會為新切片分配新的底層數組、并將原數組的元素拷貝到新數組中。這是一個代價比較高的操作、尤其是在需要多次擴容的情況下
2.底層結構
type slice struct {// 底層數組指針(或者說是指向一塊連續內存空間的起點)array unsafe.Pointer// 長度len int// 容量cap int
}
在這個結構中:
-
array
:指向底層數組的指針、或者說是指向一塊連續內存空間的起點。 -
len
:切片的長度、即切片中實際包含的元素數量。 -
cap
:切片的容量、即切片可以包含的元素的最大數量,不包括可能的擴展空間。
切片擴容
-
計算目標容量
-
case1:
如果新切片的長度大于舊切片容量的兩倍、則新切片容量就為新切片的長度。 -
case2:
-
如果舊切片的容量小于256、那么新切片的容量就是舊切片的容量的兩倍。
-
反之需要用舊切片容量按照1.25倍的增速、直到大于新切片長度。
-
-
為了更平滑的過渡、每次擴大1.25倍、還會加上
3/4 * 256
-
進行內存對齊、需要按照Go內存管理的級別去對齊內存、最終容量以這個為準。
-
3.切片問題
3.1 切片通過函數傳的是什么
package mainimport ("fmt""reflect""unsafe"
)func main() {s := make([]int, 5, 10)PrintSliceStruct(&s)test(s)
}func test(s []int) {PrintSliceStruct(&s)
}func PrintSliceStruct(s *[]int) {// 代碼 將slice 轉換成 reflect.SliceHeaderss := (*reflect.SliceHeader)(unsafe.Pointer(s))// 查看slice的結構fmt.Printf("slice struct: %+v, slice is %v\n", ss, s)
}
控制臺輸出:
slice struct: &{Data:1374389649568 Len:5 Cap:10}, slice is &[0 0 0 0 0]
slice struct: &{Data:1374389649568 Len:5 Cap:10}, slice is &[0 0 0 0 0]
-
切片的定義:你創建了一個切片 s、通過 make([]int, 5, 10) 創建了一個長度為 5、容量為 10 的切片。
-
也就是說它初始化了一個包含 5 個元素且最大容量為 10 的底層數組
總結:
-
切片傳遞:當切片通過參數傳遞到函數時、傳遞的是切片的值、但切片內部的底層數組地址(指針)并沒有被復制。
-
PrintSliceStruct
打印的結構:無論是在 main 函數還是 test 函數中、切片的底層數組地址、長度和容量都是相同的、因為底層數組是共享的。
為什么輸出相同:
輸出顯示的 Data 地址、Len 和 Cap 是一致的、因為 test(s) 傳遞的是切片的值(即切片的結構),但切片中的指針指向相同的底層數組。所以無論是傳遞給 PrintSliceStruct
函數的 s、還是 test 函數中的 s、它們指向的是同一個底層數組、并且它們的長度和容量保持一致
3.2 在函數里面改變切片 函數外的切片會被影響嗎
package mainimport ("fmt""reflect""unsafe"
)func main() {s := make([]int, 5) // 創建一個長度為 5 的切片case1(s) // 調用 case1 函數case2(s) // 調用 case2 函數PrintSliceStruct(&s) // 打印切片結構
}// 底層數組不變
func case1(s []int) {s[1] = 1 // 修改切片中的元素PrintSliceStruct(&s) // 打印切片結構
}// 底層數組變化
func case2(s []int) {s = append(s, 0) // 擴容切片s[1] = 1 // 修改切片中的元素PrintSliceStruct(&s) // 打印切片結構
}func PrintSliceStruct(s *[]int) {// 將切片轉換成 reflect.SliceHeaderss := (*reflect.SliceHeader)(unsafe.Pointer(s))// 打印切片的底層結構fmt.Printf("slice struct: %+v, slice is %v\n", ss, *s)
}
關鍵點:
-
case1
函數: -
在
case1
中、你傳入一個長度為 5 的切片 s、并修改切片中的元素。 -
切片在函數內的操作是對原切片的修改、因此底層數組沒有發生變化、切片的容量、長度仍然相同。
-
打印的
slice struct
的 Data、Len
和Cap
字段顯示的是切片的原始底層數據結構。 -
case2
函數: -
在
case2
中、你向切片添加一個元素(通過 append 操作)、這將可能導致切片的底層數組擴容。 -
因為
append
操作在超出當前容量時會觸發擴容、所以 s 的底層數組會發生變化、容量也可能增加。 -
在
case2
中、s 被賦值為 append(s, 0)、這將導致原有切片 s 的底層數組被擴展、并且一個新的數組被分配給 s(s 指向的是新的底層數組) -
打印時會看到
slice struct
中的 Data 指向一個新的地址、表示底層數組已經發生了變化。 -
append(s, 0)
函數會檢查切片s
是否有足夠的容量來存儲新的元素。如果切片的容量不足、append
函數會分配一個新的更大的數組、并復制舊數組的內容到新數組中、然后將新元素添加到新數組的末尾、并更新切片的指針以指向包含新元素的新底層數組。
3.3 截取切片
package mainimport ("fmt""reflect""unsafe"
)func main() {s := make([]int, 5) // 創建一個長度為 5 的切片,默認初始化為 [0 0 0 0 0]case1(s) // 調用 case1,修改切片內容case2(s) // 調用 case2,修改切片并改變底層數組case3(s) // 調用 case3,截取切片并改變其長度case4(s) // 調用 case4,截取切片的部分元素PrintSliceStruct(&s) // 最后打印切片的底層結構
}// case1:修改切片元素、底層數組不變
func case1(s []int) {s[1] = 1 // 修改切片中的第二個元素,s[1] = 1PrintSliceStruct(&s) // 打印修改后的切片底層結構
}// case2:重新賦值為新的切片
func case2(s []int) {s = s[:] // 這里實際上并沒有改變切片的內容、它只是重新賦值為原切片的一個新引用。PrintSliceStruct(&s) // 打印新的切片底層結構
}// case3:截取切片、底層數組不變
func case3(s []int) {s = s[:len(s)-1] // 截取切片、去掉最后一個元素、新的切片長度為 4PrintSliceStruct(&s) // 打印截取后的切片底層結構
}// case4:截取切片的部分元素、底層數組不變
func case4(s []int) {sl := s[1:2] // 截取 s[1:2],即取出切片中索引為 1 的元素PrintSliceStruct(&sl) // 打印截取后的新切片底層結構
}// PrintSliceStruct 打印切片的底層結構
func PrintSliceStruct(s *[]int) {// 將切片的指針轉換為 reflect.SliceHeader 結構體,通過 unsafe.Pointer 獲取底層數據ss := (*reflect.SliceHeader)(unsafe.Pointer(s))// 打印切片的底層數據結構、包括:指向底層數組的內存地址、切片的長度和容量fmt.Printf("slice struct: %+v, slice is %v\n", ss, *s)
}
總結:
-
切片操作的影響:
-
修改切片元素不會改變底層數組的地址。
-
重新賦值切片并沒有改變底層數組、除非涉及擴容(例如 append)。
-
截取切片時、底層數組不變、切片的長度和容量可能會變化。
3.4 刪除元素
package mainimport ("fmt""reflect""unsafe"
)func main() {// 創建一個包含5個整數的切片s := []int{0, 1, 2, 3, 4}// 打印切片的底層結構PrintSliceStruct(&s)// 刪除切片中的最后一個元素,正確的做法是通過切片截取_ = s[4] // 訪問并丟棄切片中的最后一個元素s1 := append(s[:1], s[2:]...) // 刪除元素 s[1],//s[:1](即切片 [0])和 s[2:](即切片 [2, 3, 4])拼接在一起。// 打印修改后的切片fmt.Println(s) // [0 2 3 4 4]fmt.Println(s1) // [0, 2, 3, 4]// 打印切片底層結構PrintSliceStruct(&s)PrintSliceStruct(&s1)// 訪問切片的元素s = s[:4] // 截取切片、刪除最后一個元素_ = s[3] // 訪問切片中的最后一個元素(索引為3的元素)
}// 打印切片的底層結構
func PrintSliceStruct(s *[]int) {// 將切片轉換為 reflect.SliceHeaderss := (*reflect.SliceHeader)(unsafe.Pointer(s))// 打印切片的底層結構fmt.Printf("slice struct: %+v, slice is %v\n", ss, *s)
}
-
在 Go 中、切片操作需要特別注意切片的索引和截取。訪問切片中的元素時要小心類型不匹配(例如不能將一個切片元素賦值給切片)。
控制臺輸出:
slice struct: &{Data:1374390755328 Len:5 Cap:5}, slice is [0 1 2 3 4]
[0 2 3 4 4]
[0 2 3 4]
slice struct: &{Data:1374390755328 Len:5 Cap:5}, slice is [0 2 3 4 4]
slice struct: &{Data:1374390755328 Len:4 Cap:5}, slice is [0 2 3 4]
打印原切片 s 時、它仍然指向原底層數組(長度為 5、容量為 5)、而且由于 s[4] 在內存中并沒有被移除、原底層數組中的最后一個元素 4 被保留、因此 s 顯示為 [0 2 3 4 4]。
簡而言之s 顯示為 [0 2 3 4, 4] 是因為原始切片的底層數組并沒有被修改、而 append
操作生成了一個新的切片(s1)并分配了新的底層數組。所以s 中仍然包含原數組中的所有元素、最后一個 4 仍然存在。
為什么 s變成了 [0, 2, 3, 4, 4]
-
append 會根據切片的容量決定是否會使用原來的底層數組。如果原切片的容量足夠大、append 就會直接修改原切片。
-
在這段代碼中、由于原始切片 s 的容量足夠大(原始切片 s 的容量為 5)、append 仍然修改了原始切片 s 的內容。切片的 s 和 s1 都指向相同的底層數組。
重點:
-
原切片 s 的容量沒有改變:s 底層的數組仍然包含原來 s 的所有元素。
-
append 沒有重新分配新的底層數組:由于原切片的容量足夠、所以 append 在修改原底層數組時、并沒有創建新的底層數組。因此原始切片 s 中的 4 仍然存在。
-
修改后 s 中的元素為 [0, 2, 3, 4, 4]:雖然你刪除了 s[1] 這個元素、但 append 使得 s 的底層數組沒有發生變化,因此原始的 4 元素仍然保留在切片中。
結論:
append
操作有時會創建新的底層數組(如果容量不足)、但如果原切片的容量足夠、append 直接修改原切片的底層數組。在這種情況下原切片 s 會保持原來的容量和數據、導致 s 顯示為 [0, 2, 3, 4, 4],即最后一個 4 保留下來了。
3.5 新增元素
package mainimport ("fmt""reflect""unsafe"
)func main() {case1()case2()case3()
}// case1 函數展示了使用 append 在切片末尾添加元素的行為
func case1() {// 創建一個長度為 3,容量為 3 的切片s1 := make([]int, 3, 3)// 向切片添加一個元素 1,append 返回一個新的切片s1 = append(s1, 1)// 打印切片的底層結構PrintSliceStruct(&s1) //1
}// case2 函數展示了在原切片上使用 append 并打印切片結構的變化
func case2() {// 創建一個長度為 3,容量為 4 的切片s1 := make([]int, 3, 4)// 向切片添加一個元素 1,append 會擴展切片的長度s2 := append(s1, 1)// 打印原切片 s1 和新切片 s2 的底層結構PrintSliceStruct(&s1)//2PrintSliceStruct(&s2)//3
}// case3 函數與 case2 類似,展示了切片長度、容量變化的行為
func case3() {// 創建一個長度為 3,容量為 3 的切片s1 := make([]int, 3, 3)// 向切片添加一個元素 1,append 返回一個新的切片s2 := append(s1, 1)// 打印原切片 s1 和新切片 s2 的底層結構PrintSliceStruct(&s1)//4PrintSliceStruct(&s2)//5
}// PrintSliceStruct 打印切片的底層結構
func PrintSliceStruct(s *[]int) {// 使用 reflect 和 unsafe 包將切片轉換成 reflect.SliceHeader 結構體ss := (*reflect.SliceHeader)(unsafe.Pointer(s))// 打印切片的底層結構fmt.Printf("slice struct: %+v, slice is %v\n", ss, *s)
}
控制臺輸出
slice struct: &{Data:1374390755328 Len:4 Cap:6}, slice is [0 0 0 1]
slice struct: &{Data:1374390779936 Len:3 Cap:4}, slice is [0 0 0]
slice struct: &{Data:1374390779936 Len:4 Cap:4}, slice is [0 0 0 1]
slice struct: &{Data:1374390673552 Len:3 Cap:3}, slice is [0 0 0]
slice struct: &{Data:1374390755376 Len:4 Cap:6}, slice is [0 0 0 1]
case1:
-
使用 make([]int, 3, 3) 創建了一個長度為 3,容量為 3 的切片 s1,初始內容為 [0, 0, 0]。
-
然后
append(s1, 1)
會將元素 1 添加到切片的末尾、生成一個新的切片并返回。由于容量是 3、append
會自動擴容新的切片長度是 4 -
最后調用
PrintSliceStruct
打印 s1 切片的底層結構。
case2:
-
make([]int, 3, 4)
創建了一個長度為 3、容量為 4 的切片 s1 -
使用
append(s1, 1)
向切片添加元素 1、生成一個新切片 s2。由于 s1 的容量已足夠、不會觸發擴容。 -
通過
PrintSliceStruct
打印切片 s1 和 s2 的底層結構。
s3 和 s4 參考上面
3.6 操作原來切片會影響新的切片嗎
在 Go 中、切片是引用類型,這意味著當你創建一個新切片時,它實際上可能會指向同一個底層數組。因此,如果你修改了原切片(比如通過 append 或其他操作),它可能會影響到新切片,特別是在底層數組沒有被重新分配的情況下。
切片和底層數組
-
切片(slice)是一個非常輕量級的抽象,它包含了三個部分:指向底層數組的指針、切片的長度和切片的容量。
-
當你對切片進行操作時(例如使用 append、copy 或直接修改),這些操作通常會影響到底層數組。
-
如果多個切片引用同一個底層數組,改變其中一個切片的內容可能會影響到其他切片,尤其是在沒有擴容時。
append操作
-
當使用 append 函數時、如果切片的容量足夠、append 會直接在原底層數組上操作、不會創建新的底層數組。在這種情況下、修改原切片的內容會影響到新切片、因為它們指向相同的底層數組。
-
如果容量不足、append 會創建一個新的底層數組、并將原切片的數據復制到新數組中、這時原切片和新切片就指向不同的底層數組了、它們互不影響。
例子:
沒有擴容:
s1 := []int{1, 2, 3}
s2 := s1 // s2 指向與 s1 相同的底層數組
s1[0] = 100 // 修改 s1 中的第一個元素
fmt.Println(s1) // 輸出 [100, 2, 3]
fmt.Println(s2) // 輸出 [100, 2, 3]
這里s1 和 s2 指向相同的底層數組,因此修改 s1 會影響到 s2。
擴容時:
s1 := []int{1, 2, 3}
s2 := append(s1, 4) // s2 創建了新的底層數組
s1[0] = 100 // 修改 s1 中的第一個元素
fmt.Println(s1) // 輸出 [100, 2, 3]
fmt.Println(s2) // 輸出 [1、2、3、 4]
這里s2 創建了一個新的底層數組,因此修改 s1 不會影響 s2。
結論:
-
修改原切片會影響新切片:如果新切片是通過引用原切片的底層數組創建的(沒有觸發擴容)、修改原切片的內容會影響到新切片。
-
擴容時不影響:如果 append 或其他操作導致了擴容、原切片和新切片就會指向不同的底層數組、互不影響。
4.字節面試題
下面這道題的輸出是什么
package mainimport "fmt"func main() {// 定義一個匿名函數 doAppend,用來執行 append 操作并打印切片的長度和容量doAppend := func(s []int) {s = append(s, 1) // 向切片中添加元素 1printLengthAndCapacity(s) // 打印切片的長度和容量}// 創建一個長度為 8,容量為 8 的切片 ss := make([]int, 8, 8)// 傳遞 s 的前 4 個元素(即 s[:4])到 doAppenddoAppend(s[:4]) // 只傳遞前4個元素的切片// 打印原始切片 s 的長度和容量printLengthAndCapacity(s)// 傳遞整個切片 s 到 doAppenddoAppend(s)// 打印原始切片 s 的長度和容量printLengthAndCapacity(s)
}func printLengthAndCapacity(s []int) {fmt.Println() fmt.Printf("len=%d cap=%d \n", len(s), cap(s)) // 打印切片的長度和容量
}
-
len(s) 是切片的長度。
-
cap(s) 是切片的容量、表示切片底層數組的大小。
len=5 cap=8 len=8 cap=8 len=9 cap=16 len=8 cap=8
調用 doAppend(s[:4]):
-
s[:4] 是 s 切片的前 4 個元素、創建一個新的切片 [0, 0, 0, 0],長度為 4、容量為 8(因為它引用的是原切片的底層數組)。
-
在 doAppend 中、執行 append(s, 1)、這會向切片添加一個元素 1、導致切片的長度變為 5、容量保持為 8(因為它沒有觸發擴容)。
-
打印結果為:len=5 cap=8。
調用 doAppend(s):
-
這次傳遞整個 s 切片、長度為 8、容量為 8。
-
執行
append(s 1)
、這會向切片 s 添加一個元素 1。因為 s 的容量是 8、不能再容納更多元素、因此會觸發擴容、新的底層數組的容量將是原來的 2 倍、即 16、長度變為 9。 -
打印結果為:len=9 cap=16。
-
注意:
-
append 創建了一個新的底層數組、并返回了一個新的切片。如果你不把返回的新切片賦值回 s、原始切片 s 不會改變、仍然指向舊的底層數組。
-
由于 append(s 1) 返回的是一個新的切片、但并沒有將它賦值回 s、所以原始切片 s 的長度和容量沒有變化、仍然是 len=8 和 cap=8
如果改成將新切片賦值回s
package mainimport "fmt"func main() {// 定義一個匿名函數 doAppend 用于向切片添加元素doAppend := func(s []int) {s = append(s, 1) // 向切片中添加元素 1printLengthAndCapacity(s) // 打印切片的長度和容量}// 定義一個匿名函數 doAppend 用于向切片添加元素doAppends := func(s []int) []int {s = append(s, 1) // 使用 append 向切片添加一個元素 1printLengthAndCapacity(s)return s}// 創建一個長度為 8,容量為 8 的切片 ss := make([]int, 8, 8)// 傳遞前 4 個元素的切片doAppend(s[:4]) // 只傳遞前4個元素的切片printLengthAndCapacity(s)// 傳遞整個切片 ss = doAppends(s) // 將返回的新切片賦值回 sprintLengthAndCapacity(s)
}func printLengthAndCapacity(s []int) {fmt.Println()fmt.Printf("len=%d cap=%d \n", len(s), cap(s))
}
len=5 cap=8 len=8 cap=8 len=9 cap=16 len=9 cap=16