本文部分摘自 On Java 8
面向對象編程
在提及面向對象時,不得不提到另一個概念:抽象。編程的最終目的是為了解決某個問題,問題的復雜度直接取決於抽象的類型和質量。早期的匯編語言通過對底層機器作輕微抽象,到后來的 C 語言又是對匯編語言的抽象。盡管如此,它們的抽象原理依然要求我們着重考慮計算機的底層結構,而非問題本身
面向對象編程(Object-Oriented Programming OOP)是一種編程思維方式和編碼架構。不同於傳統的面向過程編程,面向對象編程把問題空間(實際要解決的問題)中的元素以及它們在解決方案空間中的表示以一種具有普遍性的方式表達出來,這種表示被稱作對象(Object)。對於一些在問題空間無法對應的對象,我們還可以添加新的對象類型,以配合解決特定的問題。總而言之,OOP 允許我們根據問題來描述問題,而不是根據解決問題的方案。每個對象都有自己的狀態,並且可以進行特定的操作,換言之,它們都有自己的特征和行為
根據面向對象編程的概念,可以總結出五大基本特征:
- 萬物皆對象
- 程序是一組對象,可以互相傳遞消息以告知彼此應該做什么
- 每個對象都有自己的存儲空間,以容納其他對象
- 每個對象都有一種類型
- 同一個類的所有對象能接收相同的消息
對對象更簡潔的描述是:一個對象具有自己的狀態、行為和標識,這意味着對象有自己的內部數據(狀態)、方法(行為),並彼此區分(每個對象在內存中都有唯一的地址)
接口
所有對象都是唯一的,但同時也是具有相同的特征和行為的對象所歸屬的類的一部分。這種思想被應用於面向對象編程,並在程序中使用基本關鍵字 class 來表示類型,每個類都有自己的通用特征和行為。創建好一個類后,可生成許多對象,這些對象作為要解決問題中存在的元素進行處理。事實上,當我們在進行面向對象程序設計時,面臨的最大的一項挑戰就是:如何在問題空間與方案空間的元素之間建立理想的一對一映射關系?
如果無法建立有效的映射,對象也就無法做真正有用的工作,必須有一種方法能向對象發出請求,令其解決一些實際的問題,比如完成一次交易、打開一個開關等。每個對象僅能接收特定的請求,我們向對象發出的請求是通過它的接口定義的
比如有一個電燈類 Light,我們可以向 Light 對象發出的請求包括打開 on、關閉 off,因此在 Light 類我們需要定義兩個方法 on() 和 off(),然后創建一個 Light 類型的對象,並調用其接口方法
也行你已經發現了,對象通過接受請求並反饋結果,因此我們可以將對象看成是某項服務的提供者,也就是說你的程序將為用戶提供服務,並且它能還能通過調用其他對象提供的服務來實現這一點。我們的最終目標是開發或調用工具庫中已有的一些對象,提供理想的服務來解決問題
更進一步,我們需要將問題進行分解,將其抽象成一組服務,並有組織地划分出每一個功能單一、作用明確且緊密的模塊,避免將太多功能塞進一個對象里。這樣的程序設計可以提高代碼的復用性,同時也方便別人閱讀和理解我們的代碼
封裝
我們把編程的側重領域划分為研發和應用兩塊。應用程序員調用研發程序員構建的基礎工具類做快速開發,研發程序員開發一個工具類,該工具類僅向應用程序員公開必要的內容,並隱藏內部實現的細節,這樣可以有效避免工具類被錯誤使用和更改。顯然,我們需要某些方法來保證工具類的正確使用,只有設定訪問控制,才能從根本上解決這個問題
因此,使用訪問控制的原因有以下兩點:
- 讓應用程序員不要觸碰他們不應該觸碰的部分
- 類庫的創建者可以在不影響他人使用的情況下完善和更新工具庫
Java 提供了三個顯式關鍵字來設置類的訪問權限,分別是 public(公開)、private(私有)和 protected(受保護),這些訪問修飾符決定了誰能使用它們修飾的方法、變量或類
-
public
表示任何人都可以訪問和使用該元素
-
private
除了類本身和類內部的方法,外界無法直接訪問該元素
-
protected
被 protected 修飾的成員對於本包和其子類可見。這句話有點太籠統了,更具體的概括應該是:
- 基類的 protected 是包內可見的
- 若子類與基類不在同一包中,那么子類實例可以訪問從基類繼承而來的 protected 方法,而不能訪問基類實例的 protected 方法
-
default
如果不使用前面三者,默認就是 default 權限,該權限下的資源可以被同一包中其他類的成員訪問
復用
代碼和設計方案的復用性是面向對象的優點之一,我們可以通過重復使用某個類的對象來實現復用性,例如,將一個類的對象的作為另一個類的成員變量使用。因此,新構成的類可以是由任意數量和任意類型的其他對象構成,這里涉及到了組合和聚合兩個概念:
- 組合(Composition)經常用來表示擁有關系(has-a relationship)例如,汽車擁有引擎。組合關系中,整件擁有部件的生命周期,所以整件刪除時,部件一定會跟着刪除
- 聚合(Aggregation)表示動態的組合。聚合關系中,整件不會擁有部件的生命周期,所以整件刪除時,部件不會刪除
使用組合可以為我們的程序帶來極大的靈活性。通常新建的類中,成員對象會使用 private 訪問權限,這樣應用程序員無法對其直接訪問,我們就可以在不影響客戶代碼的前提下,從容地修改那些成員。我們也可以在運行時改變成員對象,從而動態地改變程序的行為。下面提到的繼承並不具備這種靈活性,因為編譯器對通過繼承創建的類進行了限制
繼承
對象的概念為編程帶來便利,它允許我們將各式各樣的數據和功能封裝到一起,這樣可以恰當表達問題空間的概念,而不用受制於必須使用底層機器語言
通過 class 關鍵字,可以形成編程語言中的基本單元。遺憾的是,這樣做還是有很多問題:在創建一個類之后,即使另一個新類與其具有相似的功能,你還是不得不重新創建一個新類。如果我們能利用現有的數據類型,對其進行克隆,再根據情況進行添加和修改,那就方便許多了。繼承正是為此而設計,但繼承並不等價於克隆。在繼承過程中,如原始類(基類、父類)發生了變化,修改過的克隆類(子類、派生類)也會反映出這種變化
基類一般會有多個派生類,並包含派生自它的類型之間共享的所有特征和行為。后者可能比前者包含更多的特征,並可以處理更多消息(或者以不同的方式處理它們)
使用繼承,你將構建一個類型層次結構,來表示你試圖解決的某種類型的問題。常見的例子是形狀,每個形狀都有大小、顏色、位置等等,每個形狀可以繪制、擦除、移動等,還可以派生出具體類型的形狀,如圓形、正方形、三角形等等。派生出的每個形狀都可以具有附加的特征和行為,例如,某些形狀可以翻轉,計算形狀面積的公式互不相同等等
類型層次結構體現了形狀之間的相似性和差異性,你不需要在問題描述和解決方案描述之間建立許多中間模型。從現有類型繼承並創建新類型,新類型不僅包含現有類型的所有成員(盡管私有成員被隱藏起來並不可訪問),更重要的是它復制了基類的接口。也就是說,基類對象能接收的消息派生類對象也能接收。如果基類不能滿足你的需求,你可以在派生類添加更多的方法,甚至改變現有基類方法的行為(覆蓋),只需在派生類重新定義這個方法即可
多態
在處理類的層次結構時,通常把一個對象看成是它所屬的基類,而不是把它當成具體類,通過這種方式,我們可以編寫出不局限於特定類型的代碼。例如上述形狀的例子,方法操縱的是通用的形狀,而不關心具體是圓還是三角形什么的。所有形狀都可以被繪制、擦除和移動。因此方法向其中任何代表形狀的對象發送消息都不必擔心對象如何處理信息
這種能力改善了我們的設計,減少了軟件的維護代價。如果我們把派生對象類型統一看成是它本身的基類,編譯器在編譯時就無法准確地獲知具體是哪個形狀被繪制,那一種車正在行駛,這正是關鍵所在:當程序接受這種消息時,程序員並不關心哪段代碼會被執行,繪圖方法可以平等地應用到每種可能的形狀上,形狀會依據自身的具體類型執行恰當的代碼
因此,我們就能添加一個新的不同執行方式的子類而不需要更改調用它的方法,更利於程序擴展。那么編譯器如何確定該執行哪部分的代碼呢?一般來說,編譯器不能進行函數調用,對於非 OOP 編譯器產生的函數調用會引起所謂的早期綁定,這意味着編譯器生成對特定函數名的調用,該調用會被解析為將執行的代碼的絕對地址。而面向對象語言使用了一種后期綁定的概念,當向對象發送信息時,被調用的代碼直到運行時才確定,編譯器要做的只是確保方法存在,並對參數和返回值執行類型檢查,但並不知道要執行的確切代碼
為了執行后期綁定,Java 使用一個特殊的代碼位來代替絕對調用,這段代碼使用對象中存儲的信息來計算方法主體的地址。因此,每個對象的行為根據特定代碼位的內容而不同。當你向對象發送消息時,對象知道該如何處理這條消息。在某些語言如 C++ 必須顯式地授予方法后期綁定屬性的靈活性,而在 Java 中,動態綁定是默認行為,不需要額外的關鍵字來實現多態性
發送消息給對象時,如果程序不知道接收的具體類型是什么,但最終執行是正確的,這就是對象的多態性。面向對象的程序設計語言是通過動態綁定的方式來實現對象的多態性的,編譯器和運行時系統會負責控制所有的細節。我們只需要知道要做什么,以及如何利用多態性更好地設計程序
對象創建與生命周期
在使用對象時要注意的一個關鍵問題就是對象的創建和銷毀方式。每個對象的生存都需要資源,尤其是內存。當對象不再被使用時,我們應該及時釋放資源,清理內存
然而,實際的情形往往要復雜許多。我們怎么知道何時清理這些對象呢?也許一個對象在某一系統中處理完成,但在其他系統可能還沒處理完成。另外,對象的數據在哪?如何控制它的生命周期?在 C++ 設計中采用的觀點是效率第一,它將這些問題的選擇權交給了程序員。程序員在編寫程序時,通過將對象放在棧或靜態存儲區域中來確定內存占用和生存空間。相對的,我們也犧牲了程序的靈活性
Java 使用動態內存分配,在堆內存中動態地創建對象。在這種方式下,直到程序運行我們才能知道創建的對象數量、生存類型和時間。在堆內存上開辟空間所需的時間可能比棧內存要長(但並不一定),但動態分配論認為:對象通常是復雜的,相比於對象創建的整體開銷,尋找和釋放內存空間的開銷微不足道。對於對象的生命周期問題,在 C++ 中你必須以編程方式確定何時銷毀對象,而 Java 的垃圾收集機制能自動發現不再被使用的對象並釋放相應的內存空間,使得 Java 的編碼過程比用 C++ 要簡單許多