数组、切片(包括字符串):“append” 方法的机制

数组、切片(包括字符串):“append” 方法的机制

原文

介绍

数组是编程语言中最常见的一个概念。数组看起来很简单但却有许多问题需要在加入编程语言中解答,比如:

  • 固定大小还是可变大小?
  • 大小作为类型的一部分吗?
  • 多维数组应该是什么样子?
  • 空数组有意义吗?

这些问题的答案会影响数组是否只是语言的一个特性还是其设计的核心部分。

在 Go 的早期开发中,在设计感觉正确之前,花了大约一年的时间来确定这些问题的答案。关键步骤是引入切片,它建立在固定大小的数组上,提供灵活、可扩展的数据结构。然而,直到今天,刚接触 Go 的程序员经常对切片的工作方式感到困惑,也许是因为其他语言的经验影响了他们的思维。

在这篇文章中,我们将尝试消除困惑。我们将通分片来解释内置函数append是如何工作的,以及它为什么会这样工作。

数组

数组是 Go 中的一个重要组成部分,但就像建筑物的地基一样,它们通常隐藏在更可见的组件之下。在我们继续讨论更有趣、更强大、更突出的切片概念之前,我们必须简单地讨论一下它们。

数组在 Go 程序中并不常见,因为数组的大小是其类型的一部分,这限制了它的能力。

例如:

var buffer [256]byte

声明了一个变量buffer,它可以存放256个字节。变量buffer的类型包括了它的大小,[256]byte。一个包含512个字节的数组将被定义为类型[512]byte

数组之间的数据关系正如其名:一组数据。举个例子我们定义的buffer在内存中看起来就像这样,

buffer: byte byte byte ... 256 times ... byte byte byte

也就是说,该变量只保存 256 个字节的数据,仅此而已。我们可以通过我们熟悉的下标语法来获取数组中的数据,buffer[0]、buffer[1]等等直到buffer[255](从0到255的256个数据)。尝试获取超出这个范围外的值会导致程序奔溃。

有一个叫做len的内置方法可以返回数组、切片以及其他一些类型的元素个数。对于数组而言我们可以很明显的直达len的返回值。在我们的例子中len(buffer)返回固定大小256。

数组有它适合的领域--数组可以很方便的表示矩阵,但是数组最大的作用是作为切片的存储部分。

切片:slice header

切片类型来自动作切片,但要很好地使用它们,必须准确了解它们是什么以及它们做什么。

切片是一种数据结构,描述与切片变量本身分开存储的数组的连续部分。切片不是数组,切片描述了数组的一部分

在上一小节中我们定义了数组类型变量buffer,我们可以创建一个描述buffer中第100位到150位之间的切片类型变量,只需要像下面这样切割数组:

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

在上面代码片段中,我们使用了完整的变量声明来明确。变量slice具有[]byte类型,读作“字节切片”,并通过对buffer中第100(包括)到 150(不包括)的数据进行切片来初始化slice变量。更常用的语法会去掉初始化表达式中的类型声明:

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],
}

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

我们也可以重新切片,也就是说切片一个切片并将结果存储回原始切片结构中。

slice = slice[5:10]

slice 变量的 sliceHeader 结构看起来就像 slice2 变量的结构一样。你会看到经常使用重新切片,例如截断切片。此语句删除切片的第一个和最后一个元素:

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

[练习:写出这个上面操作之后 sliceHeader 结构的样子。]

你会经常听到有经验的 Go 程序员谈论slice header,因为这确实是存储在切片变量中的内容。例如,当你调用将切片作为参数的函数时,例如 bytes.IndexRune,该标头就是传递给函数的内容。在下面的调用中:

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

传给IndexRune方法的参数slice本质上是一个slice header

slice header中还有一个字段我们将在后面讨论,但首先让我们看看当你用切片编程时slice header的存在意味着什么。

切片作为函数参数

重要的是要理解,即使一个 slice 包含一个指针,它本身也是一个值。在背后,它是一个包含指针长度的结构值。它不是指向结构的指针。

这一点很重要。

在前面的例子中,当我们调用IndexRune方法,传进去的其实是slice header的拷贝,这种行为具有重要的影响。

考虑下面这个简单的函数:

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 header是值传递,但是其内部包含了一个指向数组的指针元素,所以无论是源slice还是传给函数的拷贝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变量的length字段。因为该函数传递的是切片头的拷贝,而不是它本身(译者注:值传递而非引用传递)。因此,如果我们想编写一个修改标头的函数,我们必须将它作为结果参数返回,就像我们在这里所做的那样。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") // 将 string 类型 转换成 path 类型
    pathName.TruncateAtFinalSlash()
    fmt.Printf("%s\n", 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\n", pathName)
}

在这个ToUpper方法内部的for循环中使用了两个变量分别用于捕获切片的下标和元素。这种写法可以避免多次出现 p[i]

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

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

容量

下面的方法可以追加一个元素到int切片:

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

(为什么上面的方法需要返回被修改的切片?)现在运行下面代码:

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

看看这个slice的增长变化直到...它不能再变了。

是时候来讨论以下slice header中的第三个概念了。那就是容量。除了指向数组的指针和长度之外,slice header还存储它的容量:

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

这个Capacity域记录着底层数组真实拥有的空间,这也是Length字段所能达到的最大值。尝试将切片增长到超出它的容量将会引发panic。

再次之后我们使用的slice变量由下定义:

slice := iBuffer[0:0]

它的头结构如下:

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

这个Capacity字段值等于其底层数组的长度减去切片的第一个元素在数组中的索引(在这种情况下为零)。如果要查询切片的容量是多少,可以使用内置函数cap

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

Make

如果我们想让切片超出其容量怎么办?你不能!根据定义,容量是增长的极限。但是你可以通过分配一个新数组、复制数据并修改切片指向新数组来获得等效的结果。

让我们从分配开始。我们可以使用内置函数new来分配一个更大的数组,然后对结果进行切片,但使用内置函数make更简单。它一次分配一个新数组并创建一个切片头来描述它。make函数接受三个参数:切片的类型、初始长度和容量,即make分配用于保存切片数据的数组的长度。下面这个调用创建了一个长度为 10 的切片,还有 5 个空间(15-10),运行它你可以看到输出:

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

下面的代码片段可以将我们的int切片容量加倍,但是长度保持不变:

    slice := make([]int, 10, 15)
    fmt.Printf("len: %d, cap: %d\n", 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\n", len(slice), cap(slice))

运行此代码后,切片在需要再次重新分配之前就有了更多的增长空间。

创建切片时,长度和容量通常是相同的。内置的make有这种常见情况的简写。长度参数默认为容量,因此你可以省略它以将它们设置为相同的值。这种写法如下:

gophers := make([]Gopher, 10)

这个gophers切片的容量和长度都设置成了10。

COPY

我们在上一节中将切片的容量翻倍时,编写了一个循环来将旧数据复制到新切片中。 Go 有一个内置的函数,copy,使这更容易。它的参数是两个切片,它将数据从右侧参数复制到左侧参数。这是我们重写为使用复制的示例:

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

copy方法十分智能,它会注意两个参数并只复制能复制的。换句话说,它复制的元素数量是两个切片长度中的最小值。这可以节省一点簿记。当然copy方法返回一个整数表示被它复制的元素个数,虽然这不是一直都值得校验这个值。

对于copy方法来说,当源切片和目标切片有重合时也能很好的工作,这就意味着可以在单个切片中可以通过copy方法来移动元素。下面的例子演示了如何用copy方法来向切片的中间插入元素。

// Insert inserts the value into the slice at the specified index,
// which must be in range.
// The slice must have room for the new element.
func Insert(slice []int, index, value int) []int {
    // Grow the slice by one element.
    slice = slice[0 : len(slice)+1]
    // Use copy to move the upper part of the slice out of the way and open a hole.
    copy(slice[index+1:], slice[index:])
    // Store the new value.
    slice[index] = value
    // Return the result.
    return slice
}

在这个函数中有几件事需要注意。首先,当然,它必须返回更新后的切片,因为它的长度已经改变。其次,它使用方便的速记。表达式如下:

slice[i:]

上面的写法和下面的写法意义相同

slice[i:len(slice)]

此外,虽然我们还没有使用这个技巧,但我们也可以省略切片表达式的第一个元素;它默认为零。此时可以这样写:

slice[:]

上面的表达式表示的就是切片本身,这在切片数组时很有用。这个表达式是“描述数组所有元素的切片”的最短方式:

array[:]

现在已经不碍事了,让我们运行我们的插入函数。

    slice := make([]int, 10, 20) // Note capacity > length: room to add element.
    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) {
        // Slice is full; must grow.
        // We double its size and add 1, so if the size is zero we still grow.
        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\n", 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 appends the items to the slice.
// First version: just loop calling 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 函数之所以有趣还有另一个原因。我们不仅可以追加元素,还可以通过在调用时使用 ... 符号将切片“分解”为参数来追加整个第二个切片:

    slice1 := []int{0, 1, 2, 3, 4}
    slice2 := []int{55, 66, 77}
    fmt.Println(slice1)
    slice1 = Append(slice1, slice2...) // The '...' is essential!
    fmt.Println(slice1)

当然,我们可以通过不超过一次的分配来提高 Append 的效率,建立在 Extend 的内部:

// Append appends the elements to the slice.
// Efficient version.
func Append(slice []int, elements ...int) []int {
    n := len(slice)
    total := len(slice) + len(elements)
    if total > cap(slice) {
        // Reallocate. Grow to 1.5 times the new size, so we can still grow.
        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,一次是将切片数据移动到新分配的内存,然后将附加项复制到旧数据的末尾。

尝试下面的代码,它的行为和上面的一样:(译者注:这里的代码和上面的一样,只不过Append方法的内部逻辑变了)

    slice1 := []int{0, 1, 2, 3, 4}
    slice2 := []int{55, 66, 77}
    fmt.Println(slice1)
    slice1 = Append(slice1, slice2...) // The '...' is essential!
    fmt.Println(slice1)

Append:内建函数

这样我们就得出了append内置函数的设计动机。它与我们的Append示例完全一样,效率相当,但它适用于任何切片类型。

Go 的一个弱点是任何泛型类型的操作都必须由运行时提供。总有一天,情况可能会改变,但现在,为了更轻松地使用切片,Go 提供了一个内置的通用append功能。它的工作方式与我们的int切片版本相同,但适用于任何切片类型。

请记住,由于slice header总是通过调用 append 来更新,因此您需要在调用后保存返回的切片。事实上,编译器不会让你在不保存结果的情况下调用 append。

这里有一些与打印语句混合的单行语句。尝试运行它们,编辑它们并探索更多的情况:

    // Create a couple of starter slices.
    slice := []int{1, 2, 3}
    slice2 := []int{55, 66, 77}
    fmt.Println("Start slice: ", slice)
    fmt.Println("Start slice2:", slice2)

    // Add an item to a slice.
    slice = append(slice, 4)
    fmt.Println("Add one item:", slice)

    // Add one slice to another.
    slice = append(slice, slice2...)
    fmt.Println("Add one slice:", slice)

    // Make a copy of a slice (of int).
    slice3 := append([]int(nil), slice...)
    fmt.Println("Copy a slice:", slice3)

    // Copy a slice to the end of itself.
    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]

它的 Length 为0(Capacity也可能为0),但是它指向数组的指针不是 nil,所以这不是一个 nil 切片。

应该清楚,一个空切片可以增长(假设它具有非零容量),但一个 nil 切片没有数组可以放入值,并且永远不会增长到容纳一个元素。

也就是说,nil 切片在功能上等同于零长度切片,即使它不指向任何内容。它的长度为零,可以通过分配附加到。例如,查看上面的单行代码,它通过附加到 nil 切片来复制切片。

字符串

现在简要介绍切片上下文中 Go 中的字符串。

字符串实际上非常简单:它们只是只读的字节切片,并带有语言的一些额外语法支持。

因为它们是只读的,所以不需要容量(你不能增加它们),但在大多数情况下,你可以将它们视为只读字节切片。

对于初学者,我们可以索引它们以访问单个字节:

slash := "/usr/ken"[0] // yields the byte value '/'.

我们可以对字符串进行切片以获取子字符串:

usr := "/usr/ken"[0:4] // yields the string "/usr"

现在应该很明显,当我们对字符串进行切片时,背后发生了什么。

我们还可以获取一个普通的字节切片,并通过简单的转换从中创建一个字符串:

str := string(slice)

当然反过来转换也是可以的

slice := []byte(usr)

字符串背后的数组是隐藏的;除了通过字符串之外,无法访问其内容。这意味着当我们进行任何一种转换时,都必须制作数组的拷贝。当然,Go 会处理这个问题,所以你不必这样做。在这些转换中的任何一个之后,对字节切片底层数组的修改都不会影响相应的字符串。

这种类似切片的字符串设计的一个重要结果是创建子字符串非常有效。所需要做的就是创建一个两个字的字符串header。由于字符串是只读的,因此原始字符串和切片操作产生的字符串可以安全地共享同一个数组。

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

当然,字符串还有更多内容,另外一篇博客文章更深入地介绍了它们。

总结

要了解切片是如何工作的,有助于了解它们是如何实现的。有一个小数据结构,即切片头(slice header),即与切片变量关联的项,该头描述了单独分配的数组的一部分。当我们传递切片值时,标头会被复制,但它指向的数组始终是共享的。

一旦您了解它们的工作原理,切片不仅易于使用,而且功能强大且富有表现力,尤其是在copyappend内置函数的帮助下。

更多

在互联网上可以找到很多go切片相关的知识。如前所述,“Slice Tricks”Wiki 页面有很多示例。 Go Slices 博客文章用清晰的图表描述了内存布局细节。 Russ Cox 的 Go 数据结构文章包括对切片以及 Go 的其他一些内部数据结构的讨论。

有更多可用的材料,但了解切片的最佳方法是使用它们。

posted @ 2022-06-16 20:19  KainHuck  阅读(564)  评论(0编辑  收藏  举报