深入探究 Go 中的 array 与 slice
2023-2-20 12:4:44 Author: Go语言中文网(查看原文) 阅读量:14 收藏

这篇文章我们将讨论 Go 语言中数组与切片(slice),深入探究它们的内部结构以及为什么它们表现不一样,即使它们能做类似的事情。

我们将从以下几个方面讨论数组和切片的表现差异:

  • 默认值和零值
  • 声明和初始化数组和切片
  • 数组和切片的值部分
  • 操作数组和切片
  • 关于切片的潜在陷阱
  • 代码优化的小技巧

默认值和零值

Go 语言中,声明变量时如果没有显式地赋值初始化,该变量将会自动地设置成对应类型的零值。零值是在声明但未显式初始化变量时,分配给变量的特定类型对应的默认值。例如,如果像下面这样声明一个 int 型变量:

var x int

x 的初始值为 0。

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

  • int: 0
  • float: 0.0
  • bool: false
  • string: ""
  • pointer: nil
  • struct: 所有字段对应的类型零值

Go 语言中数组的零值是一个数组,所有元素的值是对应类型的零值。例如,如果你有一个整型数组:

var arr [5]int

数组的零值是:

[00000]

类似的,如果有一个 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{
    {12345},
    {678910},
    {1112131315},
}
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{12345678910}
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!"35false33.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{10203040}
    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([]int510)
    fmt.Println(len(slice1), cap(slice1)) // => 5 10

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

如果初始化的时候忽略了容量,则容量与长度时一样的。

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

slice := []int{123}
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{3691215}
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{246810}

    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{1234567}

    // 获取第一个元素
    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{1234567}
    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{123456}
    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{12345678910}
    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(s []T, x ...T) []T

s 是要追加的切片,x 是要追加的一个或多个 T 类型元素的列表,函数将返回一个包含追加元素的新切片。

示例:

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

注意,如果底层数组的容量不足以容纳附加的元素,则 append() 函数将分配一个新的、更大容量的数组保存结果。如果 append 操作之后创建了一个更大的数组,则新切片将不在与原来的子切片共享相同的底层数组。

我们来看一个例子:

package main

import "fmt"

func main() {
    n := []int{12345678910}
    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{102030405060}
    s2 := []int{708090}

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

        // 追加多个元素
    s = append(s, 100110120)
    fmt.Println(s) // => [10 20 30 40 50 60 70 80 90 100 110 120]
}

深度拷贝切片

深度拷贝是指拷贝切片的底层数组而不是直接部分,因此目标切片不会与源切片共享相同的底层数组。

使用 append() 实现深度拷贝:

package main

import (
    "fmt"
)

func main() {
    slice1 := []int{123456}
    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{123}
    t := make([]intlen(s))
    copy(t, s)
    fmt.Println(t) // => [1, 2, 3], and is a deep copy of s

    t = make([]intlen(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([]bytelen("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't do this
    arr := [10]int{12345678910}

    // 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


推荐阅读

福利
我为大家整理了一份从入门到进阶的Go学习资料礼包,包含学习建议:入门看什么,进阶看什么。关注公众号 「polarisxu」,回复 ebook 获取;还可以回复「进群」,和数万 Gopher 交流学习。


文章来源: http://mp.weixin.qq.com/s?__biz=MzAxMTA4Njc0OQ==&mid=2651454098&idx=1&sn=df116be99c06309d8500579f9cd5dc61&chksm=80bb2460b7ccad7627063eec34fbcf3888112af1146a9c34980e28111228995d9774a72b3198#rd
如有侵权请联系:admin#unsafe.sh