OO第三单元博客作业

丁一博-24371341 2026-05-28 13:53:03

第三单元 OO 学习与反思

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

JML 契约式设计

JML 是一种基于形式化逻辑的语言。它通过 requiresensuresassignablesignals 等子句,明确了方法的前置条件、后置条件、可修改状态以及异常抛出条件。相比于自然语言描述,JML 可以准确地约束方法执行前后对象状态的变化。在 OOP 中,它作为接口设计者和实现者之间的契约,有效消除了语义上的歧义。

规格驱动开发

规格驱动开发的重点是先定义接口的行为规范,再进行具体的代码实现。在本单元中,规格开发的主要优势有:

  1. 实现自由度与结果约束:调用方只关注 JML 规定的返回结果和状态变化,而无需干涉内部实现。因此我们可以自由选择如 HashMapTreeSet 或是自定义容器来优化性能,只要其对外暴露的状态符合 ensures 的要求即可。
  2. 测试驱动:JML 为代码测试提供了清晰的依据。通过直接对照 ensuressignals 子句,可以很容易地推导出测试的分支和边界情况,从而编写出高覆盖率的 JUnit 单元测试。

二、JUnit 测试经验总结

在本单元的测试中,我的主要策略是针对 JML 的完整性进行验证,包括正常结果返回、状态变化以及异常情况,具体体现在以下三个方法的测试中:

在针对 queryMutualFollowingSum 的测试中,我没有调用正式代码中动态维护 mutualFollowingSum 的逻辑,而是在测试类中独立实现了一个双重遍历图节点的逻辑。测试数据覆盖了空图、单向关注、对称互关、不成环等场景,从而有效验证了缓存计数在边增删时的正确性。

在测试 cleanSpamComments 时,由于该方法允许修改评论列表,我对调用前对象的状态进行了快照。除了验证返回的删除评论数和关键字出现最大次数是否与预期一致外,我重点验证了原先的评论顺序是否保持,以及 playCountlikes 等其他无关属性是否未被修改。针对字符串匹配,我添加了包含通配符的边界词以及重叠匹配的字符串,以确保代码逻辑严格符合题目要求。

在测试 recommendNthUp 时,我重点验证了推荐排序规则。测试涵盖了 rank 不合法、候选集为空、具有相同得分时的 ID 排序规则,以及排除自身和已关注用户的条件。此外,推荐操作是纯查询方法,因此我也测试了连续调用下对象的内部状态没有意外变更。

三、三次作业的迭代过程

1. 第一次作业:社交图谱与视频推送

第一次作业需要实现基础的用户社交图谱和视频推送功能。为了保证查询效率,我在 Network 类中使用了 HashMap 来管理 userMapvideoMap
在用户关系维护方面,我直接在 User 类中使用两个 HashMapfollowingfollowers)来记录关注和被关注关系,以此替代低效的全局查找。针对 queryMutualFollowingSum 方法,我采用动态维护的方式,在 followUserunfollowUser 中实时更新 mutualFollowingSum 的值,将时间复杂度降低到了 O(1)。在视频接收方面,为了支持频繁的头部插入操作,我自定义了 IndexedList 数据结构来管理 receivedVideos;同时在 User 类中维护了一个长度为 4 的数组 followerAgeCounts,以此在 O(1) 的时间内返回各个年龄段粉丝的占比。

2. 第二次作业:互动状态维护与缓存设计

第二次作业引入了点赞、投币、视频热度等互动状态。这涉及多对象的协同状态更新,例如投币操作需要同时修改用户余额、视频硬币数、UP主硬币数,并在 Usercontributions Map 中更新贡献度数据。
为了避免复杂的全量查询,我在 User 中通过 watchedVideoslikedVideos 等集合来记录用户的操作历史,并使用 contributions 容器来记录各个用户的贡献度。在 Video 类中,我在添加评论时使用 commentIdSet 防重,并在 cleanSpamComments 中使用双指针配合 comments.subList().clear() 的方式进行高效清理,同时还通过 cleanKeywords 集合缓存了已清理过的关键字以避免重复计算。针对 queryLongestDecSeq 这个可能引发超时的图查询操作,我引入了脏读标记 longestDirty 和缓存 longestDecSeqCache,仅在图结构发生变化时重置脏位,保证了平均情况下的高查询效率。

3. 第三次作业:热度推荐与模块化

第三次作业加入了推荐相关的方法。由于代码量逐渐庞大,我在 Network 中尽量简化业务,将状态管理交给具体的实体类。
为了优化 queryMostPopularVideo,我在 Network 中维护了 Map<String, TreeSet<Video>> typeHotRank,其中 TreeSet 按照视频的 heatid 自定义排序规则。在任何影响视频热度的操作执行前后,我会先将其从 TreeSet 中移出,热度更新后再重新插入,从而始终保持各类型视频的动态有序。在 Userinfluence 更新上,我也没有每次都遍历所有上传视频,而是通过回调在热度变化时增量更新 influences 数组。在实现 recommendNthUp 时,我使用了类似快速选择的策略,避免了全量排序的时间开销。

img

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

在历次作业中,准确找出需要修改的方法和容器是很重要的一环。我发现不能只看新增加的方法,已有的老方法在规格上也可能发生了变化。

首先是对比接口暴露的状态模型。比如在第三次作业中,UserInterface 的模型描述里新增了关于不同类型视频的数量统计要求。看到这个新增的模型属性,我就在 User 类中新增了一个 typeCounts 数组,在每次观看视频时同步更新它。

其次是检查副作用 assignable 的变化。有时候老方法的代码核心逻辑没变,但在新版本中允许修改的变量范围变大了。比如有的方法原本只修改自身属性,后来允许修改其他类的集合。如果漏看了这部分 assignable 的扩展,只按原来的逻辑写,就会导致数据没有同步更新。

另外就是对比方法签名。如果返回类型或参数数量如果变了,原来调用的地方和涉及的数据存储格式基本都需要做相应的修改,这一点编译器通常会报错提示,但也需要我们去更新相关的代码链路。

五、如何发现性能瓶颈

JML 只描述结果要求,不会考虑底层的计算开销,所以直接按照 JML 的字面意思写代码很容易在强测里超时。为了发现并解决性能瓶颈,我主要关注以下几个方面。

第一是分析量词的复杂度。如果 JML 里出现了多重嵌套的 \exists\forall,或者要求计算全局的 \sum,把它直接写成嵌套循环一般就会达到 O(N²) 以上的复杂度。遇到这类方法,我会在本地造一些比较大的数据来测试它的执行时间。

第二是判断读写频率来选择维护策略。对于像互相关注总数、粉丝年龄分布这种查询次数远大于修改次数的属性,每次查询都重新计算显然太慢了。我选择在关注、取关等操作发生时顺便更新这些值。就像代码里维护 bestContributorId 一样,通过增量维护把复杂度降下来。

最后是图算法的缓存。像 queryLongestDecSeq 这种方法每次计算开销很大,我采用了带脏位的缓存机制。不过使用缓存时必须保证状态的一致性,也就是在任何会改变图结构的方法里,都要记得把 longestDirty 设为 true,防止出现缓存和实际状态不符的问题。

六、Bug 与原因分析

得益于规格驱动开发的严谨性,在三次作业的中测、强测和互测环节中,我的代码都没有被测出 Bug。期间还发生了一个小插曲:在 hw10 的强测中,由于评测机波动,第六个数据点最初被误判为错误,好在后续重测后证实了那只是评测机自身的问题。这虽然让我虚惊一场,但也最终证明了代码的正确性。

不过在本地开发迭代的过程中,我也踩过一些坑。这些经历让我意识到规格开发中有几个很容易出错的地方:

  1. 遗漏副作用:在 hw10 迭代过程中,增加投币功能时,不仅要修改视频本身的 coins,还需要同步增加该视频上传者的 coins 以及记录 contribution。起初我没有仔细对照 JML 中新增的 assignable 语句,只修改了视频侧,导致了网络状态不一致。这说明对于具有事务性质的多对象更新,不能有一丝遗漏。
  2. 异常抛出顺序:JML 规格中异常不仅有明确的判断先后顺序,构造异常时传入的 ID 参数也有严格要求。最初我在抛出异常时未按规则排序,或者传入的 ID 顺序错位,导致 JUnit 测试中捕获到的异常信息不匹配。

七、大模型使用体会

在本单元,我更适合把大语言模型当作“辅助阅读规格和生成测试思路”的工具,而不是直接生成最终代码的工具。我使用的 Code Agent 主要是 Codex 。

它的显著优势在于:可以快速把长篇的 JML 翻译成自然语言、列出可能的边界条件、提醒某个方法需要检查哪些副作用,还可以帮助生成繁琐的 JUnit 快照验证框架。使用这些 Agent 来开展自动化测试、批量构造测试样例等还是很方便的,极大地提高了编写全覆盖测试用例的效率。对于版本变更比对,将新旧版本的接口文件发给大模型,它能迅速列出所有变更点,比人工肉眼比对高效得多。

但大模型的局限性也很明显:它往往倾向于直译 JML,给出“看起来正确但慢”的朴素实现,完全忽略强测中的性能压力。要想让大模型设计出好的架构或是运用恰当的设计模式,需要我们提供极高质量的提示词。因此在实际开发中,我经常需要使用一些提示词优化器来反复打磨优化我的输入,通过向大模型更加精准地传达性能约束和数据结构意图,从而引导其输出更优的工程化方案。

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

在第二次研讨课上的 JML “击鼓传花”游戏中,我获得了许多关于团队协作的启发。

在本次活动中,我所在的链路题目是计算两个正整数的最大公约数。初始的自然语言需求明确规定了接收正整数 a 和 b,前置条件为大于 0,且在输入小于等于 0 时抛出 IllegalArgumentException 异常。

1. 发现的 JML Bug

在从 JML 翻译回自然语言,或者从 NL 编写 JML 的过程中,很容易出理解的偏差。例如在我们组其他同学的链路中,就出现了:

  • 边界条件遗漏或变形:有的同学在 JML 转自然语言时遗漏了边界条件,把 >= 0 错误地记作了 > 0。或者对于某些特殊边界,原本要求返回“空 Map”,在传递中却被畸变成了“返回 null”。
  • 逻辑符号被忽视:在转述过程中,某些逻辑非符号(!)极容易被忽视,导致某一处表述出现明显的语义反转和偏离。
  • 异常触发条件写错:自然语言表述的异常抛出条件由于不够严谨,导致后续在写 signals 子句时发生偏离。

2. 需求与边界在传递中的变化

游戏的传递过程中,需求和边界常常会发生不可逆转的变化。不过,因为我出的 gcd 题目本身的数学计算业务逻辑比较纯粹,大家都不容易搞错,所以我们这条链路取得了 5 分满分。这也让我深刻体会到:只要初始的需求定义得足够严谨,后面传错的概率就小很多。有些自然语言觉得理所当然的条件,必须得像写数学公式一样一条条严格约束,不然很容易让其他人产生误解。

3. 多人组队编程的建议与措施

为了在未来的多人开发中统一所有人对任务需求的理解,我认为应该采取以下措施以减少信息差:

  • 统一接口规范:JML 的核心在于提供接口规范,而不是强制所有人写出一样的代码。我们只要求大家的实现符合一个统一的接口,至于方法内部实现只要没有性能瓶颈,无需强行统一。
  • 严格定义初始需求:在动手编码前,需求分析必须做到形式化般的严谨。即便是看似简单的自然语言,也要杜绝任何歧义,明确写出前提和异常分支。
  • 多重细致校验:在代码编写时,必须进行细致的逻辑复盘,严防类似遗漏 ! 等低级错误造成雪崩。

九、总结

从基于自然语言的需求文档转向严谨的形式化 JML,最初确实面临着一定的适应期。但理解之后,我深切感受到了契约编程带来的安全感——只要代码严格通过了基于规格的 JUnit 测试,一般就可以宣告它的正确性。好的实现绝不是简单把 JML 翻译成循环,而是在不改变规格的前提下,巧妙选择合适的数据容器、维护缓存并进行合理的模块划分。这种“实现自由,结果可控”的体验令人印象深刻。

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

305

社区成员

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

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