2026_OO_第三单元总结博客

李思瑶-24371271 2026-05-28 09:11:45

Unit3 的主题是规格化设计。相比前两个单元,这一单元最大的变化在于:我们不再主要依据自然语言描述来理解需求,而是需要阅读 JML 规格,并根据规格实现代码。

三次作业围绕一个在线视频平台逐步迭代。第一次作业要求实现用户、关注关系、视频上传与观看等基础社交网络功能;第二次作业在此基础上新增硬币经济体系、点赞、投币、转发、评论、粉丝勋章等事务性操作;第三次作业继续加入智能推荐、用户画像、影响力查询等功能。指导书反复强调,社交网络框架由官方接口和 JML 给出,学生需要阅读 JML 并严格按照规格实现对应类和方法。

通过这一单元,我对“正确性”的理解发生了明显变化。以前我更关注程序输出是否正确,而在 JML 规格驱动开发中,正确性不仅包括返回值正确,还包括异常行为正确、对象状态变化正确、容器内容正确,以及不该修改的内容不能被修改。

对 JML 和规格驱动开发的理解

我一开始对 JML 的理解比较浅,认为它只是比自然语言更精确的注释。完成三次作业后,我逐渐意识到,JML 更像是方法调用双方之间的“契约”:调用者需要满足前置条件,实现者需要保证后置条件,并且只能在规格允许的范围内修改程序状态。

1. requires:规定方法什么时候可以正常执行

requires 描述的是正常行为的前置条件。例如用户必须存在、视频必须存在、rank 必须为正数、评论不能重复等。如果这些条件不满足,就应该进入对应的异常行为,而不是继续执行正常逻辑。

这使我在写代码时不能只想着“怎么完成这个功能”,还要先想清楚“哪些情况根本不应该进入正常流程”。尤其是多个异常可能同时相关时,异常判断顺序也很重要。

2. ensures:规定执行后的状态

ensures 是我认为最核心的部分。它并不只是规定返回值,还规定了方法执行后整个对象网络应该满足什么状态。

例如关注操作不只是让 A 关注 B,还要让 B 的粉丝集合中出现 A;观看视频不只是增加播放量,还要修改用户的观看记录和收到视频列表;投币操作更复杂,会同时影响投币者硬币数、视频硬币数、up 主硬币数、贡献者列表和贡献值。

因此,实现一个方法时,我后来会先把 JML 中的后置条件拆成一张“状态变化清单”,逐项确认哪些对象、哪些字段、哪些容器需要改变。

3. assignablepuresafe:限制副作用

这一点是我在 Unit3 中收获最大的地方。一个方法即使返回值正确,如果它修改了不该修改的对象,仍然是错误的。

指导书对 safe 方法进行了补充说明:它允许出现 JML 描述范围内的 side effect,但不能在容器或对象中增加 JML 没有要求加入的对象,不能删除 JML 没有要求删除的对象,也不能修改 JML 涉及之外的属性。

这让我认识到,JML 的作用不是简单描述“结果是什么”,而是描述“状态如何合法地从旧状态转移到新状态”。在规格驱动开发中,程序员不能凭业务直觉随便修改对象,而必须严格受规格约束。

JUnit 测试经验总结

三次作业都要求编写 JUnit 测试。第一次测试 queryMutualFollowingSum,第二次测试 clean_spam_comments,第三次测试 recommend_Nth_up。指导书明确要求,单元测试不能只检查 requiresensures,还要检查 pureassignable 等内容;对于 pure 方法,调用前后的状态应该保持一致。JUnit 测试不是简单构造几个输入输出样例,而是要把 JML 的所有约束转化成可观测的断言。

1. 正常功能测试

最基础的测试是检查正常情况下的返回值是否正确。例如:

  • queryMutualFollowingSum 是否正确统计互相关注的用户对;
  • clean_spam_comments 是否正确删除包含关键词的评论;
  • recommend_Nth_up 是否按照推荐分数返回第 N 个 up 主。

这类测试比较直观,但只测正常功能远远不够,因为很多错误实现会在普通样例下通过。

2. 边界条件测试

边界条件往往更容易暴露 bug。例如:

  • 没有用户、没有视频;
  • rank 为 0 或负数;
  • 用户已经关注了所有其他用户;
  • 评论中不包含关键词;
  • 多个候选对象分数相同,需要按照 id 进行平分处理;
  • 推荐 up 主时不能推荐自己,也不能推荐已经关注的用户。

3. 异常测试

每个 exceptional_behavior 都应该有对应测试。以 recommend_Nth_up 为例,需要分别测试:

  • 用户不存在时抛出 UserIdNotFoundException
  • rank 非正时抛出 InvalidRankException
  • 没有视频时抛出 NoVideoUploadedException
  • 候选 up 主数量不足时抛出 ColdStartUserException

异常测试的重点不仅是“会不会抛异常”,还包括“抛出的异常类型是否正确”。如果异常判断顺序不符合规格,就可能在多个非法条件同时出现时抛出错误类型。

4. 副作用测试

副作用测试是我认为最容易被忽视、但最能体现 JML 思维的一类测试。

对于 pure 方法,调用前后对象状态应该完全一致。例如 recommend_Nth_up 是查询类方法,它只应该返回推荐结果,不应该修改用户的关注关系、视频状态、观看记录、用户画像等信息。因此,我在测试中会在调用前后对 Network 的可观测状态做快照,再比较两次快照是否相同。

三次作业的迭代过程总结

1. 第一次作业:建立基础对象和关系网络

第一次作业的主要任务是维护用户、视频以及用户之间的关注关系。核心类包括 UserVideoNetwork。作业要求中明确说明,需要新建自己的 UserNetworkVideo 类,并实现官方接口。

这次作业的重点是建立基本数据模型。我的主要思路是:

  • 用用户 id 管理用户对象;
  • 用视频 id 管理视频对象;
  • 在用户内部维护 following 和 followers;
  • 对关注和取关操作进行双向维护;
  • 对最短路查询使用 BFS;
  • 对互关数进行维护,避免每次查询都全量扫描。

第一次作业让我意识到,容器设计会直接影响后续实现。如果一开始只按照最直观的数组或列表实现,后面面对频繁查询时会比较吃力。因此,即使 JML 中的 model field 看起来像数组,实际实现也可以选择 HashMapHashSet 等更适合查询的数据结构,只要对外行为满足 JML 即可。

2. 第二次作业:事务性操作增多,状态一致性成为重点

第二次作业在第一次基础上增加了硬币、观看历史、点赞记录、粉丝勋章、贡献者、评论区等状态;Video 也新增了分区类型、播放量、点赞数、转发数、投币数、评论 id 和评论内容等属性。

这次作业最明显的变化是:一个操作往往会同时修改多个对象。

例如 coin_video 不是简单地给视频增加硬币数,它还会:

  • 减少投币者的硬币;
  • 增加视频的硬币数;
  • 增加 up 主的硬币;
  • 更新 up 主的贡献者集合;
  • 更新贡献值;
  • 影响视频热度;
  • 进一步影响最热视频缓存。

这类操作如果凭直觉写,很容易出现漏改、重复改、改错对象的问题。因此我后来采用的方法是:先对照 JML 列出所有 assignable 对象,再对照 ensures 写出每个对象的旧状态到新状态的变化,最后再考虑代码组织。

第二次作业对性能的考察更严格。比如 clean_spam_comments 如果对每条评论反复进行低效字符串匹配,可能在高强度数据下超时;query_most_popular_video 如果每次都扫描所有视频,在多次查询时也有风险。指导书中也提醒,强测存在 10s CPU 时间限制,满足 JML 不代表一定满足时间限制。

因此,我在实现中引入辅助管理器和缓存结构,例如按分区维护最热视频、对贡献者查询维护当前最优贡献者等。这些优化本质上是在 JML 语义不变的前提下,改变内部表示方式。

3. 第三次作业:推荐系统引入排序、缓存和复杂度权衡

第三次作业新增智能推荐系统,User 新增了 typeCounts 和用户发布的视频集合,Network 新增推荐视频、推荐 up 主、查询最有影响力 up 主等业务逻辑。

这次作业的难点从“状态修改”进一步转向“高复杂度查询”。例如:

  • recommend_video 需要根据用户兴趣和视频热度计算分数;
  • recommend_Nth_up 需要对候选 up 主排序;
  • query_most_influential_up 需要比较不同 up 主在某一分区的影响力;
  • queryLongestDecSeq 涉及图上的最长递减年龄路径。

如果完全按照 JML 的数学描述暴力实现,很多方法可能复杂度过高。因此需要在不改变结果的前提下进行优化。

我的思路是:

  • 对推荐 up 主,用堆维护前 rank 个候选,而不是每次完整排序所有用户;
  • 对最长递减链,使用 DP 求解,并使用 dirty 标记缓存结果;
  • 对最短路,使用 BFS 或双向 BFS;
  • 对最热视频,按类型维护当前 best video;
  • 对用户兴趣,随着观看行为维护分区计数。

这一阶段让我意识到,规格驱动开发并不意味着机械翻译 JML。JML 给出的是外部行为和约束,内部实现仍然需要良好的数据结构设计。

如何发现已有方法和容器在迭代中的变化

Unit3 的三次作业是连续迭代的。每次迭代都不是简单增加几个新方法,而是会改变旧方法依赖的状态。因此,我总结出了一套检查路径。

1. 先看接口和构造方法是否变化

例如第二次作业中,Video 构造方法从 (id, uploaderId) 变成了 (id, uploaderId, type),这意味着所有上传视频的逻辑都需要同步调整。第三次作业中虽然 Video 构造方法保持不变,但 User 新增了 typeCountsvideos,因此 watchVideouploadVideo 的副作用都需要重新检查。

2. 再看新增 model field

新增字段往往意味着旧方法的行为发生变化。例如第三次作业中新增 typeCounts 后,观看视频不再只是加入 watchedVideos 和增加 playCount,还要更新对应分区的观看次数。新增 videos 后,上传视频也不只是加入全局 videos,还要加入 uploader 自己发布的视频集合。

3. 对照旧方法的 assignable

如果某个旧方法的 assignable 范围变了,就说明它允许或必须修改的状态变了。此时不能简单复用旧代码,而要重新检查:

  • 是否新增了需要维护的容器;
  • 是否有缓存依赖这个状态;
  • 是否有测试需要补充;
  • 是否会影响复杂度。

4. 对照输出和异常变化

有些内容不完全在 JML 中,而是在指导书和 Runner 中体现。例如异常类、输出格式、指令格式等。第一次作业中指导书说明异常类由官方包给出,异常部分更多依赖指导书和 javadoc;第二、三次作业也继续强调需要结合异常描述正确处理异常行为。

如何发现程序性能瓶颈

一般有3种方法:

1. 高频全局扫描查询

例如:

  • queryMutualFollowingSum
  • queryMostPopularVideo
  • queryMostInfluentialUp
  • queryGlobalBestContributor

这些方法如果每次都遍历所有用户或所有视频,在单次数据较小时可能没问题,但在 10000 条指令下可能累计耗时明显。我的优化思路是:如果某个查询结果只会被少数修改操作影响,就可以在修改时维护缓存,在查询时 O(1) 或较低复杂度返回。

例如互关数可以在 follow/unfollow 时维护,而不是每次查询时重新统计所有用户对。

2. 图算法类查询

例如最短路和最长递减年龄链。

最短路如果对每次查询都从头 BFS,在稠密图或多次查询下可能有风险;最长递减链如果每次都暴力枚举路径,复杂度更不可接受。因此需要根据图结构选择合适算法。

我的做法是:

  • 最短路使用 BFS / 双向 BFS;
  • 最长递减年龄链使用动态规划;
  • 对最长链结果使用 dirty 标记,只有当 add_user、follow_user、unfollow_user 这类会影响图结构的方法执行后才重新计算。

3. 字符串处理和评论清理

clean_spam_comments 的性能瓶颈在于:评论数量多、keyword 查询多时,字符串匹配会被频繁执行。

如果每次都使用大量 substring,可能产生额外开销。更稳妥的做法是用 KMP 或类似线性匹配方法统计关键词出现次数。同时还要注意 keyword 为空串的情况,因为空串在一个长度为 n 的字符串中可以匹配 n+1 次,如果不单独处理,可能出现死循环或统计错误。

自己程序中出现过的 Bug 和原因分析

本单元在强测和互测中未出现bug。

使用大模型辅助规格驱动开发的体会

在 Unit3 中,我也使用了大模型辅助理解 JML、检查代码和设计测试。整体来看,大模型在规格驱动开发中有帮助,但不能完全替代人工判断。

1. 大模型的优势

大模型比较适合做三件事。第一,它可以把复杂 JML 翻译成自然语言,帮助我理解一个方法到底要修改哪些状态。第二,它可以辅助设计测试点,提醒我除了返回值以外,还要测试异常、边界、pure 和 assignable。第三,它可以帮助检查复杂度风险。例如某个方法如果每次全量扫描,大模型可以提醒我在强测中可能 TLE,并建议维护缓存或辅助容器。

2. 大模型容易忽视的问题

但是,大模型也有明显局限。大模型有时会机械翻译 JML,给出最直观但复杂度较高的实现。这样的代码在功能上可能正确,但在强测中可能超时。其次,它有时只关注当前方法,不会主动考虑后续迭代。例如新增一个字段后,哪些旧方法也要同步维护,哪些缓存需要置脏,这些仍然需要我自己从整体架构上检查。最后,它有时会忽视副作用边界。返回值正确并不等于符合 JML,如果方法偷偷修改了不该修改的状态,仍然是错误的。

3. 更有效的使用方式

我认为比较有效的提问方式不是“这段代码对吗”,而是:

  • 请逐条对照 JML 检查这段代码;
  • 请分析这个方法是否有 TLE 风险;
  • 请列出这个方法所有需要维护的容器;
  • 请构造能卡掉错误实现的边界样例;
  • 请检查这个 pure 方法是否可能产生副作用。

这样,大模型更像是一个规格阅读和测试设计助手,而不是直接替我写代码的工具。

JML“击鼓传花”游戏的感悟

研讨课上的“击鼓传花”让我最直观地感受到:规格传递过程中,信息往往不是稳定流动的,而是在不断丢失、被误解,甚至被脑补。

最典型的问题是,很多同学在从自然语言翻译到 JML 时,会优先写出边界条件和异常条件,却漏掉真正的核心业务逻辑。比如“商店推荐系统”案例中,原始需求是根据预算和商品特征推荐最大收益方案,但在传递过程中逐渐退化成了“判断钱够不够”的预算检查函数,核心的推荐逻辑完全消失了。这个案例说明,只写 requires 和简单的 ensures \result >= 0 是远远不够的,规格必须把“到底要算什么”写清楚。

第二个感受是:JML 的可读性非常重要。 如果 JML 写得过于复杂,后面的人很难正确还原需求。颜色字符串校验案例中,原本只是检查字符串是否只含 RGB 字符且相邻颜色不能相同,但由于中间版本使用了复杂的嵌套三元表达式,后续传递中出现了 B 被误读成 P、相邻不能相同被误解成大小写关系、甚至量词方向写反等问题。总结中也指出,单行嵌套三元表达式会造成理解偏差。

第三个感受是:脑补比遗漏更危险。遗漏会让规格变弱,但脑补会把原本不存在的约束固化进需求里。图可达性案例中,原始需求只是判断两点是否可达,但传递过程中有人额外加入了“路径长度小于等于 n”的限制,并且数据结构也从邻接表漂移到了邻接矩阵,编号范围从 [1,n] 变成 [0,n)。这些变化不一定立刻导致错误,但会让后续实现者误以为这些是原始需求的一部分。

相比之下,图书借阅案例保存得比较好。原因在于它把情况清晰地拆成了多个 behavior:书已借出时不操作,书未借出时修改状态,用户或图书不存在时抛异常。总结中也提到,这种“三分支拆分”是比较好的 JML 写法,因为每个 ensuressignalsassignable 都只表达一件事,减少了误解空间。

因此,我最大的收获是:以后写 JML 时不能只追求“形式上完整”,还要追求“别人能不能看懂”。好的规格应该做到:

  1. 核心业务逻辑必须写进 ensures,不能只写边界条件;
  2. 边界条件要明确,尤其是 null、空集合、0、负数等情况;
  3. 不要随意添加原需求没有的约束;
  4. 复杂逻辑尽量拆成多个 also,每个分支只描述一种情况;
  5. 对多人协作来说,JML、自然语言说明和必要的数据结构约定应该配合使用,而不是只依赖其中一种表达方式。

这次游戏让我认识到,规格不仅是写给机器看的,也是写给人看的。只有当规格清晰、完整、可读时,它才能真正便于信息的传递。

总结

Unit3 不只是一次 JML 语法训练,更是一次从“写出能跑的程序”到“写出符合契约、便于测试、能够迭代的程序”的转变。JML 让我重新思考了程序正确性的含义:一个方法不仅要返回正确结果,还要在正确的条件下执行,抛出正确的异常,并且只修改它被允许修改的状态。它也提醒我,在进行架构设计之前,应该先用规格化的方式把题目重新理一遍,明确对象之间的关系、方法的职责以及状态变化的边界。这样写出的代码不只是能通过当前测试,也更容易在后续迭代中保持一致。

...全文
13 回复 打赏 收藏 转发到动态 举报
写回复
用AI写文章
回复
切换为时间正序
请发表友善的回复…
发表回复

305

社区成员

发帖
与我相关
我的任务
社区描述
2026年北航面向对象设计与构造
java 高校
社区管理员
  • 孙琦航
加入社区
  • 近7日
  • 近30日
  • 至今
社区公告
暂无公告

试试用AI创作助手写篇文章吧