目录
1. 多态(polymorphism)
1.1 构成多态的两个条件
1.2 虚函数重写(覆盖)
1.3 协变构成多态
1.4 父虚子非虚构成多态
1.5 析构函数的重写
1.6 final 和 override 关键字(C++11)
1.7 重载、覆盖、隐藏的对比
2. 抽象类(Abstract Class)
2.1 纯虚函数和抽象类
2.2 抽象类指针
2.3 接口继承和实现继承
3. 虚函数表(VTBL)
3.1 观察虚表指针 __vfptr
3.2 虚函数的重写与覆盖
3.3 编译器的查表行为
3.4 通过打印观察 __vfptr
3.5 运行时决议与编译时决议
3.6 动态绑定与静态绑定
4. 多继承中的虚函数表
5. 继承和多态常见的面试问题
5.1 问答题
5.2 选择题
答案:
本章完。
1. 多态(polymorphism)
多态,就是 "多种形态" 的意思。
说具体点就是:去完成某个行为,不同的对象去做会产生不同的结果(状态)。
比如说地铁站买票这个行为,普通人、学生、军人买票是不同的。
普通人必须买全价票,学生就可能可以买半价票,而军人可以优先购买到预留票:
比如有一个 BuyTicket 买票的成员函数,创建普通人、学生和军人三个对象,
他们调用该函数形态结果我们就要设计成不一样的。
这种”不一样“的情况还有各种VIP等等。
所以由此可见,我们需要一种特性来做到这种 "分类" 的操作,这时我们就可以将其实现成多态。
1.1 构成多态的两个条件
构成多态的两个条件:
1、虚函数重写(覆盖) -> 虚函数 + 三同:函数名、参数和返回值相同,不符合重写就是隐藏。
2、父类指针或者引用去调用虚函数
特例1:子类虚函数不加virtual,依旧构成重写 (实际最好加上)
特例2:重写的协变。返回值可以不同,要求必须是父子关系的的指针或者引用
1.2 虚函数重写(覆盖)
我们先用代码实现一下我们刚才的购票场景。将 Student 和 Soldier 继承自 Person:
class Person {};
class Student : public Person {};
class Soldier : public Person {};
这里用 virtual 虚函数,并且做到函数名、参数和返回值相同,就能够达到 "重写" 的效果:
(不符合重写,就是隐藏关系)
class Person
{
public:
virtual void BuyTicket() // virtual + 返回值 + 函数名+ 参数 相同 = 构成多态
{
cout << "Person: 买票-全价" << endl;
}
};
class Student : public Person
{
public:
// 这里也都相同
virtual void BuyTicket()
{
cout << "Student: 买票-半价" << endl;
}
};
class Soldier : public Person
{
public:
// 这里也都相同
virtual void BuyTicket()
{
cout << "Soldier: 优先买票" << endl;
}
};
概念:重写也称为覆盖,重写即重新改写。
重写是为了将一个已有的事物进行某些改变以适应新的要求。
重写是子类对父类的允许访问的方法的实现过程进行重新编写,返回值和形参都不能改变。
最后我们再设计一个 Pay 函数去接收不同的身份,以调用对应的 BuyTicket 函数。
这里我们可以用指针和引用,这里我们用引用:
#include <iostream>
using namespace std;
class Person
{
public:
virtual void BuyTicket() // virtual + 返回值 + 函数名+ 参数 相同 = 构成多态
{
cout << "Person: 买票-全价" << endl;
}
};
class Student : public Person
{
public:
// 这里也都相同
virtual void BuyTicket()
{
cout << "Student: 买票-半价" << endl;
}
};
class Soldier : public Person
{
public:
// 这里也都相同
virtual void BuyTicket()
{
cout << "Soldier: 优先买票" << endl;
}
};
void Pay(Person& p)
{
p.BuyTicket();
}
int main()
{
Person ps;
Student st;
Soldier sd;
Pay(ps);
Pay(st);
Pay(sd);
return 0;
}
再看多态两个条件:
1、虚函数重写(覆盖)
2、父类指针或者引用去调用虚函数
特例1:子类虚函数不加virtual,依旧构成重写 (实际最好加上)
特例2:重写的协变。返回值可以不同,要求必须是父子关系的的指针或者引用
这里我们就构成了多态。(如果把Pay函数的引用去掉就不是多态了,调的三个都是全价)
1.3 协变构成多态
协变(基类与派生类虚函数返回值类型不同)
派生类重写基类虚函数时,与基类虚函数返回值类型不同。
即基类虚函数返回基类对象的指针或者引用,
派生类虚函数返回派生类对象的指针或者引用时,称为协变。(了解)
观察下面的代码,并没有达到 "三同" 的标准,它的返回值是不同的,但依旧构成多态:
#include <iostream>
using namespace std;
class A {};
class B : public A {};
class Person
{
public:
virtual A* f()
{
cout << "virtual A* Person::f()" << endl;
return nullptr;
}
};
class Student : public Person
{
public:
virtual B* f()
{
cout << "virtual B* Student::f()" << endl;
return nullptr;
};
};
int main()
{
Person p;
Student s;
Person* ptr = &p;
ptr->f();
ptr = &s;
ptr->f();
return 0;
}
但是协变也是有条件的,协变的类型必须是父子关系。
1.4 父虚子非虚构成多态
子类的虚函数没了却能构成多态:
#include <iostream>
using namespace std;
class A {};
class B : public A {};
class Person
{
public:
virtual A* f()
{
cout << "virtual A* Person::f()" << endl;
return nullptr;
}
};
class Student : public Person
{
public:
B* f()
{
cout << "virtual B* Student:::f()" << endl;
return nullptr;
};
};
int main()
{
Person p;
Student s;
Person* ptr = &p;
ptr->f();
ptr = &s;
ptr->f();
return 0;
}
这都不是虚函数了怎么也能构成多态呢?
解答:子类虚函数没有写 virtual,但 f 依旧是虚函数,是因为先继承了父类的函数接口声明。
子类继承父类的虚函数是一种接口继承,所以即使子类的 virtual 没写,它也是虚函数,
符合多态条件。这是重写父类虚函数的实现,也就是说父类有 virtual 的属性,子类也就有了。
最后,虽然子类虚函数可以不加 virtual,但是我们自己写的时候 子类虚函数建议加上 virtual。
总结:父类为虚函数,子类继承其父的情况下,即使不声明 virtual 也能构成多态。
1.5 析构函数的重写
如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,
都与基类的析构函数构成重写,虽然基类与派生类析构函数名字不同。虽然函数名不相同,
看起来违背了重写的规则,其实不然,这里可以理解为编译器对析构函数的名称做了特殊处
理,编译后析构函数的名称统一处理成destructor。
#include <iostream>
using namespace std;
class Person
{
public:
~Person()
{
cout << "~Person()" << endl;
}
};
class Student : public Person
{
public:
~Student()
{
cout << "~Student()" << endl;
}
};
int main()
{
Person p;
Student s;
return 0;
}
第一行和第二行是 Student s 的,第三行是 Person p 的。我们来看看析构顺序,
Student s 是后定义的,析构顺序是后定义先析构。根据子类对象析构先子后父,
调用子类的析构函数结束后自动调用父类的析构函数,
所以第一行的 ~Student() 和第二行的 ~Person() 都是 Student 的,
随后第三行的 ~Person() 是 Person p 自己调的。
现在这两个析构函数默认是隐藏关系,
因为它们的函数名会被同一处理修改成 destructor 。
但是如果用 virtual 修饰 ~Person,我们知道,如果这加了不管 ~Student 加不加 virtual,
子类都会跟着父类变身成 virtual,那么现在这两个析构函数还是隐藏关系吗?
如果 Person 的析构函数加了 virtual,隐藏关系就变成了重写关系。
对普通对象(像上面的代码)来说,这里加 virtual 并不会带来什么改变,
再看另一段代码:
#include <iostream>
using namespace std;
class Person
{
public:
virtual ~Person()
{
cout << "~Person()" << endl;
}
};
class Student : public Person
{
public:
~Student() // 隐藏(重定义)关系 -> 重写(覆盖) 关系
{
cout << "~Student()" << endl;
}
};
int main()
{
Person* ptr1 = new Person; // delete 调用 Person 的析构,对这个也没有影响
delete ptr1;
Person* ptr2 = new Student; // 但是对这样的场景会产生影响
delete ptr2;
return 0;
}
把父类的virtual 去掉:
刚才我们看到了,如果这里不加 virtual,~Student 是没有调用析构的。
你可能会想这有啥,那是因为这里没场景,这其实是非常致命的,是不经意间会发生的内存泄露。
比如下面这个场景,我们是希望 delete 谁调用的就是谁的析构:
#include <iostream>
using namespace std;
class Person
{
public:
~Person()
{
cout << "~Person()" << endl;
}
};
class Student : public Person
{
public:
~Student() // 隐藏(重定义)关系 -> 重写(覆盖) 关系
{
cout << "~Student()" << endl;
delete[] _name;
cout << "delete: " << (void*)_name << endl;
}
private:
char* _name = new char[10] { 'h', 'e', 'l', 'l', 'o' };
};
int main()
{
// 我们期望 delete ptr 调用析构函数是一个多态调用
Person* ptr = new Person;
delete ptr; // ptr->destructor() + operator delete(ptr)
ptr = new Student;
delete ptr; // ptr->destructor() + operator delete(ptr)
return 0;
}
但是结果让我们很失望,Student 没析构。我们加上 virtual 再试试:
结论:如果设计的类可能会作为父类,析构函数最好设计成虚函数,即加上 virtual。
像刚才这种场景不加上 virtual 就会发生内存泄露,可怕的是还是悄无声息的!
报错不可怕,怕的是这种悄无声息的,像这种内存泄露找起来可是相当的恶心。
1.6 final 和 override 关键字(C++11)
final 关键字(C++11)
上一篇提到:C++11提供了关键字 final 写在类的后面,表明这个类不能被继承。
如果我有个虚函数,但我不想让它被人重写:
也可以关键字 final 写在函数的后面让虚函数不能被重写
#include <iostream>
using namespace std;
class Car
{
public:
virtual void Drive() final
{}
};
class Benz : public Car
{
public:
virtual void Drive() // 错误 C3248 "Car::Drive": 声明为 "final" 的函数不能由 "Benz::Drive" 重写
{
cout << "Benz" << endl;
}
};
int main()
{
return 0;
}
总结:final 的两个作用
写在虚函数后面让虚函数不能被重写
写在类后面让类不能被继承
override 关键字(C++11)
相信大家也体会到了 C++ 对函数重写的要求是非常严格的,
但是人难免会犯错,有些时候可能会导致函数名次序写反而无法构成重载,
而这种错误在编译期间是不会报的,因此往往只有在程序运行时你发现没有得到预期结果,
去 debug 找个半天才能将问题找出,这会让人感到非常的不爽:C++11 为了增加容错率,
推出了 final 和 override,find 是禁止重写,override 是必须重写。
override 关键字可以帮助你检查重写:
#include <iostream>
using namespace std;
class Car
{
public:
virtual void Drive() {}
};
// override 写在子类中:要求严格检查是否完成重写,如果没有重写就报错
class Benz : public Car
{
public:
virtual void Drive() override
{
cout << "Benz" << endl;
}
};
int main()
{
return 0;
}
把父类 virtual 去掉就报错:
#include <iostream>
using namespace std;
class Car
{
public:
void Drive() {}
};
// override 写在子类中:要求严格检查是否完成重写,如果没有重写就报错
class Benz : public Car
{
public:
virtual void Drive() override//错误 C3668 “Benz::Drive”: 包含重写说明符“override”的方法没有重写任何基类方法
{
cout << "Benz" << endl;
}
};
int main()
{
return 0;
}
有了 override 修饰,像如果没有加 virtual 或参数不同就会报错。
当然,子类是可以省略 virtual 的,override 不会犯病报错放心使用,其在某些场景是非常有用的。
总结:override 写在子类中,会严格检查是否完成重写,如果没有就会报错提醒。
1.7 重载、覆盖、隐藏的对比
2. 抽象类(Abstract Class)
抽象在现实一般没有具体对应的实体,而不能实例化对象也就是没有实体,所以叫抽象类。
"抽象即不可名状,对应的是具象,具象即现实,抽象即虚拟。"
2.1 纯虚函数和抽象类
在虚函数的后面写上 =0,则我们称这个函数为 "纯虚函数"。
包含纯虚函数的类,就是 抽象类(abstract class),也叫接口类。
抽象类不能实例化出对象,子类即使在继承后也不能实例化出对象,只有重写纯虚函数,
子类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。
#include <iostream>
using namespace std;
class Car
{
public:
virtual void Drive() = 0;
};
class BMW : public Car // 如果父类是抽象类,子类必须重写才能实例化
{
public:
virtual void Drive() // 重写 注释掉就会报错:错误 C2259 “BMW” : 无法实例化抽象类
{
cout << "BMW" << endl;
}
};
int main()
{
BMW b;
b.Drive();
return 0;
}
如果 override 是直接要求你重写,那设计成抽象类就是间接要求你重写。
override 是放在子类虚函数,检查重写,它们的功能其实是有一些重叠和相似的。
纯虚函数规范了子类必须重写,另外虚函数更体现出了接口继承。
总结:抽象类不能实例化出对象,子类即使在继承后也不能实例化出对象,除非子类重写。
2.2 抽象类指针
虽然父类是抽象类不能定义对象,但是可以定义指针。
定义指针时如果 new 父类对象因为是纯虚函数,自然是 new 不出来的,
但是可以 new 子类对象:
#include <iostream>
using namespace std;
class Car
{
public:
virtual void Drive() = 0;
};
class BMW : public Car
{
public:
virtual void Drive()
{
cout << "BMW" << endl;
}
};
int main()
{
Car* BMW1 = new BMW;
BMW1->Drive();
BMW* BMW2 = new BMW;
BMW2->Drive();
return 0;
}
2.3 接口继承和实现继承
纯虚函数也是可以实现的,但是,纯虚函数的实现没有什么太大意义,因为根本就没人能用它。
你实现一个东西是为了让人能调用你,纯虚函数谁能调用?根本没有人能调用它。
所以纯虚函数一般给个声明就可以了,它本身就是一个接口继承。
普通函数的继承是一种实现继承,子类继承了父类函数,可以使用函数,继承的是函数的实现。
虚函数的继承是一种接口继承,子类继承的是父类虚函数的接口,目的是为了重写,
达成多态,继承的是接口。所以如果不实现多态,不要把函数定义成虚函数。
出现虚函数就是为了提醒你重写的,以实现多态。如果虚函数不重写,那写成虚函数就没价值了。
3. 虚函数表(VTBL)
我们首先来做一道题:sizeof(Base) 是多少(32位下)?
#include <iostream>
using namespace std;
class Base
{
public:
virtual void Func1()
{
cout << "Func1()" << endl;
}
virtual void Func2()
{
cout << "Func2()" << endl;
}
protected:
int _b = 0;
};
int main()
{
Base b;
cout << sizeof(b) << endl;
return 0;
}
居然是 8。
通过监视窗口我们发现除了 _b 成员外还有了一个 _vfptr 在 b1 对象中:
对象中的这个 _vfptr 我们称之为虚表指针(virtual function pointer),我们简称其为 虚表 。
一个含有虚函数的类中都至少有一个像这样的虚函数表指针,虚函数地址都会放到这个表里。
虚函数表是一个函数指针数组,虚函数表存储在数据段上(常量区)。
那么虚函数表中放了些什么呢?我们继续往下看。
3.1 观察虚表指针 __vfptr
#include <iostream>
using namespace std;
class Base
{
public:
virtual void Func1()
{
cout << "Func1()" << endl;
}
virtual void Func2()
{
cout << "Func2()" << endl;
}
protected:
int _b = 0;
};
int main()
{
Base b1;
Base b2;
return 0;
}
我们增加一个派生类Derive去继承Base
#include <iostream>
using namespace std;
class Base
{
public:
virtual void Func1()
{
cout << "Func1()" << endl;
}
virtual void Func2()
{
cout << "Func2()" << endl;
}
void Func3()
{
cout << "Func3()" << endl;
}
protected:
int _b = 0;
};
class Derive : public Base
{
public:
virtual void Func1()
{
cout << "Derive::Func1()" << endl;
}
protected:
int _d = 1;
};
int main()
{
Base b;
cout << sizeof(b) << endl;
return 0;
}
监视窗口是为了方便我们观测优化过的,相当于是一种美化。
Func3 没有放在 _vfptr 中,证明了这个表里只会存虚函数。
其实虚函数表也没搞什么特殊,也没什么了不起的,虚函数其实是和普通函数一样存在代码段的。
只是普通函数只会进符号表以方便链接,都是 "编译时决议",
而虚函数的地址会被放进虚表,是为了 "运行时决议" 做准备,这个我们后面会细说。
虚表的本质:虚表是一个 "存虚函数指针的指针数组" ,(函数指针数组)
一般情况这个数组最后面会放一个空指针,(取决于编译器)。
3.2 虚函数的重写与覆盖
介绍重写的时候还说过,"重写" 还可以称为 "覆盖",
这是为什么呢?叫重写似乎更好去理解,覆盖好像很难去理解啊。
#include <iostream>
using namespace std;
class Base
{
public:
virtual void Func1()
{
cout << "Func1()" << endl;
}
virtual void Func2()
{
cout << "Func2()" << endl;
}
void Func3()
{
cout << "Func3()" << endl;
}
protected:
int _b = 0;
};
class Derive : public Base
{
public:
virtual void Func1()
{
cout << "Derive::Func1()" << endl;
}
protected:
int _d = 1;
};
int main()
{
Base b;
cout << sizeof(b) << endl;
Derive d;
cout << sizeof(Derive) << endl;
return 0;
}
父类 b 对象和子类 d 对象虚表是不一样的,这里看我们发现 Func1 完成了重写,
所以 d 的虚表中存的是重写的 Derive::Func1,所以虚函数的重写也叫做覆盖。
就可以理解为:子类的虚表拷贝了父类的虚表,子类的 Func1 覆盖掉了父类上的 Func1。
(覆盖指的是虚表中虚函数的覆盖)
- 虚函数重写:语法层的概念,子类对继承父类虚函数实现进行了重写。
- 虚函数覆盖:原理层的概念,子类的虚表,拷贝父类虚表进行了修改,覆盖重写那个虚函数。
总结:虚函数的重写与覆盖,重写是语法层的叫法,覆盖是原理层的叫法。
3.3 编译器的查表行为
编译器是如何做到指针指向谁就调用谁的虚函数的?
#include <iostream>
using namespace std;
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 = 0;
};
class Derive : public Base
{
public:
virtual void Func1()
{
cout << "Derive::Func1()" << endl;
}
private:
int _d = 1;
};
int main()
{
Base b;
Derive d;
Base* ptr = &b;
ptr->Func1(); // 调用的是父类的虚函数
ptr = &d;
ptr->Func1(); // 调用的是子类的虚函数
return 0;
}
能不能猜到是跟虚表有关系?它到底要调用哪个函数不是按类型去定的,
如果是按类型去定的那这里调的应该都是父类,结果会都是 Base::Func1() ,所以显然不是。
这里会去 ptr 指针指向的对象里去查表,其实对它自己而言它自己都也不知道调用的是谁,
因为子类切个片,它自己也只能看到父类对象,它根本就没法知道,但是他会查表。
编译器会从指向的对象里去找,先在父类对象里找到了 Base::Func1,
然后指向变为 &d,它就从子类对象里找,从而找到了 Derive::Func1。
所以,多态调用实现是依靠运行时去指向对象的虚表中查,调用函数地址。
对象也能切片,为什么不能实现多态?
既然指针和引用可以实现多态,那父类赋值给子类对象也可以切片,
为什么实现不了多态?
Base* ptr = &d; √
Base& ref = d; √
Base b = d; × 为什么不行?都是支持切片的,为什么对象就不行?
从编译器的角度,编译器实现时会判断构不构成多态,不满足规则不构成多态就找到地址,call。
至于为什么实现不了多态,因为实现出来会出现混乱状态。
"即使你是一门语言的设计者,遇到这种问题也很难解决 "
根本原因是:对象切片时,子类对象只会拷贝成员给父类对象,并不会拷贝虚表指针。
因为拷贝了就混乱了,父类对象中到底是父类的虚表指针?还是子类的虚表指针?
那下面的调用是调用父类的虚函数还是子类的虚函数?就不确定了:
ptr = &b;
ptr->func1(); // ?????????? 父类的func1,还是子类的func1?
对象实现多态又不得不去拷贝虚表,因为它肯定是需要去对象里的虚表里找,
问题是拷贝虚表后就乱了套了。最大问题是 —— 这时候到底调用的是谁的问题。
如果一个父类对象切片拷贝给子类后,切片前指向子类,没切片前指向父类。"这让人头大"
所以对象不能实现多态,想实现也不行,实现了就乱套了。
总结:
一个类对象中的 __vptr 是恒定的,它永远都会指向其所属类的虚表。
当使用指针或引用时,__vptr 会指向继承类的虚表(从而达成多态的效果)
3.4 通过打印观察 __vfptr
打开监视窗口观察下列代码的虚表:
#include <iostream>
using namespace std;
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 = 0;
};
class Derive : public Base
{
public:
virtual void Func1()
{
cout << "Derive::Func1()" << endl;
}
void Func3()
{
cout << "Derive::Func3()" << endl;
}
virtual void Func4()
{
cout << "Derive::Func4()" << endl;
}
private:
int _d = 1;
};
int main()
{
Base b;
Derive d;
return 0;
}
从监视窗口观察,有时候会发现,好像有些虚函数再监视窗口显示的虚表里不存在。
这时候千万不能动摇 "只要是虚函数都会存入虚表" 这个事实。
这是监视窗口的锅,前面就过说了:监视窗口是美化过的。
想要看到真实的样子,我们可以打开内存去查看,
但是内存很难看懂,有什么办法可以把虚表打印出来?
虚表是个函数指针数组,该数组里的每个元素存放的是一个函数指针。
只要取到虚表指针,想打印虚表就很简单了:
#include <iostream>
using namespace std;
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 = 0;
};
class Derive : public Base
{
public:
virtual void Func1()
{
cout << "Derive::Func1()" << endl;
}
void Func3()
{
cout << "Derive::Func3()" << endl;
}
virtual void Func4()
{
cout << "Derive::Func4()" << endl;
}
private:
int _d = 1;
};
typedef void(*V_FUNC)(); // 把函数指针 void(*)() typedef 成 V_FUNC
void Print_VFTable(V_FUNC* arr)
{
printf("vfptr:%p\n", arr);
for (size_t i = 0; arr[i] != nullptr; ++i) // 如果编译器在最后无nullptr,就要手动传参,有几个就打印几个
{
printf("vfptr[%d]: %p\n", i, arr[i]);
V_FUNC Func = arr[i];
Func(); // 函数指针加括号->调用对应的函数
}
}
int main()
{
Derive d;
Print_VFTable
(
(V_FUNC*)(*((int*)&d))// 取d对象的头四个字节 指针之间是可以互相转换的
); // 把d取地址强转成int*,解引用就取出头四个字节(int),最后强转成V_FUNC* 传参
// 语法有规定:完全没有关系的类型强转也不行。
// 至少得有一点关系:比如指针和int
// 因为指针虽然是地址但是也是表示地址的编号,第几个编号的地址
// 指针之间可以随意转换,我想取4个字节,& d 是个 Derive* ,
// 接引用后就是 Derive,此时强转成 int* 解引用就是取4个字节了。
// 由于是 int* 要调用 Print_VFTable 函数传不过去,所以我们最外层又
// 强转回 V_FUNC* 了,这是一个函数指针数组的地址指针。
// “内线转外线再转内线”
return 0;
}
VS下打印父类出错了(这是VS的BUG,在release下就正常),
手动传试一下,Linux下也是这样手动传:
#include <iostream>
using namespace std;
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 = 0;
};
class Derive : public Base
{
public:
virtual void Func1()
{
cout << "Derive::Func1()" << endl;
}
void Func3()
{
cout << "Derive::Func3()" << endl;
}
virtual void Func4()
{
cout << "Derive::Func4()" << endl;
}
private:
int _d = 1;
};
typedef void(*V_FUNC)(); // 把函数指针 void(*)() typedef 成 V_FUNC
void Print_VFTable(V_FUNC* arr,size_t n)
{
printf("vfptr:%p\n", arr);
for (size_t i = 0; /*arr[i] != nullptr*/ i < n; ++i) // 如果编译器在最后无nullptr,就要手动传参,有几个就打印几个
{
printf("vfptr[%d]: %p\n", i, arr[i]);
V_FUNC Func = arr[i];
Func(); // 函数指针加括号->调用对应的函数
}
}
int main()
{
Derive d;
Print_VFTable
(
(V_FUNC*)(*((int*)&d)),3// 取d对象的头四个字节 指针之间是可以互相转换的
); // 把d取地址强转成int*,解引用就取出头四个字节(int),最后强转成V_FUNC* 传参
// 语法有规定:完全没有关系的类型强转也不行。
// 至少得有一点关系:比如指针和int
// 因为指针虽然是地址但是也是表示地址的编号,第几个编号的地址
// 指针之间可以随意转换,我想取4个字节,& d 是个 Derive* ,
// 接引用后就是 Derive,此时强转成 int* 解引用就是取4个字节了。
// 由于是 int* 要调用 Print_VFTable 函数传不过去,所以我们最外层又
// 强转回 V_FUNC* 了,这是一个函数指针数组的地址指针。
// “内线转外线再转内线”
Base b;
cout << endl;
Print_VFTable
(
(V_FUNC*)(*((int*)&b)),2
);
return 0;
}
结论:VS 监视窗口看到的虚函数表不一定是真实的,可能被处理过。
3.5 运行时决议与编译时决议
刚才知道了,多态调用实现是靠运行时查表做到的,再看一段代码。
注意 Func3 不是虚函数:
#include <iostream>
using namespace std;
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 = 0;
};
class Derive : public Base
{
public:
virtual void Func1()
{
cout << "Derive::Func1()" << endl;
}
private:
int _d = 1;
};
int main()
{
Base b;
Derive d;
Base* ptr = &b;
ptr->Func1(); // 调用的是父类的虚函数
ptr->Func3();
ptr = &d;
ptr->Func1(); // 调用的是子类的虚函数
ptr->Func3();
return 0;
}
这里 Func3 为什么不是 Derive 的?因为 Func3 不是虚函数,它没有进入虚表。
如果从更深的角度 —— 汇编层面去看,就可以牵扯出编译时决议和运行时决议。
决议的意思就是如何去确定函数的地址,一个是在运行时确定,一个是在编译时确定。
多态调用:运行时决议,即运行时确定调用函数的地址。【通过查虚函数表】
(编译完后通过指令,去对象中虚表里去找虚函数运行,是运行时去找,找到了才调用)
(这正是多态底层实现的原理,编译器去检查,
如果满足多态的条件了,它就按运行时决议的方式。)
普通调用:编译时决议,编译时确定调用函数的地址。【通过类型】
(所有的编译时确定都是看 ptr 是什么类型,跟对象没有关系,不看指向的对象,
自己是什么类型,就去哪里找 Func1)
3.6 动态绑定与静态绑定
静态库:指的是链接的那个阶段链接的库。
动态库:程序运行起来后才加载,去动态库里找。
静态绑定:又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,
也称为静态多态。比如函数重载。
动态绑定:又称后期绑定(晚绑定),在程序运行期间,
根据具体拿到的类型确定程序的具体行为,调用具体的函数,也成为动态多态。
多态在有些书上还细分了静态的多态和动态的多态。
静态的多态(编译时):指的是函数重载。
动态的多态(运行时):指的是本节内容讲的这个。
4. 多继承中的虚函数表
刚才我们看的是单继承,我们现在再看复杂一点的多继承。
代码:Base1 和 Base2 都进行了重写:
#include <iostream>
using namespace std;
class Base1
{
public:
virtual void func1() { cout << "Base1::func1" << endl; }
virtual void func2() { cout << "Base1::func2" << endl; }
private:
int b1 = 1;
};
class Base2
{
public:
virtual void func1() { cout << "Base2::func1" << endl; }
virtual void func2() { cout << "Base2::func2" << endl; }
private:
int b2 = 2;
};
class Derive : public Base1, public Base2
{
public:
virtual void func1() { cout << "Derive::func1" << endl; }
virtual void func3() { cout << "Derive::func3" << endl; }
private:
int d1 = 3;
};
typedef void(*V_FUNC)(); // 把函数指针 void(*)() typedef 成 V_FUNC
void Print_VFTable(V_FUNC* arr/*, size_t n*/)
{
printf("vfptr:%p\n", arr);
for (size_t i = 0; arr[i] != nullptr/*i < n*/; ++i) // 如果编译器在最后无nullptr,就要手动传参,有几个就打印几个
{
printf("vfptr[%d]: %p\n", i, arr[i]);
V_FUNC Func = arr[i];
Func(); // 函数指针加括号->调用对应的函数
}
cout << endl;
}
int main()
{
Derive d;
Print_VFTable((V_FUNC*)(*(int*)&d));
// 打印Base2的虚表先强转成char* +1 是+一个字节
Print_VFTable((V_FUNC*)(*(int*)((char*)&d + sizeof(Base1))));
return 0;
}
这里 Derive 明显会有两张虚表,我们先通过监视简单看一下:
func3 是放哪一个虚表里?是两张都放一份,还是选择一份放呢?
面试时就会问这样的问题,它是放在第一个虚表的,第二个虚表没有。
然后发现Derive::func1 的地址竟然不一样?然后直接打印func1的地址也是不一样的。
这里就是多套了一层,是一种保护机制。虽然不一样但是最后都跳到了函数上面去。
关于菱形继承及菱形虚拟继承:
实际中我们不建议设计出菱形继承及菱形虚拟继承,一方面太复杂容易出问题,另一方面这样的模型,访问基类成员有一定得性能损耗。所以菱形继承、菱形虚拟继承我们的虚表我们就不看了,一般我们也不需要研究清楚,因为实际中很少用。
这里放两篇拓展阅读:
C++ 虚函数表解析 | 酷 壳 - CoolShell
C++ 对象的内存布局 | 酷 壳 - CoolShell
5. 继承和多态常见的面试问题
5.1 问答题
1. 什么是多态?
答:通俗来说,就是多种形态,具体点就是去完成某个行为,
当不同的对象去完成时会产生出不同的状态。而多态又分为编译时多态和运行时多态。
2. 什么是重载、重写(覆盖)、重定义(隐藏)?
答:重载是函数在同一作用域内,函数名相同,函数参数的个数,顺序,类型不同,与返回值无关;
重写是函数在子类和父类两个作用域,函数名称,函数参数,函数返回值都相同(协变除外);
重定义是函数在父类和子类在两个作用域,函数名称相同,不构成重写,就是重定义。
另外重定义不仅仅针对函数变量在父类和子类两个作用域名字相同也是构成重定义。
3. 多态的实现原理是什么?
答:多态的实现原理是因为对象中存在虚表指针,通过虚表指针来找到虚表,而虚表中存在着虚函数的地址,不同类的对象找到不同的虚表从而调用到不同的虚函数而实现了多态。
4. inline 函数可以是虚函数吗?
答: 能,因为虚函数要放到虚表中去,内联函数会直接展开成指令。我们在内联函数前加virtual编译器不会报错,因为内联只是一种建议,当加了virtual后编译器会自动舍弃内联属性。
5. 静态成员可以是虚函数吗?
答:不能,因为静态成员函数没有this指针,使用类型::成员函数的调用方式无法访问虚函数表,所以静态成员函数无法放进虚函数表。
6. 构造函数可以是虚函数吗?
答:不能,因为对象中的虚函数表指针是在构造函数初始化列表阶段才初始化的。
7. 析构函数可以是虚函数吗?
答:可以,并且最好把基类的析构函数定义成虚函数。比如我们new了一个子类对象用了父类指针来接受,当我们销毁时我们期望调用父类的析构函数来进行销毁,而把父类的析构函数定义成虚函数的好处是编译器会做出特殊处理将父子的析构函数都处理成destructor,从而构成多态方便正确调用。
8. 对象访问普通函数快还是虚函数更快?
答:如果是普通对象,是一样快的。如果是指针对象或者是引用对象,则调用的普通函数快,因为构成多态,运行时调用虚函数需要到虚函数表中去查找。
9. 虚函数表是在什么阶段生成的,存在哪的?
答:虚函数表是在编译阶段就生成的,一般情况存在代码段(常量区)的。
10. C++菱形继承的问题?虚继承的原理?
答:C++菱形继承会出现数据冗余和二义性的问题。虚继承是为了消除数据冗余的问题;虚继承的原理是将冗余数据单独放在一边,让加了virtual的两个类共享该数据,但是为了方便这两个类都能够找得到该数据就在对象中多存了虚基表指针,通过该指针能够找到一张虚基表,虚基表中存在着偏移量,通过偏移量就能够方便的找到共享数据。
11. 什么是抽象类?抽象类的作用是什么?
答:在虚函数的后面写上 =0 ,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。抽象类强制重写了虚函数,另外抽象类体现出了接口继承关系。
5.2 选择题
1. 关于虚函数说法正确的是( )
A.被virtual修饰的函数称为虚函数
B.虚函数的作用是用来实现多态
C.虚函数在类中声明和类外定义时候,都必须加虚拟关键字
D.静态虚成员函数没有this指针
2. 关于多态,说法不正确的是( )
A.C++语言的多态性分为编译时的多态性和运行时的多态性
B.编译时的多态性可通过函数重载实现
C.运行时的多态性可通过模板和虚函数实现
D.实现运行时多态性的机制称为动态绑定
3. 关于重载、重写和重定义的区别说法正确的是( )【不定项选择】
A.重写和重定义都发生在继承体系中
B.重载既可以在一个类中,也可以在继承体系中
C.它们都要求原型相同
D.重写就是重定义
E.重定义就是重写
F.重写比重定义条件更严格
G.以上说法全错误
4. 关于重载和多态正确的是 ( )
A.如果父类和子类都有相同的方法,参数个数不同, 将子类对象赋给父类对象后, 采用父类对象调用该同名方法时,实际调用的是子类的方法
B.选项全部都不正确
C.重载和多态在C++面向对象编程中经常用到的方法,都只在实现子类的方法时才会使用
D.class A{ public: void test(float a) { cout << a; } }; class B :public A{ public: void test(int b){ cout << b; } }; void main() { A *a = new A; B *b = new B; a = b; a->test(1.1); } 结果是1
5. 以下哪项说法时正确的( )
class A
{
public:
void f1(){cout<<"A::f1()"<<endl;}
virtual void f2(){cout<<"A::f2()"<<endl;}
virtual void f3(){cout<<"A::f3()"<<endl;}
};
class B : public A
{
public:
virtual void f1(){cout<<"B::f1()"<<endl;}
virtual void f2(){cout<<"B::f2()"<<endl;}
void f3(){cout<<"B::f3()"<<endl;}
};
A.基类和子类的f1函数构成重写
B.基类和子类的f3函数没有构成重写,因为子类f3前没有增加virtual关键字
C.如果基类指针引用子类对象后,通过基类对象调用f2时,调用的是子类的f2
D.f2和f3都是重写,f1是重定义
6. 关于抽象类和纯虚函数的描述中,错误的是 ( )
A.纯虚函数的声明以“=0;”结束
B.有纯虚函数的类叫抽象类,它不能用来定义对象
C.抽象类的派生类如果不实现纯虚函数,它也是抽象类
D.纯虚函数不能有函数体
7. 假设A为抽象类,下列声明( )是正确的
A.A fun(int);
B.A*p;
C.int fun(A);
D.A obj;
8. 关于不能设置成虚函数的说法正确的是( )
A.友元函数可以作为虚函数,因为友元函数出现在类中
B.成员函数都可以设置为虚函数
C.静态成员函数不能设置成虚函数,因为静态成员函数不能被重写
D.析构函数建议设置成虚函数,因为有时可能利用多态方式通过基类指针调用子类析构函数
9. 要实现多态类型的调用,必须( )
A.基类和派生类原型相同的函数至少有一个是虚函数即可
B.假设重写成功,通过指针或者引用调用虚函数就可以实现多态
C.在编译期间,通过传递不同类的对象,编译器选择调用不同类的虚函数
D.只有在需要实现多态时,才需要将成员函数设置成虚函数,否则没有必要
10. 关于虚表说法正确的是( )
A.一个类只能有一张虚表
B.基类中有虚函数,如果子类中没有重写基类的虚函数,此时子类与基类共用同一张虚表
C.虚表是在运行期间动态生成的
D.一个类的不同对象共享该类的虚表
11. 如果类B继承类A,A::x()被声明为虚函数,B::x()重写了A::x()方法,下述语句中哪个x()方法会被调用:( )
B b;
b.x();
A.A::x()
B.B::x()
C.A::x() B::x()
D.B::x() A::x()
12. 以下程序输出结果是( )
class A
{
public:
A ():m_iVal(0){test();}
virtual void func() { std::cout<<m_iVal<<‘ ’;}
void test(){func();}
public:
int m_iVal;
};
class B : public A
{
public:
B(){test();}
virtual void func()
{
++m_iVal;
std::cout<<m_iVal<<‘ ’;
}
};
int main(int argc ,char* argv[])
{
A*p = new B;
p->test();
return 0;
}
13. 假设A类中有虚函数,B继承自A,B重写A中的虚函数,也没有定义任何虚函数,则( )
A.A类对象的前4个字节存储虚表地址,B类对象前4个字节不是虚表地址
B.A类对象和B类对象前4个字节存储的都是虚表的地址
C.A类对象和B类对象前4个字节存储的虚表地址相同
D.A类和B类中的内容完全一样,但是A类和B类使用的不是同一张虚表
14. 假设D类先继承B1,然后继承B2,B1和B2基类均包含虚函数,D类对B1和B2基类的虚函数重写了,并且D类增加了新的虚函数,则:( )
A.D类对象模型中包含了3个虚表指针
B.D类对象有两个虚表,D类新增加的虚函数放在第一张虚表最后
C.D类对象有两个虚表,D类新增加的虚函数放在第二张虚表最后
D.以上全部错误
15. 下面函数输出结果是( )
class A
{
public:
virtual void f()
{
cout<<"A::f()"<<endl;
}
};
class B : public A
{
private:
virtual void f()
{
cout<<"B::f()"<<endl;
}
};
A* pa = (A*)new B;
pa->f();
A.B::f()
B.A::f(),因为子类的f()函数是私有的
C.A::f(),因为强制类型转化后,生成一个基类的临时对象,pa实际指向的是一个基类的临时对象
D.编译错误,私有的成员函数不能在类外调用
答案:
1. B
A.被virtual修饰的成员函数称为虚函数
B.正确
C.virtual关键字只在声明时加上,在类外实现时不能加
D.static和virtual是不能同时使用的
2. C
A.多态分为编译时多态和运行时多态,也叫早期绑定和晚期绑定
B.编译时多态是早期绑定,主要通过重载实现
C.模板属于编译时多态,故错误
D.运行时多态是动态绑定,也叫晚期绑定
3. AF
A.重写即覆盖,针对多态, 重定义即隐藏, 两者都发生在继承体系中
B.重载只能在一个范围内,不能在不同的类里
C.只有重写要求原型相同
D.重写和重定义是两码事,重写即覆盖,针对多态, 重定义即隐藏
E.重写和重定义是两码事,重写即覆盖,针对多态, 重定义即隐藏
F.重写要求函数完全相同,重定义只需函数名相同即可
G.很明显有说法正确的答案
4. B
A.使用父类对象调用的方法永远是父类的方法
B.正确
C.重载不涉及子类
D.输入结果为1.1
5. D
A.错误,构成重写是子类重写父类的virtual函数,
B.f3构成重写,重写时子类可以不要求加virtual关键字
C.通过父类对象调用的方法永远只能是父类方法
D.正确
6. D
A.纯虚函数的声明以“=0;”结束,这是语法要求
B.有纯虚函数的类叫抽象类,它不能用来定义对象,一般用于接口的定义
C.子类不实现父类所有的纯虚函数,则子类还属于抽象类,仍然不能实例化对象
D.纯虚函数可以有函数体,只是意义不大
7. B
A.抽象类不能实例化对象,所以以对象返回是错误
B.抽象类可以定义指针,而且经常这样做,其目的就是用父类指针指向子类从而实现多态
C.参数为对象,所以错误
D.直接实例化对象,这是不允许的
8. D
A.友元函数不属于成员函数,不能成为虚函数
B.静态成员函数就不能设置为虚函数
C.静态成员函数与具体对象无关,属于整个类,核心关键是没有隐藏的this指针,可以通过类名::成员函数名 直接调用,此时没有this无法拿到虚表,就无法实现多态,因此不能设置为虚函数
D.尤其是父类的析构函数强力建议设置为虚函数,这样动态释放父类指针所指的子类对象时,能够达到析构的多态
9. D
A.必须是父类的函数设置为虚函数
B.必须通过父类的指针或者引用才可以,子类的不行
C.不是在编译期,而应该在运行期间,编译期间,编译器主要检测代码是否违反语法规则,此时无法知道基类的指针或者引用到底引用那个类的对象,也就无法知道调用那个类的虚函数。在程序运行时,才知道具体指向那个类的对象,然后通过虚表调用对应的虚函数,从而实现多态。
D.正确,实现多态是要付出代价的,如虚表,虚表指针等,所以不实现多态就不要有虚函数了
10. D
A.多继承的时候,就会可能有多张虚表
B.父类对象的虚表与子类对象的虚表没有任何关系,这是两个不同的对象
C.虚表是在编译期间生成的
D.一个类的不同对象共享该类的虚表,可以自行写代码验证之
11. B
虽然子类重写了父类的虚函数,但只要是用对象去调用,则只能调用相对类型的方法,故B正确
12. C
分析:new B时先调用父类A的构造函数,执行test()函数,在调用func()函数,由于此时还处于对象构造阶段,多态机制还没有生效,所以,此时执行的func函数为父类的func函数,打印0,构造完父类后执行子类构造函数,又调用test函数,然后又执行func(),由于父类已经构造完毕,虚表已经生成,func满足多态的条件,所以调用子类的func函数,对成员m_iVal加1,进行打印,所以打印1, 最终通过父类指针p->test(),也是执行子类的func,所以会增加m_iVal的值,最终打印2, 所以答案为C 0 1 2
13. D
A.父类对象和子类对象的前4字节都是虚表地址
B.A类对象和B类对象前4个字节存储的都是虚表的地址,只是各自指向各自的虚表
C.不相同,各自有各自的虚表
D.A类和B类不是同一类内容不同
14. B
A.D类有几个父类,如果父类有虚函数,则就会有几张虚表,自身子类不会产生多余的虚表,所以只有2张虚表
B.正确
C.子类自己的虚函数只会放到第一个父类的虚表后面,其他父类的虚表不需要存储,因为存储了也不能调用
D.错误
15. A
A.正确
B.虽然子类函数为私有,但是多态仅仅是用子类函数的地址覆盖虚表,最终调用的位置不变,只是执行函数发生变化
C.不强制也可以直接赋值,因为赋值兼容规则作出了保证
D.编译正确
本篇完。
下一篇开始用C++接触高阶数据结构的内容了,所以下一篇放在新开的高阶数据结构专栏和C++专栏里。首先是搜索二叉树,然后是map和set容器。
穿越回来复习顺便贴个下篇链接:文章来源:https://www.toymoban.com/news/detail-538850.html
从C语言到C++_24(二叉搜索树)概念+完整代码实现+笔试题-CSDN博客文章来源地址https://www.toymoban.com/news/detail-538850.html
到了这里,关于从C语言到C++_23(多态)抽象类+虚函数表VTBL+多态的面试题的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!