Go 語言 數組、字符串和切片

2023-03-22 14:56 更新

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


1.3 數組、字符串和切片

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

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

1.3.1 數組

數組是一個由固定長度的特定類型元素組成的序列,一個數組可以由零個或多個元素組成。數組的長度是數組類型的組成部分。因為數組的長度是數組類型的一個部分,不同長度或不同類型的數據組成的數組都是不同的類型,因此在 Go 語言中很少直接使用數組(不同長度的數組因為類型不同無法直接賦值)。和數組對應的類型是切片,切片是可以動態(tài)增長和收縮的序列,切片的功能也更加靈活,但是要理解切片的工作原理還是要先理解數組。

我們先看看數組有哪些定義方式:

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

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

第二種方式定義數組,可以在定義的時候順序指定全部元素的初始化值,數組的長度根據初始化元素的數目自動計算。

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

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

數組的內存結構比較簡單。比如下面是一個 [4]int{2,3,5,7} 數組值對應的內存結構:


圖 1-7 數組布局

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

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

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

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

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

可以將數組看作一個特殊的結構體,結構的字段名對應數組的索引,同時結構體成員的數目是固定的。內置函數 len 可以用于計算數組的長度,cap 函數可以用于計算數組的容量。不過對于數組類型來說,len 和 cap 函數返回的結果始終是一樣的,都是對應數組類型的長度。

我們可以用 for 循環(huán)來迭代數組。下面常見的幾種方式都可以用來遍歷數組:

    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 方式迭代的性能可能會更好一些,因為這種迭代可以保證不會出現數組越界的情形,每輪迭代對數組元素的訪問時可以省去對下標越界的判斷。

用 for range 方式迭代,還可以忽略迭代時的下標:

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

其中 times 對應一個 [5][0]int 類型的數組,雖然第一維數組有長度,但是數組的元素 [0]int 大小是 0,因此整個數組占用的內存大小依然是 0。沒有付出額外的內存代價,我們就通過 for range 方式實現了 times 次快速迭代。

數組不僅僅可以用于數值類型,還可以定義字符串數組、結構體數組、函數數組、接口數組、管道數組等等:

// 字符串數組
var s1 = [2]string{"hello", "world"}
var s2 = [...]string{"你好", "世界"}
var s3 = [...]string{1: "世界", 0: "你好", }

// 結構體數組
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}}

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

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

// 管道數組
var chanList = [2]chan int{}

我們還可以定義一個空的數組:

var d [0]int       // 定義一個長度為 0 的數組
var e = [0]int{}   // 定義一個長度為 0 的數組
var f = [...]int{} // 定義一個長度為 0 的數組

長度為 0 的數組在內存中并不占用空間??諗到M雖然很少直接使用,但是可以用于強調某種特有類型的操作時避免分配額外的內存空間,比如用于管道的同步操作:

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

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

    c2 := make(chan struct{})
    go func() {
        fmt.Println("c2")
        c2 <- struct{}{} // struct{} 部分是類型, {} 表示對應的結構體值
    }()
    <-c2

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

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

在 Go 語言中,數組類型是切片和字符串等結構的基礎。以上數組的很多操作都可以直接用于字符串或切片中。

1.3.2 字符串

一個字符串是一個不可改變的字節(jié)序列,字符串通常是用來包含人類可讀的文本數據。和數組不同的是,字符串的元素不可修改,是一個只讀的字節(jié)數組。每個字符串的長度雖然也是固定的,但是字符串的長度并不是字符串類型的一部分。由于 Go 語言的源代碼要求是 UTF8 編碼,導致 Go 源代碼中出現的字符串面值常量一般也是 UTF8 編碼的。源代碼中的文本字符串通常被解釋為采用 UTF8 編碼的 Unicode 碼點(rune)序列。因為字節(jié)序列對應的是只讀的字節(jié)序列,因此字符串可以包含任意的數據,包括 byte 值 0。我們也可以用字符串表示 GBK 等非 UTF8 編碼的數據,不過這種時候將字符串看作是一個只讀的二進制數組更準確,因為 for range 等語法并不能支持非 UTF8 編碼的字符串的遍歷。

Go 語言字符串的底層結構在 reflect.StringHeader 中定義:

type StringHeader struct {
    Data uintptr
    Len  int
}

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

我們可以看看字符串“Hello, world”本身對應的內存結構:


圖 1-8 字符串布局

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

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

字符串雖然不是切片,但是支持切片操作,不同位置的切片底層也訪問的同一塊內存數據(因為字符串是只讀的,相同的字符串面值常量通常是對應同一個字符串常量):

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

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

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

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

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

下面的“Hello, 世界”字符串中包含了中文字符,可以通過打印轉型為字節(jié)類型來查看字符底層對應的數據:

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

輸出的結果是:

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

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

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

下圖展示了“Hello, 世界”字符串的內存結構布局:


圖 1-9 字符串布局

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

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

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

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

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

如果不想解碼 UTF8 字符串,想直接遍歷原始的字節(jié)碼,可以將字符串強制轉為 []byte 字節(jié)序列后再行遍歷(這里的轉換一般不會產生運行時開銷):

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

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

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 語法對 UTF8 字符串提供了特殊支持外,還對字符串和 []rune 類型的相互轉換提供了特殊的支持。

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

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

字符串相關的強制類型轉換主要涉及到 []byte 和 []rune 兩種類型。每個轉換都可能隱含重新分配內存的代價,最壞的情況下它們的運算時間復雜度都是 O(n)。不過字符串和 []rune 的轉換要更為特殊一些,因為一般這種強制類型轉換要求兩個類型的底層內存結構要盡量一致,顯然它們底層對應的 []byte 和 []int32 類型是完全不同的內部布局,因此這種轉換可能隱含重新分配內存的操作。

下面分別用偽代碼簡單模擬 Go 語言對字符串內置的一些操作,這樣對每個操作的處理的時間復雜度和空間復雜度都會有較明確的認識。

for range 對字符串的迭代模擬實現

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 迭代字符串時,每次解碼一個 Unicode 字符,然后進入 for 循環(huán)體,遇到崩壞的編碼并不會導致迭代停止。

[]byte(s) 轉換模擬實現

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
}

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

string(bytes) 轉換模擬實現

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
}

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

[]rune(s) 轉換模擬實現

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)
}

因為底層內存結構的差異,字符串到 []rune 的轉換必然會導致重新分配 []rune 內存空間,然后依次解碼并復制對應的 Unicode 碼點值。這種強制轉換并不存在前面提到的字符串和字節(jié)切片轉化時的優(yōu)化情況。

string(runes) 轉換模擬實現

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)
}

同樣因為底層內存結構的差異,[]rune 到字符串的轉換也必然會導致重新構造字符串。這種強制轉換并不存在前面提到的優(yōu)化情況。

1.3.3 切片(slice)

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

我們先看看切片的結構定義,reflect.SliceHeader

type SliceHeader struct {
    Data uintptr
    Len  int
    Cap  int
}

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


圖 1-10 切片布局

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

var (
    a []int               // nil 切片, 和 nil 相等, 一般用來表示一個不存在的切片
    b = []int{}           // 空切片, 和 nil 不相等, 一般用來表示一個空的集合
    c = []int{1, 2, 3}    // 有 3 個元素的切片, len 和 cap 都為 3
    d = c[:2]             // 有 2 個元素的切片, len 為 2, cap 為 3
    e = c[0:2:cap(c)]     // 有 2 個元素的切片, len 為 2, cap 為 3
    f = c[:0]             // 有 0 個元素的切片, len 為 0, cap 為 3
    g = make([]int, 3)    // 有 3 個元素的切片, len 和 cap 都為 3
    h = make([]int, 2, 3) // 有 2 個元素的切片, len 為 2, cap 為 3
    i = make([]int, 0, 3) // 有 0 個元素的切片, len 為 0, cap 為 3
)

和數組一樣,內置的 len 函數返回切片中有效元素的長度,內置的 cap 函數返回切片容量大小,容量必須大于或等于切片的長度。也可以通過 reflect.SliceHeader 結構訪問切片的信息(只是為了說明切片的結構,并不是推薦的做法)。切片可以和 nil 進行比較,只有當切片底層數據指針為空時切片本身為 nil,這時候切片的長度和容量信息將是無效的。如果有切片的底層數據指針為空,但是長度和容量不為 0 的情況,那么說明切片本身已經被損壞了(比如直接通過 reflect.SliceHeader 或 unsafe 包對切片作了不正確的修改)。

遍歷切片的方式和遍歷數組的方式類似:

    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])
    }

其實除了遍歷之外,只要是切片的底層數據指針、長度和容量沒有發(fā)生變化的話,對切片的遍歷、元素的讀取和修改都和數組是一樣的。在對切片本身賦值或參數傳遞時,和數組指針的操作方式類似,只是復制切片頭信息(reflect.SliceHeader),并不會復制底層的數據。對于類型,和數組的最大不同是,切片的類型和長度信息無關,只要是相同類型元素構成的切片均對應相同的切片類型。

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

添加切片元素

內置的泛型函數 append 可以在切片的尾部追加 N 個元素:

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

不過要注意的是,在容量不足的情況下,append 的操作會導致重新分配內存,可能導致巨大的內存分配和復制數據代價。即使容量足夠,依然需要用 append 函數的返回值來更新切片本身,因為新切片的長度已經發(fā)生了變化。

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

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

在開頭一般都會導致內存的重新分配,而且會導致已有的元素全部復制 1 次。因此,從切片的開頭添加元素的性能一般要比從尾部追加元素的性能差很多。

由于 append 函數返回新的切片,也就是它支持鏈式操作。我們可以將多個 append 操作組合起來,實現在切片中間插入元素:

var a []int
a = append(a[:i], append([]int{x}, a[i:]...)...)     // 在第 i 個位置插入 x
a = append(a[:i], append([]int{1,2,3}, a[i:]...)...) // 在第 i 個位置插入切片

每個添加操作中的第二個 append 調用都會創(chuàng)建一個臨時切片,并將 a[i:] 的內容復制到新創(chuàng)建的切片中,然后將臨時創(chuàng)建的切片再追加到 a[:i]。

可以用 copy 和 append 組合可以避免創(chuàng)建中間的臨時切片,同樣是完成添加元素的操作:

a = append(a, 0)     // 切片擴展 1 個空間
copy(a[i+1:], a[i:]) // a[i:] 向后移動 1 個位置
a[i] = x             // 設置新添加的元素

第一句 append 用于擴展切片的長度,為要插入的元素留出空間。第二句 copy 操作將要插入位置開始之后的元素向后挪動一個位置。第三句真實地將新添加的元素賦值到對應的位置。操作語句雖然冗長了一點,但是相比前面的方法,可以減少中間創(chuàng)建的臨時切片。

用 copy 和 append 組合也可以實現在中間位置插入多個元素(也就是插入一個切片):

a = append(a, x...)       // 為 x 切片擴展足夠的空間
copy(a[i+len(x):], a[i:]) // a[i:] 向后移動 len(x) 個位置
copy(a[i:], x)            // 復制新添加的切片

稍顯不足的是,在第一句擴展切片容量的時候,擴展空間部分的元素復制是沒有必要的。沒有專門的內置函數用于擴展切片的容量,append 本質是用于追加元素而不是擴展容量,擴展切片容量只是 append 的一個副作用。

刪除切片元素

根據要刪除元素的位置有三種情況:從開頭位置刪除,從中間位置刪除,從尾部刪除。其中刪除切片尾部的元素最快:

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

刪除開頭的元素可以直接移動數據指針:

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

刪除開頭的元素也可以不移動數據指針,但是將后面的數據向開頭移動??梢杂?nbsp;append 原地完成(所謂原地完成是指在原有的切片數據對應的內存區(qū)間內完成,不會導致內存空間結構的變化):

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

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

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

對于刪除中間的元素,需要對剩余的元素進行一次整體挪動,同樣可以用 append 或 copy 原地完成:

a = []int{1, 2, 3, ...}

a = append(a[:i], a[i+1:]...) // 刪除中間 1 個元素
a = append(a[:i], a[i+N:]...) // 刪除中間 N 個元素

a = a[:i+copy(a[i:], a[i+1:])]  // 刪除中間 1 個元素
a = a[:i+copy(a[i:], a[i+N:])]  // 刪除中間 N 個元素

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

切片內存技巧

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

比如下面的 TrimSpace 函數用于刪除 []byte 中的空格。函數實現利用了 0 長切片的特性,實現高效而且簡潔。

func TrimSpace(s []byte) []byte {
    b := s[:0]
    for _, x := range s {
        if x != ' ' {
            b = append(b, x)
        }
    }
    return b
}

其實類似的根據過濾條件原地刪除切片元素的算法都可以采用類似的方式處理(因為是刪除操作不會出現內存不足的情形):

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
}

切片高效操作的要點是要降低內存分配的次數,盡量保證 append 操作不會超出 cap 的容量,降低觸發(fā)內存分配的次數和每次分配內存大小。

避免切片內存泄漏

如前面所說,切片操作并不會復制底層的數據。底層的數組會被保存在內存中,直到它不再被引用。但是有時候可能會因為一個小的內存引用而導致底層整個數組處于被使用的狀態(tài),這會延遲自動內存回收器對底層數組的回收。

例如,FindPhoneNumber 函數加載整個文件到內存,然后搜索第一個出現的電話號碼,最后結果以切片方式返回。

func FindPhoneNumber(filename string) []byte {
    b, _ := ioutil.ReadFile(filename)
    return regexp.MustCompile("[0-9]+").Find(b)
}

這段代碼返回的 []byte 指向保存整個文件的數組。因為切片引用了整個原始數組,導致自動垃圾回收器不能及時釋放底層數組的空間。一個小的需求可能導致需要長時間保存整個文件數據。這雖然這并不是傳統(tǒng)意義上的內存泄漏,但是可能會拖慢系統(tǒng)的整體性能。

要修復這個問題,可以將感興趣的數據復制到一個新的切片中(數據的傳值是 Go 語言編程的一個哲學,雖然傳值有一定的代價,但是換取的好處是切斷了對原始數據的依賴):

func FindPhoneNumber(filename string) []byte {
    b, _ := ioutil.ReadFile(filename)
    b = regexp.MustCompile("[0-9]+").Find(b)
    return append([]byte{}, b...)
}

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

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

保險的方式是先將需要自動內存回收的元素設置為 nil,保證自動回收器可以發(fā)現需要回收的對象,然后再進行切片的刪除操作:

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

當然,如果切片存在的周期很短的話,可以不用刻意處理這個問題。因為如果切片本身已經可以被 GC 回收的話,切片對應的每個元素自然也就是可以被回收的了。

切片類型強制轉換

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

下面的代碼通過兩種方法將 []float64 類型的切片轉換為 []int 類型的切片:

// +build amd64 arm64

import "sort"

var a = []float64{4, 2, 5, 7, 2, 1, 88, 1}

func SortFloat64FastV1(a []float64) {
    // 強制類型轉換
    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 更新切片頭部信息實現轉換
    var c []int
    aHdr := (*reflect.SliceHeader)(unsafe.Pointer(&a))
    cHdr := (*reflect.SliceHeader)(unsafe.Pointer(&c))
    *cHdr = *aHdr

    // 以 int 方式給 float64 排序
    sort.Ints(c)
}

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

第二種轉換操作是分別取到兩個不同類型的切片頭信息指針,任何類型的切片頭部信息底層都是對應 reflect.SliceHeader 結構,然后通過更新結構體方式來更新切片信息,從而實現 a 對應的 []float64 切片到 c 對應的 []int 類型切片的轉換。

通過基準測試,我們可以發(fā)現用 sort.Ints 對轉換后的 []int 排序的性能要比用 sort.Float64s 排序的性能好一點。不過需要注意的是,這個方法可行的前提是要保證 []float64 中沒有 NaN 和 Inf 等非規(guī)范的浮點數(因為浮點數中 NaN 不可排序,正 0 和負 0 相等,但是整數中沒有這類情形)。



以上內容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號