目录
面向对象的三大特性是封装,继承,多态
本篇的主要目标就是多态
假如我想实现一个买票系统,普通人就买成人票,学生就买学生票,此时就需要用到多态
class person
{
public:
virtual void ticket(){cout << "普通票" << endl;}
};
class student : public person
{
public:
virtual void ticket(){cout << "学生票" << endl;}
//将父类和子类的同名函数定义成虚函数,就会构成重写(覆盖)
};
void fun(person &p)//用父类的指针或引用调用
{
p.ticket();//如果里面存的是子类对象,那么调用的是子类的函数
//如果里面存的是父类对象,那么调用的是父类的函数
}
int main()
{
student s;
person p;
fun(s);//传入子类对象,调用子类的函数
fun(p);//传入父类对象,调用父类的函数
return 0;
}
输出结果:
可以发现多态是在继承的基础上有的
多态是在不同继承关系的类对象,去调用同一函数,产生了不同的行为。比如Student继承了
Person。Person对象买票全价,Student对象买票半价。
关于上面代码的知识点,下面一个个讲解
那么在继承中要构成多态还有两个条件:
1. 必须通过父类的指针/引用调用虚函数
因为父类指针/引用可以指向父类对象,也可以指向子类对象,但子类指针/引用不能指向父类对象
2. 被调用的函数必须是虚函数,且必须进行重写
虚函数的重写:
上面也提到了,多态的其中一个条件就是虚函数和重写
虚函数
先来讲讲虚函数
和虚继承(在继承时前面加virtual)很像,就是在成员函数前面加virtual关键字,但不能把他们混为一谈。
虚继承是通过虚基表指针指向虚基表,来存储虚基类的偏移量,而虚函数是通过虚函数表指针指向虚函数表,存储类中所有虚函数的地址,两者可以说毫无联系
虚函数的语法:
class 类名
{
virtual 返回值类型 函数名(函数参数)
{
函数体;
}
}
需要注意的是,虚函数必须是类中的成员!!!
重写(覆盖)
学过继承的都知道重定义(隐藏),是指当父类和子类成员变量/函数名相同,就构成重定义(隐藏)
重写的全称是虚函数的重写,即重写的对象必须是虚函数
而重写要求父类和子类的虚函数名/返回值/参数都相同(协变例外,下面会讲)
#include <iostream>
using namespace std;
struct base{virtual void print(){cout<<"base"<<endl;}};//struct默认public
struct derived : public base{virtual void print(){cout<<"derived"<<endl;}};
void fun(base* b)//现在取决于指向的对象类型
{
b->print();
}
int main()
{
base* B;
derived d;
base b;
B = &d;//指向子类对象
fun(B);//调用子类虚函数
B = &b;//指向父类对象
fun(B);//调用父类虚函数
return 0;
}
输出结果:
上面代码中,可以看到,两次调用父类指针的print函数,却输出的不同的值,这就是满足多态之后可以实现的
满足多态:跟指向的对象有关,指向哪个对象就调用它的虚函数
不满足多态:跟调用对象的类型有关,类型是什么就调用谁的虚函数
什么叫不满足多态?
#include <iostream>
using namespace std;
struct base{virtual void print(){cout<<"base"<<endl;}};//struct默认public
struct derived : public base{virtual void print(){cout<<"derived"<<endl;}};
void fun(base b)//现在取决于调用对象的类型
{
b.print();
}
int main()
{
base B;
derived d;
base b;
B = d;//指向子类对象
fun(B);
B = b;//指向父类对象
fun(B);
return 0;
}
输出结果:
上面代码中,fun函数的参数从指针换成了传值调用,现在就是取决于调用的类型了,不管B中存的是父类对象还是子类对象,它本身的类型还是父类对象,所以只会调用父类对象的虚函数
虚函数重写的两个例外:
上面提到,重写(覆盖)的条件是类成员函数名/参数/返回值完全一致,但有两个例外
协变:
有时当父类和子类的虚函数返回值不相同时,也可以构成重写
#include <iostream>
using namespace std;
struct base{virtual base* print(){cout<<"base"<<endl;return nullptr;}};//struct默认public
struct derived : public base{virtual derived* print(){cout<<"derived"<<endl;return nullptr;}};
void fun(base& b)
{
b.print();
}
int main()
{
derived d;
base b;
fun(d);//引用指向子类对象,调用子类的虚函数
fun(b);//引用指向父类对象,调用父类的虚函数
return 0;
}
上面代码也可以构成重写,这是因为父类的虚函数返回值是父类指针/引用,子类的虚函数返回值是子类指针/引用,这就是协变
并且,构成协变的条件未必必须类自身,别的类的父类和子类也可以
#include <iostream>
using namespace std;
struct tmpbase{};
struct tmpderived : public tmpbase{};
struct base{virtual tmpbase* print(){cout<<"base"<<endl;return nullptr;}};//struct默认public
struct derived : public base{virtual tmpderived* print(){cout<<"derived"<<endl;return nullptr;}};
void fun(base& b)
{
b.print();
}
int main()
{
derived d;
base b;
fun(d);//引用指向子类对象,调用子类的虚函数
fun(b);//引用指向父类对象,调用父类的虚函数
return 0;
}
此时也构成协变
析构函数的重写:
虽然子类和父类的析构函数名称不一样,但他们可以构成重写
struct base//struct默认public
{
virtual ~base(){cout << "~base\n";}
};
struct derived : public base
{
virtual ~derived(){cout << "~derived\n";}
};
上面代码中,~base和~derived函数也构成了虚函数的重写
但虚函数重写不是要函数名相同吗?
有一种说法是所有析构函数在编译器的角度来看名称都是相同的(例如都是~或都是destructor),再加上他们没有返回值也没用参数,所以可以构成重写
但这种说法仍然存在一些问题,我还是宁愿相信析构函数在编译器中是没有名字的,可以构成重写是底层的语法支持的,就像子类对象可以赋值给父类对象一样
那实际写程序中我们需不需要让析构函数重写呢?
struct base//struct默认public
{
~base(){cout << "~base\n";}
};
struct derived : public base
{
~derived(){cout << "~derived\n";}
};
int main()
{
base* b = new base;
delete b;
b = new derived;
delete b;
return 0;
}
上面代码中的父类子类析构函数没有重写
输出结果:
第一行调用了父类的析构函数,没有问题,因为此时b中存的就是一个父类对象
但第二行时b中存的是一个子类对象,这时如果还只调用父类对象的析构函数,子类对象中的动态开辟的内存就会因为没有释放而造成内存泄漏
struct base//struct默认public
{
virtual ~base(){cout << "~base\n";}
};
struct derived : public base
{
virtual ~derived(){cout << "~derived\n";}
};
int main()
{
base* b = new base;
delete b;
b = new derived;
delete b;
return 0;
}
输出结果:
构成重写时,就可以通过指向的对象来调用它的析构函数了(子类对象析构时先析构子类再析构父类)
练习:
下面程序会输出什么?
class A
{
public:
virtual void func(int val = 1){cout <<"A->"<<val<<endl;}
virtual void test(){func();}
};
class B : public A
{
public:
virtual void func(int val = 0){cout <<"B->"<<val<<endl;}
};
int main()
{
B *p = new B;
p->test();
return 0;
}
输出结果:
讲解:
test虽然是虚函数,但子类中没有与之对应的test,所以没有重写,那么p还是会去调用A的test。
此时要哪个func呢?test的this指针是指向p的,p是B类型对象,所以func就会被编译器写成this->func(),那么就会去调用B的func()。
那按上面的流程来讲,因为B.func()的val缺省值是0,应该输出的是B->0啊,为什么输出的是1?
虚函数的重写是由父类虚函数去覆盖子类虚函数,父类虚函数会把除了函数实现之外的部分都覆盖到子类虚函数,所以父类虚函数中val缺省值会覆盖到子类虚函数中,那此时子类的虚函数中val的缺省值也就变成1了
final和override关键字
被final修饰的虚函数,就代表它不能再被重写了,如果尝试重写它,就会报错
class Car
{
public:
virtual void Drive() final {}
};
class Benz :public Car
{
public:
virtual void Drive() {cout << "Benz-舒适" << endl;}
};
被override修饰的虚函数,会检查该虚函数是否重写了父类的某个虚函数,如果没有,就会报错
class Car
{
public:
virtual void Dirve(){}
};
class Benz :public Car
{
public:
virtual void Drive() override{cout << "Benz-舒适" << endl;}
};
抽象类
在虚函数的后面直接写上 = 0,则这个函数称为纯虚函数,包含纯虚函数的类就叫做抽象类。
class base//抽象类
{
public:
virtual void test() = 0;//纯虚函数
};
需要注意的是,抽象类是不能被实例化的
并且即使子类继承了这个抽象类后,也不能被实例化
因为此时derived也包含这个纯虚函数,那么derived也是抽象类
只有在子类中对抽象类的纯虚函数进行重写后,才可以实例化子类对象
class base
{
public:
virtual void test() = 0;
};
class derived : public base
{
public:
virtual void test()
{
cout << "Hello, world!\n";
}
};
此时就可以实例化derived对象了
纯虚函数可以强制子类去进行重写,也表示一个抽象的类型,例如抽象类是花,子类可以是具体的花种
接口继承和实现继承
普通函数的继承被叫做实现继承,而虚函数的继承被叫做接口继承
因为普通函数的继承是把整个函数继承到子类中,包括函数的具体实现
但虚函数的继承只会把函数名、参数、返回值、缺省值继承下来,函数的具体实现不会被继承,只继承了一个接口,因此被叫作接口继承
虚函数重写的原理:
每个具有虚函数的类都有一个虚函数表,它是一个函数指针数组,每个指针都指向类中的一个虚函数。当一个类对象被实例化时,该对象的前4/8个字节就是指向本类的虚函数表的指针——虚函数表指针(vftptr)。
子类对象的虚函数表中就是被子类虚函数覆盖过的,父类对象虚函数表中就是没有被子类覆盖过的,因此,只要去找该对象的虚函数表中对应函数,就可以实现多态。
struct base
{
virtual void fun1(){cout << "base:fun1\n";}
virtual void fun2(){cout << "base:fun2\n";}
int _b;
};
struct derived : public base
{
virtual void fun1(){cout << "derived:fun1\n";}
virtual void fun3(){cout << "derived:fun3\n";}
int _s;
};
int main()
{
base b;
derived d;
return 0;
}
可以看到,父类中的虚函数表中有两个指针,分别是父类的fun1和fun2
而子类的虚函数表中fun1就变成子类的了,又因为fun2没有被覆盖,所以还是父类的
那fun3呢?
这是因为编译器认为fun3不需要显示出来,就刻意的隐藏了。
怎么证明呢?
我们可以打印一下每个类的虚函数表来一探究竟
虚函数表在每个类实例化对象的最上方,如果是x86环境,就是前4个字节,如果是x64环境,就是前8个字节,只需要把前4/8个字节提出来就可以了
打印虚函数表:
首先,需要写个函数,用来打印虚函数表
typedef void(*VF_PTR)();//虚函数表是函数指针数组
void PrintVFTable(VF_PTR pTable[])
{
int i = 0;
while (pTable[i] != 0)//虚函数表的结束符是在最后填入0x00000000
{
printf("vfTable[%d]:%p->", i,pTable[i]);
(pTable[i])();//调用该函数
i++;
}
}
那传参时我们怎么传参呢?
这里以x86环境为例,取出对象的地址后先强转成int*类型,这样就把前4个字节提取出来了,但现在是虚函数表指针的地址,所以需要再解引用这个指针,即*(int*)&b,找到虚函数表指针所指向的虚函数表
然而这时还是int类型,所以要再给它强转成函数指针数组,即(VF_PTR*)*(int*)&d
int main()
{
base b;
derived d;
PrintVFTable((VF_PTR*)*(int*)&b);
cout << endl;
PrintVFTable((VF_PTR*)*(int*)&d);
return 0;
}
这里有些同学可能会有疑问:转换成int*可以,那double*、float*、long*可不可以?
先说答案:long*可以,其他的不行
double*是最不可能的,因为它解引用后是8字节,而我们只要4字节
float*虽然解引用后是4字节,但它是浮点数,float*在内存中不会被识别成指针,而是IEEE 754浮点数,这会导致解引用错误
long*可以,一是因为它也存的是整数,可以被解释为指针类型,二是因为long被解引用后也是4字节
输出结果:
可以看到,在子类的虚函数表中,的确是有fun3的
虚函数表在哪?
众所周知,类中的函数都是存在内存的代码段的,并且虚函数也是。
那虚函数表存在哪里呢?
很多人可能会认为在栈区,但如果在栈的话,每创建一个对象都要创建一个虚函数表
下面代码中,创建了两个父类对象两个子类对象
int main()
{
base b1;
base b2;
derived d1;
derived d2;
return 0;
}
四个对象的虚函数表指针所存的地址如图所示:
可以看到,同类对象中的虚函数表指针所指向的虚函数表都是同一个
所以,虚函数表不是在栈区。又因为堆区是给动态开辟内存用的,静态区是给静态变量和全局变量,所以只能是代码段了。即虚函数表存在代码段中。
怎么证明这点呢?
可以写一个程序,来分别打印各个地址区变量/常量的地址
struct base
{
virtual void fun1(){cout << "base:fun1\n";}
virtual void fun2(){cout << "base:fun2\n";}
void test() { cout << "这是一个函数\n"; }
int _b;
};
struct derived : public base
{
virtual void fun1(){cout << "derived:fun1\n";}
virtual void fun3(){cout << "derived:fun3\n";}
int _s;
};
int qi;//全局变量也在数据段
int main()
{
base b1;
int i,j;//栈区变量
int* p1 = new int;//堆区变量
int* p2 = new int;
static int si;//静态变量也在数据段
char* st = "野兽先辈";//常量存在代码段
printf("VFTable(虚函数表)地址:%p\n", *(int*) & b1);
printf("栈区变量i地址:%p\n", &i);
printf("栈区变量j地址:%p\n", &j);
printf("堆区变量p1地址:%p\n", p1);
printf("堆区变量p2地址:%p\n", p2);
printf("数据段变量qi地址:%p\n", &qi);
printf("数据段变量si地址:%p\n", &si);
printf("代码段常量st地址:%p\n", st);
printf("代码段普通函数地址:%p\n", &base::test);
printf("代码段虚函数地址:%p\n", &base::fun1);
return 0;
}
在输出结果中,可以看到,虚函数表(简称虚表)的地址和代码段的地址是最接近的
因此,虚函数表是存储在代码段中
动态绑定与静态绑定
动态绑定就是普遍意义上的多态,即在程序运行期间,根据具体拿到的类型确定程序的具体
行为,调用具体的函数。静态绑定是在程序编译期间确定了程序的行为
动态绑定又称为动态多态,静态绑定又称为静态多态。
一般说的多态就是动态多态,只是有些题里可能会分开讲
多继承中的虚函数表
上面讲过单继承中的虚函数表,下面来讲讲多继承中的虚函数表
在单继承中,虚函数表都是被放在父类的最开始处,在多继承也是,只不过每个父类的最开始处都有一个虚函数表
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 PrintVTable(VFPTR VFTable[])
{
for (int i = 0; VFTable[i] != 0; ++i)
{
printf(" VFTable[%d]:%p,->", i, VFTable[i]);
VFTable[i]();
}
cout << endl;
}
int main()
{
Derive d;
PrintVTable((VFPTR*)(*(int*)&d));//打印第一个虚函数表(也就是Base1的)
PrintVTable((VFPTR*)(*(int*)((char*)&d + sizeof(Base1))));//打印第二个虚函数表(也就是Base2的)
//这里是先将地址+"Base1"个字节数,这样就能到Base2的空间
return 0;
}
这段程序中,Derive类继承了两个父类,并且子类中有一个虚函数没有被重写,两个父类中的fun2也没有被重写
PrintVTable用于打印该类的虚函数表
对于PrintVTable((VFPTR*)(*(int*)((char*)&d + sizeof(Base1))));这里,是要取到第二个继承的父类的虚函数表,那就在第一个父类的后面,所以要让子类地址+父类字节数。但如果直接加的话,内存中移动是以d的类型为单位,也就是Derived,所以我们需要让它变为以1为一个单位,就要转换为char*
输出结果:
可以看到,两个父类中的fun1都被子类重写,fun2都保持不变
但子类中没有被重写的fun3,却到了Base1的虚函数表中了
即:多继承派生类的未重写的虚函数放在第一个继承基类部分的虚函数表中