Moteur de formule avec notation polonaise inversée en JavaScript

Les implémentations existantes de moteurs de calcul en notation polonaise inversée, que l'on peut trouver sur Internet, sont bonnes pour tout le monde, mais elles ne prennent pas en charge des fonctions telles que round (), max (arg1; arg2, ...) ou if (condition; true; false), ce qui de tels moteurs sont inutiles d'un point de vue pratique. L'article présente une implémentation d'un moteur de formule utilisant la notation polonaise inversée qui prend en charge les formules de type Excel, qui est écrite en JavaScript pur dans un style orienté objet.



Le code suivant illustre les capacités du moteur:



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


Avant de commencer à décrire l'architecture du moteur de formule, quelques notes doivent être prises:



  1. Calculator Map, 1, – , . , null.
  2. [_]([1]; [2]; …).
  3. – .
  4. , – .
  5. La division par 0 donne 0, car dans les calculs appliqués dans les situations de division possible par 0, la fonction est remplacée [si (diviseur! = 0; dividende / diviseur; 0)]


Vous pouvez trouver beaucoup de documents sur Internet sur la notation polonaise elle-même, il est donc préférable de commencer tout de suite par décrire le code. Le code source du moteur de formule lui-même est hébergé sur https://github.com/leossnet/bizcalc sous la licence MIT dans la section / js / data et comprend les fichiers calculator.js et token.js . Vous pouvez essayer la calculatrice tout de suite en entreprise sur bizcalc.ru .



Commençons donc par les types de jetons concentrés dans l'objet Types:



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


Les types suivants ont été ajoutés par rapport aux implémentations de moteur standard:



  • Cellule: "cellule" est le nom d'une cellule dans une feuille de calcul qui peut contenir du texte, un nombre ou une formule;
  • Fonction: "fonction" - fonction;
  • Point-virgule: "point-virgule" - séparateur d'argument de fonction, dans ce cas ";";
  • Texte: "texte" - texte ignoré par le moteur de calcul.


Comme dans tout autre moteur, la prise en charge de cinq opérateurs principaux est mise en œuvre:



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


Pour tester le moteur, les fonctions suivantes sont configurées (la liste des fonctions peut être étendue):



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


Je pense que le code ci-dessus parle de lui-même. Ensuite, considérez le code de la classe de jeton:



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 classe Token est un conteneur pour stocker des unités de texte indivisibles dans lesquelles une ligne de formules est coupée, chacune d'entre elles comportant une certaine fonctionnalité.



Le constructeur de la classe Token prend comme argument le type de jeton des champs de l'objet Types et comme valeur - une unité de texte indivisible extraite de la chaîne de formule.

Les champs privés internes de la classe Token qui stockent la valeur de la priorité et l'expression évaluée sont définis dans le constructeur en fonction des valeurs des objets Operators et Functions.



En tant que méthode auxiliaire, la fonction statique escape (str) est implémentée, le code qui est extrait de la première page trouvée sur Internet, échappant les caractères que l'objet RegExp perçoit comme spéciaux.



La méthode la plus importante de la classe Token est la fonction statique getTokens, qui analyse la chaîne de formule et renvoie un tableau d'objets Token. Une petite astuce est implémentée dans la méthode - avant de se diviser en jetons, le symbole «&» est ajouté aux séparateurs (opérateurs et parenthèses), qui n'est pas utilisé dans les formules, et alors seulement le symbole «&» est divisé.



L'implémentation de la méthode getTokens elle-même est une comparaison en boucle de tous les jetons reçus avec des modèles, déterminant le type de jeton, créant un objet de la classe Token et l'ajoutant au tableau résultant.



Ceci termine le travail préliminaire de préparation des calculs. L'étape suivante concerne les calculs eux-mêmes, qui sont implémentés dans la 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));
    }
}


Comme dans le moteur de formule habituel, tous les calculs sont effectués dans la fonction principale calc (formule), où une chaîne de formule ou un tableau prêt à l'emploi de jetons est passé en argument. Si une chaîne de formule est transmise à la méthode calc, elle est pré-convertie en un tableau de jetons.



En tant que méthode d'assistance, la méthode calcExpression est utilisée, qui prend comme arguments la pile d'opérandes, la pile d'opérateurs et la priorité minimale des opérateurs pour évaluer l'expression.



En tant qu'extension du moteur de formule habituel, une fonction assez simple calcFunction est implémentée, qui prend le nom de la fonction comme arguments, ainsi qu'un nombre arbitraire d'arguments pour cette fonction. La fonction calcFunction évalue la valeur de la fonction de formule et renvoie un nouvel objet Token avec un type numérique.



Pour calculer des fonctions dans le cycle général des calculs, une pile de fonctions et une mappe pour les arguments de fonction sont ajoutées aux piles d'opérandes et d'opérateurs, dans lesquelles la clé est le nom de la fonction et les valeurs sont le tableau d'arguments.



En conclusion, je vais donner un exemple de la façon dont vous pouvez utiliser une source de données sous la forme d'un hachage de cellules et de leurs valeurs. Tout d'abord, une classe est définie qui implémente l'interface utilisée par la calculatrice:

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


Eh bien, c'est simple. Nous créons une source de données contenant des valeurs de cellule. Ensuite, nous définissons une formule dans laquelle les opérandes sont des références de cellule. Et en conclusion, nous faisons des calculs:

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


Merci de votre attention.