Unit3 博客:JML规格驱动开发与社交网络系统迭代

龚之瀚-24371143 2026-05-31 13:21:28

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

JML的核心价值

JML是一种基于契约式设计(Design by Contract)的形式化规格语言。在本单元的三次作业中,JML规格以接口注释的形式给出了每个方法的前置条件(requires)、后置条件(ensures)、副作用范围(assignable)以及异常行为(signals)。
通过本单元的实践,我对JML的理解可以归纳为以下几点:
1. JML是"做什么"而非"怎么做"的精确描述。 JML规格使用一阶谓词逻辑和集合论的语言来描述方法的行为,它告诉我们方法调用后系统状态应该满足什么条件,而不约束具体的数据结构和算法实现。例如queryShortestPath的JML用\exists\forall量词定义了最短路径的数学性质,但并没有要求使用BFS还是DFS。这给予了实现者充分的自由度。
2. JML的assignable子句是理解方法副作用的关键。 每个修改方法都明确声明了它可以修改哪些状态,这使得我们可以精确判断一个方法是否是"pure"的(assignable \nothing),从而在测试中验证调用前后无关状态是否保持不变。
3. JML的异常规格是防御性编程的契约。 signals子句明确了方法在什么条件下抛出什么异常,而且通过assignable \nothing隐含了"异常路径下不修改任何状态"的约束——这对测试来说是一个非常重要的不变量。
4. 规格驱动开发的优势在于"实现与验证分离"。 有了JML规格之后,我们可以先读JML理解需求,再独立编写实现;同时也可以基于JML编写与实现无关的测试用例。这种分离使得测试可以真正验证"行为是否符合契约",而不是"实现是否与自己认为的正确行为一致"。

对规格驱动开发的反思

规格驱动开发的一个陷阱是:JML本身可能存在歧义或不完整。在本次作业中,JML规格是由课程组提供的,我们需要做的是精确翻译JML到Java代码。但实践中,JML中\forall\exists嵌套的逻辑有时非常密集(如cleanSpamComments中关于result[1]max定义),直接按照JML的字面意思去翻译可能导致O(n²)或更差的复杂度。这时候就需要在忠实于规格和合理优化之间找平衡——数学上等价但计算上更高效的实现是允许的。

二、JUnit测试的经验总结

测试策略:契约测试 vs 实现测试

我在本单元的测试中采用了基于JML契约的黑盒测试策略。每个测试用例的结构如下:

  1. 构造测试数据:通过@Parameters参数化方法生成多样化的网络状态(空网络、稀疏网络、稠密网络、互关网络、链式网络),覆盖各种图拓扑结构。
  2. 捕捉前置状态:通过deepCopyNetwork深拷贝整个网络,留存调用前的完整快照。
  3. 执行被测方法:分别测试正常路径和异常路径。
  4. 验证后置条件和副作用
    • 对正常路径,验证JML的ensures子句逐条成立。
    • 对异常路径,验证抛出的异常类型正确,且assignable \nothing被遵守。
    • 通过assertNetworkEquals(before, network)验证无关状态未被修改。

      参数化测试与随机测试

      NetworkTest1中,我使用了参数化随机测试,每轮测试随机选择网络拓扑、随机选择参数(id1/id2以概率选取存在/不存在的用户),并通过精心设计的边界年龄池({-1, 0, 1, 16, 17, 30, 31, 45, 46, 109, 110, 111})确保所有等价类和边界条件都被覆盖。测试规模达到1e4组数据,这在手工构造测试数据的情况下是不可能做到的。

      关键经验

1. pure方法测试的核心是状态不变量。 对于assignable \nothing的方法,必须深拷贝前置状态,然后逐字段比对调用后状态与前置状态是否一致。我使用strictEquals方法(在hw11中修复了hashmap遍历bug后)来精确比较两个User对象的完整内部状态。
2. 异常测试需要同时验证"抛出了正确的异常"和"状态未被修改"。 JML的exceptional_behavior虽然没有显式写assignable \nothing,但实际上异常路径下程序不应该有任何副作用——这一点需要通过深拷贝+状态比对来验证。
3. BFS oracle模式验证图算法。 对于queryShortestPath,我编写了一个独立的BFS实现作为"oracle",用它来计算正确的答案,然后与被测方法的返回值对比。这种"双实现交叉验证"的方法比手工计算期望值更可靠。

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

功能迭代总览

维度hw9 (spec1)hw10 (spec2)hw11 (spec3)
核心模型User, Video, Network+ coins, comments, type, likes+ influence, interest
用户行为关注/取关, 观看视频+ 投币, 点赞, 转发, 评论, 购买勋章+ 推荐UP主, 推荐视频
查询能力互关数, 最短路径, 粉丝年龄比+ 最热视频, 最佳贡献者, 最长递减序列+ 全局最佳贡献者, 影响力最大UP主, 用户画像
Video属性id, uploaderId+ type, playCount, likes, forwardCount, coins, comments保持不变
User属性following, followers, receivedVideos+ coins, watchedVideos, likedVideos, medals, contributors+ videos[], typeCounts[], typeInfluence[]

容器/方法在迭代中的变化

变化一:uploadVideo中通知粉丝的方式

  • hw9:遍历users.values()全部用户,对每个用户检查是否为上传者的粉丝 → O(n)
  • hw10:直接遍历uploader.getFollowers().values() → **O(f)**(f为粉丝数)
  • 发现方式:阅读迭代后的代码时注意到hw9的uploadVideo迭代了所有用户调用containsFollower,而在hw10中改为直接获取followers集合遍历。这个变化源于hw10增加了更多功能,如果不优化,O(n)操作会在大数据量下累积。

变化二:粉丝年龄比例计算

  • hw9:queryAgeRatio()每次调用都重新遍历followers计数 → O(f)
  • hw10/hw11:维护followerAgeCounts[]缓存数组,在addFollowerremoveFollower时增量更新 → **O(1)**查询
  • 发现方式:对比hw9的queryAgeRatio实现(遍历followers并逐个判断年龄段)和hw10的实现(直接使用预缓存的数组除以总数),这是典型的"时间换空间"优化——在修改操作中维护不变量,换取查询O(1)。

变化三:queryMostPopularVideo

  • hw10:每次查询时遍历所有videos,按type过滤后找出heat最高者 → O(v)
  • hw11:维护mostPopularVideos[7]数组,在每次热度变化时增量更新缓存 → **O(1)**查询(但有退化到O(v)重建的风险)
  • 发现方式:这是hw11最关键的架构变化。新增了updateMostPopularVideo方法,在watchVideolikeVideocoinVideoforwardVideo中每次热度变化后调用。当热度下降且当前最佳恰是被修改的视频时,会触发全量重扫。

变化四:queryShortestPath的实现保持不变
BFS实现在三次迭代中几乎完全一致——因为最短路径的JML规格没有变化。这恰好说明规格驱动开发的一个优势:不变的需求不需要修改已有的正确实现。

发现性能瓶颈的方法

  1. 代码审查法:直接阅读代码,关注循环嵌套。例如hw9的uploadVideo中对所有用户的遍历虽然在该规模下不会超时,但O(n)的额外开销是不必要的。
  2. JML语义推理queryMostPopularVideo在hw10中被声明为assignable \nothing,意味着每次调用都是无状态的,必须实时计算。如果播放量、点赞等操作频繁,这里的全量扫描就会成为瓶颈。hw11引入缓存正是针对这一点。
  3. 复杂度累加分析:随着hw10→hw11增加了recommendNthUp这样的方法,其O(u log u)的排序复杂度在用户数大的场景下很可观。但该方法已经是尽可能高效的实现——候选过滤是O(u),排序是O(u log u),没有明显瓶颈。

四、程序Bug分析与反思

Bug 1:strictEquals和检查类似pure规格限制下网络有没有被篡改的测试

bug原因:很多接口是对引用进行浅拷贝,于是储存的旧状态和新状态是对同一块数据进行引用,如果对实际内容进行篡改,两者的内容也会被同步修改,于是状态相等的比较必然为true。
修复:我们需要进行深克隆,面对无法深克隆的情况,在构造数据的时候构造两个一模一样的网络,对其中一个调用函数,之后检查两个网路的状态是否一直

Bug 2:第一次作业没有考虑时间复杂度,直接对jml文件进行了翻译,于是造成超时情况

位置:hw9几乎所有的位置
Bug描述:jml是行为规格描述,但是没有限定具体实现。在程序实现时,要尽量考虑程序的时间,空间复杂度,尽可能在容器,算法,缓存上进行优化,可以提升程序效率。
bug修复:通过容器的优化使查找从O(n)到O(1),通过缓存极值和一些状态使一些函数从O(n方甚至n的三次方)降到O(1)

Bug 3:hw10,jml理解偏差导致的错误

位置UserremoveReceivedVideo函数
Bug描述:观看视频时,已接受视频列表应该不存在此视频id,但是我只移出了匹配到的第一个视频,如果重复,贼删除遗漏
bug修复:将remove改为removeIf

Bug 4:hw11,对于keyword等于空串的情况处理不当

位置network.javacountKeyword函数
Bug描述:之前几次作业是正确的,问题出在让ai大模型简化我的代码上,为了缩减行数丢给ai缩减,结果ai偷偷改了我的代码逻辑,于是出现了这个bug
bug修复:稍微改改就过了,血泪的教训呀,ai改了一定要人工审核一下!

关于迭代中bug预防的思考

三次迭代中最容易出错的环节是在已有代码的基础上新增功能。例如hw10新增了likeVideo的toggle逻辑——点赞和取消点赞是同一个方法,但它们的ensures截然不同。实现时如果对"已点赞→取消点赞"路径的处理有遗漏,就可能导致likedVideos和video.likes的状态不一致。

五、大模型在规格驱动开发中的应用

大模型的优势

在规格驱动开发中,大模型具有以下显著优势:
1. JML到代码的翻译效率极高。 JML是一种形式化语言,其逻辑表达式(如\forall, \exists, \num_of等量词)有固定的翻译模式。大模型可以快速将JML翻译为等价的Java代码,特别是对于requires(前置条件检查)和signals(异常抛出条件)的翻译,几乎可以做到无差错。
2. 异常路径的完整性检查。 JML方法通常有多个exceptional_behavior子句,人类实现者容易遗漏某些异常条件。大模型可以系统性地遍历每个异常行为,确保没有遗漏。
3. 批量生成测试框架代码。 基于JML的ensuresassignable,大模型可以自动生成参数化的JUnit测试模板——包括深拷贝、异常断言、状态不变性检查等。

大模型的局限性

1. 可能忽视效率问题。 大模型在翻译JML时倾向于"逐字直译",例如将queryMostPopularVideo的JML直接翻译为"遍历所有视频→过滤type→找max heat→tie-break by id",而不会主动引入缓存优化。在hw10→hw11的迭代中,这个O(n)查询需要通过人工分析调用频率后才引入mostPopularVideos[]缓存,大模型不会自发发现这个瓶颈。
2. 可能忽视容器选择。 JML中使用数组model(UserInterface[]),大模型可能直接使用ArrayList实现。但实际场景中,通过id查找用户是高频操作,HashMap<Integer, UserInterface>是更合理的选择。大模型对容器的时间复杂度权衡不够敏感,需要人工审查。
3. 迭代中的架构一致性容易丢失。 当hw9→hw10新增了coins、contributors、watchedVideos等多个字段时,大模型可能会在每个方法中独立地添加逻辑,而忽视全局的缓存策略(如followerAgeCounts的维护需要在addFollower/removeFollower中更新)。这种"局部正确但全局不一致"的问题在手工审查代码时很容易被忽略。

如何利用大模型进行单元测试

一个有效的流程是:

  1. 将JML规格提供给大模型,要求它生成测试用例的骨架——包括所有正常路径和异常路径的测试方法签名、assertThrows调用和基本的assertEquals断言。
  2. 人工补充测试数据构造逻辑——这是最需要领域知识的部分。大模型可以生成"空网络"和"简单网络",但无法生成覆盖所有边界条件的综合测试网络(如链式网络、互关网络、稀疏/稠密网络)。
  3. 让大模型翻译ensures子句为断言代码——特别是\forall\exists量词到Java循环的转换,减少手工编写断言的工作量。
  4. 人工审查断言的正确性——大模型可能在翻译复杂JML时出错(如queryShortestPath中关于最短路径的嵌套量词),需要逐条验证。

六、JML"击鼓传花"研讨课感悟

是否发现了JML的Bug?

在研讨课上传递JML规格的过程中,我发现了一个典型的规格遗漏问题:
某组同学的JML描述uploadVideo时,写明了视频上传后要通知上传者的所有粉丝(ensures粉丝的receivedVideos增加),但遗漏了"只有粉丝才会收到通知"这一关键约束——即没有写assignable只涉及粉丝的receivedVideos,也没有写非粉丝的receivedVideos保持不变。如果只按这个JML实现,实现者可能把通知发送给了所有人。
另一个常见的JML bug是异常优先级描述不清晰。例如在hw11的recommendNthUp中,异常优先级是:UserIdNotFoundException > InvalidRankException > NoVideoUploadedException > ColdStartUserException。但在研讨课的JML中,有的同学没有明确这些异常之间的优先级关系,导致实现者可能在rank <= 0videos.length == 0的情况下抛出了NoVideoUploadedException而不是InvalidRankException

需求与边界的漂移

在传递过程中,确实观察到了需求的"漂移"现象:

  • 边界条件的模糊化:最初明确的"age在0到110之间"在传递几轮后变成了"age合法"这样的模糊描述,丢失了具体的数值边界。
  • 异常类型的膨胀:每个经手人倾向于加上自己认为需要的异常类型,但不删除不再适用的,导致最终JML中的异常行为比原始需求多了好几个。
  • assignable的退化:从最初精确列出哪些字段被修改,逐渐退化为assignable \everything或干脆省略assignable子句。

    如何统一团队对需求和实现的理解?

    基于本次研讨课的体验,我认为以下措施可以有效减少团队协作中的信息差:

1. 以JML作为"单一真相来源"(Single Source of Truth)。 不要口头描述需求,不要用自然语言写需求文档——把JML规格写进接口文件,所有组员以接口中的JML为最终依据。JML的形式化特性迫使写规格的人精确思考边界条件。
2. 建立"JML Review"机制。 在开始编码之前,全组一起逐条审查JML规格。检查点包括:

  • 每个方法的requires是否覆盖了所有正常输入且排除了所有异常输入?
  • 每个exceptional_behavior的异常条件是否互斥(不存在同时满足两个异常条件的情况)?
  • assignable是否精确(是否有遗漏的副作用或多余的副作用)?
  • ensures是否足够强(能否唯一确定正确的输出)?

3. 使用检查清单(Checklist)规范化JML编写。 例如:

  • 对于修改方法,是否声明了assignable
  • 异常条件下的assignable \nothing是否隐含在JML中(或在代码中被测试验证)?
  • 多个normal_behavior之间的requires是否互斥?
  • 边界值(空集合、等于、极端值)是否有对应的behavior?

4. 写"实现者视角"的测试。 在JML定稿后,每位组员基于JML独立编写测试用例(只依赖接口,不依赖实现),然后交叉运行。如果两人基于同一份JML写出了预期结果不一致的测试,说明JML存在歧义。
5. 建立术语表(Glossary)。 对于领域概念(如"粉丝"、"互关"、"热度"、"影响力"),在项目的README或单独的文档中给出精确定义,确保所有人对核心概念的理解一致。例如:

  • 互关:用户A关注了用户B,且用户B也关注了用户A → mutualFollowing
  • 热度:playCount×1 + likes×1.5 + forwardCount×2.0 + coins×2.5(hw10)/ playCount×2 + likes×3 + forwardCount×4 + coins×5(hw11)

七、Code Agent使用经验(可选)

在本单元作业中,这次写博客的时间还算比较充裕,让我有时间折腾code agent,这次博客的主要内容(经过人工修改,bug部分完全人工编写)由接入deepseek的claude code完成,可以看到完成的效果还是非常不错的。

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

307

社区成员

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

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