软件工程结对项目
这个作业属于哪个课程 | 首页 - 计科23级34班 - 广东工业大学 - 班级博客 - 博客园 |
---|---|
这个作业要求在哪里 | 结对项目 - 作业 - 计科23级34班 - 班级博客 - 博客园 |
这个作业的目标 | <实现一个自动生成小学四则运算题目的命令行程序> |
3223004553 陈文婉
3123004536 刘泽昊
GitHub链接:chenwenwan-git/Calculation at main · chenwenwan-git/chenwenwan-git
PSP表格
PSP2 1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
---|---|---|---|
Planning | 计划 | 300 | 300 |
Estimate | 估计这个任务需要多少时间 | 300 | 300 |
Development | 开发 | 200 | 200 |
Analysis | 需求分析(包括学习新技术) | 60 | 60 |
Design Spec | 生成设计文档 | 20 | 10 |
Design Review | 设计复审 | 20 | 20 |
Coding Standard | 代码规范(为目前的开发制定合适的规范) | 30 | 20 |
Design | 具体设计 | 60 | 50 |
Coding | 具体编码 | 100 | 120 |
Code Review | 代码复审 | 5 | 10 |
Test | 测试(自我测试,修改代码,提交修改) | 15 | 20 |
Reporting | 报告 | 15 | 30 |
Test Report | 测试报告 | 15 | 15 |
Size Measurement | 计算工作量 | 20 | 20 |
Postmortem & Process Improvement Plan | 事后总结,并提出过程改进计划 | 15 | 20 |
合计 | 375 | 395 |
效能分析
- 单次统计:
python cli.py -r 12 -n 50 --stats
→Perf.txt
示例耗时约3ms。 - 批量采集:
python perf_run.py
→perf_data.csv
(示例:6组)- 10,100 → attempts=100, duplicates=0, time_ms≈7
- 10,1000 → attempts≈1013, duplicates≈13, time_ms≈43
- 20,1000 → attempts≈1003, duplicates≈3, time_ms≈44
- 20,5000 → attempts≈5054, duplicates≈54, time_ms≈236
- 10,10000 → attempts≈10948, duplicates≈948, time_ms≈466
- 20,10000 → attempts≈10214, duplicates≈214, time_ms≈467
- 图表:
python plot_perf.py
→
性能改进思路:
- 在构建
SUB
/DIV
时优先抽样满足约束的分支以减少重试。 - 通过规范化键的字符串拼接降低哈希成本。
- 控制三运算符表达式比例,降低生成失败率。
设计实现过程
项目架构设计
采用了模块化设计,将系统划分为多个功能明确的组件,各模块之间通过清晰的接口进行交互(如下图):
关键数据结构
- 数值表示:统一使用Python标准库的
fractions.Fraction
进行精确计算,避免浮点数精度问题。带分数仅在输入/输出显示时转换。 - 表达式节点:采用二叉树结构表示表达式
Num
类:存储单个数值(自然数、真分数或带分数)BinOp
类:存储二元运算,包含运算符类型和左右子表达式
- 去重机制:通过
key()
方法生成规范化的字符串表示,对加法和乘法的操作数进行排序,确保交换律等价的表达式视为同一题目。
关键实现
1. 表达式生成流程
表达式生成采用递归构建二叉树的方法,确保生成的题目满足各种约束条件:
-
叶子节点生成:
- 随机决定生成自然数、真分数或带分数
- 确保数值在指定范围内(
-r
参数控制)
-
非叶子节点构建:
- 随机选择运算符(+、-、×、÷)
- 递归生成左右子表达式
- 对于减法和除法,检查约束条件:
- 减法:被减数 ≥ 减数(确保结果非负)
- 除法:结果为真分数(0 < 商 < 1)
- 若约束不满足,重新生成
-
去重处理:
- 通过
key()
方法生成规范化键 - 使用集合存储已生成表达式的键
- 若新生成的表达式键已存在,重新生成
- 通过
-
格式化输出:
- 使用
to_str()
方法生成带括号的表达式字符串 - 添加编号和格式前缀
- 写入文件
- 使用
2. 评分流程
评分模块采用了经典的表达式求值算法,确保准确计算结果并进行比较:
-
格式解析:
- 去除行首编号和中文标签
- 移除末尾的等号
- 按照空格分割字符串
-
符号规范化:
- 将中文运算符(×、÷)映射为Python支持的运算符(*、/)
- 解析带分数为假分数形式
-
表达式求值:
- 使用Shunting-yard算法将中缀表达式转换为逆波兰表达式(RPN)
- 利用栈计算RPN表达式的值,使用
Fraction
确保精确计算
-
结果比对:
- 将计算结果与用户答案进行比较
- 记录正确和错误的题目编号
- 生成评分报告文件
重点运用
-
使用Fraction类**:选择Python的
fractions.Fraction
类进行所有数值计算,避免浮点数精度问题,确保结果的精确性。 -
递归构建表达式树:通过二叉树结构表示表达式,便于实现运算符优先级处理、括号添加和去重机制。
-
规范化键生成:为了解决去重问题,设计了专门的键生成机制,对满足交换律的运算(加法、乘法)进行操作数排序,确保等价表达式生成相同的键。
-
灵活的格式处理:在评分模块中实现了健壮的格式解析功能,支持多种编号格式和分隔符,提高了系统的兼容性。
关键代码说明
1. 命令行接口实现
# cli.py - 核心命令行处理逻辑
def main():
"""主函数:解析命令行参数并执行相应操作"""
parser = argparse.ArgumentParser(description='小学数学四则运算题目生成器')
# 支持两种用法:直接指定参数或使用子命令
subparsers = parser.add_subparsers(dest='command', help='可用命令')
# 生成题目子命令
generate_parser = subparsers.add_parser('generate', help='生成四则运算题目')
generate_parser.add_argument('-r', type=int, default=10, help='数值范围(默认10)')
generate_parser.add_argument('-n', type=int, default=10, help='题目数量(默认10)')
generate_parser.add_argument('--stats', action='store_true', help='输出性能统计信息')
# 评分子命令
grade_parser = subparsers.add_parser('grade', help='评分四则运算题目')
grade_parser.add_argument('-e', type=str, required=True, help='题目文件路径')
grade_parser.add_argument('-a', type=str, required=True, help='答案文件路径')
# 顶层参数(兼容直接使用的情况)
parser.add_argument('-r', type=int, default=10, help='数值范围(默认10)')
parser.add_argument('-n', type=int, default=10, help='题目数量(默认10)')
parser.add_argument('-e', type=str, help='题目文件路径')
parser.add_argument('-a', type=str, help='答案文件路径')
parser.add_argument('--stats', action='store_true', help='输出性能统计信息')
args = parser.parse_args()
# 确定执行模式:生成或评分
if args.command == 'generate' or (args.e is None and args.a is None):
# 生成模式
if args.n > 10000:
print(f"警告:题目数量 {args.n} 较大,可能耗时较长")
ex_path, ans_path = gen.generate(args.n, args.r, args.stats)
print(f"已生成题目与答案:")
print(f"Exercises -> {ex_path}")
print(f"Answers -> {ans_path}")
elif args.command == 'grade' or (args.e is not None and args.a is not None):
# 评分模式
efile = args.e
afile = args.a
out_grade = grade.grade(exercise_file=efile, answer_file=afile)
print(f"已生成评分结果:")
print(f"Grade -> {out_grade}")
else:
# 参数不完整,显示帮助信息
parser.print_help()
2. 表达式生成核心
# gen.py - 递归构建表达式树
def build_expr_with_ops(depth, max_ops) -> Expr:
"""递归构建指定运算符数量的表达式"""
# 基础情况:如果已达到最大运算符数量或随机决定终止递归,生成叶子节点
if depth >= max_ops or random.random() < 0.3:
return random_leaf()
# 递归情况:生成内部节点
# 决定剩余的运算符如何分配给左右子树
remaining_ops = max_ops - depth
left_ops = random.randint(0, remaining_ops)
right_ops = remaining_ops - left_ops
# 递归生成左右子表达式
left = build_expr_with_ops(depth + 1, max_ops)
right = build_expr_with_ops(depth + 1, max_ops)
# 尝试不同的运算符,确保满足约束
while True:
op = random.choice(['+', '-', '*', '/'])
# 计算子表达式的值
left_val = left.evaluate()
right_val = right.evaluate()
# 检查约束条件
if op == '-' and left_val >= right_val:
break # 减法约束满足
elif op == '/' and right_val != 0 and 0 < left_val / right_val < 1:
break # 除法约束满足
elif op in ['+', '*']:
break # 加减乘无额外约束
# 约束不满足,尝试交换左右子树
if op == '-' or op == '/':
left, right = right, left
left_val, right_val = right_val, left_val
# 再次检查约束
if (op == '-' and left_val >= right_val) or \
(op == '/' and right_val != 0 and 0 < left_val / right_val < 1):
break
return BinOp(op, left, right)
3. 表达式求值与Shunting-yard算法
# grade.py - Shunting-yard算法实现
def _shunting_yard(tokens: List[str]) -> List[str]:
"""使用Shunting-yard算法将中缀表达式转换为后缀表达式(RPN)"""
# 运算符优先级映射
precedence = {'+': 1, '-': 1, '*': 2, '/': 2}
# 输出队列
output = []
# 运算符栈
op_stack = []
for tok in tokens:
if tok.isdigit() or '/' in tok:
# 处理数字(整数或分数)
output.append(tok)
elif tok == '(':
# 左括号直接入栈
op_stack.append(tok)
elif tok == ')':
# 右括号:弹出运算符直到遇到左括号
while op_stack and op_stack[-1] != '(':
output.append(op_stack.pop())
# 弹出左括号
if op_stack and op_stack[-1] == '(':
op_stack.pop()
else:
raise ValueError('括号不匹配')
elif tok in precedence:
# 运算符:根据优先级规则处理
while (op_stack and op_stack[-1] != '(' and
precedence[op_stack[-1]] >= precedence[tok]):
output.append(op_stack.pop())
op_stack.append(tok)
else:
# 未知token
raise ValueError(f'未知token: {tok}')
# 处理剩余的运算符
while op_stack:
if op_stack[-1] == '(':
raise ValueError('括号不匹配')
output.append(op_stack.pop())
return output
def _eval_rpn(rpn: List[str]) -> Fraction:
"""计算后缀表达式(RPN)的值"""
stack = []
for tok in rpn:
if tok.isdigit():
# 整数
stack.append(Fraction(int(tok)))
elif '/' in tok:
# 分数
num, den = map(int, tok.split('/'))
stack.append(Fraction(num, den))
elif tok in '+-*/':
# 运算符:弹出两个操作数,执行运算
if len(stack) < 2:
raise ValueError('表达式格式错误')
b = stack.pop()
a = stack.pop()
if tok == '+':
stack.append(a + b)
elif tok == '-':
stack.append(a - b)
elif tok == '*':
stack.append(a * b)
elif tok == '/':
stack.append(a / b)
if len(stack) != 1:
raise ValueError('表达式格式错误')
return stack[0]
说明
- 去重键生成机制:仅在节点层面对
+
与×
的左右交换进行规范化
# expr.py - 核心去重逻辑
def key(self) -> str:
# 递归获取左右子表达式的键
lk = self.left.key(); rk = self.right.key()
# 对于加法和乘法(满足交换律的运算),对操作数排序确保等价表达式生成相同键
if self.op == '+':
a, b = sorted([lk, rk])
return f"A:({a},{b})" # A表示加法
if self.op == '*':
a, b = sorted([lk, rk])
return f"M:({a},{b})" # M表示乘法
# 对于减法和除法(不满足交换律的运算),保持顺序
if self.op == '-':
return f"S:[{lk},{rk}]" # S表示减法,使用方括号强调顺序
if self.op == '/':
return f"D:[{lk},{rk}]" # D表示除法,使用方括号强调顺序
- 对于加法和乘法,无论操作数顺序如何,都会生成相同的键
- 对于减法和除法,由于不满足交换律,所以保留操作数顺序
- 使用不同的前缀和括号样式增强可读性和唯一性
- 表达式打印与格式控制:智能添加括号,确保运算顺序正确
# expr.py - to_str()方法核心逻辑
def to_str(self, parent_prec=0) -> str:
# 获取当前运算符优先级
curr_prec = 1 if self.op in ['+', '-'] else 2
# 需要添加括号的情况:当前优先级低于父优先级,或同级且右结合
need_paren = parent_prec > curr_prec or \
(parent_prec == curr_prec and \
(self.op in ['-', '/'] and parent_is_right))
# 获取左右子表达式的字符串表示(递归调用)
left_str = self.left.to_str(curr_prec)
right_str = self.right.to_str(curr_prec)
# 替换运算符显示:×代替*,÷代替/
display_op = '×' if self.op == '*' else ('÷' if self.op == '/' else self.op)
# 组合表达式字符串
expr_str = f"{left_str} {display_op} {right_str}"
# 必要时添加括号
return f"( {expr_str} )" if need_paren else expr_str
- 分数格式化:支持真分数和带分数的智能显示
# numutil.py - 分数格式化核心函数
def fraction_to_str(f: Fraction) -> str:
# 确保分数已约分
f = f.limit_denominator()
# 获取分子和分母
num, den = f.numerator, f.denominator
# 处理整数情况
if den == 1:
return str(num)
# 处理负数情况(将负号放在整数部分)
if num < 0:
return f"-{fraction_to_str(Fraction(-num, den))}"
# 处理带分数情况(分子大于分母)
if num > den:
whole = num // den # 整数部分
frac = Fraction(num % den, den) # 分数部分
return f"{whole}’{frac.numerator}/{frac.denominator}"
# 处理真分数情况
return f"{num}/{den}"
- gen.py - 在构建表达式时应用约束
while True:
# 尝试构建表达式
left = build_expr(...)
right = build_expr(...)
# 根据运算符类型应用对应约束
if op == '-':
if valid_sub(left_val, right_val):
break # 约束满足,退出循环
elif op == '/':
if valid_div(left_val, right_val):
break # 约束满足,退出循环
else:
break # 加法和乘法无特殊约束
测试运行
测试用例
- 确定性全对用例:
testdata/exercises_case1.txt
+answers_case1.txt
→Correct: 10
、Wrong: 0
。 - 错误示例用例:
testdata/exercises_case2.txt
+answers_case2.txt
(刻意错1、2、8) →Correct: 7
、Wrong: 3 (1, 2, 8)
。 - 随机生成核验:
python cli.py -r 12 -n 5
→ 生成编号格式的题目与答案;评分全对。 - 边界与小范围:
python cli.py -r 2 -n 10
(分数较少但可用)。 - 兼容性:顶层与子命令两种用法均可;缺少必要参数时输出帮助。
- 题目格式一致性检查:
- 运算符与等号前后均有空格;`
- 显示符号为
×/÷
; - 真分数/带分数/整数显示符合要求。
具体运用可见github项目的md执行文档,如下:
使用说明
- 生成题目(无子命令):
python cli.py -r 10 -n 10
- 可加性能统计:
python cli.py -r 20 -n 10000 --stats
- 生成题目(子命令):
python cli.py generate -r 10 -n 10
- 评分统计(无子命令):
python cli.py -e Exercises.txt -a Answers.txt
- 评分统计(子命令):
python cli.py grade -e Exercises.txt -a Answers.txt
运行结果支持10000道题目
生成文件对应在根目录下的Answers,Exercises,Grade相关txt文件
性能测试结构
项目总结
本项目基于命令行实现一个自动生成小学四则运算题目的程序,我们结队完成了包括题目生成、答案计算、去重机制、评分功能,性能估计以及支持大量(一万道)题目的生成,在功能和性能上面不断进行优化,达到了预期目标。
同时,结队完成项目的过程中也有些许不足:
初期计划不足:在项目初期,计划制定不够细致,导致部分模块(如去重逻辑)的设计在编码阶段才逐步完善,增加了开发时间。
测试覆盖率有限:虽然进行了多种测试用例验证,但缺乏更加全面的自动化单元测试,可能遗漏边缘情况。
性能优化空间:尽管性能测试显示生成一万道题目耗时已大幅减少,但对于大规模生成(如 n=10000),仍可通过进一步优化去重算法或减少递归开销来提升效率。
经验教训和收获
在完成项目的过程中,我们认识到以下几点
模块化设计的重要性:通过将程序划分为生成、评分和格式化模块,代码结构清晰,易于维护和扩展。未来项目应继续采用模块化设计。
提前学习新技术:在需求分析阶段提前学习 fractions.Fraction 和 Shunting-yard 算法,显著降低了开发中的技术障碍。
性能测试驱动优化:通过性能测试数据(如 perf_data.csv),我们识别了去重机制的瓶颈,并通过规范化键生成和约束检查优化了生成效率。
更详细的计划:未来应在项目初期制定更详细的开发计划,明确每个模块的功能和接口,减少开发中的临时调整。
加强测试:应增加自动化单元测试,覆盖更多边界情况(如极端数值范围或复杂表达式),以提升代码健壮性。
中间检查点:在开发过程中应设置更多检查点,及时复审代码和设计,避免后期修改成本过高。
结队感受
陈文婉:
这次结队项目我们分工明确,互相配合完成了从需求分析到代码实现的各个阶段,从理清需求到算法设计再到性能优化,都是在一步一步的交流讨论中构建出来的,这个过程很有意义。在测试阶段,队友提出了丰富全面的性能测试和用例,为项目的完整性和优化提供了很好的支持。
刘泽昊:
结队过程很愉快,队友在算法设计上的严谨思路让我受益匪浅,尤其是在去重机制和表达式树构建上的讨论,推动了项目的顺利进展。同时,我也学到了如何在团队中高效沟通和协调任务。通过这次结对项目,我们不仅完成了功能需求,还在协作中锻炼了沟通能力和团队意识,为未来的软件开发积累了宝贵经验。