面向對象程序設計基于三個基本概念:數據抽象、繼承和動態綁定。
繼承和動態綁定對編寫程序有兩方面的影響:一是我們可以更容易地定義與其他類相似但不完全相同的新類;二是在使用這些彼此相似的類編寫程序時,我們可以在一定程度上忽略掉它們的區別。
一、OOP:概述
面向對象程序設計(object-oriented programming)的核心思想是數據抽象、繼承和動態綁定。通過使用數據抽象,我們可以將類的接口與實現分離;使用繼承,可以定義相似的類型并對其相似關系建模;使用動態綁定,可以在一定程度上忽略相似類型的區別,而以統一的方式使用它們的對象。
繼承
基類負責定義在層次關系中所有類共同擁有的成員,而每個派生類定義各自特有的成員。
在C++語言中,基類將類型相關的函數與派生類不做改變直接繼承的函數區分對待。對于某些函數,基類希望它的派生類各自定義適合自身的版本,此時基類就將這些函數聲明成虛函數(virtual function)。
舉例:定義一個名為Quote的類,并將它作為層次關系的基類。Quote的對象表示按原價銷售的書籍。Quote派生出另一個名為bulk_quote的類,它表示可以打折銷售的書籍。
派生類必須通過使用類派生列表(class derivation list)明確指出它是從哪個(哪些)基類繼承而來的。類派生列表的形式是:首先是一個冒號,后面緊跟以逗號分隔的基類列表,其中每個基類前面可以有訪問說明符:
在派生類內部(成員函數或友元函數)使用基類成員時:不受繼承方式的影響,只看該成員在基類中的訪問屬性。
在派生類外部(派生類用戶)使用基類成員時:不同的繼承方式決定了基類成員在派生類中的訪問屬性,從而對派生類用戶的訪問權限產生影響。
public繼承:所有基類成員在派生類中保持原有的訪問級別。
之后我們將繼續學習protected繼承和private繼承。
派生類必須在其內部對所有重新定義的虛函數進行聲明。派生類可以在這樣的函數之前加上virtual關鍵字,但也并不是非得這么做。C++11允許派生類顯式地注明它將使用哪個成員函數改寫基類的虛函數,具體措施是在該函數的形參列表之后增加一個override關鍵字。
動態綁定
因為在上述過程中函數的運行版本由實參決定,即在運行時選擇函數的版本,所以動態綁定有時又被稱為運行時綁定。
二、定義基類和派生類
1. 定義基類
成員函數與繼承
任何構造函數之外的非靜態函數都可以是虛函數。關鍵字virtual只能出現在類內部的聲明語句之前而不能用于類外部的函數定義。如果基類把一個函數聲明成虛函數,則該函數在派生類中隱式地也是虛函數。
成員函數如果沒被聲明為虛函數,則其解析過程發生在編譯時而非運行時。
訪問控制與繼承
派生類可以繼承定義在基類中的成員,但是派生類的成員函數不一定有權訪問從基類繼承而來的成員。和其他使用基類的代碼一樣,派生類能訪問公有成員,而不能訪問私有成員。不過有些時候基類中還有這樣一種成員,基類希望它的派生類有權訪問該成員,同時禁止其他用戶訪問。我們用受保護的(protected)訪問運算符說明這樣的成員。
2. 定義派生類
派生類必須通過類派生列表明確指出它是從哪個(哪些)基類繼承而來的。
類派生列表的形式是:首先是一個冒號,后面緊跟以逗號分隔的基類列表,其中每個基類前面可以有以下三個訪問說明符中的一個:public、protected或者private。
派生類必須將其繼承而來的成員函數中需要覆蓋的那些重新聲明。
訪問說明符的作用是控制派生類從基類繼承而來的成員是否對派生類的用戶可見。
派生類中的虛函數
如果派生類沒有覆蓋其基類中的某個虛函數,則該虛函數的行為類似于其他的普通成員,派生類回直接繼承其在基類中的版本。
派生類對象及派生類向基類的類型轉換
C++標準并沒有明確規定派生類的對象在內存中如何分布。
因為在派生類對象中含有與其基類對應的組成部分,所以我們能把派生類的對象當成基類對象來使用,而且我們也能將基類的指針或引用綁定到派生類對象中的基類部分上。
這種轉換通常稱為派生類到基類的類型轉換。和其他類型轉換一樣,編譯器會隱式地執行派生類到基類的轉換。這種隱式特性意味著我們可以把派生類對象或者派生類對象的引用用在需要基類引用的地方;同樣的,我們也可以把派生類對象的指針用在需要基類指針的地方。
派生類構造函數
盡管在派生類對象中含有從基類繼承而來的成員,但是派生類并不能直接初始化這些成員。和其他創建了基類對象的代碼一樣,派生類也必須使用基類的構造函數來初始化它的基類部分。
派生類對象通過構造函數初始化列表來將實參傳遞給基類構造函數。
除非我們特別指出,否則派生類對象的基類部分會像數據成員一樣執行默認初始化。
派生類使用基類的成員
派生類可以訪問基類的公有成員和受保護成員。
繼承與靜態成員
如果基類定義了一個靜態成員,則在整個繼承體系只存在該成員的唯一定義。
派生類的聲明
派生類的聲明中包含類名但是不包含它的派生列表:
一條聲明語句的目的是令程序知曉某個名字的存在以及改名字表示一個什么樣的實體,如一個類、一個函數或一個變量等。
被用作基類的類
如果我們想將某個類用作基類,則該類必須已經定義而非僅僅聲明:
一個類是基類,同時它也可以是一個派生類·:
在這個繼承關系中,Base是D1的直接基類(direct base),同時是D2的間接基類(indirect base)。
每個類都會繼承直接基類的所有成員。
防止繼承的發生
C++提供了一種防止繼承發生的方法,即在類名后跟一個關鍵字final。
3. 類型轉換與繼承
通常情況下,如果我們想把引用或指針綁定到一個對象上,則引用或指針的類型應與對象的類型一致,或者對象的類型含有一個可接受的const類型轉換規則。存在繼承關系的類是一個重要的例外:我們可以將基類的指針或引用綁定到派生類對象上。例如,我們可以用Quote&指向一個Bulk_quote對象,也可以把一個Bulk_quote對象的地址賦給一個Quote*。
(可以將派生類當作基類來使用)
可以將基類的指針或引用綁定到派生類對象上有一層極為重要的含義:當使用基類的引用(或指針)時,實際上我們并不清楚該引用(或指針)所綁定對象的真實類型。該對象可能是基類的對象,也可能是派生類的對象。
靜態類型與動態類型
表達式的靜態類型(static type)在編譯時總是已知的,它是變量聲明時的類型或表達式生成的類型;動態類型(dynamic type)則是變量或表達式表示的內存中的對象的類型。動態類型直到運行時才可知。
基類的指針或引用的靜態類型可能與其動態類型不一致。
不存在從基類到派生類的隱式類型轉換
在對象之間不存在類型轉換
派生類向基類的自動類型轉換只對指針或引用類型有效,在派生類類型和基類類型之間不存在這樣的轉換。
當我們初始化或賦值一個類類型的對象時,實際上是在調用某個函數。當執行初始化時,我們調用構造函數;而當執行賦值操作時,我們調用賦值運算符。這些成員都包含一個參數,該參數的類型是類類型的const版本的引用。
三、虛函數
當我們使用基類的引用或指針調用一個虛成員函數時會執行動態綁定。因為我們直到運行時才能知道到底調用了哪個版本的虛函數,所以所有虛函數都必須有定義。通常情況下,如果我們不使用某個函數,則無須為該函數提供定義。但是我們必須為每一個虛函數都提供定義,而不管它是否被用到了,這是因為連編譯器也無法確定到底會使用哪個虛函數。
對虛函數的調用可能在運行時才被解析
當某個虛函數通過指針或引用調用時,編譯器產生的代碼直到運行時才能確定應該調用哪個版本的函數。被調用的函數是與綁定到指針或引用上的對象的動態類型相匹配的那一個。
派生類中的虛函數
派生類中虛函數的返回類型也必須與基類函數匹配。該規則有一個例外,當類的虛函數返回類型是類本身的指針或引用時,上述規則無效。也就是說,如果D由B派生得到,則基類的虛函數可以返回B*而派生類的對應函數可以返回D*,只不過這樣的返回類型要求從D到B的類型轉換是可訪問的。
final和override說明符
虛函數與默認實參
回避虛函數的機制
在某些情況下,我們希望對虛函數的調用不要進行動態綁定,而是強迫其執行虛函數的某個特定版本。通過作用域運算符可以實現這一目的。
四、抽象基類
純虛函數
純虛函數無須定義。我們通過在函數體的位置(即在聲明語句的分號之前)書寫 =0 就可以將一個虛函數說明為純虛函數。其中,=0只能出現在類內部的虛函數聲明語句處。
我們也可以為純虛函數提供定義,不過函數體必須定義在類的外部。我們不能在類的內部為一個=0的函數提供函數體。
含有純虛函數的類是抽象基類
含有(或者未經覆蓋直接繼承)純虛函數的類是抽象基類。我們不能(直接)創建一個抽象基類的對象。
派生類構造函數只初始化它的直接基類
五、訪問控制與繼承
每個類分別控制自己的成員初始化過程。與之類似,每個類還分別控制著其成員對于派生類來說是否可訪問(accessible)。
受保護的成員
一個類使用protected關鍵字來聲明那些它希望與派生類分享但不想被其他公共訪問使用的成員。
· 和私有成員類似,受保護的成員對于類的用戶來說是不可訪問的。
· 和公有成員相似,受保護的成員對于派生類的成員和友元來說是可訪問的。
· 派生類的成員或友元只能通過派生類對象來訪問基類的受保護成員。派生類對于一個基類對象中的受保護成員沒有任何訪問特權。
公有、私有和受保護繼承
某個類對其繼承而來的成員的訪問權限受到兩個因素影響:一是在基類中該成員的訪問說明符,二是在派生類的派生列表中的訪問說明符。
對基類成員的訪問權限只與基類中的訪問說明符有關。
派生訪問說明符的目的是控制派生類用戶(包括派生類的派生類在內)對于基類成員的訪問權限:
派生訪問說明符還可以控制繼承自派生類的新類的訪問權限:
派生類向基類轉換的可訪問性
友元與繼承
就像友元關系不能傳遞一樣,友元關系同樣也不能繼承。
改變個別成員的可訪問性
有時我們需要改變派生類繼承的某個名字的訪問級別,通過使用using聲明可以達到這一目的。
默認的繼承保護級別
默認情況下,使用class關鍵字定義的派生類是私有繼承的;而使用struct關鍵字定義的派生類是公有繼承的,
在使用struct關鍵字和class關鍵字定義的類之間唯一的差別就是默認成員訪問說明符及默認派生訪問說明符;除此之外,再無其他不同之處。
六、繼承中的類作用域
當存在繼承關系時,派生類的作用域嵌套在其基類的作用域之內。如果一個名字在派生類的作用域內無法正確解析,則編譯器將繼續在外層的基類作用域中尋找該名字的定義。
在編譯時進行名字查找
一個對象、引用或指針的靜態類型決定了該對象的哪些成員是可見的。即使靜態類型與動態類型可能不一致(當使用基類的引用或指針時會發生這種情況),但是我們能使用哪些成員仍然是由靜態類型決定的。
舉個例子,我們給Disc_quote添加一個新成員,該成員返回一個存有最小(或最大)數量及折扣價格的pair:
名字沖突與繼承
和其他作用域一樣,派生類也能重用定義在其直接基類或間接基類中的名字,此時定義在內層作用域(即派生類)的名字將隱藏定義在外層作用域(即基類)的名字。
通過作用域運算符來使用隱藏的成員
名字查找先于類型查找
如果派生類(即內層作用域)的成員與基類(即外層作用域)的某個成員同名,則派生類將在其作用域內隱藏該基類成員。即使派生類成員和基類成員的形參列表不一致,基類成員也仍然會被隱藏掉:
虛函數與作用域
覆蓋重載的函數
七、構造函數與拷貝控制
如果一個類(基類或派生類)沒有定義拷貝控制操作,則編譯器將為它合成一個版本。這個合成的版本可以定義成刪除的函數。
1. 虛析構函數
繼承關系對基類拷貝控制最直接的影響是基類通常應該定義一個虛析構函數,這樣我們就嫩滑動態分配繼承體系中的對象了。
虛析構函數將阻止合成移動操作
如果一個類定義了析構函數,即使它通過=default的形式使用了合成的版本,編譯器也不會為這個類合成移動操作。
2. 合成拷貝控制與繼承
派生類中刪除的拷貝控制與基類的關系
移動操作與繼承
大多數基類都會定義一個虛析構函數。因此在默認情況下,基類通常不含有合成的移動操作,而且在它的派生類中也沒有合成的移動操作。
因為基類缺少移動操作會阻止派生類擁有自己的合成移動操作,所以當我們確實需要執行移動操作時應該首先在基類中進行定義。
3. 派生類的拷貝控制成員
定義派生類的拷貝或移動構造函數
當為派生類定義拷貝或移動構造函數時,我們通常使用對應的基類構造函數初始化對象的基類部分:
派生類賦值運算符
與拷貝和移動構造函數一樣,派生類的賦值運算符也必須顯式地為其基類部分賦值。
無論基類的構造函數或賦值運算符是自定義的版本還是合成的版本,派生類的對應操作都能使用它們。
派生類析構函數
在析構函數體執行完成后,對象的成員會被隱式銷毀。類似的,對象的基類部分也是隱式銷毀的。和構造函數及賦值運算符不同的是,派生類析構函數只負責銷毀由派生類自己分配的資源:
在構造函數和析構函數中調用虛函數
4. 繼承的構造函數
類不能繼承默認、拷貝和移動構造函數。如果派生類沒有直接定義這些構造函數,則編譯器將為派生類合成它們。
派生類繼承基類構造函數的方式是提供一條注明了(直接)基類名的using聲明語句。
繼承的構造函數的特點
和普通成員的using聲明不一樣,一個構造函數的using聲明不會改變該構造函數的訪問級別。例如,不管using聲明出現在哪兒,基類的私有構造函數在派生類中還是一個私有構造函數;受保護的構造函數和公有構造函數也是同樣的規則。
而且,一個using聲明不能指定explicit或constexpr。如果基類的構造函數是explicit或者constexpr,則繼承的構造函數也擁有相同的屬性。
八、容器與繼承
當我們使用容器存放繼承體系中的對象時,通常必須采取間接存儲的方式。
在容器中放置(智能)指針而非對象
當我們希望在容器中存放具有繼承關系的對象時,我們實際上存放的通常是基類的指針(更好的選擇是智能指針)。這些指針所指的動態類型可能是基類類型,也可能是派生類類型。