1、虚函数简介
- 虚函数是在基类内部使用关键字virtual声明的成员函数。
- 使用虚函数的目的就是为了实现多态:即可以让派生类重写(override)基类的成员函数。
- 派生类在重写基类的虚函数时,可以使用也可以不使用 virtual关键字;但不论使用还是不使用,在基类和派生类中,该函数都为虚函数,也就是说虚函数一旦声明,就一直是虚函数。
- 需要注意的是:虚函数是在运行时才能确定,即动态绑定的,因为涉及到了override;因此虚函数肯定不能声明为静态成员函数等其他函数,因为非虚函数是在编译时就确定了。
- 构造函数不能是虚函数,析构函数一般会声明为虚函数:
构造函数创建对象时必须确定类型,以防止一些不恰当的操作,否则编译器就会报错。
析构函数如果不是虚函数,可能会造成内存泄漏的问题。
- 虚函数的示例:
#include<iostream>
using namespace std;
class Base {
public:
void f() { cout << "Base::f" << endl; }; //一般成员函数
virtual void f1() { cout << "Base::f1" << endl; }; //虚函数
private:
char aa[3];
};
class Derived : public Base {
public:
void f1() { cout << "Derived::f1" << endl; }; //重写基类的虚函数f1()
private:
char bb[3];
};
int main() {
Base a;
Derived b;
Base *p = &a;
Base *p0 = &b;
Base *p1 = &a;
Base *p2 = &b;
Derived *p3 = &b;
p->f(); //基类的指针调用自己的基类部分 Base::f(), 打印结果为 Base::f
p0->f(); //基类的指针调用派生类的基类部分 Base::f(), 打印结果为 Base::f
p1->f1(); //基类的指针调用基类自身的函数 Base::f1(), 打印结果为 Base::f1
p2->f1(); //基类的指针调用派生类重写的函数 Derived::f1(), 打印结果为 Derived::f1
p3->f1(); //派生类的指针调用自己重写的函数 Derived::f1(),打印结果为 Derived::f1
return 0;
}
2、虚函数的工作机制
- 由于虚函数是在运行时而不是在编译时确定的,在运行时肯定得知道虚函数的地址,因此使用了虚函数表+虚表指针来实现这一过程。
虚函数表:在编译含有虚函数的类时,会创建一个虚函数表 vtable,用于存放每个虚函数的地址
虚表指针:另外还隐式地设置了一个虚表指针,称为vptr,这个vptr指向了该类对象的虚函数表。
- 如果是基类的话,直接进行创建即可。
- 如果是派生类继承基类的时候,虚函数表和虚表指针有如下的过程:
1)派生类的虚函数表首先会继承基类的虚函数表,如果有新增的虚函数,也会将新的虚函数地址加进来;
2)然后判断基类的虚函数表中的所有虚函数是否被派生类重写(override):
3)如果派生类重写了基类的虚函数,那么虚函数表中该虚函数的地址将会被改为重写后的虚函数地址;
4)如果派生类没有重写基类的虚函数,那么该虚函数的地址就不用修改。
- 需要注意的是,每个类都只有一个虚函数表,该类的所有对象共享这个虚函数表,而不是每个实例化对象都分别有一个虚函数表。
3、虚函数与动态多态
- 多态的实现主要有两种,静态多态和动态多态
静态多态:静态多态主要是重载(overload),即函数重载或运算符重载,在编译时就已经确定
动态多态:动态多态是通过虚函数机制来实现,在运行时进行动态绑定。
- 动态多态怎么通过虚函数实现呢?需要注意:基类指针既可以指向基类对象,也可以指向派生类对象,但是在使用基类指针调用函数时,只能调用基类中定义的函数或被派生类重写的函数(通过虚函数实现)。不能直接调用派生类中新增加的函数或重载的函数。
1)如果使用基类指针或者基类引用的方式调用虚函数时,我们并不知道执行的是基类的虚函数还是派生类的虚函数,只有在运行时才能确定调用的是基类的虚函数还是派生类中的虚函数,这就是运行时多态/动态多态。
2)在运行时根据对象的具体类型来调用相应的函数,而不是根据指针或引用的类型来决定。
int main() {
Base a;
Derived b;
Base *p1 = &a; //基类的指针,指向基类的对象
Base *p2 = &b; //基类的指针,指向派生类的对象
//f1()是虚函数,只有运行时才知道真正调用的是基类的f1(),还是派生类的f1()
p1->f1(); //p1指向的是基类的对象,所以此时调用的是基类的f1()
p2->f1(); //p2指向的是派生类的对象,所以调用的是派生类重写后的f1()
return 0;
}
- 多态性其实就是想让基类的指针具有多种形态,能够在尽量少写代码的情况下让基类可以实现更多的功能。 比如说,派生类重写了基类的虚函数f1()之后,基类的指针就不仅可以调用自身的虚函数f1(),还可以调用其派生类的虚函数f1(),即实现了多种操作。
- 如果基类通过引用或者指针调用的是非虚函数,无论实际的对象是什么类型,都执行基类所定义的函数。即:
int main() {
Base a;
Derived b; //派生类的对象
Base *p1 = &a;
Base *p2 = &b; //基类的指针,指向派生类的对象
p1->f1();
p2->f1(); //基类的指针调用派生类重写的虚函数 Derived::f1(), 打印结果为 Derived::f1
return 0;
}
4、函数重写与函数重载
- 函数重写是指在派生类中重新定义基类中的同名的函数,一般是使用虚函数
- 函数重载是指在同一个作用域内,一般为同一个类内,定义多个同名函数,但他们的参数列表不同(参数类型,参数个数,参数顺序等)
- 参数列表:函数重载根据参数列表来区分同名函数,参数列表必须不同。函数重写的函数(包括函数名、参数类型和返回类型)必须与基类中的函数相同。
- 静态绑定和动态绑定(静态多态和动态多态):函数重载是静态绑定(Static Binding)的一种,即在编译时根据函数调用时提供的参数类型来确定调用哪个函数。函数重写使用动态绑定(Dynamic Binding),即在运行时根据对象的实际类型来确定调用哪个函数,实现多态性。
5、虚函数和静态函数的区别
- 静态函数在编译时已经确定,而虚函数是在运行时动态绑定的。
- 虚函数因为用了虚函数表的机制,所以在调用的时候会增加一次内存开销。
6、纯虚函数与抽象类
- 纯虚函数:是指在基类中声明为虚函数,但是没有任何定义,即没有函数体。
- 纯虚函数的声明需要在virtual的基础上再函数的参数列表之后添加一个=0
class Cat {
public:
virtual void eat()=0;
};
- 只要含有纯虚函数的类就是抽象类
- 只含有纯虚函数而没有其他函数的类称为接口类。接口类没有任何数据成员,也没有构造函数和析构函数。
- 抽象类是只要含有纯虚函数就行,因此有以下特点:
1)抽象类不能实例化对象。因为纯虚函数什么都没实现,例如Cat
2)抽象类的派生类也可以是抽象类(会继承,例如CatA),也可以通过实现全部的纯虚函数使其变成非抽象类,从而可以实例化对象,例如CatB。
3)抽象类的指针可以指向其派生类对象,并调用派生类对象的成员函数。例如:p2是抽象类CatA 类型的指针,但可以指向派生类CatB,并调用其函数
4)接口类的指针也可以指向其派生类对象,因为接口类本质也是抽象类
class Cat{
public:
//含有纯虚函数,因此Cat为抽象类
virtual void eat() = 0;
virtual void sleap() = 0;
};
class CatA : public Cat {
public:
virtual void eat() { cout << "eat fish." << endl; }; //实现了eat()函数
virtual void sleap() = 0; //仍为纯虚函数,因此CatA也是抽象类
};
class CatB : public CatA {
public:
//两个纯虚函数都被实现,都变成一般的虚函数,因此CatB不是抽象类
virtual void eat() { cout << "eat fish." << endl; };
virtual void sleap() { cout << "sleap for a long time." << endl; };
};
int main() {
Cat a; //报错,Cat是抽象类,不能实例化对象
CatA A; //报错,CatA也是抽象类,不能实例化对象
CatB B; //正确,CatB不是抽象类
CatB *p1 = &B;
CatA *p2 = &B; //抽象类虽然不能实例化对象,但是可以声明其指针或引用
p1->eat(); //打印出 eat fish.
p1->sleap(); //打印出 sleap for a long time.
p2->eat(); //打印出 eat fish.
p2->sleap(); //打印出 sleap for a long time.
return 0;
}
7、虚基类
- 虚基类(Virtual Base Class)是 C++ 中用于解决多重继承中的菱形继承问题(Diamond Inheritance Problem)的机制。
- 什么是菱形继承?
1)当一个派生类通过多条路径继承同一个基类时,如果这些路径中存在共同的基类实例,就会导致派生类中存在多份相同的基类实例,从而引发二义性问题。
2)简单来说就是,一个派生类的两个父类,这两个父类都继承了同一个基类,也就形成了菱形的形状
- 因此,使用虚基类可以确保在继承体系中只有一份共同的基类实例,而不会出现重复。