文章目录
- 多态的定义
- 多态的实现
- 1.多态实现的两个必要条件
- 2.什么是虚函数
- 3.重写的条件
- 4.多态实现代码
- 5.重写的两个例外
- C++11引入的final和override关键字
- 重载,重写(覆盖),隐藏(重定义)
- 抽象类
- 接口继承和实现继承
- 多态的原理
- 1.虚函数表
- 2.多态的实现原理
- 关于动态绑定与静态绑定
- 单继承和多继承时的虚函数表
- 单继承的虚函数表
- 多继承的虚函数表
- 例题考察
- inline函数可以是虚函数
- 静态成员函数不可以是虚函数
- 构造函数不可以是虚函数
- 析构函数作为虚函数的场景
C++面向对象三大特性之一,多态
多态是什么,直接看就是多种形态。具体细讲就是不同对象在完成同一种行为的时候结果不同。
很常见的一种情况就是不同的对象,调用同一个函数,但是出现了不同的结果.
多态分为两种:
1.静态多态:函数重载(看起来调用的是一个函数但是传递不同的参数有不同的行为,cout的自动识别)
2.动态多态:一个父类对象的引用或者指针去调用一个函数,传递不同的对象会出现不同的行为
静态:是指在编译阶段实现
动态:是指在程序运行阶段实现
1.必须是基类的引用或者指针调用函数
2.被调用的函数必须是虚函数,并且派生类对其完成了重写。
被virtual修饰的函数就是虚函数
class Student
{
public:
virtual void func1()
{
cout << "Student::virtual void func1()" << endl;
}
};
这里的func1就是虚函数
静态成员函数不能成为虚函数
因为:因为静态的成员函数没有this指针,通过类型::函数名的方式无法访问到虚函数表,所以静态函数无法放入虚函数表,也就不能成为虚函数。
(为什么要通过this指针访问虚函数表,因为虚函数表的生成是在构造函数的初始化列表里面,需要通过对象的this指针找到虚函数并将地址写入虚函数表)
解释:静态成员函数与具体对象无关,属于整个类,核心关键是没有隐藏的this指针,可以通过类名::成员函数名 直接调用,此时没有this无法拿到虚表,就无法实现多态,因此不能设置为虚函数
重写就是派生类和基类中满足三同(函数名相同,参数相同,返回值相同)的虚函数,这就是说派生类函数完成了对基类函数的重写(又叫做覆盖)
class Person
{
public:
virtual void func1()
{
cout << "Person::virtual void func1()" << endl;
}
};
class Student : public Person
{
public:
virtual void func1()
{
cout << "Student::virtual void func1()" << endl;
}
};
这段代码派生类就完成了对于基类的重写。
满足了两个必要条件那么这时候派生类和基类的这个虚函数就是满足多态的条件了
class Person
{
public:
virtual void func1()
{
cout << "Person::virtual void func1()" << endl;
}
};
class Student : public Person
{
public:
virtual void func1()
{
cout << "Student::virtual void func1()" << endl;
}
};
void test1(Person& p)
{
p.func1();
}
int main()
{
Person p1;
Student s1;
test1(s1);
test1(p1);
return 0;
}
这里使用的是引用接受,然后引用调用,然后传递了不同的对象。
多态的核心就是,不同的对象,同一种行为,不同的结果。
这里的函数调用的分析主要是看满不满足多态的两大条件
构成多态,传递什么类的参数就调用的是什么类的完成重写的那个虚函数。——与对象有关
不构成多态,调用的是参数类的那个名字的函数。——与参数类型有关
1.协变(基类与派生类的虚函数返回值不同)
上面讲重写必须要求虚函数符合三同,但是协变情况下,返回值可以不同。返回值可以是父子关系的指针或者引用。可以是其他的继承类也可以是自己这里的父子关系的指针或者引用。
但是要注意:基类的虚函数必须返回基类的指针或者引用,派生类的虚函数的返回值可以是派生类的指针或者引用也可是基类的(基类的时候就相当于返回值相同了)。
class Person
{
public:
virtual Person* func1()
{
cout << "Person::virtual void func1()" << endl;
return nullptr;
}
};
class Student : public Person
{
public:
virtual Student* func1()
{
cout << "Student::virtual void func1()" << endl;
return nullptr;
}
};
2.析构函数的重写(基类和派生类的函数名不同)
基类的析构和派生类的析构也可以完成重写,只要两个都是虚函数即可。
class Person
{
public:
virtual Person* func1()
{
cout << "Person::virtual void func1()" << endl;
return nullptr;
}
virtual ~Person()
{
cout << "~Person" << endl;
}
};
class Student : public Person
{
public:
virtual Student* func1()
{
cout << "Student::virtual void func1()" << endl;
return nullptr;
}
virtual ~Student()
{
cout << "~Student" << endl;
}
};
void test2()
{
Person* pp = new Person;
Person* ps = new Student;
delete pp;
delete ps;
}
这里的析构函数在编译的时候都会被编译器替换成destructor(),因此满足了重写的条件。
编译器做的这个工作主要是为了上面代码的这种情况设计的。
new出来的基类对象和派生类对象都交给基类的指针来管理。这时候如果没有对基类的虚函数进行重写,那么delete的时候两次调用的都是基类的析构函数,那么子类对象就会有一部分没有进行析构资源清理,可能会造成内存泄漏。
其实这里的派生类里面进行重写的虚函数不用加virtual也是可以编写通过的。因为派生类在继承的时候先继承了基类的虚函数然后再完成了重写。所以派生类的这个函数也算是虚函数。
但是不推荐省略。
这种设计的初衷是为了方便进行代码分工,如果基类函数定义成立虚函数,那么派生类继承了基类之后就算忘记加virtual也是可以完成重写的。不至于因为派生类没有重写而造成某些错误(特别是派生类的析构函数要完成重写防止内存泄漏)
C++11为了规范多态的引用引入了final和override关键字,帮助检测是否重写。
final
final关键字的作用是添加在虚函数的后面使得这个虚函数不可被继承,不能被继承进而也就不可以被重写。
final还可以放在类名后面进行修饰使得这个类不可被继承
class Person
{
public:
virtual Person* func1() final
{
cout << "Person::virtual void func1()" << endl;
return nullptr;
}
virtual ~Person()
{
cout << "~Person" << endl;
}
};
class Student : public Person
{
public:
virtual Student* func1()
{
cout << "Student::virtual void func1()" << endl;
return nullptr;
}
virtual ~Student()
{
cout << "~Student" << endl;
}
};
override
override关键字的作用主要是放在派生类虚函数的后面检查该函数是否完成了对基类虚函数的重写,没有重写就会报错。
class Person
{
public:
virtual Person* func1()
{
cout << "Person::virtual void func1()" << endl;
return nullptr;
}
virtual ~Person()
{
cout << "~Person" << endl;
}
};
class Student : public Person
{
public:
virtual Student* func2() override
{
cout << "Student::virtual void func1()" << endl;
return nullptr;
}
virtual ~Student()
{
cout << "~Student" << endl;
}
};
如何让一个类不能被继承?
可以使用final使基类中的虚函数不可以被继承。
要想一个类不能被继承可以将构造函数设置成为私有,这样就算派生类继承了基类也调不动基类的构造函数无法实例化出对象。(间接限制)
直接限制就是,使用final修饰基类,这个基类就不可以被继承了。
重载条件:
1.必须是同一作用域
2.必须函数名相同,并且参数不同(类型不同或者个数不同或者顺序不同)
重写(覆盖)条件:
1.必须是两个虚函数
2.必须分别在基类和派生类的作用域
3.必须是函数名相同,参数相同,返回值相同(协变除外)
隐藏(重定义)的条件
1.必须是分别再基类和派生类的作用域
2.必须是函数名相同
两个基类和派生类的同名函数不是重写就是隐藏。
抽象的含义就是没有实体,或者说现实世界中不存在实物的东西。
一个类如果含有纯虚函数(在虚函数后面加上 = 0)那么这个类就是抽象类。抽象类不能实例化出对象,派生类继承类抽象类之后也不可以实例化出对象,只有派生类对抽象类的纯虚函数完成重写之后,派生类才可以实例化出对象。
抽象类可以强制子类完成重写虚函数,抽象类不能实例化出对象所以抽象类内部的函数不需要写出实现。因此派生类继承了抽象类相当于继承了抽象类的接口,但是没有实现,抽象类又被叫做接口类。体现了接口继承。
class A
{
public:
virtual void test() = 0;
};
class B : public A
{
public:
virtual void test()
{
cout << "class B : public A::virtual void test()" << endl;
}
int _b;
};
class C:public A
{
public:
virtual void test()
{
cout << "class C:public A::virtual void test()" << endl;
}
int _c;
};
int main()
{
A* pb = new B;
A* pc = new C;
pb->test();
pc->test();
return 0;
}
在派生类中对抽象类进行重写之后,派生类就可以正常的创建对象了。
接口继承就是普通函数的继承。对于普通函数在派生类中可以直接调用,相当于是将函数的实现继承了过来。虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口。所以如果不实现多态,不要把函数定义成虚函数。
根据多态的实现,我们在使用父类的引用或者指针调用函数的时候,根据父类的指针或者引用指向的对象不同调用的函数就不同,那么这种实现的底层原理是什么呢?
是因为在含有虚函数的类实例化出来的对象里面都会存在一个虚函数表指针,这个指针指向了一个虚函数表(是一个虚函数指针数组)简称是虚表,虚表里面存放的就是这个类里面的虚函数的地址,也包括继承下来的虚函数。
这段代码可以看到,a对象里面有一个成员变量_a,理论上说大小应该是4字节,但是算出来是8个字节也就是印证了对象里面还包含了一个指针变量(虚函数表指针)
这里可以看到a对象里面的第一个成员是一个__vfptr的指针,这个指针就是虚函数表指针。现在虚函数表内有一个虚函数,所以只有一个地址。虚函数表最后以nullptr结尾。
下面我们多添加几个虚函数再次观察
class A
{
public:
virtual void func1()
{
cout << "func()" << endl;
}
virtual void func2()
{
cout << "func2()" << endl;
}
private:
int _a;
};
class B : public A
{
public:
private:
int _a;
};
int main()
{
A a;
B b;
return 0;
}
所以b对象的虚函数表就是A类的虚函数表的拷贝
下面我们在B类中对于func进行重写,然后增加一个B类自己的虚函数func3再来看这个虚函数表的变化。
从这里我们可以看出,经过重写后,b对象的虚函数表内的第一个函数指针的内容已经变了。也即是重写后新的派生类的虚函数将原来的那个A类的虚函数覆盖掉了。这就是重写又叫覆盖的原因,重写是语法的叫法,覆盖是原理层的含义。
从监视窗口我们看不到func3出现在虚表中,是因为vs对于监视窗口进行了优化。这里我们可以通过内存窗口来观察func3的地址是不是真的在虚函数表内。
这个内存里面,在func1和func2地址下面的那个地址就是func3的地址。
虚函数表这里也解释了为什么多态的第一个条件,必须是基类的指针或者引用调用。因为子类的指针和引用穿过去会进行切片。保留基类的一部分包括子类的虚函数表。但是如果是传对象,那么就是用子类的里面的基类部分拷贝出来一个对象,此时要注意,拷贝出来的对象那个虚表是基类的虚表,不是子类的虚表。
这里的一个规则就是父类对象里面一定是父类的虚表,子类对象里面一定是子类的虚表。
试想如果父类对象经过子类对象的一次赋值,虚表就变成了子类的虚表那么后面你自己就会混了。
同类型的对象的虚表指针是一样的都是指向同一个虚表。
只有虚函数是会存在虚表里面的。但是他们的实际存储位置都是在代码端,只是虚函数将地址又存进了虚函数表内。用来实现多态。
这张图就很好的说明了多态的实现原理。传过去的分别是基类和派生类的对象的地址,派生类传过去发生了切片。所以虽然都是转换成了A*类型但是两次调用时候的虚函数表指针是不同的。去虚函数里面找func1的时候找到的函数也就不同,这就实现了多态。
对外看就是不同的对象去完成同一行为的时候出现了不同的形态
这时我们也可以理解为什么需要子类虚函数覆盖(重写)父类的虚函数。因为子类的虚表是继承父类的虚表。重写之后才可以将虚表内的函数改为子类自己的虚函数地址。
为什么要父类的指针或者引用来调用,因为不会发生拷贝构造,穿过的子类对象虽然发生了切片,但是虚函数表指针还是子类的虚函数表指针。由此才可以调用到子类的虚函数。
再通过下面的汇编代码分析,看出满足多态以后的函数调用,不是在编译时确定的,是运行起来以后到
对象的中去找的。不满足多态的函数调用时编译时确认好的。** **
可以看到前面的代码就是取出pa指向的对象的前四个字节的虚函数表指针,然后找到虚函数表,再找到func1,将func1的地址放到eax寄存器里面,最后通过call指令调用。但是其实这里的地址并不是func1函数真正的地址。call之后会跳到真正的func1函数的地址处。
可以看到vs这里对齐封装了一层。所以第一次call之后并不是直接跳到func1函数体处开始执行,而是跳到中间层然后再次jmp跳到真正的func1函数处开始执行。
那么为什么vs这里要做多一层的封装呢?实际是为了中间层做一些事情。
总结多态原理:
满足多态的条件调用函数的时候才回去虚函数表里面去找,不满足多态编译的时候就已经确定了调用函数的地址。
如果派生类重写基类的函数放在私有里面,那么满足多态的条件的时候也是可以调用的动的。因为可以通过虚函数表里面的虚函数地址来找到函数,直接调用(堪称流氓玩法,形象的例子就是,道德规范限制了你的某种行为但是法律没有明确说明,你依旧可以做出这个行为,只是不道德)
动态绑定和静态绑定其实就是动态多态和静态多态的实现原理方面。
动态绑定是指程序运行期间确定了程序的具体行为(比如调用那个函数)所以也称为动态多态
静态绑定是指再程序编译阶段确定了函数的具体行为,将调用的函数地址填入调用的地方。因此成为静态多态。比如函数重载。
这里主要关注的时派生类对象里面的虚函数表。因为基类的虚函数表已经明确了都有什么元素。
上面讲过vs编译器将虚函数表内的有些虚函数进行了隐藏,下面我们就打印出虚函数表来直接观察虚函数表内的函数地址。
那么如何打印虚函数表?
首先我们需要拿到派生类对象的前四个字节(也就是虚函数表指针)
class A
{
public:
virtual void func1()
{
cout << "class A : func1()" << endl;
}
virtual void func2()
{
cout << "class A : func2()" << endl;
}
private:
int _a;
};
class B : public A
{
public:
virtual void func1()
{
cout << "class B : public A :: func1()" << endl;
}
virtual void func3()
{
cout << "class B : public A :: func3()" << endl;
}
virtual void func4()
{
cout<<"class B : public A :: func4()" << endl;
}
private:
int _b;
};
/
typedef void(*_vfptr)();
void VfuncPrint(_vfptr vfunc[])
{
for (int i = 0; vfunc[i] != nullptr; i++)
{
printf("第%d个虚函数地址:%p\n", i + 1, vfunc[i]);
}
}
int main()
{
A a;
B b;
VfuncPrint((_vfptr*) *((void**)&a));
puts("");
VfuncPrint((_vfptr*)*((void**)&b));
}
这段代码可以打印出虚函数表内的虚函数地址。
首先如何取出对象的前四个字节的?
先取出对象的地址然后强转成为void**,当然也可以是其他的二级指针,比如int**,强转成二级指针的原因是二级指针里面存放的是一级指针的地址,一定是四个或者八字节,我们对象里面的前四个字节或者八个字节就是一个指针(指向函数指针数组的指针,又叫虚函数表指针)所以完全吻合,不论是32位机还是64位机。
对二级指针解引用刚好拿到前四个字节,也即是虚函数表指针。然后强转成指向函数指针的指针(一个二级指针)然后传参给Print函数,进行打印即可。
这里的强转也可以将a的地址转成int*,然后解引用拿到前四个字节(只适合32位机,64位要使用long long,不可以是double因为会有精度损失使得二进制错误)
强壮的时候也要注意,不可以随便转,必须是相关类型才可以互相转换。
打印结果:
第一个和第二个虚函数是派生类继承下来的,派生类对于第一个虚函数进行了重写所以重写后的虚函数的地址覆盖了原来的地址因此第一个不一样。后面第三个和第四个虚函数地址是派生类自己的虚函数。
先看代码:
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 VfuncPrint(_vfptr vfunc[])
{
printf("虚表地址:%p\n", vfunc);
for (int i = 0; vfunc[i] != nullptr; i++)
{
printf("第%d个虚函数地址:%p\n", i + 1, vfunc[i]);
}
}
int main()
{
Base1 b1;
Base2 b2;
Derive d;
VfuncPrint((_vfptr*) *((void**)&b1));
puts("");
VfuncPrint((_vfptr*)*((void**)&b2));
puts("");
VfuncPrint((_vfptr*)*((void**)&d));
}
首先在这里可以看到Derive继承了Base1和Base2,所以d对象里面存在着两个虚表。分别是第一个继承的类和第二个继承的类的虚表的拷贝,如果没有进行重写,那么没有被重写的函数就还是原来的地址,如果进行了重写,这里Base1和Base2里面的虚函数是满足三相同的所以一旦重写了一个,另一个也会被重写。
这段代码对func1进行了重写,就是对继承的两个类都完成了重写,所以这里的两个虚表的第一个地址应该是一样的。但是这里监视窗口看到的是不一样的。因为这里vs进行了封装,实际上他们调用的是同一个函数。
下面用代码来验证一下。
先创建d对象然后使用派生类对象d的地址切片赋给Base1的指针和Base2的指针。这时候访问func1都是在子类的两个虚表中查找。因为派生类对func1进行了重写所以调用的是同一个函数。
这次才是真正调用到了func1.
下面我们在来看第二个类调用的func1函数的汇编代码,来分析为什么这两个虚表内第一个func1的地址是不一样的。
跳转到这一层,我们再次跳转,在上一次调用的时候,应该就会到func1函数出了。到那时我们可以发现这个地址是和上次的func1地址不一样的。
这次跳转到了一句汇编代码,将ecx寄存器里面的值减掉了8,然后下一句jmp跳到了上次调用jmpfunc1的指令地址处。
先来看再次跳转。
到了函数实现。
这是Derive的对象模型,Derive先继承了Base1所以在d对象里面Base1是在前面的。因此在切片的时候直接将d的地址给Base1*是没有问题的,但是在Base2这里切片的时候就得移动d的地址了。栈向下增长,但是地址是减小的。所以d的地址减掉Base1的大小字节数,就能到Base2部分的其实地址处了。因此中间需要加一层移动ecx寄存器的指令所以监视看到的第一个func1函数的地址是不相同的。
但是在汇编看到实际最后调用到的还是相同的函数func1。
下面我们来打印一下Derive的d对象的两个虚函数表,来看看Derive自己的虚函数是放在那个虚函数表的。
可以看到这里Derive自己的func3是存放在第一个虚函数表的。(默认将派生类的虚函数存放在第一个继承的类的那个虚函数表里面)
总结多继承的虚函数表,继承了几个类,就有几个虚函数表,自己的虚函数总是存放在第一个虚函数表内。
1.下面程序输出结果是什么? ()
#include
using namespace std;
class A {
public:
A(const char* s) { cout << s << endl; }
~A() {}
};
class B :virtual public A {
public:
B(const char* s1, const char* s2) :A(s1) { cout << s2 << endl; }
};
class C :virtual public A {
public:
C(const char* s1, const char* s2) :A(s1) { cout << s2 << endl; }
};
class D :public B, public C {
public:
D(const char* s1, const char* s2, const char* s3, const 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类对象创建的时候会先调用A类的构造函数,在初始化列表的最后一个位置。因为虽然A类不是直接被继承的。但是也是相当于继承在D类中,而且是最先声明的。初始化列表的初始化顺序与写的顺序无关与声明顺序有关,所以后面依次是B和C最后是D自己。
这里的D类里面只有一个A因为B和C都是虚拟继承了A。
但是如果不是虚拟继承,那么继承在D类中就有两份A一份在B类部分,一份在C类部分。D中就不能直接对A进行初始化了。但是B和C初始化的时候会带上A所以A会分别在B和C前面出现一次如下图:
2.以下程序输出结果是什么()
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: A->0 B: B->1 C: A->1 D: B->0 E: 编译出错 F: 以上都不正确
这里的答案是B
原因是,派生类继承的时候继承了基类的虚函数全部属性,但是重写的之后,就相当于是重写了基类虚函数的实现,接口还是基类的。所以这里的val缺省值也会继承基类的,不管子类的写多少最后都是基类的缺省值。
inline函数可以是虚函数,在调用这个虚函数的时候如果不构成多态,那么这个虚函数显的是inline属性,就会被原地展开。
构成多态的时候调用,就没有inline属性了。因为多态调用的时候会去对象的虚函数表里面找虚函数的地址,此时inline属性就被编译器忽略了。
静态成员函数不可定义成虚函数,因为没有价值。多态就是为了让虚函数实现多态调用,但是静态函数可以直接使用类型::函数名调用。多态对此来说就没有意义了了。
虚函数的意义就是为了构成多态,调用时要去虚表里面找虚函数,对象中的虚表指针是在初始化列表初始化的。这也就注定了构造函数不可以是虚函数。
析构函数做虚函数只有一种场景,就是基类和派生类都开辟了对象,都是交给了基类的指针管理,那么在delete的时候如果析构函数不是虚函数没有完成重写,就会造成派生类的一部分成员资源没有清理干净,可能会造成内存泄漏。