结对项目
| 这个作业属于哪个课程 | 软件工程 |
|---|---|
| 这个作业要求在哪里 | 结对项目 |
| 这个作业的目标 | 实现一个自动生成小学四则运算题目的命令行程序,生成题目、算出答案,并能批改别人交上来的题目 |
结对项目:小学四则运算题目生成器
小组成员:刘浩彬 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 |

排第一的 _richcmp 是分数之间的比较,isinstance 和 __new__ 也都来自分数运算。也就是说时间主要花在精确分数计算上:每造一个节点都要算一次分数的加减乘除,还要在"能不能做减法/除法"的判断里比较两个分数的大小。这是正确性的代价——换成浮点会快很多,但 1/3 根本存不准,题目的答案就不可信了。
改进一处:判重用的"规范形式"
需求 6 要求题目不能重复,判重的做法是给每道题算一个"规范形式"再放进集合。第一版是把规范形式拼成字符串,比如 (+ 3 (× 1 2));profile 显示这一步在生成过程里占了不少时间。
后来改成嵌套元组:数值叶子写成 (0, 分子, 分母),运算节点写成 (1, 运算符编号, 左, 右)。元组的比较和哈希都比字符串快,而且判重结果完全一样——这一点专门写了一条用例守住。
两种写法交替测量(同一台机器一会儿快一会儿慢,必须交叉着测才有可比性):
| 判重写法 | 生成一万道题的耗时(中位数) |
|---|---|
| 拼接字符串 | 0.134 s |
| 嵌套元组 | 0.118 s |
快了大约 1.13 倍。数字不大,但这已经是最值得动的一处了:
再往下就是分数运算本身,那部分不能省。

另外踩过一个坑:一开始在同一个进程里反复测同一个函数,测出来比第一次慢将近一倍,查了半天才发现是垃圾回收的锅——跑得多、产生的垃圾多,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。
整体流程

四、代码说明
挑几段比较关键的。
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

十几个有代表性的用例
| 用例 | 验的是什么 | 数据是怎么构造的 |
|---|---|---|
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 为什么不算重复",我才回去把左结合那件事想明白。一个人做的话,这种地方很可能就按自己的理解写下去了。另外我发现自己有个习惯不太好:喜欢先把代码写出来再说,遇到需求含糊的地方就想"先按这样写,错了再改"。这次返工的就是判重那块,从头改规范形式比一开始想清楚要多花不少时间。
邓家俊:我不太写代码,主要帮着抠规则和看测试够不够。做完之后我最大的感受是"约束要一条一条对着需求数",比如"除法结果必须是真分数"这句,一开始我们只检查了最终答案,后来才发现应该对每一个除法子式都检查。另外看着别人写代码也学到了点东西:给函数写清楚"为什么这么写"的注释,后来自己回头查问题时省了很多时间。
彼此的闪光点和建议
刘浩彬:看规则细。他会盯着题面里容易被跳过的一句话反复问,提的问题常常正好是我没想清楚的地方。建议是介入得再早一点——这次主要是等我把东西写出来之后再看,如果能在设计阶段就一起过一遍规则,返工可能就少一些。
邓家俊:遇到性能问题不凭感觉改,先跑一遍分析工具看时间花在哪,再决定动不动手;代码里的注释写得比较全,我读的时候基本不用问他。建议是讲方案的时候慢一点、把关键决定的理由先写下来,
有几次我们聊到一半就得回去翻需求原文确认,前面要是留个记录会顺很多。

浙公网安备 33010602011771号