同构类型:集合中的元素类型相同
异构类型:集合中的元素类型不同
数组:同构静态复合类型
逻辑定义
Go中的数组由固定长度的同构元素组成,是一个连续序列。元素的类型和数组的长度直接决定如何声明Go数组类型的变量:
var arr [N]T
以上代码声明了一个数组变量arr,类型为[N]T,其中元素类型为T,数组长度为N。数组元素的类型T可以为任意的Go原生类型或自定义类型,而且数组的长度必须在声明时确定。
如果两个数组类型的元素类型
T与数组长度N都相同,那么这个两个数组类型是等价的;如果任意一项属性不同,则被视为不同的数组类型。
例如以下代码:
func foo(arr [5]int) {}
func main() {
var arr1 [5]int
var arr2 [6]int
var arr3 [5]string
foo(arr1) // 正确
foo(arr2) // 错误:[6]int与函数foo参数的类型[5]int不匹配
foo(arr3) // 错误:[5]string与函数foo参数的类型[5]int不匹配
}
物理表现形式
Go编译器为数组类型变量分配内存时,会为所有元素分配一块连续的内存空间:

Go提供了内置函数len,可以用于获取一个数组变量的长度,通过unsafe包中的Sizeof函数可以获得一个数组变量的总大小:
var arr = [6]int{1, 2, 3, 4, 5, 6}
fmt.Println("数组长度:", len(arr)) // 输出:6
fmt.Println("数组大小:", unsafe.Sizeof(arr)) // 输出:48
数组的大小是所有元素大小之和,因此,在64位平台上,int类型的大小为8字节,因此总大小为48字节。
与基本数据类型相似,在声明数组类型变量时也可以进行显式初始化,如果没有显式初始化,数组元素会被赋予该类型的零值。同样也可以使用...代替数组长度:
var arr1 [6]int // 结果为[0 0 0 0 0 0]
var arr2 = [6]int {
11, 12, 13, 14, 15, 16,
} // 结果为[11 12 13 14 15 16]
var arr3 = […]int {
21, 22, 23,
} // 结果为[21 22 23]
fmt.Printf("%T\n", arr3) // 输出:[3]int
对于较大长度且稀疏的数组,对元素进行逐一初始化比较麻烦,Go提供了指定下标的方式进行初始化:
var arr4 = […]int{
99: 39, // 将第100个元素(下标值为99)的值设为39,其余元素值均为0
}
fmt.Printf("%T\n", arr4) // [100]int
借助数组类型变量和下标值可以高效访问数组中的元素,且和其他语言一样,Go中的数组下标从0开始,如果小标值超过长度范围或为负数,Go编译器会给出错误提示。
多维数组
数组类型自身也可以作为数组元素的类型,形成多维数组:
var mArr [2][3][4]int
首先将mArr视为一个包含两个元素且每个元素类型都为[3][4]int的数组,两个元素分别是mArr[0]和mArr[1],类型均为[3][4]int,都是二维数组。

以mArr[0]为例,进一步看作一个拥有3个元素且每个元素类型都为[4]int的数组,这个数组有3个元素,类型均为[4]int,都是一维数组。
无论数组的实际维度有多少,最终都可以按照从左到右的顺序逐一展开,简化为一位数组形式。
多维数组也可以在声明时对内容初始化:
// 多维数组
arr5 := [2][3][4]int{
{
{1, 2, 3, 4},
{2, 3, 4, 5},
{3, 4, 5, 6},
},
{
{4, 5, 6, 7},
{5, 6, 7, 8},
{6, 7, 8, 9},
},
}
fmt.Println(arr5)
Go 词法层面有一个关键机制:分号自动插入。规范里规定:在某些 token(比如标识符、基本字面量、
break/continue/return、)、]、} 等)之后如果遇到换行,编译器会在换行处自动插入一个;。
}也是会触发自动插入分号的 token。于是你写:var a = [2]int{ 1 2 }在词法阶段大致会变成(示意):
var a = [2]int{ 1; 2; };这会把“列表项之间用逗号分隔”的语法彻底打乱:解析器看到
1; 2,而它期待的是1, 2。因此 Go 规定:只要你选择把列表写成多行(即 } 不与最后一项同一行),就必须用逗号显式标明“这里还在列表里”。换句话说,这条规则是为了与“自动分号插入”配套,让“换行”能安全地用于排版,而不让解析器在“这是列表继续?”还是“这是语句结束?”之间产生歧义。
切片:同构动态复合类型
数组类型由于元素数量固定且传值机制会导致较大开销,因此Go引入了另一种同构复合类型:切片。
切片和数组相似但又各有特点,切片的声明方式如下:
var nums = []int{1, 2, 3, 4, 5, 6}
与数组声明相比,切片的声明中少了长度属性,由于没有长度的约束,切片在使用时更加灵活。切片的长度是随着切片中元素的数量变化的,这也是切片类型动态特征的体现。同样可以使用len函数获得切片变量的长度:
fmt.Println(len(nums)) // 输出:6
通过Go内置的append函数可以动态像切片中添加元素。添加元素后切片的长度也随之发生变化:
nums = append(nums, 7) // 切片变为[1 2 3 4 5 6 7]
fmt.Println(len(nums)) // 输出:7
切片类型的实现
切片在运行时其实是一个三元组结构,定义如下:
type slice struct {
array unsafe.Pointer
len int
cap int
}
每个切片包含以下三个字段:
-
array:指向底层数组的指针。 -
len:切片的长度,即当前切片中元素的数量。 -
cap:底层数组的长度,表示切片可扩展的最大长度。cap值永远大于等于len值。
Go编译器会自动为新创建的切片建立一个底层数组,默认底层数组的长度和切片初始元素的数量相同。
还可以使用以下方法创建切片,并手动指定底层数组的长度:
方法一:通过make函数创建切片,并指定底层数组的长度:
sl := make([]byte, 6, 10) // 其中,10为cap值,即底层数组长度,6为切片的初始长度
如果没有在make中指定cap参数,那么底层数组的长度cap等于len:
sl := make([]byte, 6) // cap = len = 6
方法二:采用array[low:high:max]语法基于一个已存在的数组创建切片,这种方式成为数组的切片化:
arr := [10]int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
sl := arr[3:7:9]
以上代码基于数组arr创建了一个切片sl,其在运行时的表示如图:

基于数组创建的切片的起始元素从low所表示的下标值开始,切片的长度len是high - low,容量是max - low。由于切片sl的底层数组就是arr,因此对切片sl中元素的修改将直接影响数组arr:
sl[0] += 10
fmt.Println("arr[3] =", arr[3]) // 输出:14
切片就像访问与修改数组的一个窗口,类似于文件描述符之于文件,切片对于数组的操作提供了便携的方式。
由于切片更类似于“描述符”,因此切片在函数参数传递时可以避免较大性能开销,因为传递的并不是数组本身,而是数组的“描述符”,这个描述符的大小是固定的。在进行数组切片化时,通常省略max,此时max的默认值为数组的长度。
针对一个已存在的数组,可以创建多个数组的切片,这些切片共享同一个底层数组,对底层数组的操作同样会影响其它切片:

方法三:基于切片创建新的切片
sl := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
sl1 := sl[2:4] // 结果为[3 4]
基于切片创建切片的场景和数组类似。
切片与数组最大的不同就是长度可以动态变化,这种变化需要“动态扩容”机制支持。
切片的扩容
动态扩容指通过append操作向切片追加数据时,如果切片的len值和cap值相等,也就是说底层的数组已经没有空闲空间存储追加的值,Go运行时会自动对切片进行扩容操作,确保切片能存储追加的新值:
var s []int
s = append(s, 11)
fmt.Println(len(s), cap(s)) // 输出:1 1
s = append(s, 12)
fmt.Println(len(s), cap(s)) // 输出:2 2
s = append(s, 13)
fmt.Println(len(s), cap(s)) // 输出:3 4
s = append(s, 14)
fmt.Println(len(s), cap(s)) // 输出:4 4
s = append(s, 15)
fmt.Println(len(s), cap(s)) // 输出:5 8
从输出结果来看,当现有底层数组容量无法满足要求时,append会动态分配新的数组,长度按一定的规律扩展,新数组的容量是当前数组的2倍,建立新数组后,append会把旧的数组的数据复制到新的数组中,之后新的数组成为切片的底层数组,就数组会被垃圾回收。
Go切片的动态扩容算法会随着Go版本的演进而变化。小切片与大切片的扩容比例不一样,小切片通常以2倍的比值进行扩容,大小切片的容量标准也在不断进行调整,Go1.18版本后变为256。
append操作在数据发生扩容时,会创建新的底层数组,因此基于已有数组创建的切片一旦追加数据达到切片容量上线(数组容量上线),切片将会与原数组解除绑定,后续对切片的任何修改都不会反映到原数组中,这可能在实际编码中产生一个小陷阱,使用时要格外注意。
map类型
类型定义
map是Go提供的一种抽象数据类型,用于表示一组无需的键值对,分别使用key和value进行描述。每个map集合中的key都是唯一的:

和切片类似,map在Go中的类型表示由key的类型和value的类型共同组成:
map[key_type]value_type
key和value的类型可以相同也可以不同:
map[string]string // key与value的类型相同
map[int]string // key与value的类型不同
如果两个map类型的key类型相同,value类型也相同,那么可以说是同一种map类型。
map对value的类型没有严格限制,但是对key的类型有严格要求,即key的类型必须支持==和!=两种比较操作符。但对于函数类型、map类型和切片类型来说,这些类型只能和nil进行比较,而不支持同类型变量之间的比较,因此这三类特殊类型也不能作为map的key。
声明和初始化
可以使用以下方式声明一个map变量:
var m map[string]int // 一个map[string]int类型的变量
如果没有显式赋予初始值,map类型变量的默认值为nil。
对于初始值为零值nil的切片类型变量,可以接触append函数进行操作,这种特性叫做“零值可用”。定义零值可用的类型可以提升开发体验,因为不需要担心变量的初始状态是否有效,但是map由于内部实现复杂,不支持“零值可用”,所以,如果对处于零值状态的map变量直接进行操作,会导致运行时异常(panic),使程序异常退出。
var m map[string]int // m = nil
m[“key”] = 1 // 发生运行时异常:panic: assignment to entry in nil map
因此必须对map类型变量进行显式初始化后才能使用,初始化的方式如下:
方法1:使用复合字面值初始化
m := map[int]string{}
以上语句显式初始化了map类型的变量m。即使此时没有任何键值对,但不等同于初始值为nil的map变量,此时可以直接对m进行插入操作。
复杂的显式初始化示例如下:
m1 := map[int][]string{
1: []string{"val1_1", "val1_2"},
3: []string{"val3_1", "val3_2", "val3_3"},
7: []string{"val7_1"},
}
type Position struct {
x float64
y float64
}
m2 := map[Position]string{
Position{29.935523, 52.568915}: "school",
Position{25.352594, 113.304361}: "shopping-mall",
Position{73.224455, 111.804306}: "hospital",
}
以上示例中,对两个map变量m1和m2进行显式初始化,但是作为初始值的字面值有些臃肿,因为初始值的字面值包含复合类型,在编写字面值时还带有各自元素的类型,针对这种情况,Go提供了“语法糖”:Go允许省略字面值中的元素类型。因为map类型声明中已经包含了key和value的元素类型,Go编译器完全可以使用这些信息推导出字面值中各个值的类型,m2的初始化可以简化为以下写法:
m2 := map[Position]string{
{29.935523, 52.568915}: "school",
{25.352594, 113.304361}: "shopping-mall",
{73.224455, 111.804306}: "hospital",
}
方法2:使用make显式初始化
和切片通过make进行初始化一样,可以为map类型变量指定键值对的初始容量,但无法进行具体的键值对复制:
m1 := make(map[int]string) // 未指定初始容量
m2 := make(map[int]string, 8) // 指定初始容量为8
map类型的容量不会受限于初始容量值。当其中的键值对数量超过初始容量后,Go运行时会自动增加map类型的容量,确保后续键值对的正常插入。
基本操作
插入新键值对
对于非nil的map类型变量,可以在其中插入符合map类型定义的新键值对,插入新键值对时,只需要把value赋值给map中对应的key即可:
m := make(map[int]string)
m[1] = "value1"
m[2] = "value2"
m[3] = "value3"
Go会确保插入总是成功的,因此不需要手动判断是否插入成功。Go运行时负责管理map变量内部的内存,因此,除非系统内存耗尽,否则可以放心地向map中添加任意数量的新数据。
如果插入时某个key已经存在于map中,则该操作会使用新值覆盖旧值:
m := map[string]int {
"key1" : 1,
"key2" : 2,
}
m["key1"] = 11 // 新值11会覆盖key1对应的旧值1
m["key3"] = 3 // 此时m为map[key1:11 key2:2 key3:3]
获取键值对数量
可以通过len获取当前map类型变量中已建立的键值对数量:
m := map[string]int {
"key1" : 1,
"key2" : 2,
}
fmt.Println(len(m)) // 输出:2
m["key3"] = 3
fmt.Println(len(m)) // 输出:3
不能对map类型变量调用
cap函数来获取当前容量,这是map类型与切片类型的一个不同点。
查找和数据读取
m := make(map[string]int)
v := m["key1"]
直接使用key读取map中的值时,如果key在map中不存在,会得到该value类型的零值,因此不能直接获取后再判断结果是否为零值,而应该使用一种名为comma ok的惯用法对某个key进行查询:
m := make(map[string]int)
v, ok := m["key1"]
if !ok {
// key1不在map中
}
// key1在map中,v将被赋予key1键对应的value
可以通过布尔类型变量ok判断键key1是否存在于map中,如果存在,变量v会被赋值为key1对应的value。
如果不关心某个键对应的值,只是关心是否存在,可以使用空表师傅替代变量v,忽略可能返回的value:
m := make(map[string]int)
_, ok := m["key1"]
…
在Go中,要使用“comma ok”惯用法对map进行键查找和键值读取操作。
删除数据
在Go中,可以通过内置函数delete实现删除操作,使用delete函数时,第一个参数是map类型变量,第二个参数是要删除的键:
m := map[string]int {
"key1" : 1,
"key2" : 2,
}
fmt.Println(m) // 输出:map[key1:1 key2:2]
delete(m, “key2”) // 删除key2
fmt.Println(m) // 输出:map[key1:1]
delete函数是从map中删除键的唯一方法,即便传给delete函数的键在map中不存在,该函数也不会失败或抛出运行时异常。
遍历键值
在Go中,遍历map的键值对只有一种方法:使用for range语句,类似于遍历切片。
package main
import "fmt"
func main() {
m := map[int]int{
1: 11,
2: 12,
3: 13,
}
fmt.Printf("{ ")
for k, v := range m {
fmt.Printf("[%d, %d] ", k, v)
}
fmt.Printf("}\n")
}
如果只关心每次迭代的键,可以使用:
for k, _ := range m {
// 使用k
}
或者:
for k := range m {
// 使用k
}
如果只关心value,可以使用空标识符替代变量k:
for _, v := range m {
// 使用v
}
对统一map进行多次遍历时,每次遍历元素的顺序都不同,这是Go中map类型的一个重要特性,因此,不能依赖遍历map时得到的元素顺序来编写程序逻辑。
map变量传递的开销
map在底层实际上是一个指针类型,因此,当一个map类型变量作为参数被传递给函数或方法时,实际上传递的是这个map的指针而非整个map的数据副本,因此开销是固定的且非常小。
当map类型变量传递到函数或方法内部后,对该map类型参数进行的任何修改,在调用函数或方法外部也是可见的:
package main
import "fmt"
func foo(m map[string]int) {
m["key1"] = 11
m["key2"] = 12
}
func main() {
m := map[string]int{
"key1": 1,
"key2": 2,
}
fmt.Println(m) // 输出:map[key1:1 key2:2]
foo(m)
fmt.Println(m) // 输出:map[key1:11 key2:12]
}
并发访问
在Go中,map实例并不支持并发写安全,也不支持并发读安全。这意味着尝试在多个goroutine中同时对同一个map实例进行读写操作,程序运行时可能抛出异常。为了确保数据一致性和完整性,我们需要手动同步对map实例进行访问。
在仅进行并发读取的情况下,map是没有问题的。自Go1.9版本起,引入了支持并发读写的sync.Map类型。
结构体类型
评论区