Go 速食指南
Go 是 Google 于 2009 年 11 月 10 日发布的一门静态强类型、编译型、并发型,并具有垃圾回收功能的编程语言,现在被广泛应用在服务端领域。通过 Google Trends 可以看出来,在过去 5 年,Go 的热度始终在上升。本文是笔者在学习 Go 的过程中记录的笔记,用于快速查阅 Go 的语法特性。如果恰好对其它读者也能有所帮助的话,就再好不过了。
变量定义
Go 可以使用 var 关键字来定义一个变量,类型放在变量名后面。如:
// 定义一个变量
var a string
// 定义多个变量
var a, b string
// 定义多个变量并赋值
var a, b string = "a", "b"
// 变量具有初始值时可以省略类型
var a, b = "a", "b"
// 使用下划线 _ 代替变量名,可以在赋值时忽略某个值
var a, _, b = "a", "忽略", "b"
在函数中,可以在类型明确的地方使用短变量声明。例:
func main() {
a, b := "a", "b"
// ...
}
使用 const 关键字来定义一个常量。例:
const Pi = 3.14
类型系统
Go 的基本类型如下:
- bool
- string
- int int8 int16 int32 int64
- uint uint8 uint16 uint32 uint64 uintptr
- byte
- rune
- float32 float64
- complex64 complex128
其中,int 和 uint 在 32 位操作系统上是 32 位,在 64 位操作系统上是 64 位。
uintptr 是一个 uint,用于存放指针。
byte 是 unit8 的别名。
rune 是一个 int32,用于存放 Unicode 码点。(实际上 rune 相当于 C 中的 char,不过 char 存放的是 ASCII 码,因此只有 8 位。)
complex64 与 complex128 用于保存复数,实部与虚部分别由 float32 和 float64 组成。
未赋值的变量具有零值,对于数值类型来说是 0,布尔类型是 false,字符串类型为空字符串""。
表达式 T(v) 可以将值 v 强制转换为类型 T。如:
a := 1.5
var b int = int(a) // b == 1
Go 带有类型推导,在声明一个变量而不指定其类型时,变量的类型由右值推导得出。对于整型的数值常量,总是取 int。而浮点型数值常量,总是取高精度的 float64 与 complex128。
运算符
Go 有类似 C 语言的运算符。例:
// 算数运算符
var a = 1
var b = 2
var c = 0
c = a + b
c = a - b
c = a * b
c = a / b
c = a % b
c++
c--
// 关系运算符
var d = false
d = a == b
d = a != b
d = a > b
d = a < b
d = a >= b
d = a <= b
// 逻辑运算符
var e = true
var f = false
var g = false
g = e && f
g = e || f
g = !(e || f)
// 位运算符
var h = 0x00
var i = 0xff
var j = 0x00
j = h & i
j = h | i
j = h ^ i
j = h << 2
j = i >> 2
值得一提的是,Go 没有三元运算符,需要用到三元运算符的地方都需要使用 if-else 语句来代替。
控制流语句
Go 只有一种循环结构:for 循环。Go 中的 for 语句有个特点,for 后面的三个表达式不需要括号。如:
// 一个标准形式的 for 循环
sum := 0
for i := 0; i < 10; i++ {
sum += i
}
for 语句中的初始化语句声明与后置语句可以省略,如:
// 省略了初始化语句和后置语句的 for 循环
sum := 0
i := 0
for i < 10 {
sum += i
i++
}
此时,for 语句实际上就是 while。
同时,for语句可以更进一步,直接省略循环条件。这个时候,for 语句就是无限循环。例:
for {
// ...
}
Go 中的 if 语句也类似 for,没有括号。例:
if num < 0 {
num = num * -1
}
类似的,if 语句也可以添加一条初始化语句。如:
if negative := -1; num < 0 {
num = num * negative
}
值得注意的一点是不论是 for 还是 if 的初始化语句中声明的变量,其作用域都局限在当前的块中,在外部无法访问。
Go 中的 switch 语句是一组 if 的简化写法,它会运行第一个值等于条件表达式的 case 语句,如果 case 语句不以 fallthrough 语句作为结束,则执行完 case 之后会直接退出,不再执行后续的 case 语句。同时,与 for 和 if 一样,switch 的条件表达式同样不需要括号,且允许插入一条初始化表达式。如:
text := ""
switch day := 6; day {
case 1:
text = "星期一"
case 2:
text = "星期二"
// ...
default:
text = ""
}
这里需要注意一点:case 表达式是惰性求值的。
如果省略 switch 语句的条件表达式语句,那么默认就是 switch true。
函数
Go 使用 func 来定义一个函数。例:
// 一个普通的函数
func add(a int, b int) int {
c := a + b
return c
}
// 省略连续的相同类型参数的类型
func add(a, b int) int {
c := a + b
return c
}
// 返回值可以被命名
func add(a, b int) (c int) {
c = a + b
return
}
// 一次可以返回多个返回值
func swap(a, b int) (int, int) {
return b, a
}
Go 的函数没有默认参数,但是支持可变长参数函数(JavaScript 中管这个叫做剩余参数)。如:
func acc(len int, num ...int) int {
sum := 0
for i := 0; i < len; i++ {
sum += num[i]
}
return sum
}
// 调用函数:
acc(9, 1, 2, 3, 4, 5, 6, 7, 8, 9) // 返回 45
Go 的函数支持递归(不支持尾调用优化)。例:
func fibonacci(i int) int {
if i <= 0 {
return 0
}
if i == 1 {
return 1
}
return fibonacci(i-1) + fibonacci(i-2)
}
Go 的函数支持 lambda 表达式和闭包。例:
package main
import (
"fmt"
)
func main() {
createCounter := func() func() int {
c := 0
return func() int {
c++
return c
}
}
count := createCounter()
fmt.Println(count()) // 1
fmt.Println(count()) // 2
fmt.Println(count()) // 3
}
在函数内使用 defer 关键字可以延迟调用一个函数。函数的参数将会立即求值,而函数调用将会延迟到函数返回时。利用这个特点可以在 defer 中回收资源或是修改返回值。如果在函数中多次使用 defer ,延迟调用的函数会被推入栈中,先进后出。另外,有两个与 defer 配合使用的函数:panic 与 recover。当函数调用发生错误时,函数会立即返回,然后依次执行 defer,全部执行完毕后将会抛出异常。recover 函数只能在 defer 中使用。如果没有发生异常,将会返回 nil。如果发生了异常,会将异常结果捕获,然后程序继续执行。使用 panic 可以手动抛出一个异常。例:
package main
import "fmt"
var list = [3]int{1, 2, 3}
func main() {
fmt.Println("调用f...")
f()
fmt.Println("f返回了")
}
func f() {
defer func() {
if r := recover(); r != nil {
fmt.Println("捕获了f调用过程中的异常", r)
}
}()
fmt.Println("调用g...")
g(0)
fmt.Println("g返回了")
}
func g(i int) {
// 如果取消注释,recover 捕获到的错误是 "数组越界了"
// if i > (len(list) - 1) {
// panic("数组越界了")
// }
defer fmt.Println("g中的defer", list[i])
fmt.Println("g中普通的函数调用", list[i])
g(i + 1)
}
// 执行结果:
// 调用f...
// 调用g...
// g中普通的函数调用 1
// g中普通的函数调用 2
// g中普通的函数调用 3
// g中的defer 3
// g中的defer 2
// g中的defer 1
// 捕获了f调用过程中的异常 runtime error: index out of range
// f返回了
指针
Go 拥有指针,指针保存了值的内存地址。
类型 *T 是指向 T 类型值的指针。其零值为 nil。
通过 & 操作符取得指向其操作目标的指针。
通过 * 操作符取得指针的底层值。
这是一个指针操作的例子:
var p *int
var num
p = &num
*p = 0 // num == 0
指针的零值 nil 不指向任何内存,因此有别于其它类型,指针需要显式初始化之后才能使用。Go 提供了一个分配内存的函数 new,new(T) 可以分配一段用于保存类型为 T 的值的内存。例:
var a *int // nil
*a = 1 // error
// 通过 new 来分配一块内存,简化了指针类型初始化的操作
a = new(int)
*a = 1
面向对象
Go 没有类,但是有结构体。Go 中使用 type 关键字来定义一个自定义类型,因此可以使用 type 来定义一个结构体类型。例:
// 通过 type 声明结构体
type Vertex struct {
X int
Y int
}
vertex := Vertex{1, 2}
// 通过字面量使用结构体
vertex2 := struct {
X int
Y int
}{1, 2}
使用点号来访问结构体字段,如 vertex.x 。
有一点需要注意:结构体在赋值的时候是值传递。因此,修改结构体的字段必须使用指针。为了方便起见,通过指针访问结构体的字段时,可以省略 * 操作符。例:
vertex := struct {
X int
Y int
}{0, 0}
p := vertex
p.X = 1
p.Y = 1 // vertex: {0 0} p: {1 1}
q := &vertex
(*q).X = 2
(*q).Y = 2 // vertex: {2 2} q: {2 2}
// 可以简化为
// q.X = 2
// q.Y = 2
我们还可以给结构体定义方法。方法就是带有接收者的函数,接收者实质上也是函数的参数,表示使用这个方法的结构体。如:
type Vertex struct {
X, Y int
}
// 一个带有接收者的函数
func (v Vertex) scale(s int) (int, int) {
return v.X * s, v.Y * s
}
vertex := Vertex{1, 2}
vertex.scale(5) // 调用一个方法
// 函数的等价实现 =>
func scaleFunc(v Vertex, s int) (int, int) {
return v.X * s, v.Y * s
}
scaleFunc(vertex, 5) // 调用一个函数
接收者同样也是值传递,如果在方法中需要改变结构体的值,则需要使用指针接收者。同样,为了方便起见,在使用指针接收者时,也可以省略 * 和 & 操作符。如:
type Vertex struct {
X, Y int
}
// 一个带有指针接收者的函数
func (v *Vertex) scale(s int) {
(*v).X *= s
(*v).Y *= s
// 可以简化为
// v.X *= s
// v.Y *= s
}
vertex := Vertex{1, 2}
(&vertex).scale(5) // 传递指针接收者
// 可以简化为
// vertex.scale(5)
// 函数的等价实现 =>
func scaleFunc(v *Vertex, s int) {
(*v).X *= s
(*v).Y *= s
// 可以简化为
// v.X *= s
// v.Y *= s
}
scaleFunc(&vertex, 5) // 作为函数参数而非接收者传入时,不能省略 `&` 操作符
虽然说 Go 中没有类,但是 Go 提供了接口。
接口是一组方法签名定义的集合,同样是一种类型。因此,依然使用 type 关键字来定义接口。接口类型的变量可以保存任何实现了这些方法的值。类型通过实现一个接口的所有方法来实现该接口,不需要 implements 关键字(隐式接口实现,相当于鸭子类型。),因此接口的定义与实现完全解耦,更加的灵活。例:
// 定义一个接口,接口具有 scale 方法
type Scaler interface {
scale(s int)
}
type Vertex struct {
X, Y int
}
// Vertex 具有 scale 方法,因此隐式实现了这个接口
func (v *Vertex) scale(s int) {
v.X *= s
v.Y *= s
}
//
var scaler Scaler = &Vertex{1, 2} // 不能是 Vertex{1, 2} ,因为方法 scale 是为指针类型 *Vertex 定义的,所以值类型 Vertex{1, 2} 没有实现这个接口
一个接口类型的值在内部可以看做包含具体的值和值的类型的元组。比如上面这个例子中,接口的具体的值就是 &Vertex{1, 2},而值的类型就是 *Vertex。在接口值调用方法时会执行其底层类型的同名方法。
当接口具体的值是 nil 时,方法仍然会被调用。这在其它一些语言中,会触发空指针异常。但是在 Go 中不会。为什么呢?因为接口值本身不是 nil,而方法实际上只是带有接收者的函数,因此当接口的具体值是 nil 时,方法依然可以调用,只是接收者的值为 nil,空指针异常会在方法内部被触发。与此同时,我们就可以在方法内部捕获空指针引用,避免错误。如:
type Scaler interface {
scale(s int)
}
type Vertex struct {
X, Y int
}
func (v *Vertex) scale(s int) {
if v == nil { // 当 v 是 nil 时跳出,避免空指针引用。
return
}
v.X *= s
v.Y *= s
}
var vertex *Vertex // 指针类型的零值是 nil
var scaler Scaler = vertex
scaler.scale(5) // scaler 本身不为 nil,不会触发空指针引用
需要注意的一点是,当接口值本身为 nil 时,内部既没有保存具体的值,也没有保存具体的类型,因此进行方法调用时无法找到的函数具体调用,从而引发空指针异常。
不声明任何方法的空接口可以保存任何类型的值(类似 typescript 中的 any 类型),空接口可以用来处理未知类型的值。
Go 还给接口值提供了类型断言的语法,用于判断一个接口值是否保存了某个类型的值。类型断言会返回两个值,第一个值是接口保存的具体的值,第二个值是报告断言是否成功的布尔值。如果类型断言失败且没有读取第二个值则会触发异常。例:
var a interface{} = "void interface"
b := a.(string) // b == "void interface"
c, ok := a.(int) // c == 0, ok == false
d := a.(int) // 触发异常
空接口可以结合类型选择的语法,依据传入的类型执行不同的语句。如:
package main
import "fmt"
func checkType(x interface{}) {
switch value := x.(type) { // .(type) 的类型选择语法只能在 switch 中使用
case int:
fmt.Println("Type is int: ", value)
case string:
fmt.Println("Type is string: ", value)
default:
fmt.Println("Unknow type: ", value)
}
}
func main() {
checkType(0)
checkType("string")
checkType(nil)
}
数组及映射
与其它常见的编程语言一样,Go 也具有数组类型。[n]T 表示具有 n 个 T 类型的值的数组。T 可以是任何类型,包括其它数组。数组的长度也是数组类型的一部分,因此数组类型不可变。类似结构体,数组也是值传递。例:
var a [5]int // [0 0 0 0 0]
b := [2]int{1, 2}
c := [...]int{7, 8, 9} // [...]int 会在编译阶段自动补全为 [3]int
[...]int{} == [...]int{} // true
数组类型的长度是不可变的,因此使用时有诸多不便。因此 Go 提供了一个特殊的类型:切片。
切片在本质上是对某个数组其中一个片段的引用,切片只关心它引用的数组的类型,不关心长度。因此,切片的长度不是类型的一部分,长度是可变的。切片类型的写法是 []T , T 是切片所引用的数组的元素类型。
切片具有两个额外的概念:长度与容量。长度是切片当前引用的数组片段的长度,而容量是当前切片的起始位置到当前切片所引用的数组末尾的长度。切片的长度和容量可以通过 len 函数与 cap 函数来获取。
切片可以从一个已有的数组中创建,也可以通过字面量、内置的 make 函数甚至其它切片来创建。通过重新对一个已有的切片重新切片可以改变切片的长度。例:
originArray := [10]int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
// 创建数组 originArray 在区间 [索引下界: 索引上界) 的切片。索引上界必须是一个大于等于零的自然数,同时索引上界与索引下界对数组的访问不能越界。
a := originArray[2:5] // [2 3 4] len(a) == 3 cap(a) == 8
// 索引上界和索引下界都是可省略的,默认是数组的起始位置至结束位置
b := originArray[:] // [0 1 2 3 4 5 6 7 8 9] len(b) == 10 cap(b) == 10
// 可以通过切片字面量直接创建一个切片
c := []int{1, 2, 3} // [1 2 3] len(c) == 3 cap(c) == 3
// 可以通过重新切片来改变一个切片的长度和容量。因为索引下界必须是一个自然数,因此只有索引上界能被扩展。
a = a[2:7] // [4 5 6 7 8] len(a) == 5 cap(a) == 6
// 对已有切片重新切片本质上是基于已有切片的底层数组重新切片
d := originArray[4:9] // [4 5 6 7 8] len(d) == 5 cap(d) == 6
// 使用 make 创建了一个类型为 []int、长度为 5、容量为 8 的切片。其中容量可以省略。如果容量省略,那么容量默认就是长度值。
e := make([]int, 5, 8)
因为切片是数组的引用,因此切片也是引用类型,零值同样是 nil。可以使用 append 函数来向一个存在的切片添加新元素,当切片的底层数组容量不足时,将会分配一个更大的数组,并且返回这个数组的切片。使用 for 循环的特殊形式 for range 可以方便地遍历一个切片。例:
var a []int // a == nil、len(a) == 0、cap(a) == 0,此时 a 没有底层数组,访问 a[0] 将会抛出异常
// 使用 append 函数添加一些元素,因为切片 a 的容量为 0,因此会返回一个容量足够的新切片
b := append(a, 10, 20, 30, 40) // [10, 20, 30, 40] len(b) == 4 cap(b) == 4
for index, value := range a {
fmt.Println(index, value) // 什么都不会发生,因为切片 a 依然为零值
}
for index, value := range b {
fmt.Println(index, value) // 打印了 0 10、1 20、2 30、3 40。
}
在使用切片时,需要注意一点:切片是数组的引用,因此即使切片只引用了数组中的几个元素,也会导致整个数组的内存得不到释放,使用不当有可能造成内存泄露。
除了数组以外,Go 还提供了另一个数据类型用于存储一组相同类型的值。类似其它语言中的字典,Go 当中提供了映射类型,用于将键映射到值。映射类型的写法是 map[Key]Value ,表示一个键是 Key 类型、值是 Value 类型的映射。映射是引用类型,零值同样是 nil,需要使用 make 函数来创建一个映射。例:
type City struct {
name string
}
type Detail struct {
code string
}
// 定义一个键的类型为 City、值的类型为 Deail 的映射。真实世界中的键类型一般是 string
var m map[City]Detail // m == nil
// 通过字面量来创建一个映射
m = map[City]Detail{}
// 通过 make 来创建一个映射
m = make(map[City]Detail)
// 插入或修改元素
m[City{"福州"}] = Detail{"350000"}
// 获取元素
detail := m[City{"福州"}] // Detail{"350000"}
// 删除元素
delete(m, City{"福州"})
// 获取某个元素是否存在
detail, isExist := m[City{"福州"}] // detail 是 City{},isExist 是 false
并发与异步
Go 语言最大的特色就是 Go 程(goroutine)。Go 程是由 Go 运行时管理的轻量级线程。使用关键字 go 可以启动一个 Go 程。例:
go f(x, y, z) // 参数的求值会发生在当前的Go程中,而f的执行发生在新的go程中。
信道是带有类型的管道,通过操作符 <- 来发送和接收值。默认情况下,发送和接收操作在另一端准备好之前都会阻塞。与切片和映射类似,信道在使用前也需要使用 make 来创建。例:
package main
import "fmt"
func add(c chan int, num int) {
c <- num // 将值送入 c
}
func sum(c chan int) (reulst int) {
x, y := <-c, <-c // 获取信道中的值
return x + y
}
func main() {
c := make(chan int) // 创建一个信道
go add(c, 1)
go add(c, 2)
sum(c) // 返回 3。读取值会被阻塞,直到添加值结束
}
信道可以是带缓冲的,在使用 make 创建信道时,可以提供第二个参数,作为信道的缓冲长度。
不带缓存的信道,信道值是依次发送接收的。而带缓冲的信道,在发送值时仅在缓冲区满时才阻塞,接收值时仅在缓冲区为空时才阻塞。可以简单地把不带缓冲的信道当成缓冲长度为 1 的信道。例:
// 不带缓冲的信道
c1 := make(chan int)
c1 <- 1 // 发送第一个值
c1 <- 2 // 前一个值尚未被接收,此处发生阻塞
// 带缓冲的信道
c2 := make(chan int, 2)
c2 <- 1 // 发送第一个值
c2 <- 2 // 发送第二个值,此时缓冲区满,继续发送将发生阻塞
接收者可以使用 for range 来接收信道所有的值,直到信道被关闭。而发送者则通过 close 函数来关闭一个关闭一个信道,表示不再有值会被发送。只有发送者才能关闭信道,也只有通过 for range 接收的信道需要被关闭。例:
package main
import (
"fmt"
)
func count(c chan int) {
for i := 0; i < 10; i++ {
c <- i
}
close(c) // 如果不关闭信道,for range 将会死锁
}
func main() {
c := make(chan int) // 创建一个信道
go count(c) // 在go程中添加值
for value := range c {
fmt.Println(value)
}
}
select 语句使一个 Go 程可以等待多个通信操作,select 会阻塞到某个分支可以继续执行为止,当多个分支都准备好时会随机选择一个执行。当 select 中的其它分支都没有准备好时,default 分支就会执行。例:
package main
import (
"fmt"
"math/rand"
"time"
)
// 假装我是一个网络请求
func httpRequest(c chan string) {
rand.Seed(time.Now().Unix())
time.Sleep(time.Duration(rand.Intn(10)+1) * 100 * time.Millisecond)
c <- "网络请求结果"
}
func timeOut(c chan string) {
rand.Seed(time.Now().Unix())
time.Sleep(time.Duration(rand.Intn(10)+1) * 100 * time.Millisecond)
c <- "网络请求超时"
}
func main() {
httpChan := make(chan string)
timeOutChan := make(chan string)
go httpRequest(httpChan)
go timeOut(timeOutChan)
for {
select {
case httpRes := <-httpChan:
fmt.Println(httpRes)
return
case timeOutRes := <-timeOutChan:
fmt.Println(timeOutRes)
return
default:
fmt.Println("滴答...")
time.Sleep(100 * time.Millisecond)
}
}
}
// 执行结果随机,将在随机的滴答之后打印 "网络请求结果" 或 "网络请求超时"
信道非常适合在 Go 程之间通信,但是如果仅仅只是需要在不同的 Go 程之间访问共享的一个变量,为了避免冲突,需要使用互斥锁。互斥锁是 Go 标准库中的一个类型 sync.Mutex。它有 Lock 和 Unlock 两个方法,分别用于变量的加锁与解锁。例:
package main
import (
"fmt"
"sync"
"time"
)
// Accumulator 并发安全的累加器
type Accumulator struct {
sum int
mux sync.Mutex
}
func (c *Accumulator) add(num int) {
c.mux.Lock() // 给变量加锁
defer c.mux.Unlock() // 使用 defer 在函数调用结束后解锁
c.sum += num
}
func (c *Accumulator) getValue() int {
return c.sum
}
func main() {
c := Accumulator{}
for i := 0; i < 1000; i++ {
go c.add(i) // 循环创建 1000 个异步 go程
}
time.Sleep(time.Second) // 延时等待 1000 个 go程 执行结束
fmt.Println(c.getValue()) // 获取最终的累加值,如果没有互斥锁,其值可能会小于 0 ~ 1000 的累加和 499500
}
异常处理
Go 当中没有类似其它语言中 try catch 的语法。Go 当中有一个内建接口 Stringer,它有一个方法 String,String 方法返回一个字符串以描述自己的类型。Go 当中还有另一个内建接口 error,error 有一个方法 Error,Error 方法返回一个字符串以描述错误。fmt 包(还有很多包)都通过这两个接口来打印值。通常函数会返回一个 error 值,调用的它的代码应当判断这个错误是否等于 nil 来进行错误处理。例:
package main
import "fmt"
type MyStruct struct{}
type MyError struct{}
// 隐式实现 Stringer 接口
func (myStruct *MyStruct) String() string {
return "我是 MyStruct 类型的值!"
}
// 隐式实现 error 接口
func (myStruct *MyError) Error() string {
return "执行失败了,我是一个异常/(ㄒoㄒ)/~~"
}
func (myStruct *MyStruct) run(isSuccess bool) (string, error) {
if !isSuccess {
return "", &MyError{}
}
return "执行成功啦~", nil
}
func main() {
myStruct := &MyStruct{}
fmt.Println(myStruct) // 我是 MyStruct 类型的值!
if result, err := myStruct.run(true); err == nil {
fmt.Println(result) // 执行成功啦~
} else {
fmt.Println(err)
}
if result, err := myStruct.run(false); err == nil {
fmt.Println(result)
} else {
fmt.Println(err) // 执行失败了,我是一个异常/(ㄒoㄒ)/~~
}
}