第三周作业:结对项目

一、项目基本信息

项目 内容
同学一 杨旭东,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 启动和磁盘写入,因此数值会受到机器状态影响。

xychart-beta title "题目生成耗时(单次命令,毫秒)" x-axis ["1,000题", "10,000题"] y-axis "耗时/ms" 0 --> 300 bar [183, 252]

从实现分析看,QuestionGenerator.generate 循环中的 generateOne、表达式递归求值和 HashSet 查重是生成阶段的主要耗时路径。generateOne 每次会构造表达式、验证中间结果并计算答案;CanonicalKey 通过规范签名把等价的加法、乘法表达式合并,避免了反复输出重复题目。

3.2 改进思路

  1. 使用 HashSet<String> 保存已生成题目的规范签名,使查重平均为 O(1),而不是逐题线性比较。
  2. 在构造表达式时提前检查减法结果和除数,尽量在递归阶段淘汰非法表达式,减少无效求值。
  3. Fraction 构造时立即约分,使后续比较和判题不需要重复规范化。
  4. 为生成循环设置最大尝试次数,范围过小无法产生足够不重复题目时快速报错,避免无限循环。
  5. 使用 StringBuilder/批量文件写出(由 FileStore 统一处理),减少逐字符或逐题打开文件的开销。

本次优化没有改变题目规则和输出格式,重点是减少重复比较、无效表达式和重复文件操作。

四、设计实现过程

4.1 代码组织

项目按职责划分为以下模块:

com.wddyxd
├── Main                  程序入口和模式分流
├── cli                   命令行参数解析
├── config                运行配置
├── model                 Fraction、Expr、NumberExpr、BinaryExpr、Question
├── parser                表达式递归下降解析器
├── generator             题目生成与合法性校验
├── dedup                 表达式规范签名和查重
├── grade                 批改及统计结果
├── io                    Exercises/Answers/Grade 文件读写
└── error                 面向用户的异常类型

核心对象关系如下:

classDiagram class Expr { <<interface>> +evaluate() +toDisplayString() +operatorCount() +canonicalKey() } class NumberExpr class BinaryExpr class Fraction class Question Expr <|.. NumberExpr Expr <|.. BinaryExpr BinaryExpr o-- Expr NumberExpr o-- Fraction Question o-- Expr Question o-- Fraction

4.2 关键流程

flowchart TD A[读取命令行参数] --> B{运行模式} B -->|生成| C[创建 QuestionGenerator] C --> D[递归构造表达式] D --> E{合法且规范签名未出现} E -->|否| D E -->|是| F[保存题目和答案] B -->|批改| G[读取题目文件和答案文件] G --> H[ExpressionParser 解析题目] H --> I[重新计算标准答案] I --> J[逐题比较并统计] J --> K[写入 Grade.txt]

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/22/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 成功与收获

这次结对项目让我们把需求、接口、实现、测试和打包完整串联起来。先定义 ExprFraction 的稳定接口,再实现解析器和生成器,降低了模块之间的耦合。代码评审和测试互相补充,尤其发现并处理了分母为负、零分数、括号优先级、除零和重复题目等边界问题。

7.2 不足与改进

当前性能数据主要是命令级计时,还可以进一步使用 JFR 或 VisualVM 做更细粒度的 CPU、内存和 GC 分析。Fraction 的内部字段是 long,虽然运算中使用精确算术检查溢出,但若未来支持更大范围,应考虑统一使用 BigInteger。此外,项目目前以命令行为主,图形界面和更丰富的随机种子配置仍可作为后续工作。

7.3 结对感受与彼此分享

杨旭东:陈浩良在 Fraction、表达式树和解析器实现中重视边界条件,代码结构清楚,能够把运算规则落实到可测试的接口中。建议后续在提交说明中同时记录测试命令和性能变化,方便快速回溯。

陈浩良:杨旭东负责整体接口整合、题目生成、批改、文件读写和最终联调,能够主动检查模块之间的兼容性并推动项目打包运行。建议后续在开发早期就建立统一的测试数据和性能基线,减少最后阶段的整合成本。

总体来看,本次结对的闪光点是分工明确、及时沟通、互相审查代码;需要吸取的教训是不要只关注“功能能运行”,还应在实现初期同步设计异常行为、可测性和性能指标。

posted @ 2026-09-20 20:07  hhhhhlhcbcbxhd  阅读(16)  评论(0)    收藏  举报