一個運算式計算案例的設計和實現

來源:互聯網
上載者:User

作者簡介

劉源,男,軟體工程師,您可以通過yliu@guanghua.sh.cn和作者取得聯絡。

問題由來


在我做過的一個針對網路裝置和主機的資料擷取系統中,某些採集到的資料需要經過一定的計算後才儲存入庫,而不是僅僅儲存其原始值。為了提供給使用者最大的靈活性,我設想提供一個使用者介面,允許使用者輸入計算運算式(或者稱為計算公式)。這樣,除了需要遵從少量的規則,使用者可以得到最大的靈活性。

這樣的運算式具有什麼特點呢?它一般不是純的可立即計算的運算式(簡單的如:1+2*3-4)。它含有我稱為變數的元素。變數一般具有特殊的內定的文法,例如可能用"@totalmemory"表示裝置或主機(下面簡稱為裝置)的實體記憶體總數,那麼運算式"(@totalmemory-@freememory)/@totalmemory*100"就表示裝置當前記憶體使用量率百分比。如果與警示系統聯絡起來,監測此值超過80系統就發出Warning,那麼這就成為一件有意義的事情。不同種類的採集資料入庫前可能需要經過複雜度不同的計算。但顯然,最後求值的時候,必須將那些特定的變數用具體數值(即採集到的具體數值)替換,否則運算式是不可計算的。這個過程是在運行時發生的。

問題的一般性


我認為運算式計算是個一般性的話題,並且也不是一個新的話題。我們可能在多處碰到它。我在讀書的時候編寫過一個運算式的轉換和計算程式,當時作為課餘作業。我看到過一些報表系統,不管它是單獨的,還是包含在MIS系統、財務軟體中,很多都支援計算公式。我認為這些系統中的計算公式和我所遇到的問題是大致相同的。對我來說,我在資料擷取項目中遇到這個問題,下次可能還會在其他項目中遇到它。既然已經不止一次了,我希望找到一個一般性的解決方案。

一些已有的設計和實現不能滿足要求


在設計和實現出第一個版本之後,我自己感覺不很滿意。隨後我花點時間上網搜尋了一下(關鍵字:運算式 中綴 尾碼 or Expression Infix Postfix)。令人稍感失望的是,所找到的一些關於運算式的轉換、計算的程式不能滿足我的要求。不少這樣的程式僅僅支援純的可立即計算的運算式,不支援變數。而且,運算式解析和計算是耦合在一起的,很難擴充。增加新的運算子(或新的變數文法)幾乎必定要修改原始碼。在我看來,這是最大的缺陷了(實際上,當年我編寫的運算式轉換和計算的程式,雖然當時引以自豪,但是現在看來也具有同樣的缺陷)。但是,運算式的轉換和計算本身有成熟的、基於棧的的經典演算法,許多電腦書籍或教材上都有論述。人們以自然方式書寫的運算式是中綴形式的,先要把中綴運算式轉換為尾碼運算式,然後計算尾碼運算式的值。我打算仍然採用這個經典的過程和演算法。

我的設計想法和目標


既然運算式的轉換和計算的核心演算法是成熟的,我渴望把它們提取出來,去除(與解析相關的)耦合性!試想,如果事物具有相對完整的內涵和獨立性,會產生這個需要,並且也能夠通過形式上的分離和提取來把內涵給表現出來,這個過程離不開抽象。我不久意識到自己實際上在設計一個小規模的關於運算式計算的架構。

運算式要支援加減乘除運算子,這是基本的、立即想到的。或許還應該支援平方,開方(sqrt),三角運算子如sin,cos等。那麼如果還有其它怎麼辦,包括自訂的運算子?你能確定考慮完備了嗎?像自訂的運算子,是完全存在的、合理的需求。在資料擷取系統中,我一度考慮引入一個diff運算子,表明同一個累加型的採集量,在相距最近的兩次(即採集周期)採集的差值。以上的思考促使我決定,運算子的設計必須是開放的。使用者(這裡指的是使用者程式員,下同)可以擴充,增加新的運算子。

運算式中允許含有變數。對於變數的支援貫穿到運算式解析,轉換,計算的全過程。在解析階段,應該允許使用者使用適合他/她自己的變數文法,我不應該事先實現基於某種特定文法的變數識別。

由於支援可擴充的運算子,未知的變數文法,甚至連基本的數值(象123,12.3456,1.21E17)理論上也有多種類型和精度(Integer/Long/Float/Double/BigInteger/BigDecimal),這決定了無法提供一個固化的運算式解析方法。運算式解析也是需要可擴充的。最好的結果是提供一個容易使用和擴充的解析架構。對於新的運算子,新的變數文法,使用者在這個架構上擴充,以提供增強解析能力。從抽象的角度來看,我打算支援的運算式僅由四種元素組成:括弧(包括左右括弧),運算子,數值和變數。一個終端使用者給出的運算式字串,解析通過後,可能產生了一個內部表示的、便於後續處理的運算式,組成這個運算式的每個元素只能是以上四種之一。

數值


一開始我寫了一個表達數值的類,叫做Numeral。我為Numeral具體代表的是整數、浮點數還是雙精確度數而操心。從比較模糊的意義上,我希望它能表達以上任何一種類型和精度的數值。但是我也希望,它能夠明確表達出代表的具體是哪種類型和精度的數值,如果需要的話。甚至我想到Numeral最好也能表達BigInteger和BigDecimal(設想恰巧在某種場合下,我們需要解析和計算一個這樣的運算式,它允許的數值的精度和範圍很大,以至於Long或Double容納不下),否則在特別的場合下我們將遇到麻煩。在可擴充性上,數值類不大像運算子類,它應該是成熟的,因而幾乎是不需要擴充的。

經過反覆嘗試的混亂(Numeral後來又經過修改甚至重寫),我找到了一個明智的辦法。直接用java.lang.Number作為數值類(實際上這是一個介面)。我慶幸地看到,在Java中,Integer,Long,Float,Double甚至BigInteger,BigDecimal等數值類都實現了java.lang.Number(下面簡稱Number)介面,使用者把Number作為何種類型和精度來看待和使用,權利掌握在他/她的手中,我不應該提前確定數值的類型和精度。選擇由Number類來表達數值,這看來是最好的、代價最小的選擇了,並且保持了相當的靈活性。作為一個順帶的結果,Numeral類被廢棄了。

括弧


在運算式中,括弧扮演的角色是不可忽視的。它能改變運算的自然優先順序,按照使用者所希望的順序進行計算。我用Bracket類來表示括弧,這個類可以看作是final,因為它不需要擴充。括弧分作括弧和右括弧,我把它們作為Bracket類的兩個靜態執行個體變數(並且是Bracket類僅有的兩個執行個體變數)。

public class Bracket{    private String name;    private Bracket(String name) {        this.name = name;    }    public static final Bracket        LEFT_BRACKET = new Bracket("("),        RIGHT_BRACKET = new Bracket(")");    public String toString() {         return name;     }}


運算子


運算子的設計要求是開放的,這幾乎立即意味著它必須是抽象的。我一度猶豫運算子是作為介面還是抽象類別定義,最後我選擇的是抽象類別。

public abstract class Operator{    private String name;    protected Operator(String name) {        this.name = name;    }    public abstract int getDimension();    public abstract Number eval(Number[] oprands, int offset);     // throws ArithmeticException ?        public Number eval(Number[] oprands) {    return eval(oprands,0);    }    public String toString() {        return name;    }}


這個運算子的設計包含二個主介面方法。通過getDimention()介面它傳達這麼一個資訊:運算子是幾元的?即需要幾個運算元。顯然,最常見的是一元和二元運算子。這個介面方法似乎也意味著允許有多於二元的運算子,但是對於多於二元的運算子我沒有作更深入的考察。我不能十分確定基於棧的運算式的轉換和計算演算法是否完全支援二元以上的運算子。儘管有這麼一點擔憂,我還是保留目前的介面方法。

運算子最主要的介面方法就是eval(),這是運算子的計算介面,反映了運算子的本質。在這個介面方法中要把所有需要的運算元傳給它,運算子是幾元的,就需要幾個運算元,這應該是一致的。然後,執行符合運算子含義的計算,返回結果。如果增加新的運算子,使用者需要實現運算子的上述介面方法。

變數

從某種意義上說,變數就是"待定的數值"。我是否應該設計一個Variable類(或介面)?我的確這樣做了。變數什麼時候,被什麼具體數值替代,這些過程我不知道,應該留給使用者來處理。我對於變數的知識幾乎是零,因此Variable類的意義就不大了。如果繼續保留這個類/介面,還給使用者帶來一個限制,他/她必須繼承或實現Varibale類/介面,因此不久之後我丟棄了Variable。我只是聲明和堅持這麼一點:在一個運算式中,如果一個元素不是括弧,不是數值,也不是運算子,那麼我就把它作為變數看待。

運算式解析介面


運算式解析所要解決的基本問題是:對於使用者給出的運算式字串,要識別出其中的數值,運算子,括弧和變數,然後轉換成為內部的、便於後續處理的運算式形式。我提供了一個一般的運算式解析介面,如下。

public interface Parser{Object[] parse(String expr) throws IllegalExpressionException;}


在這個解析介面中我只定義了一個方法parse()。運算式串作為輸入參數,方法返回一個數組Object[]作為解析結果。如果解析通過的話,可以肯定數組中的元素或者是Number,或者Operator,或者Bracket。如果它不是以上三種之一,就把它視為變數。

也許這樣把運算式解析設計的過於一般了。因為它迴避了"如何解析"這個關鍵的問題而顯得對於使用者協助不大。運算式究竟如何解析,在我看來是一個複雜的、甚至困難的問題。

主要困難在於,無法提供一個現成的,適用於各種運算式的解析實現。請考慮,使用者可能會增加新的運算子,引入新的變數文法,甚至支援不同類型和精度的數值處理。如前面所提到的,如果能設計出一個運算式解析架構就好了,讓使用者能夠方便地在這個架構基礎上擴充。但是我沒能完全做到這一點。後面將提到已經實現的一個預設的解析器(SimpleParser)。這個預設實現試圖建立這樣一個架構,我覺得可能有一定的局限性。

中綴運算式到尾碼的轉換


這是通過一個轉換器類Converter來完成的。我能夠把轉換演算法(以及下面的計算演算法)獨立出來,讓它不依賴於運算子或變數的擴充,這得益於先前所做的基礎工作-對於運算式元素(數值,括弧,運算子和變數)的分析和抽象。演算法的基本過程是這樣的(讀者可以從網上或相關書籍中查到,我略作改動,因為支援變數的緣故):建立一個工作棧和一個輸出隊列。從左至右讀取運算式,當讀到數值或變數時,直接送至輸出隊列;當讀到運算子t時,將棧中所有優先順序高於或等於t的運算子彈出,送到輸出隊列中,然後t進棧;讀到左括弧時總是將它壓入棧中;讀到右括弧時,將靠近棧頂的第一個左括弧上面的運算子全部依次彈出,送至輸出隊列後,再丟棄左括弧。在Converter類中,核心方法convert()執行了上述的演算法,其輸入是中綴運算式,輸出是尾碼運算式,完成了轉換的過程。

public abstract class Converter {    public abstract int precedenceCompare(Operator op1, Operator op2)            throws UnknownOperatorException;    public Object[] convert(Object[] infixExpr)            throws IllegalExpressionException, UnknownOperatorException    {        return convert(infixExpr, 0, infixExpr.length);    }    public Object[] convert(Object[] infixExpr, int offset, int len)            throws IllegalExpressionException, UnknownOperatorException    {    if (infixExpr.length - offset < len)    throw new IllegalArgumentException();        // 建立一個輸出運算式,用來存放結果        ArrayList output = new ArrayList();        // 建立一個工作棧        Stack stack = new Stack();        int currInputPosition = offset;  // 當前位置(於輸入隊列)        System.out.println(" ----------- Begin conversion procedure --------------");//TEMP!        while (currInputPosition < offset + len)        {            Object currInputElement = infixExpr[currInputPosition++];            if (currInputElement instanceof Number) // 數值元素直接輸出            {                output.add(currInputElement);                System.out.println("NUMBER:"+currInputElement);//TEMP!            } else if (currInputElement instanceof Bracket) // 遇到括弧,進棧或進行匹配            {                Bracket currInputBracket = (Bracket)currInputElement;                if (currInputBracket.equals(Bracket.LEFT_BRACKET)) { // 左括弧進棧                    stack.push(currInputElement);                } else { // 右括弧,尋求匹配(左括弧)                    // 彈出所有的棧元素(運算子)直到遇到(左)括弧                    Object stackElement;                    do                    {                    if (!stack.empty())                    stackElement = stack.pop();                    else                    throw new IllegalExpressionException("bracket(s) mismatch");                    if (stackElement instanceof Bracket)                    break;                        output.add(stackElement);                        System.out.println("OPERATOR POPUP:"+stackElement);//TEMP!                    } while (true);                }            } else if (currInputElement instanceof Operator)            {                Operator currInputOperator = (Operator)currInputElement;                // 彈出所有優先順序別高於或等於當前的所有運算子                // (直到把滿足條件的全部彈出或者遇到左括弧)                while (!stack.empty()) {                    Object stackElement = stack.peek();                    if (stackElement instanceof Bracket) {                        break;// 這一定是左括弧,沒有可以彈出的了                    } else {                        Operator stackOperator = (Operator)stackElement;                        if (precedenceCompare(stackOperator, currInputOperator) >= 0) {                            // 優先順序高於等於當前的,彈出(至輸出隊列)                            stack.pop();                            output.add(stackElement);                            System.out.println("OPERATOR:"+stackElement);//TEMP!                        } else {    // 優先順序別低於當前的,沒有可以彈出的了                            break;                        }                    }                }                // 當前運算子進棧                stack.push(currInputElement);            } else //if (currInputElement instanceof Variable)                // 其它一律被認為變數,變數也直接輸出            {                output.add(currInputElement);                System.out.println("VARIABLE:"+currInputElement);//TEMP!            }        }        // 將棧中剩下的元素(運算子)彈出至輸出隊列        while (!stack.empty())        {            Object stackElement = stack.pop();            output.add(stackElement);            System.out.println("LEFT STACK OPERATOR:"+stackElement);//TEMP!        }        System.out.println("------------ End conversion procedure --------------");//TEMP!        return output.toArray();    }}


讀者可能很快注意到,Converter類不是一個具體類。既然演算法是成熟穩定的,並且我們也把它獨立出來了,為什麼Converter類不是一個穩定的具體類呢?因為在轉換過程中我發覺必須要面對一個運算子優先順序的問題,這是一個不可忽視的問題。按照慣例,如果沒有使用括弧顯式地確定計算的先後順序,那麼計算的先後順序是通過比較子的優先順序來確定的。因為我無法確定使用者在具體使用時,他/她的運算子的集合有多大,其中任意兩個運算子之間的優先順序順序是怎樣的。這個知識只能由使用者來告訴我。說錯了,告訴給Converter類,所以Converter類中提供了一個抽象的運算子比較介面precedenceCompare()由使用者來實現。

在一段時間內,我為如何檢驗運算式的有效性而困惑。我意識到,轉換通過了並不一定意味著運算式是必定合乎文法的、有效。甚至接下來成功計算出尾碼運算式的值,也並不能證明原始運算式是有效。當然,在某些轉換失敗或者計算失敗的情況下,例如運算子的元數與運算元數量不匹配,左右括弧不匹配等,則可以肯定原始運算式是無效的。但是,證明一個運算式是有效,條件要苛刻的多。遺憾的是,我沒能找到檢驗運算式有效性的理論根據。

計算尾碼運算式


這是通過一個計算機類Calculator來完成的。Calculator類的核心方法是eval(),傳給它的參數必須是尾碼運算式。在調用本方法之前,如果運算式中含有變數,那麼應該被相應的數值替換掉,否則運算式是不可計算的,將導致拋出IncalculableExpressionException異常。演算法的基本過程是這樣的:建立一個工作棧,從左至右讀取運算式,讀到數值就壓入棧中;讀到運算子就從棧中彈出N個數,計算出結果,再壓入棧中,N是該運算子的元數;重複上述過程,最後輸出棧頂的數值作為計算結果,結束。

public class Calculator{public Number eval(Object[] postfixExpr) throws IncalculableExpressionException{return eval(postfixExpr, 0, postfixExpr.length);}public Number eval(Object[] postfixExpr, int offset, int len)            throws IncalculableExpressionException{if (postfixExpr.length - offset < len)throw new IllegalArgumentException();        Stack stack = new Stack();        int currPosition = offset;        while (currPosition < offset + len)        {            Object element = postfixExpr[currPosition++];            if (element instanceof Number) {                stack.push(element);            } else if (element instanceof Operator)            {                Operator op = (Operator)element;                int dimensions = op.getDimension();                if (dimensions < 1 || stack.size() < dimensions)                    throw new IncalculableExpressionException(                        "lack operand(s) for operator '"+op+"'");                                    Number[] operands = new Number [dimensions];                for (int j = dimensions - 1; j >= 0; j--)                {                    operands[j] = (Number)stack.pop();                }                stack.push(op.eval(operands));            } else            throw new IncalculableExpressionException("Unknown element: "+element);        }        if (stack.size() != 1)            throw new IncalculableExpressionException("redundant operand(s)");                    return (Number)stack.pop();}}


預設實現


前面我主要討論了關於運算式計算的設計。一個好的設計和實現中常常包括某些預設的實現。在本案例中,我提供了基本的四則運算子的實現和一個預設的解析器實現(SimpleParser)。

運算子


實現了加減乘除四種基本運算子。

需要說明一點的是,對於每種基本運算子,當前預設實現只支援Number是Integer,Long,Float,Double的情況。並且,需要關注一下不同類型和精度的數值相運算時,結果數值的類型和精度如何確定。預設實現對此有一定的處理。

解析器


這個預設的解析器實現,我認為它是簡單的,故取名為SimpleParser。其基本思想是:把運算式看作是由括弧、數值、運算子和變數組成,每種運算式元素都可以相對獨立地進行解析,為此提供一種運算式元素解析器(ElementParser)。SimpleParser分別調用四種元素解析器,完成所有的解析工作。

ElementParser提供了運算式元素級的解析介面。四種預設的運算式元素解析器類BasicNumberParser, BasicOperatorParser, DefaultBracketParser,DefaultVariableParser均實現這個介面。

public interface ElementParser{    Object[] parse(char[] expr, int off);}


解析方法parse()輸入參數指明待解析的串,以及起始位移。返回結果中存放本次解析所得到的具體元素(Number、Operator、Bracket或者Object),以及解析的截止位移。本次的截至位移很可能就是下次解析的起始位移,如果不考慮空白符的話。

那麼在整個解析過程中,每種元素解析器何時被SimpleParser所調用?我的解決辦法是:它依次調用每種元素解析器。可以說這是一個嘗試的策略。嘗試的先後次序是有講究的,依次是:變數解析器-〉運算子解析器-〉數值解析器-〉括弧解析器。

為什麼執行這麼一種次序?從深層次上反映了我的一種憂慮。這就是:運算式的解析可能是相當複雜的。可能有這樣的運算式,對於它不能完全執行"分而治之"的解析方式,因為存在需要"整體解析"的地方。例如:考慮"diff(@TotalBytesReceived)"這樣的一個子串。使用者可能用它可能表達這樣的含義:取採集量TotalBytesReceived的前後兩次採集的差值。diff在這裡甚至不能理解成傳統意義上的運算子。最終合理的選擇很可能是,把"diff(@TotalBytesReceived)"整個視為一個變數來解析和處理。在這樣的情況下,拆開成"diff","(","@bytereceived",")"分別來解析是無意義的、錯誤的。

這就是為什麼變數解析器被最先調用,這樣做允許使用者能夠截獲、重新定義超越常規的解析方式以滿足實際需要。實際上,我安排讓變化可能性最大的部分(如變數)其解析器被最先調用,最小的部分(如括弧)其解析器被最後調用。在每一步上,如果解析成功,那麼後續的解析器就不會被調用。如果在運算式串的某個位置上,經過所有的元素解析器都不能解析,那麼該運算式就是不可解析的,將拋出IllegalExpressionException異常。

擴充實現


由於篇幅的關係,不在此通過例子討論擴充實現。這並不意味著目前沒有一個擴充實現。在前面提到的資料擷取項目中,由於基本初衷就是支援特別文法的變數,結果我實現了一個支援變數的擴充實現,並且支援了一些其他運算子,除四則運算子之外。相信讀者能夠看出,我所做的工作體現和滿足了可擴充性。而擴充性主要體現在運算子和變數上。

總結


對於運算式計算,我提出的要求有一定挑戰性,但也並不是太高。然而為了接近或達到這個目標,在設計上我頗費了一番功夫,數易其稿。前面提到,我丟棄了Numeral類,Variable類。實際上,還不止這些。我還曾經設計了Element類,運算式在內部就表示成一個數組Element[]。在Element類中,通過一個枚舉變數指明它包含的到底是什麼類型的元素(數值,括弧,運算子還是變數)。但是我嗅出這個做法不夠清晰、自然(如果追根究底,可以說不夠物件導向化),而最後丟棄了這個類。相應地,Element[]被更直接的Object[]所代替。

我的不斷改進的動力,就是力求讓設計在追求其它目標的同時保持簡潔。注意,這並不等於追求過於簡單!我希望我的努力讓我基本達到了這個目標。我除去了主要的耦合性,讓相對不變的部分-運算式轉換和計算部分獨立出來,並把變化的部分-運算子和變數,開放出來。雖然在運算式解析上我還留有遺憾,運算式的一般解析介面過於寬泛了,未能為使用者的擴充帶來實質性的協助。所幸預設解析器的實現多少有所彌補。

最後,我希望這個關於運算式計算的設計和實現能夠為他人所用和擴充。我願意看到它能經受住考驗。

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.