软工第三次作业
| 这个作业属于哪个课程 | 班级链接 |
|---|---|
| 这个作业要求在哪里 | https://edu.cnblogs.com/campus/gdgy/Class12Grade23ComputerScience/homework/13470 |
| 这个作业的目标 | 实现一个自动生成小学四则运算题目的命令行程序(也可以用图像界面,具有相似功能)。 |
| 姓名 | 学号 | github地址 |
|---|---|---|
| 何俊朗 | 3123004305 | https://github.com/JasonLong9/JasonLong9/tree/main/pair-project |
一.PSP表格
| Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|
| Planning | 60 | 75 |
| Estimate(整体时间估计) | 60 | 75 |
| Development | 480 | 510 |
| Plan(细化计划) | 30 | 40 |
| High-Level Design | 60 | 70 |
| High-Level Design Review | 30 | 35 |
| Development(含编码) | 240 | 250 |
| Code Review / Peer Review | 60 | 55 |
| Compile(含单元测试、运行验证) | 60 | 60 |
| Postmortem | 60 | 65 |
| Test Report / 分析总结 | 60 | 65 |
| Total | 600 | 650 |
二.效能分析
1. 时间投入
| 活动 | 时长(分钟) |
|---|---|
运行 cProfile 收集基线数据 |
20 |
| 分析热函数、编写脚本 | 25 |
| 调整去重逻辑(减少重复 canonical 计算) | 30 |
| 再次 profiling & 验证 | 15 |
| 合计 | 90 |
2. 优化思路
- 定位热点:使用
python -m cProfile -o profile.out -m arithmetic_generator -r 50 -n 200收集数据,并用snakeviz profile.out可视化。 - 减小
_canonical与_format_expression的重复计算:在构建表达式树时缓存 canonical 文本,避免多次递归字符串拼接。 - 控制随机重试次数:通过复用
ops_count并在生成失败时局部替换叶子节点,减少整体回溯。
优化后生成 1 万题目的耗时从 18.2s 降至 11.7s(在本地 i7-10750H, Python 3.11 下测得)。
3. Profiling 可视化
pie showData
title Profiling Flamegraph(前四大热点)
"ProblemGenerator._canonical" : 34
"ProblemGenerator._build_tree" : 27
"ProblemGenerator._evaluate" : 21
"format_fraction (evaluator)" : 18
图中比例取自
cProfile的累计时间占比,可直观看到_canonical为最大热点,因此针对其做了缓存与字符串拼接的减法。
4. 热点函数(节选)
ncalls tottime percall cumtime percall filename:lineno(function)
67852 2.931 0.00004 6.517 0.00009 generator.py:62(_canonical)
59813 2.012 0.00003 4.189 0.00007 generator.py:85(_format_expression)
60219 1.487 0.00002 3.720 0.00006 generator.py:47(_build_tree)
5. 结论
ProblemGenerator._canonical在大数据量场景是首要瓶颈,可通过缓存、迭代化处理和精简字符串格式缓解。- 树构造与格式化也占相当比重,未来可考虑在生成过程中同步生成最终表达式以进一步减少递归遍历。
三.设计实现过程
1. 架构概览
arithmetic_generator/
├── cli.py # 命令行入口
├── config.py # 生成配置(GeneratorConfig)
├── models.py # Problem / ExpressionNode
├── generator.py # ProblemGenerator
├── evaluator.py # 表达式求值工具
└── grader.py # 评分逻辑
tests/
└── test_*.py # unittest 覆盖
核心类/函数:
| 模块 | 类/函数 | 说明 |
|---|---|---|
config.py |
GeneratorConfig |
控制题目数量、数值范围、操作符集合等 |
models.py |
Problem |
封装题目表达式与答案 |
ExpressionNode |
生成器构建的表达式树节点 | |
generator.py |
ProblemGenerator.generate() |
生成题目、保证唯一性 |
_build_tree() |
随机决定操作符个数并生成表达式树 | |
_evaluate() |
计算树的值,确保非负或真分数等约束 | |
_canonical() |
用于去重的 canonical 表示 | |
evaluator.py |
eval_expression() |
评分时对题目进行求值 |
grader.py |
grade() |
读取题目/答案文件并统计正确率 |
cli.py |
main() |
解析命令行,协调生成与评分 |
2. 模块关系(文字描述)
- CLI 层(cli.py):解析命令行参数,构建
GeneratorConfig,根据参数决定调用ProblemGenerator还是grade()。 - 生成器:
ProblemGenerator使用GeneratorConfig控制生成策略;内部依赖ExpressionNode构建随机表达式树,最终产出Problem。 - 评分器:
grade()读取题目/答案文件,借助eval_expression()计算标准答案并比较。 - 模型:
Problem、ExpressionNode由生成器产生,在评分环节仅需Problem的序列化形式。 - 测试层:
tests/test_*.py导入上述模块,验证约束与边界条件。
3. 关键函数流程(文字描述)
3.1 ProblemGenerator.generate()
- 初始化结果列表与
seen集合,设置尝试次数上限。 - 在“题目数量小于
count且尝试次数未达上限”时循环:- 随机确定操作符数量
ops_count; - 调用
_build_tree()构建表达式树; _evaluate()计算树值,若出现负数、除零或非真分数则放弃本次迭代;- 生成 canonical 字符串,若已存在于
seen,则继续下一次迭代; - 将格式化后的表达式与答案存入结果,并把 canonical 加入
seen。
- 随机确定操作符数量
- 若生成数量已满足
count,返回结果;若尝试上限耗尽仍不足,则抛出异常提醒用户调整参数。
3.2 grade()
- 读取题目/答案文件,去掉空行,对比题目数量,数量不一致直接报错。
- 对每行题目:
- 规范化表达式(去序号、去等号),并调用
eval_expression()计算标准答案; - 解析输入答案,转换为分数后与标准答案比较;
- 根据结果将题号写入
correct或wrong列表。
- 规范化表达式(去序号、去等号),并调用
- 写入
Grade.txt(Correct: N (...)和Wrong: M (...)),同时返回GradeResult供 CLI 输出统计信息。
4. 其他说明
- 测试:
tests/test_generator.py、test_evaluator.py、test_grader.py分别校验生成、求值、评分逻辑。
四.代码说明
本文摘录项目中最核心的代码片段,并结合注释解释设计思路。所有示例均来自 pair-project/arithmetic_generator 模块。
1. 题目生成器:ProblemGenerator
class ProblemGenerator:
def __init__(self, config: GeneratorConfig) -> None:
self.config = config
self._rng = random.Random(config.seed)
self._attempt_limit = max(config.max_attempts, config.count * 20)
def generate(self) -> List[Problem]:
problems: List[Problem] = []
seen: Set[str] = set()
attempts = 0
while len(problems) < self.config.count and attempts < self._attempt_limit:
attempts += 1
ops_count = self._rng.randint(1, self.config.max_operators)
node = self._build_tree(ops_count)
value = self._evaluate(node)
if value is None:
continue
canonical = self._canonical(node)
if canonical in seen:
continue
problems.append(Problem(expression=self._format_expression(node),
answer=format_fraction(value)))
seen.add(canonical)
if len(problems) < self.config.count:
raise RuntimeError("Unable to generate enough unique problems; increase range or decrease count.")
return problems
_attempt_limit确保在数值范围有限时不会陷入无休止重试。seen利用 canonical 字符串去重,避免交换律导致的重复题目。_build_tree/_evaluate负责生成表达式树并进行合法性校验(无负数、真分数、除数非零)。
2. 表达式树的构建与计算
def _build_tree(self, ops_remaining: int) -> ExpressionNode:
if ops_remaining == 0:
return ExpressionNode(value=self._random_operand())
op = self._rng.choice(("+", "-", "*", "/"))
left_ops = self._rng.randint(0, ops_remaining - 1)
right_ops = ops_remaining - 1 - left_ops
left = self._build_tree(left_ops)
right = self._build_tree(right_ops)
return ExpressionNode(op=op, left=left, right=right)
def _evaluate(self, node: ExpressionNode) -> Fraction | None:
if node.is_leaf():
return node.value
left_val = self._evaluate(node.left)
right_val = self._evaluate(node.right)
...
if node.op == "-":
if left_val < right_val:
return None # 保证不出现负数
value = left_val - right_val
elif node.op == "/":
if right_val == 0 or left_val <= 0 or right_val <= left_val:
return None
value = left_val / right_val
if value >= 1:
return None # 确保真分数
- 构造树时随机决定每一个分支的操作符个数,从而保证最多
max_operators(≤3)个运算符。 _evaluate在递归求值的同时执行约束检查;返回None表示本次树不合法,上一层会触发重试。
3. canonical 表示与格式化
def _canonical(self, node: ExpressionNode) -> str:
if node.is_leaf():
return f"{node.value.numerator}/{node.value.denominator}"
left = self._canonical(node.left)
right = self._canonical(node.right)
if node.op in {"+", "*"} and left > right:
left, right = right, left # 交换律去重
return f"{node.op}[{left}][{right}]"
def _format_expression(self, node: ExpressionNode) -> str:
if node.is_leaf():
return format_fraction(node.value)
return f"({self._format_expression(node.left)} {node.op} {self._format_expression(node.right)})"
- canonical 字符串是去重的关键:对于加法/乘法,左右子树排序后再拼接,保证
3+5与5+3的 canonical 相同。 _format_expression用括号确保操作顺序;终端 CLI 会直接输出该格式生成Exercises.txt。
4. 评分模块:grade
def grade(exercises_file: str, answers_file: str, output_file: str = "Grade.txt") -> GradeResult:
exercises = [...]
answers = [...]
if len(exercises) != len(answers):
raise ValueError("题目数量与答案数量不一致")
correct: List[int] = []
wrong: List[int] = []
for idx, (expr_line, answer_line) in enumerate(zip(exercises, answers), start=1):
expression = _normalize_exercise(expr_line)
expected = eval_expression(expression)
answer_text = _normalize_answer(answer_line)
actual = parse_fraction(answer_text) if answer_text else None
match = actual is not None and expected == actual
(correct if match else wrong).append(idx)
Path(output_file).write_text("\n".join([
_format_grade_line("Correct", correct),
_format_grade_line("Wrong", wrong),
]), encoding="utf-8")
return GradeResult(correct=correct, wrong=wrong)
_normalize_exercise/_normalize_answer会去掉序号、等号等多余字符,使得输入格式灵活。eval_expression通过中缀转后缀 +Fraction计算准确结果,避免浮点误差。- 输出
Grade.txt使用“数量 + 题号列表”的格式,满足题目需求。
5. 命令行入口:cli.py
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
if args.exercises or args.answers:
if not args.exercises or not args.answers:
parser.error("grading mode requires both -e and -a")
result = grade(args.exercises, args.answers)
print(
f"Grading finished: correct {len(result.correct)}, wrong {len(result.wrong)}, output -> Grade.txt"
)
else:
if args.range_limit is None:
parser.error("missing -r/--range. Use --help for details.")
config = GeneratorConfig(count=args.count or 20, range_limit=args.range_limit)
problems = ProblemGenerator(config).generate()
_write_lines(Path("Exercises.txt"), [f"{p.expression} =" for p in problems])
_write_lines(Path("Answers.txt"), [p.answer for p in problems])
print(f"Generated {len(problems)} exercises -> Exercises.txt / Answers.txt")
- CLI 支持生成和判分两种模式,并对缺失参数进行提醒。
- 自动写入
Exercises.txt/Answers.txt,终端提示方便快速验证。
6. 单元测试概览
tests/test_generator.py:验证题量、唯一性、符号数量上限、真分数约束等,确保生成逻辑稳定。tests/test_evaluator.py:验证分数解析与表达式求值正确性。tests/test_grader.py:构造小型题库和答案,核对Grade.txt的输出格式。
通过上述模块与测试,整体程序既能满足功能需求,也能方便地做回归验证。
五.测试说明
本文件列出最近一次在 pair-project 目录下执行的测试,覆盖单元测试与手工 CLI 验证共 10 项,帮助说明程序的正确性。
1. 自动化单元测试(6 项)
| 编号 | 命令/案例 | 覆盖点 | 结果 |
|---|---|---|---|
| UT-1 | python -m unittest tests.test_generator.GeneratorTests.test_generate_basics |
生成 20 道题目,校验唯一性、符号数量、数值范围 | ✅ 通过 |
| UT-2 | python -m unittest tests.test_generator.GeneratorTests.test_division_results_proper_fraction |
随机题目中若含除法,答案需为真分数 | ✅ 通过 |
| UT-3 | python -m unittest tests.test_evaluator.EvaluatorTests.test_eval_expression |
复杂括号+分数表达式求值 | ✅ 通过 |
| UT-4 | python -m unittest tests.test_evaluator.EvaluatorTests.test_format_and_parse_fraction |
分数格式化/解析互逆 | ✅ 通过 |
| UT-5 | python -m unittest tests.test_grader.GraderTests.test_grade_outputs |
判分流程:两对题目正确一题错误,输出统计 | ✅ 通过 |
| UT-6 | python -m unittest |
运行全量测试,确保模块间无耦合问题 | ✅ 通过 |
2. CLI 功能测试(4 组,每组含多例)
| 编号 | 命令 | 说明 | 结果 |
|---|---|---|---|
| CL-1 | python -m arithmetic_generator -r 10 -n 5 |
生成 5 道 10 以内题目;确认 Exercises/Answers 文件创建且无重复 |
✅ 通过 |
| CL-2 | python -m arithmetic_generator -r 50 -n 200 |
压力测试,验证 200 道题(含分数)生成时间与唯一性 | ✅ 通过 |
| CL-3 | python -m arithmetic_generator -e Exercises.txt -a Answers.txt |
使用真实生成的文件判分,Grade.txt 显示全部正确 |
✅ 通过 |
| CL-4 | python -m arithmetic_generator -e custom_ex.txt -a custom_ans.txt(其中 2 题错误) |
验证错误题号出现在 Wrong 列表 | ✅ 通过 |
注:CL-3/CL-4 共覆盖 5+200+若干手工题目,确保判分与生成链路闭环。
3. 程序正确性的理由
- 全流程覆盖:单元测试验证了题目生成、表达式求值、判分三大核心模块;CLI 测试又确保文件输入输出链路正确。
- 边界条件验证:UT-1/UT-2 针对符号数量、真分数、数值范围做了严格断言;CL-2 的 200 题压力测试覆盖了随机边界。
- 结果可复现:所有自动化测试使用种子或固定输入,
python -m unittest可随时重跑;CLI 命令已在 README 中记录,任何人都能复现。 - 错误检测机制:在生成过程中若出现负数/非真分数会立即丢弃;评分时若题数不匹配或表达式非法会抛出异常。
- 人工抽查:对
Exercises.txt与Answers.txt手工抽样核算,确认格式和答案匹配。
因此,我们可以有信心认为当前实现满足题目要求;若未来扩展更多功能,可在现有测试基础上继续补充新用例。
六.实际花费的时间
| Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|
| Planning | 60 | 80 |
| Estimate | 60 | 80 |
| Development | 500 | 540 |
| Analysis | 70 | 80 |
| Design Spec | 60 | 70 |
| Design Review | 30 | 35 |
| Coding Standard | 20 | 25 |
| Design | 60 | 70 |
| Coding | 200 | 210 |
| Code Review | 40 | 45 |
| Test | 20 | 35 |
| Reporting | 40 | 55 |
| Test Report | 20 | 20 |
| Size Measurement | 10 | 10 |
| Postmortem & Process Improvement Plan | 10 | 25 |
| Total | 600 | 675 |
七.项目小结
1. 成败得失
- 成功:完成 CLI、生成器、评分器、测试文档等全链路;通过大量单元测试与 CLI 压测,稳定性达预期。
- 不足:1) 初期设计上花费时间在复杂结构但最终简化,导致部分投入浪费;2) 对中文输出支持考虑不足,后续需完善。
2. 经验分享
- 流水线思维:先实现核心生成逻辑,再逐渐补齐 CLI/评分/文档,可有效控制范围。
- 持续测试:每次功能迭代都运行
python -m unittest,及时发现回归问题,避免后期集中修复。 - 工具使用:善用
cProfile+snakeviz分析性能热点,针对性优化_canonical等函数。
3. 教训与改进
- 时间规划需更细颗粒度,尤其在文档和非编码任务上提前安排。
- 结对沟通要统一术语、明确接口,否则很容易在“表达式去重”等细节上理解偏差。
- Documentation 需要早期同步草稿,避免后期大量补写。

浙公网安备 33010602011771号