标签 切片 下的文章

了解使用数组和切片在 Go 中存储数据的优缺点,以及为什么其中一个更好。

在本系列的第四篇文章中,我将解释 Go 数组和切片,包括如何使用它们,以及为什么你通常要选择其中一个而不是另一个。

数组

数组是编程语言中最流行的数据结构之一,主要原因有两个:一是简单易懂,二是可以存储许多不同类型的数据。

你可以声明一个名为 anArray 的 Go 数组,该数组存储四个整数,如下所示:

anArray := [4]int{-1, 2, 0, -4}

数组的大小应该在它的类型之前声明,而类型应该在声明元素之前定义。len() 函数可以帮助你得到任何数组的长度。上面数组的大小是 4。

如果你熟悉其他编程语言,你可能会尝试使用 for 循环来遍历数组。Go 当然也支持 for 循环,不过,正如你将在下面看到的,Go 的 range 关键字可以让你更优雅地遍历数组或切片。

最后,你也可以定义一个二维数组,如下:

twoD := [3][3]int{
  {1, 2, 3},
  {6, 7, 8},
  {10, 11, 12}}

arrays.go 源文件中包含了 Go 数组的示例代码。其中最重要的部分是:

for i := 0; i < len(twoD); i++ {
  k := twoD[i]
  for j := 0; j < len(k); j++ {
    fmt.Print(k[j], " ")
  }
  fmt.Println()
}

for _, a := range twoD {
  for _, j := range a {
    fmt.Print(j, " ")
  }
  fmt.Println()
}

通过上述代码,我们知道了如何使用 for 循环和 range 关键字迭代数组的元素。arrays.go 的其余代码则展示了如何将数组作为参数传递给函数。

以下是 arrays.go 的输出:

$ go run arrays.go
Before change(): [-1 2 0 -4]
After change(): [-1 2 0 -4]
1 2 3
6 7 8
10 11 12
1 2 3
6 7 8
10 11 12

这个输出告诉我们:对函数内的数组所做的更改,会在函数退出后丢失。

数组的缺点

Go 数组有很多缺点,你应该重新考虑是否要在 Go 项目中使用它们。

首先,数组定义之后,大小就无法改变,这意味着 Go 数组不是动态的。简而言之,如果你需要将一个元素添加到一个没有剩余空间的数组中,你将需要创建一个更大的数组,并将旧数组的所有元素复制到新数组中。

其次,当你将数组作为参数传递给函数时,实际上是传递了数组的副本,这意味着你对函数内部的数组所做的任何更改,都将在函数退出后丢失。

最后,将大数组传递给函数可能会很慢,主要是因为 Go 必须创建数组的副本。

以上这些问题的解决方案,就是使用 Go 切片。

切片

Go 切片与 Go 数组类似,但是它没有后者的缺点。

首先,你可以使用 append() 函数将元素添加到现有切片中。此外,Go 切片在内部使用数组实现,这意味着 Go 中每个切片都有一个底层数组。

切片具有 capacity 属性和 length 属性,它们并不总是相同的。切片的长度与元素个数相同的数组的长度相同,可以使用 len() 函数得到。切片的容量是当前为切片分配的空间,可以使用 cap() 函数得到。

由于切片的大小是动态的,如果切片空间不足(也就是说,当你尝试再向切片中添加一个元素时,底层数组的长度恰好与容量相等),Go 会自动将它的当前容量加倍,使其空间能够容纳更多元素,然后将请求的元素添加到底层数组中。

此外,切片是通过引用传递给函数的,这意味着实际传递给函数的是切片变量的内存地址,这样一来,你对函数内部的切片所做的任何修改,都不会在函数退出后丢失。因此,将大切片传递给函数,要比将具有相同数量元素的数组传递给同一函数快得多。这是因为 Go 不必拷贝切片 —— 它只需传递切片变量的内存地址。

slice.go 源文件中有 Go 切片的代码示例,其中包含以下代码:

package main

import (
  "fmt"
)

func negative(x []int) {
  for i, k := range x {
    x[i] = -k
  }
}

func printSlice(x []int) {
  for _, number := range x {
    fmt.Printf("%d ", number)
  }
  fmt.Println()
}

func main() {
  s := []int{0, 14, 5, 0, 7, 19}
  printSlice(s)
  negative(s)
  printSlice(s)

  fmt.Printf("Before. Cap: %d, length: %d\n", cap(s), len(s))
  s = append(s, -100)
  fmt.Printf("After. Cap: %d, length: %d\n", cap(s), len(s))
  printSlice(s)

  anotherSlice := make([]int, 4)
  fmt.Printf("A new slice with 4 elements: ")
  printSlice(anotherSlice)
}

切片和数组在定义方式上的最大区别就在于:你不需要指定切片的大小。实际上,切片的大小取决于你要放入其中的元素数量。此外,append() 函数允许你将元素添加到现有切片 —— 请注意,即使切片的容量允许你将元素添加到该切片,它的长度也不会被修改,除非你调用 append()。上述代码中的 printSlice() 函数是一个辅助函数,用于打印切片中的所有元素,而 negative() 函数将切片中的每个元素都变为各自的相反数。

运行 slice.go 将得到以下输出:

$ go run slice.go
0 14 5 0 7 19
0 -14 -5 0 -7 -19
Before. Cap: 6, length: 6
After. Cap: 12, length: 7
0 -14 -5 0 -7 -19 -100
A new slice with 4 elements: 0 0 0 0

请注意,当你创建一个新切片,并为给定数量的元素分配内存空间时,Go 会自动地将所有元素都初始化为其类型的零值,在本例中为 0(int 类型的零值)。

使用切片来引用数组

Go 允许你使用 [:] 语法,使用切片来引用现有的数组。在这种情况下,你对切片所做的任何更改都将传播到数组中 —— 详见 refArray.go。请记住,使用 [:] 不会创建数组的副本,它只是对数组的引用。

refArray.go 中最有趣的部分是:

func main() {
  anArray := [5]int{-1, 2, -3, 4, -5}
  refAnArray := anArray[:]

  fmt.Println("Array:", anArray)
  printSlice(refAnArray)
  negative(refAnArray)
  fmt.Println("Array:", anArray)
}

运行 refArray.go,输出如下:

$ go run refArray.go
Array: [-1 2 -3 4 -5]
-1 2 -3 4 -5
Array: [1 -2 3 -4 5]

我们可以发现:对 anArray 数组的切片引用进行了操作后,它本身也被改变了。

总结

尽管 Go 提供了数组和切片两种类型,你很可能还是会使用切片,因为它们比 Go 数组更加通用、强大。只有少数情况需要使用数组而不是切片,特别是当你完全确定元素的数量固定不变时。

你可以在 GitHub 上找到 arrays.goslice.gorefArray.go 的源代码。

如果你有任何问题或反馈,请在下方发表评论或在 Twitter 上与我联系。


via: https://opensource.com/article/18/7/introduction-go-arrays-and-slices

作者:Mihalis Tsoukalos 选题:lkxed 译者:lkxed 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

这篇文章受到了我与同事讨论使用 切片 slice 作为 stack 的一次聊天的启发。后来话题聊到了 Go 语言中的切片是如何工作的。我认为这些信息对别人也有用,所以就把它记录了下来。

数组

任何关于 Go 语言切片的讨论都要从另一个数据结构也就是 数组 array 开始。Go 的数组有两个特性:

  1. 数组的长度是固定的;[5]int 是由 5 个 int 构成的数组,和 [3]int 不同。
  2. 数组是值类型。看下面这个示例:
package main

import "fmt"

func main() {
        var a [5]int
        b := a
        b[2] = 7
        fmt.Println(a, b) // prints [0 0 0 0 0] [0 0 7 0 0]
}

语句 b := a 定义了一个类型是 [5]int 的新变量 b,然后把 a 中的内容 复制b 中。改变 ba 中的内容没有影响,因为 ab 是相互独立的值。 1

切片

Go 语言的切片和数组的主要有如下两个区别:

  1. 切片没有一个固定的长度。切片的长度不是它类型定义的一部分,而是由切片内部自己维护的。我们可以使用内置的 len 函数知道它的长度。 2
  2. 将一个切片赋值给另一个切片时 不会 对切片内容进行复制操作。这是因为切片没有直接持有其内部数据,而是保留了一个指向 底层数组 3 的指针。数据都保留在底层数组里。

基于第二个特性,两个切片可以享有共同的底层数组。看下面的示例:

  1. 对切片取切片
package main

import "fmt"

func main() {
        var a = []int{1,2,3,4,5}
        b := a[2:]
        b[0] = 0
        fmt.Println(a, b) // prints [1 2 0 4 5] [0 4 5]
}

在这个例子里,ab 享有共同的底层数组 —— 尽管 b 在数组里的起始偏移量不同,两者的长度也不同。通过 b 修改底层数组的值也会导致 a 里的值的改变。

  1. 将切片传进函数
package main

import "fmt"

func negate(s []int) {
        for i := range s {
                s[i] = -s[i]
        }
}

func main() {
        var a = []int{1, 2, 3, 4, 5}
        negate(a)
        fmt.Println(a) // prints [-1 -2 -3 -4 -5]
}

在这个例子里,a 作为形参 s 的实参传进了 negate 函数,这个函数遍历 s 内的元素并改变其符号。尽管 nagate 没有返回值,且没有访问到 main 函数里的 a。但是当将之传进 negate 函数内时,a 里面的值却被改变了。

大多数程序员都能直观地了解 Go 语言切片的底层数组是如何工作的,因为它与其它语言中类似数组的工作方式类似。比如下面就是使用 Python 重写的这一小节的第一个示例:

Python 2.7.10 (default, Feb  7 2017, 00:08:15)
[GCC 4.2.1 Compatible Apple LLVM 8.0.0 (clang-800.0.34)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> a = [1,2,3,4,5]
>>> b = a
>>> b[2] = 0
>>> a
[1, 2, 0, 4, 5]

以及使用 Ruby 重写的版本:

irb(main):001:0> a = [1,2,3,4,5]
=> [1, 2, 3, 4, 5]
irb(main):002:0> b = a
=> [1, 2, 3, 4, 5]
irb(main):003:0> b[2] = 0
=> 0
irb(main):004:0> a
=> [1, 2, 0, 4, 5]

在大多数将数组视为对象或者是引用类型的语言也是如此。 4

切片头

切片同时拥有值和指针特性的神奇之处在于理解切片实际上是一个 结构体 struct 类型。通常在 反射 reflect 包内相应部分之后的这个结构体被称作 切片头 slice header 。切片头的定义大致如下:

package runtime

type slice struct {
        ptr   unsafe.Pointer
        len   int
        cap   int
}

这很重要,因为和 map 以及 chan 这两个类型不同,切片是值类型,当被赋值或者被作为参数传入函数时候会被复制过去。

程序员们都能理解 square 的形参 vmain 中声明的 v 的是相互独立的。请看下面的例子:

package main

import "fmt"

func square(v int) {
        v = v * v
}

func main() {
        v := 3
        square(v)
        fmt.Println(v) // prints 3, not 9
}

因此 square 对自己的形参 v 的操作没有影响到 main 中的 v。下面这个示例中的 s 也是 main 中声明的切片 s 的独立副本, 而不是 指向 mains 的指针。

package main

import "fmt"

func double(s []int) {
        s = append(s, s...)
}

func main() {
        s := []int{1, 2, 3}
        double(s)
        fmt.Println(s, len(s)) // prints [1 2 3] 3
}

Go 的切片是作为值传递而不是指针这一点不太寻常。当你在 Go 内定义一个结构体时,90% 的时间里传递的都是这个结构体的指针 5 。切片的传递方式真的很不寻常,我能想到的唯一与之相同的例子只有 time.Time

切片作为值传递而不是作为指针传递这一特殊行为会让很多想要理解切片的工作原理的 Go 程序员感到困惑。你只需要记住,当你对切片进行赋值、取切片、传参或者作为返回值等操作时,你是在复制切片头结构的三个字段:指向底层数组的指针、长度,以及容量。

总结

我们来用引出这一话题的切片作为栈的例子来总结下本文的内容:

package main

import "fmt"

func f(s []string, level int) {
        if level > 5 {
               return
        }
        s = append(s, fmt.Sprint(level))
        f(s, level+1)
        fmt.Println("level:", level, "slice:", s)
}

func main() {
        f(nil, 0)
}

main 函数的最开始我们把一个 nil 切片传给了函数 f 作为 level 0 。在函数 f 里我们把当前的 level 添加到切片的后面,之后增加 level 的值并进行递归。一旦 level 大于 5,函数返回,打印出当前的 level 以及它们复制到的 s 的内容。

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

你可以注意到在每一个 levels 的值没有被别的 f 的调用影响,尽管当计算更高的 level 时作为 append 的副产品,调用栈内的四个 f 函数创建了四个底层数组 6 ,但是没有影响到当前各自的切片。

扩展阅读

如果你想要了解更多 Go 语言内切片运行的原理,我建议看看 Go 博客里的这些文章:

相关文章:

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

  1. 这不是数组才有的特性,在 Go 语言里中 一切 赋值都是复制过去的。
  2. 你也可以在对数组使用 len 函数,但是其结果本来就人尽皆知。
  3. 有时也叫做 后台数组 backing array ,以及更不严谨的说法是后台切片。
  4. Go 语言里我们倾向于说值类型以及指针类型,因为 C++ 的 引用 reference 类型这个词产生误会。但在这里我认为调用数组作为引用类型是没有问题的。
  5. 如果你的结构体有定义在其上的方法或者用于满足某个接口,那么你传入结构体指针的比率可以飙升到接近 100%。
  6. 证明留做习题。

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

作者:Dave Cheney 译者:name1e5s 校对:pityonline

本文由 LCTT 原创编译,Linux中国 荣誉推出