OO UNIT3 总结

刘子豪-24373149 2026-05-28 09:11:42

OO Unit 3 总结博客

前言

Unit 3 的三次作业(HW9 → HW10 → HW11)围绕一个不断演化的社交视频平台展开:从最初的"用户—关注关系"网络,到引入视频、评论、硬币经济与勋章,再到本次加入智能推荐系统。表面看是在加功能,实际是在三次循环里完成同一件事——阅读 JML、按规格实现、写测试验证、被强测/互测捶打后修复

本次博客我会沿着这条主线,把对 JML 规格驱动开发的理解、JUnit 测试经验、迭代中的方法/容器变化、自己踩过的 bug 以及"JML 击鼓传花"游戏的感悟都串起来谈。


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

1.

写过几次需求文档的人都知道,自然语言的需求总有歧义:
"返回最大值"——重复值时返回哪一个?
"如果不存在则抛异常"——抛哪一种?以怎样的顺序?

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 规则""返回什么"全部钉死了。把它读懂之后,实现只是把这个逻辑翻译成代码而已。规格驱动开发的本质是让需求变得可证伪——任何不满足规格的实现都能被一个反例命中。

2. 规格 vs 实现的脱钩

JML 抽象层只关心"做什么",不关心"怎么做"。比如 getInterest

\result == typeCounts[i] * (totalVideos - watchedVideos.length + 1)

JML 没有要求我用什么数据结构来维护 typeCounts,是 int[] 还是 Map<String, Integer> 都行。这种接口与实现的解耦让我能在性能不达标时自由替换底层结构,只要保持外部行为一致即可——这是规格驱动开发对工程效率最直接的贡献。

3. 不要在 JML 里读"行为推断"

最容易掉的坑是看到 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 时,每个谓词都要追到它的定义,不要凭直觉脑补语义。


二、JUnit 测试经验

1. 目标方法 + 周边方法的双层覆盖

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 核心约束
}

这种"用规格的别处部分校验当下结果"的写法,比写死期望值更不容易因测试数据小改而失效。

2. 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 类方法都通用。


三、三次作业的迭代过程

1. 演化主线

作业主线新增数据新增方法/异常数
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 行。

2. 如何发现"方法/容器在迭代中的变化"

这是最容易翻车的地方。我踩过两个没有显式说明的隐式变化:

变化 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);   // 还多了一个新重载
}

四、Bug 分析

Bug :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 → 第二个人翻译成自然语言 → 第三个人再翻译回 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 就先去追每个谓词的定义"的肌肉记忆。

...全文
6 回复 打赏 收藏 转发到动态 举报
写回复
用AI写文章
回复
切换为时间正序
请发表友善的回复…
发表回复

305

社区成员

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

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