OO第三单元总结博客

张以则-24231206 2026-05-28 21:28:28

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

1. JML 规格

JML(Java Modeling Language)是一种用于 Java 程序形式化规格说明的语言。它通过在 Java 代码中加入类似注释的规格,描述程序应满足的行为、约束和性质。

JML 可以分为类的规格和方法的规格:

类的规格

  最常用的是不变式 invariant,规定类内部属性在任意可见状态下需要满足的关系。例如 users 数组按下标 i < j 时 !users[i].equals(users[j]),以及 Video 中 commentIds.length == commentContents.length 等。

方法的规格

  关键词          含义
  requires        前置条件:调用前参数/状态需满足的条件;不满足时可按规格抛出异常
  ensures         后置条件:正常返回后返回值或被修改状态需满足的条件
  assignable      副作用:方法可能修改的字段;pure 方法通常为 assignable \nothing
  signals         异常行为:何种条件下抛出何种异常
  pure            纯查询:不修改对象状态,可重复调用且结果一致

以 hw9 的 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));
    @*/
  public /*@ pure @*/ int queryMutualFollowingSum();

这说明:互关对只按 users 数组下标 i < j 统计一次,与「用户 id 大小」无必然关系——这一点在公测/互测里经常被误解。

2. 规格驱动开发对我意味着什么

客观地说,JML 用接近代码的语言描述需求,比纯自然语言歧义少,对着官方包里的 JML 写实现,思路往往比较直接。

但实际工程里用得不多,也有原因:

  (1)难写、难读:嵌套 \forall、\sum、路径存在量词(如 queryShortestPath)很长,第一次作业我不少时间是「先读懂 JML,再写代码」。
  (2)与实现顺序的矛盾:写 JML 需要先有整体思路,而理清思路的常见方式是自己先实现一遍;实现完了再补 JML,又像多此一举。若直接写 JML,又难保证覆盖全部边界。
  (3)规格与效率脱钩:JML 只说「算什么」,很少约束「怎么算、复杂度多少」——这正是 hw9 互关统计大面积 TLE 的根源(见后文)。

我的理解是:在本课程语境下,规格驱动开发 = 以 JML 契约为共同标准,先固定「正确性」,再实现与测试;规格与单元测试是互补的,而不是替代关系。规格定语义,实现定算法与数据结构,二者需要在表达力、可读性、可验证性之间折中——第一次研讨课上讨论编辑距离时,小组也有类似体会:JML 偏「最优子结构/结果是什么」,DP 偏「填表顺序与空间优化」。


二、JUnit 测试经验

有 JML 之后,单测的「断言清单」相对清晰:requires 指导异常用例,ensures 指导返回值,pure 指导重复调用与状态不变,assignable 指导副作用范围。

1. 我的用例组织方式

三次作业我都为当次新增/重点方法写了 JUnit4 测试(hw9:QueryMutualFollowingSumTest;hw10:CleanSpamCommentsTest;hw11:RecommendNthUpTest),整体思路如下:

(1)用辅助方法代替巨型 TestCase 类

没有单独建 TestCase 内部类,而是把「造数据 + 金标准 + 快照」拆成 private static 方法,例如:

  - assertMutualQueryPure:断言互关和 + 两次调用一致 + 关注边快照 + 未看视频列表快照;
  - assertCleanPure:在 cleanSpamComments 之后再调一次,确认评论、播放量、点赞、投币、用户硬币等均未变;
  - snapshotUsers + strictEquals:hw11 对 pure 的 recommendNthUp 检查所有用户对象状态未变。

(2)金标准(Oracle)与 JML 对齐

  - hw9:按 JML 公式心算互关对数,并显式测 pure(两次 queryMutualFollowingSum 相等、边与 receivedVideos 不变)。
  - hw10:独立实现 keywordCount(重叠子串计数)和 expectedRemovedCount,与 ensures 中「删除条数、最大出现次数」对照;专门测 getCommentIds 深拷贝(改返回数组不影响内部状态)。
  - hw11:按 JML 手算 computeUpScore(兴趣 × 影响力),测同分取最小 up id、排除已关注、冷启动异常等。

(3)测例设计策略(结合研讨课共识)

  类型                  举例
  边界                  空网络、单向关注、无互关
  典型结构              两对互关、三角形内两对互关仍计 2
  对抗「错误假设」      上传视频后互关仍为 0,但 follower 收到视频(验证 pure 未误改其它状态)
  重叠/空串             aaaa + aa 关键词计数为 3;空 keyword 删光
  异常路径              VideoIdNotFoundException、InvalidRankException、ColdStartUserException

研讨课题目二提到:若只断言返回值,突变测试可能「期望失败却通过」——我在 hw9 里对 pure 加了边集与 receivedVideos 快照,正是为了避免「返回值碰巧对、其实改了状态」的假绿。


三、三次作业迭代过程

1. 三次作业在做什么

  作业            官方包    我实现/测试的重点
  hw9(第一次)   spec1     社交网络基础 API;queryMutualFollowingSum、queryShortestPath;JUnit 覆盖互关与 pure
  hw10(第二次)  spec2     在 hw9 上扩展点赞/投币/评论/勋章等;cleanSpamComments、queryLongestDecSeq;互测数据 8 组
  hw11(第三次)  spec3     视频类型、兴趣画像、影响力;recommendNthUp / recommendVideo 等;互测 10 组 + 公式级单测

2. 如何发现「方法/容器在迭代中的变化」

  (1)对比官方包接口:每次作业换 spec1 → spec2 → spec3,我会先 diff NetworkInterface / UserInterface / VideoInterface,看新增了哪些方法、哪些 invariant 变严了(例如 hw11 对 watchedVideos 按下标递增的约束)。
  (2)复用与迁移代码:hw10 目录里保留了 hw9 强测数据、convert_hw9_to_hw10.ps1,用旧数据回归新行为;hw11 的 mutual_10_mixed_stress 故意混测 hw10+hw11 命令。
  (3)容器职责变化:
       hw9:User 用 ArrayList 存 following/followers,查询互关时对全体 users 双重循环;
       hw10:增加 followingIds(HashSet)、getFollowingList(),queryMutualFollowingSum 改为只沿关注边枚举,且用 aid < bid 去重;
       hw10→hw11:NetworkRecommend、CommentCleaner 等拆分类,避免 Network 无限膨胀。

3. 如何发现性能瓶颈

  阶段          现象                              原因                                                                          改动
  hw9 强测      query_mutual_following_sum TLE    对 users 做 O(n²) 双重循环,每次 isFollowing 再扫 following 列表,稠密图接近 O(n³)   hw10 改为遍历每条关注边,互关判断 O(1) 量级
  hw9 强测      query_shortest_path 偏慢           BFS 扩展时对所有 users 扫描是否被当前用户关注,而非只扫 following                  hw10 改为沿 getFollowingList() 扩展
  互测/本地     未 TLE 但 WA                       逻辑错或输出格式错,而非纯性能                                                  见第四节

性能问题往往不会写在 JML 里;强测数据规模大时才暴露。我的习惯是:公测通过后,用课程下发的强测 stdin 本地跑一遍,看卡在哪个命令上,再对那条命令做复杂度估算。


四、Bug 分析

1. hw9:queryMutualFollowingSum 超时(公测/强测)

现象:互关逻辑按 JML 写对了,但强测 TLE。

原因:实现是:

  for (int i = 0; i < n; i++)
      for (int j = i + 1; j < n; j++)
          if (a.isFollowing(b) && b.isFollowing(a)) sum++;

在用户数大、关注关系多时,复杂度极高。JML 只要求结果对,不要求遍历方式。

修复思路(hw10 起):只遍历实际存在的关注边,并用 aid < bid && b.isFollowing(a) 保证每对只计一次。

2. hw9:JUnit「假绿」与突变测试(研讨课 testcase8)

现象:平台提示类似「期望测试失败,实际通过」。

原因:只断言了 \result,没有检查 pure 方法是否修改了关注关系、receivedVideos 等;错误实现可能「碰巧返回对的和」。

修复:assertMutualQueryPure 中增加二次调用一致性、边集与未看视频列表快照(见 QueryMutualFollowingSumTest)。

3. hw10:cleanSpamComments 与互测

  Bug                         原因
  重叠关键词计数错误          用 String.matches 或简单 split 无法处理 aaaa 中 aa 出现 3 次;需滑动窗口逐位匹配
  误用「单词匹配」            JML 是 contains(keyword) 子串语义,不是整词;I_dis_like_it 不含 dislike 等题目需仔细读规格
  互测 .out 合法性失败        PowerShell UTF8 默认带 BOM,首行异常;改为无 BOM UTF-8 重新生成期望输出
  queryLongestDecSeq 理解偏差 规格是沿关注边、年龄严格递减的最长链;若做成「全体用户按年龄 LIS」会在 mutual_04 / mutual_04b 上 WA

4. hw10:浅拷贝与 pure

现象:若 getCommentIds() 返回内部数组引用,调用方修改数组会影响视频状态,破坏 pure 语义。

处理:Video 的 getter 返回副本;单测 getCommentArraysAreDeepCopies 修改返回数组后断言内部评论条数仍为 0。

5. hw11:推荐与排序边界

典型坑(结合互测用例与单测):

  - 同分取最小 videoId / 最小 upId(mutual_03、testTieBreakBySmallerId);
  - 推荐 UP 时排除自己与已关注;
  - rank <= 0 → InvalidRankException;候选不足 → ColdStartUserException;
  - computeUpScore 用 long 防溢出(兴趣 × 影响力)。


五、大模型与 Code Agent 使用

我在 Unit3 中较多用到大模型辅助,主要在三个方面:

  (1)读懂 JML:嵌套量词、\old、\result 组合在一起很长,我会把官方接口方法贴给模型,让它用自然语言解释「到底要满足什么」。
  (2)查错与性能:自己按 JML 实现后,把 Network / User 关键方法发给模型,问「有没有违反 pure/assignable」「互关/最短路能否减复杂度」——hw9 的 TLE 就是在对比实现与讨论后意识到要改遍历方式。
  (3)测试思路:让模型根据 ensures 列检查清单,并帮忙设计「重叠子串」「空 keyword」「非连续 id」等边界;我再手工改成 JUnit 与互测 stdin。

体会

  优势:快速翻译 JML、补全测试用例矩阵、解释官方包 diff。
  劣势:模型容易写出「看起来对」但复杂度爆炸的实现;对容器不变式、浅拷贝、按数组下标而非 id 计数等课程特有约束,需要人以官方 JML 为准校对。
  单元测试:可以让模型生成「异常路径列表」和 oracle 函数草稿,但断言必须自己对齐 JML,否则会出现研讨课说的假绿。


六、第二次研讨课:JML「击鼓传花」感悟

(以下为结合课堂活动、小组讨论与样本反思的总结;若你小组有具体方法名/用时记录,可在发布前替换为真实例子。)

1. 是否发现自己/别人的 JML bug?

有。常见几类:

  - 边界丢失:例如只写了 rank > 0,漏了「候选不足抛冷启动」的 also 分支;
  - 自然语言 ↔ JML 漂移:击鼓传花第二轮把 JML 翻回自然语言时,同学漏了「同分取最小 id」,需求在传递中悄悄变了;
  - 纯函数未写清:该 pure 的方法没写全 assignable \nothing,导致实现方以为可以改用户状态。

我出题时给了一个偏复杂的推荐/统计类方法(与第三单元作业类似),传回我手上时 JML 骨架没大错,但中间同学翻译成的自然语言已经和我最初的业务背景有偏差——说明规格也在传话中被改写。

2. 传递过程中需求、边界是否变化?

会变化,而且往往发生在 NL → JML → NL 的往返中,而不是第一轮 JML 书写时。变化包括:计数对象(按下标还是按 id)、去重规则、异常优先级、also 分支覆盖不全等。

一次中等难度方法,四人组两轮传递就花了约 45 分钟;推想到大项目,若缺乏统一术语表和评审,信息差会指数放大。

3. 多人组队如何统一理解、减少信息差?

若课程或项目组必须用 JML,我建议:

  措施              说明
  统一术语表        固定「用户数组下标」「id」「互关对」「关注边」等说法,禁止混用
  文件级总规格      不只写单方法 JML,先写本模块目标、核心不变式、全局纯函数
  双人互审          一组写 JML,另一组只做「找漏洞」:漏异常、漏 also、漏 pure
  可执行检查        关键方法配最小 JUnit + 1~2 条互测 stdin,传花结束立刻跑
  NL 回译对照       第三人把 JML 翻成 NL 后,出题人逐条勾选是否等价,不通过不许写代码
  短会同步          传花/结对超过 30 分钟必须 sync,避免各自脑补

若可以选流程,我仍认为:JML + 单测 + 少量端到端数据 比单独传花更高效;JML 的价值在于「契约可测」,不在于形式本身。


七、小结

第三单元让我把「正确」从直觉变成了可引用的契约:JML 划边界,JUnit 和互测数据守边界,强测逼出性能与规模问题。三次作业不是三个孤立题,而是同一社交网络模型在 spec1/2/3 下持续演进——每次先读官方包 diff,再迁移实现与测试,比从零写更不容易漏需求。

最大的教训是:满足 JML 不等于满足性能;测返回值不等于测 pure。击鼓传花则提醒我们:规格也会在人之间传变形——组队时要把「回译确认」当成和写代码一样硬的步骤。
 

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

309

社区成员

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

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