结对项目(林灿、刘旭波)
| 这个作业属于哪个课程 | 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 表,定位耗时最大的函数为 eval 和 build_tree。
改进思路:
eval结果缓存:约束检查会反复对同一棵子树求值,可以在Node里加self._cached字段,首次计算后缓存,后续直接读取。- 约束前置:在
build_tree里按运算符类型直接生成满足约束的操作数(如除法先生成"小 ÷ 大"),避免"先建后验 + 失败重试"。 is_leaf缓存:在to_string里把is_leaf()结果缓存一次,避免 56.9 万次重复调用。
最终是否优化:未做代码级优化。原因:10000 题仅 0.802 秒完成,即使按上述思路优化,收益也在 0.2 秒以内,属于用户无感知范围,投入产出比低。
2.3 性能分析图


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 |
num、den、__add__、__sub__、__mul__、__truediv__、to_str、from_str |
Node |
generator.py |
op、left、right、val、eval、to_string、canonical |
3.3 类之间的关系
Node的叶子节点持有Fraction对象(组合关系)Node.eval()通过调用Fraction的运算符实现递归求值Fraction.to_str()提供统一的输出格式给Node.to_string()- 模块间:
generator和checker都依赖fraction,main依赖generator和checker
3.4 类图
3.5 关键函数流程图
(1)build_tree —— 表达式树生成流程
(2)canonical —— 判重键生成流程
(3)eval_expr —— 判卷求值流程
四、代码说明
4.1 分数类 Fraction(fraction.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_str 用 abs(num) < den 判断真分数,用 divmod 拆出带分数的整数部分和小数部分。
4.2 表达式树生成 build_tree(generator.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 判重键 canonical(generator.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 + 45和45 + 23的 canonical 相同 (判重)1 + 2 + 3(((1+2)+3))和3 + (2+1)((3+(1+2)))的 canonical 相同1 + 2 + 3和3 + 2 + 1(((3+2)+1))的 canonical 不同 (不判重)
4.4 判卷求值 eval_expr(checker.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)

对应的答案(Answers.txt)

判卷结果(Grade.txt)

10000 题判卷(Correct: 10000)

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 经验教训
- 先写测试再写实现:本项目的约束(非负、真分数、判重)都适合单元测试驱动,一开始写 test 会更省时。
- 性能问题要靠数据说话:直觉以为
random是瓶颈,cProfile 显示真正瓶颈在eval。 .gitignore一定要在git add之前写好:否则运行产物会被一起提交。
7.3 结对感受
林灿:本次结对项目我们采用了结对的方式,用同一台电脑完成。我主要担任"领航员(Navigator)"角色,负责实时 review、查文档、提建议;刘旭波担任"驾驶员(Driver)"角色,负责敲代码。我们轮流切换角色——刘旭波写 generator.py 的生成逻辑时我在旁看,我写 fraction.py 的分数类时刘旭波在旁看。最大的挑战是判重逻辑:需求里特别举了 1+2+3 与 3+2+1 不能判重的例子。刘旭波一开始想用"排序所有操作数"的简单方法,我立刻指出这会误判,因为 - 和 ÷ 不满足交换律。我们讨论后改成"递归规范化 + 交换律局部排序",一次就写对了。
结对编程最大的收获是:一个人写代码容易陷入思维定式,两个人在同一屏幕上实时 review,能发现很多自己忽略的边界情况。比如 -r 1 时 randint(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 里用单引号而不是撇号,一开始他写错了,是我对照需求原文发现的。另外他在写代码时习惯边写边口头解释思路,让我这个"领航员"很容易跟上进度。建议下次在写代码前,用笔把模块依赖图画出来贴在屏幕旁边,避免我们后期才发现 checker 和 generator 之间有隐式耦合。
刘旭波对林灿的闪光点及建议:林灿的"先写反例测试"习惯非常值得我学习。判重逻辑这么复杂的部分,他是先拿需求里给的 3+(2+1) 和 1+2+3 反例写测试,再让我按测试写实现,一次就通过。这种"测试驱动"的思维我以前只在书上看过,这次亲眼见到效果,很受启发。另外他查文档的速度很快,snakeviz 可视化就是他提议的。林灿写 review 意见时偶尔一句话说一半,我作为 Driver 有时反应不过来。建议下次提建议时直接说出"改成什么",而不是只说"这里有问题",这样沟通更高效。
7.5 项目整体总结
本次结对项目我们采用结对的模式,共同完成了全部 4 个模块(fraction.py、generator.py、checker.py、main.py)。我们一起读需求,讨论模块划分,画类图,估计 PSP 时间,一起写 fraction.py 和 generator.py 的核心逻辑(轮流执笔),一起写 checker.py 和 main.py,第一次跑通全流程,一起做测试(10 个用例 + 10000 题压测)、性能分析(cProfile + snakeviz)、上传 GitHub,一起写博客、复盘。遇到的主要困难是需求理解上的歧义("过程不能产生负数"和"结果应是真分数"),我们通过反复读需求原文、列举边界例子、讨论反例来解决。最终成品功能完整、性能优异,达成了预期目标。本次项目让我们体会到结对编程的价值在于:一个人写代码时会陷入"我认为这样对"的思维定式,另一个人会从不同角度审视,从而发现遗漏。本次项目中,至少 3 个 Bug(-r 1 时 randint 越界、带分数格式化用错引号、.gitignore 在 git add 之后才写)都是结对 review 时发现的。
项目总结:功能完整、性能优异,达成了预期目标。通过结对编程,我们不仅完成了作业,还学到了"测试驱动"、"性能靠数据说话"、"接口先定再实现"等工程方法。

浙公网安备 33010602011771号