18 从对象内存到函数调用:C++ 虚函数表原理(继承覆盖 / 动态绑定)+ 多态实战

发布于:2025-09-09 ⋅ 阅读:(21) ⋅ 点赞:(0)

8 多态的原理

1 虚函数表指针

下⾯编译为32位程序的运⾏结果是什么(12字节)

class Base
{
public:
virtual void Func1()
{
	cout << "Func1()" << endl;
}
protected:
	int _b = 1;
	char _ch = 'x';
};
int main()
{
    
    Base b;
	cout << sizeof(b) << endl;
	return 0;
}

上⾯代码运⾏结果12bytes,除了b和ch成员,还多⼀个__vfptr放在对象的前⾯(注意有些平台可能 会放到对象的最后⾯,这个跟平台有关),对象中的这个指针我们叫做虚函数表指针(v代表virtual,f代 表function)。⼀个含有虚函数的类中都⾄少都有⼀个虚函数表指针,因为⼀个类所有虚函数的地址要 被放到这个类对象的虚函数表中,虚函数表也简称虚表,需要注意的是虚表指针是属于对象的,而虚表是属于类的。

关于虚表指针的一道选择题
以下代码执行结果是()
#include <iostream>
using namespace std;
 
class Parent {
public:
    virtual void output();
};
 
void Parent::output() {
    printf("Parent!");
}
 
class Son : public Parent {
public:
    virtual void output();
};
 
void Son::output() {
    printf("Son!");
}
 
int main() {
 
    Son s;
    memset(&s, 0, sizeof(s));
    Parent& p = s;
    p.output();
 
    return 0;
}
A Parent!  B  Son!   C  编译出错  D没有输出结果,程序运行出错
    
正确答案:D
你的答案:B
官方解析:
这道题考察了C++中虚函数表(vtable)的原理以及内存操作对对象状态的影响。

当使用memset将整个对象清零时,会破坏对象的vtable指针。在C++中,包含虚函数的类会在对象内存布局的开头包含一个指向虚函数表的指针。这个指针对于虚函数的调用至关重要。

程序执行过程分析:
1. 创建son类对象s
2. memset操作将s对象的所有内存清零,包括vtable指针
3. 通过父类引用p指向s
4. 调用p.output()时,由于vtable指针已被清零,程序会试图访问地址0处的虚函数表
5. 访问空指针会导致程序崩溃

所以D选项"没有输出结果,程序运行出错"是正确的。

其他选项分析:
A错误:不会输出"parent!",因为程序在尝试调用虚函数前就会崩溃
B错误:不会输出"son!",原因同上
C错误:不会输出"son!parent!",原因同上

这个例子说明了在C++中直接操作对象的底层内存是危险的,特别是对含有虚函数的对象。应该使用proper的构造函数和赋值操作符来操作对象,而不是直接修改内存。
知识点:C++

2 虚函数表

2.1 基类对象的虚函数表中存放基类所有虚函数的地址。同类型的对象共⽤同⼀张虚表所以虚表和虚表指针是属于类的,不同类型的对象各⾃有独⽴的虚表,所以基类和派⽣类有各⾃独⽴的虚表。

2.2派生类继承含有虚函数的基类时会把先把基类的虚表中的内容拷贝一份给自己的虚表。然后派生类会有一个虚表指针指向这个虚表,如果重写了或者派生类自己有虚函数都是在这个基础上做修改的。

2.3派⽣类中重写的基类的虚函数,派⽣类的虚函数表中对应的虚函数就会被覆盖成派⽣类重写的虚函 数地址。

2.4派⽣类的虚函数表中包含,(1)基类的虚函数地址,(2)派⽣类重写的虚函数地址完成覆盖,派⽣类 ⾃⼰的虚函数地址三个部分。

class Base
{
public:
    virtual void Func1()
    {
        cout << "Func1()" << endl;
    }
    virtual void Func2()
    {
        cout << "Func1()" << endl;
    }
protected:
    int _b = 1;
    char _ch = 'x';
};

class A :public Base
{
public:
    virtual void Func()//派生类自己的虚函数
    {
        cout << "Func1()" << endl;
    }
    virtual void Func1() override//重写基类的虚函数
    {
        cout << "Func11()" << endl;
    }

};
int main()
{

    Base b;
    A a;
    cout << sizeof(b) << endl;
    return 0;
}

未重写虚函数:

重写虚函数后:

从上面的代码中和图可以看出来派生类有自己的虚表指针,没有重写基类的虚函数时,派生类虚表中存储着就是基类的虚函数的地址当然有派生类自己的虚函数只是没有显示出来,当重写了基类里面的一个虚函数之后,这个虚函数的地址从原来基类的地址覆盖成在派生类重写后的地址了,没有被重写的还是原来的地址。

2.5 虚函数表本质是⼀个存虚函数指针的指针数组,⼀般情况这个数组最后⾯放了⼀个0x00000000标 记。(这个C++并没有进⾏规定,各个编译器⾃⾏定义的,vs系列编译器会再后⾯放个0x00000000 标记,g++系列编译不会放)

2.6虚函数存在哪的?虚函数和普通函数⼀样的,编译好后是⼀段指令,都是存在代码段的,只是虚函 数的地址⼜存到了虚表中。 虚函数表存在哪的?这个问题严格说并没有标准答案C++标准并没有规定,我们写下⾯的代码可以对⽐验证⼀下。vs下是存在代码段(常量区)

class Base {
public:
virtual void func1() { cout << "Base::func1" << endl; }
virtual void func2() { cout << "Base::func2" << endl; }
void func5() { cout << "Base::func5" << endl; }
protected:
int a = 1;
};
class Derive : public Base
{
public:
// 重写基类的func1
virtual void func1() { cout << "Derive::func1" << endl; }
virtual void func3() { cout << "Derive::func1" << endl; }
void func4() { cout << "Derive::func4" << endl; }
protected:
int b = 2;
};
int main()
{
int i = 0;
static int j = 1;
int* p1 = new int;
const char* p2 = "xxxxxxxx";
printf("栈:%p\n", &i);
printf("静态区:%p\n", &j);
printf("堆:%p\n", p1);
printf("常量区:%p\n", p2);
Base b;
Derive d;
Base* p3 = &b;
Derive* p4 = &d;
printf("Person虚表地址:%p\n", *(int*)p3);
printf("Student虚表地址:%p\n", *(int*)p4);
printf("虚函数地址:%p\n", &Base::func1);
printf("普通函数地址:%p\n", &Base::func5);
return 0;
}
运⾏结果:
栈:010FF954
静态区:0071D000
堆:0126D740
常量区:0071ABA4
Person虚表地址:0071AB44
Student虚表地址:0071AB84
虚函数地址:00711488
普通函数地址:007114BF

3 动态绑定与静态绑定

• 对不满⾜多态条件(指针或者引⽤+调⽤虚函数)的函数调⽤是在编译时绑定,也就是编译时确定调⽤ 函数的地址,叫做静态绑定。

满⾜多态条件的函数调⽤是在运⾏时绑定,也就是在运⾏时到指向对象的虚函数表中找到调⽤函数 的地址,也就做动态绑定

多态原理总结(重点)

当使用基类指针或者引用去接收派生类对象时,使用该指针或者引用去调用基类的成员函数时,如果调用的是虚函数会去拿到派生类的虚表指针到虚表中查该函数的地址,如果被重写就调用重写的,没有被重写就调用没有被重写的也就是基类自己的虚函数。

4 关于调用基类虚函数怎么拿到派生类的虚表

首先基类的指针或者引用去接收派生类对象的地址,基类的指针在数值上大小是和派生类对象的地址是一样,只是类型不同而已,只需要通过指针加偏移量的方式就可以拿到虚表指针了。

5关于构造函数为什么不能是虚函数的解析

首先,虚函数的调用是动态的,也就是运行时通过虚表指针找到虚表,在虚表中查找对应的函数地址,而普通的成员函数在编译时就确定了函数的地址的。其次,虚函数表是编译时就确定数据结构(虚表是属于类的),而虚表指针的指向是动态运行时确定的即构造一个实例类对象时确定,依赖对象的构造过程(构造函数)完成虚表指针的初始化。所以如果构造函数为虚函数,那么动态运行时,在实例化对象调用构造函数完成初始化工作时要使用虚表指针去虚表查找对应构造函数的地址,而虚表指针又是依赖于构造函数完成初始化的,所以就冲突了。

9 关于重写的虚函数的访问权限问题和虚函数传入的this指针问题

首先呢如果继承的虚函数被派生类重写了,这个虚函数就相当于派生类普通的成员函数了,可以访问派生类的所有成员函数和成员变量还要静态成员变量及从基类继承下来的公共和保护成员的。不可以访问继承下来的基类对象里面的私有成员和变量。看一段代码理解一下。

class Base
{
public:
    virtual void Func1()
    {
        cout << "Func1()" << endl;
    }
    virtual void Func2()
    {
        cout << "Func1()" << endl;
    }
protected:
    int _b = 1;
    char _ch = 'x';
};

class A :public Base
{
public:
    virtual void Func()
    {
        cout << "Func1()" << endl;
    }
    virtual void Func1() override//被重写的虚函数相当于派生类自己的成员函数了
    {
        cout << a+11 << endl;
        cout << _b + 11 << endl;
    }
private:
    int a = 10;
};
int main()
{
    Base b;
    A a;
    Base* ab = &a;
    a.Func1();
    ab->Func1();
    cout << sizeof(b) << endl;
    return 0;
}

当使用基类指针或者引用调用基类的成员函数时特别是重写的,明明传入的不应该是基类对象的this指针吗,怎么可以在这个重写的虚函数里面访问到派生类的成员呢?还是那句话,使用基类指针或者引用接收的派生类指针或者对象,基类指针大小和派生类对象的地址大小是一样的,在调用的被重写的虚函数里面通过this指针加偏移量就可以访问派生类的成员了。当然调用没被重写的虚函数是不能访问派生类成员的,只能访问基类自己的成员。

10 虚函数的"虚特性"会在整个继承链中持续传递

已经被重写的虚函数在派生类继承后仍然可以再次重写。在C++中,虚函数的"虚特性"会在整个继承链中持续传递,每个派生类都可以选择重写(override)基类的虚函数。以下是关键点说明:

核心概念:

  1. 持续性‌:一旦函数被声明为 virtual,它在后续所有派生类中‌保持虚函数特性‌(即使派生类重写时省略 virtual 关键字)。

  2. 多次重写‌:每个派生类都可以重写继承的虚函数,无论该函数在中间基类中是否已被重写。

示例代码:

#include <iostream>

class Base {
public:
    virtual void show() {
        std::cout << "Base\n";
    }
};

class Derived : public Base {
public:
    void show() override { // 第一次重写
        std::cout << "Derived\n";
    }
};

class SecondDerived : public Derived {
public:
    void show() override { // 再次重写
        std::cout << "SecondDerived\n";
    }
};

int main() {
    SecondDerived obj;
    obj.show(); // 输出: SecondDerived

    // 通过基类指针验证多态
    Base* basePtr = &obj;
    basePtr->show(); // 输出: SecondDerived (动态绑定)

    Derived* derivedPtr = &obj;
    derivedPtr->show(); // 输出: SecondDerived
}

关键行为:

  • 动态绑定‌:通过基类指针/引用调用虚函数时,实际调用的是最终派生类的重写版本(如 basePtr->show() 调用 SecondDerived::show())。

  • ‌重写链

    ‌:Derived重写了Base::show() SecondDerived重写了Derived::show()(本质仍是重写原始的Base::show())

特殊控制:

  • 阻止进一步重写‌:使用 final 关键字可禁止后续派生类重写:

11 关于基类对象的切片问题的深度分析

当一个基类的虚函数被重写之后,只是派生类中的虚表被覆盖了而不是基类中的虚表的地址被覆盖了。当发生了切片的时候,得到的是一个全新的基类对象,注意,有纯虚函数的基类是不能被切片的,因为不能实例化基类对象出来。具体来说:

  • 当派生类对象通过值传递方式传递给接收基类参数的函数时,会发生对象切片

  • 切片后得到的是一个纯粹的基类对象,不包含任何派生类的信息

  • 这个基类对象的虚函数表指针(vptr)指向的是基类的虚函数表

  • 所以即使派生类重写了虚函数,通过切片后的基类对象调用时,仍然调用的是基类版本的虚函数

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

class Derived : public Base {
public:
    void foo() override { cout << "Derived::foo" << endl; }
};

void func(Base b) {  // 值传递导致切片
    b.foo();  // 总是调用Base::foo
}

int main() {
    Derived d;
    func(d);  // 输出"Base::foo"而不是"Derived::foo"
}

总的来说切片是调用基类的拷贝构造或者或者去定义一个新的基类对象出来。

  • 当发生切片时:

Base b = d;  // 切片发生

  • 实际发生的是:

    1. 调用基类的‌拷贝构造函数

    2. 只复制基类部分的数据成员

    3. 关键步骤:将新对象的vptr设置为‌指向基类的虚表

  • 构造过程:

    1. 首先初始化基类部分:设置vptr指向‌基类‌虚表 (Base_vtable)

    2. 然后初始化派生类部分:重新设置vptr指向‌派生类‌虚表 (Derived_vtable)


网站公告

今日签到

点亮在社区的每一天
去签到