面向对象设计与构造U1总结

曾芸-24373393 2026-03-28 20:07:50

OO第一单元总结

一、前言

第一单元的三次作业,我们从一个简单的表达式解析化简开始,逐步迭代到支持自定义函数、指数函数、选择表达式、二元变量、递推定义以及求导因子。整个过程中,我深刻体会到了面向对象设计在应对需求变化时的优势,也踩了不少坑。这篇博客将对我的程序结构进行度量分析,总结架构设计经验,反思 bug 与测试策略,并谈谈这一单元的学习心得。

二、程序结构度量分析

2.1 总体情况

项目总代码行 1221 行,其中源码 1087 行,空行 134 行
总圈复杂度(WMC)248,平均每个方法复杂度 2.28,平均类复杂度 12.40

整体结构清晰,大部分类的复杂度较低,少数核心类承担了较多职责。

2.2 类分析

img

设计说明:

  • 节点层次:Node 接口定义了 accept 方法,所有 AST 节点(Expr、Term、各种 Factor)均实现它,配合 Visitor 模式实现算法与结构的分离。
  • 表达式层次:Expr 包含多个 SignTerm,每个 SignTerm 包含一个 Term。Term 包含多个 Factor。
  • 因子层次:Factor 接口的子类覆盖了所有语法元素,包括基本变量、常数、表达式因子、指数函数、自定义函数、选择表达式、求导因子、递推因子。
  • 多项式表示:ExpPoly 采用 Map<Mono, BigInteger> 表示多项式,Mono 包含 x 和 y 的指数以及一个 ExpPoly 类型的内部指数(用于表示 exp(...) 嵌套)。
  • 访问者:ToExpPolyVisitor 实现 Visitor,完成 AST → 多项式的转换。

优点:

  • 扩展性强:新增语法元素只需新增 Factor 子类和对应的 visit 方法。
  • 数据与算法分离:求值、求导等操作独立于 AST 结构。
  • 多项式表示简洁:ExpPoly 的合并、乘法、求导等操作封装良好,支持任意指数和嵌套指数。

缺点:

  • ExpPoly 的 toString 方法复杂(达到 60 行),包含多种特殊情况的处理,降低了可读性。
  • 递归定义函数(如 f{n})的展开借助了 Deque 作用域和递归栈,逻辑稍显隐晦,容易出错

2.3 复杂度分析

img

其中有一些类复杂度较高的原因分析如下:

平均复杂度 (OCavg)最大复杂度 (OCmax)总复杂度 (WMC)说明
ExpPoly3.741671核心多项式类,复杂度最高,toStringmultiply 方法中分支较多
Lexer6.401632词法分析,长分支判断导致复杂度偏高
Parser2.86840递归下降解析,分支适中
ToExpPolyVisitor2.67932访问者实现,函数展开和缓存逻辑稍复杂
Processor8.0088仅一个方法,但包含多段逻辑(定义解析、主表达式解析)

改进思考

  • 高复杂度集中在 ExpPoly:这与我的代码设计架构有关,是不可避免的核心类,但 toString 方法可以拆分为单独的输出类,降低维护难度。
  • Lexer 复杂度偏高:主要是 NextToken 方法中大量 if-else 分支,也许可以考虑用状态机或查表优化?但目前的复杂度还是可以接受的。

2.4 代码规模分析

img

  • ExpPoly 是代码量最大的类,占据项目源码的 25%,因为多项式表示与运算模块是整个系统的核心。
  • ParserToExpPolyVisitor 规模适中,职责明确,未过度膨胀。
  • Factor 子类(如 NumFactorVarFactor)规模均在 20 行左右,小巧内聚。

2.5 耦合度分析

img

分析发现:

  • ExpPolyToExpPolyVisitorMonoProcessor 等类依赖,是系统的核心数据模型,耦合度高但合理,因为它承担了多项式运算的核心职责,其他模块依赖它是必要的。
  • ToExpPolyVisitor依赖几乎所有 Factor 子类及 ExprTerm,符合访问者模式的设计预期,属于“可控耦合”,便于扩展。
  • TokenLexerParser 共同依赖,作为词法与语法分析的桥梁。

2.6 总结

总的来说,复杂度方面,虽然核心类(ExpPoly)复杂度偏高,但其余类控制良好,平均复杂度 2.28 表现较好;代码规模方面,总源码 1087 行,核心模块集中,扩展类保持轻量;在耦合与内聚方面,访问者模式带来了合理的耦合,各 Factor 类内聚性好,ExpPoly 职责稍多但可接受。最大的可优化点是将ExpPoly中的toString方法拆分为单独的输出类。

三、架构设计体验

3.1 三次迭代的架构演进

第一次作业(多项式合并化简)
仅支持 x 的幂函数、常数、表达式因子,支持加减乘和幂运算。我采用了经典的递归下降解析方法,将表达式解析为 AST,然后通过Expr,Term,Factor各类中的 toPoly 方法转化为多项式,进行运算,最后输出化简后的结果。当时 Poly 只支持单变量,单项式中仅有 expX 字段。

第二次作业(引入自定义函数、指数函数、选择表达式)
新增了 exp 函数、自定义函数调用和选择表达式 [ (cond) ? true : false ]
此时因子种类增多,我想到能不能将AST中各个Node的toPoly方法集中在一起?这样便于我集中处理转化为多项式的过程,也能降低现有类的复杂度。于是通过查阅资料,我对代码进行了重构,引入了访问者模式,建立了Visitor接口以及实现该接口的ToExpPolyVisitor类。

  • 自定义函数的处理:在 Processor 中预先读取定义,ToExpPolyVisitor 中维护一个 Map<String, Expr>,遇到函数调用时替换参数(通过作用域栈 scope 实现)。
  • 指数函数:将Poly重构为ExpPoly,引入单项式Mono类,其中包括 expX 字段和 expInner 字段,后者用于表示 exp(...) 的嵌套。
  • 选择表达式:在 visit(ChoiceFactor) 中计算两个条件表达式,若相等则取真分支,否则取假分支。

此时架构开始体现扩展性:新增因子只需添加新类并在 ParserVisitor 中增加对应处理,原有 ExpPoly 的运算逻辑几乎不需改动。

第三次作业(增加变量 y、递推表达式、求导因子)

  • 变量 y:给单项式的Mono 增加 expY 字段,ExpPoly 中的乘法、求导、输出都需要修改,但得益于封装,改动范围有限。
  • 递推表达式:支持 f{n-1}f{n-2} 形式的调用,对应 RecallFactor。我通过 Deque<Integer> recurStack 记录当前递归深度,在 expandFunc 中根据偏移量查找对应的函数定义(f{1}f{2})。
  • 求导因子:dx(...)dy(...)grad(...) 被解析为 DeriveFactor,在 visit 中调用 ExpPoly 的导数方法。

在整个过程中,我始终没有重构核心数据结构,而是通过增加字段和扩展方法的方式应对变化。访问者模式使得新操作(如求导)的添加变得容易,但 ExpPoly 内部逻辑随着功能增加而变得复杂,此时重构也变得困难,这是需要权衡的地方。

3.2 可扩展性设想

假设未来增加“三角函数”因子(如 sin(factor)),我的设计可以这样扩展:

  • 新增 SinFactor 类实现 Factor,并在 Visitor 中增加 visit(SinFactor) 方法。
  • ExpPoly 需要增加 sin 的表示方式,可能需要在 Mono 中增加 sinInner 字段,并在多项式运算中处理其展开和合并规则。虽然复杂度会上升,但可以顺利实现。

四、Bug 分析

4.1 我遇到的 Bug

公测阶段

  • 第一次作业:较顺利实现,在强测和互测中未出现需要修复的bug。
  • 第二次作业:在解析选择表达式 [ (cond) ? true : false ] 时,我解析了条件表达式后,无论条件是否成立,都对两个分支进行了求值。这导致当条件分支涉及非常复杂的因子时,程序会运行时间过长,而这完全是不必要的。正确的做法应该是根据条件值只计算对应分支。这导致我在强测和互测中都受到了制裁。
  • 第三次作业:为了追求更高的性能分,我在 ExpPoly.toString 中增加了公因数提取逻辑,即对 exp(inner) 的 inner 多项式提取系数的最大公因数,将 exp(inner) 转为 exp(inner/g)^g。该优化需要递归遍历多项式的所有项并计算 gcd,在 exp 嵌套层数较深时,时间复杂度急剧上升。虽然顺利通过强测,但在互测中被构造的极端用例卡到超时。

4.2 Bug 与复杂度关系

统计出现 Bug 的方法的圈复杂度:

  • ExpPoly.multiply:圈复杂度 8(多个循环和条件)
  • ToExpPolyVisitor.expandFunc:圈复杂度 7(嵌套条件与异常处理)
  • ExpPoly.toString:圈复杂度 12(大量的 if-else 分支)
    未出现 Bug 的方法(如各 Factoraccept)圈复杂度均为 1。

这说明高复杂度方法更容易隐藏错误。当我们为了提高性能得分(指第一单元的输出长度)而主动提高复杂度时,也给我们的程序留下了隐患。这让我意识到,优化应当建立在清晰的设计和可维护性之上,盲目追求极致优化可能适得其反,这也是老师多次提及的。另外,我体会到,复杂度更低的方法,修复起来更加容易,修改篇幅也更小。

降低复杂度的思路:

  • 将复杂方法拆分为多个辅助方法,每个方法只处理一种情况。
  • 将重复出现的逻辑整合为独立方法,比如我的代码中multiply 中的合并逻辑可以提取为独立方法 mergeTerm
  • 使用设计模式替代复杂的条件分支。

4.3 互测策略

在b房间时,我的策略主要是下载同房间同学的代码,尝试找出逻辑上的漏洞。在第二次作业互测中这个策略发挥了很大的作用,而且我发现找出一个bug后,往往同房间多个同学存在相同的问题。但是在第三次作业中,我发现a房间同学的代码基本不存在明显的bug,只能通过构造极端样例,对程序的复杂度进行测试(虽然没有成功而且自己被hack了…)。

五、优化

5.1 性能优化

  • 缓存ToExpPolyVisitor 对每个函数名维护一个 Map<ExpPoly, ExpPoly>,避免重复展开同一参数下的函数。这在递推函数中效果显著,避免指数级重复计算。
  • 快速幂ExpPoly.pow 采用二进制指数算法,将时间复杂度从 O(n) 降至 O(log n)。(虽然好像有同学提出不一定更快?)
  • 输出美化ExpPoly.toString 中,对于 exp(inner) 若 inner 的系数有公因数,则提取出来写成 exp(inner/g)^g 的形式,减少了字符串长度,有利于提高性能得分。

5.2 正确性和简洁性

我的优化均基于等价变换,且通过了测试的验证,能较好地保持正确性。例如缓存不会改变结果,快速幂与直接乘等价。输出优化只改变表现形式,不影响语义。
但是优化过程中并没有做到简洁,显著地提高了程序的复杂度,带来了潜在的问题。

六、大模型使用情况

在第一次作业中,因为要实现从无到有的搭建,我耗费了较多时间,在此过程中我不断让大模型举例说明如何用递归下降法解析表达式。在后两次作业中,大模型也帮我设计了用栈来解决函数调用的问题。我认为大模型的优点是知识很全面,我们可以在对话中很迅速地学到java中的一些语法和设计模式,并判断出哪些更适合运用到我们的架构中。但是在少数情况下,大模型有“过度设计”之嫌,代码的复杂度往往更高。
在测试方面,我每次都会让大模型帮我生成尽可能全面的样例来测试我的程序,也会让大模型帮我检查互测房间其他同学的代码。一般情况下它能比较好地检查出逻辑错误,但是复杂度、优化方面提出的方案却不尽人意,也很难针对复杂度构造出极端样例。
我暂时未发现互测房间的同学有明显使用AI生成代码的现象。

七、心得体会

7.1 面向对象设计的力量

通过三次迭代,我真正体会到了好的架构带来的好处和“开闭原则”的价值。访问者模式让我能够在不修改已有类的情况下添加新功能,每次迭代只需新增类和对应的 visit 方法。虽然最初理解访问者模式有些吃力,但一旦掌握,代码的扩展性变得非常清晰。

7.2 复杂度的控制

在学习这单元之前,我其实并不太关注复杂度,觉得只要保持正确性就可以了。但是一方面,这单元的代码体量比较大,如果我们不控制复杂度,可能会导致代码可读性非常差,调试修复也非常困难(bug修复时难以达到5行以内的要求);另一方面,复杂度太高的代码会留下可乘之机,更容易出现TLE,RE的bug。所以我们在编写程序的过程中有意识地控制复杂度是非常有必要的。

八、未来方向

我认为第一单元的内容对我们的挑战比较大,但总体来说目前第一单元的课程设置已经比较好。

...全文
76 回复 打赏 收藏 转发到动态 举报
写回复
用AI写文章
回复
切换为时间正序
请发表友善的回复…
发表回复

309

社区成员

发帖
与我相关
我的任务
社区描述
2026年北航面向对象设计与构造
java 高校
社区管理员
  • 孙琦航
加入社区
  • 近7日
  • 近30日
  • 至今
社区公告
暂无公告

试试用AI创作助手写篇文章吧