极客时间已完结课程限时免费阅读

07 | 数组和切片

07 | 数组和切片-极客时间

07 | 数组和切片

讲述:黄洲君

时长12:12大小5.58M

从本篇文章开始,我们正式进入了模块 2 的学习。在这之前,我们已经聊了很多的 Go 语言和编程方面的基础知识,相信你已经对 Go 语言的开发环境配置、常用源码文件写法,以及程序实体(尤其是变量)及其相关的各种概念和编程技巧(比如类型推断、变量重声明、可重名变量、类型断言、类型转换、别名类型和潜在类型等)都有了一定的理解。
它们都是我认为的 Go 语言编程基础中比较重要的部分,同时也是后续文章的基石。如果你在后面的学习过程中感觉有些吃力,那可能是基础仍未牢固,可以再回去复习一下。
我们这次主要讨论 Go 语言的数组(array)类型和切片(slice)类型。数组和切片有时候会让初学者感到困惑。
它们的共同点是都属于集合类的类型,并且,它们的值也都可以用来存储某一种类型的值(或者说元素)。
不过,它们最重要的不同是:数组类型的值(以下简称数组)的长度是固定的,而切片类型的值(以下简称切片)是可变长的。
数组的长度在声明它的时候就必须给定,并且之后不会再改变。可以说,数组的长度是其类型的一部分。比如,[1]string[2]string就是两个不同的数组类型。
而切片的类型字面量中只有元素的类型,而没有长度。切片的长度可以自动地随着其中元素数量的增长而增长,但不会随着元素数量的减少而减小。
(数组与切片的字面量)
我们其实可以把切片看做是对数组的一层简单的封装,因为在每个切片的底层数据结构中,一定会包含一个数组。数组可以被叫做切片的底层数组,而切片也可以被看作是对数组的某个连续片段的引用。
也正因为如此,Go 语言的切片类型属于引用类型,同属引用类型的还有字典类型、通道类型、函数类型等;而 Go 语言的数组类型则属于值类型,同属值类型的有基础数据类型以及结构体类型。
注意,Go 语言里不存在像 Java 等编程语言中令人困惑的“传值或传引用”问题。在 Go 语言中,我们判断所谓的“传值”或者“传引用”只要看被传递的值的类型就好了。
如果传递的值是引用类型的,那么就是“传引用”。如果传递的值是值类型的,那么就是“传值”。从传递成本的角度讲,引用类型的值往往要比值类型的值低很多。
我们在数组和切片之上都可以应用索引表达式,得到的都会是某个元素。我们在它们之上也都可以应用切片表达式,也都会得到一个新的切片。
我们通过调用内建函数len,得到数组和切片的长度。通过调用内建函数cap,我们可以得到它们的容量。
但要注意,数组的容量永远等于其长度,都是不可变的。切片的容量却不是这样,并且它的变化是有规律可寻的。
下面我们就通过一道题来了解一下。我们今天的问题就是:怎样正确估算切片的长度和容量?
为此,我编写了一个简单的命令源码文件 demo15.go。
package main
import "fmt"
func main() {
// 示例1。
s1 := make([]int, 5)
fmt.Printf("The length of s1: %d\n", len(s1))
fmt.Printf("The capacity of s1: %d\n", cap(s1))
fmt.Printf("The value of s1: %d\n", s1)
s2 := make([]int, 5, 8)
fmt.Printf("The length of s2: %d\n", len(s2))
fmt.Printf("The capacity of s2: %d\n", cap(s2))
fmt.Printf("The value of s2: %d\n", s2)
}
我描述一下它所做的事情。
首先,我用内建函数make声明了一个[]int类型的变量s1。我传给make函数的第二个参数是5,从而指明了该切片的长度。我用几乎同样的方式声明了切片s2,只不过多传入了一个参数8以指明该切片的容量。
现在,具体的问题是:切片s1s2的容量都是多少?
这道题的典型回答:切片s1s2的容量分别是58

问题解析

解析一下这道题。s1的容量为什么是5呢?因为我在声明s1的时候把它的长度设置成了5。当我们用make函数初始化切片时,如果不指明其容量,那么它就会和长度一致。如果在初始化时指明了容量,那么切片的实际容量也就是它了。这也正是s2的容量是8的原因。
我们顺便通过s2再来明确下长度、容量以及它们的关系。我在初始化s2代表的切片时,同时也指定了它的长度和容量。
我在刚才说过,可以把切片看做是对数组的一层简单的封装,因为在每个切片的底层数据结构中,一定会包含一个数组。数组可以被叫做切片的底层数组,而切片也可以被看作是对数组的某个连续片段的引用。
在这种情况下,切片的容量实际上代表了它的底层数组的长度,这里是8。(注意,切片的底层数组等同于我们前面讲到的数组,其长度不可变。)
现在你需要跟着我一起想象:有一个窗口,你可以通过这个窗口看到一个数组,但是不一定能看到该数组中的所有元素,有时候只能看到连续的一部分元素。
现在,这个数组就是切片s2的底层数组,而这个窗口就是切片s2本身。s2的长度实际上指明的就是这个窗口的宽度,决定了你透过s2,可以看到其底层数组中的哪几个连续的元素。
由于s2的长度是5,所以你可以看到底层数组中的第 1 个元素到第 5 个元素,对应的底层数组的索引范围是[0, 4]。
切片代表的窗口也会被划分成一个一个的小格子,就像我们家里的窗户那样。每个小格子都对应着其底层数组中的某一个元素。
我们继续拿s2为例,这个窗口最左边的那个小格子对应的正好是其底层数组中的第一个元素,即索引为0的那个元素。因此可以说,s2中的索引从04所指向的元素恰恰就是其底层数组中索引从04代表的那 5 个元素。
请记住,当我们用make函数或切片值字面量(比如[]int{1, 2, 3})初始化一个切片时,该窗口最左边的那个小格子总是会对应其底层数组中的第 1 个元素。
但是当我们通过切片表达式基于某个数组或切片生成新切片的时候,情况就变得复杂起来了。
我们再来看一个例子:
s3 := []int{1, 2, 3, 4, 5, 6, 7, 8}
s4 := s3[3:6]
fmt.Printf("The length of s4: %d\n", len(s4))
fmt.Printf("The capacity of s4: %d\n", cap(s4))
fmt.Printf("The value of s4: %d\n", s4)
切片s3中有 8 个元素,分别是从18的整数。s3的长度和容量都是8。然后,我用切片表达式s3[3:6]初始化了切片s4。问题是,这个s4的长度和容量分别是多少?
这并不难,用减法就可以搞定。首先你要知道,切片表达式中的方括号里的那两个整数都代表什么。我换一种表达方式你也许就清楚了,即:[3, 6)。
这是数学中的区间表示法,常用于表示取值范围,我其实已经在本专栏用过好几次了。由此可知,[3:6]要表达的就是透过新窗口能看到的s3中元素的索引范围是从35(注意,不包括6)。
这里的3可被称为起始索引,6可被称为结束索引。那么s4的长度就是6减去3,即3。因此可以说,s4中的索引从02指向的元素对应的是s3及其底层数组中索引从35的那 3 个元素。
(切片与数组的关系)
再来看容量。我在前面说过,切片的容量代表了它的底层数组的长度,但这仅限于使用make函数或者切片值字面量初始化切片的情况。
更通用的规则是:一个切片的容量可以被看作是透过这个窗口最多可以看到的底层数组中元素的个数。
由于s4是通过在s3上施加切片操作得来的,所以s3的底层数组就是s4的底层数组。
又因为,在底层数组不变的情况下,切片代表的窗口可以向右扩展,直至其底层数组的末尾。
所以,s4的容量就是其底层数组的长度8, 减去上述切片表达式中的那个起始索引3,即5
注意,切片代表的窗口是无法向左扩展的。也就是说,我们永远无法透过s4看到s3中最左边的那 3 个元素。
最后,顺便提一下把切片的窗口向右扩展到最大的方法。对于s4来说,切片表达式s4[0:cap(s4)]就可以做到。我想你应该能看懂。该表达式的结果值(即一个新的切片)会是[]int{4, 5, 6, 7, 8},其长度和容量都是5

知识扩展

问题 1:怎样估算切片容量的增长?
一旦一个切片无法容纳更多的元素,Go 语言就会想办法扩容。但它并不会改变原来的切片,而是会生成一个容量更大的切片,然后将把原有的元素和新元素一并拷贝到新切片中。在一般的情况下,你可以简单地认为新切片的容量(以下简称新容量)将会是原切片容量(以下简称原容量)的 2 倍。
但是,当原切片的长度(以下简称原长度)大于或等于1024时,Go 语言将会以原容量的1.25倍作为新容量的基准(以下新容量基准)。新容量基准会被调整(不断地与1.25相乘),直到结果不小于原长度与要追加的元素数量之和(以下简称新长度)。最终,新容量往往会比新长度大一些,当然,相等也是可能的。
另外,如果我们一次追加的元素过多,以至于使新长度比原容量的 2 倍还要大,那么新容量就会以新长度为基准。注意,与前面那种情况一样,最终的新容量在很多时候都要比新容量基准更大一些。更多细节可参见runtime包中 slice.go 文件里的growslice及相关函数的具体实现。
我把展示上述扩容策略的一些例子都放到了 demo16.go 文件中。你可以去试运行看看。
问题 2:切片的底层数组什么时候会被替换?
确切地说,一个切片的底层数组永远不会被替换。为什么?虽然在扩容的时候 Go 语言一定会生成新的底层数组,但是它也同时生成了新的切片。
它只是把新的切片作为了新底层数组的窗口,而没有对原切片,及其底层数组做任何改动。
请记住,在无需扩容时,append函数返回的是指向原底层数组的原切片,而在需要扩容时,append函数返回的是指向新底层数组的新切片。所以,严格来讲,“扩容”这个词用在这里虽然形象但并不合适。不过鉴于这种称呼已经用得很广泛了,我们也没必要另找新词了。
顺便说一下,只要新长度不会超过切片的原容量,那么使用append函数对其追加元素的时候就不会引起扩容。这只会使紧邻切片窗口右边的(底层数组中的)元素被新的元素替换掉。你可以运行 demo17.go 文件以增强对这些知识的理解。
总结
总结一下,我们今天一起探讨了数组和切片以及它们之间的关系。切片是基于数组的,可变长的,并且非常轻快。一个切片的容量总是固定的,而且一个切片也只会与某一个底层数组绑定在一起。
此外,切片的容量总会是在切片长度和底层数组长度之间的某一个值,并且还与切片窗口最左边对应的元素在底层数组中的位置有关系。那两个分别用减法计算切片长度和容量的方法你一定要记住。
另外,如果新的长度比原有切片的容量还要大,那么底层数组就一定会是新的,而且append函数也会返回一个新的切片。还有,你其实不必太在意切片“扩容”策略中的一些细节,只要能够理解它的基本规律并可以进行近似的估算就可以了。
思考题
这里仍然是聚焦于切片的问题。
如果有多个切片指向了同一个底层数组,那么你认为应该注意些什么?
怎样沿用“扩容”的思想对切片进行“缩容”?请写出代码。
这两个问题都是开放性的,你需要认真思考一下。最好在动脑的同时动动手。
分享给需要的人,Ta购买本课程,你将得18
生成海报并分享

赞 39

提建议

上一篇
06 | 程序实体的那些事儿 (下)
下一篇
08 | container包中的那些容器
unpreview
 写留言

精选留言(91)

  • Nuzar
    置顶
    2019-02-22
    老师的行文用字非常好,不用改!
    共 2 条评论
    42
  • 清风徐来
    2018-08-29
    语言描述有点啰嗦太学术化,和我当时看go并发编程第二版开头几章同样的感觉,希望能更加精简一些,直接突出重点要好很多。
    共 4 条评论
    255
  • melon
    2018-08-27
    初始时两个切片引用同一个底层数组,在后续操作中对某个切片的操作超出底层数组的容量时,这两个切片引用的就不是同一个数组了,比如下面这个例子: s1 := []int {1,2,3,4,5} s2 := s1[0:5] s2 = append(s2, 6) s1[3] = 30 此时s1[3]的值为30, s2[3]的值仍然为4,因为s2的底层数组已是扩容后的新数组了。
    展开
    151
  • 小小笑儿
    2018-08-29
    切片缩容之后还是会引用底层的原数组,这有时候会造成大量缩容之后的多余内容没有被垃圾回收。可以使用新建一个数组然后copy的方式。

    作者回复: 没错

    60
  • 许大
    2019-08-28
    老师 go中 make和new 有什么区别

    作者回复: make 是专门用来创建 slice、map、channel 的值的。它返回的是被创建的值,并且立即可用。 new 是申请一小块内存并标记它是用来存放某个值的。它返回的是指向这块内存的指针,而且这块内存并不会被初始化。或者说,对于一个引用类型的值,那块内存虽然已经有了,但还没法用(因为里面没有针对那个值的数据结构)。 所以,对于引用类型的值,不要用 new,能用 make 就用 make,不能用 make 就用复合字面量来创建。

    共 5 条评论
    58
  • sky
    2018-09-15
    老师您好!我对源码demo16中示例1、3实际运行结果与预期结果表示ok,但唯独示例2的运行结果觉得没有什么规则可供参考,为何不是下面我预期的结果呢,对于实际的运行结果表示不理解,还烦请老师有空帮忙解答下,感谢! 代码如下: // 示例2 s7 := make([]int, 1024) fmt.Printf("The capacity of s7: %d\n", cap(s7)) s7e1 := append(s7, make([]int, 200)...) fmt.Printf("s7e1: len: %d, cap: %d\n", len(s7e1), cap(s7e1)) s7e2 := append(s7, make([]int, 400)...) fmt.Printf("s7e2: len: %d, cap: %d\n", len(s7e2), cap(s7e2)) s7e3 := append(s7, make([]int, 600)...) fmt.Printf("s7e3: len: %d, cap: %d\n", len(s7e3), cap(s7e3)) fmt.Println() 实际运行结果: The capacity of s7: 1024 s7e1: len: 1224, cap: 1280 s7e2: len: 1424, cap: 1696 s7e3: len: 1624, cap: 2048 预期运行结果: The capacity of s7: 1024 s7e1: len: 1224, cap: 1280 s7e2: len: 1424, cap: 1600 s7e3: len: 1624, cap: 2000
    展开
    共 10 条评论
    24
  • 传说中的成大大
    2020-03-03
    首先总结今天课程内容 1. 数组和切片的区别与联系 1.1数组是有长度的并且长度是类型的组成部分之一 所以[1]string!=[2]string 长度固定不可变 1.2切片实际上是对底层数组的一层封装,通过切片的容量和长度 我们可以访问到底层数组中对应的元素, 1.2.1如果切片是从底层数组下标为0处开始引用 那个切片的第一个元素(下标为0时)引用的是数组下标为0的元素 1.2.2如果切片是从底层数组下标为3处开始引用那么切片的第一个元素(下标为0时)引用的是数组下标为3的元素 2. 数组和切片的共同点 它们都是集合类型 3. 值传递和引用传递 如果实参是值类型 就是值传递 如果实参为引用类型则是引用传递 一般来说引用传递更快更好 go语言中值类型 : 数组,和内置的数据类型 以及结构体 go语言中引用类型: 切片(slice) 字典(map) 通道(channel) 函数(func) 是引用类型 引用类型一般使用make创建和初始化 4. 关于切片长度和容量的计算 切片长度一般是对底层数组的引用范围 比如s1=s2[3:6] [3,6)引用范围为3-5所以长度为6-3=3,但是切片可以向右扩展而不能向左扩展 所以 s1的容量就 = s2的容量-3 3是对数组引用的起始下标 6是对数组引用的结束下标 5. 关于append和切片扩容 一般使用append对切片进行追加元素 分为以下两种情况 1. 追加过后元素长度小于容量 append返回原切片 2. 追加过后元素长度超过了容量 2.1 如果长度小于1024 则扩容机制为 新切片容量 = 原切片容量*2 返回新切片地址 2.2 如果长度大于1024 则扩容机制为 新切片容量 = 原切片容量*1.25 返回 新切片地址 2.3 如果要追加的元素过多 比切片容量的两倍还多 则再进行前面 2.1 2.2的操作 重点 因为切片必定引用一个底层数组 所以数组也不会是原来的数组了 5. 切片的缩容 回答到思考题当中 思考题答案 1. 如果多个切片引用到同一个数组应该注意什么 这个问题 就像并发问题 多个线程同时操作一块内存区域 所以要注意的是 读写顺序 及读写过后的更新问题 避免本来想读老数据 却被另外一个切片给写入数据了 2. 切片缩容问题 其实可以反向思考 扩容问题 当切片的容量小于等于一定比例后 有大量的空间被浪费 所以新弄一个新切片 容量为原切片按比列缩小 并返回新的切片 代码 等有空了再补上
    展开

    作者回复: 赞! 就第2个问题我可以接着问你:弄新切片的话,那旧切片应该怎么处理?

    共 4 条评论
    19
  • Laughing
    2018-08-27
    1.当两个长度不一的切片使用同一个底层数组,并且两切片的长度均小于数组的容量时,对其中长度较小的一个切片进行append操作,但不超过底层数组容量,这时会影响长度较长切片中原来比较小切片多看到的值,因为底层数组被修改了。 2. 可以截取切片的部分数据,然后创建新数组来缩容
    17
  • 宇智波悟天
    2019-10-14
    关于老 slice 容量大于等于 1024 时,没有严格按照1.25倍增长的问题,和大家一样有些困惑,上网查了下。给大家一个参考: 向 slice 追加元素的时候,若容量不够,会调用 growslice 函数 func growslice(et *_type, old slice, cap int) slice { // …… newcap := old.cap doublecap := newcap + newcap if cap > doublecap { newcap = cap } else { if old.len < 1024 { newcap = doublecap } else { for newcap < cap { newcap += newcap / 4 } } } // …… capmem = roundupsize(uintptr(newcap) * ptrSize) newcap = int(capmem / ptrSize) 重点看最后两行代码。 对 newcap 作了一个内存对齐,这个和内存分配策略相关。进行内存对齐之后,新 slice 的容量是要 大于等于 老 slice 容量的 2倍或者1.25倍的。
    展开
    共 1 条评论
    15
  • 传说中的成大大
    2020-03-03
    回答追问,旧的切片,无论是扩容或者缩容都会有老的切片释放出来,这个时候应该是被回收了!不然肯定会内存泄露的

    作者回复: 如果仍然存在与老切片有关的变量,别忘了置 nil。GC 回收老切片有一个必要条件,那就是:已经没有任何代码引用它了。

    共 2 条评论
    11
  • 有铭
    2018-08-27
    谢谢老师,今天这篇文才让我意识到以前对切片的认知是不全面的。但也带来一个新问题,大部分语言里,类似切片的数据结构的实质就是可变数组,他们都没有窗口这个设计,golang是为啥设计了窗口这个功能呢?这个功能在实际开发中能如何应用呢?我想golang这种极简设计思想的语言,绝不会搞多余设计,必然是有某种场景,不用切片的窗口就搞不定。但是我想不出是什么
    共 1 条评论
    10
  • 徐宁
    2018-09-06
    能不能少用点前者后者这类语言,很容易困惑又回头去看
    共 1 条评论
    8
  • Wei Yongchao
    2020-12-01
    我的这段代码: s := make([]int, 0) fmt.Printf("len(s) = %d, cap(s)=%d, addr=%p\n", len(s), cap(s), s) for i := 1; i <= 10; i++{ s = append(s, 1) fmt.Printf("i:%d, len(s) = %d, cap(s)=%d, addr=%p\n", i, len(s), cap(s), s) } 输出如下: len(s) = 0, cap(s)=0, addr=0x6d0e70 i:1, len(s) = 1, cap(s)=1, addr=0xc00000a0d0 i:2, len(s) = 2, cap(s)=2, addr=0xc00000a0e0 i:3, len(s) = 3, cap(s)=4, addr=0xc0000103a0 i:4, len(s) = 4, cap(s)=4, addr=0xc0000103a0 i:5, len(s) = 5, cap(s)=8, addr=0xc00000e2c0 i:6, len(s) = 6, cap(s)=8, addr=0xc00000e2c0 i:7, len(s) = 7, cap(s)=8, addr=0xc00000e2c0 i:8, len(s) = 8, cap(s)=8, addr=0xc00000e2c0 i:9, len(s) = 9, cap(s)=16, addr=0xc000078000 i:10, len(s) = 10, cap(s)=16, addr=0xc000078000 他在i=3, 4和i=5, 6, 7, 8的时候没有扩容。但,看样子返回的还是以前的切片?
    展开

    作者回复: 可能新版本里又有优化了吧。 我看了下最新的源码,确实是“无须扩容时会返回原切片”。 因此,文中需要改动两处: 1. 请记住,在无需扩容时,append 函数返回的是指向原底层数组的原切片,而在需要扩容时,append 函数返回的是指向新底层数组的新切片。 2. 另外,如果新的长度比原有切片的容量还要大,那么底层数组就一定会是新的,而且 append 函数也会返回一个新的切片。 我已经提交给极客时间的编辑了。如果未能及时更新,你就以我上面写的这两句话为准吧。

    共 2 条评论
    7
  • Geek_b5a317
    2018-08-30
    老师可以多些图表在文章里,方便阅读
    7
  • 余泽锋
    2018-08-27
    1.底层数组的变动会影响多个切片 2.每一次缩容都需要生成新的切片
    7
  • wjq310
    2018-08-28
    老师,请问下demo16.go的示例三的几个cap值是怎么来的?看这后面的值,不像是2的指数倍。更奇怪的是,我在不同的地方运行(比如把代码贴到https://golang.org/go)得到的结果还不一样,不知道为什么,麻烦帮忙解答一下,感谢了

    作者回复: 每次发现容量不够都会翻一倍,你可以从头算一下。另外,一旦超过1024每次只会增大1.25倍。

    6
  • mrly
    2018-09-27
    老师,我对demo16的运行结果有疑惑,按1024*1.25*1.25*1.25来说,结果应该是这样: 实际运行结果: The capacity of s7: 1024 s7e1: len: 1224, cap: 1280 s7e2: len: 1424, cap: 1696 s7e3: len: 1624, cap: 2048 预期运行结果: The capacity of s7: 1024 s7e1: len: 1224, cap: 1280=1024*1.25 s7e2: len: 1424, cap: 1600=1024*1.25*1.25 s7e3: len: 1624, cap: 2000=1024*1.25*1.25*1.25 为什么结果不一样呢?
    展开
    共 1 条评论
    5
  • Empty
    2018-09-26
    王老师,能解释一下demo16里面的第三个示例么
    5
  • 2019-07-21
    总结一下: 如果不扩容,新切片和所有基于该底层数组的切片,都对同一个数组进行操作,会相互影响。 如果扩容,新切片的底层数组会新生成一个,因切片对该数组的操作不会影响原来的数组(原来数组没有引用可能已经被回收了)
    4
  • mateye
    2018-08-30
    老师您好,就像您说的,切片赋值的话会,如果完全赋值,会指向相同的底层数组, s1 :=[]int{1,2,3,4} s2 := s1[0:4] 就像这样,这样的话改变s2会影响s1,如何消除这种影响呢

    作者回复: 可以用copy函数,或者自己深拷贝。

    4