Post

A Tour of Go

A Tour of Go

本教程覆盖了Go语言最重要的特性,包括:

  • 基础知识:变量和函数、流控制语句、内置类型
  • 方法和接口
  • 泛型
  • 并发

官方网站:https://go.dev/tour/list

代码:https://github.com/ZZy979/go-tutorials/tree/main/tour

1.Hello World

https://go.dev/tour/welcome

下面是Go语言的Hello World程序:

1
2
3
4
5
6
7
package main

import "fmt"

func main() {
	fmt.Println("Hello, 世界")
}

可以使用Go Playground在线运行。也可以本地安装Go并在命令行中运行:

1
2
$ go run hello.go 
Hello, 世界

2.基础知识

2.1 包、函数和声明

https://go.dev/tour/basics

2.1.1 包

Go程序由(package)组成,程序从main包开始运行。

下面的程序使用了导入路径为"fmt""math/rand"的包。

basics/packages.go

包名与导入路径的最后一个元素相同。例如,math/rand包中的文件以package rand开头。

2.1.2 导入

多个导入语句可以使用括号组织在一起,称为 “factored” (提取公因式的)。例如,

1
2
3
4
import (
	"fmt"
	"math"
)

等价于

1
2
import "fmt"
import "math"

使用factored导入语句是一种良好的编程风格。

basics/imports.go

2.1.3 导出名字

在Go中,以大写字母开头的名字称为导出的(exported)。例如Pi是导出的,而pi不是导出的。

当导入一个包时,只能访问其导出的名字,未导出的名字只能在包内访问。(注:二者的区别相当于Java的public和包可见)

例如,以下代码将报错 “name cos not exported by package math” 。应将math.cos改为math.Cos

basics/exported-names.go

2.1.4 函数

函数(function)使用关键字func定义,可以接受零个或多个参数。注意Go的声明语法中类型在变量名之后。

例如,函数add()接受两个int类型的参数,返回类型为int

1
2
3
func add(x int, y int) int {
	return x + y
}

basics/functions.go

当两个或两个以上连续的参数类型相同时,可以只保留最后一个。例如,x int, y int可简写为x, y int

函数可以返回任意个结果。例如,函数swap()返回两个字符串:

1
2
3
func swap(x, y string) (string, string) {
	return y, x
}

basics/multiple-results.go

注意:和Java一样,Go的函数是传值调用(包括数组和结构体类型),因此无法通过形参修改实参。但切片和映射等都是引用类型,形参和实参是指向同一对象的两个“指针”,因此可以通过形参修改实参指向的值。

2.1.5 命名的返回值

Go的函数返回值可以被命名,可以将其当作在函数顶部定义的变量。这些名字应当标明返回值的含义。

无参数的return语句返回这些命名返回值,称为“裸return”。裸return语句只应当用在短函数中,如下例所示。

1
2
3
4
5
func split(sum int) (x, y int) {
	x = sum * 4 / 9
	y = sum - x
	return
}

basics/named-results.go

2.1.6 变量

var语句用于声明变量(variable),类型放在最后。

var语句可以位于包级别或函数级别,如下例所示。

1
2
3
4
5
6
var c, python, java bool

func main() {
	var i int
	fmt.Println(i, c, python, java)
}

basics/variables.go

变量声明可以包含初始值。此时可以省略类型,变量类型由初始值决定。

1
2
3
4
5
6
var i, j int = 1, 2

func main() {
	var c, python, java = true, false, "no!"
	fmt.Println(i, j, c, python, java)
}

basics/variables-with-initializers.go

在函数内,可以用:=短赋值语句替代有初始值的var声明。在函数外不能使用:=

例如:

1
2
k := 3
c, python, java := true, false, "no!"

basics/short-variable-declarations.go

2.1.7 基本类型

Go的基本类型有:

  • bool
  • string
  • int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, uintptr
  • byteuint8的别名)
  • runeint32的别名,表示Unicode码点)
  • float32, float64
  • complex64, complex128

下面的例子展示了几种类型的变量。与import语句一样,变量声明也可以写成 “factored” 形式。

1
2
3
4
5
6
7
8
9
10
11
var (
	ToBe   bool       = false
	MaxInt uint64     = 1<<64 - 1
	z      complex128 = cmplx.Sqrt(-5 + 12i)
)

func main() {
	fmt.Printf("Type: %T Value: %v\n", ToBe, ToBe)
	fmt.Printf("Type: %T Value: %v\n", MaxInt, MaxInt)
	fmt.Printf("Type: %T Value: %v\n", z, z)
}

basics/basic-types.go

intuintuintptr通常在32位系统上是32位宽,在64位系统上是64位宽。当需要整数值时,应该使用int,除非有特殊原因使用固定宽度或无符号整数类型。

2.1.8 零值

没有显式初始化的变量将被赋予零值(zero value)。各种类型的零值如下表所示:

类型零值
数值类型0
布尔类型false
字符串""
指针、切片、映射、函数、接口、channelnil
数组所有元素都是零值
结构体所有每个字段都是零值

basics/zero.go

2.1.9 类型转换

表达式T(v)将值v转换为类型T。例如:

1
2
3
i := 42
f := float64(i)
u := uint(f)

与C语言不同,在Go中不同类型间赋值必须进行显式类型转换。删除下面代码中的float64uint转换将导致编译失败。

basics/type-conversions.go

2.1.9 类型推断

当声明变量而没有显式指定类型时,变量的类型将通过右侧的值推断得到。

当右侧值是一个变量时,新变量的类型与其相同:

1
2
var i int
j := i // j is an int

当右侧值是数字常量时,整数、浮点数和复数的类型分别为intfloat64complex128

1
2
3
i := 42           // int
f := 3.142        // float64
g := 0.867 + 0.5i // complex128

basics/type-inference.go

2.1.10 常量

常量使用关键字const声明,不能使用:=语法。例如:

1
const Pi = 3.14

basics/constants.go

数字常量是高精度的值。无类型的常量会根据其上下文需要采用相应的类型。

例如,在下面的程序中打印needInt(Big)会导致编译失败,因为将无类型常量Big赋给int值会溢出。

basics/numeric-constants.go

2.2 流控制语句:for、if、switch和defer

https://go.dev/tour/flowcontrol

2.2.1 for语句

Go只有一种循环语句,即for语句,语法如下:

1
2
3
for init; condition; post {
	statements
}

例如:

1
2
3
4
sum := 0
for i := 0; i < 10; i++ {
	sum += i
}

flowcontrol/for.go

注意:

  • 与C和Java不同,Go的for语句三部分周围没有小括号,而大括号是必需的。
  • init部分通常是短赋值语句(:=),声明的变量仅在for语句内部可见。
  • Go的++运算符没有前缀形式。

for语句的initpost部分是可选的。例如:

1
2
3
4
sum := 1
for ; sum < 1000; {
	sum += sum
}

flowcontrol/for-continued.go

如果只有condition部分,则可以将分号省略。此时for语句相当于C语言的while语句。

1
2
3
4
sum := 1
for sum < 1000 {
	sum += sum
}

flowcontrol/for-is-gos-while.go

如果省略循环条件,就是无限循环:

1
2
for {
}

flowcontrol/forever.go

2.2.2 if语句

if语句的语法如下:

1
2
3
if condition {
	statements
}

for语句类似,条件周围没有小括号,但大括号是必需的。例如:

1
2
3
4
5
6
func sqrt(x float64) string {
	if x < 0 {
		return sqrt(-x) + "i"
	}
	return fmt.Sprint(math.Sqrt(x))
}

flowcontrol/if.go

if语句也可以包含变量声明,作用域仅在if语句内部。

1
2
3
4
5
6
func pow(x, n, lim float64) float64 {
	if v := math.Pow(x, n); v < lim {
		return v
	}
	return lim
}

flowcontrol/if-with-a-short-statement.go

if-else语句的语法如下:

1
2
3
4
5
if condition {
	statements
} else {
	statements
}

例如:

1
2
3
4
5
6
7
8
9
func pow(x, n, lim float64) float64 {
	if v := math.Pow(x, n); v < lim {
		return v
	} else {
		fmt.Printf("%g >= %g\n", v, lim)
	}
	// can't use v here, though
	return lim
}

flowcontrol/if-and-else.go

注意:

  • if语句声明的变量在else部分也可以访问。
  • 大括号的位置不能改变!
练习:牛顿法求平方根

使用牛顿法实现平方根函数Sqrt(x):初始猜测值z=1.0,使用迭代公式

1
z -= (z*z - x) / (2*z)

直到z2足够接近x。

flowcontrol/exercise-loops-and-functions.go

2.2.3 switch语句

switch语句是一种比if-else语句序列更简洁的方式,它会执行第一个值与条件表达式相等的case对应的语句。语法如下:

1
2
3
4
5
6
7
8
switch expr {
case val1:
	statement1
case val2:
	statement2
default:
	statement
}

例如:

1
2
3
4
5
6
7
8
9
fmt.Print("Go runs on ")
switch os := runtime.GOOS; os {
case "darwin":
	fmt.Println("macOS.")
case "linux":
	fmt.Println("Linux.")
default:
	fmt.Printf("%s.\n", os)
}

flowcontrol/switch.go

与C和Java不同,Go的switch语句只执行匹配的一个分支(即没有fall through),相当于C语言的switch每个分支结尾自动添加了break

另一个重要区别是,Go的switch中每个case的值不要求是常量,也不要求是整数。

switch的求值顺序为从上到下,直到一个case匹配成功。例如,

1
2
3
4
switch i {
case 0:
case f():
}

如果i==0则不会调用f()

flowcontrol/switch-evaluation-order.go

没有条件的switch语句等价于switch true(即执行等于true的分支)。例如:

1
2
3
4
5
6
7
8
9
t := time.Now()
switch {
case t.Hour() < 12:
	fmt.Println("Good morning!")
case t.Hour() < 17:
	fmt.Println("Good afternoon.")
default:
	fmt.Println("Good evening.")
}

flowcontrol/switch-with-no-condition.go

2.2.4 defer语句

defer语句用于将函数的执行延迟到其所在函数返回时,但参数会立即求值。

例如,下面的程序会先打印 “hello” ,当main()函数返回时再打印 “world” 。

1
2
3
4
5
func main() {
	defer fmt.Println("world")

	fmt.Println("hello")
}

flowcontrol/defer.go

defer函数调用被压入一个栈中。当前函数返回时,按照后进先出的顺序执行这些调用。

关于defer语句的更多细节参见Defer, Panic, and Recover

例如,下面的程序

1
2
3
4
5
6
7
8
9
func main() {
	fmt.Println("counting")

	for i := 0; i < 10; i++ {
		defer fmt.Println(i)
	}

	fmt.Println("done")
}

输出结果为

1
2
3
4
5
6
7
8
9
10
11
12
counting
done
9
8
7
6
5
4
3
2
1
0

flowcontrol/defer-multi.go

2.3 内置类型:结构体、切片和映射

https://go.dev/tour/moretypes

Go的数据类型分为四类

  • 基本类型:数字、字符串、布尔型
  • 复合类型:数组、结构体
  • 引用类型:指针、切片、映射、函数、channel
  • 接口类型

2.3.1 指针

Go有指针(pointer)。指针用于保存值的内存地址。

类型*T是指向T类型值的指针。指针的零值是nil

运算符&生成指向一个值的指针:

1
2
i := 42
p = &i // p points to i

运算符*表示指针指向的值,称为解引用(dereferencing):

1
2
fmt.Println(*p) // read i through the pointer p
*p = 21         // set i through the pointer p

与C语言不同,Go没有指针运算(如p+1p-q)。

moretypes/pointers.go

2.3.2 结构体

结构体(struct)是一些字段(field)的集合。例如:

1
2
3
4
type Vertex struct {
	X int
	Y int
}

像这样创建结构体类型的变量:

1
v := Vertex{1, 2}

moretypes/structs.go

使用.运算符访问结构体的字段:

1
2
v.X = 4
fmt.Println(v.X)

moretypes/struct-fields.go

指向结构体的指针

结构体字段可以通过结构体指针访问。要访问指针p指向的结构体的字段X,可以使用(*p).X,也可简写为p.X(注:Go没有p->X这种写法)。

1
2
3
v := Vertex{1, 2}
p := &v
p.X = 1e9

moretypes/struct-pointers.go

结构体字面值

结构体字面值表示一个新创建的结构体值,列出所有字段的值:

1
v1 := Vertex{1, 2}  // has type Vertex

也可以使用Name: Value语法(类似于Python的关键字参数)。此时可以仅列出一部分字段(省略的字段使用对应类型的零值),字段的顺序无关紧要。

1
2
v2 := Vertex{X: 1}  // Y:0 is implicit
v3 := Vertex{}      // X:0 and Y:0

moretypes/struct-literals.go

注意:Go允许获取结构体字面值的指针,这在C++中是不合法的(右值不能取地址,除非是右值引用)。但Go不允许获取数字常量的指针。

1
2
p := &Vertex{1, 2} // OK
q := &42 // error

2.3.3 数组

类型[n]T是具有nT类型值的数组。例如,

1
var a [10]int

将变量a声明为包含10个整数的数组。

使用[]运算符访问数组元素:

1
2
3
a[0] = "Hello"
a[1] = "World"
fmt.Println(a[0], a[1])

声明数组变量时可以使用{}提供元素的初始值,例如:

1
primes := [6]int{2, 3, 5, 7, 11, 13}

数组的长度是其类型的一部分,因此[3]int[4]int是两种不同的数组类型,并且数组不能改变大小。

moretypes/array.go

2.3.4 切片

数组的大小是固定的。切片(slice)是一种动态大小、灵活的视图,可以访问数组元素。实际上,切片比数组常用得多。

类型[]T是具有T类型元素的切片。

通过指定数组和两个索引(下界和上界)来构造切片:a[low:high]表示数组a的一个左闭右开区间(包括a[low],不包括a[high])。

例如:

1
var s []int = primes[1:4]

moretypes/slices.go

切片类似于数组的引用

  • 切片不存储数据,仅描述底层数组的一部分。
  • 多个切片可以共享底层数组,并且引用的数组区间可能重叠。
  • 改变切片的元素会修改底层数组的对应元素,共享同一个底层数组的其他切片也能看到修改。
  • 切片之间不能进行相等比较。

moretypes/slices-pointers.go

切片字面值

切片字面值类似于数组字面值,但没有长度。例如:

1
[]bool{true, true, false}

切片字面值构造了一个包含相同元素的数组,并创建了一个引用它的切片。

moretypes/slice-literals.go

切片默认值

切片下界的默认值是0,上界的默认值是数组/切片的长度。

例如,对于长度为10的数组a,以下切片表达式是等价的:

1
2
3
4
a[0:10]
a[:10]
a[0:]
a[:]

moretypes/slice-bounds.go

切片长度和容量

切片具有长度(length)和容量(capacity)。

  • 切片的长度是它包含元素的个数。
  • 切片的容量是底层数组从下界开始的元素个数。

例如,对于长度为10的数组a,切片a[3:8]的长度为5、容量为7。

可以使用表达式len(s)cap(s)获得切片s的长度和容量。

可以通过重新切片来扩展切片的长度,前提是长度不能超过容量。例如:

1
2
3
4
5
6
s := []int{2, 3, 5, 7, 11, 13} // len=6 cap=6
s = s[:0]  // len=0 cap=6 [] 2 3 5 7 11 13
s = s[:4]  // len=4 cap=6 [2 3 5 7] 11 13
s = s[2:]  // len=2 cap=4 2 3 [5 7] 11 13
s = s[1:4] // len=3 cap=3 2 3 5 [7 11 13]
s = s[0:8] // runtime error: slice bounds out of range [:8] with capacity 3

moretypes/slice-len-cap.go

Nil切片

切片的零值是nil

nil切片的长度和容量都是0,没有底层数组。

moretypes/nil-slices.go

用make函数创建切片

可以使用内置函数make()创建切片——这就是创建动态数组的方式。

make()函数创建一个给定长度的零值数组,并返回引用该数组的切片:

1
a := make([]int, 5)  // len(a)=5

可以通过第三个参数指定容量:

1
2
3
4
b := make([]int, 0, 5) // len(b)=0, cap(b)=5

b = b[:cap(b)] // len(b)=5, cap(b)=5
b = b[1:]      // len(b)=4, cap(b)=4

moretypes/making-slices.go

切片的切片

切片的元素可以是任意类型,包括切片。例如:

1
2
3
4
5
board := [][]string{
	{"_", "_", "_"},
	{"_", "_", "_"},
	{"_", "_", "_"},
}

moretypes/slices-of-slice.go

向切片添加元素

Go的内置函数append()用于向切片添加新元素,返回新的切片。详见文档func append

1
func append(s []T, vs ...T) []T

第一个参数s是切片,其余参数是要添加到切片的值。返回值是原切片加上新元素后的切片。例如:

1
2
3
4
var s []int            // len=0 cap=0 []
s = append(s, 0)       // len=1 cap=1 [0]
s = append(s, 1)       // len=2 cap=2 [0 1]
s = append(s, 2, 3, 4) // len=5 cap=6 [0 1 2 3 4]

moretypes/append.go

注:向切片中添加新元素时

  • 如果切片的底层数组足够大,则会覆盖数组中切片尾部之后的元素;否则会创建一个新的数组,返回的切片将会引用新的数组,此时不会修改原来的底层数组。
  • 由于不能确认新的切片和原始切片是否引用相同的底层数组,应该将append()的返回值直接赋值给输入的切片变量。例如:
1
2
3
4
a := []int{0, 1, 2}
s := a[0:2]         // a=[0 1 2], s=[0 1]
s = append(s, 3)    // a=[0 1 3], s=[0 1 3]
s = append(s, 4, 5) // a=[0 1 3], s=[0 1 3 4 5]

关于切片的更多细节参见Go Slices: usage and internals

2.3.5 Range

range用于在for循环中遍历切片或映射。

使用range遍历切片时,每次返回两个值:索引和对应元素的拷贝(相当于Python中的enumerate()函数)。

1
2
3
4
var pow = []int{1, 2, 4, 8, 16, 32, 64, 128}
for i, v := range pow {
	fmt.Printf("2**%d = %d\n", i, v)
}

moretypes/range.go

如果不需要索引或值,可以将对应的变量名改为_

1
2
for i, _ := range s
for _, v := range s

如果只需要索引,可以省略第二个变量(此时相当于Python中的for i in range(len(s)))(为什么这种形式不是遍历值?只遍历值不是更常用?):

1
for i := range s

moretypes/range-continued.go

练习:绘制函数图像

实现函数Pic(dx, dy),返回一个长度为dy的二维切片[][]uint8,其中每个元素是长度为dx的8位无符号整数切片。

运行该程序时,将展示一张图像,将Pic()返回的二维切片作为像素灰度值。每个元素由其x和y坐标计算得到,计算方式可以任意选择,例如(x+y)/2x*yx^y(异或)。

moretypes/exercise-slices.go

不同计算函数得到的图像如下。

(x+y)/2

avg

x xor y

xor

xy

mul

x2 + y2

pol

2.3.6 映射

映射(map)将键映射到值。

类型map[K]V表示键类型为K、值类型为V的映射。映射的零值是nil

使用make()函数创建映射:make(map[K]V)

使用[]运算符访问与键关联的值。例如:

1
2
m := make(map[string]Vertex)
m["Bell Labs"] = Vertex{40.68433, -74.39967}

moretypes/maps.go

映射字面值

映射字面值类似于结构体字面值,但键是必需的:

1
map[K]V{key1: value1, key2: value2, ...}

可以使用map[K]V{}创建空映射。

注意:如果写成多行的形式,最后一行的末尾也必须有逗号。例如:

1
2
3
4
var m = map[string]Vertex{
	"Bell Labs": Vertex{40.68433, -74.39967},
	"Google":    Vertex{37.42202, -122.08408},
}

moretypes/map-literals.go

可以省略字面值中的结构体类型名。例如:

1
2
3
4
var m = map[string]Vertex{
	"Bell Labs": {40.68433, -74.39967},
	"Google":    {37.42202, -122.08408},
}

moretypes/map-literals-continued.go

操作map
  • 插入或更新元素:m[key] = value
  • 读取元素:v := m[key],如果键不存在则返回值类型的零值。
  • 查询键是否存在:v, ok := m[key],如果键存在则oktruev为对应的值;否则okfalsev为值类型的零值。
  • 删除元素:delete(m, key),如果键不存在则什么都不做。
  • 映射之间不能进行相等比较。

moretypes/mutating-maps.go

可以使用range遍历映射的键值对(相当于Python的dict.items()),顺序是不确定的。例如:

1
2
3
4
m := map[string]int{"foo": 1, "bar": 2, "baz": 3}
for k, v := range m {
	fmt.Println(k, v)
}

Go语言没有提供set类型,但可以通过只使用映射的键实现。

练习:单词计数

实现WordCount(s)函数,返回字符串s中每个“单次”的出现次数构成的映射。可以使用strings.Fields()将字符串按空白符分割成单词。

moretypes/exercise-maps.go

注:标准库stringsstrconv包提供了字符串操作。

2.3.7 函数字面值

在Go中函数也是值,可以用作函数参数和返回值。

例如:

1
2
3
4
5
6
7
8
9
10
11
func compute(fn func(float64, float64) float64) float64 {
	return fn(3, 4)
}

func main() {
	hypot := func(x, y float64) float64 {
		return math.Sqrt(x*x + y*y)
	}
	fmt.Println(hypot(5, 12))
	fmt.Println(compute(hypot))
}

moretypes/function-values.go

其中func(x, y float64) float64 {...}函数字面值(function literal),定义了一个匿名函数(相当于C++和Java的Lambda表达式)。参见Go语言规范Function literals

2.3.8 函数闭包

闭包(closure)是引用了函数体外部变量的函数。该函数可以访问和赋值所引用的变量,此时称函数“绑定”到该变量。

例如,每次调用函数adder()都会返回一个闭包,每个闭包绑定到自己的sum变量。

1
2
3
4
5
6
7
func adder() func(int) int {
	sum := 0
	return func(x int) int {
		sum += x
		return sum
	}
}

moretypes/function-closures.go

在上面的程序中,闭包posneg绑定的sum变量是独立的。

练习:斐波那契闭包

实现fibonacci()函数,返回一个函数闭包,该闭包返回连续的斐波那契数(1, 1, 2, 3, 5, …)。

moretypes/exercise-fibonacci-closure.go

注:这个例子类似于Python的生成器,每次调用f都会生成下一个值,但无法用for循环遍历。

3.方法和接口

https://go.dev/tour/methods

3.1 方法

Go没有类,但是可以在类型上定义方法。

方法(method)是带有特殊的接收器(receiver)参数的函数。(注:接收器类似于Python方法的self参数)

接收器出现在单独的参数列表中,位于func关键字和方法名之间。例如,

1
2
3
func (v Vertex) Abs() float64 {
	return math.Sqrt(v.X*v.X + v.Y*v.Y)
}

在这个例子中,Abs()方法有一个Vertex类型的名为v的接收器。

使用.语法来调用方法,例如v.Abs()

methods/methods.go

方法就是带有接收器参数的特殊函数。 Abs()方法等价的普通函数写法如下:

1
2
3
func Abs(v Vertex) float64 {
	return math.Sqrt(v.X*v.X + v.Y*v.Y)
}

methods/methods-funcs.go

也可以为非结构体类型定义方法。例如:

1
2
3
4
5
6
7
8
type MyFloat float64

func (f MyFloat) Abs() float64 {
	if f < 0 {
		return float64(-f)
	}
	return float64(f)
}

注意:即使MyFloatfloat64的别名,也必须使用类型转换。

方法接收器的类型必须与方法定义在同一个包中。不能为其他包中的类型定义方法(包括内置类型,例如int)。但可以通过定义类型别名实现,如上面的例子所示。

methods/methods-continued.go

指针接收器

可以声明带有指针接收器的方法,即接收器类型为*T,其中T本身不能是一个指针类型。

例如,下面的Scale()方法是对*Vertex定义的。

1
2
3
4
func (v *Vertex) Scale(f float64) {
	v.X = v.X * f
	v.Y = v.Y * f
}

带有指针接收器的方法可以修改接收器指针指向的值,而值接收器是原始值的副本。

例如,如果将Scale()方法改为值接收器,那么v.Scale(10)无法修改v的字段,下面的程序将输出5而不是50。

methods/methods-pointers.go

注:与访问字段一样,通过指针调用方法也是使用.语法。

类似地,如果将Scale()重写为接受非指针参数的函数,则函数内修改的只是原始值的副本,不会影响实参。

methods/methods-pointers-explained.go

调用带有指针参数的函数时,实参必须是指针:

1
2
3
var v Vertex
ScaleFunc(v, 5)  // Compile error!
ScaleFunc(&v, 5) // OK

而调用带有指针接收器的方法时,接收器可以是指针也可以是值:

1
2
3
4
var v Vertex
v.Scale(5)  // OK, same as (&v).Scale(5)
p := &v
p.Scale(10) // OK

这是因为Go会自动进行取地址,v.Scale(5)等价于(&v).Scale(5)

methods/indirection.go

反过来,调用带有值参数的函数时,实参必须是值:

1
2
3
var v Vertex
fmt.Println(AbsFunc(v))  // OK
fmt.Println(AbsFunc(&v)) // Compile error!

而调用带有值接收器的方法时,接收器可以是指针也可以是值:

1
2
3
4
var v Vertex
fmt.Println(v.Abs()) // OK
p := &v
fmt.Println(p.Abs()) // OK, same as (*p).Abs()

如果是指针则Go会自动解引用,即p.Abs()等价于(*p).Abs()

methods/indirection-values.go

选择指针接收器的原因有两个:

  • 方法需要修改原始值。
  • 避免值拷贝,对于大结构体将更加高效。

一般来说,一个类型的所有方法应该要么都是值接收器,要么都是指针接收器,不应该混合使用两者(在下一节中将解释原因)。

在下面的例子中,Scale()Abs()方法的接收器类型都是*Vertex,尽管Abs()不需要修改原始值。

methods/methods-with-pointer-receivers.go

3.2 接口

接口(interface)类型定义为一组方法签名的集合。例如:

1
2
3
type Abser interface {
	Abs() float64
}

接口Abser包含一个无参数、返回float64的方法Abs()

只要类型T实现了接口定义的所有方法,就实现了该接口。

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
type MyFloat float64

func (f MyFloat) Abs() float64 {
	if f < 0 {
		return float64(-f)
	}
	return float64(f)
}

type Vertex struct {
	X, Y float64
}

func (v *Vertex) Abs() float64 {
	return math.Sqrt(v.X*v.X + v.Y*v.Y)
}

类型MyFloat*Vertex都定义了Abs()方法,因此实现了Abser接口。

注意:

  • 在上面的例子中,Vertex类型没有实现Abser接口,因为Abs()方法只对*Vertex类型定义了。
  • 如果类型T定义了一个接口的所有方法,但有些是值接收器、有些是指针接收器,则T*T没有实现该接口。这就是为什么推荐一种类型的所有方法的接收器保持一致的原因。

接口类型的变量可以存放任何实现了该接口的类型的值。例如,在下面的程序中,f&v都可以赋给Abser类型的变量a,但v不可以。

1
2
3
4
5
6
7
8
9
10
11
func main() {
	var a Abser
	f := MyFloat(-math.Sqrt2)
	v := Vertex{3, 4}

	a = f  // MyFloat implements Abser
	a = &v // *Vertex implements Abser
	a = v  // error: Vertex does NOT implement Abser

	fmt.Println(a.Abs())
}

methods/interfaces.go

Go的接口是隐式实现的,不需要像Java一样用implements关键字显式声明。这使得接口的定义和实现可以解耦,不需要出现在同一个包中。

methods/interfaces-are-satisfied-implicitly.go

接口值

接口值可以认为是值和实际类型的元组:(value, type)

调用接口值的方法将会调用实际类型对应的同名方法(注:这类似于C++的虚函数调用)。

例如,假设类型*TF都实现了接口I,则下面的两个i.M()分别调用T.M()F.M()

1
2
3
4
5
6
7
var i I

i = &T{"Hello"}
i.M() // calls T.M()

i = F(math.Pi)
i.M() // calls F.M()

methods/interface-values.go

如果接口值的底层值是nil,则调用方法时接收器将会是nil。例如:

1
2
3
4
var i I
var t *T
i = t // interface value with nil underlying value
i.M() // calls T.M() with nil receiver

在某些语言中这会引发空指针异常,但在Go中编写能够处理接收器为nil的方法是很常见的。例如下面例子中的方法M()

1
2
3
4
5
6
7
func (t *T) M() {
	if t == nil {
		fmt.Println("<nil>")
		return
	}
	fmt.Println(t.S)
}

methods/interface-values-with-nil.go

注意:

  • 底层值是nil的接口值本身不是nil
  • 在上面的例子中,如果接收器tnil,则访问t.S仍然会报错 “runtime error: invalid memory address or nil pointer dereference” 。

nil接口值没有底层值和实际类型。对nil接口值调用方法将产生运行时错误,因为无法确定要调用哪个具体方法。

1
2
var i I
i.M() // error: call method on nil interface value

methods/nil-interface-values.go

空接口

不包含任何方法的接口类型称为空接口

1
interface{}

空接口可以表示任何类型的值,因为所有类型都实现了至少0个方法(类似于Java的Object类)。

空接口通常用于处理未知类型的值的代码。例如,fmt.Println()接受任意个数的interface{}类型的参数。

methods/empty-interface.go

类型断言

类型断言(type assertion)用于访问接口值的底层值(即(value, type)中的value)。

1
t := i.(T)

该语句断言接口值i的实际类型为T,并将底层值赋给变量t。如果i的实际类型不是T则会报错(panic)。

测试一个接口值是否是特定的类型,可以使用返回两个值的类型断言:

1
t, ok := i.(T)

如果i的实际类型是T,则oktrueti的底层值;否则okfalsetT类型的零值。

methods/type-assertions.go

类型switch

类型switch类似于普通switch语句,但其中的case指定类型(而不是值),用于与接口值的实际类型进行比较。

例如:

1
2
3
4
5
6
7
8
switch v := i.(type) {
case T:
	// here v has type T
case S:
	// here v has type S
default:
	// no match; here v has the same type as i
}

其中声明部分的语法与类型断言i.(T)相同,只是具体类型T被替换为关键字type

这个switch语句判断接口值i的实际类型是否是TS。在这两个case中,变量v的类型分别是TS,值是i的底层值。在default分支中,v的类型和值与i相同。

methods/type-switches.go

Stringer接口

最常用的接口之一是fmt包定义的Stringer

1
2
3
type Stringer interface {
	String() string
}

Stringer表示可以描述为字符串的类型 (其String()方法类似于Java的Object.toString()和Python的__str__()方法)。

例如,Person类型实现了Stringer接口,可以将自身转换为字符串。

1
2
3
4
5
6
7
8
type Person struct {
	Name string
	Age  int
}

func (p Person) String() string {
	return fmt.Sprintf("%v (%v years)", p.Name, p.Age)
}

如果一个类型实现了Stringer接口,那么fmt包中的打印函数会使用其String()方法返回的字符串,而不是默认表示。

methods/stringer.go

练习:Stringer

IPAddr类型实现fmt.Stringer接口,以点分十进制格式打印IP地址。例如,IPAddr{1, 2, 3, 4}打印成"1.2.3.4"

1
type IPAddr [4]byte

methods/exercise-stringer.go

3.3 错误

Go程序使用error值来表达错误状态。

error类型是一个内置接口:

1
2
3
type error interface {
	Error() string
}

函数通常会返回一个error值,调用代码应当通过检查错误是否为nil来进行错误处理:错误为nil表示成功,否则表示失败。例如:

1
2
3
4
5
6
i, err := strconv.Atoi("42")
if err != nil {
	fmt.Printf("couldn't convert number: %v\n", err)
	return
}
fmt.Println("Converted integer:", i)

methods/errors.go

注:这个例子使用了自定义错误类型MyError。可以使用标准库函数errors.New()来创建error值。

练习:错误

修改之前的练习(2.2.2节)中牛顿法求平方根的Sqrt()函数,当参数为负数时返回一个error值。

创建一个新类型

1
type ErrNegativeSqrt float64

并使其实现error接口:

1
func (e ErrNegativeSqrt) Error() string

例如,ErrNegativeSqrt(-2).Error()返回"cannot Sqrt negative number: -2"

注意:在Error()方法内部调用fmt.Sprint(e)会导致无限递归,因为该函数会调用e.Error()。为了避免这一问题,可以先将e转换为float64类型。

methods/exercise-errors.go

3.4 Reader接口

io包定义了Reader接口,用于读取流数据。Go标准库包含该接口的很多实现,包括文件、网络连接、压缩算法、加密算法等。

io.Reader接口有一个Read()方法:

1
2
3
type Reader interface {
	Read(b []byte) (n int, err error)
}

Read()方法使用读取的数据填充给定的byte切片,返回填充的字节数和错误值。当流结束时返回io.EOF错误。

示例代码创建了一个strings.Reader,用于从字符串读取数据,每次读取8个字节。

methods/reader.go

练习:Reader

实现一个Reader类型,生成字符'A'的无限流。

methods/exercise-reader.go

练习:rot13Reader

一种常见的用法是用一个io.Reader包装另一个io.Reader,并以某种方式修改流。(注:这类似于Java中的组合输入流)

例如,gzip.NewReader()函数接受一个io.Reader参数(压缩的数据流),返回一个*gzip.Reader,该类型也实现了io.Reader(解压缩的数据流)。

编写一个类型rot13Reader,实现io.Reader,从另一个io.Reader读取数据,通过对所有字母字符应用rot13替换加密来修改流。

也就是说,对于输入流中的字母按以下规则替换:

  • 输入:ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz
  • 输出:NOPQRSTUVWXYZABCDEFGHIJKLMnopqrstuvwxyzabcdefghijklm
  • 其他字符不变

例如,输入 “Lbh penpxrq gur pbqr!” ,应该输出 “You cracked the code!” 。

methods/exercise-rot-reader.go

3.5 Image接口

image包定义了Image接口:

1
2
3
4
5
type Image interface {
	ColorModel() color.Model
	Bounds() Rectangle
	At(x, y int) color.Color
}

该接口描述了一个矩形像素网格,Bounds()方法返回其矩形边界,At(x, y)返回坐标(x, y)处像素的颜色。详见文档image.Image

例如:

1
2
3
m := image.NewRGBA(image.Rect(0, 0, 100, 100))
fmt.Println(m.Bounds())
fmt.Println(m.At(0, 0).RGBA())

methods/images.go

练习:Image

实现一个类似于之前的图像生成器(2.3.5节),但这次返回一个image.Image接口值,而不是切片。

自定义一个Image类型,实现image.Image接口:

  • ColorModel()应该返回color.RGBAModel
  • Bounds()方法应该返回一个image.Rectangle,例如image.Rect(0, 0, w, h)
  • At()应该返回一个颜色值,上一个练习中的v值在这里对应color.RGBA{v, v, 255, 255}

methods/exercise-images.go

4.泛型

https://go.dev/tour/generics

4.1 类型参数

Go的函数可以使用类型参数(type parameter)来处理多种类型,称为泛型函数(generic function)。函数的类型参数位于参数列表前的方括号内。例如:

1
func Index[T comparable](s []T, x T) int

这个声明表示s是满足内置约束comparable的任意类型T的切片,x也是相同类型的值。

约束comparable允许对特定类型的值使用==!=运算符。在这个例子中,我们使用它来将一个值与切片的所有元素进行比较,直到找到匹配项。

1
2
3
4
5
6
7
8
9
10
11
// Index returns the index of x in s, or -1 if not found.
func Index[T comparable](s []T, x T) int {
	for i, v := range s {
		// v and x are type T, which has the comparable
		// constraint, so we can use == here.
		if v == x {
			return i
		}
	}
	return -1
}

这个Index()函数适用于任何支持比较的类型。

1
2
3
4
5
6
7
// Index works on a slice of ints
si := []int{10, 20, 15, -10}
fmt.Println(Index(si, 15))

// Index also works on a slice of strings
ss := []string{"foo", "bar", "baz"}
fmt.Println(Index(ss, "hello"))

generics/index.go

4.2 泛型类型

除了泛型函数,Go还支持泛型类型(generic type)。类型也可以带有类型参数,这对于实现泛型数据结构非常有用。

下面的例子展示了一个可以存储任何类型值的单向链表的类型声明。

1
2
3
4
5
6
7
8
9
10
type Node[T any] struct {
	next *Node[T]
	val  T
}

// List represents a singly-linked list that holds values of any type.
type List[T any] struct {
	head *Node[T]
	size int
}

包含方法的完整实现:

generics/list.go

5.并发

https://go.dev/tour/concurrency

5.1 Goroutine

goroutine是由Go运行时管理的轻量级线程。

1
go f(x)

会启动一个新的goroutine来运行函数f(x),参数的求值发生在当前goroutine中,f的执行发生在新的goroutine中。

goroutine在相同的地址空间中运行,因此对共享内存的访问必须要同步。sync包提供了一些有用的原语。

concurrency/goroutines.go

示例程序启动了两个goroutine(其中一个是主goroutine),交替打印 “hello” 和 “world” 。

5.2 channel

channel是一种管道,可以使用运算符<-通过它发送和接收值。

1
2
ch <- v    // Send v to channel ch.
v := <-ch  // Receive from ch, and assign value to v.

(数据沿着箭头方向流动)

与映射和切片一样,channel也必须使用make()函数创建:

1
ch := make(chan int)

默认情况下,发送和接收操作会阻塞,直到另一端准备就绪。这使得goroutine之间无需显式的锁或条件变量即可同步。

示例代码使用两个goroutine分别对切片前半部分和后半部分的数字求和。一旦两个goroutine都完成计算,就会计算最终结果。

concurrency/channels.go

缓冲channel

channel可以带缓冲(buffered)。为了创建缓冲channel,需要将缓冲大小作为make()的第二个参数:

1
ch := make(chan int, 100)

cap(ch)返回channel的缓冲长度。

对于缓冲channel,发送操作只有当缓冲满时才会阻塞,接收操作只有当缓冲为空时才会阻塞。

concurrency/buffered-channels.go

在上面的例子中,如果将ch的容量改为1,或者在第一个打印语句之前再加一个ch <- 3,或者在最后再增加一个打印语句fmt.Println(<-ch),则程序会陷入死锁:

1
fatal error: all goroutines are asleep - deadlock!

range和close

发送者可以使用close()关闭一个channel,表示没有更多的值要发送。

接收者可以使用返回两个值的接收表达式来检查channel是否已经被关闭:

1
v, ok := <-ch

如果没有可接收的值并且channel已关闭,则okfalse

循环for i := range c不断地从channel接收值,直到它被关闭。

注意:

  • 只有发送者应该关闭channel,接收者绝不能关闭。向已关闭的channel发送值会导致panic。
  • channel不像文件,通常不需要关闭。除非必须要告诉接收者没有更多的值,例如终止range循环。

concurrency/range-and-close.go

5.3 select语句

select语句允许goroutine等待多个通信操作。

select语句会阻塞直到某个case可以执行,然后执行该分支。如果有多个case准备就绪,则随机选择一个。

例如,假设cquit是两个channel,下面的语句当c可写入时执行第一个分支,当quit可读取时执行第二个分支。

1
2
3
4
5
6
7
select {
case c <- x:
	x, y = y, x+y
case <-quit:
	fmt.Println("quit")
	return
}

concurrency/select.go

注:

  • switch语句不同,select的各个case不是从上到下判断的。
  • Go的select语句类似于Python的select模块。

如果select语句中没有其他分支准备就绪,则执行default分支。

可以使用default分支尝试发送或接收操作,而不会阻塞:

1
2
3
4
5
6
select {
case i := <-c:
	// use i
default:
	// receiving from c would block
}

concurrency/default-selection.go

练习:等价二叉树

可能有许多不同的二叉树存储相同的值序列。例如,以下是两棵存储序列1, 1, 2, 3, 5, 8, 13的二叉树。

tree

在这个练习中,将利用Go的并发和channel机制来编写一个检查两棵二叉树是否存储相同序列的函数。

二叉树类型定义如下:

1
2
3
4
5
type Tree struct {
	Left  *Tree
	Value int
	Right *Tree
}

1.实现Walk()函数,用于对二叉树进行中序遍历,并将结果发送到channel。

1
func Walk(t *tree.Tree, ch chan int)

2.测试Walk()函数。使用tree.New(k)创建一个随机结构但有序的二叉树,存储值k, 2k, …, 10k。创建一个channel ch并启动一个goroutine来执行Walk()

1
go Walk(tree.New(1), ch)

ch读取并打印10个值,结果应该为1, 2, …, 10。

3.实现Same()函数,使用Walk()来确定t1t2是否存储相同的值(即判断两棵二叉树的中序遍历结果是否相同)。

1
func Same(t1, t2 *tree.Tree) bool

4.测试Same()函数。Same(tree.New(1), tree.New(1))应该返回trueSame(tree.New(1), tree.New(2))应该返回false

关于tree包的细节参见文档

concurrency/exercise-equivalent-binary-trees.go

5.4 sync.Mutex

使用channel进行goroutine间通信非常方便。但如果我们不需要通信,只是想确保同一时刻只有一个goroutine可以访问某个变量,以避免冲突呢?

这种概念称为互斥(mutual exclusion),提供这种特性的数据结构的通常称为互斥锁(mutex)。

Go标准库提供了sync.Mutex类型,有Lock()Unlock()两个方法(注:相当于Java中的锁)。

下面的例子实现了一个并发安全的计数器。map本身不是并发安全的,要在多个goroutine中访问同一个map必须进行同步。

可以通过使用Lock()Unlock()包围一个代码块使其成为互斥执行的(即临界区),如Inc()方法所示。也可以使用defer来确保Mutex会被解锁,如Value()方法所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// SafeCounter is safe to use concurrently.
type SafeCounter struct {
	mu sync.Mutex
	v  map[string]int
}

// Inc increments the counter for the given key.
func (c *SafeCounter) Inc(key string) {
	c.mu.Lock()
	// Lock so only one goroutine at a time can access the map c.v.
	c.v[key]++
	c.mu.Unlock()
}

// Value returns the current value of the counter for the given key.
func (c *SafeCounter) Value(key string) int {
	c.mu.Lock()
	// Lock so only one goroutine at a time can access the map c.v.
	defer c.mu.Unlock()
	return c.v[key]
}

concurrency/mutex-counter.go

练习:网络爬虫

在这个练习中,将使用Go的并发特性实现并行网络爬虫。

实现Crawl()函数,并行爬取URL,但不要爬取重复的URL。

注:

  • fakeResult表示页面内容及其包含的URL。
  • fakeFetcher是一个预定义的URL到fakeResult的映射。其Fetch()方法在映射中查找给定的URL,用于模拟页面爬取过程,如果URL不存在则返回一个错误。

concurrency/exercise-web-crawler.go

This post is licensed under CC BY 4.0 by the author.