305
社区成员
发帖
与我相关
我的任务
分享第三单元和前两个单元给我的感觉很不一样。前两个单元更像是先从业务场景中抽象对象,再用继承、多态和设计模式把结构搭起来;第三单元则把“应该做什么”以前置的形式交给了我们。JML 不是自然语言提示,而是一组相对严格的契约:前置条件、后置条件、副作用范围、异常条件、模型字段和不变量共同规定了程序行为。实现者要做的不是“猜测需求”,而是把规格翻译成可维护、可通过测试、复杂度可接受的代码。
我对 JML 最核心的理解是:它把方法调用前后的状态关系写清楚了。requires 告诉我什么时候可以正常执行,ensures 告诉我执行后必须满足什么,signals 则规定了异常分支的优先级和触发条件。相比普通文字需求,JML 的优势在于边界更明确,比如空集合、重复 id、自身关注、无视频、冷启动等情况都可以被形式化地描述出来。
但 JML 也并不等价于实现方案。规格通常描述的是抽象模型,比如 users、videos、followers、watchedVideos 等 model 字段,而实际实现可以使用 HashMap、LinkedHashMap、TreeSet、链表或缓存。也就是说,JML 约束的是外部可观察行为,不限制内部容器。实现时我需要在“满足规格”和“保证性能”之间做翻译。例如查询最热视频的规格可以写成在所有同类型视频中取热度最大、id 最小的视频,但如果每次查询都线性扫描,遇到大量查询时就会很慢;因此我用按类型划分的 TreeSet 维护热度索引,并在播放、点赞、转发、投币导致热度变化时先删除旧节点、更新热度、再插回索引。
规格驱动开发也让我更重视“不应该改变什么”。很多查询方法是 assignable \nothing,这意味着不能为了求结果偷偷改状态。第三次作业中推荐 UP 主、推荐视频、查询用户画像等方法都属于这类方法,所以我在测试中专门保存调用前的用户、视频、关注关系、金币、点赞、奖章等快照,调用后再比较,防止查询方法产生副作用。
JUnit 对第三单元特别有用,因为 JML 天然适合被拆成测试分支:正常行为、异常行为、边界条件、状态保持和排序规则。
这次我比较有收获的测试方式有三类。
第一类是异常优先级测试。一个方法往往有多个异常条件,例如 recommendNthUp 要先判断用户是否存在,再判断 rank <= 0,再判断是否没有视频,最后才判断候选用户数量不足。只测“会不会抛异常”是不够的,还要测“在多个错误条件同时出现时抛哪一个异常”。
第二类是快照测试。对于 recommendNthUp、queryUserProfile 这类纯查询,我先构造一个较复杂的网络,然后记录所有用户和视频的关键状态,调用查询方法后断言状态完全不变。这种测试比只看返回值更接近 JML 中 assignable \nothing 的语义。
第三类是边界和 tie-break 测试。比如推荐 UP 主时,需要排除自己和已经关注的人;分数相同时要取 id 更小者;候选数量不足时要触发冷启动异常。只构造“普通正确”的数据很容易漏掉这些细节,所以我会刻意让多个候选拥有相同分数,让被关注者拥有更高分数,以检查实现是否真的按照规格筛选和排序。
同时,JUnit 也提醒我测试不能比规格更强。比如清理评论时,我一开始倾向于检查剩余评论的顺序,但如果 JML 只要求剩余评论存在且内容正确,而没有强制顺序,那么测试就不应该擅自把顺序当成需求。否则测试会把自己的理解误写成“伪规格”。
第一次作业的重点是搭建基本网络模型。核心对象是 Network、User、Video,主要功能包括添加用户、上传视频、关注和取关、观看视频、查询粉丝年龄比例、查询互关数、查询最短关注路径等。这个阶段我主要关注关系的一致性:一个用户关注另一个用户时,前者的 following 和后者的 followers 必须同步变化;取关时也要同时删除。最短路径使用 BFS,互关数则不每次查询时双重循环统计,而是在关注和取关时维护 mutualFollowingSum。
第二次作业在基本网络上加入了更复杂的视频互动:类型、热度、点赞、投币、转发、评论、勋章、贡献者、最热视频、最长降龄关注序列等。这个阶段的主要变化是“状态不再只是存在或不存在”,而是出现了大量会被频繁更新和查询的派生信息。为了提高效率,我维护了几个缓存或辅助结构:按类型维护最热视频的 TreeSet,按年龄段维护粉丝数量,按贡献金额维护最佳贡献者,收到视频使用链表加索引支持头插和批量删除,最长降龄序列使用 dirty cache,在关注关系或用户集合变化后标脏,查询时再重新计算。
第三次作业加入了推荐和全局统计,例如全局最佳贡献者、推荐视频、推荐第 n 个 UP 主、最有影响力 UP 主、用户画像等。这个阶段的关键是把已有数据重新组合成评分函数。视频推荐依赖 heat * interest,UP 主推荐依赖用户兴趣和 UP 主影响力的点积。实现时我更明显地感受到前两次容器设计的影响:如果前面没有维护好 typeCounts、用户上传视频集合、贡献者信息和热度信息,第三次就会出现大量重复扫描,甚至容易写出和规格不一致的推荐结果。
我发现容器变化主要靠三个步骤。
第一,先对比接口和 JML 的变化。新增 model 字段、assignable 范围扩大、新增 ensures 中的统计量,通常意味着原有容器不够用了。例如视频从只有 id 变为包含 type、playCount、likes、forwardCount、coins、comments 后,Video 就不能再只是一个简单 id 容器。
第二,列出每个方法会读写哪些状态。只要某个状态被多个方法共同维护,就需要考虑一致性。例如热度会被观看、点赞、投币、转发改变,而最热视频查询又依赖热度排序,因此必须统一通过删除旧索引、更新数据、插入新索引的流程维护。
第三,做复杂度表和压力数据。JML 不直接要求复杂度,所以需要自己看最坏情况。像 queryMostPopularVideo 如果每次遍历所有视频,在大量查询下会成为瓶颈;queryMutualFollowingSum 如果每次双重循环统计也会很慢;queryLongestDecSeq 虽然可以动态规划,但没有必要每次都重算,因此适合 dirty cache。真正定位瓶颈时,我会看是否存在“查询中嵌套遍历大容器”“评分函数内反复扫描所有视频”“同一个统计量被高频查询但每次从零计算”等信号。
我在实现中比较典型的 bug 是 cleanSpamComments 对空关键词的处理。Java 中任意字符串都包含空串,因此空关键词会删除所有评论,而最大出现次数等价于 comment.length() + 1。我一开始在清空评论数量后才记录 removedCount,导致返回的删除数量变成 0。这个 bug 的原因不是算法复杂,而是更新状态的顺序不对:先修改了被返回值依赖的状态,再去读取它。修复方式是先保存旧的 commentCount,再清空集合和计数。
另一个问题出现在异常对象的参数上。比如金币不足异常应该传入用户 id,而不是缺少的金额;转发时关注关系不存在,异常参数也要按规格中的调用语义传递。这个问题说明“抛出了正确类型的异常”还不够,异常携带的信息也属于规格的一部分。以后写异常分支时,不能只看 throws 列表,还要看 signals 后面的精确条件和异常类构造参数。
还有一类问题来自测试和规格理解之间的偏差。清理评论后剩余评论是否必须保持原顺序,如果规格没有明确要求,就不能在测试中强行要求。这让我意识到测试应该是规格的投影,而不是个人习惯的投影;否则可能把正确实现误判为错误,也可能在团队协作中制造新的歧义。
在规格驱动开发中,大模型比较擅长做两件事:一是把较长的 JML 拆成自然语言检查清单,二是根据正常行为和异常行为生成单元测试框架。比如我可以让它围绕 recommendNthUp 生成“排除自己、排除已关注者、分数相同时按 id、异常优先级、查询不改变状态”等测试点。
但大模型也有明显风险。它很容易写出“看起来满足 JML”的朴素实现,例如每次查询都线性扫描,或者忽略热度变化后 TreeSet 中可变排序键必须先删后改再插的问题。它也可能只关注返回值,忽视架构和容器维护。因此使用大模型时,我认为提示语里必须明确要求复杂度分析、容器选择、状态副作用和边界条件检查。
Code Agent 的优势在于可以结合仓库上下文工作。它能读实现、读测试、看 git diff,帮助我定位某次修复到底改变了什么。比较适合做回归测试补充、总结迭代变化、检查某个查询方法是否意外修改状态。但最终仍然需要我自己判断规格含义,尤其是异常优先级、tie-break 和性能取舍。
第二次研讨课的“击鼓传花”让我感受到,写 JML 和读 JML 都不轻松。自己写规格时,很多边界会被自然语言经验掩盖;别人接手时,又会根据自己的理解补全那些没有写清楚的部分。传递几轮之后,需求本身可能没有被有意修改,但实现者对需求的理解会发生漂移。
我确实发现过自己或别人 JML 中容易出现的问题。比如量词范围没有写完整,导致空集合或越界情况没有被约束;正常行为和异常行为条件重叠,异常优先级不清晰;只写了结果正确,却没有写哪些状态不能改变;排序规则只写了最大值,没有写并列时如何选择;对 null、空字符串、重复元素、自身关系等边界没有定义。这些问题在单人作业里可能靠测试补上,但在多人传递时会被不断放大。
传递过程中,需求和边界确实会发生变化。有些变化是显性的,比如后一个人补充了新的异常条件;有些变化是隐性的,比如测试者默认剩余元素必须保持顺序,或者实现者默认某个列表不能有重复项。最危险的不是“大家都知道自己改了需求”,而是“每个人都以为自己没有改需求”。
如果以后多人组队编程,我认为要减少信息差,首先要有一个单一可信的规格源。所有需求变更都必须进入同一份文档或接口注释,不能只存在于聊天记录里。其次,需要为每个方法维护四张表:正常前置条件、异常优先级、会修改的状态、关键边界样例。第三,团队要约定 tie-break、空集合、重复元素、顺序是否保留等规则,不能等实现时各自发挥。第四,规格变更也要像代码一样 review,至少由实现者和测试者共同确认。最后,要建设一组公共契约测试,让所有人的实现都跑同一批边界用例和随机用例。
这次研讨课给我的最大提醒是:JML 的价值不只是让机器或评测系统判断对错,更是让团队成员在同一张地图上工作。规格写得越清楚,后面的实现、测试和维护成本越低。
Unit3 让我更明确地区分了“需求”“规格”“实现”和“测试”。JML 提供的是行为契约,实现时要把契约映射到合适的数据结构;JUnit 则帮助我不断检查实现是否偏离契约。三次作业的迭代也说明,早期架构不能只满足当前方法,还要为后续统计、推荐和查询留下维护空间。
如果说前两个单元训练的是面向对象建模能力,那么第三单元训练的就是契约意识:看清楚方法承诺了什么、没有承诺什么、什么时候应该抛异常、哪些状态不能被改变,以及怎样在规格之外主动保证性能。这种意识对之后更复杂的团队开发应该会更重要。