南昌航空大学25级软件面向对象三次作业集总结2
本学期面向对象程序设计课程安排了三次编程作业,都是围绕"数字电路模拟程序"
这个主题进行迭代开发。
三次作业的知识点覆盖情况大致如下:
第一轮:涉及五种基础逻辑门——与门、或门、
非门、异或门、同或门。主要考察点我认为是字符串解析、
HashMap的使用、以及基本的继承/多态设计。题目描述很长,需要仔细读才能理清输入输出的格式.
主要是输入格式的解析比较繁琐,逻辑门的运算本身倒不难。
第二轮:在第一轮的基础上新增了四种元件——三态门、译码器、数据选择器、数据分配器。这一轮
最大的变化是引入了"控制引脚"的概念——之前的逻辑门只有输入引脚和输出引脚,
现在多了一种引脚类型。这对代码结构的影响比我想象中大得多。此外,译码器和
数据分配器这类元件有多个输出引脚,而第一轮的所有元件都只有一个输出引脚,
这意味着输出部分的逻辑需要重新设计。
第三轮:本次在程序1的基础上增加了两个新功能——子电路
和异常输入检测。子电路本质上是把一部分电路封装起来,可以在
主电路中像元件一样引用。这个设计思路其实就是"组合模式"
的直接应用。异常输入检测则涉及五种错误类型,而且有优先级顺序。
主要是因为异常检测的优先级逻辑和子电路的命名空间处理。
总的来说,这三轮作业从简单到复杂,从单一设计到设计模式的运用,梯度设置分明
设计与分析:
2.1 第一轮作业:基础逻辑门模拟
第一轮我采用的是比较直观的设计思路。核心数据结构是几个HashMap:
private static Map<String, Integer> storeSignal = new HashMap<>();
private static Map<String, Unit> units = new HashMap<>();
private static Map<String, String> driverSource = new HashMap<>();
private static Map<String, Integer> cacheMemory = new HashMap<>();
storeSignal 存放外部输入信号(比如INPUT行解析出来的A-1, B-0),
driverSource 记录每个输入引脚由哪个输出引脚驱动(一对一的映射关系),
cacheMemory 用来避免重复计算——这个想法其实是受了算法课上记忆化搜索的启发。
元件类设计上,我定义了一个抽象类 Unit,然后And、Or、Not、Xor、Xnor 五个
子类各自实现 compute 方法。这个部分我觉得写得还算清楚:
abstract class Unit {
String name;
int id;
int pinCnt;
Unit(String n, int i, int cnt) { name = n; id = i; pinCnt = cnt; }
abstract int compute(int[] arr);
}
Unit 这个类的设计我在写第二轮的时候才发现问题很大。首先,
pinCnt 这个字段在子类构造时传入,但像 And 和 Or 的输入引脚数是从元件名
字符串里解析出来的(比如 A(8)1 中的 8),而 Not、Xor、Xnor 的引脚数是
固定的。这个"半动态半固定"的设计让第二轮扩展时非常难受。
另外,信号传播这块我用了一个叫 getResult 的递归方法——从目标引脚反向
追溯到信号源。这个方法在作业1的测试样例下跑得很顺,所有样例一次过。
但后来在作业2就不行了,遇到三态门控制引脚为低电平导致"开关断开"的情况——递归链断了,返回-1,
但这个-1的处理逻辑和"输入未连接"的区分并不清晰。
2.2 第二轮作业:控制引脚与多输出元件
第二轮我基本上重写了整个代码。不是不想复用,而是第一轮那个 Unit 类的
设计在"控制引脚"这个需求面前完全不够用——之前假设所有引脚要么是输入
要么是输出,而且输入引脚从1开始连续编号。但三态门的0号引脚是控制端、
1号是输入端、2号是输出端;译码器的0/1/2号引脚是控制端、3/4/5号是输入端、
6~13号才是输出端。
于是我重新设计了一个 Element 抽象类,把输入引脚和输出引脚用 List 来管理:
abstract class Element {
String label;
List inPorts;
List outPorts;
Map<Integer,Integer> inValues;
Map<Integer,Integer> outValues;
boolean processed;
...
abstract void evaluate();
boolean ready() {
for(int p:inPorts)
if(!inValues.containsKey(p)) return false;
return true;
}
}
这个设计比第一轮的 Unit 灵活了很多——每个子类自己负责在构造函数里填入
inPorts 和 outPorts。比如三态门的实现:
class Tri extends Element {
Tri(String s) {
super(s);
inPorts = Arrays.asList(0, 1); // 0号是控制端,1号是输入端
outPorts = Collections.singletonList(2);
}
void evaluate() {
if(inValues.get(0) == 1) {
outValues.put(2, inValues.get(1));
processed = true;
} else {
processed = false; // 高阻态,不输出
}
}
}
信号传播方面,这次我放弃递归改用BFS队列。思路是:把已知信号加入队列,
每次从队列取出一个信号,沿着连接关系传播到目标引脚。当元件的所有输入
引脚都有了信号值,就调用 evaluate() 计算输出,然后把输出引脚加入队列
继续传播。
这里有个隐藏的问题我当时没有意识到——这个"所有输入就绪就计算"的策略
其实有一个前提假设,就是电路图不存在反馈回路(combinational loop)。
对于本次作业的纯组合电路来说这个假设是成立的,但如果将来引入时序电路
(比如D触发器),这个假设就不一定成立了。
另外在输出格式上这一轮比较复杂。五种逻辑门和三态门按 "元件名-引脚号:电平"
格式输出,但译码器要输出 "M(3)1:0" 这种格式(表示Y0引脚为0),数据分配器
要输出 "F(2)1:--0-" 这种格式(表示各输出引脚的信号状态,'-' 代表无效)。
这部分我在 display 方法里用 instanceof 判断来做:
if(e instanceof Dec) { ... }
else if(e instanceof Demux) { ... }
else { ... }
写的时候我就觉得用 instanceof 不太对劲——老师说尽量用多态而不是类型判断。
2.3 第三轮作业(程序4):子电路与异常输入检测
第三轮是三轮中最复杂的。子电路这个需求其实就是在考察组合模式(Composite
Pattern)——题目里已经明示了这一点。子电路本质上是一个"大元件",对外暴
露输入输出引脚,内部由其他元件组成。
我的设计思路是这样的:定义一个 SubCircuit 类来存储子电路的元信息(输入
引脚列表、输出引脚列表、内部的连接信息列表),然后在解析阶段把所有子
电路定义收集起来。在信号传播阶段,子电路内部的元件和主电路中的元件按
同样的规则处理,只是在命名上加上子电路编号作为前缀(比如 C1-A(2)1-0)。
但异常输入检测这件事比我想象的棘手很多。题目定义了五种异常类型,而且
有严格的优先级顺序:
一个连接信息中包含两个或多个输入(优先级最高)
一个连接信息中没有输入
一个连接信息中没有输出
一个连接信息中输入输出写反
一个输入引脚接受来自多个不同输出的信号(优先级最低)
而且如果一条输入出现多种异常,按优先级只报最高优先级的;多条连接信息
都有异常时,只处理最前面那条。
我的检查逻辑主要写在了 checkConnectionErrors 方法里。这个方法的大致流程
是:遍历每条连接信息,对连接信息中的每个引脚判断它是"驱动端"还是"负载端"。
判断依据是通过 isDriver 方法来做的——如果是主电路的输入信号,或者是元件
的输出引脚(末尾是-0),或者是子电路的输出引脚,那么它就是一个驱动端;
否则是负载端。
但这里我犯了一个很隐蔽的错误——对子电路引脚的判定不够严谨。在 isDriver
方法中,我的逻辑是:
if (isSubCircuitPin(pin)) {
int cid = Integer.parseInt(getSubPinCircuitId(pin));
SubCircuit sub = subCircuits.get(cid);
if (sub != null && sub.outputs.contains(getSubPinName(pin)))
return true;
return false;
}
这个逻辑的意思是:如果引脚属于某个子电路,就查该子电路的输出列表中是否
包含这个引脚名——如果包含就是驱动端,否则不是。但这里有一个边界情况没有
考虑:如果这个引脚的名字恰好出现在子电路的输出列表中,但该子电路实际上
并没有被正确连接(例如子电路的某个输出没有连到下游元件),那么这个方法
的返回值仍然是 true,但后续的信号传播会因为缺少连接关系而静默失败。
这个问题在样例7(输入引脚冲突检测)和样例8(多异常优先级)的测试中其实
没有暴露——因为样例覆盖的都是"有明显错误"的场景。但如果出现"错误不明显"
的情况,比如子电路的输出引脚名字碰巧和某个输入信号重名,我的代码可能会
给出不准确的判断。
采坑心得:
3.1 递归信号传播的"幽灵-1"问题
第一轮我用递归方式实现信号传播(getResult方法)。当时的设计是:如果一个
元件的某个输入引脚没有连接到有效信号源,getResult 返回 -1 作为标记值。
这个做法在第一轮确实能工作——因为第一轮的测试样例都是"整整齐齐"的电路,
没有输入不完整的情况。
但到了第二轮,三态门的引入让这个 -1 标记变得非常微妙。三态门在控制引脚
为低电平时进入高阻态——此时它不是一个错误状态,而是一个"合法的无效状态"。
但我用同一个 -1 来代表"输入未连接"和"三态门断开",这两种情况的语义完全
不同。在输出阶段,我需要区分"这个元件因为输入不全所以不输出"和"这个元件
计算了但因为三态门断开所以输出无效"。
最后我是在第二轮重写代码时彻底放弃了这个方案,改用 boolean processed
标记来区分。这个教训让我意识到:该用枚举就用枚举,该用布尔标记就用布尔标记。
3.2 字符串解析的"中文空格"陷阱
在处理 "INPUT: A-1 B-1" 这行
输入时,我用的是 substring(6) 然后 split("\s+")。这个逻辑在样例里跑
得好好的,结果PTA系统判了0分。
我反复检查了才发现——题目描述里写的是"英文空格",而我在本地
IDE里复制样例的时候,有时候会不小心把中文全角空格(U+3000)也复制进去。
后来我的应对办法是在解析前加了一行:
header = header.replace("\uFEFF", "").trim();
以及将所有空格统一处理。
3.3 HashMap 的迭代顺序陷阱
第三轮在输出排序时,我用了 LinkedHashMap 来保持插入顺序,配合 sort 方法
做二次排序。但在测试阶段发现,输出顺序有时和本地不一致。
追因发现是因为我在构建 components 时用的是 LinkedHashMap,它保持的是
插入顺序而不是自然顺序。当代码中有多处调用 parseComponentName 的地方,
插入顺序依赖于连接信息中元件出现的先后顺序——而题目并没有保证这个顺序。
所以我后续加了 sort 做兜底排序。但这也意味着 LinkedHashMap 的"保持插入
顺序"特性实际上对排序需求没有帮助,用普通 HashMap 就够了——这是我一开始
没有想清楚的地方。
3.4 三态门控制引脚编号的迷思
题目说三态门的"0号引脚为控制端、1号引脚为
输入端、2号引脚为输出端"。我在写 Tri 类的构造函数时,理所当然地把
控制引脚设为0,输入引脚设为1,输出引脚设为2?
但问题出在连接信息的解析上。当输入是:
[I S1-1]
[E S1-0]
这里的 S1-0 指的是三态门S1的0号引脚(控制端)。在我的代码里,控制引脚
在 inPorts 列表中,所以它应该接收来自其他地方的信号。但 S1-0 这个引脚
的名字末尾是 "-0"——而第一轮的思想是"末尾是-0的都是输出引脚"。
这个惯性思维导致我在 isDriver 判定时,把所有末尾是 "-0" 的引脚都当成了
驱动端,而三态门的0号控制引脚实际上应该是一个"负载端"(它需要接收信号)。
改进建议:
4.1 消除 instanceof 类型判断
前面提到,在 display 方法里我用 instanceof 来区别不同元件的输出格式。
这种做法违背了开闭原则(OCP)——每新增一种元件类型,就要在 display 里
多加一个 else if。
改进方案是在 Element 抽象类中增加一个 formatOutput 方法:
abstract String formatOutput(Map<Integer,Integer> outValues);
然后每个子类实现自己的输出格式化逻辑。Dec 返回 "元件名:0" 这种格式,
Demux 返回 "元件名:--0-" 这种格式,其他元件返回 "元件名-0:1" 这种格式。
这样 display 方法就变成了一个简单的遍历循环,不依赖类型判断。
4.2 子电路的递归嵌套支持
当前代码不支持子电路内部再嵌套子电路(最多一层)。虽然本次作业没有要求,
但从组合模式的设计理念来看,理论上应该支持任意深度的嵌套。改进方向是让
SubCircuit 也成为 Element 的子类,这样它的 evaluate 方法就可以递归地计
算子电路内部的所有元件。这本质上是把"组合模式"从半吊子实现变成完整实现。
总结:
第一轮作业大致就是——基于递归的信号传播,几个 HashMap 搞定一切。当时
第二轮一来,需求一变,第一轮的设计几乎全改。接下来也是不断大改
具体来说,我从这三轮作业中学到了:
(1)不要过早优化,但一定要为扩展留好接口。第一轮的 Unit 类如果把输入
引脚和输出引脚都用 List 而不是固定字段来管理,第二轮的改造代价会小很多。
(2)魔法值和枚举的差距比表面看起来大得多。用 -1 表示"无效"看起来很省事,
但在多种"无效"语义并存的时候就会变成灾难。
(3)测试不能只跑给定的样例。PTA 的样例是用来验证"正常路径"的,但真正
容易出问题的是各种边界条件——这一点我在程序4的异常检测部分深有体会。
(4)设计模式不是背下来就行的。组合模式的定义我考前背得滚瓜烂熟,但真到
写代码的时候,子电路的"递归嵌套"这个特性我一开始并没有考虑到,最后实现的
是一个"只能嵌套一层"的伪组合模式。
接下来需要进一步学习的方向:(a)设计模式的实际应用场景和边界条件;
(b)单元测试的编写方法——目前我只会用 System.out.println 来调试,效率
很低;(c)正则表达式的进阶使用,尤其是和 Java 字符串处理的配合。
要求的一到三次作业类图等如下:(从一到三)






浙公网安备 33010602011771号