OO Unit3 总结

周洛汐-ZC061005 2026-05-28 12:23:31

 

Unit3 的三次作业给我的感觉和前两个单元不太一样。前两个单元更多是在自己设计对象、拆分层次,而这一单元的重点明显转到了“根据规格实现程序”。也就是说,代码不是从一段自然语言需求开始写,而是从 JML 规格开始写。

刚开始看 JML 的时候,我其实有点不适应。它不像普通题面那样直接告诉我“你要做什么”,而是通过 requiresensuresassignablesignals 等形式把正常情况、异常情况、状态变化都列出来。读起来比较慢,但是读懂之后会发现它比自然语言更不容易产生歧义。尤其是异常优先级、方法是否有副作用、数组顺序是否要保持这些问题,如果只看自然语言,很容易靠感觉写;但 JML 会把这些东西写得比较死。

这三次作业我的代码整体围绕一个短视频社交网络迭代:从用户、关注关系、视频接收,到播放、点赞、投币、评论,再到推荐、影响力、用户画像等功能。整个过程中我最大的感受是:JML 能告诉我“结果应该是什么”,但是不会直接告诉我“怎么做才不会超时”。所以真正写代码时,还要自己把规格里的量词、数组、集合转换成合适的数据结构。

JML 和规格驱动开发的理解

  • 前置条件和异常条件。比如一个方法可能要求用户存在、视频存在、类型合法,JML 会分别写出正常行为和异常行为。实现时最容易出错的地方其实不是正常流程,而是异常优先级。例如 recommendNthUp 这类方法,既可能用户不存在,也可能 rank 非法,也可能没有视频,还可能候选不足。如果判断顺序和 JML 不一致,即使主要逻辑是对的,也会因为抛错类型不对而出错。
  • 状态变化。assignableensures 其实是在告诉我们:这个方法到底改了哪些对象,哪些对象不能变。比如 watchVideo 不只是把视频从未观看列表里删掉,还会增加播放量,并且在后续作业里还影响用户兴趣和 up 主影响力。写代码时如果只盯着方法名,很容易漏掉这些连带变化。
  • 3.查询方法。JML 里的很多查询方法是 pure 的,也就是不能改状态。这一点在自测时很重要,因为有些查询方法可能为了偷懒顺手排序、删除或更新缓存,如果不注意,就会让第二次查询结果和第一次不一样。我在测试中也专门做过类似“调用前后快照不变”的检查。

但JML描述的是功能正确性,完全不会直接提示性能风险。像 queryMutualFollowingSumqueryMostPopularVideoqueryLongestDecSeq 这些方法,如果严格按照 JML 里的求和或者存在量词暴力实现,在数据量大时就会出问题(哈哈,哈哈哈哈哈哈。。。)。所以无脑翻译是不行的,会吃tle。

三次作业的迭代过程

第一次作业

第一次作业主要包括用户、视频、关注关系、视频接收、观看视频、查询粉丝年龄比例、查询互关数、查询最短关注路径......

我的主要类是 NetworkUserVideo

  • LinkedHashMap<Integer, UserInterface> 保存用户
  • LinkedHashMap<Integer, VideoInterface> 保存视频
  • mutualFollowingSum 维护互关对数

一开始最自然的想法是,queryMutualFollowingSum 每次查询时遍历所有用户两两判断是否互相关注。这个写法很符合 JML 里的求和形式——但复杂度是 O(n²),你知道的,看到这个东西就意味着我强测要爆炸了

User 中一开始性能也炸了,后来改用了 LinkedHashMap。对于粉丝年龄比例,我在 addFollowerremoveFollower 时维护了一个长度为 4 的 followerAgeCounts 数组,查询年龄比例时直接除以粉丝数就好

第一次作业里另一个比较重要的数据结构是“未观看视频列表”。最开始用 ArrayList 会比较直观,但是在头部插入和删除指定视频时复杂度比较高(目移)。后面我改成了双向链表加索引的形式,每个接收视频对应一个 VideoNode,再用 HashMap<Integer, VideoNode> 定位视频节点。这样接收视频、观看后删除、查询前五个未观看视频都比较方便

就是,以上提到的所有性能相关的问题,都是我被炸过的地方(目移)

第二次作业

第二次作业在第一次的基础上增加了视频类型、播放量、点赞、投币、勋章、评论、热度、最长下降序列等功能

我在 Network 里新增了:

  • HashMap<String, TreeSet<VideoInterface>> videosByType,按类型维护视频集合;
  • VideoHeatComparator,按照热度降序、id 升序排序;
  • longestDirtylongestDecSeqCache,缓存最长下降序列结果。

queryMostPopularVideo 如果每次都遍历所有视频找最大热度,复杂度会比较高,为了防止hw1惨剧,我按视频类型分别维护 TreeSet,视频热度发生变化时,比如播放、点赞、投币、转发,就先把视频从 TreeSet 中移除,修改热度相关字段,再重新加入

receivedVideos 的设计也发生了变化。第一次作业中,一个视频在未观看列表里更像是“是否存在”的关系,但第二次作业有了转发,接收同一个视频可能出现多次。so我把索引从 HashMap<Integer, VideoNode> 改成了 HashMap<Integer, ArrayList<VideoNode>>。这样观看某个视频时,可以把列表中所有对应节点都删除

cleanSpamComments 是我觉得比较容易写错的方法。它要返回删除数量和关键词出现次数的最大值。这里有几个边界点:大小写是否敏感、关键词出现次数是最大值还是总和、删除评论后 commentId 是否可以重新使用、清理失败时是否影响其他视频。这次被炸的就是Junit了

最长下降序列 queryLongestDecSeq 我用了 dirty flag。只有当新增用户、关注关系变化、取消关注关系变化时,才把 longestDirty 设为 true

第三次作业

第三次作业又加入了全局最佳贡献者、视频推荐、up 主推荐、最有影响力 up 主、用户画像等内容。到这个阶段,如果所有逻辑都塞在 Network 里,类会超过500行非常臃肿。于是我做了拆分:

  • Network-接口实现、异常顺序和全局容器
  • NetworkOps-有副作用的操作,比如关注、取关啥的
  • NetworkAlgorithms-负责纯算法类逻辑,比如 BFS、最长下降序列、推荐排序、全局贡献者统计
  • ReceivedVideos-单独封装未观看视频链表和索引
  • Types-统一管理视频类型
  • User-维护兴趣、影响力、投稿视频、贡献者等状态

第三次作业里,我在 User 中维护了 typeCountsinfluenceCounts。用户观看某种类型的视频后,对应类型的观看计数增加;视频热度变化后,up 主对应类型的影响力也要更新

这个阶段最容易出错的地方是推荐类方法的边界(对,我又被炸了)

第三次作业代码的另一个变化是,我把第二次作业中写在 User 里的接收视频双向链表抽成了 ReceivedVideos 类。这样 User 不需要直接管理链表节点细节,只需要调用 addremoveAllqueryFirstFive 等方法。这个拆分虽然不影响最终功能,但让代码更容易读,也更容易定位问题。

如何发现性能瓶颈

强测和互测被炸(x)

第一是看 JML 中新增或变化的 model 字段。比如第二次作业中视频多了播放量、点赞数、转发数、投币数、评论等字段,用户多了已观看视频、已点赞视频、勋章、贡献者等字段。字段一多,就说明原来的容器不一定够用了。

第二是看 assignableensures。如果一个方法的 assignable 变多,说明这个方法会影响更多状态。例如 watchVideo 后来不仅要删除未观看视频,还要加入已观看集合、增加播放量、影响用户兴趣和 up 主影响力。每次看到这种变化,我都会检查:原来的方法是不是只改了局部状态?有没有遗漏连带更新?

第三是看查询方法是否会被反复调用。JML 中的求和、存在、排序都很直观,但直接实现不一定合适。比如互关数可以增量维护,粉丝年龄比例可以维护计数数组,最热视频可以用 TreeSet,最长下降序列可以用缓存加 dirty 标记。性能瓶颈一般不是靠猜,而是在看到“每次查询都遍历所有对象”时就要警惕。

比较典型的bug修复优化包括:

  • queryMutualFollowingSum 从每次 O(n²) 改成 O(1) 查询
  • 粉丝年龄比例从每次遍历粉丝改成维护年龄段计数
  • 未观看视频从 ArrayList 改成链表加索引
  • 最热视频从遍历查找改成分类型 TreeSet
  • 最长下降序列用 dirty flag 缓存结果
  • 第三次作业把操作和算法拆出去

JUnit 测试

这次我会更关注边界和副作用。

我写测试时主要采用几种思路——ai思路和自己的思路(不是)

*针对特殊边界写样例,年龄分组边界、空用户、稀疏 id、rank 等于候选数量加一、没有观看历史、没有视频、重复 commentId 等。

*测试异常优先级,recommendNthUp 的测试中就专门构造了用户不存在、rank 非法、没有视频、候选不足等情况

*测试方法老不老实,对于查询方法,我会构造一个 mirror 对象或者快照对象,调用查询后再比较状态是否被改变。比如评论清理相关测试里,我会保存视频清理前的播放量、点赞数、转发数、投币数等字段,确认清理评论不会影响这些无关状态

*针对排序规则测试,推如果不单独测,很容易默认用容器遍历顺序,导致炸了

一个很方便的地方是,代码后续重构时,这些测试能帮我确认没有把旧功能改坏

我程序中比较典型的问题

把 JML 的求和直接翻译成暴力查询

第一次作业里,queryMutualFollowingSum 按 JML 的形式写,很自然就是两层循环枚举所有用户对,但这样每次查询都是 O(n²),就,寄了

未观看视频列表的数据结构一开始不够贴合需求

未观看视频有几个特点:新视频要插到最前面,查询只看前五个,观看后要删除指定视频。用 ArrayList 虽然好写,但头插和删除都会产生移动开销。到了第二次作业有转发后,同一个视频还可能被多次接收,原来“一视频一节点”的想法也不够了

这个问题的原因是我一开始只看到了“列表”这个表面形式,没有细分它有哪些操作,后来改成链表加索引,第三次又把它封装成 ReceivedVideos

TreeSet 中对象热度变化

第二次作业中,最热视频用 TreeSet 维护。这里有一个很容易忽略的问题:如果视频对象已经在 TreeSet 中,直接修改它的播放量、点赞数等字段,比较器依赖的热度变了,但 TreeSet 不会自动重新排序

评论清理的返回值和副作用

这个问题的原因是我一开始容易只盯着“删除包含 keyword 的评论”,而忽略返回值定义和索引维护

推荐类方法的候选集合容易选错

第三次作业的 recommendNthUp 让我印象比较深,没投稿的普通用户也可能是候选,只是分数为 0,就很容易漏掉

此外,rank 是 1-based,同分时按 id 小者优先,候选不足时抛 ColdStartUserException,全是规格细节。总之:先整理,再写代码

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

写规格其实比想象中更难。自己写代码时,很多需求可以靠脑子里的默认理解补上;但一旦规格要传给别人,这些默认理解就会变成信息差

大家对边界条件的理解很容易不一致。比如空集合时返回什之类的。这些问题如果规格里没写清楚,后面的人就可能按自己的理解实现,而且每个人的理解都看起来很合理

最容易发生的变化有两种:需求被简化了;边界被忽略了

如果以后多人组队编程,我觉得应该至少应该:

  • 先统一术语
  • 写一张异常优先级表
  • 每个方法至少配两三个例子
  • 指定维护规则

总之就是,人类总是很难相互理解的啊.jpg

关于使用大模型

我的感觉是,大模型比较适合:帮忙把一段 JML 翻译成自然语言,提醒可能的边界情况,生成一些 JUnit 测试框架

但它不一定注意性能,它也不一定理解已有架构

所以我觉得大模型更适合作为辅助工具,真正关键的事情还是要自己做,不能只看生成代码能不能通过编译

总结

三次作业下来,虽然中间有不少容易踩坑的地方,但也正是这些坑算是让我更理解 JML 的意义

后面如果再做类似的多人协作或规格驱动开发,我会更早地把各种信息整理出来,而不是等代码写完之后再补。毕竟规格理解错了,代码写得再快也没有意义

以及注意性能

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

305

社区成员

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

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