效果图制作公司哪家好/宁波seo公司排名
文章目录
- 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; //抽象类不能实例化出对象,errorreturn 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://重写虚函数Func1virtual 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)什么是抽象类?抽线类的作用?
抽象类很好的体现了虚函数的继承是一种接口继承,强制子类去抽象纯虚函数,因为子类若是不抽象从父类继承下来的纯虚函数,那么子类也是抽象类也不能实例化出对象。
其次,抽象类可以很好的去表示现实世界中没有示例对象对应的抽象类型,比如:植物、人、动物等。
相关文章:

C++深入浅出(九)—— 多态
文章目录1. 多态的概念2. 多态的定义及实现🍑 多态的构成条件🍑 虚函数🍑 虚函数的重写🍑 虚函数重写的两个例外🍑 C11的override 和 final🍑 重载、覆盖(重写)、隐藏(重定义)的对比3. 抽象类🍑…...

shell学习4
目录 一、统计文本中的词频 二、压缩javascript 三、打印文件的或行中的第n个单词或列---awk 3.1 利用awk打印文件中每行中的第五个单词。 3.2 利用awk打印当前目录下的文件的权限和文件名 3.3 利用awk打印从M行到N行这个范围内的所有文本 3.4 利用awk 部分提取文件中的内…...

VR全景行业的应用价值如何呈现?
互联网高速发展的今天,多媒体所包含的种类也是越来越多,而一些较为传统的表现方式已经越来越无法满足大部分客户对展示方式的要求。而在传统的表现方式中,展现的方式无非是静态的平面图片以及动态的视频,但是他们都有一个缺点就是…...

ESP-IDF:TCP多线程并发服务器
核心代码: 核心思想就是主线程只处理socket监听功能,把数据处理部分分配到不同的线程中去处理。来了一个客户端连接,就分配新的线程去处理该客户端的数据请求。 代码: /多线程并发服务器/ #include <stdio.h> #include …...

Springboot扩展点之SmartInitializingSingleton
前言这篇文章会重点分析一下SmartInitializingSingleton扩展点的功能 特性、实现方式 、工作原理。SmartInitializingSingleton扩展点内只有一个扩展方法,且执行时机在Spring Bean的生命周期里比较靠后,很重要,但是也很简单。功能特性1、Smar…...

基于linux内核的驱动开发学习
1 驱动 定义:驱使硬件动起来的程序 种类:裸机驱动:需求分析--》查原理图--》查芯片手册--》code 系统驱动:需求分析--》查原理图--》查芯片手册--》设备树--》code --》安装到内核中…...

python3 django gunicorn
首先,Gunicorn是一个高效的Web服务器,地位相当于Java中的Tomcat。简单来说gunicorn封装了HTTP的底层实现,我们通过gunicorn启动服务,用户请求与服务相应都经过gunicorn传输。下载gunicorn的方法也比较简单,在django工程…...

专家分享 | 租赁型售楼处标准化示范区提效研究
2023年2月8日上午,优积科技邀请原金地集团北京公司 高级室内设计专业应锎经理为我司团队分享《租赁型售楼处标准化示范区提效》的专题。 此次专家分享课题加上大家踊跃讨论时间长达3小时,会上应总详细介绍了租赁型售楼处标准化示范区提效,需…...

linux之echo使用技巧
参考文章:linux基本功系列-echo命令实战一、echo 命令是什么?作用: echo命令能将指定文本显示在Linux命令行上,或者通过重定向符写入到指定的文件中。语 法:echo [-ne][字符串] / echo [–help][–version]补充说明&am…...

Keras实例教程(7)之构建模型的第三种方式
多年以前,在TensorFlow中搭建深度学习模型对于很多人来说其实仍然是比较困难的。相比之下,Keras作为独立于TensorFlow的一种深度学习框架则要简单很多。在TensorFlow与PyTorch的竞争中逐渐式微的情况下,TensorFlow团队终于宣布Keras将成为在tensorflow2.0中构建和训练模型的…...

【JUC并发编程】18 CopyOnWriteArrayList源码也就够看2分钟
文章目录1、CopyOnWriteArrayList概述2、原理 / 源码1)构造函数2、add()3)get()4)remove()5)iterator()1、CopyOnWriteArrayList概述 CopyOnWriteArrayList相当于线程安全的ArrayList,底层是一个可变数组。 特点如下…...

如何优雅的实现回调函数?
本篇文章又是一期优雅的代码编程介绍———回调函数。 传统的nodejs编程都是这样的 const fs require(fs) fs.readFile(test.txt,utf8, function(err, dataStr){if(err){} }) 嵌套层级如果多了就成回调地狱了。如果我们将这种风格的代码转换成这样呢? const fs …...

3GPP-NR Band20标准定义频点和信道(3GPP V17.7.0 (2022-12))
Reference test frequencies for NR operating band n20 Table 4.3.1.1.1.20-1: Test frequencies for NRoperating band n20 and SCS 15 kHz CBW [MHz]carrierBandwidth...

Excel表格的公式不想显示出来,可以这样操作
在制作Excel表格的时候,很多人做数据会用到函数公式,这些编辑都是默认可以看到的。 但有时候我们不想让他人看到自己的计算思路和所用公式,有没有办法可以隐藏公式,只显示数据呢?答案是肯定的,今天我们就来…...

【零基础入门前端系列】—语义化标签、实体字符、视频、音频(八)
【零基础入门前端系列】—语义化标签、实体字符、视频、音频(八) 一、什么是HTML语义化标签 语义化的标签,旨在让标签有自己的含义 如上代码:p标签与span标签的区别之一就是,p标签的含义是段落而span标签没有独特的…...

超详细讲解线性表和顺序表!!
超详细讲解线性表和顺序表!!线性表顺序表顺序表的概念及结构静态顺序表动态顺序表顺序表接口实现1、创建2、初始化3、扩容4、尾插5、打印6、销毁7、尾删8、头插9、头删10、插入任意位置11、删除任意位置12、查找13、修改线性表 线性表(linea…...

大数据之-Nifi-Nifi的安装_启动_认识Nifi的操作台---大数据之Nifi工作笔记0002
然后我们看一下如何安装nifi 这个上一节已经说了 然后看一下环境准备,这个自己去安装就可以了,需要jdk,1.8就可以了,然后 maven安装上就可以了 然后去下载,这里下载Linux版本的 1.9.2的版本比较稳定 下载以后,避免端口冲突要修改端口默认是8080,修改为58080 然后启动很简单,看…...

【大数据clickhouse】clickhouse 常用查询优化策略详解
一、前言 在上一篇我们分享了clickhouse的常用的语法规则优化策略,这些优化规则更多属于引擎自带的优化策略,开发过程中只需尽量遵守即可,然而,在开发过程中,使用clickhouse更多将面临各种查询sql的编写甚至复杂sql的…...

【Java项目】基于Java+MySQL+Tomcat+maven+Servlet的个人博客系统的完整分析
✨哈喽,进来的小伙伴们,你们好耶!✨ 🛰️🛰️系列专栏:【Java项目】 ✈️✈️本篇内容:个人博客系统前后端分离实现! 🚀🚀个人代码托管github:博客系统源码地址ÿ…...

java 程序员怎么做找工作
java 程序员怎么做找工作 在网络招聘网站上搜索职位。在中国,像智联招聘、前程无忧、猎聘网等招聘网站上,有许多公司在招聘JAVA程序员。通过这些网站可以快速找到自己合适的工作。 关注社交媒体和专业网站。 加入一些面向JAVA程序员的社交媒体和专业网…...

S7-1200对于不同项目下的PLC之间进行开放式以太网通信的具体方法示例
S7-1200对于不同项目下的PLC之间进行开放式以太网通信的具体方法示例 如下图所示,打开TIA博途创建一个新项目,并通过“添加新设备”组态 S7-1200 客户端 ,选择 CPU1214C DC/DC/DC (client IP:192.168.0.102),建立新子网; 首先编写客户端程序:打开OB1编程界面,选择指令…...

操作系统(四):磁盘调度算法,先来先服务,最短寻道时间优先,电梯算法
文章目录一、磁盘结构二、先来先服务三、最短寻道时间优先四、电梯算法 SCAN一、磁盘结构 盘面(Platter):一个磁盘有多个盘面; 磁道(Track):盘面上的圆形带状区域,一个盘面可以有多…...

maven解决包冲突简单方式(插件maven helper | maven指令)
文章目录使用idea插件maven helper使用maven指令在Java开发中,常常会遇到不同jar包之间存在冲突的情况,这可能会导致编译错误、运行时异常等问题。 使用idea插件maven helper 在idea安装插件maven helper 安装重启完之后点击pom文件,有一个De…...

100行Pytorch代码实现三维重建技术神经辐射场 (NeRF)
提起三维重建技术,NeRF是一个绝对绕不过去的名字。这项逆天的技术,一经提出就被众多研究者所重视,对该技术进行深入研究并提出改进已经成为一个热点。不到两年的时间,NeRF及其变种已经成为重建领域的主流。本文通过100行的Pytorch…...

linux操作系统篇
目录 操作系统概述基本特征并发共享虚拟异步进程管理内存管理文件管理设备管理宏内核和微内核宏内核微内核中断分类外中断异常陷入(系统调用)进程管理进程与线程的区别进程状态切换进程调度算法**批处理系统****交互式系统**进程同步临界...

redis+token实现登录校验,前后端分离,及解跨域问题的4种方法
目录 一、使用自定义filter实现跨域 1、客户端向服务端发送请求 2、服务端做登录验证了,并生成登路用户对应的token,保存到redis 3、响应(报错)-----跨域问题 4、解决跨域问题--------服务器端添加过滤器,设置请求…...

怎么解密MD5,常见的MD5解密方法,一看就会
MD5是一种被广泛使用的密码散列函数,曾在计算机安全领域使用很广泛,但是也因为它容易发生碰撞,而被人们认为不安全。那么,MD5应用场景有哪些,我们怎么解密MD5,本文将带大家了解MD5的相关知识,以…...

Vue3 目录结构
Vue3 目录结构 架构搭建 请确保你的电脑上成功安装 Node.js,本项目使用 Vite 构建工具,需要 Node.js 版本 > 12.0.0。 查看 Node.js 版本: node -v建议将 Node.js 升级到最新的稳定版本: 使用 nvm 安装最新稳定版 Node.js…...

Tsp_nurrec表空间满处理记录20230215
Tsp_nurrec表空间满处理记录20230215 一、问题: 问题:护理病历表空间不足。 二、解决过程:1.查询表空间使用效率 SELECT UPPER(F.TABLESPACE_NAME) “表空间名”, D.TOT_GROOTTE_MB "表空间大小(M)",D.TOT_GROOTTE_MB - F.TOTAL_BYTES "已使用空间(M)"…...

影像测量设备都有什么?有哪些影像仪器?
影像测量仪器是广泛应用于机械、电子、仪表的仪器。主要由机械主体、标尺系统、影像探测系统、驱动控制系统和测量软件等与高精密工作台结构组成的光电测量仪器。一般分为三大类:手动影像仪、自动影像仪和闪测影像仪。测量元素主要有:长度、宽度、高度、…...