面向對象編程的弊端是什麼?

當前很多資料都在講述面向對象的優點,可是在軟體開發或者大型軟體項目中,面向對象卻帶來了巨大的類關係設計開銷,但具體有何弊端卻沒有詳盡的介紹。

相關問題:

面向對象編程(OOP)的價值與優點是什麼?


OOP 實際上就是為了解決 Polymorphism 這個元問題,即:

如何讓我的函數可以適用於多種類型,而不用關心這些類型的細節?

只是看來,「類繼承」這個 JAVA 等語言試圖給出的答案似乎並不是很好。未來的語言設計很可能是 Parametric Polymorphism 的大舉回歸,加上 Bounded quantification 處理各種的「界」。


從我個人來說,經常遇到的一個問題是oo和並行的衝突。oo要封裝,並行要開放。oo要array of struct/class,並行要struct of array。以至於很多時候為了上CUDA等大規模並行,得把原有數據結構做大規模修改。

不過如果習慣了並行優先,那會把程序設計成數據和操作分離的模式。不那麼oo但結構仍然清晰。


我想從一個遊戲程序員的角度探討OOP的一個問題──性能。

現時C++可以說是支持OOP範式中最為常用及高性能的語言。雖然如此,在C++使用OOP的編程方式在一些場合未能提供最高性能。 [1]詳細描述了這個觀點,我在此嘗試簡單說明。注意:其他支持OOP的語言通常都會有本答案中提及的問題,C++只是一個合適的說明例子。

歷史上,OOP大概是60年代出現,而C++誕生於70年代末。現在的硬體和當時的有很大差異,其中最大的問題是內存牆_百度百科。

圖1: 處理器和內存的性能提升比較,處理器的提升速度大幅高於內存[2]。

圖1: 處理器和內存的性能提升比較,處理器的提升速度大幅高於內存[2]。

跟據Numbers Every Programmer Should Know By Year:

圖2:2014年計算機幾種操作的潛伏期(latency)。

圖2:2014年計算機幾種操作的潛伏期(latency)。

從這些數據,我們可以看出,內存存取成為現代計算機性能的重要瓶頸。然而,這個問題在C++設計OOP編程範式的實現方式之初應該並未能考慮得到。現時的OOP編程有可能不緩存友好(cache friendly),導致有時候並不能發揮硬體最佳性能。以下描述一些箇中原因。

1. 過度封裝

使用OOP時,會把一些複雜的問題分拆抽象成較簡單的獨立對象,通過對象的互相調用去實現方案。但是,由於對象包含自己封裝的數據,一個問題的數據集會被分散在不同的內存區域。互相調用時很可能會出現數據的cache miss的情況。

2. 多態

在C++的一般的多態實現中,會使用到虛函數表。虛函數表是通過加入一次間接層來實現動態派送。但在調用的時候需要讀取虛函數表,增加cache miss的可能性。基本上要支持動態派送,無論用虛函數表、函數指針都會形成這個問題,但如果類的數目極多,把函數指針如果和數據放在一起有時候可放緩問題。

3. 數據布局

雖然OOP本身並無限制數據的布局方式,但基本上絕大部分OOP語言都是把成員變數連續包裹在一段內存中。甚至使用C去編程的時候,也通常會使用到OOP或Object-based的思考方式,把一些相關的數據放置於一個struct之內:

struct Particle {
Vector3 position;
Vector4 velocity;
Vector4 color;
float age;
// ...
};

即使不使用多態,我們幾乎不加思索地會使用這種數據布局方式。我們通常會以為,由於各個成員變數都緊湊地放置在一起,這種數據布局通常對緩存友好。然而,實際上,我們需要考慮數據的存取模式(access pattern)。

在OOP中,通過封裝,一個類的各種功能會被實現為多個成員函數,而每個成員函數實際上可能只會存取少量的成員變數。這可能形式非常嚴重的問題,例如:

for (Particle* p = begin; p != end; ++p)
p-&>position += p-&>velocity * dt; // 或 p-&>SimulateMotion(dt);

在這種模式下,實階上只存取了兩個成員變數,但其他成員變數也會載入緩存造成浪費。當然,如果在迭代的時候能存取盡量多的成員變數,這個問題可能並不存在,但實際上是很困難的。

如果採用傳統的OOP編程範式及實現方式,數據布局的問題幾乎沒有解決方案。所以在[1]里,作者提出,在某些情況下,應該放棄OOP方式,以數據的存取及布局為編程的考慮重中,稱作面向數據編程(data-oriented programming, DOP)。

有關DOP的內容就不在此展開了,讀者可參考[1],還有[3]作為實際應用例子。

[1] ALBRECHT, 「Pitfalls of Object Oriented Programming」, GCAP Australia, 2009. http://research.scee.net/files/presentations/gcapaustralia09/Pitfalls_of_Object_Oriented_Programming_GCAP_09.pdf
[2] Hennessy, John L., and David A. Patterson. Computer architecture: a quantitative approach. Elsevier, 2012.
[3] COLLIN, 「Culling the Battlefield」, GDC 2011. http://dice.se/wp-content/uploads/CullingTheBattlefield.pdf


弊端是,沒有人還記得面向對象原本要解決的問題是什麼。

1、面向對象原本要解決什麼(或者說有什麼優良特性)
似乎很簡單,但實際又很不簡單:面向對象三要素封裝、繼承、多態

警告:事實上,從業界如此總結出這面向對象三要素的一剎那開始,就已經開始犯錯了!)。

封裝:封裝的意義,在於明確標識出允許外部使用的所有成員函數和數據項,或者叫介面

有了封裝,就可以明確區分內外,使得類實現者可以修改封裝的東西而不影響部調用者;而外部調用者也可以知道自己不可以碰哪裡。這就提供一個良好的合作基礎——或者說,只要介面這個基礎約定不變,則代碼改變不足為慮。

繼承+多態:繼承和多態必須一起說。一旦割裂,就說明理解上已經誤入歧途了。

先說繼承:繼承同時具有兩種含義:其一是繼承基類的方法,並做出自己的改變和/或擴展——號稱解決了代碼重用問題;其二是聲明某個子類兼容於某基類(或者說,介面上完全兼容於基類),外部調用者可無需關注其差別(內部機制會自動把請求派發[dispatch]到合適的邏輯)。

再說多態:基於對象所屬類的不同,外部對同一個方法的調用,實際執行的邏輯不同。

很顯然,多態實際上是依附於繼承的兩種含義的:「改變」和「擴展」本身就意味著必須有機制去自動選用你改變/擴展過的版本,故無多態,則兩種含義就不可能實現。

所以,多態實質上是繼承的實現細節;那麼讓多態與封裝、繼承這兩個概念並列,顯然是不符合邏輯的。不假思索的就把它們當作可並列概念使用的人,顯然是從一開始就被誤導了——正是這種誤導,使得大多數人把注意力過多集中在多態這個戰術層面的問題上,甚至達到近乎惡意利用的程度;同時卻忽略了戰略層面的問題,這就致使軟體很容易被他們設計成一灘稀屎(後面會詳細談論這個)。

實踐中,繼承的第一種含義(實現繼承)意義並不很大,甚至常常是有害的。因為它使得子類與基類出現強耦合。

繼承的第二種含義非常重要。它又叫「介面繼承」。
介面繼承實質上是要求「做出一個良好的抽象,這個抽象規定了一個兼容介面,使得外部調用者無需關心具體細節,可一視同仁的處理實現了特定介面的所有對象」——這在程序設計上,叫做歸一化

歸一化使得高層的外部使用者可以不加區分的處理所有介面兼容的對象集合——就好象linux的泛文件概念一樣,所有東西都可以當文件處理,不必關心它是內存、磁碟、網路還是屏幕(當然,對底層設計者,當然也可以區分出「字元設備」和「塊設備」,然後做出針對性的設計:細緻到什麼程度,視需求而定)。

歸一化的實例:
a、一切對象都可以序列化/toString
b、一切UI對象都是個window,都可以響應窗口事件。

——必須注意,是一切(符合xx條件的)對象皆可以做什麼,而不是「一切皆對象」。後者毫無意義(從資訊理論角度上說,一切皆xx蘊含的信息量為0)。

顯然,歸一化可以大大簡化使用者的處理邏輯:
這和帶兵打仗是類似的,班長需要知道每個戰士的姓名/性格/特長,否則就不知道該派誰去對付對面山坡上的狙擊手;而連長呢,只需知道自己手下哪個班/排擅長什麼就行了,然後安排他們各自去守一段戰線;到了師長/軍長那裡,他更關注戰場形勢的轉變及預期……沒有這種層層簡化、而是必須直接指揮到每個人的話,累死軍長都沒法指揮哪怕只是一場形勢明朗的衝突——光一個個打完電話就能把他累成啞巴。

反過來也對:軍長壓根就不應該去干涉某個步兵班裡、幾個大頭兵之間的戰術配合;這不僅耽誤他行使身為軍長的職責,也會干擾士兵們長久以來養成的默契。他的職責是讓合適的部隊在合適的時機出現在合適的戰場,而不是一天到晚對著幾個小兵指手畫腳、弄的他們無所適從。

約束各單位履行各自的職責、禁止它們越級胡亂指揮,這就是封裝

正是通過封裝和歸一化,我們才可以做到「如果一個師解決不了問題,那就再調兩個師」「如果單憑陸軍解決不了問題,那就讓空軍也過來」——這種靈活性顯然是從良好的部隊編製得來的。在軟體設計里,我們叫它「通過合理模塊化而靈活應對需求變更」。

軟體設計同樣。比如說,消息循環在派發消息時,只需知道所有UI對象都是個window,都可以響應窗口消息就足夠了;它沒必要知道每個UI對象究竟是什麼(歸一化)、也不應該關心這個UI對象的內部執行細節(封裝)——該對象自己知道收到消息該怎麼做;而且若它出了問題,只需修改該對象即可,不會影響外部。

合理劃分功能層級、適時砍掉不必要的繁雜信息,一層層向上提供簡潔卻又完備的信息/介面,高層模塊才不會被累死——KISS是最難也是最優的軟體設計方法,沒有之一。

可見,封裝和歸一化才是戰略層面、生死攸關的問題。遵循它並不能保證你一定能打勝仗,但違反它你必定死的很難看。

但這兩個問題太大、太難,並且不存在普適性答案。這就使得沒有足夠經驗、缺乏認真思考的外行們根本無從置喙

前面提到過,人們錯誤的把多態這個戰術技巧提到「封裝和歸一化」相同的戰略層面上。這就致使本該談論戰略的設計工作被一群毫無實踐經驗、只會就著淺顯的多態胡扯八道的戰術家攻佔和把持,進而使得「以戰術代替戰略」成為普遍現象——因為對他們來說,多態是既容易理解又容易玩出諸多花樣的;而封裝和歸一化就太空泛又太複雜,對他們來說完全無從著手了。
所以,他們把一切精力都用在多態的濫用上,卻從不談封裝和歸一化:即使談到了,也是作為多態的附庸出現的。

這種戰術層面的空談很容易、也很容易出彩,但並不解決問題——反而總是導致簡單問題複雜化。
然而,對於如何解決問題,他們並不在行,也不在乎。因為他們沒有能力在乎。
這就要命了。

總結:面向對象的好處實際就這麼兩點。
一是通過封裝明確定義了何謂介面、何謂介面內部實現、何謂介面的外部調用者,使得大家各司其職,不得越界;
二是通過繼承+多態這種內置機制,在語言的層面支持歸一化的設計,並使得內行可以從代碼本身看到這個設計——但,注意僅僅只是支持歸一化的設計。不懂如何做出這種設計的外行仍然不可能從瞎胡鬧的設計中得到任何好處。

顯然,不用面向對象語言、不用class,一樣可以做歸一化的設計(如老掉牙的泛文件概念、遊戲行業的一切皆精靈),一樣可以封裝(通過定義模塊和介面),只是用面向對象語言可以直接用語言元素顯式聲明這些而已;
而用了面向對象語言,滿篇都是class,並不等於就有了歸一化的設計。甚至,因為被這些花哨的東西迷惑,反而更加不知道什麼才是設計。

2、人們以為面向對象是什麼、以及因此製造出的悲劇以及鬧劇

誤解一、面向對象語言支持用語言元素直接聲明封裝性和介面兼容性,所以用面向對象語言寫出來的東西一定更清晰、易懂

事實上,既然class意味著聲明了封裝、繼承意味著聲明了介面兼容,那麼錯誤的類設計顯然就是錯誤的聲明、盲目定義的類就是無意義的喋喋不休。而錯誤的聲明比沒有聲明更糟;通篇毫無意義的喋喋不休還不如錯誤的聲明

除非你真正做出了漂亮的設計,然後用面向對象的語法把這個設計聲明出來——僅僅聲明真正有設計、真正需要人們注意的地方,而不是到處瞎叫喚——否則不可能得到任何好處。

一切皆對象實質上是在鼓勵堆砌毫無意義的喋喋不休,並且用這種戰術層面都蠢的要命的喋喋不休來代替戰略層面的考量。

大部分人——注意,不是個別人——甚至被這種無意義的喋喋不休搞出了神經質,以至於非要在喋喋不休中找出意義:沒錯,我說的就是設計模式驅動編程,以及如此理解面向對象編程。

誤解二、面向對象三要素是封裝、繼承、多態,所以只要是面向對象語言寫的程序,就一定「繼承」了語言的這三個優良特性

事實上,如前所述,封裝、繼承、多態只是語言層面對良好設計的支持,並不能導向良好的設計。
如果你的設計做不出真正的封裝性、不懂得何謂歸一化,那它用什麼寫出來都是垃圾(不僅如此,因為你的低水平,「面向對象三要素」反而會誤導你,使你更快、更遠、更詭異的偏離目標)。

誤解三、把軟體寫成面向對象的至少是無害的

要了解事實上是什麼,需要先科普幾個概念。

1、什麼是真正的封裝

——回答我,封裝是不是等於「把不想讓別人看到、以後可能修改的東西用private隱藏起來」?

顯然不是
如果功能得不到滿足、或者未曾預料到真正發生的需求變更,那麼你怎麼把一個成員變數/函數放到private裡面的,將來就必須怎麼把它挪出來。

你越瞎搞,越去搞某些華而不實的「靈活性」——比如某種設計模式——真正的需求來臨時,你要動的地方就越多。

真正的封裝是,經過深入的思考,做出良好的抽象,給出「完整且最小」的介面,並使得內部細節可以對外透明(注意:對外透明的意思是外部調用者可以順利的得到自己想要的任何功能,完全意識不到內部細節的存在;而不是外部調用者為了完成某個功能、卻被礙手礙腳的private聲明弄得火冒三丈;最終只能通過怪異、複雜甚至奇葩的機制,才能更改他必須關注的細節——而且這種訪問往往被實現的如此複雜,以至於稍不注意就會釀成大禍)。

一個設計,只有達到了這個高度,才能真正做到所謂的「封裝性」,才能真正杜絕對內部細節的訪問。

否則,生硬放進private裡面的東西,最後還得生硬的被拖出來——當然,這種東西經常會被美化成「訪問函數」之類渣渣(不是說訪問函數是渣渣,而是說因為設計不良、不得不以訪問函數之類玩意兒在封裝上到處挖洞洞這種行為是渣渣)。

一個典型的例子,就是C++的new和過於靈活的內存使用方式之間的耦合。
這個耦合就導致了new[]/delete[]、placement new/placement delete之類怪異的東西:這些東西必須成對使用,怎麼分配就必須怎麼釋放,任何錯誤搭配都可能導致程序崩潰——這是為了兼容C、以及得到更高執行效率的無奈之舉;但,它更是「抽象層次過於複雜,以至於無法做出真正透明的設計」的典型案例:只能說,c++設計者是真正的大師,如此複雜的東西在他手裡,才僅僅付出了如此之小的代價。

(更準確點說,是new/delete和c++的其它語言元素之間是非正交的;於是當同時使用這些語言元素時,就不可避免的出現了彼此扯淡的現象。即new/delete這個操作對其它語言元素非透明:在c++的設計里,是通過把new/delete分成兩層,一是內存分配、二是在分配的內存上初始化,然後暴露這個分層細節,從而在最大程度上實現了封裝——但比之其它真正能彼此透明的語言元素間的關係,new/delete顯然過於複雜了)

這個案例,可以非常直觀的說明「設計出真正對外透明的封裝」究竟會有多難。

2、介面繼承真正的好處是什麼?是用了繼承就顯得比較高大上嗎?

顯然不是。

介面繼承沒有任何好處。它只是聲明某些對象在某些場景下,可以用歸一化的方式處理而已。

換句話說,如果不存在「需要不加區分的處理類似的一系列對象」的場合,那麼繼承不過是在裝X罷了。

了解了如上兩點,那麼,很顯然:
1、如果你沒有做出好的抽象、甚至完全不知道需要做好的抽象就忙著去「封裝」,那麼你只是在「封」和「裝」而已。
這種「封」和「裝」的行為只會製造累贅和虛假的承諾;這些累贅以及必然會變卦的承諾,必然會為未來的維護帶來更多的麻煩,甚至拖垮整個項目。

正是這種累贅和虛假的承諾的拖累,而不是為了應付「需求改變」所必需的「靈活性」,才是大多數面向對象項目代碼量暴增的元兇。

2、沒有真正的抓到一類事物(在當前應用場景下)的根本,就去設計繼承結構,是必不會有所得的。

不僅如此,請注意我強調了在當前應用場景下
這是因為,分類是一個極其主觀的東西,不存在普適的分類法

舉例來說,我要研究種族歧視,那麼必然以膚色分類;換到法醫學,那就按死因分類;生物學呢,則搞門科目屬種……

想像下,需求是「時尚女裝」,你卻按「窒息死亡/溺水死亡/中毒死亡之體征」來了個分類……你說後面這軟體還能寫嗎?

類似的,我遇到過寫遊戲的卻去糾結「武器裝備該不該從遊戲角色繼承」的神人。你覺得呢?

事實上,遊戲界真正的抽象方法之一是:一切都是個有位置能感受時間流逝的精靈;而某個「感受到時間流逝顯示不同圖片的對象」,其實就是遊戲主角;而「當收到碰撞事件時,改變主角下一輪顯示的圖片組的」,就是遊戲邏輯。

看看它和「武器裝備該不該從遊戲角色繼承」能差多遠。想想到得後來,以遊戲角色為基類的方案會變成什麼樣子?為什麼會這樣?

最具重量級的炸彈則是:正方形是不是一個矩形?它該不該從矩形繼承?如果可以從矩形繼承,那麼什麼是正方形的長和寬?在這個設計里,如果我修改了正方形的長,那麼這個正方形類還能不能叫正方形?它不應該自然轉換成長方形嗎?如果我有兩個List,一個存長方形,一個存正方形,自動轉換後的對象能否自動遷移到合適的list?什麼語言能提供這種機制?如果不能,「一視同仁的處理某個容器中的所有元素」豈不變成了一句屁話?

造成這顆炸彈的根本原因是,面向對象中的「類」,和我們日常語言乃至數學語言中的「類」根本就不是一碼事。

面向對象中的「類」,意思是「介面上兼容的一系列對象」,關注的只不過是介面的兼容性而已(可搜索 里氏代換);關鍵放在「可一視同仁的處理」上(學術上叫is-a)。

顯然,這個定義完全是且只是為了應付歸一化的需要。

這個定義經常和我們日常對話中提到的類概念上重合;但,如前所述,根本上卻徹徹底底是八杆子打不著的兩碼事。

就著生活經驗濫用「類」這個術語,甚至依靠這種粗淺認識去做設計,必然會導致出現各種各樣的偏差。這種設計實質上就是在胡說八道。
就著這種胡說八道來寫程序——有人覺得這種人能有好結果嗎?

——但,幾乎所有的面向對象語言、差不多所有的面向對象方法論,卻就是在鼓勵大家都這麼做,完全沒有意識到它們的理論基礎有多麼的不牢靠。
——如此作死,焉能不死?!

——你還敢說面向對象無害嗎?

——在真正明白何謂封裝、何謂歸一化之前,每一次寫下class,就在錯誤的道路上又多走了一步。
——設計真正需要關注的核心其實很簡單,就是封裝和歸一化。一個項目開始的時候,「class」寫的越早,就離這個核心越遠
——過去鼓吹的各種面向對象方法論、甚至某些語言本身,恰恰正是在慫恿甚至逼迫開發者儘可能早、儘可能多的寫class。

重複一遍:封裝可(通過固定介面而)應付需求變更、歸一化可簡化(類的使用者的)設計:以上,就是面向對象最最基本的好處。
——其它一切,都不過是在這兩個基礎上的衍生而已。

換言之,如果得不到這兩個基本好處,那麼也就沒有任何衍生好處——應付需求變更/簡化設計並不是打打嘴炮就能做到的。

誤解四、只有面向對象語言寫的程序才是面向對象的。

事實上,unix系統提出泛文件概念時,面向對象語言根本就不存在;遊戲界的精靈這個基礎抽象,最初是用C甚至彙編寫的;……。

面向對象其實是汲取以上各種成功設計的經驗才提出來的。

所以,面向對象的設計,不必非要c++/java之類支持面向對象的語言才能實現;它們不過是在你做出了面向對象的設計之後,能讓你寫得更愜意一些罷了——但,如果一個項目無需或無法做出面向對象的設計,某些面向對象語言反而會讓你很難受。

用面向對象語言寫程序,和一個程序的設計是面向對象的,兩者是八杆子打不著的兩碼事。純C寫的linux kernel事實上比c++/java之類語言搞出來的大多數項目更加面向對象——只是絕大部分人都自以為自己到處瞎寫class的麵條代碼才是面向對象的正統、而死腦筋的linus搞的泛文件抽象不過是過程式思維搞出來的老古董。

——這個誤解之深,甚至達到連wiki詞條裡面,都把OOP定義為「用支持面向對象的語言寫程序」的程度。
——我們提及面向對象時,明明在談論戰略、談論軟體總體設計;但總有人把它歪曲成戰術方面的、漫無目標卻還自我感覺良好的、瑣碎的投機。
——恐怕這也是沒有人說泛文件設計思想是個騙局、而面向對象卻被業界大牛們嚴厲抨擊的根本原因了:真正的封裝、歸一化精髓被拋棄,浮於表面的、喋喋不休的class/設計模式卻成了」正統「!

借用樓下PeytonCai朋友的鏈接:
名家吐槽:面向對象編程從骨子裡就有問題

————————————————————————————

總結: 面向對象其實是對過去成功的設計經驗的總結。但那些成功的設計,不是因為用了封裝/歸一化而成功,而是切合自己面對的問題,給出了恰到好處的設計

讓一個初學者知道自己應該向封裝/歸一化這個方向前進,是好的;用一個面向對象的條條框框把他們框在裡面、甚至使得他們以為寫下class是完全無需思索的、真正應該追求的是設計模式,則是罪惡的——它實質上是把初學者的注意力從真正應該注意的封裝、歸一化方向引開,欺騙他們陷入「近乎惡意的全方位濫用多態」的泥潭。

事實上,class寫的越隨意,才越需要設計模式;就著錯誤的實現寫得越多、特性用得越多,它就越發的死板,以至於必須更加多得多的特性、模式、甚至語法hack,才能勉強完成需求。

只有經過真正的深思熟慮,才有可能做到KISS。

到處鼓噪的面向對象編程的最大弊端,是把軟體設計工作偷換概念,變成了「就著class及相關教條瞎胡鬧,不管有沒有好處先插一杠子」,甚至使得人們忘記去關注「抽象是否真正簡化了面對的問題」——這是猥瑣的投機,不是設計。

一言以蔽之:沒有銀彈。任何寄希望於靠著某種「高大上」的技術——無論是面向對象、數據驅動、消息驅動還是lambda、協程等等等等——就能一勞永逸的使得任何現實問題「迎刃而解」的企圖都是註定要失敗的,都不過是外行的意淫而已;靠意淫來做設計,不掉溝里才怪。

想要做出KISS的方案,就必須對面對的問題有透徹的了解,有足夠的經驗和能力,並經過深思熟慮,這才能做出簡潔的抽象:至於最終的抽象是面向對象的、面向過程的還是數據驅動/消息驅動的,甚至是大雜燴的,那都無所謂。只要這個設計能做到最重要、也是最難的KISS,它就是個好設計。

在特定領域、特定場景下,的確有成功的經驗、正確/合理的方向:技術無罪,但,沒有銀彈。

————————————————————————————————————————
2016.5.16:

嗯,這個是我很久很久以前在CU上發過的一系列帖子……

當時很多鼓吹「面向對象就是好來就是好的」就著一知半解胡攪蠻纏,這系列帖子是駁斥他們的。所以很多詞句挖苦意味很濃,見諒。

再比如,傳說中的面向對象本該大顯神威的遊戲領域——就說流行的WOW吧。

這個遊戲有10個職業,10個種族,每個種族都有自己的幾個特有種族天賦(這個種族天賦還可能根據職業有所不同,比如血精靈);每個職業有幾十甚至上百種不同的技能/法術,這些技能有近戰技能,有遠程技能;有的技能會對敵方造成傷害或不良狀態,有的技能能給己方隊友加上好的狀態或治療隊友;而且很多這類技能還會根據目標的狀態切換不同的效果;有些技能是單體效果,有些技能是光環效果(又分為對敵方造成光環效果還是對己方兩種,也可能兩者兼備),而另一些技能是地圖範圍效果(如烈焰風暴是一個圓形區域;冰錐術是一個錐形區域;特別的,順劈斬是在當前攻擊目標旁邊不超過5碼的另一個敵對目標——某個boss的順劈斬更強,它會從第一個目標傳遞幾十個目標,總傳遞距離可以達到誇張的幾百碼;並且這個傷害也是各有特色的:戰士的順劈斬是每個目標傷害固定,有些boss的則是同時挨打的人越多傷害越低,但還有個變態boss卻是被打的人越多傷害越高……);大多數技能還可以通過天賦雕文強化/改變的面目全非(比如插一個雕文,法師的火球就不會造成持續傷害但施法速度增加;點一個天賦,法師的冰冷減速效果就會降低對方受到的治療效果;點某個天賦,盜賊的某些技能攻擊就會延長自身提升攻擊速度這個狀態的持續時間,等等);還有很多技能是因為學習了某個專業或裝備/持有某個物品而得到(比如,學了採藥,就可以得到生命之血這個技能,每3分鐘可用,能夠在若干秒內回復你若干生命值——這個技能和採藥技能等級掛鉤,但很可能接下來的某個版本,就會再和玩家的生命上限值掛鉤,以避免它像現在一樣,被玩家斥為廢柴技能);另外,不同等級的技能可能有施法時間甚至額外特效方面的差別;此外,每個技能會造成不同屬性的傷害/效果(神聖、暗影、元素、物理等等),甚至一個技能同時造成多種類型傷害效果,更有冰火球這樣根據目標抵抗力而智能選擇更大殺傷效果類型的變態魔法……

最後,最最重要的是,這所有職業上千個技能(或許加上NPC特有的一些技能,數目會達到幾千種)並不穩定,常常會因為某個技能或某些技能的組合過於強大/弱小而加以修改(比如加一個額外的負面狀態如無敵/聖療;甚至全面修改「抗性」「破甲」概念的定義)——玩過wow的都知道,這事幾乎每個月都有發生。

好吧,你打算怎麼設計這數千個技能/效果?
或者,你就這樣把這些概念用class這個筐一裝,然後到處開特例、特例都解決不了就搞23個模式使勁往一塊粘,管他整體結構如何,淌哪算哪?

扯淡。

有個故事說的好:
有人送幾個瞎子一條魚,瞎子們高興壞了,決定熬魚湯喝。魚湯熬好了,瞎子甲嘗了一口,真鮮啊;瞎子乙趕緊也喝一口,太鮮了,太好喝了。幾個瞎子一邊喝一邊讚美——忽然瞎子丙叫了起來:魚跳我腳上了,它不在鍋里!
眾瞎子大驚:這魚都沒放到鍋里,湯就鮮成這樣了;要是放進鍋里,還不得把我們都鮮死啊!

眾面向對象原教旨主義者把事情攪得一團糟,同樣也會大驚:天哪,用了面向對象都複雜成這樣,這要不用面向對象,這軟體就不能寫了吧!

想想看,假如讓那些面向對象原教旨主義者來設計,會出現什麼情況:

定義一個基類叫技能;然後一個繼承類叫法術技能,另一個叫物理技能;然後神聖法術從法術技能繼承,疾病法術也從法術技能繼承;由於聖騎士一個技能同時具備物理和法術兩種效果,於是必須多重繼承神聖法術和物理技能;多重繼承太危險,於是不得不把神聖法術搞成介面類,引入介面繼承甚至帶實現的純虛函數等等高端概念;然後,活該槍斃的暴雪設計師又想出了讓某個技能同時對目標加上神聖持續傷害效果的奇怪點子——於是不得不再加個繼承層次,使得神聖法術是神聖持續傷害法術的子集:僅立刻造成一次持續傷害的DOT(damage of time)技能……

那麼,點一個天賦,一個技能就會有dot,否則就沒有怎麼辦?

設計模式是靈丹妙藥,不是嗎 ^_^

等到把這所有幾千個技能全部搞定,起碼也是一個數萬個類、幾十層的恐怖繼承樹,並且會用完23個設計模式(甚至再發明幾個新模式出來,我也不會感到奇怪),精巧複雜到沒有任何人願意去碰它。

但,請注意,天殺的暴雪設計師,在最開始的設計方案里規定DOT不能暴擊;後來又添加約定說某某某職業的某個dot可以暴擊;另一個職業的某個dot在點天賦後可暴擊;至於死亡騎士,在他穿了T9套裝中的其中四件裝備時,他的某個瘟疫類型的dot可以暴擊——但另一個瘟疫dot永遠不能暴擊。

嗯嗯嗯,太好解決了——這不就是策略模式嗎?

好吧,你再填幾十幾百個類體系,然後把舊的幾十層繼承樹中的數萬個類一個個都策略化吧。反正不是我在維護……

哎呀不好,那個槍斃了幾百次都還沒死的暴雪設計師又出餿主意了,他要求:當死亡騎士點了邪惡系的某個天賦時,不光給他增加一個新的dot、並且在這個新dot的存在期間,還要保護他的兩個dot性疾病和1個debuf性疾病不被驅散!

繼續補充:在WLK裡面,那個腦袋都被子彈打成篩子了的暴雪設計師又跳出來了,用他滿是漏洞的腦子出了個該殺的主意:他要求添載入具概念,當玩家坐上載具時,臨時刪除他的所有技能,替換為載具的技能;或者當他坐在特定載具的特定位置時,防止他受到任何傷害、並且允許他釋放自己的所有技能!
更該死的是,他要求,一些技能本來不允許在移動中施放;但現在,當玩家坐在載具上某個位置時,要臨時允許他移動施法!

還有,為了平衡某個野外戰場,他還要求,在某方人數較少時,臨時根據提高他們的生命值和所有技能的攻擊力和治療能力——這個改變必須根據進入戰場的人數實時進行;在一方連續在某個戰場失敗時,同樣要給他們一定補償!

嗯嗯,看看這些不斷改變的刁鑽需求吧,如果沒有面向對象,沒有以策略模式為首的28個設計模式(我有理由相信你們需要至少28個設計模式而不是23個)的英明領導,我們這些沒接觸過大項目、不懂面向對象的傻B們,就是哭的拿眼淚把長城溶解掉都沒辦法吧?——我當然知道搭建長城的材料極難溶與水。

可憐的瞎子,你們的魚湯很鮮吧?

嗯,到這裡,希望讀者們也能停下來,好好思考一下,看看這個問題該如何解決。

想到了沒有?
這裡是答案,看看你的想法是否能不謀而合吧:

這個問題暴雪在Diablo 2時代已經完美解決了: 法術/技能資料庫化

所謂資料庫化,其實等同於表格化,例如這個隨便杜撰出來的簡化方案,是設計一個有如下欄位的數據表格:

法術ID 動畫效果 作用範圍 作用類型 屬性 特殊限制 強化類型 特殊設定

其中,特殊設定欄位可以是一段LUA代碼,可以在其中搜索、設置極其特殊的傷害類型,或者查詢順劈斬/治療鏈等奇特技能的傳遞目標等等。

特殊限制欄位設定法術的施法或/和生效條件,如驅散限定為只能作用於魔法性buf/debuf(根據職業不同,可能有進攻性驅散和防守性驅散之一,也可能同時具備——這就體現在可否驅散敵方/友方目標的debuf)

在這個方案下,釋放一個法術/技能,就成為一種查表運算——找到此法術ID,找到它的作用類型和傷害屬性,計算特殊設定(包括但不限於順劈斬模式的判斷、天賦加成和天賦效果、雕文加成和雕文效果等等)。

於是,到最後,整個法術體系被分為一組組的魔法buf/debuf、物理buf/debuf,這些buf/debuf會影響傷害公式中的某個因子或者造成傷害效果;而傷害效果又分為立即傷害/立即治療和持續傷害/持續治療;最後則是一套影響範圍判定機制。

舉例來說,騎士開聖盾,他同時得到一個buf和一個debuf。
buf是「無敵」,效果相當於設置傷害公式 a*(....) 前面的a因子為0(沒有無敵時此因子為1),於是所有傷害無效。
debuf則是「自律」,因為他的聖盾、聖療技能判斷條件里都有「有自律debuf,則不允許使用」的設定,於是禁止他在短時間內再次使用這些無賴技能。

敵方法師對他釋放寒冰箭,系統受理,但查詢騎士狀態,發現他處於無敵狀態,返回大大的兩個字「免疫」。

然後,有一個敵方牧師對他使用驅散,查詢牧師的驅散術發現,在驅散術的可驅散列表裡沒有聖盾術,於是提示無法驅散或驅散了另外的可驅散(魔法)效果。
敵方牧師迅速反應過來,再次對他使用強力驅散;查詢牧師強力驅散術,發現該牧師在不久前使用過強力驅散,提示無法施法。
等待3秒後,敵方牧師發現自己的強力驅散冷卻(cool down),再次使用強力驅散,查詢發現強力驅散可驅散聖盾術,於是成功移除騎士的無敵狀態。

現在,敵方法師再次對他釋放寒冰箭,騎士切換冰抗光環,系統查詢騎士狀態,發現冰抗光環,又查詢法師穿透等級,和暴擊等級,根據公式計算能否命中、能否造成全額傷害以及能否暴擊;然後提取法師和騎士雙方裝備、天賦數據代入公式計算傷害加成、減免數據,最後給出騎士受到的傷害數字(包括部分抵抗了多少)。

在暴雪設計師的整理之下,如上種種最終構成了幾個表格;只要查詢並代入相應的數據,即可計算出傷害/治療數值以及類型;特殊效果可以用存儲在資料庫中的LUA代碼補充完成。

最終的設計效果就好像內嵌了一個解釋器,這個解釋器會根據法術ID解釋執行資料庫內部的相關內容。

這樣一來,只要傷害公式、傷害/buf類型、動畫效果等等就位,那麼新增一個法術就只不過是在資料庫中新增一條記錄;讓某個角色學會一個新法術,則只需在它的可使用法術列表裡添加法術名稱(或法術ID);釋放法術是根據法術ID在資料庫中提取動畫;計算傷害是根據法術ID讀取傷害公式,然後代入相關欄位並求值。

而這一切,甚至可以通過內部實現的編輯器,用圖形界面完成。

如何?無與倫比的擴展性和便利性,是吧?

這麼一整套東西,核心代碼很可能只有數千甚至數百行。這是因為看似複雜的光環、buf等等東西,其實都已經抽象到和其他法術同樣的流程上來了。最終,所有這些全部歸一為解釋執行傷害公式、提取執行指定動畫之類寥寥幾個通用過程——這顯然同樣是封裝和歸一化思想結出的另一顆果實。但為什麼你就是想不到封裝和歸一化還能這樣用?很簡單,因為你被那些只會就著淺顯的多態喋喋不休的笨蛋徹底引偏方向了。

我並沒有親自實現過這個,所以不敢斷定這玩意兒靠幾百行代碼真的就能全部實現;但根據我在其它項目上的經驗,這套東西應該就是數百行代碼就可以寫出來的——但寫出並調試好這數百行代碼所需的時間可能是一個星期甚至一個月。

相比於不假思索的寫下class所必然導致的龐大、複雜的類層次,以及扯來扯去蛋疼無比的複雜的設計模式大網,這玩意兒的實現、維護、修改、擴展的便利程度,顯然不是一個量級的:前者可能數百人努力數年、弄出幾百萬行代碼都不能正確實現需求,而且必然bug滿天飛;而後者,一個人,個把月,千把行代碼,完成。如果實現水平足夠的話,寫完就再不用碰代碼,而是去寫圖形編輯工具了。之後,擴展、維護都不過是用自己實現的工具拖來拖去再改改屬性、數值,然後點存檔寫入資料庫,完事。

所以說,萬不可死板的傻抱著面向對象不放。你所面對的問題才是最重要的。
你必須隨機應變給出合適的方案——至於最後的設計方案會是什麼流派,那玩意兒根本無關緊要。拿出一個簡單、有效、可靠的方案,比什麼都重要。

最後,還是我在前文總結的那句話:

封裝可(通過固定介面而)應付需求變更、歸一化可簡化(類的使用者的)設計:以上,就是面向對象最最基本的好處。其它一切,都不過是在這兩個基礎上的衍生而已。


換言之,如果得不到這兩個基本好處,那麼也就沒有任何衍生好處——應付需求變更/簡化設計並不是打打嘴炮就能做到的。

再強調一遍,應付需求變更/簡化設計並不是空洞的宣傳口號。

封裝和歸一化類似軍隊制度建設,目標是搞出一個標準化、立體、多變、高效的指揮體系,從而獲得打大戰、打硬戰的能力,然後再去輕鬆碾壓問題。此所謂戰略。

而那些堆砌無用的所謂「設計模式」的傢伙,其實是在每個零件表面粘上掛鉤——據他們說,這樣會增加靈活性、應對需求變更、簡化設計:比如說你帶了個包,就可以掛他們在飛輪上粘的那個勾子上。

但實際上,你永遠不會把包掛飛輪上(但你還是不得不為那些」聰明絕頂「的傢伙「為了避免飛輪上的鉤子脫落、掛住其它零件、離心力太大破壞掛在上面的包」等等而衍生出的」傑出「設計買單)。
幸運的是,除了某些企業項目(或其他類似性質的項目),你並不會用到這麼爛的東西。因為這些笨蛋到處亂粘的鉤子會不可避免的導致整個項目變成黏糊糊的一團,從而在曠日持久的拖延後自殺。

這種做法,顯然是和面向對象的初心——通過封裝和歸一化獲得高效指揮體系——背道而馳,從而使得每個中了這種毒的傢伙參與的項目不可避免的成為一灘稀屎。

所以,很遺憾,只有殺馬特設計師才會這樣做。真正的設計師壓根不會在設計發動機時考慮「飛輪上掛包」這樣的需求(這就叫「以不知所謂的戰術投機代替戰略布局」)。他會幹凈利落的在整車設計時加個後備箱。

請注意,這並不是個比喻。

如你所見,在」每個零件上粘上掛鉤「這種事情實在太過瘋狂,所以在其他行業連玩笑都不是,因為再傻的人都不會這麼做。

然而在軟體設計時……這種事情是如此多見,多見到面向對象的領軍人物會推薦別人這樣做(如此理解面向對象編程);多見到業內很多大佬都不得不站出來,怒斥」面向對象是個騙局「。

名家吐槽:面向對象編程從骨子裡就有問題
「面向對象編程是一個極其糟糕的主意,只有矽谷里的人能幹出這種事情。」 — Edsger Dijkstra(圖靈獎獲得者)

Edsger W. Dijkstra

如此沉重的心智負擔,這顯然是面向對象的原罪。


容我黑一波Java!Java簡直就是對OOP概念濫用的極致。其他語言里,OOP是為程序員服務;Java里,OOP是為程序員添堵。

對於程序員來說,最苦惱的事情就是數據和程序如何和諧共處的問題,這兩者是密切相關的,數據的定義改了程序就要修改;反過來,程序的功能變了,數據也要修改。但是數據和程序的定義方法是兩種不一樣的語法,如果分開來寫,誰也不知道哪段程序和哪段數據是相關的了。

C程序員表示腦容量足夠,分開寫就分開寫
C++程序員把數據和程序合在一起寫了一個class。做不到的時候他們還是用C程序員的辦法。

Java程序員要更加高瞻遠矚一些,他們是這麼看這個問題的:數據和程序應該享有相同的權利。
Java程序員把數據加了一大堆getter和setter,然後把程序寫進了一個static class,於是他有了兩個類。他對此感到很滿意。在Java的世界裡,數據和程序都是class,這代表了公平和民主。

後來Java程序員對有main函數的class非常不滿,覺得這侵犯了其他class的平等權,於是他們發明了JavaBean。從此以後他們再也不知道自己的程序究竟有多少個入口了。

Java程序員對沒有implements的class感到恐懼,這代表它不能充分的實現多態性,調用這個類的代碼不能正確調用其他有相同功能、相同介面的類。於是他給每個class創建了一個介面一模一樣的interface。實際上這個interface從此以後再也沒有其他class實現過。每次修改介面的時候,還要兩個文件一起修改。

Java程序員極端排斥使用Object類型的指針,這代表自己對這個類型一無所知,甚至無法區分這個類型是自己定義的class還是別人定義的class。後來他定義了一個叫做MyObject的interface,再讓所有的interface繼承這個interface,再讓所有的class實現那些繼承自MyObject的interface,確保所有的類的實例都可以轉換成MyObject類型。他感覺好多了。

Java程序員想用一個介面抽象出自己所有類的生命周期特性,來實現究極的多態。這個介面有start和stop兩個方法,文檔規定返回true表示成功,返回false或者拋出異常表示失敗。最後99%的類的實現中,這兩個方法的實現都是:return true;

Java程序員對單個方法的實現超過10行感到非常不安,這代表自己的代碼可重用性很差。於是他把一個3個參數的長方法拆成了4個子過程,每個子過程有10個以上的參數。後來他覺得這樣很不OOP,於是他又創建了4個interface和4個class。

---------------------------------------------------------------------------------
(這個人沒黑過癮又修改了回答)

Java程序員喜歡思考哲學問題。他之前思考一個問題思考了一個星期,這個問題是:創建服務究竟應該是Server類的方法還是ServerManager類的方法。後來他決定,創建服務完整的流程應該是ServerManager的方法,但其中把服務註冊到Server的過程應該是Server的方法。之後的一個星期他在考慮這個方法究竟應該是私有方法還是公有方法。

Java程序員發明了很多設計模式,用來把不OOP的問題轉換成OOP的形式,只需要多寫兩倍的代碼。

歡迎一起黑!


不忘初心,方得始終。

前面幾個答案都說到了點子上:OOP最大的弊端,就是很多程序員已經忘記了OOP的初心,潛意識中把OOP教條主義化(如同對GOTO語句的禁忌一般),而不是著眼於OOP著力達到的、更本質的目標,如:

- 改善可讀性
- 提升重用性

但是OOP最重要的目標,其實是OCP,即「開閉原則」。這一點很多答案都沒有提到。

遵循開閉原則設計出的模塊具有兩個主要特徵:
(1)對於擴展是開放的(Open for extension)。這意味著模塊的行為是可以擴展的。當應用的需求改變時,我們可以對模塊進行擴展,使其具有滿足那些改變的新行為。也就是說,我們可以改變模塊的功能。
(2)對於修改是關閉的(Closed for modification)。對模塊行為進行擴展時,不必改動模塊的源代碼或者二進位代碼。模塊的二進位可執行版本,無論是可鏈接的庫、DLL或者.EXE文件,都無需改動。

這就是為什麼會有「用多態代替switch」的說法。在應該使用多態的地方使用switch,會導致:

1 - 違反「開放擴展」原則。假如別人的switch調用了你的代碼,你的代碼要擴展,就必須在別人的代碼里,人工找出每一個調用你代碼的switch,然後把你新的case加進去。
2 - 違反「封閉修改」原則。這是說,被switch調用的邏輯可能會因為過於緊密的耦合,而無法在不碰switch的情況下進行修改。

但是OCP不是免費的。如果一個模塊根本沒有擴展的需求,沒有多人協作的需求,花時間達成OCP又有什麼意義呢?設計類關係的時候忘記了OOP的初心,可能就會寫出很多沒有幫助的類,白白浪費人力和運行效率。

所以,假如所有代碼都是你一個人維護,沒有什麼擴展的需求,那麼多用一些switch也未嘗不可;假如你的代碼是要被別人使用或者使用了別人的代碼,OOP很可能就是你需要用到的工具。

除了OOP,Type class和Duck Typing都是可以幫助你達成OCP原則的工具。當然,如果你使用的語言是Java,這兩種工具都不用想了。


可能我的看法有些簡單粗暴。但是我希望表達一下我的「一家之言」。

面向對象編程有很多用途,很多用法,但是我們會發現「設計模式驅動」成為了非常有代表性的流派。有時我們會疑惑,為什麼會有設計模式,為什麼面向對象編程會出現如此恐怖的這麼複雜的類關係。

我認為這是為了滿足「增量式開發」。它假定幾個前提:
1,將一份代碼測試調試穩定所需要花的時間,遠遠大於撰寫代碼的時間。
2,已經通過測試的舊代碼永遠不修改,只對新代碼進行新增,是最可靠的開發方式。
3,你經常需要重用沒有源代碼的庫,並且擴展和修改其功能。

很多面向對象的設計,其實是為了滿足一個很基本的目的:不修改舊代碼,只新增代碼。代碼只增不改,所以才會出現「繼承」這種東西。因為你不需要原有的源代碼也不需要修改原有的類,而是派生一個類增加新的方法。「繼承」的本來目的看起來就是為了解決這個問題。

因此,很多類層次關係的設計,不是為了更高的效率,不是為了代碼看起來更清晰,而是為了「保證舊代碼不需要被修改」這個目的。

不過,這是不是現實呢?在某些公司,這是事實,在很多公司,以上的假定不是現實。
1,很多代碼並沒有經過長時間的充分的測試,因而沒有必要為了不浪費原有測試資源而拒絕修改舊代碼。
2,修改舊代碼在大多數公司是不可避免的。
3,很多時候我們提倡讀懂庫的源代碼,而不會盲目使用無源代碼的庫。
4,現在流行的敏捷開發模型中宣傳要擁抱變化。在敏捷模型中,測試案例是被固化的,而代碼與架構都可以經常被修改,換句話說,不修改舊代碼,以及通過類層次關係設計系統架構,這些特性對敏捷來說都不重要,甚至背道而馳。

我的看法與 酷殼「如此理解面向對象編程」恰恰相反,他認為過度 OO 設計是敏捷帶來的後果,而我認為敏捷開發恰恰使得過度 OO 設計變得毫無意義。——那些過度 OO 設計的人並沒有真正的懂敏捷。真正的敏捷是在保證測試案例通過的前提下,直接簡單粗暴的修改源代碼,而不是為了避免修改三行源代碼製造出十多個類

如果我們的開發基於「舊代碼就是需要被經常修改的」,那麼面向對象中的一部分特性其實變成了毫無意義的累贅。當然這並意味著 OOP 完全無用,在這個前提下,其實我們需要重新審視面向對象的程序設計,取其精華去其糟粕。

封裝性,繼承性,多態性,這些特性在敏捷開發的情況下還那麼重要嗎?

封裝性:在所有代碼都公開,隨時可能被修改被重構的情況下,封裝性意義沒有想像的那麼大,雖然它仍然有價值。它的價值在於使程序行為更可預期。

繼承性:繼承的本目的是在不修改代碼的情況下擴展代碼的功能,但如果我們能夠自由修改代碼,那繼承在絕大多數時候並不是最佳選擇。事實上,巨大的類設計開銷往往都是繼承性帶來的。繼承性在今天不但沒有太大的價值,反而常常有害。(這裡所說的繼承性指的是 invalid s 所說的第一種情況,對他說的第二種情況,我不認為那算作繼承性,而應該被算作多態性。)

多態性:多態性要求一個函數在接受不同的類型對象作為參數時自動表現出不同的行為。或者說要求不同類型的對象都可以調用相同名字的方法,並且自己處理應該有的不同行為。——這個特性在今天依然重要,而且是最重要的特性,它的存在意味著 OOP 的價值仍然存在——即便對於非 OOP 的程序設計語言。只要應用了多態性思想,那麼其實它就是 OOP 的。考慮一下 Linux 中的「一切皆文件」的概念,其實換句話說就是對一切對象皆可調用用於文件的那些方法,這妥妥的是「多態性」。圖形界面的編程 API 中,一切對象皆可 Paint(),一切對象皆可 toString(),這妥妥的也是「多態性」。

從某種意義上說,「模板」這個一般認為不屬於 OOP 的特性,卻非常好的體現了多態性。模板允許同名函數接收不同類型的參數自動表現出不同的行為(雖然編譯器實際上是生成了多個函數,但從編程的角度來講它其實是一個函數)。因此我認為懂得模板技術在 OOP 設計中也非常重要,他用更小的開銷更好的可讀性實現了 OOP 思想。

結語:很多時候,巨大的類關係設計開銷是為了避免修改舊代碼而不是為了使代碼更可讀。而在修改舊代碼不可避免或者根本不需要避免的時候,很多類關係設計開銷是不必要的,我們在使用面向對象設計方法時,最好是取其精華去其糟粕,避免那些額外的不必要的類設計開銷,以提升代碼的可讀性為目標來進行設計,而不要以避免修改代碼為目標進行設計,在這種情況下 OOP 仍然有其存在的價值。


很多時候為了OO而OO就是它最大的弊端。


解決所有複雜問題的不二法門是分而治之,不同的編程語言的做法不同,比如C是面向過程(程序=演算法+數據結構),C++/Java是面向對象,Golang是基於連接和組合。面向對象編程的基本思路是先從問題中提取主要概念,然後用類表示這些概念,再用封裝、繼承、多態設計這些類,形成產品的基本組件。根據問題的實際需要,組合這些組件,從而形成整個產品。
面向對象編程的核心是封裝、繼承、多態。這三板斧中,封裝和多態沒什麼問題。問題比較大的是繼承,繼承的主要意圖是復用代碼,但這種復用其實和高內聚低耦合的基本設計原則是矛盾的:哪些代碼應該放入父類,哪些應該放在子類,邊界常常是模糊的;而且,由於父類不論是介面設計和內部實現都會影響到子類,(甚至有時候為了滿足某個子類的特定需求,常常在父類中添加私貨,而有時候由於父類的實現考慮不周,也需要在子類中做workaround),導致在軟體演化的過程中,對父類/介面的改進是很困難的。這就反過來要求程序員在最初設計介面/基類/繼承體系的時候,需要對產品需求有非常深入的了解,而且希望在未來,這些需求比較穩定,不會發生非常大的變化。但是,在互聯網/移動互聯網時代,一切都變的非常快,崇尚試錯、敏捷、快速迭代,面向對象編程構造出來的繼承體系很難適應這種開發需求。
作為對比,我們可以看看Unix的代碼復用思路。Unix推崇每個程序只解決一個問題,做到最好,然後提供豐富的輸入/輸出(即介面)跟外部交互。解決新的問題則通過組合不同的程序來完成。這種代碼復用哲學產生的效果是驚人的,像grep, awk, wc, sed等一大堆Unix工具,這麼多年來一直在用。這種哲學,其實就是基於連接和組合--Golang的核心語言特徵。很有意思的是,Golang恰恰選擇了不支持繼承。而沒有繼承就談不上面向對象,因此Golang是反對面向對象編程範式的。


面向對象的弊端在於作為一種建模技術沒有很好的定義自己的適用範圍。面向對象脫胎的環境有兩個重要因素,一是基於 WIMP (Window, Icon, Menu, Pointer) 的圖形化界面,二是早期提供圖形界面介面的機器缺乏代碼級別之外的組件管理方式 (比如 Unix 的進程和 IPC)。

面向對象在 WIMP 的環境中是很必要也是很成功的。原因是 WIMP 環境需要重量的實現繼承提供的重用,WIMP 的對象種類能很好的被單繼承模擬,WIMP 的屬性和類別容易區分。而面向對象擴展到 WIMP 之外的環境中就失敗了:

  1. 實際世界是多緯度的,屬性和類別不好區分。紅蘋果是 color 屬性為 red 的蘋果,還是 Apple 的子類?
  2. 實際世界的工具是用來完成任務的。而不是象 WIMP 那樣構建一個虛擬的空間化界面。
  3. 《人月神話》指出,編寫 reusable code 比編寫普通 code 至少要多花三倍的工作量。而面向對象的模糊了代碼的重用和使用。使被重用的代碼的依賴複雜化。導致很多不適合被重用的代碼被重用。編寫代碼時要過分考慮重用的可能性。
  4. 其它管理複雜度的機制越來越流行。

「對象」的概念適用於「一個一個」的東西,當面對「一坨一坨的」、「一灘一灘連起來的」、「無處不在的」、「無限長的一條」、「分不清個的」東西時,就暈了。


2014年

關於找不到對象。這其實不是一個包袱。找不到對象真是是面向對象的一個大問題。
用非人類的語言來說「對象生命周期和可訪問性範圍不重合「,簡稱」找不到「。

用個例子來說明,當在某個對象的方法突然需要用到另一個對象的功能,比如日誌功能,它屬於一個叫secretLog的對象。發現自身沒有這個對象的引用,持有自己生命周期的對象也沒有引用,調用自己的對象也沒有引用,這就是」找不到對象「。

現實的解決辦法只有兩個:

1.god class:一個對象持有所有基本介面,並可以全局取得,如theApp。

2.傳首九邊:把secretLog傳遞給所有對象,比如Date或DateManager。

2017年

補答

AOP可以優化這個問題,部分正交的職責,可以從各對象中提取出來,通過代碼織入的方式減少對象對全局功能(比如日誌,用戶許可權等)對象的引用。

DI 可以自動化傳首九邊這個過程,讓特別長的傳遞路徑從設計層面就變短。

三年過去了,其實在OOP範圍內,解決辦法真的不多,一是做好單一職責,不要做幾個事,也不要什麼事都不做;一是用依賴反轉來把系統中的層次變少,不要雞包紙,紙包雞。再就是用上面兩個工具,簡化記簿代碼。


明白背後的運行原理之後,懂得怎麼避免陷阱的話沒有弊端


大部分問題是面向對象無法解決的。能夠採用面向對象簡化模型得到代碼重用性的時候,就用;不能的時候,就放棄,不要到處都用。
有的場合下,面向對象會增加代碼複雜度,增加維護的困難。
除了面向對象,我們還有很多辦法來實現我們要做的功能。不談更加高端大氣的函數式編程,其實用一串switch case或者else if就可以實現,代碼還簡單。
所有需要的東西都放在一起,同一個文件裡面同一段代碼,閱讀起來並不困難,很整齊。某個case下面太長的時候,也可以獨立出來包裝成一個函數調用;你愛放在同一個文件裡面還是另開一個文件都可以。
可是面向對象是一個很重量級的方法。你得設計類的繼承關係。得寫類的聲明,得實現不同的虛方法;實現時要注意是否要調用父類的方法。調試的時候經常搞不清楚到底執行哪個類的虛方法了。而用一大堆switch case,語句執行順序一目了然。
可能還是覺得代碼層級過多?有很多辦法可以整理得更乾淨。用了面向對象,省了switch case,卻要寫更多的virtual function的聲明和實現。
業務邏輯本身是複雜的,無法避免。沒有銀彈。


工程上往往是按下葫蘆起來瓢,所謂沒有銀彈就是這個意思。
OOP的最大的問題是試圖定義並解決一個不嚴重甚至不存在的問題,並將解決方案定勢化,然後製造了一堆大麻煩,和一大批拙劣的程序員......


面向對象三要素

封裝、繼承、多態

一:關於繼承
有一個設計原則是

多用組合,少用繼承

過重的繼承會帶來一定的管理困難和性能開銷,但一般人的常規設計思想都是趨向於使用繼承而非組合(至少對於我是這樣的).
以下文字引用自:[翻譯]進化遊戲的層次結構

隨著開發的進行,我們通常需要增加很多不同的功能到實體上。對象必須要麼封裝自己封裝功能,要麼從有那個功能的別的對象那裡繼承過來。經常性的功能被載入接近類層次結構的根節點上,比如說CEntity類。這樣做有一個好處,就是所有派生類都能有那些功能。但是不好的地方是會被這些類帶來相關的開銷。

即使是非常簡單的對象比如石頭或者是手榴彈,到最後會有大量的額外功能(和相關的成員變數,或者是不必要執行的成員函數)。傳統的遊戲對象層次結構經常到最後要創建一個被稱作」團跡」(胖球)(the blob)的東西。胖球是經典的反模式之一,表現為一個巨大的單類(或者是有大量的分支在類的層次結構上),擁有大量的複雜的互相交織的功能。

當胖球反模式經常在對象層次結構的根節點附近出現,它也就顯現在葉子節點上了(譯註:因為葉子節點是繼承自根的)。最有可能的候選者因該是表示玩家的類。由於遊戲通常是針對單一角色而編寫的程序,因此表示角色的對象經常有大量的功能。這經常是實現為在一個類里比如CPlayer類,有大量的成員函數。

實現這麼多功能在層次結構的根節點附近的結果就是給葉對象大量不需要功能的過重包袱。不管怎麼樣,用相反的實現方法,在葉子節點上實現大量的功能,同樣是不幸的結果。功能現在被分解了,所以只有專門為那個對象編程的特定功能才能使用它。程序員經常複製一樣的代碼到已經被不同的對象實現的鏡子函數里。最終,需要重新組織類的層次結構這種骯髒的重構來移動和組合功能。

二:關於封裝
在一些特定應用中,比如遊戲,過度封裝只會增加函數的性能開銷
其他參考:
什麼是遊戲開發的實體系統框架 What is an entity system framework for game development
What"s an Entity System?
Why use an entity system framework for game development?
Data-oriented design
好吧,我似乎偏題去講啥是實體系統了,歸根到底OOP的弊端在於使用OOP的人OOD功底不夠吧..


感性地說,面向對象的弊端就是水太深。

面向對象對設計師(程序員中的設計師)的要求很高。設計一個高效、邏輯清楚、易於維護和擴展的框架,要求設計者必須清楚面向對象的原理、目的、特性,吃透若干設計模式。
否則,混亂的類交互、巨量的冗餘代碼、無盡的繼承層次在多次迭代和易手之後,會變成真正的災難。

一不小心就出現設計缺陷,再一不小心又變成過度設計。面向對象的開發就是這樣不斷地尋求平衡。
所以如果沒有做一名「工匠」的覺悟,它就是個大坑。


  • http://c2.com/cgi/wiki?ArgumentsAgainstOop
  • http://harmful.cat-v.org/software/OO_programming/
  • Object Oriented Programming is an expensive disaster which must end
  • All evidence points to OOP being bullshit
  • Ten Things I Hate About Object-Oriented Programming

對於MATLAB的OOP來說,最大的弊端是速度太慢了!


面向對象只是一種組織邏輯的方式,類關係複雜說明邏輯沒劃分清楚。


推薦閱讀:

TAG:編程 | 軟體工程 | 面向對象編程 | 計算機科學 |