结队项目

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

这个作业属于哪个课程 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 性能分析图

image

上半部分是生成 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 万次比较来自两处:

  1. 每步合并时判断约束:lv >= rv、0 < lv < rv、lv * rv < r,全是分数比较;
  2. 去重键 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 它们之间是什么关系

依赖是单向的,没有任何循环依赖:

flowchart LR cli[cli<br/>唯一 I/O] gen[generator] grade[grader] parser[parser] expr[expression] frac[fraction_util] cli --> gen cli --> grade cli --> frac gen --> expr grade --> parser grade --> frac parser --> expr parser --> frac expr --> frac

数据流也很直白:

生成模式:  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)题目生成主流程 —— 整个程序最核心的部分

flowchart TD A["开始: generate(r, count)"] --> B["stall = 0, seen = 空集合"] B --> C{"已生成 < count<br/>且 stall < MAX_STALL ?"} C -- 否 --> Z["返回 GenerationResult<br/>(exhausted = 数量不够)"] C -- 是 --> D["build(r):<br/>随机取运算符个数 k ∈ {1,2,3}"] D --> E["撒 k+1 个随机叶子<br/>(自然数 或 真分数)"] E --> F{"已合并 k 次?"} F -- 否 --> H["随机选两个节点 a, b"] H --> I["merge_candidates(a, b, r)<br/>列出所有满足约束的运算"] I --> J{"候选为空?"} J -- 是 --> K["build 返回 None"] J -- 否 --> L["随机选一个候选合并<br/>先删下标大的节点"] L --> F F -- 是 --> G["得到一棵表达式树"] G --> N["canonical_key 计算规范键"] N --> O{"键已在 seen 中?"} O -- 是 --> M["stall += 1"] O -- 否 --> P["加入结果, stall 清零"] K --> M M --> C P --> C

(2)判分流程

flowchart TD A["读题目文件"] --> B["跳过空行<br/>按非空行编号 1..N"] B --> C["剥掉行尾的 ="] C --> D["parse_expression 解析回表达式树"] D --> E["evaluate 求值 → 正确答案"] E --> F["取答案文件对应行"] F --> G["parse_fraction 解析作答"] G --> H{"值相等?"} H -- 是 --> I["计入 Correct"] H -- 否 --> J["计入 Wrong"] D -. 解析失败 .-> J G -. 解析失败 .-> J J --> K["写 Grade.txt"] I --> K

五、代码说明

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

三个要点:

  1. 约束在这里一次性满足。lv + rv < r 保证加法不越界,lv >= rv 保证减法不出负数,0 < lv < rv 保证除法商是真分数。上层拿到候选时不需要再做任何检查。
  2. 减法和除法要试两个方向。a - b 和 b - a 只有一个是合法的,所以两个方向都得列出来让上层随机挑。
  3. 严格性必须不对称:减法是 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. 先花时间把需求的歧义问清楚,而不是猜。
    需求里"除法结果应是真分数"有两种读法(严格:商恒小于 1;宽松:商非负即可)。我们选了严格的读法,因为那是对原文最字面的解读,即使验收时被追问也站得住脚。如果当时猜了个宽松版本,后面整个生成器都要重写。

  2. 生成算法从一开始就设计成"候选合并"而不是"生成后校验"。
    这个决定同时改善了两件事:题目质量(不会出现奇怪的越界组合)和性能(不存在大量失败重试)。如果一开始图省事写成"随机造完再检查",后期要改的可能就是整个模块了。

  3. 把"能证伪错误实现的测试"当成一个独立目标去补。
    我们写完测试后专门反问了一遍:这条测试在代码写错的情况下会不会挂? 靠这个问题找出了括号规则那组用例——如果没有它,一个错误的括号规则(左右都用严格小于)能通过当时所有的测试。

失败/不足的地方:

  1. 低估了"重复"这条需求的难度。 我们第一版的去重实现是错的(展平排序),写了半天才发现理解错了需求。教训是:需求里那种"看起来是技术细节、实际上是精确定义"的句子,应该先翻译成一组具体的判定例子再动手。需求原文其实已经给了四个例子,我们早该拿它们当规格来对照。

  2. 性能剖析做的时机偏晚。 我们是在功能全部完成之后才用 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 参数或文件错误
posted @ 2026-09-20 16:56  zs666888  阅读(14)  评论(0)    收藏  举报