309
社区成员
发帖
与我相关
我的任务
分享经了几周的“折磨”与重构,OO第一单元终于落下了帷幕。从刚开始面对长串表达式的无从下手,到最终能够有条不紊地处理嵌套函数和求导,这个过程不仅是Java语法的熟悉过程,更是编程思维从面向过程向面向对象转变的关键一步。
下面,我将从代码架构、Bug分析、测试策略等几个方面,对这一单元进行复盘。
在开始写这篇博客前,我用工具对目前的最终版代码(Main, Lexer, Parser, Poly, ElementKey)进行了度量分析。
总代码行数:约 600 行。
类与方法数量:共 5 个类,其中 Poly 类包含 17 个方法,Parser 类包含 13 个方法。
复杂度重灾区:
Parser.parseFactor():控制分支较多,因为需要判断变量、指数、自定义函数、求导等多种因子类型,圈复杂度较高。
Poly.toString():为了处理输出优化(如省略系数1、负号处理、首项正号等),内部充斥着大量的 if-else 分支,是整个程序中最容易滋生 Bug 的地方。
Poly.deriveX() / deriveY():涉及到指数多项式的链式求导法则,逻辑嵌套较深。
目前的架构采用了经典的**词法分析(Lexer) -> 语法分析(Parser) -> 抽象语法树/多项式计算(Poly)**流水线。
高内聚:ElementKey 作为项的不可变键值(包含 x的幂次、y的幂次、指数内的多项式),做到了极高的数据内聚。Lexer 只负责切词,职责单一。
高耦合:Parser 和 Poly 之间的耦合度相对较高。Parser 在解析的同时直接进行了多项式的实例化和运算(比如调用 .add(), .mul(), .substitute())。虽然简化了 AST(抽象语法树)的中间节点构建,但也导致 Parser 既管解析又管计算。
我的架构设计:
Main:入口,负责预处理字符串(如连续正负号替换)和读取自定义函数。
Lexer:维护字符串指针,提供 next() 和 peek() 接口。
Parser:基于递归下降(Recursive Descent)算法,包含 parseExpr, parseTerm, parseFactor。
Poly:核心计算类,底层使用 HashMap<ElementKey, BigInteger> 维护多项式的项。
ElementKey:重写了 equals 和 hashCode,保证同类项能够正确合并。
优点:结构非常扁平、直接。使用 HashMap 自动合并同类项,让计算逻辑(加法、乘法)变得极其简单。 缺点:Poly 隐隐有一种“上帝类(God Class)”的趋势。它既包含了纯粹的数学运算逻辑,又包含了带有副作用的输出格式化逻辑(toString)。这违背了单一职责原则。
第一次作业:当时表达式还很简单,我只用了一个简单的 HashMap<Integer, BigInteger> 来映射 x 的指数和系数。
第二次作业:引入了指数函数 exp() 和自定义函数。原本的一维 Map 瞬间失效。我经历了一次阵痛期的重构,抽象出了 ElementKey 这个类,用它来包裹 powerX, powerY 和 exponentPoly。这也是我目前架构的基石。
第三次作业:新增了求导算子和递归函数。得益于第二次作业打下的 ElementKey 基础,多项式的加乘逻辑几乎没改。我只在 Parser 中增加了求导因子的解析,并在 Poly 中利用微积分中的链式法则实现了 deriveX() 和 deriveY()。平衡复杂的递归代入与性能成为了主要难点。
新迭代情景推演: 如果下一次迭代要求引入三角函数(sin(x), cos(x)),目前的架构能否平滑扩展?
可扩展性分析:有一定的扩展性,但需要修改核心类。我需要在 ElementKey 中新增 HashMap<Poly, Integer> sinTerms 和 cosTerms 来表示三角函数的内部嵌套和外部幂次。这会让 ElementKey 的 equals 和 hashCode 变得极其庞大。更好的设计应该是抽象出一个 Factor 接口,让 VarFactor, ExpFactor, TrigFactor 各自实现求导和化简逻辑,而不是把所有属性都塞进 ElementKey 里。
在强测和公测中,我踩过几个比较痛的坑:
问题所在:Poly.toString() 方法。
特征:在输出类似于 -exp(x) 的项时,由于我的判断条件遗漏了 coefficient == -1 且带有非纯变量的情况,导致输出了 -1*exp(x),违反了化简规则(或者格式错误)。
原因与反思:大量的 if-else 用于处理首项正负号、系数为1或-1是否省略、后面是否跟有变量。这种面条式代码非常容易挂。更好的设计应该是:先将 Poly 拆分成一个个单独的 Term 对象,让每个 Term 自己决定如何 toString,最后在 Poly 层面用 String.join("+", terms) 并做一次全局的符号替换(比如把 +- 替换为 -)。
复杂度对比:出 Bug 的 toString 方法圈复杂度远高于正常运作的 add 方法。这也印证了:逻辑越绕、分支越多,越容易出错。降低复杂度的方法就是提取子方法,把符号判断、系数输出、变量输出剥离开来。
在互测环节,我的测试策略主要分为两步:
黑盒极端用例轰炸: 利用自动化脚本生成边界数据。比如连续符号 +++---x,大量的嵌套括号 (((((x))))),以及指数为 0 的情况 exp(0)。结合我近期复习离散数学时用到的等价类划分思想,我会确保测试数据覆盖了“系数为0”、“指数为0”、“空多项式”等几个正交的等价类。
白盒针对性阅读: 下载同房间同学的代码后,我不会逐行看,而是直奔他们的 toString()(找输出优化的漏洞)和 equals/hashCode(找同类项合并的漏洞)。 是否结合了代码设计结构? 是的,如果我发现对方使用了类似于我第一版那种复杂的正则表达式而不是递归下降来解析,我就会疯狂构造多层嵌套的 f(g(exp(x))) 用例,大概率能直接把对方的正则栈撑爆。
为了追求性能分(以及一点点程序员的强迫症),我做了以下优化:
合并同类项与零项剔除:在 Poly 的 addTerm 中,一旦发现相加后系数为 0,直接 terms.remove(elementKey)。这极大地减小了内存占用,防止在递归展开时节点爆炸。
输出优化:省略系数 1 和 -1,优先输出正项以节省一个 + 号。
优化是否保证了代码简洁性与正确性?
剔除零项保证了正确性,甚至让计算变快了。
输出格式的优化严重破坏了代码的简洁性。正如前文所述,它让 toString 变得臃肿。可能的解决方案是引入一个独立的 ExpressionFormatter 类专门负责输出优化,将“数学计算”与“文本渲染”彻底解耦。
在这个单元的历练中,AI 是一个非常好的辅助工具,但绝对不能成为拐杖。
代码生成率:在正确性和性能优化的代码上,我使用 AI 生成的代码占比不到 5%。主要集中在 Main.java 里那些繁琐的连续符号替换(replaceAll 正则)以及一些标准 hashCode 的重写上。核心的递归下降和求导逻辑全是我自己手敲的。因为这种高度耦合的数学逻辑,交给大模型写反而需要花更多时间去 Debug 它的逻辑断层。
辅助找 Bug 与评测:我大量使用了 AI 来帮我写评测机。丢给 AI 一个 sympy 库的需求,它能很快帮我搭好一个自动对比结果的对拍器。在这个方面,大模型的完成效果极好,节省了我大量的造轮子时间。
互测房间的“赛博同学”:在互测阶段,我确实注意到房间里有一位“天枢星”同学大量使用了 AI 生成的代码。理由很直观:他的代码里充斥着极其标准、冗长且没有实际情感的 Javadoc 注释,甚至在一些很简单的加减法方法前都有详细的 pre-condition 和 post-condition 说明。此外,他还调用了一些 Java 比较冷门、正常初学者绝对想不到的并发流(Parallel Stream)去处理多项式拼接。虽然看起来很高级,但一遇到复杂的嵌套边界条件就直接 Crash 了。
第一单元终于结束了。以前写代码是“第一步干啥,第二步干啥”,现在要想的是“这个名词应该封装成什么对象,它应该具备什么行为”。
但当程序终于不再报错,能够计算出复杂的多元偏导数时,那一刻的治愈感是无与伦比的。
如果课程组能在 Pre 阶段或者第一周,提供一个极其简单的、关于如何使用命令行/Python脚本调用 Java 程序的 入门级评测机搭建教程,或许能帮大家省去很多无谓的焦虑,把更多精力聚焦在架构设计本身上。