歡迎光臨
每天分享高質量文章

從零開始學習 Go 語言的切片 | Linux 中國

這篇文章受到了我與同事討論使用切片slice作為棧stack的一次聊天的啟發。後來話題聊到了 Go 語言中的切片是如何工作的。我認為這些資訊對別人也有用,所以就把它記錄了下來。
— Dave Cheney


致謝
編譯自 | 
https://dave.cheney.net/2018/07/12/slices-from-the-ground-up
 
 作者 | Dave Cheney
 譯者 | Name1e5s (name1e5s) ???共計翻譯:19 篇 貢獻時間:930 天

這篇文章受到了我與同事討論使用切片slice作為stack的一次聊天的啟發。後來話題聊到了 Go 語言中的切片是如何工作的。我認為這些資訊對別人也有用,所以就把它記錄了下來。

陣列

任何關於 Go 語言切片的討論都要從另一個資料結構也就是陣列array開始。Go 的陣列有兩個特性:

1. 陣列的長度是固定的;[5]int 是由 5 個 int 構成的陣列,和 [3]int 不同。
2. 陣列是值型別。看下麵這個示例:

  1. package main

  2. import "fmt"

  3. func main() {

  4.        var a [5]int

  5.        b := a

  6.        b[2] = 7

  7.        fmt.Println(a, b) // prints [0 0 0 0 0] [0 0 7 0 0]

  8. }

陳述句 b := a 定義了一個型別是 [5]int 的新變數 b,然後把 a 中的內容 複製到 b 中。改變 b 對 a 中的內容沒有影響,因為 a 和 b 是相互獨立的值。1

切片

Go 語言的切片和陣列的主要有如下兩個區別:

1. 切片沒有一個固定的長度。切片的長度不是它型別定義的一部分,而是由切片內部自己維護的。我們可以使用內建的 len 函式知道它的長度。2
2. 將一個切片賦值給另一個切片時 不會 對切片內容進行複製操作。這是因為切片沒有直接持有其內部資料,而是保留了一個指向 底層陣列 3 的指標。資料都保留在底層陣列裡。

基於第二個特性,兩個切片可以享有共同的底層陣列。看下麵的示例:

1. 對切片取切片

  1. package main

  2. import "fmt"

  3. func main() {

  4.        var a = []int{1,2,3,4,5}

  5.        b := a[2:]

  6.        b[0] = 0

  7.        fmt.Println(a, b) // prints [1 2 0 4 5] [0 4 5]

  8. }

在這個例子裡,a 和 b 享有共同的底層陣列 —— 儘管 b 在陣列裡的起始偏移量不同,兩者的長度也不同。透過 b 修改底層陣列的值也會導致 a 裡的值的改變。

2. 將切片傳進函式

  1. package main

  2. import "fmt"

  3. func negate(s []int) {

  4.        for i := range s {

  5.                s[i] = -s[i]

  6.        }

  7. }

  8. func main() {

  9.        var a = []int{1, 2, 3, 4, 5}

  10.        negate(a)

  11.        fmt.Println(a) // prints [-1 -2 -3 -4 -5]

  12. }

在這個例子裡,a 作為形參 s 的引數傳進了 negate 函式,這個函式遍歷 s 內的元素並改變其符號。儘管 nagate 沒有傳回值,且沒有訪問到 main 函式裡的 a。但是當將之傳進 negate 函式內時,a 裡面的值卻被改變了。

大多數程式員都能直觀地瞭解 Go 語言切片的底層陣列是如何工作的,因為它與其它語言中類似陣列的工作方式類似。比如下麵就是使用 Python 重寫的這一小節的第一個示例:

  1. Python 2.7.10 (default, Feb  7 2017, 00:08:15)

  2. [GCC 4.2.1 Compatible Apple LLVM 8.0.0 (clang-800.0.34)] on darwin

  3. Type "help", "copyright", "credits" or "license" for more information.

  4. >>> a = [1,2,3,4,5]

  5. >>> b = a

  6. >>> b[2] = 0

  7. >>> a

  8. [1, 2, 0, 4, 5]

以及使用 Ruby 重寫的版本:

  1. irb(main):001:0> a = [1,2,3,4,5]

  2. => [1, 2, 3, 4, 5]

  3. irb(main):002:0> b = a

  4. => [1, 2, 3, 4, 5]

  5. irb(main):003:0> b[2] = 0

  6. => 0

  7. irb(main):004:0> a

  8. => [1, 2, 0, 4, 5]

在大多數將陣列視為物件或者是取用型別的語言也是如此。4

切片頭

切片同時擁有值和指標特性的神奇之處在於理解切片實際上是一個結構體struct型別。通常在反射reflect包內相應部分之後[1]的這個結構體被稱作切片頭slice essay-header。切片頭的定義大致如下:

  1. package runtime

  2. type slice struct {

  3.        ptr   unsafe.Pointer

  4.        len   int

  5.        cap   int

  6. }

這很重要,因為和 map 以及 chan 這兩個型別不同[1],切片是值型別,當被賦值或者被作為引數傳入函式時候會被覆制過去。

程式員們都能理解 square 的形參 v 和 main 中宣告的 v 的是相互獨立的。請看下麵的例子:

  1. package main

  2. import "fmt"

  3. func square(v int) {

  4.        v = v * v

  5. }

  6. func main() {

  7.        v := 3

  8.        square(v)

  9.        fmt.Println(v) // prints 3, not 9

  10. }

因此 square 對自己的形參 v 的操作沒有影響到 main 中的 v。下麵這個示例中的 s也是 main 中宣告的切片 s 的獨立副本, 而不是 指向 main 的 s 的指標。

  1. package main

  2. import "fmt"

  3. func double(s []int) {

  4.        s = append(s, s...)

  5. }

  6. func main() {

  7.        s := []int{1, 2, 3}

  8.        double(s)

  9.        fmt.Println(s, len(s)) // prints [1 2 3] 3

  10. }

Go 的切片是作為值傳遞而不是指標這一點不太尋常。當你在 Go 內定義一個結構體時,90% 的時間裡傳遞的都是這個結構體的指標5 。切片的傳遞方式真的很不尋常,我能想到的唯一與之相同的例子只有 time.Time

切片作為值傳遞而不是作為指標傳遞這一特殊行為會讓很多想要理解切片的工作原理的 Go 程式員感到困惑。你只需要記住,當你對切片進行賦值、取切片、傳參或者作為傳回值等操作時,你是在複製切片頭結構的三個欄位:指向底層陣列的指標、長度,以及容量。

總結

我們來用引出這一話題的切片作為棧的例子來總結下本文的內容:

  1. package main

  2. import "fmt"

  3. func f(s []string, level int) {

  4.        if level > 5 {

  5.               return

  6.        }

  7.        s = append(s, fmt.Sprint(level))

  8.        f(s, level+1)

  9.        fmt.Println("level:", level, "slice:", s)

  10. }

  11. func main() {

  12.        f(nil, 0)

  13. }

在 main 函式的最開始我們把一個 nil 切片傳給了函式 f 作為 level 0 。在函式 f裡我們把當前的 level 新增到切片的後面,之後增加 level 的值併進行遞迴。一旦 level 大於 5,函式傳回,打印出當前的 level 以及它們複製到的 s 的內容。

  1. level: 5 slice: [0 1 2 3 4 5]

  2. level: 4 slice: [0 1 2 3 4]

  3. level: 3 slice: [0 1 2 3]

  4. level: 2 slice: [0 1 2]

  5. level: 1 slice: [0 1]

  6. level: 0 slice: [0]

你可以註意到在每一個 level 內 s 的值沒有被別的 f 的呼叫影響,儘管當計算更高的 level 時作為 append 的副產品,呼叫棧內的四個 f 函式建立了四個底層陣列6 ,但是沒有影響到當前各自的切片。

擴充套件閱讀

如果你想要瞭解更多 Go 語言內切片執行的原理,我建議看看 Go 部落格裡的這些文章:

◈ Go Slices: usage and internals[2] (blog.golang.org)
◈ Arrays, slices (and strings): The mechanics of 'append'[3] (blog.golang.org)

相關文章:

1. If a map isn't a reference variable, what is it?[4]
2. What is the zero value, and why is it useful?[5]
3. The empty struct[6]
4. Should methods be declared on T or *T[7]


1. 這不是陣列才有的特性,在 Go 語言裡中 一切 賦值都是複製過去的。 ↩
2. 你也可以在對陣列使用 len 函式,但是其結果本來就人盡皆知。 ↩
3. 有時也叫做後臺陣列backing array,以及更不嚴謹的說法是後臺切片。 ↩
4. Go 語言裡我們傾向於說值型別以及指標型別,因為 C++ 的取用reference型別這個詞產生誤會。但在這裡我認為呼叫陣列作為取用型別是沒有問題的。 ↩
5. 如果你的結構體有定義在其上的方法或者用於滿足某個介面[1],那麼你傳入結構體指標的比率可以飆升到接近 100%。 ↩
6. 證明留做習題。 ↩

via: https://dave.cheney.net/2018/07/12/slices-from-the-ground-up 

作者:Dave Cheney[9] 譯者:name1e5s 校對:pityonline

本文由 LCTT 原創編譯,Linux中國 榮譽推出

贊(0)

分享創造快樂

© 2024 知識星球   網站地圖