309
社区成员
发帖
与我相关
我的任务
分享第四单元要求完成一个图书馆管理系统,并且要在写代码之前先画 UML 类图。听起来好像就是多画一张图的事,但实际做下来才发现,正向建模和逆向建模(先写代码再画图)对自己思维方式的要求是完全不同的。
逆向建模本质上是"翻译"——代码已经在那里了,只是把它的结构画出来。而正向建模要求在没有任何代码的情况下,先想清楚系统里有哪些实体,它们之间是什么关系,哪些操作属于哪个类。这个过程更像是在脑子里构建一个虚拟的世界,然后把它的骨架画下来。图书馆这个题目不算复杂,但涉及的实体挺多:书的 ISBN 号、副本、用户、书架(还分普通和精品)、借还处、预约处、阅览室……一旦开始在类图里排列它们,就会发现很多设计决策需要提前做出,比如"移动轨迹"是存在书副本里还是单独建一个类,"预约状态"是用枚举还是用独立的数据结构来维护。
课程设计了一个两阶提交机制:在代码截止日之前,必须先提交一阶类图,并且一阶类图的截止时间早于代码开始提交的时间。这个机制在刚接触的时候感觉有点烦,但理解了它的意图之后觉得确实有道理。
一阶类图最大的作用是强迫自己在动手写代码之前把架构想清楚。以图书馆的借阅流程为例,如果没有一阶类图,我可能会先写一个 Library 类,然后把借阅逻辑全塞进去,写着写着发现这个类越来越庞大,再去重构就很痛苦了。而画一阶类图的时候,就要提前回答:borrow 这个操作放在哪个类里?是 Library 统一调度,还是 BookShelf 自己处理?这些问题在画图阶段解决,比在代码里纠结要轻松得多。
一阶类图的语义完整性检查也很有意义。评测给出了一组关键词(borrow、return、order、read、grade、treasured bookshelf、reading room 等),要求这些关键词出现在合适的类名、属性名或方法名中。这个检查实际上是在督促设计者把业务语义显式地体现在架构里,而不是只有一个 Library 类然后里面塞一堆 handle1、handle2 之类没有语义的方法。
二阶类图的提交要求它与最终代码保持一致,评测会检查 R2 到 R5,也就是类的属性、方法、继承、实现、关联关系都要对得上。这个要求让我意识到,UML 类图在工程意义上不只是一张示意图,它更像是代码的另一种形式的规格说明。
在实际做的过程中,一阶和二阶之间确实会有一些差异。我在一阶类图里最初没有显式地把 BookCopy 和 ISBN 分成两个类,而是用一个 Book 类混合表示。写代码的时候很快就发现这样不行——ISBN 是图书的"种类",副本是图书的"实体",很多操作的主语是不同的(比如评分针对 ISBN,查询轨迹针对副本),必须分开。二阶类图里做了这个修正,并且顺理成章地把精品书架的判断逻辑归属到了 ISBN 类(因为精品与否取决于 ISBN 的平均分),这在一阶图里是没有想到的。
关于追踪关系,最终的代码与二阶类图的对应情况大致如下:Library 类负责持有所有地点实例并在开关馆时触发整理流程;LibraryManager 作为请求的入口,调用各个地点类的处理方法;BookShelf 和 TreasuredBookShelf 各自管理自己持有的副本集合;AppointmentOffice 维护预约记录和保留期限;User 维护持有书目和当日阅读状态。整体来看,代码结构和二阶类图的吻合度较高,少数差异在于一些辅助方法在代码里存在但类图里没有详细列出——这是规则允许的(代码可以多于类图,但类图不能多于代码)。
第二次作业新增了状态图要求,要对图书副本的状态变化建模。图书副本的位置本质上就是它的状态:普通书架、精品书架、借还处、预约处、阅览室、用户手中。触发状态转移的事件(borrow、return、order、read、restore 等)和代码里带 @Trigger 注解的方法需要一一对应。
设计状态图的过程让我对整理流程的理解变得更清晰。在单纯写代码的时候,整理流程就是一堆 if-else,处理各种书在不同位置之间的移动。但在画状态图的时候,需要把每一条边的 Guard 条件写清楚,比如从借还处到书架的迁移,Guard 条件是"开馆前整理"这个时机触发,而到精品书架的迁移还需要额外满足"对应 ISBN 的平均分 >= 4"。把这些条件显式化之后,回头看代码,反而发现自己之前写的整理逻辑有一处遗漏——阅览室里的书在开馆前整理时也必须先回到书架,这个条件在代码里确实处理了,但最初没有在设计时显式想到。
第四单元做下来,用大模型辅助正向建模的体验是有收获也有坑的。总结一句话:大模型生成"一个合理的架构"很容易,但生成"这个场景下最合适的架构"需要引导。
直接把题目丢给大模型,它能给出一个看起来很完整的类图设计,有 Library、User、Book、各种地点类。但如果仔细看,会发现这种"默认方案"往往把过多的职责堆在 Library 一个类上,而且不会主动区分 ISBN 和副本这两个概念——因为题目的这个核心业务约束需要仔细阅读才能意识到。
有几个方法我觉得比较有效。
第一是先让大模型列举实体,而不是直接要架构。让它回答"这个场景里有哪些名词可以抽象成类",然后自己来筛选和整理,比直接要一张完整的类图更容易发现遗漏的实体。比如在图书馆这个题目里,"移动轨迹"这个概念如果不主动提,大模型基本不会自己设计一个 MovingTrace 类,而是把它处理成一个 List<String> 属性藏在别的类里。
第二是把约束条件显式地告诉它。比如"ISBN 和副本必须是两个独立的类,因为评分针对 ISBN,查询轨迹针对副本","精品书架和普通书架需要是两个不同的类因为书在它们之间可以转移"。有了这些约束,大模型给出的方案会合理很多,而且能够比较快地产出一个接近最终设计的初版类图描述。
第三是用迭代式对话,不要指望一次就对。先让它给一个粗粒度的设计,然后针对每个模糊的点追问。比如"整理流程的逻辑你打算放在哪个类里?为什么?"这种追问既能帮助自己理解它的设计意图,也能发现它的设计里不合理的地方。大模型在被追问的时候往往会暴露出它的默认假设,而这些假设不一定适合具体场景。
第四是把自己的疑惑反过来问它。如果自己在设计某个类的时候拿不定主意,把两种方案都描述给它,让它分析两种方案各自的优缺点。这比直接问"怎么设计"要有用,因为大模型给出对比分析的质量通常高于直接给出答案。
第一单元的核心任务是把字符串变成多项式,再把多项式变回字符串。这个时候的"架构设计"其实就是:用什么数据结构存多项式,怎么组织解析器。我选择了 HashMap<Integer, BigInteger> 来表示多项式(指数到系数的映射),然后按照 BNF 文法把 Expression、Term、Factor 各建一个类。这个设计基本上是文法结构的直接映射,没有太多独立的架构决策。
这个单元的思维特点是:以数据结构为中心,先想清楚"数据怎么存",再想"类怎么组织"。这对于算法题来说是合理的,但对于工程项目来说还不够,因为这种设计方式很难应对需求的变化。
第二单元进入多线程领域,设计的难度显著上升。这个单元最核心的约束不是"功能怎么实现",而是"怎么保证共享数据在多线程下是安全的"。所有的架构决策都要绕着这个约束转。
我设计了 RequestQueue 作为线程间通信的核心数据结构,用 synchronized 保护所有对队列的读写,用 wait/notify 机制避免轮询。分派器线程和电梯线程之间的交互全部通过队列完成,没有直接的方法调用。这种"以消息队列为中心"的设计,实际上是把线程解耦了——生产者只管往队列里放,消费者只管从队列里取,两者不直接交互。
这个单元让我第一次体会到"架构是为了管理复杂度"。多线程的复杂度主要来自共享状态,所以好的架构要做的就是把共享状态的范围尽量缩小,理想情况下每个线程只有一个共享资源需要同步。
第三单元的设计约束是最明确的——接口的 JML 规格就是规格说明,实现类只需要满足规格即可。这个单元让我意识到,接口设计得好,实现就自然而然;接口设计得模糊,实现就充满歧义。
这个单元的架构思维是契约驱动:先确认接口的前置条件和后置条件,再考虑如何实现。在实现 Network 类的时候,我没有急着写代码,而是先把 JML 里每个方法的 requires 和 ensures 都翻译成中文理解了一遍,然后再决定用什么内部数据结构。比如 queryShortestPath 需要 BFS,那就需要一个 Map<Integer, List<Integer>> 来存关注关系图;queryMutualFollowingSum 需要遍历所有用户对,那就需要能快速访问所有用户。
第四单元是这四个单元里架构设计思维最"完整"的一次。从 UML 类图出发,先把系统的骨架画出来,然后用代码填充细节,最后用状态图验证状态转移的逻辑是否完整。
最大的变化是"设计"和"实现"之间有了明显的边界。前三个单元里,设计和实现往往是混在一起的,边写代码边想架构。第四单元强制把这两个阶段分开,一阶类图必须在代码之前提交。虽然这个流程会让人感觉"绕了一圈",但做完之后确实感受到了好处:代码写起来更顺,因为在画图的时候大部分类的职责已经想清楚了,代码只是在把这个设计具体化。
四个单元架构设计思维的演进轨迹大致是:数据中心 → 约束中心 → 规格中心 → 模型中心。这条路径和软件工程的成熟度发展是对应的——越成熟的工程实践,越会把设计工作提前,把实现工作约束在设计的框架内进行。
第一单元的测试思维主要是黑盒测试:给定一个表达式字符串,输出的多项式要和预期一致。测试的重点在于构造各种边界情况——多层嵌套的括号、连续的加减号、指数为 0 的情况、系数抵消为 0 的情况等。
这个单元里我自己构造了一些"刁钻"的测试用例,比如 - -1 + + +x(连续符号)、(x^0)^0(嵌套 0 次幂)、系数超出 long 范围的情况。互测的经验也印证了:大部分 bug 都出在边界情况上,而不是主干流程上。
测试思维的局限在于,这个阶段还没有系统地用单元测试工具,测试基本靠手动构造用例,覆盖率有限。
第二单元的测试难度陡增,因为多线程程序的行为是不确定的,同一段代码跑两次可能给出不同的结果。我用了课程组提供的投喂脚本进行测试,但光靠投喂脚本不够,还需要自己构造一些极端情况:比如大量请求集中在同一秒涌入,或者请求要去的楼层跨度很大,或者电梯满载的边界情况。
这个单元让我意识到,并发 bug 往往不是必现的,有时候需要跑很多次才能触发。这对测试提出了更高的要求——不能只测功能正确性,还要测"在高压力下是否还正确"。后来发现自己的一个线程安全 bug 在普通测试下一直没出现,在强测的某个特定数据集下触发了,原因是那个数据集的请求时间戳非常密集。
第三单元是第一次正式接触 JUnit 单元测试,而且测试的目标非常明确:验证实现是否符合 JML 规格。这让测试从"我觉得应该这样"变成了"规格说应该这样",测试用例的设计有了更清晰的依据。
针对 queryMutualFollowingSum 方法写测试的时候,需要覆盖:正确的返回值、pure 约束(调用前后其他状态不变)、边界情况(没有互相关注的用户对时返回 0)。这种按照规格逐条验证的方式,比之前那种"感觉差不多了"的方式严谨很多。但也暴露了一个问题:JML 规格写得再详细,如果测试用例设计得不好,仍然会漏掉一些边界情况。
第四单元采用了交互式评测——评测机根据程序的输出动态生成后续输入。这意味着程序的输出会直接影响接下来收到的请求,比如借阅成功后系统会生成一个还书请求。这种测试方式让功能之间的依赖关系变得显式:如果借书逻辑有问题,还书测试也会随之出问题。
这个单元的测试思维转向了状态覆盖。图书的状态转移路径有很多条,要保证每条主要路径都有测试覆盖。比如"从普通书架借走,还回借还处,开馆整理搬到精品书架,从精品书架借走"这条路径,就需要专门构造一个先评高分、再借阅的测试用例来覆盖。状态图在这里就非常有用——把所有可能的状态和迁移画出来之后,测试用例的设计就有了明确的参考框架。
OO 这门课最让我感受深刻的,是它在四个单元里分别用不同的方式回答了同一个问题:面对复杂性,怎么把它管理好?
第一单元的答案是:把问题的结构显式化,用类的层次对应问题域的层次。第二单元的答案是:通过约束共享状态来管理并发复杂性,线程之间尽量只通过消息通信。第三单元的答案是:用形式化的规格把"应该做什么"和"怎么做"分开,让实现只负责满足规格。第四单元的答案是:用模型把设计决策提前,让代码只负责实现已经想好的设计。
这四个答案并不是孤立的,它们是在不断叠加的。到了第四单元,已经会自然地想:这个类的职责是什么(第一单元的意识),共享状态在哪里需要小心(第二单元的意识),这个方法的规格是什么(第三单元的意识),它在整体架构里处于什么位置(第四单元的意识)。
在架构设计能力上,变化是从"先写再想"到"先想再写"。这个转变不是一蹴而就的,第一单元里还是习惯边写边想,第四单元才真正体会到先设计的好处。先设计不是说要把所有细节都想好再动手,而是把关键的结构决策(类有哪些,职责怎么分,关系怎么建)在动手之前解决,留给实现阶段的只是填充细节。
在测试能力上,变化是从"构造特殊用例"到"系统性覆盖"。最开始的测试思维是"我能想到什么坑就测什么坑",后来逐渐变成"这个问题有哪些维度,每个维度的边界在哪里,怎么做到有据可依的覆盖"。JUnit 和状态图都是这种系统性思维的工具。