這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
初看go語言中的slice,覺得是可變數組的一種很不錯的實現,直接在語言文法的層面支援,操作方面比起java中的ArrayList方便了許多。但是在使用了一段時間後,覺得這東西埋的坑不少,使用方式上和arrayList也有很大的不同,在使用時要格外注意。
slice的資料結構
首先說一下slice的資料結構,源碼可以在google code上找到,http://code.google.com/p/go/source/browse/src/pkg/runtime/runtime.h
struct Slice{ byte* array; // actual data uintgo len; // number of elements uintgo cap; // allocated number of elements};
可以看出主要儲存了三個資訊:
- 一個指向原生數組的指標
- 元素的個數
- 數組分配的儲存空間
slice的基本操作
go中產生切片的方式有以下幾種,這幾種產生方式也對應了對slice的基本操作,每個操作後面go隱藏了很多的細節,如果沒有對其足夠瞭解,在使用時很容易被這些坑絆倒。
1.make函數產生
這是最基本,最原始產生slice切片的方式,通過其他方式產生的切片最終也是通過這種方式來完成。因為無論如何都需要填充上面slice結構的三個最基本資料。
通過尋找源碼,發現最終都是經過下面的c代碼實現的:
static void makeslice1(SliceType *t, intgo len, intgo cap, Slice *ret){ ret->len = len; ret->cap = cap; ret->array = runtime·cnewarray(t->elem, cap);}
make函數在產生slice時的寫法:
var slice1 = make([]int, 0, 5)var slice2 = make([]int, 5, 5)// 省略len的寫法,len預設等於cap,相當於make([]int, 5, 5)var slice3 = make([]int, 5)
這個簡便的寫法實在是有點坑爹,如果你寫成make([]int, 5),go會預設把數組長度len當作slice的容量,按照上面的例子,便產生了這樣的結構:[0 0 0 0 0]
2.對數組進行切片 首先來看下面的代碼:
arr := [5]int{1, 2, 3, 4, 5}slice := arr[3 : 5] // slice:[4, 5]slice[0] = 0 // slice:[0, 5]fmt.Println(slice)fmt.Println(arr)
輸出結果:
[0 5][1 2 3 0 5]
從上面可以看出,對數組進行了切片操作,產生的切片裡的array指標實際指向了原數組的一個位置,相當於c的代碼中對原數組截取產生新的數組[2]arrNew,數組的指標指向arr[3],所以改變切片裡0下標對應元素的值,實際上也就改變了原數組相應數組位置3中元素的值。
關於這個問題這篇博文說的比較詳細:對Go的Slice進行Append的一個“坑”
3.對數組或切片進行append
個人認為這個append是go語言中實現地不太優雅的一個地方,比如對一個slice進行append必須要這樣寫:slice = append(slice, 1)
。說白了就是,對一個slice進行append時,必須把新的引用重新賦值給slice。如果只是文法上怪異,那問題還好,只是代碼寫起來麻煩一點。但是實際情況是這個append操作導致的問題多多,不小心很容易走到append埋的坑裡面去。
先來看一個比較奇怪的現象:
var sliceA = make([]int, 0, 5)sliceB := append(sliceA, 1)fmt.Println(sliceA)fmt.Println(sliceB)
輸出結果是:
[][1]
剛看到這樣的結果時讓人很難以理解,明明聲明了容量是5的切片,現在sliceA的len是0,遠沒有達到切片的容量。按理說對sliceA進行append操作,在沒有達到切片容量的情況下根本不需要重新申請一個新的大容量的數組,只需要在原本數組內修改元素的值。而且,go函數在傳輸切片時是引用傳遞,這樣的話,sliceB和sliceA應該輸出一樣才對。看到這樣的結果,著實讓人困惑了很長時間,難道每次append操作都會重新分配數組嗎?
答案肯定不是這樣的,如果真是這樣的話,go也就不用再混了,效能肯定會出問題。下面從go實現append的源碼中去找答案,源碼位置在:http://code.google.com/p/go/source/browse/src/pkg/runtime/slice.c 代碼很長,這裡只截取關鍵的片段來說明問題:
void runtime·appendslice(SliceType *t, Slice x, Slice y, Slice ret){ intgo m = x.len+y.len; void *pc; if(m > x.cap) growslice1(t, x, m, &ret); else ret = x; // read x[:len] if(m > x.cap) runtime·racereadrangepc(x.array, x.len*w, pc, runtime·appendslice); // read y runtime·racereadrangepc(y.array, y.len*w, pc, runtime·appendslice); // write x[len(x):len(x)+len(y)] if(m <= x.cap) runtime·racewriterangepc(ret.array+ret.len*w, y.len*w, pc, runtime·appendslice); ret.len += y.len; FLUSH(&ret);}
函數定義appendslice(SliceType *t, Slice x, Slice y, Slice ret)
,對應slice3 = append(slice1, slice1...)
操作,分別代表:數組裡的元素類型、slice1, slice2, slice3。雖然append()文法中,第二個參數不能為slice,但是第二個參數其實是一個可變參數elems ...Type
,可以傳輸打散的數組,所以go在處理時同樣是轉換為slice來操作的。
從上面的代碼很清楚的看到,如果x.len + y.len 超過了x.cap,那麼就會重新擴充新的切片,如果x.len + y.len還沒有超過x.cap,則還是在原切片的數組中進行元素的填充。那麼這樣跟我們理性的認識是一致的。可以打消掉之前誤解的對go append的擔心。那問題出在哪呢?
上面忽略了一點,append函數是有go的代碼的,不是直接語言級c的實現,在c的實現上還加了go語言自己的處理,在/pkg/builtin/bulitin.go裡有函數的定義。這裡我只能假設在go的層面對scliceA做了一些隱秘的處理,go如何去調用c的底層實現,我現在還不甚瞭解,這裡也只能分析到這裡。以後瞭解之後再來補充這篇部落格,如果有瞭解的朋友,也非常感激你告訴我。
4.聲明無長度的數組
聲明無長度的數組其實就是聲明了一個可變數組,也就是slice切片。只不過這個切片的len和cap都是0。這個方法寫起來非常方便,如果不瞭解其背後的實現,那麼這樣用起來是效能最差的一種。因為會導致頻繁的對slice進行重新申請內容的操作,並且需要把,原數組中的元素copy到新的大容量的數組裡去。每次重新分配數組容量的步長是len*2,如果進行n次append,那麼需要經過log2(n)次的重新申請記憶體和copy的開銷。
後面的一篇文章會繼續介紹切片和數組的一些區別:
go slice和數組的區別
還可以訪問我樹莓派上搭的部落格地址:
http://www.codeforfun.info/