换一种表示:架构第一课
同一道题,换一种表示,十行代码就能写完;换不动的,往往是你脑子里的那幅图。
- 几行代码的小程序为什么也有架构,边界靠什么兑现;
- 汉诺塔的三种写法:找数学规律、用任务清单、模拟函数调用;
- 一个二维码程序为什么必须先造一个中间表示,生成时与运行时怎么分开;
- 本质复杂性与附带复杂性,以及 Dijkstra 说的「尊重自己的小脑袋」;
- 珍珠项链比喻,与「好架构设计的是一片解空间」。
一、几行代码的小程序也有架构
软件架构(software architecture)说的是:怎样分解问题、怎样安排职责、谁可以依赖谁。它让一些事情变容易,也让另一些事情在将来变得很贵。不必等到有数据库、有微服务、有消息队列才开始谈架构——一个几十行的小程序同样有它的架构。
最朴素的架构是四段:读输入、解析、处理、输出。这四段一旦成为明确的边界,就有几件事可以分开想:换一种输入格式,要改哪里?核心算法要不要知道结果写到终端还是写进文件?出错时该由谁负责报告?
学编程语言时,每一种构件都跟架构有关:函数帮我们表达边界,类型帮我们约束表示,作用域限制信息可见的范围,而函数调用过程中保存未完成工作的那份记录,让递归得以成立。只把这些当成语法细节去背,很容易错过真正有意思的问题——这些机制怎样帮我们掌控一个程序。
汉诺塔这道题把这个问题问到了极致:不用递归,怎么写?
二、汉诺塔的三种写法
规则大家都熟:一叠盘子从 A 柱搬到 C 柱,借助 B 柱,每次只能搬最上面一个,大盘不能压在小盘上。递归解法三句话就完了:先把上面 n−1 个搬到辅助柱,再搬最大的那个,最后把那 n−1 个搬回来。要求「不准用递归」时,很多人当场卡住。
写法一:直接找移动序列的数学规律
把盘子从小到大编号 1 到 n,把最少步数解法里的步骤从 1 开始编号。第 step 步该移动哪个盘子?答案是 step 的二进制表示末尾连续零的个数加一。三层汉诺塔步号从 1 到 7,对应盘号恰好是 1, 2, 1, 3, 1, 2, 1。
每个盘子的移动方向也是固定的:它沿 A、B、C 构成的环走,方向由总盘数与盘号的奇偶关系决定。于是只需要记住每个盘子当前在哪根柱子上,就能一步步推下去:
for (uint64_t step = 1; step <= total; step++) {
int disk = 1;
for (uint64_t x = step; (x & 1) == 0; x >>= 1) {
disk++;
}
int direction = (n - disk) % 2 == 0 ? -1 : 1;
int from = position[disk];
int to = (from + direction + 3) % 3;
printf("Move disk %d: %c -> %c\n", disk, pegs[from], pegs[to]);
position[disk] = to;
}
这段代码里的变量要先准备好:A、B、C 编成 0、1、2,pegs 保存这三个字符,position 初始全为 0;盘数限定 1 到 63,总步数用 uint64_t total = (UINT64_C(1) << n) - 1 算出来。
代码之所以这么短,是因为对问题的理解承担了原本要写在代码里的复杂度。代价也很直接:那些规律得解释、得验证;游戏规则一改,原来的数学结构未必还能用。而且代码短不等于问题小,指数级的移动次数一步也没少。
写法二:把任务放进后进先出的清单
第二种看法换个角度:不再关心第几步该动哪个盘子,改为保存「还没有做完的任务」。一个任务记录四样东西:盘数、起点柱、终点柱、辅助柱。只剩一个盘子时直接搬;否则拆成三个子任务:把上面的盘子搬到辅助柱,搬最大的那个,把辅助柱上的盘子搬到终点。
关键在于用什么东西保存这些任务。用后进先出的清单时,后放进去的先被取出,所以放入顺序必须与执行顺序相反。这样得到一个不断「取出、分解、执行」的循环:递归所表达的任务结构原样保留,只是那些等待执行的工作由我们显式存起来了。这种写法不依赖步号规律,只需要把熟悉的递归分解说清楚。
写法三:模拟函数调用的执行过程
第三种看法更进一步:直接把函数调用本身模拟出来。每一次调用用一份调用记录表示,保存 n、三根柱子分别扮演什么角色,以及一个程序计数器 pc,表示「这层函数执行到了第几步」。遇到调用,就把新记录压进清单;遇到返回,就取出最上面那份;重新轮到上一层时,按它保存的 pc 接着往下走。
走到这一步,我们其实造了一台很小的抽象机器:函数调用和返回都变成了状态转移。「非递归汉诺塔」也就成了理解程序执行语义的机会,同样一套办法能解释更一般的递归过程。
flowchart TB P["同一个问题 汉诺塔"] --> S1["数学规律 步号与盘号的对应 方向由奇偶决定"] P --> S2["任务清单 取出 分解 执行 后进先出"] P --> S3["执行机器 调用记录加程序计数器 模拟调用与返回"] S1 --> C["复杂性 指数级移动次数 一步没少"] S2 --> C S3 --> C
三、二维码程序:为什么不先造一个中间表示
汉诺塔只是开始。看一个真实一点的需求:在 Excel 里输入一串字符,由工作表公式算出并显示二维码,输入改了二维码跟着变。
直接让 AI 把所有公式写出来,很快会撞上长公式的脆弱性:括号、单元格引用、数组运算、算法步骤全缠在一起,光是让括号配对就能耗掉大半天。就算公式终于能跑,下一次需求变化照样痛苦:要兼容更老的 Excel,或者用上新版本的能力;要把二维码挪到工作表另一个位置;要换一种计算方法;要多加一个输入单元格。每一件看起来局部的事,都可能穿过整片公式。
跳出公式再看,二维码算法本身用普通编程语言描述并不难,难的是把它映射到工作表这个奇怪的计算环境里。真正需要的东西浮出水面:一个编译器。用一种便于表达算法的形式描述计算,再把它翻译到目标环境。
一种可行的设计是引入中间表示(Intermediate Representation,缩写 IR):它保存输入、常量、异或、查表、条件选择这些计算节点,节点之间的边记录数据依赖。前端用高级语言构造这张计算图,后端负责把它翻译成 Python 程序或者 Excel 公式。
生成时与运行时:一个必须分清的界线
造中间表示的时候,有一道界线必须划清,划错了整个程序就白做。
- 生成时:构造中间表示并翻译成 Python 或 Excel 公式的那个阶段。此时可以按选定的容量,把二维码计算所需的有限步骤全部展开;与输入无关的部分也能先算好,这叫常量折叠。
- 运行时:用户在 Excel 里输入字符串、触发重算的阶段。用户输入必须保留成符号输入,由内容决定的分支必须变成图里的条件选择节点,留给 Excel 在重算时判断。
界线一旦划错,后果很具体:生成器提前算出了一张固定的二维码,换个输入它一动不动,看着像模像样,需求根本没实现。
有了中间表示,各类变化都找到了自己的落点:算法只知道有一个叫 text 的输入,至于它在哪个单元格,由输入绑定模块决定;布局模块分配中间结果和最终图案的位置,生成公式时再解析地址;兼容不同 Excel 版本,由目标能力描述和公式生成部分处理;增加一个输入,则先明确它怎样参与拼接、编码和容量计算,再改相应的输入与算法构造。
中间表示自己也需要语义:字节的取值范围、整数除法怎么算、字符串用什么编码、数组是什么形状,这些不能交给两个后端各自猜。构图时就能检查类型和容量,节点还可以记下来源,出错时能追回是哪一步算错了;括号和单元格引用这些琐事,由生成器统一处理。性能优化也有了明确的位置:重复的纯计算共享同一个节点,避免同一段长公式到处粘贴;真正生成工作表时,再决定哪些计算内联、哪些中间结果放到辅助单元格。
两个后端还顺带提供了一种验证办法:给定相同输入,比较 Python 的结果与目标 Excel 实际重算的结果,必要时逐个中间值对比。这里要留一个心眼:两个后端可能共享同一个算法错误,所以还得靠独立实现或者二维码解码器去核验内容。这套设计的价值在于,一次需求变化可以主要落在负责它的那一个模块里。
四、本质复杂性与附带复杂性
从意图到规格再到实现,没有一条唯一的翻译路径。「让学生方便地选课」可以对应先到先得、志愿排序、抽签几种不同的规格;就算定了其中一种,还有一堆数据结构、模块划分、交互方式可选。每一步都在做决定,每个没被确认的决定都可能让结果偏离原来的意图。
软件工程做的事,就是驾驭这些不断长出来的复杂性。其中一部分来自问题本身,叫本质复杂性(essential complexity):一门课有容量上限,学生要满足先修条件,两门课时间不能冲突,转专业后还要认定已经修过的课程——这些事即使完全不用计算机、改由教务员拿纸笔办,一样存在。
另一部分是实现方式额外带来的,叫附带复杂性(accidental complexity):同一个学生的信息存了五份,改一份就要同步另外四份;几个模块用了不同的日期格式,连起来时不断转换;一套规则被复制到好几个页面,改动时总会漏掉一处。这些麻烦来自我们组织软件的方式,客户的工作本身没有提出这种要求。
| 对比项 | 本质复杂性 | 附带复杂性 |
|---|---|---|
| 来源 | 问题领域本身 | 我们组织软件的方式 |
| 典型例子 | 容量上限、先修条件、时间冲突、转专业学分认定 | 同一条信息存五份、日期格式不统一、规则复制到多处 |
| 该拿它怎么办 | 分解、组织、让它在某处集中 | 减少,甚至消除 |
| 能不能靠分模块消掉 | 不能,问题还在 | 能,前提是边界真的成立 |
需求工程要先面对本质复杂性,把意图空间里的不确定性收窄。说「课程容量为 100 人」还不够:候补学生算不算?跨院预留名额能不能借用?特殊批准能不能突破上限?把这些条件补上,实现者自己猜的空间就少了。可需求并非越长越好——每写下一条承诺,就多一条要检查、要维护、要在需求变化时重新审视的约束;到了生成式软件工程里,这些约束还会变成上下文长度、token 开销,以及模型在复杂约束下出错的机会。
架构设计承担另一项工作:分解本质复杂性,减少附带复杂性。分模块没法让先修要求凭空消失,却可以把资格、容量、时间冲突分别建模,让学生选课、管理员补选、批量导入三个入口都调用同一份规则。
五、尊重自己的小脑袋
Dahl、Dijkstra 和 Hoare 合写的《Structured Programming》里讨论的许多程序,今天看都很小,可它们面对的问题一点没过时:人能同时精确理解的东西非常有限,怎样让这点有限的能力足以构造并检查一个大程序?
Dijkstra 在书的第一部分里直说:自己的脑袋很小,最好学会尊重这个限制。程序规模变大,不只是把同样的代码多写几遍——能在脑子里同时追踪十个状态,不意味着能用同样的办法追踪一万个状态。接着他讨论了三种思维辅助:
- 枚举,帮我们检查有限的分支,一个不漏;
- 归纳,帮我们理解循环与递归,不必展开每一轮;
- 抽象,使用一个操作时只依赖它承诺的效果,不必每次都把内部过程展开一遍。
书里有个很有名的比喻,叫珍珠项链:把程序看成一串相互衔接的抽象层,每一颗珍珠承担一项设计决定,上层使用下层提供的概念。两种实现如果提供相同的接口,就有机会替换其中一颗而保留其余部分。
重要的除了切出多少块,还有决定的顺序。太早把一幅图像解释成一行一行的表示,中间各层就被迫都要理解「行」这个东西;把这个表示决定往后推,它们就能继续用「图像」来思考。表示细节传播得越远,将来修改时牵动的地方就越多。
书里第三部分把这些想法落实到了语言机制上。类描述一组相关对象的结构与行为,对象保存自己的状态,于是可以先按业务概念思考,再逐层翻译成机器能执行的操作。协程处理的是另一种耦合:几段程序之间,谁必须服从谁的控制流?子程序的 basic 关系是调用与返回,主程序调用它,它干完活返回;协程可以在执行中暂停,保留自己的局部状态和执行位置,之后从暂停处接着跑。书里用两个游戏程序对弈来说明:双方都要算自己的下一步、交给对方、等回应、再继续。硬把其中一方改成另一方的子程序,本来对等的合作关系立刻变得别扭;把它们看作交替恢复的协程,各自的程序结构就都保住了。
第三部分第七节「Concept Hierarchies」尤其值得顺着读一遍:先有 TWLIST 提供双向链表操作,在它之上构造离散事件模拟机制 MINISIM,再用这些机制描述车间里的订单和机器。底层负责链表连接,中层负责模拟时间、等待和唤醒,上层可以直接表达「请求一台机器,加工一段时间,再释放机器」。每往上一层,使用者就获得一套更贴近问题的词汇,也能暂时忘掉一批已经处理好的细节。
今天完全可以请 AI 带着读这段程序,但有价值的提问要落在原文上:这一层新引入了什么概念?调用者因此不再需要关心什么?哪些操作会破坏底层的假设?让它指出对应的代码和页码,再去核对,比让它概括一句「这本书很经典」有用得多。
六、好的架构,设计的是一片解空间
Guy Steele 在一篇前言里谈到,人类特别擅长给东西命名:看到一个名字,就能借联想记忆唤起一整片相关概念。一个好名字可以承载已经理解过的知识,于是程序员可以用「事务」「队列」「选课资格」来思考,不必每次都回到它们的全部实现细节。他自己就是这类抽象工具的重要推手:与 Gerald Jay Sussman 一起设计了 Scheme,写了 Common Lisp 的语言专著,还参与了 Java 语言规范的编写。
他在 1998 年的一场主题演讲里,让听众亲身经历了一次语言的「生长」:从单音节词出发,逐步定义新词、建立构词规则,再用这套共同词汇表达复杂思想。这场演讲提出的关键观点是:设计一个东西固然好,设计一种允许后来者继续创造的模式,可能更有价值,也更难。模式规定各部分怎样配合,同时留下可以以后填入的空位。
把这个思想放回软件架构,就是这一讲的核心:好的架构会设计与开放的需求空间相对应的解空间。今天可以定下数据交换的规则,未来仍然允许增加新的处理步骤;今天可以定下业务对象的接口,未来仍然允许更换实现。我们没法提前猜中所有需求,却可以让某一类未来变化有合理的落脚点。
这也解释了为什么值得读经典。海量教程、拼凑来的经验和 AI 生成的一大堆东西,很容易让人习惯「能跑就行」。经典里的许多设计,是顶级工程师长期碰壁之后攒下来的判断。读它们能培养一种眼光:看到一个系统时,辨认得出它固定了什么、开放了什么,以及这些决定会把使用者带向哪里。
① 让 AI 生成一个递归版汉诺塔,然后你自己用纸笔画出「后进先出任务清单」那种写法的前七步:每取出一张便签,写下它被拆成哪三张、以什么顺序压回去,最后核对移动序列是否为 1, 2, 1, 3, 1, 2, 1;
② 挑一个你写过的小程序,标出它的「生成时」和「运行时」:哪些事可以在程序启动前就算好,哪些必须等用户输入才知道;
③ 写下你这个程序里最可能变化的一处需求,再回答一个问题:它变化时,会牵动几个文件。