Unit3 单元总结:JML与规格驱动开发

顾文达-24373130 2026-06-01 20:58:37

一、对JML和规格驱动开发的理解

本单元的核心模式是规格驱动开发:先读JML规格,再写实现。通过三次作业,对这一模式的理解有以下几点。

第一,写JML规格的难度其实远大于按规格写代码。

JML要求用形式化语言描述前置条件、后置条件、异常行为、不变式、副作用范围等。这不仅需要精确理解需求,还需要掌握一阶逻辑、量词、\old表达式等形式化工具的用法。本单元是JML而非JML,但即使是阅读,理解 queryShortestPath 中用存在量词和全称量词嵌套描述的最短路径数学定义,也不比写一段BFS代码简单。这说明规格驱动开发的成本主要在规格的生产和验证环节,而不是实现环节。

第二,契约式编程适合重安全、重一致性的场景。

通过本单元的体验,JML/契约式编程最适用的场景是:多人协作开发同一接口、需要长期维护的模块、对异常行为有严格一致性要求的系统(如航空、医疗、金融软件)。在这些场景下,JML的价值在于提供一套可被双方独立验证的公共基准。本单元的作业规模较小,JML的价值更多体现在教学层面;但在实际工程中,当接口两侧由不同团队维护时,形式化契约能显著减少"我以为你懂"的沟通成本。(代价是开发慢)

第三,JML不等于实现算法。

JML描述的是"做什么",不是"怎么做"。例如 queryLongestDecSeq 的JML只要求返回最长递减序列长度,并未指定算法;实现者可以选择暴力搜索、记忆化搜索或动态规划。这种解耦使得规格可以在实现优化后保持不变,但也要求实现者具备独立设计正确且高效算法的能力。


二、JUnit测试的经验

本单元的测试方法经历了逐步演进,不是突然质变,而是在原有基础上的修补和扩展

hw9:随机化测试。

hw9的测试已经使用了随机方法生成2000次随机的follow/unfollow操作,并在每次操作后比对 queryMutualFollowingSum() 的结果与本地维护的计数器。这比固定用例覆盖更广,但当时只保存了 nameage 进行比对,没有检查 pure / safe 方法是否意外修改了其他属性(如 coinsfollowers),对无副作用的验证是不完整的。

hw10:影子模型对拍。

hw10针对 cleanSpamComments 构建了 HashMap<Integer, HashMap<Integer, String>> 作为影子模型,每次调用被测方法后与模型中的预期状态逐一比对。这一步的关键改进是**从"验证结果正确"扩展到"验证状态一致"**——不仅看返回值,还看容器内的全部数据是否匹配。

hw11:不变性检查 + AI辅助。

hw11测试 recommendNthUp 时,在调用前后分别快照用户的 idnameagecoins 等属性,调用后逐一比对,确保 pure 查询方法没有副作用。同时借助codex补全了测试实现,但测试策略(对拍模型设计、状态机指令选择、边界用例构造)仍由人工设计。

总结起来,测试手段的变化是:

  • 从固定用例 → 随机指令序列
  • 从只验证返回值 → 验证全部状态一致性
  • 从手工编写 → 人工设计核心逻辑 + AI生成重复代码

三、三次作业的迭代历程

3.1 stream的使用

在学习过程中,发现stream格式的容器操作不仅提高可读性,还大大减轻了代码长度和书写难度,遂大范围使用。

hw11中开始尝试使用Stream API(如 Arrays.stream(oldUsers).forEach(...)Network.types.stream().map(...).collect(Collectors.toList())),但用得比较基础,主要是替代简单的for-each循环,没有涉及复杂的流式操作。

3.2 JML规格的演进观察

在三次作业迭代过程中,我通过 diff 对比官方包中各版本的JML接口文件,来快速查看JML规格的变化。过程中,除了业务功能的增加,我还发现JML本身也在不断修正。

例如hw10到hw11的 uploadVideo 规格中,u.receivedVideos[i + 1] 被修改为 u.receivedVideos.get(i + 1)——从数组下标访问改为容器get方法。watchVideo 的规格新增了 typeCounts 相关的 ensures 子句。这些出现在课程组官方包的中文commit中,说明即使由课程组维护的JML,在迭代中也需要修正,形式化规格并非一经写出就绝对正确;这也侧面反映出契约式开发的问题——很难保证一个完美的规格。

3.3 JML形式化语言与代码实现的差距

JML使用一阶逻辑和数学量词描述行为,而代码使用算法和数据结构实现行为,两者之间存在显著的表达差距。以下用 queryShortestPathqueryMutualFollowingSum 为例说明。

例1:queryShortestPath

JML规格(节选):

/*@ public normal_behavior
  @ requires containsUser(id1) && containsUser(id2) && id1 != id2 &&
  @          (\exists UserInterface[] path;
  @          path.length >= 2 &&
  @          path[0].equals(getUser(id1)) &&
  @          path[path.length - 1].equals(getUser(id2)) &&
  @          (\forall int i; 1 <= i && i < path.length; path[i - 1].isFollowing(path[i])));
  @ ensures  (\exists UserInterface[] pathM; ...
  @          (\forall UserInterface[] path; ...
  @          (\sum int i; ...) >= (\sum int i; ...)) &&
  @          \result == (\sum int i; 1 <= i && i < pathM.length; 1));
  @ also
  @ public exceptional_behavior
  @ signals (UncessException e) ...
  @*/
public /*@ pure @*/ int queryShortestPath(int id1, int id2);

这段JML用存在量词声明"存在一条路径",用全称量词声明"该路径在所有路径中边数最少",从而数学化地定义了"最短路径"。但代码实现完全不使用路径数组,而是使用BFS:

// 伪代码:实际实现的核心逻辑
Queue<User> queue = new ArrayDeque<>();
Map<User, Integer> distMap = new HashMap<>();
distMap.put(from, 0);
for (User cur = from; cur != null; cur = queue.poll()) {
    int newDistance = distMap.get(cur) + 1;
    for (User next : cur.getFollowing()) {
        if (!distMap.containsKey(next) || newDistance < distMap.get(next)) {
            if (to.equals(next)) { return newDistance; }
            distMap.put(next, newDistance);
            queue.add(next);
        }
    }
}

JML描述的是"什么是最短路径"这一数学概念,代码实现的是"如何高效找到最短路径"这一算法。两者之间没有直接的语法对应关系,实现者必须独立完成从数学定义到算法的转换。

例2:queryMutualFollowingSum

JML规格:

/*@ ensures \result == (\sum int i; 0 <= i && i < users.length;
  @          (\sum int j; i < j && j < users.length;
  @           (users[i].isFollowing(users[j]) && users[j].isFollowing(users[i])) ? 1 : 0));
  @*/
public /*@ pure @*/ int queryMutualFollowingSum();

这段规格用双重求和定义了互关对的数量。但代码实现并未在查询时遍历所有用户对,而是在 followUserunfollowUser 中维护一个 mutualFollowingCount 计数器:

// followUser中
if (user1.isFollowing(user2) && user2.isFollowing(user1)) {
    mutualFollowingCount++;
}

// query时直接返回
public int queryMutualFollowingSum() {
    return mutualFollowingCount;
}

JML的数学描述是 $O(n^2)$ 的,而实现是 $O(1)$ 查询 + $O(1)$ 维护。两者在行为上等价,但在计算路径上完全不同。这正是JML"只规定做什么,不规定怎么做"的体现,也是实现者需要独立做出的设计决策。


四、Bug分析与JML的价值

从代码演变来看,本单元的功能性bug数量实际上很少。

这种低bug率并非因为代码简单,而是因为JML前置条件已经将大量非法输入挡在了实现之外。当 requires 子句明确规定了 !containsUser(id) 时抛出 UserIdNotFoundException,实现者就不太可能在正常分支中遗漏空指针检查。当 signals 子句列出了所有异常条件时,异常处理的完备性就有了明确的检查清单。

当然这个bug率,实际需要依赖一个完整可靠的规格,所以在印证了前文的观点——规格驱动的开发,难点在于写规格。


五、Code Agent使用体验

由于这个单元中JML实际上起到了prompt的作用,所以如果使用大模型,可能太没参与感(?),所以这几次作业,仅hw11借助了codex生成部分JUnit测试。

其实,本单元如果使用AI agent,可以借助良好定义的JML规格,直接一次生成相当可用的代码。(但是我感觉,实际开发中,如果JML已经写出来了,其实再写点代码完全费不了不长时间,使用code agent生成实现,反而可能引入不确定性,何况契约式编程的核心就是为了保障安全性;但如果是规格简单,算法复杂的情况,人工写规格,使用实现agent+测试agent进行自动化迭代或许确实是合适的)

5.1 人工代码与codex代码的边界

从hw11测试文件的最终代码可以明确划分人工编写与codex生成的边界:

  • 人工编写case_1 固定用例(测试基本异常分支和正常返回值),以及 assertResultIsassertExceptionIs 两个断言辅助函数,负责调用待测方法,并检查是否符合pure要求。
  • codex生成case_2 的完整随机测试流程(3000次循环、10种指令的switch状态机、每种操作的模型同步更新),以及 RecommendModel 影子模型的全部实现

5.2 使用 code agent 的好处

人工编写的 case_1 覆盖5个固定边界场景;而codex生成的 case_2RecommendModel 合计约400行,覆盖3000次随机指令序列的完整状态空间。这种规模的手写测试在同等时间内几乎不可能完成,其基本上重新实现了一套独立架构。

5.3 将测试交给codex是否合适

我个人认为是合适的。使用agent自动生成随机测试,实际上是使用不同的策略,重新对代码代码进行实现,之后进行对比。这种情况下,自己构造评测机不仅十分耗时,且自身很容易在评测机内重新犯同样的错误,很难评测出自身的问题。

(同时反思,我其实应该agent在写测试时,不给出我的实现代码,这样其实更加合适。)


六、击鼓传花游戏的感悟

6.1 流程记录纸

img

6.2 传递中的语义偏移

第一次传递时,"可达"(传递闭包)被降为 connections[a][b](直接邻接)。

第一轮自然语言中我写到"可能使用并查集实现,则查询关系可能修改属性"。但JML中 pure \assignable \nothing 其实并不会约束不可见状态的修改。所以建模中,只要抽象的连接关系不变,实际的数据结构可以被修改。

第一版JML直接写 connections[a][b],没有声明模型属性,也没有限定 a,b 的合法范围。

6.3 启发

这个传递也反应出自然语言交流的问题,业务需求很难被自然语言精确建模,这也凸显出了JML等规格语言的价值所在。

6.4 多人编程的交流

这个小游戏也启发我们,未来在多人协作的软件开发中,应该尽可能使用精准的语言进行接口/交界处的约定,必要时可以使用规格语言。即使不使用规格语言这么极端的形式,也应该将软件开发分为尽可能耦合少的几个部分,进行分别开发,并在交界处使用javaDoc、@标注等等,并使用相关静态分析工具,在编译前就即使发现理解的差异。


七、结语

本单元的主要收获不是某种"质变",而是一些具体的改进:学会了阅读形式化规格,测试手段从固定用例扩展到随机对拍和影子模型,代码风格从随意变为受checkStyle约束,开始尝试基础的Stream API,对容器选型的时机从被动补丁变为主动规划。

击鼓传花游戏则提供了一个重要提醒:信息在传递中必然发生漂移。在多人协作中,JML的价值在于提供一套可被独立验证的公共基准。当团队中的每个人都基于同一套形式化契约来校对自己的理解时,协作中的信息差才能被有效压缩。

...全文
51 回复 打赏 收藏 转发到动态 举报
写回复
用AI写文章
回复
切换为时间正序
请发表友善的回复…
发表回复
内容概要:本文详细介绍了利用Simulink进行变压器开路试验的电路连接配置与仿真实现方法,重点在于通过仿真手段还原实际电力系统中变压器在空载条件下的电气特性,从而深入理解其工作原理与性能表现。文章作为电力系统仿真系列研究的一部分,系统阐述了从电路模型搭建、参数设定、仿真运行到结果分析的完整流程,突出展示了MATLAB/Simulink在电力设备建模与教学科研中的强大功能与应用价值。; 适合人群:具备电力系统基础知识,熟悉MATLAB/Simulink仿真环境,从事电气工程、自动化及相关领域的研发人员,以及高年级本科生和研究生。; 使用场景及目标:①掌握变压器开路试验的基本原理与Simulink仿真建模的具体步骤;②通过仿真实验深入理解空载电流、铁芯损耗及励磁特性等关键参数的物理意义;③为后续开展变压器短路试验、暂态过程分析以及其他电力设备的仿真研究奠定理论与实践基础。; 阅读建议:建议结合Simulink软件动手实践,逐步构建并调试电路模型,重点关注各元件参数的设置方法与测量模块的应用技巧,同时推荐参考文中提及的其他相关仿真案例进行拓展学习,以全面提升对电力系统仿真实践的整体认知与操作能力。

307

社区成员

发帖
与我相关
我的任务
社区描述
2026年北航面向对象设计与构造
java 高校
社区管理员
  • 孙琦航
加入社区
  • 近7日
  • 近30日
  • 至今
社区公告
暂无公告

试试用AI创作助手写篇文章吧