Learn Go(Boot.dev)

Boot.dev 的 Learn Go 课程框架笔记——Go 语言入门主线(语法、值语义、函数、控制流),对应仓库 Go 学习路线阶段一~三。

#type / resource #status / seed #society / education #resource / boot-dev #resource / go #media / course

Learn Go(Boot.dev)

[!info] 关联笔记

概览

这是 Boot.dev 中 Go 语言基础课程的学习记录。下面主要是我在学习过程中的一些笔记和记录。

Function

函数部分的话,主要是讲了函数的基础以及它的传参。

核心知识点包括:

  1. 传参:参数后面跟着的是变量名和变量类型。
  2. 返回值:包括返回值的类型。这里有一个知识点是它可以进行一些省略,比如裸返回(naked return)和显式返回的区别。
  3. 命名函数与匿名函数。
  4. 提前返回(early return):满足提前触发条件时可以直接返回。
  5. defer 方法:相当于在返回前绝对会执行的一个机制,类似于 JavaScript 中的 finally。
  6. 函数内部的作用域。
  7. 闭包函数。

主要就是这一类函数式编程的知识点。

structs

结构体部分主要讲解了以下核心内容:

  1. 基础结构体定义 Go 语言不是传统的面向对象语言,它没有 class(类)的概念,主要是基于结构体(struct)来构建的,这和 C 语言中的结构体类似。 基础结构体的定义语法为:type 结构体名字 struct,后面用花括号包裹结构体的属性(包括属性名称和对应的属性类型)。

  2. 匿名结构体与嵌套/嵌入 • 匿名结构体:在使用过程中不给结构体命名而直接使用,通常只适用于单次使用的场景。 • 嵌套(Nested)与嵌入(Embedded):这两个概念很像,本质上都是组合。 (a) 嵌入(Embedding):比如定义一个卡车类型,可以直接在其中嵌入一个 Car 结构体,这样卡车就直接包含了 Car 的所有属性,在此基础上还可以添加卡车自身的专属属性。 (b) 嵌套(Nesting):比如 Car 结构体内部包含 Wheel(轮子)类型的属性(如前轮、后轮),每个轮子都对应一个 Wheel 结构体。

  3. 结构体方法 对应于类的方法。在 Go 语言中,定义结构体方法时,会在函数名的前方加上一个括号,里面填写接收者(Receiver),即填入对应的结构体。这代表该函数是该结构体特有的成员方法。

  4. 内存对齐与空结构体 • 内存对齐:结构体在内存中的存储和分布讲究内存对齐(Memory Alignment)。 • 空结构体(Empty Struct):即 struct{},它是不占任何内存空间的,后续会讲解它的具体应用场景。

  5. 属性的访问与控制 结构体属性的控制和访问可以使用点操作符(. dot operator)来进行,方便对属性进行赋值或修改。

interface

接口部分的内容,主要可以分为以下几个核心要点:

  1. 接口的基础定义 接口定义了一个方法的结构,包括方法名、传入的参数以及返回的值。与 Java 等语言不同,它没有面向对象的概念,而是基于结构体,并且强调组合而非继承。在 Go 语言中,只要一个结构体拥有对应接口的所有方法,它就隐式地实现了该接口。

  2. 接口的编写规范 为了让代码更优雅,你应该给接口的方法提供详细的定义。不仅要写明参数和返回值的类型,还应该写明具体的形参名称和返回值名称。另外,一个类型是可以同时实现多种接口的。

  3. 类型断言(Type Assertion) 这是一个非常重要的概念。当一个方法接收的参数是接口类型时,不同的具体实现类(结构体)都可以传进来。 例如,有一个通知接口,它只需要实现 notify 方法。系统通知和用户通知这两个结构体都实现了该方法,但它们内部肯定有不同的字段。在进行实际通知时,如果你需要使用特定结构体的特有方法,就需要进行类型断言。

  • 具体操作:使用“变量.(目标类型)”的语法,即 value, ok := x.(T) 的形式。
  • 常见用法:通常会配合 switch 语句(Type Switch)来使用,以此明确接口变量底层的具体结构体类型,并进行相应的转换和后续操作。
  1. 保持接口的干净整洁 在设计接口时,要注意保持接口的最小化,避免过于复杂,确保其干净整洁。

error

还有一个部分是专门讲 error 的错误处理,错误处理确实是 Go 语言中很重要的一部分。

首先,error 本质上就是 Go 里面的一个接口(interface)类型,只需要实现它的 Error() 方法,就相当于实现了 error 接口、属于 error 类型。

关于 error 有几个重点:

  1. 可视化展示: 通常是通过 fmt.Sprintf() 方法去返回错误字符串来进行展示。
  2. 自定义实现: 你不仅仅可以用官方的 error,也可以给你的任何自定义结构体实现 Error() 方法,来满足 error 接口。
  3. errors 包: errors 包也是 Go 官方的,可以直接用 errors.New() 方法来快速生成一个 error 对象,直接传入 string 就能返回 error 类型。

另外还有一个很重要的相关概念就是 panic,也就是崩溃的情况。一般是程序在遇到重大错误、不能自己处理而导致崩溃的时候会触发。

这里需要强调的是: 在 Go 里面永远不要主动、过分地去尝试使用 panic,除非是出现了其他根本性的系统错误。否则在正常情况下,可以理解的业务错误都应该使用 error 去显式返回。这就是 Go 和其他语言不同的地方——把错误当做一个正常的值来对待。

如果实在遇到不能恢复的错误,一般使用 log.Fatal() 来打印信息并退出

slice

这一部分主要讲解了 Go 语言中 slice(切片)的基础知识。slice 是一种基于 array(数组)构建的数据类型。

  1. 数组与切片的区别: • 数组(Array):具有固定长度。在定义时就必须确定长度,可以使用 var 关键字或 := 来定义。 • 切片(Slice):在实际开发中更加常用且重要。它本质上是一个描述符,包含一个指向底层数组的指针。

  2. 切片的定义方法: • 切片操作:通过对现有数组进行切片(使用中括号 [low:high] 语法)来创建。这是一个闭区间操作,左右边界都会包含在内。 • make 函数:在很多不需要关心底层数组的场景下,可以直接使用 make 凭空创建一个切片。 (a) 语法示例:mySlice := make([]int, length, capacity) (b) 长度(length):决定了切片当前包含多少个元素。 (c) 容量(capacity):决定了该切片最多能容纳多少个元素。

  3. 可变参数(Variadic Parameters): Go 语言支持传入任意数量的参数,这与 JavaScript 类似。 • 语法:在类型前加上三个点(例如 …string)。 • 解构传入:如果想把一个已有的数组或切片传入可变参数函数,需要在变量名后面加上 (例如 names…),这相当于将数组解构,将其中的每个元素依次传入。

  4. 常用方法: • append:用于向 slice 添加元素。它可以添加单个、多个元素,或者将两个 slice 合并在一起。 • range:这是一个语法糖,常用于 for 循环中。通过 for index, element := range slice 可以快速遍历数组或切片中的所有数据。

  5. 扩容机制与潜在问题: 扩容行为取决于 slice 的容量(cap): • 当容量已满且继续使用 append 添加元素时,系统底层会创建一个新的 slice,扩大容量并迁移数据。 • 如果容量充足,则不会创建新切片,而是直接在原有 slice 后增加。

    这种机制可能导致“新旧切片指向同一底层内存”的分歧,从而引发意想不到的 Bug。为了避免此类问题,最佳实践是始终将 append 的结果重新赋值给原变量,例如:mySlice = append(mySlice, element)

一、 为什么将 append 的结果重新赋值给自己就能解决?

因为 Go 的 append 函数可能会有两种行为:

  1. 容量足够(len < cap:直接在原有的底层数组上覆盖写入新元素,返回的切片仍然指向同一个底层数组,只是 len 加 1。
  2. 容量不足(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)

  1. 隐藏内部逻辑:封装复杂逻辑,仅暴露出干净的 API,隐藏实现细节。
  2. 保持 API 稳定:不轻易改变导出的函数签名,避免破坏依赖方(调用者)的代码。
  3. main 包不导出函数main 是程序入口而非库,无需导出函数。
  4. 包不应依赖调用方:包应当保持独立通用,不应了解任何具体使用它的上层应用细节。

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

  1. 什么是枚举(Enum)与和其它语言的对比

    • 枚举是一种允许变量成为一组硬编码常量之一的数据类型(例如,状态只能是 email_bouncedemail_failed 或 email_sent)。
    • 在 Rust、TypeScript 等强类型语言中,具有强大的 Enum 或 Sum Types(和类型)/ Tagged Unions(标记联合) 支持。比如 Rust 的 Result<T, E> 类型在编译期就强制开发者必须处理成功(Ok)或失败(Err)的情况,否则代码无法通过编译。
  2. Go 语言缺少原生 Enum 的局限

    • Go 语言的设计哲学偏向简单,类型系统与 C 语言类似,没有 Rust 那样强大的原生的 Enum / Sum Types。
    • 在 Go 中,方法返回错误(err)时,编译器并不会强制你检查 if err != nil。如果开发者忽视了错误,程序可能会继续使用无效的数据(如 nil 或空结构体),从而带来隐藏的 Bug。
  3. Go 中的最佳实践与错误包裹(Error Wrapping)

    • 正是因为 Go 的编译器不会强制校验错误,所以良好的 Go 编程习惯要求我们显式地检查每一个可能返回的 error
    • 当捕获到错误并向上层传递时,推荐使用 fmt.Errorf("…: %w", err) 格式化函数配合 %w 动词。这样可以在保留原始错误(可以通过 errors.Is 或 errors.As 拆包)的同时,为错误添加上下文信息(Context),便于排查问题。
创建于 2026/8/1 更新于 2026/8/1