面向物件第一單元總結
面向物件第一單元總結
第一單元的內容為表示式解析計算,主要訓練了對層次化結構的理解,和麵向物件思維的基本運用。
三次作業的設計與迭代
第一次作業
最初看到第一次作業有些不知所措:表示式計算曾在資料結構中實現過,因此第一反應純純是面向過程,用資料結構和演算法直接實現。但畢竟是面向物件課程,每一個任務都是為了更好地掌握面向物件思維與設計。看著一串串表示式,一個個整體浮現於我腦海,卻怎麼也拆分不成一個個分層次的物件。直到做完了指導書的訓練,我才恍然大悟,原來可以以符號的連線方式來分層:單一元素構成變數,乘號連線的整體是一個項,由很多因子組成,而最高層次的表示式由加減號連線的各個項組成。同時,表示式也可以成為因子居於項中,形成遞迴。
大體層次分好,接下來是分工問題。資料部分,我以指導書程式碼為原型,抽象出Factor介面,由表示式和變數類實現。如今想來,這種抽象是行為層次的抽象,表示式和變數都具有乘法、自反等行為特徵,抽象出介面後便於統一管理,為項的功能實現做好了鋪墊。功能部分我直接套用指導書方法,用專門的類去識別字符串,另一個類解析出資料物件。這樣的設計分工明確,便於實現。
在具體實現過程中還有幾個優化。第一次只有x變數,因此每一項可以化簡成a*x**b次方的形式,即可以以x的次方唯一標識一個表示式中的一項。進而以指數為key,變數物件為value便可設計出一個HashMap,便於統一管理同時便於輸出。其次關於表示式的計算化簡:化簡也是遞迴形式,表示式呼叫每個項的化簡方法,如果項裡的因子是表示式則會再呼叫表示式的化簡方法,直到遞迴到最底層(變數)便可開始往上返回。計算的難點在於實現乘法。抽象出乘法方法後便可在Term類中直接統一呼叫方法每項相乘,得出最終的物件,再以Factor的形式返回,同樣是便於統一管理。
第一次的作業完成得還是比較迷茫,因為大多數思路照搬於指導書,很多精妙之處在後面的開發中才逐漸想通。這次簡單功能的實現算是面向物件思想的初步建立,瞭解了怎樣將終極目標按照層次逐步縮小,分離出能很輕易實現的物件,物件也有自己的屬性和方法,能完成自己的職責。
-
Lexer: 負責解析輸入字串,將每一個元素解析出來供Parser物件進一步解析。
-
Parser: 分層次解析Expr, Term, Factor的物件,構建資料間的關係。
-
Factor: Expr和Variable的共同介面,從行為層次抽象,便於Term中統一管理。
-
Expr: 表示式類,處於最高層次,同時可以降到最低層次進行遞迴。
-
Term: 由乘號連線,處於中間層次。
-
Variable: 只有係數與指數屬性,處於最低層次,是最基本的物件。
-
MainClass: 主類,控制流程,同時負責輸入輸出功能(設計不當,違背了單一職責原則)。
資料層次的劃分比較清晰,實現關係與聚合關係使用合理,但Lexer類完全可以放在Parser類中,因為解析表示式時,一個Parser是唯一使用一個對應Lexer物件的,這也是Lexer類的唯一作用。這兩個類可以形成組合關係,連線更緊密,這一點當初未能考慮到。
第二次作業
第二次作業對我整體設計的衝擊十分大,sin和cos的加入完全否認了我之前為了偷懶建立的以x係數為key,Variable為物件的簡單模式,不得不進一步複雜化Term的屬性和方法,而這也很大程度上違背了開閉原則,屬於某種程度上的重構了。考慮到sin和cos與x的相似性,即都有係數和指數,唯一的區別是前者的內部多了一部分。因此我分別實現了sin和cos類,繼承Variable。因此整體看來,資料層次的劃分基本延續了第一次作業的設計。為什麼不將sin和cos再抽象出一個公共的類呢?因為當時偷懶覺得只有兩個類分別實現也不復雜。但這種設計不僅可擴充套件性差,而且當我在具體實現時才發現分別實現sin cos導致我在建立新物件時不得不用getClass()條件判斷當前物件是sin還是cos,格外繁瑣。雖然這個問題可以用建立物件的一些設計模式解決,但我功力不夠也不敢嘗試,只能將就著了。這次設計最大的亮點在於往各個層次中加入低層次物件的同時進行合併同類項,這樣就相當於加入元素時完成了計算,能很大地優化時空複雜度。比如,向Expr中加入一個Term物件時,直接從Expr的容器中尋找是否有和當前物件equal的物件(前提是我為各個類重寫了equals方法),如果有,便進行合併,沒有便加入容器。具體實現方式如以下程式碼所示。
public void addTerm(Term term) {
for (Term thisTerm : terms) {
if (thisTerm.equals(term)) {
// 如果是同類項便將係數相加
thisTerm.setPreNum(thisTerm.getPreNum().add(term.getPreNum()));
return;
}
}
terms.add(new Term(term));
}
為了方便,我還是使用的ArrayList,迴圈遍歷查詢。後來經過反思,我認為可以直接為Term類重寫compareTo方法(畢竟已經重寫了equals方法),用HashSet儲存項,加入項時判斷集合內是否已包含當前項來決定合併還是加入集合,這樣的實現方法效率會更高。
另一個新增功能是函式的實現。其實我最初的想法也是將函式視為因子,在解析過程中進行計算後返回因子型別。但隨著更多的具體思考,我面臨一個問題:函式的定義需要建立物件,而解析時必然需要根據函式的定義來代入實參獲得計算後的物件,那麼這些函式定義物件由誰建立,又應該交給誰保管?當初我認為無論將這些物件放在哪個類裡都似乎有點超越許可權職責,進而選擇最暴力的表示式替換進行處理。但在第三次作業時,我又認真思考了這個問題,發現將函式定義物件交由Parser,即解析類,去儲存呼叫既自然又合理,因為這個物件本身需要解析表示式的成分,那麼保管一些解析所必須的物件也是職責之內。因此我在第三次作業中成功替換成了邊解析邊代入的模式。
之前提到,我這次對函式的處理是表示式替換,因此我特意實現了專門的輸入類,去處理函式的字串替換,具體實現起來也遇到較多bug,這些內容會在後面專門部分提及。
- Sin: 繼承Variable,是一種變數,區別在於內部多了一個表示式物件。
- Cos: 同Sin。
- Function: 介面,抽象出函式公共的替換實參方法,本次設計時返回全部替換後的表示式字串,便於統一管理自定義函式和求和函式。
- SelfDefined: 自定義函式,具有表示式和引數屬性,實現了Function介面
- Sum: 求和函式,實現了Function介面。
- Input: 輸入類,完成所有函式的替換功能,將字串傳遞給後續解析。
- Expr: 相較於第一次作業修改了addTerm方法,加入項時進行同類項合併,加入了各種三角函式優化方法。
- Term: 修改了addFactor方法,加入因子是進行計算。
相對第一次作業,三角函式繼承了Variable並新增了內部表示式屬性,實際意義為將三角函式和變數統一為一個層次,便於統一解析。主要缺點是為能進一步抽象出三角函式類供sin和cos繼承,導致可複用性變差。函式這一塊抽象出了函式介面,由自定義函式和求和函式實現字串替換方法。函式和輸入類構成組合關係,僅在輸入時便徹底完成所有函式替換。這種設計雖然思路直接,處理起來比較純粹,但沒有很好地體現函式個體作為因子的層次化思想,有違面向物件設計理念。
第三次作業
第三次作業對我而言十分輕鬆,因為我之前就設定sin和cos裡面的元素是個表示式,而非單純按照第二次作業的要求設定為常數或者x的次方,因此這一功能已經實現。剩下的函式遞迴呼叫問題也在我修改了邊解析邊代入的模式後順帶實現。所以第三次作業的整體架構基本等同於第二次作業,甚至程式碼量還減少了許多。主要額外處理的部分是當三角函式內部是項或者表示式因子時需要額外加括號。這裡我是稍微修改了各個資料類的toString方法,比如項如果包含多個因子就最終套上括號,表示式如果有多個項或者項toString後兩邊有括號則需要套括號。
由於第三次作業相較於第二次作業的主要變化在於函式,這裡主要解釋有關函式處理的變化。
首先有輸入類負責解析出函式名、引數個數、引數順序等資訊,創建出具體函式物件並交由Parser類保管,供解析表示式時遇到函式使用。因此,Function與Parser和Input構成聚合關係。這種設計體現出函式的個體性,將函式呼叫看做是函式本身的一個方法,返回Factor物件。
優化部分
優化是獲得性能分的關鍵,也是功能性正確後的另一重大任務。
第一次作業最終的輸出單純是一多項式,前文也提到可以設計出簡單的係數->物件的鍵值對去合併所有同類項,因此沒有什麼優化的空間。
但自從第二次作業引入了三角函式,優化便成了一座大山。
首先自然是sin(0)和cos(0)的優化,因為這兩個因子可以替換為常數,能極大地縮短長度,是不得不優化的部分。這裡值得注意的是如何進行常數替換。sin(0)為0,與其相乘的項最終都是0,因此可以直接將表示式中包含的這個項直接刪除。cos(0)是1,即在項的乘法中沒有貢獻,因此可在項中將cos(0)給移出。刪除便帶來問題:java中for-each語句不支援正常刪除,如果強行刪除並繼續遍歷會帶來越界問題。解決這個問題的方法有兩種,一種是使用迭代器模式進行遍歷,一種是使用迴圈呼叫這個優化方法的方式不斷尋找可能的優化,找到一個刪除後便返回,直到找不到存在的優化。第二種方式實現如下。
boolean flag = true;
while (flag) {
flag = sin0AndCos0();
}
其次是sin**2+cos**2的優化,兩者相加等於1,也能極大地縮短長度。這個優化相對困難一點,因為需要兩重迴圈比對錶達式中的項,還要迴圈判斷兩個項的其他因子是否完全一樣。我的第一想法是尋找存在的sin**2,為其匹配對應的cos**2,但是這樣的簡單想法無法合併高於2次的可能合併的情況,比如,sin**2*cos+cos**3。厲害的朋友告訴我可以尋找sin的任何高於2次的項,將其係數減2後以新的項去匹配其他cos係數減2的項,這樣的方法能很好地解決高次項的合併問題。在表示式中,可以迴圈呼叫這個優化方法,直到沒有優化的空間,便退出。但這種隨便找兩個就合併的貪心模式不一定能達到最優解,原因在於優化後便沒有了反悔的可能。後來考慮使用動歸,感覺類似選擇個數的問題,需要使用狀壓,用二進位制不同狀態記錄選擇了哪些項(這裡資料的大小也很適合狀壓)。然而,我總感覺這裡不具有無後效性,子狀態的最優並不一定能推到後續的最優,因為每個狀態只能記錄當前表示式,無法記錄優化順序(具體的思路也不太清晰,若有錯誤,懇請指正)。後來我還是選擇了最容易最暴力的dfs,列舉所有優化順序選擇最短結果。但是這種暴力搜尋在處理平方和的六次方展開式時便會因遞迴深度太深而難以計算出結果。考慮到狀態的重複性,我又使用記憶化的方法,暴力使用HashSet記錄訪問過的表示式toString後的狀態。若當前搜尋的表示式在HashSet中有記錄便直接返回。實現後的效果可以在1s左右計算出上述記憶化優化前計算不出的六次方平方和,有了很大的提升。其實記憶化後,面對更高次的複雜度,本身的時間消耗的增長速率相對於dfs直接的階乘複雜度也有了很大的提升。
其他諸如a-b*sin**2,a-b*cos**\2的優化類似上述平方和優化,不在此贅述。
另一個較容易的優化是sin的二倍角。我的實現方式是在一個項中迴圈遍歷sin和cos,取出最高次冪,再判斷這個項的係數能不能分解出最高次冪個2,如果可以便進行合併,反之則放棄(因為個人感覺二倍角若不能完全化簡反而會增加長度)。至於cos的二倍角實現起來相對複雜,需要考慮三種展開/合併方式,這種優化我也沒有動力去實現,而這也導致我第三次作業部分效能分的丟失。
三次作業度量分析
本次使用程式碼度量工具DesigniteJava來分析三次作業的結構。由於每次作業分析資料太多,這裡不一一展示,僅列出具有代表性的資料重點分析。
第一次作業
從資料分析可以看出,第一次作業整體程式碼量比較小,單個類的程式碼行數最多也只有79。此外,類的方法個數體量也很小,都不超過10個,平均應該在5個左右。LCOM(方法的內聚缺乏度)都小於等於0,基本滿足了方法間的高內聚低耦合。資料層次的類FANIN和FANOUT比較多,表示互相呼叫次數多,相對而言耦合性更大。
因此,整體而言,第一次作業的設計由於體量小,最終效果也中規中矩。資料層次類的耦合其實也符合本次單元作業層次化設計的理念,讓不同層次的物件之間互相呼叫。其他資料處理類和流程控制類的耦合度都為0,利於複用和擴充套件。
第二次作業
第二次作業的程式碼量很明顯遠大於第一次。這裡列出幾列關鍵資料。
Type Name | NOPM | LOC | LCOM | FANIN | FANOUT |
---|---|---|---|---|---|
Cos | 6 | 39 | 0 | 3 | 2 |
Expr | 18 | 365 | 0 | 6 | 5 |
Factor | 0 | 4 | -1 | 4 | 1 |
Sin | 6 | 39 | 0 | 3 | 2 |
Term | 16 | 223 | 0 | 3 | 6 |
Variable | 10 | 69 | 0 | 2 | 2 |
Function | 0 | 3 | -1 | 0 | 0 |
SelfDefined | 4 | 30 | 0 | 1 | 0 |
Sum | 1 | 16 | -1 | 0 | 0 |
Input | 2 | 64 | 0 | 0 | 1 |
Lexer | 3 | 46 | 0 | 0 | 0 |
MainClass | 1 | 9 | -1 | 0 | 1 |
Parser | 6 | 103 | 0 | 0 | 2 |
可以看出,某些類的程式碼量達到了幾百行,方法個數也接近20個,而產生如此冗長的類的原因是三角函式的優化。這些優化需要多重迴圈的遍歷和條件判斷,每種優化方法我又單獨提出複寫,導致每種優化方法之間雖有相似之處卻未抽象出公共方法,屬於設計缺陷。和第一次作業相同,資料層次的類之間的耦合度依舊比較高。至於圈複雜度,Expr類中的優化方法毫無懸念地居於複雜榜首,數值在13左右。其餘相對更復雜的是Parser類的因子解析方法,可能是因為具有一定的遞迴深度而且隨著因子種類增加判斷條件變多。
第三次作業
Type Name | NOM | LOC | LCOM | FANIN | FANOUT |
---|---|---|---|---|---|
Cos | 6 | 39 | 0 | 3 | 2 |
Expr | 19 | 405 | 0 | 6 | 5 |
Factor | 2 | 4 | -1 | 5 | 1 |
Sin | 6 | 39 | 0 | 3 | 2 |
Term | 16 | 260 | 0 | 3 | 6 |
Variable | 10 | 69 | 0 | 2 | 2 |
Function | 1 | 3 | -1 | 0 | 0 |
SelfDefined | 2 | 26 | 0 | 1 | 2 |
Sum | 1 | 18 | -1 | 0 | 2 |
Input | 4 | 36 | 0 | 1 | 1 |
Lexer | 6 | 67 | 0 | 4 | 0 |
MainClass | 1 | 15 | -1 | 0 | 4 |
Parser | 9 | 137 | 0 | 4 | 5 |
這次作業基本沒什麼修改,因此整體資料和第二次相差不大。由於稍微增加了一點優化,因此部分類的程式碼行數略有增加。由於應用了邊解析邊處理函式的方式,導致函式內部需要呼叫新的解析物件的方法,因此類之間的耦合度變高。圈複雜度也與上次作業相似,那些優化方法複雜度最高。
遇到的bug
自己的bug
在課下完成作業的過程中,我個人遇到了許多bug,其中始終伴隨我的是深淺克隆問題。
對錶達式層次化解析需要使用各種容器取儲存更低層次的物件,因此我在一些類中使用ArrayList去儲存。然而,在進行計算時,比如表示式的乘法,我的實現方式是兩重迴圈遍歷兩個表示式的每個項,將其中一個項的所有因子加入到另一個項的容器中(因為項的所有因子只有乘法關係,所有因子儲存於同一個容器中)。這種情況下,同一個因子會被重複加入不同項的容器中,然而真實我物理意義是這兩個項包含的因子是不同的。這樣的狀態不僅違背了設計初衷,在需要合併同類項或進行表示式優化時,修改其中一個因子更是會導致其他毫不相干的項的改變,產生致命錯誤。
意識到這一點後,我逐漸去為各個類實現深克隆,具體實現方法是建立新的構造方法,引數是需要克隆的物件,然後在構造方法中建立新的容器,將原物件容器中的每個元素再深克隆一份加入新容器中。這樣迴圈遞迴下去,直到最底層的Variable類的屬性只有兩個BigInteger,屬於不可變型別,直接等號賦值即可完成深克隆。底層完成克隆後再逐步向上返回,最終實現所需物件的深克隆。
其次,我還在課下遇到一些細節bug,比如解析正負號不符合形式化表述的要求造成runtime error,對函式的實參替換不正確,表示式替換忘加括號……但很幸運,這些bug都在我的測試或和朋友們的討論中及時發現並解決了。
然而,有些bug就沒有這麼幸運了……
在第二次作業中,房間的一位朋友銳利地找出我的一個bug:cos(0)前面有兩個符號且緊挨著的是負號會出錯。這個bug說來也巧,由兩個地方的疏忽造成。首先是我對正負號的解析順序由偏差。形式化表述明確說明cos前面不會有符號,因此,其前面的符號應該是屬於更高層次的項的符號。然而我卻將其解析給了cos這一更低層次。其實只有這一理解錯誤倒不會影響正確性,因為我的設計中,化簡後的項有標準形式,即ax**bsin...cos... ,所有的常量最終會在第一項。但巧合的是我一時沒想清楚,將對sin(0) cos(0)的優化放在了化簡前面。這意味著在優化cos(0)時我還沒有將其前面的係數放到第一項。於是我直接remove了cos(0),其係數(-1)也被我順便抹去,導致錯誤。此外,這個bug還導致我優化失敗,比如(cos(0)),cos(0)在化簡前還屬於表示式因子,優化方法檢測不到便不會對其進行替換。
第三次互測,我的作業同樣未能倖免,被一位同學hack。這次是三角函式裡的加減表示式未加括號導致format error。說來慚愧,課下迭代第三次作業只想著乘法需要加括號,忘記了對加減的處理。
他人的bug
互測中,我都是將所有人的程式碼下載到一個資料夾,用Python指令碼不斷執行尋找錯誤。但這種方式找到的bug都是很明顯的bug(因為隨機資料復現一些極端情況bug的概率很小),比如第三次中有人的三角函式裡是一個項時便會解析錯誤,丟擲異常、第一次作業中有人解析連續的正負號會出錯。更多有效的bug還是通過手動嘗試典型資料找到的,比如sum爆int,sum裡上下限是負數會因為處理時沒打括號出錯等。這些bug都是由於對專案需求分析不到位造成的,看似細節,實則重要。在實際開發中,不能忽視每一種可能遇到的狀態,要精準處理所有情況。測試方面
測試是課下必不可缺的一部分,因為完成作業後,即使通過了中測,也很難保證自己的專案有一定的正確性。這三次作業中,我分別實現了資料生成器與對拍指令碼。資料生成的方法類似作業,將表示式層層解析,從低到高toString獲得最終的表示式。後面的函式也是額外實現類,在項中加入。對拍指令碼用Python實現,用subprocess模組呼叫命令,用sympy模組判斷表示式是否等價。
# 執行java專案方法
def get_data(name, flag, main_name, expr) -> str:
class_path = "E:/MyOO/" + name + "/out/production/" + name
if flag: # 資料生成器不需要jar包,額外判斷一下
jar = ";E:/MyOO/jarLib/official_3.jar"
else:
jar = ";"
command = "java -Dfile.encoding=UTF-8 -classpath " + class_path + jar + " " + main_name
p = subprocess.run(command, input=expr, stdout=subprocess.PIPE, encoding="UTF-8")
return p.stdout
# 比較答案是否等價
my_ans = get_data("task3", True, "MainClass", expr).split('\n')[1]
other_ans = get_data("other" + str(num), True, name, expr).split('\n')[1]
if sympy.Symbol.equals(sympy.simplify(my_ans), sympy.simplify(other_ans)) == False:
print("wrong " + str(num))
# 如果錯誤則將資料備份下來
f = open(backup_path + str(num) + "_" + str(times) + "backup.txt", "w")
f.write(bak)
f.write(other_ans)
f.close()
在互測中也可以用指令碼一起比對,迴圈呼叫評測方法去發現bug。自動評測固然好,但生成的資料缺乏典型性,自己測試典型資料也是非常重要的。比如測試sum的上下界會不會爆int,連續符號的處理會不會錯,三角函式的一些優化會不會導致bug……
心得與感悟
經過第一單元的訓練,我開始習慣面向物件這門課程的節奏。但是要說對面向物件思維的理解,我還是隻觸及了皮毛。拿到一個全新的任務,我很難自己設計層級架構,很難自己為所需的類分配各自的任務。最近了解了設計模式、設計原則有關內容,更是感覺面向物件無涯,諸多方法只可嘆其妙,不知如何化為己用。還得多學多練!希望在今後單元的學習中,我能不斷深化對面向物件設計的理解,能夠自如地分解需求,讓一個個物件自動浮現在腦海!