对于有 C++ 经验的程序员来说,学习 Go 是一次有趣的体验。Go 在许多设计上与 C++ 有着相似的底层思路,但又在语法和哲学上做出了截然不同的取舍。本文是我学习 Go 的实践笔记,以代码为主线,穿插与 C++ 的对比,力求在「知其然」的同时也「知其所以然」。
Hello, Go
一切从 go mod init 和一个最简单的程序开始。
1 | package main |
几个关键点:
- 每个可执行程序必须有
package main和func main()。 - 导入了但未使用的包会导致编译错误——Go 在这一点上比 C++ 严格得多。
- 左花括号
{必须与函数声明在同一行,不能换行。这不是风格建议,而是语法规则。 - 不需要分号结尾。
Go 天然支持 Unicode
Go 的源代码和字符串都是 UTF-8 编码的。这不是巧合——UTF-8 编码本身就是 Go 的两位创造者之一 Rob Pike 和 Ken Thompson 发明的。
1 | s := "你好Go" |
这里涉及一个核心概念的区分:string 的底层是 UTF-8 字节序列,len(s) 返回的是字节数,而非字符数。rune 是 Go 的字符类型(等同于 int32),代表一个 Unicode 码点。
两种遍历方式的行为因此不同:
1 | // 逐字节遍历 |
后者的输出中,位置会从 0 跳到 3 再跳到 6,因为每个中文字符占 3 个 UTF-8 字节。
[]rune(s) 是什么
这是 Go 的类型转换语法。[]rune 是 rune 的切片类型,(s) 将字符串 s 转换为该类型。Go 的类型转换统一使用 Type(value) 语法,例如 float64(x)、string(65)、[]rune(s)。
1 | bytes := []byte(s) // [228 189 160 229 165 189 71 111] 8 个字节 |
「你」被拆成了 3 个字节 [228 189 160],而作为 rune 它就是一个码点 20320。
变量与类型
三种声明方式
1 | var name string = "Liam" // 完整写法 |
短声明 := 是日常开发中最常见的写法。
基本类型
| 类型 | 说明 | 零值 |
|---|---|---|
bool |
布尔 | false |
int, int8/16/32/64 |
整数 | 0 |
float32, float64 |
浮点数 | 0.0 |
string |
字符串(UTF-8) | "" |
rune |
字符(= int32) |
0 |
byte |
字节(= uint8) |
0 |
零值:没有「未初始化」的概念
Go 的每种类型都有零值。声明变量后即可安全使用,不存在 C++ 中局部变量未初始化导致的未定义行为。
1 | var i int // 0 |
类型转换必须显式
Go 不做任何隐式类型转换。int 和 float64 之间必须显式转换:
1 | var x int = 10 |
这一点和 C++ 的隐式转换(int 到 double)形成鲜明对比。Go 的哲学是:显式优于隐式。
nil:不只是 nullptr
C++ 的 nullptr 只用于指针。Go 的 nil 是六种类型的零值:
| 类型 | nil 时的行为 |
|---|---|
指针 *T |
解引用 panic(同 C++) |
切片 []T |
可以 append,len 返回 0 |
map |
可以读(返回零值),写入 panic |
函数 func() |
调用 panic |
接口 / error |
打印 <nil> |
channel |
收发阻塞 |
注意:基本类型(int、string、bool 等)的零值不是 nil。var i int = nil 会导致编译错误。
为什么 nil 切片能 append,而 nil map 不能写入
上表中一个值得玩味的不对称性是:nil 切片可以直接 append,而 nil map 却不能写入。先看代码:
1 | // nil 切片:可以直接 append |
原因在于两者的操作语义不同。append 是函数调用,返回一个新切片,调用者必须接收返回值:
1 | s = append(s, 1) // append 发现 s 是 nil,分配新数组,返回新切片 |
而 map 赋值是原地操作,没有返回值:
1 | m["key"] = 1 // 要求 m 背后已有哈希表,nil map 没有 |
如果 Go 要让 nil map 自动初始化,就需要让赋值语句暗中修改 m 本身的指向——这会引入隐式副作用,违背 Go 追求显式、可预测的设计原则。因此 Go 选择让你显式初始化:
1 | m := make(map[string]int) |
控制流
if:可以带初始化语句
1 | if x := compute(); x > 0 { |
条件不需要括号。如果写了括号(比如从 C++ 迁移过来的习惯),go fmt 会自动将其去除。go fmt 是 Go 官方的代码格式化工具,几乎所有 Go 项目都强制使用它。因此 Go 代码的风格在全世界高度统一——你不需要记风格规则,写完跑一下 go fmt 就行。
for:唯一的循环
Go 没有 while,没有 do-while,for 一个关键字涵盖所有循环场景:
1 | for i := 0; i < 5; i++ {} // 经典 for |
switch:默认不穿透
1 | switch day { |
与 C/C++ 相反,Go 的 case 默认不穿透(fall through),不需要写 break。一个 case 可以匹配多个值。switch 还可以不带变量,作为 if/else 链的替代:
1 | switch { |
另外,Go 没有三元运算符(? :),必须使用 if/else。
函数
多返回值与错误处理
Go 最具特色的设计之一是多返回值,它直接塑造了 Go 的错误处理范式:
1 | func divide(a, b float64) (float64, error) { |
Go 没有 try/catch。error 是一个内置接口,只有一个方法:
1 | type error interface { |
任何实现了 Error() string 方法的类型都满足该接口。错误就是普通的返回值,调用者用 if err != nil 显式检查。与 C++ 异常的核心区别在于:C++ 的异常会中断控制流自动向上传播,Go 的错误则必须逐层手动传递。
interface{} 与 any
interface{} 是空接口——没有任何方法要求,所有类型都满足它。类似 C++ 的 std::any。Go 1.18 引入了 any 作为其别名:
1 | type any = interface{} |
这里的 type foo = bar 是类型别名,等价于 C++ 的 using foo = bar。Go 还有一种不带 = 的写法 type MyInt int,它创建一个全新的类型,可以为其定义方法——C++ 没有直接对应的概念。
用 _ 丢弃返回值
声明了但未使用的变量在 Go 中会导致编译错误。_ 是语言级别的空白标识符,用于丢弃不需要的值:
1 | _, err := divide(10, 0) // 只关心错误 |
与 Python 的 _ 不同,Go 的 _ 不是变量,不能读取它的值。
参数与类型省略
相邻的同类型参数可以省略前面的类型声明:
1 | func add(a, b int) int // a 和 b 都是 int |
省不省略纯看个人风格,go fmt 不干预这一点。
对于复杂的函数签名,可以用类型定义来简化:
1 | type BinaryOp func(int, int) int |
类似 C++ 的 using BinaryOp = std::function<int(int, int)>。
值语义与指针
Go 的函数传参全部是值语义,与 C++ 的默认行为一致。也就是说,函数接收的是实参的副本,修改副本不影响原变量:
1 | func swapByValue(a, b int) { |
要修改原变量,需要传指针:
1 | func swapByPointer(a, b *int) { |
语法与 C++ 几乎一样(* 解引用、& 取地址),但 Go 的指针更简单——没有指针运算,不能 p++,不能 p + offset。
实际上,Go 中交换两个变量通常直接使用多重赋值:
1 | a, b = b, a |
不需要 std::swap。
命名返回值
Go 允许在函数签名中为返回值命名:
1 | func rectInfo(w, h float64) (area, perimeter float64) { |
命名返回值的本质是:
- 在函数入口定义局部变量并以零值初始化。
- 若函数使用裸
return,则以这些变量的最终值作为返回值。 - 若函数显式
return expr,则按表达式的实际值返回。
一个重要细节是 return 与 defer 的执行顺序。return 语句实际上分三步:赋值 → 执行 defer → 真正返回。因此 defer 能够修改命名返回值:
1 | func named() (x int) { |
命名返回值最大的实际用途正是在 defer 中修改返回值(例如错误恢复)。
关于 return 的省略
返回值列表为空的函数可以不写 return,执行到末尾自动返回。有返回值的函数必须显式 return,否则编译报错。唯一的例外是编译器能确定控制流到不了函数末尾的情况(例如无限 for {})。
可变参数
1 | func sum(nums ...int) int { |
...int 在函数内部就是 []int 切片。展开操作 nums... 类似 C++ 的参数包展开,但有三个限制:
- 可变参数必须是函数的最后一个参数。
- 展开不能和额外参数混用——
sum(nums..., 4)不合法。 - 类型必须严格匹配——
[]int不能展开为...interface{}。
闭包
1 | func makeCounter() func() int { |
每次调用 makeCounter 都会创建一个独立的 count 变量。Go 的闭包按引用捕获,但每次函数调用产生的是不同的变量实例。类似 C++ 中 lambda 捕获一个局部变量——只是每次调用外层函数时,局部变量都是新的。
defer:延迟执行
defer 注册一个函数调用,推迟到当前函数返回前执行。多个 defer 按后进先出(LIFO)顺序执行:
1 | func deferDemo() { |
最常见的用途是资源清理:
1 | func process() { |
用 C++ 类比,defer 的效果类似于 RAII——在离开作用域(函数)时自动执行清理逻辑。
关于 make
make 是内置函数,仅用于初始化三种类型:
| 用法 | 作用 |
|---|---|
make([]T, len, cap) |
创建切片,cap 可省略 |
make(map[K]V, hint) |
创建 map,hint 可省略 |
make(chan T, buffer) |
创建 channel,buffer 可省略 |
这三种类型的底层都有复杂的内部结构(数组指针、哈希表、通信队列),var 声明只得到一个 nil,make 才真正分配并初始化。用 C++ 类比,make 类似调用构造函数(如 std::vector<int>(5)),而 var m map[K]V 类似声明了一个空指针但还没有 new。
自定义类型不使用 make,而是直接用字面量或取地址:
1 | u := &User{Name: "Liam", Age: 25} // 类似 C++ 的 new User{...} |
不需要手动 delete——Go 有垃圾回收。
切片:不是 std::vector
Go 的切片(slice)本质上是底层数组的一个视图,类似 C++20 的 std::span,但多了一个 cap 字段并且配合 append 可以触发扩容。
1 | // 切片的底层表示 |
C++ 的 std::vector 把视图和存储绑在一起作为一个对象,Go 则把它们拆开了——切片是视图,底层数组是存储,append 是连接二者的机制。多个切片可以指向同一个底层数组的不同区段。
切片没有方法(不是对象),append 是唯一的追加方式。因为切片是值类型,append 可能改变内部的 len 甚至 ptr(扩容时),所以必须接收返回值:
1 | s = append(s, elem) // 必须赋值回 s |
如果把 std::vector 想象成值语义,push_back 不是成员函数而是返回新 vector 的自由函数——那你也必须写 v = push_back(v, elem)。Go 的切片就是这种设计。
小结
目前而言,Go 给我的最大感受是:它在每个设计决策上都倾向于更少的隐式行为。没有隐式类型转换,没有异常的隐式传播,没有未初始化的变量,没有不带括号的选择——甚至连代码风格都由 go fmt 统一,不留余地。
对于 C++ 程序员来说,这种「显式哲学」有时会觉得啰嗦(比如反复写 if err != nil),但它带来的好处是代码行为的高度可预测性——读代码时不需要在脑中模拟复杂的隐式转换链、异常传播路径或模板特化规则。