BUAA OO Unit3 总结:从 JML 规格到可测、可迭代的实现

熊智勇-24371057 2026-05-28 00:21:12

摘要:本文结合 OO 第三单元三次作业,从 JML 规格理解、JUnit 测试设计、三次迭代中的架构变化、性能瓶颈定位、bug 复盘以及研讨课“JML 击鼓传花”几个角度进行总结。

目录

  • 一、对 JML 和规格驱动开发的理解
  • 二、JUnit 测试经验总结
  • 1. 按 JML 分支设计测试
  • 2. 用快照检查 pure 方法
  • 3. 对复杂排序写朴素 oracle
  • 4. 压测要贴近性能风险点
  • 三、三次作业的迭代过程
  • 1. 第一次作业:基础社交网络
  • 2. 第二次作业:视频互动与统计
  • 3. 第三次作业:推荐、画像与性能优化
  • 四、如何发现迭代中的方法和容器变化
  • 1. 对比接口和 JML 模型字段
  • 2. 重点看 assignable
  • 3. 搜索高频查询和量词表达
  • 五、性能瓶颈定位与优化思路
  • 1. 线性查找改为哈希判断
  • 2. 查询结果能增量维护就不要反复统计
  • 3. 字符串处理要警惕极端输入
  • 4. 推荐问题避免不必要的全量排序
  • 六、Bug 复盘
  • 1. 异常优先级错误
  • 2. 查询方法意外修改状态
  • 3. tie-break 规则写反
  • 4. 重复推送视频后的观看处理
  • 5. 评论清理的性能问题
  • 七、关于大模型和 Code Agent 的使用体会
  • 八、研讨课“JML 击鼓传花”的感悟
  • 1. 是否发现了 JML 的 bug
  • 2. 需求和边界是否发生变化
  • 3. 多人协作时如何减少信息差
  • 九、总结

img

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

Unit3 给我的最大感受是:JML 并不是“把自然语言翻译成注释”,而是把需求压缩成一组可以被讨论、验证和实现的契约。相比前两个单元主要靠题意和样例理解任务,本单元更强调从规格出发写程序。

我对 JML 的理解可以概括为三层:

层次关注点对实现的影响
前置条件requires、异常分支中的 signals决定参数检查顺序,尤其影响抛出哪一个异常
后置条件ensures\old\result决定方法执行后哪些状态必须变化,哪些状态必须保持不变
可修改范围assignablepure决定方法是否可以修改对象状态,是写缓存、更新统计量时最需要谨慎的地方

在实现时,我通常不是逐行“翻译”JML,而是先从规格中抽出三个问题:

  1. 这个方法读了哪些模型字段?
  2. 这个方法允许修改哪些模型字段?
  3. 正常分支和异常分支的优先级是什么?

例如第三次作业中的推荐类方法大多是 pure,这意味着即使内部为了效率使用 PriorityQueue 或临时集合,也不能改变用户观看历史、点赞记录、关注关系等状态。这个限制直接影响了我的测试方式:不能只检查返回值,还要检查调用前后的网络状态是否完全一致。

规格驱动开发的优势在于边界更清楚。比如 recommendNthUp 不只是“推荐第 n 个 up 主”,它还包含候选人过滤、分数计算、并列时按 id 取小、异常优先级等细节。如果只看自然语言描述,很容易把“没有足够候选人”和“没有视频”混在一起;而 JML 会把这些情况拆开。

当然,JML 也不是自动正确的。它能降低沟通成本,但前提是规格本身足够明确。研讨课中的“击鼓传花”也说明:一旦规格中存在模糊边界,后续实现者会自然地按照自己的理解补全空白,最后就会产生行为差异。

二、JUnit 测试经验总结

Unit3 的 JUnit 测试不能只停留在“调用一次,看看输出”的层面。因为很多方法的错误不是语法错误,而是状态更新、异常优先级、排序规则、边界条件上的偏差。

我的测试经验主要有以下几点。

1. 按 JML 分支设计测试

一个方法通常对应多个正常分支和多个异常分支。测试时应该先把 JML 中的分支列出来,再为每个分支构造样例。

recommendNthUp 为例,需要覆盖:

  • 用户不存在时抛出 UserIdNotFoundException
  • rank <= 0 时抛出 InvalidRankException
  • 没有视频时抛出 NoVideoUploadedException
  • 候选 up 主数量不足时抛出 ColdStartUserException
  • 正常返回时排除自己和已关注用户;
  • 分数相同的时候选择 id 更小者;
  • 查询方法不应修改网络状态。

这些测试比单纯随机生成数据更有针对性,因为它们直接对应规格中的不同路径。

2. 用快照检查 pure 方法

第三次作业中推荐和画像查询较多,很多方法要求 assignable \nothing。为了检查这类方法,我使用“调用前快照 + 调用后快照”的方式,把用户、视频、关注关系、观看记录、点赞记录、金币、热度等信息保存下来,调用方法后再比较。

这种方法的好处是能发现“返回值正确但顺手改了状态”的错误。例如推荐算法如果为了排序临时改变了用户列表、候选列表或者观看记录,普通返回值测试不一定能发现,但快照测试可以发现。

NetworkSnapshot before = NetworkSnapshot.take(network, userIds, videoIds);
int result = network.recommendNthUp(1, 3);
NetworkSnapshot after = NetworkSnapshot.take(network, userIds, videoIds);
assertEquals(before, after);

3. 对复杂排序写朴素 oracle

对于推荐、最优贡献者、最热视频这类带排序和 tie-break 的方法,我会在测试中写一个不追求性能、但尽量贴近 JML 的朴素版本作为 oracle。正式代码可以用缓存、堆、哈希表等优化结构,测试代码则用简单排序表达规格含义。

这种做法可以把“实现是否高效”和“行为是否符合规格”分开,降低排错难度。

4. 压测要贴近性能风险点

性能测试不应平均用力,而应该针对风险点构造数据。本单元中比较典型的风险点包括:

  • 大量用户和关注边下的最短路查询;
  • 频繁查询粉丝年龄比例;
  • 大量评论和长字符串下的 cleanSpamComments
  • 推荐 up 主时对所有用户排序;
  • 多次查询最长年龄下降链。

例如评论清理如果对每条评论都反复 substring,在长文本和大量评论下很容易超时。针对这种场景,需要专门构造长评论输入进行测试。

三、三次作业的迭代过程

三次作业的变化不是简单地“方法越来越多”,而是状态规模、查询复杂度和性能要求都在上升。

1. 第一次作业:基础社交网络

第一次作业主要实现用户、视频、关注、取关、观看、接收未看视频、粉丝年龄比例、互相关注数量和最短路。

我的基本设计是:

  • Network 使用 HashMap<Integer, User> 管理用户;
  • Network 使用 HashMap<Integer, Video> 管理视频;
  • User 中维护 followingfollowersreceivedVideos
  • mutualFollowingSum 在关注和取关时增量维护;
  • queryShortestPath 使用 BFS。

这一阶段的核心是把模型字段落到容器上。由于数据规模和查询复杂度还不算高,很多关系可以先用 ArrayList 表示,再通过遍历判断是否存在。

但第一次作业也已经埋下了迭代风险:如果所有关系查询都依赖线性遍历,那么后续功能增加后很容易被放大为性能瓶颈。

2. 第二次作业:视频互动与统计

第二次作业加入了视频类型、播放量、点赞、投币、转发、评论、勋章、贡献者、最热视频和最长年龄下降链等内容。

这一阶段最大的变化是状态之间开始互相影响:

  • watchVideo 不只修改用户观看记录,还会增加视频播放量;
  • likeVideo 既要维护用户点赞列表,也要维护视频点赞数;
  • coinVideo 会同时修改视频投币数、用户金币、up 主金币和贡献者列表;
  • forwardVideo 会把视频推送给粉丝,并增加转发数;
  • queryLongestDecSeq 和图结构相关,可以通过 graphVersion 做缓存。

这时我开始引入“增量维护”的思想。比如互相关注数量不在查询时重新统计,而是在 followUserunfollowUser 时维护;最长下降链则用 graphVersion 判断缓存是否失效。

第二次作业让我意识到:JML 中一个简单的 assignable 后面往往对应多个真实字段的同步更新。只改了其中一个字段,短样例可能过,但复杂状态下就会出错。

3. 第三次作业:推荐、画像与性能优化

第三次作业继续加入全局最佳贡献者、视频推荐、up 主推荐、最有影响力 up 主、用户画像等功能。为了控制 Network 的复杂度,我将部分查询逻辑拆到了 NetworkQueryHelper 中。

第三次迭代中的主要优化包括:

优化点原因做法
关注/粉丝判断ArrayList.contains 或遍历代价较高额外维护 followingIdsfollowerIds
年龄比例查询每次遍历所有粉丝会重复计算维护 followerAgeBuckets
未看视频去重/删除同一视频可能多次被推送使用 receivedVideoCounts 辅助判断
点赞/观看判断查询频繁维护 watchedVideoIdslikedVideoIds
最佳贡献者每次查询遍历贡献者列表在投币时维护 bestContributorId
up 主影响力由视频热度累加而来在热度变化时增量更新 uploader 的 influence
推荐第 n 个 up 主全量排序开销大使用大小为 rankPriorityQueue

第三次作业的重点不再只是“能不能实现”,而是“能不能在频繁查询下稳定实现”。这也是我对容器选择理解最深的一次:容器不是实现细节,而是规格到性能之间的桥。

四、如何发现迭代中的方法和容器变化

我主要通过三种方式发现迭代中的变化。

1. 对比接口和 JML 模型字段

每次新作业开始时,先看接口新增了哪些 model 字段和方法。例如第三次 UserInterface 中出现了 typeCountsvideos、画像和影响力相关方法,这提示实现中需要记录用户观看兴趣和 up 主视频影响力。

如果只是看新增方法,很容易漏掉已有方法语义变化。比如视频热度的计算在第二次和第三次中权重不同,第三次 getHeat 是整数权重:

playCount * 2 + likes * 3 + forwardCount * 4 + coins * 5

如果沿用第二次的浮点热度公式,就会在推荐和最热视频中产生错误。

2. 重点看 assignable

assignable 能直接告诉我一个操作会影响哪些状态。比如上传视频不仅修改 videos,还会修改粉丝的 receivedVideos,第三次还要修改上传者自己的 videos 列表。

这类信息决定了我是否需要新增容器,或者是否需要在原有方法里补同步更新。

3. 搜索高频查询和量词表达

JML 中出现 \sum\max\min\num_of、路径存在性等表达时,我会格外关注性能。它们在规格里很自然,但直接翻译成多层循环可能会超时。

例如:

  • queryMutualFollowingSum 适合增量维护;
  • queryUpFollowersAgeRatio 适合维护年龄桶;
  • queryBestContributor 适合维护当前最佳贡献者;
  • queryLongestDecSeq 适合缓存;
  • recommendNthUp 可以用堆保留前 rank 个候选。

五、性能瓶颈定位与优化思路

本单元的性能瓶颈大多来自“查询方法反复扫描大容器”。我的定位思路是先从复杂度上判断风险,再用极端数据验证。

1. 线性查找改为哈希判断

第一次作业中使用 ArrayList 保存关注关系是够用的,但到了第三次,isFollowingcontainsFollowerhasWatchedVideohasLikedVideo 会被推荐和测试频繁调用。于是我在保留列表顺序的同时增加了 id 集合。

这种“双容器”设计需要注意一致性:添加、删除时必须同时更新列表和集合。它提高了查询效率,但也增加了维护成本。

2. 查询结果能增量维护就不要反复统计

粉丝年龄比例、up 主影响力、最佳贡献者都属于可以增量维护的数据。相比每次查询时重新遍历,增量维护能把开销转移到修改操作中。

不过增量维护必须严格受 JML 约束。比如 pure 查询方法不能因为想“顺手刷新缓存”而修改可见状态;缓存字段也要确保只在依赖状态变化时失效。

3. 字符串处理要警惕极端输入

cleanSpamComments 是一个很容易低估的性能点。短评论下,朴素地从每个位置 substring 计数也能通过;但当评论很长、评论数量很多时,字符串复制和重复匹配会被放大。

更稳妥的做法是使用 indexOf(keyword, fromIndex) 滑动查找,并在删除评论后同步更新 commentIdSet,保证后续 containsComment 仍然正确。

4. 推荐问题避免不必要的全量排序

recommendNthUp 只需要第 rank 个结果,不一定需要对所有候选完整排序。使用大小为 rank 的优先队列,可以在候选数量较大时减少排序成本。

同时,推荐问题的 tie-break 特别容易写反:分数越高越优先,分数相同 id 越小越优先。测试里需要专门构造同分样例。

六、Bug 复盘

结合三次作业的实现和测试,我将容易出现的问题归纳为以下几类。

1. 异常优先级错误

JML 的异常分支有明确条件,实际代码必须按照条件优先级检查。例如 recommendNthUp 中,如果用户不存在,应先抛 UserIdNotFoundException;用户存在但 rank <= 0 时,才抛 InvalidRankException

这类 bug 的原因是实现时容易按“业务直觉”检查参数,而不是按 JML 条件检查。解决方式是为每个异常分支写单独测试,并在测试中构造多个错误条件同时成立的输入。

2. 查询方法意外修改状态

推荐、画像、最优查询等方法往往需要临时排序或筛选。如果直接复用真实容器并排序,就可能破坏对象状态。

我在测试中用快照比较来防止这类问题。凡是 JML 标注 pureassignable \nothing 的方法,都应该默认加一个“调用前后状态一致”的测试。

3. tie-break 规则写反

最热视频、最佳贡献者、推荐 up 主等方法都有“分数相同取 id 小者”的规则。实现中如果使用 PriorityQueue,比较器尤其容易写反,因为堆顶需要保留的是当前前 rank 个候选中最差的那个。

解决方法是构造同分样例,不要只测分数不同的情况。

4. 重复推送视频后的观看处理

同一个视频可能通过上传和转发被多次放入用户的未看列表。观看视频后,规格要求用户不再拥有该视频的未看记录,因此需要删除所有对应项,而不是只删除第一次出现的位置。

第一次实现时如果只写一次 remove,在重复推送场景下就会留下脏状态。后续我使用循环删除或 removeIf 保证删除完整。

5. 评论清理的性能问题

评论清理既有正确性风险,也有性能风险。正确性上,删除评论后需要同步维护评论 id 集合;性能上,长文本重复匹配可能导致超时。

这个问题提醒我:字符串题看起来局部,但一旦输入规模被放大,就会成为整份程序的瓶颈。

七、关于大模型和 Code Agent 的使用体会

在规格驱动开发中,大模型比较擅长做三类事情:

  • 帮助把 JML 分支整理成测试清单;
  • 根据已有实现发现可能遗漏的边界条件;
  • 生成朴素 oracle 或快照测试框架。

但大模型也有明显局限。它容易给出“语义上看起来正确”的实现,却忽略复杂度和容器一致性。例如把 JML 里的 \max\sum 直接翻译成多层循环,短数据能跑,大数据就可能超时;又比如只记得更新 ArrayList,忘记同步更新辅助 HashSet

所以我认为 Code Agent 更适合做辅助开发,而不是替代规格阅读。比较有效的使用方式是:

  1. 先让它整理 JML 分支和边界;
  2. 再让它生成单元测试或对照 oracle;
  3. 最后由自己确认容器设计、复杂度和异常顺序。

换句话说,大模型可以提高“发现问题”的效率,但最终的规格理解和架构取舍仍然需要人来负责。

八、研讨课“JML 击鼓传花”的感悟

研讨课中的“JML 击鼓传花”给我的感受很直接:规格如果不精确,传递次数越多,偏差越容易被放大。

1. 是否发现了 JML 的 bug

在传递过程中,我确实感受到了一些容易出问题的地方。例如:

  • 边界条件没有写清楚,后续实现者会自己补规则;
  • 异常优先级没有明确时,不同人会按不同顺序检查;
  • 对返回列表的顺序、长度、是否允许重复没有说明时,实现会出现差异;
  • assignable 写得过宽,会让实现者误以为可以修改更多状态;
  • pure 语义没有被重视时,查询方法可能产生副作用。

这些问题不一定都是语法层面的 bug,更多是“规格表达不完整”导致的理解偏差。

2. 需求和边界是否发生变化

我认为需求在传递过程中通常不会被有意改变,但边界会被无意识地改变。比如“推荐一个用户”传到后面,可能有人默认排除已关注用户,有人默认不排除;有人认为没有候选者应返回特殊值,有人认为应该抛异常。

这说明自然语言需求和形式化规格之间存在差距。JML 的价值就在于尽量把这种差距显式化。

3. 多人协作时如何减少信息差

如果以后多人组队编程,我认为至少需要以下规则:

  • 先统一术语表,例如“候选人”“已观看”“未观看”“贡献者”的含义;
  • 为每个接口写异常优先级表;
  • 为关键方法提供最小样例和反例;
  • 明确哪些方法是查询,哪些方法允许修改状态;
  • 每次修改规格时同步更新测试,而不是只更新代码;
  • 代码评审时先审规格理解,再审实现技巧。

其中最重要的是“用测试固定共识”。口头讨论很容易遗漏细节,而一组边界测试可以把团队对需求的理解沉淀下来。

九、总结

Unit3 让我真正体会到,规格驱动开发不是多写一些注释,而是把需求、实现和测试连接起来的一种方法。

从第一次作业到第三次作业,我的实现从简单容器逐步演进到增量维护、缓存和辅助查询类。这个过程也让我意识到:正确性和性能并不是分开的。很多性能优化本质上是在维护另一份规格等价的状态,而只要存在冗余状态,就必须用测试保证它们始终一致。

JUnit 在本单元中的作用也不只是“测对不对”,更是帮助我确认自己是否真正理解了 JML。能写出覆盖异常优先级、边界条件、纯查询副作用和复杂排序的测试,说明我对规格的理解才算比较扎实。

最后,研讨课让我认识到,规格本身也需要被测试和评审。一个好的 JML 应该让实现者少猜一点,让协作者少误解一点,让后续迭代少踩一点坑。这也是我在 Unit3 中最大的收获。

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

309

社区成员

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

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