Go语言复合数据类型
复合数据类型是可以包含大量条目的数据结构,例如数组、切片和映射等。
Go语言的复合数据类型吸收了很多 Python的优点,相对而言没有C 语言那么灵活、复杂。
因为引入了切片这种概念使得 GO
语言的代码里并不容易见到数组, 切片是构建在数组之上并且提供更强大的能力和便捷的 种数据类型 学好切片有 于在编程过程中灵活处理数据。Go 语言的3种复合数据类型可以让开发者管理集合数据,这3种数据类型也是Go 语言核心的一部分,在标准库里被广泛使用 。掌握这些数据结构后,用 Go 语言编写程序会变得快速、有趣且十分灵活。
数组
了解数据结构,一般会从数组开始,因为数组是切片和映射的基础数据结构。理解了数组的工作原理,有助于理解切片和映射提供的优雅、 强大的功能。
- 数组元素都是相同类型的任意原始类型,例如int、string或者自定义类型
- 数组长度是固定的,数组长度必须是一个非负整数的常量(或常量表达式),编译时需要知道数组长度以便分配内存,数组长度最大为 2GB
- 数组长度也是数组类型的一部分,所以[20]int 和[100]int 不是同一种类型。
//声明一个包含5个元素的整型数组
var array [5]int
Go 语言在声明变量时,都是使用相应类型的零值来初始化变量的,数组也一样。
例如上面的整型数组里,每个元素都被初始化为0
//声明一个包含5个元素的整型数组
//用具体值初始化每个元素
array := [5]int{l0, 20, 30, 40, 50}
//声明一个整型数量
//用具体值初始化每个元素
//容量由初始化值的数量决定
array := [...]int{10, 20, 30, 40, 50}
//声明一个有5个元素的数组
//用具体值初始化索引为1和2的元素
//其余元素保持零值
array : = [5]int{1: 10, 2: 20}
数组是效率很高的数据结构,因为数组在内存分配中是连续的,要访问数组里某个单独元素,使用[]运算符即可,如下面代码所示:
array := [5]int{l0, 20, 30, 40, 50}
//修改索引为2的元素的值
array[2] = 35
数组的值也可以是指针
array := [5]*int{0: new(int), 1:new(int)}
*array[0] = 10
*array[1] = 20
同样类型的数组可以赋给另一个数组
var array1 [5]string
array2 := [5]string{"red", "Blue", "green", "yellow", "pink"}
array1 = array2
//不同长度的数组不是一种类型,不可以互相赋值
array3 := [6]string
array3 = array2
与之前的参数传递一样,如果复制数组指针,只会复制指针的值,而不会复制指针所指向的值, 如下面代码所示:
var array1 [3]*string
array2 := [3]*string{new(string), new(string), new(string)}
*array2[0] = "red"
*array2[1] = "blue"
*array2[2] = "green"
array1 = array2
在上面的代码中,复制操作之后,两个数组指向同一组字符串。
多维数组
//声明一个二维数组
var array [4][2]int
//声明并初始化
array := [4][2]int{{10, 11}, {20, 21}, {30, 31}, {40, 41}}
//声明并初始化
array := [4][2]int{1: {20, 21}, 3: {40,41}}
//声明并初始化
array := [4][2]int{1: {0, 20}, 3: {1: 41}}
//访问二维数组
var array [2][2]int
array[0][0] = 10
array[0][1] = 20
array[1][0] = 30
array[1][1] = 40
var array1 [2][2]int
var array2 [2][2]int
array2[0][0] = 10
array2[0][1] = 20
array2[1][0] = 30
array2[1][1] = 40
//将array2的值赋值给array1里
array1 = array2
//将array1的索引为1的维度复制到一个同类型的新数组里
var array3 [2]int = array1[1]
//将外层数组索引为1、内层数组索引为0的整型值复制到新的整型变量里
var value int = array1[1][0]
将数组传递给函数
前面讲过,在 Go 语言中数组是一个值类型(value type) ,所有的值类型变量在赋值和作为参数传递时都将产生一次复制动作。 如果直接将数组作为函数的参数, 在函数调用时数组会复制一份作为函数参数。因此,在函数体中无法修改传入的数组的内容,因为函数内操作的只是所传入数组的一个副本。从内存和性能角度看,在函数间传递数组是一个开销很大的操作。
为了体验这个操作到底有多消耗性能,我们来创建一个包含 100 万个int 类型元素的数组。在64位架构上,这将需要100 万字节,即 8M 的内存。如果声明了这种大小的数组,并将其传递给函数,可以想象是个非常“重”的操作
//声明一个需要8MB的数组
var array [1e6]int
foo(array)
func foo(array [1e6]int){
...
}
每次函数 foo()被调用时,必须在栈上分配 8M 的内存。 之后,整个数组的值(大小8M )被复制到刚分配的内存里。
虽然 Go 语言会处理这个复制操作,不过其实有一种更好且更高效的方法来处理这个操作,那就是只传入指向数组的指针,这样只需在栈上分配8字节的内存给指针即可,如下面代码所示:
//分配一个需要8MB内存的数组
var array [1e6]int
foo(&array)
func foo(array *[1e6]int){
...
}
这个操作会更有效地利用内存,性能也更好。不过要意识到,因为现在传递的是指针,所以如果改变指针指向的值,会改变共享内存中的值。为了解决这个问题,就需要使用切片来更好地处理这类共享问题了。
切片
//创建一个字符串切片
//其长度和容量都是5个元素
slice := make([]string, 5)
//长度为3,容量为5
slice := make([]int, 3, 5)
分别指定长度和容量时,创建出来的切片的底层数组长度就是创建时指定的容量,但是初始化后并不能访问所有的数组元素。上面代码中的切片可以访问 3个元素,而底层数组拥有 5个元素,因此剩余的 2个元素可以在后期操作中合并到切片 ,然后才可以通过切片访问这些元素
基于上面这个切片创建新的切片,新切片会和原有切片共享底层数组,也能通过后期操作来访问多余容量的元素。不过不允许创建容量小于底层数组长度的切片,如下面代码所示:
slice := make([]int, 5, 3)
Compiler error
len large than cap in make([]int)
另一种常用的创建切片的方法是使用切片字面量,如下面代码所示:
//长度和容量都是5
slice := []string{"red", "blue", "green", "yellow", "pink"}
//长度和容量都是3
slice := []int{10, 20, 30}
这种方法和创建数组类似,只是不需要指定[]运算符里的值,初始的长度和容量会基于初始化时提供的元素的个数确定。
当使用切片字面量时, 可以设置初始长度和容量,下面代码中的语法展示了如何创建长度和容量都是 100个元素的切片:
//使用空字符串初始化第 100 个元素
slice := []string{99: ""}
nil和空切片
有时程序可能需要声一个值为 nil 的切片(也称 nil 切片或空切片)。 只要在声明时不做任何初始化,就会创建一个nil切片,如下面代码所示:
var slice []int
在Go语言里,nil切片是很常见的创建切片的方法。在需要描述一个目前暂时不存在的切片时, nil 切片十分好用。例如,函数要求返回一个切片但是发生异常的时候,利用初始化,通过声明一个切片可以创建一个 nil 切片:
//使用make创建空的整型切片
slice := make([]int, 0)
//使用切片字面量创建空的整型切片
slice := []int{}
nil 切片在底层数组中包含0个元素, 也没有分配任何存储空间。
此外, nil 切片还可以用来表示空集合。例如,数据库查询返回0个查询结果时。nil切片和普通切片一样,调用内置函数 append、len、cap 的效果都是一样的。
切片的使用
改
slice := []int{10, 20, 30, 40, 50}
slice[1] = 25
切片之所以被称为切片,是因为创建一个新的切片就是把底层数组切出一部分 例如:
//长度容量都是5
slice := []int{10, 20, 30, 40, 50}
//长度2,容量4
newslice := slice[1:3]
//使用3个索引创建切片,长度1,容量2
newslice2 := slice[2:3:4]
执行上面的代码后,就有了两个切片,它们共享同一段底层数组,但通过不同的切片会看到底层数组的不同部分。
newSlice无法访问到它所指向的底层数组的第一个元素之前的部分,对newSlice来说,之前的那些元素是不存在的
一个常见的描述是,对底层数组容量是k的切片 slice[i:j:k]来说,长度为 j-i,容量为k-i。
需要记住的是,现在两个切片共一个底层数组,如果一个切片修改了该底层数组的共享部分,另一个切片也会被影响
slice := []int{10, 20, 30, 40, 50}
//创建新切片,长度2,容量4
newSlice := slice[1:3] //[20 30]
//修改
newSlice[1] = 35 //[20 35]
//slice->[10 20 35 40 50]
切片只能访问到其长度内的元素,试图访问超出其长度的元素将会导致语言运行时异常。
在使用与切片的容量相关联的元素前,必须将其合并到切片的长度里,只能用于增长切片。总的来说,切片有额外的容量是很好的,但是如果不能把这些容量合并到切片的长度里,这些容量就没有用处。当然 Go 语言是由内置函数来解决这个问题的append()就是专门来做这种合并的内置函数。
增
相对于数组而言,使用切片的一个好处是可以按需增加数据集合的容量。 Go 语言内置的append()函数会处理增加长度时的所有操作细节。
slice := []int{10, 20, 30, 40}
newSlice := append(slice, 50)
在这个append()操作完成后,newSlice拥有一个全新的底层数组,这个数组的容量是原来的两倍。
https://blog.csdn.net/jaylaozhou/article/details/95322613
https://blog.go-zh.org/go-slices-usage-and-internals
函数append()会智能地处理底层数组的容量,使其增长。在切片的容量小于 1000个元素时,总是会成倍地增加容量。一旦元素个数超过 1000 的增长因子会设为1.25,也就是会每次增加25%的容量(随着语言的演化, 这种增长算法可能会有所改变〉。
遍历
slice := []int{10, 20, 30, 40} //长度和容量都是4
for index, value := range slice {
fmt.Printf("index : %d value : %d\n", index, value)
}
//index : 0 value : 10
//index : 1 value : 20
//index : 2 value : 30
//index : 3 value : 40
和数组类似,迭代切片时,range 会返回两个值。第一个值是当前途代到的索引位置,第二个值是该位置对应元素值的一份副本
slice := []int{10, 20, 30, 40}
//迭代每个元素,并显示值和地址
for index, value := range slice {
fmt.Printf("Value: %d Value-Addr %X ElemAddr: %X\n", value, &value, &slice[i
ndex])
}
/*
返回:
Value: 10 Value-Addr: 10500168 ElemAddr: 1052E100
Value: 20 Value-Addr: 10500168 ElemAddr: 1052E104
Value: 30 Value-Addr: 10500168 ElemAddr: 1052E108
Value: 40 Value-Addr: 10500168 ElemAddr: 1052E10C
*/
关键字range总是从切片头部开始迭代。如果想对迭代做更多的控制,依旧可以使用传统的 for 循环
slice := []int{10,20,30,40}
for index := 2; index < len(slice), index++ {
...
}
限制容量
可以看到,允许限制新切片的容量为底层数组提供了一定的保护,可以更好地控制追加操作。
source := []string{ ”Apple", "Orange ”, Plum ”, Banana ,” apple"}
slice := source[2:3:4]
对于slice[i:j:k] ([2:3:4]),长度为 j-i(3-2=1),容量为 k-i(4-2=2)。
如果试图设置的容量比可用的容量还大,就会得到一个语言运行时错误。
内置函数append()会首先使用可用容量。一旦没有可用容量,会分配一个新的底层数组,这导致很容易忘记切片间正在共享同一个底层数组,一旦发生这种情况,对切片进行修改,很可能会导致随机且奇怪的问题。https://blog.csdn.net/jaylaozhou/article/details/95322613
对切片内容的修改会影响多个切片,却很难找到问题的原因。如果在创建切片时设置切片的容量和长度一样,就可以强制让新切片的第一个append()操作创建新的底层数组,与原有的底层数组分离。新切片与原有的底层数组分离后,可以安全地进行后续修改。
source := []string{"apple", "orange", "plum", "banana", "grape"}
slice := source[2:3:3]
slice = append(slice, "kiwi")
如果不加第3个索引,由于剩余的所有容量都属于slice,向slice追加 Kiwi 元素会改变原有底层数组索引为3的元素的值 Banana。
使用运算符...
s1 := []int{1, 2}
s2 := []int{3, 4}
fmt.Println("%v\n", append(s1, s2...))
多维切片
slice := [][]int{{10}, {100, 200}}
slice[0] = append(slice[0], 20)
//append处理方式:先增长切片,再将新的整型切片赋值给外层切片的第一个元素。执行上述代码后,会为新的整型切片分配新的底层数组,然后将切片复制到外层切片的索引为0的元素中。
即便是这么简单的多维切片,操作时也会涉及众多布局和值。看起来在函数间像这样传递数据结构也会很复杂,不过切片本身结构很简单,可以以很小的成本在函数间传递。
将切片传递给函数
在函数间传递切片就是要在函数间以值的方式传递切片。由于切片的尺寸很小,在函数间复制和传递切片成本也很低。让我们创建一个大切片,并将这个切片以值的方式传递给函数foo():
//分配包含100万个整型值的切片
slice := make([]int, 1e6)
slice = foo(slice)
func foo(slice []int) []int {
...
return slice
}
在64 位架构的机器上,一个切片需要 24B的内存:指针字段需要8B,长度和容量字段分别需要8B。由于与切片关联的数据包含在底层数组里,不属于切片本身,所以将切片复制到任意函数的时候,对底层数组大小都不会有影响。复制时只会复制切片本身,不会涉及底层数组。
在函数间传递24B的数据会非常快速、简单,这也是切片效率高的地方。不需要传递指针和处理复杂的语法,只需复制切片,按想要的方式修改数据,然后传递回一份新的切片副本。
映射
映射是一种数据结构,用于存储一系列无序的键值对,映射基于键来存储值。映射功能强大的地方是,能够基于键快速检索数据。键就像索引一样,指向与该键关联的值。
与C++、Java不一样,Go语言使用映射(map)不需要引入任何库,而C++、Java需要先引用响应的库,因此Go语言的映射使用起来更加方便。
映射的实现
因为映射也是一个数据集合,所以也可以使用类似处理数组和切片的方式来迭代映射中的元素,但映射是无序集合,所以即使以同样的顺序保存键值对,每次选代映射时,元素顺序也可能不一样。无序的原因是映射的实现使用了散列表。
Go 语言中 map 在底层是用哈希(hash)表实现的,在$GOROOT/src/pkg/runtime/hashmap .go 可以查看它的实现细节。Go 语言的 map 是一个 hash 数组列表,而不是像 C++一样使用红黑树,与传统的 hashmap 一样, Go 语言的 map 由一个个 bucket 组成,如图所示。
此列表中的每一个元素都被称为 bucket 的结构体,每个 bucket 可以保存8个键值对,所有元素将被 hash 算法填入到数组的 bucket中·,bucket 填满后 将通过一个 overflow 指针来扩展一个 bucket 从而形成链表,以此解决 hash 冲突的问题。简单来说,这个map就是一个bucket指针型的一维数组,而每个 bucket 指针下面则不定长,可能挂着bucket指针 list,也可能只有一个,视 hash 冲突而定。
映射的创建
//创建一个映射,键的类型是string,值的类型是int
dict := make(map[string]int)
//创建一个映射,键和值的类型都是string
//使用两个键值对初始化映射
dict := map[string]string{"red": "#da1337", "orange": "#e95a2"}
使用映射字面量是更常用的方法,映射的初始长度会根据初始化时指定的键值对的数量来确定。
映射的健可以是任何值,这个值的类型并不限制 ,内置的类型或者结构类型都可以,不过需要确定这个值可以使用==运算符做比较 。需要注意的是,切片、函数以及包含切片的结构类型由于具有引用语义,均不能作为映射的键,使用这些类型会造成编译错误。
//创建一个映射,使用字符串切片作为映射的键
dict := map[[]string]int{}
// Compiler Exception:
// invalid map key type []string
//可以使用切片作为映射的值:
dict := map[int][]string{}
映射的使用
//创建一个空映射
colors := map[string]string{}
colors["red"] = "#DA1337"
与切片类似,通过声明一个未初始化的映射可以创建一个值为 nil 的映射(称为ni映射)。nil映射不能用于存储键值对,否则,会产生一个语言运行时错误。
//通过声明映射创建一个nil映射
var colors map[string]string
colors["red"] = "#da1337"
//Runtime error:
//panic: runtime error: assignment to entry in nil map
查找与遍历
从映射取值时有两种方式。第一种方式是,获得值以及一个表示这个键是否存在的标志:
//获取键 Blue 对应的值
value, exists := colors[”Blue"]
//这个键存在吗?
if exists {
fmt.Println(value)
}
另一种方式是,只返回键对应的值,再判断这个值是否为零值,以此来确定键是否存在。显然,这种方式只能用在映射存储的值都是非零值的情况:
value := colors["blue"]
if value != "" {
fmt.Println(value)
}
Go语言里,通过键来索引映射时,即便这个键不存在也会返回该值对应的类型的零值。
和迭代数组或切片一样,使用关键宇 range 可以迭代映射里的所有值:
colors := map[string]string{
"aliceblue": "#f0f8ff",
"darkgray": "#a9a9a9",
"forestgreen": "#228b22"
}
for key, value := range colors {
fmt.Println("key: %s value: %s\n", key, value)
}
元素的删除
Go语言提供了一个内置函数delete()用于删除容器内的元素。下面是用delete() 函数删除map内元素的一个例子:
delete(myMap, "1234")
上面的代码将从myMap中删除键为 1234 的键值对。如果 1234 这个键不存在,那么这个调用将什么都不发生,也不会有什么副作用 。但是如果传入 map 变量的值是 nil,该调用将导致程序抛出异常(panic)。
以上面的 range 为例,对映射来说,range 返回的不是索引和值,而是键值对。如果想把一个键值对从映射里删除,可以使用内置 delete 函数:
delete(colors, "coral")
for key, value := range colors {
fmt.Printf("key: %s value: %s\n", key, value)
}
将映射传递给函数
在函数间传递映射并不会制造出该映射的一个副本。当传递映射给一个函数,并对这个映射做了修改时,所有对这个映射的引用都会察觉到这个修改:
package main
import (
"fmt"
)
func main() {
colors := map[string]string{
"aliceblue": "#f0f8ff",
"coral": "#ff7f50",
"darkgray": "#a9a9a9",
"forestgreen": "#228b22"
}
for key, value := range colors {
fmt.Printf("key: %s value: %s\n", key, value)
}
removeColor(colors, "coral")
for key, value := range colors {
fmt.Printf("key: %s value: %s\n", key, value)
}
}
func removeColor(colors map[string]string, key string) {
delete(colors, key)
}
可以看到,在调用了 removeColor 之后, main 函数引用的映射中不再有 Coral 颜色了。这个特性和切片类似,保证可以用很小的成本来复制映射。