怎麼從本質上理解面向對象的編程思想?

2019-09-19     java進階落

面向對象編程(OOP)

面向對象編程(OOP),是一種設計思想或者架構風格。OO語言之父Alan Kay,Smalltalk的發明人,在談到OOP時是這樣說的:

I thought of objects being like biological cells and/or individual computers on a network, only able to communicate with messages (so messaging came at the very beginning -- it took a while to see how to do messaging in a programming language efficiently enough to be useful).

...

OOP to me means only messaging, local retention and protection and hiding of state-process, and extreme late-binding of all things. It can be done in Smalltalk and in LISP.

簡單解釋一下上面的這幾句話的大概意思:OOP應該體現一種網狀結構,這個結構上的每個節點「Object」只能通過「消息」和其他節點通訊。每個節點會有內部隱藏的狀態,狀態不可以被直接修改,而應該通過消息傳遞的方式來間接的修改。

這個編程思想被設計能夠編寫龐大複雜的系統。

那麼為什麼OOP能夠支撐龐大複雜的系統呢?用開公司舉個例子。如果公司就只有幾個人,那麼大家總是一起幹活,工作可以通過「上帝視角「完全搞清楚每一個細節,於是可以制定非常清晰的、明確的流程來完成這個任務。這個思想接近於傳統的面向過程編程。而如果公司人數變多,達到幾百上千,這種「上帝視角」是完全不可行的。在這樣複雜的公司里,沒有一個人能搞清楚一個工作的所有細節。為此,公司要分很多個部門,每個部門相對的獨立,有自己的章程,辦事方法和規則等。獨立性就意味著「隱藏內部狀態」。比如你只能說申請讓某部門按照章程辦一件事,卻不能說命令部門裡的誰誰誰,在什麼時候之前一定要辦成。這些內部的細節你管不著。類似的,更高一層,公司之間也存在大量的協作關係。一個汽車供應鏈可能包括幾千個企業,組成了一個商業網絡。通過這種鬆散的協作關係維繫的系統可以無限擴展下去,形成龐大的,複雜的系統。這就是OOP想表達的思想。

第一門OOP語言是Ole-Johan Dahland和Kristen Nygaard發明的Simula(比smalltalk還要早)。從名字就可以看出來,是用來支撐「模擬系統」的。模擬這個場景非常適合體現OOP的這個思想。這個語言引入了object、class、subclass、inheritance、動態綁定虛擬進程等概念,甚至還有GC。Java很大程度上受了Simula的影響。我們在現在教書上講解OOP類、實例和繼承關係時,總會給出比如動物-貓-狗,或者形狀-圓-矩形的例子,都源自於此。

還有一些帶有OO特徵的語言或者研究成果在Simula之前就出現,這裡就不往前追溯了。

但隨後在施樂Palo Alto研究中心(Xerox PARC),Alan Kay、Dan Ingalls、Adele Goldberg在1970年開發了smalltalk,主要用於當時最前沿計算模型研究。在Simula的基礎之上,smalltak特彆強調messaging的重要性,成為了當時最有影響力的OOP語言。與smalltalk同期進行的還有比如GUI、超文本等項目。smalltalk也最早的實現了在GUI使用MVC模型來編程。

但是,並不是說OOP程序一定要用OOP語言來寫。再強調一下,OOP首先是一種設計思想,非僅僅是編碼方式。從這個角度推演,其實OOP最成功的例子其實是網際網路。(Alan Kay也是網際網路前身ARPNET的設計者之一)。另外一個OOP典型的例子是Linux內核,它充分體現了多個相對獨立的組件(進程調度器、內存管理器、文件系統……)之間相互協作的思想。儘管Linux內核是用C寫的,但是他比很多用所謂OOP語言寫的程序更加OOP。

現在很多初學者會把使用C++,Java等語言的「OOP」語法特性後的程序稱為OOP。比如封裝、繼承、多態等特性以及class、interface、private等管家你在會被大量提及和討論。OOP語言不能代替人類做軟體設計。既然做不了設計,就只能把一些輪子和語法糖造出來,供想編寫OOP程序的人使用。但是,特彆強調,是OOP設計思想在前,OOP編碼在後。簡單用OOP語言寫代碼,程序也不會自動變成OOP,也不一定能得到OOP的各種好處。

我們在以為我們在OOP時,其實很多時候都是在處理編碼的細節工作,而非OOP提倡的「獨立」,「通訊」。以「class」為例,實際上我們對它的用法有:

  • 表達一個類型(和父子類關係),以對應真實世界的概念,一個類型可以起到一個「模版」的作用。這個類型形成的對象會嚴格維護內部的狀態(或者叫不變量)
  • 表達一個Object(即單例),比如XXXService這種「Bean」
  • 表達一個名字空間,這樣就可以把一組相關的代碼寫到一起而不是散播的到處都是,其實這是一個「module」
  • 表達一個數據結構,比如DTO這種
  • 因為代碼復用,硬造出來的,無法與現實概念對應,但又不得不存在的類
  • 提供便利,讓foo(a)這種代碼可以寫成a.foo()形式

其中前兩種和OOP的設計思想有關,而其他都是編寫具體代碼的工具,有的是為了代碼得到更好的組織,有的就是為了方便。

很多地方提及OOP=封裝+繼承+多態。我非常反對這個提法,因為這幾個術語把原本很容易理解的,直觀的做事方法變的圖騰化。初學者往往會覺得他們聽上去很牛逼,但是使用起來又經常和現實相衝突以至於落不了地。

「封裝」,是想把一段邏輯/概念抽象出來做到「相對獨立」。這並不是OOP發明的,而是長久以來一直被廣泛採用的方法。比如電視機就是個「封裝」的好例子,幾個簡單的操作按鈕(接口)暴露出來供使用者操作,複雜的內部電路和元器件在機器裡面隱藏。再比如,Linux的文件系統接口也是非常好的「封裝」的例子,它提供了open,close,read,write和seek這幾個簡單的接口,卻封裝了大量的磁碟驅動,文件系統,buffer和cache,進程的阻塞和喚醒等複雜的細節。然而它是用函數做的「封裝」。好的封裝設計意味著簡潔的接口和複雜的被隱藏的內部細節。這並非是一個private關鍵字就可以表達的。一個典型的反面的例子是從資料庫里讀取出來的數據,幾乎所有的欄位都是要被處理和使用的,還有新的欄位可能在處理過程中被添加進來。這時用ORM搞出一個個實體class,弄一堆private成員再加一堆getter和setter是非常愚蠢的做法。這裡的數據並非是具有相對獨立性的,可以進行通訊的「Object「,而僅僅是「Data Structure」。因此我非常喜歡有些語言提供「data object」的支持。

當然,好的ORM會體現「Active Record」這種設計模式,非常有趣,本文不展開

再說說「繼承」,是希望通過類型的 is-a 關係來實現代碼的復用。絕大部分OOP語言會把is-a和代碼復用這兩件事情合作一件事。但是我們經常會發現這二者之間並不一定總能對上。有時我們覺得A is a B,但是A並不想要B的任何代碼,僅僅想表達is-a關係而已;而有時,僅僅是想把A的一段代碼給B用,但是A和B之間並沒有什麼語義關係。這個分歧會導致嚴重的設計問題。比如,做類的設計時往往會希望每個類能與現實當中的實體/概念對應上;但如果從代碼復用角度出發設計類,就可能會得到很多現實並不存在,但不得不存在的類。一般這種類都會有奇怪的名字和非常玄幻的意思。如果開發者換了個人,可能很難把握原來設計的微妙的思路,但又不得不改,再穩妥保守一點就繞開重新設計,造成玄幻的類越來越多…… 繼承造成的問題相當多。現在人們談論「繼承」,一般都會說「Composite Over Inheritance「。

多態和OOP也不是必然的關係。所謂多態,是指讓一組Object表達同一概念,並展現不同的行為。入門級的OOP的書一般會這麼舉例子,比如有一個基類Animal,定義了run方法。然後其子類Cat,Dog,Cow等都可以override掉run,實現自己的邏輯,因為Cat,Dog,Cow等都是Animal。例子說得挺有道理。但現實的複雜性往往會要求實現一個不是Animal的子類也能「run」,比如汽車可以run,一個程序也可以「run」等。總之只要是run就可以,並不太在意其類型表達出的包含關係。這裡想表達的意思是,如果想進行極致的「多態」,is-a與否就不那麼重要了。在動態語言里,一般採用duck typing來實現這種「多態」——不關是什麼東西,只要覺得他可以run,就給他寫個叫「run」的函數即可;而對於靜態語言,一般會設計一個「IRun」的接口,然後mixin到期望得到run能力的類上。簡單來說,要實現多態可以不用繼承、甚至不用class。

OOP一定好嗎?顯然是否定的。回到OOP的本心是要處理大型複雜系統的設計和實現。OOP的優勢一定要到了根本就不可能有一個「上帝視角」的存在,不得不把系統拆成很多Object時才會體現出來。

舉個例子,smalltalk中,1 + 2 的理解方式是:向「1」這個Object發送一給消息「+」,消息的參數是「2」。的確是非常存粹的OOP思想。但是放在工程上,1 + 2理解為一般人常見的表達式可能更容易理解。對於1 + 2這樣簡單的邏輯,人很容易從上帝視角出發得到最直接的理解,也就有了最簡單直接的代碼而無用考慮「Object」。

如果是那種「第一步」、「第二步「……的程序,面向數據的程序,極致為性能做優化的程序,是不應該用OOP去實現的。但很無奈如果某些「純OOP語言」,就不得不造一些本來就不需要的class,再繞回到這個領域適合的編碼模式上。比如普通的Web系統就是典型的「面向」資料庫這個中心進行數據處理(處理完了展示給用戶,或者響應用戶的操作)。這個用FP的思路去理解更加簡單,直觀。也有MVC,MVVM這樣的模式被廣泛應用。

還有一些領域儘管用OOP最為基礎很適合,但是根據場景,已經誕生出了「領域化的OOP」,比如GUI是一個典型的例子。GUI里用OOP也是比較適合的,但是GUI里有很多細節OOP不管或者處理不好,因此好的GUI庫會在OOP基礎之上擴展很多。早期的MFC,.Net GUI Framework, React等都是這樣。另外一個領域是遊戲,用OOP也很合適,但也是有些性能和領域細節需要特殊處理,因此ECS會得到廣泛的採用。

總結一下,OOP是眾多設計思想中的一種。很多OOP語言把這種思想的不重要的細節工具化,但直接無腦應用這些工具不會直接得到OOP的設計。即便是OOP思想本身也有其適合的場景和不適合的場景。即便是適合的場景,也可能針對這個場景在OOP之上做更針對這個場景需求的定製的架構/框架。如果簡單把OOP作為某種教條就大大的違反了這個思想的初衷,也只能得到擰巴的代碼。

作者:大寬寬

連結:https://www.zhihu.com/question/305042684/answer/550196442

文章來源: https://twgreatdaily.com/zh-sg/XLnc420BMH2_cNUgcmiL.html