结对项目:四则运算表达式生成器
一. 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;
});
}
六、测试运行
-
先生成100个题目
-
上传两个文件进行校对
-
校对结果如下
-
修改正确答案
-
再次进行校对
从图例可知,修改过答案的题号已被统计出来
七、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
,善于交流 。
锐基闪光点:打码贼六,让我学到了很多新知识以及许多骚操作。