文章目录
- 1. 多态的概念
- 2. 多态的定义及实现
- 🍑 多态的构成条件
- 🍑 虚函数
- 🍑 虚函数的重写
- 🍑 虚函数重写的两个例外
- 🍑 C++11的override 和 final
- 🍑 重载、覆盖(重写)、隐藏(重定义)的对比
- 3. 抽象类
- 🍑 接口继承和实现继承
- 4. 多态的原理
- 🍑 虚函数表
- 🍑 多态的原理
- 🍑 动态绑定与静态绑定
- 5. 单继承和多继承关系的虚函数表
- 🍑 单继承中的虚函数表
- 🍑 多继承中的虚函数表
- 🍑 菱形继承和菱形虚拟继承
- 6. 继承和多态常见的面试问题
- 🍑 概念查考
- 🍑 问答题
1. 多态的概念
多态的概念:通俗来说,就是多种形态,具体点就是去完成某个行为,当不同的对象去完成时会产生出不同的状态。
(1)示例一
比如春节回家买票这个行为,当普通人买票时,是全价买票;学生买票时,是半价买票;军人买票时是优先买票。
不同身份的人去买票,所产生的行为是不同的,这就是所谓的多态。
(2)示例二
为了争夺在线支付市场,支付宝年底经常会做诱人的扫红包 - 支付 - 给奖励金的活动。
那么大家想想为什么有人扫的红包又大又新鲜 8 块、10 块…,而有人扫的红包都是 1 毛,5 毛…。其实这背后也是一个多态行为。
支付宝首先会分析你的账户数据,比如你是新用户、比如你没有经常支付宝支付等等,那么你需要被鼓励使用支付宝,那么就你 扫码金额 = random()%99
;
比如你经常使用支付宝支付或者支付宝账户中常年没钱,那么就不需要太鼓励你去使用支付宝,那么就你 扫码金额 = random()%1
;
总结一下:同样是扫码动作,不同的用户扫得到的不一样的红包,这也是一种多态行为。
2. 多态的定义及实现
🍑 多态的构成条件
多态是在不同继承关系的类对象,去调用同一函数,产生了不同的行为。
比如 Student
继承了 Person
。Person
对象买票全价,Student
对象买票半价。
那么在继承中要构成多态还有两个条件:
(1)必须通过基类的指针或者引用调用虚函数
(2)被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写(重写有三同:函数名、参数、返回值)
🍑 虚函数
虚函数:即被 virtual
修饰的类成员函数称为虚函数。
class Person {
public:
virtual void BuyTicket()
{
cout << "买票-全价" << endl;
}
};
🍑 虚函数的重写
虚函数的重写(覆盖):派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的返回值类型、函数名字、参数列表完全相同),称子类的虚函数重写了基类的虚函数。
下面代码中,Person
是基类,Student
和 Soldier
是派生类,它们分别继承了 Person
类,并且重写了基类的虚函数
// 基类
class Person {
public:
Person(const char* name)
:_name(name)
{}
// 虚函数
virtual void BuyTicket()
{
cout << _name << "Person:买票-全价 100¥" << endl;
}
protected:
string _name;
};
// 派生类 - 学生
class Student : public Person {
public:
Student(const char* name)
:Person(name)
{}
// 虚函数 + 函数名/参数/返回值 ==> 重写/覆盖
virtual void BuyTicket()
{
cout << _name << " Student:买票-半价 50 ¥" << endl;
}
};
// 派生类 - 军人
class Soldier : public Person {
public:
Soldier(const char* name)
:Person(name)
{}
// 虚函数 + 函数名/参数/返回值 ==> 重写/覆盖
virtual void BuyTicket()
{
cout << _name << " Soldier:优先买预留票-88折 88 ¥" << endl;
}
};
思考一下:三个类里面都有 BuyTicket
,那么会不会构成隐藏呢?当然不会!
我们这里是:虚函数+相同的函数名、相同的参数、相同的返回值,那么就构成覆盖或者重写。意思就是子类里面的覆盖了父类里面的相同函数!
如果我要去调用基类的虚函数怎么办呢?有两种方法!
(1)父类指针去调用虚函数
// 父类指针去调用虚函数
void Pay(Person* ptr)
{
ptr->BuyTicket();
}
int main()
{
int option = 0;
cout << "=======================================" << endl;
do
{
cout << "请选择身份:";
cout << "1、普通人 2、学生 3、军人" << endl;
cin >> option;
cout << "请输入名字:";
string name;
cin >> name;
switch (option)
{
case 1:
{
Person p(name.c_str());
Pay(&p); // 传地址
break;
}
case 2:
{
Student s(name.c_str());
Pay(&s);
break;
}
case 3:
{
Soldier s(name.c_str());
Pay(&s);
break;
}
default:
cout << "输入错误,请重新输入" << endl;
break;
}
cout << "=======================================" << endl;
} while (option != -1);
return 0;
}
我们运行以后可以看到,当你选择不同的身份时,会去调用不同的买票函数,产生的金额也是不一样的,所以实现了函数调用的多种形态。
(1)父类引用去调用虚函数
// 父类引用去调用虚函数
void Pay(Person& ptr)
{
ptr.BuyTicket();
}
int main()
{
int option = 0;
cout << "=======================================" << endl;
do
{
cout << "请选择身份:";
cout << "1、普通人 2、学生 3、军人" << endl;
cin >> option;
cout << "请输入名字:";
string name;
cin >> name;
switch (option)
{
case 1:
{
Person p(name.c_str());
Pay(p); // 这里就不能传地址了
break;
}
case 2:
{
Student s(name.c_str());
Pay(s);
break;
}
case 3:
{
Soldier s(name.c_str());
Pay(s);
break;
}
default:
cout << "输入错误,请重新输入" << endl;
break;
}
cout << "=======================================" << endl;
} while (option != -1);
return 0;
}
当然,运行结果和上面也是一样的:
注意:在重写基类虚函数时,派生类的虚函数在不加 virtual
关键字时,虽然也可以构成重写,因为继承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性。但是该种写法不是很规范,不建议这样使用!
🍑 虚函数重写的两个例外
(1)协变(基类与派生类虚函数返回值类型不同)
派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。
下面代码中,首先 A
是基类,B
继承了 A
是派生类;同样 Person
是基类,Student
继承了 Person
是派生类。
在 Person
中的虚函数 fun
的返回值类型是基类 A
对象的指针,在派生类 Student
当中的虚函数 fun
的返回值类型是派生类 B
对象的指针。
那么此时是可以认为派生类 Student
的虚函数重写了基类 Person
的虚函数。
// 基类
class A{};
// 派生类
class B : public A {};
// 基类
class Person {
public:
virtual A* f() {
cout << "virtual A* Person::f()" << endl;
return new A;
}
};
// 派生类
class Student : public Person {
public:
virtual B* f() {
cout << "virtual B* Student::f()" << endl;
return new B;
}
};
int main()
{
Person p;
Student s;
Person* ptr = &p;
ptr->f();
ptr = &s;
ptr->f();
return 0;
}
我们运行可以看到,当 Person
指针指向的是基类对象时,调用的是基类的虚函数;当 Person
指针指向的是派生类对象时,调用的是派生类的虚函数。
注意,虚函数重写对返回值要求有一个例外:协变时,必须是父子关系指针或者引用。
也就是说返回值不管是指针还是引用,A
和 B
必须是父子关系!
还记得我们上面说的派生类的虚函数在不加 virtual
关键字时,也可以构成重写吗?
// 基类
class A{};
// 派生类
class B : public A {};
// 基类
class Person {
public:
virtual A* f()
{
cout << "virtual A* Person::f()" << endl;
return new A;
}
};
// 派生类
class Student : public Person {
public:
B* f()
{
cout << "virtual B* Student::f()" << endl;
return new B;
}
};
int main()
{
Person p;
Student s;
Person* ptr1 = &p;
ptr1->f();
Person* ptr2 = &s;
ptr2->f();
return 0;
}
此时,子类虚函数没有写 virtual
,f()
依旧时虚函数,因为它先继承了父类函数接口声明,运行以后结果也是正确的:
注意:不推荐这种写法,我们自己写的时候子类虚函数也写上 virtual
。
(2)析构函数的重写(基类与派生类析构函数的名字不同)
如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加 virtual
关键字,都与基类的析构函数构成重写,即使基类与派生类析构函数名字不同。
下面代码中,基类 Person
和 派生类 Student
都没有加 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;
return 0;
}
运行以后可以看到,先调用派生类 Student
对象自己的析构函数,然后 Student
会自动调用基类 Person
的析构函数清理基类成员,最后基类 Person
对象再调用自己的析构函数。
如果基类 Person
析构函数加了 virtual
,那么此时关系就变了,从 重定义(隐藏)关系 变成了 重写(覆盖)关系。
// 基类
class Person {
public:
virtual ~Person()
{
cout << "~Person()" << endl;
}
};
// 派生类
class Student : public Person {
public:
~Student()
{
cout << "~Student()" << endl;
}
};
虽然,它们打印的结果还是一样滴。
虽然函数名不相同,看起来违背了重写的规则,其实不然,这里可以理解为编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成 destructor
。
那么场景下才需要子类的析构函数也写上 virtual
呢?
假设有这么一个场景:分别 new
一个父类对象和子类对象,并均用父类指针指向它们,然后分别用 delete
调用析构函数并释放对象空间。
// 基类
class Person {
public:
~Person()
{
cout << "~Person()" << endl;
}
};
// 派生类
class Student : public Person {
public:
~Student()
{
cout << "~Student()" << endl;
}
};
int main()
{
Person* p1 = new Person;
Person* p2 = new Student;
delete p1;
delete p2;
return 0;
}
如果不加 virtual
,就可能会导致内存泄漏,因为此时 delete p1
和 delete p2
都是调用的父类的析构函数:
只有派生类 Student
的析构函数重写了 Person
的析构函数,下面的 delete
对象调用析构函数,才能构成多态,才能保证 p1
和 p2
指向的对象正确的调用析构函数。
// 基类
class Person {
public:
virtual ~Person()
{
cout << "~Person()" << endl;
}
};
// 派生类
class Student : public Person {
public:
virtual ~Student()
{
cout << "~Student()" << endl;
}
};
int main()
{
Person* p1 = new Person;
Person* p2 = new Student;
delete p1;
delete p2;
return 0;
}
可以看到,p1
调用父类的析构函数,p2
调用子类的析构函数,是一种多态行为。
🍑 C++11的override 和 final
从上面示例中可以看出,C++ 对函数重写的要求比较严格,但是有些情况下由于疏忽,可能会导致函数名字母次序写反而无法构成重载,而这种错误在编译期间是不会报出的,只有在程序运行时没有得到预期结果再来进行 debug
会得不偿失,因此,C++11 提供了 override
和 final
两个关键字,可以帮助用户检测是否重写。
(1) final:修饰虚函数,表示该虚函数不能再被重写
代码示例
// 基类
class Car
{
public:
// 被final修饰,该虚函数不能再被重写
virtual void Drive() final {}
};
// 子类
class Benz :public Car
{
public:
virtual void Drive()
{
cout << "Benz-舒适" << endl;
}
};
int main()
{
return 0;
}
基类 Car
的虚函数 Drive()
被 final
修饰后就不能再被重写了,派生类若是重写了基类的 Drive()
函数则编译报错。
(2)override: 检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错。
代码示例
// 基类
class Car {
public:
virtual void Drive() {}
};
// 派生类
class Benz :public Car {
public:
// 子类完成了父类虚函数的重写,编译通过
virtual void Drive() override
{
cout << "Benz-舒适" << endl;
}
};
// 派生类
class BMW :public Car {
public:
// 子类没有完成了父类虚函数的重写,编译报错
void Drive(int i) override
{
cout << "Benz-好开" << endl;
}
};
int main()
{
return 0;
}
派生类 Benz
和 BMW
的虚函数 Drive
被 override
修饰,编译时就会检查子类的这两个 Drive
函数是否重写了父类的虚函数,如果没有重写就会编译报错。
🍑 重载、覆盖(重写)、隐藏(重定义)的对比
总结一下这三者的含义:
3. 抽象类
在虚函数的后面写上 = 0
,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化出对象。
代码示例
//抽象类(接口类)
class Car
{
public:
//纯虚函数
virtual void Drive() = 0;
};
int main()
{
Car c; //抽象类不能实例化出对象,error
return 0;
}
派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。
//抽象类(接口类)
class Car
{
public:
//纯虚函数
virtual void Drive() = 0;
};
//派生类
class Benz : public Car
{
public:
//重写纯虚函数
virtual void Drive()
{
cout << "Benz-舒适" << endl;
}
};
//派生类
class BMV : public Car
{
public:
//重写纯虚函数
virtual void Drive()
{
cout << "BMV-操控" << endl;
}
};
int main()
{
//派生类重写了纯虚函数,可以实例化出对象
Benz b1;
BMV b2;
//不同对象用基类指针调用Drive函数,完成不同的行为
Car* p1 = &b1;
Car* p2 = &b2;
p1->Drive(); //Benz-舒适
p2->Drive(); //BMV-操控
return 0;
}
运行结果
纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。
抽象类既然不能实例化出对象,那抽象类存在的意义是什么?
- 抽象类可以更好的去表示现实世界中,没有实例对象对应的抽象类型,比如:植物、人、动物等。
- 抽象类很好的体现了虚函数的继承是一种接口继承,强制子类去重写纯虚函数,因为子类若是不重写从父类继承下来的纯虚函数,那么子类也是抽象类也不能实例化出对象。
🍑 接口继承和实现继承
- 实现继承: 普通函数的继承是一种实现继承,派生类继承了基类函数的实现,可以使用该函数,继承的是函数的实现。
- 接口继承: 虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口。
建议: 所以如果不实现多态,就不要把函数定义成虚函数。
4. 多态的原理
🍑 虚函数表
下面是常考一道笔试题:sizeof(Base)
是多少?
class Base
{
public:
virtual void Func1()
{
cout << "Func1()" << endl;
}
private:
int _b = 1;
};
通过观察测试,我们发现 Base 类实例化的对象 b 的大小是 8 个字节。
b 对象当中除了 _b
成员外,实际上还有一个 _vfptr
放在对象的前面(有些平台可能会放到对象的最后面,这个跟平台有关)。
对象中的这个指针叫做虚函数表指针,简称虚表指针,虚表指针指向一个虚函数表,简称虚表,每一个含有虚函数的类中都至少都有一个虚表指针。
因为虚函数的地址要被放到虚函数表中,虚函数表也简称虚表。
那么虚函数表中到底放的是什么?我们接着往下分析
下面代码中 Base 类有三个成员函数,其中 Func1 和 Func2 是虚函数,Func3 是普通成员函数,子类 Derive 当中仅对父类的 Func1 函数进行了重写。
//父类
class Base
{
public:
//虚函数
virtual void Func1()
{
cout << "Base::Func1()" << endl;
}
//虚函数
virtual void Func2()
{
cout << "Base::Func2()" << endl;
}
//普通成员函数
void Func3()
{
cout << "Base::Func3()" << endl;
}
private:
int _b = 1;
};
//子类
class Derive : public Base
{
public:
//重写虚函数Func1
virtual void Func1()
{
cout << "Derive::Func1()" << endl;
}
private:
int _d = 2;
};
int main()
{
Base b;
Derive d;
return 0;
}
通过调试可以发现,父类对象 b
和基类对象 d
当中除了自己的成员变量之外,父类和子类对象都有一个虚表指针,分别指向属于自己的虚表。
实际上虚表当中存储的就是虚函数的地址,因为父类当中的 Func1 和 Func2 都是虚函数,所以父类对象 b
的虚表当中存储的就是虚函数 Func1 和 Func2 的地址。
而子类虽然继承了父类的虚函数 Func1 和 Func2,但是子类对父类的虚函数 Func1 进行了重写,因此,子类对象 d
的虚表当中存储的是父类的虚函数 Func2 的地址和重写的Func1的地址。这就是为什么虚函数的重写也叫做覆盖,覆盖就是指虚表中虚函数地址的覆盖,重写是语法的叫法,覆盖是原理层的叫法。
其次需要注意的是:Func2 是虚函数,所以继承下来后放进了子类的虚表,而 Func3 是普通成员函数,继承下来后不会放进子类的虚表。此外,虚函数表本质是一个存虚函数指针的指针数组,一般情况下会在这个数组最后放一个 nullptr。
总结一下,派生类的虚表生成步骤如下:
- 先将基类中的虚表内容拷贝一份到派生类的虚表。
- 如果派生类重写了基类中的某个虚函数,则用派生类自己的虚函数地址覆盖虚表中基类的虚函数地址。
- 派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。
这里还有很容易混淆的问题:虚函数存在哪的?虚表存在哪的?
虚表实际上是在构造函数初始化列表阶段进行初始化的,注意虚表当中存的是虚函数的地址不是虚函数,虚函数和普通函数一样,都是存在 代码段 的,只是它的地址又存到了虚表当中。另外,对象中存的不是虚表而是指向虚表的指针。
我们可以通过下面这段代码判断虚表是存在哪里的。
int main()
{
Base b;
Base* p = &b;
printf("vfptr:%p\n", *((int*)p));
int i = 0;
printf("栈上地址:%p\n", &i);
printf("数据段地址:%p\n", &j);
int* k = new int;
printf("堆上地址:%p\n", k);
const char* cp = "hello world";
printf("代码段地址:%p\n", cp);
return 0;
}
可以看到,代码当中打印了对象 b
当中的虚表指针,也就是虚表的地址,可以发现虚表地址与代码段的地址非常接近,由此我们可以得出虚表实际上是存在代码段的。
🍑 多态的原理
上面分析了这个半天了那么多态的原理到底是什么?
我们还是拿买票这个代码来说明:
// 父类
class Person {
public:
virtual void BuyTicket()
{
cout << "买票-全价" << endl;
}
int _p = 1;
};
// 子类
class Student : public Person {
public:
virtual void BuyTicket()
{
cout << "买票-半价" << endl;
}
int _s = 2;
};
// 调用函数
void Func(Person& p)
{
p.BuyTicket();
}
int main()
{
Person Mike;
Func(Mike);
Student Johnson;
Func(Johnson);
return 0;
}
为什么当父类 Person 指针指向的是父类对象 Mike 时,调用的就是父类的 BuyTicket,当父类 Person 指针指向的是子类对象 Johnson 时,调用的就是子类的 BuyTicket?
通过调试可以发现,对象 Mike 中包含一个成员变量 _p 和一个虚表指针,对象 Johnson 中包含两个成员变量 _p 和 _s 以及一个虚表指针,这两个对象当中的虚表指针分别指向自己的虚表。
围绕此图分析便可得到多态的原理:
- p 是指向 Mike 对象时,
p->BuyTicket
在 Mike 的虚表中找到虚函数是Person::BuyTicket
。 - p 是指向 Johnson 对象时,
p->BuyTicket
在 Johson 的虚表中找到虚函数是Student::BuyTicket
。
这样就实现出了不同对象去完成同一行为时,展现出不同的形态。
现在想想多态构成的两个条件,一是完成虚函数的重写,二是必须使用父类的指针或者引用去调用虚函数。
必须完成虚函数的重写是因为我们需要完成子类虚表当中虚函数地址的覆盖,那 为什么必须使用父类的指针或者引用去调用虚函数呢?为什么使用父类对象去调用虚函数达不到多态的效果呢?
使用父类指针或者引用时,实际上是一种切片行为,切片时只会让父类指针或者引用得到父类对象或子类对象中切出来的那一部分。
因此我们现在对代码进行一下修改,当我们把父类和子类对象直接赋值给 p1 和 p2 时,再去调用,会发生什么呢?
int main()
{
Person Mike;
Student Johnson;
Johnson._p = 3; //以便观察是否完成切片
Person p1 = Mike;
Person p2 = Johnson;
p1.BuyTicket();
p2.BuyTicket();
return 0;
}
可以看到并没有实现多态,因为 p1 和 p2 调用虚函数时,p1 和 p2 通过虚表指针找到的虚表是不一样的,最终调用的函数也是不一样的。
使用父类对象时,切片得到部分成员变量后,会调用父类的拷贝构造函数对那部分成员变量进行拷贝构造,而拷贝构造出来的父类对象 p1 和 p2 当中的虚表指针指向的都是父类对象的虚表。因为同类型的对象共享一张虚表,他们的虚表指针指向的虚表是一样的。
对象切片的时候,子类只会拷贝成员给父类对象,不会拷贝虚表指针,否则拷贝就混乱了,所以父类对象中到底是父类的虚表指针还是子类的虚表指针,是都有可能的,那么是去调用父类的虚函数还是子类的虚函数就不确定!
因此,我们用 p1 和 p2 调用虚函数时,p1 和 p2 通过虚表指针找到的虚表是一样的,最终调用的函数也是一样的,也就无法构成多态。
总结一下:
- 构成多态,指向谁就调用谁的虚函数,跟对象有关。
- 不构成多态,对象类型是什么就调用谁的虚函数,跟类型有关。
🍑 动态绑定与静态绑定
静态绑定: 静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也成为静态多态,比如:函数重载。
动态绑定: 动态绑定又称为后期绑定(晚绑定),在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态。
对于下面这段代码,我们可以通过查看汇编的方式进一步理解静态绑定和动态绑定。
//父类
class Person
{
public:
virtual void BuyTicket()
{
cout << "买票-全价" << endl;
}
};
//子类
class Student : public Person
{
public:
virtual void BuyTicket()
{
cout << "买票-半价" << endl;
}
};
我们若是按照下面方式调用 BuyTicket 函数,则不构成多态,函数的调用是在编译时确定的。
int main()
{
Student Johnson;
Person p = Johnson; //不构成多态
p.BuyTicket();
return 0;
}
将调用函数的那句代码翻译成汇编就只有以下两条汇编指令,也就是直接调用的函数。
而我们若是按照如下方式调用 BuyTicket 函数,则构成多态,函数的调用是在运行时确定的。
int main()
{
Student Johnson;
Person& p = Johnson; //构成多态
p.BuyTicket();
return 0;
}
相比不构成多态时的代码,构成多态时调用函数的那句代码翻译成汇编后就变成了八条汇编指令,主要原因就是我们需要在运行时,先到指定对象的虚表中找到要调用的虚函数,然后才能进行函数的调用。
这样就很好的体现了静态绑定是在编译时确定的,而动态绑定是在运行时确定的。
5. 单继承和多继承关系的虚函数表
需要注意的是在单继承和多继承关系中,下面我们去关注的是派生类对象的虚表模型,因为基类
的虚表模型前面我们已经看过了,没什么需要特别研究的。
🍑 单继承中的虚函数表
以下列单继承关系为例,我们来看看基类和派生类的虚表模型。
// 父类
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;
};
其中,基类和派生类对象的虚表模型如下:
在单继承关系当中,派生类的虚表生成过程如下:
- 继承基类的虚表内容到派生类的虚表。
- 对派生类重写了的虚函数地址进行覆盖,比如 func1。
- 虚表当中新增派生类当中新的虚函数地址,比如 func3 和 func4。
但是,通过监视窗口我们发现看不见 func3 和 func4。这里是编译器的监视窗口故意隐藏了这两个函数,也可以认为是他的一个小 bug。那么我们如何查看 d 的虚表呢?
(1)使用内存监视窗口
使用内存监视窗口看到的内容是最真实的,我们调出内存监视窗口,然后输入派生类对象当中的虚表指针,即可看到虚表当中存储的四个虚函数地址。
(2)使用代码打印虚表内容
我们可以使用以下代码,打印上述基类和派生类对象的虚表内容,在打印过程中可以顺便用虚函数地址调用对应的虚函数,从而打印出虚函数的函数名,这样可以进一步确定虚表当中存储的是哪一个函数的地址。
代码示例
// 取内存值,打印并调用,确认是否是func4
typedef void(*VFPTR) ();
// 打印虚表
void PrintVTable(VFPTR vTable[])
{
// 依次取虚表中的虚函数指针打印并调用。调用就可以看出存的是哪个函数
cout << " 虚表地址:" << vTable << endl;
for (int i = 0; vTable[i] != nullptr; ++i)
{
printf(" 第%d个虚函数地址:0X%x --> ", i, vTable[i]);
VFPTR f = vTable[i];
f();
}
cout << endl;
}
int main()
{
Base b;
Derive d;
VFPTR * vTableb = (VFPTR*)(*(int*)&b);
PrintVTable(vTableb);
VFPTR* vTabled = (VFPTR*)(*(int*)&d);
PrintVTable(vTabled);
return 0;
}
我这里稍微解释一下打印虚表的代码:
- 思路:取出 b、d 对象的头 4bytes,就是虚表的指针,前面我们说了虚函数表本质是一个存虚函数指针的指针数组,这个数组最后面放了一个 nullptr
- 先取 b 的地址,强转成一个
int*
的指针 - 再解引用取值,就取到了 b 对象头 4bytes 的值,这个值就是指向虚表的指针
- 再强转成
VFPTR*
,因为虚表就是一个存 VFPTR 类型(虚函数指针类型)的数组 - 虚表指针传递给 PrintVTable 进行打印虚表
- 需要说明的是这个打印虚表的代码经常会崩溃,因为编译器有时对虚表的处理不干净,虚表最后面没有放 nullptr,导致越界,这是编译器的问题。
运行结果如下:
模型图如下:
🍑 多继承中的虚函数表
以下列多继承关系为例,我们来看看基类和派生类的虚表模型。
// 父类1
class Base1 {
public:
virtual void func1() { cout << "Base1::func1" << endl; }
virtual void func2() { cout << "Base1::func2" << endl; }
private:
int _b1;
};
// 父类2
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;
};
其中,两个基类的虚表模型如下:
而派生类的虚表模型就不那么简单了,派生类的虚表模型如下:
在多继承关系当中,派生类的虚表生成过程如下:
- 分别继承各个基类的虚表内容到派生类的各个虚表当中。
- 对派生类重写了的虚函数地址进行覆盖(派生类中的各个虚表中存有该被重写虚函数地址的都需要进行覆盖),比如 func1。
- 在派生类第一个继承基类部分的虚表当中新增派生类当中新的虚函数地址,比如 func3。
这里在调试时,在某些编译器下也会出现显示不全的问题,此时如果我们想要看到派生类对象完整的虚表也是用那两种方法。
(1)使用内存监视窗口
直接调用内存窗口查看:
(2)使用代码打印虚表内容
需要注意的是,我们在派生类第一个虚表地址的基础上,向后移 sizeof(Base1)
个字节即可得到第二个虚表的地址。
// 取内存值,打印并调用,确认是否是func4
typedef void(*VFPTR) ();
// 打印虚表
void PrintVTable(VFPTR vTable[])
{
// 依次取虚表中的虚函数指针打印并调用。调用就可以看出存的是哪个函数
cout << " 虚表地址:" << vTable << endl;
for (int i = 0; vTable[i] != nullptr; ++i)
{
printf(" 第%d个虚函数地址:0X%x --> ", i, vTable[i]);
VFPTR f = vTable[i];
f();
}
cout << endl;
}
int main()
{
Base1 b1;
Base2 b2;
VFPTR* vTableb1 = (VFPTR*)(*(int*)&b1);
PrintVTable(vTableb1); // 打印基类对象b1的虚表地址及其内容
VFPTR* vTableb2 = (VFPTR*)(*(int*)&b2);
PrintVTable(vTableb2); // 打印基类对象b2的虚表地址及其内容
Derive d;
VFPTR* vTableb3 = (VFPTR*)(*(int*)&d);
PrintVTable(vTableb3); // 打印派生类对象d的第一个虚表地址及其内容
VFPTR* vTableb4 = (VFPTR*)(*(int*)((char*)&d + sizeof(Base1)));
PrintVTable(vTableb4); // 打印派生类对象d的第二个虚表地址及其内容
return 0;
}
运行结果如下:
观察下图可以看出:多继承派生类的未重写的虚函数放在第一个继承基类部分的虚函数表中:
🍑 菱形继承和菱形虚拟继承
实际中我们不建议设计出菱形继承及菱形虚拟继承,一方面太复杂容易出问题,另一方面这样的模型,访问基类成员有一定得性能损耗。所以菱形继承、菱形虚拟继承我们的虚表我们就不看了,一般我们也不需要研究清楚,因为实际中很少用。
但是这里可以给大家推荐两篇文章:
- C++ 虚函数表解析
- C++ 对象的内存布局
6. 继承和多态常见的面试问题
🍑 概念查考
-
下面哪种面向对象的方法可以让你变得富有?
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
- 多继承中指针偏移问题?下面说法正确的是?
#include <iostream>
using namespace std;
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
- 以下程序输出结果是什么?
#include <iostream>
using namespace std;
class A
{
public:
virtual void func(int val = 1)
{
cout << "A->" << val << endl;
}
virtual void test()
{
func();
}
};
class B : public A
{
public:
void func(int val = 0)
{
cout << "B->" << val << endl;
}
};
int main()
{
B* p = new B;
p->test();
return 0;
}
A. A->0
B. B->1
C. A->1
D. B->0
E. 编译出错 F. 以上都不正确
答案如下:
🍑 问答题
(1)什么是多态?
多态是指不同继承关系的类对象,去调用同一函数,产生了不同的行为。多态又分为静态的多态和动态的多态。
(2)什么是重载、重写(覆盖)、重定义(隐藏)?
重载是指两个函数在同一作用域,这两个函数的函数名相同,参数不同。
重写(覆盖)是指两个函数分别在基类和派生类的作用域,这两个函数的函数名、参数、返回值都必须相同(协变例外),且这两个函数都是虚函数。
重定义(隐藏)是指两个函数分别在基类和派生类的作用域,这两个函数的函数名相同。若两个基类和派生类的同名函数不构成重写就是重定义。
(3)多态的实现原理?
构成多态的父类对象和子类对象的成员当中都包含一个虚表指针,这个虚表指针指向一个虚表,虚表当中存储的是该类对应的虚函数地址。
因此,当父类指针指向父类对象时,通过父类指针找到虚表指针,然后在虚表当中找到的就是父类当中对应的虚函数;
当父类指针指向子类对象时,通过父类指针找到虚表指针,然后在虚表当中找到的就是子类当中对应的虚函数。
(4)inline 函数可以是虚函数吗?
我们知道内联函数是会在调用的地方展开的,也就是说内联函数是没有地址的,但是内联函数是可以定义成虚函数的,当我们把内联函数定义虚函数后,编译器就忽略了该函数的内联属性,这个函数就不再是内联函数了,因为需要将虚函数的地址放到虚表中去。
(5)静态成员可以是虚函数吗?
静态成员函数不能是虚函数,因为静态成员函数没有this指针,使用类型 ::
成员函数的调用方式无法访问虚表,所以静态成员函数无法放进虚表。
(6)构造函数可以是虚函数吗?
构造函数不能是虚函数,因为对象中的虚表指针是在构造函数初始化列表阶段才初始化的。
(7)析构函数可以是虚函数吗?
析构函数可以是虚函数,并且最后把基类的析构函数定义成虚函数。若是我们分别 new 一个父类对象和一个子类对象,并均用父类指针指向它们,当我们使用 delete 调用析构函数并释放对象空间时,只有当父类的析构函数是虚函数的情况下,才能正确调用父类和子类的析构函数分别对父类和子类对象进行析构,否则当我们使用父类指针 delete 对象时,只能调用到父类的析构函数。
(8)对象访问普通函数快还是虚函数更快?
对象访问普通函数比访问虚函数更快,若我们访问的是一个普通函数,那直接访问就行了,但当我们访问的是虚函数时,我们需要先找到虚表指针,然后在虚表当中找到对应的虚函数,最后才能调用到虚函数。
(9)虚函数表是在什么阶段生成的?存在哪的?
虚表是在构造函数初始化列表阶段进行初始化的,虚表一般情况下是存在代码段(常量区)的。
(10)C++菱形继承的问题?虚继承的原理?
菱形虚拟继承因为子类对象当中会有两份父类的成员,因此会导致数据冗余和二义性的问题。
虚继承对于相同的虚基类在对象当中只会存储一份,若要访问虚基类的成员需要通过虚基表获取到偏移量,进而找到对应的虚基类成员,从而解决了数据冗余和二义性的问题。
(11)什么是抽象类?抽线类的作用?
抽象类很好的体现了虚函数的继承是一种接口继承,强制子类去抽象纯虚函数,因为子类若是不抽象从父类继承下来的纯虚函数,那么子类也是抽象类也不能实例化出对象。
其次,抽象类可以很好的去表示现实世界中没有示例对象对应的抽象类型,比如:植物、人、动物等。