侧边栏壁纸
  • 累计撰写 16 篇文章
  • 累计创建 2 个标签
  • 累计收到 0 条评论

目 录CONTENT

文章目录
go

【Go】复合数据类型

hxuanyu
2025-12-26 / 0 评论 / 0 点赞 / 89 阅读 / 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类型。

结构体类型

在实际开发中,只有基本数据类型和简单的复合类型是不够的,尤其是面向对象变成思想下,需要一种通用的,对实体对象进行聚合抽象的能力,在Go中,结构体类型提供了这一能力。

定义新类型

在Go中,有两中方式定义新的类型。

第一种:类型定义

type T S // 定义一个新类型T

type T1 int // S可以任何已定义类型,包括Go原生类型
type T2 T1 // 也可以是已定义的其它类型

底层类型:如果新类型基于某个Go原生类型定义,那么该原生类型是新类型的底层类型。

底层类型在Go中被用于判断两个类型是否本质上相同。 除了基于已有类型定义新类型外,还可以基于类型字面值定义新类型,这种方式常用语创建复合类型:

type M map[int]String
type S []string

也支持使用type代码块的方式:

type (
	T1 int
    T2 T1
    T3 string
)

第二种方法:类型别名

这种方法常用语项目重构或二次封装现有包,具体形式:

type T = S

实际上类型别名并未定义新类型,而是给已有类型起了另一个名字:

type T = string
 
var s string = "hello" 
var t T = s // 正确
fmt.Printf("%T\n", t) // 输出:string

定义结构体类型

常用定义方式

复合类型的定义一般通过类型字面值的方式进行,结构体的定义方式如下:

type T struct {
    Field1 T1
    Field2 T2
    …
    FieldN Tn
}

通过以上定义,我们可以得到一个名为T​的结构体类型。struct​关键字之后的大括号包含了类型字面值,这些类型字面值由若干个字段(field)聚合而成,每个字段都有自己的名称和类型,并且在一个结构体中,字段名称应该是唯一的。这一结构展示了强大而灵活的抽象能力:

package book
type Book struct {
    Title string              // 书名
    Pages int                 // 页数
    Indexes map[string]int    // 索引
}

在上述代码中,类型Book​及其各个字段都是导出标识符,意味着只要其它包导入了book​,就可以直接饮用类型名Book​,并通过Book​类型的变量访问Title​、Pages等字段:

var b book.Book
b.Title = "The Go Programming Language"
b.Pages = 800

如果某个结构体仅在包内使用,则可以将类型名的首字母小写,同样如果某个字段不想暴露给其它包,也可以将该字段名称的首字母小写。

定义空结构体类型

在一些场景下,可以定义空的结构体类型:

type Empty struct{} // Empty是一个不包含任何字段的空结构体类型

这样的类型作用是作为一个事件信息在goroutine之间进行通信,因为这样的类型实际不会占用内存空间。

var s Empty
println(unsafe.Sizeof(s)) // 输出:0

这种方式创建的以空结构体为元素的channel,是在Go中实现最小内存占用的goroutine间通信方式之一。

使用其它结构体作为自定义结构体中字段的类型

对于更加复杂的抽象场景,可以在结构体中定义结构体类型的字段:

type Person struct {
    Name string
    Phone string
    Addr string
}
type Book struct {
    Title string
    Author Person
    …
}

如果要访问Book​结构体字段Author​中的Phone字段,可以通过以下方式访问:

var book Book
println(book.Author.Phone)

此外,Go还提供了更简洁的方法来定义这样的结构体:

type Book struct {
    Title string
    Person
    …
}

这种方式定义的结构体字段称为嵌入字段或匿名字段,可以通过以下两种方式访问:

var book Book
println(book.Person.Phone) // 使用类型名作为字段名称
println(book.Phone)        // 直接访问嵌入字段中的成员

在Go的结构体定义中,不允许定义包含自身类型的字段:

type T struct {
    t T  // 编译器错误:invalid recursive type T
    …
}

递归定义也是非法的:

type T1 struct {
    t2 T2
}
type T2 struct {
    t1 T1
}

不过可以包含指向自身的指针、以自身类型为元素的切片,或者以自身类型作为value​的map

type T struct {
    t  *T           // 正确
    st []T          // 正确
    m  map[string]T // 正确
}     

结构体变量声明与初始化

与其他类型的变量一样,可以使用标准变量声明语法或段变量声明语句来声明一个结构体类型的变量:

type Book struct {
    …
}
var book Book
var book = Book{}
book := Book{}

结构体类型通常用于对现实世界复杂实体的抽象,这与简单的数值、字符串、数组、切片等类型不同。因此,结构体类型的变量通常需要赋予适当的初始值,才能具有合理的意义。

零值初始化

零值初始化是指使用结构体类型的默认值作为初始值,对于结构体类型,如果所有字段都处于各自的零值状态,则成这个结构体类型处于零值状态:

var book Book // book为零值的结构体变量

但在实际应用中,由于零值初始化,在使用结构体类型时无需为字段进行初始化,可以简化代码,提升开发者使用体验;对于一些场景,我们需要的零值并不是有意义的,这种情况则需要对其变量进行显式初始化后才能正确使用。最常用的方式就是使用复合字面值

使用复合字面值

对于结构体变量的初始化,最直接的方式是按顺序给每个字段赋值:

type Book struct {
    Title string              // 书名
    Pages int                 // 页数
    Indexes map[string]int    // 索引
}
var book = Book{"The Go Programming Language", 700, make(map[string]int)}

这种方式存在一些问题,例如当结构体类型定义中的字段顺序发生改变或字段增删时,需要手动调整结构体类型变量的初始化代码来匹配新的字段顺序;其次,对于字段较多的结构体,逐一赋值的方式容易出错且难以维护,开发过程中需要反复对照结构体定义进行赋值操作;必须为所有字段提供初始值,否则编译器报错:


type T struct {
    F1 int
    F2 string
    f3 int
    F4 int
    F5 int
}
var t = T{11, "hello", 13} // 编译器错误:too few values in struct literal of type T
var t = T{11, "hello", 13, 14, 15} // 正确

因此Go并不推荐按照字段顺序初始化结构体类型的方式,甚至在go vet​工具中内置了检查规则来警告这种初始化方式。Go中推荐使用field: value形式的复合字面值进行结构体变量初始化,这种方式可以减少结构体类型使用者与设计者之间的耦合度,并遵循Go的惯用法:

var t = T{
    F2: "hello",
    F1: 11,
    F4: 14,
}

这种方式非常灵活,字段的顺序可以任意出现,未出现在字面值中的字段将自动采用类型的零值,即使结构体采用类型零值时,也推荐使用复合字面值的形式:

t := T{}

很少使用new函数来创建结构体变量的实例:

tp := new(T)

不能使用从其他包导入的结构体中的未导出字段作为复合字面值中的字段,这会导致编译器错误,因为未导出字段是不可见的。

如果一个结构体包含未导出的字段,且这些字段的零值不可用,或某些字段需要复杂的初始化逻辑,则应使用特定的构造函数来创建和初始化结构体变量。

使用特定的构造函数

在Go标准库中常用构造函数来创建和初始化结构体变量,例如time.Timer结构体:

// $GOROOT/src/time/sleep.go
func NewTimer(d Duration) *Timer {
    c := make(chan Time, 1)
    t := &Timer{
        C: c,
        r: runtimeTimer{
            when: when(d),
            f:    sendTime,
            arg:  c,
        },
    }
    startTimer(&t.r)
    return t
}

NewTime​函数接受一个表示定时时间的参数d​,经过一系列负责的初始化步骤后返回一个可用的Timer类型指针实例。

这种通过专用构造函数结构一个表示定时时间的参数d​,经过一系列复杂的初始化步骤后返回一个可用的Timer类型指针实例。

通常这种初始化方式遵循以下模式:

func NewT(field1, field2, …) *T {
    …
}

NewT​是结构体类型T​的专用构造函数,其参数列表中的参数通常对应于T​定义中的导出字段,而返回值是一个指向T​类型的指针。T​的非导出字段在NewT​内部完成初始化,一些需要复杂初始化逻辑的字段也会在NewT​内部完成初始化。这样,只需要调用NewT​函数即可得到一个已经正确初始化的T类型指针类型变量。

0

评论区