0%

Go语言学习笔记2-容器

[TOC]

变量在一定程度上能满足函数及代码要求。如果编写一些复杂算法、结构和逻辑,就需要更复杂的类型来实现。这类复杂类型一般情况下具有各种形式的存储和处理数据的功能,将它们称为“容器(container)”。

数组

数组(Array) 是一段固定长度的连续内存区域
在Go语言中,数组从声明时就确定,使用时可以修改数组成员,但是数组大小不可变化。
Go 的数组和切片都是从C语言延续过来的设计。

声明数组

1
var 数组变量名 [元素数量]T

其中:

  • 数组变量名:数组声明及使用时的变量名
  • 元素数量:数组的元素数量。可以是一个表达式,但最终通过编译期计算的结果必须是整形数值
  • T 可以是任意基本类型,包括 T 为数组本身。但类型为数组本身时,可以实现多维数组
1
2
3
4
5
var team [3] string
team[0] = "JD"
team[1] = "TaoBao"
team[2] = "Wechat"
fmt.Println(team) // [JD TaoBao Wechat]

数组初始化

数组可以在声明时使用初始化列表进行元素设置,参考下面的代码:

1
var team = [3]string{"JD", "TaoBao", "Wechat"}

这种方式编写时,需要保证大括号后面的元素数量与数组的大小一致

但一般情况下,这个过程可以交给编译器,让编译器在编译时,根据元素个数确定数组大小

1
var team = [...]string{"JD" , "TaoBao" , "Wechat"}

...表示让编译器确定数组大小。上面例子中,编译器会自动为这个数组设置元素个数为 3

遍历数组

遍历数组也和遍历切片类似

1
2
3
4
5
6
7
8
9
var team = [...]string{"JD", "TaoBao", "Wechat"}
for k, v := range team {
fmt.Println(k, v)
/**
0 JD
1 TaoBao
2 Wechat
*/
}

切片

切片是一个拥有相同类型元素的可变长度的序列,Go语言切片的内部包含地址大小容量,切片一般用于快速地操作一块数据集合。如果将数据集合比作切糕的话,切片就是你要的“那一块”。切的过程包含从哪里开始(这个就是切片的地址)及切多大(这个就是切片的大小)。容量可以理解为装切片的口袋大小

生成切片(从数组或切片生成新的切片)

切片默认指向一段连续内存区域,可以是数组,也可以是切片本身

从连续内存区域生成切片是常见的操作

1
slice[开始位置:结束位置]
  • slice表示目标切片对象
  • 开始位置对应目标切片对象的索引
  • 结束位置对应目标切片的结束索引

从数组生成切片:

1
2
var arr = [3]int{1,2,3}
fmt.Println(arr , arr[1:2]) // [1 2 3] [2]

[2]就是arr[1:2]切片操作的结果

从数组或切片生成新的切片拥有如下特性:

  • 取出的元素数量为:结束位置-开始位置。
  • 取出元素不包含结束位置对应的索引,切片最后一个元素使用 slice[len(slice)] 获取。
  • 当缺省开始位置时,表示从连续区域开头到结束位置。
  • 当缺省结束位置时,表示从开始位置到整个连续区域末尾。
  • 两者同时缺省时,与数组本身等效。
  • 两者同时为0时,等效于空切片,一般用于切片复位。

根据索引位置取切片 slice 元素值时,取值范围是(0~len(slice)-1),超界会报运行时错误

生成切片时,结束位置可以填写 len(slice) 但不会报错。

具体,下面使用一些实例演示:

指定范围生成切片
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package main

import "fmt"

func main() {
// 声明一个array
build := [30]int{}
// 赋值
for i:=0;i<30;i++ {
build[i] = i +1
}
// 区间取值
fmt.Println(build[10:15]) // [11 12 13 14 15]
// 中间部分到结尾所有元素
fmt.Println(build[20:]) // [21 22 23 24 25 26 27 28 29 30]
// 开始到中间所有元素
fmt.Println(build[:8]) // [1 2 3 4 5 6 7 8]
}

切片在指针的基础上增加了大小,约束了切片对应的内存区域,切片使用中无法对切片内部的地址和大小进行手动调整,因此切片比指针更安全、强大。

表示原有的切片

生成切片的格式中,当开始和结束都范围都被忽略,则生成的切片将表示和原切片一致的切片,并且生成的切片与原切片在数据内容上是一致的

1
2
3
4
5
6
7
8
package main

import "fmt"

func main() {
originSlice := []int{1, 2, 3}
fmt.Println(originSlice[:]) // [1 2 3]
}
重置切片,清空所有元素
1
2
3
4
5
6
7
8
package main

import "fmt"

func main() {
originSlice := []int{1, 2, 3}
fmt.Println(originSlice[0:0]) // []
}

声明新的切片

除了可以从原有的数组或者切片中生成切片,你也可以声明一个新的切片
每一种类型都可以拥有其切片类型,表示多个类型元素的连续集合。因此切片类型也可以被声明。切片类型声明格式如下:

1
var name []T
  • Name 表示切片的变量名
  • T 表示切片对应的元素类型
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package main

import "fmt"

func main() {
// 声明字符串切片
var strSlice []string
// 声明整形切片
var intSlice []int
// 声明一个空切片
var emptySlice = []int{}

fmt.Println(strSlice, intSlice, emptySlice) // [] [] []

// 输出3个切片大小
fmt.Println(len(strSlice), len(intSlice), len(emptySlice)) // 0 0 0
// 切片判定空的结果
fmt.Println(strSlice == nil)
fmt.Println(intSlice == nil)
fmt.Println(emptySlice == nil) // true true false

}

切片是动态结构,只能与nil判定相等,不能互相判等时。

声明新的切片后,可以使用 append()函数来添加元素。

使用make()函数构造切片

如果需要动态的创建一个切片,可以使用make()内建函数

1
make([]T,size,cap)
  • T : 切片的元素类型
  • size : 就是为这个类型分配多少个元素
  • cap : 预分配元素数量,这个值设定不影响size,只是能提前分配空间,降低多次分配空间造成的性能问题。

示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
package main

import "fmt"

func main() {
a := make([]int, 2)
b := make([]int, 2, 10)

fmt.Println(a, b) // [0 0] [0 0]
fmt.Println(len(a), len(b)) // 2 2
fmt.Println(cap(a), cap(b)) // 2 10
}

a 和 b 均是预分配 2 个元素的切片,只是 b 的内部存储空间已经分配了 10 个,但实际使用了 2 个元素。
容量不会影响当前的元素个数,因此 a 和 b 取 len 都是 2。

使用 make() 函数生成的切片一定发生了内存分配操作。但给定开始与结束位置(包括切片复位)的切片只是将新的切片结构指向已经分配好的内存区域,设定开始与结束位置,不会发生内存分配操作。

切片不一定必须经过 make() 函数才能使用。生成切片、声明后使用 append() 函数均可以正常使用切片

使用append()为切片添加元素

  • Go语言的内建函数 append() 可以为切片动态添加元素
  • 每个切片会指向一片内存空间,这片空间能容纳一定数量的元素
  • 当空间不能容纳足够多的元素时,切片就会进行“扩容”。“扩容”操作往往发生在 append() 函数调用时
  • 切片在扩容时,容量的扩展规律按容量的 2 倍数扩充,例如 1、2、4、8、16……
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package main

import "fmt"

func main() {
var numbers []int
for i := 0; i < 10; i++ {
numbers = append(numbers, i)
fmt.Printf("len: %d,cap: %d,pointer: %p\n", len(numbers), cap(numbers), numbers)
// len : 1,cap:1,pointer:0xc000014080
// len : 2,cap:2,pointer:0xc0000140b0
// len : 3,cap:4,pointer:0xc0000160c0
// len : 4,cap:4,pointer:0xc0000160c0
// len : 5,cap:8,pointer:0xc00001a080
// len : 6,cap:8,pointer:0xc00001a080
// len : 7,cap:8,pointer:0xc00001a080
// len : 8,cap:8,pointer:0xc00001a080
// len : 9,cap:16,pointer:0xc00008a000
// len : 10,cap:16,pointer:0xc00008a000
}
}

append() 函数除了添加一个元素外,也可以一次性添加很多元素。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package main

import "fmt"

func main() {
var company []string
// 添加一个元素
company = append(company , "JD")
// 添加多个元素
company = append(company , "taobao" , "t.tt")
// 添加切片
newCompany := []string{"mi" , "坚果"}
company = append(company , newCompany...)

fmt.Println(company) // [JD taobao t.tt mi 坚果]
}

第 13 行,在newCompany后面加上了...,表示将 newCompany 整个添加到 car 的后面。

切片复制

使用内建的 copy() 函数,可以迅速地将一个切片的数据复制到另外一个切片空间中,copy() 函数的使用格式如下:

1
copy(originSlice,srcSlice []T)int
  • originSlice 为数据来源切片
  • srcSlice为复制的目标。目标切片必须分配过空间且足够承载复制的元素个数,来源和目标的类型一致,copy 的返回值表示实际发生复制的元素个数。

删除切片元素

Go语言并没有对删除切片元素提供专用的语法或者接口,需要使用切片本身的特性来删除元素

1
2
3
4
5
6
7
8
seq := []string{"a", "b", "c", "d", "e"}
// 指定删除位置
index := 2
// 查看删除位置之前的元素和之后的元素
fmt.Println(seq[:index], seq[index+1:])// [a b] [d e]
// 将删除点前后的元素连接起来
seq = append(seq[:index], seq[index+1:]...)
fmt.Println(seq) // [a b d e]

代码的删除过程可以使用下图来描述。

Go 语言中切片删除元素的本质是:以被删除元素为分界点,将前后两个部分的内存重新连接起来。

连续容器的元素删除无论是在任何语言中,都要将删除点前后的元素移动到新的位置。随着元素的增加,这个过程将会变得极为耗时。因此,当业务需要大量、频繁地从一个切片中删除元素时,如果对性能要求较高,就需要反思是否需要更换其他的容器(如双链表等能快速从删除点删除元素)。

map(Go语言映射)

在业务和算法中需要使用任意类型的关联关系时,就需要使用到映射,如学号和学生的对应、名字与档案的对应等。

Go语言提供的映射关系容器为 map,map使用散列表(hash)实现。

添加数据到map

Go语言中 map 的定义是这样的:

1
map[keyType]vluesType
  • keyTyp 表示键类型
  • valueType 表示键对应值类型

一个map 里,符合keyType和valueType 的映射总是成对出现

1
2
3
4
5
6
7
8
9
10
11
12
13
package main

import "fmt"

func main() {
scene := make(map[string]int) // 这里的map是内部实现的类型
scene["route"] = 666
fmt.Println(scene["route"]) // 666

v := scene["route2"]
fmt.Println(v) // 0
}

尝试查找一个不存在的键,那么返回的将是 ValueType 的默认值

某些情况下,需要明确知道查询中某个键是否在 map 中存在,可以使用一种特殊的写法来实现

1
v, ok := scene["route"]

在默认获取键值的基础上,多取了一个变量 ok,可以判断键 route 是否存在于 map 中。

map 还可以在声明时填充内容,例如:

1
2
3
4
5
6
7
m := map[string]string{
"W" : "forward",
"A": "left",
"D": "right",
"S": "backward",
}
fmt.Println(m) // map[W:forward A:left D:right S:backward]

遍历map

遍历key 和 value

map 的遍历过程使用 for range循环完成,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
family := map[string]string{
"dad": "zhimma dad",
"mom": "zhimma mom",
"daughter": "zhimma",
}
for key,value := range family{
fmt.Println("hello" , key ,value)
/**
hello dad zhimma dad
hello mom zhimma mom
hello daughter zhimma
*/
}

只遍历value

遍历时,可以同时获得键和值。如只遍历值,可以使用下面的形式:

1
for _,value := range  family{

将不需要的键改为匿名变量形式。

只遍历key

只遍历键时,使用下面的形式:

1
for key := range  family{

无须将值改为匿名变量形式,忽略值即可

map 元素删除和清空

元素删除

使用delete()内建函数从map中删除一组键值对 ,delete()函数的格式如下

1
delete(map , 键)
  • map 为要删除的map实例
  • 键为要删除的 map 键值对中的键
1
2
3
4
5
6
7
8
9
10
11
12
13
numMap := make(map[string]int)

numMap["aroute"] = 66
numMap["brazil"] = 4
numMap["china"] = 960
delete(numMap , "brazil")
for k, v := range numMap {
fmt.Println(k , v)
/**
aroute 66
china 960
*/
}

清空map

有意思的是,Go语言中并没有为 map 提供任何清空所有元素的函数、方法。清空 map 的唯一办法就是重新 make 一个新的 map。不用担心垃圾回收的效率,Go 语言中的并行垃圾回收效率比写一个清空函数高效多了。

sync.Map(在并发环境中使用的map)

Go 语言中的 map 在并发情况下,只读是线程安全的,同时读写线程不安全。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 创建一个int到int的映射
n := make(map[int]int)

// 开启一段并发代码
go func() {
for {
// 不停地对map进行写入
n[1] = 1
}
}()

// 开启一段并发代码
go func() {
for {
_ = n[1]
}
}()
for {
}

运行代码会报错,输出如下:
fatal error: concurrent map read and map write

运行时输出提示:并发的 map 读写。也就是说使用了两个并发函数不断地对 map 进行读和写而发生了竞态问题。map 内部会对这种并发操作进行检查并提前发现

需要并发读写时,一般的做法是加锁,但这样性能并不高。Go 语言在 1.9 版本中提供了一种效率较高的并发安全的 sync.Mapsync.Map 和 map 不同,不是以语言原生形态提供,而是在 sync 包下的特殊结构。

sync.Map有以下特性:

  • 无须初始化,直接声明即可。
  • sync.Map 不能使用 map 的方式进行取值和设置等操作,而是使用 sync.Map 的方法进行调用。Store 表示存储,Load 表示获取,Delete 表示删除。
  • 使用 Range 配合一个回调函数进行遍历操作,通过回调函数返回内部遍历出来的值。Range 参数中的回调函数的返回值功能是:需要继续迭代遍历时,返回 true;终止迭代遍历时,返回 false。

并发安全的 sync.Map 演示代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var scenes sync.Map
// 将键值对保存到sync.Map
scenes.Store("greece", 97)
scenes.Store("london", 100)
scenes.Store("egypt", 200)
// 从sync.Map中根据键取值
fmt.Println(scenes.Load("london"))
// 根据键删除对应的键值对
scenes.Delete("london")
// 遍历所有sync.Map中的键值对
scenes.Range(func(k, v interface{}) bool {
fmt.Println("iterate:", k, v)
// iterate: greece 97
//i terate: egypt 200
return true
})

sync.Map 没有提供获取 map 数量的方法,替代方法是获取时遍历自行计算数量。sync.Map 为了保证并发安全有一些性能损失,因此在非并发情况下,使用 map 相比使用 sync.Map 会有更好的性能。

list(列表)

列表是一种非连续存储的容器,由多个节点组成,节点通过一些变量记录彼此之前的关系。列表有多种实现方法,如单链表、双链表等。

列表的原理可以这样理解:假设 A、B、C 三个人都有电话号码,如果 A 把号码告诉给 B,B 把号码告诉给 C,这个过程就建立了一个单链表结构,如下图所示:

如果在这个基础上,再从 C 开始将自己的号码给自己知道号码的人,这样就形成了双链表结构,如下图所示。

那么如果需要获得所有人的号码,只需要从 A 或者 C 开始,要求他们将自己的号码发出来,然后再通知下一个人如此循环。这个过程就是列表遍历。
如果 B 换号码了,他需要通知 A 和 C,将自己的号码移除。这个过程就是列表元素的删除操作,如下图所示

在Go语言中,将列表使用 container/list 包来实现,内部的实现原理是双链表。列表能够高效地进行任意位置的元素插入和删除操作。

初始化列表

list 的初始化有两种方法:New 和声明。两种方法的初始化效果都是一致的。

通过container/list 包的 New 方法初始化 list

1
变量名 := list.New()

通过声明初始化list

1
var 变量名 list.List

列表与切片和 map 不同的是,列表并没有具体元素类型的限制。因此,列表的元素可以是任意类型。这既带来便利,也会引来一些问题。给一个列表放入了非期望类型的值,在取出值后,将 interface{} 转换为期望类型时将会发生宕机。

在列表中插入元素

双链表支持从队列前方或后方插入元素,分别对应的方法是 PushFront PushBack

这两个方法都会返回一个 *list.Element 结构。如果在以后的使用中需要删除插入的元素,则只能通过 *list.Element 配合Remove()方法进行删除,这种方法可以让删除更加效率化,也是双链表特性之一

下面代码展示给list添加元素:

1
2
3
4
l := list.New()

l.PushBack("age")
l.PushFront("18+")

列表插入元素的方法如下表所示。

方 法 功 能
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 结构,这个结构记录着列表元素的值及和其他节点之间的关系等信息。从列表中删除元素时,需要用到这个结构进行快速删除。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
l := list.New()

// 尾部插入age
l.PushBack("age")
// 头部插入18+
l.PushFront("18+")

// 尾部添加后保存元素句柄
element := l.PushBack("fist")
// 在fist之后添加high
l.InsertAfter("high", element)
// 在fist之前添加noon
l.InsertBefore("noon", element)
// 使用
l.Remove(element)

下表中展示了每次操作后列表的实际元素情况。

操作内容 列表元素
l.PushBack(“age”) age
l.PushFront(“18+”) 18+, age
element := l.PushBack(“fist”) 18+, age, fist
l.InsertAfter(“high”, element) 18+, age, fist, high
l.InsertBefore(“noon”, element) 18+, age, noon, fist, high
l.Remove(element) 18+, age, noon, high

遍历列表

遍历双链表需要配合 Front()函数获取头元素,遍历时只要元素不为空就可以继续进行。每一次遍历调用元素的 Next,如代码中第 6 行所示

1
2
3
4
5
6
7
8
9
10
l := list.New()
// 尾部添加
l.PushBack("canon")
// 头部添加
l.PushFront(67)
for i := l.Front(); i != nil; i = i.Next() {
fmt.Println(i.Value)
// 67
// canon
}

使用 for 语句进行遍历,其中 i:=l.Front() 表示初始赋值,只会在一开始执行一次;每次循环会进行一次 i!=nil 语句判断,如果返回 false,表示退出循环,反之则会执行 i=i.Next()。