C++虛函數

C++虛函數

C++虛函數是多態性實現的重要方式,當某個虛函數通過指針或者引用調用時,編譯器產生的代碼直到運行時才能確定到底調用哪個版本的函數。被調用的函數是與綁定到指針或者引用上的對象的動態類型相匹配的那個。因此,借助虛函數,我們可以實現多態性。這也是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";
    }
};

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

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;
}

但是輸出并不是預期的那樣:

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 ???

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

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

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

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

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

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

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

所以,你需要虛函數!

虛函數與多態性

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

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";
    }
};

此時,再測試一下下面的代碼,可以看到輸出實現了預期的效果:

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

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

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

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

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)
    {
        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;
}

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

final標識符

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

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

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

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

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

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; }
};

協變返回類型

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

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

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

析構函數要聲明為虛函數

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

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;
};

如果執行下面的代碼:

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!

可以看到,派生類的析構函數沒有執行,其數據成員Resource也沒有被析構。但是如果你將析構函數都聲明為虛函數,上面的代碼將得到如下的結果:

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

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

函數調用捆綁

要想深刻理解虛函數機理,首先要了解函數調用捆綁機制。捆綁指的是將標識符(如變量名與函數名)轉化為地址。這里我們僅僅關注有關函數調用的捆綁。我們知道每個函數在編譯的過程中是存在一個唯一的地址的。如果我們在程序段里面直接調用某個函數,那么編譯器或者鏈接器會直接將函數標識符替換為一個機器地址。這種方式是早捆綁,或者說是靜態捆綁。因為捆綁是在程序運行之前完成的。看下面的簡單例子:

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)
    {
        // 使用早綁定來直接調用函數
        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;
}

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

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

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(*opFun)(int, int) = nullptr;
    switch (op)
    {
        // 使用早捆綁來直接調用函數
        case 0: opFun = add; break;
        case 1: opFun = subtract; break;
        case 2: opFun = multiply; break;
    }

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

        return 0;
}

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

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

虛函數表(Vtable)

C++使用了一種稱為“虛表”的晚捆綁技術來實現虛函數。虛表是一個函數查詢表,以動態捆綁的方式解析函數調用。每個具有一個或者多個虛函數的類都有一張虛表,這個表是在編譯階段建立的靜態數組,其中包含了每個虛方法的函數指針,這些指針指向的是該類可見的派生最遠的函數實現。其次,編譯器會在基類對象都會添加一個隱含指針,這里我們稱為*__vptr。這個指針當然能夠被派生類所繼承,這相當重要。當類的實例被創建時,這個指針指向該類所對應的虛表。這樣,當使用某個對象調用虛方法時,通過該指針查找虛表,然后根據實際的對象類型執行正確版本的方法調用。看下面的簡單例子:

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()虛方法。編譯器會相應地創建3個不同的虛表,分別對應每個類。而且編譯器也會自動地為基類添加一個函數指針,如下所示:

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 { }
}

這樣,每個類實例創建時,*__vptr將指向該類所對應的虛表,比如基類的一個實例創建時,這個指鎮就指向基類的虛表。

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

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

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

虛函數表

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

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

使用虛表技術,虛函數得以正確實現!從而實現多態性!

純虛函數與抽象基類

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

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

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

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

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;
}

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

接口類

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

// 樂器純抽象類
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是一個純抽象類,其只提供方法的聲明,具體卻沒有實現。但是它的兩個派生類分別重寫了這些純虛方法,因此可以實例化。并且兩個函數可以接收任意繼承了Instrument的類實例對象。進一步說,這兩個函數僅關注接收的對象是否提供了Instrument所要求的接口,但是不關注具體是怎么實現的。純抽象類提供了更高級的抽象!這符合OOP的思想。

虛基類

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

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;
}

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

多重繼承圖譜

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

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

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: 虛基類是由派生最遠的類負責創建,所以,
    //       構造函數初始化列表中需要增加虛基類的構造函數調用
    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;
}

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

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

對象切片

前面講過,實現虛函數及多態性必須要用傳地址的方式(引用或者指針)。一般,地址具有相同的長度,這意味著派生類對象的地址與基類對象的地址也是相同,盡管派生類對象所占的內存一般要高過基類對象。所以,傳地址的方式不會導致類型信息損失,進而可以實現多態性。看下面的例子:

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;
}

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

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

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

動態轉型

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

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

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

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

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

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

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.

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 229,908評論 6 541
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,324評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,018評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,675評論 1 317
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,417評論 6 412
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,783評論 1 329
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,779評論 3 446
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,960評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,522評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,267評論 3 358
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,471評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,009評論 5 363
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,698評論 3 348
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,099評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,386評論 1 294
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,204評論 3 398
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,436評論 2 378

推薦閱讀更多精彩內容

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