多态

本文详细探讨了C++中的多态性,包括向上类型转换、虚函数、抽象基类和纯虚函数的概念及实现。通过实例展示了如何通过虚函数实现动态绑定,解释了类型信息的存放和VTABLE的作用。同时,文章讨论了构造函数和析构函数中的虚函数行为,以及向下类型转换的安全方式。

多态


向上类型转换

对象可以作为它自己的类或它的基类的对象来使用。另外,还能通过基类的地址操作它。取一个对象的地址(指针或引用),并将其作为基类的地址来使用,这被称为向上类型转换(upcasting)。甚至可以直接把一个派生类的对象赋值给基类的对象。

#include <iostream>
using namespace std;
enum note{ middleC, Csharp, Eflat };

class Instrument{
public:
    void play(note) const{
        cout << "Instrument::play" << endl;
    }
};

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

void tune(Instrument& i){
    i.play(middleC);
}
int main(){
    Wind flute;
    tune(flute);    //Upcasting
}

函数tune()通过引用接受一个Instrument,同样能够接受一个从Instrument继承而来的类的对象。这里无需类型转换,就能将wind对象传给tune()。在instrument中的接口必定存在于wind中。windinstrument的向上类型转换会使wind的接口变窄,但是不会窄过instrument的整个接口。
上面程序的输出是:

Instrument::play

我们传递给tune()的参数是Wind&,他也是Instrument&,所以它调用了Instrument::play()。然而实际中,我们可能并不想这么做,我们想要派生类对象使用派生类的方法,即调用Wind::play(),虽然tune()函数的形参类型是基类指针。

仔细思考一下tune()函数的执行过程。它接受一个Instrument地址,由于继承是一种is-a的关系,它也可以接受一个instrument派生类对象的地址,这种向上类型转换是自然的。在tune()函数内部,它调用这个对象的play()方法。这个函数不知道Instrument有多少种派生类,但是它需要对每一个派生类做出相应的play()调用,那么它怎么由一个对象地址得到该对象所需要的play函数地址呢?程序编译期间是无法确定的,因为只有在程序执行的过程中才知道传递给tune()的形参的类型(我们就是要这种功能)。这就需要这样一个地址能够自己说明自己的类型信息,即它在继承树的位置(它是Instrument还是Wind)。

虚函数

上面讲到的是动态绑定技术,一个函数调用只有在实际运行期间才能知道函数入口地址。它与普通的函数调用有很大差别,前面的例子程序就是普通函数。为了引起动态绑定,C++要求在基类中声明这个函数的时候使用virtual关键字(定义的时候并不需要)。动态绑定只对virtual函数起作用。如果一个基类的函数是virtual的,那么他的派生类中的此函数也是virtual的(可以不用virtual关键字声明)。在派生类中virtual的函数的定义叫重写(overriding)(一般的函数的定义叫重定义)。

前面的例子中在Instrument类中play()的声明前加virtual,程序的输出就变成了Wind::play.

#include <iostream>

using namespace std;

enum note{ middleC, Csharp, Cflat};

class Instrument{
public:
    virtual void play(note) const {
        cout << "Instrument::play\n";
    }
    virtual char* what() const{
        return "Instrument";
    }
    virtual void adjust(int){}
};

class Wind: public Instrument{
public:
    void play(note)const {
        cout << "Wind::play\n";
    }
    char* what() const{
        return "Wind";
    }
    void adjust(int){}
};

class Percussion: public Instrument{
public:
    void play(note) const {
        cout << "Percussion::play\n";
    }
    char* what() const { return "Percussion";}
    void adjust(int){}
};

class Stringed: public Instrument{
public:
    void play(note) const {
        cout << "Stringer::play\n";
    }
    char* what() const {return "Stringed";}
    void adjust(int){}
};

class Brass: public Wind{
public:
    void play(note) const{
        cout << "Brass::play" << endl;
    }
    char* what() const { return "Brass";}
};

class WoodWind: public Wind{
public:
    void play(note) const {
        cout << "WoodWind::play" << endl;
    }
    char* what() const { return "WoodWind";}
};

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

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

Instrument* a[] = {
        new Wind,
        new Percussion,
        new Stringed,
        new Brass
};

int main(){
    Wind flute;
    Percussion drum;
    Stringed violin;
    Brass flugelhorn;
    WoodWind recorder;
    tune(flute);
    tune(drum);
    tune(violin);
    tune(flugelhorn);
    tune(recorder);
    f(flugelhorn);
    for(int i = 0; i < 4; i++)
        tune(*a[i]);
}

运行结果:

Wind::play
Percussion::play
Stringer::play
Brass::play
WoodWind::play
Wind::play
Percussion::play
Stringer::play
Brass::play

这个例子只是多了几个继承类和层次,virtual机制同样工作。注意f(flugelhorn这个调用,flugelhornBrass类,它继承自WindWind继承自Instrument),它Wind中有虚函数adjust,而Brass类中没有定义相应的虚函数,这时候他就使用继承层次中最近的那个虚函数。即Wind中的adjust
虚函数机制

如何实现动态捆绑

虚函数机制也就是动态捆绑,它的实现需要编译器添加额外的代码或数据。关键字virtual告诉编译器它不应该执行静态捆绑,相反,它应该自动安装实现动态捆绑所需要的机制。这意味着,如果对Brass对象通过基类Instrument地址调用play(),将得到恰当的函数。

为了达到这个目的,典型的编译器(当然还有其他的实现方法)对每个包含虚函数的类创建一个表(称为VTABLE)。在VTABLE中,编译器放置特定类的虚函数的地址。在每个带有虚函数的类中,编译器秘密地放置一个指针,称为vpointer(VPTR),指向这个对象的VTABLE。当通过基类指针做虚函数调用时,编译器静态地插入能取得这个VPTR并在VTABLE中查找函数地址的代码,这样就能调用正确的函数并引起动态捆绑的发生。

为每个类设置VTABLE、初始化VPTR、为虚函数调用插入代码,所有这些都是自动发生的,不用程序员关心。利用虚函数,即使在编译器还不知道这个对象的特定类型的情况下,也能调用这个对象中正确的函数。

具体细节

存放类型信息

在任何类中不存在显式的类型信息。而先前的例子告诉我们,必须有一些类型信息放在对象中;否则,类型将不能再运行时建立。

下面例子展示含有虚函数的类和普通的类的不同:

#include <iostream>
using namespace std;

class NoVirtual{
    int a;
public:
    void x() const{}
    int i() const{ return 1;}
};

class OneVirtual{
    int a;
public:
    virtual void x() const{}
    int i() const { return 1;}
};

class TwoVirtual{
    int a;
public:
    virtual void x() const{}
    virtual int i() const{ return 1;}
};

int main(){
    cout << "int: " << sizeof(int) << endl;
    cout << "NoVirtual: " << sizeof(NoVirtual) << endl;
    cout << "OneVirtual: "<< sizeof(OneVirtual) << endl;
    cout << "TwoVirtual: " << sizeof(TwoVirtual) << endl;
}

运行结果:

int: 4
NoVirtual: 4
OneVirtual: 8
TwoVirtual: 8

不带虚函数的类的长度就是数据成员的总长度,而带有单个虚函数的类OneVirtual的对象的长度是NoVirtual的长度加上一个指针的长度。它反映出,如果有一个或多个虚函数,编译器都只加入一个VPTR。这是因为VPTR指向一个存放函数地址的表,我们只需要一个表,所有的虚函数地址都在表中。
这个例子需要一个数据成员。没有数据成员的类编译器也强制他有非零的长度,否则无法存放。

对于前面的instrument类的例子,这个Instrument的指针数组没有特殊的类型信息,它的每一个元素都指向一个类型为Instrument的对象。Wind,Percussion,Stringed和Brass都可以归入这个类别之中,所以他们的地址自然的放入这个数组。然而,编译器并不知道他们是比Instrument具有更多内容的东西,所以就将他们留给自己的设备处理。
每当创建一个包含虚函数的类或从包含虚函数的类派生出一个类时,编译器就为这个类创建一个唯一的VTABLE。在这个表中,编译器放置了在这个类中或在它的基类中所有已经声明为virtual的函数的地址。如果在这个派生类中没有对基类中的虚函数进行重写,编译器就是用基类的虚函数地址(在Brass类中的adjust函数就是这种情况)。

如果能看到由虚函数调用而产生的汇编代码,这将是很有帮助的。下面是在函数f(Instrument& i)内部调用
i.adjust(1);
某个编译器所产生的输出:

push 1
push si
mov bx, word ptr [si]
call word ptr [bx + 4]
add sp ,4

C++函数调用的参数与C函数调用一样,是从右向左进栈的,所以参数1首先压栈。对于这个函数,寄存器si存放i的地址。因为他是被选中对象的首地址,它也被压栈。这个首地址对应于this的值,所以成员函数知道他工作在那个特殊的对象上。
现在,必须实现实际的虚函数调用。首先,必须产生VPTR,使得能够找到VTABLE。对于这个编译器,VPTR在对象的开头,所以this的内容对应于VPTR。下面这一行
mov bx, word ptr [si]
取出si(即this)所指的字,他就是VPTR。将这个VPTR放到寄存器bx中。
放在bx中的内容是VTABLE的首地址,但被调用的函数不是在VTABLE中第0个位置,而是在第二个位置(因为他是这个表中的第三个函数)。这种内存下,地址占用两个字节。所以对bx + 4再间接寻址。然后执行call。

因为VPTR决定了对象的虚函数的行为,所以我们可以看到VPTR指向相应的VTABLE是多么重要。在VPTR适当初始化之前绝对不能调用虚函数。当然,可以保证初始化的地点是在构造函数中。幸运的是,编译器已经自己帮我们做好了。

C++是注重效率的。如果编译器有一个它知道确切类型的对象,那么尽管这个类包含虚函数,编译器也可以不使用动态绑定,这样直接使用Call的函数调用会更高效而且不会引起问题。比如对于对象(注意,是对象,若是地址,信息就不够确定他的类型)进行成员函数调用,编译器可以直接确定待函数的函数是哪个,就把从VPTR找VTABLE,从VTABLE找虚函数入口地址省掉,直接变成一条call语句。

抽象基类和纯虚函数

在程序设计时,常常希望基类仅仅作为其派生类的接口,而不想用户实际地创建一个积累对象。要做到这点,可以在基类中加入至少一个纯虚函数(pure virtual function)。来使基类称为抽象类(abstract)。纯虚函数使用关键字virtual,并且在其后面加上=0。如果试图用抽象类定义一个对象,编译器会报错。

当继承一个抽象类时,派生类需要实现所有的纯虚函数,否则它也会称为抽象类。如果一个类中全是抽象函数,那么也称作纯抽象类。

建立公共接口的唯一原因是它能够对于每个不同的子类有不同的表示。它建立一个基本的格式,用来确定什么是对于所有的派生类是公有的。

从VTABLE的角度来说。定义一个纯虚函数,等于是告诉编译器在VTABLE中为函数保留一个位置,但这个位置中不放任何地址。只要有一个函数在类中被声明为纯虚函数,则VTABLE就是不完全的。那么,当试图创建这个类的对象的时候,编译器就不能安全的创建一个抽象类的对象。

纯虚函数禁止对抽象类的函数以传值方式调用。通过抽象类,可以保证在向上类型转换期间总是使用指针或引用。

纯虚定义

在基类中,对纯虚函数提供定义是可能的。我们仍然告诉编译器不允许产生抽象基类的对象,而且如果要创建派生类的对象,该派生类必须定义了对应的虚函数。然而,我们可能希望一段公共代码,使一些或所有的派生类定义都能调用,而不必在每个函数中重复这段代码。

例如:

#include <iostream>

using namespace std;

class Pet{
public:
    virtual void speak() const = 0;
    virtual void eat() const = 0;
    //inline pure virtual function illegal;
    //! virtual void sleep() const = 0 {}
};

//Ok,not defined inline
void Pet::eat() const{
    cout << "Pet::eat\n";
}

void Pet::speak() const {
    cout << "Pet::speak\n";
}

class Dog: public Pet{
public:
    //Use the common Pet code:
    void speak() const { Pet::speak(); }
    void eat() const { Pet::eat(); }
};

int main(){
    Dog simba;
    simba.speak();
    simba.eat();
}

运行结果:

Pet::speak
Pet::eat

Pet的VTABLE仍然空着,但这个派生类中刚好有一个函数,可以通过名字调用它。


可以想象,当实现继承和重新定义一些虚函数时,编译器对新类创建一个新的VTALBE,并且创建新函数的地址,对于没有重新定义的虚函数使用基类函数的地址。无论如何,对于每个可创建的额对象(即它的类不含有纯虚函数),在VTABLE中总有一个函数地址的全集,所以绝对不能对不在其中的地址进行调用。
但是在派生类中继承或增加新的虚函数时会发生什么呢?

#include <iostream>
#include <string>

using namespace std;

class Pet{
    string pname;
public:
    Pet(const string& petName): pname(petName){}
    virtual string name() const {return pname;}
    virtual string speak() const {return "";}
};

class Dog: public Pet{
    string name;
public:
    Dog(const string& petName): Pet(petName){}
    //New virtual function in Dog class
    virtual string sit() const {
        return Pet::name() + " sits ";
    }
    string speak() const {
        return Pet::name() + " says bark! ";
    }
};

int main(){
    Pet* p[] = {new Pet("generic"),new Dog("bob") };
    cout << "p[0]->speak() = " << p[0]->speak() << endl;
    cout << "P[1]->speak() = " << p[1]->speak() << endl;
    //! cout << "p[1]->sit() = " << p[1]->sit() << endl;
}

类Pet中含有两个虚函数:speak()和name(),而在类Dog中又增加了第三个称为sit()的虚函数,并且重新定义了speak()的含义。编译器在Dog的VTABLE中把speak()的偏移与基类VTABLE中speak()的偏移相同。因为编译器产生的代码在VTABLE中使用一个简单的偏移来选择虚函数。不论对象属于哪个特殊的类,它的VTABLE都是以同样的方法设置,所以对虚函数的调用总是使用同样的方法。

然而这里,编译器只对指向基类的指针进行操作。而这个基类只有speak和name函数,所以它就是编译器唯一允许调用的函数。那么,如果只有基类的指针,那么编译器怎么可能知道自己正在对Dog对象进行工作呢?这个指针可能指向其他一些没有sit函数的类。在VTALBE中,可能有,也可能没有一些其他函数的地址,但无论如何,对这个VTALBE地址做虚函数调用都不是我们想做的。所以编译器通过防止我们对只存在与派生类中的函数做虚函数调用来完成工作。
有时,我们可能知道指针实际上指向的是哪一种特殊子类的对象。这时如果想调用只存在于这个子类中的函数,则必须类型转换这个指针。

((Dog*)p[1])->sit();

这里发生了向下类型转换,从基类指针到派生类指针。向上类型转换是自动进行的,无须强制。向下类型转换是不安全的,因为这里没有关于实际类型的编译时信息,所以必须准确地知道这个类实际上是什么类型。如果把它转换成错误的类型,就会出错。这实际是运行时类型确认问题(RTTI)。

对象切片

当多态地处理对象时,传地址与传值有明显的不同。地址都有相同的长度,传递派生类(它通常稍大一点)对象的地址和传递基类(通常小一点)对象的地址大小是相同的。这是使用多态的目的,即让对基类对象操作的代码也能透明地操作派生类对象。

如果对一个类型进行向上类型转换,而且不使用地址或引用,这个对象被‘切片’,直到剩下的是适合于目的的子对象。

#include <iostream>
#include <string>

using namespace std;

class Pet{
    string pname;
public:
    Pet(const string& name): pname(name){}
    virtual string name()const  {return pname;}
    virtual string description() const{ return "this is a" + pname;}
};

class Dog: public Pet{
    string favoriteActivity;
public:
    Dog(const string& name,const string& Acitivity)
        :Pet(name), favoriteActivity(Acitivity){}
    string description() const {
        return Pet::name() + "likes to " + favoriteActivity;
    }

};

void describe(Pet p){   //slice the object
    cout << p.description() << endl;
}

int main(){
    Pet p("Alice");
    Dog d("Bob","sleep");
    describe(p);
    describe(d);
}

运行结果:

this is aAlice
this is aBob

函数describe通过传值方式传递一个类型为pet的对象。然后对这个对象调用虚函数description。我们可能希望第一次调用产生”This is a Alice”,而第二次产生”Bob likes to sleep”。实际上,两次调用都是调用了基类版本的description。
在这个程序中发生了两件事。第一,describe接受的是Pet对象(而不是指针或引用),所以describe中的任何调用都将引起一个与Pet大小相同的对象压栈并在调用后清除。这意味着,如果一个由Pet派生而来的类的对象被传给describe,编译器会接受他,但是只拷贝这个对象的对应于Pet的部分,切除这个对象的派生部分。如图所示:
对象切片
这个切片过程发生在describe的实参拷贝初始化形参的过程中。形参毫无疑问是Pet类型,所以它的VPTR指向Pet类型的VTALBE,然后从实参的成员中拷贝所需要的成员。在调用description函数的时候,编译器根据对象的类型决定调用那个description,显然此时形参的类型是Pet。
对象切片发生在拷贝构造过程中。

重载和重新定义

重新定义一个基类中的重载函数将会隐藏所有该函数的其他基类版本。而当对虚函数进行这些操作时,情况会有所不同。

#include <iostream>
#include <string>

using namespace std;

class Base{
public:
    virtual int f() const {
        cout << "Base::f()\n";
        return 1;
    }
    virtual void f(string) const{}
    virtual void g() const{}
};

class Derived1: public Base{
public:
    void g() const{}
};

class Derived2: public Base{
public:
    int f() const {
        cout << "Derived2::f()\n";
        return 2;
    }
};

class Derived3: public Base{
public:
    //can not change return type
    //! void f() const {}
};

class Derived4:public Base{
public:
    //change argument
    int f(int) const{
        cout << "Derived4:f()\n";
        return 4;
    }
};

int main(){
    string s("hello");
    Derived1 d1;
    int x = d1.f();
    d1.f(s);
    Derived2 d2;
    x = d2.f();
    //! d2.f(s);    //string version hidden
    Derived4 d4;
    x = d4.f(1);
    //! d4.f();     //hidden
    //! d4.f(s);    //hidden
    Base& br = d4;
    //! br.f(1);    //couldn't find this member function in Base;
    br.f();     //ok
    br.f(s);    //ok
}

运行结果:

Base::f()
Derived2::f()
Derived4:f()
Base::f()

在Derived3中,编译器不允许我们改变重新定义过的函数的返回值(如果f()不是虚函数,这是允许的)。这很好理解,派生类可以使用基类的虚函数,而如果再定义一个同名同参而不同返回值的函数当然不行。
对于普通函数的规则仍然适用。如果重新定义了基类的一个重载成员函数,则在派生类中其他的重载函数版本将会被隐藏。

变量返回值类型

上例的类Derived3显示了我们不能在重新定义的过程中改变返回值类型。通常是这样的,但也有特例。如果返回一个指向基类的指针或引用,则该函数的重新定义版本可以返回一个指向派生类的指针或引用。

#include <iostream>
#include <string>

using namespace std;

class PetFood{
public:
    virtual string foodType() const = 0;
};

class Pet{
public:
    virtual string type() const = 0;
    virtual PetFood* eats() = 0;
};

class Bird: public Pet{
public:
    string type() const {return "Bird";}
    class BirdFood: public PetFood{
    public:
        string foodType() const {return "BirdFood";}
    };
    PetFood* eats() { return &bf;}
private:
    BirdFood bf;
};

class Cat: public Pet{
public:
    string type() const {return "Cat";}
    class CatFood: public PetFood{
    public:
        string foodType() const { return "Birds";}
    };
    CatFood* eats() { return &cf;}
private:
    CatFood cf;
};
int main(){
    Bird b;
    Cat c;
    Pet* p[] = {&b, &c};
    for(int i = 0; i < sizeof( p ) / sizeof(*p); i ++){
        cout << p[i]->type() << " eats " << p[i]->eats()->foodType()<< endl;
    }
    Cat::CatFood* cf = c.eats();
    Bird::BirdFood* bf;
    //! bf = b.eats();
    bf = dynamic_cast<Bird::BirdFood*>(b.eats());
}

虚函数和构造函数

当创建一个包含有虚函数的对象时,必须初始化它的VPTR以指向相应的VTABLE。这必须在对虚函数进行任何调用之前完成。生成一个对象是构造函数的工作,所以设置VPTR也是构造函数的任务。编译器在构造函数的开头秘密地插入能初始化VPTR的代码。如果我们没有为一个类显示定义构造函数,编译器会自动合成一个默认构造函数。如果该类有虚函数,则生成的构造函数也会设置VPTR。

构造函数调用顺序

所有的基类构造函数总是在继承类构造函数之前被调用。因为构造函数的工作就是建立对象。派生类只能访问它自己的成员,而不能访问基类的私有成员。只有基类的构造函数才能正确初始化它自己的成员。如果不在构造函数初始化列表中显式调用基类的构造函数,他就调用基类默认构造函数,如果没有默认构造函数,编译器报错。

虚函数在构造函数中的行为

构造函数的调用层次会导致一个有趣的问题。如果我们在构造函数中调用了虚函数,那会怎么样呢?虚函数的调用是在运行时决定的,因为编译时这个对象并不能知道它是属于这个成员函数的那个类,还是属于由它派生出来的类。
然而,对于在构造函数中调用一个虚函数的情况,被调用的只是这个函数的本地版本。也就是说,虚机制在构造函数中不工作。

这种行为有两个理由。在概念上,构造函数的工作是生成一个对象。在任何构造函数中,可能只是部分形成对象——我们只能知道基类已经被初始化,但不知道哪个类是从这个基类派生而来的。然而,虚函数在继承层次上是向前和向外进行调用。它可以调用在派生类中的函数。如果我们在构造函数中也这么做,那么我们所调用的函数可能操作还没有被初始化的成员,所以不行。

第二,当一个构造函数被调用时,它首先要初始化它的VPTR。然而,它只能知道它属于“当前”类——即构造函数所在的类。于是它完全忽视这个对象是否是基于其他类的。当编译器为这个构造函数产生代码时,它是为这个类的构造函数产生代码——既不是为基类,也不是为它的派生类(因为它不知道谁继承它)。所以他使用的VPTR必须是对于这个类的VTABLE。只要它是最后一个构造函数调用,那么这个VPTR就是对象最后的VPTR。如果接着还有一个更晚派生的类的构造函数被调用,那么这个派生类又将自己对应的VPTR覆盖旧的VPTR。所以,VPTR是由最后调用的构造函数确定的,这也是构造顺序为什么是从基类逐层到派生类底层的原因。

当这一系列构造函数调用正在发生时,每个构造函数都已经设置VPTR指向自己类的VTABLE。如果此时发生了虚函数调用(即构造函数内调用了虚函数),它将只能通过自己的VTABLE调用,而不是最后派生的VTABLE。另外,许多编译器认识到,如果在构造函数内进行虚函数调用(其实不虚),应该使用早捆绑,可以优化速度。

析构函数和虚拟析构函数

构造函数时不能为虚函数的,但是析构函数可以而且常常必须是虚的。

构造函数的工作就是一块一地组合成一个对象。它首先调用基类构造函数,然后再调用继承顺序中更晚的类的构造函数(同样,它也必须按照此方法调用成员对象的构造函数)。类似地,析构函数必须拆卸某层次的对象。为了完成这项工作,编译器生成代码来调用所有的析构函数,但它必须按照与构造函数调用相反的顺序。这就是,析构函数从最晚派生的类开始,并向上到基类。这是安全且合理的:当前析构函数一直知道基类成员仍是有效的。如果需要在析构函数中调用某一基类的成员函数,进行这样的操作是安全的。因此,析构函数能够对自身进行清除,然后调用下一个析构函数。每个析构函数知道它所在类从哪一个类派生而来,但不知道它派生出哪些类。

通常,析构函数的执行是相当充分的。但是,如果想通过指向某个对象基类的指针操作这个对象(也就是,通过一般的接口操作这个对象),会怎么样呢?

#include <iostream>
#include <string>

using namespace std;

class Base1{
public:
    ~Base1(){ cout << "Base1::~Base1()\n";}
};
class Derived1:public Base1{
public:
    ~Derived1(){ cout << "Derived1::Derived1()\n";}
};
class Base2{
public:
    virtual ~Base2() { cout << "Base2::~Base2()\n";}
};
class Derived2: public Base2{
public:
    ~Derived2() { cout << "Derived2:~Derived2()\n";}
};

int main(){
    Base1 *bp = new Derived1;   //Upcast
    delete bp;
    Base2* b2p = new Derived2;  //Upcast
    delete b2p;
}

运行结果:

Base1::~Base1()
Derived2:~Derived2()
Base2::~Base2()

从结果可以看出,delete bp只调用基类的析构函数。而当delete b2p调用时,在基类的析构函数执行后,派生类的析构函数将会执行,这正是我们所期望的。不把析构函数设为虚函数是一个隐匿的错误,因为它常常不会对程序有直接的影响,但要注意它不知不觉地引起了内存泄漏。

纯虚析构函数

尽管纯虚析构函数是合法的,但是使用时有一个限制:必须为纯虚析构函数提供一个函数体。这看起来有点违反常规;如果它需要一个函数体,那它又如何成为“纯”?类似于前面说到过的纯虚定义,一个纯虚函数可以提供定义,目的是为了代码重用。而纯虚析构函数必须有函数体,是因为对象的析构过程是逐层进行的,如果一个析构函数没有函数体,怎么体现这个析构函数被调用了呢。因此,编译器和连接器强迫纯虚析构函数一定有一个函数体。。

然而,当从某个含有纯虚析构函数的类中继承一个类,不要求在派生类中提供纯虚函数的定义。

#include <iostream>
using namespace std;

class AbstractBase{
public:
    virtual ~AbstractBase() = 0;
};
AbstractBase::~AbstractBase() {}

class Derived: public AbstractBase{

};
int main(){
    Derived d;
}

一般来说,如果在派生类中基类的纯虚函数(和其他纯虚函数)没有重新定义,则派生类将会成为抽象类。但是,如果不对析构函数定义,编译器将会自动地为每个类生成一个析构函数定义并且派生类不会成为抽象类。
那么问题来了,纯虚析构函数的目的是什么?
当我们的类仅含有一个纯虚函数时,就会发现这个唯一的差别:析构函数。析构函数的纯虚性的唯一效果就是阻止基类的实例化。如果有其他的纯虚函数,则它们也会阻止类的实例化,但如果没有那些纯虚函数,可以方便的定义一个纯虚析构函数来完成这个任务而没有副作用。

析构函数中的虚机制

如果在一个普通的成员函数中调用一个虚函数,则会使用动态绑定机制来调用这个函数。而对于析构函数,只有成员函数的“本地”版本被调用,虚机制被忽略。

#include <iostream>
using namespace std;

class Base{
public:
    virtual ~Base(){
        cout << "~Base()\n";
        f();
    }
    virtual void f() { cout << "Base::f()\n";}
};

class Derived: public Base{
public:
    ~Derived() { cout << "~Derived()\n";}
    void f() {cout << "Derived::f()\n";}
};
int main(){
    Base* bp = new Derived;
    delete bp;
}

运行结果:

~Derived()
~Base()
Base::f()

在析构函数的调用中,Derived::f()没有被调用,即使f()是一个虚函数。
这是因为:析构函数总是“外层”(最晚派生的析构函数想基类的析构函数),如果在某个层次的析构函数中使用虚机制,实际上被调用的函数可能属于低于当前派生层次的类,而这个函数就可能操作已经被消除的数据。所以,编译器决定在编译的时候只调用这个函数的本层次版本。注意,在构造函数中也是如此,那是因为在构造时,后续派生的类的信息还不可用,在析构函数中,信息(VPTR)虽然存在,但不可靠。

向下类型转换

不同于向上类型转换总是清除地知道基类(典型情况下是一个,也可以是多继承),当进行想下类型转换时,通常会有多种选择进行。C++提供了一个特殊的称为dynamic_cast的显式类型转换,它是一种安全类型向下类型转换的操作。当使用dynamic_cast向下转换成一个特殊的类型时,只有类型转换是正确并且成功时,返回值是一个所需类型的指针,否则为0。

#include <iostream>
using namespace std;

class Pet{
public:
    virtual ~Pet(){}
};

class Dog:public Pet{};

class Cat:public Pet{};

int main(){
    Pet *p  = new Cat;  //Upcast
    //Try to cast it to Dog
    Dog *d1 = dynamic_cast<Dog*>(p);
    //Try to cast it back to Cat
    Cat *c1 = dynamic_cast<Cat*>(p);
    cout << "p = "<< (long)p << endl;
    cout << "d1 = "<< (long)d1 << endl;
    cout << "c1 = "<< (long)c1 << endl;
    return 0;
}

运行结果:

p = 7019896
d1 = 0
c1 = 7019896

当使用dynamic_cast操作一个指针,他必须属于一个多态的类层次———它必须含有虚函数,因为dynamic_cast使用了存放在对象头部的VPTR即VTABLE信息来判断实际的类型。不正确的向下类型转换返回0,所以,无论何时进行向下类型转换都要检查返回值为非0。但我们不用确保指针的值完全一样,因为通常在向上类型转换和向下类型转换的时候指针会进行调整。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值