JavaScriptの逆研磨表記のフォーミュラエンジン

インターネットで見つけることができる逆ポーランド表記の計算エンジンの既存の実装は、すべての人に適していますが、round()、max(arg1; arg2、...)またはif(condition; true; false)などの関数をサポートしていません。このようなエンジンは、実用的な観点からは役に立たない。この記事では、Excelのような式をサポートする逆ポーランド表記に基づく式エンジンの実装を紹介します。これは、オブジェクト指向のスタイルで純粋なJavaScriptで記述されています。



次のコードは、エンジンの機能を示しています。



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


フォーミュラエンジンのアーキテクチャについて説明する前に、いくつかの注意事項を作成する必要があります。



  1. Calculatorオブジェクトは、マップ形式のスプレッドシートセルのデータソースを引数として取ることができます。キーはA1形式のセル名であり、値は単一のトークンまたはトークンオブジェクトの配列であり、式文字列は作成時に解析されます。この例では、式でセルが使用されていないため、データソースはnullとして指定されています。
  2. 関数は[function_name]([argument1]; [argument2]; ...)の形式で記述されます。
  3. 数式を作成するときにスペースは考慮されません。数式文字列をトークンに分割する場合、すべての空白文字は事前に削除されます。
  4. 数値の10進数部分は、ポイントまたはコンマで区切ることができます。数式文字列をトークンに分割すると、10進数のポイントがポイントに変換されます。
  5. 0で除算すると、0になります。これは、0で除算できる状況で適用される計算では、関数が代入されるためです[if(divisor!= 0; dividend / divisor; 0)]


ポーランド語の表記自体に関する資料はインターネット上でかなりたくさん見つけることができるので、コードの説明からすぐに始めることをお勧めします。式エンジン自体のソースコードはでホストされているhttps://github.com/leossnet/bizcalc下MITライセンスの下で/ JS /データとが含まcalculator.jstoken.jsファイルをあなたはbizcalc.ruでビジネスですぐに計算機を試すことができます。



それでは、Typesオブジェクトに集中しているトークンのタイプから始めましょう。



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


一般的なエンジンの実装と比較して、次のタイプが追加されました。



  • セル:「セル」は、テキスト、数値、または式を含むことができるスプレッドシート内のセルの名前です。
  • 関数: "関数"-関数;
  • セミコロン: "semicolon"-関数引数の区切り文字、この場合は ";";
  • テキスト: "text"-計算エンジンによって無視されるテキスト。


他のエンジンと同様に、5つの主要なオペレーターのサポートが実装されています。



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


エンジンをテストするために、次の機能が構成されています(機能のリストは拡張できます)。



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


上記のコードはそれ自体を物語っていると思います。次に、トークンクラスのコードについて考えます。



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, '\\$&');
	}    
}


Tokenクラスは、式の行が分割された分割できないテキストユニットを格納するためのコンテナであり、各ユニットには特定の機能があります。



Tokenクラスのコンストラクターは、Typesオブジェクトのフィールドからトークンタイプを引数として受け取り、値として、式文字列から抽出された分割できないテキスト単位を取ります。

優先度の値と評価された式を格納するTokenクラスの内部プライベートフィールドは、OperatorsオブジェクトとFunctionsオブジェクトの値に基づいてコンストラクターで定義されます。



補助的な方法として、静的関数エスケープ(str)が実装されています。これは、インターネット上で最初に見つかったページから取得されたコードで、RegExpオブジェクトが特別であると認識する文字をエスケープします。



Tokenクラスで最も重要なメソッドはgetTokens静的関数です。この関数は、式の文字列を解析し、Tokenオブジェクトの配列を返します。このメソッドには小さなトリックが実装されています。トークンに分割する前に、数式で使用されない区切り文字(演算子と括弧)に「&」記号が追加され、その後「&」記号が分割されます。



getTokensメソッド自体の実装は、受信したすべてのトークンとテンプレートのループ比較であり、トークンタイプを決定し、Tokenクラスのオブジェクトを作成して、結果の配列に追加します。



これで、計算の準備に関する準備作業が完了しました。次のステップは、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));
    }
}


通常のフォーミュラエンジンと同様に、すべての計算はメイン関数calc(フォーミュラ)で実行され、フォーミュラ文字列またはトークンの既製の配列のいずれかが引数として渡されます。式文字列がcalcメソッドに渡されると、トークンの配列に事前に変換されます。



ヘルパーメソッドとして、calcExpressionメソッドが使用されます。このメソッドは、式を評価するために、オペランドスタック、演算子スタック、および最小演算子優先順位を引数として取ります。



通常のフォーミュラエンジンの拡張として、かなり単純な関数calcFunctionが実装されています。これは、関数の名前を引数として取り、この関数への任意の数の引数を取ります。 calcFunctionは、数式関数の値を評価し、数値型の新しいTokenオブジェクトを返します。



一般的な計算サイクル内で関数を計算するために、関数スタックと関数引数のマップがオペランドと演算子のスタックに追加されます。キーは関数の名前であり、値は引数の配列です。



結論として、セルとその値のハッシュの形式でデータソースを使用する方法の例を示します。まず、計算機が使用するインターフェースを実装するクラスが定義されています。

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


さて、それは簡単です。セル値を含むデータソースを作成します。次に、オペランドがセル参照である式を定義します。そして結論として、私たちは計算を行います:

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


清聴ありがとうございました。