C++繼承和組合(無師自通)

2020-07-16 10:04:44
在物件導向語言中,類繼承可用於描述派生類的型別是基礎類別的一種特殊情況的事實,並建立相應的模型。實際上,類應該被視為可從中建立的所有物件的集合。因為派生類是基礎類別的特例,所以對應於派生類的物件集合將是對應於基礎類別的物件集合的子集。因此,派生類的每個物件也是基礎類別的一個物件。換句話說,每個派生類物件都是一個(Is-a)基礎類別物件。

每當某個類包含另一個類的物件作為其成員變數之一時,就會出現類組合。組合在兩個類之間建立了(Has-a)的關係。

由於派生類繼承了其基礎類別的所有成員,派生類實際上包含其基礎類別的一個物件。正因為如此,可能在某些需要組合的地方也可以使用繼承。

現在來看一個範例。假設有一個程式需要能代表某個人的資料,比如說這個人的姓名和街道地址。街道地址可能由兩行組成:

123 Main Street
Hometown, 12345

現在假設有一個代表街道地址的類:
class StreetAddress
{
    private:
        string line1, line2;
    public:
        void setLine1(string);
        void setLine2(string);
        string getLine1();
        string getLine2();
};
因為一個人的資料包含一個姓名和一個街道地址,所以正確表示一個人的資料的類將使用以下方式的組合:
class PersonData
{
    private:
        string name;
        StreetAddress address;
    public:
        ...
};

PemmData 的類宣告中忽略了其餘部分,因為那些與我們要闡述的主旨無關。

在這裡可以使用繼承而不是組合來定義這個類。例如,可以定義一個類 PersonData1,如下所示:
class PersonData1:public StreetAddress
{
    private:
        string name;
    public:
};
雖然這個新的定義能夠正確編譯,但從概念上講這其實是錯誤的,因為它將一個人的資料視為一種特殊的街道地址,而事實並非如此。這種型別的設計概念錯誤可能會導致程式理解困擾並難以維護。

所以,更好的設計做法是,盡可能優先選擇組合而不是繼承。這樣做還有一個原因是,繼承打破了基礎類別的封裝,因為它將基礎類別的受保護成員暴露給了派生類的方法。

現在來看一個使用繼承比組合更有意義的範例。假設有一個 dog 類,代表了所有狗的集合。每個 Dog 物件都有一個型別為 double 的成員變數 weight 和一個成員函數 voidbark(),該類的範例如下:
class Dog
{
    protected:
        double weight;
    public:
        Dog(double w)
        { weight = w; }
        virtual void bark() const
        {
            cout << "I am dog weighing " << weight << " pounds." << endl;
        }
};
這個類還有一個建構函式,允許 Dog 物件被初始化。請注意,以上範例中已經宣告了一個虛成員函數 bark(),以允許它在派生類中被覆蓋。

假設需要一個代表所有牧羊犬集合的類。由於每隻牧羊犬都是狗,因此從 Dog 類中派生出新的 SheepDog 類是有意義的。這樣,SheepDog 物件將繼承 Dog 類的每個成員。除了具有狗所具有的各種特徵之外,每隻牧羊犬還應該具有區別於其他犬種的特殊特徵,例如,有一個整數成員 numberSheep 指示牧羊犬被訓練看護的綿羊的最大數量。另外,牧羊犬的吠叫方式可能不同於普通犬種,也許要適應看護羊群的需要。這可以通過覆蓋 Dog 類的 bark() 成員函數來解決。
class SheepDog:public Dog
{
    private:
        int numberSheep;
    public:
        SheepDog(double w, int nSheep) : Dog(w)
        {
            numberSheep = nSheep;
        }
        virtual void bark() const override
        {
            cout << "I am a sheepdog weighing " << weight << " pounds nand guarding " << numberSheep << " sheep." << endl;
        }
};
為了演示該類,可以建立一個狗的向量,向量中的一些狗就是牧羊犬。為了規避向量不能擁有兩種不同型別的事實,可以使用指向 Dog 的指標的向量。前面講過,一個指向基礎類別(在本範例中即 Dog 類)的指標也可以指向任何派生類物件(在本範例中為 SheepDog)。因此,可以建立一個指向 Dog 的指標向量,並且其中一些指標指向 Dog 物件,而另一些指標則指向 SheepDog 物件。
vector<shared_ptr<Dog>> kenne1
{
    make_shared<Dog>(40.5),
    make_shared<SheepDog>(45.3, 50),
    make_shared<Dog>(24.7)
};
最後,可以使用一個迴圈來呼叫向量中每個 Dog 物件的 bark() 成員函數:
for (int k = 0; k < 3; k++)
{
    cout << k+1 << ": ";
    kennel[k]->bark();
}
由於多型性,並且因為 bark() 函數被宣告為虛擬函式,所以迴圈內的同一行程式碼對普通狗將呼叫原始的 bark() 函數,而對牧羊犬則會呼叫派生類 SheepDog 中的特殊 bark() 函數。完整的程式為:
// This program demonstrates the Is-A relation in inheritance.
#include <iostream>
#include <memory>
#include <vector>

using namespace std;
// Base class
class Dog
{
    protected:
        double weight;
    public:
        Dog(double w)
        {
            weight = w;
        }
        virtual void bark() const
        {
            cout << "I am a dog weighing " << weight << " pounds." << endl;
        }
};

// A SheepDog is a special type of Dog
class SheepDog :public Dog
{
        int numberSheep;
    public:
        SheepDog(double w, int nSheep) : Dog(w)
        {
            numberSheep = nSheep;
        }
        void bark() const override
        {
            cout << "I am a sheepdog weighing " << weight << " pounds and guarding " << numberSheep << " sheep." << endl;
        }
};

int main()
{
    // Create a vector of dogs
    vector<shared_ptr<Dog>> kennel
    {
        make_shared<Dog>(40.5),
        make_shared<SheepDog>(45.3, 50),
        make_shared<Dog>(24.7)
    };
    // Walk by each kennel and make the dog bark
    for (int k = 0; k < kennel.size(); k++)
    {
        cout << k + 1 << ": ";
        kennel[k]->bark();
    }
    return 0;
}
程式輸出結果:

1: I am a dog weighing 40.5 pounds.
2: I am a sheepdog weighing 45.3 pounds and guarding 50 sheep.
3: I am a dog weighing 24.7 pounds.

在本範例中,繼承是一個比組合更好的選擇,因為使用組合就等於說一隻牧羊犬有一隻(Has-a)狗,而不是說一隻牧羊犬是一隻(Is-a)狗。

有些作者認為在類之間存在第三種關係,即:使用實現關係。基本上,一個類如果呼叫第二個類的物件的成員函數,則稱它使用了第二個類的實現。

如何才能知道何時該使用繼承,何時該使用組合呢?

假設有一個現有的類 C1,並且需要為另一個類 C2 編寫一個定義,而 C2 需要一個關聯的 C1 物件的服務。那麼,究竟是需要從 C1 派生 C2,還是應該給 C2 —個 C1 型別的成員變數?一般來說,應該優選組合而不是繼承。

為了幫助確定繼承是否合適,可以提出以下問題:
  • 將 C2 物件設想成 C1 物件的特殊型別是否自然?如果是,那麼應該使用繼承。
  • C2 類物件是否需要在 C1 類物件使用的地方使用?例如,C2 物件是否需要被傳遞給函數,而該函數釆用的參照形參為 C1 型別或指向 C1 的指標?如果是,那麼應該使 C2 成為 C1 的派生類。