结对项目(柯燕纯、胡俐伶)
| 这个作业属于哪个课程 | 计科24级78班-软件工程 |
|---|---|
| 这个作业要求在哪里 | 结对项目 |
| 这个作业的目标 | 实现一个自动生成小学四则运算题目的命令行程序,支持分数运算、表达式查重、约束校验、批量出题、自动批改;通过结对编程实践 Git/GitHub 协作流程,提升软件工程规范意识与单元测试能力。 |
结对项目:自动生成小学四则运算题目的命令行程序实现
| 项目 | 信息 |
|---|---|
| 组员1 姓名/学号 | 柯燕纯 / 3224004305 |
| 组员2 姓名/学号 | 胡俐伶 / 3224004304 |
| GitHub 项目地址 | https://github.com/Woodrinke/Woodrinke |
一、PSP表格(预估)
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) |
|---|---|---|
| Planning | 计划 | 20 |
| · Estimate | · 估计这个任务需要多少时间 | 20 |
| Development | 开发 | 330 |
| · Analysis | · 需求分析(包括学习新技术) | 20 |
| · Design Spec | · 生成设计文档 | 20 |
| · Design Review | · 设计复审(和同事审核设计文档) | 15 |
| · Coding Standard | · 代码规范(为目前的开发制定合适的规范) | 10 |
| · Design | · 具体设计 | 30 |
| · Coding | · 具体编码 | 150 |
| · Code Review | · 代码复审 | 30 |
| · Test | · 测试(自我测试,修改代码,提交修改) | 55 |
| Reporting | 报告 | 100 |
| · Test Report | · 测试报告 | 30 |
| · Size Measurement | · 计算工作量 | 10 |
| · Postmortem & Process Improvement Plan | · 事后总结,并提出过程改进计划 | 60 |
| 合计 | 450 |
二、效能分析
1 性能分析耗时
效能分析环节预估耗时 45 分钟,实际耗时约 40 分钟,具体分配如下:
| 阶段 | 任务 | 耗时(分钟) |
|---|---|---|
| 性能采样 | 用 cProfile 生成 1 万道题并收集数据 | 5 |
| 热点定位 | 分析 profile.txt,定位耗时最大的函数 | 10 |
| 优化尝试 | 尝试减少 evaluate 的重复调用 | 15 |
| 复测对比 | 重新运行 cProfile,对比优化前后数据 | 10 |
2 性能分析结果
运行命令:
python -m cProfile -s tottime main.py -n 10000 -r 100 > profile.txt
程序生成 1 万道题的总耗时为 1.060 秒,平均每道题约 0.106 毫秒。从 profile 报告看,耗时排名前 8 的函数如下:

3 消耗最大的函数
从数据看,random_expression_tree(递归生成表达式树) 和 randrange(随机数生成) 是程序最大的两个性能热点:
random_expression_tree:tottime = 0.153 秒,占程序总耗时的 14.4%。原因是它需要递归生成表达式,且因为约束校验(减法不为负、除法结果小于 1)会频繁重试。
randrange / getrandbits:二者合计消耗约 0.170 秒,占约 16.0%。这是 Python random 模块的底层实现,说明程序对随机数的依赖度很高。
random_fraction:tottime = 0.042 秒,占约 4.0%,是生成数字节点的核心函数。

火焰图更清晰地展示了调用链:main → generate_problems(0.564 秒)→ random_expression_tree(0.430 秒 cumtime)→ random_fraction / value / is_proper
4 改进思路
针对上述热点,我们提出以下优化方向:
优化 1:减少 evaluate 的重复调用
原逻辑在生成表达式后,会先在 _check_constraints 中递归求值校验,然后又在 generate 中调用 value() 得到答案。可以在约束校验通过后直接复用已求出的值,减少一次完整遍历。
优化 2:调整生成策略,减少重试次数
当前生成器采用“先随机生成、再校验、失败重试”的策略。可以改为“生成时即保证约束”:例如生成除法时,直接构造一个真分数作为结果,再反推被除数和除数,避免随机失败后的重试。
5 结论
程序生成 1 万道题仅需** 1.060 秒(总耗时),平均每道题 0.106 毫秒,性能已远超题目要求(支持万级题目生成)。主要热点集中在随机数生成和递归表达式构造**上,这两者都是生成器不可避免的开销。通过上述优化思路,理论上可以进一步压缩到 1 秒以内。
三、设计实现过程
1 代码组织与模块划分
本项目采用分层设计,共 6 个源码文件,职责单一、依赖清晰:
| 文件 | 职责 | 依赖 |
|---|---|---|
fraction.py |
分数类 Fraction,实现四则运算、约分、比较、字符串互转 |
无 |
expression.py |
表达式树 Expression,实现求值、中缀输出、查重签名 |
fraction |
parser.py |
表达式字符串解析器 Parser,把字符串还原为表达式树 |
fraction、expression |
generator.py |
题目生成器,批量生成不重复题目并写入文件 | expression |
grader.py |
批改器,读取题目/答案文件,比对并输出 Grade.txt |
fraction、parser |
main.py |
命令行入口,解析 -n/-r/-e/-a 参数,分发任务 |
全部 |
依赖关系图:
main.py
├── generator.py ── expression.py ── fraction.py
└── grader.py ──── parser.py ────── expression.py ── fraction.py
2 核心类设计
(1)Fraction(分数类)
① 属性:numerator(分子)、denominator(分母),构造时自动约分、保证分母为正。
② 方法:
- 四则运算:
__add__、__sub__、__mul__、__truediv__ - 比较运算:
__eq__、__lt__、__le__、__gt__、__ge__ - 判定真分数:
is_proper() - 字符串互转:
__str__、from_string() - 随机生成:
random_fraction(r)
(2)Expression(表达式树)
① 属性:
- 叶子节点:
value_node(一个Fraction) - 内部节点:
left、op、right
② 方法:
is_leaf():判断是否为叶子节点value():递归求值,返回Fraction__str__/_to_string():中缀输出,根据优先级自动加括号signature():查重签名,递归生成归一化元组random_expression_tree(r, count):随机生成表达式树
(3)Parser(解析器)
① 方法:
tokenize(s):把字符串切分为 token 序列parse_expression():解析加减parse_term():解析乘除parse_factor():解析数字、分数、括号
递归下降解析,保证优先级正确。
(4)Generator(生成器)
- 函数
generate_problems(n, r):循环生成题目,用signature()去重,最多尝试n * 100次。 - 函数
write_files(problems, ...):写入Exercises.txt和Answers.txt。
(5)Grader(批改器)
- 函数
extract_expression(line)/extract_answer(line):从带编号的行中提取表达式/答案。 - 函数
grade(exercises_path, answers_path, output_path):逐题解析求值、比对、输出Grade.txt。
(6)main.py(命令行入口)
① 用** argparse 解析参数**:
-n<个数>:题目数量(默认 10)-r<范围>:数值范围(生成模式必须提供)-e<题目文件> -a <答案文件>:批改模式
② 参数校验:-e/-a 必须成对出现;-r < 1 报错;-n < 1 报错。
3 关键函数流程图
(1)signature() 查重签名(核心难点)

设计要点:
- 对 + 和 × 做单层排序,实现“交换律 + 左结合”,但不做跨层扁平化,所以
1+2+3和3+2+1不会判重(符合题目要求)。 - 对 - 和 ÷ 保持左右顺序 ,所以
5-3和3-5不会判重。
(2)random_expression_tree() 生成器

设计要点:
- 除法优先尝试
left/right,如果不是真分数,尝试交换左右。两边都不行则重试。 - 50 次都失败则返回
None,由generate_problems跳过这道题。
4 测试文件组织
共 8 个测试文件,覆盖全部模块:
| 文件 | 覆盖内容 |
|---|---|
test_fraction.py |
分数类四则运算、约分、比较、字符串转换 |
test_random.py |
random_fraction 的边界(r=1、r=2) |
test_expression.py |
表达式树求值、随机树约束 |
test_expression_str.py |
中缀输出、括号处理 |
test_signature.py |
查重签名(交换律、结合律、非结合律) |
test_generator.py |
题目生成、去重、文件写入 |
test_grader.py |
批改正确、批改混合 |
test_main.py |
命令行参数校验 |
四 代码说明
1 分数类 Fraction:基础数据结构
分数运算是整个项目的基础。Fraction 类在构造时自动约分、规范符号,保证后续所有运算结果一定是最简分数。
class Fraction:
def __init__(self, numerator, denominator=1):
if denominator == 0:
raise ValueError("分母不能为 0")
# 统一符号:让分母始终为正
if denominator < 0:
numerator = -numerator
denominator = -denominator
# 用最大公约数约分
g = math.gcd(abs(numerator), denominator)
self.numerator = numerator // g
self.denominator = denominator // g
思路说明:
- 约分逻辑用
math.gcd,一步到位,避免后续运算时反复约分。 - 符号统一到分子(分母恒为正),这样比较、排序、判等时不会因为符号位置不同而误判。
- 所有四则运算(
__add__、__sub__、__mul__、__truediv__)都通过构造函数返回新对象,保证结果一定是最简分数。
2 查重核心:Expression.signature()
题目要求“任何两道题不能通过有限次交换 + 和 × 左右的算术表达式变换为同一道题目”。查重是项目的最大难点,我们使用表达式树归一化签名解决。
def signature(self):
# 叶子节点:用 ("n", 分子, 分母) 统一表示
if self.is_leaf():
return f"V{self.value_node}"
# 内部节点:递归求左右子树签名
left_sig = self.left.signature()
right_sig = self.right.signature()
# + 和 × 满足交换律:左右签名排序后拼接
if self.op in ("+", "×"):
return (self.op,) + tuple(sorted([left_sig, right_sig]))
# - 和 ÷ 不满足交换律:保持左右顺序
return (self.op, left_sig, right_sig)
思路说明:
- 叶子节点: 返回
"v" + 分数,例如1/2返回"v1/2"。所有返回值都是字符串,保证递归时类型统一,不会出现字符串和元组混用导致TypeError。 +和×排序: 这是实现交换律的关键。递归求左右签名后,如果左边字符串大于右边,就交换左右,然后拼接。23+45和45+23递归后得到的左右签名一致,交换后拼接结果相同,因此判重。-和÷不排序: 保持左右顺序,因此5-3和3-5不判重。- 对结合律的精确处理: 由于我们只做单层字符串比较,不做跨层扁平化,所以:
3+(2+1)和1+2+3(即(1+2)+3)判重(因为每层字符串比较后3被换到后面)。1+2+3和3+2+1不判重(因为左子树结构不同)。
- 这恰好符合题目“支持交换律 + 左结合”的要求。
3 生成器:random_expression_tree()
生成器需要在随机生成的同时满足“不产生负数”“除法结果为真分数”“运算符不超过 3 个”等约束。我们采用“递归生成 + 约束校验 + 重试”策略。
def random_expression_tree(r, count):
# 边界:r < 2 时只能生成 0
if r < 2:
return Expression(value_node=Fraction(0, 1))
# 没有运算符了,生成一个叶子
if count == 0:
return Expression(value_node=random_fraction(r))
op = random.choice(["+", "-", "×", "÷"])
left_count = random.randint(0, count - 1)
right_count = count - 1 - left_count
for _ in range(20):
left = random_expression_tree(r, left_count)
right = random_expression_tree(r, right_count)
if op == "+":
return Expression(left=left, op=op, right=right)
if op == "-":
# 减法约束:保证 left >= right,否则交换
if left.value() >= right.value():
return Expression(left=left, op=op, right=right)
else:
return Expression(left=right, op=op, right=left)
if op == "×":
return Expression(left=left, op=op, right=right)
if op == "÷":
if right.value() == Fraction(0, 1):
continue
# 除法约束:结果必须是真分数
result = left.value() / right.value()
if result.is_proper():
return Expression(left=left, op=op, right=right)
# 尝试交换左右
if left.value() != Fraction(0, 1):
result2 = right.value() / left.value()
if result2.is_proper():
return Expression(left=right, op=op, right=left)
# 20 次都失败,返回随机叶子
return Expression(value_node=random_fraction(r))
思路说明:
- 边界处理:
r < 2时只能生成0,直接返回叶子节点,避免在真分数生成上崩溃。 - 减法约束:比较
left.value()和right.value(),如果不满足>=,则交换左右再返回(相当于a-b变成b-a,结果仍非负)。 - 除法约束:优先判断
left/right是否为真分数;不是则尝试right/left;都不行则continue重试。 - 重试上限:
for _ in range(50)避免无限循环;20 次都失败返回一个随机叶子节点,由generate_problems层继续。 - 运算符数量控制:通过
left_count + right_count = count - 1严格保证运算符总数不超过3。
4 解析器:Parser.parse_expression()(递归下降)
批改器需要把 Exercises.txt 里的题目字符串还原成表达式树,用于重新求值。我们使用递归下降解析器,通过三层函数处理优先级。
class Parser:
def parse_expression(self):
# 处理加减(最低优先级)
left = self.parse_term()
while self.peek() in ("+", "-"):
op = self.next()
right = self.parse_term()
left = Expression(left=left, op=op, right=right)
return left
def parse_term(self):
# 处理乘除(较高优先级)
left = self.parse_factor()
while self.peek() in ("×", "÷"):
op = self.next()
right = self.parse_factor()
left = Expression(left=left, op=op, right=right)
return left
def parse_factor(self):
# 处理数字、分数、括号
tok = self.peek()
if tok == "(":
self.next()
expr = self.parse_expression()
if self.peek() != ")":
raise ValueError("缺少右括号")
self.next()
return expr
self.next()
return Expression(value_node=Fraction.from_string(tok))
思路说明:
- 三层函数对应三级优先级:
parse_expression → parse_term → parse_factor,每层处理一类运算符,这是递归下降解析器的标准结构。 - while 循环处理左结合:
1+2+3会被解析为((1+2)+3),因为每次循环都把当前left作为新节点的左子树,符合左结合语义。 - 括号递归:遇到 ( 就递归调用
parse_expression,遇到 ) 就返回,天然支持任意深度嵌套。 - 与
signature()的一致性:解析出的树结构与生成器构造的树结构完全一致,因此批改器重新求值得到的答案与Answers.txt的标准答案能够精确比对。
五、测试运行
1 测试文件组织
本项目采用 pytest 框架,共 8 个测试文件,覆盖全部模块,累计 35 个测试用例:
| 测试文件 | 覆盖内容 | 用例数 |
|---|---|---|
| test_fraction.py | 分数四则运算、约分、比较、字符串转换 | 6 |
| test_random.py random_fraction | 边界(r=1、r=2、r=10) | 1 |
| test_expression.py | 表达式树求值、随机树约束、运算符个数 | 6 |
| test_expression_str.py | 中缀输出、括号处理(8 种优先级场景) | 8 |
| test_signature.py | 查重签名(交换律、结合律、非结合律) | 6 |
| test_generator.py | 题目生成、去重、文件写入 | 3 |
| test_grader.py | 批改全部正确、批改混合对错 | 2 |
| test_main.py | 命令行参数校验(缺失 -r、非法 -r) | 3 |
| 合计 | 35 |
运行命令:
python -m pytest tests/ -v
运行结果:35 个测试用例全部通过(35 passed in 0.84s)。

2 核心测试用例(10 组)
| 编号 | 测试用例 | 输入 / 命令 | 预期输出 | 实际输出 | 结果 |
|---|---|---|---|---|---|
| 1 | 分数约分 | Fraction(2, 4) |
等于 Fraction(1, 2) |
相等 | 通过 |
| 2 | 分数四则运算 | Fraction(1,2) + Fraction(1,3) |
5/6 | 5/6 | 通过 |
| 3 | 字符串转换 | str(Fraction(3, 2)) |
1'1/2 |
1'1/2 |
通过 |
| 4 | 表达式求值 | 1/2 + 1/3 |
5/6 | 5/6 | 通过 |
| 5 | 括号输出 | (1/2 + 1/3) × 2 |
(1/2 + 1/3) × 2 |
一致 | 通过 |
| 6 | 查重 (交换律) | 23+45 VS 45+23 |
判重 | 判重 | 通过 |
| 7 | 查重 (左结合) | 3+(2+1) VS (1+2)+3 |
判重 | 判重 | 通过 |
| 8 | 查重 (不重复) | 1+2+3 VS 3+2+1 |
不判重 | 不判重 | 通过 |
| 9 | 生成器去重 | generate_problems(50, 10) |
50 个唯一签名 | 50 个唯一签名 | 通过 |
| 10 | 命令行异常 | python main.py -n 5 (缺 -r) |
报错并打印帮助 | 报错并打印帮助 | 通过 |
3 为什么能确定程序是正确的?
我们从以下五个维度论证程序的正确性:
(1)数学逻辑正确
- 分数类通过
math.gcd约分,保证所有结果都是最简分数(test_reduce验证)。 - 分数比较通过通分后比较分子实现,数学上等价于浮点比较但无精度损失(
test_compare验证)。 - 表达式求值采用递归后序遍历,符合四则运算语义(
test_tree_value验证)。
(2)约束全部满足
- 不产生负数:减法节点强制
left >= right,否则交换左右(test_random_tree_sub_no_negative验证)。 - 除法结果为真分数:优先尝试
left/right,不是真分数则尝试right/left,都不行则重试(test_random_tree_div_proper和check_div.py双重验证 bad = 0)。 - 运算符不超过3个:通过
left_count + right_count = count - 1严格保证(test_random_tree_count验证)。
(3)查重算法理论正确
- 查重算法基于表达式树递归归一化:对
+和×排序(交换律),对-和÷保持顺序。 3+(2+1)和1+2+3判重(左结合 + 交换律),1+2+3和3+2+1不判重(不支持右结合)。- 测试用例
test_signature.py覆盖所有 6 个场景,全部通过。
(4)边界场景覆盖完整
-r 1边界:程序不崩溃,正常生成题目(test_random_fraction验证)。-r 0:命令行报错并退出(test_invalid_r验证)。- 缺失
-r:命令行打印帮助并退出(test_missing_r验证)。 - 无重复签名:
test_no_duplicate_signature验证 50 道题无重复。
(5)实测无重复、字符串与解析互逆
- 生成 1000 道题,
signature唯一数 = 1000(check_gen.py验证)。 - 生成 10000 道题,无重复题目,生成时间 1.060 秒(
check_gen2.py验证)。 - 1000 个随机表达式
str()→parse()往返,signature完全一致(check_roundtrip验证)。
六、PSP表格(实际)
| PSP2.1 | Personal Software Process Stages | 实际耗时(分钟) |
|---|---|---|
| Planning | 计划 | 25 |
| · Estimate | · 估计这个任务需要多少时间 | 25 |
| Development | 开发 | 355 |
| · Analysis | · 需求分析(包括学习新技术) | 25 |
| · Design Spec | · 生成设计文档 | 25 |
| · Design Review | · 设计复审(和同事审核设计文档) | 20 |
| · Coding Standard | · 代码规范(为目前的开发制定合适的规范) | 15 |
| · Design | · 具体设计 | 40 |
| · Coding | · 具体编码 | 160 |
| · Code Review | · 代码复审 | 25 |
| · Test | · 测试(自我测试,修改代码,提交修改) | 45 |
| Reporting | 报告 | 115 |
| · Test Report | · 测试报告 | 35 |
| · Size Measurement | · 计算工作量 | 10 |
| · Postmortem & Process Improvement Plan | · 事后总结,并提出过程改进计划 | 70 |
| 合计 | 495 |
七、项目小结
1 项目总结
本次结对项目完成了小学四则运算题目自动生成与批改系统,实现了分数运算、表达式树查重、约束校验、命令行生成与批改全套功能。8 个测试文件、35 个用例全部通过,生成 10000 道题耗时 1.060 秒,无重复、无负数、无违规除法,完全满足题目要求。
2 结对感受
这次结对让我们体会到,遇到查重这种难点时,两个人讨论比一个人死磕高效得多。“表达式树递归排序”的思路就是我们讨论出来的,直接帮我们跳出了死胡同。以前我们习惯“先写完再测试”,但这次坚持“每写完一个模块立刻写单元测试”,让我们意识到测试驱动开发的重要性。查重那部分幸好有测试及时发现问题,否则后期返工成本很高。
3 从对方身上看到的闪光点
组员1:逻辑严谨,对查重算法、结合律语义理解透彻,能准确判断“支持左结合、不支持右结合”的边界。抽象设计能力强,把表达式树和分数封装成清晰的类。
组员2:细心负责,编写了 35 个测试用例,覆盖边界值、异常参数、文件缺失等场景。还主动增加了 check_roundtrip.py 验证字符串与解析互逆,这个思路很巧妙。
4 对队友的建议
给组员1:代码复审时可以更主动提出简化建议,比如生成器的重试逻辑可以更早优化。
给组员2:性能分析可以更早介入,早跑 cProfile 能更早发现热点函数。
5 经验与教训
成功经验:
- 分步开发、每步验证,避免大规模返工。
- 查重难点集中攻克,用“递归 + 单层排序”精确实现要求。
- 边界测试覆盖完整(
-r 1、-r 0、缺失-r等)。
教训:
- 需求理解要透彻:查重语义一开始理解错了,写了两次
signature()。 - 文档边做边写:博文素材集中在最后写,容易遗漏细节。
6 总结
次项目让我们体会到结对编程的核心价值:1 + 1 > 2。一个人的抽象设计加上另一个人的测试严谨,形成了很好的互补,让我们都学会了“先设计、再测试、后编码”的工程化流程。
更重要的是,这次项目让我们意识到:好的代码不是一个人写出来的,而是两个人一起磨出来的。从最初的需求拆解,到查重算法的反复调试,再到最后的性能分析和文档撰写,每一个环节都离不开两个人的互相审视和补位。这种“边写边讨论、边测边优化”的节奏,比一个人闷头写代码高效得多,也让我们对软件工程的“协作”二字有了更深的理解。
浙公网安备 33010602011771号