Motor de fórmulas con notación de pulido inverso en JavaScript

Las implementaciones existentes de motores de cálculo en notación polaca inversa, que se pueden encontrar en Internet, son buenas para todos, solo que no admiten funciones como round (), max (arg1; arg2, ...) o if (condition; true; false), que sí tales motores son inútiles desde un punto de vista práctico. El artículo presenta una implementación de un motor de fórmulas basado en notación polaca inversa que admite fórmulas similares a Excel, que está escrito en JavaScript puro en un estilo orientado a objetos.



El siguiente código demuestra las capacidades del motor:



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 comenzar a describir la arquitectura del motor de fórmulas, deben tomarse algunas notas:



  1. El objeto Calculadora como argumento puede tomar una fuente de datos de celdas de hoja de cálculo en forma de Mapa, en el que la clave es el nombre de la celda en formato A1 y el valor es un solo token o una matriz de objetos de token en los que se analiza la cadena de fórmula cuando se crea. En este ejemplo, no se utilizan celdas en las fórmulas, por lo que la fuente de datos se especifica como nula.
  2. Las funciones se escriben en el formato [nombre_función] ([argumento1]; [argumento2]; ...).
  3. Los espacios no se tienen en cuenta al escribir fórmulas; al dividir una cadena de fórmula en tokens, todos los espacios en blanco se eliminan de antemano.
  4. La parte decimal de un número se puede separar con un punto o una coma; al dividir una cadena de fórmula en tokens, el punto decimal se convierte en un punto.
  5. La división por 0 da como resultado 0, ya que en los cálculos aplicados en situaciones de posible división por 0, la función [si (divisor! = 0; dividendo / divisor; 0)]


Puede encontrar una gran cantidad de material en Internet sobre la notación polaca en sí, por lo que es mejor comenzar de inmediato con la descripción del código. El código fuente del motor de fórmulas en sí está alojado en https://github.com/leossnet/bizcalc bajo la licencia MIT en la sección / js / data e incluye los archivos calculator.js y token.js . Puede probar la calculadora de inmediato en el negocio en bizcalc.ru .



Entonces, comencemos con los tipos de tokens que se concentran en el objeto Tipos:



const Types = {
    Cell: "cell" ,
    Number: "number" ,
    Operator: "operator" ,
    Function: "function",
    LeftBracket: "left bracket" , 
    RightBracket: "right bracket",
    Semicolon: "semicolon",
    Text: "text"
};


En comparación con las implementaciones típicas de motores, se han agregado los siguientes tipos:



  • Celda: "celda" es el nombre de una celda en una hoja de cálculo que puede contener texto, un número o una fórmula;
  • Función: "función" - función;
  • Punto y coma: "punto y coma" - separador de argumento de función, en este caso ";";
  • Texto: "texto": texto que el motor de cálculo ignora.


Como en cualquier otro motor, se implementa el soporte para cinco operadores principales:



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 probar el motor, se configuran las siguientes funciones (la lista de funciones se puede ampliar):



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) }
};


Creo que el código anterior habla por sí solo. A continuación, considere el código de la clase 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, '\\$&');
	}    
}


La clase Token es un contenedor para almacenar unidades de texto indivisibles en las que se divide una línea de fórmulas, cada una de las cuales tiene una funcionalidad específica.



El constructor de la clase Token toma como argumento el tipo de token de los campos del objeto Tipos y, como valor, una unidad de texto indivisible extraída de la cadena de fórmulas.

Los campos privados internos de la clase Token que almacenan el valor de la prioridad y la expresión evaluada se definen en el constructor en base a los valores de los objetos Operadores y Funciones.



Como método auxiliar, se implementa la función estática escape (str), el código que se toma de la primera página encontrada en Internet, escapando caracteres que el objeto RegExp percibe como especiales.



El método más importante de la clase Token es la función estática getTokens, que analiza la cadena de fórmula y devuelve una matriz de objetos Token. Se implementa un pequeño truco en el método: antes de dividir en tokens, se agrega el símbolo "&" a los separadores (operadores y paréntesis), que no se usa en las fórmulas, y solo entonces se divide el símbolo "&".



La implementación del método getTokens en sí es una comparación de bucle de todos los tokens recibidos con plantillas, determinando el tipo de token, creando un objeto de la clase Token y agregándolo a la matriz resultante.



Esto completa el trabajo preliminar sobre la preparación de los cálculos. El siguiente paso son los cálculos en sí, que se implementan en la clase Calculadora:



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 en el motor de fórmulas habitual, todos los cálculos se realizan en la función principal calc (fórmula), donde se pasa como argumento una cadena de fórmula o una matriz de tokens ya preparada. Si se pasa una cadena de fórmula al método calc, se convierte previamente en una matriz de tokens.



Como método auxiliar, se utiliza el método calcExpression, que toma como argumentos la pila de operandos, la pila de operadores y la precedencia mínima de operadores para evaluar la expresión.



Como una extensión del motor de fórmulas habitual, se implementa una función bastante simple calcFunction, que toma el nombre de la función como argumentos, así como un número arbitrario de argumentos para esta función. CalcFunction evalúa el valor de la función de fórmula y devuelve un nuevo objeto Token con un tipo numérico.



Para calcular funciones dentro del ciclo general de cálculos, se agregan una pila de funciones y un mapa de argumentos de función a las pilas de operandos y operadores, en los que la clave es el nombre de la función y los valores son la matriz de argumentos.



En conclusión, daré un ejemplo de cómo puede utilizar una fuente de datos en forma de hash de celdas y sus valores. Para empezar, se define una clase que implementa la interfaz que utiliza la 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) );
    }
}


Bueno, entonces es simple. Creamos una fuente de datos que contiene valores de celda. Luego definimos una fórmula en la que los operandos son referencias de celda. Y en conclusión, hacemos 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


Gracias por su atención.