Go:struct、方法集和组合,为什么比面向对象语法更重要

学 Go 到这里时,常会突然产生一种不适应感:

  • 没有 class
  • 没有 extends
  • 没有那种熟悉的“对象自己管理一切”的语法氛围
  • 明明有方法,却又总觉得不像传统面向对象语言

于是就很容易进入一个误区:一直追着 Go 里有没有“类”这件事问,却没有把 struct、方法集、接收者和组合真正学明白。

这会直接带来几类很现实的问题:

  • 明明只是想给类型加行为,却不知道该用值接收者还是指针接收者
  • 写接口时感觉都对,结果一赋值就编译报错
  • 看到嵌入字段就把它当成继承,后来一改结构就越来越乱
  • 为了“像面向对象”把一切都绑成大 struct,最后耦合一团

Go 在设计上根本不鼓励你先找“类的替身”。
它更在意的是另外几件事:

  • 先把数据形状定义清楚
  • 再把行为挂到合适的类型上
  • 用方法集和接口描述能力边界
  • 用组合复用能力,而不是靠继承堆层级

这篇文章就围绕一个实际的小场景来讲:做一个回归任务执行结果汇总器
这个场景里会自然碰到:

  • struct 表达任务、统计器、任务集
  • 给类型加方法,而不是先找“类”
  • 用值接收者和指针接收者区分“读”和“改”
  • 理解方法集为什么会影响接口实现
  • 用嵌入和组合复用统计能力,而不是追着继承语法跑

把这条线理顺,后面再学 interface、error、并发、HTTP 服务时,会顺很多。

一、这篇文章要解决什么问题

读完这一篇,应该能独立回答这些问题:

  • struct 在 Go 里到底扮演什么角色
  • 方法为什么不是“类语法的残片”,而是类型行为的组织方式
  • 值接收者和指针接收者到底分别在表达什么
  • 什么是方法集,为什么它会直接影响接口实现
  • 嵌入和组合为什么比“模拟继承”更重要
  • 真实工程里,什么时候该优先组合,什么时候只保留最小行为接口

如果这些问题能说清楚,后面你写的 Go 代码会更像工程代码,而不是“能跑的练习题”。

二、先看最终要做出来的小场景

假设你在做一个最小的 CI 回归任务汇总器。

它要做几件事:

  1. 定义一个任务 Task
  2. 记录任务执行结果
  3. 把多个任务放进一个任务集 Suite
  4. 统计通过率
  5. 输出摘要信息

这个需求不大,但非常适合用来理解 Go 的类型组织方式。

先把核心对象列出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
type Task struct {
ID string
Name string
Status string
DurationSec int
ErrorMsg string
}

type Counter struct {
Total int
Passed int
Failed int
}

type Suite struct {
Name string
Tasks []Task
Counter
}

这里先注意两件事:

  • TaskCounterSuite 都只是数据形状
  • Suite 通过嵌入 Counter 获得统计能力

Go 的第一步不是“建类”,而是先给数据一个稳定形状

三、先从最小 struct 开始:Go 先用它描述数据

看到 struct 时,第一反应很容易是“这是不是 class 的简化版”。

这个理解会让你很快跑偏。

在 Go 里,struct 最重要的职责不是面向对象包装,而是:

  • 明确字段
  • 明确边界
  • 让数据在函数和模块之间有稳定形状

看最小例子:

1
2
3
4
5
6
type Task struct {
ID string
Name string
Status string
Retries int
}

这时的 Task 只是一个类型定义,它并不自动携带“封装”“继承”“访问控制树状结构”那套传统想象。

它只是明确告诉你:

  • 一个任务有哪些字段
  • 每个字段是什么类型
  • 任务这个概念在代码里长什么样

如果这一步都没稳定下来,后面讨论方法、接口和组合都没有意义。

四、方法不是 class 的附属物,而是给类型补行为

Go 允许你给类型定义方法。

比如给 Task 增加一个摘要方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package main

import "fmt"

type Task struct {
ID string
Name string
Status string
DurationSec int
}

func (t Task) Summary() string {
return fmt.Sprintf("%s[%s] status=%s duration=%ds", t.Name, t.ID, t.Status, t.DurationSec)
}

这里要注意的是:

  • 方法本质上还是函数
  • 只是它多了一个接收者
  • 接收者把这个函数和某个类型绑定起来了

也就是说,Go 的方法并不是在模仿“类的内部成员函数”,而是在表达:

  • 某个类型有哪些相关行为
  • 这些行为应该围绕哪个类型组织

这比“它像不像类方法”重要得多。

五、值接收者和指针接收者,到底分别在表达什么

这是这一篇的核心之一。

看两组方法:

1
2
3
4
5
6
7
8
9
func (t Task) Summary() string {
return fmt.Sprintf("%s[%s] status=%s", t.Name, t.ID, t.Status)
}

func (t *Task) MarkPassed(seconds int) {
t.Status = "passed"
t.DurationSec = seconds
t.ErrorMsg = ""
}

这两个方法最关键的区别不是语法,而是语义

  • Summary 只是读取数据,适合值接收者
  • MarkPassed 会修改任务状态,适合指针接收者

所以接收者选择时,先问的不是“哪个更像面向对象”,而是:

  • 这个方法是在读,还是在改
  • 我想表达复制语义,还是原地修改语义
  • 这个类型复制成本大不大
  • 这个类型里有没有不该被随便复制的状态

更直接一点:

  • 值接收者更像“拿到一个当前值,基于它做只读动作”
  • 指针接收者更像“我要修改这份对象本身”

六、先看一个最小例子:为什么指针接收者能改到原对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package main

import "fmt"

type Task struct {
Name string
Status string
}

func (t Task) Print() {
fmt.Println("print:", t.Name, t.Status)
}

func (t *Task) MarkFailed() {
t.Status = "failed"
}

func main() {
task := Task{Name: "login-smoke", Status: "pending"}

task.Print()
task.MarkFailed()
task.Print()
}

输出:

1
2
print: login-smoke pending
print: login-smoke failed

这里常见的问题是:task 不是值吗,为什么 task.MarkFailed() 还能改到原对象?

答案是:

  • task 是一个可寻址变量
  • 方法接收者是 *Task
  • 编译器在这里帮你做了自动取地址

也就是它等价于:

1
(&task).MarkFailed()

但这个自动取地址不是无条件的,后面讲方法集时会看到它的边界。

七、方法集到底是什么,为什么接口实现经常卡在这里

方法集这个词看起来抽象,但它其实在解决一个很具体的问题:

某个类型,究竟“拥有”哪些方法。

Task 为例:

1
2
3
4
5
6
7
8
9
10
11
12
type Task struct {
Name string
Status string
}

func (t Task) Summary() string {
return t.Name + ":" + t.Status
}

func (t *Task) MarkPassed() {
t.Status = "passed"
}

它的方法集可以这样理解:

  • Task 的方法集只包含 Summary
  • *Task 的方法集包含 SummaryMarkPassed

所以这两个接口的实现结果不同:

1
2
3
4
5
6
7
type Summarizer interface {
Summary() string
}

type TaskUpdater interface {
MarkPassed()
}

下面这段是成立的:

1
2
3
4
5
6
7
task := Task{Name: "login-smoke", Status: "pending"}

var s1 Summarizer = task
var s2 Summarizer = &task

_ = s1
_ = s2

但下面这段只有后一行成立:

1
2
3
4
5
6
7
task := Task{Name: "login-smoke", Status: "pending"}

var u1 TaskUpdater = task
var u2 TaskUpdater = &task

_ = u1
_ = u2

第一行会编译失败,因为 Task 的方法集里没有 MarkPassed

典型报错会像这样:

1
2
cannot use task (variable of type Task) as TaskUpdater value in variable declaration:
Task does not implement TaskUpdater (method MarkPassed has pointer receiver)

这就是为什么代码里常会出现“明明写了方法”,结果接口实现还是报错。
问题不在有没有方法,而在方法集是否匹配接口要求

八、为什么方法调用能自动取地址,接口赋值却不会替你兜底

这是一个非常高频的困惑点。

下面这段调用可以:

1
2
task := Task{Name: "login-smoke", Status: "pending"}
task.MarkPassed()

因为 task 是可寻址变量,编译器可以帮你转成:

1
(&task).MarkPassed()

但接口赋值不行:

1
var updater TaskUpdater = task

这里编译器不会偷偷替你做地址转换。原因很简单:

  • 接口实现是类型系统层面的静态判断
  • 不是一次普通的方法调用语法糖

所以你要显式写成:

1
var updater TaskUpdater = &task

这也是为什么方法集一旦没搞懂,后面写 interface 会反复卡住。

九、再看一个很容易踩坑的错误:为什么 map 元素调指针方法会报错

看这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type Task struct {
Status string
}

func (t *Task) MarkFailed() {
t.Status = "failed"
}

func main() {
taskByID := map[string]Task{
"case-1": {Status: "pending"},
}

taskByID["case-1"].MarkFailed()
}

这会报错。

根本原因不是“map 不支持方法”,而是:

  • MarkFailed 需要 *Task
  • taskByID["case-1"] 取出来的是一个不可直接取地址的 map 元素
  • 编译器没法帮你自动取地址

典型报错类似:

1
2
cannot call pointer method MarkFailed on Task
cannot take the address of taskByID["case-1"]

这个现象非常值得记住,因为它说明:

  • 指针接收者不是随时都能自动帮你补地址
  • 方法能不能调通,和“值是否可寻址”直接有关

更稳的写法有两种:

第一种,先取出来改,再放回去:

1
2
3
task := taskByID["case-1"]
task.MarkFailed()
taskByID["case-1"] = task

第二种,map 里直接存指针:

1
2
3
4
5
taskByID := map[string]*Task{
"case-1": &Task{Status: "pending"},
}

taskByID["case-1"].MarkFailed()

但第二种虽然方便,也意味着共享可变状态更明显,不能无脑上。

十、为什么说别先追 class 语法,而要先追语义边界

这里常见的问题是:

  • Go 里哪一个最像 class
  • 嵌入是不是继承
  • interface 是不是抽象类

这些问题不是完全不能问,但如果一直停在这里,学习效率会非常低。

因为 Go 的核心关注点并不是“复刻经典面向对象语法”,而是:

  • 数据怎么组织
  • 行为挂在哪个类型上最自然
  • 是否需要修改原对象
  • 哪些能力应该通过接口暴露
  • 哪些能力应该通过组合复用

你可以把它理解为:

  • 传统 class 风格,更强调“把对象包装成一个统一实体”
  • Go 风格,更强调“给数据和行为建立清楚边界,再通过小接口和组合协作”

一旦你把注意力从“像不像类”切到“边界清不清楚”,很多设计就顺了。

十一、组合和嵌入到底是什么,为什么它是 Go 的主路径

先看一个最小例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
type Counter struct {
Total int
Passed int
Failed int
}

func (c *Counter) Add(status string) {
c.Total++
switch status {
case "passed":
c.Passed++
case "failed":
c.Failed++
}
}

func (c Counter) PassRate() float64 {
if c.Total == 0 {
return 0
}
return float64(c.Passed) / float64(c.Total)
}

type Suite struct {
Name string
Tasks []Task
Counter
}

这里 Suite 嵌入了 Counter。这意味着:

  • Suite 里实际有一个 Counter 字段
  • 但你访问时可以直接写 suite.Total
  • 也可以直接调用 suite.PassRate()

这叫字段和方法提升。

注意它的本质不是“Suite 继承了 Counter”,而是:

  • Suite 组合了一个 Counter
  • 编译器帮你提供了更方便的访问语法

所以更准确的理解是:

  • 嵌入是组合的一种写法
  • 提升是语法便利
  • 不是类式继承

十二、嵌入不是继承,不要把它当成“父类”

这是非常高频的误判。

比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type AuditFields struct {
CreatedBy string
UpdatedBy string
}

func (a *AuditFields) Touch(user string) {
a.UpdatedBy = user
}

type Task struct {
AuditFields
ID string
Name string
Status string
}

你可以这样写:

1
2
task.Touch("ci-bot")
fmt.Println(task.UpdatedBy)

但这不意味着:

  • TaskAuditFields 的子类
  • AuditFields 可以替代 Task
  • 你应该把所有通用逻辑都塞进一个“基础父类”

真正发生的事情只是:

  • Task 里有一个匿名字段 AuditFields
  • AuditFields 的字段和方法被提升了
  • Task 因而获得了一组可直接访问的能力

这比继承轻得多,也更明确。

十三、一个真实的小项目版本:回归任务执行结果汇总器

下面把前面的内容串成一个完整示例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
package main

import "fmt"

type AuditFields struct {
CreatedBy string
UpdatedBy string
}

func (a *AuditFields) Touch(user string) {
a.UpdatedBy = user
}

type Task struct {
AuditFields
ID string
Name string
Status string
DurationSec int
ErrorMsg string
}

func NewTask(id, name, user string) Task {
return Task{
AuditFields: AuditFields{
CreatedBy: user,
UpdatedBy: user,
},
ID: id,
Name: name,
Status: "pending",
}
}

func (t Task) Summary() string {
if t.ErrorMsg == "" {
return fmt.Sprintf("%s[%s] status=%s duration=%ds", t.Name, t.ID, t.Status, t.DurationSec)
}
return fmt.Sprintf("%s[%s] status=%s duration=%ds err=%s", t.Name, t.ID, t.Status, t.DurationSec, t.ErrorMsg)
}

func (t *Task) MarkPassed(seconds int, user string) {
t.Status = "passed"
t.DurationSec = seconds
t.ErrorMsg = ""
t.Touch(user)
}

func (t *Task) MarkFailed(seconds int, errMsg, user string) {
t.Status = "failed"
t.DurationSec = seconds
t.ErrorMsg = errMsg
t.Touch(user)
}

type Counter struct {
Total int
Passed int
Failed int
}

func (c *Counter) Add(task Task) {
c.Total++
switch task.Status {
case "passed":
c.Passed++
case "failed":
c.Failed++
}
}

func (c Counter) PassRate() float64 {
if c.Total == 0 {
return 0
}
return float64(c.Passed) / float64(c.Total)
}

type Suite struct {
Name string
Tasks []Task
Counter
}

func (s *Suite) AddTask(task Task) {
s.Tasks = append(s.Tasks, task)
s.Counter.Add(task)
}

func (s Suite) Summary() string {
return fmt.Sprintf(
"suite=%s total=%d passed=%d failed=%d rate=%.0f%%",
s.Name,
s.Total,
s.Passed,
s.Failed,
s.PassRate()*100,
)
}

type Summarizer interface {
Summary() string
}

func printSummary(s Summarizer) {
fmt.Println(s.Summary())
}

func main() {
task1 := NewTask("case-001", "login-smoke", "ryan")
task2 := NewTask("case-002", "order-create", "ryan")

task1.MarkPassed(8, "ci-bot")
task2.MarkFailed(15, "assert status code failed", "ci-bot")

suite := Suite{Name: "daily-smoke"}
suite.AddTask(task1)
suite.AddTask(task2)

printSummary(task1)
printSummary(&task2)
printSummary(suite)

fmt.Println("updated by:", task2.UpdatedBy)
fmt.Println("pass rate:", suite.PassRate())
}

这个小例子里,前面讲的概念全部落地了:

  • Taskstruct 稳定表达任务数据
  • Summary 用值接收者,表达只读行为
  • MarkPassedMarkFailed 用指针接收者,表达原地修改
  • Summarizer 只暴露最小能力接口
  • Suite 通过嵌入 Counter 复用统计逻辑

这里没有任何 class 继承树,但工程表达已经足够清楚。

十四、为什么这个设计比硬找“类语法”更重要

如果你执着于 class 风格,往往会把上面的代码写成这种思路:

  • 先造一个大而全的 BaseEntity
  • 然后 TaskEntitySuiteEntity 都想“继承”它
  • 再把统计、审计、输出都往一个大对象里堆

最后的结果通常是:

  • 数据边界不清楚
  • 行为职责互相污染
  • 为了复用而复用,导致耦合越来越高

而上面这个 Go 风格的设计更像是在做这些事:

  • Task 只负责任务状态
  • Counter 只负责统计
  • Suite 负责组织任务并组合统计能力
  • Summarizer 只抽象摘要输出这个最小能力

它关心的不是“有没有父类”,而是“职责是不是被压到了正确的类型里”。

十五、测试示例:把方法集、接收者和组合钉住

这类内容如果只靠肉眼看,很容易“以为懂了”。
更稳的方式是写几个最小测试。

可以新建 main_test.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
package main

import "testing"

type TaskUpdater interface {
MarkFailed(seconds int, errMsg, user string)
}

func TestTaskImplementsSummarizer(t *testing.T) {
task := NewTask("case-001", "login-smoke", "ryan")

var _ Summarizer = task
var _ Summarizer = &task
}

func TestOnlyPointerImplementsTaskUpdater(t *testing.T) {
task := NewTask("case-001", "login-smoke", "ryan")

var updater TaskUpdater = &task
updater.MarkFailed(9, "timeout", "ci-bot")

if task.Status != "failed" {
t.Fatalf("expect failed, got %s", task.Status)
}

if task.UpdatedBy != "ci-bot" {
t.Fatalf("expect ci-bot, got %s", task.UpdatedBy)
}
}

func TestSuiteEmbeddingCounter(t *testing.T) {
suite := Suite{Name: "daily-smoke"}

task1 := NewTask("case-001", "login-smoke", "ryan")
task1.MarkPassed(8, "ci-bot")

task2 := NewTask("case-002", "order-create", "ryan")
task2.MarkFailed(15, "assert status code failed", "ci-bot")

suite.AddTask(task1)
suite.AddTask(task2)

if suite.Total != 2 {
t.Fatalf("expect total 2, got %d", suite.Total)
}

if suite.Passed != 1 {
t.Fatalf("expect passed 1, got %d", suite.Passed)
}

if suite.Failed != 1 {
t.Fatalf("expect failed 1, got %d", suite.Failed)
}

if suite.PassRate() != 0.5 {
t.Fatalf("expect pass rate 0.5, got %v", suite.PassRate())
}
}

这里有个很重要的点:

1
var updater TaskUpdater = &task

如果你把它写成:

1
var updater TaskUpdater = task

测试文件会直接编译失败。
这比背定义更能把方法集刻进脑子里。

十六、怎么验证这段代码不是“看起来对”

建议至少做三步验证。

第一步,直接运行主程序:

1
go run main.go

你应该能看到任务摘要、任务集摘要和通过率输出。

第二步,跑最小测试:

1
go test ./...

第三步,故意做几次“错误修改”验证理解是否稳定:

  • MarkFailed 改成值接收者,观察状态为什么不再改到原任务
  • var updater TaskUpdater = &task 改成 task,观察编译错误
  • Suite 里的匿名字段改成具名字段 Counter Counter,再看调用方式怎么变化

如果这三种变化你都能解释清楚,说明这篇的核心已经掌握了。

十七、一个高频排错场景:为什么我明明能调用方法,却实现不了接口

这是非常常见的现场问题。

你有这样的代码:

1
2
task := NewTask("case-001", "login-smoke", "ryan")
task.MarkPassed(8, "ci-bot")

它跑得很好,于是你很自然地写:

1
2
3
4
5
type Runner interface {
MarkPassed(seconds int, user string)
}

var r Runner = task

结果编译报错。

这时最容易出现的误判是:“Go 的接口设计怎么这么奇怪?”

其实问题很具体:

  • 方法调用阶段,编译器可以对可寻址变量自动取地址
  • 接口实现判断阶段,不会自动补这层转换

排查这类问题时,直接按下面顺序看:

  1. 接口要求的是哪些方法
  2. 这些方法是值接收者还是指针接收者
  3. 你塞进接口的是 T 还是 *T
  4. 当前值是否只是一次普通调用,还是在做接口赋值

只要按这个顺序过一遍,基本不会迷路。

十八、几个高频误用,最好现在就避开

1. 只因为“统一”就把所有方法都写成值接收者

这会导致修改根本落不到原对象上,尤其是状态更新类方法。

2. 只因为“方便”就把所有方法都写成指针接收者

这也不好。
如果一个类型绝大多数方法只是读数据,全部强行用指针,会让语义变糊。

3. 把嵌入当继承,然后开始堆“基础父 struct”

一旦你这么做,代码通常会越来越像你本来熟悉的 class 体系,但会失去 Go 里更直接的边界感。

4. 忽略值接收者里的“内部可变字段”

比如:

1
2
3
4
5
6
7
type Task struct {
Labels []string
}

func (t Task) AddLabel(label string) {
t.Labels = append(t.Labels, label)
}

看到值接收者时,很容易以为“不会影响外面”。

其实未必。
因为 Labels 是 slice,背后还有共享底层数组和扩容语义。
这个坑和上一篇值语义、slice 的内容是连着的。

所以接收者选择不能孤立看,得和字段类型一起看。

5. 在含有锁、连接、缓冲等状态的 struct 上随意复制

这篇不展开并发细节,但工程上要有这个意识:

  • 有些 struct 不适合复制
  • 一旦复制,语义和状态一致性都可能出问题

这时通常就应该明确使用指针接收者,并避免随意按值传递。

十九、工程判断:什么时候用值接收者,什么时候用指针接收者,什么时候用组合

可以先按下面这套规则判断。

值接收者更适合:

  • 只读方法
  • 小而轻的值类型
  • 明确要表达“基于当前值计算,不修改原对象”

指针接收者更适合:

  • 需要修改接收者内部状态
  • struct 比较大,复制成本不划算
  • 类型内部包含不该被随便复制的状态
  • 希望 *T 明确实现某组变更型接口

组合和嵌入更适合:

  • 你要复用一段能力,而不是建立“is-a”层级
  • 你想把统计、审计、日志等横向能力挂到多个类型上
  • 你希望每块职责都能独立测试、独立替换

如果一个判断标准要再压缩成一句话,就是:

  • “读”优先想值接收者
  • “改”优先想指针接收者
  • “复用”优先想组合

二十、这一篇的边界在哪里

这篇先不展开这些内容:

  • interface 的完整设计原则
  • 嵌入 interface 时的行为细节
  • nil 接口和值为 nil 的具体区别
  • 并发场景下含锁 struct 的复制风险细节
  • 逃逸分析和接收者性能优化

这些内容后面都会继续接上。
这一篇先把最关键的主线压实:

  • struct
  • 方法
  • 方法集
  • 接收者
  • 组合

二十一、一个实际练习

你可以自己做一个“任务执行告警器”,要求:

  1. 定义 Alert struct,保存任务 ID、级别、消息
  2. Alert 加一个值接收者方法 Summary() string
  3. Alert 加一个指针接收者方法 Upgrade(level string)
  4. 定义 AlertCounter,统计不同级别数量
  5. 定义 AlertBoard,通过嵌入 AlertCounter 管理多条告警
  6. 再定义一个只包含 Summary() string 的接口,让 AlertAlertBoard 都能实现

如果你能自己把这个练习写通,并解释:

  • 为什么 Alert 能实现摘要接口
  • 为什么只有 *Alert 能实现升级接口
  • 为什么 AlertBoard 可以直接调用嵌入统计器的方法

那这篇的关键内容就真正过关了。

二十二、结语

学 Go 时,struct、方法、方法集和组合看起来像一组零散语法点,实际上它们是一条完整的工程主线。

真正重要的不是“Go 有没有 class 的替代品”,而是你能不能把这些事情做清楚:

  • 数据形状有没有先定义稳定
  • 行为是不是挂在了正确的类型上
  • 读写语义有没有通过接收者表达清楚
  • 接口实现有没有和方法集对齐
  • 复用是不是通过组合完成,而不是靠想象中的继承

一旦这条线通了,就会发现 Go 的设计并不“缺少面向对象能力”,它只是把重点从语法外壳,挪到了更直接的边界组织上。

这也是为什么在 Go 里,struct、方法集和组合,往往比追着面向对象语法更重要。