用C++進行函數式編程
文 / John Carmack 譯 / 王江平
英文原文:Functional Programming in C++
《Quake》作者 Carmack 認為追求函數式的程序設計有著實實在在的價值,然而,勸說所有程序員拋棄他們的 C++ 編譯器,轉而啟用 Lisp、Haskell,或者干脆說任何其他邊緣語言,都是不負責任的。
或許本文的每位讀者都聽說過,當初“函數式編程”(Functional Programming)肩負著為軟件開發帶來福祉的期望來到這個世界,大家可能還聽說過有人將它奉為軟件開發的銀彈。然而,上維基百科查看更多信息卻讓人大倒胃口,一上來就引用λ演算和形式系統。很難一眼看出這跟編寫更好的軟件有什么關系。
我的實效性總結:軟件開發中的大部分問題都緣于程序員沒有完全理解程序執行中所有可能的狀態。在多線程環境中,這一理解的缺失以及它所導致的問題變得更加嚴重,如果你留意這些問題,會發現它幾乎嚴重到令人恐慌的地步。通過函數式的風格編寫程序,可以將狀態清晰地呈現給你的代碼,從而使代碼的邏輯更易于推理,而在純粹的函數式系統中,這更使得線程競爭條件成為不可能的事情。
我確實相信追求函數式的程序設計有著實實在在的價值,然而勸說所有程序員拋棄 C++ 編譯器,轉而啟用 Lisp、Haskell,或者干脆說任何其他邊緣語言,那是不負責任的。讓語言設計者永遠懊惱的是,總會有大量的外在因素壓跨一門語言的好處,相對大多數領域來說,游戲開發尤其如此。除了大家都要面對的遺留代碼庫和有限的人力資源問題之外,我們還有跨平臺問題、私有工具鏈、證書網關、需要授權的技術,以及嚴酷的性能要求。
如果你的工作環境中可以用非主流語言完成主要開發任務,那應該為你歡呼,不過也等著打板子吧,罪名是項目進展方面的。而對所有其他人:不論你用何種語言工作,通過函數式的風格編寫程序都會帶來好處。任何時候,只要方便,就應當這么做;而不方便時,也應當仔細想想自己的決定。以后,只要愿意,你可以學學 lambda、monad、currying、在無限集上合成懶惰式求值的函數,以及顯式面向函數式語言的所有其他方面。
C++語言并不鼓勵函數式程序設計,但它也不妨礙你這么做,而且為你保留了深入下層、運用 SIMD 內在函數基于內存映射文件直接布局數據的能力,或任何其他你發現自己用得著的精華特性。
純函數
純函數是這樣一種函數:它只會查看傳進來的參數,它的全部行為就是返回基于參數計算出的一個或多個值。它沒有邏輯副作用。這當然只是一種抽象;在 CPU 層面,每個函數都是有副作用的,多數函數在堆的層面上就有副作用,但這一抽象仍然有價值。
純函數不查看也不更新全局狀態,不維護內部狀態,不執行任何I/O操作,也不更改任何輸入參數。最好不要傳遞任何無關的數據給它——如果傳一個 allMyGlobals 指針進來,這一目標就基本破滅了。
純函數有許多良好的屬性。
- 線程安全 使用值參數的純函數是徹底線程安全的。使用引用或指針參數的話,就算是 const 的,你也應當知曉一個執行非純操作的線程可能更改或釋放其數據的風險。但即便是這種情況,純函數仍不失為編寫安全多線程代碼的利器。你可以輕松地將一個純函數替換為并行實現,或者運行多種實現并比較結果。這讓代碼的試驗和演化都更加便利。
- 可復用性 移植一個純函數到新的環境要容易很多。類型定義和所有被調用的其他純函數仍然需要處理,但不會有滾雪球效應。有多少次,你明明知道另一個系統有代碼可以實現你的需要,但要把它從所有對系統環境的假設中解脫出來,還不如重寫一遍來得容易?
- 可測試性 純函數具有引用透明性(referential transparency),也就是說,不論何時調用它,對于同一組參數它永遠給出同樣的結果,這使它跟那些與其他系統相互交織的東西比起來更易于使用。在編寫測試代碼的問題上,我從來沒有特別盡責;太多代碼與大量系統交互,以至于使用它們需要相當精細的控制,而我常常能夠說服自己(也許不正確)這樣的付出并不值得。純函數很容易測試,其測試代碼就像直接從教料書上摘抄下來的一樣:構造一些輸入并查看結果。每次遇到一小段目前看起來有些奇技淫巧的代碼,我都會把它拆成一個單獨的純函數并編寫測試。可怕的是,我常常發現這樣的代碼中存在問題,意味著我撒下的測試安全網還不夠大。
- 可理解性與可維護性 輸入和輸出的限制使得純函數在需要時更易于重新學習,由于文檔不足而隱藏了外部信息的情況也會更少。
形式系統和軟件的自動推理將來會越來越重要。靜態代碼分析今天已經很重要了,將代碼轉換成更加函數式的風格有助于工具對它的分析,或者至少能讓速度更快的局部工具所覆蓋的問題跟速度慢且更加昂貴的全局工具一樣多。我們這個行業講的是“把事情做出來”,我還看不到關于整個程序“正確性”的形式證明能成為切實的目標,但能夠證明代碼的特定部分不存在特定種類的問題也是很有價值的。我們可以在開發過程中多運用一些科學和數學成果。
正在修編程導論課的同學可能一邊撓頭一邊想:“不是所有的程序都要這么寫嗎?”現實情況卻是“大泥球”(Big Balls of Mud)程序多,架構清晰的程序少。傳統的命令式編程語言為你提供了安全艙口,結果它們就總是被使用。如果你只是寫一些用一下就扔掉的代碼,那就怎么方便怎么來,用到全局狀態也是常事。如果你在編寫一年之后仍將使用的代碼,那就要將眼前的便利因素跟日后不可避免的麻煩平衡一下了。大部分程序員都不擅長預測日后改動代碼將會導致的各種痛苦。
“純粹性”實踐
并非所有東西都可以是純的,除非程序只操作自己的代碼,否則到某個點總要與外部世界交互。嘗試最大限度地推進代碼的純粹性可以帶來難以想象的樂趣,然而,要達到一個務實的臨界點,我們需要承認副作用到某一刻是必要的,然后有效地管理它們。
即使對某個特定的函數而言,這都不是一個“要么全有要么全無”的目標。隨著一個函數的純度不斷提高,其價值可以連續增大,而且從“幾乎純粹”到 “完全純粹”帶來的價值要低于從“意大利面條狀態”到“基本純粹”帶來的價值。只要讓函數朝著純粹的目標前進,即使不能達到完全的純度,也能改善你的代碼。增減全局計數器或檢查一個全局調試標志的函數是不純的,但如果那是它唯一的不足,它仍然可以收獲函數式的大部分好處。
避免在更大的上下文中造成最壞的結果通常比在有限的情形中達到完美狀態更加重要。考慮一下你曾經對付過的最令人不爽的函數或系統,那種只有全副武裝才能應付的,幾乎可以確定,其中必有復雜的狀態網絡和代碼行為所依賴的各種假設,而這些復雜性還不只發生在參數上。在這些方面強化一下約束,或至少努力防止更多的代碼陷入類似的混亂局面,帶來的影響將比擠壓幾個底層的數學函數大得多。
朝著純粹性的目標重構代碼,這一過程通常包含將計算從它所運行的環境中解脫出來,這幾乎必然意味著更多的參數傳遞。似乎有點奇特——編程語言中的煩瑣累贅已被人罵夠了,而函數式編程卻常常與代碼體積的減少相關。函數式編程語言寫的程序會比命令式語言的實現更加簡潔,其中的因素與使用純函數在很大程度上是正交的,這些因素包括垃圾回收、強大的內建類型、模式匹配、列表推導、函數合成以及各種語法糖等。程序體積的減少多半與函數式無關,某些命令式語言也能帶來同樣的效果。
如果你必須給一個函數傳遞十多個參數,惱火是應該的,你可以通過一些降低參數復雜性的方法來重構代碼。C++中沒有任何維護函數純粹性的語言支持,這確實不太理想。如果有人通過一些不好的方法把一個大量使用的基礎函數變得不再純粹,所有使用這一函數的代碼便統統失去了純粹性。從形式系統的角度聽起來這是災難性的,但還是那句話,這并不是一念之惡便與佛無緣的那種“要么全有要么全無”的主張。很遺憾,大規模軟件開發中的問題只能是統計意義上的。
看來未來的C/C++語言標準很有必要增加一個“pure”關鍵字。C++中已經有了一個近似的關鍵字 const—一個支持編譯時檢查程序員意圖的可選修飾符,加上它對代碼百利而無一害。D語言倒是提供了一個“pure”關鍵字:http://www.d-programming-language.org/function.html。注意它們對弱純粹性和強純粹性的區分—要達到強純粹,輸入參數中的引用或指針需要使用 const 修飾。
從某些方面來看,語言關鍵字過于嚴格了—一個函數即使調用了非純粹的函數也仍然可以是純粹的,只要副作用不逃出函數之外即可。如果一個程序只處理命令行參數而不操作隨機的文件系統狀態,那么整個程序都可看做純粹的函數式單元。
面向對象程序設計
Michael Feathers(推ter @mfeathers)說:OO 通過把移動的部件封裝起來使代碼可理解。FP 通過把移動的部件減到最少使代碼可理解。
“移動的部件”就是更改中的狀態。通知一個對象改變自己,這是面向對象編程基礎教材的第一課,在大多數程序員的觀念中根深蒂固,但它卻是一種反函數式的行為。將函數和它們操作的數據結構組織在一起,這一基本的 OOP 思想顯然有其價值,但如果想在自己的部分代碼中獲得函數式編程的好處,那么在這些部分,你必須疏遠一下某些面向對象的行為。
無法聲明為 const 的類方法從定義上就是不純的,因為它們要修改對象的部分或全部狀態集合,這一集合可能十分龐大。它們也不是線程安全的,這里戳一下,那里捅一下,一點一點地把對象置成了非預期的狀態,這種力量才真正是 Bug 的不竭之源。如果不考慮那個隱含的 const this 指針,從技術角度 const 對象方法仍可看做純函數,但許多對象十分龐大,大到它本身就足以構成一種全局狀態,從而弱化了純函數的在簡潔清晰上的一些好處。構造函數也可以是純函數,通常應該努力使之成為純函數——它們接受參數并返回一個對象。
從靈活編程的層面來看,你常常可以用更加函數式的方法使用對象,但可能需要一點接口上的改變。在 id Software,我們曾有十年時間在使用一個 idVec3 類,它只有一個改變自己的 void Normalize ()方法,卻沒有相應的 idVec3 Normalized () const 方法。許多字符串方法也是以類似的方式定義的,它們操作自身,而不是返回執行過相應操作的一個新的副本——比如 ToLowerCase ()、StripFileExtension ()等。
性能影響
在任何情況下,直接修改內存塊幾乎都是無法逾越的最優方案,而不這么做就難免犧牲性能。多數時候這只有理論上的好處,我們一向都在用性能換生產率。
使用純函數編程會導致更多的數據復制,出于性能方面的考慮,某些情況下這顯然會成為不正確的實現策略。舉個極端的例子,你可以寫一個純函數的 DrawTriangle (),接受一個幀緩存(framebuffer)參數并返回一個全新的畫上三角形的幀緩存作為結果。可別這么做。
按值返回一切結果是自然的函數式編程風格,然而總是依靠編譯器實施返回值優化會對性能造成危害,因此對于函數輸出的復雜數據結構,傳遞引用參數常常是合理的,但這么也有不好的一面:它阻止你將返回值聲明為 const 以避免多次賦值。
很多時候人們都有強烈的欲望去更新傳入的復雜結構中的某個值,而不是復制一份副本并返回修改后的版本,但這樣等于舍棄了線程安全保障,因此不要輕易這么做。列表的產生倒是一種可以考慮就地更新的合理情形。往列表中追加新的元素,純函數式的做法是返回尾端包含新元素的一個全新列表副本,原先的列表則保持不變。真正的函數式語言都在實現上運用了特別手法,從而使這種行為的后果沒有聽上去那么糟糕,但如果在典型的 C++ 容器上這么做,那你就死定了。
一項重要的緩解因素是,如今性能意味著并行程序設計,相比單線程環境,并行程序即使在性能最優的情形中也需要更多的復制與合并操作,因此復制造成的損失減少了,而復雜性的降低和正確性的提高這兩方面的好處相應增加了。例如,當開始考慮并行地運行一個游戲世界中的所有角色時,你就會漸漸明白,用面向對象的方法來更新對象,這在并行環境中難度很大。或許所有對象都引用了世界狀態的一個只讀版本,而在一幀結束時卻復制了更新后的版本……嗨,等一下……
如何行動
在自己的代碼庫中檢查某些有一定復雜度的函數,跟蹤它能觸及的每一比特外部狀態以及所有可能的狀態更新。即使對它不做一點改動,把這些信息放入一個注釋塊就已經是極好的文檔了。如果函數能夠——比方說,通過渲染系統觸發一次屏幕刷新,你就可以直接把手舉在空中,聲明這個函數所有的正副作用已經超出了人類的理解力。你要著手的下一項任務是基于實際執行的計算從頭開始重新考慮這個函數。收集所有的輸入,把它傳給一個純函數,然后接收結果并做相應處理。
調試代碼的時候,讓自己著重了解那些更新的狀態和隱藏的參數悄然登場,從而掩蓋實際動作的部分。修改一些工具對象的代碼,讓函數返回新的副本而不是修改自身,除了迭代器,試著在自己使用的每個變量之前都加上 const。
作者 John Carmack,享譽世界的著名程序員,id Software 創始人之一。Doom 和 Quake 系列游戲作者。(感謝 John Carmack 和 Mike Acton 對本文的授權,原文鏈接為 http://www.altdevblogaday.com/2012/04/26/functional-programming-in-c/)。