面向對象編程的流行是計算機科學領域的不幸,它對現代經濟造成了極大的破壞,造成了數萬億美元的間接損失。在過去的三十年中,幾乎所有行業都因潛在的面向對象編程危機而受到影響。
C++和 Java 可能是計算機科學領域最大的錯誤。就連面向對象的創建者 Alan Kay 都曾對這兩門語言提出了嚴厲的批評。然而,C++和 Java 都是比較主流的面向對象語言。
面向對象編程的流行是計算機科學領域的不幸,它對現代經濟造成了極大的破壞,造成了數萬億美元的間接損失。在過去的三十年中,幾乎所有行業都因潛在的面向對象編程危機而受到影響。
為什麼面向對象編程如此危險?下面我們一起來尋找答案。
2007 年 9 月,美國 Jean Bookout 駕駛的 2005 款凱美瑞突然失控,Bookout 嘗試剎車但是失敗,最終發生了碰撞事故,導致車內另一人身亡,Bookout 受傷。然而,此案只是豐田在美上百起車輛意外加速投訴的其中之一。
在 Bookout 事件調查的過程中,原告方聘請了兩位軟體專家,他們花了將近 18 個月的時間來研究豐田代碼。最終,他們都形容豐田代碼庫為「麵條式代碼」(Spaghetticode),程序的流向就像一盤麵條一樣扭曲糾結在一起。
軟體專家演示了大量豐田軟體可能導致意外加速的情況。最終,豐田被迫召回 900 多萬輛汽車,賠付款項高達 30 多億美元。
麵條式代碼有什麼問題?然而,豐田並不是唯一一家有麵條式代碼問題的公司。曾經有兩架波音 737 Max 飛機墜毀,造成 346 人死亡,損失超過 600 億美元。這兩起事件的原因也出在了軟體 bug 上,而且都是由麵條式代碼引起的。
麵條式代碼困擾著全世界上許許多多的代碼庫,包括飛機、醫療設備以及核電站上運行的代碼。
程序代碼不是為機器編寫的,而是為人類編寫的。Martin Fowler 曾說過:「任何傻瓜都可以編寫計算機能夠理解的代碼。但只有優秀的程式設計師可以編寫人類能夠理解的代碼。」
如果代碼不能正常運行,那說明出了問題。但是,如果人們不理解代碼,那麼它肯定會出問題。遲早的事兒。
此處,我們來談論一下人類的大腦。人腦是世界上最強大的機器。但是,它有其自身的局限性。我們的工作記憶是有限的,人腦一次最多只能思考 5 件事。這意味著,程序代碼的編寫方式不應該超出人腦的局限。
然而,麵條式代碼導致人類無法理解代碼庫。這就會埋下深遠的禍根,因為我們不清楚某些代碼變動是否會引發問題。我們無法運行詳盡的測試,找出所有缺陷,甚至沒有人知道這樣的系統是否能正常工作。即便系統能夠正常工作,我們也不明白為什麼。
麵條式代碼的起因為什麼經過一段時間的發展之後,代碼庫會出現麵條式代碼?因為熵。宇宙中的一切都變得混亂無序。就像電纜終將亂如一團麻,我們的代碼最終也將變得混亂不堪。除非我們施加足夠的約束。
為什麼高速公路有時速限制?這是為了防止我們撞車。為什麼道路上有交通信號?為了防止人們走錯路,為了防止事故發生。
編程也一樣。這樣的約束不應讓程式設計師來決定,應該通過工具自動實現,或者理想情況下通過編程範例本身來實現。
為什麼面向對象是萬惡之源?我們怎樣才能施加足夠的約束,防止麵條式代碼的出現?兩個辦法:手動或自動。手動很容易出錯,人類難免會犯錯。因此,我們理應自動執行此類約束。
然而,面向對象編程並不是我們一直在尋找的解決方案。它沒有提供任何約束來幫忙解決代碼扭曲糾纏的問題。一個人可以精通各種面向對象編程的最佳實踐,例如依賴注入、測試驅動的開發、領域驅動的設計等(這些實踐確實有幫助)。但是,這些都不是由編程範例本身來強制執行的(而且也沒有相應的工具來強制執行最佳實踐)。
面向對象編程內部沒有任何功能可以幫助我們預防麵條式代碼,封裝只是隱藏和打亂了程序的狀態,只會讓情況變得更糟。繼承帶來了更多的混亂。面向對象編程的多態性更是火上澆油,我們根本不知道程序運行時會採用哪種確切的執行路徑。特別是在涉及多個繼承級別時。
面向對象進一步加劇了麵條式代碼的問題然而,面向對象的缺點可不止缺乏適當的約束。
在大多數面向對象程式語言中,默認情況下一切都是通過引用共享的。這實際上將一個程序變成了一個龐大的全局狀態。這與面向對象原本的思想背道而馳。面向對象的創建者 Alan Kay 擁有生物學的背景。他想到了一種語言(Simula),可以讓我們按照生物細胞的組織方式編寫電腦程式。他希望有獨立的程序(細胞)通過相互發送消息進行通信。獨立程序的狀態永遠不會與外界共享(封裝)。
AlanKay 從來也沒想過讓「細胞」直接進入其他細胞的內部做任何修改。但現代面向對象編程就這麼幹了,因為在現代面向對象編程中,默認情況下,一切都是通過引用共享的。這也意味著破壞正常功能的錯誤無法避免。修改程序的某一部分就會破壞其他功能(這在函數式編程等其他編程範例中很少見。)
我們可以清楚地看到,現代面向對象編程本質上就存在很大的缺陷。它不僅會讓你在日常工作中痛苦不堪,而且還會讓你夜不成寐。
可預測性麵條式代碼是一個重大的問題。面向對象的代碼特別容易形成麵條式。
麵條式代碼導致軟體無法維護,但這只是問題的一部分。此外,我們還希望軟體具有可靠性,以及可預測性。
任何系統的用戶都應該享受相同的、可預測的體驗。踩下油門,汽車就會加速;相反,踩剎車,汽車就會減速。用計算機科學術語來說,我們希望汽車的行為是確定的。
我們非常不希望汽車表現出隨機行為,例如加速器無法加速,或制動器不能減速(豐田的問題)。即使此類問題發生的概率非常低。
然而,大多數軟體工程師的心態都是「我們的軟體要足夠好,才能讓客戶繼續使用。」我們能做的只有這麼多嗎?當然不是,我們應該做得更好!然而,首先最起碼應該解決程序的不確定性。
不確定性在計算機科學中,確定性算法指的是針對相同的輸入,算法始終能夠表現出相同的行為。而不確定性算法恰恰相反,即便輸入相同,每次運行算法也會表現出不同的行為。
舉個例子:
console.log( 'result', computea(2) );
console.log( 'result', computea(2) );
console.log( 'result', computea(2) );
// output:
// result 4
// result 4
// result 4無需在意上述函數的具體功能,你只需要知道對於相同的輸入,它總是會返回相同的輸出。下面,我們看一看另一個函數 computeb:
console.log( 'result', computeb(2) );
console.log( 'result', computeb(2) );
console.log( 'result', computeb(2) );
console.log( 'result', computeb(2) );
// output:
// result 4
// result 4
// result 4
// result 2 <= not good這一次,這個函數在面對相同的輸入時,卻給了不同的輸出。這兩個函數之間有什麼區別?前者針對相同的輸入,總是能給出相同的輸出,就像數學函數一樣。換句話說,這個函數是確定的。而後者則不一定會輸出預期的值,換句話說,這個函數是不確定的。
如何判斷某個函數是確定的,還是不確定的?
function computea(x) {
return x * x;
}
function computeb(x) {
return Math.random()< 0.9
? x * x
: x;
}在上述示例中,computea 是確定的,它總是能夠針對相同的輸入給出相同的輸出。因為它的輸入只取決於參數x。
而 computeb 是不確定的,因為它調用了另一個不確定的函數Math.random()。我們怎麼知道 Math.random()是不確定的?因為這個函數會根據系統時間(外部狀態)來計算隨機值。而且,它也沒有參數,只取決於外部狀態。
確定性與可預測性之間有什麼聯繫?確定的代碼就是可預測的代碼。不確定的代碼就是不可預測的代碼。
從確定的到不確定的我們再來看一個函數。
function add(a, b) {
return a + b;
};我們可以確定,輸入(2,2)的結果總是等於 4。我們為什麼能確定?在大多數程式語言中,加法操作都是通過硬體實現的,換句話說,CPU 會負責計算結果始終保持不變。除非我們需要處理浮點數的比較(但這是另一個話題,與不確定性問題無關)。這裡,我們只討論整數。硬體非常可靠,因此我們可以放心加法的結果正確無誤。
下面,我們給 2 加一個處理:
const box = value => ({ value });
const two = box(2);
const twoPrime = box(2);
function add(a, b) {
return a.value +b.value;
}
console.log("2 + 2' == " + add(two, twoPrime));
console.log("2 + 2' == " + add(two, twoPrime));
console.log("2 + 2' == " + add(two, twoPrime));
// output:
// 2 + 2' == 4
// 2 + 2' == 4
// 2 + 2' == 4到這裡,這個函數依然是確定的!
下面,我們來稍微修改一下函數本身:
function add(a, b) {
a.value += b.value;
return a.value;
}
console.log("2 + 2' == " + add(two, twoPrime));
console.log("2 + 2' == " + add(two, twoPrime));
console.log("2 + 2' == " + add(two, twoPrime));
// output:
// 2 + 2' == 4
// 2 + 2' == 6
// 2 + 2' == 8發生了什麼?突然間,函數的結果就不可預測了!第一次運行沒有問題,但是後面每次運行得到的結果都是不可預測的。換句話說,這個函數不再具備確定性。
為什麼突然變成不確定的?這是因為我們修改了函數作用域之外的一個值,函數出現了副作用。
總結一下確定的程序可確保 2 + 2 == 4。換句話說,給定輸入(2, 2),函數 add 必然會輸出 4。無論這個函數被調用多少次,無論是否並行調用該函數,也無論函數外部是什麼狀況,它必然會輸出 4。
不確定的程序則恰好相反,在大多數情況下,add(2, 2)將返回 4。但有時,該函數可能會返回 3、5,甚至 1004。程序中萬萬不能出現不確定性,我希望你明白為什麼。
不確定的代碼有什麼後果?它們會引發軟體缺陷,也就是常說的 bug。遇到 bug,開發人員需要浪費大量寶貴的時間來調試代碼,如果將這類代碼投入生產,則會大大降低客戶體驗。
為了讓我們的程序更可靠,首先應該解決不確定性的問題。
副作用這裡,我們不得不談一談副作用。
什麼是副作用?通常意義的副作用是指如果你因頭痛而服用藥物,但這種藥物讓你感到噁心,那麼噁心就是一種副作用。簡而言之,副作用就是不良反應。
想像一下,你買了一臺計算器。帶回家後卻發現這不是一臺簡單的計算器。你輸入 10 * 11,而它卻輸出了 110,同時在你耳邊大叫「一百一十」。這就是副作用。接著,你輸入 41+ 1,它輸出了 42,但又喊道「四十二,死掉。」這也是副作用。你帶著滿臉的疑惑,打電話叫外賣,結果這臺計算器偷聽到了你的電話,大聲說「好的」,然後還幫你下了訂單。這也是副作用。
下面,我們繼續來說 add 函數:
function add(a, b) {
a.value += b.value;
return a.value;
}這個函數執行了預期的操作,即 a 加 b。但是,它也有副作用。因為在執行a.value+ = b.value 後,對象 a 會發生變化。假設剛開始的是 a.value=1,則第一次調用完成後,a.value=2。而且第二次調用後,它的值會再次變化。
純粹在討論了確定性和副作用後,我們再來看一看純粹。純函數是確定的,而且沒有副作用。
純函數有什麼優點?它們是可預測的。因此,非常易於測試(無需編寫模擬函數和樁函數)。理解純函數非常容易,不需要將整個應用程式狀態都裝入大腦。你只需要考慮眼前的函數。
編寫純函數也很容易(因為它們不會修改任何範圍之外的數據)。純函數非常適合併發,因為函數之間沒有共享狀態。另外,重構純函數也很簡單,只需複製和粘貼,無需複雜的 IDE 工具。
簡而言之,純函數可以讓我們快樂地編程。
面向對象編程是否是純粹的?為了舉例說明,我們看兩個面向對象的特性:getter和setter。
getter 的結果取決於外部狀態,也就是對象狀態。每次調用 getter,得到的結果都不相同,具體取決於系統的狀態。因此,getter 本質上是不確定的。
setter會修改對象的狀態,因此它們本質上就帶有副作用。
這意味著面向對象所有的方法(除靜態方法外)或者是不確定的,或者會帶來副作用。因此,面向對象編程並不純粹,它與純粹背道而馳。
銀彈無知並不值得羞愧,無知又不學才讓人羞愧。
—— 班傑明·富蘭克林
雖然軟體世界裡困難重重,但我們仍存一線希望,即便無法解決所有的問題,至少也可以解決大多數問題。但是,只有當你願意學習和應用,才能成功。
銀彈的定義是什麼?就是可以解決所有問題的靈丹妙藥。經過千百年的努力,數學界也有銀彈。
如果不確定性(即不可預測)成為現代科學的支柱,那你覺得我們的世界能走多遠?可能不會太遠,或許我們還停留在中世紀。醫學界就經歷過這樣的情況,過去我們沒有嚴格的試驗來證實特定的治療方法或藥物的功效。
不幸的是,如今的軟體行業與過去的醫學太相似了。現代軟體行業的基礎非常脆弱,也就是所謂的面向對象編程。我們希望軟體也能夠像醫學一樣,找到堅實的基礎。
堅實的基礎在編程世界中,我們也可以擁有像數學一樣可靠的基礎嗎?可以!我們可以將許多數學概念直接轉化為編程,並以此為基礎,也就是我們所說的函數式編程。
函數式編程是編程界的數學,它是非常牢固且強大的基礎,可用於構建可靠且健壯的程序。是什麼讓函數式編程如此強大?這種編程方式的基礎是數學,尤其是 Lambda 演算。
做個比較,現代面向對象編程的基礎是什麼?Alan Kay 的面向對象編程基於的是生物細胞。但是,現代 Java/C#的基礎是一套荒謬的思想,比如類、繼承和封裝等,這些並非源自 Alan Kay 最初的思想。
反觀函數式編程,它的核心構件是函數,而且在大多數情況下是純函數。純函數是確定性的,因此它們是可預測的。這意味著由純函數組成的程序將是可預測的。這倒不是說函數式編程沒有 bug,但是如果程序中存在 bug,那也是確定的,即對於相同的輸入始終會引發相同的 bug,因此非常容易修復。
代碼是如何執行到這一步的?以前,在過程式編程和函數式編程出現之前,goto 語句廣泛用於程式語言中。goto 語句允許程序在執行的過程中跳至代碼的任何部分。因此,開發人員很難回答:「代碼是如何執行到這裡的?」而且 goto 語句引發了大量 bug。
如今,面向對象編程也有這個問題。在面向對象編程中,一切都是通過引用傳遞的。從理論上講,這意味著任何對象都有可能被其他對象修改(面向對象編程對此沒有任何約束)。封裝根本沒有幫助,它只不過是調用一種方法來更改某些對象的欄位。這意味著,程序中的依賴關係很快就會亂成一鍋粥,整個程序都會成為一個大型的全局狀態。
有什麼辦法可以解決這個問題嗎?沒錯,就是採用函數式編程。
過去曾經許多人對於停止使用 goto 的建議都有牴觸,就像如今很多人會反對函數式編程和不可變狀態的思想。
麵條式代碼怎麼辦?在面向對象編程中,「組合優於繼承」被視為最佳實踐。從理論上講,這類的最佳實踐有助於改善麵條式代碼。然而,這只是「最佳實踐」,面向對象的編程範例本身沒有任何約束,強制人們遵守這類最佳實踐。團隊中的初級開發人員是否遵循這類最佳實踐,完全看個人,或者你在代碼審查中強制實施。
函數式編程如何?在函數式編程中,函數組合(和分解)是構建程序的唯一方法。這意味著編程範例本身會強制執行組合。這正是我們一直在尋找的解決方案。
函數調用其他函數,大函數始終由小函數組成。組合在函數式編程中是很自然的選擇。此外,在這種方式下,重構的難度也會降低,只需剪切代碼,然後將其粘貼到新函數中即可。無需管理複雜的對象依賴項,也不需要複雜的工具(例如Resharper)。
我們可以看出,要想更好地組織代碼,選擇面向對象編程並不明智,函數式編程明顯更勝一籌。
現在就開始行動面向對象編程本身就是一個巨大的錯誤。
如果我知道我乘坐的汽車運行的軟體是由面向對象程式語言編寫的,我會感到害怕;知道我和家人乘坐的飛機使用了面向對象的代碼,也會讓我感到不安。
我們應該採取行動,認識到面向對象編程的危險,並努力學習函數式編程。我知道這個過程很漫長,至少需要十年才能做出轉變。
但我相信在不久的將來,終有一天面向對象編程會退出這個舞臺,就像如今的 COBOL 一樣。
參考連結:https://suzdalnitski.medium.com/oop-will-make-you-suffer-846d072b4dce