Go 数组和切片比较

切片的底层数据结构是数组,所以,切片是基于数组的上层封装,使用数组的场景,也完全可以使用切片。

切片的用法

我们使用的切片,其实是一个头结构,Go 语言用 SliceHeader 结构体就表示一个切片。Data 指向的是实际的数据地址,Len表示切片的长度,Cap表示切片的容量。

type SliceHeader struct {
	Data unsafe.Pointer
	Len  int
	Cap  int
}

下面用一个简单的示意图来表示切片的数据结构,我们经常说“切片是引用传递的”,这句话并不完全准确,在切片赋值的过程中,还是会发生 SliceHeader 的拷贝动作,只是 Data 指向的内存地址不会发生变化而已。

下面通过一个简单的代码例子来阐述对切片赋值的过程:在函数的参数中使用切片,并期望在函数内部对切片做的修改,也同样在外部的切片中生效。

在这里插入图片描述

func main() {
	ids := make([]int, 0, 8)
	push(ids)
	fmt.Println(ids)
}

func push(list []int) {
	list = append(list, 12)
}

代码中最后输出的内容会是 [12] 吗?为了避免函数 push 中触发切片扩容,在 ids 声明时,提前指定了足够的容量,保证在程序执行的过程中,ids 的底层数组都不会触发扩容。

最后的输出结果是 [],很奇怪吗?其实也不奇怪。在调用 push 传递参数时,原先的 ids 发生了一次值拷贝,最后的效果就是下面这个样子,SliceHeader 被重新拷贝了一次,但底层的的 Data 指针仍然指向了同一块内存地址。

在这里插入图片描述
在 push 函数体内修改了 Data 指向的数据,但只有 list 结构的 Len 被更新为1,外层 ids 的 Len 字段仍然还是 0。当前切片可以访问的元素是根据 Data 和 Len 共同确定的,结果就是外层的 ids 并没有被修改。

此时,如果我们继续对外层的 ids 做 append 操作,就会覆盖 list 追加的结果。它们共享了一段内存数据,但彼此并不能感知到对方,最坏的结果就是值被相互覆盖。

类型比较

我看到 go 1.17 有对切片和数组转换的优化,禁不住纳闷,有什么场景是必须数组来完成的呢?我特意去查了一下数组和切片的核心差异,刨去有的没的,核心就 2 点:

  • 数组是可比较的,切片是不可比较的,验证的方式也非常简单。声明一个 map 类型,如果 key 的类型是切片,会报如下的编译错误。如果是数组类型,可以正常编译。
  • 数组赋值是值拷贝,切片赋值是引用拷贝,切片类型在值拷贝前后,底层共用的还是同一个数组,而数组的值拷贝就是值拷贝。

在这里插入图片描述
在查看的过程中,我也有发现数组指针的用法,除了可以通过数组本身来操作数组,还可以通过数组指针来操作。而这种使用手法,是切片不具备的。

下面的代码,通过数组指针 (&b) 可以像直接使用 b 一样访问数组的元素,而通过使用指针赋值的方式,也可以让两个变量共用同一个数组。

func main() {
	var b = [3]int{11, 12, 13} //
	fmt.Println((&b)[0])
	fmt.Println(b[0])
}

下面数组的赋值过程, a 和 b 是值拷贝,完全独立的两份数据,b 数组修改第一个元素,并不会影响到数组 a。而 c 和 a 是数组指针类型的值拷贝,共享同一份数组数据,c 数组修改第一个元素,也就修改了 a 指向的数组。

func main() {
	var a = [3]int{11, 12, 13} //

	b := a
	b[0] = 10
	fmt.Println(a[0])
	fmt.Println(b[0])

	c := &a
	c[0] = 10
	fmt.Println(a[0])
	fmt.Println(c[0])
}

类型转换

数组和切片的转换,说实话,在业务开发的过程中,我基本上没有用到这种转换场景。因为切片的底层是数组,数组可以直接转换为切片类型,转换后,切片和数组共享同一份空间。

func main() {
	var a = [3]int{11, 12, 13} 

	b := a[:len(a)]

	fmt.Printf("%T,%T", b, a)
}

仔细想想,切片在 go 中的结构为 SliceHeader ,除了数组 Data 部分,还包含了 Len 和 Cap 两个属性。所以,go 数组转切片的过程,也是封装这个 SliceHeader 的过程。

type SliceHeader struct {
	Data uintptr
	Len  int
	Cap  int
}

但在 1.17 之前,切片是不能直接转换为数组的,要想转换为数组,就需要通过 unsafe 包来实现。不过,切片要转换为数组,相比,数组转换为切片,要简单很多。

原理上就是获取切片底层数组的第一个元素的指针,转换为数组的指针。

func main() {
	var a = []int{11, 12, 13} //
	b := (*[3]int)(unsafe.Pointer(&a[0]))
	fmt.Println(b)
}

现在 go 1.17 默认也支持了这个转换,转换的方式也很好理解,转换为数组类型的指针。因为是指针,所以,转换后的数组 b 和 a 仍然还是共用同一份数据。

为什么默认转换使用的类型是数组指针 (*[3]int),而非 ([3]int) 呢?我理解,如果是值类型的话,还需要底层数据的拷贝,语义上也没有指针类型好理解。

func main() {
	var a = []int{11, 12, 13} //
	b := (*[3]int)(a)
	fmt.Println(b)
}

切片安全并发

结合以往的工作,比较适合使用数组替代切片的场景大概就是:控制并发的顺序。

我们有一组数组 reqs,要请求某一个接口。然后,需要按照请求的顺序,返回请求的结果。我们使用 go 协程并发请求接口,然后基于请求的索引,将接口返回的结果存储到对应索引数组的位置。

因为数组的个数是固定的,所以,这种场景还是比较少的


func main() {

	var reqs [3]interface{} = [...]interface{}{1, 2, 3}
	var res [3]interface{}

	for i:= range reqs {
		go func(index int) {
			res[index] = reqs[index]
		}(i)
	}

	time.Sleep(time.Second)
	fmt.Println(res)
}

双色球模拟

下面模拟一个双色球彩票的程序,给自己一个 500 万的机会。首先生成一个整形切片,生成 1-33 数字,操作比较简单。我们初始化一个容量为 33 的切片,通过一个 for 循环来填充整数。

func InitBalls() []int {
	balls := make([]int, 0, 33)
	for i := 0; i< 33; i++ {
		balls = append(balls, i)
	}
	return balls
}

接着,我们模拟双色球摇号的过程,每次摇号都打乱切片中的元素的位置,这个过程叫 shuffle,洗牌的含义。简单的处理方式是通过 rand 包来实现。

func Shuffle(balls []int) []int {
	rand.Seed(time.Now().UnixNano())
	rand.Shuffle(len(balls), func(i, j int) {
		balls[i], balls[j] = balls[j], balls[i]
	})
	
	return balls
}

数组被重新洗牌之后,我们只需要从其中任意取出一个值,作为本次的摇号结果。之后,将这个值从切片中删除掉,值空出来的位置由相邻的后续元素补上。

最简单的处理方式就是读取最后一个元素,然后修改切片的长度,效率非常高。因为程序非常简单,我们不考虑数组下标越界的情况。

func RPop(balls []int) (lessBalls []int, ball int) {
	ball = balls[len(balls)-1]
	lessBalls = balls[:len(balls)-1]
	return
}

当然,如果想读取第一个元素,实现也没有那么麻烦,通过数组 copy 就可以轻易的实现数据的移动。

func LPop(balls []int) (lessBalls []int, ball int) {
	ball = balls[0]
	copy(balls, balls[1:])
	lessBalls = balls[:len(balls)-1]
	return
}

最后就是上述的关键步骤进行组合,实现 6 次循环来获取红球号码。到此,一个简单的摇号程序就写完了,篮球的摇号也是如此,不同的是,篮球只需要一个整数。

func GetRedBalls() []int {
	balls := InitBalls()
	redBalls := make([]int, 0, 6)

	for i := 0; i < 6; i++ {
		var elem int

		Shuffle(balls)
		balls, elem = LPop(balls)

		redBalls = append(redBalls, elem)
	}
	return redBalls
}

程序剖析

上述程序中 Shuffle 其实是最关键的,因为它决定了切片元素顺序的随机性,但 rand 其实是伪随机的,用 Seed 指定一个种子,rand 返回的随机数都是相同的,其实知道了种子,生成的随机数就是可预测的。

这可能不太符合部分人对随机的预期,我们实际希望的是真随机。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值