Go高階特性 17 | SliceHeader:slice 高效處理資料

Swenson1992發表於2021-03-02

陣列

在講 slice 的原理之前,先來介紹一下陣列。幾乎所有的程式語言裡都存在陣列,Go 也不例外。那麼為什麼 Go 語言除了陣列之外又設計了 slice 呢?要想解答這個問題,先來了解陣列的侷限性。

在下面的示例中,a1、a2 是兩個定義好的陣列,但是它們的型別不一樣。變數 a1 的型別是 [1]string,變數 a2 的型別是 [2]string,也就是說陣列的大小屬於陣列型別的一部分,只有陣列內部元素型別和大小一致時,這兩個陣列才是同一型別。

a1:=[1]string{"Golang"}
a2:=[2]string{"Golang"}

可以總結為,一個陣列由兩部分構成:陣列的大小和陣列內的元素型別。

//陣列結構虛擬碼表示
array{
  len
  item type
}

比如變數 a1 的大小是 1,內部元素的型別是 string,也就是說 a1 最多隻能儲存 1 個型別為 string 的元素。而 a2 的大小是 2,內部元素的型別也是 string,所以 a2 最多可以儲存 2 個型別為 string 的元素。一旦一個陣列被宣告,它的大小和內部元素的型別就不能改變,不能隨意地向陣列新增任意多個元素。這是陣列的第一個限制。

既然陣列的大小是固定的,如果需要使用陣列儲存大量的資料,就需要提前指定一個合適的大小,比如 10 萬,程式碼如下所示:

a10:=[100000]string{"Golang"}

這樣雖然可以解決問題,但又帶來了另外的問題,那就是記憶體佔用。因為在 Go 語言中,函式間的傳參是值傳遞的,陣列作為引數在各個函式之間被傳遞的時候,同樣的內容就會被一遍遍地複製,這就會造成大量的記憶體浪費,這是陣列的第二個限制。

雖然陣列有限制,但是它是 Go 非常重要的底層資料結構,比如 slice 切片的底層資料就儲存在陣列中。

slice 切片

陣列雖然也不錯,但是在操作上有不少限制,為了解決這些限制,Go 語言創造了 slice,也就是切片。切片是對陣列的抽象和封裝,它的底層是一個陣列儲存所有的元素,但是它可以動態地新增元素,容量不足時還可以自動擴容,你完全可以把切片理解為動態陣列。在 Go 語言中,除了明確需要指定長度大小的型別需要陣列來完成,大多數情況下都是使用切片的。

動態擴容

通過內建的 append 方法,你可以向一個切片中追加任意多個元素,所以這就可以解決陣列的第一個限制。

在下面的示例中,通過內建的 append 函式為切片 ss 新增了兩個字串,然後返回一個新的切片賦值給 ss。

func main() {
   ss:=[]string{"Golang","張三"}
   ss=append(ss,"李四","王五")
   fmt.Println(ss)
}

現在執行這段程式碼,會看到如下列印結果:

[Golang 張三 李四 王五]

當通過 append 追加元素時,如果切片的容量不夠,append 函式會自動擴容。比如上面的例子,列印出使用 append 前後的切片長度和容量,程式碼如下:

func main() {
   ss:=[]string{"Golang","張三"}
   fmt.Println("切片ss長度為",len(ss),",容量為",cap(ss))
   ss=append(ss,"李四","王五")
   fmt.Println("切片ss長度為",len(ss),",容量為",cap(ss))
   fmt.Println(ss)
}

其中,通過內建的 len 函式獲取切片的長度,通過 cap 函式獲取切片的容量。執行這段程式碼,可以看到列印結果如下:

切片ss長度為 2 ,容量為 2
切片ss長度為 4 ,容量為 4
[Golang 張三 李四 王五]

在呼叫 append 之前,容量是 2,呼叫之後容量是 4,說明自動擴容了。

小提示:append 自動擴容的原理是新建立一個底層陣列,把原來切片內的元素拷貝到新陣列中,然後再返回一個指向新陣列的切片。

資料結構

在 Go 語言中,切片其實是一個結構體,它的定義如下所示:

type SliceHeader struct {
   Data uintptr
   Len  int
   Cap  int
}

SliceHeader 是切片在執行時的表現形式,它有三個欄位 Data、Len 和 Cap。

  1. Data 用來指向儲存切片元素的陣列。
  2. Len 代表切片的長度。
  3. Cap 代表切片的容量。

通過這三個欄位,就可以把一個陣列抽象成一個切片,便於更好的操作,所以不同切片對應的底層 Data 指向的可能是同一個陣列。現在通過一個示例來證明,程式碼如下:

func main() {
   a1:=[2]string{"Golang","張三"}
   s1:=a1[0:1]
   s2:=a1[:]
   //列印出s1和s2的Data值,是一樣的
   fmt.Println((*reflect.SliceHeader)(unsafe.Pointer(&s1)).Data)
   fmt.Println((*reflect.SliceHeader)(unsafe.Pointer(&s2)).Data)
}

用 unsafe.Pointer 把它們轉換為 *reflect.SliceHeader 指標,就可以列印出 Data 的值,列印結果如下所示:

824634150744
824634150744

會發現它們是一樣的,也就是這兩個切片共用一個陣列,所以在切片賦值、重新進行切片操作時,使用的還是同一個陣列,沒有複製原來的元素。這樣可以減少記憶體的佔用,提高效率。

注意:多個切片共用一個底層陣列雖然可以減少記憶體佔用,但是如果有一個切片修改內部的元素,其他切片也會受影響。所以在切片作為引數在函式間傳遞的時候要小心,儘可能不要修改原切片內的元素。

切片的本質是 SliceHeader,又因為函式的引數是值傳遞,所以傳遞的是 SliceHeader 的副本,而不是底層陣列的副本。這時候切片的優勢就體現出來了,因為 SliceHeader 的副本記憶體佔用非常少,即使是一個非常大的切片(底層陣列有很多元素),也頂多佔用 24 個位元組的記憶體,這就解決了大陣列在傳參時記憶體浪費的問題。

小提示:SliceHeader 三個欄位的型別分別是 uintptr、int 和 int,在 64 位的機器上,這三個欄位最多也就是 int64 型別,一個 int64 佔 8 個位元組,三個 int64 佔 24 個位元組記憶體。

要獲取切片資料結構的三個欄位的值,也可以不使用 SliceHeader,而是完全自定義一個結構體,只要欄位和 SliceHeader 一樣就可以了。

比如在下面的示例中,通過 unsfe.Pointer 轉換成自定義的 *slice 指標,同樣可以獲取三個欄位對應的值,甚至可以把欄位的名稱改為 d、l 和 c,也可以達到目的。

sh1:=(*slice)(unsafe.Pointer(&s1))
fmt.Println(sh1.Data,sh1.Len,sh1.Cap)
type slice struct {
   Data uintptr
   Len  int
   Cap  int
}

小提示:還是儘可能地用 SliceHeader,因為這是 Go 語言提供的標準,可以保持統一,便於理解。

高效的原因

如果從集合型別的角度考慮,陣列、切片和 map 都是集合型別,因為它們都可以存放元素,但是陣列和切片的取值和賦值操作要更高效,因為它們是連續的記憶體操作,通過索引就可以快速地找到元素儲存的地址。

小提示:當然 map 的價值也非常大,因為它的 Key 可以是很多型別,比如 int、int64、string 等,但是陣列和切片的索引只能是整數。

進一步對比,在陣列和切片中,切片又是高效的,因為它在賦值、函式傳參的時候,並不會把所有的元素都複製一遍,而只是複製 SliceHeader 的三個欄位就可以了,共用的還是同一個底層陣列。

在下面的示例中,定義了兩個函式 arrayF 和 sliceF,分別列印傳入的陣列和切片底層對應的陣列指標。

func main() {
   a1:=[2]string{"Golang","張三"}
   fmt.Printf("函式main陣列指標:%p\n",&a1)
   arrayF(a1)
   s1:=a1[0:1]
   fmt.Println((*reflect.SliceHeader)(unsafe.Pointer(&s1)).Data)
   sliceF(s1)
}
func arrayF(a [2]string){
   fmt.Printf("函式arrayF陣列指標:%p\n",&a)
}
func sliceF(s []string){
   fmt.Printf("函式sliceF Data:%d\n",(*reflect.SliceHeader)(unsafe.Pointer(&s)).Data)
}

然後在 main 函式裡呼叫它們,執行程式會列印如下結果:

函式main陣列指標:0xc0000a6020
函式arrayF陣列指標:0xc0000a6040
824634400800
函式sliceF Data:824634400800

同一個陣列在 main 函式中的指標和在 arrayF 函式中的指標是不一樣的,這說明陣列在傳參的時候被複制了,又產生了一個新陣列。而 slice 切片的底層 Data 是一樣的,這說明不管是在 main 函式還是 sliceF 函式中,這兩個切片共用的還是同一個底層陣列,底層陣列並沒有被複制。

小提示:切片的高效還體現在 for range 迴圈中,因為迴圈得到的臨時變數也是個值拷貝,所以在遍歷大的陣列時,切片的效率更高。

切片基於指標的封裝是它效率高的根本原因,因為可以減少記憶體的佔用,以及減少記憶體複製時的時間消耗。

string 和 []byte 互轉

通過 string 和 []byte 相互強制轉換的例子,進一步幫你理解 slice 高效的原因。

比如把一個 []byte 轉為一個 string 字串,然後再轉換回來,示例程式碼如下:

s:="Golang"
b:=[]byte(s)
s3:=string(b)
fmt.Println(s,string(b),s3)

在這個示例中,變數 s 是一個 string 字串,它可以通過 []byte(s) 被強制轉換為 []byte 型別的變數 b,又可以通過 string(b) 強制轉換為 string 型別的變數 s3。列印它們三個變數的值,都是

“Golang”。

Go 語言通過先分配一個記憶體再複製內容的方式,實現 string 和 []byte 之間的強制轉換。現在通過 string 和 []byte 指向的真實內容的記憶體地址,來驗證強制轉換是採用重新分配記憶體的方式。如下面的程式碼所示:

s:="Golang"
fmt.Printf("s的記憶體地址:%d\n", (*reflect.StringHeader)(unsafe.Pointer(&s)).Data)
b:=[]byte(s)
fmt.Printf("b的記憶體地址:%d\n",(*reflect.SliceHeader)(unsafe.Pointer(&b)).Data)
s3:=string(b)
fmt.Printf("s3的記憶體地址:%d\n", (*reflect.StringHeader)(unsafe.Pointer(&s3)).Data)

執行它們,會發現列印出的記憶體地址都不一樣,這說明雖然內容相同,但已經不是同一個字串了,因為記憶體地址不同。

小提示:可以通過檢視 runtime.stringtoslicebyte 和 runtime.slicebytetostring 這兩個函式的原始碼,瞭解關於 string 和 []byte 型別互轉的具體實現。

通過以上的示例程式碼,已經知道了 SliceHeader 是什麼。其實 StringHeader 和 SliceHeader 一樣,代表的是字串在程式執行時的真實結構,StringHeader 的定義如下所示:

// StringHeader is the runtime representation of a string.
type StringHeader struct {
   Data uintptr
   Len  int
}

也就是說,在程式執行的時候,字串和切片本質上就是 StringHeader 和 SliceHeader。這兩個結構體都有一個 Data 欄位,用於存放指向真實內容的指標。所以列印出 Data 這個欄位的值,就可以判斷 string 和 []byte 強制轉換後是不是重新分配了記憶體。

現在已經知道了 []byte(s) 和 string(b) 這種強制轉換會重新拷貝一份字串,如果字串非常大,由於記憶體開銷大,對於有高效能要求的程式來說,這種方式就無法滿足了,需要進行效能優化。

如何優化呢?既然是因為記憶體分配導致記憶體開銷大,那麼優化的思路應該是在不重新申請記憶體的情況下實現型別轉換。

仔細觀察 StringHeader 和 SliceHeader 這兩個結構體,會發現它們的前兩個欄位一模一樣,那麼 []byte 轉 string,就等於通過 unsafe.Pointer 把 *SliceHeader 轉為 *StringHeader,也就是 *[]byte 轉 *string,原理和上面講的把切片轉換成一個自定義的 slice 結構體類似。

在下面的示例中,s4 和 s3 的內容是一樣的。不一樣的是 s4 沒有申請新記憶體(零拷貝),它和變數 b 使用的是同一塊記憶體,因為它們的底層 Data 欄位值相同,這樣就節約了記憶體,也達到了 []byte 轉 string 的目的。

s:="Golang"
b:=[]byte(s)
//s3:=string(b)
s4:=*(*string)(unsafe.Pointer(&b))

SliceHeader 有 Data、Len、Cap 三個欄位,StringHeader 有 Data、Len 兩個欄位,所以 *SliceHeader 通過 unsafe.Pointer 轉為 *StringHeader 的時候沒有問題,因為 *SliceHeader 可以提供 *StringHeader 所需的 Data 和 Len 欄位的值。但是反過來卻不行了,因為 *StringHeader 缺少 *SliceHeader 所需的 Cap 欄位,需要自己補上一個預設值。

在下面的示例中,b1 和 b 的內容是一樣的,不一樣的是 b1 沒有申請新記憶體,而是和變數 s 使用同一塊記憶體,因為它們底層的 Data 欄位相同,所以也節約了記憶體。

s:="Golang"
//b:=[]byte(s)
sh:=(*reflect.SliceHeader)(unsafe.Pointer(&s))
sh.Cap = sh.Len
b1:=*(*[]byte)(unsafe.Pointer(sh))

注意:通過 unsafe.Pointer 把 string 轉為 []byte 後,不能對 []byte 修改,比如不可以進行 b1[0]=12 這種操作,會報異常,導致程式崩潰。這是因為在 Go 語言中 string 記憶體是隻讀的。

通過 unsafe.Pointer 進行型別轉換,避免記憶體拷貝提升效能的方法在 Go 語言標準庫中也有使用,比如 strings.Builder 這個結構體,它內部有 buf 欄位儲存內容,在通過 String 方法把 []byte 型別的 buf 轉為 string 的時候,就使用 unsafe.Pointer 提高了效率,程式碼如下:

// String returns the accumulated string.
func (b *Builder) String() string {
   return *(*string)(unsafe.Pointer(&b.buf))
}

string 和 []byte 的互轉就是一個很好的利用 SliceHeader 結構體的示例,通過它可以實現零拷貝的型別轉換,提升了效率,避免了記憶體浪費。

總結

通過 slice 切片的分析,相信你可以更深地感受 Go 的魅力,它把底層的指標、陣列等進行封裝,提供一個切片的概念給開發者,這樣既可以方便使用、提高開發效率,又可以提高程式的效能。

Go 語言設計切片的思路非常有借鑑意義,可以使用 uintptr 或者 slice 型別的欄位來提升效能,就像 Go 語言 SliceHeader 裡的 Data uintptr 欄位一樣。

本作品採用《CC 協議》,轉載必須註明作者和本文連結
golang

相關文章