生成式软件工程
搜索
开始阅读/第 4 部分 · 工程判断/第 11 章

换一种表示:架构第一课

同一道题,换一种表示,十行代码就能写完;换不动的,往往是你脑子里的那幅图。

这一页讲什么
  • 几行代码的小程序为什么也有架构,边界靠什么兑现;
  • 汉诺塔的三种写法:找数学规律、用任务清单、模拟函数调用;
  • 一个二维码程序为什么必须先造一个中间表示,生成时与运行时怎么分开;
  • 本质复杂性与附带复杂性,以及 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
关键结论 三种写法把复杂性分别放在了数学规律、任务分解和执行机器里。盯着「这一行该写哪个 if」很容易陷进越来越多的分支;换一种表示,问题可能突然变得简单。架构关心的正是这件事:我们对问题的掌控到了什么程度。

三、二维码程序:为什么不先造一个中间表示

汉诺塔只是开始。看一个真实一点的需求:在 Excel 里输入一串字符,由工作表公式算出并显示二维码,输入改了二维码跟着变。

直接让 AI 把所有公式写出来,很快会撞上长公式的脆弱性:括号、单元格引用、数组运算、算法步骤全缠在一起,光是让括号配对就能耗掉大半天。就算公式终于能跑,下一次需求变化照样痛苦:要兼容更老的 Excel,或者用上新版本的能力;要把二维码挪到工作表另一个位置;要换一种计算方法;要多加一个输入单元格。每一件看起来局部的事,都可能穿过整片公式。

跳出公式再看,二维码算法本身用普通编程语言描述并不难,难的是把它映射到工作表这个奇怪的计算环境里。真正需要的东西浮出水面:一个编译器。用一种便于表达算法的形式描述计算,再把它翻译到目标环境。

一种可行的设计是引入中间表示(Intermediate Representation,缩写 IR):它保存输入、常量、异或、查表、条件选择这些计算节点,节点之间的边记录数据依赖。前端用高级语言构造这张计算图,后端负责把它翻译成 Python 程序或者 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;
② 挑一个你写过的小程序,标出它的「生成时」和「运行时」:哪些事可以在程序启动前就算好,哪些必须等用户输入才知道;
③ 写下你这个程序里最可能变化的一处需求,再回答一个问题:它变化时,会牵动几个文件。

自测

1. 把代码切成四个函数,为什么还不能保证边界真的存在?判断架构好坏的那个朴素问题是什么?
如果四个函数随意读写同一批全局变量,每一步都依赖其他步骤的内部细节,变化仍然会四处传播。边界要落实到数据怎样流动、谁拥有状态、谁可以依赖谁。判断的朴素问题是:需求改了一处,系统里要跟着改多少处。
2. 三种汉诺塔写法分别把复杂性放在哪里?用后进先出清单时,为什么放入顺序要反过来?
分别放在数学规律(步号二进制末尾零的个数决定盘号,方向由总盘数与盘号的奇偶关系决定)、任务分解(取出、分解、执行的循环)、执行机器(调用记录加程序计数器模拟调用与返回)。清单是后进先出,而子任务的执行顺序是「上面的盘子、最大的盘子、搬回来」,所以放入时必须把最先执行的任务最后放才取得到。
3. 二维码程序为什么需要一个中间表示?没有它会怎样?
因为直接写工作表公式会让括号、引用、算法步骤缠在一起,改一处需求就要穿整片公式。中间表示把计算节点和数据依赖单独存下来,前端负责用高级语言构造,后端分别翻译成 Python 或 Excel 公式,于是换位置、换版本、换算法、加输入这些变化各有落点。没有它,每次需求变化都要重写整片公式,而且无法验证两个目标环境算得是否一致。
4. 生成时与运行时的界线划错,会出什么具体后果?
生成时可以把与输入无关的计算提前算好、把有限步骤展开;运行时必须把用户输入保留成符号输入,由内容决定的分支变成条件选择节点交给目标环境判断。界线划错,生成器会提前算出一张固定的二维码,换个输入一模一样,看起来像完成了需求,实际上没有实现。
5. 本质复杂性与附带复杂性各举一例,分别该怎么处理?分模块能消掉哪一种?
本质复杂性来自问题本身,例如容量上限、先修条件、时间冲突、转专业学分认定,即使不用计算机也依然存在,只能分解和组织,让它集中在某处。附带复杂性来自组织软件的方式,例如同一条信息存五份、日期格式不统一、规则复制到多处,应当减少甚至消除。分模块能减少附带复杂性(前提是边界真的成立),消不掉本质复杂性。
6. 珍珠项链比喻里,「过早把图像解释成一行一行的表示」为什么是问题?
中间各层会被迫都理解「行」这个表示细节,表示细节传播得越远,将来修改时牵动的地方就越多。把这个决定往后推,各层就能继续用「图像」来思考,替换底层实现时上层的概念不受影响。这说明重要的除了切分出多少层,还有设计决定的顺序:决定得越早、传播得越广,替换成本越高。
7. 「好的架构设计的是一片解空间」是什么意思?汉诺塔的三段代码与二维码的中间表示各自留下了什么空位?
意思是架构固定一部分决定,同时为未来一类变化预留可填入的位置,让后来的需求有合理落脚处,而不必推翻重来。汉诺塔的三种写法各自把复杂性放进不同的表示,换表示就等于换了一整片可能的解法空间;二维码的中间表示固定了「计算图」这个形式,留空了输入绑定、布局、目标版本、后端生成这些位置,因此每一类变化都只影响负责它的那一块。