前言
多态 是面向对象三大特性的最后一个,多态 说的是 具有继承关系的不同对象,调用同一方法时展现出来的不同状态!也就是说 多态 是基于 继承 实现的!本博客将详细介绍C++的多态及其原理!
1、多态的概念
多态 指的是 具有继承关系 的不同对象在调用同一函数 时形成的不同状态!
举个例子:比如我们平时买高铁/火车票时,不同的人买票买的结果都是不一样的!学生是半价、成人是全价、军人是军人优先票 等!而这三种对象本质都是继承自同一个 Person 的父类,所以他们执行同一操作时不同的结果就是多态!
• 构成多态的两个必要条件
1、必须通过基类/父类的 指针或者引用 调用虚函数
2、派生类必须得对基类的虚函数进行重写
举个栗子先见一见:
class Person
{
public:
// 虚函数
virtual void BuyTicket()
{
cout << "Person::买票-成人-全价" << endl;
}
};
class Student : public Person // 构成继承关系
{
public:
// 虚函数
virtual void BuyTicket()
{
cout << "Student::买票-学生-半价" << endl;
}
};
int main()
{
// 父类对象的指针 调用虚函数
Person* pp = new Student;
pp->BuyTicket();
// 父类对象的引用 调用子类的对象
Student st;
Person& rp = st;
rp.BuyTicket();
Person p;
Person& r = p;// 父类对象的引用 调用虚函数
r.BuyTicket();
Person* ptr = &p;// 父类对象的指针 调用虚函数
ptr->BuyTicket();
return 0;
}
此时,不同的对象去以父类的指针/引用去调用虚函数时结果是不一样的
C++的多态是基于虚函数和虚函数的重写实现的,所以在我们正式的介绍多态之前,得先把虚函数和虚函数的重写给介绍了!
2、虚函数
2.1 什么是虚函数
虚函数 :被 virtual 修饰的类成员函数
class Person
{
public:
// 虚函数
virtual void BuyTicket()
{
cout << "买票-全价" << endl;
}
};
此时的 BuyTicket 就是一个虚函数,1、他是成员函数 2、他被virtual修饰
注意:virtual 只有修饰的是成员函数是才是虚函数,且virtual 不能修饰非成员函数
2.2 虚函数的重写
虚函数的重写 又称虚函数的覆盖,指的是子类中有一个和父类中一样的"三同 " 虚函数,则称子类中的虚函数重写了父类的虚函数!
三同 指的是 :返回值、函数名、形参列表类型 都相同
class Person
{
public:
// 虚函数
virtual void BuyTicket()
{
cout << "买票-全价" << endl;
}
};
class Student : public Person // 构成继承关系
{
public:
// 虚函数
virtual void BuyTicket()
{
cout << "买票-半价" << endl;
}
};
此时,Person类 中的 BuyTicket 和 Student类 中的 BuyTicket 虚函数构成重写!
• 重写必须是虚函数,不是虚函数,父子类中的BuyTicket 函数就是隐藏关系
• 子类中的虚函数重写,本质是子类继承了父类的虚函数声明,重写实现部分
第二点很有必要说一下:
class Person
{
public:
// 虚函数
virtual void BuyTicket(int a = 10)
{
cout << "Person::买票-全价-" << a << endl;
}
};
class Student : public Person // 构成继承关系
{
public:
// 虚函数
virtual void BuyTicket(int b = 20)
{
cout << "Student::买票-半价-"<< b << endl;
}
};
int main()
{
Person* pp = new Student;// 多态
pp->BuyTicket();
return 0;
}
这段代码如果利用多态调用的话,乍一看结果不就是20吗?是不是呢?看结果:
咋是10呢?其实原因就是上面说的,子类虚函数的重写本质是继承了父类的声明,重写了实现
注意:这里和形参的名字没有关系,只看类型是否相同! 另外,既然是子类继承了父类的声明,所以子类可以不写 virtual 关键字 但是一般建议还是加上!!
• 虚函数重写的两个例外
1、协变 :子类重写虚函数时,子类和父类的虚函数的返回值不同,子类虚函数返回子类类型的指针/引用,父类虚函数返回父类类型的指针或引用!
class Person
{
public:
// 虚函数
// virtual Person& BuyTicket()
virtual Person* BuyTicket()
{
cout << "Person::买票-成人-全价" << endl;
return nullptr;
}
};
class Student : public Person // 构成继承关系
{
public:
// 虚函数
// virtual Student& BuyTicket()
virtual Student* BuyTicket()
{
cout << "Student::买票-学生-半价" << endl;
return nullptr;
}
};
int main()
{
// 父类对象的指针 调用虚函数
Person* pp = new Student;
pp->BuyTicket();
Person p;
Person& r = p;// 父类对象的引用 调用子类的对象
r.BuyTicket();
return 0;
}
2、析构函数的重写
如果在多态中,父类的析构不写成虚函数,就会可能造成内存泄漏
原因是:多态调用是父类型的指针或引用在调用对应的虚函数,而父子类的析构是不同名的!当父类的指针或引用对象销毁时,就是默认去掉父类的,造成子类对象的内存泄漏!
所以父类必须得将析构写成虚函数,子类继承后默认就是虚函数,就可以合理的释放资源了!
class Person
{
public:
// 虚函数
// virtual Person& BuyTicket()
virtual Person* BuyTicket()
{
cout << "Person::买票-成人-全价" << endl;
return nullptr;
}
~Person()
{
cout << "~Person()" << endl;
}
};
class Student : public Person // 构成继承关系
{
public:
// 虚函数
// virtual Student& BuyTicket()
virtual Student* BuyTicket()
{
cout << "Student::买票-学生-半价" << endl;
return nullptr;
}
~Student()
{
cout << "~Student()" << endl;
}
};
int main()
{
// 父类对象的指针 调用虚函数
Person* pp = new Student;
// pp->BuyTicket();
Person* p = new Person;
// p->BuyTicket();
delete pp;
delete p;
}
这是没有将父类的析构设置成虚函数前:
可以看到没有将Student的对象释放,而是将 Person 的对象释放了两次!不仅造成内存泄漏,还导致重复析构!!
原因是刚说的,不将父类的析构写成虚函数会导致,父子类中 的析构是不同的函数(函数名不同)构不成重写,所以父类型的指针或引用就会调到父类的析构了!
将父类和子类的析构搞成虚函数的重写,即将父类的析构前加上 virtual 就可以解决!
但是构成重写的条件不是 三同 吗?这里明显的函数名不同啊!其实编译器的底层对父子类的析构函数重写进行了统一的处理,名字都是 destructor ,这样他两就构成了虚函数的重写!
这里因为子类继承了父类,子类构造前先要调用父类构造初始化父类的那一部分,结束后 调用完子类的析构后还会调用父类的析构,所以这里多了中间的 ~Person !
2.3 final 和 override
在C++11中,提供了 final 和 override 两个关键字
final :修饰父类虚函数,表示该虚函数不能被重写,即不构成多态
override :修饰子类虚函数,检查是否构成重写(是否满足重写的条件),不满足则报错
显然,前者是:不想被重写->不想构成多态;后者是:检查是否重写->想构成多态
对父类的虚函数加上 final 子类就不能重写了
对子类的虚函数加上 override 进行检查
另外,final 还可以修饰类,表示该类不可以被继承,即最终类!
注意:final 可以修饰子类,以为子类也有可能变成父类
2.4 重载、重写、重定义
我们目前已经学习了 "三重" 即重载、重写、重定义
这三兄弟,看起来很容易混淆,实际上确实容易迷糊!下面就来区分一下
重载:即 函数重载,在同一作用域中,函数名相同、形参列表不同 的函数
重写:即虚函数重写又称覆盖,在父子类中,构成 三同(返回值、函数名、形参列表) 的虚函数
重定义:又称隐藏,在父子类中,函数名相同且不是重写的函数
也就是说重定义式包含重写的,因为重写也是函数名相同的!
3、抽象类
抽象类是一种特殊的类,他不能实例化对象,只能当作其他类的基类使用!
抽象类的目的是为了设计出一些通用的接口(没有实现),强制要求继承抽象类的子类要重写抽象类中的虚函数
可以认为是提高了编程的整体规范性
要定义一个抽象类,该类中必须至少有一个 纯虚函数(虚函数以 =0 结尾且没有函数体的实现)
class Person
{
public:
virtual void func(int a) = 0;// 纯虚函数,没有实现只有接口的申明
};
子类要使用这这些纯虚函数的接口,就需要对继承下来的纯虚函数的接口进行重写
class Person
{
public:
virtual void func(int a) = 0;// 纯虚函数,没有实现只有接口的申明
};
class Student final : public Person
{
public:
// 重写纯虚函数
virtual void func(int a) override
{
cout << "Student::func(int a)" << endl;
}
};
抽象类的继承很好的体现了,虚函数重写时是接口式继承:父类的接口申明+子类的实现!
普通继承:子类可以直接使用父类中的函数
接口继承:子类继承父类的接口声明,需要自己重写实现(多态/抽象类)
建议:不实现多态,就不要把成员函数定义成虚函数
注意:如果子类只是继承了抽象类,没有重写它的纯虚函数的接口,子类也是无法实例对象的!
4、多态的原理
上面介绍了多态,也知道咋用了!那多态是如何做到不同对象调用同一方法时展现出不同的结果的呢?下面我们就来探索一下多态的原理!
4.1 虚函数表和虚函数表指针
我们先来看一段代码
class A
{
public:
virtual void Func()
{
cout << "Func" << endl;
}
};
int main()
{
A a;
cout << sizeof(a) << endl;
return 0;
}
这个对象的大小是多少呢?如果你类和对象初阶那里看了,你可能会说是 1 ?是不是呢?
这里虽然类中没有一个成员,但是前面说过当类中没有一个成员属性的时候,会用1个字节表示这是一个空类!
但是这里我们也没有成员啊,为什么这里的大小是 4 字节呢?
那原因肯定和 virtual 有关系喽!
虚函数表
当一个类中包含有虚函数时,编译器在编译阶段会为该类创建一张表叫做虚函数表,简称虚表(virtual function table, VFT);
虚函数表的本质就是一个函数指针数组(nullptr结尾),作用就是存储当前类中的虚函数地址
虚函数表指针
每个包含虚函数类的对象都有一个指向其类的虚函数表的指针,称为虚函数表指针,简称虚表指针(VFPTR)
虚函数表指针一般存储在对象模型的开始位置!它的作用是:对象运行时通过它找到虚表中的对应虚函数进行调用!
• 子类的虚表是如何生成的?
1、子类会将父类的虚函数表继承一份(拷贝一份)
2、如果子类重写了父类的虚函数,则将重写的虚函数地址覆盖掉父类虚函数的地址
3、如果派生类有自己额外的虚函数,则依次添加在子类虚表的结尾
4、非虚函数他是不会被放进虚函数表的
注意:派生类一般是不自己生成虚表的,而是直接继承父类的!
这里的第二点其实就是重写为什么叫覆盖的原因,重写是语法叫的,覆盖是底层叫的~!同时,第二点也是实现多态的核心!
我们可以举个例子,用监视窗口看看:
class A
{
public:
virtual void Func1()
{
cout << "Func1" << endl;
}
virtual void Func2()
{
cout << "Func2" << endl;
}
void Func3()
{
cout << "Func3" << endl;
}
};
class B : public A
{
public:
virtual void Func1() override
{
cout << "Func1" << endl;
}
};
int main()
{
A a;
B b;
return 0;
}
当然可以加一些字段验证来验证虚表指针在对象模型的最开始
虚表是以 nullptr 结尾的,我们也可以打印出来看看,顺便玩一玩C语言指针的高级玩法!
typedef void(*VFPTR)();// 将void(*)() 的函数指针 重定义为 VFPTR
void PrintVFT(VFPTR vf_table[])// 这里是一个指针, 可以写成 VFPTR* vf_table
{
int i = 0;
while (vf_table[i])
{
printf("%p -> ", vf_table[i]);
VFPTR f = vf_table[i];
f();// 调用
i++;
}
}
int main()
{
A a;
B b;
PrintVFT((VFPTR*)*(int*)&a);// 将对象取地址,然后强转为4个字节,然后解引用取前四个字节并强转为VFPTR*
PrintVFT((VFPTR*)*(int*)&b);
return 0;
}
如果这个指针你可以搞出来,那你的指针水平可以说是很强了!
注意:当前是 32位 如果在64位需要转为8个字节!
• 虚函数表补充
1、虚函数表是在编译阶段生成的
2、虚函数表是在对象初始化走初始化列表时初始化的
3、虚表指针属于对象,虚表属于类,一般存在常量区
前两点都很好理解,也很好验证,但是第三点真不好验证,我们这里采用对比法验证:
int main()
{
//验证虚表的存储位置
A aa;
B bb;
int a = 10; //栈
int* b = new int; //堆
static int c = 0; //静态区
const char* d = "xxx"; //常量区
printf("a-栈地址:%p\n", &a);
printf("b-堆地址:%p\n", b);
printf("c-静态区地址:%p\n", &c);
printf("d-常量区地址:%p\n", d);
printf("p 对象虚表地址:%p\n", *(VFPTR**)&aa);// 等价于 (VFPTR*)*(int*)&aa
printf("s 对象虚表地址:%p\n", (VFPTR*)*(int*)&bb);
return 0;
}
和常量区非常近,所以一般认为 虚函数表存储在常量区,属于类!
4.2 多态原理
当有了虚表指针和虚表我们可以理解多态的调用原理了!
1、当父类的指针/引用, 指向/引用父类对象时,去父类对象的虚表调用对应的虚函数
2、当父类的指针/引用, 指向/引用子类对象时,去子类对象的虚表调用重写的虚函数
3、当使用父类对象调用子类额外的虚函数时,是调不到的,不构成多态!
可以把父类的指针/引用子类的对象,去查找虚表的过程认为是对象切片:
将子类中父类的那一部分(虚表和其他)切出来给父类,父类去对象首部找到虚表指针,然后在子类虚表中索引调用的虚函数,如果有就调用,没有就去父类查找(有就调用,没有报错)
为什么说是认为是一次对象切片呢?其实这里并不是切片,因为父类指针或引用指向子类对象时,子类对象的首部是继承下来的父类的那一部分,也就是说子类对象的首部依然是虚表指针!和父类指针/引用调用父类对象是没有区别的!这也就做到了指向谁就调用谁的效果,即实现多态!
• 两个思考问题
1、为什么父类对象的指针/引用调不到子类对象的额外虚函数?
因为此时不构成多态,构成多态的两条件:1、重写父类的虚函数 2、父类的指针/引用调用
所以就不会去子类中查找虚表!而是直接根据调用的类型去对应的类查找!
2、为什么将子类对象切片给父类对象,实现不了多态效果?
首先还是构不成多态!这一点毋庸置疑,但是为什么这里就构不成多态,而非要是父类的指针/引用才能构成呢?
原因在于当对象切片时,只会将子类中父类的成员给切出来,会把其他子类独有的(包含虚表)直接丢弃!对象切片的本质是创建一个新的父类对象,并将子类中父类的那部分给这个新的对象,虚表丢弃了,只给了成员!所以使用对象切片是无法实现多态的!
4.3 静态绑定和动态绑定
静态绑定又称前期绑定/静态多态:在程序编译期间就确定了调用的形式。例如:函数重载、模版
动态绑定又称后期绑定/动态多态:在程序运行期间才去确定调用的形式。例如:多态在运行时才去查找虚表中的虚函数!
我们可以利用汇编查看他两的区别:
静态绑定应该是,编译完成直接 call 的,而动态绑定是运行时采取找的,即不是直接call的:
class A
{
public:
void f()
{
cout << "A::f() is called." << endl;
}
void f(int x)
{
cout << "A::f(int) is called with value: " << x << endl;
}
};
int main() {
A a;
a.f(); // 调用第一个f()
a.f(10); // 调用第二个f(int)
return 0;
}
再来一个动态绑定的,即多态的例子:
class A
{
public:
virtual void f()
{
cout << "A::f() is called." << endl;
}
void f(int x)
{
cout << "A::f(int) is called with value: " << x << endl;
}
};
class B : public A
{
public:
virtual void f()
{
cout << "B::f() is called." << endl;
}
};
5、多继承与多态
上面介绍的点多态原理是基于单继承的,其中子类将父类的虚表和虚表指针继承下来,用子类中的重写的虚函数的地址覆盖掉当父类中的虚函数地址,然后将子类自己的虚函数放到继承下来虚表的结尾!这很简单,那如果继承关系不是单继承而是多继承呢?下面我们就来讨论一下多继承的情况!
5.1 多继承与多态
我们直接来一个多继承的栗子
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;
};
此时,Derive 就是多继承的情况!因为是先继承的先存放,对象模型如下:
这里就有两个问题:
1、func3 应该存放在哪一张虚表?
2、子类重写的func1应该如何覆盖?覆盖在继承下来的那一个虚表?
因为VS 的监视窗口不会对子类的虚函数进行在虚表中显示,所以我们直接打印地址:
typedef void(*VFPTR) ();
void PrintVTable(VFPTR vTable[])
{
cout << " 虚表地址>" << vTable << endl;
for (int i = 0; vTable[i] != nullptr; ++i)
{
printf(" 第%d个虚函数地址 :0x%p,->", i, vTable[i]);
VFPTR f = vTable[i];
f();
}
cout << endl;
}
int main()
{
Derive d;
VFPTR* vTableb1 = (VFPTR*)(*(int*)&d);
PrintVTable(vTableb1);
VFPTR* vTableb2 = (VFPTR*)(*(int*)((char*)&d + sizeof(Base1)));
PrintVTable(vTableb2);
return 0;
}
我们可以看到:
1、当多继承时,会将子类重写的函数覆盖在第一个继承的虚表中
2、会将子类额外的虚函数放在第一个继承虚表的结尾
按理来说:子类重写之后,因该是把两个继承下来的虚表中的func1的地址进行都覆盖,也就是两个虚表中的func1的地址应该是一样的,这样才能实现多态的效果!但是我们发现这里两个虚表中的 func1的地址明显不一样!那他们是如何实现多态效果的呢?
这里他是这样做的:
1、当第一个继承类的指针/引用访问时,当前虚表指针就在对象的头部,直接查找虚表然后访问
2、当第二个继承累的指针/引用访问时,虚表不在当前对象的头部,需要将当前对象偏移第一个继承类的大小,才能访问到
这也是我么上面打印出他们各自虚表的原理!
5.2 虚继承与多态
在虚继承中存在 虚基表和虚基表指针,在多态这里又有虚表和虚表指针,这很容易混淆,下面来梳理一下:
1、virtual
虚函数 与 虚继承 共用一个关键字 virtual ,就好像 引用和取地址一样,没多大关系
2、指针
虚表指针 全称 虚函数表指针,指向的是虚函数表/虚表
虚基表指针 指向 虚基表的指针
当出现菱形虚拟继承和多态时,虚基表指针放在虚表指针的上面
3、表
虚表/虚函数表:是一个函数指针数组存的是类中虚函数的地址
虚基表:用来共享间接基类的数据的,里面存的是 当前对象虚基表指针距离共享数据的偏移量
在虚基表中,第一个位置会空出来,存储一个虚基表指针与虚表指针的偏移量
此时D的对象模型结构图如下:
最后的虚拟继承和多态这里,校招面试中不会考的!我们只是分析一下!如果最后这里不明白也没有关系!
OK,本期分享就到这里,我是 cp 我们下期再见!