309
社区成员
发帖
与我相关
我的任务
分享第三单元的主题是 JML 规格 与一个社交视频网络系统。三次作业(spec1 / spec2 / spec3)层层迭代,从基础的关注/视频,到硬币经济与勋章,再到智能推荐系统。
这篇博客我想聊四件事:对 JML 和规格驱动开发的理解、JUnit 测试的经验、三次作业的迭代与性能、以及——也是我这单元最大的感受——当规格已经把需求设计好之后,写代码的我到底还剩下什么。
刚接触 JML 时,我把它当成「写在注释里的、更严格的文档」。requires / ensures / assignable / signals / pure,逐条对应前置条件、后置条件、副作用范围、异常行为、纯查询。这是契约式设计(Design by Contract)最朴素的样子:调用方保证 requires,实现方保证 ensures,双方在 assignable 划定的边界内交易。
但写到第三次作业,我对规格有了一个更具体的感受:规格是一次「升维后降维」的过程。
这个视角解释了规格驱动开发最迷人、也最危险的地方。
迷人在于:当规格写好,「做什么」已经被彻底设计完了,实现层退化成了纯粹的算法 / 容器选择。我在 recommendNthUp 里要操心的,不再是「业务上推荐意味着什么」,而只是「怎么排序、怎么防溢出、怎么不超时」。
危险在于:自然语言在升维的那一步,本身就藏着 bug。
举个我在研讨课上反复想到的例子:「给我一个数组,做冒泡排序。」我们都想当然地认为这个数组是非空的。可是——「非空」这层 requires 到底归谁?
requires arr.length > 0,把空数组当成调用方违约?单机开发时,这个边界放在哪儿、甚至重复放两遍,对程序实际运行毫无影响——反正都是我一个人,我心里清楚。但一旦是多人协作:我以为上游保证了非空,上游以为我会检查,那个没人负责的边界,就是 bug 出生的地方。
所以规格驱动开发真正约束的,从来不是机器,而是人与人之间对「边界归属」的共识。JML 之所以有价值,正是因为它把这种共识从「大家心照不宣」变成了「白纸黑字、可被工具检查」。
三次作业是同一套系统的连续生长:
| 作业 | 规格包 | 主要新增 |
|---|---|---|
| hw9 | spec1 | 用户、关注、视频、最短路、最长递减链 |
| hw10 | spec2 | 硬币经济、投币贡献、勋章、评论与刷屏清理、热度 |
| hw11 | spec3 | 智能推荐:recommendVideo / recommendNthUp / queryMostInfluentialUp / 用户画像 |
最朴素也最有效的办法:把新旧规格包的接口拿来 diff,盯住语义而非签名。 签名变了编译器会替我报错,真正吃人的是签名没变、语义变了。两个真实的坑:
Video.equals 的语义漂移。hw9 里我判等用的是 id == other.getId() && uploaderId == other.getUploaderId();到 hw10,视频被允许进入更多容器(按类型分桶、被多个用户引用),规格的实际语义收敛成了「id 相同即同一视频」。如果沿用 hw9 的双字段判等,在 HashSet / Map 里就会出现「同一个视频被当成两个」的诡异问题。这种 bug 不会编译报错,只会在某个查询里悄悄算错。
getHeat(热度)公式的演化。从 hw10 到 hw11,热度的权重重新定义为 playCount*2 + likes*3 + forwardCount*4 + coins*5。热度是 queryMostPopularVideo、getInfluence、推荐分数共同依赖的底层量——一个底层公式变化,会沿着调用链污染上层所有结果。所以每次迭代我做的第一件事,是把「被多处依赖的核心量」(热度、interest、influence)单独列出来,确认它们的定义没有偷偷变。
经验:迭代里最该警惕的不是新方法,而是老方法依赖的底层语义。容器选型也一样——我从 hw9 开始就坚持 List + Map 双存(List 保插入序、Map 给 O(1) 查找),正是因为预见到后续查询既要遍历又要按 id 命中。
第三单元的性能压力,几乎全部来自「图遍历 / 全量扫描类查询,叠加高频调用」。我的判断方法是:先估算单次复杂度,再乘以调用频率,看最坏情况会不会爆。
几个真实的优化点:
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 在排序前预先取好,避免比较器里被反复重算。
性能瓶颈的发现,靠的不是跑了才知道,而是写之前就用「复杂度 × 频率」做一遍最坏情况推演。
computeUpScore 里 interest × influence 两个 int 相乘,在极端数据下会整型溢出。我的处理是相乘前强制提升到 long:
sum += (long) this.getInterest(t, totalVideos) * up.getInfluence(t);
还有 queryMostInfluentialUp,初始 bestInfluence 不能写成 0——因为所有用户的 influence 可以全为 0,若用 0 做初值并要求「严格大于」,就会漏掉合法答案。我用 -1 做初值(influence 非负),保证第一个用户一定能进入比较。这类 bug 编译器、甚至大多数随机数据都抓不到,只在精心构造的边界上现形。
我这单元的单测思路,可以概括成一句话:JML 写了什么,我就逐条造一个断言去验证什么。 以最复杂的 recommendNthUp 为例,我把测试拆成了几层:
ensures 的「结果正确」)多个固定种子随机建网(用户、视频、关注、观看、点赞都随机),然后枚举每个 user 的每一个合法 rank,对每次调用都用一套独立实现的 assertAllEnsures 去核对「结果确实是按分数降序、id 升序的第 rank 名」。种子固定保证可复现,失败时直接打印 seed=xxx。
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 几乎看不出来,但快照一比对就无所遁形。
随机数据几乎不会让两个候选分数恰好相等,可并列时的「id 升序」恰恰是最容易写错的地方。所以我手工构造了「三个候选分数全为 0」的网络,确定性地验证 rank=1/2/3 严格返回升序 id。同理,异常优先级 UserIdNotFound > InvalidRank > NoVideoUploaded > ColdStart 也是逐对构造、逐对验证。
signals)每一条 signals 都对应一个 @Test(expected = XxxException.class),并且我特意为每条异常路径也做一次快照——确认抛异常时状态同样没变(异常路径也要满足 pure,这点很容易漏)。
一句话总结 JUnit 经验:把 JML 的 requires / ensures / assignable / signals 当成一张 checklist,一条规格至少对应一类测试;其中 assignable \nothing 用「前后快照比对」来验,性价比最高。
诚实地说:
我没必要为「记不清具体 bug」感到尴尬,因为回头看,这些 bug 的根因高度一致,正是第一节那个「升维」步骤里出的岔子:
在把自然语言需求翻译成规格、再把规格翻译成代码的两次转译中,某个边界条件被我无意识地「想当然」了——它在我脑子里有一个默认值(数组非空、影响力可以全 0、id 相同即同一对象),而这个默认值没有被任何一行代码或断言显式守住。
换句话说,我的 bug 不是「算法写错了」,而是「对边界的理解,停留在我自己的脑子里,没有落到规格和代码上」。这也正是为什么我后来在测试上重金投入 StateSnapshot 和确定性边界构造——正确性不能靠「我以为」,得靠「我验过」。
这单元我确实用了大模型(Claude Code)来辅助开发,想认真聊聊这段体验——它带来的不全是爽感,还有一点不安。
规格驱动和大模型的契合度高得有点反常。原因在第一节就埋好了:当规格写好,「做什么」已经被人类彻底设计完了,剩给大模型的只是「实现层」——把确定的工作流翻译成确定的算法。 这正是大模型最稳的活:边界清晰、有标准答案、不需要它去揣摩业务意图。
代价是:它的发挥空间也被规格框死了。 但在工程上,这种「被框死」恰恰换来了稳定——它很难跑偏,因为没有可以跑偏的余地。
会,但坑往往不在「单个方法」里,而在「全局视野」里。我观察到两点:
new ArrayList 然后线性查找,因为单看这个方法毫无问题;但它看不到这个容器会被另外五个高频查询反复扫——它缺的不是算法知识,是全局上下文。List 保序,B 代理在另一处假设了 Set 去重,谁也没看到对方的容器决策,最后在集成层撞车。这本质上和第一节说的多人协作 bug 是同一个病——信息差,只不过这次「多人」换成了「多个 agent」。所以我后来倾向于:架构和容器选型由人(或单一主上下文)统一拍板,让大模型只在被约束好的实现层里干活。让大模型写单测,和人设计单测有本质区别。人写单测是「带着假设去证伪」——我怀疑并列排序会错,所以专门去构造并列;我怀疑 pure 会被破坏,所以去拍快照。这是一种对抗性、目标驱动的思维。
大模型默认的单测更像「覆盖性铺开」——它能很快把 happy path、各条异常路径都铺满,覆盖率好看,但不会主动去想「哪个边界最阴险」,除非你明确把对抗目标告诉它(「去构造分数并列的 case」「验证异常路径下的 pure」)。所以我的用法是:对抗性的测试设计由我出(我决定要打哪些角),机械的脚手架铺设交给它。
这单元写到后面,我有一种很微妙的失落:编码任务仿佛被 AI 整个端走了。 规格是给定的,实现被大模型补全,我的角色不知不觉从「写代码的人」滑向了「提需求、定边界、验收结果的产品经理」。
而最值得警惕的,是那种「我还自诩懂技术」的错觉——因为代码能跑、测试能过,我会误以为自己掌控了一切。可实际上,真正决定成败的东西已经悄悄上移了:不再是「我会不会写这段代码」,而是「我能不能定义清楚边界、能不能设计出有效的工作流、能不能验出大模型没替我想到的那个角」。
所以这单元给我最大的方法论收获,恰恰不是某个算法,而是工作流本身的重要性:
大模型没有取代工程师,它只是把工程师的价值,从「实现层」整体上移到了「规格层与验证层」。 看不清这一点的人,才会变成那个「自诩懂技术」的产品经理。
第二次研讨课的 JML「击鼓传花」,把第一节那个抽象的道理,变成了一次可被亲眼看见的实验:同一份需求,在一次次「自然语言 → JML → 理解 → 再表述」的传递中,边界会一点点漂移,而每个人都觉得自己传的是「原话」。
回到那个冒泡排序的例子:「给我一个非空数组排序」。
requires;signals」。需求没变,但「非空」这个边界的归属,在传递中从「调用方义务」漂成了「实现方义务」又漂成了「异常行为」。 单机时三种写法都能跑对,多人集成时——那个没人真正负责的边界,就是 bug。
今后多人组队编程,怎么统一所有人对需求与实现的理解、减少组内信息差? 这单元给我的答案是:
requires)」还是「实现方检查并抛异常(signals)」,不留「反正不影响运行」的灰色地带——因为灰色地带在多人协作里一定会被两个人朝相反方向理解。equals 的判等语义、getHeat 的公式这种被多处依赖的底层约定,必须全组对齐、集中定义,不允许各写各的。第三单元教给我的,表面上是 JML 和契约式设计,骨子里却是一件更朴素的事:
把「我以为」变成「我写下了、并且我验过了」。
无论对面坐着的是队友、是助教的强测数据、还是一个上下文有限的大模型——所有的 bug,几乎都诞生在那个「大家心照不宣、却谁也没真正写下来」的边界上。规格的意义,就是不给这种心照不宣留任何空间。
而当大模型把实现层整个接管之后,工程师真正的护城河,也正落在了这件事上:你能不能把边界定义得足够清楚,让机器无法、也无需揣测。