OO第一单元博客

代码架构的度量分析

项目包含的类有:MainParserLexerPolyMonoFunctionEnvRecursiveFunction

类的设计思路

  1. Main

    Main 类是程序的入口,主要负责整体流程控制,包括读入输入数据、创建词法分析器和语法解析器对象、初始化函数环境,并最终输出计算结果。其设计考虑是将程序执行过程集中在入口类中,使各功能类只关注自身职责,而由 Main 完成对象组装和调用顺序安排。

  2. Lexer

    Lexer 类负责对输入字符串进行扫描,提供跳过空白符、查看当前字符、判断字符是否匹配、读取有符号整数和指数等功能。设计该类的主要考虑是将底层字符串遍历细节封装起来,使上层 Parser 不需要直接操作字符数组,而只通过统一接口完成词法级访问。

  3. Parser

    Parser 类是程序中的核心控制类,负责对输入表达式进行语法分析,同时在分析过程中直接构造并返回 Poly 对象,完成表达式求值。该类还支持普通函数定义、递推函数定义、函数调用、求导、条件因子、指数表达式以及跳过未执行分支等功能。通过递归下降分析方法,将表达式文法规则直接映射为方法调用,从而实现语法分析与语义求值的一体化。

  4. FunctionEnv

    FunctionEnv 类用于维护函数定义环境,包括普通函数体和递推函数对象,并向外提供相应的调用接口。将函数定义与表达式解析过程解耦,使函数的存储和调用都通过统一环境对象进行管理,避免 Parser 直接持有多个函数定义细节。

  5. RecursiveFunction

    RecursiveFunction 类用于表示递推函数定义,并通过缓存机制完成递推函数体的展开。它保存递推系数、参数模板、尾项以及已经计算出的函数体。设计该类的考虑是将递推函数的递推规则与普通多项式运算分离开来,使递推展开逻辑被封装在独立类中,从而避免将该部分复杂性混入 ParserFunctionEnv 中。

  6. Mono

    Mono 类用于表示单项式,由 x 的指数、y 的指数以及指数部分 expPart 组成。设计该类的目的是把单项式作为多项式中的最小结构单元抽象出来,使多项式能够通过“单项式 + 系数”的方式组织存储。该类同时提供单项式乘法、判等、排序键生成和字符串输出等方法。

  7. Poly

    Poly 类是程序中最核心的数据结构,用于表示多项式,并提供加法、减法、乘法、常数乘法、幂运算、变量代换、求导以及字符串输出等功能。设计该类的考虑是将表达式求值的结果统一表示为多项式对象,并把所有代数运算集中封装在该类中,从而使语法分析阶段只需构造和组合 Poly 对象即可完成表达式求值。

以下是项目类图:

类图

类方法汇总表:

属性数 方法数 类总代码规模
Main 0 1 45
Lexer 2 11 93
FunctionEnv 2 4 26
RecursiveFunction 6 3 39
Mono 3 11 100
Poly 1 23 279
Parser 2 22 324

类的内聚与耦合分析

本程序整体采用了以表达式处理为核心的面向对象设计。各个类分别承担输入处理、语法分析、函数环境管理、递推函数展开以及多项式运算等职责。从结构上看,系统的主要功能划分较为清晰,但部分核心类承担的职责较多,存在一定程度的内聚不足和耦合偏高的问题。

内聚分析

Lexer 类的内聚性较高。该类主要负责字符串输入的扫描、跳过空白符、识别字符和读取数字等操作,其所有属性和方法都围绕词法分析这一单一目标展开,因此职责集中、结构清晰。FunctionEnv 类的内聚性也较高,它仅用于维护普通函数和递推函数的定义环境,并向外提供函数调用接口,功能较为单一。RecursiveFunction 类主要实现递推函数的展开和缓存管理,其字段和方法都服务于递推表达式求值,因此也具有较好的内聚性。

相比之下,Mono 类和 Poly 类虽然都围绕数学对象展开设计,但承担的功能范围较广。Mono 类不仅表示单项式的指数结构,还负责单项式乘法、判断、排序键生成以及字符串输出,因此其职责已经不再局限于纯数据存储。Poly 类的问题更为明显,它既承担多项式的数据表示,又负责加减乘幂、代入、求导、指数表达式处理和字符串格式化等多项任务。虽然这些功能都与多项式对象密切相关,但过多功能集中于一个类中,会使得类规模偏大、维护难度上升,因此其内聚性相对一般。

Parser 类是整个系统中内聚性最弱的类。它本应主要承担语法分析职责,但实际上还负责普通函数定义解析、递推函数定义解析、条件表达式分支选择、跳过未执行分支以及调用函数环境直接完成表达式求值。也就是说,该类同时混合了语法分析、部分语义处理以及执行控制等多种职责,导致其方法数量较多、控制逻辑复杂,内聚性不足较为明显。

耦合分析

本程序的耦合主要集中在少数核心类之间。Main 类作为系统入口,需要创建并组织 LexerParserFunctionEnvPolyRecursiveFunction 等对象,其对多个类存在直接依赖。这种耦合属于主控类的正常现象。FunctionEnv 类与 PolyRecursiveFunction 存在较轻量的关联,主要体现为对象持有和方法转发,因此耦合程度不高。RecursiveFunction 类则与 Poly 存在较强关联,因为递推函数的参数模板、尾项和缓存结果都由 Poly 表示,这种耦合具有明显的领域合理性。

系统中耦合最强的类是 Parser。该类同时依赖 Lexer 进行词法访问,依赖 FunctionEnv 完成函数调用,并以 Poly 作为统一的表达式结果类型,同时还需要构造 RecursiveFunction 对象。因此,Parser 实际上成为连接输入、语法、语义和计算过程的核心枢纽,类间依赖较为集中。除此之外,PolyMono 之间形成了双向紧密关联:一方面,Poly 内部使用 HashMap<Mono, BigInteger> 保存各项;另一方面,Mono 又通过 expPart 持有一个 Poly 对象来表示指数部分。这种递归式的数据结构设计增强了表达能力,但同时提高了两个类之间的耦合程度,也增加了程序理解和后续修改的复杂度。

类的度量指标表
class OCavg OCmax WMC
Main 6.00 6 6
FunctionEnv 1.50 2 6
RecursiveFunction 1.33 2 4
Lexer 1.91 5 21
Parser 2.68 9 59
Mono 2.27 10 25
Poly 2.83 7 65
程序的优缺点分析

从整体设计上看,本程序能够较好地完成表达式解析与多项式运算任务,各类之间基本遵循了按功能划分职责的思路。LexerFunctionEnvRecursiveFunction 的设计较为清晰,体现了较好的模块化意识;MonoPoly 对数学对象进行了较自然的抽象;Parser 则通过递归下降分析高效地组织了解析流程。

不过,程序的主要不足在于核心类职责过于集中。Parser 同时承担语法分析和部分语义求值,Poly 同时承担表示、运算和输出,这使得两个类的规模和复杂度明显高于其他类。若进一步优化,可以考虑将表达式求值逻辑从 Parser 中拆分出来,将 Poly 中的格式化输出或部分运算能力进一步模块化。这样可以提高内聚性,降低耦合度,也有助于程序后续扩展。

架构设计体验

架构演进

  1. 第一次作业代码架构

    Main 负责读入并启动解析;Lexer 只提供字符级扫描能力;Parser 采用递归下降,直接把文法映射成 parseExpr/parseTerm/parseFactor 这一套结构;Poly 则承担了全部代数职责,包括内部存储、合并同类项、乘法、幂和输出格式化。这一阶段的架构特点是“词法/语法/语义”初步分层,但语义层只有一个 Poly,因此它适合处理单变量多项式,却还没有为更复杂因子预留结构空间。

  2. 第二次作业代码架构

    把“项”抽象成了 Mono,此时 Poly 的内部存储从“指数 -> 系数”变成了“单项式对象 -> 系数”,允许项内部继续携带 expPart;Parser 同时扩充了因子分支和跳读逻辑。

  3. 第三次作业代码架构

    Mono 从一维扩展到二维,加入 xexp/yexp/expPart 三部分,因此 Poly 很自然地支持了多变量和偏导;函数相关逻辑被从 Parser 中抽离到环境对象 FunctionEnv,普通函数和递推函数都通过统一的“注册/调用”接口管理;递推函数再进一步被封装成 RecursiveFunction,把“初值 + 递推式 + 展开缓存”集中起来,避免让 Parser 直接承担递推求值职责。

新的迭代情景假设

设定一个新的迭代场景:支持三元普通函数与复合调用,例如 g(x,y,z)=xy+exp(z),并允许表达式中出现 g(f(x), y^2,dx(exp(xy))),同时保留现有的多项式、指数函数、条件表达式、偏导和递推函数能力。

这要求:

  1. 函数参数从单参数提升到多参数;
  2. 调用参数从“因子”提升为“完整表达式”;
  3. 替换机制从单一 x -> arg 升级为“形参表 -> 实参表”。
可拓展性分析

首先,语法层扩展成本可控。Parser 现在已经能区分普通函数调用、递推函数调用、导数因子、条件因子等多种分支,这说明它已经具备“按前缀分派”的形态。要支持 g(x,y,z),主要是把单参数解析改成参数列表解析;这属于局部修改,不会冲击 Poly 和 Mono 的基本结构。

其次,环境层有较好的承载能力。第三次作业中已经把函数管理抽成 FunctionEnv,如果继续迭代,完全可以把当前的 normalFuncBody 从“单个函数体”升级为“函数名 -> 函数定义对象”的映射,再把 RecursiveFunction 视为某类特殊函数注册进去。也就是说,函数环境这一层已经具备演化成“小型符号表/函数表”的基础。

bug 分析

在第二次作业中的强测与互测发现一类bug,以下是测试点:

0
[(((x+1)^4)==((x+1)^2)^2)?(0):(((((((((((((x^2+x+1)^8)^8)^8)^8)^8)^8)^8)^8)^8)^8)^8)^8)]

本应只计算条件成立后对应的分支,但原本的代码会把 yes 和 no 两个分支都解析并计算一遍,再根据条件返回结果。

这会导致当未被选中的分支非常复杂时,程序仍然会错误地进入大规模计算,从而出现输出错误、运行超时或内存占用过大的问题。

修复思路

修复方法是:只计算被选中的分支,未被选中的分支只跳过、不求值。

具体做法如下:

先判断条件 polyA == polyB
若条件为真:
解析并计算 yes 分支
跳过 no 分支
若条件为假:
跳过 yes 分支
解析并计算 no 分支

为实现这一点,在 Parser.java 中新增了一组 skip 方法,用于只移动词法分析指针,而不真正构造多项式对象。

hack 策略

搭建数据生成器,有家常版genetor.py以及hack专用版hack_genetor.py构造合法且符合cost要求的输入用于hack.

优化策略

本作业将Mono相同的项进行合并,为进一步进行优化

大模型相关使用

本作业先由大模型给出大体框架,再进行人工填充代码,未有AI直接生成的代码。

除作业代码外,曾用大模型辅助在互测中读懂房间其他人的代码并找bug、搭建数据生成器、在博客中辅助分析代码的内聚与耦合性。

大模型效果评估

  1. 作业代码框架研究上

    很有帮助,但是必须要注意辨别真假,大模型直接给出的思路框架有时候并不能考虑全面,还需智能人工而非人工智能。

  2. 互测辅助上

    有时候能找到有时候找不到bug,效果一般,不如用来辅助阅读代码。

  3. 搭建数据生成器上

    很有用,不想动脑子构建输入数据并测cost的话,可直接使用脚本当然是hack版的啦,生成几百个输入,保证合法且符合cost,可能还会有hack中的意外之喜hh

  4. 博客作业上

    很有用,codex CLI直接把三次作业的项目路径告诉它,辅助分析代码的内聚与耦合性非常方便。

另外,互测中并未发现明显ai代码

心得体会

这三次作业做下来,我最大的感受是:面向对象不是一开始就能“设计对”的,而是在一次次被新需求逼着重构之后,才慢慢理解什么叫“好的抽象”。

第一次作业的时候,其实更多还是“把题做出来”的思路。那时候觉得只要能用递归下降把表达式解析出来,再用一个 Poly 类把结果存
下来、算出来、输出出来,这件事就算完成了。写的时候会有一种很直接的成就感,因为输入、处理、输出是完整闭环,程序也确实跑
起来了。但做完以后回头看,会发现当时的设计还是比较“顺手写”的,很多职责都堆在 Poly 里,能解决当前问题,却没有真正为后面
的扩展做准备。

第二次作业给我的冲击是比较大的。因为一旦加入更复杂的因子,比如 exp(…)、条件表达式和函数调用,我马上就感受到第一次作
业那种“一个类包打天下”的写法开始吃力了。也就是在这个阶段,我才真正体会到为什么要把“项”单独抽象成 Mono。这个抽象不是为
了显得类更多、更面向对象,而是真的让我第一次感受到:一个好的结构,是能帮自己扛住需求变化的。第二次作业写完之后,我
对“不要只盯着眼前功能,而要看数据结构是否支持后续扩展”这件事有了很具体的认识。

到了第三次作业,感受就更深了。因为这次已经不仅仅是在原有表达式上补功能,而是在变量、导数、递推函数这些不同方向一起扩
展。这个时候如果前面没有把结构慢慢拆开,后面几乎是很难继续往上加的。所以第三次作业里,当我把函数环境、递推函数这些内容
进一步独立出来的时候,我会明显感觉到,自己已经不再只是“为了过样例写代码”,而是在尝试搭一个真正有层次的系统。这个过程其
实挺有成就感的,因为能感觉到自己的思维方式在变化:从“这个函数怎么写”慢慢变成“这个职责应该放在哪一层”。

总的来说,这三次作业让我对面向对象和架构设计的理解比以前具体了很多。以前我会觉得“架构”这个词有点空,但现在我会知道,它其实就是在需求不断变化的时候,代码还能不能稳住、还能不能继续长。对我来说,这三次作业不仅是在完成题目,也是在逼着自己从“会写代码”向“会组织代码”迈一步。

未来方向

首先,我觉得可以把前期的引导再做细一点。第一单元刚开始时,很多同学其实并不是不会写代码,而是不太清楚这类作业到底想训练什么。大家一开始容易把注意力全放在“怎么过样例、怎么把结果算出来”,但对“为什么要分层”“为什么要做抽象”理解不深。这样就会导致前几次作业很多人都在硬写,等到后面需求一扩展,才发现前面的结构完全撑不住。所以如果课程能在第一次作业发布前,更明确地讲清楚第一单元的核心目标是“从需求变化中学习架构演化”,而不只是“实现一个表达式处理程序”,我觉得大家会更容易建立正确的学习方向。

其次,我觉得可以增加一些“中间层次”的教学支持。现在不少同学的感受是,理论课上讲的是面向对象思想和设计原则,但真正写作业时,面对的却是很具体的词法分析、递归下降、表达式建模,理论和实践之间有一点断层。如果能在课程中增加一些更贴合作业的小型案例,比如从一个很简单的表达式解析器一步步演化到可扩展结构,给大家展示“为什么一开始的写法后面会出问题”“怎样的重构是有价值的”,那会比只讲原则更有帮助。因为很多时候,同学不是不懂封装和抽象,而是不知道在手头这道题里该怎么落地。

思考题

  1. 如何检查输入是否符合要求?包括空格,连续符号等。

词法层先保证“字符合法”和“空格位置可接受”。

  • 先逐字符扫描,只允许题目规定出现的字符,比如数字、x/y、+ - * ^ ( ) [ ] ? : , =、函数名字母等。
  • 空格不要直接忽略到底,最好统一策略:
    • 如果题目允许任意位置空格,就在 Lexer 里每次读 token 前调用 skipBlank()。
    • 如果题目只允许部分位置有空格,就不能无脑跳过,而要在读 token 时判断当前位置是否允许空格。
  • 连续符号也建议在词法阶段先拦一部分,比如 **、^^、(+、==+ 这类明显不可能合法的组合。

语法层再检查“结构是否合法”。

  • 用递归下降时,不只是“解析成功”,还要保证:
    • 每次进入 parseExpr/parseTerm/parseFactor 都确实匹配到合法结构。
    • 括号、方括号必须成对。
    • 指数 ^ 后面必须是合法指数。
    • 函数调用后面必须跟完整参数。
    • 条件表达式必须完整包含 [( … ) ? … : … ]。
  • 解析结束后必须检查 lexer.hasNext() 是否为 false,否则说明还有残留非法输入。

对“连续符号”的判断,最实用的方法是结合上下文,而不是只看两个字符。

  • 在“期待因子”的位置,只能出现:数字、变量、左括号、函数名、前导正负号。
  • 在“期待运算符”的位置,只能出现:+ - * 或右括号结束。
  • 这样像 x++y、x**y、x±*y、exp() 这类都能自然判错。
  1. 如何精确计算一个合法输入的 cost?

可以把这套 cost 理解成两件事:

  1. 对“表达式”按语法树递归计算。
  2. 对“递推函数定义”先逐层展开出 f{0} 到 f{5} 的右部表达式,再分别计算它们的 cost。

这样最不容易混。

一、表达式 Cost(Expr) 的计算
核心思想是:先按输入建立表达式树,然后自底向上递归算。

基本规则直接照题面:

  • Cost(常数) = max(1, len(常数))
  • Cost(x) = 1,Cost(y) = 1
  • Cost(a + b) = Cost(a) + Cost(b)
  • Cost(a - b) = Cost(a) + Cost(b)
  • Cost(a * b) = Cost(a) * Cost(b)
  • Cost(+a) = Cost(a) + 1
  • Cost(-a) = Cost(a) + 1
  • Cost(exp(a)) = Cost(a) + 1
  • Cost(dx(a)) = 2 ^ Cost(a)
  • Cost(dy(a)) = 2 ^ Cost(a)
  • Cost(grad(a)) = 2 ^ Cost(a) + 2 ^ Cost(a) = 2 * 2 ^ Cost(a)

幂要分底数类型:

  • 若底数是单变量因子 x 或 y,则 Cost(a^b) = 1
  • 若底数是表达式因子 ©,则 Cost(a^b) = max(Cost©, 2) ^ max(b,1)
  • 若底数是指数函数因子 exp(a),则 Cost(a^b) = 2^b + Cost(a)

条件表达式:

  • Cost([(A==B)?C:D]) = Cost(A) + Cost(B) + choose(Cost©, Cost(D)) + 5
  • 这里 choose 不是随便选,也不是取较大/较小,而是看 A==B 在当前语义下是否成立
  • 若条件真,就取 Cost©
  • 若条件假,就取 Cost(D)

函数调用:

  • Cost(f(a)) = Cost(f’) * 2
  • 其中 f’ 是把定义体里的形参 x 替换成实参表达式因子 a 后得到的新表达式
  • 递推函数调用 Cost(f{i}(a)) = Cost(f{i}') * 2
  • 同时必须满足实参 a 的 cost 不超过 500

注意这里调用和定义是两回事:

  • f{i}(a) 是“调用”的 cost
  • f{i} 本身右部表达式 e 的 cost 是“定义”的 cost

二、递推函数 Cost(Func) 的计算
题目要求的是:

  • 自定义递推函数整体要满足 Cost(Func) <= 2000
  • 且对任意 0 <= i <= 5,都要满足 Cost(f{i}) <= 2000

这里的 Cost(f{i}) 指的是:

  • 把递推定义展开后,f{i} 的等号右边表达式记作 e
  • 然后 Cost(f{i}) = Cost(e)

也就是说,检查递推函数是否合法时,不是看调用 f{i}(a) 的那个乘二规则,而是看它展开后的定义体右边本身有多复杂。

三、递推函数怎么展开
假设你有:

f{0}(x) = e0
f{1}(x) = e1
f{n}(x) = A * f{n-1}(p(x)) + B * f{n-2}(q(x)) + t(x)

那么:

  • body[0] = e0
  • body[1] = e1
  • 对 i = 2…5:
    • 先把 body[i-1] 里的形参 x 用 p(x) 代换,得到左半部分
    • 再把 body[i-2] 里的形参 x 用 q(x) 代换,得到右半部分
    • 再乘上前面的系数 A、B
    • 最后加上尾项 t(x)

得到的整个右部表达式就是 f{i} 的定义体,记作 body[i]

然后检查:

  • Cost(body[0]) <= 2000
  • Cost(body[1]) <= 2000
  • Cost(body[5]) <= 2000

只要有一个超过,就不合法。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐