Go 数据结构
数组
所谓的数组,是指存放在连续内存空间
上的相同类型
数据的集合。
示例:数组定义和赋值
// 定义数组
var arr [10]int // 数组的长度定义只能用常量,且不能改变
fmt.Println(len(arr)) // 打印数组长度
// 定义时,元素已有默认值(基本数据类型的默认值)
// 数组赋值
// 方式一:繁琐
arr[0] = 1
arr[1] = 2
...
// 方式二:使用循环
for i:=0; i<len(arr); i++ {
arr[i] = i + 1
}
// 遍历输出
for i, value := range arr {
fmt.Println("下标:", i)
fmt.Println("元素值:", value)
}
示例:数组初始化
// 全部初始化
var arr [5]int = [5]int {1, 2, 3, 4, 5}
// 部分初始化,其余保持默认值
arr2 := [5]int {1, 2, 3}
// 指定索引初始化
arr3 := [5]int {2: 10, 3: 11}
// 通过初始化再决定数组长度
arr4 := [...]int {1, 2, 3, 4}
示例:数组作为实参
func modify(arr [5]int) {
arr[0] = 666
fmt.Println("arr after modify: ", arr)
}
func main() {
arr := [5]int{1, 2, 3, 4, 5}
modify(arr) // [666 2 3 4 5]
fmt.Println("main arr: ", arr) // [1 2 3 4 5]
// 注意结果:不会影响 main() 函数中数组 arr 的值
}
在 GO 语言中,数组作为参数进行传递时是值传递,而切片作为参数进行传递时是引用传递
。
切片
什么是切片?
先思考一下数组有什么问题:
- 数组定义后,长度是固定的。
- 使用数组作为函数参数进行传递时,如果形参为 5 个元素的整型数组,那么实参也必须是 5 个元素的整型数组。
针对以上两个问题,可以使用切片(Slice)来进行解决。
与数组相比,切片的长度是不固定的,可以追加元素
,在追加时可能使切片的容量增大,所以可以将切片理解成“动态数组”,但是它不是数组。
切片的底层数据结构定义如下:
type slice struct {
array unsafe.Pointer
len int
cap int
}
slice 结构包含三个字段:
- array:是一个指针变量,指向一块连续的内存空间,即底层数组结构
- len:当前切片中数据长度
- cap:切片的容量
注意:cap 总是大于等于 len 的,当 cap 大于 len 时,说明切片未满,且多出来的位置并不属于当前切片(即不可访问)。
切片初始化
// 方式一:定义空(nil)切片
s1 := []int{}
s2 := [...]int{}
var s3 []int
// 方式二:初始化切片
s4 := []int{1, 2, 3}
append(s4, 4, 5, 6)
s5 := s4[1:2] // 从切片s4初始化一个新的切片s5
// 方式三:通过make函数定义
s6 := make([]int, 5, 10) // make(切片类型, 长度, 容量)
s7 := make([]int, 5) // 如果不指定cap,则默认创建cap和len大小相同的切片
// 容量参数也可以省略,此时容量=长度
fmt.Println(s5) // [0 0 0 0 0]
// GO语言提供了相应的函数来获取切片的长度与容量
fmt.Println("长度是:", len(s6)) // 5
fmt.Println("容量是:", cap(s6)) // 10
// len 是数组的长度,指的是这个数组在定义的时候,所约定的长度
// cap 是数组的容量,指的是底层数组的长度,也可以说是原数组在内存中的长度
// 切片同样可以使用下标或循环的方式赋值/取值
// 但要注意循环结束条件不能大于切片的长度(而不是容量)
切片截取
所谓截取,就是从切片中获取指定的数据。
// 定义切片
s := []int {10, 20, 30, 40, 50}
// 窃取数据赋值给s1
// s[low:high:max]:low表示开始截取的索引位,high表示结束截取的索引位(包头不包尾),max表示截取后的切片容量(cap=max-low)
s1 := s[0:3:5]
s2 := s[1:3:5]
fmt.Println(s1) // [10 20 30],容量为5
fmt.Println(s2) // [20 30],容量为4(s的容量减去s2的起始索引)
注意:修改新切片 s1/s2 的值时,会影响到原切片 s 的值,原因是新切片实际仍然指向了原切片的底层数据
(而不是新开辟内存空间)。
-
如上图所示,比如通过截取的方式由 slice 派生出一个新额切片 slice1,其实底层他们都是指向的同一块数据区域,只是两个切片的下标对应的底层数组的数据不同,slice[1]=2,而 slice1[0]=2,他们指向同一个元素,所以当修改 slice1[0] 的值,也会影响原始数组。
-
切片的(复制)效果也是如此(如arr2:=arr1,操作 arr2 会影响 arr1),原因是复制了指针仍指向同一块区域。
-
从一个切片截取出另一个切片,修改新切片的值会影响原来的切片内容吗?
在截取完之后,如果新切片没有触发扩容,则修改切片元素会影响原切片,如果触发了扩容则不会。
切片的截取操作:
操作 | 含义 |
---|---|
s[n] | 切片 s 中索引位置为 n 的项 |
s[:] | 从切片 s 的索引位置 0 到 len(s)-1 处所获得的切片 |
s[low:] | 从切片 s 的索引位置 low 到 len(s)-1 处所获得的切片 |
s[:high] | 从切片 s 的索引位置 0 到 high 处所获得的切片,len=high |
s[low:high] | 从切片 s 的索引位置 low 到 high 处所获得的切片,len=high-low |
s[low:high:max] | 从切片 s 的索引位置 low 到 high 处所获得的切片,len=high-low,cap=max-low |
len(s) | 切片 s 的长度,总是 <= cap(s) |
cap(s) | 切片 s 的容量,总是 >= len(s) |
切片追加:append()
切片是动态数组,大小不固定,可以往后追加元素,追加的方法是通过 append 函数来实现,看一个有意思的例子:
package main
import "fmt"
func main() {
arr1 := make([]int, 0, 4)
arr1 = append(arr1, 1)
arr2 := append(arr1, 2)
arr3 := append(arr1, 3)
fmt.Printf("arr1=%v, addr1=%p\n", arr1, &arr1)
fmt.Printf("arr2=%v, addr2=%p\n", arr2, &arr2)
fmt.Printf("arr3=%v, addr3=%p\n", arr3, &arr3)
}
运行结果:
arr1=[1], addr1=0xc000098060
arr2=[1 3], addr2=0xc000098078
arr3=[1 3], addr3=0xc000098090
为什么 arr2 和 arr3 的结果是一样都是 [1,3] 呢?为什么 arr2 不是 [1,2] ?
在前面的分析中我们知道了切片的结构定义为 type slice struct {...},且 Go 语言内置函数 append 参数是值传递,所以 append 函数在追加新元素到切片时,会生成一个新切片,并且将原切片的值拷贝到新切片。
注意这里的新切片并不是指底层的数据结构,而是指 slice 这个结构体。
所以我们每调用一次 append 函数,都会产生一个新的 slice 结构体,但是它们底层都指向同一块连续的内存区域,即共享底层数组,所以执行 arr3 := append(arr1, 3) 将 arr2 底层的数据 1,2 给覆盖了。
假设原切片 arr1 中有 4 个元素 1,2,3,4,我们执行语句 arr2=append(arr1, 5),其过程如下图:
最终会有两个 slice 结构体,但是他们都指向同一块内存区域。
在追加元素时,slice 容量不足怎么办?
当往切片追加元素时,如果切片容量不足,会自动扩容,具体的扩容策略如下:
- 首先看新的容量是否超过原容量的两倍,若超过原容量两倍,则扩容后的容量即为新容量大小。
- 新容量未超过原容量两倍,则看原切片容量是否小于1024,若小于1024,则新切片容量为原切片容量的两倍;若大于等于1024,则会反复地在原切片容量上增加1/4,直到新容量大于等于需要的容量。
切片完全复制:copy()
前面分析了 slice 的复制其底层仍然指向同一块内存区域,这样在使用中可能会带来问题,比如有时候我们想要完全复制出一个新的切片,二者用不同的底层数组,这样使用起来互不干扰,那么我们就可以使用 copy 函数来实现这个功能。
func main() {
srcSlice := []int{1, 2}
dstSlice := []int{6, 6, 6, 6}
cnt := copy(dstSlice, srcSlice) // copy(目标切片, 源切片)
// 将srcSlice切片中两个元素拷贝到dstSlice元素中相同的位置,而dstSlice原有的元素被替换掉
fmt.Println("cnt: ", cnt) // cnt: [1 2 6 6]
srcSlice1 := []int{1, 2}
dstSlice1 := []int{6, 6, 6, 6}
cnt1 := copy(srcSlice1, dstSlice1) // copy(目标切片, 源切片)
// 如果第一个参数切片容量不够,则返回的cnt1的值为第一个切片容量大小,只会成功复制cnt1个元素
fmt.Println("cnt1: ", cnt1) // cnt1: [6 6]
}
切片总结
Go 中的切片,是定义了新的指针,指向了原来数组所在的内存空间。所以,修改了切片数组的值,也就相应地修改了原数组的值。
此外,切片可以用 append 增加元素。但是,如果此时底层数组容量不够,切片将会指向一个【重新分配空间后进行拷贝的数组】。
因此可以得出结论:
- 切片并不存储任何数据,它只是描述了底层数组中的一段。
- 更改切片的元素会修改其底层数组中对应的元素。
- 与它共享底层数组的切片都会观测到这些修改。
字符串
什么是 string ?
string 源码的位置在 src/builtin/builtin.go,描述如下:
// string is the set of all strings of 8-bit bytes, conventionally but not
// necessarily representing UTF-8-encoded text. A string may be empty, but
// not nil. Values of string type are immutable.
type string string
翻译一下,可以这样理解:
- 字符串是所有 8 比特字节的集合,但不一定是 UTF-8 编码的文本。
- 字符串可以为 empty,但不能为 nil ,empty 字符串就是一个没有任何字符的空串"" 。
字符串不可以被修改,所以字符串类型的值是不可变的
。
所以字符串的本质是一串字符数组,每个字符在存储时都对应一个整数,也有可能对应多个整数,具体要看字符串的编码方式。
可以看个例子:
package main
import (
"fmt"
"time"
)
func main() {
ss := "Hello"
for _, v := range ss {
fmt.Printf("%d\n", v)
}
}
运行结果:
s[0]: 72
s[1]: 101
s[2]: 108
s[3]: 108
s[4]: 111
可以看到,字符串的位置每个字符对应一个整数。
string 数据结构
Go 语言在 src/runtime/string.go 文件中对 string 的结构进行了定义:
type stringStruct struct {
str unsafe.Pointer // 指向一个byte类型的切片指针
len int
}
Go 语言中字符串的底层实现是一个结构类型,包含两个字段:一个指向字节数组的指针,另一个是字符串的字节长度
。
定义一个字符串 word := "Hello",其底层结构如下图:
在本例中,len的长度为5,表示word这个字符串占用的字节数,每个字节的值如图中所示。
这里需要注意,len字段存储的是实际的字节数,而不是字符数,所以对于非单字节编码的字符,其结果可能多于字符个数
我们知道了在 runtime 里 string 的定义,但是我们平常写代码似乎并没有用到 stringStruct 结构,它是在什么地方被用到呢?
其实 stringStruct 是字符串在运行时状态下的表现,当我们创建一个 string 的时候,可以理解为有两步:
- 根据给定的字符创建出 stringStruct 结构
- 将 stringStruct 结构转化为 string 类型
通过观察字符串的结构定义我们可以发现,其定义中并没有一个表示容量(Cap)的字段,所以意味着字符串类型并不能被扩容,即字符串上的写操作包括拼接、追加等,都是通过拷贝来实现的。
string 与 []byte 的互相转换
使用
前面说了,string 是只读的,不可以被改变,但是我们在编码过程中,进行重新赋值也是很正常的,既然可以重新赋值,为什么说不能被修改呢,这不是互相矛盾吗?
这里要弄清楚一个概念,字符串修改并不等于重新赋值。我们在开发中所使用的,其实是对字符串的重新赋值。
示例:
package main
import "fmt"
func main() {
var ss string
ss = "Hello"
ss = "Hello2" // 重新赋值:string结构里的指针指向了新的字节数组
ss[1] = "A"
fmt.Println(ss)
}
运行结果:
.\man.go:8:8: cannot assign to ss[1] (strings are immutable)
程序会报错,提示 string 是不可修改的,为什么不能以下标的形式修改字符串呢?
思考一下,通过前面我们了解到的 Go 语言字符串的结构定义,字符串结构是由一个指向 byte 类型的切片指针和一个表示字节数组长度的整形变量构成,指针指向的一个切片才是真正的字符串值。这就比较好理解了,既然字符串的值是一个 []byte 类型的切片,那我们使用下标的方式去修改值的时候,是将一个字符内容赋值给 byte 类型,这是不允许的。
这样一分析,那么可不可以将字符串转化为字节数组,然后通过下标修改字节数组,再转化回字符串呢?答案是可行的。
相互转化的语法如下例所示:
package main
import "fmt"
func main() {
var ss string
ss = "Hello"
strByte := []byte(ss)
strByte[1] = 65
fmt.Println(string(strByte)) // HAllo
}
Hello 变成了 HAllo,好像达到了我们的目的。这里需要注意,虽然这种方式看似可行,但其实最终得到的只是 ss 字符串的一个拷贝,源字符串并没有变化。
转化原理
string 与 []byte 的转化其实会发生一次内存拷贝,或申请一块新的切片内存空间
。
byte 切片转化为 string,大致过程分为两步:
- 新申请切片内存空间,构建内存地址为addr,长度为len
- 构建 string对象,指针地址为addr,len字段赋值为len(string.str = addr;string.len = len;)
- 将原切片中数据拷贝到新申请的string中指针指向的内存空间
string 转化为 byte 数组,同样大致分为两步:
- 新申请切片内存空间
- 将string中指针执行内存区域的内容拷贝到新切片
[]byte 转 string 一定会发生内存拷贝?
很多场景中会用到 []byte 转化为 string,但是并不是每一次转化都会像上述过程一样,发生一次内存拷贝
。
那么在什么情况下不会发生拷贝呢?答案是转化后的字符串被用于临时场景
。举几个例子:
- 字符串比较:string(ss) == "Hello"
- 字符串拼接:"Hello" + sting(ss) + "world"
- 用作查找,比如 key, val := map[string(ss)]
这几种情况下,[]byte 转化成的字符串并不会被后面程序用到,只是在当前场景下被临时用到,所以并不会拷贝内存,而是直接返回一个 string,这个 string 的指针 (string.str) 指向字节切片的内存。
字符串转换
GO 语言还提供了字符串与其它类型之间相互转换的函数,相应的字符串转换函数都在 strconv 包中。
Format 系列函数
:将其他类型转成字符串
// 整型转字符串
fmt.Println(strconv.Itoa(666)) // "666"
// 布尔转字符串
fmt.Println(strconv.FormatBool(false)) // "false"
// 浮点数转字符串
// 3.14指需要转字符串的浮点数,'f'指打印格式,3指保留3位小数,64表示以float64处理
fmt.Println(strconv.FormatFloat(3.14, 'f', 3, 64)) // "3.140"
Parse 系列函数
:将字符串转成其他类型
// 字符串转整数
result, err := strconv.Atoi("666")
if err != nil {
fmt.Println(result) // 666
} else {
fmt.Println("转换失败,原因为:", err)
}
// 字符串转布尔
fmt.Println(strconv.ParseBool("false")) // false <nil>
// 字符串转浮点数
fmt.Println(strconv.ParseFloat("123.12", 64)) // 123.12 <nil>
append()
:将整数等转换为字符串后,添加到现有的字节数组中
// 转换为字符串后追加到字节数组
slice := make([]byte, 0, 1024)
slice = strconv.AppendBool(slice, true)
// 第2个参数表示要追加的数,第3个参数表示指定10进制方式追加
slice = strconv.AppendInt(slice, 1234, 10)
slice = strconv.AppendQuote(slice, "abcgohello")
// 转换成string后再打印
fmt.Println(string(slice)) // true1234"abcgohello"
字符串类型
Go 语言中以字面量来声明字符串有两种方式,双引号和反引号:
str1 := "Hello World"
str2 := `Hello
Golang`
使用双引号声明的字符串和其他语言中的字符串没有太多的区别,但是这种使用双引号的字符串只能用于单行字符串的初始化,当字符串里使用到一些特殊字符,比如双引号,换行符等等需要用 \ 进行转义。
但是,反引号声明的字符串没有这些限制,字符内容即为字符串里的原始内容,所以一般用引号来声明的比较复杂的字符串,比如 json 串:
json := `{"hello": "golang", "name": ["zhangsan"]}`
字符串常用函数
strings 包中常用的字符串处理函数:
函数 | 说明 |
---|---|
func Contains(s, substr string) bool | 判断字符串 s 中是否包含 substr,返回 bool 值 |
func Join(arr []string, sep string) string | 字符串连接,把 arr 中的元素通过 sep 拼接起来 |
func Index(s, sep string) int | 在字符串 s 中查找 sep 所在的位置,返回索引值,找不到返回 -1 |
func Repeat(s string,count int) string | 重复 s 字符串 count 次,返回重复的字符串 |
func Replace(s, old, news tring,n int) string | 在 s 字符串中,把 old 字符串替换为 new 字符串,n 表示替换的次数,小于 0 表示全部替换 |
func Split(s,sep string) []string | 把 s 字符串按照 sep 分割,返回 slice |
func Trim(s string, cutset string) string | 在 s 字符串的头部和尾部,去除 cutset 指定的字符串 |
func Fields(s string) []string | 去除 s 字符串中的空格符,并且按照空格分割返回 slice |
func HasPrefix(s, prefix string) bool | 判断 s 字符串是否有前缀子串 prefix |
func HasSuffix(s, prefix string) bool | 判断 s 字符串是否有后缀子串 suffix |
示例:
// Contains
fmt.Println(strings.Contains("hellogo", "go")) // true
// Join
s := []string{"1", "2", "3"}
buf := strings.Join(s, "|")
fmt.Println(buf) // "1|2|3"
// Repeat
fmt.Println(strings.Repeat("go", 3)) // "gogogo"
// Replace
fmt.Println(strings.Replace("gogogo", "o", "d", -1)) // "gdgdgd"
// Split
fmt.Println(strings.Split("hello@go@go@", "@")) // [hello go go ] 注意最后还有个空字符串元素
// Trim
fmt.Println(strings.Trim(" Are u ok? ", " ")) // "Are u ok?"
// Fields
fmt.Println(strings.Fields(" Are u ok ? ")) // [Are u ok ?]
字符串拼接及性能比较
Go 语言中字符串是不可改变的,所以我们在对字符串进行拼接的时候会有内存的拷贝,存在性能损耗。常见的字符串拼接有以下几种方式:
- + 操作符
- fmt.Sprintf
- bytes.Buffer
- strings.Builder
- append
采用 testing 包下 benchmark 测试性能:
package main
import (
"bytes"
"fmt"
"strings"
"testing"
)
const (
str = "efwaefnurgnrehgepbnrebewnbgblasjfnowbgwooihfunw"
cnt = 10000
)
// BenchmarkPlusConcat + 拼接
func BenchmarkPlusConcat(b *testing.B) {
for i := 0; i < b.N; i++ {
ss := ""
for i := 0; i < cnt; i++ {
ss += str
}
}
}
// BenchmarkSprintfConcat sprintf拼接
func BenchmarkSprintfConcat(b *testing.B){
for i := 0; i < b.N; i++ {
ss := ""
for i := 0; i < cnt; i++ {
ss = fmt.Sprintf("%s%s", ss, str)
}
}
}
// BenchmarkBuilderConcat stringbuilder 拼接
func BenchmarkBuilderConcat(b *testing.B){
for i := 0; i < b.N; i++ {
var builder strings.Builder
for i := 0; i < cnt; i++ {
builder.WriteString(str)
}
builder.String()
}
}
// BenchmarkBufferConcat stringbuilder 拼接
func BenchmarkBufferConcat(b *testing.B){
for i := 0; i < b.N; i++ {
buf := new(bytes.Buffer)
for i := 0; i < cnt; i++ {
buf.WriteString(str)
}
buf.String()
}
}
// BenchmarkAppendConcat append 拼接
func BenchmarkAppendConcat(b *testing.B){
for i := 0; i < b.N; i++ {
buf := make([]byte, 0)
for i := 0; i < cnt; i++ {
buf = append(buf, str...)
}
}
}
运行结果:
go test -bench="Concat$" -benchmem .
goos: darwin
goarch: amd64
pkg: go_tour
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkPlusConcat-12 5 209911901 ns/op 2389122236 B/op 10091 allocs/op
BenchmarkSprintfConcat-12 2 532197570 ns/op 4750051508 B/op 40119 allocs/op
BenchmarkBuilderConcat-12 3895 267453 ns/op 2317271 B/op 28 allocs/op
BenchmarkBufferConcat-12 4500 277094 ns/op 2303259 B/op 15 allocs/op
BenchmarkAppendConcat-12 4929 242086 ns/op 2317271 B/op 28 allocs/op
采用 sprintf 拼接字符串性能是最差的,执行5次,每次要消耗532197570ns 。
性能最好的方式是append,执行了4929次,每次花费时间242086 ,其性能差不多比sprintf好了1000倍。
所以平时代码中,我们在拼接字符串的时候,最好采用后面几种方式,不要直接采用+或者sprintf,sprintf一般用于字符串的格式化而不用于拼接。
性能原理分析
方法 | 说明 |
---|---|
+ | 用 + 拼接 2 个字符串时,会生成一个新的字符串,开辟一段新的内存空间,新空间的大小是原来两个字符串的大小之和,所以每拼接一次买就要开辟一段空间,性能很差。 |
Sprintf | Sprintf 会从临时对象池中获取一个 对象,然后格式化操作,最后转化为string,释放对象,实现很复杂,性能也很差。 |
strings.Builder | 底层存储使用[] byte,转化为字符串时可复用,每次分配内存的时候,支持预分配内存并且自动扩容,所以总体来说,开辟内存的次数就少,性能相对就高。 |
bytes.Buffer | 底层存储使用[] byte,转化为字符串时不可复用,底层实现和 strings.Builder 差不多,性能比 strings.Builder 略差一点。 区别是 bytes.Buffer 转化为字符串时重新申请了一块空间,存放生成的字符串变量;而 strings.Builder 直接将底层的 []byte 转换成了字符串类型返回了回来。 |
append | 直接使用 []byte 扩容机制,可复用,支持预分配内存和自动扩容,性能最好。 |
Map(字典)
Map 初始化
- Map 即 GO 语言中的字典(key/value)
- key 不能重复
- 键的类型,必须是支持 == 或 != 操作符的类型(如切片、函数以及包含切片的类型不能作为字典的键)
// 字典的定义
dict := map[int]string // []中指定的是键(key)的类型,后面紧跟着的是值(value)的类型
fmt.Println(dict) // nil
fmt.Println(len(dict)) // 0
// 字典中不能使用 cap 函数,只能使用 len 函数。len 函数返回 map 拥有的键值对的数量
// 也可以在定义时指定容量
dict2 := make([int]string, 3)
fmt.Println(len(dict2)) // 还是0,因为还没有赋值
// 赋值
dict2[3] = "张三"
dict2[4] = "李四"
dict2[5] = "王五"
dict2[6] = "老六"
// 容量不够时会自动扩容
// 注意:map是无序的,我们无法决定它的返回顺序
fmt.Println(dict2)
fmt.Println(len(dict2)) // 4
fmt.Println(dict2[6]) // 老六
// 在定义时初始化
dict3 := map[int]string{1:"mike", 2:"luke"}
// 以循环的方式输出
for key, value := range dict3 {
fmt.Printf("key=%d, value=%s", key, value)
}
// 输出时进行判断
value, ok := dict3[1] // ok表示key是否存在
if ok == true {
fmt.Println(value)
} else {
fmt.Println("key不存在")
}
Map 底层实现
Map 的底层实质是可以存储键值对的哈希表,Map 类型变量实质是一个指针,指向 hmap 结构体。
hmap 中有记录键值对数量、桶大小、旧桶地址、渐进扩容旧桶处理的进度、下一个溢出桶等字段。每个桶可以存储 8 个键值对。
为了内存紧凑,采用的是先存 8 个 key 过后再存 value 。
Map 赋值原理
若 map 中已存在 key,则更新对应的值为 value;若 map 中不存在 key,则插入键值对 key/value 。
但是有两点需要注意:
- 在对 map 进行赋值操作的时候,map 一定要先进行初始化,否则会 panic 。
var m map[int]int
m[1] = 1 // m只是做了声明为一个map,并未初始化,所以程序会panic
- map 是非线程安全的,不支持并发读写操作。当有其他线程正在读写 map 时,执行 map 的赋值会报为并发读写错误。
package main
import (
"fmt"
)
func main() {
m := make(map[int]int)
go func() {
for {
m[1] = 1
}
}()
go func() {
for {
v := m[1]
fmt.Printf("v=%d\n", v)
}
}()
select {}
}
运行结果:
fatal error: concurrent map read and map write
遍历无序
为什么遍历 map 是无序的?
-
Map 在扩容后,会发生 key 的搬迁,原来落在同一个 bucket 中的 key,搬迁后,有些 key 的位置就会发生改变。而遍历的过程,就是按顺序遍历 bucket,同时按顺序遍历 bucket 中的 key。由于搬迁后,key 的位置发生了重大的变化,因此遍历 map 的结果就不可能按原来的顺序了。
-
在 map 写入的时候,并不是按照桶顺序进行依次写的,正常写入时是随机选个桶进行键值对储存;哈希冲突写入时是写到同一个桶中。所以 map 的每次插入是没有固定规则的。
-
综上,Go 语言强制每次遍历 Map 都随机选一个桶下标开始。
如何实现有序遍历 map?
- 可以在遍历的时候将结果保存到一个 slice 里面,对 slice 进行排序。
Map 线程安全问题
为什么 map 是非线程安全的?
- Go 官方给出的原因是:map 适配的场景应该是简单的(不需要从多个 gorountine 中进行安全访问的),而不是为了小部分情况(并发访问),导致大部分程序付出锁的代价,因此决定了不支持。
线程安全的 map 如何实现?
- 加锁
- 使用 sync.map
sync.map 和原生 map 谁的性能好,为什么?
- 原生 map 的性能好,因为 sync.map 为了保证线程安全,其操作过程中还是会有加锁操作,所以性能上会有损耗。
从功能上看,sync.map 是一个读写分离的 map,采用了空间换时间的策略来提高数据的读写性能,其内部其实用了两个 map 来实现,一个 read map 和一个 dirty map。
在并发处理上,相比于我们前面提到的普通 map 的无脑加锁操作,sync.map 将读和写分开,读数据优先从 read 中读取,对 read 的操作是不会加锁的,当 read 读取不到才会取 dirty 读;而写数据只会在 dirty 写,且只有对 dirty 操作时需要加锁的,这样区分加锁时机,就提升了并发性能。
结构体
什么是结构体?
在 Go 中没有对象这一说法,因为 Go 是一个面向过程的语言。但是我们又知道面向对象在开发中的便捷性,所以在 Go 中有了结构体这一类型。
结构体是复合类型,当需要定义一种类型(它由一系列属性组成,每个属性都有自己的类型和值)时,就应该使用结构体,它把数据聚集在一起。
组成结构体类型的那些数据称为字段(fields)。每个字段都有一个类型和一个名字;在一个结构体中,字段名字必须是唯一的。
可以近似认为,一个结构体就是一个类,结构体内部的字段,就是类的属性。
注意,在结构体中也遵循用大小写来设置公有或私有的规则。
如果这个结构体名字的第一个字母是大写,则可以被其他包访问,否则,只能在包内进行访问。
而结构体内的字段也一样,也是遵循一样的大小写确定可用性的规则。
定义与初始化
结构体的定义方式如下:
// 结构
type 结构体名称 struct {
字段1 类型
字段2 类型
}
// 示例
// type 后面跟着的是结构体的名字Student, struct表示定义的是一个结构体
// 大括号中是结构体的成员,注意在定义结构体成员时,无需加var
type Student struct {
id int
name string
score int
gender byte
age int
}
结构体的声明方式如下:
- 使用 var 关键字
var s Student
s.name = "xiaoming"
s.age = 18
注意,在使用了var关键字之后不需要初始化,Golang会自动分配内存空间,并将该内存空间设置为默认的值,因此只需要按需进行赋值即可。
- 使用 new() 函数
s := new(Student)
s.name = "xiaoming"
s.age = 18
- 使用字面量 &
s := &Student
s.name = "xiaoming"
s.age = 18
结论:
- 第一种使用 var 声明的方式,返回的是该对象的结构类型的【值】;而第二和第三种,返回的是一个指向这个结构类型的指针,是【地址】。
- 对于第二第三种返回指针的声明形式,在我们需要修改其值的时候,其实应该使用的方式是
(*s).name = "xiaoming"
。- 也就是说,对于指针类型的数值,应该要先用*取值,然后再修改。
- 而在 Go 中可以省略这一步骤,直接使用
s.name = "xiaoming"
。尽管如此,我们应该知道这一行为的原因,分清楚自己所操作的对象究竟是什么类型,这样更有助于理解后面的【指针】【方法】等章节内容。
结构体操作
type Student struct {
id int
name string
score int
gender byte
age int
}
func main() {
// 顺序初始化:此时每个成员必须初始化,值的顺序与结构体成员的顺序保持一致
var student1 = Student{1, "小明", 97, 'm', 18}
fmt.Println(student1) // {1 小明 97 109 18}
// 指定成员初始化。没有指定的成员自动赋默认值
var student2 = Student{id: 2, name: "小光"}
fmt.Println(student2) // {2 小光 0 0 0}
// 成员的使用
var student3 Student // 也可以使用 student3 := new(Student)
student3.id = 3
student3.name = "小柳"
student3.gender = 'f'
fmt.Println(student3) // {3 小柳 0 102 0}
// 结构体比较(这里比较的是值)
//两个结构体可以使用 == 或 != 运算符进行比较,但不支持 > 或 <
student4 := Student{4, "小二", 98, 'm', 16}
student5 := Student{4, "小二", 98, 'm', 16}
student6 := Student{6, "小六", 98, 'm', 16}
fmt.Println(student4 == student5) // true
fmt.Println(student5 == student6) // false
// 同类型的两个结构体变量可以相互赋值
var student7 Student
student7 = student6
fmt.Println(student7) // {6 小六 98 109 16}
// 结构体格式化输出
s := Student{id: 1, name: "xiaoming", score: 97}
fmt.Printf("%v\n", &s) // &{1 xiaoming 97 0 0}
fmt.Printf("%+v\n", &s) // &{id:1 name:xiaoming score:97 gender:0 age:0}
fmt.Printf("%#v\n", &s) // &main.Student{id:1, name:"xiaoming", score:97, gender:0x0, age:0}
// 打印复合类型的内存地址使用%p
fmt.Printf("%p\n", &s) // 0x1108b9e0
}
注意:结构体作为函数参数时,是值传递
。
结构体数组/切片
// 定义结构体
type Student struct {
id int
name string
score int
}
// 计算平均分
func Avg(students []Student) int {
var sum int
for i := 0; i < len(students); i++ {
sum += students[i].score
}
return sum / len(students)
}
func main() {
// 定义并初始化结构体切片
students := []Student{
Student{101, "张三", 98},
Student{102, "李四", 66},
Student{103, "王五", 80},
}
fmt.Println("平均分=", Avg(students)) // 81
}
指针
简单理解,指针就是地址,指针变量就是存放地址的变量。在一个变量前加上*,那么这个变量就是指针变量,指针变量只能存放地址。
1个指针变量可以指向任何一个值的内存地址,它所指向的值的内存地址在 32 和 64 位机器上分别占用 4 或 8 个字节,占用字节的大小与所指向的值的大小无关。
var num int
fmt.Printf("num=%d\n", num) // num=0,表示num的内存地址中存储的值
fmt.Printf("&num=%v\n", &num) // &num=0x110160d8,表示num的内存地址
// 定义一个指针变量
var p *int // 表示存储的是一个整型变量的地址,此时指向的内容未分配内存空间,因此*P为nil
p = &num // 把变量num的内存地址赋值给指针变量p
fmt.Printf("i=%d, p=%v\n", num, p) // i=0, p=0x110160d0
// 根据存储的变量的地址,来操作变量的存储单元(如输出变量存储单元中的值、对值进行修改)
*p = 80
fmt.Printf("i=%d\n", num) // i=80
注意:普通变量作为函数参数进行传递时,是值传递;而指针作为参数进行传递时,是引用传递(实质是指针的拷贝)
。
new()
指针变量,除了以上介绍的指向以外(p=&a),还可以通过 new() 函数来指向。
var p *int
// new(int) 作用就是创建一个整型大小的空间,此时指向的内容已分配内存空间,即*p不为nil
p = new(int) // 也可以使用自动推导 p := new(int)
// 然后让指针变量 p 指向了该空间
*p = 59
// 所以通过指针变量 p 进行赋值后,该空间中的值就是 59
fmt.Println("*p=", *p) // 59
new() 函数的作用就是 C 语言中的动态分配空间
。但是在这里与 C 语言不同的地方,就是最后不需要关心该空间的释放,GO语言会自动释放。这也是比 C 语言使用方便的地方。
数组指针
数组作为函数参数进行传递时是值传递,如果想改为引用传递,可以使用数组指针(也就是让一个指针指向数组)。
// 定义一个数组,作为函数Swap的实参进行传递
// 这里需要传递的是数组的地址,所以Swap的形参是数组指针
func Swap(p *[3]int) {
(*p)[1] = 89 // 可以通过*p结合下标将对应的值取出来进行修改,注意要加小括号
}
func main() {
arr := [3]int{1, 2, 3}
// 这时指针p指向了数组arr,对指针p的操作实际上是对数组arr的操作
Swap(&arr)
fmt.Println(arr) // [1 89 3] 发现arr的元素变化了
}
指针数组
针数组指的是一个数组中存储的都是指针(也就是地址)。
// 定义指针数组
var p [2]*int
i := 10
j := 20
p[0] = &i
p[1] = &j
fmt.Println(p[0]) // 0x110140b0
fmt.Println(*p[0]) // 10(不用加小括号,因为是先取p[0]的地址,再根据地址获取值)
结构体指针变量
前面定义了指针指向了数组,解决了数组引用传递的问题。那么指针是否可以指向结构体,也能够解决结构体引用传递的问题呢?大难是可以的。
type Student struct {
id int
name string
score int
}
func Test(p *Student) {
p.id = 19
}
func main() {
var p *Student = &Student{1, "xiaoming", 66} // 也可以使用 p := &Student{1, "xiaoming", 66}
fmt.Println(p) // &{1 xiaoming 66}
fmt.Println(*p) // {1 xiaoming 66}
Test(p) // 传递的是结构体地址
fmt.Println(*p) // {19 xiaoming 66} 发现id变化了
}
值/引用传递 总结
默认传递:
- 值传递:基本数据类型(包括字符串)、数组、结构体
- 引用传递:切片、字典
注意:GO 中的函数参数传递都是值传递,之所以能引用传递,是因为拷贝的指针和原指针指向的是同一块区域
若想将值传递改为“引用传递”,则可以使用指针