加载中...

本篇要回答的四个问题
① 为什么过程式的核心恰好是结构体和过程这两样,不多不少?
② 为什么"变量不可变"这么一条看似温和的规定,能把大学教的数据结构整个作废?
③ 继承明明很好用,凭什么被判为"过度设计"?
④ "面向连接"这个听起来很玄的词,到底在说什么?

原讲这两节极度压缩,几乎每句话背后都省掉了一层推理。下面按原文顺序补回来。

贯穿全篇的一个词是「契约」,原讲反复用却从没定义过。先钉死它,后面全通:

契约 = 一份边界声明:我保证对外提供什么,你只准依赖这些,界内的事我随时可以改。

面向对象、接口、代码规范、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 constmap/filter/reduce(返回新数组,不改原数组)
React state 必须换新对象,不能原地改(改了检测不到)
Rust 变量默认不可变,要改得显式写 mut
Go 结构体默认值传递(传的是副本)

实践共识:默认不可变,只在真的需要性能、或真的要做 IO 的地方开口子。
既拿到"少犯错"的大部分收益,又不用重修数据结构。


第三部分 · 面向对象

六、它在过程式上到底加了什么

过程式里,数据和过程的关系是松散的

struct Polygon p;
perimeter(p);          // perimeter 是个全局函数

编译器并不知道 perimeterPolygon 有什么关系。带来两个实际麻烦:拿到一个 Polygon,你不知道能对它干什么(得翻文档或全局 grep);而且所有函数挤在一个全局命名空间里打架——C 里满世界的 png_read_xxxzip_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 是第一个在语言层面直接不给你继承的主流语言

公告栏
这是我的个人知识库。
记录技术,也记录生活 —— 读过的、试过的、想明白的,都堆在这儿。
最新文章
网站资讯
文章数目 :
5
已运行时间 :
本站总字数 :
15.7k
本站访客数 :
本站总访问量 :
最后更新时间 :
全局知识图谱
当前页面 已访问 文章 标签
ESC 关闭 · 滚轮缩放 · 拖拽移动 · Ctrl+G 开关