包和工具

在每个Go语言源文件的开头都必须有包声明语句。包声明语句的主要目的是确定当前包被其它包导入时默认的标识符(也称为包名)。

例如,math/rand包的每个源文件的开头都包含package rand包声明语句,所以当你导入这个包,你就可以用rand.Int、rand.Float64类似的方式访问包的成员。s

通常来说,默认的包名就是包导入路径名的最后一段,因此即使两个包的导入路径不同,它们依然可能有一个相同的包名。例如,math/rand包和crypto/rand包的包名都是rand。

Go 语言规定:同一个目录下的所有 Go 文件必须属于同一个包🔥🔥🔥。

如果我们想同时导入两个有着名字相同的包,例如math/rand包和crypto/rand包,那么导入声明必须至少为一个同名包指定一个新的包名以避免冲突。这叫做导入包的重命名。

1
2
3
4
import (
"crypto/rand"
mrand "math/rand" // alternative name mrand avoids conflict
)

导入包重命名是一个有用的特性,它不仅仅只是为了解决名字冲突。如果导入的一个包名很笨重,特别是在一些自动生成的代码中,这时候用一个简短名称会更方便。选择用简短名称重命名导入包时候最好统一,以避免包名混乱。选择另一个包名称还可以帮助避免和本地普通变量名产生冲突。例如,如果文件中已经有了一个名为path的变量,那么我们可以将“path”标准包重命名为pathpkg。

每个导入声明语句都明确指定了当前包和被导入包之间的依赖关系。如果遇到包循环导入的情况,Go语言的构建工具将报告错误。

在 Go 的工程实践中,包(Package)分为两类:

类型 声明方式 用途
可执行包 package main 编译后产生 .exe (Windows) 或 二进制文件 (Linux),可以直接运行。
依赖包/库 package <任意名称> 不能直接运行,只能被其他程序通过 import 引用,提供功能支持。

程序结构

声明与变量

在 Go 语言中,字面量(Literal) 的概念和 C++ 中的字面量非常相似,都是指在代码中直接表示固定值的符号或表达式。它们用于表示基本数据类型(如整数、浮点数、字符、字符串等)的常量值,而无需通过变量或计算来获取。

场景 短赋值 := 一般赋值 =
函数内声明新变量 ✅ ❌(需配合 var)
包作用域变量赋值 ❌ ✅
仅赋值(变量已声明) ❌ ✅
多变量赋值(含新变量) ✅ ❌
显式指定变量类型 ❌ ✅(配合 var)

流程控制

for

Go 只有一种循环结构:for 循环。基本的 for 循环由三部分组成,它们用分号隔开,表达式外无需小括号 ( ),而大括号 { } 则是必须的

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

初始化语句和后置语句是可选的,且可以去掉分号,此时for相当于C++中的while

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

如果省略循环条件,该循环就不会结束,因此无限循环可以写得很紧凑 for { }

for 循环的 range 形式

  • 每次迭代都会返回两个值。 第一个值为当前元素的下标,第二个值为该下标所对应元素的一份副本🔥🔥🔥

  • 可以遍历 silce 以及 map,string应该也行

也是融合了c++两种for循环写法,索引遍历和迭代器遍历

可以将下标或值赋予 _ 来忽略它。

1
2
for i, _ := range pow
for _, value := range pow

if else

Go 的 if 语句与 for 循环类似,表达式外无需小括号 ( ),而大括号 { } 则是必须的

  • 和 for 一样,if 语句可以在条件表达式前执行一个简短语句,该语句声明的变量作用域仅在 if 之内。

1
2
3
4
if v := math.Pow(x, n); v < lim {
fmt.Println(v)
return v
}

在 if 的简短语句中声明的变量同样可以在对应的任何 else 块中使用。if 和 else 的使用方式除去小括号外与C++一致

swtich case

switch 语句是编写一连串 if - else 语句的简便方法。它运行第一个 case 值 值等于条件表达式的子句。

Go 只会运行选定的 case,而非之后所有的 case。 在效果上,Go 的做法相当于这些语言中为每个 case 后面自动添加了所需的 break 语句。 在 Go 中,除非以 fallthrough 语句结束,否则分支会自动终止。

  • case 无需为常量,且取值不限于整数

  • case 语句从上到下顺次执行,直到匹配成功时停止

  • 无条件的 switch 同 switch true 一样,该形式能将一长串 if-then-else 写得更加清晰。

1
2
3
4
5
6
7
8
9
10
11
func main() {
t := time.Now()
switch {
case t.Hour() < 12:
fmt.Println("早上好!")
case t.Hour() < 17:
fmt.Println("下午好!")
default:
fmt.Println("晚上好!")
}
}

🔥defer推迟

defer 语句会将函数推迟到外层函数返回之后执行。推迟调用的函数其参数会立即求值,但只会在外层函数返回前该函数才会被调用

  • 推迟调用的函数调用会被压入一个栈中。 当外层函数返回时,被推迟的调用会按照后进先出的顺序调用。

基础数据类型

字符

Unicode建立二进制编码与字符的映射,UTF-8是一个将Unicode码点编码为字节序列的变长编码,下图中x的部分是Unicode定义的字符二进制编码,每个字节中显式定义的字节前缀就是UTF编码格式

1
2
3
4
0xxxxxxx                             runes 0-127    (ASCII)
110xxxxx 10xxxxxx 128-2047 (values <128 unused)
1110xxxx 10xxxxxx 10xxxxxx 2048-65535 (values <2048 unused)
11110xxx 10xxxxxx 10xxxxxx 10xxxxxx 65536-0x10ffff (other values unused)

在 Go 语言中,rune 是一个内置的数据类型别名,它实际上是 int32 的别名。

得益于UTF8编码优良的设计,诸多字符串操作都不需要解码操作。我们可以不用解码直接测试一个字符串是否是另一个字符串的前缀:

  • 解码: 特指将 UTF-8 字节序列 -> 转换为 Unicode 码点序列 ([]byte -> []rune)。

每一个UTF8字符解码,不管是显式地调用utf8.DecodeRuneInString解码或是在range循环中隐式地解码,如果遇到一个错误的UTF8编码输入,将生成一个特别的Unicode字符\uFFFD,在印刷中这个符号通常是一个黑色六角或钻石形状,里面包含一个白色的问号"?"。当程序遇到这样的一个字符,通常是一个危险信号,说明输入并不是一个完美没有错误的UTF8字符串🔥🔥🔥。

字符串

内置的 len 函数可以返回一个字符串中的字节数目(不是 rune 字符数目),索引操作 s [ i ] 返回第 i 个字节的字节值,i必须满足 0 ≤ i < len( s ) 条件约束。

  • 反引号用于原生字符串字面量,里面的内容不会做任何转义处理,写什么就是什么,适合包含特殊字符或多行字符串。(注:在原生字符串面值内部是无法直接写字符的,可以用八进制或十六进制转义或+""连接字符串常量完成)

  • 双引号用于解释型字符串字面量,里面的内容会做转义处理,比如 "\n" 会被解释为换行。

原生字符串面值用于编写正则表达式会很方便,因为正则表达式往往会包含很多反斜杠。原生字符串面值同时被广泛应用于HTML模板、JSON面值、命令行提示信息以及那些需要扩展到多行的场景。

字符串包

标准库中有四个包对字符串处理尤为重要:bytes、strings、strconv和unicode包。

  • strings包提供了许多如字符串的查询、替换、比较、截断、拆分和合并等功能。

  • bytes包也提供了很多类似功能的函数,但是针对和字符串有着相同结构的[]byte类型。因为字符串是只读的,因此逐步构建字符串会导致很多分配和复制。在这种情况下,使用bytes.Buffer类型将会更有效,稍后我们将展示。

  • strconv包提供了布尔型、整型数、浮点数和对应字符串的相互转换,还提供了双引号转义相关的转换。

  • unicode包提供了IsDigit、IsLetter、IsUpper和IsLower等类似功能,它们用于给字符分类。每个函数有一个单一的rune类型的参数,然后返回一个布尔值。而像ToUpper和ToLower之类的转换函数将用于rune字符的大小写转换。所有的这些函数都是遵循Unicode标准定义的字母、数字等分类规范。strings包也有类似的函数,它们是ToUpper和ToLower,将原始字符串的每个字符都做相应的转换,然后返回新的字符串。

字符串和数字的转换

由strconv包提供这类转换功能

将一个整数转为字符串,一种方法是用fmt.Sprintf返回一个格式化的字符串;另一个方法是用strconv.Itoa(“整数到ASCII”):

1
2
3
x := 123
y := fmt.Sprintf("%d", x)
fmt.Println(y, strconv.Itoa(x)) // "123 123"

FormatInt和FormatUint函数可以用不同的进制来格式化数字:

1
fmt.Println(strconv.FormatInt(int64(x), 2)) // "1111011"

mt.Printf函数的%b、%d、%o和%x等参数提供功能往往比strconv包的Format函数方便很多,特别是在需要包含有附加额外信息的时候:

1
s := fmt.Sprintf("x=%b", x) // "x=1111011"

如果要将一个字符串解析为整数,可以使用strconv包的Atoi或ParseInt函数,还有用于解析无符号整数的ParseUint函数:

1
2
x, err := strconv.Atoi("123")             // x is an int
y, err := strconv.ParseInt("123", 10, 64) // base 10, up to 64 bits

ParseInt函数的第三个参数是用于指定整型数的大小;例如16表示int16,0则表示int。在任何情况下,返回的结果y总是int64类型,你可以通过强制类型转换将它转为更小的整数类型。

有时候也会使用fmt.Scanf来解析输入的字符串和数字,特别是当字符串和数字混合在一行的时候,它可以灵活处理不完整或不规则的输入。

复合数据类型

指针

一如C语言,其保存了值的内存地址

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

1
var p *int

& 操作符会生成一个指向其操作数的指针。

1
2
i := 42
p = &ig

* 操作符表示指针指向的底层值。

1
2
fmt.Println(*p) // 通过指针 p 读取 i
*p = 21 // 通过指针 p 设置 i

这也就是通常所说的「解引用」或「间接引用」。

与 C 不同,Go 无法对指针进行四则运算,即直接修改地址,能够保障内存的安全性

结构体

一个 结构体(struct)就是一组 字段(field)

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

结构体指针

如果我们有一个指向结构体的指针 p 那么可以通过 (*p).X 来访问其字段 X。 不过这么写太啰嗦了,所以语言也允许我们使用隐式解引用,直接写 p.X 就可以。

数组

类型 [n]T 表示一个数组,它拥有 n 个类型为 T 的值。

1
var a [10]int

会将变量 a 声明为拥有 10 个整数的数组。

  • 数组的长度是其类型的一部分,因此数组不能改变大小。

🔥切片

类型 []T 表示一个元素类型为 T 的切片。.

切片通过两个下标来界定,一个下界和一个上界,二者以冒号分隔:

1
a[low : high]

它会选出一个半闭半开区间,包括第一个元素,但排除最后一个元素。

  • 切片就像数组的引用,切片并不存储任何数据,它只是映射了底层数组,更改切片的元素会修改其底层数组中对应的元素

  • 切片下界的默认值为 0,上界则是该切片的长度

切片拥有 长度 和 容量。

  • 切片的长度就是它所包含的元素个数

  • 切片的容量是从它的第一个元素开始数,到其底层数组元素末尾的个数

  • 切片 s 的长度和容量可通过表达式 len(s) 和 cap(s) 来获取

切片的零值是 nil

  • nil 切片的长度和容量为 0 且没有底层数组

切片可以用内置函数 make 来创建,这也是你创建动态数组的方式。

make 函数会分配一个元素为零值的数组并返回一个引用了它的切片:

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

要指定它的容量,需向 make 传入第三个参数:

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

切片可以包含任何类型,当然也包括其他切片,可以使用切片嵌套来创建更高维的数据结构

Go 提供了内置的 append 函数为切片追加新的元素,内置函数的文档对该函数有详细的介绍。

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

append 的第一个参数 s 是一个元素类型为 T 的切片,其余类型为 T 的值将会追加到该切片的末尾

  • 当 s 的底层数组太小,不足以容纳所有给定的值时,它就会分配一个更大的数组。 返回的切片会指向这个新分配的数组。真的像vector吧

1
2
var s []int
s = append(s, 0)

复合字面量命名

在 out := []int{} 这行代码里,大括号 {} 是复合字面量(composite literal)的语法,用来表示一个空的切片字面量。

拆开看:

  • []int 是类型:元素为 int 的切片。

  • {} 是值:一个空的元素列表。

所以 out := []int{} 的意思是:声明变量 out,类型是 []int,并把它初始化为一个长度为 0、容量为 0 的空切片(但它不是 nil 切片)。

1
2
var out []int      // out == nil,长度 0,容量 0
out := []int{} // out != nil,长度 0,容量 0

两者长度都是 0,都能直接 append,但有一个关键区别:[]int{} 是非 nil 的空切片。

这会影响:

  1. JSON 序列化:nil 切片序列化成 null,空切片序列化成 []。如果你希望接口返回 [] 而不是 null,就应该用 []int{}。

  2. 和 nil 比较:out == nil 对 []int{} 是 false。

  3. 反射/测试断言:有些测试会区分 nil 和空切片。

map映射

map 映射将键映射到值。

  • 映射的零值为 nil 。nil 映射既没有键,也不能添加键。

1
var m map[string]int

声明一个key为string类型,value为int类型的map,此时的map没有引用任何哈希表,属于零值

1
m := make(map[string]int)

声明一个key为rune类型,value为[]string 类型的map

1
m := make(map[rune][]string)

短赋值一个key为string类型,value为int类型的map

映射的字面量和结构体类似,只不过必须有键名,若顶层类型只是一个类型名,那么你可以在字面量的元素中省略它。

修改映射

在映射 m 中插入或修改元素:

1
m[key] = elem

获取元素:

1
elem = m[key]

删除元素:

1
delete(m, key)

通过双赋值检测某个键是否存在(这种形式可以在不希望因为查找操作自动插入新元素时使用):

1
elem, ok = m[key]

若 key 在 m 中,ok 为 true ;否则,ok 为 false。

若 key 不在映射中,则 elem 是该映射元素类型的零值。

注:若 elem 或 ok 还未声明,你可以使用短变量声明:

1
elem, ok := m[key]

函数

函数也是值

  • 它们可以像其他值一样传递。

  • 函数值可以用作函数的参数或返回值。

Go 函数可以是一个闭包。闭包是一个函数值,它引用了其函数体之外的变量。 该函数可以访问并赋予其引用的变量值,换句话说,该函数被“绑定”到了这些变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
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))
fmt.Println(compute(math.Pow))
//结果为13 5 81
}

函数闭包🔥🔥🔥

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func adder() func(int) int {
sum := 0
return func(x int) int {
sum += x
return sum
}
}

func main() {
pos, neg := adder(), adder()
for i := 0; i < 10; i++ {
fmt.Println(
pos(i),
neg(-2*i),
)
}
}

pos 和 neg 这两个变量,都是通过 adder() 函数创建的闭包

  • adder() 函数返回一个函数 func(int) int

即pos和neg变量都可以视作为一个函数,并且具有一个外部变量sum:

1
2
3
4
func(x int) int {
sum += x
return sum
}

它们引用的闭包中的 sum 变量跟随 main 函数的生命周期,因此每次调用pos和neg时会在上一次加和的基础上进行计算

复合字面量

复合字面量(composite literal)是 Go 里构造值的核心语法

1
2
3
4
5
6
Point{X: 1, Y: 2}          // struct,带字段名
Point{1, 2} // struct,按位置
[]int{1, 2, 3} // slice
[3]int{1, 2, 3} // array
map[string]int{"a": 1} // map
[]Point{{1,2}, {3,4}} // 嵌套时可省略内层类型名

嵌套省略类型是个高频技巧:[]Point{{1,2},{3,4}} 里内层 Point 可省,因为外层已经确定了元素类型。这在二维结构里很省字。

结构体

字段名可以换行、顺序随意

1
2
3
4
p := Point{
Y: 2,
X: 1, // 顺序无所谓
}

只写部分字段 → 其余是零值

1
2
3
4
5
6
type Config struct {
Host string
Port int
TLS bool
}
c := Config{Host: "localhost"} // Port=0, TLS=false

空字面量 T{} 及 new(T)

1
2
3
p := Point{}      // 值,零值
q := &Point{} // 指针,指向零值(等价 new(Point) 但更常用)
r := new(Point) // 指针,零值
  • T{} 得到值。

  • &T{} 得到指针,是 Go 里"构造并取地址"的惯用法,比 new 更常见(尤其要初始化字段时:&Point{X:1})。

  • new(T) 只能得到零值指针,不能设字段,所以有初始值时一律用 &T{...}。

切片/映射

预知容量时配 make,而不是纯字面量

1
2
s := make([]int, 0, 100)   // 预分配,避免多次扩容
s = append(s, ...)

字面量 []int{...} 适合元素已知且固定;动态填充用 make + append 更高效。

二维切片可以省内层 []T

1
2
3
4
grid := [][]int{
{1, 2, 3},
{4, 5, 6}, // 内层 []int 省略了
}

map 字面量的尾逗号规则

1
2
3
4
m := map[string]int{
"a": 1,
"b": 2, // 换行时最后一项也必须带逗号
}

方法与接口

方法

Go 没有类。不过你可以为类型定义方法。

  • 方法就是一类带特殊的 接收者 参数的函数。

  • 方法接收者在它自己的参数列表内,位于 func 关键字和方法名之间。

在此例中,Abs 方法拥有一个名字为 v,类型为 Vertex 的接收者

1
2
3
4
5
6
7
8
9
10
11
12
type Vertex struct {
X, Y float64
}

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

func main() {
v := Vertex{3, 4}
fmt.Println(v.Abs())
}

方法只是个带接收者参数的函数,上下两段代码等效

1
2
3
4
5
6
7
8
9
10
11
12
type Vertex struct {
X, Y float64
}

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

func main() {
v := Vertex{3, 4}
fmt.Println(Abs(v))
}

也可以为非结构体类型声明方法,但接收者的类型定义和方法声明必须在同一包内

接收者参数需要在函数内再进行一次赋值才能进行迭代,若是直接对接收者参数操作则只会产生一个新的局部变量,无法真正影响到接收者参数的值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
type List[T any] struct {
next *List[T]
val T
}

func (l *List[T]) Print() {
current := l
for current != nil {
fmt.Println(current.val)
current = current.next
}
}

//错误写法:
//这种写法是修改了方法内的局部变量 l,修改不会影响下一次循环,原始的 l 指针并没有改变
func (l *List[T]) Print() {
if l != nil {
fmt.Println(l.val)
l = l.next
}

func main() {
head := &List[int]{nil, 1}
head.next = &List[int]{nil, 2}
head.next.next = &List[int]{nil, 3}
head.Print()
}

接口

接口类型 的定义为一组方法签名,接口类型的变量可以持有任何实现了这些方法的值。

接口也是值。它们可以像其它值一样传递。

  • 接口值可以用作函数的参数或返回值。

  • 在内部,接口值可以看做包含值和具体类型的元组:

1
(value, type)
  • 接口值保存了一个具体底层类型的具体值。

  • 接口值调用方法时会执行其底层类型的同名方法。

接口的本质:

  • 接口就像是一个"合同"或"协议"

  • 它定义了一组方法,但只声明这些方法应该做什么,而不具体实现

  • 任何类型只要实现了接口中定义的所有方法,就自动实现了这个接口

即便接口内的具体值为 nil,方法仍然会被 nil 接收者调用, 保存了 nil 具体值的接口其自身并不为 nil。

nil 接口值(即仅声明不赋值)既不保存值也不保存具体类型,为 nil 接口调用方法会产生运行时错误,因为接口的元组内并未包含能够指明该调用哪个 具体 方法的类型。

指定了零个方法的接口值被称为空接口:

1
interface{}

空接口可保存任何类型的值。(因为每个类型都至少实现了零个方法。)

空接口被用来处理未知类型的值。例如,fmt.Print 可接受类型为 interface{} 的任意数量的参数。

类型断言

类型断言 提供了访问接口值底层具体值的方式。

1
t := i.(T)

该语句断言接口值 i 保存了具体类型 T,并将其底层类型为 T 的值赋予变量 t。

若 i 并未保存 T 类型的值,该语句就会触发一个 panic。

为了 判断 一个接口值是否保存了一个特定的类型,类型断言可返回两个值:其底层值以及一个报告断言是否成功的布尔值。

1
t, ok := i.(T)

若 i 保存了一个 T,那么 t 将会是其底层值,而 ok 为 true。

否则,ok 将为 false 而 t 将为 T 类型的零值,程序并不会产生 panic。

Stringer

fmt 包中定义的 Stringer 是最普遍的接口之一。

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

Stringer 是一个可以用字符串描述自己的类型。fmt 包(还有很多包)都通过此接口来打印值。

1
2
3
4
5
6
7
8
9
func (p Person) String() string {
return fmt.Sprintf("%v (%v years)", p.Name, p.Age)
}

func main() {
a := Person{"Arthur Dent", 42}
z := Person{"Zaphod Beeblebrox", 9001}
fmt.Println(a, z)
}

可以直接通过Stringer 自定义输出类型

错误与处理

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

与 fmt.Stringer 类似,error 类型是一个内建接口:

1
2
3
type error interface {
Error() string
}
  • error 是一个接口,它要求实现者必须提供一个 Error() string 方法。

  • 任何类型只要实现了 Error() string 方法,就可以作为 error 类型使用。

(与 fmt.Stringer 类似,fmt 包也会根据对 error 的实现来打印值。)

通常函数会返回一个 error 值,调用它的代码应当判断这个错误是否等于 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)

error 为 nil 时表示成功;非 nil 的 error 表示失败。

检查与解包:errors.Is 与 errors.As

不要使用 == 直接比较错误,也不要直接使用类型断言,因为包装后的错误会破坏这些操作。

  • errors.Is(err, target):判断错误链中是否包含特定的“哨兵错误”(Sentinel Error)。它取代了 err == target。例如,检查文件是否存在:errors.Is(err, os.ErrNotExist)。

  • errors.As(err, &target):判断错误链中是否包含特定类型的错误,并将其赋值给目标变量。它取代了 err.(*MyError) 这样的类型断言。

创建与包装错误

在实际应用中,我们很少直接返回裸的错误信息,而是需要携带上下文。

基础创建与自定义类型

  • errors.New:创建一个只包含静态文本的错误。

  • 自定义错误类型:通过定义结构体并实现 Error() 方法,可以携带结构化的数据(如错误码、具体参数等)。随后可以使用 errors.As 将其从错误链中提取出来。

包装错误(Wrapping) 为了给错误添加调用上下文(例如“读取配置失败”),同时又不丢失原始错误,Go 1.13 引入了 %w 动词。

1
2
3
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}

使用 %w 包装后,底层错误被“隐藏”在链中,但仍然可以被 errors.Is 和 errors.As 发现。如果使用 %v,则只会拼接文本,丢失错误链。

多错误合并 从 Go 1.20 开始,errors.Join 可以将多个独立的错误合并成一个。这在并发任务或清理操作中非常有用,且合并后的错误依然支持 errors.Is/As 检查。

数据读写

Readers

io 包指定了 io.Reader 接口,它表示数据流的读取端。

Go 标准库包含了该接口的许多实现,包括文件、网络连接、压缩和加密等等。

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

1
func (T) Read(b []byte) (n int, err error)

b []byte 表示Read输入数据所使用的buffer,返回的 int 为buffer长度(填充的字节数),error 为错误值,在遇到数据流的结尾时,它会返回一个 io.EOF 错误。

示例代码创建了一个 strings.Reader 并以每次 8 字节的速度读取它的输出。

嵌套Reader

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
type rot13Reader struct {
r io.Reader
}

func (r rot13Reader) Read(b []byte) (int, error) {
// 首先从底层的 Reader 读取数据
n, err := r.r.Read(b)
if err != nil {
return n, err
}

for i := range n {
switch {
case b[i] >= 'A' && b[i] <= 'Z':
b[i] = 'A' + (b[i]-'A'+13)%26
case b[i] >= 'a' && b[i] <= 'z':
b[i] = 'a' + (b[i]-'a'+13)%26
}
}
return n, nil
}

func main() {
s := strings.NewReader("Lbh penpxrq gur pbqr!")
r := rot13Reader{s}
io.Copy(os.Stdout, &r)
}

一个实现了 io.Reader 并从另一个 io.Reader 中读取数据的 rot13Reader,通过应用 rot13 代换密码对数据流进行修改。

并发

Go 程(goroutine)

由 Go 运行时管理的轻量级线程,go后跟的函数的参数会在go语句自身执行时被求值🔥🔥🔥。

1
go f(x, y, z)

会启动一个新的 Go 协程并执行

1
f(x, y, z)

f, x, y 和 z 的求值发生在当前的 Go 协程中,而 f 的执行发生在新的 Go 协程中。

下列代码存在一个常见的 goroutine 与循环变量捕获的问题

1
2
3
4
5
for _, f := range filenames {  // f 是单个变量,每次迭代被重用
go func() {
thumbnail.ImageFile(f) // 所有goroutine共享同一个f的引用
}()
}
  • f 是单个变量,在每次迭代中被重用(内存地址不变,只是值改变)

  • 闭包捕获的是变量 f 本身,而不是迭代时的值

  • goroutine 执行时,f 可能已经指向下一个值

每个迭代启动一个 goroutine,但 goroutine 不会立即执行,可能会导致所有go程只会执行最后一个文件

信道

信道是带有类型的管道,元素先进先出,你可以通过它用信道操作符 <- 来发送或者接收值。

1
2
ch <- v    // 将 v 发送至信道 ch。
v := <-ch // 从 ch 接收值并赋予 v。

(“箭头”就是数据流的方向。)

和映射与切片一样,信道在使用前必须创建:

1
ch := make(chan int)

默认情况下,发送和接收操作在另一端准备好之前都会阻塞。这使得 Go 程可以在没有显式的锁或竞态变量的情况下进行同步。

带缓冲的信道

信道可以是 带缓冲的。将缓冲长度作为第二个参数提供给 make 来初始化一个带缓冲的信道:

1
ch := make(chan int, 100)

仅当信道的缓冲区填满后,向其发送数据时才会阻塞。当缓冲区为空时,接受方会阻塞。缓冲区若填满会产生死锁

从 channel 持续接收值,直到 channel 被关闭

1
for v := range ch

大致等价于

1
2
3
4
5
6
7
for {
v, ok := <-ch
if !ok {
break
}
out = append(out, v)
}

所以 range ch 的关键点是:它会在 channel 关闭后自动退出,不需要手动判断 ok。

sync.WaitGroup

方法 作用
Add(delta int) 计数器 += delta,在启动 goroutine 前调用
Done() 计数器 -= 1,通常 defer wg.Done()
Wait() 阻塞直到计数器归 0

标准模板

1
2
3
4
5
6
7
8
9
10
11
var wg sync.WaitGroup

for i := 0; i < n; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
// work
}(i)
}

wg.Wait() // 等所有完成

关键规则(踩坑点)

  1. Add 必须在启动 goroutine 之前,不能放进 goroutine 内部,否则 Wait 可能提前返回(竞争)。

  2. Add 的增量要和 Done 次数匹配。Done 多于 Add → panic: sync: negative WaitGroup counter。

  3. WaitGroup 不可拷贝(含 sync.noCopy),传参用指针 *sync.WaitGroup。

  4. Wait 之后可以复用:计数器归 0 后,可以再次 Add 开始新一轮(但不要和正在进行的 Wait 混用同一轮)。

  5. 协调者不参与计数:像"等所有 worker 完成再 close(ch)"的那个 goroutine,只 wg.Wait(),不要 wg.Done()。

sync.Mutex / sync.RWMutex

1
2
3
4
5
6
7
8
9
10
var mu sync.Mutex
mu.Lock()
// 临界区
mu.Unlock()

var rw sync.RWMutex
rw.RLock() // 多读并发
rw.RUnlock()
rw.Lock() // 写独占
rw.Unlock()
  • 用 defer mu.Unlock() 防漏解锁。

  • 优先"不共享":把累加等写操作收拢到单 goroutine,就不用锁(channel 通信优于共享内存)。

泛型

泛型函数

可以使用类型参数编写 Go 函数来处理多种类型。 函数的类型参数出现在函数参数之前的方括号之间。

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

此声明意味着 s 是满足内置约束 comparable 的任何类型 T 的切片。 x 也是相同类型的值。

comparable 是一个有用的约束,它能让我们对任意满足该类型的值使用 == 和 != 运算符。在此示例中,我们使用它将值与所有切片元素进行比较,直到找到匹配项。 该 Index 函数适用于任何支持比较的类型。

泛型类型

除了泛型函数之外,Go 还支持泛型类型。 类型可以使用类型参数进行参数化,这对于实现通用数据结构非常有用。

Go函数轮子

字符串

1
2
3
4
import "strconv" //字符串操作包

func Itoa(x int) string //int转字符串
func Atoi(s string) (int, error) //字符串转int

其他

Go 没有 public / private 关键字,而是用首字母大小写决定可见性:

  • TaskId、TaskType、FileName → 导出,包外可见

  • taskId、taskType、fileName → 包私有,包外不可见