0%

从 C++ 到 Go(一):基础语法、类型系统与函数

对于有 C++ 经验的程序员来说,学习 Go 是一次有趣的体验。Go 在许多设计上与 C++ 有着相似的底层思路,但又在语法和哲学上做出了截然不同的取舍。本文是我学习 Go 的实践笔记,以代码为主线,穿插与 C++ 的对比,力求在「知其然」的同时也「知其所以然」。

Hello, Go

一切从 go mod init 和一个最简单的程序开始。

1
2
3
4
5
6
7
package main

import "fmt"

func main() {
fmt.Println("你好,Go!")
}

几个关键点:

  • 每个可执行程序必须有 package mainfunc main()
  • 导入了但未使用的包会导致编译错误——Go 在这一点上比 C++ 严格得多。
  • 左花括号 { 必须与函数声明在同一行,不能换行。这不是风格建议,而是语法规则。
  • 不需要分号结尾。

Go 天然支持 Unicode

Go 的源代码和字符串都是 UTF-8 编码的。这不是巧合——UTF-8 编码本身就是 Go 的两位创造者之一 Rob Pike 和 Ken Thompson 发明的。

1
2
3
s := "你好Go"
fmt.Println(len(s)) // 8(字节数)
fmt.Println(len([]rune(s))) // 4(字符数)

这里涉及一个核心概念的区分:string 的底层是 UTF-8 字节序列len(s) 返回的是字节数,而非字符数。rune 是 Go 的字符类型(等同于 int32),代表一个 Unicode 码点。

两种遍历方式的行为因此不同:

1
2
3
4
5
6
7
8
9
// 逐字节遍历
for i := 0; i < len(s); i++ {
fmt.Printf("字节[%d] = %x\n", i, s[i])
}

// 逐字符遍历:range 自动按 rune 解码
for i, ch := range s {
fmt.Printf("位置[%d] = %c (Unicode: %U)\n", i, ch, ch)
}

后者的输出中,位置会从 0 跳到 3 再跳到 6,因为每个中文字符占 3 个 UTF-8 字节。

[]rune(s) 是什么

这是 Go 的类型转换语法。[]runerune 的切片类型,(s) 将字符串 s 转换为该类型。Go 的类型转换统一使用 Type(value) 语法,例如 float64(x)string(65)[]rune(s)

1
2
bytes := []byte(s)   // [228 189 160 229 165 189 71 111]  8 个字节
runes := []rune(s) // [20320 22909 71 111] 4 个字符

「你」被拆成了 3 个字节 [228 189 160],而作为 rune 它就是一个码点 20320

变量与类型

三种声明方式

1
2
3
var name string = "Liam"   // 完整写法
var age = 25 // 省略类型,自动推断
city := "Shanghai" // 短声明,最常用(只能在函数内使用)

短声明 := 是日常开发中最常见的写法。

基本类型

类型 说明 零值
bool 布尔 false
int, int8/16/32/64 整数 0
float32, float64 浮点数 0.0
string 字符串(UTF-8) ""
rune 字符(= int32 0
byte 字节(= uint8 0

零值:没有「未初始化」的概念

Go 的每种类型都有零值。声明变量后即可安全使用,不存在 C++ 中局部变量未初始化导致的未定义行为。

1
2
3
var i int       // 0
var s string // ""
var b bool // false

类型转换必须显式

Go 不做任何隐式类型转换。intfloat64 之间必须显式转换:

1
2
3
var x int = 10
var y float64 = float64(x) // 必须显式
// var bad float64 = x // 编译错误

这一点和 C++ 的隐式转换(intdouble)形成鲜明对比。Go 的哲学是:显式优于隐式

nil:不只是 nullptr

C++ 的 nullptr 只用于指针。Go 的 nil 是六种类型的零值:

类型 nil 时的行为
指针 *T 解引用 panic(同 C++)
切片 []T 可以 appendlen 返回 0
map 可以读(返回零值),写入 panic
函数 func() 调用 panic
接口 / error 打印 <nil>
channel 收发阻塞

注意:基本类型(intstringbool 等)的零值不是 nilvar i int = nil 会导致编译错误。

为什么 nil 切片能 append,而 nil map 不能写入

上表中一个值得玩味的不对称性是:nil 切片可以直接 append,而 nil map 却不能写入。先看代码:

1
2
3
4
5
6
7
8
9
10
11
12
// nil 切片:可以直接 append
var s []string
s = append(s, "Go")
s = append(s, "C++")
fmt.Println(s) // [Go C++]

// nil map:读取返回零值,不报错
var m map[string]int
fmt.Println(m["Go"]) // 0

// nil map:写入直接 panic
// m["Go"] = 1 // panic: assignment to entry in nil map

原因在于两者的操作语义不同。append 是函数调用,返回一个新切片,调用者必须接收返回值:

1
s = append(s, 1)   // append 发现 s 是 nil,分配新数组,返回新切片

而 map 赋值是原地操作,没有返回值:

1
m["key"] = 1   // 要求 m 背后已有哈希表,nil map 没有

如果 Go 要让 nil map 自动初始化,就需要让赋值语句暗中修改 m 本身的指向——这会引入隐式副作用,违背 Go 追求显式、可预测的设计原则。因此 Go 选择让你显式初始化:

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

控制流

if:可以带初始化语句

1
2
3
4
if x := compute(); x > 0 {
fmt.Println(x)
}
// x 在这里不可见

条件不需要括号。如果写了括号(比如从 C++ 迁移过来的习惯),go fmt 会自动将其去除。go fmt 是 Go 官方的代码格式化工具,几乎所有 Go 项目都强制使用它。因此 Go 代码的风格在全世界高度统一——你不需要记风格规则,写完跑一下 go fmt 就行。

for:唯一的循环

Go 没有 while,没有 do-whilefor 一个关键字涵盖所有循环场景:

1
2
3
4
for i := 0; i < 5; i++ {}      // 经典 for
for n < 100 {} // 当 while 用
for {} // 无限循环
for i, v := range collection {} // 遍历集合

switch:默认不穿透

1
2
3
4
5
6
switch day {
case "周一", "周二", "周三", "周四", "周五":
fmt.Println("工作日")
case "周六", "周日":
fmt.Println("周末")
}

与 C/C++ 相反,Go 的 case 默认不穿透(fall through),不需要写 break。一个 case 可以匹配多个值。switch 还可以不带变量,作为 if/else 链的替代:

1
2
3
4
5
6
switch {
case temp >= 35:
fmt.Println("酷热")
case temp >= 25:
fmt.Println("温暖")
}

另外,Go 没有三元运算符(? :),必须使用 if/else。

函数

多返回值与错误处理

Go 最具特色的设计之一是多返回值,它直接塑造了 Go 的错误处理范式:

1
2
3
4
5
6
7
8
9
10
11
func divide(a, b float64) (float64, error) {
if b == 0 {
return 0, errors.New("除数不能为零")
}
return a / b, nil
}

result, err := divide(10, 3)
if err != nil {
fmt.Println("错误:", err)
}

Go 没有 try/catch。error 是一个内置接口,只有一个方法:

1
2
3
type error interface {
Error() string
}

任何实现了 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
2
_, err := divide(10, 0)       // 只关心错误
_, _, third := threeValues() // 可以出现多次

与 Python 的 _ 不同,Go 的 _ 不是变量,不能读取它的值。

参数与类型省略

相邻的同类型参数可以省略前面的类型声明:

1
2
func add(a, b int) int              // a 和 b 都是 int
func mixed(a, b int, c string) int // 只有连续同类型才能省略

省不省略纯看个人风格,go fmt 不干预这一点。

对于复杂的函数签名,可以用类型定义来简化:

1
2
3
4
5
type BinaryOp func(int, int) int

func apply(a, b int, op BinaryOp) int {
return op(a, b)
}

类似 C++ 的 using BinaryOp = std::function<int(int, int)>

值语义与指针

Go 的函数传参全部是值语义,与 C++ 的默认行为一致。也就是说,函数接收的是实参的副本,修改副本不影响原变量:

1
2
3
4
5
6
7
func swapByValue(a, b int) {
a, b = b, a // 只改了副本,调用者的变量不受影响
}

x, y := 1, 2
swapByValue(x, y)
fmt.Println(x, y) // 1 2,没有交换

要修改原变量,需要传指针:

1
2
3
4
5
func swapByPointer(a, b *int) {
*a, *b = *b, *a
}
swapByPointer(&x, &y)
fmt.Println(x, y) // 2 1,交换成功

语法与 C++ 几乎一样(* 解引用、& 取地址),但 Go 的指针更简单——没有指针运算,不能 p++,不能 p + offset

实际上,Go 中交换两个变量通常直接使用多重赋值:

1
a, b = b, a

不需要 std::swap

命名返回值

Go 允许在函数签名中为返回值命名:

1
2
3
4
5
func rectInfo(w, h float64) (area, perimeter float64) {
area = w * h
perimeter = 2 * (w + h)
return // 裸 return,自动返回 area 和 perimeter
}

命名返回值的本质是:

  1. 在函数入口定义局部变量并以零值初始化。
  2. 若函数使用裸 return,则以这些变量的最终值作为返回值。
  3. 若函数显式 return expr,则按表达式的实际值返回。

一个重要细节是 returndefer 的执行顺序。return 语句实际上分三步:赋值 → 执行 defer → 真正返回。因此 defer 能够修改命名返回值:

1
2
3
4
5
6
7
8
9
10
func named() (x int) {
defer func() { x += 100 }()
return 1 // x 先被设为 1,defer 把 x 改为 101,最终返回 101
}

func unnamed() int {
x := 0
defer func() { x += 100 }()
return x // 返回值已确定为 0,defer 改的是局部变量,无法影响返回值
}

命名返回值最大的实际用途正是在 defer 中修改返回值(例如错误恢复)。

关于 return 的省略

返回值列表为空的函数可以不写 return,执行到末尾自动返回。有返回值的函数必须显式 return,否则编译报错。唯一的例外是编译器能确定控制流到不了函数末尾的情况(例如无限 for {})。

可变参数

1
2
3
4
5
6
7
8
9
10
11
func sum(nums ...int) int {
total := 0
for _, n := range nums {
total += n
}
return total
}

sum(1, 2, 3) // 逐个传入
nums := []int{1, 2, 3}
sum(nums...) // 展开切片传入

...int 在函数内部就是 []int 切片。展开操作 nums... 类似 C++ 的参数包展开,但有三个限制:

  1. 可变参数必须是函数的最后一个参数。
  2. 展开不能和额外参数混用——sum(nums..., 4) 不合法。
  3. 类型必须严格匹配——[]int 不能展开为 ...interface{}

闭包

1
2
3
4
5
6
7
func makeCounter() func() int {
count := 0
return func() int {
count++
return count
}
}

每次调用 makeCounter 都会创建一个独立的 count 变量。Go 的闭包按引用捕获,但每次函数调用产生的是不同的变量实例。类似 C++ 中 lambda 捕获一个局部变量——只是每次调用外层函数时,局部变量都是新的。

defer:延迟执行

defer 注册一个函数调用,推迟到当前函数返回前执行。多个 defer 按后进先出(LIFO)顺序执行:

1
2
3
4
5
6
7
func deferDemo() {
fmt.Println("开始")
defer fmt.Println("defer 1")
defer fmt.Println("defer 2")
fmt.Println("结束")
}
// 输出:开始 → 结束 → defer 2 → defer 1

最常见的用途是资源清理:

1
2
3
4
5
func process() {
f, _ := os.Open("file.txt")
defer f.Close() // 不管后续如何 return,文件都会被关闭
// ... 使用 f ...
}

用 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
2
3
4
5
6
// 切片的底层表示
struct {
ptr *array // 指向底层数组
len int // 当前长度
cap int // 容量
}

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),但它带来的好处是代码行为的高度可预测性——读代码时不需要在脑中模拟复杂的隐式转换链、异常传播路径或模板特化规则。

俗话说,投资效率是最好的投资。 如果您感觉我的文章质量不错,读后收获很大,预计能为您提高 10% 的工作效率,不妨小额捐助我一下,让我有动力继续写出更多好文章。