面向对象程序设计--第二单元总结

一、前言
说实话,第一次看到“数字电路模拟程序”这个题目时,我心里是有点发怵的。虽然学过数电,也知道与门或门是怎么回事,但要把它们变成能跑起来的代码,而且还要一步步迭代出子电路和异常检测,总感觉像是一道从“会算”到“会造”的鸿沟。但既然作业摆在眼前,硬着头皮也得干。

现在回头再看,这三次作业就像一段连续的打怪升级之旅——从一开始只求“能跑”,到后来开始琢磨“怎么跑得优雅”,再到最后一次不得不面对“怎么处理乱七八糟的错误输入”,每一步都逼着我重新思考代码的设计。

第一次作业,我拿到的是最基本的五种门:与、或、非、异或、同或。需求很清晰:解析输入、连接、事件驱动传播、按顺序输出。我一开始写得极其“直白”,所有逻辑糊在 main 里,正则匹配、信号传播、元件创建全搅在一起。结果就是,自己写完调试的时候,每次改一个地方都要牵动全身,非常痛苦。于是我咬牙把代码拆成了 Gate、PinRef、GateFactory、Circuit、Main 五个类。虽然第一次写的时候也不确定什么“单一职责”,但至少改一个门逻辑不用重写整个模拟器了——那种清爽感,让我第一次体会到“设计”不是空话。

第二次作业就像是在第一版基础上疯狂加料:三态门、译码器、数据选择器、数据分配器,还要求输出格式五花八门。如果还沿用第一次那种硬编码 switch-case 的写法,光是新增元件类型就能让我改到怀疑人生。于是,我引入了枚举 CompType,把通用属性抽到抽象类 Component,让每种元件自己实现 ready()、compute()、outStr()。工厂类负责解析门名字符串,电路类只负责信号传播和排序。虽然文件数量从 5 个飙升到 15 个,但每个文件都短小精悍,加了新元件之后几乎不用改旧代码——这大概就是传说中的“开闭原则”吧?虽然那时候我还不知道这个词。

第三次作业是最让人头大的,因为突然要求支持子电路和异常输入检测。子电路相当于在电路里嵌套电路,还要带上编号前缀输出;异常检测要识别五种错误,还有优先级顺序。一开始我想着直接在原有结构上打补丁,但很快就发现子电路展开如果不提前规划,会导致元件命名冲突和信号传播混乱。于是我决定把子电路定义和主电路分开解析,在模拟前统一展开——给每个内部元件名字加上 C<编号>- 前缀,内部信号也做同样的映射。异常检测则是另一场战斗,每一条连接行都要按优先级逐一检查,而且一旦发现错误就立即停止,输出第一条异常。那些关于“多于一个输出”、“没有输入”、“输入输出写反”的提示,写起来琐碎,但真测起来却很有成就感——因为我的程序终于能“有礼貌”地指出用户哪里写错了,而不是默默崩溃。

三次作业下来,最大的收获不是写出了多少行代码,而是学会了一个道理:软件设计不是为了好看,而是为了迎接变化。第一次我只想完成功能,第二次我开始考虑扩展性,第三次我不得不面对错误处理——而每一次迭代都让我更清楚,一个好的架构能让新需求变得顺其自然,而不是破窗效应。

现在我把这三段历程整理成这篇博客,一方面是给自己做个记录,另一方面也想把踩过的坑和想通的设计思路分享出来。如果你也在做类似的仿真器,或者正在被迭代开发折磨,希望我的经验能让你少走一点弯路。

二、作业总结
2.1 第一次作业
2.1.1 类图

屏幕截图 2026-05-17 210002

2.1.2 复杂度分析

屏幕截图 2026-05-17 205635

2.1.3 BUG分析

  1. 信号传播顺序乱了
    我一开始的实现是:从队列取出一个信号,更新所有连接的门,门算完立刻把新信号塞进队列。看起来没问题,但一旦有两个信号同时驱动同一个门的不同引脚,而这个门需要两个引脚都到齐才能算,就可能出现“先收到引脚1,发现不够,等引脚2来了再算”的情况——这本来是对的。真正出问题的是:门算完输出后,这个输出信号可能又被别的门当成输入,而那个门可能已经在队列里排队了,结果新值覆盖旧值,导致同一个信号被处理两次或者旧值覆盖新值。

最后我改成了把信号值和信号名一起打包放进队列,处理的时候直接取打包好的值,不再从 signals 表里读,这样就不会互相覆盖了。

  1. 解析门名的时候把编号和输入数搞反了
    A(8)1 表示 8 输入与门,编号为 1。我写正则的时候,group(1) 拿到的是 8,group(2) 拿到的是 1,结果我创建门的时候把这两个参数传反了,变成了 1 输入、编号 8 的门。后面连输入引脚的时候当然对不上,门永远算不出来。

这个 bug 藏得挺深,我是打印日志才发现门的总输入数不对。改完参数顺序就好了。

  1. 输出引脚对应的门没被创建
    连接行里如果出现 [X1-0 N1-1],我原本只在解析输入引脚(N1-1)时才会去创建 N1,但 X1 作为输出引脚的门,我漏了创建。结果 X1 从来没被加入门表,后面的信号自然传不过去。

后来我在解析连接行的时候,对第一个 token(输出信号)也做了判断,如果是 元件名-0 这种格式,就先把对应的门创建出来。

  1. 还没算出门就往输出列表里塞
    最后输出结果的时候,我遍历了所有门,不管它有没有算出结果都打印。结果有些门因为输入没接全,output 是 null,打印出来就是 null:0,完全不对。

改法很简单,加个判断:只有 output != null 的门才输出。

  1. 最隐蔽的坑:同或门、异或门要求两个引脚都收到才触发
    这两个门都是二输入,但引脚号是 1 和 2。如果两个输入信号到达的时间间隔很大,第一个信号到达时,ready() 返回 false,门不会算。第二个信号到达时,虽然会触发重新检查,但因为我前面提到的信号覆盖问题,有时第二个信号的值会被错误地覆盖掉,导致门永远收不到完整的输入。

这个 bug 其实和第一个 bug 本质一样,都是信号更新机制的问题。修复完第一个问题后,这个自然就好了。

2.2 第二次作业
2.2.1 类图

屏幕截图 2026-05-17 210133

2.2.2 复杂度分析

屏幕截图 2026-05-17 200754

2.2.3 BUG分析

  1. 引脚号分配完全搞错
    这次新增的元件引脚号不是随便排的,题目有明确规定:控制引脚在前、输入引脚在中间、输出引脚在后,同类引脚按编号从小到大排。

比如译码器 M(3)1 有 3 个输入引脚、3 个控制引脚、8 个输出引脚:

0/1/2 是控制端(S1/S2/S3)

3/4/5 是输入端(A0/A1/A2)

6~13 是输出端(Y0~Y7)

我一开始没仔细看,想当然地认为控制引脚应该在后面,结果把引脚号全配反了。比如我把控制端放到了最后,输入端放到了最前面,这样连接信息里的引脚号就对不上,译码器永远收不到正确的控制信号。

改的时候老老实实把每个元件的引脚分配按题目要求重新排了一遍,三态门的 0/1/2 也确认是控制/输入/输出。

  1. 三态门的高阻态被我忽略了
    三态门的逻辑:控制端为 1 时输出等于输入,控制端为 0 时输出无效(高阻态)。

我一开始的写法是:不管控制端是 0 还是 1,只要两个引脚都收到信号就算就绪,然后输出值。结果控制端为 0 时,三态门还在输出 0,完全没体现“高阻态”的意思。

后来改成:控制端为 0 时,valid = false,这样输出阶段会跳过这个门,相当于没算出来。这才符合题目说的“输出为无效状态,忽略该元件”。

  1. 译码器的输出格式搞反了
    题目要求译码器输出的是“哪个引脚输出 0”,比如 M(3)1:3 表示 Y3 输出 0,其他引脚输出 1。

我一开始按普通元件的思路来,输出 M(3)1-6:0 这种格式,结果完全不对。后来才发现译码器是特殊的,输出格式和其他元件不一样。

更坑的是,如果控制引脚没满足 S1=1, S2+S3=0 的条件,译码器处于无效状态,所有输出无效,程序要忽略它——这个我一开始也漏了,导致控制信号不对的时候译码器还在输出。

  1. 数据分配器的输出格式是字符串拼接
    数据分配器的输出格式也特殊:按引脚编号从小到大的顺序输出所有输出引脚的信号,无效状态用 - 表示。

比如 F(2)1 有 4 个输出引脚,如果只有 W2 输出 0,其他三个无效,输出就是 F(2)1:--0-。

我第一次实现的时候,试图把每个输出引脚单独输出一行,被助教说格式不对。后来改成把所有输出引脚的状态拼成一个字符串,才通过测试。

  1. 数据选择器的输出引脚号算错了
    数据选择器的输出引脚不是 0,而是 控制引脚数 + 数据输入数。

比如 Z(2)2 有 2 个控制端、4 个数据输入端,输出引脚号就是 6。

我一开始想当然地认为输出引脚也是 0,和基本门一样,结果输出格式成了 Z(2)2-0:1,而题目期望的是 Z(2)2-6:1。改了引脚号计算之后才正常。

  1. 就绪条件越来越复杂
    基本门就绪很简单:收到的引脚数等于总输入数就行了。但新增元件就不一样了:

三态门需要两个引脚都收到(控制和数据)

译码器需要所有控制引脚和所有输入引脚都收到

数据选择器需要控制端和数据端全部到齐

数据分配器需要控制端和数据端全部到齐

我一开始把 ready() 写得太简单,比如译码器只检查了输入引脚是否到齐,没检查控制引脚,结果控制信号还没到就开始算了,输出自然不对。后来老老实实把每个元件的就绪条件按引脚分配表逐条列出来,才解决了这个问题。

  1. 工厂类新增的元件匹配顺序错了
    ComponentFactory.make() 里是按正则匹配顺序创建元件的。我一开始把 S(\d+) 放在 M\((\d+)\)(\d+) 前面,结果 M(3)1 被 S 的正则匹配到了开头,直接解析失败。

后来把 S(\d+) 这种简单匹配放到最后,先匹配带括号参数的复杂元件,才避免了误匹配。

2.3 第三次作业
2.3.1 类图

屏幕截图 2026-05-17 210319

2.3.2 复杂度分析

屏幕截图 2026-05-17 193449

2.3.3 BUG分析

  1. 子电路展开时元件名和信号名没统一加前缀
    这是最致命的问题。子电路的内部元件、输入输出信号,展开到主电路时必须全部加上子电路编号前缀,否则会和主电路的元件或信号重名。

我第一次实现时,只给元件名加了前缀(比如 X1 变成 C2-X1),但连接行里的信号名没加。结果 [C2-X1-0 Y1-1] 里 Y1 没加前缀,被当成主电路的 Y1,连接关系全乱了。

后来我把展开逻辑统一成:所有出现在子电路内部的元件名、信号名、引脚引用,只要不是外部输入,全部加前缀,才算解决了这个问题。

  1. 子电路的输入输出信号映射搞混了
    子电路有 INPUT 和 OUT 声明,这些信号在子电路内部使用,但在主电路里是作为引脚被引用的(比如 C2-A 表示子电路 C2 的输入引脚 A)。

我一开始把子电路的输入和输出当成普通信号处理,结果展开的时候把 C2-A 也加了前缀,变成了 C2-C2-A,彻底乱套。

正确做法是:子电路的输入输出信号名在展开时保持原样,只加一次前缀。我最后的方案是:先给所有内部元件加 C<编号>- 前缀,然后对连接行里的每个 token 判断——如果是子电路的输入或输出信号,直接在前面拼上 C<编号>-,否则原样保留。这样 C2-A 就变成了 C2-A(如果已经带了 C2 前缀就不重复加)。

  1. 异常检测的优先级顺序没做对
    题目给了 5 种异常,还规定了优先级顺序。我以为按顺序判断就行了,但实际做的时候发现多条异常同时出现在一行里的情况很常见,必须严格按照 1→2→3→4→5 的顺序检测,一旦命中就停止检查,输出该异常。

我一开始是并列判断的,结果一行里既有“多个输出”又有“输入输出写反”,先报了后面的错,被测试用例判错。后来改成顺序 if-else,每条都 return,才通过。

三、总结与展望
回顾
三次作业走下来,从最基础的与或非门,到带控制引脚的组合元件,再到子电路和异常检测,每一步都在原来的代码上叠加新需求。回头看,代码量翻了将近三倍,但这并不是简单的堆砌——每一次迭代都在逼迫我思考如何让代码更灵活、更健壮。
代码结构的变化是最直观的。第一次作业把逻辑塞进一个类里硬编码,第二次引入了枚举和抽象类,第三次用组合模式展开了子电路。这种演进的节奏让我对“设计模式”和“开闭原则”有了切身的体会,不是看书的抽象概念,而是踩坑之后自然领悟出来的。
BUG 排查的过程则教会了我另一件事:仿真器的核心不是“算得对”,而是“信号传得对”。元件逻辑再正确,信号传播的顺序、命名、覆盖、冲突只要有一个地方出问题,整个电路就全错了。三次作业里绝大部分 bug 都出在信号管理上,而不是门逻辑本身。
测试心态也发生了变化。第一次遇到 bug 会慌,觉得是代码写得不够好;到第三次的时候,已经能冷静地画信号流动图,逐条追查每个信号的值变化。这种“稳定的调试节奏”比任何技巧都重要。

展望
虽然这次作业已经结束了,但我清楚它还有很大的完善空间。
首先,时序元件还没做。 带反馈的电路(比如 D 触发器)会引入时钟和状态保持的概念,整个模拟机制需要从“组合逻辑”升级为“时序逻辑”。现在的事件驱动模型能处理无环传播,但遇到时序元件就需要引入“时间步”的概念,每个时钟边沿触发一次状态更新,这会是一次较大的架构调整。
其次,现有的异常检测还不够完整。 第三次作业只覆盖了连接行的五种错误,但还有很多边界情况没有处理,比如元件未定义、输入信号重复、元件编号冲突等。如果要做得更严谨,还需要一套更完整的错误恢复机制,而不是像现在这样“一错即停”。
另外,代码还可以继续重构。 随着元件种类增加,工厂类的正则匹配会越来越臃肿,可以考虑引入注解或配置文件来注册元件类型。子电路展开的逻辑也可以和主电路统一,用同样的数据结构来处理,而不是像现在这样分两阶段解析。
如果还有后续迭代,我最想尝试的是图形化输入——画一个电路图,自动生成连接关系,再跑模拟。这样就不用写繁琐的文本连接行了,也能直观地看到信号在门之间传播的过程。

最后
这三次作业算不上什么“伟大的软件工程”,但它让我真实地经历了从需求到设计、从编码到调试、从简单到复杂的完整过程。回头看,当初觉得“不可能完成”的迭代,最后都一步步做出来了,这种踏实的感觉比任何课堂知识都更有说服力。

posted @ 2026-06-24 21:43  陈俊谚  阅读(2)  评论(0)    收藏  举报