聊聊編程范式

什么是編程范式

編程范式一詞最早來自 Robert Floyd 在 1979 年圖靈獎的頒獎演說,是程序員看待程序應該具有的觀點,代表了程序設計者認為程序應該如何被構建和執行的看法,與軟件建模方式和架構風格有緊密關系。

現在主流的編程范式有三種:

  • 結構化編程(structured programming)
  • 面向對象編程(object-oriented programming)
  • 函數式編程(functional programming)

這幾種編程范式之間的關系如下:


relations.png

如果你對上圖中編程范式之間的關系已理解得非常透徹,那就沒有必要再往下看了,否則建議耐心看完本文,在過程中可以跳過熟悉的章節。

眾所周知,計算機運行在圖靈機模型之上。最初,程序員通過紙帶將指令和數據輸入到計算機,計算機執行指令,完成計算。后來,程序員編寫程序(包括指令和數據),將程序加載到計算機,計算機執行指令,完成計算。時至今日,軟件已經非常復雜,規模也很大,人們通過軟件來解決各個領域(Domain)的問題,比如通信,嵌入式,銀行,保險,交通,社交,購物等。

thinking-base.png

人們把一個個具體的領域問題跑在圖靈機模型上,然后做計算,而領域問題和圖靈機模型之間有一個很大的 gap(What,How,Why),這是程序員主要發揮的場所。編程范式是程序員的思維底座,決定了設計元素和代碼結構。程序員把領域問題映射到某個編程范式之上,然后通過編程語言來實現。顯然,編程范式到圖靈機模型的轉化都由編譯器來完成,同時這個思維底座越高,程序員做的就會越少。

你可能會有一個疑問:為什么會有多個編程范式?換句話說,就是程序員為什么需要多個思維底座,而不是一個?

思維底座取決于程序員看待世界的方式,和哲學及心里學都有關。程序員開發軟件是把現實中的世界模擬到計算機中來運行,每個程序員在這個時候都相當于一個造物主,在計算機重新創造一個特定領域的世界,那么如何看待這個世界就有些哲學觀的味道在里面。這個虛擬世界的最小構筑物是什么?每個構筑物之間的關系是什么?用什么方式把這個虛擬世界層累起來。隨著科學技術的演進,人們看待世界的方式會發生變化,比如生物學已經演進到細胞,自然科學已經演進到原子,于是程序員模擬世界的思維底座也會發生變化。

程序員模擬的世界最終要跑在圖靈機模型上,這就有經濟學的要求,成本越小越好。資源在任何時候都是有限的,性能是有約束的,不同的編程范式有不同的優缺點,程序員在解決領域問題時需要有多個思維底座來進行權衡取舍,甚至融合。

為了能更深刻的理解編程范式,我們接下來一起回顧一下編程范式的簡史。

編程范式簡史

paradigm-history.png

機器語言使用 0 和 1 組成的二進制序列來表達指令,非常晦澀難懂。匯編語言使用助記符來表達指令,雖然比機器語言進步了一些,但編寫程序仍然是一件非常痛苦的事情。匯編語言可以通過匯編(編譯)得到機器語言,機器語言可以通過反匯編得到匯編語言。匯編語言和機器語言一一對應,都是直接面向機器的低級語言,最貼近圖靈機模型。

站在結構化編程的視角,機器語言和匯編語言也是有編程范式的,它們的編程范式就是非結構化編程。當時 goto 語句滿天飛,程序及其難以維護。后來,大家對于 goto 語句是有害的達成了共識,就從編程語言設計上把 goto 語句拿掉了。

隨著計算機技術的不斷發展,人們開始尋求與機器無關且面向用戶的高級語言。無論何種機型的計算機, 只要配備上相應高級語言的編譯器,則用該高級語言編寫的程序就可以運行。首先被廣泛使用的高級語言是 Fortran,有效的降低了編程門檻,極大的提升了編程效率。后來 C 語言橫空出世,它提供了對于計算機而言較為恰當的抽象,屏蔽了計算機硬件的諸多細節,是結構化編程語言典型代表。時至今日,C 語言依然被廣泛使用。

當高級語言大行其道以后,人們開發的程序規模逐漸膨脹,這時如何組織程序變成了新的挑戰。有一種語言搭著 C 語言的便車將面向對象的設計風格帶入主流視野,這就是 C++,它完全兼容 C 語言。在很長一段時間內,C++ 風頭十足,成為行業中最主流的編程語言。后來,計算機硬件的能力得到了大幅提升,Java 語言脫穎而出。Java 語言假設程序的代碼空間是開放的,在 JVM 虛擬機上運行,一方面支持面向對象,另一方面支持 GC 功能。

不難看出,編程語言的發展就是一個逐步遠離計算機硬件,向著待解決的領域問題靠近的過程。所以,編程語言后續的發展方向就是探索怎么更好的解決領域問題

前面說的這些編程語言只是編程語言發展的主流路徑,其實還有一條不那么主流的路徑也一直在發展,那就是函數式編程語言,這方面的代表是 Lisp。首先,函數式編程的主要理論基礎是 Lambda 演算,它是圖靈完備的;其次,函數式編程是抽象代數思維,更加接近現代自然科學,使用一種形式化的方式來解釋世界,通過公式來推導世界,極度抽象(比如 F=ma)。在這條路上,很多人都是偏學術風格的,他們關注解決方案是否優雅,如何一層層構建抽象。他們也探索更多的可能,垃圾回收機制就是從這里率先出來的。但函數式編程離圖靈機模型太遠了,在圖靈機上的運行性能得不到直接的支撐,同時受限于當時硬件的性能,在很長一段時間內,這條路上的探索都只是學術圈玩得小眾游戲,于是函數式編程在當時被認為是一個在工程上不成熟的編程范式。當硬件的性能不再成為阻礙,如何解決問題開始變得越來越重要時,函數式編程終于和編程語言發展的主流路徑匯合了。促進函數式編程引起廣泛重視還有一些其他因素,比如多核 CPU 和分布式計算。

編程范式是抽象的,編程語言是具體的。編程范式是編程語言背后的思想,要通過編程語言來體現。編程范式的世界觀體現在編程語言的核心概念中,編程范式的方法論體現在編程語言的表達機制中,一種編程語言的語法和風格與其所支持的編程范式密切相關。雖然編程語言和編程范式是多對多的關系,但每一種編程語言都有自己的主流編程范式。比如,C 語言的主流編程范式是結構化編程,而 Java 語言的主流編程范式是面向對象編程。程序員可以打破“次元壁”,將不同編程范式中的優秀元素吸納過來,比如在 linux 內核代碼設計中,就將對象元素吸納了過來。無論在以結構化編程為主的語言中引入面向對象編程,還是在以面向對象編程為主的語言中引入函數式編程,在一個程序中應用多范式已經成為一個越來越明顯的趨勢。不僅僅在設計中,越來越多的編程語言逐步將不同編程范式的內容融合起來。C++ 從 C++ 11 開始支持 Lambda 表達式,Java 從 Java 8 開始支持 Lambda 表達式,同時新誕生的語言一開始就支持多范式,比如 Scala,Go 和 Rust 等。

從結構化編程到面向對象編程,再到函數式編程,離圖靈機模型越來越遠,但抽象程度越來越高,與領域問題的距離越來越近。

結構化編程

結構化編程,也稱作過程式編程,或面向過程編程。

基本設計

在使用低級語言編程的年代,程序員站在直接使用指令的角度去思考,習慣按照自己的邏輯去寫,指令之間可能共享數據,這其中最方便的寫法就是需要用到哪塊邏輯就 goto 過去執行一段代碼,然后再 goto 到另外一個地方。當代碼規模比較大時,就難以維護了,這種編程方式便是非結構化編程。


goto.png

迪克斯特拉(E.W.dijkstra)在 1969 年提出結構化編程,摒棄了 goto 語句,而以模塊化設計為中心,將待開發的軟件系統劃分為若干個相互獨立的模塊,這樣使完成每一個模塊的工作變得單純而明確,為設計一些較大的軟件打下了良好的基礎。按照結構化編程的觀點,任何算法功能都可以通過三種基本程序結構(順序、選擇和循環)的組合來實現。

結構化編程主要表現在一下三個方面:

  • 自頂向下,逐步求精。將編寫程序看成是一個逐步演化的過程,將分析問題的過程劃分成若干個層次,每一個新的層次都是上一個層次的細化。
  • 模塊化。將系統分解成若干個模塊,每個模塊實現特定的功能,最終的系統由這些模塊組裝而成,模塊之間通過接口傳遞信息。
  • 語句結構化。在每個模塊中只允許出現順序、選擇和循環三種流程結構的語句。

結構化程序設計是用計算機的思維方式去處理問題,將數據結構和算法分離(程序 = 數據結構 + 算法)。數據結構描述待處理數據的組織形式,而算法描述具體的操作過程。我們用過程函數把這些算法一步一步的實現,使用的時候一個一個的依次調用就可以了。

在三種主流的編程范式中,結構化編程離圖靈機模型最近。人們學習編程的時候,大多數都是從結構化編程開始。按照結構化編程在做設計時,也是按照指令和狀態(數據)兩個緯度來考慮。在指令方面,先分解過程 Procedure,然后通過 Procedure 之間的一系列關系來構建整個計算,對應算法(流程圖)設計。在狀態方面,將實例數據都以全局變量的形式放在模塊的靜態數據區,對應數據結構設計。

procedure.png

架構風格

結構化編程一般偏底層,一般適用于追求確定性和性能的系統軟件。這類軟件偏靜態規劃,需求變化也不頻繁,適合多人并行協作開發。將軟件先分完層和模塊,然后再確定模塊間的 API,接著各組就可以同時啟動開發。各組進行數據結構設計和算法流程設計,并在規定的時間內進行集成交付。分層模塊化架構支撐了軟件的大規模并行開發,且偏靜態規劃式開發交付。層與層之間限定了依賴方向,即層只能向下依賴,但同層內模塊之間的依賴卻無法約束,經常會出現模塊之間互相依賴的情況,導致可裁剪性和可復用性過粗,響應變化能力較弱。

s-module-style.png

結構化編程的優點:

  • 貼近圖靈機模型,可以充分調動硬件,控制性強。從硬件到 OS,都是從圖靈機模型層累上來的。結構化編程離硬圖靈機模型比較近,可以充分挖掘底下的能力,盡量變得可控。
  • 流程清晰。從 main 函數看代碼,可以一路看下去,直到結束。

結構化編程的缺點:

  • 數據的全局訪問性帶來較高的耦合復雜度,局部可復用性及響應變化能力差,模塊可測試性差。想單獨復用一個 Procedure 比較困難,需要將該過程函數相關的全局數據及與全局數據相關的其他過程函數(生命周期關聯)及其他數據(指針變量關聯)一起拎出來復用,但這個過程是隱式的,必須追著代碼一點點看才能做到。同理,想要單獨修改一個 Procedure 也比較困難,經常需要將關聯的所有Procedure 進行同步修改才能做到,即散彈式修改。還有一點,就是模塊之間可能有數據耦合,打樁復雜度高,很難單獨測試。
  • 隨著軟件規模的不斷膨脹,結構化編程組織程序的方式顯得比較僵硬。結構化編程貼近圖靈機模型,恰恰說明結構化編程抽象能力差,離領域問題的距離比較遠,在代碼中找不到領域概念的直接映射,難以組織管理大規模軟件。

剛才在優點中提到,結構化編程貼近圖靈機模型,可以充分調動硬件,控制性強。為什么我們需要這個控制性?你可能做過嵌入式系統的性能優化,你肯定知道控制性是多么重要。你可能要優化版本的二進制大小,也可能要優化版本的內存占用,還有可能要優化版本的運行時效率,這時你如果站在硬件怎么運行的最佳狀態來思考優化方法,那么與圖靈機模型的 gap 就非常小,則很容易找到較好的優化方法來實施較強的控制性,否則中間有很多抽象層,則很難找到較好的優化方法。

除過性能,確定性對于系統軟件來說也很重要。對于 5G,系統要求端到端時延不超過 1ms,我們不能 80% 的情況時延是 0.5ms,而 20% 的情況時延卻是 2ms。賣出一個硬件,給客戶承諾可以支持 2000 用戶,我們不能 80% 的情況可以支持 3000 用戶,而 20% 的情況僅支持 1000 用戶。靜態規劃性在某些系統軟件中是極度追求的,這種確定性需要對底層的圖靈機模型做很好的靜態分解,然后把我們的程序從內存到指令和數據一點點映射下去。因為結構化編程離圖靈機模型較近,所以映射的 gap 比較小,容易通過靜態規劃達成這種確定性。

面向對象編程

隨著軟件種類的不斷增多,軟件規模的不斷膨脹,人們希望可以更小粒度的對軟件進行復用和裁剪。

基本設計

將全局數據拆開,并將數據與其緊密耦合的方法放在一個邏輯邊界內,這個邏輯邊界就是對象。用戶只能訪問對象的 public 方法,而看不到對象內部的數據。對象將數據和方法天然的封裝在一個邏輯邊界內,可以整體直接復用而不用做任何裁剪或隱式關聯。

object.png

人們將領域問題又開始映射成實體及關系(程序 = 實體 + 關系),而不再是數據結構和算法(過程)了,這就是面向對象編程,核心特點是封裝、繼承和多態。

封裝是面向對象的根基,它將緊密相關的信息放在一起,形成一個邏輯單元。我們要隱藏數據,基于行為進行封裝,最小化接口,不要暴露實現細節。

繼承分為兩種,即實現繼承和接口繼承。實現繼承是站在子類的視角看問題,而接口繼承是站在父類的視角看問題。很多程序員把實現繼承當作一種代碼復用的方式,但這并不是一種好的代碼復用方式,推薦使用組合。

對于面向對象而言,多態至關重要,接口繼承是常見的一種多態的實現方式。正因為多態的存在,軟件設計才有了更大的彈性,能夠更好地適應未來的變化。只使用封裝和繼承的編程方式,我們稱之為基于對象編程,而只有把多態加進來,才能稱之為面向對象編程。可以這么說,面向對象設計的核心就是多態的設計。

面向對象建模

面向對象編程誕生后,程序員需要從領域問題映射到實體和關系這種模型,后續再映射到圖靈機模型就交給面向對象編程語言的編譯器來完成。于是問題來了,領域千差萬別,如何能將領域問題高效簡潔的映射到實體和關系?這時 UML(Unified Model Language,統一建模語言)應運而生,是由一整套圖表組成的標準化建模語言。可見,面向對象極大的推進了軟件建模的發展。

uml-view.png

現在有一些新的程序員對于 UML 不太熟悉,建議至少要掌握兩個UML圖,即類圖和序列圖:

  • 類圖是靜態視圖,體現類和結構
  • 序列圖是動態視圖,體現對象和交互

軟件設計一般從動態圖開始,在動態交互中會把相對比較固定的模式下沉到靜態視圖里,然后形成類和結構。在看代碼的時候,通過類和結構就知道一部分對象和交互的信息了,可以約束及校驗對象和交互的關系。

面向對象建模一般分為四個步驟:

  • 需求分析建模
  • 面向對象分析(OOA)
  • 面向對象設計(OOD)
  • 面向對象編碼(OOP)

在 OOA 階段,分析師產出分析模型。同理,在 OOD 階段,設計師產出設計模型。


tranditional-method.png

分析模型和設計模型的分離,會導致分析師頭腦中的業務模型和設計師頭腦中的業務模型不一致,通常要映射一下。伴隨著重構和 fix bug 的進行,設計模型不斷演進,和分析模型的差異越來越大。有些時候,分析師站在分析模型的角度認為某個需求較容易實現,而設計師站在設計模型的角度認為該需求較難實現,那么雙方都很難理解對方的模型。長此以往,在分析模型和設計模型之間就會存在致命的隔閡,從任何活動中獲得的知識都無法提供給另一方。

Eric Evans 在 2004 年出版了 DDD(領域驅動設計, Domain-Driven Design)的開山之作《領域驅動設計——軟件核心復雜性應對之道》,拋棄將分析模型與設計模型分離的做法,尋找單個模型來滿足兩方面的要求,這就是領域模型。許多系統的真正復雜之處不在于技術,而在于領域本身,在于業務用戶及其執行的業務活動。如果在設計時沒有獲得對領域的深刻理解,沒有將復雜的領域邏輯以模型的形式清晰地表達出來,那么無論我們使用多么先進多么流行的平臺和基礎設施,都難以保證項目的真正成功。

DDD 是對面向對象建模的演進,核心是建立正確的領域模型:


domain-model.png

DDD 的精髓是對邊界的劃分和控制,共有四重邊界:

  • 第一重邊界是在問題空間分離子域,包括核心域,支撐域和通用域
  • 第二重邊界是在解決方案空間拆分 BC(限界上下文,Bounded Context),BC 之間的協作關系通過 Context Mapping(上下文映射) 來表達
  • 第三重邊界是在 BC 內部分離業務復雜度和技術復雜度,形成分層架構,包括用戶界面層,應用層,領域層和基礎設施層
  • 第四重邊界是在領域層引入聚合這一最小的設計單元,它從完整性與一致性對領域模型進行了有效的隔離,聚合內部包括實體、值對象、領域服務、工廠和倉儲等設計元素

設計原則與模式

設計原則很多,程序員最常使用的是 SOLID 原則,它是一套比較成體系的設計原則。它不僅可以指導我們設計模塊(類),還可以被當作一把尺子,來衡量我們設計的有效性。

SOLID 原則是五個設計原則首字母的縮寫,它們分別是:

  • 單一職責原則(Single responsibility principle,SRP):一個類應該有且僅有一個變化的原因
  • 開放封閉原則(Open–closed principle,OCP):軟件實體(類、模塊、函數)應該對擴展開放,對修改封閉
  • 里氏替換原則(Liskov substitution principle,LSP):子類型(subtype)必須能夠替換其父類型(base type)
  • 接口隔離原則(Interface segregation principle,ISP):不應強迫使用者依賴于它們不用的方法
  • 依賴倒置原則(Dependency inversion principle,DIP):高層模塊不應依賴于低層模塊,二者應依賴于抽象;抽象不應依賴于細節,細節應依賴于抽象

前面我們提到,對于面向對象來說,核心是多態的設計,我們看看 SOLID 原則如何指導多態設計:

  • 單一職責原則:通過接口分離變與不變,隔離變化
  • 開放封閉原則:多態的目標是系統對于變化的擴展而非修改
  • 里氏替換原則:接口設計要達到細節隱藏的圓滿效果
  • 接口隔離原則:面向不同客戶的接口要分離開
  • 依賴倒置原則:接口的設計和規定者應該是接口的使用方

除過設計原則,我們還要掌握常用的設計模式。設計模式是針對一些普遍存在的問題給出的特定解決方案,使面向對象的設計更加靈活和優雅,從而復用性更好。學習設計模式不僅僅要學習代碼怎么寫,更重要的是要了解模式的應用場景。不論那種設計模式,其背后都隱藏著一些“永恒的真理”,這個真理就是設計原則。的確,還有什么比原則更重要呢?就像人的世界觀和人生觀一樣,那才是支配你一切行為的根本。可以說,設計原則是設計模式的靈魂

守破離是武術中一種漸進的學習方法:

  • 第一步——守,遵守規則直到充分理解規則并將其視為習慣性的事
  • 第二步——破,對規則進行反思,尋找規則的例外并“打破”規則
  • 第三步——離,在精通規則之后就會基本脫離規則,抓住其精髓和深層能量

設計模式的學習也是一個守破離的過程:

  • 第一步——守,在設計和應用中模仿既有設計模式,在模仿中要學會思考
  • 第二步——破,熟練使用基本設計模式后,創造新的設計模式
  • 第三步——離,忘記所有設計模式,在設計中潛移默化的使用

架構風格

面向對象設計大行其道以后,組件化或服務化架構風格開始流行起來。組件化或服務化架構風格參考了對象設計:對象有生命周期,是一個邏輯邊界,對外提供 API;組件或服務也有生命周期,也是一個邏輯邊界,也對外提供 API。在這種架構中,應用依賴導致原則,不論高層還是低層都依賴于抽象,好像整個分層架構被推平了,沒有了上下層的關系。不同的客戶通過“平等”的方式與系統交互,需要新的客戶嗎?不是問題,只需要添加一個新的適配器將客戶輸入轉化成能被系統 API 所理解的參數就行。同時,對于每種特定的輸出,都有一個新建的適配器負責完成相應的轉化功能。

port-adapter.png

面向對象編程的優點:

  • 對象自封裝數據和行為,利于理解和復用
  • 對象作為“穩定的設計質料”,適合廣域使用
  • 多態提高了響應變化的能力,進一步提升了軟件規模
  • 對設計的理解和演進優先是對模型和結構的理解和調整。不要一上來就看代碼,面向對象的代碼看著看著很容易斷,比如遇到虛接口,就跟不下去了。通常是先掌握模型和結構,然后在結構中打開某個點的代碼進行查看和修改。請記住,先模型,再接口,后實現。

面向對象編程的缺點:

  • 業務邏輯碎片化,散落在離散的對象內。類的設計遵循單一職責原則,為了完成一個業務流程,需要在多個類中跳來跳去。
  • 行為和數據的不匹配協調,即所謂的貧血模型和充血模型之爭。后來發現可通過 DCI(Data、Context 和 Interactive)架構來解決該問題。
  • 面向對象建模依賴工程經驗,缺乏嚴格的理論支撐。面向對象建模回答了從領域問題如何映射到對象模型,但一般只是講 OOA 和 OOD 的典型案例或最佳實踐,屬于歸納法范疇,并沒有嚴格的數學推導和證明。

函數式編程

與結構化編程與面向對象編程不同,函數式編程對很多人來說要陌生一些。你可能知道,C++ 和 Java 已經引入了 Lambda 表達式,目的就是為了支持函數式編程。函數式編程中的函數不是結構化編程中的函數,而是數學中的函數,結構化編程中的函數是一個過程(Procedure)。

基本設計

函數式編程的起源是數學家 Alonzo Church 發明的 Lambda 演算(Lambda calculus,也寫作 λ-calculus)。所以,Lambda 這個詞在函數式編程中經常出現,你可以把它簡單地理解成匿名函數。

functions.png

函數式編程有很多特點:

  • 函數是一等公民。一等公民的含義:(1)它可以按需創建;(2)它可以存儲在數據結構中;(3)它可以當作參數傳給另一個函數;(4)它可以當作另一個函數的返回值。
  • 純函數。所謂純函數,是符合下面兩點的函數:(1)對于相同的輸入,返回相同的輸出;(2)沒有副作用。
  • 惰性求值。惰性求值是一種求值策略,它將求值的過程延遲到真正需要這個值的時候。
  • 不可變數據。函數式編程的不變性主要體現在值和純函數上。值類似于 DDD 中的值對象,一旦創建,就不能修改,除非重新創建。值保證不會顯式修改一個數據,純函數保證不會隱式修改一個數據。當你深入學習函數式編程時,會遇到無副作用、無狀態和引用透明等說法,其實都是在討論不變性。
  • 遞歸。函數式編程用遞歸作為流程控制的機制,一般為尾遞歸。

函數式編程還有兩個重要概念:高階函數和閉包。所謂高階函數,是指一種比較特殊的函數,它們可以接收函數作為輸入,或者返回一個函數作為輸出。閉包是由函數及其相關的引用環境組合而成的實體,即閉包 = 函數 + 引用環境

閉包有獨立生命周期,能捕獲上下文(環境)。站在面向對象編程的角度,閉包就是只有一個接口(方法)的對象,即將單一職責原則做到了極致。可見,閉包的設計粒度更小,創建成本更低,很容易做組合式設計。在面向對象編程中,設計粒度是一個 Object,它可能還需要拆,但你可能已經沒有意識再去拆,那么上帝類大對象就會存在了,創建成本高。在函數式編程中,閉包給你一個更精細化設計的能力,一次就可以設計出單一接口的有獨立生命周期的可以捕獲上下文的原子對象,天然就是易于組合易于重用的,并且是易于應對變化的。

有一句化說的很好:閉包是窮人的對象,對象是窮人的閉包。有的語言沒有閉包,你沒有辦法,只能拿對象去模擬閉包。又有一些語言沒有對象,但單一接口不能完整表達一個業務概念,你沒有辦法,只能將多個閉包組合在一起當作對象用。

對于函數式編程,數據是不可變的,所以一般只能通過模式匹配和遞歸來完成圖靈計算。當程序員選擇將函數式編程作為思維底座時,就需要解決如何將領域問題映射到數據和函數(程序 = 數據 + 函數)。

函數式設計的思路就是高階函數與組合,背后是抽象代數那一套邏輯。下面這張圖是關于高階函數的,左邊是將函數作為輸入,右邊是將函數作為輸出:


high-order-function.png

對于將函數作為輸入的高階函數,就是面向對象的策略模式。對于將函數作為輸出的高階函數,就是面向對象的工廠模式。每個高階函數都是職責單一的,所以函數式設計是以原子的方式通過策略模式和工廠模式來組合類似面向對象的一切。在這個過程中,到底哪些函數作為入參,哪些函數作為返回值,然后這些返回值函數再傳給哪些函數,接著再返回哪些函數......,你發現你在套公式,通過公式的層層嵌套完成一個算法的描述,所以核心就是設計有哪些高階函數以及它們的組合規則,這是函數式設計中最難的,就是抽象代數的部分。可見,函數式設計的基本方法為:借助閉包的單一接口的標準化和高階函數的可組合性,通過規則串聯設計,完成數據從源到結果的映射描述。這里的映射是通過多個高階函數的形式化組合完成,描述就像寫數學公式一樣放在那,等源數據從一頭傳入,然后經過層層函數公式的處理,最后變成你想要的結果。數據在形式化轉移的過程中,不僅僅包括數據本身,還包括規則的創建、返回和傳遞。

架構風格

前面我們講過,函數式編程引起人們重視的因素包括硬件性能提升,多核 CPU 和分布式計算等。函數式編程的一些特點,使得并發程序更容易寫了。一些架構風格,尤其是分布式系統的架構風格,借鑒了函數式的特點,使得系統的擴展性和彈性變得更容易。

函數式編程的建模方式是抽象代數,在上面層累出兩類架構風格:
(1)Event Sourcing,Reative Achitecture


event-sourcing.png

(2)Lambda Achitecture,FaaS,Serverless


lambda.png

借鑒函數式編程的理念,分布式系統的架構風格,在架構層面完成更高抽象力度的表達,在并發層面完成更好的彈性和可靠性。

函數式編程的優點:

  • 高度的抽象,易于擴展。函數式編程是數據化表達,非常抽象,在表達范圍內是易于擴展的。
  • 聲明式表達,易于理解
  • 形式化驗證,易于自證
  • 不可變狀態,易于并發。數據不可變不是并發的必要條件,不共享數據才是,但不可變使得并發更加容易。

函數式編程的缺點:

  • 對問題域的代數化建模門檻高,適用域受限。現實是復雜的,不是在每個方面都是自洽的,要找到一套完整的規則映射是非常困難的。在一些狹窄的領域,可能找得到,而一旦擴展一下,就會破壞該狹窄領域,你發現以前找到的抽象代數建模方式就不再適用了。
  • 在圖靈機上性能較差。函數式編程增加了很多中間層,它的規則描述和惰性求值等使得優化變得困難。
  • 不可變的約束造成了數據泥團耦合。領域對象是有狀態的,這些狀態只能通過函數來傳遞,導致很多函數有相同的入參和返回值。
  • 閉包接口粒度過細,往往需要再組合才能構成業務概念

小結

作為一個程序員,我們應該清楚每種編程范式的適用場景,在特定的場景下選擇合適的范式來恰當的解決問題。

多范式融合的設計建議:

  • 每種編程范式都有優缺點,不做某單一范式的擁坌,分場景靈活選擇合適的范式恰當的解決問題
  • 從 DDD 的角度,按照模型一致性,將不同范式的設計劃分到不同的子域、BC 或層內

最后,我們重新看看開始的那張編程范式之間的關系圖:


relations.png

說明如下:

  • 最早是非結構化編程,指令可以隨便跳,數據可以隨便引用。后來有了結構化編程,人們把 goto 語句去掉了,約束了指令的方向性,過程之間是單向的,但數據卻是可以全局訪問的。再到面向對象編程的時候,人們干脆將數據與其緊密耦合的方法放在一個邏輯邊界內,約束了數據的作用域,靠關系來查找。最后到函數式編程的時候,人們約束了數據的可變性,通過一系列函數的組合來描述數據從源到目標的映射規則的編排,在中間它是無狀態的。可見,從左邊到右邊,是一路約束的過程
  • 越往左邊限制越少,越貼近圖靈機模型,可以充分調動硬件,“直接”帶來的可控性及廣域適用性。對于可控性,因為離圖靈機模型很近,可以按自己的想法來“直接”控制。對于廣域適用性,因為約束越多,說明門檻越高,一旦右邊搞不定,可以往回退一步,當你找到合理的對象模型或抽象代數模型時,可以再往前走一步。
  • 越往右邊限制越多,通過約束建立規則,通過規則描述系統,“抽象”帶來的定域擴展性。對于定域,因為這種“抽象”一定是面向某一個狹窄的切面,找到的對象模型或抽象代數模型會有很強的擴展性和可理解性,但一旦超過這個范圍,模型可能就無效了,所以 DDD 一直在強調分離子域、劃分 BC 和分層架構。

參考資料

  • C++及系統軟件技術大會2020,《多范式融合的Modern C++軟件設計》,王博
  • 極客時間專欄,《軟件設計之美》,鄭曄
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容