在C++中顺利使用虚函数需知道的细节
- 如函数在派生类中的定义有别于基类中的定义,而且你希望它成为虚函数,就要为基类的函数声明添加保留字
virtual
。在派生类的函数声明中,则可以不添加virtual
。函数在基类中virtual
,在派生类中自动virtual
(但为了澄清,最好派生类中也将函数声明标记为virtual
,尽管这非必须)。 - 保留字
virtual
在函数声明中添加,不要再函数定义中添加。 - 除非使用保留字
virtual
,否则不能获得虚函数,也不能获得虚函数的任何好处。 - 既然虚函数如此好用,为何不将所有成员函数都设为
virtual
?这似乎只有一个理由——效率。编译器和“运行时”环境要为虚函数做多得多的工作。所以,无谓地将成员函数为virtual
会影响程序执行效率。
重写
虚函数定义在派生类中发生改变时我们说函数定义被重写。一些C++书籍区分了重定义(redefine)和重写(override)。两者都是在派生类更改函数定义。函数是虚函数,就称为重写。如果不是,就称为重定义。对于我们程序员而言,这种区分似乎有点无聊,因为程序员在两种情况下做的事情是一样的。不过,编译器对于这两种情况确定是区别对待的。
多态
多态性是指借助晚期绑定技术,为一个函数名关联多种含义的能力。因此,多态性、晚期绑定和虚函数其实是同一个主题。
虚函数和扩展类型兼容性、切割问题
#include <iostream>
#include <string>
using std::cout;
using std::endl;
using std::string;
class Pet
{
public:
virtual void print();
string name;
};
class Dog : public Pet
{
public:
virtual void print();
string breed; // 品种
};
void Pet::print()
{
cout << "Pet name: " << name << endl;
}
void Dog::print()
{
cout << "Dog name: " << name << ", breed: " << breed << endl;
}
int main()
{
Pet vPet;
Dog vDog;
vDog.name = "Tiny";
vDog.breed = "Great Dane";
vPet = vDog;
// cout << vPet.breed;
return 0;
}
上述代码vPet = vDog;
的赋值是允许的,但赋给变量vPet
的值会丢失其breed
字段。这称为切割问题(slicing problem)。例如,cout << vPet.breed
会报错。
切割问题:在将派生类对象赋给基类变量时,派生类对象有、基类没有的数据成员会在赋值过程中丢失,基类没有的成员函数也会丢失。在最终的基类对象中,将无法使用这些丢失的成员。
切割测试:
#include <iostream>
#include <string>
using std::cout;
using std::endl;
using std::string;
class Demo
{
public:
Demo(const string& s): str(s)
{
cout << "Demo constructor called (" + str + ").
";
}
~Demo()
{
cout << "Demo deconstructor called (" + str + ").
";
}
Demo(const Demo& other)
{
str = other.str;
cout << "Demo copy constructor called (" + str + ").
";
}
Demo& operator=(const Demo& other)
{
str = other.str;
cout << "Demo operator= called (" + str + ").
";
return *this;
}
private:
string str;
};
class Base
{
public:
Demo member1 = Demo("member1");
};
class Derived : public Base
{
public:
Demo member2 = Demo("member2");
};
int main()
{
Derived derived;
Base base;
base = derived;
}
/* Output
Demo constructor called (member1).
Demo constructor called (member2).
Demo constructor called (member1).
Demo operator= called (member1).
Demo deconstructor called (member1).
Demo deconstructor called (member2).
Demo deconstructor called (member1).
*/
幸好,C++提供了一种方式,允许在将一个Dog
视为Pet
的同时不丢失品种名称:
Pet *pPet;
Dog *pDog;
pDog = new Dog;
pDog->name = "Tiny";
pDog->breed = "Great Dane";
pPet = pDog;
pPet->print(); // prints "Dog name: Tiny, breed: Great Dane"
基类Pet
把print()
声明为virtual
。所以一旦编译器看到pPet->print();
就会检查Pet
和Dog
的virtual
表,判断pPet
指向的是Dog
类型的对象。因此,它会使用Dog::print()
,而不是Pet::print()
。
配合动态变量进行OOP是一种全然不同的编程方式。只要记住以下两条简单的规则,理解起来就容易得多。
- 如果指针
pAncestor
的域类型是指针pDescendant
的域类型的基类,则以下指针赋值操作允许:pAncestor = pDescendant;
。此外,pDescendant
指向的动态变量的任何数据成员或成员函数都不会丢失。 - 虽然动态变量所有附加字段(成员)都没有丢,但要用
virtual
成员函数访问。
视图对虚成员函数定义不齐全的类进行编译
编译前,如果还有任何尚未实现的virtual
成员函数,编译就会失败,并产生形如undefined reference to Class_Name virtual table
的错误信息。即使没有派生类,只有一个virtual
成员,并且没有调用该虚函数,只要函数没有定义,就会产生这种形式的消息。此外,可能还会产生进一步的错误消息,声称程序对默认构造函数进行了未定义的引用,即使确实已定义了这些构造函数。
始终/尽量使析构函数成为虚函数(主要讲述把析构函数声明为虚函数的优点)
这里主要阐述让析构函数称为虚函数的好处,但实际上也有坏处。在《Effective C++》条款07中有提到具体内容,见本文后记。
析构函数最好都是虚函数。但在解释它为什么好之前,首先解释一下析构函数和指针如何交互,以及虚析构函数的具体含义。如以下代码,其中SomeClass
是含有非虚析构函数的类:
SomeClass *p = new SomeClass;
// ...
delete p;
为p
调用delete
,会自动调用SomeClass
类的析构函数,现在看看将析构函数标记为virtual
之后会发生什么。为了描述析构函数与虚函数机制的交互,最简单的方式是将所有析构函数都视为同名(即使它们并非真的同名)。如假定Derived
类是Base
类的派生类,并假定Base
类的析构函数标记为virtual
,现在分析以下代码:
Base *pBase = new Derived;
// ...
delete pBase;
为pBase
调用delete
时,会调用一个析构函数。由于Base
类中的析构函数标记为virtual
,且指向的对象是Derived
类型,故会调用Derived
的析构函数(它进而调用Base类的析构函数)。若Base
类的析构函数没有标记为virtual
,则只调用Base
类的析构函数。
还要注意一点,将析构函数标记为virtual
后,派生类的所有析构函数都自动成为virtual
的(不管是否用virtual
标记)。同样,这种行为就好比所有析构函数具有相同的名称(即使事实上不同名)。
现在,已准备好解释为什么所有析构函数都应该是虚函数。假定Base
类有一个指针类型的成员变量pB
,Base
类的构造函数会创建由pB
指向的一个动态变量,而Base
类的析构函数会删除之;另外,假定Base
类的析构函数没有标记为virtual
,并假定Derived
类(从Base
派生)有一个指针类型的成员变量pD
,Derived
类的构造函数会创建由pD
指向的一个动态变量,而Derived
类的析构函数会删除之。则以下代码
Base *pBase = new Derived;
// ...
delete pBase;
由于基类析构函数未标记为virtual
,所以只会调用Base
类的析构函数。这会将pB
指向的动态变量的内存返还给自由存储;但pD
指向的动态变量占用的内存永远不会返还给自由存储直到程序终止。
另一方面,将基类Base
析构函数标记为virtual
,delete pBase;
时会调用Derived
类的析构函数(因为指向的对象是Derived
类型)。Derived
类的析构函数会删除pD
指向的动态变量,再自动调用基类Base
的析构函数删除pB
指向的动态变量。
测试代码:
#include <iostream>
class Base
{
public:
Base()
{
baseData = new int;
std::cout << "baseData allocated.
";
}
~Base()
{
delete baseData;
std::cout << "baseData deleted.
";
}
private:
int *baseData;
};
class Derived : public Base
{
public:
Derived()
{
derivedData = new int;
std::cout << "derivedData allocated.
";
}
~Derived()
{
delete derivedData;
std::cout << "derivedData deleted.
";
}
private:
int *derivedData;
};
int main()
{
Base *base = new Derived;
delete base;
}
/* Output
baseData allocated.
derivedData allocated.
baseData deleted.
*/
将第11行的~Base()
改为virtual ~Base()
,程序输出为
/* Output
baseData allocated.
derivedData allocated.
derivedData deleted.
baseData deleted.
*/
后记
参考:Walter Savitch《Problem Solving with C++, Tenth Edition》《Effective C++》。
《Effective C++》条款07:“为多态基类声明virtual析构函数”中提到:
- 带多态性质的基类应该声明一个virtual析构函数;如果类带有任何virtual函数,则它就应该拥有一个virtual析构函数。
- 类的设计目的如果不是作为基类使用,或不是为了具备多态性,就不该声明virtual析构函数。(如标准库
input_iterator_tag
等)