天天看點

程式設計範式-面向對象程式設計

本系列文章會依次介紹三個主要的程式設計範式,它們分别是結構化程式設計(structured programming)、面向對象程式設計(object-oriented programming)以及函數式程式設計(functional programming),本文聚焦在面向對象程式設計。

什麼是面向對象程式設計

我們一般都知道,設計一個優秀的軟體,要基于面向對象設計,那首先要搞明白一個問題,什麼是面向對象?

一種常見的回答是,“資料與函數的組合”,這種說法雖然被廣為引用,但也不是那麼貼切,因為它似乎暗示了o.f()與f(o)之間是有差別的,這顯然不是事實。面向對象理論是在1966年提出的,當時Dahl和Nygaard主要是将函數調用棧遷移到了堆區域中。資料結構被用作函數的調用參數這件事情遠比這發生的時間更早。

一種常用的回答是,”面向對象程式設計是一種對真實世界模組化的方式“,這種回答也隻能是避重就輕,對真實世界模組化到底是如何進行的?我們為什麼要這麼多,有什麼好處?也許會繼續說道,”由于采用面向對象方式建構的軟體與真實世界的關系更為緊密,是以面向對象程式設計可以使軟體開發更為容易“,即使這樣說,也逃避了一個關鍵的問題---面向對象程式設計究竟是什麼?

還有一種回答是,通過”封裝”、"繼承”、"多态”三個特性的有機組合來實作對真實世界的模組化,這種回答看起來回答了面向對象究竟是什麼,那我們來仔細分析下這些特殊是否是面向對象設計特有的?

面向對象特性

特性1: 封裝

通過封裝特性,我們把一組相關聯的資料+函數打包起來,封裝成一個整體,使圈外的代碼隻能看見函數,資料則完全不可見,比如實際應用中的類中的公共函數和私有成員變量就是這樣。

但是這個特性并不是面向對象所特有的,比如C語言也支援完整的封裝,來看看一個C語言的常見例子:

程式設計範式-面向對象程式設計
程式設計範式-面向對象程式設計

在C語言中,使用point.h的程式是沒有point結構體成員的通路權限的,他們隻知道調用makepoint函數和distinct函數。

在C語言.h頭檔案中隻申明結構體和函數,結構體的内部細節,以及函數的實作方式完全是不可見的。這正是我們所說的完美封裝性,雖然c語言大家都認為他是非面向對象的程式設計語言。

再來看看,大家都認為是面向的對象的語言C++語言,他反而破壞了C的完美封裝性。

程式設計範式-面向對象程式設計
程式設計範式-面向對象程式設計

由于一些技術原因,C++編譯器要求類的成員變量必須在類的頭檔案中進行聲明,這樣point.h的使用者就知道了point的成員變量x和y了,雖然編譯器會對上面兩個private變量禁止直接通路,但使用者人任然知道他的存在,如果x和y兩個變量名稱變了,point.cc也必須要重新編譯才能運作,這樣的封裝性是不完美的。

當然C++的語言層面也引入了public、protect、private等關鍵詞部分維護了封裝性,但這些都是為了解決編譯器自身的問題而引入的hack-編譯器必須在頭檔案中看到成員變量的定義

進一步看java和c#,則徹底抛棄了頭檔案與實作的分離的的程式設計方式,這其實是進一步削弱了封裝性,因為在這些語言中我們是無法區分一個類的定義和聲明的。

程式設計範式-面向對象程式設計

由于上述分析,我們很難說強封裝是面向對象程式設計的必要條件,很多面向對象的程式設計語言反而對封裝沒有強制的要求,面向對象程式設計上面确實要求程式員盡量不要破壞資料的封裝性。

結論: 由于面向對象程式設計語言為我們實作資料和函數封裝提供了有效友善的支援,導緻封裝這個特性和概念經常被引用為面向對象程式設計定義的一部分,然而這個特性并不是面向對象程式設計所特有的,反而面向對象程式設計一定程度上破壞了完美封裝性。

特性2: 繼承

簡而言之,繼承的主要作用是讓我們可以在某個作用域内對外部定義的某一組變量與函數進行覆寫。

這其實在C語言時代已經出現被廣泛使用了,具體來看point.h的擴充版本namedPoint.h

程式設計範式-面向對象程式設計
程式設計範式-面向對象程式設計

這裡NamedPoint資料結構是被當作Point資料結構的一個衍生體來使用的。之是以可以這樣做,是因為NamedPoint結構體的前兩個成員的順序與Point結構體的完全一緻。簡單來說,NamedPoint之是以可以被僞裝成Point來使用,是因為NamedPoint是Point結構體的一個超集,同時兩者共同成員的順序也是一樣的。

是以,我們可以說,早在面向對象程式設計語言被發明之前,對繼承性的支援就已經存在很久了。當然了,這種支援用了一些投機取巧的手段,并不像如今的繼承這樣便利易用,而且,多重繼承(multiple inheritance)如果還想用這種方法來實作,就更難了。同時應該注意的是,在main.c中,程式員必須強制将NamedPoint的參數類型轉換為Point,而在真正的面向對象程式設計語言中,這種類型的向上轉換通常應該是隐性的。

綜上所述,我們可以認為,雖然面向對象程式設計在繼承性方面并沒有開創出新,但是的确在資料結構的僞裝性上提供了相當程度的便利性。

特性3: 多态

多态的核心本質是通過分離做什麼和怎麼做來達到靈活程式設計的目的,主要有三個核心條件:

  1. 類之間要有繼承關系
  2. 子類要重寫父類的方法
  3. 父類引用指向子類對象

在面向對象程式設計語言出現之前,我們所使用的程式設計語言支援多态嗎,答案是肯定的,看下面的代碼

程式設計範式-面向對象程式設計

函數getchar()主要從STDIN裡面讀取資料和putchar()主要将資料寫入STDOUT,那STDIN和STDOUT究竟指代的是什麼呢,很顯然這類函數就是多态了,都依賴具體的類型。這裡的STDIN和STDOUT類似于java中的接口,各種裝置都有自己的實作,那沒有接口的C語言中是如何實作這個概念的,getchar這個動作如何投遞到具體的裝置驅動中呢,進而讀取到内容的? 答案是函數指針,具體來看。

Unix系統要求每個IO裝置都要提供open、close、read、write、seek五個标準函數。也就是每個IO裝置驅動程式都對這5種函數實作在函數調用上保持一緻。

程式設計範式-面向對象程式設計

底層操作作業系統提供了一個FILE結構體,包含了相應的5個函數指針,分别指向這些函數。

程式設計範式-面向對象程式設計

然後,控制台裝置的IO驅動程式就會提供這五個函數的實際定義,将FILE結構體的函數指針指向這些對應的實作函數:

程式設計範式-面向對象程式設計

現在,如果STDIN的定義是FILE*,并同時指向了console這個資料結構,那麼getchar的實作就是

程式設計範式-面向對象程式設計

核心就是,getchar()隻是調用了STDIN所指向的FILE資料結構體中的read函數指針指向的函數。

又比如C++中,類中的每個虛函數(virtual function)的位址都被記錄在一個vtable的資料結構裡,我們對虛函數的每次調用都是先查詢這個vtable,其衍生類的構造函數負責将改衍生類的虛函數位址加載到整個對象的vtable中。

如果在一個基類中,有函數被關鍵詞virtual進行修飾, 那麼一個虛函數表就會被自動建構起來去儲存這個類中虛函數的位址。同時編譯器會為這個類的所有對象添加一個隐藏指針vptr指向虛函數表。

如果在派生類中沒有重寫虛函數, 那麼派生類中虛表存儲的是父類虛函數的位址;相反地,如果在派生類中重寫父類的虛函數,派生類的虛表中将覆寫父類在該虛函數的位址。

每當虛函數被調用時, 虛表會決定具體去調用哪個函數。是以,C++中的動态綁定是通過虛函數表機制進行的。當我們用基類指針指向派生類時,虛表指針vptr指向派生類的虛函數表。 這個機制可以保證派生類中的虛函數被調用到。

程式設計範式-面向對象程式設計

這正是面向對象程式設計中多态的基礎,多态其實不過就是函數指針的一種應用。

自從20世紀40年代末期馮諾依曼架構誕生的那天起,程式員們就一直用函數指針模拟多态了,也就是面向對象程式設計在多态程式設計方面沒有提出任何新的概念。

但用函數指針顯示實作多态最大的問題就在于函數指針的危險性。畢竟,函數指針的調用依賴于一系列需要人為遵守的約定。程式員必須嚴格按照固定的約定來初始化函數指針,并同樣嚴格地按照約定來調用這些指針,隻要不遵守這些約定,整個程式就會産生極其難以跟蹤和消除的bug

業界一直有一句話:指針已經很危險了,函數指針那就更不可控了。

面向對象程式設計語言為我們消除了這些風險性,提供了非常多的機制和政策,盡量讓程式員原理底層和約定,讓多态實作變得非常簡單。

總結:雖然面向對象程式設計語言在多态上沒有理論創新,但他讓多态變得更加的安全和便于使用了

多态的優勢

靈活的插件式架構

再來看剛才的copy程式,如果要新增一種IO裝置,目前的Copy程式是不需要做任何更改的,甚至完全不需要重新編譯該源代碼。

程式設計範式-面向對象程式設計

因為copy程式的源代碼并不依賴于IO裝置驅動程式的具體代碼。隻要IO裝置驅動程式實作了FILE結構體中定義的5個标準函數,該copy程式就可以正常使用它們。簡單來說,IO裝置變成了copy程式的插件。

程式設計範式-面向對象程式設計

為什麼UNIX作業系統會将IO裝置設計成插件形式呢?因為自20世紀50年代末期以來,我們學到了一個重要經驗:程式應該與裝置無關。這個經驗從何而來呢?因為一度所有程式都是裝置相關的,但是後來我們發現自己其實真正需要的是在不同的裝置上實作同樣的功能。例如,我們曾經寫過一些程式,需要從卡片盒中的打孔卡片讀取資料,同時要通過在新的卡片上打孔來輸出資料。後來,客戶不再使用打孔卡片,而開始使用錄音帶卷了。這就給我們帶來了很多麻煩,很多程式都需要重寫。于是我們就會想,如果這段程式可以同時操作打孔卡片和錄音帶那該多好。

插件式架構就是為了支援這種IO不相關性而發明的,它幾乎在随後的所有作業系統中都有應用。但即使多态有如此多優點,大部分程式員還是沒有将插件特性引入他們自己的程式中,因為函數指針實在是太危險了。而面向對象程式設計的出現使得這種插件式架構可以在任何地方被安全地使用。

依賴反轉下獨立開發

如下圖所示,main函數調用一些高層函數,高層函數又調用一些中層函數,中層函數又調用一些底層函數,這裡源代碼層面的依賴不可避免的要跟随程式的控制流。

程式設計範式-面向對象程式設計

main函數如果要調用高層函數,不可避免的需要依賴高層函數,需要看到和依賴函數所屬的子產品,在C中,我們會通過#include來實作,在Java中則通過import來實作,而在C#中則用的是using語句。總之,每個函數的調用方都必須要引用被調用方所在的子產品。

顯然,這樣做就導緻了我們在軟體架構上别無選擇。在這裡,系統行為決定了控制流,而控制流則決定了源代碼依賴關系。但一旦我們使用了多态,情況就不一樣了。如果将上面程式進行重構,如下所示,可見

程式設計範式-面向對象程式設計

子產品HL1調用了ML1子產品中的F()函數,這裡的調用是通過源代碼級别的接口來實作的。當然在程式實際運作時,接口這個概念是不存在的,HL1會調用ML1中的F()函數。請注意子產品ML1和接口I在源代碼上的依賴關系(或者叫繼承關系),該關系的方向和控制流正好是相反的,我們稱之為依賴反轉。這種反轉對軟體架構設計的影響是非常大的。

通過利用面向程式設計語言所提供的這種安全便利的多态實作,無論我們面對怎樣的源代碼級别的依賴關系,都可以将其反轉。

通過這種方法,軟體架構師可以完全控制采用了面向對象這種程式設計方式的系統中所有的源代碼依賴關系,而不再受到系統控制流的限制。不管哪個子產品調用或者被調用,軟體架構師都可以随意更改源代碼依賴關系。這就是面向對象程式設計的好處,同時也是面向對象程式設計這種範式的核心本質——至少對一個軟體架構師來說是這樣的。

在面向對象程式設計領域中,依賴反轉原則(Dependency inversion principle,DIP)是指一種特定的解耦(傳統的依賴關系建立在高層次上,而具體的政策設定則應用在低層次的子產品上)形式,使得高層次的子產品不依賴于低層次的子產品的實作細節,依賴關系被颠倒(反轉),進而使得低層次子產品依賴于高層次子產品的需求抽象。

該原則規定:

  • 高層次的子產品不應該依賴于低層次的子產品,兩者都應該依賴于抽象接口。
  • 抽象接口不應該依賴于具體實作。而具體實作則應該依賴于抽象接口。

這種能力有什麼用呢?在下面的例子中,我們可以用它來讓資料庫子產品和使用者界面子產品都依賴于業務邏輯子產品而非相反

程式設計範式-面向對象程式設計

資料庫和使用者界面都依賴于業務邏輯這意味着我們讓使用者界面和資料庫都成為業務邏輯的插件。也就是說,業務邏輯子產品的源代碼不需要引入使用者界面和資料庫這兩個子產品。

這樣一來,業務邏輯、使用者界面以及資料庫就可以被編譯成三個獨立的元件或者部署單元(例如jar檔案、DLL檔案、Gem檔案等)了,這些元件或者部署單元的依賴關系與源代碼的依賴關系是一緻的,業務邏輯元件也不會依賴于使用者界面和資料庫這兩個元件。

于是,業務邏輯元件就可以獨立于使用者界面和資料庫來進行部署了,我們對使用者界面或者資料庫的修改将不會對業務邏輯産生任何影響,這些元件都可以被分别、獨立地部署。簡單來說,當某個元件的源代碼需要修改時,僅僅需要重新部署該元件,不需要更改其他元件,這就是獨立部署能力。

如果系統中的所有元件都可以獨立部署,那它們就可以由不同的團隊并行開發,這就是所謂的獨立開發能力。

架構視角的面向對象

面向對象程式設計到底是什麼?業界在這個問題上存在着很多不同的說法和意見。然而對一個軟體架構師來說,其含義應該是非常明确的:面向對象程式設計就是以多态為手段來對源代碼中的依賴關系進行控制的能力,這種能力讓軟體架構師可以建構出某種插件式架構,讓高層政策性元件與底層實作性元件相分離,底層元件可以被編譯成插件,實作獨立于高層元件的開發和部署。

相比于結構化程式設計對程式控制權的直接轉移GOTO進行了限制和規範。

面向對象程式設計對程式控制權的間接轉移(函數指針)進行了限制和規範。