OOP 的問題與反思

*** 本文簡體版已發表於超理論壇: OOP 的问题与反思 - 超理论坛

已經两年沒有寫文章了。這两年因為個人原因,沒有繼續研究數學,在一心搞 programming. 以後還是會重新回來研究數學的,數學方面的文章也會繼續寫,大概明年吧。

雖然我很早就會寫程式了,但是真正熟悉這個領域也只是最近的事情。我發現這個領域其實充斥著各種權威,不斷傳播各種錯誤資訊。其中有些事情,我已經看的比較清楚了,所以我要寫出來,讓更多的人了解。

Object-Oriented Programming, 簡稱 OOP, 基本上是現在主導業界的 programming paradigm. 這一篇文章就來談一談 OOP 的問題。

Youtube 上有一個叫 Brian Will 的傢伙做了四支影片,一針見血地指出了 OOP 的一些根本的問題:

然而他的影片比較長,有些部分也不太好理解。所以我想不如就寫篇文章,把他影片中的要點拿出來,和我自己的看法相結合,簡潔地說明一下。

Encapsulation 的問題

OOP 所提倡的 encapsulation 存在一些問題。Brian Will 甚至直接說 OOP 的 encapsulation 根本就不 work. OOP 在 encapsulation 這方面的問題比較少被指出,常見的對 OOP 的批評一般都集中在 abstraction 上面,對 encapsulation 並不是很關注。但是事實上 encapsulation 的問題才是 OOP 最嚴重的問題。

耦合問題

問一下自己,我們為什么需要 encapsulation? 一般認為,做 encapsulation 是為了將程式模組化,踐行所謂「單一功能原則(single responsibility principle)」。然而,這個目的只依靠 encapsulation 是非常難以達成的。在實際的 non-trivial 的程式設計中,如果不使用一些 design patterns, dependency injection 之類的方法,單純使用 encapsulation 寫出來的 code, 一般都是不能做到所謂 single responsibility 的。我看過很多這樣子的 code: 各個 class 看上去各司其職,其實背地裡扭成一團,所謂 single responsibility 不過是表象而已。Brian Will 用下面這一句話來形容這種表象:

You'll have all these objects giving you a warm fuzzy feeling of encapsulation but you're not going to have any real encapsulation of any significance.

那麼為什麼單憑 encapsulation 不能做到 single responsibility 呢?這是因為,要把 class 看做模組,使用模組化的方法以達成 single responsibility, 模組之間必須是相對獨立 (self-contained) 的。Encapsulation 限制的只是單個模組的結構,只能保證模組不會暴露出太多的內部狀態,它對於模組之間的相對獨立性並没有太大的幫助,因為獨立性是相互的,反映在多個模組之間的關係而不是單個模組的結構。假如模組 A 相依於模組 B, 模組 B 又相依於模組 A, 那麽 A, B 這兩個模組就互相依存了,耦合在一起不能分離,可以看成一個整體。這種情況下,甚至可以直接把 A 和 B 合併——它們已經耦合在一起了,為什么硬要分成兩個模組?類似地,假如模組 1 和模組 2 都要讀寫模組 3 的狀態,模組 1 和模組 2 就會間接地耦合在一起,把它們分成两個不同的模組也已經没什麼意義。在使用 OOP 的過程中,像這樣的模組間出現耦合的狀況會常常出現,水準高的人也會使用 design patterns 之類的方法去解耦合或者直接把耦合的模組合併成較大的模組;而水準低的人就會寫出上文提到的那種只具有 single responsibility 的表象的爛程式。

看到這裡讀者可能會 argue 這是寫程式的人的問題而不是 OOP (寫程式的方法) 的問題。這樣的論斷其實並不成立。其一,design patterns 之類的東西是後起的,它們並不屬於 OOP 自身的理論範圍之內。說難聽一點,design patterns 只是為了彌補 OOP 自身的不足而設計的一些 workaround. 這恰恰說明了 OOP 自身是有問題的。其二,workaround != solution, 即便有一些解耦合的手段,也無法解決 encapsulation 的根本問題——粒度問題。

粒度問題

使用一系列解耦合的手段,把各個模組安排成嚴格的樹形結構(或者至少是 DAG),可以避免 encapsulation 的耦合問題。然而,encapsulation 除了耦合問題,還有粒度的問題。因為 OOP 用 class 來對應模組的概念,並且提倡使用儘量多的 class 來對應不同的 responsibility, 模組的數量會比較多,粒度會比較小。當很多的模組被安排在樹形結構上的時候,如果樹的一個分支上的一個模組,因為某種原因,需要去依賴另一個分支上的另一個模組,就會很容易產生耦合問題。

如果不想讓這兩個模組耦合起來,要麼去修改它們所有共同的 ancestor, 要麼將整個樹結構砍掉重練。這兩個方案都需要修改不少這兩個模組以外的模組(因為模組總數很多),直接違反了 single responsibility 的原則。要解決這個問題,只能增大模組的粒度,減少模組的總數。但是可想而知,模組的粒度一旦增加到一定的程度,原來的 OOP 設計就變成和基於模組的 procedural programming 類似的東西了,既然如此,直接寫 procedural code 不好嗎,何必要用 OOP.

過早抽象、過度抽象、濫用 interface

基於 OOP 的程式往往會出現過早抽象和過度抽象的問題。這一類問題很好理解,不難解決,但是並不是很被重視,因為它們經常被歸結成人的水準問題而不是 OOP 本身的問題。事實上,受到一些流行的 OOP 觀點和具體的 OOP 技術(比如 Java)的影響,人們會不由自主地進行過早抽象或是過度抽象。Java 要求把所有的函式都裝到 class 裡面,好像暗示了每個函式都必須和特定的資料型別相關聯。要實作這樣苛刻的全面關聯的話,結果必然是:經常需要添加一些不必要的抽象,或者提前建立一些自己也拿不準的抽象,只是為了建立函式和型別之間的關聯。

可是誰說 class 一定要被作為資料型別來使用呢?它完全可以用作一個只包含 static member 的 namespace, 完全可以不包含任何抽象,只是用來把 code 組織起來而已。而可惜的是由於 OOP 的思想禁錮,class = data type = abstraction 已經成為了一種普遍的想法。按照這種想法,創建一個 class 就是創建一個對現實世界的抽象,那麼過早抽象和過度抽象也就成了順理成章的事情。

除了過早抽象和過度抽象以外,濫用 interface 也是一個很嚴重的問題。看一個極端的例子:RxGo/observable.go, 其中 Observable 這個 interface 有整整 72 個 method, 這還不算它繼承的 Iterable interface 裡面的 method. 目前只有 ObservableImpl 這一個 struct 實作了 Observable. 不知道這樣寫 code 到底有什麼意義——如果 Observable 要有一個新的實作,就必須重新實作 72 個 method.

看到這裡讀者可能又會 argue 這個其實是人的水準的問題。可是 RxGo 是 ReactiveX 的 Go 語言官方實作,而 ReactiveX 是理解難度很大的東西,根本不是水準很差的人有能力實作的。所以這是一個很典型的例子,說明水準不差的人也會濫用 interface. 究其原因,可以歸結於 OOP 提倡的這一種寫法:即便現在某個功能 Foo 只有一個實作,如果覺得將來有可能加入其它實作,那麼應該寫一個 interface Foo 和一個 class FooImpl, 而不是只寫一個 class Foo. 這樣做的話將來假如要新增實作就會比較容易。這種觀點的流行,導致了很多不必要的 interface 被丢在 code 裡面,如果没一直有新實作,久而久之,interface 作為 interface 的存在感越來越低(因為 interface 的功能沒有被有效使用),寫 code 的人專注於添加新功能,於是 method 就越來越多。所謂「不識廬山真面目」,外人看起來很不可思議的問題(72 個 method),專案作者自己可能完全没意識到。其實,interface Foo 的寫法根本就是畫蛇添足,誤人子弟——最開始寫一個 class Foo, 有了新實作再改成 interface, 也不是什麼很困難的事情。

一些反思

儘管 OOP 有很多問題,業界對於現狀其實也没有太多的不滿,畢竟大部分情況下都是寫 CURD, 何必計較這麼多。不過,回想一下 OOP 是怎麼「兜售」給初學者的,就會覺得毛骨悚然。

因為,從 OOP 的各種問題很容易看出,寫出好的 OOP code 其實是一件很難的事情。要寫好 OOP, 不只要理解 OOP 本身,還要學會用 design patterns 等等 workaround, 控制 class 的粒度,謹慎地對現實世界做抽象。相比之下 procedural code 反而單純一些,不會產生太多的「心智負担」。但是 OOP 的初學者被灌輸的觀念卻是: OOP 比 procedural 更高級,基本上是比 procedural 更好的選擇。我們只告訴了初學者 OOP 好的一面,而忽略了壞的一面,甚至讓初學者誤以為用 OOP 總是可以寫出比 procedural 更好的 code. 這是非常可怕的。

另外,OOP 的過早抽象問題給我們敲響了警鐘:不要妄想自己一拍腦袋想出來的抽象就是一個好的抽象——建立好的抽象需要付出很多,天下沒有免費的午餐。

最後說一下,既然 OOP 有這麼多的問題,那麼避免這些問題的最好的做法是什麼?Brian Will 的建議是完全不應該使用 OOP, 只寫 procedural code. 這就比較偏激了。我的建議是:最好先寫 procedural code, 有必要再把其中一部分轉換成 OOP. 這樣的循序漸進的轉換是比較自然的,可以在一定程度上避免生搬硬套 OOP 的各種觀點,所以不容易出問題。

This page intends for crawlers/spiders/bots of search engines. If you didn't modify your HTTP UserAgent, please report the bug to spider-detector.