虚函数

继承 — 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++ 代码定义了继承自 BaseDer 类。编译器会重复步骤 #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);
}

在典型的硬件上,机器代码是两次“加载”加上一次调用

  1. 第一次加载获取 v-pointer,将其存储到一个寄存器中,例如 r1。
  2. 第二次加载获取r1 + 3*4处的字(假设函数指针是4字节长,所以r1 + 12是指向正确类virt3()函数的指针)。假设它将该字放入寄存器r2(或者r1)。
  3. 第三条指令调用 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,派生出CarTruck类。代码遍历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’操作的半通用代码”),将 VehiclefooBar() 从纯虚函数改为普通虚函数,并将代码移动到该成员函数中

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;
}

此函数将正确工作,无论 ShapeCircleSquare,还是其他尚未存在的 Shape 类型。

注意:Circleclone() 成员函数的返回类型有意与 Shapeclone() 成员函数的返回类型不同。这被称为协变返回类型,这个特性最初并非语言的一部分。如果你的编译器在 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() 完全与诸如 AXAY 之类的类的知识隔离开来。