结对项目

这个作业属于哪个课程 软件工程
这个作业要求在哪里 结对项目
这个作业的目标 实现一个自动生成小学四则运算题目的命令行程序,生成题目、算出答案,并能批改别人交上来的题目

结对项目:小学四则运算题目生成器

小组成员:刘浩彬 3124004477、邓家俊 3124004465

作业 GitHub 地址:https://github.com/Siruetto/liuhaobin/tree/main/PairProject

程序是 Python 3 写的,入口 main.py,只用标准库。

分工上,代码和测试主要由刘浩彬写,邓家俊负责一起抠需求、复核测试用例。

一、动手之前的 PSP 预估

两个人先对着需求估了一遍时间(单位:分钟):

PSP2.1 Personal Software Process Stages 预估耗时(分钟)
Planning 计划 15
· Estimate · 估计这个任务需要多少时间 15
Development 开发 300
· Analysis · 需求分析(包括学习新技术) 25
· Design Spec · 生成设计文档 20
· Design Review · 设计复审 15
· Coding Standard · 代码规范 10
· Design · 具体设计 30
· Coding · 具体编码 120
· Code Review · 代码复审 20
· Test · 测试 60
Reporting 报告 50
· Test Report · 测试报告 20
· Size Measurement · 计算工作量 10
· Postmortem & Process Improvement Plan · 事后总结,并提出过程改进计划 20
合计 365

当时觉得 365 分钟差不多了,实际用了 505 分钟。差在哪写在第六节。

二、效能分析

Python 里没有 VS 那种性能分析器,等价的工具是标准库的 cProfile + pstats。写完之后花在效能分析上的时间大约是 40 分钟:先跑一遍看热点,改一处写法,再回头对比。这些数据都能用 python tools/benchmark.py 一键复现。

实测耗时

题目个数 -r 10 -r 100
1000 0.011 s 0.012 s
5000 0.056 s 0.052 s
10000 0.125 s 0.103 s

需求要求"支持一万道题目的生成",实测一万道题只要 0.12 秒左右。

消耗最大的函数

对生成一万道题的过程做一次 cProfile,按函数自身耗时(tottime)排出来是这样:

耗时 函数 在哪个文件
0.056 s _richcmp fractions.py
0.044 s _randbelow_with_getrandbits random.py
0.038 s _combine generator.py
0.038 s _build generator.py
0.035 s isinstance 内置
0.032 s canonical expression.py

profile_tottime

排第一的 _richcmp 是分数之间的比较,isinstance 和 __new__ 也都来自分数运算。也就是说时间主要花在精确分数计算上:每造一个节点都要算一次分数的加减乘除,还要在"能不能做减法/除法"的判断里比较两个分数的大小。这是正确性的代价——换成浮点会快很多,但 1/3 根本存不准,题目的答案就不可信了。

改进一处:判重用的"规范形式"

需求 6 要求题目不能重复,判重的做法是给每道题算一个"规范形式"再放进集合。第一版是把规范形式拼成字符串,比如 (+ 3 (× 1 2));profile 显示这一步在生成过程里占了不少时间。

后来改成嵌套元组:数值叶子写成 (0, 分子, 分母),运算节点写成 (1, 运算符编号, 左, 右)。元组的比较和哈希都比字符串快,而且判重结果完全一样——这一点专门写了一条用例守住。

两种写法交替测量(同一台机器一会儿快一会儿慢,必须交叉着测才有可比性):

判重写法 生成一万道题的耗时(中位数)
拼接字符串 0.134 s
嵌套元组 0.118 s

快了大约 1.13 倍。数字不大,但这已经是最值得动的一处了:
再往下就是分数运算本身,那部分不能省。

canonical_compare

另外踩过一个坑:一开始在同一个进程里反复测同一个函数,测出来比第一次慢将近一倍,查了半天才发现是垃圾回收的锅——跑得多、产生的垃圾多,GC 就会在计时区间里触发。后来改成"两种写法交替、每次都重新起进程"才好。测性能这件事本身也要小心方法。

三、设计实现过程

需求怎么拆

九条需求可以分成两类:

  • 生成:-n 题目个数、-r 数值范围、不出现负数、除法结果是真分数、运算符不超过 3 个、题目不重复;
  • 文件与批改:写 Exercises.txt、写 Answers.txt、-e/-a 批改并输出 Grade.txt。

生成那几条互相牵制得厉害(比如既要有除法,又要保证商是真分数),所以最后没有把约束当成"生成完再检查",而是直接做进生成过程里。

五个模块,各管一件事

文件 职责 关键函数
main.py 命令行入口:解析 -n/-r/-e/-a,把各模块串起来,参数不合法时给帮助信息 main(argv)、_run_generate()、_run_grade()
expression.py 表达式树:求值、输出成题目文本、算判重用的规范形式、把文本解析回表达式 Expression.__str__()、Expression.canonical()、parse_expression()
generator.py 随机造题:保证满足所有约束、题目不重复 Generator.generate()、Generator._combine()
number_text.py 分数和文本之间的转换:3/5、2'3/8 format_number()、parse_number()
records.py Exercises.txt、Answers.txt、Grade.txt 三种文件的读写 write_exercises()、read_answers()、write_grade()
grading.py 批改:按题目重算标准答案,再和答案文件比对 grade()

依赖是单向的:main → generator/grading → records/expression → number_text。expression.py 和 number_text.py 不碰文件,所以它们的测试不用建临时文件。

关键设计一:先造子式,再挑可行的运算符

最容易写错的写法是"先随机挑运算符,再凑两个满足约束的操作数"——那样减法要保证不出负数、除法要保证商是真分数,得反复重试,写起来也啰嗦。

这里反过来做:先把左右两个子式造出来(顺带拿到它们的值),再从可行的运算符里随机挑一个:

        smaller, larger = sorted((left_value, right_value))
        choices = [ADD, MUL]                    # 加和乘永远可行
        if left_value >= right_value:
            choices.append(SUB)                 # 左不小于右, 减法才不会出负数
        if 0 < smaller < larger:
            choices.append(DIV)                 # 交换后商才是真分数
        op = self.random.choice(choices)

这么写有个好处:约束在挑选那一刻就满足了,不需要生成完再检查、更不需要重试。代价是运算符的出现概率不是各占四分之一(加、乘总在候选里,减、除要看情况)。

关键设计二:判重只承认"交换",不承认"结合"

判重规则是这道题最容易理解错的地方。题面说:

3+(2+1) 和 1+2+3 是重复的……但是 1+2+3 和 3+2+1 是不重复的两道题

这句话的意思不是"加法满足结合律所以都能合并"。因为 + 是左结合的,1+2+3 实际是 (1+2)+3,交换左右子式可以得到 3+(1+2)、再得到 3+(2+1);而 3+2+1 是 (3+2)+1,两棵树的形状不一样,靠交换换不过去。

所以规范形式只在 + 和 × 的节点上把左右子式排序,− 和 ÷ 保持原样:

    def canonical(self):
        if self.is_leaf:
            value = self.value
            return (0, value.numerator, value.denominator)
        left = self.left.canonical()
        right = self.right.canonical()
        if self.op in COMMUTATIVE and right < left:
            left, right = right, left
        return (1, _OP_CODE[self.op], left, right)

题面里那几个例子全都写成了单元测试,包括"这两个算重复"和"这两个不算重复"两种。

关键设计三:括号要加得刚好

题目输出的是给人做的题,括号既不能多也不能少。规则是标准的:
优先级低的子式要加括号,右子式和父节点同优先级时也要加(因为四则运算都是左结合的)。

这样保证一件事:打印出来的题目,再读回去能还原出同一棵树。
test_parse_then_render_keeps_the_structure 就是守着这条的——5 − (3 − 1) 不能打印成 5 − 3 − 1。

整体流程

微信圖片_20260919190328_550_41

四、代码说明

挑几段比较关键的。

1. 分数的文本形式(number_text.py)
题目要求五分之三写成 3/5、二又八分之三写成 2'3/8。写的时候有个小细节:题面里的撇号是全角的 ’,别人交上来的答案可能就用这个,所以解析时半角全角都收,输出统一用半角:

def format_number(value):
    if value.denominator == 1:
        return str(value.numerator)
    sign = "-" if value < 0 else ""
    numerator = abs(value.numerator)
    if numerator < value.denominator:
        return "{}{}/{}".format(sign, numerator, value.denominator)
    whole, remainder = divmod(numerator, value.denominator)
    return "{}{}'{}/{}".format(sign, whole, remainder, value.denominator)

2. 表达式的值在建节点时就算好(expression.py)
每个内部节点在构造时就把值算出来存进去,这样生成器拿到子式的值就能直接判断约束,不用回头再遍历一遍树。顺带也把"除以 0"这种不可能出现的情况挡住:

    @staticmethod
    def binary(op, left, right):
        if op == ADD:
            value = left.value + right.value
        elif op == SUB:
            value = left.value - right.value
        elif op == MUL:
            value = left.value * right.value
        elif op == DIV:
            if right.value == 0:
                raise ZeroDivisionError("除数不能为 0")
            value = left.value / right.value
        else:
            raise ValueError("未知运算符: {!r}".format(op))
        return Expression(op, left, right, value)

3. 题目打印时的括号(expression.py)

    def _child_text(self, child, is_right):
        if child.is_leaf:
            return format_number(child.value)
        needs_parens = PRECEDENCE[child.op] < PRECEDENCE[self.op] or (
            PRECEDENCE[child.op] == PRECEDENCE[self.op] and is_right
        )
        return "({})".format(child) if needs_parens else str(child)

4. 造不出题的时候要报错,不能死循环(generator.py)
-r 1 是允许的参数,但那个范围里题目里只能出现 0,凑不出几千道不重复的题。所以记一个"连续撞重复"的计数,撞太多次就抛错并提示把 -r 调大:

            if key in self._seen:
                repeats += 1
                if repeats > _MAX_CONSECUTIVE_REPEATS:
                    raise GeneratorError(
                        "在 -r {} 的范围内凑不齐 {} 道不重复的题目, 请把 -r 调大"
                        .format(self.limit, count)
                    )
                continue
            repeats = 0

5. 批改时按数值比,不按写法比(grading.py)
批改不是把答案文本和标准答案文本做字符串比较,而是两边都解析成分数再比。所以别人写 14/48、46/2、7'1/2 都能判对:

def grade(exercise_path, answer_path):
    given = read_answers(answer_path)
    correct, wrong = [], []
    for number, expression in read_exercises(exercise_path):
        if number not in given:
            raise ValueError("答案文件里缺少第 {} 题的答案".format(number))
        if given[number] == expression.evaluate():
            correct.append(number)
        else:
            wrong.append(number)
    return correct, wrong

五、测试运行

规模和结果

项目 数值
测试文件 6 个(按模块分开)
用例数量 66 个
结果 66 passed
覆盖率 6 个源文件语句与分支全部 100%
python -m pytest --cov=. --cov-branch --cov-report=term-missing tests -q

微信圖片_20260919185506_548_41

十几个有代表性的用例

用例 验的是什么 数据是怎么构造的
test_fraction_addition_from_the_spec 题面给的例子必须算对 直接用 1/6 + 1/8,断言结果是 7/24
test_addition_is_commutative 23+45 和 45+23 算重复 手搭两棵树,比较规范形式
test_multiplication_is_commutative 6×8 和 8×6 算重复 同上
test_three_addends_with_same_structure_are_equivalent 3+(2+1) 和 1+2+3 算重复 按题面原话搭树
test_different_addend_structures_are_not_equivalent 1+2+3 和 3+2+1 不算重复 故意搭成两种树形,断言不相等
test_constraints_hold_for_many_problems 生成器必须满足全部约束 4 个范围各生成 200 道,遍历表达式的每一个子式,检查无负数、除法结果在 0 和 1 之间、运算符不超过 3 个、叶子的数值和分母都小于 r
test_no_duplicate_problems 同一批题目不重复 生成 1000 道,把规范形式放进集合看数量是否还是 1000
test_ten_thousand_problems 需求 8 的一万道题 生成 10000 道,检查数量和不重复性
test_limit_one_still_works_for_small_counts -r 1 这种退化情况不能崩 用 -r 1 生成 20 道,断言每题的值都是 0
test_range_too_small_is_reported 凑不出题要报错而不是死循环 -r 1 要 1000 道,断言抛 GeneratorError
test_parse_then_render_keeps_the_structure 打印出来能读回去 拿 5 − (3 − 1) 这类题目做"解析→打印"往返,断言字符串没变
test_equivalent_answer_forms_are_accepted 等价写法算对 答案文件里写 14/48、46/2、15/2,断言全部判对
test_grade_file_format Grade.txt 的格式 断言输出正是 Correct: 2 (1, 3) 和 Wrong: 1 (2)
test_missing_range_prints_help 缺 -r 要报错并给帮助 只传 -n,断言返回码 2 且输出里有用法说明
test_same_seed_gives_the_same_problems 随机数可控 同一个种子跑两次,断言题目完全一样

为什么能确定程序是对的

四点:

一是题面自己给了标准答案和标准规则。1/6 + 1/8 = 7/24、23+45 和 45+23 算重复、1+2+3 和 3+2+1 不算重复,这些直接写成了断言,不是我们自己理解的规则。

二是约束是逐个检查的,不是抽查。生成的每道题都会把表达式树走一遍,对每个子式都验一遍减法不出负数、除法结果在 0 到 1 之间。

三是生成和批改互相验证。端到端测试先正常生成一批题,答案文件写的是程序自己算的答案,
再故意把其中一题的答案改错,看批改能不能只挑出那一题——Correct: 9 (...) / Wrong: 1 (2)。如果生成或者求值有错,这个测试会立刻挂掉。

四是覆盖率。6 个源文件的语句和分支都是 100%,包括参数错误、文件格式错误、范围太小这些分支。

当然也有说不到的地方:测试用的是固定的随机种子,覆盖的是"程序逻辑正确",而不是"生成的题目是不是小学生觉得合适"。后者只能靠人翻几页题目来看。

六、PSP 表格(实际耗时)

PSP2.1 Personal Software Process Stages 预估耗时(分钟) 实际耗时(分钟)
Planning 计划 15 20
· Estimate · 估计这个任务需要多少时间 15 20
Development 开发 300 425
· Analysis · 需求分析(包括学习新技术) 25 40
· Design Spec · 生成设计文档 20 25
· Design Review · 设计复审 15 15
· Coding Standard · 代码规范 10 10
· Design · 具体设计 30 35
· Coding · 具体编码 120 180
· Code Review · 代码复审 20 30
· Test · 测试 60 90
Reporting 报告 50 60
· Test Report · 测试报告 20 25
· Size Measurement · 计算工作量 10 10
· Postmortem & Process Improvement Plan · 事后总结,并提出过程改进计划 20 25
合计 365 505

差得最多的是具体编码(120 → 180)和测试(60 → 90)。编码多花的时间几乎都在需求 6 的判重上:一开始以为按交换律合并就够了,看到题面里 1+2+3 和 3+2+1 那段说明才明白"只允许交换、不允许重新结合",为此把规范形式重做了一遍。测试多花的时间在"逐个检查每个子式"上。

七、项目小结

做得好和做得不好的地方

做得好的:把约束做进了生成过程,而不是生成完再筛。省掉了反复重试,代码也短。另外把题面给的每个例子(包括"这两道不算重复"那种反例)都写成了测试,需求里最容易理解错的那条被钉死了。

做得不好的:需求分析阶段对 -r 的理解来回改了几次——它管的是题目里出现的数值,不包括算出来的结果。这一点直到写生成器时才发现,前面有一部分设计白做了。还有就是我一开始急着写代码,判重规则没完全想清楚就动手,结果返工。

结对感受

刘浩彬:这次结对对我最大的作用是"被追问"。判重那条规则我自己看的时候觉得意思很直白,邓家俊问了一句"那 3+2+1 为什么不算重复",我才回去把左结合那件事想明白。一个人做的话,这种地方很可能就按自己的理解写下去了。另外我发现自己有个习惯不太好:喜欢先把代码写出来再说,遇到需求含糊的地方就想"先按这样写,错了再改"。这次返工的就是判重那块,从头改规范形式比一开始想清楚要多花不少时间。

邓家俊:我不太写代码,主要帮着抠规则和看测试够不够。做完之后我最大的感受是"约束要一条一条对着需求数",比如"除法结果必须是真分数"这句,一开始我们只检查了最终答案,后来才发现应该对每一个除法子式都检查。另外看着别人写代码也学到了点东西:给函数写清楚"为什么这么写"的注释,后来自己回头查问题时省了很多时间。

彼此的闪光点和建议

刘浩彬:看规则细。他会盯着题面里容易被跳过的一句话反复问,提的问题常常正好是我没想清楚的地方。建议是介入得再早一点——这次主要是等我把东西写出来之后再看,如果能在设计阶段就一起过一遍规则,返工可能就少一些。

邓家俊:遇到性能问题不凭感觉改,先跑一遍分析工具看时间花在哪,再决定动不动手;代码里的注释写得比较全,我读的时候基本不用问他。建议是讲方案的时候慢一点、把关键决定的理由先写下来,
有几次我们聊到一半就得回去翻需求原文确认,前面要是留个记录会顺很多。

posted @ 2026-09-19 19:18  刘浩彬  阅读(40)  评论(0)    收藏  举报