C++虚函数是多态性实现的重要方式,当某个虚函数通过指针或者引用调用时,编译器产生的代码直到运行时才能确定到底调用哪个版本的函数。被调用的函数是与绑定到指针或者引用上的对象的动态类型相匹配的那个。因此,借助虚函数,我们可以实现多态性。这也是OOP
的核心思想之一。
考虑下面一个继承的例子,Dog类与Cat类都继承自Animal类,但是它们拥有不同的speak()方法:
#include <iostream> #include <string> class Animal { public: Animal(const std::string& name) : m_name{ name } { } const std::string& getName() const { return m_name; } std::string speak() const { return "???"; } private: std::string m_name; }; class Cat : public Animal { public: Cat(const std::string& name) : Animal(name) { } std::string speak() const { return "Meow"; } }; class Dog : public Animal { public: Dog(const std::string& name) : Animal(name) { } std::string speak() const { return "Woof"; } }; int main() { Cat cat{ "Fred" }; std::cout << "Cat is named " << cat.getName() << ", and it says " << cat.speak() << std::endl; Dog dog{ "Carbo" }; std::cout << "Dog is named " << dog.getName() << ", and it says " << dog.speak() << std::endl; Animal* catAnimal = &cat; std::cout << "Cat is named " << catAnimal->getName() << ", and it says " << catAnimal->speak() << std::endl; Animal& dogAnimal = dog; std::cout << "Dog is named " << dogAnimal.getName() << ", and it says " << dogAnimal.speak() << std::endl; return 0; }
结果
Cat is named Fred, and it says Meow Dog is named Carbo, and it says Woof Cat is named Fred, and it says ??? Dog is named Carbo, and it says ???
派生类的对象可以赋值给基类的指针或者引用,我们希望可以通过这些指针或引用可以调用各个派生类的方法。但是结果并不想预期那样,它们没有调用派生类所重写的方法,而是基类原有的方法。
大家可能会想,为什么我非要将派生类对象赋值给基类的指针或者引用来调用派生类的方法?直接利用派生类对象调用不就可以了吗? 使用基类的指针或引用来调用派生类的方法有很很多好处,比如你想使用一个函数,接收一个动物对象类,然后打印其名字以及叫声:
// 派生类调用派生类方法,重载 void print(Cat& cat) { cout << cat.getName() << " says " << cat.speak() << endl; } void print(Dog& dog) { cout << dog.getName() << " says " << dog.speak() << endl; } // 基类引用调用派生类方法 void print(Animal& animal) { cout << animal.getName() << " says " << animal.speak() << endl; }
由上可知,使用派生类来调用各自的方法会比使用基类调用派生类方法复杂多。
尽管每个派生类都有自己实现的speak()方法,但是它们实际上并没有真正的重写基类方法,仅仅是隐藏。因为派生类对象传递给基类的指针或者引用并没有调用派生类版本的方法,依然是基类方法,所以你需要虚函数。
虚函数仅需要再前面加上一个virtual
关键字即可,当你调用它时,它会根据绑定的派生类对象调用派生类中的方法。这种特性是多态性。匹配的规则是相同的函数签名(函数名,参数个数与类型)以及返回类型(返回类型可以不相同,但必须存在派生关系)。
利用虚函数我们可以修改上面的代码:
class Animal { public: Animal(const string& name) : m_name{ name } { } const string& getName() const { return m_name; } virtual string speak() const { return "???"; } private: string m_name; }; class Cat : public Animal { public: Cat(const string& name) : Animal(name) { } virtual string speak() const { return "Meow"; } }; class Dog : public Animal { public: Dog(const string& name) : Animal(name) { } virtual string speak() const { return "Woof"; } };
结果
cat is named Fred, and it says Meow dog is named Garbo, and it says Woof cat is named Fred, and it says Meow dog is named Garbo, and it says Woof
注意:
不论是基类版本还是派生类版本,我们都在函数前面使用了virtual
关键字,事实上,派生类中的virtual
关键字并不是必要的。一旦基类中的方法打上了virtual
标签,那么派生类中匹配的函数也是虚函数。但是,还是建议在后面的派生类中加上virtual
关键字,作为虚函数的一种提醒,以便后面可能还会有更远的派生。
千万不要在构造函数与析构函数中调用虚函数。我们知道派生类对象在创建时,首先基类部分先被创建,如果你在基类构造函数调用虚函数时,它此时将无法调用派生类版本的函数,因为派生类对象还未创建,此时派生类虚函数没有作用的对象。那么,它只能调用基类版本的虚函数。对于析构函数,派生类对象中的派生部分先被析构,如果你在基类析构函数中调用了虚函数,它也只能调用基类版本的虚函数,因为派生类对象已经不存在了。
派生类的重写方法必须与基类方法要匹配,否则编译器会认为派生类创建了一个新方法,而不是重写基类的版本,看下面的例子:
#include <iostream> #include <string> class Super { public: virtual std::string getName1(int x) { return "Super"; } virtual std::string getName2(int x) { return "Super"; } }; class Sub : public Super { public: virtual std::string getName1(double x) { return "Sub"; } virtual std::string getName2(int x) const { return "Sub"; } }; int main() { Sub sub; Super* super = ⊂ std::cout << super->getName1(1) << std::endl; // output: Super std::cout << super->getName2(2) << std::endl; // output: Super return 0; }
结果
Super Super
可以看到调用的是基类的方法,而不是派生类的方法,由于派生类中的方法与基类的方法不匹配,因此并没有重写基类版本,而是作为派生类的新方法。在实际中,我们希望派生类重写基类的方法,但是很容易犯一些小错误导致重写失效。
为了避免这样的错误,C++引入了override
标识符,使用这个标识符告诉编译器这是重写的方法,如果方法不匹配,那么将无法通过编译。
class Super { public: virtual std::string getName1(int x) { return "Super"; } virtual std::string getName2(int x) { return "Super"; } }; class Sub : public Super { public: virtual std::string getName1(double x) override { return "Sub"; } virtual std::string getName2(int x) const override { return "Sub"; } };
编译出错:
error: ‘virtual std::__cxx11::string Sub::getName1(double)’ marked ‘override’, but does not override error: ‘virtual std::__cxx11::string Sub::getName2(int) const’ marked ‘override’, but does not override
所以,只要重写基类方法,建议使用override
标识符,让编译器检查出可能的错误,从而避免无意的错误。
有时候,你不想派生类重写基类的虚方法,此时可以使用final
标识符,这个时候如果派生类重写了基类虚方法,那么将无法编译:
class A { public: virtual void someMethod() { std::cout << "A" << std::endl; } } class B : public A { public: // 基类A的someMethod方法没有final标识符,那么B可以重写该方法 // 但是此虚方法使用了final标识符,后面的派生类无法重写 virtual void someMethod() override final { std::cout << "B" << std::endl; } } class C : public B { public: // 无法编译,因为不允许重写 virtual void someMethod() override { std::cout << "C" << std::endl; } }
final
标识符还可以直接用于类,此时该类将不能被继承:
class A { public: virtual void someMethod() { std::cout << "A" << std::endl; } }; // B可以继承A class B final : public A { public: virtual void someMethod() override { std::cout << "B" << std::endl; } }; // B无法被继承,此时无法编译 class C : public B { public: virtual void someMethod() override { std::cout << "C" << std::endl; } };
要想成功重写方法,基类虚方法与派生类虚方法必须匹配,其中返回类型也必须一致。但是有时候返回类型不相同,也能实现重写,此时返回类型存在继承关系:基类方法返回类型是一个指向某一类的指针或者引用,而派生类重写版本的返回类型是指向派生类的指针或者引用。这种情况称为协变返回类型。下面是一个例子:
class Super { public: virtual Super* getThis() { return this; } }; class Sub : public Super { virtual Sub* getThis() override { return this; } };
对于析构函数,大部分时间我们只需要使用编译器提供的默认版本就好,除非涉及到释放动态分配的内存。但是如果存在继承,析构函数最好声明为虚函数。否则删除一个实际指向派生类的基类指针,只会调用基类的析构函数,而不会调用派生类的析构函数以及派生类数据成员的析构函数。这样就可能造成内存泄露,看下面的例子:
#include <iostream> #include <string> class Resource { public: Resource() { std::cout << "Resource created!" << std::endl; } ~Resource() { std::cout << "Resource destoryed!" << std::endl; } }; class Super { public: Super() { std::cout << "Super constructor called!" << std::endl; } ~Super() { std::cout << "Super destructor called!" << std::endl; } }; class Sub : public Super { public: Sub() { std::cout << "Sub constructor called!" << std::endl; } ~Sub() { std::cout << "Sub destructor called!" << std::endl; } private: Resource res; }; int main() { Sub* sub = new Sub; Super* super = sub; delete super; return 0; }
结果
Super constructor called! Resource created! Sub constructor called! Super destructor called!
从结果可知,派生类的析构函数和派生类数据成员的析构函数都没有被调用。因此需要讲析构函数声明为虚函数,派生类的析构函数才能被调用。
要想深刻理解虚函数机理,首先要了解函数调用捆绑机制。捆绑指的是将标识符(如变量名与函数名)转化为地址。这里我们仅仅关注有关函数调用的捆绑。
我们知道每个函数在编译的过程中是存在一个唯一的地址的。如果我们在程序段里面直接调用某个函数,那么编译器或者链接器会直接将函数标识符替换为一个机器地址,这种方式是早捆绑(静态捆绑),因为捆绑是在程序运行之前完成的。看下面的简单例子:
#include <iostream> #include <string> using namespace std; 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; } int main() { int x; cout << "Enter a number: "; cin >> x; int y; cout << "Enter another number: "; cin >> y; int op; cout << "Enter an operation (0=add, 1=subtract, 2=multiply): "; cin >> op; int result; switch (op) { // 使用早绑定来直接调用函数 case 0: result = add(x, y); break; case 1: result = subtract(x, y); break; case 2: result = multiply(x, y); break; } cout << "The answer is: " << result << endl; return 0; }
由于上面三个函数的调用都是直接使用函数名,采用早捆绑的方式。编译器会将每个函数调用替换为一个跳转指令,这个指令告诉CPU跳转到函数的地址来执行。
但是有时候,我们在程序运行前并不知道调用哪个函数,此时必须使用晚捆绑或者动态捆绑。晚绑定的一个例子就是使用函数指针,修改上面的例子:
int main() { int x; cout << "Enter a number: "; cin >> x; int y; cout << "Enter another number: "; cin >> y; int op; cout << "Enter an operation (0=add, 1=subtract, 2=multiply): "; cin >> op; // 定义一个函数指针 int (*opFun)(int, int) = nullptr; switch (op) { // 使用早捆绑来直接调用函数 case 0: opFun = add; break; case 1: opFun = subtract; break; case 2: opFun = multiply; break; } // 通过函数指针来调用,只能是晚捆绑 cout << "The answer is: " << opFun(x, y) << endl; return 0; }
使用函数指针来间接调用函数,编译器在编译阶段并不知道函数指针到底指向哪个函数,所以必须使用动态捆绑的方式。
动态绑定看起来更灵活,但是其是有代价的。静态捆绑时,CUP可以直接跳转到函数地址。但是动态捆绑,CPU必须先提取指针的地址,然后再跳转到指向的函数地址。这多了一个步骤!
C++使用了一种称为“虚表”的晚捆绑技术来实现虚函数。虚表是一个函数查询表,以动态捆绑的方式解析函数调用。每个具有一个或者多个虚函数的类都有一张虚表,这个表是在编译阶段建立的静态数组,其中包含了每个虚方法的函数指针,这些指针指向的是该类可见的派生最远的函数实现。其次,编译器会在基类对象都会添加一个隐含指针,这里我们称为*__vptr
。这个指针当然能够被派生类所继承,这相当重要。当类的实例被创建时,这个指针指向该类所对应的虚表。这样,当使用某个对象调用虚方法时,通过该指针查找虚表,然后根据实际的对象类型执行正确版本的方法调用。看下面的简单例子:
class Base { public: virtual void function1() { } virtual void function2() { } } class D1 : public Base { public: virtual void function1() override { } } class D2 : public Base { public: virtual void function2() override { } }
上面包含3个类,其中派生类D1与D2分别重写了基类的function1()和function2()虚方法。编译器会相应地创建3个不同的虚表,分别对应每个类,而且编译器也会自动地为基类添加一个函数指针。这样当一个类的实例创建式,该函数指针指向该类对应的虚表。
下面我们看看每个类的虚表是怎么建立的。因为仅有两个虚方法,所以每个虚表仅包含两个函数指针,分别对应function1()和function2()。但是每个函数指针实际指向的是那个类所可见的派生最远的函数实现:
下面是具体的示意图(来源:learncpp):
所以,下面的代码就有了很好的解释:
int main() { D1 d1; // d1中的*__vptr指向类D1的虚表 Base *dPtr = &d1; // dPtr对*__vptr是可见的,但是实际上其指向的是D1的虚表; dPtr->function1(); // 此时dPtr通过虚表查找,调用的是D1::function1() }
使用虚表技术,虚函数得以正确实现,从而实现多态性!
有时候,基类的某个虚方法并不需要实现,但是希望派生类能够提供重写的版本。这个时候,你需要定义纯虚函数。纯虚函数在类的定义中显示说明该方法不需要实现,其作用在于指明派生类必须要重写它。纯虚函数的定义很简单:方法声明后紧跟着=0
。如果一个类中至少含有一个纯虚函数,那么这个类是抽象类,抽象类无法实例化。当继承一个抽象类时,必须重写所有纯虚函数,否则继承出来的类也是一个抽象类。下面演示例子:
#include <iostream> #include <string> class Animal { public: Animal(const std::string& name) : m_name{ name } { } const std::string& getName() const { return m_name; } virtual std::string speak() const = 0; // 纯虚函数 // 因为包含一个纯虚方法,所以是抽象基类 private: std::string m_name; }; class Cat : public Animal { public: Cat(const std::string& name) : Animal(name) { } // 重写了纯虚方法,所以Cat不是抽象类,可以实例化 virtual std::string speak() const { return "Meow"; } }; // Dog没有重写基类的纯虚方法,所以仍然无法实例化 class Dog : public Animal { public: Dog(const std::string& name) : Animal(name) { } }; int main() { // Animal animal{"luly"}; // 无法编译,因为抽象基类无法实例化 Cat cat{ "Sally" }; // 合法 // Dog dog{ "Betsy" }; // 无法编译,因为抽象基类无法实例化 // 下面的代码可以运行,因为可以指向可以实例化的派生类对象 Animal* aPtr = new Cat{ "Sally" }; return 0; }
抽象类至少包含一个纯虚函数,抽象类无法实例化,但是重写纯虚方法的派生类可以实例化。
接口是一个抽象的概念,使用者只关注功能而不要求了解实现。一个接口类可以看成一些纯虚方法的集合,这意味着接口类仅有定义功能,而没有具体的实现。C++ 其实没有单独的接口概念,而在Java和C#等语言中接口是与类相区别的。但是 C++ 仍然可以使用接口类实现类似的效果。有时候,我们也称接口类为纯抽象类,因为这个类中全是虚方法。下面是一个纯抽象类的例子:
#include <iostream> #include <string> // 乐器纯抽象类 class Instrument { public: virtual void play() const = 0; virtual std::string what() const = 0; virtual void adjust(int) = 0; }; class Wind : public Instrument { public: virtual void play() const override { std::cout << "Wind: paly" << std::endl; } virtual std::string what() const override { return "Wind"; } virtual void adjust(int i) override { } }; class Brass : public Instrument { public: virtual void play() const override { std::cout << "Brass: paly" << std::endl; } virtual std::string what() const override { return "Brass"; } virtual void adjust(int i) override { } }; void tune(Instrument& i) { // ... i.play(); } void f(Instrument& i) { i.adjust(1); } int main() { Wind wind; Brass brass; tune(wind); tune(brass); f(wind); f(brass); return 0; }
可以看到Instrument是一个纯抽象类,其只提供方法的声明,具体却没有实现。但是它的两个派生类分别重写了这些纯虚方法,因此可以实例化。并且两个函数可以接收任意继承了Instrument的类实例对象。进一步说,这两个函数仅关注接收的对象是否提供了Instrument所要求的接口,但是不关注具体是怎么实现的。纯抽象类提供了更高级的抽象!这符合OOP的思想。
虚基类主要是用来解决菱形层次结构中的歧义基类问题。菱形层次结构是多重继承中的一个典例,还是例子说话:
#include <iostream> #include <string> using namespace std; class PoweredDevice { public: PoweredDevice(int power) { cout << "PoweredDevice: " << power << endl; } virtual void reportError() { cout << "Error" << endl; } }; class Scanner : public PoweredDevice { public: Scanner(int scanner, int power) : PoweredDevice(power) { cout << "Scanner: " << scanner << endl; } }; class Printer : public PoweredDevice { public: Printer(int printer, int power) : PoweredDevice(power) { cout << "Printer: " << printer << endl; } }; class Copier : public Scanner, public Printer { public: Copier(int scanner, int printer, int power) : Scanner(scanner, power), Printer(printer, power) { } }; int main() { Copier copier(1, 2, 3); // 无法编译,有歧义,因为继承了两个版本的PoweredDevice // copier.reportError(); return 0; }
结果:
PoweredDevice: 3 Scanner: 1 PoweredDevice: 3 Printer: 2
上面的继承关系有点复杂,但是画出继承图谱(来源:learncpp)就很清晰了:
Scanner和Printer分别继承了PoweredDevice类,然后Copier又同时继承了Scanner和Printer类,我们实际希望Copier仅继承一次PoweredDevice类,但是实际上Copier包含了两个版本的PoweredDevice。所以可以看到,PoweredDevice被构造了两次。而且更严重的是,PoweredDevice中没有被重写的方法是无法调用的,因为编译器会给出一个有歧义的错误!
此时,你可以用虚基类解决这个问题,只需要在继承列表中加上virtual
关键字:
#include <iostream> #include <string> using namespace std; class PoweredDevice { public: PoweredDevice(int power) { cout << "PoweredDevice: " << power << endl; } virtual void reportError() { cout << "Error" << endl; } }; class Scanner : virtual public PoweredDevice { public: Scanner(int scanner, int power) : PoweredDevice(power) { cout << "Scanner: " << scanner << endl; } }; class Printer : virtual public PoweredDevice { public: Printer(int printer, int power) : PoweredDevice(power) { cout << "Printer: " << printer << endl; } }; class Copier : public Scanner, public Printer { public: // Note: 虚基类是由派生最远的类负责创建,所以, // 构造函数初始化列表中需要增加虚基类的构造函数调用 Copier(int scanner, int printer, int power) : Scanner(scanner, power), Printer(printer, power), PoweredDevice(power) { } }; int main() { Copier copier(1, 2, 3); // 合法 copier.reportError(); return 0; }
结果
PoweredDevice: 3 Scanner: 1 Printer: 2 Error
利用虚基类,可以解决上面多重继承中歧义基类问题,基类仅被继承一次。但是要注意的是此时的虚基类由派生最远的类负责创建(可以看成该类的直接基类),因为PoweredDevice并没有无参构造函数,所以在Copier构造函数初始化列表中必须加上PoweredDevice的有参构造函数调用!
说点题外话,尽管虚基类可以解决多重继承中的菱形层次结构,但是看起来还是很抽象与复杂。实际上,多重继承本来就是一个很有争议的话题,因为使用多重继承会使得继承体系变得复杂,而且产生一系列问题,像Java和C#这类语言,是不允许多重继承的,但是其单独提供了接口,类可以继承多个接口,这也相当于多重继承了。而且好处是接口的继承相当于组合,这也是比较推崇的!
前面讲过,实现虚函数及多态性必须要用传地址的方式(引用或者指针)。一般,地址具有相同的长度,这意味着派生类对象的地址与基类对象的地址也是相同,尽管派生类对象所占的内存一般要高过基类对象。所以,传地址的方式不会导致类型信息损失,进而可以实现多态性。看下面的例子:
#include <iostream> #include <string> using namespace std; class Base { public: Base(int value) : m_value{ value } { } virtual string getName() const { return "Base"; } int getValue() const { return m_value; } protected: int m_value; }; class Derived : public Base { public: Derived(int value) : Base(value) { } virtual string getName() const override { return "Derived"; } }; int main() { Derived derived{ 5 }; cout << "derived is a " << derived.getName() << " with value " << derived.getValue() << endl; Base& ref = derived; cout << "ref is a " << ref.getName() << " with value " << ref.getValue() << endl; Base* ptr = &derived; cout << "ptr is a " << ptr->getName() << " with value" << ptr->getValue() << endl; Base base = derived; cout << "base is a " << base.getName() << " with value " << base.getValue() << endl; return 0; }
结果
derived is a Derived with value 5 ref is a Derived with value 5 ptr is a Derived with value5 base is a Base with value 5
由上可知,使用引用或者指针的方式,多态性能够实现,但是传值的方式就存在问题。当我们将一个派生类对象直接赋值给基类对象时,仅仅基类的部分被复制,派生类的那部分信息将丢失。我们称这种现象为“对象切片”:对象丢失了自己原有的部分信息。使用对象本身并没有问题,但是处理不当,会造成很多问题,看下面的例子:
int main() { Derived d1{5}; Derived d2{2}; Base& b = d2; b = d1; // 有隐患 return 0; }
上面的例子很简单,但是会有问题:首先d2引用给b时,b将指向d2,这没有问题。但是将d1的值直接赋值给b时,会发生对象切片,只有d1的基类部分复制给b。此时,问题来了,你会发现现在d2拥有d1的基类部分与d2的派生部分,这显得很混乱!所以,尽可能地别使用对象切片,否则你会麻烦不断!
前面的例子,我们都是将派生类对象复制给基类对象,不管是通过传地址的方式还是对象切片方式。这些都是向上转型——在类层次中向上移动。我们不禁会想,肯定会存在可以向下移动的向下转型。一般来说,派生类包含基类信息,所以向上转型是容易的。但是,反过来可能会失败!因为无法保证基类对象实际上存储的是派生类对象。看下面的例子:
void process(Base* ptr) { Derived* derived = static_cast<Derived*>(ptr); // 后序处理 // ... }
process函数接收一个基类指针,但是在内部使用static_cast
向下转型为派生类指针,然后进行后序处理。如果送入process函数的指针实际上就是指向派生类对象,那么上面的代码是没有问题的。但是,如果仅仅传入就是指向基类对象的指针,或者指向其他派生类的指针,那么函数内部的转型将存在问题:由于static_cast
在运行时是不检查对象实际类型的,这将导致不可控行为!
为了解决这样的隐患,C++引入了运行时的动态类型转化操作符dynamic_cast
。dynamic_cast
在运行时检测底层对象的类型信息。如果类型转换没有意义,那么它将返回一个空指针(对于指针类型)或者抛出一个std::bad_cast
异常(对于引用类型)。所以,可以修改上面的代码如下:
void process(Base* ptr) { Derived* derived = dynamic_cast<Derived*>(ptr); if (derived == nullptr) { // 后序处理 // ... } }
尽管如此,向下转型还是不推荐的,除非必要!