C++ 系列 -- 多态和虚函数

780 阅读16分钟

虚函数和多态的定义

在《C++ 系列 -- 继承和派生》一节中讲到,基类的指针也可以指向派生类对象

#include <iostream>
using namespace std;

// 基类 People
class People{
public:
    People(char *name, int age);
    void display();
protected:
    char *m_name;
    int m_age;
};
People::People(char *name, int age): m_name(name), m_age(age){}
void People::display(){
    cout<<m_name<<"今年"<<m_age<<"岁了,是个无业游民"<<endl;
}

// 派生类 Teacher
class Teacher: public People{
public:
    Teacher(char *name, int age, int salary);
    void display();
private:
    int m_salary;
};
Teacher::Teacher(char *name, int age, int salary): People(name, age), m_salary(salary){}
void Teacher::display(){
    cout<<m_name<<"今年"<<m_age<<"岁了,是一名教师,每月有"<<m_salary<<"元的收入"<<endl;
}

int main(){
    People *p = new People("王志刚", 23);
    p -> display();

    p = new Teacher("赵宏佳", 45, 8200);
    p -> display();

    return 0;
}

运行结果:

王志刚今年23岁了,是个无业游民
赵宏佳今年45岁了,是个无业游民 // 预期输出:赵宏佳今年45岁了,是一名教师,每月有8200元的收入。

本例中,当基类指针 p 指向派生类 Teacher 的对象时,虽然使用了 Teacher 的成员变量,但是却没有使用它的成员函数

由此可证:通过基类指针只能访问派生类的成员变量,但是不能访问派生类的成员函数

为了解决这个问题,让基类指针能够访问派生类的成员函数,C++ 增加了虚函数 。使用虚函数非常简单,只需要在成员函数声明前面增加 virtual 关键字

#include <iostream>
using namespace std;

// 基类 People
class People{
public:
    People(char *name, int age);
    virtual void display();  // 声明为虚函数
protected:
    char *m_name;
    int m_age;
};
People::People(char *name, int age): m_name(name), m_age(age){}
void People::display(){
    cout<<m_name<<"今年"<<m_age<<"岁了,是个无业游民"<<endl;
}

// 派生类 Teacher
class Teacher: public People{
public:
    Teacher(char *name, int age, int salary);
    virtual void display();  // 声明为虚函数
private:
    int m_salary;
};
Teacher::Teacher(char *name, int age, int salary): People(name, age), m_salary(salary){}
void Teacher::display(){
    cout<<m_name<<"今年"<<m_age<<"岁了,是一名教师,每月有"<<m_salary<<"元的收入"<<endl;
}

int main(){
    People *p = new People("王志刚", 23);
    p -> display();

    p = new Teacher("赵宏佳", 45, 8200);
    p -> display();

    return 0;
}

运行结果:

王志刚今年23岁了,是个无业游民 // 基类指针指向派生类对象时就使用派生类的成员
赵宏佳今年45岁了,是一名教师,每月有8200元的收入 // 基类指针指向基类对象时就使用基类的成员

有了虚函数以后:

  • 基类指针指向基类对象时就使用基类的成员(包括成员函数和成员变量)
  • 基类指针指向派生类对象时就使用派生类的成员

我们将这种现象称为多态

C++提供多态的目的是:可以通过基类指针对所有派生类(包括直接派生和间接派生)的成员变量和成员函数进行“全方位”的访问,尤其是成员函数。如果没有多态,我们只能访问成员变量

虚函数是根据指针的指向来调用的,指针指向哪个类的对象就调用哪个类的虚函数

多态的应用场景

假设你正在玩一款军事游戏,敌人突然发动了地面战争,于是你命令陆军、空军及其所有现役装备进入作战状态

#include <iostream>
using namespace std;

// 基类:军队
class Troops
{
public:
  virtual void fight() { cout << "Strike back!" << endl; }
};

// 派生类1:陆军
class Army : public Troops
{
public:
  void fight() { cout << "--Army is fighting!" << endl; }
};
// 派生类1_1:99A主战坦克
class _99A : public Army
{
public:
  void fight() { cout << "----99A(Tank) is fighting!" << endl; }
};
// 派生类1_2:武直10武装直升机
class WZ_10 : public Army
{
public:
  void fight() { cout << "----WZ-10(Helicopter) is fighting!" << endl; }
};
// 派生类1_3:长剑10巡航导弹
class CJ_10 : public Army
{
public:
  void fight() { cout << "----CJ-10(Missile) is fighting!" << endl; }
};

// 派生类2:空军
class AirForce : public Troops
{
public:
  void fight() { cout << "--AirForce is fighting!" << endl; }
};
// 派生类2_1:J-20隐形歼击机
class J_20 : public AirForce
{
public:
  void fight() { cout << "----J-20(Fighter Plane) is fighting!" << endl; }
};
// 派生类2_2:CH5无人机
class CH_5 : public AirForce
{
public:
  void fight() { cout << "----CH-5(UAV) is fighting!" << endl; }
};
// 派生类2_3:轰6K轰炸机
class H_6K : public AirForce
{
public:
  void fight() { cout << "----H-6K(Bomber) is fighting!" << endl; }
};

int main()
{
  Troops *p = new Troops;
  p->fight();
  // 陆军
  p = new Army;
  p->fight();
  p = new _99A;
  p->fight();
  p = new WZ_10;
  p->fight();
  p = new CJ_10;
  p->fight();
  // 空军
  p = new AirForce;
  p->fight();
  p = new J_20;
  p->fight();
  p = new CH_5;
  p->fight();
  p = new H_6K;
  p->fight();

  return 0;
}

运行结果:

Strike back!  
--Army is fighting!  
----99A(Tank) is fighting!  
----WZ-10(Helicopter) is fighting!  
----CJ-10(Missile) is fighting!  
--AirForce is fighting!  
----J-20(Fighter Plane) is fighting!  
----CH-5(UAV) is fighting!  
----H-6K(Bomber) is fighting!

这个例子中的派生类比较多,如果不使用多态,那么就需要定义多个指针变量,很容易造成混乱;而有了多态,只需要一个指针变量 p 就可以调用所有派生类的虚函数

虚函数注意事项

  1. 只需要在虚函数的声明处加上 virtual 关键字,函数定义处可以加也可以不加

  2. 为了方便,你可以只将基类中的函数声明为虚函数,这样所有派生类中具有遮蔽关系的同名函数都将自动成为虚函数

  3. 当在基类中定义了虚函数时,如果派生类没有定义新的函数来遮蔽此函数,那么将使用基类的虚函数

  4. 只有派生类的虚函数覆盖基类的虚函数(函数原型相同)才能构成多态(通过基类指针访问派生类函数)。例如基类虚函数的原型为virtual void func(),派生类虚函数的原型为virtual void func(int),那么当基类指针 p 指向派生类对象时,语句p -> func(100)将会出错,而语句p -> func()将调用基类的函数

  5. 构造函数不能是虚函数。对于基类的构造函数,它仅仅是在派生类构造函数中被调用,这种机制不同于继承。也就是说,派生类不继承基类的构造函数,将构造函数声明为虚函数没有什么意义

  6. 析构函数可以声明为虚函数,而且有时候必须要声明为虚函数

构成多态的条件

  • 必须存在继承关系
  • 继承关系中必须有同名虚函数,并且它们是覆盖关系(函数原型相同)
  • 存在基类的指针,通过该指针调用虚函数

什么时候声明虚函数

  1. 看成员函数所在的类是否会作为基类
  2. 看成员函数在类的继承后有无可能被更改功能,如果希望更改其功能的,一般应该将它声明为虚函数

如果成员函数在类被继承后功能不需修改,或派生类用不到该函数,则不要把它声明为虚函数

纯虚函数和抽象类

可以将虚函数声明为纯虚函数,语法格式为:

virtual 返回值类型 函数名 (函数参数) = 0;

纯虚函数没有函数体,只有函数声明,在虚函数声明的结尾加上=0,表明此函数为纯虚函数。

最后的=0并不表示函数返回值为0,它只起形式上的作用,告诉编译系统“这是纯虚函数”。

包含纯虚函数的类称为抽象类。之所以说它抽象,是因为它无法创建对象。原因很明显,纯虚函数没有函数体,不是完整的函数,无法调用,也无法为其分配内存空间

抽象类通常是作为基类,让派生类去实现纯虚函数。派生类必须实现纯虚函数才能被实例化

纯虚函数和抽象类举例:

#include <iostream>
using namespace std;

// 基类1:线(未定义 area 和 volume,属于抽象类)
class Line{
public:
    Line(float len);
    virtual float area() = 0;
    virtual float volume() = 0;
protected:
    float m_len;
};
Line::Line(float len): m_len(len){ }

// 中间基类2:矩形(未定义 volume,属于抽象类)
class Rec: public Line{
public:
    Rec(float len, float width);
    float area();
protected:
    float m_width;
};
Rec::Rec(float len, float width): Line(len), m_width(width){ }
float Rec::area(){ return m_len * m_width; }

// 派生类:长方体(已定义 area 和 volume,可以实例化)
class Cuboid: public Rec{
public:
    Cuboid(float len, float width, float height);
    float area();
    float volume();
protected:
    float m_height;
};
Cuboid::Cuboid(float len, float width, float height): Rec(len, width), m_height(height){ }
float Cuboid::area(){ return 2 * ( m_len*m_width + m_len*m_height + m_width*m_height); }
float Cuboid::volume(){ return m_len * m_width * m_height; }

// 派生类:正方体
class Cube: public Cuboid{
public:
    Cube(float len);
    float area();
    float volume();
};
Cube::Cube(float len): Cuboid(len, len, len){ }
float Cube::area(){ return 6 * m_len * m_len; }
float Cube::volume(){ return m_len * m_len * m_len; }

int main(){
    Line *p = new Cuboid(10, 20, 30);
    cout<<"The area of Cuboid is "<<p->area()<<endl;
    cout<<"The volume of Cuboid is "<<p->volume()<<endl;
  
    p = new Cube(15);
    cout<<"The area of Cube is "<<p->area()<<endl;
    cout<<"The volume of Cube is "<<p->volume()<<endl;

    return 0;
}

运行结果:

The area of Cuboid is 2200  
The volume of Cuboid is 6000  
The area of Cube is 1350  
The volume of Cube is 3375

本例中定义了四个类,它们的继承关系为:Line --> Rec --> Cuboid --> Cube

Line 是一个抽象类,也是最顶层的基类,在 Line 类中定义了两个纯虚函数 area() 和 volume()。Line 类表示“线”,没有面积和体积,但它提前定义了这两个纯虚函数。这样的用意很明显:Line 类不需要被实例化,但是它为派生类提供了约束条件,派生类必须要实现这两个函数,完成计算面积和体积的功能,否则就不能实例化

于是在 Rec 类中,实现了 area() 函数,也就是定义了纯虚函数的函数体。但这时 Rec 仍不能被实例化,因为它没有实现继承来的 volume() 函数,volume() 仍然是纯虚函数,所以 Rec 也仍然是抽象类

直到 Cuboid 类,才实现了 volume() 函数,才是一个完整的类才可以被实例化

抽象基类还可以实现多态

抽象基类除了约束派生类的功能,还可以实现多态

注意第 51 行代码,指针 p 的类型是 Line,但是它却可以访问派生类中的 area() 和 volume() 函数,正是由于在 Line 类中将这两个函数定义为纯虚函数;如果不这样做,51 行后面的代码都是错误的。这或许才是C++提供纯虚函数的主要目的

关于纯虚函数的几点说明

  1. 一个纯虚函数就可以使类成为抽象基类,但是抽象基类中除了包含纯虚函数外,还可以包含其它的成员函数(虚函数或普通函数)和成员变量
  2. 只有类中的虚函数才能被声明为纯虚函数,普通成员函数和顶层函数均不能声明为纯虚函数

typeid 运算符用来获取一个表达式的类型信息。类型信息对于编程语言非常重要,它描述了数据的各种属性:

  • 对于基本类型(int、float 等C++内置类型)的数据,类型信息所包含的内容比较简单,主要是指数据的类型。

  • 对于类类型的数据(也就是对象),类型信息是指对象所属的类、所包含的成员、所在的继承关系等。

类型信息是创建数据的模板,数据占用多大内存、能进行什么样的操作、该如何操作等,这些都由它的类型信息决定。

typeid运算符

typeid 的操作对象既可以是表达式,也可以是数据类型,下面是它的两种使用方法:

typeid( dataType )  
typeid( expression )

dataType 是数据类型,expression 是表达式,这和 sizeof 运算符非常类似,只不过 sizeof 有时候可以省略括号( ),而 typeid 必须带上括号。

typeid 会把获取到的类型信息保存到一个 type_info 类型的对象里面,并返回该对象的常引用;当需要具体的类型信息时,可以通过成员函数来提取。typeid 的使用非常灵活,请看下面的例子(只能在 VC/VS 下运行):

#include <iostream>
#include <typeinfo>
using namespace std;

class Base{ };

struct STU{ };

int main(){
    // 获取一个普通变量的类型信息
    int n = 100;
    const type_info &nInfo = typeid(n);
    cout<<nInfo.name()<<" | "<<nInfo.raw_name()<<" | "<<nInfo.hash_code()<<endl;

    // 获取一个字面量的类型信息
    const type_info &dInfo = typeid(25.65);
    cout<<dInfo.name()<<" | "<<dInfo.raw_name()<<" | "<<dInfo.hash_code()<<endl;

    // 获取一个对象的类型信息
    Base obj;
    const type_info &objInfo = typeid(obj);
    cout<<objInfo.name()<<" | "<<objInfo.raw_name()<<" | "<<objInfo.hash_code()<<endl;

    // 获取一个类的类型信息
    const type_info &baseInfo = typeid(Base);
    cout<<baseInfo.name()<<" | "<<baseInfo.raw_name()<<" | "<<baseInfo.hash_code()<<endl;

    // 获取一个结构体的类型信息
    const type_info &stuInfo = typeid(struct STU);
    cout<<stuInfo.name()<<" | "<<stuInfo.raw_name()<<" | "<<stuInfo.hash_code()<<endl;

    // 获取一个普通类型的类型信息
    const type_info &charInfo = typeid(char);
    cout<<charInfo.name()<<" | "<<charInfo.raw_name()<<" | "<<charInfo.hash_code()<<endl;

    // 获取一个表达式的类型信息
    const type_info &expInfo = typeid(20 * 45 / 4.5);
    cout<<expInfo.name()<<" | "<<expInfo.raw_name()<<" | "<<expInfo.hash_code()<<endl;

    return 0;
}

运行结果:

int | .H | 529034928  
double | .N | 667332678  
class Base | .?AVBase@@ | 1035034353  
class Base | .?AVBase@@ | 1035034353  
struct STU | .?AUSTU@@ | 734635517  
char | .D | 4140304029  
double | .N | 667332678 

从本例可以看出,typeid 的使用非常灵活,它的操作数可以是普通变量、对象、内置类型(int、float等)、自定义类型(结构体和类),还可以是一个表达式。

本例中还用到了 type_info 类的几个成员函数,下面是对它们的介绍:

  • name() 用来返回类型的名称

  • raw_name() 用来返回名字编码算法产生的新名称

  • hash_code() 用来返回当前类型对应的 hash 值。hash 值是一个可以用来标志当前类型的整数,有点类似学生的学号、公民的身份证号、银行卡号等。不过 hash 值有赖于编译器的实现,在不同的编译器下可能会有不同的整数,但它们都能唯一地标识某个类型

遗憾的是,C++ 标准只对 type_info 类做了很有限的规定,不仅成员函数少,功能弱,而且各个平台的实现不一致。例如上面代码中的 name() 函数,nInfo.name()objInfo.name()在 VC/VS 下的输出结果分别是intclass Base,而在 GCC 下的输出结果分别是i4Base

C++ 标准规定,type_info 类至少要有如下所示的 4 个 public 属性的成员函数,其他的扩展函数编译器开发者可以自由发挥,不做限制。

1) 原型:const char* name() const;

返回一个能表示类型名称的字符串。但是C++标准并没有规定这个字符串是什么形式的,例如对于上面的objInfo.name()语句,VC/VS 下返回“class Base”,但 GCC 下返回“4Base”。

2) 原型:bool before (const type_info& rhs) const;

判断一个类型是否位于另一个类型的前面,rhs 参数是一个 type_info 对象的引用。但是C++标准并没有规定类型的排列顺序,不同的编译器有不同的排列规则,程序员也可以自定义。要特别注意的是,这个排列顺序和继承顺序没有关系,基类并不一定位于派生类的前面。

3) 原型:bool operator== (const type_info& rhs) const;

重载运算符“==”,判断两个类型是否相同,rhs 参数是一个 type_info 对象的引用。

4) 原型:bool operator!= (const type_info& rhs) const;

重载运算符“!=”,判断两个类型是否不同,rhs 参数是一个 type_info 对象的引用。

raw_name() 是 VC/VS 独有的一个成员函数,hash_code() 在 VC/VS 和较新的 GCC 下有效。

可以发现,不像 JavaC# 等动态性较强的语言,C++ 能获取到的类型信息非常有限,也没有统一的标准,如同“鸡肋”一般,大部分情况下我们只是使用重载过的“==”运算符来判断两个类型是否相同。

判断类型是否相等

typeid 运算符经常被用来判断两个类型是否相等。

1) 内置类型的比较

例如有下面的定义:

char *str;
int a = 2;
int b = 10;
float f;

类型判断结果为:

类型比较结果类型比较结果
typeid(int) == typeid(int)truetypeid(int) == typeid(char)false
typeid(char*) == typeid(char)falsetypeid(str) == typeid(char*)true
typeid(a) == typeid(int)truetypeid(b) == typeid(int)true
typeid(a) == typeid(a)truetypeid(a) == typeid(b)true
typeid(a) == typeid(f)falsetypeid(a/b) == typeid(int)true

typeid 返回 type_info 对象的引用,而表达式typeid(a) == typeid(b)的结果为 true,可以说明,一个类型不管使用了多少次,编译器都只为它创建一个对象,所有 typeid 都返回这个对象的引用。

需要提醒的是,为了减小编译后文件的体积,编译器不会为所有的类型创建 type_info 对象,只会为使用了 typeid 运算符的类型创建。不过有一种特殊情况,就是带虚函数的类(包括继承来的),不管有没有使用 typeid 运算符,编译器都会为带虚函数的类创建 type_info 对象,我们将在《C++ RTTI机制精讲(C++运行时类型识别机制)》中展开讲解。

2) 类的比较

例如有下面的定义:

class Base{};
class Derived: public Base{};

Base obj1;
Base *p1;
Derived obj2;
Derived *p2 = new Derived;
p1 = p2;

类型判断结果为:

类型比较结果类型比较结果
typeid(obj1) == typeid(p1)falsetypeid(obj1) == typeid(*p1)true
typeid(&obj1) == typeid(p1)truetypeid(obj1) == typeid(obj2)false
typeid(obj1) == typeid(Base)truetypeid(*p1) == typeid(Base)true
typeid(p1) == typeid(Base*)truetypeid(p1) == typeid(Derived*)false

表达式typeid(*p1) == typeid(Base)typeid(p1) == typeid(Base*)的结果为 true 可以说明:即使将派生类指针 p2 赋值给基类指针 p1,p1 的类型仍然为 Base*。

type_info 类的声明

最后我们再来看一下 type_info 类的声明,以进一步了解它所包含的成员函数以及这些函数的访问权限。type_info 类位于typeinfo头文件,声明形式类似于:

class type_info {
public:
    virtual ~type_info();
    int operator==(const type_info& rhs) const;
    int operator!=(const type_info& rhs) const;
    int before(const type_info& rhs) const;
    const char* name() const;
    const char* raw_name() const;
private:
    void *_m_data;
    char _m_d_name[1];
    type_info(const type_info& rhs);
    type_info& operator=(const type_info& rhs);
};

它的构造函数是 private 属性的,所以不能在代码中直接实例化,只能由编译器在内部实例化(借助友元)。而且还重载了“=”运算符,也是 private 属性的,所以也不能赋值。