1. 我对 JML 与规格驱动开发的理解
1.1 JML 的价值:把“应该怎么做”写成可检查的格式
在 Unit3 的三次作业中,JML 不只是注释,而是把一个方法拆成了几类信息:
- requires:调用方必须满足的前置条件;不满足时行为未定义(或由 exceptional_behavior 规定抛异常)
- ensures:方法返回后必须满足的后置条件;是我们判断“实现对不对”的核心依据
- signals:异常路径的契约——什么时候必须抛出哪个异常
- assignable:允许修改的状态边界;尤其是
assignable \nothing / pure,直接决定方法是否允许有副作用
这在图结构、容器维护里非常关键:很多“看起来返回值对”的错误实现,实际偷偷修改了容器,导致后续查询异常。
对我而言,JML 的最大变化是:以前写代码靠经验和直觉,出了 bug 再修;现在写代码时会先问:
这个方法是否允许改变状态?允许改变哪些对象的哪些字段?边界条件在哪里?异常触发条件是否互斥且覆盖完整?
1.2 requires/ensures/assignable 三件套如何指导实现
以 recommend_Nth_up(userId, rank) 为例:
requires containsUser(userId) && rank > 0 && videos.length > 0 ...ensures 限定返回的 up 必须是“未关注集合中的第 rank 名”(通过 computeUpScore 和 tie-break id 来定义)assignable \nothing / pure 意味着:推荐只是查询,不能在内部为了“加速/缓存”偷偷改动任何用户状态。
2. JUnit 测试经验总结:从“测结果”到“测规格”
2.1 只测返回值远远不够:要测 JML 的全部内容
在这类规格驱动作业里,很多错误代码能做到:
- 返回值偶尔对
- 或者在某些数据上对
- 但违反了 pure/assignable,破坏了系统状态
因此我在写 JUnit 时遵循一个原则:
对每个方法,测试至少覆盖:requires/signal、ensures、assignable/pure。
以 recommend_Nth_up 的单元测试为例:
- signals:
UserIdNotFoundException / InvalidRankException / NoVideoUploadedException / ColdStartUserException - ensures:候选集合过滤正确、排序正确、rank 正确、tie-break 正确
- pure:调用前后
Network 内所有用户状态、视频状态、互关数、视频容器可见性都不应改变
其中“pure 检查”是最容易被忽视、但最容易筛掉错误实现的一类。
2.2 如何检查 pure/assignable:快照对比与 strictEquals
这次 JUnit 评测给了两个“辅助接口”:
User.strictEquals(UserInterface other):对用户的基本字段与容器状态做严格相等判断Network.getUsers():返回浅拷贝,便于遍历所有用户做快照
我的做法是“双重检查”:
- strictEquals 快速判定:调用前后,对每个用户
before.strictEquals(after) 必须为真 - 可观察属性逐项快照对比:补充检查 id/name/age/coins、following/followers、received/watched/liked/medals、interest/influence 等
这样能防止 strictEquals 被错误实现“漏字段”的情况。
这种测试模式的收益是:能抓住很多“输出正确但破坏状态”的 bug。
2.3 测试用例设计:随机 + 结构化边界
我的经验是不要只写固定样例,因为容易被错误代码“蒙对”。更可靠的策略是:
- 结构化边界样例(必测)
- rank = 0 / rank < 0
- videos.length = 0
- 未关注候选不足 rank
- tie-break(同分时选 id 更小)
- 随机生成图与数据(强测)
- 随机关注图(避免只测链/只测稀疏)
- 随机上传视频(保证 videos.length > 0)
- 随机 userId/rank(在合法范围内)
因此我尽量使用接口可见信息;确实需要 totalVideos 时,再用反射+兼容(List/数组/Collection/Map)方式获取。
3. 三次作业迭代过程:如何发现容器/方法变化?如何定位性能瓶颈?
3.1 迭代的核心变化:状态越来越多、规格越来越细
三次作业的共同特点是:
- 前一次写的容器结构(users、videos、关系表)在下一次往往要新增维护项
- 新功能(推荐、影响力、profile)会强迫我们回头补齐旧方法的“状态维护”
例如 spec3 引入:
types/typeCounts:watchVideo 需要更新分区观看计数User.videos:uploadVideo 需要把视频加入 uploader 的发布列表 这些不是新增接口方法,而是“老方法必须额外维护的新状态”。
如何发现变化?
我采用“从规格反推状态”的方法:
- 每次升级先扫一遍 JML 的
assignable 和 ensures - 只要
ensures 出现了 \old(...) 对某个字段的增量关系,就说明这个字段必须被维护 - 若某字段被写在
invariant 里,则所有 public 方法都必须保证它始终成立
3.2 性能瓶颈定位:从 O(n²) 扫描到索引 + 邻接表
在社交网络这类图结构中,最常见的性能问题是:
- 用
ArrayList 做查找:getUser(id) 每次 O(n) - BFS/DFS 中对每个点扫全体 users:导致 O(n²) 或更高
我曾遇到的典型慢点:
containsUser/getUser 线性扫描导致互测大数据超时
解决:引入 Map<Integer, UserInterface> userByIdqueryShortestPath BFS 每次出队都扫 users 再 isFollowing
解决:遍历 followingArray() 邻接表(复杂度从接近 O(n²) 降为 O(n+m))queryLongestDecSeq DFS 每次扫 users 判断关注关系
解决:同样改为只遍历 following 邻接表 + memo
定位方法上,我主要靠:
- 看调用频率(Runner 输入规模大时,contains/get 会被调用非常多)
- 看循环嵌套结构(“循环里再扫整个 users”是危险信号)
- 必要时加临时计数/日志(本地测)确认热点
4. 我遇到过的 bug 与原因分析(可替换成你自己的真实经历)
4.1 “容器索引不同步” bug:new 了两次对象
在把 users + userById 同时维护时,一个非常隐蔽的错误是:
users.add(new User(...))userById.put(id, new User(...))
这样会导致同一个 id 对应两个不同对象:
- follow/watch/coins 等状态在两个对象间分裂
- 查询结果和状态快照对不上
- 某些方法遍历 users,某些方法 map.get,表现为“偶现错误”
原因本质是:索引表与实体存储必须指向同一对象引用。
正确做法:只 new 一次,然后同时放入 list 和 map。
4.2 “pure 方法偷偷改状态”的 bug:缓存/排序写回导致后续错
一些错误实现为了加速 recommend,会把排序结果存入成员变量,甚至修改用户内部容器。短期看推荐结果可能对,但违反 assignable \nothing,后续查询会错。
这类 bug 光测返回值很难抓,必须用 pure 快照对比。
4.3 “tie-break 忘记按 id” bug
规格常见的是:
如果实现忘了 tie-break 或方向写反,会在大部分随机数据上隐藏(因为同分概率不高),但在强测中会被专门构造出来卡死。
因此我在 JUnit 中专门构造“同分但不同 id”的候选集,并检查 rank-1 条件。
5. 大模型/Code Agent 在规格驱动开发中的作用(我的体会)
5.1 优势:快速把规格拆成“需要维护的状态清单”
大模型特别适合做:
- 从一段 JML 中提取“有哪些状态要维护”“哪些方法必须更新哪些字段”
- 从 ensures 中推导测试断言(例如
rank-1 的定义) - 生成覆盖 signals/ensures/pure 的测试模板
5.2 风险:容易忽视架构与复杂度
但也确实存在风险:
- 模型可能给出“可用但慢”的实现(例如 BFS 扫全体 users)
- 或者引入反射/临时容器导致副作用(违背 pure)
我的经验是:用大模型生成初版后,必须自己做两步复查:
- 检查是否满足 assignable/pure(是否写回成员变量、是否修改容器)
- 检查复杂度(是否出现 n² 扫描、重复线性查找)
6. “规格传声筒(JML Telephone)”研讨课游戏感悟
6.1 我发现的常见“规格丢失/畸变点”
在 Round 1(NL)到 Round 6(最终 JML)的传递中,我观察到最容易丢的是:
- 边界条件丢失
例如 NL 里写了 “size==0 返回空数组”,到后面 JML 可能只剩 “返回某集合” 而没写空情况。 - 异常条件互斥关系被破坏
NL 中先判断 A 再判断 B;传递后可能变成 A/B 同时触发,导致实现者不知道优先级。 - assignable/pure 被忽略
很多人写功能规格时不写“能不能改状态”,最终实现就会随意缓存/修改。 - 过度设计
某一轮的人“为了严谨”加了额外限制(例如限制数组长度、限制输入范围),最后变成需求畸变。
6.2 我是否发现了自己/别人的 JML bug?
是的,最典型的是:
ensures 写成了“存在某个元素满足条件”,但其实应是“所有元素满足条件”(\exists vs \forall)- 没写清 tie-break(同分时如何选)
- signals 条件覆盖不全(漏了某一类非法输入)
6.3 多人组队开发如何减少信息差?
我认为最有效的措施不是“多开会”,而是把规格写成统一的、可审查的产物:
- 规格先行 + 规格评审
开始编码前,组内先对 JML/NL 进行 review:边界、异常、assignable 必须明确。 - 明确异常优先级规则
例如:先抛 UserNotFound,再抛 NoVideo,再抛 ColdStart。所有人遵循同一优先级。 - 状态维护清单(Checklist)
每次迭代列出:哪个老方法必须更新哪些字段(例如 watchVideo 必须更新 typeCounts)。 - 测试作为“共同语言”
把关键 JML 条款写成单元测试断言;测试通过就说明大家对规格理解一致。 - 禁止“隐藏缓存”
对纯方法明确约定:不允许写成员变量缓存;若要缓存必须写入规格并说明 assignable。