楔子

这一次我们来介绍一下go语言中的派生数据类型,或者说高级数据类型。

指针

只要将数据存储在内存中都会为其分配内存地址,内存地址使用十六进数据表示;内存中的每一个字节都有一个32位或64位编号(与32位或64位处理器相关),对于go中的变量我们可以使用&获取其地址。

package main

import "fmt"

func main() {
    var i int = 10
    //使用格式化打印变量的内存地址
    //%p是一个占位符输出一个十六进制地址格
    fmt.Printf("%p\n", &i)  //0xc0000a0068

    //我们go中的变量没有赋初始值之外, 默认有一个零值, 所以已经分配好内存了
    //既然分配好内存了, 那么就一定有地址
    var j int
    fmt.Printf("%p\n", &j)  //0xc0000a00a0
}

如果想将获取的地址进行保存,应该怎样做呢?可以通过指针变量来存储,所谓的指针变量:就是用来存储任何一个值的内存地址。

package main

import "fmt"

func main() {
    var a = "hello"

    //格式: var 指针变量名 *类型 = &变量
    //不过我们说在声明变量的时候, 如果赋初始值的话, 可以省略类型
    //这里就表示将a的地址赋值给指针变量p1和p2, 指针变量也是变量, 只不过它保存的是地址, 地址指向对应的值
    var p1 *string = &a
    var p2 = &a
    // p1和p2是同一个变量的地址, 所以它们的值是一样的
    fmt.Println(p1, p2)  // 0xc0000441f0 0xc0000441f0

    //有了指针变量, 那么通过 *指针变量 即可操作地址对应的内存
    fmt.Println(*p1)  // hello

    //将p1指向的值修改之后, p2指向的值、以及a本身都会改变, 因为它们都是同一份内存
    *p1 = "world"
    fmt.Println(*p2, a)  // world world
}

此外还有很重要的一点,那就是go中变量的传递统统都是值传递,go中没有所谓的引用传递。无论是传递值,还是传递指针,都是值传递(即拷贝一份出来)。

package main

import "fmt"

func main() {
    var a = 123
    var b = a
    // 虽然b = a, 但是这只是将a的值拷贝一份给b, 所以这两个变量的地址是不一样的
    fmt.Printf("%p %p\n", &a, &b)  // 0xc0000a0068 0xc0000a0080

    var c = 456
    var p1 = &c
    var p2 = p1
    //因为将p1赋值给了p2, 所以这两个指针变量的值是一样的, 因为存储的都是变量c的地址
    //但我们说了, go中任何变量的传递都是值传递, 也就是要拷贝一份出来
    //所以p1和p2存储的值一样, 表示它们存储的地址是一样的, 修改*p1会影响*p2, 修改*p2会影响*p1, 因为都指向同一份内存
    //但是这两个指针变量本身的地址是不一样的, 指针变量也是有地址的, 只要它有值
    fmt.Printf("%p %p\n", &p1, &p2)  // 0xc000006030 0xc000006038
    //此时修改p1, 不会影响p2; 修改p2不会影响p1, 因为它们是两个不同的变量, 只是存储的值(地址)一样, 而其本身的地址不一样
}

注意:还有很重要的一点,指针变量如果不赋初始值的话,那么它就是一个空指针,也就是nil。

package main

import (
    "fmt"
)

func main() {
    var p1 *int
    var p2 *int
    //空指针也是有地址的, 它已经分配好了内存, 因为指针也是有零值的, 而nil就是指针的零值
    //打印布尔值用%t, 可以看到这两个指针是一样的, 虽然地址不一样, 但是存储的值一样都是nil; 并且任何类型的指针, 结果为空, 结果都是nil
    fmt.Printf("%p %p %t\n", &p1, &p2, p1 == p2)  // 0xc000006028 0xc000006030 true
    fmt.Println(p1, p2)  // <nil> <nil>

    //另外空指针的话, 你不能操作其指向的内存, 这是不允许的, 因为空指针没有指向一块合法的内存
    //所以虽然这个指针本身被分配了内存, 但是指针指向的内存并没有分配, 所以需要给指针赋一个初始值才可以
    //给指针赋值, 一定要通过取变量地址的方式赋值, 直接 var p *int = 0xF000006060 这种方式也是不允许的
}

所以指针变量需要指向一个具体的普通变量,但是除了创建一个变量并取其&符的方式之外,还可以使用用new函数。

package main

import (
    "fmt"
)

func main() {
    //new函数里面接收一个类型, 会自动创建一个该类型的零值(相当于分配内存), 然后返回其指针
    var p1 = new(int)
    fmt.Println(*p1) // 0
    //所以此时p1这个指针变量就不是空指针nil了, 它是有具体的指向的, 只不过这个位置我们不知道罢了
    *p1 = 123
    fmt.Println(*p1) // 123

    //上面就等价于:
    var i int
    var p2 = &i
    fmt.Println(*p2) // 0
    //只不过此时我们知道p2这个指针变量指向谁, 修改其中一个都会影响另一个, 因为都是同一份内存
    i = 1
    fmt.Println(*p2, i) // 1 1
    *p2 = 2
    fmt.Println(*p2, i) // 2 2
}

因此new函数就方便很多,会动态分配空间,而且我们不需要关心空间释放,go编译器会自动帮我们做到这一点。

既然说起指针,我们很容易想到C,都说指针是C语言的灵魂,不会指针说明你不懂C,那么在go中是不是这样呢?答案不是的,虽然go中的指针也很重要,但是没有C中的指针那么强大,不过能够获取一个变量的地址,并且能通过地址来改变存储的值,我个人觉得已经足够了。因为go是一个高级语言,它要保证安全性,而指针是很危险的,像其它语言在语法层面上都摒弃指针了。所以一句话:go里面有指针,但是相较于C,go中的指针被弱化了许多,所以既提供了指针的便利性,又保证了安全。那么它都进行了哪些弱化呢?

弱化一:golang中的指针不能进行数学运算。

package main

import (
    "fmt"
)

func main() {
    var p1 = new(int)
    //如果是在C中, p1可以是一个整型数组中的某个元素的地址
    //那么 p1++ 即可让p1等于下一个元素的地址, 但是在go中是行不通的, 因为go中的指针不可以运算
    fmt.Println(p1)  // 0xc0000160c0
    //所以 0xc0000160c0 + 1, 行; 但是 p1 + 1, 不行
    fmt.Println(0xc0000160c0 + 1)  // 824633811137
}

弱化二:golang中不同类型的指针不能进行比较。

package main

import (
    "fmt"
)

func main() {
    var p1 *int
    var p2 *int32
    fmt.Println(p1, p2)  // <nil> <nil>
    fmt.Println(p1 == nil, p2 == nil)  // true true

    // 我们看到p1和p2都是nil, 而且它们都可以和nil进行比较, 并且相等
    // 但是 p1 和 p2 两者本身不可以比较, 因为它们是不同类型的指针, 所以go语言对类型的要求真的非常严格
    // 但是nil是一个特例, 任何类型的指针都可以和nil进行比较, 用来判断指针是否为空
    // 如果是空指针, 那么和nil相等, 不是空指针, 和nil不相等
}

弱化三:golang中不同类型的指针不能进行转化或者赋值。

//这个很好理解, 比较都不行, 更何况转化和赋值

但是随着学习的深入,我们会了解到如何使用unsafe包来突破这一限制,当然这都是后话了。总之记住:go中的指针是类型安全的,可以大胆放心使用。

从终端中读取数据

既然学习的指针,那么应该也要了解如何从终端中读取数据了:

package main

import "fmt"

func main() {
    var name string
    var age int

    // 这里会将屏幕中的值读到name和age中
    // 注意: 这种方法会默认以空格进行分隔, 然后按下回车即可
    fmt.Scanln(&name, &age)
    // 比如: 输入mashiro 16即可
    fmt.Println(name, age)  // mashiro 16

    // Scanln是只要按下了回车, 那么读取就结束了, 没有收到值的则默认为零值
    // 比如: 输入mashiro, 那么结果就打印 mashiro 0; 如果无法解析的话也是为零值, 比如: mashiro xxx, 那么age依旧是0

    // 除了Scanln, 还有一个Scanf, 它的语法格式是: Scanf("%s %d", &name, &age)
    // 这会按照你的占位符进行解析

    // 最后还有一个Scan, 和Scanln用法一样, 但区别是Scanln一旦回车就结束读取, 不管有没有读取指定数量的元素, 不够的用对应类型的零值填充
    // Scan的话, 依旧以空格分割, 如果读取的元素不够、即使换行也不会结束, 而是会一直卡住, 直到读取完指定个数的元素
}

说实话这个不是很好演示,可以自己试一下,很简单的。不过还遗留了一个问题:

package main

import "fmt"

func main() {
    var word string
    fmt.Scan(&word)
    // 这个时候我输入 hello mashiro 的话
    // 我们看到打印的是 hello, 遇见空格停止了
    fmt.Println(word) // hello
}

那么如何才能让它读取完一整行呢?可以使用bufio这个包。

package main

import (
    "bufio"
    "fmt"
    "os"
)

func main() {
    // 这个包后面会说
    r := bufio.NewReader(os.Stdin)
    // 执行的时候会在这里卡住, 输入 hello mashiro
    word, err := r.ReadString('\n')
    if err != nil {
        fmt.Println("出错啦,", err)
    } else {
        // 会发现一整行都读取了, 因为我们上面是读取到换行符才结束的
        fmt.Println(word)  // hello mashiro
    }
}

数组

数组:一组具有相同数据类型在内存中有序存储的数据集合,数组的声明方式如下:

var 变量名 [数组元素个数]元素类型

我们来测试一下:

package main

import "fmt"

func main() {
    // 这里没有赋初始值, 那么里面的元素默认为0
    var arr1 [5]int
    fmt.Println(arr1) // [0 0 0 0 0]

    // 如果是二维数组的话
    var arr2 [4][2]int
    // 外层数组有四个元素, 每个元素都是[2]int
    fmt.Println(arr2) // [[0 0] [0 0] [0 0] [0 0]]

    // 也可以赋上初始值
    var arr3 [3]int = [3]int{1, 2, 3}
    fmt.Println(arr3) // [1 2 3]

    // 赋上初始值的话, 那么类型是可以省略的
    var arr4 = [4]int{2, 3, 4}
    // 我们看到元素不够的话, 默认使用零值进行填充; 比如: var arr = [100]int{1, 2, 3}, 那么数组中的前三个元素是1 2 3, 后面97个元素都是0
    // 但是数组的元素不可以多, 比如: [2]int{1, 2, 3}是不合法的, 此时会报错
    fmt.Println(arr4) // [2 3 4 0]

    // 里面元素要是比较多, 而我们又不想数的话, 那么可以指定为..., 这个时候编译器会帮我们数
    // 本来数组长度以[]里面指定的数字为准, 但是我们指定为...的话, 那么数组里面有几个元素, 长度就是几;
    var arr5 = [...]int{1, 1, 1, 2, 3}
    fmt.Println(arr5)

    // 数组最大的特点就是元素的类型一致, 并且长度不可以变
    // 而且数组的个数也会体现在类型中, 比如: [3]int 和 [4]int 是不同的类型
    // 不过在C语言中, 数组的长度不体现在类型中, 我们可以将长度声明为1, 而具体多长则以实际使用时为准, 但是在go中数组的长度必须一开始就确定好
    // 所以在声明数组时, 指定的数组长度一定要是一个常量, 比如下面就是不合法的
    /*
	    var count = 10
	    var arr [count]int  // 不合法, 因为数组的元素个数必须是个常量
    */

    // 我们来查看一下数组类型, 查看类型使用 %T
    fmt.Printf("%T %T %T %T %T\n", arr1, arr2, arr3, arr4, arr5) // [5]int [4][2]int [3]int [4]int [5]int
    // 我们看到指定的数组元素个数也是类型的一部分

    // 此外声明数组还有一个更简单的方式
    // 这里表示索引为3的元素是22, 说明什么, 数组中至少有4个元素, 所以长度为4
    var arr6 = [...]int{3: 22}
    fmt.Println(arr6)  // [0 0 0 22]
    var arr7 = [5]int{3: 33}
    fmt.Println(arr7)  // [0 0 0 33 0]
    /*
    但是注意:
        var arr = [...]int{1, 2, 3, 2:1}
    这种方式是不对的, 因为索引为2的地方已经有数字3了, 然后再指定 2:1 的话, 就会报出索引重复的错误

    下面也不对:
    var arr = [5]int{5:1}
    因为数组长度为5, 索引最大为4, 因此指定 5:1 的话会报出索引越界的错误
    */
}

此外数组的元素类型也可以是指针,当然默认值也是nil、即空指针,因为指针本身的内存编译器会自动分配、并且指针变量也是有地址的,但是它指向的内存就不会自动分配了,需要你显式的设置其指向的值。

package main

import (
    "fmt"
)

func main() {
    var arr [3]*int
    fmt.Println(arr)  // [<nil> <nil> <nil>]
}

此外数组一旦创建,大小就不可以再变了,然后我们可以像其它编程语言那样通过索引进行取值和赋值;

package main

import "fmt"

func main() {
    var arr [3]*int
    var a, b, c = 22, 33, 44
    arr[0], arr[1], arr[2] = &a, &b, &c
    fmt.Println(arr)  // [0xc0000160c0 0xc0000160c8 0xc0000160d0]
    fmt.Println(*arr[0], *arr[1], *arr[2])  // 22 33 44

    a, b, c = 222, 333, 444
    fmt.Println(arr)  // [0xc0000160c0 0xc0000160c8 0xc0000160d0]
    fmt.Println(*arr[0], *arr[1], *arr[2])  // 222 333 444

    // 当然数组也可以使用new, 这里创建一个含有3个整型的数组、返回它的地址
    var arr2 = new([3]int)
    fmt.Println(*arr2)  // [0 0 0]

    // *arr2[1] 相当于 *(arr2[1]), 显然这是不对的, 我们需要将*arr2用括号括起来表示一个整体
    (*arr2)[1] = 123
    fmt.Println(*arr2)  // [0 123 0]
}

切片

我们说数组长度是不可以变的,显然这极大的限制了数组的灵活性,于是我们需要切片(slice)。切片非常灵活,它的操作方式和数组一样,但是可以动态扩容;当然切片本质上也是使用了数组,因为切片本身是一个结构体(后面会说),内部有一个指针,这个指针指向一个数组。

// runtime/slice.go
type slice struct {
    array unsafe.Pointer // 指向底层数组的指针
    len   int // 长度
    cap   int // 容量
}

我们看到slice底层是一个结构体,有三个字段,分别是指向底层数组的指针、长度、容量。而显然这三者都占8个字节(64位机器上),那么这就意味着无论什么样的切片,大小都是24个字节,不信我们来看一下。

package main

import (
    "fmt"
    "unsafe"
)

func main() {
    // []里面什么都不写的话, 表示创建一个切片
    var s1 = []int{1, 2, 3}
    var s2 = []string{"1, 2, 3"}
    var s3 = []float64{1.1, 2.2, 3.3}

    // 查看变量所占内存的话, 可以使用unsafe.Sizeof
    fmt.Println(unsafe.Sizeof(s1))  // 24
    fmt.Println(unsafe.Sizeof(s2))  // 24
    fmt.Println(unsafe.Sizeof(s3))  // 24
}

所以slice底层还是使用数组存储的,slice只是一个结构体,保存了底层数组的首地址、元素的个数、以及容量。长度指的是元素的个数,而容量是指底层数组的长度。我们往切片里面添加元素,实际上是往底层数组添加元素,当底层数组满了的时候,那么会申请一个更大的数组。假设申请一个长度为3,容量为5的切片:

Go 语言中的指针、数组、切片、map

底层数组虽然长度是5,但是对于切片而言只能看到前3个,我们代码演示一下:

package main

import "fmt"

func main() {
    // 如果是通过 []int{}方式创建, 那么默认情况下长度和容量是一样的
    // 但是我们可以使用make创建, 这个语法后面会说
    s := make([]int, 3, 5)
    // 创建[]int类型的切片, 长度为3, 容量为5; 如果不指定容量, 那么容量和长度一致
    fmt.Println(s)  // [0 0 0]

    // 此时打印的s就是底层数组中的元素, 虽然长度为5, 但是打印出来我们能看到的只有3个
    // 事实上底层数组是[0 0 0 0 0], 因为默认都是零值; 但是对于切片而言, 它只能看到3个元素
    // 我们可以像操作底层数组一样操作切片, 因为操作切片本质上也是操作底层数组
    s[0], s[1], s[2] = 1, 2, 3
    // 注意: 如果使用s[3], 那么会索引越界; 虽然底层数组有5个元素, 但是对于切片而言, 它只能看到3个
    fmt.Println(s)  // [1 2 3]

    // 然后我们可以使用append函数进行添加, 注意: 此时必须用一个值进行接收, 但是前后的地址是不会变的
    s = append(s, 11)  // 所以使用同一个变量进行接收, 可以在append前后打印一下s的地址, 是一样的
    fmt.Println(s)  // [1 2 3 11]
    s = append(s, 22)
    fmt.Println(s)  // [1 2 3 11 22]

    // 此时底层数组就变成了[1 2 3 11 22], 但我们说底层数组长度为5
    // 因为创建切片的时候指定的容量是5, 所以底层数组长度也是5, 可现在已经5个元素了, 如果继续添加的话
    s = append(s, 33)
    fmt.Println(s)  // [1 2 3 11 22 33]

    // 虽然结果和我们想象的一样, 而且s还是原来的s, 但是底层数组却不是原来的底层数组了
    // 因为原来的底层数组长度不够了, 所以这个时候会申请一个更大的底层数组
    // 然后把原来数组的元素依次拷贝过去, 再让切片内部的指针指向新的数组
    // 因此数组扩容显然是一个比较耗费资源的操作, 因此每次扩容时都会将底层数组申请的大一些
    // 至于新的底层数组相比之前到底有多大, 可以认为当切片长度比较小(小于1024)的时候, 在扩容时容量(底层数组长度)直接翻倍
    // 切片长度比较大的时候, 扩容时容量增加百分之25

    // 查看切片长度可以使用len函数, len函数也可以作用于数组, 我们在介绍数组的时候没有说, 当前也可以作用于字符串
    fmt.Println(len(s))  // 6

    // 而查看切片的容量(底层数组的长度), 可以使用cap函数
    // 我们看到直接翻倍了
    fmt.Println(cap(s))  // 10
}

所以最终的示意图就是这样:

Go 语言中的指针、数组、切片、map

我们访问切片的元素等价于访问底层数组的元素,修改切片里面元素的值等价于修改底层数组里面元素的值。但是注意:底层数组是可以被多个slice同时指向的,因此修改一个其中一个slice,也会影响其他的slice,因为这些切片指向的都是同一个底层数组。

创建slice

创建slice有很多种方式:

1. 直接声明

package main

import (
    "fmt"
)

func main() {
    // 这种方式只是声明了一个切片, 当然对于结构体而言, 如果没有赋值, 那么里面的每个成员默认也是零值
    // 所以长度和容量都是0, 并且内部的指针是一个空指针、没有指向任何的底层数组, 因此它是一个nil slice
    var s []int
    // 如果指针为空, 那么s和nil是相等的
    fmt.Println(s == nil)  // true

    // 但是我们看到指针明明没有指向底层数组, 居然也能append
    // 这是因为使用append的话,如果没有分配底层数组的话,那么会自动先帮你分配一个大小、容量都为0的底层数组,然后再把元素append进去
    s = append(s, 123)
    fmt.Println(s)  // [123]

    // 当然也可以直接创建, 此外也支持索引的方式
    var s1 = []int{1, 5:1, 3}
    fmt.Println(s1)  // [1 0 0 0 0 1 3]
    // 5:1 表示索引为5的元素是1, 索引为1 2 3 4的元素都是0, 最后是元素3
}

2. 使用new的方式

package main

import "fmt"

func main() {
    // 我们知道new是创建一个类型的零值, 然后返回其指针
    var s = new([]int)
    // 所以这种方式的话, 会创建切片本身,但是切片对应的底层数组是不会被创建的,内部的指针是一个 nil、长度和容量都是 0
    // 不过使用 append 的话会自动创建
    *s = append(*s, 1, 2, 3, 4)   // 可以一次性添加多个元素
    fmt.Println(s)   // &[1 2 3 4]
    fmt.Println(*s)  //  [1 2 3 4]
}

3. 使用make的方式,这是最常用的方式

package main

import "fmt"

func main() {
    // 创建长度为3、容量为3的切片
    var s1 = make([]int, 3)

    // 创建长度为3、容量为5的切片
    var s2 = make([]int, 3, 5)

    // 直接返回切片本身
    fmt.Println(s1)  // [0 0 0]
    fmt.Println(s2)  // [0 0 0]
}

4. 从数组中截取

package main

import "fmt"

func main() {
    //创建元素个数为6的数组
    var arr = [...]int{5:1}
    fmt.Println(arr) // [0 0 0 0 0 1]

    //通过切片的方式从数组中拷贝一个切片, [start: end]和其它高级语言类似, 从索引为start的地方截取到索引为end - 1的地方
    //start可以省略, 默认从头开始; end也可以省略, 表示截取到尾; 都不写则从头截取到尾
    s := arr[0: 1]
    s[0] = 123
    fmt.Println(s) // [123]
    fmt.Println(arr)  // [123 0 0 0 0 1]

    //我们看到对切片的修改是会影响底层数组的
    //如果我们不是手动从已存在的数组拷贝的话, 而是使用其他方式创建的话, 那么go会默认给你分配一个底层数组
    //只不过这个数组我们看不到罢了, 但它确实是分配了; 如果从已经存在的数组中拷贝, 那么这个数组就是拷贝出来的切片的底层数组

    //并且我们拷贝切片的时候, 还可以指定容量
    //注意: 第三个6可不是步长, 而是用来指定容量的, 但它又不完全等于容量
    s1 := arr[2:4:6]
    //arr[start:end:cap], 此时 cap - start 才是容量, 所以这里的容量是6-2=4
    //所以这里的cap无论何时都不能超过底层数组的元素个数, 并且不能小于结束位置, 关于这一点我们马上会继续说, 现在觉得懵的话没关系
    fmt.Println(len(s1), cap(s1)) //2 4
}

slice的截取

我们如果使用make创建、或者直接声明切片的话,那么会默认给我们创建一个底层数组。但是问题就在于,我们很多时候是会从数组中拷贝的,而这里面会隐藏着一些玄机。

package main

import "fmt"

func main() {
    //此时数组共有8个元素,元素的最大索引为7
    var arr = [...]int{0, 1, 2, 3, 4, 5, 6, 7}

    //此时s1和s2都指向了arr,只不过它们指向了不同的部分。
    //我们看到s1的第一个元素,就是s2的第二个元素
    s1 := arr[1: 2]
    s2 := arr[0: 2]

    s2[1] = 111  // 将s2的第一个元素改掉
    // 我们看到s1也被改了,而且底层数组也被改了
    fmt.Println(s1)  // [111]
    fmt.Println(arr)  // [0 111 2 3 4 5 6 7]

    //很好理解,因为我们可以把切片看成是底层数组的一个映射,修改切片等价于修改底层数组
    //s1和s2映射同一个底层数组
    //当然我们知道切片内部是只有一个指针和两个整型, 最终的操作都会体现在底层数组上
}

Go 语言中的指针、数组、切片、map

上面的很好理解,然后我们再来看看下面的例子:

package main

import "fmt"

func main() {
    var arr = [...]int{0, 1, 2, 3, 4, 5, 6, 7}
    s1 := arr[1: 2]
    fmt.Println(s1[3: 6]) // [4 5 6]
    fmt.Println("----------我是分界线----------")
    fmt.Println(s1[3])
    /*
    [4 5 6]
    ----------我是分界线----------
    panic: runtime error: index out of range [3] with length 1
    */
}

惊了,s1里面只有一个元素,我们居然能够通过s1[3: 6]访问,但是后面访问s1[3]却报错了。所以这就是切片的可扩展性,其实我们上面的图画的不是很准确。

Go 语言中的指针、数组、切片、map

因此切片实际上是可扩展的,如果对切片进行索引的话,那么最大索引就是切片的长度减去1。但是如果对切片进行切片的话(reslice),那么是根据底层数组来的。我们看到s[3: 6]对应底层数组是[4, 5, 6],所以是不会报错的。尽管s1只有一个元素,但是它记得自己的底层数组,并且是可扩展的。并且这个扩展只能是向后扩展,可以看到后面的底层数组的元素。但是不能向前扩展,比如底层数组的0,通过s1的话是无论如何都获取不到的。

但是如果我们在截取的时候指定了容量呢?

package main

import "fmt"

func main() {
    var arr = [...]int{0, 1, 2, 3, 4, 5, 6, 7}
    //对于从数组截取切片的话, s1 := arr[1: 2]等价于s1 := arr[1: 2: len(arr)]
    //表示s1的容量为len(arr) - 1, 相当于切片能够从当前位置扩展到arr的尽头
    //但是这里我们指定5, 表示最多只能扩展4个元素
    s1 := arr[1: 2: 5]
    fmt.Println(s1[2: 4]) // [3 4]
    fmt.Println("----------我是分界线----------")
    fmt.Println(s1[3: 5])
    /*
    [3 4]
    ----------我是分界线----------
    panic: runtime error: slice bounds out of range [:5] with capacity 4
    */
}

因此我们看到此时访问[2: 4]是可以的,但是访问[3: 5]就报错了,因为我们这里指定了容量。

Go 语言中的指针、数组、切片、map

s1的容量是5 - 1 = 4,向后扩展最多只能扩展三个元素,那么访问[3: 5]肯定就报错了。

另外我们知道,由数组创建两个切片,对任何一个切片进行修改都会影响底层数组,进而影响另一个切片。如果我创建了一个切片s1,然后根据s1再创建出s2,那么对s2修改同样会影响底层数组,进而影响s1。因为它们指向的都是同一个底层数组,并且s2依旧是可以向后扩展的,至于能向后扩展多少就根据它的容量来决定了,总之不能超过底层数组。

package main

import "fmt"

func main() {
    var arr = [...]int{0, 1, 2, 3, 4, 5, 6, 7}
    s1 := arr[1: 3]
    s2 := s1[3: 6]
    fmt.Println(s1)  // [1 2]
    fmt.Println(s2)  // [4 5 6]

    // [:]这种方式的话只会获取当前切片的可以看到的元素, 换句话说等于切片的长度
    fmt.Println(s2[:])  // [4 5 6]
    fmt.Println(s2[: 4])  // [4 5 6 7]
}

Go 语言中的指针、数组、切片、map

此时我们修改,s[2] = 222, 那么底层数组会有何变化呢?显然是arr[6]也变成了222

    s2[2] = 222
    fmt.Println(arr)  // [0 1 2 3 4 5 222 7]

切片的扩容

切片的扩容,实际上就是对底层数组的扩容,假设我们申请的切片容量是3,那么对应的底层数组的大小就是3。我们知道切片是可以进行append的,如果容量不够的话,怎么办呢?显然就要进行扩容了。

package main

import "fmt"

func main() {
    var s = make([]int, 0, 3)
    s = append(s, 1)
    fmt.Printf("%p\n", &s[0]) //0xc000060140
    s = append(s, 2)
    fmt.Printf("%p\n", &s[0]) //0xc000060140
    s = append(s, 3)
    fmt.Printf("%p\n", &s[0]) //0xc000060140

    //我们知道此时如果再append,那么容量肯定不够了
    s = append(s, 4)
    fmt.Printf("%p\n", &s[0]) //0xc00008c030
}

我们看到扩容之前,s[0]的地址时不变的。但是扩容之后,地址变了。说明golang中切片的扩容是在底层申请一个更大的数组,让s指向这个新的数组,并把对应元素依次拷贝过去(所以&s[0]会变),那么原来var s = make([]int, 0, 3)所指向的底层数组怎么办呢?这个不用担心,golang的垃圾回收机制会自动销毁它。

package main

import "fmt"

func main() {
    var arr = []int{1, 2, 3}
    s1 := arr[1: 3]
    s2 := s1[: 2]
    fmt.Println(s1, s2)  // [2 3] [2 3]
    fmt.Println(&s1[0], &s2[0]) //0xc000060148 0xc000060148

    //此时s1和s2都是[2, 3],下面给s2扩容
    s2 = append(s2, 4)
    fmt.Println(&s1[0], &s2[0]) //0xc000060148 0xc000060180
    fmt.Println(s1[0], s2[0]) //2 2
}

惊了,我们看到s2的第一个元素的地址变了,而s1的第一个元素的地址没有变。因此可以猜测扩容之后,s2指向了新的数组,但是s1还是指向了原来的数组。事实上也确实如此,因为对s2添加元素,发现底层数组容量不够了,那么就申请一个更大的,让s2重新指向,但是s1还是指向原来的底层数组。而且既然s1引用的还是原来的数组的话,那么原来的数组则不会被gc回收了,并且我们再对s1做任何操作都不会影响s2了,因为这两个切片指向的不再是同一个底层数组了。

package main

import "fmt"

func main() {
    var arr = []int{1, 2, 3}
    s1 := arr[1: 3]
    s2 := s1[: 2]

    //对s1操作,此时会影响s2
    s1[0] = 111
    fmt.Println(s2) //[111 3]

    //扩容之后,s2指向新的数组
    s2 = append(s2, 4)
    //再对s1操作,不会影响s2
    s1[0] = 333
    //s2[0]还是之前被影响的111,不会是s1新设置的333
    fmt.Println(s2)  //[111 3 4]
}

Go 语言中的指针、数组、切片、map

而且申请更大的底层数组的时候,并不是把原来数组中所有元素都拷贝过去,而是把切片对应的底层数组的元素拷贝过去。因为切片无法向前扩展,那么不好意思,前面的元素就不会拷贝了。

切片的拷贝

有一个copy函数,可以专门实现切片的拷贝。

package main

import "fmt"

func main() {
    var s1 = []int{1, 2, 3, 4, 5}
    var s2 = []int{6, 7, 8}
    // 将s1拷贝到s2中, 是从头开始
    copy(s2, s1)
    fmt.Println(s2)  // [1 2 3]

    var s3 = []int{1, 2, 3}
    var s4 = []int{4, 5, 6, 7, 8}
    copy(s4, s3)  // 将s3拷贝到s4中
    fmt.Println(s4)  // [1 2 3 7 8]

    var s5 = []int{1, 2, 3, 4, 5}
    var s6 = make([]int, 1, 3)
    copy(s6, s5)
    // 我们看到copy切片不会影响底层数组
    fmt.Println(s6)  // [1]
    fmt.Println(s6[: 3]) // [1 0 0]

    var s7 = []int{1, 2, 3}
    var s8 = []int{3, 4, 5}
    // 如果想将一个切片的元素拷贝到另一个元素里面去要怎么做呢
    s7 = append(s7, s8[1:]...)
    // 因为append接收一个 切片 和 任意个相同类型的元素
    // 所以通过...的方式可以将切片里面的元素展开, 用过Python的话就类似于里面的 *list
    fmt.Println(s7)  // [1 2 3 4 5]
}

切片和数组的区别

slice的底层数据结构是数组,slice是对数组的一个封装,它描述数组的一个片段,两者都可以通过下表来访问单个元素。

数组是定长的,长度定义好之后不能再更改。在go中,数组不常用,因为长度也是类型的一部分,限制了它的表达能力,比如[3]int[4]int就是不同的类型。

而切片则非常灵活,它可以动态扩容,并且类型和长度无关。

map

map(映射)类似于Python中的dict,它是使用哈希表实现的,是一种基于key-value形式的、无序的数据集合。那么如何创建一个映射呢?

package main

import "fmt"

func main() {
    // 此时我们就声明了一个map, 但是注意: 此时是为nil的
    var m map[string]int
    fmt.Println(m)  // map[]
    fmt.Println(m == nil)  // true
    // 虽然显示的是map[], 但其实结果是nil, 所以此时是不能操作的
    // 我们必须要先赋值
    m = map[string]int{"math": 95, "english": 88, "history": 77}
    fmt.Println(m)  // map[english:88 history:77 math:95]

    // 获取元素的方式类似于json, 直接通过中括号的方式获取即可
    fmt.Println(m["math"], m["history"])  // 95 77

    // 获取一个不存在的元素, 那么结果为零值
    fmt.Println(m["not exists"])  // 0

    // 但是问题来了, 如果有的值刚好为0, 该怎么办呢? 答案是用两个变量来接收
    // 如果只用一个变量, 那么key存在、则返回对应value; 不存在则返回零值
    // 如果用两个变量, 那么key存在、则返回对应value和true; 不存在则返回零值和false
    val, flag := m["math"]
    fmt.Println(val, flag)  // 95 true

    // 上面已经创建了val 和 flag, 所以下面直接赋值就可以了
    val, flag = m["not exists"]
    fmt.Println(val, flag)  // 0 false

    // 添加一个键值对, key存在则更新, 不存在则添加
    m["not exists"] = 123
    fmt.Println(m["not exists"])  // 123

    // 删除一个键值对
    delete(m, "not exists")
    fmt.Println(m)  // map[english:88 history:77 math:95]

    // 使用len函数可以查看键值对个数
    fmt.Println(len(m))  // 3
}

需要注意的是,map基于哈希表实现,这就意味着key必须是可哈希的;像字典、切片就不可以作为key,因为它们支持动态修改,导致哈希值会变;当然 通道和函数 也不可以。

另外map也是会动态扩容的,但不会缩容,即使你删除了全部的key,容量占用依旧摆在那里。

相比上面那种创建方式,我们会更倾向于使用make,没错又是make。

像切片、map,这类数据结构它们是可以动态改变的,因此对应的结构本身并没有真正地存储数据;而是通过一个指针,指向一块内存(真正存储数据),如果直接声明的话,那么指针会是nil。所以go中针对这样数据结构,专门设置了make函数,会自动创建内存并把地址交给指针。同理我们后面要学习的channel也是如此,总之:切片、map、通道,这三者都使用make创建即可。

package main

import "fmt"

func main() {
    var m = make(map[int]int)
    m[1] = 11
    m[2] = 22
    m[3] = 33
    fmt.Println(m)  // map[1:11 2:22 3:33]
}

怎么样,是不是很方便呢。

小结

这一次我们把go中的一些高级数据类型介绍了一部分,其实总的来说go还是比较简单的。不过简单,也就意味着很多操作都需要我们自己写,比如:如何往切片中间插入一个元素我们没有说,原因就在于go中没有现成的方法,所以该怎么做你肯定清楚。

相关文章:

  • 2022-02-19
  • 2021-07-29
  • 2021-09-06
  • 2021-07-20
  • 2022-12-23
  • 2021-04-07
  • 2021-11-28
  • 2021-06-07
猜你喜欢
  • 2022-12-23
  • 2018-06-19
  • 2021-11-13
  • 2022-12-23
  • 2021-09-11
  • 2022-01-22
  • 2022-12-23
相关资源
相似解决方案