- Go語(yǔ)言高級(jí)編程(第2版)
- 柴樹(shù)杉 曹春暉
- 3940字
- 2025-08-07 17:56:12
1.3.3 切片
簡(jiǎn)單地說(shuō),切片(slice)就是一種簡(jiǎn)化版的動(dòng)態(tài)數(shù)組。因?yàn)閯?dòng)態(tài)數(shù)組的長(zhǎng)度不固定,所以切片的長(zhǎng)度自然也就不能是類型的組成部分了。數(shù)組雖然有適用的地方,但是數(shù)組的類型和操作都不夠靈活,因此在Go語(yǔ)言中數(shù)組使用得并不多。而切片則使用得相當(dāng)廣泛,理解切片的原理和用法是Go程序員的必備技?能。
我們先看一下切片的結(jié)構(gòu)定義,即reflect
包里定義的SliceHeader
:
type SliceHeader struct { Data uintptr Len int Cap int }
由此可以看出切片的開(kāi)頭部分和Go字符串是一樣的,但是切片多了一個(gè)Cap
成員表示切片指向的內(nèi)存空間的最大容量(元素的個(gè)數(shù),而不是字節(jié)數(shù))。圖1-9給出了x := []int{2,3,5, 7,11}
和y := x[1:3]
兩個(gè)切片的內(nèi)存結(jié)?構(gòu)。

圖1-9 切片的內(nèi)存結(jié)構(gòu)
讓我們看看切片有哪些定義方式:
var ( a []int // nil切片,和nil相等,一般用來(lái)表示一個(gè)不存在的切片 b = []int{} // 空切片,和nil不相等,一般用來(lái)表示一個(gè)空的集合 c = []int{1, 2, 3} // 有3個(gè)元素的切片,len和cap都為3 d = c[:2] // 有2個(gè)元素的切片,len為2,cap為3 e = c[0:2:cap(c)] // 有2個(gè)元素的切片,len為2,cap為3 f = c[:0] // 有0個(gè)元素的切片,len為0,cap為3 g = make([]int, 3) // 有3個(gè)元素的切片,len和cap都為3 h = make([]int, 2, 3) // 有2個(gè)元素的切片,len為2,cap為3 i = make([]int, 0, 3) // 有0個(gè)元素的切片,len為0,cap為3 )
和數(shù)組一樣,內(nèi)置的len()
函數(shù)返回切片中有效元素的長(zhǎng)度,內(nèi)置的cap()
函數(shù)返回切片容量大小,容量必須大于或等于切片的長(zhǎng)度。也可以通過(guò)reflect.
SliceHeader
結(jié)構(gòu)訪問(wèn)切片的信息(只是為了說(shuō)明切片的結(jié)構(gòu),并不是推薦的做法)。切片可以和nil
進(jìn)行比較,只有當(dāng)切片底層數(shù)據(jù)指針為空時(shí)切片本身才為nil
,這時(shí)候切片的長(zhǎng)度和容量信息將是無(wú)效的。如果有切片的底層數(shù)據(jù)指針為空,但是長(zhǎng)度和容量不為0的情況,說(shuō)明切片本身已經(jīng)損壞了(如直接通過(guò)reflect.
SliceHeader
或unsafe
包對(duì)切片作了不正確的修改)。
遍歷切片的方式和遍歷數(shù)組的方式類似:
for i := range a { fmt.Printf("a[%d]: %d\n", i, a[i]) } for i, v := range b { fmt.Printf("b[%d]: %d\n", i, v) } for i := 0; i < len(c); i++ { fmt.Printf("c[%d]: %d\n", i, c[i]) }
其實(shí),只要切片的底層數(shù)據(jù)指針、長(zhǎng)度和容量沒(méi)有發(fā)生變化,對(duì)切片的遍歷、元素的讀取和修改就和數(shù)組一樣。在對(duì)切片本身進(jìn)行賦值或參數(shù)傳遞時(shí),和數(shù)組指針的操作方式類似,但是只復(fù)制切片頭信息(reflect.
SliceHeader
),而不會(huì)復(fù)制底層的數(shù)據(jù)。在類型上,與數(shù)組最大的不同是,切片的類型和長(zhǎng)度信息無(wú)關(guān),只要是相同類型元素構(gòu)成的切片均對(duì)應(yīng)相同的切片類?型。
如前所述,切片是一種簡(jiǎn)化版的動(dòng)態(tài)數(shù)組,這是切片類型的靈魂。除構(gòu)造切片和遍歷切片之外,添加切片元素、刪除切片元素都是切片類型的常用操?作。
1.添加切片元素
內(nèi)置的泛型函數(shù)append()
可以在切片的尾部追加n個(gè)元素:
var a []int a = append(a, 1) // 追加1個(gè)元素 a = append(a, 1, 2, 3) // 追加多個(gè)元素,手寫(xiě)解包方式 a = append(a, []int{1,2,3}...) // 追加1個(gè)切片,切片需要解包
不過(guò),要注意的是,在容量不足的情況下,append()
操作會(huì)重新分配內(nèi)存,可能導(dǎo)致巨大的內(nèi)存分配和復(fù)制數(shù)據(jù)的代價(jià)。即使容量足夠,依然需要用append()
函數(shù)的返回值來(lái)更新切片本身,因?yàn)樾虑衅拈L(zhǎng)度已經(jīng)發(fā)生了變?化。
除了在切片的尾部追加元素,還可以在切片的開(kāi)頭添加元素:
var a = []int{1,2,3} a = append([]int{0}, a...) // 在開(kāi)頭添加1個(gè)元素 a = append([]int{-3,-2,-1}, a...) // 在開(kāi)頭添加1個(gè)切片
在開(kāi)頭添加元素一般都會(huì)重新分配內(nèi)存,而且會(huì)導(dǎo)致已有的元素全部復(fù)制一次。因此,從切片的開(kāi)頭添加元素的性能一般要比從尾部追加元素的性能差很?多。
由于append()
函數(shù)返回新的切片,也就是說(shuō)它支持鏈?zhǔn)讲僮鳎覀兙涂梢詫⒍鄠€(gè)append()
操作組合起來(lái),實(shí)現(xiàn)在切片中間插入元素:
var a []int a = append(a[:i], append([]int{x}, a[i:]...)...) // 在第i個(gè)位置插入x a = append(a[:i], append([]int{1,2,3}, a[i:]...)...) // 在第i個(gè)位置插入切片
每個(gè)添加操作中的第二個(gè)append()
調(diào)用都會(huì)創(chuàng)建一個(gè)臨時(shí)切片,并將a[i:]
的內(nèi)容復(fù)制到新創(chuàng)建的切片中,然后將臨時(shí)創(chuàng)建的切片再追加到a[:i]
。
用copy()
和append()
組合可以避免創(chuàng)建中間的臨時(shí)切片。同樣是完成添加元素的操作:
a = append(a, 0) // 切片擴(kuò)展1個(gè)空間 copy(a[i+1:], a[i:]) // a[i:]向后移動(dòng)1個(gè)位置 a[i] = x // 設(shè)置新添加的元素
第一句中的append()
用于擴(kuò)展切片的長(zhǎng)度,為要插入的元素留出空間。第二句中的copy()
操作將要插入位置開(kāi)始之后的元素向后移動(dòng)1個(gè)位置。第三句真實(shí)地將新添加的元素賦值給對(duì)應(yīng)的位置。操作語(yǔ)句雖然冗長(zhǎng)了一點(diǎn),但是相比前面的方法,可以減少中間創(chuàng)建的臨時(shí)切?片。
用copy()
和append()
組合也可以實(shí)現(xiàn)在中間位置插入多個(gè)元素(也就是插入1個(gè)切片):
a = append(a, x...) // 為x切片擴(kuò)展足夠的空間 copy(a[i+len(x):], a[i:]) // a[i:]向后移動(dòng)len(x)個(gè)位置 copy(a[i:], x) // 復(fù)制新添加的切片
稍顯不足的是,在第一句擴(kuò)展切片容量的時(shí)候,擴(kuò)展空間部分的元素復(fù)制是沒(méi)有必要的。沒(méi)有專門(mén)的內(nèi)置函數(shù)用于擴(kuò)展切片的容量,append()
本質(zhì)是用于追加元素而不是擴(kuò)展容量,擴(kuò)展切片容量只是append()
的一個(gè)副作?用。
2.刪除切片元素
根據(jù)要?jiǎng)h除元素的位置,有從開(kāi)頭位置刪除、從中間位置刪除和從尾部刪除3種情況,其中刪除切片尾部的元素最快:
a = []int{1, 2, 3} a = a[:len(a)-1] // 刪除尾部1個(gè)元素 a = a[:len(a)-N] // 刪除尾部N個(gè)元素
要?jiǎng)h除開(kāi)頭的元素可以直接移動(dòng)數(shù)據(jù)指針:
a = []int{1, 2, 3} a = a[1:] // 刪除開(kāi)頭1個(gè)元素 a = a[N:] // 刪除開(kāi)頭N個(gè)元素
刪除開(kāi)頭的元素也可以不移動(dòng)數(shù)據(jù)指針,而將后面的數(shù)據(jù)向開(kāi)頭移動(dòng)。可以用append()
原地完成(所謂原地完成是指在原有的切片數(shù)據(jù)對(duì)應(yīng)的內(nèi)存空間內(nèi)完成,不會(huì)導(dǎo)致內(nèi)存空間結(jié)構(gòu)的變化):
a = []int{1, 2, 3} a = append(a[:0], a[1:]...) // 刪除開(kāi)頭1個(gè)元素 a = append(a[:0], a[N:]...) // 刪除開(kāi)頭N個(gè)元素
也可以用copy()
刪除開(kāi)頭的元素:
a = []int{1, 2, 3} a = a[:copy(a, a[1:])] // 刪除開(kāi)頭1個(gè)元素 a = a[:copy(a, a[N:])] // 刪除開(kāi)頭N個(gè)元素
對(duì)于刪除中間的元素,需要對(duì)剩余的元素進(jìn)行一次整體移動(dòng),同樣可以用append()
或copy()
原地完成:
a = []int{1, 2, 3, ...} a = append(a[:i], a[i+1:]...) // 刪除中間1個(gè)元素 a = append(a[:i], a[i+N:]...) // 刪除中間N個(gè)元素 a = a[:i+copy(a[i:], a[i+1:])] // 刪除中間1個(gè)元素 a = a[:i+copy(a[i:], a[i+N:])] // 刪除中間N個(gè)元素
刪除開(kāi)頭的元素和刪除尾部的元素都可以認(rèn)為是刪除中間的元素操作的特殊情?況。
3.切片內(nèi)存技巧
在1.3.1節(jié)中我們提到過(guò)類似[0]int
的空數(shù)組,空數(shù)組一般很少用到。但是對(duì)切片來(lái)說(shuō),len
為0
但cap
不為0
是非常有用的特性。當(dāng)然,如果len
和cap
都為0
的話,則變成一個(gè)真正的空切片,雖然它并不是一個(gè)nil
的切片。當(dāng)判斷一個(gè)切片是否為空時(shí),通常通過(guò)len
獲取切片的長(zhǎng)度來(lái)判斷,一般很少將切片和nil
做直接比?較。
例如,下面的TrimSpace
()
函數(shù)用于刪除[]byte
中的空格。函數(shù)實(shí)現(xiàn)利用了長(zhǎng)度為0的切片特性,實(shí)現(xiàn)高效而且簡(jiǎn)?潔。
func TrimSpace(s []byte) []byte { b := s[:0] for _, x := range s { if x != ' ' { b = append(b, x) } } return b }
其實(shí)類似的根據(jù)過(guò)濾條件原地刪除切片元素的算法都可以采用類似的方式處理(因?yàn)槭莿h除操作,所以不會(huì)出現(xiàn)內(nèi)存不足的情形):
func Filter(s []byte, fn func(x byte) bool) []byte { b := s[:0] for _, x := range s { if !fn(x) { b = append(b, x) } } return b }
切片高效操作的要點(diǎn)是要降低內(nèi)存分配的次數(shù),盡量保證append()
操作不會(huì)超出cap
,減少觸發(fā)內(nèi)存分配的次數(shù)和每次分配內(nèi)存的大?小。
4.避免切片內(nèi)存泄漏
如前所述,切片操作并不會(huì)復(fù)制底層的數(shù)據(jù)。底層的數(shù)組會(huì)被保存在內(nèi)存中,直到它不再被引用。但是有時(shí)候可能會(huì)因?yàn)橐粋€(gè)小的內(nèi)存引用而導(dǎo)致底層整個(gè)數(shù)組處于被使用的狀態(tài),這會(huì)延遲垃圾收集器對(duì)底層數(shù)組的回?收。
例如,FindPhoneNumber
()
函數(shù)加載整個(gè)文件到內(nèi)存,然后搜索第一個(gè)出現(xiàn)的電話號(hào)碼,最后結(jié)果以切片方式返?回。
func FindPhoneNumber(filename string) []byte { b, _ := ioutil.ReadFile(filename) return regexp.MustCompile("[0-9]+").Find(b) }
這段代碼返回的[]byte
指向保存整個(gè)文件的數(shù)組。由于切片引用了整個(gè)原始數(shù)組,因此垃圾收集器不能及時(shí)釋放底層數(shù)組的空間。一個(gè)小的需求可能導(dǎo)致需要長(zhǎng)時(shí)間保存整個(gè)文件數(shù)據(jù)。這雖然不是傳統(tǒng)意義上的內(nèi)存泄漏,但是可能會(huì)降低系統(tǒng)的整體性?能。
要解決這個(gè)問(wèn)題,可以將感興趣的數(shù)據(jù)復(fù)制到一個(gè)新切片中(數(shù)據(jù)的傳值是Go語(yǔ)言編程的一個(gè)哲學(xué),雖然傳值有一定的代價(jià),但是換取的好處是切斷了對(duì)原始數(shù)據(jù)的依賴):
func FindPhoneNumber(filename string) []byte { b, _ := ioutil.ReadFile(filename) b = regexp.MustCompile("[0-9]+").Find(b) return append([]byte{}, b...) }
類似的問(wèn)題在刪除切片元素時(shí)也可能會(huì)遇到。假設(shè)切片里存放的是指針對(duì)象,那么下面刪除尾部的元素后,被刪除的元素依然被切片底層數(shù)組引用,從而導(dǎo)致不能及時(shí)被垃圾收集器回收(這要依賴回收器的實(shí)現(xiàn)方式):
var a []*int{ ... } a = a[:len(a)-1] // 被刪除的最后一個(gè)元素依然被引用,可能導(dǎo)致垃圾收集操作被阻礙
保險(xiǎn)的方式是先將指向需要回收內(nèi)存的指針設(shè)置為nil
,保證垃圾收集器可以發(fā)現(xiàn)需要回收的對(duì)象,然后再進(jìn)行切片的刪除操作:
var a []*int{ ... } a[len(a)-1] = nil // 垃圾收集器回收最后一個(gè)元素的內(nèi)存 a = a[:len(a)-1] // 從切片中刪除最后一個(gè)元素
當(dāng)然,如果切片的生命周期很短,可以不用刻意處理這個(gè)問(wèn)題。因?yàn)槿绻衅旧硪呀?jīng)可以被垃圾收集器回收,切片對(duì)應(yīng)的每個(gè)元素自然也就可以被回收?了。
5.切片類型強(qiáng)制轉(zhuǎn)換
為了安全,當(dāng)兩個(gè)切片類型[]T
和[]Y
的底層數(shù)據(jù)類型不同時(shí),Go語(yǔ)言是無(wú)法直接轉(zhuǎn)換類型的。不過(guò),有時(shí)候這種類型轉(zhuǎn)換是有它的價(jià)值的——可以簡(jiǎn)化編碼或者提升代碼的性能。例如在64位系統(tǒng)上,需要對(duì)一個(gè)[]float64
類型且沒(méi)有負(fù)數(shù)的切片進(jìn)行快速排序,我們可以將它強(qiáng)制轉(zhuǎn)換為整型切片[]int
,然后以整數(shù)的方式進(jìn)行排序(因?yàn)?code>float64遵循IEEE 754浮點(diǎn)數(shù)標(biāo)準(zhǔn)特性,所以當(dāng)幾個(gè)非負(fù)浮點(diǎn)數(shù)有序時(shí),其底層內(nèi)存數(shù)據(jù)作為整數(shù)時(shí)也必然是有序的)。
下面的代碼通過(guò)兩種方法將[]float64
類型的切片轉(zhuǎn)換為[]int
類型的切片:
// +build amd64 arm64 import "sort" var a = []float64{4, 2, 5, 7, 2, 1, 88, 1} func SortFloat64FastV1(a []float64) { // 強(qiáng)制類型轉(zhuǎn)換 var b []int = ((*[1 << 20]int)(unsafe.Pointer(&a[0])))[:len(a):cap(a)] // 以int方式給float64排序(不支持負(fù)數(shù)) sort.Ints(b) } func SortFloat64FastV2(a []float64) { // 通過(guò)reflect.SliceHeader更新切片頭部信息實(shí)現(xiàn)轉(zhuǎn)換 var c []int aHdr := (*reflect.SliceHeader)(unsafe.Pointer(&a)) cHdr := (*reflect.SliceHeader)(unsafe.Pointer(&c)) *cHdr = *aHdr // 以int方式給float64排序(不支持負(fù)數(shù)) sort.Ints(c) }
第一種強(qiáng)制類型轉(zhuǎn)換是先將切片數(shù)據(jù)的開(kāi)始地址轉(zhuǎn)換為一個(gè)指向長(zhǎng)度較大的數(shù)組的指針,然后對(duì)數(shù)組指針對(duì)應(yīng)的數(shù)組重新做切片操作。中間需要unsafe.Pointer
來(lái)連接兩個(gè)不同類型的指針傳遞。第二種轉(zhuǎn)換操作是分別取兩個(gè)不同類型的切片頭信息指針,任何類型的切片頭部信息底層都對(duì)應(yīng)reflect.
SliceHeader
結(jié)構(gòu),然后通過(guò)更新結(jié)構(gòu)體方式來(lái)更新切片信息,從而實(shí)現(xiàn)a
對(duì)應(yīng)的[]float64
切片到c
對(duì)應(yīng)的[]int
類型的
切片的轉(zhuǎn)?換。
在Go 1.17到Go 1.20之間,unsafe
包提供了類似的功能,因此新的寫(xiě)法如下:
func SortFloat64FastV3(a []float64) { c := unsafe.Slice( (*int)(unsafe.Pointer(unsafe.SliceData(a))), len(a), ) // 以int方式給float64排序(不支持負(fù)數(shù)) sort.Ints(c) }
unsafe.
SliceData
()
從切片提取底層數(shù)據(jù)的指針,unsafe.Slice()
則根據(jù)數(shù)據(jù)指針和長(zhǎng)度構(gòu)建新的切?片。
通過(guò)基準(zhǔn)測(cè)試可以發(fā)現(xiàn),用sort.
Ints
對(duì)轉(zhuǎn)換后的[]int
排序的性能要比用sort.Float64s
排序的性能好一點(diǎn)。不過(guò)需要注意的是,這個(gè)方法可行的前提是要保證[]float64
中沒(méi)有NaN
和Inf
等非規(guī)范的浮點(diǎn)數(shù)(因?yàn)楦↑c(diǎn)數(shù)中NaN
不可排序,正0和負(fù)0相等,但是整數(shù)中沒(méi)有這類情形)。
- 深入淺出數(shù)據(jù)科學(xué):Python編程
- 樂(lè)學(xué)Web編程:網(wǎng)站制作不神秘
- TestNG Beginner's Guide
- 看透JavaScript:原理、方法與實(shí)踐
- 征服RIA
- Banana Pi Cookbook
- JavaScript by Example
- BIM概論及Revit精講
- Go語(yǔ)言編程
- C編程技巧:117個(gè)問(wèn)題解決方案示例
- Learning Ionic
- Python程序設(shè)計(jì)開(kāi)發(fā)寶典
- Node.js從入門(mén)到精通
- R的極客理想:量化投資篇
- WordPress Search Engine Optimization(Second Edition)