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

image

用 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 流程图:
image

signature 去重逻辑:
image

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. 交换律等价:1+2 和 2+1,视为同一道题,signature 相等
  2. 结合律不等价:(1+2)+3 和 1+(2+3),视为不同题目,signature 不相等
  3. 链式交换不等价:(1+2)+3 和 (3+2)+1,视为不同题目,signature 不相等
  4. 减法、除法严格区分左右操作数
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)
核心规则:

  1. 生成 n 道题目,语义查重基于 Node.signature
  2. 输出 Exercises.txt、Answers.txt 文件
  3. 达到最大尝试次数时,会输出警告
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 时很快定位到 − 没替换的问题。

posted @ 2026-09-21 21:20  wenqian1  阅读(11)  评论(0)    收藏  举报