305
社区成员
发帖
与我相关
我的任务
分享Unit3 的主题是规格化设计。相比前两个单元,这一单元最大的变化在于:我们不再主要依据自然语言描述来理解需求,而是需要阅读 JML 规格,并根据规格实现代码。
三次作业围绕一个在线视频平台逐步迭代。第一次作业要求实现用户、关注关系、视频上传与观看等基础社交网络功能;第二次作业在此基础上新增硬币经济体系、点赞、投币、转发、评论、粉丝勋章等事务性操作;第三次作业继续加入智能推荐、用户画像、影响力查询等功能。指导书反复强调,社交网络框架由官方接口和 JML 给出,学生需要阅读 JML 并严格按照规格实现对应类和方法。
通过这一单元,我对“正确性”的理解发生了明显变化。以前我更关注程序输出是否正确,而在 JML 规格驱动开发中,正确性不仅包括返回值正确,还包括异常行为正确、对象状态变化正确、容器内容正确,以及不该修改的内容不能被修改。
我一开始对 JML 的理解比较浅,认为它只是比自然语言更精确的注释。完成三次作业后,我逐渐意识到,JML 更像是方法调用双方之间的“契约”:调用者需要满足前置条件,实现者需要保证后置条件,并且只能在规格允许的范围内修改程序状态。
requires:规定方法什么时候可以正常执行requires 描述的是正常行为的前置条件。例如用户必须存在、视频必须存在、rank 必须为正数、评论不能重复等。如果这些条件不满足,就应该进入对应的异常行为,而不是继续执行正常逻辑。
这使我在写代码时不能只想着“怎么完成这个功能”,还要先想清楚“哪些情况根本不应该进入正常流程”。尤其是多个异常可能同时相关时,异常判断顺序也很重要。
ensures:规定执行后的状态ensures 是我认为最核心的部分。它并不只是规定返回值,还规定了方法执行后整个对象网络应该满足什么状态。
例如关注操作不只是让 A 关注 B,还要让 B 的粉丝集合中出现 A;观看视频不只是增加播放量,还要修改用户的观看记录和收到视频列表;投币操作更复杂,会同时影响投币者硬币数、视频硬币数、up 主硬币数、贡献者列表和贡献值。
因此,实现一个方法时,我后来会先把 JML 中的后置条件拆成一张“状态变化清单”,逐项确认哪些对象、哪些字段、哪些容器需要改变。
assignable、pure 和 safe:限制副作用这一点是我在 Unit3 中收获最大的地方。一个方法即使返回值正确,如果它修改了不该修改的对象,仍然是错误的。
指导书对 safe 方法进行了补充说明:它允许出现 JML 描述范围内的 side effect,但不能在容器或对象中增加 JML 没有要求加入的对象,不能删除 JML 没有要求删除的对象,也不能修改 JML 涉及之外的属性。
这让我认识到,JML 的作用不是简单描述“结果是什么”,而是描述“状态如何合法地从旧状态转移到新状态”。在规格驱动开发中,程序员不能凭业务直觉随便修改对象,而必须严格受规格约束。
三次作业都要求编写 JUnit 测试。第一次测试 queryMutualFollowingSum,第二次测试 clean_spam_comments,第三次测试 recommend_Nth_up。指导书明确要求,单元测试不能只检查 requires 和 ensures,还要检查 pure、assignable 等内容;对于 pure 方法,调用前后的状态应该保持一致。JUnit 测试不是简单构造几个输入输出样例,而是要把 JML 的所有约束转化成可观测的断言。
最基础的测试是检查正常情况下的返回值是否正确。例如:
queryMutualFollowingSum 是否正确统计互相关注的用户对;clean_spam_comments 是否正确删除包含关键词的评论;recommend_Nth_up 是否按照推荐分数返回第 N 个 up 主。这类测试比较直观,但只测正常功能远远不够,因为很多错误实现会在普通样例下通过。
边界条件往往更容易暴露 bug。例如:
每个 exceptional_behavior 都应该有对应测试。以 recommend_Nth_up 为例,需要分别测试:
UserIdNotFoundException;InvalidRankException;NoVideoUploadedException;ColdStartUserException。异常测试的重点不仅是“会不会抛异常”,还包括“抛出的异常类型是否正确”。如果异常判断顺序不符合规格,就可能在多个非法条件同时出现时抛出错误类型。
副作用测试是我认为最容易被忽视、但最能体现 JML 思维的一类测试。
对于 pure 方法,调用前后对象状态应该完全一致。例如 recommend_Nth_up 是查询类方法,它只应该返回推荐结果,不应该修改用户的关注关系、视频状态、观看记录、用户画像等信息。因此,我在测试中会在调用前后对 Network 的可观测状态做快照,再比较两次快照是否相同。
第一次作业的主要任务是维护用户、视频以及用户之间的关注关系。核心类包括 User、Video 和 Network。作业要求中明确说明,需要新建自己的 User、Network、Video 类,并实现官方接口。
这次作业的重点是建立基本数据模型。我的主要思路是:
第一次作业让我意识到,容器设计会直接影响后续实现。如果一开始只按照最直观的数组或列表实现,后面面对频繁查询时会比较吃力。因此,即使 JML 中的 model field 看起来像数组,实际实现也可以选择 HashMap、HashSet 等更适合查询的数据结构,只要对外行为满足 JML 即可。
第二次作业在第一次基础上增加了硬币、观看历史、点赞记录、粉丝勋章、贡献者、评论区等状态;Video 也新增了分区类型、播放量、点赞数、转发数、投币数、评论 id 和评论内容等属性。
这次作业最明显的变化是:一个操作往往会同时修改多个对象。
例如 coin_video 不是简单地给视频增加硬币数,它还会:
这类操作如果凭直觉写,很容易出现漏改、重复改、改错对象的问题。因此我后来采用的方法是:先对照 JML 列出所有 assignable 对象,再对照 ensures 写出每个对象的旧状态到新状态的变化,最后再考虑代码组织。
第二次作业对性能的考察更严格。比如 clean_spam_comments 如果对每条评论反复进行低效字符串匹配,可能在高强度数据下超时;query_most_popular_video 如果每次都扫描所有视频,在多次查询时也有风险。指导书中也提醒,强测存在 10s CPU 时间限制,满足 JML 不代表一定满足时间限制。
因此,我在实现中引入辅助管理器和缓存结构,例如按分区维护最热视频、对贡献者查询维护当前最优贡献者等。这些优化本质上是在 JML 语义不变的前提下,改变内部表示方式。
第三次作业新增智能推荐系统,User 新增了 typeCounts 和用户发布的视频集合,Network 新增推荐视频、推荐 up 主、查询最有影响力 up 主等业务逻辑。
这次作业的难点从“状态修改”进一步转向“高复杂度查询”。例如:
recommend_video 需要根据用户兴趣和视频热度计算分数;recommend_Nth_up 需要对候选 up 主排序;query_most_influential_up 需要比较不同 up 主在某一分区的影响力;queryLongestDecSeq 涉及图上的最长递减年龄路径。如果完全按照 JML 的数学描述暴力实现,很多方法可能复杂度过高。因此需要在不改变结果的前提下进行优化。
我的思路是:
这一阶段让我意识到,规格驱动开发并不意味着机械翻译 JML。JML 给出的是外部行为和约束,内部实现仍然需要良好的数据结构设计。
Unit3 的三次作业是连续迭代的。每次迭代都不是简单增加几个新方法,而是会改变旧方法依赖的状态。因此,我总结出了一套检查路径。
例如第二次作业中,Video 构造方法从 (id, uploaderId) 变成了 (id, uploaderId, type),这意味着所有上传视频的逻辑都需要同步调整。第三次作业中虽然 Video 构造方法保持不变,但 User 新增了 typeCounts 和 videos,因此 watchVideo 和 uploadVideo 的副作用都需要重新检查。
新增字段往往意味着旧方法的行为发生变化。例如第三次作业中新增 typeCounts 后,观看视频不再只是加入 watchedVideos 和增加 playCount,还要更新对应分区的观看次数。新增 videos 后,上传视频也不只是加入全局 videos,还要加入 uploader 自己发布的视频集合。
assignable如果某个旧方法的 assignable 范围变了,就说明它允许或必须修改的状态变了。此时不能简单复用旧代码,而要重新检查:
有些内容不完全在 JML 中,而是在指导书和 Runner 中体现。例如异常类、输出格式、指令格式等。第一次作业中指导书说明异常类由官方包给出,异常部分更多依赖指导书和 javadoc;第二、三次作业也继续强调需要结合异常描述正确处理异常行为。
一般有3种方法:
例如:
queryMutualFollowingSumqueryMostPopularVideoqueryMostInfluentialUpqueryGlobalBestContributor这些方法如果每次都遍历所有用户或所有视频,在单次数据较小时可能没问题,但在 10000 条指令下可能累计耗时明显。我的优化思路是:如果某个查询结果只会被少数修改操作影响,就可以在修改时维护缓存,在查询时 O(1) 或较低复杂度返回。
例如互关数可以在 follow/unfollow 时维护,而不是每次查询时重新统计所有用户对。
例如最短路和最长递减年龄链。
最短路如果对每次查询都从头 BFS,在稠密图或多次查询下可能有风险;最长递减链如果每次都暴力枚举路径,复杂度更不可接受。因此需要根据图结构选择合适算法。
我的做法是:
clean_spam_comments 的性能瓶颈在于:评论数量多、keyword 查询多时,字符串匹配会被频繁执行。
如果每次都使用大量 substring,可能产生额外开销。更稳妥的做法是用 KMP 或类似线性匹配方法统计关键词出现次数。同时还要注意 keyword 为空串的情况,因为空串在一个长度为 n 的字符串中可以匹配 n+1 次,如果不单独处理,可能出现死循环或统计错误。
本单元在强测和互测中未出现bug。
在 Unit3 中,我也使用了大模型辅助理解 JML、检查代码和设计测试。整体来看,大模型在规格驱动开发中有帮助,但不能完全替代人工判断。
大模型比较适合做三件事。第一,它可以把复杂 JML 翻译成自然语言,帮助我理解一个方法到底要修改哪些状态。第二,它可以辅助设计测试点,提醒我除了返回值以外,还要测试异常、边界、pure 和 assignable。第三,它可以帮助检查复杂度风险。例如某个方法如果每次全量扫描,大模型可以提醒我在强测中可能 TLE,并建议维护缓存或辅助容器。
但是,大模型也有明显局限。大模型有时会机械翻译 JML,给出最直观但复杂度较高的实现。这样的代码在功能上可能正确,但在强测中可能超时。其次,它有时只关注当前方法,不会主动考虑后续迭代。例如新增一个字段后,哪些旧方法也要同步维护,哪些缓存需要置脏,这些仍然需要我自己从整体架构上检查。最后,它有时会忽视副作用边界。返回值正确并不等于符合 JML,如果方法偷偷修改了不该修改的状态,仍然是错误的。
我认为比较有效的提问方式不是“这段代码对吗”,而是:
这样,大模型更像是一个规格阅读和测试设计助手,而不是直接替我写代码的工具。
研讨课上的“击鼓传花”让我最直观地感受到:规格传递过程中,信息往往不是稳定流动的,而是在不断丢失、被误解,甚至被脑补。
最典型的问题是,很多同学在从自然语言翻译到 JML 时,会优先写出边界条件和异常条件,却漏掉真正的核心业务逻辑。比如“商店推荐系统”案例中,原始需求是根据预算和商品特征推荐最大收益方案,但在传递过程中逐渐退化成了“判断钱够不够”的预算检查函数,核心的推荐逻辑完全消失了。这个案例说明,只写 requires 和简单的 ensures \result >= 0 是远远不够的,规格必须把“到底要算什么”写清楚。
第二个感受是:JML 的可读性非常重要。 如果 JML 写得过于复杂,后面的人很难正确还原需求。颜色字符串校验案例中,原本只是检查字符串是否只含 RGB 字符且相邻颜色不能相同,但由于中间版本使用了复杂的嵌套三元表达式,后续传递中出现了 B 被误读成 P、相邻不能相同被误解成大小写关系、甚至量词方向写反等问题。总结中也指出,单行嵌套三元表达式会造成理解偏差。
第三个感受是:脑补比遗漏更危险。遗漏会让规格变弱,但脑补会把原本不存在的约束固化进需求里。图可达性案例中,原始需求只是判断两点是否可达,但传递过程中有人额外加入了“路径长度小于等于 n”的限制,并且数据结构也从邻接表漂移到了邻接矩阵,编号范围从 [1,n] 变成 [0,n)。这些变化不一定立刻导致错误,但会让后续实现者误以为这些是原始需求的一部分。
相比之下,图书借阅案例保存得比较好。原因在于它把情况清晰地拆成了多个 behavior:书已借出时不操作,书未借出时修改状态,用户或图书不存在时抛异常。总结中也提到,这种“三分支拆分”是比较好的 JML 写法,因为每个 ensures、signals、assignable 都只表达一件事,减少了误解空间。
因此,我最大的收获是:以后写 JML 时不能只追求“形式上完整”,还要追求“别人能不能看懂”。好的规格应该做到:
ensures,不能只写边界条件;null、空集合、0、负数等情况;also,每个分支只描述一种情况;这次游戏让我认识到,规格不仅是写给机器看的,也是写给人看的。只有当规格清晰、完整、可读时,它才能真正便于信息的传递。
Unit3 不只是一次 JML 语法训练,更是一次从“写出能跑的程序”到“写出符合契约、便于测试、能够迭代的程序”的转变。JML 让我重新思考了程序正确性的含义:一个方法不仅要返回正确结果,还要在正确的条件下执行,抛出正确的异常,并且只修改它被允许修改的状态。它也提醒我,在进行架构设计之前,应该先用规格化的方式把题目重新理一遍,明确对象之间的关系、方法的职责以及状态变化的边界。这样写出的代码不只是能通过当前测试,也更容易在后续迭代中保持一致。