多态
如何理解多态
多态通俗点来说,就是多种形态,具体点来说就是不同的对象去完成同一个动作产生的不同的状态
举一个简单例子:在日常买票时,对于买票这个行为,普通人去买票是全价买票,学生去买票买到的是学生票。那么,对于同样一个买票的行为,不同的人去买票,结果却不相同,我们就可以说这可以体现多态的特性
C++中多态的条件
多态是不同继承关系的对象,去调用同一个函数,产生了不同的行为
class Person
{
public:
virtual void BuyTicket()
{
cout << "成人票" << endl;
}
};
class Student :public Person
{
public:
virtual void BuyTicket()
{
cout << "学生票" << endl;
}
};
void test(Person& p)
{
p.BuyTicket();
}
int main()
{
Person per;
test(per);
Student stu;
test(stu);
return 0;
}
通过以上代码我们可以看到,当我们给test()
函数传递Person
类的对象并调用BuyTicket
函数时,调用的是Person
类中的BuyTicket
函数,而当我们调用Student
类的对象并调用BuyTicket
函数时,调用的是Student
类中的函数
以上例子只是初步认识了以下C++中的多态。在继承中,构成多态用两个必不可少的条件
- 必须通过调用基类的指针或者是引用调用虚函数
- 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写
虚函数
虚函数就是指被virtual修饰的类成员函数
class Person
{
public:
virtual void BuyTicket()//虚函数
{
cout << "成人票" << endl;
}
};
虚函数的重写
在派生类中有一个和基类完全相同的虚函数(即派生类虚函数与基类虚函数的返回值,函数名称和参数列表都完全相同),这个时候就称派生类的虚函数重写了基类的虚函数
虚函数重写的两个例外
1、协变(基类与派生类虚函数返回值的类型不同)
派生类重写基类虚函数时,与基类虚函数的返回值类型不同,但是两个返回值类型之间构成"父子关系",并且两个返回值必须是指针类型或者是引用类型
class A
{};
class B :public A
{};
class Person
{
virtual A* func()
{
return new A;
}
};
class Student
{
virtual B* func()
{
return new B;
}
};
从以上代码中我们可以看到,虽然Person
类中的func()
函数和Student
类中的func()
函数返回值类型并不相同,但是这仍然构成重写,因为一个返回值类型为A*
,一个返回值类型为B*
,两个都是指针,并且这两个所指向的对象类型正好符合"父子关系",所以这里就构成重写
2、析构函数的重写(基类与派生类的析构函数名不同)
首先我们要知道以下两点内容
- 如果基类的虚构函数为虚函数,那么派生类的析构函数只要定义了,无论是否加上
virtual
关键字,都与基类的析构函数构成重写关系(这里可以理解为站在多态的角度上,重写是将基类的函数声明继承下来后,由派生类来重新实现这个函数,所以哪怕派生类重写的函数并没有加上virtual
关键字,由于继承的是父类的虚函数的声明,所以在子类中,这个函数仍然可以当成是虚函数看待) - 虽然基类与派生类的析构函数的名字并不相同(看起来并不符合重写的规则),但是实际上,这里可以理解为编译器对析构函数的名称做了特殊处理,编译后所有类的析构函数的名称统一被处理成
destructor
。
在知晓了以上之后,我们来看一个样例
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;
}
首先来分析一下以上代码,首先是通过Person*
类型的指针p1
去指向一个堆区开辟出来的Person
类对象,然后通过Person*
类型的指针p2
去指向一个堆区开辟出来的Student
类对象,最后去分别销毁这两个开辟出来的对象(其中包含了调用析构函数)
通过打印的结果我们可以看到,这里只调用两次了Person
的析构函数,但是并没有调用Student
的析构函数,那么这是什么原因呢?
要搞清楚为什么,我们还需要再深入了解普通调用和多态调用
普通调用:普通调用主要是看指针或引用或者是对象的类型
多态调用:多条调用主要是指针或引用指向的对象的类型
再知道以上两点之后,我们再来分析以下代码,由于代码中析构函数并没有添加virtual
关键字,所以在调用p1和p2的析构函数都是属于普通调用,因为普通调用看的是指针的类型,而指针的类型是Person*
,所以在调用时只会调用Person的析构函数。正确的做法是将析构函数加上virtual
关键字。
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;
}
再加上virtual关键字之后,通过Person*的指针去调用析构函数就符合多态调用的规则,指针p2看到自己实际指向的是一个Student类型的对象,就会去调用Student
的析构函数(为什么调用完Student
的析构函数之后又会调用Person
的析构函数,可以看这篇文章)。
C++11中两个关键字
final
final关键字有两个作用
- 修饰一个类,使该类不能被继承
- 修饰虚函数,表示该虚函数不能被重写
class Person final
{
public:
virtual ~Person()
{
cout << "~Person()" << endl;
}
};
class Student:public Person
{
public:
virtual ~Student()
{
cout << "~Student()" << endl;
}
};
overide
这个关键字的作用就是检查派生类虚函数是否重写了基类某个虚函数,如果没有重写则编译报错
class Person
{
virtual void func()
{
cout << "Person::func()" << endl;
}
};
class Studemt :public Person
{
virtual void test() override
{
cout << "Student::test()" << endl;
}
};
重载、重写(覆盖)、重定义(隐藏)的对比
重载
- 两个函数在同一个作用域
- 函数名/参数不同
重写
- 两个函数分别在基类和派生类的作用域
- 函数名/参数/返回值都必须相同(协变除外)
- 两个函数都必须是虚函数
重定义(隐藏)
- 两个函数分别在基类和派生类的作用域
- 函数名相同
- 两个基类和派生类的同名函数不构成重写就是重定义
抽象类
什么是抽象类
在虚函数的后面写上=0
,则这个函数为纯虚函数,包含纯虚函数的类就叫做抽象类(也叫做接口类),抽象类不能实例化出对象。派生类继承后也不能实例化出对象(派生类继承后,类中也有纯虚函数),只有重写纯虚函数,派生类才能实例化出对象
class Road
{
public:
virtual void limiting_velocity() = 0;
};
class expressway :public Road
{
public:
virtual void limiting_velocity()
{
cout << "最高限速120" << endl;
}
};
class Urban_road :public Road
{
public:
virtual void limiting_velocity()
{
cout << "最高限速50" << endl;
}
};
int main()
{
Road* p1 = new expressway;
p1->limiting_velocity();
Road* p2 = new Urban_road;
p2->limiting_velocity();
}
纯虚函数规范了派生类必须重写,另外纯虚函数更体现了接口继承
接口继承和实现继承
普通函数和继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实现,虚函数的继承是一种结构继承,派生类继承的是基类虚函数的接口(也可以理解为基类虚函数的声明),目的是为了重写,达成多态,继承的是接口,所以如果不实现多态,不要把函数定义成虚函数
多态的原理
虚函数表
首先我们来看一下,这个Base类的大小是多少(在VS2022下的x86环境下)
class Base
{
public:
virtual void func1()
{
cout << "Base::func1" << endl;
}
protected:
int _b = 1;
};
int main()
{
Base b;
cout<<sizeof(b)<<endl;
return 0;
}
实际运行结果是
如果是一般的类,这个大小应该是4字节,但是这里的结果是8字节,说明b对象的结构发生了改变。
通过监视窗口我们可以看到,b对象中不仅有一个成员对象_b
,还有个成员_vfptr
放在前面(注意有些平台可能会放到对象的后面,这个跟平台有关),对象中的这个指针叫做虚函数表指针(v代表virtual,f代表function),一个含有虚函数的类中都至少有一个虚函数表指针,因为虚函数的地址要被放到虚函数表中,虚函数表也简称虚表
为了更加深入的研究,我们再对以上代码做一些改变
class Base
{
public:
virtual void func1()
{
cout << "Base::func1" << endl;
}
virtual void func2()
{
cout << "Base::func2" << endl;
}
void func3()
{
cout << "func3()" << endl;
}
protected:
int _b = 1;
};
class Derive :public Base
{
public:
virtual void func1()
{
cout << "Derive::func1()" << endl;
}
private:
int _d = 2;
};
int main()
{
Base bb;
Derive dd;
return 0;
}
_vfptr
是一个虚函数指针,指向的是一个虚函数表,这个虚函数表的结构通过监视窗口可以很清晰地看出,虚函数表是一个函数指针数组,这个数组里面存的是类中虚函数的地址。
这里补充几点
- 派生类对象中也有一个虚表指针,对象dd中由两部分构成,一部分是父类继承下来的成员,虚表指针指向地虚函数表中也存在一部分是父类虚函数的地址,一部分是子类虚函数的地址
- 基类bb对象和派生类dd对象虚表是不一样的,比如这里func1函数发生了重写,所以dd对象地虚函数表中存的是重写后的
Derive::func1
的地址,所以虚函数的重写也叫做覆盖(将重写后虚函数的地址覆盖在虚函数表中原来虚函数的地址上面),重写是语法的叫法,覆盖是原理层的叫法 func1
是重写后的虚函数,func2
是继承下来的虚函数,它们都是虚函数,所以存在虚函数表中,虽然func3也被继承下来了,但是func3不是虚函数,所以不会被放到虚函数表中- 虚函数表本质是一个函数指针数组,一般在这个数组后面加上
nullptr
作为结尾(不同编译器不同) - 虚函数是存在
总结一下派生类中虚函数的生成:
- 先将基类中虚函数表中的内容拷贝一份到派生类虚函数表中
- 如果派生类中重写了基类中的某个虚函数,则会用派生类中重写的虚函数的地址覆盖原先虚函数表中的基类的虚函数的地址
- 派生类自己新增加的虚函数按照在派生类中声明的次序依次增加到派生类虚函数表的最后
普通调用和多态调用
普通调用时,编译器期间通过调用者的类型确定函数地址(在符号表去找)
多态调用时,在运行期间,去虚函数表中找函数的地址,并进行调用
class Person
{
public:
virtual void BuyTicket()
{
cout << "成人票" << endl;
}
};
class Student :public Person
{
public:
virtual void BuyTicket()
{
cout << "学生票" << endl;
}
};
void test(Person& p)
{
p.BuyTicket();
}
int main()
{
Person per;
test(per);
Student stu;
test(stu);
return 0;
}
回到最开始的代码,给test函数传递Person类型的对象和传递Student类型的对象显示的结果不同是因为,Student类中重写了BuyTicket这个函数,在Student类中的虚函数表里面,基类的BuyTicket地址已经被派生类的重写后的BuyTicket地址给覆盖了,然后传递给形参p时,p引用的是Student类型对象stu中父类的切片(关于这点可看这篇文章),由于里面存的虚函数表中的虚函数地址已经被重写后的函数地址覆盖过了,所以调用的是重写后的函数地址。
动态绑定与静态绑定
- 静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态(比如:函数重载)
- 动态绑定又称后期绑定(晚绑定),是程序在运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也成为动态多态
单继承和多继承关系的虚函数表
单继承中的虚函数表
class Base
{
public:
virtual void func1()
{
cout << "Base::func1" << endl;
}
virtual void func2()
{
cout << "Base::func2()" << endl;
}
private:
int _a = 0;
};
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 = 1;
};
int main()
{
Base bb;
Derive dd;
return 0;
}
通过监视窗口,我们只能看到func1
和func2
,派生类自己形成的虚函数func3
和func4
却看不到。这里时编译器的监视窗口故意隐藏了这两个函数。
下面我们可以通过打印的方式打印出虚函数表中的函数
class Base
{
public:
virtual void func1()
{
cout << "Base::func1()" << endl;
}
virtual void func2()
{
cout << "Base::func2()" << endl;
}
private:
int _a = 0;
};
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 = 1;
};
typedef void (*VFPTR)();//typedef 函数指针 VFPTR
void printVFTable(VFPTR Vtable[])
{
for (int i = 0; Vtable[i] != nullptr; i++)
{
printf("【%d】:%p ->", i, Vtable[i]);
VFPTR f = Vtable[i];
f();
}
}
int main()
{
Base bb;
Derive dd;
printf("Base类型的虚函数表\n");
printVFTable((VFPTR*)(*(int*)&bb));
printf("\nDerive类型的虚函数表\n");
printVFTable((VFPTR*)(*(int*)&dd));
return 0;
}
对于以上代码解释几点
1.(VFPTR*)(*(int*)&bb)
,(int*)&bb
是取出bb对象的前4个字节(在32位机器下,地址的大小是4个字节,如果是64位的机器,地址的大小是8个字节,那么就需要将&bb
强转为long long*
类型的;因为在大部分编译器中,虚函数表的地址被放在在对象位置的最前面),然后对(int*)&bb
进行解引用,拿到虚函数表的地址(此时的地址类型为iint),传参时因为形参类型是VFPTR*
类型,所以,要将该地址类型强转为VFPTR*
2. 在大多数编译器中,虚函数表的最后会放nullptr
作为结束,所以这里利用了这一点作为循环的终止
3. 在C/C++,中,函数名和函数地址是等价的,所以f()
和(*f)()
等价
最终运行结果如下:
多继承中的虚函数表
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;
};
int main()
{
Derive dd;
printVFTable((VFPTR*)*(int*)&dd);
cout << endl;
printVFTable((VFPTR*)*(int*)((char*)&dd + sizeof(Base1)));
return 0;
}
通过监视窗口可和运行结果可以看到
- 当派生类继承了两个基类后,派生类对象中有两张虚表
- 派生类新增的虚函数会放到第一张虚函数表(虚表)中
- 当派生类重写了两个基类的虚函数后,派生类重写的虚函数地址会覆盖两张虚表
菱形继承中的多态
class A
{
public:
virtual void func1() { }
protected:
int _a;
};
class B:public A
{
public:
virtual void func1() { }
private:
int _b;
};
class C:public A
{
public:
virtual void func1() {}
private:
int _c;
};
class D :public B, public C
{
public:
virtual void func1() {}
private:
int _d;
};
int main()
{
D dd;
return 0;
}
普通的菱形继承就当成是多继承来理解即可
菱形虚拟继承中的多态
class A
{
public:
virtual void func1() { }
protected:
int _a;
};
class B:virtual public A
{
public:
virtual void func1() { }
private:
int _b;
};
class C:virtual public A
{
public:
virtual void func1() {}
private:
int _c;
};
class D :public B, public C
{
private:
int _d;
};
int main()
{
D dd;
return 0;
}
运行上面代码,会出现报错
这是因为A中有一个虚函数func1,B和C都重写了这个虚拟函数,而D类型对象中只有一个”A“,那么A中虚函数表中的func1()的地址是填B重写的还是填C重写?这就会造成歧义,存在二义性的问题
解决方法:D中也将func1函数重写,这样D重写的func1虚函数地址就会覆盖虚函数表中func1的地址
d
因为B、C都共享一个A,若B、C又有自己的虚函数,B、C中各自又会有一份自己的虚函数表指针,就不会放到A里面去了(也就意味着B、C里面不仅会有自己的成员变量,还会有一个虚函数表指针和一个虚基表指针)