Go 語言 數(shù)組、字符串和切片

2023-03-22 14:56 更新

原文鏈接:https://chai2010.cn/advanced-go-programming-book/ch1-basic/ch1-03-array-string-and-slice.html


1.3 數(shù)組、字符串和切片

在主流的編程語言中數(shù)組及其相關(guān)的數(shù)據(jù)結(jié)構(gòu)是使用得最為頻繁的,只有在它(們)不能滿足時(shí)才會(huì)考慮鏈表、hash 表(hash 表可以看作是數(shù)組和鏈表的混合體)和更復(fù)雜的自定義數(shù)據(jù)結(jié)構(gòu)。

Go 語言中數(shù)組、字符串和切片三者是密切相關(guān)的數(shù)據(jù)結(jié)構(gòu)。這三種數(shù)據(jù)類型,在底層原始數(shù)據(jù)有著相同的內(nèi)存結(jié)構(gòu),在上層,因?yàn)檎Z法的限制而有著不同的行為表現(xiàn)。首先,Go 語言的數(shù)組是一種值類型,雖然數(shù)組的元素可以被修改,但是數(shù)組本身的賦值和函數(shù)傳參都是以整體復(fù)制的方式處理的。Go 語言字符串底層數(shù)據(jù)也是對(duì)應(yīng)的字節(jié)數(shù)組,但是字符串的只讀屬性禁止了在程序中對(duì)底層字節(jié)數(shù)組的元素的修改。字符串賦值只是復(fù)制了數(shù)據(jù)地址和對(duì)應(yīng)的長度,而不會(huì)導(dǎo)致底層數(shù)據(jù)的復(fù)制。切片的行為更為靈活,切片的結(jié)構(gòu)和字符串結(jié)構(gòu)類似,但是解除了只讀限制。切片的底層數(shù)據(jù)雖然也是對(duì)應(yīng)數(shù)據(jù)類型的數(shù)組,但是每個(gè)切片還有獨(dú)立的長度和容量信息,切片賦值和函數(shù)傳參數(shù)時(shí)也是將切片頭信息部分按傳值方式處理。因?yàn)榍衅^含有底層數(shù)據(jù)的指針,所以它的賦值也不會(huì)導(dǎo)致底層數(shù)據(jù)的復(fù)制。其實(shí) Go 語言的賦值和函數(shù)傳參規(guī)則很簡(jiǎn)單,除了閉包函數(shù)以引用的方式對(duì)外部變量訪問之外,其它賦值和函數(shù)傳參數(shù)都是以傳值的方式處理。要理解數(shù)組、字符串和切片三種不同的處理方式的原因需要詳細(xì)了解它們的底層數(shù)據(jù)結(jié)構(gòu)。

1.3.1 數(shù)組

數(shù)組是一個(gè)由固定長度的特定類型元素組成的序列,一個(gè)數(shù)組可以由零個(gè)或多個(gè)元素組成。數(shù)組的長度是數(shù)組類型的組成部分。因?yàn)閿?shù)組的長度是數(shù)組類型的一個(gè)部分,不同長度或不同類型的數(shù)據(jù)組成的數(shù)組都是不同的類型,因此在 Go 語言中很少直接使用數(shù)組(不同長度的數(shù)組因?yàn)轭愋筒煌瑹o法直接賦值)。和數(shù)組對(duì)應(yīng)的類型是切片,切片是可以動(dòng)態(tài)增長和收縮的序列,切片的功能也更加靈活,但是要理解切片的工作原理還是要先理解數(shù)組。

我們先看看數(shù)組有哪些定義方式:

var a [3]int                    // 定義長度為 3 的 int 型數(shù)組, 元素全部為 0
var b = [...]int{1, 2, 3}       // 定義長度為 3 的 int 型數(shù)組, 元素為 1, 2, 3
var c = [...]int{2: 3, 1: 2}    // 定義長度為 3 的 int 型數(shù)組, 元素為 0, 2, 3
var d = [...]int{1, 2, 4: 5, 6} // 定義長度為 6 的 int 型數(shù)組, 元素為 1, 2, 0, 0, 5, 6

第一種方式是定義一個(gè)數(shù)組變量的最基本的方式,數(shù)組的長度明確指定,數(shù)組中的每個(gè)元素都以零值初始化。

第二種方式定義數(shù)組,可以在定義的時(shí)候順序指定全部元素的初始化值,數(shù)組的長度根據(jù)初始化元素的數(shù)目自動(dòng)計(jì)算。

第三種方式是以索引的方式來初始化數(shù)組的元素,因此元素的初始化值出現(xiàn)順序比較隨意。這種初始化方式和 map[int]Type 類型的初始化語法類似。數(shù)組的長度以出現(xiàn)的最大的索引為準(zhǔn),沒有明確初始化的元素依然用零值初始化。

第四種方式是混合了第二種和第三種的初始化方式,前面兩個(gè)元素采用順序初始化,第三第四個(gè)元素零值初始化,第五個(gè)元素通過索引初始化,最后一個(gè)元素跟在前面的第五個(gè)元素之后采用順序初始化。

數(shù)組的內(nèi)存結(jié)構(gòu)比較簡(jiǎn)單。比如下面是一個(gè) [4]int{2,3,5,7} 數(shù)組值對(duì)應(yīng)的內(nèi)存結(jié)構(gòu):


圖 1-7 數(shù)組布局

Go 語言中數(shù)組是值語義。一個(gè)數(shù)組變量即表示整個(gè)數(shù)組,它并不是隱式的指向第一個(gè)元素的指針(比如 C 語言的數(shù)組),而是一個(gè)完整的值。當(dāng)一個(gè)數(shù)組變量被賦值或者被傳遞的時(shí)候,實(shí)際上會(huì)復(fù)制整個(gè)數(shù)組。如果數(shù)組較大的話,數(shù)組的賦值也會(huì)有較大的開銷。為了避免復(fù)制數(shù)組帶來的開銷,可以傳遞一個(gè)指向數(shù)組的指針,但是數(shù)組指針并不是數(shù)組。

var a = [...]int{1, 2, 3} // a 是一個(gè)數(shù)組
var b = &a                // b 是指向數(shù)組的指針

fmt.Println(a[0], a[1])   // 打印數(shù)組的前 2 個(gè)元素
fmt.Println(b[0], b[1])   // 通過數(shù)組指針訪問數(shù)組元素的方式和數(shù)組類似

for i, v := range b {     // 通過數(shù)組指針迭代數(shù)組的元素
    fmt.Println(i, v)
}

其中 b 是指向 a 數(shù)組的指針,但是通過 b 訪問數(shù)組中元素的寫法和 a 類似的。還可以通過 for range 來迭代數(shù)組指針指向的數(shù)組元素。其實(shí)數(shù)組指針類型除了類型和數(shù)組不同之外,通過數(shù)組指針操作數(shù)組的方式和通過數(shù)組本身的操作類似,而且數(shù)組指針賦值時(shí)只會(huì)拷貝一個(gè)指針。但是數(shù)組指針類型依然不夠靈活,因?yàn)閿?shù)組的長度是數(shù)組類型的組成部分,指向不同長度數(shù)組的數(shù)組指針類型也是完全不同的。

可以將數(shù)組看作一個(gè)特殊的結(jié)構(gòu)體,結(jié)構(gòu)的字段名對(duì)應(yīng)數(shù)組的索引,同時(shí)結(jié)構(gòu)體成員的數(shù)目是固定的。內(nèi)置函數(shù) len 可以用于計(jì)算數(shù)組的長度,cap 函數(shù)可以用于計(jì)算數(shù)組的容量。不過對(duì)于數(shù)組類型來說,len 和 cap 函數(shù)返回的結(jié)果始終是一樣的,都是對(duì)應(yīng)數(shù)組類型的長度。

我們可以用 for 循環(huán)來迭代數(shù)組。下面常見的幾種方式都可以用來遍歷數(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])
    }

用 for range 方式迭代的性能可能會(huì)更好一些,因?yàn)檫@種迭代可以保證不會(huì)出現(xiàn)數(shù)組越界的情形,每輪迭代對(duì)數(shù)組元素的訪問時(shí)可以省去對(duì)下標(biāo)越界的判斷。

用 for range 方式迭代,還可以忽略迭代時(shí)的下標(biāo):

    var times [5][0]int
    for range times {
        fmt.Println("hello")
    }

其中 times 對(duì)應(yīng)一個(gè) [5][0]int 類型的數(shù)組,雖然第一維數(shù)組有長度,但是數(shù)組的元素 [0]int 大小是 0,因此整個(gè)數(shù)組占用的內(nèi)存大小依然是 0。沒有付出額外的內(nèi)存代價(jià),我們就通過 for range 方式實(shí)現(xiàn)了 times 次快速迭代。

數(shù)組不僅僅可以用于數(shù)值類型,還可以定義字符串?dāng)?shù)組、結(jié)構(gòu)體數(shù)組、函數(shù)數(shù)組、接口數(shù)組、管道數(shù)組等等:

// 字符串?dāng)?shù)組
var s1 = [2]string{"hello", "world"}
var s2 = [...]string{"你好", "世界"}
var s3 = [...]string{1: "世界", 0: "你好", }

// 結(jié)構(gòu)體數(shù)組
var line1 [2]image.Point
var line2 = [...]image.Point{image.Point{X: 0, Y: 0}, image.Point{X: 1, Y: 1}}
var line3 = [...]image.Point{{0, 0}, {1, 1}}

// 圖像解碼器數(shù)組
var decoder1 [2]func(io.Reader) (image.Image, error)
var decoder2 = [...]func(io.Reader) (image.Image, error){
    png.Decode,
    jpeg.Decode,
}

// 接口數(shù)組
var unknown1 [2]interface{}
var unknown2 = [...]interface{}{123, "你好"}

// 管道數(shù)組
var chanList = [2]chan int{}

我們還可以定義一個(gè)空的數(shù)組:

var d [0]int       // 定義一個(gè)長度為 0 的數(shù)組
var e = [0]int{}   // 定義一個(gè)長度為 0 的數(shù)組
var f = [...]int{} // 定義一個(gè)長度為 0 的數(shù)組

長度為 0 的數(shù)組在內(nèi)存中并不占用空間??諗?shù)組雖然很少直接使用,但是可以用于強(qiáng)調(diào)某種特有類型的操作時(shí)避免分配額外的內(nèi)存空間,比如用于管道的同步操作:

    c1 := make(chan [0]int)
    go func() {
        fmt.Println("c1")
        c1 <- [0]int{}
    }()
    <-c1

在這里,我們并不關(guān)心管道中傳輸數(shù)據(jù)的真實(shí)類型,其中管道接收和發(fā)送操作只是用于消息的同步。對(duì)于這種場(chǎng)景,我們用空數(shù)組來作為管道類型可以減少管道元素賦值時(shí)的開銷。當(dāng)然一般更傾向于用無類型的匿名結(jié)構(gòu)體代替:

    c2 := make(chan struct{})
    go func() {
        fmt.Println("c2")
        c2 <- struct{}{} // struct{} 部分是類型, {} 表示對(duì)應(yīng)的結(jié)構(gòu)體值
    }()
    <-c2

我們可以用 fmt.Printf 函數(shù)提供的 %T 或 %#v 謂詞語法來打印數(shù)組的類型和詳細(xì)信息:

    fmt.Printf("b: %T\n", b)  // b: [3]int
    fmt.Printf("b: %#v\n", b) // b: [3]int{1, 2, 3}

在 Go 語言中,數(shù)組類型是切片和字符串等結(jié)構(gòu)的基礎(chǔ)。以上數(shù)組的很多操作都可以直接用于字符串或切片中。

1.3.2 字符串

一個(gè)字符串是一個(gè)不可改變的字節(jié)序列,字符串通常是用來包含人類可讀的文本數(shù)據(jù)。和數(shù)組不同的是,字符串的元素不可修改,是一個(gè)只讀的字節(jié)數(shù)組。每個(gè)字符串的長度雖然也是固定的,但是字符串的長度并不是字符串類型的一部分。由于 Go 語言的源代碼要求是 UTF8 編碼,導(dǎo)致 Go 源代碼中出現(xiàn)的字符串面值常量一般也是 UTF8 編碼的。源代碼中的文本字符串通常被解釋為采用 UTF8 編碼的 Unicode 碼點(diǎn)(rune)序列。因?yàn)樽止?jié)序列對(duì)應(yīng)的是只讀的字節(jié)序列,因此字符串可以包含任意的數(shù)據(jù),包括 byte 值 0。我們也可以用字符串表示 GBK 等非 UTF8 編碼的數(shù)據(jù),不過這種時(shí)候?qū)⒆址醋魇且粋€(gè)只讀的二進(jìn)制數(shù)組更準(zhǔn)確,因?yàn)?nbsp;for range 等語法并不能支持非 UTF8 編碼的字符串的遍歷。

Go 語言字符串的底層結(jié)構(gòu)在 reflect.StringHeader 中定義:

type StringHeader struct {
    Data uintptr
    Len  int
}

字符串結(jié)構(gòu)由兩個(gè)信息組成:第一個(gè)是字符串指向的底層字節(jié)數(shù)組,第二個(gè)是字符串的字節(jié)的長度。字符串其實(shí)是一個(gè)結(jié)構(gòu)體,因此字符串的賦值操作也就是 reflect.StringHeader 結(jié)構(gòu)體的復(fù)制過程,并不會(huì)涉及底層字節(jié)數(shù)組的復(fù)制。在前面數(shù)組一節(jié)提到的 [2]string 字符串?dāng)?shù)組對(duì)應(yīng)的底層結(jié)構(gòu)和 [2]reflect.StringHeader 對(duì)應(yīng)的底層結(jié)構(gòu)是一樣的,可以將字符串?dāng)?shù)組看作一個(gè)結(jié)構(gòu)體數(shù)組。

我們可以看看字符串“Hello, world”本身對(duì)應(yīng)的內(nèi)存結(jié)構(gòu):


圖 1-8 字符串布局

分析可以發(fā)現(xiàn),“Hello, world”字符串底層數(shù)據(jù)和以下數(shù)組是完全一致的:

var data = [...]byte{
    'h', 'e', 'l', 'l', 'o', ',', ' ', 'w', 'o', 'r', 'l', 'd',
}

字符串雖然不是切片,但是支持切片操作,不同位置的切片底層也訪問的同一塊內(nèi)存數(shù)據(jù)(因?yàn)樽址侵蛔x的,相同的字符串面值常量通常是對(duì)應(yīng)同一個(gè)字符串常量):

s := "hello, world"
hello := s[:5]
world := s[7:]

s1 := "hello, world"[:5]
s2 := "hello, world"[7:]

字符串和數(shù)組類似,內(nèi)置的 len 函數(shù)返回字符串的長度。也可以通過 reflect.StringHeader 結(jié)構(gòu)訪問字符串的長度(這里只是為了演示字符串的結(jié)構(gòu),并不是推薦的做法):

fmt.Println("len(s):", (*reflect.StringHeader)(unsafe.Pointer(&s)).Len)   // 12
fmt.Println("len(s1):", (*reflect.StringHeader)(unsafe.Pointer(&s1)).Len) // 5
fmt.Println("len(s2):", (*reflect.StringHeader)(unsafe.Pointer(&s2)).Len) // 5

根據(jù) Go 語言規(guī)范,Go 語言的源文件都是采用 UTF8 編碼。因此,Go 源文件中出現(xiàn)的字符串面值常量一般也是 UTF8 編碼的(對(duì)于轉(zhuǎn)義字符,則沒有這個(gè)限制)。提到 Go 字符串時(shí),我們一般都會(huì)假設(shè)字符串對(duì)應(yīng)的是一個(gè)合法的 UTF8 編碼的字符序列??梢杂脙?nèi)置的 print 調(diào)試函數(shù)或 fmt.Print 函數(shù)直接打印,也可以用 for range 循環(huán)直接遍歷 UTF8 解碼后的 Unicode 碼點(diǎn)值。

下面的“Hello, 世界”字符串中包含了中文字符,可以通過打印轉(zhuǎn)型為字節(jié)類型來查看字符底層對(duì)應(yīng)的數(shù)據(jù):

fmt.Printf("%#v\n", []byte("Hello, 世界"))

輸出的結(jié)果是:

[]byte{0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x2c, 0x20, 0xe4, 0xb8, 0x96, 0xe7, \
0x95, 0x8c}

分析可以發(fā)現(xiàn)0xe4, 0xb8, 0x96對(duì)應(yīng)中文“世”,0xe7, 0x95, 0x8c對(duì)應(yīng)中文“界”。我們也可以在字符串面值中直指定 UTF8 編碼后的值(源文件中全部是 ASCII 碼,可以避免出現(xiàn)多字節(jié)的字符)。

fmt.Println("\xe4\xb8\x96") // 打印: 世
fmt.Println("\xe7\x95\x8c") // 打印: 界

下圖展示了“Hello, 世界”字符串的內(nèi)存結(jié)構(gòu)布局:


圖 1-9 字符串布局

Go 語言的字符串中可以存放任意的二進(jìn)制字節(jié)序列,而且即使是 UTF8 字符序列也可能會(huì)遇到壞的編碼。如果遇到一個(gè)錯(cuò)誤的 UTF8 編碼輸入,將生成一個(gè)特別的 Unicode 字符‘\uFFFD’,這個(gè)字符在不同的軟件中的顯示效果可能不太一樣,在印刷中這個(gè)符號(hào)通常是一個(gè)黑色六角形或鉆石形狀,里面包含一個(gè)白色的問號(hào)‘?’。

下面的字符串中,我們故意損壞了第一字符的第二和第三字節(jié),因此第一字符將會(huì)打印為“?”,第二和第三字節(jié)則被忽略,后面的“abc”依然可以正常解碼打?。ㄥe(cuò)誤編碼不會(huì)向后擴(kuò)散是 UTF8 編碼的優(yōu)秀特性之一)。

fmt.Println("\xe4\x00\x00\xe7\x95\x8cabc") // ?界abc

不過在 for range 迭代這個(gè)含有損壞的 UTF8 字符串時(shí),第一字符的第二和第三字節(jié)依然會(huì)被單獨(dú)迭代到,不過此時(shí)迭代的值是損壞后的 0:

for i, c := range "\xe4\x00\x00\xe7\x95\x8cabc" {
    fmt.Println(i, c)
}
// 0 65533  // \uFFFD, 對(duì)應(yīng) ?
// 1 0      // 空字符
// 2 0      // 空字符
// 3 30028  // 界
// 6 97     // a
// 7 98     // b
// 8 99     // c

如果不想解碼 UTF8 字符串,想直接遍歷原始的字節(jié)碼,可以將字符串強(qiáng)制轉(zhuǎn)為 []byte 字節(jié)序列后再行遍歷(這里的轉(zhuǎn)換一般不會(huì)產(chǎn)生運(yùn)行時(shí)開銷):

for i, c := range []byte("世界abc") {
    fmt.Println(i, c)
}

或者是采用傳統(tǒng)的下標(biāo)方式遍歷字符串的字節(jié)數(shù)組:

const s = "\xe4\x00\x00\xe7\x95\x8cabc"
for i := 0; i < len(s); i++ {
    fmt.Printf("%d %x\n", i, s[i])
}

Go 語言除了 for range 語法對(duì) UTF8 字符串提供了特殊支持外,還對(duì)字符串和 []rune 類型的相互轉(zhuǎn)換提供了特殊的支持。

fmt.Printf("%#v\n", []rune("世界"))             // []int32{19990, 30028}
fmt.Printf("%#v\n", string([]rune{'世', '界'})) // 世界

從上面代碼的輸出結(jié)果來看,我們可以發(fā)現(xiàn) []rune 其實(shí)是 []int32 類型,這里的 rune 只是 int32 類型的別名,并不是重新定義的類型。rune 用于表示每個(gè) Unicode 碼點(diǎn),目前只使用了 21 個(gè) bit 位。

字符串相關(guān)的強(qiáng)制類型轉(zhuǎn)換主要涉及到 []byte 和 []rune 兩種類型。每個(gè)轉(zhuǎn)換都可能隱含重新分配內(nèi)存的代價(jià),最壞的情況下它們的運(yùn)算時(shí)間復(fù)雜度都是 O(n)。不過字符串和 []rune 的轉(zhuǎn)換要更為特殊一些,因?yàn)橐话氵@種強(qiáng)制類型轉(zhuǎn)換要求兩個(gè)類型的底層內(nèi)存結(jié)構(gòu)要盡量一致,顯然它們底層對(duì)應(yīng)的 []byte 和 []int32 類型是完全不同的內(nèi)部布局,因此這種轉(zhuǎn)換可能隱含重新分配內(nèi)存的操作。

下面分別用偽代碼簡(jiǎn)單模擬 Go 語言對(duì)字符串內(nèi)置的一些操作,這樣對(duì)每個(gè)操作的處理的時(shí)間復(fù)雜度和空間復(fù)雜度都會(huì)有較明確的認(rèn)識(shí)。

for range 對(duì)字符串的迭代模擬實(shí)現(xiàn)

func forOnString(s string, forBody func(i int, r rune)) {
    for i := 0; len(s) > 0; {
        r, size := utf8.DecodeRuneInString(s)
        forBody(i, r)
        s = s[size:]
        i += size
    }
}

for range 迭代字符串時(shí),每次解碼一個(gè) Unicode 字符,然后進(jìn)入 for 循環(huán)體,遇到崩壞的編碼并不會(huì)導(dǎo)致迭代停止。

[]byte(s) 轉(zhuǎn)換模擬實(shí)現(xiàn)

func str2bytes(s string) []byte {
    p := make([]byte, len(s))
    for i := 0; i < len(s); i++ {
        c := s[i]
        p[i] = c
    }
    return p
}

模擬實(shí)現(xiàn)中新創(chuàng)建了一個(gè)切片,然后將字符串的數(shù)組逐一復(fù)制到了切片中,這是為了保證字符串只讀的語義。當(dāng)然,在將字符串轉(zhuǎn)為 []byte 時(shí),如果轉(zhuǎn)換后的變量并沒有被修改的情形,編譯器可能會(huì)直接返回原始的字符串對(duì)應(yīng)的底層數(shù)據(jù)。

string(bytes) 轉(zhuǎn)換模擬實(shí)現(xiàn)

func bytes2str(s []byte) (p string) {
    data := make([]byte, len(s))
    for i, c := range s {
        data[i] = c
    }

    hdr := (*reflect.StringHeader)(unsafe.Pointer(&p))
    hdr.Data = uintptr(unsafe.Pointer(&data[0]))
    hdr.Len = len(s)

    return p
}

因?yàn)?Go 語言的字符串是只讀的,無法直接同構(gòu)構(gòu)造底層字節(jié)數(shù)組生成字符串。在模擬實(shí)現(xiàn)中通過 unsafe 包獲取了字符串的底層數(shù)據(jù)結(jié)構(gòu),然后將切片的數(shù)據(jù)逐一復(fù)制到了字符串中,這同樣是為了保證字符串只讀的語義不會(huì)受切片的影響。如果轉(zhuǎn)換后的字符串在生命周期中原始的 []byte 的變量并不會(huì)發(fā)生變化,編譯器可能會(huì)直接基于 []byte 底層的數(shù)據(jù)構(gòu)建字符串。

[]rune(s) 轉(zhuǎn)換模擬實(shí)現(xiàn)

func str2runes(s string) []rune{
    var p []int32
    for len(s)>0 {
        r,size:=utf8.DecodeRuneInString(s)
        p=append(p,int32(r))
        s=s[size:]
        }
        return []rune(p)
}

因?yàn)榈讓觾?nèi)存結(jié)構(gòu)的差異,字符串到 []rune 的轉(zhuǎn)換必然會(huì)導(dǎo)致重新分配 []rune 內(nèi)存空間,然后依次解碼并復(fù)制對(duì)應(yīng)的 Unicode 碼點(diǎn)值。這種強(qiáng)制轉(zhuǎn)換并不存在前面提到的字符串和字節(jié)切片轉(zhuǎn)化時(shí)的優(yōu)化情況。

string(runes) 轉(zhuǎn)換模擬實(shí)現(xiàn)

func runes2string(s []int32) string {
    var p []byte
    buf := make([]byte, 3)
    for _, r := range s {
        n := utf8.EncodeRune(buf, r)
        p = append(p, buf[:n]...)
    }
    return string(p)
}

同樣因?yàn)榈讓觾?nèi)存結(jié)構(gòu)的差異,[]rune 到字符串的轉(zhuǎn)換也必然會(huì)導(dǎo)致重新構(gòu)造字符串。這種強(qiáng)制轉(zhuǎn)換并不存在前面提到的優(yōu)化情況。

1.3.3 切片(slice)

簡(jiǎn)單地說,切片就是一種簡(jiǎn)化版的動(dòng)態(tài)數(shù)組。因?yàn)閯?dòng)態(tài)數(shù)組的長度是不固定,切片的長度自然也就不能是類型的組成部分了。數(shù)組雖然有適用它們的地方,但是數(shù)組的類型和操作都不夠靈活,因此在 Go 代碼中數(shù)組使用的并不多。而切片則使用得相當(dāng)廣泛,理解切片的原理和用法是一個(gè) Go 程序員的必備技能。

我們先看看切片的結(jié)構(gòu)定義,reflect.SliceHeader

type SliceHeader struct {
    Data uintptr
    Len  int
    Cap  int
}

可以看出切片的開頭部分和 Go 字符串是一樣的,但是切片多了一個(gè) Cap 成員表示切片指向的內(nèi)存空間的最大容量(對(duì)應(yīng)元素的個(gè)數(shù),而不是字節(jié)數(shù))。下圖是 x := []int{2,3,5,7,11} 和 y := x[1:3] 兩個(gè)切片對(duì)應(yīng)的內(nèi)存結(jié)構(gòu)。


圖 1-10 切片布局

讓我們看看切片有哪些定義方式:

var (
    a []int               // nil 切片, 和 nil 相等, 一般用來表示一個(gè)不存在的切片
    b = []int{}           // 空切片, 和 nil 不相等, 一般用來表示一個(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ù)返回切片中有效元素的長度,內(nèi)置的 cap 函數(shù)返回切片容量大小,容量必須大于或等于切片的長度。也可以通過 reflect.SliceHeader 結(jié)構(gòu)訪問切片的信息(只是為了說明切片的結(jié)構(gòu),并不是推薦的做法)。切片可以和 nil 進(jìn)行比較,只有當(dāng)切片底層數(shù)據(jù)指針為空時(shí)切片本身為 nil,這時(shí)候切片的長度和容量信息將是無效的。如果有切片的底層數(shù)據(jù)指針為空,但是長度和容量不為 0 的情況,那么說明切片本身已經(jīng)被損壞了(比如直接通過 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ù)指針、長度和容量沒有發(fā)生變化的話,對(duì)切片的遍歷、元素的讀取和修改都和數(shù)組是一樣的。在對(duì)切片本身賦值或參數(shù)傳遞時(shí),和數(shù)組指針的操作方式類似,只是復(fù)制切片頭信息(reflect.SliceHeader),并不會(huì)復(fù)制底層的數(shù)據(jù)。對(duì)于類型,和數(shù)組的最大不同是,切片的類型和長度信息無關(guān),只要是相同類型元素構(gòu)成的切片均對(duì)應(yīng)相同的切片類型。

如前所說,切片是一種簡(jiǎn)化版的動(dòng)態(tài)數(shù)組,這是切片類型的靈魂。除了構(gòu)造切片和遍歷切片之外,添加切片元素、刪除切片元素都是切片處理中經(jīng)常遇到的問題。

添加切片元素

內(nèi)置的泛型函數(shù) append 可以在切片的尾部追加 N 個(gè)元素:

var a []int
a = append(a, 1)               // 追加 1 個(gè)元素
a = append(a, 1, 2, 3)         // 追加多個(gè)元素, 手寫解包方式
a = append(a, []int{1,2,3}...) // 追加 1 個(gè)切片, 切片需要解包

不過要注意的是,在容量不足的情況下,append 的操作會(huì)導(dǎo)致重新分配內(nèi)存,可能導(dǎo)致巨大的內(nèi)存分配和復(fù)制數(shù)據(jù)代價(jià)。即使容量足夠,依然需要用 append 函數(shù)的返回值來更新切片本身,因?yàn)樾虑衅拈L度已經(jīng)發(fā)生了變化。

除了在切片的尾部追加,我們還可以在切片的開頭添加元素:

var a = []int{1,2,3}
a = append([]int{0}, a...)        // 在開頭添加 1 個(gè)元素
a = append([]int{-3,-2,-1}, a...) // 在開頭添加 1 個(gè)切片

在開頭一般都會(huì)導(dǎo)致內(nèi)存的重新分配,而且會(huì)導(dǎo)致已有的元素全部復(fù)制 1 次。因此,從切片的開頭添加元素的性能一般要比從尾部追加元素的性能差很多。

由于 append 函數(shù)返回新的切片,也就是它支持鏈?zhǔn)讲僮?。我們可以將多個(gè) append 操作組合起來,實(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ò)展切片的長度,為要插入的元素留出空間。第二句 copy 操作將要插入位置開始之后的元素向后挪動(dòng)一個(gè)位置。第三句真實(shí)地將新添加的元素賦值到對(duì)應(yīng)的位置。操作語句雖然冗長了一點(diǎn),但是相比前面的方法,可以減少中間創(chuàng)建的臨時(shí)切片。

用 copy 和 append 組合也可以實(shí)現(xiàn)在中間位置插入多個(gè)元素(也就是插入一個(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ù)制是沒有必要的。沒有專門的內(nèi)置函數(shù)用于擴(kuò)展切片的容量,append 本質(zhì)是用于追加元素而不是擴(kuò)展容量,擴(kuò)展切片容量只是 append 的一個(gè)副作用。

刪除切片元素

根據(jù)要?jiǎng)h除元素的位置有三種情況:從開頭位置刪除,從中間位置刪除,從尾部刪除。其中刪除切片尾部的元素最快:

a = []int{1, 2, 3}
a = a[:len(a)-1]   // 刪除尾部 1 個(gè)元素
a = a[:len(a)-N]   // 刪除尾部 N 個(gè)元素

刪除開頭的元素可以直接移動(dòng)數(shù)據(jù)指針:

a = []int{1, 2, 3}
a = a[1:] // 刪除開頭 1 個(gè)元素
a = a[N:] // 刪除開頭 N 個(gè)元素

刪除開頭的元素也可以不移動(dòng)數(shù)據(jù)指針,但是將后面的數(shù)據(jù)向開頭移動(dòng)??梢杂?nbsp;append 原地完成(所謂原地完成是指在原有的切片數(shù)據(jù)對(duì)應(yīng)的內(nèi)存區(qū)間內(nèi)完成,不會(huì)導(dǎo)致內(nèi)存空間結(jié)構(gòu)的變化):

a = []int{1, 2, 3}
a = append(a[:0], a[1:]...) // 刪除開頭 1 個(gè)元素
a = append(a[:0], a[N:]...) // 刪除開頭 N 個(gè)元素

也可以用 copy 完成刪除開頭的元素:

a = []int{1, 2, 3}
a = a[:copy(a, a[1:])] // 刪除開頭 1 個(gè)元素
a = a[:copy(a, a[N:])] // 刪除開頭 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è)元素

刪除開頭的元素和刪除尾部的元素都可以認(rèn)為是刪除中間元素操作的特殊情況。

切片內(nèi)存技巧

在本節(jié)開頭的數(shù)組部分我們提到過有類似 [0]int 的空數(shù)組,空數(shù)組一般很少用到。但是對(duì)于切片來說,len 為 0 但是 cap 容量不為 0 的切片則是非常有用的特性。當(dāng)然,如果 len 和 cap 都為 0 的話,則變成一個(gè)真正的空切片,雖然它并不是一個(gè) nil 值的切片。在判斷一個(gè)切片是否為空時(shí),一般通過 len 獲取切片的長度來判斷,一般很少將切片和 nil 值做直接的比較。

比如下面的 TrimSpace 函數(shù)用于刪除 []byte 中的空格。函數(shù)實(shí)現(xiàn)利用了 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ù)過濾條件原地刪除切片元素的算法都可以采用類似的方式處理(因?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)存大小。

避免切片內(nèi)存泄漏

如前面所說,切片操作并不會(huì)復(fù)制底層的數(shù)據(jù)。底層的數(shù)組會(huì)被保存在內(nèi)存中,直到它不再被引用。但是有時(shí)候可能會(huì)因?yàn)橐粋€(gè)小的內(nèi)存引用而導(dǎo)致底層整個(gè)數(shù)組處于被使用的狀態(tài),這會(huì)延遲自動(dòng)內(nèi)存回收器對(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ù)組。因?yàn)榍衅昧苏麄€(gè)原始數(shù)組,導(dǎo)致自動(dòng)垃圾回收器不能及時(shí)釋放底層數(shù)組的空間。一個(gè)小的需求可能導(dǎo)致需要長時(shí)間保存整個(gè)文件數(shù)據(jù)。這雖然這并不是傳統(tǒng)意義上的內(nèi)存泄漏,但是可能會(huì)拖慢系統(tǒng)的整體性能。

要修復(fù)這個(gè)問題,可以將感興趣的數(shù)據(jù)復(fù)制到一個(gè)新的切片中(數(shù)據(jù)的傳值是 Go 語言編程的一個(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...)
}

類似的問題,在刪除切片元素時(shí)可能會(huì)遇到。假設(shè)切片里存放的是指針對(duì)象,那么下面刪除末尾的元素后,被刪除的元素依然被切片底層數(shù)組引用,從而導(dǎo)致不能及時(shí)被自動(dòng)垃圾回收器回收(這要依賴回收器的實(shí)現(xiàn)方式):

var a []*int{ ... }
a = a[:len(a)-1]    // 被刪除的最后一個(gè)元素依然被引用, 可能導(dǎo)致 GC 操作被阻礙

保險(xiǎn)的方式是先將需要自動(dòng)內(nèi)存回收的元素設(shè)置為 nil,保證自動(dòng)回收器可以發(fā)現(xiàn)需要回收的對(duì)象,然后再進(jìn)行切片的刪除操作:

var a []*int{ ... }
a[len(a)-1] = nil // GC 回收最后一個(gè)元素內(nèi)存
a = a[:len(a)-1]  // 從切片刪除最后一個(gè)元素

當(dāng)然,如果切片存在的周期很短的話,可以不用刻意處理這個(gè)問題。因?yàn)槿绻衅旧硪呀?jīng)可以被 GC 回收的話,切片對(duì)應(yīng)的每個(gè)元素自然也就是可以被回收的了。

切片類型強(qiáng)制轉(zhuǎn)換

為了安全,當(dāng)兩個(gè)切片類型 []T 和 []Y 的底層原始切片類型不同時(shí),Go 語言是無法直接轉(zhuǎn)換類型的。不過安全都是有一定代價(jià)的,有時(shí)候這種轉(zhuǎn)換是有它的價(jià)值的——可以簡(jiǎn)化編碼或者是提升代碼的性能。比如在 64 位系統(tǒng)上,需要對(duì)一個(gè) []float64 切片進(jìn)行高速排序,我們可以將它強(qiáng)制轉(zhuǎn)為 []int 整數(shù)切片,然后以整數(shù)的方式進(jìn)行排序(因?yàn)?nbsp;float64 遵循 IEEE754 浮點(diǎn)數(shù)標(biāo)準(zhǔn)特性,當(dāng)浮點(diǎn)數(shù)有序時(shí)對(duì)應(yīng)的整數(shù)也必然是有序的)。

下面的代碼通過兩種方法將 []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 排序
    sort.Ints(b)
}

func SortFloat64FastV2(a []float64) {
    // 通過 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 排序
    sort.Ints(c)
}

第一種強(qiáng)制轉(zhuǎn)換是先將切片數(shù)據(jù)的開始地址轉(zhuǎn)換為一個(gè)較大的數(shù)組的指針,然后對(duì)數(shù)組指針對(duì)應(yīng)的數(shù)組重新做切片操作。中間需要 unsafe.Pointer 來連接兩個(gè)不同類型的指針傳遞。需要注意的是,Go語言實(shí)現(xiàn)中非0大小數(shù)組的長度不得超過 2GB,因此需要針對(duì)數(shù)組元素的類型大小計(jì)算數(shù)組的最大長度范圍([]uint8 最大 2GB,[]uint16 最大 1GB,以此類推,但是 []struct{} 數(shù)組的長度可以超過 2GB)。

第二種轉(zhuǎn)換操作是分別取到兩個(gè)不同類型的切片頭信息指針,任何類型的切片頭部信息底層都是對(duì)應(yīng) reflect.SliceHeader 結(jié)構(gòu),然后通過更新結(jié)構(gòu)體方式來更新切片信息,從而實(shí)現(xiàn) a 對(duì)應(yīng)的 []float64 切片到 c 對(duì)應(yīng)的 []int 類型切片的轉(zhuǎn)換。

通過基準(zhǔn)測(cè)試,我們可以發(fā)現(xiàn)用 sort.Ints 對(duì)轉(zhuǎn)換后的 []int 排序的性能要比用 sort.Float64s 排序的性能好一點(diǎn)。不過需要注意的是,這個(gè)方法可行的前提是要保證 []float64 中沒有 NaN 和 Inf 等非規(guī)范的浮點(diǎn)數(shù)(因?yàn)楦↑c(diǎn)數(shù)中 NaN 不可排序,正 0 和負(fù) 0 相等,但是整數(shù)中沒有這類情形)。



以上內(nèi)容是否對(duì)您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號(hào)
微信公眾號(hào)

編程獅公眾號(hào)