305
社区成员
发帖
与我相关
我的任务
分享第三单元的核心并不是“多写几个容器类”,而是训练我们从规格出发进行开发。JML 给出的 requires、ensures、signals、assignable、pure 等约束,把一个方法应该做什么、不应该做什么、异常何时发生、哪些状态可以被修改都写成了相对明确的契约。
在前两个单元中,我更多是从输入输出和自然语言描述出发写程序,而第三单元要求我先阅读接口规格,再决定数据结构和实现策略。这个过程让我意识到:规格驱动开发中,代码不是第一位的,需求边界才是第一位的。比如一个查询方法如果标注了 pure,那么它不仅要返回正确值,还不能悄悄修改缓存、列表顺序或对象状态。再比如一个方法的异常顺序也不是随意的,必须严格符合 JML 中不同 signals 条件之间的优先级。
我对 JML 的理解主要有三点:
JML 是方法行为的精确定义
它把自然语言中容易产生歧义的部分形式化。例如“推荐第 N 个 up 主”并不是简单排序,还要明确候选集合、分数相同时的 id 比较、异常何时抛出。
JML 约束的不只是结果,还有过程中的副作用
很多方法看似只要返回值正确即可,但 assignable \nothing 或 pure 实际要求方法调用前后系统状态保持一致。这提醒我在写查询方法时不能为了方便修改容器。
JML 能帮助发现自然语言需求遗漏
当我同时阅读指导书和接口 JML 时,经常能发现仅凭指导书不够精确的地方,比如 tie-break、异常优先级、容器长度变化等。真正实现时,JML 比文字描述更接近评测依据。
规格驱动开发的难点在于:读懂规格本身也需要成本。有时一个方法的 JML 很长,里面嵌套了 forall、exists、sum、num_of 等表达式,直接阅读会很吃力。我一般会先把它翻译成自然语言,再拆成“正常行为、异常行为、副作用、排序规则、边界条件”几个部分,最后再写代码。
第三单元要求为指定方法编写 JUnit 测试,这让我意识到测试不应该只测“正常输入输出”,还应该测规格中的所有关键约束。
我的 JUnit 测试经验主要包括以下几点:
测试正常功能,也测试异常路径
对于 recommendNthUp 这类方法,除了测试第 1 名、第 N 名是否正确,还要测试 UserIdNotFoundException、InvalidRankException、NoVideoUploadedException、ColdStartUserException 等异常是否按正确条件触发。
测试 tie-break 规则
很多错误实现不是完全算错,而是在分数相等时没有按 id 最小或指定规则选择。互测中这种数据很容易构造,因此 JUnit 中需要专门设计平分场景。
测试 pure 和 assignable
查询方法调用前后,用户、视频、网络状态都不应该变化。可以利用课程组提供的 strictEquals、getUsers 等辅助方法,也可以手动记录关键字段。这个测试方向比只看返回值更能抓出隐蔽 bug。
测试迭代后的旧功能
第三次作业新增推荐逻辑后,旧方法如 watchVideo、likeVideo、coinVideo 会影响新增字段,例如分区观看次数、视频热度、up 主影响力。因此旧方法也要重新测试,不能只测新增接口。
测试要小而明确
一个测试用例最好只验证一类行为。比如“排序规则测试”“异常优先级测试”“pure 测试”分开写,失败时更容易定位原因。
这三次作业让我体会到,JUnit 不只是为了凑测试文件,而是帮助自己理解规格的工具。写测试的过程其实也是重新审查 JML 的过程。
第一次作业主要实现用户、视频、关注、观看、接收未观看视频、最短路、互相关注数等基础功能。这个阶段最重要的是建立基础容器结构。
我使用了如下数据结构:
LinkedHashMap<Integer, User> 存储用户,便于按 id 查询。LinkedHashMap<Integer, Video> 存储视频。第一次作业让我熟悉了 JML 中“对象是否相等通常按 id 判断”的特点,也让我开始意识到容器顺序、重复元素、浅拷贝等细节的重要性。
第二次作业加入了点赞、投币、转发、评论、清理垃圾评论、勋章、最佳贡献者、最长年龄下降链等功能。
这一阶段的主要变化是:视频不再只是静态对象,而是拥有播放数、点赞数、投币数、转发数、评论列表等状态;用户也增加了金币、勋章、贡献者列表等状态。
我在迭代时重点检查了两个方面:
已有方法是否需要同步维护新增字段
例如 coinVideo 不只是修改视频投币数,还要修改用户金币、up 主金币、贡献者列表和贡献值。
新增查询是否可以增量优化
例如互相关注数、最长下降链如果每次都完全重算,数据量变大时可能成为瓶颈。因此我对部分结果使用了缓存或增量维护。
第二次作业中,我进一步体会到 assignable 的意义。一个方法可能涉及多个对象的字段修改,必须严格控制修改范围,不能因为方便而顺手改了无关状态。
第三次作业加入了智能推荐视频、推荐 up 主、用户画像、最有影响力 up 主、全局最佳贡献者等功能。相比前两次,这次的难点在于新增功能依赖旧功能产生的数据。
例如:
watchVideo 会影响用户在对应分区的观看次数。likeVideo、coinVideo、forwardVideo、watchVideo 会影响视频热度。因此第三次作业不能只“新增几个方法”,而要重新审查所有旧方法的副作用。我采用的方式是:
性能方面,我重点关注了推荐 up 主和影响力查询。如果每次推荐都对每个 up 主重新遍历其所有视频计算影响力,复杂度会较高。我的做法是给用户维护各分区影响力缓存,视频热度变化时增量更新 up 主对应分区的影响力。这样推荐时只需要按 7 个分区计算分数,不需要反复扫描视频列表。
我总结出一个比较有效的方法:每次迭代不要先写新增方法,而是先对比新旧接口。
具体做法如下:
typeCounts 的来源是观看视频,videos 的来源是上传视频,influence 的来源是视频热度。assignable \nothing。这种方法能避免“新增方法写对了,但旧方法没有维护新状态”的问题。
第三单元的数据规模不算特别大,但如果完全按 JML 暴力实现,仍然可能超时。
我发现性能瓶颈主要靠三种方式:
从复杂度上预判。
如果一个查询方法每次都要遍历所有用户、所有视频,甚至嵌套遍历,就要警惕。比如推荐 up 主如果对每个候选 up 都重新扫描其全部视频,就可能在多次查询时变慢。
关注高频查询。
有些方法虽然单次不慢,但指令中可能大量出现。例如 queryMutualFollowingSum、recommendNthUp、queryMostInfluentialUp 等都可能被重复调用。
构造极端数据测试。
可以构造大量用户、大量关注关系、大量视频,再连续调用查询方法。虽然本地测试不一定完全模拟评测机,但能帮助发现明显的 O(n^2) 或 O(nm) 问题。
我的优化思路主要是:
rank 的堆,而不是每次全量排序。第三次作业中,我的程序曾经出现代码风格扣分,平台显示 Indentation 有 7 处问题。后来助教提醒检查 lambda 表达式,我定位到 PriorityQueue 比较器中使用了 lambda:
new PriorityQueue<>(rank,
(first, second) -> {
...
});
这段代码在我本地使用某些 Checkstyle 配置可以通过,但评测端对 lambda 换行缩进更敏感,导致缩进被扣分。最后我将 lambda 改成普通的内部比较器类,避免不同工具版本对 lambda 缩进解释不一致。
这个 bug 的原因是我过于相信本地检查结果,没有意识到 Checkstyle 的版本、配置路径、扫描范围都可能影响最终结果。之后我会尽量避免在课程作业中使用格式容易产生争议的写法,例如复杂 lambda、多层链式调用等。
第三次作业新增推荐系统后,旧方法的副作用变多。例如 watchVideo 不仅要增加播放数,还要更新用户分区观看次数;likeVideo、coinVideo、forwardVideo 会改变视频热度,进而影响 up 主影响力。
这类 bug 的根本原因是迭代时只关注新增方法,而没有从新增 model 字段反向检查旧方法。解决方式是每次迭代都建立“字段来源表”,明确每个字段由哪些方法维护。
研讨课中的“JML 击鼓传花”让我印象很深。每个人先写一段需求或规格,再交给别人理解和实现。在这个过程中,我明显感受到:需求传递一次,信息就可能损失一次;边界条件如果没有写清楚,后面的人就会按照自己的理解补全。
我发现 JML 中最容易出 bug 的地方主要有:
assignable,导致实现者不知道哪些状态可以修改。有些 JML 看起来描述了返回值,但没有描述容器长度变化、元素顺序、是否允许重复等细节。实现者如果根据自己的直觉写,就可能和出题者预期不一致。
我认为确实会发生变化。需求在传递过程中,经常从“明确规则”变成“个人理解”。比如“推荐最合适的对象”如果没有明确评分公式、候选范围和 tie-break,那么不同人会实现出完全不同的版本。再比如“删除元素”如果没有说明删除一个还是所有、顺序是否保持,也会产生歧义。
需求边界不能靠口头补充,也不能靠“大家应该懂”。只要没有写进规格,后续实现者就可能产生不同理解。
这次研讨课让我认识到,规格的价值不只是约束个人代码,更重要的是帮助团队建立共同语言。多人协作中,代码可以重构,算法可以优化,但需求理解一旦不一致,后面所有工作都会变得低效。
Unit3 让我从“面向输入输出编程”逐渐转向“面向规格编程”。JML 让方法行为更加精确,也让我意识到正确性不仅包括返回值,还包括异常、状态变化、副作用和性能。
三次作业的迭代过程也让我体会到,软件开发不是简单地不断加功能。每次新增需求都会影响已有结构,旧方法也可能需要更新。如果没有系统地分析新增字段、容器关系和性能瓶颈,就很容易在迭代中埋下 bug。
JUnit 测试、复杂度分析、代码风格检查、需求文档和团队沟通,这些看起来都不是“写功能代码”,但它们共同决定了程序能否稳定、可维护地演进。第三单元的训练虽然细节很多,但也让我更接近真实的软件开发流程。