《Go题库·1》Golang里的数组和切片有了解过吗?

itmrtan · · 984 次点击 · 开始浏览    置顶

> **题目来源:** 深信服、知乎、跟谁学 ## 答案 1:(溪尾) 数组长度是固定的,而切片是可变长的。可以把切片看作是对底层数组的封装,每个切片的底层数据结构中,一定会包含一个数组。数组可以被称为切片的底层数组,切片也可以被看作对数组某一连续片段的引用。因此,Go 中切片属于引用类型,而数组属于值类型,通过内建函数 len,可以取得数组和切片的长度。通过内建函数 cap,可以得到数组和切片的容量。但是数组的长度和容量是相等的,并且都不可变,而且切片容量是有变化规律的。 ## 答案 2:(行飞子) **数组和切片的关系:** 切片一旦初始化, 切片始终与保存其元素的基础数组相关联。因此,切片会和与其拥有同一基础数组的其他切片共享存储 ; 相比之下,不同的数组总是代表不同的存储。 **数组和切片的区别** 1. 切片的长度可能在执行期间发生变化 ,而数组的长度不能变化,可以把切片看成一个长度可变的数组。 2. 数组作为函数参数是进行值传递的,函数内部改变传入的数组元素值不会影响函数外部数组的元素值; 切片作为函数的参数是进行的指针传递,函数内部改变切片的值会影响函数外部的切片元素值。 3. 数组可以比较,切片不能比较(对底层数组的引用)。 ## 答案 3:(栾龙生) **1. Go 切片和 Go 数组** Go 切片,又称动态数组,它实际是基于数组类型做的一层封装。 **Go 数组** 数组是内置(build-in)类型,是一组同类型数据的集合,它是值类型,通过从 0 开始的下标索引访问元素值。在初始化后长度是固定的,无法修改其长度。当作为方法的参数传入时将复制一份数组而不是引用同一指针。数组的长度也是其类型的一部分,通过内置函数 len(array)获取其长度。 Go 数组与像 C/C++等语言中数组略有不同,如下 - Go 中的数组是值类型,换句话说,如果你将一个数组赋值给另外一个数组,那么,实际上就是将整个数组拷贝一份。因此,在 Go 中如果将数组作为函数的参数传递的话,那效率就肯定没有传递指针高了。 - 数组的长度也是类型的一部分,这就说明`[10]int`和`[20]int`不是同一种数据类型。 **Go 切片** Go 语言中数组的长度是固定的,且不同长度的数组是不同类型,这样的限制带来不少局限性。 而切片则不同,切片(slice)是一个拥有相同类型元素的可变长序列,可以方便地进行扩容和传递,实际使用时比数组更加灵活,这也正是切片存在的意义。 切片是引用类型,因此在当传递切片时将引用同一指针,修改值将会影响其他的对象。 **2. 切片底层** 现在就来看一下 Go 语言切片的底层是什么样子吧! Go 切片(slice)的实现可以在源码包`src/runtime/slice.go`中找到。在源码中,slice 的数据结构定义如下。 ```go type slice struct { array unsafe.Pointer //指向底层数组的指针 len int //切片长度 cap int //切片容量 } ``` 可以看到,组成 Go 切片的三元组分别为指向底层数组的指针,切片长度和切片容量。 1. 指向底层数组的指针 前面已经提到,切片实际是对数组的一层封装。这个指针便是记录其底层数组的地址,也正是切片开始的位置。 2. 切片长度 `len`表示切片的长度,即切片中现存有效元素的个数,它不能超过切片的容量。可以通过`len()`函数获取切片长度。 3. 切片容量 `cap`表示切片的容量,即切片能存储元素的多少,通常是从切片的起始元素到底层数组的最后一个元素间的元素个数,当切片容量不足时,便会触发 slice 扩容。可以通过`cap()`函数获取切片容量。 下图展示了一个 Go 切片的底层数据结构,这个切片的长度为 3,容量为 6。 ![](https://image-1302243118.cos.ap-beijing.myqcloud.com/img/image-20211122105154216-16375495155681.png) **3. 切片使用** 1. 切片定义方式 ```go var a []int //nil切片,和nil相等,一般用来表示一个不存在的切片 var b []int{} //空切片,和nil不相等,一般用来表示一个空的集合 var c []int{1, 2, 3} //有3个元素的切片,len和cap都为3 var d = c[:2] //有2个元素的切片,len为2,cap为3 var e = c[:2:cap(c)] //有2个元素的切片,len为2,cap为3 var f = c[:0] //有0个元素的切片,len为0,cap为3 var g = make([]int, 3) //创建一个切片,len和cap均为3 var h = make([]int, 3, 6) //创建一个切片,len为3,cap为5 var i = make([]int, 0, 3) //创建一个切片,len为0,cap为3 ``` 2. 从数组中切取切片 数组和切片是紧密相连的。切片可以用来访问数组的部分或全部元素,而这个数组称为切片的底层数组。切片的指针指向数组第一个可以从切片中访问的元素,这个元素并不一定是数组的第一个元素。 一个底层数组可以对应多个切片,这些切片可以引用数组的任何位置,彼此之前的元素可以重叠。 slice 操作符 `s[i:j]` 创建了一个新的 slice,这个新的 slice 引用了 s 中从 i 到 j-1 索引位置的所有元素。 如果表达式省略了 i,那么默认是`s[0:j]`;如果省略了 j,默认是`s[i:len(s)]`; ```go //示例来源:The Go Programming Language //创建一个数组 months := [...]string{1:"January", /*...*/, 12: "December"} Q2 := months[4:7] summer := months[6:9] fmt.Println(Q2) //["April" "May" "June"] fmt.Println(summer) //["June" "July" "August"] ``` 月份名称字符串数组与其对应的两个元素重叠的 slice 图示 ![](https://image-1302243118.cos.ap-beijing.myqcloud.com/img/image-20211122113142144-16375519031723.png) > 注意:切片与原数组或切片共享底层空间,修改切片会影响原数组或切片 3. 迭代切片 切片可以用 range 迭代,但是要注意:如果只用一个值接收 range,则得到的只是切片的下标,用两个值接收 range,则得到的才是下标和对应的值。 ```go //使用一个值接收range, 则得到的是切片的下标 for i := range months { fmt.Println(i) //返回下标 0 1 ... 12 } //使用两个值接收range,则得到的是下标和对应的值 for i, v := range months { fmt.Println(i, v) //返回下标0 1 ... 12 和 值 "" "January" ... "December" } ``` 4. 切片拷贝 使用`copy`内置函数拷贝两个切片时,会将源切片的数据逐个拷贝到目的切片指向的数组中,拷贝数量取两个切片的最小值。 例如长度为 10 的切片拷贝到长度为 5 的切片时,将拷贝 5 个元素。也就是说,拷贝过程中不会发生扩容。 copy 函数有返回值,它返回实际上复制的元素个数,这个值就是两个 slice 长度的较小值。 **4. 切片扩容-append 函数** **追加元素** - 通过`append()`函数可以在切片的尾部追加 N 个元素 ```go var a []int a = append(a, 1) // 追加一个元素 a = append(a, 1, 2, 3) // 追加多个元素 a = append(a, []int{1, 2, 3}...) // 追加一个切片,注意追加切片时后面要加... ``` - 使用 append()函数也可以在切片头部添加元素 ```go a = append([]int{0}, a...) // 在开头添加一个元素 a = append([]int{1, 2, 3}, a...) // 在开头添加一个切片 ``` > 注:从头部添加元素会引起内存的重分配,导致已有元素全部复制一次。因此从头部添加元素的开销要比从尾部添加元素大很多 - 通过 append()函数链式操作从中间插入元素 ```go a = append(a[:i], append([]int{x}, a[i:]...)...) //在第i个位置上插入x a = append(a[:i], append([]int{1, 2, 3}, a[i:]...)...) //在第i个位置上插入切片 ``` 使用链式操作在插入元素,在内层 append 函数中会创建一个临式切片,然后将`a[i:]`内容复制到新创建的临式切片中,再将临式切片追加至`a[:i]`中。 - 通过 append()和 copy()函数组合从中间插入元素 使用这种方式可以避免创建过程中间的临式切片,也可以做到从中间插入元素 ```go //中间插入一个元素 a = append(a, 0) //切片扩展一个空间 copy(a[i+1:], a[i:]) //a[i:]向后移动一个位置 a[i] = x //设置新添加的元素 //中间插入多个元素 a = append(a, x...) //为x切片扩展足够的空间 copy(a[i+len(x):], a[i:]) //a[i:]向后移动len(x)个位置 copy(a[i:], x) //复制新添加的切片 ``` 使用此方式虽然稍显复杂,但是可以减少创建中间临时切片的开销。 **删除元素** 很遗憾,Go 语言中并没有提供直接删除指定位置元素的方式。不过根据切片的性质,我们可以通过巧妙的拼接切片来达到删除指定数据的目的。 ```go a = []int{1, 2, 3} //删除尾部元素 a = a[:len(a) - 1] //删除尾部一个元素 a = a[:len(a) - N] //删除尾部N个元素 //删除头部元素 a = [1:] //删除开头1个元素 a = [N:] //删除开头N个元素 //删除中间元素 a = append(a[:i], a[i+1:]...) //删除中间一个元素 a = append(a[:i], a[i+N:]...) //删除中间N个元素 ``` **slice 扩容** 很多人以为 slice 是可以自动扩充的, 估计都是 `append` 函数误导的。其实 slice 并不会自己自动扩充, 而是 `append` 数据时, 该函数如果发现超出了 cap 限制自动帮我们扩的。 使用 append 向 slice 追加元素时,如果 slice 空间不足,则会触发 slice 扩容,扩容实际上是分配一块更大的内存,将原 slice 的数据拷贝进新 slice,然后返回新 slice,扩容后再将数据追加进去。 例如,当向一个容量为 5 且长度也为 5 的切片再次追加 1 个元素时,就会发生扩容,如下图所示。`(示例来源:Go专家编程)` ![](https://image-1302243118.cos.ap-beijing.myqcloud.com/img/image-20211122122445713-16375550869414.png) 扩容操作只关心容量,会把原 slice 的数据拷贝至新 slice 中,追加数据由 append 在扩容后完成。由上图可见,扩容后新 slice 的长度仍然是 5,但容量由 5 提到了 10,原 slice 的数据也都拷贝到了新的 slice 指向的数组中。 扩容容量的选择遵循以下基本规则 - 如果原 slice 的容量小于 1024,则新 slice 的容量将扩大为原来的 2 倍; - 如果原 slice 的容量大于 1024,则新的 slice 的容量将扩大为原来的 1.25 倍; **5. Go 切片,Python 切片,都是切片,有什么不同?** Go 有切片 slice 类型,Python 有列表和元组,这两种语言都有切片操作。但是它们的切片操作是完全不同的。 1. 最大的不同就是 - Python 的切片产生的是新的**对象**,对新对象的成员的操作不影响旧对象; - Go 的切片产生的是旧对象一部分的**引用**,对其成员的操作会影响旧对象; 究其原因还是底层实现不同 - Go 的切片,底层是一个三元组。指针指向一块连续的内存,长度是已有成员数,容量是最大成员数。切片时,一般并不会申请新的内存,而是对原指针进行移动,然后和新的长度、容量组成一个切片类型值返回。也就是说,Go 的切片操作通常会和生成该切片的切片或数组共享内存。 - Python 的切片,其实就是指针数组。对它进行切片,会创建新的数组。在 Python 的切片中,并没有容量的概念。 这其实也体现了脚本语言和编译语言的不同。虽然两个语言都有类似的切片操作;但是 Python 主要目标是方便;Go 主要目标却是快速。 2. 在使用中,Go 切片和 Python 切片也有很多不同 - 首先,Go 的切片,其成员是相同类型的,Python 的列表则不限制类型。 - 两种语言都有[a:b]这种切片操作,意义也类似,但是 Go 的 a、b 两个参数不能是负数,Python 可以是负数,此时就相当于从末尾往前数。 - 两种语言都有`[a:b:c]`这种切片操作,意义却是完全不同的。Go 中的 c 表示的是**容量**;而 Python 的 c 表示的是**步长**。 **6. 切片陷阱** 1. 无法做比较 和数组不同的是,slice 无法做比较,因此不能用==来测试两个 slice 是否拥有相同的元素。标准库里面提供了高度优化的函数`bytes.Equal`来比较两个字节 slice。但是对于其它类型的 slice,就必须要自己写函数来比较。 slice 唯一允许的比较操作是和 nil 进行比较,例如 ```go if slice == nil {/*...*/} ``` 2. 空切片和 nil 切片 空切片和 nil 切片是不同的。 - nil 切片中,切片的指针指向的是空地址,其长度和容量都为零。nil 切片和 nil 相等。 - 空切片,切片的指针指向了一个地址,但其长度和容量也为 0,和 nil 不相等,通常用来表示一个空的集合。 ```go var s []int // s == nil var s = nil // s == nil var s = []int{nil} // s == nil var s = []int{} // s != nil s := make([]int,0) // s != nil ``` 3. 使用 range 进行切片迭代 当使用 range 进行切片迭代时,range 创建了每个元素的副本,而不是直接返回对该元素的引用。如果使用该值变量的地址作为每个元素的指针,就会造成错误。 ```go func main() { a := []int{1, 2, 3, 4, 5} for i, v := range a { fmt.Printf("Value: %d, v-addr: %X, Elem-addr: %X\n", v, &v, &a[i]) } } ``` ```makefile # output Value: 1, v-addr: C0000AA058, Elem-addr: C0000CC030 Value: 2, v-addr: C0000AA058, Elem-addr: C0000CC038 Value: 3, v-addr: C0000AA058, Elem-addr: C0000CC040 Value: 4, v-addr: C0000AA058, Elem-addr: C0000CC048 Value: 5, v-addr: C0000AA058, Elem-addr: C0000CC050 ``` 从结果中可以看出,使用 range 进行迭代时,v 的地址是始终不变的,它并不是切片中每个变量的实际地址。而是在使用 range 进行遍历时,将切片中每个元素都复制到了同一个变量 v 中。如果错误的将 v 的地址当作切边元素的地址,将会引发错误。 4. 切片扩容引发的问题 正因为有扩容机制。所以我们无法保证原始的 slice 和用 append 后的结果 slice 指向同一个底层数组,也无法证明它们就指向不同的底层数组。同样,我们也无法假设旧 slice 上对元素的操作会或者不会影响新的 slice 元素。所以,通常我们将 append 的调用结果再次赋给传入 append 的 slice。 内置 append 函数在向切片追加元素时,如果切片存储容量不足以存储新元素,则会把当前切片扩容并产生一个新的切片。 append 函数每次追加元素都有可能触发切片扩容,即有可能返回一个新的切片,这正是 append 函数声明中返回值为切片的原因,使用时应该总是接收该返回值。 **建议** 使用 append 函数时,谨记 append 可能会产生新的切片,并谨慎的处理返回值。 5. append 函数误用 使用 append 函数时,需要考虑 append 返回的切片是否跟原切片共享底层的数组。下面这段程序片段,来看看函数返回的结果。 ```go //示例来源:Go专家编程 func AppendDemo() { x := make([]int, 0, 10) x = append(x, 1, 2, 3) y := append(x, 4) z := append(x, 5) fmt.Println(x) fmt.Println(y) fmt.Println(z) } //output [1 2 3] [1 2 3 5] [1 2 3 5] ``` 题目首先创建了一个长度为 0,容量为 10 的切片 x,然后向切片 x 追加了 1,2,3 三个元素。其底层的数组结构如下图所示 ![](https://image-1302243118.cos.ap-beijing.myqcloud.com/img/image-20211122200538072-16375827394232.png) 创建切片 y 为切片 x 追加一个元素 4 后,底层数组结构如下图所示 ![](https://image-1302243118.cos.ap-beijing.myqcloud.com/img/image-20211122200655204.png) 需要注意的是切片 x 仍然没有变化,切片 x 中记录的长度仍为 3。继续向 x 追加元素 5 后,底层数组结构如下图所示 ![](https://image-1302243118.cos.ap-beijing.myqcloud.com/img/image-20211122200904091-16375829451383.png) 至此,答案已经非常明确了。当向 x 继续追加元素 5 后,切片 y 的最后一个元素被覆盖掉了。 此时切片 x 仍然为[1 2 3],而切片 y 和 z 则为[1 2 3 5]。 **建议** 一般情况下,使用 append 函数追加新的元素时,都会用原切片变量接收返回值来获得更新 ```go a = append(a, elems...) ``` 6. 函数传参 Go 语言中将切片作为函数参数传递会有什么神奇的现象,一起来看看下面这个示例。 ```go package main import "fmt" func main(){ a := []int{1, 2, 3} //长度为3,容量为3 b := make([]int, 1, 10) //长度为1,容量为10 test(a,b) fmt.Println("main a =", a) fmt.Println("main b =", b) } func test(a,b []int){ a = append(a, 4) //引发扩容,此时返回的a是一个新的切片 b = append(b, 2) //没有引发扩容,仍然是原切片 a[0] = 3 //改变a切片元素 b[0] = 3 //改变b切片元素 fmt.Println("test a =", a) //打印函数内的a切片 fmt.Println("test b =", b) //打印函数内的b切片 } //output test a = [3 2 3 4] test b = [3 2] main a = [1 2 3] main b = [3] ``` 首先,我们创建了两个切片,a 切片长度和容量均为 3,b 切片长度为 1,容量为 10。将 a 切片和 b 切片作为函数参数传入 test 函数中。 在 test 函数中,对 a 切片和 b 切片做了如下两点改动 1. 分别使用 append 函数在 a 切片和 b 切片中追加一个元素 2. 分别对 a 切片和 b 切片的第一个元素做了修改 分别在主函数中和 test 函数中输出两个切片,会发现在主函数中和 test 函数中两个切片好像改了,又好像没改,下面我们就来分析一下。 **理论分析** 当我们将一个切片作为函数参数传递给函数的时候,采用的是值传递,因此我们传递给函数的参数其实是上面这个切片三元组的值拷贝。当我们对切片结构中的指针进行值拷贝的时候,得到的指针还是指向了同一个底层数组。因此我们通过指针对底层数组的值进行修改,从而修改了切片的值。 但是,当我们以值传递的方式传递上面的结构体的时候,同时也是传递了`len`和`cap`的值拷贝,因为这两个成员并不是指针,因此,当我们从函数返回的时候,外层切片结构体的`len`和`cap`这两个成员并没有改变。 所以当我们传递切片给函数的时候,并且在被调函数中通过`append`操作向切片中增加了值,但是当函数返回的时候,我们看到的切片的值还是没有发生变化,其实底层数组的值是已经改变了的(如果没有触发扩容的话),但是由于长度`len`没有发生改变,所以我们看到的切片的值也没有发生改变。 **题目再分析** 有了前面的理论基础,我们再来分析一下 a,b 切片的返回结果。 1. a 切片作为参数传至 test 函数中,在 test 中向 a 切片追加一个元素后,此时触发扩容机制,返回的切片已经不再是原切片,而是一个新的切片。后续对 a 切片中的第一个元素进行修改也是对新切片进行修改,对老切片不会产生任何影响。 所以,最终在主函数中 a 切片仍然为[1 2 3],而在 test 函数中 a 切片变成了[3 2 3 4]。 2. b 切片作为参数传至 test 函数中,在 test 中向 b 切片追加一个元素后,不会触发扩容机制,返回的仍然是原切片,所以在后续对 b 切片的修改都是在原切片中进行的修改。故在 test 函数中 b 切片为[3 2]。但是在主函数中确为[3],可以看出在 test 中对切片进行修改确实反应到主函数中了,但是由于其 len 和 cap 没有改变,len 仍为 1,所以最终就只输出切片中的第一个元素[3],但其底层数组的值其实已经改变了。

有疑问加站长微信联系(非本文作者)

入群交流(和以上内容无关):加入Go大咖交流群,或添加微信:liuxiaoyan-s 备注:入群;或加QQ群:692541889

984 次点击  
加入收藏 微博
暂无回复
添加一条新回复 (您需要 登录 后才能回复 没有账号 ?)
  • 请尽量让自己的回复能够对别人有帮助
  • 支持 Markdown 格式, **粗体**、~~删除线~~、`单行代码`
  • 支持 @ 本站用户;支持表情(输入 : 提示),见 Emoji cheat sheet
  • 图片支持拖拽、截图粘贴等方式上传