1. 虚函数

当基类希望派生类覆盖自己的某个函数时,可以将这个函数定义为虚函数。则这个函数在派生类中同样为虚函数,不管加不加 virtual 关键字。

当使用基类或派生类的指针或引用来调用虚函数的时候,执行动态绑定。与之相对应的是静态绑定,即程序在编译时就确定如何调用。看下面的例子:

class Base {
public:
    Base() {
        cout << "Base() called" << endl;
    }
    Print() {
        cout << "Base Print() called" << endl;
    }
};

class Derived : public Base {
public:
    Derived() {
        cout << "Derived() called" << endl;
    }
    Print() {
        cout << "Derived Print() called" << endl;
    }
};

在上面的例子中,Print() 在派生类中被重写。假设现在有这么一个函数:

void func(Base& b) {
    b.Print();
}

int main() {
    Derived d;
    func(d);
}

则此时执行的是静态绑定,即编译器根据 bBase& 类型推测,此时调用的一定是 Base::Print()。而不管实参到底是个 Base 还是个 Derived。在这个例子中就是实参明明是 Derived ,但却错误的调用了 BasePrint()

但假如我在这里想执行动态绑定,即根据实参的类型判断到底是调用 Base::Print() 还是 Derived::Print()。就需要将基类中的 Print() 函数定义为虚函数,即加上 virtual 关键字。则派生类中覆盖 Print() 时也默认为虚函数。

class Base {
public:
    Base() {
        cout << "Base() called" << endl;
    }
    virtual Print() {
        cout << "Base Print() called" << endl;
    }
};

class Derived : public Base {
public:
    Derived() {
        cout << "Derived() called" << endl;
    }
    virtual Print() {
        cout << "Derived Print() called" << endl;
    }
};

在这种情况下,再次调用 func(),具体调用哪个 Print() 不在编译时期决定,而是等到运行时,根据实参到底是 Base 还是 Derived 来判断到底调用哪个。

2. 虚析构函数

除了普通成员函数可以定义为虚函数,析构函数也可以定义为虚函数。

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

class Derived : public Base {
public:
    Derived() {
        ptr = new int(10);
        cout << "Derived() called" << endl;
    }
    virtual ~Derived() {
        free(ptr);
        cout << "~Derived() called" << endl;
    }
private:
    int* ptr;
};

析构函数为啥要定义成虚函数呢?看下面这个函数:

void func(Base& b) {
    delete &b;
}

int main() {
    Derived* p = new Derived;
    func(*p);
}

定义 Derived 类对象的时候,构造函数中申请了一个 int 的空间,对应的释放写在了 Derived 的析构函数中。但像上面这样调用 func() 的话,形参是 Base& 类型,假如析构函数不定义为虚函数,则会执行静态绑定,一定调用的是 Base 的析构函数,这样就会导致 Derived 类对象的那个 int 空间没有被释放,从而产生内存泄漏。所以只要将析构函数定义为虚函数,就会产生动态绑定,运行时才确定要调用的析构函数是哪个,这样就可以成功释放 int 空间。

3. 小结

那么虚函数的好处也很明显了。我可以用一套通用的接口(例如上面的 func()),既可以接收基类对象为参数,也可以接收派生类对象为参数,根据实参类型自动的选择调用哪一个虚函数。

4. 虚表

那么,虚函数的动态绑定到底是如何实现的呢?程序在运行阶段已经变成了一条一条的指令,确定的指令为什么可以自行决定调用哪个虚函数呢?

首先,每一个定义了虚函数的类,除了有自己的成员变量之外,编译器还会为其多分配一个成员变量,被称为虚指针。这个指针指向一个属于特定的类的结构,该结构被称为虚表。虚表中存储了当前类中所有虚函数的入口地址。如下图:

图源自《清华大学-C++语言程序设计》

Base() 类中有两个虚函数 f()g()Derived 类中覆盖了 f() 、继承了 g() 、又新增了个自己的虚函数 h() 。则可以看到,Base 类和 Derived 类的对象中,除了有本身就有的成员变量 i j ,又多了一个成员变量 vptr,即虚指针。Base 类对象的虚指针指向 Base 类的虚表,Derived 类对象的虚指针指向 Derived 类的虚表。如上面所说,虚表中存储了类中所有虚函数的入口地址。

但关键点就在于虚表中存储的函数指针。Base 虚表中 f() 的指针指向 Base::f()g() 指向 Base::g()。而由于 Derived 类覆盖了 f(),所以 Derived 虚表中的 f() 指针指向自己的 Derived::f() 而不是 Base::f() 。类似的,继承自 Baseg() 指向 Base::f() ,自己定义的 h() 指向 Derived::h()

所以,运行时调用虚函数的时候,其实就是通过类对象的 vptr 虚指针找到虚表,然后再调用虚表中对应的虚函数。所以虽然形参是 Base* 或者 Base& ,但只要实参传进来的是 Derived 对象,则就可以根据这个对象的虚指针执行正确的 f()

5. 深层剖析

上面都是理论层面的东西,下面来看看实际情况。CSDN 中一篇博客讲的相当细致,下面内容都出自这篇博客。

一般继承(无函数覆盖)

先来第一个例子,即虽然所有函数都是虚函数,但派生类并不覆盖基类的虚函数:

class Base {
public:
    virtual void func1() { cout << "Base::func1() called" << endl; }
    virtual void func2() { cout << "Base::func2() called" << endl; }
};

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

typedef void (*fptr)();

int main() {
    Derived d;

    cout << "虚指针地址:" << (int*)(&d) << endl;
    cout << "虚函数表地址:" << (int*) * (int*)(&d) << endl;
    cout << "虚函数表中第一个函数的地址:" << (fptr) * ((int*) * (int*)(&d)) << endl;
    cout << "调用第一个函数:";
    fptr p1 = (fptr) * (((int*) * (int*)(&d)) + 0);
    p1();
    cout << "调用第二个函数:";
    fptr p2 = (fptr) * (((int*) * (int*)(&d)) + 1);
    p2();
    cout << "调用第三个函数:";
    fptr p3 = (fptr) * (((int*) * (int*)(&d)) + 2);
    p3();
    cout << "调用第四个函数:";
    fptr p4 = (fptr) * (((int*) * (int*)(&d)) + 3);
    p4();

    return 0;
}

首先,虚表指针默认是在类对象空间的最前面。所以,获取 d 的虚表指针就直接可以用 (int*)(&d),这就是 d 的最前面的地址。然后,这块四字节的空间存储了虚函数表的地址,所以获取虚函数表的地址就可以用 (int*) * (int*)(&d)

然后我们根据 Derived 继承两个 Base 的虚函数,自己又定义两个虚函数推测,虚表中共有四个表项,所以我们依次调用试试:

果然,四个函数都被调用了,而且可以看到,顺序是先基类的虚函数,再派生类的虚函数,且内部顺序和定义的顺序相同。

由此我们可以推测,内存中的结构大体是这样的:

但注意一点,虽然虚函数表中有派生类未覆盖的虚函数 func3()func4() ,而且你可以通过上面的方法调用这两个函数。但 C++ 并不允许通过基类的指针或者引用来调用派生类的未覆盖的虚函数。例如:

Derived d;
Base& re = d;
d.func3();  // wrong
d.func4();  // wrong

编译器会直接报错。这也说明了在 C++ 中,派生类的虚函数如果不覆盖基类的虚函数的话,声明成虚函数是毫无作用的,还不如直接声明成普通成员函数呢。

一般继承(有函数覆盖)

然后我们来看一看,DerivedBasefunc1() 覆盖后会怎么样:

class Derived : public Base {
public:
    virtual void func1() { cout << "Derived::func1() called" << endl; }
    virtual void func3() { cout << "Derived::func3() called" << endl; }
    virtual void func4() { cout << "Derived::func4() called" << endl; }
};

如图所示,覆盖后的 Derived::func1() 会取代 Base::func1() 的位置。也就是下图:

原来如此!这才最终解释了动态绑定的机制。编译器在编译时,并不知道传过来的参数是 Base 对象还是 Derived 对象,但不管是哪一个,虚指针一定在内存模型中最开头的位置,所以编译器就可以根据这个虚指针找到该对象的虚函数表。然后如果调用的是 func1() ,编译器虽然不知道到底要调用哪一个类的 func1() ,但由于派生类在覆盖了基类的虚函数后,一定是取代了虚函数表中该函数的位置,就像上图,不管是 Basefunc1() 还是 Derivedfunc1() ,一定都在第一个位置,所以编译器在编译阶段只需要指定要调用的函数在虚函数表中的偏移即可。这样运行时就能确保调用的是真正的对象的虚函数。

多重继承(无函数覆盖)

class Base1 {
public:
    virtual void func1() { cout << "Base1::func1() called" << endl; }
    virtual void func2() { cout << "Base1::func2() called" << endl; }
};

class Base2 {
public:
    virtual void func1() { cout << "Base2::func1() called" << endl; }
    virtual void func2() { cout << "Base2::func2() called" << endl; }
};

class Base3 {
public:
    virtual void func1() { cout << "Base3::func1() called" << endl; }
    virtual void func2() { cout << "Base3::func2() called" << endl; }
};

class Derived : public Base1, public Base2, public Base3 {
public:
    virtual void func3() { cout << "Derived::func3() called" << endl; }
};

如上代码,Derived 继承自 Base1Base2Base3 。在这种多继承的情况下,Derived 类不只有一个虚表了,而是继承自几个类就有几个虚表。所以在这个例子中 Derived 共有三个虚表,对应着也就有三个虚指针。如下图:

注意此时派生类的虚函数并未覆盖。可以看到,三个虚函数表分别对应了派生类的三个基类,且派生类自己的虚函数放在了第一张虚函数表的最后。

这同样可以通过指针验证一下:

typedef void(*fptr)();

int main() {
    Derived d;

    cout << "第一个虚指针地址:" << (int*)(&d) + 0 << endl;
    cout << "第二个虚指针地址:" << (int*)(&d) + 1 << endl;
    cout << "第三个虚指针地址:" << (int*)(&d) + 2 << endl;

    cout << "第一个虚表地址:" << (int*) * ((int*)(&d) + 0) << endl;
    cout << "第二个虚表地址:" << (int*) * ((int*)(&d) + 1) << endl;
    cout << "第三个虚表地址:" << (int*) * ((int*)(&d) + 2) << endl;

    cout << "调用第一个虚表的第一个函数:";
    fptr ptr1 = (fptr) * (((int*) * ((int*)(&d) + 0)) + 0);
    ptr1();
    cout << "调用第一个虚表的第二个函数:";
    fptr ptr2 = (fptr) * (((int*) * ((int*)(&d) + 0)) + 1);
    ptr2();
    cout << "调用第一个虚表的第三个函数:";
    fptr ptr3 = (fptr) * (((int*) * ((int*)(&d) + 0)) + 2);
    ptr3();

    cout << "调用第二个虚表的第一个函数:";
    fptr ptr4 = (fptr) * (((int*) * ((int*)(&d) + 1)) + 0);
    ptr4();
    cout << "调用第二个虚表的第二个函数:";
    fptr ptr5 = (fptr) * (((int*) * ((int*)(&d) + 1)) + 1);
    ptr5();

    cout << "调用第三个虚表的第一个函数:";
    fptr ptr6 = (fptr) * (((int*) * ((int*)(&d) + 2)) + 0);
    ptr6();
    cout << "调用第三个虚表的第二个函数:";
    fptr ptr7 = (fptr) * (((int*) * ((int*)(&d) + 2)) + 1);
    ptr7();

    return 0;
}

那么为啥有几个含虚函数的基类就要有几个虚表呢?这是为了正确处理不同的基类指针指向派生类对象的情况。当基类引用是 Base1& 的时候,实参可以传 Base1 对象也可以传 Derived 对象,编译器在做动态绑定的时候就会找第一张虚表。类似的,基类引用是 Base2& 的时候,编译器就会去找第二张虚表。Base3& 时找第三张虚表。

多重继承(有函数覆盖)

这次我们在 Derived 类中覆盖 func1()

class Derived : public Base1, public Base2, public Base3 {
public:
    virtual void func1() { cout << "Derived::func1() called" << endl; }
    virtual void func3() { cout << "Derived::func3() called" << endl; }
};

此时内存中的情景会变成这样:

如图,此时 Derived::func1() 会将三个基类中的所有 func1() 都覆盖掉。可以用代码验证一下:

和一般继承一样,覆盖基类的 func1() 的位置,使得编译器在编译期间只需要指定要调用的函数在虚表中的偏移即可。如果实参是 Base1 对象就调用的是 Base1::func1() ,如果实参是 Derived 对象就调用的是 Derived::func1()

6. 纯虚函数 & 抽象类

试想这样一种情况,我们有一个二维图形基类,其中定义了一些二维图形共有的属性,其中就包括求面积的函数接口。但我们知道,二维图形是个抽象概念,其并没办法求面积,只有具体的二维图形如正方形才能求面积。所以在基类中这个求面积的函数是没办法实现的。只有派生了圆形派生类、三角形派生类后才能实现求面积的函数。

这时候我们就可以将基类中的求面积的函数定义为纯虚函数。具体的格式如下:

virtual double area(void* arg) = 0;

纯虚函数是一个在基类中声明的虚函数,其在基类中并没有定义具体的函数体,要求派生类根据实际需要自己实现各自的版本。

只要带有纯虚函数的类就叫做抽象类抽象类不允许实例化,即不能实例出对象。

class dimension_2 {
public:
    virtual double area(void* arg) = 0;
};

class square : public dimension_2 {
public:
    virtual double area(void* arg) {
        int side_len = *(int*)arg;
        return side_len * side_len;
    }
}

就像上面的例子,正方形类派生自二维图形类,并实现了自己的求面积接口。

那么纯虚函数有啥好处呢?我可以实现一个通用的求面积的接口:

double Area(dimension_2& d) {
    return d.area(d.arg);
}

将形参设置为基类的引用,这样不管实参是圆形类还是三角形类还是方形类,都可以用这个接口求面积。试想基类中没有纯虚函数,各自的派生类实现各自的 area() 函数会怎么样,你永远也不可能用一套通用的接口去求所有二维图形的面积。这就是纯虚函数的好处。

7. override

不同于普通成员函数的覆盖只要求函数名相同,虚函数的覆盖必须要函数签名相同,即函数名、参数列表、以及类似 const 修饰符都要完全一样。

但假如你的本意是覆盖虚函数,但由于疏忽将函数签名并未定义的完全一样,这时候编译器并不会报错,因为编译器只是以为你定义了另一个虚函数,这时候你通过基类的指针调用这个函数时,由于没有虚函数覆盖,所以仍旧是静态绑定,即调用的是基类中的那个虚函数,这样的错误不好排查。

但如果你在派生类的覆盖虚函数的参数列表之后加一个 override 关键字,就是显式告诉编译器,这个虚函数要覆盖基类中的某个虚函数,所以编译器就会去基类中找,有没有能触发覆盖的虚函数,假如没有触发覆盖,编译器就会直接报错,这样就容易排查了。

8. final

final 关键字可以修饰类,也可以修饰成员函数。

final 修饰类的时候,该类不允许被继承。

final 修饰成员函数的时候,该类可以被继承,但该函数不能被覆盖。

9. 这些函数可以是虚函数吗?

inline 函数可以是虚函数吗?

不能,inline 在编译后就会被直接放在调用的地方,所以也不会有地址,就无法被放进虚表。

static 函数可以是虚函数吗?

不能。我的理解是,虚函数是为了实现运行时多态而提出的,也就是在运行时根据调用者的不同而选择调用不同的虚函数版本。而 static 成员函数是属于整个继承体系的,本来就不应该有多个版本,这与虚函数的意图相违背。

友元函数可以是虚函数吗?

不能。友元函数不能被派生类继承,所以也就没有当作虚函数的必要。

构造函数可以是虚函数吗?

不能。我觉得首要因素还是没必要。正如上面所说,虚函数的出现是为了实现运行时多态,可你一个构造函数要运行时多态有啥用嘛,构造本来就应该是确定的,编译时期连要构造啥都不清楚这合适吗?这同样与虚函数的意图相违背。

析构函数可以是虚函数吗?

可以,如第二节所示。

最后修改:2020 年 04 月 14 日
如果觉得我的文章对你有用,请随意赞赏