305
社区成员
发帖
与我相关
我的任务
分享第三单元的三次作业围绕一个在线视频平台进行展开:第一次从用户、关注关系、视频接收与观看开始;第二次加入硬币、点赞、转发、评论和粉丝勋章;第三次进一步加入基于用户兴趣和 UP 主影响力的推荐系统。和前两个单元相比,这一单元最大的变化是:代码不再从“我觉得应该这样做”出发,而是从 JML 规格出发,先理解方法的前置条件、后置条件、异常行为和可修改范围,再决定数据结构和实现方式。
JML 作为约束实现的规范,它把“方法能在什么状态下被调用”,“正常返回时必须满足什么性质”,“异常何时抛出”,“方法能修改哪些对象”等内容写在接口旁边,我们需要做的是把这份规范翻译成程序,而不是重写需求。
我对 JML 的理解主要有三点。
requires 和 signals 决定了异常优先级。比如第三次作业中 recommendNthUp(userId, rank) 的异常顺序不能凭直觉写,而要按照 JML 的条件判断:if (!containsUser(userId)) {
throw new UserIdNotFoundException(userId);
}
if (rank <= 0) {
throw new InvalidRankException(rank);
}
if (videos.isEmpty()) {
throw new NoVideoUploadedException();
}
这类代码看似机械,但它直接对应规格中的 exceptional behavior。JUnit 测试里我也专门构造了“用户不存在且 rank 非法”的情况,检查是否优先抛出 UserIdNotFoundException。
ensures 不只是返回值公式,还包含边界条件和 tie-break 规则。recommendNthUp 的结果不是“分数最高的某个 UP 主”,而是严格满足:候选人不能是自己,不能已经被关注,并且排在结果前面的候选数量正好是 rank - 1。因此同分时要按 id 小者优先。我的实现中用一个大小为 rank 的优先队列维护当前前 rank 名:PriorityQueue<UpCandidate> topRank = new PriorityQueue<>((a, b) -> {
if (a.score != b.score) {
return Long.compare(a.score, b.score);
}
return Integer.compare(b.id, a.id);
});
这里堆顶是当前前 rank 名中最弱的候选人。如果新候选更强,就替换堆顶。这样既满足排序规则,也避免每次完整排序。
pure 和 assignable \nothing 要求实现者关注“有没有偷偷改状态”。例如推荐方法看起来只是在查询,但如果为了缓存临时结果而修改了用户画像、视频热度或关注关系,就违反了规格。这部分也是Junit测试的重点,因此我在测试中不仅检查返回值,也会比较调用前后的用户状态、视频状态、互关总数和全局贡献者信息。规格驱动开发带来的好处是:需求被拆成了很多可验证的小条件。缺点是:读规格的成本很高,尤其是嵌套量词、\old、\num_of、\max 混在一起时,容易只看懂大意而漏掉边界。
三次作业不是简单地增加方法,而是在原有模型上不断增加状态和约束。我的整体设计可以概括为:
classDiagram
class Network {
Map~Integer, UserInterface~ users
Map~Integer, VideoInterface~ videos
Map~String, VideoInterface~ bestVideos
int mutualFollowingSum
int longestDecSeqCache
recommendVideo()
recommendNthUp()
}
class User {
int id
String name
int age
int coins
Map followings
Map followers
Set watchedVideoIds
Set likedVideoIds
int[] typeCounts
Map uploadedVideos
computeUpScore()
}
class Video {
int id
int uploaderId
String type
int playCount
int likes
int forwardCount
int coins
List commentIds
List commentContents
getHeat()
}
Network "1" --> "*" User
Network "1" --> "*" Video
User "1" --> "*" Video : uploaded/watched/liked
第一次作业的核心是社交网络和视频流转。此时最重要的是关注关系、粉丝关系、收到但未观看的视频、互关数量和最短路。这个阶段我开始意识到,JML 中的“集合关系”如果直接用数组反复遍历,功能上能做出来,但性能会很脆弱。因此我在实现中用 HashMap<Integer, UserInterface> 和 HashMap<Integer, VideoInterface> 做主容器,用 id 作为索引。
第二次作业加入互动系统后,原来的 User 和 Video 都变重了。User 需要记录硬币、观看记录、点赞记录、勋章和贡献者;Video 需要记录播放量、点赞数、转发数、投币数和评论区。这个阶段最需要注意事务性操作,例如投币不是单纯修改一个视频,而是同时影响用户余额、视频硬币数、UP 主余额和贡献记录。只要异常判断顺序或中途修改时机不对,就可能出现“抛异常但状态已经变了”的问题。
第三次作业新增推荐系统后,前两次积累的数据变成了推荐算法的输入。User 新增了 typeCounts 和投稿视频集合,getInterest(type, totalVideos) 用来表示用户对分区的兴趣,getInfluence(type) 用来表示 UP 主在某分区的影响力,二者组合成推荐 UP 主的评分
这次迭代中,我感受到前期容器设计会直接影响后期扩展。如果一开始只用数组存所有对象,第三次作业里很多查询和推荐都会变成高频全表扫描。虽然本次数据规模不算特别大,但设计时仍需要区分“状态真实来源”和“可增量维护的缓存”。
我主要通过三种方式发现变化。
第一是对比指导书中的 Modify 和新增指令。比如第二次作业中 upload_video 的构造参数从 (id, uploaderId) 变成 (id, uploaderId, type),这意味着 Video 的身份信息不再只有上传者,还要参与分区查询和热度排行。第三次作业中 query_user_profile、recommend_video、recommend_Nth_up 的出现,则提示我用户侧必须维护观看分区统计,否则每次计算兴趣都要遍历观看历史和全体视频。
第二是读官方接口里的 JML,而不是只读指导书。指导书说明业务背景,JML 才说明判定标准。例如 cleanSpamComments 不仅要求删除包含 keyword 的评论,还要求返回两个数:删除数量,以及被删除评论中 keyword 出现次数的最大值。这里的出现次数是通过所有起点 j 的 substring 匹配计数,因此重叠出现也要算。
第三是从测试目标倒推可观察状态。每次作业都要求给某个方法写 JUnit,这说明该方法容易出现边界错误。第一次是互关总数,第二次是评论清理,第三次是第 N 个 UP 主推荐。围绕这些方法,我会额外检查调用前后状态是否变化,因为 JML 中的 pure、assignable 往往是隐藏的易错点。
本单元的性能瓶颈通常来自两个地方:一是高频查询中反复遍历全体对象,二是迭代后状态越来越多,导致一次操作牵连多个容器。
第一次作业中,queryMutualFollowingSum 如果每次都双重遍历所有用户,就是 O(n^2)。我的实现选择在 followUser 和 unfollowUser 中动态维护 mutualFollowingSum。当一条关注边加入后,如果反向边已经存在,互关总数加一;删除时同理减一。这样查询变成 O(1)。
第二次作业中,queryMostPopularVideo(type) 如果每次遍历所有视频,会随着视频数增加而变慢。我维护了 bestVideos,在播放、点赞、转发、投币等热度增加时增量更新;当取消点赞导致当前最热视频热度下降时,再只对该分区重新扫描。
第三次作业中,recommendNthUp 的朴素做法是收集所有候选 UP 主后完整排序,复杂度约为 O(n log n)。我用大小为 rank 的优先队列保存当前最优的前 rank 个候选,复杂度变为 O(n log rank)。当 rank 较小时,这种写法更贴近需求。
flowchart TD
A[遍历所有用户] --> B{是否为自己或已关注}
B -- 是 --> C[跳过]
B -- 否 --> D[计算 computeUpScore]
D --> E{堆大小 < rank}
E -- 是 --> F[加入候选堆]
E -- 否 --> G{是否强于堆顶}
G -- 是 --> H[替换堆顶]
G -- 否 --> I[保留原堆]
F --> J[继续遍历]
H --> J
I --> J
C --> J
J --> K[返回堆顶 id]
我对 JUnit 的理解从“测几个样例”逐渐变成“测规格”。以第三次作业的 RecommendNthUpTest 为例,我构造了一个专门用于排序的网络:
assertEquals(2, network.recommendNthUp(1, 1));
assertEquals(4, network.recommendNthUp(1, 2));
assertEquals(3, network.recommendNthUp(1, 3));
assertEquals(5, network.recommendNthUp(1, 4));
assertEquals(7, network.recommendNthUp(1, 5));
这个测试不是随机堆数据,而是有目的地覆盖几个风险点:自己不能被推荐,已关注用户不能被推荐,没有投稿的用户仍然是合法候选,同分时 id 小者优先。
异常测试也不能只测“会不会抛异常”,还要测异常类型和优先级。例如用户不存在且 rank 非法时,应该抛 UserIdNotFoundException;用户存在但 rank 非法时,才抛 InvalidRankException。因此我写了 assertThrowsExactly 来检查异常类完全一致。
还有就是副作用检查。对于 pure 方法,我会构造两个完全相同的网络,一个作为 expected,一个作为 actual;调用 actual 的方法后,再比较用户、视频、互关总数、全局贡献者等可观察状态。这样可以抓住返回值对了但偷偷改了状态的错误。
我在实际开发中遇到的一个典型 bug 出现在 cleanSpamComments:从评论中查找 keyword 时,最初没有处理重叠匹配。例如在 abbbc 中查找 bb,如果每次找到后直接跳过 keyword.length(),只能找到一个 bb;但按照 JML 的计数方式,起点 1 和起点 2 都能匹配,所以应该算两个。
错误写法的思路大致是:
index += keyword.length();
修复后的做法是每次只将起点向后移动一位:
while ((index = content.indexOf(keyword, index)) != -1) {
count++;
index++;
}
这个 bug 的原因是对JML语言不熟悉,\num_of int j 实际上是枚举了所有可能的起点。这个例子提醒我:自然语言里的“包含几次”可能有歧义,但 JML 的量词通常没有歧义。
另一个容易出错的点是事务性操作。例如 coinVideo 需要先完成所有异常判断,再修改用户硬币、视频硬币、UP 主硬币和贡献记录。如果先扣硬币再发现视频未观看,就会造成异常路径上的状态污染。我的经验是:复杂方法可以先写成“检查区”和“提交区”,检查区只读状态,提交区才集中修改状态。
在本单元中,我使用 Code Agent 的价值主要体现在三方面。
第一,它适合帮助梳理 JML。对于比较长的 ensures,可以让它先把量词翻译成人话,再反向检查实现是否遗漏条件。比如 recommendNthUp 的候选排除、rank 计数、tie-break 都适合这样拆解。
第二,它适合辅助生成单元测试框架。JUnit 测试里大量代码不是算法本身,而是构造网络、断言异常、比较状态。Code Agent 可以较快写出骨架,但测试数据的意图仍然需要自己设计,否则很容易生成“看起来很多,实际覆盖很浅”的测试。
第三,我认为它不应该替代效率和架构判断。大模型根据 JML 直接写代码时,常见倾向是“按规格逐字遍历”,这样正确性可能比较直观,但容易忽视缓存、容器选择和增量维护。例如 queryMutualFollowingSum 直接双重循环最容易写出来,但不一定适合高频查询。因此使用大模型时,我更倾向于让它做规格解释和测试补充,核心数据结构仍由自己根据迭代趋势决定。
第二次研讨课中的 JML“击鼓传花”游戏给我的感受是:规格一旦经过多人传递,就会出现轻微漂移。我们小组由于部分同学没有到场,一共只有四人,只传递了两轮;这次没有发现明显 bug,需求整体变化也不大,但仍能感觉到每个人对边界条件的关注点不同。
我认为这个游戏最有意义的地方不在于“抓出几个错”,而在于暴露了多人协作中的信息差。一个人写规格时觉得“显然”的条件,另一个人实现时可能完全不会默认它。例如:空字符串是否合法、异常优先级如何排列、同分时如何比较、方法异常退出前能不能产生副作用。如果这些内容只存在于口头解释里,就很容易在传递过程中变形。
今后多人组队编程时,我认为可以采用以下规则减少信息差。
第一,需求必须有唯一的书面来源。无论是 JML、接口文档还是 issue 描述,都应该有一个被大家共同承认的版本,避免每个人根据聊天记录各自理解。
第二,边界条件要显式列成 checklist。比如空容器、重复 id、非法参数、同分 tie-break、异常优先级、异常路径副作用,都应该在评审时逐项确认。
第三,测试样例要和需求一起传递。只传递规格容易产生读懂了但没测到的问题;如果同时传递最小测试、边界测试和反例测试,后续成员更容易理解规格的真实含义。
第四,评审时不要只看正常路径。多人协作中最容易产生分歧的往往是异常路径和副作用范围。尤其是 JML 中出现 pure、assignable \nothing 或 \old 时,需要明确哪些对象允许改变,哪些对象必须保持原样。
第三单元让我真正体会到规格驱动开发的特点:写代码之前,最重要的工作是读懂 JML;写完代码之后,测试也应该回到 JML 规格本身,而不是只验证几个直觉样例。JML 把很多模糊需求变成了可检查的逻辑条件,同时也要求实现者更加严谨地处理异常、边界、副作用和性能等问题。
从三次作业的迭代来看,好的容器设计会在后续扩展中不断产生收益。HashMap 让按 id 查询稳定高效,增量维护让高频查询避免重复计算,优先队列让推荐算法更贴近 rank 查询需求。更重要的是,本单元让我意识到:正确性和性能不是两条无关的线。真正可靠的实现,应该是在严格满足规格的基础上,选择能够支撑后续迭代的数据结构。