309
社区成员
发帖
与我相关
我的任务
分享这几次作业做下来,我最大的感觉是: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 时才真正理解的一点。
assignable 和 pure 给我的提醒也很大。以前我会觉得一个查询方法只要返回对了就可以,但是 JML 里如果写了 assignable \nothing,那就意味着这个方法不能偷偷改用户、视频、关注关系、金币、观看记录这些状态。比如推荐 up 主的方法,如果为了计算方便把某个用户临时加入列表、或者改变了视频集合,即使最后返回值对了,也是不符合规格的。
所以规格驱动开发和普通写程序最大的不同是:不能只看输出,还要看执行前后的整个状态是不是符合规格。
这次 JUnit 测试主要围绕 recommendNthUp。我在这里花了很多时间,也反复出现“这个错误实现没测出来”“那个正确实现被误杀”的情况。
一开始我写测试时,主要是构造几个用户和视频,然后断言推荐结果是不是某个 id。这样可以测出一部分排序错误,但覆盖不完整。
后来对照 JML 才发现,应该检查:
rank - 1。其中最关键的是 betterCount == rank - 1。这个断言比硬编码一个 expected id 更接近 JML,也更容易抓出一些边界错误,比如同分时没有按 id 小优先,或者把已关注用户也算进候选里。
我之前漏过一个异常优先级的问题:当用户不存在,同时 rank 也是非法的时候,到底应该抛 UserIdNotFoundException 还是 InvalidRankException?
看 JML 后可以发现,UserIdNotFoundException 的条件是:
!containsUser(userId)
它不要求 rank 合法。所以只要用户不存在,就应该先抛用户不存在异常。这个坑说明,测试异常时不能只单独测每一种异常,还要测条件重叠的情况。
这是我这次测试里最典型的一个反复。
第一次写 pure 检查时,我只检查了一些简单状态,比如用户数量、金币、几个视频的播放量。结果有一个错误实现没有被测出来,评测显示 Expected: Fail, Actual: Pass。这说明我的检查太窄了。
后来我把检查范围一下子扩大,加入了很多查询,比如全局最佳贡献者、最长递减链、推荐视频、各种和 recommendNthUp 不直接相关的方法。这样确实抓住了那个错误实现,但是又把正确实现测挂了,评测变成 Expected: Pass, Actual: Fail。这说明测试又太宽了,把不该检查的东西也放进去了。
最后比较合理的做法是:只检查 recommendNthUp 的 JML 里涉及的状态。
具体来说:
recommendNthUp,一份不调用,之后比较它们。strictEquals 检查用户对象完整状态。VideoInterface 的 getter 检查视频状态。videos 容器和 videos.length,因为 recommendNthUp 的分数计算里用到了 videos.length。这个过程让我明白:JUnit 不是写得越多越好,而是要贴着 JML 写。该测的不能漏,不该测的不要加。
这三次作业是一步一步在原来的基础上加功能。每次新增内容都不只是“多写几个方法”,还会影响以前已经写好的方法。
第一次主要是用户、关注关系和一些基础查询。这个阶段我最关心的是:
当时的容器设计还比较简单,主要是维护用户集合和关注关系。这里我开始意识到,用什么容器会影响后面很多实现。比如全局用户用 Map<Integer, User> 会比每次遍历数组找用户方便很多。
第二次作业,也就是 hw10,复杂度明显上来了。视频相关功能加入后,很多方法不再只是改一个对象。
比如投币 coinVideo,它至少会影响:
点赞也有切换逻辑:没点过就是点赞,点过再调用就是取消点赞。评论清理还要删除包含关键词的评论,并返回清理数量和最大关键词出现次数。
这一阶段我比较典型的问题是:一开始容易只想着“这个方法输出什么”,但忘了它背后联动的容器。比如贡献者集合和贡献值数组必须同步维护,否则 queryBestContributor 后面就会错。
hw10 也让我意识到,旧方法很可能会成为新方法的基础。如果旧的观看、点赞、投币状态维护不干净,后面推荐和统计都会受影响。
hw11 加入了智能推荐,新增了用户画像、分区兴趣、up 主影响力等内容。这个阶段不是简单添加推荐方法,而是要回头改旧方法。
最典型的变化有两个:
第一,User 新增了 typeCounts。这要求 watchVideo 在用户观看视频时,必须更新对应分区的观看次数。否则 getInterest 和 queryUserProfile 都会错。
第二,User 新增了自己发布的视频集合 videos。这要求 uploadVideo 不仅要把视频放进全局视频集合,还要放进上传者自己的视频集合。否则 getInfluence 和 queryMostInfluentialUp 就会错。
还有一个细节是 Video.getHeat 从原来的浮点计算改成了整数计算:
playCount * 2 + likes * 3 + forwardCount * 4 + coins * 5
如果只是复制 hw10 的代码,不对照 spec3 接口,就很容易漏掉这个变化。
这几次迭代后,我觉得比较靠谱的方法是:不要一上来就改代码,而是先对照接口。
我一般会看这些地方:
ensures 是否要求初始化新字段。assignable 是否多了新字段。ensures 是否多了新容器的维护要求。getHeat。比如 hw11 中,如果只看新增方法,很容易以为只要写 recommendVideo、recommendNthUp、queryUserProfile 就行。但 JML 里其实告诉我们,watchVideo 和 uploadVideo 也要改。一个负责维护 typeCounts,一个负责维护用户发布的视频集合。
这也是规格驱动开发比较“严格”的地方:新增需求不一定只出现在新增方法里,也可能藏在旧方法的新 ensures 中。
性能瓶颈一般出现在两个地方:重复遍历和图搜索。
比如:
queryShortestPath 如果暴力枚举路径会很慢,用 BFS 更合适。queryMutualFollowingSum 如果每次查询都双重循环统计,数据大时会比较慢,可以在关注/取关时维护计数。recommendNthUp 如果每比较两个 up 都重新遍历所有东西,候选多时会变慢。queryLongestDecSeq 如果暴力 DFS 所有路径,也容易爆炸,可以利用年龄递减关系做动态规划。我发现性能问题的方式主要有两个:
第一,看 JML 里的量词和求和。如果规格里有多层 \forall、\exists、\sum,直接照着暴力翻译一般不太稳。
第二,看方法会不会被频繁调用。比如推荐排序时,比较器里可能反复调用 computeUpScore,如果这个函数本身很重,就要考虑缓存或提前计算。
不过这几次作业里,我更多还是先保证正确性。性能优化要建立在状态维护正确的基础上,否则优化只会把 bug 藏得更深。
这里总结几个比较典型、对我影响比较大的BUG,很多都困扰了我很久,尤其是JUnit测试的问题,由于这个不是传统意义上的“对错”,而是能不能完全符合JML的要求,所以排查起来难度不小。
hw11 的 typeCounts 和 videos 都不是只写 getter 就行。它们要在旧方法里被维护:
watchVideo 更新 typeCounts。uploadVideo 更新上传者的 videos。这个问题一开始不容易被普通样例发现,因为上传、观看本身还能输出 succeeded。但后面的画像和影响力会错。
最初测试 recommendNthUp 的 pure 性质时,我只检查了很少的状态。结果错误实现改了某些没被我检查的状态,测试仍然通过。
后来我才意识到,题目给 strictEquals 是有原因的。用户状态里有很多容器,手写检查很容易漏。用 strictEquals 可以更稳地检查用户对象有没有被修改。
为了修漏测,我一度把很多无关查询都放进状态快照里。结果正确实现被误判失败。这是另一个方向的错误。
根本原因是我没有严格按被测方法的 JML 来限定检查范围。测试应该服务于规格,而不是把整个系统都测一遍。
我一开始对 recommendNthUp 的测试偏向写死返回值。后来参考正确测试后才发现,更好的断言是检查:
betterCount == rank - 1
这个断言才真正对应 JML 的 ensures。它能测出一些写死 expected id 不一定覆盖到的问题,比如 rank 处理、同分排序、已关注过滤。
本单元由于主要在训练阅读理解从JML写代码和测试的过程,而JML本身抽象不易读,所以我适当使用了大模型来帮助理解JML的含义,并且用大模型编写过JUnit测试。所用的模型为codex gpt-5.5 medium/high.总体感受是,大模型对大量JML的理解和遵守程度还是有限,让大模型解读单个JML含义完全没有问题,可以很好地辅助理解,但是其在编写JUnit的时候明显出现了不严格遵守JML的问题。在使用的时候,还是应当提示大模型写测试的要求,比如要逐条审查是否符合JML,应该以一个合理的顺序展开测试顺序等等。
研讨课上,我本人由于对JML尚不很熟悉,加之得到的第一个问题比较难,所以短时间内写出来的JML漏洞百出,只可会意。但随后在其他问题的转化中,由于问题还是比较基础,所以转化的比较流畅。总体来说大家再碰到题目复杂一点的就容易出错,出现ensures等意思偏移,或者requires的缺失。同时,我也发现了不少同学在初始描述问题的时候写的很不清晰,开头只做了简单的问题说明,对于问题的边界、约束、出错抛出Exception等等都描述不清或者缺失,甚至出现问题本身的问题(隔壁组有个人的问题是“寻找链表上的环”,我实在无法理解)。
多人组队编程时,最让人头疼的往往不是技术难题,而是“我以为你要的是A,结果你写了个B”这种理解偏差。要解决这种信息差,核心思路其实就是把隐性的想法显性化,并且把沟通前置。
这里有几个我认为非常实用、能有效统一认知和减少信息差的措施与规则:
这一单元让我对“按规格写程序”有了更具体的认识。以前我更习惯从样例和直觉出发,现在会先问自己:JML 到底要求改什么?不允许改什么?异常条件有没有重叠?容器长度和对象状态有没有变化?
JUnit 测试也不是简单堆样例。真正有效的测试应该像把 JML 翻译成 Java 断言:
这几次迭代中,我最大的收获是“不要靠感觉补测试”。测试过窄会漏掉错误实现,测试过宽会误伤正确实现。最合适的测试应该严格贴着 JML,做到该测的都测,不相关的坚决不测。