初看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的開銷。
后面的一篇文章會繼續介紹切片和數組的一些區別:
還可以訪問我樹莓派上搭的博客地址: