309
社区成员
发帖
与我相关
我的任务
分享第三单元围绕一个类短视频社交平台展开,通过三次迭代作业,从基础的用户关注与视频上传功能逐步扩展到硬币系统、推荐算法和影响力模型。本单元的核心特色在于所有功能均由 JML 规格严格定义,迫使我在"读懂规格→实现代码→编写测试"的闭环中反复打磨。以下从四个维度进行全面总结。
JML 本质上是一套行为契约语言,它将方法的行为拆解为三个核心部分:
requires:调用方必须满足的约束。例如 followUser 要求 containsUser(id1) && containsUser(id2) && id1 != id2。ensures:方法执行完毕后的保证。例如 uploadVideo 保证视频被加入 videos 数组,并且所有粉丝的 receivedVideos 链表头部新增了该视频 ID。assignable:明确声明该方法可以修改哪些状态。例如 watchVideo 的 assignable 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 保证不重)。
在实际编码中,我将 JML 规格视为"测试预言"(test oracle):代码是否正确?对照 JML 的后置条件逐条检查即可。规格中的 \old() 表达式尤为关键——它提醒我在修改前必须保存旧状态、或确保操作正确补偿。例如 hw10 中 coinVideo 涉及三方状态变更(投币者扣钱、视频加硬币、上传者收钱),规格中大量的 \old() 让我清晰意识到每一步的先后顺序和不可逆性。
JML 中 /*@ pure @*/ 标记的方法保证不修改任何状态。在三次作业中,containsUser、queryMutualFollowingSum、queryShortestPath 等查询方法全部为 pure。这个约束对测试至关重要——pure 方法可以安全地在测试断言中反复调用,不会产生副作用。在我的 hw11 测试中,大量使用了 captureSnapshot / assertSnapshotUnchanged 模式来验证 recommendNthUp 这类 pure 查询方法确实没有篡改图状态。
规格驱动开发的价值在于"先契约,后实现"。JML 把复杂的业务逻辑转化为形式化约束,开发者不再需要从自然语言需求出发自行脑补所有边界条件。三次作业中,只要我严格对照 JML 实现,异常分支和边界条件就不会遗漏。规格中的 exceptional_behavior 完整列举了所有异常抛出条件,相当于自动生成了异常处理的 checkList。当然,JML 的学习曲线较陡峭,尤其是 \forall、\exists、\num_of 等量词表达式的阅读和验证,需要一定的数理逻辑基础。
assignable \nothing 是硬约束,违反即错误。exceptional_behavior 给出了完整的异常条件矩阵。用 @Test(expected = ...) 或 try-catch + fail() 覆盖每一个异常分支,确保异常在正确条件下抛出、且异常抛出后状态无变化。| 维度 | hw9 | hw10 | hw11 |
|---|---|---|---|
| 用户属性 | 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 测试聚焦 | queryMutualFollowingSum | cleanSpamComments | recommendNthUp |
迭代中最容易被忽视的坑是已有方法的语义被悄然扩展。通过 diff 对比三次代码,我发现了以下关键变化:
watchVideo 的副作用膨胀:hw9 仅从 receivedVideos 中移除;hw10 额外增加了 addWatchedVideo 和 video.addPlay();hw11 进一步增加了 incrementTypeCount 和上传者的 addInfluence(type, 2)。这个方法的 assignable 列表在 JML 中逐次增长,如果不仔细阅读新规格,很容易漏加新逻辑。likeVideo / coinVideo 新增影响力传播:hw11 中,点赞/投币不仅改变视频计数,还通过 addInfluence(video.getType(), +/-3/5*amount) 影响上传者的分类影响力。这种"涟漪效应"是迭代开发中最大的出错点。watchVideo 用 receivedVideos.remove((Integer) videoId) 只移除首次出现;hw10/hw11 改用 receivedVideos.removeIf(id -> id == videoId) 移除所有出现。这个差异源于 JML 规格的变更——!getUser(userId).hasReceivedVideo(getVideo(videoId)) 要求观看后不再拥有该视频。NetworkInterface.java 是变化的源头。每次新作业发布后,首先 diff 接口文件,重点关注 assignable 子句和新增的 ensures。InsufficientCoinsException、InvalidCoinsException 等 5 个异常类,直接暗示了新增的业务约束。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 遍历。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。
cleanSpamComments 空 keyword 的处理当 keyword 为空字符串时,JML 规格要求删除所有评论(因为空字符串是任何字符串的子串)。但 KMP 算法在 pattern 为空时 next 数组长度为 0,需要特殊处理。初始实现中未处理此边界,导致 ArrayIndexOutOfBoundsException 或结果错误。修复方式为:当 keyword.isEmpty() 时,将每个评论的重复次数设为 comment.length() + 1(确保 > 0 从而触发删除)。教训:算法实现必须覆盖规格中的所有边界条件,尤其是对于"空"和"null"的语义要仔细区分——规格中 keyword 可以为空字符串但不可为 null。
方法签名为 public int costPoints(int userId, int points) throws UserNotFoundException, InsufficientPointsException,业务场景为从用户账户中扣除指定积分数。
前置条件要求用户存在且积分充足;后置条件为用户积分减少对应数值并返回扣除后的余额;异常分三类:用户不存在抛 UserNotFoundException、积分不足或为负抛 InsufficientPointsException、不允许改变属性(推测指 assignable \nothing 的语义)。
整体结构清晰,三个行为块(normal + 两个 exceptional)对应三种情形,量词使用得当。值得注意的是,第一页 JML 中 ensures 子句写为\result == \old(getPoints(userId)) - points,语义准确。第二页(传递后)的 JML 改用 userMap 作为底层数据结构,引入了 containsKey 和 get 方法,与第一页风格有所漂移,但核心逻辑基本保留。
points 为负的边界条件处理不一致:R1 NL 中明确提到"points 为负时抛异常",但第二页 JML 的 requires 中仅写 getPoints() < point,未单独处理 points < 0 的情形,存在边界遗失风险。signals 与 signals_only 混用:第二页出现了signals_only 关键字,与第一页的 signals 不一致,语义有细微差别,可能引发歧义。assignable 范围模糊:第一页写 assignable userPoints(userId),第二页写 assignable userMap...getpoints(),两次传递后对"允许修改的状态"的描述逐渐泛化,精确性有所下降。