305
社区成员
发帖
与我相关
我的任务
分享这一单元感觉更强调对 JML 的阅读、翻译和执行能力。代码本身未必特别复杂,但一旦规格理解有偏差,bug 往往会非常隐蔽。
刚开始看 JML 的时候,我更多是把它当成一种“更严格的注释”。后来真正写代码时才发现,它其实更像是方法的合同:requires 规定调用前必须满足什么,ensures 规定调用后必须保证什么,assignable 规定我到底能改哪些状态,signals 则规定异常情况下应该发生什么。
这一单元让我印象最深的是,JML 中很多看起来“不起眼”的条件,在实现中都可能变成坑。例如一个方法是不是 pure,不只是看它有没有返回值,而是要保证它不修改对象状态;一个方法的 assignable 也不是随便写写,而是直接限制了实现中能动哪些容器和对象。如果查询方法为了图方便顺手更新了缓存,就要非常小心这个缓存是否属于规格允许修改的范围。
规格驱动开发带来的好处是比较明显的:
在写实现前,需求边界已经被形式化地写出来了。我不需要先猜测“这个方法大概想干什么”,而是可以从前置条件、后置条件、异常行为一步步还原业务逻辑。这样写出来的代码更容易自查,也更容易设计测试。
但它的问题也很明显:
JML 本身并不会自动保证我写得高效,也不会替我设计好数据结构。它只告诉我“要满足什么”,并不告诉我“怎么满足”。所以如果只是机械地把 \forall 和 \exists 翻译成遍历,很容易写出正确但超时的程序。规格驱动开发并不是替代架构设计,而是把“正确性边界”提前固定下来,之后仍然需要自己考虑容器、缓存和复杂度。
这一单元的 JUnit 测试让我意识到,测试不能只测返回值,还要测“方法有没有偷偷改状态”。
最开始我写测试时比较习惯构造几个样例,然后判断结果是否等于预期。但 Unit3 的 JUnit 要求更像是在检查 JML,因此还需要考虑:
ensures;pure 方法调用前后对象状态是否完全一致;例如第三次作业中测试 recommend_Nth_up 时,我除了检查推荐结果,还专门检查了它不会修改用户、视频、互关数、最长下降链等状态。因为推荐方法本质上应该是查询,如果它为了计算排名而改了用户状态,那即使返回值对了,也是不符合规格的。
我比较有收获的一点是:测试用例最好围绕规格拆分,而不是围绕实现拆分。也就是说,不要只想着“我的代码里有哪个分支”,而是想“JML 里有哪些行为必须被保证”。这样更容易覆盖到别人代码中可能出现的错误。
第一次作业主要是搭建视频平台的基本模型,包括用户、视频、关注关系、观看关系、互关数量、最短路径等功能。
这一阶段我主要关注的是容器关系的正确维护。例如用户之间的 follow / unfollow 不是只改一个人的关注列表,而是要同时维护关注者和被关注者两边的关系。query_mutual_following_sum 也让我意识到,如果每次查询都重新遍历所有用户对,虽然实现简单,但性能上并不理想。因此我在实现中维护了一个 mutual 缓存,在 follow 和 unfollow 时增量更新。
这次作业让我形成了一个基本习惯:对于查询频率高、更新逻辑清晰的数据,可以考虑用缓存维护;但缓存的更新必须和状态修改绑定在一起,否则很容易出现“主体数据是对的,查询结果是旧的”的问题。
第二次作业在第一次的基础上加入了硬币、点赞、投币、转发、评论、粉丝勋章等功能,复杂度明显上升。这里最大的变化是,很多操作开始具有“事务性”。比如投币不仅要修改视频的硬币数和热度,还要修改投币用户的余额、UP 主的余额、贡献记录等。
这一阶段我明显感觉到,JML 中的后置条件虽然是分散写的,但实现时必须把它们当成一个整体来看。一个方法可能涉及多个对象,如果中间任何一步异常顺序处理错了,或者先修改状态再发现异常,就会留下不一致状态。
评论清理 clean_spam_comments 是我觉得比较典型的例子。它看起来只是删除包含关键字的评论,但真正实现时还要考虑删除数量、关键字最大出现次数、评论 id 与内容的一一对应关系,以及多次清理时的行为。我在这里使用了更专门的字符串匹配思路,而不是每次都暴力地做大量重复扫描。
第三次作业加入了智能推荐,包括推荐视频、推荐 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 名时,没有必要总是完整排序所有候选。
我发现性能优化不能等到最后再做。因为有些优化会反过来影响类的字段设计。如果一开始没有维护必要的映射、计数或排名结构,后面再补就很容易牵一发而动全身。
我的程序中比较容易出问题的地方主要有以下几类。
有些方法可能同时满足多个异常条件,但规格或官方包通常隐含了检查顺序。如果顺序错了,功能逻辑看起来没问题,评测却会认为输出异常类型不对。
这个问题的根本原因是我一开始只关注“会不会抛异常”,而没有特别关注“先抛哪个异常”。后来我在写方法时会先把异常判断集中放在前面,并按照规格顺序逐条写清楚。
使用缓存能提高性能,但也带来了新的风险。比如互关数量、视频热度排名、UP 主影响力、最佳贡献者等,只要某个修改状态的方法忘记同步更新缓存,查询结果就会和真实状态不一致。
这个问题的原因不是算法不会写,而是状态之间的依赖没有整理清楚。后来我会把“修改主状态”和“修改派生状态”放在同一个代码片段里,避免一个改了另一个忘了。
空数组、空用户集、没有视频、没有观看记录、rank 非正、评论为空,这些边界在普通样例中不一定出现,但在规格中都很重要。
这类 bug 的原因是我写代码时容易先按正常业务流程走,再补异常和边界。后面我发现更稳妥的方式是先写异常和边界,再写正常逻辑。
有些推荐或查询方法内部会临时计算排名,如果实现时不小心复用了真实容器,或者为了方便更新了某些字段,就可能违反 pure 或 assignable 的要求。
这个问题让我意识到,查询方法最好只读已有状态。如果需要临时结构,就新建局部变量,而不是借用对象内部容器。
在 Unit3 中,我觉得大模型比较适合做三类事情。
第一是解释 JML。
有些 JML 条件比较长,尤其是带有量词、蕴含、old 表达式的时候,直接看容易漏条件。把规格拆成自然语言后,会更容易发现它到底要求了哪些状态变化。
第二是辅助生成测试思路。
比如我可以让大模型根据某个方法的 JML 列出正常情况、异常情况、边界情况和 assignable 检查点。它不一定能直接生成完全可用的 JUnit,但可以帮助我补充测试维度。
第三是检查实现和规格是否对齐。
把方法实现和对应规格放在一起,让大模型指出可能遗漏的边界或状态修改,有时能发现一些自己看顺眼了的错误。
不过,大模型也有明显局限。
它在根据 JML 写代码时,往往更关注“逻辑正确”,容易忽略效率问题。例如它可能会把所有查询都写成全量遍历,也不一定会主动设计缓存、TreeSet、HashMap 或优先队列。它也可能忽略项目中已经存在的容器结构,生成一段看起来正确但和整体架构不兼容的代码。
所以我觉得比较合适的使用方式是:
让大模型辅助理解规格、生成测试方向、检查明显漏洞;但核心的数据结构、缓存策略和异常顺序,还是要自己掌握。
我比较推荐的方式不是直接让大模型“写一个完整测试类”,而是分步骤使用。
首先,把方法的自然语言需求或 JML 发给它,让它列出测试点。
例如:正常返回、异常路径、空状态、重复元素、边界 rank、状态不变性等。
然后,再让它针对每个测试点生成小规模数据。
小规模数据的好处是容易人工验证,不会出现测错了自己还看不出来的情况。
最后,自己检查并改写生成的 JUnit。
尤其要检查异常类型、异常顺序、对象状态是否需要深拷贝或 strictEquals,以及测试是否依赖了实现细节。
大模型生成的测试可以作为草稿,但不能完全信任。测试代码本身也是代码,也需要被审查。
我没有把 Code Agent 当成完全自动写代码的工具,而是更偏向于让它做辅助阅读和局部修改。比如让它帮我定位某个方法涉及哪些字段,或者检查某个新增状态是否在所有相关方法中都被维护。
Code Agent 比普通问答更适合处理跨文件问题,因为 Unit3 的状态往往分布在 Network、User、Video 等多个类中。如果只看一个方法,很容易忽略另一个类中的容器变化。
但它也不能替代人工判断。特别是 JML 中的异常顺序、assignable 限制和性能约束,Code Agent 不一定总能把握准确。我的经验是,Code Agent 可以帮我节省搜索和定位时间,但最后是否修改、怎么修改,仍然需要自己根据规格确认。
第二次研讨课上的 JML“击鼓传花”让我对规格失真有了很直观的感受。这个游戏的过程其实很简单:一个人先写自然语言需求,后面的人不断在自然语言和 JML 之间转换。但传了几轮之后,最开始的一些边界条件、业务语义或者限制条件就很容易发生变化。
我发现的一个问题是,很多 bug 并不是因为某个人完全不会写 JML,而是因为他对上一轮文字的理解和原作者不一样。例如“返回第一把可用的伞”和“返回任意一把可用的伞”在日常语言里差别不大,但在 JML 中就是完全不同的规格。再比如“没有可用对象时不修改状态”这样的要求,如果翻译时没有写进 assignable 或 ensures,后面的人就可能默认允许修改。
在传递过程中,需求和边界确实发生了变化。自然语言中比较细的条件,比如 null、空数组、重复元素、是否修改原容器、返回值的优先级,很容易在下一轮被简化掉。而 JML 中如果量词写得不够精确,反向翻译成人话时又可能变成另一个业务。
这个活动让我意识到,多人组队编程时,大家对需求的理解不统一是非常危险的。一个人以为“显然应该这样”,另一个人可能完全不是这么理解的。如果不提前对齐,最后代码合并时就会出现接口能对上、语义对不上的问题。
我认为以后多人合作时,可以采取以下措施减少信息差:
我觉得“击鼓传花”最有价值的地方不是让我们发现某一个具体 JML bug,而是让我们看到:需求只要经过几次不精确的转述,就会发生偏移。规格化开发的意义就在这里,它强迫我们把模糊的自然语言变成可以检查、可以讨论、可以测试的形式。
Unit3 让我对“正确性”有了新的理解。以前我更关注程序能不能通过样例,现在会更关注它是否满足完整规格:正常行为、异常行为、状态变化、边界条件和性能要求是否都被考虑到了。
JML 提供的是一套明确的契约,但真正写好程序还需要合适的数据结构、清晰的状态维护和系统的测试。三次作业从基础社交网络,到互动系统,再到推荐系统,逐步把这个问题放大:越到后面,越能看出前期架构和容器设计的重要性。
总体来说,这一单元最重要的收获是:不要把规格当注释,也不要把测试当补丁。规格应该指导实现,测试应该验证规格,而代码则要在正确性和效率之间找到平衡。