软件工程从哪来
今天所有的工程方法,几乎都能追到六十年前同一个困境:客户讲不清想要什么,写程序的人又不懂客户的业务。
- 1960 年代的软件危机具体是什么样子,为什么给它加上「工程」两个字;
- 阿波罗登月的 1202 警报,怎样把「正确」的含义往前推了一步;
- 瀑布模型为什么常常被读反,Royce 说的 do it twice 是什么;
- 把开发流程看成一张数据依赖图之后,各种方法为什么有了统一的解释;
- 契约式设计里前置条件、后置条件、不变量各承诺什么,以及 UML 背后的五个设计问题。
一、一台机器,两种不懂
先把时间拨回 1960 年代。那时候写程序是一件很专门的手艺:机器贵,程序员也贵,工具还很原始。一家公司想用计算机管点事情,得先找来会写程序的人,再花大量时间解释自己的业务。
解释这一步最容易出事,因为双方各有一块知识空白。
- 客户说不清。他非常熟悉自己每天做的那套流程,却很难把「习惯」和「例外」分开讲。让他描述正常情况,三句话就完了;让他把一年里遇到过的所有特殊情况都列出来,他要翻抽屉找单据。
- 程序员不懂业务。他懂循环、懂数组、懂怎么把数据存进磁带,可他不知道「缓考」是怎么回事,不知道「转专业学生适用的培养方案」为什么要认版本,不知道「重修只计一次学分、成绩单保留历次记录」这种规矩从哪来。
两边都真诚地想把事办好,结果却常常是:系统做出来了,跑得通,可客户一用就说「跟我要的对不上」。这种局面反复出现,规模越大的项目越严重,进度、成本、质量、维护一起失控。人们给它起了个名字,叫软件危机(software crisis)。
更快的机器没有自动消除危机。它只是让人们敢去做规模更大、要求更高的系统,于是缺口跟着放大。
1968 年,一场以软件工程为主题的会议召开,把工程的理论基础和实践纪律带进了对软件生产的讨论。「软件工程」这个名字本身就带着一股挑战意味:能不能像对待桥梁、飞机那样,有组织、有依据地开发和维护软件,让结果不再依赖少数高手的个人状态。
同一时期还有一个细节值得记住。有人在 1972 年的图灵奖演讲里回忆:1957 年他结婚登记时,「程序员」还不能被当成一种正当职业,他最后填了「理论物理学家」。十几年之后,软件已经成了一个必须认真对待的大问题。这个落差说明,软件成为社会基础设施的速度,比它自己的方法论成熟得更快。
二、阿波罗 1202 警报:正确还有另一层意思
登月软件是理解这件事的一个具体入口。它面对的要求除了「算得对」,还有一堆硬邦邦的限制:存储空间小到要一个字节一个字节抠,时间要求严格到几毫秒都不能超,出发之后出任何意外都没有重启的机会。
阿波罗 11 号下降过程中,舱内计算机报了 1201、1202 的程序警报。按照参与者的回忆,系统在资源不足之后重新初始化,把不重要的任务丢掉,保住了关键任务,发动机控制这类要命的工作得以继续。这套恢复机制事先经过了大量测试,地面人员因此能够判断:可以继续下降。
这一层「正确」的含义,后来成了软件工程的基本要求之一。IEEE 的定义强调系统化、纪律性和可量化,说的正是希望把这些工作变成能够组织、检查和改进的过程,让「它恰好能跑」推进到「我们知道它在什么条件下不会跑」。
放回今天的日常,这条要求依然适用。你写一个把 Excel 转成 Markdown 的脚本,正常文件跑通了,就该再问几句:文件有空白行会怎样?某一列全部为空会怎样?路径里有中文和空格会怎样?三个问题问完,脚本就从「演示能跑」变成了「我敢用它处理自己的作业」。
三、瀑布模型被读反了
1970 年,Winston Royce 发表了一篇讨论大型软件系统开发管理的文章。他讨论的背景是航天任务:怎样按时、不超预算地把一套庞大系统交付出去。
文章里画出了后来被叫作瀑布模型(waterfall model)的阶段图:先分析需求,再设计、编码、测试,最后投入运行,一阶一阶往下流。很多人对软件工程的第一印象就来自这张图,也停留在这一张图上。
可原文紧接着就说了另一件事:照这样简单地往下推,风险很大,会招致失败。
Royce 最锋利的一句观察是:运行时间、存储占用、输入输出这些东西,往往到了测试阶段才第一次真正被「经历」,在此之前它们只是被「分析」过。分析并不总能预测运行时会发生什么。一个控制算法在数学上完全正确,却没法在规定的控制周期里算完,这时候删掉几条指令毫无帮助,得换算法、重新划分任务,甚至重新跟客户商量系统原本承诺的功能。问题从测试一路退回了需求。
他给出的建议之一叫 do it twice:正式交付之前,先做一个实验性的系统(pilot model),用它尽早接触那些最危险的不确定性。第一次实现的价值在于让实际运行给设计提供反馈。这就像正式演出前的彩排:灯光、音响、换场全部按真实流程走一遍,问题在彩排里解决,正式演出才有把握。
四、把开发流程看成数据依赖
与其背下那张阶段图的顺序,不如换个看法:把它当成一张数据依赖图。每个阶段要消费上一步的产物,才能产出自己的东西。
flowchart LR R["需求 想要什么"] --> D["设计 系统怎样组织"] D --> C["实现 写出代码"] C --> T["测试 对照规格检查行为"] R --> T T --> U["运行与维护 不断产生新事实"] U -.->|"暴露问题"| R
依赖关系真实存在:设计要消费需求,否则会沿着想象漂移;实现要消费设计与规格,否则各模块各说各话;测试要拿实际实现对照规格,需求则是判断对错的依据。可这些依赖并没有规定所有需求必须一次写完、所有设计必须一次完成,也没有规定每个阶段只能走一遍。
完全可以只挑一小部分需求,为它完成对应的设计、实现和验证,再根据反馈扩充下一小部分。这样做的效果,与一次性把全部需求分析透彻,在结果上可能接近,在风险上完全不同:前者的错误假设影响的是这一小部分,后者的错误假设影响的是全部。
测试驱动开发(Test-Driven Development,缩写 TDD)把这个思路再往前推了一步,它提醒我们分清两种工作:写出期望的行为,以及执行程序检查行为。前者可以在实现之前做,后者必须有实现才能做。先写一个测试用例,往往就是在澄清需求:空输入该返回什么?余额不足时该发生什么?同一个请求重复提交算一次还是两次?
这也解释了为什么值得回头读原文。过去找一篇四十年前的论文成本很高,教材承担了知识的压缩工作。压缩当然有用,可如果只留下了那张阶段图、丢掉了紧随其后的批评,得到的知识可能正好与原文相反。今天查找、翻译、串联一手材料的成本低了很多,一个下午就能把原文翻出来核对,顺手还能把当时的技术背景补上。经典即使有些部分已经过时,也常常保留着发明者第一次面对问题时的观察;把背景恢复出来,才分得清哪些做法依赖当年的工具条件、哪些洞见今天依然成立。
五、契约:调用者和实现者各答应什么
流程管的是时间上的顺序,还有一类问题管的是模块之间的责任。Bertrand Meyer 讨论的契约式设计(Design by Contract)出发点很朴素:一个组件调用另一个组件时,双方应该把各自的责任说清楚。
- 前置条件(precondition):调用者需要保证什么成立;
- 后置条件(postcondition):在前置条件成立的前提下,实现者需要保证什么;
- 类不变量(invariant):对象在对外可用的稳定状态下,始终要满足什么。
三样加起来构成接口的语义,远不止参数类型和返回值类型。举个校园场景:到图书馆借书,你有有效证件、目标书在馆可借,这是你答应的事;图书管理员承诺把可外借的书交到你手上并登记归还日期,这是他答应的事;「每本书的馆藏记录始终对应一本真实存在的书」,则是整个过程任何时刻都要成立的约定。
换成一个不允许透支的银行账户,取款操作可以这样写(Eiffel 风格,省略外围的类定义):
withdraw (amount: INTEGER)
require
positive_amount: amount > 0
sufficient_balance: amount <= balance
do
balance := balance - amount
ensure
balance_decreased: balance = old balance - amount
end
读法是这样的:require 后面是前置条件,金额为正、不超过当前余额;do 与 end 之间是实现;ensure 后面是后置条件,old balance 表示调用开始那一刻的余额,新余额必须等于旧余额减去取款金额。这个账户还可以有一条类不变量 balance >= 0。
余额具体存在哪个字段、用什么类型,调用者不必知道。只要他满足了前置条件,就有权依赖后置条件成立。这份契约同时把边界划得很清楚:余额不足的请求没有满足前置条件,调用者本来就不该那样调用。
把条件写进语言之后,工具可以在运行时检查某次调用有没有违反约定,也可以在合适的语言与逻辑系统里对实现做静态验证。这件事有一个容易高估的地方:一次断言检查通过,只说明这一次执行没有触发违例;一个验证结论,也只对写下的规约和假设成立。真正难的部分始终是先把我们关心的性质写出来,验收工具只能替我们检查写下来的东西。
六、UML 背后的五个设计问题
除了流程和契约,还有一类工具管的是「怎么把设计说清楚」。UML(Unified Modeling Language,统一建模语言)试图为面向对象系统提供共同的模型元素、记号和语义。自然语言能调动人的经验,却容易让同一句话在不同人脑子里长出不同解释;数学语言靠共同定义让不同背景的人讨论同一个对象。UML 做的是同一件事:规定一个箭头表示什么、一个接口承诺什么。
很多人学 UML 的印象是背各种图形和箭头,考完就忘。换一个问题去读它,会读出完全不同的东西:哪些意图必须明确固定下来,哪些选择可以留白?顺着这条线,能提出五个具体的设计问题。
1. 图与模型:意义存在哪里
模型包含元素、关系和约束;图只是其中一部分内容的展示。一张图没有画出某项信息,不能说明底层模型里没有这项信息。选课的例子很典型:「学生与课程之间存在选课关系」这句话回答不了每人最多选多少门、一门课能容纳多少人,这些数量约束属于模型,不属于某张图。UML 规范本身也把语义落实成机器可读的数据:规范正文与随附的规范性 XMI 文件冲突时,以后者为准。
把这条经验推广到自己的项目:凡是需要跨工具、跨时间保留的意图,都该找到显式的数据表示。把类图存成一张截图,人还能看懂,另一个工具很难可靠地知道某条线连的是哪个元素。需求文档、设计图、测试各自漂移,语义就是这样一点点分裂的。
2. 契约与实现:这次成立,还是每次都成立
一个实现成功处理过一次请求,不能说明它在所有约定场景下都能满足要求。UML 用前置条件、后置条件、不变量、协议状态机和多重性来表达这类普遍要求。一个查询支付状态的操作,声明为查询就不该顺手改变系统状态——查一下付款了没有,不能暗中完成一次扣款。
自然语言也能表达普遍要求,「任何重试都不能重复扣款」是一句清楚的意图。难点在于这句话不会自动去检查后面的实现有没有遵守它。在生成代码之前写下约束,在代码生成之后用测试、检查或验证把它们联系起来,承诺才能持续生效。
3. 留白也是一种决定
模型可以有意不把一切都说完。UML 的交互语义区分合法执行轨迹和非法执行轨迹,这两个集合不一定覆盖所有可能的轨迹,于是还存在「模型尚未表态」的行为。一份早期支付模型可能这样分:
- 合法:用户确认支付,系统扣款成功,返回成功结果;
- 非法:用户没有确认,系统已经扣款;
- 尚未规定:用户确认之后请求超时,系统是否自动重试。
第三类不能当成已经获得业务认可的行为,它说明还有一个决定没人做。形式化是有成本的,可以把成本花在歧义大、违反代价高、需要机器继续推导的地方;同时把「哪些地方还没决定」明确记下来,渐进的过程才可控。
4. 不同的含义,需要不同的表达
自然语言和随手画的图,常让一个记号同时承担好几种没说清楚的含义。两条记录此刻具有相同的属性值,不说明它们是同一个对象;修改其中一个,另一个跟不跟着变,取决于身份和共享关系。在选课系统里,从学生对象能找到课程,回答的是访问关系;关联端的属性归谁拥有,回答的是模型结构;对象是否随另一个对象一起销毁,涉及生命周期。删除一个学生的选课信息,显然不该顺手删掉整门课程。
时间顺序同样是重灾区。交互图上的上下位置不能总被解释为全局串行执行,UML 的 seq 表示的是弱顺序:来自不同生命线的事件仍然可能交错。短信通知画在仓库备货上方,并不要求短信全部发完仓库才能开始工作。人类程序员会用经验补齐这些模糊处,语言模型也会;一旦双方的经验不同,模糊之处就变成行为差异。
5. 模型本身也要检查
既然模型由明确的元素和关系组成,就可以像检查程序的类型那样检查模型。OCL(Object Constraint Language,对象约束语言)用来表达这类条件;UML 还用元模型描述自身——元模型规定「模型可以由什么组成、它们怎样关联」。扩展机制同样需要边界:Profile 可以给特定领域增加标记和约束,扩展时仍然要尊重原有语义。
把惯例逐步写成可检查的规则,许多错误在执行之前就能暴露。这里也有边界:通过良构性检查只说明模型符合建模规则,一个语法完全正确的支付模型仍然可能遗漏重复扣款的问题。UML 规范里有部分约束明确标注了「无法用 OCL 表达」,说明有些规则还得靠别的办法守住。
flowchart TB Q1["图与模型 意义存在哪里"] --> D["一份说清楚的设计"] Q2["契约与实现 这次成立还是每次都成立"] --> D Q3["留白 哪些还没决定"] --> D Q4["不同含义 用不同表达"] --> D Q5["模型自身也要检查"] --> D
七、差距挪到了规格维护上
大语言模型让另一条路变得可行:直接从自然语言生成程序,过去需要人工补齐的许多中间步骤可以一跳而过。阶段图的顺序被打乱了,原型几分钟就能出来,试错的成本显著下降。
规格与实现之间的差距并没有因此消失,它换了个位置。当用户没说清「支付超时之后怎么办」,模型为了把程序写完,会自己选一种处理方式:第一次可能等待,第二次可能自动重试,第三次可能直接返回失败。三份代码都能运行,接口看起来一样,正常路径的测试甚至全绿,可它们包含了三个不同的业务决定。
于是需要有人持续维护三类信息:已经决定的行为、明确禁止的行为、还没有决定的行为。AI 越能快速生产代码,这个接口就越值得认真设计——哪些事已经定了,哪些行为绝不能发生,哪些细节可以交给实现者自己选。
承诺可以先写在自然语言里,再根据问题的代价和工具的能力,逐步落实成接口契约、状态机、断言、测试或者验证条件。它们需要能独立于某一次聊天、某一份实现被保存下来,能在需求变化时一起更新。给已经决定的事一个显式、可检查的表达,快速改写实现的时候才握得住软件的行为。
这一章讲的那些历史并没有过时,它们只是被压缩成了几行结论,藏在今天每个工具的默认设置里。读原文、看原始场景的价值,在于重新看见那些结论当初要对付的是什么问题;问题换了样子,答案才有机会跟着换。
① 写一张决策表:为你常去的那家奶茶店列出「学生九折」「满二十减三」「两者能否叠加」的所有组合,把每一个必须由店家拍板的决定标出来;
② 给你写过的某个函数写一份契约:一条前置条件、一条后置条件,再想一条整个对象生命周期都成立的不变量,用中文写清楚就行;
③ 让 AI 助手帮你找出 Royce 1970 年那篇论文里提出 do it twice 的位置,核对原文怎么说,再用自己的话写三行笔记,记下他担心的到底是什么。
自测
1. 软件危机里,客户和程序员各自缺的是哪一块知识?为什么双方都真诚也可能失败?
2. 阿波罗 11 号的 1201、1202 警报为什么没有导致任务失败?它把「正确」的含义推到了哪一层?
3. 「瀑布模型」这个词与 Royce 1970 年的原文之间,最常见的误读是什么?do it twice 想解决什么?
4. 把开发流程看成数据依赖图,能得到哪些阶段图给不出的自由?评价一种开发方法的好问题是什么?
5. 取款例子里,前置条件、后置条件、类不变量分别是哪一句?余额不足该由哪一部分处理?
require 后面的两条:金额为正、金额不超过当前余额。后置条件是 balance = old balance - amount,其中 old balance 是调用开始时的余额。类不变量是 balance >= 0。余额不足的请求没有满足前置条件,调用者本来就不该那样调用;如果产品要求把它当作正常业务结果返回,就要另外设计契约,把「返回失败状态、余额不变」写进后置条件。