BUAA OO 第三单元(JML与规格驱动开发)总结博客

孙意-24371286 2026-05-28 16:25:32

第三单元围绕一个类短视频社交平台展开,通过三次迭代作业,从基础的用户关注与视频上传功能逐步扩展到硬币系统、推荐算法和影响力模型。本单元的核心特色在于所有功能均由 JML 规格严格定义,迫使我在"读懂规格→实现代码→编写测试"的闭环中反复打磨。以下从四个维度进行全面总结。


一、对 JML 与规格驱动开发的理解

1. JML 作为代码的"法律文书"

JML 本质上是一套行为契约语言,它将方法的行为拆解为三个核心部分:

  • 前置条件 requires:调用方必须满足的约束。例如 followUser 要求 containsUser(id1) && containsUser(id2) && id1 != id2
  • 后置条件 ensures:方法执行完毕后的保证。例如 uploadVideo 保证视频被加入 videos 数组,并且所有粉丝的 receivedVideos 链表头部新增了该视频 ID。
  • 可赋值域 assignable:明确声明该方法可以修改哪些状态。例如 watchVideoassignable getUser(userId).receivedVideos, getUser(userId).watchedVideos, getVideo(videoId).playCount

这种"契约式编程"与传统的"看需求文档猜行为"有本质区别:JML 规格用一阶谓词逻辑精确界定了每个方法的输入输出边界,消除了自然语言理解偏差导致的歧义。以 queryMutualFollowingSum 的 JML 为例:

/*@ ensures \result == (\sum int i; 0 <= i && i < users.length;
  @          (\sum int j; i < j && j < users.length;
  @           (users[i].isFollowing(users[j]) && users[j].isFollowing(users[i])) ? 1 : 0));
  @*/

这一行数学表达式比任何自然语言描述都更精确——它告诉我们这是一个纯查询方法,不可以修改任何状态(没有 assignable),结果等于所有双向关注对的数量,且每对只计一次(i < j 保证不重)。

2. 从 JML 到实现的心智转换

在实际编码中,我将 JML 规格视为"测试预言"(test oracle):代码是否正确?对照 JML 的后置条件逐条检查即可。规格中的 \old() 表达式尤为关键——它提醒我在修改前必须保存旧状态、或确保操作正确补偿。例如 hw10 中 coinVideo 涉及三方状态变更(投币者扣钱、视频加硬币、上传者收钱),规格中大量的 \old() 让我清晰意识到每一步的先后顺序和不可逆性。

3. Pure 方法与副作用隔离

JML 中 /*@ pure @*/ 标记的方法保证不修改任何状态。在三次作业中,containsUserqueryMutualFollowingSumqueryShortestPath 等查询方法全部为 pure。这个约束对测试至关重要——pure 方法可以安全地在测试断言中反复调用,不会产生副作用。在我的 hw11 测试中,大量使用了 captureSnapshot / assertSnapshotUnchanged 模式来验证 recommendNthUp 这类 pure 查询方法确实没有篡改图状态。

4. 对规格驱动开发的感悟

规格驱动开发的价值在于"先契约,后实现"。JML 把复杂的业务逻辑转化为形式化约束,开发者不再需要从自然语言需求出发自行脑补所有边界条件。三次作业中,只要我严格对照 JML 实现,异常分支和边界条件就不会遗漏。规格中的 exceptional_behavior 完整列举了所有异常抛出条件,相当于自动生成了异常处理的 checkList。当然,JML 的学习曲线较陡峭,尤其是 \forall\exists\num_of 等量词表达式的阅读和验证,需要一定的数理逻辑基础。


二、JUnit 测试的经验总结

  • 快照模式是 pure 方法测试的关键:对于查询方法,验证它"什么都没改"比验证它"返回了什么"更重要。因为 JML 的 assignable \nothing 是硬约束,违反即错误。
  • 异常分支必须覆盖:JML 的 exceptional_behavior 给出了完整的异常条件矩阵。用 @Test(expected = ...)try-catch + fail() 覆盖每一个异常分支,确保异常在正确条件下抛出、且异常抛出后状态无变化。
  • 边界与组合:空网络、单用户、大图全连接、链式单向关注……这些极端场景往往暴露普通测试发现不了的 bug。

三、三次作业的迭代过程分析

1. 功能迭代全景

维度hw9hw10hw11
用户属性id, name, age, following, followers, receivedVideos+coins, medals, contributions, watchedVideos, likedVideos+typeCounts[7], videos, influenceByType
视频属性id, uploaderId+type, playCount, likes, forwardCount, coins, comments同 hw10(热度公式变化)
Network 方法数10 个18 个22 个
核心新增基础社交图 + 最短路径 BFS经济系统 + 评论 KMP + 最长降序链推荐系统 + 影响力 + 全局贡献者
JUnit 测试聚焦queryMutualFollowingSumcleanSpamCommentsrecommendNthUp

2. 已有方法/容器在迭代中的变化

迭代中最容易被忽视的坑是已有方法的语义被悄然扩展。通过 diff 对比三次代码,我发现了以下关键变化:

  • watchVideo 的副作用膨胀:hw9 仅从 receivedVideos 中移除;hw10 额外增加了 addWatchedVideovideo.addPlay();hw11 进一步增加了 incrementTypeCount 和上传者的 addInfluence(type, 2)。这个方法的 assignable 列表在 JML 中逐次增长,如果不仔细阅读新规格,很容易漏加新逻辑。
  • likeVideo / coinVideo 新增影响力传播:hw11 中,点赞/投币不仅改变视频计数,还通过 addInfluence(video.getType(), +/-3/5*amount) 影响上传者的分类影响力。这种"涟漪效应"是迭代开发中最大的出错点。
  • 容器操作的微妙变化:hw9 的 watchVideoreceivedVideos.remove((Integer) videoId) 只移除首次出现;hw10/hw11 改用 receivedVideos.removeIf(id -> id == videoId) 移除所有出现。这个差异源于 JML 规格的变更——!getUser(userId).hasReceivedVideo(getVideo(videoId)) 要求观看后不再拥有该视频。

3. 如何发现迭代中的变化?

  • 逐条对比 JML 规格接口:官方包的 NetworkInterface.java 是变化的源头。每次新作业发布后,首先 diff 接口文件,重点关注 assignable 子句和新增的 ensures
  • 关注新增 import 和异常类:hw10 新增了 InsufficientCoinsExceptionInvalidCoinsException 等 5 个异常类,直接暗示了新增的业务约束。
  • 用 git diff 追踪自己的代码:对比自己 hw9→hw10→hw11 的实现变化,确认哪些修改是主动适配、哪些是无意中的行为变化。

4. 如何发现程序的性能瓶颈?

  • queryShortestPath(BFS):每次调用都从零开始 BFS,复杂度 O(V+E)。在关注关系密集的大图上,频繁的路径查询成为瓶颈。官方评测的 CPU 时间限制迫使我将 BFS 实现得尽量紧凑(用 ArrayDeque 而非 LinkedList,用 HashSet 做 visited 标记)。
  • queryLongestDecSeq:采用记忆化 DFS,每个节点只计算一次,复杂度 O(V+E)。如果在 hw10 中没有引入 memo,该方法的复杂度将是阶乘级。
  • recommendNthUp:需要对所有候选 UP 计算 score、排序,复杂度 O(U log U)。在 hw11 中,我通过在 Network 层用 HashMap 缓存 computeUpScore 结果来避免排序时的重复计算。
  • globalBestContributorVotes 的增量维护:在 coinVideo 中实时追踪每个 UP 的"最佳贡献者"票数变化,将 queryGlobalBestContributor 从 O(U²) 的全量扫描降到 O(U) 的 HashMap 遍历。

四、程序 Bug 与原因分析

Bug 1:hw10 中 watchVideo 的 remove 语义变更

在 hw9 中,watchVideo 使用 receivedVideos.remove((Integer) videoId) 仅移除链表中的第一个匹配元素。而 hw10 的 JML 要求观看后 !getUser(userId).hasReceivedVideo(getVideo(videoId)),这意味着如果同一个视频被转发多次导致 receivedVideos 中出现多个相同 ID,应全部移除。我在 hw10 中改为 removeIf 修复了此问题。教训:JML 规格中的后置条件是"状态断言"而非"操作描述",hasReceivedVideo 返回 false 意味着该视频 ID 在 receivedVideos 中出现次数必须为 0。

Bug 2:hw10 中 cleanSpamComments 空 keyword 的处理

当 keyword 为空字符串时,JML 规格要求删除所有评论(因为空字符串是任何字符串的子串)。但 KMP 算法在 pattern 为空时 next 数组长度为 0,需要特殊处理。初始实现中未处理此边界,导致 ArrayIndexOutOfBoundsException 或结果错误。修复方式为:当 keyword.isEmpty() 时,将每个评论的重复次数设为 comment.length() + 1(确保 > 0 从而触发删除)。教训:算法实现必须覆盖规格中的所有边界条件,尤其是对于"空"和"null"的语义要仔细区分——规格中 keyword 可以为空字符串但不可为 null。


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

题目概述

方法签名为 public int costPoints(int userId, int points) throws UserNotFoundException, InsufficientPointsException,业务场景为从用户账户中扣除指定积分数。

R1 NL 规格

前置条件要求用户存在且积分充足;后置条件为用户积分减少对应数值并返回扣除后的余额;异常分三类:用户不存在抛 UserNotFoundException、积分不足或为负抛 InsufficientPointsException、不允许改变属性(推测指 assignable \nothing 的语义)。

R2 JML 规格

整体结构清晰,三个行为块(normal + 两个 exceptional)对应三种情形,量词使用得当。值得注意的是,第一页 JML 中 ensures 子句写为\result == \old(getPoints(userId)) - points,语义准确。第二页(传递后)的 JML 改用 userMap 作为底层数据结构,引入了 containsKeyget 方法,与第一页风格有所漂移,但核心逻辑基本保留。

发现的问题

  1. points 为负的边界条件处理不一致:R1 NL 中明确提到"points 为负时抛异常",但第二页 JML 的 requires 中仅写 getPoints() < point,未单独处理 points < 0 的情形,存在边界遗失风险。
  2. signalssignals_only 混用:第二页出现了signals_only 关键字,与第一页的 signals 不一致,语义有细微差别,可能引发歧义。
  3. assignable 范围模糊:第一页写 assignable userPoints(userId),第二页写 assignable userMap...getpoints(),两次传递后对"允许修改的状态"的描述逐渐泛化,精确性有所下降。
...全文
30 回复 打赏 收藏 举报
写回复
用AI写文章
回复
切换为时间正序
请发表友善的回复…
发表回复

309

社区成员

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

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