1. 程式人生 > >反對函數語言程式設計的政治正確

反對函數語言程式設計的政治正確

在技術社群裡,與函數語言程式設計相關的話題一直十分火熱,這尤以素有娛樂圈之稱的前端社群為甚。大量相關的入門文章中,面向物件與指令式程式設計常常被作為對比的反例,彷佛它們已經是醜陋而骯髒的過時技術了。對這種矯枉過正觀點的擔憂,正是這篇文章寫作的初心。

為什麼這裡會牽扯到政治正確呢?這是因為對程式設計正規化的執著,已經或多或少地成為了一種道德綁架了:這很接近政治正確的背後,那種強大的道德信念。這種信念摻雜在了對程式語言的信仰之中,其結果是用非強迫的方式完成了對話語權的控制。比如下面這樣的觀點:

  • 你這段程式碼用了 for 迴圈,這是過程式的。為了優雅,你應該寫成函式式的。
  • 你這段程式碼有副作用,這是骯髒的。為了純淨性,你應該把 IO 包在 Monad 裡。
  • 你這段程式碼用了 class,這是面向物件的。為了無狀態,你應該寫成高階函式。

這種粗暴的邏輯和【父母都是為你好】與【女性不適合程式設計】有什麼區別嗎?許多這樣不負責任的偏激言辭,造就了當前社群中對於面向物件與指令式程式設計的 Stereotype 刻板印象。實際上,把函數語言程式設計與面向物件 / 指令式程式設計對立的觀點,其本身在分類上就是不嚴謹的。姑且不論這點,這些論調也相當武斷,忽略了技術的適用場景。

例如,對於函數語言程式設計的最主要的讚譽之一,就在於純函式的無狀態性質。關於它的好處我們已經聽到太多了:結果可預期、利於測試、利於複用、利於併發……一切聽起來都這麼理想,所以我們能夠自底到上地用純函式來編寫出一個完整的系統了嗎?這裡的荒誕之處在於,當你越接近一臺計算機 under the hood 的原貌時,你離純函式與無狀態越遠

當你想要嘗試理解 CPU / 顯示卡 / 網路等真正的基礎的時候,你會發現它們一點兒也不函式式:

  • CPU 本身就是個最典型的狀態機。例如筆者的週末玩具 CHIP-8 模擬器 裡,它的 CPU 狀態就是用幾個變數模擬出的一堆暫存器、堆疊指標和計數器罷了。每條指令都是個修改全域性狀態的函式罷了——這當然很不純粹,但十分符合對 CPU 執行方式的直覺與抽象。
  • 驅動顯示卡工作的 API 有狀態得令人髮指。相信任何嘗試過從頭搭建 WebGL 渲染管線的同學都能夠明白這是什麼意思。並且在下一代以壓榨出極致效能到導向的顯示驅動 API 裡,需要人肉維護的狀態還會更瑣碎。
  • 網路協議棧的狀態機如何遷移,已經在《計算機網路》的課本里畫出了無數次了。即便是已經封裝到應用層的最傻瓜的 Web 頁面裡,看看控制檯裡執行一句 performance.timing
    的內容,要想正確地畫出這些欄位間的狀態遷移關係都已經絕非易事了。

當你想要理解上面的這些玩意如何工作的時候,你所能查到的最經典的資料與業界最經過實戰檢驗的實現,幾乎都是清一色地在過程式、命令式的程式設計正規化下非常地【有狀態】的。難道說編寫這些基礎性工程壯舉的開發者們,其技術水平都不如函數語言程式設計的佈道師們嗎?

這個矛盾可以這樣歸結:函數語言程式設計的理念,更接近理論上的數學概念。而命令式、過程式的程式設計,更貼近實際工程中硬體的工作方式。二者的簡潔性是體現在不同維度的。作為例子,許多函數語言程式設計狂熱者所不屑乃至唾棄的 C 風格 while 迴圈,其實是個非常易於實現與硬體優化的設計。只要你嘗試過閱讀 gcc 生成的彙編碼,不難發現一個 while 非常容易與彙編的跳轉指令聯絡起來:

       JMP LOOP   ; 首先跳到底部以開始迴圈
BEGIN: NOP        ; 空指令佔位符
                  ; ...此處開始放置迴圈體中程式碼
                  ; ...
                  ; ...
                  ; ...執行完迴圈體內程式碼
LOOP:  CMP ...    ; 檢查條件
       JNE BEGIN  ; 若不滿足則跳轉到 BEGIN 位置
複製程式碼

而對於飽受函式式愛好者們抨擊的 for 迴圈,實現時只需在上面的控制流裡增加初始化過程與計數器臨時變數就行。而被嫌棄的 break 與 continue 等語法也只需要增加更多的標號即可靈活地基於 JMP 實現。相比於函式呼叫並返回時所需小心翼翼地儲存並恢復上下文的一系列跳轉邏輯,命令式的迴圈語法顯然更易於實現——當然了,你可以硬槓說 Scheme 這樣的函式式語言,其直譯器更容易實現,筆者也確實作為週末玩具而實現過一門 Scheme 方言 哦語言 的直譯器。但是,這種幾乎等於手寫語法樹的語言有多少工程中的實用價值呢?類似的地方還體現在對各種數學計算的抽象上。如筆者蹭 PR 貢獻過的 gl-matrix 矩陣運算庫,其中大量的 mutation 也非常不函式式,但它實際上仍然十分簡潔可靠而高效呀。

有些函數語言程式設計的愛好者們,對遞迴一類函式式手法的 all in 推崇也有些令人費解:你寫的什麼 for 迴圈太低端啦,看我寫成優雅的尾遞迴還自帶直譯器優化不會爆棧呢!誠然,在處理巢狀的資料結構的時候,使用遞迴是相當簡潔易讀的。這一點筆者在畢業前嘗試實現遞迴下降和 LALR 語法分析器的課後作業時,就有了這樣的體會:這時非遞迴的寫法實在很囉嗦。然而,這種本來是以一定的效能代價來提高可讀性的手法,卻常常被誤解為優秀的實現而被濫用。例如,一個深拷貝演算法用遞迴實現固然簡單,但它客觀地存在 Stack Overflow 的風險。作為解決方案,我們可以用陣列模擬棧來實現它。這時你的程式碼就沒有那麼函式式了:你會因為函式式的程式碼更加優雅,就拒絕修復棧溢位的 bug 嗎?

類似的捨近求遠,還體現在對一些實際上更加晦澀的概念的推崇上。許多希望入門函數語言程式設計的初學者,都會被 Monad 這個【自函子上的么半群】概念唬住。誠然,你可以把 Hooks 和 Promise 這些簡單易用的概念解釋成為 Monad,筆者在入門時也確實寫了一篇文章從單位元與結合律的角度出發,來證明 為什麼 Promise 是一種 Monad。然而,明明是實際開發中非常實用且易於理解的東西,卻要使用更難以懂的一套概念去形式化地定義和解釋,這恐怕並不利於優秀工具和理念的普及。比如,日語裡函式的概念叫做関數,不懂日語體系裡的這個詞,也並不影響一個漢語使用者知識體系的自洽以及對函式的使用呀。並且,Monad 其實已經相當於函數語言程式設計正規化中的一種【設計模式】了,而對設計模式的摒棄,不正是函數語言程式設計自身的優勢之一嗎?

提到了設計模式,就繞不開軟體工程。這時候我們有不少【道】層面的設計準則,但這些真正利於工程 Scale Up 的準則,反倒和具體的程式設計正規化關係不大了。比如,我們都知道高內聚低耦合的模組劃分是利於維護的,但在這個維度上起最重要作用的並不是與函式式相關的語法特性,而是語言的包管理器與模組載入規範等。再比如變更遊戲業界的 ECS 架構,它在【組合優於繼承】方向上的演進也仍然是在面嚮物件語言上就能夠實現的。即便到了實際的工程案例上,對於前端這種與 UI 深度相關的領域,其中最大規模且最可靠的實現仍然是非常面向物件的——Windows 和 macOS 的桌面 UI 環境並非源於函式式語言,難道作業系統的桌面管理器會像 Redux 那樣在單個 store 裡管理全域性狀態嗎?

行文至此,這篇文章的吐槽應該告一段落了。但我們顯然並不應該為了發洩而寫作,筆者更不是指令式程式設計的死忠粉(相反地,筆者還特地寫過一篇 RxJS 模擬電梯排程 的安利文章)。差不多時候做一些澄清與提出訴求了:

  • 函數語言程式設計非常重要,且在許多細分領域值得學習與推廣。但不能一概而論地認為它優於面向物件或過程式等其它正規化。
  • 函數語言程式設計同樣存在著自身固有的缺陷,這些地方要客觀地看待。
  • 我們希望能夠平等地看待各種程式設計正規化,保持開放的心態,拒絕譁眾取寵的引戰言論,根據實際需求折衷選擇更具開發效率與執行效率的技術方案。

在實際的編碼中,筆者更關注【符合直覺】這一點。這大概包括兩個維度:

  • 一個語言特性的使用方式,是否符合它設計出來要解決的場景。
  • 一個具體需求的實現方式,是否符合對其最為簡單直接的抽象。

不管黑貓白貓,只要能抓到老鼠就是好貓。只要是可讀可維護的高質量程式碼,為什麼要在乎它屬於哪個正規化呢 :)