305
社区成员
发帖
与我相关
我的任务
分享Unit 3 的三次作业(HW9 → HW10 → HW11)围绕一个不断演化的社交视频平台展开:从最初的"用户—关注关系"网络,到引入视频、评论、硬币经济与勋章,再到本次加入智能推荐系统。表面看是在加功能,实际是在三次循环里完成同一件事——阅读 JML、按规格实现、写测试验证、被强测/互测捶打后修复。
本次博客我会沿着这条主线,把对 JML 规格驱动开发的理解、JUnit 测试经验、迭代中的方法/容器变化、自己踩过的 bug 以及"JML 击鼓传花"游戏的感悟都串起来谈。
写过几次需求文档的人都知道,自然语言的需求总有歧义:
"返回最大值"——重复值时返回哪一个?
"如果不存在则抛异常"——抛哪一种?以怎样的顺序?
JML 的价值就在于把所有边界条件、副作用、异常优先级都用数学谓词钉死。比如 HW11 的 recommendNthUp 的核心 ensures:
@ ensures (\num_of int i; 0 <= i && i < users.length;
@ users[i].getId() != userId && !getUser(userId).isFollowing(users[i]) &&
@ (computeUpScore(users[i]) > computeUpScore(\result) ||
@ (computeUpScore(users[i]) == computeUpScore(\result) && users[i].getId() < \result)))
@ == rank - 1;
短短几行,把"候选范围""排序规则""tie-break 规则""返回什么"全部钉死了。把它读懂之后,实现只是把这个逻辑翻译成代码而已。规格驱动开发的本质是让需求变得可证伪——任何不满足规格的实现都能被一个反例命中。
JML 抽象层只关心"做什么",不关心"怎么做"。比如 getInterest:
\result == typeCounts[i] * (totalVideos - watchedVideos.length + 1)
JML 没有要求我用什么数据结构来维护 typeCounts,是 int[] 还是 Map<String, Integer> 都行。这种接口与实现的解耦让我能在性能不达标时自由替换底层结构,只要保持外部行为一致即可——这是规格驱动开发对工程效率最直接的贡献。
最容易掉的坑是看到 ensures 写了一条就以为只有这条生效。比如 HW10 的 watch_video:
@ ensures !getUser(userId).hasReceivedVideo(getVideo(videoId));
很多人(包括我)的第一反应是 received.remove(videoId)——只移除一份。但 hasReceivedVideo 的定义是:
ensures \result == (\exists int i; 0 <= i && i < receivedVideos.length;
receivedVideos[i] == video.getId());
这是个 \exists 谓词。!hasReceivedVideo 意味着列表里一个副本都不能剩。当 forward_video 把同一视频多次塞进 received 后,单删一份会直接挂掉强测(后面 bug 复盘会详谈)。
结论:读 JML 时,每个谓词都要追到它的定义,不要凭直觉脑补语义。
HW10 让我写 cleanSpamComments 的单元测试,HW11 写 recommendNthUp。课程组给了关键约束:目标方法可能有 bug,其他方法保证正确。所以测试策略要分两层:
第一层:异常优先级的覆盖
每个抛异常的分支至少一个测试。关键是验证异常优先级——把多个条件同时触发,看哪个先抛。例如 HW11 recommendNthUp 的 JML 优先级是 UID → InvalidRank → NoVideoUploaded → ColdStartUser,那我至少要测:
@Test public void testUidBeforeInvalidRank() { // 用户不存在 + rank=0 -> UID
try { n.recommendNthUp(999, 0); fail(); }
catch (UserIdNotFoundException e) { /* ok */ }
}
@Test public void testInvalidRankBeforeNoVideo() { // rank=0 + 无视频 -> InvalidRank
try { n.recommendNthUp(1, 0); fail(); }
catch (InvalidRankException e) { /* ok */ }
}
这种"双重违规"用例最容易把异常顺序写错的实现抓出来。
第二层:JML 公式的反向验证
利用"周边方法正确"的假设,我用 computeUpScore 反向验证 recommendNthUp 的结果:
@Test public void testJmlCountFormula() {
int upId = n.recommendNthUp(5, rank);
long resultScore = user5.computeUpScore(n.getUser(upId), totalVideos);
int beatCount = 0;
for (UserInterface u : n.getUsers()) {
if (u.getId() == 5 || user5.isFollowing(u)) continue;
long s = user5.computeUpScore(u, totalVideos);
if (s > resultScore || (s == resultScore && u.getId() < upId)) {
beatCount++;
}
}
assertEquals(rank - 1, beatCount); // JML 核心约束
}
这种"用规格的别处部分校验当下结果"的写法,比写死期望值更不容易因测试数据小改而失效。
assignable \nothing 的检查recommendNthUp 被标为 pure 查询。要验证它没改状态,我准备了一个孪生 Network:
Network a = buildNetwork();
Network b = buildNetwork(); // 与 a 经过完全相同的命令序列
a.recommendNthUp(5, 1);
for (UserInterface u : a.getUsers()) {
UserInterface mirror = b.getUser(u.getId());
assertTrue(((User) u).strictEquals(mirror)); // 状态必须一一对应
}
strictEquals 是课程组提供的一个"全字段对比"方法,专门给 pure 检查用。这个模式对所有 query 类方法都通用。
| 作业 | 主线 | 新增数据 | 新增方法/异常数 |
|---|---|---|---|
| HW9 | 用户—关注关系 | following / followers / received | ~10 / 8 |
| HW10 | 视频 + 互动 + 经济 | watched / liked / coins / medals / contributors / videos / comments | +9 / +8 |
| HW11 | 智能推荐 | typeCounts / uploaded videos | +6 / +5 |
每次作业大约新增 5-10 个方法、4-8 个异常。代码量上 User 从 ~150 行涨到 ~350 行,Network 从 ~200 行涨到 ~650 行。
这是最容易翻车的地方。我踩过两个没有显式说明的隐式变化:
变化 1:HW11 Video.getHeat() 从 double 变 int,公式完全换了
// HW10: playCount * 1.0 + likes * 1.5 + forwardCount * 2.0 + coins * 2.5
// HW11: playCount * 2 + likes * 3 + forwardCount * 4 + coins * 5
指导书在不显眼处一句"为了避免浮点数计算误差和判等问题,hw10中原有的queryMostPopularVideo 和 getHeat中的浮点类型改为了整数类型"带过。如果只 copy-paste HW10 代码,编译会直接挂(接口签名变了)
变化 2:HW11 FollowLinkNotFoundException 构造函数加了 min-first 重排
// HW11 新行为:
public FollowLinkNotFoundException(int id1, int id2) {
if (id1 < id2) { this.id1 = id1; this.id2 = id2; }
else { this.id1 = id2; this.id2 = id1; }
count++;
errorCount.putError(id1, id2); // 还多了一个新重载
}
removeReceivedVideo 只删一份 —— JML \exists 谓词的误读症状:强测 4 期望 2060175099 261442296 1776993254,我输出 2060175099 261442296 1776993254 2060175099(末尾多一份重复)。强测 17 期望 None,我输出 1 1 1 1 1。
根因:
public void removeReceivedVideo(int videoId) {
receivedVideos.removeFirstOccurrence(Integer.valueOf(videoId)); // 只删一份!
}
但 JML 的 !hasReceivedVideo 是 \exists 谓词,意味着"列表里不能存在任何匹配",必须删全部。forward_video 不去重,同一视频可能被多次 forward 给同一 follower,受害者只删了第一份。
修复:
public void removeReceivedVideo(int videoId) {
Integer key = Integer.valueOf(videoId);
while (receivedVideos.remove(key)) {
// 删干净
}
}
反思:这是个典型的"凭直觉读 JML"的坑——!hasReceivedVideo 看着像"不在列表里",凭语义直觉对应 remove;但严谨追到定义就会发现"不在"的精确含义是"没有任何位置"。
我在 Unit 3 里使用了 AI 辅助(包括评测机的开发)。几个观察:
1.JML 翻译加速:把一段嵌套的 \exists/\forall/\sum 描述清楚后,让大模型生成"按 JML 一比一实现"的代码骨架,对手动写 JML 的人是数倍的速度提升。
2. 测试用例联想:让它根据 JML 列举边界用例(空容器、单元素、重复元素、临界值、异常优先级双重触发等),覆盖率比我自己拍脑袋想高得多。
3. 跨作业 diff:上传 HW10 和 HW11 的官方包,让它列出"差异点"——上面提到的 FLNF min-first 变化,就是这样发现的。
1.性能问题:大模型默认会按 JML 字面写实现,比如 queryMutualFollowingSum 它会写 O(n²)。需要主动追问"这个方法在 3000 条命令下会不会 TLE?能否增量维护?"
2. 容器选型:它倾向用 ArrayList,不会主动判断"这里频繁查找用 HashMap 更好"。需要明确提示"按 BUAA OO checkstyle,类字段限制 15 个,请合并冗余容器"。
3. 隐式假设:它会顺着 JML 字面意思写,不会主动去核对"hasReceivedVideo 是不是 \exists 谓词"。前面那个 removeFirstOccurrence 的 bug,大模型一开始就和我一起栽进去了——我们都"凭语义直觉"读了 JML,没追定义。
1.把目标方法的 JML(含所有 signals)发给它
2. 让它先列异常优先级——一行一行 "UID → InvalidRank → NoVideo → ColdStart"
3. 对每个异常分支,让它生成"刚好满足该分支条件、且违反前一个分支条件"的最小测试用例
4. 再让它写一个JML 公式反向验证测试(用周边正确方法计算期望值)
5. 最后让它写一个 assignable \nothing 状态保持测试
按这个流程下来,每个 query 方法稳定产 12-20 个 JUnit 测试。
研讨课上的击鼓传花游戏让我印象深刻:第一个人写 JML → 第二个人翻译成自然语言 → 第三个人再翻译回 JML → ……最后回到出发点对比,发现需求已经"漂移"得面目全非。
1.范围漂移变化:\forall int i; 0 <= i && i < arr.length 被翻译成"对所有元素",再被翻译回 JML 就变成了 \forall UserInterface u; condition(u) ——隐式默认了"对全集枚举",把"基于索引"变成了"基于元素",本质完全不同。
2. 异常优先级丢失:自然语言版本通常说"如果用户不存在、视频不存在、或者...抛异常",丢失了这些条件之间的顺序关系。重新写回 JML 时,每个人都按自己想象的顺序排了 signals 子句。
最容易丢的是"边界两侧的不对称行为":
@ signals (InvalidAgeException) age < 0 || age > 110;
中间过一遍自然语言,常变成"年龄不在合理范围内抛异常"。再翻译回 JML,有人写 age < 1 || age > 110,有人写 age <= 0 || age >= 110——边界值 0 和 110 是否被接受成了悬案。
我的几点思考:
1.JML 是唯一的真实来源(Single Source of Truth)。组内任何"我以为是这样"的争论,都要回到 JML 比对,而不是回到自然语言文档。
2. **建立"边界值清单"**:每个数值字段(年龄、硬币、ID、rank...)的合法/非法边界值统一列表,每个人写完代码后用清单逐项 check。
3. 异常优先级表:所有抛异常的方法,画一张"异常优先级表"(method × 触发条件 × 抛出异常的顺序),评测时先匹配表,再匹配 JML 子句。
4. 集体写测试用例库:每个人针对自己实现的方法写"边界用例 + 异常优先级用例",集成到组内统一的 JUnit 套件。
5. 不允许"二次翻译":所有讨论引用 JML 时直接贴原文,不允许任何人用自己的话"解释一下 JML 大概意思"——这正是击鼓传花失真的源头。
如果有一种工具能做到"自动从 JML 生成自然语言摘要 + 反向校验",团队效率会大幅提升。例如:
-写完 JML 后自动生成"中文需求摘要",组员阅读后确认无误
-摘要被独立的工具再翻译回 JML 后与原版对比,差异部分作为"高风险条款"提醒
-任何人修改自然语言摘要时,工具自动检查与 JML 的一致性
这其实就是我们击鼓传花游戏的自动化版本——只不过让大模型来传花,人类只在终点校验。
Unit 3 三次作业让我对"严谨"这个词有了具体的体感。每一次强测的红色失败提示,回头追根溯源都能定位到某条 JML 我没读到位、某个 \exists 我当成了 \forall、某个隐式假设我做了想当然的推断。
JML 不是负担,是契约。规格驱动开发也不是束缚,是让协作不再靠默契的工程基础。从 HW9 到 HW11,最大的进步不是写出了更多代码,而是慢慢培养出了"看到一段 JML 就先去追每个谓词的定义"的肌肉记忆。