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 语言中,数组作为参数进行传递时是值传递,而切片作为参数进行传递时是引用传递


切片

什么是切片?

先思考一下数组有什么问题:

  1. 数组定义后,长度是固定的。
  2. 使用数组作为函数参数进行传递时,如果形参为 5 个元素的整型数组,那么实参也必须是 5 个元素的整型数组。

针对以上两个问题,可以使用切片(Slice)来进行解决。

与数组相比,切片的长度是不固定的,可以追加元素,在追加时可能使切片的容量增大,所以可以将切片理解成“动态数组”,但是它不是数组。

切片的底层数据结构定义如下:

type slice struct {
   array unsafe.Pointer
   len   int
   cap   int
}

image

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 的值,原因是新切片实际仍然指向了原切片的底层数据(而不是新开辟内存空间)。

image

  • 如上图所示,比如通过截取的方式由 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),其过程如下图:

image

最终会有两个 slice 结构体,但是他们都指向同一块内存区域。

在追加元素时,slice 容量不足怎么办?

当往切片追加元素时,如果切片容量不足,会自动扩容,具体的扩容策略如下:

  1. 首先看新的容量是否超过原容量的两倍,若超过原容量两倍,则扩容后的容量即为新容量大小。
  2. 新容量未超过原容量两倍,则看原切片容量是否小于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",其底层结构如下图:

image

在本例中,len的长度为5,表示word这个字符串占用的字节数,每个字节的值如图中所示。
这里需要注意,len字段存储的是实际的字节数,而不是字符数,所以对于非单字节编码的字符,其结果可能多于字符个数

我们知道了在 runtime 里 string 的定义,但是我们平常写代码似乎并没有用到 stringStruct 结构,它是在什么地方被用到呢?

其实 stringStruct 是字符串在运行时状态下的表现,当我们创建一个 string 的时候,可以理解为有两步:

  1. 根据给定的字符创建出 stringStruct 结构
  2. 将 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,大致过程分为两步:

  1. 新申请切片内存空间,构建内存地址为addr,长度为len
  2. 构建 string对象,指针地址为addr,len字段赋值为len(string.str = addr;string.len = len;)
  3. 将原切片中数据拷贝到新申请的string中指针指向的内存空间

image

string 转化为 byte 数组,同样大致分为两步:

  1. 新申请切片内存空间
  2. 将string中指针执行内存区域的内容拷贝到新切片

image


[]byte 转 string 一定会发生内存拷贝?

很多场景中会用到 []byte 转化为 string,但是并不是每一次转化都会像上述过程一样,发生一次内存拷贝

那么在什么情况下不会发生拷贝呢?答案是转化后的字符串被用于临时场景。举几个例子:

  1. 字符串比较:string(ss) == "Hello"
  2. 字符串拼接:"Hello" + sting(ss) + "world"
  3. 用作查找,比如 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 。

image


image


Map 赋值原理

若 map 中已存在 key,则更新对应的值为 value;若 map 中不存在 key,则插入键值对 key/value 。

但是有两点需要注意:

  1. 在对 map 进行赋值操作的时候,map 一定要先进行初始化,否则会 panic 。
var m map[int]int
m[1] = 1  // m只是做了声明为一个map,并未初始化,所以程序会panic
  1. 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 是无序的?

  1. Map 在扩容后,会发生 key 的搬迁,原来落在同一个 bucket 中的 key,搬迁后,有些 key 的位置就会发生改变。而遍历的过程,就是按顺序遍历 bucket,同时按顺序遍历 bucket 中的 key。由于搬迁后,key 的位置发生了重大的变化,因此遍历 map 的结果就不可能按原来的顺序了。

  2. 在 map 写入的时候,并不是按照桶顺序进行依次写的,正常写入时是随机选个桶进行键值对储存;哈希冲突写入时是写到同一个桶中。所以 map 的每次插入是没有固定规则的。

  3. 综上,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
}

结构体的声明方式如下:

  1. 使用 var 关键字
var s Student
s.name = "xiaoming"
s.age = 18

注意,在使用了var关键字之后不需要初始化,Golang会自动分配内存空间,并将该内存空间设置为默认的值,因此只需要按需进行赋值即可。

  1. 使用 new() 函数
s := new(Student)
s.name = "xiaoming"
s.age = 18
  1. 使用字面量 &
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 中的函数参数传递都是值传递,之所以能引用传递,是因为拷贝的指针和原指针指向的是同一块区域

若想将值传递改为“引用传递”,则可以使用指针


posted @ 2023-03-08 13:43  Juno3550  阅读(71)  评论(2编辑  收藏  举报