【C++进阶】多态
🥕个人主页:开敲🍉
🔥所属专栏:C++
🌼文章目录🌼
1. 多态的概念
多态((polymorphism)的概念:通俗来说,就是多种形态。多态分为 编译时多态(静态多态) 和 运行时多态(动态多态),本章重点在于运行时多态(动态多态)。编译时多态(静态多态)主要就是函数重载和函数模板,它们传不同类型的参数就可以调用不同的函数完成不同的功能,之所以叫编译时多态(静态多态),是因为实参传给形参的参数匹配是在编译时完成的,我们将编译时归为静态,运行时归为动态。
运行时多态,具体就是去完成某个行为(函数),传不同的对象就会完成不同的行为,实现多种形态。比如买票:普通人买票,买的就是全价票;学生买票,可以买到折扣票;军人买票时,可以优先买票。
2. 多态的定义及实现
2.1 多态的构成条件
多态是一个继承关系下的类和对象,去调用同一个函数,产生了不同的行为。比如:
2.1.1 实现多态的两个必要条件
实现多态有两个必要条件,缺少其中一个都没法构成多态:
① 必须是父类(基类)的指针或引用调用虚函数
② 被调用的函数必须是虚函数
补充说明:要实现多态效果,第⼀必须是基类的指针或引⽤,因为只有基类的指针或引⽤才能既指向派⽣类对象;第⼆派⽣类必须对基类的虚函数重写/覆盖,重写或者覆盖了,派⽣类才能有不同的函数,多态的不同形态效果才能达到。
2.1.2 虚函数
类的成员函数前加上 virtual 关键词修饰后,这个成员函数就称之为虚函数。
注意:非成员函数不能用 virtual 修饰。
2.1.3 虚函数的重写/覆盖
虚函数的重写/覆盖:派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的 函数名、返回值、参数类型完全相同),那么就称 派生类的虚函数重写了基类的虚函数。
注意:在重写基类虚函数时,派生类的虚函数在不加 virtual 关键字时虽然也构成重写(因为继承后,基类的虚函数属性也继承了下来,因此派生类依旧保持虚函数属性),但是这种写法不规范,不建议这样使用。
完成了对基类的重写后,我们就可以很方便的访问到我们想要访问的函数:
2.1.4 多态场景的一个选择题
出这道选择题的人可以说是非常阴间了,坑到你人麻:
以下程序的输出结果是什么?()
A. A->0 B. B -> 1 C. A -> 1 D. B -> 0 E. 编译报错 F. 以上都不对
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;
}
正确答案:B
我们来分析一下,为什么选B:
首先 B 中的 虚函数是重写了 A 的虚函数,p的指针类型是B类类型的,指向的也是 一个 B 的地址。因为B继承了A,因此我们可以调用test函数。随后 test 函数调用 func 函数,问题就在于,这里调用的是哪个 func 函数呢?
首先我们知道 test 中的 func 函数是通过 this 指针来调用的,同时我们也知道,p指向的是B,因此我们可以很容易知道 这里的 this 调用的就是 B 中的func,并且,由于这里 this 是 B 的指针,而不是父类类型的指针或者引用,因此这里也不会构成多态,那么答案显然应该选D。就算答案不是D,那也应该是C啊,A对应的是1,B对应的是0,怎么会搞出个 B -> 1 呢?
想要弄明白这点,我们就需要对继承深入了解。在我们之前学习继承的时,我们的理解是,父类中的成员在子类中进行了拷贝,因此子类中存有父类成员的一份拷贝,因此我们在子类中可以调用父类的成员。但实际上不是这样的,说父类的成员在子类中有一份拷贝只是为了方便我们理解继承的含义。实际上更加准确的说法是——共用,父类中的成员是父类和子类共用的。因此,实际上不管在子类还是父类中,从始至终都只有一份父类的成员。
因此,我们这里的 p 在调用 test 函数时,实际上调用的就是父类中的 test 函数,因此 this 指针也就是父类的 this,因此,实际上这里是构成了多态的,调用的就是 B 中的 func 函数。
但是,又有一个问题了,既然调用的就是 B 的 func 函数,那不是应该输出 B -> 0 吗?
这个时候又要我们深入了解以下 重写 了。当子类的虚函数对父类的虚函数进行重写时,实际上就相当于 用父类的虚函数的函数头 代替了 子类的虚函数的函数头,如下:
这也就能解释 为什么子类继承父类时,子类的虚函数前不加 virtual 关键字也带有虚函数属性,因为发生了替换。
同时这也就能解释为什么父类的虚函数前必须带有 vritual 关键字。
这样,上面那道题的问题也就迎刃而解了:因为调用的是父类的 test ,构成多态,调用了子类的 func,因此输出 B -> ;因为发生了替换,func(int val = 0) 变成了 func(int val = 1),因此输出 1。
2.1.5 虚函数重写的一些其他问题
1、协变(了解即可):
派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或引用,派生类虚函数返回派生类对象的指针或引用时,称为协变。协变的实际意义不大,了解即可。
2、析构函数的重写:
基类的析构函数为虚函数,此时派生类析构函数只要定义了,不管是否加 virtual 关键字,都与基类的析构函数构成重写:
这时候你肯定有个疑问:不对啊,构成重写不是要函数名相同吗?这里函数名都不相同为什么构成重写。
这是因为,编译器在编译时对析构函数进行了特殊处理,编译后的所有析构函数都统一叫作 destructor ,因此只要基类的析构函数加了 vritual 关键字称为虚函数,派生类的析构函数就与基类构成重写。
2.1.6 override 和 final 关键字
从上面的重写要求就可以看出,C++对重写的要求比较严格,但是我们在有些情况下可能会因为一点小失误导致无法构成重写,比如:函数名写错
这里我们函数名写错了,但是一般很难看出来,编译器也不会报错,那可能就会导致出现找bug找半天的情况。这个时候我们就可以用到 override 关键字,这是C++11开始提供的关键字,可以帮助我们检查是否构成了重写。
这个时候编译器就会找出问题。
并且,如果我们不想基类的虚函数让派生类去重写,就可以在后面加上 final 关键字:
2.1.7 重载/重写/隐藏的对比
3. 纯虚函数和抽象类
在虚函数后面加上 =0,则这个函数称之为纯虚函数,纯虚函数不需要定义实现(实现也没啥意义,因为要被派生类重写才能使用),只需要声明即可。包含纯虚函数的类叫做抽象类,抽象类不能实例化出对象:
4. 多态的原理
4.1 虚函数表指针
先来看一道题:
下面编译为32位程序的运行结果是什么?()
A. 编译报错 B. 运行报错 C. 8 D. 12
class A
{
public:
virtual void func() {}
private:
int _a;
char _b;
};
int main()
{
A a;
cout << sizeof(a) << endl;
return 0;
}
正确答案:D
A类中放了一个虚函数 func 、一个整型变量 _a 、一个字符型遍历 -b。
在我们之前学习C++时,知道成员函数并不是放在类中的,因此计算类的大小时只会计算成员变量。
那么按照我们之前学习的知识来做这道题肯定是觉得选C:int类型占4个字节,char类型占1个字节,共计5个字节,然后内存对齐成4的倍数,8个字节。
但是这里为什么是12字节呢?因为虚函数在类中会存放一个叫做虚函数表指针的东西,指针是占4个字节的,因此答案是12字节。
这个 _vfptr 就是虚函数表指针。那么它是干什么用的呢?
实际上这个指针指向的是一个叫做 虚函数表的数组,这个数组里面存放的就是 虚函数的地址 ,因此这个数组也可以叫做 虚函数指针数组。
之所以要有这个数组是因为:
当一个继承体系中含有多个虚函数时,将这些虚函数全部存入同一个虚函数表中(注意:一个继承体系有一个虚函数表),基类以及派生类中会存放一个指向虚函数表的指针,当调用虚函数时,根据调用的对象去虚函数表中查找对应的虚函数的地址。
4.2 多态的原理
4.2.1 多态是如何实现的
从底层角度,我们是如何调用不同的func的呢?如何 当我们的 _vfptr 指向 A 对象时调用 A::func,指向 B 对象时调用 B::func的呢?
通过上面的图我们可以看到,满足多态条件后,底层不再是通过编译时通过调用对象确定函数的地址,而是运行时到指向的对象的虚表中确定对应的虚函数的地址,这样就实现了指针或引用指向基类就调用基类的虚函数,指向派生类就调用派生类的虚函数。第一张图,ptr指向的Person对象,调用的是 Person 的虚函数;第二张图,ptr指向的Student对象,调用的是Student的虚函数。
4.2.2 动态绑定与静态绑定
① 对不满足多态条件的函数调用是在编译时绑定,也就是编译时确定调用函数地址,叫做静态绑定。
② 满足多态条件的函数调用是在运行时绑定(动态绑定),也就是在运行时到指定对象的虚函数表中找到调用函数的地址,叫做动态绑定。
4.2.3 虚函数表
① 基类对象的虚函数表中存放基类对象的所有虚函数的地址。
② 派生类由两部分构成:继承下来的基类和自己的成员。一般情况下,继承下来的基类中有虚函数表指针,自己就不会再生成虚函数表指针。但是要注意的这里 继承下来的基类部分虚函数表指针 和 基类对象的虚函数表指针 不是同一个,就像 基类对象的成员 和 派生类对象中的基类对象成员 也独立。
③ 派生类中重写的基类和虚函数,派生类的虚函数表中 基类对应的虚函数 就会被覆盖成派生类重写的虚函数地址。
④ 派生类的虚函数表中包含:基类的虚函数地址、派生类重写的虚函数地址、派生类自己的虚函数地址 三个部分。
⑤ 虚函数表本质是一个 存指向虚函数地址的指针的指针数组,一般情况下这个数组最后面放了一个0x00000000标记。(这个不是C++规定的,是由各个编译器定义的,VS系列编译器会在后面放,g++则不会)
⑥ 虚函数存在哪?虚函数和普通函数是一样的,编译好后就是一段指令,因此和普通函数一样都是存放在代码段的,只不过虚函数的地址又存放在了虚函数表里。
⑦ 虚函数表存在哪?这个问题也没有标准答案,在VS中,是存放在常量区的。
下面一段代码也能够验证 ⑥、⑦点:
class Base {
public:
virtual void func1() { cout << "Base::func1" << endl; }
virtual void func2() { cout << "Base::func2" << endl; }
void func5() { cout << "Base::func5" << endl; }
protected:
int a = 1;
};class Derive : public Base
{
public:
// 重写基类的func1
virtual void func1() { cout << "Derive::func1" << endl; }
virtual void func3() { cout << "Derive::func1" << endl; }
void func4() { cout << "Derive::func4" << endl; }
protected:
int b = 2;
};
int main()
{
int i = 0;
static int j = 1;
int* p1 = new int;
const char* p2 = "xxxxxxxx";
printf("栈:%p\n", &i);
printf("静态区:%p\n", &j);
printf("堆:%p\n", p1);
printf("常量区:%p\n", p2);Base b;
Derive d;
Base* p3 = &b;
Derive* p4 = &d;
printf("Person虚表地址:%p\n", *(int*)p3);
printf("Student虚表地址:%p\n", *(int*)p4);
printf("虚函数地址:%p\n", &Base::func1);
printf("普通函数地址:%p\n", &Base::func5);
return 0;
}