前言
第二部分的主題內容
Chap3. 編程範式總覽
Chap4. 結構化編程
Chap5. 面向對象編程
Chap6.函數式編程
總結
前言上次土撥鼠水了一篇第一部分的讀書筆記,《clean architecture》第一部分筆記。今天再來水一下第二部分關於編程範式的筆記。
第二部分的主題內容第二部分主要是關於編程範式的講述,分別從結構化編程、面向對象編程、函數式編程來說明介紹編程範式。
Chap3. PARADIGM OVERVIEW 編程範式總覽
STRUCTURED PROGRAMMING 結構化編程OBJECT-ORIENTED PROGRAMMING 面向對象編程FUNCTIONAL PROGRAMMING 函數式編程Chap4. STRUCTURED PROGRAMMING 結構化編程
A HARMFUL PROCLAMATION goto 是有害的FUNCTIONAL DECOMPOSITION 功能性降解拆分NO FORMAL PROOFS 形式化證明沒有發生SCIENCE TO THE RESCUE 科學來救場Chap5. OBJECT-ORIENTED PROGRAMMING 面向對象編程
THE POWER OF POLYMORPHISM 多態的強大性DEPENDENCY INVERSION 依賴反轉Chap6. FUNCTIONAL PROGRAMMING 函數式編程
IMMUTABILITY AND ARCHITECTURE 不可變性與軟體架構SEGREGATION OF MUTABILITY 可變性的隔離Chap3. 編程範式總覽三個編程範式三個編程範式,它們分別是結構化編程(structured programming)、 面向對象編程(object-oriented programming)以及函數式編程(functional programming)。
結構化編程是第一個普遍被採用的編程範式,由 Edsger Wybe Dijkstra 於 1968 年最先提出。結構化編程範式歸結為:結構化編程對程序控制權的直接轉移進行了限制和規範。
面向對象編程式的提出比結構化編程還早了兩年,是在 1966 年由 Ole Johan Dahl 和 Kriste Nygaard 在論文中總結歸納出來的。面向對象編程範式歸結為:面向對象編程對程序控制權的間接轉移進行了限制和規範。
函數式編程概念是基於與阿蘭·圖靈同時代的數學家 Alonzo Church 在 1936 年發明的入演算的直接衍生物。函數式程式語言中應該是沒有賦值語句的。大部分函數式程式語言只允許在非常嚴格的限制條件下,才可以更改某個變量的值。函數式編程範式歸結為:函數式編程對程序中的賦值進行了限制和規範。
思考和小結每個編程範式的目的都是設置限制。這些範式主要是為了告訴我們不能做什麼,而不是可以做什麼。這三個編程範式分別限制了 goto 語句、函數指針和賦值語句的使用。
這三個編程範式可能是僅有的三個了,三個編程範式都是在 1958 年到 1968 年這 10 年間被提出來的,後續再也沒有新的編程範式出現過。
這些編程範式的歷史知識與軟體架構有關係嗎?當然有,而且關係相當密切。譬如說多態是我們跨越架構邊界的手段,函數式編程是我們規範和限制數據存放位置與訪問權限的手段,結構化編程則是各模塊的算法實現基礎。 這和軟體架構的三大關注重點不謀而合:功能性、組件獨立性以及數據管理。
Chap4. 結構化編程可推導性Dijkstra 認為程式設計師可以像數學家一樣對自己的程序進行推理證明。換句話說,程式設計師可以用代碼將一些已證明可用的結構串聯起來。
Dijkstra 在研究過程中發現了一個問題。goto 語句的某些用法會導致某個模塊 無法被遞歸拆分成更小的、可證明的單元,這會導致無法採用分解法來將大型問題進一步拆分成更小的、可證明的部分。
不過兩年前Bohm 和 Jocopini 剛剛證明了人們可以用順序結構、分支結構、循環結構這三種結構構造出任何程序。這個發現非常重要:因為它證明了我們構建可推導模塊所需要的控制結構集與構建所有程序所需的控制結構集的最小集是等同的。這樣—來,結構化編程就誕生了。
goto 是有害的隨著程式語言的演進,goto 語句的重要性越來越小,最終甚至消失了。如今大部分的現代程式語言中都已經沒有了 goto 語句。哦,對了,LISP 裡從來就沒有過!
功能分解既然結構化編程範式可將模塊遞歸降解拆分為可推導的單元,這樣一來,我們就可以將一個大型問題拆分為一系列高級函數的組合,而這些高級函數各自又可以繼續被拆分為一系列低級函數。
測試Dijkstra 曾經說過「測試只能展示 Bug 的存在,並不能證明不存在 Bug」, 換句話說,一段程序可以由一個測試來證明其錯誤性,但是卻不能被證明是正確的。
小結結構化編程範式中最有價值的地方就是,它賦予了我們創造可證偽程序單元的能力。這就是為什麼現代程式語言一般不支持無限制的 goto 語句。更重要的是,這也是為什麼在架構設計領域,功能性分解仍然是最佳實踐之一。
Chap5. 面向對象編程什麼是面向對象?一種常見的回答是「數據與函數的組合」 。另一種是面向對象編程是一種對真實世界進行建模 的方式。回答此問題的同時另外還會搬出這三個詞語:封裝(encapsulation)、繼承(inheritance)、多態(polymorphism)。其隱含意思就是說面向對象編程是這三項的有機組合,或者任何一種支持面向對象的程式語言必須支持這三個特性。
封裝通過釆用封裝特性,我們可以把一組相關聯的數據和函數圈起來,使圈外面的代碼只能看見部分函數,數據則完全不可見。這裡舉了一個c語言版本封裝的例子。使用 point.h 的程序是沒有 Point 結構體成員的訪問權限的。它們只能調用 makePoint() 函數和 distance() 函數,但對它們來說,Point 這個數據結構體的內部細節,以及函數的具體實現方式都是不可見的。
point.h
struct Point;
struct Point* makePoint(double x, double y);
double distance (struct Point *p1, struct Point *p2);point.c
#include "point.h"
#include <stdlib.h>
#include <math.h>
struct Point {
double x,y;
};
struct Point* makepoint(double x, double y) {
struct Point* p = malloc(sizeof(struct Point));
p->x = x;
p->y = y;
return p;
}
double distance(struct Point* p1, struct Point* p2) {
double dx = p1->x - p2->x;
double dy = p1->y - p2->y;
return sqrt(dx*dx+dy*dy);
}C++通過在程式語言層面引入 public、private、protected 這些關鍵詞,部分維護了封裝性。但所有這些都是為了解決編譯器自身的技術實現問題而引入的 hack——編譯器由於技術實現原因必須在頭文件中看到成員變量的定義。
而 Java 和 C# 則徹底拋棄了頭文件與實現文件分離的編程方式,這其實進一步削弱了封裝性。因為在這些語言中,我們是無法區分一個類的聲明和定義的。
所以我們很難說強封裝是面向對象編程的必要條件。而事實上,有很多面向對象程式語言|對封裝性並沒有強制性的要求。
繼承繼承的主要作用是讓我們可以在某個作用域內對外部定義的某一組變量與函數進行覆蓋。
namedPoint.h
struct NamedPoint;
struct NamedPoint* makeNamedPoint(double x, double y, char* name);
void setName(struct NamedPoint* np, char* name);
char* getName(struct NamedPoint* np);namedPoint.c
#include "namedPoint.h"
#include <stdlib.h>
struct NamedPoint {
double x,y;
char* name;
};
struct NamedPoint* makeNamedPoint(double x, double y, char* name) {
struct NamedPoint* p = malloc(sizeof(struct NamedPoint));
p->x = x;
p->y = y;
p->name = name;
return p;
}
void setName(struct NamedPoint* np, char* name) {
np->name = name;
}
char* getName(struct NamedPoint* np) {
return np->name;
}main.c
#include "point.h"
#include "namedPoint.h"
#include <stdio.h>
int main(int ac, char** av) {
struct NamedPoint* origin = makeNamedPoint(0.0, 0.0, "origin");
struct NamedPoint* upperRight = makeNamedPoint (1.0, 1.0, "upperRight");
printf("distance=%f\n",
distance(
(struct Point*) origin,
(struct Point*) upperRight));
}可以看出NamedPoint 之所以可以被偽裝成 Point 來使用,是因為 NamedPoint 是 Point 結構體的一個超集,同兩者共同成員的順序也是一樣的。這種編程方式雖然看上去有些投機取巧,但是在面向對象理論被提出之前,這已經很常見了。其實,C++內部就是這樣實現單繼承的。在 main.c 中,程式設計師必須強制將 NamedPoint 的參數類型轉換為 Point,而在真正的面向對象程式語言中,這種類型的向上轉換通常應該是隱性的。因此,我們可以說,早在面向對象程式語言被發明之前,對繼承性的支持就已經存在很久了。
綜上所述,我們可以認為,雖然面向對象編程在繼承性方面並沒有開創出新,但是的確在數據結構的偽裝性上提供了相當程度的便利性。分析得出 面向對象編程在封裝性上得 0 分,在繼承性上勉強可以得 0.5 分(滿分為 1)。
多態在面向編程對象語言被發明之前,我們所使用的程式語言能支持多態嗎? 答案是肯定的。那是怎麼做的呢?函數指針。調用的方法會指向函數指針指向的函數。UNIX 作業系統強制要求每個 IO 設備都要提供 open、close、read、write 和 seek 這 5 個標準函數。所以IO設備需要支持這些接口函數。為什麼 UNIX 作業系統會將 IO 設備設計成插件形式呢?因為自 20 世紀 50 年代末期以來,我們學到了一個重要經驗:程序應該與設備無關。 這個經驗從何而來呢?因為一度所有程序都是設備相關的,但是後來我們發現自己其實真正需要的是在不同的設備上實現同樣的功能。歸根結底,多態其實不過就是函數指針的一種應用。自從 20 世紀 40 年代末期馮·諾依曼架構誕生那天起,程式設計師們就一直在使用函數指針模擬多態了。也就是說,面向對象編程在多態方面沒有提出任何新概念。
依賴反轉想像一下在安全和便利的多態支持出現之前,軟體是什麼樣子的。下面有一個典型的調用樹的例子如圖5.1,main 函數調用了一些高層函數,這些高層函數又調用了一些中層函數,這些中層函數又繼續調用了一些底層函數。在這裡,系統行為決定了控制流,而控制流則決定了原始碼依賴關係。
一旦我們使用了多態,情況就不一樣了。如圖5.2。模塊 HL1 調用了 ML1 模塊中的 F() 函數,這裡的調用是通過原始碼級別的接口來實現的。
現在,我們可以再回頭來看圖 5.1 中的調用樹,就會發現其中的眾多原始碼依賴關係都可以通過引入接口的方式來進行反轉。通過這種方法,軟體架構師可以完全控制採用了面向對象這種編程方式的系統中所有的原始碼依賴關係,而不再受到系統控制流的限制。不管哪個模塊調用或者被調用,軟體架構師都可以隨意更改原始碼依賴關係。
圖5.3 依賴反轉可以使資料庫模塊和用戶界面模塊都依賴於業務邏輯模塊。我們讓用戶界面和資料庫都成為業務邏輯的插件。也就是說,業務邏輯模塊的原始碼不需要引入用戶界面和資料庫這兩個模塊。
這樣一來,業務邏輯、用戶界面以及資料庫就可以被編譯成三個獨立的組件或者部署單元(例如 jar 文件、DLL 文件、Gem 文件等)了,這些組件或者部署單元的依賴關係與原始碼的依賴關係是一致的,業務邏輯組件也不會依賴於用戶界面和資料庫這兩個組件。
業務邏輯組件就可以獨立於用戶界面和資料庫來進行部署了,我們對用戶界面或者資料庫的修改將不會對業務邏輯產生任何影響,這些組件都可以被分別獨立地部署。
如果系統中的所有組件都可以獨立部署,那它們就可以由不同的團隊並行開發,這就是所謂的獨立開發能力。
小結面向對象編程到底是什麼?業界在這個問題上存在著很多不同的說法和意見。然而對一個軟體架構師來說,其含義應該是非常明確的:面向對象編程就是以對象為手段來對原始碼中的依賴關係進行控制的能力, 這種能力讓軟體架構師可以構建出某種插件式架構,讓高層策略性組件與底層實現性組件相分離,底層組件可以編譯成插件,實現獨立於高層組件的開發和部署。
Chap6.函數式編程函數式編程所依賴的原理,在很多方面其實是早於編程本身出現的。因為函數式編程這種範式強烈依賴於 Alonzo Church 在 20 世紀 30 年代發明的 λ 演算。
函數式程式語言中的變量(Variable)是不可變(Vary)的。
不可變性與軟體架構為什麼不可變性是軟體架構設計需要考慮的重點呢?為什麼軟體架構帥要操心變量的可變性呢?答案顯而易見:所有的競爭問題、死鎖問題、並發更新問題都是由可變變量導致的。 如果變量永遠不會被更改,那就不可能產生競爭或者並發更新問題。如果鎖狀態是不可變的,那就永遠不會產生死鎖問題。
作為一個軟體架構師,當然應該要對並發問題保持高度關注。我們需要確保自己設計的系統在多線程、多處理器環境中能穩定工作。
可變性的隔離一種常見方式是將應用程式,或者是應用程式的內部服務進行切分,劃分為可變的和不可變的兩種組件。不可變組件用純函數的方式來執行任務,期間不更改任何狀態。這些不可變的組件將通過與一個或多個非函數式組件通信的方式來修改變量狀態(參見圖 6.1)。
由於狀態的修改會導致一系列並發問題的產生,所以我們通常會採用某種事務型內存來保護可變變量,避免同步更新和競爭狀態的發生。事務型內存基本上與資料庫保護磁碟數據的方式 1 類似,通常釆用的是事務或者重試機制。
這裡的要點是:一個架構設計良好的應用程式應該將狀態修改的部分和不需要修改狀態的部分隔離成單獨的組件,然後用合適的機制來保護可變量。
軟體架構師應該著力於將大部分處理邏輯都歸於不可變組件中,可變狀態組件的邏輯應該越少越好。
事件溯源這裡舉了個簡單的例子,假設某個銀行應用程式需要維護客戶帳戶餘額信息,當它放行存取款事務時,就要同時負責修改餘額記錄。
如果我們不保存具體帳戶餘額,僅僅保存事務日誌,那麼當有人想查詢帳戶餘額時。我們就將全部交易記錄取出,並且每次都得從最開始到當下進行累計。當然,這樣的設計就不需要維護任何可變變量了。
事件溯源,在這種體系下,我們只存儲事務記錄,不存儲具體狀態。當需要具體狀態時,我們只要從頭開始計算所有的事務即可。
這種數據存儲模式中不存在刪除和更新的情況,我們的應用程式不是 CRUD,而是 CR。因為更新和刪除這兩種操作都不存在了,自然也就不存在並發問題。如果我們有足夠大的存儲量和處理能力,應用程式就可以用完全不可變的、純函數式的方式來編程。
小結每個範式都約束了某種編寫代碼的方式,沒有一個編程範式是在增加新能力。
我們必須面對這種不友好的現實:軟體構建並不是一個迅速前進的技術。今天構建軟體的規則和 1946 年阿蘭·圖靈寫下電子計算機的第一行代碼時是一樣的。儘管工具變化了,硬體變化了,但是軟體編程的核心沒有變。
總而言之,軟體,或者說電腦程式無一例外是由順序結構、分支結構、循環結構和間接轉移這幾種行為組合而成的,無可增加,也缺一不可。
總結名言警句:
三個編程範式,它們分別是結構化編程(structured programming)、 面向對象編程(object-oriented programming)以及函數式編程(functional programming)。結構化編程範式:對程序控制權的直接轉移進行了限制和規範。面向對象編程範式:對程序控制權的間接轉移進行了限制和規範。面向對象編程在封裝性上得 0 分,在繼承性上勉強可以得 0.5 分(滿分為 1)。多態是我們跨越架構邊界的手段,函數式編程是我們規範和限制數據存放位置與訪問權限的手段,結構化編程則是各模塊的算法實現基礎。如果系統中的所有組件都可以獨立部署,那它們就可以由不同的團隊並行開發,這就是所謂的獨立開發能力。面向對象編程就是以對象為手段來對原始碼中的依賴關係進行控制的能力。所有的競爭問題、死鎖問題、並發更新問題都是由可變變量導致的。一個架構設計良好的應用程式應該將狀態修改的部分和不需要修改狀態的部分隔離成單獨的組件,然後用合適的機制來保護可變量。每個範式都約束了某種編寫代碼的方式,沒有一個編程範式是在增加新能力。軟體,或者說電腦程式無一例外是由順序結構、分支結構、循環結構和間接轉移這幾種行為組合而成的,無可增加,也缺一不可。關於整潔架構之道的第二部分關於三種編程範式的記錄土撥鼠今天就介紹到這裡了。第三部分從設計原則(SOLID)開始,敬請期待。如果有不同見解歡迎留言討論。