308
社区成员
发帖
与我相关
我的任务
分享本单元的核心模式是规格驱动开发:先读JML规格,再写实现。通过三次作业,对这一模式的理解有以下几点。
第一,写JML规格的难度其实远大于按规格写代码。
JML要求用形式化语言描述前置条件、后置条件、异常行为、不变式、副作用范围等。这不仅需要精确理解需求,还需要掌握一阶逻辑、量词、\old表达式等形式化工具的用法。本单元是读JML而非写JML,但即使是阅读,理解 queryShortestPath 中用存在量词和全称量词嵌套描述的最短路径数学定义,也不比写一段BFS代码简单。这说明规格驱动开发的成本主要在规格的生产和验证环节,而不是实现环节。
第二,契约式编程适合重安全、重一致性的场景。
通过本单元的体验,JML/契约式编程最适用的场景是:多人协作开发同一接口、需要长期维护的模块、对异常行为有严格一致性要求的系统(如航空、医疗、金融软件)。在这些场景下,JML的价值在于提供一套可被双方独立验证的公共基准。本单元的作业规模较小,JML的价值更多体现在教学层面;但在实际工程中,当接口两侧由不同团队维护时,形式化契约能显著减少"我以为你懂"的沟通成本。(代价是开发慢)
第三,JML不等于实现算法。
JML描述的是"做什么",不是"怎么做"。例如 queryLongestDecSeq 的JML只要求返回最长递减序列长度,并未指定算法;实现者可以选择暴力搜索、记忆化搜索或动态规划。这种解耦使得规格可以在实现优化后保持不变,但也要求实现者具备独立设计正确且高效算法的能力。
本单元的测试方法经历了逐步演进,不是突然质变,而是在原有基础上的修补和扩展。
hw9:随机化测试。
hw9的测试已经使用了随机方法生成2000次随机的follow/unfollow操作,并在每次操作后比对 queryMutualFollowingSum() 的结果与本地维护的计数器。这比固定用例覆盖更广,但当时只保存了 name 和 age 进行比对,没有检查 pure / safe 方法是否意外修改了其他属性(如 coins 或 followers),对无副作用的验证是不完整的。
hw10:影子模型对拍。
hw10针对 cleanSpamComments 构建了 HashMap<Integer, HashMap<Integer, String>> 作为影子模型,每次调用被测方法后与模型中的预期状态逐一比对。这一步的关键改进是**从"验证结果正确"扩展到"验证状态一致"**——不仅看返回值,还看容器内的全部数据是否匹配。
hw11:不变性检查 + AI辅助。
hw11测试 recommendNthUp 时,在调用前后分别快照用户的 id、name、age、coins 等属性,调用后逐一比对,确保 pure 查询方法没有副作用。同时借助codex补全了测试实现,但测试策略(对拍模型设计、状态机指令选择、边界用例构造)仍由人工设计。
总结起来,测试手段的变化是:
在学习过程中,发现stream格式的容器操作不仅提高可读性,还大大减轻了代码长度和书写难度,遂大范围使用。
hw11中开始尝试使用Stream API(如 Arrays.stream(oldUsers).forEach(...) 和 Network.types.stream().map(...).collect(Collectors.toList())),但用得比较基础,主要是替代简单的for-each循环,没有涉及复杂的流式操作。
在三次作业迭代过程中,我通过 diff 对比官方包中各版本的JML接口文件,来快速查看JML规格的变化。过程中,除了业务功能的增加,我还发现JML本身也在不断修正。
例如hw10到hw11的 uploadVideo 规格中,u.receivedVideos[i + 1] 被修改为 u.receivedVideos.get(i + 1)——从数组下标访问改为容器get方法。watchVideo 的规格新增了 typeCounts 相关的 ensures 子句。这些出现在课程组官方包的中文commit中,说明即使由课程组维护的JML,在迭代中也需要修正,形式化规格并非一经写出就绝对正确;这也侧面反映出契约式开发的问题——很难保证一个完美的规格。
JML使用一阶逻辑和数学量词描述行为,而代码使用算法和数据结构实现行为,两者之间存在显著的表达差距。以下用 queryShortestPath 和 queryMutualFollowingSum 为例说明。
例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();
这段规格用双重求和定义了互关对的数量。但代码实现并未在查询时遍历所有用户对,而是在 followUser 和 unfollowUser 中维护一个 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数量实际上很少。
这种低bug率并非因为代码简单,而是因为JML前置条件已经将大量非法输入挡在了实现之外。当 requires 子句明确规定了 !containsUser(id) 时抛出 UserIdNotFoundException,实现者就不太可能在正常分支中遗漏空指针检查。当 signals 子句列出了所有异常条件时,异常处理的完备性就有了明确的检查清单。
当然这个bug率,实际需要依赖一个完整可靠的规格,所以在印证了前文的观点——规格驱动的开发,难点在于写规格。
由于这个单元中JML实际上起到了prompt的作用,所以如果使用大模型,可能太没参与感(?),所以这几次作业,仅hw11借助了codex生成部分JUnit测试。
其实,本单元如果使用AI agent,可以借助良好定义的JML规格,直接一次生成相当可用的代码。(但是我感觉,实际开发中,如果JML已经写出来了,其实再写点代码完全费不了不长时间,使用code agent生成实现,反而可能引入不确定性,何况契约式编程的核心就是为了保障安全性;但如果是规格简单,算法复杂的情况,人工写规格,使用实现agent+测试agent进行自动化迭代或许确实是合适的)
从hw11测试文件的最终代码可以明确划分人工编写与codex生成的边界:
case_1 固定用例(测试基本异常分支和正常返回值),以及 assertResultIs 和 assertExceptionIs 两个断言辅助函数,负责调用待测方法,并检查是否符合pure要求。case_2 的完整随机测试流程(3000次循环、10种指令的switch状态机、每种操作的模型同步更新),以及 RecommendModel 影子模型的全部实现人工编写的 case_1 覆盖5个固定边界场景;而codex生成的 case_2 和 RecommendModel 合计约400行,覆盖3000次随机指令序列的完整状态空间。这种规模的手写测试在同等时间内几乎不可能完成,其基本上重新实现了一套独立架构。
我个人认为是合适的。使用agent自动生成随机测试,实际上是使用不同的策略,重新对代码代码进行实现,之后进行对比。这种情况下,自己构造评测机不仅十分耗时,且自身很容易在评测机内重新犯同样的错误,很难评测出自身的问题。
(同时反思,我其实应该agent在写测试时,不给出我的实现代码,这样其实更加合适。)

第一次传递时,"可达"(传递闭包)被降为 connections[a][b](直接邻接)。
第一轮自然语言中我写到"可能使用并查集实现,则查询关系可能修改属性"。但JML中 pure \assignable \nothing 其实并不会约束不可见状态的修改。所以建模中,只要抽象的连接关系不变,实际的数据结构可以被修改。
第一版JML直接写 connections[a][b],没有声明模型属性,也没有限定 a,b 的合法范围。
这个传递也反应出自然语言交流的问题,业务需求很难被自然语言精确建模,这也凸显出了JML等规格语言的价值所在。
这个小游戏也启发我们,未来在多人协作的软件开发中,应该尽可能使用精准的语言进行接口/交界处的约定,必要时可以使用规格语言。即使不使用规格语言这么极端的形式,也应该将软件开发分为尽可能耦合少的几个部分,进行分别开发,并在交界处使用javaDoc、@标注等等,并使用相关静态分析工具,在编译前就即使发现理解的差异。
本单元的主要收获不是某种"质变",而是一些具体的改进:学会了阅读形式化规格,测试手段从固定用例扩展到随机对拍和影子模型,代码风格从随意变为受checkStyle约束,开始尝试基础的Stream API,对容器选型的时机从被动补丁变为主动规划。
击鼓传花游戏则提供了一个重要提醒:信息在传递中必然发生漂移。在多人协作中,JML的价值在于提供一套可被独立验证的公共基准。当团队中的每个人都基于同一套形式化契约来校对自己的理解时,协作中的信息差才能被有效压缩。