软工第三次作业

这个作业属于哪个课程 班级链接
这个作业要求在哪里 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. 优化思路

  1. 定位热点:使用 python -m cProfile -o profile.out -m arithmetic_generator -r 50 -n 200 收集数据,并用 snakeviz profile.out 可视化。
  2. 减小 _canonical_format_expression 的重复计算:在构建表达式树时缓存 canonical 文本,避免多次递归字符串拼接。
  3. 控制随机重试次数:通过复用 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() 计算标准答案并比较。
  • 模型ProblemExpressionNode 由生成器产生,在评分环节仅需 Problem 的序列化形式。
  • 测试层tests/test_*.py 导入上述模块,验证约束与边界条件。

3. 关键函数流程(文字描述)

3.1 ProblemGenerator.generate()

  1. 初始化结果列表与 seen 集合,设置尝试次数上限。
  2. 在“题目数量小于 count 且尝试次数未达上限”时循环:
    • 随机确定操作符数量 ops_count
    • 调用 _build_tree() 构建表达式树;
    • _evaluate() 计算树值,若出现负数、除零或非真分数则放弃本次迭代;
    • 生成 canonical 字符串,若已存在于 seen,则继续下一次迭代;
    • 将格式化后的表达式与答案存入结果,并把 canonical 加入 seen
  3. 若生成数量已满足 count,返回结果;若尝试上限耗尽仍不足,则抛出异常提醒用户调整参数。

3.2 grade()

  1. 读取题目/答案文件,去掉空行,对比题目数量,数量不一致直接报错。
  2. 对每行题目:
    • 规范化表达式(去序号、去等号),并调用 eval_expression() 计算标准答案;
    • 解析输入答案,转换为分数后与标准答案比较;
    • 根据结果将题号写入 correctwrong 列表。
  3. 写入 Grade.txtCorrect: N (...)Wrong: M (...)),同时返回 GradeResult 供 CLI 输出统计信息。

4. 其他说明

  • 测试tests/test_generator.pytest_evaluator.pytest_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+55+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. 程序正确性的理由

  1. 全流程覆盖:单元测试验证了题目生成、表达式求值、判分三大核心模块;CLI 测试又确保文件输入输出链路正确。
  2. 边界条件验证:UT-1/UT-2 针对符号数量、真分数、数值范围做了严格断言;CL-2 的 200 题压力测试覆盖了随机边界。
  3. 结果可复现:所有自动化测试使用种子或固定输入,python -m unittest 可随时重跑;CLI 命令已在 README 中记录,任何人都能复现。
  4. 错误检测机制:在生成过程中若出现负数/非真分数会立即丢弃;评分时若题数不匹配或表达式非法会抛出异常。
  5. 人工抽查:对 Exercises.txtAnswers.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 需要早期同步草稿,避免后期大量补写。
posted @ 2025-11-19 16:14  何俊朗  阅读(2)  评论(0)    收藏  举报