Go语言学习指南:惯例模式与编程实践
上QQ阅读APP看书,第一时间看更新

3.2 切片

如果需要一种结构来存储一系列值,那么大多数情况下切片(slice)都是更好的选择。切片的长度并不是切片类型的一部分,这样就消除了数组的限制。我们可以编写函数来处理任意长度的切片(详见第5章),并且可以按需修改切片的长度。让我们先了解在Go中切片的基本使用方法,然后进行实践学习。

使用切片与使用数组非常相似,但是有一些细微的区别。首先,我们在声明切片时并没有指定它的长度:

 使用[...]定义数组,使用[]定义切片。

使用这样的切片字面量会创建具有3个整数的切片。就像数组一样,我们也可以指定其中的一部分值:

这会创建一个包含12个整数的切片:[1,0,0,0,0,4,6,0,0,0,100,15]。

也可以这样定义多维切片:

与数组一样,可以通过中括号的形式来读写切片的内容,但是依然不能越界读写:

到目前为止,切片和数组没有区别。而不同之处在于,我们可以定义一个不带字面量的切片:

这会创建一个整型切片。因为没有赋值,所以x被赋予切片的零值nil(我们将在第6章讨论nil)。注意,nil和其他语言中的null稍有区别。在Go中,nil是一个标识符,代表某些类型没有值。与上一章中谈到的无类型数字常量一样,nil没有类型,因此它能被赋值或者和其他类型进行比较。如果一个切片不包含任何内容,那么值就是nil

切片不能被比较。使用==或者!=比较两个切片都会导致编译错误。切片只能和nil进行比较:

 reflect这个包包含一个DeepEqual函数,它可以用来比较几乎所有的类型(包括切片)。这个函数尽管常用于测试,但是也可以用来比较两个切片,详见第14章。

3.2.1 len

Go为内置类型提供了内置函数。我们已经见过complexrealimag这些用来处理复数的内置函数。切片也有内置函数,数组中使用的len同样适用于切片。如果对len传入值为nil的切片,就会返回0。

 len函数的功能是普通函数无法实现的。len函数的参数可以接受任意类型的数组或者切片。它也适用于字符串和映射,在10.3节中,我们还能看到它也适用于通道类型。而对除此以外的类型使用len函数会导致编译错误(详见第5章)。

3.2.2 append

内置函数append用于为切片增加元素:

append函数接受至少两个参数,一个为任意类型的切片,另一个为该类型的值。返回的结果为同一类型的切片。这个返回的切片被赋值回了原切片。在以上的代码中,我们给值为nil的切片添加新元素,当然也能给包含内容的切片添加新元素:

还可以一次性添加多个值:

一个切片可以通过...操作符添加到另一个切片中,将源切片展开为各个值(详见5.1.2节):

如果忘记把append的结果赋值给源切片,则会导致编译时错误,这看起来似乎有些重复。我们会在第5章详细讨论,简而言之,Go是一种传值调用语言,每次将参数传递给函数时,Go会将参数拷贝一份,因此对append传入切片实质上是传入了切片的拷贝,返回的也是这个拷贝的值。最终需要将新的切片重新赋值给源切片。

3.2.3 容量

正如我们所看到的,切片是一个数值序列,切片中的值占用内存中连续的地址,因此能极快地读写这些值。切片在连续内存地址的大小就是切片的容量。容量可能会大于切片的长度,每次为切片添加一个元素时,都会将一个或者多个值添加到切片的尾部,容量也相应增加。如果最终所需的长度超出了容量大小,则append函数使用Go运行时分配一个有更大容量的切片,然后将原切片的内容拷贝到新的切片中,将需要添加的值加到新切片的末尾,最终返回新的切片。

Go运行时

每一种高级语言都需要一组库来保证语言的运行,Go也不例外。Go运行时提供了内存分配和垃圾回收、并发支持、联网,以及内置类型和函数的实现等服务。

Go运行时被编译到每个二进制文件中。这种方式与其他使用虚拟机的语言不同,使用虚拟机的语言要求虚拟机单独安装以便程序能正常工作。将运行时包含在二进制文件中的方式能够更轻松地分发Go程序,也避免了运行时和程序之间的兼容性问题。

当切片通过append添加元素时,Go运行时需要时间来分配新的内存,并将数据从旧的内存空间拷贝到新的内存空间。旧的内存空间会进行垃圾回收。由于这个原因,Go运行时通常会在超出容量时至少增加一个切片。在Go 1.14版本中,规则为当切片的容量小于1024时将切片的长度加倍,之后每次扩容至少增加25%。

就像内置的len函数返回切片当前的长度一样,内置的cap函数会返回切片当前的容量。cap没有len常用,大部分时候,cap用来检查一个切片是否足够容纳新的数据,或者是否需要通过调用make来创建新的切片。

cap函数也接受数组作为参数,但是对数组使用caplen的结果是一样的,所以不建议对数组使用cap函数。

我们现在来看看给切片添加元素怎么改变切片的长度和容量。在The Go Playground(https://oreil.ly/yiHu-)或者本机执行示例3-1的代码。

示例3-1:了解容量

执行完代码后,你会看到如下的结果。注意容量是如何变化的:

虽然切片能够自动扩容,但是一次性确定容量则会更加高效。对于已知需要将多少元素放入切片的情况,我们可以使用make函数创建所需容量的切片。

3.2.4 make

我们已经知道了两种声明切片的方法:使用切片字面量或者nil零值。但是这两种方法都不能创建一个指定长度或容量的空切片。内置的make函数能做到这一点,它可以指定类型、长度以及可选的容量。我们现在来看看:

这会创建一个长度为5、容量为5的整型切片。因为指定了长度为5,所以x[0]x[4]都是有效的元素,它们的初始值都是0。

一个常见的新手错误是使用append为切片添加初始元素:

10被添加到切片的尾部,也就是0~4位置的零值之后,因为append会增加切片的长度。现在x的值是[0 0 0 0 0 10],长度为6,容量为10(因为添加了第6个值,切片的容量增加了一倍)。

我们可以在make中指定初始容量:

这会创建一个长度为5、容量为10的整型切片。

你还可以创建一个长度为零但容量大于零的切片:

也可以创建一个长度为0但容量为10的切片。因为长度为0,所以不能直接使用索引来访问数据,但是可以给它添加值:

现在x的值是[5 6 7 8],其长度为4,容量为10。

 永远不要设置比长度小的容量!使用常数和数字字面量会出现编译时错误。但如果使用变量来指定容量,一旦出现容量小于长度的情况,就会导致程序在运行时崩溃。

3.2.5 切片的声明

我们已经了解了所有创建切片的方式,那么如何选择合适的声明方式呢?首要目标是最小化切片增长的次数。如果一个切片不会增长(因为函数可能不会返回任何内容),那么使用没有赋值的var声明来创建一个nil切片,如示例3-2所示。

示例3-2:声明一个为nil的切片

 切片可以通过空切片字面量定义:

这会创建一个零长度切片,但这并不是nil(和nil比较会返回false)。除此之外,nil切片和零长度切片没有区别。只有在需要将切片转换为JSON时需要用到零长度切片,详见11.3节。

如果我们知道初始值,或者切片中的值不会再变动,那么使用切片字面量是一个好的选择,参见示例3-3.

示例3-3:声明一个有默认值的切片

如果希望设置切片的大小,但却无法确定具体的值,请使用make。但是在调用make时,应该指定非零长度还是指定零长度和非零容量呢?有以下三种可能:

  • 如果将切片作为缓冲区(详见11.1节),则指定非零长度。
  • 如果已知所需的大小,可以指定长度,然后通过索引设置值。这种方式一般用于将数据从一个切片存储到另一个切片中。它的缺点也显而易见,如果长度定义错误,切片会在末尾包含额外的零值,或者在索引时造成崩溃。
  • 除此之外,可以使用make定义零长度并指定容量。这可以让我们通过append向切片中添加值。如果实际的元素数量偏少,切片不会在末尾包含多余的零值;即使数量超出,代码也不会造成崩溃。

Go社区一般会采用第二种和第三种方案。我个人倾向对初始化为零长度的切片使用append。这种方式在有的情况下会比较慢,但是不容易导致问题。

 append总是会增加切片的长度!如果使用make指定了切片的长度,那么一定要确定是要执行添加的操作,否则切片的起始部分会出现意料之外的零值。

3.2.6 派生切片

派生表达式用来从切片创建切片。这种方式是在中括号中指定起始偏移量和结束偏移量,并使用冒号(:)隔开。如果不定义起始偏移量,则默认取值为0;如果不定义结束偏移量,则默认取值为切片的实际长度。可以在The Go Playground(https://oreil.ly/DW_FU)上运行示例3-4的程序了解派生切片的工作原理。

示例3-4:派生切片

结果如下所示:

切片有时会共享内存

如果是取一个切片的子切片,那么并不会执行拷贝操作。两个切片会共享内存。这也意味着更改一个切片的元素会影响所有共享该元素的切片。我们通过在The Go Playground(https://oreil.ly/mHxe4)中执行示例3-5的代码,看看改变值会发生什么。

示例3-5:共享内存的切片

结果如下:

可以看到,改变x也改变了yz的值,改变yz也影响了x

如果对子切片执行append操作,会导致切片更加混乱。在The Go Playground(https://oreil.ly/2mB59)中执行示例3-6的代码。

示例3-6:append让内存重叠的切片更加混乱

结果如下:

这是怎么回事呢?只要是从一个切片得到的子切片,该子切片的容量就是原切片的容量减去子切片的偏移量。这也意味着所有原切片中未使用的容量都和子切片共享。

当我们从x创建一个y切片,并且长度设置为2,容量设置为与x一样的4时,在y的末尾添加一个元素就会成为x的第三个索引的值。

这种行为会产生一些奇怪的状况,导致多个切片添加并互相覆盖数据。在The Go Playground(https://oreil.ly/1u_tO)中运行示例3-7的代码,并猜测结果。

示例3-7:更加令人困惑的切片

为了避免复杂的切片行为,要么永远不要对子切片使用append,要么对append使用完全派生表达式来避免出现覆盖数据的情况。这看起来有些奇怪,不过这样可以清楚地知道父切片和子切片共享哪些内存。完全派生表达式包含第三部分,该部分定义了父切片容量中的最后位置,用来确定子切片可以使用的容量,将其减去起始偏移量就能得到子切片的实际容量。示例3-8中修改了示例3-7中的第三行和第四行,转而使用完全派生表达式。

示例3-8:完全派生表达式避免append的影响

你可以在The Go Playground(https://oreil.ly/Cn2cX)中运行完整的代码。yz的容量都为2。因为我们限制子切片的容量为它们的长度,所以对yz添加新的元素会创建新的切片,这样就不会和原来的切片互相影响。代码运行后,x的值为[1 2 3 4 60],y的值为[1 2 30 40 50],z的值为[3 4 70]。

 使用切片的派生操作要注意!这些切片会共享内存,改变其中一个会影响其他的切片。因此要避免修改派生出的子切片,或者使用完全派生表达式避免使用append造成的切片共享容量的问题。

3.2.7 数组转换为切片

切片不是唯一能派生的类型。对于数组,也可以使用派生表达式。这对于有一个数组但是函数只接受切片的情况很有用。但是需要注意的是,对数组派生也存在内存共享问题。如果在The Go Playground(https://oreil.ly/kliaJ)中运行以下代码:

会得到如下输出:

3.2.8 copy

如果需要创建一个独立于原切片的切片,可以使用copy函数。我们来看一个简单的代码示例,可以直接在The Go Playground(https://oreil.ly/ilMNY)中运行:

得到结果:

copy函数接受两个参数:目标切片和源切片。拷贝操作会从源切片拷贝尽可能多的数据到目标切片,然后返回拷贝的元素数量,其限制仅仅在于哪个切片的容量更小。在这种情况下,xy的容量不重要,长度至关重要。

不需要拷贝整个切片。以下代码会将一个包含四个元素切片的前两位拷贝为一个新的两元素切片:

变量y设为[1 2],num设为2。

copy也可以从源切片的中间进行拷贝:

我们通过派生x切片的方式拷贝第三个元素和第四个元素。另外要注意,我们并没有将copy的返回值赋值给一个变量。如果不需要知道拷贝了多少元素,就不需要进行赋值。

copy函数还允许拷贝两个共享部分数据的子切片:

在这种情况下,x中的最后三个值被拷贝到了x的前三个值,最终打印[2 3 4 4] 3。

copy函数可以用于数组。数组既可以作为拷贝的源也可以作为目标。在The Go Playground(https://oreil.ly/-mhRW)中执行以下代码:

第一次调用copyd数组中的最后两个值拷贝到了切片y中。第二次调用则拷贝x中所有的值到数组d中。以上代码的输出结果如下: