北航计算机学院面向对象(2023 第一单元)
文章目录
本文将以笔者学习过程中的思考感悟为基础,对2023北航计算机学院面向对象课程第一单元(作业1~3)的架构搭建和程序设计思路做简明的描述;如有不同见解,欢迎学习交流。
一、架构的搭建和迭代
在面向对象(OO)这门课程中,每个单元的作业采取增量开发的模式,需求随着时间的推进变得更加复杂、丰富;这需要我们在编程过程中尽量构建可扩展性强的架构。
1.第一次作业
第一单元第一次作业是我们建构面向对象思维很好的起点,我们在这次作业构建的架构的科学性很大程度上决定了后两次作业的工作量。
(1)需求概述
本次作业的目标可以概述为:编写一个可以化简由数字(支持前导零),变量(x,y,z),指数,括号(暂不考虑嵌套)构成的表达式的程序,去除所有括号,结果短则性能佳。
(2)基本思路
由于本人上学期没有选上 oopre,故仔细学习了课程组提供的练习题的代码,将其思想推广到本次作业的情形:
- 不要妄图靠特判和顺序循环把本次作业搞成字符串的 tirck,为了方便后两次作业的迭代,基于表达式文法的递归下降方法需要被采用。
- 作业需求中对待化简表达式的递归定义十分严谨,简单来看可以分为三个层次:
- 我们的精力集中在对表达式的解析上,所以表达式形制越简单,我们在编写文法和词法解析器时的思考量也越小;预处理是一种较好的策略(将空格全部去除,将幂全部转为乘积,将相连的正负号等价缩短)。
(3)架构搭建
- 预处理上文已经交代;
- 文法词法解析主要包括两个重要的类:
Lexer
词法解析器,指示目前程序读到的关键字段,如数字,括号,正负号等,使顺序遍历字符串的工具。
Parser
语法解析器,遇到标志符号则调用相应的解析器,简要规则:- 入口为表达式解析器;
- 遇到加减切换至项解析器;
- 遇到乘法切换到因子解析器;
- 遇到括号切换到表达式解析器。
这里为了方便描述一些细节处理没有完全写出。
- 后处理主要包括将连乘恢复为幂,根据项内各因子的符号决定项的符号(exp: -x*+y*-z --> +x*y*z);
- 合并同类项只需检查幂次的对应匹配即可。
(4)可拓展性
本次作业虽然需求较为单一,但考虑到后两次作业不可避免的增量开发,故提前支持了括号的嵌套功能;其实这也是十分自然的设计,因为层次化的递归下降法就是为了解析这种嵌套次数不定,字词出现概率不定但递归定义十分明朗的语言而设计的。
2.第二次作业
(1)新增需求
本次作业支持括号嵌套(上一次已实现),三角函数(函数内容为任意因子),自定义函数。
(2)扩展思路
-
处理三角函数
由于三角函数无法在预处理中消除,我们必须对 Lexer 和 Parser 做适当的扩展。
扩展时可参考的层次结构如下:主要的工作是扩展 Lexer 使其支持 sin/cos 的识别;扩展 Parser 使其读到 sin/cos 时再次进入因子解析器。
-
处理自定义函数
为了降低解析的复杂度,考虑在预处理中消除自定义函数,则这个过程分为两步:- 读取用户自定义的函数,做法是建立自定义函数类,包含函数名、形参列表、函数体三个关键参数。
- 依据定义的函数名识别带化简字符串中调用的函数,进行简单的字符串替换和拼接(注意括号);笔者开始时尝试使用递归的方法从内到外对调用的函数进行解析,无奈方法复杂度较高,故采用了循环化简直至式子中不出现函数名为止的较为朴素的方法。
(3)架构搭建
基于第一次作业的架构,在文法词法解析前加入了自定义函数处理的环节,扩展了文法词法解析对三角函数的支持,结构如下:
3.第三次作业
(1)新增要求
自定义函数定义时可以使用已定义的自定义函数;加入求导算子(一组输入中只出现一次)。
(2)扩展思路
根据笔者前两次作业调试 Bug 的经验,文法词法解析和后处理的步骤最容易出现错误,故考虑将新增功能的扩展尽量前移;
-
其中支持使用现有函数定义函数的处理较为简单,只需动态维护一个现有函数库,将第二次作业的函数处理类对每个函数在定义时解析出的函数体使用即可,只不过传入的参数不是完全的函数库而是目前为止已定义的函数的集合。
-
对于导数的处理,要尽可能简化我们求导的流程,则又可以采用预处理的策略;可以先对求导对象(d[xyz](content) 的 content)使用第二次作业的程序对其进行全过程解析化简,最后的通式为:
Constant * x**a * y**b * z**c * sin(A)**d * cos(B)**f [+-] ···
则基于此通式,采用递归下降的思路,可构建一个类专门用于求导,构造的三个函数调用关系如下:
其中调用的衔接是根据求导公式的十分简单的字符串拼接。
(3)架构搭建
值得注意的是,将预处理放在了函数、导数处理之后;这是因为预处理中包含了乘方转化为乘法的步骤,若放在第一步,会大大降低求导的效率。
这个架构的优点是我们可以完全复用上一次作业的文法词法解析器、后处理、合并同类项的函数和类,无需任何改动。
二、模块化重构策略
虽然三次作业是迭代开发的逻辑关系,但由于架构和需求的原因,难免会出现重构的情况;重构常常是必要的,但我们可以采取一些策略以降低我们的工作量。笔者认为,主要有以下两点:
- 非必要不改动核心架构,而是通过添加预/后处理操作使现有的核心架构能够处理新增的需求。
- 开始一个项目时遵循模块化策略(将问题的处理按流程划分),面对新的需求时仅重构其中一个流程的操作,避免修改较多的文件。
具体来说,以第一单元的作业为例:
1.三角函数导致的部分重构
在第二次作业中,支持三角函数的需求的加入使笔者不得不对代码作出一些修改:
- 在词法文法解析模块加入识别三角函数的判定,并为三角函数新建一个类以存储函数名和函数体;这部分的工作量并不大,因为三角函数括号内部可以看做因子处理,总的来看,三角函数符号可以看作一个无法被消除的括号。
- 对于化简和合并同类项的部分:
由于第一次作业在此部分的函数默认了表达式中已经没有括号了,故采用切割表达式录入项的通式,最后匹配幂次进行合并的方式。
但第二次作业存在三角函数这种“无法被消去的括号”,故笔者的化简部分需要重构。- 考虑到思路的连贯性,还是采取拆字符串的方式,但分隔符是表达式不被在括号中的加减号。
- 重新构造项类,将三角函数纳入,并使用 hashset 记录因子,方便后续的合并操作。
- 对于三角函数内的式子,递归调用此化简和合并方法。
可见这次重构涉及了四个文件,并未进行全局性的大改。
2.“懒惰”地处理求导
第三次作业的一个要求看起来十分得复杂:
加入求导算子
但我们可以这样理解:
只加入求导算子,不引入新的变量或常量
因此我们只要在词法文法分析之前将求导算子计算完毕,则后续的操作可以直接复用第二次作业的代码;故我们将求导操作放在预处理之前。
- 那如何尽量简化求导的操作呢,基本思想是将待求导的式子规范化;这里笔者采取的方式是复用第二次作业对表达式处理的全程代码,最后生成的式子较为规整,格式如下:
Constant * x**a * y**b * z**c * sin(A)**d * cos(B)**f [+-] ···
- 有了规整的式子,我们的求导操作可以由三个基本的操作构成:常数求导,一次变量求导,三角函数求导,具体逻辑可参照第一节相关内容;在此基础上进行层次化的递归下降,不难构造出求导类。
将求导操作集中在一个类里,避免了在文法词法分析时进行求导操作,直接避免了在各个因子的类中构造求导函数;虽然这样安排有些面向过程的嫌疑,但集中在一个类中极大方便了代码的维护,提高了代码的复用率,降低了编程思维量。
通过这两个例子,可以看出模块化重构和添加功能的优势。
三、Bug 的查找、修复和编程习惯
在此节,笔者将以反思的态度总结在第一单元作业编程过程中的疏漏,并记录一些实用技巧。
1.Bug 的定位
从接触编程到现在,其实每次程序运行结果出错,定位一个 Bug 比改好一个 Bug 花费的时间多得多。
在维护一个较大的项目时,这种问题尤为突出,笔者认为可以从两个方面入手改善 debug 的体验感:
- 利用好程序模块化的特点,在主程序各个阶段使用打印的方式定位 Bug 出现在哪个流程中,在相应的流程中再次利用此方法,定位 Bug 在哪个函数中;这样下来 Bug 的出现范围就大大缩小了。
- 利用好报错信息和编辑器的调试功能;得到大致范围后,根据 IDEA 详细的报错信息和自己具备的调试经验,不难精准定位 Bug。
2.Bug 的修复
对于 Bug 的修复,大抵可以遵循重构一节的策略,即非必要不要做影响基本架构的改动,非必要不要做跨文件的修改。
3. 作业中的 Bug
笔者在第一次作业和第三次作业中各出现了一个 Bug。
(1)字符串处理的 Trick
在第一次作业中,笔者在乘方转化为乘法的函数中,画蛇添足地记录了每一次乘方符号的位置,在下一次从该位置开始继续找,但忽视了展开操作对字符串长度的影响,导致有时展开不完全,出现错误,在互测中被测出2次。
(2)危险的思维定势
在第三次作业中指导书有这样一句话:在输入中,求导算子最多只能出现一次,然而,由于求导算子可以作为函数的参数,展开后可以出现多次,故需要循环处理求导因子,笔者仅仅处理了一次,直接导致强测爆炸呜呜呜。
反思:多做测试,多做测试,多做测试!
中测的数据不强,在今后的作业中还是要仔细考虑可能出现的情况,对代码进行针对性测试。
4.Hack 数据的构造
面向对象课程独特的互测机制极大地考验构造数据的能力;笔者认为构造数据的技巧有以下几点:
- 考虑边界条件和简单的样例(大数字,0,1,x**0,sin(0)**0 ……)
笔者的室友第一次作业就靠捏0收获6分 - 考虑正负号的连续和与数字的特殊组合
- 考虑函数调用的特殊情况
- 考虑字符串处理中可能的疏漏
- 考虑从易误读的定义入手
- 考虑自己编程的易错点(同一个房间的水平相差不大)
- 认真阅读他人代码
在互测中,笔者一般先采取盲狙的策略,之后再对房间内的代码进行针对性检查。关于手写评测机,由于数据的随机性,可能没有针对性构造效率高,但也不失为一种省心省力的办法。
ps: 个人体验是中测的强度不是很高,所以 强测前的自查是必要的,群里和讨论区的优秀样例都可以拿来测试。
4.编程习惯
面向对象课程作业的规模直接决定了我们需要改变以往面向过程的编程习惯,笔者到目前为止粗浅地总结了几点:
- 架构极其重要,刚接触一个作业时,我们既要根据已有需求选择合理的安排,又要“脑补”下后续可能的需求,给架构留足拓展空间。
- 巧用递归和层次分析可以大大减少编程的思维量和复杂度。
- 面向过程地安排模块(这也是自然的,做任何事总会有个先后和流程),在模块内利用面向对象的思想构造类,巧用继承和接口降低代码重复率。
- 善于利用 java 的数据类型、库和函数大大降低代码量。
- 摒弃过多的字符串循环检查和读取操作,利用解析器结合构造的类更科学地获取数据。
四、 心得体会
第一单元的作业让我真正踏入了面向对象编程的大门;在编写程序的过程中,我熟悉了许多 java 的语法和工具,能较灵活地应用递归下降等经典方法,学会了如何科学地维护自己的程序。
但我也暴露出一些不足,包括没有完全利用 java 的继承属性导致寻找合法符号的函数在不同类中出现多次;为保证低耦合度每个流程对字符串处理一遍导致效率不高;构造 hack 数据经验尚少等。
总的来说,这次迭代开发的作业让我收获许多,也为后续的任务打下了一定的基础。