電腦編程發展至今,一共只有三個編程範式: - 結構化編程 - 面向對象編程 - 函數式編程 ### 編程範式和軟體架構的關係 - 結構化編程是各個模塊的演算法實現基礎 - 多態(面向對象編程)是跨越架構邊界的手段 - 函數式編程是規範和限制數據存放位置與訪問許可權的手段 **軟體架構的三大關註重點** ...
電腦編程發展至今,一共只有三個編程範式:
- 結構化編程
- 面向對象編程
- 函數式編程
編程範式和軟體架構的關係
- 結構化編程是各個模塊的演算法實現基礎
- 多態(面向對象編程)是跨越架構邊界的手段
- 函數式編程是規範和限制數據存放位置與訪問許可權的手段
軟體架構的三大關註重點:功能性、組建獨立性以及數據管理,和編程範式不謀而合
結構化編程
限制控制權的直接轉移,禁止 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 語句、函數指針、賦值語句之外,也沒有什麼可以限制的了。