309
社区成员
发帖
与我相关
我的任务
分享第三单元的三次作业围绕一个在线视频平台逐步展开:hw9 维护用户、关注关系和视频接收;hw10 加入硬币、点赞、转发、评论和粉丝勋章等互动逻辑;hw11 继续引入推荐系统和用户画像。这一单元最明显的变化是:题目要求先读懂一份已经写好的规格,再把规格转化成可靠的实现。
一开始读 JML 时,我更容易把它看成一种“更严格的注释”。做完三次作业之后,我对它的理解变成了:JML 是接口和实现之间的契约,它规定了方法在什么前提下可以被调用、调用后必须满足什么状态、哪些对象可以被修改、哪些内容必须保持不变。
在这一单元里,requires 和 ensures 决定了基本功能正确性,但真正容易漏掉的是 pure、assignable 和各种边界条件。比如一个查询方法不只是“返回值正确”就结束了,它还不能改变容器内容;一个清理评论的方法不只是“删掉含关键词的评论”,还要保证非目标视频不被影响、剩余评论的 id 和内容关系不被破坏、被删除评论对应的索引结构同步更新。
我觉得规格驱动开发带来的最大好处是把“需求理解”前置了。以前写代码时可能先搭框架,再边测边补;而 JML 迫使在实现前回答几个问题:
这些问题如果在写代码前没有想清楚,后面就会变成 bug 或性能问题。
这一单元每次作业都要求为一个指定方法编写 JUnit 测试:hw9 是 queryMutualFollowingSum,hw10 是 cleanSpamComments,hw11 是 recommendNthUp。我的体会是,JUnit 测试不能只测“几个正常样例”,而要把 JML 翻译成测试点。
最直接的是构造小规模网络,手算答案。例如互关数量可以用三角形、单向环、互关后取消关注等数据覆盖;推荐 up 可以构造几个 up 主的视频热度和用户兴趣,让排名结果能够手算出来。
异常不仅要测“会不会抛出”,还要测抛出的优先级是否符合规格。例如 recommendNthUp 中,我专门测试了 rank <= 0、没有视频、候选 up 不足等情况,避免实现里因为检查顺序不同导致错误异常。
这是我后面最重视的一点。比如 recommendNthUp 是查询式方法,调用前后用户的关注关系、粉丝关系、看过的视频、点赞状态、硬币数、视频热度、评论状态都不应该改变。因此我在测试里写了快照类,把调用前的关键状态保存下来,调用后逐项比较。
这种测试方法比单纯 assert 返回值更有价值,因为很多“错误代码”会返回正确答案,但为了求答案排序、删除、移动了原容器。规格测试恰恰要抓住这种副作用。
cleanSpamComments 的测试让我印象比较深。它的边界并不难,但很细:关键词重叠出现如何计数、连续命中的评论是否全部删除、空评论列表如何处理、删除后 containsComment 是否更新、清理不存在视频时原状态是否保持。这些测试不是随机测出来的,而是从 JML 中逐句拆出来的。
我的经验是:写 JUnit 时先不要急着写很多复杂数据,而是先把规格中的量词、条件分支和“不变状态”列成清单,再每个清单项构造一个尽量小的样例。
hw9 的核心是用户网络和视频传播。我的初始设计是用 HashMap<Integer, User> 和 HashMap<Integer, Video> 做按 id 查询,同时保留 ArrayList 维护用户和视频的遍历顺序。用户内部同时维护 following/followers 列表和对应的 HashMap,这样既能保留顺序,又能把 isFollowing、containsFollower 这类判断从线性扫描优化到接近 O(1)。
hw9 强测后我修复了部分情况用时过长的问题。原先 queryMutualFollowingSum 每次调用都遍历所有用户对,复杂度接近 O(n^2)。当查询频繁时,这个方法会明显拖慢程序。修改后我在 Network 中维护 mutualFollowingSum,在 followUser 成功建立一条关注边时,如果反向边已经存在就加一;在 unfollowUser 删除一条边前,如果反向边存在就减一。这样查询本身变成 O(1)。
同一次修改还优化了视频推送和最短路查询:上传视频时不再遍历全体用户判断谁是粉丝,而是直接遍历上传者的粉丝列表;BFS 不再维护额外的距离表,而是按层计数。这些优化说明,JML 能告诉我“结果应该是什么”,但不能保证“怎么写都能过时间限制”。规格正确只是第一步,容器和算法仍然要自己负责。
hw10 在 hw9 基础上加入了硬币、点赞、投币、转发、评论、勋章和最长下降序列等功能。这次迭代的主要难点不是某一个算法,而是状态数量明显增加:
coins、watchedVideoIds、likedVideoIds、medals、贡献者列表等状态;playCount、likes、forwardCount、coins、评论 id 和评论内容;明显感觉到:每个容器都要有清楚的“主数据”和“辅助索引”。例如评论使用 commentIds/commentContents 保存顺序和内容,用 commentIndexMap 快速判断 id 是否存在。清理评论时,如果只删数组而忘记重建索引,就会出现 containsComment 与实际评论列表不一致的问题。
hw10 的 JUnit 我更改了很多次才通过中测。 cleanSpamComments中不断补充测试点,从“能删掉含关键词评论”扩展到“只修改目标视频”“删除后索引同步”“重复清理只统计当前存在评论”“视频统计信息不被评论清理影响”。
hw11 新增推荐视频、推荐第 N 个 up、最有影响力 up、用户画像和全局最佳贡献者等功能。相比前两次,这次的新增需求更偏查询和排序,性能压力也更集中。
我在用户中增加了 typeCounts 记录用户观看各分区视频的次数,增加 uploadedVideos 记录用户上传的视频。这样 getInterest、getProfile、getInfluence 都可以直接基于已有状态计算,而不需要每次在全局视频列表里反复扫描。这个变化体现了一个迭代中的容器判断原则:当某个查询反复需要“按用户”或“按类型”聚合数据时,就要考虑在状态变化发生时同步维护辅助容器。
这次我还把 BFS、最长下降序列、全局贡献者统计、推荐 up 的候选筛选和选择逻辑抽到了 NetworkAlgorithms 中。这样 Network 更像业务流程层,NetworkAlgorithms 专门处理算法细节。这个拆分不是为了形式上的“多一个类”,而是因为推荐系统加入后,Network 已经同时承担异常检查、状态更新、输出、查询和算法,继续堆在一起会降低可读性。
我还对 recommendNthUp 做了性能优化:原来先对所有候选 up 全排序,再取第 rank 个;修改后用类似快速选择的方式,只把第 N 个候选放到目标位置。全排序复杂度是 O(n log n),选择算法平均复杂度接近 O(n)。在只需要第 N 名、不需要完整排名时,全排序就是多做了工作。
三次作业让我形成了一个比较固定的检查流程。
第一步是对照新旧 JML 和指导书,看接口签名、异常、assignable 范围有没有变化。例如 uploadVideo 在 hw10 中增加了 type,hw11 中虽然签名延续,但用户需要额外维护上传视频列表和类型观看次数。
第二步是从新增查询倒推容器。例如 queryMostInfluentialUp(type) 需要快速知道每个 up 在某个分区的影响力,那么用户对象就应该知道自己上传过哪些视频;queryUserProfile 需要用户各分区兴趣,那么用户观看视频时就应该同步更新 typeCounts。
第三步是检查旧方法是否需要维护新状态。比如 watchVideo 在 hw9 只需要把视频从“收到但未看”里移除;到 hw10 要加入观看历史并增加播放量;到 hw11 又要更新分区观看次数。很多迭代 bug 都不是新方法写错,而是旧方法忘记维护新增状态。
第四步是检查辅助索引和主容器的一致性。只要一个对象里同时有 ArrayList 和 HashMap/HashSet,每个增删操作都必须成对更新。评论清理、关注/取关、收到/观看视频都属于这种情况。
我的方法主要有三类。
第一类是看复杂度和调用频率。queryMutualFollowingSum 如果每次都扫所有用户对,在 10000 条指令下很容易成为瓶颈;所以我把它改成增量维护。类似地,isFollowing 如果每次线性扫描关注列表,也会在图查询和推荐中反复放大。
第二类是看是否重复做了可以提前维护的聚合。粉丝年龄比例、用户分区兴趣、up 主影响力、贡献者统计等都可以通过辅助数组或容器降低查询成本。
第三类是看“目标结果是否真的需要完整过程”。recommendNthUp 只需要第 N 个 up,不需要完整排序,因此全排序不是必要的。这个思路也提醒我:性能优化不只是换容器,有时是重新理解问题本身。
hw9 的超时是最典型的例子。最初我按照 JML 的描述直接计算互关数量,逻辑上正确,但在大规模查询下会超时。原因是我把规格中的数学定义直接翻译成了实现,没有进一步考虑查询频率和复杂度。修复方式是把互关数量变成随关注关系变化同步维护的状态。
评论系统很容易出现这种问题。删除评论后,如果 commentIds/commentContents 变了,而 commentIndexMap 没有同步清理或重建,就会导致已经删除的评论仍然被认为存在,或者新评论 id 被误判为重复。这个问题的根源是一个抽象状态被多个物理容器共同表示,因此修改时必须维护 representation invariant。
cleanSpamComments 中,关键词可能重叠出现,例如 "aaaa" 中 "aa" 可以出现 3 次。如果只用非重叠计数,就会得到错误的最大出现次数。此外,清理评论只能影响目标视频,不能影响其他视频,也不能改变播放量、点赞数、投币数等非评论状态。这个问题的根源是对 ensures 和 assignable 的阅读不够细。
第三单元我使用过 Code Agent 辅助阅读与编写代码、整理测试思路和检查复杂状态。我的体会是,大模型在规格驱动开发里最有价值的地方不是“替我写完代码”,而是帮助我把 JML 拆成检查清单。
它比较擅长的事情包括:
但大模型也有明显风险。它容易根据规格写出直观但低效的实现,例如把 ensures 中的量词直接翻译成多重循环;也可能忽略架构和容器问题,把所有逻辑堆进一个类里。我的使用方式是:先自己确定数据结构和复杂度,再让它帮忙检查遗漏;对它给出的代码,重点审查异常优先级、容器一致性和时间复杂度。
在单元测试方面,大模型比较适合生成测试点列表和小规模样例,但最终断言什么、哪些状态必须保持不变,还是要确认。
第二次研讨课的 JML“击鼓传花”让我印象很深。它实际上暴露的是团队协作中常见的问题:每个人以为自己理解了需求,但理解的边界并不完全一样。
我在这个过程中最大的感受是,JML 的 bug 不一定是语法写错,更多时候是“没有把隐含前提写出来”。例如某个容器是否允许重复、异常检查的先后顺序、查询方法是否允许缓存、空集合时返回什么、边界值是否包含等,如果没有在规格中明确,后面的人就可能按照自己的直觉补全。
在传递过程中,需求和边界很容易发生变化。有的人会为了实现方便把条件写得更强,有的人会为了覆盖更多情况把条件写得更弱;有的人关注返回值,有的人关注副作用;还有的人会默认“正常输入”不会出现某些边界。这些变化单独看都不大,但传几轮之后,最初的需求就可能被悄悄改写。
如果以后多人组队编程,我认为减少信息差至少要做到几件事:
我觉得 JML 在团队合作中最大的价值,就是把“我以为你知道”的部分变成可讨论、可检查、可测试的文字。它不能消除所有沟通成本,但可以让沟通有一个共同对象。
第三单元让我真正体会到,规格驱动开发并不是削弱设计,而是把设计的起点从“自由发挥”移动到了“精确理解契约”。JML 负责定义正确性的边界,JUnit 负责把这些边界变成可执行的检查,而容器选择和算法优化负责让程序在规模上站得住。
三次作业中,我的代码从直接实现 JML,逐步变成维护辅助状态、拆分算法类、用快照测试 pure/assignable、用复杂度分析定位瓶颈。这个过程也让我意识到:写对一个方法并不难,难的是在需求迭代后,仍然让所有旧状态、新状态、异常分支、查询语义和性能约束保持一致。