数组

一个数组的所有元素紧挨着存放在一块连续的内存中,一个数组中的所有元素均存放在此数组值的直接部分。也就是常说的值类型。

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:

  1. 避免潜在的错误和复杂性:使用 uint 作为索引类型可能会带来一些潜在的错误和复杂性。例如,负数索引会自动变成一个很大的正数,从而引发难以发现的错误。而 int 类型可以更好地处理负数,从而减少这些潜在的问题。
  2. 简化代码和类型转换:大多数情况下,索引是由计算或循环变量生成的,而这些变量通常是 int 类型。如果索引类型是 uint,则需要进行额外的类型转换,这会增加代码的复杂性。
  3. 简洁和易用,同其他语言保持一致,降低学习难度

在运行时刻,即使一个数组变量在声明的时候未指定初始值,它的元素所占的内存空间也已经被开辟出来(并且填充了零值)。 但是一个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)[:]
}

内置lencap函数调用接受数组指针做为实参。 nil数组指针实参不会导致panic

var pa *[5]int // == nil
fmt.Println(len(pa), cap(pa)) // 5 5

Q: 为什么不会 panic ?切片呢?

A: 因为这些函数只是检查指针指向的数据结构的长度和容量,而数组的长度和容量是类型固有的(编译时就确定了),与指针是否实际指向一个有效的数组无关。即便指针是 nil,Go 语言的类型系统知道数组的大小,因此可以返回长度和容量,而不需要实际访问数组内容。

显然切片的长度和容量不是固定的,只有在运行时才能获取,自然会 panic 。