O código a seguir demonstra os recursos do mecanismo:
const formula = "if( 1; round(10,2); 2*10)";
const formula1 = "round2(15.542 + 0.5)";
const formula2 = "max(2*15; 10; 20)";
const formula3 = "min(2; 10; 20)";
const formula4 = "round4(random()*10)";
const formula5 = "if ( max(0;10) ; 10*5 ; 15 ) ";
const formula6 = "sum(2*15; 10; 20)";
const calculator = new Calculator(null);
console.log(formula+" = "+calculator.calc(formula)); // if( 1; round(10,2); 2*10) = 10
console.log(formula1+" = "+calculator.calc(formula1)); // round2(15.542 + 0.5) = 16.04
console.log(formula2+" = "+calculator.calc(formula2)); // max(2*15; 10; 20) = 30
console.log(formula3+" = "+calculator.calc(formula3)); // min(2; 10; 20) = 2
console.log(formula4+" = "+calculator.calc(formula4)); // round4(random()*10) = 5.8235
console.log(formula5+" = "+calculator.calc(formula5)); // if ( max(0;10) ; 10*5 ; 15 ) = 50
console.log(formula6+" = "+calculator.calc(formula6)); // sum(2*15; 10; 20) = 60
Antes de começar a descrever a arquitetura do motor de fórmula, algumas notas devem ser feitas:
- O objeto Calculadora pode tomar como argumento uma fonte de dados de células de planilha na forma de um Mapa, em que a chave é o nome da célula no formato A1 e o valor é um único token ou uma matriz de objetos de token nos quais a string de fórmula é analisada quando é criada. Neste exemplo, nenhuma célula é usada nas fórmulas, portanto, a fonte de dados é especificada como nula.
- As funções são escritas no formato [function_name] ([argument1]; [argument2]; ...).
- Os espaços não são levados em consideração ao escrever fórmulas - ao dividir uma string de fórmula em tokens, todos os caracteres de espaço em branco são removidos antecipadamente.
- A parte decimal de um número pode ser separada por um ponto ou vírgula - ao dividir uma string de fórmula em tokens, o ponto decimal é convertido em um ponto.
- A divisão por 0 resulta em 0, pois nos cálculos aplicados em situações de possível divisão por 0, a função [if (divisor! = 0; dividendo / divisor; 0)]
Você pode encontrar muitos materiais na Internet sobre a notação polonesa em si, então é melhor começar imediatamente com a descrição do código. O código-fonte do mecanismo de fórmula em si está hospedado em https://github.com/leossnet/bizcalc sob a licença MIT na seção / js / data e inclui os arquivos calculator.js e token.js . Você pode experimentar a calculadora imediatamente no site bizcalc.ru .
Então, vamos começar com os tipos de tokens que estão concentrados no objeto Tipos:
const Types = {
Cell: "cell" ,
Number: "number" ,
Operator: "operator" ,
Function: "function",
LeftBracket: "left bracket" ,
RightBracket: "right bracket",
Semicolon: "semicolon",
Text: "text"
};
Os seguintes tipos foram adicionados em comparação com as implementações de mecanismo padrão:
- Célula: "célula" é o nome de uma célula em uma planilha que pode conter texto, um número ou uma fórmula;
- Função: "função" - função;
- Ponto e vírgula: "ponto e vírgula" - separador de argumentos de função, neste caso ";";
- Texto: "texto" - texto que é ignorado pelo mecanismo de cálculo.
Como em qualquer outro motor, o suporte para cinco operadores principais é implementado:
const Operators = {
["+"]: { priority: 1, calc: (a, b) => a + b }, //
["-"]: { priority: 1, calc: (a, b) => a - b }, //
["*"]: { priority: 2, calc: (a, b) => a * b }, //
["/"]: { priority: 2, calc: (a, b) => a / b }, //
["^"]: { priority: 3, calc: (a, b) => Math.pow(a, b) }, //
};
Para testar o motor, as seguintes funções são configuradas (a lista de funções pode ser expandida):
const Functions = {
["random"]: {priority: 4, calc: () => Math.random() }, //
["round"]: {priority: 4, calc: (a) => Math.round(a) }, //
["round1"]: {priority: 4, calc: (a) => Math.round(a * 10) / 10 },
["round2"]: {priority: 4, calc: (a) => Math.round(a * 100) / 100 },
["round3"]: {priority: 4, calc: (a) => Math.round(a * 1000) / 1000 },
["round4"]: {priority: 4, calc: (a) => Math.round(a * 10000) / 10000 },
["sum"]: {priority: 4, calc: (...args) => args.reduce( (sum, current) => sum + current, 0) },
["min"]: {priority: 4, calc: (...args) => Math.min(...args) },
["max"]: {priority: 4, calc: (...args) => Math.max(...args) },
["if"]: {priority: 4, calc: (...args) => args[0] ? args[1] : (args[2] ? args[2] : 0) }
};
Acho que o código acima fala por si. Em seguida, considere o código da classe de token:
class Token {
// "+-*/^();""
static separators = Object.keys(Operators).join("")+"();";
// "[\+\-\*\/\^\(\)\;]"
static sepPattern = `[${Token.escape(Token.separators)}]`;
// "random|round|...|sum|min|max|if"
static funcPattern = new RegExp(`${Object.keys(Functions).join("|").toLowerCase()}`, "g");
#type;
#value;
#calc;
#priority;
/**
* , ,
*
*/
constructor(type, value){
this.#type = type;
this.#value = value;
if ( type === Types.Operator ) {
this.#calc = Operators[value].calc;
this.#priority = Operators[value].priority;
}
else if ( type === Types.Function ) {
this.#calc = Functions[value].calc;
this.#priority = Functions[value].priority;
}
}
/**
*
*/
/**
*
* @param {String} formula -
*/
static getTokens(formula){
let tokens = [];
let tokenCodes = formula.replace(/\s+/g, "") //
.replace(/(?<=\d+),(?=\d+)/g, ".") // ( )
.replace(/^\-/g, "0-") // 0 "-"
.replace(/\(\-/g, "(0-") // 0 "-"
.replace(new RegExp (Token.sepPattern, "g"), "&$&&") // &
.split("&") // &
.filter(item => item != ""); //
tokenCodes.forEach(function (tokenCode){
if ( tokenCode in Operators )
tokens.push( new Token ( Types.Operator, tokenCode ));
else if ( tokenCode === "(" )
tokens.push ( new Token ( Types.LeftBracket, tokenCode ));
else if ( tokenCode === ")" )
tokens.push ( new Token ( Types.RightBracket, tokenCode ));
else if ( tokenCode === ";" )
tokens.push ( new Token ( Types.Semicolon, tokenCode ));
else if ( tokenCode.toLowerCase().match( Token.funcPattern ) !== null )
tokens.push ( new Token ( Types.Function, tokenCode.toLowerCase() ));
else if ( tokenCode.match(/^\d+[.]?\d*/g) !== null )
tokens.push ( new Token ( Types.Number, Number(tokenCode) ));
else if ( tokenCode.match(/^[A-Z]+[0-9]+/g) !== null )
tokens.push ( new Token ( Types.Cell, tokenCode ));
});
return tokens;
}
/**
*
* @param {String} str
*/
static escape(str) {
return str.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
}
}
A classe Token é um contêiner para armazenar unidades de texto indivisíveis, nas quais uma linha de fórmulas é quebrada, cada uma com uma funcionalidade específica.
O construtor da classe Token toma como argumento o tipo de token dos campos do objeto Tipos e como valor - uma unidade de texto indivisível extraída da string de fórmula.
Os campos privados internos da classe Token que armazenam o valor da prioridade e a expressão avaliada são definidos no construtor com base nos valores dos objetos Operadores e Funções.
Como método auxiliar, é implementada a função estática escape (str), o código que é obtido da primeira página encontrada na Internet, escapando caracteres que o objeto RegExp percebe como especiais.
O método mais importante na classe Token é a função estática getTokens, que analisa a string de fórmula e retorna uma matriz de objetos Token. Um pequeno truque é implementado no método - antes de dividir em tokens, o símbolo “&” é adicionado aos separadores (operadores e parênteses), que não é usado em fórmulas, e somente então o símbolo “&” é dividido.
A implementação do método getTokens em si é uma comparação em um loop de todos os tokens recebidos com modelos, determinando o tipo de token, criando um objeto da classe Token e adicionando-o ao array resultante.
Isso conclui o trabalho preliminar de preparação dos cálculos. A próxima etapa são os próprios cálculos, que são implementados na classe Calculator:
class Calculator {
#tdata;
/**
*
* @param {Map} cells ,
*/
constructor(tableData) {
this.#tdata = tableData;
}
/**
*
* @param {Array|String} formula -
*/
calc(formula){
let tokens = Array.isArray(formula) ? formula : Token.getTokens(formula);
let operators = [];
let operands = [];
let funcs = [];
let params = new Map();
tokens.forEach( token => {
switch(token.type) {
case Types.Number :
operands.push(token);
break;
case Types.Cell :
if ( this.#tdata.isNumber(token.value) ) {
operands.push(this.#tdata.getNumberToken(token));
}
else if ( this.#tdata.isFormula(token.value) ) {
let formula = this.#tdata.getTokens(token.value);
operands.push(new Token(Types.Number, this.calc(formula)));
}
else {
operands.push(new Token(Types.Number, 0));
}
break;
case Types.Function :
funcs.push(token);
params.set(token, []);
operators.push(token);
break;
case Types.Semicolon :
this.calcExpression(operands, operators, 1);
//
let funcToken = operators[operators.length-2];
//
params.get(funcToken).push(operands.pop());
break;
case Types.Operator :
this.calcExpression(operands, operators, token.priority);
operators.push(token);
break;
case Types.LeftBracket :
operators.push(token);
break;
case Types.RightBracket :
this.calcExpression(operands, operators, 1);
operators.pop();
//
if (operators.length && operators[operators.length-1].type == Types.Function ) {
//
let funcToken = operators.pop();
//
let funcArgs = params.get(funcToken);
let paramValues = [];
if ( operands.length ) {
//
funcArgs.push(operands.pop());
//
paramValues = funcArgs.map( item => item.value );
}
//
operands.push(this.calcFunction(funcToken.calc, ...paramValues));
}
break;
}
});
this.calcExpression(operands, operators, 0);
return operands.pop().value;
}
/**
* ()
* @param {Array} operands
* @param {Array} operators
* @param {Number} minPriority
*/
calcExpression (operands, operators, minPriority) {
while ( operators.length && ( operators[operators.length-1].priority ) >= minPriority ) {
let rightOperand = operands.pop().value;
let leftOperand = operands.pop().value;
let operator = operators.pop();
let result = operator.calc(leftOperand, rightOperand);
if ( isNaN(result) || !isFinite(result) ) result = 0;
operands.push(new Token ( Types.Number, result ));
}
}
/**
*
* @param {T} func -
* @param {...Number} params -
*/
calcFunction(calc, ...params) {
return new Token(Types.Number, calc(...params));
}
}
Como no mecanismo de fórmula usual, todos os cálculos são executados na função principal calc (formula), onde uma string de fórmula ou uma matriz pronta de tokens é passada como um argumento. Se uma string de fórmula for passada para o método calc, ela será pré-convertida em uma matriz de tokens.
Como um método auxiliar, o método calcExpression é usado, que leva como argumentos a pilha de operandos, a pilha de operadores e a precedência mínima do operador para avaliar a expressão.
Como uma extensão do mecanismo de fórmula usual, uma função bastante simples calcFunction é implementada, que leva o nome da função como argumentos, bem como um número arbitrário de argumentos para esta função. O calcFunction avalia o valor da função de fórmula e retorna um novo objeto Token com um tipo numérico.
Para calcular funções dentro do ciclo geral de cálculos, uma pilha de funções e um Mapa para argumentos de função são adicionados às pilhas de operandos e operadores, em que a chave é o nome da função e os valores são a matriz de argumentos.
Para concluir, darei um exemplo de como você pode usar uma fonte de dados na forma de um hash de células e seus valores. Para começar, é definida uma classe que implementa a interface que é usada pela calculadora:
class Data {
#map;
//
constructor() {
this.#map = new Map();
}
//
add(cellName, number) {
this.#map.set(cellName, number);
}
// , , Calculator.calc()
isNumber(cellName) {
return true;
}
// , Calculator.calc()
getNumberToken (token) {
return new Token (Types.Number, this.#map.get(token.value) );
}
}
Bem, então é simples. Criamos uma fonte de dados contendo valores de células. Em seguida, definimos uma fórmula na qual os operandos são referências de células. E para concluir, fazemos cálculos:
let data = new Data();
data.add("A1", 1);
data.add("A2", 1.5);
data.add("A3", 2);
let formula = "round1((A1+A2)^A3)";
let calculator = new Calculator(data);
console.log(formula+" = "+calculator.calc(formula)); // round1((A1+A2)^A3) = 6.3
Obrigado pela atenção.