前言
虚函数是C++实现动态单分派子类型(dynamic single-dispatch subtype polymorphism)的方式。
*动态:在运行时决定的(相对的是静态,即在编译期决定,如函数重载、模板类的非虚函数调用)
*单分派:基于一个类型去选择调用哪个函数(相对于多分派,即由多个类型去选择调用哪个函数)
*子类型多态:以子类型-超类型关系实现多态(相对于参数形式,如函数重载、模板参数)
c++作为面向对象的语言,主要有三大特性:继承、封装、多态。关于多态,简而言之就是用父类型的指针指向其子类的实例,然后通过父类的指针调用实际子类的成员函数。这种技术可以让父类的指针有“多种形态”,这是一种泛型技术。所谓泛型技术,说白了就是试图使用不变的代码来实现可变的算法。比如:模板技术,RTTI技术,虚函数技术,要么是试图做到在编译时绑定,要么试图做到运行时绑定。因此C++的多态分为静态多态(编译时多态)和动态多态(运行时多态)两大类。静态多态通过重载、模板来实现;动态多态就是通过本文的主角虚函数来体现的。
例子
Animal <- Dog和Animal <- Cat。狗和猫均为动物,都具有四条腿、含有尾巴等特征。但细节上却各不相同,例如它们拥有不同的speak()方式。
class Animal{
···
string speak() const
{
return "???";
}
};
class Cat : public Animal{
···
string speak() const
{
return "Meow";
}
};
class Dog : public Animal{
···
string speak() const
{
return "Woof";
}
};
我们希望调用这些指针或者引用时,能够调用各个派生类自己的方法,比如下面的例子:
int main()
{
Cat cat{ "Fred" };
cout << cat.getName() << " says " << cat.speak() << endl;
Dog dog{ "Carbo" };
cout << dog.getName() << " says " << dog.speak() << endl;
Animal* catAnimal = &cat;
cout << catAnimal->getName() << " says " << catAnimal->speak() << endl;
Animal& dogAnimal = dog;
cout << dogAnimal.getName() << " says " << dogAnimal.speak() << endl;
return 0;
}
但是输出却不如预期:
Fred says Meow
Garbo says Woof
Fred says ???
Garbo says ???
无论是指针还是引用,它们都没有调用其派生对象所重写的方法,而是调用的基类的原有方法。如果要动态态确定其实际所指向的派生类对象,虚函数能够做到。 给每个speak()函数加上关键字virtual
class Animal{
···
virtual string speak() const
{
return "???";
}
···
};
再次输出,结果为:
Fred says Meow
Garbo says Woof
Fred says Meow
Garbo says Woof
事实上,派生类中的virtual关键字并不是必要的。一旦基类中的方法打上了virtual标签,那么派生类中匹配的函数也是虚函数。但是,还是建议在后面的派生类中加上virtual关键字,作为虚函数的一种提醒,以便后面可能还会有更远的派生。
重写
class Animal{
public:
virtual string getQuantity(int x) {
return "aa";
}
};
class Dog: public Animal {
public:
virtual string getQuantity(float x) {
return "aa";
}
};
int main() {
Dog dog;
Animal* animal = &animal;
cout << animal->getQuantity(3) << endl; //output: aa
}
可以发现,虚函数并没有重写基类版本,因为一个int类型一个float类型,所以两个方法的函数签名不一样,只是调用基类方法。有时候这种错误,(int->float)是不小心发生的,为了避免这种错误,C++引入override标识符,使用此标识符是告诉编译器是重写方法,如果方法不匹配,将无法通过编译。用override修改代码如下:
class Dog: public Animal {
public:
virtual string getQuantity(float x) override{
return "aa";
}
}; //此时无法编译
所以,要重写基类方法,建议使用override标识符,避免无意的错误。
fianl标识符
当然,当你不想要派生类重写基类的虚方法,此时可以使用final标识符,如果派生类重写了基类虚方法,将无法编译:
class Animal{
public:
virtual string getQuantity(int x) {
return "aa";
}
};
class Dog: public Animal{
public:
virtual string getQuantity(int x) override final {
return "aa";
}
};
class Cat: public Dog{
public:
virtual string getQuantity(int x) override{
return "aa";
} //无法编译
};
协变返回类型
派生类重写版本的返回类型是指向派生类的指针或者引用。 class Super { public: virtual Super* getThis() {return this;} };
class Sub: public Super { virtual Sub* getThis() override() {return this;} };
虚析构函数
存在继承关系,如果要删除派生类的指针,要运用虚析构。
函数调用捆绑
int add(int x, int y) {
return x + y;
};
int subtract(int x, int y) {
return x + y;
};
int multiply(int x, int y) {
return x + y;
};
早绑定:
result = add(x, y); break;
result = subtract(x, y); break;
result = multiply(x, y);break;
早捆绑:
int(*opFun)(int, int) = nullptr;
opFun = add;
opFun = subtract;
opFun = multiply;
晚捆绑:
opFun(x, y)
使用函数指针来间接调用函数,在编译阶段并不知道函数指针指向哪个函数,所以必须用动态捆绑的方式。
动态绑定看起来更灵活,但是其是有代价的。静态捆绑时,
原理
虚函数(Virtual Function)是通过一张虚函数表(Virtual Table)来实现的。简称为V-Table。在这个表中,主是要一个类的虚函数的地址表,这张表解决了继承、覆盖的问题,保证其容真实反应实际的函数。
虚表属于类,而不是属于某个具体的对象,一个类只需要一个虚表即可。同一个类的所有对象都使用同一个虚表。为了指定对象的虚表,对象内部包含一个虚表的指针,来指向自己所使用的虚表。为了让每个包含虚表的的对象都拥有一个虚表指针,编译器在类中添加了一个指针,*_vptr,用来指向虚表。这样,当类的对象在创建时便拥有了这个指针,且这个指针的值会自动被设置为指向类的虚表。
纯虚函数
有时候,基类的某个虚方法并不需要实现,但是希望派生类能够提供重写的版本。这个时候,你需要定义纯虚函数。纯虚函数在类的定义中显示说明该方法不需要实现,其作用在于指明派生类必须要重写它。纯虚函数的定义很简单:方法声明后紧跟着=0。
接口类
接口是一个抽象的概念,使用者只关注功能而不要求了解实现。一个接口类可以看成一些纯虚方法的集合,这意味着接口类仅有定义功能,而没有具体的实现。全是纯抽象函数
虚基类
//间接基类A
class A{
protected:
int m_a;
};

//直接基类B
class B: public A{
protected:
int m_b;
};
//直接基类C
class C: public A{
protected:
int m_c;
};
//派生类D
class D: public B, public C{
public:
void seta(int a){ m_a = a; } //命名冲突
void setb(int b){ m_b = b; } //正确
void setc(int c){ m_c = c; } //正确
void setd(int d){ m_d = d; } //正确
private:
int m_d;
};
int main(){
D d;
return 0;
}
试图直接访问成员变量 m_a时,发生了错误,因为类 B 和类 C 中都有成员变量 m_a(从 A 类继承而来),编译器不知道选用哪一个,所以产生了歧义。 为了消除歧义,我们可以在 m_a 的前面指明它具体来自哪个类:
void seta(int a){ B::m_a = a; }
这样表示使用 B 类的 m_a。当然也可以使用 C 类的:
void seta(int a){ C::m_a = a; }
虚继承:
//间接基类A
class A{
protected:
int m_a;
};
//直接基类B
class B: virtual public A{ //虚继承
protected:
int m_b;
};
//直接基类C
class C: virtual public A{ //虚继承
protected:
int m_c;
};
//派生类D
class D: public B, public C{
public:
void seta(int a){ m_a = a; } //正确
void setb(int b){ m_b = b; } //正确
void setc(int c){ m_c = c; } //正确
void setd(int d){ m_d = d; } //正确
private:
int m_d;
};
int main(){
D d;
return 0;
}
这段代码使用虚继承重新实现了上图所示的菱形继承,这样在派生类 D 中就只保留了一份成员变量 m_a,直接访问就不会再有歧义了。
虚继承的目的是让某个类做出声明,承诺愿意共享它的基类。在这种机制下,不论虚基类在继承体系中出现了多少次,在派生类中都只包含一份虚基类的成员。
对象切片
使用引用或者指针的方式,多态性都能够实现,但是传值的方式就存在问题。当我们将一个派生类对象直接赋值给基类对象时,仅仅基类的部分被复制,派生类的那部分信息将丢失。我们称这种现象为“对象切片”:对象丢失了自己原有的部分信息。使用对象本身并没有问题,但是处理不当,会造成很多问题。