快学 Go 语言 - 老钱 | 知乎专栏
《快学 Go 语言》最新内容大全
代码在线运行 - 在线工具

更新中…

1. 预备知识

摘自《快学 Go 语言》第 1 课 —— Hello World

Go 语言的「元团队」

很多著名的计算机语言都是那么一两个人业余时间捣鼓出来的,但是 Go 语言是 Google 养着一帮团队打造出来的。这个团队非常豪华,它被称之为 Go Team,成员之一就有大名鼎鼎的 Unix 操作系统的创造者 Ken Thompson,C 语言就是他和已经过世的 Dennis Ritchie 一起发明的。

图中翘着二郎腿的谢顶老头就是 Ken Thompson
图中翘着二郎腿的谢顶老头就是 Ken Thompson

Hello World

package main

import "fmt"

func main() {
    fmt.Println("hello world!")
}

直接运行源文件main.go

> go run main.go

编译二进制文件:

> go build main.go

设置 GOPATH 环境变量

环境变量 GOPATH 指向一个目录,以后我们下载的第三方包和我们自己开发的程序代码包都要放在这个目录里面,它就是 Go 语言的工作目录

当你在源码里使用import语句导入一个包时,编译器都会来 GOPATH 目录下面寻找这个包。

MacLinux 用户的 GOPATH 通常设置为~/go。将下面环境变量的设置命令追加到~/.bashrc~/.zshrc的文件末尾,然后重启终端:

> export GOPATH=~/go

在 Go 语言的早期版本中,还需要用户设置 GOROOT 环境变量,指代 Go 语言开发包的目录,类似于 Java 语言里面的JAVA_HOME环境变量。不过后来 Go 取消了 GOROOT 的设置,也就是说用户可以不必再操心这个环境变量了,当它不存在就行。

2. 变量

摘自《快学 Go 语言》第 2 课 —— 变量什么的最讨厌了

定义变量的三种方式

package main

import "fmt"

func main() {
    var s1 int = 42 // 显式定义,可读性最强
    var s2 = 42 // 编译器自动推导变量类型
    s3 := 42 // 自动推导类型 + 赋值
    fmt.Println(s1, s2, s3)
}

-------------
42 42 42
  1. 如果一个变量很重要,建议使用第一种显式声明类型的方式来定义,比如全局变量的定义就比较偏好第一种定义方式。
  2. 如果要使用一个不那么重要的局部变量,就可以使用第三种,比如循环下标变量
  3. var关键字无法直接写进循环条件的初始化语句中。
for i:=0; i<10; i++ {
  doSomething()
}

如果在第一种声明变量的时候不赋初值,编译器就会自动赋予相应类型的「零值」,不同类型的零值不尽相同,比如字符串的零值不是nil,而是空串整型的零值就是0布尔类型的零值是false

package main

import "fmt"

func main() {
    var i int
    fmt.Println(i)
}

-----------
0

全局变量和局部变量

局部变量定义在函数内部,函数调用结束就随之消亡全局变量则定义在函数外部,在程序运行期间会一直存在

package main

import "fmt"

var globali int = 24

func main() {
    var locali int = 42
    fmt.Println(globali, locali)
}

---------------
24 42
  • 首字母大写的全局变量:公开的全局变量
  • 首字母小写的全局变量:内部的全局变量

内部的全局变量只有当前包内的代码可以访问,外面包的代码是不能看见的。另外,Go 语言没有静态变量

变量与常量

常量关键字const用来定义常量,可以是全局常量也可以是局部常量,大小写规则与变量一致。常量必须初始化,因为它无法二次赋值。不可以对常量进行修改,否则编译器会报错。

package main

import "fmt"

const globali int = 24

func main() {
    const locali int = 42
    fmt.Println(globali, locali)
}

---------------
24 42

指针类型

Go 语言被称为互联网时代的 C 语言,它延续使用了 C 语言的指针类型。指针符号*取地址符&在功能和使用上同 C 语言几乎一模一样。同 C 语言一样,指针还支持二级指针、三级指针,不过在日常应用中很少遇到。

package main

import "fmt"

func main() {
    var value int = 42
    var p1 *int = &value
    var p2 **int = &p1
    var p3 ***int = &p2
    fmt.Println(p1, p2, p3)
    fmt.Println(*p1, **p2, ***p3)
}

----------
0xc4200160a0 0xc42000c028 0xc42000c030
42 42 42

指针变量本质上就是一个整型变量,里面存储的值是另一个变量的内存地址*&符号都只是它的语法糖,是用来在形式上方便使用和理解指针的。*操作符存在两次内存读写,第一次获取指针变量的值,也就是内存地址,然后再去拿这个内存地址所在的变量内容

指针变量
指针变量

如果普通变量是一个储物箱,那么指针变量就是另一个储物箱,这个储物箱里存放了普通变量所在储物箱的钥匙。通过多级指针来读取变量值就好比在玩一个解密游戏。

Go 语言基础类型大全

package main

import "fmt"

func main() {
    // 有符号整数,可以表示正负
    var a int8 = 1 // 1 字节
    var b int16 = 2 // 2 字节
    var c int32 = 3 // 4 字节
    var d int64 = 4 // 8 字节
    fmt.Println(a, b, c, d)

    // 无符号整数,只能表示非负数
    var ua uint8 = 1
    var ub uint16 = 2
    var uc uint32 = 3
    var ud uint64 = 4
    fmt.Println(ua, ub, uc, ud)

    // int 类型,在32位机器上占4个字节,在64位机器上占8个字节
    var e int = 5
    var ue uint = 5
    fmt.Println(e, ue)

    // bool 类型
    var f bool = true
    fmt.Println(f)

    // 字节类型
    var j byte = 'a'
    fmt.Println(j)

    // 字符串类型
    var g string = "abcdefg"
    fmt.Println(g)

    // 浮点数
    var h float32 = 3.14
    var i float64 = 3.141592653
    fmt.Println(h, i)
}

-------------
1 2 3 4
1 2 3 4
5 5
true
abcdefg
3.14 3.141592653
97

另外还有几个不太常用的数据类型:

  • 复数类型:complex64complex128
  • Unicode 字符类型:rune
  • 指针类型:uinitptr

3. 分支与循环

摘自《快学 Go 语言》第 3 课 —— 分支与循环

程序 = 数据结构 + 算法
程序 = 数据结构 + 算法

上面的等式并不是什么严格的数学公式,它只是对一般程序的简单认知

  1. 数据结构内存数据关系静态表示算法是数据结构从一个状态变化到另一个状态需要执行的机器指令序列
  2. 数据结构是静态的,算法是动态的
  3. 数据结构是状态,算法是状态的变化

if else 语句

Go 语言没有三元操作符a > b ? a : b,另外分支与循环语句条件不需要用括号括起来

package main

import "fmt"

func main() {
    fmt.Println(sign(max(min(24, 42), max(24, 42))))
}

func max(a int, b int) int {
    if a > b {
        return a
    }
    return b
}

func min(a int, b int) int {
    if a < b {
        return a
    }
    return b
}

func sign(a int) int {
    if a > 0 {
        return 1
    } else if a < 0 {
        return -1
    } else {
        return 0
    }
}

------------
1

switch 语句

switch 语句有两种匹配模式:一种是变量值匹配,另一种是表达式匹配

package main

import "fmt"

func main() {
    fmt.Println(prize1(60))
    fmt.Println(prize2(60))
}

// 值匹配
func prize1(score int) string {
    switch score / 10 {
    case 0, 1, 2, 3, 4, 5:
        return "差"
    case 6, 7:
        return "及格"
    case 8:
        return "良"
    default:
        return "优"
    }
}

// 表达式匹配
func prize2(score int) string {
    // 注意 switch 后面什么也没有
    switch {
        case score < 60:
            return "差"
        case score < 80:
            return "及格"
        case score < 90:
            return "良"
        default:
            return "优"
    }
}

for 循环

Go 语言虽然没有提供 while 和 do while 语句,不过这两个语句都可以使用 for 循环的形式来模拟。平时使用 while 语句来写死循环while (true) {},Go 语言可以这么写:

package main

import "fmt"

func main() {
    for {
        fmt.Println("hello world!")
    }
}

或者:

package main

import "fmt"

func main() {
    for true {
        fmt.Println("hello world!")
    }
}

for 什么条件也不带的,相当于 loop 语句。for 带一个条件的,相当于 while 语句。for 带三个条件的就是普通的 for 语句。

package main

import "fmt"

func main() {
    for i := 0; i < 10; i++ {
        fmt.Println("hello world!")
    }
}

循环控制

Go 语言支持 continuebreak 语句来控制循环,除此之外还支持 goto 语句。

4. 数组

摘自《快学 Go 语言》第 4 课 —— 低调的数组

Go 语言里面的数组其实很不常用,这是因为数组是定长静态的,一旦定义好长度就无法更改,而且不同长度的数组属于不同的类型,之间不能相互转换与赋值,用起来多有不便。

切片 (slice) 是动态的数组,是可以扩充内容增加长度的数组。当切片长度不变时,用起来和普通数组一样。当长度不同时,它们也属于相同的类型,之间可以相互赋值。这就决定了数组的应用领域都广泛的被切片取代了。

在切片的底层实现中,数组是切片的基石,是切片的特殊语法隐藏了内部的细节,让用户不能直接看到内部隐藏的数组。可以说切片是数组的一个包装

数组变量的定义

只声明类型,不赋初值,这时编译器会给数组默认赋上「零值」

package main

import "fmt"

func main() {
    var a [9]int
    fmt.Println(a)
}

------------
[0 0 0 0 0 0 0 0 0]

另外三种变量定义形式如下,效果都是一样的的:

package main

import "fmt"

func main() {
    var a = [9]int{1, 2, 3, 4, 5, 6, 7, 8, 9}
    var b [10]int = [10]int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
    c := [8]int{1, 2, 3, 4, 5, 6, 7, 8}
    fmt.Println(a)
    fmt.Println(b)
    fmt.Println(c)
}

---------------------
[1 2 3 4 5 6 7 8 9]
[1 2 3 4 5 6 7 8 9 10]
[1 2 3 4 5 6 7 8]

数组的访问

使用下标访问数组中的元素:

package main

import "fmt"

func main() {
    var squares [9]int
    for i := 0; i < len(squares); i++ {
        squares[i] = (i + 1) * (i + 1)
    }
    fmt.Println(squares)
}

--------------------
[1 4 9 16 25 36 49 64 81]

数组的下标越界检查

Go 语言会对数组访问下标越界进行编译器检查

package main

import "fmt"

func main() {
    var a = [5]int{1,2,3,4,5}
    a[101] = 255
    fmt.Println(a)
}

-----
./main.go:7:3: invalid array index 101 (out of bounds for 5-element array)

而当数组下标是变量时,Go 会在编译后的代码中插入下标越界检查的逻辑,在运行时也会提示数组下标越界。所以数组的下标访问效率是要打折扣的,比不上 C 语言的数组访问性能。

package main

import "fmt"

func main() {
    var a = [5]int{1,2,3,4,5}
    var b = 101
    a[b] = 255
    fmt.Println(a)
}

------------
panic: runtime error: index out of range

goroutine 1 [running]:
main.main()
    /Users/qianwp/go/src/github.com/pyloque/practice/main.go:8 +0x3d
exit status 2

数组赋值

同样的子元素类型并且是同样长度的数组才可以相互赋值,否则就是不同的数组类型,不能赋值。数组的赋值本质上是一种浅拷贝操作,赋值的两个数组变量的值不会共享

package main

import "fmt"

func main() {
    var a = [9]int{1, 2, 3, 4, 5, 6, 7, 8, 9}
    var b [9]int
    b = a
    a[0] = 12345
    fmt.Println(a)
    fmt.Println(b)
}

--------------------------
[12345 2 3 4 5 6 7 8 9]
[1 2 3 4 5 6 7 8 9]

从上面代码的运行结果中可以看出赋值后的两个数组并没有共享内部元素。如果数组的长度很大,那么拷贝操作是有一定的开销的,使用的时候要多加注意。

数组的遍历

数组除了可以使用下标进行遍历之外,还可以使用range关键字来进行遍历。range遍历提供了下面两种形式:

package main

import "fmt"

func main() {
    var a = [5]int{1,2,3,4,5}
    for index := range a {
  fmt.Println(index, a[index])
 }
 for index, value := range a {
        fmt.Println(index, value)
    }
}

------------
0 1
1 2
2 3
3 4
4 5
0 1
1 2
2 3
3 4
4 5

5. 切片

摘自《快学 Go 语言》第 5 课 —— 神奇的切片

学过 Java 语言的人会比较容易理解切片,因为它的内部结构非常类似于 ArrayList,ArrayList 的内部实现也是一个数组。当数组容量不够需要扩容时,就会换新的数组,还需要将老数组的内容拷贝到新数组。ArrayList 内部有两个非常重要的属性capacitylengthcapacity表示内部数组的总长度length表示当前已经使用的数组的长度length永远不能超过capacity

Go 语言中的切片
Go 语言中的切片

上图中的一个切片变量包含三个域,分别是底层数组的指针切片的长度length切片的容量capacity。切片支持 append 操作可以将新内容追加到底层数组,也就是填充上图中的灰色格子。如果格子满了,切片就需要扩容,底层的数组就会更换

切片的创建

切片的创建有多种方式,先来看最通用的创建方法,那就是内置的 make 函数:

package main

import "fmt"

func main() {
    var s1 []int = make([]int, 5, 8)
    var s2 []int = make([]int, 8) // 满容切片
    fmt.Println(s1)
    fmt.Println(s2)
}

-------------
[0 0 0 0 0]
[0 0 0 0 0 0 0 0]

使用 make 函数创建切片,需要提供三个参数:切片的类型切片的长度容量。其中第三个参数是可选的,如果不声明切片的容量,那么长度和容量相等,也就是说切片是满容的。

切片和普通变量一样,也可以使用类型自动推导,省区类型定义以及var关键字:

package main

import "fmt"

func main() {
    var s1 = make([]int, 5, 8)
    s2 := make([]int, 8)
    fmt.Println(s1)
    fmt.Println(s2)
}

-------------
[0 0 0 0 0]
[0 0 0 0 0 0 0 0]

切片的初始化

使用 make 函数创建的切片内容是「零值切片」,也就是内部数组的元素都是零值。Go 语言还提供了另一种创建切片的语法,允许我们给它赋初值,使用这种方式创建的切片是满容的

package main

import "fmt"

func main() {
    var s []int = []int{1,2,3,4,5}  // 满容的
    fmt.Println(s, len(s), cap(s))
}

---------
[1 2 3 4 5] 5 5

Go 语言提供了内置函数len()cap()可以直接获得切片的长度容量属性。

空切片

在创建切片时,还有两个非常特殊的情况需要考虑,那就是容量和长度都是零的切片,叫做「空切片」。这个不同与之前提到的「零值切片」。

package main

import "fmt"

func main() {
    var s1 []int
    var s2 []int = []int{}
    var s3 []int = make([]int, 0)
    fmt.Println(s1, s2, s3)
    fmt.Println(len(s1), len(s2), len(s3))
    fmt.Println(cap(s1), cap(s2), cap(s3))
}

-----------
[] [] []
0 0 0
0 0 0

上面三种形式创建的切片都是「空切片」,不过在内部结构上这三种形式还是有所差异的,准确来说第一种应该称为「nil 切片」,但是二者形式上几乎一模一样,用起来差不多没有区别,所以初级用户暂时可以不必区分。

切片的赋值

切片的赋值是一次浅拷贝操作,拷贝的是切片变量的三个域。拷贝前后两个变量共享底层数组,对一个切片的修改会影响另一个切片的内容。

package main

import "fmt"

func main() {
    var s1 = make([]int, 5, 8)
    // 切片的访问和数组差不多
    for i := 0; i < len(s1); i++ {
        s1[i] = i + 1
    }
    var s2 = s1
    fmt.Println(s1, len(s1), cap(s1))
    fmt.Println(s2, len(s2), cap(s2))

    // 尝试修改切片内容
    s2[0] = 255
    fmt.Println(s1)
    fmt.Println(s2)
}

--------
[1 2 3 4 5] 5 8
[1 2 3 4 5] 5 8
[255 2 3 4 5]
[255 2 3 4 5]

从上面的输出可以看到赋值的两切片共享了底层数组

切片的遍历

切片在遍历的语法上和数组是一样的,除了支持下标遍历外,那就是使用 range 关键字。

package main


import "fmt"


func main() {
    var s = []int{1,2,3,4,5}
    for index := range s {
        fmt.Println(index, s[index])
    }
    for index, value := range s {
        fmt.Println(index, value)
    }
}

--------
0 1
1 2
2 3
3 4
4 5
0 1
1 2
2 3
3 4
4 5

切片的追加

之前有提到切片是动态的数组,其长度是可以变化的,可以通过追加操作改变切片的长度

切片每一次追加后都会形成新的切片变量,如果底层数组没有扩容,那么追加前后的两个切片变量就共享底层数组;如果底层数组扩容了,那么追加前后的底层数组是分离的不共享的

如果底层数组是共享的,那么一个切片的内容变化就会影响到另一个切片,这点需要特别注意。

package main

import "fmt"

func main() {
    var s1 = []int{1,2,3,4,5}
    fmt.Println(s1, len(s1), cap(s1))

    // 对满容的切片进行追加会分离底层数组
    var s2 = append(s1, 6)
    fmt.Println(s1, len(s1), cap(s1))
    fmt.Println(s2, len(s2), cap(s2))

    // 对非满容的切片进行追加会共享底层数组
    var s3 = append(s2, 7)
    fmt.Println(s2, len(s2), cap(s2))
    fmt.Println(s3, len(s3), cap(s3))
}

--------------------------
[1 2 3 4 5] 5 5
[1 2 3 4 5] 5 5
[1 2 3 4 5 6] 6 10
[1 2 3 4 5 6] 6 10
[1 2 3 4 5 6 7] 7 10

正是因为切片追加后是新的切片变量,所以 Go 编译器禁止追加了切片后不使用这个新的切片变量,以避免用户以为追加操作的返回值和原切片变量是同一个变量。

package main

import "fmt"

func main() {
    var s1 = []int{1,2,3,4,5}
    append(s1, 6)
    fmt.Println(s1)
}

--------------
./main.go:7:8: append(s1, 6) evaluated but not used

如果真的不需要使用这个新的变量,可以将 append 的结果赋值给下划线变量_

下划线变量_是 Go 语言特殊的内置变量,它就像一个黑洞,可以将任意变量赋值给它,但是却不能读取这个特殊变量

package main

import "fmt"

func main() {
    var s1 = []int{1,2,3,4,5}
    _ = append(s1, 6)
    fmt.Println(s1)
}

----------
[1 2 3 4 5]

还需要注意的是追加虽然会导致底层数组发生扩容、更换的新的数组,但是旧数组并不会立即被销毁被回收,因为老切片还指向着旧数组

切片的域是只读的

需要仔细思考

我们刚才说切片的长度是可以变化的,为什么又说切片是只读的呢?这不是矛盾么。这是为了提醒读者注意切片追加后形成了一个新的切片变量,而老的切片变量的三个域其实并不会改变,改变的只是底层的数组。这里说的是切片的「域」是只读的,而不是说切片是只读的。切片的「域」就是组成切片变量的三个部分,分别是底层数组的指针切片的长度切片的容量

切片的切割

切片的切割可以类比字符串的子串,它并不是要把切片割断,而是从母切片中拷贝一个子切片出来,子切片和母切片共享底层数组

package main

import "fmt"

func main() {
    var s1 = []int{1,2,3,4,5,6,7}
    // start_index 和 end_index,不包含 end_index
    // [start_index, end_index)
    var s2 = s1[2:5] 
    fmt.Println(s1, len(s1), cap(s1))
    fmt.Println(s2, len(s2), cap(s2))
}

------------
[1 2 3 4 5 6 7] 7 7
[3 4 5] 3 5

上面的输出需要特别注意的是:既然切割前后共享底层数据,那为什么容量不一样呢?下图可以解释这个问题。

切片的切割
切片的切割

可以注意到子切片的内部数据指针指向了数组的中间位置,而不再是数组的开头了。子切片容量的大小从中间的位置开始直到切片末尾的长度,母子切片依旧共享底层数组

子切片语法上要提供起始结束位置,这两个位置都是可选的。不提供起始位置,默认就是从母切片的初始位置开始(不是底层数组的初始位置)。不提供结束位置,默认就结束到母切片尾部(是长度线,不是容量线)。

package main

import "fmt"

func main() {
    var s1 = []int{1, 2, 3, 4, 5, 6, 7}
    var s2 = s1[:5]
    var s3 = s1[3:]
    var s4 = s1[:]
    fmt.Println(s1, len(s1), cap(s1))
    fmt.Println(s2, len(s2), cap(s2))
    fmt.Println(s3, len(s3), cap(s3))
    fmt.Println(s4, len(s4), cap(s4))
}

-----------
[1 2 3 4 5 6 7] 7 7
[1 2 3 4 5] 5 7
[4 5 6 7] 4 4
[1 2 3 4 5 6 7] 7 7

上面的s1[:]普通的切片赋值没有区别,同样是共享底层数组,同样是浅拷贝。另外,Go 语言中切片的下标不支持负数

数组变切片

对数组进行切割可以转换成切片。切片将原数组作为内部底层数组,也就是说修改了原数组会影响到新切片,对切片的修改也会影响到原数组

package main

import "fmt"

func main() {
    var a = [10]int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
    var b = a[2:6]
    fmt.Println(b)
    a[4] = 100
    fmt.Println(b)
}

-------
[3 4 5 6]
[3 4 100 6]

copy 函数

Go 语言还内置了一个 copy 函数,用来进行切片的深拷贝。不过其实也没那么深,只是深到底层的数组而已。如果数组里面装的是指针,比如[]*int类型,那么指针指向的内容还是共享的。

func copy(dst, src []T) int

copy 函数不会因为原切片和目标切片的长度问题而额外分配底层数组的内存,它只负责拷贝数组的内容,从原切片拷贝到目标切片,拷贝的量是原切片和目标切片长度的较小值min(len(src), len(dst)),函数返回的是拷贝的实际长度

package main

import "fmt"

func main() {
    var s = make([]int, 5, 8)
    for i:=0;i<len(s);i++ {
        s[i] = i+1
    }
    fmt.Println(s)
    var d = make([]int, 2, 6)
    var n = copy(d, s)
    fmt.Println(n, d)
}

-----------
[1 2 3 4 5]
2 [1 2]

切片的扩容点

比较短的切片扩容时,系统会多分配 100% 的空间,也就是说分配的数组容量是切片长度的 2 倍。但当切片长度超过 1024 时,扩容策略调整为多分配 25% 的空间,这是为了避免空间的过多浪费

package main

import "fmt"

func main() {
    s1 := make([]int, 6)
    s2 := make([]int, 1024)
    s1 = append(s1, 1)
    s2 = append(s2, 2)
    fmt.Println(len(s1), cap(s1))
    fmt.Println(len(s2), cap(s2))
}

-------------------------------------------
7 12
1025 1344

6. 字典

摘自《快学 Go 语言》第 6 课 —— 字典

数组切片让我们具备了可以操作一块连续内存的能力,它是对同质元素的统一管理。而字典则赋予了不连续不同类的内存变量的关联性,它表达的是一种因果关系,字典的 key 是因,字典的 value 是果。

指针、数组切片和字典都是容器型变量。字典比数组切片在使用上要简单很多,但是内部结构却非常复杂。

字典的创建

在创建字典时,必须要给 key 和 value 指定类型。创建字典也可以使用 make 函数:

package main

import "fmt"

func main() {
    var m map[int]string = make(map[int]string)
    fmt.Println(m, len(m))
}

----------
map[] 0

使用 make 函数创建的字典是空的,长度为零,内部没有任何元素。如果需要给字典提供初始化的元素,就需要使用另一种创建字典的方式:

package main

import "fmt"

func main() {
    var m map[int]string = map[int]string{
        90: "优秀",
        80: "良好",
        60: "及格",  // 注意这里逗号不可缺少,否则会报语法错误
    }
    fmt.Println(m, len(m))
}

---------------
map[90:优秀 80:良好 60:及格] 3

字典变量同样支持类型推导,上面的变量定义可以简写成:

var m = map[int]string {
    90: "优秀",
    80: "良好",
    60: "及格",
}

如果提前知道字典内部键值对的数量,那么还可以给 make 函数传递一个整数值,通知运行时提前分配好相应的内存,这样可以避免字典在长大的过程中要经历的多次扩容操作

var m = make(map[int]string, 16)

字典的读写

字典可以使用中括号[]读写内部元素,使用 delete 函数来删除元素

package main

import "fmt"

func main() {
      var fruits = map[string]int {
          "apple": 2,
          "banana": 5,
          "orange": 8,
      }
      // 读取元素
      var score = fruits["banana"]
      fmt.Println(score)

      // 增加或修改元素
      fruits["pear"] = 3
      fmt.Println(fruits)

      // 删除元素
      delete(fruits, "pear")
      fmt.Println(fruits)
}

-----------------------
5
map[apple:2 banana:5 orange:8 pear:3]
map[orange:8 apple:2 banana:5]

字典 key 不存在会怎么样?

删除操作时,如果对应的 key 不存在,delete 函数会静默处理读操作时,如果 key 不存在,也不会抛出异常,它会返回 value 类型对应的零值。

可以通过字典的特殊语法来判断对应的 key 是否存在

package main

import "fmt"

func main() {
    var fruits = map[string]int {
        "apple": 2,
        "banana": 5,
        "orange": 8,
    }

    var score, ok = fruits["durin"]
    if ok {
        fmt.Println(score)
    } else {
        fmt.Println("durin not exists")
    }

    fruits["durin"] = 0
    score, ok = fruits["durin"]
    if ok {
        fmt.Println(score)
    } else {
        fmt.Println("durin still not exists")
    }
}

-------------
durin not exists
0

字典的下标读取可以返回两个值,使用第二个返回值都表示对应的 key 是否存在。它只是 Go 语言提供的语法糖,内部并没有太多的玄妙。

正常的函数调用可以返回多个值,但是并不具备这种“随机应变”的特殊能力 —— 「多态返回值」。

字典的遍历

字典的遍历提供了以下两种方式:一种是需要携带 value,另一种是只需要 key,需要使用到 Go 语言的 range 关键字:

package main

import "fmt"

func main() {
    var fruits = map[string]int {
        "apple": 2,
        "banana": 5,
        "orange": 8,
    }

    for name, score := range fruits {
        fmt.Println(name, score)
    }

    for name := range fruits {
        fmt.Println(name)
    }
}

------------
orange 8
apple 2
banana 5
apple
banana
orange

然而,Go 语言的字典并没有提供例如keys()values()这样的方法,意味着如果要获取 key 列表,就得自己循环一下:

package main

import "fmt"

func main() {
    var fruits = map[string]int {
        "apple": 2,
        "banana": 5,
        "orange": 8,
    }

    var names = make([]string, 0, len(fruits))
    var scores = make([]int, 0, len(fruits))

    for name, score := range fruits {
        names = append(names, name)
        scores = append(scores, score)
    }

    fmt.Println(names, scores)
}

----------
[apple banana orange] [2 5 8]

注意:遍历的时候,直接得到的 value拷贝过后的,会影响性能。在遍历中,使用map[key]的方式可以直接用索引获取数据,速度要比使用 value 快将近一倍,但要注意指针安全的问题。

线程安全

Go 语言的内置字典不是线程安全的,如果需要线程安全,必须使用锁来控制

字典变量里存的是什么?

字典变量里存的只是一个地址指针,这个指针指向字典的头部对象。所以字典变量占用的空间是一个字,也就是一个指针的大小,64 位机器是 8 字节,32 位机器是 4 字节。

字典变量中的地址指针
字典变量中的地址指针

可以使用 unsafe 包提供的Sizeof函数来计算一个变量的大小:

package main

import (
    "fmt"
    "unsafe"
)

func main() {
    var m = map[string]int{
        "apple":  2,
        "pear":   3,
        "banana": 5,
    }
    fmt.Println(unsafe.Sizeof(m))
}

------
8

7. 字符串

摘自《快学 Go 语言》第 7 课 —— 字符串

字符串通常有两种设计,一种是「字符」串,一种是「字节」串。「字符」串中的每个字都是定长的,而「字节」串中每个字是不定长的。Go 语言里的字符串是「字节」串,英文字符占用 1 个字节,非英文字符占多个字节。这意味着无法通过位置来快速定位出一个完整的字符来,而必须通过遍历的方式来逐个获取单个字符

我们所说的字符通常是指 unicode 字符,一个 unicode 字符通常用 4 个字节来表示,对应的 Go 语言中的字符 rune 占 4 个字节。

在 Go 语言的源码中可以看到,rune 类型是一个衍生类型,它在内存里面使用int32类型的 4 个字节存储。

type rune int32
字节 byte 和字符 rune 的关系
字节 byte 和字符 rune 的关系

其中 codepoint 是每个「字」的实际偏移量。Go 语言的字符串采用 utf-8 编码,中文汉字通常需要占用 3 个字节英文只需要 1 个字节len()函数得到的是字节的数量,通过下标来访问字符串得到的是「字节」。

按字节遍历

字符串可以通过下标来访问内部字节数组具体位置上的字节,字节是 byte 类型:

package main

import "fmt"

func main() {
    var s = "嘻哈china"
    for i:=0;i<len(s);i++ {
        fmt.Printf("%x ", s[i])
    }

}

-----------
e5 98 bb e5 93 88 63 68 69 6e 61

按字符 rune 遍历

package main

import "fmt"

func main() {
    var s = "嘻哈china"
    for codepoint, runeValue := range s {
        fmt.Printf("%d %d ", codepoint, int32(runeValue))
    }
}

-----------
0 22075 3 21704 6 99 7 104 8 105 9 110 10 97

对字符串进行 range 遍历,每次迭代出两个变量codepointruneValuecodepoint 表示字符起始位置runeValue表示对应的 unicode 编码(类型是 rune)。

字符串的内存表示

字符串的内存结构
字符串的内存结构

字符串的内存结构不仅包含前面提到的字节数组,编译器还为它分配了头部字段来存储 长度信息指向底层字节数组的指针,如上图所示,结构非常类似于切片,区别是头部少了一个容量字段。

字符串是只读的

可以使用下标来读取字符串指定位置的字节,但是无法修改这个位置上的字节内容。如果尝试使用下标赋值,编译器在语法上直接拒绝:

package main

func main() {
    var s = "hello"
    s[0] = 'H'
}
--------
./main.go:5:7: cannot assign to s[0]

字符串的切割

字符串在内存形式上比较接近于切片,它也可以像切片一样进行切割来获取子串。子串和母串共享底层字节数组

package main

import "fmt"

func main() {
    var s1 = "hello world"
    var s2 = s1[3:8]
    fmt.Println(s2)
}

-------
lo wo

字节切片和字符串的相互转换

在使用 Go 语言进行网络编程时,经常需要将来自网络的字节流转换成内存字符串,同时也需要将内存字符串转换成网络字节流。Go 语言直接内置了字节切片和字符串的相互转换语法

package main

import "fmt"

func main() {
    var s1 = "hello world"
    var b = []byte(s1)  // 字符串转字节切片
    var s2 = string(b)  // 字节切片转字符串
    fmt.Println(b)
    fmt.Println(s2)
}

--------
[104 101 108 108 111 32 119 111 114 108 100]
hello world

注意:字节切片和字符串的底层字节数组不是共享的,底层字节数组会被拷贝。这是因为字节切片的底层数组内容是可以修改的,而字符串的底层字节数组是只读的,如果共享了,就会导致字符串的只读属性不再成立

8. 结构体

摘自《快学 Go 语言》第 8 课 —— 结构体

Go 语言结构体里面装的是基础类型、数组、切片、字典以及其他类型结构体等。

Go 语言中的结构体
Go 语言中的结构体

结构体类型的定义

结构体和其它高级语言里的「类」比较相似:

type Circle struct {
    x int
    y int
    Radius int
}

需要特别注意的是结构体内部变量的大小写首字母大写公开变量首字母小写内部变量,分别相当于类成员变量的 publicprivate 类别。内部变量只有属于同一个 package 的代码才能直接访问。

结构体变量的创建

最常见的创建形式是「KV 形式」,通过显式指定结构体内部字段的名称和初始值来初始化结构体,没有指定初值的字段会自动初始化为相应类型的「零值」

package main

import "fmt"

type Circle struct {
    x      int
    y      int
    Radius int
}

func main() {
    var c1 Circle = Circle{
        x:      100,
        y:      100,
        Radius: 50,
    }
    var c2 Circle = Circle{
        Radius: 50,
    }
    var c3 Circle = Circle{}
    fmt.Printf("%+v\n", c1)
    fmt.Printf("%+v\n", c2)
    fmt.Printf("%+v\n", c3)
}

----------
{x:100 y:100 Radius:50}
{x:0 y:0 Radius:50}
{x:0 y:0 Radius:0}

结构体的第二种创建形式是不指定字段名称来顺序字段初始化,需要显式提供所有字段的初值,一个都不能少。这种形式称之为「顺序形式」

package main

import "fmt"

type Circle struct {
    x      int
    y      int
    Radius int
}

func main() {
    var c Circle = Circle{100, 100, 50}
    fmt.Printf("%+v\n", c)
}

-------
{x:100 y:100 Radius:50}

结构体变量和普通变量都有指针形式,使用取地址符&就可以得到结构体的指针类型

package main

import "fmt"

type Circle struct {
    x      int
    y      int
    Radius int
}

func main() {
    var c *Circle = &Circle{100, 100, 50}
    fmt.Printf("%+v\n", c)
}

-----------
&{x:100 y:100 Radius:50}

结构体变量创建的第三种形式是使用全局的new()函数来创建一个「零值」结构体,所有字段都被初始化为相应类型的零值:

package main

import "fmt"

type Circle struct {
    x      int
    y      int
    Radius int
}

func main() {
    var c *Circle = new(Circle)
    fmt.Printf("%+v\n", c)
}

----------
&{x:0 y:0 Radius:0}

第四种创建形式也是零值初始化:

package main

import "fmt"

type Circle struct {
    x      int
    y      int
    Radius int
}

func main() {
    var c Circle
    fmt.Printf("%+v\n", c)
}

----------
{x:0 y:0 Radius:0}

三种零值初始化形式对比:

var c1 Circle = Circle{}
var c2 Circle
var c3 *Circle = new(Circle)

零值结构体和 nil 结构体

nil 结构体是指结构体指针变量没有指向一个实际存在的内存。这样的指针变量只会占用 1 个指针的存储空间,也就是一个机器字的内存大小。

var c *Circle = nil

零值结构体则会实际占用内存空间,只不过每个字段都是零值

结构体的内存大小

Go 语言的 unsafe 包提供了获取结构体内存占用的函数Sizeof()

package main

import "fmt"
import "unsafe"

type Circle struct {
    x      int
    y      int
    Radius int
}

func main() {
    var c Circle = Circle{Radius: 50}
    fmt.Println(unsafe.Sizeof(c))
}

-------
24

64 位机器上每个 int 类型都是 8 字节。而 32 位机器上,Circle 结构体就只会占用 12 字节

结构体的拷贝

结构体之间可以相互赋值,本质上是一次浅拷贝操作,拷贝了结构体内部的所有字段

结构体指针之间也可以相互赋值,本质上也是一次浅拷贝操作,不过它拷贝的仅仅是指针地址值结构体的内容是共享的

package main

import "fmt"

type Circle struct {
    x      int
    y      int
    Radius int
}

func main() {
    var c1 Circle = Circle{Radius: 50}
    var c2 Circle = c1
    fmt.Printf("%+v\n", c1)
    fmt.Printf("%+v\n", c2)
    c1.Radius = 100
    fmt.Printf("%+v\n", c1)
    fmt.Printf("%+v\n", c2)

    var c3 *Circle = &Circle{Radius: 50}
    var c4 *Circle = c3
    fmt.Printf("%+v\n", c3)
    fmt.Printf("%+v\n", c4)
    c3.Radius = 100
    fmt.Printf("%+v\n", c3)
    fmt.Printf("%+v\n", c4)
}

----------------------
{x:0 y:0 Radius:50}
{x:0 y:0 Radius:50}
{x:0 y:0 Radius:100}
{x:0 y:0 Radius:50}
&{x:0 y:0 Radius:50}
&{x:0 y:0 Radius:50}
&{x:0 y:0 Radius:100}
&{x:0 y:0 Radius:100}

通过观察 Go 语言的底层源码,可以发现所有的 Go 语言内置的高级数据结构都是由结构体来完成的

结构体中的数组和切片

之前分析了数组与切片在内存形式上的区别:数组只有「体」切片除了「体」之外,还有「头」部。切片的头部和内容体是分离的,使用指针关联起来。

package main

import "fmt"
import "unsafe"

type ArrayStruct struct {
    value [10]int
}

type SliceStruct struct {
    value []int
}

func main() {
    var as = ArrayStruct{[...]int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}}
    var ss = SliceStruct{[]int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}}
    fmt.Println(unsafe.Sizeof(as), unsafe.Sizeof(ss))
}

-------------
80 24

注意代码中的数组初始化使用了[...]语法糖,表示让编译器自动推导数组的长度

结构体的参数传递

函数调用时参数传递结构体变量,值传递涉及到结构体字段的浅拷贝指针传递则会共享结构体内容,只拷贝指针地址,规则上和赋值是等价的:

package main

import "fmt"

type Circle struct {
    x      int
    y      int
    Radius int
}

func expandByValue(c Circle) {
    c.Radius *= 2
}

func expandByPointer(c *Circle) {
    c.Radius *= 2
}

func main() {
    var c = Circle{Radius: 50}
    expandByValue(c)
    fmt.Println(c)
    expandByPointer(&c)
    fmt.Println(c)
}

---------
{0 0 50}
{0 0 100}

结构体方法

package main

import (
    "fmt"
    "math"
)

type Circle struct {
    x      int
    y      int
    Radius int
}

// 面积
func (c Circle) Area() float64 {
    return math.Pi * float64(c.Radius) * float64(c.Radius)
}

// 周长
func (c Circle) Circumference() float64 {
    return 2 * math.Pi * float64(c.Radius)
}

func main() {
    var c = Circle{Radius: 50}
    fmt.Println(c.Area(), c.Circumference())
    // 指针变量调用方法形式上是一样的
    var pc = &c
    fmt.Println(pc.Area(), pc.Circumference())
}

-----------
7853.981633974483 314.1592653589793
7853.981633974483 314.1592653589793
  • Go 语言不喜欢类型的隐式转换,所以需要将整型显式转换成浮点型
  • Go 语言结构体方法里面也没有selfthis这样的关键字来指代当前的对象
  • Go 语言的方法名称也分首字母大小写,它的权限规则和字段一样,首字母大写就是公开方法,首字母小写就是内部方法,只有归属与同一个包的代码才可以访问
  • 结构体的值类型和指针类型访问内部字段和方法在形式上是一样的,都是使用句点.操作符

结构体的指针方法

结构体的值方法无法改变结构体内部状态。例如,使用下面的方法无法扩大 Circle 的半径:

func (c Circle) expand() {
    c.Radius *= 2
}

这是因为参数传递是值传递,复制了一份结构体内容。要想修改结构体内部状态,就必须要使用结构体的指针方法

func (c *Circle) expand() {
    c.Radius *= 2
}

通过指针访问内部的字段需要 2 次内存读取操作,第一步是取得指针地址,第二步是读取地址的内容它比值访问要慢。但是在方法调用时,指针传递可以避免结构体的拷贝操作,结构体比较大时,这种性能的差距就会比较明显。

还有一些特殊的结构体不允许被复制,比如结构体内部包含有锁时,这时就必须使用它的指针形式来定义方法,否则会发生一些莫名其妙的问题。

内嵌结构体

结构体作为一种变量它可以放进另外一个结构体作为一个字段来使用,这种内嵌结构体的形式在 Go 语言里称之为「组合」

package main

import "fmt"

type Point struct {
    x int
    y int
}

func (p Point) show() {
    fmt.Println(p.x, p.y)
}

type Circle struct {
    loc    Point
    Radius int
}

func main() {
    var c = Circle{
        loc: Point{
            x: 100,
            y: 100,
        },
        Radius: 50,
    }
    fmt.Printf("%+v\n", c)
    fmt.Printf("%+v\n", c.loc)
    fmt.Printf("%d %d\n", c.loc.x, c.loc.y)
    c.loc.show()
}

----------------
{loc:{x:100 y:100} Radius:50}
{x:100 y:100}
100 100
100 100

匿名内嵌结构体

还有一种特殊的内嵌结构体形式,内嵌的结构体不提供名称。这时外面的结构体将直接继承内嵌结构体所有的内部字段和方法,匿名的结构体字段将会自动获得以结构体类型的名字命名的字段名称

package main

import "fmt"

type Point struct {
    x int
    y int
}

func (p Point) show() {
    fmt.Println(p.x, p.y)
}

type Circle struct {
    Point // 匿名内嵌结构体
    Radius int
}

func main() {
    var c = Circle{
        Point: Point{
            x: 100,
            y: 100,
        },
        Radius: 50,
    }
    fmt.Printf("%+v\n", c)
    fmt.Printf("%+v\n", c.Point)
    fmt.Printf("%d %d\n", c.x, c.y) // 继承了字段
    fmt.Printf("%d %d\n", c.Point.x, c.Point.y)
    c.show() // 继承了方法
    c.Point.show()
}

-------
{Point:{x:100 y:100} Radius:50}
{x:100 y:100}
100 100
100 100
100 100
100 100

这里的继承仅仅是形式上的语法糖c.show()转换成二进制代码后和c.Point.show()等价的c.xc.Point.x也是等价的

Go 语言的结构体没有多态性

Go 语言不是面向对象语言在于它的结构体明确不支持多态,外结构体的方法不能覆盖内部结构体的方法

多态是指父类定义的方法可以调用子类实现的方法,不同的子类有不同的实现,从而给父类的方法带来了多样的不同行为

package main

import "fmt"

type Fruit struct{}

func (f Fruit) eat() {
    fmt.Println("eat fruit")
}

func (f Fruit) enjoy() {
    fmt.Println("smell first")
    f.eat()
    fmt.Println("clean finally")
}

type Apple struct {
    Fruit
}

func (a Apple) eat() {
    fmt.Println("eat apple")
}

type Banana struct {
    Fruit
}

func (b Banana) eat() {
    fmt.Println("eat banana")
}

func main() {
    var apple = Apple{}
    var banana = Banana{}
    apple.enjoy()
    banana.enjoy()
}

----------
smell first
eat fruit
clean finally
smell first
eat fruit
clean finally

可以看到,enjoy方法调用的eat方法还是 Fruit 自己的eat方法,它没能被外面的结构体方法覆盖掉,这意味着面向对象的代码习惯不能直接用到 Go 语言中

9. 接口

摘自《快学 Go 语言》第 9 课 —— 接口

接口是一个对象的对外能力的展现,我们使用一个对象时,往往不需要知道一个对象的内部复杂实现,通过它暴露出来的接口,就知道了这个对象具备哪些能力以及如何使用这个能力。

Go 语言的接口类型非常特别,它的作用和 Java 语言的接口一样,但是在形式上有很大的差别。Java 语言需要在类的定义上显式实现了某些接口,才可以说这个类具备了接口定义的能力。但是 Go 语言的接口是隐式的,只要结构体上定义的方法在形式上(名称、参数和返回值)和接口定义的一样,那么这个结构体就自动实现了这个接口,我们就可以使用这个接口变量来指向结构体对象。

package main

import "fmt"

// 可以闻
type Smellable interface {
    smell(s string)
}

// 可以吃
type Eatable interface {
    eat(s string)
}

// 苹果既可以闻又可以吃
type Apple struct {
    name string
}

func (a Apple) smell(s string) {
    fmt.Println(s + " can smell")
}

func (a Apple) eat(s string) {
    fmt.Println(s + " can eat")
}

// 花只可以闻
type Flower struct {
    name string
}

func (f Flower) smell(s string) {
    fmt.Println(s + " can smell")
}

func main() {
    var s1 Smellable
    var s2 Eatable
    var apple = Apple{
        name: "Apple",
    }
    var flower = Flower{
        name: "Flower",
    }
    s1 = apple
    s1.smell(apple.name)
    s1 = flower
    s1.smell(flower.name)
    s2 = apple
    s2.eat(apple.name)
}

--------------------
apple can smell
flower can smell
apple can eat

Apple 结构体同时实现了SmellableEatable这两个接口,而 Flower 结构体只实现了Smellable接口。可以看到在 Go 语言中,无需使用类似于 Java 语言的 implements 关键字,结构体和接口就自动产生了关联。

空接口

如果一个接口里面没有定义任何方法,那么它就是空接口,任意结构体都隐式的实现了空接口。

Go 语言为了避免用户重复定义,自己内置了一个名为interface{}空接口。空接口里没有方法,所以它也不具备任何能力,其作用相当于 Java 的 Object 类型,可以容纳任意对象,是一个万能容器。比如一个字典的 key 是字符串,但是希望 value 可以容纳任意类型的对象,类似于 Java 语言的 Map 类型,这时候就可以使用空接口类型interface{}

package main

import "fmt"

func main() {
    var user = map[string]interface{}{
        "age":     30,
        "address": "Guangdong Guangzhou",
        "married": true,
    }
    fmt.Println(user)
    // 类型转换语法
    var age = user["age"].(int)
    var address = user["address"].(string)
    var married = user["married"].(bool)
    fmt.Println(age, address, married)
}

-------------
map[age:30 address:Guangdong Guangzhou married:true]
30 Guangdong Guangzhou true

因为 user 字典变量的类型是map[string]interface{},从这个字典中直接读取得到的 value 类型是interface{},所以需要通过类型转换才能得到期望的变量。

接口变量的本质

可以将 Go 语言中的接口看成一个特殊的容器:这个容器只能容纳一个对象,只有实现了这个接口类型的对象才可以放进去。

Go 语言中的接口变量
Go 语言中的接口变量

查看 Go 语言的源码发现,接口变量也是由结构体来定义的。这个结构体包含两个指针字段,所以接口变量的内存占用是 2 个机器字

package main

import "fmt"
import "unsafe"

func main() {
    var s interface{}
    fmt.Println(unsafe.Sizeof(s))
    var arr = [10]int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
    fmt.Println(unsafe.Sizeof(arr))
    s = arr
    fmt.Println(unsafe.Sizeof(s))
}

----------
16
80
16

用接口来模拟多态

接口是一种特殊的容器,可以容纳多种不同的对象。那么只要这些对象都同样实现了接口定义的方法,再将容纳的对象替换成另一个对象,就可以模拟实现多态

package main

import (
    "fmt"
)

type Fruitable interface {
    eat()
}

type Fruit struct {
    Name string // 属性变量
    Fruitable   // 匿名内嵌接口变量
}

func (f Fruit) want() {
    fmt.Printf("I like ")
    f.eat() // 外结构体会自动继承匿名内嵌变量的方法
}

type Apple struct{}

func (a Apple) eat() {
    fmt.Println("eating apple")
}

type Banana struct{}

func (b Banana) eat() {
    fmt.Println("eating banana")
}

func main() {
    var f1 = Fruit{"Apple", Apple{}}
    var f2 = Fruit{"Banana", Banana{}}
    f1.want()
    f2.want()
}

---------
I like eating apple
I like eating banana

使用这种方式模拟多态本质上是通过组合属性变量 Name接口变量 Fruitable 来做到的。属性变量对象的数据,而接口变量对象的功能,将它们组合到一块就形成了一个完整的多态性结构体。

接口的组合继承

接口的定义也支持组合继承:

type Smellable interface {
    smell()
}

type Eatable interface {
    eat()
}

type Fruitable interface {
    Smellable
    Eatable
}

这时 Fruitable 接口就自动包含了smell()eat()两个方法,和下面的定义是等价的:

type Fruitable interface {
    smell()
    eat()
}

接口变量的赋值

变量的赋值本质上是一次内存浅拷贝切片的赋值是拷贝了切片头字符串的赋值是拷贝了字符串的头部,而数组的赋值则是直接拷贝了整个数组

package main

import "fmt"

type Rect struct {
    Width  int
    Height int
}

func main() {
    var a interface{}
    var r = Rect{50, 50}
    a = r

    var rx = a.(Rect)
    r.Width = 100
    r.Height = 100
    fmt.Println(rx)
}

------
{50 50}

可以根据上面的输出结果推断出结构体的内存发生了复制,这是因为赋值a = r类型转换rx = a.(Rect)两者都发生了数据内存的赋值——浅拷贝

指向指针的接口变量

将上面的例子改成指针,将接口变量指向结构体指针,就会得到不一样的结果:

package main

import "fmt"

type Rect struct {
    Width  int
    Height int
}

func main() {
    var a interface{}
    var r = Rect{50, 50}
    a = &r // 指向了结构体指针

    var rx = a.(*Rect)
    r.Width = 100
    r.Height = 100
    fmt.Println(rx)
}

-------
&{100 100}

可以看到指针变量 rx 指向的内存和变量 r 的内存是同一份,因为在类型转换的过程中只发生了指针变量的内存复制,而指针变量指向的内存是共享的。

10. 错误和异常

摘自《快学 Go 语言》第 10 课 —— 错误和异常

错误接口

Go 语言规定凡是实现了错误接口的对象都是错误对象,这个错误接口只定义了一个方法:

type error interface {
    Error() string
}

编写一个错误对象很简单:写一个结构体,然后挂在Error方法里

package main

import "fmt"

type SomeError struct {
    Reason string
}

func (s SomeError) Error() string {
    return s.Reason
}

func main() {
    var err error = SomeError{"something happened"}
    fmt.Println(err)
}

---------------
something happened

Go 语言内置了一个通用错误类型,在 errors 包里面。这个包还提供了一个New()函数来方便的创建一个通用错误:

var err = errors.New("something happened")

还可以使用 fmt 包提供的Errorf函数来给错误字符串定制一些参数

var thing = "something"
var err = fmt.Errorf("%s happened", thing)

错误处理首体验

Java 语言中,如果遇到 I/O 问题通常会抛出IOException类型的异常。然而在 Go 语言中,它不会抛出异常,而是以返回值的形式来通知上层逻辑来处理错误

package main

import (
    "fmt"
    "os"
)

func main() {
    // 打开文件
    var f, err = os.Open("quick.go")
    if err != nil {
        // 文件不存在、权限等原因
        fmt.Println("open file failed reason: " + err.Error())
        return
    }
    // 推迟到函数尾调用,确保文件会关闭
    defer f.Close()
    // 存储文件内容
    var content = []byte{}
    // 临时的缓冲,按块读取,一次最多读取 100 字节
    var buf = make([]byte, 100)
    for {
        // 读文件,将读到的内容填充到缓冲
        n, err := f.Read(buf)
        if n > 0 {
            // 将读到的内容聚合起来
            content = append(content, buf[:n]...)
        }
        if err != nil {
            // 遇到流结束或者其它错误
            break
        }
    }
    // 输出文件内容
    fmt.Println(string(content))
}

-------
package main

import "os"
import "fmt"
.....

体验 Redis 的错误处理

首先需要使用go get指令下载 redis 包:

go get github.com/go-redis/redis

下面实现一个小功能:获取 Redis 中两个整数值,然后相乘,再存入 Redis 中:

package main

import "fmt"
import "strconv"
import "github.com/go-redis/redis"

func main() {
    // 定义客户端对象,内部包含一个连接池
    var client = redis.NewClient(&redis.Options{
        Addr: "localhost:6379",
    })

    // 定义三个重要的整数变量值,默认都是零
    var val1, val2, val3 int

    // 获取第一个值
    valstr1, err := client.Get("value1").Result()
    if err == nil {
        val1, err = strconv.Atoi(valstr1)
        if err != nil {
            fmt.Println("value1 not a valid integer")
            return
        }
    } else if err != redis.Nil {
        fmt.Println("redis access error reason:" + err.Error())
        return
    }

    // 获取第二个值
    valstr2, err := client.Get("value2").Result()
    if err == nil {
        val2, err = strconv.Atoi(valstr2)
        if err != nil {
            fmt.Println("value1 not a valid integer")
            return
        }
    } else if err != redis.Nil {
        fmt.Println("redis access error reason:" + err.Error())
        return
    }

    // 保存第三个值
    val3 = val1 * val2
    ok, err := client.Set("value3", val3, 0).Result()
    if err != nil {
        fmt.Println("set value error reason:" + err.Error())
        return
    }
    fmt.Println(ok)
}

------
OK
  • Go 语言中不轻易使用异常语句,所以对于任何可能出错的地方都需要判断返回值的错误信息
  • 字符串的零值是空串而不是 nil,需要通过返回值的错误信息来判断。redis.Nil就是客户端专门为 key 不存在这种情况而定义的错误对象

异常与捕捉

Go 语言提供了 panicrecover 全局函数让我们可以抛出异常、捕获异常,类似于 trythrowcatch语句,但是又很不一样。比如 panic 函数可以抛出任意对象

package main

import "fmt"

var negErr = fmt.Errorf("negative number")

func main() {
    fmt.Println(fact(5))
    fmt.Println(fact(10))
    fmt.Println(fact(15))
    fmt.Println(fact(-20))
}

func fact(a int) int {
    if a <= 0 {
        panic(negErr)
    }
    var result = 1
    for i := 1; i <= a; i++ {
        result *= i
    }
    return result
}

-------
120
3628800
1307674368000
panic: negative number

goroutine 1 [running]:
main.fact(0xffffffffffffffec, 0x1)
    C:/Users/abel1/go/src/hello/quickgo.go:16 +0x7e
main.main()
    C:/Users/abel1/go/src/hello/quickgo.go:11 +0x15e

Process finished with exit code 2

上面的代码抛出了negErr,直接导致了程序崩溃,程序最后打印了异常堆栈信息。下面我们可以使用 recover 函数来保护它,需要结合 defer 语句一起使用,这样可以确保recover()逻辑在程序异常时也可以得到调用

package main

import "fmt"

var negErr = fmt.Errorf("negative number")

func main() {
    defer func() {
        if err := recover(); err != nil {
            fmt.Println("error catched", err)
        }
    }()
    fmt.Println(fact(5))
    fmt.Println(fact(10))
    fmt.Println(fact(15))
    fmt.Println(fact(-20))
}

func fact(a int) int {
    if a <= 0 {
        panic(negErr)
    }
    var result = 1
    for i := 1; i <= a; i++ {
        result *= i
    }
    return result
}

-------
120
3628800
1307674368000
error catched negative number

Process finished with exit code 0

可以看到程序成功捕获了异常,并且不再崩溃,但异常点后面的逻辑也不会再继续执行了

我们经常还需要对recover()返回的结果进行判断,以挑选出我们愿意处理的异常对象类型。对于那些不愿意处理的,可以选择再次抛出,让上层来处理

defer func() {
    if err := recover(); err != nil {
        if err == negErr {
            fmt.Println("error catched", err)
        } else {
            panic(err)  // rethrow
        }
    }
}()

Go 语言官方表态不要轻易使用 panic recover,除非你真的无法预料中间可能会发生的错误,或者它能非常显著地简化你的代码。除非逼不得已,否则不要使用它

多个 defer 语句

有时我们需要在一个函数里使用多次 defer 语句。例如拷贝文件,需要同时打开源文件和目标文件,那就需要调用两次defer f.Close

package main

import (
    "fmt"
    "os"
)

func main() {
    fsrc, err := os.Open("source.txt")
    if err != nil {
        fmt.Println("open source file failed")
        return
    }
    defer fsrc.Close()
    fdes, err := os.Open("target.txt")
    if err != nil {
        fmt.Println("open target file failed")
        return
    }
    defer fdes.Close()
    fmt.Println("do something here")
}

------
open source file failed

Process finished with exit code 0

需要注意的是 defer 语句的执行顺序代码编写的顺序相反的,也就是说最先 defer 的语句最后执行

package main

import "fmt"
import "os"

func main() {
    fsrc, err := os.Open("source.txt")
    if err != nil {
        fmt.Println("open source file failed")
        return
    }
    defer func() {
        fmt.Println("close source file")
        fsrc.Close()
    }()

    fdes, err := os.Open("target.txt")
    if err != nil {
        fmt.Println("open target file failed")
        return
    }
    defer func() {
        fmt.Println("close target file")
        fdes.Close()
    }()
    fmt.Println("do something here")
}

--------
do something here
close target file
close source file

Process finished with exit code 0