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 类:记录信号传播的"目标元件 + 目标引脚"

虽然第一次的代码量不大,但它其实已经涉及到后续迭代中最核心的几个问题:输入应该怎么解析、信号应该怎么传播、不同元件的输出顺序应该如何统一管理。

代码规模截图image

这里的截图可以从 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)1O(4)2
  • 非门、异或门、同或门:标识符+编号,如 N1X8Y4
  • 编号规则:不同类元件编号可相同(如 X4 和 Y4),同类元件编号不可重复

类图截图image

这是第一次作业的类图。核心是 LogicGate 接口与 BaseGate 抽象类的关系。BaseGate 作为模板方法模式的基类,定义了 calculate() 的整体流程,子类只需实现 compute()。Source 和 Header 作为辅助类负责信号源和连接管理。第一次的设计重点就是把"计算什么"和"怎么传播"分开了。

4. 信号传播机制

信号传播采用迭代计算的方式:

  1. 从外部信号源(INPUT 行)开始,将信号值设置到对应的元件输入引脚上
  2. 进入循环,遍历所有元件,对每个就绪的元件调用 calculate()
  3. 如果某元件的输出发生变化,通过 link() 建立的连接关系,将新值传播到后续元件的输入引脚
  4. 循环直到所有元件的输出都不再变化

这种方式的好处是实现直观,问题在于需要保证计算顺序——必须先计算输入完整的元件,再计算依赖它的元件。而由于信号传播本身会触发后续元件的输入更新,所以循环迭代可以自然地收敛到稳定状态。

5. 主要难点

  • 元件名解析:需要区分 A(8)1(带括号)和 X5(不带括号)两种格式,从名称中提取类型、输入引脚数和编号
  • 引脚号提取:格式为 元件名-引脚号,需要从字符串中分离元件名和引脚号
  • 输入解析:需要处理 INPUT 行和 [...] 连接行两种格式

复杂度截图image

复杂度主要集中在装载和判断相关的方法上——"能不能计算""是否所有输入就绪""输出是否变化"这些分支判断。整体来看第一题的复杂度并不高,调试时主要是边界判断问题:引脚号解析是否正确、-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() 反向递归

代码规模截图image

和第一次相比,第二次的代码量增长明显。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 方法膨胀:九种元件的计算逻辑全部集中在一个方法里,导致该方法非常长
  • 递归环路检测:虽然递归求值天然支持环路检测,但在实现时需要注意缓存和计算顺序的正确性

类图截图image

第二次的类图比第一次简洁得多。LogicGate 接口只剩三个方法,BaseGate 只解析名称和编号,五个具体门类是纯数据类,外加新增的四个门类。计算逻辑全部集中到 IO 类的 evaluateGate() 中。这个设计的取舍很明确——用集中式管理换来了类结构的简化。

复杂度截图image

复杂度集中在 IO 类的 evaluateGate() 和 resolvePinValue() 中。evaluateGate() 里 switch 分支众多,每种元件的引脚计算规则都不一样。resolvePinValue() 的递归调用路径也可能很深——从输出引脚一路追溯到最外层的输入信号。pinCache 缓存和 computing 环路检测是控制复杂度的关键。


三、数字电路模拟程序-4

注意:题目编号中跳过了"3",直接从 2 跳到 4。第四次作业(即第三次迭代)在第一次的基础上增加子电路支持和异常输入检测。

1. 需求概述

在第一次作业(五种基础逻辑门)的基础上,增加两个新功能:

子电路:可以将一部分电路定义为一个子电路,在主电路中引用。子电路的元件编号可以与主电路相同而不会冲突。子电路内部包含完整的输入、输出和连接信息。

异常输入检测:检测五类异常——连接信息含多个输入、无输入、无输出、输入输出顺序错误、输入引脚信号冲突。异常按优先级输出。

2. 代码规模分析

第三次的代码量达到约 2000 行,是三次中增长最明显的。但它的难点不在于"多写几个类",而在于要在一个已有的系统上同时增加两个大型功能模块。

这次设计回到了第一次的信号传播 + 迭代计算模式,而不是继续使用第二次的递归求值。原因在于:子电路需要独立的内部计算环境,如果用递归求值方式处理子电路,信号追溯会变得极其复杂。

代码规模截图image

第三次的代码量翻了将近三倍。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>>。路由表记录了每个信号源连接到了哪些元件的哪些引脚。

同时维护三组映射关系:

  1. extToSub:外部信号 → 子电路输入(如 X → (C1, A)
  2. subOutConns:子电路内部元件输出 → 子电路输出信号(如 A(2)1-0 → C
  3. 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

类图截图image

第三次回到了第一次的接口+抽象类结构(LogicGate 接口恢复为 8 个方法,BaseGate 恢复管理引脚数组和信号传播),同时新增了子电路相关的辅助类。SubDef 负责描述子电路定义,RoutDest 和 SubInDest 负责路由映射,OutEntry 统一管理输出排序。如果严格按组合模式实现,子电路本身也应该继承 LogicGate 接口,但当前实现是通过路由表系统来处理的。

复杂度截图image

复杂度分布更为分散。子电路解析部分的 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月