BUAA_2022面向物件_第一單元總結
O、寫在前面
總的來說,第一單元儘管涵蓋了面向物件這個名詞的大多數含義,但事實上其難度跨度並不大,更多的是給予我們一定的時間來適應這門課的一些特徵。根據我自己的理解,這門課最為重要的兩個特徵為:合作共贏、崇尚設計。
崇尚設計:當我們在強調層次架構的時候我們在強調什麼?其本質,還是設計。面向物件以物件為單位,與之相隨的有八大設計原則,其難點在此,但其魅力亦在此。如何減少耦合性,如何滿足單一職責、開閉原則等設計理念是至關重要的,不論是漏洞查詢,還是程式拓展,亦或是區域性重構,都需要一個靈活明瞭的架構。往往從一開始的第一次作業,就決定了本單元的艱難與否,這與架構是分不開的。
一、關於三次作業
為何對上述兩個特徵印象如此深刻,那必定是我吃過這倆的虧。在第一次作業時,嘗試採取遞迴下降的方式進行解析,但是由於“沒想好就動手”,在倒數第一天遇到de不出來的bug,四處翻找也沒能在雜亂的設計中找到一個合理的bug解釋,無奈只能臨時跳轉預解析。另外,也是犯了閉門造車的老毛病,遞迴下降的演算法沒有理解透徹,寫的時候只是根據淺顯的認識加以模仿,導致後續debug的時候沒有一個明確的思維指引。而之後,權衡其他課程的時間,終究沒能選擇重構。
1.1 儲存方式選擇
言歸正傳,既然走上預解析的不歸路,那就暫且不談解析方法,而專注於儲存方式與化簡技巧吧。下面附上最終三次作業的UML圖。
對於儲存方法,廣泛使用的有兩種:尋求一般形式、(遞迴)建樹。下面對這兩種進行詳盡的分析。
1.1.1 尋求一般形式
這對於第一次作業是再簡單不過了,沒有sin和cos的干擾,我們需要考慮的範圍僅僅是多項式環F[x]。在該範圍中,一個多項式可以僅僅用指數、係數構成的節點連結而成,於是很容易想到利用一系列帶兩個屬性:coefficient、exponent,的term來儲存整個多項式結構。對於簡單的合併操作,加法比較exponent然後操作coefficient,乘法coefficient相乘、exponent相加,幾乎沒有任何難點。
第二次作業增加了sin和cos,如果在尋求一般形式,則形式如下:
$$f(x)=a*x^b*sin^{m_1}*sin^{m_2}*...*cos^{n_1}*cos^{n_2}*...
$$
仍可以採用單個term的形式存取這一個一般形式。其優點顯而易見,即架構尤其簡單,只需要將term進行相加即可;其缺點也非常明顯,即在這一個term類中,需要實現x、sin、cos這三者的計算操作,這直接引起討論的情況數大大增加,不論是程式碼量還是出bug概率都大幅提升。
如果拿單一職責與開閉原則來比對,由於一個類實現幾乎全部計算,顯然不滿足單一職責;另外,三角函式和多項式項集合在一起,如果另加其他函式,則需要在每種分支結構中新增新的分支,顯然也不滿足開閉原則。因此,這樣的構思是完全面向問題設計,如果再繼續拓展下去,重構不可避免。
1.1.2 (遞迴)建樹
至於為什麼在遞迴外加括號,想必大家也很清楚。可以說,在第三次允許巢狀之後,遞迴儲存的方法成為“首選”方法。運算式本身就是一個多層巢狀結構,其本質,也是一個類似b+樹的結構:非葉子節點——多元運算子;葉子結點——基本運算單位。因此只要這個樹建好了,就可以通過後序遍歷,遞迴進行:獲取操作物件(子節點),獲取操作符(本節點),得到本節點的運算結果,最終就能在根節點獲取全部的運算結果。
第一單元作業全部採用BNF形式化描述,其遞迴描述的方式為我們直接提供了遞迴儲存的思路。於是很容易就能構建出上面UML圖的架構:頂層formula表徵只定義了加法的多項式,子類term表徵只定義了乘法的多項式,而factor則表徵乘法的單元物件,即a*x^b與三角函式,而在三角函式中又用content儲存頂層的formula,由此構成遞迴儲存結構。
這樣儲存的優點:具有靈活性和可拓展性,並且對化簡友好。相較於一般形式的方法,遞迴儲存方式將表示式結構劃分為一個個小的要素,可以分別定義各要素之間的運算,這樣為履行單一職責提供了天然條件;另外對要素進行了分類,因此新增其他運算單位時可以作為新的類,並繼續定義此類與其他運算單元的互動方法(運算)。有關化簡下面會談到。
1.2 複雜度分析
下面是使用idea圈複雜度分析外掛MatricsReloader的分析結果
aim.Formula | 4.384615384615385 | 10.0 | 57.0 |
---|---|---|---|
aim.Term | 3.8461538461538463 | 13.0 | 50.0 |
aim.Parser | 3.75 | 12.0 | 30.0 |
aim.Variable | 2.1 | 8.0 | 21.0 |
Main | 2.0 | 2.0 | 2.0 |
aim.Triger | 1.8181818181818181 | 6.0 | 20.0 |
aim.Factor | 1.1666666666666667 | 3.0 | 14.0 |
Total | 194.0 | ||
Average | 2.8529411764705883 | 7.714285714285714 | 27.714285714285715 |
這是排序後的類複雜度評估結果,可見,Formula類由於幾乎所有運算都需要從該類開始,並且下述的幾個主要方法都以該類為頂層展開,Term緊跟其後,可以說,有這樣的結果是不言而喻的。
aim.Term.toString() | 38.0 | 5.0 | 16.0 | 16.0 |
---|---|---|---|---|
aim.Formula.simplify() | 21.0 | 1.0 | 9.0 | 10.0 |
aim.Parser.parser() | 15.0 | 1.0 | 8.0 | 12.0 |
aim.Term.trigerCompose(Term) | 14.0 | 1.0 | 10.0 | 10.0 |
aim.Formula.add(Formula) | 13.0 | 5.0 | 8.0 | 9.0 |
aim.Formula.equals(Formula) | 12.0 | 7.0 | 5.0 | 8.0 |
aim.Term.addAbility(Term) | 12.0 | 7.0 | 4.0 | 8.0 |
aim.Formula.need() | 11.0 | 6.0 | 8.0 | 10.0 |
aim.Triger.toString() | 11.0 | 3.0 | 6.0 | 6.0 |
aim.Variable.toString() | 11.0 | 4.0 | 7.0 | 8.0 |
aim.Formula.add(Term) | 10.0 | 4.0 | 6.0 | 6.0 |
aim.Term.simplify() | 9.0 | 3.0 | 7.0 | 7.0 |
aim.Parser.isTerm(String) | 8.0 | 5.0 | 6.0 | 7.0 |
aim.Term.multiply(Term) | 8.0 | 4.0 | 5.0 | 5.0 |
aim.Factor.equals(Factor) | 7.0 | 3.0 | 3.0 | 5.0 |
aim.Formula.multiply(Formula) | 7.0 | 2.0 | 5.0 | 6.0 |
aim.Triger.multiAbility(Factor) | 6.0 | 4.0 | 4.0 | 4.0 |
aim.Formula.toString() | 5.0 | 4.0 | 5.0 | 5.0 |
aim.Formula.addUp() | 4.0 | 1.0 | 4.0 | 4.0 |
aim.Parser.getTwoOperator(String[]) | 4.0 | 1.0 | 5.0 | 5.0 |
aim.Triger.composeAbility(Factor) | 4.0 | 1.0 | 7.0 | 7.0 |
aim.Variable.tackle(String) | 4.0 | 1.0 | 4.0 | 4.0 |
aim.Term.hasTriger() | 3.0 | 3.0 | 1.0 | 3.0 |
aim.Triger.equals(Factor) | 3.0 | 2.0 | 5.0 | 5.0 |
aim.Variable.equals(Factor) | 3.0 | 2.0 | 3.0 | 3.0 |
aim.Parser.computeNeg(String[]) | 2.0 | 1.0 | 2.0 | 2.0 |
aim.Parser.computePos(String[]) | 2.0 | 1.0 | 2.0 | 2.0 |
aim.Parser.computeTriger(String[]) | 2.0 | 1.0 | 2.0 | 2.0 |
Main.main(String[]) | 1.0 | 1.0 | 2.0 | 2.0 |
aim.Factor.myClone() | 1.0 | 1.0 | 2.0 | 2.0 |
aim.Formula.myClone() | 1.0 | 1.0 | 2.0 | 2.0 |
aim.Formula.neg() | 1.0 | 1.0 | 2.0 | 2.0 |
aim.Term.equals(Term) | 1.0 | 1.0 | 2.0 | 2.0 |
aim.Term.myClone() | 1.0 | 1.0 | 2.0 | 2.0 |
aim.Factor.Factor() | 0.0 | 1.0 | 1.0 | 1.0 |
aim.Factor.composeAbility(Factor) | 0.0 | 1.0 | 1.0 | 1.0 |
aim.Factor.getCoefficient() | 0.0 | 1.0 | 1.0 | 1.0 |
aim.Factor.getContent() | 0.0 | 1.0 | 1.0 | 1.0 |
aim.Factor.getExponent() | 0.0 | 1.0 | 1.0 | 1.0 |
aim.Factor.getType() | 0.0 | 1.0 | 1.0 | 1.0 |
aim.Factor.multiAbility(Factor) | 0.0 | 1.0 | 1.0 | 1.0 |
aim.Factor.setCoefficient(BigInteger) | 0.0 | 1.0 | 1.0 | 1.0 |
aim.Factor.setExponent(BigInteger) | 0.0 | 1.0 | 1.0 | 1.0 |
aim.Factor.toString() | 0.0 | 1.0 | 1.0 | 1.0 |
aim.Formula.Formula() | 0.0 | 1.0 | 1.0 | 1.0 |
aim.Formula.addTerm(Term) | 0.0 | 1.0 | 1.0 | 1.0 |
aim.Formula.peek() | 0.0 | 1.0 | 1.0 | 1.0 |
aim.Parser.Parser(List) | 0.0 | 1.0 | 1.0 | 1.0 |
aim.Parser.makeFormula(String[]) | 0.0 | 1.0 | 1.0 | 1.0 |
aim.Term.Term() | 0.0 | 1.0 | 1.0 | 1.0 |
aim.Term.addFactor(Factor) | 0.0 | 1.0 | 1.0 | 1.0 |
aim.Term.getCoefficient() | 0.0 | 1.0 | 1.0 | 1.0 |
aim.Term.getFactors() | 0.0 | 1.0 | 1.0 | 1.0 |
aim.Term.setCoefficient(BigInteger) | 0.0 | 1.0 | 1.0 | 1.0 |
aim.Triger.Triger(String, Formula) | 0.0 | 1.0 | 1.0 | 1.0 |
aim.Triger.getCoefficient() | 0.0 | 1.0 | 1.0 | 1.0 |
aim.Triger.getContent() | 0.0 | 1.0 | 1.0 | 1.0 |
aim.Triger.getExponent() | 0.0 | 1.0 | 1.0 | 1.0 |
aim.Triger.getType() | 0.0 | 1.0 | 1.0 | 1.0 |
aim.Triger.setCoefficient(BigInteger) | 0.0 | 1.0 | 1.0 | 1.0 |
aim.Triger.setExponent(BigInteger) | 0.0 | 1.0 | 1.0 | 1.0 |
aim.Variable.Variable(BigInteger, BigInteger) | 0.0 | 1.0 | 1.0 | 1.0 |
aim.Variable.Variable(String) | 0.0 | 1.0 | 1.0 | 1.0 |
aim.Variable.getCoefficient() | 0.0 | 1.0 | 1.0 | 1.0 |
aim.Variable.getExponent() | 0.0 | 1.0 | 1.0 | 1.0 |
aim.Variable.multiAbility(Factor) | 0.0 | 1.0 | 1.0 | 1.0 |
aim.Variable.setCoefficient(BigInteger) | 0.0 | 1.0 | 1.0 | 1.0 |
aim.Variable.setExponent(BigInteger) | 0.0 | 1.0 | 1.0 | 1.0 |
Total | 255.0 | 123.0 | 203.0 | 225.0 |
Average | 3.75 | 1.8088235294117647 | 2.985294117647059 | 3.3088235294117645 |
經過排序可以發現,複雜度最高的一個是term的toString方法,這是由於優化輸出的需要,必定會在toString中進行完備的討論。譬如,係數為0或1的情況,指數為0或1的情況,有三角函式或沒三角函式等等,這樣一來,巢狀if的層數增多,加上factor的複雜度,才導致如上結果。另外,我每次呼叫化簡方法simplify都是在toString前呼叫,因此也會增加其複雜度。排名第二的formula的simplify(化簡)方法,也是由於多次遍歷元素導致的複雜度上升。除此之外,圈複雜度高的方法基本都是“判斷類”方法,這類方法需要進行大量比較,對於第二種儲存方式,由於其“深度”加深,因此在遍歷比較的時候需要多層巢狀的迴圈,這自然也是其複雜度高的原因。
因此在設計的時候儘可能減少if巢狀與迴圈巢狀是減少圈複雜度的關鍵方法。
1.3 bug分析
按理來說,預解析是不該有bug的,但是這動手比腦子塊的老毛病讓我在寫程式碼的時候總是“先下手為強”,最終不得不“拆東牆補西牆”,下次一定多多注意。
前兩次作業我確實採取第一種儲存方式,第一次由於提交匆忙,有一處的String轉Biginteger仍採用的是long過渡的形式,導致這一個bug被六個人圍攻,著實不該,此處姑且不談。著重談談第二次作業遇到的天坑。
1.2.1 淺克隆與深克隆
慚愧的是,在我寫第二次作業之前,就有了解討論區吳佬發起的討論,但是在實際運用的時候,還是由於理解不深導致致命bug。沿用了第一次的架構,我一直都沒有考慮這個問題。加法乘法運算,我一開始採用的是自儲存的方式,即
$$A.add(B),\ A.multiply
$$
這兩種計算的結構都是直接修改A,之前在作業一中,由於Biginteger的不可變特性,這個漏洞沒有顯現,但是在作業二中操作物件變為定義的類,因此在多次進行上述操作的時候,這致命的bug才會逐漸浮現。可以看出,這個bug的關鍵在於,如果A儲存結果,那麼B必須不可變,即除了方法內不能更改B,上述運算形式還必須具有方向性,絕對不可以B.multiply(A)。
針對這個bug,我在第三次作業重構中採取:每種運算都用一個新的物件來儲存。這對加法而言好說,但是乘法還是自儲存更為方便(畢竟依據一個個factor重新設定term著實沒必要),因此我採用先將A進行克隆,然後再與B進行乘法,將結果加入到另一個預先設定好的容器中。對於clone,我是通過在各類中統一定義了myClone方法,利用Serializable介面利用序列化克隆實現的。
1.2.2 toString
如果採用第一種儲存方式,那麼toString一定需要小心,因為如果只定義了term、並且還有化簡想法的我們,就會如上面複雜度分析那般所言。眾多if巢狀就必須考慮全面,否則就會出錯。另外一個bug集中的地方是化簡的部分,提交最後一版的時候由於化簡演算法不夠成熟,存在些許bug而最終選擇簡單化簡的一版。從這裡可以看出,複雜度更高的模組,其出bug的概率也越大,上面分析到的其很大一部分原因是由於大量if巢狀與迴圈巢狀,這正是我們debug需要注意的地方。
在某些方面來看,在term中toString,不如分散到factor中進行toString,這也是第三次作業重構的一大原因。
1.4 化簡分析
第三次作業著重化簡,我的架構在上面的UML圖中十分顯然,之前也說過這樣的架構對化簡友好,這裡就詳細談談其優點。
化簡的關鍵點有二:能否化簡、如何化簡。
我的設計中將二者分開實現,“能否化簡”用\#Ability的形式定義方法,而化簡操作,則直接在toString中呼叫。
1.4.1 簡單長度化簡
對於基本的長度化簡此處做簡單概括。
首先是合併操作,加法的係數合併,乘法的指數合併,以及有0的時候的消除合併操作。當然,對於x**2轉化為x*x就不必多說了。需要說明,這裡需要考慮到消除合併時,子類列表為空的情況,譬如formula的terms列表裡,由於係數全消為0而全被移除列表,導致terms.size()==0
,這種情況需要特判。
其次是順序移動操作,講正數提到表示式開頭可以省去一個符號的長度。
最後是三角函式括號的化簡,對於有符號整數與x冪次的情況,sin和cos中不需要兩層括號的,這可以通過一個need函式判斷是否要加括號來實現。
1.4.2 三角函式合併化簡
三角函式的平方和:我的做法是,在formula層級遍歷term,尋找每對term中可能可以相加的指數為2的三角函式,利用一個composeAbility方法進行判斷,如果可以,則對sin邊消除sin^2成分,而對cos這一邊,係數需要減去sin那一邊的係數。
二倍角操作:在單個term中進行搜尋,同樣記錄可能可以合併的三角函式,利用doubleAbility方法進行判斷,如果可以,則消除cos成分,並令係數除以2(前提是係數為2的倍數,因為本次作業不支援小數),令sin內content的係數double。
二、關於hack經歷
只能說從提心吊膽到心平氣和。最初對自己的設計確實有所不自信,一方面來源於對java語言本身的不熟悉,擔心有語言特性上的bug,另一方面還是自己架構的設計問題,缺乏此類經驗,沒有想得特別清楚。從最開始被hack5、6次,到最後0/20的hack結果,不僅體現了程式碼架構邏輯的進步,也說明了我的設計方法信心的積攢。
對於我hack別人,第一次兢兢業業下載別人的程式碼進行閱讀,用資料驅動來發現存在的bug,然後針對bug進行hack。之後兩次的hack,都是藉助資料生成器,初此之外,加上自己能想到的邊界資料來進行測試。只能說,星期天需要處理的作業有點多,留給hack的時間著實不多。。。
總體而言,所遇見的bug集中在兩個方面:
-
擴充套件後的成分沒有考慮到老的特殊情況。譬如前導零、帶符號的指數、資料範圍(未用biginteger)等等,以及最終結果為0或1的輸出情況處理。
-
擴充套件後的乘法,即sin、cos分別存在的兩個多項式之間的乘法或乘方。一般來說這是由於合併的結果。
基本上,如果提交前在這幾個方面多多測試,在配以幾個較為複雜的測試樣例,在hack途中可能出現bug的概率不大(前提是架構明確。。。)
三、心得體會
在小組討論過程中,我們組尤其討論了撰寫部落格的意義所在,其中一大討論結果就是“積累經驗”。經驗一次看似一筆帶過般簡單,但事實上卻濃縮著整個大腦的抽象工作。它負責將我們所學的知識串聯起來,並抽象成一塊塊有關係有結構的樹型知識鏈,儘管日後我們不一定記得詳細的知識點,但是卻能聯想到這方面的主要思想和易出錯點,知識點忘了可以重新查詢,但是“經驗”在未來做專案時扮演者極其重要的角色。當然,這樣說並不意味著知識點不重要,能記住知識點固然更好,只不過我認為經驗更為重要。
經過這三週的學習,最大的收穫還是文前所言的兩個課程特徵,這也是課程對我們提出的要求。如何能在一週為數不多的大段時間內,從設計到實現一個解決問題的方案,不僅考驗知識,更是考驗能力。我們需要合作,我們不僅要有自己的想法,更要善於學習他人想法,並在此過程中改善自己的想法,這樣才能鍛煉出短時間內得出最佳方案的能力;我們需要想清楚再動手,這個階段才是最耗費時間的,如果將架構設計好之後,bug會少,重構會少,可謂一勞永逸。
希望下一單元能搞定這兩方面的問題!