奇怪的继承

继承 — 你的母亲从未告诉你的

我如何设置我的类,使其不能被继承?

只需将类声明为 final

但也要问问自己为什么要这样做?通常有两种答案:

  • 为了效率:避免您的函数调用是 virtual
  • 为了安全:确保您的类不被用作基类(例如,确保您可以复制对象而无需担心切片)。

在当今常用的实现中,调用虚函数需要从对象中获取“vptr”(即虚表指针),通过常量对其进行索引,并通过在该位置找到的函数指针间接调用函数。常规调用通常是对字面地址的直接调用。虽然虚函数调用看起来工作量更大,但判断成本的正确方法是与函数实际执行的工作量进行比较。如果工作量很大,那么调用本身的成本相比之下可以忽略不计,而且通常无法测量。但是,如果函数体很简单(即,一个访问器或转发),虚函数调用的成本是可测量的,有时甚至很重要。

虚函数调用机制通常仅在使用指针或引用调用时使用。当直接调用命名对象(例如,在调用者的堆栈上分配的对象)的函数时,编译器会插入常规调用的代码。但是请注意,频繁的这种使用可能表明设计中存在其他问题——虚函数只有与多态和间接使用(指针和引用)结合使用才能发挥作用。此类情况可能需要重新审查设计以避免过度使用 virtual

我如何设置我的成员函数,使其在派生类中不能被重写?

只需将函数声明为 final

但同样,问问自己为什么要这样做。请参阅针对 final 类给出的原因

基类的非 virtual 函数调用 virtual 函数是否可以?

可以。这有时(不总是!)是一个好主意。例如,假设所有 Shape 对象都有一个通用的打印算法,但这个算法取决于它们的面积,并且它们可能都有不同的计算面积的方法。在这种情况下,Shapearea() 成员函数必然是 virtual(可能是纯 virtual),但 Shape::print() 可以是,如果我们保证没有派生类需要不同的打印算法,则可以在基类 Shape 中定义为非 virtual

#include "Shape.h"

void Shape::print() const
{
    float a = this->area();  // area() is pure virtual
    // ...
}

上一条 FAQ 让我很困惑。这与使用 virtual 函数的其他方式是不同的策略吗?到底发生了什么?

是的,这是一种不同的策略。是的,使用 virtual 函数确实有两种不同的基本方式:

  1. 假设您遇到了上一条 FAQ 中描述的情况:您有一个成员函数,其整体结构对每个派生类都是相同的,但每个派生类中都有一些小片段是不同的。因此,算法是相同的,但原语是不同的。在这种情况下,您将把整体算法作为 public 成员函数(有时是非 virtual)写在基类中,并将小片段写在派生类中。小片段将在基类中声明(它们通常是 protected,通常是纯 virtual,并且它们肯定是 virtual),并且它们最终将在每个派生类中定义。在这种情况下最关键的问题是包含整体算法的 public 成员函数是否应该是 virtual。答案是,如果您认为某些派生类可能需要重写它,则使其成为 virtual
  2. 假设您遇到了与上一条 FAQ 完全相反的情况,即您有一个成员函数,其整体结构在每个派生类中都不同,但它有一些小片段在大多数(如果不是所有)派生类中都是相同的。在这种情况下,您将把整体算法放在一个 public virtual 中,该 virtual 最终在派生类中定义,而公共代码的小片段可以编写一次(以避免代码重复)并存储在某个地方(任何地方!)。存储小片段的常见位置是基类的 protected 部分,但这并非必需,甚至可能不是最好的。只需找到一个地方存储它们即可。请注意,如果将它们存储在基类中,通常应将其设为 protected,因为它们通常执行 public 用户不需要/不想执行的操作。假设它们是 protected,它们可能不应该是 virtual:如果派生类不喜欢其中一个的行为,它就不必调用该成员函数。

强调一下,上述列表是一种“两者兼而有之”的情况,而不是“非此即彼”的情况。换句话说,您不必在任何给定的类中选择这两种策略中的一种。成员函数 f() 对应于策略 #1,而成员函数 g() 对应于策略 #2 是完全正常的。换句话说,在同一个类中同时使用这两种策略是完全正常的。

我应该使用保护性虚函数而不是公共虚函数吗?

有时是,有时否。

首先,远离“总是/从不”的规则,而是使用最适合当前情况的方法。至少有两个充分的理由使用受保护的虚函数(见下文),但仅仅因为有时使用受保护的虚函数更好,并不意味着您应该总是使用它们。一致性和对称性在一定程度上是好的,但归根结底,最重要的指标是成本 + 进度 + 风险,除非一个想法能实质性地改善成本和/或进度和/或风险,否则它只是为了对称而对称(或为了保持一致而保持一致,等等)。

根据我的经验,最便宜 + 最快 + 风险最低的方法最终会导致大多数虚函数都是公共的,而受保护的虚函数则在以下两种情况之一时使用:上一条 FAQ 中讨论的情况,或与隐藏规则相关的情况。

后者需要一些额外的评论。假设您有一个基类,其中包含一组重载的虚函数。为了简化示例,假设只有两个:virtual void f(int)virtual void f(double)公共重载非虚函数调用保护性非重载虚函数的习语思想是将公共重载成员函数更改为非虚函数,并让它们调用保护性非重载虚函数。

使用公共重载虚函数的代码

class Base {
public:
  virtual void f(int x);    // May or may not be pure virtual
  virtual void f(double x); // May or may not be pure virtual
};

通过公共重载非虚函数调用保护性非重载虚函数习语改进此代码

class Base {
public:
  void f(int x)    { f_int(x); }  // Non-virtual
  void f(double x) { f_dbl(x); }  // Non-virtual
protected:
  virtual void f_int(int);
  virtual void f_dbl(double);
};

以下是原始代码的概述

成员函数 公共? 内联? 虚函数? 重载?
f(int) & f(double) 是的 是的 是的

以下是使用公共重载非虚函数调用保护性非重载虚函数习语的改进代码概述

成员函数 公共? 内联? 虚函数? 重载?
f(int) & f(double) 是的 是的 是的
f_int(int) & f_dbl(double) 是的

我和其他人使用这种习语的原因是为了让派生类的开发者更容易、更不容易出错。还记得上面提到的目标吗:进度 + 成本 + 风险?让我们根据这些目标评估这个习语。从成本/进度角度来看,基类(单一)略大,但派生类(复数)略小,因此在进度和成本方面取得了(小的)净改进。更显著的改进在于风险:这种习语将正确管理隐藏规则的复杂性封装到基类(单一)中。这意味着派生类(复数)或多或少会自动处理隐藏规则,因此生产这些派生类的各种开发者几乎可以完全专注于派生类本身的细节——他们无需担心(微妙且经常被误解的)隐藏规则。这大大降低了派生类的编写者搞砸隐藏规则的可能性。

恕我直言,斯波克先生说,许多人的需求(派生类(复数))大于一个人的需求(基类(单一))

(阅读隐藏规则,了解为什么在重写一组重载成员函数中的部分而不是全部时需要小心,以及因此为什么上述方法能使派生类更易于使用。)

什么时候应该使用私有虚函数?

当您需要在基类中使特定行为在派生类中可定制,同时保护接口(和/或其中的基本算法)的语义时,这些语义是在调用私有虚成员函数的公共成员函数中定义的。

私有虚函数出现的一种情况是在实现模板方法设计模式时。一些专家,例如 Herb Sutter 的 C/C++ Users Journal 文章 Virtuality,倡导始终将虚函数定义为私有,除非有充分的理由将其设置为保护。在他们看来,虚函数不应是公共的,因为它们定义了类的接口,该接口必须在所有派生类中保持一致。保护和私有虚函数定义了类的可定制行为,无需将其公开。公共虚函数将同时定义接口和定制点,这种二元性可能反映了设计上的缺陷。

顺便说一下,私有虚函数可以被重写,更不用说它们根本有效,这让大多数 C++ 初学者感到困惑。我们都被教导说基类中的私有成员在派生类中是不可访问的,这是正确的。然而,这种派生类不可访问性与虚函数调用机制无关,虚函数调用机制是派生类的。由于这可能会混淆初学者,C++ FAQ 以前建议使用保护性虚函数而不是私有虚函数。然而,私有虚函数方法现在已经足够普遍,因此初学者的困惑不再是一个主要问题。

你可能会问,一个派生类不能调用的函数有什么用?即使派生类不能在基类中调用它,基类也可以调用它,这有效地调用了(适当的)派生类。这就是模板方法模式的全部意义所在。

想想“回到未来”。假设基类是去年编写的,而您今天晚些时候将创建一个新的派生类。基类的成员函数,可能在几个月前就已经编译并放入库中,将调用私有(或保护)虚函数,这将有效地“调用未来”——几个月前编译的代码将调用甚至尚不存在的代码——您将在接下来的几分钟内编写的代码。您无法访问基类的私有成员——您无法回到过去,但过去可以进入未来并调用您尚未编写的成员函数。

以下是模板方法模式的样子

class MyBaseClass {
public:
  void myOp();

private:
  virtual void myOp_step1() = 0;
  virtual void myOp_step2();
};

void MyBaseClass::myOp()
{
  // Pre-processing...

  myOp_step1();  // call into the future - call the derived class
  myOp_step2();  // optionally the future - this one isn't pure virtual

  // Post-processing...
}

void MyBaseClass::myOp_step2()
{
  // this is "default" code - it can optionally be customized by a derived class
}

在这个例子中,公共成员函数 MyBaseClass::myOp() 实现了执行某个操作的接口和基本算法。预处理和后处理,以及步骤 1 和步骤 2 的顺序是故意固定的,不能由派生类定制。如果 MyBaseClass::myOp() 是虚函数,该算法的完整性将受到严重损害。相反,定制仅限于算法的特定“部分”,这些部分在两个私有虚函数中实现。这强制派生类更好地遵守基类中体现的原始意图,也使定制更容易——派生类的作者需要编写更少的代码。

如果 MyBaseClass::myOp_step2() 可能需要由派生类调用,例如,如果派生类可能需要(或想要)使用该代码来简化其自己的代码,那么可以将其从私有虚函数提升为受保护虚函数。如果由于基类属于不同的组织而无法实现,作为权宜之计,可以复制代码。

(此时我几乎能读懂您的想法:“什么?复制代码???!您开玩笑吧?!?那会增加维护成本并重复错误!!您疯了吗?!?!”我是否疯了还有待观察,但我经验丰富到足以意识到生活有时会将您逼入绝境。如果基类无法修改,有时最不坏的选择是复制一些代码。“最不坏”。请记住,一种尺寸并不适合所有情况,“思考”不是一个贬义词。所以忍着点,做最不坏的事情。然后洗澡。两次。但是,如果您因为等待某个第三方更改其基类而冒着团队成功的风险,或者如果您使用 #define 来更改 private 的含义,那么您可能选择了更糟糕的邪恶。哦对了,如果您复制了代码,请在旁边加上一个大大的注释,这样我就不会认为疯了!!SMILE!。)

另一方面,如果您正在创建基类,并且不确定派生类是否希望调用 MyBaseClass::myOp_step2(),您可以将其声明为 protected 以防万一。在这种情况下,您最好在它旁边加上一个醒目的注释,这样 Herb 就不会认为您疯了!无论哪种方式,总会有人认为您疯了。

当我的基类的构造函数在其 this 对象上调用 virtual 函数时,为什么我的派生类对该 virtual 函数的重写没有被调用?

因为那会非常危险,C++ 正在保护您免受这种危险。

本 FAQ 的其余部分解释了为什么 C++ 需要保护您免受这种危险,但在我们开始之前,请注意,您可以通过初始化期间的动态绑定习语,即使在构造函数期间也能在 this 对象上获得如同动态绑定工作的效果

可以在构造函数中调用虚函数,但要小心。它可能不会像你预期的那样做。在构造函数中,虚函数调用机制被禁用,因为派生类的重写尚未发生。对象是从基类开始构造的,“基类在派生类之前”。

考虑

    #include<string>
    #include<iostream>
    using namespace std;

    class B {
    public:
        B(const string& ss) { cout << "B constructor\n"; f(ss); }
        virtual void f(const string&) { cout << "B::f\n";}
    };

    class D : public B {
    public:
        D(const string & ss) :B(ss) { cout << "D constructor\n";}
        void f(const string& ss) { cout << "D::f\n"; s = ss; }
    private:
        string s;
    };

    int main()
    {
        D d("Hello");
    }

程序编译并生成

    B constructor
    B::f
    D constructor

注意不是 D::f。考虑一下如果规则不同,D::f()B::B() 被调用会发生什么:由于构造函数 D::D() 尚未运行,D::f() 将尝试将其参数赋值给未初始化的字符串 s。结果很可能是立即崩溃。所以幸运的是 C++ 语言不允许这种情况发生:它确保在控制流经 B 的构造函数时发生的任何对 this->f() 的调用最终都会调用 B::f(),而不是重写 D::f()

销毁是“派生类在基类之前”完成的,因此虚函数的行为与构造函数中一样:只使用本地定义——并且不调用重写函数,以避免触及对象中(现在已销毁的)派生类部分。

有关更多详细信息,请参阅 D&E 13.2.4.2 或 TC++PL3 15.4.3。

有人提出这条规则是实现上的产物。并非如此。事实上,实现从构造函数调用虚函数的非安全规则(与从其他函数调用完全相同)会明显更容易。然而,这将意味着任何虚函数都不能依赖于基类建立的不变量来编写。那将是一团糟。

好的,但是有没有办法模拟这种行为,就好像动态绑定在基类的构造函数中对 this 对象起作用一样?

有:初始化期间的动态绑定习语(又称初始化期间调用虚函数)。

为了澄清,我们讨论的是 Base 的构造函数在其 this 对象上调用虚函数的情况

class Base {
public:
  Base();
  // ...
  virtual void foo(int n) const; // often pure virtual
  virtual double bar() const;    // often pure virtual
  // if you don't want outsiders calling these, make them protected
};

Base::Base()
{
  // ...
  foo(42);  // Warning: does NOT dynamically bind to the derived class
  bar();    // (ditto)
  // ...
}

class Derived : public Base {
public:
  // ...
  virtual void foo(int n) const;
  virtual double bar() const;
};

本 FAQ 展示了一些方法来模拟动态绑定,就好像Base 的构造函数中进行的调用动态绑定到了 this 对象的派生类一样。我们将展示的方法都有权衡,所以选择最适合您需求的方法,或者自己创建一个。

第一种方法是两阶段初始化。在第一阶段,有人调用实际的构造函数;在第二阶段,有人在对象上调用一个“初始化”函数。在第二阶段,this 对象上的动态绑定运行良好,并且第二阶段概念上是构造的一部分,因此我们只需将一些代码从原始的 Base::Base() 移动到 Base::init()

class Base {
public:
  void init();  // may or may not be virtual
  // ...
  virtual void foo(int n) const; // often pure virtual
  virtual double bar() const;    // often pure virtual
};

void Base::init()
{
  // Almost identical to the body of the original Base::Base()
  // ...
  foo(42);
  bar();
  // ...
}

class Derived : public Base {
public:
  // ...
  virtual void foo(int n) const;
  virtual double bar() const;
};

唯一剩下的问题是确定在哪里调用第一阶段以及在哪里调用第二阶段。这些调用可以在很多地方存在;我们将考虑两种情况。

第一种变体最初最简单,尽管实际创建对象的代码需要一点点程序员的自律,这实际上意味着你注定要失败。严肃地说,如果只有一两个地方实际创建此层次结构的对象,程序员的自律将非常局部化,不应引起问题。

在这种变体中,创建对象的代码显式执行这两个阶段。在执行第一阶段时,创建对象的代码要么知道对象的精确类(例如,new Derived() 或一个局部 Derived 对象),要么不知道对象的精确类(例如,虚构造函数习语或其他一些工厂)。当您希望轻松插入新的派生类时,“不知道”的情况是强烈首选的。

注意:第一阶段通常(但不总是)从堆分配对象。当它分配时,您应该将指针存储在某种托管指针中,例如std::unique_ptr引用计数指针,或某个其析构函数delete分配的对象。这是当第二阶段可能抛出异常时防止内存泄漏的最佳方法。以下示例假设第一阶段从堆分配对象。

#include <memory>

void joe_user()
{
  std::unique_ptr<Base> p( /*...somehow create a Derived object via new...*/ );
  p->init();
  // ...
}

第二种变体是将 joe_user 函数的前两行组合到一个 create 函数中。当有许多类似 joe_user 的函数时,这几乎总是正确的做法。例如,如果您使用某种工厂,例如注册表和虚构造函数习语,您可以将这两行移到一个名为 Base::create() 的静态成员函数中

#include <memory>

class Base {
public:
  // ...
  using Ptr = std::unique_ptr<Base>;  // type aliases simplify the code
  static Ptr create();
  // ...
};

Base::Ptr Base::create()
{
  Ptr p( /*...use a factory to create a Derived object via new...*/ );
  p->init();
  return p;
}

这简化了所有类似 joe_user 的函数(一点点),但更重要的是,它减少了任何一个函数在不调用 init() 的情况下创建 Derived 对象的可能性。

void joe_user()
{
  Base::Ptr p = Base::create();
  // ...
}

如果您足够聪明和积极,您甚至可以消除有人在不调用 init() 的情况下创建 Derived 对象的可能性。实现这一目标的一个重要步骤是Derived 的构造函数,包括其复制构造函数,设为 protectedprivate

下一个方法不依赖于两阶段初始化,而是使用第二个层次结构,其唯一工作是容纳成员函数 foo()bar()。这种方法并不总是有效,特别是在 foo()bar() 需要访问 Derived 中声明的实例数据的情况下,但它在概念上非常简单和清晰,并且常用。

我们将此第二层次结构的基类命名为 Helper,其派生类命名为 Helper1Helper2 等。第一步是将 foo()bar() 移动到此第二层次结构中

class Helper {
public:
  virtual void foo(int n) const = 0;
  virtual double bar() const = 0;
};

class Helper1 : public Helper {
public:
  virtual void foo(int n) const;
  virtual double bar() const;
};

class Helper2 : public Helper {
public:
  virtual void foo(int n) const;
  virtual double bar() const;
};

接下来,从 Base 中删除 init()(因为我们不再使用两阶段方法),从 BaseDerived 中删除 foo()bar()foo()bar() 现在位于 Helper 层次结构中),并更改 Base 构造函数的签名,使其通过引用接收一个 Helper

class Base {
public:
  Base(const Helper& h);
  // Remove init() since not using two-phase this time
  // Remove foo() and bar() since they're in Helper
};

class Derived : public Base {
public:
  // Remove foo() and bar() since they're in Helper
};

然后我们定义 Base::Base(const Helper&),使其在 init() 曾经调用 this->foo(42)this->bar() 的确切位置调用 h.foo(42)h.bar()

Base::Base(const Helper& h)
{
  // Almost identical to the body of the original Base::Base()
  // except for the insertion of h.

  // ...
  h.foo(42);
  h.bar();
  ↑↑ // The h. occurrences are new
  // ...
}

最后,我们更改 Derived 的构造函数,使其将适当的 Helper 派生类(可能是临时对象)传递给 Base 的构造函数(使用初始化列表语法)。例如,如果 Helper2 恰好包含 Derived 希望 foo()bar() 函数具有的行为,Derived 将传递一个 Helper2 实例

Derived::Derived()
  : Base(Helper2())   // ← the magic happens here
{
  // ...
}

请注意,Derived 可以将值传递给 Helper 派生类的构造函数,但它绝不能传递任何实际存在于 this 对象中的数据成员。顺便说一句,我们明确地说 Helper::foo()Helper::bar() 不得访问 this 对象的数据成员,特别是 Derived 中声明的数据成员。(想想那些数据成员何时初始化,您就会明白为什么。)

当然,Helper 派生类的选择可以在类似 joe_user 的函数中完成,在这种情况下,它将被传递到 Derived 构造函数,然后传递到 Base 构造函数

Derived::Derived(const Helper& h)
  : Base(h)
{
  // ...
}

如果 Helper 对象不需要保存任何数据,也就是说,如果每个对象仅仅是其成员函数的集合,那么您可以简单地传递static 成员函数。这可能更简单,因为它完全消除了 Helper 层次结构。

class Base {
public:
  using FooFn = void (*)(int);  // type aliases simplify
  using BarFn = double (*)();   //    the rest of the code
  Base(FooFn foo, BarFn bar);
  // ...
};

Base::Base(FooFn foo, BarFn bar)
{
  // Almost identical to the body of the original Base::Base()
  // except the calls are made via function pointers.

  // ...
  foo(42);
  bar();
  // ...
}

Derived 类也易于实现

class Derived : public Base {
public:
  Derived();
  static void foo(int n); // the static is important!
  static double bar();    // the static is important!
  // ...
};

Derived::Derived()
  : Base(foo, bar)  // ← pass the function-ptrs into Base's ctor
{
  // ...
}

如前所述,foo() 和/或 bar() 的功能可以从类似 joe_user 的函数中传入。在这种情况下,Derived 的构造函数只接受它们并将它们传递给 Base 的构造函数

Derived::Derived(FooFn foo, BarFn bar)
  : Base(foo, bar)
{
  // ...
}

最后一种方法是使用模板将功能“传递”到派生类中。这类似于类似 joe_user 的函数选择初始化函数或 Helper 派生类的情况,但它不是使用函数指针或动态绑定,而是通过模板将代码连接到类中。

我的析构函数也遇到了同样的问题:从基类的析构函数中调用 this 对象的 virtual 函数,结果忽略了派生类中的重写;这是怎么回事?

C++ 正在保护你免受伤害。你试图做的事情非常危险,如果编译器按照你想要的方式行事,你的处境会更糟。

为了解释为什么 C++ 需要保护您免受这种危险,请确保您了解当构造函数在其 this 对象上调用虚函数时会发生什么。析构函数期间的情况与构造函数期间的情况类似。特别是,在 Base::~Base(){body} 中,最初类型为 Derived 的对象已经降级(如果您愿意,可以理解为退化)为 Base 类型的对象。如果您调用一个在 Derived 类中被重写的虚函数,该调用将解析为 Base::virt(),而不是重写 Derived::virt()。对 this 对象使用 typeid 也是如此:this 对象确实已被降级为 Base 类型;它不再是 Derived 类型的对象。

提醒也阅读

派生类是否应该重新定义(“重写”)基类中非 virtual 的成员函数?

这是合法的,但并不道德。

经验丰富的 C++ 程序员有时会为了效率(例如,如果派生类实现能更好地利用派生类的资源)或为了规避隐藏规则而重新定义非 virtual 函数。然而,客户端可见的效果必须完全相同,因为非 virtual 函数是根据指针/引用的静态类型而不是指向/引用对象的动态类型来分派的。

Warning: Derived::f(char) hides Base::f(double) 是什么意思?

这意味着你将面临麻烦。

你遇到的问题是:如果 Base 声明了一个成员函数 f(double x),而 Derived 声明了一个成员函数 f(char c)(同名但参数类型和/或 constness 不同),那么 Basef(double x) 会被“隐藏”而不是“重载”或“重写”(即使 Basef(double x)virtual)。

class Base {
public:
  void f(double x);  // Doesn't matter whether or not this is virtual
};

class Derived : public Base {
public:
  void f(char c);  // Doesn't matter whether or not this is virtual
};

int main()
{
  Derived* d = new Derived();
  Base* b = d;
  b->f(65.3);  // Okay: passes 65.3 to f(double x)
  d->f(65.3);  // Bizarre: converts 65.3 to a char ('A' if ASCII) and passes it to f(char c); does NOT call f(double x)!!
  delete d;
  return 0;
}

以下是如何摆脱这种困境的方法:Derived 必须对隐藏的成员函数进行 using 声明。例如,

class Base {
public:
  void f(double x);
};

class Derived : public Base {
public:
  using Base::f;  // This un-hides Base::f(double x)
  void f(char c);
};

如果您的编译器不支持 using 语法,请重新定义隐藏的 Base 成员函数,即使它们是非 virtual。通常,这种重新定义只是使用 :: 语法调用隐藏的 Base 成员函数。例如,

class Derived : public Base {
public:
  void f(double x) { Base::f(x); }  // The redefinition merely calls Base::f(double x)
  void f(char c);
};

注意:如果类 Base 声明了成员函数 f(char),也会出现隐藏问题。

注意:警告不是标准的一部分,因此您的编译器可能会或可能不会给出上述警告。

注意:当您拥有基类指针时,没有任何东西会被隐藏。想想看:当编译器处理基类指针时,派生类做什么或不做什么都无关紧要。编译器甚至可能不知道特定派生类的存在。即使它知道某个特定派生类的存在,它也无法假定特定的基类指针必然指向该特定派生类的对象。隐藏发生在您拥有派生类指针时,而不是拥有基类指针时。

为什么派生类不能重载?

这个问题(有多种变体)通常是由这样的例子引发的

    #include<iostream>
    using namespace std;

    class B {
    public:
        int f(int i) { cout << "f(int): "; return i+1; }
        // ...
    };

    class D : public B {
    public:
        double f(double d) { cout << "f(double): "; return d+1.3; }
        // ...
    };

    int main()
    {
        D* pd = new D;

        cout << pd->f(2) << '\n';
        cout << pd->f(2.3) << '\n';

        delete pd;
    }

它会产生

    f(double): 3.3
    f(double): 3.6

而不是

    f(int): 3
    f(double): 3.6

某些人(错误地)猜测的。

换句话说,DB 之间没有重载解析。重载解析在概念上一次发生在一个作用域中:编译器查看 D 的作用域,找到唯一的函数 double f(double),并调用它。因为它找到了匹配项,所以它不再费心继续查找 B 的(包围)作用域。在 C++ 中,作用域之间没有重载——派生类作用域不是这一通用规则的例外。(详见D&ETC++PL4)。

但是,如果我想创建我的基类和派生类中所有 f() 函数的重载集怎么办?这很容易通过 using 声明来实现,它要求将函数引入作用域

    class D : public B {
    public:
        using B::f; // make every f from B available
        double f(double d) { cout << "f(double): "; return d+1.3; }
        // ...
    };

在进行上述修改后,输出将是

    f(int): 3
    f(double): 3.6

也就是说,对 Bf()Df() 应用了重载解析,以选择最合适的 f() 来调用。

如果您收到“Error: Unresolved or undefined symbols detected: virtual table for class Fred”形式的链接错误,您可能在 class Fred 中有一个未定义的virtual 成员函数。

编译器通常会为具有 virtual 函数的类创建一个神奇的数据结构,称为“虚表”(这是它处理动态绑定的方式)。通常您完全不需要了解它。但是,如果您忘记为 Fred 类定义 virtual 函数,有时会得到这个链接错误。

细节如下:许多编译器将这个神奇的“虚表”放在定义类中第一个非 inline virtual 函数的编译单元中。因此,如果 Fred 中第一个非 inline virtual 函数是 wilma(),编译器会将 Fred 的虚表放在它看到 Fred::wilma() 的同一个编译单元中。不幸的是,如果您不小心忘记定义 Fred::wilma(),您可能不会得到“Fred::wilma() 未定义”的错误,而是得到“Fred 的虚表未定义”的错误。悲哀但真实。