Unit3 规格化设计博客

24373390-张丹婷 2026-05-29 11:00:00

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

经过 Unit3 的三次作业,我对 JML 的理解从“按题意补代码”逐渐变成了“先确认状态与约束,再写实现”。JML 最重要的价值,不是把方法签名写得更正式,而是把一个方法在什么前提下可以调用、调用后哪些状态必须变化、哪些状态绝对不能变化,全部提前说清楚。这样一来,开发时真正需要关注的就不只是“能不能跑出这个返回值”,而是“是否完整满足 requires / ensures / signals / assignable / pure 这些约束”。

我觉得规格驱动开发最核心的收益有三点。

第一,需求边界被提前暴露。很多平时容易在实现阶段才发现的问题,例如空容器、重复元素、异常优先级、对象状态是否允许修改,在 JML 中都必须正面回答。第二,实现会更有方向感。写代码时不再是凭感觉补分支,而是围绕“状态如何从 old 变成 new”组织逻辑。第三,测试目标更明确。因为规格已经给出了行为边界,测试时就不能只验功能输出,还要验副作用是否符合要求。

当然,JML 也不是“写了就万无一失”。真正困难的地方在于:自然语言中的模糊认识,一旦变成 JML,就必须具体到容器内容、数量变化、异常条件和副作用范围。如果一开始对需求理解就有偏差,那么这个偏差只会被更精确地固化下来。这也是我在后面“规格传声筒”部分感受最深的一点。

二、JUnit 测试经验总结

这三次作业让我对 JUnit 的认识发生了明显变化。之前我更习惯把测试当成“验样例”的工具,但在规格化作业里,JUnit 更像是在验证“实现是否忠实执行了规格”。

  • hw9 的 queryMutualFollowingSum,我重点测了三类情况:没有互相关注、多对互相关注、以及 pure 性。也就是说,不仅检查返回值,还会在调用前后对用户对象状态做比对,确认查询方法不会偷偷修改对象。
  • hw10 的 cleanSpamComments,我开始更重视“副作用范围”。测试不仅检查删了几条评论、最大出现次数是多少,还检查评论 id 和内容是否仍然一一对应,别的视频是否不受影响,点赞数、投币数、播放数、用户硬币数是否保持不变。
  • hw11 的 recommendNthUp,我把重点放在异常优先级、排序规则和 pure 性上。因为这类推荐方法很容易“结果大体对了,但细节顺序不对,或者状态被不小心改了”。

这三次测试让我总结出几个比较稳定的经验。

第一,规格测试一定要覆盖“正常行为 + 异常行为 + 状态不变性”。只测返回值是不够的。第二,pure / assignable 相关的约束必须单独设计测试,否则很多 bug 根本暴露不出来。第三,异常优先级必须刻意构造数据测试,因为多个前置条件同时不满足时,抛哪个异常往往才是最容易写错的地方。第四,遇到容器类状态,最好做“镜像对象”或“调用前后快照”比较,而不是只看一两个字段。

三、三次作业的迭代过程

1. hw9:先把基础社交网络的状态关系理顺

第一次作业的核心是把 UserNetworkVideo 三个对象之间的关系稳定下来。我的实现里,Network 负责维护全局 usersvideos 容器,User 负责维护关注、粉丝、收到的视频,Video 只保留基本身份信息。这个阶段最重要的不是“功能多”,而是先把对象职责切干净。

在容器选择上,我主要用了:

  • HashMap<Integer, User/Video> 做 id 到对象的快速索引;
  • ArrayList<UserInterface/VideoInterface> 保留整体遍历视图;
  • LinkedHashMap<Integer, UserInterface> 维护关注和粉丝关系;
  • LinkedList<Integer>HashSet<Integer> 维护收到但未观看的视频。

这个阶段我已经开始有“为后续迭代留口子”的意识。例如 mutualFollowingSum 没有每次查询时现算,而是在关注/取关时增量维护,这样 queryMutualFollowingSum 就是 O(1)。queryShortestPath 则用 BFS,在正确性比较直观的同时,复杂度也能接受。

2. hw10:从静态关系扩展到带副作用的事务操作

第二次作业在原有网络上叠加了硬币系统、点赞、投币、转发、评论、勋章等功能,系统状态一下子复杂了很多。这里的难点已经不是“多写几个方法”,而是一个操作经常会同时修改多个对象、多个容器。例如投币会同时影响投币者、up 主、贡献记录、视频热度;清理评论会影响评论区内容,但不应该影响别的统计量。

和 hw9 相比,我的类状态有了明显扩展:

  • User 新增了 coinswatchedVideoslikedVideosmedalscontributorscontributions
  • Video 新增了播放数、点赞数、转发数、投币数,以及评论 id / 评论内容;
  • Network 中的方法不再只是简单转发,而是开始承担事务编排的职责。

这一阶段我比较重要的改进是:把“检查存在性”的重复逻辑收敛成 checkedUsercheckedVideo,把评论清理逻辑收在 Video 内部,把热度比较集中在查询方法里完成。这样每次迭代时,状态归属会更清晰,不容易在多个类之间来回分散。

3. hw11:为了推荐系统,把“行为记录”进一步结构化

第三次作业新增推荐视频、推荐 up 主、查询用户画像等功能。到了这一步,前两次作业里积累的行为数据终于开始被真正消费。如果前面只是“把状态存下来”,那这次就是“让状态能够支持计算”。

为了支持推荐,我在 User 中进一步加入了:

  • typeCounts:记录各分区观看次数;
  • uploadedVideos:记录用户发布过的视频。

这两个状态非常关键。因为推荐视频需要用户兴趣度,推荐 up 主需要把“用户对各分区的兴趣”与“up 在各分区的影响力”结合起来计算。也就是说,hw11 的很多功能其实都建立在 hw10 状态设计是否足够可复用之上。

从实现角度看,这一阶段最明显的变化是:系统从“维护关系”转向了“维护可计算的数据”。如果前两次作业只是让功能正确,第三次作业则要求我开始为查询效率和后续统计服务。

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

我觉得这类迭代题最容易出问题的点,不是新增方法,而是“旧方法的语义已经悄悄变了”。我的做法主要有三步。

第一步,看类状态而不是只看方法名。每次新作业下来,我都会先看 UserNetworkVideo 新增了哪些属性。因为只要状态变了,旧方法几乎一定会受影响。比如 watchVideo 在 hw9 只需要把视频从“收到未观看”中移除,但到了 hw10 还要增加播放数,到了 hw11 还要更新用户在分区上的观看计数。

第二步,看 JML 里的 assignableensures。这比只看自然语言更可靠。因为自然语言常常只告诉你“做什么”,但 assignable 会明确告诉你“能改什么”,ensures 会告诉你“必须改成什么”。例如 purchaseMedalcoinVideo 这种方法,如果只按常识实现,很容易漏掉某个对象状态变化,或者改多了不该改的内容。

第三步,把“受新增状态影响的旧方法”列一个清单。以这三次作业为例,典型需要重新审视的旧方法有:uploadVideowatchVideofollowUserunfollowUser、各类查询方法。因为新状态一旦引入,这些旧方法往往不再是原来的简单版本了。

五、如何发现程序的性能瓶颈

我判断性能瓶颈主要靠两种方法:先做复杂度分析,再结合查询频率看热点。

先说复杂度分析。像 queryMutualFollowingSum 这种高频查询,如果每次都遍历全图计算,就很容易退化,所以我在 hw9 直接改成了增量维护。queryShortestPath 用 BFS,本身已经是比较合适的图搜索方案。到了 hw11,recommendVideo 需要扫描所有视频,recommendNthUp 需要筛候选人并排序,这种方法在数据量大时就比前两次作业更容易成为热点。

再说查询频率。不是所有 O(n) 都一定有问题,要看它是不是被高频调用。如果一个方法会反复调用,那么哪怕单次复杂度看起来还能接受,也可能在强测里成为瓶颈。比如:

  • queryMostPopularVideo 目前是按类型线性扫描全部视频;
  • recommendVideo 会遍历全部视频计算得分;
  • recommendNthUp 会对候选 up 排序;
  • queryMostInfluentialUp 会遍历用户,并进一步统计其上传视频热度。

这些方法都不是错,但它们提醒我:性能优化不能只盯着“单个方法是否正确”,而要看“是否把重复计算留在了高频查询阶段”。

我后来比较认同的一种思路是:如果某个量在更新时容易维护,而查询时会频繁使用,那就尽量前移到更新阶段增量维护;如果某个量依赖全局排序或复杂评分,而且查询频率不高,就可以先保留直接计算的实现。

六、我程序中出现过的 bug 和原因分析

这三次作业里,我最容易出 bug 的地方主要有以下几类。

1. 缓存量没有和修改操作同步

最典型的是互相关注数。既然选择把 mutualFollowingSum 作为缓存量维护,那么 followUserunfollowUser 就必须严格处理“什么时候加一、什么时候减一”。这类 bug 的根本原因不是算法难,而是状态依赖被低估了。缓存一旦引入,实现就必须对所有影响路径负责。

2. 容器之间的一一对应关系被破坏

评论系统里,commentIdscommentContents 是并行数组,它们必须始终保持下标对应。清理垃圾评论时,如果一边遍历一边原地删除,或者只删其中一个容器,就很容易出现错位。这个 bug 的本质原因是:我一开始只关注“删没删对元素”,没有把“多个容器共同表示同一份逻辑状态”这件事想清楚。后来改成“重建保留列表再整体替换”,风险就小很多。

3. 只顾功能结果,忽视 pure / assignable

规格化作业里很容易出现这样的问题:返回值是对的,但方法偷偷改了不该改的状态。尤其是查询类方法、推荐类方法,如果在内部排序、复用容器、临时写回对象,都可能违反 pure 或 assignable 约束。这类 bug 的原因是我早期写代码时还是偏向“命令式直觉”,没有时刻把“哪些状态绝不能动”放在第一位。

4. 异常优先级判断错误

hw10 和 hw11 的很多方法都有多个前置条件。例如推荐 up 主时,可能同时存在用户不存在、rank 非法、系统没有视频、候选人不足等情况。此时抛哪个异常,不由“我觉得哪个更合理”决定,而由规格决定。这个 bug 的根源是把异常当成了实现细节,而不是规格的一部分。

5. 需求变了,但旧方法理解没跟着变

这个问题贯穿整个迭代过程。最典型的是 watchVideouploadVideo。前者在不同作业中承担的副作用越来越多,后者从“上传视频”逐渐变成“校验类型 + 建立视频对象 + 更新 up 的发布记录 + 向粉丝分发”。如果只记住方法名,很容易沿用旧理解写出不完整实现。

七、JML“击鼓传花”游戏的感悟

这个活动本质上是在多轮 “自然语言 -> JML -> 自然语言 -> JML” 的转换中观察信息损失。它最打动我的地方在于:需求并不是一次性丢失的,而是在每一次转写里,被一点点模糊、替换、遗漏。

我在这个活动里最明显的感受有三点。

第一,边界条件最容易在传递过程中消失。像空集合、null、重复元素、异常条件、是否允许修改对象状态,这些内容在最初的自然语言里如果没有强调,后面几轮几乎一定会丢。尤其是 size == 0、是否需要抛异常、assignable \nothing 这种信息,如果没有被明确写进 JML,最后实现者很可能根本意识不到这里有约束。

第二,同一句自然语言,不同人会自动补全不同的“默认常识”。这也是我发现自己和别人 JML 中最容易出 bug 的地方。有人会把“查询”默认理解为 pure,有人会觉得“为了方便缓存可以顺手改一下”;有人会觉得“包含关键字”是区分大小写的字面匹配,有人会下意识按更宽松的规则理解。自然语言里的模糊空间,在多人传递时会被迅速放大。

第三,JML 并不会自动消除误解,它只能把当前理解精确化。如果 Round 2 的 JML 就已经漏掉了异常条件,那么后面的 Round 3、Round 4 只会把这个不完整的理解继续传下去。也就是说,规格不是“纠错机器”,它首先是一面镜子,照出团队是否真的对需求达成了一致理解。

八、以后多人协作时,怎样减少对需求和实现理解的不一致

如果以后是多人组队编程,我认为至少要做下面几件事。

第一,先统一“状态模型”,再分工写方法。也就是先明确每个类维护哪些核心状态、每个状态的拥有者是谁、哪些方法可以改它。很多实现分歧其实不是写法问题,而是状态归属没统一。

第二,把异常优先级和副作用范围单独列成清单。只在正文里写需求不够,因为最容易产生信息差的恰恰是这些“边角规则”。我现在越来越觉得,requires / signals / assignable / pure 应该是多人协作时最先对齐的部分。

第三,给每个关键方法配“正例 + 反例 + 边界例”。只讨论一句抽象描述往往不够,最好配上最小样例。比如“包含关键字”到底是不是区分大小写、“评论清理后是否允许重用 commentId”、“推荐排名是否先按分数再按 id”,这些都可以用样例迅速统一。

第四,评审时不要只审代码,还要审规格。很多 bug 不是实现写错,而是规格本身写漏了。既然规格驱动开发把规格放在前面,那么团队评审也应该把规格当作一等公民。

第五,尽量减少“口头约定”。凡是重要规则,都应该落到文档、规格或测试里。因为只要没有写下来,它在多人传递中几乎必然会变形。

九、Code Agent / 大模型的使用体会

如果把大模型或 Code Agent 用在规格化作业里,我觉得它最适合做三类事情:快速梳理 JML 条款、生成基础测试骨架、帮忙枚举边界情况。尤其是在方法很多、异常很多的时候,它对于“把遗漏的情况列出来”很有帮助。

但它的局限也很明显。第一,它很容易优先追求“功能能跑”,而忽视 assignablepure、异常优先级这种规格细节。第二,它常常默认用直接可行的实现,而不会主动替你考虑复杂度和容器设计。第三,如果提示不够精确,它会把自然语言里的模糊理解合理化,而不是主动质疑需求本身。

所以我现在更倾向于把它当成“辅助检查工具”,而不是替代自己理解规格。真正决定程序质量的,仍然是自己是否先把状态、边界、约束和复杂度想清楚。

十、总结

回看 Unit3,我最大的收获不是学会了多少 JML 语法,而是逐渐形成了一种更稳的开发顺序:先认清状态,再读规格;先明确边界,再写代码;先验证副作用,再相信结果。三次作业的迭代让我越来越确信,规格驱动开发的关键不在“写得像不像数学”,而在“团队是否真的把需求说明白、把实现约束写完整、把测试做到和规格对齐”。

这也是我对这三次作业最核心的总结:代码可以不断重构,但如果对状态和规格的理解不清楚,那么功能越迭代,系统只会越混乱;相反,如果规格、测试和实现始终围绕同一套状态模型演进,那么系统即使不断加功能,也仍然能保持可控。

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

309

社区成员

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

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