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]
. (索引范围为0到255覆盖了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中。
我们也可以reslice,slice一个slice变量并且将返回值存储到原slice中。
slice = slice[5:10]
这时
slcie
变量的
sliceHeader
和
slice2
变量饿该结构就是相同的。下边这个操作抛弃了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
方法在
for
range
结构中使用了两个变量
,用于捕捉索引和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操作该数组,但更简单的方法就是使用内建函数make
。make
分配一个新的数组,然后创建一个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)
在运行这段代码后,gophers
slice长度和容量都为10.
Copy
之前倍增slice的容量时,我们写了一个循环来将旧数据复制到新的slice。Go有一个内建函数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 }
注意
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
函数之所以有趣,还有另外一个原因。我们不止能够添加元素,还能添加整个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
函数。它将我们的int
slice版本扩展到所有的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)可以增长,但是nil
slice并没有可以存放值的数组,也就永远不能增长。
也就是说,一个nil
slice在功能上等价于一个长度为0的slice,尽管它没有指向任何位置。它长度为0,经过内存分配后也可以附加。上边就有一个例子,附加到一个nil
slice.
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