6. 類繼承(公有繼承,is-a)


除非特別指出,C++中的繼承默認爲私有繼承。

// 等效
class child : public base1, base2
{
    // ...
}
// 等效
class child : base2, public base1
{
    // ...
}
// 等效
class child : private base2, public base1
{
    // ...
}

6.1 成員初始化列表

理論上,const成員變量或基類成員變量都應該在構造函數之前初始化。C++提供了成員初始化列表來實現。

class base
{
    double _number;
public:
    base(double number);
    virtual ~base();
};

class child : public base
{
    const short _ARRSIZE;
    double* _numbers;
public:
    child(const double* arr, short size, double number = 0);
};

// 成員初始化列表
child::child(const double* arr, short size, double number) : base(number), _ARRSIZE(size)
{
    _numbers = new double[_ARRSIZE];
    for(int i = 0; i < _ARRSIZE; i++)
        _numbers[i] = arr[i];
}

6.2 虛函數(多態公有繼承)

要實現多態公有繼承,可以採用兩種方法:

  • 在派生類中重新定義一個同名的成員函數。
  • 使用虛函數。

第一種方法在使用向上轉換時會出現問題。
假設動態創建一個基類指針,用這個指針指向一個派生類對象。派生類的指針在儲存時向上轉換爲基類指針。
如果採用第一種方法,用這個指針調用函數會使用基類版本,因爲這樣的實現會根據指針類型調用函數。
如果採用第二種方法,用這個指針調用函數會使用派生類版本,因爲這樣的實現會根據指針指向的對象的類型調用函數。

6.2.1 有關虛函數的要點

  • 聲明一個函數爲虛函數,只需要在首次聲明該函數的類中在其聲明前加上virtual關鍵字即可,之後的派生類中的該函數會自動變成虛的。
  • 派生類中不一定要爲此函數創建創建新的定義,使用虛函數時會自動向上選擇最新的版本。
  • 如果定義的類要被用作基類,應該將那些要在派生類中重新定義的函數聲明爲虛的。

6.2.2 有關虛函數的注意事項

  1. 構造函數

構造函數不能是虛函數,派生類都應該

  1. 析構函數

析構函數必須是虛函數。這樣可以保證在使用動態內存分配+向上轉換,用delete釋放內存時可以調用正確的析構函數
在調用派生類析構函數之後,編譯器會自動調用基類的析構函數。這樣一來讓使用者不用顧忌基類的實現,而來能使基類和派生類以正確的順序被釋放。

  1. 友元

友元不是成員函數,不不能聲明爲虛函數。不過要是想要友元具有虛函數的特性,可以讓友元調用虛函數。

  1. 重新定義將隱藏方法

在派生類中重新定義函數(不管是不是虛函數)將隱藏原來的函數,而不會重載函數(即使參數列表不同)。
因此,考慮兩條規則:

  • 如果重新定義繼承的方法,應確保函數原型完全相同。不過基類指針或引用可以用派生類指針或引用代替,這種特性稱爲返回類型協變。
  • 若要重新定義,應重新定義所有的重載版本。

6.3 訪問控制

protected除非遇到私有繼承,否則protected成員將一直保持爲protected
public成員在派生類中的訪問控制與繼承方式相同。
private成員在派生類中都是不可見的。

public protected private
公有繼承 public protected 不可見
私有繼承 private private 不可見
保護繼承 protected protected 不可見

protected成員在基類和派生類(公有繼承)中都類似私有成員。

私有繼承就相當於在類中添加了一個未命名的私有基類對象
使用基類的成員變量或成員函數,可以使用強制類型轉換

6.4 抽象基類

對於概念類似但是實現方法差別很大的兩個類,用**抽象基類(Abstract Base Class, ABC)**作爲它們的基類以實現多態。

語法規則:

  • 抽象基類中至少要包含一個純虛函數
  • 由於包含純虛函數,不能創建抽象基類的實例。

純虛函數:

// 在虛函數聲明結尾加上“= 0”即可
virtual void show() const = 0;

抽象基類的存在只是爲了描述派生類的共同特點(接口),爲此純虛函數可以沒有定義。但C++中也允許定義純虛函數

ABC理念

  • 只應該將不會被用作基類的設計爲具體的類(與抽象基類相對)。
  • 派生類應實現新函數覆蓋純虛函數。

6.5 類繼承設計細節

6.5.1 不能繼承的函數

  • 構造函數:特徵標不同,正確的做法是在派生類初始化列表中初始化基類。
  • 析構函數:特徵標不同,派生類的析構函數會自動調用基類析構函數。
  • operator=:返回值和參數類型不同。

6.5.2 在派生類中調用基類成員變量或成員函數

想要有兩種方式:

  • 將派生類指針/引用強制轉換爲基類指針/引用。
  • 給函數調用附上作用域解析。
// 將派生類指針/引用**強制轉換**爲基類指針/引用。
// 這種方法常在友元函數中使用。
std::ostream & operator<<(std::ostream & os, const child & rs)
{
    os << (const base &)rs;
    return os;
}
// 給函數調用附上作用域解析。
// 這種方法常在成員函數中使用。
child & child::operator=(const child & rs)
{
    base::operator=(rs);
    return *this;
}

6.6 多重繼承(MI)

使用多重繼承會出現問題。

6.6.1 MI與向上類型轉換

class worker
{
    // ...
};
class singer : public worker
{
    // ...
};
class waiter : public worker
{
    // ...
};
class singerwaiter : public singer, public waiter
{
    // ...
};
singerwaiter ed;
worker *pw = &ed;

雖然看起來沒問題,但其實這樣會出現二義性(ambiguous),因爲singerwaiter對象分別從singer對象和waiter對象繼承了一個waiter對象。
一種做法是顯式指出使用從誰那裏繼承來的waiter對象。

singerwaiter ed;
worker *pw = (singer *)&ed;

先由(singer *)&ed&ed轉換爲singer *類型,再在賦值時隱式地將singer *類型轉換爲worker *類型。

這樣雖然可以解決問題,但是將多態(用基類指針/引用可以指向不同的對象)複雜化了。
下面介紹另外一種方法。

6.6.2 虛基類

中間派生類singerwaiter)聲明時爲基類(worker)加上關鍵字virtual可以保證在最終派生類(singerwaiter)中只繼承一個基類。

class worker
{
    // ...
};
class singer : virtual public worker    // 將worker聲明爲singer的虛基類
{
    // ...
};
class waiter : public virtual worker    // 將worker聲明爲waiter的虛基類
{
    // ...
};
class singerwaiter : public singer, public waiter
{
    // ...
};

可能會有這樣的疑問:

  • 爲什麼要用關鍵字virtual
  • 爲什麼不將虛基類設爲默認準則?
  • 這樣是否會產生問題?

虛函數與虛基類之間的行爲並不是很相似,使用virtual只是挑了個比較好的已有關鍵字,避免引入新的關鍵字。

虛基類要求進行額外的運算,會增加開銷,爲了不需要的的工具付出額外的代價,這種事應該儘量避免。而且有時確實需要在最終的派生類中包含多個基類。

的確會產生問題。若這樣做,在初始化最終派生類時將通過多箇中間派生類將基類所需輸出傳遞給基類的構造函數,但是虛基類只有一個。
爲了避免這種衝突,C++不會通過中間派生類初始化基類。若不想使用基類的默認構造函數,需另外指出。
要注意的是,這種語法能對虛基類使用,對於非虛基類,這是非法的

waiter::waiter(const worker& wk, int p) : worker(wk)
{
    // ...
}
singer::singer(const worker& wk, int v) : worker(wk)
{
    // ...
}
singwewaiter::singerwaiter(const worker& wk, int v, int p) : worker(wk), singer(wk, v), waiter(wk, p){}
singerwaiter(const singer &s, const waiter &w) : worker(s), singer(s), waiter(w){}

完整的測試代碼如下:

class worker
{
    int w;
public:
    worker(int n){ w = n; }
    worker(const worker &wk){ w = wk.w; }
};
class singer : virtual public worker    // 將worker聲明爲singer的虛基類
{
    int v;
public:
    singer(const worker &wk, int v) : worker(wk){ this->v = v; }
};
class waiter : public virtual worker    // 將worker聲明爲waiter的虛基類
{
    int p;
public:
    waiter(const worker &wk, int p) : worker(wk){ this->p = p; }
};
class singerwaiter : public singer, public waiter
{
public:
    singerwaiter(const worker &wk, int v, int p) : worker(wk), singer(wk, v), waiter(wk, p){}
    singerwaiter(const singer &s, const waiter &w) : worker(s), singer(s), waiter(w){}
};

int main()
{
    worker john(10);
    singer tom(john, 6);
    waiter smith(john, 14);
    singerwaiter sam(john, 6, 14);
    singerwaiter jack(tom, smith);
}

6.6.3 間接派生類的方法的選擇

如果沒有在派生類中重新定義函數,則會調用基類的函數。但是現在有兩個間接派生類作爲基類。
爲了說明要使用哪個函數,可以通過作用域解析運算符指出:

singerwaiter sam(john, 6, 14);
sam.singer::work();
sam.waiter::work();

更好的做法是在最終派生類中重新定義每一個可能發生衝突的函數(除非想用以上方式使用不同函數)(這種做法也允許選擇性地使用不同函數)。

6.6.4 關於重複的問題

如果singerwaiter類中的work()函數都調用了workerwork()函數,而在singerwaiter類中的work()函數又分別調用了singerwaiter類中的work()函數。那麼singerwaiter類就要重複進行兩次workerwork()。一般·情況下,這顯然是不合理的。

爲避免這個問題,一種方法是間接派生類的函數中額外提供一個只進行間接派生類中獨有的工作的函數(這裏就要用到protected函數了)。
另一種方法是將基類和間接派生類中的成員變量都設置爲protected。不過用第一種方法可以更嚴格地控制對數據的訪問。

6.6.5 MI的順序問題

注意這裏的設計並不是嚴格按照is-a關係的,這樣只是爲了方便。

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

class Father{
    string name;
public:
    Father(string s) : name(s) {}
    ~Father() { cout << 'F'; }
};

class Mother{
    string name;
public:
    Mother(string s) : name(s) {}
    ~Mother() { cout << 'M'; }
};

class Child : public Mother, public Father{
    string name;
    int age;
public:
    Child(string f, string m, string c, int a) : Mother(m), Father(f), name(c), age(a) {}
    ~Child() {cout << 'C'; }
};

class Girl : public Father, public Mother{
    Child child;
    string name;
    int age;
public:
    Girl(string f, string m, string c, int a, string cf, string cm, string cc, int ca)
        : Father(f), Mother(m), name(c), age(a), child(cf, cm, cc, ca){}
    ~Girl() {cout << 'G'; }
};

int main(){
    {Child grandson("Zhang", "Li", "Ming", 3);}
    cout << endl;
    {Girl daughter("Zhao", "Liu", "Li", 26, "Zhang", "Li", "Ming", 3);}
    return 0;
}
CFM
GCFMMF

GCFMMF中看出,Girl對象daughter先調用自己的析構函數,然後銷燬成員,調用成員Child對象grandson的析構函數,最後調用自己的基類FatherMother的構造函數。
另外,grandsondaughter的構造函數的初始化列表初始化基類的順序相同繼承順序不同,而grandson結尾是FMdaughter的卻是MF

繼承與析構函數調用順序:

  1. 自己的析構函數
  2. 成員函數的析構函數
  3. 基類的析構函數
    • 與初始化列表中的順序無關
    • 與繼承的“列表中的順序相反”
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章