程序结构

声明与变量

在 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 形式可遍历切片或映射

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

也是融合了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 块中使用。ifelse 的使用方式除去小括号外与C++一致

swtich case

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

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

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

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

  • 无条件的 switchswitch 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 语句会将函数推迟到外层函数返回之后执行。推迟调用的函数其参数会立即求值,但只会在外层函数返回前该函数才会被调用

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

复合数据类型

指针

一如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)

map映射

map 映射将键映射到值。

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

1
var m map[string]int

声明一个key为string类型,value为int类型的map

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

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

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

修改映射

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

1
m[key] = elem

获取元素:

1
elem = m[key]

删除元素:

1
delete(m, key)

通过双赋值检测某个键是否存在:

1
elem, ok = m[key]

keym 中,oktrue ;否则,okfalse

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

:若 elemok 还未声明,你可以使用短变量声明:

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时会在上一次加和的基础上进行计算

方法与接口

方法

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 将会是其底层值,而 oktrue

否则,ok 将为 falset 将为 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 表示失败。

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 函数来处理多种类型。 函数的类型参数出现在函数参数之前的方括号之间。

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

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

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

泛型类型

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

并发

Go 程(goroutine)

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

1
go f(x, y, z)

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

1
f(x, y, z)

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

信道

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

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

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

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

1
ch := make(chan int)

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

带缓冲的信道

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

1
ch := make(chan int, 100)

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

缓冲区若填满会产生死锁