多态和虚函数
- 1. 多态和虚函数
- 2. 引用形式的多态
- 3. 虚函数注意事项
- 4. 构成多态的条件
- 5. 为什么构造函数不能是虚函数
- 6. 虚析构函数的必要性
- 7. 纯虚函数
- 8. 抽象类
- 9. 虚函数表
- 10. typeid运算符:获取类型信息
- 11. RTTI机制(C++运行时类型识别机制)
- 12. 静态绑定和动态绑定
引用:
[1]C语言中文网
1. 多态和虚函数
前提:
类型转化(向上转型):当把派生类对象指针赋值给基类对象指针后,基类对象指针只能访问派生类的成员变量,但是不能访问派生类的成员函数。因为,编译器会根据指针类型来调用对应类型的成员函数。
引入问题:
这样就会限制灵活性,我们想要向上转型后,基类对象指针既可以调用派生类的成员变量,又可以调用派生类的成员函数。此时就可以通过虚函数来实现多态。
虚函数就是在需要多态的函数前加上virtual声明。只有虚函数声明后的成员函数(同名)才能多态。
多态:基类对象指针既可以调用自己的成员(成员变量和成员函数),又可以调用直接派生类或间接派生类的成员(成员变量和成员函数)。这种灵活的调用的形式就是多态。
C++中,虚函数就是为了多态而存在的。
class A{
public:
int m_a;
virtual void display(){cout<<"class A: m_a="<<m_a<<endl;}
A(int a);
};
A::A(int a):m_a(a){}
class B: public A{
public:
int m_b;
void display(){cout<<"class B: m_a="<<m_a<<", m_b="<<m_b<<endl;};
void show(){cout<<"class B"<<endl;};
B(int a, int b);
};
B::B(int a, int b):A(a), m_b(b){}
int main() {
A *pa = new A(10);
B *pb = new B(1, 2);
pa = pb;
pa->display();
return 0;
}
输出:
可以看到基类对象指针可以调用派生类的成员函数了,也可以使用派生类的成员变量。但是要注意的是:只有virtual修饰的函数可以调用,派生类中没有遮蔽的同名虚函数不能调用,例如下例中的show函数。派生类自己新增的成员变量也不可调用,下例中的m_b
2. 引用形式的多态
引用因为本身就是指针的封装,因此引用也可以实现多态。但是,引用一经赋值就无法改变指向,所以并没有指针灵活。所以一般多态都是通过指针来实现的。
class A{
public:
int m_a;
virtual void display(){cout<<"class A: m_a="<<m_a<<endl;}
A(int a);
};
A::A(int a):m_a(a){}
class B: public A{
public:
int m_b;
void display(){cout<<"class B: m_a="<<m_a<<", m_b="<<m_b<<endl;}
void show(){cout<<"class B"<<endl;};
B(int a, int b);
};
B::B(int a, int b):A(a), m_b(b){}
int main() {
A a = A(10);
B b = B(1, 2);
A &ra = b;
ra.display();
return 0;
}
3. 虚函数注意事项
- 只需要在函数声明之前加上virtual关键字,函数定义处可以不用加。
- 可以只将基类中的函数变成虚函数,此时派生类中的具有遮蔽关系的同名函数也会自动也变成虚函数类型。
- 在基类中定义虚函数,如果派生类中没有同名的函数,那么将使用基类的虚函数。
- 只有派生类的虚函数覆盖基类的虚函数(函数名称和参数都相同)才能构成多态。
- 构造函数不能是虚函数。对于基类的构造函数,它仅仅是在派生类构造函数中被调用,这种机制不同于继承。也就是说,派生类不继承基类的构造函数,将构造函数声明为虚函数没有什么意义。
- 析构函数可以声明为虚函数,而且有时候必须要声明为虚函数。
4. 构成多态的条件
- 必须是继承关系。
- 继承关系中必须有同名的虚函数,并且是覆盖关系。(函数原型相同,包括名称和参数)。
- 存在基类指针,通过该指针调用虚函数。
例子:
class Base{
public:
virtual void func(){cout<<"Base:func()"<<endl;}
virtual void func(int){cout<<"Base:func(int)"<<endl;}
};
class Derived: public Base{
public:
void func(){cout<<"Derived:func()"<<endl;}
void func(char*){cout<<"Derived:func(char*)"<<endl;}
};
int main() {
Base *p = new Derived();
p->func(); // 调用派生类中的虚函数func()
p->func(10);//由于派生类没有遮蔽的同名虚函数,则调用基类的虚函数func(int)
p->func("1111"); // 编译错误 ,因为通过基类的指针只能访问从基类继承过去的成员,不能访问派生类新增的成员。
return 0;
}
5. 为什么构造函数不能是虚函数
- 派生类不会继承基类的构造函数,因此,这是虚函数没有意义。
- 构造函数是完成对象初始化工作的,会在构造函数中初始化虚函数表和虚函数指针。如果把构造函数设置为虚函数,那么就不会存在虚函数表,因此无法查询虚函数表,也就不知道要调用哪一个构造函数。
6. 虚析构函数的必要性
析构函数用于在销毁对象时进行清理工作,可以声明为虚函数,而且有时候必须要声明为虚函数。
举例说明:
class Base{
public:
char * a;
Base(){a = new char[100]; cout << "BASE construct!"<<endl;}
~Base(){delete a; cout << "BASE destruct!"<<endl;}
};
class Derived: public Base{
public:
char * b;
Derived(){b = new char[100]; cout << "Derived construct!"<<endl;}
~Derived(){delete b; cout << "Derived destruct!"<<endl;}
};
int main() {
Base *p = new Derived();
delete p;
return 0;
}
从输出结果就可以看到,Derived没有调用析构函数,因此,析构函数中的b的内存就没有被释放,因此会造成内存泄漏。
原因就是:多态只能调用虚函数。对于非虚函数,就回到了上篇"继承和派生14.2章节"中说的那样,根据指针类型调用对应类型的类成员函数。在该例子中,析构函数不是虚函数,因此就会根据指针类型Base,来调用Base类中的析构函数,没有调用Derived类的析构函数。
一般析构函数就是用来释放内存的。为了避免内存泄漏,一定要将基类的析构函数声明为虚函数。(这里强调的是基类,如果一个类是最终的类,那就没必要再声明为虚函数了)
修改为下例子:
class Base{
public:
char * a;
Base(){a = new char[100]; cout << "BASE construct!"<<endl;}
virtual ~Base(){delete a; cout << "BASE destruct!"<<endl;} //把基类的析构函数声明为虚函数
};
class Derived: public Base{
public:
char * b;
Derived(){b = new char[100]; cout << "Derived construct!"<<endl;}
~Derived(){delete b; cout << "Derived destruct!"<<endl;}
};
int main() {
Base *p = new Derived();
delete p;
return 0;
}
将基类的析构函数声明为虚函数后,派生类的析构函数也会自动成为虚函数。这个时候编译器会忽略指针的类型,而根据指针的指向来选择函数;当我们调用派生类的析构函数后,系统还会默认的调用基类的析构函数。
7. 纯虚函数
纯虚函数只有函数声明,没有函数体。具体形式如下:
此处=0不代表返回值为0,而是声明该函数是纯虚函数。
8. 抽象类
包含纯虚函数的类为抽象类。
- 抽象类不能实例化。
- 抽象类通常都是基类,由派生类进行实现纯虚函数。
- 派生类必须实现纯虚函数才能实例化。
- 抽象基类除了约束派生类的功能,还可以实现多态。
- 只有类中的虚函数才能被声明为纯虚函数,普通成员函数和顶层函数均不能声明为纯虚函数。
9. 虚函数表
前提总结,当通过指针访问成员函数时,有以下两个状况。
- 成员函数是非虚函数,则编译器根据指针的类型,找到对应类中的成员函数。
- 成员函数是虚函数,且该虚函数有相同的虚函数遮蔽,那么指针指向哪一类,编译器就在该类中调用对应的虚函数。
指针之所以能找到对应的虚函数,就是通过虚函数表实现的。
如何一个类中有含有虚函数的话,在构造函数中,就会创建一个数组以及指向这个数组的指针(简称vfptr)。这个数组中每个元素都存有虚函数的入口地址,这个数组就是虚函数表,简称vtable。虚函数表指针存在对象内存模型中。
(此图来自C语言中文网)
可以看出一个包含虚函数的类,实例化后会生成两个数组,一个是对象,一个是虚函数表。虚函数指针存在对象的第一个元素中,并指向虚函数表。虚函数表的顺序是先基类虚函数,然后派生类虚函数。当派生类中存在遮挡的同名虚函数时,则会替换基类中的对应虚函数,例如图中间,Student::display替换了People::display。
10. typeid运算符:获取类型信息
typeid是一个运算符,引用自#include <typeinfo>。可以获取一个表达式的类型信息(包含基本类型, 例如int,float等,和类类型信息,例如对象)。最终把结果存储到tpye_info对象中。
具体操作方式为:
typeid(p)
type_info对象的成员:
11. RTTI机制(C++运行时类型识别机制)
有的数据类型在编译器阶段就可以确定。但是有时候数据类型在编译期间无法确定,需要在程序执行到该表达式时才能确定,例如,多态时需要根据用户输入信息,来判断指针指向。
针对这种情形,C++会在虚函数表的开头增加一个额外的type_info对象指针,指向一个数组,该数组中存放所有对象的类型信息(type_info对象)。这样,当运行时,通过对象指针p找到虚函数指针vfptr,在通过vfptr找到type_info对象的指针,从而获取类型数据。具体形式见下图:
(此图来自C语言中文网)
在程序运行后确定对象的类型信息的机制称为运行时类型识别(Run-Time Type Identification,RTTI)。
12. 静态绑定和动态绑定
背景:
在编译器看来,代码中的函数名和变量名其实都是地址符号,它们本身代表的是地址,因为CPU是通过地址来取值的,并不是通过函数名或变量名。编译和链接的操作其实就是在找变量和函数对应的地址,并替换成对应的地址。
- 函数绑定:找到函数名对应的地址,并将函数调用处用该地址替换,这称为函数绑定。
- 静态绑定:在编译期间(包括链接)就能找到函数名对应的地址,并完成函数绑定的,就被称为静态绑定。
- 动态绑定:在编译期间(包括链接)无法确定函数名对应的地址,必须等到程序运行后根据具体环境或者用户操作来进行函数绑定的,则被称为动态绑定。
通过函数重载实现多态的就是静态绑定。因为重载的函数名在编译阶段就能确定其地址,并完成函数绑定。
通过基类对象指针的指向实现多态的则是动态绑定,因为只有当运行对应指针赋值表达式时才能确认是执行那个对象的函数。例如,都是p->display(),你无法判断display()是那个地址,需要借助之前的指针指向来确定。