《架構整潔之道》學習筆記 Part 2 程式設計正規化

2023-07-16 12:00:57

計算機程式設計發展至今,一共只有三個程式設計正規化:

  • 結構化程式設計
  • 物件導向程式設計
  • 函數語言程式設計

程式設計正規化和軟體架構的關係

  • 結構化程式設計是各個模組的演演算法實現基礎
  • 多型(物件導向程式設計)是跨越架構邊界的手段
  • 函數語言程式設計是規範和限制資料存放位置與存取許可權的手段

軟體架構的三大關注重點功能性組建獨立性以及資料管理,和程式設計正規化不謀而合

結構化程式設計

限制控制權的直接轉移,禁止 goto,用 if/else/while 替代

  • Dijkstra 發現:goto 語句的某些用法會導致模組無法被遞迴拆分成更小的、可證明的單元,這會導致無法採用分解法將大型問題進一步拆分成更小的、可證明的部分。
  • Bohm 和 Jocopini 證明了:可以用順序結構、分支結構、迴圈結構構造出任何程式
  • 測試只能證明 Bug 的存在,並不能證明不存在 Bug
  • 結構化程式設計正規化的價值:賦於我們構建可證偽程式單元的能力。如果測試無法證偽這些函數,就可以認為這些函數足夠正確
  • 在架構設計領域,功能性降解拆分仍然是最佳實踐之一

物件導向程式設計

限制控制權的間接轉移,禁用函數指標,用多型替代

什麼是物件導向?
  • 資料與函數的組合?
    • o.f() 和 f(o) 沒有區別
  • 對真實世界進行建模的方式?
    • 到底如何進行?為什麼這麼做?有什麼好處?
    • 物件導向程式設計究竟是什麼?
  • 封裝、繼承、多型?
    • 物件導向程式語言必須支援這三個特性
封裝

把一組關聯的資料和函數管理起來,外部只能看見部分函數,資料則完全不可見。

封裝並不是物件導向語言特有的,C 語言也支援:

point.h

struct Point;
struct Point* makePoint(double x, double y);
double distance(struct Point *p1, struct Point *p2)

C 語言的封裝是完美的封裝:利用 forward declaration,Point 的資料結構、內部實現對 point.h 的使用者完全不可見。

而後來的 C++ 雖然是物件導向的程式語言,但卻破壞了封裝性:

point.h

class Point {
public:
    Point(double x, double y);
    double distance(const Point& p1, const Point& p2);
    
private:
    double sqrt(double x);
private:
    double x;
    double y;
};

C++ 編譯器需要知道類的物件大小,因此必須在標頭檔案中看到成員變數的定義。雖然 private 限制了使用者存取私有成員,但這樣仍然暴露了類的內部實現。(C++ 的 PIMPL 慣用法可以在一定程度上緩解這個問題)

Java 和 C# 拋棄了標頭檔案、實現分離的程式設計方式,進一步削弱了封裝性,因為無法區分類的宣告和定義。

繼承

C 語言也支援繼承:

namedPoint.h

struct NamedPoint;
struct NamedPoint* makeNamedPoint(double x, double y, char* name);
void setName(struct NamePoint *np, char* name);
char* getName(struct NamedPoint *np);

namedPoint.c

#include "namePoint.h"

struct NamedPoint {
    double x;
    double y;
    char* name;
};

// 或者
#include "point.h"

struct NamePoint {
    Point parent_;
    char* name;
};

// 省略其他函數實現

main.c

#include "point.h"
#include "namedPoint.h"

int main() {
    struct NamePoint* p1 = makeNamedPoint(0.0, 0.0, "origin");
    struct NamePoint* p2 = mameNamePoint(1.0, 1.0, "upperRight");
    // C 語言中的繼承需要強制轉換 p1、p2 的型別
    // 真正的物件導向語言一般可以自動將子類轉成父類別指標/參照
    distance((struct Point*)p1, (struct Point*)p2);
}

在 main.c 中,NamePoint 被當作 Point 來使用。之所以可以,是因為 NamePoint 是 Point 的超集,且共同成員的順序一致。C++ 中也是這樣實現單繼承的。

多型

在物件導向語言發明之前,C 語言也支援多型。

UNIX 要求每個 IO 裝置都提供 open、close、read、write、seek 這 5 個標準函數:

struct FILE {
    void (*open)(char* name, int mode);
    void (*close)();
    int (*read)();
    void (*write)(char);
    void (*seek)(long index, int mode);
};

這裡的 FILE 就相當於一個介面類,不同的 IO 裝置有各自的實現函數,通過設定函數指標指向不同的實現來達到多型的目的。上層的功能邏輯只依賴 FILE 結構體中的 5 個標準函數,並不關心具體的 IO 裝置什麼。更換 IO 裝置也無需修改功能邏輯的程式碼,IO 只是功能邏輯的一個外掛

C++ 中每個虛擬函式的地址都記錄在一個叫 vtable 的資料結構中,帶有虛擬函式的類會有一個隱式的、指向 vtable 的虛表指標。每次呼叫虛擬函式都會先查詢 vtable,子類建構函式負責將子類虛擬函式地址載入到物件的 vtable 中。

多型本質上就是函數指標的一種應用。用函數指標實現多型的問題在於函數指標的危險性。依賴人為遵守一系列的約定很容易產生難以跟蹤和偵錯的 bug。物件導向程式設計使得多型再不需要依賴人工遵守約定,可以更簡單、更安全地實現複雜功能。物件導向程式設計的出現使得「外掛式架構」普及開來。

此外,物件導向程式設計的帶來的另一個重大好處是依賴反轉:通過引入介面,原始碼的依賴關係不再受到控制流的限制,軟體架構師可以輕易地更改原始碼的依賴關係。這也是物件導向程式設計正規化的核心本質(關於依賴反轉,後面會單獨用一篇來介紹)。

回到開始的問題,物件導向到底什麼?有許多不同的說法和意見,但是對於軟體架構師來說,物件導向程式設計就是以多型為手段,控制原始碼依賴的能力。這種能力可以讓軟體架構師構建某種外掛式架構,讓高層策略和底層實現元件相分離,底層元件作為外掛可以獨立開發和部署。

函數語言程式設計

限制賦值操作

  • 函數語言程式設計中的變數不可變

  • 不可變性是軟體架構需要考慮的重點,因為所有的並行、死鎖、競爭問題都是可變變數導致的,如果變數不可變,就不會有這些問題

  • 架構設計良好的程式應該拆分成可變不可變兩種元件,其中可變狀態元件中的邏輯越少越好

  • 事件溯源:只儲存事務記錄,不儲存具體狀態;需要狀態時,從頭計算所有事務。

    • 例如銀行程式只儲存每次的交易記錄,不儲存使用者餘額,每次查詢餘額時,將全部交易記錄取出累計
    • 這種模式只需要 CR (Create & Retrieve),不需要 UD (Update & Delete),沒有了更新和刪除操作,自然也不存在並行問題
    • 缺點:對儲存和處理能力要求較高(但隨著技術的發展,這方面將越來越不成問題)
    • 應用:git

總結

從 1946 年圖靈寫下第一行程式碼至今,軟體程式設計的核心沒有變:計算機程式無一例外是由順序結構、分支結構、迴圈結構和間接轉移這幾種行為組合而成的,無可增加, 也缺一不可。

所有三個正規化都是限制了編碼方式,而不是增加新能力

  • 結構化程式設計:限制控制權的直接轉移,禁止 goto,用 if/else/while 替代
  • 物件導向程式設計:限制控制權的間接轉移,禁用函數指標,用多型替代
  • 函數語言程式設計:限制賦值操作

三個程式設計正規化都是在 1958 - 1968 年間提出,此後再也沒有新的正規化提出,未來幾乎不可能再有新的正規化。因為除了 goto 語句、函數指標、賦值語句之外,也沒有什麼可以限制的了。