309
社区成员
发帖
与我相关
我的任务
分享JML 把“需求”拆成了更接近程序行为的形式。自然语言指导书会描述业务背景,例如用户关注、视频互动、推荐视频;而 JML 更关注一个方法在调用前后必须满足什么条件:哪些对象应该存在,哪些异常应该抛出,哪些容器应该改变,哪些状态必须保持不变。
在 JML 约束下,除了能跑,还必须证明这个函数没有改变任何不该改变的状态。例如 queryMutualFollowingSum、recommendNthUp 这类方法,从业务上看只是查询,但如果在查询时顺手重排用户列表、补全缓存或修改中间容器,就可能违反 pure 语义。
另外,JML 也重视异常顺序。当很多错误输入同时满足多个异常条件时,最终抛出的异常必须和规格一致。实现时必须要按照规格规定的优先级逐项检查。
第一次规格化作业的核心对象是 User、Video 和 Network。我的整体设计比较直接:
Network 使用 HashMap<Integer, User> 维护用户,用 HashMap<Integer, Video> 维护视频,保证按 id 查询是常数级。User 内部维护关注列表、粉丝列表、粉丝年龄段计数,以及收到但未观看的视频列表。Video 只保存视频 id 和上传者 id。这次作业中最需要注意的是关注关系带来的派生状态。queryMutualFollowingSum 如果每次查询都遍历所有用户对,复杂度会比较高,而且 pure 方法中临时修正状态也不合适。因此我在 Network 中维护了 mutualFollowingSum,在 followUser 和 unfollowUser 时增量更新。这样查询时可以直接返回缓存值。
另一个性能点是用户收到的视频。指导书中 query_received_unwatched_videos 只需要返回前若干个未观看视频,因此我没有只用普通列表线性删除,而是用链表节点加索引的方式维护未观看视频。这样在 watchVideo 时可以通过索引找到节点并删除,同时保留接收顺序。
queryShortestPath 则是标准图搜索问题。我使用 BFS,从起点沿关注关系扩展,找到终点后返回距离;如果不可达则抛出 UncessException。
第二次迭代在原有社交网络上加入了大量业务状态:
Network 新增充值、点赞、投币、转发、评论、清理垃圾评论、购买勋章等方法。这一轮最大的变化是操作更像“事务”。例如投币不仅要修改观看者硬币,还要修改 UP 主硬币、视频硬币数和 UP 主收到的贡献记录。只要前置条件不满足,就不能改动任何状态。因此实现时必须先完成所有合法性检查,再统一执行状态修改。
评论区我使用 LinkedHashMap<Integer, String> 保存评论 id 和内容。这样既能快速判断评论 id 是否重复,又能保留评论插入顺序,便于 getCommentIds() 和 getCommentContents() 返回一一对应的结果。cleanSpamComments 则遍历评论,删除包含关键字的评论,并统计被删除评论中关键字出现次数的最大值。
这次迭代也暴露出旧容器设计需要调整的问题。第一次作业中,一个视频对一个用户而言只需要判断“是否收到”;第二次作业出现转发后,同一个视频可能通过不同路径进入未观看列表。于是未观看视频索引从单个节点扩展为 videoId -> 节点队列,避免同一视频多次接收时删除逻辑错误。
第三次迭代继续保留前两次功能,并加入推荐视频、推荐 UP 主、查询最有影响力 UP 主、用户兴趣画像、全局最佳贡献者等功能。
这次主要增加了两个和推荐有关的状态:
User 中维护 typeCounts,记录用户在每个分区观看过的视频数量。User 中维护自己上传的视频集合,用于计算某个 UP 主在指定分区的影响力。推荐视频的核心是把视频热度和用户兴趣相乘。用户兴趣由观看历史决定,因此每次 watchVideo 时必须同步更新 typeCounts。推荐 UP 主则要排除自己和已经关注的用户,然后根据当前用户兴趣与候选 UP 主影响力计算分数,再按分数和 id 进行排序。
第三次作业还有一个细节:指导书说明 hw10 中原有的 queryMostPopularVideo 和 getHeat 的浮点类型改为整数类型。这个变化看似很小,但会影响比较逻辑和接口签名。如果只是在旧代码上局部添加推荐方法,而不重新核对接口,就很容易遗漏这种既有方法的变化。
三次作业共用同一套业务背景,因此最危险的地方不是新增方法,而是旧方法和旧容器在新规格下含义发生了变化。我的做法是把变化分成三类:
第一类是构造方法和接口签名变化。例如 Video 在第一次作业中构造方法是 Video(int id, int uploaderId),第二次开始变成 Video(int id, int uploaderId, String type)。这种变化必须优先处理,否则 Runner 无法正确反射构造对象。
第二类是已有状态的语义变化。例如“未观看视频”在第一次作业中更像一个去重集合,但转发功能加入后,同一视频可能重复出现在接收列表中,因此原来的 HashMap<Integer, VideoNode> 不够,需要扩展成 HashMap<Integer, ArrayDeque<VideoNode>>。
第三类是查询方法的性能变化。例如互相关注数、年龄段比例、用户兴趣度都可以每次查询时重新遍历计算,但当查询频繁时就会成为性能瓶颈。因此我把一些高频派生结果维护成增量状态:关注关系改变时更新 mutualFollowingSum,粉丝改变时更新年龄桶,观看视频时更新分区观看次数。
这种方式的核心是:不要只看这次新增了什么方法,还要检查旧容器是否仍然能表达新规格。
本单元的数据规模虽然不是特别大,但如果完全按 JML 的量词形式直译成多重循环,仍然可能超时。
queryMutualFollowingSum 是最典型的例子。规格可以理解为统计所有互相关注对,如果查询时双重遍历用户,复杂度是 O(n^2)。我将其维护为缓存值,在关注和取关时增量更新,使查询变成 O(1)。
queryUpFollowersAgeRatio 也类似。如果每次都遍历 UP 主所有粉丝并分类,在大量查询下会重复计算。因此我在 User 中维护四个年龄段的粉丝计数,关注和取关时更新。
未观看视频列表的瓶颈在删除。单纯用 ArrayList 保存接收顺序,观看视频时删除某个视频需要线性查找;使用链表节点和索引后,可以在已知 videoId 的情况下快速定位节点,同时保留顺序。
推荐系统的复杂度主要来自扫描候选视频和候选 UP 主。recommendVideo 需要遍历所有视频计算分数,recommendNthUp 需要筛选候选用户并排序。由于数据规模不大,这种实现还在可以接受的范围内。
本单元的 JUnit 测试要求测试能够区分目标方法是否符合规格。因此测试不能只覆盖一个样例,而要专门针对 JML 中容易写错的部分设计数据。
第九次作业测试 queryMutualFollowingSum 时,我主要覆盖了:
第十次作业测试 cleanSpamComments 时,我重点关注评论 id 与内容的对应关系:
VideoIdNotFoundException。{0, 0} 且评论区不变。第十一次作业测试 recommendNthUp 时,我主要覆盖:
JUnit 不只是为了提高覆盖率,更重要的是把规格中隐含的边界条件显式化。尤其是 pure 和异常顺序,如果没有专门测试,很容易在实现时忽略。
本单元中没有出现不能通过评测或互测的bug。
随着大模型能力增强,在有完整规格的情况下 Code Agent 已经能独立完成本单元从阅读规格、实现到测试的所有工作。
它的优势在于能快速根据接口和已有代码列出可能受影响的容器、方法和测试点。例如在迭代时,可以让它帮助检查哪些旧方法签名发生变化,哪些查询方法可能违反 pure,哪些容器在新增需求后表达能力不足。它也适合根据 JML 描述生成边界测试,例如异常顺序、重复元素、状态不变性等。
需要注意的点是,如果直接让模型根据 JML 写完整实现,它可能更关注“功能上能跑通”,而忽略复杂度这种没有显式说明的约束,需要在提示词显式说明。
第三单元体现了规格化设计的价值。JML 把需求变成了可检查的契约,使实现者要全面关注完整的前置条件、后置条件、异常行为和副作用范围。JUnit 测试则是连接规格和实现的工具。它不仅验证结果,也验证状态是否被错误修改、异常是否按顺序抛出、容器关系是否仍然一致。在大模型时代,严格的契约和测试是保证程序的具体实现不跑偏的重要手段。