数组是指一系列同一类型数据的集合。数组中包含的每个数据被称为数组元素(element),这种类型可以是任意的原始类型,比如 int、string 等,也可以是用户自定义的类型。一个数组包含的元素个数被称为数组的长度。在 Golang 中数组是一个长度固定的数据类型,数组的长度是类型的一部分,也就是说 [5]int 和 [10]int 是两个不同的类型。Golang 中数组的另一个特点是占用内存的连续性,也就是说数组中的元素是被分配到连续的内存地址中的,因而索引数组元素的速度非常快。
本文将介绍 Golang 数组的基本概念和用法,演示环境为 ubuntu 18.04 & go1.10.1。
Golang 数组的特点
我们可以把 Golang 数组的特征归纳为以下三点:
- 固定长度:这意味着数组不可增长、不可缩减。想要扩展数组,只能创建新数组,将原数组的元素复制到新数组。
- 内存连续:这意味可以在缓存中保留的时间更长,搜索速度更快,是一种非常高效的数据结构,同时还意味着可以通过数值的方式(arr[index])索引数组中的元素。
- 固定类型:固定类型意味着限制了每个数组元素可以存放什么样的数据,以及每个元素可以存放多少字节的数据。
数组是个固定长度的数据类型,其长度和存储元素的数据类型都在声明数组时确定,并且不能更改。如果需要存储更多的元素,必须先创建一个更长的数组,然后把原来数组里的数据复制到新数组中。
数组占用的内存是连续分配的,比如我们创建一个包含 5 个整数元素的数组:
arr1 := [5]int{10,20,30,40,50}
数组在内存中的结构类似下图:
由于内存连续,CPU 能把正在使用的数据缓存更久的时间。而且在内存连续的情况下非常容易计算索引,也就是说可以快速迭代数组里的所有元素。原因是数组的类型信息可以提供每次访问一个元素时需要在内存中移动的距离,既然数组的每个元素的类型都相同,又是连续分配,因此就可以以固定的速度索引数组中的任意元素,并且速度非常快!
数组的声明与初始化
声明数组
声明数组时需要指定数组的长度和数组中元素的类型,比如声明一个包含 5 个元素,类型为 int 的数组:
var arr1 [5]int
这里强调一点,数组的类型是包含数组长度的,因此 [5]int 和 [10]int 是两个不同类型的数组。
在 Go 语言中声明变量时,总会使用对应类型的零值来初始化变量,数组也不例外。当声明数组变量时,数组内的每个元素被初始化为对应类型的零值。比如变量 arr1,它的 5 个元素都被初始化成了 int 类型的零值 0。使用 fmt.Println(arr1) 可以看到数组中元素的值:
package main
import "fmt"
func main(){
var arr1 [5]int
fmt.Println(arr1) // 输出为:[0 0 0 0 0]
}
你可以把此时数组在内存中的状态想象为下图所示的样子:
使用字面量初始化数组
我们可以通过字面量在声明数组的同时快速的初始化数组:
arr2 := [5]int{10,20,30,40,50}
对于这种情况,还可以使用 … 代替数组的长度,让编译器根据实际的元素个数自行推断数组的长度:
arr3 := […]int{10,20,30,40,50}
如果设置了数组的长度,还可以通过指定下标的方式初始化部分元素:
// 用具体值初始化索引为 1 和 3 的元素
arr4 := [5]int{1:20,3:40}
数组的内容如下图所示:
访问与修改数组元素
和其它类 C 语言一样,Go 语言数组通过数组下标(索引位置)来读取或者修改数组元素。下标(索引)从 0 开始,第一个元素的索引为 0,第二个索引为 1,依次类推。元素的数目(数组长度)必须是固定的并且在声明数组时就指定(编译器需要知道数组的长度以便分配内存),数组长度最大为 2G。
访问数组元素
对于数组 arr 来说,第一个元素就是 arr[0],第二个元素是 arr[1],最后一个元素则是 arr[len(arr)-1]。下面的代码定义一个整型数组,然后通过 for 循环打印数组中的每个元素:
package main
import "fmt"
func main(){
arr := [5]int{10,20,30,40,50}
for i := 0; i < len(arr); i++ {
fmt.Printf("At index %d is %d\n", i, arr[i])
}
}
运行上面的代码,输出如下:
At index 0 is 10
At index 1 is 20
At index 2 is 30
At index 3 is 40
At index 4 is 50
除了使用 len() 函数通过索引遍历数组,还可以使用更方便的 range,结果都是一样的:
for index,value := range arr {
fmt.Printf("At index %d is %d\n", index, value)
}
修改数组元素
要修改单个元素的值,直接通过下标访问元素并赋值就可以了:
arr := [5]int{10,20,30,40,50}
arr[2] = 35
指针数组
数组的元素除了是某个类型外,还可以是某个类型的指针,下面声明一个所有元素都是指针的数组,然后使用 * 运算符就可以访问元素指针所指向的值:
arr := [5]*int{0: new(int), 1: new(int)}
new(TYPE) 函数会为一个 TYPE 类型的数据结构划分内存并执行默认的初始化操作,然后返回这个数据对象的指针,所以 new(int) 表示创建一个 int 类型的数据对象,同时返回指向这个对象的指针。
// 为索引为 0 和 1 的元素赋值
*arr[0] = 10
*arr[1] = 20
完成赋值后的结果如下:
我们还可以接着初始化剩下的元素并赋值:
arr[2] = new(int)
arr[3] = new(int)
arr[4] = new(int)
*arr[2] = 30
最后打印整个指针数组指向的内容:
for i := 0; i < len(arr); i++ {
fmt.Printf("At index %d is %d\n", i, *arr[i])
}
结果如下:
At index 0 is 10
At index 1 is 20
At index 2 is 30
At index 3 is 0
At index 4 is 0
数组是值类型
在 Golang 中,数组是值类型,这意味着数组也可以用在赋值操作中。变量名代表整个数组,同类型的数组可以赋值给另一个数组:
var arr1 [3]string
arr2 := [3]string{"nick", "jack", "mark"}
// 把 arr2 的赋值(其实本质上是复制)到 arr1
arr1 = arr2
复制完成后两个数组的值完全一样,但是彼此之间没有任何关系:
前面我们不止一次地提到:数组的类型包括数组的长度和数组元素的类型。只有这两部分都一样才是相同类型的数组,也才能够互相赋值。下面的代码中,在类型不同的数组间赋值,编译器会阻止这样的操作并报错:
// 声明第一个包含 4 个元素的字符串数组
var arr1 [4]string
// 声明第二个包含 3 个元素的字符串数组,并初始化
arr2 := [3]string{"nick", "jack", "mark"}
// 将 arr2 赋值给 arr1
arr1 = arr2
编译器表示在赋值时不能把 type [3]string 当 type [4]string 用。
把数组赋值给其它数组时,实际上是完整地复制一个数组。所以,如果数组是一个指针型的数组,那么复制的将是指针,而不会复制指针所指向的对象。看下面的代码:
// 声明第一个包含 4 个元素的字符串数组
var arr1 [3]*string
// 声明第二个包含 3 个元素的字符串数组,并初始化
arr2 := [3]*string{new(string), new(string), new(string)}
*arr2[0] = "nick"
*arr2[1] = "jack"
*arr2[2] = "mark"
// 将 arr2 赋值给 arr1
arr1 = arr2
在赋值完成后,两个数组指向的是同一组字符串:
把数组传递给函数
在 Golang 中数组是一个值类型,所有的值类型变量在赋值和作为参数传递时都将产生一次复制操作。如果直接将数组作为函数的参数,则在函数调用时数组会被复制一份传递给函数。因此,在函数体中无法修改源数组的内容,因为函数内操作的只是源数组的一个副本。
如此一来,从内存和性能上来看,在函数间传递数组是一个开销很大的操作。因为无论这个数组有多长,都会完整复制,并传递给函数。下面的 demo 中会声明一个包含 100 万个 int64 类型元素的数组,这会消耗掉 8MB 的内存:
func showArray(array [1e6]int64){
// do something
}
var arr [1e6]int64
showArray(arr)
每次函数 showArray 被调用时,必须在栈上分配 8MB 的内存。之后整个数组的值(8MB 内存) 被复制到刚刚分配的内存中。虽然 Golang 的运行时会自动处理这个复制操作,但这样做的效率实在是太低了,也太耗费内存!合理且高效的方式是只传入指向数组的指针,这样只需复制 8 个字节的数据到函数的栈上就可以了:
func showArray(array *[1e6]int64){
// do something
}
var arr [1e6]int64
showArray(&arr)
这段代码中的 showArray 函数接收一个指向包含 100 万个 int64 值的数组的指针,调用函数时传入的参数则是指向数组的指针。现在只需在栈上分配 8 个字节的内存给这个指针就行了。
这个方法能够更有效地利用内存,性能也更好。需要注意的是,此时在函数内外操作的都是同一个数组中的元素,会互相影响。
多维数组
多维数组的典型用例是平面坐标(二维数组)和三维坐标(三维数组),这里我们简单介绍一下二维数组。
Golang 的数组本身只有一个维度,但是我们可以组合多个数组从而创建出多维数组,下面是声明二维数组的实例代码:
// 声明一个二维整型数组,两个维度分别存储 4 个元素和 2 个元素
var arr [4][2]int
// 使用数组字面量来声明并初始化一个二维整型数组
arr1 := [4][2]int{{10, 11}, {20, 21}, {30, 31}, {40, 41}}
// 声明并初始化外层数组中索引为 1 和 3 的元素
arr2 := [4][2]int{1: {20, 21}, 3: {40, 41}}
// 声明并初始化外层数组和内层数组的单个元素
arr3 := [4][2]int{1: {0: 20}, 3: {1: 41}}
下图展示了上面代码声明的二维数组在每次声明并初始化后包含的值:
为了访问单个元素,需要反复组合使用 [] 运算符,比如:
arr1[0][0] = 666
因为每个数组都是一个值,所以可以独立复制某个维度:
// 将 arr1 的索引为 1 的维度复制到一个同类型的新数组里
var arr4 [2]int = arr1[1]
// 将外层数组的索引为 1、内层数组的索引为 0 的整型值复制到新的整型变量里
var value int = arr1[1][0]
总结
数组在 Golang 中是作为高性能的基础类型设计的,因此对用户来说使用起来并不是特别方便,这一点在众多的开源代码中(数组用的少,slice 用的多)可以得到印证。其实基于数组实现的 slice 以其简单灵活的特性更易于被大家接受,这也正是 Golang 设计 slice 的初衷。本文介绍了数组这个幕后大英雄,后面的文章会介绍 slice 的用法。
参考:
Golang Array types
《Go语言编程》
《Go语言实战》
go基础系列:数组
作者:sparkdev
有疑问加站长微信联系(非本文作者)