HITSC——軟體構造,到底學啥子咯

Ch1ldKing發表於2024-05-28

本文請配合大綱食用

最近很多同學都在吐槽一門課:軟體構造。🤬有的同學說這是一門“文科課”:“這麼多概念,不是設計理念,就是教我怎麼寫註釋,跟politics一樣教條”

這位同學說的也沒什麼錯,確實是學了這些東西和概念,什麼不變數啊,一攬子設計模式啊,什麼測試策略和規約,確實很多概念。但問題在於,它並不教條。

“軟體構造”是一種思想

❓為什麼這麼講

  1. 首先,這是一門我認為最接近上班的實際生產的課程,實際上,這是一門未來工作的“減負課”,旨在提出一系列方法來參考我們的生產活動,也就是程式碼。
  2. 其次,這門課其實是在幫你從學生向程式設計師進行思想上的改變:我們寫程式碼不再是寫一段程式、寫一個功能、寫一個演算法,而是要把磚塊築成大廈。在思考一個程式的時候,我們不再會思考“先定義一個變數,再透過迴圈遍歷,最後返回值”,而是思考“為了這個功能,我需要設計哪些實體類,同時我需要測試、設計 RI,設計父類子類”。顯然,我們的視角已經上升了一到兩個層次。
  3. 這是一門“經驗學科”,前人總結了一系列方法,能夠規範程式碼的行為,並透過這門課把這些方法喂到我們嘴裡。如果某一天一群“有素質”的程式設計師寫了一個程式,你並不會覺得有什麼。但一個沒學過“軟體構造”的人進來了,一切功能都能跑,但你改的時候,就是頭疼的要命。你會不會破口大罵呢
  4. 當一大堆理念傳授給你的時候,往往你學會的就不是方法和理念,而是理念背後的思想。我敢打賭,即使你課都沒好好聽,在借鑑(copy)實驗作業的過程中,熬夜看 PPT 的時候,也有一種設計思想潤物細無聲般進入了你的大腦:
    • 測試優先:這能保證我的程式及時發現錯誤,減少後期成本
    • 寫好測試策略和規約等註釋文件:這能保證我和其他程式設計師不用讀複雜的程式碼,也能知道這段程式碼幹了什麼
    • 設計好 ADT:這能將現實中的實體引入到虛擬世界,未來的工作就是基於現實最佳化的網路世界。同時,做好防護(AF,RI),能幫你迅速找到許多 bug,好事一樁
    • 做好 AF 、RI和表示洩露處理:這能保證我的服務端和客戶端之間不需要“知根知底”就能進行交流與開發。要知道,當你把一切都告訴別人,別人往往不是幫你,而是害你
    • OOP 的理念:我更願意稱其一種“模組化”的理念。當一個複雜的程式,可以被拆解為一個一個類,並且透過 Override、Overload等實現類之間的繼承,你透過抽象出來的介面,最終拼接成一個完整的程式。而當你維護時,發現一切已經在最初設計時就鋪平道路,聽著就爽
    • 複用性、擴充性、健壯性、正確性:如果一個程式滿足這四點,那麼它就是一個比較完美的程式。從開發到運維,真正的全過程生命週期,都被包含在內。所以這就是一個程式需要滿足的東西,簡單而純粹
      因此,這些才是軟體構造所學的。即使你忘記了具體有哪些設計方法,忘記了 @Overload怎麼用,忘記了 Javadoc 怎麼寫,但這些思想是不會消散的

多年以後,你可能需要回顧的東西

本文章顯然不是為了考試,而是在說“我學了什麼有用的”。主要基於上面提到的思想進行擴充:

測試優先

“測”什麼

image.png

怎麼“測”

🧐好的測試?

  1. 能發現錯誤
  2. 不冗餘
  3. 有最佳特性
  4. 別太複雜也別太簡單
    首先,要明確單元測試是需要根據 spec 進行的,這個後面會說
    因此,我們需要一些測試的方法:

黑盒測試

等價類劃分

根據spec去分析等價類,從不同角度:正負、奇偶、整數非整數

邊界值分析

邊界上的值:比邊界略大略小、無限大無限小、0

如何設計

每個維度被覆蓋一次即可
image.png

白盒測試

考慮內部實現細節,根據程式程式碼執行時可能走過的所有路徑進行測試,比如進入或不進入迴圈/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. 語句覆蓋
    - 測試用例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");
  2. 分支覆蓋
    - 測試用例1:divide(4, 2),預期結果:2
    - 覆蓋 if (denominator == 0) 的false分支。
    - 測試用例2:divide(4, 0),預期結果:丟擲 IllegalArgumentException
    - 覆蓋 if (denominator == 0) 的true分支。
  3. 路徑覆蓋
    - 測試用例1:divide(4, 2),預期結果:2
    - 覆蓋路徑:進入方法 -> if條件為false -> 執行除法並返回。
    - 測試用例2:divide(4, 0),預期結果:丟擲 IllegalArgumentException
    - 覆蓋路徑:進入方法 -> if條件為true -> 丟擲異常。
  4. 條件覆蓋
    - 測試用例1:divide(4, 2),預期結果:2
    - 確保條件 denominator == 0 為false。
    - 測試用例2:divide(4, 0),預期結果:丟擲 IllegalArgumentException
    - 確保條件 denominator == 0 為true。

測試覆蓋度

已有的測試用例有多大程度覆蓋了被測程式

迴歸測試

每次都要完整的測試整個系統才能保證其他功能不受影響,且配合良好

告訴別人咋“測”的

記錄一下測試策略唄
🌰例子:左側為spec,右側測試用例,包括分割槽維度:對三個維度進行取值,取了哪幾種值,並解釋為何這樣取值。對於一些測試方法,可以解釋它覆蓋了什麼部分image.png

寫好規約

規約幹嘛的

  1. 給自己和別人寫出設計決策:如final、資料型別定義
  2. 作為契約,服務端與客戶端達成一致
  3. 呼叫方法時雙方都要遵守
  4. 便於定位錯誤
  5. 解耦,無需告訴客戶端具體實現,變化也無需通知客戶端,扮演防火牆角色
  6. 判斷行為等價性

規約都有啥

  1. 前置條件:對客戶端的約束,在使用方法時必須滿足的條件,用@param,並用@requires進行說明
  2. 後置條件:對開發者的約束,方法結束時必須滿足的條件,用@return@throws,並用@effects進行描述
  3. 契約:前置條件滿足了,則後置條件必須滿足

怎麼寫好規約

規約其實是你的程式碼設計方案

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都有啥

  1. 構造器
  2. 觀察器
  3. 生產器
  4. 變值器(定義了是否可變)

咋設計 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); 
    // ...
}
  1. 採用Set,防止成員重複,同時改進了List的實現細節問題
  2. 透過getmembers來獲取成員列表,而不是直接暴露內部people,防止修改
  3. getMembers不直接返回people,而是複製一個列表,防止表示暴露

不變數

一個東西在程式中是不會改變的,這樣滿足一些設計的同時我們可以很輕易的判斷程式是否出錯,但要記得防止不變數被修改

如何防止不變數被修改

image.png

RI

表示不變數RI:某個具體的“表示”是否是“合法的”
也就是R的一個子集,這裡面都是合法的輸入,是一些限定條件
❓為什麼是表示不變數:在方法執行完後,要仍然保持住所設定好的RI,執行後這個值仍然在 R 的子集當中

AF

抽象函式:R和A之間對映關係的函式,即如何去解釋R中的每一個值為A中的每一個值
也就是對映到客戶端那裡的值,需要具體解釋是如何對映的

checkRep()

透過一個方法checkRep()來保證不變數任何時候都不會改變,所有可能改變rep的地方都要檢查。可以藉此替代前置條件,方法是在每個方法中加入checkRep,並丟擲Exception

咋測試ADT

這就用到測試優先思想了,無論何時你要記得測試一段程式

  1. 用observers測試creators、producers、mutators
  2. 呼叫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

介面與介面,介面與類之間可以繼承和擴充

介面中可以透過靜態工廠來實現類似構造器的作用,能夠防止客戶端直接接觸到具體實現類image.png
default可以實現介面的統一功能,無需在各個實現類中重複實現
image.png

封裝

  1. 使用介面型別宣告變數
  2. 客戶端僅使用介面中定義的方法
  3. 客戶端程式碼無法直接訪問屬性,透過封裝get方法等來防止洩露。
  4. private只能在當前類中訪問,protected可允許子類訪問,public允許任何類訪問

繼承和重寫

final變數不允許重引用;final方法不允許重寫,final類不允許擴充繼承
📕抽象的思想:抽象,意思是提取共同特徵,你不瞭解每一個的具體,但是你瞭解他們的抽象。例如,抽象類介面,則它是提取的特徵,所有人都該實現它。

Overriding

完全相同的Signature,使用哪個執行時決定
父型別三種情況:

  1. 被重寫函式體不為空,大多數子類可複用,也可以重寫
  2. 函式實現體為空,則子型別需要這個功能時需要重寫
  3. 如果該方法為抽象方法,其沒有實現體,則所有子類都需要實現
    在重寫中,可透過super來利用父類的功能。但注意如果呼叫父類的構造器,必須是實現體的第一調語句

抽象類

至少包含一個抽象方法,可以有屬性
抽象方法必須沒有實現體

多型

小結

軟體構造固然是一門概念很多的課,但其重點在於其背後蘊藏的思想,我們稱其為“一個程式猿的自我修養”。生活中很多事情何嘗不是如此:不識廬山真面目,只緣身在此山中。不必拘泥於考試背背背的束縛,而是參悟其背後的道理;不必拘泥於眼前的苟且,向更遠大的方向去走,總一天回頭時,發現原來已經在明燈的指引下走了很遠的路。讓這門課成為程式猿之路的領路人,是課程的目的,也是我們應領悟的道理。

相關文章