Arrays,slices (and strings): The mechanics of 'append'

Arrays,slices (and strings): The mechanics of 'append'

介绍

过程式编程语言中,一个最常见的特性就是数组这个概念。数组看起来很简单,但是把它们加入语言时有很多问题需要考虑啊,例如:

  • 大小固定还是可变?

  • 大小是否是类型的一部分?

  • 多维数组看起来是什么样子?

  • 空数组有含义吗?

这些问题的答案影响到一个决策,数组仅仅是语言的一个特性还是语言设计的核心之一。

Go的开发前期,在感觉到设计合理之前,大概花了一年时间来决定这些问题的答案。关键步骤是slice的导入,slice基于一个固定大小的数组,且实现了灵活的、可扩展的数据结构。直到今天,Go的新开发人员们依然对slice的工作方式感觉到困惑,可能是因为在其他语言上的工作经验使得他们带有思维定势.

在这篇文章后,我们试图澄清这些误解。我们通过一个个代码片段来解释内建函数append如何工作,以及为何如此工作。

数组

Go中,数组是一个非常重要的构建元素,但是类似于建筑的基石一样,它们通常隐藏在其他可见部件之下。在开始更有趣、更强大、更突出的slice概念前,我们必须简单的谈谈数组。

Go程序中,不会常常看到数组,因为大小是数组类型的一部分,这一特性限制了其表示能力。

声明

var buffer [256]byte

声明了变量buffer,它具有256个字节。buffer的类型包含了其大小,[256]byte.一个大小为512个字节的数组和buffer具有不同的类型,[512]byte.

和数组关联的数据仅仅是:一个元素数组。从图上看来,buffer变量在内层中类似于

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

也就是说,该变量具有256个字节的数组,仅此而已。我们可以使用相同熟悉的索引语法来访问其元素,buffer[0],buffer[1], and so on throughbuffer[255]. (索引范围为0255覆盖了256个元素.)尝试越界的下标来访问buffer会毁坏程序.

有一个名len的内建函数,返回了数组/slice以及其他一些数据类型的元素数目。对于数组,len的返回值是很显然的。在我们的例子中,len(buffer)返回一个固定值256.

数组有其用处---它们非常适合表示了一个矩阵-----但是在Go中它们的通常目的是为slice持有一个存储。

Slices:The slice header

Slice是动作发生的地方,但是要用好它,必须准确理解它是什么和它如何工作。

Slice是一个描述了数组某个连续区段的数据结构,数组存储在别的地方。Slice不是数组。Slice描述了数组的一部分。

使用之前定义的buffer数组变量,我们可以创建一个描述下标为[100,149]的元素集,通过slice该数组:

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

在这个代码片段中,为了更明白,我们使用了完整的变量声明。变量slice类型为[]byte,buffer数组进行初始化,通过slice其元素。更简洁的语法是去掉类型,类型由初始化表达式设置:

var slice = buffer[100:150]

在函数中,我们可以使用短声明格式,

slice := buffer[100:150]

这个slice变量究竟是什么呢?可以简单的将slice认为是一个小的数据结构,只有两个元素:长度和指向数组元素的指针。类似于:

type sliceHeader struct {
    Length        int
    ZerothElement *byte
}

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

我们已经在数组上使用了一个slice操作,我们还可以slice这个slice::

slice2 := slice[5:10]

和前边一样,这个操作创建了一个新的slice,这个例子里是slice变量里下标为[5,9]的元素,也就是原数组中下标[105,109]的元素。slice2变量的底层的sliceHeader结构类似于:

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

注意,这个header依然指向同一个底层数组,存储在buffer中。

我们也可以resliceslice一个slice变量并且将返回值存储到原slice中。

slice = slice[5:10]

这时slcie变量的sliceHeaderslice2变量饿该结构就是相同的。下边这个操作抛弃了slice变量的第一个和最后一个元素:

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

[练习:写出这条语句结束后,sliceHeader结构的燕子.]

你可能会经常听到有经验的Go程序员谈及"sliceheader",因为它才是存储在slice变量中的真实数据。例如,当你调用一个使用了slice类型参数的函数时,如bytes.IndexRune,这个header才是传递到该函数的真实数据。在下边的调用中,

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

传递给IndexRune函数的slice参数,实际上就是一个"sliceheader".

Sliceheader中还有一个数据元素,我们下边会谈到,但是首先让我们看看当你使用slice编程时,sliceheader意味着什么.

传递slice到函数Passingslices to functions

必须意识到,景观slice变量包含一个指针,它依然是一个值。它是一个包含了指针和长度的数据结构,而不是指向结构的指针。这一点很重要。

在前边的例子里,当我们调用IndexRune函数时,传递给它一个sliceheader的拷贝。这个行为有很重要的影响。

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

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

该函数的行为已经由名称表明了,在一个slice的索引上迭代(使用了forrange循环),增加了其元素的值。试一下:

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



尽管这个 sliceheader是值传递,header包含了指向数组元素的指针,所以愿header和传递给函数的拷贝描述了同一个数组。当函数返回后,修改后的元素可以通过原slice变量观察到。.

正如下边的例子描述的那样,函数的参数只是一个拷贝T

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参数的内容可以被函数修改,但函数却不能修改其header。调用函数并没有修改存储在slice变量中的长度值,因为传递给函数的只是header的一个拷贝。因此,如果我们需要编写一个修改header的函数,那么必须将其当做返回值,就像我们上边做的那样。

指向slice的指针:方法的receivers

另一种使用函数来修改sliceheader的方法,就是传递一个指向slice的指针。下边是前边例子的一个变种:

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

这段代码看起来有些笨拙,特别是多了一层间接引用,但这是使用指向slice指针的通常情况。.对于需要修改slice的方法而言,一个指针receiver通常是惯用法。

如果说我们有个slice对象上的方法需要截断该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") // Conversion from string to path.
    pathName.TruncateAtFinalSlash()
    fmt.Printf("%s\n", pathName)
}

如果你运行这个例子,那么你将看到代码工作正常,在调用中更新了该slice

[练习:receiver的类型更改为值类型而不是指针,重新运行。解释将发生的情况。]

另一方面,如果我们想要编写path类型的另外一个方法,将path中的小写字符更改为对应的大写字符(暂时忽略非英语名称)。该方法可以使用值类型作为receiver,因为值同样指向相同的底层数组。

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方法在forrange结构中使用了两个变量,用于捕捉索引和slice元素。迭代的这种格式避免了在循环体重写两次p[i]

[练习:将ToUpper方法转换为使用指针做receiver,看看最终结果有无变化。]

[升级练习:ToUpper方法转换为可以处理所有的Unicode字符]

容量

看看下边的函数,给作为参数的slice添加了一个元素

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

(为什么需要返回修改后的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如何增长,直到...它不再增长。

该谈谈sliceheader的第三个部件了,其容量。除了数组指针和长度外,sliceheader存储了它的容量:type sliceHeader struct {

    Length        int
    Capacity      int
    ZerothElement *byte
}

Capacity字段记录了底层数组还有多少空间,也就是Length可以达到的最大值。尝试将slice增长到越过其底层数组的限制,将引发一个panic.

上例中,当我们创建了slice后,

slice := iBuffer[0:0]

header类似于:

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

Capacity字段等于底层数组的长度,减去slice第一个元素在底层数组中的索引(这里是0).。如果你需要查看slice的容量,使用内建函数cap:

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

如果我们需要增长slice以致超过其容量呢?这是不能的!容量被定义为增加的极限。但是可以通过分配一个新数组,复制数据,修改slice来描述新数组,得到等价的结果。

让我们从分配开始。我们可以使用内建函数new来分配一个更多的数组,然后slice操作该数组,但更简单的方法就是使用内建函数makemake分配一个新的数组,然后创建一个sliceheader来描述该数组,一次完成全部操作。make函数接受3个参数:slice的类型,初始长度,容量,容量描述了将要分配的底层数组的长度。下边的调用创建了一个长度为10,以及可以多放5个元素的slice:

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

下边的代码片段,倍增了intslice的容量,同时保持了其长度

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

运行这段代码后,在需要再次内存分配前,slice有了更多的增长空间。

当创建slice时,通常长度和空间是相同的。make函数有一种更简单的方式,专门用于这种情况。.

gophers := make([]Gopher, 10)

在运行这段代码后,gophersslice长度和容量都为10.

Copy

之前倍增slice的容量时,我们写了一个循环来将旧数据复制到新的sliceGo有一个内建函数copy,使得这个工作更为简单。它使用两个slice作为参数,将右边参数中的数据复制到左边参数中。下边是使用copy重写的代码:

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

copy函数很聪明。它只复制它能够复制的数据,同时关心两个参数的长度。换句话说,它复制的元素数量等于两个slice长度的最小值。这就节省了一些记录工作。同时,copy返回一个整数值,表示复制了的元素数量,虽然这个值并不总值得检查。

即使来源和目的地重叠,copy函数依然正确工作,因此它可以用作在slice内部移动元素。下边就是如何使用copy在一个slice的中间插入元素.

// Insert 在指定的索引位置插入一个值,该索引必须合法
// slice也必须有额外的空间
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,因为它的长度发生了变化。其次,使用了一个方便的简写。表达式

slice[i:]

和以下的表达式完全等价

slice[i:len(slice)]

同样,尽管我们还没有使用这个技巧,我们也可以将slice表达式的第一个元素留空;它默认等于0。于是

slice[:]

就相等于整个slice,这在slice操作一个数组时很有用。这是最简单的方式来表示一个描述了整个数组的slice

array[:]

有些跑题了,让我们运行这个Insert函数

    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函数,给slice增加了一个元素。它有bug,因为slice的容量太小,函数会出问题。(我们的Insert例子有同样的问题)现在,我们来修正这个这问,写一个Extend的健壮实现,用于元素类型为整数的slice.

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是特别重要的,因为结果slice描述了一个新分配的数组。下边的代码片段展示了当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的初始数组已满时惊醒的重新分配。slice的容量及其第一个元素的地址同时发生了改变。

有了这个健壮的Extend函数作为示范,我们可以写出更好的代码,允许以多个元素的方式扩展slice。我们使用了Go的一个特性,在调用函数时,将参数列表转换为一个slice。也就是说,我们使用了Go的可变函数功能。

我们把这个函数叫做Append.。在第一个版本中,我们只是重复调用Extend函数,所以机制很简单。.Append函数的签名为:

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

它表明, Append接受一个slice参数,然后是零个或者过个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
}

注意forrange迭代了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函数之所以有趣,还有另外一个原因。我们不止能够添加元素,还能添加整个slice,方法是使用...记号来将slice展开为参数列表:

    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,一次将slice的元数据复制到新分配的内存中,另外一次将需要附加的元素移动到元数据的末尾。.

试一下,和之前版本的结果一样 :

    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完全相同,效率有了提升,同时对所有类型的slice有效。

Go的一个弱点出现了,所有泛型操作都必须由运行时提供。这一点可能日后会改变,但是目前为了使得slice工作更加简单,Go提供了一个通用的append函数。它将我们的intslice版本扩展到所有的slice类型。

记住,由于每次调用append都会修改slicehead,需要将返回的slice保存。事实上,编译器根本不允许不保存的情况出现.

下边是一些简单的例子。试着运行它们,修改它们来获得更多信息:

    // 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的设计是如何使得这个简单调用正确工作。

有更多关于 append,copy以及其他使用slices的例子,在社区构建的"SliceTricks" Wiki page.

Nil

题外话,有了新知识,我们可以看看一个nilslice的表示形式。很自然的,它可以使一个0值的head:

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

或者仅仅是

sliceHeader{}

关键细节是元素指针也是nil下边创建的slice

array[0:0]

长度为0(甚至容量也可能为0),但是它的指着不是nil,所以它不是一个nilslice.

现在已经很清楚了,一个空的slice(假设其容量不为0)可以增长,但是nilslice并没有可以存放值的数组,也就永远不能增长。

也就是说,一个nilslice在功能上等价于一个长度为0slice,尽管它没有指向任何位置。它长度为0,经过内存分配后也可以附加。上边就有一个例子,附加到一个nilslice.

Strings

现在,在slice上下文中,我们简单的考虑以下Go的字符串。

事实上,字符串非常简单:只读的字节slice,以及一些来自语言的语法支持。

由于它是只读的,也就没有容量这个概念了(因为不可增长),但是其他大多数情况下可以将其当做只读的字节slice.

对于刚起步者,我们可以通过索引来访问单独的字节:

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

我们可以在字符串上执行slice操作,获得一个子字符串:

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

当我们slice一个字符串时,发生的事情现在已经很显然了。

我们可以通过一个简单的类型转换,从一个普通的字节slice构建一个字符串:

str := string(slice)

也可以逆向操作:

slice := []byte(usr)

字符串的底层数组是不可见的,除了通过字符串外,没有别的方法来访问该数组的内容。这就意味着,当我们执行这两种转换时,数组肯定会进行一次复制。Go完成了这个操作,所以你不需要担心。当转换完成后,修改字节slice的底层数组,不会影响到对应的字符串。

这种类似于slice的字符串设计带来一个严重的后果,创建一个子字符串是非常高效的。所有需要做的只不过是创建一个两个机器字大小的字符串header。由于字符串是只读的,原字符串和由slice操作得到的子字符串可以安全的共享底层数组。

早期,字符串的实现总是需要分配内存,当slice加入后,它提供了高效处理字符串的模型。其结果可以从某些评分看出,有巨大的提升。

结论Conclusion

理解slice如何实现,有助于理解它们如何工作。很小的一个数据结构关联到slice变量,sliceheader,它描述了另外分配的数组的某个部分。当传递slice值时,header被复制和传递,而它指向的数组则总是被共享的。



更多阅读

"SliceTricks" Wiki page有许多例子。

GoSlices博客以图的形式描述了其内存布局。

RussCox'sGoData Structures包含了一个关于slice和其他Go的内部数据结构的讨论

尽管有非常多的资料,但最好的学习方式就是使用它。

By Rob Pike

翻译: fighterlyt


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值