NCHUD-数字电路模拟程序三次作业总结
前言
面向对象程序设计课程中,"数字电路模拟程序"系列一共完成了三次 PTA 作业。这三次题目围绕同一个主题迭代展开:第一次实现五种基础逻辑门(与门、或门、非门、异或门、同或门)构成的简单数字电路模拟;第二次引入了三态门、译码器、数据选择器、数据分配器等更复杂的组合逻辑元件;第三次则在此基础上增加了子电路(Subcircuit)支持和异常输入检测。
不过,这三次作业在迭代过程中出现了一个有意思的情况:第二次作业并没有直接继承第一次的代码架构,而是推翻了重写;第三次作业也没有继承第二次,而是回到了第一次的基础上进行扩展。也就是说,三次作业形成了两种不同的设计思路:
- 思路一(第一、三次):接口 + 抽象类 + 信号传播 + 迭代计算
- 思路二(第二次):数据类 + 递归求值 + 集中式计算
| 作业 | 主要内容 | 代码规模 | 设计模式 |
|---|---|---|---|
| 第一次 | 五种基础逻辑门,单一电路模拟 | ~250 行 | 信号传播 + 迭代计算 |
| 第二次 | 新增三态门、译码器、数据选择器、数据分配器 | ~750 行 | 递归求值 + 集中式计算 |
| 第三次 | 子电路支持 + 异常输入检测 | ~2000 行 | 路由表 + 信号传播 + 迭代计算 |
下面我会按照三次作业的顺序,分别分析每次的设计思路、类结构和复杂度,最后汇总踩过的坑和一些改进的想法。
一、数字电路模拟程序-1
1. 需求概述
设计一个基础的数字电路模拟程序,支持与门(A)、或门(O)、非门(N)、异或门(X)、同或门(Y)五种逻辑门元件。程序需要解析元件信息、引脚连接关系和输入信号,按照与门→或门→非门→异或门→同或门的顺序,输出各元件输出引脚的电平。
2. 代码规模分析
第一次作业的规模相对较小,代码行数大约在 250 行左右。主要类包括:
- Main 类:程序入口,创建 IO 对象并调用 input → build → simulate → output 四个阶段
- IO 类:承担了输入解析、电路构建、仿真驱动、结果输出等所有职责
- LogicGate 接口:定义了 8 个方法——getName、getOrder、getID、getOutput、setInput、link、isReady、calculate
- BaseGate 抽象类:实现了 LogicGate 接口中的通用逻辑,包括引脚数组管理、输出值缓存、信号传播等
- AndGate / OrGate / NotGate / XorGate / XnorGate:五种具体门类,各自实现 compute() 和 getOrder()
- Source 类:代表外部输入信号源
- Header 类:记录信号传播的"目标元件 + 目标引脚"
虽然第一次的代码量不大,但它其实已经涉及到后续迭代中最核心的几个问题:输入应该怎么解析、信号应该怎么传播、不同元件的输出顺序应该如何统一管理。
代码规模截图:
这里的截图可以从 SourceMonitor 或 IDE 的统计工具中获取,展示第一次作业各文件的行数分布、方法长度等统计信息。可以看到代码整体比较紧凑,方法长度普遍不长,说明功能点比较集中,没有出现特别臃肿的模块。
3. 设计分析
这一版的核心设计思路是接口 + 抽象类 + 信号传播。
LogicGate 接口定义了所有逻辑门必须实现的通用方法。BaseGate 抽象类则实现了其中的通用逻辑,特别是:
- 引脚数组用
int[] input存储,下标从 1 开始(0 号下标不使用) - 所有引脚初始化为 -1(表示未连接或未赋值)
isReady()检查所有输入引脚是否都有值calculate()先检查就绪状态,调用子类的compute()计算新输出值,如果输出发生变化则通过outputs列表传播到后续元件
五种具体门类的计算逻辑:
| 门类型 | 标识符 | 输入引脚数 | compute() 逻辑 |
|---|---|---|---|
| 与门 | A(n) | n | 所有输入相与,初始值 1 |
| 或门 | O(n) | n | 所有输入相或,初始值 0 |
| 非门 | N | 1 | 1 - input[1] |
| 异或门 | X | 2 | input[1] ^ input[2] |
| 同或门 | Y | 2 | 1 ^ (input[1] ^ input[2]) |
元件命名规则:
- 与门、或门:
标识符(输入引脚数)+编号,如A(8)1、O(4)2 - 非门、异或门、同或门:
标识符+编号,如N1、X8、Y4 - 编号规则:不同类元件编号可相同(如 X4 和 Y4),同类元件编号不可重复
类图截图:
这是第一次作业的类图。核心是 LogicGate 接口与 BaseGate 抽象类的关系。BaseGate 作为模板方法模式的基类,定义了 calculate() 的整体流程,子类只需实现 compute()。Source 和 Header 作为辅助类负责信号源和连接管理。第一次的设计重点就是把"计算什么"和"怎么传播"分开了。
4. 信号传播机制
信号传播采用迭代计算的方式:
- 从外部信号源(INPUT 行)开始,将信号值设置到对应的元件输入引脚上
- 进入循环,遍历所有元件,对每个就绪的元件调用
calculate() - 如果某元件的输出发生变化,通过
link()建立的连接关系,将新值传播到后续元件的输入引脚 - 循环直到所有元件的输出都不再变化
这种方式的好处是实现直观,问题在于需要保证计算顺序——必须先计算输入完整的元件,再计算依赖它的元件。而由于信号传播本身会触发后续元件的输入更新,所以循环迭代可以自然地收敛到稳定状态。
5. 主要难点
- 元件名解析:需要区分
A(8)1(带括号)和X5(不带括号)两种格式,从名称中提取类型、输入引脚数和编号 - 引脚号提取:格式为
元件名-引脚号,需要从字符串中分离元件名和引脚号 - 输入解析:需要处理 INPUT 行和
[...]连接行两种格式
复杂度截图:
复杂度主要集中在装载和判断相关的方法上——"能不能计算""是否所有输入就绪""输出是否变化"这些分支判断。整体来看第一题的复杂度并不高,调试时主要是边界判断问题:引脚号解析是否正确、-1(未连接)的处理是否到位、迭代计算是否能稳定收敛。
二、数字电路模拟程序-2
1. 需求概述
在第一次的基础上,新增三态门(S)、译码器(M)、数据选择器(Z)、数据分配器(F)四种元件,使总元件数达到九种。引入"控制引脚"的概念,元件的引脚按"控制引脚 → 输入引脚 → 输出引脚"的顺序编号。程序输出顺序扩展为:与门→或门→非门→异或门→同或门→三态门→译码器→数据选择器→数据分配器。
2. 代码规模分析
第二次的代码量增长到约 750 行,增长原因不只是新增了四种元件类,更重要的是设计思路发生了根本性转变。
第一次采用"信号传播"模式——元件之间通过 link() 建立连接,信号通过 setInput() 逐级传播。第二次则采用了递归求值方式——不再主动传播信号,而是在需要某个引脚的值时,沿着连接关系递归向上追溯,直到找到外部输入或元件输出。
这种变化导致整个类结构都发生了调整:
| 方面 | 第一次(信号传播) | 第二次(递归求值) |
|---|---|---|
| LogicGate 接口 | 8 个方法 | 精简到 3 个方法(getName/getOrder/getID) |
| BaseGate 抽象类 | 管理引脚数组、输出值、信号传播 | 只管理名称和编号 |
| 具体门类 | 实现 compute() 和 getOrder() | 只实现 getOrder(),是纯数据类 |
| 核心计算 | 分散在各门类的 compute() 中 | 集中在 IO.evaluateGate() 中 |
| 信号追踪 | link() 正向传播 | resolvePinValue() 反向递归 |
代码规模截图:
和第一次相比,第二次的代码量增长明显。SourceMonitor 统计显示,IO 类成为了最长的类,因为 evaluateGate() 需要处理九种元件的计算逻辑。各个门类本身反而变得很薄——只是几个字段和 getOrder() 的声明。
3. 设计分析
第二次的设计可以概括为数据类 + 递归求值 + 集中式计算。
IO 类成为了真正的核心,内部维护了以下关键数据结构:
private Map<String, Integer> xinhao = new HashMap<>(); // 信号值映射
private Map<String, String> srcOf = new HashMap<>(); // 引脚→信号源映射
private Map<String, Integer> pinCache = new HashMap<>(); // 引脚值缓存
private Set<String> computing = new HashSet<>(); // 递归环路检测
递归求值流程(resolvePinValue):
要获取某引脚的值:
1. 检查缓存 pinCache → 有则直接返回
2. 检查环路 computing → 有则返回 -1
3. 检查信号源 srcOf → 有则递归解析信号源的值
4. 检查信号映射 xinhao → 有则直接返回
5. 如果是元件输出引脚 → 找到对应元件,调用 evaluateGate()
6. 缓存结果,返回
这种方式的好处:
- 按需计算:不需要预先传播所有信号
- 天然支持环路检测:通过 computing 集合可以检测递归中的循环依赖
- 减少冗余计算:通过 pinCache 缓存已计算过的引脚值
4. 新增元件详解
三态门(S)
三态门的作用类似于电路中的开关,包含三个引脚:
| 引脚号 | 类型 | 说明 |
|---|---|---|
| 0 | 控制引脚 | 控制信号 |
| 1 | 输入引脚 | 数据输入 |
| 2 | 输出引脚 | 数据输出 |
计算逻辑:控制引脚为 1 时,输出 = 输入;控制引脚为 0 时,输出为无效状态(高阻态,用 -1 表示)。
译码器(M)
以 M(3)1(3-8 线译码器)为例,引脚分配如下:
| 引脚号范围 | 类型 | 数量 | 说明 |
|---|---|---|---|
| 0~2 | 控制引脚 | 3 | S1/S2/S3 |
| 3~5 | 输入引脚 | n | A0/A1/A2 |
| 6~13 | 输出引脚 | 2^n | Y0~Y7 |
计算逻辑:
- 控制条件:
S1=1 && S2=0 && S3=0时译码器正常工作 - 正常工作时:根据输入编码(A0A1A2),对应输出引脚为 0,其余为 1
- 控制不满足时:所有输出为无效状态
数据选择器(Z)
以 Z(2)2(四选一数据选择器)为例:
| 引脚号范围 | 类型 | 说明 |
|---|---|---|
| 0~n-1 | 控制引脚 | 选择信号 |
| n~n+2^n-1 | 数据输入引脚 | 多路数据输入 |
| n+2^n | 输出引脚 | 唯一输出 |
计算逻辑:根据控制信号的编码(如 00/01/10/11),选择对应的数据输入信号作为输出。
数据分配器(F)
以 F(3)2(八路数据分配器)为例:
| 引脚号范围 | 类型 | 说明 |
|---|---|---|
| 0~n-1 | 控制引脚 | 选择信号 |
| n | 数据输入引脚 | 唯一数据输入 |
| n+1~n+2^n | 数据输出引脚 | 多路数据输出 |
计算逻辑:根据控制信号的编码,将数据输入信号输出到对应的输出引脚,其余输出引脚为无效状态。
5. 输出格式差异
第二次的输出格式与第一次有所不同:
- 基础门类:同第一次,
元件名-0:值 - 三态门:
S1-2:值(输出引脚号为 2,而非 0) - 译码器:
M(3)1:addr(输出输出为 0 的引脚编号,如M(3)1:0) - 数据选择器:
Z(1)1-3:值(输出引脚号取决于控制引脚数) - 数据分配器:
F(3)1:--0-(按顺序输出所有输出引脚信号,无效状态输出-)
6. 主要难点
- 引脚分配规则复杂:每种元件的控制/输入/输出引脚分区不同,在
evaluateGate()中需要为每种元件单独处理引脚号的计算和范围判断 - evaluateGate 方法膨胀:九种元件的计算逻辑全部集中在一个方法里,导致该方法非常长
- 递归环路检测:虽然递归求值天然支持环路检测,但在实现时需要注意缓存和计算顺序的正确性
类图截图:
第二次的类图比第一次简洁得多。LogicGate 接口只剩三个方法,BaseGate 只解析名称和编号,五个具体门类是纯数据类,外加新增的四个门类。计算逻辑全部集中到 IO 类的 evaluateGate() 中。这个设计的取舍很明确——用集中式管理换来了类结构的简化。
复杂度截图:
复杂度集中在 IO 类的 evaluateGate() 和 resolvePinValue() 中。evaluateGate() 里 switch 分支众多,每种元件的引脚计算规则都不一样。resolvePinValue() 的递归调用路径也可能很深——从输出引脚一路追溯到最外层的输入信号。pinCache 缓存和 computing 环路检测是控制复杂度的关键。
三、数字电路模拟程序-4
注意:题目编号中跳过了"3",直接从 2 跳到 4。第四次作业(即第三次迭代)在第一次的基础上增加子电路支持和异常输入检测。
1. 需求概述
在第一次作业(五种基础逻辑门)的基础上,增加两个新功能:
子电路:可以将一部分电路定义为一个子电路,在主电路中引用。子电路的元件编号可以与主电路相同而不会冲突。子电路内部包含完整的输入、输出和连接信息。
异常输入检测:检测五类异常——连接信息含多个输入、无输入、无输出、输入输出顺序错误、输入引脚信号冲突。异常按优先级输出。
2. 代码规模分析
第三次的代码量达到约 2000 行,是三次中增长最明显的。但它的难点不在于"多写几个类",而在于要在一个已有的系统上同时增加两个大型功能模块。
这次设计回到了第一次的信号传播 + 迭代计算模式,而不是继续使用第二次的递归求值。原因在于:子电路需要独立的内部计算环境,如果用递归求值方式处理子电路,信号追溯会变得极其复杂。
代码规模截图:
第三次的代码量翻了将近三倍。SourceMonitor 的统计会显示 IO 类的规模急剧膨胀——它现在要同时处理主电路解析、子电路解析、路由表构建、信号传播、异常检测等多项职责。新增的 SubDef、RoutDest、SubInDest、OutEntry 等辅助类虽然不大,但在结构上起到了关键作用。
3. 新增类与结构
| 类名 | 作用 |
|---|---|
| SubDef | 存储子电路定义:输入信号列表、输出信号列表、连接信息 |
| RoutDest | 路由表条目:记录目标元件和目标引脚 |
| SubInDest | 外部信号到子电路输入的映射 |
| OutEntry | 输出条目:支持带子电路前缀的排序输出 |
4. 子电路系统设计
子电路的设计采用了路由表 + 独立仿真的方式。
输入格式:
C1: // 子电路编号
INPUT: A B // 子电路输入信号
OUT: C // 子电路输出信号
[A A(2)1-1] // 子电路内部连接
[B A(2)1-2]
[A(2)1-0 C]
endc // 子电路结束
INPUT: X-1 Y-0 // 主电路输入
[X C1-A] // 主电路引用子电路输入
[Y C1-B]
end
路由表构建:
为主电路和每个子电路分别构建独立的路由表 Map<String, List<RoutDest>>。路由表记录了每个信号源连接到了哪些元件的哪些引脚。
同时维护三组映射关系:
- extToSub:外部信号 → 子电路输入(如
X → (C1, A)) - subOutConns:子电路内部元件输出 → 子电路输出信号(如
A(2)1-0 → C) - subOutVals:子电路输出信号 → 当前值(如
C → 0)
仿真流程:
1. 将外部输入信号推送到主电路路由表和子电路输入
2. 循环迭代:
a. 计算主电路元件,输出变化时推送到主电路路由表
b. 如果主电路信号也连接了子电路,推送到子电路
c. 计算各子电路内部元件,输出变化时推送到子电路路由表和子电路输出
d. 子电路输出变化时,推送到主电路和其他子电路
e. 重复直到所有输出不再变化
环路保护:使用 pushedSubOuts 集合记录已推送的子电路输出信号值组合,避免主电路和子电路之间的信号互相触发形成死循环。
5. 异常检测系统
异常检测覆盖主电路和子电路内部连接,按照以下优先级顺序处理:
| 优先级 | 异常类型 | 判定条件 |
|---|---|---|
| 1 | 含多个输入 | 连接信息中包含两个或以上"源" |
| 2 | 无输入 | 连接信息中没有"源" |
| 3 | 无输出 | 连接信息只有"源"没有目标 |
| 4 | 顺序错误 | 第一个元素不是"源"而最后一个元素是"源" |
| 5 | 信号冲突 | 同一输入引脚被多个不同输出连接 |
关键设计:
- 通过
_isSourceInMain()和_isSourceInSub()判断一个引脚是否为"源" - "源"的判断规则:
- 无连字符的 token → 检查是否为外部输入信号或子电路输入信号
- 带连字符且后缀为
-0的 token → 是源(元件输出引脚) - 带连字符且前缀为
C{编号}的 token → 检查是否为子电路输出信号
- 多条异常时只输出优先级最高的
- 不同连接信息中的异常,只处理排在最前面的
6. 输出格式
第三次的输出格式在第一次的基础上增加了子电路前缀:
- 主电路元件:
A(2)1-0:0(与第一次相同) - 子电路元件:
C1-A(2)1-0:0(前缀C{编号}-) - 异常输出:
ERROR: [...] include more than one input
类图截图:
第三次回到了第一次的接口+抽象类结构(LogicGate 接口恢复为 8 个方法,BaseGate 恢复管理引脚数组和信号传播),同时新增了子电路相关的辅助类。SubDef 负责描述子电路定义,RoutDest 和 SubInDest 负责路由映射,OutEntry 统一管理输出排序。如果严格按组合模式实现,子电路本身也应该继承 LogicGate 接口,但当前实现是通过路由表系统来处理的。
复杂度截图:
复杂度分布更为分散。子电路解析部分的 parseLines() 需要处理主电路和子电路两种输入格式的混合。异常检测部分的 checkAllErrors() 需要按优先级检查五种异常,且同时覆盖主电路和子电路。仿真部分的信号传播需要处理主电路↔子电路之间的双向推送。环路保护(pushedSubOuts)是这个复杂系统中的关键收敛机制。
采坑心得
1. 两次推翻重写的代价
三次作业实际上形成了两种完全不同的设计思路,这在迭代式作业中是比较少见的。第二次作业推翻第一次重写,第三次又推翻第二次回到第一次的架构——这让我付出了很多重复劳动。
反思下来,主要原因是我在设计时没有为后续迭代留够扩展空间。如果第一次设计时就把"未来可能要增加多种复杂元件"考虑进去,也许就不需要在第二次完全重写。同样,如果第二次设计时能预留子电路的扩展点,第三次也不用回到第一次的架构。
2. 接口设计要适度
第一次设计的 LogicGate 接口包含了 8 个方法,看起来功能完备,但第二次作业时大部分方法都被废弃了。这让我意识到:接口的方法不是越多越好,关键是这些方法是否真正"通用"。如果一个方法只有部分子类需要,那它就不应该放在接口里。
3. 引脚编号规则非常容易搞错
第二次作业中,不同元件的引脚分配规则不同:
- 三态门:0-控制, 1-输入, 2-输出
- 译码器:0/1/2-控制, 3~3+n-1-输入, 3+n~-输出
- 数据选择器:0~n-1-控制, n~n+2^n-1-数据输入, n+2^n-输出
- 数据分配器:0~n-1-控制, n-数据输入, n+1~-数据输出
尤其是译码器的输出引脚号计算(base + addr)和输出范围判断,我调试了很多次才全部通过。
4. 子电路的信号传播需要防死循环
第三次作业中,主电路和子电路之间的信号是双向传播的。子电路输出变化推送到主电路 → 主电路元件重新计算 → 可能又影响子电路的输入 → 子电路内部重新计算 → 输出又变化...
解决方案是用 pushedSubOuts 记录已经推送过的 (信号名=值) 组合,避免重复推送。这个问题的发现过程让我意识到:在复杂系统中,不仅要保证功能正确,还要考虑系统的收敛性——迭代计算是否能稳定结束。
5. 异常检测中"源"的判断很微妙
异常检测中最容易出错的地方是判断一个引脚是"源"还是"目标"。同一个引脚在不同上下文中角色可能完全不同:
A(2)1-0是元件的输出引脚(0 号引脚),所以它是"源"A(2)1-1是元件的输入引脚,但在连接信息[A(2)1-1]中它作为第一个元素,属于"连接系统的输出",所以也算"源"- 子电路的无连字符信号名(如 A、B),需要根据子电路的 inputs/outputs 列表判断
6. 第三次作业的设计建议——组合模式
题目设计建议中提到了采用组合模式(Composite Pattern),将子电路和电路元件都作为抽象元件类的子类。这样可以把子电路看作一种"组合型"电路元件,对外接口与普通元件一致。
虽然我当前的实现没有严格采用组合模式(而是通过路由表系统处理),但组合模式确实是处理这种"整体-部分"关系的经典方案。如果后续还要继续迭代,用组合模式重构子电路系统会让代码更加清晰。
改进建议
1. 统一设计思路
如果后续还要继续迭代(如增加 D 触发器、JK 触发器、时序电路等),建议从三种设计中选一种作为基础架构,并坚持下去。我个人倾向于第一次的信号传播 + 迭代计算模式,因为它在处理元件间的信号依赖关系时更直观,也更容易扩展到时序电路。
2. 解耦 IO 类
三次作业中,IO 类的职责都过于集中。如果继续迭代,可以拆分为:
- InputParser:输入解析
- CircuitGraph:电路图构建(维护元件列表、连接关系)
- SimulationEngine:仿真引擎(驱动迭代计算)
- OutputFormatter:输出格式化
3. 用组合模式重构子电路
按照题目建议,将子电路和元件统一抽象为 CircuitComponent,子电路内部维护一个子元件列表,通过递归调用的方式实现信号传播。
4. 集中管理业务常量
像元件类型标识符、引脚分区规则、输出顺序等,如果分散在代码各处,后续维护会很麻烦。可以把这些常量集中到一个配置类或枚举中。
5. 增加自动化测试
目前的测试方式还是"手动输入 + 观察输出",效率较低。后续可以把典型测试数据整理成固定样例,按正常情况、边界情况、非法输入分类,每次改代码后快速回归。
总结
这三次"数字电路模拟程序"作业做下来,我对于这次作业的理解为:迭代式开发并不只是"加功能",更考验的是架构设计的可扩展性。
第一次作业让我体会到了接口和抽象类在统一操作方式上的好处;第二次作业让我看到,在需求复杂度大幅提升时,有时换一种设计思路反而更高效;第三次作业则让我认识到,在设计复杂系统时必须提前考虑模块化和异常处理,这两个功能如果等到项目后期再加,难度会成倍增加。
最后,这门课的 PTA 作业设计确实很有特色——它不是一上来就给一个大系统,而是通过迭代的方式让程序"生长"起来。如果后续能增加一些设计方案互评或课堂讲评环节,相信对理解"为什么有些设计更好"会更有帮助。
作者:陈文博 (Bio-Laser)
博客园:https://www.cnblogs.com/Bio-Laser/
日期:2026年6月









浙公网安备 33010602011771号