为什么光有继承还不够
上一页你构建了一个类层次结构:派生类从基类继承成员。但这里有个问题。当你通过基类指针调用方法时,C++ 是根据指针的类型而不是对象的真实类型来决定执行哪个函数的。所以一个实际指向 Dog 的 Animal*,调用的仍然是 Animal 版本的方法。
这几乎从来都不是你想要的。大多数时候,你有一组基类指针,每个指向不同的派生类对象,而你希望每个对象都表现得像它自己。虚函数就是用来实现这一点的。
对象确确实实是一个 Dog,但 a->speak() 执行的却是 Animal::speak()。因为 speak 不是虚函数,编译器在编译时就根据静态类型 Animal* 选定了函数。这正是虚函数要解决的 bug。
把函数声明为虚函数
在基类的方法上加上 virtual 关键字。现在调用会在运行时根据对象的实际类型来决定,这就是动态分派。
对 Animal* 做一次循环,得到三种不同的行为。基类指针在运行时“知道”真实的类型,并据此进行分派。这一个机制(一个接口,多种实现)就是 C++ 中多态的含义。
注意 virtual 只需要出现在基类的声明上;一个函数一旦是虚函数,在每个派生类中都会自动保持为虚函数。在派生类中再写一遍是可选的,也是多余的。
始终使用 override 关键字
在上面的例子中,每个派生类的方法都标上了 override。代码要运行起来并不需要它,但你应该把它当作必须写的。override(C++11)要求编译器验证你确实在用匹配的签名重写一个基类的虚函数。如果你把签名写错了一点点,得到的会是一个清晰的错误,而不是一个悄无声息的 bug。
struct Animal {
virtual void speak() const { } // note: const
};
struct Dog : Animal {
void speak() { } // NOT const - this is a NEW function, not an override!
void speak() override { } // error: 'speak' does not override - tells you immediately
};
没有 override 时,第一个 speak() 可以正常编译,但通过 Animal* 永远调用不到它,因为它的签名和基类不同(少了 const)。你可能要花一下午琢磨为什么你的重写不起作用。有了 override,编译器会当场发现不匹配。给每个重写的函数都加上它。
纯虚函数与抽象类
有时基类没有合理的默认实现:一只泛泛的“动物”该发出什么声音呢?这时可以通过赋值 = 0 把函数声明为纯虚函数。这样它就没有函数体,并且这个类会变成不能单独实例化的抽象类。它存在的唯一目的,就是定义一个派生类必须实现的接口。
每个具体的子类都必须实现 area(),否则它也仍然是抽象类。这就是 C++ 表达“接口”的方式:只包含纯虚函数的抽象类,相当于 Java 等语言中的接口。
虚析构函数规则
这是每个人至少会被坑一次的陷阱。当你通过基类指针 delete 一个对象时,C++ 会调用它找到的析构函数,如果那个析构函数不是虚函数,就只会执行基类的析构函数。派生部分永远不会被销毁,它拥有的资源都会泄漏。标准把这称为未定义行为。
修复只需要一个词:把基类的析构函数声明为 virtual。这样 delete p 会先执行 ~Derived,再执行 ~Base,完全符合预期。
struct Base {
virtual ~Base() { cout << "~Base\n"; } // correct
};
// now: ~Derived then ~Base
经验法则: 一个类只要有任何虚函数,就也给它一个虚析构函数。如果一个类打算作为基类、通过指针来使用,它的析构函数就必须是虚函数。
常见错误与陷阱
熟悉虚函数之后,还有几个陷阱需要留意:
对象切片。 如果你把派生类对象按值传递或存储到一个基类变量里,派生部分会被“切掉”,只剩下一个普通的基类对象,虚函数分派也就再也到达不了重写的版本。实现多态时,请始终使用指针或引用:
Dog d;
Animal a = d; // SLICED: a is now just an Animal, the Dog part is gone
a.speak(); // runs Animal::speak even though virtual
Animal& ref = d; // OK: reference keeps the real type
ref.speak(); // runs Dog::speak
不要在构造函数或析构函数中调用虚函数。 在构造期间派生部分还不存在,所以虚函数调用会解析为当前类的版本,而不是派生类的重写版本,这很少是你想要的。
虚函数分派有少量开销。 每次虚函数调用都要经过一张隐藏的函数指针表(“虚函数表”,vtable),每次调用多一次间接寻址。开销很小,但不是零,所以除非真的需要重写,否则不要把函数声明为虚函数。
有意调用基类版本。 在重写的函数内部,你仍然可以用 Base::method() 显式调用基类的实现。当派生类的行为是扩展而不是替换基类行为时,这很有用。
下一步:运算符重载
虚函数让你的对象可以通过共同的接口定制自己的行为。下一页会介绍如何定制作用在对象上的运算符:借助运算符重载,你可以让自己的类型响应 +、==、<< 等运算符,让 Vector + Vector 或 cout << myObject 读起来和内置类型一样自然。
常见问题
C++ 中的虚函数是什么?
虚函数是在基类中用 virtual 关键字声明的成员函数。当你通过基类指针或引用调用它时,C++ 执行的是派生类的重写版本,而不是基类的版本。这种在运行时做出的选择叫作动态分派,它是多态的基础。
虚函数和纯虚函数有什么区别?
虚函数有函数体,可以被重写。纯虚函数用 = 0 声明,在基类中没有函数体,它强制每个具体的派生类都提供实现。只要一个类至少有一个纯虚函数,它就是抽象类,不能被实例化。
C++ 中为什么基类需要虚析构函数?
如果你通过基类指针 delete 一个派生类对象,而基类的析构函数不是虚函数,那么只会执行基类的析构函数:派生部分永远不会被清理,这会泄漏资源,而且是未定义行为。任何打算以多态方式使用的类,都要把析构函数声明为 virtual。