OO 第三单元总结:JML、规格驱动开发,与一个「产品经理」的自我怀疑

黄扬淞-ZC061023 2026-06-10 22:59:28

OO 第三单元总结:JML、规格驱动开发,与一个「产品经理」的自我怀疑

第三单元的主题是 JML 规格 与一个社交视频网络系统。三次作业(spec1 / spec2 / spec3)层层迭代,从基础的关注/视频,到硬币经济与勋章,再到智能推荐系统。
这篇博客我想聊四件事:对 JML 和规格驱动开发的理解、JUnit 测试的经验、三次作业的迭代与性能、以及——也是我这单元最大的感受——当规格已经把需求设计好之后,写代码的我到底还剩下什么


一、JML 与规格驱动开发:一种「升维后降维」

刚接触 JML 时,我把它当成「写在注释里的、更严格的文档」。requires / ensures / assignable / signals / pure,逐条对应前置条件、后置条件、副作用范围、异常行为、纯查询。这是契约式设计(Design by Contract)最朴素的样子:调用方保证 requires,实现方保证 ensures,双方在 assignable 划定的边界内交易。

但写到第三次作业,我对规格有了一个更具体的感受:规格是一次「升维后降维」的过程。

  • 升维:把自然语言里那句模糊的「给我推荐一个最合适的 up」,先翻译成一个完整、无歧义的工作流——什么叫合适?分数怎么算?并列怎么办?谁被排除?异常按什么优先级抛?这一步把口语化的需求展开成了一个高维的、每个分支都被钉死的状态机。
  • 降维:再把这个工作流里真正的核心抽象出来,剥掉所有边角,只留下「分数 = Σ interest × influence,按分数降序、id 升序取第 rank 个」这样一条可以直接落到代码里的算法。

这个视角解释了规格驱动开发最迷人、也最危险的地方。

迷人在于:当规格写好,「做什么」已经被彻底设计完了,实现层退化成了纯粹的算法 / 容器选择。我在 recommendNthUp 里要操心的,不再是「业务上推荐意味着什么」,而只是「怎么排序、怎么防溢出、怎么不超时」。

危险在于:自然语言在升维的那一步,本身就藏着 bug

举个我在研讨课上反复想到的例子:「给我一个数组,做冒泡排序。」我们都想当然地认为这个数组是非空的。可是——「非空」这层 requires 到底归谁?

  • 是在排序方法里写 requires arr.length > 0,把空数组当成调用方违约?
  • 还是在上游产生这个数组的地方,由输出规格保证它永远非空,排序方法根本不必检查?

单机开发时,这个边界放在哪儿、甚至重复放两遍,对程序实际运行毫无影响——反正都是我一个人,我心里清楚。但一旦是多人协作:我以为上游保证了非空,上游以为我会检查,那个没人负责的边界,就是 bug 出生的地方。

所以规格驱动开发真正约束的,从来不是机器,而是人与人之间对「边界归属」的共识。JML 之所以有价值,正是因为它把这种共识从「大家心照不宣」变成了「白纸黑字、可被工具检查」。


二、三次作业的迭代:如何察觉「方法 / 容器在悄悄变化」

三次作业是同一套系统的连续生长:

作业规格包主要新增
hw9spec1用户、关注、视频、最短路、最长递减链
hw10spec2硬币经济、投币贡献、勋章、评论与刷屏清理、热度
hw11spec3智能推荐:recommendVideo / recommendNthUp / queryMostInfluentialUp / 用户画像

2.1 怎么发现「已有方法 / 容器」在迭代中变了?

最朴素也最有效的办法:把新旧规格包的接口拿来 diff,盯住语义而非签名。 签名变了编译器会替我报错,真正吃人的是签名没变、语义变了。两个真实的坑:

  1. Video.equals 的语义漂移。hw9 里我判等用的是 id == other.getId() && uploaderId == other.getUploaderId();到 hw10,视频被允许进入更多容器(按类型分桶、被多个用户引用),规格的实际语义收敛成了「id 相同即同一视频」。如果沿用 hw9 的双字段判等,在 HashSet / Map 里就会出现「同一个视频被当成两个」的诡异问题。这种 bug 不会编译报错,只会在某个查询里悄悄算错。

  2. getHeat(热度)公式的演化。从 hw10 到 hw11,热度的权重重新定义为 playCount*2 + likes*3 + forwardCount*4 + coins*5。热度是 queryMostPopularVideogetInfluence、推荐分数共同依赖的底层量——一个底层公式变化,会沿着调用链污染上层所有结果。所以每次迭代我做的第一件事,是把「被多处依赖的核心量」(热度、interest、influence)单独列出来,确认它们的定义没有偷偷变。

经验:迭代里最该警惕的不是新方法,而是老方法依赖的底层语义。容器选型也一样——我从 hw9 开始就坚持 List + Map 双存(List 保插入序、Map 给 O(1) 查找),正是因为预见到后续查询既要遍历又要按 id 命中。

2.2 怎么发现性能瓶颈?

第三单元的性能压力,几乎全部来自「图遍历 / 全量扫描类查询,叠加高频调用」。我的判断方法是:先估算单次复杂度,再乘以调用频率,看最坏情况会不会爆。

几个真实的优化点:

  • queryLongestDecSeq(最长年龄递减关注链):本质是 DAG 上的最长路径,我用 DFS + 记忆化 把单次降到 O(V+E)。但它可能被高频查询,而图在两次查询间往往没变。于是我加了一个 dirtyDecSeq 脏标志:只有 addUser / followUser / unfollowUser 才置脏,查询命中缓存时直接返回 cachedDecSeq,把「重复查询」摊薄成 O(1)。

    public int queryLongestDecSeq() {
        if (!dirtyDecSeq) { return cachedDecSeq; }   // 懒缓存:图没变就不重算
        // ... DFS + memo ...
        dirtyDecSeq = false;
        return cachedDecSeq;
    }
    
  • **queryShortestPath**:标准 BFS,ArrayDeque 当队列、HashMap 记距离,找到目标立即返回,找不到抛 UncessException

  • **recommendNthUp**:候选集是「未被自己关注的其他用户」,需要对每个候选算 computeUpScore(一次要遍历 7 个 type 求 Σ interest×influence)。最坏是 O(候选数 × type 数 × 每人上传视频数)。这里我把 influence 在排序前预先取好,避免比较器里被反复重算。

性能瓶颈的发现,靠的不是跑了才知道,而是写之前就用「复杂度 × 频率」做一遍最坏情况推演

2.3 一个不是性能、但同样致命的「数值」坑

computeUpScoreinterest × influence 两个 int 相乘,在极端数据下会整型溢出。我的处理是相乘前强制提升到 long

sum += (long) this.getInterest(t, totalVideos) * up.getInfluence(t);

还有 queryMostInfluentialUp,初始 bestInfluence 不能写成 0——因为所有用户的 influence 可以全为 0,若用 0 做初值并要求「严格大于」,就会漏掉合法答案。我用 -1 做初值(influence 非负),保证第一个用户一定能进入比较。这类 bug 编译器、甚至大多数随机数据都抓不到,只在精心构造的边界上现形。


三、JUnit 测试经验:把 JML 的四要素逐条「翻译」成断言

我这单元的单测思路,可以概括成一句话:JML 写了什么,我就逐条造一个断言去验证什么。 以最复杂的 recommendNthUp 为例,我把测试拆成了几层:

3.1 暴力枚举 + 对拍(验 ensures 的「结果正确」)

多个固定种子随机建网(用户、视频、关注、观看、点赞都随机),然后枚举每个 user 的每一个合法 rank,对每次调用都用一套独立实现的 assertAllEnsures 去核对「结果确实是按分数降序、id 升序的第 rank 名」。种子固定保证可复现,失败时直接打印 seed=xxx

3.2 状态快照(验 assignable \nothing 的「纯查询」)

recommendNthUp 规格是 pure 的,意味着调用前后系统状态必须一字不变。我写了一个 StateSnapshot:调用前给整个网络(每个用户的关注、被关注、硬币、观看集、各类型计数……)拍一张「指纹」,调用后再拍一张,逐字段比对。

StateSnapshot snap = NetworkAssertions.snapshot(network, vids, total);
int result = network.recommendNthUp(userId, rank);
NetworkAssertions.assertAllEnsures(network, userId, rank, result, total);
snap.assertUnchanged(network);   // 验证 pure:状态零改动

这是我觉得最值的一笔投入pure 违约(一个查询偷偷改了状态)是最隐蔽的 bug,靠肉眼 review 几乎看不出来,但快照一比对就无所遁形。

3.3 确定性构造(补随机测不到的角)

随机数据几乎不会让两个候选分数恰好相等,可并列时的「id 升序」恰恰是最容易写错的地方。所以我手工构造了「三个候选分数全为 0」的网络,确定性地验证 rank=1/2/3 严格返回升序 id。同理,异常优先级 UserIdNotFound > InvalidRank > NoVideoUploaded > ColdStart 也是逐对构造、逐对验证。

3.4 异常路径(验 signals

每一条 signals 都对应一个 @Test(expected = XxxException.class),并且我特意为每条异常路径也做一次快照——确认抛异常时状态同样没变(异常路径也要满足 pure,这点很容易漏)。

一句话总结 JUnit 经验:把 JML 的 requires / ensures / assignable / signals 当成一张 checklist,一条规格至少对应一类测试;其中 assignable \nothing 用「前后快照比对」来验,性价比最高。


四、我的 bug 与原因:根子还在那次「升维」

诚实地说:

  • 强测扣了分,而且不是 TLE,是逻辑错误——具体是哪个查询、哪条边界,现在已经记不清了。
  • 互测我没能 hack 到别人,但我自己的代码确实有 bug

我没必要为「记不清具体 bug」感到尴尬,因为回头看,这些 bug 的根因高度一致,正是第一节那个「升维」步骤里出的岔子:

在把自然语言需求翻译成规格、再把规格翻译成代码的两次转译中,某个边界条件被我无意识地「想当然」了——它在我脑子里有一个默认值(数组非空、影响力可以全 0、id 相同即同一对象),而这个默认值没有被任何一行代码或断言显式守住。

换句话说,我的 bug 不是「算法写错了」,而是「对边界的理解,停留在我自己的脑子里,没有落到规格和代码上」。这也正是为什么我后来在测试上重金投入 StateSnapshot 和确定性边界构造——正确性不能靠「我以为」,得靠「我验过」。


五、大模型与规格驱动开发:我成了产品经理,却还自诩懂技术

这单元我确实用了大模型(Claude Code)来辅助开发,想认真聊聊这段体验——它带来的不全是爽感,还有一点不安。

5.1 规格驱动开发,对大模型「过于友好」

规格驱动和大模型的契合度高得有点反常。原因在第一节就埋好了:当规格写好,「做什么」已经被人类彻底设计完了,剩给大模型的只是「实现层」——把确定的工作流翻译成确定的算法。 这正是大模型最稳的活:边界清晰、有标准答案、不需要它去揣摩业务意图。

代价是:它的发挥空间也被规格框死了。 但在工程上,这种「被框死」恰恰换来了稳定——它很难跑偏,因为没有可以跑偏的余地。

5.2 它会忽视效率 / 架构 / 容器吗?会,而且很隐蔽

会,但坑往往不在「单个方法」里,而在「全局视野」里。我观察到两点:

  • 大模型盯着单个 JML 实现时,默认会选「能跑对」的容器,而不是「全局最优」的容器。 比如它可能在某个方法里随手 new ArrayList 然后线性查找,因为单看这个方法毫无问题;但它看不到这个容器会被另外五个高频查询反复扫——它缺的不是算法知识,是全局上下文。
  • 如果用 subagent(子代理)拆分开发,这个问题会被放大。 子代理各自独立上下文、各自实现一块,「上下联动性」会明显变差:A 代理选了 List 保序,B 代理在另一处假设了 Set 去重,谁也没看到对方的容器决策,最后在集成层撞车。这本质上和第一节说的多人协作 bug 是同一个病——信息差,只不过这次「多人」换成了「多个 agent」。所以我后来倾向于:架构和容器选型由人(或单一主上下文)统一拍板,让大模型只在被约束好的实现层里干活。

5.3 用大模型做单元测试:它的「逻辑」和人不一样

让大模型写单测,和人设计单测有本质区别。人写单测是「带着假设去证伪」——我怀疑并列排序会错,所以专门去构造并列;我怀疑 pure 会被破坏,所以去拍快照。这是一种对抗性、目标驱动的思维。

大模型默认的单测更像「覆盖性铺开」——它能很快把 happy path、各条异常路径都铺满,覆盖率好看,但不会主动去想「哪个边界最阴险」,除非你明确把对抗目标告诉它(「去构造分数并列的 case」「验证异常路径下的 pure」)。所以我的用法是:对抗性的测试设计由我出(我决定要打哪些角),机械的脚手架铺设交给它。

5.4 最后一点不安:工作流,以及「自诩懂技术的产品经理」

这单元写到后面,我有一种很微妙的失落:编码任务仿佛被 AI 整个端走了。 规格是给定的,实现被大模型补全,我的角色不知不觉从「写代码的人」滑向了「提需求、定边界、验收结果的产品经理」。

而最值得警惕的,是那种「我还自诩懂技术」的错觉——因为代码能跑、测试能过,我会误以为自己掌控了一切。可实际上,真正决定成败的东西已经悄悄上移了:不再是「我会不会写这段代码」,而是「我能不能定义清楚边界、能不能设计出有效的工作流、能不能验出大模型没替我想到的那个角」。

所以这单元给我最大的方法论收获,恰恰不是某个算法,而是工作流本身的重要性

  • 谁来定边界(人),谁来填实现(大模型),谁来对抗性验收(人);
  • 全局架构 / 容器不下放给独立的子上下文,避免「上下联动性」失守;
  • 把「我以为」的每一个默认假设,都逼成规格或断言里「我验过」的一行。

大模型没有取代工程师,它只是把工程师的价值,从「实现层」整体上移到了「规格层与验证层」。 看不清这一点的人,才会变成那个「自诩懂技术」的产品经理。


六、研讨课「击鼓传花」的感悟:边界,是在传递中悄悄变形的

第二次研讨课的 JML「击鼓传花」,把第一节那个抽象的道理,变成了一次可被亲眼看见的实验:同一份需求,在一次次「自然语言 → JML → 理解 → 再表述」的传递中,边界会一点点漂移,而每个人都觉得自己传的是「原话」。

回到那个冒泡排序的例子:「给我一个非空数组排序」。

  • 第一个人写规格时,把「非空」放进了 requires
  • 传到下一个人手里,他读到的是方法签名,默认「上游会保证非空」,于是在调用处没有做检查;
  • 再传一手,有人甚至把「非空」理解成了「应该抛异常的 signals」。

需求没变,但「非空」这个边界的归属,在传递中从「调用方义务」漂成了「实现方义务」又漂成了「异常行为」。 单机时三种写法都能跑对,多人集成时——那个没人真正负责的边界,就是 bug。

今后多人组队编程,怎么统一所有人对需求与实现的理解、减少组内信息差? 这单元给我的答案是:

  1. 规格先行,且规格是唯一事实来源(Single Source of Truth)。 不靠口头约定、不靠「我以为」,所有人对齐到同一份 JML / 接口契约上,有歧义先改规格再写代码。
  2. 显式约定「边界归属」。 每一个前置条件,必须白纸黑字写清楚是「调用方保证(requires)」还是「实现方检查并抛异常(signals)」,不留「反正不影响运行」的灰色地带——因为灰色地带在多人协作里一定会被两个人朝相反方向理解。
  3. 统一容器与核心量的定义。equals 的判等语义、getHeat 的公式这种被多处依赖的底层约定,必须全组对齐、集中定义,不允许各写各的。
  4. 用契约评审 + 共享词汇表压缩信息差。 集成前先做一轮「规格评审」,让每个人复述自己负责模块的边界假设——把藏在各自脑子里的默认值,全部摊到桌面上。这其实和 5.2 节里「让独立的 subagent 别各自决定架构」是同一条原则:信息差,无论发生在人与人之间还是 agent 与 agent 之间,都得靠「显式、集中、可被检查的契约」来消除。

七、结语

第三单元教给我的,表面上是 JML 和契约式设计,骨子里却是一件更朴素的事:

把「我以为」变成「我写下了、并且我验过了」。

无论对面坐着的是队友、是助教的强测数据、还是一个上下文有限的大模型——所有的 bug,几乎都诞生在那个「大家心照不宣、却谁也没真正写下来」的边界上。规格的意义,就是不给这种心照不宣留任何空间。

而当大模型把实现层整个接管之后,工程师真正的护城河,也正落在了这件事上:你能不能把边界定义得足够清楚,让机器无法、也无需揣测。

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

309

社区成员

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

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