C++ 基础系列——多态与虚函数
1. 多态与虚函数快速入门
基类的指针可以指向派生类对象,其使用的是派生类对象的数据,调用的是根据指针的类型调用成员函数。
对此 C++ 提供了虚函数,当基类指针指向基类对象时,使用基类的成员(包括成员函数和成员变量),当基类指针指向派生类对象时,使用派生类的成员。也就是说,虚函数可以让基类指针按照指针所指对象的类型,而不是指针的类型来操作,这种现象称为多态(Polymorphism)。
C++提供多态的目的是:可以通过基类指针对所有派生类(包括直接派生和间接派生)的成员(变量+函数)进行“全方位”的访问,尤其是成员函数。如果没有多态,会根据指针的类型访问成员函数,根据指针所指对象类型访问成员变量。
引用也可以实现多态,引用本质上是对指针的封装。
class People
{
public:
People(string name, int age);
virtual void display(); //声明为虚函数
protected:
string m_name;
int m_age;
};
People::People(string name, int age): m_name(name), m_age(age){}
void People::display(){
cout << m_name << "今年" << m_age << "岁了,是个无业游民。"<< endl;
}
class Teacher : public People
{
public:
Teacher(string name, int age);
virtual void display(); //声明为虚函数
private:
int m_salary;
};
Teacher::Teacher(string 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("张三", 25);
p -> display(); // 基类 People:张三今年 25 岁了,是个无业游民。
p = new Teacher("李四", 45, 8200);
p -> display(); // 派生类 Teacher:李四今年 45 岁了,是一名教师,每月有 8200 元的收入。
return 0;
}
2. 虚函数注意事项
注意事项
- 可以只在函数的声明处加 virtual 关键字,定义出可加可不加;
- 可以只将基类中的函数声明为虚函数,派生类具有遮蔽关系的同名函数会自动成为虚函数。(只要派生类成员函数与基类名字一样,就会造成遮蔽,遮蔽与参数无关);
- 如果基类定义了虚函数,派生类未重新定义此函数来遮蔽,将使用基类的虚函数;所以只有派生类的虚函数覆盖基类的虚函数(重载,原型相同)才能构成多态;
- 构造函数不能是虚函数,因为构造函数不能继承,且构造函数用于创建对象时初始化,在构造函数执行前对象未创建,虚函数表不存在。
- 析构函数可以声明为虚函数,有时也必须声明为虚函数。
构成多态的条件
- 存在继承关系;
- 继承关系中虚函数必须是覆盖关系(原型相同);
- 通过基类指针调用虚函数
3. 虚析构函数的必要性
先看一个例子:
class Base{
public:
Base();
~Base();
protected:
string str;
};
// ... 省略函数定义
class Derived : public Base{
public:
Derived();
~Derived();
private:
string name;
};
// ...
int main(){
Base *pb = new Derived(); // 调用基类和派生类的构造函数
delete pb; // 只调用基类的析构函数。非虚函数,调用指针的类型的方法,使用指针所指类型的数据
cout << "------------------" << endl;
Derived *pd = new Derived(); // 调用基类和派生类的构造函数
delete pd; // 调用派生类和基类的析构函数,派生类析构函数始终会调用基类的析构函数。
return 0;
}
运行结果
Base constructor
Derived constructor
Base destructor
------------------
Base constructor
Derived constructor
Derived destructor
Base destructor
delete
pb 只会调用基类的析构函数,没有调用派生类析构函数,会造成内存泄漏。
可以将基类的析构函数声明为虚函数
class Base{
public:
Base();
virtual ~Base();
protected:
string str;
};
运行结果
Base constructor
Derived constructor
Derived destructor
Base destructor
------------------
Base constructor
Derived constructor
Derived destructor
Base destructor
将基类的析构函数声明为虚函数后,派生类的析构函数也会自动成为虚函数,这时候基类指针会根据指针所指数据的实际类型来调用函数,所以此时会调用派生类的析构函数,继而再调用基类的析构函数。派生类析构函数始终会调用基类的析构函数。
大部分情况都应该将基类的析构函数声明为虚函数。
4. 纯虚函数和抽象类
C++ 中可以将虚函数声明为纯虚函数,语法格式为:
virtual 返回值类型 函数名 (函数参数) = 0;
= 0 只起形式作用,告诉编译系统这是纯虚函数。
包含纯虚函数的类称为抽象类,无法实例化。
// 线,抽象类
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){ }
派生类必须实现抽象类的全部纯虚函数,也就是实现抽象类的全部功能,才能实例化。
抽象基类除了约束派生类的功能,还能实现多态。
- 一个纯虚函数就可以使类称为抽象基类,但抽象基类除了包含纯虚函数外,还可以包含其他成员函数和成员变量;
- 只有类中的虚函数才能被声明为纯虚函数,普通成员函数和顶层函数均不能声明为纯虚函数。
5. 虚函数表
虚函数实现多态时,编译器之所以能通过指针指向的对象找到虚函数,是因为在创建对象时额外增加了虚函数表。
如果一个类包含虚函数,那么在创建该类的对象时会额外增加一个数组,数组中的每一个元素都是虚函数入口地址。
数组和对象是分开存储的,为了将对象和数组关联起来,编译器还要在对象中安插一个指针,指向数组的起始位置。这里的数组就是虚函数表(Virtual function table,vtable)。
class People{
public:
People(string name, int age);
virtual void display();
virtual void eating();
protected:
string m_name;
int m_age;
};
// ...
class Student : public People{
public:
Student(string name, int age, float score);
virtual void display();
virtual void examing();
protected:
float m_score;
};
// ...
class Senior : public Student{
public:
Senior(string name, int age, float score, bool hasJob);
virtual void display();
virtual void partying();
private:
bool m_hasJob;
};
// ...
int main(){
People *p = new People("赵红", 29);
p -> display();
p = new Student("王刚",16,84.5);
p -> display();
p = new Senior("李智",22, 92.0, true);
p-> display();
return 0;
}
运行结果:
Class People:赵红今年 29 岁了。
Class Student:王刚今年 16 岁了,考了 84.5 分。
Class Senior:李智以 92 的成绩从大学毕业了,并且顺利找到了工作,Ta 今年 22 岁。
各个类的对象内存模型如下所示:
- 在每个对象的开头有一个指针 vfptr,指向虚函数表,并且这个指针始终位于对象的开头位置。
- 基类的虚函数在 vtable 中的索引是固定的,不会随着继承层次的增加而改变,派生类新增的虚函数会在表后面。
- 如果派生类有同名的虚函数遮蔽了基类的虚函数,那么将使用派生类的虚函数替换基类的虚函数,具有遮蔽关系的虚函数在 vtable 中只会出现一次。
当通过指针调用虚函数时,先根据指针找到 vfptr,再根据 vfptr 找到虚函数的入口地址。以虚函数 display() 为例,它在 vtable 中的索引为 0,通过 p 调用时:
p -> display();
编译器内部会发生类似转换:
(((p+0)+0))(p);
- 0时 vfptr 在对象中的偏移, p+0 时 vfptr 地址;
- *(p+0)是 vfptr 的值,而 vfptr 是指向 vtable 的指针,所以*(p+0) 就是 vtable 的地址;
- display() 在 vtable 中的索引(下标)是 0,所以( *(p+0) + 0 )也就是 display() 的地址;
- ( *( *(p+0) + 0 ) )(p)也就是对 display() 的调用了,这里的 p 就是传递的实参,它会赋值给 this 指针。
转换后的表达式其实是固定的,只要调用这个函数,不管是哪个类,都会使用这个表达式。转换后的表达式没有用到与 p 的类型有关的信息,只要知道 p 的指向就可以调用函数,这跟名字编码(Name Mangling)算法有着本质上的区别。
以上是针对单继承进行的讲解,当存在多继承时,虚函数表的结构会变得很复杂,尤其有虚继承时,还会增加虚基类表。
6. typeid 运算符获取类型信息
类型信息是创建数据的模板,数据占多大内存、能进行什么样的操作、该如何操作等,都是由它的类型信息决定。
typeid 的操作对象既可以是表达式,也可以是数据类型,与 sizeof 运算符使用非常类似,只不过 sizeof 有时可以省略括号,typeid 必须带上。
typeid(dataType)
typeid(expression)
typeid 会把获取到的类型信息保存到一个 type_info 类型的对象里面,并返回该对象的常引用;当需要具体的类型信息时,可以通过成员函数来提取。
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 &expInfo = typeid(20 * 45 / 4.5);
cout<<expInfo.name()<<" | "<<expInfo.raw_name()<<" | "<<expInfo.hash_code()<<endl;
}
- name() 用来返回类型的名称。
- raw_name() 用来返回名字编码(Name Mangling)算法产生的新名称。
- hash_code() 用来返回当前类型对应的 hash 值。
C++ 标准规定,type_info 类至少要有如下所示的 4 个 public 属性的成员函数,其他的扩展函数编译器开发 者可以自由发挥,不做限制。
- 原型:const char* name() const;
- 原型:bool before (const type_info& rhs) const;
- 原型:bool operator== (const type_info& rhs) const;
重载运算符“==”,判断两个类型是否相同,rhs 参数是一个 type_info 对象的引用。- 原型:bool operator!= (const type_info& rhs) const;
重载运算符“!=”,判断两个类型是否不同,rhs 参数是一个 type_info 对象的引用。
大部分情况下只使用重载过的 == 运算符来判断两个类型是否相同。
判断类型是否相等
string str;
int a = 2;
int b = 10;
float f;
判断结果:
typeid 返回 type_info 对象的引用,而表达式 typeid(a) == typeid(b)的结果为 true,可以说明,一个类型不管使用了多少次,编译器都只为它创建一个 type_info 对象,所有 typeid 都返回这个对象的引用。
需要提醒的是,为了减小编译后文件的体积,编译器不会为所有的类型创建 type_info 对象,只会为使用了 typeid 运算符的类型创建。不过有一种特殊情况,就是带虚函数的类(包括继承来的),不管有没有使用 typeid 运算符,编译器都会为带虚函数的类创建 type_info 对象。
Base obj1;
Base *p1;
Derived obj2;
Derived *p2 = new Derived;
p1 = p2;
判断类型结果为:
表达式 typeid(p1) == typeid(Base)和 typeid(p1) == typeid(Base)的结果为 true 可以说明:即使将派生 类指针 p2 赋值给基类指针 p1,p1 的类型仍然为 Base*。
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 属性的,所以也不能赋值。
7. RTTI(运行时类型识别)机制
class Base{
// ...
};
class Derived : public Base{
// ...
};
int main(){
Base *p;
int n;
cin >> n;
if(n<10>)
p = new Base();
else
p = new Derived();
//根据不同的类型进行不同的操作
if(typeid(*p) == typeid(People))
cout<<"I am human."<<endl;
else
cout<<"I am a student."<<endl;
上述程序中编译器在编译阶段无法确定 p 指向的对象类型,需要在运行后才能确定类型信息,这种在程序运行后确定对象的类型信息的机制称为运行时类型识别(Run-Time Type Identification,RTTI)。
C++ 对象的内存模型主要包含以下几个方面内容:
- 如果没有虚函数也没有虚继承,那么对象内存模型中只有成员变量。
- 如果类包含了虚函数,那么会额外添加一个虚函数表,并在对象内存中插入一个指针,指向这个虚函数表。
- 如果类包含了虚继承,那么会额外添加一个虚基类表,并在对象内存中插入一个指针,指向这个虚基类表。
如果类包含虚函数,该对象内存中还会存在额外的类型信息,即 type_info 对象。以上面代码为例,Base 和 Derived 对象的内存模型如下:
编译器会在虚函数表 vftable 的开头插入一个指针,指向当前类对应的 type_info 对象。当程序在运行阶段获取类型信息时,可以通过对象指针 p 找到虚函数表指针 vfptr,再通过 vfptr 找到 type_info 对象的指针,进而取得类型信息。下面的代码演示了这种转换过程:
**(p->vfptr - 1)
编译器在编译阶段无法确定 p 指向哪个对象,也就无法获取*p 的类型信息,但是编译器可以在编译阶段做好各 种准备,这样程序在运行后可以借助这些准备好的数据来获取类型信息。这些准备包括:
- 创建 type_info 对象,并在 vftable 的开头插入一个指针,指向 type_info 对象。
- 将获取类型信息的操作转换成类似**(p->vfptr - 1)这样的语句。
多态(Polymorphism)是面向对象编程的一个重要特征,它极大地增加了程序的灵活性,C++、C#、Java 等“正统的”面向对象编程语言都支持多态。但是支持多态的代价也是很大的,有些信息在编译阶段无法确定下来,必须提前做好充足的准备,让程序运行后再执行一段代码获取,这会消耗更多的内存和 CPU 资源。
8. 静态绑定和动态绑定
CPU 访问内存时是通过地址,而不是变量名和函数名,变量名和函数名只是地址的一种助记符。当源文件被编译和链接成可执行程序后,它们都会被替换成地址。编译和链接过程的一项重要任务就是找到这些名称所对应的地址。
可以将变量名和函数名统称为符号(Symbol),找到符号对应的地址的过程叫做符号绑定。
函数调用实际上是执行函数体中的代码,函数体是内存中的一个代码段,函数名是该代码段的首地址,也就是函数的入口地址。
找到函数名对应的地址,然后将函数调用处用该地址替换,这称为函数绑定。
静态绑定:在编译/链接期间找到函数名对应地址完成函数绑定
动态绑定:需要等到程序运行后根据具体的环境或者用户操作才能决定使用哪个函数
动态绑定的本质:编译器在编译期间不能确定指针指向哪个对象,只能等到程序运行后根据具体的情况 再决定。
9. 总结
- C++提供多态的目的是:可以通过基类指针对所有派生类(包括直接派生和间接派生)的成员(变量+函数)进行“全方位”的访问,尤其是成员函数。如果没有多态,会根据指针的类型访问成员函数,根据指针所指对象的类型访问成员变量。
- 虚函数可以只在声明处加 virtual 关键字,定义处可加可不加。
- 可以只将基类中函数声明为虚函数,派生类具有遮蔽关系的同名函数会自动成为虚函数(遮蔽只与函数名有关)。
- 构造函数不能是虚函数,因为构造函数不能继承,且构造函数用于创建对象时初始化,在构造函数执行前对象未创建,虚函数表不存在。
- 析构函数可以声明为虚函数,且在为实现多态(虚函数)的基类指针指向派生类时析构函数必须声明为虚函数。因为未实现多态的基类指针使用的是指针类型(基类)的析构函数,不会调用派生类的析构函数,会造成内存泄漏。(派生类析构函数始终会调用基类的析构函数)
- 构成多态的条件:继承关系、虚函数覆盖关系、基类指针调用虚函数。
- 派生函数必须实现抽象类的全部纯虚函数。
- 虚函数实现多态时,编译器之所以能通过指针指向的对象找到虚函数,是因为在创建对象时额外增加了虚函数表。(对象和函数是分离的,通过函数 this 指针找对象)。
- 如果一个类包含虚函数,那么在创建该类对象时会额外增加一个虚函数表数组,表中每一个元素都是虚函数入口地址。
- 虚函数表中只会存放遮蔽了的虚函数、没有遮蔽的基类虚函数、以及新增的虚函数。
- typeid 会把获取到的类型信息保存到一个 type_info 类型的对象里面,并返回该对象的常引用。
- 一个类型不管创建多少对象,编译器都只会为它创建一个 type_info 对象,并且编译器只会为使用了 typeid 运算符的类型以及有虚函数的类创建 type_info 对象。
- C++ 对象的内存模型主要包含以下几个方面内容:
- 如果没有虚函数也没有虚继承,那么对象内存模型中只有成员变量。
- 如果类包含了虚函数,那么会额外添加一个虚函数表,并在对象内存中插入一个指针,指向这个虚函数表。
- 如果类包含了虚继承,那么会额外添加一个虚基类表,并在对象内存中插入一个指针,指向这个虚基类表。
- 编译器会在虚函数表 的开头插入一个指向当前类对应 type_info 对象的指针。
- 编译器在编译阶段无法确定多态的基类指针指向哪个对象,但可以在编译阶段做好各种准备:
- 创建 type_info 对象,并在 vftable 的开头插入一个指针,指向 type_info 对象。
- 将获取类型信息的操作转换成类似**(p->vfptr - 1)这样的语句。
- 静态绑定:在编译/链接期间找到函数名对应地址完成函数绑定
- 动态绑定:需要等到程序运行后根据具体的环境或者用户操作才能决定使用哪个函数