第三周作业:结对项目
一、项目基本信息
| 项目 | 内容 |
|---|---|
| 同学一 | 杨旭东,3124004109 |
| 同学二 | 陈浩良,3124004087 |
| GitHub 项目 | https://github.com/Greatdom/corenger |
| 开发环境 | JDK 17.0.14、Maven、JUnit 5 |
| 项目类型 | Java 命令行程序 |
本项目实现了小学四则运算题目的生成、答案计算、文件保存、答案批改和结果统计。程序支持整数、真分数、带分数、括号以及 + - × ÷ 运算,并限制中间结果和最终结果不能为负数、除数不能为零。
开发过程截图
下图为项目开发过程中的 IntelliJ IDEA 截图,展示了项目目录、模块代码以及博客文档的编辑情况。

二、PSP 表格
本项目采用仓库中已有的 PSP2.1 耗时记录表,表格中的时间单位为分钟。下表与 Excel 文件保持一致,其中“预估耗时”是在实现前填写,“实际耗时”是在实现后回填。
| PSP2.1 阶段 | 工作内容 | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|---|
| Planning(计划) | |||
| Estimate(估计) | 估计完成项目所需时间 | 370 | 490 |
| Development(开发) | |||
| Analysis(需求分析) | 需求分析(包括学习新技术) | 30 | 60 |
| Design Spec(设计说明) | 撰写设计说明 | 30 | 60 |
| Design Review(设计复审) | 与同伴复审设计文档 | 60 | 30 |
| Coding Standard(编码规范) | 为项目制定合适的编码规范 | 30 | 30 |
| Design(详细设计) | 详细设计 | 60 | 120 |
| Coding(编码) | 编码实现 | 60 | 60 |
| Code Review(代码复审) | 代码复审 | 30 | 30 |
| Test(测试) | 测试、查找并修改缺陷 | 30 | 60 |
| Reporting(报告) | |||
| Test Report(测试报告) | 测试报告 | 30 | 30 |
| Size Measurement(工作量度量) | 计算工作量 | 0 | 0 |
| Postmortem & Process Improvement Plan(项目总结与过程改进计划) | 项目总结、过程改进计划 | 10 | 10 |
原始 Excel 表中 Estimate 行给出了总估计和总实际耗时:预估 370 分钟,实际 490 分钟。实际总耗时比预估多 120 分钟,主要增加在需求分析、详细设计和测试阶段;设计复审阶段实际耗时少于预估,得益于前期接口划分较清晰。
三、效能分析与改进
3.1 分析方法和结果
在 Windows、JDK 17.0.14、同一台机器上执行打包后的 fat jar,计时范围包含 JVM 启动、随机生成、查重、答案计算和文件写入。结果如下:
| 场景 | 题目数量 | 总耗时 |
|---|---|---|
| 小批量生成 | 1,000 | 约 183 ms |
| 大批量生成 | 10,000 | 约 252 ms |
该计时用于比较程序规模变化,不等同于专业 profiler 的 CPU 采样结果;单次命令包含 JVM 启动和磁盘写入,因此数值会受到机器状态影响。
从实现分析看,QuestionGenerator.generate 循环中的 generateOne、表达式递归求值和 HashSet 查重是生成阶段的主要耗时路径。generateOne 每次会构造表达式、验证中间结果并计算答案;CanonicalKey 通过规范签名把等价的加法、乘法表达式合并,避免了反复输出重复题目。
3.2 改进思路
- 使用
HashSet<String>保存已生成题目的规范签名,使查重平均为 O(1),而不是逐题线性比较。 - 在构造表达式时提前检查减法结果和除数,尽量在递归阶段淘汰非法表达式,减少无效求值。
Fraction构造时立即约分,使后续比较和判题不需要重复规范化。- 为生成循环设置最大尝试次数,范围过小无法产生足够不重复题目时快速报错,避免无限循环。
- 使用
StringBuilder/批量文件写出(由FileStore统一处理),减少逐字符或逐题打开文件的开销。
本次优化没有改变题目规则和输出格式,重点是减少重复比较、无效表达式和重复文件操作。
四、设计实现过程
4.1 代码组织
项目按职责划分为以下模块:
com.wddyxd
├── Main 程序入口和模式分流
├── cli 命令行参数解析
├── config 运行配置
├── model Fraction、Expr、NumberExpr、BinaryExpr、Question
├── parser 表达式递归下降解析器
├── generator 题目生成与合法性校验
├── dedup 表达式规范签名和查重
├── grade 批改及统计结果
├── io Exercises/Answers/Grade 文件读写
└── error 面向用户的异常类型
核心对象关系如下:
4.2 关键流程
ExpressionParser 使用递归下降法分成三层:parseExpression 处理加减,parseTerm 处理乘除,parsePrimary 处理数字和括号。这样天然保证乘除优先于加减,同时括号可以重新进入最低优先级解析。
五、代码说明
5.1 分数模型
public Fraction(long numerator, long denominator) {
if (denominator == 0) {
throw new IllegalArgumentException("分母不能为0");
}
if (denominator < 0) {
numerator = Math.negateExact(numerator);
denominator = Math.negateExact(denominator);
}
if (numerator == 0) {
this.numerator = 0;
this.denominator = 1;
return;
}
long gcd = gcd(Math.abs(numerator), denominator);
this.numerator = numerator / gcd;
this.denominator = denominator / gcd;
}
所有 Fraction 在构造时约分并统一为正分母,因此 1/2、2/4 可以通过值相等直接比较。四则运算使用 Math.*Exact,整数溢出会显式报告,而不会静默得到错误答案。
5.2 表达式求值和显示
@Override
public Fraction evaluate() {
Fraction l = left.evaluate();
Fraction r = right.evaluate();
switch (op) {
case "+": return l.add(r);
case "-": return l.sub(r);
case "*": return l.mul(r);
case "/": return l.div(r);
default: throw new IllegalStateException("未知运算符: " + op);
}
}
BinaryExpr 是表达式树的二元节点,左、右子节点可以继续是任意 Expr。输出时根据运算优先级自动补括号,避免“显示出来的题目”和实际表达式树含义不一致。
5.3 递归下降解析
private Expr parseExpression() {
Expr result = parseTerm();
while (true) {
skipWhitespace();
if (!hasNext() || current() == ')' || current() == '=') {
return result;
}
String operator = readAdditiveOperator();
if (operator == null) throw error("缺少有效的运算符");
result = new BinaryExpr(operator, result, parseTerm());
}
}
解析器支持普通分数、带分数、ASCII 运算符和题目中常用的 × ÷ − 符号;输入不完整、括号不匹配、等号不在末尾等情况都会转换为 InvalidExpressionException。
5.4 生成和查重
Question question = generateOne();
if (seen.add(question.getSignature())) {
result.add(question);
}
canonicalKey 对加法和乘法递归收集同类项并排序,因此交换律、结合律造成的重复会得到同一个签名;减法和除法保留左右顺序,避免把不等价表达式误判为相同题目。
六、测试运行
项目使用 JUnit 5,共 49 个测试,执行 mvn test 的结果为 49 passed, 0 failures, 0 errors。以下列出具有代表性的测试用例:
| # | 测试用例 | 预期结果 |
|---|---|---|
| 1 | Fraction(2, 4) |
自动约分为 1/2 |
| 2 | 1/2 + 1/3 |
得到 5/6 |
| 3 | 3/4 - 1/2 |
得到 1/4 |
| 4 | 任意分数除以 0 |
抛出算术异常 |
| 5 | 解析 5 = |
得到整数 5 |
| 6 | 解析 3/5 = |
得到 3/5 |
| 7 | 解析 2'3/8 = |
得到 19/8 |
| 8 | 解析 3 + 5 × 2 = |
按优先级得到 13 |
| 9 | 解析 (1 + 2) × 3 = |
得到 9 |
| 10 | 解析 6 ÷ 3 = |
得到 2 |
| 11 | 生成 100 道、范围为 10 的题 | 数量正确、签名不重复、结果非负 |
| 12 | 题目和答案数量不一致 | 抛出 InvalidAnswerFormatException |
| 13 | 正确和错误答案混合批改 | 正确/错误题号分别统计 |
| 14 | 文件写出后重新读取 | 题目、答案和批改结果可往返读取 |
正确性依据包括:分数模型的值语义保证等价分数可比较;解析器测试覆盖三种数值格式、优先级、括号和除法;生成器测试检查数量、去重和合法性;Grader 不信任外部标准答案,而是重新计算表达式;文件测试验证实际输出格式。单元测试通过后又使用打包 jar 完整执行了生成模式和批改模式。
七、项目小结
7.1 成功与收获
这次结对项目让我们把需求、接口、实现、测试和打包完整串联起来。先定义 Expr 和 Fraction 的稳定接口,再实现解析器和生成器,降低了模块之间的耦合。代码评审和测试互相补充,尤其发现并处理了分母为负、零分数、括号优先级、除零和重复题目等边界问题。
7.2 不足与改进
当前性能数据主要是命令级计时,还可以进一步使用 JFR 或 VisualVM 做更细粒度的 CPU、内存和 GC 分析。Fraction 的内部字段是 long,虽然运算中使用精确算术检查溢出,但若未来支持更大范围,应考虑统一使用 BigInteger。此外,项目目前以命令行为主,图形界面和更丰富的随机种子配置仍可作为后续工作。
7.3 结对感受与彼此分享
杨旭东:陈浩良在 Fraction、表达式树和解析器实现中重视边界条件,代码结构清楚,能够把运算规则落实到可测试的接口中。建议后续在提交说明中同时记录测试命令和性能变化,方便快速回溯。
陈浩良:杨旭东负责整体接口整合、题目生成、批改、文件读写和最终联调,能够主动检查模块之间的兼容性并推动项目打包运行。建议后续在开发早期就建立统一的测试数据和性能基线,减少最后阶段的整合成本。
总体来看,本次结对的闪光点是分工明确、及时沟通、互相审查代码;需要吸取的教训是不要只关注“功能能运行”,还应在实现初期同步设计异常行为、可测性和性能指标。
浙公网安备 33010602011771号