作业集4~6的总结性Blog
一、前言
本阶段共完成了三次数字电路模拟程序的迭代作业,分别是作业集4(基础逻辑门电路模拟)、作业集5(含组合电路元件的模拟)和作业集6(引入子电路和异常检测的模拟)。每一次作业都在前一次的基础上增加新的功能与复杂度,三次作业的核心任务都是实现一个数字电路模拟器,能够解析电路描述、计算元件输出,并按要求输出结果。
三次作业的侧重的知识点各不相同。作业集4聚焦于基础数字逻辑,核心知识点包括五种基本逻辑门(与门、或门、非门、异或门、同或门)的逻辑功能与信号传播、引脚信息的解析与连接关系的建立、信号值的迭代传播算法,以及元件按类型和编号排序输出的规则。这是整个项目的基础,要求对数字电路的基本概念有清晰的理解,并能将其转化为数据结构与算法。作业集5扩展了组合逻辑电路,新增了四类元件(三态门、译码器、数据选择器和数据分配器),核心知识点包括多引脚类型(控制引脚、输入引脚、输出引脚)的处理、多输出元件的输出格式、编码与译码的逻辑映射,以及高阻态等特殊信号状态的处理。这一阶段重点考察了对复杂元件行为的建模能力。作业集6引入了系统级设计,核心知识点包括子电路的定义与引用、组合模式的应用、子电路内部信号的隔离与传播,以及五种异常输入的检测与优先级处理。这一阶段重点考察了面向对象设计能力,特别是如何将子电路视为一种特殊的电路元件,实现递归组合。
三次作业的难度明显在逐渐增长。作业集4难度适中,主要挑战在于理解信号传播的迭代本质,以及处理元件之间复杂的依赖关系。由于电路中的元件可能形成任意长度的依赖链,必须采用迭代计算的方式,直到所有元件的输出都稳定下来。作业集5难度明显提升,主要体现在两个方面:一是新增元件的逻辑差异较大,三态门有高阻态,译码器有多个输出引脚,数据选择器和分配器需要处理控制编码;二是输出格式多样化,译码器输出的是输出0的引脚编号,数据分配器需要按顺序输出所有输出引脚的状态(无效引脚输出"-")。作业集6难度最大,不仅因为引入了子电路这个复杂概念,还因为新增了异常检测功能。子电路需要支持嵌套引用,且子电路内部的元件编号与主电路可以相同,这要求良好的命名空间隔离。异常检测涉及五种不同类型的错误,每种错误的判断逻辑和优先级都不同,需要仔细设计。此外,组合模式的设计虽然优雅,但也增加了代码的抽象层次。
二、设计与分析
(1)作业集4
本次作业要求实现包含与门、或门、非门、异或门、同或门五种基本逻辑门的数字电路模拟程序。输入包括电路连接信息和输入信号,程序需要计算所有元件的输出并按要求排序输出。
类图:

从类图可以看出,Circuit类作为核心数据容器,存储了输入信号、连接信息、门实例和信号来源映射。Gate类作为数据载体,封装了元件的名称、类型、输入引脚数、输入来源映射和输出值。InputParser负责解析原始输入行,GateParser负责从连接信息中提取和构建门对象,CircuitEvaluator负责执行信号传播计算,OutputFormatter负责将计算结果格式化为要求的输出格式。这种分层设计将数据、解析、计算和输出分离,符合单一职责原则。
SourceMontor的生成报表内容:

根据SourceMonitor生成的报表,我的作业集4的代码规模为283行,包含220条语句,其中分支语句占比31.8%,方法调用语句有116条。从类的数量来看,共有7个类和接口,每个类平均包含1.86个方法,平均每个方法包含14.54条语句。CircuitEvaluator.evaluate()方法是最复杂的方法,位于第163行,最大圈复杂度为11。这说明信号传播的迭代计算逻辑虽然清晰,但分支判断较多,有一定的复杂度。最大块深度为6,平均块深度为2.68,表明代码嵌套层次控制在合理范围内。平均复杂度为4.78,表明各方法的复杂程度适中。
核心实现分析:
信号传播是本次作业的核心算法。CircuitEvaluator.evaluate()方法采用迭代计算的方式:每一轮遍历所有门,检查其输入引脚是否都已就绪(即所有输入来源在信号值映射表中都有值),如果就绪则计算其输出并存入信号值映射表,然后进入下一轮迭代,直到某一轮没有新的输出产生为止。这种方法虽然简单直接,但对于依赖链较长的电路可能需要多次遍历,存在一定的性能开销。computeGateOutput()方法负责根据门类型和输入值计算输出结果。五种门的逻辑通过一个switch语句分支处理,每种逻辑都清晰易懂。与门只要有一个输入为0就输出0,或门只要有一个输入为1就输出1,非门对输入取反,异或门在两个输入不同时输出1,同或门在两个输入相同时输出1。
作业集4让我深刻体会到了单一职责原则在实际编码中的重要性。将输入解析、门解析、电路计算和输出格式化分离到不同的类中,使得每个类的职责清晰明确,代码的可读性和可维护性都得到了显著提升。当后续迭代需要增加新功能时,这种设计能够最大限度地减少对其他模块的影响。在信号传播算法的设计上,迭代计算虽然直观,但如果电路规模较大,可以考虑引入拓扑排序优化。
(2)作业集5
本次作业在基础逻辑门的基础上,新增了四种组合电路元件:三态门、译码器、数据选择器和数据分配器。这些元件的引入不仅增加了系统的功能,也对代码架构提出了更高的要求——不同元件的引脚定义不同,输出格式各异,需要更加灵活的设计。
类图:

Component接口作为整个元件体系的顶层抽象,定义了所有元件必须实现的方法,包括获取名称和类型、计算输出、判断是否可计算等。BaseComponent抽象类实现了这些方法中的通用部分,如输入来源的管理和排序键的提取,同时保留了compute和canCompute等关键方法让子类去实现。各具体元件类——LogicGate、TriStateGate、Decoder、Multiplexer和Demultiplexer——分别封装了五种元件的特有逻辑。ComponentFactory工厂类负责根据类型和参数创建对应的元件实例,将对象的创建与使用分离。这种设计实现了元件之间的解耦,新增元件时只需新增一个子类并在工厂中注册即可,现有的代码无需改动,符合开闭原则。
SourceMontor的生成报表内容:

根据SourceMonitor生成的报表,作业集5的代码规模为554行,包含439条语句,其中分支语句占比33.5%,方法调用语句有74条。从类的数量来看,共有7个类和接口,每个类平均包含28.29个方法,平均每个方法包含2.16条语句。Main.equals()方法是最复杂的方法,位于第19行,最大圈复杂度为4。最大块深度为8,平均块深度为2.17,平均复杂度为1.75。与作业集4相比,方法数量大幅增加,但每个方法的平均语句数却显著减少,这说明代码的粒度更细,每个方法只负责单一的职责。然而,Main.equals()方法作为最复杂的方法,其复杂度为4,表明该方法涉及较多的条件判断,需要仔细审视其实现是否合理。报表中Methods per Class为28.29,这个数值与实际情况(7个类,每个类平均约2-3个方法)存在明显偏差,可能是SourceMonitor将内部类的方法也计入统计所致。
核心实现分析:
作业集5的核心挑战在于处理不同类型元件的引脚编号规则和输出格式。三态门的引脚0为控制端、1为输入端、2为输出端,译码器的引脚按控制-输入-输出的顺序排列(0-2为控制,3-5为输入,6-13为输出),数据选择器和数据分配器则以控制引脚数来命名。为了应对这种多样性,parsePinInfo和parseComponentFromPin方法使用正则表达式提取引脚信息,并根据元件类型进行不同的处理。Pin类的引入使得引脚信息的处理更加规范,避免了字符串拼接带来的错误风险。在信号传播方面,作业集5沿用了迭代计算的方式,但需要处理更多种类的输出格式:译码器需要将选中的输出引脚置0,其他输出引脚置1;数据分配器需要按顺序输出所有输出引脚的状态,无效引脚输出"-"。这些特殊格式在各自元件的getOutputDisplay方法中实现,保持了输出的统一入口。Component接口的getOutputDisplay方法统一了所有元件的输出格式,使得输出部分的代码可以与具体元件类型解耦。
作业集5让我深刻体会到了接口设计在面向对象编程中的重要性。通过定义Component接口,所有的元件都可以被统一管理和计算,而不需要关心具体类型。这种设计使得在main方法中可以简单地遍历components集合,调用canCompute和compute方法,而不需要针对每种元件类型编写特定的逻辑。同时,工厂模式的应用使得元件的创建更加灵活,新增元件时只需扩展工厂方法即可。然而,本次作业也暴露出了一些设计上的不足。Demultiplexer类的getOutput()方法返回null,因为它有多个输出引脚,这与Component接口的单一输出语义存在冲突。这说明接口设计需要更加精细地考虑多输出元件的场景,或者考虑引入更灵活的输出模型。此外,Main类中包含了过多的逻辑,包括输入解析、元件创建、信号计算和结果输出,这违反了单一职责原则,在后续作业中需要将不同职责分离到独立的类中。
(3)作业集6
作业集6是三次作业中功能最复杂的一次,在保留原有元件的基础上,新增了子电路的定义与引用机制,以及五种异常输入的检测与处理。子电路的引入使得电路可以分层设计,大大增强了系统的表达能力;异常检测则提高了程序的健壮性和用户体验。
类图:

Component接口是所有元件的统一抽象,定义了获取输出、计算、添加输入来源等核心行为。LogicGateBase抽象类作为五种基本逻辑门的基类,提供了通用的属性和方法实现。各具体门类(AndGate、OrGate等)继承LogicGateBase并实现各自的逻辑。SubCircuit类实现了Component接口,内部维护了输入引脚列表、输出引脚列表、子元件列表和内部连接映射,使得子电路可以像普通元件一样被组合和计算,实现了组合模式的核心思想。ExceptionDetector类专门负责异常检测,Parser和MainParser分别负责子电路和主电路的解析,Simulator负责信号传播计算,OutputFormatter负责输出格式化。各个类职责清晰,符合单一职责原则。
SourceMontor的生成报表内容:

根据SourceMonitor生成的报表,作业集6的代码规模为669行,包含575条语句,其中分支语句占比28.2%,方法调用语句有143条。从类的数量来看,共有9个类和接口,每个类平均包含32.22个方法,平均每个方法包含1.97条语句。最大块深度为8,平均块深度为2.06,平均复杂度为1.00。Signal.INVALID.toString()方法是最复杂的方法,位于第14行,最大复杂度为1。与作业集5相比,代码规模进一步扩大,但方法的平均语句数继续减少,说明代码粒度持续细化。值得注意的是,最大复杂度从4降到了1,表明本次作业中各个方法的逻辑更加简单、聚焦,没有出现复杂的方法。这得益于组合模式的合理应用,将复杂的逻辑分散到了多个小方法中。然而,平均复杂度1.00这个数值值得怀疑,因为SubCircuit.compute和ExceptionDetector.detect等方法的实际逻辑并不简单。这可能是因为SourceMonitor将Signal枚举等简单类也纳入了统计,拉低了整体平均值。
核心实现分析:
作业集6最核心的设计是子电路的实现。SubCircuit类实现了Component接口,内部维护了输入引脚列表(inputs)、输出引脚列表(outputs)、子元件列表(children)和内部连接映射(internalConnections)。当子电路被计算时,它首先将外部输入信号映射到内部输入引脚,然后依次计算所有子元件,最后通过内部连接将子元件的输出传递到输出引脚。这种设计使得子电路可以像普通元件一样被嵌套和引用,实现了电路的层次化设计。信号传播算法在作业集6中得到了进一步优化。在SubCircuit.compute方法中,首先构建内部信号映射表(internal),将外部输入信号映射到子电路的输入引脚。然后重置所有子元件的输出状态,通过迭代计算逐步求解所有子元件的输出。每轮计算完成后,处理内部连接,将子元件的输出传递到子电路的输出引脚,直到没有新的变化产生。异常检测是作业集6的另一大亮点。ExceptionDetector类负责检测五种异常:多个输出、无输入、无输出、顺序错误和输入冲突。其中顺序错误和输入冲突的判断涉及到对引脚类型的识别,需要区分INPUT信号、元件输出引脚(以-0结尾)和子电路引脚(Cx-xxx格式)。
作业集6让我对组合模式有了深刻的理解和应用。将子电路设计为Component接口的实现类,使其能够像普通元件一样被组合和嵌套,这种设计极大地增强了系统的扩展性和表达能力。子电路内部完全自包含,外部只需要关心其输入输出引脚,而不需要了解其内部实现细节,这正是封装思想的体现。异常检测的实现让我意识到了防御性编程的重要性。在解析输入时不仅要处理正确的输入,还要能够优雅地处理各种错误情况,并给出清晰的错误提示。五种异常的优先级处理也让我对责任链模式有了初步的理解——按照优先级顺序依次检查异常,一旦发现则立即报告,这种思想可以应用于更广泛的场景。此外,MainParser.parseConnection方法中对子电路输出引脚的处理不够完善。当遇到C1-B这样的子电路输出引脚时,代码只检查了它是否是输入引脚,没有处理输出引脚的情况。虽然在本版本中通过异常检测部分地解决了这个问题,但从架构上看,应该让子电路的输出引脚能够像普通元件的输出引脚一样被直接连接和使用,这需要在parseConnection中增加对子电路输出引脚的处理逻辑。
回顾这三次作业的完成过程,踩过的坑主要集中在这几个方面:
首先是子电路解析时startIdx的定位问题。作业集6中,子电路信息定义在主电路之前,解析完子电路后需要正确记录主电路的起始位置。我最初的实现是用一个变量记录每次遇到子电路结束时的位置,但这个变量被后面的子电路覆盖了。调试时打印日志才发现,第一个子电路结束后的位置被第二个子电路覆盖了,导致主电路解析从错误的位置开始,所有连接信息都读不到。改了好几次才确定下来,花费了不少时间反复打印日志对比不同输入格式下的索引变化。
然后是子电路内部信号传播的顺序问题。在SubCircuit.compute中,我最初先处理internalConnections再计算children,结果子元件的输出信号无法传递到子电路的输出引脚。调试日志显示,internalConnections中记录的key(如C1-C)在internal映射表中找不到对应的值,因为children还没计算。把顺序调过来,先计算children再处理internalConnections,这个问题就解决了。后来又发现有些依赖链需要第二轮计算才能稳定,就加了一个do-while循环,但控制不好又容易死循环,最后只做两轮计算。
异常检测的优先级顺序也折腾了很久。作业集6要求按5种异常的顺序依次检测,优先级靠前的先处理。我最初用多个if并列判断,结果一条连接同时有多个异常时会输出多条错误信息。题目要求只输出优先级最高的那条,改成if-else if链之后解决了这个问题。另外"无输入"和"无输出"这两个异常的判断特别容易搞反,[A(2)1-1]应该报include none input,[A(2)1-0]应该报include none output,两者就差一个数字,花了不少时间看题目描述才搞明白。
还有一个问题折磨了我很久:当连接中只有一个引脚且该引脚是子电路的输出引脚时(如[C1-B]),isOutputPin方法会返回true,但这个引脚实际上应该作为输入使用。调试日志显示C1-B被判断为输出引脚,导致outputCount为1但inputCount为0,触发了错误的异常类型。后来在isOutputPinInInputPosition方法中把子电路引用直接返回false才解决了这个问题。
我觉得我的代码有以下几个方面可以改进:
首先,信号传播算法可以优化。目前的迭代计算每次都要遍历所有元件,即使大部分元件已经计算完毕也要重新检查一遍。对于大规模电路,可以用拓扑排序构建依赖图,只计算尚未稳定的元件,把O(n²)降到O(n)。不过这个方法实现起来需要解析元件之间的依赖关系,代码量会增大不少。
其次,异常检测的逻辑可以更清晰。目前isOutputPin和isOutputPinInInputPosition两个方法承担了太多的判断职责,各种正则表达式混在一起,可读性很差。可以拆成几个独立的判断策略,比如区分INPUT信号判断、后缀判断、子电路引脚判断等,每个策略单独成方法,然后用责任链模式串起来,这样新增一种判断规则时不需要改原有的代码。
然后,子电路和主电路的解析应该统一。目前Parser和MainParser两个类的功能高度重叠,代码重复率比较高。可以抽象出一个基类,把通用的解析逻辑放进去,子类和主类只处理各自特有的部分。这样代码会更干净,也更容易维护。
最后,输出格式化可以更灵活。目前的实现是在OutputFormatter里硬编码了元件的输出顺序,如果以后新增元件类型,还得改这里的代码。可以考虑让每个元件自己声明输出顺序的优先级,格式化器只需要按优先级排序即可。
做完这三次作业,我最大的收获不是学会了数字电路怎么模拟,而是真正理解了“面向对象”到底在解决什么问题。作业集4的时候我一开始习惯性的把所有逻辑塞到Main类里,用一堆HashMap存数据、用循环迭代算信号,功能确实跑通了,但代码改起来特别费劲——加一个新元件类型就要改好几个地方。作业集5开始尝试用接口和继承把不同元件分开,虽然一开始不习惯,但慢慢发现加新元件的时候只需要新建一个类、实现几个方法,其他地方完全不用动。到了作业集6用组合模式实现子电路,我才真正体会到“递归组合”这个东西为什么在面向对象里这么重要——子电路和普通元件用同一个接口对待,外部根本不需要关心它内部有多少元件、怎么连接的,只管调用compute就行。
面向对象设计原则方面,作业集4其实没什么设计可言,就是面向过程。作业集5通过Component接口统一了所有元件的计算和输出行为,初步实现了开闭原则——新增元件不需要改现有代码。作业集6通过组合模式实现了子电路的递归嵌套,同时各个类的职责也分得更清楚了:Parser只解析子电路,MainParser解析主电路,ExceptionDetector检测异常,Simulator计算信号,OutputFormatter格式化输出,每个类只管一件事。
但也明显感觉到自己还有很多东西没掌握。多态的应用还不够灵活,作业集6中有些地方还是用instanceof来区分元件类型,而不是利用多态让不同的对象自己处理自己。设计模式也只用了组合模式和简单的工厂模式,责任链模式在异常检测中其实很适用,但当时没用到。如果以后做更复杂的系统,这些设计模式的理解需要更深入。
三次作业做下来,从面向过程到面向接口,再到组合模式,每一步都在逼迫我重新思考代码该怎么组织。数字电路本身的知识倒没有那么难,但如何用代码去模拟它、如何让代码在面对功能扩展时不需要推翻重来,这才是这三次作业真正教会我的东西。
浙公网安备 33010602011771号