Mesin formula dengan notasi polesan terbalik di JavaScript

Implementasi mesin kalkulasi yang ada dalam notasi Polandia terbalik, yang dapat ditemukan di Internet, baik untuk semua orang, hanya saja mereka tidak mendukung fungsi seperti round (), max (arg1; arg2, ...) atau if (condition; true; false), yang mendukung mesin seperti itu tidak berguna dari sudut pandang praktis. Artikel ini menyajikan implementasi mesin rumus menggunakan notasi Polandia terbalik yang mendukung rumus seperti Excel, yang ditulis dalam JavaScript murni dalam gaya berorientasi objek.



Kode berikut menunjukkan kemampuan mesin:



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


Sebelum mulai menjelaskan arsitektur mesin rumus, beberapa catatan harus dibuat:



  1. Objek Kalkulator dapat menggunakan argumen sebagai sumber data sel spreadsheet dalam bentuk Peta, di mana kuncinya adalah nama sel dalam format A1, dan nilainya adalah token tunggal atau larik objek token yang di dalamnya string rumus diurai saat dibuat. Dalam contoh ini, tidak ada sel yang digunakan dalam rumus, sehingga sumber data ditentukan sebagai null.
  2. Fungsi ditulis dalam format [function_name] ([argument1]; [argument2]; ...).
  3. Spasi tidak diperhitungkan saat menulis rumus - saat memisahkan string rumus menjadi token, semua karakter spasi putih dihapus sebelumnya.
  4. Bagian desimal sebuah angka dapat dipisahkan dengan titik atau koma - saat memisahkan string rumus menjadi token, titik desimal diubah menjadi titik.
  5. Pembagian dengan 0 menghasilkan 0, karena dalam perhitungan yang diterapkan dalam situasi kemungkinan pembagian dengan 0, fungsinya diganti [if (pembagi! = 0; pembilang / pembagi; 0)]


Anda dapat menemukan cukup banyak materi di Internet tentang notasi Polandia itu sendiri, jadi lebih baik segera mulai dengan menjelaskan kodenya. Kode sumber dari mesin formula itu sendiri di-host di https://github.com/leossnet/bizcalc di bawah lisensi MIT di bawah / js / data dan termasuk file kalkulator.js dan token.js . Anda dapat langsung mencoba kalkulator dalam bisnis di bizcalc.ru .



Jadi, mari kita mulai dengan jenis token yang terkonsentrasi di objek Jenis:



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


Jenis berikut telah ditambahkan dibandingkan dengan implementasi engine standar:



  • Sel: "sel" adalah nama sel dalam spreadsheet yang dapat berisi teks, angka, atau rumus;
  • Fungsi: "function" - function;
  • Titik koma: "titik koma" - pemisah argumen fungsi, dalam hal ini ";";
  • Teks: "teks" - teks yang diabaikan oleh mesin kalkulasi.


Seperti di mesin lainnya, dukungan untuk lima operator utama diterapkan:



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


Untuk menguji mesin, fungsi-fungsi berikut dikonfigurasi (daftar fungsi dapat diperluas):



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


Saya pikir kode di atas berbicara sendiri. Selanjutnya, pertimbangkan kode kelas 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, '\\$&');
	}    
}


Kelas Token adalah wadah untuk menyimpan unit teks tak terpisahkan di mana baris rumus dipecah, yang masing-masing membawa fungsionalitas tertentu.



Konstruktor kelas Token mengambil sebagai argumen tipe token dari bidang objek Tipe, dan sebagai nilai - unit teks tak terpisahkan yang diekstrak dari string rumus.

Bidang privat internal dari kelas Token yang menyimpan nilai prioritas dan ekspresi yang dievaluasi didefinisikan dalam konstruktor berdasarkan nilai dari objek Operator dan Fungsi.



Sebagai metode tambahan, fungsi escape (str) statis diimplementasikan, kode yang diambil dari halaman pertama yang ditemukan di Internet, meng-escape karakter yang dianggap istimewa oleh objek RegExp.



Metode yang paling penting dalam kelas Token adalah fungsi statis getTokens, yang mengurai string rumus dan mengembalikan larik objek Token. Trik kecil diimplementasikan dalam metode - sebelum memisahkan menjadi token, simbol "&" ditambahkan ke pemisah (operator dan tanda kurung), yang tidak digunakan dalam rumus, dan hanya kemudian pemisahan dengan simbol "&" terjadi.



Implementasi metode getTokens itu sendiri adalah perbandingan dalam loop semua token yang diterima dengan template, menentukan jenis token, membuat objek kelas Token dan menambahkannya ke array yang dihasilkan.



Ini menyelesaikan pekerjaan pendahuluan untuk menyiapkan perhitungan. Langkah selanjutnya adalah penghitungan itu sendiri, yang diimplementasikan di kelas Kalkulator:



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


Seperti pada mesin rumus biasa, semua penghitungan dilakukan dalam fungsi utama kalk (rumus), di mana string rumus atau larik token yang sudah jadi diteruskan sebagai argumen. Jika string rumus diteruskan ke metode calc, itu akan diubah sebelumnya menjadi array token.



Sebagai metode pembantu, metode calcExpression digunakan, yang mengambil argumen tumpukan operan, tumpukan operator, dan prioritas operator minimum untuk mengevaluasi ekspresi.



Sebagai perpanjangan dari mesin rumus biasa, fungsi calcFunction yang agak sederhana diimplementasikan, yang menggunakan nama fungsi sebagai argumen, serta sejumlah argumen untuk fungsi ini. CalcFunction mengevaluasi nilai fungsi rumus dan mengembalikan objek Token baru dengan tipe numerik.



Untuk menghitung fungsi dalam siklus umum penghitungan, tumpukan fungsi dan peta untuk argumen fungsi ditambahkan ke tumpukan operan dan operator, di mana kuncinya adalah nama fungsi, dan nilainya adalah array argumen.



Sebagai kesimpulan, saya akan memberikan contoh bagaimana Anda dapat menggunakan sumber data dalam bentuk hash sel dan nilainya. Untuk memulainya, didefinisikan kelas yang mengimplementasikan antarmuka yang digunakan oleh kalkulator:

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


Nah, itu sederhana. Kami membuat sumber data yang berisi nilai sel. Kemudian kami mendefinisikan rumus di mana operan adalah referensi sel. Dan sebagai kesimpulan, kami membuat perhitungan:

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


Terima kasih atas perhatian Anda.