文章目录
- 多态的定义
- 多态的实现
- 1.多态实现的两个必要条件
- 2.什么是虚函数
- 3.重写的条件
- 4.多态实现代码
- 5.重写的两个例外
- C++11引入的final和override关键字
- 重载,重写(覆盖),隐藏(重定义)
- 抽象类
- 接口继承和实现继承
- 多态的原理
- 1.虚函数表
- 2.多态的实现原理
- 关于动态绑定与静态绑定
- 单继承和多继承时的虚函数表
- 单继承的虚函数表
- 多继承的虚函数表
- 菱形继承的虚函数表
- 菱形虚拟继承的虚函数表
- inline函数可以是虚函数
- 静态成员函数不可以是虚函数
- 构造函数不可以是虚函数
- 析构函数作为虚函数的场景
C++面向对象三大特性之一,多态
多态的定义
多态就是多种形态。就是不同对象在完成同一种行为的时候结果不同。
一种情况是不同的对象,调用同一个函数,但是出现了不同的结果。
多态分为两种:
1.静态多态:函数重载(看起来调用的是一个函数但是传递不同的参数有不同的行为,比如cout的自动识别类型)
2.动态多态:用一个基类对象的引用或者指针去调用重写完成的虚函数,更具引用或指向的对象不同,出现不同的行为
静态:是指在编译阶段实现
动态:是指在程序运行阶段实现
多态的实现
1.多态实现的两个必要条件
1.必须是基类的引用或者指针调用
2.被调用的函数必须是虚函数,并且派生类对其完成了重写。
2.什么是虚函数
被virtual修饰的函数就是虚函数
class Student
{
public:
virtual void func1()
{
cout << "Student::virtual void func1()" << endl;
}
};
这里的func1就是虚函数
静态成员函数不能成为虚函数(理解多态原理后再看)
解释:静态成员函数与具体对象无关,属于整个类,核心关键是没有隐藏的this指针,可以通过类名::成员函数名 直接调用,此时没有this无法拿到虚表(虚表的指针存放在对象里面),就无法实现多态,因此不能设置为虚函数
3.重写的条件
派生类和基类中的虚函数满足三同(函数名相同,参数相同,返回值相同)就构成重写,派生类函数完成了对基类函数的重写(又叫做覆盖)
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;
}
};
这段代码派生类就完成了对于基类的重写。
4.多态实现代码
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;
}
这里使用的是引用接收,然后引用调用,传递了不同的对象,调用了同一个函数(看似是同一个,实际底层调用的并不是一个函数)出现了不同的结果。
关于多态这里的函数调用,主要是看满不满足多态条件
- 多态调用,调用的函数与引用或者指向的对象有关。
- 普通调用,调用的函数与调用该函数的对象有关。
5.重写的两个例外
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.析构函数的重写
只要基类的析构时virtual,基类的析构和派生类的析构就构成重写
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也算是虚函数。重写是接口继承,派生类只重写了基类虚函数的实现。
这种设计的初衷是为了方便进行代码分工,如果基类函数定义成立虚函数,那么派生类继承了基类之后就算忘记加virtual也是可以完成重写的。不至于因为派生类没有重写而造成内存泄漏的错误(特别是派生类的析构函数要完成重写防止内存泄漏)
所以在写父类的时候,析构函数最好设置为虚函数,不管子类析构有没有加virtual都构成重载,避免出现内存泄漏。
C++11引入的final和override关键字
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修饰基类,使基类不可被继承,这个类叫做最终类。(直接限制)
- 将构造函数设置成为私有,派生类继承基类后调不动基类的构造函数无法实例化出对象。(间接限制)
重载,重写(覆盖),隐藏(重定义)
重载条件:
- 必须是同一作用域
- 必须函数名相同,并且参数不同(类型不同或者个数不同或者顺序不同)
重写(覆盖)条件:
- 必须是虚函数
- 必须满足函数名相同,参数相同,返回值相同(协变除外)
隐藏(重定义)的条件
- 必须是分别在基类和派生类的作用域
- 必须是函数名相同
基类和派生类的同名函数不是重写就是隐藏。
抽象类
抽象的含义就是没有实体,或者说现实世界中不存在实物的东西。
一个类如果含有纯虚函数(在虚函数后面加上 = 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;
}
派生类对抽象类中的纯虚函数进行重写之后,派生类就可以正常的创建对象了。
接口继承和实现继承
实现继承就是普通函数的继承。普通函数在派生类中可以直接调用,相当于是将函数的实现继承了过来。
虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,实现多态。所以如果不实现多态,不要把函数定义成虚函数。
多态的原理
1.虚函数表
有虚函数的类实例化出来的对象里面都会存在一个虚函数表指针(一般是在最前面),这个指针指向了一个虚函数表(是一个虚函数指针数组)简称虚表,虚表里面存放是虚函数的地址,也包括继承下来的虚函数。
a对象里面有一个成员变量_a,理论上说大小应该是4字节,但是算出来是8个字节也就是印证了对象里面还包含了一个指针变量(虚函数表指针,指针在32位机4个字节,64位机8个字节)
a对象里面的第一个成员是一个__vfptr的指针,这个指针就是虚函数表指针。现在虚函数表内有一个虚函数,所以只有一个地址。vs中虚函数表最后以nullptr结尾(不同的编译器可能不同,还有的用-1作为结尾)。
class A
{
public:
virtual void func1()
{
cout << "func1()" << 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类,自然也是继承了A类的虚函数表。
b对象的虚函数表是A类的虚函数表的拷贝
如果B类中对于func1进行重写,然后增加一个B类自己的虚函数func3再来看这个虚函数表的变化。
经过重写后,b对象的虚函数表内的第一个函数指针的内容已经变了。也即是重写后新的派生类的虚函数将原来的那个A类的虚函数覆盖掉了。这就是重写又叫覆盖的原因,重写是语法的叫法,覆盖是原理层的含义。
从监视窗口看不到func3出现在虚表中,是因为vs对于监视窗口进行了优化。这里可以通过内存窗口来观察func3的地址是不是真的在虚函数表内。
这个内存里面,在func1和func2地址下面的那个地址就是func3的地址。
虚函数表这里也验证了为什么多态的第一个条件,必须是基类的指针或者引用调用。因为子类对象传给父类的指针或引用会进行切片。保留基类的那一部分包括虚函数表。
如果是赋值,就是将子类的里面的基类部分拷贝给基类对象,拷贝的时候是不会拷贝虚表的。
这里的规则就是父类对象里面一定是父类的虚表,子类对象里面一定是子类的虚表。
如果父类对象经过子类对象的一次赋值,父类虚表就变成了子类的虚表那么后续使用父类对象的时候就不能确定此时对象里面是父类虚表还是子类虚表了。
同类型的对象共用一个虚表。
虚表是在编译阶段生成的,在构造函数的初始化列表中初始化给对象。虚表存在代码段或者叫常量区。
2.多态的实现原理
B类继承了A类。
实参分别是基类和派生类的指针,派生类传过去发生了切片。所以虽然都是转换成了A*类型但是两次调用的虚函数表指针是不同的,所以虚函数表也不是同一个。去虚函数表里面找func1的时候找到的函数也就不同,这就实现了多态。
这时我们也可以理解为什么需要子类虚函数覆盖(重写)父类的虚函数。
因为子类的虚表是拷贝的父类虚表。重写之后才可以将虚表内的函数覆盖为子类自己的虚函数地址。
为什么要用父类的指针或者引用来调用?
因为不会发生拷贝构造,子类传过去发生了切片,但是虚函数表指针还是子类的虚函数表指针。由此才可以调用到子类的虚函数。
通过下面的汇编代码分析,满足多态以后的函数调用,不是在编译时确定的,是运行起来以后到
对象的虚函数表中去找的。不满足多态的函数调用时编译时确认好的。** **
前面的汇编代码是取出pa指向的对象的前四个字节的虚函数表指针,然后找到虚函数表,再找到func1,将func1的地址放到eax寄存器里面,最后通过call指令调用。其实这里的地址并不是func1函数真正的地址。call之后会跳到真正的func1函数的地址处。
vs这里封装了一层,所以第一次call之后并不是直接跳到func1函数体处开始执行,而是跳到中间层然后再次jmp跳到真正的func1函数处开始执行。
vs这里做多一层的封装,实际是为了中间层控制this指针的偏移。
总结多态原理:
满足多态的条件调用函数的时候才去虚函数表里面找,不满足多态,编译的时候就已经确定了调用函数的地址。
如果派生类重写基类的函数被放在派生类的私有或限定域内,在满足多态的条件的时候也是可以调用的。因为可以通过虚函数表里面的虚函数地址来找到函数,直接调用。也就是说访问限定符的限定作用并不是绝对的。
关于动态绑定与静态绑定
动态绑定和静态绑定就是动态多态和静态多态的实现原理方面。
动态绑定是指程序运行期间确定了程序的具体行为(调用什么函数)所以也称为动态多态。
静态绑定是指在程序编译阶段确定了程序的具体行为,将调用的函数地址填入调用的地方。因此称为静态多态。比如函数重载。
单继承和多继承时的虚函数表
单继承的虚函数表
因为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)();//将函数指针重命名成_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函数,依次向后直到nullptr即可打印所有的虚函数地址。
这里的强转也可以将a的地址转成int*,然后解引用拿到前四个字节(只适合32位机,64位要使用long long,不可以用double因为会有精度损失使得二进制错误)
强制类型转换的时候也要注意,不可以随便转,必须是相关类型才可以互相转换。什么是相关类型,比如i整形和指针就是。
打印结果:
第一个和第二个虚函数是派生类继承下来的,派生类对于第一个虚函数进行了重写,所以重写后的虚函数的地址覆盖了原来的地址,因此第一个不一样。后面第三个和第四个虚函数地址是派生类自己的虚函数。
多继承的虚函数表
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是在子类的两个虚表中查找。
下面看汇编代码演示Base1*的调用过程:
eax里存放的是虚函数表里面func1的地址
汇编中不是直接跳到了func1而是到一层中转层。
再次jmp才是真正调用到了func1.
下面是Base2*调用的func1函数的汇编代码,来分析一下,为什么这两个虚表内第一个func1的地址是不一样的。
第一步取eax寄存器内的地址进行跳转。可以看到这里是和Base1调用时候eax的地址不一样的。
再次跳转,在Base1调用的时候,应该就到func1函数了。
这次跳转到了一句汇编代码,将ecx寄存器里面的值减掉了8,然后下一句jmp跳到了Base1*调用jmp func1的指令地址处,再次跳转。
再次跳转
到了func1函数。
为什么ecx寄存器要先减掉8个字节呢?因为切片造成的this指针偏移。
首先说明ecx里面存放的是this指针。
这是Derive的对象模型,Derive先继承了Base1所以在d对象里Base1是在前面的。因此在切片的时候直接将d的地址给Base1*是没有问题的,但是在Base2这里切片的时候就不是d的地址了。栈向下增长,地址是减小的。所以d的地址减掉Base1的大小字节数,就能到Base2部分的起始地址处了。因此中间需要加一层移动ecx寄存器的指令所以监视看到的虚函数表中第一个func1函数的地址是不相同的。
但是在汇编看到实际最后调用到的还是相同的函数func1。
下面打印一下Derive的d对象的两个虚函数表,来看看Derive自己的虚函数是放在那个虚函数表的。
Derive自己的func3是存放在第一个虚函数表的。(默认将派生类自己的虚函数存放在第一个继承的类的那个虚函数表里面)
总结多继承的虚函数表,继承了几个类,就有几个虚函数表,自己的虚函数总是存放在第一个虚函数表内。
菱形继承的虚函数表
该模块总结内容来自刘皓前辈的博客:https://coolshell.cn/articles/12176.html我做了很大成度的简化,尽量使之容易理解。
菱形继承时重复继承(不使用虚拟继承)虚函数表存在数据冗余和二义性
D类对象d里面存在两个虚函数表,一个是B1类的一个是B2类的,B1和B2类的虚函数表内都存在一份B类的虚函数,D类重写了B类的f( )(必须重写f( )),B1类的f1( ) ,B2类的f2( ) ,D类中还添加了一个没有参与任何重写的虚函数Df( )存在了B1部分的虚函数表内。
因为f1和f2分别重写了B1类和B2类中的f1,f2,所以他们的位置是确定的。需要覆盖B1和B2继承下来的虚函数。
菱形继承存在数据冗余,D类对象内部,B1类虚函数表后面跟着B类和B1类的成员变量。B2类的虚函数表后面先跟着B类的成员变量然后是B2类的成员变量。最后存的是D类的成员变量。
菱形虚拟继承的虚函数表
菱形虚拟继承在继承的时候加上了virtual关键字,解决了菱形继承时候造成的数据冗余和二义性。
菱形虚拟继承的时候,两个子类B1和B2虚继承了同一个父类B,下面是他们的内存模型
内存分布:B1类的虚表指针,B1类的成员变量,然后是B类的虚表指针,B类的成员变量。
VC++进一步的解决了虚函数表内的重复,因为B1类重写了B类的f()所以只在B1类虚函数表内存放没有重写的虚函数,重写的虚函数f( )一同放到公共的B类的虚函数表内部。
注意区分:如果是普通的单继承,那么只有一个虚函数表,子类重写覆盖父类的虚函数,子类自己的虚函数放在父类虚函数的下面。这里是虚拟单继承,所以存在两个虚函数表(为了解决菱形继承),这就是虚拟继承对内存模型的改变。
下面就是菱形虚拟继承中D类对象的内存模型
在GCC环境下B1类的虚函数表里存放的是B类和B1类和D类中的所有虚函数,如果完成了重写的虚函数就覆盖。与普通菱形继承的时候不同,f2( )也出现在了B1的虚函数表内。GCC下B2类中存放了B2类原有的虚函数,D类中完成重写的虚函数会覆盖B2类原有的虚函数。公共部分B类里面存放的是B类经过D类覆盖后的虚函数表。这些虚函数表内还是存在着重复的虚函数。比如三个部分都有的f()函数。
但是条理清晰,D类自己的虚函数放在了第一个继承的B1类部分的虚函数表内。B2类部分只放B2类自己的虚函数。B类部分的虚函数表只放B类自己的虚函数。
GCC下的成员变量部分,B1虚表指针后跟着B1的成员变量。B2虚表指针后面是B2类的成员变量。D类的成员变量。最后是公共部分B的虚表指针和成员变量。
VC++的情况与GCC有所不同,B1类虚表内保存了B1的虚函数,f1( )和Bf1( )还有D类中没有重写的Df( )函数,在B2类的虚表里面存放的也是B2的虚函数,f2( )和B2( )。在公共部分B类的虚表内,存放了公共的f( )和没被重写的Bf( )函数。从此方面来看VC++这里的重复处理的是比GCC更好的
VC++的成员变量部分,就与GCC部分一样了。
表内标准红色的语段是不知道为何这里是-4.
VC++和GCC都把公共部分B放在最下面,而VC++有一个NULL作为B和B1和B2的虚表的结束标志。GCC则是在B1虚表结束用了1,B2和B虚表结束使用了0。
下面这段话截取自开头提到的博客。
VC++中的内存布局有两个地址我有些不是很明白,在其中我用红色标出了。取其内容是-4。接道理来说,这个指针应该是指向B类实例的内存地址(这个做法就是为了保证重复的父类只有一个实例的技术)。但取值后却不是。这点我目前还并不太清楚,还向大家请教。
**GCC的内存布局中在B1和B2中则没有指向B的指针。这点可以理解,编译器可以通过计算B1和B2的size而得出B的偏移量。这个偏移量就存放在虚基表中(虚基表就是D类对象的B1部分和B2部分都有一个指针,这个指针指向的就是虚基表,**虚基表内存放的是当前B1分区到公共的虚函数表([0]到[8])和公共的成员变量的两个偏移量([0]到[9]))。所以虚基表里面存在两个偏移量的问题就解决了。
inline函数可以是虚函数
inline函数可以是虚函数,在调用这个虚函数的时候如果不构成多态,那么这个虚函数显的是inline属性,就会被原地展开。
构成多态的时候调用,就没有inline属性了。因为多态调用的时候会去对象的虚函数表里面找虚函数的地址,此时inline属性就被编译器忽略了。
静态成员函数不可以是虚函数
静态成员函数不可定义成虚函数,因为没有价值。多态就是为了让虚函数实现多态调用,但是静态函数可以直接使用类型::函数名调用。多态对此来说就没有意义了了。
构造函数不可以是虚函数
虚函数的意义就是为了构成多态,调用时要去虚表里面找虚函数,对象中的虚表指针是在初始化列表初始化的。这也就注定了构造函数不可以是虚函数。
析构函数作为虚函数的场景
析构函数做虚函数只有一种场景,就是基类和派生类都开辟了对象,都是交给了基类的指针管理,那么在delete的时候如果析构函数不是虚函数没有完成重写,就会造成派生类的一部分成员资源没有清理干净,可能会造成内存泄漏。