前言
这个作业属于哪个课程 | 21计科三班 |
---|---|
这个作业要求在哪里 | 结对项目 |
这个作业的目标 | 结对完成一个四则运算生成器及判题器,体验并熟悉结对编程的流程 |
开发者 | 黄锐智(3121005262) 岑坤涛(3121005077) |
项目地址 | https://github.com/Taoika/Arithmetic |
已实现功能点
- 使用 -n 参数控制生成题目的个数
- 使用 -r 参数控制题目中数值(自然数、真分数和真分数分母)的范围
- 生成的题目中计算过程不能产生负数
- 生成的题目中如果存在形如e1÷ e2的子表达式,那么其结果应是真分数
- 每道题目中出现的运算符个数不超过3个
- 程序一次运行生成的题目不能重复
- 在生成题目的同时,计算出所有题目的答案,并存入执行程序的当前目录下的Answers.txt文件
- 程序应能支持一万道题目的生成
- 程序支持对给定的题目文件和答案文件,判定答案中的对错并进行数量统计
程序架构
模块说明
题目生成
-
题目生成模块由ExprGenerate类组成,根据以下规则生成四则运算式:
- 运算符1~3个,从[+、-、*、/]中选取;
- 操作符数量比运算符多一个,为0到limit之间的自然数或真分数。
-
ExprGenerate类组成如下图所示:
-
函数简要说明:
- random_operators:生成一个四则运算符列表;
- random_numbers:生成一个操作数列表,操作数大小由self.limit限制;
- generate:组合生成四则运算表达式;
- add_expression:添加记录生成的表达式及其结果。
-
对random_numbers进行详细说明:
-
参数:k: 随机生成操作数个数
-
算法流程如下图所示:
-
代码如下所示:
-
def random_numbers(self, k):
"""
生成一个操作数列表,操作数大小由self.limit限制。
:param k: 随机生成操作数个数
:return: 操作数列表
"""
nums = []
for _ in range(k):
# 以一半的概率生成一个自然数,以一半的概率生成一个分数
if randrange(2) == 0:
# 生成一个0到limit之间的自然数
nums.append(Integer(randrange(self.limit)))
else:
# 生成一个分数
numerator = Integer(randrange(1, self.limit / 5))
denominator = Integer(randrange(1, self.limit / 5))
fraction = Rational(numerator, denominator).cancel() # 对分数进行化简
if fraction.is_integer:
nums.append(fraction) # 整数直接添加
else:
nums.append(f"({fraction})") # 分数转换成字符串并两侧添加括号,防止除以分数识别错误
return nums
题目质量判断
组成&规则
题目质量判断模块由ExprJudge类组成,根据以下规则进行题目质量判断:
- 题目不能重复
- 题目运算过程不能出现负数
ExprJudeg类
- ExprJudge类组成如下图所示:
- 函数简要说明:
- judge_negative:判定中间过程不为负数
- judge_repeat:四则运算表达式不重复
- judge:总判定,中间过程不为负数和不重复需要同时满足
judge_repeat
- 参数: expr_str: 四则运算表达式字符串
- 代码如下所示:
def judge_repeat(self, expr_str):
expr_tree = srepr(parse_expr(expr_str, evaluate=False)) # 将表达式字符串转换为二叉树形式字符串
is_unique = expr_tree not in self.expr_trees # 判断二叉树形式字符串是否已存在(即重复)
if is_unique:
self.expr_trees.append(expr_tree)
return is_unique
- 算法说明:为实现程序一次运行生成的题目不能重复,即任何两道题目不能通过有限次交换+和×左右的算术表达式变换为同一道题目的需求,我们根据二叉树数据结构的性质,设计算法对生成算式进行判重。
- 算法原理:二叉树是一种可以无歧义地表示代数表达式的数据结构,其中叶子节点是操作数,非叶子节点是操作符。如果要判断两个表达式是否相同,可以用以下的方法:
- 首先,将两个表达式转换为标准的先缀或后缀形式,也就是波兰表示法或逆波兰表示法。这样可以避免括号和优先级的影响,只需要按照一定的规则调整操作符和操作数的顺序。
- 然后,将两个表达式的后缀形式构建成二叉树,这时需要注意一些细节,比如当操作符是 + 或 * 时,需要保证左子树的值不小于右子树的值,这样可以避免交换律的影响。
- 最后,比较两个二叉树是否完全相同,也就是结构和节点值都相同。如果是,则说明两个表达式是等价的,剔除当前生成表达式;如果不是,则说明两个表达式不等价。
- 实现细节:
- 通过python的sympy库的parse_expr函数将生成的四则运算表达式字符串转换为sympy表达式对象,再利用srepr函数将sympy表达式对象转换为二叉树形式字符串。例如:字符串 "2 * 4 + 3" 和 "3 + 4 * 2" 经上述转换后均得到字符串 “Add(Integer(3), Mul(Integer(2), Integer(4)))”,可见避免了交换律影响,满足需求。
- 判断二叉树形式字符串是否存在已记录的二叉树字符串列表中,若存在则判定重复,返回false进行剔除;若不存在则判定未重复,返回true并记录到列表。
- 算法流程如下所示:
judge_negative
-
作用:判定中间过程不为负数
-
参数: expr_str: 四则运算表达式字符串
-
算法流程如下图所示:
-
代码如下所示:
@staticmethod
def judge_negative(expr_str):
expr = parse_expr(expr_str, evaluate=False) # 转换为表达式对象
for sub_expr in preorder_traversal(expr): # 遍历子表达式
try:
if sub_expr.func == Add and sympify((srepr(sub_expr))) < 0: # 筛选多项式(乘除不可能生成负数)并计算中间结果
return False
except TypeError: # 除数为零引发的异常
return False
return True
题目对错判断
- 题目对错判断模块由Grade类组成,用于对提交的题目和答案进行批改。
- Grade类的组成如下图所示:
- 简要函数说明:
- get_grade:比较参考答案和答案文件得到正确与错误的题目信息;
- save_grade用于保存批改结果到文件Grade.txt。
字符串格式化
- 字符串格式化模块由ExprFormat类组成,用于将机器能够直接计算的格式转换为便于人类阅读的格式。
- ExprFormat类的组成如下图所示:
- 简要函数说明:
- fraction_to_mixed:字符串假分数转换为带分数
- match_common:将字符串中的假分数转化为带分数
- match_expression:对四则运算表达式字符串中的乘号、除号、假分数及冗余括号进行替换
- match_result:对结果表达式中的假分数进行替换
- expr_format:对四则运算表达式列表的格式化,将机器能够直接计算的格式转换为便于人类阅读的格式
- result_format:对结果列表的格式化,方便人类阅读和后续写入文件
- 对fraction_to_mixed的详细说明:
-
参数:match: 正则匹配得到的对象,包含分数信息
-
算法流程图如下所示:
-
代码如下所示:
-
staticmethod
ef fraction_to_mixed(match):
"""
字符串假分数转换为带分数。
:param match: 正则匹配得到的对象,包含分数信息
:return: 真分数字符串或带分数字符串
"""
numerator = int(match.group(1)) # 获取分子
denominator = int(match.group(2)) # 获取分母
if numerator > denominator: # 如果是假分数
quotient = numerator // denominator # 计算商
remainder = numerator % denominator # 计算余数
return f"{quotient}'{remainder}/{denominator}" # 返回带分数形式
else: # 如果不是假分数
return match.group() # 返回原样
字符串逆格式化
- 字符串格式化模块由ExprReverFormat类组成,用于将易于人类阅读的格式转化为机器可以读取的格式。与ExprFormat类功能互逆,因此这里不再展开对ExprReverFormat类进行说明。
文件处理
-
文件处理模块组成如下图所示:
-
简要函数说明:
- read_files:读取四则运算式及其对应结果
- save_generate: 保存生成的四则运算式及其对应结果
效能分析及优化
- 效能分析及可视化工具:viztracer
- 效能分析结果:
-
1题目量
-
1000题目量:
-
10000题目量
- 从1题目量的分析结果图中我们可以看到,使用时间最长的函数是ExprJusge类中的judge_nerative函数,该函数用于判定题目是否满足“中间过程不为负数”的要求,这是一个瓶颈,我们可以从这方面着手进行优化。
- 从1题目量到1000再到10000题目量的分析结果中,我们可以看到ExprJusge类中的judge_repeat函数的使用随着题目量的增大不断增长,此函数用于判断题目是否满足“不重复”的要求。我们可以从这方面入手,将该函数的执行时间随着题目量的增长幅度变缓。
- 优化:在一开始我们使用遍历的方式对表达式进行判重处理,这种方法在题目量增大的情况下时间消耗增长很快,因此我们做了一定的优化,根据二叉树数据结构的性质重新设计算法进行判重。上面的性能分析结果就是使用新算法的结果,我们可以看到在题目量达到10000的情况下判重跟判负的时间看看持平,已经是不错的结果了。
测试说明
工具
- 自动化测试框架:unittest
- 覆盖率分析工具:coverage
测试结果
- 测试目标:src文件夹下面的全部代码
- 测试样例数量:17个
- 覆盖率:100%
具体测试
主要结构
tests:
│ test_args.py
│ test_file.py
│ test_generate.py
│ test_judge.py
args
- 此测试文件测试各种命令行参数情况下程序的运行,包括缺少参数、多余参数、正常生成题目的参数、正常批改作业的参数、不同的题目量、不同的答案文件参数等等。
- 以下分析其中一个“多余参数”样例,代码如下所示:
@patch('sys.argv', ['src/main.py', '-n', '10', '-e', 'Exercises.txt', '-a', 'Answers.txt'])
@patch('sys.stdout', new_callable=io.StringIO)
def test_lastArgs2(self, mock_stdout):
main()
output = mock_stdout.getvalue().strip()
self.assertIn("成功保存批改结果至当前目录下的", output)
- 使用unittest.mock模块中的“@patch”装饰器来模拟命令行传参以及获取标准输出内容,对标准输出内容进行断言,判断工作是否符合预期。
file
- 此测试文件测试文件处理相关的问题,包括找不到文件,文件权限不足等等。
- 以下分析“写入题目文件时权限不足”的测试样例,代码如下所示:
@patch('sys.argv', ['src/main.py', '-n', '10', '-r', '10'])
@patch('sys.stdout', new_callable=io.StringIO)
def test_exercise_file(self, mock_stdout):
exercise_path = 'Exercises.txt'
try:
os.chmod(exercise_path, 0o444) # 设置只读权限,即 0o444(八进制表示)
print(f"成功设置文件 '{exercise_path}' 为只读。")
except OSError:
print(f"无法设置文件 '{exercise_path}' 为只读。")
main()
try:
os.chmod(exercise_path, 0o644) # 取消只读属性,设置为可读写权限,即 0o644(八进制表示)
print(f"成功取消文件 '{exercise_path}' 为只读。")
except OSError:
print(f"无法取消文件 '{exercise_path}' 的只读属性。")
output = mock_stdout.getvalue().strip()
self.assertIn(f"成功设置文件 '{exercise_path}' 为只读。", output)
self.assertIn("ERROR: 写入文件错误。请检查写入权限和文件占用", output)
self.assertIn(f"成功取消文件 '{exercise_path}' 为只读。", output)
- 首先使用os.chmod更改文件权限为只读,然后运行main函数,接着再将文件权限更改为可读写,最后断言查看文件权限是否更改成功以及是否给出文件权限不足的警告。
generate & judge
- 对类ExprGenerate以及类ExprJudge进行单独的测试,包括类的初始化以及一些一般覆盖不到的分支测试。
运行结果
题目生成
- 运行过程如图所示:
- 文件部分内容如图所示:
-
Exercises.txt
-
Answers.txt
作业批改
-
运行过程如图所示:
-
批改结果如图所示:
- Grade.txt
10000题目量
- 题目生成时间:69.44 秒
- 题目批改时间:11.11 秒
- 运行过程如图所示:
PSP表格
PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
---|---|---|---|
Planing | 计划 | 10 | 15 |
· Estimate | 估计这个任务需要多少时间 | 5 | 2 |
Development | 开发 | 1380 | 1340 |
· Analysis | 需求分析 (包括学习新技术) | 30 | 60 |
· Design Spec | · 生成设计文档 | 10 | 8 |
· Design Review | · 设计复审 | 10 | 8 |
· Coding Standard | · 代码规范 (为目前的开发制定合适的规范) | 10 | 5 |
· Design | · 具体设计 | 120 | 110 |
· Coding | · 具体编码 | 900 | 930 |
· Code Review | · 代码复审 | 0 | 0 |
· Test | · 测试(自我测试,修改代码,提交修改) | 360 | 300 |
Reporting | 报告 | 30 | 33 |
· Test Repor | · 测试报告 | 20 | 16 |
· Size Measurement | · 计算工作量 | 10 | 7 |
· Postmortem & Process Improvement Plan | · 事后总结, 并提出过程改进计划 | 10 | 7 |
合计 | 2905 | 2841 |
- 注:代码复审因为结对编程的原因,所以相当于时时刻刻都在复审,就没有单独算进代码复审这一部分
项目小结
黄锐智
在完成四则运算式生成项目的过程中,我扮演了开发者的角色,这个项目让我获得了很多宝贵的经验和体会。
首先,项目的开发阶段需要深入理解用户需求。我花了很多时间与朋友进行讨论,确保我对需求的分析无误。这个过程教会了我沟通的重要性,因为只有清晰地了解需求,才能有效地进行开发工作。
其次,编码阶段是我的工作中最具挑战性的部分。我不仅需要确保生成的四则运算式满足所有需求,还需要优化代码以提高性能和可维护性。我使用了python作为开发语言,并辅以sympy第三方运算库来进行设计开发,这让我更加熟悉了编程语言和算法。
与朋友的合作也教会了我合作的重要性。我们互相协作,确保项目的开发和测试能够无缝地配合。同时,我也学会了接受反馈并进行改进,经过朋友的测试后对几个bug进行修复。
测试阶段也是一个不可或缺的部分。我的朋友岑坤涛在这一阶段发挥了关键作用,帮助我们发现并修复了许多问题。
最后,这个项目教会了我不仅是编写代码,还包括合作、理解用户需求和不断改进的技能,深入体会了结对项目开发的各个环节,这些经验将对我的未来开发工作产生积极的影响。
岑坤涛
在这个项目中,我主要担任测试以及文档编写的工作,工作的时候我们一人边写边进行说明,一人边看边提出意见,这种相当于时时刻刻都在进行复审的编程方式,让我们的项目结构更加清晰,也让自己对整个项目的了解更加全面以及深入,让我获益颇多。
在测试的过程中,我们及时发现了一些bug并进行了修复,这让我更加清晰地了解到测试以及自动化测试的重要性,自动化测试它是一个保障系统在开发阶段及时排除bug、在扩展阶段保证前面的代码不会出现问题的一大利器,编写良好的测试样例,并集成到自动化测试中,是对系统的一个保障。
再来说一下我的结对感受。锐智是一个很好的伙伴,在他编写代码的时候,可以很恰当地对自己的想法进行组织说明,并且很好地解答以及吸收我提出来的一些意见;在我编写代码的时候,他也可以很快速地了解到我想要表达的信息,并给出适当的反馈。
通过这次的合作,我深刻地了解到结对编程的魅力,也让我更加期待后面的团队开发了。