Learn Go(Boot.dev)
Boot.dev 的 Learn Go 课程框架笔记——Go 语言入门主线(语法、值语义、函数、控制流),对应仓库 Go 学习路线阶段一~三。
Learn Go(Boot.dev)
[!info] 关联笔记
- 所属路线:Go 学习路线 · Go MOC
- 来源平台:Boot.dev
- 对象:Go · Go 设计哲学
- 后续课程:boot-dev-learn-http-protocol-in-go · boot-dev-learn-http-clients-in-go · boot-dev-learn-http-servers-in-go
概览
这是 Boot.dev 中 Go 语言基础课程的学习记录。下面主要是我在学习过程中的一些笔记和记录。
Function
函数部分的话,主要是讲了函数的基础以及它的传参。
核心知识点包括:
- 传参:参数后面跟着的是变量名和变量类型。
- 返回值:包括返回值的类型。这里有一个知识点是它可以进行一些省略,比如裸返回(naked return)和显式返回的区别。
- 命名函数与匿名函数。
- 提前返回(early return):满足提前触发条件时可以直接返回。
- defer 方法:相当于在返回前绝对会执行的一个机制,类似于 JavaScript 中的 finally。
- 函数内部的作用域。
- 闭包函数。
主要就是这一类函数式编程的知识点。
structs
结构体部分主要讲解了以下核心内容:
-
基础结构体定义 Go 语言不是传统的面向对象语言,它没有 class(类)的概念,主要是基于结构体(struct)来构建的,这和 C 语言中的结构体类似。 基础结构体的定义语法为:
type 结构体名字 struct,后面用花括号包裹结构体的属性(包括属性名称和对应的属性类型)。 -
匿名结构体与嵌套/嵌入 • 匿名结构体:在使用过程中不给结构体命名而直接使用,通常只适用于单次使用的场景。 • 嵌套(Nested)与嵌入(Embedded):这两个概念很像,本质上都是组合。 (a) 嵌入(Embedding):比如定义一个卡车类型,可以直接在其中嵌入一个 Car 结构体,这样卡车就直接包含了 Car 的所有属性,在此基础上还可以添加卡车自身的专属属性。 (b) 嵌套(Nesting):比如 Car 结构体内部包含 Wheel(轮子)类型的属性(如前轮、后轮),每个轮子都对应一个 Wheel 结构体。
-
结构体方法 对应于类的方法。在 Go 语言中,定义结构体方法时,会在函数名的前方加上一个括号,里面填写接收者(Receiver),即填入对应的结构体。这代表该函数是该结构体特有的成员方法。
-
内存对齐与空结构体 • 内存对齐:结构体在内存中的存储和分布讲究内存对齐(Memory Alignment)。 • 空结构体(Empty Struct):即
struct{},它是不占任何内存空间的,后续会讲解它的具体应用场景。 -
属性的访问与控制 结构体属性的控制和访问可以使用点操作符(. dot operator)来进行,方便对属性进行赋值或修改。
interface
接口部分的内容,主要可以分为以下几个核心要点:
-
接口的基础定义 接口定义了一个方法的结构,包括方法名、传入的参数以及返回的值。与 Java 等语言不同,它没有面向对象的概念,而是基于结构体,并且强调组合而非继承。在 Go 语言中,只要一个结构体拥有对应接口的所有方法,它就隐式地实现了该接口。
-
接口的编写规范 为了让代码更优雅,你应该给接口的方法提供详细的定义。不仅要写明参数和返回值的类型,还应该写明具体的形参名称和返回值名称。另外,一个类型是可以同时实现多种接口的。
-
类型断言(Type Assertion) 这是一个非常重要的概念。当一个方法接收的参数是接口类型时,不同的具体实现类(结构体)都可以传进来。 例如,有一个通知接口,它只需要实现 notify 方法。系统通知和用户通知这两个结构体都实现了该方法,但它们内部肯定有不同的字段。在进行实际通知时,如果你需要使用特定结构体的特有方法,就需要进行类型断言。
- 具体操作:使用“变量.(目标类型)”的语法,即
value, ok := x.(T)的形式。 - 常见用法:通常会配合 switch 语句(Type Switch)来使用,以此明确接口变量底层的具体结构体类型,并进行相应的转换和后续操作。
- 保持接口的干净整洁 在设计接口时,要注意保持接口的最小化,避免过于复杂,确保其干净整洁。
error
还有一个部分是专门讲 error 的错误处理,错误处理确实是 Go 语言中很重要的一部分。
首先,error 本质上就是 Go 里面的一个接口(interface)类型,只需要实现它的 Error() 方法,就相当于实现了 error 接口、属于 error 类型。
关于 error 有几个重点:
- 可视化展示: 通常是通过 fmt.Sprintf() 方法去返回错误字符串来进行展示。
- 自定义实现: 你不仅仅可以用官方的 error,也可以给你的任何自定义结构体实现 Error() 方法,来满足 error 接口。
- errors 包: errors 包也是 Go 官方的,可以直接用 errors.New() 方法来快速生成一个 error 对象,直接传入 string 就能返回 error 类型。
另外还有一个很重要的相关概念就是 panic,也就是崩溃的情况。一般是程序在遇到重大错误、不能自己处理而导致崩溃的时候会触发。
这里需要强调的是: 在 Go 里面永远不要主动、过分地去尝试使用 panic,除非是出现了其他根本性的系统错误。否则在正常情况下,可以理解的业务错误都应该使用 error 去显式返回。这就是 Go 和其他语言不同的地方——把错误当做一个正常的值来对待。
如果实在遇到不能恢复的错误,一般使用 log.Fatal() 来打印信息并退出
slice
这一部分主要讲解了 Go 语言中 slice(切片)的基础知识。slice 是一种基于 array(数组)构建的数据类型。
-
数组与切片的区别: • 数组(Array):具有固定长度。在定义时就必须确定长度,可以使用 var 关键字或
:=来定义。 • 切片(Slice):在实际开发中更加常用且重要。它本质上是一个描述符,包含一个指向底层数组的指针。 -
切片的定义方法: • 切片操作:通过对现有数组进行切片(使用中括号
[low:high]语法)来创建。这是一个闭区间操作,左右边界都会包含在内。 • make 函数:在很多不需要关心底层数组的场景下,可以直接使用make凭空创建一个切片。 (a) 语法示例:mySlice := make([]int, length, capacity)(b) 长度(length):决定了切片当前包含多少个元素。 (c) 容量(capacity):决定了该切片最多能容纳多少个元素。 -
可变参数(Variadic Parameters): Go 语言支持传入任意数量的参数,这与 JavaScript 类似。 • 语法:在类型前加上三个点(例如
…string)。 • 解构传入:如果想把一个已有的数组或切片传入可变参数函数,需要在变量名后面加上…(例如names…),这相当于将数组解构,将其中的每个元素依次传入。 -
常用方法: • append:用于向 slice 添加元素。它可以添加单个、多个元素,或者将两个 slice 合并在一起。 • range:这是一个语法糖,常用于 for 循环中。通过
for index, element := range slice可以快速遍历数组或切片中的所有数据。 -
扩容机制与潜在问题: 扩容行为取决于 slice 的容量(cap): • 当容量已满且继续使用
append添加元素时,系统底层会创建一个新的 slice,扩大容量并迁移数据。 • 如果容量充足,则不会创建新切片,而是直接在原有 slice 后增加。这种机制可能导致“新旧切片指向同一底层内存”的分歧,从而引发意想不到的 Bug。为了避免此类问题,最佳实践是始终将
append的结果重新赋值给原变量,例如:mySlice = append(mySlice, element)
一、 为什么将 append 的结果重新赋值给自己就能解决?
因为 Go 的 append 函数可能会有两种行为:
- 容量足够(
len < cap):直接在原有的底层数组上覆盖写入新元素,返回的切片仍然指向同一个底层数组,只是len加 1。 - 容量不足(
len == cap):Go 会在内存中分配一块全新的、更大的数组,把旧数据复制过去,再追加新元素,最后返回一个指向新数组的切片。
看这个例子:
s := []int{1, 2, 3} // len=3, cap=3
s = append(s, 4) // 容量不够了!分配新数组,s 现在指向新数组
如果你不把返回值赋回给原来的变量:
s1 := make([]int, 3, 5) // len=3, cap=5
s2 := append(s1, 100)
此时 s1 和 s2 会共享同一个底层数组。后续对 s2 的修改(或者再次对 s1 进行 append)就会悄悄覆盖掉对方的数据。
重新赋值给自己(s = append(s, val))的本质:保证你的切片变量永远指向最新的、合法的底层数组状态,不再保留指向旧数组或被其他人共享的隐患。
二、 实际开发中常见的 3 大 Bug 场景
场景 1:函数传参中的“修改丢失”或“数据污染”
在 Go 中,切片作为参数传递时是值传递(复制了切片头,包括指针、len、cap)。
package main
import "fmt"
func appendValue(slice []int) {
// 如果容量不够,这里会触发扩容,slice 变量指向了新数组
slice = append(slice, 99)
slice[0] = 777
}
func main() {
a := []int{1, 2, 3} // len=3, cap=3
appendValue(a)
fmt.Println(a) // 输出: [1 2 3],99 和 777 都丢失了!
}
- Bug 原因:函数内部触发了扩容,函数内的
slice指向了新数组,而外部main函数里的a依然指向旧数组。 - 解决办法:如果要在函数内部修改切片长度,要么让函数返回新切片(
a = appendValue(a)),要么传递切片指针(*[]int)。
场景 2:切片截取(Slice Truncation)导致的数据隐蔽覆盖
当你从一个已有的切片创建新切片时,新切片会继承原切片剩余的容量(cap)。
package main
import "fmt"
func main() {
// 原始数据
path := []byte("USER/ADMIN")
// 截取前 4 个字节
user := path[:4] // len=4, cap=10 ("USER")
// 以为是在 user 后面追加,其实覆盖了原数组后面的字节!
user = append(user, "-MOD"…)
fmt.Println(string(user)) // USER-MOD
fmt.Println(string(path)) // USER-MODIN (原 path 的数据被改变了!)
}
-
Bug 原因:
path[:4]生成的切片容量是 10,append发现容量够用,直接在原数组上覆写了ADMIN所在的位置。 -
解决办法:使用 三索引切片(Full Slice Expression) 限制切片的容量:
user := path[:4:4] // len=4, cap=4 (限制容量,强制 append 时重新分配内存)
场景 3:并发读写导致的数据竞争(Data Race)
如果有多个 goroutine 共享同一个底层数组的切片,并且其中一个在进行 append 操作:
// 线程 A 读取数据
go func() {
fmt.Println(s[0])
}()
// 线程 B 进行追加
go func() {
s = append(s, 42) // 可能会修改底层数组,或者导致扩容
}()
- Bug 原因:
append不是原子操作,它包含读取len、比较cap、写入内存、更新len等多个步骤。并发读写会导致严重的数据竞态(Data Race),甚至引发程序崩溃。 - 解决办法:并发访问切片时必须加锁(
sync.Mutex),或者在并发前对切片进行深拷贝(Deep Copy)。
Maps
1. Map 的基本概念
Map 是 Go 中的键值对(key-value)数据结构,类似其他语言里的字典(dictionary)或哈希表(hash table)。声明和初始化方式:
ages := make(map[string]int)
ages["Alice"] = 30
2. 增删改查操作
- 添加/修改:
m[key] = value - 读取:
value := m[key] - 删除:
delete(m, key) - 判断 key 是否存在:
value, ok := m[key](ok是布尔值)
3. 遍历 map
用 for key, value := range m 遍历所有键值对。需要注意:map 的遍历顺序是不确定的,每次运行顺序可能不同。
4. Map 作为集合(Set)
今天这道题用到的核心技巧:当你只关心”有没有这个元素”而不关心值时,可以用 map[T]struct{} 来模拟一个集合(Set)。空结构体 struct{}{} 不占内存,是最省空间的写法。
5. 用 map 统计/去重
这是 map 最常见的用途之一——利用 key 的唯一性来做计数或去重,就像这道题里统计不同单词数量一样。
6. Map 是引用类型
Map 在函数间传递时是引用传递(类似指针的行为),在一个函数里修改 map 会影响到调用者持有的那个 map。
pointer
1. 基础概念
- 指针保存的是某个值的内存地址。
- 指针类型写作
*T,表示“指向T类型值的指针”,例如*int。 - 两个常见操作符:
&value:获取value的地址。*pointer:解引用,访问或修改该地址中存储的值。
score := 10
p := &score
*p = 20 // 修改 score 本身
此时 score 的值也变为 20。
2. 函数参数与修改原值
- Go 的参数传递始终是按值传递。
- 当传入普通值时,函数得到的是该值的副本;修改副本不会影响调用方变量。
- 当传入指针时,复制的是“地址”,但两个指针都指向同一份底层数据,因此可通过解引用修改原值。
func addOne(n *int) {
*n++
}
调用 addOne(&score) 可以修改外部的 score。
更严谨地说:Go 不是“值传递或引用传递”二选一;它始终值传递。指针传递时,传递的是地址这个值。
3. 方法接收者
- 若方法需要修改结构体本身,通常使用指针接收者:
func (c *customer) deposit(amount float64) {
c.balance += amount
}
- 值接收者适合不修改对象、且结构体较小的场景。
- 指针接收者也能避免复制较大的结构体。
- 接口实现需注意:若方法是指针接收者,只有
*T实现该接口,T本身不实现。
4. 本课案例
updateBalance 接收 *customer,因此能够直接修改客户余额:
- 存款:增加余额。
- 取款:先检查余额是否足够;不足时返回错误且不修改余额。
- 未知交易类型:返回错误。
switch很适合按交易类型分支处理。
5. 性能与使用建议
- 不要因为“指针可能更快”就默认使用指针。
- 小型结构体按值传递通常很清晰,复制成本也可能很低。
- 大型结构体、需要共享或修改同一对象时,指针往往更合适。
- “指针一定在堆、普通变量一定在栈”并不准确:Go 编译器会通过逃逸分析决定分配位置。
- 是否使用指针应优先考虑语义:是否需要修改原对象、是否需要共享同一份数据;性能优化应以测量结果为准。
packages
1. 基础概念 (Packages & Modules)
- Package(包):Go 程序的基本组织单位。同一个目录下的所有
.go文件必须属于同一个包。 - Module(模块):项目的根单元,由
go.mod文件定义,用于管理依赖关系及版本控制。
2. 导出与可见性 (Exporting & Scope)
Go 使用首字母大小写控制访问权限(无需 public 或 private 关键字):
- 导出(Exported / 公开):首字母大写(如
ClassifyImage)。可被其他包导入并使用。 - 未导出(Unexported / 私有):首字母小写(如
hasHotdogShape)。只能在同一包内访问。
3. Go CLI 工具命令
go run:编译并运行临时的 Go 文件(适合测试)。go build:编译项目生成可执行二进制文件(存在当前工作目录)。go install:编译并将可执行程序安装到$GOPATH/bin目录中。
4. 引入第三方包 (Remote Packages)
使用 go get 命令拉取外部依赖(例如 GitHub 上的开源库):
go get github.com/user/repo
引入后,Go 会自动更新 go.mod 和 go.sum 文件。
5. 设计干净包的经验法则 (Clean Packages)
- 隐藏内部逻辑:封装复杂逻辑,仅暴露出干净的 API,隐藏实现细节。
- 保持 API 稳定:不轻易改变导出的函数签名,避免破坏依赖方(调用者)的代码。
main包不导出函数:main是程序入口而非库,无需导出函数。- 包不应依赖调用方:包应当保持独立通用,不应了解任何具体使用它的上层应用细节。
channels
1. Channel 是什么
Channel(通道)用于在 goroutine 之间安全地传递数据。
messages := make(chan string)
可以把它想成两个并发任务之间的一条传送带。
2. 发送与接收
发送数据:
messages <- "hello"
接收数据:
message := <-messages
<- 的方向代表数据流动方向:
ch <value:向 channel 发送value := <-ch:从 channel 接收
3. 无缓冲 Channel 会同步
默认 channel 是无缓冲的:
ch := make(chan int)
发送方会等待接收方来取数据;接收方也会等待发送方提供数据。
因此下面的发送会阻塞,直到别的 goroutine 接收:
ch <42
这使 channel 不仅能传数据,也能协调 goroutine 的执行顺序。
4. Goroutine 与 Channel 配合
若在同一个 goroutine 里先发送再接收,无缓冲 channel 会死锁:
ch := make(chan string)
ch <- "hello" // 卡住:还没人接收
msg := <-ch
通常让其中一边在 goroutine 中执行:
go func() {
ch <- "hello"
}()
msg := <-ch
5. Buffered Channel(缓冲 Channel)
创建时提供容量:
ch := make(chan int, 3)
它能先存放最多 3 个值;缓冲未满时发送不会等待接收方。
ch <1
ch <2
ch <3
第 4 次发送会阻塞,直到有人接收。
适合允许生产者暂时领先于消费者的场景,但它不是无限队列。
6. len 和 cap
对于 buffered channel:
len(ch) // 当前缓冲中的元素数量
cap(ch) // 缓冲容量
它们适合观察状态,不应通常用来控制并发逻辑,因为多个 goroutine 下状态随时可能变化。
7. 关闭 Channel
发送方在确定不再发送数据时关闭它:
close(ch)
关闭 channel 不是“删除”它,而是广播:“不会再有新值了。”
重要规则:
- 通常由发送方关闭。
- 不要向已关闭的 channel 发送,会
panic。 - 重复关闭也会
panic。 - 从关闭的 channel 接收不会阻塞。
8. 判断 Channel 是否关闭
接收时可使用第二个返回值:
value, ok := <-ch
ok == true:成功收到一个正常发送的值ok == false:channel 已关闭且没有剩余值
value, ok := <-ch
if !ok {
// channel 已关闭
}
9. 使用 range 接收直到关闭
最常见的消费模式:
for value := range ch {
fmt.Println(value)
}
range 会持续接收值,直到 channel 被关闭并清空,然后退出循环。
因此生产者必须在完成后关闭 channel,否则消费者可能永远等待。
10. 单向 Channel 类型
函数参数可限制 channel 的用途:
func send(out chan<string) {
out <- "message"
}
func receive(in <-chan string) {
fmt.Println(<-in)
}
chan<T:只能发送<-chan T:只能接收chan T:可发送也可接收
这能让函数的职责更清晰,并让编译器阻止错误操作。
11. 防止程序提前退出
go 启动的 goroutine 不会自动阻止主程序退出。
go doWork()
如果 main 或当前调用链结束,仍未完成的 goroutine 会被终止。
需要一个同步点来等待工作结束,例如:
- 从 channel 接收结果
for range ch等待 channel 关闭- 后续章节会学到的
sync.WaitGroup
mutexs
1. Mutex 是什么
Mutex(互斥锁)用于保护共享数据,避免多个 goroutine 同时修改数据导致竞态条件。
mu.Lock()
sharedData++
mu.Unlock()
使用锁时,通常要保证解锁:
mu.Lock()
defer mu.Unlock()
2. sync.Mutex
普通互斥锁:
- 同一时间只能有一个 goroutine 持有锁
- 其他 goroutine 必须等待
- 适合保护写操作,也可以保护读操作
var mu sync.Mutex
mu.Lock()
// 访问共享数据
mu.Unlock()
3. sync.RWMutex
读写互斥锁,区分读和写:
var mu sync.RWMutex
读锁:RLock
- 多个 goroutine 可以同时读取
- 读取期间不能写入
mu.RLock()
// 读取共享数据
mu.RUnlock()
写锁:Lock
- 写入时具有独占权
- 其他读操作和写操作都必须等待
mu.Lock()
// 修改共享数据
mu.Unlock()
4. 访问规则
| 操作组合 | 是否允许同时进行 |
|---|---|
| 读 + 读 | 允许 |
| 读 + 写 | 不允许 |
| 写 + 写 | 不允许 |
5. Map 与 Mutex
Go 的 map:
- 可以安全地并发读取
- 不能安全地并发读写
- 不能安全地并发写入
因此,访问共享 map 时需要使用锁,尤其是写操作和遍历操作。
6. 关键原则
只要共享数据可能被多个 goroutine 访问,就要考虑同步。
并且:
- 锁住共享资源,而不是无关代码
- 临界区尽量短
Lock()和Unlock()必须成对出现- 不要忘记解锁,否则可能造成死锁
RWMutex适合读多写少的场景
一句话总结:
Mutex保证独占访问;RWMutex允许并发读取,但写入时必须独占。
generics
1. 泛型函数与类型参数(Type Parameters)
- 基本语法:在函数名后使用方括号
[]来声明类型参数,例如func fn[T any](s []T) T。 - 作用:类型参数
T充当类型的占位符,可以在函数的参数列表、返回值以及函数体中使用。 - 调用方式:大部分情况下,Go 编译器可以根据传入的实际参数自动进行类型推导(Type Inference),无需显式写出
fn[int](…)。
2. 类型约束(Type Constraints)与 any
- 限制类型范围:类型约束规定了调用者可以传入哪些类型。
any约束:any是interface{}的别名,表示允许任何类型。comparable约束: Go 内置的约束,表示可以使用==和!=进行比较的类型(如整型、字符串、指针等,但不包含 slice、map 或含非比较字段的 struct)。
3. 自定义约束与类型集(Type Sets)
- 接口作为约束:在泛型中,接口被扩展为定义一个“类型集合”。
- 联合类型(Union Types):使用竖线
|将多个类型组合在一起,例如type Number interface{ int | float64 }。 - 近似符号
~:在类型前加上~表示包含底层类型相同的自定义类型(底层类型约束)。例如~int既匹配int,也匹配type MyInt int。
4. 泛型数据结构(Generic Data Structures)
-
不仅是函数,结构体(struct)、切片(slice)和映射(map)也可以是泛型的。
-
语法示例:
type Stack[T any] struct { elements []T } -
实例化泛型结构体时必须显式提供类型参数,例如
s := Stack[int]{}。
5. 类型参数命名规范
- 类型参数本质上是变量名,可以使用任何合法的标识符。
- 单字母惯例:对于简单通用逻辑,最常使用
T(Type)。 - 多参数惯例:处理映射或多类型时,常用
K(Key)和V(Value),或T, U。 - 可读性优先:在复杂的业务场景或多类型转换中,完全可以使用明确含义的名称(如
FromAny, ToAny或Element)。
enums
-
什么是枚举(Enum)与和其它语言的对比
- 枚举是一种允许变量成为一组硬编码常量之一的数据类型(例如,状态只能是
email_bounced、email_failed或email_sent)。 - 在 Rust、TypeScript 等强类型语言中,具有强大的 Enum 或 Sum Types(和类型)/ Tagged Unions(标记联合) 支持。比如 Rust 的
Result<T, E>类型在编译期就强制开发者必须处理成功(Ok)或失败(Err)的情况,否则代码无法通过编译。
- 枚举是一种允许变量成为一组硬编码常量之一的数据类型(例如,状态只能是
-
Go 语言缺少原生 Enum 的局限
- Go 语言的设计哲学偏向简单,类型系统与 C 语言类似,没有 Rust 那样强大的原生的 Enum / Sum Types。
- 在 Go 中,方法返回错误(
err)时,编译器并不会强制你检查if err != nil。如果开发者忽视了错误,程序可能会继续使用无效的数据(如nil或空结构体),从而带来隐藏的 Bug。
-
Go 中的最佳实践与错误包裹(Error Wrapping)
- 正是因为 Go 的编译器不会强制校验错误,所以良好的 Go 编程习惯要求我们显式地检查每一个可能返回的
error。 - 当捕获到错误并向上层传递时,推荐使用
fmt.Errorf("…: %w", err)格式化函数配合%w动词。这样可以在保留原始错误(可以通过errors.Is或errors.As拆包)的同时,为错误添加上下文信息(Context),便于排查问题。
- 正是因为 Go 的编译器不会强制校验错误,所以良好的 Go 编程习惯要求我们显式地检查每一个可能返回的