Go语言之父详述切片与数组的不一样

0.jpeg
切片是Go 语言核心的数据结构,然而刚接触 Go 的程序员常常在切片的工做方式和行为表现上被绊倒。好比,明明说切片是引用类型但在函数内对其作的更改有时候却保留不下来,有时候却能够。究其缘由是由于咱们不少人用其余语言的思惟来尝试猜想 Go 语言中切片的行为,切片这个内置类型在 Go 语言底层有其单独的类型定义,而不是咱们一般理解的其余语言中数组的概念。php

文章翻译自罗伯·派克发布在 Go Blog 中的文章,文中详述了切片是如何被设计出来的以及其与数组的关联和区别,以及内置append函数的实现细节。虽篇幅很长,仍是建议认证读完,尤为是关于切片的设计和append函数实现的部分,理解了“切片头”后不少的切片行为就天然而然可以理解。程序员

Rob·Pike
2013 年 9 月 26 日
原文地址: https://blog.golang.org/slices

介绍

过程编程语言最多见的特征之一就是数组的概念。数组看似简单,可是将数组添加到语言时必须回答许多问题,例如:golang

  • 数组使用固定尺寸仍是可变尺寸?
  • 尺寸是数组类型的一部分吗?
  • 多维数组是什么样的?
  • 空数组有意义吗?

这些问题的答案会影响数组是否只是语言的一个普通的功能仍是其设计的核心部分。编程

在 Go 的早期开发中,在感受到设计正确以前,咱们花了大约一年的时间决定对这些问题的答案。很是关键的一步是咱们引入了切片,它基于固定大小的数组构建,以提供灵活,可扩展的数据结构。然而,直到今天,刚接触 Go 的程序员常常在切片的工做方式上被绊倒,这也许是由于其余语言的经验固化了他们的思惟。数组

在这篇文章中,咱们将尝试消除混乱。咱们将经过构建知识片断来解释 append 内置函数的工做原理以及它如此工做的缘由。安全

数组

数组是 Go 中重要的构建块,但就像建筑物的基础同样,它们一般隐藏在可见的组件下。在继续介绍切片的更有趣,更强大和更重要的概念以前,咱们必须简短地谈论一下数组。数据结构

在 Go 程序中并不常常看到数组,由于数组的大小是数组类型的一部分,这限制了数组的表达能力。app

声明数组以下编程语言

var buffer [256]byte

声明数组变量 buffer,其中包含 256 个字节。 buffer 的类型包括其大小,[256] byte。 一个包含 512 个字节的数组将具备不一样的类型 [512] byte函数

与数组关联的数据就是:元素数组。从原理上讲,咱们的 buffer 在内存中看起来像这样,

buffer: byte byte byte ... 256 个 ... byte byte byte

也就是说,该变量保存 256 个字节的数据,仅此而已。咱们能够经过使用熟悉的索引语法 buffer [0]buffer [1]buffer [255] 等访问其元素。 (索引范围 0 到 255 涵盖 256 个元素。) 尝试使用该范围以外的值索引数组 buffer 会使程序崩溃。

内置函数 len 的回数组或切片以及其余一些数据类型的元素数量。对于数组,很明显 len 会返回什么。在咱们的示例中,len(buffer) 返回固定值 256。

数组有本身的一席之地 (例如,它们很好地表示了转换矩阵),可是它们在 Go 中最多见的应用目的是保留切片的存储空间。

Slices:切片头

切片是执行操做的地方,可是要充分利用它们,开发者必须准确了解它们的含义和做用。

切片是一种数据结构,描述与切片变量自己分开存储的数组的一段连续的部分,。 切片不是数组。切片描述一块数组。

用上节给定的数组变量 buffer,咱们能够建立一个描述了数组第 100 个元素到第 150 个元素的切片(准确地说是包含第 100 个元素到 149 个元素):

var slice []byte = buffer[100:150]

在该代码段中,咱们使用了完整的变量声明。变量slice 的类型为 [] byte 的 “字节切片”,并经过从名为 buffer 的数组切片第 100 个元素 (包括) 到第 150 个元素 (不包括) 来初始化。更惯用的语法是忽略类型,类型由初始化表达式设置:

var slice = buffer[100:150]

在函数内部,咱们可使用简短声明形式,

slice := buffer[100:150]

切片变量究竟是什么?如今将 slice 看做是一个具备两个元素的小数据结构:长度和指向数组元素的指针。你能够认为它是在底层像这样被构建的:

type sliceHeader struct {
    Length        int
    ZerothElement *byte
}

slice := sliceHeader{
    Length:        50,
    ZerothElement: &buffer[100],
}

固然,这只是一个为了说明举的例子。尽管此代码段说明了 sliceHeader 结构对于程序员是不可见的,而且元素指针的类型取决于元素的类型,但这给出了切片机制大致上的概念。

到目前为止,咱们已经对数组使用了切片操做,可是咱们也能够对切片进行切片操做,以下所示:

slice2 := slice[5:10]

和以前同样,此操做将建立一个新的切片,在这种状况下,新切片将使用原始切片的元素 5 至 9,也就是原始数组的元素 105 至 109。 slice2 变量底层的 sliceHeader 结构以下所示:

slice2 := sliceHeader{
    Length:        5,
    ZerothElement: &buffer[105],
}

请注意,此标头仍指向存储在 buffer 变量中的相同底层数组。

咱们还能够重切片,也就是说对切片进行切片操做,而后将结果存储回原始切片结构中。在执行下面的切片操做后

slice = slice[5:10]

slice 变量的 sliceHeader 结构看起来和 slice2 变量的结构同样。在使用Go 的过程当中你将会看到重切片会被常用,例如截断切片。下面的语句删除切片的第一个和最后一个元素:

slice = slice[1:len(slice)-1]

[练习:在上面的赋值以后,写出 sliceHeader 结构的外观。]

你将常常会听到经验丰富的 Go 程序员谈论 “切片标头”,由于这其实是存储在切片变量中的内容。例如,当您调用一个将切片做为参数的函数时,例如bytes.IndexRune,该标头就是传递给该函数的内容。在此次调用中

slashPos := bytes.IndexRune(slice, '/')

传递给 IndexRune 函数的 slice 参数其实是一个 “切片标头”。

切片头中还有一个数据项,咱们将在下面讨论,可是首先让咱们看看在使用切片进行编程时,切片 头的存在乎味着什么。

将切片传递给函数
重要的是要理解,即便切片包含指针,它自己也是一个值。在幕后,它是一个结构体值,包含一个指针和一个长度。它不是结构体的指针。

这很重要。

在上一个示例中,当咱们调用IndexRune 时,它传递了切片头的副本。这种行为具备重要的影响。

考虑下面这个简单的函数

func AddOneToEachElement(slice []byte) {
    for i := range slice {
        slice[i]++
    }
}

它确实作到了其名称暗示的那样,对切片的索引进行迭代 (使用 for range 循环),自增每一个元素。

尝试一下:

func main() {
    slice := buffer[10:20]
    for i := 0; i < len(slice); i++ {
        slice[i] = byte(i)
    }
    fmt.Println("before", slice)
    AddOneToEachElement(slice)
    fmt.Println("after", slice)
}

(若是想探究,能够编辑并从新执行这些可运行的代码段。)

尽管切片头是按值传递的,但标头包含指向数组元素的指针,所以原始切片标头和传递给函数的标头副本都描述了同一数组。因此,当函数返回时,能够经过原始slice变量看到修改后的元素。

该函数的参数其实是一个切片的标头副本,如如下示例所示:

func SubtractOneFromLength(slice []byte) []byte {
    slice = slice[0 : len(slice)-1]
    return slice
}

func main() {
    fmt.Println("Before: len(slice) =", len(slice))
    newSlice := SubtractOneFromLength(slice)
    fmt.Println("After:  len(slice) =", len(slice))
    fmt.Println("After:  len(newSlice) =", len(newSlice))
}

在这里咱们看到slice参数的内容能够由函数修改,可是它的切片标头不能。调用该函数不会修改slice 变量中存储的长度,由于传给该函数的是切片头的副本 (而不是原始头)。所以,若是咱们要编写一个修改标头的函数,则必须像在此所作的同样,将其做为结果参数返回。 slice 变量不变,但返回的值具备新长度,而后将其存储在 newSlice 中,

指向切片的指针:方法接收者

另外一种让函数修改切片头的方法是将指向切片的指针传递给函数,下面是咱们以前的示例的一个变体:

func PtrSubtractOneFromLength(slicePtr *[]byte) {
    slice := *slicePtr
    *slicePtr = slice[0 : len(slice)-1]
}

func main() {
    fmt.Println("Before: len(slice) =", len(slice))
    PtrSubtractOneFromLength(&slice)
    fmt.Println("After:  len(slice) =", len(slice))
}

这个例子看起来很笨拙,尤为是还须要处理额外的间接寻址(使用临时变量实现),可是有一种状况咱们会常常看到指向切片的指针:一个会修改切片的方法的惯用模式是使用切片的指针做为方法的接收者。

假设咱们想在切片上有一个方法,以便在最后一个斜杠处将其截断。咱们能够这样写:

type path []byte

func (p *path) TruncateAtFinalSlash() {
    i := bytes.LastIndex(*p, []byte("/"))
    if i >= 0 {
        *p = (*p)[0:i]
    }
}

func main() {
    pathName := path("/usr/bin/tso") // 将字符串转换为 path 类型
    pathName.TruncateAtFinalSlash()
    fmt.Printf("%s.", pathName)
}

若是运行此示例,您将看到它能够正常工做,并在调用的函数中更新切片。

[练习:将接收器的类型更改成值而不是指针,而后再次运行。解释发生了什么。]

另外一方面,若是咱们想为path类型编写一个方法,该方法会将路径中的ASCII字母转为大写,则该方法的接口者能够是一个切片值,由于值接收者仍然会指向相同的基础数组。

type path []byte

func (p path) ToUpper() {
    for i, b := range p {
        if 'a' <= b && b <= 'z' {
            p[i] = b + 'A' - 'a'
        }
    }
}

func main() {
    pathName := path("/usr/bin/tso")
    pathName.ToUpper()
    fmt.Printf("%s.", pathName)
}

在这里,ToUpper 方法在中为range 循环使用两个变量来捕获索引和切片元素。这种形式的循环避免在体内屡次写入p[i]

[练习:转换 ToUpper 方法以使用指针接收器,并查看其行为是否改变。]

[高级练习:转换 ToUpper 方法以处理 Unicode 字母,而不只仅是ASCII。]

容量

下面这个函数为其整型切片参数扩充一个元素:

func Extend(slice []int, element int) []int {
    n := len(slice)
    slice = slice[0 : n+1]
    slice[n] = element
    return slice
}

(为何它须要返回修改后的切片?) 如今使用 Extend 函数并运行下面的程序:

func main() {
    var iBuffer [10]int
    slice := iBuffer[0:0]
    for i := 0; i < 20; i++ {
        slice = Extend(slice, i)
        fmt.Println(slice)
    }
}

看看切片如何增加,直到... 它不会增加。

如今该讨论切片标头的第三个组成部分:容量。除了数组指针和长度,切片头还存储其切片容量:

type sliceHeader struct {
    Length        int
    Capacity      int
    ZerothElement *byte
}

Capacity字段记录基础数组实际有多少空间;它是 Length 能够达到的最大值。试图使切片超出其容量将超出切片的底层数组的限制,这会引起 panic

在咱们的示例切片经过下面的语句建立以后,

slice := iBuffer[0:0]

它的切片头会是这样:

slice := sliceHeader{
    Length:        0,
    Capacity:      10,
    ZerothElement: &iBuffer[0],
}

Capacity 字段等于基础数组的长度减去切片的第一个元素指向的数组元素在数组中的索引 (在本例中切片第一个元素对应的数组元素的索引为 0)。若是要查询切片的容量,请使用内置函数 cap

if cap(slice) == len(slice) {
    fmt.Println("slice is full!")
}

Make 函数

若是咱们想将切片扩大到超出其capacity怎么办?实际上你办不到!根据定义,capacity 是切片增加的极限。可是,您能够经过分配一个新数组,复制数据到新数组并修改切片以描述新的数组来得到等效的结果。

让咱们从分配开始。咱们可使用 new 内置函数分配一个更大的数组,而后对结果进行切片,可是使用 make 内置函数更简单。它分配一个新数组并建立一个切片头来描述它。 make函数采用三个参数:切片的类型,初始长度和容量,容量是 make分配的用来保存切片数据的数组的长度。下面make函数的调用,能够建立一个长度为 10 的切片,底层数组还有 5 个余量 (15-10),这能够经过运行它看到:

slice := make([]int, 10, 15)
fmt.Printf("len: %d, cap: %d.", len(slice), cap(slice))

下面的代码片断使咱们的 int 切片的容量增长了一倍,但长度保持不变:

slice := make([]int, 10, 15)
fmt.Printf("len: %d, cap: %d.", len(slice), cap(slice))
newSlice := make([]int, len(slice), 2*cap(slice))
for i := range slice {
    newSlice[i] = slice[i]
}
slice = newSlice
fmt.Printf("len: %d, cap: %d.", len(slice), cap(slice))

运行上面的代码后,slice 在须要再次分配新的底层数组以前拥有了更多空间去扩充。

建立切片时,长度和容量一般是相同的。内置的make支持此常见状况的简写形式。 length 参数值默认为capacity值,所以在使用make函数时您能够省略capacity将它们设置为相同的值。像下面这样:

gophers := make([]Gopher, 10)

gophers切片的长度和容量都被设置为 10。

Copy 函数

在上一节中将切片的容量加倍时,咱们编写了一个循环,将旧数据复制到新切片。 Go 具备内置函数 copy,可简化此操做。它的参数是两个切片,它将数据从右侧参数复制到左侧参数。下面咱们使用 copy 函数重写上节的示例:

newSlice := make([]int, len(slice), 2*cap(slice))
copy(newSlice, slice)

copy 函数很智能。它只复制它能够复制的内容,会关注两个参数的长度。换句话说,它复制的元素数量是两个切片长度中的最小值。这样能够节省一些记录操做。一样,copy 返回一个整数值,即它复制的元素数量,尽管这个返回值并不老是值得在程序中检查。

当源切片和目标切片重叠时,copy 函数也能够正确处理,这意味着它能够用于在单个切片中移动元素。如下是使用 copy 将值插入切片中间的方法。

//Insert 函数将值插入到切片指定的索引位置上
//插入的位置必须在范围内。
//切片必须为新元素留出空间。
func Insert(slice []int, index, value int) []int {
    //将切片增长一个元素。
    slice = slice[0 : len(slice)+1]
    //使用复制将切片的上部移开,并留出一个位置。
    copy(slice[index+1:], slice[index:])
    //插入新值。
    slice[index] = value
    // 返回结果。
    return slice
}

在这个函数中有两点须要注意。首先,它必须返回更新的切片,由于其长度已更改。其次,它使用了简写的切片表达式

slice[i:]

效果与下面的表达式彻底相同

slice[i:len(slice)]

一样,尽管咱们尚未使用这个技巧,可是咱们也能够省略切片表达式的第一个元素,它默认为零。

slice[:]
上面的表达式表示切片自己,这在切片(动词)数组时颇有用。下面的表达式是 “描述数组全部元素的切片” 的最快捷的方法:

array[:]

如今,让咱们运行 Insert 函数。

slice := make([]int, 10, 20) // 注意容量>长度:表明添加元素的空间。
for i := range slice {
    slice[i] = i
}
fmt.Println(slice)
slice = Insert(slice, 5, 99)
fmt.Println(slice)

Append: 一个例子

在前面几节中,咱们编写了Extend函数,该函数将切片扩展了一个元素。可是,这个函数是有问题的,由于若是切片的容量过小,该函数将崩溃。 (咱们的 Insert 示例函数也有一样的问题。) 如今咱们已经解决了这一问题,因此让咱们为整数切片编写一个Extend的可靠实现。

func Extend(slice []int, element int) []int {
    n := len(slice)
    if n == cap(slice) {
        // 切片已满,必须扩充容量
        // 咱们将其容量加倍并加1,所以若是原来大小为零,仍能扩展切片容量。
        newSlice := make([]int, len(slice), 2*len(slice)+1)
        copy(newSlice, slice)
        slice = newSlice
    }
    slice = slice[0 : n+1]
    slice[n] = element
    return slice
}

在这个函数中,最后返回切片特别重要,由于当它从新分配时,结果切片描述了一个彻底不一样的数组。下面的代码片断演示了切片填满时发生的状况:

slice := make([]int, 0, 5)
for i := 0; i < 10; i++ {
    slice = Extend(slice, i)
    fmt.Printf("len=%d cap=%d slice=%v.", len(slice), cap(slice), slice)
    fmt.Println("address of 0th element:", &slice[0])
}

请注意,当初始大小为 5 的数组填满时,发生了数组从新分配。分配新数组时,切片的容量以及第零个元素的地址都会改变。

借助强大的Extend函数做为引导,咱们能够编写一个更好的函数,使咱们能够将切片扩展多个元素。为此,咱们使用Go在调用函数时将函数参数列表转换为切片的功能。也就是说,咱们使用 Go的可变函数参数功能。

咱们将新函数命名为Append。对于第一个版本,咱们能够重复调用 Extend,这样可变函数的机制就很清楚了。 Append的函数签名是这样的:

func Append(slice []int, items ...int) []int

Append接受一个切片参数,而后是零个或多个int参数。就Append的实现而言,这些参数正是一个int 型切片,如您所见:

// Append将项目追加到切片
//第一个版本:只是循环调用Extend。
func Append(slice []int, items ...int) []int {
    for _, item := range items {
        slice = Extend(slice, item)
    }
    return slice
}

注意 for range 循环遍历 items 参数的元素,该参数具备隐式类型[]int。还要注意使用空白标识符_来丢弃循环中的索引,由于在个例子中咱们不须要索引。

尝试一下:

slice := []int{0, 1, 2, 3, 4}
fmt.Println(slice)
slice = Append(slice, 5, 6, 7, 8)
fmt.Println(slice)

此示例中的另外一项新技术是,咱们经过编写复合字面量来初始化切片,该复合字面量由切片的类型以及括号中的元素组成:

slice := []int{0, 1, 2, 3, 4}

Append 颇有意思的另外一个缘由是,咱们不只能够像源切片追加元素,还能够在调用`Append
时使用...语法将切片拆分红函数的实参。这样咱们就能用Append`函数将第二个切片整个追加给源切片了。

slice1 := []int{0, 1, 2, 3, 4}
slice2 := []int{55, 66, 77}
fmt.Println(slice1)
slice1 = Append(slice1, slice2...) //  '...' 是必须的!
fmt.Println(slice1)

固然,咱们能够在Extend的内部基础上分配不超过一次的分配来提升Append的效率:

// Append 将元素追加到切片
//高效的版本。
func Append(slice []int, elements ...int) []int {
    n := len(slice)
    total := len(slice) + len(elements)
    if total > cap(slice) {
         //从新分配。增加到新大小的1.5倍,所以咱们仍然能够增加。
        newSize := total*3/2 + 1
        newSlice := make([]int, total, newSize)
        copy(newSlice, slice)
        slice = newSlice
    }
    slice = slice[:total]
    copy(slice[n:], elements)
    return slice
}

在这里,请注意咱们如何两次使用copy 函数的,一次将切片数据移动到新分配的内存中,而后将附加项复制到旧数据的末尾。

尝试一下;新代码片断的行为与之前相同:

slice1 := []int{0, 1, 2, 3, 4}
slice2 := []int{55, 66, 77}
fmt.Println(slice1)
slice1 = Append(slice1, slice2...) //'...'是必不可少的!
fmt.Println(slice1)

Append: 内置函数

所以,咱们得出了设计append内置函数的动机。它的效率与咱们的Append示例彻底相同,可是它可以适用于任何切片类型。

Go的一个缺点是任何泛型类型的操做都必须由运行时提供。有一天这种状况可能会改变,可是如今,为了更容易地处理切片Go提供了一个内置的泛型函数append。它的工做方式与咱们的 int切片版本相同,但适用于任何切片类型

请记住,因为切片标头老是经过调用append进行更新,因此须要在调用后保存返回的切片。实际上,编译器不会让您在不保存结果的状况下调用append

下面是一些与print语句混合的线性程序。试试看,编辑并探究结果

// 建立两个初始切片
slice := []int{1, 2, 3}
slice2 := []int{55, 66, 77}
fmt.Println("Start slice: ", slice)
fmt.Println("Start slice2:", slice2)

//将一个元素添加到切片
slice = append(slice, 4)
fmt.Println("Add one item:", slice)

//将一个切片添加到另外一个切片。
slice = append(slice, slice2...)
fmt.Println("Add one slice:", slice)

//复制(int的)切片。
slice3 := append([]int(nil), slice...)
fmt.Println("Copy a slice:", slice3)

//将切片复制到其自身的末尾。
fmt.Println("Before append to self:", slice)
slice = append(slice, slice...)
fmt.Println("After append to self:", slice)

值得花一点时间仔细考虑该示例的最后一个代码,以理解切片的设计如何使此简单调用正确工做成为可能。

在社区构建的“Slice Tricks” Wiki 页面。上,有更多的appendcopy 和其余使用切片方式的示例。

Nil

顺便说一句,有了咱们新学到的知识,咱们能够看到nil切片的表示是什么。天然地,它是切片标头的零值:

sliceHeader{
    Length:        0,
    Capacity:      0,
    ZerothElement: nil,
}

或者这么表示

sliceHeader{}

关键的细节是切片头中元素指针也是nil,而由下面语句建立的切片

array[0:0]

长度为零 (甚至容量为零),但其指针不是nil,所以它不是nil切片。

须要清楚的是,空切片能够增加 (假设其容量为非零),可是nil切片没有数组能够放入值,甚至不能增加以容纳一个元素。

就是说,nil切片在功能上等效于零长度切片,即便它没有指向任何内容。它的长度为零,经过分配新数组能够用append 函数向其追加元素。例如,请查看上面的单线程序,该单线程序经过附加到nil切片来复制切片。

译注:说的是下面这个程序

//复制(int的)切片。
slice3 := append([]int(nil), slice...)
fmt.Println("Copy a slice:", slice3)

字符串

如今简要介绍一下切片上下文中的Go中的字符串。

字符串实际上很是简单:它们只是只读的字节切片,而切在语言层面还提供了一些额外的语法支持。

由于它们是只读的,因此不须要容量 (不能增长它们),可是对于大多数状况下,您能够将它们像只读的字节切片同样对待他们。

首先,咱们能够为它们索引字符串以访问各个字节:

slash := "/usr/ken"[0] //产生字节值'/'

咱们能够对字符串进行切片以获取子字符串:

usr := "/usr/ken"[0:4] // 产生字符串"/usr"

如今,当咱们切成字符串时,幕后发生的事情应该很容易理解了。

咱们还能够用一个普通的字节切片,经过简单的转换从中建立一个字符串:

str := string(slice)

反之亦然:

slice := []byte(usr)

字符串底层的数组从视野中被隐藏掉了;除了经过字符串,没法访问其内容。这意味着当咱们执行这些转换中的任何一个时,都必须复制该数组。固然,Go 会处理好这一点,所以您没必要这样作。在这些转换中的任何一个以后,对字节片下面的数组的修改不会影响相应的字符串。

这种相似切片的字符串设计的一个重要结果是建立子字符串很是高效。全部须要作的就是建立一个两个字的字符串标头。因为字符串是只读的,所以原始字符串和切片操做产生的字符串能够安全地共享同一数组。

历史记录:最先的字符串实现老是分配的,可是当将切片添加到语言时,它们提供了有效的字符串处理模型。结果一些基准测试得到了巨大的加速。

固然,字符串还有更多的东西,单独的博客文章能够更深刻地了解它们。

结论

理解切片的工做原理,有助于了解切片的实现方式。切片有一个小的数据结构,即切片标头,它是与 slice 变量关联的项目,而且该标头描述了单独分配的数组的一部分。当咱们传递切片值时,将标头将会被复制,但始终都会指向它(译注:源标头)指向的数组。

一旦了解了它们的工做原理,切片不只变得易于使用,并且变得强大而富有表现力,尤为是在 copyappend内置函数的帮助下。

阅读更多

Go中有关切片的管间中能够找到不少东西。如前所述,“Slice Tricks” Wiki 页面有不少示例。

有不少可用的资料,可是学习切片的最佳方法是使用切片。

tWbHIMFsM3.png

相关文章
相关标签/搜索