深入理解go的slice和到底什麼時候該用slice

sheepbao發表於2016-10-12

前言

用過 go 語言的親們都知道,slice(中文翻譯為切片)在程式設計中經常用到,它代表變長的序列,序列中每個元素都有相同的型別,類似一個動態陣列,利用 append 可以實現動態增長,利用 slice 的特性可以很容易的切割 slice,它們是怎麼實現這些特性的呢?現在我們來探究一下這些特性的本質是什麼。

先了解一下 slice 的特性

  • 定義一個 slice go s := []int{1,2,3,4,5} fmt.Println(s) // [1 2 3 4 5] 一個 slice 型別一般寫作 [] T,其中 T 代表 slice 中元素的型別;slice 的語法和陣列很像,只是沒有固定長度而已。
  • slice 的擴容 go s := []int{1,2,3,4,5} s = append(s, 6) fmt.Println(s) // [1 2 3 4 5 6] 內建 append 函式在現有陣列的長度 < 1024 時 cap 增長是翻倍的,再往上的增長率則是 1.25,至於為何後面會說。
  • slice 的切割 go s := []int{1,2,3,4,5,6} s1 := s[0:2] fmt.Println(s1) // [1 2] s2 := s[4:] fmt.Println(s2) // [5 6] s3 := s[:4] fmt.Println(s3) // [1 2 3 4]
  • slice 作為函式引數

    package main
    
    import &quot;fmt&quot;
    
    func main() {
    
        slice_1 := []int{1, 2, 3, 4, 5}
        fmt.Printf(&quot;main--&gt;data:\t%#v\n&quot;, slice_1)
        fmt.Printf(&quot;main--&gt;len:\t%#v\n&quot;, len(slice_1))
        fmt.Printf(&quot;main--&gt;cap:\t%#v\n&quot;, cap(slice_1))
        test1(slice_1)
        fmt.Printf(&quot;main--&gt;data:\t%#v\n&quot;, slice_1)
    
        test2(&amp;slice_1)
        fmt.Printf(&quot;main--&gt;data:\t%#v\n&quot;, slice_1)
    
    }
    
    func test1(slice_2 []int) {
        slice_2[1] = 6666               // 函式外的slice確實有被修改
        slice_2 = append(slice_2, 8888) // 函式外的不變
        fmt.Printf(&quot;test1--&gt;data:\t%#v\n&quot;, slice_2)
        fmt.Printf(&quot;test1--&gt;len:\t%#v\n&quot;, len(slice_2))
        fmt.Printf(&quot;test1--&gt;cap:\t%#v\n&quot;, cap(slice_2))
    }
    
    func test2(slice_2 *[]int) { // 這樣才能修改函式外的slice
        *slice_2 = append(*slice_2, 6666)
    }
    

    結果:

    main--&gt;data:    []int{1, 2, 3, 4, 5}
    main--&gt;len: 5
    main--&gt;cap: 5
    test1--&gt;data:   []int{1, 6666, 3, 4, 5, 8888}
    test1--&gt;len:    6
    test1--&gt;cap:    12
    main--&gt;data:    []int{1, 6666, 3, 4, 5}
    main--&gt;data:    []int{1, 6666, 3, 4, 5, 6666}
    

    這裡要注意註釋的地方,為何 slice 作為值傳遞引數,函式外的 slice 也被更改了?為何在函式內 append 不能改變函式外的 slice?要回 da 這些問題就得了解 slice 內部結構,詳細請看下面.

slice 的內部結構

其實 slice 在 Go 的執行時庫中就是一個 C 語言動態陣列的實現,在 $GOROOT/src/pkg/runtime/runtime.h 中可以看到它的定義:

struct    Slice
{    // must not move anything
    byte*    array;        // actual data
    uintgo    len;        // number of elements
    uintgo    cap;        // allocated number of elements
};

這個結構有 3 個欄位,第一個欄位表示 array 的指標,就是真實資料的指標(這個一定要注意),所以才經常說 slice 是陣列的引用,第二個是表示 slice 的長度,第三個是表示 slice 的容量,注意:len 和 cap 都不是指標

現在就可以解釋前面的例子 slice 作為函式引數提出的問題: 函式外的 slice 叫 slice_1,函式的引數叫 slice_2,當函式傳遞 slice_1 的時候,其實傳入的確實是 slice_1 引數的複製,所以 slice_2 複製了 slise_1,但要注意的是 slice_2 裡儲存的陣列的指標,所以當在函式內更改陣列內容時,函式外的 slice_1 的內容也改變了。在函式內用 append 時,append 會自動以倍增的方式擴充套件 slice_2 的容量,但是擴充套件也僅僅是函式內 slice_2 的長度和容量,slice_1 的長度和容量是沒變的,所以在函式外列印時看起來就是沒變。

append 的運作機制

在對 slice 進行 append 等操作時,可能會造成 slice 的自動擴容。其擴容時的大小增長規則是:

  • 如果新的 slice 大小是當前大小 2 倍以上,則大小增長為新大小
  • 否則迴圈以下操作:如果當前 slice 大小小於 1024,按每次 2 倍增長,否則每次按當前大小 1/4 增長。直到增長的大小超過或等於新大小。
  • append 的實現只是簡單的在記憶體中將舊 slice複製給新 slice

至於為何會這樣,你要看一下 golang 的原始碼就知道了: https://github.com/golang/go/blob/master/src/runtime/slice.go

newcap := old.cap
if newcap+newcap &lt; cap {
    newcap = cap
} else {
    for {
        if old.len &lt; 1024 {
            newcap += newcap
        } else {
            newcap += newcap / 4
        }
        if newcap &gt;= cap {
            break
        }
    }
}

為何不用動態連結串列實現 slice?

  • 首先拷貝一斷連續的記憶體是很快的,假如不想發生拷貝,也就是用動態連結串列,那你就沒有連續記憶體。此時隨機訪問開銷會是:連結串列 O(N), 2 倍增長塊鏈 O(LogN),二級表一個常數很大的 O(1)。問題不僅是演算法上開銷,還有記憶體位置分散而對快取高度不友好,這些問題 i 在連續記憶體方案裡都是不存在的。除非你的應用是狂 append 然後只順序讀一次,否則優化寫而犧牲讀都完全不 make sense. 而就算你的應用是嚴格順序讀,快取命中率也通常會讓你的綜合效率比拷貝換連續記憶體低。
  • 對小 slice 來說,連續 append 的開銷更多的不是在 memmove, 而是在分配一塊新空間的 memory allocator 和之後的 gc 壓力(這方面對連結串列更是不利)。所以,當你能大致知道所需的最大空間(在大部分時候都是的)時,在 make 的時候預留相應的 cap 就好。如果所需的最大空間很大而每次使用的空間量分佈不確定,那你就要在浪費記憶體和耗 CPU 在 allocator + gc 上做權衡。
  • Go 在 append 和 copy 方面的開銷是可預知 + 可控的,應用上簡單的調優有很好的效果。這個世界上沒有免費的動態增長記憶體,各種實現方案都有設計權衡。

什麼時候該用 slice?

在 go 語言中 slice 是很靈活的,大部分情況都能表現的很好,但也有特殊情況。 當程式要求 slice 的容量超大並且需要頻繁的更改 slice 的內容時,就不應該用 slice,改用list更合適。

更多原創文章乾貨分享,請關注公眾號
  • 深入理解go的slice和到底什麼時候該用slice
  • 加微信實戰群請加微信(註明:實戰群):gocnio

相關文章