结对项目:四则运算表达式生成器

一. github 地址

https://github.com/Chendabaiiii/expression-generator

项目合作者:郑秀欢 3218005084, 陈锐基 3118005044

二.项目PSP表格

PSP2.1 Personal Software Process Stages 预估耗时(分钟) 实际耗时(分钟)
Planning 计划 30
Estimate 估计这个任务需要多少时间 30
Development 开发 1310
Analysis 需求分析 (包括学习新技术) 60
Design Spec 生成设计文档 0
Design Review 设计复审 (和同事审核设计文档) 0
Coding Standard 代码规范 (为目前的开发制定合适的规范) 10
Design 具体设计 180
Coding 具体编码 1000
Code Review 代码复审 30
Test 测试(自我测试,修改代码,提交修改) 30
Reporting 报告 120
Test Report 测试报告 60
Size Measurement 计算工作量 30
Postmortem & Process Improvement Plan 事后总结, 并提出过程改进计划 30
总计 1460

三.耗能测试

我们对代码的某些功能进行数量级耗能测试,可以支持10000甚至是100万条题目以及答案的生成,其中数量级和所耗时间(ms)对应的图表关系如下

程序中消耗最大的函数:

/**
 * @description: 题目数组遍历转换为题目格式(string[]) 
 * @param {Array[]} questionArr  题目数组
 * @return: 转为固定格式的题目字符串数组 例如:1. a + b + c = 
 */
let questionsToStr = questionArr => questionArr.map((expression, index) => {
  let str = expression.map(item => (typeof item === 'object') ? item.toStr() : item);
  str.unshift(`${index + 1}. `);
  return str.join('').concat(' = ');
})

四. 设计实现过程

1. 技术栈

考虑到组队成员都是前端选手,又考虑到需要操作文件,但是由于浏览器没有操作文件的能力,最终决定用基于node和前端三剑客的Electron技术开发桌面程序。

2.代码组织

由于Electron项目需要主进程和渲染进程(即展示页),并且到开发过程中需要函数可能较多,于是决定在根目录下的Utils放功能函数,并用ES6模块化进行模块管理。

1) 项目目录

App
├── node_modules 			// 依赖包
├── index.html 				// 主页面
├── main.js 				// 主进程
├── renderer 				// 渲染进程(即展示页)
│ ├── index.css      		
│ └── index.mjs
├── Class 				// 类
│ ├── Operator.mjs      		// 操作符类
│ └── Operands.mjs      		// 操作数类
├── Uitls 				// 存放功能函数
│ ├── brackets.mjs      		// 与括号相关的方法
│ ├── calculate.mjs     		// 与计算相关的方法
│ ├── questions.mjs     		// 与题目相关的方法
│ └── file.mjs         		        // 关于文件读写的方法 
│ └── index.mjs         		// 公共方法      
├── package.json 			// webpack 配置
├── package-lock.json 			// webpack 配置
├── .gitignore 				// github 推送忽略配置
├── .babelrc 				// es6 babel 配置文件
├── Answers.txt 			// 生成题目时的答案文件
├── Exercises.txt 			// 生成题目时的题目文件
├── Grade.txt 				// 校对答案时的结果文件
└── preload.js 				// 主页面

2)图形界面

在本次项目中,我们选择了图形界面的形式来实现一个四则运算表达式生成器

基本图形界面

校验错误

校验成功


3) 类的封装

①由于有分数和整数以及随机性,我们决定将操作数写为一个类,具有分子、分母和真值等属性,以及转换为字符串的方法,可以接收分子、分母和操作数范围生成实例;

②由于操作符具有优先级和随机性,我们也将操作符写为一个类,具有优先级以及转字符串的方法

将它们写成类的初衷是为了方便进行数组操作和转换操作。

Operands操作数类
import {
  gcd,
  randomNum
} from '../Utils/index.mjs';
import { rangeObj } from '../renderer/index.mjs'

// 操作数类 
export default class Operands {
  constructor({
    range = rangeObj.range, // range 是范围
    canBeZero = true, // canBeZero 代表该数字是否可以为0,由于存在作为除数的可能性不能为0
    denominator = null, // 分母 1
    numerator = null // 分子 0
  }) {
    this.range = range; // 生成范围
    this.denominator = denominator !== null ? Number(denominator) : randomNum(1, this.range - 1); // 分母
    this.numerator = numerator !== null ? Number(numerator) : randomNum(canBeZero ? 0 : 1, this.denominator * this.range - 1); // 分子
    this.value = this.numerator / this.denominator;   // 数值
  }

  // 转换为 a'b/c 格式的字符串
  toStr() {
    this.absDen = Math.abs(this.denominator);   // 取绝对值 1
    this.absNum = Math.abs(this.numerator);     // 取绝对值 0 
    this.isNegative = this.denominator * this.numerator < 0 ? '-' : '';   // 是否是负数 ''
    let integer = Math.floor(this.absNum / this.absDen); // 假分数前面的整数 0
    let numerator = this.absNum % this.absDen; // 分子 0
    let denominator = this.absDen; // 分母 1
    if (numerator === 0) return `${this.absNum / denominator}`; // 是否整除
    let gcdNum = gcd(numerator, denominator); // 求最大公约数
    return `${this.isNegative}${integer === 0 ? '' : `${integer}'`}${numerator / gcdNum}/${denominator / gcdNum}`;
  }
}
Operator操作符类
import {
  randomNum
} from '../Utils/index.mjs';
// 运算符类
export default class Operator {
  constructor(operator = ['+', '-', '×', '÷'][randomNum(0, 3)]) {
    this.operator = operator; // 操作符,默认是随机生成,也可以传入生成
    this.value = this.getValue();
  }

  // 计算运算符优先级
  getValue() {
    switch (this.operator) {
      case "+":
        return 1;
      case "-":
        return 1;
      case "×":
        return 2;
      case "÷":
        return 2;
      default:
        // 不存在该运算符 
        return 0;
    }
  }

  // 转为字符串
  toStr() {
    return ` ${this.operator} `;
  }
}

4) 函数关系

五. 代码说明

主要函数:

generateQuestions: 生成题目的主函数
/**
 * @description: 生成题目的函数
 * @param {number} total  题目个数
 * @param {number} range  参数范围
 * @return: ['表达式1','表达式2'...]
 */
export let generateQuestions = (total, range) => {
  let questionArr = []; // 题目数组
  let canBeZero = true; // 操作数是否可以为0 
  // 生成 total 个题目
  for (let i = 0; i < total; i++) {
    let operandNum = randomNum(2, 4); // 2-4个操作数
    let operatorNum = operandNum - 1; // 1-3个操作符
    let expArr = []; // 表达式数组
    for (let j = 0; j < operandNum; j++) {
      let operands = new Operands({
        range,
        canBeZero
      });
      expArr.push(operands);
      while (calculateExp(expArr).value < 0) {
        operands = new Operands({
          range,
          canBeZero
        });
        expArr.pop();
        expArr.push(operands);
      }
      if (j !== operatorNum) {
        let operator = new Operator(); // 随机生成操作符
        canBeZero = (operator.operator === '÷') ? false : true; // 如果操作符是 ÷ ,那么下一个生成数不能为 0
        expArr.push(operator);
      }
    }
    questionArr.push(expArr);
  }

  // 给数组中每一条表达式插入括号
  let insertBracketsArr = insertBrackets(questionArr);
  // 题目转为写入文件的字符串格式(无转后缀)
  let strQuestionsArr = questionsToStr(insertBracketsArr);
  // 转为写入文件格式的答案数组
  let answers = insertBracketsArr.map((exp, index) => `${index+1}. ${calculateExp(exp).toStr()}`);
  writeFile('Exercises.txt', strQuestionsArr.join('\n'));
  writeFile('Answers.txt', answers.join('\n'));
}
analyzeQuestions: 分析题目文件并生成答案,与自己的答案文件进行比对生成 Grade.txt
/**
 * @description: 分析题目文件并生成答案,与自己的答案文件进行比对生成 Grade.txt
 * @param {string} exercisefile 题目文件的路径
 * @param {string} answerfile 答案文件的路径
 */
export let analyzeQuestions = (exercisefile, answerfile) => {
  // 读取题目文件
  readFileToArr(exercisefile, (strQuestionsArr) => {
    // 解析每一个题目,并得到一个嵌套答案数组[[],[],[]]
    let realAnswersArr = strQuestionsArr.map(item => {
      let expArr = [];
      // 去除开头的 '1. '和结尾的 ' = '
      item = item.substring(item.indexOf(".") + 1, item.indexOf(" = ")).trim();
      expArr = item.split(""); // 字符串先转为数组,为了在括号旁边插入空格
      // 括号旁边插入空格
      for (let i = 0; i < expArr.length; i++) {
        if (expArr[i] === '(') {
          expArr.splice(i++ + 1, 0, " ");
        } else if (expArr[i] === ')') {
          expArr.splice(i++, 0, " ");
        }
      }
      // 通过空格隔开操作数、操作符与括号
      expArr = expArr.join("").split(" ");
      // 将字符串表达式转为对应可运算的类
      let answer = expArr.map(item => {
        if (["+", "×", "÷", "-"].indexOf(item) >= 0) {
          return new Operator(item); // 操作符
        } else if ("(" === item || ")" === item) {
          return item; // 括号
        } else {
          // 操作数,则将操作数的带分数的整数部分、分母、分子分离,并返回操作数实例
          let element = item.split(/'|\//).map(item => parseInt(item));
          switch (element.length) {
            case 1:
              // 操作数是整数
              return new Operands({
                denominator: 1, // 分母
                numerator: element[0] // 分子
              });
            case 2:
              // 操作数不是带分数的分数
              return new Operands({
                denominator: element[1],
                numerator: element[0]
              });
            case 3:
              // 操作数是带分数
              return new Operands({
                denominator: element[2],
                numerator: element[0] * element[2] + element[1]
              });
          }
        }
      })
      return calculateExp(answer)
    })
    // 将答案文件与标准答案进行比对
    compareAnswers(answerfile, realAnswersArr);
  });
}
calculateExp: 计算表达式的值
/**
 * @description: 计算表达式的值
 * @param {Object[]} expression 表达式
 * @return: Oprands 答案实例
 */
export let calculateExp = (expression) => {
  // 将中缀表达式转为后缀表达式
  let temp = []; // 临时存放
  let suffix = []; // 存放后缀表达式
  expression.forEach(item => {
    if (item instanceof Operands) {
      suffix.push(item) // 遇到操作数,压入 suffix
    } else if (item === '(') {
      temp.push(item); // 遇到左括号,压入 temp
    } else if (item === ')') {
      // 遇到右括号
      while (temp[temp.length - 1] !== '(') {
        suffix.push(temp.pop());
      }
      temp.pop(); // 弹出左括号
    } else if (item instanceof Operator) {
      // 运算符
      // 如果栈顶是运算符,且栈顶运算符的优先级大于或等于该运算符
      while (temp.length !== 0 &&
        temp[temp.length - 1] instanceof Operator &&
        temp[temp.length - 1].value >= item.value) {
        suffix.push(temp.pop());
      }
      // 是空栈或者栈顶是左括号亦或是栈顶优先级低,则直接入栈到 temp
      temp.push(item);
    }
  });
  while (temp.length !== 0) {
    suffix.push(temp.pop());
  }
  // 以下过程将后缀表达式计算成答案并转为 Oprands 实例
  const {
    addOperands,
    subOperands,
    multOperands,
    divOperands
  } = Arithmetic; // 四则运算方法
  let answerStack = []; // 存放运算结果
  suffix.forEach(item => {
    if (item instanceof Operands) {
      answerStack.push(item); // 如果是操作数则推入
    } else {
      // 是操作符则弹出最顶出的两个操作数进行运算
      let b = answerStack.pop();
      let a = answerStack.pop();
      let result = null;
      switch (item.operator) {
        case '+':
          result = addOperands(a, b);
          break;
        case '-':
          result = subOperands(a, b);
          break;
        case '×':
          result = multOperands(a, b);
          break;
        case '÷':
          result = divOperands(a, b);
          break;
        default:
          break;
      }
      answerStack.push(result);
    }
  })
  return answerStack.pop();
}
insertBrackets: 给问题数组的每个表达式插入括号的主要函数
/**
 * @description: 给问题数组的每个表达式插入括号的主要函数
 * @param {Array[]} questionArr 表达式未插入括号的问题数组
 * @return: 表达式插入括号后的问题数组
 */
export let insertBrackets = (questionArr) => {
  return questionArr.map((item, index) => {
    let bracketsNum = 0; // 括号对的数目
    switch (item.length) {
      case 5:
        bracketsNum = randomNum(0, 1); // 3个操作数则最多1对括号
        break;
      case 7:
        bracketsNum = randomNum(0, 2); // 4个操作数则最多2对括号
        break;
      default:
        break;
    }
    // 将原来 item 项(即一个表达式)随机插入 bracketsNum 对括号
    let newItem = item;
    while (bracketsNum--) {
      newItem = randomInsertBrackets(newItem);
    }
    return newItem; 
  });
}

六、测试运行

  1. 先生成100个题目

  2. 上传两个文件进行校对

  3. 校对结果如下

  4. 修改正确答案

  5. 再次进行校对

从图例可知,修改过答案的题号已被统计出来

七、PSP表格

PSP2.1 Personal Software Process Stages 预估耗时(分钟) 实际耗时(分钟)
Planning 计划 30 15
Estimate 估计这个任务需要多少时间 30 15
Development 开发 1310 1410
Analysis 需求分析 (包括学习新技术) 60 100
Design Spec 生成设计文档 0 0
Design Review 设计复审 (和同事审核设计文档) 0 0
Coding Standard 代码规范 (为目前的开发制定合适的规范) 10 30
Design 具体设计 180 210
Coding 具体编码 1000 1020
Code Review 代码复审 30 30
Test 测试(自我测试,修改代码,提交修改) 30 20
Reporting 报告 120 145
Test Report 测试报告 60 120
Size Measurement 计算工作量 30 10
Postmortem & Process Improvement Plan 事后总结, 并提出过程改进计划 30 15
总计 1460 1570

八、项目小结

我们第一次使用 Electron 技术进行开发,期间需要用到 node.js 的相关知识,由于个人比较习惯使用es6语法,但在node环境下本身不太支持es6语法,一开始在构建项目目录和配置支持es6模块化的时候耗费了一些时间。在两个人开发的项目中,由于经常需要交流沟通来开发模块,所以两个人都需要对该项目使用的技术栈有基本的了解,当其中有一个对该项目需要用到的技术比较陌生的时候,就要尽快去了解,才能够赶上项目的进度。

在本次项目中,我们遇到的一个比较难找的bug,就是在进行插入括号的时候,会导致本来不为0的子表达式被括号括起来之后值为0,从而形成了 a ÷ 0 = ∞的子表达式结果,在求最大公约数时进入了死循环,导致项目崩溃。后来在一步步排查中,才攻破了这个bug。但目前由于对去除重复的功能,我们没有更好的方法,因为暴力方法效率低耗能大,所以我们没有进行对它的开发。

结对共同感受:Code Review 的次数明显增多,能够减少很多bug的产生,同时也能学习到对方的某些骚操作和解决方法的思路。简而言之,男女搭配,干活不累。

秀欢闪光点:学习能力强,有责任感,头脑清晰,会主动地进行 Code Review,善于交流 。

锐基闪光点:打码贼六,让我学到了很多新知识以及许多骚操作。

posted @ 2020-03-30 02:21  Chendabaiiii  阅读(510)  评论(1编辑  收藏  举报