NCHU数字电路模拟程序——前三次迭代作业总结
前言
这三道题是《面向对象程序设计》课程中接力迭代的具体案例。题目围绕着“数字电路模拟”这个硬核的底层硬件场景展开。
从第一题 5 种基础逻辑门的信号仿真,到第二题引入了三态门、译码器等控制逻辑,再到第三题的子电路嵌套与严格异常处理。这种层层加码的闭环设计,真正做到了将现实的物理逻辑转化为代码逻辑。
不过说实话,做完这三道题,我真的觉得自己像经历了一场程序员版的电路焊接。最开始写 V1 的时候,满脑子想的都是怎么把多态和继承什么的都套进去;到了 V2,面对大量乱飞的引脚索引和 3-8 线译码器,我甚至怀疑自己是在写C语言;
等到 V3,看到题目要求子电路嵌套并且给出组合模式建议时,一直快到结束了才硬着头皮写出来。但也正是这种痛苦,让我真正把书上的设计模式用在了实战里。
一、总体知识点预览
这次系列大作业不再局限于简单的继承、多态等,核心知识点明显向设计逻辑、设计模式和基础数据结构与算法:
(1): 面向对象核心:类的多态、抽象方法的重写、子类与父类的解耦。
(2): 设计模式:V3 明确要求并使用了组合模式,通过树形结构统一处理基础元件与复杂子电路。
(3): 数据结构与算法:HashMap 与 Queue 的事件驱动传播、二进制的位运算、列表与自定义 Comparator 的排序等等。
(4): 字符串解析与映射:高频字符提取(如 A(8)1、M(3)1),控制引脚、输入引脚、输出引脚的复杂映射关系。
(5): 异常防御与优先级:5 种异常情况的分类处理,严格要求“短路”输出。
二、题量与难度
这三道题的难度完全就是滚雪球式增长:
T1(基础框架):代码量和难度适中。难点在于把单向的连线关系和队列的先进先出逻辑理顺。
T2(组合逻辑扩展):代码量暴涨。难点在于非门、译码器、数据选择器不仅有输入输出,还有“控制引脚”,需要用到复杂的位运算去计算信号的路由。
T3(综合应用):量级直接拉满。不仅实现了子电路的递归嵌套(树形结构),还要在解析阶段严格按优先级拦截 5 种错误输入。这一轮简直把“防御式编程”做到了极致。
三、设计与分析
1. 第一次作业:7-16 NCHU-数字电路模拟程序-1
(1)类结构设计:
T1 的基础是 5 个门电路。我提取了它们的共性,设计了一个抽象基类 Men,这个基类里存了输入引脚数组 protected int[] In,以及记录当前接到了几个信号的 NowInCount。
当 NowInCount == SumInCount 时,触发 IsOk() 表示引脚都接好了。接着,Yu、Huo、Fei、YiHuo、TongHuo 这五个子类分别继承 Men,并重写各自的 CalculateOut() 方法。
在传播方面,我利用了 Queue
(2)对应的类图:

(3)代码规模:

(4)SourceMonitor 分析:








(5)分析心得:
T1 让我摸透了多态的实际意义。以前觉得继承就是少写几行代码,现在发现,如果不用继承,我必须在同一个循环里写死 5 个 if...else if 来判断门类型,代码又臭又长。
用继承之后,每个门的小逻辑都封装在自己的类里,非常清爽。另外,用 Queue 做 BFS 传播也很巧妙,当一个门算完输出值,把输出事件丢进队列,队列再去唤醒下游的门,就像多米诺骨牌。
(6)错误总结:
① 提取编号排序时逻辑遗漏导致输出乱序。在处理最终输出时,我需要按数字大小对元件排序,比如 A(2)1 要排在 A(2)2 前面。
我写了一个 getAllNum 手动提取数字,但最初没有处理好括号后面直接接编号的规则,导致 A(4)1 和 A(4)11 被错误比较,排序结果混乱。
② BFS 事件队列死循环隐患。在 while (!que.isEmpty()) 的循环中,我使用了 que.add() 将输出结果重新入队。
如果在某种极端的连线情况下,比如门 A 的输出也是门 A 的输入,我的队列就会无限触发,陷入死循环。
③ 连线解析时的数组越界问题。最初在解析 INPUT: A-1 B-1 时,我是直接用 line.substring(6)。
如果给的字符串前后多了一个空格,或者两个输入之间有多个空格导致 split 产生空字符串,程序就会直接抛出数组越界异常。
④ createMen 解析失败导致的空指针。在识别门类型时,我第一次没考虑到 Fei(非门)这种没有 () 情况的特别处理,导致程序去截取括号时抛异常,返回了 null,后续使用时直接炸了空指针。
(7)改进方案:
现在所有门的创建逻辑都堆在 Main.createMen 里面,像个大杂烩。如果以后要加新门,还得回来改这个方法。
应该抽出一个 MenFactory 工厂类,专门负责根据字符串创建对应的门对象,把创建和业务分离。
(8)作业一总结:
T1 虽然基础,但它是整个系列的地基。通过 T1,我熟练掌握了多态、队列的应用,并熟悉了题目中那种文本解析为对象的流程。这为我后续题目的解答打下了基础。
2. 第二次作业:7-17 NCHU-数字电路模拟程序-2
(1)类结构设计:
T2 引入了带控制引脚的复杂元件(三态门、译码器等)。因为逻辑太过离散,我放弃了继承树,改为了数据驱动的设计。
我使用 Map<String, String> gateType、Map<String, Integer> ctrlCount 和 dataCount 来存储所有元件的元数据。在处理信号传播时,我没有用实时入队,而是采用了 for(int round=0; round<500; round++) 这种暴力的方式。
每一轮遍历所有元件,如果有元件状态发生改变,则通过 fillFanout 函数把信号广播出去。
(2)对应的类图:

(3)代码规模:

(4)SourceMonitor 分析:


(5)分析心得:
T2 我做得很纠结。因为引入控制引脚后,每个元件的引脚类型(输入、输出、控制)不再像 T1 那样单纯,我选择用 Map 来做全局数据缓存。
那个 round < 500 让我一直心虚,因为现实世界中电路传播是微秒级的,这里硬生生用 500 次循环去处理,虽然测试能过,但我总觉得不够严谨。
不过在实现 3-8 译码器时,addr |= (pinVal.get(3+j) << j) 这种位运算的应用,让我对硬件的编码原理有了真切体会。
(6)错误总结:
① 译码器输出引脚索引严重错位。3-8 译码器,0、1、2 号引脚是控制端,3、4、5 是输入端,6 到 13 是 8 个输出端。
我一开始计算输出端的起始索引时算错了,导致控制端选了 1,2,3,直接把输入端的信号覆盖了。当时查错查到怀疑人生,最后是把 signal 里的键值对全打印出来才发现索引不对。
② 数据分配器旧输出未清空导致信号赖着不走。数据分配器 F 只能输出到当前控制端选中的那一个引脚。如果控制端变了,老的输出引脚应该断电。
我一开始只写了 signal.put(targetPin, data),没管别的引脚,结果控制端一换,老引脚居然还保留着 1 的信号,造成输出结果全是错的。
后来我加了前置判断:先把控制端对应的所有输出引脚的信号从 signal 里清除掉,再赋值,才解决了问题。
③ fillFanout 递归导致栈溢出。在传播信号的 fillFanout 方法中,我最初使用了递归调用。如果线路中存在环形结构比如最后接回了自己,代码就会无限递归,直接爆栈。
后来我重构了这个方法,改为了队列遍历,并在更新状态前做了“值是否改变”的校验,才压住了这个 Bug。
④ Z 数据选择器控制位逻辑拼错。V2 中有个四选一数据选择器 Z(2)2,控制端有两个,需要拼成一个 2 位二进制数作为索引。
我的拼接代码 sel |= (1 << j) 中把位移方向弄反了,导致 AB=10 和 AB=01 控制的通道被反了,输出一直匹配不到正确的输入信号。
(7)改进方案:
T2 的逻辑全堆在 Main 类里,如果以后再来几个新元件,这段 switch 能写到几百行。
应该把三态门、选择器、分配器的具体计算逻辑单独拆出来,形成独立的策略类,彻底解决Main类过于复杂的问题。
(8)作业二总结:
T2 虽然通过暴力轮询过了测试,但它让我切身感受到高耦合带来的痛苦。因为处理逻辑都揉在同一个类里,调试极其费劲。
吃一堑长一智,这也使我决顶在写 T3 时,一定要用设计模式把结构理清。
3. 第三次作业:7-1 NCHU-数字电路模拟程序-4
(1)类结构设计:
Gate:抽象基类,约定好通用的 setVal()、compute()、collect()。
AndGate、OrGate、NotGate、XorGate、XnorGate:继承 Gate,作为组合模式的叶子节点,执行最底层的逻辑运算。
Sub:继承 Gate,是组合节点。内部维护一个 ArrayList
异常处理:在 parseMainConn 和 parseAll 中严格按照题目要求的 5 种错误先后顺序,采取 if...return 的阶梯式拦截,一旦命中优先级最高的错误,立即结束解析。
(2)对应的类图:

(3)代码规模:

(4)SourceMonitor 分析:









(5)分析心得:
这一题做到最后,我彻底被组合模式折服了。当我在 Sub.compute() 里面写下一句 gates.get(i).compute() 的时候,这个函数根本不需要知道这是个基本门还是一个包含 50 个逻辑门的子电路,它直接递归地往下算,一算到底。
这种逻辑层次的绝对统一,才是设计模式真正的魅力所在。另外,T3 严格的异常检测逻辑让我学会了防御式编程,错误必须按顺序拦截,不能心软。
(6)错误总结:
① 子电路输出丢失父级前缀。输出要求带有嵌套层级,比如子电路 C2 里面的异或门 X1 要输出 C2-X1-0:1。我最初忘了加前缀,直接打印了 X1-0:1。
后来在 Gate.collect 方法中引入了 String prefix 参数,在递归到底层叶子时,才将前缀一层层拼接上去。
② 异常处理没做“短路”导致连报多条错误。题目要求如果一行连线触发了错误,只能报最先发现的那一条。我一开始是把所有错误列个清单,然后一次性输出。
结果被 PTA 疯狂扣分。后来我改成了阶梯式的 if ... else if ... 结构,一旦检测到一个错误,立刻 print 并 return,后面的代码连跑都不跑。
③ Sub 子电路的内部连接映射顺序错误。解析 Sub 内部的连线时,我利用 dstMap 做了冲突检查(防止 input signal conflict)。但最初我没有注意解析顺序,导致在连线时,如果后面的输出引脚接在了前面已经赋值过的输入引脚上,判断逻辑出现了偏差。
④ 跨子电路的信号获取失败。在 Main 里的 fetchMainVal 方法中,我需要从 gid 中取出元件,我当时分了两种情况:一种是主电路直接取,一种是去 subMap 里找。
最初我在跨级取子电路内部引脚值时,少写了 sub.getVal(pin) 这个分支,导致信号取不到,直接变成 -1,连不上线路。
(7)改进方案:
虽然组合模式很成功,但信号传播依然依赖队列。对于庞大的树形结构,应该进阶学习拓扑排序。
先把整个电路建模成一张有向无环图,计算好每个节点的入度,保证每一个元件在接收信号前,前面的电路已经完全算完了,这样一次遍历就能完成,省时省力。
(8)作业三总结:
T3 是我这次迭代作业中耗时最久也是最费力的一题。我不仅实现了一个能跑通、能检测异常的子电路模拟器,更重要的是,我切身体会到架构设计的力量。
面向对象不是纯理论,它是真的能在几百行代码里拯救你的逻辑和智商的。
四、改进建议
虽然三次作业都勉强通关,但回头审阅代码,我发现仍然有巨大的优化空间:
(1): 彻底贯彻策略模式:V2 和 V3 的 Main 类中,switch(type) 等操作依然臃肿。应该将每个元件具体的运算逻辑,比如与门的逻辑、译码器的逻辑封装成独立的策略类。如果未来要扩展元件,不需要改动主流程,只需追加策略即可。
(2): 引入图论拓扑排序:目前的队列(Queue)和 round 循环虽然能应急,但不是长久之计。作为软件工程的学生,我应该尝试引入拓扑排序,将依赖关系拆解开,实现真正的线性信号传播。
(3): 拆分臃肿的解析类:V3 中的解析逻辑和异常检查混在了一起,使一个方法长达上百行。下一步应该把输入解析、异常校验、仿真执行分别封装到 InputParser、ExceptionValidator、Simulator 中,真正把单一职责原则贯彻到底。
五、总结
1. 学到了什么
这三次作业,让我从一个只会在 main 方法里写顺序逻辑的菜鸟,蜕变成了一个会思考架构的人。**
(1): 我真正把设计模式内化成了自己的武器。组合模式在子电路上的应用,让我明白了树形结构为什么要这么设计。
(2): 我明白了防患于未然。T3 中那种按优先级找错误的短路写法,虽然代码看起来繁琐,但是它保障了程序的绝对安全。先校验,再执行,这是一种好习惯。
(3): 我对数据解析有了肌肉记忆。面对 INPUT: A-1 B-1 这种不规则格式,我也能游刃有余地提取关键数据。
2. 需要进一步学习及研究
(1): 算法与数据结构:这次写电路模拟发现,电路的连线本质上就是图。我要恶补图论中的邻接表、拓扑排序,这对我未来写游戏引擎和模拟器一定很有帮助。
(2): 单元测试:T3 调那 5 种异常情况,我每次都要手动跑一遍代码看输出。如果我能提前给这个异常函数写好 JUnit 单元测试,几秒钟就能验证一次,效率直接起飞。
(3): 设计模式的进阶:组合模式学完了,接下来我要继续研究工厂模式、状态模式和观察者模式,把这些模式串起来,去设计更大的软件。

浙公网安备 33010602011771号