目 录CONTENT

文章目录
go

【Go】复合数据类型

hxuanyu
2025-12-23 / 0 评论 / 0 点赞 / 11 阅读 / 0 字

同构类型:集合中的元素类型相同

异构类型:集合中的元素类型不同

数组:同构静态复合类型

逻辑定义

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,都是二维数组。

image

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的默认值为数组的长度。

针对一个已存在的数组,可以创建多个数组的切片,这些切片共享同一个底层数组,对底层数组的操作同样会影响其它切片:

基于数组array创建两个切片的内存表示

方法三:基于切片创建新的切片

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用于表示键值对的集合

和切片类似,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类型。

结构体类型

0

评论区