assignment3:结对项目
姓名:温美如 学号:3224004081
姓名:陈婉怡 学号:3224004078
Github地址:https://github.com/w7885/w7885
| 这个作业属于哪个课程 | https://edu.cnblogs.com/campus/gdgy/Class56-Grade2024-CS/ |
|---|---|
| 这个作业要求在哪里 | https://edu.cnblogs.com/campus/gdgy/Class56-Grade2024-CS/homework/15694 |
| 这个作业的目标 | 通过实现一个小学四则运算题目生成器,练习从需求分析、设计、编码、测试到性能优化的完整软件开发流程,并体验结对编程的分工与协作。 |
一、PSP 表格(预估)
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) |
|---|---|---|
| Planning | 计划 | |
| · Estimate | · 估计这个任务需要多少时间 | 30 |
| Development | 开发 | |
| · Analysis | · 需求分析(包括学习新技术) | 40 |
| · Design Spec | · 生成设计文档 | 30 |
| · Design Review | · 设计复审(和同事审核设计文档) | 20 |
| · Coding Standard | · 代码规范(为目前的开发制定合适的规范) | 15 |
| · Design | · 具体设计 | 40 |
| · Coding | · 具体编码 | 180 |
| · Code Review | · 代码复审 | 40 |
| · Test | · 测试(自我测试,修改代码,提交修改) | 80 |
| Reporting | 报告 | |
| · Test Report | · 测试报告 | 30 |
| · Size Measurement | · 计算工作量 | 15 |
| · Postmortem & Process Improvement Plan | · 事后总结,并提出过程改进计划 | 30 |
| 合计 | 550 |
二、效能分析
1. 改进前的问题
第一版程序能跑通,但生成 10000 道题时明显偏慢。用 cProfile 分析后发现,耗时主要集中在两个地方:
- gen_expr_node:随机生成表达式时,因为要满足“不出现负数、除数不为 0、除法结果是真分数”等约束,大量尝试被丢弃,反复重试。
- signature:去重时,每次都要递归遍历整棵表达式树,生成签名,树越深越慢。
此外,safe_eval 最初用 eval 实现,虽然快,但有浮点误差和安全隐患,后来重写为递归下降解析器,这部分也花了不少时间。
2. 改进思路
针对上述问题,我们做了以下优化:
(1)去重签名只处理交换律
题目要求:1+2 和 2+1 视为重复(交换律),但 1+2+3 和 3+2+1 不重复(不处理结合律)。因此 signature 只对直接左右子节点排序,不递归扁平化:
def signature(self):
if self.op is None:
return ("num", str(self.value))
ls = self.left.signature()
rs = self.right.signature()
if self.op in ("+", "×"):
return (self.op, tuple(sorted([ls, rs])))
return (self.op, ls, rs)
这样既能识别 1+2 和 2+1 为同一题,又能保证 (1+2)+3 和 (3+2)+1 不被误判为重复。
(2)限制重试次数,避免死循环
生成题目时设置最大尝试次数 n * MAX_ATTEMPTS_FACTOR,避免在 r 很小时无限重试:
max_attempts = n * MAX_ATTEMPTS_FACTOR
while len(exercises) < n and attempts < max_attempts:
...
(3)去掉 eval,改用递归下降解析器
eval 虽然快,但存在浮点误差和安全隐患。我们改用自己写的 FractionEvaluator,全程用 Fraction 计算,保证精度:
class FractionEvaluator:
def parse(self) -> Fraction:
result = self.expr()
...
return result
3. 性能分析图
用 cProfile 对生成 10000 道题进行采样:
python -m cProfile -s tottime Myapp.py -n 10000 -r 100

用 cProfile 对生成 10000 道题进行采样,总耗时 1.999 秒。按 tottime 排序,耗时最大的函数依次是:
random._randbelow_with_getrandbits(0.194s)
random.randint(0.149s)
Myapp.py:239(build)(0.124s)
Myapp.py:197(make_number_node)(0.122s)
Myapp.py:209(combine_node)(0.112s)
可以看出,随机数生成和表达式树构建是两大主要开销。随机数生成占比高是因为每道题要多次调用 random.randint、random.choice、random.shuffle;表达式树构建则是业务逻辑本身的开销。去重签名 signature 只占 0.064 秒,说明去重效率良好,不是瓶颈。
优化思路:减少不必要的随机数调用,比如可以预生成一批随机数复用;另外,去重时可以先用简单的值比较做初筛,减少 signature 的递归调用。
4. 改进效果
优化后,生成 10000 道题的时间从约 8 秒降到约 2 秒,去重更准确,且彻底消除了 eval 的安全隐患。
三、设计实现过程
1. 代码组织
项目代码集中在一个文件 Myapp.py 中,按功能划分为以下几个模块:
| 模块 | 主要函数/类 | 职责 |
|---|---|---|
| 分数格式化与解析 | format_fraction、parse_fraction | 分数与字符串互转 |
| 表达式求值 | FractionEvaluator、safe_eval | 解析并计算表达式 |
| 表达式树 | Node | 表示表达式树,支持去重签名 |
| 题目生成 | make_number_node、combine_node、gen_expr_node | 随机生成合法表达式 |
| 主流程 | generate_exercises、grade | 生成题目、批改答案 |
| 命令行入口 | main | 解析参数、分发任务 |
2. 类与函数关系
- Node 是核心数据结构,每个节点代表表达式树的一个节点。
- gen_expr_node 调用 make_number_node 生成叶子,再调用 combine_node 合并节点,最终返回一棵 Node 树。
- Node.signature() 用于去重,Node.to_expr() 用于输出题目字符串。
- safe_eval 内部使用 FractionEvaluator,批改时解析题目字符串。
- generate_exercises 和 grade 分别对应生成模式和批改模式,由 main 根据命令行参数调用。
调用关系可以简化为:
main
├── generate_exercises
│ └── gen_expr_node
│ ├── make_number_node
│ └── combine_node
│ └── Node.signature
└── grade
├── safe_eval
│ └── FractionEvaluator
└── parse_fraction
3. 关键函数流程图
gen_expr_node 流程图:

signature 去重逻辑:

4. 关键设计点
- 不出现负数:combine_node 中,− 检查左值 ≥ 右值,否则返回 None。
- 除数不为 0:÷ 检查右值 ≠ 0。
- 除法结果约束:题目要求除法结果是真分数,因此 ÷ 还检查结果分子 < 分母。
- 运算符个数限制:num_ops = random.randint(1, MAX_OPS),MAX_OPS = 3。
- 去重:signature 对 +、× 的直接左右子节点排序,处理交换律,不处理结合律,符合题目“1+2+3 和 3+2+1 不重复”的要求。
四、代码说明
1. 分数格式化:format_fraction
def format_fraction(fr: Fraction) -> str:
"""将 Fraction 转为题目要求的格式:真分数 a/b,带分数 a’b/c"""
if fr.denominator == 1:
return str(fr.numerator)
if abs(fr.numerator) < fr.denominator:
return f"{fr.numerator}/{fr.denominator}"
whole = fr.numerator // fr.denominator
rem = fr.numerator % fr.denominator
if rem == 0:
return str(whole)
return f"{whole}’{rem}/{fr.denominator}"
思路:先判断是否为整数,再判断是否为真分数,最后处理带分数。用 // 和 % 取出整数部分和余数,拼成 a’b/c 格式。
2. 表达式求值:FractionEvaluator
class FractionEvaluator:
def __init__(self, text: str):
text = text.replace("×", "*").replace("÷", "/").replace("−", "-")
self.text = text
self.pos = 0
def expr(self) -> Fraction:
value = self.term()
while True:
ch = self.peek()
if ch == "+":
self.pos += 1
value += self.term()
elif ch == "-":
self.pos += 1
value -= self.term()
else:
break
return value
def term(self) -> Fraction:
value = self.factor()
while True:
ch = self.peek()
if ch == "*":
self.pos += 1
value *= self.factor()
elif ch == "/":
self.pos += 1
divisor = self.factor()
if divisor == 0:
raise ZeroDivisionError("除数为 0")
value /= divisor
else:
break
return value
思路:这是一个递归下降解析器,按 expr → term → factor → number 的层次解析。expr 处理加减,term 处理乘除,factor 处理括号和数字。全程用 Fraction 计算,保证精度。
3. 表达式树节点:Node
class Node:
def __init__(self, op=None, left=None, right=None, value=None, text=None):
self.op = op
self.left = left
self.right = right
self.value = value
self.text = text
def to_expr(self) -> str:
if self.op is None:
return self.text
return f"{self.left.to_expr()} {self.op} {self.right.to_expr()}"
def signature(self):
if self.op is None:
return ("num", str(self.value))
ls = self.left.signature()
rs = self.right.signature()
if self.op in ("+", "×"):
return (self.op, tuple(sorted([ls, rs])))
return (self.op, ls, rs)
思路:to_expr 递归把树转成字符串;signature 生成去重签名,对 + 和 × 的直接左右子节点排序,处理交换律,不处理结合律;− 和 ÷ 保持左右顺序。
4. 合并节点:combine_node
def combine_node(op: str, left: Node, right: Node):
if op == "+":
val = left.value + right.value
return Node(op="+", left=left, right=right, value=val)
if op == "−":
if left.value < right.value:
return None
val = left.value - right.value
return Node(op="−", left=left, right=right, value=val)
if op == "×":
val = left.value * right.value
return Node(op="×", left=left, right=right, value=val)
if op == "÷":
if right.value == 0:
return None
val = left.value / right.value
if val.denominator == 1 or abs(val.numerator) >= val.denominator:
return None
return Node(op="÷", left=left, right=right, value=val)
return None
思路:这是合法性检查的核心。− 保证不出负数;÷ 保证除数不为 0 且结果真分数。不合法返回 None,让上层重试。
5. 生成题目:generate_exercises
def generate_exercises(n: int, r: int):
exercises = []
answers = []
signatures = set()
attempts = 0
max_attempts = n * MAX_ATTEMPTS_FACTOR
while len(exercises) < n and attempts < max_attempts:
attempts += 1
node = gen_expr_node(r, max_ops=MAX_OPS)
if node is None:
continue
sig = node.signature()
if sig in signatures:
continue
signatures.add(sig)
exercises.append(f"{node.to_expr()} =")
answers.append(format_fraction(node.value))
with open("Exercises.txt", "w", encoding="utf-8") as f:
for i, e in enumerate(exercises, start=1):
f.write(f"{i}.{e}\n")
with open("Answers.txt", "w", encoding="utf-8") as f:
for i, a in enumerate(answers, start=1):
f.write(f"{i}.{a}\n")
思路:循环生成表达式,用 signature 去重,写入文件时加序号。设置最大尝试次数,避免死循环。
6. 批改:grade
def grade(exercise_file: str, answer_file: str):
def strip_index(line: str) -> str:
return re.sub(r"^\s*\d+\.\s*", "", line).strip()
with open(exercise_file, "r", encoding="utf-8") as f:
exercises = [strip_index(line) for line in f if line.strip()]
with open(answer_file, "r", encoding="utf-8") as f:
answers = [strip_index(line) for line in f if line.strip()]
correct = []
wrong = []
for i, (ex, ans) in enumerate(zip(exercises, answers), start=1):
expr = ex.rstrip("=").strip()
try:
val = safe_eval(expr)
user_ans = parse_fraction(ans)
if val == user_ans:
correct.append(i)
else:
wrong.append(i)
except Exception as e:
print(f"第 {i} 题解析失败: {e}")
wrong.append(i)
with open("Grade.txt", "w", encoding="utf-8") as f:
f.write(f"Correct: {len(correct)} ({', '.join(map(str, correct))})\n")
f.write(f"Wrong: {len(wrong)} ({', '.join(map(str, wrong))})\n")
思路:先去掉行首序号,再逐题用 safe_eval 重新计算,与答案文件比较,统计对错,写入 Grade.txt。
五、测试用例
基于需求文档,共设计以下用例:
| 测试文件 | 测试模块 | 测试重点 |
|---|---|---|
| test_node_signature.py | Myapp.Node / Node.signature | 表达式树查重(signature 签名),校验交换律生效、结合律不生效 |
| test_generate.py | Myapp.generate_exercises | 题目生成功能、生成数量、语义查重、文件输出 |
| test_grade.py | Myapp.grade | 批改功能:正误识别、序号收集、空格容错、输出文件写入 |
| test_safe_eval.py | Myapp.safe_eval | 表达式计算、四则运算、括号、除零错误、非法表达式容错 |
1. test_node_signature.py 表达式树签名查重测试
被测对象:Node 类、signature()
核心规则:
- 交换律等价:1+2 和 2+1,视为同一道题,signature 相等
- 结合律不等价:(1+2)+3 和 1+(2+3),视为不同题目,signature 不相等
- 链式交换不等价:(1+2)+3 和 (3+2)+1,视为不同题目,signature 不相等
- 减法、除法严格区分左右操作数
def test_add_commute_dup():
"""2+3 和3+2 重复"""
- 测试目标:验证加法交换律,左右操作数交换后签名相同
- 断言:expr_a.signature() == expr_b.signature()
def test_mul_commute_dup():
"""6×8 和8×6 重复"""
- 测试目标:验证乘法交换律,左右操作数交换后签名相同
- 断言:expr_a.signature() == expr_b.signature()
def test_add_associate_not_dup():
"""(1+2)+3 和 1+(2+3) 不重复(只处理交换律)"""
- 测试目标:验证不处理结合律,嵌套结构不同则签名不同
- 断言:expr1.signature() != expr2.signature()
def test_chain_add_reverse_not_dup():
"""(1+2)+3 和(3+2)+1 不重复"""
- 测试目标:验证链式加法交换后仍不重复
- 断言:expr1.signature() != expr2.signature()
2. test_generate.py 题目生成测试
被测对象:generate_exercises(n, r)
核心规则:
- 生成 n 道题目,语义查重基于 Node.signature
- 输出 Exercises.txt、Answers.txt 文件
- 达到最大尝试次数时,会输出警告
def test_generate_op_count_no_more_than_3():
"""需求:每题运算符数量不超过3个,至少1个"""
- 测试目标:验证生成的题目运算符个数在 1~3 之间
- 执行逻辑:调用 generate_exercises(n=30, r=10),读取 Exercises.txt,统计每行运算符个数
def test_generate_all_numbers_less_than_r():
"""需求:r=10,生成所有数字不能包含10"""
- 测试目标:验证题目中所有数字都小于 r
- 执行逻辑:调用 generate_exercises(n=30, r=10),读取文件,解析所有数字
def test_generate_10000_stress():
"""需求:支持一万道题目生成,语义上无重复(依据signature查重)"""
- 测试目标:压力测试,验证生成 10000 道语义不重复的四则运算题
- 执行逻辑:调用 generate_exercises(10000, r=10),检查生成数量和无崩溃
3. test_grade.py 批改模块测试
被测对象:grade(exercise_file, answer_file)
功能:读取题目文件、答案文件,逐题比对,输出正确 / 错误题号到 Grade.txt
def test_grade_mixed_correct_wrong():
"""混合正确、错误答案批改"""
- 测试目标:测试混合正误场景下,批改结果统计、题号收集是否正确
- 准备临时文件:
- Exer_tmp.txt:3 道题 1+1,2+2,3+3
- Ans_tmp.txt:3 个答案 2,5,6
- 预期:第 1、3 题正确,第 2 题错误
- 断言:输出文件包含字符串 Correct: 2 (1, 3)、Wrong: 1 (2)
def test_grade_answer_with_space():
"""答案前后带多余空格,正常识别"""
- 测试目标:答案文本前后有空格的容错能力(strip 处理)
- 准备:题目 1/6 + 1/8,答案文件写 " 7/24 "(前后空格)
- 预期:答案识别正确,判定为正确
- 断言:输出文件包含 Correct: 1 (1)
4. test_safe_eval.py 表达式求值测试
被测对象:safe_eval(expr),安全计算四则表达式,支持分数、括号,捕获除零、非法表达式
def test_safe_eval_add():
- 测试目标:加法表达式求值正确性
def test_safe_eval_sub():
- 测试目标:减法表达式求值正确性
def test_safe_eval_mul():
- 测试目标:乘法表达式求值正确性
def test_safe_eval_div():
- 测试目标:除法表达式,分数结果计算正确
def test_safe_eval_mixed_number():
- 测试目标:混合四则运算(加减乘除组合),优先级计算正确
def test_safe_eval_bracket():
- 测试目标:括号改变运算优先级,带括号表达式计算正确
def test_safe_eval_zero_div():
- 测试目标:除零异常捕获,不会直接崩溃,返回错误标记
def test_safe_eval_invalid_expr():
- 测试目标:非法表达式容错,解析失败不会崩溃
![微信图片_20260921205602_136_292]()
六、实际 PSP 表格
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|---|
| Planning | 计划 | ||
| · Estimate | · 估计这个任务需要多少时间 | 30 | 40 |
| Development | 开发 | ||
| · Analysis | · 需求分析(包括学习新技术) | 40 | 60 |
| · Design Spec | · 生成设计文档 | 30 | 25 |
| · Design Review | · 设计复审(和同事审核设计文档) | 20 | 20 |
| · Coding Standard | · 代码规范(为目前的开发制定合适的规范) | 15 | 10 |
| · Design | · 具体设计 | 40 | 50 |
| · Coding | · 具体编码 | 180 | 240 |
| · Code Review | · 代码复审 | 40 | 60 |
| · Test | · 测试(自我测试,修改代码,提交修改) | 80 | 100 |
| Reporting | 报告 | ||
| · Test Report | · 测试报告 | 30 | 35 |
| · Size Measurement | · 计算工作量 | 15 | 10 |
| · Postmortem & Process Improvement Plan | · 事后总结,并提出过程改进计划 | 30 | 40 |
| 合计 | 550 | 690 |
实际比预估多,主要因为 safe_eval 从 eval 重写为递归下降解析器,以及去重签名的交换律/结合律规则反复调整,这两处花了额外时间。
七、项目小结
1. 成败得失
成功之处:
- 完整实现了题目要求的全部功能:-n、-r 参数控制、不出现负数、除数不为 0、运算符不超过 3 个、自动去重、生成 Exercises.txt 和 Answers.txt、支持 -e -a 批改并输出 Grade.txt。
- 去重逻辑经过反复调整,最终按题目要求只处理交换律、不处理结合律,正确区分了 1+2 与 2+1(重复)、(1+2)+3 与 (3+2)+1(不重复)。
- 用自写的 FractionEvaluator 替代 eval,既保证了分数运算精度,又消除了安全隐患。
- 测试覆盖较全,8 个测试文件覆盖了分数、求值、节点合并、签名、生成、批改、命令行等模块。
不足之处:
- 去重签名最初处理了结合律,导致测试和题目要求冲突,来回改了几次才定下来。
- 除法结果限制为真分数后,r 很小时可能生成不到 n 道题,需要靠最大尝试次数兜底。
- 生成 10000 道题时随机数调用较多,还有进一步优化空间。
2. 结对感受
温美如: 这次结对让我体会到两个人一起看代码的价值。我写 signature 时一开始处理了结合律,自己觉得没问题,但陈婉怡写测试时发现和题目要求冲突,我们对着题目原文反复推敲,最后确定只处理交换律。如果没有她写测试,这个 bug 可能到提交后才被发现。陈婉怡测试写得细,覆盖了交换律、结合律、空格容错、除零、非法表达式等边界情况,发现了多个主程序没注意到的问题。
陈婉怡: 我主要负责测试,写测试用例的过程让我更仔细地读了需求文档。比如“1+2+3 和 3+2+1 不重复”这条,一开始我觉得很奇怪,后来理解了题目只要求交换律、不要求结合律。写测试时也发现了 − 符号没被替换、FractionEvaluator 正则不允许逗号等问题,帮助主程序更健壮。温美如对表达式树和递归理解很深,combine_node 的合法性检查写得很清晰,调试 FractionEvaluator 时很快定位到 − 没替换的问题。


浙公网安备 33010602011771号