本篇要回答的四个问题:
① 为什么过程式的核心恰好是结构体和过程这两样,不多不少?
② 为什么"变量不可变"这么一条看似温和的规定,能把大学教的数据结构整个作废?
③ 继承明明很好用,凭什么被判为"过度设计"?
④ "面向连接"这个听起来很玄的词,到底在说什么?
原讲这两节极度压缩,几乎每句话背后都省掉了一层推理。下面按原文顺序补回来。
贯穿全篇的一个词是「契约」,原讲反复用却从没定义过。先钉死它,后面全通:
契约 = 一份边界声明:我保证对外提供什么,你只准依赖这些,界内的事我随时可以改。
面向对象、接口、代码规范、channel,这一讲讲的全部东西,都是"在某条边界上立一份契约"。区别只在于边界画在哪。
第一部分 · 过程式
一、它不是被发明的,是机器本来的样子
原文:过程式就是以一条条命令的方式,让计算机按我们的意愿来执行。今天计算机的机器语言本身就是一条条指令构成,本身也是过程式的。
CPU 干活的方式就是「取一条指令 → 执行 → 取下一条」。所以过程式不是某个人的设计主张,它是硬件的形状,高级语言只是把这堆指令写得像人话。
这解释了原文那句"每个语言都有一定过程式的影子"——只要最终要在冯·诺依曼机器上跑,就绕不开它。别的范式都是在这个底子上加约束、加抽象,没有谁能真的替换掉它。参见 02 讲 的 CPU + 存储 + IO 骨架。
二、为什么核心恰好是「结构体 + 过程」
原文:过程式编程中最核心的两个概念是结构体(自定义的类型)和过程(也叫函数)。
关键在于它们是你唯一能扩充语言本身的两个口子。
if / while / 赋值 / 算术运算符,都是语言写死给你的,你没法定义一个新的控制结构。但你可以定义一个语言里原本不存在的类型 Matrix,也可以定义一个原本不存在的指令 matmul()。所以说是"最核心",不是修辞。
为什么是两个,不是一个也不是三个
因为机器上只有两种东西:存着的数据、对数据的操作。
语言给的原语是一小撮固定的:数据侧 int/float/char/指针,操作侧加减乘除、比较、跳转。这点东西离你要表达的问题差着十万八千里——你想说的是"这个多边形的周长",机器能听懂的只有"从地址 0x7ff… 取 8 字节做浮点减法"。
结构体在名词方向上补齐这段距离,过程在动词方向上补齐。两条腿,缺一条都走不动。
两者其实是同一个动作
都是:打包 + 命名 → 得到一个新原语。
struct Point { double x, y; }; // 打包两个 double,叫它"点"
struct Segment { Point a, b; }; // 打包两个 Point,叫它"线段"
struct Polygon { Segment edges[MAX]; int n; }; // 再打包,叫它"多边形"
double dist(Point a, Point b) { // 打包几条算术,叫它"距离"
return sqrt(pow(a.x-b.x, 2) + pow(a.y-b.y, 2));
}
double perimeter(Polygon p) { // 打包若干次 dist,叫它"周长"
double s = 0;
for (int i = 0; i < p.n; i++) s += dist(p.edges[i].a, p.edges[i].b);
return s;
}
"任意复杂"的来源不是结构体能装很多字段,而是这个动作可以无限次施加在自己的产物上。
Point 一旦定义好,就跟 int 一样能当字段、当参数、当返回值;dist 一旦定义好,就跟 + 一样能被别的函数直接调用——打包出来的东西和原料是同一级公民(数学上叫封闭性)。一层一层往上垒,每层把下一层的细节封死,垒到第 20 层时你写的已经是"结算这笔订单"而不是"取 8 字节做浮点减法"了。
原文后半句的两个好处,分别在省什么
| 原文说法 | 省的是谁的功夫 |
|---|---|
| 复用以前的成果 | 省重新做。perimeter 里那句 dist(...),不用第二次去想勾股定理、不用管 sqrt 会不会溢出 |
| 简化意图的表达 | 省读的人重新理解——这条更值钱 |
perimeter(p) 字面上写的就是"求周长"这个意图本身;把 dist 展开内联进去,程序干的事没变,但"我想干嘛"这层意思被淹在算术细节里了。
抽象的本质,是在代码里恢复出人脑思考问题时的层级。
机器只有一层(全是指令),人脑有很多层,结构体和过程是把这些层重新架回去的唯一工具。
第二部分 · 函数式
三、不是另一种写法,是给过程式上锁
原文:函数式本质上是过程式编程的一种约束。
这句是理解这一段的钥匙。注意是"约束"不是"新东西":过程式做加法(给你两个工具去造),函数式做减法(禁止你做某些事)。它没给你任何新能力,只划了两条禁令。
禁令一:变量不可变
let x = 5;
x = x + 1; // 过程式允许。x 现在是 6,那个"5"没了
// 函数式不允许。x 一旦是 5,永远是 5
这条其实很符合直觉——回想中学数学,x = x + 1 是个无解方程。
| 含义 | |
|---|---|
数学 / 函数式的 x = 5 |
一句断言:x 就是 5 |
过程式的 x = 5 |
一次赋值:把 5 塞进 x 这个内存盒子 |
**这是两个完全不同的东西,只是碰巧都叫"变量"。**函数式里的变量是数学意义上的变量,过程式里的变量是内存盒子的别名。
禁令二:函数没有副作用
副作用 = 函数除了返回值以外,对外部世界造成的任何可观察的影响。
let total = 0;
function add(n) { total += n; return total; } // 有副作用:偷偷改了外面的 total
function add(a, b) { return a + b; } // 无副作用(纯函数)
纯函数的性质:同样的输入永远得到同样的输出,且不留痕迹。所以可以直接把 add(2,3) 这个调用替换成 5,程序行为完全不变(引用透明)。
原文括号里"内部有 IO 行为的函数就有副作用"是说:print()、读文件、发网络请求——这些函数存在的全部意义就是改变外部世界。一个彻底无副作用的程序跑完之后屏幕上什么都不会出现,等于没跑。所以纯函数式语言必须专门设计机制(Haskell 的 IO Monad)把这些脏活隔离在一个角落。
为什么"犯错机会更少"
因为最难缠的 bug 几乎全出在共享的可变状态上:
- 我把数组传给你的函数,你偷偷改了,我不知道,后面炸了
- 两个线程同时改一个变量,结果取决于谁先跑到(竞态,见 12 讲)
- 调试时得在脑子里模拟"执行到第 40 行时 x 是多少",因为 x 的值取决于时间
不可变 + 无副作用,等于把「时间」这个维度从程序里删掉了。
一个值就是一个值,不存在"它现在是几"。看输入就能推出输出,不用管上下文。
四、数组那个例子(原讲最难懂的一段)
原文:对某个下标的数组元素的修改,就需要复制整个数组,非常低效。
问题在哪:数组是一整块连续内存
a = [1, 2, 3, 4, 5]
a[0] = 99 // 过程式:原地改,O(1),快得不能再快
函数式说 a 不可变,那只能造个新的:
b = set(a, 0, 99) // b = [99,2,3,4,5],而 a 依然是 [1,2,3,4,5]
b 必须是一块全新的连续内存,因为 a 那块不能动。所以哪怕只改一个元素,也得把全部 n 个元素复制一遍——O(1) 变成 O(n)。一百万个元素改一百万次,就是一万亿次拷贝,直接废掉。
怎么救:换个能让新旧版本共享内存的结构
数组救不了(连续内存没法部分共享),但树可以。把元素挂在树的叶子上:
v1 v2 = set(v1, 0, 99)
[root] [root'] ← 新建
/ \ / \
[A] [B] [A'] \ ← A' 新建
/ \ / \ / \ \
1 2 3 4 99 2 ↓
[B] ← 直接复用 v1 的 B
/ \
3 4
改一个叶子,只需新建从根到该叶子那条路径上的节点(root'、A'),其他子树连碰都不用碰,新树直接指向旧树的节点。这叫结构共享(structural sharing)。
代价从 O(n) 降到 O(log n),且 v1 和 v2 同时有效、各自完好、互不干扰——这正是不可变要的效果。
原文说"平衡二叉树"是简化说法。实际实现多用高分叉的树(Clojure 的 vector 是 32 叉 trie),
一百万个元素深度才 4 层,改一次只复制 4 个小节点,实践中快到近似常数。
"大学里学的数据结构不顶用了"是什么意思
| 定义 | 谁教 | |
|---|---|---|
| ephemeral(易失性) | 改完之后旧版本就没了,默认可以原地改内存 | 大学数据结构课教的全部 |
| persistent(持久性) | 每次"修改"产出一个新版本,所有历史版本继续有效 | 函数式需要的 |
链表、数组、哈希表、红黑树,全都要在"不许改内存"的前提下重新想一遍。
**不是学得不够,是前提变了,整套方法论要重来。**经典教材:Chris Okasaki《Purely Functional Data Structures》。
五、不可变思想在现实里怎么用
原文说"大部分语言难以彻底实施,但思想上有所借鉴"——这个借鉴其实天天在用:
| 语言/框架 | 借鉴了什么 |
|---|---|
| JS | const、map/filter/reduce(返回新数组,不改原数组) |
| React | state 必须换新对象,不能原地改(改了检测不到) |
| Rust | 变量默认不可变,要改得显式写 mut |
| Go | 结构体默认值传递(传的是副本) |
实践共识:默认不可变,只在真的需要性能、或真的要做 IO 的地方开口子。
既拿到"少犯错"的大部分收益,又不用重修数据结构。
第三部分 · 面向对象
六、它在过程式上到底加了什么
过程式里,数据和过程的关系是松散的:
struct Polygon p;
perimeter(p); // perimeter 是个全局函数
编译器并不知道 perimeter 跟 Polygon 有什么关系。带来两个实际麻烦:拿到一个 Polygon,你不知道能对它干什么(得翻文档或全局 grep);而且所有函数挤在一个全局命名空间里打架——C 里满世界的 png_read_xxx、zip_open_xxx 前缀,就是在手工模拟命名空间。
面向对象把方法绑到类型上:p.perimeter()。原文那两个优点其实是同一件事的两面——
| 原文的优点 | 实质 | 说人话 |
|---|---|---|
| 清晰的使用界面 | 契约可见 | 打个点,IDE 列出这东西能干的所有事 |
| 信息的封装 | 契约划界 | 界内随便改,界外我保证不变 |
第二条为什么重要,原文那句是关键:“有一天对象的内部实现方式改变了,依赖该对象的相关代码也需要跟着调整。”
封装不是为了"保密",是为了给自己留下修改的自由。
一旦别人依赖了你的内部字段,你就永远改不动它了。
七、接口与多态:过程式"很费劲"到底费在哪
原文:通过接口,我们可以优雅地实现过程式编程中很费劲才能做到的一个能力:多态。
多态 = 同一段代码,作用在不同类型上,各自跑出各自的行为。
过程式不是做不到,是得手搓。Linux 内核的 file_operations 就是这么干的:
// 过程式里做多态:手写一张函数指针表(vtable)
typedef struct {
void (*move)(void *self);
void (*eat)(void *self);
} AnimalOps;
typedef struct { AnimalOps *ops; /* 鸟的字段 */ } Bird;
typedef struct { AnimalOps *ops; /* 猪的字段 */ } Pig;
void daily(void *a) {
((Bird*)a)->ops->move(a); // 强转 + void*,编译器一点忙帮不上
}
费劲在哪:这张表得你自己建、自己填、自己保证填对。void* 一路裸奔,填错一个槽位就是运行时崩溃,编译器全程沉默。
type Animal interface { Move(); Eat() }
func daily(a Animal) { a.Move(); a.Eat() } // 编译器检查,写错编译不过
面向对象干的事,是把这个手工模式变成了语言特性——vtable 还在,只是编译器替你建、替你查。
八、继承为什么被点名为「过度设计」
原文给的理由是心智负担:“本来复合对象的唯一构造方法是组合,现在多了一个选择”——多一个选择就多一次纠结,而这次纠结没有标准答案。
但还有一条原文没展开、杀伤力更大的理由:继承破坏封装,而封装正是面向对象自己的第二条优点。
看这段 Java(《Effective Java》的经典例子):
class CountingSet extends HashSet<String> {
int count = 0;
public boolean add(String s) { count++; return super.add(s); }
public boolean addAll(Collection<String> c) { count += c.size(); return super.addAll(c); }
}
var s = new CountingSet();
s.addAll(List.of("a", "b", "c"));
// count == 6,不是 3
为什么是 6?因为 HashSet.addAll 内部自己循环调了 add。这件事没写在任何文档里,是父类的内部实现细节。
于是矛盾出现了:
要正确地继承一个类,你必须了解它的内部实现——这恰恰是面向对象明令禁止的事。
extends 表面上是"用接口",实际上是把自己焊死在父类的实现细节上。父类哪天改了内部调用顺序,你全部子类静悄悄地错掉。这就叫脆弱基类问题。
组合就没这毛病,因为边界是真的:
type CountingSet struct {
set map[string]bool // 持有它,不是变成它
count int
}
func (c *CountingSet) Add(s string) { c.count++; c.set[s] = true }
set 内部怎么实现,与我无关;我只用它的公开契约。
继承是 is-a,组合是 has-a;is-a 会传染实现,has-a 不会。
第四部分 · 面向连接
九、一句话:把契约从"对象之间"推广到"一切边界"
面向对象说:代码单元之间要有契约。
面向连接说:不止代码单元,凡是有"连接"的地方,都要有契约。
原文那句 “并不是只有对象需要契约,语言设计的方方面面都需要契约” 就是全节论点。面向对象只管住了一层边界,Go 说至少有三层:
| 连接的是谁 | 契约是什么 | Go 的具体做法 |
|---|---|---|
| 代码 ↔ 代码 | 接口、组合 | 隐式接口 + 嵌入,放弃继承 |
| 人 ↔ 人 | 代码规范 | gofmt 强制统一格式;语言里干脆不给三元运算符、默认参数、宏;未使用的变量直接编译报错 |
| 并发单元 ↔ 并发单元 | 消息传递 | 内建 channel,且类型安全 |
第二行最反直觉:代码规范凭什么算"语言设计"
原文的逻辑是:同一门语言里大家写法各异,语言就长出了方言,人与人之间读不懂对方——这也是一种连接失败。
所以 Go 的做法不是"提倡好风格",而是从语言里把最容易吵架的地方直接删掉。没有三元运算符、没有函数重载、没有默认参数、花括号位置不给选——没得选,就没得吵。这不是设计者偷懒,是把风格争议提前消灭在语法层。
第三行的原理接上了 12 讲
见 12 讲:共享内存 + 锁,等于没有契约的连接——谁都能碰那块内存,全靠程序员自觉记得加锁。channel 把它变成有契约的连接:数据只能从这根管子过,类型编译期就查。Go 那句口号说的正是这件事:
Don’t communicate by sharing memory; share memory by communicating.
十、最能说明问题的一处设计:隐式接口(原讲没提)
// Java:实现方必须提前知道有这个接口
class Dog implements Animal { ... }
// Go:只要方法签名对得上,就自动满足,不需要写 implements
type Closer interface{ Close() error }
// os.File 自动满足 Closer —— 而 os.File 的作者根本不知道你的 Closer 存在
差别在于契约由谁定义:
| 谁定义契约 | 时机 | |
|---|---|---|
| 面向对象 | 提供方预先声明 | 连接之前就得约好 |
| 面向连接 | 使用方在连接处定义 | 事后也能约,为早已写好的类型定义接口 |
这就是原文那句 “研究代码与代码之间怎么组合” 的落地——组合的规则写在连接点上,而不是写在任何一端里面。所以你能把两个互不知情、来自不同库的类型接在一起;这在 Java 里得写一层适配器。
一句话速记
过程式给你两个造新原语的口子(结构体造名词、过程造动词),靠"打包+命名+可再打包"垒出任意复杂度;
函数式不给新口子,只上两把锁(值不可变、函数无副作用),代价是连数据结构都得重造,收益是程序里没有"时间";
面向对象的贡献是发现了契约,把它立在"对象"这条边界上,顺手用接口解决了多态,但同时塞进来一个自我打脸的继承;
面向连接不是新东西,是把契约从对象之间推广到一切边界——代码之间、人之间、并发单元之间——并且砍掉继承,只留组合。
一条暗线贯穿四者:范式演进的方向,是不断把"原本靠程序员自觉"的东西,变成"语言强制或编译器可查"的契约。
存疑
一、原讲把"复用"和"简化意图表达"并列,实际权重差很多。
表达意图那条重要得多——复用只省一次力气,表达清晰是每次阅读都在省。
二、抽象是有代价的:错的抽象比重复更糟。
过早造一个只用一次的结构体或函数,只是给读的人凭空加一层要跳转的目录。这两个工具强在能垒,也坑在能乱垒。
三、"面向连接"作为范式名,营销成分不小。
范式一般指约束你怎么组织计算(过程式=指令流、函数式=不可变)。而 gofmt、无三元运算符这些是工程实践和语言取舍,跟"计算怎么组织"不在一个层面上。把它们打包叫"范式",更像 Go 在给自己的设计哲学找一个统一叙事。
不过底下那句判断站得住:继承确实是面向对象里被过度推销的一环,Java 界自己也喊了二十年 “favor composition over inheritance”。这点原讲没说错,只是这个共识不是 Go 首创的——Go 是第一个在语言层面直接不给你继承的主流语言。
