使用多态需要注意的问题

C++ 作为一种面向对象语言,其最重要的一个特征(也是面向对象的最重要的特征之一)是多态和动态绑定。所谓动态绑定,也称为“运行时绑定”,是指在执行期间(非编译期)判断所引用对象的实际类型,根据其实际的类型调用其相应的方法。基于此,我们可以编写如下的代码:

class Base
{
public:
    virtual void show() { cout << "Base::show()" << endl; }
    void get() { cout << "Base::get()" << endl; }
};

class Derived : public Base
{
public:
    virtual void show() { cout << "Derived::show()" << endl; }
    void get() { cout << "Derived::get()" << endl; }
};

int main(int argc, char* argv[])
{
    Base *base = new Derived;
    base->show(); // Derived::show()
    base->get();  // Base::get()

    return 0;
}

注意,由于show()函数是虚函数,因此在main()中调用时,发生了动态绑定:尽管是父类的指针,实际却是子类对象。动态绑定的结果是,父类指针调用了子类的实现,我们说,这就是多态。而对于get()函数,由于没有virtual修饰,不会发生动态绑定,因此只是运行的父类版本。

上面代码的 Java 版本是:

class Base {
    public void show() {
        System.out.println("Base.show()");
    }
    public void get() {
        System.out.println("Base.get()");
    }
}

class Derived extends Base {
    public void show() {
        System.out.println("Derived.show()");
    }
    public void get() {
        System.out.println("Derived.get()");
    }
}

public static void main(String[] args) {
    Base base = new Derived();
    base.show(); // Derived.show()
    base.get();  // Derived.get()
}

由于 Java 中所有方法都是虚方法,全部会发生多态现象,因此我们的代码执行的都是子类的实现。

这种多态调用是通过虚函数表(vtable,简称虚表)实现的。简单来说,程序内部维护一个内部表。如果函数是虚函数,则会在执行时向虚表中添加一行,指明这个函数的实际入口(也就是相对于子类在内存中的起始位置的偏移地址)。这一过程比较复杂,属于 C++ 实现底层,暂且不详细讨论。这里只是想说明一点:多态(动态绑定)的实现,也就是虚函数的调用,是建立在虚表的基础之上

当我们了解模板方法模式的时候,我们可能会希望执行下面的代码:

class Base
{
public:
    Base()
    {
        calculate(); // Base::calculate()
    }
    virtual void calculate() { cout << "Base::calculate()" << endl; }
};

class Derived : public Base
{
public:
    virtual void calculate() { cout << "Derived::calculate()" << endl; }
};

这段代码看起来很不错:构造父类时,调用了子类实现的虚函数。也就是说,我们将这个类的构造与子类实现结合起来,这样的话,就可以让子类实现自己的初始化,无需修改父类,即可调用不同的初始化代码初始化子类。

但是,这种代码是不正确的。

在 C++ 中,虚表会在构造函数调用堆栈上“逐步”初始化完成。所谓构造函数堆栈,就是在构造子类的时候,会首先调用父类的构造函数。比如前面的代码,在构造Derived的时候,会首先调用Base的构造函数,由此形成了一个构造函数的调用堆栈。而虚表就是在这个堆栈的调用过程中建立起来的。由此得出这样一个结论:在 C++ 中,不应该在构造函数中调用被子类重写的虚函数,因为虚表还没构造完全。另外一个显而易见的原因是:子类中覆盖的虚函数可能会调用到子类的数据成员,如果在父类的构造函数中调用虚函数(注意,虚函数是要调用子类实现的),子类尚未构造(原因在于目前还处于父类的构造函数,子类构造函数当然尚未调用),虚函数可能使用为构造的子类中的数据,从而发生异常。因此,上面代码的结果是,父类构造函数中调用的依然是父类的版本。

值得说明的是,这样的代码在 Java 中却完全正确(只不过可能使用了未初始化的数据):

class Base {
    public Base() {
        calculate();
    }
    public void calculate() {
        System.out.println("Base.calculate()");
    }
}

class Derived extends Base {
    public void calculate() {
        System.out.println("Derived.calculate()");
    }
}

Java 语言规范(http://docs.oracle.com/javase/specs/jls/se5.0/html/execution.html#12.5)有明确地说明:“Unlike C++, the Java programming language does not specify altered rules for method dispatch during the creation of a new class instance. If methods are invoked that are overridden in subclasses in the object being initialized, then these overriding methods are used, even before the new object is completely initialized.”简单来说,在 Java 中,在父类中调用子类覆盖的虚函数完全没有问题,即使此时子类还没有完全初始化。只不过和 C++ 一样,此时如果在虚函数中使用了子类的数据,也是未初始化的,例如:

class Base {
    public Base() {
        calculate();
    }
    public void calculate() {
        System.out.println("Base.calculate()");
    }
}

class Derived extends Base {
    private int i = 3;
    public void calculate() {
        System.out.println(i); // 0
    }
}

因此在使用多态时,C++ 不应该在构造函数中调用由子类覆盖的虚函数(如果要这样做,则不会调用子类版本);而 Java 则无此限制。

但是在 C++ 中,我还是想用上面的代码:在父类中调用子类的版本,该怎么做呢?通过上面的分析我们发现,之所以有这种限制,是因为多态的代码所带来的:虚表的构造,以及子类数据成员未初始化。所以我们要想办法回避这两点。

我们可以使用 C++ 的 Curiously Recurring Template Pattern(奇异递归模板模式,CRTP)。这是独立于 GoF 23 种设计模式之一的一种模式,其代码结构如下:

// The Curiously Recurring Template Pattern (CRTP)
template<class Derived>
class Base
{
    // methods within Base can use template to access members of Derived
};
class Derived : public Base<Derived>
{
    // ...
};

利用这一函数,我们可以达到我们的目的:

template<class Derived>
class Base
{
public:
    Base()
    {
        static_cast<Derived *>(this)->calc(); // Derived::calc()
    }
    void calc() { cout << "Base::calc()" << endl; } // 注意,没有 virtual 关键字!
};

class Derived : public Base<Derived>
{
public:
    void calc() { cout << "Derived::calc()" << endl; }
};

CRTP 也被称为“静态多态”。有些时候,我们编写多态的代码,但是子类并不是运行时才能确定,而是编译时已经确定了(或者我们仅仅是为了重用部分代码才使用了多态机制)。在这种情况下,我们完全可以将“多态”的实现提前到编译期。CRTP 允许我们实现类似多态的行为:调用子类版本的函数,同时没有多态的限制,可以在构造函数中调用子类的虚函数,也可以在虚函数中使用子类的成员数据。虽然其实现不是那么优雅(所有“多态”调用都必须通过static_cast完成),但是这并不妨碍我们正确使用这种模式。事实上,Boost iterator 库就是使用了这种技术;WTL 也大量使用了类似的代码:

class MyWindow : public CWindowImpl<MyWindow>
{
}

5 Comments

  1. IamSlash 2013年5月6日
    • IamSlash 2013年5月6日
      • IamSlash 2013年5月6日
        • 豆子 2013年5月6日
    • 豆子 2013年5月6日

Leave a Reply