C++面向对象特性之多态篇
今天带来的是关于C++面向对象特性之多态性的有关知识的介绍。
一、多态的理解
多态即多种形态,分为编译时的多态即静态多态和运行时多态即动态多态。
函数重载和函数模版就是常见的编译时多态,通过传不同类型的参数就可以实现调用不同类型的函数,因为传不同类型的参数就会对应地匹配或推导不同类型的函数,这个过程是在编译阶段完成的,因此称为编译时多态。
而运行时多态,即完成某个行为,或调用某个函数时,传不同的对象会完成不同的行为。这里就又跟继承有一点关系。比如一个函数的参数是父类的指针或引用,既可以传子类对象也可以传父类对象,但是这两种对象可能会产生不同的结果。具体到现实生活中的例子:高铁买票,普通人买全价,而学生买会有折扣,这里面学生属于人这个类的派生类,但是调用“买票”这个函数,结果却不同。
那么先来看一个多态的例子,我们先看结果,哪个对象调用Test函数那么就调用它自己所属类的Buyticket函数。
class Person
{
public:virtual void Buyticket(){cout << "全价票" << endl;}
};class Student : public Person
{virtual void Buyticket(){cout << "学生票" << endl;}
};void Test(Person& p)
{p.Buyticket();
}int main()
{Person p;Student q;Test(p);Test(q);return 0;
}
二、多态的定义及构成
1、多态的定义
多态是一个继承关系下的类的对象,去调用同一函数,产生了不同的行为。
2、多态的构成条件
在了解多态的形成条件之前,我们需要了解一下什么是虚函数以及虚函数的重写。
(1)、虚函数
在类成员函数面前加上virtual关键字,那么这个成员函数就成为了虚函数。非成员函数不能用虚函数修饰。
class Person
{
public:virtual void Buyticket(){cout << "全价票" << endl;}
};
那么这就是一个使用虚函数的例子。
(2)、虚函数的重写(覆盖)
当我们在派生类中实现一个跟基类完全相同(派生类虚函数与基类虚函数返回值类型相同、函数名字相同、参数列表完全相同)的虚函数,那么这时候就称派生类的虚函数重写了基类的虚函数。
注意这里派生类的虚函数不加virtual时也认为构成重写,因为函数声明相当于是从基类继承下来的,这里会容易产生误会。
(3)、实现多态的重要条件
必须是基类的指针或基类的引用调用虚函数
被调用的必须是虚函数并且该虚函数已经实现重写
我们前面提到是一个继承关系下的类的对象去调用同一函数才是,那么这里必须使用基类的指针额或基类的引用调用虚函数,因为在继承那里我们知道只有基类的指针或引用可以既指向基类对象又指向子类对象,只不过子类那里有一个切片而已。
为何要实现重写?因为只有重写才相当于实现了两个不同的函数,不然只有一个函数怎么实现多态的行为。
3、关于多态的一道题
以下程序的输出结果是什么?
A: A->0 B:B->1 C:A->1 D:B->0 E:编译出错 F:以上都不正确
class A
{
public:virtual void func(int val = 1) { std::cout << "A->" << val << std::endl; }virtual void test() { func(); }
};
class B : public A
{
public:void func(int val = 0) { std::cout << "B->" << val << std::endl; }
};
int main()
{B* p = new B;p->test();return 0;
}
我们来一步步分析,创建了一个B对象的指针,通过指针调用test函数,这个test函数在B中是从A中继承下来的,在test函数中调用func函数,由于是B对象调用的,所以是调用B的func函数,结果就是输出B->0,对吗,大部分人应该这样想的。但是这样就错了,正确输出答案是B->1。
这是为什么?我们重新来看,前面我们提到,即使B的func函数没有加virtual,他也完成了虚函数的重写,有人说这里的参数不是不同吗,为什么构成重写,我们说的是参数列表类型,跟他的缺省值没有关系,所以这里B的func完成了虚函数的重写。我们还提到说这个函数声明是从基类继承下来的,也就是这里B中的func函数声明是A的,所以参数val缺省值就用的是1,所以输出了B->1,如果这里实在想要输出B中的,可以显示传参,或者修改A中的缺省值跟B中一样。
三、虚函数的一些其他问题
1、协变
有一种特殊情况,派生类重写基类函数时,可以与基类虚函数返回值类型不同。基类虚函数对象返回基类对象的指针或引用,派生类虚函数返回派生类对象的指针或引用,这种情况称之为协变。什么意思呢,我们来看。
class A
{};
class B :public A
{};class Person
{
public:virtual A* Buyticket(){cout << "全价票" << endl;return nullptr;}
};class Student :public Person
{
public:virtual B* Buyticket(){cout << "学生票" << endl;return nullptr;}
};void func(Person& p)
{p.Buyticket();
}int main()
{Person p;Student s;func(p);func(s);
}
这里我们Person中Buyticket的返回值类型是A*,Student中Buyticket的返回值类型是B*,A和B是构成父子关系的,B继承A,只要满足这样父子的继承关系就可以分别作为基类和派生类同一个虚函数的不同返回类型,这就构成了协变,协变是多态的一种体现,这里也满足多态。
2、析构函数的重写
基类的析构函数建议设计为虚函数
基类的析构函数为虚函数,只要派生类的析构函数定义,那么就会与基类的析构函数构成重写。又有人说了,这不是不满足前面说的函数名相同的条件吗,但是编译器会将析构函数的名称统一处理成destructor,因此只要基类析构函数加virtual,那么就构成虚函数重写。
class A
{
public:virtual ~A(){cout << "~A()" << endl;}
};
class B : public A {
public:~B(){cout << "~B()->delete:" << _p << endl;delete _p;}
protected:int* _p = new int[10];
};
int main()
{A* p1 = new A;A* p2 = new B;delete p1;delete p2;return 0;
}
这里可以看到结果正常,对于B先调用派生类的析构函数,再调用基类的析构函数。
但是如果把A的析构函数前面的virtual去掉,那么如图,没有正确实现析构,造成了内存泄漏,后果很严重。
因为不加virtual,没有构成虚函数的重写,没有构成多态,这里的p1和p2都是A*,那么delete p2时只调用了A的析构函数,没有调用B的析构函数,造成了内存泄漏。只有当派生类的析构函数重写了基类的析构函数,delete对象调用析构函数时,才能构成多态,才能保证p1和p2指向的对象正确调用析构函数。
3、override和final关键字
(1)override
从上面可以看出,C++中虚函数重写的要求还是比较严格的,但是一些错误情况下,可能因为疏忽没有正确地实现虚函数重写,于是引入了override这个关键字帮我们检查是否构成了虚函数重写。
class Animal
{
public:virtual void size()//动物尺寸大小{}
};class cat:public Animal
{
public:virtual void size(int a) override{cout << "大" << endl;}
};int main()
{Animal* a = new cat;a->size();return 0;
}
上图我们在派生类的参数中多加了一个int类型参数,此时不构成虚函数的重写,而我们可以使用override关键字来帮助我们检查出这个错误。
(2)final
如果我们不想让派⽣类重写基类的某个虚函数,那么可以⽤final去修饰这个虚函数。
class A
{
public:virtual void func() final{}
};class B:public A
{
public:virtual void func(){}
};
final修饰后,就不能再重写这个虚函数,如下图错误。
四、纯虚函数和抽象类
在虚函数的后⾯写上=0,则这个函数为纯虚函数,纯虚函数不需要定义实现,只要声明即可。因为实现了也没啥意义,后面还要被派生类重写。包含纯虚函数的类叫做抽象类,抽象类不能实例化出对象,如果派生类没有重写纯虚函数,那么派生类也是抽象类。
class Shape
{
public:virtual void get_size_bian() = 0{}
};class rectangle :public Shape
{
public:virtual void get_size_bian(){cout << 4 << endl; //矩形是四边形}
};
int main()
{//Shape A; 编译报错rectangle r;r.get_size_bian();return 0;
}
如果实例化抽象类的对象就会产生编译错误,如图
因此纯虚函数某种程度上强制了派生了重写虚函数,不重写虚函数的话派生类也是抽象类不能实例化对象出来。
五、多态的原理
1、虚函数表指针
先来看这段代码在VS2022编译器X64环境下的输出是什么
class A
{
public:virtual void func(){cout << "func()" << endl;}
private:int _a;char _ch;
};int main()
{A a;cout << sizeof(a) << endl;return 0;
}
类里面的成员变量_a占四个字节,_ch占一个字节,结合对齐的规则,我们是不是认为应该占8个字节,但是实际输出后发现结果是16,如果在X86环境下就是12个字节,那么这是为什么呢?
通过调试窗口观察发现,在a对象里面除了我们定义的_a和_ch变量外,还多了一个指针_vfptr,我们知道指针变量在X86环境下占4个字节,X64环境下占8个字节,这就是为什么两次输出结果不同的原因。这个多出来的指针就叫做虚表指针,指向了对应的虚函数地址。
一个含有虚函数的类中至少有一个虚函数表指针,因为一个类所有虚函数地址要被放到这个类对象的虚函数表中。
2、多态如何实现
我们还是以Person类与Student类作为例子来说明
class Person
{
public:virtual void Buyticket(){cout << "全价票" << endl;}
};class Student :public Person
{
public:virtual void Buyticket(){cout << "学生票" << endl;}
};void func(Person& p)
{p.Buyticket();
}int main()
{Person p;Student s;func(p);func(s);return 0;
}
我们还是通过调试窗口来观察
这两个对象p和s分别传给函数func,func的参数是用Person&接收的,虽然都是Person的引用来调用函数,但结果跟这个引用没有关系,而是由其指向的对象决定的。观察两个对象的_vfptr可以看到其值是不同的,同时后面的类域也是不同的。
3、动态绑定与静态绑定
满足多态条件的函数调用是在运行时绑定,也就是在运行时到指向对象的虚函数表中找到调用的虚函数的地址,这也叫做动态绑定。
不满足多态条件的函数调用是在编译时绑定,也就是编译过程就直接确定了调用函数的地址,后面执行时直接根据该地址找到对应的函数,这也叫做静态绑定。
4、虚函数表
关于虚函数表有许多细碎的知识:
1、虚函数表本质就是一个存放虚函数指针的指针数组。
2、基类对象的虚函数表中存放基类所有虚函数的地址,同类型的对象共用一张虚函数表,不同类型的对象有各自的虚函数表。
3、派生类中继承基类的部分中如果有虚函数表指针,自己就不会再生成虚函数表指针。
4、派生类如果重写了基类的虚函数,那么派生类的虚函数表中对应的虚函数地址就会被覆盖为派生类重写的虚函数地址。
5、派生类的虚函数表有三部分分别是:基类的虚函数地址,派生类重写基类虚函数后覆盖的地址,派生类自己特有的虚函数地址。
六、关于重载/重写/隐藏的对比