“小学四则运算题目生成”项目说明

准备

所属课程 计科24级56班
作业要求 第一周作业
作业目标 实现一个自动生成小学四则运算题目的命令行程序,通过github进行版本管理
小组成员 曾盈洁(3224004120),邓炜娟(3224004116)
作业链接 Ajie8021
github图片 github展示

正文

一、PSP表格记录

PSP2.1 预估耗时(分钟)
Planning
· Estimate(估计这个任务需要多少时间) 30
Development
· Analysis(需求分析,包括学习新技术) 60
· Design Spec(生成设计文档) 90
· Design Review(设计复审) 30
· Coding Standard(代码规范) 30
· Design(具体设计) 90
· Coding(具体编码) 300
· Code Review(代码复审) 60
· Test(自我测试,修改代码,提交修改) 120
Reporting
· Test Report(测试报告) 60
· Size Measurement(计算工作量) 30
· Postmortem & Process Improvement Plan(事后总结, 并提出过程改进计划) 30
Total(合计) 940

二、效能分析

本项目的主要功能是自动生成指定数量的四则运算题目。当题目数量较少时,程序运行时间较短,性能问题并不明显。但是题目要求程序支持一次生成最多 10000 道题目,因此需要对题目生成过程进行性能分析。

1、消耗最大的函数

从当前程序的代码结构来看,消耗最大的函数是 ExerciseGenerator.generate()

事实上,该函数中并不存在特别复杂的算法,主要是因为它负责着整个题目的循环生成,即它本身是一个高调用频率的调度函数。比如,当要求生成 10000 道题目时,它内部的随机生成、合法性判断、规范化和重复检测可能会被执行远多于 10000 次,因为无效题目和重复题目都会导致重新生成。

其完整代码见第四部分代码说明第三点

2、性能优化所花费的时间

本次性能优化主要集中在题目生成模块。

工作内容 实际耗时
性能问题定位 15 min
生成算法分析 20 min
重复检测分析 15 min
代码优化 25 min
优化后测试 20 min
性能结果整理 15 min
合计 110 min

因此,本次性能分析和优化阶段共花费约110 分钟

3、初始实现的性能问题

3.1 随机生成存在无效表达式

随机生成表达式后,需要经过合法性验证,例如结果为负数的题目不能作为生成结果,因此随机生成的大量表达式可能在验证阶段被直接丢弃。

如果随机生成的无效表达式比例较高,就会浪费计算时间。

3.2 重复检测的性能问题

程序需要保证一次运行生成的题目不能重复。

当前采用Set<String>保存已经生成题目的规范化形式。

每生成一个表达式:

flowchart TD A["Expression"] B["ExpressionNormalizer"] C["String"] D{"HashSet.contains()"} E["加入 Set"] F["丢弃并重新生成"] A --> B B --> C C --> D D -- 不存在 --> E D -- 存在 --> F

这里使用 HashSet 的主要优点是平均情况下查找复杂度接近O(1)。因此,这里认为,相比使用 List 遍历所有历史题目进行比较,HashSet 更适合本项目的重复检测需求。

4、性能优化思路

本项目的优化主要采用以下思路。

4.1 使用 HashSet 进行重复检测

不采用List<Expression>逐个比较,而是将表达式规范化后保存到Set<String>中,例如3 + 55 + 3,经过规范化后得到相同的标识,因此可以快速判断为重复。

4.2 使用表达式树进行规范化

表达式不是直接通过字符串进行处理,而是先构造表达式树。例如3 + (2 + 1)对应的表达式树为:

       +
      / \
     3   +
        / \
       2   1

规范化时只对 + 和 × 的左右子树进行交换排序。这样既能够判断重复,又不会错误地将所有不同树结构的表达式全部判断为重复。

4.3 限制表达式最大运算符数量

题目要求每道题最多 3 个运算符,因此生成器直接控制operatorCount ∈ [0, 3],避免生成超过要求的大型表达式后再进行裁剪。

4.4 限制随机生成尝试次数

如果输入参数导致可生成的不重复题目数量不足,程序不能无限循环。

因此增加最大尝试次数:在生成尝试次数达到上限调用GenerationException模块,这样可以避免程序长时间卡死。

5.、优化后的生成流程

优化后的整体流程如下:

flowchart TD A[开始生成题目] --> B[随机确定运算符数量] B --> C[生成表达式树] C --> D{表达式是否合法} D -- 否 --> E[重新生成] E --> C D -- 是 --> F[表达式规范化] F --> G{HashSet中是否存在} G -- 是 --> E G -- 否 --> H[加入题目集合] H --> I{是否达到目标数量} I -- 否 --> B I -- 是 --> J[输出题目]

该流程避免了对已经生成的所有题目进行线性遍历,同时通过规范化字符串解决了等价题目的重复问题。

6、性能分析结果

为了观察题目数量对程序运行时间的影响,我们分别测试了100、1000、5000、10000道题目的生成时间,结果如下:

题目数量 生成耗时(秒/s)
100 100
1000 142
5000 232
10000 256
xychart-beta title "题目数量与生成耗时关系" x-axis "题目数量" [100, 1000, 5000, 10000] y-axis "生成耗时(ms)" 0 --> 300 line [100, 142, 232, 256]

三、设计实现过程

1、设计目标

本项目采用 Java + Maven + CLI 实现小学四则运算题目生成程序。

系统主要包括两个功能:

(1)根据命令行参数生成指定数量的四则运算题目及其答案。
(2)根据题目文件和提交的回答文件进行自动判题并生成统计结果。

设计重点是保证题目生成的正确性、重复题判断的准确性以及大量题目生成时的性能。

2、系统总体结构

程序按照功能划分为以下几个模块:

flowchart TD A["Main"] B["CommandLineParser"] C["生成题目模式"] D["判题模式"] E["ExerciseGenerator"] F["Grader"] G["ExpressionGenerator"] H["ExpressionCalculator"] I["ExpressionValidator"] J["FileManager"] K["ExpressionNormalizer"] L["ExpressionCalculator"] A --> B B --> C B --> D C --> E D --> F E --> G F --> H G --> I G --> J I --> K K --> L

主要模块职责如下:

模块 主要职责
Main 程序入口,协调各模块
CommandLineParser 解析和检查命令行参数
ExerciseGenerator 控制题目数量并生成题目
ExpressionGenerator 生成表达式结构和操作数
Expression 表示算术表达式
Fraction 表示和计算自然数、分数
ExpressionCalculator 计算表达式结果
ExpressionValidator 检查减法、除法等是否合法
ExpressionNormalizer 将表达式转换为规范形式,用于判断重复
FileManager 负责题目、答案和成绩文件的读写
Grader 根据答案文件进行判题和统计

3、核心数据设计

3.1 Fraction

使用 Fraction 表示程序中的数值。

统一使用:

numerator / denominator

表示分数,并保证:

  • 分母不为 0;
  • 分母保持为正数;
  • 分数进行约分;
  • 整数可以作为分母为 1 的分数处理。

例如:

2       → 2/1
4/8     → 1/2
2'3/4   → 11/4

这样可以统一处理自然数和分数运算。

3.2 Operator

使用枚举表示四种运算符:

ADD       +
SUBTRACT  -
MULTIPLY  ×
DIVIDE    ÷

Operator 统一保存运算符信息及相关属性。

3.3 Expression

采用表达式树表示算术表达式,而不是直接使用字符串保存。

基本结构:

Expression
├── NumberExpression
└── BinaryExpression
    ├── left
    ├── operator
    └── right

例如:

(3 + 5) × 2

表示为:

        ×
       / \
      +   2
     / \
    3   5

表达式树可以统一支持:

  • 表达式计算;
  • 表达式输出;
  • 合法性检查;
  • 重复题判断。

4、题目生成设计

4.1 生成流程

题目生成采用随机生成方式:

flowchart TD A["生成运算符数量"] B["生成表达式结构"] C["生成操作数"] D["计算子表达式"] E["检查表达式是否合法"] F["规范化并检查重复"] G["保存"] A --> B B --> C C --> D D --> E E -- 合法 --> F F -- 不重复 --> G F -- 重复 --> A E -- 不合法 --> A

每道题的运算符数量限制为 1~3 个。

4.2 合法性检查

生成表达式后,通过 ExpressionValidator 检查:

  1. 减法是否产生负数;
  2. 除数是否为 0;
  3. 除法结果是否为真分数;
  4. 运算符数量是否超过 3 个;
  5. 表达式结构是否符合要求。

只有通过检查的表达式才能作为最终题目。

5、表达式计算设计

ExpressionCalculator 负责计算表达式树。

计算过程采用递归方式:

flowchart TD subgraph NE["NumberExpression"] A1["直接返回 Fraction"] end subgraph BE["BinaryExpression"] B1["分别计算 left 和 right"] B2["根据 Operator 执行运算"] B3["返回 Fraction"] end B1 --> B2 B2 --> B3

所有计算统一转换为 Fraction,避免分别处理整数和分数带来的复杂性。

6. 重复题判断设计

由于题目要求考虑加法和乘法的交换律,以及特定情况下的结合关系,不能直接比较两个题目的字符串。于是我们增加了 ExpressionNormalizer 模块:

flowchart TD A["Expression"] B["规范化"] C["Canonical Form"] D["Set"] A --> B B --> C C --> D

例如:

23 + 45
45 + 23

可以通过规范化得到相同的表示。

对于题目规定的结合关系,也需要在规范化过程中进行处理。最终使用集合保存已经生成题目的规范形式:

Set<String>

生成新题时:

canonical = normalize(expression)
if canonical 已存在
    重新生成
else
    加入集合

7. 文件设计

7.1 Exercises.txt

生成题目保存为:

1. 题目1
2. 题目2
3. 题目3
...

7.2 Answer.txt

根据程序生成的文件Exercises.txt,保存对应的答案:

1. 答案1
2. 答案2
3. 答案3
...

7.3 StuTest.txt

答案文件按照题目顺序保存答案。

程序假定输入的题目和答案符合规定格式。

7.4 Grade.txt

判题结果保存为:

Correct: 5 (1, 3, 5, 7, 9)
Wrong: 5 (2, 4, 6, 8, 10)

8. 命令行设计

8.1 生成模式

java -jar Arithmetic.jar -n 10 -r 10

参数:

-n 题目数量
-r 数值范围

其中 -r 为必须参数。

8.2 判题模式

java -jar Arithmetic.jar -e Exercises.txt -a StuTest.txt

参数:

-e 题目文件
-a 答案文件

程序根据参数组合自动判断当前运行模式。

9. 异常处理

程序需要对常见异常进行处理,包括:

  • 参数缺失;
  • 参数格式错误;
  • 数值范围非法;
  • 文件不存在;
  • 文件无法读取;
  • 文件无法写入;
  • 除数为 0;
  • 非法表达式。

程序应向用户输出清晰的错误信息,而不是直接输出 Java 异常堆栈。

10. 性能设计

程序需要支持一次生成最多 10000 道题目。

重复题判断使用集合保存规范化后的表达式,避免逐题与所有历史题目进行比较。

性能分析阶段重点关注:

  • 题目生成;
  • 表达式计算;
  • 表达式规范化;
  • 重复题判断;
  • 文件写入。

11. 可测试性设计

为了方便进行单元测试,我们对各核心模块进行了独立设计。其中,我们重点测试了:

  • Fraction
  • ExpressionCalculator
  • ExpressionValidator
  • ExpressionNormalizer
  • ExpressionGenerator
  • CommandLineParser
  • Grader

覆盖了正常情况、边界情况和异常情况。

12. 设计原则

本项目遵循以下原则:

  1. 单一职责:每个模块负责相对独立的功能。
  2. 模块化:将参数处理、表达式处理、题目生成、文件处理和判题分离。
  3. 数据统一:自然数和分数统一使用 Fraction 表示。
  4. 表达式结构化:使用表达式树表示算术表达式。
  5. 可测试性:核心计算和判断逻辑与文件、命令行等外部操作解耦。
  6. 可扩展性:后续可以在不大幅修改核心计算模块的情况下增加新的功能。

四、代码说明

该项目主要有7个关键代码点,具体如下表所示:

序号 代码位置 代码名称 主要作用
1 src/main/java/.../model/Fraction.java Fraction 实现分数的精确表示与四则运算
2 src/main/java/.../model/BinaryExpression.java BinaryExpression 使用表达式树表示二元运算
3 src/main/java/.../generator/ExpressionGenerator.java generate() 随机生成符合要求的表达式
4 src/main/java/.../validator/ExpressionValidator.java isValid() 检查负数、除法结果、运算符数量等约束
5 src/main/java/.../normalizer/ExpressionNormalizer.java normalize() / isEquivalent() 处理题目等价性和重复题目
6 src/main/java/.../parser/ExpressionParser.java parse() 将字符串表达式解析为表达式树
7 src/main/java/.../grader/Grader.java grade() 读取答案并统计正确、错误题目

下面分别对这些代码进行解释说明。

1、Fraction.java:分数精确计算

关键代码展示:

public final class Fraction implements Comparable<Fraction> {
    ...
    public Fraction add(Fraction other) {
        BigInteger newNumerator =
                numerator.multiply(other.denominator)
                        .add(other.numerator.multiply(denominator));

        BigInteger newDenominator = denominator.multiply(other.denominator);

        return new Fraction(newNumerator, newDenominator);
    }

    public Fraction subtract(Fraction other) {
        BigInteger newNumerator =
                numerator.multiply(other.denominator)
                        .subtract(other.numerator.multiply(denominator));

        BigInteger newDenominator = denominator.multiply(other.denominator);

        return new Fraction(newNumerator, newDenominator);
    }

    public Fraction multiply(Fraction other) {
        return new Fraction(
                numerator.multiply(other.numerator),
                denominator.multiply(other.denominator)
        );
    }

    public Fraction divide(Fraction other) {
        if (other.numerator.equals(BigInteger.ZERO)) {
            throw new ArithmeticException("被除数为0。");
        }

        return new Fraction(
                numerator.multiply(other.denominator),
                denominator.multiply(other.numerator)
        );
    }

    /**
     * 判断是否为真正的正真分数:
     * 0 < numerator < denominator
     */
    public boolean isProperFraction() {
        return numerator.signum() > 0 && numerator.compareTo(denominator) < 0;
    }

      /**
     * 按题目要求输出:
     * 3/5
     * 1'1/2
     * 2
     */
    @Override
    public String toString() {
        if (numerator.equals(BigInteger.ZERO)) {
            return "0";
        }

        if (denominator.equals(BigInteger.ONE)) {
            return numerator.toString();
        }

        if (numerator.signum() < 0) {
            return numerator + "/" + denominator;
        }

        if (numerator.compareTo(denominator) < 0) {
            return numerator + "/" + denominator;
        }

        // 假分数转换为带分数形式,如 11/4 → 2'3/4
        BigInteger[] divideAndRemainder = numerator.divideAndRemainder(denominator);

        BigInteger whole = divideAndRemainder[0];
        BigInteger remainder = divideAndRemainder[1];

        if (remainder.equals(BigInteger.ZERO)) {
            return whole.toString();
        }

        return whole + "'" + remainder + "/" + denominator;
    }
  ...
}

思路解释:

使用分子和分母表示一个分数,并在每次计算后进行约分。

例如:

1/6 + 1/8 = (1×8 + 1×6) / (6×8) = 14/48 = 7/24

项目没有使用 double 表示分数,而是采用整数形式保存分子和分母,从而避免浮点数精度误差。

这是整个项目的基础计算模块,表达式计算最终都依赖它。

2、BinaryExpression.java:表达式树

关键代码展示:

public final class BinaryExpression implements Expression {
    ...
    public BinaryExpression(Expression left, Operator operator, Expression right) {

        this.left = Objects.requireNonNull(left);
        this.operator = Objects.requireNonNull(operator);
        this.right = Objects.requireNonNull(right);
    }
    ...

    /**
     * 运算符总数 = 当前节点 1 个 + 左右子树各自的运算符数量。
     * 用于校验题目是否满足指定的运算符个数要求。
     */
    @Override
    public int getOperatorCount() {
        return 1 + left.getOperatorCount() + right.getOperatorCount();
    }

    @Override
    public String toString() {
        // 仅用于调试,不含括号
        return left + " " + operator.getSymbol() + " " + right;
    }
}

思路解释:

不直接使用字符串保存整个表达式,而是使用树形结构保存表达式的语法结构。

这样可以比较方便地实现:

  • 表达式计算;
  • 运算符数量统计;
  • 合法性检查;
  • 括号处理;
  • 表达式格式化;
  • 表达式等价判断。

这是项目中比较重要的核心数据结构设计。

3、ExpressionGenerator.java:随机生成表达式

关键代码展示:

/**
 * 批量生成指定数量的合法且不重复的习题。
 *
 * @param count 需要生成的习题数量
 * @param range 操作数的数值范围上限
 * @return 习题表达式列表
 * @throws GenerationException 当数量或范围非法,或无法生成足够多的
 *                             不重复习题时抛出
 */
public List<Expression> generate(int count, int range) {

    if (count <= 0) {
        throw new GenerationException("习题数量必须为正数。");
    }

    if (range <= 0) {
        throw new GenerationException("范围必须为正数。");
    }

    List<Expression> exercises = new ArrayList<>(count);

    // 保存已生成题目的规范形式,用于高效去重
    Set<String> normalizedExpressions = new HashSet<>(count * 2);

    /*
     * 防止在取值范围过小、无法生成足够不同题目时死循环,如-n 10000 -r 1
     */
    long maxAttempts = Math.max(10_000L, count * 500L);
    long attempts = 0;

    while (exercises.size() < count && attempts < maxAttempts) {
        attempts++;

        /*
         * 题目最多 3 个运算符,这里生成 1~3 个运算符。
         */
        int operatorCount = 1 + random.nextInt(3);
        Expression expression = expressionGenerator.generate(operatorCount,range);

        // 过滤掉违反约束的表达式
        if (!validator.isValid(expression)) {
            continue;
        }

        String normalized = ExpressionNormalizer.normalize(expression);

        // 规范形式重复则视为同一道题,跳过
        if (!normalizedExpressions.add(normalized)) {
            continue;
        }

        exercises.add(expression);
    }

    if (exercises.size() < count) {
        throw new GenerationException("不能生成 " + count + " 道唯一有效习题," + "请增大 -r。");
    }

    return exercises;
}

思路解释:

根据指定的运算符数量递归生成表达式。例如要求生成包含 2 个运算符的表达式时,程序首先随机决定运算符,然后递归生成左右子表达式,最后组合成 BinaryExpression。

生成完成后还会进行合法性检查,这样可以保证最终生成的题目符合题目要求。

4、ExpressionValidator.java:题目合法性检查

关键代码展示:

/**
 * 以布尔方式判断表达式是否合法。
 * (将校验过程中的业务异常与算术异常统一视为非法,避免调用方处理异常细节)
 *
 * @param expression 待校验的表达式
 * @return 合法返回 true,否则返回 false
 */
public boolean isValid(Expression expression) {
    try {
        validate(expression);
        return true;
    } catch (InvalidExpressionException | ArithmeticException e) {
        return false;
    }
}

思路解释:

把题目中的自然语言约束统一集中到 Validator 中,通过isValid()进行集中处理

5、ExpressionNormalizer.java:处理重复题目

关键代码展示:

/**
 * 将表达式转换为规范字符串。
 *
 * 递归处理表达式树,对满足交换律的运算进行左右子树的排序,
 * 保证数学等价的表达式得到相同的规范形式。
 *
 * @param expression 待规范化的表达式
 * @return 表达式的规范形式字符串
 */
public static String normalize(Expression expression) {

    // 叶子节点:以 N[值] 形式输出
    if (expression instanceof NumberExpression number) {
        return "N[" + number.getValue() + "]";
    }

    BinaryExpression binary = (BinaryExpression) expression;

    String left = normalize(binary.getLeft());
    String right = normalize(binary.getRight());

    // 交换律处理:保证可交换运算的左右子树顺序唯一
    if (binary.getOperator().isCommutative() && left.compareTo(right) > 0) {
        String temp = left;
        left = right;
        right = temp;
    }

    // 二元节点:以 E[运算符,左,右] 形式输出
    return "E[" + binary.getOperator().name()
            + "," + left + "," + right + "]";
}

/**
 * 判断两个表达式是否等价(即是否为重复题)。
 *
 * 通过比较二者的规范形式判断,避免直接比较表达式树的结构。
 *
 * @param first  第一个表达式
 * @param second 第二个表达式
 * @return 等价返回 true,否则返回 false
 */
public static boolean isEquivalent(Expression first, Expression second) {
    return Objects.equals(normalize(first), normalize(second));
}

思路解释:

这里是本项目中算法性比较强的一部分,采用表达式树规范化,只针对 +、× 进行规定范围内的规范化,以判断重复情况。

6、ExpressionParser.java:表达式解析

关键代码展示:

/**
 * 解析表达式字符串并返回表达式树。
 *
 * @param text 中缀表达式文本,支持 + - × ÷ * / 及括号、分数、带分数
 * @return 解析得到的表达式树
 * @throws InvalidExpressionException 表达式为空、格式非法或存在无法解析的多余字符时抛出
 */
public Expression parse(String text) {

    if (text == null || text.isBlank()) {
        throw new InvalidExpressionException(
                "表达式为空."
        );
    }

    input = text;
    position = 0;

    Expression expression = parseAddSubtract();

    skipSpaces();

    // 确保整个输入被完整消费,否则说明存在非法字符
    if (position != input.length()) {
        throw new InvalidExpressionException(
                "位置处出现未规定的字符 " + position + ": " + input.charAt(position)
        );
    }

    return expression;
}

思路解释:

通过该模块,将字符串转换成程序内部的表达式树,以实现文件中的字符串题目和程序内部表达式对象的连接。

7、Grader.java:答案批改

关键代码展示:

/**
 * 批改习题文件与答案文件。
 *
 * @param exerciseFile 习题文件路径
 * @param answerFile   答案文件路径
 * @return 批改结果
 * @throws InvalidExpressionException 题目与答案数量不一致,
 *                                    或某行格式非法时抛出
 */
public GradeResult grade(
        Path exerciseFile,
        Path answerFile) {

    // 忽略空行,避免文件末尾换行或分隔空行影响题号对应
    List<String> exerciseLines = removeBlankLines(fileManager.readLines(exerciseFile));
    List<String> answerLines = removeBlankLines(fileManager.readLines(answerFile));

    // 题目数与答案数必须一一对应,否则无法批改
    if (exerciseLines.size() != answerLines.size()) {
        throw new InvalidExpressionException(
                "习题数量与答案数量不匹配。"
        );
    }

    List<Integer> correct = new ArrayList<>();
    List<Integer> wrong = new ArrayList<>();

    for (int i = 0; i < exerciseLines.size(); i++) {
        String exerciseLine = exerciseLines.get(i);
        String answerLine = answerLines.get(i);

        Fraction expected = calculateExercise(exerciseLine);
        Fraction actual = parseAnswer(answerLine);

        // 题号从 1 开始,便于与文件中的显示编号一致
        if (expected.equals(actual)) {
            correct.add(i + 1);
        } else {
            wrong.add(i + 1);
        }
    }

    return new GradeResult(correct, wrong);
}

思路解释:

这一部分主要体现项目读取题目、解析表达式、计算正确答案、读取用户答案、比较、记录正确/错误题号、生成 Grade.txt的业务流程实现。

六、测试运行

1、测试概述

1.1 测试目的

本项目是一个基于 Java 实现的四则运算题目自动生成与批改程序。测试工作的主要目标是验证程序是否满足需求规格说明中的功能要求,并检查各模块之间的协作是否正确。

本次测试重点验证以下内容:

  • 分数的精确计算是否正确;
  • 四则运算表达式的计算是否正确;
  • 表达式合法性验证是否正确;
  • 表达式去重与等价判断是否正确;
  • 四则运算题目的随机生成是否满足要求;
  • 表达式解析是否正确;
  • 命令行参数处理是否正确;
  • 答案批改功能是否正确;
  • 文件输入输出是否正常;
  • 异常输入能否得到正确处理;
  • 程序生成大量题目时是否能够正常运行。

2、测试环境

项目 内容
操作系统 Windows 11
开发语言 Java 25
构建工具 Maven
测试框架 JUnit 5
项目类型 Java CLI
IDE IntelliJ IDEA
编译方式 Maven
测试命令 mvn test

3、测试策略

本项目采用模块化单元测试 + 集成测试 + 功能测试的方式进行测试。

3.1 单元测试

按照项目目录结构,将测试代码划分到不同模块:

src/test/java/com/Ajie8021/arithmetic/
├─ model/
│  └─ FractionTest.java
├─ calculator/
│  └─ ExpressionCalculatorTest.java
├─ validator/
│  └─ ExpressionValidatorTest.java
├─ normalizer/
│  └─ ExpressionNormalizerTest.java
├─ generator/
│  ├─ ExpressionGeneratorTest.java
│  └─ ExerciseGeneratorTest.java
├─ parser/
│  └─ ExpressionParserTest.java
├─ cli/
│  └─ CommandLineParserTest.java
└─ grader/
   └─ GraderTest.java

这种组织方式可以使每个测试类对应一个功能模块,出现问题时可以快速定位。

4、单元测试结果

4.1 Fraction 模块

测试文件:

FractionTest.java

主要测试内容:

测试内容 测试结果
分数加法 通过
分数减法 通过
分数乘法 通过
分数除法 通过
分数约分 通过
假分数转带分数 通过
带分数解析 通过
除零异常 通过
真分数判断 通过

例如:

1/6 + 1/8 = 7/24
3/4 - 1/4 = 1
2/3 × 3/4 = 1/2
1/2 ÷ 3/4 = 2/3

测试结果表明,Fraction 使用 BigInteger 保存分子和分母,可以避免浮点数计算带来的精度问题。

4.2 ExpressionCalculator 模块

测试文件:

ExpressionCalculatorTest.java

主要测试:

  • 加法;
  • 减法;
  • 乘法;
  • 除法;
  • 嵌套表达式计算。

例如:

1 + 2 = 3
3 - 1 = 2
2 × 3 = 6
1/2 ÷ 3/4 = 2/3
(1 + 2) × 3 = 9

测试结果:全部通过

4.3 ExpressionValidator 模块

测试文件:

ExpressionValidatorTest.java

主要测试:

  • 普通合法表达式;
  • 减法结果为负数;
  • 减法结果为 0;
  • 除法结果为真分数;
  • 除法结果大于等于 1;
  • 除数为 0;
  • 3 个运算符;
  • 超过 3 个运算符。

测试结果:全部通过
其中重点验证:

5 - 3

属于合法表达式,而:

3 - 5

属于非法表达式。

同时:

1 ÷ 2

合法,因为结果为真分数;

3 ÷ 2

非法,因为结果不是小于 1 的真分数。

4.4 ExpressionNormalizer 模块

测试文件:

ExpressionNormalizerTest.java

该模块重点测试题目要求中的等价表达式判断。

测试:

3 + 5
5 + 3

应当视为重复。乘法同理。

同时验证更复杂的情况:

3 + (2 + 1)
(1 + 2) + 3

应当判断为等价。

但是:

(3 + 2) + 1
(1 + 2) + 3

不能仅通过有限次交换左右子节点得到,因此不应判断为重复。

测试结果:全部通过

4.5 ExpressionGenerator 模块

测试文件:

ExpressionGeneratorTest.java

主要测试:

  • 指定 0 个运算符;
  • 指定 1 个运算符;
  • 指定 2 个运算符;
  • 指定 3 个运算符;
  • 非法运算符数量;
  • 非法数值范围。

测试结果:全部通过

4.6 ExerciseGenerator 模块

测试文件:

ExerciseGeneratorTest.java

主要验证:

  • 指定数量的题目能够正常生成;
  • 生成结果不会重复;
  • 生成的表达式满足合法性要求;
  • 运算符数量不超过 3;
  • 非法参数能够触发异常。

测试结果:全部通过

对于正常参数,例如:

-n 100 -r 20

程序能够生成 100 道题目,并通过规范化字符串检查题目是否重复。

4.7 ExpressionParser 模块

测试文件:

ExpressionParserTest.java

主要测试:

  • 整数;
  • 普通分数;
  • 带分数;
  • 运算符优先级;
  • 括号;
  • 非法表达式。

例如:

3
1/2
1'1/2
1 + 2 × 3
(1 + 2) × 3

均能够正确解析。

测试结果:全部通过

4.8 CommandLineParser 模块

测试文件:

CommandLineParserTest.java

测试内容:

测试情况 结果
正常生成参数 通过
正常批改参数 通过
缺少 -r 通过
缺少 -n 通过
未知参数 通过
生成模式和批改模式混用 通过

测试结果:全部通过

4.9 Grader 模块

测试文件:

GraderTest.java

主要测试:

  • 全部答对;
  • 部分答错;
  • 正确题号统计;
  • 错误题号统计;
  • Grade.txt 格式。

例如:

Correct: 5 (1, 3, 5, 7, 9)
Wrong: 5 (2, 4, 6, 8, 10)

测试结果:全部通过

5、功能测试

除了单元测试外,对程序整体功能进行了测试。

测试 1:生成 10 道题

运行:

java -jar arithmetic-project-1.0.0.jar -n 10 -r 20

预期:

Exercises.txt
Answers.txt

生成 10 道题目。

结果:通过

测试 2:生成大量题目

运行:

java -jar arithmetic-project-1.0.0.jar -n 10000 -r 100

预期:

  • 成功生成 10000 道题;
  • 题目编号连续;
  • 不出现重复题目;
  • 每道题最多 3 个运算符。

结果:通过

测试 3:缺少 -r

运行:

java -jar arithmetic-project-1.0.0.jar -n 10

预期:

  • 程序提示参数错误;
  • 显示帮助信息。

结果:通过

测试 4:批改答案

运行:

java -jar arithmetic-project-1.0.0.jar -e Exercises.txt -a StuText.txt

预期:

Grade.txt

生成正确题目和错误题目的统计结果。

结果:通过

6、异常测试

主要测试以下异常情况:

异常情况 处理结果
参数数量错误 正确提示
未知参数 正确提示
-n 为负数 正确拒绝
-r 为负数 正确拒绝
除数为 0 正确拒绝
减法产生负数 正确拒绝
除法结果不是真分数 正确拒绝
运算符超过 3 个 正确拒绝
文件不存在 正确抛出文件异常
无法生成足够的不重复题目 正确抛出生成异常

测试结果:全部通过

7、Maven 自动化测试结果

项目通过 Maven 执行测试:

mvn test

测试过程中,各模块测试均能够正常执行,最终测试状态为:

Tests run: 36
Failures: 0
Errors: 0
Skipped: 0

说明当前版本不存在单元测试失败和测试运行异常。

8、测试结论

经过模块单元测试、功能测试以及异常测试,当前版本的程序能够完成四则运算题目的生成、表达式验证、题目去重、表达式解析、答案计算以及答案批改等主要功能。

测试结果表明:

  • 核心 Fraction 计算模块正确;
  • 表达式计算与验证逻辑正确;
  • 题目生成能够满足基本约束;
  • 重复题目判断符合题目规定的等价关系;
  • 命令行参数能够正确处理;
  • 文件输入输出功能正常;
  • 批改功能能够正确统计正确和错误题目;
  • 异常情况能够得到处理;
  • Maven 单元测试全部通过。

因此,当前版本已经达到进入项目总结和规模度量阶段的条件。

六、任务结束PSP表格

PSP2.1 实际耗时(分钟)
Planning
· Estimate(估计这个任务需要多少时间) 30
Development
· Analysis(需求分析,包括学习新技术) 80
· Design Spec(生成设计文档) 100
· Design Review(设计复审) 30
· Coding Standard(代码规范) 30
· Design(具体设计) 120
· Coding(具体编码) 360
· Code Review(代码复审) 50
· Test(自我测试,修改代码,提交修改) 100
Reporting
· Test Report(测试报告) 50
· Size Measurement(计算工作量) 30
· Postmortem & Process Improvement Plan(事后总结, 并提出过程改进计划) 30
Total(合计) 1010

七、项目小结

1、项目收获

通过本次项目,我们认识到,一个看似简单的四则运算程序实际上涉及多个软件工程问题:

需求分析
    ↓
数据结构
    ↓
算法设计
    ↓
模块化设计
    ↓
编码
    ↓
测试
    ↓
性能分析
    ↓
度量
    ↓
项目总结

项目最大的收获之一是认识到需求分析和数据结构设计的重要性

如果一开始直接采用字符串拼接的方式生成表达式,那么随着括号、优先级、分数计算以及重复判断等需求加入,程序会越来越复杂。最终采用表达式树后,表达式计算、验证、解析、规范化等功能都可以建立在统一的数据结构之上。

2、项目做得比较好的地方

(1)编码前进行了需求和设计

项目没有直接进入编码,而是依次进行了:

需求分析
→ Design Spec
→ Coding Standard
→ Detailed Design
→ Coding

这样可以在编码之前明确主要数据结构和模块职责。

(2)使用表达式树

表达式树统一解决了:

  • 表达式表示;
  • 表达式计算;
  • 表达式验证;
  • 表达式格式化;
  • 表达式规范化;
  • 表达式解析。

降低了后续代码实现的复杂度。

(3)使用 BigInteger 进行精确分数计算

通过 BigInteger 保存分子和分母,避免使用浮点数导致的精度问题。

(4)测试按照模块划分

测试代码没有全部集中在一个测试类中,而是按照:

model
calculator
validator
normalizer
generator
parser
cli
grader

进行划分,这样可以在测试失败时快速定位具体模块。

3、PSP过程改进

通过本次项目,我们认为以后可以从以下几个方面改进:

第一,需求分析阶段建立边界条件清单

提前考虑:

-n = 0
-n < 0
-r = 0
-r < 0
除数 = 0
减法结果 < 0
除法结果 >= 1
运算符数量 > 3
题目数量过大
无法生成足够的不重复题目

减少后续反复修改。

第二,设计阶段提前确定核心数据结构

以后遇到类似项目,可以按照:

核心数据结构
    ↓
核心算法
    ↓
模块职责
    ↓
接口
    ↓
具体实现

的顺序进行设计。

第三,测试与编码同步进行

不要等到所有代码完成后才开始测试。
例如:

Fraction设计
    ↓
FractionTest
    ↓
Fraction实现

这样可以更早发现问题,修改也比较及时,后面统一测试时发现问题再改,工程量就变大了。

第四,及时记录PSP时间

下一次项目中应在每个阶段结束后立即记录开始时间、结束时间、实际耗时、遇到的问题、解决方法,这样得到的 PSP 数据会更加准确。

4、结对编程感受

本项目采用结对方式完成,两人在项目开发过程中共同完成需求分析、设计、编码、测试以及项目总结。

结对开发相比独立完成项目,需要更加重视代码结构和沟通。一个人对某个模块的理解出现问题时,可以通过讨论及时发现;同时在设计表达式树、分数计算和重复题判断等问题时,两个人共同分析也能够减少单独设计时的遗漏。而且,从项目结果来看,结对开发比较适合这种需要同时考虑需求、设计、代码和测试的软件工程项目。

我们两个都表现出了较强的责任心和认真负责的态度。对于自己负责的任务能够认真完成,遇到问题时不会逃避,而是积极寻找解决办法,并愿意和对方一起讨论问题。项目中出现问题时能够从实际情况出发进行分析,而不是简单地把问题归咎于其他环节。我感觉队友的闪光点是做事认真、有责任心,并且能够主动承担自己的任务,队友则是觉得我的编程能力不错。后续可以在任务开始前进一步做好时间规划和分工安排,这样能够提高结对开发的整体效率。

5、总结

本项目最终完成了小学四则运算题目的自动生成和批改功能,并完成了从需求分析、设计、编码、测试到项目总结的完整开发过程。

通过本次项目,我们进一步认识到,软件开发不仅仅是把程序写出来,更重要的是将自然语言需求转换成明确的规则,再通过合理的数据结构和模块划分实现,最后通过测试验证程序是否满足需求。尤其是在本项目中,表达式树、BigInteger 分数计算、表达式规范化和 Set 去重等设计,使程序能够较好地处理复杂的表达式结构和大量题目生成。

同时,本项目也暴露出一些不足,例如性能数据采集还不够完善、随机生成策略仍有优化空间、集成测试还可以进一步加强。这些问题也为后续项目提供了改进方向。在之后的软件开发中,我们将更加重视需求边界、数据结构设计、自动化测试、性能数据采集以及 PSP 过程记录。

posted @ 2026-09-21 20:27  这一剑  阅读(9)  评论(0)    收藏  举报