Java 物件導向概述

低吟不作語發表於2020-12-24

本文部分摘自 On Java 8


物件導向程式設計

在提及物件導向時,不得不提到另一個概念:抽象。程式設計的最終目的是為了解決某個問題,問題的複雜度直接取決於抽象的型別和質量。早期的組合語言通過對底層機器作輕微抽象,到後來的 C 語言又是對組合語言的抽象。儘管如此,它們的抽象原理依然要求我們著重考慮計算機的底層結構,而非問題本身

物件導向程式設計(Object-Oriented Programming OOP)是一種程式設計思維方式和編碼架構。不同於傳統的程式導向程式設計,物件導向程式設計把問題空間(實際要解決的問題)中的元素以及它們在解決方案空間中的表示以一種具有普遍性的方式表達出來,這種表示被稱作物件(Object)。對於一些在問題空間無法對應的物件,我們還可以新增新的物件型別,以配合解決特定的問題。總而言之,OOP 允許我們根據問題來描述問題,而不是根據解決問題的方案。每個物件都有自己的狀態,並且可以進行特定的操作,換言之,它們都有自己的特徵和行為

根據物件導向程式設計的概念,可以總結出五大基本特徵:

  • 萬物皆物件
  • 程式是一組物件,可以互相傳遞訊息以告知彼此應該做什麼
  • 每個物件都有自己的儲存空間,以容納其他物件
  • 每個物件都有一種型別
  • 同一個類的所有物件能接收相同的訊息

對物件更簡潔的描述是:一個物件具有自己的狀態、行為和標識,這意味著物件有自己的內部資料(狀態)、方法(行為),並彼此區分(每個物件在記憶體中都有唯一的地址)


介面

所有物件都是唯一的,但同時也是具有相同的特徵和行為的物件所歸屬的類的一部分。這種思想被應用於物件導向程式設計,並在程式中使用基本關鍵字 class 來表示型別,每個類都有自己的通用特徵和行為。建立好一個類後,可生成許多物件,這些物件作為要解決問題中存在的元素進行處理。事實上,當我們在進行物件導向程式設計時,面臨的最大的一項挑戰就是:如何在問題空間與方案空間的元素之間建立理想的一對一對映關係?

如果無法建立有效的對映,物件也就無法做真正有用的工作,必須有一種方法能向物件發出請求,令其解決一些實際的問題,比如完成一次交易、開啟一個開關等。每個物件僅能接收特定的請求,我們向物件發出的請求是通過它的介面定義的

比如有一個電燈類 Light,我們可以向 Light 物件發出的請求包括開啟 on、關閉 off,因此在 Light 類我們需要定義兩個方法 on() 和 off(),然後建立一個 Light 型別的物件,並呼叫其介面方法

也行你已經發現了,物件通過接受請求並反饋結果,因此我們可以將物件看成是某項服務的提供者,也就是說你的程式將為使用者提供服務,並且它能還能通過呼叫其他物件提供的服務來實現這一點。我們的最終目標是開發或呼叫工具庫中已有的一些物件,提供理想的服務來解決問題

更進一步,我們需要將問題進行分解,將其抽象成一組服務,並有組織地劃分出每一個功能單一、作用明確且緊密的模組,避免將太多功能塞進一個物件裡。這樣的程式設計可以提高程式碼的複用性,同時也方便別人閱讀和理解我們的程式碼


封裝

我們把程式設計的側重領域劃分為研發和應用兩塊。應用程式設計師呼叫研發程式設計師構建的基礎工具類做快速開發,研發程式設計師開發一個工具類,該工具類僅嚮應用程式設計師公開必要的內容,並隱藏內部實現的細節,這樣可以有效避免工具類被錯誤使用和更改。顯然,我們需要某些方法來保證工具類的正確使用,只有設定訪問控制,才能從根本上解決這個問題

因此,使用訪問控制的原因有以下兩點:

  1. 讓應用程式設計師不要觸碰他們不應該觸碰的部分
  2. 類庫的建立者可以在不影響他人使用的情況下完善和更新工具庫

Java 提供了三個顯式關鍵字來設定類的訪問許可權,分別是 public(公開)、private(私有)和 protected(受保護),這些訪問修飾符決定了誰能使用它們修飾的方法、變數或類

  1. public

    表示任何人都可以訪問和使用該元素

  2. private

    除了類本身和類內部的方法,外界無法直接訪問該元素

  3. protected

    被 protected 修飾的成員對於本包和其子類可見。這句話有點太籠統了,更具體的概括應該是:

    • 基類的 protected 是包內可見的
    • 若子類與基類不在同一包中,那麼子類例項可以訪問從基類繼承而來的 protected 方法,而不能訪問基類例項的 protected 方法
  4. default

    如果不使用前面三者,預設就是 default 許可權,該許可權下的資源可以被同一包中其他類的成員訪問


複用

程式碼和設計方案的複用性是物件導向的優點之一,我們可以通過重複使用某個類的物件來實現複用性,例如,將一個類的物件的作為另一個類的成員變數使用。因此,新構成的類可以是由任意數量和任意型別的其他物件構成,這裡涉及到了組合和聚合兩個概念:

  • 組合(Composition)經常用來表示擁有關係(has-a relationship)例如,汽車擁有引擎。組合關係中,整件擁有部件的生命週期,所以整件刪除時,部件一定會跟著刪除
  • 聚合(Aggregation)表示動態的組合。聚合關係中,整件不會擁有部件的生命週期,所以整件刪除時,部件不會刪除

使用組合可以為我們的程式帶來極大的靈活性。通常新建的類中,成員物件會使用 private 訪問許可權,這樣應用程式設計師無法對其直接訪問,我們就可以在不影響客戶程式碼的前提下,從容地修改那些成員。我們也可以在執行時改變成員物件,從而動態地改變程式的行為。下面提到的繼承並不具備這種靈活性,因為編譯器對通過繼承建立的類進行了限制


繼承

物件的概念為程式設計帶來便利,它允許我們將各式各樣的資料和功能封裝到一起,這樣可以恰當表達問題空間的概念,而不用受制於必須使用底層機器語言

通過 class 關鍵字,可以形成程式語言中的基本單元。遺憾的是,這樣做還是有很多問題:在建立一個類之後,即使另一個新類與其具有相似的功能,你還是不得不重新建立一個新類。如果我們能利用現有的資料型別,對其進行克隆,再根據情況進行新增和修改,那就方便許多了。繼承正是為此而設計,但繼承並不等價於克隆。在繼承過程中,如原始類(基類、父類)發生了變化,修改過的克隆類(子類、派生類)也會反映出這種變化

基類一般會有多個派生類,幷包含派生自它的型別之間共享的所有特徵和行為。後者可能比前者包含更多的特徵,並可以處理更多訊息(或者以不同的方式處理它們)

使用繼承,你將構建一個型別層次結構,來表示你試圖解決的某種型別的問題。常見的例子是形狀,每個形狀都有大小、顏色、位置等等,每個形狀可以繪製、擦除、移動等,還可以派生出具體型別的形狀,如圓形、正方形、三角形等等。派生出的每個形狀都可以具有附加的特徵和行為,例如,某些形狀可以翻轉,計算形狀面積的公式互不相同等等

型別層次結構體現了形狀之間的相似性和差異性,你不需要在問題描述和解決方案描述之間建立許多中間模型。從現有型別繼承並建立新型別,新型別不僅包含現有型別的所有成員(儘管私有成員被隱藏起來並不可訪問),更重要的是它複製了基類的介面。也就是說,基類物件能接收的訊息派生類物件也能接收。如果基類不能滿足你的需求,你可以在派生類新增更多的方法,甚至改變現有基類方法的行為(覆蓋),只需在派生類重新定義這個方法即可


多型

在處理類的層次結構時,通常把一個物件看成是它所屬的基類,而不是把它當成具體類,通過這種方式,我們可以編寫出不侷限於特定型別的程式碼。例如上述形狀的例子,方法操縱的是通用的形狀,而不關心具體是圓還是三角形什麼的。所有形狀都可以被繪製、擦除和移動。因此方法向其中任何代表形狀的物件傳送訊息都不必擔心物件如何處理資訊

這種能力改善了我們的設計,減少了軟體的維護代價。如果我們把派生物件型別統一看成是它本身的基類,編譯器在編譯時就無法準確地獲知具體是哪個形狀被繪製,那一種車正在行駛,這正是關鍵所在:當程式接受這種訊息時,程式設計師並不關心哪段程式碼會被執行,繪圖方法可以平等地應用到每種可能的形狀上,形狀會依據自身的具體型別執行恰當的程式碼

因此,我們就能新增一個新的不同執行方式的子類而不需要更改呼叫它的方法,更利於程式擴充套件。那麼編譯器如何確定該執行哪部分的程式碼呢?一般來說,編譯器不能進行函式呼叫,對於非 OOP 編譯器產生的函式呼叫會引起所謂的早期繫結,這意味著編譯器生成對特定函式名的呼叫,該呼叫會被解析為將執行的程式碼的絕對地址。而面嚮物件語言使用了一種後期繫結的概念,當向物件傳送資訊時,被呼叫的程式碼直到執行時才確定,編譯器要做的只是確保方法存在,並對引數和返回值執行型別檢查,但並不知道要執行的確切程式碼

為了執行後期繫結,Java 使用一個特殊的程式碼位來代替絕對呼叫,這段程式碼使用物件中儲存的資訊來計算方法主體的地址。因此,每個物件的行為根據特定程式碼位的內容而不同。當你向物件傳送訊息時,物件知道該如何處理這條訊息。在某些語言如 C++ 必須顯式地授予方法後期繫結屬性的靈活性,而在 Java 中,動態繫結是預設行為,不需要額外的關鍵字來實現多型性

傳送訊息給物件時,如果程式不知道接收的具體型別是什麼,但最終執行是正確的,這就是物件的多型性。物件導向的程式設計語言是通過動態繫結的方式來實現物件的多型性的,編譯器和執行時系統會負責控制所有的細節。我們只需要知道要做什麼,以及如何利用多型性更好地設計程式


物件建立與生命週期

在使用物件時要注意的一個關鍵問題就是物件的建立和銷燬方式。每個物件的生存都需要資源,尤其是記憶體。當物件不再被使用時,我們應該及時釋放資源,清理記憶體

然而,實際的情形往往要複雜許多。我們怎麼知道何時清理這些物件呢?也許一個物件在某一系統中處理完成,但在其他系統可能還沒處理完成。另外,物件的資料在哪?如何控制它的生命週期?在 C++ 設計中採用的觀點是效率第一,它將這些問題的選擇權交給了程式設計師。程式設計師在編寫程式時,通過將物件放在棧或靜態儲存區域中來確定記憶體佔用和生存空間。相對的,我們也犧牲了程式的靈活性

Java 使用動態記憶體分配,在堆記憶體中動態地建立物件。在這種方式下,直到程式執行我們才能知道建立的物件數量、生存型別和時間。在堆記憶體上開闢空間所需的時間可能比棧記憶體要長(但並不一定),但動態分配論認為:物件通常是複雜的,相比於物件建立的整體開銷,尋找和釋放記憶體空間的開銷微不足道。對於物件的生命週期問題,在 C++ 中你必須以程式設計方式確定何時銷燬物件,而 Java 的垃圾收集機制能自動發現不再被使用的物件並釋放相應的記憶體空間,使得 Java 的編碼過程比用 C++ 要簡單許多


相關文章