结对项目(林灿、刘旭波)

这个作业属于哪个课程 https://edu.cnblogs.com/campus/gdgy/Class78-Grade2024-CS/
这个作业要求在哪里 https://edu.cnblogs.com/campus/gdgy/Class78-Grade2024-CS/homework/15703
这个作业的目标 本项目旨在通过实现一个小学四则运算题目生成器,掌握个人软件工程从需求分析、算法设计、编码实现到测试验证的完整开发流程。在实现分数类运算、表达式树生成、约束验证与判重算法的过程中,熟悉单元测试、代码覆盖率、性能分析和 GitHub 版本管理等工程化工具的使用。最终目的是培养规范化的代码组织能力与工程实践意识,为后续团队协作与大型项目开发打下基础。

姓名 学号:林灿 3124004251、刘旭波 3124004175

GitHub 项目地址https://github.com/Lincan-gdut/Myapp

一、PSP2.1 表格(预估)

本表为开始实现程序之前,两人共同讨论后对各个模块开发耗时的预估。

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

二、效能分析

2.1 改进性能花费的时间

  • 预估:40 分钟
  • 实际花费:约 55 分钟(含 cProfile 分析、snakeviz 可视化、阅读火焰图、判断是否优化)

2.2 改进思路

使用 cProfile 做性能采样:

python -m cProfile -o profile.out main.py -n 10000 -r 100
python -m snakeviz profile.out

分析过程:先跑一次 10000 题生成,得到 profile.out,再用 snakeviz 渲染火焰图。观察火焰图与 profile 表,定位耗时最大的函数为 evalbuild_tree

改进思路

  1. eval 结果缓存:约束检查会反复对同一棵子树求值,可以在 Node 里加 self._cached 字段,首次计算后缓存,后续直接读取。
  2. 约束前置:在 build_tree 里按运算符类型直接生成满足约束的操作数(如除法先生成"小 ÷ 大"),避免"先建后验 + 失败重试"。
  3. is_leaf 缓存:在 to_string 里把 is_leaf() 结果缓存一次,避免 56.9 万次重复调用。

最终是否优化未做代码级优化。原因:10000 题仅 0.802 秒完成,即使按上述思路优化,收益也在 0.2 秒以内,属于用户无感知范围,投入产出比低。

2.3 性能分析图

f03254ac71b935524e2b161463627d7f
db776009f3625923b0f9775cc28a51c3

2.4 最耗时的函数

总耗时 0.802 秒,共 3,134,158 次函数调用。

排名 函数 ncalls tottime 占比
1 generator.py:20(eval) 369,148 0.1352 s 16.8%
2 generator.py:68(build_tree) 70,034 0.0981 s 12.2%
3 fraction.py:6(__init__) 163,389 0.0651 s 8.1%
4 random.py:242(_randbelow) 120,158 0.0510 s 6.4%
5 random.py:291(randrange) 90,141 0.0443 s 5.5%

结论:程序中最消耗 CPU 的函数是 generator.py 第 20 行的 eval() 方法,耗时占整体的 16.8%。


三、设计实现过程

3.1 代码组织方式

项目共 4 个 Python 模块

模块 职责
fraction.py 分数类:四则运算、约分、真分数/带分数格式化
generator.py 表达式树节点 + 随机生成 + 判重
checker.py 判卷:解析文件、求值、比对
main.py 命令行入口、参数解析、文件 IO

3.2 类的设计(共 2 个类)

所在文件 关键成员
Fraction fraction.py numden__add____sub____mul____truediv__to_strfrom_str
Node generator.py opleftrightvalevalto_stringcanonical

3.3 类之间的关系

  • Node 的叶子节点持有 Fraction 对象(组合关系)
  • Node.eval() 通过调用 Fraction 的运算符实现递归求值
  • Fraction.to_str() 提供统一的输出格式给 Node.to_string()
  • 模块间:generatorchecker 都依赖 fractionmain 依赖 generatorchecker

3.4 类图

classDiagram class Fraction { +int num +int den +__add__() +__sub__() +__mul__() +__truediv__() +to_str() +from_str() } class Node { +str op +Node left +Node right +Fraction val +eval() +to_string() +canonical() } Fraction <-- Node

3.5 关键函数流程图

(1)build_tree —— 表达式树生成流程

%%{init: {'theme':'base', 'themeVariables': {'primaryColor':'#ffffff','primaryTextColor':'#000000','primaryBorderColor':'#333333','lineColor':'#333333','fontSize':'15px'}}}%% flowchart LR A([开始]) --> B{max_ops == 0 或 depth > 4?} B -- 是 --> C([返回叶子节点]) B -- 否 --> D[随机分配左右子树运算符数] D --> E[递归生成左右子树] E --> F[随机选运算符] F --> G{满足非负 / 真分数约束?} G -- 否 --> H[交换左右] G -- 是 --> I[构造 Node 并 eval 验证] H --> I I --> J{验证通过?} J -- 是 --> K([返回 Node]) J -- 否 --> L{还有重试次数?} L -- 是 --> D L -- 否 --> C

(2)canonical —— 判重键生成流程

%%{init: {'theme':'base', 'themeVariables': {'primaryColor':'#ffffff','primaryTextColor':'#000000','primaryBorderColor':'#333333','lineColor':'#333333','fontSize':'15px'}}}%% flowchart LR A([开始]) --> B{叶子节点?} B -- 是 --> C([返回值的字符串]) B -- 否 --> D[递归获取左右子串] D --> E{运算符是 + 或 ×?} E -- 是 --> F[左右子串排序] E -- 否 --> G[保持原顺序] F --> H([拼接返回]) G --> H

(3)eval_expr —— 判卷求值流程

%%{init: {'theme':'base', 'themeVariables': {'primaryColor':'#ffffff','primaryTextColor':'#000000','primaryBorderColor':'#333333','lineColor':'#333333','fontSize':'15px'}}}%% flowchart LR A([开始]) --> B[正则匹配所有数字] B --> C[替换为 Fraction.from_str] C --> D[×→* ÷→/] D --> E[Python eval 求值] E --> F([返回 Fraction])

四、代码说明

4.1 分数类 Fractionfraction.py

class Fraction:
    def __init__(self, num, den=1):
        if den == 0:
            raise ValueError("分母不能为0")
        if den < 0:                     # 保证分母为正
            num, den = -num, -den
        g = math.gcd(abs(num), den)     # 构造函数中即约分
        self.num = num // g
        self.den = den // g

    def __add__(self, o):
        return Fraction(self.num * o.den + o.num * self.den,
                        self.den * o.den)

    def __sub__(self, o):
        return Fraction(self.num * o.den - o.num * self.den,
                        self.den * o.den)

    def __mul__(self, o):
        return Fraction(self.num * o.num, self.den * o.den)

    def __truediv__(self, o):
        if o.num == 0:
            raise ZeroDivisionError
        return Fraction(self.num * o.den, self.den * o.num)

    def to_str(self):
        """输出格式:整数 / 真分数 / 带分数"""
        if self.den == 1:
            return str(self.num)
        if abs(self.num) < self.den:
            return f"{self.num}/{self.den}"
        sign = '-' if self.num < 0 else ''
        n = abs(self.num)
        whole, rem = divmod(n, self.den)
        return f"{sign}{whole}'{rem}/{self.den}"

    @staticmethod
    def from_str(s):
        """解析 '3/5'、\"2'3/8\"、'5' 三种格式"""
        if "'" in s:
            whole, frac = s.split("'")
            num, den = frac.split('/')
            return Fraction(int(whole) * int(den) + int(num), int(den))
        if '/' in s:
            num, den = s.split('/')
            return Fraction(int(num), int(den))
        return Fraction(int(s))

思路说明:所有运算都转换为分子分母的整数运算,构造函数中立即用 gcd 约分,保证对象始终处于最简形式。to_strabs(num) < den 判断真分数,用 divmod 拆出带分数的整数部分和小数部分。

4.2 表达式树生成 build_treegenerator.py

def build_tree(max_ops, r, depth=0):
    if max_ops == 0 or depth > 4:       # 递归终止条件
        return Node(val=rand_num(r))

    for _ in range(20):                 # 最多重试 20 次
        left_ops = random.randint(0, max_ops - 1)
        right_ops = max_ops - 1 - left_ops
        left = build_tree(left_ops, r, depth + 1)
        right = build_tree(right_ops, r, depth + 1)
        op = random.choice(OPS)

        try:
            lv, rv = left.eval(), right.eval()
        except ZeroDivisionError:
            continue

        # 约束 1:减法不能产生负数
        if op == '-':
            if lv < rv:
                left, right = right, left

        # 约束 2:除法结果必须是真分数
        elif op == '÷':
            if rv.num == 0:
                continue
            if not (lv < rv):
                left, right = right, left
            if right.eval().num == 0:
                continue
            if not (left.eval() < right.eval()):
                continue

        node = Node(op=op, left=left, right=right)
        try:
            node.eval()                 # 整体合法性验证
            return node
        except ZeroDivisionError:
            continue

    return Node(val=rand_num(r))        # 兜底

思路说明:递归生成左右子树,随机选运算符,然后对减法和除法做约束修正(必要时交换左右)。交换后再次 eval() 验证整棵树合法,不合法则重试,最多 20 次;全失败则返回一个叶子节点兜底。

4.3 判重键 canonicalgenerator.py

def canonical(self):
    """生成判重用的规范化字符串"""
    if self.is_leaf():
        return self.val.to_str()
    l = self.left.canonical()
    r = self.right.canonical()
    if self.op in ('+', '×'):           # 加法和乘法满足交换律
        l, r = sorted([l, r])           # 左右子串排序后拼接
    return f"({l}{self.op}{r})"

思路说明:递归把表达式树规范化为唯一的字符串。关键是只对 +× 排序(这两个满足交换律),-÷ 不排序。这样:

  • 23 + 4545 + 23 的 canonical 相同 (判重)
  • 1 + 2 + 3((1+2)+3))和 3 + (2+1)(3+(1+2)))的 canonical 相同
  • 1 + 2 + 33 + 2 + 1((3+2)+1))的 canonical 不同 (不判重)

4.4 判卷求值 eval_exprchecker.py

def eval_expr(expr):
    """把 '×' '÷' 换成 Python 运算符,把分数替换为 Fraction.from_str 调用"""
    pattern = re.compile(r"\d+'\d+/\d+|\d+/\d+|\d+")

    def repl(m):
        s = m.group(0)
        return f"Fraction.from_str({s!r})"  # 用 !r 自动处理单引号

    expr = pattern.sub(repl, expr)
    expr = expr.replace('×', '*').replace('÷', '/')
    return eval(expr, {"Fraction": Fraction})

思路说明:正则 \d+'\d+/\d+|\d+/\d+|\d+ 依次匹配"带分数 / 真分数 / 整数"三种形式,用 Fraction.from_str(...) 替换;{s!r} 中的 !r 表示用 repr() 结果拼接,能自动为含单引号的带分数选择双引号包裹,避免语法错误。


五、测试运行

5.1 测试用例(共 10 条)

# 测试命令 / 场景 结果
1 python main.py -n 10 -r 10 生成 10 题,无重复,答案正确
2 python main.py -n 100 -r 20 生成 100 题
3 python main.py -n 1 -r 1 生成 1 题(边界值)
4 python main.py -n 10(缺 -r 报错并给出帮助信息
5 python main.py -r 10(缺 -n 显示帮助信息
6 python main.py -n 10 -r 0 报错:-r 必须 >= 1
7 python main.py -e Exercises.txt -a Answers.txt 生成 Grade.txt 全对
8 手动改错 2 题答案后再判卷 Wrong 精确列出错误编号
9 python main.py -n 10000 -r 100 0.802 秒内完成
10 10000 题全量判卷 Correct: 10000 (...)

5.2 关键测试截图

生成 10 题(Exercises.txt)

f043bf69d62a7de0a4ac6d513fdd0310

对应的答案(Answers.txt)

ecc81bfdcab30c6ae45e58963ffa15b5

判卷结果(Grade.txt)

9a3fc703e1a5f76d8adc367a35d3fbec

10000 题判卷(Correct: 10000)

698305b94c328dd8f1940af72dfd11f4

5.3 为什么可以确定程序是正确的

(1) 数学验证:人工核对了 10 道题中每一道的中间过程和最终结果。例如第 1 题 (8+4)-(1×2) = 12-2 = 10;第 2 题 2/3 + ((8+3/8)-7) = 2/3 + 11/8 = 49/24 = 2'1/24。全部吻合。
(2) 约束验证:扫描所有生成的题目,未发现负数的中间结果;所有除法子表达式的右操作数都严格大于左操作数(真分数约束)。
(3) 判重验证:特意生成了 10000 题,用 canonical 键集合验证无重复。同时用需求给出的反例 3+(2+1)1+2+3 做过针对性单元测试,判等正确。
(4) 判卷闭环:用 checker.py 对同一份 Exercises/Answers 重新求值,10000 题全部命中 Correct,说明生成逻辑与求值逻辑互为独立验证(两套代码计算同一结果,如果都错才是双向 bug)。
(5) 边界测试-r 1 只生成 1 题(值只能是 0);缺参数时报错;-r 0 被拒绝;10000 题不崩。


六、PSP2.1 表格(实际)

PSP2.1 Personal Software Process Stages 预估耗时(分钟) 实际耗时(分钟)
Planning 计划 30 25
· Estimate · 估计这个任务需要多少时间 30 25
Development 开发 300 340
· Analysis · 需求分析(包括学习新技术) 40 50
· Design Spec · 生成设计文档 20 15
· Design Review · 设计复审(和同事审核设计文档) 15 10
· Coding Standard · 代码规范(为目前的开发制定合适的规范) 10 10
· Design · 具体设计 30 35
· Coding · 具体编码 150 170
· Code Review · 代码复审 15 20
· Test · 测试(自我测试、修改代码、提交修改) 20 30
Reporting 报告 100 120
· Test Report · 测试报告 30 40
· Size Measurement · 计算工作量 10 10
· Postmortem & Process Improvement Plan · 事后总结,并提出过程改进计划 60 70
合计 430 485

偏差分析:实际比预估多 55 分钟

  • 编码阶段超时 20 分钟:表达式树判重逻辑(交换律处理)调试了几轮
  • 测试阶段超时 10 分钟:10000 题全量测试时发现 1 次重复,定位到是字符串排序在两位数上不稳定
  • 需求分析超时 10 分钟:对"÷ 结果必须是真分数"这条约束的理解花了较多时间

七、项目小结

7.1 成败得失

成功之处

  • 功能实现完整,需求里 15 条硬性要求全部满足
  • 性能远超预期:10000 题仅 0.8 秒
  • 判卷系统独立实现,与生成逻辑互为验证

不足之处

  • 生成的题目偶尔括号嵌套较深(三层),不太像小学题
  • canonical 用字符串排序,在两位数上曾有边界 bug(已修复)
  • Git 第一次提交误把 Answers.txt 等运行时产物提交了,重做了一次

7.2 经验教训

  1. 先写测试再写实现:本项目的约束(非负、真分数、判重)都适合单元测试驱动,一开始写 test 会更省时。
  2. 性能问题要靠数据说话:直觉以为 random 是瓶颈,cProfile 显示真正瓶颈在 eval
  3. .gitignore 一定要在 git add 之前写好:否则运行产物会被一起提交。

7.3 结对感受

林灿:本次结对项目我们采用了结对的方式,用同一台电脑完成。我主要担任"领航员(Navigator)"角色,负责实时 review、查文档、提建议;刘旭波担任"驾驶员(Driver)"角色,负责敲代码。我们轮流切换角色——刘旭波写 generator.py 的生成逻辑时我在旁看,我写 fraction.py 的分数类时刘旭波在旁看。最大的挑战是判重逻辑:需求里特别举了 1+2+33+2+1 不能判重的例子。刘旭波一开始想用"排序所有操作数"的简单方法,我立刻指出这会误判,因为 -÷ 不满足交换律。我们讨论后改成"递归规范化 + 交换律局部排序",一次就写对了。
结对编程最大的收获是:一个人写代码容易陷入思维定式,两个人在同一屏幕上实时 review,能发现很多自己忽略的边界情况。比如 -r 1randint(1, 0) 会报错,就是我在 review 时一眼看出来的。

刘旭波:我在这几天的结对中主要担任"驾驶员",负责敲代码。林灿当"领航员",在我写代码时查文档、看需求、提前想下一步。我们轮流当 Driver,比如写 fraction.py 时我执笔,林灿在旁边提醒"带分数格式化要用 divmod"。起初我们想直接用 Python 自带的 fractions.Fraction,但讨论后发现输出格式(2'3/8 带分数)需要自定义,而且自带类不方便控制约分时机,就决定手写。这个决定是结对讨论的成果——如果一个人写,可能就随手用自带库了。性能分析阶段林灿提出用 snakeviz 可视化火焰图,比只看 profile 表更直观。通过它我们发现 eval 调用次数高达 36.9 万次,理解了"约束验证会在递归中反复执行"这个性能陷阱。
结对让我学到的最大一点是:好代码不是写出来的,是"吵"出来的。 我们为"canonical 该不该排序字符串"这个细节争论了 20 分钟,最后用需求里的反例说服了对方,写出来的代码一次就通过测试。

7.4 对彼此的闪光点与建议

林灿对刘旭波的闪光点及建议:刘旭波对输出格式的细节把控非常严谨。带分数 2'3/8 里用单引号而不是撇号,一开始他写错了,是我对照需求原文发现的。另外他在写代码时习惯边写边口头解释思路,让我这个"领航员"很容易跟上进度。建议下次在写代码前,用笔把模块依赖图画出来贴在屏幕旁边,避免我们后期才发现 checkergenerator 之间有隐式耦合。

刘旭波对林灿的闪光点及建议:林灿的"先写反例测试"习惯非常值得我学习。判重逻辑这么复杂的部分,他是先拿需求里给的 3+(2+1)1+2+3 反例写测试,再让我按测试写实现,一次就通过。这种"测试驱动"的思维我以前只在书上看过,这次亲眼见到效果,很受启发。另外他查文档的速度很快,snakeviz 可视化就是他提议的。林灿写 review 意见时偶尔一句话说一半,我作为 Driver 有时反应不过来。建议下次提建议时直接说出"改成什么",而不是只说"这里有问题",这样沟通更高效。

7.5 项目整体总结

本次结对项目我们采用结对的模式,共同完成了全部 4 个模块(fraction.pygenerator.pychecker.pymain.py)。我们一起读需求,讨论模块划分,画类图,估计 PSP 时间,一起写 fraction.pygenerator.py 的核心逻辑(轮流执笔),一起写 checker.pymain.py,第一次跑通全流程,一起做测试(10 个用例 + 10000 题压测)、性能分析(cProfile + snakeviz)、上传 GitHub,一起写博客、复盘。遇到的主要困难是需求理解上的歧义("过程不能产生负数"和"结果应是真分数"),我们通过反复读需求原文、列举边界例子、讨论反例来解决。最终成品功能完整、性能优异,达成了预期目标。本次项目让我们体会到结对编程的价值在于:一个人写代码时会陷入"我认为这样对"的思维定式,另一个人会从不同角度审视,从而发现遗漏。本次项目中,至少 3 个 Bug(-r 1randint 越界、带分数格式化用错引号、.gitignoregit add 之后才写)都是结对 review 时发现的。


项目总结:功能完整、性能优异,达成了预期目标。通过结对编程,我们不仅完成了作业,还学到了"测试驱动"、"性能靠数据说话"、"接口先定再实现"等工程方法。

posted @ 2026-09-21 00:47  计科7班林灿  阅读(4)  评论(0)    收藏  举报