OO第三单元总结博客

王雨森-24373080 2026-05-30 00:24:05

OO第三单元总结博客

这几次作业做下来,我最大的感觉是:JML 不是“写在接口里的长注释”,而是程序真正要满足的说明书。以前写代码时,我经常是先看指导书,再跑样例,样例过了就觉得差不多了。但这一单元不太一样,很多错误样例根本测不出来,只有逐条看 JML,才能知道方法到底该改哪些对象、不该改哪些对象、异常应该按什么顺序抛。

下面总结一下这个单元。

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

刚开始看 JML 的时候,我其实更关注 ensures,觉得它就是告诉我返回值应该是什么。后来发现这只是其中一部分。一个方法的完整规格至少要一起看:

  • requires:什么情况下能正常执行。
  • ensures:正常执行后结果和状态应该怎样。
  • signals:什么情况下抛什么异常。
  • assignable:这个方法允许改哪些东西。
  • pure:这个方法不能有可观察的副作用。

比如这次 hw11 的 recommendNthUp,如果只看名字,很容易理解成“推荐第 rank 个 up 主”。但 JML 里真正写的是:返回的 up 主不能是自己,不能是已经关注的人,并且所有候选里,比它分数高,或者分数相同但 id 更小的人数,必须正好是 rank - 1

也就是说,不能只凭感觉排序,而要按 JML 的定义写。测试时也是一样,不能只写:

assertEquals(某个id, recommendNthUp(...));

更好的办法是自己按 JML 再算一遍 betterCount,然后检查它是不是等于 rank - 1。这也是我后面改 JUnit 时才真正理解的一点。

assignablepure 给我的提醒也很大。以前我会觉得一个查询方法只要返回对了就可以,但是 JML 里如果写了 assignable \nothing,那就意味着这个方法不能偷偷改用户、视频、关注关系、金币、观看记录这些状态。比如推荐 up 主的方法,如果为了计算方便把某个用户临时加入列表、或者改变了视频集合,即使最后返回值对了,也是不符合规格的。

所以规格驱动开发和普通写程序最大的不同是:不能只看输出,还要看执行前后的整个状态是不是符合规格。

二、JUnit 测试的经验

这次 JUnit 测试主要围绕 recommendNthUp。我在这里花了很多时间,也反复出现“这个错误实现没测出来”“那个正确实现被误杀”的情况。

1. 不能只测返回值

一开始我写测试时,主要是构造几个用户和视频,然后断言推荐结果是不是某个 id。这样可以测出一部分排序错误,但覆盖不完整。

后来对照 JML 才发现,应该检查:

  • 返回的用户是否存在。
  • 返回的用户是否不是自己。
  • 返回的用户是否不是已经关注的人。
  • 比返回结果更优先的候选数量是否等于 rank - 1
  • rank 非法、没有视频、候选人数不足等异常是否正确。
  • 方法调用前后状态是否不变。

其中最关键的是 betterCount == rank - 1。这个断言比硬编码一个 expected id 更接近 JML,也更容易抓出一些边界错误,比如同分时没有按 id 小优先,或者把已关注用户也算进候选里。

2. 异常测试要看优先级

我之前漏过一个异常优先级的问题:当用户不存在,同时 rank 也是非法的时候,到底应该抛 UserIdNotFoundException 还是 InvalidRankException

看 JML 后可以发现,UserIdNotFoundException 的条件是:

!containsUser(userId)

它不要求 rank 合法。所以只要用户不存在,就应该先抛用户不存在异常。这个坑说明,测试异常时不能只单独测每一种异常,还要测条件重叠的情况。

3. pure 检查不能太窄,也不能乱加

这是我这次测试里最典型的一个反复。

第一次写 pure 检查时,我只检查了一些简单状态,比如用户数量、金币、几个视频的播放量。结果有一个错误实现没有被测出来,评测显示 Expected: Fail, Actual: Pass。这说明我的检查太窄了。

后来我把检查范围一下子扩大,加入了很多查询,比如全局最佳贡献者、最长递减链、推荐视频、各种和 recommendNthUp 不直接相关的方法。这样确实抓住了那个错误实现,但是又把正确实现测挂了,评测变成 Expected: Pass, Actual: Fail。这说明测试又太宽了,把不该检查的东西也放进去了。

最后比较合理的做法是:只检查 recommendNthUp 的 JML 里涉及的状态。

具体来说:

  • 用两份一样的网络,一份调用 recommendNthUp,一份不调用,之后比较它们。
  • 用题目提供的 strictEquals 检查用户对象完整状态。
  • VideoInterface 的 getter 检查视频状态。
  • 补充检查 videos 容器和 videos.length,因为 recommendNthUp 的分数计算里用到了 videos.length
  • 不检查和这个方法无关的全局查询,避免误伤正确实现。

这个过程让我明白:JUnit 不是写得越多越好,而是要贴着 JML 写。该测的不能漏,不该测的不要加。

三、三次作业的迭代过程

这三次作业是一步一步在原来的基础上加功能。每次新增内容都不只是“多写几个方法”,还会影响以前已经写好的方法。

第一次作业:先把用户网络搭起来

第一次主要是用户、关注关系和一些基础查询。这个阶段我最关心的是:

  • 用户 id 是否唯一。
  • 关注和粉丝关系是否同步维护。
  • 查询最短路径时能不能正确处理不可达。
  • 异常计数输出是否符合要求。

当时的容器设计还比较简单,主要是维护用户集合和关注关系。这里我开始意识到,用什么容器会影响后面很多实现。比如全局用户用 Map<Integer, User> 会比每次遍历数组找用户方便很多。

第二次作业 hw10:视频互动变多,状态开始联动

第二次作业,也就是 hw10,复杂度明显上来了。视频相关功能加入后,很多方法不再只是改一个对象。

比如投币 coinVideo,它至少会影响:

  • 视频的 coins。
  • 投币用户的 coins。
  • up 主的 coins。
  • up 主的 contributors。
  • up 主的 contributions。

点赞也有切换逻辑:没点过就是点赞,点过再调用就是取消点赞。评论清理还要删除包含关键词的评论,并返回清理数量和最大关键词出现次数。

这一阶段我比较典型的问题是:一开始容易只想着“这个方法输出什么”,但忘了它背后联动的容器。比如贡献者集合和贡献值数组必须同步维护,否则 queryBestContributor 后面就会错。

hw10 也让我意识到,旧方法很可能会成为新方法的基础。如果旧的观看、点赞、投币状态维护不干净,后面推荐和统计都会受影响。

第三次作业 hw11:推荐系统依赖旧状态

hw11 加入了智能推荐,新增了用户画像、分区兴趣、up 主影响力等内容。这个阶段不是简单添加推荐方法,而是要回头改旧方法。

最典型的变化有两个:

第一,User 新增了 typeCounts。这要求 watchVideo 在用户观看视频时,必须更新对应分区的观看次数。否则 getInterestqueryUserProfile 都会错。

第二,User 新增了自己发布的视频集合 videos。这要求 uploadVideo 不仅要把视频放进全局视频集合,还要放进上传者自己的视频集合。否则 getInfluencequeryMostInfluentialUp 就会错。

还有一个细节是 Video.getHeat 从原来的浮点计算改成了整数计算:

playCount * 2 + likes * 3 + forwardCount * 4 + coins * 5

如果只是复制 hw10 的代码,不对照 spec3 接口,就很容易漏掉这个变化。

四、如何发现已有方法和容器在迭代中的变化

这几次迭代后,我觉得比较靠谱的方法是:不要一上来就改代码,而是先对照接口。

我一般会看这些地方:

  1. 新版接口里新增了哪些 model field。
  2. 构造方法的 ensures 是否要求初始化新字段。
  3. 旧方法的 assignable 是否多了新字段。
  4. 旧方法的 ensures 是否多了新容器的维护要求。
  5. 返回值类型是否变化,比如 getHeat

比如 hw11 中,如果只看新增方法,很容易以为只要写 recommendVideorecommendNthUpqueryUserProfile 就行。但 JML 里其实告诉我们,watchVideouploadVideo 也要改。一个负责维护 typeCounts,一个负责维护用户发布的视频集合。

这也是规格驱动开发比较“严格”的地方:新增需求不一定只出现在新增方法里,也可能藏在旧方法的新 ensures 中。

五、如何发现性能瓶颈

性能瓶颈一般出现在两个地方:重复遍历和图搜索。

比如:

  • queryShortestPath 如果暴力枚举路径会很慢,用 BFS 更合适。
  • queryMutualFollowingSum 如果每次查询都双重循环统计,数据大时会比较慢,可以在关注/取关时维护计数。
  • recommendNthUp 如果每比较两个 up 都重新遍历所有东西,候选多时会变慢。
  • queryLongestDecSeq 如果暴力 DFS 所有路径,也容易爆炸,可以利用年龄递减关系做动态规划。

我发现性能问题的方式主要有两个:

第一,看 JML 里的量词和求和。如果规格里有多层 \forall\exists\sum,直接照着暴力翻译一般不太稳。

第二,看方法会不会被频繁调用。比如推荐排序时,比较器里可能反复调用 computeUpScore,如果这个函数本身很重,就要考虑缓存或提前计算。

不过这几次作业里,我更多还是先保证正确性。性能优化要建立在状态维护正确的基础上,否则优化只会把 bug 藏得更深。

六、程序中出现过的典型 bug

这里总结几个比较典型、对我影响比较大的BUG,很多都困扰了我很久,尤其是JUnit测试的问题,由于这个不是传统意义上的“对错”,而是能不能完全符合JML的要求,所以排查起来难度不小。

1. 新字段没有在旧方法里维护

hw11 的 typeCountsvideos 都不是只写 getter 就行。它们要在旧方法里被维护:

  • watchVideo 更新 typeCounts
  • uploadVideo 更新上传者的 videos

这个问题一开始不容易被普通样例发现,因为上传、观看本身还能输出 succeeded。但后面的画像和影响力会错。

2. JUnit pure 测试问题

最初测试 recommendNthUp 的 pure 性质时,我只检查了很少的状态。结果错误实现改了某些没被我检查的状态,测试仍然通过。

后来我才意识到,题目给 strictEquals 是有原因的。用户状态里有很多容器,手写检查很容易漏。用 strictEquals 可以更稳地检查用户对象有没有被修改。

为了修漏测,我一度把很多无关查询都放进状态快照里。结果正确实现被误判失败。这是另一个方向的错误。

根本原因是我没有严格按被测方法的 JML 来限定检查范围。测试应该服务于规格,而不是把整个系统都测一遍。

5. 推荐测试没有完全按 ensures 写

我一开始对 recommendNthUp 的测试偏向写死返回值。后来参考正确测试后才发现,更好的断言是检查:

betterCount == rank - 1

这个断言才真正对应 JML 的 ensures。它能测出一些写死 expected id 不一定覆盖到的问题,比如 rank 处理、同分排序、已关注过滤。

七、大模型在JML中的应用感受

本单元由于主要在训练阅读理解从JML写代码和测试的过程,而JML本身抽象不易读,所以我适当使用了大模型来帮助理解JML的含义,并且用大模型编写过JUnit测试。所用的模型为codex gpt-5.5 medium/high.总体感受是,大模型对大量JML的理解和遵守程度还是有限,让大模型解读单个JML含义完全没有问题,可以很好地辅助理解,但是其在编写JUnit的时候明显出现了不严格遵守JML的问题。在使用的时候,还是应当提示大模型写测试的要求,比如要逐条审查是否符合JML,应该以一个合理的顺序展开测试顺序等等。

八、研讨课“击鼓传花”的感悟

研讨课上,我本人由于对JML尚不很熟悉,加之得到的第一个问题比较难,所以短时间内写出来的JML漏洞百出,只可会意。但随后在其他问题的转化中,由于问题还是比较基础,所以转化的比较流畅。总体来说大家再碰到题目复杂一点的就容易出错,出现ensures等意思偏移,或者requires的缺失。同时,我也发现了不少同学在初始描述问题的时候写的很不清晰,开头只做了简单的问题说明,对于问题的边界、约束、出错抛出Exception等等都描述不清或者缺失,甚至出现问题本身的问题(隔壁组有个人的问题是“寻找链表上的环”,我实在无法理解)。
多人组队编程时,最让人头疼的往往不是技术难题,而是“我以为你要的是A,结果你写了个B”这种理解偏差。要解决这种信息差,核心思路其实就是把隐性的想法显性化,并且把沟通前置。
这里有几个我认为非常实用、能有效统一认知和减少信息差的措施与规则:

  • 把“想法”变成“白纸黑字”把隐性需求显性化,并且统一
  • 设计阶段统一架构和思路
  • 开发阶段妥善使用Git工具等,多沟通,多对进度
  • 把需求都落实在文档里

九、总结

这一单元让我对“按规格写程序”有了更具体的认识。以前我更习惯从样例和直觉出发,现在会先问自己:JML 到底要求改什么?不允许改什么?异常条件有没有重叠?容器长度和对象状态有没有变化?

JUnit 测试也不是简单堆样例。真正有效的测试应该像把 JML 翻译成 Java 断言:

  • 正常情况检查 ensures。
  • 异常情况检查 signals。
  • pure 方法检查状态不变。
  • assignable 检查不该改的对象没有被改。

这几次迭代中,我最大的收获是“不要靠感觉补测试”。测试过窄会漏掉错误实现,测试过宽会误伤正确实现。最合适的测试应该严格贴着 JML,做到该测的都测,不相关的坚决不测。

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

307

社区成员

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

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