OO Unit3总结

吕世英-24373352 2026-05-28 12:10:54

OO Unit3总结

这一单元感觉更强调对 JML 的阅读、翻译和执行能力。代码本身未必特别复杂,但一旦规格理解有偏差,bug 往往会非常隐蔽。


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

刚开始看 JML 的时候,我更多是把它当成一种“更严格的注释”。后来真正写代码时才发现,它其实更像是方法的合同:requires 规定调用前必须满足什么,ensures 规定调用后必须保证什么,assignable 规定我到底能改哪些状态,signals 则规定异常情况下应该发生什么。

这一单元让我印象最深的是,JML 中很多看起来“不起眼”的条件,在实现中都可能变成坑。例如一个方法是不是 pure,不只是看它有没有返回值,而是要保证它不修改对象状态;一个方法的 assignable 也不是随便写写,而是直接限制了实现中能动哪些容器和对象。如果查询方法为了图方便顺手更新了缓存,就要非常小心这个缓存是否属于规格允许修改的范围。

规格驱动开发带来的好处是比较明显的:
在写实现前,需求边界已经被形式化地写出来了。我不需要先猜测“这个方法大概想干什么”,而是可以从前置条件、后置条件、异常行为一步步还原业务逻辑。这样写出来的代码更容易自查,也更容易设计测试。

但它的问题也很明显:
JML 本身并不会自动保证我写得高效,也不会替我设计好数据结构。它只告诉我“要满足什么”,并不告诉我“怎么满足”。所以如果只是机械地把 \forall\exists 翻译成遍历,很容易写出正确但超时的程序。规格驱动开发并不是替代架构设计,而是把“正确性边界”提前固定下来,之后仍然需要自己考虑容器、缓存和复杂度。


二、JUnit 测试的经验

这一单元的 JUnit 测试让我意识到,测试不能只测返回值,还要测“方法有没有偷偷改状态”。

最开始我写测试时比较习惯构造几个样例,然后判断结果是否等于预期。但 Unit3 的 JUnit 要求更像是在检查 JML,因此还需要考虑:

  1. 正常情况下返回值是否符合 ensures
  2. 异常情况下是否按正确顺序抛出异常;
  3. pure 方法调用前后对象状态是否完全一致;
  4. 不该修改的容器是否真的没有被修改;
  5. 边界条件,比如空集合、重复 id、不存在用户、rank 非法等。

例如第三次作业中测试 recommend_Nth_up 时,我除了检查推荐结果,还专门检查了它不会修改用户、视频、互关数、最长下降链等状态。因为推荐方法本质上应该是查询,如果它为了计算排名而改了用户状态,那即使返回值对了,也是不符合规格的。

我比较有收获的一点是:测试用例最好围绕规格拆分,而不是围绕实现拆分。也就是说,不要只想着“我的代码里有哪个分支”,而是想“JML 里有哪些行为必须被保证”。这样更容易覆盖到别人代码中可能出现的错误。


三、三次作业的迭代过程分析

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

第一次作业主要是搭建视频平台的基本模型,包括用户、视频、关注关系、观看关系、互关数量、最短路径等功能。

这一阶段我主要关注的是容器关系的正确维护。例如用户之间的 follow / unfollow 不是只改一个人的关注列表,而是要同时维护关注者和被关注者两边的关系。query_mutual_following_sum 也让我意识到,如果每次查询都重新遍历所有用户对,虽然实现简单,但性能上并不理想。因此我在实现中维护了一个 mutual 缓存,在 follow 和 unfollow 时增量更新。

这次作业让我形成了一个基本习惯:对于查询频率高、更新逻辑清晰的数据,可以考虑用缓存维护;但缓存的更新必须和状态修改绑定在一起,否则很容易出现“主体数据是对的,查询结果是旧的”的问题。

2. 第二次作业:加入互动、硬币和评论

第二次作业在第一次的基础上加入了硬币、点赞、投币、转发、评论、粉丝勋章等功能,复杂度明显上升。这里最大的变化是,很多操作开始具有“事务性”。比如投币不仅要修改视频的硬币数和热度,还要修改投币用户的余额、UP 主的余额、贡献记录等。

这一阶段我明显感觉到,JML 中的后置条件虽然是分散写的,但实现时必须把它们当成一个整体来看。一个方法可能涉及多个对象,如果中间任何一步异常顺序处理错了,或者先修改状态再发现异常,就会留下不一致状态。

评论清理 clean_spam_comments 是我觉得比较典型的例子。它看起来只是删除包含关键字的评论,但真正实现时还要考虑删除数量、关键字最大出现次数、评论 id 与内容的一一对应关系,以及多次清理时的行为。我在这里使用了更专门的字符串匹配思路,而不是每次都暴力地做大量重复扫描。

3. 第三次作业:加入推荐系统

第三次作业加入了智能推荐,包括推荐视频、推荐 UP 主、查询用户画像、查询最有影响力 UP 主等。这一阶段的重点从“维护关系”进一步变成了“基于已有状态进行排序和选择”。

推荐功能给我的最大提醒是:不能只看方法本身,还要看它依赖哪些已有数据。例如推荐视频依赖视频热度和用户兴趣,推荐 UP 主依赖用户兴趣向量和 UP 主影响力。前两次作业中积累的播放、点赞、投币、分区观看次数等状态,在第三次作业中都变成了推荐算法的输入。

这也说明了迭代开发中的一个问题:前期容器设计如果过于随意,后期会很难补。比如如果之前没有方便地维护视频分区、用户观看记录、UP 主影响力,那么第三次作业就只能到处遍历,既难写也容易超时。


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

我总结下来,发现变化主要有三个入口。

第一是看接口和指导书中的 Modify 标记。
比如第二次作业中 User 增加了 coins、watchedVideos、likedVideos、medals 等状态,Video 增加了 type、播放数、点赞数、转发数、投币数和评论区;第三次作业中 User 又新增了 typeCounts 和 videos。这些变化说明原来的类不能只“补几个方法”,而是需要重新审视内部状态的组织方式。

第二是看新增方法依赖哪些旧状态。
例如 recommend_video 看似是新方法,但它依赖观看记录、视频热度、视频分区和用户兴趣。如果旧的 watch_video 没有正确维护 typeCounts,推荐功能就会出错。所以迭代时不能只写新增方法,还要反过来检查旧方法是否需要承担新的状态维护责任。

第三是看异常和边界条件有没有新增。
比如第二次作业加入了未观看视频不能互动、硬币不足、评论为空等异常;第三次作业加入了冷启动、rank 非法、没有视频、没有用户等异常。这些异常往往决定了方法的判断顺序,也决定了实现是否满足规格。

我觉得比较有效的做法是每次迭代先列一张“状态变化表”:
哪些类新增了字段,哪些方法会修改这些字段,哪些查询方法依赖这些字段。只要这张表能列清楚,代码结构就不会乱。


五、如何发现程序的性能瓶颈

性能瓶颈一般出现在两个地方:高频查询和嵌套遍历。

第一次作业中,互关数量和最短路径就是典型例子。互关数量如果每次查询都遍历所有用户,数据一大就会很慢,所以我选择在 follow / unfollow 时维护 mutual。最短路径则用 BFS,根据关注关系即时搜索。

第二次作业中,视频热度、最受欢迎视频、最佳贡献者这类查询也容易成为瓶颈。如果每次查询都遍历所有视频或所有贡献者,在数据量大时就不划算。因此我在实现中使用了一些缓存结构,在状态变化时同步更新排名信息。

第三次作业中,推荐 UP 主的排序也需要注意。如果每次把所有候选人完整排序,复杂度会比较高。我在实现时更倾向于用优先队列维护前若干名,尤其是在只需要第 rank 名时,没有必要总是完整排序所有候选。

我发现性能优化不能等到最后再做。因为有些优化会反过来影响类的字段设计。如果一开始没有维护必要的映射、计数或排名结构,后面再补就很容易牵一发而动全身。


六、自己程序中出现过的 bug 及原因分析

我的程序中比较容易出问题的地方主要有以下几类。

1. 异常判断顺序错误

有些方法可能同时满足多个异常条件,但规格或官方包通常隐含了检查顺序。如果顺序错了,功能逻辑看起来没问题,评测却会认为输出异常类型不对。

这个问题的根本原因是我一开始只关注“会不会抛异常”,而没有特别关注“先抛哪个异常”。后来我在写方法时会先把异常判断集中放在前面,并按照规格顺序逐条写清楚。

2. 缓存更新遗漏

使用缓存能提高性能,但也带来了新的风险。比如互关数量、视频热度排名、UP 主影响力、最佳贡献者等,只要某个修改状态的方法忘记同步更新缓存,查询结果就会和真实状态不一致。

这个问题的原因不是算法不会写,而是状态之间的依赖没有整理清楚。后来我会把“修改主状态”和“修改派生状态”放在同一个代码片段里,避免一个改了另一个忘了。

3. 边界条件考虑不完整

空数组、空用户集、没有视频、没有观看记录、rank 非正、评论为空,这些边界在普通样例中不一定出现,但在规格中都很重要。

这类 bug 的原因是我写代码时容易先按正常业务流程走,再补异常和边界。后面我发现更稳妥的方式是先写异常和边界,再写正常逻辑。

4. 查询方法误改状态

有些推荐或查询方法内部会临时计算排名,如果实现时不小心复用了真实容器,或者为了方便更新了某些字段,就可能违反 pure 或 assignable 的要求。

这个问题让我意识到,查询方法最好只读已有状态。如果需要临时结构,就新建局部变量,而不是借用对象内部容器。


七、大模型在规格驱动开发中的使用体会

在 Unit3 中,我觉得大模型比较适合做三类事情。

第一是解释 JML。
有些 JML 条件比较长,尤其是带有量词、蕴含、old 表达式的时候,直接看容易漏条件。把规格拆成自然语言后,会更容易发现它到底要求了哪些状态变化。

第二是辅助生成测试思路。
比如我可以让大模型根据某个方法的 JML 列出正常情况、异常情况、边界情况和 assignable 检查点。它不一定能直接生成完全可用的 JUnit,但可以帮助我补充测试维度。

第三是检查实现和规格是否对齐。
把方法实现和对应规格放在一起,让大模型指出可能遗漏的边界或状态修改,有时能发现一些自己看顺眼了的错误。

不过,大模型也有明显局限。
它在根据 JML 写代码时,往往更关注“逻辑正确”,容易忽略效率问题。例如它可能会把所有查询都写成全量遍历,也不一定会主动设计缓存、TreeSet、HashMap 或优先队列。它也可能忽略项目中已经存在的容器结构,生成一段看起来正确但和整体架构不兼容的代码。

所以我觉得比较合适的使用方式是:
让大模型辅助理解规格、生成测试方向、检查明显漏洞;但核心的数据结构、缓存策略和异常顺序,还是要自己掌握。


八、如何用大模型进行基础单元测试

我比较推荐的方式不是直接让大模型“写一个完整测试类”,而是分步骤使用。

首先,把方法的自然语言需求或 JML 发给它,让它列出测试点。
例如:正常返回、异常路径、空状态、重复元素、边界 rank、状态不变性等。

然后,再让它针对每个测试点生成小规模数据。
小规模数据的好处是容易人工验证,不会出现测错了自己还看不出来的情况。

最后,自己检查并改写生成的 JUnit。
尤其要检查异常类型、异常顺序、对象状态是否需要深拷贝或 strictEquals,以及测试是否依赖了实现细节。

大模型生成的测试可以作为草稿,但不能完全信任。测试代码本身也是代码,也需要被审查。


九、Code Agent 的使用体会

我没有把 Code Agent 当成完全自动写代码的工具,而是更偏向于让它做辅助阅读和局部修改。比如让它帮我定位某个方法涉及哪些字段,或者检查某个新增状态是否在所有相关方法中都被维护。

Code Agent 比普通问答更适合处理跨文件问题,因为 Unit3 的状态往往分布在 NetworkUserVideo 等多个类中。如果只看一个方法,很容易忽略另一个类中的容器变化。

但它也不能替代人工判断。特别是 JML 中的异常顺序、assignable 限制和性能约束,Code Agent 不一定总能把握准确。我的经验是,Code Agent 可以帮我节省搜索和定位时间,但最后是否修改、怎么修改,仍然需要自己根据规格确认。


十、JML“击鼓传花”游戏的感悟

第二次研讨课上的 JML“击鼓传花”让我对规格失真有了很直观的感受。这个游戏的过程其实很简单:一个人先写自然语言需求,后面的人不断在自然语言和 JML 之间转换。但传了几轮之后,最开始的一些边界条件、业务语义或者限制条件就很容易发生变化。

我发现的一个问题是,很多 bug 并不是因为某个人完全不会写 JML,而是因为他对上一轮文字的理解和原作者不一样。例如“返回第一把可用的伞”和“返回任意一把可用的伞”在日常语言里差别不大,但在 JML 中就是完全不同的规格。再比如“没有可用对象时不修改状态”这样的要求,如果翻译时没有写进 assignable 或 ensures,后面的人就可能默认允许修改。

在传递过程中,需求和边界确实发生了变化。自然语言中比较细的条件,比如 null、空数组、重复元素、是否修改原容器、返回值的优先级,很容易在下一轮被简化掉。而 JML 中如果量词写得不够精确,反向翻译成人话时又可能变成另一个业务。

这个活动让我意识到,多人组队编程时,大家对需求的理解不统一是非常危险的。一个人以为“显然应该这样”,另一个人可能完全不是这么理解的。如果不提前对齐,最后代码合并时就会出现接口能对上、语义对不上的问题。

我认为以后多人合作时,可以采取以下措施减少信息差:

  1. 先写统一的需求文档,不只写功能,还要写边界条件和异常情况;
  2. 对关键方法写清楚输入、输出、副作用和异常顺序;
  3. 对容易歧义的词做约定,比如“第一个”“任意一个”“所有”“至少一个”;
  4. 开发前一起过一遍典型样例和反例;
  5. 每次修改容器结构或缓存策略时,在组内同步说明;
  6. 对查询方法特别标注是否允许修改状态;
  7. 用单元测试把大家达成共识的需求固定下来。

我觉得“击鼓传花”最有价值的地方不是让我们发现某一个具体 JML bug,而是让我们看到:需求只要经过几次不精确的转述,就会发生偏移。规格化开发的意义就在这里,它强迫我们把模糊的自然语言变成可以检查、可以讨论、可以测试的形式。


十一、总结

Unit3 让我对“正确性”有了新的理解。以前我更关注程序能不能通过样例,现在会更关注它是否满足完整规格:正常行为、异常行为、状态变化、边界条件和性能要求是否都被考虑到了。

JML 提供的是一套明确的契约,但真正写好程序还需要合适的数据结构、清晰的状态维护和系统的测试。三次作业从基础社交网络,到互动系统,再到推荐系统,逐步把这个问题放大:越到后面,越能看出前期架构和容器设计的重要性。

总体来说,这一单元最重要的收获是:不要把规格当注释,也不要把测试当补丁。规格应该指导实现,测试应该验证规格,而代码则要在正确性和效率之间找到平衡。

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

305

社区成员

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

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