C++---多态
Posted Moua
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了C++---多态相关的知识,希望对你有一定的参考价值。
目录
一、多态的概念
1、什么是多态?
通俗的说,多态就是一个事务的不同形态。即不同的对象完成某个任务的状态。例如,买票时学生票半价,军人优先买票,其他人正常买票。
实际上,多态就是在不同继承关系的类对象,去调用同一函数,产生了不同的行为。
2、多态的实现
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() //重写了Person类中的虚函数
{
cout << "买票-半价" << endl;
}
/*void BuyTicket()
{
cout << "买票-半价" << endl;
}*/
};
void Func(Person& p)
{
p.BuyTicket();
}
int main()
{
Person ps;
Student st;
Func(ps);
Func(st);
return 0;
}
注意:由于派生类继承了基类,因此即使派生类的虚函数不使用virtual关键字也可以和基类中的虚函数构成重写,但是这样写会降低代码的可读性。
3)函数重写的两个例外
- 协变:基类和派生类虚函数的返回值不同,即基类返回基类的对象指针,派生类返回派生类对象指针。
class Person
{
public:
virtual A* f()
{
return new A;
}
};
class Student : public Person
{
public:
virtual B* f()
{
return new B;
}
};
- 析构函数的重写:当基类析构函数定义成虚函数时,无论派生类是否定义为虚函数,基类和派生类的析构函数都构成重写。因为,编译器对析构函数的函数名进行了统一处理,所有析构函数的函数名都为destructor。、
class Person
{
public:
virtual ~Person()
{
cout << "~Person()" << endl;
}
};
class Student : public Person
{
public:
virtual ~Student()
{
cout << "~Student()" << endl;
}
};
// 只有派生类Student的析构函数重写了Person的析构函数,下面的delete对象调用析构函数,才能构成多态,才能保证p1和p2指向的对象正确的调用析构函数。
int main()
{
Person* p1 = new Person;
Person* p2 = new Student;
delete p1;
delete p2;
return 0;
}
- 为什么要支持析构函数的重写?
class A
{
public:
virtual ~A()
{
std::cout << "~A" << std::endl;
}
};
class B:public A
{
public:
virtual ~B()//和基类中的析构函数构成重写
{
std::cout << "~B" << std::endl;
}
};
int main()
{
A* a = new B;
delete a;//不仅会调用A的析构函数还会调用B的析构函数
//如果不构成重写,则只调用A的析构函数,造成B里的部分内容不会被释放,造成内存泄露
return 0;
}
4)多态的构成条件
- 必须通过基类的指针或者引用调用虚函数
- 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写
也就是说,一旦只要满足上面两个条件一定构成多态,构成多态也必须满足上面两个条件。构成多态后,派生类调用的是派生类的虚函数,基类调用的是积累的虚函数。最终实现,不同对象产生不同的结果。
5)c++11 override和final
override和final最重要的作用就是检查是否重写,override针对的是子类,final针对的是父类。
final:使用final修饰的函数不能被重写
class A
{
public:
virtual void func() final
{
std::cout << "A::func" << std::endl;
}
};
class B : public A
{
public:
void func()//报错,基类中的func函数为final函数,不可以被重写
{
std::cout << "B::func" << std::endl;
}
};
override:检查派生类是否重写了某个虚函数,如果没有编译器会报错
class A
{
public:
virtual void func()
{
std::cout << "A::func" << std::endl;
}
};
class B :public A
{
public:
virtual void func() override //重写了父类的override函数
{
std::cout << "B::func" << std::endl;
}
virtual void error() override //父类中找不到相同的函数,因此不构成重写,使用override会报错
{
std::cout << "B::erroe" << std::endl;
}
};
6)函数重载、重写、重定义的比较
注意:构造函数不能定义成虚函数,如果说构造函数可以定义成虚函数构成多态,创建子类对象就只调用子类的构造函数,父类的那部分怎么办?
二、抽象类
1、纯虚函数
纯虚函数是一种特殊的虚函数,在许多情况下,在基类中不能对虚函数给出有意义的实现,而把它声明为纯虚函数,它的实现留给该基类的派生类去做。这就是纯虚函数的作用。。C++中的纯虚函数,一般在函数签名后使用=0作为此类函数的标志。
virtual void func() = 0;//声明纯虚函数
2、什么是抽象类?
包含纯虚函数的类称为抽象类。
class Car
{
public:
virtual void func() = 0;//这是一个纯虚函数,Car这个类是一个抽象类
};
3、抽象类的特点是什么?为什么要存在抽象类?
- 抽象类不能实例化出对象
- 派生类如果不对基类的纯虚函数重写,派生类也不能实例化出对象。
- 纯虚函数规范了派生类“必须”重写,另外纯虚函数更体现出了接口继承。这里的必须不是一定,只是如果派生类不重写派生类也就不能实例化出对象,那么这个派生类的作用也就不大。
class Car
{
public:
virtual void Drive() = 0;
};
class Benz :public Car
{
public:
virtual void Drive()
{
cout << "Benz-舒适" << endl;
}
};
class BMW :public Car
{
public:
virtual void Drive()
{
cout << "BMW-操控" << endl;
}
};
现实中我们并不需要实现一个汽车对象,这个范围太广了没办法实现,我们要实现的是具体的某一个品牌的汽车。因此,我们就可以把汽车类定义成抽象类,让派生类对抽象类中的纯虚函数重写。
4、接口继承和实现继承
普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实现。虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口。所以如果不实现多态,不要把函数定义成虚函数。
分析下面程序,判断程序的运行结果:
class A
{
public:
virtual void func(int val = 1)
{
std::cout<<"A->"<< val <<std::endl;
}
virtual void test()
{
func();
}
};
class B : public A
{
public:
void func(int val=0)
{
std::cout<<"B->"<< val <<std::endl;
}
};
int main(int argc ,char* argv[])
{
B*p = new B;
p->test();
return 0;
}
- 首先基类A中的func函数和派生类B中的func函数构成了重写。test函数的唯一一个参数是A* this,而通过B类对象调用test是传的是B*,因此构成多态。也就是说,p->test()最终调用了B中的func函数。
- 虚函数的继承是一种接口继承,也就是说B类中的func函数只是对A类中func函数的virtual void func(int val = 1)进行了继承,具体的实现是std::cout<<"B->"<< val <<std::endl; 。也就是说在编译器眼中,看到的B类中的func函数是这样的
virtual void func(int val=1)
{
std::cout<<"B->"<< val <<std::endl;
}
-
结论:输出结果为B->1
三、多态的原理
1、虚函数表
1)虚函数表相关概念
分析下面程序,判断下列程序在64位下的运行结果:
class Base
{
public:
virtual void Func1()
{
cout << "Func1()" << endl;
}
private:
int _b = 1;
};
void main()
{
Base b;
std::cout<<sizeof(b)<<std::endl;//4?
}
- 按照以前的说法,类实例化的对象只是保存了类中的成员变量,成员函数保存在代码段中,所有对象公用一份,这些代码段不属于任何一个对象。
- 当一个类中存在虚函数时,虚函数是保存在虚函数表中的,每个对象都会保存一个虚函数表指针访问该虚函数(为什么要这么做后边再说)。
- 也就是说,使用这个类实例化的对象,除了有一个int类型的成员变量,指向该虚函数表的指针。因此,根据结构体内存对齐规则,64位程序下该程序的运行结果为8.
概念总结:
- 虚函数表:当一个类中存在虚函数时,这些虚函数的地址会被保存在一个函数指针数组中,这个数组就叫做虚函数表。
- 虚函数表指针:使用该类实例化对象时,对象中会包含一个指针,这个指针保存的是虚函数表的地址。保存虚函数表地址的指针就称为虚函数表指针,实例化的对象通过虚函数表指针找到虚函数表在在虚函数表中找到虚函数的地址,从而找到具体的虚函数。
2)派生类的虚函数表
- 定义一个Derive类,该类继承了Base类并重写了Base类中的func1函数。
- Base中增加一个虚函数func2,Derive函数中增加一个虚函数func3。
class Base
{
public:
virtual void func1()
{
std::cout << "Base::func1()" << std::endl;
}
virtual void func2()
{
std::cout << "Base::func2" << std::endl;
}
private:
int _b = 1;
};
class Derive : public Base
{
public:
virtual void func1()
{
std::cout << "Derive::func1" << std::endl;
}
virtual void func3()
{
std::cout << "Base::func3" << std::endl;
}
};
- d中也存在一个虚表指针,这个虚表指针指向的虚函数表中包含了Derive中的虚函数和Base中继承下来的虚函数。
- 基类和派生类的虚表不是同一个虚表,这里Derive中的func1重写了基类中的func1函数,因此虚表中将基类的func1使用派生类中的func1进行覆盖。
- 虚函数表保存的是虚函数的地址,他本质是一个函数指针数组,一般的数组的最后一个元素为nullptr类型。
总结:派生类的虚函数表会先将基类的虚函数表复制一份,如果派生类对基类的某些虚函数进行了重写则对重写的虚函数进行覆盖,同时还会按照派生类中虚函数的声明顺序加入派生类自己的虚函数在虚函数表的最后。
虚函数存在内存中的什么位置?虚函数表呢?
- 虚函数表中保存的是虚函数的地址,并不是虚函数,因此虚函数肯定不在虚函数表中。虚函数和普通的函数一样保存在代码段中。
- 使用同一个类实例化出来的两个对象,它们的虚函数表是否相同?答案是相同
- 既然同一个类的所有对象使用同一份虚函数表,因此虚函数表不可能存在栈区中;堆区中的内存都是使用malloc或new开辟的,因此虚函数表也不可能存在堆区中。
- 那么虚函数表到底存在静态区还是代码段中呢?使用下面程序来验证以下:
void main()
{
Derive d1;
Derive d2;
//栈区
int a = 10;//局部变量存在栈区中
printf("栈区:%p\\n",&a);
//堆区
int* b = new int(20);
printf("堆区:%p\\n",b);
//常量区(代码段)
char* str = "hello";//str存在栈中,但是*str在常量区中
printf("常量区:%p\\n",str);
//静态区
static int c = 10;
printf("静态区:%p\\n",&c);
//虚函数表---如何得到虚函数表地址
printf("虚函数表:%p\\n",*((int*)(&d2)));
}
-
根据程序的输出结果,我们可以看出虚函数表的地址和常量区的地址最接近,因此就可以证明虚函数表存在代码段中(这里不是很准确,但虚函数表确实保存在常量区中)
2、多态原理
继续以下面这段程序为例,来探究多态的原理。
class Person {
public:
virtual void BuyTicket()
{
cout << "买票-全价" << endl;
}
};
class Student : public Person {
public:
virtual void BuyTicket() //重写了Person类中的虚函数
{
cout << "买票-半价" << endl;
}
};
void Func(Person& p)
{
p.BuyTicket();
}
int main()
{
Person p;
Student s;
Func(p);
Func(s);
return 0;
}
- 通过虚函数表,我们知道p对象和s对象都含有一个虚函数表指针,并且s中的虚函数表指针指向的虚函数表中对p中的虚函数表的BuyTicket函数进行了覆盖。
- 当我们使用父类对象调用虚函数时,查找的父类的虚函数表找到的是父类的func函数。
- 当我们使用子类对象调用虚函数时,查找的是子类的虚函数表,子类的虚函数表对父类的虚函数表中的func函数进行了覆盖,因此调用的是子类的func函数。
3、动态绑定和静态绑定
- 动态绑定:静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态,比如:函数重载
- 静态绑定:动态绑定又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态。
思考:为什么构成多态必须是虚函数重写和父类的指针或引用调用?
- 只有对虚函数进行重写,才能通过各自的虚函数表找到各自的虚函数。
- 之所以是父类的对象或者引用,首先父类对象不能赋值为子类对象,因此不可能使用子类对象指针或引用。
- 如果直接使用父类对象,采用的是静态绑定,在编译时就指定了调用的函数的地址(父类中虚函数的地址)。因此,必须使用指针或引用进行动态绑定。下面是VS下查看的汇编代码:
//构成多态,动态绑定
void Func(Person* p)
{
...
p->BuyTicket();
// p中存的是mike对象的指针,将p移动到eax中
001940DE mov eax,dword ptr [p]
// [eax]就是取eax值指向的内容,这里相当于把mike对象头4个字节(虚表指针)移动到了edx
001940E1 mov edx,dword ptr [eax]
// [edx]就是取edx值指向的内容,这里相当于把虚表中的头4字节存的虚函数指针移动到了eax
00B823EE mov eax,dword ptr [edx]
// call eax中存虚函数的指针。这里可以看出满足多态的调用,不是在编译时确定的,是运行起来以后到对象的中取找的。
001940EA call eax
00头1940EC cmp esi,esp
}
int main()
{
...
// 首先BuyTicket虽然是虚函数,但是mike是对象,不满足多态的条件,所以这里是普通函数的调用转换成地址时,是在编译时已经从符号表确认了函数的地址,直接call 地址
mike.BuyTicket();
00195182 lea ecx,[mike]
00195185 call Person::BuyTicket (01914F6h)
...
}
//使用父类对象,不构成多态,静态绑定
004E4628 push ecx
004E4629 mov ecx,esp
004E462B lea eax,[p]
004E462E push eax
004E462F call Person::Person (04E1582h)
004E4634 call Func (04E1587h) //直接调用
004E4639 add esp,4
四、单继承和多继承关系的虚函数表
无论在单继承还是多继承中,基类的继承表和普通类的继承表是一样的,因此这里我们只研究单继承和多继承中派生类的虚函数表。
1、单继承中的虚函数表
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;
};
这里我们无法通过监视窗口查看到真实的派生类的虚函数表,我们只能通过内存或者直接打印派生类的虚函数地址来分析派生类中的虚函数表。
- 通过内存窗口查看虚函数表
- 打印虚函数地址
typedef void(*funP)();
int main()
{
Base b;
Derive d;
//虚函数表中存的是函数指针,虚函数表的类型是函数指针数组
//void(*VIRTUAL)()---函数指针
//typedef void(*funP)()
//函数指针数组:funP array[]
//要打印虚函数表,就得找到虚函数表的地址,也就是函数指针数组的首元素地址
funP* VFTPTR = (funP*)(*((int*)&b));
for (int i = 0; VFTPTR[i] != nullptr; i++)
{
printf("第%d个虚函数地址:%p\\n", i, VFTPTR[i]);
}
printf("\\n\\n");
VFTPTR = (funP*)(*((int*)&d));
for (int i = 0; VFTPTR[i] != nullptr; i++)
{
printf("第%d个虚函数地址:%p\\n", i, VFTPTR[i]);
}
return 0;
}
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(*funP)();
int main()
{
Derive d;
funP* VFTPTR = (funP*)(*((int*)&d));
for (int i = 0; VFTPTR[i] != nullptr; i++)
{
printf("第%d个虚函数地址:%p\\n", i, VFTPTR[i]);
}
printf("\\n\\n");
VFTPTR = (funP*)(*((int*)((char*)&d + sizeof(Base1))));
for (int i = 0; VFTPTR[i] != nullptr; i++)
{
printf("第%d个虚函数地址:%p\\n", i, VFTPTR[i]);
}
return 0;
}
运行上面代码我们可以发现,在多继承中,派生类中存在多个虚函数表。第一个虚函数表中保存了第一个基类的虚函数和派生类的虚函数,其他虚函数表保存了各自的虚函数(如果有重写则保存重写后的虚函数)。
五、常见面试题
- 下面程序输出结果是什么? (A)
#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
派生类的构造函数会先自动的调用基类的构造函数,在执行自己的构造函数。因此,A最先被调用;由于D先调用的B所以第二个调用B的构造函数在调用C的构造函数;最后调用D的构造函数。
注意:初始化列表中显式调用积累的构造函数实际上并没有调用。
- 多继承中指针偏移问题?下面说法正确的是( C )
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
问答题:
- 什么是多态?
计算机运行程序时,相同的消息可能会送给多个不同的类别之对象,而系统可依据对象所属类别,引发对应类别的方法,而有不同的行为。简单来说,所谓多态意指相同的消息给予不同的对象会引发不同的动作。在C++中,当派生类中的某一个函数和基类的一个虚函数完全相同(函数名,参数列表,返回值均相同)时,派生类就对基类的虚函数进行了重写,当使用基类的指针或引用调用虚函数时,父类调用父类的虚函数子类调用子类的虚函数,这种情况就称为多态。
多态有两个例外:协变(基类返回基类对象,派生类返回派生类对象)和析构函数的重写
- 什么是重载、重写(覆盖)、重定义(隐藏)
函数重载:在同一作用域中,函数名相同,参数列表不同(参数个数,类型)的两个或两个以上的函数称为函数重载。
函数重写:派生类拥有和基类相同的虚函数(函数名,参数列表,返回值均相同),则派生类虚函数对基类虚函数进行了重写,也成为覆盖。
函数重定义:在继承关系中,派生类拥有和基类函数名相同的函数,且不构成重写。派生类的函数对基类的同名函数进行了覆盖,派生类对象默认访问的是派生类的函数,需要访问基类的同名函数时需要指定作用域。
- 多态的实现原理?
多态是通过虚函数重写来实现的。类中的虚函数都保存在虚函数表中,通过一个虚函数表指针访问虚函数。在继承关系中,派生类对基类的虚函数进行重写,在派生类的虚函数表中保存的是重写后的虚函数和从基类继承的虚函数(被重写的不在继承)以及自己的虚函数。派生类对象调用虚函数会通过派生类的虚函数表指针查看派生类的虚函数表,找到的是重写后的虚函数;基类对象调用时,会通过基类的虚函数指针查看基类的虚函数表找到基类的虚函数。
- 多态为什么要是派生类对基类的虚函数重写?为什么一定是基类的对象指针或引用调用虚函数?
只有派生类对基类的虚函数重写,在能在派生类和基类的虚函数表中存在两个不同的虚函数,从而实现不同对象调用不同的虚函数。
使用基类对象的指针和引用是因为存在动态绑定和静态绑定问题,如果直接使用基类对象则在编译时就指定了调用的是基类的函数,无法实现多态。只有是指针或引用时,采用的是动态绑定,程序运行时才决定具体的函数地址,从而实现积累对象调用基类虚函数派生类对象调用派生类的对象。
- inline函数可以是虚函数吗?
本质上说是不可以的,因为inline函数编译器会在调用的地方展开,没有函数地址无法存入虚函数表中。但是,很多编译器却支持inline函数是虚函数,只是该函数不在具有inline属性。
- 静态成员可以是虚函数吗?
不能,因为静态成员函数没有this指针,使用类型::成员函数的调用方式无法访问虚函数表,所以静态成员函数无法放进虚函数表。
- 构造函数可以是虚函数吗?
不能,因为对象中的虚函数表指针是在构造函数初始化列表阶段才初始化的。
- 析构函数可以是虚函数吗?什么场景下析构函数是虚函数?
可以,并且最好把基类的析构函数定义成虚函数。在某些情况下,构造函数不是虚函数会存在内存泄露。
- 对象访问普通函数快还是虚函数更快?
首先如果是普通对象,是一样快的。如果是指针对象或者是引用对象,则调用的普通函数快,因为构成多态,运行时调用虚函数需要到虚函数表中去查找。
- 虚函数表是在什么阶段生成的,存在哪的?
虚函数表是在编译阶段就生成的,一般情况下存在代码段(常量区)的。
- C++菱形继承的问题?虚继承的原理?
菱形继承是指,在继承关系中,一个派生类继承了多个基类而这多个基类又继承了同一个基类,称为菱形继承。菱形继承存在数据冗余和二义性问题。
菱形虚拟继承解决了菱形继承的数据冗余和二义性问题。在菱形虚拟继承中,存在一个虚表指针,该虚表指针指向一个虚基表,虚基表保存的是该对象到基类成员的偏移量。
- 什么是抽象类?抽象类的作用?
C++中将拥有纯虚函数的类称为抽象类,抽象类不能实例化出具体的对象。
抽象类强制重写了虚函数,另外抽象类体现出了接口继承关
以上是关于C++---多态的主要内容,如果未能解决你的问题,请参考以下文章