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

前言

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

先了解一下slice的特性

定義一個slice:

s := []int{1,2,3,4,5}
fmt.Println(s)  // [1 2 3 4 5]

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

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,至于為何后面會說。

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?要回答這些問題就得了解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的源碼slice就知道了:

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更合適。

轉自: https://segmentfault.com/a/1190000005812839

后記

  • slice的底層是array,make([]T, len, cap)的動作是: 1. 初始化一個array;2. 返回一個指向1的slice
  • slice的數據結構中ptr至關重要,它影響slice1 = slice2賦值過程,二者的底層數據結構是同一份數據的拷貝(包含*ptr, len, cap)
    • 其中*ptr相同,意味著不同slice最終指向的array元素是相同的地址。
    • 舉例: slice2 = slice1[2:],其中&slice1[2] == &slice2[0]
  • append函數是copy了連續內存中的值到另一段連續內存
    • 舉例: slice2 = append(slice1, 3), 其中 &slice1[0] != &slice2[0]
    • 以上的舉例不完全正確~~
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 229,517評論 6 539
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,087評論 3 423
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 177,521評論 0 382
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,493評論 1 316
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,207評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,603評論 1 325
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,624評論 3 444
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,813評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,364評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,110評論 3 356
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,305評論 1 371
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,874評論 5 362
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,532評論 3 348
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,953評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,209評論 1 291
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,033評論 3 396
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,268評論 2 375

推薦閱讀更多精彩內容