结队项目
结对项目:小学四则运算题目生成器
| 这个作业属于哪个课程 | https://edu.cnblogs.com/campus/gdgy/Class78-Grade2024-CS |
|---|---|
| 这个作业的要求在哪里 | https://edu.cnblogs.com/campus/gdgy/Class78-Grade2024-CS/homework/15703 |
| 这次作业的目标是什么 | 与同伴一起完成结伴项目 |
题目
题目:实现一个自动生成小学四则运算题目的命令行程序,支持
-n控制题量、-r控制数值范围,
题目与答案分别写入Exercises.txt与Answers.txt,并支持-e/-a判分输出Grade.txt。
一、小组信息
| 姓名 | 学号 |
|---|---|
| 张帅 | 3124004451 |
| 王俊霖 | 3124004441 |
GitHub 项目地址 : https://github.com/zs666700/secondhomework
二、PSP 表格(开始实现之前:预估)
在动手写代码之前,我们先通读了需求,把整个任务拆成阶段并给出了时间估计。
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) |
|---|---|---|
| Planning | 计划 | |
| · Estimate | · 估计这个任务需要多少时间 | 10 |
| Development | 开发 | |
| · Analysis | · 需求分析(包括学习新技术) | 60 |
| · Design Spec | · 生成设计文档 | 50 |
| · Design Review | · 设计复审 | 20 |
| · Coding Standard | · 代码规范(为目前的开发制定合适的规范) | 10 |
| · Design | · 具体设计 | 60 |
| · Coding | · 具体编码 | 120 |
| · Code Review | · 代码复审 | 30 |
| · Test | · 测试(自我测试,修改代码,提交修改) | 60 |
| Reporting | 报告 | |
| · Test Report | · 测试报告 | 20 |
| · Size Measurement | · 计算工作量 | 10 |
| · Postmortem & Process Improvement Plan | · 事后总结,并提出过程改进计划 | 10 |
| 合计 | 460 |
三、效能分析
3.1 在改进性能上花了多少时间
大约 40 分钟,分散在两个阶段:
- 设计阶段(约 25 分钟):这部分时间虽然记在"具体设计"里,但实质上是在做性能决策——决定用"候选合并"而不是"先生成后校验"的生成算法。
- 测试阶段(约 15 分钟):用
cProfile做了一次性能剖析,用time.perf_counter()反复测端到端耗时,并调整了一次MAX_STALL参数。
3.2 改进思路
思路一:把"失败重试"改成"只在合法候选里选",这是最大的一笔性能收益。
最直觉的做法是随机造一个表达式再检查合法性,不合法就扔掉重来。但每道题最多 3 个运算符,要同时满足"中间结果非负""除法商是真分数""结果小于 r",随机命中的概率很低,尤其 -r 小的时候可能试几十次才中一个。更糟的是它把失败概率藏在了循环里,题目越难生成、重试越多,耗时就越不可控。
我们改成自底向上合并:每合并两个节点之前,先把所有满足约束的运算列出来(merge_candidates),然后只在其中随机挑一个。这样每一步的中间结果天然合法,不存在"造完再扔"。实测生成 10000 道题只要 0.53 秒。
思路二:用 Fraction 而不是 float,这是一个"故意选慢"的决策。
float 的加减乘除比 Fraction 快得多,我们完全可以先用 float 算,最后再想办法转成分数。但需求给的例子 1/6 + 1/8 = 7/24 暴露了问题:1/6 + 1/8 的浮点结果是 0.29166666666666663,而 7/24 是 0.2916666666666667,两者不相等。要把它还原成分数就得靠猜,Fraction(0.29166666666666663) 会给出分母几万亿的怪物,最后 Fraction.limit_denominator() 之类的补救措施又会带来新的不确定性。
所以我们选了 Fraction:精确性优先于速度。实测证明这个代价完全可以接受——一万道题 0.53 秒,离 60 秒的上限差了两个数量级。
思路三:MAX_STALL 那次调整,我们主动选了"更慢但更可信"。
MAX_STALL 是"连续失败多少次就判定题目空间耗尽"的阈值。我们一开始定成 50,000,后来发现这个值会让程序过早宣告耗尽。实测数据:
| MAX_STALL | -r 3 能生成的题目数 |
耗时 |
|---|---|---|
| 50,000 | 7510 道 | 20.1 秒 |
| 200,000 | 7512 道 | 27.8 秒 |
多花 7.7 秒,换来不丢那 2 道题。我们选了大值——因为"耗尽"这个信号的价值就在于可信,如果它会误报,那这个信号本身就没有意义了。这是一个用性能换正确性的决策。
3.3 性能分析图

上半部分是生成 10000 道题时各函数的自身耗时(tottime,即不含子函数调用的时间),下半部分是端到端墙钟耗时对比。
3.4 消耗最大的函数
生成侧排名前三:
| 函数 | 自身耗时 | 调用次数 | 说明 |
|---|---|---|---|
fractions.py:983(_richcmp) |
0.167s | 263,881 | Fraction 的比较运算 |
generator.py:65(merge_candidates) |
0.138s | 26,736 | 列出所有合法合并方案 |
generator.py:91(build) |
0.136s | 13,354 | 构造单道题 |
判分侧排名第一的是 fraction_util.py:36(parse_fraction),43,054 次调用。
最让我们意外的是头号热点是 Fraction 的比较。 26 万次比较来自两处:
- 每步合并时判断约束:
lv >= rv、0 < lv < rv、lv * rv < r,全是分数比较; - 去重键
canonical_key对+/×的两个子键排序时,元组一路递归比较到里面的Fraction。
Fraction.__lt__ 内部要做交叉相乘(a/b < c/d ⇔ a*d < c*b),比整数比较大很多,所以调用次数一上来就成了热点。
这告诉我们一个反直觉的事实:在"大整数运算"型的程序里,瓶颈往往不在算术本身,而在比较和构造。 Fraction 构造(_from_coprime_ints)和 Fraction.forward(分数加法)也都进了前十。
四、设计实现过程
4.1 代码如何组织
按职责分层,一共 6 个模块,每层只做一件事:
myapp.py 命令行入口
four_ops/
fraction_util.py 分数文本 <-> Fraction(最底层,不依赖任何其他模块)
expression.py 表达式树:求值、合法性、去重键、渲染
parser.py 表达式文本 -> 表达式树
generator.py 题目生成与去重
grader.py 判分
cli.py 参数校验、文件读写、模式分派(唯一做 I/O 的模块)
tests/ 6 个测试文件
有一条贯穿始终的纪律:只有 cli 做 I/O,其余模块全是纯函数。
这么分的好处是每个模块都能独立测试——generator 不需要真的写文件才能测,grader 也不需要 mock 任何东西,直接传两个 Path 进去就行。后来加 UTF-8 BOM 的修复时,我们只改了 grader 里的一行,cli 完全没动。
4.2 有几个类、几个函数
类(5 个数据结构 + 2 个异常)
| 类 | 模块 | 职责 |
|---|---|---|
Num |
expression | 表达式树的叶子节点,持有 Fraction |
BinOp |
expression | 表达式树的内部节点:op + 左右子树 |
Problem |
generator | 一道题:表达式树、题面文本、答案 |
GenerationResult |
generator | 一次生成的结果:题目列表 + 请求数 + 是否耗尽 |
GradeResult |
grader | 判分结果:对题号、错题号、总数、警告列表 |
ExpressionSyntaxError |
parser | 表达式文法错误(继承 ValueError) |
UsageError |
cli | 命令行参数不合法 |
其中 Num / BinOp 是 frozen dataclass——不可变,可以安全地当字典键,也能直接 == 比较,测试写起来很舒服。
函数(按模块)
| 模块 | 关键函数 |
|---|---|
fraction_util |
format_fraction / parse_fraction |
expression |
evaluate / operator_count / legal_operand_values / is_legal / canonical_key / render |
parser |
tokenize / parse_expression |
generator |
random_operand / merge_candidates / build / generate |
grader |
grade / format_grade / format_grade_line |
cli |
build_parser / validate / main / _run_generate / _run_grade |
4.3 它们之间是什么关系
依赖是单向的,没有任何循环依赖:
数据流也很直白:
生成模式: cli → generator → [Problem, ...] → cli 写 Exercises.txt / Answers.txt
判分模式: cli → grader → parser → expression → [GradeResult] → cli 写 Grade.txt
为什么 grader 要绕一圈经过 parser? 因为判分不能直接拿 Answers.txt 说事——那样只是"比对两个文本",题目文件被改过就失效了。正确的做法是从题目文本重新解析出表达式、重新求值得到正确答案,再和作答比较。这样即使题目文件里是我们生成器永远不会产出的式子(比如 6 ÷ 2、3 - 5),也能正确判分。
4.4 关键函数流程图
(1)题目生成主流程 —— 整个程序最核心的部分
(2)判分流程
五、代码说明
5.1 去重规范键:整个项目技术含量最高的一段
需求里这句话是核心:
任何两道题目不能通过有限次交换 + 和 × 左右的算术表达式变换为同一道题目。
关键词是"交换"——不允许重新结合。这直接否定了我们最初的方案。
我们一开始想的是:加法有交换律和结合律,那把加法链展平成多重集再排序不就行了?1+2+3 展平成 {1,2,3},3+2+1 也展平成 {1,2,3},判为重复。但需求明确说它们不重复,因为:
1+2+3=(1+2)+3→ 交换外层 →3+(1+2)→ 交换内层 →3+(2+1)✓ 能和3+(2+1)对上3+2+1=(3+2)+1→ 交换后只能得到1+(3+2)、1+(2+3)等,换不成(1+2)+3✗
展平排序等于偷偷用了结合律,这是错的。
正确的做法是递归地构造规范键,只对 + 和 × 排序它们的两个子键:
def canonical_key(expr):
"""去重用的规范键。
需求只允许交换 + 和 × 的左右操作数,不允许重新结合,所以不能把加法链
展平排序——那会把 1+2+3 和 3+2+1 误判成同一道题。这里的做法是:+ 和 × 的
两个子键排序,- 和 ÷ 保持左右有序。
"""
if isinstance(expr, Num):
return ("n", expr.value)
left = canonical_key(expr.left)
right = canonical_key(expr.right)
if expr.op in (PLUS, TIMES) and right < left:
left, right = right, left
return (expr.op, left, right)
对照需求给的四个例子验算:
| 题目 A | 题目 B | 规范键 | 结论 |
|---|---|---|---|
23 + 45 |
45 + 23 |
相同 | 重复 ✓ |
6 × 8 |
8 × 6 |
相同 | 重复 ✓ |
1+2+3 |
3+(2+1) |
相同 | 重复 ✓ |
1+2+3 |
3+2+1 |
不同 | 不重复 ✓ |
这个键还有个附赠好处:它是嵌套元组,可以直接哈希,扔进 set 就是 O(1) 判重,一万道题的判重开销基本可以忽略。
5.2 生成器的候选合并:把约束检查前移到"选择"这一步
def merge_candidates(left, right, r):
"""列出把 left、right 合并成一次运算的所有合法方案。
返回 [(op, 左, 右, 结果值), ...]。减法和除法会同时考虑两个方向,
但用 >= / > 的严格性保证 a == b 时不会产生重复候选。
"""
left_value = evaluate(left)
right_value = evaluate(right)
candidates = []
if left_value + right_value < r:
candidates.append((PLUS, left, right, left_value + right_value))
if left_value >= right_value:
candidates.append((MINUS, left, right, left_value - right_value))
if right_value > left_value:
candidates.append((MINUS, right, left, right_value - left_value))
if 0 < left_value < right_value:
candidates.append((DIVIDE, left, right, left_value / right_value))
if 0 < right_value < left_value:
candidates.append((DIVIDE, right, left, right_value / left_value))
if left_value * right_value < r:
candidates.append((TIMES, left, right, left_value * right_value))
return candidates
三个要点:
- 约束在这里一次性满足。
lv + rv < r保证加法不越界,lv >= rv保证减法不出负数,0 < lv < rv保证除法商是真分数。上层拿到候选时不需要再做任何检查。 - 减法和除法要试两个方向。
a - b和b - a只有一个是合法的,所以两个方向都得列出来让上层随机挑。 - 严格性必须不对称:减法是
lv >= rv配rv > lv,除法是0 < lv < rv配0 < rv < lv。如果写成lv >= rv配rv >= lv,那两个数相等时就会塞进去两个一模一样的减法候选,等于给这个题目偷偷加了权重。这种 bug 不会报错,只会让某些题目出现得莫名其妙地多,极难排查。
5.3 最小括号渲染:不对称的括号规则
def render(expr):
"""渲染成最小括号的表达式文本。"""
if isinstance(expr, Num):
return format_fraction(expr.value)
precedence = PRECEDENCE[expr.op]
left = render(expr.left)
if isinstance(expr.left, BinOp) and PRECEDENCE[expr.left.op] < precedence:
left = f"({left})"
# 运算符左结合,右子节点同级时必须加括号才能保持语义
right = render(expr.right)
if isinstance(expr.right, BinOp) and PRECEDENCE[expr.right.op] <= precedence:
right = f"({right})"
return f"{left} {DISPLAY_OP[expr.op]} {right}"
注意规则是不对称的:
- 左子节点:优先级严格小于父节点时才加括号
- 右子节点:优先级小于或等于父节点时就要加括号(
<=而不是<)
原因是我们所有的运算符都是左结合的。a - (b - c) 如果不加括号写成 a - b - c,解析回来就变成了 (a-b)-c,题意完全变了。而 (a-b)-c 写成 a - b - c 则没有任何问题。
这条规则极其容易被写成对称的版本(左右都用 <),而且在大多数题目上看不出问题——只有像 1 - (2 - 3)、1 ÷ (2 × 3) 这种"右子节点与父节点同级"的表达式才会暴露。所以我们在测试里专门放了这两个例子。
5.4 用词法一次性消除 / 的歧义
/ 这个符号有两个身份:既可能是"分数的一部分"(1/2),也可能是"除号"。如果不处理,1/2 + 3/4 到底该怎么解析就说不清了。
我们的解法是在词法层彻底消掉这个歧义——数字记号的正则一次吃掉整个分数,/ 永远进不了运算符表:
# 数字记号:整数、带分数(三种引号)、分数
_NUMBER_RE = re.compile(r"\d+(?:['’`]\d+/\d+|/\d+)?")
# 文本运算符 -> 内部运算符
_OPERATOR_CHARS = {
"+": "+",
"-": "-", "−": "-", "–": "-", # ASCII 减号 / U+2212 / U+2013
"*": "*", "×": "*", # ASCII 星号 / U+00D7
"÷": "/", # U+00F7
}
所以 1/2 被当成一个数字,而除号只写作 ÷。解析器里就完全没有歧义了。
顺带说明:内部统一用 ASCII 的 + - * /,只在渲染时才映射成 + − × ÷。这样模块之间传的是稳定的符号,不会因为显示格式变化而影响逻辑。
减号除了 ASCII 的 -,还接受排版用的 −(U+2212)和 –(U+2013);乘号除了 × 也接受 *。这些字符在 Word 和记事本里太容易混进来了,输入侧宽容一点能省掉很多莫名其妙的报错。
5.5 一万道题的兜底:绝不能死循环
-r 1 的时候,可用的操作数只有 0 一个值。这时候最多能造出多少道不重复的题?
一道题的差异只可能来自树形和运算符。运算符个数为 k 时有 4^k 种运算符组合,而二叉树的形状在 k = 1/2/3 时分别有 1/2/5 种,所以题目总数的上界是:
1×4 + 2×16 + 5×64 = 356
(规范键还会把其中一些合并,实际更少——实测 -r 1 能生成 84 道。)
所以如果用户输入 -n 10000 -r 1,程序必须能停下来,不能无限重试:
while len(problems) < count and stall < MAX_STALL:
expr = build(r, rng)
if expr is None:
stall += 1
continue
key = canonical_key(expr)
if key in seen:
stall += 1
continue
seen.add(key)
problems.append(Problem(expr=expr, text=f"{render(expr)} =", answer=evaluate(expr)))
stall = 0 # 成功一次就清零
连续失败达到 MAX_STALL 就停止,把已经生成的题目照样写盘,然后明确报告实际数量。
这里有个措辞上的细节值得说:我们把用户提示写成"无法生成 N 道题,实际生成 K 道",而不是"题目已经用光了"。因为 exhausted 的真实语义是"在尝试预算内没凑够",不等于穷尽了整个题目空间——这两件事不一样,说成后者是一种过度承诺。
六、测试运行
我们用标准库 unittest,零第三方依赖,一共 125 个测试用例,分布在 6 个测试文件里。
python -m unittest discover -s tests -t . -v
6.1 十二个代表性测试用例
| # | 用例 | 输入 | 期望 | 为什么需要它 |
|---|---|---|---|---|
| 1 | 加法交换律去重 | 23+45 vs 45+23 |
规范键相同 | 需求原文直接给出的例子 |
| 2 | 乘法交换律去重 | 6×8 vs 8×6 |
规范键相同 | 需求原文直接给出的例子 |
| 3 | 结合律不算重复 | 1+2+3 vs 3+2+1 |
规范键不同 | 最容易写错的边界,展平排序会在这里翻车 |
| 4 | 结合律+交换律算重复 | 1+2+3 vs 3+(2+1) |
规范键相同 | 与 #3 配对,缺一个都测不出正确性 |
| 5 | 减法顺序敏感 | 5-3 vs 3-5 |
规范键不同 | 防止把交换律错用到不满足交换律的运算符上 |
| 6 | 商必须是真分数 | 6÷2、3÷2、0÷3 |
全部不合法 | 需求"结果应是真分数"的严格解读 |
| 7 | 所有中间结果在范围内 | 生成 300 道,r=10 |
每个子表达式值 ∈ [0,10) | 不变量测试,覆盖减法不出负数、乘法不越界 |
| 8 | 渲染→解析结构往返 | 1 ÷ (2 × 3) 等 14 棵树 |
解析回来与原树完全相同 | 唯一能否证伪"右子同级不加括号"的用例 |
| 9 | 除法商恒为真分数 | 生成 1000 道 | 每个除法节点商 ∈ (0,1) | 不变量测试,无关具体种子 |
| 10 | 题目空间耗尽 | -r 1 请求 5000 道 |
exhausted=True,不卡死 |
防止死循环,-r 1 实际只有 84 道 |
| 11 | 答案等价形式 | 1’5/8、1'5/8、13/8 |
三种都判对 | 手写作答的容错 |
| 12 | 带 BOM 的答案文件 | 记事本风格存盘的答案 | 全部判对,零警告 | 真实踩过的坑,见 6.3 |
6.2 为什么我们能确定程序是正确的
我们不敢说"绝对正确",但下面这五层保障让我们对核心逻辑相当有信心:
第一层:需求原文给的例子,逐条断言。
需求里明明白白写了四个去重判定例子(23+45 / 45+23 / 6×8 / 8×6 / 3+(2+1) / 1+2+3 / 3+2+1)和一个运算例子(1/6 + 1/8 = 7/24)。这些是最权威的验收标准,我们全部写成了独立的测试用例。如果这一层挂了,说明我们从根上理解错了需求。
第二层:不变量测试,而不是只测具体例子。
测具体例子的问题是"你只测了你想到的情况"。所以我们大量使用不变量断言——不是断言某道具体的题,而是断言生成出来的任何一道题都满足所有约束:
def test_all_values_stay_in_range(self):
for problem in generate(10, 300, random.Random(4)).problems:
for sub in iter_subexpressions(problem.expr):
value = evaluate(sub)
self.assertGreaterEqual(value, 0) # 减法不出负数
self.assertLess(value, 10) # 中间结果不越界
随机测试用固定种子(random.Random(4)),所以可复现——挂了就一定能重现,不会变成"偶发失败"。
第三层:往返测试(round-trip)。
render 和 parse_expression 是一对互逆操作,我们断言 parse(render(e)) == e 对一批表达式成立。这比单纯断言"渲染出来的字符串是什么"强得多——它验证的是语义保真,而不只是格式正确。
更关键的是,我们专门挑了能证伪错误实现的用例。比如括号规则如果错写成"左右都用严格小于",那 1 + 2 + 3 这类例子照样通过,只有 1 ÷ (2 × 3) 才会挂。一条永远不会失败的测试等于没写,所以我们后来专门回头补了这类用例。
第四层:自判分闭环。
程序自己能生成也能判分,那就让它判自己:
生成 10000 道 → 判分 → Correct: 10000, Wrong: 0 ()
这验证了"生成"和"判分"两条独立代码路径是互相一致的——生成器认为答案是 A,判分器从题面重新解析求值也得到 A。
第五层:端到端实测。
最终跑真实命令,检查文件真的落在磁盘上、格式真的对、退出码真的符合预期:
| 场景 | 命令 | 期望 | 实测 |
|---|---|---|---|
| 正常生成 | -n 10 -r 10 |
退出码 0,两个文件各 10 行 | ✓ 逐题心算核对全部正确 |
缺 -r |
-n 10 |
退出码 2 + 帮助信息 | ✓ |
| 一万道题 | -n 10000 -r 10 |
60 秒内完成 | ✓ 0.54 秒 |
| 判分自检 | -e ... -a ... |
Correct: 10000 |
✓ |
| 故意答错 | 改错第 2 题 | Wrong: 1 (2) |
✓ |
| 等价形式 | 第 5 题写 4/1 而非 4 |
判对 | ✓ |
| 题目空间不足 | -n 5000 -r 1 |
退出码 1,报告实际数量 | ✓ |
七、PSP 表格(实现完成之后:实际)
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|---|
| Planning | 计划 | ||
| · Estimate | · 估计这个任务需要多少时间 | 10 | 10 |
| Development | 开发 | ||
| · Analysis | · 需求分析(包括学习新技术) | 60 | 75 |
| · Design Spec | · 生成设计文档 | 50 | 55 |
| · Design Review | · 设计复审 | 20 | 15 |
| · Coding Standard | · 代码规范(为目前的开发制定合适的规范) | 10 | 10 |
| · Design | · 具体设计 | 60 | 55 |
| · Coding | · 具体编码 | 120 | 130 |
| · Code Review | · 代码复审 | 30 | 45 |
| · Test | · 测试(自我测试,修改代码,提交修改) | 60 | 65 |
| Reporting | 报告 | ||
| · Test Report | · 测试报告 | 20 | 15 |
| · Size Measurement | · 计算工作量 | 10 | 8 |
| · Postmortem & Process Improvement Plan | · 事后总结,并提出过程改进计划 | 10 | 7 |
| 合计 | 460 | 490 |
偏差分析:
| 阶段 | 偏差 | 原因 |
|---|---|---|
| 需求分析 | +15 | 需求里"重复"的定义我们来回讨论了很久,"除法结果应是真分数"也反复确认了三遍。这部分时间花得值——如果不确认清楚,后面全部要返工。 |
| 具体编码 | +10 | 去重规范键写了两个版本,第一版是错的(展平排序),推倒重来。 |
| 代码复审 | +15 | 实际远超预估。我们做了 7 轮分模块的代码复审,其中 2 轮发现了需要修复的问题,最后一次交付后实跑又抓到一个 BOM 的 bug。低估了复审的价值。 |
| 测试报告 | −5 | README 写起来比想得快。 |
| 事后总结 | −3 | 主要是把过程中记录的东西整理出来,不需要重新回忆。 |
最大的教训:我们低估了"计划外"的时间。 490 分钟里有相当一部分不是花在"写新代码"上,而是花在"发现问题、回到前面的阶段返工"上。尤其代码复审——我们按经验给了 30 分钟,实际花了 45 分钟,而且它抓到的每一个问题,如果留到交付后才被发现,修复成本都要高得多(比如括号规则那个不对称性,如果没有专门的往返测试兜着,很可能就带着 bug 交上去了)。
八、项目小结
8.1 技术上的成败得失
成功的三个决定:
-
先花时间把需求的歧义问清楚,而不是猜。
需求里"除法结果应是真分数"有两种读法(严格:商恒小于 1;宽松:商非负即可)。我们选了严格的读法,因为那是对原文最字面的解读,即使验收时被追问也站得住脚。如果当时猜了个宽松版本,后面整个生成器都要重写。 -
生成算法从一开始就设计成"候选合并"而不是"生成后校验"。
这个决定同时改善了两件事:题目质量(不会出现奇怪的越界组合)和性能(不存在大量失败重试)。如果一开始图省事写成"随机造完再检查",后期要改的可能就是整个模块了。 -
把"能证伪错误实现的测试"当成一个独立目标去补。
我们写完测试后专门反问了一遍:这条测试在代码写错的情况下会不会挂? 靠这个问题找出了括号规则那组用例——如果没有它,一个错误的括号规则(左右都用严格小于)能通过当时所有的测试。
失败/不足的地方:
-
低估了"重复"这条需求的难度。 我们第一版的去重实现是错的(展平排序),写了半天才发现理解错了需求。教训是:需求里那种"看起来是技术细节、实际上是精确定义"的句子,应该先翻译成一组具体的判定例子再动手。需求原文其实已经给了四个例子,我们早该拿它们当规格来对照。
-
性能剖析做的时机偏晚。 我们是在功能全部完成之后才用
cProfile看了第一眼。虽然这次没有发现需要紧急优化的问题,但如果早点做,至少能更早知道"热点是比较而不是算术"这件事,写代码时心里更有数。
8.2 结对感受
分工情况: 本次结对我们采用轮换角色模式,张帅主要负责表达式树、题目生成模块的算法设计与编码实现,重点完成 merge_candidates 候选合并逻辑、canonical_key 去重规范键的编写;王俊霖负责解析器、判分模块开发,同时承担代码复审、性能分析与单元测试编写工作。开发过程中两人随时交换角色,一方编码时另一方作为审查者实时检视代码逻辑,遇到难点共同讨论,最后一起完成文档整理、PSP 耗时记录与项目整体自测。
结对中彼此的闪光点或建议: 在项目推进过程中,两个人的思维互补带来了很大帮助。在理解题目去重规则时,王俊霖一开始差点误用展平排序的方案,张帅及时对照需求样例指出该方案会错误使用结合律,避免我们在错误方向上投入大量开发时间;在性能优化环节,王俊霖提出使用cProfile做性能剖析,让我们定位到 Fraction 比较运算为程序性能热点,而不是盲目优化算术逻辑。同时,在设计代码分层架构时,我们共同约定只让 cli 模块处理 IO,其余模块实现纯函数,极大降低模块耦合度,方便单独编写单元测试。
结对也让我们感受到协作开发存在挑战:两人编码习惯、思考节奏不同,前期对于需求文字的解读容易出现分歧,需要反复对照需求样例沟通确认。这次结对让我们深刻体会到,结对编程不只是两个人分工写代码,实时代码复审、互相校验边界案例非常有价值,很多隐蔽 bug(例如括号不对称渲染规则、MAX_STALL 阈值误报)都是在结对讨论与互审阶段发现。相比单人开发,结对能够有效减少需求理解偏差,拓宽解决问题的思路,锻炼清晰表达技术想法的能力。后续开展项目,我们会更早开展性能分析,在编码前就把需求转化为测试用例,进一步提升开发效率。
张帅对王俊霖的建议:
王俊霖在编写单元测试、性能剖析方面能力很强,能精准设计可以证伪错误实现的测试用例。建议后续在前期需求讨论阶段,可以更早地提出测试用例构思,把边界案例提前梳理出来,方便我们在编码之前就锁定需求细节;另外在文档撰写时,可以适当简化部分性能数据描述,突出核心结论,减少冗余文字。
王俊霖对张帅的建议:
张帅算法理解能力突出,能够快速完成表达式树、候选合并、规范去重键等核心逻辑设计。建议在编写复杂算法代码时,增加更多行内注释,方便快速理解代码逻辑;在编码阶段可以更主动地进行小模块自测,每完成一个函数就简单验证,不要集中到模块全部写完之后再统一测试,便于更早发现逻辑问题。
附录:如何运行
# 生成题目与答案(在当前目录生成 Exercises.txt 与 Answers.txt)
python myapp.py -n 10 -r 10
# 判分(生成 Grade.txt)
python myapp.py -e Exercises.txt -a Answers.txt
# 运行全部测试
python -m unittest discover -s tests -t . -v
- 运行环境:Python 3.10+(开发环境为 3.13.2),无需安装任何第三方依赖
-n生成题目的个数(默认 10)-r数值范围上界(不含),生成模式下必给- 退出码:
0成功 /1题目空间耗尽只生成了部分 /2参数或文件错误

浙公网安备 33010602011771号