類繼承

除了修改代碼外,有兩種方法能夠用來擴展類的定義,一個是組合,另一個就是繼承

 

組合:  使用類型爲別類的成員變量

繼承:  從已有的類派生出新類,在新類中加入新的成員

 

本文僅討論繼承中的公有繼承 (class derivedClass : public baseClass)

 

 

分爲三部分: 第一部分講述多態,第二部分講述派生類的方法,第三部分講一些額外補充的知識,比較雜

 

 

第一部分  多態

 

概念:     

  多態是針對類中的成員函數而言的。設有一個父類parent,和其子類(child1...childN)若干。設這些類中都定義了一個使用“多態機制”的成員函數A。這個成員函數A在每個類中,名稱相同,參數列表(argument-list)也相同。如果我們爲每個類都創建了一個對象,那麼使用這些對象調用方法A,自然調用的都是對象所屬類的那個A。那如果,聲明父類parent的引用/指針,並且讓這些引用/指針指向這些對象,再使用這些引用/指針來調用方法A呢?  答案還是一樣,每個引用/指針會使用它所指的那個對象來調用方法A。

  若方法A沒有使用“多態機制”,那麼引用/指針調用方法A會發生什麼情況呢?   那麼,它們都將調用,引用/指針的類型--parent類中的方法A。

  以上情況可以推廣: “如果沒有使用多態,將根據引用/指針的類型來選擇方法。如果使用了多態,將根據引用/指針指向的對象的類型來使用方法。”

 

優點:    

  前提: 需要創建子類 child1...childN的多個對象,並調用這些對象的A方法。

  若不使用多態,那麼創建這些對象後,你需要記住這每一個對象的名稱,調用的時候,把這些名稱,一個不落地寫出,也就是說,記憶的任務交給了程序員。

  若使用多態,你只需再創建對象後,把這些對象放到 類型爲parent引用/指針的數組中,調用的時候,令數組元素訪問方法A即可。即,可以使用一個數組來表示多種類型的對象。這就是多態性。

 

實現:    

  有兩種方式可以實現 “多態公有繼承”:

  1. 在子類中重新定義父類的方法 ( 即override,函數名和參數列表均相同 )

      2. 使用虛方法 ( 在子類和父類的那個要使用多態的方法名前加關鍵字 “virtual” )

 

更多:    

  我們知道,使用多態的時候,我們可以將對象賦給指針/引用。當使用指針/引用來訪問多態方法時,程序會讓所指向的那個對象調用它所在的類方法。但有時,所指向的那個對象的類型是不能在編譯時確定的。因此,C++規定,當使用指針/引用來調用虛函數時,使用動態匹配(dynamic binding)。反之,使用靜態匹配(static binding)。


舉個例子:

#include <iostream>
using std::cin;
using std::cout;

class parent
{
public:
    virtual void print() {
        cout << "This is parent.\n";
    }
};

class child1 : public parent
{
public:
    void print() {
        cout << "This is child one.\n";
    }
};

class child2 : public parent
{
public:
    void print() {
        cout << "This is child two.\n";
    }
};

int main() {
    int choice;
    parent* p;

    cin >> choice;
    if(choice == 0)
    {
        parent obj;
        p = &obj;
    }
    else if (choice == 1)
    {
        child1 obj;
        p = &obj;
    }
    else
    {
        child2 obj;
        p = &obj;
    }
    p->print();
    return 0;
}


在上面的例子中,父類指針p最終指向哪個類型的對象,調用那個類中的函數版本,取決於程序員的輸入。


既然無論何時使用動態匹配,程序都不會出錯。爲什麼C++不默認使用呢 ?

 

兩個原因:

1.   效率,爲了使用動態匹配在運行階段進行決策,必定使用了一些方法來跟蹤,這會有額外的開銷。如果,子類不重新定義父類的任何方法(不使用多態),   那麼根本不需要動態匹配。況且,就算子類重新定義了父類中的某些方法,所編寫的代碼還不一定需要動態匹配呢。

2.   C++的指導原則之一是: “不要爲不使用的特性付出代價”。

 

 

 

第二部分  派生類的方法

  

構造函數 (constructor)

 

  子類構造函數的函數體,執行之前,會先調用父類構造函數構造父類的對象。然後,才執行子類構造函數體中的代碼,完成子類新定義成員的初始化。最終“組合”成一個子類的對象。可以選擇將父類構造函數的調用放在子類構造函數的成員初始化列表(member-initializer-list)中。否則將調用父類的默認構造函數。

  複製構造函數(copy constructor)是特殊的構造函數。當子類成員中的新成員需要使用new來進行動態內存分配的時候,需要顯式定義子類的複製構造函數。函數體中涉及具體的內存使用問題,由程序員自行解決。

  此外,構造函數不可以是虛函數。這並沒有意義。想一想,當一個對象被賦給父類的指針/引用之前,它必須已經被初始化(調用過確定的構造函數了)。因此,這並沒有意義。

 

析構函數 (destructor)

 

  和父子類構造函數調用的順序相反。當一個對象析構的時候,將先調用子類的析構函數,然後才調用父類的析構函數。

  當子類成員中的新成員需要使用new來進行動態內存分配的時候,需要顯式定義子類的析構函數。函數體中涉及具體的內存使用問題,由程序員自行解決。然而,要注意,父類中的成員情況無需考慮,因爲父類已經封裝(encapsulate)好了自己的解決方法,子類只需關注於解決自己定義的新成員帶來的問題就好。

  析構函數可以是虛函數。而且父類的析構函數被定義爲虛函數在某種情況下是必要的:

  父類的指針/引用指向一個對象,且要釋放這個指針或引用的時候:

  如果,析構函數不是虛函數,那麼將僅調用父類的析構函數,而不會調用子類的析構函數。當子類的析構函數涉及新成員內存的釋放時,就會發生內存泄漏(memory leak)。


舉個例子:

#include <iostream>
using namespace std;

class Parent
{
public:
    virtual ~Parent() {
        cout << "destructor of Parent.\n";
    }
};

class Child: public Parent
{
public:
    ~Child() {
        cout << "destructor of Child.\n";
    }
};

int main() {
    {
        Parent* p = new Child();
        delete p;
    }
    return 0;
}


      上述代碼中,基類Parent中的析構函數是虛函數,所以調用析構函數的時候,會先調用子類的析構函數,再調用父類的析構函數。

  輸出如下:


      去掉virtual時,輸出如下:



      不過上面的例子比較簡單,只是突出沒有調用到子類的析構函數,但是沒有強調不調用析構函數帶來的壞處。當子類成員中需要使用動態內存分配。則須在子類的析構函數中進行內存的釋放。這時候,不調用析構函數的壞處就顯而易見了。

 

  解決方法: 如果一個類將要作爲基類,則將它的析構函數定義爲虛函數

  

賦值運算符函數 (assignment operator)

 

  當子類成員中的新成員需要使用new來進行動態內存分配的時候,需要顯式定義子類的賦值運算符函數。函數體中涉及具體的內存使用問題,由程序員自行解決。如果需要調用父類的賦值運算符函數來複制對象中屬於父類的成員部分,則可以使用域限定符來訪問父類的賦值運算符函數(assignment operator),像這樣: baseClass::operator=(xxx)。其中,xxx是類型爲derivedClass的引用。有一點要注意的是: 你不能這樣: *this = xxx。

第二種表達不僅包含了第一種表達(對對象的父類成員部分的更新),還包含了對當前類成員部分的更新。而且這個更新正是調用這條語句所在的賦值運算符函數。最終,這個賦值運算符函數就會反覆調用自己,沒有出口。


舉個例子:

#include <iostream>
#include <cstring>
using namespace std;

class Staff
{
private:
    int id;
public:
    Staff() {}
    Staff(int ID) {
        id = ID;
    }
};

class Teacher : public Staff
{
private:
    char* name;
public:
    Teacher() : Staff(0) {
        name = nullptr;
    }

    Teacher(int ID , const char *Name) : Staff(ID) {
        name = new char [strlen(Name)+1];    
        strcpy(name,Name);
    }

    //copy constructor
    Teacher(const Teacher& te) : Staff(te) { //use implicit copy constructor
        name = new char[strlen(te.name)+1];      // of Staff
        strcpy(name,te.name);
    }

    //assignment operator
    Teacher& operator=(const Teacher& te) {
        if(this == &te)
            return *this;
        //*this = te;            //錯誤語句
        Staff::operator=(te);            //正確語句

        delete [] name;
        name = new char[strlen(te.name)+1];
        strcpy(name,te.name);
        return *this;
    }

    //destructor
    ~Teacher() {
        delete [] name;
    }
};

int main() {
    Teacher Sue(1000,"Sue");
    Teacher Coco(1001,"Coco");
    Sue = Coco;
    return 0;
}


轉換函數 (transfer function)

 

1. 當需要從其他類型轉換到本類型的時候,轉換函數是一個帶參數的構造函數。這時,轉換函數不可以是虛函數。

2. 當需要從本類型轉換到其他類型的時候,轉換函數需要自定義。這時,轉換函數可以是虛函數。

 

其他成員函數 (others except operator)

 

其他成員函數視具體需要而定,可以是虛函數。

 

友元函數 (friend)

 

友元函數不可以是虛函數,因爲友元函數並不是類的成員,只是它可以訪問類的成員而已。

很自然的,派生類的友元函數是可以訪問基類的保護成員或公有成員的。但是如果想讓派生類的友元函數訪問基類的友元函數,則需要使用強制類型轉換,以匹配友元函數的調用條件。

 

舉個例子:

#include <iostream>
using namespace std;


class A
{
public:
    friend ostream& operator << (ostream& os , const A& a) {
        os << "This is A ";
    }
};

class B : public A
{
public:
    friend ostream& operator << (ostream& os , const B& b) {
        os << (const A&) b;        //使用強制類型轉換,將調用A的友元函數
        os << "and B\n";
    }
};

int main() {
    B b;
    cout << b;
    return 0;
}


小結

  第二部分的重點在於: 

1. 當子類成員中的新成員需要使用new來進行動態內存分配的時候,需要顯式定義子類的複製構造函數,析構函數,賦值操作符函數。

2. 學會在子類的新成員函數中正確調用父類的成員函數。

 

 

第三部分  雜談

 

好的習慣    

 

  如果在派生類中重新定義從基類繼承的方法,將不僅是使用相同的argument-list覆蓋基類的聲明,無論argument-list是否相同,該操作將隱藏所有的同名基類方法。(不管是不是虛函數,只要在派生類中redefine了,都會覆蓋)

  因此,書中總結了兩條經驗規則:

1. 如果重新定義繼承的方法,應和原來的原型prototype相同。但如果返回類型是基類引用/指針,則可以修改爲派生類的引用/指針。這被成爲返回類型協變(covariance of return type),因爲允許返回類型隨類的類型而變化。

2. 如果基類聲明被重載了,則應在派生類中重新定義所有的基類版本。派生類中定義的版本的函數體中,可以使用 “基類名::函數名” 來訪問父類的版本。

 

虛函數的工作原理

  

  不同編譯器中虛函數的工作原理可能不一樣,這裏僅僅給出一種實現機制,大家可以發揮自己的想像力,得出自己的可行的解決方案:

 

  編譯器會給每一個類的對象添加一個隱藏的成員,這個隱藏的成員中保存了一個指針,它指向這個類的虛函數地址數組。

  比如基類對象的這個指針,將指向基類中虛函數的地址數組。派生類對象中也有一個這樣的指針,如果派生類重新定義(redefine)了基類中的某個虛函數,那麼(派生類的)虛函數地址數組中的某個位置保存新版本的地址。如果派生類添加了新的虛函數,那麼這個函數的地址被加入(派生類的)虛函數地址數組中。

  當把一個子類的對象賦給父類的引用/指針後,使用這個引用/指針訪問虛函數的時候,做以下4件事情,先後順序不嚴格區分

  1. 首先拿到所指對象的指針vptr。

  2. 獲悉在該對象所屬的類中,這個虛函數是第i個。

  3. 根據這個指針vptr來到指向的虛函數地址數組。

  4. 執行數組第i個位置存儲的地址中的函數。

  

  書上有詳盡的例子,這裏就不贅述了。 @《C++ Primer Plus 6th edition》--13.4.2.2

 

抽象基類    

 

概念:

  抽象基類常作爲一個通用的接口它廣泛用於這種情況: 兩個類有共性,但是直接繼承存在爭議和麻煩,就可以考慮把共性提取出來,放在一個抽象基類中,然後讓兩個類繼承和實現它。

特性:

  1.不可以定義它的對象,但可以定義它的引用和指針。

  2.抽象基類中至少要有一個純虛函數。

  3. 抽象基類的子類如果沒有實現它的父類的所有純虛函數,那麼這個子類也是一個抽象基類。



References:


[1]《C++ Primer Plus 6th Edition》
發佈了36 篇原創文章 · 獲贊 4 · 訪問量 3萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章