本文是「从 C++ 到 Go」系列的第二篇,承接上一篇对基础语法、类型系统和函数的讨论,继续深入 Go 的核心数据结构(数组、切片、Map)、结构体与方法、以及接口机制。对于 C++ 程序员来说,这三部分恰好覆盖了 Go 与 C++ 差异最集中的区域——从 std::vector 到切片、从类继承到组合与接口。
数组与切片
数组:值类型,极少直接使用
1 | a := [3]int{1, 2, 3} // 固定长度 |
Go 的数组是值类型——赋值和传参都会完整复制,这一点和 C++ 的 std::array 一致。[3]int 和 [4]int 是不同类型,不能互相赋值。在实际开发中,数组很少直接使用,切片才是日常的主角。
切片:底层数组的视图
正如上一篇在讨论 append 语义时所提到的,切片本质上是底层数组的一个视图,类似 C++20 的 std::span,但额外维护了一个 cap 字段并配合 append 可以触发扩容。切片可以从数组上截取,也可以从另一个切片上截取:
1 | src := []int{10, 20, 30, 40, 50} |
这里有一个关键事实:截取不复制数据,新切片和原切片共享同一块底层内存。
共享底层数组
1 | original := []int{1, 2, 3, 4, 5} |
修改 sub 会直接影响 original。这一点无论原始数据是数组还是切片都一样——切片只是视图,底层只有一份数据。可以通过比较首元素地址来验证两个切片是否共享底层数组:
1 | fmt.Println(&sub[0] == &original[1]) // true |
append 与共享的断裂
append 在容量(cap)足够时直接在底层数组上追加,不够时分配新数组。这导致了一个微妙的行为:
1 | original := []int{10, 20, 30, 40, 50} |
第一次 append 时 cap 够用,99 被写入了 original[4] 的位置——这是切片最大的陷阱,因为你以为在操作 sub,实际上悄悄改了 original。
要避免这个问题,可以使用三索引切片语法限制 cap:
1 | sub := original[1:4:4] // len=3, cap=3,cap 被限制 |
三个索引都是相对于原数组的位置:[low:high:max],其中 len = high - low,cap = max - low。当 high == max 时 cap 等于 len,任何 append 都会立即触发扩容,避免了静默覆盖。注意三索引切片仍然共享底层数组——它限制的是 append 的行为,不是直接修改。
要完全独立的副本,唯一的方式是 copy:
1 | safeCopy := make([]int, len(sub)) |
删除切片元素
Go 没有内置的切片删除函数,惯用写法是:
1 | nums := []int{10, 20, 30, 40, 50} |
这个操作是原地的——nums[:2] 的 cap 足够容纳后续元素,所以 append 在原数组上将后面的元素向前搬移,不会分配新内存。和 C++ 的 std::vector::erase 一样,时间复杂度 O(n)。
Map
Go 的 map 是哈希表,对应 C++ 的 std::unordered_map。Go 没有内置的有序 map。
1 | scores := map[string]int{ |
读取不存在的 key
C++ 的 operator[] 会在 key 不存在时插入默认值——这是一个经典陷阱。Go 更安全:读取不存在的 key 返回零值,但不会插入。要区分「不存在」和「值恰好为零」,使用 comma-ok 模式:
1 | val, ok := scores["Python"] // val=0, ok=false |
遍历顺序不保证
Go 故意将 map 遍历随机化——每次运行的顺序可能不同,防止程序依赖于遍历顺序。C++ 的 std::unordered_map 也不保证顺序,但 Go 在这一点上更激进。
嵌套 map
1 | m := map[string]map[string]int{ |
嵌套 map 的陷阱在于,往不存在的外层 key 写入时内层 map 是 nil:
1 | m["physics"]["optics"] = 92 // panic!m["physics"] 是 nil map |
必须先初始化内层。实际开发中,如果嵌套变复杂,通常用结构体替代嵌套 map,更清晰也更安全。
并发安全
Go 的 map 不是并发安全的,这一点和 C++ 的 std::unordered_map 一样。但 Go 更激进——运行时检测到并发读写会直接 panic,而非 C++ 的未定义行为(可能静默出错)。需要并发访问时,用 sync.Mutex 保护:
1 | var mu sync.Mutex |
结构体与方法
定义与创建
1 | type Point struct { |
Go 用首字母大小写控制可见性,没有 public/private 关键字。并且可见性的粒度是包而不是结构体——同一个包内的所有代码都能访问小写字段。这意味着 Go 用包的拆分来实现 C++ 用 class 做的封装。如果一个包膨胀到几十个结构体,它们之间就没有封装可言了,因此 Go 社区倾向于把不同职责拆成不同的包。
创建结构体的方式:
1 | p1 := Point{X: 3, Y: 4} // 命名字段 |
结构体是值类型,赋值会完整复制。指针访问字段不需要 ->,Go 自动解引用:p2.X 等同于 (*p2).X。
方法:值接收者与指针接收者
1 | func (p Point) Distance(other Point) float64 { // 值接收者 |
值接收者不修改接收者本身,类似 C++ 的 const 成员函数;指针接收者可以修改,类似非 const 成员函数。Go 在方法调用时会自动取地址或解引用——p.Translate(1, 2) 自动变成 (&p).Translate(1, 2)。
但 Go 没有 const 指针。C++ 中可以用 const Point* 或 const Point& 防止意外修改传入的参数,Go 做不到。传指针以避免大结构体的拷贝开销是常见做法,但编译器不会帮你阻止对传入指针的修改——只能靠纪律和代码审查。
没有构造函数和析构函数
Go 的结构体没有构造函数,惯例是用 NewXxx 工厂函数:
1 | func NewUser(name string, age int) *User { |
NewUser 能访问小写的 role 字段,不是因为什么特殊权限,而是因为它和 User 在同一个包内。
Go 也没有析构函数。资源清理用显式的 Close 方法加 defer:
1 | store, err := NewUserStore("...") |
和 C++ RAII 的关键区别在于:C++ 的析构函数由编译器保证在对象离开作用域时自动调用;Go 的 defer 需要调用者手动写,忘了就泄漏。此外 defer 是函数级别的,不是块级别的——在循环内 defer 会堆积到函数返回时才一起执行。
对于需要长期持有资源的场景(如数据库连接贯穿整个服务生命周期),Go 的惯例是在 main 中创建资源,通过参数显式注入到需要的地方,defer 负责在程序退出时收尾——和 C++ 中在 main 里持有资源的做法是同一个时机,只是 Go 不鼓励用单例模式,偏好显式的依赖注入。
匿名嵌入:Go 的「继承替代品」
Go 没有继承。替代方案是匿名嵌入——被嵌入类型的字段和方法会自动提升到外层:
1 | type Animal struct{ Name string } |
Dog 可以直接访问 d.Name(从 Animal 提升上来),也可以「重写」方法。Cat 没有自己的 Speak,调用时自动使用 Animal 的版本。
但这不是真正的继承——Dog 不能赋值给 Animal 类型的变量。要实现多态,必须通过接口。
接口
隐式实现
Go 的接口只声明方法签名:
1 | type Shape interface { |
任何类型只要实现了接口要求的所有方法,就自动满足该接口,不需要 implements 声明:
1 | type Circle struct{ Radius float64 } |
这和 C++ 必须显式写 class Circle : public Shape 形成鲜明对比。隐式实现的好处是,你可以为别人写的类型定义新接口——只要方法签名对得上。
多态
1 | func PrintShapeInfo(s Shape) { |
在 PrintShapeInfo 内部,只能调用 Shape 接口声明的方法。即使具体类型有其他方法(比如 String()),通过 Shape 也看不到——这和 C++ 中通过基类指针只能调用基类声明的虚函数是一样的。要访问其他方法,需要类型断言。
接口变量的本质
一个重要的心智模型:Go 的接口变量本质上是一个胖指针 (类型信息, 数据指针)。
1 | var s Shape // (nil, nil),类似 C++ 的 Shape* s = nullptr |
它不是具体类型的实例(不等于 C++ 中实例化一个抽象类),而是一个始终指向别处的间接引用。和 C++ 基类指针的区别在于自动解引用——s.Area() 不需要写 s->Area()。和 C++ 引用的区别在于它可以是 nil、可以重新赋值。
类型断言与类型开关
类型断言是 Go 版的 dynamic_cast:
1 | if c, ok := s.(Circle); ok { |
失败时有两种行为——带 ok 返回 false(类似 dynamic_cast 指针版返回 nullptr),不带 ok 直接 panic(类似 dynamic_cast 引用版抛 std::bad_cast)。
类型开关用于多路分发:
1 | switch v := s.(type) { |
接口组合
多个接口可以组合成新接口:
1 | type Stringer interface { |
一个类型必须同时满足 Shape 和 Stringer 的所有方法,才能赋值给 PrintableShape。这类似 C++ 的多重继承纯虚基类,但不存在菱形继承问题。
方法集规则
值接收者和指针接收者对接口满足的影响是一个微妙但重要的规则:
1 | type Mover interface{ Move() } |
| 接收者类型 | 谁能满足接口 |
|---|---|
值接收者 (t T) |
T 和 *T 都行 |
指针接收者 (t *T) |
只有 *T |
严格来说,如果 Move() 定义在 *Dog 上,那么满足 Mover 接口的是 *Dog,而不是 Dog:
1 | var m Mover = &Dog{} // OK |
日常说话时会偷懒说「Dog 实现了 Mover」,但类型系统的真相是「*Dog 实现了 Mover」。
接口与 nil 的陷阱
接口内部是 (类型信息, 值) 两个字段。只有两者都为 nil 时,接口才等于 nil:
1 | var s Shape // (nil, nil) → s == nil 是 true |
这在错误处理中最容易踩坑:
1 | func GetShape() Shape { |
正确做法是显式返回 nil:
1 | func GetShape() Shape { |
小结
这三部分内容——集合、结构体、接口——集中体现了 Go 与 C++ 在设计哲学上的分歧。
Go 用切片视图代替了 std::vector 的自包含设计,获得了轻量和灵活,代价是共享底层数组带来的隐式耦合。Go 用包级可见性代替了类级封装,用 NewXxx 工厂函数和 defer Close() 代替了构造/析构函数和 RAII,简化了对象生命周期模型,代价是资源管理的责任从编译器转移到了程序员。Go 用隐式接口实现代替了显式继承,消除了继承层次的复杂性,代价是失去了编译器对「这个类型打算实现这个接口」的文档化声明。
这些取舍是否值得,取决于项目的规模和团队的习惯。但理解这些差异的存在,是从 C++ 程序员顺利过渡到 Go 程序员的关键一步。