物件導向程式設計,不美了麼?

四猿外發表於2022-01-20

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

如果物件導向程式設計是一個人,我猜他自己在不斷被非議的今天,一定會這樣感慨。

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

剛學 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 物件的實現細節裡,這種嚴重的反並行也是現在越來越多人開始反感物件導向的重要原因。

結尾

說了這麼多物件導向的壞話,其實真的是物件導向自身的問題嗎?並不是。

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

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

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

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

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

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


你好,我是四猿外。

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

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

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

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

相關文章