数据结构在每个编程语言都有应用,最基本的数组、映射图、列表等,而在 Go 语言中最常用的内置数据结构是 slice 、 map 、 channel 使得 Go 在开发者过程中大部分问题都可以使用这些结构来解决,使得开发更简单并且代码也能精简。Go 在设计的时候就对传统 Java 开发使用的 OOP 编程范式,做了一些改进重新思考🤔,如何能用更少的代码写出来高效高性能的软件,也能解决大项目编译时需要的编译时间问题,本文是笔者对现有 Go 语言的核心常用数据结构一些介绍和总结。


数组

在介绍切片之前要介绍一下 Go 语言中和切片很相似的结构数组 Arrays ,和其他语言一样数组在创建的时候就申请对应的大小空间;空间大小取决于该数组所需要存储的类型大小,然后乘以容量,公式:s = t * n

不管在什么编程语言里面,数组都一个固定长度大小的数据类型,用于存储一段具有相同类型的元素连续块,如下 Go 语言可以通过下面几种方式使用数组:

package main 

func main() {
    // 创建一个连续的类型为int64元素为5的数组
    ages := [5]int64{11, 16, 18, 19, 22}
    for i := 0; i < len(ages); i++ {
        if i == 0 {
            fmt.Print("ages = [\t")
        }
        //遍历打印ages
        fmt.Print(ages[i], "\t")
        if i == len(ages)-1 {
            fmt.Println("]")
        }
    }
}

创建数组的时候也可以不用指定元素个数,Go 的编译器比较智能可以通过传入的元素个数自动推算出来容量,可以改成下面的方式:

var ages  = [...]int64{22,11,21,18}

比较方便的声明方式 ... 代表你不确定这个数组有几个元素位 让 Go 语言自己去推断,通过内置的 len() 函数就能得出数组里面有多少个元素。

数组的特点因为是连续的内存空间,可以被 CPU 缓存更长的时间,连续并且元素类型大小固定,可以通过偏移量来访问每个元素。当然上面的方式使用的传统的方式遍历,这种方式如果数组的元素是指针类型的话,在遍历得到相应的元素就可以直接修改原数据信息,而另外一种方式就是 ForEach ,如下:

package main

func main() {
    //多维数组 并且 数组类型要一至
    n := [3][2]int{
        [2]int{1, 2},
        [2]int{2, 2},
        [2]int{3, 2},
    }
    //多维数组遍历
    for in, v1 := range n {
        fmt.Println("n[", in, "] = ", v1)
        for index, v2 := range v1 {
            fmt.Println("n[", in, "] [", index, "]= ", v2)
        }
    }
}

这种遍历的方式只会得到原始数据的副本,当然如果是指针类型可以排外的,下面这种方式元素类型就在指针的方式去初始化,并且指定元素的位置:

package main

import "fmt"

func main() {
    var arrays = [5]*int{0: new(int), 4: new(int)}
    *arrays[0] = 1
    fmt.Println(*arrays[0]) // 1
}

在 Go 语言中数组的长度是数组类型的一部分,如果类型一样但是元素长度不一样,这两个数组就被视为不等同的类型。


切片

在 Go 语言中切片和数组相似,但是可以自动扩容,当在创建的时候容量不够的时候会发生自动扩容。切片又称 动态数组 依托数组实现,可以方便进行扩容和传递,传统数组如果在函数传统的时候是直接拷贝传递的,因为 Go 语言函数参数传递都是值传递;而切片底层是一个结构体里面包含的是指向堆区指针,所以拷贝指针的代价很小,使用起来很灵活,但是就是因为这个底层实现还是有很多在开发中需要注意的 Bug 的,本节我讲解 Go 中的切片细节。

package main

import (
    "fmt"
)

func main() {
    // 通过make创建切片,
    s := make([]int,5,10)
    fmt.Println(s)
}

上面这种方式是通过 Go 语言内部的 make 函数创建一个元素类型为 int 类型的切片,第一个参数代表的是目前存储的元素个数,第二个参数是切片的容量,这个和底层实现有关系就因为这个两个 len()cap() 如果没有理解清楚,会在某种情况下写出有 bug 的代码。


当使用 s := make([]int,5,10) 这种方式,在底层会分配一个元素容量为 10 类型为 int 的数组,而在这个数组的 0~4 位上全部为默认类型的零值,int 类型的零值是 0,对应的元素位是已经存储值了,如果是指针类型存储对应的指针存储空间;剩下的位置 5~10 位为当新的元素添加的时候可以直接插入到对应的位置上,例如下面代码使用 append 内置函数添加新的元素:

package main

import (
    "fmt"
)

func main() {
    // 通过make创建切片,
    s := make([]int,5,10)
    s = append(s,6)
    // [0 0 0 0 0 6]
    fmt.Println(s)
}

当内部的 len() 大于等于 cap() 时就会发生扩容操作,扩容操作可以查看我之前写一篇文章Go Slice Design

另外需要注意切片的地方就是如果使用切片高级语义,例如:元素切取切片拷贝 这是都是存在很多坑的地方,下面我将介绍一下切片的一些常见使用避坑细节,如下下面有一段代码:

package main

import (
    "fmt"
)

func main() {

    var arr = [...]int64{80, 90, 99, 120, 87, 78}

    score := arr[3:5]

    // len 2 - cap 3    len = 5 - 3 cap = 6 - 3
    fmt.Printf("len %d - cap %d \n", len(score), cap(score))

    // [120 87]
    fmt.Println(score)

    // change element
    score[0] = 100

    // [80 90 99 100 87 78]
    fmt.Println(arr)
}

在切片中如果是基于一个数组创建的切片那么切片底层数据结构在没有扩容之前都是共享着这块连续的数组存储空间,如上的代码实例中我使用了一个名为 arr 数组创建一个切片,切片为 score 二者实际存储数据都是基于 arr 数组的空间;例如上面我修改了 score[0]=100 ,由于没有发生扩容换地址,底层还是同一块连续存储空间所以修改就会影响到外部的 arr

另外就是 切片截取 语义,截取可以在一个现有的切片上截取一部分元素,例如上面的代码中我使用了截取功能 score := arr[3:5] ,意思是在原有的切片基础上截取,从索引为 3 然后到索引为 5 的之前的元素,也不是左开右闭原则。截取之后内容是 [120 87] ,内存布局也会发生改变,此时的 len() 计算公式是:结束索引减去开始的索引,另外 cap() 计算公式是:元素总容量减去开始索引

内置的 append 函数可以帮助切片扩容,当然 len() 超过 cap() 时就会发生一次扩容操作,默认扩容过程就是将原来的底层存储空间扩大,然后将原来的元素以之前顺序存储到新空间中,然后如果有新的元素添加放到后面一个元素即可。扩容策略:如果原始存储数组空间没有超过 1024 个就按照两倍的扩容,如果超过了就得乘以 1.25 倍扩。

下面代码中我使用两个切片 s1s2s2 切片是由 s1 得到的,在 Go 中如果这样操作切片的话,s1s2 都是共用一个底层存储数组:

package main

import "fmt"

func main() {
    s1 := []int{12, 344, 131, 341, 88}
    s2 := s1
    // [12 344 131 341 88] [12 344 131 341 88]
    fmt.Println(s1, s2)
    s2[4] = 99
    // [12 344 131 341 99] [12 344 131 341 99]
    fmt.Println(s1, s2)
}

如果我们修改了其中一个切片的值的话,例如上面修改了 s2[4]=99 也会影响到 s1 ,可以证明底层同一块存储空间,但是这个前提是两个切片中一个没有发生扩容的情况。

package main

import "fmt"

func main() {
    s1 := []int{12, 344, 131, 341, 88}

    // [0 0 0]
    s3 := make([]int, 3)

    s3[0] = 100

    // [100 0 0]
    fmt.Println(s3)

    copy(s3, s1)

    // [12 344 131]
    fmt.Println(s3)
}

上面代码就演示通过内置的 copy 函数将一个切片的元素复制到另外一个切片中,复制元素个数取决于两个切片中元素位最少的那个,例如上面的取决于 s3 的元素位数,并还会覆盖原理的切片相应位上的元素。

切片扩充表达式: 这Go语言中针对传统切片做的功能增强,例如下面这种方式通过第三方索引的方式去创建新的切片,第 3 个参数会限制被创建的新切片容量:

package main

import "fmt"

func main() {
    s1 := []int{13, 99, 88, 66, 100}
    s2 := s1[2:4:5]
    // cap = 5 - 2 len = 4 - 2
    fmt.Println("cap =", cap(s2), "len =", len(s2))
}

这种创建切片的方式可以限制被创建新的切片的容量,不会让原有的 2 个切片共用相同的数组存储空间,如下代码:

package main

import "fmt"

func main() {
    source := []int{1, 2, 3, 4, 5}
    newSlice := source[2:3:4]

    // [3]
    fmt.Println(newSlice)
    // cap = 4 - 2 = 2
    fmt.Println(cap(newSlice))

    newSlice = append(newSlice, 6)

    // [3 6]
    fmt.Println(newSlice)

    // [1 2 3 6 5]
    fmt.Println(source)
}

在通过共享一个原始数组操作多个切片的是时候一定要考虑数据读写竞争问题,这里竞争指得是底层存储空间没有发送改变时影响全部共用切片。


映射图

Map 数据结构在每个编程语言中都存在的,本节将介绍一些 Go 语言中映射图结构,Go 语言中的 map 是无序这和他底层存储方式和存储结构有关,这里我不做过多介绍底层实现细节,这里我只会介绍使用 map 常见的一些问题。

下面是创建几种 map 的方式演示,如下代码:

package main

import "fmt"

func main() {

    var m1 map[string]int
    // map[]
    fmt.Println(m1)

    var m2 map[string]int = map[string]int{"Josh": 90, "Tom": 78, "Leon": 100}
    // map[Josh:90 Leon:100 Tom:78]
    fmt.Println(m2)

    m3 := map[int][]string{}
    // map[]
    fmt.Println(m3)

    m4 := make(map[string]int)
    m4["A"] = 100
    fmt.Println(m4)

}

上面几种方式都可以创建 map ,但是第一种方式只是声明一个 map ,底层存储结构还是没有被分配,如果对其进行赋值操作会发生 panic ,导致程序运行崩溃,例如下面代码:

package main

import "fmt"

func main() {
    var m1 map[string]int
    // panic: assignment to entry in nil map
    m1["B"] = 80
    // map[]
    fmt.Println(m1)
}    

由于 map 底层数据结构是通过指针的方式来放实际存储空间的,所以我们在函数之间传递 map 的时候不需要考虑传是引用还是值,因为默认就是值,值里面又包含了指针,这样就达到传应用的效果。


通道

管道 channel 是 Go 语言中最重要的一个特性数据结构了,在源代码中是一个结构体,当然分析源代码不是我这篇文章要介绍的,我之前有这方面的文章。

在当前程序员编程遇到令人头疼几个大问题是:怎么把并发玩好? 怎么在多个线程之间共享一个变量? 怎么处理阻塞的IO? 这些都是现在程序员在写并发软件的时候需要考虑的问题。

传统语言 Java 可能需要通过锁来控制在不同的线程里面共享的数据,但是 Go 在设计的时候采用了另外一种方案 CSP 并发模型,关于这个并发的模型我在之前的问介绍过,可以查看这篇文章virtual thread scheduler里面有介绍;

CSP 并发模型最重要一个组件就是 channel ,通过通道在多个并行执行的 goroutine 间共享数据,而不是传统上锁的方式争取同一个数据的使用权。让编程的逻辑更倾向于人类的方式,线性的去理解并发控制,本节将介绍 channel 基本使用和一些注意事项。

package main

func main() {
    var channel chan int
    fmt.Println(channel)
}

这种创建的通道其实为 nil 也就是空的,底层没有分配存储数据的缓冲区,正确的初始化通道的方式应该使用的是 make 内置函数, make 会返回被创建的其数据类型本身,和 new 不一样, new 返回的是其数据类型的指针,当然两个内置函数都是用于创建对应的类型的内存分配使用的。

在 Go 语言里面通道分为有缓冲区的通道和没有缓冲区通道,没有缓冲区通道在读取的数据的会阻塞,直到有数据写入的,要读和写都要准备好,才能协调两个 goroutine 的操作,如下代码:

package main

import (
    "fmt"
    "sync"
)

var wg sync.WaitGroup

func main() {
    var ch = make(chan int)
    wg.Add(1)
    go produce(ch, 110)
    consume(ch)
    wg.Wait()
}

// 生成数据
func produce(ch chan<- int, n int) {
    defer wg.Done()
    ch <- n
}

// 读取数据
func consume(ch <-chan int) {
    // defer wg.Done()
    fmt.Println(<-ch)
}

上面的代码是可以正常工作的,因为在 main 函数启动时候 Go 的 runtime 就会跑起来一个主协程,然后在创建一个 produce 协程生成数据,然后主协程里面的 consume 接收数据,是并没有阻塞的也没有发生死锁,是因为先创建并且把数据生产好了,反之如果先让主协程生成数据就会阻塞,导致下面的 consume 不能正常执行,从而发生了死锁。

下面的方式创建一个有缓冲区的通道,因为是有缓冲区的通道在主协程里面发送数据到通道里面可以暂存不会阻塞主协程,反之如果没有缓冲区就不行会阻塞主协程不能往下执行程序导致程序死锁。

package main

import (
    "fmt"
    "sync"
)

func main() {
    wg := new(sync.WaitGroup)
    wg.Add(1)

    // ch := make(chan string)
    // all goroutines are asleep - deadlock!
    ch := make(chan string, 10)

    ch <- "received 01"

    go func() {
        defer wg.Done()
        fmt.Println(<-ch)
    }()

    wg.Wait()

}

日常开发大多数使用的都是使用的有缓冲区通道,其通道在底层实现就是一个双端阻塞队列,可以查阅我之前写一篇分析Go Channel Design,这篇文章详细介绍了通道的内部实现。

开发注意事项: 当元素满的时候读不会阻塞写会阻塞,而通道空的时候写不会阻塞,读会阻塞。如果无缓冲区通道读数据的会阻塞直到有数据写入,类似得写数据直到有数据读取才能正常工作,不然死锁。还有通道的第二返回 bool 参数是用来判断通道是否还有数据并非是否关闭,关闭的通道如果有数据一样可以读取到数据,但是第二个参数是 true ,另外写一个已经关闭的通道会发生 panic


小结

newmake 都是Go语言内置的内存分配关键字,区别在于 new 返回对应类型的指针,而 make 通常返回的被创建类型本身,这些类型往往都是一些复合数据结构,例如 mapchannelslice ,这些数据结构特点就是内部使用了指针,从语义上来是传递了引用,其实是拷贝了内部的指针所达成的效果。内置的 len() 能获取切片和映射图的长度,但是 cap() 只能用于切片。

参考资料

便宜 VPS vultr
最后修改:2023 年 07 月 05 日
如果觉得我的文章对你有用,请随意赞赏 🌹 谢谢 !