305
社区成员
发帖
与我相关
我的任务
分享JML是一种形式化的规格描述语言,它用数学化的方式精确描述一段代码"应该做什么",而不关心"怎么做"。
规格驱动开发的核心思想是:先写规格,再写实现。规格就像是代码的"合同"——它规定了方法的前置条件(requires)、后置条件(ensures)、副作用范围(assignable)以及异常行为(exceptional_behavior)。实现者只需要保证自己的代码满足这份合同,而不需要猜测需求。
在本单元的三次作业中,课程组给出了完整的JML规格,我们按照规格来实现一个视频社交网络。这个过程让我体会到JML的几个优点:
精确性:自然语言描述需求很容易有歧义,但JML用数学量词(如\forall、\exists、\num_of)把需求的边界写得很清楚。比如queryAgeRatio中四个年龄段的划分方式,JML直接把区间写死了,没有任何模糊空间。
可验证性:规格本身可以作为测试的参照。如果一个方法的行为和JML描述的不一样,那一定是代码写错了。本单元我们写JUnit测试时,很多测试用例就是直接从JML的后置条件翻译过来的。
解耦合:JML只描述行为,不限制实现。以收到的视频列表(receivedVideos)为例,JML用int[]的模型字段来描述它,但实际实现时,我用的是双向链表+HashMap的混合结构——只要对外表现和JML一致,怎么实现都可以。
但JML也有不足。复杂的\forall和\exists嵌套读起来很费劲,尤其是queryShortestPath和queryLongestDecSeq的规格,读半天才能理解在说什么。另外JML本身不能完全覆盖非功能性需求,比如时间复杂度——JML不会告诉你某个操作必须在O(1)完成,但这恰恰是实际程序能否通过测试的关键。
总的来说,规格驱动开发让"需求"变得可执行、可验证。比起传统的"读文档->揣摩意图->写代码"流程,规格驱动的优势在于需求是精确的,实现者不需要猜测。
JUnit单元测试主要集中在几个关键方法上,比如queryMutualFollowingSum的正确性和"纯性"(多次调用结果不变且不修改状态),以及recommendNthUp的排序逻辑、排除自身和已关注用户、异常抛出等。写这些测试时,基本思路是:构造一个特定的场景 -> 调用方法 -> 断言结果和JML的后置条件一致。
几点经验:
从JML直接翻译测试用例。JML的ensures子句基本就是一个测试断言的模板。比如规范里说\result.size() == min(receivedVideos.length, 5),那测试就直接验证返回列表的大小不超过5。
测试"纯性"很重要。很多查询方法在JML里标注了assignable \nothing,意味着不能修改任何状态。我专门写了测试——在调用前后分别对整个系统状态拍快照,然后逐个字段比较,确保没有副作用。这类测试帮我发现了几个意外修改了状态的bug。
关注异常路径。JML中exceptional_behavior部分定义的异常抛出顺序也很重要。比如addUser要求先检查EqualUserIdException再检查InvalidAgeException,如果顺序反了,即使在功能上看起来没区别,测试也会挂掉。
三次作业的功能迭代大致是:
迭代中最容易出问题的地方是:新需求改变了旧方法隐含的"前提条件"。
最典型的例子是hw10新增了forwardVideo(转发)功能。在hw9中,一个视频只会出现在receivedVideos中一次——因为视频只能由上传者推送给粉丝。但hw10的转发机制意味着:同一个视频可以被不同的人多次转发给同一个用户,导致receivedVideos中出现重复视频。
这个变化不是JML直接告诉你的——JML只在forwardVideo的规格里写了它会往follower的receivedVideos里加视频,但是没有明确说"需要注意重复"。如果你没有仔细推演这个操作对之前方法的影响,就很容易踩坑。
我觉得每次新增一个会修改共享状态的方法时,要追溯所有读取或依赖那个状态的方法,检查在新场景下是否还正确。具体来说,就是在forwardVideo加了之后,重新审视watchVideo(看视频时删除receivedVideos中的记录)——如果列表里有重复,删除一个而不删另一个,残留的视频就会一直留在列表里。
首先,JML里没有性能要求,所以光看规格发现不了性能问题。但课程组的测试数据规模很大,性能瓶颈最终会以"超时"的形式暴露出来。
我的做法是:
分析每个方法的时间复杂度。对于高频调用的方法(如followUser、watchVideo),如果实现是O(n),当用户量上到几千甚至上万时就会出问题。
用增量维护替代全量计算。hw9初次实现时,queryUpFollowersAgeRatio每次都要遍历所有followers来统计四个年龄区间的数量,这是O(n)的。后来改成在addFollower和removeFollower时增量更新ageBuckets[4]数组,查询直接读数组值,变成了O(1)。同样,mutualFollowingSum也是增量维护,避免每次查询都O(n²)遍历。
选择合适的数据结构。hw9最初大量使用ArrayList存储receivedVideos,添加是O(1)但删除是O(n)。后来改成了双向链表+HashMap索引的结构——链表保证顺序迭代(取前5个),HashMap保证O(1)查找和删除。这个改动对性能影响很大。
使用缓存/记忆化。queryLongestDecSeq用DFS+备忘录,避免对同一节点重复搜索。BFS最短路径在找到目标后立即返回,不遍历整张图。
现象:在大量watchVideo操作时程序超时。
原因:receivedVideos最初用ArrayList实现。添加视频是O(1)(加到末尾),但看视频时要删除指定视频,ArrayList.remove(Object)需要先遍历找到元素位置,再移动后续所有元素,整体O(n)。当粉丝数量多、视频数量大时,频繁的watchVideo操作导致总时间复杂度到了O(n²)级别。
修复:改为双向链表+HashMap索引的混合结构。HashMap<Integer, VideoNode>保存每个视频ID对应的链表节点引用,删除时O(1)定位、O(1)摘链。查询前5个视频时直接从链表头向后遍历最多5步。
教训:选择容器时不能只看写代码是否方便,要考虑实际的使用模式。JML用int[]描述receivedVideos,但这只是行为模型,不代表实现上也用数组——理解规格和实现之间的"自由度"很重要。
现象:watchVideo之后,用户的未看视频列表中仍然残留已经看过的视频。
原因:这个bug的链条比较长:
forwardVideo,用户A可以把同一个视频多次转发给用户B(或者不同人分别转发给B),导致B的 receivedVideos 中出现重复的视频ID。removeReceivedVideo 方法通过 HashMap 定位到一个节点后只删除那一个,列表里其余重复节点就残留下来了。receivedIndex 的 HashMap 会被新的节点覆盖(同一个key),导致旧的节点无法通过 HashMap 直接访问,变成"孤儿节点"。修复:将 removeReceivedVideo 改为遍历整条链表,删除所有匹配的节点,而不只是 HashMap 指向的那个。
教训:新增功能时,不能只验证新功能本身是否正常,还要推演新功能对已有状态的影响。转发机制打破了"每个视频在receivedVideos中只出现一次"这个在hw9中隐含成立的前提,而这个前提之前写 removeReceivedVideo 时可能根本没有意识到。
在本单元中我使用了Code Agent辅助编程。主要体会:
优势:JML规格是形式化的,大模型能比较准确地理解JML的语义并翻译成Java代码,减少了手动翻译规格的时间。在写工具类方法(如cleanSpamComments这种纯字符串处理逻辑)时,直接给模型JML就能生成基本可用的代码。
潜在问题:大模型在根据JML编程时确实会忽视效率问题。模型倾向于选择最直接的实现方式——比如用ArrayList而不会主动考虑双向链表优化。在架构和容器选择上,模型也不会主动做"增量维护"这种优化决策。所以用大模型写JML实现时,需要人工检查性能。
用于单元测试:我尝试过让模型根据JML的ensures子句生成JUnit测试用例,效果还可以。模型能准确地把\result == ...翻译成assertEquals,把\forall翻译成循环断言。但对于需要构造复杂前置场景的测试,还是需要人工参与设计测试数据。
在第二次研讨课上,我们组玩了JML版的"击鼓传花"游戏:一共五个人,第一个人用自然语言描述一个方法的功能,第二个人只看这段自然语言写出JML规格,第三个人只看JML写出新的自然语言描述……以此类推,每人只能看上一人的输出。最后比较最初的描述和最终描述之间的语义差异。
我们在游戏中观察到一些有意思的现象:
语义漂移有时大有时小。如果初始描述的是一个边界清晰的方法,经过五轮传递后语义基本没变。但如果是复杂逻辑,每一轮传递都会丢失或扭曲一些细节,到最后虽然大方向还在,但关键的约束条件就丢了。
JML环节是信息损失最大的环节。自然语言->JML这一步,写JML的人如果对规格语言不熟练,可能会遗漏一些自然语言中隐含的边界条件。反过来JML->自然语言这一步也一样,读JML的人可能没有完全理解\forall和\exists嵌套表达的含义。
没有人"故意"犯错,但传递还是会失真。这说明问题不在于个人能力,而在于信息载体本身的表达能力有限。自然语言有歧义,JML有理解门槛,每一层转换都有信息熵的损失。
结合本单元的三次作业迭代,我对"多人协作时如何统一理解"有了以下思考:
形式化规格确实有用,但不能只有规格。JML精确但不够直观,需要配合有代表性的示例和边界案例一起传达。就像课程组给我们的JML之外还有指导书——两者结合才能准确理解需求。
关键的不变量和边界条件要显式标注。比如"receivedVideos中是否允许重复"这个问题,如果在协作之初就明确写出来,后续很多bug都可以避免。在团队协作中,一个共享的"约束文档"(记录所有跨方法的隐含前提)可能比JML本身更实用。
交叉验证。一个人写实现,另一个人根据JML写测试,两个人在不沟通的情况下各自理解规格,最后看测试是否通过。如果测试挂了很多,说明规格本身有歧义或两个人理解不一致——这和击鼓传花的原理一样。
减少传递层级。游戏展示了多层级传递的放大效应。在真实协作中,应该尽量让所有人直接面对原始需求文档(JML+指导书),而不是通过某个成员转述。