C++设计原理———多重继承(虚基类、菱形继承、委托、重命名、基类和成员初始化)

文章目录

  • C++设计原理---------多重继承的设计演进
    • [1 背景](#1 背景)
    • [2 C++ 多重继承的设计背景、动机与早期考量](#2 C++ 多重继承的设计背景、动机与早期考量)
      • [2.1. 引言与历史背景](#2.1. 引言与历史背景)
      • [2.2. 普通基类与设计动机](#2.2. 普通基类与设计动机)
    • [2.3. 歧义性处理与设计原则](#2.3. 歧义性处理与设计原则)
    • [3 虚基类(Virtual Base Classes)和支配规则(Dominance Rules)](#3 虚基类(Virtual Base Classes)和支配规则(Dominance Rules))
      • [3.1. 虚基类 (Virtual Base Classes) - 解决"菱形继承"问题](#3.1. 虚基类 (Virtual Base Classes) - 解决“菱形继承”问题)
      • [3.2 兄弟通信](#3.2 兄弟通信)
        • [3.2.1 问题的本质:兄弟类之间的"隐形契约"](#3.2.1 问题的本质:兄弟类之间的“隐形契约”)
        • [3.2.2 替代方案的缺陷:为什么其他路走不通?](#3.2.2 替代方案的缺陷:为什么其他路走不通?)
          • [3.2.2.1. 强制公共根类(The "Unified" Base / Mixin Style)](#3.2.2.1. 强制公共根类(The "Unified" Base / Mixin Style))
          • [3.2.2.2. 全局数据(Global Data)](#3.2.2.2. 全局数据(Global Data))
          • [3.2.2.3. 显式指针(Explicit Pointers)](#3.2.2.3. 显式指针(Explicit Pointers))
        • [3.2.3 虚基类的解决方案:编译器托管的共享子对象](#3.2.3 虚基类的解决方案:编译器托管的共享子对象)
        • [3.2.4 代价与权衡:为什么现代 C++ 慎用虚继承?](#3.2.4 代价与权衡:为什么现代 C++ 慎用虚继承?)
      • [3.2. 虚基和虚函数:混合子风格 (Mixin Style)](#3.2. 虚基和虚函数:混合子风格 (Mixin Style))
      • [3.3. 支配规则 (Dominance Rules)](#3.3. 支配规则 (Dominance Rules))
      • [3.4 两条规则的详解](#3.4 两条规则的详解)
        • [3.4.1 第一条规则:覆盖的普遍性](#3.4.1 第一条规则:覆盖的普遍性)
          • [3.4.1.1. 核心含义](#3.4.1.1. 核心含义)
          • [3.4.1.2. 为什么这是"必要"的?](#3.4.1.2. 为什么这是“必要”的?)
          • [3.4.1.3. 技术实现:支配规则(Dominance Rule)](#3.4.1.3. 技术实现:支配规则(Dominance Rule))
          • [3.4.1.4. 反面论证:若不遵守会怎样?](#3.4.1.4. 反面论证:若不遵守会怎样?)
        • [3.4.2 第二条规则:静态可判定性](#3.4.2 第二条规则:静态可判定性)
          • [3.4.2.1. 核心含义](#3.4.2.1. 核心含义)
          • [3.4.2.2. 为什么这是"必要"的?](#3.4.2.2. 为什么这是“必要”的?)
          • [3.4.2.3. 技术实现:继承格上的偏序关系](#3.4.2.3. 技术实现:继承格上的偏序关系)
          • [3.4.2.4. 反面论证:若不遵守会怎样?](#3.4.2.4. 反面论证:若不遵守会怎样?)
        • [3.4.3 两条规则的协同:构建可靠的 MI 语义](#3.4.3 两条规则的协同:构建可靠的 MI 语义)
        • [3.4.4 现代 C++ 的印证与演进](#3.4.4 现代 C++ 的印证与演进)
        • [3.4.5 总结:如何内化这两条规则](#3.4.5 总结:如何内化这两条规则)
    • [4 对象布局](#4 对象布局)
      • [4.1. 对象布局模型 ( Object Layout Model)](#4.1. 对象布局模型 ( Object Layout Model))
      • [4.2. 虚基布局 ( Virtual Base Layout)](#4.2. 虚基布局 ( Virtual Base Layout))
      • [4.3 虚基类和强制转换 ( Virtual Base Classes and Casting)](#4.3 虚基类和强制转换 ( Virtual Base Classes and Casting))
    • [5 "方法组合"(Method Combination)](#5 “方法组合”(Method Combination))
      • [5.1 核心背景:什么是"方法组合"?](#5.1 核心背景:什么是“方法组合”?)
      • [5.2 手工方法组合面临的核心问题](#5.2 手工方法组合面临的核心问题)
      • [5.3 解决方案:保护接口 + 显式委托模式](#5.3 解决方案:保护接口 + 显式委托模式)
        • [5.3.1. 模式结构解析](#5.3.1. 模式结构解析)
        • [5.3.2. 为什么这种方法能避免重复调用?](#5.3.2. 为什么这种方法能避免重复调用?)
      • [5.4 该方案的优缺点评价](#5.4 该方案的优缺点评价)
      • [5.5 与现代 C++ 实践的关联](#5.5 与现代 C++ 实践的关联)
    • [6 有关多重继承的论战](#6 有关多重继承的论战)
      • [6.1 历史背景:一场关于多重继承的"论战"](#6.1 历史背景:一场关于多重继承的“论战”)
        • [6.1.1 📌 反对观点 1:多重继承是"不必要的花饰",会导致语言臃肿](#6.1.1 📌 反对观点 1:多重继承是“不必要的花饰”,会导致语言臃肿)
        • [6.1.2 📌 反对观点 2:MI 违背了"简单性"原则,增加了认知负担](#6.1.2 📌 反对观点 2:MI 违背了“简单性”原则,增加了认知负担)
        • [6.1.3 📌 反对观点 3:MI 的实现太复杂,会给编译器和工具带来巨大负担](#6.1.3 📌 反对观点 3:MI 的实现太复杂,会给编译器和工具带来巨大负担)
        • [6.1.4 📌 反对观点 4:MI 容易被滥用,导致设计混乱](#6.1.4 📌 反对观点 4:MI 容易被滥用,导致设计混乱)
        • [6.1.5 📌 反对观点 5:可以用委托(Delegation)替代 MI](#6.1.5 📌 反对观点 5:可以用委托(Delegation)替代 MI)
        • [6.1.6 📌 反对观点 6:MI 放进 C++ 会给语言的潜在特征(如垃圾回收)增加困难](#6.1.6 📌 反对观点 6:MI 放进 C++ 会给语言的潜在特征(如垃圾回收)增加困难)
        • [6.1.7 📌 反对观点 7:多数人在多年后才意识到 MI 是个好主意,说明当初加入它是错误的](#6.1.7 📌 反对观点 7:多数人在多年后才意识到 MI 是个好主意,说明当初加入它是错误的)
      • [6.2 Stroustrup 的最终立场与设计哲学](#6.2 Stroustrup 的最终立场与设计哲学)
      • [6.3 多重继承的成功使用模式](#6.3 多重继承的成功使用模式)
      • [6.4 对"外来风格强加"的警告](#6.4 对“外来风格强加”的警告)
    • [7 委托(Delegation)](#7 委托(Delegation))
      • [7.1 历史背景:委托概念的起源](#7.1 历史背景:委托概念的起源)
        • [7.1.1 📌 示例代码解析](#7.1.1 📌 示例代码解析)
      • [7.2 委托机制的吸引力与初步实现](#7.2 委托机制的吸引力与初步实现)
      • [7.3 委托机制失败的两大根本原因](#7.3 委托机制失败的两大根本原因)
        • [7.3.1 ❌ 问题 1:位于委托类里的函数不能覆盖被委托类里的函数](#7.3.1 ❌ 问题 1:位于委托类里的函数不能覆盖被委托类里的函数)
        • [7.3.2 ❌ 问题 2:被委托函数不能使用委托类里的函数,或者以其他方式"回到"委托对象那里](#7.3.2 ❌ 问题 2:被委托函数不能使用委托类里的函数,或者以其他方式“回到”委托对象那里)
      • [7.4 更深层次的技术障碍:虚函数表的修改难题](#7.4 更深层次的技术障碍:虚函数表的修改难题)
      • [7.5 另一个致命案例:共享委托导致的歧义](#7.5 另一个致命案例:共享委托导致的歧义)
      • [7.6 最终决策:放弃原生委托,回归手工实现](#7.6 最终决策:放弃原生委托,回归手工实现)
        • [7.6.1 ✅ 替代方案 1:使用灵巧指针(Smart Pointers / Handle)](#7.6.1 ✅ 替代方案 1:使用灵巧指针(Smart Pointers / Handle))
        • [7.6.2 ✅ 替代方案 2:手工转发(Manual Forwarding)](#7.6.2 ✅ 替代方案 2:手工转发(Manual Forwarding))
      • [7.7 📌 现代 C++ 如何实现"委托"?](#7.7 📌 现代 C++ 如何实现“委托”?)
    • [7.8 现代【灵巧指针】](#7.8 现代【灵巧指针】)
      • [7.8.1 场景一:独占委托(Exclusive Delegation)](#7.8.1 场景一:独占委托(Exclusive Delegation))
      • [7.8.2 场景二:共享委托(Shared Delegation)](#7.8.2 场景二:共享委托(Shared Delegation))
      • [7.8.3 场景三:非拥有式委托 / 弱委托(Non-Owning Delegation)](#7.8.3 场景三:非拥有式委托 / 弱委托(Non-Owning Delegation))
      • [7.8.4 总结:为什么 Stroustrup 认为这是"最佳实践"?](#7.8.4 总结:为什么 Stroustrup 认为这是“最佳实践”?)
    • [7.9 现代【手工转发】](#7.9 现代【手工转发】)
      • [7.9.1 示例 1:基础手工转发(显式接口适配)](#7.9.1 示例 1:基础手工转发(显式接口适配))
      • [7.9.2 示例 2:基于接口的转发与 `override` 契约(现代 C++ 最佳实践)](#7.9.2 示例 2:基于接口的转发与 override 契约(现代 C++ 最佳实践))
      • [7.9.3 示例 3:完美转发(Perfect Forwarding)处理泛型与重载](#7.9.3 示例 3:完美转发(Perfect Forwarding)处理泛型与重载)
      • [7.9.4 示例 4:工程化技巧:使用宏消除样板代码(Macro Boilerplate Reduction)](#7.9.4 示例 4:工程化技巧:使用宏消除样板代码(Macro Boilerplate Reduction))
      • [7.9.5 总结:手工转发的现代哲学](#7.9.5 总结:手工转发的现代哲学)
    • [8 重命名](#8 重命名)
      • [8.1 问题起源:多重继承中的名称冲突](#8.1 问题起源:多重继承中的名称冲突)
        • [8.1.1📌 经典反例:`LotterySimulation`](#8.1.1📌 经典反例:LotterySimulation)
      • [8.2 提案方案:"重命名"语法扩展](#8.2 提案方案:“重命名”语法扩展)
        • [8.2.1📌 提案语法示例](#8.2.1📌 提案语法示例)
      • [8.3 转折点:Beth Crockett 的"两周规则"与 Doug McIlroy 的洞察](#8.3 转折点:Beth Crockett 的“两周规则”与 Doug McIlroy 的洞察)
        • [8.3.1📌 McIlroy 的替代方案:包装层 + 转发](#8.3.1📌 McIlroy 的替代方案:包装层 + 转发)
      • [8.4 Stroustrup 的反思与最终决策](#8.4 Stroustrup 的反思与最终决策)
        • [8.4.1📌 更深层的哲学:同义词的危害](#8.4.1📌 更深层的哲学:同义词的危害)
      • [8.5 历史影响与现代印证](#8.5 历史影响与现代印证)
        • [8.5.1 📌 现代 C++ 如何应对类似问题?](#8.5.1 📌 现代 C++ 如何应对类似问题?)
          • [8.5.1.1 方案 1:根本性重构 ------ 语义明确化(最佳实践)](#8.5.1.1 方案 1:根本性重构 —— 语义明确化(最佳实践))
          • [8.5.1.2 方案 2:接口隔离 + 私有继承(适配遗留代码)【McIlroy 风格的包装层】](#8.5.1.2 方案 2:接口隔离 + 私有继承(适配遗留代码)【McIlroy 风格的包装层】)
          • [8.5.1.3 方案 3:使用 std::function / 策略模式(细粒度委托)](#8.5.1.3 方案 3:使用 std::function / 策略模式(细粒度委托))
          • [8.5.1.4 方案 4:CRTP 静态多态(编译期消歧)](#8.5.1.4 方案 4:CRTP 静态多态(编译期消歧))
          • [8.5.1.5 方案 5:概念约束(C++20 Concepts)](#8.5.1.5 方案 5:概念约束(C++20 Concepts))
    • [9 基类和成员初始化](#9 基类和成员初始化)
      • [9.1 问题背景:多重继承下的初始化顺序与语法歧义](#9.1 问题背景:多重继承下的初始化顺序与语法歧义)
        • [9.1.1❌ 缺陷 1:初始化顺序未明确定义](#9.1.1❌ 缺陷 1:初始化顺序未明确定义)
        • [9.1.2❌ 缺陷 2:基类初始化语法模糊且易混淆](#9.1.2❌ 缺陷 2:基类初始化语法模糊且易混淆)
        • [9.1.3 缺陷 3:派生类无法显式指定基类构造参数(旧式语法的局限)](#9.1.3 缺陷 3:派生类无法显式指定基类构造参数(旧式语法的局限))
      • [9.2 解决方案:C++ 2.0 的显式初始化列表语法](#9.2 解决方案:C++ 2.0 的显式初始化列表语法)
        • [9.2.1 ✅ 新语法规范](#9.2.1 ✅ 新语法规范)
        • [9.2.2 ✅ 明确的初始化顺序规则](#9.2.2 ✅ 明确的初始化顺序规则)
        • [9.2.3 ✅ 派生类显式调用基类构造函数的新写法](#9.2.3 ✅ 派生类显式调用基类构造函数的新写法)
      • [9.3 深层动机:分布式系统与编译程序的可靠性](#9.3 深层动机:分布式系统与编译程序的可靠性)
      • [9.4 过渡期策略:向后兼容与废弃警告](#9.4 过渡期策略:向后兼容与废弃警告)
      • [9.5 现代 C++ 的印证与最佳实践](#9.5 现代 C++ 的印证与最佳实践)
        • [9.5.1. 统一初始化语法(C++11 `{}`)](#9.5.1. 统一初始化语法(C++11 {}))
        • [9.5.2. 成员就地初始化(C++11)](#9.5.2. 成员就地初始化(C++11))
        • [9.5.3. 委托构造函数(C++11)](#9.5.3. 委托构造函数(C++11))
        • [9.4. 编译器警告与静态分析](#9.4. 编译器警告与静态分析)

C++设计原理---------多重继承的设计演进

1 背景

本文阐述多重继承(MI)的设计演进。Stroustrup指出,MI旨在组合正交接口而非混合实现。针对名称冲突,他否决了"重命名"语法,主张通过语义明确化重构或私有继承适配解决,避免同义词维护噩梦。针对初始化歧义,C++2.0确立了显式初始化列表语法,强制按声明顺序执行,消除了旧式隐式语法的混乱,保障了跨平台对象布局的一致性。此外,虚继承虽解决菱形问题但引入开销,现代实践更推崇组合。

2 C++ 多重继承的设计背景、动机与早期考量

本节内容主要讲述了 C++ 引入多重继承(Multiple Inheritance, MI)特性的历史背景、设计者的个人思考、遇到的挑战以及最初的设计原则。

Stroustrup 强调了多重继承作为一种"组合"和"组织"工具的价值,并确立了 C++ 在处理多重继承歧义时"编译期报错、拒绝隐式顺序依赖、保持静态类型高效性"的核心原则。

2.1. 引言与历史背景

  • 最初的看法:在大部分人心中,多重继承是 C++ 2.0 的标志性特征。但作者(Stroustrup)当时并不完全赞同,他认为对类型系统的整体改进更为重要。他甚至认为将多重继承加入 Release 2.0 是一个"失误",因为他当时更渴望参数化类型(模板)和异常处理。
  • 为什么还要做? 尽管有上述看法,作者还是决定实现多重继承,原因包括:
    • 设计演进:这是语言设计的自然前进。
    • 实现成本低:在 C++ 的类型系统中加入多重继承不需要做太多扩充,且可以在当时的 Cfront 编译器中完成。
    • 非理性的挑战欲:当时业界(如 Brad Cox 在关于 Objective C 的书中)普遍认为在 C++ 中有效实现多重继承是"不可能的"或极其困难的。作者早在 1982 年就开始思考此问题,并在 1984 年发现了有效的实现技术,因此无法忍受这种"不可能"的挑战。作者幽默地指出,在这里"时尚影响了事件发生的顺序"。
  • 历史渊源 :
    • 1984年9月,作者在 IFIP WG2.4 会议上遇到了 Stein Krogdahl,后者刚完成了一份给 Simula 语言增加多重继承的建议书。这个思想后来成为了 C++ 常规多重基类的基础。
    • 有趣的是,Simula 语言的创始人之一 Ole-Johan Dahl 早在 1966 年就考虑过多重继承,但拒绝了它,因为那会使 Simula 的垃圾回收程序变得过于复杂。

2.2. 普通基类与设计动机

  • 最根本的原因 :引入多重继承的最原始、最简单的动机是允许两个类以某种方式组合起来,使得结果产生的类的对象的行为就像两个类中任何一个的对象。
  • 经典示例 :假设有两个库类 displayed(表示显示管理器控制下的对象)和 task(表示调度器控制下的协作程序)。
    • 使用多重继承,程序员可以灵活地创建:
      • class my_displayed_task : public displayed, public task (既是显示对象又是任务)
      • class my_task : public task (只是任务)
      • class my_displayed : public displayed (只是显示对象)
    • 如果只有单继承,这三种组合方式中只有两种能对程序员开放。
  • 组织手段 vs. 特征堆砌 :作者当时担心为了满足过多需求,库类会变得过于庞大("充斥各种特征")。他将多重继承视为一种潜在的重要组织手段,通过围绕几个简单的类和类间的依赖关系来把库组织起来,分别用不同的类表示并发性和输出,从而减轻应用程序员的负担。

2.3. 歧义性处理与设计原则

  • 编译时处理歧义 :C++ 选择在编译时就处理多重继承带来的歧义性,而不是留到运行时。
    • 例如,如果类 A 和类 B 都有一个同名函数 f(),而类 C 同时继承了 A 和 B。当通过 C 的指针调用 p->f() 时,编译器会直接报错(error: ambiguous),因为不知道应该调用 A::f 还是 B::f。
cpp 复制代码
class A { public: void f(); /* ... */ };
class B { public: void f(); /* ... */ };
class C : public A, public B { /* no f() ... */ };

void g()
{
    C* p;
    p->f(); // error: ambiguous
}
  • 拒绝顺序依赖 :C++ 拒绝依靠基类在列表中的出现顺序来解决歧义(例如,不会因为 A 写在 B 前面就默认偏向 A::f)。作者认为这种顺序依赖性会带来负面影响。
  • 拒绝复杂的动态解析 :作者明确指出,除了使用虚函数之外,他拒绝了任何其他形式的动态解析。他认为,对于一个需要在严格的效率约束下使用的静态类型语言(如 C++)而言,那些复杂的动态解析机制是根本不合适的。这也体现了 C++ 注重运行期效率和静态类型安全的设计哲学。

3 虚基类(Virtual Base Classes)和支配规则(Dominance Rules)

本节揭示了 C++ 多重继承机制设计的精妙与复杂。虚基类 解决了对象模型中子对象共享的问题(菱形继承),是构建复杂类层次(如 iostream)的基础;而支配规则则提供了一套严谨的静态名称解析机制,确保了在复杂的继承网络中,函数调用和成员访问的唯一性和确定性,从而在保持灵活性的同时,最大限度地避免了运行时错误和歧义。这两者共同构成了 C++ 强大但需谨慎使用的多重继承体系的基石。

在今天,我们很少直接写 virtual 继承,因为组合模式 和智能指针提供了更安全、更高效的替代。但理解虚基类的设计动机,有助于我们明白:C++ 的每一个复杂特性,都是为了在极端约束下保留最大化的设计自由度。

  • 没有虚基类:C++ 将退化为要么强制单一根类(牺牲灵活性),要么依赖全局/指针(牺牲安全性)。这将使 C++ 失去作为"系统编程语言"的精细控制能力,或失去作为"抽象工具"的组合能力。
  • 有了虚基类 :C++ 获得了一种声明式的共享语义------程序员只需说"这两个基类共享同一个根",编译器负责解决复杂的内存布局和访问路径。

Stroustrup 的最终结论 :

虚基类不是完美的方案(它有性能代价),但它是必要之恶(Necessary Evil) 。它填补了"完全独立的重复子对象"与"强制全局统一架构"之间的空白地带,使得 C++ 能够支持真正的正交多重组合,而不必强迫用户接受某种特定的对象模型范式(如 Java 的单根或 Smalltalk 的全局元类)。

3.1. 虚基类 (Virtual Base Classes) - 解决"菱形继承"问题

问题的提出:重复的子对象

  • 在普通的非虚多重继承中,如果一个类在继承的有向无环图(DAG)中出现不止一次,那么最终的派生类对象中就会包含该基类的多个独立子对象。
  • 例子 :task 和 displayed 都继承自 link。当 displayed_task 同时继承 task 和 displayed 时,displayed_task 的对象内部实际上包含了两个 link 类的子对象(一个来自 task::link,一个来自 displayed::link)。
cpp 复制代码
class task : public link { /* ... */ };
class displayed : public link { /* ... */ };
class displayed_task
    : public displayed, public task { /* ... */ };
  • 适用场景 :作者指出,这种"重复"在某些情况下是非常有用的。例如,如果要求表中的每个元素都包含一个 link 元素,这种设计就能让 displayed_task 同时存在于一个 displayed 的表和一个 task 的表中。因此,C++ 默认支持这种普通基类(非虚)的行为。

虚基类的引入:共享唯一的子对象

  • 动机:为了解决另一种需求------即多个派生类之间共享对象的方面描述。作者希望有一种方式能描述"一个基类只能在最终派生的类里有一个对象,即使它曾经作为基类出现过多次"。

  • 解决方案 :将这种需要共享的基类声明为虚的(virtual)。

  • 例子:

    • class AW : public virtual W { ... };
    • class BW : public virtual W { ... };
    • class CW : public AW, public BW { ... };
    • 在这种情况下,CW 对象中只包含一个 W 的子对象,由 AW 和 BW 共享。
  • 关键理解 :"虚"是描述派生类(AW, BW)如何继承基类(W)的一种性质,而不是 W 类自身的性质。在继承 DAG 中,每个 virtual 基类总表示同一个对象。

实际应用场景

  • 窗口系统示例 :作者举了一个基于 Lisp 文献思想的单窗口系统例子。window_with_menu_and_border 继承自 window_with_menu 和 window_with_border,而这两者又都继承自 window。为了避免一个窗口对象里出现两个 window 基础部分,window 必须被声明为虚基类。

  • 标准库示例 :C++ 标准 iostream 库是虚基类的经典应用。iostream 继承自 istream 和 ostream,而这两者都虚继承自 ios。这保证了 iostream 对象中只有一个 ios 状态部分。同样,fstream 相关的类层次也使用了虚继承来共享 ios。

设计权衡与哲学

  • 为什么默认是非虚的? 作者认为虚基类和普通基类都有用。选择普通基类作为默认情况,是因为它的实现比虚基类更节约运行时间和空间,且使用虚基类的程序设计通常需要更多的技巧。
  • 防止多次调用:使用虚基类的一个关键问题是防止对虚基类里函数的多次调用(例如构造函数)。
  • 兄弟通信:作者曾考虑过不把虚基类概念加入语言,但意识到必须有一种方式表示互不相关的兄弟类之间的依赖性。如果没有虚基类,兄弟类只能通过公共根类、全局数据或显式指针通信,这会导致"统一的"基类情况(即混合子风格)。

3.2 兄弟通信

3.2.1 问题的本质:兄弟类之间的"隐形契约"

在多重继承中,最常见的场景是菱形继承(Diamond Inheritance):

cpp 复制代码
class Root { public: int id; };
class Left : public Root { /* ... */ };
class Right : public Root { /* ... */ };
class Derived : public Left, public Right { /* ... */ };
  • 非虚继承的问题 :Derived 对象中包含两份 Root 子对象(一份来自 Left,一份来自 Right)。

    • d.id 产生二义性:是 Left::Root::id 还是 Right::Root::id?
    • 更严重的是语义错误:如果 Left 和 Right 都试图修改同一个逻辑实体的状态(如 id),它们实际上在修改两个不同的副本,导致状态不一致。
  • Stroustrup 的困境:

    "我曾考虑过不把虚基类概念加入语言......但我意识到必须有一种方式表示互不相关的兄弟类之间的依赖性。"

    这里的"依赖性"指的是:Left 和 Right 虽然独立开发,但它们共享同一个逻辑基类 Root 的语义。 当它们被组合进 Derived 时,程序员期望 Root 只存在一份实例,以便 Left 和 Right 能通过这份共享实例进行隐式通信或状态同步。

3.2.2 替代方案的缺陷:为什么其他路走不通?

Stroustrup 列举了三种不使用虚基类的替代方案,并逐一否定了它们,因为它们都会导致更糟糕的设计退化:

3.2.2.1. 强制公共根类(The "Unified" Base / Mixin Style)
  • 做法 :要求所有可能组合的类都必须继承自一个唯一的、全局的 Object 或 Component 根类,并在该根类中管理所有共享状态。
  • 后果 :
    • "统一的"基类情况 :整个系统被迫采用单一的继承树根(类似 Java 的 Object 或 Smalltalk 的 Object)。
    • 混合子风格(Mixin Hell):为了在不相关的功能间共享数据,开发者不得不将大量无关接口塞进这个公共根类,或者创建庞大的"上帝类"。
    • 耦合度爆炸:任何底层根类的修改都会波及整个系统,破坏了模块化。
  • Stroustrup 的评价:这违背了 C++ "不为不使用的东西付费"和"局部性"原则。它强制了一种全局架构,而非允许局部组合。
3.2.2.2. 全局数据(Global Data)
  • 做法 :Left 和 Right 不通过基类共享状态,而是读写全局变量或单例。
  • 后果 :
    • 线程不安全:全局状态难以保护。
    • 不可重入 :无法在同一进程中运行多个独立的 Derived 实例。
    • 测试噩梦:单元测试无法隔离状态。
  • Stroustrup 的评价:这是 C 语言的旧习,C++ 的目标正是消除对全局状态的依赖,封装数据于对象内部。
3.2.2.3. 显式指针(Explicit Pointers)
  • 做法 :Left 持有一个指向 Right 的指针(或反之),或在 Derived 中手动维护两者间的引用。
  • 后果 :
    • 生命周期管理复杂:谁拥有指针?如何防止悬空指针?
    • 破坏封装 :Left 必须知道 Right 的存在,违反了"互不相关"的前提。
    • 代码冗余:每个需要共享的地方都要手动传递指针, boilerplate 代码激增。
  • Stroustrup 的评价:这将本应由编译器自动处理的子对象定位问题,推给了程序员手动管理,增加了出错概率。
3.2.3 虚基类的解决方案:编译器托管的共享子对象

虚基类机制的核心在于:将共享子对象的唯一性保证交给编译器,而非程序员。

cpp 复制代码
class Root { public: int id; };
class Left : public virtual Root { /* ... */ };  // virtual 关键字
class Right : public virtual Root { /* ... */ }; // virtual 关键字
class Derived : public Left, public Right { 
    // Derived 负责初始化唯一的 Root 子对象
    Derived() : Root(42) { } 
};
  • 内存布局变化 :
    • Left 和 Right 不再直接包含 Root 的数据成员。
    • 它们包含一个虚基类指针(vbptr) ,指向共享的 Root 子对象。
    • Derived 对象末尾(或特定位置)存放唯一的 Root 实例。
  • 语义收益 :
    • d.id 无二义性,直接访问唯一的 Root::id。
    • Left 和 Right 的代码无需修改即可共享状态,保持了它们的独立性。
    • 无需全局变量,无需显式指针,无需强制统一根类。
3.2.4 代价与权衡:为什么现代 C++ 慎用虚继承?

尽管虚基类解决了语义问题,但 Stroustrup 也清醒地认识到其代价,:

维度 普通继承 虚继承 代价分析
内存开销 无额外开销 每个虚基类路径增加一个 vbptr (4/8 字节) 对象体积膨胀,缓存局部性下降
访问速度 固定偏移,单条指令 二次间接寻址 (load vbptr -> load offset -> access) 关键路径性能损失 10%~30%
构造复杂度 各层独立构造 最派生类负责初始化虚基类,中间层忽略 构造函数逻辑分散,易出错
ABI 稳定性 稳定 高度依赖编译器实现 (vbtable 布局各异) 跨编译器/跨版本二进制兼容困难
  • Stroustrup 的反思:

    "虚基类是一种强大的机制,但它引入了运行时开销和实现复杂性。只有在确实需要共享子对象语义时才应使用。"

  • 现代替代方案:

    1. 组合优于继承 :将共享状态提取为独立成员对象,通过引用传递。

      cpp 复制代码
      struct SharedState { int id; };
      class Left { SharedState& state_; ... };
      class Right { SharedState& state_; ... };
      class Derived { 
          SharedState state_; 
          Left left_{state_}; 
          Right right_{state_}; 
      };
    2. CRTP + 接口分离:利用模板静态多态避免运行时开销。

    3. std::shared_ptr 管理共享状态:明确所有权语义。

3.2. 虚基和虚函数:混合子风格 (Mixin Style)

  • 定义:抽象类和虚基类的组合支持一种类似于某些 Lisp 系统使用的"混合子(Mixin)"程序设计风格。即用一个抽象类定义接口,用若干个派生类提供具体实现。每个派生类(混合子)为完整的类提供某些特定的功能。

  • 使用这种规则的两条必要规则【详解见3.4小结】:

    1. 必须能在不同的派生类里覆盖基类里的函数。否则,一个实现的基本部分就只能来自单个继承链。
    2. 必须能确定覆盖了虚函数的函数,并能从继承格上捕捉到所有的不一致性问题。否则只能依赖顺序相关性或运行时检查,这是不可靠的。
  • 代码示例解析:

    • 基类 W 有两个虚函数 f() 和 g()。
    • AW 虚继承 W 并覆盖了 g()。
    • BW 虚继承 W 并覆盖了 f()。
    • CW 继承 AW 和 BW。
    • 当通过 CW*、AW* 或 BW* 指针调用 f() 或 g() 时,无论指针类型如何,调用的始终是同一个最终被覆盖的函数(pcw->f() 调用 BW::f(),pcw->g() 调用 AW::g())。这允许将不同的类贡献组合到一个公共基类上。
cpp 复制代码
class W {
    // ...
    virtual void f();
    virtual void g();
};

// 而 AW 和 BW 各覆盖了其中的一个:
class AW : public virtual W {
    // ...
    void g();
};

class BW : public virtual W {
    // ...
    void f();
};

class CW : public AW, public BW, public virtual W {
    // ...
};

// 然后 CW 可能被以如下方式使用:
// (注:以下变量声明通常位于函数内或全局作用域)
CW* pcw = new CW;
AW* paw = pcw;
BW* pbw = pcw;

void fff()
{
    pcw->f();   // invokes BW::f()
    pcw->g();   // invokes AW::g()

    paw->f();   // invokes BW::f() !
    pbw->g();   // invokes AW::g() !
}

3.3. 支配规则 (Dominance Rules)

  • 问题:在多重继承中,如果不同的分支覆盖了同一个虚函数,或者定义了同名的成员,编译器如何决定使用哪一个?必须有一条规则说明什么是能接受的,哪些覆盖的组合将作为错误被拒绝。
  • Andrew Koenig 的发现(唯一规则):为了保证由一个虚函数调用的必须是同一个函数,无论类的对象如何描述,需要支配规则。
  • 支配规则定义 ARM:"名称 B::f 支配 A::f,如果其类 B 以 A 作为基类。如果一个名称支配另一个,在两者之间不存在歧义性,那么当存在选择时就使用那个支配着另一个的名称。"
  • 例子解析 :
    • V 有 int f() 和 int x。
    • B 虚继承 V,并定义了自己的 int f() 和 int x。
    • C 虚继承 V。
    • D 继承 B 和 C。
    • 在 D::g() 中直接使用 x++ 或 f() 是合法的(ok),因为 B::x 支配 V::x,B::f() 支配 V::f()。由于 C 没有重新定义 x 或 f,所以不存在来自 C 分支的歧义,B 中的定义"胜出"。
cpp 复制代码
class V { public: int f(); int x; };
class B : public virtual V { public: int f(); int x; };
class C : public virtual V { };

class D : public B, public C { void g(); };

void D::g()
{
    x++;    // ok: B::x dominates V::x
    f();    // ok: B::f() dominates V::f()
}
  • 适用范围 :支配关系适用于所有的名称,而不止是函数(包括数据成员等)。
  • 重要性:对于虚函数,支配规则是必需的,因为它决定了在一个虚调用中究竟应该执行哪个函数。经验证明,这个规则同样能很好地用于非虚函数。早期有些编译器对非虚函数没有采用支配规则,导致程序员写出许多错误和扭曲的程序。
  • 实现视角:从实现者的角度看,支配规则就是一个常见的查找规则,用以确定是不是存在唯一的一个函数可以放进虚函数表中。
  • 保证完整性:有关抽象类的规则和支配规则能够保证,只有那些提供了完整的、一致的服务的类才能够创建对象。如果没有这些规则,程序员在使用不太简单的框架时就很难避免严重的运行时错误。

3.4 两条规则的详解

这两条规则是 Stroustrup 为多重继承(MI)中的虚函数机制 确立的"宪法级"约束。它们不是实现细节,而是语言语义的基石。理解它们,就理解了 C++ 为何选择当前的虚表(vtable)模型,以及为何拒绝了其他看似更简单的方案。

以下从设计动机、技术内涵、反面论证、现代印证四个维度深度解析。

3.4.1 第一条规则:覆盖的普遍性

"必须能在不同的派生类里覆盖基类里的函数。否则,一个实现的基本部分就只能来自单个继承链。"

3.4.1.1. 核心含义

在菱形继承或多路径继承中,同一个虚函数可能通过多条路径被继承。此规则要求:无论该函数是从哪条路径继承来的,最派生类(Most Derived Class)都必须能够提供一个统一的覆盖版本,且该覆盖对所有路径都生效。

cpp 复制代码
struct Base { virtual void f(); };
struct Left : virtual Base { void f() override; };   // 路径1覆盖
struct Right : virtual Base { /* 未覆盖 */ };        // 路径2未覆盖
struct Derived : Left, Right { 
    void f() override; // ✅ 必须能覆盖,且同时替代 Left::f 和 Base::f
};
3.4.1.2. 为什么这是"必要"的?
  • 正交组合的前提 :MI 的核心价值是组合独立开发 的模块。如果 Left 和 Right 各自覆盖了 Base::f,而 Derived 无法提供一个统一的新实现,那么 Derived 的行为将取决于调用路径(Left::f() vs Right::f()),这破坏了"单一对象、单一行为"的直觉。
  • 避免"单链锁定" :若不允许跨路径覆盖,则 Derived 只能选择继承 Left::f 或 Right::f,另一个路径的实现被"冻结"。这意味着 Derived 的功能被限制在单条继承链上,MI 退化为多个单继承的并列,丧失了真正的多维扩展能力。
  • 接口一致性契约 :虚函数代表一种契约 。当 Derived 声称实现了 Base 接口时,它必须对所有 Base 子对象负责。允许选择性覆盖等于允许违反契约。
3.4.1.3. 技术实现:支配规则(Dominance Rule)

C++ 通过支配规则实现此要求:

  • 在继承图中,若类 D 覆盖了虚函数 f,则 D::f 支配 (dominates)所有从基类到 D 路径上的 f。
  • 编译器在构建 vtable 时,对每个虚函数槽位,沿所有路径向上搜索,取最近(most derived)的覆盖版本。
  • 若存在多个"最近"覆盖且互不支配(如 Left::f 和 Right::f 都覆盖了 Base::f,但 Derived 未覆盖),则产生二义性错误,而非静默选择其一。
cpp 复制代码
// ❌ 二义性:Left::f 和 Right::f 互不支配
struct Derived2 : Left, Right { }; 
// Derived2 d; d.f(); // ERROR: ambiguous

// ✅ 解决:Derived3 提供统一覆盖
struct Derived3 : Left, Right { void f() override; };
3.4.1.4. 反面论证:若不遵守会怎样?
  • 方案A:路径相关分发
    d.f() 的行为取决于静态类型是 Left& 还是 Right&。这使多态失去意义------同一对象在不同上下文中表现不同,调试和维护成本极高。
  • 方案B:强制单链优先
    规定"左优先"或"声明顺序优先"。这引入了隐式顺序依赖,代码可读性差,重构时极易引入隐蔽 bug。Stroustrup 明确拒绝:"C++ 不应依赖顺序来解决语义问题。"
  • 方案C:运行时合并
    在运行时动态决定使用哪个覆盖。这违背了 C++ "零开销抽象"原则,且使编译期检查失效。
3.4.2 第二条规则:静态可判定性

"必须能确定覆盖了虚函数的函数,并能从继承格上捕捉到所有的不一致性问题。否则只能依赖顺序相关性或运行时检查,这是不可靠的。"

3.4.2.1. 核心含义

所有关于"谁覆盖了谁"、"是否存在冲突/二义性"的问题,必须在编译期通过静态分析继承图(Inheritance Graph)完全解决。不允许将决策推迟到运行时,也不允许依赖源码书写顺序等脆弱线索。

3.4.2.2. 为什么这是"必要"的?
  • 可靠性(Reliability) :运行时检查意味着错误只在特定执行路径下暴露。对于系统编程语言,未定义行为或延迟发现的错误是不可接受的。编译期捕获 = 错误前移 = 工业级安全。
  • 可预测性(Predictability) :程序员应能通过阅读类声明和继承关系,推导出虚函数的最终绑定结果。若结果依赖编译器内部算法或链接顺序,则代码失去可移植性和可维护性。
  • 性能(Performance) :静态解析允许编译器生成固定偏移的 vtable 槽位。运行时检查意味着每次调用都需要查表、比较、分支判断,这与 C++ 的性能目标背道而驰。
  • 工具链支持:IDE 自动补全、重构工具、静态分析器都依赖静态可判定的语义。若语义模糊,工具无法可靠工作。
3.4.2.3. 技术实现:继承格上的偏序关系

C++ 将继承关系建模为有向无环图(DAG),并定义严格的偏序:

  • 支配关系(Dominance) :若类 A 是类 B 的基类,且存在从 B 到 A 的唯一路径,则 B 的成员支配 A 的同名成员。
  • 唯一最派生覆盖(Unique Most-Derived Override) :对于每个虚函数,编译器在 DAG 上执行拓扑排序,寻找唯一的、不被任何其他覆盖所支配的节点。
    • 找到唯一节点 → 合法,绑定到该节点。
    • 找到多个互不支配节点 → 二义性错误(编译期)。
    • 未找到任何覆盖 → 若为纯虚函数则抽象类错误;否则绑定到基类版本。
  • vtable 布局确定性:vtable 中每个槽位对应的函数指针在编译期完全确定,与链接顺序、翻译单元顺序无关。
3.4.2.4. 反面论证:若不遵守会怎样?
  • 方案A:运行时类型检查(RTTI-only dispatch)
    每次虚调用都执行 dynamic_cast 风格的搜索。性能灾难,且无法在编译期发现缺失覆盖。
  • 方案B:顺序敏感解析
    "先声明的基类优先"或"最后定义的覆盖胜出"。这使代码成为"文本顺序的奴隶",重排 include 或基类列表即可改变程序行为,极度危险。
  • 方案C:链接时决议(Link-time resolution)
    类似 C 语言的弱符号机制。不同翻译单元可能看到不同的覆盖版本,导致 ODR(One Definition Rule)违规,且错误仅在链接阶段甚至运行时才暴露。
  • 方案D:默认静默选择
    遇到冲突时自动选一个(如"最深"或"最左")。这掩盖了设计错误,使 bug 难以定位。Stroustrup 坚持:"沉默是错误的敌人。"
3.4.3 两条规则的协同:构建可靠的 MI 语义

这两条规则并非孤立,而是互为支撑:

维度 规则一(覆盖普遍性) 规则二(静态可判定性) 协同效应
语义目标 确保 MI 的表达力 确保 MI 的安全性 表达力与安全性的平衡
失败模式 功能受限(单链锁定) 行为不可预测 两者共同防止 MI 退化
实现基础 支配规则 DAG 偏序分析 共享同一套继承图分析框架
错误处理 二义性 → 编译错误 不一致 → 编译错误 所有歧义都是编译错误
设计哲学 "你必须能表达意图" "编译器必须验证意图" 程序员负责正确性,编译器负责可验证性

关键洞察 :

C++ 的 MI 虚函数机制本质上是一个编译期图算法问题。两条规则共同定义了该算法的输入(继承图)、输出(唯一绑定)和错误条件(二义性/不一致)。这使得 MI 既强大又可控,避免了像早期 Eiffel 或某些 MI 提案那样陷入运行时复杂性或语义模糊。

3.4.4 现代 C++ 的印证与演进

这两条规则在今天依然深刻影响着语言演进:

  1. override / final 关键字(C++11)

    正是规则二的工程化体现:将"覆盖是否正确"从隐式图分析变为显式声明+编译检查 。override 让程序员主动声明覆盖意图,编译器验证其是否符合支配规则。这是对"静态可判定性"的用户界面增强。

  2. Concepts 与约束(C++20)

    在模板多重继承场景中,Concepts 提供了比虚函数更强的静态检查。但底层逻辑一致:所有接口一致性必须在实例化时(编译期)验证,而非运行时。

  3. 对 Mixin 模式的支撑

    现代 C++ 的 CRTP Mixin 依赖精确的覆盖语义。若没有规则一,Mixin 无法可靠地注入行为;若没有规则二,Mixin 组合的顺序敏感性将使代码脆弱不堪。

  4. 对其他语言的启示

    • Java:放弃 MI,用接口+默认方法模拟。但其默认方法的"钻石问题"解决方案(显式指定 super)本质上是在手动执行规则一的支配解析,且缺乏规则二的全面静态检查(某些冲突仅在运行时暴露)。
    • Rust:Trait 系统通过"孤儿规则"和"重叠检查"在编译期强制执行类似规则二的静态一致性,但完全避免了运行时 vtable 开销。
    • Scala:Trait 线性化(Linearization)提供了一种确定的顺序,但这恰恰是 Stroustrup 拒绝的"顺序相关性"方案。Scala 接受了这一权衡以换取表达力,而 C++ 选择了静态图分析以保持可靠性。
3.4.5 总结:如何内化这两条规则

不要将它们视为"C++ 的怪癖",而应视为软件工程中"组合"与"可靠性"矛盾的通用解法:

  • 当你设计自己的组件系统时 :

    问自己:"我的插件/模块能否在不修改原有代码的情况下,统一覆盖某个扩展点?"(规则一)

    问自己:"当多个插件提供同名扩展时,系统能否在启动前就报告冲突,而非在用户操作时崩溃?"(规则二)

  • 当你阅读 C++ MI 代码时 :

    看到 override,想到规则二:这是编译器替你验证的契约。

    看到菱形继承中的覆盖,想到规则一:这个覆盖必须对所有路径生效,否则设计有误。

  • 当你评估其他语言的 MI 方案时 :

    用这两条规则作为标尺:它是否允许跨路径统一覆盖?它是否在编译期捕获所有冲突?答案决定了该方案是"玩具"还是"工业级工具"。

Stroustrup 的伟大之处在于,他在 1980 年代就认识到:多重继承的真正难点不是语法,而是语义的确定性。 这两条规则是他为 C++ 注入的"语义骨架",使得 MI 在四十年的实践中,虽常被劝诫慎用,却从未因根本性缺陷而被废弃。它们至今仍是理解 C++ 对象模型、设计可靠抽象层的思维基石。

4 对象布局

本节深入探讨了 C++ 多重继承在底层是如何实现的,核心聚焦于对象布局模型(Object Layout Model) 、虚基类的内存布局 以及由此引发的类型强制转换(Casting)限制。

这部分内容展示了 C++ 多重继承底层实现的工程智慧。对象布局模型 通过多个 vptr 和 delta 偏移量(或 thunk)巧妙解决了多重继承下的多态调用问题;虚基布局 通过间接寻址解决了菱形继承中的子对象共享问题;而虚基类强制转换的限制 则深刻体现了 C++ "不为不使用的功能付出代价(零开销原则)"的设计哲学------由于虚基类位置在编译期不确定,静态转换被禁止,转而依赖运行时的 dynamic_cast 来保证类型安全。这些设计共同构成了 C++ 强大、高效但也相对复杂的对象模型基础。

4.1. 对象布局模型 ( Object Layout Model)

多重继承使得 C++ 的对象模型变得复杂,主要体现在两个方面:

  1. 多个虚函数表(vptr/vtbl):一个对象可能需要包含多个虚函数表指针。
  2. this 指针调整 :虚函数表必须提供一种机制,以便在调用虚函数时,能够找到提供该虚函数的那个特定基类子对象(即调整 this 指针)。

基本布局与 this 指针调整

  • 场景 :类 C 多重继承自 A 和 B。A 和 B 都有虚函数,C 覆盖了 A 的虚函数 f。
cpp 复制代码
class A {
public:
    virtual void f(int);
};

class B {
public:
    virtual void f(int);
    virtual void g();
};

class C : public A , public B {
public:
    void f(int);
};
  • 内存布局 :C 的对象在内存中通常由 A part、B part 和 C part 拼接而成。
    • 因为 C 可以被当作 A* 或 B* 使用,所以它需要两个 vptr :一个指向 A & C's vtbl,另一个指向 B's vtbl。
    • 这样,无论拿到的是 A* 还是 B* 指针,都能通过各自的 vptr 找到正确的虚函数表,而不需要知道底层到底是一个独立的 A/B 对象,还是 C 的一部分。
  • Delta 偏移量 :当通过 B* 指针调用 C 覆盖的函数(如 f)时,函数内部期望的 this 指针是指向完整的 C 对象的,但当前传入的是指向 B part 的指针。因此,虚函数表中必须存储一个偏移量(delta)。
cpp 复制代码
void ff(B* pb)
{   pb->f(2);
}

//等价于下面的代码:
    /* generated code */
vtbl_entry* vt = &pb->vtbl[index(f)];
(*vt->fct)((B*)((char*)pb+vt->delta),2);

* 在 B's vtbl 中,&C::f 对应的 delta 是 -delta(B)。调用时,编译器生成的代码会先将 this 指针减去这个偏移量,还原成指向完整 C 对象的指针,然后再跳转执行 C::f。

  • 对于 B 自己的函数 g,delta 为 0,因为 this 指针不需要调整。

  • Cfront 的实现策略:这是 Stroustrup 在 Cfront 编译器中首次实现多重继承时采用的策略。优点是容易用 C 语言表示,可移植性好,且生成的代码没有分支,在流水线机器上执行极快。

另一种实现策略:Thunk(转换块)

  • 思路 :不在虚函数表中保存 delta 偏移量,而是保存一个指向"小代码片段(thunk)"的指针。

  • 工作方式:

    • 如果不需要调整 this,vtbl 直接指向虚函数。
    • 如果需要调整,vtbl 指向一段 thunk 代码。这段代码负责执行 this -= delta(B),然后无条件跳转(goto)到真正的虚函数 C::f。
  • 优缺点:

    • 优点:虚函数表更紧凑;当偏移量为 0 时调用更快;对于纯单继承的 C++ 程序,这种实现不会引入任何额外的时间或空间开销(满足"零开销"设计原则)。
    • 缺点:可移植性稍差,因为不是所有机器架构都允许跳进另一个函数的内部(尽管现代架构基本都支持)。

4.2. 虚基布局 ( Virtual Base Layout)

为什么叫 "virtual"?

  • Stroustrup 给出了一个比"某种魔力"更深刻的解释(源于与 Doug McIlroy 的讨论):

    • 普通虚函数:通过对象间接找到函数。
    • 虚基类 :表示这个基类的子对象并不位于某个固定位置 ,必须通过一个间接机制才能访问到它。就像一个基类被定义为无名成员一样。
  • 理想的虚基布局模型:

    • 对象 X 中包含一个指向虚基类 V 的偏移量(&X::Vobj)。
    • 虚函数表 vtbl 中也包含这个偏移量 &X::Vobj,以便在调用涉及虚基类的虚函数时,能够通过两次间接寻址找到真正的虚基类子对象。
  • Cfront 的"优化"实现:

    • Cfront 为了节省一次间接寻址的时间,将 &X::Vobj 直接放在了对象 X 的内存布局中(而不是放在 vtbl 里),并通过 vptr 间接访问。这多用了一点空间,但少用了一点时间。
  • 虚数据成员的提议 :作者提到,人们一直建议 C++ 扩充"虚数据成员"的概念(类似于虚函数),用于运行时的对象类型识别等场景,但这超出了当时多重继承设计的范畴。【虚数据成员充其量只是一种"语法糖"。如果需要一个多态的数据成员,完全可以通过定义一个"虚访问器函数"(virtual accessor)来实现。例如,基类提供一个虚函数 virtual int getVal(),派生类重写它返回不同的值,这在语义上等价于虚数据成员】

4.3 虚基类和强制转换 ( Virtual Base Classes and Casting)

核心问题:无法从虚基类指针向下强制转换

  • 原因 :虚基类是一种"派生时的性质"。在对象布局中,虚基类子对象的位置不是固定的,它取决于最终派生类的具体布局。

  • 例子 :

    cpp 复制代码
    class A : public virtual complex { ... };
    class B : public virtual complex { ... };
    class C : public A, public B { ... };
    
    void f(complex* p1, complex* p2, complex* p3) {
        (A*)p1; // error: can't cast from virtual base
        (A*)p2; // error
        (A*)p3; // error
    }
  • 解释 :当拿到一个 complex* 指针时,编译器在编译期无法知道 这个 complex 子对象相对于 A、B 或 C 对象的偏移量是多少。因为 p1 可能指向 A 中的 complex,p2 可能指向 B 中的,p3 可能指向 C 中的,它们的内存布局完全不同。

  • 结论 :由虚基类到派生类的强制转换实际上需要一个运行时的动作 ,这需要基于存储在基类对象里面的信息(如 RTTI)。在简单的静态对象布局约束下,根本没有这种信息可用。因此,C++ 禁止从虚基类指针进行静态的向下强制转换(static_cast / C-style cast)。

设计权衡与 dynamic_cast 的引入

  • 作者的疑虑:如果严格禁止这种转换,可能会让缺乏从基类出发的强制转换显得过于严苛。
  • 实际情况:通常通过虚函数访问的类,只保留几个数据项,是最典型的虚基类用法。如果基类只有数据成员,你通常不会把它的指针作为整个类的代表传来传去。如果基类有虚函数,你可以直接调用那些函数,通常不需要强制转换。
  • dynamic_cast 的解决方案 :作者指出,如果真的需要从基类强制转换到它的派生类,dynamic_cast已经基于虚函数(RTTI)解决了这个问题。它可以在运行时检查对象的真实类型并计算正确的偏移量。
  • 为什么不设计"潜在虚基类"? 作者曾考虑过允许将类显式声明为"潜在的虚基类",以便加入某些信息允许从虚基类强制转换。但他拒绝了,因为那样会迫使程序员为一个概念定义两个不同的版本(一个作为常规类,一个作为虚基类),增加了复杂性。
  • 为什么不给所有类加通用虚基类? 另一种方式是给每个类对象增加一个最一般的虚基类指针。但这会给不使用虚基类的应用带来严重负担(违反零开销原则),并导致布局不兼容。
  • 最终决定 :正因为上述原因,作者允许任何类被用作虚基类 ,并且接受了禁止对虚基类使用静态强制转换的限制。这是一个在灵活性、安全性和零开销原则之间做出的深思熟虑的权衡。

5 "方法组合"(Method Combination)

本节主要探讨在 C++ 多重继承环境下,如何手动实现类似其他面向对象语言(如 CLOS)中自动化的"方法组合"机制,并解决由此带来的基类函数重复调用问题。

本节展示了 C++ 设计哲学的一个典型侧面:不提供高度自动化的"黑盒"机制,而是提供足够灵活的底层工具,让程序员能够根据具体需求构建出高效、可控的解决方案。

面对多重继承中方法组合的复杂性,C++ 选择了"手工显式调用 + 保护接口分离"的策略。虽然这增加了编码工作量,但它换来了零运行时开销 、绝对的执行顺序控制权 以及对菱形继承重复调用问题的彻底解决。对于追求性能和确定性的系统编程而言,这种权衡是值得的。

5.1 核心背景:什么是"方法组合"?

  • 定义:派生类的某个函数,往往不是凭空编写的,而是将同一个函数在各个基类中的版本"综合/拼接"起来执行。这种机制被称为"方法组合"。
  • C++ 的现状 :Stroustrup 指出,某些面向对象的方言(如 Common Lisp Object System, CLOS)原生支持这种特性,甚至提供了 call、return、.before、.after 等专门的概念来自动化处理方法的组合顺序。
  • C++ 的设计取舍 :C++ 没有 在语言层面内置这种自动化的方法组合机制。作者认为,如果早期引入了这些复杂概念,可能会让已经因多重继承而显得复杂的 C++ 变得更加难以被大众接受。因此,C++ 选择将控制权交给程序员,通过手工方式来实现方法组合。

5.2 手工方法组合面临的核心问题

在多重继承中,手工组合方法最大的痛点是:如何避免在不希望的时候,对基类里的同一个函数进行多次调用?

  • 例如,在菱形继承结构中,如果派生类简单地依次调用所有直接基类的同名函数,那么位于菱形顶端的公共基类的函数就会被执行两次,这通常会导致逻辑错误(如重复初始化、重复计数等)。

5.3 解决方案:保护接口 + 显式委托模式

为了解决上述问题, Stroustrup 提出了一种特定的编码风格/设计模式。其核心思想是将"对外公开的接口"与"实际执行自身逻辑的内部实现"分离开来。

5.3.1. 模式结构解析
  • 顶层基类 (W) :
    • 提供一个 protected 的保护函数 _f():用于封装该类"自己的事情"(核心逻辑)。
    • 提供一个 public 的公开函数 f():作为一般公开使用的接口。在基类中,f() 通常直接调用 _f()。
  • 中间派生类 (A, B) :
    • 同样遵循此模式:重写 protected void _f() 来做自己的事。
    • 重写 public void f():在 f() 内部,先调用自己的 _f(),再显式调用直接基类的 f() (如 W::_f())。
    • 注意代码细节 :A 和 B 的 f() 实现是 { _f(); W::_f(); }。这里直接调用了 W::_f() 而不是 W::f(),这是一种避免递归或重复调用的技巧,但在更通用的场景下,通常是调用基类的 f() 让其自行组合。书中的写法强调了每个类只负责调用自己需要的基类逻辑片段。
  • 最终派生类 (C) :
    • C 同时继承自 A、B 和虚基类 W(class C : public A, public B, public virtual W)。
    • C 的 f() 实现为:{ _f(); A::_f(); B::_f(); W::_f(); }。
    • 关键点 :C 显式地、按顺序调用了所有相关基类的保护函数 _f() ,而不是调用它们的公开函数 f()。
cpp 复制代码
class W {
    // ...
protected:
    void _f() { /* W's own stuff */ }
    // ...
public:
    void f() { _f(); }
    // ...
};

class A : public virtual W {
    // ...
protected:
    void _f() { /* A's own stuff */ }
    // ...
public:
    void f() { _f(); W::_f(); }
    // ...
};

class B : public virtual W {
    // ...
protected:
    void _f() { /* B's own stuff */ }
    // ...
public:
    void f() { _f(); W::_f(); }
    // ...
};

class C : public A, public B, public virtual W {
protected:
    void _f() { /* C's own stuff */ }
    // ...
public:
    void f() { _f(); A::_f(); B::_f(); W::_f(); }
    // ...
};
5.3.2. 为什么这种方法能避免重复调用?
  • 因为每个类都把"自己的逻辑"封装在 _f() 中,而把"组合逻辑"放在 f() 中。
  • 在最底层的派生类 C 中,程序员拥有完全的控制权。C 直接调用 A::_f()、B::_f() 和 W::_f()。
  • 由于 W 是虚基类,且在 C 中只被显式调用了一次 W::_f(),因此无论 W 在继承层次中被间接继承了多少次,它的核心逻辑 _f() 在整个调用链中只会被执行一次。
  • 这就完美解决了菱形继承中基类方法被多次触发的问题。

5.4 该方案的优缺点评价

  • 缺点(不如自动化方便) :
    • 需要程序员手动编写大量的样板代码(boilerplate code)。
    • 每个类都要维护一对 f() 和 _f()。
    • 最终派生类必须清楚地知道整个继承层次结构,并手动列出所有需要调用的基类 _f()。如果继承结构发生变化,所有相关的 f() 实现都需要手动更新。
  • 优点(更灵活) :
    • 完全控制:程序员可以精确控制各个基类逻辑的执行顺序(谁先谁后)。
    • 选择性调用:可以选择性地跳过某些基类的逻辑,或者在某些条件下才调用。
    • 无运行时开销:不像某些动态语言的自动化方法组合可能涉及反射或元对象协议,这种纯静态的手工调用在编译后就是普通的函数调用,效率极高。
    • 透明性:代码的逻辑流向一目了然,没有隐藏的"魔法"行为,便于调试和理解。

5.5 与现代 C++ 实践的关联

虽然上面展示的是早期的手工方法,但这种思想在现代 C++ 中依然有回响:

  1. 模板方法模式(Template Method Pattern) :书中的 _f() 相当于钩子函数(hook),f() 相当于模板方法。这是 GoF 设计模式中的经典应用。
  2. CRTP(奇异递归模板模式):现代 C++ 常用 CRTP 在编译期静态地组合多个 Mixin 的行为,避免了虚函数的运行时开销,同时也避免了手动维护调用列表的繁琐。
  3. std::function / 回调链:在现代事件驱动或组件系统中,方法组合常通过注册回调列表来实现,而不是通过继承层次硬编码。

6 有关多重继承的论战

本节记录了 C++ 发展早期(1990年代初)社区围绕"是否应该在 C++ 中引入多重继承"展开的激烈争论,以及 Stroustrup 本人对这些批评的回应和最终的设计哲学总结。

这一节不仅是历史记录,更是 C++ 设计哲学的宣言。它告诉我们:

  1. 实用主义至上:C++ 不追求理论上的完美或极简主义,而是追求解决实际工程问题的能力。MI 虽然有复杂性,但它解决了真实的建模需求,因此值得保留。
  2. 信任程序员:C++ 假设使用者是理性的、有能力的工程师。语言提供强大的工具,同时通过文档、最佳实践和社区规范来引导正确使用,而不是通过限制功能来防止误用。
  3. 演进式发展:语言设计不是一蹴而就的。MI 在早期备受争议,但随着时间推移,社区逐渐摸索出了安全有效的使用模式(如"至多一个数据基类 + 多个纯接口"),使其成为现代 C++ 不可或缺的一部分。
  4. 警惕"范式移植":不要试图把其他语言的成功经验生搬硬套到 C++ 上。理解 C++ 的底层模型(内存布局、编译期确定性、零开销原则)是正确使用 MI 的前提。

6.1 历史背景:一场关于多重继承的"论战"

在 C++ 标准化之前,多重继承是一个极具争议的特性。Stroustrup 列举了当时学术界和工业界提出的多种反对意见,这些意见主要来自 Cargill, 1991、Carroll, 1991、Waldo, 1991、Sakkinen, 1992、Waldo, 1993 等文献。争论的核心集中在:概念复杂性、实际有用性、对其他语言特性的影响、以及对工具链(编译器/调试器)的负担。

Stroustrup 将当时的反对声音归纳为以下几类典型观点:

6.1.1 📌 反对观点 1:多重继承是"不必要的花饰",会导致语言臃肿
  • 论点:早期很多人认为 MI 是 C++ 的第一个主要扩充,是一种"节外生枝",会让 C++ 变得像 Ada 一样复杂难用,甚至导致大门洞开使新特征蜂拥而入。
  • 代表人物:Tom Cargill 曾建议删除 MI,或至少限制其使用场景。Jim Waldo 后来也指出,人们在提出建议时往往只看到表面问题,而忽略了深层的技术权衡。
  • Stroustrup 的回应:他承认早期确实收到过大量"删除 MI"的建议,但他坚持认为 MI 解决的是真实存在的建模需求(如接口组合、Mixin),不能因为怕复杂就因噎废食。
6.1.2 📌 反对观点 2:MI 违背了"简单性"原则,增加了认知负担
  • 论点:Smalltalk 支持者认为,只有单继承才是"好"的面向对象,MI 会让程序员困惑。他们担心 MI 会破坏 Smalltalk 那种简洁优雅的编程模型。
  • Stroustrup 的回应:他指出这是一种误解。Smalltalk 没有 MI 是因为它的设计哲学不同,但 C++ 面向的是系统编程和性能敏感领域,需要更强大的表达能力。MI 本身并不必然导致混乱,关键在于如何使用。他还反驳说,如果连 Smalltalk 都能通过元类机制模拟出类似 MI 的行为,那么直接在语言层面支持反而更清晰。
6.1.3 📌 反对观点 3:MI 的实现太复杂,会给编译器和工具带来巨大负担
  • 论点:有人认为 MI 的实现方式(尤其是虚继承)过于复杂,会增加编译器的难度,降低编译速度,并使调试器难以处理对象布局。
  • Stroustrup 的回应:他承认 MI 的实现确实比单继承复杂,但这属于"工程挑战"而非"理论缺陷"。随着编译器技术的进步,这些问题是可以被克服的。更重要的是,MI 带来的表达力提升远远超过了实现成本。
6.1.4 📌 反对观点 4:MI 容易被滥用,导致设计混乱
  • 论点:许多人认为 MI 从根本上说是个坏东西,因为它"太通用",容易导致拙劣的设计和错误的代码。"多重继承当然可能被过度使用,但每个令人感兴趣的语言特征都会有这种风险。"
  • Stroustrup 的回应 :这是全书最精彩的辩驳之一。他指出:任何强大的工具都可能被滥用,但这不是废除它的理由。 就像指针可能导致内存泄漏,但我们不会因此废除指针;模板可能导致代码膨胀,但我们不会因此废除模板。关键在于教育程序员如何正确使用,而不是剥夺他们的能力。他强调,C++ 的目标是提供"足够的抽象能力",让专家能写出高效优雅的代码,同时通过良好的文档和最佳实践引导新手避免陷阱。
6.1.5 📌 反对观点 5:可以用委托(Delegation)替代 MI
  • 论点:有些人认为 MI 是一种"太弱"的机制,应该用运行时的委托机制来取代。委托可以在运行时动态地将操作转发给另一个对象,看起来更灵活。
  • Stroustrup 的回应 :他非常喜欢委托的想法,并在 C++ 中尝试实现了它的一个变体(见后面小节)。但他发现,委托在实际使用中遇到了严重的问题:每次调用都需要一次间接跳转,性能开销大;而且委托关系在运行时才确定,使得静态分析和优化变得困难。 相比之下,MI 是在编译期确定的静态关系,编译器可以进行充分的优化,生成高效的代码。因此,他认为 MI 和委托各有适用场景,不能互相替代。
6.1.6 📌 反对观点 6:MI 放进 C++ 会给语言的潜在特征(如垃圾回收)增加困难
  • 论点:有人断言,MI 会使未来的语言扩展(如自动内存管理、反射等)变得更加困难。
  • Stroustrup 的回应:他认为这是一种短视的观点。语言的设计应该着眼于当前和可预见的未来的需求,而不是为了 hypothetical 的未来特性而牺牲现在的表达力。事实上,后来的 Java、C# 等语言虽然放弃了 MI,但也付出了其他代价(如接口的局限性)。
6.1.7 📌 反对观点 7:多数人在多年后才意识到 MI 是个好主意,说明当初加入它是错误的
  • 论点:有些意见认为,既然大多数人在 C++ 诞生几年后才接受 MI,那说明当初把它加进来是个错误。
  • Stroustrup 的回应:他幽默地反驳道:"这种意见可能会使'C+++'的设计者们感兴趣,但我没有发现这些建议对自己改进 C++ 语言的工作有多少帮助。" 他强调,语言设计是一个渐进的过程,早期的决策往往是基于有限的经验和直觉,后来的修正和完善是正常的演进过程,不能以此否定最初的价值。

6.2 Stroustrup 的最终立场与设计哲学

在逐一回应了各种反对意见后,Stroustrup 给出了自己的结论:

"我认为------就像我那时所认为的一样------这些论断的根本缺陷在于把多重继承看得太严重了。多重继承不可能解决你的所有问题,它本来就不必这样,因为它是很便宜的东西。"

这句话蕴含了深刻的工程智慧:

  1. MI 不是银弹:它不能解决所有设计问题,也不应该被期望去解决所有问题。
  2. MI 是"廉价"的工具:这里的"廉价"指的是它的实现成本和使用门槛相对较低(相比于构建一套完整的委托系统或元对象协议)。它是一个轻量级的、可选的工具,程序员可以根据需要选择用或不用。
  3. Grady Booch 的精辟比喻 :Stroustrup 引用了 Grady Booch 的话:"多重继承就像一项降落伞,你并不经常需要它,但在你需要的时候它就是最关键的。" 这个比喻完美诠释了 MI 的定位------它不是日常必需品,但在特定场景下(如框架设计、组件复用、接口聚合)是不可或缺的救命稻草。

6.3 多重继承的成功使用模式

Stroustrup 总结了在实践中被证明成功的几种 MI 使用模式,这些模式至今仍是 C++ 最佳实践的核心:

模式编号 模式名称 描述 内容引用
1 归并相互独立的类层次 将两个或多个本质上无关的类层次结构合并到一个派生类中。例如,一个 Task 类既需要 Displayed(可显示)的能力,又需要 Runnable(可运行)的能力。 普通类型
2 接口的组合 这是现代 C++ 中最常见的用法。派生类从多个纯接口类(只有虚函数,无数据成员)继承,以承诺实现多种契约。例如,流 I/O 体系中 iostream 同时继承 istream 和 ostream。 虚基类
3 从一个接口和一个实现综合出一个类 派生类从一个接口类继承以获得多态性,同时从一个具体的实现类继承以复用代码。例如,slist_set 可能从 Set 接口和 SList 实现类继承。 抽象类型

此外,他还提到了更多例子分布在抽象类型、 简单的对象I/O和 结组,涵盖了 Mixin、策略模式、适配器等多种高级用法。

6.4 对"外来风格强加"的警告

Stroustrup 特别警告了一种常见的误区:

"大部分失误出现在某些人想将某种外来的风格强加进 C++ 的时候。"

他举例说,CLOS(Common Lisp Object System)的一些设计理念(如基于按义消解的线性化、共享做名称匹配、用 .before 和 .after 方法建立组合操作)如果直接照搬到 C++ 中,会形成"很不令人喜欢"的东西,并大大增加程序的复杂性。

核心启示:每种语言都有其独特的基因和设计哲学。C++ 的 MI 是为静态类型、高性能、零开销抽象而设计的,不应该盲目模仿动态语言的运行时元编程风格。尊重语言的本性,才能发挥出它的最大威力。


7 委托(Delegation)

本节记录了 C++ 早期对"委托"机制的一次重要实验、失败原因分析,以及最终为何 C++ 没有将委托作为原生语言特性,而是选择通过其他手段(如指针、引用、手工转发)来实现类似功能的决策过程。

这一节深刻揭示了 C++ 设计哲学中的一个核心原则:"零开销抽象"与"显式优于隐式"。

  1. 拒绝隐藏的运行时成本:委托机制看似方便,但其背后隐含的 vtable 动态修改、调用歧义解析等都需要运行时开销或复杂的编译器支持。C++ 不愿意为了语法糖而牺牲性能和可预测性。
  2. 显式控制优于自动魔法:手工转发虽然繁琐,但每一行代码的意图都清晰可见。程序员知道调用链是如何传递的,也知道状态是如何共享的。这种"显式性"对于大型系统的维护和调试至关重要。
  3. 组合优于复杂的继承/委托 :现代 C++ 实践进一步印证了这一观点。我们不再追求语言层面的自动委托,而是通过组合(Composition)+ 接口抽象 + std::function/std::bind 来实现更灵活、更安全的委托行为。

7.1 历史背景:委托概念的起源

  • 首次提出:委托的概念最早出现在 1987 年 5 月赫尔辛基的欧洲 UNIX 用户组织 (EUUG) 大会上,由 Stroustrup 本人演示 Stroustrup, 1987。
  • 理论来源:该概念借鉴自 Agha 在 1986 年提出的 Actor 模型中的委托思想 Agha, 1986。
  • 核心想法 :允许在一个类中定义一个指针,该指针指向另一个类的对象。当通过这个指针调用函数时,行为就像这个被指向的对象是当前类的一个基类一样。这提供了一种运行时的、动态的继承/复用机制。
7.1.1 📌 示例代码解析

下面给出了一个简单的例子:

cpp 复制代码
class B { int b; void f(); };
class C { *p (B* p); int c; }; // 注意:这里的语法是早期草案写法,意为 C 包含一个指向 B 的指针 p

void f(C* q) {
    q->f();   // meaning q->p->f() ------ 自动转发到 p 指向的 B 对象
}
  • 语义 :C 类的对象在初始化后,内部持有一个指向 B 对象的指针 p。当外部代码调用 q->f() 时,编译器会自动将其解释为 q->p->f(),即把调用"委托"给内部的 B 对象。
  • 内存布局示意 :图中展示了 C 对象的结构:它包含自己的数据成员 c,以及一个指向 B 对象的指针 p。B 对象本身包含数据 b 和方法 f()。

7.2 委托机制的吸引力与初步实现

Stroustrup 承认,这个概念在理论上非常有吸引力:

  1. 更高的灵活性:相比静态的多重继承,委托可以在运行时动态改变被委托的对象,从而实现更灵活的结构重组。
  2. 简单的实现:从原理上看,委托的实现并不复杂,运行时和空间效率也很理想(本质上就是一次指针间接寻址)。
  3. 实际尝试:Stroustrup 曾为几个用户做出了一个实验性的实现。特别提到 Bill Hopkins 对这个想法非常热衷,投入了大量精力去完善它。

然而,这次实验最终以失败告终,并导致了著名的 Cfront Release 2.0 中出现严重问题。

7.3 委托机制失败的两大根本原因

Stroustrup 明确指出,导致委托机制在实践中崩溃的两个核心问题是:

7.3.1 ❌ 问题 1:位于委托类里的函数不能覆盖被委托类里的函数
  • 含义 :如果 C 类自己定义了一个 f() 函数,同时它又委托给 B 类的 f(),那么当调用 c.f() 时,应该执行哪一个?
  • 困境 :在传统的继承中,派生类的函数会"覆盖"(override)基类的虚函数,这是明确且可预测的。但在委托中,C 和 B 之间没有严格的"is-a"关系,C 的 f() 和 B 的 f() 是两个独立的函数。语言层面很难定义一套清晰、无歧义的规则来决定优先级。
  • 后果:这导致了语义上的混乱。程序员无法确定调用的是哪个版本,编译器也难以生成正确的代码。
7.3.2 ❌ 问题 2:被委托函数不能使用委托类里的函数,或者以其他方式"回到"委托对象那里
  • 含义 :假设 B::f() 在执行过程中需要调用 C 类的某个辅助函数 g()。由于 B 并不知道自己是被人委托的,它也没有持有指向 C 对象的反向指针,因此 B::f() 无法直接调用 C::g()。
  • 困境:这破坏了对象之间的协作能力。在真实的软件设计中,组件之间往往需要双向通信。委托机制切断了这种反向依赖,使得被委托的对象变成了一个"孤岛",无法与委托者进行深度交互。
  • 后果:这极大地限制了委托的实用性。许多需要紧密协作的设计模式(如观察者模式、策略模式中的回调)都无法用纯委托机制优雅地实现。

Stroustrup 的反思 :

"自然,这两个问题是相关的。同样也很自然,我已经考虑到这些潜在问题并提醒用户注意它们。但是这种警告并没有起作用------甚至我自己也忘记了自己的规则而落入圈套。这就使这些问题看起来不属于小污点一类的东西,无法通过教育和编译警告的组合的方式加以处理。"

这段话非常坦诚地表明,即使设计者本人也无法在实际编码中始终遵守这些复杂的隐含规则,说明该机制的认知负担过重,不适合成为语言的核心特性。


7.4 更深层次的技术障碍:虚函数表的修改难题

除了上述两个语义问题,Stroustrup 还指出了一个更底层的技术挑战:

  • 问题描述:要解决问题 1(允许覆盖),就需要在被委托对象重新与某个委托对象建立约束时,动态地修改被委托对象的虚函数表(vtable)。
  • 为什么困难:虚函数表是 C++ 运行时多态的基础,通常在编译期或对象构造时确定。在运行时动态修改 vtable 不仅违反了 C++ 的静态类型安全原则,还会带来巨大的性能开销和实现复杂性。
  • 结论:"这一点已经超出了语言其他部分的界限,很难给出合理的定义。" 这意味着,要让委托真正好用,需要对 C++ 的对象模型进行根本性的改造,而这与 C++ 追求高效、可预测的设计哲学相悖。

7.5 另一个致命案例:共享委托导致的歧义

这里还有另外的一个具体的反例:

"我们还发现了另一些例子,在那里需要通过一个 B* 委托到一个被派生类 D 的对象。"

  • 场景 :假设 C 委托给一个 B* 指针,但这个指针实际指向的是一个 D 对象(D 继承自 B)。
  • 问题 :当调用 c.f() 时,是应该调用 B::f() 还是 D::f()?如果 D 重写了 f(),委托机制是否应该支持这种"向下"的多态?
  • 复杂性:这引入了类似于多重继承中的菱形问题和虚继承的复杂性,但发生在运行时,使得情况更加不可控。

7.6 最终决策:放弃原生委托,回归手工实现

基于以上种种困难,Stroustrup 得出了明确的结论:

"由于委托不是 C++ 直接支持的东西,如果真的需要它,我们就需要能迂回地完成相关工作。"

他指出了两种在 C++ 中实现"委托效果"的可行替代方案:

7.6.1 ✅ 替代方案 1:使用灵巧指针(Smart Pointers / Handle)
  • 做法:不依赖语言内置的委托语法,而是让程序员显式地使用指针或智能指针来持有被委托对象,并在需要时手动调用其方法。
  • 优点:完全透明,程序员清楚知道每一次调用的去向,没有隐藏的魔法行为。性能可控,语义清晰。
  • 示例 【在 C++11 之前通常指自定义的引用计数句柄(Handle)或裸指针;而在现代 C++(C++11 及以后)中,这完美映射到了标准库提供的 std::unique_ptr、std::shared_ptr 以及 std::weak_ptr。】:
7.6.2 ✅ 替代方案 2:手工转发(Manual Forwarding)
  • 做法:让委托类提供一个完整的公共接口,然后在每个接口函数的实现中,"手工地"将请求向前推送给内部持有的其他对象。

  • 示例 :

    cpp 复制代码
    class C {
        B* p;
    public:
        void f() { p->f(); }  // 手工转发
        void g() { /* C 自己的逻辑 */ }
    };
  • 优点 :虽然需要写一些样板代码(boilerplate),但它赋予了程序员绝对的控制权。你可以决定哪些函数需要转发,哪些不需要;可以在转发前后添加额外的逻辑(如日志、权限检查);可以完美解决"反向调用"的问题(因为 C 可以把 this 指针传给 B,如果 B 需要的话)。

7.7 📌 现代 C++ 如何实现"委托"?

今天,如果你想在 C++ 中实现类似当年 Stroustrup 设想的委托功能(下面示例中,类 C 完全拥有并控制委托对象 B 的生命周期【独占委托】),你会这样做:

cpp 复制代码
#include <functional>
#include <memory>

class B {
public:
    virtual ~B() = default;
    virtual void f() { std::cout << "B::f\n"; }
};

class C {
    std::unique_ptr<B> p_;  // 持有被委托对象
    
    // 可选:存储回调,解决"反向调用"问题
    std::function<void()> callback_from_b_;

public:
    C(std::unique_ptr<B> b) : p_(std::move(b)) {}

    // 手工转发 ------ 清晰、可控、零隐藏开销
    void f() { 
        if (p_) p_->f(); 
    }

    // 如果需要 B 能回调 C,可以注入一个 lambda
    void setup_callback() {
        callback_from_b_ = [this]() { 
            std::cout << "C received callback from B\n"; 
        };
        // 假设 B 有 set_callback 方法...
    }
};

这种方式虽然比 q->f() 自动变成 q->p->f() 多写了几行代码,但它避免了所有导致 Cfront 2.0 崩溃的陷阱,并且与现代 C++ 的资源管理(RAII)、类型安全和泛型编程理念完美契合。

7.8 现代【灵巧指针】

Stroustrup 所说的"灵巧指针(Smart Pointers / Handle)"在 C++11 之前通常指自定义的引用计数句柄(Handle)或裸指针;而在现代 C++(C++11 及以后)中,这完美映射到了标准库提供的 std::unique_ptr、std::shared_ptr 以及 std::weak_ptr。

这种方案的核心思想是:将"委托关系"显式化为"对象的所有权或观察权"。调用者清楚地知道对象在哪里、由谁管理,没有语言层面的隐式转发。

以下是现代 C++ 中实现该方案的具体示例,分为三种典型场景:

7.8.1 场景一:独占委托(Exclusive Delegation)

适用情况 :类 A 完全拥有并控制委托对象 B 的生命周期。

现代实现 :使用 std::unique_ptr。

cpp 复制代码
#include <iostream>
#include <memory>

// 1. 定义被委托的接口(或具体类)
class Printer {
public:
    void print(const std::string& msg) const {
        std::cout << "Printer: " << msg << "\n";
    }
};

// 2. 委托方:通过 unique_ptr 显式持有委托对象
class Document {
    std::unique_ptr<Printer> printer_; // 显式的句柄/灵巧指针

public:
    // 构造时注入依赖(所有权转移)
    explicit Document(std::unique_ptr<Printer> p) 
        : printer_(std::move(p)) {}

    // 显式的手动转发(没有隐藏的魔法)
    void render() const {
        if (printer_) { // 程序员显式检查有效性
            printer_->print("Rendering document...");
        }
    }
};

int main() {
    // 明确的所有权转移
    auto doc = std::make_unique<Document>(std::make_unique<Printer>());
    doc->render(); 
}

Stroustrup 视角的解读:

  • 完全透明 :阅读 Document 的代码,一眼就能看出它依赖 Printer。
  • 无隐藏开销:没有编译器在背后生成额外的 vtable 查找或隐式参数传递。

7.8.2 场景二:共享委托(Shared Delegation)

适用情况 :多个对象共同委托给同一个服务或状态,且该服务的生命周期独立于这些对象。

现代实现 :使用 std::shared_ptr。

cpp 复制代码
#include <iostream>
#include <memory>

class DatabaseConnection {
public:
    void execute(const std::string& sql) const {
        std::cout << "Executing SQL: " << sql << "\n";
    }
};

class UserRepository {
    std::shared_ptr<DatabaseConnection> db_; // 共享所有权

public:
    explicit UserRepository(std::shared_ptr<DatabaseConnection> db) 
        : db_(std::move(db)) {}

    void saveUser() const {
        // 显式调用,引用计数保证 db_ 在此期间不会被销毁
        db_->execute("INSERT INTO users ...");
    }
};

class OrderRepository {
    std::shared_ptr<DatabaseConnection> db_; // 共享同一个连接

public:
    explicit OrderRepository(std::shared_ptr<DatabaseConnection> db) 
        : db_(std::move(db)) {}

    void createOrder() const {
        db_->execute("INSERT INTO orders ...");
    }
};

int main() {
    auto db = std::make_shared<DatabaseConnection>();
    UserRepository users(db);
    OrderRepository orders(db);

    users.saveUser();
    orders.createOrder();
}

Stroustrup 视角的解读:

  • 这替代了早期 C++ 中程序员手写 Handle 类(包含裸指针+引用计数)的繁琐工作。
  • 生命周期管理是显式的、可审计的,而不是依赖垃圾回收器的隐式行为。

7.8.3 场景三:非拥有式委托 / 弱委托(Non-Owning Delegation)

适用情况 :对象 A 需要委托给对象 B,但 A 不应该 延长 B 的生命周期(例如:子节点持有父节点的引用、观察者持有被观察者的引用)。

现代实现 :使用 std::weak_ptr 或 裸指针/引用(配合明确的文档约定)。

cpp 复制代码
#include <iostream>
#include <memory>

class Engine; // 前置声明

class Car {
    std::weak_ptr<Engine> engine_; // 弱委托:Car 不拥有 Engine

public:
    void setEngine(std::shared_ptr<Engine> e) {
        engine_ = e; // 不增加引用计数
    }

    void start() const {
        // 必须显式检查委托对象是否还活着
        if (auto e = engine_.lock()) {
            e->ignite();
        } else {
            std::cout << "No engine attached!\n";
        }
    }
};

class Engine {
public:
    void ignite() const { std::cout << "Vroom!\n"; }
};

int main() {
    auto car = std::make_shared<Car>();
    {
        auto engine = std::make_shared<Engine>();
        car->setEngine(engine);
        car->start(); // Vroom!
    } // engine 在这里被销毁

    car->start(); // No engine attached! (安全,不会崩溃)
}

Stroustrup 视角的解读:

  • 这正是 Stroustrup 强调的"程序员清楚知道每一次调用的去向"。
  • weak_ptr::lock() 是一种显式的防御性编程 ,比 C# 或 Java 中委托可能抛出的 NullReferenceException 更具表达力。

7.8.4 总结:为什么 Stroustrup 认为这是"最佳实践"?

对比 C# 的 event 或 Objective-C 的 delegate 属性,C++ 的这种"手工实现"看似繁琐,实则具有巨大的工程优势:

维度 语言内置委托 (C#/ObjC) 灵巧指针/Handle (C++ 方案)
所有权语义 隐藏(通常是弱引用,但不可见) 显式 (unique / shared / weak)
生命周期安全 运行时可能空引用 编译期约束 + 运行时显式检查 (lock())
性能 可能有 GC 压力或隐式开销 零开销抽象(RAII,栈上分配控制块)
代码可读性 看起来像魔法 看起来像普通的成员访问
调试难度 难以追踪委托链 断点直接打在 printer_->print() 上

Stroustrup 的结论在今天依然适用:不要试图在 C++ 中发明一种新的"委托语法"来掩盖指针的本质。相反,应该利用现代 C++ 的类型系统(智能指针)将指针的语义(所有权、生命周期、可空性)表达得尽可能清晰。 这就是"回归手工实现"在现代语境下的真正含义。

7.9 现代【手工转发】

Stroustrup 提到的"手工转发(Manual Forwarding)"方案,其核心思想是:将"委托"从一种语言级的隐式机制,降级为一种显式的对象组合(Composition)与接口转发模式。

在 C++11 之前,手工转发意味着程序员需要为每一个被委托的方法手写冗长的转发函数。而在现代 C++ 中,借助 override、noexcept、完美转发(Perfect Forwarding)以及宏模板等工具,我们可以将"手工转发"写得既安全又优雅。

以下是现代 C++ 中实现手工转发的具体示例,按工程实践的深度递进:

7.9.1 示例 1:基础手工转发(显式接口适配)

适用情况 :委托对象与宿主对象的接口不完全一致,或者宿主对象需要对外暴露一个"净化"后的接口。

核心机制:显式持有对象,逐一手写转发函数。

cpp 复制代码
#include <iostream>
#include <string>

// 1. 被委托的底层服务(可能是第三方库,无法修改)
class LegacyLogger {
public:
    void log(const std::string& msg) { std::cout << "[LOG] " << msg << "\n"; }
    void warn(const std::string& msg) { std::cout << "[WARN] " << msg << "\n"; }
};

// 2. 宿主类:通过手工转发暴露接口
class SecureService {
    LegacyLogger logger_; // 组合(内嵌对象),而非指针

public:
    // 显式的手工转发:可以加入业务逻辑(如过滤敏感词)
    void log_message(const std::string& msg) {
        // 转发前的预处理
        if (msg.find("password") != std::string::npos) return; 
        logger_.log(msg); // 显式调用
    }

    // 直接转发:保持接口一致
    void log_warning(const std::string& msg) {
        logger_.warn(msg);
    }
};

Stroustrup 视角的解读:

  • 没有隐藏行为 :SecureService 没有任何"魔法"。读者一眼就能看出它依赖 LegacyLogger。
  • 完全可控:你可以在转发前后插入任何逻辑(权限检查、日志增强等),这是语言级委托难以做到的。

7.9.2 示例 2:基于接口的转发与 override 契约(现代 C++ 最佳实践)

适用情况 :宿主类需要实现某个抽象接口,并将具体实现委托给内部对象。

核心机制 :使用 override 确保编译期契约,结合 noexcept 优化性能。

cpp 复制代码
#include <memory>
#include <string>

// 1. 抽象接口
class IStorage {
public:
    virtual ~IStorage() = default;
    virtual void save(const std::string& key, const std::string& value) = 0;
    virtual std::string load(const std::string& key) const = 0;
};

// 2. 具体的委托目标
class RedisStorage : public IStorage {
public:
    void save(const std::string& key, const std::string& value) override {
        // Redis 实际保存逻辑
    }
    std::string load(const std::string& key) const override {
        return "redis_value";
    }
};

// 3. 宿主类:手工转发 + 接口契约
class CacheManager : public IStorage {
    std::unique_ptr<IStorage> backend_; // 持有接口指针

public:
    explicit CacheManager(std::unique_ptr<IStorage> backend) 
        : backend_(std::move(backend)) {}

    // 显式转发,并使用 override 保证与 IStorage 签名严格一致
    void save(const std::string& key, const std::string& value) override {
        backend_->save(key, value); // 委托给后端
    }

    std::string load(const std::string& key) const override {
        return backend_->load(key); // 委托给后端
    }
};

Stroustrup 视角的解读:

  • 类型安全 :override 关键字让编译器替你检查转发函数的签名是否与被委托的接口完全匹配。
  • 解耦 :CacheManager 只依赖 IStorage,不依赖 RedisStorage。这是经典的装饰器模式(Decorator Pattern)或适配器模式(Adapter Pattern)。

7.9.3 示例 3:完美转发(Perfect Forwarding)处理泛型与重载

适用情况 :被委托的函数有复杂的参数(如右值引用、模板参数),需要保证转发时不丢失值类别(Value Category)。

核心机制 :使用模板 + std::forward。

cpp 复制代码
#include <utility>
#include <string>

class DataProcessor {
public:
    // 接受左值引用
    void process(const std::string& data) { /* 拷贝处理 */ }
    // 接受右值引用
    void process(std::string&& data) { /* 移动处理,更高效 */ }
};

class Task {
    DataProcessor processor_;

public:
    // 完美转发:无论传入左值还是右值,都能精确匹配 DataProcessor 的重载
    template <typename T>
    void handle_data(T&& data) {
        processor_.process(std::forward<T>(data)); 
    }
};

Stroustrup 视角的解读:

  • 零开销抽象 :这种手工转发在编译期完全展开,生成的机器码与直接调用 processor_.process() 毫无二致。
  • 表达力:用几行模板代码,替代了为每个重载版本手写转发函数的繁琐工作。

7.9.4 示例 4:工程化技巧:使用宏消除样板代码(Macro Boilerplate Reduction)

适用情况 :需要转发的接口极其庞大(如 COM 接口、大型 GUI 框架),手写转发会导致代码膨胀且难以维护。

核心机制:预处理器宏。

cpp 复制代码
// 定义一个通用的转发宏
// 注意:实际工程中需处理 noexcept 和返回类型,这里为演示简化
#define DELEGATE_METHOD(Class, Method, ...) \
    auto Method(__VA_ARGS__) -> decltype(std::declval<Class>().Method(std::forward<decltype(__VA_ARGS__)>(__VA_ARGS__))) { \
        return delegate_.Method(std::forward<decltype(__VA_ARGS__)>(__VA_ARGS__)); \
    }

class HugeInterface {
public:
    void actionA(int x) { /*...*/ }
    std::string actionB(const std::string& s) { return s; }
};

class Wrapper {
    HugeInterface delegate_;
public:
    // 一行代码完成转发,且保留了原始签名和返回值类型
    DELEGATE_METHOD(HugeInterface, actionA, int x)
    DELEGATE_METHOD(HugeInterface, actionB, const std::string& s)
};

Stroustrup 视角的解读:

  • 务实的妥协:Stroustrup 承认 C++ 缺乏某些语法糖。宏虽然"不优雅",但在系统级编程中,它是消除机械重复、防止手写转发出错(如漏掉一个参数)的有效工具。
  • 现代替代 :在 C++20 中,可以使用 Concepts 约束模板,或者使用代码生成工具(如 Python 脚本)在构建期生成转发代码,从而避免宏的副作用。

7.9.5 总结:手工转发的现代哲学

Stroustrup 在 1990 年代提出"手工转发"时,C++ 程序员面临着巨大的心智负担。但在今天,结合现代 C++ 的特性,手工转发已经成为一种设计模式而非"语法缺陷":

  1. 所有权显式化 :结合 unique_ptr / shared_ptr,解决了"委托对象何时销毁"的致命问题。
  2. 契约显式化 :结合 override,解决了"转发签名错误"的隐蔽 Bug。
  3. 性能显式化 :结合 noexcept 和完美转发,保证了零运行时开销。
  4. 逻辑显式化:在转发函数体内,你可以自由地加入缓存、锁、日志、参数校验等横切关注点(Cross-cutting concerns)。

最终结论 :在 C++ 中,不要寻找"委托(Delegate)"关键字,而要寻找"组合(Composition)+ 接口(Interface)+ 转发(Forwarding)"的组合拳。 这正是 Stroustrup 所期望的:用清晰的、可审计的工程实践,替代不可控的语言魔法。

8 重命名

本节记录了一个差点被加入 C++ 标准、但最终被否决的语言特性提案,以及围绕它所展开的关于"语言复杂性 vs. 表达力"、"显式 vs. 隐式"、"同义词危害"等深刻的设计哲学辩论。

这是理解 C++ 为何"克制"而非"膨胀"的绝佳案例。它展示了 C++ 标准化过程中理性、审慎、以工程实践为导向的决策文化。

  • 表面问题:多重继承中的同名虚函数冲突。
  • 诱人方案 :添加 virtual RetType newName() = Base::oldName; 语法。
  • 致命缺陷:鼓励同义词、增加语言复杂度、掩盖设计缺陷、维护成本高。
  • 正确解法:通过包装类间接层解决,或从根本上重构命名以避免冲突。
  • 永恒教训 :语言的简洁性和一致性,比个别场景下的便利性更重要。真正的优雅,来自于约束之下的创造力,而非无限自由的语法糖。

Stroustrup implicitly 呼应他反复强调的主题:C++ 不是为了取悦所有人而设计的,它是为了那些愿意深入思考、承担责任、并追求长期可维护性的专业工程师而存在的。拒绝"重命名",正是这种精神的体现。

8.1 问题起源:多重继承中的名称冲突

在 1989 年末至 1990 年初,随着多重继承(MI)在实际项目中的使用增多,一个棘手的问题浮出水面:

当两个基类拥有同名但语义完全不同的虚函数时,派生类如何清晰、无歧义地分别覆盖它们?

8.1.1📌 经典反例:LotterySimulation
cpp 复制代码
class Lottery {
public:
    virtual int draw(); // "抽奖" ------ 返回中奖号码
};

class GraphicalObject {
public:
    virtual void draw(); // "绘制" ------ 在屏幕上画图
};

class LotterySimulation : public Lottery, public GraphicalObject {
    // 问题来了:
    // 我们想同时覆盖 Lottery::draw() 和 GraphicalObject::draw()
    // 但它们的返回类型不同 (int vs void),语义也完全不同!
    // C++ 不允许仅靠返回类型区分重载,也无法用同一个名字 draw() 同时覆盖两者。
};
  • 困境 :程序员希望 LotterySimulation 能同时具备"抽奖"和"绘制"两种能力,并且能用各自基类的原始名称(draw)来调用对应的行为。但在当时的 C++ 中,这是不可能的------你只能选择覆盖其中一个,或者被迫改名,导致接口不一致。

8.2 提案方案:"重命名"语法扩展

为了解决这个问题,有人提出了一种看似优雅的语法扩展:允许在派生类中为继承来的虚函数指定一个新的本地名称。

8.2.1📌 提案语法示例
cpp 复制代码
class LotterySimulation : public Lottery, public GraphicalObject {
public:
    // 将 Lottery::draw 重命名为 l_draw,并覆盖它
    virtual int l_draw() = Lottery::draw;

    // 将 GraphicalObject::draw 重命名为 go_draw,并覆盖它
    virtual void go_draw() = GraphicalObject::draw;
};
  • 优点 :
    • 语义清晰:每个基类的行为都有独立的、无冲突的名字。
    • 实现简单:编译器只需在符号表中建立别名映射。
    • 自然扩展了纯虚函数的语法(= Base::func 看起来像是一种"绑定")。
  • 支持者:包括 Martin O'Riordan 和 Stroustrup 本人早期都曾认为这是一个合理的解决方案。该提案甚至被提交到了 1990 年 7 月的西雅图标准会议上,并获得了大多数与会者的初步赞同。

8.3 转折点:Beth Crockett 的"两周规则"与 Doug McIlroy 的洞察

就在提案即将通过之际,来自 Apple 的 Beth Crockett 行使了"两周规则"(Two-Week Rule)------即任何重大变更必须在会议前两周分发给所有成员审阅,否则推迟表决。这一程序性动作拯救了 C++ 免于一次可能的过度设计。

在重新审视期间,Doug McIlroy(Unix 管道之父、软件工程先驱)提出了一个颠覆性的观点:

"重命名的问题可以通过对每个类引进一个额外的类的方式解决,为每个需要覆盖的虚函数使用一个具有不同名称的函数,再加上一个前推函数。"

8.3.1📌 McIlroy 的替代方案:包装层 + 转发
cpp 复制代码
// 1. 为 Lottery 创建一个包装类,提供新名字
class LLottery : public Lottery {
public:
    virtual int l_draw() = 0;           // 新的纯虚接口
    int draw() { return l_draw(); }     // 原接口转发到新接口
};

// 2. 为 GraphicalObject 创建一个包装类
class GGraphicalObject : public GraphicalObject {
public:
    virtual void go_draw() = 0;         // 新的纯虚接口
    void draw() { go_draw(); }          // 原接口转发到新接口
};

// 3. 最终派生类从包装类继承
class LotterySimulation : public LLottery, public GGraphicalObject {
public:
    int l_draw() override { /* 实现抽奖逻辑 */ }
    void go_draw() override { /* 实现绘制逻辑 */ }
};
  • 核心思想 :不修改语言本身,而是通过增加一层间接抽象(Wrapper Class)来解决命名冲突。这是一种典型的"软件工程中,所有问题都可以通过增加一层间接层来解决"的实践。
  • 效果:完全达到了"重命名"提案的效果,但无需引入新的语言关键字或语法规则。

8.4 Stroustrup 的反思与最终决策

McIlroy 的方案让 Stroustrup 意识到:

  1. 需求并不普遍 :这种极端的名称冲突情况在实践中其实很少见。大多数 MI 场景是接口组合或 Mixin,基类的方法名通常是协调一致的(如都叫 update()、render() 等)。
  2. 语言扩充的成本过高:为一个低频场景引入新的语法特性,会增加语言的认知负担、编译器的复杂度、以及教学难度。对于新手来说,合并大的类层次结构本就不应是日常操作;而对于专家来说,手写几个包装类并非不可接受的负担。
  3. "重命名"本质上是维护噩梦 :Stroustrup 进一步指出,他真正反感的是"在维护代码时追寻别名"。如果一个变量在头文件里叫 g,在文档里叫 h,在代码里叫 k,那么调试和理解代码将变得极其困难。每一次重命名,实际上都是向用户和工具施加的一个必须理解的映射关系。
8.4.1📌 更深层的哲学:同义词的危害

Stroustrup 在此处提出了一个极具洞见的观点:

"同义词可能是有用的,但极少会是根本性的。无论如何,为了维护清晰性和代码在不同环境中使用的一致性,应当尽可能减少同义词的使用。多一个直接支持重命名的特征,就会进一步鼓励同义词的(错误)使用。"

  • 含义:语言不应该鼓励"一件事有多种说法"。虽然灵活性听起来很好,但它会导致代码风格碎片化、团队协作困难、静态分析工具失效。C++ 的设计哲学倾向于**"一种最佳实践"**,而不是"无数种可行方案"。
  • 类比 :这就像为什么 C++ 没有内置"属性"(Property)语法(如 C# 的 obj.Name),而是坚持用 getName()/setName()。虽然前者更简洁,但它引入了"字段访问"和"方法调用"的同义词混淆,破坏了"调用即可能有副作用"的明确语义。

8.5 历史影响与现代印证

这个被否决的"重命名"提案,成为了 C++ 标准化过程中的一个里程碑式案例,它确立了以下几条不成文的设计原则:

  1. "两周规则"的价值:强制冷却期可以避免群体思维下的冲动决策,让深思熟虑的声音(如 McIlroy)有机会浮现。
  2. 库解决方案优于语言扩展:如果一个问题可以通过现有的语言机制(如继承、组合、模板)以稍显繁琐但清晰的方式解决,就不要轻易修改语言核心。这就是后来 Boost 库和现代 C++ 标准库蓬勃发展的哲学基础。
  3. 反对"语法糖滥用":C++ 对新增关键字和语法结构保持极度谨慎。每一个新特性都必须证明其带来的表达力提升远大于其引入的复杂性和兼容性成本。
8.5.1 📌 现代 C++ 如何应对类似问题?

今天,如果你遇到 Lottery::draw() 和 GraphicalObject::draw() 的冲突,你会怎么做?

场景 推荐方案 理由
新项目设计 策略一:语义明确化重构 零开销,代码最清晰,符合 C++ 哲学
集成遗留/第三方库 策略二:私有继承 + 公共接口 零运行时开销,隔离污染,编译器可优化
需要运行时切换行为 策略三:std::function 注入 灵活性最高,开销可控(避免热路径)
极致性能 + 编译期确定 策略四:CRTP 静态多态 零运行时开销,全内联,适合库开发
团队强制命名规范 策略五:C++20 Concepts 编译期拦截错误,提升代码一致性
快速原型 / 脚本式逻辑 策略三:std::function 开发速度优先,性能次要
8.5.1.1 方案 1:根本性重构 ------ 语义明确化(最佳实践)

这是最彻底、零开销、最符合 C++ 哲学的解决方案。如果两个基类的同名函数语义不同,那么它们就不应该叫同一个名字。

  • 原则:名称应反映意图(Intent),而非实现细节。
  • 做法:修改基类接口,使用动词+名词的精确命名。
cpp 复制代码
// ❌ 糟糕的设计:语义模糊的同名函数
class Lottery {
public:
    virtual int draw(); // 抽奖?画图?歧义!
};

class GraphicalObject {
public:
    virtual void draw(); // 绘制图形
};

// ✅ 优秀的设计:语义明确,天然无冲突
class Lottery {
public:
    virtual int pickWinningNumber() = 0; // 明确:抽取中奖号码
};

class Renderable {
public:
    virtual void renderOnScreen() = 0;   // 明确:在屏幕上渲染
};

class LotterySimulation : public Lottery, public Renderable {
public:
    int pickWinningNumber() override { return 42; }
    void renderOnScreen() override { /* 绘制彩票界面 */ }
};
  • 优点:零运行时开销,代码自文档化,编译器优化友好,完全消除歧义。
  • 适用场景:你拥有基类的源码控制权,或正在设计新系统。
  • 哲学依据 :这正是 Stroustrup 所倡导的------与其让语言提供"重命名"拐杖,不如促使程序员做出更好的抽象设计。
8.5.1.2 方案 2:接口隔离 + 私有继承(适配遗留代码)【McIlroy 风格的包装层】

当你无法修改第三方库或遗留基类时(如 Lottery::draw() 和 GraphicalObject::draw() 已固化),可以使用私有继承 + 公共纯接口的模式,将冲突隔离在实现层。

cpp 复制代码
// 不可修改的遗留基类
class LegacyLottery { public: virtual int draw() { return 7; } };
class LegacyRenderer { public: virtual void draw() { /* ... */ } };

// 1. 定义语义明确的公共接口
struct ITicketService {
    virtual ~ITicketService() = default;
    virtual int generateTicket() = 0;
};

struct IVisualComponent {
    virtual ~IVisualComponent() = default;
    virtual void paint() = 0;
};

// 2. 适配器类:私有继承遗留类,公共实现新接口
class LotterySimulation : public ITicketService, 
                          public IVisualComponent,
                          private LegacyLottery,    // 私有继承:不暴露 draw()
                          private LegacyRenderer {  // 私有继承:不暴露 draw()
public:
    // 显式转发,语义清晰
    int generateTicket() override { 
        return LegacyLottery::draw(); // 明确调用哪个 draw
    }
    
    void paint() override { 
        LegacyRenderer::draw();       // 明确调用哪个 draw
    }
};
  • 优点 :对外暴露的接口干净无冲突;对内通过作用域解析符 Base::func() 精确控制调用;无需堆分配。
  • 缺点:需要编写少量转发代码(可用宏简化)。
  • 关键点 :私有继承确保了 LegacyLottery::draw() 不会污染 LotterySimulation 的公共接口,外部用户只能通过 generateTicket() 和 paint() 访问,彻底消除歧义。

McIlroy 风格:

cpp 复制代码
struct ILotteryDraw {
    virtual ~ILotteryDraw() = default;
    virtual int performDraw() = 0; // 语义明确的新名字
};

struct IGraphicalDraw {
    virtual ~IGraphicalDraw() = default;
    virtual void render() = 0;     // 语义明确的新名字
};

class LotterySimulation : public ILotteryDraw, public IGraphicalDraw,
                          private Lottery, private GraphicalObject {
public:
    int performDraw() override { return Lottery::draw(); }
    void render() override { GraphicalObject::draw(); }
};
8.5.1.3 方案 3:使用 std::function / 策略模式(细粒度委托)

当冲突仅涉及个别函数,且你需要运行时动态切换行为时,使用 std::function 将"函数"作为数据成员持有,而非通过继承获得。

cpp 复制代码
#include <functional>
#include <iostream>

class FlexibleSimulation {
    // 将冲突的行为抽象为可调用对象
    std::function<int()> ticket_generator_;
    std::function<void()> screen_painter_;

public:
    // 构造时注入具体实现
    FlexibleSimulation(
        std::function<int()> gen, 
        std::function<void()> paint)
        : ticket_generator_(std::move(gen))
        , screen_painter_(std::move(paint)) {}

    void run() {
        int num = ticket_generator_(); // 调用"抽奖"逻辑
        std::cout << "Ticket: " << num << "\n";
        
        screen_painter_();             // 调用"绘制"逻辑
    }
};

// 使用示例:绑定到不同的遗留系统
LegacyLottery lotto;
LegacyRenderer renderer;

FlexibleSimulation sim(
    [&lotto]() { return lotto.draw(); },      // Lambda 捕获并转发
    [&renderer]() { renderer.draw(); }
);
  • 优点:完全解耦,支持运行时热插拔,Lambda 可捕获上下文解决反向调用问题。
  • 缺点 :std::function 有微小的类型擦除开销(见第二部分性能分析)。
  • 适用场景:插件系统、测试 Mock、需要频繁切换策略的场景。
8.5.1.4 方案 4:CRTP 静态多态(编译期消歧)

如果冲突可以在编译期确定,且你追求零运行时开销,可以使用奇异递归模板模式(CRTP)将"重命名"转化为编译期的类型区分。

cpp 复制代码
template <typename Derived>
class TicketMixin {
public:
    int generateTicket() {
        // 编译期绑定到派生类的具体实现
        return static_cast<Derived*>(this)->doGenerateTicket();
    }
};

template <typename Derived>
class RenderMixin {
public:
    void paint() {
        static_cast<Derived*>(this)->doPaint();
    }
};

class LotterySimulation : public TicketMixin<LotterySimulation>,
                          public RenderMixin<LotterySimulation> {
    friend class TicketMixin<LotterySimulation>;
    friend class RenderMixin<LotterySimulation>;

    // 私有实现方法,名字可以任意取,无冲突风险
    int doGenerateTicket() { return 42; }
    void doPaint() { /* 绘制逻辑 */ }

public:
    void run() {
        generateTicket(); // 来自 TicketMixin
        paint();          // 来自 RenderMixin
    }
};
  • 优点:零运行时开销,所有调用可被内联,无虚函数表查找。
  • 缺点:不是真正的运行时动态,模板膨胀可能增加二进制体积。
  • 适用场景:高性能库、嵌入式系统、Mixin 风格的代码复用。
8.5.1.5 方案 5:概念约束(C++20 Concepts)

在 C++20 中,你可以使用 Concepts 在编译期强制要求接口名称的语义一致性,从而在设计阶段就阻止冲突的发生。

cpp 复制代码
template <typename T>
concept TicketProvider = requires(T t) {
    { t.pickWinningNumber() } -> std::convertible_to<int>;
};

template <typename T>
concept Renderable = requires(T t) {
    { t.renderOnScreen() } -> std::same_as<void>;
};

// 编译器会在实例化时检查名称是否匹配预期语义
template <TicketProvider TP, Renderable R>
class SimulationEngine {
    TP ticket_provider_;
    R renderer_;
public:
    void execute() {
        int n = ticket_provider_.pickWinningNumber(); // 名称强制明确
        renderer_.renderOnScreen();                   // 名称强制明确
    }
};
  • 优点:编译期错误信息更友好,强制团队遵循命名规范。
  • 缺点:需要 C++20 支持,学习曲线较陡。

9 基类和成员初始化

本节记录了 C++ 在引入多重继承后,对构造函数初始化列表语法的一次关键性修正。它看似是一个微小的语法调整,实则解决了语义歧义、教学困难和分布式系统可靠性等深层问题。

这是理解 C++ 如何从"能工作"进化到"清晰、可预测、可教授"的绝佳案例。

本节展示了 C++ 如何通过一次看似微小的语法修正,解决了语义歧义、教学困难、跨平台可靠性和分布式系统兼容性等多重挑战。

  • 表面问题:多重继承下初始化语法模糊、顺序未定义。
  • 核心修正 :
    1. 引入显式的 : Base(args), member(val) 初始化列表语法。
    2. 强制规定初始化顺序由声明顺序决定,而非书写顺序。
    3. 废弃隐式的"构造函数体第一行作为基类参数"的旧语法。
  • 深层价值 :
    1. 可预测性:确保所有编译器生成一致的对象布局和行为。
    2. 可教学性:消除隐藏规则,降低学习曲线。
    3. 可扩展性:为分布式系统、对象持久化、RPC 奠定坚实基础。
  • 永恒教训 :语言的清晰度不是奢侈品,而是工业级软件的必需品。一个看似微不足道的语法歧义,可能在大规模系统中引发灾难性的后果。C++ 的成功,很大程度上源于它对这类"小问题"的认真对待和系统性解决。

Stroustrup implicitly 呼应了他反复强调的主题:C++ 的演化不是随意的功能堆砌,而是对实际工程痛点的精准回应。从模糊的隐式规则到清晰的显式语法,从实现者的自由到用户的确定性------这正是 C++ 从一门"黑客语言"成长为"工业基石"的关键一步。

9.1 问题背景:多重继承下的初始化顺序与语法歧义

在 C++ 早期(pre-2.0),当引入多重继承时,构造函数的初始化列表语法存在两个严重缺陷:

9.1.1❌ 缺陷 1:初始化顺序未明确定义
  • 旧规则:标准没有规定基类和成员变量的初始化顺序。编译器可以按任意顺序执行。
  • 后果:如果成员变量的初始化依赖于基类的状态(或反之),程序行为将变得不可预测,且在不同编译器间不一致。
  • Stroustrup 的反思:"在 C++ 的原来定义中,将初始化顺序放着不定义,实际上是在损害了用户的情况下为语言实现者留下不必要的自由度。" 这是一种典型的"实现便利优先于用户安全"的设计失误。
9.1.2❌ 缺陷 2:基类初始化语法模糊且易混淆
  • 旧语法示例 :

    cpp 复制代码
    class X : public A, public B {
        int xx;
        X(int a, int b)
            : A(a),      // 初始化基类 A
              B(b),      // 初始化基类 B
              xx(1)      // 初始化成员 xx
        { }
    };
  • 问题所在 :这种语法 A(a) 看起来像是在调用一个名为 A 的函数,或者创建一个临时对象。对于新手来说,很难直观理解这表示"调用基类 A 的构造函数"。更糟糕的是,它与普通对象的初始化语法 A x(1); 完全平行,导致了概念上的混淆。

9.1.3 缺陷 3:派生类无法显式指定基类构造参数(旧式语法的局限)

书中举了一个经典的混乱案例:

cpp 复制代码
class vector {
public:
    vector(int);
};

class vec : public vector {
public:
    vec(int, int);
};

// 旧式写法(C++ 2.0 之前)
vec::vec(int low, int high)
{
    (high - low - 1)  // argument for base class constructor ???
    // ...
}
  • 混乱点 :在这种旧语法中,派生类构造函数体内第一行的表达式 (high-low-1) 被隐式地当作传递给基类 vector 构造函数的参数。
  • 为什么糟糕 :
    1. 极度不直观:读者必须知道"构造函数体第一行的表达式是基类参数"这条隐藏规则才能读懂代码。
    2. 无法处理多基类:如果有多个基类,你如何区分哪个表达式对应哪个基类?
    3. 容易出错:如果构造函数体第一行是一个复杂的表达式或有副作用的函数调用,调试极其困难。
    4. 教学噩梦:Stroustrup 坦言,"一些年来这种记法引起了许多混乱"。

9.2 解决方案:C++ 2.0 的显式初始化列表语法

为了解决上述问题,C++ 2.0 引入了我们现在熟知的冒号初始化列表语法,并强制规定了初始化顺序。

9.2.1 ✅ 新语法规范
cpp 复制代码
class X : public A, public B {
    int xx;
public:
    X(int a, int b)
        : A(a),      // 显式调用基类 A 的构造函数
          B(b),      // 显式调用基类 B 的构造函数
          xx(1)      // 显式初始化成员 xx
    { 
        // 构造函数体
    }
};
9.2.2 ✅ 明确的初始化顺序规则

C++ 标准最终确立了以下不可更改的初始化顺序:

  1. 虚基类(按深度优先、从左到右的声明顺序)。
  2. 非虚基类 (按它们在类定义中声明的顺序,而非初始化列表中出现的顺序)。
  3. 非静态数据成员 (按它们在类定义中声明的顺序,而非初始化列表中出现的顺序)。
  4. 构造函数体执行。

关键洞察 :初始化列表中书写的顺序不影响实际执行顺序!实际顺序完全由类定义中的声明顺序决定。这一规则消除了编译器实现的自由度,确保了跨平台的一致性。

9.2.3 ✅ 派生类显式调用基类构造函数的新写法

针对前面的 vec 例子,新语法变为:

cpp 复制代码
vec::vec(int low, int high) 
    : vector(high - low - 1)  // 显式指明:调用基类 vector 的构造函数,参数为 (high-low-1)
{
    // 构造函数体
}
  • 优点 :
    1. 语义清晰 :vector(...) 明确表示"调用名为 vector 的基类的构造函数"。
    2. 支持多基类:可以为每个基类单独指定参数,无歧义。
    3. 易于阅读和维护:无需记忆"第一行表达式是基类参数"的隐藏规则。
    4. 教学友好:Stroustrup 指出,"采用了新语法之后,在教授初始化时遇到的问题就完全消失了。"

9.3 深层动机:分布式系统与编译程序的可靠性

Stroustrup 在此处揭示了一个常被忽视的深层原因:初始化顺序的确定性对于分布式系统和独立编译至关重要。

"接收方所进行的对象重构必须正好按传送方分解对象的相反顺序进行。除非语言明确规定构造的顺序,否则就无法保证不同提供商的编译程序编译出来的程序之间能进行对象通信。"

  • 场景:假设你在机器 A 上用编译器 X 序列化一个对象,通过网络发送到机器 B,用编译器 Y 反序列化。
  • 风险 :如果初始化顺序未定义,编译器 X 可能按 A, B, xx 顺序构造,而编译器 Y 按 xx, B, A 顺序解构。这将导致对象状态损坏,甚至安全漏洞。
  • 解决:通过标准化初始化顺序,C++ 确保了对象布局和行为的可预测性,使得跨编译器、跨平台的对象持久化和 RPC(远程过程调用)成为可能。
  • 历史注脚:Stroustrup 特别感谢 Keith Gorlen(NIH 库的作者)指出了这个问题。这表明 C++ 的演进深受实际大型项目(如科学计算、分布式系统)需求的驱动。

9.4 过渡期策略:向后兼容与废弃警告

为了平滑迁移,C++ 2.0 采取了务实的过渡策略:

"老风格的基类初始化还将保留一段时间,作为一个转变期。它只能在单继承的情况下使用,因为在其他情况中是有歧义的。"

  • 限制范围 :旧式语法(构造函数体第一行作为基类参数)仅允许在单继承 且无歧义的场景下继续使用。
  • 逐步淘汰:编译器会对旧式语法发出警告,鼓励开发者迁移到新语法。最终,该特性在后续标准中被彻底移除。
  • 设计哲学体现:这展示了 C++ 委员会在"打破兼容性"问题上的谨慎态度------即使旧设计有明显缺陷,也会提供足够的过渡期,避免破坏现有代码库。

9.5 现代 C++ 的印证与最佳实践

今天,我们在 C++11/14/17/20 中使用的初始化列表语法,正是该节所确立的规范的直接延续。不仅如此,现代 C++ 还在此基础上增加了更多保障:

9.5.1. 统一初始化语法(C++11 {})
cpp 复制代码
class X : public A, public B {
    int xx;
    std::string name;
public:
    X(int a, int b, std::string n)
        : A{a},           // 使用花括号,防止窄化转换
          B{b},
          xx{1},
          name{std::move(n)}
    { }
};
  • 优势 :{} 语法禁止窄化转换(如 double 到 int),比 () 更安全。
9.5.2. 成员就地初始化(C++11)
cpp 复制代码
class X : public A, public B {
    int xx = 1;           // 默认初始化,可在构造函数列表中覆盖
    std::string name = "default";
public:
    X(int a, int b)
        : A(a), B(b)      // xx 和 name 使用默认值,除非在列表中显式覆盖
    { }
    
    X(int a, int b, int custom_xx)
        : A(a), B(b), xx(custom_xx)  // 显式覆盖 xx
    { }
};
  • 优势:减少构造函数列表的冗余,确保成员总有合理默认值。
9.5.3. 委托构造函数(C++11)
cpp 复制代码
class X : public A, public B {
    int xx;
public:
    X(int a, int b) : A(a), B(b), xx(1) { /* 主构造函数 */ }
    
    X() : X(0, 0) { }  // 委托给另一个构造函数,避免重复初始化逻辑
};
  • 优势:进一步减少样板代码,确保所有构造路径遵循相同的初始化逻辑。
9.4. 编译器警告与静态分析

现代编译器(GCC, Clang, MSVC)会在以下情况发出警告:

  • 初始化列表中的顺序与声明顺序不一致(-Wreorder)。
  • 成员未在初始化列表中初始化(-Weffc++, -Wuninitialized)。
  • 使用已废弃的旧式基类初始化语法。

这些工具链的支持,正是本节所倡导的"明确性"原则在工程实践中的落地。

相关推荐
旖旎夜光1 小时前
【C++ 基础入门】命名空间、输入输出、缺省参数与函数重载
开发语言·c++·笔记·学习
扶尔魔ocy2 小时前
【QT入门到晋级】用开源的自定义控件开发
c++·qt·自定义控件
akinggw3 小时前
libsherpacore - 基于 sherpa-onnx 的 C++ 语音交互静态库
c++·交互·语音识别
xiaoye-duck4 小时前
《C++ 项目:手写大模型接入 SDK》ChatGPT接口设计(中):全量响应实现和测试
网络·c++·语言模型·chatgpt
東隅已逝,桑榆非晚4 小时前
算法3.双指针法-快乐数
c++·笔记·学习·算法
eggwyw5 小时前
C++相交链表和反转链表详解
开发语言·c++
杜 硕5 小时前
从零开始的C++生活——第六讲——02
c++·算法·生活
小超人诞生日记5 小时前
C++ STL 反向迭代器:从源码框架到适配器实现
c++·算法
青少儿编程课堂5 小时前
蓝桥杯青少组图形化 + 全国青少年信息素养大赛 + 电子学会Scratch考级:校园成语接龙大挑战解析
c++·python·算法·bfs·信息学竞赛