> 文章列表 > C++ - 多态(1) | 多态的概念、构成条件 、原理

C++ - 多态(1) | 多态的概念、构成条件 、原理

C++ - 多态(1) | 多态的概念、构成条件 、原理

之前的文章中我们讲述了继承有关的知识,在本文中将继续进行C++中多态的学习。

多态的概念

多态的概念:通俗来说,就是多种形态,具体点就是去完成某个行为,当不同的对象去完成时会
产生出不同的状态。举个栗子:比如买票这个行为,当普通人买票时,是全价买票;学生买票时,是半价买票;军人买票时是优先买票。

多态的定义及实现

多态是在不同继承关系的类对象,去调用同一函数,产生了不同的行为。比如Student继承了
Person。Person对象买票全价,Student对象买票半价。

多态构成的条件

在继承中要构成多态还要有两个条件

  1. 必须通过基类的指针或者引用调用虚函数
  2. 被调用的函数必须是虚函数,而且派生类必须对基函数的虚函数进行重写

虚函数

被virtual修饰的类成员函数被称为虚函数。

class Person {
public:virtual void BuyTicket() { cout << "买票-全价" << endl;}
};

虚函数的重写

虚函数的重写(覆盖):派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的
返回值类型、函数名字、参数列表完全相同),称子类的虚函数重写了基类的虚函数。下面是一个多态的例子:

// 虚函数的重写 -- 三同(函数名、参数、返回值)
// 父类的指针或引用调用
class Person {
public:virtual void BuyTicket() { cout << "买票-全价" << endl; }// void BuyTicket() { cout << "买票-全价" << endl; } // err
};class Student : public Person {
public:// virtual void BuyTicket() { cout << "买票-半价" << endl; }void BuyTicket() { cout << "买票-半价" << endl; }
};void Func(Person& p)
{// 不满足多态 -- 看调用者的类型,调用这个类型的成员函数// 满足多态 -- 看指向对象的类型,调用这个类型的成员函数p.BuyTicket();
}

如果我们将父类中的virtual去掉,就无法满足多态的条件,因此也就无法构成多态,就变味了我们之前在继承中学习的隐藏。还有就是如果我们将子类虚函数中的virtual去掉会发现没有如同上面一样报错,反而多态可以正常的运行。虚函数中的重写表示的是接口继承,重写父类这个函数的实现。

虚函数重写的两个例外:

协变(基类与派生类虚函数返回值类型不同)

派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指
针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。

class A{};
class B : public A {};
class Person {
public:virtual A* f() {return new A;}
};
class Student : public Person {
public:virtual B* f() {return new B;}
};

析构函数的重写(基类与派生类析构函数的名字不同)

如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,
都与基类的析构函数构成重写,虽然基类与派生类析构函数名字不同。虽然函数名不相同,
看起来违背了重写的规则,其实不然,这里可以理解为编译器对析构函数的名称做了特殊处
理,编译后析构函数的名称统一处理成destructor。下面看一段简单的代码:

class Person {
public:virtual ~Person() { cout << "~Person()" << endl; }
};class Student : public Person {
public:~Student() { cout << "~Student()" << endl; }
};void Func(Person* p)
{delete p;
}

这就是添加了virtual前后的变化,可以发现在有virtual之前析构只能父类进行析构,这显然是不正确的,这里编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor,因此需要我们进行虚函数的编写。

 C++11 override 和 final

从上面可以看出,C++对函数重写的要求比较严格,但是有些情况下由于疏忽,可能会导致函数
名字母次序写反而无法构成重载,而这种错误在编译期间是不会报出的,只有在程序运行时没有
得到预期结果才来debug会得不偿失,因此:C++11提供了override和final两个关键字,可以帮
助用户检测是否重写。

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 Drive(){}
};
class Benz :public Car {
public:virtual void Drive() override {cout << "Benz-舒适" << endl;}
};

下面展示一道例题非常值得我们看一下:

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(int argc, char* argv[])
{B*p = new B;p->test();return 0;
}

首先,先看在类A中函数都添加了virtual因此都是虚函数,满足了多态的第一个条件,再看使用B的指针调用了从A中继承下来的test函数,其中的func函数使用A类中的this指针进行调用的,因此,满足第二个多态的条件。同时还需要注意的是多态需要继承父类接口,重写的是函数的实现。因此,缺省参数也会被继承,这里的缺省参数使用的就不是0而是1。

重载、覆写(重写)、隐藏(重定义)的对比 

抽象类

概念

在虚函数的后面写上 =0 ,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口
类),抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生
类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。

class Car
{
public:virtual void Drive() = 0;
};
class Benz :public Car
{
public:virtual void Drive(){cout << "Benz-舒适" << endl;}
}; void Test()
{Car car; // errBenz car_benz; 
}

接口继承和实现继承

普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实现。虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口。所以如果不实现多态,不要把函数定义成虚函数。

多态的原理

首先,我们看这样的一个问题:下面的这个类占据的空间是多少?

class Base
{
public:virtual void Func1(){cout << "Func1()" << endl;}private:int _b = 1;char _ch;
};

可以从下图中看到Base类一共占据了12个字节的空间 

虚函数表

通过观察测试我们发现bb对象是8bytes,除了_b,_ch成员,还多一个__vfptr放在对象的前面(注意有些平台可能会放到对象的最后面,这个跟平台有关),对象中的这个指针我们叫做虚函数表指针(v代表virtual,f代表function)。一个含有虚函数的类中都至少都有一个虚函数表指针,因为虚函数的地址要被放到虚函数表中,虚函数表也简称虚表。

多态的原理

来看这样的一段代码:

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 mike;Func(mike);Student johnson;Func(johnson);//mike = johnson;return 0;
}

父类对象的续表存的是父类的虚函数, 子类对象的续表存的是子类的虚函数,这里如果是多态就会在对应的虚表中进行查找。观察下图的红色箭头我们看到,p是指向mike对象时,p->BuyTicket在mike的虚表中找到虚函数是Person::BuyTicket。观察下图的蓝色箭头我们看到,p是指向johnson对象时,p->BuyTicket在johson的虚表中找到虚函数是Student::BuyTicket。这样就实现出了不同对象去完成同一行为时,展现出不同的形态。
 

 在调用BuyTicket时编译器并不知道自己调用的到底是父类还是子类,如果是直接的父类对象就会去调用父类的函数,如果是子类函数对应的切片,就会去调用子类函数的虚函数,因为子类的虚表覆盖了父类的虚表。

那如果时直接使用对象,而不是用引用或指针时会有什么样问题?

mike = johnson;

 在上面子类赋值给父类,在这个过程中子类的虚表并不会拷贝给父类,如果拷贝给父类就会有一个问题,我们自己也不清楚父类中的虚表到底是谁的虚表。我们有一个父类,他可能时子类切片的拷贝,又或者本来就是父类的实例化。因此在多态时不能使用对象来进行处理。