C++关键字:virtual用法

1,056 阅读6分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第21天,点击查看活动详情

概念

virtual是C++ OO机制中很重要的一个关键字。主要用在两个方面:虚函数、纯虚函数和虚基类、虚继承。

虚函数

virtual放在函数的返回值前面,用于表示该类成员函数为虚函数;父类虚函数前的virtual必须写;子类虚函数前的virtual可以省略,因为不管省不省略,该函数在子类中也是虚函数类型;virtual只能出现在类内部的函数声明语句之前而不能用于类外部的函数定义。

如下程序,在类Base中加了virtual关键字的函数就是虚拟函数(函数foo),于是在Base的派生类Derived中就可以通过重写虚拟函数来实现对基类虚拟函数的覆盖。当基类Base的指针p指向派生类Derived的对象时,对p的foo函数的调用实际上是调用了Derived的foo函数而不是Base的foo函数。这是面向对象中的多态性的体现。如下例程序,则会输出:“Derived”。

#include <iostream>
#include <memory>

struct Base {
    void bar();
    virtual void foo() {std::cout<<"Base";}
};
 
struct Derived : Base  {
    void foo() {std::cout<<"Derived";} 
};

int main() {
    std::shared_ptr<Base> p = std::make_shared<Derived>();
    p->foo();
}

这里“覆盖/重写”的必须要求是函数的特征标(包括参数的数目、类型和顺序)以及返回值都必须与基类中的函数一致,否则就属于重载了。

上面main函数中通过基类指针访问派生类函数的方式就是我们常常提到的多态,具体的原理可以看一下这篇文章:C++虚函数表解析

这里要说明一下:友元函数,构造函数,static静态函数是不能用virtual关键字修饰的;能修饰的只有普通成员函数和析构函数;

虚析构函数

我们来先看下面的示例,最后结果是输出Base基类的析构,也就是delete p时没有析构派生类而生析构了基类,这显然不是我们期望的。

#include <iostream>
using namespace std;

class Base
{
public:
    ~Base() { cout << "Base::destrutor" << endl; }
};
class Derived : public Base
{
public:
    int w, h;
    ~Derived() { cout << "Derived::destrutor" << endl; }
};
int main()
{
    Base* p = new Derived;
    delete p;  //输出Base的析构
    return 0;
}

一个基类指针指向用 new 运算符动态生成的派生类对象,而释放该对象时是通过释放该基类指针来完成的,这导致程序不正确。

按理说,delete p;会导致一个 Derived类的对象消亡,应该调用 Derived类的析构函数才符合逻辑,否则有可能引发程序的问题。其实编译器编译到此时,不可能知道此时 p 到底指向哪个类型的对象,它只根据 p 的类型是 Base* 来决定应该调用 Base类的析构函数。因为动态绑定是运行时才进行的。实际上,这也是多态。为了在这种情况下实现多态,C++ 规定,需要将基类的析构函数声明为虚函数,即虚析构函数。

class Base
{
public:
    virtual ~Base() { cout << "Base::destrutor" << endl; }
};

按照上面讲基类改成虚析构函数后,delete p确实调用了派生类和基类的析构函数了,实际上,派生类的析构函数会自动调用基类的析构函数。

只要基类的析构函数是虚函数,那么派生类的析构函数不论是否用virtual关键字声明,都自动成为虚析构函数。跟普通虚函数是一样的。

一般来说,一个类如果定义了虚函数,则最好将析构函数也定义成虚函数。 析构函数可以是虚函数,但是构造函数不能是虚函数。

纯虚函数

我们都知道C++没有类似其他语言interface这样的直接的功能,但是C++语言为我们提供了一种另一种语法结构,通过它可以指明,一个虚拟函数只是提供了一个可被子类型改写的接口。但是,它本身并不能通过虚拟机制被调用。这就是纯虚拟函数(pure virtual function)。

class Interface{
public:
    // 声明纯虚拟函数,虚函数声明后面紧跟赋值0
    virtual void foo() = 0;
};

包含(或继承)一个或多个纯虚拟函数的类被编译器识别为抽象基类。试图创建一个抽象基类的独立类对象会导致编译时刻错误,如下:

// ERROR: cannot declare variable to be of abstract type
Interface if;

抽象基类只能作为子对象出现在后续的派生类中。

父类中定义了一个纯虚函数,它的派生类(子类)必须定义,若不定义,则派生类也成为了抽象类,但是抽象类不能实例化对象。

纯虚函数可以为不同种类的子类对象提供了一个通用接口,由子类根据自己的功能具体定义实现该接口。

虚基类、虚继承

类的继承与派生,由于派生类会完全继承基类的公有成员。如果从多个基类继承,那么就会存在同一个基类的成员会被继承多个,造成成员变量的冗余。为了解决这个问题,C++提供了虚拟继承的机制,使得在派生类中只保留一份间接基类的成员,达到消除成员变量的冗余。

#include <iostream>

class A {
protected:
    int m_a;
};

class B: public A {
protected:
    int m_b;
};

class C: public A {
protected:
    int m_c;
};

class D: public B, public C {
public:
    // void seta(int a){ m_a = a; }  //命名冲突
    void setba(int a){ B::m_a = a; }  //正确
    void setca(int a){ C::m_a = a; }  //正确
    void setb(int b){ m_b = b; }  //正确
    void setc(int c){ m_c = c; }  //正确
    void setd(int d){ m_d = d; }  //正确
private:
    int m_d;
};

int main(){
    D d;
    return 0;
}

如上示例代码,注释掉的seta函数试图直接访问成员变量 m_a,结果发生了错误,因为类 B 和类 C 中都有成员变量 m_a(从 A 类继承而来),编译器不知道选用哪一个,所以产生了歧义。为了消除歧义,必须显示指定m_a具体来自哪个类,就像setba和setca函数一样。

为了解决多继承时的命名冲突和冗余数据问题,C++ 提出了虚继承,使得在派生类中只保留一份间接基类的成员。

虚继承的目的是让某个类做出声明,承诺愿意共享它的基类。其中,这个被共享的基类就称为虚基类(Virtual Base Class),如下例中的 A 就是一个虚基类。在这种机制下,不论虚基类在继承体系中出现了多少次,在派生类中都只包含一份虚基类的成员。

#include <iostream>

class A {
protected:
    int m_a;
};

class B: virtual public A {
protected:
    int m_b;
};

class C: virtual public A {
protected:
    int m_c;
};

class D: public B, public C {
public:
    void seta(int a){ m_a = a; }  //正确
    void setb(int b){ m_b = b; }  //正确
    void setc(int c){ m_c = c; }  //正确
    void setd(int d){ m_d = d; }  //正确
private:
    int m_d;
};

int main(){
    D d;
    return 0;
}

我们细心观察上面这个程序,就会发现虚继承的一个不太直观的特征:必须要在虚派生的真实需求出现前就完成了虚派生的操作。在上面示例中,当定义 D 类时才出现了对虚派生的需求。

换个角度讲,虚派生只影响从指定了虚基类的派生类中进一步派生出来的类(D类),它不会影响派生类本身(B和C类)。

虚基类成员的可见性

在虚基类中只有唯一一个共享子对象(如上述示例程序中的m_a成员变量),所以在派生类中可以直接访问该虚基类的成员不会产生二义性。但是如果虚基类中的成员被某一个派生类覆盖了,我们仍然可以直接访问这个被覆盖的成员但是如果成员被多于一个派生类覆盖,则一般情况下派生类必须为该成员自定义一个新的版本,否则我们就无法直接访问这个成员了。

如下示例程序:

#include <iostream>

class A {
protected:
    int m_a;
};

class B: virtual public A {
protected:
    int m_a;
    int m_b;
};

class C: virtual public A {
protected:
    int m_a;
    int m_c;
};

class D: public B, public C {
public:
    void seta(int a){ m_a = a; }  //错误
    void setb(int b){ m_b = b; }  //正确
    void setc(int c){ m_c = c; }  //正确
    void setd(int d){ m_d = d; }  //正确
private:
    int m_d;
};

int main(){
    D d;
    return 0;
}

A类定义了成员m_a,B和C类继承了A并且重定义覆盖了m_a成员,这种情况下D类就无法直接访问m_a成员了,因为它不知道到底时访问B类的还是C类的。当然如果B或C类只有一个重写覆盖了成员m_a,那么D类还是可以直接访问m_a的。

可以看到,使用多继承经常会出现二义性问题,必须十分小心。上面的例子是简单的,如果继承的层次再多一些,关系更复杂一些,程序员就很容易陷人迷魂阵,程序的编写、调试和维护工作都会变得更加困难,因此我不提倡在程序中使用多继承,只有在比较简单和不易出现二义性的情况或实在必要时才使用多继承,能用单一继承解决的问题就不要使用多继承。也正是由于这个原因,C++ 之后的很多面向对象的编程语言,例如 Java、C#、PHP 等,都不支持多继承。

总结

派生类会拥有基类定义的函数,但是对于某些函数,有时候希望每个派生类各自定义适合于自己版本的函数,于是基类就将此函数定义为虚函数,让派生类各自实现自己功能版本的函数,也可以不实现;这就是virtual关键字的主要作用。下面简单总结几点:

  1. 构造函数不能是虚函数。在构造函数中调用虚函数,实际运行的是父类的相应函数。因为自己还没有构造好,多态还无法正常使用。
  2. 将一个函数定义为纯虚函数。实际上是将这个类定义为抽象类,不能实例化对象
  3. 纯虚函数通常未定义体,但也可以能够拥有, 甚至能够显示的调用。