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

Unit3 给我的最大感受是:JML 并不是“把自然语言翻译成注释”,而是把需求压缩成一组可以被讨论、验证和实现的契约。相比前两个单元主要靠题意和样例理解任务,本单元更强调从规格出发写程序。
我对 JML 的理解可以概括为三层:
| 层次 | 关注点 | 对实现的影响 |
|---|---|---|
| 前置条件 | requires、异常分支中的 signals | 决定参数检查顺序,尤其影响抛出哪一个异常 |
| 后置条件 | ensures、\old、\result | 决定方法执行后哪些状态必须变化,哪些状态必须保持不变 |
| 可修改范围 | assignable、pure | 决定方法是否可以修改对象状态,是写缓存、更新统计量时最需要谨慎的地方 |
在实现时,我通常不是逐行“翻译”JML,而是先从规格中抽出三个问题:
例如第三次作业中的推荐类方法大多是 pure,这意味着即使内部为了效率使用 PriorityQueue 或临时集合,也不能改变用户观看历史、点赞记录、关注关系等状态。这个限制直接影响了我的测试方式:不能只检查返回值,还要检查调用前后的网络状态是否完全一致。
规格驱动开发的优势在于边界更清楚。比如 recommendNthUp 不只是“推荐第 n 个 up 主”,它还包含候选人过滤、分数计算、并列时按 id 取小、异常优先级等细节。如果只看自然语言描述,很容易把“没有足够候选人”和“没有视频”混在一起;而 JML 会把这些情况拆开。
当然,JML 也不是自动正确的。它能降低沟通成本,但前提是规格本身足够明确。研讨课中的“击鼓传花”也说明:一旦规格中存在模糊边界,后续实现者会自然地按照自己的理解补全空白,最后就会产生行为差异。
Unit3 的 JUnit 测试不能只停留在“调用一次,看看输出”的层面。因为很多方法的错误不是语法错误,而是状态更新、异常优先级、排序规则、边界条件上的偏差。
我的测试经验主要有以下几点。
一个方法通常对应多个正常分支和多个异常分支。测试时应该先把 JML 中的分支列出来,再为每个分支构造样例。
以 recommendNthUp 为例,需要覆盖:
UserIdNotFoundException;rank <= 0 时抛出 InvalidRankException;NoVideoUploadedException;ColdStartUserException;这些测试比单纯随机生成数据更有针对性,因为它们直接对应规格中的不同路径。
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);
对于推荐、最优贡献者、最热视频这类带排序和 tie-break 的方法,我会在测试中写一个不追求性能、但尽量贴近 JML 的朴素版本作为 oracle。正式代码可以用缓存、堆、哈希表等优化结构,测试代码则用简单排序表达规格含义。
这种做法可以把“实现是否高效”和“行为是否符合规格”分开,降低排错难度。
性能测试不应平均用力,而应该针对风险点构造数据。本单元中比较典型的风险点包括:
cleanSpamComments;例如评论清理如果对每条评论都反复 substring,在长文本和大量评论下很容易超时。针对这种场景,需要专门构造长评论输入进行测试。
三次作业的变化不是简单地“方法越来越多”,而是状态规模、查询复杂度和性能要求都在上升。
第一次作业主要实现用户、视频、关注、取关、观看、接收未看视频、粉丝年龄比例、互相关注数量和最短路。
我的基本设计是:
Network 使用 HashMap<Integer, User> 管理用户;Network 使用 HashMap<Integer, Video> 管理视频;User 中维护 following、followers、receivedVideos;mutualFollowingSum 在关注和取关时增量维护;queryShortestPath 使用 BFS。这一阶段的核心是把模型字段落到容器上。由于数据规模和查询复杂度还不算高,很多关系可以先用 ArrayList 表示,再通过遍历判断是否存在。
但第一次作业也已经埋下了迭代风险:如果所有关系查询都依赖线性遍历,那么后续功能增加后很容易被放大为性能瓶颈。
第二次作业加入了视频类型、播放量、点赞、投币、转发、评论、勋章、贡献者、最热视频和最长年龄下降链等内容。
这一阶段最大的变化是状态之间开始互相影响:
watchVideo 不只修改用户观看记录,还会增加视频播放量;likeVideo 既要维护用户点赞列表,也要维护视频点赞数;coinVideo 会同时修改视频投币数、用户金币、up 主金币和贡献者列表;forwardVideo 会把视频推送给粉丝,并增加转发数;queryLongestDecSeq 和图结构相关,可以通过 graphVersion 做缓存。这时我开始引入“增量维护”的思想。比如互相关注数量不在查询时重新统计,而是在 followUser 和 unfollowUser 时维护;最长下降链则用 graphVersion 判断缓存是否失效。
第二次作业让我意识到:JML 中一个简单的 assignable 后面往往对应多个真实字段的同步更新。只改了其中一个字段,短样例可能过,但复杂状态下就会出错。
第三次作业继续加入全局最佳贡献者、视频推荐、up 主推荐、最有影响力 up 主、用户画像等功能。为了控制 Network 的复杂度,我将部分查询逻辑拆到了 NetworkQueryHelper 中。
第三次迭代中的主要优化包括:
| 优化点 | 原因 | 做法 |
|---|---|---|
| 关注/粉丝判断 | ArrayList.contains 或遍历代价较高 | 额外维护 followingIds、followerIds |
| 年龄比例查询 | 每次遍历所有粉丝会重复计算 | 维护 followerAgeBuckets |
| 未看视频去重/删除 | 同一视频可能多次被推送 | 使用 receivedVideoCounts 辅助判断 |
| 点赞/观看判断 | 查询频繁 | 维护 watchedVideoIds、likedVideoIds |
| 最佳贡献者 | 每次查询遍历贡献者列表 | 在投币时维护 bestContributorId |
| up 主影响力 | 由视频热度累加而来 | 在热度变化时增量更新 uploader 的 influence |
| 推荐第 n 个 up 主 | 全量排序开销大 | 使用大小为 rank 的 PriorityQueue |
第三次作业的重点不再只是“能不能实现”,而是“能不能在频繁查询下稳定实现”。这也是我对容器选择理解最深的一次:容器不是实现细节,而是规格到性能之间的桥。
我主要通过三种方式发现迭代中的变化。
每次新作业开始时,先看接口新增了哪些 model 字段和方法。例如第三次 UserInterface 中出现了 typeCounts、videos、画像和影响力相关方法,这提示实现中需要记录用户观看兴趣和 up 主视频影响力。
如果只是看新增方法,很容易漏掉已有方法语义变化。比如视频热度的计算在第二次和第三次中权重不同,第三次 getHeat 是整数权重:
playCount * 2 + likes * 3 + forwardCount * 4 + coins * 5
如果沿用第二次的浮点热度公式,就会在推荐和最热视频中产生错误。
assignableassignable 能直接告诉我一个操作会影响哪些状态。比如上传视频不仅修改 videos,还会修改粉丝的 receivedVideos,第三次还要修改上传者自己的 videos 列表。
这类信息决定了我是否需要新增容器,或者是否需要在原有方法里补同步更新。
JML 中出现 \sum、\max、\min、\num_of、路径存在性等表达时,我会格外关注性能。它们在规格里很自然,但直接翻译成多层循环可能会超时。
例如:
queryMutualFollowingSum 适合增量维护;queryUpFollowersAgeRatio 适合维护年龄桶;queryBestContributor 适合维护当前最佳贡献者;queryLongestDecSeq 适合缓存;recommendNthUp 可以用堆保留前 rank 个候选。本单元的性能瓶颈大多来自“查询方法反复扫描大容器”。我的定位思路是先从复杂度上判断风险,再用极端数据验证。
第一次作业中使用 ArrayList 保存关注关系是够用的,但到了第三次,isFollowing、containsFollower、hasWatchedVideo、hasLikedVideo 会被推荐和测试频繁调用。于是我在保留列表顺序的同时增加了 id 集合。
这种“双容器”设计需要注意一致性:添加、删除时必须同时更新列表和集合。它提高了查询效率,但也增加了维护成本。
粉丝年龄比例、up 主影响力、最佳贡献者都属于可以增量维护的数据。相比每次查询时重新遍历,增量维护能把开销转移到修改操作中。
不过增量维护必须严格受 JML 约束。比如 pure 查询方法不能因为想“顺手刷新缓存”而修改可见状态;缓存字段也要确保只在依赖状态变化时失效。
cleanSpamComments 是一个很容易低估的性能点。短评论下,朴素地从每个位置 substring 计数也能通过;但当评论很长、评论数量很多时,字符串复制和重复匹配会被放大。
更稳妥的做法是使用 indexOf(keyword, fromIndex) 滑动查找,并在删除评论后同步更新 commentIdSet,保证后续 containsComment 仍然正确。
recommendNthUp 只需要第 rank 个结果,不一定需要对所有候选完整排序。使用大小为 rank 的优先队列,可以在候选数量较大时减少排序成本。
同时,推荐问题的 tie-break 特别容易写反:分数越高越优先,分数相同 id 越小越优先。测试里需要专门构造同分样例。
结合三次作业的实现和测试,我将容易出现的问题归纳为以下几类。
JML 的异常分支有明确条件,实际代码必须按照条件优先级检查。例如 recommendNthUp 中,如果用户不存在,应先抛 UserIdNotFoundException;用户存在但 rank <= 0 时,才抛 InvalidRankException。
这类 bug 的原因是实现时容易按“业务直觉”检查参数,而不是按 JML 条件检查。解决方式是为每个异常分支写单独测试,并在测试中构造多个错误条件同时成立的输入。
推荐、画像、最优查询等方法往往需要临时排序或筛选。如果直接复用真实容器并排序,就可能破坏对象状态。
我在测试中用快照比较来防止这类问题。凡是 JML 标注 pure 或 assignable \nothing 的方法,都应该默认加一个“调用前后状态一致”的测试。
最热视频、最佳贡献者、推荐 up 主等方法都有“分数相同取 id 小者”的规则。实现中如果使用 PriorityQueue,比较器尤其容易写反,因为堆顶需要保留的是当前前 rank 个候选中最差的那个。
解决方法是构造同分样例,不要只测分数不同的情况。
同一个视频可能通过上传和转发被多次放入用户的未看列表。观看视频后,规格要求用户不再拥有该视频的未看记录,因此需要删除所有对应项,而不是只删除第一次出现的位置。
第一次实现时如果只写一次 remove,在重复推送场景下就会留下脏状态。后续我使用循环删除或 removeIf 保证删除完整。
评论清理既有正确性风险,也有性能风险。正确性上,删除评论后需要同步维护评论 id 集合;性能上,长文本重复匹配可能导致超时。
这个问题提醒我:字符串题看起来局部,但一旦输入规模被放大,就会成为整份程序的瓶颈。
在规格驱动开发中,大模型比较擅长做三类事情:
但大模型也有明显局限。它容易给出“语义上看起来正确”的实现,却忽略复杂度和容器一致性。例如把 JML 里的 \max、\sum 直接翻译成多层循环,短数据能跑,大数据就可能超时;又比如只记得更新 ArrayList,忘记同步更新辅助 HashSet。
所以我认为 Code Agent 更适合做辅助开发,而不是替代规格阅读。比较有效的使用方式是:
换句话说,大模型可以提高“发现问题”的效率,但最终的规格理解和架构取舍仍然需要人来负责。
研讨课中的“JML 击鼓传花”给我的感受很直接:规格如果不精确,传递次数越多,偏差越容易被放大。
在传递过程中,我确实感受到了一些容易出问题的地方。例如:
assignable 写得过宽,会让实现者误以为可以修改更多状态;pure 语义没有被重视时,查询方法可能产生副作用。这些问题不一定都是语法层面的 bug,更多是“规格表达不完整”导致的理解偏差。
我认为需求在传递过程中通常不会被有意改变,但边界会被无意识地改变。比如“推荐一个用户”传到后面,可能有人默认排除已关注用户,有人默认不排除;有人认为没有候选者应返回特殊值,有人认为应该抛异常。
这说明自然语言需求和形式化规格之间存在差距。JML 的价值就在于尽量把这种差距显式化。
如果以后多人组队编程,我认为至少需要以下规则:
其中最重要的是“用测试固定共识”。口头讨论很容易遗漏细节,而一组边界测试可以把团队对需求的理解沉淀下来。
Unit3 让我真正体会到,规格驱动开发不是多写一些注释,而是把需求、实现和测试连接起来的一种方法。
从第一次作业到第三次作业,我的实现从简单容器逐步演进到增量维护、缓存和辅助查询类。这个过程也让我意识到:正确性和性能并不是分开的。很多性能优化本质上是在维护另一份规格等价的状态,而只要存在冗余状态,就必须用测试保证它们始终一致。
JUnit 在本单元中的作用也不只是“测对不对”,更是帮助我确认自己是否真正理解了 JML。能写出覆盖异常优先级、边界条件、纯查询副作用和复杂排序的测试,说明我对规格的理解才算比较扎实。
最后,研讨课让我认识到,规格本身也需要被测试和评审。一个好的 JML 应该让实现者少猜一点,让协作者少误解一点,让后续迭代少踩一点坑。这也是我在 Unit3 中最大的收获。