面向對象編程,不美了麼?

“我是舊時代的殘黨,新時代沒有承載我的船。”

如果面向對象編程是一個人,我猜他自己在不斷被非議的今天,一定會這樣感慨。

說實話,我用面向對象方式編程已經十幾年了,我做架構設計離不開它,做系統分析離不開它,編碼的時候更是嚴重依賴它,我對面向對象無論是思想上還是寫代碼上都對它是有很深的感情。

剛學 Java 的時候,我覺得面向對象編程(OOP)真牛逼,用面向對象方式寫出來的代碼是最好的代碼。但是隨着項目越做越多,代碼越寫越多,我發現 OOP 不是萬能的,盲目的迷信追求 OOP 會有代價。

今天這篇文章我不是說面向對象不好,只是希望大家不要過度神話它,更不要人云亦云。

大家都聽說過

面向對象的三大特性:繼承、封裝、多態

但其實這個說法有問題。面向對象的思想裏沒有任何繼承和多態的概念,正確的說法是:

這三大特性是面嚮對象語言的特性,而不是面向對象理念本身的。

面嚮對象語言是面向對象設計思想的一種實現,面嚮對象語言爲了能在真實世界使用,其必須經過一些拓展和妥協,而問題也就隨着這些拓展和妥協而來。

1. 繼承帶來的也可能是無以復加的痛苦

在實際開發中,我們無論誰寫代碼,都要考慮代碼的複用性。面向對象的編程語言作爲給開發人員使用的工具,它也必須考慮到複用性。

所以,在面向對象編程語言裏,對面向對象的基礎思想做了拓展,搞出了繼承這個概念。

繼承就具體實現來說,就是子類擁有父類的所有非 private 的屬性和方法。繼承的出現能夠最大化的代碼複用。

當項目裏一個類已經有了我們需要的屬性和方法,而我們現在的需求只是在這個已有類的基礎上有些許的不同,我們只需要繼承這個類,僅把這少許的不同在子類中實現即可。

但是如果你用了繼承,你就引入了問題。

繼承的出現天然會使得子類和父類緊耦合。也就是說,父類和子類是緊密關聯的,牽一髮動全身。

如果現實世界裏,所有業務模型都是有層次的,而且層次井然有序,是一顆天然的樹,那這種緊耦合沒有什麼問題。

但是現實的需求可不是喫乾飯的!

咱們看看這樣一種情況。假設現在我們一家只有兩口人,即只有父親和孩子,那麼類繼承模型很容易模擬這種情況:

我們在現實生活裏,往往是三口之家:

那這就有問題了。就像小時候經常有人會問孩子,你覺得你是爸爸的孩子,還是媽媽的孩子啊?如果你要用 Java 的規矩回答,只能從是爸爸或者媽媽裏選一個,那麼完蛋了。回答爸爸的孩子,媽媽不高興;回答媽媽的孩子,問題更嚴重,隔壁老王?

但是,如果像 C++ 那樣,你說我既是爸爸的孩子也是媽媽的孩子,也有問題。

假設爸爸類裏有個方法叫說話,媽媽類也有個方法叫說話,你作爲繼承了他們的孩子類,自然也會擁有說話這個方法。問題來了,你所擁有的的說話這個方法到底來源於誰?

另外咱們說了,繼承會把子類和父類緊耦合,一旦業務模型失配,就會造成問題。

這裏給出一個維基百科舉的經典例子,來說明一下:

class Super {

  private int counter = 0;

  void inc1() {
    counter++;
  }

  void inc2() {
    counter++;
  }

}

class Sub extends Super {

  @Override
  void inc2() {
    inc1();
  }

}

你看,子類覆蓋了父類的 inc2 方法,但是這個 inc2 方法依賴於父類 inc1 的實現。

如果父類的 inc1 邏輯發生變化了,變成下面這樣

class Super {

  private int counter = 0;

  void inc1() {
    inc2();
  }

  void inc2() {
    counter++;
  }
}

這就會出現 stack overflow 的異常,因爲出現了無限遞歸。

所以,當我們在子類裏,依賴了父類方法作爲子類業務邏輯的一個關鍵步驟的時候,當父類的邏輯修改的時候,必須聯動修改所有依賴父類相關邏輯的子類,否則就可能引發嚴重的問題。

用繼承,本來是想少寫點代碼少加點班,結果……用網上看到的一句話說就是:

一日爲父,終生是祖宗。

像這種情況該怎麼辦?

現在只要是個正經的介紹面嚮對象的技術文章或者書籍裏,只要是涉及到繼承的,都會加這麼句話:

儘量選擇對象組合的設計方式。

在《阿里巴巴Java開發手冊》中就有一條:

組合和繼承的區別如下:

其實我認爲繼承和組合各有優缺點,如果兩個類確實非常緊密,就是存在層次關係,用繼承沒問題。

之所以有“組合優於繼承”這個說法,我個人感覺是組合更靈活,而且能防止被人濫用,用不好的話輕則類的層次失控,重則很可能就把整個項目的代碼質量給腐蝕了。

2. 封裝如同帶有漏洞的封印,可能會逃逸出魔王

封裝,說白了就是把屬性、方法,封到一個對象裏,這是面向對象的核心理念。

嘴上叫封裝,卻開了個縫兒。

我們知道,項目是既要兼顧代碼質量,還要兼顧運行性能的。不可能說爲了提升什麼松耦合、高內聚,就不管不顧性能了。

事情就壞在了這個兼顧性能這裏。面向對象裏,以上帝角度看,系統就是對象和對象之間的關係構造成的網絡。

就拿咱們上面談到的組合關係來說,組合關係的實現就是通過把一個對象當成另一個對象的屬性來實現的。

上面這圖就叫做 A 和 B 之間是組合關係。想用 A 對象裏的 B 對象,代碼這麼寫:

A a = new A();
B b = a.getB();

好,我們要問了,這個從 A 中獲取的 B,是 B 對象的實例還是實例的一個引用指針呢?

必然是引用指針吧,這是最基礎的知識。諾,問題來了,引用指針是可以修改的。

b.getS(); //原來是Hello World
b.setS("World");//直接改成World

原來 B 中有個字段 s,值是個 “Hello World”,我直接可以用代碼改成“World”。

如果這次修改隨意在個犄角旮旯裏,A 能知道嗎?A 矇在鼓裏,還以爲一切盡在把控當中呢。

你看,封裝的縫兒出來了吧。說句實話,就這種鬼操作,是非常難以排查的。

像這種封裝了,但是又沒封裝的問題,我只想說“封裝的挺好的,下次別封裝了”。

3. 多態好,但可能是面向對象的貪天之功

再說說多態。

其實,面向對象中的多態使用,纔是面嚮對象語言最被認可的地方。因爲有了多態,代碼才能保證在業務需求多變的情況下,保證了項目的相對穩定。

可是,多態不是面向對象獨有的啊。面向過程,函數式編程也可以:面向過程裏,C 語言可以靠虛函數去在運行時加載對應的函數實現去實現多態。函數式編程也可以通過組合函數去實現多態。

所以,面向對象連多態這種優勢都不獨特了。

4. 服務端業務變了,人們的觀點發生變化了

在說服務端業務的變化之前,我想先普及兩個概念,即有狀態的服務和無狀態的服務。

有狀態的服務就是說,服務需要暫時存一些和客戶端相關的數據,以便客戶端後續發來的請求可以和客戶端前面發的請求通過服務器端關聯起來,從而共同完成一項業務。

無狀態服務是說,服務端不存儲任何和客戶端相關的數據,客戶端每次請求,服務端都認爲這是個新客戶端,和以前的請求無任何關係。

用現實生活舉例的話,有狀態服務就是你去一家健身房,第一次去的時候花了一筆錢辦了一張健身卡,你以後每次去健身,有卡就不用再掏錢了。

無狀態服務就是,你沒辦卡,每次去都和第一次去一樣現掏錢。

那麼,無狀態服務和有狀態服務和麪向對象的衰落又有什麼關係呢?在如今的年代,分佈式、微服務大行其道。一個有狀態的服務是不容易做分佈式和做彈性伸縮的。

當年,大家做有多個步驟的業務的時候,爲了保證業務數據不會因爲用戶偶然的關閉瀏覽器或者瀏覽器崩潰等問題而丟失,往往會把上一個步驟的信息存在服務端的 session 裏,而現在則會傾向考慮把信息放在客戶端的本地存儲上。

我舉個例子,假設現在有個需求,要在後臺系統新增加一個功能:用戶信息管理。其中有個需求要求這樣操作,錄入用戶信息分成兩步。

  • 第一步,錄入用戶的基本信息:姓名、手機號、年齡……

  • 第二步,錄入額外信息:家庭成員、教育經歷、工作經歷……

出於信息完整度的考慮,業務要求這兩步應該是一個完整的事務。要麼都成功,要麼都失敗。

從技術實現上講,如果是多年以前,我們會在第一步的時候,把商戶的基本信息做成表單提交,然後爲了保證不會因爲用戶誤關閉瀏覽器等意外問題丟失中間的數據,保存在對應的 session 中後,在第二步信息提交後,合併起來一起存入到數據庫中。

但是,現在的技術趨勢是,做任何事情,儘量讓服務器端無狀態,也就是不存儲客戶端相關數據。

此時,這個需求的解決方案就是,當第一步填寫商戶信息完成後,直接把數據存儲在客戶端的本地存儲裏又或者直接就存在 cookie 裏,在第二步填寫內容完畢後,聯合存在客戶端的信息一起提交到服務器端,然後存入數據庫。

所以,你看到了,現在大家的趨勢就是服務器端都在轉向無狀態服務,哪怕以前是有狀態的服務,也會通過一些增加客戶端參數等手段,去改造爲無狀態服務。

說了這麼多,那這種技術趨勢的變化對我們的面向對象有什麼影響呢?

影響在於,服務端現在越來越變得往單純的處理數據這個方向發展。當僅處理數據的時候,服務器端真正的需求其實就是計算,然後就是爲了大幅度提升計算速度,而帶來的並行化需求。

而面向對象這種方式和我們當今的技術趨勢是有一些衝突的。

首先就是確定性的衝突。

我們的首要需求從以前重度處理業務狀態加業務數據變成了業務數據的計算,而計算是需要確定性的:即給定相同的輸入,經過服務器端相同的邏輯處理後,應該給定相同的輸出。

而面向對象這種方式,出身在有狀態服務大行其道的年代,它會優先考慮業務邏輯的調度,其次纔是計算,所以,面向對象是擁有狀態的。面向對象的狀態就是它的字段值。這些字段值,如果單純的從計算數據角度看,他們不僅無意義了,反而還引入了風險。

比如,我們不小心把一個對象的狀態給共享出去了,那當我們用同樣的輸入計算的時候,很可能由於狀態的變化,導致了不同的輸出結果,最後就是項目出了問題。

其次,由於計算我們對性能更加看重了,又由於無狀態服務的大量使用,所以,並行的重要性也遠遠超出了以前。而並行,要求的是結構的開放,和更加嚴格的無狀態化,而面向對象,恰恰嚴重依賴於狀態,並且,他還把這種狀態依賴封裝在了複雜的對象關係裏。

A 狀態依賴於 B 的狀態,B 的狀態又依賴於 C,而這些依賴,全部被封裝在了 D 對象的實現細節裏,這種嚴重的反並行也是現在越來越多人開始反感面向對象的重要原因。

結尾

說了這麼多面向對象的壞話,其實真的是面向對象自身的問題嗎?並不是。

首先,面向對象其實就是我們程序員試圖簡化這個世界,提高對這個世界的認知的一種美好願望而已。願望來自於人自身認知的侷限性,所以本身就不可能完美。

其次,面向對象編程語言只是一種工具,工具的使用的好壞還是要靠人的,不可能每個人能把一套工具用的完美無缺。

如上所說,面向對象的問題本質還是人的問題,而人可能永遠都需要通過組合使用越來越多的類似面向對象的這種並不完美的工具去解決自己的問題。

所以,我們不能一味的依靠面向對象,認爲面向對象就是最棒的,也不能發現面向對象可能應付不了某些業務場景了,就開始極端地摒棄它。

我們要靈活地,合理地使用任何我們可以使用的編程思想、編程工具,積極地去擁抱變化。

不要忘了我們寫代碼的初衷。


你好,我是四猿外。

一家上市公司的技術總監,管理的技術團隊一百餘人。

我從一名非計算機專業的畢業生,轉行到程序員,一路打拼,一路成長。

我會把自己的成長故事寫成文章,把枯燥的技術文章寫成故事。

歡迎關注我的公衆號,關注後可以領取高併發、算法學習資料。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章