C++虛函數(shù)

C++虛函數(shù)

C++虛函數(shù)是多態(tài)性實現(xiàn)的重要方式,當(dāng)某個虛函數(shù)通過指針或者引用調(diào)用時,編譯器產(chǎn)生的代碼直到運行時才能確定到底調(diào)用哪個版本的函數(shù)。被調(diào)用的函數(shù)是與綁定到指針或者引用上的對象的動態(tài)類型相匹配的那個。因此,借助虛函數(shù),我們可以實現(xiàn)多態(tài)性。這也是OOP的核心思想之一。

引言

考慮下面一個繼承的例子,Dog類與Cat類都繼承自Animal類,但是它們擁有不同的speak()方法:

class Animal
{
public:
    Animal(const string& name):
        m_name{name}
    {}

    const string& getName() const
    {
        return m_name;
    }

    string speak() const
    {
        return "???";
    }

private:
    string m_name;
};

class Cat : public Animal
{
public:
    Cat(const string& name): 
        Animal(name)
    {}

    string speak() const
    {
        return "Meow";
    }
};

class Dog : public Animal
{
public:
    Dog(const string& name):
        Animal(name)
    {}

    string speak() const
    {
        return "Woof";
    }
};

我們知道派生類對象可以賦值給基類的指針或者引用,但是我們希望調(diào)用這些指針或者引用時,能夠調(diào)用各個派生類自己的方法,比如下面的例子:

int main()
{
    Cat cat{ "Fred" };
    cout << "Cat is named " << cat.getName() << ", and it says " << cat.speak() << endl;
    
    Dog dog{ "Carbo" };
    cout << "Dog is named " << dog.getName() << ", and it says " << dog.speak() << endl;

    Animal* catAnimal = &cat;
    cout << "Cat is named " << catAnimal->getName() << ", and it says " << catAnimal->speak() << endl;

    Animal& dogAnimal = dog;
    cout << "Dog is named " << dogAnimal.getName() << ", and it says " << dogAnimal.speak() << endl;

        return 0;
}

但是輸出并不是預(yù)期的那樣:

cat is named Fred, and it says Meow
dog is named Garbo, and it says Woof
pAnimal is named Fred, and it says ???
pAnimal is named Garbo, and it says ???

無論是指針還是引用,它們都沒有調(diào)用其派生對象所重寫的方法,而是基類原有的方法。大家可能會想,為什么我非要將派生類對象賦值給基類的指針或者引用來調(diào)用派生類的方法?直接利用派生類對象調(diào)用不就可以了嗎?這樣做有很多好處,比如你想使用一個函數(shù),接收一個動物對象類,然后打印其名字以及叫聲。但是由于這樣的動物類有兩個,你必須利用重載的思想實現(xiàn)兩個版本:

void print(Cat& cat)
{
    cout << cat.getName() << " says " << cat.speak() << endl;
}

void print(Dog& dog)
{
    cout << dog.getName() << " says " << dog.speak() << endl;
}

兩個版本實現(xiàn)起來并沒有那么麻煩,但是如果動物類的種類更多呢?這個時候你就有點不樂意了,僅僅是對象類型不同,但是方法是相同的,為什么不能僅寫一個版本:

void print(Animal& animal)
{
    cout << animal.getName() << " says " << animal.speak() << endl;
}

如果基類能夠動態(tài)確定其實際所指向的派生類對象,并調(diào)用合適版本的方法,那么一個函數(shù)就可以解決上面的問題。

看來盡管每個派生類都有自己實現(xiàn)的speak()方法,但是它們實際上并沒有真正的重寫基類方法,僅僅是隱藏。因為派生類對象傳遞給基類的指針或者引用并沒有調(diào)用派生類版本的方法,依然是基類方法。

所以,你需要虛函數(shù)!

虛函數(shù)與多態(tài)性

虛函數(shù)是類方法中的一種特殊函數(shù),當(dāng)你調(diào)用它時,它會匹配派生最遠(yuǎn)的重寫版本。這種特性是多態(tài)性。匹配的規(guī)則是相同的函數(shù)簽名(函數(shù)名,參數(shù)個數(shù)與類型)以及返回類型(返回類型可以不相同,但必須存在派生關(guān)系)。虛函數(shù)僅需要再前面加上一個virtual關(guān)鍵字即可,利用虛函數(shù)我們可以修改上面的代碼:

class Animal
{
public:
    Animal(const string& name):
        m_name{name}
    {}

    const string& getName() const
    {
        return m_name;
    }

    virtual string speak() const
    {
        return "???";
    }

private:
    string m_name;
};

class Cat : public Animal
{
public:
    Cat(const string& name): 
        Animal(name)
    {}

    virtual string speak() const
    {
        return "Meow";
    }
};

class Dog : public Animal
{
public:
    Dog(const string& name):
        Animal(name)
    {}

    virtual string speak() const
    {
        return "Woof";
    }
};

此時,再測試一下下面的代碼,可以看到輸出實現(xiàn)了預(yù)期的效果:

int main()
{
    Cat cat{ "Fred" };
    cout << "Cat is named " << cat.getName() << ", and it says " << cat.speak() << endl;
    
    Dog dog{ "Carbo" };
    cout << "Dog is named " << dog.getName() << ", and it says " << dog.speak() << endl;

    Animal* catAnimal = &cat;
    cout << "Cat is named " << catAnimal->getName() << ", and it says " << catAnimal->speak() << endl;

    Animal& dogAnimal = dog;
    cout << "Dog is named " << dogAnimal.getName() << ", and it says " << dogAnimal.speak() << endl;

        return 0;
}
output:
cat is named Fred, and it says Meow
dog is named Garbo, and it says Woof
cat is named Fred, and it says Meow
dog is named Garbo, and it says Woof

可以看到,不論是基類版本還是派生類版本,我們都在函數(shù)前面使用了virtual關(guān)鍵字,事實上,派生類中的virtual關(guān)鍵字并不是必要的。一旦基類中的方法打上了virtual標(biāo)簽,那么派生類中匹配的函數(shù)也是虛函數(shù)。但是,還是建議在后面的派生類中加上virtual關(guān)鍵字,作為虛函數(shù)的一種提醒,以便后面可能還會有更遠(yuǎn)的派生。

注意千萬不要在構(gòu)造函數(shù)與析構(gòu)函數(shù)中調(diào)用虛函數(shù)。我們知道派生類對象在創(chuàng)建時,首先基類部分先被創(chuàng)建,如果你在基類構(gòu)造函數(shù)調(diào)用虛函數(shù)時,它此時將無法調(diào)用派生類版本的函數(shù),因為派生類對象還未創(chuàng)建,此時派生類虛函數(shù)沒有作用的對象。那么,它只能調(diào)用基類版本的虛函數(shù)。對于析構(gòu)函數(shù),派生類對象中的派生部分先被析構(gòu),如果你在基類析構(gòu)函數(shù)中調(diào)用了虛函數(shù),它也只能調(diào)用基類版本的虛函數(shù),因為派生類對象已經(jīng)不存在了。

到底什么時候使用虛函數(shù)?大部分時候,我們希望派生類是真正的“重寫”基類函數(shù),而不是“隱藏”。所以一般建議將所有方法都聲明為virtual。既然如此,為什么編譯器不默認(rèn)這樣做呢,其實對于Java語言來說,所有的方法默認(rèn)是虛函數(shù)。但是使用虛函數(shù)是有代價的,相對于普通函數(shù),虛函數(shù)的調(diào)用代價稍高,但是這種差別不會太大,所以還是建議所有方法都使用virtual關(guān)鍵字。

override標(biāo)識符

前面說到,派生類的重寫方法必須與基類方法要匹配,否則編譯器會認(rèn)為派生類創(chuàng)建了一個新方法,而不是重寫基類的版本,看下面的例子:

class Super
{
public:
    virtual string getName1(int x)
    {
        return "Super";
    }

    virtual string getName2(int x)
    {
        return "Super";
    }
};

class Sub: public Super
{
public:
    virtual string getName1(double x)
    {
        return "Sub";
    }
    
    virtual string getName2(int x) const
    {
        return "Sub";
    }
};

int main()
{
    Sub sub;
    Super* super = ?
    
    cout << super->getName1(1) << endl;  // output: Super
    cout << super->getName2(2) << endl;  // output: Super

    cin.ignore(10);
    return 0;
}

可以看到,派生類的兩個虛方法并沒有重寫基類版本,這是由于兩個方法的函數(shù)簽名并不一樣。所以將派生類對象賦值給基類的指針只能是調(diào)用基類方法。但是,實際上我們希望派生類的兩個方法是重寫版本。有時候,我們很容易犯一些小錯誤導(dǎo)致重寫失敗,比如上面的例子。還有時候,我們修改了基類虛函數(shù),但是沒有更新派生類的對應(yīng)重載版本,也將有可能使重寫失效。為了避免這樣的錯誤,C++引入了override標(biāo)識符,使用這個標(biāo)識符告訴編譯器這是重寫的方法,如果方法不匹配,那么將無法通過編譯。用override修改代碼如下:

class Super
{
public:
    virtual string getName1(int x)
    {
        return "Super";
    }

    virtual string getName2(int x)
    {
        return "Super";
    }
};

class Sub: public Super
{
public:
    virtual string getName1(double x) override
    {
        return "Sub";
    }
    
    virtual string getName2(int x) const override
    {
        return "Sub";
    }
    // 此時無法編譯
};

所以,只要重寫基類方法,建議使用override標(biāo)識符,避免無意的錯誤。

final標(biāo)識符

有時候,你不想派生類重寫基類的虛方法,此時可以使用final標(biāo)識符,這個時候如果派生類重寫了基類虛方法,那么將無法編譯:

class A
{
public:
    virtual void someMethod() { cout << "A" << endl; }
}

class B: public A
{
public:
    // 基類A的someMethod方法沒有final標(biāo)識符,那么B可以重寫該方法
    // 但是此虛方法使用了final標(biāo)識符,后面的派生類無法重寫
    virtual void someMethod() override final { cout << "B" << endl; }
}

class C: public B
{
public:
    // 無法編譯,因為不允許重寫
    virtual void someMethod() override { cout << "C" << endl; }
}

而且final標(biāo)識符還可以直接用于類,此時該類將不能被繼承:

class A
{
public:
    virtual void someMethod() { cout << "A" << endl; }
};

// B可以繼承A
class B final: public A
{
public:
    virtual void someMethod() override { cout << "B" << endl; }
};

// B無法被繼承,此時無法編譯
class C: public B
{
public:
    virtual void someMethod() override { cout << "C" << endl; }
};

協(xié)變返回類型

前面說過,要想成功重寫方法,基類虛方法與派生類虛方法必須匹配,其中返回類型也必須一致。但是有時候返回類型不相同,也能實現(xiàn)重寫,此時返回類型存在繼承關(guān)系:基類方法返回類型是一個指向某一類的指針或者引用,而派生類重寫版本的返回類型是指向派生類的指針或者引用。這種情況稱為協(xié)變返回類型。下面是一個例子:

class Super
{
public:
    virtual Super* getThis() { return this; }
};

class Sub : public Super
{
    virtual Sub* getThis() override { return this; }
};

析構(gòu)函數(shù)要聲明為虛函數(shù)

對于析構(gòu)函數(shù),大部分時間我們只需要使用編譯器提供的默認(rèn)版本就好,除非涉及到釋放動態(tài)分配的內(nèi)存。但是如果存在繼承,虛函數(shù)最好聲明為虛函數(shù)。否則刪除一個實際指向派生類的基類指針,只會調(diào)用基類的析構(gòu)函數(shù),而不會調(diào)用派生類的析構(gòu)函數(shù)以及派生類數(shù)據(jù)成員的析構(gòu)函數(shù)。這樣就可能造成內(nèi)存泄露,看下面的例子:

class Resource
{
public:
    Resource() { cout << "Resource created!" << endl; }
    ~Resource() { cout << "Resource destoryed!" << endl; }
};

class Super
{
public:
    Super() { cout << "Super constructor called!" << endl; }
    ~Super() { cout << "Super destructor called!" << endl; }
};

class Sub : public Super
{
public:
    Sub() { cout << "Sub constructor called!" << endl;}

    ~Sub() { cout << "Sub destructor called!" << endl;}
private:
    Resource res;
};

如果執(zhí)行下面的代碼:

int main()
{
    Sub* sub = new Sub;
    Super* super = sub;
    delete super;

    cin.ignore(10);
        return 0;
}

其輸出為:

Super constructor called!
Resource created!
Sub constructor called!
Super destructor called!

可以看到,派生類的析構(gòu)函數(shù)沒有執(zhí)行,其數(shù)據(jù)成員Resource也沒有被析構(gòu)。但是如果你將析構(gòu)函數(shù)都聲明為虛函數(shù),上面的代碼將得到如下的結(jié)果:

Super constructor called!
Resource created!
Sub constructor called!
Resource destoryed!
Sub destructor called!
Super destructor called!

此時,程序按照預(yù)期輸出,所以,對于繼承問題,沒有理由不將析構(gòu)函數(shù)聲明為虛函數(shù)!

函數(shù)調(diào)用捆綁

要想深刻理解虛函數(shù)機(jī)理,首先要了解函數(shù)調(diào)用捆綁機(jī)制。捆綁指的是將標(biāo)識符(如變量名與函數(shù)名)轉(zhuǎn)化為地址。這里我們僅僅關(guān)注有關(guān)函數(shù)調(diào)用的捆綁。我們知道每個函數(shù)在編譯的過程中是存在一個唯一的地址的。如果我們在程序段里面直接調(diào)用某個函數(shù),那么編譯器或者鏈接器會直接將函數(shù)標(biāo)識符替換為一個機(jī)器地址。這種方式是早捆綁,或者說是靜態(tài)捆綁。因為捆綁是在程序運行之前完成的??聪旅娴暮唵卫樱?/p>

int add(int x, int y)
{
    return x + y;
}

int subtract(int x, int y)
{
    return x - y;
}

int multiply(int x, int y)
{
    return x * y;
}

int main()
{
    int x;
    cout << "Enter a number: ";
    cin >> x;

    int y;
    cout << "Enter another number: ";
    cin >> y;

    int op;
    cout << "Enter an operation (0=add, 1=subtract, 2=multiply): ";
    cin >> op;

    int result;
    switch (op)
    {
        // 使用早綁定來直接調(diào)用函數(shù)
        case 0: result = add(x, y); break;
        case 1: result = subtract(x, y); break;
        case 2: result = multiply(x, y); break;
    }

    cout << "The answer is: " << result << endl;

        return 0;
}

由于上面三個函數(shù)的調(diào)用都是直接使用函數(shù)名,采用早捆綁的方式。編譯器會將每個函數(shù)調(diào)用替換為一個跳轉(zhuǎn)指令,這個指令告訴CPU跳轉(zhuǎn)到函數(shù)的地址來執(zhí)行。

但是有時候,我們在程序運行前并不知道調(diào)用哪個函數(shù),此時必須使用晚捆綁或者動態(tài)捆綁。晚綁定的一個例子就是使用函數(shù)指針,修改上面的例子:

int main()
{
    int x;
    cout << "Enter a number: ";
    cin >> x;

    int y;
    cout << "Enter another number: ";
    cin >> y;

    int op;
    cout << "Enter an operation (0=add, 1=subtract, 2=multiply): ";
    cin >> op;

    // 定義一個函數(shù)指針
    int(*opFun)(int, int) = nullptr;
    switch (op)
    {
        // 使用早捆綁來直接調(diào)用函數(shù)
        case 0: opFun = add; break;
        case 1: opFun = subtract; break;
        case 2: opFun = multiply; break;
    }

    // 通過函數(shù)指針來調(diào)用,只能是晚捆綁
    cout << "The answer is: " << opFun(x, y) << endl;

        return 0;
}

使用函數(shù)指針來間接調(diào)用函數(shù),編譯器在編譯階段并不知道函數(shù)指針到底指向哪個函數(shù),所以必須使用動態(tài)捆綁的方式。

動態(tài)綁定看起來更靈活,但是其是有代價的。靜態(tài)捆綁時,CUP可以直接跳轉(zhuǎn)到函數(shù)地址。但是動態(tài)捆綁,CPU必須先提取指針的地址,然后再跳轉(zhuǎn)到指向的函數(shù)地址。這多了一個步驟!

虛函數(shù)表(Vtable)

C++使用了一種稱為“虛表”的晚捆綁技術(shù)來實現(xiàn)虛函數(shù)。虛表是一個函數(shù)查詢表,以動態(tài)捆綁的方式解析函數(shù)調(diào)用。每個具有一個或者多個虛函數(shù)的類都有一張?zhí)摫?,這個表是在編譯階段建立的靜態(tài)數(shù)組,其中包含了每個虛方法的函數(shù)指針,這些指針指向的是該類可見的派生最遠(yuǎn)的函數(shù)實現(xiàn)。其次,編譯器會在基類對象都會添加一個隱含指針,這里我們稱為*__vptr。這個指針當(dāng)然能夠被派生類所繼承,這相當(dāng)重要。當(dāng)類的實例被創(chuàng)建時,這個指針指向該類所對應(yīng)的虛表。這樣,當(dāng)使用某個對象調(diào)用虛方法時,通過該指針查找虛表,然后根據(jù)實際的對象類型執(zhí)行正確版本的方法調(diào)用??聪旅娴暮唵卫樱?/p>

class Base
{
public:
    virtual void function1() { }
    virtual void function2() { }
}

class D1: public Base
{
public:
    virtual void function1() override { }
}

class D2: public Base
{
public:
    virtual void function2() override { }
}

上面包含3個類,其中派生類D1與D2分別重寫了基類的function1()和function2()虛方法。編譯器會相應(yīng)地創(chuàng)建3個不同的虛表,分別對應(yīng)每個類。而且編譯器也會自動地為基類添加一個函數(shù)指針,如下所示:

class Base
{
public:
    FunctionPointer *__vptr;
    virtual void function1() { }
    virtual void function2() { }
}

class D1: public Base
{
public:
    virtual void function1() override { }
}

class D2: public Base
{
public:
    virtual void function2() override { }
}

這樣,每個類實例創(chuàng)建時,*__vptr將指向該類所對應(yīng)的虛表,比如基類的一個實例創(chuàng)建時,這個指鎮(zhèn)就指向基類的虛表。

下面我們看看每個類的虛表是怎么建立的。因為僅有兩個虛方法,所以每個虛表僅包含兩個函數(shù)指針,分別對應(yīng)function1()和function2()。但是每個函數(shù)指針實際指向的是那個類所可見的派生最遠(yuǎn)的函數(shù)實現(xiàn):

  • Base的虛表:因為Base的實例僅可見自己的成員,所以它的虛表中的指針分別指向Base::function1()和Base::function2();
  • D1的虛表:D1的實例可見Base的成員與自身的成員,但是D1僅重寫了function1(),所以虛表中的指針分別指向D1::function1()和Base::function2();
  • D2的虛表:與D1類似,分別指向Base::function1()和D2::function2()。

下面是具體的示意圖(來源:learncpp):

虛函數(shù)表

所以,下面的代碼就有了很好的解釋:

int main()
{
    D1 d1;   // d1中的*__vptr指向類D1的虛表
    Base *dPtr = &d1;  // dPtr對*__vptr是可見的,但是實際上其指向的是D1的虛表;
    dPtr->function1();  // 此時dPtr通過虛表查找,調(diào)用的是D1::function1()
}

使用虛表技術(shù),虛函數(shù)得以正確實現(xiàn)!從而實現(xiàn)多態(tài)性!

純虛函數(shù)與抽象基類

有時候,基類的某個虛方法并不需要實現(xiàn),但是希望派生類能夠提供重寫的版本。這個時候,你需要定義純虛函數(shù)。純虛函數(shù)在類的定義中顯示說明該方法不需要實現(xiàn),其作用在于指明派生類必須要重寫它。純虛函數(shù)的定義很簡單:方法聲明后緊跟著=0。如果一個類中至少含有一個純虛函數(shù),那么這個類是抽象基類,因為這個類無法實例化。當(dāng)繼承一個抽象類時,必須重寫所有純虛函數(shù),否則繼承出來的類也是一個抽象類。下面演示例子:

class Animal
{
public:
    Animal(const string& name):
        m_name{name}
    {}

    const string& getName() const
    {
        return m_name;
    }

    virtual string speak() const = 0;  // 純虛函數(shù)
    // 因為包含一個純虛方法,所以是抽象基類

private:
    string m_name;
};

class Cat : public Animal
{
public:
    Cat(const string& name): 
        Animal(name)
    {}

    // 重寫了純虛方法,所以Cat不是抽象類,可以實例化
    virtual string speak() const
    {
        return "Meow";
    }
};

// Dog沒有重寫基類的純虛方法,所以仍然無法實例化
class Dog : public Animal
{
public:
    Dog(const string& name):
        Animal(name)
    {}
};

int main()
{
    // Animal animal{"luly"}; // 無法編譯,因為抽象基類無法實例化
    Cat cat{ "Sally" };      // 合法
    // Dog dog{ "Betsy" };      // 非法,抽象類無法實例化

    // 下面的代碼可以運行,因為可以指向可以實例化的派生類對象
    Animal* aPtr = new Cat{ "Sally" }; 
    cin.ignore(10);
        return 0;
}

抽象類至少包含一個純虛方法,抽象類提供了一種禁止其他代碼直接實例化對象的方法,但是重寫純虛方法的派生類可以實例化。

接口類

接口是一個抽象的概念,使用者只關(guān)注功能而不要求了解實現(xiàn)。一個接口類可以看成一些純虛方法的集合,這意味著接口類僅有定義功能,而沒有具體的實現(xiàn)。C++ 其實沒有單獨的接口概念,而在Java和C#等語言中接口是與類相區(qū)別的。但是 C++ 仍然可以使用接口類實現(xiàn)類似的效果。有時候,我們也稱接口類為純抽象類,因為這個類中全是虛方法。下面是一個純抽象類的例子:

// 樂器純抽象類
class Instrument
{
public:
    virtual void play() const = 0;
    virtual string what() const = 0;
    virtual void adjust(int) = 0;
};

class Wind: public Instrument
{
public:
    virtual void play() const override
    {
        cout << "Wind: paly" << endl;
    }

    virtual string what() const override
    {
        return "Wind";
    }

    virtual void adjust(int i) override {}
};

class Brass : public Instrument
{
public:
    virtual void play() const override
    {
        cout << "Brass: paly" << endl;
    }

    virtual string what() const override
    {
        return "Brass";
    }

    virtual void adjust(int i) override {}
};

void tune(Instrument& i)
{
    // ...
    i.play();
}

void f(Instrument& i)
{
    i.adjust(1);
}

int main()
{
    
    Wind wind;
    Brass brass;
    tune(wind);
    tune(brass);
    f(wind);
    f(brass);
        return 0;
}

可以看到Instrument是一個純抽象類,其只提供方法的聲明,具體卻沒有實現(xiàn)。但是它的兩個派生類分別重寫了這些純虛方法,因此可以實例化。并且兩個函數(shù)可以接收任意繼承了Instrument的類實例對象。進(jìn)一步說,這兩個函數(shù)僅關(guān)注接收的對象是否提供了Instrument所要求的接口,但是不關(guān)注具體是怎么實現(xiàn)的。純抽象類提供了更高級的抽象!這符合OOP的思想。

虛基類

虛基類主要是用來解決菱形層次結(jié)構(gòu)中的歧義基類問題。菱形層次結(jié)構(gòu)是多重繼承中的一個典例,還是例子說話:

class PoweredDevice
{
public:
    PoweredDevice(int power)
    {
        cout << "PoweredDevice: " << power << endl;
    }

    virtual void reportError() { cout << "Error" << endl; }
};

class Scanner : public PoweredDevice
{
public:
    Scanner(int scanner, int power) :
        PoweredDevice(power)
    {
        cout << "Scanner: " << scanner << endl;
    }
};

class Printer : public PoweredDevice
{
public:
    Printer(int printer, int power) :
        PoweredDevice(power)
    {
        cout << "Printer: " << printer << endl;
    }
};

class Copier : public Scanner, public Printer
{
public:
    Copier(int scanner, int printer, int power):
        Scanner(scanner, power), Printer(printer, power)
    {}
};
int main()
{
    Copier copier(1, 2, 3);
    // output:
    // PoweredDevice: 3
    // Scanner : 1
    // PoweredDevice : 3
    // Printer : 2
    // 可以看到PoweredDevice被繼承了兩次

    // 無法編譯,有歧義,因為繼承了兩個版本的PoweredDevice
    copier.reportError();   
        return 0;
}

上面的繼承關(guān)系有點復(fù)雜,但是畫出繼承圖譜(來源:learncpp)就很清晰了:

多重繼承圖譜

Scanner和Printer分別繼承了PoweredDevice類,然后Copier又同時繼承了Scanner和Printer類,我們實際希望Copier僅繼承一次PoweredDevice類,但是實際上Copier包含了兩個版本的PoweredDevice。所以可以看到,次PoweredDevice被構(gòu)造了兩次。而且更嚴(yán)重的是,PoweredDevice中沒有被重寫的方法是無法調(diào)用的,因為編譯器會給出一個有歧義的錯誤!解決這個錯誤的方法很多,比如你可以在Copier類中明確聲明繼承的版本:using Scanner::PoweredDevice::reportError;。但是這本質(zhì)上沒有解決多版本的繼承問題。

此時,你可以用虛基類,使用虛基類,只需要在繼承列表中加上virtual關(guān)鍵字:

class PoweredDevice
{
public:
    PoweredDevice(int power)
    {
        cout << "PoweredDevice: " << power << endl;
    }

    virtual void reportError() { cout << "Error" << endl; }
};

class Scanner : virtual public PoweredDevice
{
public:
    Scanner(int scanner, int power) :
        PoweredDevice(power)
    {
        cout << "Scanner: " << scanner << endl;
    }
};

class Printer : virtual public PoweredDevice
{
public:
    Printer(int printer, int power) :
        PoweredDevice(power)
    {
        cout << "Printer: " << printer << endl;
    }
};

class Copier : public Scanner, public Printer
{
public:
    // Note: 虛基類是由派生最遠(yuǎn)的類負(fù)責(zé)創(chuàng)建,所以,
    //       構(gòu)造函數(shù)初始化列表中需要增加虛基類的構(gòu)造函數(shù)調(diào)用
    Copier(int scanner, int printer, int power):
        Scanner(scanner, power), Printer(printer, power),
        PoweredDevice(power)
    {}
    
};
int main()
{
    Copier copier(1, 2, 3);

    // 合法
    copier.reportError(); 

    // output:
    // PoweredDevice: 3
    // Scanner : 1
    // Printer : 2
    // 可以看到PoweredDevice繼承了一次
    
    return 0;
}

利用虛基類,可以解決上面多重繼承中歧義基類問題,基類僅被繼承一次。但是要注意的是此時的虛基類由派生最遠(yuǎn)的類負(fù)責(zé)創(chuàng)建(可以看成該類的直接基類),因為PoweredDevice并沒有無參構(gòu)造函數(shù),所以在Copier構(gòu)造函數(shù)初始化列表中必須加上PoweredDevice的有參構(gòu)造函數(shù)調(diào)用!

說點題外話,盡管虛基類可以解決多重繼承中的菱形層次結(jié)構(gòu),但是看起來還是很抽象與復(fù)雜。實際上,多重繼承本來就是一個很有爭議的話題,因為使用多重繼承會使得繼承體系變得復(fù)雜,而且產(chǎn)生一系列問題,像Java和C#這類語言,是不允許多重繼承的,但是其單獨提供了接口,類可以繼承多個接口,這也相當(dāng)于多重繼承了。而且好處是接口的繼承相當(dāng)于組合,這也是比較推崇的!

對象切片

前面講過,實現(xiàn)虛函數(shù)及多態(tài)性必須要用傳地址的方式(引用或者指針)。一般,地址具有相同的長度,這意味著派生類對象的地址與基類對象的地址也是相同,盡管派生類對象所占的內(nèi)存一般要高過基類對象。所以,傳地址的方式不會導(dǎo)致類型信息損失,進(jìn)而可以實現(xiàn)多態(tài)性??聪旅娴睦樱?/p>

class Base
{
public:
    Base(int value):
        m_value{value}
    {}

    virtual string getName() const { return "Base"; }
    int getValue() const { return m_value; }
protected:
    int m_value;
};

class Derived: public Base
{
public:
    Derived(int value):
        Base(value)
    {}

    virtual string getName() const override { return "Derived"; }
}

int main()
{
    Derived derived{ 5 };
    cout << "derived is a " << derived.getName() << " with value " << derived.getValue() << endl;
    // output: derived is a Derived with value 5
    Base& ref = derived;
    cout << "ref is a " << ref.getName() << " with value " << ref.getValue() << endl;
    // output: ref is a Derived with value 5
    Base* ptr = &derived;
    cout << "ptr is a " << ptr->getName() << " with value" << ptr->getValue() << endl;
    // output: ptr is a Derived with value 5
    Base base = derived;
    cout << "base is a " << base.getName() << " with value " << base.getValue() << endl;
    // output: base is a Base with value 5
    return 0;
}

可以看到使用引用或者指針的方式,多態(tài)性都能夠?qū)崿F(xiàn),但是傳值的方式就存在問題。當(dāng)我們將一個派生類對象直接賦值給基類對象時,僅僅基類的部分被復(fù)制,派生類的那部分信息將丟失。我們稱這種現(xiàn)象為“對象切片”:對象丟失了自己原有的部分信息。使用對象本身并沒有問題,但是處理不當(dāng),會造成很多問題,看下面的例子:

int main()
{
    Derived d1{5};
    Derived d2{2};
    Base& b = d2;
    b = d1;   // 有隱患
    return 0;
}

上面的例子很簡單,但是會有問題:首先d2引用給b時,b將指向d2,這沒有問題。但是將d1的值直接賦值給b時,會發(fā)生對象切片,只有d1的基類部分復(fù)制給b。此時,問題來了,你會發(fā)現(xiàn)現(xiàn)在d2擁有d1的基類部分與d2的派生部分,這顯得很混亂!所以,盡可能地別使用對象切片,否則你會麻煩不斷!

動態(tài)轉(zhuǎn)型

前面的例子,我們都是將派生類對象復(fù)制給基類對象,不管是通過傳地址的方式還是對象切片方式。這些都是向上轉(zhuǎn)型——在類層次中向上移動。我們不禁會想,肯定會存在可以向下移動的向下轉(zhuǎn)型。一般來說,派生類包含基類信息,所以向上轉(zhuǎn)型是容易的。但是,反過來可能會失??!因為無法保證基類對象實際上存儲的是派生類對象??聪旅娴睦樱?/p>

void process(Base* ptr)
{
    Derived* derived = static_cast<Derived*>(ptr);
    // 后序處理
    // ...
}

process函數(shù)接收一個基類指針,但是在內(nèi)部使用static_cast向下轉(zhuǎn)型為派生類指針,然后進(jìn)行后序處理。如果送入process函數(shù)的指針實際上就是指向派生類對象,那么上面的代碼是沒有問題的。但是,如果僅僅傳入就是指向基類對象的指針,或者指向其他派生類的指針,那么函數(shù)內(nèi)部的轉(zhuǎn)型將存在問題:由于static_cast在運行時是不檢查對象實際類型的,這將導(dǎo)致不可控行為!

為了解決這樣的隱患,C++引入了運行時的動態(tài)類型轉(zhuǎn)化操作符dynamic_cast。dynamic_cast在運行時檢測底層對象的類型信息。如果類型轉(zhuǎn)換沒有意義,那么它將返回一個空指針(對于指針類型)或者拋出一個std::bad_cast異常(對于引用類型)。所以,可以修改上面的代碼如下:

void process(Base* ptr)
{
    Derived* derived = dynamic_cast<Derived*>(ptr);
    if (derived == nullptr)
    {
        // 后序處理
        // ...
    }
}

盡管如此,向下轉(zhuǎn)型還是不推薦的,除非必要!

Reference

[1] cpp leraning online(本文按照該教程書寫,作者人很nice,可以直接留言).
[2] Marc Gregoire. Professional C++, Third Edition, 2016.
[3] cppreference
[4] Bruce Eckel, Chuck Allison. Thinking in C++, Second Edition, 2011.

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • 參考來源:知乎 定義一個函數(shù)為虛函數(shù),不代表函數(shù)為不被實現(xiàn)的函數(shù)。定義他為虛函數(shù)是為了允許用基類的指針來調(diào)用子類的...
    夜幕青雨閱讀 892評論 0 6
  • 什么是繼承?什么是多重繼承?多重繼承存在變量和函數(shù)名沖突怎么辦?子類對象和父類對象的內(nèi)存模型是什么樣的?虛繼承如何...
    金戈大王閱讀 2,710評論 3 12
  • 說明:本系列文章翻譯自Android官方文檔。分為四篇:android monkeyrunner 官方文檔andr...
    lovexiaov閱讀 966評論 1 1
  • 一般的感謝,都是讓人感到舒服的,可是你有沒有遇到過那種會讓你感到心疼的感謝。我曾經(jīng)就有,每每想起,心口就隱隱作痛,...
    慢跑的小暖閱讀 751評論 1 4
  • 拜讀得到各位老師的文章一段時間了,剛剛開始的時候比較興奮,似乎在平平淡淡的生活中聽到了悠揚的音樂,發(fā)現(xiàn)了新的趣味,...
    LvJack閱讀 195評論 0 0

友情鏈接更多精彩內(nèi)容