深刻理解go的slice和到底何時該用slice?

深刻理解go的slice和到底何時該用slice

前言

用過go語言的親們都知道,slice(中文翻譯爲切片)在編程中常常用到,它表明變長的序列,序列中每一個元素都有相同的類型,相似一個動態數組,利用append能夠實現動態增加,利用slice的特性能夠很容易的切割slice,它們是怎麼實現這些特性的呢?如今咱們來探究一下這些特性的本質是什麼。git

先了解一下slice的特性

  • 定義一個slice
s := []int{1,2,3,4,5}
    fmt.Println(s)  // [1 2 3 4 5]

一個slice類型通常寫做[]T,其中T表明slice中元素的類型;slice的語法和數組很像,只是沒有固定長度而已。github

  • slice的擴容
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,至於爲什麼後面會說。golang

  • slice的切割
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 "fmt"

    func main() {

        slice_1 := []int{1, 2, 3, 4, 5}
        fmt.Printf("main-->data:\t%#v\n", slice_1)
        fmt.Printf("main-->len:\t%#v\n", len(slice_1))
        fmt.Printf("main-->cap:\t%#v\n", cap(slice_1))
        test1(slice_1)
        fmt.Printf("main-->data:\t%#v\n", slice_1)

        test2(&slice_1)
        fmt.Printf("main-->data:\t%#v\n", slice_1)

    }

    func test1(slice_2 []int) {
        slice_2[1] = 6666               // 函數外的slice確實有被修改
        slice_2 = append(slice_2, 8888) // 函數外的不變
        fmt.Printf("test1-->data:\t%#v\n", slice_2)
        fmt.Printf("test1-->len:\t%#v\n", len(slice_2))
        fmt.Printf("test1-->cap:\t%#v\n", cap(slice_2))
    }

    func test2(slice_2 *[]int) { // 這樣才能修改函數外的slice
        *slice_2 = append(*slice_2, 6666)
    }

結果:算法

main-->data:    []int{1, 2, 3, 4, 5}
main-->len: 5
main-->cap: 5
test1-->data:   []int{1, 6666, 3, 4, 5, 8888}
test1-->len:    6
test1-->cap:    12
main-->data:    []int{1, 6666, 3, 4, 5}
main-->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的長度和容量是沒變的,因此在函數外打印時看起來就是沒變。app

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 < cap {
        newcap = cap
    } else {
        for {
            if old.len < 1024 {
                newcap += newcap
            } else {
                newcap += newcap / 4
            }
            if newcap >= 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更合適。

相關文章
相關標籤/搜索