ARTICLE · 1209557
模型驱动的软件工程
引篇 软件的未竟之业
在一切现代工程门类中,软件工程或许是最年轻、也最缺乏“工程感”的一门。桥梁工程师在浇筑第一方混凝土之前,已经完成结构力学的演算;航空工程师在样机离地之前,已经在风洞中度过数千小时;化工工程师在投料之前,已经用物料衡算与热量衡算把每一条管线的状态推演完毕。他们共同的特征是:在物理实现之前,先在抽象的、可推演的层面把问题求解清楚,图纸与模型是工程的语言,计算与验证是交付的前提。而软件工程师却长期在一种近乎倒置的秩序中工作——他们习惯于直接写下可执行的语句,然后借助编译、调试与无休止的测试,让一个本应预先想清楚的东西,在反复试错中逐渐显形。
这并非因为从业者不够聪明。然而,正是这种近乎无限的可塑性,使软件失去了其他工程门类赖以立足的“硬度”。在土木与航空的世界里,错误会以断裂、坍塌、坠落的方式立刻要求偿付;在软件的世界里,错误却可以潜伏数年,在某个无人预料的时刻以一次宕机、一场灾难的方式现身。物理世界为工程师的疏忽提供即时反馈,而软件的反馈被推迟、被掩盖、被层层偶然包裹。
这门行当的自我认知因此长期处于一种奇特的张力之中。
基于模型的软件工程(Model-Based Software Engineering),其核心主张可以被凝练为一句看似平淡、实则颠覆性的话:软件的本质是一组关于问题域的精确语义,代码只是这组语义在特定自动化技术上的一次映射;工程师应当先在与技术无关的层面把“做什么”刻画为可验证的模型,再以可机械推导的方式把“如何做”映射为设计与代码。
这不是又一种编程风格,也不是又一套过程框架,而是对软件工程中“问题”与“求解”两者关系的一次根本性重构。它要把一个大到难以驾驭的难题,切成两个各自可解的小问题。
理解这一主张的分量,需要先回到它试图治愈的病灶。对大量商用软件项目的长期统计勾勒出一幅令人难堪的图景:约三成九的项目能够按约交付,四成有余的项目在超支、延期与功能缩水的夹击下艰难求生,另有近两成项目最终被彻底取消或废弃,从未投入使用。即便是那些被“完成”的项目,平均也要比计划延期四成以上、超预算三成以上,交付时缺失的功能数以四分之一计。更值得深思的是缺陷的来源分布:对缺陷谱系的追溯表明,超过半数的缺陷并非源于编码的笔误,而是在需求尚未被精确理解的阶段就已埋下。
主流的应对方式,是把质量的赌注押在测试上。于是测试团队不断膨胀,在许多组织中,测试人员与开发人员的比例已经达到一比四,甚至更高。然而,一个早已被理论与实践反复确认的事实是:测试在原理上不具备证明正确性的能力。测试可以揭示缺陷的存在,却永远无法揭示缺陷的不存在;对任何稍具规模的程序,穷尽所有输入与状态组合在物理上都是不可能的。当人们试图用测试去捕获本应在需求与设计阶段消除的歧义时,本质上是在用一个无穷尽的下游活动,去为一个有限的上游疏忽买单。
与之相伴的是另一个流传甚广的神话:“代码即文档”,优秀的代码应当自明其意。但一旦系统规模越过某个阈值,代码便同时承载了问题域的策略、实现技术的选择与历史遗留的偶然,任何读者都无法从成千上万行交织着业务规则与技术细节的语句中,逆向还原出当初那个纯粹的问题。代码是映射的产物,它天然混淆了“是什么”与“怎么做”;指望从代码中读出问题的语义,如同指望从建筑的砖块排布中读出建筑的设计意图。
三大病灶——含糊而不完整的需求、对测试的过度依赖、对自文档化代码的迷信——并非彼此孤立,它们共享同一个认识论根源:从业者从未在工程介质中明确区分“问题的语义”与“实现的技术”。这一区分,正是全部基于模型的软件工程的逻辑起点。
沿着这条主轴,先厘清语法与语义的分野、代码作为映射的本质与软件失效的根源,审视软件危机的经济学;继而进入方法论核心——分离原理如何把一个难题切成两个可解的问题,语义模型如何在刻意排除一切自动化技术的“真空”中被精确刻画,用例、类、交互、状态四种视图如何从边界、结构、信息流动与时间行为四个维度完整凝固问题域;再转向契约与正确性证明,以及评审、模拟与风险驱动测试如何在最早时点、以最小代价消除缺陷;随后展开从模型到高层设计、语义保持优化、模型编译器的完整映射链条;最后回到工程经济学,正面回应这一范式的种种诘难,并在与其他成熟工程门类的对照中理解软件工程的“终极目标”。
第一章 语法与语义:软件失效的认识之源
一、被混淆的两个层次
要理解软件工程的困境,必须先建立一个在日常编程中几乎被完全遮蔽的概念区分:语法(syntax)与语义(semantics)。语法是符号的组合规则,规定哪些字符序列构成合法的语句;语义则是符号所承载的意义,规定一段程序在问题域中究竟表达了什么、做了什么。一个程序可以在语法上完全正确——编译器不报错、能够运行、产生输出——却在语义上彻底错误,因为它精确地实现了一个错误的意图。机器对语义毫无判断力,它忠实执行每一条指令,却从不追问这些指令是否符合人的真实意图,于是语义的全部重担都压在人的肩上。传统开发流程的根本问题,正在于让人在尚未厘清语义之前就被迫去构造语法,把“理解问题”与“选择实现”混在一起完成。
二、代码即映射
基于模型的视角对“代码是什么”给出了一个不同寻常的回答:代码是一次映射(mapping)。它把两类不同性质的语义——关于问题域的策略与过程语义(policy and process semantics),以及关于自动化技术的技术语义(technical semantics)——融合并映射到某种编程语言的语法之上。
策略是问题域中那些必须被满足的规则、约束与不变量。以存贷业务为例,“账户余额不得低于透支限额”就是策略;它们描述业务世界的内在规律,与软件是否存在、用什么语言、运行在什么硬件上毫无关系,即使银行回到手工记账时代依然成立。技术语义则关心这些策略与过程如何在特定计算机上被高效实现:数据存放在关系数据库还是内存对象、通信用同步调用还是消息队列、并发用线程还是事件循环,这些技术决策不影响业务规则,却深刻影响软件的性能、成本与可维护性。
在传统代码中,两类语义被压缩进同一组语句。代码即映射这一命题的革命性在于:一旦承认代码是两类语义的映射结果,就意味着这两类语义在映射之前应当、而且可以被分别、清晰地表达。映射是保持意义的变换,而要保证映射保持意义,前提是被映射的“意义”本身已被独立、无歧义地固定下来。
三、一个正确程序的三个性质
从代码即映射的观点出发,可以严格地陈述一个程序“正确”究竟意味着什么。它必须同时满足三个性质。其一,充分且完整(sufficient and complete):程序必须实现策略与过程所要求的全部行为,不多也不少;少了任何一条要求是不完整,多做了任何未被要求的事则引入未经授权的行为。其二,保持语义(semantic preservation):程序从输入到输出的全过程,必须在每一步都维持问题域的不变量,映射可以改变表示方式、重新组织计算顺序,但不允许改变那些被策略规定的意义。其三,满足非功能需求(nonfunctional requirements):程序不仅要“做对的事”,还要“把事做到足够好”,响应时间、吞吐量、可用性、安全性、可扩展性、资源占用,这些关于“做得多好”的质量属性同样是正确性的组成部分。
前两个性质属于语义正确性,第三个属于技术适配性,它们恰好对应映射的两端:源端是策略与过程的语义,目标端是技术约束下的实现。正确性从来不是单维度的标签,而是一张必须同时满足的约束网络。
四、契约:把责任划分到边界两侧
一旦把程序视为映射,“不同部分之间如何就各自的责任达成一致”就成为必须形式化的问题。Bertrand Meyer 在面向对象设计中提出的契约式设计(Design by Contract)为此提供了基本语言,它借用法理学中“契约”的隐喻:调用方与被调用方的关系,类似签约双方的权利义务关系。
每一个操作都可被规约为两个逻辑断言。前置条件(precondition,requires)陈述调用方在调用前必须保证为真的条件;后置条件(postcondition,guarantees)陈述被调用方在操作完成后承诺为真的结果。契约清晰划分了责任:若前置条件不满足,调用方违约,被调用方无需对结果负责;若前置条件满足而后置条件未达成,则被调用方违约。
在继承与多态的语境下,契约还必须满足 Barbara Liskov 揭示的可替换性原则(Liskov Substitution Principle):子类型必须能够在不改变程序正确性的前提下替换其基类型。这在契约层面被精确表述为格言——子类“要求的不能更多,承诺的不能更少”(require no more, guarantee no less):子类可以放宽前置条件(接受更广输入)却不能收紧,可以强化后置条件(承诺更多结果)却不能弱化。契约式设计实质上把程序从“执行的指令序列”重新定义为“责任的精确划分”,让每段代码的边界都成为可检查、可推理、可验证的界面。
五、状态、自动机与行为的本质
语义不仅是静态的结构,更是时间中的行为。理解动态行为,需要引入有限状态机(finite state machine)这一经典计算模型。一个对象在其生命周期中处于若干离散的状态(state),在特定事件(event)发生时,沿着迁移(transition)从一个状态转移到另一个状态,并在迁移中执行相应的动作(action)。状态是“对象行为保持一致且稳定的一段连续时间”;事件是“可能触发对象产生效果的某种发生”,它本身不持续,若对象未处在能响应它的状态,事件就被忽略。自动机理论为这种描述提供了数学基础:Mealy 型机器把输出与当前状态及输入同时关联,动作附着于迁移;Moore 型机器把输出仅与当前状态关联,动作附着于进入或离开状态的时刻;二者是刻画同一行为的两种视角,可相互转换。
引入状态机的意义,在于它为“行为的完备性”提供了可检验的标尺。对一个有 m 个状态、n 个事件的对象,状态与事件构成 m×n 个组合。严谨的模型必须为每个状态—事件组合显式规定行为:要么触发一条迁移,要么被明确忽略(ignore,事件可能发生但无害),要么被判定为不可能(impossible,事件在该状态下绝不应发生,若发生则意味着系统故障,在安全关键场景中必须以抛出致命异常等方式处理)。“未规定”与“忽略”截然不同——前者是规格的漏洞,后者是深思熟虑的沉默。
语法与语义、映射、正确程序的三性质、契约、状态机,构成了理解软件失效的认识论底座:绝大多数缺陷并非语法的失败,而是语义的失败,是在表达之前理解与组织的失败。要真正提升软件质量,必须在语义层面建立一套独立的、可验证的工程介质。
第二章 危机的经济画像:缺陷、返工与复杂度的账
一、“软件危机”并非修辞
“软件危机”在今天听起来已有些陈腐,但它的命名本身就是学科自觉的开端。1968 年,在联邦德国加米施召开的一次学术会议上,一批计算机科学家首次正式使用“软件危机”来描述这样一种局面:硬件能力以指数速度增长,软件的生产率与可靠性却举步维艰,系统的规模与复杂度正在逼近人脑可以驾驭的边界。当时被记录下来的病症——进度失控、预算超支、质量低劣、维护困难——在此后半个多世纪非但没有消失,反而随软件渗透到文明的每个角落而被放大。
危机的深层原因不在于工具原始,而在于软件复杂度具有一种与硬件截然不同的性质。Frederick Brooks 在《人月神话》中给出了著名的区分:软件所固有的本质复杂度(essential complexity),与本可避免、却因糟糕设计而被人为引入的偶然复杂度(accidental complexity)。本质复杂度来自问题本身,无法被任何技术消除,只能被管理;偶然复杂度则来自人自己制造的混乱——纠缠不清的依赖、重复的逻辑、为绕过旧缺陷而打的补丁、层层堆叠的兼容层。工具与语言的进步可以不断削减偶然复杂度,却对本质复杂度无能为力。更尖锐的表述来自 Edsger Dijkstra,他指出计算机所面对的抽象量级是人类此前从未经验过的,朴素的经验主义与“先写出来再调试”的作坊作风从根本上不够用;他那篇以“转向语句是有害的”为题的论文,矛头看似指向一种语法,实则指向一种思维方式:随意的控制流使程序的状态空间无法推理,从而使正确性证明在原理上成为不可能,程序应当被作为数学对象来构造,而不是作为经验试错的产物来修补。
二、缺陷的非随机分布
把软件危机从模糊的抱怨转化为可度量、可管理的工程问题,需要对缺陷的分布规律有精确认识。一个在工程上极其重要、却被严重低估的事实是:缺陷并非随机散布在代码中,而是高度聚集的,它们聚集在复杂度周围。
以 McCabe 圈复杂度(cyclomatic complexity)为例。Thomas McCabe 在 1976 年把图论引入软件度量,圈复杂度计算一段代码(通常是一个方法)中潜在执行路径的数量,数值上等于判定节点数加一——直线代码复杂度为一,每增加一个判定就增加一条可能路径。它与人的认知负荷和缺陷密度都呈现强相关。对大量生产级代码的统计给出了令人警醒的分布:圈复杂度不超过四的方法,占据全部代码行数的约四成五,却只对应约一成二的缺陷;而圈复杂度达到十五或更高的方法,虽然只占全部代码的约一成一,却集中了约四成二的缺陷;两端缺陷密度相差超过十三倍。除圈复杂度外,还有三项结构度量与缺陷密度强相关:判定嵌套深度、参数数量(过多往往意味着职责混杂)、扇出度(一个模块直接依赖的其他模块数量)。这四项度量构成从结构视角控制偶然复杂度的仪表盘,用绿、黄、红三色区间管理——绿色可接受、黄色须给出充分理由、红色在任何情况下都不可接受——组织才能对明显糟糕的代码说出“不”。
三、测试的原理性边界
面对缺陷,主流工程的第一反应是测试,然而测试的能力存在原理性、无法逾越的边界。Dijkstra 的论断道出了实质:测试只能证明缺陷的存在,无法证明缺陷的不存在。Boris Beizer 进一步指出,即便投入趋于无穷的测试也不能证明一个程序正确——任何非平凡程序的输入空间与状态空间都是无限或天文数字,穷尽测试在物理上不可行。测试因此不是缺陷的“过滤器”,而是“探测器”:它在质量链条末端工作,发现的每一个缺陷都是上游失误已经发生的证据。
缺陷经济学中还有一条“杠杆”规律:修复成本随缺陷被发现的阶段呈指数级增长。这条规律决定了理性的质量策略:把缺陷消除的时点尽可能向需求与设计的上游推移。下游测试之所以昂贵,不仅因为它本身耗费人力,更因为它是在错误已被层层放大之后才出手。
四、需求:缺陷的源头
当缺陷被沿链路上溯,最终的源头几乎总是指向需求。超过半数的缺陷可以追溯到需求阶段,而需求缺陷的典型形态是三种:含糊、不完整、彼此矛盾。
含糊,是指同一条需求可被合理地解读为多种含义。不完整,是指需求遗漏了关键情形,尤其是异常与边界情形——人们擅长描述“正常路径”,却本能地回避“如果……会怎样”的穷举。矛盾,是指两处需求无法同时满足,而这种冲突往往要到集成测试阶段才暴露,因为没有任何单一地方同时承载着它们。
需求质量的理性标准因此可被明确陈述为:无歧义、可测、有约束力。功能需求描述“做什么”,其中策略规定规则、过程规定流程;非功能需求进一步分为两类,“如何做”涉及技术选型,“做得多好”涉及可度量的质量属性,其标准框架可参照 ISO 25010 这样的软件质量模型。
五、成熟工程的对照
把软件工程放到工程史的长镜头中,它的不成熟格外清晰:成熟的工程门类都经历过从依赖工匠个人经验的手艺,转变为建立在显式科学模型与可验证计算之上的学科,“模型”由此获得独立于最终产品的地位——在模型中犯错的代价远低于在实物中犯错。软件工程长期停留在“手艺”阶段,恰恰因为它缺少独立于代码、可推演的模型介质,代码既是模型又是产品,结果便是“在实物上做设计”。
软件危机的经济画像归根到底是一笔账:缺陷在上游以极低成本即可消除,却因缺乏适当的语义介质而被推迟到下游、以指数级代价偿还;本质复杂度本应被显式管理,却因语义与技术纠缠而被淹没在海量偶然复杂度中;测试本应是最后的验证手段,却被误用为质量的主要防线。要还清这笔账,需要在认识论与方法论上做出一次结构性改变。这场改变的第一步,是一次看似简单却极其深刻的“分离”。
第三章 分离原理:把一个难题切成两个可解的问题
一、一句心法的分量
在基于模型的软件工程全部思想中,最具纲领性的一句话来自 Sally Shlaer 与 Stephen Mellor 的工作,其核心被反复转述为:把技术与业务分离,并且把业务与技术分离。这句话的两个分句看似同义反复,实则各有指向:前一个分句要求技术的实现不得污染对业务的刻画;后一个分句要求业务的刻画不得依赖任何技术的假设。两者合在一起,构成了一道双向的绝缘墙。一个庞大而困难的问题被作为整体直接面对时,往往超出人脑的处理能力;把它分解为若干彼此独立、可分别求解的子问题,是工程学应对复杂度的根本手段。软件工程面对的“大问题”——“如何让一组计算机程序满足业务的全部需要”——之所以难,正因为它把两类性质迥异的考量纠缠在一起:业务规律追求正确与完整,由领域专家主导、相对稳定;技术约束追求效率与经济,由技术专家主导、随平台快速变化。当二者被混合在同一组代码中,任何一方的改动都会波及另一方,认知负荷成倍增长。分离原理的做法,是把这一个大问题切成两个较小的问题:第一,业务世界的策略与过程究竟是什么?这是纯粹的问题域描述问题,不涉及任何自动化技术;第二,在给定技术环境下如何最优地实现这些策略与过程?这是技术设计问题,以第一个问题的清晰答案为输入。两个问题各有其专业语言、评判标准与专家群体,通过一个严格定义的接口——语义模型——相连接。
二、两个小问题为何可解
这一拆分不是简单的“分而治之”口号,它的有效性依赖于一个深刻事实:两个子问题解耦后各自都变得易于驾驭,而且它们之间的接口具有高度稳定性。先看第一个问题:当分析师被要求纯粹地描述“在线书店的订单履行”时,他不再需要考虑数据库用哪一种、界面是网页还是移动端、并发用锁还是事件驱动,只需回答订单如何创建、包含哪些订单行、库存如何扣减、什么条件下可打包、什么条件下需要补货、支付如何处理;这些问题有来自业务世界的明确答案,可被业务专家直接理解确认,问题域的复杂度第一次以它本来的、而非被技术扭曲的面貌呈现。再看第二个问题:当设计师面对的不再是模糊需求,而是一份精确、完整、可验证的语义模型时,他不再需要猜测业务意图、在编码的同时澄清规则,只需把一个已明确的语义在特定技术约束下做出最优实现,这从充满不确定性的创造,转变为有明确目标、有评判标准、甚至可以机械化执行的映射。
更关键的是接口的稳定性。业务策略与过程相对技术实现是稳定的:银行的借贷规则数十年间可能只有有限调整,而底层语言与硬件平台已更迭数代。语义与技术分离后,同一份语义模型可被映射到 Java、C++、Python、关系数据库、内存对象、单机、分布式等多种技术之上;技术更新不再威胁业务语义,业务变更也不必牵动对底层技术的重估。这种“一个语义、多个实现”的能力,正是工程上长期追求而在传统代码中难以实现的。
三、复杂度的三个层次
要把分离原理贯彻到底,需要对“复杂度”做更细致的分解。本质复杂度是问题域本身的复杂度,无法被消除,只能被清晰表达和管理;偶然复杂度还可进一步分为必要的与不必要的,必要的偶然复杂度是在特定技术下为满足性能、成本等非功能需求而不得不引入的,是合理权衡的结果。不必要的偶然复杂度则是纯粹的浪费:设计不当、职责混乱、沟通不畅所引入的复杂度,既不服务业务也不服务性能,只是历史失误的沉积。
分离原理的工程价值,正在于让这三类复杂度各归其位:语义模型只承载本质复杂度,以最清晰、最无冗余的方式表达问题域;设计阶段在语义模型之上叠加必要的偶然复杂度以满足非功能需求;不必要的偶然复杂度则通过基本原则与机械化映射被最大限度避免。
四、恰当抽象与封装
分离原理在技术上的实现,依赖于两项基本工程能力:恰当的抽象(appropriate abstraction)与对偶然复杂度的封装(encapsulation)。抽象是人类对抗复杂度的根本武器,但抽象的质量有高下之分,判断一个抽象是否恰当有两个经典思想过滤器。其一可称为“完美计算机”假设:想象一台无限快、无限内存、从不出错、天然并发、界面完美的计算机,若在这样一台机器上仍需保留的考量,那便是业务的本质;若只因现实计算机不够完美才需要的考量,那便是技术的偶然。其二可称为“直接心灵融合”的理想:想象业务专家能把脑中知识毫无损耗地传达给机器,凡为克服沟通损耗而存在的环节,都属于实现层面。用这两个过滤器审视需求,便能干净地把“业务是什么”与“技术怎么办”分开。
封装则是把偶然复杂度收纳到明确边界之后的工程机制。高内聚(high cohesion)要求一个模块内部的元素紧密相关、共同服务于单一职责;低耦合(low coupling)要求模块之间的依赖尽可能少、尽可能弱。二者一体两面:高内聚的模块往往低耦合,因为它把相关变化都收敛到内部;低耦合的模块允许各自独立演化,因为它们之间只有稳定而狭窄的接口。
五、面向不变式与面向变化
分离原理还隐含着一种关于时间与演化的设计哲学。软件不是静止的,它会随着业务而变化,工程上的智慧在于区分哪些东西是稳定的、哪些是易变的,并让设计围绕稳定的部分展开。不变式(invariant)是那些在系统的整个生命周期中、在所有操作的前后都必须始终成立的条件:银行账户的余额必须始终满足“资产与负债平衡”,订单的状态必须始终处于合法的状态集合之中。不变式是问题域中最稳定、最根本的部分,是设计的锚点;面向不变式设计,意味着首先识别并形式化这些恒常成立的约束,让所有行为都围绕维持它们而组织。
与此同时,系统还必须面向变化设计。不同的产品变体、客户配置、地区规则构成产品族(product family)。理性的设计应当让易变的部分依赖稳定的部分而非相反,并尽可能用数据而非代码表达变体。
分离原理以语义与技术的双向绝缘为纲、以复杂度的分类管理为目、以恰当抽象与封装为手段,把一个庞大问题切成两个各自可解、接口稳定的小问题。纯粹描述业务世界、与一切技术无关的那一部分,需要一个专门的、严谨的工程介质来承载——它就是语义模型。
第四章 语义模型:在无技术的真空中刻画问题
一、语义模型是什么
语义模型(semantic model)是基于模型的软件工程全部方法论的基石。它是对问题域的策略与过程所做的一份精确、完整、可验证的描述,其最根本的规定性在于:它不包含任何自动化技术。语义模型中不应当出现编程语言、数据库、网络、用户界面、文件格式、并发机制等任何实现层面的概念,它描述的是“业务是什么”,而不是“软件怎么做”。
为了强调这一特征,模型驱动架构的术语体系把它命名为计算无关模型(Computation-Independent Model,CIM):在建模的这一阶段,计算被刻意地、彻底地“抽离”了,建模者仿佛不知道未来会有一台计算机去执行它,而只是在刻画业务世界本身的运行规则。这与后来出现的平台无关模型(Platform-Independent Model,PIM)和平台特定模型(Platform-Specific Model,PSM)形成对照:CIM 纯粹描述问题,PIM 在 CIM 之上叠加少量类型与算法提示但仍不绑定具体平台,PSM 则面向特定技术平台。CIM 是整条链的源头,它的纯粹性决定了整个工程链条的可靠性。语义模型不是一份供人阅读的模糊文档,而是一套具有形式化基础、可被精确解释的模型,严谨程度介于自然语言规格与完全形式化的数学证明之间,常被称为“半形式化”方法:它既足够严谨、每个元素都有唯一精确含义以支持模拟与机械推导,又足够直观、业务专家经短暂培训就能读懂。
二、为何不能从语义模型直接生成代码
一个自然的疑问是:既然语义模型如此精确,为何不能直接由它机械地生成可执行代码?这有两个根本性原因,它们恰恰界定了语义模型与设计的边界。其一在于属性的值域:模型中“余额,以美元计、精确到分、下限为负的透支限额”这一描述,无法机械决定用何种语言的何种数据类型实现,这一选择依赖语言、平台与性能权衡,是典型技术决策。其二在于行为规约:模型中的动作用契约、伪码或抽象动作语言表达,描述“做什么”与“保证什么结果”而非“按什么步骤做”,契约是说明性的(declarative),它陈述结果而非算法。把非算法的规约转化为算法,需要技术判断。
这两个原因共同说明:语义模型有意保留了技术上的不确定性,把这些决策留给设计阶段。它的价值不在于能直接被执行,而在于精确锚定了“意义”,使后续无论技术映射如何展开,都有一个可以对照的、不变的基准。
三、四种视图与它们的科学基础
一个复杂的问题域无法被单一视角完整把握。语义模型因此采用四种相互补充的视图,每一种回答一类特定问题,四者合起来才构成对问题域的完整刻画。用例视图(use case view)划定系统的边界与上下文,回答“系统与世界如何交互”;类视图(class view)描述问题域中静态的结构——有哪些类、具有哪些属性、彼此之间存在何种关联与泛化,回答“业务世界由哪些事物构成、它们如何分类与关联”;交互视图(interaction view)描述在完成具体事务时信息如何在对象之间流动,回答“一个事务发生时谁向谁提供了什么信息”;状态视图(state view)描述对象在其生命周期中的动态行为,回答“对象在时间中如何演化”。
这四种视图并非随意的图形约定,它们各自有坚实的科学根基。类与属性的底层形式化是集合论与关系代数:每一个类是一个具有外延(当前存在的成员)与内涵(所有可能存在的成员)的集合,类对应关系、属性对应列、对象对应行;状态、事件与迁移的底层形式化是有限自动机理论;属性值域的底层形式化是测量理论,它区分名义、顺序、间隔、比例四种尺度并规定不同尺度上合法的运算——名义尺度只能判等、顺序尺度可排序、间隔尺度可加减、只有比例尺度才能乘除。这些形式化基础保证了语义模型不是一堆直观图表的拼凑,而是一套具有内在数学一致性的语言。
四、建模的语言:两种方言
承载语义模型的图形语言,是统一建模语言(Unified Modeling Language,UML)。UML 于 1997 年由对象管理组织(OMG)标准化,整合了此前面向对象建模的多种方法,提供了一套国际通用、工具支持广泛、表达能力足够的图形词汇。一个关键的方法论决定是:同一套 UML 符号在不同阶段以不同“方言”使用。语义建模阶段使用需求方言(analysis dialect)——属性只写业务意义与值域,绝不写 int、String 等语言类型,关联表达业务对象间的真实关系而非外键或指针,图中不出现纯技术辅助类;设计阶段则切换为设计方言(design dialect),此时才出现类型化的实例变量、操作签名、依赖与可见性标记。同一种图形语言、两种严格区分的用法,防止了最常见的错误:让技术考量过早渗入语义模型、破坏其纯粹性。
UML 还提供若干通用机制:注释(note)补充文字;约束(constraint)以花括号表达图形无法表示的规则,如 {complete}、{disjoint};构造型(stereotype)以双书名号标记对标准元素的特化,如 «key»、«new»;包(package)组织大型模型;依赖(dependency)表达引用关系。
五、自动化边界与缩减模型
语义模型的范围还需要一道明确的边界:自动化边界。并非业务世界中的一切过程都将由软件实现,系统分析师必须区分哪些用例最终由系统自动执行,哪些仍由人工或外部系统完成。为此,模型在不同语境下呈现为两种形态:缩减模型(reduced model)只刻画那些将被自动化的部分,它是后续设计与实现的直接依据;扩展模型(expanded model)则把人工环节与外部系统也作为参与者纳入,描述完整的业务过程。缩减模型边界内的每一个用例才需要完整的设计与实现,而边界之外的参与者则通过明确的接口规格与系统交互。
语义模型还天然服务于产品族。
在真空般纯粹的语义模型中,第一个需要刻画的是系统与外部世界的交界:边界从哪里划定、外部有哪些参与者、哪些业务事件触发响应、如何把大型系统分解为可分别征服的主题域——这是语义建模的第一步。
第五章 边界与事件:用例视图与域的分解
一、用例:划定范围与上下文
语义建模的第一种视图回答一个看似简单、实则决定性的问题:系统的边界在哪里。用例图(use case diagram)用两个最基本的概念来回答它。用例(use case)表示系统内部的一个连贯功能单元,它界定了系统的范围(scope);参与者(actor)表示系统外部、与系统交互的人、其他系统或外部设备,它界定了系统的上下文(context)。
Alistair Cockburn 对用例的抽象层次提出了一个被广泛采用的三分法:天空高度、海平面高度与泥层高度。天空高度是高度概括的宏观目标,泥层高度是极其细碎的界面操作。真正适合作为建模工作单元的,是海平面高度的用例——它对应一个完整、原子的事务(transaction),要么全部成功、要么全部失败,具有明确的商业意义。
二、业务事件的四种类型
用例由业务事件(business event)驱动。要完整刻画系统行为,必须穷尽其可能遭遇的事件,而事件的分类本身就是一种防止遗漏的检查清单。业务事件可分为四类:第一类是不可预测的外部事件,由外部参与者随机发起;第二类是定时事件,由时间的到达触发,不依赖任何外部动作却必须被系统响应;第三类是后继事件(或称必然事件),由前一个事件的完成自然引发;第四类最容易被忽略,也最能体现建模的严谨——“未发生”事件(nonoccurrence):某个预期的事件在规定时间内没有发生,如支付在授权后三十分钟内未完成、心跳信号缺失,这些“什么都没发生”本身就是一种需要响应的事件。传统系统中大量的超时、挂起与僵死故障,根源都在于未发生事件没有被显式建模。事件还可按其性质分为事件用例与数据用例:事件用例导致系统状态发生变化,数据用例则只读取信息而不改变状态,这一区分在设计阶段会直接影响信息流的方向与操作的归属。
三、只建模信息净流
用例图与交互图共同遵守一条极重要的纪律:只建模信息的净流(net information flow),而绝不预设信息是被推送还是被拉取。推送是生产者主动把信息发给消费者,拉取是消费者主动向生产者索取,二者在实时性、耦合度、资源占用上各有取舍,属于典型的技术决策。语义模型只回答“谁需要谁的什么信息”,而不回答“谁先发起调用”,因此交互图中两个对象之间只画一条携带信息的箭头,标明流动的是什么数据或事件,却不标明推送还是拉取。把这一选择推迟到设计阶段,使语义模型能够同时容纳多种实现可能,避免过早被某种技术模式锁死。用例之间还存在三种结构化关系:包含(include)表示一个用例必然调用另一个用例的步骤;扩展(extend)表示在特定条件下可选地插入额外步骤;泛化(generalization)表示多个用例共享一个抽象父用例,参与者本身也可泛化。这些关系若被滥用会使模型迅速膨胀、晦涩,只应在确实减少重复、澄清共性时使用。此外,还需要为那些不直接服务于业务过程、但系统运行所必需的照管性动态(housekeeping dynamics)建模,典型的是对基础数据的增删改查(CRUD,即创建、读取、更新、删除),如维护商品目录、管理客户信息、配置系统参数,它们不应被遗漏,也不应与核心业务用例混为一谈。
四、域:大型系统的第一道切分
当系统规模增长,单一的语义模型会超出人脑的处理能力。应对之道是按主题域(domain)对系统进行分解,域是问题域中一组紧密相关、共享同一套核心概念与规则的部分。以在线书店为例,可自然划分出订单履行、支付、可伸缩性、高可用、用户安全等域,分别处理订单库存发货、交易授权结算、负载增长下的性能、故障检测恢复、身份认证与权限。每个域都有相对独立的概念体系与业务规则,域之间通过明确的参与者接口通信。域划分的关键纪律是:一个非参与者类必须唯一地归属于一个域,同一个类不能在两个域中被分别定义,否则便埋下不一致的种子;当一个域需要引用另一个域的功能时,被引用的域在引用方的模型中只作为参与者出现,其内部细节对引用方透明。
域分解并非任意的切割,它遵循一条深刻的判据:在语义建模阶段,按通信的“种类”划分;在设计阶段,可进一步按通信的“频次”划分。语义层面的域,是根据对象之间需要交换哪些不同性质的信息来划分的;设计层面的子域划分,则可在语义边界的基础上再根据通信的频率与性能代价细分。
五、子域:当类的数量越过认知阈值
域还可能过大,需要继续分解为子域。实践中存在一个经验性的认知阈值:当一个语义模型中的类增长到约四十个时,模型中会出现一条自然断裂线——线的两侧类关联稀疏,线的内部关联密集,沿此线划分就得到规模适中的子域。四十这个数字反映了人类工作记忆对复杂关联网络的处理上限:顶尖开发者在头脑中可隐式管理约十二个类,普通开发者平均约七个;四十左右的子域规模恰好把每一块压到人能整体驾驭的范围。配合自动化边界,一个庞大系统被逐级分解为若干语义内聚、规模适中、接口清晰的部分。接下来要逐一深入每个子域,从最核心、最能反映业务世界构造的视图开始——这就是描述静态结构的类模型。
第六章 结构的秩序:类模型、值域与规范化
一、类、属性与关联
类模型是四种视图中最核心、信息密度最高的一种,它凝固了问题域的静态结构。一个类,是对一组受相同策略、过程与约束支配的事物的抽象;它并非程序员头脑中的代码模板,而是业务世界中一类真实事物的形式化描述:一个类是一个集合,其外延是当前存在的全部成员,其内涵是所有可能存在的成员,类模型的底层形式化是集合论与关系代数。
寻找类的过程,是对问题域进行概念提炼的过程。候选类大体有七种来源:参与者、物理对象、概念性事物、角色、规格说明、事件,以及关联(当两种事物之间的关系本身需要携带信息时)。
类具有属性(attribute),属性是为支持某个过程而需要记录的、关于对象的一类事实,作用于实例层面,语义模型中不允许静态的类属性。对象之间通过关联(association)连接,它表示对象间的成员映射。
二、值域:四式规定
语义模型中属性最容易被做坏、也最能体现纯粹性的地方,是它的取值范围。语义模型严禁用 int、String、double 这类编程语言类型来描述属性,因为这些类型混合了技术实现,它要求用四种值域(range)形式来精确规定取值。第一式是带跨度、精度与单位的值域,由上下界、精度与单位构成,例如“账户余额,以美元计,精确到分,下限为负的透支限额”;精度(precision)规定最小有意义的计量单位,单位则把数值锚定到具体测量体系。第二式是枚举值域,取值必须是一个有限的符号集合,例如订单状态只能是若干合法状态之一,在测量理论上属于名义尺度或顺序尺度。第三式是外部引用值域,其合法取值由外部权威来源定义,例如国家代码引用某一国际标准,此时模型应指向该权威来源而非复制其内容,这正是“一处事实、一处存放”的文档原则。第四式是无约束值域,表示业务上不施加任何限制,若实现时有限制,那也是技术权衡而非业务规则。
值域的四式规定看起来朴素,却是消除需求歧义的利器。值域还直接对应测量理论的四种尺度(名义、顺序、间隔、比例,不同尺度上合法运算不同,尺度的正确性将在第八章展开),模型若在错误的尺度上进行运算,语义上就是错的。
三、键与派生属性
类需要键(key)来唯一标识对象。键是一个或一组属性的组合,其值在该类的全部对象中唯一且非空,模型中以构造型 «key» 标记。键不是数据库概念,而是问题域中区分对象身份的业务规则——在一个银行系统中,账号是账户的键;在一个书店中,ISBN 是图书题名的候选键。派生属性(derived attribute)以斜杠标记,表示它的值可以随时由模型中其他信息重新计算,如订单的“总金额”由各订单行金额汇总得出。形式化地说,派生属性并不意味着必须存储的数据,它只规定了因变量与自变量之间的一条一致性关系;是否在实现中存储派生值,是可以在设计阶段权衡的技术决策——按需计算保证一致却消耗计算,更新时计算并存储换取读取速度却要维护一致性,延迟更新则是二者的折中。关键在于,语义模型只规定推导规则,不替设计做选择。
四、规范化:消除结构中的异常
类模型的核心方法论之一,是把数据库理论中成熟的规范化(normalization)思想提升到语义建模层面,其目标是消除由数据结构组织不当所导致的插入异常、删除异常与更新异常。插入异常指某些信息无法在缺少另一信息时被录入;删除异常指删除一个对象时连带丢失本应保留的另一类信息;更新异常指同一事实在多处重复存储、修改一处而忘记其他处,导致数据不一致。经典例子是出版社与图书:若把出版社地址直接放入每本图书记录,删除该出版社最后一本图书时地址也随之消失,迁址时需修改其名下全部记录;解决之道是把出版社独立为一个类,图书通过关联引用它。订单与所购图书之间携带购买数量的“多对多”关系同理,必须提升为关联类“订单行”。规范化理论给出了逐级增强的范式:第一范式要求每个属性取值原子、不可再分;第二范式要求非键属性完全依赖于整个键;第三范式要求非键属性之间不存在传递依赖。Ronald Fagin 在 1981 年提出的域键范式(Domain-Key Normal Form,DK/NF)是理论上可被论证的“终点”:当一个关系中的所有约束都能通过属性值域(域)与键约束表达时,它就不存在由函数依赖、多值依赖引起的任何插入、删除与更新异常。基于模型的方法进一步把范式扩展为域—键—多重性—泛化完整性范式(RKMC/NF),把关联的多重性约束与泛化层次也纳入规范化框架。规范化的实质,是让每一条业务事实都恰好在一个地方被表达一次——它不是数据库设计的技术技巧,而是组织业务知识的逻辑要求,任何被重复表达的事实都是潜在的不一致源。
五、多重性、聚合与泛化
关联的两端需要标注多重性(multiplicity),即一端的对象可与另一端的多少个对象相连接。多重性在语义上对应一条不变式:对任意对象,它通过某关联连接的对象数量必须落在给定的最小与最大值之间。最可靠的标注方法是“锚点分析”:固定关联一端的一个对象,逐一数清它在另一端能连接几个对象。模型中还会出现反身关联(同类对象之间,如员工与经理)、多重关联(同类之间存在多条不同语义的连接)等情形,需谨慎处理。聚合与组合表达“整体—部分”关系:空心菱形表示聚合,部分可脱离整体存在;实心菱形表示组合,部分与整体同生共死,如订单与订单行。
泛化(generalization)即继承,表达“是一种”(is-a)的分类,必须标注为完整(complete)或不完整(incomplete)、互斥(disjoint)或重叠(overlapping)。在语义模型中,泛化层次必须是纯粹无环的层级,子类属性只能增补、不能覆盖;从集合论角度,子类必须构成对父类的划分,因而原则上要求互斥,重叠分类更适合用角色关联表达。
类模型把业务世界的静态结构组织得井井有条,但业务更是在时间中不断流转的过程,要刻画对象在时间中的行为,还需交互视图与状态视图。
第七章 时间中的行为:交互、状态机与完备性
一、交互视图:信息而非控制
交互视图描述一个事务发生时对象之间如何协作。它有两种主要图形:序列图(sequence diagram)以生命线自上而下强调消息的时间顺序,回答“先发生什么、后发生什么”;通信图(communication diagram)把消息以编号标注在关联线上,强调对象之间的连接关系,回答“谁与谁直接通信”。二者语义等价、可相互转换。
交互建模最重要的纪律仍然是只描述信息净流、不描述控制流:图中只画“哪个对象需要从哪个对象获得什么信息”,而不画“谁调用了谁、谁等待谁返回”。同步调用还是异步消息、由谁主动发起、是否需要中介者(broker)等决策全部推迟到设计阶段。交互图的价值在于暴露真实的信息依赖、验证类模型完整性,而非追求图形上的完备。
二、状态模型的基本要素
状态视图是对对象动态行为的严格刻画,其底层是有限自动机理论。一个状态模型由若干要素构成:初始态标记对象诞生时所处的状态;状态是对象行为保持一致与稳定的连续时段;事件是可能改变对象状态的发生;迁移是对象在特定状态下、收到特定事件时、沿特定路径转移到另一状态的过程;守卫(guard)是附加在迁移上的布尔条件,只有当事件发生且守卫为真时迁移才会触发;动作是迁移过程中执行的计算;终态表示对象生命的结束。
一个关键的认知是:状态模型的真正工作量不在于状态的数量,而在于迁移的数量。状态是相对稳定的分类,迁移才是对每一种“状态—事件”组合的具体响应,因此衡量一个类的行为复杂度,应当看它的状态—事件矩阵有多大、有多少非平凡的迁移,而不是简单地数它有几个状态。状态模型还提供了若干处理复杂行为的结构:顺序子状态把一个状态细化为依次经历的子状态;历史态允许对象在被中断后返回到离开时所处的子状态;自迁移是对象收到事件后保持同一状态却仍执行相应动作。这些结构使状态图既能表达复杂流程,又不至于因状态爆炸而不可读。
三、Mealy 与 Moore:动作附着于何处
动作可以附着在不同位置,这对应着自动机理论中的两种经典模型。Mealy 模型把动作附着在迁移上,动作的触发取决于当前状态与输入事件,输出是状态与输入的共同函数,Moore 模型把动作附着在状态本身,进入状态时执行进入动作(entry)、离开时执行退出动作(exit)、处于某状态期间执行状态动作,动作只取决于当前状态,输出是状态的纯函数。
动作以契约(requires-guarantees)、伪码或抽象动作语言表达,规定动作执行前必须满足的前置条件、执行后必须保证的后置条件,以及动作可能向哪些对象发出哪些消息。动作在形式上是纯函数:它本身不记忆跨调用的内部状态,所有持久状态都以属性值、对象存在性与对象间连接的形式存在于对象之中。一个深刻的建模约定是:迁移被假定为在零时间内完成,无论其动作多么复杂,同一迁移上的多个动作默认可并发执行,除非数据流依赖迫使它们顺序执行。这一约定把“行为的逻辑正确性”与“执行的快慢”彻底分开——在语义模型中,任何论证都不能依赖计算速度,性能问题完全留给设计。
四、状态向量与完备性准则
为严谨地判断状态模型是否合理,需要引入状态向量(state vector)的概念:它由能够区分对象行为的全部实例变量构成,一个状态本质上是状态向量在某个取值区间内的抽象——在此区间内,对象对所有事件的反应模式保持一致。状态划分的正确性取决于这一抽象是否恰当:若两个状态向量取值导致对象对同一事件有不同反应,它们就必须被划分为不同状态;反之若所有反应都相同,就不应人为拆分。这使状态划分不再是拍脑袋,而是从行为出发的形式化推导。
状态建模的最高纪律是完备性准则(all-states-all-events):对每一个状态—事件组合都必须明确给出行为。在一个 m 状态、n 事件的对象上,必须显式填满 m×n 个格子,每一格要么是迁移、要么是忽略、要么是不可能。这一准则在安全关键系统中尤为重要:当一个“不可能”的组合在运行时由于缺陷真的发生,系统不应静默继续,而应抛出致命异常、记录错误并进入安全状态,因为“不可能”的出现意味着不变式已被破坏;“忽略”则是设计上确认无害的事件,什么都不做是合法的。区分“我没考虑到”与“我确认它无害”,是成熟模型与潦草模型的分水岭。
五、四种视图的闭环
至此,用例、类、交互、状态四种视图构成了一个自洽的闭环:它们分别从外部边界、静态结构、信息流动、动态行为四个维度刻画问题域。
四者之间存在严格的相互校核关系:用例中的每一个事件,都必须能在某个对象的状态模型中找到对应的迁移;交互图中流动的每一条信息,都必须对应类模型中的某个属性、派生属性或事件;状态模型中的每一个动作,都必须能通过交互图追踪到它所依赖的信息来源与发出的消息去向;类模型中的每一个关联,都必须被至少一个交互过程所使用。这种跨视图的一致性校核,是模型能够被机械验证的基础。四视图合起来,从外部边界到内部结构、从静态组织到动态行为,把一个问题域无遗漏地凝固下来。
模型是精确、完整、彼此一致的,但它仍是“静态的知识”。如何在模型层面就确认它符合业务意图、发现错误?这需要把模型当作可“运行”的机器来检验,也需要把程序构造为可被数学证明的对象——下一章进入契约与正确性。
第八章 契约与正确性:从断言到可证明的程序
一、类不变式:贯穿始终的约束
契约式设计在单个操作层面规定了前置条件与后置条件,而在类的层面,它需要一个更稳定的约束——类不变式(class invariant)。类不变式是对该类每一个对象而言,在任何时刻(严格地说,是在任何操作的边界处)都必须为真的逻辑断言。以一个银行账户为例,类不变式可能包括:余额始终处于合法值域之内;账户状态只能是合法状态集合中的一个;已关闭账户不再关联任何有效交易。这些条件不依赖于执行了哪个操作,而是对象作为合法业务实体的根本规定。类不变式的工程意义在于,它把分散在各操作中的约束提升为类的全局属性,使正确性的论证有了稳定的锚点:一个操作的正确性,现在可以表述为“在操作开始前不变式与前置条件成立的前提下,操作结束后不变式与后置条件成立”。
这一表述形式正是 C.A.R. Hoare 提出的霍尔逻辑(Hoare logic)的核心结构——霍尔三元组。一个三元组写作 {P} S {Q},其中 P 是前置条件,S 是一段程序语句,Q 是后置条件,它断言:若 S 执行前 P 成立且 S 终止,则 S 执行后 Q 成立。在类的语境中,P 是“类不变式 ∧ 操作前置条件”,Q 是“类不变式 ∧ 操作后置条件”。
二、按意图编程:意图—行动—证明—断言
把程序从“调试出来的”转变为“证明出来的”,需要一种具体的编程纪律,可称之为按意图编程(Programming by Intent),其核心循环是四步:意图、行动、证明、断言。意图(intend)是在写下任何实现之前,先以断言形式明确这段代码打算完成什么、假定了什么;行动(act)是写出实现这一意图的代码;证明(prove)是论证这段代码在满足前置条件并终止的前提下确实建立了后置条件;断言(assert)是把论证中的关键命题以可执行断言写入代码,使它们在运行时也能被检查。
这四步的顺序至关重要,它与“先写代码、再补测试”的传统截然相反:断言不是实现之后的补丁,而是实现之前的意图声明。面对分支逻辑,先写下守卫条件,再逐一在每个分支中证明并断言其结论;面对循环,先确定循环不变式——一个在每次迭代前后都保持为真的命题——再据此编写循环体并证明终止。循环是形式化证明中最微妙的结构,因为它的执行次数取决于数据,必须借助循环不变式与一个收敛的界函数(每轮迭代严格递减、有下界的量,用于证明循环必然终止)来论证。
三、断言与防御式编程的谱系
断言(assertion)是一类在运行时被求值、若为假则报告错误的布尔表达式。它与通常的错误处理不是一回事:断言表达“我认为必然成立的命题”,它若失败,说明程序自身存在缺陷,而非外部环境出现可恢复异常。
围绕断言的多与少,存在一个防御式编程的谱系。一端是“信任契约”:严格遵循契约式设计,调用方负责保证前置条件,被调用方不重复检查,代码简洁、责任清晰;另一端是“全面防御”:每个操作都在入口处重新检查所有可能被违反的条件,即便按契约应由调用方保证。
四、从断言到正确性证明
断言的系统化运用,最终通向完整的正确性证明。证明一个程序正确,不是对它进行多次测试,而是在每一个控制结构上应用逻辑推理,从前置条件出发,通过语句序列逐步推出后置条件。对于顺序语句,推理规则是把前一句的后置条件作为后一句的前置条件,形成推理链;对于条件语句,需分别证明两个分支,在“前置条件 ∧ 条件为真”下成立一条结论、在“前置条件 ∧ 条件为假”下成立另一条,二者的逻辑析取必须蕴含目标后置条件。对于循环,必须建立循环不变式:证明初始化使不变式成立、证明每轮迭代在不变式成立的前提下执行循环体后不变式仍成立(归纳步骤)、证明循环在不变式成立且循环条件为假时退出而此时恰好蕴含目标后置条件。这正是数学方法相对经验测试的决定性优势:测试以有限样本推断无限情形,永远留有缺口;证明以归纳与演绎覆盖全部可能,得到确定性结论。
五、度量理论:让值域名副其实
正确性不仅是逻辑的,也是量纲的。形式化严谨性要求回到测量理论,因为属性值域的正确性,本质上是测量尺度的正确性。测量理论区分四种尺度:名义尺度(nominal)只用于分类与判等,如性别代码;顺序尺度(ordinal)允许排序但不表示等量间隔,如优先级;间隔尺度(interval)有等距刻度但零点任意,可加减而不可乘除,如摄氏温度;比例尺度(ratio)有绝对零点,可做全部算术运算,如金额、重量、时长。
语义模型中每一个带跨度的值域都必须判断它在何种尺度上,这直接约束了合法运算:在名义尺度上做除法、在顺序尺度上求和、在间隔尺度上计算比例,都是语义错误,即使程序在语法上完全正确。通过在值域上明确尺度,语义模型把“量纲正确”这一在物理工程中被视为天经地义的要求第一次引入了软件。
六、公理化与构造性之争
形式化方法存在两条路线:公理化路线试图为系统建立一套完整的数学公理,全部行为都从公理推导,追求封闭严密的演绎体系;构造性路线不预先给出全部公理,而是通过构造一个模型,使其行为恰好满足业务意图,模型本身既是规格又是推理对象。
语义模型采取构造性立场:它不追求用谓词逻辑把一切公理化,而是通过类、状态、迁移、契约这些半形式化的构造,把业务语义“做出来”。它划界的判据务实而可操作——是否引入了自动化技术,这一点将在方法论的诘难中再次展开。
契约、不变式、霍尔逻辑、归纳证明、度量理论,共同构成语义模型严谨性的支柱,使模型成为可逐步推理、逐处验证、逐项断言的对象。但完整证明工业规模系统的每一行代码仍成本高昂,工程上还需要一套可操作的验证与确认方法,用合理成本在模型阶段发现尽可能多的错误。
第九章 验证与确认:评审、模拟与测试的经济学
一、两个不同的问题
工程界常把质量检验笼统地称为“测试”,但在模型驱动的语境下,首先要区分两个性质迥异的问题:验证(verification)与确认(validation)。验证问的是“我是否把产品正确地建造出来了”,即建造过程是否忠实于规格;确认问的是“我是否建造了正确的产品”,即规格本身是否符合真实的业务意图。
在传统流程中,这两个问题被推迟到交付前后才暴露,此时无论哪种错误都代价高昂。语义模型的巨大价值,在于它把验证与确认都提前到了代码之前:模型是可阅读、可评审、可模拟的,因此在投入任何编码之前,业务方就能确认“这是不是我们要的东西”,建模者就能验证“四种视图是否彼此一致、是否完整”。缺陷被消除的时点由此从下游测试推移到上游建模,这正是缺陷经济学中最划算的移动。
二、同行评审:三向包围
最简单、也最被低估的验证手段是同行评审(peer review)。它不需要任何工具,只需让建模者之外的人系统性地审视模型,从三个方向包围建模者:上游、平级与下游。上游评审者是业务方与需求提供者,最有资格确认模型是否忠实于业务意图、是否遗漏策略,是确认的主力;平级评审者是其他建模者,从方法论一致性、四视图完整性、规范化质量等角度审查;下游评审者是后续把模型映射为设计与代码的设计师、开发人员,他们最关心模型是否精确到足以指导实现,任何含混之处都会在其工作中暴露,因而最有动力找错。三向包围之所以有效,是因为不同角色的知识盲区恰好互补:业务方懂策略却不懂建模形式,建模者懂形式却不懂深层业务,下游人员懂实现约束却不掌握全部策略。
三、模拟执行:在纸面运行模型
语义模型不仅可以被阅读,还可以被“执行”。模型模拟(simulation)是一种极其强大的确认手段,它让模型在没有任何代码的情况下像一台机器逐拍运行,从而暴露逻辑上的矛盾与遗漏。最简单的工具是“令牌便签”法:为每个对象准备一张记录关键属性与当前状态的便签,建模者扮演事件源依次宣布业务事件,推动对象迁移、更新便签;另一种是表格法,用一张状态—事件矩阵和一组属性值表,逐行记录每个事件后对象的完整状态。无论哪种方式,模拟都必须有真实的业务方在场观察,因为只有他们才能在模型“运行”出违反直觉的结果时,立刻指出这是模型错了还是他们自己的认知有盲区。
模拟的力量在于它把抽象图形转化为具体、可见的时间序列,例如逐拍推演订单打包流程,会立刻暴露出“最后一个商品到货时此前等待的订单如何被唤醒”“同一库存被多个订单竞争时如何分配”这类静态图中难以察觉的动态问题。模拟是模型的“风洞”,以极低成本提供接近真实运行的反馈。
四、从模型派生测试
语义模型不仅是设计的源头,也应当是测试的源头。传统测试的设计依赖于测试者的经验与想象,覆盖面参差不齐;而一个完整、精确的模型为测试用例的系统派生提供了依据。从状态模型可以直接派生测试:每条迁移至少需要一个用例验证,每个守卫条件的真、假两个分支都要覆盖,每个“不可能”的状态—事件组合都需要一个负向测试以验证系统是否如预期拒绝或进入安全状态;从属性值域可以派生边界测试:数值型值域的上下界、紧邻上下界的值、精度边界都要测试,枚举值域的每个合法值与非法值都要测试;从关联多重性可以派生测试:对“0、1、多个”以及恰为最小值、最大值、越界值的情况分别测试;从交互信息流可以派生定义—使用(def-use)测试,验证每条信息在被使用前都已被正确定义。
测试由此被分为黑盒与白盒、正向与负向:黑盒测试基于外部行为、不关心内部结构,白盒测试基于内部逻辑、覆盖特定分支与路径;正向测试验证系统在合法输入下做对了该做的事,负向测试验证系统在非法输入或异常状态下优雅地拒绝、报错或自保,而不是崩溃或产生错误结果。一个完整的模型天然规定了这两类测试的范围,使测试设计从依赖灵感的手艺,转变为可系统推导、可度量覆盖度的工程活动。
五、风险驱动的测试经济学
即便测试可以从模型系统派生,工业规模系统的测试组合仍然是无穷的,必须回答“测试到什么程度才够”。这本质上是一个经济学问题:测试的成本近似随覆盖的深度线性增长,而缺陷被遗漏的风险随覆盖深度呈非线性下降,理性的策略是在风险与成本之间寻找最优点。
风险可以分解为两个因子:缺陷发生的概率与一旦失效造成的损失。这一思想把“责任暴露”(maximum liability)作为决策依据:系统在最坏情况下需承担多大责任,决定了它应被验证到什么程度。任务关键与安全关键系统责任暴露极高,应当承受完整的模拟、形式化证明与穷举式测试;许多普通业务系统的最优策略,则是用相对少的测试覆盖高风险路径,把资源留给更有价值的地方。
六、对验收测试驱动的批判
需要澄清模型驱动与以测试为中心的开发方法(如验收测试驱动开发,ATDD)的差异。ATDD 主张在编码前先写验收测试,把可执行测试作为事实上的规格。这比“先编码后测试”前进了一步,但有根本性的表达力缺陷:测试用例是具体、有限的实例,而规格是普遍、抽象的规则;用有限实例表达普遍规则,必然需要大量用例,且永远无法证明“没有遗漏的情形”,测试用例还混合了业务规则与测试数据,读者难以从中抽象出完整语义。
语义模型由此被建立、验证、确认、模拟并用来派生测试,它是一份精确、完整、无歧义、经过检验的业务规格,但仍不是可执行的软件。接下来的问题是:如何把这份纯粹语义映射为特定技术下的设计与代码?这是一条从“是什么”通向“怎么做”、严格保持意义的工程链条。
第十章 映射的展开:从语义模型到高层设计
一、设计的本质:表示意义
越过语义模型这道边界,工程进入了设计阶段。语义建模关心的是“意义”,技术无关;设计关心的是“如何表示意义”,技术相关,并且明确关注资源效率。设计的输入是语义模型与接口规格,输出是一套可以在特定自动化技术上实现的方案。设计不是自由创造,而是一次有约束的映射:源端是被冻结的语义,目标端是由非功能需求与软硬件环境所界定的技术空间。
这一阶段首先要选定自动化技术——编程语言、数据库、中间件、运行环境,然后明确自动化边界内的全部内容。设计可分为两类:外部接口设计与内部结构设计,前者处理系统与真实参与者(人、设备、其他系统)的交互,后者处理语义模型在计算机内的组织方式。需要强调的是,从语义模型到设计的映射路径并非唯一,甚至不一定要面向对象:这一章所描述的是一种系统、可复现的派生过程,它首先是一个“存在性证明”:证明从语义模型到设计的映射可以被严格地、一步一步地完成,而不是只能依赖灵感的黑箱。
二、模型—视图—控制器及其扩展
内部结构设计的自然起点,是模型—视图—控制器(Model-View-Controller,MVC)这一经典架构风格及其变体。MVC 把软件分为三个职责清晰的区域:模型区(Model)承载业务状态与规则,直接由语义模型派生;视图区(View)负责把信息呈现给外部参与者;控制器区(Controller)负责解析外部输入、协调模型区处理。实际工程中视图与控制器常常紧密耦合,因而常被合并称为视图—控制器区。当系统规模扩大,会自然涌现第四个区域:基础设施区(Infrastructure),它提供执行环境本身不具备、但多处共同需要的通用服务,如配置启动、错误日志、进程间通信、多线程、数据分发、定点数学等,既不属于业务语义也不属于具体界面,而是支撑应用运行的技术底座。
四个区域的派生来源各不相同,这是模型驱动设计的清晰之处:模型区由语义模型机械派生,每个语义模型中的类都对应模型区的一个设计类;视图—控制器区由外部接口规格派生,实现与参与者交互的界面;基础设施区按非功能需求与技术环境按需引入。这种来源的分离,使设计的每一部分都能追溯到明确的上游依据,而非设计师的即兴发挥。
三、类与操作的派生
从语义模型派生高层设计,有一套确定的步骤。第一步,为语义模型中的每一个类——包括参与者——在模型区建立一个设计类,并把语义模型中的泛化转化为设计中的继承。第二步,为语义模型中的每一个关联在设计中增加相应的设计元素(关联在设计中往往通过引用来实现,但在最初的高层设计中先作为占位,过早决定其实现方式会诱发过早优化)。需要小心的是,关联类不应被重复生成——如果一个类已经在第一步中由关联类视角生成,就不要再为它的关联视角生成第二个类。第三步,把语义模型中的消息转化为设计类上的操作,这是设计与建模最关键的区别:语义模型只描述信息流动、不描述控制流,而设计必须决定控制流,即每一次通信由谁发起、谁提供服务,这就引入了客户—服务器(主—从)关系:客户决定通信何时发生,服务器在其接口上提供一个供客户调用的操作。
语义模型中的任何信息流,无论事件还是数据,都可被实现为推送或拉取。推送时,信息生产者是客户、消费者是服务器,生产者主动调用消费者的操作;拉取时,消费者是客户、生产者是服务器,消费者主动索取。这一选择产生组合爆炸:若有 n 个相互通信的类,高层设计的候选方案数量级约为 2 的 n 次方。实践中两条启发式规则能大幅收敛设计空间:推送事件、拉取数据——事件的发出方作为客户把事件推给无法预知其何时到来的响应方,数据则由持有方作为服务器、需要方主动拉取。
四、动态分类的处理
设计阶段必须处理语义模型中的动态分类,而主流面向对象语言(Java、C++、C#)不支持对象在运行时改变所属子类——一个对象一旦实例化为某个具体子类,终其一生都属于该子类。这给“计时工转为受薪工”这类同一对象改变类别的情形制造了障碍,有两种标准处理方式。第一种是塌缩继承层次:把所有子类吸收进父类,用一个“类别”属性区分行为,它简单但子类差异繁多时会膨胀。第二种是状态模式(State pattern,又称委托模式):把父类中不变的属性实现于基类,把随类别变化的属性与行为实现于一组可替换的委托子类,基类对象在需要类别相关行为时把请求转交给当前委托对象,类别改变时只需替换它持有的委托引用。状态模式把动态行为干净地隔离到可替换的委托中,是更符合开闭原则的方案。
五、操作签名与契约
高层设计确定了类与操作的骨架,紧接着要为每个操作规定签名与契约。签名是操作的语法:操作名、输入参数及其类型、返回值及其类型、可能抛出的异常;契约是操作的语义:由语义模型中的动作契约派生的前置条件、后置条件与类不变式。前者内聚、可读,后者在某些通信机制(如远程过程调用)中更高效。这一取舍清楚地表明:控制流与数据流是两个不同的概念,理解这一差异才能打开设计空间。
高层设计目前是逻辑设计而非物理设计:类与操作尚未被分配到处理器、进程、线程等计算单元。这套最初的设计是在“最简单、最直接”原则下得到的第一次近似,它首先追求正确与可理解,而非性能;当真实的非功能需求——尤其是严苛的性能约束——被提出时,还需要一套在不改变语义的前提下改善实现效率的方法,这就是优化。
第十一章 优化:语义保持下的变换
一、优化的位置与戒律
优化是在高层设计完成、正确性已经确立之后才被允许的活动,这条次序不可颠倒。模型驱动的方法把这一纪律制度化:首先用最简单、最直接的方式完成一份正确的设计,使它在逻辑上忠实于语义模型;只有当真实测量表明非功能需求无法满足,或进一步优化有明确的投资回报时,才着手优化。
优化还有一条不可逾越的戒律:一切优化都必须是语义保持的变换(semantic-preserving transformation)。优化可以改变数据的组织方式、改变计算的时机、改变操作的物理位置,却绝不允许改变对外可观察的行为;变换前后,系统对任意合法输入必须产生相同的、满足契约的结果。这一戒律把优化与“改逻辑”严格区分开来:优化是在同一语义的不同实现之间做选择,而不是对语义本身的修正。语义模型因此成为优化的试金石——任何优化都必须能够被论证为与语义模型保持一致,否则它就是在偷偷改变业务,是不被允许的。
二、使用矩阵:用数据决定优化
在优化之前,首先要知道时间究竟花在哪里。任何凭直觉的优化都是投机,真正的工程优化必须基于剖析与测量。在模型层面,一个有力的分析工具是使用矩阵(usage matrix):它的行列分别是类与操作,矩阵单元记录每一个操作在典型事务中被调用的频次,以及它们所访问的属性与关联。
三、水平切分与垂直切分
在使用矩阵的指导下,结构优化的基本操作可归纳为切分与合并:切分把过于庞大、承担多种职责的设计元素拆开,合并把总是一起使用、分开反而增加开销的元素收拢,二者看似相反,实则都是让物理结构更好地匹配实际使用与部署。垂直切分沿职责维度把一个类或模块拆开——把高频访问与低频访问部分分离、稳定核心逻辑与易变技术接口分离、业务计算与数据持久化分离;水平切分沿数据或负载维度,把同一类的大量对象分布到不同计算单元或存储分区,合并则用于相反情形:当两个类通信极其频繁、分离开销超过分离带来的清晰度收益时,可在设计中合并。切分与合并的依据始终是使用矩阵与非功能需求,而非个人偏好。
四、逐级下沉的优化手段
优化手段可按与语义模型的距离由远及近组织为若干层次。第二层是持久化与查询优化:把类与关联映射为关系数据库的表与外键,通过 SQL 让数据库管理系统承担集合运算,并用索引、查询计划、反规范化等手段优化访问。需要强调的是,反规范化(有意引入冗余以减少连接)是语义保持的实现选择,与语义模型的高度规范化并不矛盾——语义模型保持“一处事实、一处存放”的纯粹逻辑,物理设计为性能在受控范围内引入冗余并用派生一致性约束维护,二者处于不同层次。第三层是运行时与语言优化:选择更贴近硬件的语言、调整内存管理策略、用表驱动解释器代替长链条件判断;第四层是硬件优化:把类与操作分配到多核、多处理器或专用硬件以提高并行度。这些优化越往下越接近技术、越远离语义,但都必须在语义保持的前提下进行。
五、表驱动:把行为编码为数据
在状态密集的系统中,有一项特别能体现模型驱动思想的优化:把状态模型从一长串条件判断,转化为一张由数据驱动的状态—事件矩阵。直接实现会为每个事件生成一长串 if-then-else 或 switch-case 依据当前状态逐一判断,状态与迁移很多时既冗长又低效。更优的做法是把每个“状态—事件”组合应执行的动作与目标状态预先填入一张二维表,再用一个短小的表解释器在运行时查表执行:根据当前状态与事件定位表项,若该表项是忽略则什么都不做、是不可能则按异常处理、是迁移则执行关联动作并更新当前状态。表驱动的精妙之处在于它把原本写在控制流中的行为重新编码为数据:状态越多、事件越多优势越明显——解释器只有一份,行为复杂度全部沉淀在表中;在模型编译语境下,这张表可由模型编译器从状态模型机械生成,既保证语义严格一致,又获得紧凑、可预测的执行性能。这预示了下一章:当优化与映射的规则可被系统化表达,它们就能从人的手工操作转变为机器的自动执行。
六、结构复杂度的度量与管控
优化不仅是提升性能,也包括控制结构复杂度。在设计与代码层面,偶然复杂度有一系列可度量的代理指标:圈复杂度度量方法内的执行路径数;判定嵌套深度度量判定之间的依赖;参数数量度量接口复杂度;扇出度度量模块的耦合广度。它们都与缺陷密度强相关,应持续测量并用绿、黄、红三色区间管理:绿色无需干预,黄色须附带必须如此的理由否则重构,红色在任何情况下都不可接受。
优化的全过程,从使用矩阵到切分合并、从算法到硬件、从条件判断到表驱动,始终遵守同一准则:语义不变、表示可变。当这些映射与优化规则被充分形式化、积累到足够丰富时,它们便可被提炼为一套可自动执行的机器,让机器承担从模型到代码的机械劳动。这便是模型编译器,是整个范式在工程自动化上的高峰。
第十二章 自动化的高峰:模型编译器与语言的演进
一、编程语言演进的梯子
要看清模型编译的历史位置,需要把它放在编程语言抽象层级不断抬升的长梯之中。这架梯子的第一级是机器码,程序员直接书写二进制指令;第二级是汇编语言,以助记符代替二进制,把最机械的翻译交给汇编器;第三级是 FORTRAN、COBOL 等高级编译语言,程序员第一次用接近数学与业务的表达式书写,编译器负责翻译;第四级是现代语言及其庞大的类库,用面向对象、异常、泛型、自动内存管理与丰富的库封装了更多底层细节。模型驱动方法断言:可编译的语义模型正是这架梯子的下一级,人将不再直接编写面向具体平台的代码,而是编写与技术无关的语义模型,由模型编译器把它翻译为可执行程序。
二、开放与封闭的模型编译器
模型编译器有两种根本不同的形态。封闭模型编译器把翻译规则固化在工具内部,使用者只能通过有限选项影响结果;当生成的代码不满足性能或平台要求时,往往只能在生成代码之外打补丁,而一旦手工修改生成代码,模型与代码的一致性便被破坏。开放模型编译器则把翻译规则本身交给使用者,其核心哲学是:如果你不喜欢生成的代码,不要去改代码,而去改编译器里的规则。翻译规则以可读、可改的产生式形式暴露,使用者可以像修改配置一样修改映射方式。这一思想由 Shlaer 与 Mellor 开创,它从根本上改变了人与代码的关系:可被优化与维护的不再是成千上万行应用代码,而是数量有限、可复用的规则。
三、CIM、PIM、PSM 与叠加层
模型驱动架构把模型分为三个层次,这一区分是理解模型编译的关键。CIM(计算无关模型)即前述纯语义模型,不含任何技术信息;PIM(平台无关模型)是在 CIM 之上叠加了少量类型与算法提示、但仍不绑定具体平台的模型,这些叠加内容被组织为一个独立的叠加层(overlay),它与 CIM 严格分离;PSM(平台特定模型)则面向某个具体平台,包含最终生成代码所需的全部技术细节。
这一分离的工程价值极其深远。这正是产品族工程的理想形态:一个语义,多个平台;核心业务逻辑只写一次,技术变体通过叠加层表达。PIM 叠加层正是弥合 CIM 有意不回答之技术问题的地方,正因如此,模型编译的产物中约八成代码可直接源自语义,剩余约两成是“显著的 PIM 内容”,需要人为提供技术提示。
四、动作语言:让行为可执行又可移植
CIM 中的动作最初以契约与伪码表达,它们说明“做什么”而不规定算法。为了让动作既能被模型编译器处理、又能保持平台无关,需要一种专门的抽象动作语言,这正是 UML 动作语义(Action Semantics)的目标。动作语义是抽象的、计算完备的、在概念上支持并发的,它描述对象的创建与删除、属性的读写、关联的建立与解除、事件的发送等语义动作,却不涉及任何具体语言的语法。历史上出现过多种动作语言,它们共享相同的语义内核;对象管理组织最终于 2013 年采纳 Alf(Action Language for Foundational UML)作为标准化动作语言。有了动作语言,模型中的行为就不再是不可执行的文字,而是可被编译器解释、可被映射到任意语言的中间表达。
五、产生式规则、框架与机制
一个开放模型编译器的技术内核由三部分构成:产生式规则解释器(production rule interpreter)、框架(frames)与机制(mechanisms)。规则可递归引用其他规则——生成一个类的规则会调用生成属性、生成状态枚举、生成事件方法的子规则。一个简单的模型编译器可能需要约六十条规则,要生成工业级高效代码的复杂域则可能膨胀到三百条以上;规则的遍历顺序也有讲究,深度优先单遍与广度优先多遍适用于不同的代码组织需要。
框架(frames)是编译器为特定结构(类、关联、状态机)提供的代码模板与支撑结构,规定生成代码的骨架;机制(mechanisms)是可被生成代码复用的底层服务,如对象查找、事件队列、内存管理、持久化、关联导航。机制是运行时基础设施,生成的代码调用机制完成通用任务,避免为每个类重复生成同样的底层逻辑。改进机制(如用增量垃圾回收)、改进框架与规则(如把线性查找改为二分查找、长条件链改为查表),构成优化编译器的三个层次。
六、标记与智能规则
开放模型编译器的优化能力通过两种机制表达。所有标记都必须位于叠加层而非 CIM,因为不同平台可能需要不同标记,写在 CIM 中就会破坏一模型多平台的能力。
更复杂的是智能规则(smart rules,或称替代映射规则):标记依赖外部的人决定,智能规则则把决策逻辑编码进规则本身,让编译器在无人干预时自行判断。
七、一个已经兑现的承诺
模型编译并非停留在纸面的设想。自 20 世纪 90 年代起,这一方法就已在高速电话交换、医疗设备等任务关键与生命安全关键系统中实际部署,成功支撑了数百人规模团队、数百万行代码的系统按时按预算交付。这些系统对正确性与可靠性的要求极高,而模型驱动恰好把缺陷消除前置、把实现一致性交给机器,从而在最苛刻的场景中证明了自己。
一个必须澄清的边界是:只有模型区可以由 CIM 自动生成,视图—控制器区与基础设施区仍需人工设计,因为它们与具体技术环境、外部接口紧耦合,难以从纯语义机械推导。这不是方法的局限,而是对“什么是本质、什么是技术”的清醒划分。模型与代码之间“往返工程”的边界也由此确定:设计模型(技术相关)与代码之间可以双向往返,但从代码逆向还原出原始的纯 CIM 在原理上不可能——语义到技术的映射丢失了大量决策信息,被刻意排除的技术因素在代码中已与语义融为一体。这一不对称性再次强调:CIM 是唯一权威的源头,必须被作为第一公民维护。
模型编译器并非孤立的技术奇迹,它之所以成立,是因为前面所有环节都已就位。当方法与工具都已具备,剩下的问题便回到工程组织与经济理性:需要多少人力、如何估算、如何处理错误、如何裁剪过程、如何在最广范围内复用知识。这是把模型驱动方法从技术体系变为工程学科的最后一块拼图。
第十三章 工程经济学:估算、错误、过程与复用
一、工作量的真实分布
一门成熟工程学科的标志,是它能够对一项工作的投入产出做出可靠的预测。软件工程之所以长期不被信任,很大程度上是因为它的估算近乎占卜。模型驱动方法为估算提供了一个理性基础,而这个基础首先来自对工作量分布的重新认识。在传统开发中,编码被误认为是主要工作,排期与人力配置都围绕“写代码”展开;模型驱动的实践给出了截然不同的分布:在一个组织良好的项目中,语义建模约占技术工作量的六成,设计约占三成,编码仅约一成。这一分布与传统直觉形成强烈反差,却完全符合缺陷经济学的逻辑——既然超过半数的缺陷源于需求,而需求阶段的纠错成本最低,那么把最大的智力投入放在语义建模上,正是把资源配置到了杠杆最高的环节。当语义模型被做得足够精确、完整,设计就成为相对机械的映射,编码则在有模型编译器的情况下近乎自动化。
这一分布也重塑了团队的角色配比:理想的配置中,业务分析师与开发者的比例约为六比四,因为语义建模的工作量远超传统估计。分析师不再是需求文档的记录员,而是与业务专家共同构建语义模型的工程师;开发者则把精力集中在技术映射、机制建设与编译器规则上。
二、估算、不确定性与风险
理性的估算不是给出一个虚假的精确数字,而是显式地表达不确定性:工作量与工期应被表示为带有概率分布的区间,而非点估计,随着项目推进、信息明确,区间才逐渐收窄。早期估算的巨大不确定性是客观的,强行要求早期承诺精确日期,是在制造日后的延期与欺骗。估算需要区分名义工作量(不计意外、按理想状态所需)与实际工期(考虑沟通、等待、干扰、返工后的日历时间),二者存在系统性偏差,且偏差与团队规模相关——Brooks 早已指出,向延期项目追加人手往往只会让它更慢,因为沟通与培训成本随人数超线性增长。
是否拥有模型编译器,会显著改变工作量构成。
三、错误处理的经济学
错误应当如何处理,并不是一个纯技术问题,而是一个经济问题。系统在运行中可能遭遇各种错误:外部输入非法、依赖服务不可用、内部不变式被破坏,对每一类错误,都要权衡检测与处理的成本与失效造成的损失。错误分析可由简单到精细分为几个层次:最简单的是“责任暴露”,识别系统在最坏情况下可能承担的最大损失,以此决定整体健壮性投入;更精细的分析针对每个可能的错误点,分别估计错误发生概率、未被处理时的后果、检测与处理的成本,从而决定在入口处拦截、在内部传播、还是直接终止。
一个指导性原则是区分两类错误:契约违约(调用方违反前置条件,责任在外部,被调用方可拒绝并报告)与内部缺陷(本应成立的不变式被破坏,责任在自身,往往意味着继续运行是危险的)。对前者,合理的是返回错误、让调用方处理;对后者,在安全关键系统中合理的是快速失败、记录现场、进入安全状态,而不是带着已被破坏的状态继续运行。错误处理不应是整齐划一的教条:对所有错误都层层防御,会产生大量冗余代码并模糊责任;对所有错误都放任不管,又会在关键路径上酿成灾难。正确的做法是把错误处理的强度与失效的经济后果对齐,这与测试资源的风险分配遵循同一条边际原理。
四、过程的右尺化
模型驱动并不天然绑定某一种过程模型。瀑布式的串行推进与敏捷式的增量迭代常被描述为对立两极,但在模型驱动视角下它们都有适用区间,关键是为具体项目的不确定性与风险“右尺化”地裁剪一个元过程。模型驱动与敏捷并不冲突:语义模型可以增量构建,每个迭代只建模并实现一个子域或一组用例,模型的纪律反而使增量开发更安全——每次变更都首先作用于语义模型、再机械映射到代码,避免无约束重构带来的语义漂移。过程的价值不在于名字,而在于它是否使缺陷被尽早发现、变更被有序管理、风险被持续暴露。
五、复用的层级与源代码神话
复用是软件工程长期追求的圣杯,却长期被误解。一个广为流传的信念是“源代码复用是软件复用的最高形式”,而模型驱动的分析表明,源代码复用恰恰是最脆弱、最受限的一种。它要同时满足三个条件才成立:语义兼容(复用方与被复用代码解决的问题确实相同)、技术兼容(语言、平台、运行环境匹配)、性能兼容(被复用代码的性能特征满足复用方的非功能需求)。三者缺一不可,而现实中它们很少同时成立——
复用性因此呈现出一个清晰的层级:最高的是语义模型的复用,问题域的策略与过程最稳定、最与技术无关,一个产品族共享同一套语义模型,其价值远高于复用代码;其次是设计模式与架构风格的复用,MVC、状态模式等经过验证的设计智慧在不同语言与平台间都可迁移;源代码复用则处于最底层、最受条件约束。自动测试设备(ATE)产品族的工程实践印证了这一点:整个设备族之所以能高效衍生成一系列型号,靠的不是复制粘贴源代码,而是在顶层共享一套语义模型,不同型号在模型层与少量叠加层上分化,再由模型编译器生成各自的实现。
六、设计与代码:同一映射的两种视图
关于复用与文档,还有一个概念需要澄清:设计与代码不是两件事,而是同一次映射的粗细两种视图——设计是抽象层级较高、省略细节的视图,代码是抽象层级较低、包含全部细节的视图,二者描述的是同一个东西;当设计由模型派生、代码由设计派生,它们始终保持一致,不存在“设计过期”的问题。Knuth 的文学化编程(literate programming)是对这一思想的早期探索:在 WEB 体系中,同一份源文件经 WEAVE 生成文档、经 TANGLE 生成可执行代码,从机制上消除了不一致。模型编译把这一理想推到更广层面:语义模型是唯一源头,设计文档、代码、测试用例、自然语言规格都是从它派生的不同视图。
经济学的分析最终回到一个核心问题:软件工程的资源应当投向何方?答案清晰一致——投向语义、投向模型、投向那些最稳定、最能复用、最能前置消除缺陷的环节。然而,一种如此完整、有效、又有数十年安全关键实践背书的方法,为何至今未被整个行业普遍采用?这需要在方法论的最后,直面那些最常见的诘难。
第十四章 范式之争与终极目标:九条诘难与学科的成熟
一、九条诘难的逐一回应
一种范式的价值,不仅要看它主张什么,更要看它能否正面回应实践中最尖锐的质疑。围绕模型驱动方法,长期流传着九条诘难,几乎涵盖了所有犹豫者的疑虑。其一,“没有模型,我们一样能很快交付。”这只在小规模、需求稳的项目上成立,是牺牲可维护性换来的短期速度。其二,“这需要大量前置设计,违背敏捷。”事实相反,语义模型完全可以增量构建,模型纪律恰恰让敏捷增量更安全。其三,“业务干系人看不懂 UML。”模型中全是业务语言,阅读模型只需约半小时培训。其四,“有些人就是只喜欢读文本。”模型并非图形的专利,它可完整序列化为文本(UML 有基于 XML 的交换标准 XMI)。
其五,“模型不过是另一种语言写成的代码。”这需区分层次:PIM 确带技术性质、可视为更高层代码,但 CIM 是纯语义、不含任何技术,二者不可混为一谈;抽象层级的抬升是语言演进的必然,不是换汤不换药。其六,“语义模型其实是设计,不是需求。”这一争论背后是公理化与构造性之争,而划界的实操标准明确:是否引入自动化技术——语义模型严格停留在“没有计算机也成立”的一侧,它就是问题而非解决方案。其七,“没有昂贵工具就做不了。”价值首先在思维方式而非工具,早期用纸笔、白板、通用绘图软件即可建模,工具是效率倍增器而非入场券。其八,“不知道该由谁来建模。”业务分析师、开发者、业务专家都可成为建模者,关键是建立语义模型这一共同语言。
其九,也是最根本的诘难:“既然它这么好,为什么没有被广泛采用?”这从来不是技术有效性的问题,而是无知、文化惰性与竞争沉默的共同结果——成功者出于竞争原因往往不愿公开其核心工程能力,而行业文化对“写代码”的偏爱与对“建模”的轻视,使这一方法长期停留在高可靠性领域,难以扩散到更广泛的商业开发。
二、方法论的科学根基:元模型
要回应“模型驱动是不是只是一堆经验性图形”的质疑,最有力的方式是展示它的科学根基,这可通过“元模型”来呈现——即用语义模型自身来描述语义建模语言。这一自指构造并非文字游戏,它表明语义建模语言本身就是一套严密的、可被建模的对象。元模型中的类、属性、关联、状态、事件、迁移、动作,每一个都被形式化定义:类的底层是集合论与关系代数,类是具有外延与内涵的集合,类对应关系、属性对应列、对象对应行;关联的底层是成员映射;状态、事件、迁移的底层是有限自动机理论;值域的底层是测量理论。元模型还把一系列关键约束固化下来:泛化图必须无环;子类属性只能增补不能覆盖;子类必须构成对父类的集合划分因而要求互斥;对每一个状态—事件组合必须存在一个行为条目,且同一状态、同一事件上的多个守卫必须互斥以保证确定性;动作在形式上是纯函数,所有持久状态都外置为属性值、对象存在性与连接。
这一元模型赋予语义模型“唯一的、精确的含义”:任何人、任何工具读取同一个模型,都依据同一套语义规则解释它,不会因人而异。
三、三大病灶的消解与量化证据
回到引篇提出的三大病灶,可以检验模型驱动方法究竟带来了什么。对“含糊而不完整的需求”,语义模型以四视图、值域四式、完备性准则与形式化约束,迫使需求达到无歧义、可测、有约束力的标准,并可通过模拟与评审被业务方直接确认。对“过度依赖测试找缺陷”,由于缺陷被前置消除在建模阶段,测试人员与开发人员的配比可从主流的约一比四改善到约一比十,测试不再是质量主战场,而是从模型派生的验证手段。对“代码自文档化”的神话,语义模型成为唯一权威的知识载体,代码只是它的投影之一,软件知识不再随人员流动而流失。
这一转变带来的是数量级的经济收益:主流项目返工率常在约六成,模型驱动可将其压到约一成以下;缺陷前置消除、返工大幅减少后,整体成本与工期约可减半;维护成本可下降四到八倍,因为维护者首先面对的是清晰、稳定的语义模型,而非技术与业务纠缠的代码。缺陷在新范式中获得了一个干净的定义:缺陷(bug)就是语义不一致(semantic inconsistency)——实现与模型、模型与意图、或模型内部各视图之间的任何不一致。这一定义把质量问题从模糊的“好不好”转化为可检验的“一致不一致”。
四、航空工程的镜鉴:一次以生命为代价的成熟
要理解软件工程正在经历的转变,航空史提供了一个意味深长的镜鉴。1931 年,一架福克 F-10 三发客机在执行 TWA 599 航班时坠毁,机上包括著名美式橄榄球教练克努特·罗克尼在内的人员全部罹难,原因是木质机翼在长期风雨侵蚀与振动中发生分层、结构失效;彼时飞机结构仍大量依赖木材与织物,设计凭经验与类比,缺乏对材料与应力的精确把握,坠机几乎被视为飞行的固有代价。
这一悲剧成为航空工程走向成熟的转折点:它动摇了公众对木质机翼的信任,推动全金属应力蒙皮结构普及,催生了以道格拉斯 DC-3 为代表、建立在系统应力分析与材料科学之上的新一代飞机。航空工程师由此学会在图纸与计算中、在风洞与模型上预先确定结构安全,而不是在一次次坠落中积累经验,完成了从“在实物上试错”到“在模型上验证”的范式跃迁。软件工程正处在与航空工程当年相似的关口:当软件越来越多地控制着电网、医疗器械、交通工具、金融系统与通信网络,它的失效同样可能危及生命与文明运转,“在代码中反复试错、在现场用事故学习”的旧范式已不再可接受。模型驱动方法所做的,正是为软件提供它自己的应力分析、它自己的风洞——在模型中验证、在映射中保真、在编译器中自动化。
五、终极目标:从私人解谜到公共数学
这门学科的终极目标,可以用 David Parnas 的一句话来概括。他在论及程序设计应当成为何种活动时写道,目标是把程序设计的性质,从一种私人的、解谜式的活动,转变为一种公共的、以数学为基础的活动——把规格翻译为程序,使这些程序几乎无需调试即可预期地运行并做正确的事。
这句话的每一个词都值得反复咀嚼。“私人的、解谜式”描绘了旧范式的本质:代码是个别程序员头脑中的私人谜题,知识不可共享、正确性不可论证、质量依赖个人天赋与反复试错;“公共的、以数学为基础”描绘了工程学科的本质:知识以模型为公共载体、可被评审与验证,正确性以契约、不变式与证明为基础、可被论证,实现是从规格到程序的、可机械执行的翻译,其结果可被预期地信赖。基于模型的软件工程正是通往这一目标的具体道路:它通过语义模型让软件知识成为公共的、与技术无关的资产;通过契约与正确性证明让软件构造成为数学化的活动;通过设计映射与模型编译让“从规格到程序”的翻译成为可自动化、可复用、可信赖的工程过程。
六、结语:一门学科的成年
回望这条从软件危机到语义模型、从契约证明到模型编译、再到工程经济学的完整叙事链,一切共同指向一个朴素而宏大的结论:软件并非注定是一门不可靠的手艺,它可以成为一门真正的工程学科。一门工程学科的成年,不在于它掌握了多少工具,而在于它是否学会了在物理实现之前,先在抽象、可推演的模型中把问题求解清楚,是否敢于把“做什么”与“怎么做”分开、把意义与表示分开、把本质与技术分开。
模型的秩序,归根结底是思想的秩序。当软件工程师不再把混乱的语义与技术一并倾倒进代码,而是先在与技术无关的真空中把业务世界的规则精确凝固为可验证的模型,再让机器忠实地完成那些机械的映射,软件便不再是那个不断制造危机、不断依赖英雄的年轻行当。它将带着属于一门成熟工程学科的尊严,从容支撑起一个日益依赖它的世界。这是一场尚未完成的范式革命,也是软件这门学科真正的成年礼。















































































