C++ 學習筆記之(15)-面向對象程序設計
OOP
:概述
面向對象程序設計基於三個基本概念:數據抽象、繼承和動態綁定。
- 數據抽象:將類的接口與實現分離,詳情可在C++ 學習筆記之(7)-類查閱
- 繼承:可以定義相似的類型並對其相似關係建模, 繼承構建一種層次關係,層次根部爲基類,其他類則直接或間接地從基類繼承而來,稱爲派生類
- 動態綁定:在一定程度上忽略相似類型的區別,而以統一的方式使用他們的對象。即函數的運行版本由實參決定,即在運行時選擇函數的版本,又被成爲 運行時綁定(run-time binding)
- 虛函數(virtual function):某些基類希望派生類重新定義的函數,會被基類聲明成虛函數
定義基類和派生類
定義基類
class Quote{
public:
Quote() = default;
Quote(const std::string &book, double sales_price): bookNo(book), price(sales_price){}
std::string isbn() const { return bookNo; }
// 返回給定數量的銷售總額,派生類負責改寫並使用不同的折扣計算算法
virtual double net_price(std::size_t n) const { return n * price; }
virtual ~Quote() = default; // 虛析構函數,進行動態綁定
private:
std::string bookNo; // 書籍的ISBN 編號
protected:
double price = 0.0; // 代表普通狀態下不打折的價格
};
- 基類通常都應該定義一個虛析構函數,即使該函數不執行任何實際操作也是如此
- 基類通過聲明成員函數爲
virtual
使得該函數執行動態綁定 - 關鍵字
virtual
只能出現在類內部的聲明語句之前,而不能用於類外部的函數定義 - 任何構造函數之外的非靜態函數都可以是虛函數
- 如果基類把一個函數聲明成虛函數,則該函數在派生類中隱式地也是虛函數
- 虛函數的解析過程發生在運行時,而非編譯時
- 受保護的(protected)訪問運算符表示基類成員希望派生類成員有權訪問,而禁止其他用戶訪問
定義派生類
派生類必須通過使用 類派生列表(class derivation list)明確指出從那個基類繼承而來
class Bulk_quote : public Quote{ // Bulk_quote 繼承自 Quote
public:
Bulk_quote() = default;
Bulk_quote(const std::string& book, double p, std::size_t qty, double disc):
Quote(book, p), min_qty(qty), discount(disc) {}
// 覆蓋基類的函數版本以實現基於大量購買的折扣政策
double net_price(std::size_t) const override
{
{
if(cnt >= min_qty)
return cnt * (1 - discount) * price;
else
return cnt * price;
}
}
private:
std::size_t min_qty = 0; // 使用折扣政策的最低購買量
double discount = 0.0; // 以小數表示的折扣額
};
共有派生類型的對象能夠綁定到基類的引用或指針,即
Quote
的引用或指針可以使用Bulk_quote
對象若派生類未覆蓋基類的虛函數,則直接繼承基類版本
override
關鍵字表明派生類覆蓋了基類的虛函數派生類的對象可當成基類對象使用,也可將基類的指針或引用綁定到派生類對象中的基類部分
下列代碼爲派生類到基類的(derived-to-base)類型轉換,編譯器會隱式執行
Quote item; // 基類對象 Bulk_quote bulk; // 派生類對象 Quote *p = &item; // p 指向 Quote 對象 p = &bulk; // p 指向 buld 的 Quote 部分 Quote &r = bulk; // r 綁定到 bulk 的 Quote 部分
首先初始化基類部分,將參數傳遞給
Quote
的構造函數,然後按照聲明的順序依次初始化派生類成員每個類負責定義各自的接口。要想與類的對象交互必須使用該類的接口,即使這個對象是派生類的基類, 因此派生類對象不能直接初始化基類成員
基類定義的靜態成員在整個繼承體系中只存在唯一定義
某個類用做基類的前提是已經定義,而非僅僅聲明
C++11定義了關鍵字
final
防止被繼承class NoDerived final { /* */ };
類型轉換與繼承
- 靜態類型(static type):編譯時已知,是變量聲明時的類型或表達式生成的類型
- 動態類型(dynamic type):變量或表達式表示的內存中的對象的類型,運行時纔可知
- 基類不能隱式轉換爲派生類,但可使用
static_cast
轉換,只要保證安全 - 派生類向基類的自動類型轉換隻對指針或引用類型有效,而派生類對象與基類對象的轉換是通過拷貝/移動構造函數、賦值運算符發生的
虛函數
虛函數必須有定義
當虛函數通過指針或引用調用時,編譯器知道運行時才能確定引用或指針綁定的對象類型。而若通過一個具有普通類型(非引用非指針)的表達式調用虛函數時,編譯時即可確定
Quote base; Quote *p = &item; p.net_price(10); // 調用 Quote::net_price Bulk_quote derived; p = &derived; p.net_price(20); // 調用 Bulk_quote::net_price
動態綁定只發生在通過指針或引用調用虛函數時
base = derived; // 把 derived 的 Quote 部分拷貝給 base base.net_price(20); // 調用 Quote::net_price
基類中的虛函數在派生類中隱含地也是虛函數,若派生類的函數覆蓋了繼承而來的虛函數,則它的形參類型必須與被覆蓋的基類函數嚴格匹配,但返回類型只需與基類函數匹配
override
關鍵字表明派生類覆蓋了基類的虛函數。final
可用於函數,表明函數不可覆蓋。這兩個說明符出現在形參列表(包括任何const
或引用修飾符)以及尾置返回類型之後如果虛函數使用默認實參,則基類和派生類中定義的默認實參最好一致,否則使用基類中定義的默認實參
可以使用作用域運算符來貨幣虛函數的動態綁定機制,通常只有成員函數(或友元)才需要
抽象基類
- 純虛函數(pure virtual function):無需定義,只需要在函數體前書寫
=0
即可,=0
只能出現在類內部的虛函數聲明語句處 - 抽象基類(abstract base class):含有(或者未經覆蓋直接繼承)純虛函數的類。抽象類負責定義接口,不能創建抽象類的對象
class A{
public:
virtual int func() = 0; // 純虛函數,故 A 爲抽象類
};
訪問控制與繼承
每個類分別控制自己的成員初始化過程,與之類似,每個類還分別控制着其成員對於派生類是否可訪問
受保護的成員
- 和私有成員類似,受保護的成員對於類的用戶來說是不可訪問的
- 和公有成員類似,受保護的成員對於派生類的成員和友元來說是可訪問的
- 派生類的成員或友元只能通過派生類對象來訪問基類的受保護成員。派生類對於一個基類對象中的受保護成員沒有任何訪問特權
class Base{
protected:
int prot_mem; // protected 成員
};
class Sneaky: public Base{
friend void clobber(Sneaky&) { s.j = s.prot_mem = 0; } // 能訪問 Sneaky::prot_mem
friend void clobber(Base&) { b.prot_mem = 0; } // 不能訪問 Base::prot_mem
int j;
};
公有、私有和受保護繼承
某個類對其繼承而來的成員的訪問權限受到兩個因素影響:一是在基類中該成員的訪問說明符,二是在派生類的派生列表中的訪問說明符
- 派生訪問說明符的目的是控制派生類用戶(包括派生類的派生類在內)對於基類成員的訪問權限
- 派生類無法訪問基類中的
private
成員,無論何種方式繼承 - 派生類用戶無法訪問
private
繼承的成員
派生類向基類轉換的可訪問性
- 只有派生類公有繼承基類,才能使用派生類向基類的轉換
- 派生類的成員函數和友元 ,可以使用派生類向基類的轉換
若派生類公有或受保護繼承基類,則派生類的成員和友元可以使用派生類向基類的轉換。若私有,則不能
友元關係不能傳遞,不能繼承,每個類負責控制各自成員的訪問權限
- 通過使用
using
聲明可改變派生類繼承的某個成員的訪問級別 - 默認情況下,使用
class
關鍵字定義的派生類爲私有繼承,使用struct
關鍵字定義的派生類爲公有繼承
繼承中的類作用域
派生類的作用域嵌套在其基類的作用域之內,若一個名字在派生類作用域無法解析,則會在外層的基類作用域尋找
名字查找發生在編譯時,即靜態類型決定可見的成員,即使靜態類型與動態類型可能不一致
派生類能夠重用定義在其基類或間接基類中的名字,此時內層作用域(即派生類)的名字將隱藏定義在外層作用域(即基類)的名字
- 可以通過使用作用域運算符使用被隱藏的成員
using
聲明語句可以把基類中某函數的所有重載實例都添加到派生類作用域中
構造函數與拷貝控制
虛析構函數
若靜態類型與動態類型不相符的話,編譯器期望指向的是動態類型的析構函數,故需要在基類中將析構函數定義爲虛函數以確保執行正確的析構函數版本
- 如果基類的析構函數不是虛函數,則
delete
一個指向派生類對象的基類指針將產生未定義的行爲 - 基類通常需要析構函數,且將其設定爲虛函數
合成拷貝控制與繼承
定義基類的方式可能導致派生類成員成爲被刪除的函數
- 若基類中的默認構造函數、拷貝構造函數、拷貝賦值運算符或析構函數是被刪除的函數或者不可訪問,則派生類中對應的成員將是被刪除的,因爲編譯器不能使用基類成員來執行派生類對象基類部分的構造、賦值或銷燬操作
- 如果在基類中有一個不可訪問或刪除掉的析構函數,則派生類中合成的默認和拷貝構造函數將是被刪除的,因爲編譯器無法銷燬派生類對象的基類部分
- 當使用
=default
請求一個移動操作時,如果基類中的對應操作是刪除的或不可訪問的,則派生類中該函數將是被刪除的,因爲派生類對象的基類部分不可移動
派生類的拷貝控制成員
- 當派生類定義了拷貝或移動操作時,該操作負責拷貝或移動包括基類部分成員在內的整個對象
- 默認情況下,基類的默認構造函數初始化對象的基類部分,如果我們想拷貝或移動基類部分,必須在派生類構造函數初始值列表中顯示調用基類的拷貝或移動構造函數
class Base { /* ... */ }
class D: public Base{
public:
D(const D& d): Base(d) /* D 成員初始值 */ { /* ... */ } // 拷貝基類成員
D(D&& d): Base(std::move(d)) /* D 成員初始值 */ { /* ... */ } // 移動基類成員
};
- 與構造函數即賦值運算符不同,派生類析構函數只負責銷燬由派生類自己分配的資源
- 如果構造函數或虛構函數調用了某個虛函數,則應該執行與構造函數或析構函數所屬類型相對應的虛函數版本
繼承的構造函數
- 類不能繼承默認、拷貝和移動構造函數,若派生類沒有直接定義這些構造函數,則編譯器會合成
- 通常情況下,
using
聲明語句只是令某個名字在當前作用域可見,而當作用於構造函數時,using
聲明語句令編譯器產生代碼,即對於基類的每個構造函數,編譯器都在派生類中生成一個形參列表完全相同的構造函數 - 構造函數的
using
聲明不會改變該構造函數的訪問級別 - 當基類構造函數含有默認實參時,這些實參並不會被繼承
class Bulk_quote: public Disc_quote{
public:
using Disc_quote::Disc_quote; // 繼承 A 的構造函數, 等價於
/*
若派生類有自己的數據成員,則會默認初始化
Bulk_quote(const std::string&book, double price, std::size_t qty, double disc):
Disc_quote(book, price, qty, disc) {}
*/
};
容器與繼承
當使用容器存放集成體系中的對象時,通常採用簡介存儲的方式。因爲不允許在容器中存放不同類型的元素
- 當派生類對象被賦值給基類對象時,其中的派生類部分將被切掉,因此容器和存在繼承關係的類型無法兼容
- 可以採用存放基類指針(更好的選擇是智能指針)來存放具有繼承關係的對象,因爲指針所指對象的動態類型可能是基類類型也可能是派生類型
class B{
public:
virtual void info() { cout << str << endl; }
private:
std::string str = "B";
};
class D: public B{
public:
void info() override { cout << str2 << endl; }
private:
std::string str2 = "D";
};
vector<shared_ptr<B>> vec;
vec.push_back(make_shared<B>());
vec.push_back(make_shared<D>());
vec[0]->info(); // B
vec[1]->info(); // D
vector<B> vec2;
vec2.push_back(B());
vec2.push_back(D());
vec2[0].info(); // B
vec2[1].info(); // B, 派生部分被截斷
結語
繼承和動態綁定的結合使得我們能夠編寫具有特定類型行爲但又獨立於類型的程序
C++中的動態綁定只作用於虛函數,並且通過指針或引用調用
當執行派生類的構造、拷貝、移動和賦值操作時,首先構造、拷貝、移動和賦值其中的基類部分,然後才輪到派生類部分。析構函數的執行順序正好相反,首先銷燬派生類,接下來執行基類子對象的析構函數。
基類通常需要定義一個虛析構函數
派生類爲每個基類提供一個保護級別,public
基類的成員也是派生類接口的一部分,private
基類的成員不可訪問,protected
基類的成員對於派生類的派生類是可訪問的,但是對於派生類的用戶不可訪問