OO Unit3 总结博客

杨启桐24373180 2026-05-28 01:13:12

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

1.1 JML 是什么

JML(Java Modeling Language)是一种用于 Java 程序的形式化规格语言。它通过在注释中嵌入逻辑断言,将方法的行为约束从自然语言描述提升为可精确验证的数学表达。Unit3 中我们主要接触了以下几个核心概念:

关键字含义
requires前置条件(调用方保证)
ensures后置条件(实现方保证)
exceptional_behavior异常行为规格
assignable可修改范围(副作用声明)
\result方法返回值
\old(e)调用前的表达式值
\forall / \exists全称/存在量词
\num_of满足条件的元素计数

1.2 规格驱动开发的核心思想

规格驱动开发的本质是**先定义"做什么",再思考"怎么做"**。在 Unit3 中,接口文件(NetworkInterfaceUserInterfaceVideoInterface)配合 JML 注释,给出了每个方法的完整行为规约,我们的任务是实现满足规格的代码。

这种开发模式有几个显著优势:

契约明确,责任清晰。 requires 子句规定了调用方必须满足的前提,ensures 子句规定了实现方必须保证的结果。双方各守契约,接口成为真正的"合同",而不仅仅是方法签名。

纯函数验证。 JML 中的 assignable \nothing 是一个非常有价值的断言——它明确告知读者此方法不修改任何状态。在实现 queryMutualFollowingSumqueryShortestPath 等查询方法时,这条约束直接指导了我的设计:不应在查询时修改缓存或内部字段,每次都应该从当前状态计算结果(或用增量维护的字段直接返回)。

异常行为规格化。 传统文档很难精确描述异常边界,而 JML 的 exceptional_behavior 子句配合 signals 约束,使每一种异常的触发条件都有精确定义,避免了"这种情况抛什么异常"的猜测。

1.3 规格到实现的映射难点

理论上,JML 规格可以完全指导实现。但在实践中我遇到了几个映射难点:

  • \num_of 的重叠计数语义cleanSpamCommentsresult[1] 要求统计被删评论中关键词重叠出现的最大次数。初次阅读 JML 时我以为用 String.splitindexOf 做非重叠计数就够了,但 JML 的 \num_of i; 0<=i && i+keyword.length()<=content.length(); content.substring(i, i+keyword.length()).equals(keyword) 明确定义的是逐位滑动的重叠计数(例如 "aaaa""aa" 出现 3 次,而非 2 次)。

  • pure 方法的副作用约束pure 标注要求方法不改变任何可观察状态。HW11 的 recommendNthUppure 方法,但内部需要对候选 UP 进行排序。如果直接对内部列表排序则违反规格,必须在辅助类 RecommendService.sortedCandidates 中构造一个新列表再排序,不触碰任何已有容器。


二、JUnit 测试经验总结

Unit3 的一个重要训练是针对 JML 规格编写 JUnit 测试。三次作业我分别为核心方法设计了测试类:

  • **HW9 SumTest**:覆盖 queryMutualFollowingSum 的所有边界情况
  • **HW10 CleanSpamCommentsTest**:覆盖 cleanSpamComments 的完整 JML 约束
  • **HW11 Test_Recommend_Nth_Up**:覆盖 recommendNthUp 的逻辑正确性与纯函数性

2.1 从 JML 到测试用例的推导方法

我的核心思路是逐条翻译 JML 子句,每条 ensures、每条 exceptional_behavior 对应至少一个测试方法:

JML: exceptional_behavior: !containsVideo(videoId) ==> VideoIdNotFoundExceptiontestThrowsWhenVideoNotFound()

JML: ensures result[0] == 含 keyword 的评论数
→ testPartialMatchPreservesOrder()、testAllCommentsContainKeyword()

JML: assignable videos[videoId].comments  ← 仅目标视频评论可变
→ testUnrelatedVideoUnchanged()、testUsersUnchangedAfterClean()

2.2 纯函数测试(状态快照模式)

对于声明为 pureassignable \nothing 的方法,我采用了状态快照模式:在调用前对网络状态做完整快照,调用后逐字段比对,确保没有任何隐式副作用。

// SumTest 中的快照比对
Map<Integer, UserSnapshot> before = snapshot(network);
assertEquals(expected, network.queryMutualFollowingSum());
assertSameState(before, network);  // 验证无副作用

这种模式在 HW11 中更加重要——recommendNthUp 内部的排序操作很容易写成修改原列表的形式,快照测试能第一时间发现这类 bug。

2.3 边界用例的重要性

cleanSpamComments 为例,除了正常情况外,以下边界用例覆盖了容易出错的地方:

  • keyword 比任何评论都长result 应为 [0, 0],所有评论保留
  • 重叠匹配"abababab""abab" 应匹配 3 次(位置 0、2、4)
  • 大小写敏感"Spam" 不应被 "spam" 命中
  • 第二次清理幂等:第一次清理后再清理同一关键词,result 应为 [0, 0]

这些边界用例很多时候比普通流程更能暴露实现缺陷。

2.4 测试与暴力对拍

对于 queryMutualFollowingSum 这类有清晰数学定义的查询,我额外写了一个 bruteMutualSum 暴力实现,并在每次操作后对比增量维护的字段与暴力计算结果是否一致:

assertEquals(bruteMutualSum(network), network.queryMutualFollowingSum());

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

3.1 整体演进脉络

HW9 (spec1) ─────────────────────────────────────────────
  User + Video + Network(关注/视频推流/最短路)

HW10 (spec2) ────────────────────────────────────────────
  + 经济系统(coins、medals)
  + 视频互动(like、coin、forward、comment)
  + cleanSpamComments、queryMostPopularVideo
  + GraphAlgo 辅助类(BFS + 最长递减链)
  + CommentCleaner 辅助类

HW11 (spec3) ────────────────────────────────────────────
  + 推荐系统(recommendVideo、recommendNthUp)
  + 影响力查询(queryMostInfluentialUp)
  + RecommendService 辅助类
  + User 新增 typeCounts[] 兴趣向量
  + Video.getHeat() 公式变更(double → int,系数全变)

3.2 如何发现方法/容器在迭代中的变化

3.2.1 接口 diff:新增方法一目了然

每次新作业的第一步是对比 NetworkInterface(spec1 → spec2 → spec3),新增的方法名即为本次需要实现的功能。但更隐蔽的是已有方法的签名或语义变化

最典型的例子是 Video.getHeat()

版本返回类型公式
HW10 (spec2)doubleplay*1.0 + likes*1.5 + forward*2.0 + coins*2.5
HW11 (spec3)intplay*2 + likes*3 + forward*4 + coins*5

如果在 HW11 中没有仔细阅读 VideoInterface 的变化,沿用 HW10 的 getHeat() 实现,推荐分数的计算结果就会完全错误。我的应对方法是:每次迭代先阅读接口文件,逐方法对比返回类型和 JML 注释,而不是直接复制旧代码。

3.2.2 容器的演变

从三次作业的 User.java 可以清楚看到容器随功能增长的演变过程:

字段HW9HW10HW11
following / followersArrayListArrayListArrayList
receiveVideosArrayList<Integer>ArrayList<Integer>ArrayList<Integer>
watchedVideosArrayList<VideoInterface>ArrayList<VideoInterface>
likedVideosArrayList<VideoInterface>ArrayList<VideoInterface>
coinsintint
medalsArrayList<Integer>ArrayList<Integer>
contributors/contributions双平行 ArrayList双平行 ArrayList
typeCountsint[7](兴趣向量)
videos(已上传)ArrayList<VideoInterface>

每次迭代,User 的字段量都在增加,而**关注/被关注关系始终使用 ArrayList 而非 HashSet**,这是贯穿三次作业的一个设计抉择,也是主要性能瓶颈之一(见下节)。

3.2.3 辅助类的分离演变

随着 Network 的功能增多,将算法逻辑内联在 Network 中会导致类过于臃肿。我在 HW10 中将图算法提取为 GraphAlgo,将评论清洗提取为 CommentCleaner;在 HW11 中将推荐与贡献统计提取为 RecommendService

这种分离带来了明显好处:单独为 CommentCleanerRecommendService 写 JUnit 测试时,不需要构造完整的 Network 上下文,单元测试更轻量、隔离性更好。

四、Bug 分析

4.1 Lambda 变量名遮蔽(Shadowing)

User.javaremoveReceivedVideo 方法中:

public void removeReceivedVideo(int videoId) {
    receiveVideos.removeIf(id -> id == videoId);
    //                     ^^── lambda 参数名 "id" 与字段名 "id" 同名
}

Java 语义上 lambda 参数会遮蔽外部同名变量,id 指的是当前迭代元素(Integer),videoId 是方法参数,逻辑上是正确的。但这种写法容易引起误解,更规范的写法应该用 vid -> vid == videoId,避免与字段 this.id 产生视觉混淆。在审查代码时曾一度怀疑这里有 bug,花费了额外时间确认。

教训:lambda 参数命名应避免与类字段同名,哪怕语义正确。

4.2 HW10 → HW11 迁移时的 getHeat() 公式不同步

上面提到 Video.getHeat() 在 HW11 中将公式从浮点改为整数,且系数完全不同。如果在 HW11 中继续沿用 HW10 的 Video 类,推荐分数的比较就会用错误的热度值,导致 recommendVideorecommendNthUp 排名错误。

这个 bug 很隐蔽,因为:

  1. 编译不报错(接口返回类型 int / double 变了才报错);
  2. 简单的手工测试样例(只有一两个视频时)不容易暴露排名差异。

发现方式:在写 Test_Recommend_Nth_Up 时,testHigherScoreWins 用例需要手动推算分数,迫使我去核实 getHeat() 的当前实现,这才发现公式已经变了。

教训:每次迭代迁移代码时,必须以新接口的 JML 为准重读被继承方法的规格,而不能想当然地认为"这个方法和上次一样"。

4.3 watchVideo 的重复观看逻辑

在 HW10 / HW11 中,watchVideo 调用 user.addWatchedVideo(v) 时有去重逻辑:

public void addWatchedVideo(VideoInterface video) {
    if (!hasWatchedVideo(video)) {
        watchedVideos.add(video);  // 只记录首次观看
    }
}

video.incPlayCount() 在每次 watchVideo 时都会调用(无论是否已观看)。这意味着播放量是累计的,但 watchedVideos 列表只记录首次。这与 JML 中 ensure video.getPlayCount() == \old(video.getPlayCount()) + 1 的定义一致,但如果编写 JUnit 测试时错误地认为重复调用 watchVideo 不会影响 playCount,就会写出错误的测试预期。

教训watchedVideos(是否看过)和 playCount(播放次数)是两个独立的概念,JML 中对两者都有明确区分,不可混淆。


五、Unit3 中大模型的使用经验

本单元我尝试在规格驱动开发流程中使用大模型辅助,以下是几点体会。

5.1 大模型在规格驱动开发中的优势

大模型在"将 JML 规格翻译为实现框架"上表现出色。将 JML 注释粘贴给模型,要求生成方法骨架,它能快速产出前置条件检查、异常抛出、返回值结构等样板代码,省去了大量机械性工作。

另一个优势是快速生成测试用例的初稿。给出方法签名和 JML,模型能列出多数边界情况,覆盖正常流程和典型异常路径。

5.2 大模型容易忽视的问题

效率问题:大模型生成的代码天然倾向于"正确优先",往往使用 ArrayList + 线性扫描,很少主动引入 HashMapHashSet 来优化查询复杂度。例如要求生成 isFollowing 时,模型默认产出的就是 ArrayList 遍历。

纯函数的副作用约束:模型在生成排序相关代码时,很容易直接 Collections.sort(list) 修改原列表,而忽略了 assignable \nothing 的约束。需要明确在 prompt 中强调"此方法不能修改任何已有容器"。

5.3 用大模型进行基础单元测试

我在这几次单元测试中发现的几个较有效的 prompt 策略是:

  1. 提供 JML 规格 + 接口定义 → 让模型识别所有 ensuresexceptional_behaviorassignable 子句
  2. 要求模型列出测试矩阵(每条子句对应哪些测试方法)
  3. 逐类生成测试代码,并人工审核边界用例是否准确(尤其是数值边界和重叠语义)

模型对于"状态快照 + 副作用验证"这类模式通常需要额外提示,否则容易漏掉 assertSameState 的检查。


六、JML "击鼓传花"游戏感悟

Unit3 第二次研讨课上,我们进行了 JML"击鼓传花"游戏:每个同学依次阅读并传递 JML 规格,在传递过程中对 JML 进行解读或补充,最后观察需求在传递中的变化。

6.1 发现的 JML Bug

在游戏过程中,我和同学们发现了一些典型的 JML 书写问题:

量词作用域缺失:有同学写了类似 \forall User u; u.isFollowing(v) 的规格,但没有约束 u 的范围(应加 u != null && containsUser(u.getId())),导致全称量词的语义是"所有 Java 对象中的 User",而非"网络中的 User",这是一个典型的未约束作用域 bug

\old 使用位置错误:有同学在 ensures 中写 \old(user.getCoins()) == user.getCoins() - amount,方向写反了(应该是 user.getCoins() == \old(user.getCoins()) - amount),导致规格实际上在断言旧值等于新值减去 amount,完全描述了一个错误的方向。

边界条件遗漏:对于"返回最大值,相同时返回 id 最小的"这类规格,有同学只写了 result == max_heat_video.id,遗漏了平局时的决胜规则,导致规格在存在多个热度相同的视频时存在歧义。

6.2 需求和边界在传递中的变化

游戏最有趣的发现是:即使传递的是同一份 JML,不同人对其边界的理解也会产生显著分歧

典型例子:"关注图中年龄严格递减的最长链"——

  • 有人理解为"链的长度"是节点数(链上有 3 个节点 = 长度 3)
  • 有人理解为"链的长度"是边数(同一条链 = 长度 2)

JML 中 returns nn 指的是什么,如果不加 // @example 注释,在传递过程中很容易产生歧义。等到各自实现后再对拍,才会发现答案系统性地差 1。

另一个例子是"评论数量上限":有人认为 \num_of 计算的是非重叠匹配,有人认为是重叠匹配。这个差异在测试数据中只有 "aaaa" / "aa" 这类重叠输入时才会暴露,普通随机数据很难触发。

6.3 如何在多人编程中统一理解

这次游戏深刻地说明:规格的文本是形式化的,但人对规格的解读是模糊的。在多人协作中,消除信息差的关键措施包括:

(1)强制要求附带具体例子(@example 注释)

对每一条非平凡的 JML 规格,要求同时提供 2~3 个手算的输入-输出样例。例如:

// @example: countOccurrences("aaaa", "aa") == 3
//           countOccurrences("abc", "abc") == 1
//           countOccurrences("abc", "abcd") == 0

这将"隐性理解"转变为"显性约定",阅读者可以立刻验证自己的理解是否与规格一致。

(2)边界条件专项会议

在开始编码前,召集团队成员对所有方法的边界情况(空集、相等时的决胜规则、长度 vs. 数量的语义)逐一口头确认,并将共识记录在 Wiki 或注释中。

(3)接口 + 测试先行(TDD 思想)

规格一旦确定,由一人先写 JUnit 测试(不写实现),其他人在实现完成后运行测试。测试文件本身就是对规格的"可执行文档",比 JML 注释更直观,对边界的描述也更具体。

(4)统一名词术语表

建立团队内的"术语表",例如明确规定"链的长度"指节点数还是边数、"最优"的平局规则等,避免同一个词在不同人脑中指代不同含义。

(5)定期 JML Review

在版本迭代时,不仅 review 代码,也 review JML 注释是否随实现一同更新。实现改了但规格没改,或规格改了但实现没跟上,都是常见的信息差来源。

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

309

社区成员

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

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