編程範式與系統設計
作為一個程序員,最重要的責任是使用計算機編程,幫用戶解決實際問題。
程序員面對的問題是多種多樣的。有些直接面對底層,在機場的電子告示牌上顯示信息。有些面對特定平台,在微信上為用戶提供點餐服務。有些軟體管理的是計算資源,比如Linux內核管理內存、CPU、各種輸入輸出設備等。有些軟體管理的是代碼資源,比如git。有些問題域是相對確定的,比如路由器、交換機等網路設備遵循標準的RFC規範。有些問題域則與具體的使用場景密切相關,比如醫院或企業使用的ERP系統。林林總總,不一而足。
程序員面對的問題是不確定的,解決的方法有很多種,但是解決問題的思路其實是相似的。無非是分為如下幾步:
1)需求分析。這一步的核心目標是要界定問題的邊界,找到我們要解決的問題到底是什麼。需求分析階段,我們必須找出系統運行的各種約束條件,這決定了我們如何設計系統,也是檢驗系統質量、評價系統設計的重要依據。
2)模型抽象。模型抽象是我們思考需求的方式,如何抽象取決於我們從哪些角度看問題,取決於我們的取捨。模型抽象通常被稱為架構設計,是系統的骨架,決定了系統演進的方式和系統發展的總基調。初入職場的程序員往往是在前人設定的模型基礎上工作。等有一些工作經驗後,往往會在一些系統設計上陷入迷茫。面對一團糟的代碼,像陷入了泥潭中動彈不得,往往才開始認識到抽象的重要性。
3)編程實現。如果說模型抽象是對問題域的抽象。編程範式則是對編程模式的抽象,是程序員對解決抽象問題的思維方法工具庫的總結。歸根到底,需求分析和模型抽象都和問題領域關係密切,而編程範式則是工具的抽象。而編程範式的最重要形式就是編程語言。編程範式看起來是計算機科學的範疇,實際上也是軟體工程的範疇,是軟體複雜度發展到一定程度的必然產物。
編程範式就是關於編程的方法,編程的思維,是我們編寫程序解決問題的思路和視角,提供了也決定了程序員對程序運行的看法。計算機編程有多種範式:命令式編程、聲明式編程、面向對象編程、結構化編程等(https://zh.wikipedia.org/wiki/%E7%BC%96%E7%A8%8B%E8%8C%83%E5%9E%8B)。如果我們看系統設計的書,往往會提到諸如面向方面編程、面向領域編程、數據驅動、事件驅動、元編程、范型編程等等概念。
聲明式編程與命令式編程
概念是對思想的抽象。更一般的,如上種種概念其實都可以歸結到「做什麼」和「怎麼做」這兩個思考維度上,進一步的可以歸類為「命令式編程」和「聲明式編程」兩種編程範式。
命令式編程通過一系列改變程序狀態的指令完成計算,是行為導向的,關鍵在於定義演算法和過程,是典型的「怎麼做」思維。「命令式編程」最典型的例子是彙編語言。彙編語言描述了如何執行一系列指令,不斷改變運行狀態,直到計算出最終結果的過程。強調程序代碼模擬電腦運行過程,強調「先做什麼」、「再做什麼」。彙編語言的理論基礎是馮諾依曼自動機理論。大多數編程語言都屬於命令式。
聲明式編程由若干規範的聲明組成。聲明式編程利用數理邏輯或既定規範對已知條件進行推理運算,強調「最終要什麼」,相比命令式編程範式來講,它更看重結果而非過程。聲明式編程範式屬於目標驅動,著重於分析和描述問題,它的思考層面要高於命令式編程,更接近人腦的抽象思維方式。「聲明式編程」的典型例子是函數式編程。函數式編程將計算描述為數學函數的求值。函數式編程類似代數中的表達式變換和計算。「聲明式編程」的另一類典型代表是SQL語句、XML/HTML等。
之所以強調命令式和聲明式的不同,是因為大多數程序員習慣於用命令式的方式思考問題。當系統規模變大後,要簡化和優化系統設計,就必然需要越來越多的從聲明式的角度思考問題。聲明式和命令式,一體兩面,都是解決實際問題的重要武器。
1)命令式和聲明式只是看待問題的側重點有差異,是互補而不是對立的。聲明式語言往往通過命令式編程語言做底層實現。命令式編程則逐漸加入函數式編程的種種元素,呈現互相交融的景象。
聲明式語言專註問題的分析和表達,更接近人的思維方式,是相對高級的語言。所有高級語言都建立於低級語言之上,最終轉化為機器語言,聲明式語言也不例外。
2)多從聲明式的角度考慮有助於我們分解問題複雜度。當我們熱衷於找到解決問題的更多辦法的時候,往往忽略了定義問題本身才是有效解決問題的先決條件。數據驅動是通過定義數據規範以提供最大的系統靈活性,元編程和范型編程(比如模板、比如反射)則通過定義程序代碼的生成規範來應對變化。
3)結構化編程和OOP都是從「做什麼」角度給命令式編程打的補丁。如果說結構化軟體設計是將函數式編程技術應用到命令式語言中進行程序設計,面向對象編程不過是將函數式模型應用到命令式程序中的另一途徑。模塊進步為對象,過程被封裝到對象的成員方法中。OOP的很多技術——抽象數據類型、信息隱藏、介面與實現分離、對象生成功能、消息傳遞機制等等,很多東西就是結構化軟體設計所擁有的、或者在其他編程語言中單獨出現。但只有在面向對象語言中,他們才共同出現,以一種獨特的合作方式互相協作、互相補充。
函數式編程
函數式編程是一種典型的聲明式編程範式,它將計算機運算看作是數學中函數的計算,並且避免了狀態以及變數的概念。
數學中的函數僅僅描述一種「映射關係」,給定一個自變數,我們可以得到一個因變數,僅此而已。而程序中的函數更多的時候扮演的是一種「功能」角色,它能夠完成指定任務。當然,如果程序中一個函數包含參數,並且能夠返回值,那麼它完全可以模擬數學函數。具體來講,如果將程序中函數做一些限制,那麼它就可以模擬數學中的函數了:
1)每個函數必須包含輸入參數(作為自變數);
2)每個函數必須有返回值(作為因變數);
3)無論何時,給定參數調用函數時,返回值必須一致。
上面第三條限制是為了滿足函數的「確定性」,該條限制要求程序中的函數執行期間不能依賴於外界因素,也不要影響外部環境。換句話說,它在執行期間與外界是隔絕的。我們將滿足以上條件的函數稱為「純函數(Pure Function)」。純函數與外界交互只有一條渠道——傳入參數與返回值。純函數也不讀取/改變全局變數、無IO操作等。
純函數是程序代碼模擬數學函數的基礎。理論上講,函數式編程中,函數是第一公民的同時,所有函數也都應該屬於「純函數」。到此,我們再回過頭看一下維基百科上對「函數式編程」的解釋:很顯然,函數式編程向數學驗算靠攏,使用一種平時正常的數學思維去解決問題。
純函數保證了,函數執行過程中不依賴外界因素,也不影響外部環境。換句話說,在函數式編程中,狀態和變數不再是程序員需要關注的問題。在程序的執行過程中,狀態是存在的,而且隨著函數的執行進度不斷變化。但狀態的變化過程不再與計算的順序有關,而僅與輸入有關。函數和輸入參數就決定了程序執行過程中所處的狀態。
相當於命令式編程,函數式編程的優點是顯而易見的:
1)單元測試。函數式編程不依賴外部狀態,也沒有副作用。最終的計算結果不受函數調用順序影響,只與輸入參數有關。只要每個函數都得了測試,就可以確認系統質量。而傳統系統中,狀態的變更是不受控的。完整的測試往往意味著遍歷所有狀態。而這是一個不可能完成的任務。
2)調試。函數式編程中調試也是很簡單的,如果函數的執行結果不符預期,那一定是因為輸入參數錯誤。只要順著調用鏈一層層找下去,很容易就可以找到問題所在。
3)並行。函數式程序是天然並行的,因為沒有任何數據需要在線程之間共享。
4)熱部署。函數式編程沒有副作用,只要參數和介面不變,內部實現可以在線調整。
函數式程序是天然解耦的。或者說,函數式程序只依賴於函數的輸入參數,是一種單向的依賴關係。純函數執行不依賴外部因素,也沒有副作用。函數執行順序不影響執行結果。這是函數式程序沒有耦合的關鍵所在。
當然,系統的邏輯複雜度並沒有因為使用函數式編程而自然消失。命令式程序中,程序員的工作在於規劃狀態和指令執行順序,程序正確執行依賴於程序員思考是否全面,是否考慮儘可能多的狀態和輸入組合。雖然程序邏輯是形式正確的,但是結果仍然可能是不可預料的。在函數式程序中,邏輯推理和調用組織本身就是函數正確性的保證。只要數學推理沒有錯,最終的計算結果就不會錯。系統的複雜度主要在於邏輯推理的過程。
推薦閱讀:
※幻想中的Haskell - Compiling Combinator
※函數式又是函數式
※Python 為什麼不能序列化函數閉包?
※Speak my true name, summon my constructor
※為什麼lists在functional programming里很重要?