作业集4~6的总结性Blog
一、前言
数字电路是计算机硬件的基础,而通过软件模拟数字电路的运行,不仅能够加深对逻辑门、组合逻辑、时序逻辑等核心概念的理解,还能锻炼编程能力,特别是面向对象设计、解析算法和异常处理等实战技能。本阶段的三次作业“数字电路模拟程序”正是基于这一教学目的而设计,从最初的基础逻辑门,到增加复杂组合元件,再到引入子电路与异常检测,难度逐级递增,对代码的可扩展性、健壮性和可读性提出了很高的要求。
三次作业是为了优先夯实组合逻辑和模块化设计的基础。每一次作业都在前一次的基础上增加了新的功能点,且输入格式和输出规则都有所调整,需要我们在理解原有代码的基础上进行平稳的扩展,而不能轻易推倒重来。这无疑是对工程化思维的一次极好训练。
就整体而言,这三套题目的知识点覆盖面相当广,包括但不限于:
Java 面向对象基础:抽象类、接口、继承、多态、集合框架;
解析技术:词法分析、语法分析,处理自定义 DSL(领域特定语言);
数据结构与算法:图论中的拓扑排序,用于确定元件计算顺序;
数字电路理论:基本门电路(与或非异或同或)、三态门、译码器、数据选择器、数据分配器,以及子电路的封装与复用;
异常处理:多级优先级规则,按优先级输出最严重的错误。
题量方面,每次作业都提供了 6~10 个公开样例,隐藏测试点则更多,总计超过四十个测试用例。难度评估上,1 属于“较难”,2 为“困难”,3 则为“困难”,这不仅仅是难度的变化,更体现了功能复杂度的巨大跨越。完成这三轮作业,我总共花费了约 60 小时,其中 3 占据了近一半的时间,主要难点集中在子电路的递归解析与异常检测的优先级逻辑上。
通过这三次迭代,我深刻认识到,优秀的程序设计应当具备“高内聚、低耦合”的特点,并且要时刻为未来的扩展预留接口。下面,我将从设计与分析、踩坑心得、改进建议和总结四个方面,详细阐述我的实践过程与思考。
二、设计与分析
2.1 整体架构与核心设计理念
为了应对多次迭代,我从一开始就确立了几条核心设计原则:
面向接口编程:所有逻辑门元件均实现统一的 Component 接口,该接口定义了 String getId()、void setInput(int pinIndex, int value)、Optional
工厂模式:创建元件的工作交由 ComponentFactory 完成,它根据输入的标识符(如 A(2)1)解析出类型、参数和编号,然后实例化对应的元件子类。工厂模式让新元件的添加变得简单,无需修改主解析逻辑。
解析与执行分离:读入的所有内容先经过解析器 Parser,生成一个中间数据结构(包含所有元件列表、引脚连接列表和输入信号列表),然后再由 Simulator 执行计算和输出。这种分离使得解析错误和计算错误可以独立处理,也便于进行单元测试。
引脚抽象:每个元件内部维护一个 Pin 列表,引脚分为输入引脚、输出引脚和控制引脚。引脚对象存储当前值(或无效状态),并记录其类型和索引。对于多输出元件(如译码器),其输出引脚数量可能不固定,但通过列表管理仍然很灵活。
拓扑排序计算:电路本质是一个有向无环图(DAG),元件为节点,信号连接为边。为了正确计算每个元件的输出,必须按依赖关系从输入源开始逐步传播。我采用 Kahn 算法进行拓扑排序,同时处理无效状态(高阻态、未连接等)的传播。
下图是最终系统的简化类图:
+-------------------+ +-------------------+
| Circuit |<>-------| Component |
+-------------------+ +-------------------+
| - components: Map | | - id: String |
| - pins: Map | | - inputs: List
| - connections: List| | - outputs: Pin |
| + addComponent() | | + compute(): int |
| + addConnection() | | + getOutput(): int|
| + simulate() | +-------------------+
+-------------------+ △
| |
| |
v |
+-------------------+ +--------------+-------------+
| Pin | | AndGate | OrGate |
+-------------------+ +--------------+-------------+
| - name: String | | + compute() | + compute() |
| - value: int | +--------------+-------------+
| - isOutput: bool | ... (其他具体门类)
+-------------------+
顶层为 Circuit 类,包含 Map<String, Component> components、Map<String, Integer> inputSignals、List
Component 接口下派生出 BasicGate 抽象类(含与或非异或同或)、TriStateGate、Decoder、Mux、Demux 以及 SubCircuit。
SubCircuit 内部又持有一个 Circuit 实例,形成递归结构。
Parser 使用 Scanner 逐行读取,先处理子电路定义块(C...: 至 endc),再处理主电路的 INPUT 和连接,最后处理 end 终止。解析过程中,遇到异常立即记录到 errors 列表中,并按优先级保留最严重的一条。
这种设计使得代码结构清晰,各模块职责分明,为后续迭代奠定了良好的基础。
2.2 作业一:基础逻辑门实现

第一版作业仅包含五种基本逻辑门:与门(A)、或门(O)、非门(N)、异或门(X)、同或门(Y)。输入格式相对简单,元件名中只有与门和或门需要标明输入引脚数量(如 A(2)1),而非门、异或门、同或门则直接以 X8 等形式出现。连接信息以 [输出 输入1 输入2 ...] 的形式给出,输出引脚可以是顶层输入名称(如 A)或某个元件的输出引脚(如 X1-0),而输入引脚只能是元件的输入引脚(如 A(2)1-1)。
实现细节:
每个门类都重写了 compute() 方法,该方法遍历输入引脚列表,若任一输入无效(未连接或值为 -1),则返回 Optional.empty();否则根据逻辑运算规则计算输出值。
输出时,需要按照“与门→或门→非门→异或门→同或门”的顺序,并且同类元件按编号升序排列。我使用了一个 TreeMap<Integer, Component> 按类型分组,然后依次输出。
对于输入信号,它们被视作特殊的“源元件”,其输出引脚固定为 0,信号值直接来自 INPUT 行。
SourceMonitor 分析(第一版代码行数约 420 行,类数 9 个):
平均方法复杂度 3.5,较低,因为每个门只实现一个简单的逻辑函数。
最大复杂度出现在 Parser.parseConnection() 方法中,因为需要解析元件名、引脚号,并验证连接合法性,复杂度为 7,尚在可控范围。
注释率约为 25%,主要注释集中在门类的逻辑说明上。
设计亮点:
使用 Map<Integer, Integer> 存储输入引脚值,避免了因连接顺序不同而导致的错误。
拓扑排序采用队列实现,每次选取入度为 0 的元件计算,并更新其后继元件的输入值,保证了所有可计算元件均被正确计算。
2.3 作业二:复杂组合元件的加入

第二版在原有基础上新增了四种元件:三态门(S)、译码器(M)、数据选择器(Z)、数据分配器(F),并调整了引脚编号规则——对于这些新元件,引脚号按“控制引脚→输入引脚→输出引脚”的顺序排列(译码器则为“控制→输入→输出”)。输出格式也更加多样化:译码器只输出低电平引脚的编号(例如 M(2)1:0 表示 Y0 为低电平),数据分配器则输出所有输出引脚的状态,无效用 - 表示(如 F(3)1:-1------)。
技术难点与解决:
引脚类型区分:不同元件引脚类型顺序不同,必须在元件类内部明确标识。我添加了 PinType 枚举(CONTROL、INPUT、OUTPUT),并在构造函数中按顺序生成引脚列表。例如译码器 M(3)1 有 3 个控制引脚(索引 0~2)、3 个输入引脚(索引 3~5)和 8 个输出引脚(索引 6~13)。
控制引脚生效条件:译码器要求 S1=1, S2=0, S3=0 才有效;三态门要求控制端为 1 才导通。我将这些条件直接写在 compute() 中,若条件不满足则返回 Optional.empty(),表示输出无效。
数据选择器与分配器:根据控制引脚编码决定选通通道。由于控制引脚数量可变(如 Z(2) 表示两个控制位),我通过计算二进制值来映射通道号。
输出排序与格式:排序时新增了 S、M、Z、F 四类,按照题目指定的顺序(A,O,N,X,Y,S,M,Z,F)输出。对于译码器,若其有效,则找到唯一输出为 0 的引脚,输出其编号;对于分配器,按引脚编号顺序拼接字符串,无效输出为 -。
SourceMonitor 变化:代码行数增至 870,类数 14,最大复杂度跃升至 10(位于 Mux.compute() 中,因为需要处理不同控制位数)。注释率略有下降,因为忙于实现新功能,但核心逻辑仍保持清晰。
设计反思:这次作业让我意识到,必须严格遵守“对修改关闭,对扩展开放”的原则。在 V1 中,我将输出顺序硬编码在一个列表中,导致 V2 需要修改该列表。改进后,我将输出顺序定义为全局常量数组,新元件只需在数组中添加一项即可,实现了零修改扩展。
2.4 作业三:子电路与异常处理

最终版本是功能最全面的,引入了两个重大特性:子电路定义与引用 和 异常输入检测。子电路允许将一部分电路封装成一个模块,并在主电路或其他子电路中使用,类似于编程中的函数调用。异常检测则要求对五种典型输入错误按优先级输出第一条。
子电路实现:
定义子电路时,以 C<编号>: 开头,内部包含 INPUT、OUT 和连接信息,以 endc 结束。子电路内部的元件编号与主电路独立,且子电路可以嵌套(虽然题目未显式要求,但我的解析器支持递归)。
在主电路中使用子电路时,将子电路视为一个特殊元件,其“引脚”由子电路的 INPUT 和 OUT 列表中定义的标识符构成。例如子电路 C1 有输入 A 和输出 C,则主电路中可引用 C1-A 和 C1-C。
实现上,我创建了 SubCircuit 类,它内部持有一个 Circuit 实例,并重写 compute(),先递归计算内部电路的所有元件,再取出输出引脚的值。子电路的输出需要加上子电路编号前缀,例如 C1-A(2)1-0。
异常检测:
按优先级从高到低排列:
连接信息中包含两个或多个输出(include more than one input,实际应为 output,但题目用词如此)。
连接信息中没有输入(include none input)。
连接信息中没有输出(include none output)。
输入输出顺序写反(input and output sequence error)。
一个输入引脚被多个输出驱动(input signal conflict)。
我实现了一个 ErrorDetector 类,在解析每条连接信息时,依次检查上述五种情况,一旦命中即记录错误类型和所在行,并终止该连接信息的检查。同时,如果主输入中存在多条连接错误,只处理排在最前面的(按出现顺序)。这要求解析器顺序处理连接信息,不能提前缓存。
SourceMonitor 最终报表:总行数 1120,类数 18,最大复杂度 12(位于 Parser.parseSubCircuit() 中,因为需要处理嵌套和状态切换)。整体可维护性良好,但解析器部分略显臃肿,可以考虑拆分为多个子解析器。
难点与创新:
子电路的输出计算需要先确保内部所有元件的依赖都被满足,我采用递归调用 simulate(),并传入一个 Set 防止循环引用(虽然题目保证无环)。
对于异常优先级,我利用枚举的 ordinal() 顺序,但为了避免与输入顺序混淆,我在 ErrorDetector 中同时记录了行号,并在所有连接解析完毕后,按行号升序取第一个错误。
三、采坑心得
三次作业中,我踩过不少坑,有些是粗心,有些是设计缺陷。下面以具体案例和数据说明。
3.1 引脚编号解析的“陷阱”
问题现象:在解析 A(8)1-2 时,我最初使用 split("-"),得到数组 ["A(8)1", "2"],再对 "A(8)1" 进行二次分割,试图提取编号。但 A(8)1 中含有括号,正则表达式 [A-Z]((\d+))(\d+) 我写成了 [A-Z]((\d+))(\d+),漏掉了转义,导致匹配失败。
调试过程:通过打印日志发现,Pattern 报错。修正后,我添加了单元测试,覆盖所有可能的元件名格式(包括无输入引脚数量的门,如 X8)。
数据:在样例 5 中,有 A(2)1-0 O(2)1-0 X1-0 这样的连接,若不正确解析,会导致 X1 被误认为 X(1) 或编号丢失。修正后,所有样例均通过。
3.2 拓扑排序中的无效状态传播
问题现象:当三态门控制为 0 时,其输出为高阻态,但后续与门若有一个输入为高阻态,按照规则应视为无效,整个与门输出也应无效。然而我最初在 AndGate.compute() 中,只要输入中有 -1,就直接返回 Optional.empty(),这是正确的。但问题在于,三态门返回 empty 后,它的输出引脚并没有被赋予一个实际值,导致连接表中的后继元件在获取输入时,从引脚对象中读取到的仍为上一次计算的值(因为引脚对象没有重置)。
解决方法:在每次模拟开始前,将所有引脚的值重置为 -1(表示未知/无效),然后从输入信号开始赋值,逐步传播。同时,compute() 方法返回 Optional,但引脚本身不存储无效值,而是由元件的 getOutputValue() 方法根据计算结果动态返回。
测试:加入样例 7 验证,三态门控制为 1 时输出正常,为 0 时后续元件被忽略,输出中不出现任何依赖三态门的元件。
3.3 译码器输出格式混淆
问题现象:题目要求“译码器不输出引脚电平,输出其输出为 0 的引脚的编号”,例如 M(2)1:0。我一开始理解成输出全部引脚的二进制编码,结果样例 8 输出错误。
修正:在 Decoder.compute() 中,若有效,则遍历输出引脚列表,找到值为 0 的引脚索引(注意引脚号可能不是从 0 开始,例如译码器输出引脚起始为 6),输出“引脚编号”,即 index。但题目中示例 M(2)1:0 中的 0 是指 Y0,即最低位输出引脚。需要明确:输出引脚编号并非其全局引脚号,而是从 0 开始的输出通道号。我为此添加了一个 getOutputPinIndex() 方法,映射到通道号。
验证:样例 8 输入 A-0 B-0 C-1 D-0 E-0,译码器 M(2)1 的输入引脚(3,4)对应 A0,A1,控制(0,1,2)对应 S1,S2,S3,条件满足,输入编码为 00,因此 Y0 为 0,输出 M(2)1:0,通过。
3.4 子电路输出名称的全局唯一性
问题现象:子电路 C1 有一个输出引脚名为 C,主电路中连接 [C1-C N1-1],但主电路 INPUT 中也可能有 C-0,解析时无法区分“子电路的输出引脚”和“顶层输入引脚”。
解决方案:在解析连接信息时,根据上下文判断 C1-C 是否属于某个子电路。我维护了一个 subCircuitMap,在遇到 C1-C 时,先查找子电路 C1 的输出列表,若存在则创建 SubCircuitPin 对象,其全名为 C1-C。这样就与顶层输入 C 区分开了。
测试:样例 2 涉及两个子电路 C1 和 C2,都输出 B,主电路分别引用,输出时前缀不同,顺利通过。
3.5 异常优先级处理的“顺序陷阱”
问题现象:题目要求“如果一条输入出现了多种异常,按异常先后顺序为优先级”,我最初理解为在单条连接信息中检查异常类型的枚举顺序,但忽略了输入顺序(即行号)。结果在样例 8 中,第一条连接有“多个输出”和“无输入”两种异常,但优先级应为“多个输出”更高(因为其枚举顺序靠前),我正确实现了。然而,当多条连接信息都有错误时,要求“只处理排在最前面的异常信息”,我一开始按异常类型排序,导致错误。
修正:在解析时,为每条连接信息记录其出现行号(或顺序序号)。所有解析完成后,按行号升序,取第一个存在异常的行,输出其异常信息。
代码片段(伪代码):
text
List
for (Connection conn : connections) {
if (conn.hasError()) {
errors.add(conn.getError());
break; // 只记录第一条有错误的连接,因为后面的不需处理
}
}
if (!errors.isEmpty()) {
System.out.println(errors.get(0).toString());
return;
}
验证:样例 8 输入两条连接,第一条连接有错误,第二条也有但被忽略,输出第一条的错误,符合预期。
四、改进建议
尽管最终代码通过了所有测试点,但在回顾过程中,我仍发现若干可改进之处,若能重构,将使系统更加健壮和易于维护。
4.1 解析器的模块化拆分
当前 Parser 类承担了词法分析、语法分析和部分语义检查,总行数超过 400 行,内部有多个 while 循环和状态变量,导致复杂度较高。建议将解析过程拆分为三个层次:
Lexer:负责将输入字符串切分为 Token(如 IDENTIFIER, NUMBER, COLON, BRACKET 等)。
Parser:根据 Token 流构建抽象语法树(AST),处理子电路块、INPUT、连接等。
SemanticAnalyzer:遍历 AST,创建元件、连接,并进行异常检测。
这样不仅提高了可读性,也便于单独测试每个阶段。
4.2 使用设计模式优化元件创建
虽然我使用了工厂模式,但工厂内部仍使用了 switch 语句根据类型创建实例。每当新增元件时,都需要修改工厂类,违反了开闭原则。可以采用 反射 或 注册表模式,将类型与对应的构造函数注册到工厂中,从而做到零修改扩展。或者使用 Builder 模式 构建复杂元件(如译码器),避免构造函数参数过多。
4.3 增强异常处理的细粒度
目前异常检测只覆盖了连接信息,但输入格式错误(如 INPUT 行缺少 -)、子电路定义不完整等并未检测。后续迭代中可能会增加这些要求,因此应提前设计通用的异常处理框架,能够记录错误位置、错误类型,并统一输出。
4.4 性能优化与缓存
在大型电路中,子电路可能被多次引用,每次模拟都需要重新计算内部所有元件。可以引入 计算缓存,对于相同的输入组合,直接返回上次的计算结果。这需要为每个元件添加一个 cacheKey,由所有输入值组成,当输入值未变化时,跳过计算。虽然目前电路规模不大,但为未来考虑是值得的。
4.5 增加单元测试覆盖率
目前所有测试都依赖手动输入和肉眼观察,效率低且容易遗漏边界条件。建议使用 JUnit 5 编写参数化测试,将每个样例的输入输出作为参数,自动比对结果。同时,针对单个元件(如译码器)编写白盒测试,验证各种控制组合下的输出。
五、总结
本次三轮作业是我大学阶段迄今为止最具挑战性的编程任务之一,它不仅检验了 Java 编程基础,更让我对软件工程中的迭代开发、设计模式、代码重构有了切身体会。
学到的知识与技能:
深入理解了面向对象设计的五大原则,特别是在实践中应用了开闭原则和依赖倒置原则。
掌握了复杂文本解析的常用技巧,如状态机、正则表达式、递归下降。
强化了调试能力,通过打印日志、逐步断点和单元测试,能够快速定位逻辑错误。
对数字电路的基础元件及其工作原理有了清晰的认识,为后续硬件课程学习打下基础。
待深入学习的方向:
时序电路涉及时钟信号和状态保持,其模拟算法与组合逻辑截然不同,需要研究事件驱动模拟。
性能优化方面,可以学习并行计算和缓存技术,应对大规模集成电路模拟。
工程化方面,可以尝试使用 Maven/Gradle 管理项目,引入 Checkstyle 和 SpotBugs 保证代码质量。
心路历程:从最初面对 1 的茫然,到 2 的渐入佳境,再到 3 的攻坚克难,每一次挫折都让我更加坚韧。特别是最后一次作业,由于子电路和异常处理的交织,我一度陷入困境,但通过整理思路、绘制流程图并逐行推演,最终攻克了所有难关。这种“从问题到解决方案”的完整闭环,是书本上无法获得的宝贵财富。
总而言之,这三套作业是我编程生涯中的一次重要里程碑,我将把在此过程中学到的经验和方法,应用到未来的学习和项目中,持续精进。同时,我也将根据上述改进建议,不断完善自己的代码,力争达到工业级标准。

浙公网安备 33010602011771号