本文實踐環境:
Operating System: CentOS Linux release 8.4.2105
Kernel: 4.18.0-305
Architecture: x86-64
剛學習完《Clean Architecture》第 2 部分編程範式相關內容,為提高學習留存率,加深理解,故將相關內容進行簡單總結。
三大編程範式梳理:
從上面的表格中可以看到一個比較有意思的事情,編程範式的提出時間與編程範式的普及正好相反。回顧過去幾十年的發展歷史,結構化編程最先為大眾所接受,然後是面向對象編程,最後是現在慢慢為大家推崇的函數式編程。我個人對這個發展趨勢的原因分析是:程序規模的不斷膨脹,以及得益於摩爾定律而發展起來的硬體(如 CPU,內存,磁碟等)性能的大幅度提升。
1、goto 是有害的
結構化編程是相對於非結構化編程而言的。比如,對於 C 這類高級語言來說,彙編語言就是非結構化的。如果用彙編語言進行編程,你面對的將是各種寄存器和內存地址,以及各種指令:比如 add, push, pop, mov, cmp, jmp等等。由於沒有 if/else 這類分支結構,所以代碼中都是通過比較指令進行判斷,然後再通過跳轉指令跳到對應的地方執行。
可以想像得到,這樣編寫出來的代碼一旦程序規模擴大,就會失控(代碼隨意跳來跳去),換個新人接手代碼,不但不好維護,閱讀起來都非常費力。於是,Dijkstra 在 1968 年提出了《Go To Statement Considered Harmful》,並且被最終證明是對的。
2、功能分解是最佳實踐之一
在 Dijkstra 研究結構化編程之時,Bohm 和 Jocopini 證明了人們可以用順序結構、分支結構、循環結構這三種結構構造出任何程序。Dijkstra 使用枚舉法證明了順序結構的正確性、分支結構的可推導性,而循環結構的正確性則稍有不同,它是通過數學歸納法證明的。
結構化編程通過拆分,可以將一個大程序自上而下拆分成一個個小單元,如此一來,再複雜的問題也能解決。以此為基礎,便出現了後面的結構化分析和結構化設計等工作方式。而我們平常工作時,經常需要進行任務分解,也是同一個道理。
3、測試只能證明 bug 存在,不能證明沒有 bug
科學研究與數學有所不同,數學可以被證明,但科學只能被證偽,因為在某個特殊的場景下,科學定律可能就不成立了。但在沒有遇到這個特殊場景之前,如果科學定律都被證明是正確的,那麼我們就認為它在當下是對的。
同樣,一段程序可以由一個測試來證明其錯誤性,卻不能被證明它沒有其他缺陷。即程序與科學定律類似,可以被證偽,卻不能被證明。如果分解後的每個小程序都證明沒有錯誤,則認為對應的大程序暫時是正確的。但如果程序中無節制的使用 goto 語句,那麼寫多少測試用例也無法證明它當下是正確的,因為 goto 語句的某些用法會導致某個模塊無法被遞歸拆分成更小的、可證明的單元。這就是為什麼要限制使用 goto 語句的原因。
1、封裝
封裝不能完全說是面向對象編程的必要條件,因為相對於 C 語言這類結構化程式語言來說,C++,Java,C# 的封裝特性是被逐漸虛弱了,而不是增強。
怎麼理解?先看一個簡單的程序:
// 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 <math.h>
#include <stdlib.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);
}
另外一個程序通過 #include "point.h" 即可使用 makePoint() 和 distance() 兩個函數,但是對於 Point 結構體成員的訪問權限,它是沒有的。通過這種頭文件與實現文件分離的方式,完美的封裝了 Point 結構體的內部細節以及函數的實現細節。
接著再看 C++ 版本的寫法:
// point.h
class Point{
public:
Point(double x, double y);
double distance(const Point& p) const;
private:
double x;
double y;
};
// point.cpp
#include "point.h"
#include <math.h>
Point::Point(double x, double y): x(x), y(y){}
double Point::distance(const Point& p) const{
double dx = x-p.x;
double dy = y-p.y;
return sqrt(dx*dx + dy*dy);
}
與前面的 C 語言相比,C++ 的封裝性削弱了,因為頭文件中暴露了類的成員變量 x 和 y。而之所以需要在頭文件中暴露成員變量,卻是因為 C++ 語言特性:C++ 編譯器必須要知道每個類實例的大小。
最後我們再看看用 Java 寫這段代碼的樣子:
package com.learn.core.blog;
public class Point {
private double x;
private double y;
public Point(double x, double y) {
this.x = x;
this.y = y;
}
public double distance(Point p) {
double dx = x - p.x;
double dy = y - p.y;
return Math.sqrt(dx * dx + dy * dy);
}
}
很明顯,Java 在 C++ 的基礎上直接將頭文件中類的聲明給幹掉了,面向對象的封裝性進一步被削弱了,因為我們已經無法區分一個類的聲明和定義了。
2、繼承
繼承的主要作用是:讓我們可以在某個作用域內對外部定義的某一組變量與函數進行覆蓋。
在面向對象程式語言發明之前,繼承就已經存在了,只是實現起來有些投機取巧,不像如今的繼承這樣方便使用。是怎樣投機取巧的呢?下面筆者便把書中的例子演示一遍,你一看便明白了。
在 point.h 和 point.c 的基礎上,新增 namedPoint.h 和 namedPoint.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 函數入口的代碼:
// 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(3.0, 4.0, "upperRight");
double distanceValue = distance((struct Point*) origin, (struct Point*) upperRight);
printf("distanceValue=%f\n", distanceValue);
}
注意上面調用 distance() 函數的代碼,將 NamedPoint 類型的變量強制轉換為 Point 類型:
double distanceValue = distance((struct Point*) origin, (struct Point*) upperRight);
之所以可以這樣偽裝,是因為 NamedPoint 是 Point 結構體的一個超集,同時兩者共同成員的順序也是一樣的。這便是所謂的投機取巧(我們可以將其理解為 NamedPoint 繼承自 Point)。
編譯運行結果如下:
➜ chapter5-inheritance$ ll
total 44
-rw-rw-r-- 1 demonlee demonlee 377 Sep 29 22:54 main.c
-rw-rw-r-- 1 demonlee demonlee 137 Sep 29 23:20 Makefile
-rwxrwxr-x 1 demonlee demonlee 17776 Oct 1 21:42 namedPoint
-rw-rw-r-- 1 demonlee demonlee 433 Sep 29 22:40 namedPoint.c
-rw-rw-r-- 1 demonlee demonlee 174 Sep 29 22:36 namedPoint.h
-rw-rw-r-- 1 demonlee demonlee 386 Sep 29 22:29 point.c
-rw-rw-r-- 1 demonlee demonlee 113 Sep 29 22:28 point.h
➜ chapter5-inheritance$
➜ chapter5-inheritance$ cat Makefile
objs=point.o namedPoint.o main.o
all: namedPoint
namedPoint: ${objs}
gcc -o $@ $? -lm
echo "### $@ created ###\n"
clean:
rm -f *.o
➜ chapter5-inheritance$
➜ chapter5-inheritance$ make all
cc -c -o point.o point.c
cc -c -o namedPoint.o namedPoint.c
cc -c -o main.o main.c
gcc -o namedPoint point.o namedPoint.o main.o -lm
echo "### namedPoint created ###\n"
### namedPoint created ###\n
➜ chapter5-inheritance$
➜ chapter5-inheritance$ ./namedPoint
distanceValue=5.000000
➜ chapter5-inheritance$
3、多態
如你想像,在面向對象程式語言發明之前,多態也是支持的,比如 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);
}
不同的設備都會實現這幾個函數,所以當切換設備時,業務上層代碼可以做到不用改動就能用了,即程序與設備無關,這也就是控制邏輯與業務邏輯分離的好處。
為什麼UNIX作業系統會將IO設備設計成插件形式呢?因為自20世紀50年代末期以來,我們學到了一個重要經驗:程序應該與設備無關。這個經驗從何而來呢?因為一度所有程序都是設備相關的,但是後來我們發現自己其實真正需要的是在不同的設備上實現同樣的功能。
上面的 FILE 結構體有 5 個函數指針,不同的設備會對這個結構體賦不同的函數指針,所以說多態其實就是函數指針的一種應用。但是用函數指針的壞處就是風險高,結構體中的值可以被修改,這樣就必須依靠人為約定,一旦有人不小心犯錯,就會失控。
面向對象程式語言中將這種一個接口多個不同實現中的函數指針賦值給隱藏了,下沉到運行時中去實現。簡單來說會有一張虛擬函數表,不同的子類有不同的虛擬表,虛擬表中記錄了每個函數對應的函數指針。
在面向對象的多態出現之前,軟體原始碼中的依賴與程序控制流的方向一致,即 B 模塊調用 A 模塊,B 模塊調用 C 模塊,那麼 A <-- B --> C (B 依賴 A,B 依賴 C)。也就是,系統行為決定了控制流,而控制流則決定了原始碼依賴關係。
依賴
有了多態之後,通過引入一個中間抽象層(接口),讓上層依賴這個接口,而接口的實現有各種情況,與上層無關,只與接口關聯,如下圖所示:
依賴反轉
此時,實際的控制流(代碼實際調用)的方向,與具體業務實現的方向是相反的,這就是所謂的依賴反轉。
1、What
函數式編程中的函數不是軟體中的函數,而是來自數學中的函數,比如:
f(x)=2x+1
數學中的函數有一個特點,就是無論調用多少次,相同的入參,永遠都會獲得相同的結果。簡單一點說,函數式編程中的函數無副作用,天生就是冪等的,但又不止步於此。比如,函數式程式語言中的變量是不可變的。在這裡,我們將這種函數稱之為純函數。
面向對象編程中,類或對象是一等公民,而函數式編程中函數是一等公民。
2、Why
那為啥要不可變呢?很簡單:在當前多處理器架構的伺服器上跑的程序,所有的競爭問題、死鎖問題等,都是由可變變量導致的。如果可以做到變量不可變,函數不可變,那麼這些問題通通沒有了,複雜度立即便降低了一個檔次。假設我們忽略存儲器與處理器在速度上的限制,不可變性的實現是可行的。
3、How
如何實現不可變性?答案是:分離關注點。
將一個服務中的內部組件進行拆分,一種是可變的,一種是不可變的。不可變的部分用純函數的方式來實現,不改變任何狀態。而這些不可變組件通過與一個或多個非函數式組件通信的方式來修改變量狀態。
可變性隔離
軟體架構師需要花大力氣將大部分處理邏輯歸併到不可變組件中,將可變組件的數量降至最低,最後用合適的機制來保護可變量。
另外一個可行的解決方案是事件溯源,也就是只記錄事務操作日誌,而不記錄最終的計算結果。如果要結果呢?那就將之前的事務日誌數據都拿出來,按照時間順序從頭到尾計算一遍。如此一來,也就不用維護任何可變變量了,CRUD 中的 U(更新)和 D(刪除)沒了,變成了 CR。
是不是覺得不可思議,可我們用的原始碼管理工具(Svn,Git等),不正是按照這種方式來工作的嗎。只要要足夠大的存儲能力和計算能力,我們完全可以使用這種方式來實現函數式編程。
三大編程範式是對程式設計師提出的限制,限制就是規則,從而降低程序故障或問題出現的概率,同時提高編程的效率。
結構化編程從微觀上解決程序的實現邏輯,但因為是微觀,所以抽象程度不高,搞不定大規模的軟體系統。
面向對象編程便克服了結構化編程的不足,從更高的宏觀層面,對物理世界進行建模,然後再按照一定的規則進行組織。比如,以多態為手段來對原始碼中的依賴關係進行控制,構建出某種插件式架構,讓高層策略性組件與底層實現性組件相分離,底層實現性組件便可以被編譯成插件,實現獨立開發和部署。
函數式編程之所以限制賦值操作,是為了提高程序的不可變性,防止自己定義的變量被別人隨意賦值更改。除了不可變性,函數式編程中還有組合性,在後續的學習中,筆者還會進行相關梳理和總結。
Clean Architecture: https://book.douban.com/subject/30333919,by Robert C. Martin
軟體設計之美: https://time.geekbang.org/column/intro/100052601?tab=catalog,by 鄭曄
Go To Statement Considered Harmful: https://homepages.cwi.nl/~storm/teaching/reader/Dijkstra68.pdf,by Dijkstra