0%

从 C++ 到 Go(二):集合、结构体与接口

本文是「从 C++ 到 Go」系列的第二篇,承接上一篇对基础语法、类型系统和函数的讨论,继续深入 Go 的核心数据结构(数组、切片、Map)、结构体与方法、以及接口机制。对于 C++ 程序员来说,这三部分恰好覆盖了 Go 与 C++ 差异最集中的区域——从 std::vector 到切片、从类继承到组合与接口。

数组与切片

数组:值类型,极少直接使用

1
2
a := [3]int{1, 2, 3}       // 固定长度
b := [...]int{1, 2, 3, 4} // 编译器推断长度

Go 的数组是值类型——赋值和传参都会完整复制,这一点和 C++ 的 std::array 一致。[3]int[4]int 是不同类型,不能互相赋值。在实际开发中,数组很少直接使用,切片才是日常的主角。

切片:底层数组的视图

正如上一篇在讨论 append 语义时所提到的,切片本质上是底层数组的一个视图,类似 C++20 的 std::span,但额外维护了一个 cap 字段并配合 append 可以触发扩容。切片可以从数组上截取,也可以从另一个切片上截取:

1
2
3
4
src := []int{10, 20, 30, 40, 50}
a := src[1:3] // [20 30],半开区间 [low, high)
b := src[:3] // [10 20 30],省略 low 默认 0
c := src[2:] // [30 40 50],省略 high 默认 len

这里有一个关键事实:截取不复制数据,新切片和原切片共享同一块底层内存。

共享底层数组

1
2
3
4
5
original := []int{1, 2, 3, 4, 5}
sub := original[1:4] // [2 3 4]

sub[0] = 99
fmt.Println(original) // [1 99 3 4 5],被修改了

修改 sub 会直接影响 original。这一点无论原始数据是数组还是切片都一样——切片只是视图,底层只有一份数据。可以通过比较首元素地址来验证两个切片是否共享底层数组:

1
fmt.Println(&sub[0] == &original[1])   // true

append 与共享的断裂

append 在容量(cap)足够时直接在底层数组上追加,不够时分配新数组。这导致了一个微妙的行为:

1
2
3
4
5
6
7
8
9
10
original := []int{10, 20, 30, 40, 50}
sub := original[1:4] // [20 30 40],cap=4

// cap 内 append:不扩容,直接写入原数组
sub = append(sub, 99)
fmt.Println(original) // [10 20 30 40 99],original[4] 被静默覆盖!

// 超出 cap:扩容,分配新数组
sub = append(sub, 77)
fmt.Println(&sub[0] == &original[1]) // false,已断开共享

第一次 append 时 cap 够用,99 被写入了 original[4] 的位置——这是切片最大的陷阱,因为你以为在操作 sub,实际上悄悄改了 original

要避免这个问题,可以使用三索引切片语法限制 cap:

1
sub := original[1:4:4]   // len=3, cap=3,cap 被限制

三个索引都是相对于原数组的位置:[low:high:max],其中 len = high - lowcap = max - low。当 high == max 时 cap 等于 len,任何 append 都会立即触发扩容,避免了静默覆盖。注意三索引切片仍然共享底层数组——它限制的是 append 的行为,不是直接修改。

要完全独立的副本,唯一的方式是 copy

1
2
safeCopy := make([]int, len(sub))
copy(safeCopy, sub)

删除切片元素

Go 没有内置的切片删除函数,惯用写法是:

1
2
3
nums := []int{10, 20, 30, 40, 50}
nums = append(nums[:2], nums[3:]...) // 删除索引 2 的元素
fmt.Println(nums) // [10 20 40 50]

这个操作是原地的——nums[:2] 的 cap 足够容纳后续元素,所以 append 在原数组上将后面的元素向前搬移,不会分配新内存。和 C++ 的 std::vector::erase 一样,时间复杂度 O(n)。

Map

Go 的 map 是哈希表,对应 C++ 的 std::unordered_map。Go 没有内置的有序 map。

1
2
3
4
scores := map[string]int{
"Go": 95,
"C++": 90,
}

读取不存在的 key

C++ 的 operator[] 会在 key 不存在时插入默认值——这是一个经典陷阱。Go 更安全:读取不存在的 key 返回零值,但不会插入。要区分「不存在」和「值恰好为零」,使用 comma-ok 模式:

1
2
3
4
val, ok := scores["Python"]   // val=0, ok=false
if ok {
fmt.Println("found:", val)
}

遍历顺序不保证

Go 故意将 map 遍历随机化——每次运行的顺序可能不同,防止程序依赖于遍历顺序。C++ 的 std::unordered_map 也不保证顺序,但 Go 在这一点上更激进。

嵌套 map

1
2
3
m := map[string]map[string]int{
"math": {"algebra": 90, "calculus": 85},
}

嵌套 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
2
3
4
5
6
7
8
var mu sync.Mutex
m := map[int]int{}

go func() {
mu.Lock()
m[1] = 1
mu.Unlock()
}()

结构体与方法

定义与创建

1
2
3
4
5
6
7
8
9
type Point struct {
X, Y float64 // 大写开头:包外可见(public)
}

type User struct {
Name string
Age int
role string // 小写开头:包外不可见(private)
}

Go 用首字母大小写控制可见性,没有 public/private 关键字。并且可见性的粒度是而不是结构体——同一个包内的所有代码都能访问小写字段。这意味着 Go 用包的拆分来实现 C++ 用 class 做的封装。如果一个包膨胀到几十个结构体,它们之间就没有封装可言了,因此 Go 社区倾向于把不同职责拆成不同的包。

创建结构体的方式:

1
2
3
p1 := Point{X: 3, Y: 4}    // 命名字段
p2 := &Point{X: 5, Y: 6} // 取指针,类似 C++ 的 new
p3 := Point{} // 零值,所有字段自动初始化

结构体是值类型,赋值会完整复制。指针访问字段不需要 ->,Go 自动解引用:p2.X 等同于 (*p2).X

方法:值接收者与指针接收者

1
2
3
4
5
6
7
8
9
10
func (p Point) Distance(other Point) float64 {   // 值接收者
dx := p.X - other.X
dy := p.Y - other.Y
return math.Sqrt(dx*dx + dy*dy)
}

func (p *Point) Translate(dx, dy float64) { // 指针接收者
p.X += dx
p.Y += dy
}

值接收者不修改接收者本身,类似 C++ 的 const 成员函数;指针接收者可以修改,类似非 const 成员函数。Go 在方法调用时会自动取地址或解引用——p.Translate(1, 2) 自动变成 (&p).Translate(1, 2)

但 Go 没有 const 指针。C++ 中可以用 const Point*const Point& 防止意外修改传入的参数,Go 做不到。传指针以避免大结构体的拷贝开销是常见做法,但编译器不会帮你阻止对传入指针的修改——只能靠纪律和代码审查。

没有构造函数和析构函数

Go 的结构体没有构造函数,惯例是用 NewXxx 工厂函数:

1
2
3
func NewUser(name string, age int) *User {
return &User{Name: name, Age: age, role: "member"}
}

NewUser 能访问小写的 role 字段,不是因为什么特殊权限,而是因为它和 User 在同一个包内。

Go 也没有析构函数。资源清理用显式的 Close 方法加 defer

1
2
3
store, err := NewUserStore("...")
if err != nil { ... }
defer store.Close()

和 C++ RAII 的关键区别在于:C++ 的析构函数由编译器保证在对象离开作用域时自动调用;Go 的 defer 需要调用者手动写,忘了就泄漏。此外 defer 是函数级别的,不是块级别的——在循环内 defer 会堆积到函数返回时才一起执行。

对于需要长期持有资源的场景(如数据库连接贯穿整个服务生命周期),Go 的惯例是在 main 中创建资源,通过参数显式注入到需要的地方,defer 负责在程序退出时收尾——和 C++ 中在 main 里持有资源的做法是同一个时机,只是 Go 不鼓励用单例模式,偏好显式的依赖注入。

匿名嵌入:Go 的「继承替代品」

Go 没有继承。替代方案是匿名嵌入——被嵌入类型的字段和方法会自动提升到外层:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type Animal struct{ Name string }

func (a Animal) Speak() string { return a.Name + " makes a sound" }

type Dog struct {
Animal // 匿名嵌入,不写字段名
Breed string
}

func (d Dog) Speak() string { return d.Name + " barks" }

type Cat struct {
Animal // Cat 不重写 Speak,自动使用 Animal 的
}

Dog 可以直接访问 d.Name(从 Animal 提升上来),也可以「重写」方法。Cat 没有自己的 Speak,调用时自动使用 Animal 的版本。

但这不是真正的继承——Dog 不能赋值给 Animal 类型的变量。要实现多态,必须通过接口。

接口

隐式实现

Go 的接口只声明方法签名:

1
2
3
4
type Shape interface {
Area() float64
Perimeter() float64
}

任何类型只要实现了接口要求的所有方法,就自动满足该接口,不需要 implements 声明:

1
2
3
4
5
type Circle struct{ Radius float64 }

func (c Circle) Area() float64 { return math.Pi * c.Radius * c.Radius }
func (c Circle) Perimeter() float64 { return 2 * math.Pi * c.Radius }
// Circle 自动满足 Shape

这和 C++ 必须显式写 class Circle : public Shape 形成鲜明对比。隐式实现的好处是,你可以为别人写的类型定义新接口——只要方法签名对得上。

多态

1
2
3
4
5
6
func PrintShapeInfo(s Shape) {
fmt.Printf("面积: %.2f, 周长: %.2f\n", s.Area(), s.Perimeter())
}

PrintShapeInfo(Circle{Radius: 5})
PrintShapeInfo(Rectangle{Width: 3, Height: 4})

PrintShapeInfo 内部,只能调用 Shape 接口声明的方法。即使具体类型有其他方法(比如 String()),通过 Shape 也看不到——这和 C++ 中通过基类指针只能调用基类声明的虚函数是一样的。要访问其他方法,需要类型断言。

接口变量的本质

一个重要的心智模型:Go 的接口变量本质上是一个胖指针 (类型信息, 数据指针)

1
2
var s Shape             // (nil, nil),类似 C++ 的 Shape* s = nullptr
s = Circle{Radius: 5} // (Circle, &data),绑定到具体类型

不是具体类型的实例(不等于 C++ 中实例化一个抽象类),而是一个始终指向别处的间接引用。和 C++ 基类指针的区别在于自动解引用——s.Area() 不需要写 s->Area()。和 C++ 引用的区别在于它可以是 nil、可以重新赋值。

类型断言与类型开关

类型断言是 Go 版的 dynamic_cast

1
2
3
if c, ok := s.(Circle); ok {
fmt.Println("半径:", c.Radius)
}

失败时有两种行为——带 ok 返回 false(类似 dynamic_cast 指针版返回 nullptr),不带 ok 直接 panic(类似 dynamic_cast 引用版抛 std::bad_cast)。

类型开关用于多路分发:

1
2
3
4
5
6
switch v := s.(type) {
case Circle:
fmt.Println("圆形, 半径:", v.Radius)
case Rectangle:
fmt.Println("矩形:", v.Width, "x", v.Height)
}

接口组合

多个接口可以组合成新接口:

1
2
3
4
5
6
7
8
type Stringer interface {
String() string
}

type PrintableShape interface {
Shape
Stringer
}

一个类型必须同时满足 ShapeStringer 的所有方法,才能赋值给 PrintableShape。这类似 C++ 的多重继承纯虚基类,但不存在菱形继承问题。

方法集规则

值接收者和指针接收者对接口满足的影响是一个微妙但重要的规则:

1
2
3
4
type Mover interface{ Move() }

func (d Dog) Speak() string {} // 值接收者
func (d *Dog) Move() {} // 指针接收者
接收者类型 谁能满足接口
值接收者 (t T) T*T 都行
指针接收者 (t *T) 只有 *T

严格来说,如果 Move() 定义在 *Dog 上,那么满足 Mover 接口的是 *Dog,而不是 Dog

1
2
var m Mover = &Dog{}   // OK
var m Mover = Dog{} // 编译错误

日常说话时会偷懒说「Dog 实现了 Mover」,但类型系统的真相是「*Dog 实现了 Mover」。

接口与 nil 的陷阱

接口内部是 (类型信息, 值) 两个字段。只有两者都为 nil 时,接口才等于 nil:

1
2
3
var s Shape          // (nil, nil) → s == nil 是 true
var cp *Circle // nil 指针
s = cp // (*Circle, nil) → s == nil 是 false!

这在错误处理中最容易踩坑:

1
2
3
4
5
6
7
func GetShape() Shape {
var c *Circle // nil
return c // 返回 (*Circle, nil),不是 nil 接口!
}

s := GetShape()
if s == nil { ... } // 永远不会进入

正确做法是显式返回 nil

1
2
3
4
5
6
7
func GetShape() Shape {
c := getCircle()
if c == nil {
return nil // 返回 (nil, nil)
}
return c
}

小结

这三部分内容——集合、结构体、接口——集中体现了 Go 与 C++ 在设计哲学上的分歧。

Go 用切片视图代替了 std::vector 的自包含设计,获得了轻量和灵活,代价是共享底层数组带来的隐式耦合。Go 用包级可见性代替了类级封装,用 NewXxx 工厂函数和 defer Close() 代替了构造/析构函数和 RAII,简化了对象生命周期模型,代价是资源管理的责任从编译器转移到了程序员。Go 用隐式接口实现代替了显式继承,消除了继承层次的复杂性,代价是失去了编译器对「这个类型打算实现这个接口」的文档化声明。

这些取舍是否值得,取决于项目的规模和团队的习惯。但理解这些差异的存在,是从 C++ 程序员顺利过渡到 Go 程序员的关键一步。

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