NCHU_面向对象程序设计_题目集4-5总结Blog

前言

距离上一篇总结“电梯调度”的Blog已经过去了几周,这期间我们迎来了Java面向对象课程的第二阶段。如果说第一阶段是让我们从C语言的面向过程思维强行“扭转”到Java的面向对象思维,那么这几周的题目集4和题目集5则是真正让我们在代码的泥潭中挣扎着学会如何设计一个可扩展的系统

本次Blog主要针对题目集4、5进行总结。虽然只有两次题目集,但含金量(和痛苦指数)却远超以往:

  • 题目集4:这是一个承上启下的关键节点。它不仅包含了一道 7-1 NCHU_点线面问题重构,让我们在熟悉的题目上练习继承与多态;更直接抛出了本次阶段的核心大题 数字电路模拟程序-1,跨度非常大。

  • 题目集5:核心任务非常明确,就是 数字电路模拟程序-2。它在上一集的基础上进行了大幅迭代,增加了多路选择器、译码器等复杂组件,直接考验我们上一次作业的架构是否经得起折腾。

知识点方面,这两次作业集中轰炸了:

  1. 继承与多态:这是解决“点线面”和不同类型“门电路”统一管理的核心,也是这两次作业的灵魂。

  2. 抽象类:如何提取 Element 或 Component 这样的父类来屏蔽底层差异,是我们必须掌握的技能。

  3. 容器类ArrayList 和 HashMap 几乎成了代码里的标配,用来存储动态生成的元件和复杂的引脚连接关系。

  4. 正则表达式:在数字电路题目中,面对 A(8)1-1 这样复杂的输入格式,正则成了救命稻草,同时也成了Debug时的重灾区。

难度与题量分析
这两次题目集的题量不大,但难度系数大幅提高
在题目集4中,我不仅要处理点线面的重构,还要从零搭建电路模拟系统,当时我勉强用面向过程加一点点对象的思想拼凑完了代码。
但到了题目集5,当需求变为“增加多种复杂元件”且“引脚数量可变”时,我才深刻体会到什么叫“技术债”。如果第一次架构没设计好,第二次作业就几乎等同于重写。这种“牵一发而动全身”的无力感,大概就是面向对象设计中反复强调单一职责和开闭原则的原因所在吧。

总的来说,这两次作业让我从“写出能跑的代码”进阶到了“思考如何写出下次还能用的代码”,过程虽然折磨,但看着自己一步步搭建起能跑通复杂电路的系统,收获还是相当扎实的。

设计与分析:

第一次尝试(题目集4):面向过程的伪对象

在做数字电路模拟程序-1时,题目只要求处理五种基本门(与、或、非、异或、同或)。
拿到题目时,我虽然知道要用类,但潜意识里还是觉得“这就一个功能嘛”。于是我设计了一个巨大的 Gate 类,试图用一个类包揽所有事情。

SourceMonitor分析
回顾第一次提交的代码,我的 Main 类的圈复杂度高得吓人。为什么?因为我把所有的逻辑判断都堆在了一起。

看看我当时的 Gate 类设计(截取自第一次提交源码):

 

 1         // 执行逻辑计算
 2         public void compute() {
 3             if (!isReady()) return;
 4 
 5             switch (type) {
 6                 case "A": // AND
 7                     boolean allOne = true;
 8                     for (int v : inputs) {
 9                         if (v == 0) {
10                             allOne = false;
11                             break;
12                         }
13                     }
14                     output = allOne ? 1 : 0;
15                     break;
16                 case "O": // OR
17                     boolean allZero = true;
18                     for (int v : inputs) {
19                         if (v == 1) {
20                             allZero = false;
21                             break;
22                         }
23                     }
24                     output = allZero ? 0 : 1;
25                     break;
26                 case "N": // NOT
27                     output = (inputs[0] == 0) ? 1 : 0;
28                     break;
29                 case "X": // XOR (2 inputs)
30                     output = (!inputs[0].equals(inputs[1])) ? 1 : 0;
31                     break;
32                 case "Y": // XNOR (2 inputs)
33                     output = (inputs[0].equals(inputs[1])) ? 1 : 0;
34                     break;
35             }
36         }
37     }

 

存在的问题

  1. 违反单一职责原则Gate 类既负责解析字符串(如 A(2)1-1),又负责存储状态,还负责所有的逻辑运算。

  2. 缺乏扩展性:每增加一种门,我就得去修改 compute 方法里的 switch 语句。

  3. 解析逻辑混乱:在 Main 类中,我写了大量的 substring 和 split 来手动解析引脚连接,代码非常脆弱,稍微多一个空格就崩了。

第二次迭代(题目集5):痛定思痛的重构

到了题目集5,需求增加了三态门、译码器、选择器等复杂元件。
特别是译码器,它有多个输出引脚;而选择器有控制引脚。我之前那个默认“单输出、无控制位”的 Gate 类根本没法复用,
不得不重构

这次我引入了真正的继承体系,绘制了如下的类结构:

    •  Component: 顶层父类,负责存储 id 和通用的引脚连接关系 Map<Integer, String> connections

    •  Gate: 继承原有逻辑,处理常规逻辑门。

    •  Decoder: 重写逻辑,处理多输入多输出。

    •  Selector: 处理带控制位的逻辑。

关键代码分析

1. 抽象父类与通用接口
我定义了 Component 类,并实现 Comparable 接口以便于最后的排序输出。最重要的是,我把计算逻辑分散到了各个子类中。

abstract class Component implements Comparable<Component> {
    String id;
    Map<Integer, String> connections = new HashMap<>(); // 存储引脚连接:引脚号 -> 信号源ID
    
    // 核心抽象:每个组件自己决定如何计算输出
    // 但由于不同组件输出格式不同(有的输出0/1,有的输出x),这里做了一定妥协
    // ...
}

2. 复杂元件的处理(以选择器为例)
在选择器中,我需要先读取控制引脚(Pin 0...),根据控制位计算出应该选通哪个数据引脚。这种逻辑被完美封装在 Selector 类内部:

static class Selector extends Component {
    // ...
    String getOutput() {
        int select = 0;
        // 计算控制位数值
        for (int i = 0; i < param; i++) {
            if (getInput(i) == 1) select |= (1 << i);
        }
        // 根据控制位找到对应的数据引脚
        int dataPin = param + select; 
        return String.valueOf(getInput(dataPin));
    }
}

对比第一次作业,这里的逻辑清晰了太多。外部调用者根本不需要知道选择器内部是怎么工作的,实现了高内聚

3. 信号解析的挑战与遗憾
在处理信号传递时,我采用了一种“拉取”模式:当需要输出时,递归去查询输入引脚的源头。
这里有一个比较棘手的地方:不同组件的 getOutput 返回值类型可能不同(三态门可能返回 "x")。为了统一处理,我在 resolveSignal 方法中还是没能完全摆脱 instanceof 的魔咒:

// 这是一个让我不太满意的地方,为了处理不同类型的返回值,使用了类型判断
static int resolveSignal(String sourceRef) {
    Component c = components.get(cId);
    String res = "0";
    if (c instanceof Gate) res = ((Gate)c).getOutput();
    else if (c instanceof TriState) res = ((TriState)c).getOutput();
    else if (c instanceof Selector) res = ((Selector)c).getOutput();
    // ...
    return Integer.parseInt(res);
}

虽然这样写解决了问题,但它违背了“多态”的初衷。如果下次再加一种组件,我还是得改这里的代码。理想的做法应该是统一 getOutput 的接口签名,但这在处理异构组件(有的单输出,有的多输出)时确实是个设计难点。

4. 排序与输出
题目集5特别要求输出按照元件排序。在第一次作业中我可能手动写了个冒泡排序,但这次我学会了利用 Java 的 Comparable 接口:

@Override
public int compareTo(Component o) {
    // 自定义排序逻辑:先按类型字母排,再按数字编号排
    // 解决了 Gate2 排在 Gate10 后面的字典序问题
    return this.id.compareTo(o.id); 
}

这也让 main 方法中的输出代码缩减为一行 Collections.sort(complist)

源码度量分析

类复杂度分析

image

分析:
从表格中可以明显看出两个“重灾区”:

  1. Main 类:WMC(加权方法复杂度)高达 24。虽然比上次电梯作业动辄 40+ 的 Controller 要好,但它依然承担了过多的职责——既要读取输入,又要正则解析,还要负责最后的排序输出。这说明我的 Main 类依然有“上帝类”的嫌疑。

  2. Utils:这里包含了核心的递归求解逻辑 resolveSignal。由于使用了大量的 if-else 或 instanceof 来判断组件类型,导致复杂度较高。

  3. Component 子类Selector 和 Decoder 的复杂度相对较低(WMC < 6),这说明多态起到了作用——复杂的业务逻辑被成功分散到了各个子类中,避免了逻辑过于集中。

核心方法复杂度分析

image

深度剖析:

  • 复杂度之王 
    v(G)(圈复杂度)达到了 14,且嵌套深度为 4。这正是前文提到的那个“递归求值”的方法。
    原因:在这个方法里,我为了处理不同组件的返回值(有的返回三态"x",有的返回"0/1"),写了多层判断逻辑;加上递归调用的终止条件判断,使得这个方法成了整个程序最脆弱的地方。

  • Main 方法
    main 方法的复杂度主要来自于输入处理的 while 循环和内部的 switch-case(用于判断创建哪种元件)。如果能引入工厂模式,这里的复杂度至少能降低一半。

  • 改进对比
    回想起第一次作业(数字电路-1)时,我的核心计算方法 compute 圈复杂度曾一度飙升到 30+(因为所有逻辑门都在一个 switch 里)。现在通过继承体系,单个 getOutput 方法的复杂度控制在 7 以内,这证明了面向对象重构的有效性

总结

通过这两次大题的磨练,我深刻体会到了设计模式中封装的重要性平均圈复杂度反而下降了(除了那个负责统筹的 resolveSignal 方法)。这说明将逻辑分散到子类中确实有效地降低了系统的维护难度。

尽管现在的设计中仍有 instanceof 这样的瑕疵,但相比于最初那个 switch-case 满天飞的 Gate 类,这已经是一个巨大的进步了。

posted @ 2025-12-14 22:49  Tshed  阅读(14)  评论(0)    收藏  举报