BUAA OO 第一單元總結與反思
BUAA OO 第一單元總結與反思
寫在前面
本篇部落格重點對三次作業進行需求分析,並在此基礎上給出架構方案設計、程式碼結構分析與測試思路,最後講述了自己在完成三次作業後的整體感受與體會。
儘管我在第一次作業花費了多日思考且無從下手,但也正是由於我在第一次作業中採用了助教推薦的遞迴下降演算法進行表示式的解析,並且在架構的過程中注重考慮了可拓展性,因此本單元並未進行程式碼的重構,三次作業所花費的時間也是依次遞減的。
第一次作業
需求分析
本次作業要求我們能夠讀入一個包含加、減、乘、乘方(**)以及括號(其中括號的深度至多為 1 層)的單變量表達式,表示式的規範由形式化表述進行限定,最終輸出恆等變形展開所有括號後的表示式。
在參考了許多資料,經過長時間思考後,我首先對讀入的表示式進行預處理,去除空白字元和連續符號,然後將表示式分為Expr->Term->Factors三個層級,表示式包含項,項包含因子,因子分為表示式因子和常數/x因子,而表示式因子又可以分為多個項......如此遞迴下降,直至全部分解成常數/x因子為止,即可完成解析。(細細思考一下不難發現,這樣的設計已經可以滿足多層括號巢狀的計算化簡了。)
架構方案設計
考慮到設計的層次性、可拓展性已經後續作業可能出現的各種功能需求,我採取了編譯中常見的遞迴下降演算法來作為解析表示式的核心邏輯,這一底層核心架構也貫穿了整個三次作業。
本次作業的UML類圖如下所示:
在我們的解析工作完成之後,我們的表示式樹所有的葉子節點都是數字/x因子,此時我們不難想到用一個統一的表達形式ax^b來表達一個因子,即對於每個因子由數對<a,b>來唯一確定,再考慮到合併同類項的實現,使用HashMap這一資料結構來儲存就顯得理所應當了。
輸出優化策略
- x ** 2 --> x * x
- 當存在係數為正的項時,任選其一放在第一個輸出
當我們做到以上兩點時,我相信本次作業的效能分一定能拿滿了!
程式碼結構分析
首先從類的維度來分析:
對於這三個評價標準的理解如下:
- 3:代表類的方法的平均迴圈複雜度。
- OCmax:代表類的方法的最高迴圈複雜度。
- WMC:代表類的總迴圈複雜度。
然後我們從更細分的方法維度來分析:
首先對一下表格中的評價標準進行解釋:
- ev(G):基本複雜度,是用來衡量程式非結構化程度的。
- Iv(G):模組設計複雜度,是用來衡量模組判定結構,即模組和其他模組的呼叫關係。
- v(G):模組判定結構複雜度,數量上表現為獨立路徑的條數。
- CogC:認知複雜度。
整體來看,除了類PrintAns中的printans()方法在複雜度上一騎絕塵遠遠甩開其他方法之外,整體的複雜度較為均衡且比較低,基本符合了設計的思想。下面讓我們單獨來看看這個方法。
public void printans() {
StringBuilder anss = new StringBuilder();
//第一項輸出正數
for (Integer i : ans.keySet()) {
if (ans.get(i).compareTo(BigInteger.valueOf(0)) > 0) {
if (ans.get(i).equals(BigInteger.valueOf(1))) {
if (i == 0) {
anss.append(ans.get(i)); } else if (i == 1) {
anss.append("x"); } else if (i == 2) {
anss.append("x*x"); } else if (i >= 3) {
anss.append("x**").append(i); }
} else {
anss.append(ans.get(i));
if (i == 1) {
anss.append("*x"); } else if (i == 2) {
anss.append("*x*x"); } else if (i >= 3) {
anss.append("*x**").append(i); } }
ans.remove(i);
break; } }
//
for (Integer i : ans.keySet()) {
if (!ans.get(i).equals(BigInteger.valueOf(0))) {
if (i == 0) {
anss.append("+").append(ans.get(i));
} else if (i == 1) {
if (ans.get(i).equals(BigInteger.valueOf(1))) {
anss.append("+x");
} else if (ans.get(i).equals(BigInteger.valueOf(-1))) {
anss.append("-x");
} else {
anss.append("+").append(ans.get(i)).append("*x");
}
} else if (i == 2) {
if (ans.get(i).equals(BigInteger.valueOf(1))) {
anss.append("+x*x");
} else if (ans.get(i).equals(BigInteger.valueOf(-1))) {
anss.append("-x*x");
} else {
anss.append("+").append(ans.get(i)).append("*x*x");
}
} else {
if (ans.get(i).equals(BigInteger.valueOf(1))) {
anss.append("+x**").append(i);
} else if (ans.get(i).equals(BigInteger.valueOf(-1))) {
anss.append("-x**").append(i);
} else {
anss.append("+").append(ans.get(i)).append("*x**").append(i);
}
}
}
}
if (anss.length() == 0) {
System.out.print(0);
} else {
if (anss.charAt(0) == '+') {
anss.delete(0, 1);
}
String ansss = String.valueOf(anss);
ansss = ansss.replaceAll("\\x2B-", "-");
System.out.print(ansss);
}
}
不難發現,這個方法有著較為龐大的if-else分支語句巢狀的結構,導致獨立路徑的條數多,判定結構的複雜程度就高;同時程式非結構化程度也相當高,通俗來講就是不夠”面向物件“,而過於“面向過程”,多個原因疊加導致這個方法模組認知複雜度較高。儘管我的程式碼沒有檢測出bug,但根據hank其他同學的經驗來看,這樣的部分的確是我們的“重點關注物件”,是bug出現的重災區。
對於改進辦法,我認為可以將其拆分為兩個方法,一個用來實現term內的輸出,另一個用來實現不同term之間的連線輸出。筆者在第二三次作業中就採用了這種方法,從而有效降低了這個類的複雜度。當然在我看,為了更好的優化輸出結構獲得更好的效能分數,在程式碼靜態分析的合理性上做出一定的犧牲也是具有合理性的。
測試思路與bug分析
本次作業在強側與互測中均未發現bug,同時化簡較為徹底,在強側中獲得了100分。
對自身程式的測試,首先進行模組化測試,針對不同模組的特定功能構造針對性樣例測試。然後進行整體測試,主要是使用連續的正負號,以及冪函式,表示式因子的不同構造,從而實現對程式碼整體正確性的檢測。
對於互測除上述方法之外,還應該針對該份程式碼的化簡邏輯進行閱讀與理解,找到其中可能存在的邏輯漏洞,然後構造針對性樣例進行hank。
第二次作業
需求分析
第二次作業需要在第一次作業的基礎上,完成對自定義函式、求和函式與三角函式的多項式的化簡,最終輸出恆等變形展開所有括號後的表示式。
本茨作業依然沿用了遞迴下降的解析方法,同時增加了三角函式因子類,此時再考慮到合併同類項的需求,之前的資料結構已經無法滿足需求,最終經過考量採用了 HashMap<HashMap<String, Integer>, BigInteger> 的巢狀HashMap結構來儲存資料,三個引數依次代表了因子的字串內容,因子的次數,因子所處項的係數。
架構方案設計
由於本次新增的函式限制較多,形式比較單一,因此為了方便我採用了在預處理階段檢測出自定義函式和求和函式,然後直接呼叫處理方法返回替換後、能夠符合解析要求的表示式,替換掉原先的字串。最後對預處理完成之後的表示式進行解析和計算化簡。
本次作業的UML類圖如下所示:
其實這樣的架構並不夠好,對Function、Sum兩個類的可拓展性較差,當有更復雜的需求出現時(第三次作業),這樣的架構顯然就不夠合適了。
程式碼結構分析
靜態分析的結果如下:
通過觀察筆者發現,出去calculate()這個方法的相對高複雜可以預見之外,我們主類的入口函式複雜度也相當高;經過閱讀程式碼我發現,核心的原因是我進行了較為複雜的預處理,即相對第一次新增加的檢測函式,呼叫方法並完成替換的過程,這一部分程式碼段結構較為複雜,也比較的面向過程,因此導致整個複雜度大大提升。
導致這一問題的本質原因其實就是不夠合理的架構選擇,當我在完成作業之後,看著主類裡又長又臭的大段程式碼,也很難不意識到這個問題。因此我在下一次的作業中果斷優化了架構,進而解決了這個問題。
輸出優化策略
- x ** 2 --> x * x
- 當存在係數為正的項時,任選其一放在第一個輸出
- sin(0) --> 0 cos(0) --> 1
- sin(-number) --> -sin(number) cos(-number) --> cos(number)
對於其他一系列三角函式公式化簡策略,筆者認真思考了其實現的可能性,最終沒有采用。一是因為實現的複雜性,要想覆蓋一個三角公式的所有情形是較為困難的,也許我們能輕易實現sin(x)2+cos(x)2 = 1,但諸如3xsin(x)2+2*x*cos(x)2乃至更復雜的形式則不容易完全實現;二是因為投入產出比低,有效使用概率太低,而且極有可能因小失大,導致了正確性上的謬誤。
本次作業最終強測拿到了95.1157分,個人感覺較為滿意。
測試思路與bug分析
本次作業的架構儘管不夠理想,但順利通過了強側,在互測中被 sin(0)^0 這一資料hank了一次,個人認為這是數學中的未定義行為,不應當作為合法資料,當然之前的指導書中似乎也有0^0==1的特殊規定,因此也算是一個小失誤吧。
在互測中我也成功hank他人2次,本次作業中我認為關於三角函式的化簡是極易考慮不周而出錯的,尤其是第四條化簡策略中三角因子次數為偶數時,許多同學任然保留的負號而導致錯誤。同時我也發現,採用預解析模式的同學,或是幾乎沒有優化化簡的同學,反而不容易出現bug,hank難度較高,可能這也未嘗不是一種好的策略。
第三次作業
需求分析
第三次作業中需要完成的任務為:讀入一系列自定義函式的定義以及一個包含冪函式、三角函式、自定義函式呼叫以及求和函式的表示式,輸出恆等變形展開所有括號後的表示式,同時本次沒有了巢狀括號的限制。
架構方案設計
由於第二次作業的架構缺陷,我果斷進行了架構的修改,,將Function類和Sum類也實現了Factor介面,,讓其成為與表示式因子類似的可再分解的因子,最終表示式樹的葉子節點依然全部為常數/x因子或三角函式因子。
本次作業的UML類圖如下所示:
可以明顯看出,本次作業的架構比第二次好了許多,UML圖的層次也更加清晰,更加美觀。
程式碼結構分析
靜態分析的結果如下:
與第二次結果較為相似,整個複雜度也沒有明顯提升,故不再贅述。
測試思路與bug分析
本次測試依然是採用模組化測試+整體測試的模式來進行。注意簡單樣例對新增功能的重分測試,同時亦可將前兩次強側的資料拿來再次測試,以保證我們在增量開發的過程中未導致原有功能產生bug。
儘管本次作業的架構我個人感覺比較滿意,但卻犯了一些致命的錯誤。首先是有一段程式碼本應當註釋掉我卻忘記註釋了,導致在互測中被多次觸發導致bug。同時,由於我在for迴圈的迴圈條件和迴圈中呼叫.calculate()方法(正確的做法應當是先在迴圈開始前將.calculate()的結果存入一個新的變數中,在for迴圈中使用該變數而不是頻繁呼叫.calculate() ),導致我的時間複雜度成指數增長,在強測中有三個測試點因此而TLE。
當然本次互測我也成功hank他人12次,主要的檢測點為sum函式上下界的資料範圍,測出了不少沒有用BigInteger儲存的同學,同時還有一些同學對三角函式內表示式化簡的處理存在明顯的邏輯漏洞,對於非因子的結果未加上必要的括號。
反思與反省
本來可以以一個比較完美的結果結束第一單元,我卻因為疏忽大意和不良的程式碼習慣而導致了不少丟分,如果我做了更充分的自我測試,一定能夠在提交之前發現那段沒有註釋掉的程式碼,如果我能在開發過程中及時想起老師不止一次關於不良程式碼習慣大力批判,也許就能避免TLE。看著寥寥幾行的修改就能夠完成所有這些bug的修復,我更加懊悔。不過事後平靜下來仔細想想,許多東西的確都是在付出了相當的代價之後才能深入人心。權當是對自己不夠成熟的一次懲罰吧。
心得體會
第一單元的OO之旅已經結束了,儘管從結果上看有不小的遺憾,但是從學習過程與收穫的角度上來看,至少我認為是十分豐富的。這是第一次面向物件設計與構造課程第一單元的作業,也是我第一次接觸JAVA,第一次系統學習面向物件的程式設計思想(JYP老師則認為不應該將面向物件侷限成一種程式設計思想,它應當是一種認識世界的思想)。這三週以來,每週五的晚上到週六的凌晨,我總能在自習室遇到幾個熟悉的小夥伴,似乎已經形成了一種心照不宣的默契。從第一次作業的整整兩天翻閱資料無從下手,到後來能夠理清相對清晰的架構再相對有條不紊的進行程式碼編寫,我不僅有了程式碼水平上的提升,更重要的是克服了內心的障礙,讓我對一些看似難於下手的新東西有了嘗試的勇氣與自信,這份勇氣與自信也是我面對今後更大挑戰的底氣。
希望接下來的OO之旅更加精彩,也更加順利。