数字电路模拟程序三次迭代作业总结——25201310-甘景凯
面向对象程序设计三次作业集阶段性总结
一、前言
本阶段的三次作业集都围绕逻辑电路仿真展开。第一次作业先从基本逻辑门入手,要求程序能够识别输入信号、连接关系并计算门电路输出;第二次作业加入了三态门、译码器、数据选择器和数据分配器,原来比较简单的单输出模型被迫升级为多引脚模型;第三次作业进一步引入子电路定义和子电路调用,程序不再只是“算一个门”,而是要先把层次化的电路描述展开成可仿真的结构。
这三次作业的难度不是突然拔高,而是一层一层叠上来的。第一次主要考查类的封装、继承、多态、正则解析和集合使用;第二次开始考验前一次设计是否有扩展空间;第三次则把问题推到“结构组织”和“合法性判断”上。做到第三次时,我明显感觉到,单纯会写 Java 语法已经不够了,必须先想清楚对象之间的关系,否则后面调试会非常痛苦。
我对这三份源码做了本地统计,结果如下:
| 作业集 | 总行数 | 非空行数 | 类数量 | 近似决策点 |
|---|---|---|---|---|
| OOP-4 | 703 | 618 | 16 | 87 |
| OOP-5 | 777 | 697 | 21 | 154 |
| OOP-6 | 930 | 838 | 13 | 184 |
这里的“近似决策点”主要按 if、for、while、switch、case、逻辑与、逻辑或等关键结构粗略统计,不能完全等同于 SourceMonitor 的圈复杂度,但可以反映代码复杂度的变化趋势。可以看到,第二次作业类数量最多,因为新增了多种器件类;第三次作业类数量减少,但决策点继续上升,说明复杂度集中到了 Parser、ConnectionChecker 这类规则处理类中。
总体来说,三次作业的知识点覆盖面比较广。第一阶段偏基础建模,第二阶段偏模型扩展,第三阶段偏结构解析与错误处理。它们共同训练的是面向对象程序设计中的一个核心能力:先把现实问题拆成合适的对象,再让对象之间通过清晰的接口协作。
二、设计与分析
2.1 第一次作业集:基础逻辑门电路仿真
第一次作业的核心是基础逻辑门仿真。程序需要读取类似 INPUT:A-1 B-0 和 [A A(2)1-1] 这样的文本,把外部输入、逻辑门和连线关系解析出来,然后计算每个门的输出。我的整体设计是把主流程拆成四步:输入解析、建立电路对象、信号传播、输出格式化。
Main 类只负责调度,不直接处理具体逻辑。它依次创建 Input、Circuit、Simulator 和 Output 对象。这样做的好处是主流程比较清楚,也方便后续定位问题。输入格式有问题时看 Input 和 GatePatterns,信号传播有问题时看 Circuit 和 Simulator,某个逻辑门计算错误时看对应的门类。
基础门使用继承结构实现。抽象类 Gate 保存门名、门类型、编号、输入引脚数组和输出值,AndGate、OrGate、NotGate、XorGate、XnorGate 分别继承它,并重写 evaluate 方法。这样每种门只关心自己的计算规则,外部调用时只需要面对统一的 Gate 接口。第一次写的时候,我对这种写法的体会还不深,后来做到第二次作业才发现,前面这个抽象确实给后续扩展省了不少事。
Circuit 是第一次作业中最关键的数据结构。它保存外部输入、逻辑门列表、连线列表和需要观察的输出。Wire 表示一条连线,保存源端和目标端;PinRef 表示某个元件的某个引脚,例如把 A(2)1-1 拆成元件名 A(2)1 和引脚号 1。这些类单独看都不复杂,但组合起来之后,文本形式的电路就被转换成了对象图。
信号传播采用反复迭代直到稳定的方式。每一轮先传播外部输入,再传播已有的门输出,最后重新计算所有门。如果这一轮中有任何输入值或输出值发生变化,就继续下一轮;如果没有变化,说明电路已经稳定。这个算法不算高效,因为每轮都会扫描较多对象,但它直观、容易实现,也适合作业规模下的组合逻辑电路。
本次作业中我踩得比较早的坑是“边读边算”。如果读到一条连线就立刻计算,很容易遇到后面才出现的门已经被前面引用的问题。后来我改成先收集输入行和连线行,再统一解析,并用 getOrCreateGate 延迟创建门对象。这个改动让程序对输入顺序不那么敏感,也让结构更稳定。



我用下面这个基础样例验证第一次作业:
INPUT:A-1 B-0
[A A(2)1-1]
[B A(2)1-2]
[A(2)1-0 N1-1]
end
输出结果为:
A(2)1-0:0
N1-0:1
这个结果符合预期。A=1、B=0,与门输出 0;与门输出再进入非门,非门输出 1。虽然样例很短,但它覆盖了外部输入、与门计算、门到门传播和最终输出几个主干环节。
2.2 第二次作业集:复杂器件与多引脚模型
第二次作业相比第一次,最大的变化是“一个门只有一个输出”的假设不成立了。三态门、译码器、数据选择器、数据分配器都有自己的引脚规则,有的器件还有多个输出端。如果继续沿用第一次作业中“输入数组 + 一个输出值”的模型,代码会很别扭,后面也很难维护。
因此我在第二次作业中把 Gate 改造成更通用的多引脚模型。Gate 中不再只有输入数组和单个输出值,而是使用 values 保存所有引脚的状态,用 inputFlags 标记哪些引脚是输入,用 outputFlags 标记哪些引脚是输出。这样一来,不同器件可以在同一套框架下表达自己的引脚含义。
第二次作业新增的器件类包括 TriStateGate、DecoderGate、MultiplexerGate 和 DemultiplexerGate。它们仍然继承 Gate,但各自实现 evaluateOutputs。译码器和数据分配器还重写了输出格式相关方法,因为它们的输出不只是简单的 门名-引脚:值。例如译码器需要输出被选中的编号,数据分配器需要拼接多个输出状态。这一部分让我意识到,输出格式也应该进入类设计,而不是最后在 Output 类里写一大堆特殊判断。
第二次作业还引入了高阻态 HIGH_Z。程序中高阻态显示为 -,传播到后级输入时按 UNDEFINED 处理。这样做能表达“该输出端不驱动后级”的含义。这个设计在当前题目中可以工作,但它也暴露出一个问题:UNDEFINED 同时表示“未知”和“没有被驱动”,语义并不完全干净。以后如果题目继续扩展到总线冲突或多个源驱动同一节点,就需要把状态拆得更细。
从统计数据看,第二次作业有 777 行代码、21 个类、近似决策点 154 个,是三次作业中类数量最多的一次。类数量增加主要来自复杂器件类。好在第一次作业已经把解析、电路保存、仿真和输出分开,所以第二次扩展时没有完全推倒重来。这个经历也说明,面向对象设计不是为了让代码看起来“高级”,而是在需求变化时尽量少改动原有结构。
第二次作业中比较容易出错的是引脚编号。译码器的输出起始位置是 3 + inputBits,数据选择器的输出引脚是 ctrlBits + (1 << ctrlBits),数据分配器的输出起始位置是 ctrlBits + 1。这些地方如果下标差 1,程序通常还能编译,但输出会错。我后来把这些关键位置保存成 outStart、outPin、outputCount 等字段,避免同一个公式在多个方法里重复出现。



我用第一次作业的基础样例测试第二次作业,输出仍然是:
A(2)1-0:0
N1-0:1
这说明第二次作业在扩展引脚模型后,没有破坏基础逻辑门的原有行为。对于持续迭代的程序,这一点很重要。新增功能如果把旧功能弄坏,就说明抽象改造还不够稳。
2.3 第三次作业集:子电路、实例展开与合法性检查
第三次作业的重点是子电路。题目中会先定义一个子电路,再在主电路中通过端口调用它。程序要做的事情不只是计算,还要先理解子电路的输入端口、输出端口和内部连线,再把它展开成普通逻辑门组成的平面电路。
当前实现中,SubCircuit 保存子电路定义,包括输入端口、输出端口和内部连线;Instance 保存子电路实例,包括实例前缀、输入绑定关系和输出来源;Parser 负责读取文本、拆分子电路、收集实例和展开实例;ConnectionChecker 负责连线合法性检查;Syntax 集中处理正则表达式和基础语法解析。
第三次作业最难的地方是实例展开。比如主电路中引用 C1,而 C1 内部有一个门 A(2)1,展开后就要重命名为 C1-A(2)1,否则不同子电路里的同名元件会冲突。如果子电路内部还嵌套其他子电路,前缀还要继续叠加。这个过程本质上是在处理命名空间问题。
程序在正式展开之前会先做连线检查。ConnectionChecker 会判断一条连线中是否没有输入、是否有多个输入、输入输出顺序是否错误、是否没有输出、目标端是否被多个源驱动等。这个检查很有必要。非法输入如果进入仿真阶段,最后往往只表现为“没有输出”或“输出不对”,很难反推到底是哪条连线出了问题。
第三次作业的仿真部分比解析部分简单。Circuit 用 Map<String, Integer> 保存输入值,用 Map<String, Gate> 保存门对象,连线仍然保存为 Wire 列表。simulate 每轮遍历连线传播信号,再遍历门计算输出,直到状态不再变化。输出时引入 groupOrder,用于保证子电路相关门的输出顺序。
这次作业也暴露出一个比较明显的设计问题:Parser 太重了。它既负责读文本,又负责拆子电路、收集实例、绑定端口、解析内部源、递归展开,还要添加主电路连线。功能能跑,但后期不好改。如果以后题目允许同一个子电路编号被多次实例化,当前以 "C" + id + "-" 作为实例键的做法就不够用了,因为多个使用点可能共享同一套输入绑定关系。



我用下面的样例验证子电路展开:
C1:
INPUT: IN1 IN2
OUT: OUT1
[IN1 A(2)1-1]
[IN2 A(2)1-2]
[A(2)1-0 OUT1]
endc
INPUT:A-1 B-1
[A C1-IN1]
[B C1-IN2]
[C1-OUT1 N1-1]
end
输出结果为:
C1-A(2)1-0:1
N1-0:0
说明 C1 的两个输入端都成功绑定到了主电路输入。子电路内部与门输出 1,输出端口再连接到主电路的非门,所以 N1 输出 0。
三、心得
第一,输入解析比我一开始想的更容易出问题。三次作业都高度依赖文本格式,正则表达式稍微写窄一点,就会漏掉合法输入;写宽一点,又可能把非法内容当成合法内容。第一次作业中我把门名、引脚引用、输入信号、连线行都集中在 GatePatterns 中处理,第三次作业中又把语法解析集中到 Syntax。这样至少可以避免同一种格式在多个地方重复写正则。
第二,引脚编号必须非常谨慎。第一次作业中输入引脚从 1 开始,输出引脚是 0;第二次作业中复杂器件的引脚含义更多,控制位、数据位、输出位都在同一个数组里。这里最怕的是“代码能跑但结果不对”。比如输出起始下标差 1,程序不会报错,只会在某些测试点上给出错误答案。后来我把关键下标单独命名,调试时会轻松很多。
第三,未定义状态不能简单地当成错误。与门只要有一个输入为 0,输出就已经确定为 0;或门只要有一个输入为 1,输出就已经确定为 1。也就是说,即使部分输入还是 UNDEFINED,有些门也可以提前得到确定输出。第二次作业中我专门处理了这一点,否则仿真会过于保守,部分本来能确定的结果可能一直不输出。
第四,高阻态和未知态最好不要混为一谈。当前代码中,高阻态传播到后级时按 UNDEFINED 处理,这能满足题目需求,但语义上并不完美。高阻表示“不驱动”,未知表示“暂时不知道”,二者不是一回事。这个问题在小规模作业中影响不大,但它提醒我,以后设计状态模型时不要偷懒。
第五,输出顺序不能最后再补。在线评测通常严格比较文本输出,逻辑算对了但顺序错了也会判错。第一次和第二次作业中,我用 GateType.order、门编号和门名排序;第三次作业中又加入 groupOrder。这个细节看起来不像核心算法,但它直接决定提交结果。
第六,第三次作业的 Parser 职责过重。写的时候为了尽快把流程跑通,把很多逻辑都放进了同一个类里。现在回头看,它已经同时承担了解析器、构建器和展开器的职责。短期能完成作业,长期维护就比较吃力。这个问题也让我认识到,能通过测试不代表设计就舒服。
第七,源码中的部分中文注释出现了乱码。这说明文件编码、IDE 编码或控制台编码没有统一。乱码不影响编译,但会影响后续阅读,尤其是在写总结和复盘时很麻烦。以后应该统一使用 UTF-8,并在项目开始时就设置好编码。
四、改进建议
第一,三次作业中有不少概念是重复的,例如 Gate、Wire、Circuit、Parser 和 Simulator。如果继续迭代,可以把这些通用概念提取成统一框架。新增器件时只扩展器件类和语法规则,不必每次都重新组织一套电路模型。
第二,输入解析和语义校验可以分开。现在很多地方是边解析边创建对象,解析错误、语义错误和对象缺失容易混在一起。更好的做法是先把文本解析成中间结构,例如 RawWire、RawComponent、RawSubCircuit,再由校验器检查端口是否合法、是否多源驱动、是否缺少输出,最后再构建真正用于仿真的 Circuit。
第三,仿真算法可以从全量扫描改成事件驱动。当前做法是反复扫描所有连线和所有门,直到状态稳定。这个方法简单,但效率不高。可以建立源端到目标端的邻接表,当某个信号变化时,只把受影响的后继门加入队列。这样代码会复杂一些,但更接近真实的数据流传播过程,也方便做环路检测。
第四,信号状态建议改为枚举。当前使用整数常量表示 ZERO、ONE、UNDEFINED 和 HIGH_Z,虽然写起来方便,但语义不够清楚。可以定义 SignalState 枚举,明确区分 ZERO、ONE、UNKNOWN、HIGH_Z、CONFLICT。这样既能减少魔法值,也方便以后支持更多电路状态。
第五,第三次作业的实例模型需要改进。当前主电路中收集实例时,以 "C" + id + "-" 作为键,这意味着同一个子电路编号默认只有一个实例。如果题目允许同一子电路被多次使用,就会出现端口绑定相互覆盖的问题。更稳妥的做法是根据出现位置或显式编号生成唯一实例名。
第六,应该补充自动化测试。现在主要靠手动样例测试,覆盖面不够。至少应该为基础门、复杂器件、非法连线、子电路嵌套、子电路输出回连等场景分别准备用例。如果使用 JUnit,可以把输入文本作为字符串传入解析器,再比较输出字符串。以后重构时,有测试兜底会安心很多。
第七,类规模需要控制。Parser、ConnectionChecker 这类规则密集型类可以继续拆分。例如用 SubCircuitParser 专门拆子电路,用 InstanceResolver 专门处理端口绑定,用 CircuitFlattener 专门展开电路,用 ConnectionValidator 专门检查连线错误。拆开后每个类的职责更明确,修改风险也更低。
第八,注释和编码要规范。源码中已有乱码注释,后续应统一修复。注释也不应该重复代码表面行为,而应解释设计原因。例如“高阻态传播到后级时按未知处理,是为了表示该源不驱动目标端”就比“设置 targetVal”更有价值。
五、总结
这三次作业让我对面向对象程序设计有了更实际的理解。第一次作业让我学会把逻辑门、连线、电路和仿真器拆成不同对象;第二次作业让我看到早期模型会被新需求检验,设计得太死就会很难扩展;第三次作业则让我接触到子电路展开、命名冲突、端口绑定和连线校验这些更复杂的问题。
从知识点上看,我巩固了 Java 基础语法、集合类、正则表达式、继承与多态、抽象类、字符串解析、排序输出和迭代仿真。更重要的是,我开始意识到程序正确性不只是某个函数能算出结果,还包括输入是否解析正确、对象关系是否清楚、边界条件是否覆盖、输出格式是否稳定。
我目前比较欠缺的地方也很明确。第一是复杂模块拆分能力还不够,第三次作业的 Parser 就是例子;第二是测试意识还需要加强,不能只靠几个手动样例判断程序可靠;第三是抽象能力还要继续练习,尤其是在需求不断变化时,如何设计一个既不过度复杂、又能继续扩展的模型。
总的来说,三次作业的递进关系很清楚:第一次建立对象模型,第二次扩展模型能力,第三次处理层次结构。完成这三次作业后,我不只是更熟悉 Java,也更理解了为什么面向对象设计强调职责划分、封装和可维护性。后续学习中,我会重点补充设计模式、单元测试、图结构算法和代码重构方面的内容,让程序不仅能通过当前测试点,也能在需求变化时更容易修改。
浙公网安备 33010602011771号