BUAA面向物件課程部落格 第1彈: 簡單表示式化簡
本文是北京航空航天大學電腦科學與技術專業本科二年級課程“面向物件設計與構造”第一單元的總結部落格。作者:肖聖鵬
1 概述
本單元我使用面向物件的思想設計了一個簡單的表示式化簡程式。本文中我將從思路與實現兩個角度總結本次學習。通過閱讀本文你可以:
- 根據我的思路設計一個表示式化簡程式
- 瞭解我在實踐過程中總結的面向物件思想
- 瞭解一些Java程式設計的技巧
2 思路回顧
實戰經驗是最寶貴的資料。這一部分我將覆盤我的開發歷程,記錄我在完成作業中獲得的經驗與教訓,以及我對面向物件思想的理解逐漸加深的過程。
2.1 第一次作業:基本單變量表達式化簡
第一次作業的內容是完成對包含加法(+)、減法(-)、乘法(*)和冪運算(**)的帶括號的單變量表達式的化簡。示例如下:
[input]: -(x+1)**2+x*2
[output]: -x**2-1
這個問題足夠簡單,有很多面向過程的精妙演算法可以將其輕鬆完成。但是那不是我要在這裡討論的,我要說說如何使用面向物件的程式設計思想讓對演算法瞭解不多的人也能輕鬆解決這個問題。
首先我們思考一個更為簡單的問題:常數表示式的計算(例如-9*9+8-(1+2)**2=-82
)。這裡我學習到了一個重要的面向物件程式設計的思想就是:每個人只需完成自己的工作。
觀察常數表示式,不難發現它可以分為以下幾個層級進行計算:
- “表示式”是若干“項”加減運算的結果;
- “項”是若干“因子”乘法運算的結果;
- “因子”是常數、常數的冪或一個括號表示式。
結合上面的思想不難得出以下演算法:
/* 表示式計算演算法 */
// I. 初始化結果為 0
// II. 結果加上下一個項的值
// III. 若沒有下一項則結束(識別不到+/-)
// 否則回到 II
/* 項計算演算法 */
// I. 初始化結果為 0
// II. 結果乘上下一個因子的值
// III. 若沒有下一因子則結束(識別不到*)
// 否則回到 II
/* 因子計算演算法 */
// I. 若識別到數字,到II
// 若識別到括號,到III
// II. 識別下一個數字和指數(預設為1),返回冪運算的值
// III. 遞迴呼叫 *表示式計算演算法*
就是如此簡單的程式結構,絲毫不見任何逆波蘭表示式和棧,遞迴幫助我們完成了一切。
那麼,如何剛才的方法如何應用到含有變數x
的表示式的化簡呢?不難注意到在常數表示式計算中,很關鍵的一點就是無論是“表示式”,“項”還是“因子”都可以計算為值——一個數字。接下來再使用這個數字進行進一步的化簡工作。而對於單變量表達式,卻有x+1
這樣不能計算的存在。但是,通過面嚮物件的技術,我們可以讓x+1
、x**2-5
變得和數字同樣可以計算。這裡我學習到了第二個重要的面向物件思想:對行為的抽象讓熟悉的演算法可以運用到意想不到的資料結構上。
在常數表示式計算中,我們用整數作為化簡結果,實際只利用了它加、減、乘、冪等行為。那麼在單變數多項式的化簡中,我們只需設計標準多項式類作為化簡結果,並實現加、減、乘、冪等方法,替換掉原演算法中的整數,就可以完美地遷移原先的演算法。如果你對Java有所瞭解,一定能發現這就是介面Interface
的典型使用場景。
2.2 第二次作業:函式的加入
第二次作業在第一次作業的基礎上加入了函式作為新的因子,加入的函式有以下幾種:
- 三角函式:cos(src)|sin(src)
- 求和函式:sum(i, inf, sup, src)
- 自定義函式:f(), g()
程式首先定義一些自定義函式,然後再進行化簡,示例如下:
[input1]: 2
[input2]: f(x) = -x**2
[input3]: g(x,y,z) = (x-y)**2 + (y-z)**2 + (z-x)**2
[input4]: f(cos(x))+g(x,x,x)+sum(i, -2, 3, i*x)
[output]: -cos(x)**2 + 3 * x
如果你去本文第3部分看一看第一次作業的程式結構,你就知道本次需求的小小改動對我的程式碼有如何大的衝擊。第一次作業中,無論表示式如何複雜,其化簡結果最終都是一個標準多項式,而當三角函式引入後,帶來了以下若干問題:
- 三角函式的多樣性,多項式類與單項式類需要完全重構,以滿足對包含三角函式的表示式的表示,如
x*sin(x**2)+x**2*cos(x)
- 三角變換使得之前那樣直接得到結果變得不可能:統一餘弦化正弦的話,並不能總得到更好效果,如
cos(x)**2+sin(x)**2
、cos(x)**2
與1-cos(x)**
在連夜重構的不斷思索中,我學習到了面向物件的第三個重要思想:先構建好完整的資料結構再對其進行操作可以避免在加入新型別的資料後對整個程式進行重構。這有點像製作一個魔方一類的玩具,我們在計算(解魔方)前構建一個簡單但是完備的資料結構(一個魔方),那麼當我們就可以直接在一個簡單演算法(解一個面)的基礎上,繼續使用基本的方法(擰魔方),實現一個更復雜的演算法(解六個面)。
因此,本次作業的思路可以分為以下兩個步驟: - 將字串表示式按“表示式”、“項”和“因子”的層級構建成對應的資料結構
- 愉快地對構建好的表示式進行各種各樣的操作:加、減、乘7冪運算,化簡,代入等
構建表示式與第一次作業的方法類似,但是不需要進行化簡,只要把解析的因子、項拼接起來即可。並且引入了函式因子:形如f(x,y)
,由一個函式名和括號引數表組成。
接下來對錶達式進行化簡: 實現simplify()
方法,返回化簡後的表示式。這依然可以歸結到逐項化簡併相加,而項的化簡則可以歸結到因子的化簡與相乘。這裡的難點有二: - 因子如何化簡
- 加法與乘法如何合併項與因子
我們首先討論因子的化簡。
首先我們關注遞迴的終點:常數(2
,-990
)與變數(x
,y
),它們只需返回自身;
然後我們再考慮簡單的子表示式:直接遞迴呼叫表示式化簡;
最後是最有趣的函式化簡: - 對於三角函式,判斷是否可以轉化為常數,可以則返回常數,否則化簡運算元並返回
- 對於求和函式,將求和變數的值不斷代入求和表示式進行求和,得到一個子表示式,化簡併返回
- 對於自定義函式,將引數代入該函式定義式,得到一個子表示式,化簡併返回
然後是如何進行合併:
這裡我們用到了數學中的一個經典思想:如果兩個集合互為子集,那麼它們相等,對於兩個項也是如此,若兩個項的所有非常數因子都可以在另一個項中找到,則它們是同類項。表示式的相等判斷與項類似。而因子的相等判斷則是簡單的相等判斷:型別與屬性的相等。
2.3 第三次作業:多層函式巢狀
第三次作業允許在自定義函式的定義不包含自定義函式的情況下,進行f(f(f(x))+1,g(x))
,sum(i,-7,9,f(x))
這樣的多層函式巢狀。很容易發現這我們的第二次作業已經可以完成這一點了,這帶來了輕鬆的第三週,但以地獄般的第二週為代價。因此本次作業的收穫只有兩條重要的思想:
- 一條關於程式設計:好的面向物件程式有自己的生命
- 一條關於人生:一次達到完美並不如逐漸進步
3 程式設計
本部分給出程式結構設計與關鍵實現細節。
3.1 第一次作業
首先展示UML圖。可以清楚地看到程式分為了兩個部分,左側負責解析字串(同時進行計算),右側則完成了多項式行為的抽象,讓我們可以如同處理數字那樣處理多項式。
我們先來看看邏輯更為簡單的右側部分。多項式是單項式的聚合,多項式的加法運算是所有單項式相加,多項式的乘法運算是所有單項式交叉相乘再相加,多項式的冪類似。
現在問題的終點落到了單項式的計算上,這裡是遞迴的終點,是程式抽象度最低的點。在設計使用遞迴的程式時,我們最應該注意這裡。
在我的程式裡需要注意的一點就是單項式層級的加法與多項式層級的加法的不同。多項式層級的加法把所有多項式中所有單項式能合併的合併,不能合併的拼接;因此單項式的加法需要做的就是判斷兩個單項式是否能合併,在可以時返回合併結果。
public Monomial add(Monomial monomial) throws RuntimeException {
if (this.getExponent() != monomial.getExponent()) {
throw new RuntimeException("Monomial addition: Exponents must be same!");
}
BigInteger coefficient = this.getCoefficient().add(monomial.coefficient);
return new Monomial(coefficient, this.getExponent());
}
接下來我們再來看看解析的部分。Parser
介面的各個實現負責“表示式”、“項”和“因子”這樣抽象的句法解析,其程式碼結構與第2部分的演算法描述並無二致。而Lexer
這個類負責字串結構的具體的詞法解析,其介面模仿了輸入流。這裡附上最複雜的FactorParser
的parse()
方法的實現:
public Polynomial parse() {
Polynomial polynomial;
switch (lexer.nextType()) {
case CONSTANT:
polynomial = parseConstant(lexer.nextConstant());
break;
case VARIABLE:
lexer.nextVariable();
polynomial = parseVariable();
break;
case EXPRESSION:
polynomial = new ExprParser(lexer.nextExpression()).parse();
break;
default:
throw new RuntimeException("Unknown next factor: " + lexer.nextType());
}
if (lexer.nextType() == Lexer.Type.EXPONENT) {
polynomial = polynomial.pow(lexer.nextExponent());
}
return polynomial;
}
本次的程式碼充分利用了層級化處理與遞迴下降,較為優雅地解決了本次作業的需求,但依然存在以下不足:
- 使用的資料結構——多項式類,對輸入資料進行了壓縮,導致擴充套件性差,不能輕鬆加入新型別因子——函式
- 資料的構建與對資料的操作耦合在一起,導致各部分難以修改。在加入三角函式後,無法進行三角優化(因為三角優化需要通過嘗試進行)
-
Parser
介面的3個實現過於冗餘,完全可以合併為一個類
3.2 第二次作業
同樣首先展示UML圖。可以看出本次作業對第一次作業列出的3個不足之處進行了修改。
表示式的構建的方法通過圖片一目瞭然,由於ddl快到了,我直接開始介紹對錶達式的操作的實現細節。首先我們看看錶達式的化簡方法,依然十分簡單,複雜的部分都封裝在了add
方法裡,它會對兩個表示式裡所有項進行合併嘗試,若不能合併則拼接它。這裡運用到一個技巧:將複雜的程式碼封裝為簡單的底層操作
public Expression simplify() {
Expression result = new Expression();
for (Term term : terms) {
result = result.add(term.simplify());
}
return result;
}
public Expression add(Term term) {
if (term.onlyConst().equals(Constant.zero())) {
return this.clone();
}
Expression result = new Expression();
boolean find = false;
for (Term term1 : terms) {
if (!find && term1.merge(term) != null) {
find = true;
Term merged = term.merge(term1);
if (!merged.onlyConst().equals(Constant.zero())) {
result.append(term1.merge(term));
}
continue;
}
result.append(term1);
}
if (!find) {
result.append(term);
}
return result;
}
然後再讓我們看看函式因子是如何被化簡的。我們以sum
函式為例,可見函式的化簡封裝了對應函式的運算規則。而就sum
函式而言,使其程式碼簡潔的一點在於replace()
方法的封裝。replace()
傳入一個HashMap
引數,表示某個名字的變數需要被換成某個子表示式。它依然使用遞迴下降的方法完成,在此不做贅述。
@Override
public Expression simplify() {
if (getExp() == 0) {
return Expression.one();
}
Expression result = new Expression();
BigInteger i = inf;
while (i.compareTo(sup) <= 0) {
HashMap<String, Expression> replacement = new HashMap<>();
Expression expression = new Expression(new Constant(i));
replacement.put(target, expression);
result = result.add(this.expression.replace(replacement).simplify());
i = BigInteger.ONE.add(i);
}
return result;
}
在本次作業中,除了以上敘述的部分,我還有以下幾點收穫:
- 過載的使用:表示式可以使用相同的介面加上表達式、項或者因子
- 工廠類的例項化:
FunctionFactory
採用工廠模式建立函式,但與我以往見到的工廠不同,本次我使用了非靜態方法建立函式。因為此工廠還記錄了自定義函式定義的資訊,相當於一個數據庫。這說明工廠類的例項化是有意義的。
4 測試經驗
簡潔起見,我用一個問題概括本部分的內容:是否更測試用例,能測出更多的bug?至少在本次作業中,這個問題的答案是否定的。在遞迴下降的演算法中,可能存在的bug分為以下兩種:
- 遞迴終點處的bug
- 遞迴過程處的bug
對於第一種bug,我們只需考慮終點的測試樣例:各種因子的全覆蓋。我們可以對每一種因子的有特點的資料進行構建。
對於第二種bug,我們需要考慮遞迴呼叫的過程:加法與乘法的呼叫。需要注意的一點就是多層巢狀和兩層巢狀沒有任何區別,我們用以上的因子樣例組合成巢狀的樣例。
5 總結
迫於時間和篇幅所限,本次部落格暫且包含以上內容。我認為本文最精華的部分是各黑體字所示的程式設計思想。如果你認為它們沒什麼用,那麼你也可以試著通過我的思路設計與改進一個新的表示式化簡程式,看看有沒有更好的解決方案,那樣本文也是有意義的。