该内容中的代码以及解释都是在 vs2022 下的 x86 环境中,涉及的指针都是 4 个字节,如果要在其他的平台下运行,部分代码需要改动。
Ⅰ. 多态的概念
通俗来说,多态就是不同的类型对象,去做同一件事情的时候会产生不一样的状态。
举个例子:对于买票找个行为,【普通人】去买票可能是【全价】,而**【学生】买票可以【半价】,而对于【军人】来说可以【优先购票】**。
多态分为静态和动态两种类型:
-
静态多态:在编译时的,最经典的体现就是函数重载。
int i = 0, j = 1; double a = 1.1, b = 2.2; swap(i, j); swap(a, b); -
动态多态:在程序运行时的,根据不同的对象调用不同的函数完成不同的行为。(原理是到所指对象的虚表中找到要调用的虚函数)
Ⅱ. 多态的定义和实现
1、多态的构成条件
- 继承关系。
- 必须通过基类的指针或者基类的引用调用虚函数,这个过程也称为向上转型。
- 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行了重写。
那么问题来啦,什么是虚函数?重写又是什么?请看下面!
2、虚函数 -- virtual
虚函数指的是被 virtual 修饰的类成员函数!
注意事项☢️
- 静态成员函数不能作为虚函数,因为其没有
this指针,无法找到虚表。(虚表下面会讲) inline内联函数是可以用virtual修饰的,只不过一旦修饰后,内联函数的特性就消失了。(因为内联函数是没有地址的,而虚函数是有地址的)- 构造函数不能是虚函数,因为定义虚函数是为了构成多态,多态调用要去对象的虚表中找到虚函数,但是在构造函数阶段,对应的虚表指针都还没有初始化,所以构造函数不能有虚函数,否则会出错!
- 虽然【虚函数】和【虚继承】都用了
virtual修饰,但是他们的作用是完全不一样的,虚函数是为了实现多态,而虚继承是为了解决菱形继承的问题,也就是数据冗余和二义性的问题。 - 虚函数只重写实现,而不会重写接口,接口依然还是父类的接口,包括参数列表等。(可以看笔记最下面常见面试题的选择题最后一道题,是一个坑)
虚函数的定义的例子:
class Animal
{
public:
virtual void speak()
{
std::cout << "I am an animal." << std::endl;
}
};
class Cat : public Animal
{
public:
void speak() override
{
std::cout << "I am a cat." << std::endl;
}
}; 这段代码定义了两个类 Animal 和 Cat,其中 Cat 是 Animal 的子类。Animal 类中声明了一个虚函数 speak(),该函数在声明时使用了 virtual 关键字。Cat 类继承了 Animal 类,并覆盖了 speak() 函数。
并且可以使用 override 关键字来显式地指定覆盖了基类的虚函数。
3、重写(覆盖)
派生类中有一个跟基类的接口完全相同的虚函数,即派生类虚函数与基类虚函数的 返回值类型**、函数名称、**参数列表 完全相同,则称子类的虚函数重写了基类的虚函数,也被称为覆盖。
举个例子:
class Person
{
public:
virtual void BuyTicket()
{
cout << "全价购票" << endl;
}
};
class Student : public Person
{
public:
virtual void BuyTicket()
{
cout << "半价购票" << endl;
}
};
class Soldier : public Person
{
public:
virtual void BuyTicket()
{
cout << "优先购票" << endl;
}
};
//void Fun(person* p) 用指针也是可以的
void Fun(Person& p) // 用父类的引用接收参数,去调用其他对象的虚函数
{
p.BuyTicket();
}
int main()
{
Person p; // 普通人
Student st; // 学生
Soldier sd; // 军人
Fun(p);
Fun(st);
Fun(sd);
// 用指针传参的话:
//Fun(&p);
//Fun(&st);
//Fun(&sd);
return 0;
}
🚩 运行结果:
全价购票
半价购票
优先购票🏗 解析: 这里
Fun()函数的参数是基类对象的引用,通过接收对象,来调用不同派生类对象的虚函数,实现多态。
- 假设这里没有用
virtual修饰基类的BuyTicket()函数,那么这两个派生类的同名函数与基类之间就构成了隐藏,而不是重写!那么通过Fun函数,我们只能调用到Person类自己的函数,因为形参里面的对象类型就是Person,所以传什么类型的对象过来都没作用,因为没有形成多态。 - 假设我们在这里把参数列表中的引用去掉,那么结果和第一种情况一样,我们只能调用到
Person类自己的函数,因为这里涉及到虚表指针的知识,这里浅谈一下:当这里是父类对象而不是父类对象的引用或指针的时候,它接收了参数,其实是一个切片的过程,如果是派生类切给父类,那这里面只会将成员变量切过去,但是不会把虚表指针也切过去,所以p对象还是指向Person类的虚表!
4、重写的三个特例情况
① 协变
所谓协变,就是基类与派生类的虚函数返回值类型可以不同,但两者的返回值必须要有继承关系。即基类虚函数返回基类对象的指针或引用,派生类虚函数返回派生类对象的指针或引用,就称为协变。
举个例子:
class A
{};
class B : public A
{};
class Person
{
public:
virtual A* f()
{
cout << "A::f()" << endl;
return new A;
}
};
class Student : public Person
{
public:
virtual B* f()
{
cout << "B::f()" << endl;
return new B;
}
};
int main()
{
Person p;
Student s;
Person* ptr;
ptr = &p;
ptr->f();
ptr = &s;
ptr->f();
return 0;
}
🚩 运行结果:
A::f()
B::f() 假设将 B 类中的继承 A 的关系去掉,那么这个程序就会报错,因为这样子就不构成了虚函数的重写了!
② 析构函数的重写
如果基类的析构函数设为虚函数,那么基类的析构函数就和派生类的析构函数构成重写!虽然基类与派生类析构函数名字不同,看起来好像不符合重写的规则,实则不然,编译器对析构函数的名称做了特殊处理,在编译后任何类的析构函数的名称统一处理成 destructor(),此时就构成了重写!
❓ 问题: 那么如果我们不对基类的析构函数处理为虚函数会发生什么❓❓❓
🐛 解答: 可能会发生内存泄漏!假设有以下的情况:
class Person
{
public:
~Person()
{
cout << "~Person()" << endl;
}
};
class Student : public Person
{
public:
~Student()
{
cout << "~Student()" << endl;
}
};
int main()
{
// 若为下面的普通对象则没有问题,因为他们会分别去调用他们的析构函数,与是否构成重写没有关系
// Person p;
// Student s;
// 只有派生类Student的析构函数重写了Person的析构函数
// 下面的delete对象调用析构函数,才能构成多态,才能保证p1和p2指向的对象正确的调用析构函数。
Person* p1 = new Person;
Person* p2 = new Student;
delete p1;
delete p2;
return 0;
}
🚩 运行结果:
~Person()
~Person() 假设基类的析构函数没有被处理为虚函数,那么它和派生类的析构函数构成的是 隐藏 关系(因为编译器统一将析构函数处理为**destructor()**,但因为不是虚函数,所以不符合重写),而这里的 p1 和 p2 都是 Person 类的指针,也就是说他们在 delete 的时候只能去调用 Person 的析构函数,即 p2 没办法去调用到 Student 的析构函数去清理类内的数据。
若此时 Student 类中没有需要清理内存的成员,那没有问题。但是如果有需要清理的成员,比如说开辟的动态内存,那么就没有释放,就会造成内存泄漏.......
所以我们对于基类的析构函数,推荐一律处理为虚函数,这样子即使是 Person 类的指针调用 Student,也能去访问 Student 的析构函数,达到了内存清理的目的!
这也是为什么编译器要将析构函数统一处理为 destructor() 的原因,因为这样子他们的函数名就相同了,且都是没有返回值,也没用传入参数,只需要我们加上 virtual 修饰就构成了重写的条件!
class Person
{
public:
virtual ~Person()
{
cout << "~Person()" << endl;
}
};
class Student : public Person
{
public:
virtual ~Student()
{
cout << "~Student()" << endl;
}
};
int main()
{
// 若为下面的普通对象则没有问题,因为他们会分别去调用他们的析构函数,与是否构成重写没有关系
// Person p;
// Student s;
// 只有派生类Student的析构函数重写了Person的析构函数
// 下面的delete对象调用析构函数,才能构成多态,才能保证p1和p2指向的对象正确的调用析构函数。
Person* p1 = new Person;
Person* p2 = new Student;
delete p1;
delete p2;
return 0;
}
🚩 运行结果:
~Person()
~Student()
~Person() 第一个析构是 p1 所指对象的析构,后两个析构是 p2 所指对象的析构,分别调了派生类和父类的析构函数完成析构,符合继承原理!

③ 派生类中重写的虚函数没加 virtual
这里不写子类重写的虚函数的 virtual 是可以的,但是注意不能不写基类中虚函数的 virtual,因为它认为派生类是先继承父类的虚函数的,继承下来之后就有了 virtual 的属性了,派生类只是重写了这个 virtual 函数。
class Person
{
public:
virtual void BuyTicket()
{
cout << "全价购票" << endl;
}
};
class Student : public Person
{
public:
void BuyTicket()
{
cout << "半价购票" << endl;
}
};
void Fun(Person& p)
{
p.BuyTicket();
}
int main()
{
Person p;
Student s;
Fun(p);
Fun(s);
return 0;
}
🚩 运行结果:
全价购票
半价购票5、final 和 override关键字
从上面可以看出,C++ 对函数重写的要求比较严格,但是有些情况下由于疏忽,可能会导致函数名字母次序写反而无法构成重载,而这种错误在编译期间是不会报出的,只有在程序运行时没有得到预期结果才来 debug 会得不偿失,因此 C++11 提供了 override 和 final 两个关键字,可以帮助用户检测是否重写。
首先是 final 关键字,它有如下作用:
- 修饰类时,表示该类不能被继承。
- 修饰成员函数时,表示该成员函数不能被子类继承或重写。
class Car
{
public:
virtual void Drive() final {} // 在不想被继承的函数之前加上final
};
class Benz :public Car
{
public:
virtual void Drive() { cout << "Benz-舒适" << endl; }
};
🚩 运行结果:
error C3248: “Car::Drive”: 声明为“final”的函数无法被“Benz::Drive”重写然后是 override 关键字,它有如下作用:
- 在编译阶段检查派生类的虚函数是否重写了基类对应的虚函数,如果没有重写编译报错。
- 注意
override只能修饰子类的虚函数。
class Car
{
public:
virtual void Drive(char ch) {}
};
class Benz :public Car
{
public:
// 在想检测的虚函数的实现之前加上override
virtual void Drive(int i) override
{
cout << "Benz-舒适" << endl;
}
};
🚩 运行结果:
error C3668: “Benz::Drive”: 包含重写说明符“override”的方法没有重写任何基类方法6、重载、覆盖 ( 重写 ) 、隐藏 ( 重定义 ) 的区别总结☢️
-
重载:
- 两个函数必须在同一作用域。
- 函数名相同,参数不同。
-
隐藏(重定义):
- 两个函数分别在基类和派生类的作用域中。
- 只要求函数名相同。
- 两个基类和派生类的同名函数不构成重写的话则构成隐藏。
-
重写(覆盖):
- 两个函数分别在基类和派生类的作用域。
- 函数名、返回值类型、参数都必须相同。(协变与析构函数除外)
- 重写的函数必须是虚函数。
Ⅲ. 抽象类
1、概念
在虚函数的后面加上 = 0 ,表示这个函数为纯虚函数。包含纯虚函数的类叫做抽象类,并且抽象类不能实例化出对象。
派生类继承后也不能实例化出对象,只有派生类重写纯虚函数,派生类才能实例化出对象。也就是说纯虚函数要求派生类必须重写,另外纯虚函数更体现出了接口继承的思想。
🐛 除此之外,纯虚函数是可以有函数体的,但是由于无法实例出对象,以及它一般会被派生类重写该纯虚函数,并且纯虚函数是用来表达一些比较抽象的事物比如植物、动物等,所以一般基类的纯虚函数的实现内容是没必要给的,因为没什么意义。
🗯 抽象类的定义如下:
class A
{
public:
A(){ cout << "A()" << endl; }
virtual void fun() = 0
{
cout << "可以有实现内容,但是没有意义" << endl;
}
};
class B : public A
{
public:
B() { cout << "B()" << endl; }
virtual void fun()
{
cout << "B::fun()" << endl;
}
};
int main()
{
//A a; // ❌不允许实例化出抽象类
//A* ppa = new A; // ❌也不允许通过动态内存开辟A类
B b;
b.A::fun(); // 可以通过b来调用A的虚函数
A& pa = b; // 也可以用A类的引用来调用子类的虚函数
pa.fun();
return 0;
}
🚩 运行结果:
A()
B()
可以有实现内容,但是没有意义
B::fun() 🗡 假设这里我们没有重写 B 类的虚函数,那么 B 类也是没办法生成对象的。
🍦 总结:
- 抽象类不允许实例化出对象,但不代表不能被其派生类调用派生类可访问的纯虚函数以及成员。
- 动态开辟的抽象类也是不允许的!
- 纯虚函数保持了多态的特性,通过父类的指针或者引用完成多态行为。
- 对于抽象类,当我们定义派生类对象的时候,也会去调用抽象类的构造函数。
- 若子类没有重写纯虚函数,则子类也无法实例化出对象。
2、接口继承和实现继承
普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实现。虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口。
所以如果不实现多态,不要把函数定义成虚函数!
Ⅳ. 多态的原理
1、问题引入
我们先来看一道经典的面试题:
class Base
{
public:
virtual void Func1()
{
cout << "Func1()" << endl;
}
private:
char _c = '\0';
int _b = 1;
};
int main()
{
cout << sizeof(Base) << endl;
return 0;
} 很明显,这道题要我们求基类 Base 的大小,如果我们不了解多态的底层原理的话,在这里我们可能觉得这只是一个比较简单的结构体内存对齐问题,假设在 32 位平台下面运行,我们可能会以为这是 8 个字节,但是答案是 12 个字节!为什么?
🚩 运行结果:
12
我们通过定义一个基类 Base 的对象,通过监视窗口来看看是什么情况:
Base b;
咦,很奇怪是不是?我们本以为 Base 类里只有 _b 和 _c 两个成员,但是这里的成员前面又多了一个 _vfptr(注意有些平台可能把该指针放到下面,这个跟平台有关) ,这个是个什么东西❓❓❓
但是从这里可以看出来,_vfptr 是个指针,所以最后我们的类的大小加上 4 个字节,就是 12 个字节了!下面让我们来一探究竟!
2、虚函数表 & 虚函数表指针
上面类中出现的 _vfptr 指针(v 代表 virtual,f 表示 function)叫做虚函数表指针,简称为虚表指针。而虚表指针中存放的是指向虚表的地址,通过这个地址就能找到虚表!
而虚表实际上是一个指向虚函数的指针数组,实际上虚函数是存放在代码段(常量区)中的,所以虚函数是在程序编译期间就确定的,但是因为对象创建是在程序运行的时候,所以虚表指针就是在运行时才创建的!
💥注意:【虚表】跟虚继承中的【虚基表】是不一样的,虽然它们都用了 virtual 关键字,但是他们的使用场景完全不一样,解决的问题也是不一样的。虚继承产生的是虚基表,由虚基表指针指向它,而虚基表里面存的是距离虚基类的偏移量!
下面我们给 Base 再增加一个虚函数 Func2() 和一个普通函数 Func3() 来观察一下:
// 代码一
// Base再增加一个虚函数Func2和一个普通函数Func3
class Base
{
public:
virtual void Func1()
{
cout << "Func1()" << endl;
}
virtual void Func2()
{
cout << "Func2()" << endl;
}
void Func3()
{
cout << "Func3()" << endl;
}
private:
char _c = '\0';
int _b = 1;
};
int main()
{
Base bs;
return 0;
}
❓ 可能有些童鞋就会问:咦!那 func3 去哪里了?
💡 解答: 哎呀,知识不能乱了,还记得吗,类的普通成员函数是不包含在类中的,它是存在于公共代码段中的,只是我们这里有了虚函数之后,为了实现多态的行为,所以必须得有虚函数表,所以才将虚函数表指针算入了成员变量中,而普通函数是不算入的!
好啦,接下来我们继续观察,这次我们写一个派生类,来观察他们的虚表指针以及虚表:
// 代码二
class A
{
public:
virtual void fun()
{
cout << "A::fun()" << endl;
}
};
class B : public A
{
public:
virtual void fun()
{
cout << "B::fun()" << endl;
}
};
void Func(A& a)
{
a.fun();
}
int main()
{
A a;
Func(a);
B b;
Func(b);
return 0;
}
🚩 运行结果:
A::fun()
B::fun()下图分别对三种情况进行解析:
问题:为什么一定要求说要用父类的指针或引用去调用虚函数,而不是父类的对象❓❓❓
void Func(A& a) // 为什么这里不能是父类对象?而一定要是父类的引用或指针呢?
{
a.fun();
}先来看一下构成多态和不构成多态时候的区别:
-
若构成多态:父类的指针或引用,在程序运行时到指定的对象中的虚表去找对应的虚函数调用,所以指向的是父类对象,则调用的是父类的虚函数,指向的是子类的对象,则调用的是子类的虚函数。
-
若不构成多态,也就是下面这种情况:
void Func(A a) // 这里用的是父类对象 { a.fun(); } 那么这里调用的就是编译时确定的调用那个函数,主要看的是
a的类型,这里是A类对象,所以只会去调用A类对象的函数,传其他的派生类对象过来也没有影响。
那好像还是没说透呀,为什么父类对象就不能构成多态的条件❓❓❓
其实这是一个切片的规则,我们对上面的代码,每个类都加个成员变量,来观察一下切片的过程:
- 对于父类的指针或引用:
class A
{
public:
virtual void fun()
{
cout << "A::fun()" << endl;
}
int _a = 1;
};
class B : public A
{
public:
virtual void fun()
{
cout << "B::fun()" << endl;
}
int _b = 250;
};
void Func(A& p)
{
p.fun();
}
int main()
{
A a;
Func(a);
B b;
Func(b);
return 0;
}
- 对于父类的对象:
void Func(A p) // 这里用父类对象
{
p.fun();
}
int main()
{
A a;
Func(a);
B b;
b._a = 100; // 将b中的_a置为100,才容易观察其中的切片变化
Func(b);
return 0;
}
简单地说,就是切片过程中不会将虚表指针 _vfptr 切给父类,所以如果是对象赋值,这样子就达不到多态的目的!
了解:还可以从汇编的角度观察多态!
class Person {
public:
virtual void BuyTicket() { cout << "买票-全价" << endl; }
};
class Student : public Person {
public:
virtual void BuyTicket() { cout << "买票-半价" << endl; }
};
void Func(Person* p) {
p->BuyTicket();
}
int main()
{
Person mike;
Func(&mike);
mike.BuyTicket();
return 0;
}
// 以下汇编代码中跟这个问题不相关的都被去掉了
void Func(Person* p) {
...
p->BuyTicket();
// p中存的是mike对象的指针,将p移动到eax中
001940DE mov eax,dword ptr [p]
// [eax]就是取eax值指向的内容,这里相当于把mike对象头4个字节(虚表指针)移动到了edx
001940E1 mov edx,dword ptr [eax]
// [edx]就是取edx值指向的内容,这里相当于把虚表中的头4字节存的虚函数指针移动到了eax
00B823EE mov eax,dword ptr [edx]
// call eax中存虚函数的指针。这里可以看出满足多态的调用,不是在编译时确定的,是运行起来以后到对象的中取找的。
001940EA call eax
001940EC cmp esi,esp
}
int main()
{
...
// 首先BuyTicket虽然是虚函数,但是mike是对象,不满足多态的条件,所以这里是普通函数的调用转换成地址时,是在编译时已经从符号表确认了函数的地址,直接call 地址
mike.BuyTicket();
00195182 lea ecx,[mike]
00195185 call Person::BuyTicket (01914F6h)
...
}🚩 运行结果:
买票-全价
买票-全价简单点说就是这样子:
- 不构成多态的话,编译时直接调用当前对象的函数地址。
- 构造多态的话,运行时到指向的对象的虚表中找到要调用的虚函数。
3、虚表的总结与注意事项💥
-
同一类型的对象,共享同一张虚表。并且要注意如果虚表指针的地址不同,则代表它们的虚表不一样,若虚表地址一样的话,则它们是共享同一张虚表的!
-
虚表是在编译阶段产生的,而不是在运行的时候产生,并且虚表存放在代码段(常量区)!而虚表指针是在构造函数初始化列表的时候产生的,这也是为什么构造函数不能定义为虚函数的原因!
-
覆盖就是指虚表中虚函数被覆盖,另外重写是语法性的叫法,覆盖是原理性的叫法。
-
虚表本质是一个存虚函数指针的指针数组(注意区分虚表指针和虚函数指针),这个数组最后面放了一个
nullptr,也就是说虚表中存的是指针,而不是虚函数,这一点很容易混淆,因为虚函数和普通函数一样,都是存在代码段中的。
-
一个含有虚函数的类中至少有一个虚函数表指针(可能有多个)。
-
派生类的虚表生成过程:
- 先将基类中的虚表内容拷贝一份到派生类虚表中。
- 如果派生类重写了基类中某个虚函数,用派生类自己的虚函数指针覆盖虚表中基类的虚函数指针。
- 派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。
4、动态绑定和静态绑定
-
静态绑定又称为前期绑定(早绑定),是在程序编译期间确定了程序的行为,也称为静态多态,比如:函数重载。
-
动态绑定又称为后期绑定(晚绑定),是在程序运行期间根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态。
5、写一段代码,证明一下虚表是存在常量区的
我们可以将虚表的地址和存放在栈、堆、静态区、常量区的数据进行比较,看看与谁最接近
class Base
{
public:
virtual void fun1()
{
cout << "Base::fun1()" << endl;
}
};
class Derive : public Base
{
public:
virtual void fun1()
{
cout << "Derive::fun1()" << endl;
}
};
int main()
{
Base b;
Base* p = &b;
// 在vs中取虚表地址就是取对象的前四个字节
// 所以这里我们把对象指针先转化为int*,然后再将其解引用得到他的地址
printf("_vfptr:%p\n", *((int*)p));
int i; //栈上的数据
int* j = new int; //堆上的数据
static int Global = 0; //静态区的数据
const char* c = "liren"; //常量区的数据
printf("栈上的地址:%p\n", &i);
printf("堆上的地址:%p\n", j);
printf("静态区上的地址:%p\n", &Global);
printf("常量区上的地址:%p\n", c);
return 0;
}
🚩 运行结果:
_vfptr:00639B34
栈上的地址:012FF7F8
堆上的地址:014FD5A8
静态区上的地址:0063C3FC
常量区上的地址:00639B70 可以很直观的看见,虚表放在常量区!
Ⅴ. 单继承和多继承关系的虚函数表
1、单继承中的虚函数表
我们先来观察一下下面的代码:
class Base
{
public:
virtual void func1() { cout << "Base::func1()" << endl; }
virtual void func2() { cout << "Base::func2()" << endl; }
private:
int _a;
};
class Derive : public Base
{
public:
virtual void func1() { cout << "Derive::func1()" << endl; }
virtual void func3() { cout << "Derive::func3()" << endl; }
virtual void func4() { cout << "Derive::func4()" << endl; }
private:
int _b;
};
int main()
{
Base b;
Derive d;
return 0;
}

很奇怪是不是,监视窗口里面找不到派生类对象 d 自己的两个虚函数 func3 和 func4,既然这样子,我们只能通过内存窗口来看看这两个虚函数究竟被 vs 编译器藏在了哪里!

唉~有没有发现我们只是用了前面虚表的知识才比较感性的说明这两个是 func3 和 func4 的地址,那要是只是刚刚好是巧合呢?
写一个程序,打印出虚表里面的函数,证明一下隐藏的虚函数指针在虚表里面
class Base
{
public:
virtual void func1() { cout << "Base::func1()" << endl; }
virtual void func2() { cout << "Base::func2()" << endl; }
private:
int _a;
};
class Derive : public Base
{
public:
virtual void func1() { cout << "Derive::func1()" << endl; }
virtual void func3() { cout << "Derive::func3()" << endl; }
virtual void func4() { cout << "Derive::func4()" << endl; }
private:
int _b;
};
// 写一个程序,打印出虚表里面的函数,确认一下是否真的在虚表里面
typedef void(*VFunc)(); // 由于等会要传_vfptr也就是存函数指针的数组指针,类型是void*,所以我们把他们都统一重命名为VFTunc
void PrintVFT(VFunc* ptr) // 这里ptr是个存函数指针的数组指针
{
// 依次取虚表中的虚函数指针打印并调用。调用就可以看出存的是哪个函数
printf("_vfptr:%p\n", ptr);
for (int i = 0; ptr[i] != nullptr; ++i)
{
printf("_vfptr[%d]:%p --> ", i, ptr[i]);
ptr[i](); //通过函数指针来调用虚函数来打印函数的内容
}
cout << endl;
}
int main()
{
// 思路:取出b、d对象的头4bytes,就是虚表的指针,前面我们说了虚函数表本质是一个存虚函数指针的指针数组,这个数组最后面放了一个nullptr
// 1. 先取b的地址,强转成一个int*的指针
// 2. 再解引用取值,就取到了b对象头4bytes的值,这个值就是指向虚表的指针
// 3. 再强转成VFunc*,因为虚表就是一个存VFunc类型(虚函数指针类型)的数组。
// 4. 虚表指针传递给PrintVTable进行打印虚表
// 5. 需要说明的是这个打印虚表的代码经常会崩溃,因为编译器有时对虚表的处理不干净,虚表最后面没有放nullptr,导致越界,这是编译器的问题。我们只需要点目录栏的 - 生成 - 清理解决方案,再编译就好了。
Base b;
PrintVFT((VFunc*)(*((int*)&b)));
Derive d;
PrintVFT((VFunc*)(*((int*)&d)));
return 0;
}
2、多继承中的虚函数表
多继承中的虚表那就更复杂啦!但是我们依然可以用单继承中打印虚表内容的程序来测试以下,假设有以下的情况:
// 多继承
class Base1
{
public:
virtual void func1() { cout << "Base1::func1" << endl; }
virtual void func2() { cout << "Base1::func2" << endl; }
private:
int b1;
};
class Base2
{
public:
virtual void func1() { cout << "Base2::func1" << endl; }
virtual void func2() { cout << "Base2::func2" << endl; }
private:
int b2;
};
class Derive : public Base1, public Base2
{
public:
virtual void func1() { cout << "Derive::func1" << endl; }
virtual void func3() { cout << "Derive::func3" << endl; }
private:
int d1;
};
int main()
{
Base1 b1;
Base2 b2;
Derive d;
return 0;
}
比起单继承,多继承的派生类会生成多份虚表,也就印证了一个道理:一个对象的虚表不只有一张!
很明显,对于其他的函数我们都能理解,这里和单继承一样,还是找不到派生类对象自己的虚函数 func3。
所以啊,又得再来打印一遍他们的虚表地址和函数调用,看看是否也符合我们的与其,也就是看看是否 func3 也在虚表内。借助单继承里面打印地址的代码,我们来测试一下:
typedef void(*VFunc)(); // 由于等会要传_vfptr也就是存函数指针的数组指针,类型是void*,所以我们把他们都统一重命名为VFTunc
void PrintVFT(VFunc* ptr) // 这里ptr是个存函数指针的数组指针
{
// 依次取虚表中的虚函数指针打印并调用。调用就可以看出存的是哪个函数
printf("_vfptr:%p\n", ptr);
for (int i = 0; ptr[i] != nullptr; ++i)
{
printf("_vfptr[%d]:%p --> ", i, ptr[i]);
ptr[i]();
}
cout << endl;
}
int main()
{
Base1 b1;
Base2 b2;
Derive d;
PrintVFT((VFunc*)(*((int*)&d)));
PrintVFT((VFunc*)(*((int*)((char*)&d + sizeof(Base1))))); // 括号比较多,看的时候注意看仔细
return 0;
} ☢️注意: 派生类有两张虚表,不过借助它们之间是紧挨着的关系,我们可以直接把第一张虚表取出来,然后第二张虚表就是第一张虚表加上第一个继承的父类 Base 的大小 sizeof(Base1),注意要先将 &d 强转为 char* ,因为 &d 是一个 Derive*,所以加了 sizeof(Base1) 后并不是跳到第二张虚表,为了让其加上相隔的字节数,所以我们得将 &d 转化为 char*。

由此可以看出,派生类自己的虚函数放在继承下来的第一个父类的虚表中,所谓的第一个父类是按照声明顺序来判断的!然后其他的规则是和单继承一样的!
3、菱形继承 & 菱形虚拟继承
实际中我们不建议设计出菱形继承及菱形虚拟继承,一方面太复杂容易出问题,另一方面这样的模型,访问基类成员有一定得性能损耗。我们来看下面的代码,体会体会!
🗯 代码一:
// 菱形继承
class A
{
public:
virtual void fun()
{
cout << "A::fun()" << endl;
}
public:
int _a;
};
class B : public A
{
public:
virtual void fun()
{
cout << "B::fun()" << endl;
}
public:
int _b;
};
class C : public A
{
public:
virtual void fun()
{
cout << "C::fun()" << endl;
}
public:
int _c;
};
class D : public B, public C
{
public:
int _d;
};
int main()
{
D d;
d.B::_a = 1;
d.C::_a = 2;
d._b = 3;
d._c = 4;
d._d = 5;
return 0;
}
和学继承的时候是一样的,这里存在着数据冗余和二义性,所以得用菱形虚拟继承来解决!
🗯 代码二:
// 菱形虚拟继承
class A
{
public:
virtual void fun()
{
cout << "A::fun()" << endl;
}
public:
int _a;
};
class B : virtual public A // 对两个腰部的类添加virtual修饰
{
public:
virtual void fun()
{
cout << "B::fun()" << endl;
}
public:
int _b;
};
class C : virtual public A // 对两个腰部的类添加virtual修饰
{
public:
virtual void fun()
{
cout << "C::fun()" << endl;
}
public:
int _c;
};
class D : public B, public C
{
public:
int _d;
};
int main()
{
D d;
d.B::_a = 1;
d.C::_a = 2;
d._b = 3;
d._c = 4;
d._d = 5;
return 0;
} 可以看出来,这里就是我们的菱形虚拟继承,在继承的部分内容中,我们提及到,为了解决数据冗余和二义性,我们得用菱形虚拟继承来解决,但是在这里,如果不对 D 里面的虚函数进行重写,就会报错:
为什么呢❓❓❓
因为这里如果我们不对 D 类的虚函数进行重写,按我们之前学习的虚继承知识,这里的 A 只有一份,而 B 和 C 都对 A 的虚函数进行了重写,那么这样子的话 D 就不知道要继承 B 重写 A 的那份还是继承 C 重写 A 的那份,间接的产生了新的二义性!
💡 解决方法: 既然 D 没办法判断要继承谁的那份虚函数,那就 得让 D 类重写这个有二义性的虚函数!
class D : public B, public C
{
public:
virtual void fun() //对菱形继承下来的虚函数必须进行重写!
{
cout << "D::fun()" << endl;
}
public:
int _d;
}; 下面我们用内存窗口来观察一下:

可以发现,这里只有 A 有虚表,而 B 和 C 中的是虚基表。(注意它和虚表是没有关系的)
除此之外,利用菱形虚拟继承,我们很好的解决了数据冗余和二义性,只不过这让我们需要更大的学习成本!
问题: 我们在继承部分不是说过,虚基表里面前四个字节是留给多态的吗,那为什么这里虚基表中的前四个字节还是空的呢?
下面我们让 B 和 C 类拥有自己的虚函数,来看看效果:
// 菱形虚拟继承
class A
{
public:
virtual void fun()
{
cout << "A::fun()" << endl;
}
public:
int _a;
};
class B : virtual public A
{
public:
virtual void fun()
{
cout << "B::fun()" << endl;
}
virtual void fun1() // B多了一个自己的虚函数
{
cout << "B::fun1()" << endl;
}
public:
int _b;
};
class C : virtual public A
{
public:
virtual void fun()
{
cout << "C::fun()" << endl;
}
virtual void fun1() // C也多了一个自己的虚函数
{
cout << "C::fun1()" << endl;
}
public:
int _c;
};
class D : public B, public C
{
public:
virtual void fun()
{
cout << "D::fun()" << endl;
}
public:
int _d;
};
int main()
{
D d;
d.B::_a = 1;
d.C::_a = 2;
d._b = 3;
d._c = 4;
d._d = 5;
return 0;
} 代码三与代码二不同的点在于,代码三的 B 和 C 类又多了自己的虚函数 fun1(),这样子我们的对象模型就变的更复杂了,我们继续打开内存窗口来观察一下:

从图中可以观察到菱形虚拟继承的对象模型是比较复杂的,所以在平时的设计中,非常不建议设计为菱形继承,尽量避开!但是我们也要了解菱形继承的底层原理!
4、单继承和多继承下的虚表总结💥
- 一个类可能有多张虚表。
- 虚基表中的第一个指针大小的空间存放的是当前地址距离虚函数表的偏移量。
- 在多继承中,派生类对象自己的成员函数是存在第一个继承基类部分的虚函数表的。
- 在内存布局中,父类的布局是依次按声明顺序排列的。
- 设计对象模型时候避免出现菱形继承,因为比较复杂,容易搞错!
- 如果派生类有自己的虚函数,由于
vs编译器的原因无法在监视窗口的派生类对象中的虚表观察到,所以我们可以自己写个程序确认一下是否存在虚表中,结果也证实了派生类自己的虚函数在虚表中是存在的,只不过没有在监视窗口展现出来。
Ⅵ. 继承和多态的常见面试题
1、选择题
- 下面哪种面向对象的方法可以让你变得富有( )
A: 继承 B: 封装 C: 多态 D: 抽象
- ( )是面向对象程序设计语言中的一种机制。这种机制实现了方法的定义与具体的对象无关,而对方法的调用则可以关联于具体的对象。
A: 继承 B: 模板 C: 对象的自身引用 D: 动态绑定
- 面向对象设计中的继承和组合,下面说法错误的是?()
A:继承允许我们覆盖重写父类的实现细节,父类的实现对于子类是可见的,是一种静态复用,也称为白盒复用
B:组合的对象不需要关心各自的实现细节,之间的关系是在运行时候才确定的,是一种动态复用,也称为黑盒复用
C:优先使用继承,而不是组合,是面向对象设计的第二原则
D:继承可以使子类能自动继承父类的接口,但在设计模式中认为这是一种破坏了父类的封装性的表现
- 以下关于纯虚函数的说法,正确的是( )
A:声明纯虚函数的类不能实例化对象 B:声明纯虚函数的类是虚基类
C:子类必须实现基类的纯虚函数 D:纯虚函数必须是空函数
- 关于虚函数的描述正确的是( )
A:派生类的虚函数与基类的虚函数具有不同的参数个数和类型 B:内联函数不能是虚函数
C:派生类必须重新定义基类的虚函数 D:虚函数可以是一个static型的函数
- 关于虚表说法正确的是( )
A:一个类只能有一张虚表
B:基类中有虚函数,如果子类中没有重写基类的虚函数,此时子类与基类共用同一张虚表
C:虚表是在运行期间动态生成的
D:一个类的不同对象共享该类的虚表
- 假设A类中有虚函数,B继承自A,B重写A中的虚函数,也没有定义任何虚函数,则( )
A:A类对象的前4个字节存储虚表地址,B类对象前4个字节不是虚表地址
B:A类对象和B类对象前4个字节存储的都是虚基表的地址
C:A类对象和B类对象前4个字节存储的虚表地址相同
D:A类和B类虚表中虚函数个数相同,但A类和B类使用的不是同一张虚表
- 下面程序输出结果是什么? ()
#include<iostream>
using namespace std;
class A{
public:
A(char *s) { cout<<s<<endl; }
~A(){}
};
class B:virtual public A {
public:
B(char *s1,char*s2):A(s1) { cout<<s2<<endl; }
};
class C:virtual public A {
public:
C(char *s1,char*s2):A(s1) { cout<<s2<<endl; }
};
class D:public B,public C {
public:
D(char *s1,char *s2,char *s3,char *s4):B(s1,s2),C(s1,s3),A(s1)
{ cout<<s4<<endl;}
};
int main() {
D *p=new D("class A","class B","class C","class D");
delete p;
return 0;
}A:class A class B class C class D B:class D class B class C class A
C:class D class C class B class A D:class A class C class B class D
🗾 解析: 这道题考察的其实是派生类的初始化列表执行顺序是与继承父类的声明顺序有关的,这里 D 先继承 B 再继承 C,而他们两个又是继承 A,所以一开始就先调用 A 的构造函数,然后就调用 B ,接着调用 C,最后才调用 D。又因为这里是菱形虚拟继承,所以这里只有一份 A,所以 A 只会被调用一次。
- 多继承中指针偏移问题?下面说法正确的是( )
class Base1 { public: int _b1; };
class Base2 { public: int _b2; };
class Derive : public Base1, public Base2 { public: int _d; };
int main(){
Derive d;
Base1* p1 = &d;
Base2* p2 = &d;
Derive* p3 = &d;
return 0;
}A:p1 p2 p3 B:p1 < p2 < p3 C:p1 == p3 != p2 D:p1 != p2 != p3
- 以下程序输出结果是什么()
class A
{
public:
virtual void func(int val = 1){ std::cout<<"A->"<< val <<std::endl;}
virtual void test(){ func(); }
};
class B : public A
{
public:
void func(int val=0){ std::cout<<"B->"<< val <<std::endl; }
};
int main(int argc ,char* argv[])
{
B* p = new B;
p->test();
return 0;
}A: A->0 B: B->1 C: A->1 D: B->0 E: 编译出错 F: 以上都不正确
🌛 解析: 这里 B* p = new B 是不构成多态的,因为得用父类的指针去调用才能构成多态,但是这里有一个隐藏的多态,就是 p->test(),当子类的指针去调用父类的 test() ,而 test() 的参数其实是 A* this,也就是 this 是父类的指针,也就是说这里就是 this 来调 test() ,然后这里 this 又指向 p,p 又是子类的对象指针,所以这里最后就调用了 B::func()。
而这里还有一个大坑,就是虽然派生类重写了父类的虚函数,但是注意一点:虚函数只重写实现,不会重写接口,也就是接口还是父类的接口,所以这里的 val 是 1,而不是 0,这是一个很奇怪的点!
参考答案:
1. A 2. D 3. C 4. A 5. B
6. D 7. D 8. A 9. C 10. B2、问答题
-
什么是多态?答:参考笔记
-
什么是重载、重写(覆盖)、重定义(隐藏)?答:参考笔记
-
多态的实现原理?答:参考笔记
-
inline函数可以是虚函数吗?答:可以,不过编译器就忽略inline属性,这个函数就不再是inline,因为虚函数要放到虚表中去。 -
静态成员可以是虚函数吗?答:不能,因为静态成员函数没有
this指针,使用类型::成员函数的调用方式无法访问虚函数表,所以静态成员函数无法放进虚函数表。 -
构造函数可以是虚函数吗?答:不能,因为对象中的虚函数表指针是在构造函数初始化列表阶段才初始化的。
-
析构函数可以是虚函数吗?什么场景下析构函数是虚函数?答:可以,并且最好把基类的析构函数定义成虚函数。参考笔记
-
对象访问普通函数快还是虚函数更快?答:首先如果是普通对象,是一样快的。如果是指针对象或者是引用对象,则调用的普通函数快,因为构成多态,运行时调用虚函数需要到虚函数表中去查找。
-
虚函数表是在什么阶段生成的,存在哪的?答:虚函数表是在编译阶段就生成的,一般情况下是存在代码段(常量区)的。
-
**
C++**菱形继承的问题?虚继承的原理?答:参考继承笔记。注意这里不要把虚函数表和虚基表搞混了。 -
什么是抽象类?抽象类的作用?答:参考(3.抽象类)。抽象类强制重写了虚函数,另外抽象类体现出了接口继承关系。