OO Unit3 博客

彭程-24373403 2026-05-28 09:06:26

OO Unit3 博客

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

JML是一种形式化的规格描述语言,它用数学化的方式精确描述一段代码"应该做什么",而不关心"怎么做"。

规格驱动开发的核心思想是:先写规格,再写实现。规格就像是代码的"合同"——它规定了方法的前置条件(requires)、后置条件(ensures)、副作用范围(assignable)以及异常行为(exceptional_behavior)。实现者只需要保证自己的代码满足这份合同,而不需要猜测需求。

在本单元的三次作业中,课程组给出了完整的JML规格,我们按照规格来实现一个视频社交网络。这个过程让我体会到JML的几个优点:

  1. 精确性:自然语言描述需求很容易有歧义,但JML用数学量词(如\forall\exists\num_of)把需求的边界写得很清楚。比如queryAgeRatio中四个年龄段的划分方式,JML直接把区间写死了,没有任何模糊空间。

  2. 可验证性:规格本身可以作为测试的参照。如果一个方法的行为和JML描述的不一样,那一定是代码写错了。本单元我们写JUnit测试时,很多测试用例就是直接从JML的后置条件翻译过来的。

  3. 解耦合:JML只描述行为,不限制实现。以收到的视频列表(receivedVideos)为例,JML用int[]的模型字段来描述它,但实际实现时,我用的是双向链表+HashMap的混合结构——只要对外表现和JML一致,怎么实现都可以。

但JML也有不足。复杂的\forall\exists嵌套读起来很费劲,尤其是queryShortestPathqueryLongestDecSeq的规格,读半天才能理解在说什么。另外JML本身不能完全覆盖非功能性需求,比如时间复杂度——JML不会告诉你某个操作必须在O(1)完成,但这恰恰是实际程序能否通过测试的关键。

总的来说,规格驱动开发让"需求"变得可执行、可验证。比起传统的"读文档->揣摩意图->写代码"流程,规格驱动的优势在于需求是精确的,实现者不需要猜测。


二、JUnit测试的经验

JUnit单元测试主要集中在几个关键方法上,比如queryMutualFollowingSum的正确性和"纯性"(多次调用结果不变且不修改状态),以及recommendNthUp的排序逻辑、排除自身和已关注用户、异常抛出等。写这些测试时,基本思路是:构造一个特定的场景 -> 调用方法 -> 断言结果和JML的后置条件一致。

几点经验:

  1. 从JML直接翻译测试用例。JML的ensures子句基本就是一个测试断言的模板。比如规范里说\result.size() == min(receivedVideos.length, 5),那测试就直接验证返回列表的大小不超过5。

  2. 测试"纯性"很重要。很多查询方法在JML里标注了assignable \nothing,意味着不能修改任何状态。我专门写了测试——在调用前后分别对整个系统状态拍快照,然后逐个字段比较,确保没有副作用。这类测试帮我发现了几个意外修改了状态的bug。

  3. 关注异常路径。JML中exceptional_behavior部分定义的异常抛出顺序也很重要。比如addUser要求先检查EqualUserIdException再检查InvalidAgeException,如果顺序反了,即使在功能上看起来没区别,测试也会挂掉。


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

3.1 迭代内容概述

三次作业的功能迭代大致是:

  • hw9(基础) :用户系统、关注关系、视频上传/观看、BFS最短路径、年龄比例查询
  • hw10(扩展) :转发视频、点赞(toggle式)、投币、评论及垃圾评论清理、勋章、最佳贡献者、最长递减年龄序列
  • hw11(推荐) :视频推荐(基于兴趣x影响力)、Up主推荐排名、最受欢迎视频、最有影响力Up主、全局最佳贡献者

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

迭代中最容易出问题的地方是:新需求改变了旧方法隐含的"前提条件"。

最典型的例子是hw10新增了forwardVideo(转发)功能。在hw9中,一个视频只会出现在receivedVideos中一次——因为视频只能由上传者推送给粉丝。但hw10的转发机制意味着:同一个视频可以被不同的人多次转发给同一个用户,导致receivedVideos中出现重复视频。

这个变化不是JML直接告诉你的——JML只在forwardVideo的规格里写了它会往follower的receivedVideos里加视频,但是没有明确说"需要注意重复"。如果你没有仔细推演这个操作对之前方法的影响,就很容易踩坑。

我觉得每次新增一个会修改共享状态的方法时,要追溯所有读取或依赖那个状态的方法,检查在新场景下是否还正确。具体来说,就是在forwardVideo加了之后,重新审视watchVideo(看视频时删除receivedVideos中的记录)——如果列表里有重复,删除一个而不删另一个,残留的视频就会一直留在列表里。

3.3 如何发现程序的性能瓶颈

首先,JML里没有性能要求,所以光看规格发现不了性能问题。但课程组的测试数据规模很大,性能瓶颈最终会以"超时"的形式暴露出来。

我的做法是:

  1. 分析每个方法的时间复杂度。对于高频调用的方法(如followUserwatchVideo),如果实现是O(n),当用户量上到几千甚至上万时就会出问题。

  2. 用增量维护替代全量计算。hw9初次实现时,queryUpFollowersAgeRatio每次都要遍历所有followers来统计四个年龄区间的数量,这是O(n)的。后来改成在addFollowerremoveFollower时增量更新ageBuckets[4]数组,查询直接读数组值,变成了O(1)。同样,mutualFollowingSum也是增量维护,避免每次查询都O(n²)遍历。

  3. 选择合适的数据结构。hw9最初大量使用ArrayList存储receivedVideos,添加是O(1)但删除是O(n)。后来改成了双向链表+HashMap索引的结构——链表保证顺序迭代(取前5个),HashMap保证O(1)查找和删除。这个改动对性能影响很大。

  4. 使用缓存/记忆化queryLongestDecSeq用DFS+备忘录,避免对同一节点重复搜索。BFS最短路径在找到目标后立即返回,不遍历整张图。


四、程序出现的Bug及原因分析

Bug 1:hw9——ArrayList导致超时

现象:在大量watchVideo操作时程序超时。

原因receivedVideos最初用ArrayList实现。添加视频是O(1)(加到末尾),但看视频时要删除指定视频,ArrayList.remove(Object)需要先遍历找到元素位置,再移动后续所有元素,整体O(n)。当粉丝数量多、视频数量大时,频繁的watchVideo操作导致总时间复杂度到了O(n²)级别。

修复:改为双向链表+HashMap索引的混合结构。HashMap<Integer, VideoNode>保存每个视频ID对应的链表节点引用,删除时O(1)定位、O(1)摘链。查询前5个视频时直接从链表头向后遍历最多5步。

教训:选择容器时不能只看写代码是否方便,要考虑实际的使用模式。JML用int[]描述receivedVideos,但这只是行为模型,不代表实现上也用数组——理解规格和实现之间的"自由度"很重要。

Bug 2:hw10——转发导致的重复视频和删除不干净

现象watchVideo之后,用户的未看视频列表中仍然残留已经看过的视频。

原因:这个bug的链条比较长:

  1. hw10新增了 forwardVideo,用户A可以把同一个视频多次转发给用户B(或者不同人分别转发给B),导致B的 receivedVideos 中出现重复的视频ID。
  2. 但原来的 removeReceivedVideo 方法通过 HashMap 定位到一个节点后只删除那一个,列表里其余重复节点就残留下来了。
  3. 同时,当视频被重复添加时,receivedIndexHashMap 会被新的节点覆盖(同一个key),导致旧的节点无法通过 HashMap 直接访问,变成"孤儿节点"。

修复:将 removeReceivedVideo 改为遍历整条链表,删除所有匹配的节点,而不只是 HashMap 指向的那个。

教训:新增功能时,不能只验证新功能本身是否正常,还要推演新功能对已有状态的影响。转发机制打破了"每个视频在receivedVideos中只出现一次"这个在hw9中隐含成立的前提,而这个前提之前写 removeReceivedVideo 时可能根本没有意识到。


五、大模型在Unit3的使用

在本单元中我使用了Code Agent辅助编程。主要体会:

优势:JML规格是形式化的,大模型能比较准确地理解JML的语义并翻译成Java代码,减少了手动翻译规格的时间。在写工具类方法(如cleanSpamComments这种纯字符串处理逻辑)时,直接给模型JML就能生成基本可用的代码。

潜在问题:大模型在根据JML编程时确实会忽视效率问题。模型倾向于选择最直接的实现方式——比如用ArrayList而不会主动考虑双向链表优化。在架构和容器选择上,模型也不会主动做"增量维护"这种优化决策。所以用大模型写JML实现时,需要人工检查性能。

用于单元测试:我尝试过让模型根据JML的ensures子句生成JUnit测试用例,效果还可以。模型能准确地把\result == ...翻译成assertEquals,把\forall翻译成循环断言。但对于需要构造复杂前置场景的测试,还是需要人工参与设计测试数据。


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

在第二次研讨课上,我们组玩了JML版的"击鼓传花"游戏:一共五个人,第一个人用自然语言描述一个方法的功能,第二个人只看这段自然语言写出JML规格,第三个人只看JML写出新的自然语言描述……以此类推,每人只能看上一人的输出。最后比较最初的描述和最终描述之间的语义差异。

我们在游戏中观察到一些有意思的现象:

语义漂移有时大有时小。如果初始描述的是一个边界清晰的方法,经过五轮传递后语义基本没变。但如果是复杂逻辑,每一轮传递都会丢失或扭曲一些细节,到最后虽然大方向还在,但关键的约束条件就丢了。

JML环节是信息损失最大的环节。自然语言->JML这一步,写JML的人如果对规格语言不熟练,可能会遗漏一些自然语言中隐含的边界条件。反过来JML->自然语言这一步也一样,读JML的人可能没有完全理解\forall\exists嵌套表达的含义。

没有人"故意"犯错,但传递还是会失真。这说明问题不在于个人能力,而在于信息载体本身的表达能力有限。自然语言有歧义,JML有理解门槛,每一层转换都有信息熵的损失。

结合本单元的三次作业迭代,我对"多人协作时如何统一理解"有了以下思考:

  1. 形式化规格确实有用,但不能只有规格。JML精确但不够直观,需要配合有代表性的示例和边界案例一起传达。就像课程组给我们的JML之外还有指导书——两者结合才能准确理解需求。

  2. 关键的不变量和边界条件要显式标注。比如"receivedVideos中是否允许重复"这个问题,如果在协作之初就明确写出来,后续很多bug都可以避免。在团队协作中,一个共享的"约束文档"(记录所有跨方法的隐含前提)可能比JML本身更实用。

  3. 交叉验证。一个人写实现,另一个人根据JML写测试,两个人在不沟通的情况下各自理解规格,最后看测试是否通过。如果测试挂了很多,说明规格本身有歧义或两个人理解不一致——这和击鼓传花的原理一样。

  4. 减少传递层级。游戏展示了多层级传递的放大效应。在真实协作中,应该尽量让所有人直接面对原始需求文档(JML+指导书),而不是通过某个成员转述。

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

305

社区成员

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

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