Golang中的数组和切片了解多少呢?

Golang中的数组和切片了解多少呢?

1.Go切片和数组

go切片又称动态数组,实际上是基于数组类型做的一个封装。

Go数组

数组是内置(build-in)类型,是一组同类型数据的集合,它是值类型,通过从0开始的下标索引访问元素值。在初始化后长度是固定的,无法修改其长度。当作为方法的参数传入时将复制一份数组而不是引用同一指针。数组的长度也是其类型的一部分,通过内置函数len(array)获取其长度。

Go切片

Go语言中数组的长度是固定的,且不同长度的数组是不同类型,这样的限制带来不少局限性。

而切片则不同,切片(slice)是一个拥有相同类型元素的可变长序列,可以方便地进行扩容和传递,实际使用时比数组更加灵活,这也正是切片存在的意义。

切片是引用类型,因此在当传递切片时将引用同一指针,修改值将会影响其他的对象。

2.切片底层

Go切片(slice)的实现可以在源码包src/runtime/slice.go中找到。在源码中,slice的数据结构定义如下。

type slice struct {
	array unsafe.Pointer	//指向底层数组的指针
	len int					//切片长度
	cap int					//切片容量
}

可以看到构成切片的三元组分别是指向底层的指针,切片长度和切片容。

1.指向底层的数组

前面已经提到,切片实际是对数组的一层封装。这个指针便是记录其底层数组的地址,也正是切片开始的位置

2.切片长度

len表示切片的长度,即切片中现存有效元素的个数,它不能超过切片的容量。可以通过len()函数获取切片长度。

3.切片容量

cap表示切片的容量,即切片能存储元素的多少,通常是从切片的起始元素到底层数组的最后一个元素间的元素个数,当切片容量不足时,便会触发slice扩容。可以通过cap()函数获取切片容量。

下图展示了一个Go切片的底层数据结构,这个切片的长度为3,容量为6。

none

3.切片的使用

1.定义切片的方式

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为6
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)]

注意:切片与原数组或切片共享底层空间,修改切片会影响原数组或切片

3.迭代切片

切片可以用range迭代,但是要注意:如果只用一个值接收range,则得到的只是切片的下标,用两个值接收range,则得到的才是下标和对应的值。

//使用一个值接收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个元素

var a []int
a = append(a, 1)					// 追加一个元素
a = append(a, 1, 2, 3)				// 追加多个元素
a = append(a, []int{1, 2, 3}...)	// 追加一个切片,注意追加切片时后面要加...

通过append()函数在切片头部增加元素

a = append([]int{0}, a...)			// 在开头添加一个元素
a = append([]int{1, 2, 3}, a...)	// 在开头添加一个切片

注:从头部添加元素会引起内存的重分配,导致已有元素全部复制一次。因此从头部添加元素的开销要比从尾部添加元素大很多

通过append()函数链式操作从中间插入元素

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]中。

删除元素

根据切片的性质,我们可以通过巧妙的拼接切片来达到删除指定数据的目的。

a = []int{1, 2, 3}
//删除尾部元素
a = a[:len(a) - 1]				//删除尾部一个元素
a = a[:len(a) - N]				//删除尾部N个元素
//删除头部元素
a = a[1:]						//删除开头1个元素
a = a[N:]						//删除开头N个元素
//删除中间元素
a = append(a[:i], a[i+1:]...)	//删除中间一个元素

切片扩容

很多人以为 slice 是可以自动扩充的, 估计都是 append 函数误导的。其实 slice 并不会自己自动扩充, 而是 append 数据时, 该函数如果发现超出了 cap 限制自动帮我们扩的。

使用append向slice追加元素时,如果slice空间不足,则会触发slice扩容,扩容实际上是分配一块更大的内存,将原slice的数据拷贝进新slice,然后返回新slice,扩容后再将数据追加进去。

例如,当向一个容量为5且长度也为5的切片再次追加1个元素时,就会发生扩容,如下图所示

none

扩容操作只关心容量,会把原slice的数据拷贝至新slice中,追加数据由append在扩容后完成。由上图可见,扩容后新slice的长度仍然是5,但容量由5提到了10,原slice的数据也都拷贝到了新的slice指向的数组中。

扩容容量的选择遵循以下基本规则

  • 如果原slice的容量小于1024,则新slice的容量将扩大为原来的2倍;
  • 如果原slice的容量大于1024,则新的slice的容量将扩大为原来的1.25倍;

切片陷阱

1.切片无法做比较

和数组不同的是,slice无法做比较,因此不能用==来测试两个slice是否拥有相同的元素。标准库里面提供了高度优化的函数bytes.Equal来比较两个字节slice。但是对于其它类型的slice,就必须要自己写函数来比较。

slice唯一允许的比较操作是和nil进行比较,例如

if slice == nil {/*...*/}

2.空切片和nil切片

空切片和nil切片是不同的。

  • nil切片中,切片的指针指向的是空地址,其长度和容量都为零。nil切片和nil相等。
  • 空切片,切片的指针指向了一个地址,但其长度和容量也为0,和nil不相等,通常用来表示一个空的集合。
 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

当使用range进行切片迭代时,range创建了每个元素的副本,而不是直接返回对该元素的引用。如果使用该值变量的地址作为每个元素的指针,就会造成错误。

	var a []int
	a = []int{1,2,3,4}
	for i, v := range a {
		fmt.Printf("addr: %p ,value : %v ,ele-addr: %X",&v,v,&a[i])
		fmt.Println()
	}
//out
addr: 0xc00000a098 ,value : 1 ,ele-addr: C000010220
addr: 0xc00000a098 ,value : 2 ,ele-addr: C000010228
addr: 0xc00000a098 ,value : 3 ,ele-addr: C000010230
addr: 0xc00000a098 ,value : 4 ,ele-addr: C000010238

从结果中可以看出,使用range进行迭代时,v的地址是始终不变的,它并不是切片中每个变量的实际地址。而是在使用range进行遍历时,将切片中每个元素都复制到了同一个变量v中。如果错误的将v的地址当作切边元素的地址,将会引发错误。

3.切片扩容引发的问题

正因为有扩容机制。所以我们无法保证原始的slice和用append后的结果slice指向同一个底层数组,也无法证明它们就指向不同的底层数组。同样,我们也无法假设旧slice上对元素的操作会或者不会影响新的slice元素。所以,通常我们将append的调用结果再次赋给传入append的slice。

内置append函数在向切片追加元素时,如果切片存储容量不足以存储新元素,则会把当前切片扩容并产生一个新的切片。

append函数每次追加元素都有可能触发切片扩容,即有可能返回一个新的切片,这正是append函数声明中返回值为切片的原因,使用时应该总是接收该返回值。

建议

使用append函数时,谨记append可能会产生新的切片,并谨慎的处理返回值。

4.append函数误用

使用append函数时,需要考虑append返回的切片是否跟原切片共享底层的数组。下面这段程序片段,来看看函数返回的结果。

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三个元素。其底层的数组结构如下图所示

image-20211122200538072

创建切片y为切片x追加一个元素4后,底层数组结构如下图所示

none

需要注意的是切片x仍然没有变化,切片x中记录的长度仍为3。继续向x追加元素5后,底层数组结构如下图所示

image-20211122200904091

至此,答案已经非常明确了。当向x继续追加元素5后,切片y的最后一个元素被覆盖掉了。

此时切片x仍然为[1 2 3],而切片y和z则为[1 2 3 5]。

建议

一般情况下,使用append函数追加新的元素时,都会用原切片变量接收返回值来获得更新

a = append(a, elems...)

5.函数传参

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函数中两个切片好像改了,又好像没改,下面我们就来分析一下。

理论分析

当我们将一个切片作为函数参数传递给函数的时候,采用的是值传递,因此我们传递给函数的参数其实是上面这个切片三元组的值拷贝。当我们对切片结构中的指针进行值拷贝的时候,得到的指针还是指向了同一个底层数组。因此我们通过指针对底层数组的值进行修改,从而修改了切片的值。

但是,当我们以值传递的方式传递上面的结构体的时候,同时也是传递了lencap的值拷贝,因为这两个成员并不是指针,因此,当我们从函数返回的时候,外层切片结构体的lencap这两个成员并没有改变。

所以当我们传递切片给函数的时候,并且在被调函数中通过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],但其底层数组的值其实已经改变了。

本文摘自 golangroadmap题库中题号为1的"栾龙生"的回答

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值