go语言数据结构详解
go语言数组
数组是一个由固定长度的特定类型元素组成的序列,一个数组可以由零个或多个元素组成。因为数组的长度是固定的,所以在go语言中很少直接使用数组。
在数组的定义中,如果在数组长度的位置出现“…”省略号,则表示数组的长度是根据初始化值的个数来计算:
func main() {
a := [...]int{1, 2}
fmt.Println(a)
}
数组的长度是数组类型的一个组成部分,因此 [3]int 和 [4]int 是两种不同的数组类型,数组的长度必须是常量表达式,因为数组的长度需要在编译阶段确定。
go语言多维数组
- 声明二维数组:
var array [4][2]int
// 使用数组字面量来声明并初始化一个二维整型数组
array = [4][2]int{{10, 11}, {20, 21}, {30, 31}, {40, 41}}
// 声明并初始化数组中索引为 1 和 3 的元素
array = [4][2]int{1: {20, 21}, 3: {40, 41}}
// 声明并初始化数组中指定的元素
array = [4][2]int{1: {0: 20}, 3: {1: 41}}
go语言切片
- 从数组或切片生成新的切片拥有如下特性:
- 取出的元素数量为:结束位置-开始位置
- 取出元素不包含结束位置对应的索引,切片最后一个元素使用slice[len(slice)-1]获取
- 当缺省开始位置时,表示从连续区域开头到结束位置
- 当缺省结束位置时,表示从开始位置到整个连续区域末尾
- 两者同时缺省时,与切片本身等效
- 两者同时为0时,等效于空切片,一般用于切片复位
案例:切片复位:
func main() {
a := []int{11, 22, 33}
fmt.Printf("%p\n", a)
// 切片复位后,内存地址不变,长度变为0,容量不变
a = a[:0]
fmt.Printf("%p\n", a)
a = append(a, 55, 66, 77)
fmt.Printf("%p\n", a)
a = append(a, 55, 66, 77)
fmt.Printf("%p\n", a)
}
输出结果:
0xc000114000
0xc000114000
0xc000114000
0xc00011c000
- 取切片地址的三种写法及区别
func main() {
a := []int{11, 22, 33}
fmt.Printf("%p, %p, %p\n", a, &a[0], &a)
b := a[:]
fmt.Printf("%p, %p, %p\n", b, &b[0], &b)
}
输出结果:
0xc0000ae078, 0xc0000ae078, 0xc000096060
0xc0000ae078, 0xc0000ae078, 0xc000096090
可以看到,前面四个相同,输出的是底层数组的地址,后面两个各不相同,输出的是各自切片的内存地址。
3. 切片取开始位置与结束位置不会发生内存分配,只是引用了底层数组的一段内存
func main() {
a := []int{11, 22, 33}
fmt.Printf("%p, %p\n", a, &a[1])
b := a[1:2]
fmt.Printf("%p, %p\n", b, &b[0])
}
输出结果:
0xc00000c180, 0xc00000c188
0xc00000c188, 0xc00000c188
可以看到,a中第一个元素的地址其实就是b中第0个元素的地址。
温馨提示
使用make函数生成的切片,一定发生了内存分配操作,但给定开始与结束位置(包括切片复位)的切片只是将新的切片结构指向已经分配好的内存区域,设定开始与结束位置,不会发生内存分配操作。
使用append为切片添加元素
- 除了可以使用append函数在切片尾部追加元素外,还可以在切片头部追加元素
func main() {
a := []int{11, 22, 33}
fmt.Printf("%p\n", a)
a = append([]int{1, 2}, a...)
fmt.Printf("%p\n", a)
fmt.Println(a)
}
输出:
0xc0000ae078
0xc0000c0060
[1 2 11 22 33]
在切片头部添加元素一般会导致内存的重新分配,而且会导致已有元素全部被复制一次,因此从切片头部添加元素的性能要比切片尾部追加元素的性能差很多。
因为append函数返回新切片的特性,所以切片也支持链式操作,我们可以将多个append操作组合起来,实现在切片中间插入元素:
案例1:在第2个位置插入一个元素:
func main() {
// 在第二个位置插入一个元素
a := []int{11, 22, 33, 44}
a = append(a[:2], append([]int{1}, a[2:]...)...)
fmt.Println(a)
}
输出结果:[11 22 1 33 44]
案例2:在第1个位置插入一个切片
func main() {
// 在第1个位置插入一个切片
a := []int{11, 22, 33, 44}
a = append(a[:1], append([]int{1, 2, 3}, a[1:]...)...)
fmt.Println(a)
}
输出结果:[11 1 2 3 22 33 44]
go语言切片复制
Go语言的内置函数copy() 可以将一个数组切片复制到另一个数组切片中,如果加入的两个数组切片不一样大,就会按照其中较小的那个数组切片的元素个数进行复制。
func main() {
slice1 := []int{11, 22}
slice2 := []int{1, 2, 3, 4}
copy(slice1, slice2) // 只会复制slice2的前2个元素到slice1
fmt.Println(slice1, slice2)
copy(slice2, slice1) // 只会复制slice1的两个元素到slice2的前两个位置
fmt.Println(slice1, slice2)
}
【示例】通过代码演示对切片的引用和复制操作后对切片元素的影响。
func main() {
srcData := []int{11, 22, 33, 44, 55}
refData := srcData
copData := make([]int, len(srcData))
copy(copData, srcData)
srcData[1] = 100
fmt.Println(srcData, refData, copData)
copy(copData, srcData[1:3])
fmt.Println(srcData, refData, copData)
}
输出:
[11 100 33 44 55] [11 100 33 44 55] [11 22 33 44 55]
[11 100 33 44 55] [11 100 33 44 55] [100 33 33 44 55]
go语言从切片中删除元素
Go语言并没有对删除切片元素提供专用的语法或者接口,需要使用切片本身的特性来删除元素,根据要删除元素的位置有三种情况,分别是从开头位置删除、从中间位置删除和从尾部删除,其中删除切片尾部的元素速度最快。
从开头位置删除
删除开头的元素可以直接移动数据指针:
a = []int{1, 2, 3}
a = a[1:] // 删除开头1个元素
a = a[N:] // 删除开头N个元素
也可以不移动数据指针,但是将后面的数据向开头移动,可以用 append 原地完成(所谓原地完成是指在原有的切片数据对应的内存区间内完成,不会导致内存空间结构的变化):
a = []int{1, 2, 3}
a = append(a[:0], a[1:]...) // 删除开头1个元素
a = append(a[:0], a[N:]...) // 删除开头N个元素
还可以使用copy函数来删除开头元素:
a = []int{1, 2, 3}
a = a[:copy(a, a[1:])] // 删除开头1个元素
a = a[:copy(a, a[N:])] // 删除开头N个元素
从中间位置删除
对于删除中间的元素,需要对剩余的元素进行一次整体挪动,同样可以用 append 或 copy 原地完成:
func main() {
s := []int{11, 22, 33, 44, 55}
s = append(s[:1], s[2:]...) // 删除中间第一个元素
s = s[:1+copy(s[1:], s[2:])] // 删除中间第一个元素
fmt.Println(s)
}
从尾部删除
func main() {
s := []int{11, 22, 33, 44, 55}
s = s[:len(s)-1] // 从尾部删除一个元素
fmt.Println(s)
}
从切片中使用开头和结尾取切片,然后追加元素是否会修改原切片经典案例:
案例1:会修改原切片(前提是没有发生扩容)
func main() {
seq := []string{"a", "b", "c", "d", "e"}
fmt.Println(cap(seq))
index := 3
fmt.Println(append(seq[:index], "ma", "ni"))
fmt.Println(cap(seq))
fmt.Println(seq)
}
输出结果:
5
[a b c ma]
5
[a b c ma e]
案例2:不会修改原切片(发生了扩容)
func main() {
seq := []string{"a", "b", "c", "d", "e"}
fmt.Println(cap(seq))
fmt.Println(append(seq[4:], "ma"))
fmt.Println(cap(seq))
fmt.Println(seq)
}
输出结果:
5
[e ma]
5
[a b c d e]
go语言中删除切片元素的本质是:以被删除元素为分界点,将前后两个部分的内存重新链接起来
提示:
连续容器的元素删除无论在任何语言中,都要将删除点前后的元素移动到新的位置,随着元素的增加,这个过程将会变得极为耗时,因此,当业务需要大量、频繁地从一个切片中删除元素时,如果对性能要求较高的话,就需要考虑更换其他的容器了(如双链表等能快速从删除点删除元素)。
go语言range关键字
func main() {
a := []int{11, 22, 33, 44}
// 注意:v返回的是对应元素的一份副本,而且v的内存在循环时不会发生变化
for i, v := range a {
fmt.Printf("i: %d, v: %d, p: %p, p: %p\n", i, v, &v, &a[i])
}
}
当迭代切片时,关键字range会返回两个值,第一个值是当前迭代的索引位置,第二个值是该位置对应元素值的一份副本,
需要强调的是,range 返回的是每个元素的副本,而不是直接返回对该元素的引用,如下所示。
组合切片的切片:
func main() {
a := [][]int{{11}, {1, 2}}
a[0] = append(a[0], 22)
fmt.Println(a)
}
go语言遍历map
注意:遍历输出元素的顺序与填充顺序无关,不能期望map在遍历时返回某种期望顺序的结果
map元素的删除和清空
Go语言提供了一个内置函数 delete(),用于删除容器内的元素,下面我们简单介绍一下如何用 delete() 函数删除 map 内的元素。
使用delete函数从map中删除键值对
func main() {
m1 := map[string]any{"name": "张三", "age": 18, "gender": true}
delete(m1, "name")
fmt.Println(m1)
}
清空map中的所有元素:
有意思的是,Go语言中并没有为 map 提供任何清空所有元素的函数、方法,清空 map 的唯一办法就是重新 make 一个新的 map,不用担心垃圾回收的效率,Go语言中的并行垃圾回收效率比写一个清空函数要高效的多。
go语言sync.Map
go语言中的map,在并发的环境下,只读是安全的,同时读写是线程不安全的。
下面来看下并发情况下读写 map 时会出现的问题,代码如下:
func main() {
m1 := make(map[string]any)
go func() {
for {
m1["1"] = 1
}
}()
go func() {
for {
_ = m1["1"]
}
}()
select {
}
}
执行会报错:fatal error: concurrent map read and map write
需要并发读写时,一般的做法是加锁,但这样性能并不高,Go语言在 1.9 版本中提供了一种效率较高的并发安全的 sync.Map,sync.Map 和 map 不同,不是以语言原生形态提供,而是在 sync 包下的特殊结构。
sync.Map有如下特性:
- 无须初始化,直接声明即可
- sync.Map 不能使用 map 的方式进行取值和设置等操作,而是使用 sync.Map 的方法进行调用,Store 表示存储,Load 表示获取,Delete 表示删除。
- 使用 Range 配合一个回调函数进行遍历操作,通过回调函数返回内部遍历出来的值,Range 参数中回调函数的返回值在需要继续迭代遍历时,返回 true,终止迭代遍历时,返回 false。
并发安全的 sync.Map 演示代码如下:
func main() {
var m sync.Map
m.Store("name", "张三")
m.Store("age", 18)
var k int // 计算map中key,value的数量
m.Range(func(key, value any) bool {
fmt.Println(key, value)
k++
return true
})
fmt.Println(k)
}
sync.Map 没有提供获取 map 数量的方法,替代方法是在获取 sync.Map 时遍历自行计算数量,sync.Map 为了保证并发安全有一些性能损失,因此在非并发情况下,使用 map 相比使用 sync.Map 会有更好的性能。
go语言list列表
列表是一种非连续的存储容器,由多个节点组成,节点通过一些变量记录彼此之间的关系,列表有多种实现方法,如单链表、双链表等。
在Go语言中,列表使用 container/list包来实现,内部的实现原理是双链表,列表能够高效地进行任意位置的元素插入和删除操作。
初始化列表
list 的初始化有两种方法:分别是使用 New() 函数和 var 关键字声明,两种方法的初始化效果都是一致的。
- 通过 container/list 包的 New() 函数初始化 list
变量名 := list.New()
- 通过 var 关键字声明初始化 list
var 变量名 list.List
列表与切片和 map 不同的是,列表并没有具体元素类型的限制,因此,列表的元素可以是任意类型,这既带来了便利,也引来一些问题,例如给列表中放入了一个 interface{} 类型的值,取出值后,如果要将 interface{} 转换为其他类型将会发生宕机。
在列表中插入元素
双链表支持从队列前方或后方插入元素,分别对应的方法是 PushFront 和 PushBack。
提示:
这两个方法都会返回一个 list.Element 结构,如果在以后的使用中需要删除插入的元素,则只能通过 list.Element 配合 Remove() 方法进行删除,这种方法可以让删除更加效率化,同时也是双链表特性之一。
向list中添加元素:
func main() {
li := list.New()
li.PushBack("first")
li.PushFront(67)
fmt.Println(li.Front().Value)
}
列表插入元素的方法如下图所示:
方 法 | 功 能 |
---|---|
InsertAfter(v interface {}, mark Element) Element | 在 mark 点之后插入元素,mark 点由其他插入函数提供 |
InsertBefore(v interface {}, mark Element) Element | 在 mark 点之前插入元素,mark 点由其他插入函数提供 |
PushBackList(other *List) | 添加 other 列表元素到尾部 |
PushFrontList(other *List) | 添加 other 列表元素到头部 |
从列表中删除元素
列表插入函数的返回值会提供一个 *list.Element 结构,这个结构记录着列表元素的值以及与其他节点之间的关系等信息,从列表中删除元素时,需要用到这个结构进行快速删除。
列表操作元素:
func main() {
l := list.New()
l.PushBack("last")
e := l.PushFront("first")
e2 := l.InsertAfter("2", e)
l.InsertBefore("3", e2)
l.Remove(e2)
fmt.Println(l.Front().Next().Next().Value)
}
列表顺序:first---3---last, 2已经被移除了。
遍历列表,访问列表的每一个元素
遍历双链表需要配合 Front() 函数获取头元素,遍历时只要元素不为空就可以继续进行,每一次遍历都会调用元素的 Next() 函数,代码如下所示。
func main() {
l := list.New()
l.PushBack("last")
e := l.PushFront("first")
e2 := l.InsertAfter("2", e)
l.InsertBefore("3", e2)
head := l.Front()
for head != nil {
fmt.Println(head.Value)
head = head.Next()
}
}
顺序:first---3---2---last
go语言nil:空值/零值
在go语言中,布尔类型的零值为false,数值类型的零值为0,字符串类型的零值为空字符串,而指针、切片、映射、通道、函数、接口类型的零值为nil。
nil 是Go语言中一个预定义好的标识符,有过其他编程语言开发经验的开发者也许会把 nil 看作其他语言中的 null(NULL),其实这并不是完全正确的,因为Go语言中的 nil 和其他语言中的 null 有很多不同点。
下面通过几个方面来介绍一下Go语言中 nil。
nil标识符是不能比较的
fmt.Println(nil == nil)
报错:invalid operation: nil == nil (operator == not defined on untyped nil)
可以看出,==对于nil来说是一种未定义的操作。
nil不是关键字或保留字
nil 并不是Go语言的关键字或者保留字,也就是说我们可以定义一个名称为 nil 的变量,比如下面这样:
func main() {
nil := 123
fmt.Println(nil)
}
虽然上面的声明语句可以通过编译,但是并不提倡这么做。
不同类型的nil是不能比较的
func main() {
var m map[string]any
var p *int
fmt.Println(m == p)
}
两个相同类型的nil值也可能无法比较
在Go语言中 map、slice 和 function 类型的 nil 值不能比较,比较两个无法比较类型的值是非法的,下面的语句无法编译。
func main() {
var s1 []int
var s2 []int
fmt.Println(s1 == s2)
}
nil 是 map、slice、pointer、channel、func、interface 的零值
零值是go语言中变量在声明之后但是未初始化被赋予该类型的一个默认值。
不同类型的nil值占用的内存大小可能是不一样的
一个类型的所有的值的内存布局都是一样的,nil 也不例外,nil 的大小与同类型中的非 nil 类型的大小是一样的。但是不同类型的 nil 值的大小可能不同。
func main() {
var p *struct{}
fmt.Println(unsafe.Sizeof(p)) // 8
var s []int
fmt.Println(unsafe.Sizeof(s)) // 24
var f func()
fmt.Println(unsafe.Sizeof(f)) // 8
var ch chan string
fmt.Println(unsafe.Sizeof(ch)) // 8
var m map[string]any
fmt.Println(unsafe.Sizeof(m)) // 8
var i interface{}
fmt.Println(unsafe.Sizeof(i)) // 16
}
具体的大小取决于编译器和架构,上面打印的结果是在 64 位架构和标准编译器下完成的,对应 32 位的架构的,打印的大小将减半。