用函數語言程式設計寫出“傻瓜”都能看懂的程式碼

語言: CN / TW / HK

highlight: vs2015

小知識,大挑戰!本文正在參與“程式設計師必備小知識”創作活動。

名詞

  • FP: Functional Programming,函數語言程式設計。
  • OOP:Object Oriented Programming,面向物件程式設計。
  • AOP:Aspect Oriented Programming,面向切面程式設計。

導讀

“任何傻瓜都能寫機器可執行的程式碼,而優秀的程式設計師寫的程式碼傻瓜都能看懂。”——Martin Fowler

這句名言以飽滿的感情描述了“優秀程式設計師”的標準(個人認為這個翻譯要比原文給力)。無論是 OOP、AOP、FP 最終都要服務於這個基本點。所以本文並不會照搬市面上流行的介紹函數語言程式設計的內容,而是一切圍繞此基本點展開,有所取捨,以最小的成本,帶來最大的收益。

背景

目前業內對於 FP 的討論很多,也比較熱烈,但是想要找到一些真正能指導實踐,切實提高生產力的有用資訊,著實不易。正如導讀中說的,筆者本著切實提高生產力為目的,研究了 FP 的一些相關資料,再綜合平時的實踐,經過取捨和變通,整理出了此文件。旨在讓 FP 切實應用到日常開發中,並且有所收益。

另外,從目前流行的三大框架來看,都已經逐漸“資料化”。以 React 為例,其抽象思想可以簡化為 UI = fn(state),利用 VDOM、diff、JSX 等方式將 DOM 操作過程遮蔽,實現了 state(純資料)與 UI 的對映,其帶來的影響就不展開了,這裡只強調一點:

目前前端開發的絕大部分工作是在處理資料(state),而不是像之前 Jquery 那樣直接處理 DOM。

這會從底層改變我們的編碼思維與方式,請仔細品味這句話,很重要。既然是處理資料,而且是 immutable 的資料,那麼 FP 就有用武之地了,這也將是本文的討論重點。

最後,簡單列舉一下筆者認定 FP 能夠在前端有所建樹的判斷依據: - React immutable 和單向資料流的概念與 FP 很吻合; - Redux 是目前使用最多的 React 狀態管理庫,它的存在提供了 FP 在前端實踐的典型案例; - ES 的迭代引入了箭頭函式、Array.prototype.map 等具有明顯 FP 特色的語法,讓人忍不住聯想; - 筆者曾主導過一個“部門前端質量控制”專案,對於程式碼的“可測試性”有一些研究。可測試性高就意味著“高內聚低耦合”,是專案可維護性的基石。而 FP 就是提升程式碼可測試性的最佳途徑,詳見《如何說服前端寫單測》; - ~~FP 看起來很酷,可以用來 ZB。~~

目標

  1. 選出 FP 中適合前端實踐的特性,總結成可落地的規範,指導開發;
  2. 以 FP 為切入點,引出程式設計思想層面的思考,讓內容更具有啟發性;
  3. 一切的一切都是為了能寫出“傻瓜都能看懂”的程式碼,降低程式碼的維護成本。

結論

PS:以下內容為突出重點,適用範圍為絕大多數專案,corner case 不在討論範圍之內。

篇幅太長,先拋結論,思考過程見下文。規範是:

除框架的模板程式碼外,專案裡不要出現 Class(詳見《前端不需要 Class》),只允許出現函式,且只能是以下兩種函式,具體約束如下:

純函式

  1. 除回撥(包括生命週期鉤子)、初始化以外的所有函式;
  2. 一定有入參和出參,即 (params: any) => any
  3. 不允許對入參進行任何修改操作(immutable),尤其是引用型別資料。如果非要修改,先 cloneDeep 生成新物件,操作後再返回該新物件;
  4. 程式碼裡禁止出現 thisfetchlocalStoragewindowDOM 等明顯需要訪問外部的關鍵字或全域性變數;
  5. 特別的,返回 Promise 物件的函式,均使用 async/await 呼叫,並將其視為純函式(如 ajax 請求,嚴格意義來說不能算純函式,這裡做了變通);

副作用函式

  1. 只能是回撥或初始化函式;
  2. 沒有出參,入參只能由回撥函式自身提供,不能引入額外入參,即 (parmas?: any) => void;
  3. 內部程式碼絕大部分是宣告式,而非命令式; 可以看到,規則只用到了純函式與副作用的概念,僅此而已。沒有 pointfree、container、monad、functor 等概念,甚至要堅決杜絕引入這些概念,否則只會帶來更難維護的後果,切記切記。即使這樣,如果完全做到了上述規則,你的專案程式碼質量肯定已經很棒了。

正文

傻瓜能看懂的程式碼是什麼樣的

筆者將其總結為以下幾點: 1. 符合常識和一般邏輯,No Surprise; 2. 巨集觀上看整體程式碼,能夠像書的目錄一樣快速看懂整個專案是做什麼的,每個模組大概負責什麼; 3. 中觀上看每個模組的程式碼,就像合體機器人一樣。每個子機器人內部自成體系,自給自足,給上電(輸入)就能單獨作戰;同時,輸入輸出明確,有明確的介面可以進行組合,組成完全體; 4. 微觀上看每行程式碼,以宣告式程式碼為主,程式碼規範清晰,可讀性強,可參考《Lint 不能解決的前端程式碼規範》、《Vue3 最佳實踐之編碼規範》 嗯……描述的就挺傻瓜的,很合理。其實我們可以把寫一個專案類比成寫一本書,從目錄到章節,再到逐字逐句的推敲,實在像極了寫程式碼。我們就是在寫一本“傻瓜”都能看得懂的書。

宣告式(Declarative) vs 命令式(Imperative)

上文提到了要以宣告式程式碼為主,與之對應的是命令式,下面展開說明下。我們來看一個《函數語言程式設計指北》中的例子: ```js // 命令式 var makes = []; for (i = 0; i < cars.length; i++) { makes.push(cars[i].make); }

// 宣告式 var makes = cars.map(function(car){ return car.make; }); `` 很明顯,命令式會把每一步操作都寫出來,即每一步做什麼都需要下命令,這樣的程式碼不太能第一時間看出其意義;而宣告式可以很快的看出是要實現makescars` 之間的一種對映關係,不需要看具體的邏輯,從名字也能夠猜到個大概。所以在快速閱讀和理解程式碼意義上來講,我們更應該使用宣告式程式設計的正規化。

但是,在微觀層面來講,粒度已經細緻到函式的具體實現了,勢必要細讀每一行程式碼。那麼即使使用指令式程式設計正規化也差別不大了,實際上當遇到 break、continue 等邏輯時,使用上述指令式程式設計反而是更好的選擇。這個尺度可以自己把握,在保證巨集觀整體質量可控的情況下,在微觀層面有所變通,甚至偶爾耍一些“花活”也無傷大雅。

PS:此處對函數語言程式設計的概念做了取捨,在函數語言程式設計中是沒有迴圈的,取而代之的是遞迴呼叫。筆者認為大可不必,很明顯迴圈要比遞迴(尤其是尾遞迴)好理解,畢竟我們的目標使用者是“傻瓜”。

純函式的好處

本小節內容主要來自《函數語言程式設計指北 - 追求“純”的理由》,筆者原創的部分會標註,未標註部分全部為引用內容。

可快取性

原創)這點比較好理解,根據純函式的概念:

純函式是這樣一種函式,即相同的輸入,永遠會得到相同的輸出,而且沒有任何可觀察的副作用。

輸入相同就意味著輸出相同,所以可以進行快取,利用空間換時間,提升速度。

可移植性/自文件化

純函式是完全自給自足的,它需要的所有東西都能輕易獲得。仔細思考思考這一點...這種自給自足的好處是什麼呢?

首先,純函式的依賴很明確,因此更易於觀察和理解——沒有偷偷摸摸的小動作。 ```js // 不純的 var signUp = function(attrs) { var user = saveUser(attrs); welcomeUser(user); };

var saveUser = function(attrs) { var user = Db.save(attrs); ... };

var welcomeUser = function(user) { Email(user, ...); ... };

// 純的 var signUp = function(Db, Email, attrs) { return function() { var user = saveUser(Db, attrs); welcomeUser(Email, user); }; };

var saveUser = function(Db, attrs) { ... };

var welcomeUser = function(Email, user) { ... }; ```

這個例子表明,純函式對於其依賴必須要誠實,這樣我們就能知道它的目的。僅從純函式版本的 signUp 的簽名就可以看出,它將要用到 DbEmail 和 attrs,這在最小程度上給了我們足夠多的資訊。

其次,通過強迫“注入”依賴,或者把它們當作引數傳遞,我們的應用也更加靈活;因為資料庫或者郵件客戶端等等都引數化了。如果要使用另一個 Db,只需把它傳給函式就行了。如果想在一個新應用中使用這個可靠的函式,儘管把新的 Db 和 Email 傳遞過去就好了,非常簡單。

原創)最後,如果結合了 TS,出參和入參的型別又進一步的明確,每個純函式的作用可以更容易地被推測出來,讓閱讀程式碼的體驗更加順滑。具體例子可參考《函數語言程式設計指北 - 第 7 章:Hindley-Milner 型別簽名》。

可測試性

原創)這點也很好理解,因為純函式的特性,只需簡單地給函式一個輸入,然後斷言輸出就好了。值得一提的是《指北》一文中有提到:

事實上,我們發現函數語言程式設計的社群正在開創一些新的測試工具,能夠幫助我們自動生成輸入並斷言輸出。這超出了本書範圍,但是我強烈推薦你去試試 Quickcheck——一個為函式式環境量身定製的測試工具。

如果連單元測試都能夠自動化生成,那這點無疑是非常有吸引力的。

合理性

原創)其實“合理性”這個翻譯不太好,Reasonable 似乎更能表達出這種含義,大概是一種很確定,很有依據的意思。舉個例子,在 debug 的時候,你可以毫無顧忌的把一個純函式的呼叫結果,替換成一個“臨時常量”,甚至刪掉一些確定性的程式碼,來簡化複雜度,提高 debug 的效率。

可並行

最後一點,也是決定性的一點:我們可以並行執行任意純函式。因為純函式根本不需要訪問共享的記憶體,而且根據其定義,純函式也不會因副作用而進入競爭態(race condition)。

並行程式碼在服務端 js 環境以及使用了 web worker 的瀏覽器那裡是非常容易實現的,因為它們使用了執行緒(thread)。不過出於對非純函式複雜度的考慮,當前主流觀點還是避免使用這種並行。

如何處理副作用

對於副作用《函數語言程式設計指北 - 副作用可能包括》中有較詳細的分析。裡面提到:

“作用”本身並沒什麼壞處,“副作用”的關鍵部分在於“副”。就像一潭死水中的“水”本身並不是幼蟲的培養器,“死”才是生成蟲群的原因。同理,副作用中的“副”是滋生 bug 的溫床。

筆者深以為然。但是副作用是不可能避免的,副作用可能包含,但不限於:

  • 更改檔案系統
  • 往資料庫插入記錄
  • 傳送一個 http 請求
  • 可變資料
  • 列印/log
  • 獲取使用者輸入
  • DOM 查詢
  • 訪問系統狀態 純正的 FP 中對於副作用的處理非常的複雜,個人認為如果全盤照搬會讓使用成本大大增加,至少“傻瓜”是很難看懂的。所以筆者一直在思考,如何根據前端專案的特點,界定一個範圍,把副作用給“圈住”。最後思考出了以下結論:

除了初始化和回撥函式(也包括生命週期函式)之外,其他的函式都能夠寫成純函式。

其實回撥函式佔了前端程式碼的絕大部分,如果是這樣,FP 又有多大意義呢?於是又加了一條規則:

副作用函式禁止有返回值,即 (params?: any) => void

這條初聽起來會覺得莫名其妙。試想下,沒有返回值意味著沒有任何地方會依賴這個函式的執行結果,遮蔽了下游。這樣其實就起到了把“副作用”控制在一定範圍內的效果,雖然顯得比較笨,但是成本低,起碼在 debug 的時候不用再擔心任何意外的出現。

結語

綜合上述內容再補充一些細節後,已經得出了結論,並寫在文章開頭了。說實話,寫這篇文件實屬不易,裡面有很多的思考結論是找不到印證的。這就意味著這些結論需要反覆推敲,很費腦細胞。而且最終的結果也很有可能是貽笑大方。不管怎樣,把自己的思考整理成文,是逼著自己完善思想的一種方式,能達到這個目的也就知足了。

最後歡迎大家討論,提意見,這也是讓自己快速成長的另外一個強大助力。

“Reading makes a full man,conference a ready man,and writing an exact man”——Francis Bacon\ “讀書使人充實,討論使人機敏,寫作使人嚴謹”——弗朗西斯·培根

參考文獻