Motor de fórmula com notação polida reversa em JavaScript

As implementações existentes de mecanismos de cálculo em notação polonesa reversa, que podem ser encontradas na Internet, são boas para todos, mas não suportam funções como round (), max (arg1; arg2, ...) ou if (condição; verdadeiro; falso), o que suporta tais motores são inúteis do ponto de vista prático. Este artigo apresenta uma implementação de um mecanismo de fórmula baseado em notação polonesa reversa que oferece suporte a fórmulas semelhantes ao Excel, que são escritas em JavaScript puro em um estilo orientado a objetos.



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:



  1. 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.
  2. As funções são escritas no formato [function_name] ([argument1]; [argument2]; ...).
  3. 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.
  4. 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.
  5. 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.