生成的设备程序被手工补上一条规则:活跃设备暂时不允许退役。补丁运行有效,但下一次按旧模型生成,规则就消失了。另一种修补方式是从代码读回迁移表,可这张表仍然允许退役,新增限制实际藏在函数分支里。

本篇实际执行这两种失败,再构造模型与代码同时修改同一迁移的冲突。最后把状态名从 ACTIVE 改成 READY,检查旧快照能否继续使用。实验讨论的是有限状态工具的演化,不承诺通用往返工程。

先区分四种会变化的内容

生命周期模型表达状态和迁移;生成逻辑决定如何把它们写成 Python;生成物是某次转换的结果;手写政策表达暂未进入 DSL 的规则。它们有不同的修改入口,不能只靠文件扩展名判断谁应被覆盖。

Fowler 的 Generation Gap 说明通过继承将生成与手写部分分开,以便重新生成。本例沿用第33篇的函数组合:生命周期函数可重建,理由政策保留在独立文件。这个实现借鉴分离原则,没有使用生成基类与手写子类。Generation Gap,作者说明

初始源码由第33篇构建入口产生,仍经过第32篇校验和第31篇平台转换。实验随后只修改本章保存的副本,不编辑被冻结的工具或累计 Java 代码。保存的生成物是反例证据,不是新的正式生命周期规则。

手改确实有效,也确实会丢失

第一项修改在生成文件末尾包装原 step:遇到活跃设备的退役请求就抛出错误。实际加载修改后的文件并调用,结果是拒绝。补丁不只是一个新增注释,也不是检查文件中有没有某段字符串。

随后用原模型重新生成并覆盖该文件,再执行同一操作,结果变为 RETIRED。旧模型没有包含临时限制,生成器自然无法保留它。这里记录的是覆盖前后的行为变化,不能把重新生成后的文件标为“已自动合并定制代码”。

一个保守的保护函数先比较现有文件与上次生成内容的哈希。遇到手改文件,它拒绝写入,并核对文件字节仍保持修改后的样子。哈希在这里是漂移信号,不会解释补丁,也不会决定补丁是否正确。

flowchart TB
    M["旧模型"] --> G["重新生成"]
    EDIT["生成物被手改:禁止退役"] --> HASH["与上次生成哈希比较"]
    HASH --> STOP["不同:拒绝覆盖,保留文件"]
    EDIT --> RAW["直接写入生成结果"]
    G --> RAW
    RAW --> LOST["再次执行退役:成功<br/>手写限制丢失"]

第一张图的两条路径都在实验中执行。保护函数只处理一个已有文件,没有文件锁或原子替换,也没有覆盖并发修改。它证明当前样本的手改可被发现,不是完整发布系统。

读回表格会漏掉哪些语义

受限反向读取函数使用 Python 语法树,提取顶层字面量 INITIAL 和 TRANSITIONS,再借用原模型的状态、操作和终态声明重建模型。它没有执行输入源码,也不理解函数里的条件、调用或副作用。

对带退役禁令的文件进行读取,得到的表仍允许活跃设备退役。用第30篇解释器运行这个读回模型,结果是 RETIRED;执行原补丁文件却是拒绝。同一文件的表格和函数共同决定行为,只提取表格就会丢掉另一部分。

因此,这个函数只能称为特定生成格式的表格读取器。它依赖原始模型补齐声明,连模型名称和终态都不是从任意 Python 中推导出来的。可解析、能生成模型,仍不足以证明完成语义无损的反向转换。

正文中的往返指“模型生成代码,再从指定代码片段提取候选模型”。这个限定避免把实验扩大成任意程序到模型的恢复。若生成程序引入缓存、异常分支或外部服务调用,读取两张表不会自动保留这些行为。

两边同时修改时需要报告冲突

第二组实验固定同一个迁移单元:ACTIVE / retire。基线目标为退役;模型侧改成待检,代码表格侧改成继续活跃。两个修改都被实际执行,分别返回 INSPECTION_REQUIRED 与 ACTIVE,不再只是文本差异。

三方比较同时读取基线、模型侧和代码侧。若只有一侧变化,采用该侧候选;若两侧都偏离基线且彼此不同,则返回 ROUND_TRIP_CONFLICT。本例正好进入冲突分支,没有默默采用最后写入者。

flowchart LR
    BASE["基线:retire → RETIRED"] --> LEFT["模型侧:→ INSPECTION_REQUIRED"]
    BASE --> RIGHT["代码侧:→ ACTIVE"]
    LEFT --> MERGE["同一迁移的三方比较"]
    RIGHT --> MERGE
    MERGE --> FAIL["双方都变且不同:拒绝合并"]

第二张图只描述一个迁移单元的比较,不能解决多条边的联合约束、名称重构或复杂函数合并。这两个候选是故意构造的分歧,不是对设备退役政策的正式修订;即使合并算法选出一个值,也还要重新通过领域合同。

手写政策保存在独立位置

第33篇的理由政策不写入生成文件。重新生成生命周期后,再通过同一个包装函数调用:空理由仍被拒绝,有损坏理由的活跃设备仍可退役。测试运行的是组合后的行为,不能仅凭手写文件还存在就说扩展被保留。

这种分离仍有依赖。包装层需要生成函数继续接收状态和操作,并用约定方式表示拒绝;如果生成接口改了参数或异常类型,手写政策同样需要回归。独立文件可以避免覆盖,却不能自动消除接口兼容问题。

迁移表的事实来源是模型,退役理由的事实来源是手写政策,目标语言表达由生成器负责。“唯一事实源”应针对具体知识说明;不能把模型没有表达的规则也声称由模型统一控制。

状态重命名还要迁移旧数据

第三组实验将活跃状态从 ACTIVE 改名为 READY,所有相关迁移同时替换。这个新模型在通用有限状态语法中合法,却不再满足冻结的第24篇设备合同。第33篇严格构建入口先拒绝它,表明不能把重命名当成透明兼容修改。

为了研究这项明确的教学变更,实验随后单独调用第30篇通用生成器,生成新状态模型。这里绕开的不是一项已经证明多余的检查,而是主动进入另一版领域合同;代码和文章都保留这一边界。

旧快照保存设备 E1、模型修订 1、状态 ACTIVE。加载到修订 2 时直接拒绝;把旧状态直接交给新函数执行要求检查,也会拒绝。仅更新生成源码并不会改写已有数据中的状态名。

显式迁移将旧 ACTIVE 映射为 READY,待检和退役保持各自含义,同时保留设备编号,设置修订 2。迁移后的 E1 能执行要求检查并返回待检。未知旧状态和未知修订被拒绝,不用猜测性的默认值填补。

若把所有不认识的旧状态重置为新模型初态,原本已经通过审批的 E1 会变成待检;随后要求检查反而失败。这个实际结果表明重置虽然能得到一个合法状态,却丢失了旧数据表达的审批事实。

这里的模型修订号与 DSL 格式版本分开:输入结构仍是版本 1,只是领域状态词汇变成第二版。Sadalage 与 Fowler 在数据库演化实践中区分结构调整与已有数据迁移。本例借鉴需要迁移存量数据的做法,只操作内存快照,没有实现数据库升级。Evolutionary Database Design

迁移映射还承担另一项责任:说明旧事实如何保留。本例的两个名称指向同一个已审批状态,所以可以一对一替换。如果新版本把活跃拆成“可出租”和“维修锁定”,仅凭旧状态就无法决定目标,需要额外事实或人工处理。把未知情况都放进其中一类,会得到可运行的数据,却可能凭空增加业务判断。

当前快照的设备编号来自实验内可信输入,恢复函数只检查固定字段、修订号和状态,不是面向外部请求的完整输入验证器。它也没有幂等迁移日志、事务回滚或批量失败恢复。重复把修订二的快照交给仅接受修订一的迁移函数应当拒绝;真实迁移任务需要在调度层明确哪些记录尚未处理,不能依靠猜测版本继续执行。

复跑覆盖、冲突与迁移

1
bash examples/software-modeling/labs/34/run.sh

Python 3.10 及以上与 Bash 即可运行,实际得到 20 条场景记录,全部 pass=true,退出码为 0。手改源码、模型侧与代码侧候选、冲突记录、读回模型和迁移快照保存在 evidence/34/,旧输入保持不变。

第34篇实验包 包含本章及所需的第30–33篇工具文件,保留仓库路径后可独立运行。依赖实验通过临时复制验证,没有覆盖其他章节证据。前置接口可对照 第30篇:模型、元模型与DSL。

这组实验没有处理真实数据库、在线双版本读写或完整模型合并。它给出的保护范围是具体的:发现当前生成物漂移、拒绝一个迁移单元的双边冲突,以及按显式映射迁移一个旧状态快照。