温馨提示×

温馨提示×

您好,登录后才能下订单哦!

密码登录×
登录注册×
其他方式登录
点击 登录注册 即表示同意《亿速云用户服务条款》

go数组和切片的概念及用法

发布时间:2021-07-10 10:48:01 来源:亿速云 阅读:184 作者:chen 栏目:编程语言

这篇文章主要介绍“go数组和切片的概念及用法”,在日常操作中,相信很多人在go数组和切片的概念及用法问题上存在疑惑,小编查阅了各式资料,整理出简单好用的操作方法,希望对大家解答”go数组和切片的概念及用法”的疑惑有所帮助!接下来,请跟着小编一起来学习吧!

1.容器类型和容器值

每个容器(值)用来表示和存储一个元素(element)序列或集合。一个容器中的所有元素的类型是相同的。此相同的类型称为此容器的类型的元素类型(或简称此容器的元素类型)。

存储在一个容器中的每个元素值都关联着着一个键值(key)。每个元素可以通过它的键值而被访问到。 一个映射类型的键值类型必须为一个可比较类型

可比较类型即指支持使用==和!=运算标识符比较的类型,在Go中,除了切片类型、映射类型、函数类型、任何包含有不可比较类型的字段的结构体类型、任何元素类型为不可比较类型的数组类型之外的其它类型称为可比较类型。

数组和切片类型的键值类型均为内置类型int。 一个数组或切片的一个元素对应的键值总是一个非负整数下标,此非负整数表示该元素在该数组或切片所有元素中的顺序位置。此非负整数下标亦常称为一个元素索引(index)。

每个容器值有一个长度属性,用来表明此容器中当前存储了多少个元素。 一个数组或切片中的每个元素所关联的非负整数索引键值的合法取值范围为左闭右开区间[0, 此数组或切片的长度)

2.数组和切片的异同

2.1.异同点

  • slice 的底层数据是数组,slice 是对数组的封装,它描述一个数组的片段。两者都可以通过下标来访问单个元素。

  • 数组是定长的,长度定义好之后,不能再更改。在 Go 中,数组是不常见的,因为其长度是类型的一部分,限制了它的表达能力,比如 [3]int 和 [4]int 就是不同的类型。

  • 而切片则非常灵活,它可以动态地扩容。切片的类型和长度无关。

  • 数组就是一片连续的内存, slice 实际上是一个结构体,包含三个字段:长度、容量、底层数组。

2.2.切片结构及示例

slice 的数据结构如下:

// runtime/slice.go
type slice struct {
    array unsafe.Pointer // 元素指针
    len   int // 长度 
    cap   int // 容量
}

或者用图表示:

go数组和切片的概念及用法

注意:底层数组是可以被多个 slice 同时指向的,因此对一个 slice 的元素进行操作是有可能影响到其他 slice 的。

1. 示例一

[3]int 和 [4]int 是同一个类型吗?

不是。因为数组的长度是类型的一部分,这是与 slice 不同的一点。

2. 示例二

Go中对nil的Slice和空Slice的处理是一致的吗?

2.1. 首先Go的JSON 标准库对 nil slice 和 空 slice 的处理是不一致的:

package main

import (
	"encoding/json"
	"fmt"
	"log"
)

func main(){
	var s1 []int
	s2 := []int{}
	b, err := json.Marshal(s1)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(string(b))
	b, err = json.Marshal(s2)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(string(b))
}

输出结果:

null
[]

2.2.通常错误的用法,会报数组越界的错误,因为只是声明了slice,却没有给实例化的对象:

var slice []int
slice[1] = 0

此时slice的值是nil,这种情况可以用于需要返回slice的函数,当函数出现异常的时候,保证函数依然会有nil的返回值。

2.3.empty slice 是指slice不为nil,但是slice没有值,slice的底层的空间是空的,此时的定义如下:

slice := make([]int,0)
slice := []int{}

当我们查询或者处理一个空的列表的时候,这非常有用,它会告诉我们返回的是一个列表,但是列表内没有任何值。

总之,nil slice empty slice是不同的东西,需要我们加以区分的.

3. 示例三

下面的代码输出是什么?

package main

import (
	"fmt"
)

func main() {
	slice := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
	s1 := slice[2:5]
	/*
	对于切片截取操作s[low:high:max]:
	1. 第一个数(low)表示下标的起点(从该位置开始截取),
	如果low取值为0表示从第一个元素开始截取;
	2. 第二个数(high)表示取到哪结束,也就是下标的终点(不包含该位置),
	根据公式计算(len=high-low),也就是第二个数减去第一个数,差就是数据长度。
	在这里可以将长度理解成取出的数据的个数。
	3. 第三个数用来计算容量,所谓容量:是指切片目前可容纳的最多元素个数。
	通过公式计算(cap=max-low),也就是第三个数减去第一个数。
	*/
	s2 := s1[2:6:7]

	s2 = append(s2, 100)
	s2 = append(s2, 200)

	s1[2] = 20

	fmt.Println(s1)
	fmt.Println(s2)
	fmt.Println(slice)

}

输出结果:

[2 3 20]
[4 5 6 7 100 200]
[0 1 2 3 20 5 6 7 100 9]

s1slice 索引2(闭区间)到索引5(开区间,元素真正取到索引4),长度为3,容量默认到数组结尾,为8。 s2s1 的索引2(闭区间)到索引6(开区间,元素真正取到索引5),容量到索引7(开区间,真正到索引6),为5。

go数组和切片的概念及用法

接着,向s2尾部追加一个元素 100:

s2 = append(s2, 100)

s2 容量刚好够,直接追加。不过,这会修改原始数组对应位置的元素。这一改动,数组和 s1 都可以看得到。

go数组和切片的概念及用法

再次向 s2 追加元素200:

s2 = append(s2, 100)

这时,s2 的容量不够用,该扩容了。于是,s2 另起炉灶,将原来的元素复制到新的位置,扩大自己的容量。并且为了应对未来可能的 append 带来的再一次扩容,s2 会在此次扩容的时候多留一些 buffer,将新的容量扩大为原始容量的2倍,也就是10了。

go数组和切片的概念及用法

最后,修改 s1 索引为2位置的元素:

s1[2] = 20

这次只会影响原始数组相应位置的元素。它影响不到 s2 了,人家已经远走高飞了。

go数组和切片的概念及用法

再提一点,打印 s1 的时候,只会打印出 s1 长度以内的元素。所以,只会打印出3个元素,虽然它的底层数组不止3个元素。

3.切片作为函数参数传递

前面我们说到,slice 其实是一个结构体,包含了三个成员:len, cap, array。分别表示切片长度,容量,底层数据的地址。

当 slice 作为函数参数时,就是一个普通的结构体。其实很好理解:若直接传 slice,在调用者看来,实参 slice 并不会被函数中的操作改变;若传的是 slice 的指针,在调用者看来,是会被改变原 slice 的。

值的注意的是,不管传的是 slice 还是 slice 指针,如果改变了 slice 底层数组的数据,会反应到实参 slice 的底层数据。为什么能改变底层数组的数据?很好理解:底层数据在 slice 结构体里是一个指针,仅管 slice 结构体自身不会被改变,也就是说底层数据地址不会被改变; 但是通过指向底层数据的指针,可以改变切片的底层数据,没有问题。

通过 slice 的 array 字段就可以拿到数组的地址。在代码里,是直接通过类似 s[i]=10 这种操作改变 slice 底层数组元素值。

另外,值得注意的是,Go 语言的函数参数传递,只有值传递,没有引用传递。

来看一个代码片段:

package main

func main() {
    s := []int{1, 1, 1}
    f(s)
    fmt.Println(s)
}

func f(s []int) {
    // i只是一个副本,不能改变s中元素的值
    /*for _, i := range s {
        i++
    }
    */

    for i := range s {
        s[i] += 1
    }
}

运行一下,程序输出:

[2 2 2]

通过下标引用的方式果真改变了原始 slice 的底层数据。

这里调用 f 函数时传递的是一个 slice 的副本,即在 f 函数中,s 只是 main 函数中 s 的一个拷贝。在f 函数内部,对 s 的作用并不会改变外层 main 函数的 s,正如上面所说的,在 f 函数中slice 结构体自身不会改变,即使我们通过下标引用的方式改变了切片的底层数据,其底层数据地址是不变的。

要想真的改变外层 slice,只有将返回的新的 slice 赋值到原始 slice,或者向函数传递一个指向 slice 的指针。我们再来看一个例子:

package main

import "fmt"

func myAppend(s []int) []int {
    // 这里 s 虽然改变了,但并不会影响外层函数的 s
    s = append(s, 100)
    return s
}

func myAppendPtr(s *[]int) {
    // 会改变外层 s 本身
    *s = append(*s, 100)
    return
}

func main() {
    s := []int{1, 1, 1}
    newS := myAppend(s)

    fmt.Println(s)
    fmt.Println(newS)

    s = newS

    myAppendPtr(&s)
    fmt.Println(s)
}

运行结果:

[1 1 1]
[1 1 1 100]
[1 1 1 100 100]

myAppend 函数里,虽然改变了 s,但它只是一个值传递,并不会影响外层的 s,因此第一行打印出来的结果仍然是 [1 1 1]

newS 是一个新的 slice,它是基于 s 得到的。因此它打印的是追加了一个 100 之后的结果: [1 1 1 100]

最后,将 newS 赋值给了 ss 这时才真正变成了一个新的slice。之后,再给 myAppendPtr 函数传入一个 s 指针,这回它就真的被改变了:[1 1 1 100 100]

4.切片的扩容

一般都是在向 slice 追加了元素之后,才会引起扩容。追加元素调用的是 append 函数。

先来看看 append 函数的原型:

func append(slice []Type, elems ...Type) []Type

append 函数的参数长度可变,因此可以追加多个值到 slice 中,还可以用 ... 传入 slice,直接追加一个切片:

slice = append(slice, elem1, elem2)
slice = append(slice, anotherSlice...)

append函数返回值是一个新的slice,Go编译器不允许调用了 append 函数后不使用返回值:

append(slice, elem1, elem2)
append(slice, anotherSlice...)

所以上面的用法是错的,不能编译通过。

使用 append 可以向 slice 追加元素,实际上是往底层数组添加元素。但是底层数组的长度是固定的,如果索引 len-1 所指向的元素已经是底层数组的最后一个元素,就没法再添加了。

这时,slice 会迁移到新的内存位置,新底层数组的长度也会增加,这样就可以放置新增的元素。同时,为了应对未来可能再次发生的 append 操作,新的底层数组的长度,也就是新 slice 的容量是留了一定的 buffer 的。否则,每次添加元素的时候,都会发生迁移,成本太高。

新 slice 预留的 buffer 大小是有一定规律的。网上大多数的文章都是这样描述的:

当原 slice 容量小于 1024 的时候,新 slice 容量变成原来的 2 倍;原 slice 容量超过 1024,新 slice 容量变成原来的1.25倍。

这里先说结论:以上描述是错误的。

为了说明上面的规律是错误的,看下面这段代码:

package main

import "fmt"

func main() {
	s := make([]int, 0)

	oldCap := cap(s)

	for i := 0; i < 2048; i++ {
		s = append(s, i)

		newCap := cap(s)

		if newCap != oldCap {
			fmt.Printf("[%d -> %4d] cap = %-4d  |  after append %-4d  cap = %-4d\n",
				0, i-1, oldCap, i, newCap)
			oldCap = newCap
		}
	}
}

先创建了一个空的 slice,然后,在一个循环里不断往里面 append 新的元素。然后记录容量的变化,并且每当容量发生变化的时候,记录下老的容量,以及添加完元素之后的容量,同时记下此时 slice 里的元素。这样,我就可以观察,新老 slice 的容量变化情况,从而找出规律。

运行结果:

[0 ->   -1] cap = 0     |  after append 0     cap = 1
[0 ->    0] cap = 1     |  after append 1     cap = 2
[0 ->    1] cap = 2     |  after append 2     cap = 4
[0 ->    3] cap = 4     |  after append 4     cap = 8
[0 ->    7] cap = 8     |  after append 8     cap = 16
[0 ->   15] cap = 16    |  after append 16    cap = 32
[0 ->   31] cap = 32    |  after append 32    cap = 64
[0 ->   63] cap = 64    |  after append 64    cap = 128
[0 ->  127] cap = 128   |  after append 128   cap = 256
[0 ->  255] cap = 256   |  after append 256   cap = 512
[0 ->  511] cap = 512   |  after append 512   cap = 1024
[0 -> 1023] cap = 1024  |  after append 1024  cap = 1280
[0 -> 1279] cap = 1280  |  after append 1280  cap = 1696
[0 -> 1695] cap = 1696  |  after append 1696  cap = 2304

在老 slice 容量小于1024的时候,新 slice 的容量的确是老 slice 的2倍。目前还算正确。

但是,当老 slice 容量大于等于 1024 的时候,情况就有变化了。当向 slice 中添加元素 1280 的时候,老 slice 的容量为 1280,之后变成了 1696,两者并不是 1.25 倍的关系 (1696/1280=1.325)。添加完 1696 后,新的容量 2304 当然也不是 16961.25 倍。

可见,现在网上各种文章中的扩容策略并不正确。我们直接搬出源码:

向 slice 追加元素的时候,若容量不够,会调用 growslice 函数,所以我们直接看它的代码。

// go 1.14.6 src/runtime/slice.go:76
// et:slice元素类型,old:旧的slice,cap:所需的新最小容量
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 {
			// Check 0 < newcap to detect overflow
			// and prevent an infinite loop.
			for 0 < newcap && newcap < cap {
				newcap += newcap / 4
			}
			// Set newcap to the requested cap when
			// the newcap calculation overflowed.
			if newcap <= 0 {
				newcap = cap
			}
		}
	}
	// ……
}

如果只看前半部分,现在网上各种文章里说的 newcap 的规律是对的。现实是,后半部分还对 newcap 作了一个内存对齐,这个和内存分配策略相关。进行内存对齐之后,新slice的容量是要 大于等于 老 slice 容量的 2倍或者1.25倍。

之后,向 Go 内存管理器申请内存,将老 slice 中的数据复制过去,并且将 append 的元素添加到新的底层数组中。

最后,向 growslice 函数调用者返回一个新的 slice,这个 slice 的长度并没有变化,而容量却增大了。

下面来看几个示例:

1. 示例一

package main

import "fmt"

func main() {
    s := []int{5}
    s = append(s, 7)
    s = append(s, 9)
    x := append(s, 11)
    y := append(s, 12)
    fmt.Println(s, x, y)
}

解释:

代码切片对应状态
s := []int{5}s 只有一个元素,[5]
s = append(s, 7)s 扩容,容量变为2,[5, 7]
s = append(s, 9)s 扩容,容量变为4,[5, 7, 9]。注意,这时 s 长度是3,只有3个元素
x := append(s, 11)由于 s 的底层数组仍然有空间,因此并不会扩容。这样,底层数组就变成了 [5, 7, 9, 11]。注意,此时 s = [5, 7, 9],容量为4;x = [5, 7, 9, 11],容量为4。这里 s 不变
y := append(s, 12)这里还是在 s 元素的尾部追加元素,由于 s 的长度为3,容量为4,所以直接在底层数组索引为3的地方填上12。结果:s = [5, 7, 9],y = [5, 7, 9, 12],x = [5, 7, 9, 12],x,y 的长度均为4,容量也均为4

所以最后程序的执行结果是:

[5 7 9] [5 7 9 12] [5 7 9 12]

这里要注意的是,append函数执行完后,返回的是一个全新的 slice,并且对传入的 slice 并不影响。

2. 示例二

关于 append,我们来看这个例子:

package main

import "fmt"

func main() {
    s := []int{1,2}
    s = append(s,4,5,6)
    fmt.Printf("len=%d, cap=%d",len(s),cap(s))
}

运行结果是:

len=5, cap=6

如果按网上各种文章中总结的那样:小于原 slice 长度小于 1024 的时候,容量每次增加 1 倍。添加元素 4 的时候,容量变为4;添加元素 5 的时候不变;添加元素 6 的时候容量增加 1 倍,变成 8。 那上面代码的运行结果应该是:

len=5, cap=8

这显然是错误的,我们来仔细看看,为什么会这样,再次搬出源码:

// go 1.14.6 src/runtime/slice.go:76
// et:slice元素类型,old:旧的slice,cap:所需的新最小容量
func growslice(et *_type, old slice, cap int) slice {
	// ……
	newcap := old.cap
	doublecap := newcap + newcap
	if cap > doublecap {
		newcap = cap
	} else {
		// ……
	}
	// ……
	// Specialize for common values of et.size.
	// For 1 we don't need any division/multiplication.
	// For sys.PtrSize, compiler will optimize division/multiplication into a shift by a constant.
	// For powers of 2, use a variable shift.
	switch {
	case et.size == 1:
		// ……
	case et.size == sys.PtrSize:
		// ……
		capmem = roundupsize(uintptr(newcap) * sys.PtrSize)
		// ……
		newcap = int(capmem / sys.PtrSize)
	case isPowerOfTwo(et.size):
		// ……
	default:
		// ……
	}
}

这个函数的参数依次是 元素的类型,老的 slice,新 slice 所需的最小容量。

例子中s原来只有 2 个元素,len 和 cap 都为 2,append 了三个元素后,长度变为 5,容量最小要变成 5,即调用 growslice 函数时,传入的第三个参数应该为 5。即 cap=5。而一方面,doublecap 是原 slice容量的 2 倍,等于 4。满足第一个 if 条件,所以 newcap 变成了 5

接着调用了 roundupsize 函数,传入 40。(代码中ptrSize是指一个指针的大小,int类型在64位机上大小是8

我们再看内存对齐,搬出 roundupsize 函数的代码:

// Returns size of the memory block that mallocgc will allocate if you ask for the size.
func roundupsize(size uintptr) uintptr {
	if size < _MaxSmallSize {
		if size <= smallSizeMax-8 {
			return uintptr(class_to_size[size_to_class8[(size+smallSizeDiv-1)/smallSizeDiv]])
		} else {
			//……
		}
	}
	//……
}

const _MaxSmallSize = 32768
const smallSizeMax = 1024
const smallSizeDiv = 8

很明显,我们最终将返回这个式子的结果:

class_to_size[size_to_class8[(size+smallSizeDiv-1)/smallSizeDiv]]

这是 Go 源码中有关内存分配的两个 sliceclass_to_size通过 spanClass获取 span划分的 object大小。而 size_to_class8 表示通过 size 获取它的 spanClass

var size_to_class8 = [smallSizeMax/smallSizeDiv + 1]uint8{0, 1, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13, 14, 14, 15, 15, 16, 16, 17, 17, 18, 18, 18, 18, 19, 19, 19, 19, 20, 20, 20, 20, 21, 21, 21, 21, 22, 22, 22, 22, 23, 23, 23, 23, 24, 24, 24, 24, 25, 25, 25, 25, 26, 26, 26, 26, 26, 26, 26, 26, 27, 27, 27, 27, 27, 27, 27, 27, 28, 28, 28, 28, 28, 28, 28, 28, 29, 29, 29, 29, 29, 29, 29, 29, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31}

var class_to_size = [_NumSizeClasses]uint16{0, 8, 16, 32, 48, 64, 80, 96, 112, 128, 144, 160, 176, 192, 208, 224, 240, 256, 288, 320, 352, 384, 416, 448, 480, 512, 576, 640, 704, 768, 896, 1024, 1152, 1280, 1408, 1536, 1792, 2048, 2304, 2688, 3072, 3200, 3456, 4096, 4864, 5376, 6144, 6528, 6784, 6912, 8192, 9472, 9728, 10240, 10880, 12288, 13568, 14336, 16384, 18432, 19072, 20480, 21760, 24576, 27264, 28672, 32768}

我们传进去的 size 等于 40。所以 (size+smallSizeDiv-1)/smallSizeDiv = 5;获取 size_to_class8 数组中索引为5的元素为 4;获取 class_to_size 中索引为 4 的元素为 48。 最终,新的 slice 的容量为 6

newcap = int(capmem / ptrSize) // 6

至于上面的两个魔法数组的由来,就不展开了。

3. 示例三

向一个nil的slice添加元素会发生什么?为什么?

其实 nil slice 或者empty slice都是可以通过调用 append 函数来获得底层数组的扩容。最终都是调用 mallocgc 来向 Go 的内存管理器申请到一块内存,然后再赋给原来的nil sliceempty slice,然后摇身一变,成为“真正”的 slice 了。

综上,本篇文章介绍了数组和切片的相关知识,并着重分析了切片的扩容原理,了解这些对于我们恰当使用切片进行开发并规避一些如底层数组改变而引发的切片问题也是有所帮助的。

参考文章:

https://qcrao91.gitbook.io/go/shu-zu-he-qie-pian

https://gfw.go101.org/article/container.html

到此,关于“go数组和切片的概念及用法”的学习就结束了,希望能够解决大家的疑惑。理论与实践的搭配能更好的帮助大家学习,快去试试吧!若想继续学习更多相关知识,请继续关注亿速云网站,小编会继续努力为大家带来更多实用的文章!

向AI问一下细节

免责声明:本站发布的内容(图片、视频和文字)以原创、转载和分享为主,文章观点不代表本网站立场,如果涉及侵权请联系站长邮箱:is@yisu.com进行举报,并提供相关证据,一经查实,将立刻删除涉嫌侵权内容。

go
AI