C++ 多态
C++ 多态
C++ 多态(polymorphism)是面向对象编程中的一个重要特性,它允许你使用基类指针或引用来调用派生类中重写的函数。多态提供了代码的灵活性和可扩展性,使得你可以在不修改现有代码的情况下添加新的功能或行为。
多态的定义及实现
什么是虚函数?
被 virtual 修饰的类成员函数称为虚函数。
多态的构成条件
在继承中要构成多态还有两个条件:
- 必须通过基类的指针或者引用去调用虚函数。
- 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写。
实例:
虚函数的重写
上面例子中,我们实现了虚函数的重写,也叫虚函数的覆盖。
派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的返回值类型、函数名字、参数列表完成相同),称子类的虚函数重写了基类的虚函数。
注意:在重写基类虚函数时,派生类的虚函数在不加virtual关键字时,虽然也可以构成重写(因为继承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性),但是该种写法不是很规范,不建议这样使用。
虚函数重写的两个例外
协变
派生类重写基类虚函数时,与基类虚函数返回值类型可以不同。 即基类虚函数返回值基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。
实例:
class Person
{
public:
virtual Person* BuyTicket()
{
cout << "买票-全价" << endl;
return this;
}
};
class Student : public Person
{
public:
virtual Student* BuyTicket()
{
cout << "买票-半价" << endl;
return this;
}
};
析构函数的重写
如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加 virtual 关键字,即使函数名不相同,都与基类的析构函数构成重写,因为编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成 destructor。
实例:
class Person
{
public:
virtual ~Person()
{
cout << "~Person()" << endl;
}
};
class Student : public Person
{
public:
virtual ~Student()
{
cout << "~Student()" << endl;
}
};
从下图中可以看出,Student 对象被正常析构。
构造函数的构造顺序:基类->派生类。
析构函数的构造顺序:派生类->基类。
如果我们把 virtual 关键字去掉,再运行程序,结果如下:
这里编译器隐式地将 ~Person() 变为 this->destructor(),将~Student() 变为 this->destructor(),因此调用的时候只看自身的类型,是Person就调用Person的函数,是Student就调用Student的函数,根本不构成多态。这里 Student() 只调用了基类 Person 的析构函数,发生了内存泄漏,这是非常危险的行为。
C++11 关键字:override 和 final
C++对函数重写的要求比较严格,但是有些情况下由于疏忽无法构成重载。这种错误在编译期间是不会报出的,只有在程序运行时没有得到预期结果才能发现。因此,C++11提供了 override 和 final 两个关键字,可以帮助用户检测是否重写。
final
final 用于修饰虚函数,表示该虚函数不能再被重写。
实例:
override
override 用于检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错。
实例:
重载、重写(覆盖)、重定义(隐藏)的对比
抽象类
纯虚函数
在虚函数的后面写上 = 0 ,则这个函数为纯虚函数。
实例:
virtual void BuyTicket() = 0;
纯虚函数只是一个定义,没有具体的实现。
抽象类的定义
包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写所有的纯虚函数,派生类才能实例化出对象。纯虚函数规范了派生类必须重写,另外,纯虚函数更体现出了接口继承。
实例:
实现继承和接口继承
- 普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实现。
- 虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口。
所以如果不实现多态,不要把函数定义成虚函数。
多态的原理
虚函数表
我们先来看一个实例的运行结果:
通过运行与观察监视窗口,我们发现 p 对象是 16 Bytes,除了 age 成员,还多了一个_vfptr(_vptr)放在对象的前面(注意有些平台可能会放到对象的最后面,这个跟平台有关),对象中的这个指针我们叫做虚函数表指针(v代表virtual,f代表function)。
_vfptr 是一个指针,存放虚函数表的地址。我们可以通过 _vfptr 找到虚函数表的开头。
一个含有虚函数的类中都至少都有一个虚函数表指针,因为虚函数的地址要被放到虚函数表中,虚函数表也简称虚表。
那么派生类中这个表又放了些什么呢?我们看看下面这个代码:
#include <iostream>
#include <stdlib.h>
using namespace std;
class Base
{
private:
int _b = 1;
public:
virtual void Func1()
{
cout << "Base::Func1()" << endl;
}
virtual void Func2()
{
cout << "Base::Func2()" << endl;
}
void Func3()
{
cout << "Base::Func3()" << endl;
}
};
class Derive : public Base
{
private:
int _d = 2;
public:
virtual void Func1()
{
cout << "Derive::Func1()" << endl;
}
};
int main()
{
Base b;
Derive d;
cout << "Done" << endl;
system("pause");
return 0;
}
通过监视窗口,我们可以发现一下几点问题:
- 虚函数会放到虚函数表中,普通函数不会,并且表里面的内容是一个数组,是函数指针数组。
- 虚函数表的本质是一个存虚函数指针的指针数组,一般情况这个数组的大小是虚函数个数+1,最后面放了一个nullptr。
- 基类b对象和派生类d对象虚表是不一样的,这里我们发现Func1完成了重写,所以d的虚表中存的是重写的Derive::Func1,Base::Func1和Derive::Func1的地址不同。所以虚函数的重写也叫作覆盖,覆盖就是指虚表中虚函数的覆盖。重写是语法的叫法,覆盖是原理层的叫法。
- 派生类把虚函数Func2也继承下来,所以放进了虚表。基类和派生类的Func2地址是相同的。
- Func3也继承下来了,但是不是虚函数,所以不会放在虚表里。
总结一下,一个派生类的虚表的生成步骤:
- 先将基类中的虚表内容拷贝一份到派生类虚表中。
- 如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数。
- 派生类自己新增的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。
虚函数存在虚表中,虚表存在对象中,这是错误的理解。 虚表存的是虚函数指针,不是虚函数,虚函数和普通函数一样的,都是存在代码段中,只是他的指针存到了虚表中。而对象中存的也不是虚表,存的是虚表指针,而虚表也是存在代码段中的。
引用和指针如何实现多态?
为什么多态可以实现指向父类调用父类函数 ,指向子类调用子类函数?
步骤:
- 传递类对象指针
- 通过 _vfptr 找到虚函数表的地址
- 在虚函数表中找到要调用的虚函数
- 有了虚函数的地址,就能去 call 这个虚函数
注意,只有引用和指针才能触发多态。
如果是普通类,那么会调用基类的拷贝构造函数,但拷贝成员,不拷贝虚函数表指针,所以不实现多态。
动态绑定与静态绑定
-
静态绑定:又称为前期绑定,早绑定。在程序编译期间确定了程序的行为,也称为静态多态,比如:函数重载。
-
动态绑定:又称后期绑定,晚绑定。在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态。
单继承和多继承关系的虚函数表
单继承中的虚函数表
思路:
- 先取b、d对象的地址,强转成一个int*的指针。
- 再解引用取值,就取到了b、d对象头4bytes的值,这个值就是指向虚表的指针。
- 再强转成VFPTR*,因为虚表就是一个存VFPTR类型(虚函数指针类型)的数组。
- 虚表指针传递给PrintVTable进行打印虚表
测试代码:
#include <iostream>
#include <stdlib.h>
using namespace std;
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 d;
};
typedef void(*VFPTR) ();
void PrintVTable(VFPTR vTable[])
{
// 依次取虚表中的虚函数指针打印并调用。调用就可以看出存的是哪个函数
cout << "虚表地址 > " << vTable << endl;
for (int i = 0; vTable[i]; i++)
{
printf("第%d个虚函数地址 :%p -> ", i, vTable[i]);
VFPTR f = vTable[i];
f();
}
cout << endl;
}
int main()
{
Base b;
Derive d;
PrintVTable((VFPTR*)(*(int*)&b));
PrintVTable((VFPTR*)(*(int*)&d));
system("pause");
return 0;
}
运行结果:
多继承中的虚函数表
测试代码:
#include <iostream>
#include <stdlib.h>
using namespace std;
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 d;
};
typedef void(*VFPTR) ();
void PrintVTable(VFPTR vTable[])
{
cout << "虚表地址>" << vTable << endl;
for (int i = 0; vTable[i]; i++)
{
printf("第%d个虚函数地址 : %p -> ", i, vTable[i]);
VFPTR f = vTable[i];
f();
}
cout << endl;
}
int main()
{
Derive d;
VFPTR* vTableb1 = (VFPTR*)(*(int*)&d);
PrintVTable(vTableb1);
VFPTR* vTableb2 = (VFPTR*)(*(int*)((char*)&d + sizeof(Base1)));
PrintVTable(vTableb2);
system("pause");
return 0;
}
基类和派生类的继承关系:
运行结果:
观察上图可以看出:多继承派生类的未重写的虚函数放在第一个继承基类部分的虚函数表中。
结尾
看了这篇文章后,是否觉得自己已经学会了多态,来做道题吧!
请回答下面程序的运行结果是什么?
class A
{
public:
virtual void func(int val = 1)
{
cout << "A->" << val << endl;
}
virtual void test()
{
func();
}
};
class B : public A
{
virtual void func(int val = 0)
{
cout << "B->" << val << endl;
}
};
int main()
{
B* pB = new B;
pB->test();
}
答案可能出乎你的意料:
首先,B类型的对象pB去调用test()。test()是B类继承下来的,但是里面默认存放的this指针依然是A*,将一个B类型的指针传给A类型的指针,会发生多态,B类里面的func()是重写了A类的func() 。
注意重写的关键点,仅仅是重写了A类的实现,而前面的那些声明,依然是调用的A类的声明,因此给到的val默认值是1,调用了B类的函数实现,输出B->1。