導讀:《Quake》作者 John 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(twitter @mfeathers)說:OO通過把移動的部件封裝起來使程式碼可理解。FP通過把移動的部件減到最少使程式碼可理解。
“移動的部件”就是更改中的狀態。通知一個物件改變自己,這是物件導向程式設計基礎教材的第一課,在大多數程式設計師的觀念中根深蒂固,但它卻是一種反函式式的行為。將函式和它們操作的資料結構組織在一起,這一基本的OOP思想顯然有其價值,但如果想在自己的部分程式碼中獲得函數語言程式設計的好處,那麼在這些部分,你必須疏遠一下某些物件導向的行為。
無法宣告為const的類方法從定義上就是不純的,因為它們要修改物件的部分或全部狀態集合,這一集合可能十分龐大。它們也不是執行緒安全的,這裡戳一下,那裡捅一下,一點一點地把物件置成了非預期的狀態,這種力量才真正是Bug的不竭之源。如果不考慮那個隱含的const this指標,從技術角度const物件方法仍可看做純函式,但許多物件十分龐大,大到它本身就足以構成一種全域性狀態,從而弱化了純函式的在簡潔清晰上的一些好處。建構函式也可以是純函式,通常應該努力使之成為純函式——它們接受引數並返回一個物件。
從靈活程式設計的層面來看,你常常可以用更加函式式的方法使用物件,但可能需要一點介面上的改變。在id Software,我們曾有十年時間在使用一個idVec3類,它只有一個改變自己的void Normalize()方法,卻沒有相應的idVec3 Normalized() const方法。許多字串方法也是以類似的方式定義的,它們操作自身,而不是返回執行過相應操作的一個新的副本——比如ToLowerCase()、StripFileExtension()等。
效能影響
在任何情況下,直接修改記憶體塊幾乎都是無法逾越的最優方案,而不這麼做就難免犧牲效能。多數時候這隻有理論上的好處,我們一向都在用效能換生產率。
使用純函式程式設計會導致更多的資料複製,出於效能方面的考慮,某些情況下這顯然會成為不正確的實現策略。舉個極端的例子,你可以寫一個純函式的DrawTriangle(),接受一個幀快取(framebuffer)引數並返回一個全新的畫上三角形的幀快取作為結果。可別這麼做。
按值返回一切結果是自然的函數語言程式設計風格,然而總是依靠編譯器實施返回值優化會對效能造成危害,因此對於函式輸出的複雜資料結構,傳遞引用引數常常是合理的,但這麼也有不好的一面:它阻止你將返回值宣告為const以避免多次賦值。
很多時候人們都有強烈的慾望去更新傳入的複雜結構中的某個值,而不是複製一份副本並返回修改後的版本,但這樣等於捨棄了執行緒安全保障,因此不要輕易這麼做。列表的產生倒是一種可以考慮就地更新的合理情形。往列表中追加新的元素,純函式式的做法是返回尾端包含新元素的一個全新列表副本,原先的列表則保持不變。真正的函式式語言都在實現上運用了特別手法,從而使這種行為的後果沒有聽上去那麼糟糕,但如果在典型的C++容器上這麼做,那你就死定了。
一項重要的緩解因素是,如今效能意味著並行程式設計,相比單執行緒環境,並行程式即使在效能最優的情形中也需要更多的複製與合併操作,因此複製造成的損失減少了,而複雜性的降低和正確性的提高這兩方面的好處相應增加了。例如,當開始考慮並行地執行一個遊戲世界中的所有角色時,你就會漸漸明白,用物件導向的方法來更新物件,這在並行環境中難度很大。或許所有物件都引用了世界狀態的一個只讀版本,而在一幀結束時卻複製了更新後的版本……嗨,等一下……
如何行動
在自己的程式碼庫中檢查某些有一定複雜度的函式,跟蹤它能觸及的每一位元外部狀態以及所有可能的狀態更新。即使對它不做一點改動,把這些資訊放入一個註釋塊就已經是極好的文件了。如果函式能夠——比方說,通過渲染系統觸發一次螢幕重新整理,你就可以直接把手舉在空中,宣告這個函式所有的正副作用已經超出了人類的理解力。你要著手的下一項任務是基於實際執行的計算從頭開始重新考慮這個函式。收集所有的輸入,把它傳給一個純函式,然後接收結果並做相應處理。
除錯程式碼的時候,讓自己著重瞭解那些更新的狀態和隱藏的引數悄然登場,從而掩蓋實際動作的部分。修改一些工具物件的程式碼,讓函式返回新的副本而不是修改自身,除了迭代器,試著在自己使用的每個變數之前都加上const。