结对项目:小学四则运算生成器
软件工程项目:实现一个自动生成小学四则运算题目的命令行程序
一、基本内容
| 这个作业属于哪个课程 | 软件工程 |
|---|---|
| 这个作业要求在哪里 | 结对项目 |
| 这个作业的目标 | 两人结对完成一个命令行四则运算题目生成器,满足生成题目、控制数值范围、去重、批改答案等 9 项功能需求,熟悉结对开发全流程。 |
成员信息:
| 结对成员 | 姓名 | 学号 |
|---|---|---|
| 成员一 | 谭添睿 | 3224004344 |
| 成员二 | 李采云 | 3224004382 |
Github 仓库地址: https://github.com/ttr-rui/Pair-Project-Math-Exercise-Generator
二、PSP 表格(预估时间)
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) |
|---|---|---|
| Planning | 计划 | 30 |
| · Estimate | · 估计任务时间 | 30 |
| Development | 开发 | 300 |
| · Analysis | · 需求分析 | 40 |
| · Design Spec | · 生成设计文档 | 20 |
| · Design Review | · 设计复审 | 20 |
| · Coding Standard | · 代码规范 | 10 |
| · Design | · 具体设计 | 30 |
| · Coding | · 具体编码 | 100 |
| · Code Review | · 代码复审 | 20 |
| · Test | · 测试 | 60 |
| Reporting | 报告 | 60 |
| · Test Report | · 测试报告 | 20 |
| · Size Measurement | · 计算工作量 | 10 |
| · Postmortem & Process Improvement Plan | · 事后总结 | 30 |
| 合计 | 390 |
三、效能分析
1. 测试环境与测试方法
| 项目 | 说明 |
|---|---|
| CPU | Windows x64 |
| Python | 3.13 |
| 工具 | cProfile + pstats(累计/自身耗时双口径) |
| 测试命令 | python profile_perf.py(-n 10000 -r 10) |
2. 性能分析图


四、设计实现过程
1. 代码组织
Pair-Project-Math-Exercise-Generator/
├── main.py # 命令行入口:参数解析 + 调度
├── fraction_util.py # 分数 ↔ 字符串 的双向转换
├── calculator.py # 表达式树求值
├── generator.py # 表达式树生成 + 约束校正 + 规范化去重 + 转字符串
├── checker.py # 批改:读题求值 → 比对 → 写 Grade.txt
├── test_main.py # 单元测试(19 个用例)
├── .gitignore
└── README.md
依赖关系(箭头表示"调用"):
main.py
/ | \
generator checker fraction_util
| | ▲
calculator ─┘ │
└────────────────────┘
五个模块的职责边界很清楚:
| 模块 | 唯一职责 | 不做什么 |
|---|---|---|
fraction_util |
格式 ↔ 数值 的转换 | 不做算术 |
calculator |
对已有表达式树求值 | 不管字符串、不管生成 |
generator |
造树、纠错、去重、加括号 | 不管文件读写 |
checker |
读文件、求值比对、输出统计 | 不管生成 |
main |
参数解析、调度、写 Exercises/Answers | 不含任何业务逻辑 |
generator 里的建树已经顺手把值算出来了,calculator.evaluate_tree 仍然单独存在——因为测试用例和批改模块需要独立复算,不能用生成器自己给出的值,否则就是"自己证明自己"。
2. 核心数据结构
表达式统一用二叉树(嵌套元组)表示:
叶子节点:("num", Fraction)
运算节点:(op, left, right) # op ∈ {"+", "-", "×", "÷"}
选元组而不是类,是因为它天然可哈希、可直接进 set 去重、可整棵递归传递,省掉了一整个类的样板代码。代价是没有字段名,所以代码里统一约定 tree[0] 是标签、tree[1]/tree[2] 是左右子树——这个约定我们一开始就写在模块 docstring 里了。
3. 关键算法
(1)生成算法 —— 边生成边校正
generate_tree(max_value, op_count) 递归构造:随机把 op_count-1 个运算符分给左右子树,再随机挑运算符,最后按局部约束校正:
-:left < right就交换,保证结果非负;÷:right == 0就改写成 1;left == right会导致结果为 1(不是真分数),优先重取左值、否则整棵重试、最终兜底为1/(r-1) ÷ 1;+×:无需校正。
为什么不直接"生成完再检查"? 初版我们试过 rejection sampling(生成一棵完整的树,检查不合法就丢弃重来),结果 3 个运算符的树里只要有一处不合法就全废,命中率很低。改成递归过程中就地校正后,一次成型的比例大幅提升,这也是后来 generate_tree 能占到 75% 却仍然只有 0.19 秒的原因。
(2)去重算法 —— 规范化元组(本项目的技术核心)
题目要求:任何两道题不能通过有限次交换 + 和 × 的左右子树变成同一道题。做法是给每棵树算一个"规范形式" canonical(tree),相同当且仅当两题等价。
def canonical(tree):
if tree[0] == "num":
return (0, tree[1]) # ← 标签 0
op, left, right = tree[0], tree[1], tree[2]
lc, rc = canonical(left), canonical(right)
if op in ("+", "×"):
if lc > rc:
lc, rc = rc, lc
return (1, op, lc, rc) # ← 标签 1
这里最容易漏掉的是首尾那个标签。0 和 1 不是随便写的,它们保证了叶子节点的元组永远小于任何内部节点。少了这个标签,3+(2+1) 和 1+2+3 就会因为树形不同而被判为不重复,直接读错题。
实测对照(题目原文给的三个例子,我们全部验过):
| 题目 A | 题目 B | A ≡ B? | 期望 | 结果 |
|---|---|---|---|---|
23 + 45 |
45 + 23 |
是 | 重复 | ✅ 判为重复 |
6 × 8 |
8 × 6 |
是 | 重复 | ✅ 判为重复 |
3+(2+1) |
1+2+3 |
是 | 重复 | ✅ 判为重复 |
1+2+3 |
3+2+1 |
否 | 不重复 | ✅ 判为不重复 |
后两例的推导:1+2+3 = ((1+2)+3),排序时叶子 3 被换到前面得到 (0,3) + (1,'+',(0,1),(0,2));3+(2+1) 右侧是 (0,3)、左侧是内部节点,排序后同样是那个元组,所以判重。而 3+2+1 = ((3+2)+1) 排序后是 (0,1) + (1,'+',(0,2),(0,3)),内部节点里 2 和 3 的次序不同,故不判重。完全踩中题目"有限次交换"的定义。
(3)批改算法 —— 全部操作数强制 Fraction
批改要读的是字符串,所以先把 3/5、2'3/8、整数分别替换成 Fraction(...) 调用,再用受限环境的 eval 求值。这里的坑见 §5.4。
4. 需求覆盖对照表
| # | 需求 | 落在哪个模块 | 如何验证 |
|---|---|---|---|
| 1 | -n 控制题目个数 |
main |
用例 1、8 |
| 2 | -r 控制数值范围,缺则报错 |
main |
用例 7(退出码 1 + 打印帮助) |
| 3 | 计算过程不产生负数 | generator.generate_tree |
单元测试 + 2 万次采样零违规 |
| 4 | ÷ 结果为真分数 |
generator.generate_tree |
单元测试 + 2 万次采样零违规 |
| 5 | 每道题运算符 ≤ 3 | generator.generate_tree |
单元测试 test_operator_count_max_3 |
| 6 | 交换律意义下不重复 | generator.canonical |
用例 4、5 + 一万道全场零重复 |
| 7 | Exercises.txt / Answers.txt 格式 |
fraction_util + main |
用例 1~3 目视 + 编码校对 |
| 8 | 支持一万道生成 | 全部 | 用例 8:0.19 s |
| 9 | -e / -a 批改并统计到 Grade.txt(附加分) |
checker |
用例 9、10、11 |
5. 关键函数流程图
主流程:
开始 → 解析命令行参数
├─ 有 -e 和 -a ──→ 批改模式 → 读题目/答案 → 求值比对 → 写 Grade.txt
├─ 有 -n ──→ 缺 -r ? ──是──→ 打印错误 + 帮助 → 退出码 1
│ │否
│ └─→ 生成模式 → generate_exercises
│ └→ generate_tree → canonical 查重 → 写文件
└─ 参数不足 ──────────→ 打印帮助信息
结束
generate_tree 单棵表达式树的构造流程:
generate_tree(r, op_count)
├─ op_count == 0 → 生成一个 < r 的自然数或真分数
└─ op_count > 0 → 随机切分 左/右 各得多少运算符 → 递归 → 随机挑运算符
├─ '-' → 左 < 右 则交换 → 返回 (树, 左-右)
├─ '÷' → 右为 0 改写为 1;左 == 右 则重取/重试/兜底;左 > 右 则交换
├─ '+' → 返回 (树, 左+右)
└─ '×' → 返回 (树, 左×右)
五、代码说明
5.1 分数格式化(fraction_util.py)
def format_fraction(f: Fraction) -> str:
"""将 Fraction 转换为题目要求的格式:
整数:5
真分数:3/5
带分数:2'3/8
"""
if f.denominator == 1:
return str(f.numerator)
if abs(f.numerator) > f.denominator:
whole = f.numerator // f.denominator
remainder = f.numerator % f.denominator
return f"{whole}'{remainder}/{f.denominator}"
return f"{f.numerator}/{f.denominator}"
思路:按分母是否为 1 区分整数;再按分子绝对值是否大于分母区分真分数和带分数。Fraction 自带约分,所以我们完全不需要自己写 gcd 和通分——整数÷整数那个 bug(见 §5.5)之所以能修复得这么干净,就是因为底层始终是 Fraction 而不是我们手搓的运算。整个程序从生成到批改全程 Fraction,没有一个浮点数,这是正确性的根基。
已知边界:该函数对负数走的是 Python 向下取整语义。由于生成器保证非负值,这条路径目前没有输入源;若将来扩展减法允许负数,需要单独处理并补测试。
5.2 表达式生成(generator.py)
def generate_tree(max_value: int, op_count: int, _depth: int = 0):
"""递归生成一个有 op_count 个运算符的表达式树,返回 (tree, value)"""
if op_count == 0:
v = generate_number(max_value)
return ("num", v), v
left_ops = random.randint(0, op_count - 1)
right_ops = op_count - 1 - left_ops
left_tree, left_val = generate_tree(max_value, left_ops, _depth + 1)
right_tree, right_val = generate_tree(max_value, right_ops, _depth + 1)
op = random.choice(["+", "-", "×", "÷"])
if op == "-":
if left_val < right_val: # 保证 e1 >= e2
left_tree, right_tree = right_tree, left_tree
left_val, right_val = right_val, left_val
value = left_val - right_val
elif op == "÷":
if right_val == 0: # 除数为 0 → 改写为 1
right_tree, right_val = ("num", Fraction(1, 1)), Fraction(1, 1)
if left_val == right_val and left_tree[0] == "num":
for _ in range(20): # 结果为 1 不是真分数 → 重取左值
new_v = generate_number(max_value)
if new_v != right_val:
left_tree, left_val = ("num", new_v), new_v
break
if left_val == right_val: # 左子树非叶子,改不动 → 整棵重试
if _depth < 50:
return generate_tree(max_value, op_count, _depth + 1)
# 兜底(实测约 0.1% 触发):退化为确定合法的最简除法
den = max(2, max_value - 1)
return ("÷", ("num", Fraction(1, den)), ("num", Fraction(1, 1))), Fraction(1, den)
if left_val > right_val: # 保证结果是真分数
left_tree, right_tree = right_tree, left_tree
left_val, right_val = right_val, left_val
value = left_val / right_val
elif op == "+":
value = left_val + right_val
else:
value = left_val * right_val
return (op, left_tree, right_tree), value
分层兜底是本函数的设计要点,三级依次降级:
| 级别 | 条件 | 动作 | 触发率 |
|---|---|---|---|
| 一级 | left == right 且左是叶子 |
重取左值(最多 20 次) | 常见 |
| 二级 | left == right 且左非叶子 |
整棵递归重试(_depth<50) |
少见 |
| 三级 | _depth >= 50 |
退化为 1/(r-1) ÷ 1 |
~0.1% |
第三级特意写成 1/(r-1) ÷ 1 而不是当初那版"左值 +1"——后者在 -r 10 时会造出 19 这种远超范围的数,属于隐性的需求违反,-r 参数的意义是所有出现过的数都要落在范围内,不只是叶子。
5.3 去重(generator.py)
def canonical(tree):
"""把表达式树规范化为一个元组用于去重。
对 + 和 × 应用交换律:左右子树按字典序排序。"""
if tree[0] == "num":
return (0, tree[1]) # 叶子标签 0
op, left, right = tree[0], tree[1], tree[2]
lc, rc = canonical(left), canonical(right)
if op in ("+", "×"):
if lc > rc:
lc, rc = rc, lc # 交换律:小的放前面
return (1, op, lc, rc) # 内部节点标签 1
为什么 (0, ...) 和 (1, ...) 不能省,第四节已经推导过。另外注意 - 和 ÷ 不能排序——5-3 和 3-5 不是一道题(后者还是负数),1÷2 和 2÷1 也不是一道题(后者不是真分数),所以 if op in ("+", "×") 这个判断同样不可省。
5.4 括号处理(generator.py)
题目最容易被忽略的一点是:同一个 Priority 下不能出现多余括号,否则出题会变得很丑。
def _to_string_with_parens(tree, parent_op, is_left):
prec = {"+": 1, "-": 1, "×": 2, "÷": 2}
if tree[0] == "num":
return format_fraction(tree[1])
op = tree[0]
s = tree_to_string(tree)
if prec[op] < prec[parent_op]: # 优先级低 → 加括号
return f"({s})"
if prec[op] == prec[parent_op] and not is_left and parent_op in ("-", "÷"):
return f"({s})" # 右子树的同级运算 → 加括号
return s
规则只有两条:子运算优先级更低必须加括号;优先级相同但是右侧操作数时也要加括号(因为 - 和 ÷ 不满足交换律,8 - (3 - 1) 少了括号就会变成 8 - 3 - 1 = 4,答案是错的)。左侧则一律不加——+ 和 × 可以展开,- / 的左结合也是对的。
5.5 批改求值(checker.py)—— 本项目踩过的最大的坑
先看我们最初的版本(有 bug,不要照抄):
# ❌ 初版:只把 a/b 形式的字符串转成 Fraction,剩下的整数原样留下
s = re.sub(r"(\d+)'(\d+)/(\d+)", replace_mixed, s)
s = re.sub(r"(\d+)/(\d+)", r"Fraction(\1, \2)", s)
return eval(s, {"__builtins__": {}}, {"Fraction": Fraction})
这段看起来没问题,跑十道四十道题也全对。但一万道题里有 378 道被误判为错(3.78%)。
原因很隐蔽:题目里只要出现整数 ÷ 整数(比如 2 ÷ 3),替换成 ÷→/ 后就是纯 Python 的 2 / 3,得到浮点数 0.6666666666666666。拿它跟答案文件里的 2/3(Fraction(2,3))一比,永远不等。只有真分数参与的那部分表达式被正确转成了 Fraction,其余落到浮点——所以它是"半对半错",肉眼抽样几道题根本发现不了。
修复版:
def _placeholder(index: int) -> str:
"""占位符里不能出现数字,否则会被后面的整数正则误匹配,
因此把序号 0-9 映射成字母 A-J。"""
return "__" + "".join(chr(65 + int(ch)) for ch in str(index)) + "__"
def evaluate_expression_string(expr: str) -> Fraction:
s = expr.replace("×", "*").replace("÷", "/")
mapping, counter = {}, [0]
def next_key():
key = _placeholder(counter[0]); counter[0] += 1; return key
def replace_mixed(m): # ① 带分数 2'3/8 —— 必须最先处理,否则被真分数规则拆坏
key = next_key()
whole, num, den = int(m.group(1)), int(m.group(2)), int(m.group(3))
mapping[key] = f"Fraction({whole} * {den} + {num}, {den})"
return key
def replace_fraction(m): # ② 真分数 1/2
key = next_key()
mapping[key] = f"Fraction({int(m.group(1))}, {int(m.group(2))})"
return key
def replace_integer(m): # ③ 剩下的整数,包括 "2 / 3" 里的 2 和 3
key = next_key()
mapping[key] = f"Fraction({int(m.group(1))}, 1)"
return key
s = re.sub(r"(\d+)'(\d+)/(\d+)", replace_mixed, s)
s = re.sub(r"(\d+)/(\d+)", replace_fraction, s)
s = re.sub(r"(\d+)", replace_integer, s)
for key, value in mapping.items(): # ④ 回填(三步全部扫完后再写)
s = s.replace(key, value)
return eval(s, {"__builtins__": {}}, {"Fraction": Fraction})
三条经验:
- 顺序不能乱:带分数必须先于真分数,否则
2'3/8里的3/8会被提前抢走。 - 占位符不能含数字:我们第一版用
__MIXED_0__,结果第 ③ 步的整数正则把0又识别成一个操作数,表达式彻底乱掉。改成字母 A–J 映射才解决。 - 回填必须延后:三步扫描期间字符串里还留着裸
Fraction(...),如果边扫边回填,后面的正则会命中回填内容。
修复后一万道题闭环批改 Correct: 10000 / Wrong: 0,并补了回归用例 test_eval_integer_division_is_exact 把它钉死。
六、测试运行
1. 单元测试(test_main.py,19 个用例)
python -m unittest test_main -v
【待替换截图:新版的 "Ran 19 tests ... OK"】
| 编号 | 用例 | 测试内容 | 目的 |
|---|---|---|---|
| 1 | test_format_integer |
Fraction(5,1) → "5" |
整数输出格式 |
| 2 | test_format_proper_fraction |
Fraction(3,5) → "3/5" |
真分数输出格式 |
| 3 | test_format_mixed_fraction |
Fraction(19,8) → "2'3/8" |
带分数输出格式 |
| 4 | test_parse_proper_fraction |
"3/5" → Fraction(3,5) |
输入解析 |
| 5 | test_parse_mixed_fraction |
"2'3/8" → Fraction(19,8) |
带分数解析 |
| 6 | test_parse_integer |
"5" → Fraction(5,1) |
整数解析 |
| 7 | test_add |
1/2 + 1/3 = 5/6 |
加法求值 |
| 8 | test_sub |
5/6 - 1/3 = 1/2 |
减法求值 |
| 9 | test_mul |
1/2 × 2/3 = 1/3 |
乘法求值 |
| 10 | test_div |
1/3 ÷ 2/3 = 1/2 |
除法求值 |
| 11 | test_operator_count_max_3 |
30 轮 × {1,2,3} 运算符 | 运算符个数 ≤ 3 |
| 12 | test_no_negative_results |
100 轮递归全树检查 | 减法子式 e1 >= e2 |
| 13 | test_division_always_proper |
100 轮递归全树检查 | 除法结果恒为真分数 |
| 14 | test_no_duplicate_exercises |
生成 20 道,canonical 集合去重 | 题目之间不重复 |
| 15 | test_commutative_dedup |
3+2 与 2+3 canonical 相同 |
交换律判定 |
| 16 | test_eval_simple_fraction |
"1/2 + 1/3" → 5/6 |
批改模块求解 |
| 17 | test_eval_mixed_fraction |
"2'3/8 + 1/8" → 5/2 |
带分数参与求值 |
| 18 | test_eval_with_multiplication |
"1/2 × 2/3" → 1/3 |
乘号识别 |
| 19 | test_eval_integer_division_is_exact |
"2 ÷ 3" → 2/3 等 3 组 |
整数÷整数回归 |
2. 黑盒 / 命令行用例(14 个)
直接跑 main.py,覆盖参数组合、边界与异常。下表为实际执行结果,非预期值:
| # | 输入 / 场景 | 预期结果 | 实际结果 |
|---|---|---|---|
| 1 | -n 10 -r 10 |
生成 10 题 + 10 答案 | 退出码 0;题目 10 行、答案 10 行;首题 1. 8 × 4 - 1/4 = |
| 2 | -n 1 -r 10 |
正常生成 1 行 | 退出码 0;题目 1 行 |
| 3 | -n 5 -r 5 |
所有数值 < 5 | 退出码 0;样例 2/3 - 1/2 ÷ (1/2 + 2) =、1/2 ÷ 2 = |
| 4 | -n 10(缺 -r) |
报错 + 打印帮助 | 退出码 1;stderr 错误:必须指定 -r 参数(数值范围);stdout 含 usage |
| 5 | -r 10(只给 -r) |
打印帮助,不生成文件 | 退出码 0;Exercises.txt 未生成 |
| 6 | 不带任何参数 | 打印帮助 | 退出码 0;stdout 首行 usage: main.py [-h] [-n N] [-r R] [-e E] [-a A] |
| 7 | 边界 -n 5 -r 1 |
不崩溃 | 退出码 0;样例 1. 1 + 1 - 1 =、1 × 1 + 1 = |
| 8 | 边界 -n 5 -r 2 |
不崩溃 | 退出码 0;样例 1. 1 - 1 =、1 × 1 × 1 = |
| 9 | -n 10000 -r 10 |
能生成 1 万道不重复题目 | 退出码 0;题目 10000 行;端到端(含进程启动与文件 IO)约 0.3~0.6 s,其中纯生成约 0.19 s |
| 10 | 批改:答案全对 | Correct: 10 |
Correct: 10 (1, 2, 3, 4, 5, 6, 7, 8, 9, 10) / Wrong: 0 () |
| 11 | 批改:第 2/4/6 题故意答错 | Correct: 7 / Wrong: 3 (2, 4, 6) |
Correct: 7 (1, 3, 5, 7, 8, 9, 10) / Wrong: 3 (2, 4, 6) |
| 12 | 批改:题目/答案都不带题号前缀 | 仍能正确批改 | Correct: 1 (1) / Wrong: 0 () |
| 13 | 批改:答案无法解析(写 abc) |
判为错误而非崩溃 | 退出码 0;Correct: 0 () / Wrong: 1 (1) |
| 14 | 批改:带分数答案 | 1'1/8 + 1'1/4 答 2'3/8 判对 |
Correct: 1 (1) / Wrong: 0 () |
用完一遍之后可以再跑一次回批:python main.py -e Exercises.txt -a Answers.txt,应当得到 Wrong: 0 ()。
3. 约束采样校验(2 万次)
另有一个 test_constraints.py,对生成的表达式树做全树递归校验(不是只看最终结果),断言六条不变量,各跑一万次:
| 编号 | 不变量 | 违规数 |
|---|---|---|
| C1 | 运算符个数 ≤ 3 | 0 |
| C2 | 所有减法子式 e1 >= e2 |
0 |
| C3 | 所有除法子式 e1 < e2 且 e2 ≠ 0 |
0 |
| C4 | 整题的值 ≥ 0 | 0 |
| C5 | 一万道两两不重复 | 0 |
| C6 | 所有出现的数值 < r |
0 |
4. 为什么能确定程序是正确的?
我们不用"跑了几道题没什么问题"来论证,用的是下面四层:
第一层|构造性保证(最强的一层)
四条约束不是"生成后检查出来的",而是生成过程的数学不变量:- 通过交换保证左 ≥ 右,÷ 通过交换 + 三级兜底保证左 < 右,op_count 直接作为递归参数递减到 0。只要 generate_tree 的每条分支成立,输出就必然合法——不存在"运气好才合法"的情况。单元测试的作用是把每条分支钉死。
第二层|独立复算
生成器返回的 value 我们不信。测试用 calculator.evaluate_tree 重新独立求值,批改模块再用 evaluate_expression_string 从字符串第三次求值。三条路径(树上fold、字符串 eval、文件往返)对同一道题给出同一个 Fraction,才认为正确。
第三层|闭环一致性(最关键的一层)
生成一万道题 → 写文件 → 用 -e/-a 回批 → Correct: 10000 / Wrong: 0。这是最强的端到端证据:它同时证明了「生成合规」「格式往返无损」「答案自洽」「批改正确」四件事。也正是这一层揪出了 §5.5 那个 3.78% 的隐性 bug。
第四层|大规模随机 + 已知答案交叉验证
2 万次采样的约束校验覆盖随机分支;另挑若干含括号、带分数、连除的题目手工计算核对,与程序输出逐字符一致。
5. 功能演示
(1)生成 10 道题(python main.py -n 10 -r 10)



(2)批改功能(python main.py -e Exercises.txt -a Answers.txt)
全对的情形:

手动改错第 2、4、6 题的答案后,Grade.txt 输出:
Correct: 7 (1, 3, 5, 7, 8, 9, 10)
Wrong: 3 (2, 4, 6)

(3)缺 -r 参数的错误提示(python main.py -n 10)

(4)一万道规模测试
(python main.py -n 10000 -r 10
python main.py -e Exercises.txt -a Answers.txt)

七、PSP 表格(实际时间)
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|---|
| Planning | 计划 | 30 | 25 |
| · Estimate | · 估计这个任务需要多少时间 | 30 | 25 |
| Development | 开发 | 300 | 350 |
| · Analysis | · 需求分析(包括学习新技术) | 40 | 50 |
| · Design Spec | · 生成设计文档 | 20 | 25 |
| · Design Review | · 设计复审(和同事审核设计文档) | 20 | 20 |
| · Coding Standard | · 代码规范(为目前的开发制定合适的规范) | 10 | 15 |
| · Design | · 具体设计 | 30 | 35 |
| · Coding | · 具体编码 | 100 | 110 |
| · Code Review | · 代码复审 | 20 | 25 |
| · Test | · 测试(自我测试,修改代码,提交修改) | 60 | 70 |
| Reporting | 报告 | 60 | 70 |
| · Test Report | · 测试报告 | 20 | 25 |
| · Size Measurement | · 计算工作量 | 10 | 15 |
| · Postmortem & Process Improvement Plan | · 事后总结,并提出过程改进计划 | 30 | 30 |
| 合计 | 390 | 445 |
八、项目小结
1. 结对感受
本次结对项目由我们两人共同完成。整个过程下来,有顺利也有踩坑,幸运的是成功解决并成为了实用的经验。
整体来看,这次合作是成功的。通过按模块划分职责、提前对齐接口、以及用测试作为共同标准,让协作效率明显提高,也避免了大部分返工。
从中我们学到了:一是按时长轮换的分工方式行不通,思路容易断;二是重要的判断最好当场留下记录,不然后面只能靠回忆;三是异常或失败最好在出口处就做好分类,否则后期排查会花很多不必要的精力。
下次如果再结对,我们会更早地确定接口和验收标准,在开始前提早做好完善的计划。
2. 双方互评
李采云对谭添睿:她负责题目生成、表达式树构建、运算求值以及去重算法的核心逻辑,在处理边界情况时习惯按层次逐级兜底,把边界当作设计的一部分来对待,这是项目后期能推得比较稳的主要原因。当然了,如果能在关键判断的当下就留下可复查的痕迹,而不只是临时口头确认,后续复盘时的依据会更扎实。
谭添睿对李采云:她负责分数格式化与解析、批改答案、结果统计,以及后期对整体流程的闭环验证,在代码写完初期先主动做了一轮完整的验证,用实际数据论证结果是否正确,这种处理方式对项目质量帮助很大。如果以后能把不同类型的失败在记录上再做一点区分,排查起来会更省力。
3. 经验教训
(1)索引写错导致大面积测试失败
一开始把表达式树的标签 tree[0] 误写成 tree[1],单元测试几乎全红。两个人一起排查很快就定位了。教训是:元组节点没有字段名,可读性很差,必须在一开始就在 docstring 里固化约定;后续每个函数入口都显式解包 op, left, right = tree[0], tree[1], tree[2],把这个坑一次性堵掉。
(2)不用浮点数,是最划算的一个决定
从设计之初就定了"全程 Fraction,一个数都不用 float"。这个决定后来有两笔收益:一是彻底没有精度误差,二是发现 bug 时可以直接排除整个浮点分支。代价只是 9% 的性能,而我们的性能需求是"一万道 0.2 秒内"。
(3)隐性 bug 只能靠大规模闭环发现
如前所述,抽样测试的心智模型是"如果它有 bug,我随便挑几道应该能发现"。但整数÷整数这种 bug 的命中率是 3.78%,抽 10 道题有一半以上概率完全测不到。从"抽样"切换到"全量闭环",我们对程序正确性的信心才真正建立起来。
(4)except Exception 掩盖问题的教训
调试初期有个 Version 在 checker 里把所有异常静默判错,一度让我们以为是生成器的 bug,绕了不少弯路。
4. 改进方向
(1)-e/-a 的健壮性
目前对题号编号、多余空格做了兼容(strip_number_prefix),但如果题目文件比答案文件长,多出来的题目会被静默忽略。应该显式报一行警告。
(2)-r 1 的语义
-r 1 时所有数值强制退化为 1,题目空间极小且会出现形如 1/2 ÷ 1 的退化表达式(严格说超出范围)。这是 -r 1 下题目采样本质困难导致的,建议要么在参数校验时拒绝 r < 2,要么明确定义为"退化为最小可用范围"。
(3)难度控制
可以增加按 -r 自动调整运算符数量、或增加一个 --level 参数控制是否出现带分数、连除等复杂形态。
(4)更强的等价判定
现在的去重只处理了 +、× 的交换律。若要完全贴合"有限次交换"的定义,还可以考虑结合律带来的等价(1+2+3 与 3+(2+1) 我们已经能识别,这条是踩遍了的);更进一步的话可以处理 a-b+c 与 a+c-b 这类移项等价,但目前题目未要求,做了反而可能把老师认为合法的题目误删。
(5)跨平台与 GUI
目前只在 Windows + Python 3.13 下验证。下一步可以加一份 GitHub Actions 的 CI,在 Linux/macOS 上自动跑测试;有余力再做图形界面版本,方便小学生直接使用。
浙公网安备 33010602011771号