继承 — virtual
函数
什么是“virtual
成员函数”?
虚成员函数是面向对象范式的关键,例如使旧代码调用新代码变得容易。
一个virtual
函数允许派生类替换基类提供的实现。编译器会确保当对象实际上是派生类时,即使通过基类指针而不是派生类指针访问对象,替换也会始终被调用。这允许在派生类中替换基类中的算法,即使用户不知道派生类。
派生类可以完全替换(“覆盖”)基类成员函数,或者派生类可以部分替换(“增强”)基类成员函数。后者可以通过让派生类成员函数调用基类成员函数(如果需要)来实现。
为什么成员函数默认不是virtual
?
因为许多类并非设计为基类。例如,请参阅类complex
。
此外,带有虚函数的类对象需要虚函数调用机制所需的空间——通常每个对象一个字。这种开销可能很大,并且可能妨碍与其他语言(例如 C 和 Fortran)的数据布局兼容性。
更多设计原理请参阅《C++ 的设计和演化》。
C++ 如何在实现动态绑定的同时实现静态类型?
当你有一个指向对象的指针时,该对象实际上可能是一个派生自该指针类型的类的对象(例如,一个实际上指向 Car
对象的 Vehicle*
;这被称为“多态性”)。因此,有两种类型:指针的(静态)类型(在这种情况下是 Vehicle
),以及被指向对象的(动态)类型(在这种情况下是 Car
)。
静态类型意味着成员函数调用的合法性在最早可能的时间进行检查:由编译器在编译时检查。编译器使用指针的静态类型来确定成员函数调用是否合法。如果指针的类型能够处理该成员函数,那么被指向的对象肯定也能处理它。例如,如果 Vehicle
有某个成员函数,那么 Car
肯定也有该成员函数,因为 Car
是一种 Vehicle
。
动态绑定意味着成员函数调用中代码的地址在最后可能的时间确定:基于运行时对象的动态类型。它被称为“动态绑定”是因为与实际被调用代码的绑定是动态完成的(在运行时)。动态绑定是virtual
函数的结果。
什么是纯虚函数?
纯虚函数是一个必须在派生类中被覆盖且不需要定义的函数。虚函数使用奇特的=0
语法声明为“纯”。例如
class Base {
public:
void f1(); // not virtual
virtual void f2(); // virtual, not pure
virtual void f3() = 0; // pure virtual
};
Base b; // error: pure virtual f3 not overridden
这里,Base
是一个抽象类(因为它有一个纯虚函数),因此不能直接创建 Base
类的对象:Base
(明确地)旨在作为基类。例如
class Derived : public Base {
// no f1: fine
// no f2: fine, we inherit Base::f2
void f3();
};
Derived d; // ok: Derived::f3 overrides Base::f3
抽象类在定义接口方面非常有用。实际上,一个没有数据且所有函数都是纯虚函数的类通常被称为接口。
您可以为纯虚函数提供定义
Base::f3() { /* ... */ }
这偶尔有用(为派生类提供一些简单的通用实现细节),但 Base::f3()
仍必须在某些派生类中被覆盖。如果你不在派生类中覆盖纯虚函数,该派生类将成为抽象类
class D2 : public Base {
// no f1: fine
// no f2: fine, we inherit Base::f2
// no f3: fine, but D2 is therefore still abstract
};
D2 d; // error: pure virtual Base::f3 not overridden
virtual
和非virtual
成员函数的调用方式有什么区别?
非virtual
成员函数是静态解析的。也就是说,成员函数是根据指向对象(或引用)的指针类型静态选择的(在编译时)。
相比之下,virtual
成员函数是动态解析的(在运行时)。也就是说,成员函数是根据对象的类型,而不是指向该对象的指针/引用类型动态选择的(在运行时)。这称为“动态绑定”。大多数编译器使用以下技术的一种变体:如果对象有一个或多个virtual
函数,编译器会在对象中放置一个隐藏指针,称为“虚指针”或“v-pointer”。这个v-pointer指向一个全局表,称为“虚表”或“v-table”。
编译器会为每个至少有一个 virtual
函数的类创建一个 v-table。例如,如果 Circle
类有 draw()
、move()
和 resize()
的 virtual
函数,那么即使有无数个 Circle
对象,也只会有一个与 Circle
类关联的 v-table,并且每个 Circle
对象的 v-pointer 都会指向 Circle
的 v-table。v-table 本身包含指向类中每个虚函数的指针。例如,Circle
的 v-table 将有三个指针:一个指向 Circle::draw()
,一个指向 Circle::move()
,和一个指向 Circle::resize()
。
在虚函数分派过程中,运行时系统会沿着对象的 v-pointer 到类的 v-table,然后沿着 v-table 中相应的槽位到方法代码。
上述技术的空间开销是微不足道的:每个对象多一个指针(但仅限于需要进行动态绑定的对象),加上每个方法多一个指针(但仅限于虚方法)。时间开销也相当微不足道:与普通函数调用相比,虚函数调用需要两次额外的数据获取(一次获取 v-pointer 的值,第二次获取方法的地址)。所有这些运行时活动都不会发生在非virtual
函数上,因为编译器完全在编译时根据指针的类型解析非virtual
函数。
注意:以上讨论已大大简化,因为它没有考虑多重继承、虚继承、RTTI 等额外结构性事物,也没有考虑页面错误、通过函数指针调用函数等空间/速度问题。如果你想了解这些其他事物,请询问comp.lang.c++
;请勿给我发电子邮件!
当我调用一个虚函数时,硬件会发生什么?有多少层间接?有多少开销?
这是上一个常见问题的深入探讨。答案完全取决于编译器,因此您的实际情况可能有所不同,但大多数 C++ 编译器使用的方案与此处介绍的方案相似。
我们来举个例子。假设类Base
有5个虚函数:virt0()
到virt4()
。
// Your original C++ source code
class Base {
public:
virtual arbitrary_return_type virt0( /*...arbitrary params...*/ );
virtual arbitrary_return_type virt1( /*...arbitrary params...*/ );
virtual arbitrary_return_type virt2( /*...arbitrary params...*/ );
virtual arbitrary_return_type virt3( /*...arbitrary params...*/ );
virtual arbitrary_return_type virt4( /*...arbitrary params...*/ );
// ...
};
步骤1:编译器构建一个包含5个函数指针的静态表,将该表埋入静态内存中。许多(并非所有)编译器在编译定义Base
的第一个非内联虚函数的.cpp文件时定义此表。我们将此表称为v-table;我们假装它的技术名称是Base::__vtable
。如果函数指针在目标硬件平台上适合一个机器字,Base::__vtable
最终将占用5个隐藏字内存。不是每个实例5个,不是每个函数5个;就是5个。它可能看起来像以下伪代码
// Pseudo-code (not C++, not C) for a static table defined within file Base.cpp
// Pretend FunctionPtr is a generic pointer to a generic member function
// (Remember: this is pseudo-code, not C++ code)
FunctionPtr Base::__vtable[5] = {
&Base::virt0, &Base::virt1, &Base::virt2, &Base::virt3, &Base::virt4
};
步骤2:编译器为每个 Base
类对象添加一个隐藏指针(通常也是一个机器字)。这称为 v-pointer。将此隐藏指针视为一个隐藏数据成员,就像编译器将您的类重写为以下内容一样
// Your original C++ source code
class Base {
public:
// ...
FunctionPtr* __vptr; // Supplied by the compiler, hidden from the programmer
// ...
};
第三步:编译器在每个构造函数中初始化this->__vptr
。其目的是使每个对象的v-pointer指向其类的v-table,就像在每个构造函数的初始化列表中添加以下指令一样
Base::Base( /*...arbitrary params...*/ )
: __vptr(&Base::__vtable[0]) // Supplied by the compiler, hidden from the programmer
// ...
{
// ...
}
现在我们来研究一个派生类。假设你的 C++ 代码定义了继承自 Base
的 Der
类。编译器会重复步骤 #1 和 #3(但不重复 #2)。在步骤 #1 中,编译器会创建一个隐藏的 v-table,保留与 Base::__vtable
中相同的函数指针,但替换对应于覆盖的那些槽位。例如,如果 Der
覆盖了 virt0()
到 virt2()
并按原样继承了其他函数,那么 Der
的 v-table 可能看起来像这样(假设 Der
没有添加任何新的虚函数)
// Pseudo-code (not C++, not C) for a static table defined within file Der.cpp
// Pretend FunctionPtr is a generic pointer to a generic member function
// (Remember: this is pseudo-code, not C++ code)
FunctionPtr Der::__vtable[5] = {
&Der::virt0, &Der::virt1, &Der::virt2, &Base::virt3, &Base::virt4
↑↑↑↑ ↑↑↑↑ // Inherited as-is
};
在步骤3中,编译器在每个Der
构造函数的开头添加一个类似的指针赋值。这样做的目的是改变每个Der
对象的v-pointer,使其指向其类的v-table。(这不是第二个v-pointer;它是在基类Base
中定义的同一个v-pointer;请记住,编译器不会在Der
类中重复步骤2。)
最后,我们来看看编译器如何实现对虚函数的调用。您的代码可能如下所示
// Your original C++ code
void mycode(Base* p)
{
p->virt3();
}
编译器不知道这将调用Base::virt3()
还是Der::virt3()
,或者可能是另一个甚至还不存在的派生类的virt3()
方法。它只知道您正在调用virt3()
,而virt3()
恰好是v-table中槽位#3的函数。它会将该调用重写为如下所示
// Pseudo-code that the compiler generates from your C++
void mycode(Base* p)
{
p->__vptr[3](p);
}
在典型的硬件上,机器代码是两次“加载”加上一次调用
- 第一次加载获取 v-pointer,将其存储到一个寄存器中,例如 r1。
- 第二次加载获取
r1 + 3*4
处的字(假设函数指针是4字节长,所以r1 + 12
是指向正确类virt3()
函数的指针)。假设它将该字放入寄存器r2(或者r1)。 - 第三条指令调用 r2 位置的代码。
结论
- 与没有虚函数的类对象相比,具有虚函数的类对象只有很小的空间开销。
- 调用虚函数很快——几乎和调用非虚函数一样快。
- 无论继承深度有多深,都不会有额外的每次调用开销。你可能有10层继承,但没有“链式”——它总是相同的——获取、获取、调用。
注意:我故意忽略了多重继承、虚继承和 RTTI。根据编译器的不同,这些可能会使事情变得有点复杂。如果你想了解这些事情,请勿给我发电子邮件,而是询问comp.lang.c++
。
注意:此FAQ中的所有内容都依赖于编译器。您的实际情况可能有所不同。
我的派生类中的成员函数如何调用其基类中的相同函数?
使用 Base::f();
我们从一个简单的情况开始。当你调用一个非虚函数时,编译器显然不会使用虚函数机制。相反,它会按名称调用函数,使用成员函数的完全限定名。例如,以下C++代码…
void mycode(Fred* p)
{
p->goBowling(); // Pretend Fred::goBowling() is non-virtual
}
…可能会被编译成类似这样的 C 语言代码(p
参数在成员函数内部变成 this
对象)
void mycode(Fred* p)
{
__Fred__goBowling(p); // Pseudo-code only; not real
}
实际的名称修饰方案比上面暗示的简单方案更复杂,但你懂的。重点是这种情况没有什么奇怪的——它或多或少地解析为一个普通函数,就像printf()
一样。
现在回答上面问题中涉及的情况:当您使用其完全限定名(类名后跟“::
”)调用虚函数时,编译器不使用虚调用机制,而是使用与调用非虚函数相同的机制。换句话说,它按名称而不是按槽号调用函数。因此,如果您希望派生类Der
中的代码调用Base::f()
,即在其基类Base
中定义的f()
版本,您应该这样写
void Der::f()
{
Base::f(); // Or, if you prefer, this->Base::f();
}
编译器会将其转换为类似以下内容(再次使用过于简化的名称修饰方案)
void __Der__f(Der* this) // Pseudo-code only; not real
{
__Base__f(this); // Pseudo-code only; not real
}
我有一个异构对象列表,我的代码需要对这些对象执行类特定的操作。这似乎应该使用动态绑定,但我想不出来。我该怎么办?
这出奇地容易。
假设有一个基类Vehicle
,派生出Car
和Truck
类。代码遍历Vehicle
对象列表,根据Vehicle
的类型执行不同的操作。例如,它可能会称量Truck
对象(以确保它们没有超载),但可能会对Car
对象做一些不同的事情——例如检查注册。
对于这个问题,至少对大多数人来说,最初的解决方案是使用if
语句。例如,“如果对象是Truck
,执行这个,否则如果它是Car
,执行那个,否则执行第三件事”
typedef std::vector<Vehicle*> VehicleList;
void myCode(VehicleList& v)
{
for (VehicleList::iterator p = v.begin(); p != v.end(); ++p) {
Vehicle& v = **p; // just for shorthand
// generic code that works for any vehicle...
// ...
// perform the "foo-bar" operation.
// note: the details of the "foo-bar" operation depend
// on whether we're working with a car or a truck.
if (v is a Car) {
// car-specific code that does "foo-bar" on car v
// ...
} else if (v is a Truck) {
// truck-specific code that does "foo-bar" on truck v
// ...
} else {
// semi-generic code that does "foo-bar" on something else
// ...
}
// generic code that works for any vehicle...
// ...
}
}
这样做的问题在于我称之为“else-if-heimer 病”(快速说出来你就会明白)。上面的代码会让你患上 else-if-heimer 病,因为最终当你添加一个新的派生类时,你会忘记添加一个 else if
,你可能会有一个直到运行时才会被发现的 bug,更糟的是,当产品投入使用时才会被发现。
解决方案是使用动态绑定而不是动态类型。与其使用(我称之为)活代码死数据的比喻(代码是活的,汽车/卡车对象相对死),不如将代码移到数据中。这是伯特兰·迈耶(Bertrand Meyer)《倒置定律》的一个微小变体。
这个想法很简单:使用每个 if
的 {...}
块中代码的描述(在这种情况下是“foo-bar 操作”;显然您的名称会不同)。只需选择这个描述性名称,并将其用作基类中新的 virtual
成员函数的名称(在这种情况下,我们将向 Vehicle
类添加一个 fooBar()
成员函数)。
class Vehicle {
public:
// performs the "foo-bar" operation
virtual void fooBar() = 0;
};
然后你移除整个 if...else if
… 块,并用一个简单的对这个 virtual
函数的调用来替换它
typedef std::vector<Vehicle*> VehicleList;
void myCode(VehicleList& v)
{
for (VehicleList::iterator p = v.begin(); p != v.end(); ++p) {
Vehicle& v = **p; // just for shorthand
// generic code that works for any vehicle...
// ...
// perform the "foo-bar" operation.
v.fooBar();
// generic code that works for any vehicle...
// ...
}
}
最后,你将每个 if
的 {...}
块中原来的代码移动到相应派生类的 fooBar()
成员函数中
class Car : public Vehicle {
public:
virtual void fooBar();
};
void Car::fooBar()
{
// car-specific code that does "foo-bar" on 'this'
// this is the code that was in {...} of if (v is a Car)
}
class Truck : public Vehicle {
public:
virtual void fooBar();
};
void Truck::fooBar()
{
// truck-specific code that does "foo-bar" on 'this'
// this is the code that was in {...} of if (v is a Truck)
}
如果你在原始的 myCode()
函数中实际有一个 else
块(参见上面关于“对除了 Car 或 Truck 之外的东西执行‘foo-bar’操作的半通用代码”),将 Vehicle
的 fooBar()
从纯虚函数改为普通虚函数,并将代码移动到该成员函数中
class Vehicle {
public:
// performs the "foo-bar" operation
virtual void fooBar();
};
void Vehicle::fooBar()
{
// semi-generic code that does "foo-bar" on something else
// this is the code that was in {...} of the else
// you can think of this as "default" code...
}
就是这样!
当然,关键在于我们试图避免基于你正在处理的派生类类型的决策逻辑。换句话说,你正在试图避免“如果对象是汽车,则执行 xyz,否则如果是卡车,则执行 pqr”等等,因为这会导致 else-if-heimer 病。
我的析构函数什么时候应该是virtual
?
当有人通过基类指针delete
派生类对象时。
特别是,在以下情况下,你需要将析构函数声明为virtual
- 如果有人将从您的类派生,
- 并且如果有人将使用
new Derived
,其中Derived
是从您的类派生的, - 并且如果有人将使用
delete p
,其中实际对象的类型是Derived
,但指针p
的类型是您的类。
困惑吗?这里有一个简化的经验法则,它通常能保护你,并且通常不会让你付出任何代价:如果你的类有任何virtual
函数,就让你的析构函数成为virtual
。理由如下
- 这通常能保护你,因为大多数基类至少有一个
virtual
函数。 - 这通常不会让你付出任何代价,因为在你的类中,第二个或后续的
virtual
函数没有增加每个对象的空间开销。换句话说,一旦你添加了第一个virtual
函数,你就已经支付了你可能支付的所有每个对象的空间开销,所以virtual
析构函数不会增加任何额外的每个对象空间开销。(此点中的所有内容在理论上都与编译器相关,但实际上它在几乎所有编译器上都有效。)
注意:在派生类中,如果你的基类有一个virtual
析构函数,你自己的析构函数会自动成为virtual
。你可能出于其他原因需要明确定义析构函数,但没有必要仅仅为了确保它是virtual
而重新声明析构函数。无论你是否使用virtual
关键字声明它,是否不使用virtual
关键字声明它,或者根本不声明它,它仍然是virtual
。
顺便说一下,如果你感兴趣,这里是当你使用指向Derived
对象的Base
指针调用delete
时,为什么你需要一个virtual
析构函数的具体细节。当你调用delete p
,并且p
的类有一个virtual
析构函数时,被调用的析构函数是与对象*p
的类型关联的那个,而不一定是与指针类型关联的那个。这是一件好事。事实上,违反这个规则会使你的程序未定义。技术术语是“糟糕”。
为什么析构函数默认不是virtual
?
因为许多类不是设计用作基类。虚函数仅在旨在用作派生类对象接口的类中有意义(通常在堆上分配并通过指针或引用访问)。
那么我什么时候应该将析构函数声明为虚函数呢?当类至少有一个虚函数时。拥有虚函数表明一个类旨在作为派生类的接口,并且在这种情况下,派生类的对象可以通过指向基类的指针销毁。例如
class Base {
// ...
virtual ~Base();
};
class Derived : public Base {
// ...
~Derived();
};
void f()
{
Base* p = new Derived;
delete p; // virtual destructor used to ensure that ~Derived is called
}
如果 Base
的析构函数不是虚函数,那么 Derived
的析构函数将不会被调用——这很可能导致不良后果,例如 Derived
所拥有的资源未被释放。
什么是“virtual
构造函数”?
一种允许你实现 C++ 不直接支持功能的习语。
你可以通过一个virtual
clone()
成员函数(用于拷贝构造)或一个virtual
create()
成员函数(用于默认构造函数)来获得virtual
构造函数的效果。
class Shape {
public:
virtual ~Shape() { } // A virtual destructor
virtual void draw() = 0; // A pure virtual function
virtual void move() = 0;
// ...
virtual Shape* clone() const = 0; // Uses the copy constructor
virtual Shape* create() const = 0; // Uses the default constructor
};
class Circle : public Shape {
public:
Circle* clone() const; // Covariant Return Types; see below
Circle* create() const; // Covariant Return Types; see below
// ...
};
Circle* Circle::clone() const { return new Circle(*this); }
Circle* Circle::create() const { return new Circle(); }
在 clone()
成员函数中,new Circle(*this)
代码调用 Circle
的拷贝构造函数,将 this
的状态复制到新创建的 Circle
对象中。(注意:除非 Circle
被已知为最终类(AKA 叶子类),否则通过将其拷贝构造函数设置为 protected
,可以减少切片的机会。)在 create()
成员函数中,new Circle()
代码调用 Circle
的默认构造函数。
用户像使用“virtual
构造函数”一样使用这些功能
void userCode(Shape& s)
{
Shape* s2 = s.clone();
Shape* s3 = s.create();
// ...
delete s2; // You need a virtual destructor here
delete s3;
}
此函数将正确工作,无论 Shape
是 Circle
、Square
,还是其他尚未存在的 Shape
类型。
注意:Circle
的 clone()
成员函数的返回类型有意与 Shape
的 clone()
成员函数的返回类型不同。这被称为协变返回类型,这个特性最初并非语言的一部分。如果你的编译器在 Circle
类中声明 Circle* clone() const
时抱怨(例如,说“返回类型不同”或“成员函数的类型仅因返回类型而异”),那么你使用的是旧编译器,你将不得不将返回类型更改为 Shape*
。
为什么我们没有virtual
构造函数?
虚调用是一种在信息不完整的情况下完成工作的机制。特别是,virtual
允许我们仅了解接口而不了解对象的精确类型就调用函数。要创建对象,您需要完整的信息。特别是,您需要知道要创建的精确类型。因此,“调用构造函数”不能是虚的。
当您要求创建一个对象时,使用间接寻址的技术通常被称为“虚拟构造函数”。例如,请参阅TC++PL3 15.6.2。
例如,这里有一个使用抽象类生成适当类型对象的技术
struct F { // interface to object creation functions
virtual A* make_an_A() const = 0;
virtual B* make_a_B() const = 0;
};
void user(const F& fac)
{
A* p = fac.make_an_A(); // make an A of the appropriate type
B* q = fac.make_a_B(); // make a B of the appropriate type
// ...
}
struct FX : F {
A* make_an_A() const { return new AX(); } // AX is derived from A
B* make_a_B() const { return new BX(); } // BX is derived from B
};
struct FY : F {
A* make_an_A() const { return new AY(); } // AY is derived from A
B* make_a_B() const { return new BY(); } // BY is derived from B
};
int main()
{
FX x;
FY y;
user(x); // this user makes AXs and BXs
user(y); // this user makes AYs and BYs
user(FX()); // this user makes AXs and BXs
user(FY()); // this user makes AYs and BYs
// ...
}
这是通常被称为“工厂模式”的一种变体。重点是 user()
完全与诸如 AX
和 AY
之类的类的知识隔离开来。