【Clean Architecture-學習筆記-1】編程範式

2021-12-09 華仔的網絡日誌

收錄於話題 #軟體架構 1個內容

本文實踐環境:
Operating System: CentOS Linux release 8.4.2105
Kernel: 4.18.0-305
Architecture: x86-64

剛學習完《Clean Architecture》第 2 部分編程範式相關內容,為提高學習留存率,加深理解,故將相關內容進行簡單總結。

三大編程範式梳理:

從上面的表格中可以看到一個比較有意思的事情,編程範式的提出時間與編程範式的普及正好相反。回顧過去幾十年的發展歷史,結構化編程最先為大眾所接受,然後是面向對象編程,最後是現在慢慢為大家推崇的函數式編程。我個人對這個發展趨勢的原因分析是:程序規模的不斷膨脹,以及得益於摩爾定律而發展起來的硬體(如 CPU,內存,磁碟等)性能的大幅度提升。

structured programming

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 語句的原因。

object-oriented programming

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)。也就是,系統行為決定了控制流,而控制流則決定了原始碼依賴關係。

依賴

有了多態之後,通過引入一個中間抽象層(接口),讓上層依賴這個接口,而接口的實現有各種情況,與上層無關,只與接口關聯,如下圖所示:

依賴反轉

此時,實際的控制流(代碼實際調用)的方向,與具體業務實現的方向是相反的,這就是所謂的依賴反轉。

functional programming

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

相關焦點

  • 《clean architecture》第二部分編程範式讀書筆記
    編程範式總覽Chap4. 結構化編程Chap5. 面向對象編程Chap6.函數式編程總結前言上次土撥鼠水了一篇第一部分的讀書筆記,《clean architecture》第一部分筆記。今天再來水一下第二部分關於編程範式的筆記。
  • 聊聊架構:Easy Clean architecture on Android
    本文的代碼示例可以從 Github 中獲得,倉庫地址是:https://github.com/SmartDengg/android-easy-cleanarchitectureWhy we need an architecture?
  • 整潔架構(Clean Architecture)的Go微服務: 程序結構
    點擊上方藍色「Go語言中文網」關注我們,領全套Go資料,每天學習 Go 語言我使用 Go 和 gRPC 創建了一個微服務,並試圖找出最佳的程序結構,它可以用作我未來程序的模板。我有 Java 背景,並發現自己在 Java 和 Go 之間掙扎,它們之間的編程理念完全不同。我寫了一系列關於在項目工作中做出的設計決策和取捨的文章。
  • 程式語言學習心得 (1)-- 掌握編程範式優於牢記語法
    由於涉及編程學習的不同方面,內容較多,將分為多篇內容向大家逐一推送。掌握編程範式優於牢記語法各種程式語言裡的獨特語法簡直是五花八門,下面就隨便選取了其中幾種語言,看看你們知道他們都是什麼語言嗎?1.其實對於程式語言的學習,意義最大,收穫最大的就是對於編程思想的學習。正如著名的計算機學者,首位圖靈獎獲得者,Alan Perlis說的那樣如果一個程式語言不能夠影響你的編程思維,這個語言便不值得學習。
  • 一種新的編程範式
    今天給大家介紹一下一種新的編程範式,這會對大家有所幫助。Vyper的創作為新的編程範式打開了大門。例如,Vyper正在刪除類繼承以及其他功能,因此可以說Vyper偏離了傳統的面向對象編程(OOP)範例,這很好。歷史上,OOP提供了一種表示現實世界對象的機制。例如,OOP允許實例化可以從person類繼承的employee對象。
  • 多範式程式語言-以Swift為例
    由於代表了語言背後的思想,編程範式很大程度上決定了語言會呈現為何種面貌。用不著深入學習,僅僅瀏覽代碼,就能發現 Scala 和 Swift 很類似,這是因為它們支持的編程範式是類似的;Scheme 和 Swift 看起來就相差很遠,這是因為它們支持的編程範式很不一樣。對於理解一門程式語言而言,相對於語言的語法和編寫經驗,理解語言的編程範式是更重要的。
  • Swift 不是多範式函數式程式語言
    在Swift中,你基本上是以一個過程式編程/面向對象編程的範式進行工作,並且整個語言都是圍繞這個範式構建的。當有需要時,有一些工具可以讓你跳到函數編程樣式(但沒有函數式編程的全部功能)。在Haskell中,你基本上是以函數式編程的範式進行工作。有一些可用的工具(Monad)可以讓你在需要過程編程樣式(沒有過程編程的全部功能)時,跳轉到它。
  • 左耳聽風系列之編程範式
    本章導航地圖:1.什麼是編程範式?編程範式:Programming Paradigm。即模範的意思,範式即方式、方法,是一種典型的編程風格。說白了就是指從事軟體工程的一種方法論。4.1面向對象編程範式的定義面向對象編程是一種具有對象概念的程序編程範型,同時也是一種程序開發的抽象方針,它可能包含數據、屬性、代碼與方法。它將對象作為程序的基本單元,將程序和數據封裝其中,以提高軟體的可重用性、靈活性和可擴展性,對象裡的程序可以訪問及修改對象相關聯的數據。在面向對象編程裡,電腦程式會被設計成彼此相關的對象。
  • 淺談程式語言合理的學習順序
    再之後想學就是返回來學習 C 語言,彙編語言,再加上一點硬體知識和計算機體系結構的學習。這時你會發現萬能的 C 其實也不是能力最強大的,C 只是彙編的高層抽象與封裝,彙編的世界裡是很神奇和強大的,幾近可以為所欲為。
  • MXNet設計筆記之:深度學習的編程模式比較
    尤為難得的是,MXNet開發團隊把設計筆記也做了分享。筆記的思想不局限於MXNet,也不局限於深度學習,無論對深度學習初學入門還是對高階提升,都具有很好的參考價值。本文是第一篇設計筆記的譯文,深入討論了不同深度學習庫的接口對深度學習編程的性能和靈活性產生的影響。市面上流行著各式各樣的深度學習庫,它們風格各異。那麼這些函數庫的風格在系統優化和用戶體驗方面又有哪些優勢和缺陷呢?
  • 程式語言學習心得 (精簡版) -- 不要害怕遺忘
    所以,快速學習和掌握程式語言一直以來都是每一個工程師夢最想要擁有的超能力。我從小學開始學習編程,在後來17年的職業生涯中也主動和被動的學習了一眾程式語言,如C/C++,Java,Python,Haskell,Groovy,Scala,Clojure,Go等等,在這期間付出了很多努力,取得了不少經驗,當然也走過了更多彎路。下面分享一下自己的學習心得,希望可以對大家的學習有所幫助和借鑑。
  • 2020年,五個學習一門新程式語言的理由
    但如果你已經正確地掌握了一種語言,或者你是一名經驗豐富的軟體開發人員,已經掌握了不止一種程式語言,那麼我建議你明年學習一種新的語言。學習一門新的程式語言是要付出代價的,會耗費大量的時間、精力和腦力。但學習一門新的語言可以直接或間接地給你帶來巨大的好處。
  • MDH 前端周刊第 18 期:stitches 1、ultra、7GUIs、Clean 架構
    ,支持 Stitches 組件的多態,支持組合、推導、布爾值、默認值和響應式tokens,通過 token 實現設計風格的原子化theming,主題的定義和使用overrides,通過 css prop 覆蓋 Stitches 組件樣式terser-webpack-plugin
  • 劉海龍《大眾傳播理論:範式與流派》筆記和課後習題(含考研真題)詳解
    在線題庫:劉海龍《大眾傳播理論:範式與流派》筆記和課後習題(含考研真題)詳解
  • 學習筆記系列NO.1 SQL學習筆記及資料分享
    本期就是一期非常良心的學習資料分享~希望大家能覺得有用~為什麼學SQL呢,就是因為看到越來越多的產品崗,數據崗,戰略崗甚至營銷崗都開始要求會使用
  • Python Socket 編程學習筆記
    瓜瓜君按:通過Python Socket模塊,實現server和client的通訊,這篇是一些學習筆記。1.
  • C++語言學習筆記1
    聲明:       本系列是我從csdn上學習c++語言基礎記錄的筆記,是跟著賀利堅老師所學,這位老師在教學上有獨特的見解,而且人非常耐心,他的課程也非常適合我這種跨專業程式語言學習者。        本系列將記錄C++語言學習的點點滴滴,內容中會有部分賀老師的課件截圖,現已獲得賀老師授權,發出來分享給大家。
  • Python生物統計---前言及Flag---學習筆記1
    現在想起這句話, 我聯想到我數次學習Python時都半途而廢, 真真的從開始到放棄, 從入門到出家, 主要原因就在於我沒有就此寫一本書…這次迷途知返, 那就寫讀書筆記吧.為了方便編程, 我將輸入法默認的符號變成了英文的形式, 所以我的逗號, 句號, 都是英文的, 特此聲明.
  • C/C++編程筆記:string at()函數,及其使用方法
    它支持兩種具有相似參數的不同語法:語法1:char&string :: at(size_type idx)語法2:const char&string :: at(size_type idx)constidx:索引號兩種形式都返回具有索引idx的字符(第一個字符具有索引0)。
  • 學習 Shell 腳本編程的免費資源 | Linux 中國
    當談到 shell 腳本編程的時候,也就意味著 —— 用戶希望使用腳本來執行多條命令來獲得一個輸出。也許你需要學習 shell 腳本編程作為你的課程或者工作的一部分。了解 shell 腳本編程也可以幫助你在 Linux 中自動化某些重複的任務。不管出於什麼原因學習 shell 腳本編程,都可以看看這些我給你展示的資源。