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

软件工程从哪来

今天所有的工程方法,几乎都能追到六十年前同一个困境:客户讲不清想要什么,写程序的人又不懂客户的业务。

这一页讲什么
  • 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 赞成的主张,恰好把原文的意思弄反了。

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)出发点很朴素:一个组件调用另一个组件时,双方应该把各自的责任说清楚。

三样加起来构成接口的语义,远不止参数类型和返回值类型。举个校园场景:到图书馆借书,你有有效证件、目标书在馆可借,这是你答应的事;图书管理员承诺把可外借的书交到你手上并登记归还日期,这是他答应的事;「每本书的馆藏记录始终对应一本真实存在的书」,则是整个过程任何时刻都要成立的约定。

生活类比 去驿站取快递:你报出取件码、出示身份,这是你答应的事;驿站承诺把对应的包裹交到你手上,这是它答应的事。包裹出库以后「取件码只能用一次」这条规矩,则在整个取件过程里一直成立。只要你的取件码是对的,驿站就没有理由说「我查不到」。

换成一个不允许透支的银行账户,取款操作可以这样写(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 想解决什么?
误读在于把文中画出的阶段图当成作者赞成的主张。原文紧接着指出,简单按阶段单向推进风险很大,会招致失败:运行时间、存储占用这些约束往往到测试阶段才第一次被真正经历,此时问题可能要从测试一路退回设计和需求。do it twice 建议正式交付前先做一个实验性系统,用它尽早接触最危险的不确定性,让实际运行给设计提供反馈。
4. 把开发流程看成数据依赖图,能得到哪些阶段图给不出的自由?评价一种开发方法的好问题是什么?
依赖关系规定了「谁要消费谁的产物」,没有规定所有需求一次写完、所有阶段只走一遍。于是可以只取一小部分需求,完成对应的设计、实现、验证,再扩到下一部分;错误假设影响的范围被限制在这一小部分里。评价方法的好问题是:关键反馈什么时候到来?在它到来之前,有多少工作建立在未经检验的假设上?
5. 取款例子里,前置条件、后置条件、类不变量分别是哪一句?余额不足该由哪一部分处理?
前置条件是 require 后面的两条:金额为正、金额不超过当前余额。后置条件是 balance = old balance - amount,其中 old balance 是调用开始时的余额。类不变量是 balance >= 0。余额不足的请求没有满足前置条件,调用者本来就不该那样调用;如果产品要求把它当作正常业务结果返回,就要另外设计契约,把「返回失败状态、余额不变」写进后置条件。
6. UML 五个设计问题里,「图上没画」和「模型里没有」的差别为什么重要?「留白也是决定」又指什么?
模型包含元素、关系与约束,图只是其中一部分的展示,一张图省略了多重性标注,不能说明模型里没有这条约束,凭省略的画面替模型作决定会出错。UML 规范规定正文与规范性 XMI 冲突时以 XMI 为准,正说明语义要落在机器可读的数据上。留白是指模型有意不把一切说完,合法轨迹与非法轨迹之外还存在「尚未表态」的行为;这类行为不能当成已获认可的规则,它标记的是一个还没人做的决定。
7. 大语言模型时代,规格与实现的差距挪到了哪里?需要持续维护哪三类信息?
差距挪到了规格维护上:模型会把没说清的地方自己补上,三份都能运行、接口相同、正常路径测试全绿的代码,可能包含三个不同的业务决定。需要维护的三类是:已经决定的行为、明确禁止的行为、还没有决定的行为。它们要能独立于某次聊天和某份实现被保存、检查,并在需求变化时一起更新。