返回文章列表

【Go】数组,切片(字符串):"append"的机制

2024年8月16日
翻译 Go

简介

过程式编程语言最常用的功能之一就是数组的概念。数组看似简单,然而把他添加到编程语言中有许多的问题需要去解决:

  • 定长还是变长?
  • 尺寸作为类型的一部分吗?
  • 多维数组如何处理?
  • 空数组的含义?

解决这些问题将影响到数组只是语言的一个功能,还是一个语言设计的重要部分。 在Go的早期开发时,大约用了一年的时间去解决这些问题。关键的一步就是slice的引导,它建立在定长的数组上,又是可扩展,很灵活的数据结构。直到今天,Go语言的的初学者经常混淆切片的形式,可能因为其他语言的一些思想和Go的不太一样。 在这个文章中,我们将尝试着解决这些疑惑,通过一些demo来解释append函数是怎样工作的,为什么以这种方式来工作。

数组

数组是Go重要的组成部分,像建筑的基础设施一样经常被隐藏在更多可见的组件之下。在讨论更有趣,更强大和更突出的切片时,先简要的介绍一下数组。 数组在Go程序中并不经常看到,因为数组的长度也是类型的一部分(也就是固定长度),所以有很多的限制。

定义

var buffer [256]byte

定义一个变量buffer,可以储存256字节。buffer的类型包含它的尺寸,[256]bytebuffer在内存中的展现形式:

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

我们可以通过索引形式方位元素buffer[0], buffer[1]。(索引从0到255),如果超过255就会越界造成程序崩溃。 有一个内置函数len,返回数组或切片还有其他的类型的元素数量。数组中,显而易见,len(buffer)返回256。 数组很好的表示了矩阵变换,但是主要的目的还是为切片做存储。

切片

切片会用在很多地方,但是用好最好必须要理解它是什么和它能做什么。 切片是一种数据结构,用来描述一块连续的数组。切片不是数组,用来描述数组的一部分。 上述的buffer是一个数组。我们可以通过对数组进行切分来创建一个可以描述从100到150之间的元素的切片:

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

上述代码是一个完整的变量声明。变量slice有类型[]byte,意思是“字节切片”,并且从buffer数组初始化,通过切分100到150的元素。更常用的语法是去掉类型,如:

var slice = buffer[100:150]

在函数内容,我们可以使用更简短的声明方式:

slice := buffer[100:150]

那切片变量到底是什么呢?我们可以把它想象成一个有两个元素数据结构:一个长度和一个指针指向一个数组的某一个元素。如:

type sliceHeader struct {
    Length               int
    ZerothElement *byte
}
slice := sliceHeader{
    Length:                 50
    ZerothElement:    &buffer[100],
}

当然,这只是一个假设。sliceHeader对于程序员是不可见的,并且元素的指针依赖于具体的数据类型。这只是给出了这个机制的概念。 到目前为止,我们对数组进行切片,其实也可以对切片进行切片,如:

slice2 := slice[5:10]

这个操作会生成一个新的切片,包含原始切片的第5到9的元素,也就是原始数组第105到109的元素。用sliceHeader结构来描述slice2就是:

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

我们也可以重置slice,像这样:

slice = slice[5:10]

这个sliceslice2是一样的,你将会经常看到这种方式,例如,你想去掉头和尾的两个元素:

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

你会经常听到有经验的Go编程者说“slice header”,因为确实存在切片变量中。当你调用一个切片作为参数的函数时,如:bytes.IndexRune,header就会传递给函数。

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

在函数IndexRune传递的slice参数实际上就是“slice header”。 在slice header中海油另外一个元素,我们接下来将会介绍。

传递切片给函数

即使一个切片包含一个指针,但是它本身是一个值。 在上述例子中调用了IndexRune,它传了一个slice header的副本。这点很重要。 举个例子:

func main() {
    slice := buffer[10:20]
    for i := 0; i < len(slice); i++ {
        slice[i] = byte(i)
    }
    fmt.Println("before", slice)        // before [0 1 2 3 4 5 6 7 8 9]
    AddOneToEachElement(slice)
    fmt.Println("after", slice)           // after [1 2 3 4 5 6 7 8 9 10]
}
// AddOneToEachElement函数对slice进行迭代,使每个元素自增1. 
func AddOneToEachElement(slice []byte) {
    for i := range slice {
        slice[i]++
    }
}

在这里我们看似给函数传了一个切片类型的值,执行后影响到了外部的切片。其实并不是影响了外部的切片。因为函数操作是对切片的元素进行操作,因为切片头包含的是源数组元素的指针和切片的长度,所以当你在函数中对切片的元素进行操作时,实际上操作的源数组。所以会改变外部切片的变量。 我们再来看一下另一种复制:

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

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

这个函数对外部的切片就没有改变。所以切片在函数中传递的其实是切片头的一个副本,也就是sliceHeader,所以函数操作的只是一个副本,并不会影响到外部切片的sliceHeader。但这个函数返回了一个切片,并且我们也赋值了所以newSlice和slice是一样的,但是它们存的元素值都是来源于一个数组。

切片的指针

有一种方法可以改变切片的头,那就是传递一个切片的指针。例如:

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

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

这个例子只是一个例子,并且我们想到改变一个切片,传切片的指针是一个惯用的做法。 再看一个,去除切片的最后一个元素:

type path []byte
func main() {
    pathName := path("/usr/bin/tso")
    pathName.TruncateAtFinalSlash()
    fmt.Printf("%s\n", pathName)  // /usr/bin
}
func (p *path) TruncateAtFinalSlash {
    i := bytes.LastIndex(*p, []byte("/"))
    if i >= 0 {
        *p = (*p)[0:i]
    }
}

Capacity(容量)

我们来看一个下面的函数,对切片进行扩展

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

有时这个程序会down掉,这就是容量的问题。 现在看一个下slice header的第三个元素,就是capacity(容量),出了个数组的指针和切片的长度外,还有一个可以存储容量在slice header中。

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

这个capacity记录了切片底层数组的空间,它是切片能达到的最大长度,如果长度超过容量,就会抛出异常。

var iBuffer [10]int
slice := iBuffer[0:0]
// slice 看起来就是这样的
slice := sliceHeader{
    Length:                 0,
    Capacity:              10,
    ZerothElement:    &iBuffer[0],
}
cap(slice) // 通过内置函数cap,可以得到切片的容量

Make

如果我们想要增长切片超过它本身的容量会怎么样?你不能这么操作,容量是增长的最大值。但是你可以分配一个新的数组达到相同的结果,复制数据,然后重新定义一个切片来描述这个新的数组。 我们可以使用内置的new函数来重新生成一个更大的数组,然后再进行切片,但是我们有更简单的方法,使用内置函数make。它重新生成一个数组并且创建一个切片头。make有三个参数,切片的类型,初始化长度,容量。下边这段代码就会创建一个拥有10个长度,15个容量的切片:

slice := make([]int, 10, 15)
fmt.Printf("len: %d, cap: %d\n", len(slice), cap(slice))   // len: 10, cap: 15
//生成一个拥有2倍容量的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))  // len: 10, cap: 30

当不指定第三个参数时,会创建一个长度和容量一致的切片。

Copy

上一个例子,我们把原有的切片赋到新的切片时,去遍历了原有的切片。Go还提供了一个内置函数copy,实现上述需求更加容易。它有两个切片参数,会把右边的切片赋值到左边的切片,那么上述的例子就可以写成:

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

它仅仅能复制newSlice的长度,也就是说,如果newSlice的长度小于slice的长度,那么newSlice就只有slice长度的部分。copy还能返回一个int值,表示复制了几个值。 同一个切片也是可以复制的,下面会展示一个例子,可以在切片的任意位置插入一个值。

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)]

顺便说一句,尽管我们没有用过,但是我们也可以不要第一个参数,那么默认就是0:

slice[:]

返回还是切片本身,这个对数组进行切分是相当有用的:

array[:]

Append: 实现append的例子 上述的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])
}
/**
 * len=1 cap=5 slice=[0]
 * address of 0th element: 0x10458000
 * len=2 cap=5 slice=[0 1]
 * address of 0th element: 0x10458000
 * len=3 cap=5 slice=[0 1 2]
 * address of 0th element: 0x10458000
 * len=4 cap=5 slice=[0 1 2 3]
 * address of 0th element: 0x10458000
 * len=5 cap=5 slice=[0 1 2 3 4]
 * address of 0th element: 0x10458000
 * len=6 cap=11 slice=[0 1 2 3 4 5]
 * address of 0th element: 0x10450030
 * len=7 cap=11 slice=[0 1 2 3 4 5 6]
 * address of 0th element: 0x10450030
 * len=8 cap=11 slice=[0 1 2 3 4 5 6 7]
 * address of 0th element: 0x10450030
 * len=9 cap=11 slice=[0 1 2 3 4 5 6 7 8]
 * address of 0th element: 0x10450030
 * len=10 cap=11 slice=[0 1 2 3 4 5 6 7 8 9]
 * address of 0th element: 0x10450030
**/

可以看到当数组填充5个元素之后,这个切片的容量和数组的第一个元素的地址改变了,这说明切片所描述的数组已经不再是原来的数组。 强大的Extend函数可以让我们更好的扩展切片。接着我们可以添加函数的多个参数到切片中。这会用到函数的可变参数。 声明一个函数Append,第一个版本,我们可以循环调用Extend来让可变参数更加清晰一些:

func Append(slice []int,  items ...int) []int
//这个函数的意思就是第一个参数是一个切片,然后就是0个或者多个int类型的参数:
func Append(slice []int, items ...int) []int {
    for _, item := range items {
        slice = Extend(slice, item)
    }
    return slice
}

这里用到for range去迭代参数items,还用到_忽略迭代时的index,调用一下:

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

Append函数还有一个有趣的就是,它不仅仅可以增加元素,还可以用...将整个切片拆分成参数:

slice1 := []int{0, 1, 2, 3, 4}
slice2 := []int{55, 66, 77}
fmt.Println(slice1) // [0 1 2 3 4]
slice1 = Append(slice1, slice2...) // 这个'...' is 必须的!,但是当第二个参数是切片的时候,就不能传入第三个切片了
fmt.Println(slice1) // [0 1 2 3 4 55 66 77]

当然,我们可以使Append更加的高效,重新分配的时候可以是原先的1.5倍。就是这样:

func Append(slice []int, elements ...int) []int {
    n := len(slice)
    total := len(slice) + len(elements)
    if total > cap(slice) {
        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:内置函数

根据上边提到的,我们就设计了一个append内置函数,它实际上和上述的Append例子是一样的,效率相同,但是它可以作用于任何类型的切片。 请记住,因为在调用append函数时切片头总是更新,所以你需要保存返回值。事实上,编译器不允许在不保存结果的情况下调用。 这有一些demo,可以试着改变一下:

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

// 添加一个元素到切片 slice.
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)

Nil

现在我们来了解一下nil切片,事实上,它的sliceHeder都是空值

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

关键点是元素的指针也是nil

Strings

现在我们简单的介绍一下字符串。字符串非常简单:它是一个只读的byte类型的切片,并具有一些额外的语法。 因为它是只读的,所以不需要容量(你不能扩容),但是大多数情况下你可以像只读切片一样操作。 我们可以使用索引来访问单个字节:

slash := "/usr/ken"[0] // 可以得到值'/'

也可以对字符串进行切片来得到字串:

usr := "/usr/ken"[0:4] // 可以得到"/usr"

我们也可以通过简单的转换,把byte类型的切片转换为string

str := string(slice)

也可以执行相反的操作

slice := []byte(usr)

字符串所在的数组是不可见的,没有任何方式去访问它们,除了通过字符串。也就是意味着当我们做类型转换的时候,数组的copy也就发生了,我们不用去关心。还有就是当我们修改定义数组的元素的时候,字符串不会受到影响。 通过类似于切片的形式来设计字符串,对操作字串是非常的高校的。因为字符串是只读的,原始字符串和通过字符串操作产生的子字符串是共享了同一个数组的。

--- 待续 --- 参考: Arrays, slices (and strings): The mechanics of 'append'