【譯】Go 語言:深入探究 array 與 slice

你好,我是小四,你情商高,也可以叫我四哥~

這篇文章我們將討論 Go 語言中數組與切片 (slice),深入探究它們的內部結構以及爲什麼它們表現不一樣,即使它們能做類似的事情。

我們將從以下幾個方面討論數組和切片的表現差異:

默認值和零值

Go 語言中,聲明變量時如果沒有顯式地賦值初始化,該變量將會自動地設置成對應類型的零值。零值是在聲明但未顯式初始化變量時,分配給變量的特定類型對應的默認值。例如,如果像下面這樣聲明一個 int 型變量:

var x int

x 的初始值爲 0。

不同類型的零值如下所示:

Go 語言中數組的零值是一個數組,所有元素的值是對應類型的零值。例如,如果你有一個整型數組:

var arr [5]int

數組的零值是:

[0, 0, 0, 0, 0]

類似的,如果有一個 string 數組:

var arr [5]string

數組的零值爲:

[""""""""""]

Go 語言裏,切片的零值是 nil,是長度和容量爲 0、底層沒有對應數組的切片。例如:

var slice []int
fmt.Println(slice == nil) // => true

聲明、初始化數組

Go 中聲明數組的語法是:var name [L]T,var 是 Go 語言聲明變量的關鍵字,name 是變量名稱 (需要符合變量命名要求),L 是數組的長度 (必須是常量),T 是數組元素的類型。

//Array of 5 Intergers
var nums [5]int
fmt.Println(nums) // =[0 0 0 0 0]

//Array of 10 strings
var strs [10]string
fmt.Println(nums) // =[         ]

// Nested arrays 多維數組
var nested = [3][5]int{
    {1, 2, 3, 4, 5},
    {6, 7, 8, 9, 10},
    {11, 12, 13, 13, 15},
}
fmt.Println(nested) // =[[1 2 3 4 5] [6 7 8 9 10] [11 12 13 13 15]]

數組初始化可以簡單地理解爲是爲變量賦值,格式爲 var name = [L]T{...},其中 ... 表示 T 類型的數組元素。

//Intializing an array containing 10 intergers
var nums = [10]int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
fmt.Println(nums) // =[1 2 3 4 5 6 7 8 9 10]

//Intializing an array containing 10 strings
var strs = [10]string{"one""two""three""four""five""six""seven""eight""nine""ten"}
fmt.Println(strs) // =[one two three four five six seven eight nine ten]

//Nested arrays
var nested = [3][2]int{}

你也可以創建一個結構體類型的數組:

type Car struct {
    Brand string
    Color string
    Price float32
}

//Array of 5 items of type Car
var arrayOfCars = [5]Car{
    {Brand: "Porsche", Color: "Black", Price: 20_000.00},
    {Brand: "Volvo", Color: "White", Price: 8_000.00},
    {Brand: "Honda", Color: "Blue", Price: 7_000.00},
    {Brand: "Tesla", Color: "Black", Price: 50_000.00},
    {Brand: "Kia", Color: "Red", Price: 5_000.98},
}
fmt.Println(arrayOfCars) // =[{Porsche Black 20000} {Volvo White 8000} {Honda Blue 7000} {Tesla Black 50000} {Kia Red 5000.98}]

如果想要創建具有不同類型元素的數組,可以使用 interface{} 類型。接口是 Go 的一種類型,它定義了其他類型必須實現一組方法。任何實現接口中列出的所有方法的類型,都被認爲是實現了該接口,認爲是該接口類型。特殊的接口類型 interface{} 沒有定義方法,意味着所有的類型都實現了該接口。

package main

import "fmt"

func main() {
    // 數組包含不同的類型
    var randomsArray = [5]interface{}{"Hello world!", 35, false, 33.33, 'A'}
    fmt.Println(randomsArray) // =[Hello world! 35 false 33.33 65]
}

初始化數組的其他方式:

import "fmt"

func main() {
    // 使用短變量聲明方式
    cars := [3]string{"Tesla""Ferrari""Benz"}
    fmt.Println(cars) // =[Tesla Ferrari Benz]

    // 使用 ... 代替數組長度
    digits := [...]int{10, 20, 30, 40}
    fmt.Println(digits) // =[10 20 30 40]

    // 使用 len 關鍵字
    countries := [len(digits)]string{"China""India""Kenya"}
    fmt.Println(countries) // =[China India Kenya]
}

注意,聲明全局變量時不能使用短變量聲明的方式::=

聲明和初始化切片

聲明切片的語法是:var name []int,這種方式與聲明數組唯一區別是聲明切片是可以忽略長度。

例如:

import "fmt"

func main() {
    // 整型切片
    var intSlice []int
    fmt.Println(intSlice) // =[]

    // 字符串切片
    var stringSlice []string
    fmt.Println(stringSlice) // =[]
}

Go 語言裏面可以使用 make() 函數初始化切片,該函數有三個參數:切片元素類型、切片長度和切片容量 (可以忽略),語法是:make([]T, len, cap)

例如,想要創建長度爲 5、容量爲 10 的整型切片可以使用如下代碼:

package main

import "fmt"

func main() {
    // With capacity
    slice1 := make([]int, 5, 10)
    fmt.Println(len(slice1), cap(slice1)) // => 5 10

    // Without capacity
    slice2 := make([]int, 5)
    fmt.Println(len(slice2), cap(slice2)) // => 5 5
}

如果初始化的時候忽略了容量,則容量與長度時一樣的。

除了 make() 函數之外,也可以通過賦值操作直接初始化切片。

slice := []int{1, 2, 3}
fmt.Println(len(slice), cap(slice)) // => 3 3

數組和切片的值部分

這是 Go 中數組與切片最重要的區別,數組只包含一部分而切片由直接和間接兩部分組成。這意味着,數組是固定長度的數據結構,由存儲元素的連續內存塊組成。切片是動態大小,並引用底層數組的連續內存塊。

爲了更好地理解,通過下面示例看看數組和切片的值部分。

var arr = [5]int{1,2,3,4,5}
var slice = []int{1,2,3,4,5}

數組:

切片:

從上圖我們可以得出,數組是相同類型元素的固定大小集合、存儲在連續的內存塊中,另外一方面,切片由指向底層數組的指針、長度和容量組成。

切片的直接部分的內部結構:

type _slice struct {
    // referencing underlying elements
    elements unsafe.Pointer
    // number of elements
    len int
    // capacity of the slice
        cap int
}

當拷貝數組和切片時,發生了什麼?

在 Go 中,賦值時底層值不會被拷貝,只有直接值會被拷貝。這意味着,當我們拷貝數組時,將會得到一份值得副本。而當拷貝切片時,我們將會拷貝它的直接部分,比如長度、容量和指向底層數組的指針。

數組拷貝示例:

x := [5]int{3, 6, 9, 12, 15}
y := v

上面的例子中,我們初始化了數組 x,接着通過賦值的方式創建了變量 y,它是 x 的副本。

當我們複製一個數組時,所有元素將會被拷貝到另一個單獨的內存塊中。在上面的代碼中,加入我們修改 x 並不會影響到 y,反之亦然。我們稍後會詳細討論。

切片拷貝示例:

x := []int{2,4,6,8,10}
y := x

上面的代碼中,我們初始化了一個切片 x,接着創建了另一個切片 y,並將 x 賦給 y。

從上圖可以看出,x 的直接部分被拷貝到 y 對應的內存中,但是 x 和 y 共享底層數組,所以當修改 x 的元素時也會影響到 y。但是 x 和 y 可以有不同的長度和容量,因爲它們存儲在各自單獨的內存中。這塊我們將在後面詳細討論。

操作數組和切片

在本節中,我們將討論操作數組和切片。

數組

因爲 Go 中的數組長度是固定的,所以唯一可以對數組進行的操作是更改數組的元素值。

示例:

package main

import "fmt"

func main() {
    var fruits [6]string // 聲明字符串數組(默認零值)
    fmt.Println(fruits)  // =[       ] (字符串零值 "")

    // 🍊 修改索引爲 0 的元素值
    fruits[0] = "Orange"
    fmt.Println(fruits) // =[Orange      ]

    //🍋  修改最後面一個元素值
    fruits[5] = "Lemon"
    fmt.Println(fruits) // =[Orange     Lemon]

    // 修改所有元素
    fruits[1] = "Banana"
    fruits[2] = "Watermelon"
    fruits[3] = "Pear"
    fruits[4] = "Apple"
    fmt.Println(fruits) // =[Orange Banana Watermelon Pear Apple Lemon]

    // 再次修改
    fruits[0] = "Pineapple"
    fmt.Println(fruits) // =[Pineapple Banana Watermelon Pear Apple Lemon]

    // 修改整型數組
    evenNumbers := [5]int{2, 4, 6, 8, 10}

    evenNumbers[0] = 12
    fmt.Println(evenNumbers) // =[12 4 6 8 10]

    evenNumbers[3] = 20
    fmt.Println(evenNumbers) // =[12 4 6 20 10]

}

訪問數組值:

import "fmt"

func main() {
    nums := [7]int{1, 2, 3, 4, 5, 6, 7}

    // 獲取第一個元素
    first := nums[0]
    fmt.Println(first) // =1

    // 獲取第三個元素
    fmt.Println(nums[2]) // =3

    // 獲取最後一個元素
    fmt.Println(nums[6]) // =7

    // 或者使用下面這種方式獲取最後一個元素
    fmt.Println(nums[len(nums)-1]) // =7

}

如果被修改元素的索引值大於等於數組長度,將會報 panic 錯誤。

package main

import "fmt"

func main() {
    nums := [7]int{1, 2, 3, 4, 5, 6, 7}
    outOfBound := nums[7]
}
invalid argument: array index 7 out of bounds [0:6]

切片

切片是 Go 中非常有用的數據類型,它提供了一種靈活方便的方式來操作數據集合。它可以像數組一樣被訪問和修改,但也有一些特殊的用法,使得切片更強大。下面我們將更詳細地探討其中一些用法。

切片表達式

切片表達式的簽名:s[start:end:cap]

基於切片 s 創建一個新的切片 (可以包括原切片的所有元素),包含的元素從索引 start 處開始,到但不包括 end 索引處的元素,cap 是新創建子切片的容量,是可選的。如果 cap 省略,子切片的容量等於其長度。子切片的長度計算公式:end - start。

示例:

package main

import "fmt"

func main() {
    slice := []int{1, 2, 3, 4, 5, 6}
    subSlice := slice[1:4]
    fmt.Println(subSlice)                     // =[2 3 4]
    fmt.Println(len(subSlice), cap(subSlice)) // => 3 3

    subSliceWithCap := slice[1:4:5]
    fmt.Println(subSliceWithCap)                            // =[2 3 4]
    fmt.Println(len(subSliceWithCap), cap(subSliceWithCap)) // => 3 4
}

如果開始索引爲零,則可以省略,例如: s[:end],同樣,如果結束索引是數組的結尾,則也可以省略它,例如:s[start:]。

package main

import "fmt"

func main() {
    s := []string{"g""o"" ""i""s"" ""s""w""e""e""t"}

    // 複製索引 0 到 2 的元素(不包括索引 2 的元素)
    goSubSlice := s[:2]
    fmt.Println(goSubSlice) // =[g o]

    // 複製從索引 3 開始的所有元素
    isSweetSubSlice := s[3:]
    fmt.Println(isSweetSubSlice) // =[i s   s w e e t]

    // 複製所有元素
    copySlice := s[:]
    fmt.Println(copySlice) // =[g o  i s   s w e e t]
}

之前我們討論了切片值的部分以及只有切片的直接部分會被複制,那麼當我們創建一個子切片時實際上會發生什麼?

我們通過示例來說明:

package main

import "fmt"

func main() {
    n := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
    n1 := n[:6]
    n2 := n[3:8]
    n3 := n[4:10]

    fmt.Println(n1, len(n1), cap(n1)) // =[1 2 3 4 5 6]  6 10
    fmt.Println(n2, len(n2), cap(n2)) // =[4 5 6 7 8]  5 7
    fmt.Println(n3, len(n3), cap(n3)) // =[5 6 7 8 9 10] 6 6

    // 將索引 4 的元素值改爲 15
    n1[4] = 15

    fmt.Println(n)  // =[1 2 3 4 15 6 7 8 9 10]
    fmt.Println(n1) // =[1 2 3 4 15 6]
    fmt.Println(n2) // =[4 15 6 7 8]
    fmt.Println(n3) // =[15 6 7 8 9 10]
}

注意,當我們將 n1[4] 更改爲 15 時,它會影響所有其他子切片,包括主切片。這是因爲它們共享相同的底層數組元素,因此每當我們對子切片進行更改時,它會影響所有其他子切片。

下面這張圖可以幫助我們理解上面的代碼:

從上圖可以看出,所有的子切片共享相同的底層數組,但是各自包括的元素不同。當我們修改某一索引處的元素時,包含該元素的切片也會被修改。

當你修改 n3 索引 4 的元素時,數組 n 索引 8 的元素也會被修改,因爲 n3 和 n 共享底層數組。同樣,對於 n 中索引 4 到 9(包括 9)之間的元素所做的任何更改也會影響到 n3,因爲 n3 包含這些元素。

n3[4] = 18
fmt.Println(n)  // =[1 2 3 4 15 6 7 8 18 10]
fmt.Println(n1) // =[1 2 3 4 15 6]
fmt.Println(n2) // =[4 15 6 7 8]
fmt.Println(n3) // =[15 6 7 8 18 10]
切片追加元素

Go 的 append() 函數允許我們向切片末尾添加元素,語法如下:

func append([]T, x ...T) []T

s 是要追加的切片,x 是要追加的一個或多個 T 類型元素的列表,函數將返回一個包含追加元素的新切片。

示例:

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

注意,如果底層數組的容量不足以容納附加的元素,則 append() 函數將分配一個新的、更大容量的數組保存結果。如果 append 操作之後創建了一個更大的數組,則新切片將不在與原來的子切片共享相同的底層數組。

我們來看一個例子:

package main

import "fmt"

func main() {
    n := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
    n1 := n[:6]
    n2 := n[3:8]
    n3 := n[4:10]

    fmt.Println(n1, len(n1), cap(n1)) // =[1 2 3 4 5 6]  6 10
    fmt.Println(n2, len(n2), cap(n2)) // =[4 5 6 7 8]  5 7
    fmt.Println(n3, len(n3), cap(n3)) // =[5 6 7 8 9 10] 6 6

}

上面的代碼,n2 的長度 5、容量爲 7,這意味着在不創建新的底層數組的情況下,還可以再多追加兩個元素,並且和其他子切片共享相同的底層數組。

n2 = append(n2, 100)
fmt.Println(n)  // =[1 2 3 4 15 6 7 8 100 10]
fmt.Println(n1) // =[1 2 3 4 15 6]
fmt.Println(n2) // =[4 15 6 7 8, 100]
fmt.Println(n3) // =[15 6 7 8 100 10]

n2 = append(n2, 101)
fmt.Println(n)  // =[1 2 3 4 15 6 7 8 100 101]
fmt.Println(n1) // =[1 2 3 4 15 6]
fmt.Println(n2) // =[4 15 6 7 8 100 101]
fmt.Println(n3) // =[15 6 7 8 100 101]

// Check the capacity and length of n2
fmt.Println(cap(n2), len(n2)) // =>  7 7

當我們追加更多元素時,它會影響到 n 和 n3,但現在 n2 的容量已經等於它的長度,因此追加一個新的元素將會爲 n2 創建一個新的數組,它將不再與其他子切片共享相同的底層數組。

n2 = append(n2, 102)
fmt.Println(n)  // =[1 2 3 4 15 6 7 8 100 101]
fmt.Println(n1) // =[1 2 3 4 15 6]
fmt.Println(n2) // =[4 15 6 7 8 100 101 102]
fmt.Println(n3) // =[15 6 7 8 100 101]

從上面的代碼可以看出只有 n2 被修改了,其他的切片沒有受到影響。

追加多個元素

示例:

package main

import "fmt"

func main() {
    s := []int{10, 20, 30, 40, 50, 60}
    s2 := []int{70, 80, 90}

        // 往一個切片追加另一切片
    s = append(s, s2...)
    fmt.Println(s) // =[10 20 30 40 50 60 70 80 90]

        // 追加多個元素
    s = append(s, 100, 110, 120)
    fmt.Println(s) // =[10 20 30 40 50 60 70 80 90 100 110 120]
}
深度拷貝切片

深度拷貝是指拷貝切片的底層數組而不是直接部分,因此目標切片不會與源切片共享相同的底層數組。

使用 append() 實現深度拷貝:

package main

import (
    "fmt"
)

func main() {
    slice1 := []int{1, 2, 3, 4, 5, 6}
    slice2 := []int{}
    slice2 = append(slice2, slice1...)

    fmt.Println(slice1) // =[1 2 3 4 5 6]
    fmt.Println(slice2) // =[1 2 3 4 5 6]

    // 修改 slice2 不會影響到 slice1
    slice2[0] = 100
    fmt.Println(slice1) // =[1 2 3 4 5 6]
    fmt.Println(slice2) // =[100 2 3 4 5 6]

    // 拷貝一定範圍內的值
    slice3 := []int{}
    slice3 = append(slice3, slice1[3:5]...)
    fmt.Println(slice3) // =[4 5]

    // slice3 與 slice1 不會共享底層數組
    slice3[0] = -10
    fmt.Println(slice1) // =[1 2 3 4 5 6]
    fmt.Println(slice3) // =[-10 5]

}

Go 中,可以使用 copy() 函數對切片進行深度拷貝。深度拷貝會創建一個新的切片,並拷貝原始切片的元素,新的切片將擁有自己獨立的元素副本。

語法如下:

func copy(dst, src []T) int

dst 是目標切片,src 是源切片。兩個切片必須有相同的元素類型 T。該函數返回複製的元素數量,取 dst 和 src 長度的最小值。

示例:

package main

import "fmt"

func main() {
    s := []int{1, 2, 3}
    t := make([]int, len(s))
    copy(t, s)
    fmt.Println(t) // =[1, 2, 3], and is a deep copy of s

    t = make([]int, len(s)-1)
    copy(t, s[0:2])
    fmt.Println(t) // =[1, 2], and is a deep copy of s

}

注意,如果目標切片 dst 的長度小於源切片 src 的長度,則只會複製 src 的前 len(dst) 個元素。要深度複製整個切片,必須確保 dst 具有足夠的容量以容納 src 的所有元素。

切片的潛在陷阱

正如之前提到過,子切片與原切片共享底層數組。因此,當我們從一個大小爲 10MB 的切片 sBig 創建一個大小爲 3 字節的子切片 sTiny 時,sTiny 和 sBig 將引用相同的底層數組。你可能知道 Go 是通過垃圾回收機制來自動釋放不再被引用的內存的。因此在這種情況下,即使我們只需要 3 個字節的 sTiny,sBig 仍將繼續存在於內存中,因爲 sTiny 引用了與 sBig 相同的底層數組。爲了解決這個問題,我們可以進行深度複製,這樣 sTiny 不會與 sBig 共享相同的底層數組,因此它可以被垃圾回收,從而釋放內存。

var gopherRegexp = regexp.MustCompile("gopher")

func FindGopher(filename string) []byte {
    // 讀取大文件  1,000,000,000 bytes (1GB)
    b, _ := ioutil.ReadFile(filename)
    // 只取一個 6 字節的子切片
    gopherSlice := gopherRegexp.Find(b)
    return gopherSlice
}

上面的示例中,我們讀取了一個非常大的文件(1GB)並返回了它的一個子切片(僅 6 個字節)。由於 gopherSlice 仍然引用與大文件相同的底層數組,這意味着 1GB 的內存即使我們不再使用它也無法被垃圾回收。如果多次調用 FindGopher 函數,則程序可能會耗盡計算機的所有內存。爲了解決這個問題,就像之前說過的一樣,我們可以進行深度複製,這樣 gopherSlice 就不會再與巨大的切片共享底層數組。

var gopherRegexp = regexp.MustCompile("gopher")

func FindGopher(filename string) []byte {
    // 讀取大文件  1,000,000,000 bytes (1GB)
    b, _ := ioutil.ReadFile(filename)
    // 只取一個 6 字節的子切片
    gopherSlice := make([]byte, len("gopher"))

    // 深度拷貝
    copy(gopherSlice, gopherRegexp.Find(b...)
    return gopherSlice
}

這樣寫的話,Go 的垃圾回收器可以釋放大約 1G 的內存。

優化代碼性能的技巧

就像我之前提到的,Go 中數組和切片最重要的區別在於他們值部分不同,再加上 Go 複製時的成本,這是它們性能差異的原因。

值拷貝開銷

值分配、參數傳遞、使用 range 關鍵字循環等,都涉及值拷貝。值大小越大,拷貝的代價就越大,複製 10M 字節所需的時間將比複製 10 字節的時間更長。而拷貝切片時只有直接部分會被複制。

示例:

array := [100]int{1,2,3,4,5,6, ..., 100}
slice := []int{1,2,3,4,5,6, ..., 100}

在上面的例子,我們創建了一個包含 1-100 數字的數組和一個包含 1-100 數字的切片。當我們拷貝數組時,所有元素都被複制,因此值拷貝的代價將是 8 * 100 = 800 字節(64 位架構中 1 個 int 佔用 8 字節),但是當我們複製切片時,只有直接部分被複制(長度,容量和元素指針),因此值拷貝的代價將是 8 + 8 + 8 = 24 字節。儘管切片和數組都包含 100 個元素,但數組的值複製代價比切片的要大得多。

從上面情況來看,如果有這方面性能問題,首先考慮是數組導致的而不是切片。我將專注於如何在考慮性能的情況下使用數組。此外,對於小數組,與切片相比,拷貝的時候性能差異微不足道,就無需再費精力考慮如何優化了。

不要使用 range 關鍵字來遍歷數組,像下面這樣:

package main

import "fmt"

func main() {
    // Don'do this
    arr := [10]int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}

    // arr is copied
    for key, value := range arr {
        fmt.Println(key, value)
    }

    // Do this instead
    for i := 0; i < len(arr); i++ {
        fmt.Println(i, arr[i])
    }
}

via: https://dev.to/dawkaka/go-arrays-and-slices-a-deep-dive-dp8
作者:Yussif Mohammed



本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/uTdhLMfXG-AF21PhKqTpww