本文請配合大綱食用
最近很多同學都在吐槽一門課:軟體構造。🤬有的同學說這是一門“文科課”:“這麼多概念,不是設計理念,就是教我怎麼寫註釋,跟politics一樣教條”
這位同學說的也沒什麼錯,確實是學了這些東西和概念,什麼不變數啊,一攬子設計模式啊,什麼測試策略和規約,確實很多概念。但問題在於,它並不教條。
“軟體構造”是一種思想
❓為什麼這麼講
- 首先,這是一門我認為最接近上班的實際生產的課程,實際上,這是一門未來工作的“減負課”,旨在提出一系列方法來參考我們的生產活動,也就是程式碼。
- 其次,這門課其實是在幫你從學生向程式設計師進行思想上的改變:我們寫程式碼不再是寫一段程式、寫一個功能、寫一個演算法,而是要把磚塊築成大廈。在思考一個程式的時候,我們不再會思考“先定義一個變數,再透過迴圈遍歷,最後返回值”,而是思考“為了這個功能,我需要設計哪些實體類,同時我需要測試、設計 RI,設計父類子類”。顯然,我們的視角已經上升了一到兩個層次。
- 這是一門“經驗學科”,前人總結了一系列方法,能夠規範程式碼的行為,並透過這門課把這些方法喂到我們嘴裡。如果某一天一群“有素質”的程式設計師寫了一個程式,你並不會覺得有什麼。但一個沒學過“軟體構造”的人進來了,一切功能都能跑,但你改的時候,就是頭疼的要命。你會不會破口大罵呢
- 當一大堆理念傳授給你的時候,往往你學會的就不是方法和理念,而是理念背後的思想。我敢打賭,即使你課都沒好好聽,在借鑑(copy)實驗作業的過程中,熬夜看 PPT 的時候,也有一種設計思想潤物細無聲般進入了你的大腦:
- 測試優先:這能保證我的程式及時發現錯誤,減少後期成本
- 寫好測試策略和規約等註釋文件:這能保證我和其他程式設計師不用讀複雜的程式碼,也能知道這段程式碼幹了什麼
- 設計好 ADT:這能將現實中的實體引入到虛擬世界,未來的工作就是基於現實最佳化的網路世界。同時,做好防護(AF,RI),能幫你迅速找到許多 bug,好事一樁
- 做好 AF 、RI和表示洩露處理:這能保證我的服務端和客戶端之間不需要“知根知底”就能進行交流與開發。要知道,當你把一切都告訴別人,別人往往不是幫你,而是害你
- OOP 的理念:我更願意稱其一種“模組化”的理念。當一個複雜的程式,可以被拆解為一個一個類,並且透過 Override、Overload等實現類之間的繼承,你透過抽象出來的介面,最終拼接成一個完整的程式。而當你維護時,發現一切已經在最初設計時就鋪平道路,聽著就爽
- 複用性、擴充性、健壯性、正確性:如果一個程式滿足這四點,那麼它就是一個比較完美的程式。從開發到運維,真正的全過程生命週期,都被包含在內。所以這就是一個程式需要滿足的東西,簡單而純粹
因此,這些才是軟體構造所學的。即使你忘記了具體有哪些設計方法,忘記了@Overload
怎麼用,忘記了 Javadoc 怎麼寫,但這些思想是不會消散的
多年以後,你可能需要回顧的東西
本文章顯然不是為了考試,而是在說“我學了什麼有用的”。主要基於上面提到的思想進行擴充:
測試優先
“測”什麼
怎麼“測”
🧐好的測試?
- 能發現錯誤
- 不冗餘
- 有最佳特性
- 別太複雜也別太簡單
首先,要明確單元測試是需要根據 spec 進行的,這個後面會說
因此,我們需要一些測試的方法:
黑盒測試
等價類劃分
根據spec去分析等價類,從不同角度:正負、奇偶、整數非整數
邊界值分析
邊界上的值:比邊界略大略小、無限大無限小、0
如何設計
每個維度被覆蓋一次即可
白盒測試
考慮內部實現細節,根據程式程式碼執行時可能走過的所有路徑進行測試,比如進入或不進入迴圈/if,是否丟擲異常,為每種路徑至少覆蓋一次
舉個例子:
public class Division {
public int divide(int numerator, int denominator) {
if (denominator == 0) {
throw new IllegalArgumentException("Denominator cannot be zero");
}
return numerator / denominator;
}
}
每一種可能執行過的邏輯都要覆蓋到
覆蓋如下:
- 語句覆蓋:
- 測試用例1:divide(4, 2)
,預期結果:2
- 覆蓋語句:if (denominator == 0)
(false) 和return numerator / denominator;
- 測試用例2:divide(4, 0)
,預期結果:丟擲IllegalArgumentException
- 覆蓋語句:if (denominator == 0)
(true) 和throw new IllegalArgumentException("Denominator cannot be zero");
- 分支覆蓋:
- 測試用例1:divide(4, 2)
,預期結果:2
- 覆蓋if (denominator == 0)
的false分支。
- 測試用例2:divide(4, 0)
,預期結果:丟擲IllegalArgumentException
- 覆蓋if (denominator == 0)
的true分支。- 路徑覆蓋:
- 測試用例1:divide(4, 2)
,預期結果:2
- 覆蓋路徑:進入方法 ->if
條件為false -> 執行除法並返回。
- 測試用例2:divide(4, 0)
,預期結果:丟擲IllegalArgumentException
- 覆蓋路徑:進入方法 ->if
條件為true -> 丟擲異常。- 條件覆蓋:
- 測試用例1:divide(4, 2)
,預期結果:2
- 確保條件denominator == 0
為false。
- 測試用例2:divide(4, 0)
,預期結果:丟擲IllegalArgumentException
- 確保條件denominator == 0
為true。
測試覆蓋度
已有的測試用例有多大程度覆蓋了被測程式
迴歸測試
每次都要完整的測試整個系統才能保證其他功能不受影響,且配合良好
告訴別人咋“測”的
記錄一下測試策略唄
🌰例子:左側為spec,右側測試用例,包括分割槽維度:對三個維度進行取值,取了哪幾種值,並解釋為何這樣取值。對於一些測試方法,可以解釋它覆蓋了什麼部分
寫好規約
規約幹嘛的
- 給自己和別人寫出設計決策:如final、資料型別定義
- 作為契約,服務端與客戶端達成一致
- 呼叫方法時雙方都要遵守
- 便於定位錯誤
- 解耦,無需告訴客戶端具體實現,變化也無需通知客戶端,扮演防火牆角色
- 判斷行為等價性
規約都有啥
- 前置條件:對客戶端的約束,在使用方法時必須滿足的條件,用
@param
,並用@requires
進行說明 - 後置條件:對開發者的約束,方法結束時必須滿足的條件,用
@return
和@throws
,並用@effects
進行描述 - 契約:前置條件滿足了,則後置條件必須滿足
怎麼寫好規約
規約其實是你的程式碼設計方案
1.內聚的
Spec描述的方法應單一、簡單、易理解
分離:規約做了兩件事,所以要分離開形成兩個方法。可以使spec更容易理解,且耦合性低應對變化。如下
public static int LONG_WORD_LENGTH = 5;
public static String longestWord;
/**
* Update longestWord to be the longest element of words, and print
* the number of elements with length > LONG_WORD_LENGTH to the console.
* @param words list to search for long words
*/
public static void countLongWords(List<String> words) {}
2.資訊豐富的
不能引起客戶端的歧義
🌰例子:客戶端不知道返回null是因為原來繫結的值是null,還是因為不存在舊值
static V put(Map<K,V> map, K key, V val)
/**
* requires: `val`可以為`null`,`map`可以包含`null`值
* effects: 將`(key, val)`插入到對映中,如果存在相同的鍵,則覆蓋舊值。返回該鍵的舊值,如果不存在舊值,則返回`null`
*/
3.足夠強
太弱的spec,client不放心、不敢用 (因為沒有給出足夠的承諾)。
開發者應儘可能考慮各種特殊情況,在post-condition給出處理措施
🌰例子:客戶端在得到Exception的時候,不知道哪些元素被新增了,需要自己定位。應該完善exception
static void addAll(List<T> list1, List<T> list2)
effects: adds the elements of list2 to list1,
unless it encounters a null element,
at which point it throws a NullPointerException
4.使用抽象型別
給方法實現體與客戶端更大的自由度
static ArrayList<T> reverse(ArrayList<T> list)
effects: returns a new list which is the reversal of list, i.e.
newlist[i] == list[n-i-1]
for all 0 <= i < n, where n = list.size()
5.避免可變數
😀方法內部儘量不要修改傳入的引數,不要設計mutating的spec,容易引發錯誤。除非必須是mutator方法的spec,否則避免使用mutable的類與方法。
📕因為程式中很有可能有多個變數指向同一個可變物件(別名),在類的實現體或客戶端儲存別名的情況下,可能導致修改併產生bug
🌰例子:
客戶端為了使用者隱私,因此隱藏了id前5位
char[] id = getMitId("bitdiddle");
for (int i = 0; i < 5; ++i) {
id[i] = '*';
}
System.out.println(id);
服務端擔心效率,所以採用了cache全域性可變變數(char[]可變)
private static Map<String, char[]> cache = new HashMap<String, char[]>();
public static char[] getMitId(String username) throws NoSuchUserException {
// see if it's in the cache already
if (cache.containsKey(username)) {
return cache.get(username);
}
// ... look up username in MIT's database ...
// store it in the cache for future lookups
cache.put(username, id);
return id;
}
由於char[]可變,修改前五位會導致Map中的資料也被更改。所以最好採用String
設計好 ADT
ADT是由操作定義的,與其內部如何實現無關
ADT都有啥
- 構造器
- 觀察器
- 生產器
- 變值器(定義了是否可變)
咋設計 ADT
簡潔一致
表示獨立性
能夠實現不論服務端程式碼如何改變 ADT 的內部具體實現,客戶端對於ADT的使用不會變,仍然滿足 spec,也就是 ADT 的本質沒有變
🌰例子:
違反RI
/**
* Represents a family that lives in a household together.
* A family always has at least one person in it.
* Families are mutable.
*/
class Family {
public List<Person> people;
public List<Person> getMembers() {
return people;
}
}
void client1(Family f) {
Person baby = f.people.get(f.people.size() - 1); // 直接訪問內部表示,違反封裝
// ...
}
問題:直接暴露了people的內部,並且沒有封裝好。查詢方法依賴於具體內部有多少個元素這類的實現細節
改進RI
/**
* Represents a family that lives in a household together.
* A family always has at least one person in it.
* Families are mutable.
*/
class Family {
// 使用Set代替List,以避免重複元素
public Set<Person> people;
/**
* @return a list containing all the members of the family, with no duplicates.
*/
public List<Person> getMembers() {
return new ArrayList<>(people);
}
}
void client3(Family f) {
// 透過getMembers方法獲取成員列表,而不是直接訪問內部表示
Person anybody = f.getMembers().get(0);
// ...
}
- 採用Set,防止成員重複,同時改進了List的實現細節問題
- 透過getmembers來獲取成員列表,而不是直接暴露內部people,防止修改
- getMembers不直接返回people,而是複製一個列表,防止表示暴露
不變數
一個東西在程式中是不會改變的,這樣滿足一些設計的同時我們可以很輕易的判斷程式是否出錯,但要記得防止不變數被修改
如何防止不變數被修改
RI
表示不變數RI:某個具體的“表示”是否是“合法的”
也就是R的一個子集,這裡面都是合法的輸入,是一些限定條件
❓為什麼是表示不變數:在方法執行完後,要仍然保持住所設定好的RI,執行後這個值仍然在 R 的子集當中
AF
抽象函式:R和A之間對映關係的函式,即如何去解釋R中的每一個值為A中的每一個值
也就是對映到客戶端那裡的值,需要具體解釋是如何對映的
checkRep()
透過一個方法checkRep()
來保證不變數任何時候都不會改變,所有可能改變rep的地方都要檢查。可以藉此替代前置條件,方法是在每個方法中加入checkRep,並丟擲Exception
咋測試ADT
這就用到測試優先思想了,無論何時你要記得測試一段程式
- 用observers測試creators、producers、mutators
- 呼叫creators、producers、mutators等產生或修改結果來測試 observers
OOP設計理念
Object
Object 由類組成,定義了方法和變數
靜態方法與例項方法
class Difference {
public static void main(String[] args) {
display(); // 呼叫靜態方法,無需物件
Difference t = new Difference();
t.show(); // 呼叫例項方法,需要物件
}
static void display() {
System.out.println("Programming is amazing.");
}
void show() {
System.out.println("Java is awesome.");
}
}
介面 Interface
介面與介面,介面與類之間可以繼承和擴充
介面中可以透過靜態工廠來實現類似構造器的作用,能夠防止客戶端直接接觸到具體實現類
default
可以實現介面的統一功能,無需在各個實現類中重複實現
封裝
- 使用介面型別宣告變數
- 客戶端僅使用介面中定義的方法
- 客戶端程式碼無法直接訪問屬性,透過封裝get方法等來防止洩露。
- private只能在當前類中訪問,protected可允許子類訪問,public允許任何類訪問
繼承和重寫
final變數不允許重引用;final方法不允許重寫,final類不允許擴充繼承
📕抽象的思想:抽象,意思是提取共同特徵,你不瞭解每一個的具體,但是你瞭解他們的抽象。例如,抽象類介面,則它是提取的特徵,所有人都該實現它。
Overriding
完全相同的Signature,使用哪個執行時決定
父型別三種情況:
- 被重寫函式體不為空,大多數子類可複用,也可以重寫
- 函式實現體為空,則子型別需要這個功能時需要重寫
- 如果該方法為抽象方法,其沒有實現體,則所有子類都需要實現
在重寫中,可透過super來利用父類的功能。但注意如果呼叫父類的構造器,必須是實現體的第一調語句
抽象類
至少包含一個抽象方法,可以有屬性
抽象方法必須沒有實現體
多型
小結
軟體構造固然是一門概念很多的課,但其重點在於其背後蘊藏的思想,我們稱其為“一個程式猿的自我修養”。生活中很多事情何嘗不是如此:不識廬山真面目,只緣身在此山中。不必拘泥於考試背背背的束縛,而是參悟其背後的道理;不必拘泥於眼前的苟且,向更遠大的方向去走,總一天回頭時,發現原來已經在明燈的指引下走了很遠的路。讓這門課成為程式猿之路的領路人,是課程的目的,也是我們應領悟的道理。