虚继承以及一些技巧

原文出自:http://www.codeproject.com/Tips/1023429/The-Virtual-Inheritance-and-Funny-Tricks

简介

为解决钻石问题,C++ 引入了虚继承,改变了子类调用父类构造函数的方式。这种“副作用”对某些不同寻常的类实现非常有用。

虚继承

虚继承的引入主要是为了解决多继承环境下有歧义的层次组合问题(通常被称为“钻石问题”)。例如,FAQ Where in a hierarchy should I use virtual inheritance?。下面我们回忆一下一些有趣的细节。

考虑下面的类定义:

class Base
{
public:
    Base(int n) : value(n)
    { 
        std::cout << "Base(" << n << ")"<< std::endl; // 输出传入值:Base(N)
    }
    Base() : value(0)
    { 
        std::cout << "Base()"<< std::endl; // 没有传入值:Base()
    }
    ~Base() { std::cout << "~Base()"<< std::endl; }

    int value;
};

class One : public Base
{
public:
    One() : Base(1) 
    { 
        std::cout << "One()"<< std::endl; 
    }
    ~One() { std::cout << "~One()"<< std::endl; }
};

class Two : public Base
{
public:
    Two() : Base(2)
    { 
        std::cout << "Two()"<< std::endl; 
    }
    ~Two() { std::cout << "~Two()"<< std::endl; }
};

class Leaf : public One, public Two
{
public:
    Leaf() : { std::cout << "Leaf()"<< std::endl; }
    ~Leaf() { std::cout << "~Leaf()"<< std::endl; }
};

在这个实现中,Leaf实例持有两个Base类的拷贝:第一个来自于One,第二个来自于Two。这样的实现使得下面语句:

Leaf lf;
lf.value = 0; // 编译错误!

会出现歧义。我们必须显式指定,要么是lf.One::value = 0,要么是lf.Two::value = 0

通常,我们会尽量避免一个Leaf对象持有多个Base类。这可以通过使用虚继承实现:我们可以在继承的子类添加virtual关键字:class One : public virtual Base…以及class Two : public virtual Base…。使用virtual关键字,Leaf类仅会调用一次Base类的构造函数,因而也就只构建了一个BaseLeaf内部只持有一个Base子对象(该子对象也会被OneTwo“共享”)。这就是我们所需要的。

那么问题来了,“编译器怎么知道该给Base的构造函数传哪个参数?”的确,我们有两个选择:One的构造函数调用了Base(1)Two的构造函数调用了Base(2)。那么,我们究竟该选哪个?答案很明显:哪个都不选。Leaf的构造函数直接调用时,编译器选择的是Base()的默认构造函数,而不是Base(1)或者Base(2)。因而,我们例子的输出将会是:

Base() // 注意:这里既不是 Base(1) 也不是 Base(2)
One()
Two()
Leaf()

编译器会隐式在Leaf初始化列表中添加Base()的调用,并忽略其它Base()构造函数的调用。因此,初始化列表类似这样:

class Leaf : public Base(), public One, public Two
{
    ...
}

当然,我们也可以给Leaf的初始化列表显式添加Base(...)语句,例如,我们需要给构造函数传入参数 3,那么就可以这么写:

class Leaf : public Base(3), public One, public Two
{
    ...
}

后面的例子的输出将会是:

Base(3)
One()
Two()
Leaf()

这是非常重要的:Leaf的构造函数直接调用了Base的构造函数,而不是像非虚继承那样的间接调用(通过OneTwo的构造函数。Leaf的构造函数能够直接调用Base的构造函数(绕过OneTwo的构造函数)这一事实,在一些类系统设计上非常有用。

为了更全面认识,现在考虑几个有趣的例子。第一个,也就是著名的“最终类”问题。

最终类

所谓“最终类”,是一种能够在堆上或者栈上创建实例,但是不能被继承的类。换句话说,下面的代码是合法的:

Final fd;
Final *pd = new Final();

但是,下面的代码则会给出一个编译错误:

class Derived : public Final{};

在 C++ 标准引入final关键字之前的很长一段时间,最终类问题都是通过虚继承解决的。例如More C++ Idioms/Final Class这里所阐述的。其解决方案如下所示:

class Seal
{
    friend class Final;
    Seal() {}
};

class Final : public virtual Seal
{
public:
    Final() {}
};

继承Final会引发一个编译错误“不能访问Seal类的私有成员”:

class Derived : public Final
{
public:
    Derived() {} // 编译错误:
                 // cannot access private member of class Seal
};

这个技巧的关键是虚继承:Derived构造函数必须直接调用Seal的构造函数,而不能通过Final的构造函数间接调用。但是,这又是不允许的:Seal类只有私有构造函数,而Derived类又不是像Final那样是Seal的友元。Final类本身允许调用Seal的私有构造函数,因为它是Seal的友元。这就是为什么Final可以在栈上或者堆上创建对象。

这个解决方案建立在虚继承的基础之上。如果移除继承声明class Final : public virtual Seal中的virtual关键字,Derived类就可以通过Final类间接调用Seal的构造函数(因为后者是Seal的友元),这个魔法就消失了。

第二个例子是另外一个著名的问题:在构造函数中调用虚函数。

在构造函数中调用虚函数

我们都知道,构造函数中不应该调用虚函数。例如:FAQ When my base class’s constructor calls a virtual function on its this object, why doesn’t my derived class’s override of that virtual function get invoked?这里,还有这里

不能在构造函数中调用虚函数的原因在于,由于子类的覆盖还没有完成,因而虚函数的调用机制是被禁止的。在调用虚函数之前,我们必须等待构造函数完成了对象的创建。

幸运的是,有一个简单的方法:如果我们能够在函数调用之后立即运行另外一段代码。考虑下面的代码:

f(const caller_helper& caller = caller_helper())
{
    ...
}

在调用函数f()之前,编译器创建了一个临时对象caller_helper,调用之后,这个临时对象被销毁。这意味着,析构函数~caller_helper()在函数f()完成之后立刻调用。这就是我们所需要的。

解决方案如下:

class base;

class caller_helper
{
public:
    caller_helper() : m_p(nullptr) {}
    void init(base* p) const { m_p = p; }
    ~caller_helper();
private:
    mutable base* m_p;
};

class base
{
public:
    base(const caller_helper& caller) 
    {   
        caller.init(this); // 存储指针
    }
    virtual void to_override() {} // 空实现
};

class derived : public base
{
public:
    derived(const caller_helper& caller = caller_helper()) : base(caller) {}
    virtual void to_override()
    {
        std::cout << "derived"<< std::endl;
    }
};

class derived_derived : public derived
{
public:
    derived_derived(const caller_helper& caller = caller_helper()) : derived(caller) {}
    virtual void to_override()
    {
        std::cout << "derived_derived"<< std::endl;
    }
};

caller_helper::~caller_helper()
{
    if (m_p) m_p->to_override();
}

base的构造函数将this指针注册到caller_helper对象。

base(const caller_helper& caller) 
{   
    caller.init(this); // 存储指针 m_p = p;
}

析构函数~caller_helper()调用虚函数:

caller_helper::~caller_helper()
{
    if(m_p) m_p->to_override();
}

子类构造函数参数中的默认参数const caller_helper& caller = caller_helper()输出为:

...
    derived_derived(const caller_helper& caller = caller_helper()) : derived(caller) {}
...
    derived(const caller_helper& caller = caller_helper()) : base(caller) {}

这就是我们需要的:析构函数~caller_helper()在相应子类构造函数退出之后立即调用。

代码:

derived td;
derived_derived tdd;

输出:

derived
derived_derived

这个解决方案是可行的,但是有一个值得注意的缺陷。

的确,构造函数完成时,虚函数可以被调用,但是,我们必须在构造函数参数中加入一个默认参数const caller_helper& caller = caller_helper(),并且我们必须为类层次结构中的每一个子类都添加这么一个参数,例如我们的derived()derived_derived()

忘记为derived添加参数并不容易,因为其父类base没有默认构造函数。但是,忘记为derived_derived添加这个参数却很容易,但是它的父类derived的确默认构造函数。

这里,虚继承再一次拯救了世界。我们可以定义class derived : public virtual base,强制所有子类直接调用base(const caller_helper& caller)构造函数。

最后经过修改,我们的代码如下:

class base;

class caller_helper
{
public:
    caller_helper() : m_p(nullptr) {}
    void init(base* p) const { m_p = p; }
    ~caller_helper();
private:
    mutable base* m_p;
};

class base
{
public:
    base(const caller_helper& caller) 
    {   
        caller.init(this); // 存储指针
    }
    virtual void to_override(){} // 空实现
};

class derived : public virtual base
{
public:
    derived(const caller_helper& caller = caller_helper()) : base(caller) {}
    virtual void to_override()
    {
        std::cout << "derived"<< std::endl;
    }
};

class derived_derived : public derived
{
public:
    derived_derived(const caller_helper& caller = caller_helper()) : base(caller) {}
    virtual void to_override()
    {
        std::cout << "derived_derived"<< std::endl;
    }
};

caller_helper::~caller_helper()
{
    if (m_p) // 检查指针是否初始化,如果没有初始化完成则不调用
        m_p->to_override();
}

结论

总结一下前面提到的种种思路。

  • 虚继承着眼于子类直接调用虚基类的构造函数,而不是像非虚继承那样通过构造函数链间接调用。
  • 利用这一特性,结合私有成员访问控制,我们可以设计出最终类模式。当然,随着 C++11 标准引入final关键字,这一模式已经不像原来那样重要,但仍不失为一种好的模式。
  • 如果一个函数有一个传值的类参数,那么,这个类的实例会在函数调用之前创建,在函数调用完毕之后销毁。利用这一特性,我们可以在类构造函数完成并且虚表也构造完成之后调用一个虚函数。这允许我们调用正确的虚函数。结合虚继承,我们可以实现一种“在构造函数中”调用虚函数的优雅模式。

Leave a Reply