数组
一个数组的所有元素紧挨着存放在一块连续的内存中,一个数组中的所有元素均存放在此数组值的直接部分。也就是常说的值类型。
Q: 数组的类型是存在间接部分呢?
一个数组类型的长度是此数组类型的一部分。比如[5]int
和[8]int
是两个不同的类型。
一个数组类型的尺寸等于它的元素类型的尺寸和它的长度的乘积。长度为零的数组的尺寸为零;元素类型尺寸为零的任意长度的数组类型的尺寸也为零。在容器类型(数组,切片,映射)中,只有数组可以这么计算。
也可以通过 unsafe 包提供的 Sizeof 函数,我们可以获得一个数组变量的总大小。
var arr = [6]int{1, 2, 3, 4, 5, 6}
fmt.Println("数组长度:", len(arr)) // 6
fmt.Println("数组大小:", unsafe.Sizeof(arr)) // 48
声明以及初始化
字面量初始化
比如对于一个数组(容器)类型T
,它的值可以用形式T{...}
来表示(除了切片和映射的零值外)
// 一个含有4个布尔元素的数组值。
[4]bool{false, true, true, false}
// 下面这些数组字面量都是等价的。
[4]bool{false, true, true, false}
[4]bool{0: false, 1: true, 2: true, 3: false}
[4]bool{1: true, true}
[4]bool{2: true, 1: true}
[...]bool{false, true, true, false}
[...]bool{3: false, 1: true, true}
//零值
[100]int{}
// 长度为 0 的数组
var d = [0]int{}
上例中的...
表示让编译器推断出相应数组值的类型的长度。
我们可以看出数组和切片组合字面量中的索引下标(即数组和切片的键值)是可选的。在一个数组或者切片组合字面量中,组合字面量中索引的要求:
- 如果一个索引下标出现,它的类型不必是数组和切片类型的键值类型
int
,但它必须是一个可以表示为int值的非负常量; 如果它是一个类型确定值,则它的类型必须为一个内置整数类型。 - 在一个数组或切片组合字面量中,如果一个元素的索引下标缺失,则编译器认为它的索引下标为出现在它之前的元素的索引下标加一。
- 如果出现的第一个元素的索引下标缺失,则它的索引下标被认为是0。
注意索引一定得是常量(非负):
var a uint = 1
var _ = []int{a: 100} // error: 下标必须为常量
Q: 既然索引一定是非负常量,为什么不使用uint类型呢?
A:
- 避免潜在的错误和复杂性:使用
uint
作为索引类型可能会带来一些潜在的错误和复杂性。例如,负数索引会自动变成一个很大的正数,从而引发难以发现的错误。而int
类型可以更好地处理负数,从而减少这些潜在的问题。- 简化代码和类型转换:大多数情况下,索引是由计算或循环变量生成的,而这些变量通常是
int
类型。如果索引类型是uint
,则需要进行额外的类型转换,这会增加代码的复杂性。- 简洁和易用,同其他语言保持一致,降低学习难度
在运行时刻,即使一个数组变量在声明的时候未指定初始值,它的元素所占的内存空间也已经被开辟出来(并且填充了零值)。 但是一个nil切片或者映射值的元素的内存空间尚未被开辟出来。
func TestArray(t *testing.T) {
var a = [6]int{}
fmt.Println("数组大小:", unsafe.Sizeof(a)) // 48
}
声明以及初始化
// 声明
var a [3]int
// 初始化
a = [3]int{1, 2, 3}
a = [...]int{1, 2, 3}
b := [...]int{1, 2, 3}
数组的地址
一个数组变量即表示整个数组,它并不是隐式的指向第一个元素的指针(比如 C 语言的数组),而是一个完整的值。当一个数组变量被赋值或者被传递的时候,实际上会复制整个数组。如果数组较大的话,数组的赋值也会有较大的开销。为了避免复制数组带来的开销,可以传递一个指向数组的指针,但是数组指针并不是数组。
但是在取地址时候,直接取变量的地址实际上是等于下标为0的元素的地址。
s := [2]int{1, 2}
fmt.Printf("s: %p\n", &s) // 0xc00000adb0
fmt.Printf("s[0]: %p\n", &s[0]) // 0xc00000adb0
如果一个数组是可寻址的,则它的元素也是可寻址的;反之亦然,即如果一个数组是不可寻址的,则它的元素也是不可寻址的。 原因很简单,因为一个数组只含有一个(直接)值部,并且它的所有元素和此直接值部均承载在同一个内存块上。
数组赋值
当一个数组被赋值给另一个数组,所有的元素都将被从源数组复制到目标数组。赋值完成之后,这两个数组不共享任何元素。
一个数组中的元素个数总是恒定的,我们无法向其中添加元素,也无法从其中删除元素。但是可寻址的数组值中的元素是可以被修改的。
Q: 什么是不可寻址的数组值?
数组的遍历
详见 for range 章节。
数组值的比较
大多数数组类型都是可比较类型,除了元素类型为不可比较类型的数组类型。
当比较两个数组值时,它们的对应元素将按照逐一被比较(可以认为按照下标顺序比较)。这两个数组只有在它们的对应元素都相等的情况下才相等;当一对元素被发现不相等的或者在比较中产生 panic 的时候,对数组的比较将提前结束。
把数组指针当做数组来使用
对于某些情形,我们可以把数组指针当做数组来使用。
我们可以通过在range
关键字后跟随一个数组的指针来遍历此数组中的元素。复制一个切片或者映射的代价很小,但是复制一个大尺寸的数组的代价比较大。 对于大尺寸的数组,这种方法比较高效,因为复制一个指针比复制一个大尺寸数组的代价低得多, 或者遍历从这个数组中派生出的一个切片。
for range:被遍历的是一个副本。直接部分被复制。
下面的例子中的两个循环是等价的,它们的效率也基本相同。
package main
import "fmt"
func main() {
var a [100]int
for i, n := range &a { // 复制一个指针的开销很小
fmt.Println(i, n)
}
for i, n := range a[:] { // 复制一个切片的开销很小
fmt.Println(i, n)
}
}
如果一个for-range
循环中的第二个循环变量既没有被忽略,也没有被舍弃,并且range
关键字后跟随一个nil数组指针,则此循环将造成一个panic。
在下面这个例子中,前两个循环都将打印出5个下标,但最后一个循环将导致一个panic。
package main
import "fmt"
func main() {
var p *[5]int // nil
for i, _ := range p { // okay
fmt.Println(i)
}
for i := range p { // okay
fmt.Println(i)
}
for i, n := range p { // panic
fmt.Println(i, n) // panic: runtime error: invalid memory address or nil pointer dereference
}
}
因为等同于
n = p[i]
,而 p 为空指针,所以会 panic 。
我们可以通过数组的指针来访问和修改此数组中的元素。如果此指针是一个nil指针,将导致一个panic。
package main
import "fmt"
func main() {
a := [5]int{2, 3, 5, 7, 11}
p := &a
p[0], p[1] = 17, 19
fmt.Println(a) // [17 19 5 7 11]
p = nil
_ = p[0] // panic
}
我们可以从一个数组的指针派生出一个切片(暂时将切片当做动态数组即可)。从一个nil数组指针派生切片将导致一个panic。
package main
import "fmt"
func main() {
pa := &[5]int{2, 3, 5, 7, 11}
s := pa[1:3]
fmt.Println(s) // [3 5]
pa = nil
s = pa[0:0] // panic
// 如果下一行能被执行到,则它也会产生panic。
_ = (*[0]byte)(nil)[:]
}
内置len
和cap
函数调用接受数组指针做为实参。 nil数组指针实参不会导致panic。
var pa *[5]int // == nil
fmt.Println(len(pa), cap(pa)) // 5 5
Q: 为什么不会 panic ?切片呢?
A: 因为这些函数只是检查指针指向的数据结构的长度和容量,而数组的长度和容量是类型固有的(编译时就确定了),与指针是否实际指向一个有效的数组无关。即便指针是 nil,Go 语言的类型系统知道数组的大小,因此可以返回长度和容量,而不需要实际访问数组内容。
显然切片的长度和容量不是固定的,只有在运行时才能获取,自然会 panic 。