简介:一份一百七十四页的Go语言学习笔记,面向从零基础到进阶的开发者,系统覆盖Go语言的核心知识。全篇分为三大部分:第一部分从变量、基本类型、类型转换、常量、字符串、运算符、指针、保留字、控制结构、自定义类型、初始化与内置函数入手,继而讲解函数类型、多返回值、命名返回参数、变参、匿名函数、闭包、延迟调用、恐慌恢复等,并逐一剖析数组、切片、映射、结构体、接口、并发模型以及源文件、包、测试等程序结构。第二部分围绕标准库展开,梳理输入输出、字符串处理、压缩、命令行标志、加密、编码、网络、系统调用、时间、同步、操作系统等常用包,覆盖文件读写、JSON、TCP、HTTP、RPC、正则、摘要等典型场景;第三部分补充基于MongoDB的驱动、Redis客户端、压缩库等扩展示例。资源为单个PDF文件,大小仅1.18MB,便于离线阅读,已有四千零四十四人学习下载。笔记目录清晰、知识点密集,代码片段与解释相互配合,既适合入门自学与面试复习,也可作为日常开发的速查手册。
1. 从 174 页笔记看 Go 语言到底在讲什么
这份《Go语言学习笔记》写于 Go 1.0.3 时代,配套环境是 MacBook Pro 和 Mac OS X 10.8,却把 Go 语言最核心的骨架全部点到了:从变量零值、类型转换、指针限制,到 goroutine 与 channel 的并发模型,再到 io、net/http、crypto 等标准库的组织方式。174 页不长,但它是一份典型的"工程师按自己思考路径写出来的笔记"——不是语法手册的复述,而是踩过坑之后的归纳。对想入门 Go 语言的人,它可以帮你建立从基础语法到标准库的全景认知;对已经写了几年 Go 的开发者,它的价值在附录和边界细节:比如&^位清除运算符的语义、unsafe.Pointer转uintptr做指针运算的写法、Go 1.0 时期测试的组织方式。接下来的内容以这份笔记的章节顺序为主线,把最关键的知识点重新推演一遍,并补上现代 Go 版本下的验证方法。
2. 基础语法与字符串编码:从变量零值到 rune 语义
2.1 变量声明、零值与类型推断的边界
笔记第 1 章开头就强调了一个 Go 新手最容易忽略的事实:Go 是强类型语言,:=只是语法糖,类型推断不等于动态类型。声明变量时如果不给初始值,编译器会赋予零值——bool 是 false,整数是 0,string 是空字符串 "",而指针、函数、接口、切片、channel、map 的零值都是 nil。这个设计简化了初始化逻辑,但也带来一个常见误用:直接对 nil map 赋值会触发运行时 panic。
func main() { var m map[string]int m["a"] = 1 fmt.Println(m) } // panic: runtime error: assignment to entry in nil map这段代码对应笔记中"必须用 make() 或初始化表达式分配内存"的提醒。这里的关键点在于:map 是引用类型,零值是 nil,向 nil map 写入会 panic,但读取是安全的,返回零值。我在实际项目中见过不少次这个坑——从 JSON 反序列化得到的 map 一定是非 nil 的,但直接var m map[string]int再往里写就会炸。另外笔记还提到一个多人踩过的细节:多变量赋值时先计算所有相关值,再从左到右赋值。
sa := []int{1, 2, 3} i := 0 i, sa[i] = 1, 2 // sets i = 1, sa[0] = 2这个规则和 Python 的元组赋值类似,但 Go 文档里写得更明确:先求值右侧所有表达式,再按从左到右的顺序赋值。理解这个顺序,才能解释sc[0], sc[0] = 1, 2为什么最终结果是 2——右侧先求值为 1 和 2,然后按顺序完成两次赋值,后者覆盖前者。
2.2 类型转换的显式规则与截断行为
Go 不支持隐式类型转换,这是它和 C 语言差异最大的一点。笔记给出了一个很典型的案例:整数转更窄的整数类型会截短长度,浮点转整数直接截断小数部分。
a := 0x1234 b := 1234.56 c := 256 fmt.Printf("%x\n", uint8(a)) // 输出 34:0x1234 截断为低 8 位 fmt.Printf("%d\n", int(b)) // 输出 1234:小数位被截断 fmt.Printf("%f\n", float64(c)) // 输出 256.000000注意第一行输出的34不是十六进制数字 34,而是0x34去掉了高字节0x12之后的结果。这个行为在写二进制协议解析时非常常见,比如从网络字节流里拼接 uint16 再转 byte。还有一个容易误解的优先级问题:类型转换的优先级高于解引用和 channel 操作,所以*Point(p)等价于*(Point(p)),而(*Point)(p)才是把 p 转换为指针类型。
2.3 常量、iota 与枚举生成规则
笔记里对 iota 的讲解非常细致,这是 Go 语言里少有的"看起来简单但边界情况很多"的语法点。iota 按行递增,可以省略后续行的iota关键字,但如果某一行单独给了值,后面没有 iota 的行会沿用同一个值和类型表达式,直到再次出现 iota 才会恢复递增。
const ( a = iota // 0 b // 1 c // 2 d = "ha" // 独立值 e // 没有使用 iota 恢复,所以和 d 值相同,是 "ha" f = 100 // 100 g // 和 f 值相同,是 100 h = iota // 7,恢复 iota 计数器,继续按行递增 i // 8 )这里最容易搞混的是e和g的行为:它们继承了上一行的值和类型表达式,而不是简单地"复制"常量值。写成const ( KB ByteSize = 1 << (10 * iota); MB; GB )时,MB 和 GB 会用自己的 iota 值重新计算表达式,这正是字节单位换算的标准写法。如果用-ldflags "-u"编译参数禁止 unsafe 代码,常量表达式里的unsafe.Sizeof()也会受影响,这是笔记中没有展开但值得注意的关联点。
2.4 字符串是不可变值类型:byte 与 rune 的取舍
笔记第 1.5 节对字符串的剖析非常深入。string 在 Go 内部是一个指向 UTF-8 字节数组的指针加长度,这和 C 语言的 NUL 结尾字符串完全不同。它不可变,可以通过索引访问某个字节,但不能取字节元素的地址,也不包含 NULL 结尾。
s := "a:中国人" bs := []byte(s) // 转换为 bytes,以便修改 bs[0] = 'A' println(string(bs)) // 输出 A:中国人 u := []rune(s) // 转换为 Unicode code point 序列 u[4] = '龙' println(string(u)) // 输出 a:中国龙注意len("a:中国人")返回的是 11,而不是 5——因为"中国"和"人"各占 3 个 UTF-8 字节。用for i := 0; i < len(s); i++遍历会一个字节一个字节地输出乱码,用for i, c := range s则按 rune 遍历,i 是字节偏移量。这在处理中文输入校验、截断字符串时是必须理解的底层细节。用[]rune(s)转成 Unicode 序列再处理就安全得多,代价是一次 O(n) 的转换。
提示:现代 Go 版本(1.9+)里
range对字符串的遍历会自动解码 UTF-8,遇到无效字节会生成 U+FFFD 替换字符,而for i := 0; i < len(s); i++不会。如果需要做健壮的文本处理,优先使用range或[]rune。
字符串拼接方面,笔记提到+操作符连接跨行字符串时,加号必须留在上一行末尾,否则编译器会报错。这个规则源自 Go 的分号自动插入机制——如果一行以字符串字面量结尾,行尾会自动补分号,导致s := "abc"和+ ", 123"被解析为两条语句。这个细节在生成复杂 SQL 模板时经常遇到。
3. 指针、切片与 unsafe:内存布局的底层视角
3.1 指针的限制与 unsafe.Pointer 的正确姿势
Go 保留了指针,但不支持指针运算,不支持->运算符。&取地址,*间接访问,默认值 nil。笔记明确指出:指针符号*总是和类型放在一起,var a, b *int声明两个指针,这在 C 语言里要写成int *a, *b才能确保两个都是指针。Go 的这个设计消除了 C 语言里int* a, b的歧义。
真正有意思的部分是unsafe.Pointer。笔记演示了如何用它把*int转换为*[N]byte来查看内存字节序:
const N int = int(unsafe.Sizeof(0)) func main() { x := 0x1234 p := unsafe.Pointer(&x) // *int -> Pointer p2 := (*[N]byte)(p) // Pointer -> *[4]byte for i, m := 0, len(p2); i < m; i++ { fmt.Printf("%02X ", p2[i]) } } // 输出:34 12 00 00(小端序)注意*[N]byte中N必须是编译期常量,不能用变量替代。unsafe.Sizeof(0)在你的目标平台上等于strconv.IntSize / 8,在 64 位系统上是 8,所以数组长度是 8,但 0x1234 高字节都是 0,实际输出 34 12 00 00。小端序在这个输出里看得很清楚:低字节 0x34 在低地址。
更进阶的用法是把unsafe.Pointer转成uintptr做偏移计算,再转回指针取值。笔记里的例子是访问结构体字段:
type User struct { Id int Name string } p := &User{1, "User1"} var np uintptr = uintptr(unsafe.Pointer(p)) + unsafe.Offsetof(p.Name) var name *string = (*string)(unsafe.Pointer(np)) println(*name) // 输出 User1unsafe.Offsetof返回字段相对于结构体起始地址的字节偏移。这种写法在反射库内部、序列化框架里很常见,但业务代码中除非性能瓶颈明确指向反射开销,否则不建议直接用。Go 1.20 之后引入了unsafe.String和unsafe.Slice,官方在逐步规范化 unsafe 包的边界,但对uintptr做算术运算仍然属于高风险操作,配合-gcflags=-d=checkptr可以在运行时捕获部分非法指针转换。
3.2 append 的容量增长规则与切片别名陷阱
笔记第 3 章讲了 Array、Slices 和 Maps。数组是值类型,切片是引用类型,这个区别直接决定了函数传参行为。切片本身是一个三元组:指针、长度、容量。用append追加元素时,如果容量不够,Go 会分配一块更大的底层数组并拷贝旧元素,此时新切片和旧切片指向不同的底层数组。
s := make([]int, 3, 3) s[0], s[1], s[2] = 1, 2, 3 s2 := append(s, 4) s2[0] = 100 fmt.Println(s[0]) // 输出 1:扩容后 s 和 s2 底部分离这个例子对应了"append go语言"热词背后的常见困惑。make([]int, 3, 3)创建长度为 3、容量为 3 的切片,append(s, 4)触发扩容,底层数组替换,s 和 s2 不再共享内存。如果把容量改成 4,append 不会扩容,s2 和 s 共享底层数组,s2[0] = 100会同时影响 s。这在写并发代码时尤其危险:多个 goroutine 同时 append 同一个切片的底层数组,可能出现数据竞争。Go 1.14+ 的竞态检测器-race能发现这类问题,但根本解法是明确切片的容量语义。
Slices 的另一个经典陷阱是子切片共享内存:
data := []byte("hello world") sub := data[6:] // 指向 " world",和 data 共享底层数组 sub[0] = 'W' fmt.Println(string(data)) // hello World这不是 bug,是设计。但当你把data传给某个函数,函数持有sub时,原始切片的修改会意外影响sub的内容。如果要完全独立的数据,用copy。
3.3 Map 的 nil 语义与按键取值
Maps 部分最值得注意的语法点是:从 map 读取不存在的键不会 panic,而是返回零值。这导致了一个经典误判——无法区分"键不存在"和"键对应的值就是零值"。
m := map[string]int{"a": 0, "b": 2} v1 := m["a"] // 0 v2 := m["notexist"] // 0 v3, ok := m["notexist"] // 0, false解决方法是使用 comma ok 语法:
if v, ok := m["key"]; ok { // 键存在,v 是真实值 } else { // 键不存在 }笔记中 nil map 赋值的 panic 行为要特别注意:从 nil map 读取是安全的,写入会 panic。var m map[string]int之后直接m["a"] = 1会崩溃。这个问题在并发场景下的解决方案不是加锁读写 map(Go 1.9 之前的 map 非并发安全),而是用sync.RWMutex包裹,或者直接换sync.Map。笔记写作时 sync.Map 还不存在,但现在已经是常规选项了。
4. 接口机制与并发原语:从类型系统到 goroutine 协作
4.1 接口的执行机制与类型断言
笔记第 5 章讲接口时,重点放在了两块:接口定义的是一组方法签名,类型通过方法集隐式实现接口;接口变量内部保存了动态类型和动态值。空接口interface{}能接住任何类型,但前提是理解它只是一个装箱容器。
var v interface{} = 42 i, ok := v.(int) // 类型断言:ok 为 true,i = 42 s, ok := v.(string) // ok 为 false,s 是空字符串类型断言的两种写法:v.(int)返回两个值,第二个是布尔;v.(int)只返回一个值,断言失败会 panic。笔记里还提到"接口转换"——把接口类型转换为具体类型,再把具体类型赋给另一个接口。这中间有一个容易被忽略的性能问题:接口装箱和断言在 Go 1.13 之前有额外的运行时开销,频繁在热点路径做类型断言会影响性能。现代 Go 版本优化了大部分场景,但写高性能代码时仍然建议用type switch替代多个if断言。
4.2 goroutine 的调度语义:M 对 N 与栈增长
第 6 章并发部分,笔记直接切入 goroutine 和 channel。goroutine 不是操作系统线程,而是 Go 运行时管理的轻量级协程,初始栈只有 2KB(Go 1.4 之前是 4KB),按需增长。运行时把 goroutine 调度到若干 OS 线程上,这就是 M:N 调度模型。
func main() { ch := make(chan int) go func() { ch <- 42 }() v := <-ch println(v) }这段代码中make(chan int)创建无缓冲 channel,发送操作会阻塞直到有接收者就绪。在go func() { ch <- 42 }()启动的子 goroutine 里写入,主 goroutine 在<-ch处等待,两边的阻塞共同完成了同步。笔记里没有展开讲的是死锁检测:如果只有发送没有接收者,整个程序会在运行时报fatal error: all goroutines are asleep - deadlock!。这是 Go 运行时的保护机制,正常编译能通过,运行时才触发。
实际项目中,无缓冲 channel 常用作信号量,有缓冲 channel 常用作队列。容量参数make(chan int, 10)决定缓冲区大小,缓冲满时发送阻塞,缓冲空时接收阻塞。用close(ch)关闭 channel 后,接收方会持续收到零值;配合v, ok := <-ch可以判断 channel 是否已关闭。
4.3 sync.WaitGroup 与主 goroutine 的退出时序
goroutine 的一个经典误用是:主函数结束了,子 goroutine 还没跑完。笔记第 19 章有 WaitGroup,这是解决这个问题的标准手段:
func main() { var wg sync.WaitGroup for i := 0; i < 10; i++ { wg.Add(1) go func(n int) { defer wg.Done() fmt.Println(n) }(i) } wg.Wait() }wg.Add(1)必须在 go 语句之前调用,wg.Done()需要在 goroutine 内部确保执行,所以一般配defer。wg.Wait()阻塞直到计数器归零。这里有个容易踩的坑:把一个WaitGroup按值传给函数会导致计数器拷贝,调用Done()时不会减少原来那个计数器的值,最终死锁。常见解法是传指针。
4.4 select 与超时控制的经典写法
笔记中 channel 的部分没有单独讲 select,但第 6 章之后的实践里这是必用的。select 可以在多个 channel 操作之间选择可执行的那个,配合 time.Timer 可以实现带超时的接收:
ch := make(chan int) timeout := time.After(3 * time.Second) select { case v := <-ch: fmt.Println("received", v) case <-timeout: fmt.Println("timeout") }time.After会创建一个定时器管道,在指定时间后写入一个 time.Time 值。select 会阻塞直到任意一个 case 就绪,如果多个同时就绪,伪随机选择一个。select 还有一个特殊语法:空的select{}会永久阻塞,常被用来让主 goroutine 不退出。
5. 标准库实战:io、json、net/http、time 约定与 flag 参数解析
5.1 io.Reader / io.Writer 接口与链式处理
标准库部分笔记从 io 讲起,核心是io.Reader和io.Writer两个接口。几乎所有 I/O 类型(文件、网络连接、bytes.Buffer、os.Stdin)都实现了这两个接口。io 库的设计哲学是"组合优于继承"——用小的接口组合出大的功能。
文本文件的读取非常直接:
f, err := os.Open("data.txt") if err != nil { log.Fatal(err) } defer f.Close() data, err := io.ReadAll(f) fmt.Println(string(data))io.ReadAll在 Go 1.16 之前叫ioutil.ReadAll。对大文件不建议一次读入内存,常见的做法是用bufio.Scanner按行扫描:
sc := bufio.NewScanner(f) for sc.Scan() { line := sc.Text() // 处理每一行 } if err := sc.Err(); err != nil { log.Fatal(err) }默认bufio.Scanner的 buffer 上限是 64KB,处理超长行需要手动调整sc.Buffer(make([]byte, 1024*1024), 1024*1024*10)。这是日志解析场景常见的坑——某一行 JSON 太长直接报bufio.Scanner: token too long。
5.2 encoding/json 的字段标签与 Time 特殊处理
Go 的 json 序列化依赖结构体字段标签。笔记第 15 章只放了 json 一个主题,这个选择很务实——在 Go 的实际项目里,几乎没有不用 json 的。反序列化最典型的坑是:JSON 里的字段名和 Go 结构体字段名不一致,或者不需要的字段不知道如何处理。
type User struct { Id int `json:"id"` Name string `json:"name,omitempty"` Email string `json:"email,omitempty"` }标签里omitempty表示字段为零值(空字符串、0、false、nil)时不输出到 JSON。注意omitempty不适用于嵌套结构体指针为 nil 的情况——如果Email *string是 nil,不输出;但Email string是空字符串,同样不输出,这可能导致下游反序列化后字段缺失而不是得到空字符串。
json 的另一个大问题是时间字段。热词"go语言中时间为什么是20060102"指的就是 Go 的时间格式化模板:2006-01-02 15:04:05是参考时间,不是随意写的格式。具体来说,2006 代表年份、01 代表月份、02 代表日期、15 代表 24 小时制的小时、04 代表分钟、05 代表秒。Go 选择用这个日期作为格式化模板,是为了让人读代码时一眼看出每个字段对应什么。JSON 序列化 time.Time 默认输出 RFC3339 格式,如果后端存的是 Unix 时间戳,需要自定义 MarshalJSON:
type User struct { CreatedAt time.Time `json:"created_at"` } func (u User) MarshalJSON() ([]byte, error) { type Alias User return json.Marshal(&struct { CreatedAt int64 `json:"created_at"` *Alias }{ Alias: (*Alias)(&u), CreatedAt: u.CreatedAt.Unix(), }) }注意type Alias User的用法:定义一个没有方法的新类型,避免递归调用 MarshalJSON 导致无限循环。
反序列化时的另一个陷阱是 JSON 数字到结构体字段的隐式转换:JSON 里的大整数(如 64 位 ID)如果结构体字段是 int,反序列化会失败。
5.3 net/http 服务端:Handler 与 MaxBytesReader
net 部分的 TCP、HTTP、RPC 三块内容里,HTTP 是日常开发用到最多的。笔记写的是 Go 1.0 时代的 http 包,接口形态和现在基本一致:
func handler(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: w.Write([]byte("Hello")) case http.MethodPost: body, _ := io.ReadAll(r.Body) w.Write(body) } } func main() { http.HandleFunc("/", handler) http.ListenAndServe(":8080", nil) }需要补充的实战细节:r.Body必须读完后关闭,否则连接无法复用;对上传接口要用http.MaxBytesReader限制请求体大小,防止内存攻击;设置r.Body = http.MaxBytesReader(w, r.Body, 1<<20)之后,超过 1MB 的请求会在读取时返回错误。RPC 部分笔记用的是标准库net/rpc,这个包在内部系统里仍有用武之地,但新项目基本都用 gRPC 或 restful 替代了。
5.4 flag 包参数解析与 time.Duration 的格式化
第 13 章 flag 提供命令行参数解析。最常用的模式是注册参数、调用 Parse、取值:
var ( host = flag.String("host", "0.0.0.0", "listen address") port = flag.Int("port", 8080, "listen port") debug = flag.Bool("debug", false, "enable debug mode") timeout = flag.Duration("timeout", 3*time.Second, "request timeout") ) func main() { flag.Parse() addr := fmt.Sprintf("%s:%d", *host, *port) }flag.Duration能直接解析 "3s"、"1m30s" 这样的字符串,底层调用了 time.ParseDuration,这是 time 库和 flag 库天然配合的一个点。命令行传入-timeout=5s,内部自动完成字符串到 time.Duration 的转换。如果需要对多个同类参数做归档(如-config a.toml -config b.toml),可以用flag.Var自定义 Value 接口。
5.5 sync 库的 Cond、Once 与 atomic 实战
sync 库看起来简单,但并发控制里几乎每个组件都有适用边界。以 Once 为例,它的语义是"某个函数只执行一次",哪怕多个 goroutine 同时调用:
var once sync.Once var instance *Singleton func GetInstance() *Singleton { once.Do(func() { instance = &Singleton{} }) return instance }sync.Once的内部实现用了原子操作和互斥锁,比"自己加锁 + 布尔标记"更稳——它能保证即使函数 panic,once 也会被标记为已执行,后续不会再调用。这既是优点也是坑:如果once.Do里的初始化函数 panic,程序不会重复尝试初始化。Cond 用于多个 goroutine 等待一个条件变化,笔记里的实现基于Wait/Signal/Broadcast。实际项目中大部分 Cond 场景可以用 channel 替代,但 Cond 的优势是支持Wait期间释放锁、广播唤醒多个等待者。sync/atomic的AddInt64和CompareAndSwapInt64是无锁计数器和高性能状态机的基础,在指标采集、连接池状态切换这些场景里几乎必用。
6. 工具链进阶:GDB 调试点、条件编译与跨平台编译的搭配
笔记第 9 章讲工具,但 Go 工具链的变化比语言本身大得多——go mod在 1.11 才引入,笔记写作时用的还是 GOPATH 模式。这部分提几个到今天仍然有效的实践技巧。
跨平台编译是 Go 最实用的一项能力。设置GOOS和GOARCH环境变量即可在本地交叉编译:
GOOS=linux GOARCH=amd64 go build -o app-linux-amd64 main.go GOOS=windows GOARCH=amd64 go build -o app-windows-amd64.exe main.go GOOS=darwin GOARCH=arm64 go build -o app-darwin-arm64 main.go注意CGO_ENABLED=0才能做纯静态交叉编译,否则依赖 cgo 的包(如 net 包的部分解析逻辑)会报编译错误。如果需要读取运行时平台的常量,可以用runtime.GOOS和runtime.GOARCH,避免在代码里硬编码。
条件编译现在主要通过 build tags 实现,文件头加注释:
//go:build linux // +build linux package mainGo 1.17 之后推荐用//go:build语法,旧的// +build需要保留以便旧版本工具识别。这个机制在开发中常用在:embed 不同平台的资源文件、注入不同平台的系统调用实现(如syscall.Syscall的参数类型差异)。笔记里还提到-ldflags "-u"参数禁止 unsafe 代码——在编译发布给第三方的二进制时这是有用的加固选项,但注意-u标志在 Go 1.20 之后被移除了,新的做法是用go vet的 unsafeptr 检查来静态扫描。
GDB 调试方面,Go 1.10 之后推荐用 Delve(dlv),但 GDB 仍然适合做底层分析。用 GDB 调 Go 程序有两个关键注意点:编译时加-gcflags "all=-N -l"禁用优化和内联,否则断点位置和变量值都可能不准确;运行时需要用info goroutines查看协程列表而不是info threads。runtime.Breakpoint()可以在代码里主动触发断点,配合 GDB 的continue命令做动态分析:
gdb ./app (gdb) break main.main (gdb) run (gdb) info goroutines (gdb) goroutine 1 bt最后一个实用技巧是go tool pprof做性能剖析。在 HTTP 服务里引入net/http/pprof空导入,就能在运行时拉取 CPU 和堆内存 profile。go tool pprof http://localhost:8080/debug/pprof/profile?seconds=30采样 30 秒后进入交互式分析,输入top看热点函数、web生成火焰图。这份笔记的附录里提到的 mgo、godis、snappy 都是当时流行的扩展库,和今天的 MongoDB 官方驱动、go-redis、klauspost/compress 在接口设计上大同小异,核心概念仍然相通。
本文还有配套的精品资源,点击获取