现在的位置: 首页 > 综合 > 正文

有关虚函数

2012年12月26日 ⁄ 综合 ⁄ 共 9001字 ⁄ 字号 评论关闭

1.简介
    
虚函数是C++中用于实现多态(polymorphism)的机制。核心理念就是通过基类访问派生类定义的函数。假设我们有下面的类层次:

class A
{
public:
    virtual void foo() { cout << "A::foo() is called" << endl;}
};

class B: public A
{
public:
    virtual void foo() { cout << "B::foo() is called" << endl;}
};

那么,在使用的时候,我们可以:
A * a = new B();
a->foo();       //
在这里,a虽然是指向A的指针,但是被调用的函数(foo)却是B!

    这个例子是虚函数的一个典型应用,通过这个例子,也许你就对虚函数有了一些概念。它虚就虚在所谓“推迟联编”或者“动态联编”上,一个类函数的调用并不是在编译时刻被确定的,而是在运行时刻被确定的。由于编写代码的时候并不能确定被调用的是基类的函数还是哪个派生类的函数,所以被成为“虚”函数。

    虚函数只能借助于指针或者引用来达到多态的效果,如果是下面这样的代码,则虽然是虚函数,但它不是多态的:
class A
{
public:
    virtual void foo();
};

class B: public A
{
    virtual void foo();
};

void bar()
{
    A a;
    a.foo();   // A::foo()被调用
}

1.1 多态
    
在了解了虚函数的意思之后,再考虑什么是多态就很容易了。仍然针对上面的类层次,但是使用的方法变的复杂了一些:
void bar(A * a)
{
    a->foo();  //
被调用的是A::foo() 还是B::foo()
}

因为foo()是个虚函数,所以在bar这个函数中,只根据这段代码,无从确定这里被调用的是A::foo()还是B::foo(),但是可以肯定的说:如果a指向的是A类的实例,则A::foo()被调用,如果a指向的是B类的实例,则B::foo()被调用。

这种同一代码可以产生不同效果的特点,被称为“多态”。

1.2 多态有什么用?
    
多态这么神奇,但是能用来做什么呢?这个命题我难以用一两句话概括,一般的C++教程(或者其它面向对象语言的教程)都用一个画图的例子来展示多态的用途,我就不再重复这个例子了,如果你不知道这个例子,随便找本书应该都有介绍。我试图从一个抽象的角度描述一下,回头再结合那个画图的例子,也许你就更容易理解。

    在面向对象的编程中,首先会针对数据进行抽象(确定基类)和继承(确定派生类),构成类层次。这个类层次的使用者在使用它们的时候,如果仍然在需要基类的时候写针对基类的代码,在需要派生类的时候写针对派生类的代码,就等于类层次完全暴露在使用者面前。如果这个类层次有任何的改变(增加了新类),都需要使用者“知道”(针对新类写代码)。这样就增加了类层次与其使用者之间的耦合,有人把这种情况列为程序中的“bad smell”之一。

    多态可以使程序员脱离这种窘境。再回头看看1.1中的例子,bar()作为A-B这个类层次的使用者,它并不知道这个类层次中有多少个类,每个类都叫什么,但是一样可以很好的工作,当有一个C类从A类派生出来后,bar()也不需要“知道”(修改)。这完全归功于多态--编译器针对虚函数产生了可以在运行时刻确定被调用函数的代码。

 

       简言之,多态是实现现代软件工程代码复用的基石!

1.3 如何“动态联编” (多态是如何实现的)
    
编译器是如何针对虚函数产生可以再运行时刻确定被调用函数的代码呢?也就是说,虚函数实际上是如何被编译器处理的呢?Lippman在深度探索C++对象模型[1]中的不同章节讲到了几种方式,这里把“标准的”方式简单介绍一下。

    我所说的“标准”方式,也就是所谓的“VTABLE”机制。编译器发现一个类中有被声明为virtual的函数,就会为其搞一个虚函数表,也就是VTABLEVTABLE实际上是一个函数指针的数组,每个虚函数占用这个数组的一个slot。一个类只有一个VTABLE,不管它有多少个实例。派生类有自己的VTABLE,但是派生类的VTABLE与基类的VTABLE有相同的函数排列顺序,同名的虚函数被放在两个数组的相同位置上。在创建类实例的时候,编译器还会在每个实例的内存布局中增加一个vptr字段,该字段指向本类的VTABLE。通过这些手段,编译器在看到一个虚函数调用的时候,就会将这个调用改写,针对1.1中的例子:
void bar(A * a)
{
    a->foo();
}

会被改写为:
void bar(A * a)
{
    (a->vptr[1])();
}

    因为派生类和基类的foo()函数具有相同的VTABLE索引,而他们的vptr又指向不同的VTABLE,因此通过这样的方法可以在运行时刻决定调用哪个foo()函数。

    虽然实际情况远非这么简单,但是基本原理大致如此。

1.4 overloadoverride
    
虚函数总是在派生类中被改写,这种改写被称为“override”。我经常混淆“overload”和“override”这两个单词。但是随着各类C++的书越来越多,后来的程序员也许不会再犯我犯过的错误了。但是我打算澄清一下:

override是指派生类重写基类的虚函数,就象我们前面B类中重写了A类中的foo()函数。重写的函数必须有一致的参数表和返回值(C++标准允许返回值不同的情况,这个我会在“语法”部分简单介绍,但是很少编译器支持这个feature)。这个单词好象一直没有什么合适的中文词汇来对应,有人译为“覆盖”,还贴切一些。
overload
约定成俗的被翻译为“重载”。是指编写一个与已有函数同名但是参数表不同的函数。例如一个函数即可以接受整型数作为参数,也可以接受浮点数作为参数。


2.
虚函数的语法
    虚函数的标志是“virtual”关键字。

2.1 使用virtual关键字
    
考虑下面的类层次:

class A
{
public:
    virtual void foo();
};

class B: public A
{
public:
    void foo();    // 没有virtual关键字!
};

class C: public B  // B继承,不是从A继承!
{
public:
    void foo();    //
也没有virtual关键字!
};

    这种情况下,B::foo()是虚函数,C::foo()也同样是虚函数。因此,可以说,基类声明的虚函数,在派生类中也是虚函数,即使不再使用virtual关键字。

2.2 纯虚函数
    
如下声明表示一个函数为纯虚函数:

class A
{
public:
    virtual void foo()=0;   // =0标志一个虚函数为纯虚函数
};

    一个函数声明为纯虚后,纯虚函数的意思是:我是一个抽象类!不要把我实例化!纯虚函数用来规范派生类的行为,实际上就是所谓的“接口”。它告诉使用者,我的派生类都会有这个函数。

2.3 虚析构函数
    
析构函数也可以是虚的,甚至是纯虚的。例如:

class A
{
public:
    virtual ~A()=0;   // 纯虚析构函数
};

    当一个类打算被用作其它类的基类时,它的析构函数必须是虚的。考虑下面的例子:

class A
{
public:
    A() { ptra_ = new char[10];}
    ~A() { delete[] ptra_;}        // 非虚析构函数
private:
    char * ptra_;
};

class B: public A
{
public:
    B() { ptrb_ = new char[20];}
    ~B() { delete[] ptrb_;}
private:
    char * ptrb_;
};

void foo()
{
    A * a = new B;
    delete a;
}

    在这个例子中,程序也许不会象你想象的那样运行,在执行delete a的时候,实际上只有A::~A()被调用了,而B类的析构函数并没有被调用!这是否有点儿可怕?

    如果将上面A::~A()改为virtual,就可以保证B::~B()也在delete a的时候被调用了。因此基类的析构函数都必须是virtual的。

    纯虚的析构函数并没有什么作用,是虚的就够了。通常只有在希望将一个类变成抽象类(不能实例化的类),而这个类又没有合适的函数可以被纯虚化的时候,可以使用纯虚的析构函数来达到目的。

2.4 虚构造函数?
    
构造函数不能是虚的。

3. 虚函数使用技巧

3.1 private的虚函数
    
考虑下面的例子:

class A
{
public:
    void foo() { bar();}
private:
    virtual void bar() { ...}
};

class B: public A
{
private:
    virtual void bar() { ...}
};

    在这个例子中,虽然bar()A类中是private的,但是仍然可以出现在派生类中,并仍然可以与public或者protected的虚函数一样产生多态的效果。并不会因为它是private的,就发生A::foo()不能访问B::bar()的情况,也不会发生B::bar()A::bar()override不起作用的情况。

    这种写法的语意是:A告诉B,你最好override我的bar()函数,但是你不要管它如何使用,也不要自己调用这个函数。

3.2 构造函数和析构函数中的虚函数调用
    
一个类的虚函数在它自己的构造函数和析构函数中被调用的时候,它们就变成普通函数了,不“虚”了。也就是说不能在构造函数和析构函数中让自己“多态”。例如:

class A
{
public:
    A() { foo();}        // 在这里,无论如何都是A::foo()被调用!
    ~A() { foo();}       //
同上
    virtual void foo();
};

class B: public A
{
public:
    virtual void foo();
};

void bar()
{
    A * a = new B;
    delete a;
}

    如果你希望delete a的时候,会导致B::foo()被调用,那么你就错了。同样,在new B的时候,A的构造函数被调用,但是在A的构造函数中,被调用的是A::foo()而不是B::foo()

3.3什么时候使用虚函数
    
在你设计一个基类的时候,如果发现一个函数需要在派生类里有不同的表现,那么它就应该是虚的。从设计的角度讲,出现在基类中的虚函数是接口,出现在派生类中的虚函数是接口的具体实现。通过这样的方法,就可以将对象的行为抽象化。

    以设计模式[2]Factory Method模式为例,CreatorfactoryMethod()就是虚函数,派生类override这个函数后,产生不同的Product类,被产生的Product类被基类的AnOperation()函数使用。基类的AnOperation()函数针对Product类进行操作,当然Product类一定也有多态(虚函数)。

    另外一个例子就是集合操作,假设你有一个以A类为基类的类层次,又用了一个std::vector<A *>来保存这个类层次中不同类的实例指针,那么你一定希望在对这个集合中的类进行操作的时候,不要把每个指针再cast回到它原来的类型(派生类),而是希望对他们进行同样的操作。那么就应该将这个“一样的操作”声明为virtual

    现实中,远不只我举的这两个例子,但是大的原则都是我前面说到的“如果发现一个函数需要在派生类里有不同的表现,那么它就应该是虚的”。这句话也可以反过来说:“如果你发现基类提供了虚函数,那么你最好override它”。

 

4.关于虚析构函数-只在多态基类中声明虚析构函数

关于virtual desctructor的详细讨论。同样来自于《Effective C++3rd Edition


跟踪时间是很平常的任务,所以开发一个名为TimeKeeper的基类,并让不同的派生类来实现不同的计时方法是很合理的事情:

class TimeKeeper {

public:

    TimeKeeper();

    ~TimeKeeper();

    ...

};

 

class AtomicClock: public TimeKeeper { ... };

class WaterClock: public TimeKeeper { ... };

class WristWatch: public TimeKeeper{ ... };

很多用户都希望直接用这些类来计数,而对于他们究竟是如何实现的并不关心。于是一个我们可以用一个Factory function——创建一个派生类对象并返回一个基类指针的函数——返回一个指向TimeKeeper的指针。

TimeKeeper* getTimeKeeper(); // returns a pointer to a dynamically

                              // allocated object of a class derived

                              // from TimeKeeper

通常,factory function返回的对象都是创建在堆上的,当用户使用完计数器的时候把对象析构掉是很重要的:

TimeKeeper *ptk = getTimeKeeper(); // get dynamically allocated object

                                    // from TimeKeeper hierarchy

...                                  // use it

delete ptk;                        // release it to avoid resource leak

但是,依赖用户来执行删除是错误的重要来源。条款18介绍了如何修改Factory function的接口来避免这些常见的用户错误,但是,这些目前都是次要的,因为在上面的代码中还存在更为严重的问题:即使客户执行的正确的动作,你还是无法预期你的程序能够正确执行。

问题在于getTimeKeeper返回了一个派生类对象(例如:AutoicClock),但是这个对象却通过基类的指针来删除(一个指向Timekeeper的指针),并且这个基类没有虚析构函数。这种组合是制造灾难的良方,因为C++规定:用不带有虚析构函数的基类的指针来删除一个派生类,其结果是未定的。通常在运行时发生的情况是这个对象的派生类部分没有被析构。如果getTimeKeeper返回一个指向AtomicClock对象的指针,那么AtomicClock中派生类的部分(例如在AtomicClock中声明的数据成员)将不会被正确的析构,实际上AtomicClock的析构函数都根本不会被调用。但是,基类的部分,却会被正确的清除,这就造就了一个“畸形”的partially destroyed object。这是一个非常棒的泄漏资源、破坏数据的方法,它会让你在调试器上花费大量的精力。

解决这个问题的方法很简单,给派生类加上一个虚析构函数。这样派生类对象就会如你所愿,被正确的清除:

class TimeKeeper {

public:

    TimeKeeper();

    virtual ~TimeKeeper();

    ...

};

 

TimeKeeper *ptk = getTimeKeeper();

...                            
delete ptk;                 // now behaves correctlhy

TimeKeeper这样的基类,除了析构函数外,通常会包含其它的虚函数。因为虚函数的目标就是让派生类来订制基类的实现。例如,getCurrentTime,在不同的派生类中就会有不同的实现(注:其实getTimeKeeper也可以是一个虚函数)。任何一个拥有虚函数的类都应该包含一个虚析构函数。

如果一个类没有虚函数呢,这也就意味着这个类并不是被当作基类来使用的。当遇到这种情况的时候,声明一个虚析构函数往往不是一个好主意。考虑一个用来表示二维空间中的某点的类:

class Point { // a 2D point

public:

    Point(int xCoord, int yCoord);

    ~Point();

 

private:

    int x, y;
};

如果一个int32 bits,这样的一个Point可以被放到一个64位寄存器中。另外,这样的一个Point对象还可以被当作是一个整体被其它的语言使用,例如CFORTRAN。但是,如果Point的析构函数是虚拟的,故事就完全不一样了。

虚函数的实现需要对象承载某些额外信息,这些信息用来在运行时对虚函数的调用进行正确的转发。这个额外的信息使通过一个vtpr来实现的。Vptr指向一个存放函数指针(vtbl)的数组,每一个具有虚函数的类都有一个对应的vtbl。当一个对象的虚函数被调用的时候,该对象的vtprvtbl组合来完成定位正确的函数调用的工作。

这里,虚函数如何实现的并不重要。重要的是如果Point包含了一个虚函数,对象将会长胖。在一个32 bits的机器上,它将会从64 bits长到96 bits;在64 bit的机器上,它将会从64 bits长到128 bits。这个额外的vtpr的存在让对象的体积增长了50%~100%Point对象也不再能够放到一个64-bit寄存器中了。另外,Point对象也不再和C语言的保持兼容,因为C语言中没有vrpr机制。结果是,你要想使用该Point对象,除非自己来实现vtprvtpl机制,而这样做,往往又会降低你的代码的可移植性。

也就是说,把所有的析构函数都不加思索的声明为虚拟的和从不把它们声明为虚拟的一样,都是不明智的行为。实际上,很多人得除了这样的结论:当且仅当一个类有至少一个虚函数的时候,才把析构函数声明为虚拟的。

实际上,即使你的类中没有虚函数,你还是有可能被非虚析构函数的问题咬上一口。例如std::string就没有虚函数,但是一些被误导的程序员有时会把它当作基类来使用:

class SpecialString: public std::string {

// bad idea! std::string has a

    ...                          // non-virtual destructor
}

乍一看,这可能没什么问题,但是一旦你把一个指向SpecialString的指针转换成一个string,并用这个指针来删除SpecialString对象的时候,你马上就被带进了未定义行为的深潭。

SpecialString *pss = new SpecialString("Impending Doom");

std::string *ps;

...

ps = pss; // SpecialString* --> std::string*

 

delete ps; // undefined! In practice, *ps's Specialstring resources

            // will be leaked, because the SpecialString destructor won't        // be called

同样的结果还会出现在其它没有虚析构函数的类中,例如所有的STL容器类型(例如:vector, list, set, tr1::unordered_map等等)。如果你曾经对于从一个标准容器或其它带有非虚析构函数的类继承,那么彻底打消这个想法。(不幸的是C++没有提供像C#(sealed)Java(final)类似的拒绝继承的语言机制)

有时候,把析构函数设定为pure virtual是非常方便的。一个pure virtual函数可以让一个类成为抽象类。有时,你可能需要让你的类成为一个abstract class,但是你一时又找不到合适的纯虚函数。怎么办呢?因为一个抽象类往往是要被作为基类的,而一个基类往往又应该有一个虚析构函数。这样一来:声明一个pure virtual destructor就是一个不错的主意。一箭双雕。

class AWOV { // AWOV = "Abstract w/o Virtuals"

public:

    virtual ~AWOV() = 0; // declare pure virtual destructor

};

这个类有一个纯虚函数,因此这是以个抽象基类,并且这个类有一个虚析构函数,这也使你远离了析构函数的问题,唯一要注意的,就是一定要为纯虚析构函数提供一份实现。

虚析构函数的工作方式是从最深的派生类的析构函数依次调用其基类的析构函数,编译器会生成生成一个从派生类到基类的~AWOV的调用。如果你没有提供析构函数的实现,链接器就会抱怨错误。

所以,你只应该把多态基类的析构函数声明为虚拟的。只有你想通过基类接口来操作派生类的时候,一个基类才是多态的。TimeKeeper就是一个多态基类,因为我们需要用一个TimeKeeper*来操作AtomicClockWaterClock对象。

另外,并不是所有的基类都要按照多态的方式来设计和使用。Std::stringSTL中的容器类型就都不具备多态性。一些类被设计成基类,但是却不应该按照多态的方式来使用,例如input_iterator_tag就是一个例子,你并不需要用基类接口来操纵派生类。结果是,他们也不需要虚拟析构函数。

时时刻刻让自己记住

l         应该为多态基类声明虚拟析构函数。如果一个类有一个虚函数,那么它也应该有一个虚析构函数

l         如果一个类不是被设计为基类或者它们并不是按照多态的方式来使用的,不要为它们声明虚析构函数

抱歉!评论已关闭.