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 注释是否随实现一同更新。实现改了但规格没改,或规格改了但实现没跟上,都是常见的信息差来源。

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

309

社区成员

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

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