往期文章:
《C++ 模版进阶》 链接:http://t.csdnimg.cn/4UBQl
《C++ 继承第一弹》链接:http://t.csdnimg.cn/tNZs7
目录
前言
本文讲解C++三大特性之一多态。多态是C++的灵魂所在,它使得程序设计更加抽象、灵活,能够处理更为复杂的问题。接下来,让我们一起揭开多态的神秘面纱,探索它的应用。
1. 多态的概念
多态的概念:通俗来说,就是多种形态,具体点就是去完成某个行为,当不同的对象去完成时会产生处不同的状态。
比如说:猫来叫发出的声音是“喵喵”,狗发出的叫声是“汪汪”,牛发出的声音是“哞哞”。不同的动物发出的叫声不尽相同,这就相当于不同的对象去完成某个行为,产生的不同状态。
2. 多态定义及实现
2.1 虚函数
虚函数:被virtual修饰的类成员函数称为虚函数。
class Animal {
public:
virtual void sound()
{
cout << "Animal makes a sound" << endl;
}
};
2.2 虚函数重写
虚函数的重写(覆盖):派生类中有一个跟基类完全相同的虚函数,即派生类虚函数与基类虚函数的返回值类型、函数名字、参数列表完全相同,称子类的虚函数重写了基类的虚函数。
在重写基类函数时,派生类的虚函数在不加virtual关键字时,也构成重写。因为继承后基类的虚函数被继承袭来在派生类依旧保持虚函数属性。但是这样子写不规范,建议加上virtual关键字。
class Animal {
public:
virtual void sound()
{
cout << "动物叫" << endl;
}
};
class Dog : public Animal {
public:
virtual void sound()
{
cout << "汪汪" << endl;
}
};
class Cat : public Animal {
public:
virtual void sound()
{
cout << "喵喵" << endl;
}
};
虚函数重写有两个例外:
- 协变:派生类重写基类虚函数时,与基类虚函数返回值类型不同。基类虚函数返回基类对象指针或者引用,派生类函数返回派生类对象基类对象指针或者引用。
- 析构函数的重写,继承中讲到,编译器对析构函数名称做特殊处理,所有析构函数名称统一为destructor。所以不管加不加virtual关键字,基类和派生类的析构函数都构成重写。这是为了解决下面的场景。
class Person {
public:
virtual ~Person() {
cout << "~Person()" << endl;
}
};
class Student : public Person {
public:
virtual ~Student() {
cout << "~Student()" << endl;
}
};
// 只有派生类Student的析构函数重写了Person的析构函数,
// 下面的delete对象调用析构函数,才能构成多态,
// 才能保证p1和p2指向的对象正确的调用析构函数。
int main()
{
Person* p1 = new Person;
Person* p2 = new Student;
delete p1;
delete p2;
return 0;
}
2.3 构成多态的条件
多态是在不同继承关系的类对象,去调用同一函数,产生了不同的行为。比如Dog继承了Animal。Animal对象是泛指,叫声未知。Dog类叫声是汪汪,Cat类叫声是喵喵。
那么在继承中要构成多态还有两个条件:
- 通过基类的指针或者引用调用虚函数
- 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写
2.4 C++11 override 和 final
从上面可以看出,C++对函数重写的要求比较严格,但是有些情况下由于疏忽,可能会导致函数名字母次序写反而无法构成重写,而这种错误在编译期间是不会报出的,只有在程序运行时没有得到预期结果才来debug会得不偿失。因此,C++提供了override和final两个关键字,可以帮助用户检测是否重写。
- final:修饰虚函数,表示该虚函数不能再被重写
class Animal {
public:
virtual void sound() final
{
cout << "动物叫" << endl;
}
};
class Dog : public Animal {
public:
virtual void sound()
{
cout << "汪汪" << endl;
}
};
- override: 检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错。
class Animal {
public:
virtual void sound()
{
cout << "动物叫" << endl;
}
};
class Dog : public Animal
{
public:
virtual void sound() override
{
cout << "汪汪" << endl;
}
};
2.5 重载、覆盖(重写)、隐藏(重定义)的对比
重载:
- 两个函数在同一个作用域下。
- 函数名相同,参数类型,个数不同。
重写(覆盖):
- 两个函数分别在基类和派生类的作用域中。
- 两个函数必须是虚函数。
- 函数名,参数,返回值都必须相同(除了两个例外)。
重定义(隐藏):
- 两个函数分别在基类和派生类的作用域中。
- 函数名相同即可。
3. 抽象类
概念:在虚函数的后面写上=0,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化对象。派生类继承后不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现了接口继承。
如下面代码所示,Animal类泛指所有动物,不是某个具体的动物。现实中,许多概念都是泛指。
class Animal //抽象类
{
public:
//纯虚函数
virtual void sound() = 0;
};
class Dog : public Animal
{
public:
virtual void sound()
{
cout << "汪汪" << endl;
}
};
class Cat : public Animal
{
public:
virtual void sound()
{
cout << "喵喵" << endl;
}
};
void Test()
{
Animal* pdog = new Dog;
pdog->sound();
Animal* pcat = new Cat;
pcat->sound();
}
抽象类是一种特殊的类,它在面向对象编程中扮演着蓝图或模板的角色。一个抽象类至少包含一个纯虚函数,这使得它不能被直接实例化。抽象类的主要目的是为一系列具有共同特征和行为的派生类提供一个公共的接口框架。通过声明纯虚函数,抽象类规定了派生类必须实现的特定方法,从而确保了派生类能够满足特定的功能需求。
4. 多态的原理
4.1 虚函数表
Base类对象的所占内存字节数是多少呢?
class Base
{
public:
virtual void Func1()
{
cout << "Func1()" << endl;
}
private:
int _b = 1;
};
int main()
{
Base b;
cout << sizeof(Base) << endl;
return 0;
}
运行结果如上,sizeof(Base)大小是8个字节。下图的监视窗口中,除了_b成员,还有一个_vfptr放在对象前面,对象中的这个指针我们叫做虚函数表指针(v代表virtual,f代表function)。
一个含有虚函数的类中至少都有一个虚函数表指针,因为虚函数的地址要被放到虚函数中,虚函数表也简称虚表。派生类中这个表放了些什么呢?
我们对上面的代码进行改造。
- 加一个Derive类去继承Base。
- Derive类中重写Fun1函数。
- Base类中,增加一个Fun2虚函数和一个Fun3普通函数。
class Base
{
public:
virtual void Func1()
{
cout << "Base::Func1()" << endl;
}
virtual void Func2()
{
cout << "Base::Func2()" << endl;
}
void Func3()
{
cout << "Base::Func3()" << endl;
}
private:
int _b = 1;
};
class Derive : public Base
{
public:
virtual void Func1()
{
cout << "Derive::Func1()" << endl;
}
private:
int _d = 2;
};
int main()
{
Base b;
Derive d;
return 0;
}
下面是调试这段代码,监视窗口中的内容,通过观察,我们可以发现一些规律。
- 派生类对象d由两部分组成,一部分是父类继承下来的成员,虚表指针就在里面,另一部分是自己的成员。
- 通过对比,我们发现基类b对象和派生类d对象的虚表是不一样的。因为Fun1函数完成了重写,所以d的虚表中第一个存放的虚函数指针跟b的不同,虚函数的重写也叫覆盖。覆盖就是指虚表中虚函数的覆盖。
- 另外Fun2被继承下来,没有完成重写,所以b对象和d对象虚表中第二个存放的地址相同。Fun3是普通函数,不会放进虚表里面。
- 虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后放一个nullptr。
总的来说,先将基类中的虚表内容拷贝一份到派生类虚表中,如果派生类重写了基类中某个虚函数,会用派生类的虚函数覆盖虚表中基类的虚函数,派生类自己新增加的虚函数按在派生类中声明的次序增加到虚表的最后。
还需要注意的是,虚表存的是虚函数指针,不是虚函数,虚函数和普通函数一样,存放在代码段,只是他们的指针存放在虚表中。且对象中存的不是虚表,存的是虚表指针。那么虚表在哪里呢?我们可以写段代码验证一下。
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;
printf("Base虚表地址:%p\n", *((int*)&b));
printf("Derive虚表地址:%p\n", *((int*)&d));
return 0;
}
运行结果如下,我们通过比较各个区的地址,虚表应该存放在常量区中。
4.2 多态的原理
我们了解完虚函数表后,该揭开多态的面纱了。运用一开始的动物叫声的例子。
- 如下图,当an指向d对象时,就向红色线指引的一样,an->sound在d对象的虚表中找虚函数Dog::sound。当an指向c对象时,也类似,an->sound在虚表中找虚函数Cat::sound。
- 这样就实现了不同对象完成同一行为时,展现不同的情况。
- 要达到多态,需要虚函数的覆盖,和对象指针或医用调用虚函数。
- 下面我们通过汇编代码分析,发现满足多态的函数调用,不是在编译时确定的,是在运行中对象里去找的。
4.3 动态绑定和静态绑定
- 静态绑定是在程序编译期间确定了程序的行为,也成静态多态,如函数重载。
- 动态绑定是在程序运行期间,根据具体拿到的类型确定程序具体行为,调用具体的函数,也称为动态多态。
总结
多态是C++面向对象编程中的一个关键特性,其中的弯弯绕绕十分多,需要细心的学习。建议多上手敲代码并调试体会,通过实践来深化对多态概念的理解。
创作不易,希望这篇文章能给你带来启发和帮助,如果喜欢这篇文章,请留下你的三连,你的支持的我最大的动力!!!