文章目录
1. 多态的概念
多态的概念:通俗来说,就是多种形态,具体点就是去完成某个行为,当不同的对象去完成时会产生出不同的状态
举个例子,对于买票这个行为,当普通人买票时就是全价买票,学生买票就是半价,军人买票就是优先买票。这就是不同的对象执行容易个行为的时候,会有不同的状态产生。
再有一个例子,我们知道有种东西叫做大数据杀熟,这就是通过分析你的以往的行为,给使用者分为几个不同的类型,对于不同的使用者,做买东西这个行为,定价不同。这也是一种多态行为
2.多态的定义和实现
1. 虚函数
1. 虚函数的概念
使用virtual修饰的类成员函数叫做虚函数
class person
{
public:
virtual void BuyTicket() { cout << "买票-全价" << endl; }//虚函数
};
2. 虚函数的重写
我们知道,当基类和派生类中的成员函数重名的时候,会构成隐藏(重定义),其中,如果重名函数的返回值类型和参数列表完全相同,并且都是虚函数时,将会构成虚函数的重写。
class person
{
public:
virtual void BuyTicket() { cout << "买票-全价" << endl; }
};
class student : public person
{
public:
virtual void BuyTicket() { cout << "买票-半价" << endl; }//写法一
//void BuyTicket() { cout << "买票-半价" << endl; }//写法二
};
其中person和student中的BuyTicket函数就构成了重写。
在上述student类中的BuyTicket函数还有写法二,使用写法二这种写法也是构成重写的。
✅原因:在重写基类虚函数的时候,派生类的虚函数不加virtual关键字也可以构成重写,这是因为在继承后基类的虚函数被继承下来任然保持着虚函数属性,也可以理解成:在重写的时候,只关注函数体的实现。函数返回值类型、函数名和参数列表的要求只是为了保证能够构成重写。为了规范写法,我们这里还是建议派生类的虚函数也加上virtual关键字。
3. 构成虚函数重写的两个例外
析构函数的重写
如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,都与基类的析构函数构成重写,虽然基类与派生类析构函数名字不同。虽然函数名不相同,看起来违背了重写的规则,其实不然,这里可以理解为编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor
协变:基类和派生类虚函数返回值不同
派生类重写基类虚函数的时候,改变了返回值的类型。即基类虚函数返回基类类型的指针或引用,派生类返回派生类的指针或引用也构成重写
2. C++11 override和final
可以看到,C++对函数重写的要求比较严格,但是在某些情况下很容易忽略,导致无法构成重写,但是这种错误在编译期间不会被报出,最终运行结果却会和预期不同,后期再debug就很麻烦,所以C++11提供了override和final两个关键字用于检测是否重写。
1. final关键字
final修饰虚函数,表示该虚函数不能再被重写
class Car
{
public:
virtual void Drive() final {}
};
class Benz : public Car
{
public:
virtual void Drive() { cout << "Benz" << endl; }
};
final的另一个作用:创建一个不能被继承的类
2.override关键字
override:检查派生类虚函数是否重写了基类的某个虚函数,如果没有重写则编译报错,如果重写了正常运行
class Car
{
public:
virtual void Drive() {}
};
class Benz : public Car
{
public:
virtual void Drive(int a) override { cout << "Benz" << endl; }
};
3.多态构成的条件
- 必须通过基类的指针或者引用调用虚函数
- 被调用的函数必须是虚函数,而且派生类必须对虚函数进行重写
class person
{
public:
virtual void BuyTicket() { 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 func(person& p)
{
p.BuyTicket();
}
void Test2()
{
person pn;
student st;
soldier sr;
func(pn);
func(st);
func(sr);
}
可以看到,三个不同类对象调用同一个函数,最终执行的结果不同,这就是多态
当没有通过基类的指针或者引用调用时:不构成多态
当没有虚函数重写的时候:不构成多态
4.重载、重写(覆盖)、重定义(隐藏)的对比
重载:两个在同一作用域的函数,其函数名相同且参数不同,构成函数重载
重定义(隐藏):两个分别在基类和派生类中的同名函数,构成隐藏
重写(覆盖):两个分别在基类和派生类中的函数名、返回值、参数列表都相同的虚函数构成重写(协变例外)
3. 抽象类
在虚函数后面加上=0,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(接口类),抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。
class person
{
public:
virtual void BuyTicket() = 0 { cout << "person::买票-全价" << endl; }
};
class student : public person
{
public:
virtual void BuyTicket(int a) { cout << "student::买票-半价" << endl; }
};
void Test3()
{
person pn;
}
接口继承与实现继承
普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实现。虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口。所以如果不实现多态,不要把函数定义成虚函数
4. 多态的原理
首先,我们来看一道题
//(在x86环境下)这里sizeof(Base)是多少 class Base { public: virtual void func() { cout << "func" << endl; } private: int _b = 1; };
可以看到,结果是8个字节,这是为什么?一个Base对象中不是只有一个_b成员吗?我们来看一下监视窗口
可以发现,在一个Base对象中,除了_b成员之外,还有一个_vfptr(在VS2022平台下是放在前面的),看类型可知这是一个指针。这个指针我们叫他虚函数表指针(其中v表示virtual,f表示function)。一个含有虚函数的类至少有一个虚函数表指针,因为虚函数的地址要被放到虚函数表(也叫虚表)中。
现在我们来改写一下这个代码
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:
virtual void Func1()
{
cout << "Derive::Func1()" << endl;
}
private:
int _d = 2;
};
void Test4()
{
Base b;
Derive d;
}
现在,我们通过监视窗口来观察一下b和d对象
有以下几点发现:
- 可以看到d对象继承了Base类中的成员,所以也就理所当然的继承了一个虚表指针
- 在代码中,我们让Func1完成了重写的过程,所以看到d对象中的虚表中的Func1是Drive中的Func1,也可以理解成覆盖,就是指虚表中的虚函数的覆盖。这里重写是语法层上的叫法,覆盖是原理层上的叫法。
- 由于在基类中,Func2也是虚函数,所以也放在虚表里面,但是在派生类中没有被重写,所以在b对象和d对象中虚表的Func2地址是相同的。
对上述现象的疑问与分析
✅虚函数表指针vfptr本质上是一个函数指针数组指针,这个指针指向了一个函数指针数组,这个数组的名字叫做虚函数表(虚表),表里面存放的是该类中的所有虚函数 ==> 类中的所有虚函数都会进入虚表
❓派生类中的虚表是怎么生成的?
✅首先,将基类中的虚表内容拷贝一份到派生类虚表中;然后,将派生类中所有的虚函数放进虚表中,如果发现其中有虚函数是重写基类中的,那就覆盖掉;最后,对于新增虚函数的顺序问题:按照在派生类中的声明顺序排列
✅在VS下测试,虚表以一个nullptr结尾。
❓虚表是存放在什么位置的?
✅我们不太清楚虚表存放的位置,那么现在用一个方法来测试一下,我们看下面一段代码:
void print() { Base b; cout << (void*)(*(int*)&b) << endl;//拿到b对象的地址,强转成int*,拿到前四个字节的地址,解引用就是虚表的地址 } void Test5() { int a = 10; int* b = new int(20); static int c = 30; const char* d = "aaaa"; cout << "栈:" << &a << endl; cout << "堆:" << b << endl; cout << "静态区:" << &c <<endl; cout << "代码段:" << (void*)d << endl; cout << "虚表地址"; print(); }
可以看到虚表地址和代码段是最接近的,所以虚表是存放在代码段的
在g++下测试也是在代码段。
所以多态的原理就是在在类中定义了虚函数,然后在运行时虚函数会进入虚表中,在构造对象的时候,会构造一个函数指针数组指针,用于存放虚表的地址,这里的虚表本质上是一个函数指针数组,数组内存放的就是虚函数的地址。如果在派生类中对虚函数进行了重写,那么派生类对象中的基类成员的虚表中对应的地址会被重写之后的虚函数地址覆盖。此时通过切片得到的派生类对象指针或引用与基类对象指针或引用调用虚函数时,就会通过虚表去找到需要调用的虚函数,从而就实现了调用同一个函数名,却产生了不同的结果的情况,即多态行为。
动态绑定和静态绑定
静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态,比如:函数重载
动态绑定又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态
5. 单继承和多继承关系的虚函数表
1.单继承中的虚函数表
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 _a;
};
class Derive : public Base
{
public:
virtual void Func1() { cout << "Base::Func1()" << endl; }
virtual void Func3() { cout << "Base::Func3()" << endl; }
virtual void Func4() { cout << "Base::Func4()" << endl; }
private:
int _b = 2;
};
void Test4()
{
Base b;
Derive d;
}
按照以往的管理,这个时候我们应该打开监视窗口来查看虚表情况
可以看到,在d对象的虚表中本应该有四个虚函数,但是只能看到两个,这里可以认为是VS下的一个bug,那我们要怎么看到虚表中的内容呢?
这里推荐大家写一个接口用来打印虚表中的内容,完整代码如下:
class Base
{
public:
virtual void Func1() { cout << "Base::Func1()" << endl; }
virtual void Func2() { cout << "Base::Func2()" << endl; }
void Func3()
{
cout << "Base::Func3()" << endl;
}
typedef void(*vfptr)();//这里将上面虚函数的指针类型重定义成vfptr,方便使用
void print_vftptr()
{
print((vfptr*)(*(int*)this));
}
private:
void print(vfptr table[])
{
//依次拿到虚表中的所有函数指针
for (int i = 0; table[i] != nullptr; ++i)//在VS下,虚表以nullptr结束,所以可以用此方式作为循环条件
{
printf("[%d]:%p->", i, table[i]);//打印序号和地址
table[i]();//调用函数,虚函数与预期相符
}
cout << endl;
}
int _a = 1;
};
class Derive : public Base
{
public:
virtual void Func1() { cout << "Base::Func1()" << endl; }
virtual void Func3() { cout << "Base::Func3()" << endl; }
virtual void Func4() { cout << "Base::Func4()" << endl; }
private:
int _b = 2;
};
void Test4()
{
Base b;
Derive d;
b.print_vftptr();
d.print_vftptr();
}
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;
};
typedef void(*vfptr)();
void print(vfptr table[])
{
for (int i = 0; table[i] != nullptr; ++i)
{
printf("[%d]:%p->", i, table[i]);
table[i]();
}
cout << endl;
}
void Test6()
{
Derive d;
vfptr* vTableb1 = (vfptr*)(*(int*)&d);
print(vTableb1);
vfptr* vTableb2 = (vfptr*)(*(int*)((char*)&d + sizeof(Base1)));
print(vTableb2);
}
3.菱形继承和菱形虚拟继承
实际中我们不建议设计出菱形继承及菱形虚拟继承,一方面太复杂容易出问题,另一方面这样的模型,访问基类成员有一定得性能损耗。因此这里就不堆其进行详细的探讨,如果有想要了解的,这里推荐两篇博客
6. 一些考察题
❓inline函数可以是虚函数吗?
✅虚函数可以使用inline修饰,但是编译器会忽略,因为虚函数将会放到虚表中
❓静态成员可以是虚函数吗?
✅不可以,静态函数没有this指针,所以没办法存放虚表
❓构造函数可以是虚函数吗?
✅不可以,因为对象中的虚表指针实在构造函数走初始化列表的时候才初始化的,所以构造函数没办法放进虚表
❓对象访问普通函数快还是虚函数更快?
✅如果不构成多态的话,是一样快,如果构成了多态,那就是普通函数更快,因为运行时虚函数需要到虚表中找到地址再调用
❓虚函数表是在什么阶段生成的,存在哪的?
✅虚函数表是在编译阶段就生成的,一般情况下存在代码段(常量区)的
❓析构函数被处理成destructor的必要性/是否推荐析构函数加上virtual关键字?
✅我们来看下面一段代码:
class person { public: virtual void BuyTicket() { cout << "买票-全价" << endl; } ~person() { cout << "~person()" << endl; } }; class student : public person { public: virtual void BuyTicket() { cout << "买票-半价" << endl; } ~student() { cout << "~student()" << endl; } }; void Test() { person* p1 = new person; person* p2 = new student; delete p1; delete p2; }
可以看到,在析构的时候,只调用了person的析构函数,这里如果student的析构函数需要释放资源,按照这种写法就会出现内存泄漏的情况,为了构成多态,让其能够根据指向的对象的实际类型决定调用基类还是派生类的析构函数,所以这里需要将所有的析构函数处理成相同函数名(析构函数没有返回值和参数)。