官术网_书友最值得收藏!

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.SliceHeaderunsafe包對(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ō),len0cap不為0是非常有用的特性。當(dāng)然,如果lencap都為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)有NaNInf等非規(guī)范的浮點(diǎn)數(shù)(因?yàn)楦↑c(diǎn)數(shù)中NaN不可排序,正0和負(fù)0相等,但是整數(shù)中沒(méi)有這類情形)。

主站蜘蛛池模板: 九龙坡区| 利津县| 中阳县| 华坪县| 山西省| 修水县| 偃师市| 扶沟县| 锡林郭勒盟| 双牌县| 陆良县| 金川县| 廉江市| 台前县| 松原市| 东港市| 称多县| 酉阳| 张掖市| 马尔康县| 永胜县| 闵行区| 宕昌县| 东城区| 波密县| 南阳市| 乌鲁木齐县| 白玉县| 泰宁县| 察隅县| 阿拉善盟| 民勤县| 怀仁县| 河源市| 乌海市| 商河县| 黑水县| 吴堡县| 南充市| 呼图壁县| 吴江市|