C++设计原理———重载(extern “C“的由来、顺序依赖、语义依赖、overload、名称修饰符、对象操作、运算符重载、新增运算符、枚举和布尔类型)

文章目录

  • [C++设计原理---------重载(extern "C"的由来、顺序依赖、语义依赖、overload、名称修饰符、对象操作、运算符重载、新增运算符、枚举和布尔类型)](#C++设计原理———重载(extern “C“的由来、顺序依赖、语义依赖、overload、名称修饰符、对象操作、运算符重载、新增运算符、枚举和布尔类型))
    • [1 背景](#1 背景)
    • [2 引入原因](#2 引入原因)
    • [3 早期引入函数名和运算符重载后的改进](#3 早期引入函数名和运算符重载后的改进)
      • [3.1 核心背景与目标](#3.1 核心背景与目标)
      • [3.2 细粒度解析的具体改进](#3.2 细粒度解析的具体改进)
        • [3.2.1 基础类型的区分 (`char`, `int`, `float`)](#3.2.1 基础类型的区分 (char, int, float))
        • [3.2.2 `const` 修饰符的区分](#3.2.2 const 修饰符的区分)
        • [3.2.3 类层级结构中的匹配 (继承体系)](#3.2.3 类层级结构中的匹配 (继承体系))
      • [3.3 歧义控制:从"顺序依赖"到"语义依赖"](#3.3 歧义控制:从“顺序依赖”到“语义依赖”)
        • [3.3.1 旧规则的缺陷(顺序依赖)](#3.3.1 旧规则的缺陷(顺序依赖))
        • [3.3.2 新规则的诞生(最佳匹配)](#3.3.2 新规则的诞生(最佳匹配))
        • [3.3 多参数函数的"交叉规则"](#3.3 多参数函数的“交叉规则”)
      • [3.4 空指针](#3.4 空指针)
        • [3.4.1. 核心矛盾:概念 vs. 实现](#3.4.1. 核心矛盾:概念 vs. 实现)
        • [3.4.2. 为什么不用 `(void*)0`?](#3.4.2. 为什么不用 (void*)0?)
        • [3.4.3. 使用 `0` 的代价:重载解析的灾难](#3.4.3. 使用 0 的代价:重载解析的灾难)
        • [3.4.4. Stroustrup 的辩护与妥协](#3.4.4. Stroustrup 的辩护与妥协)
        • [3.4.5 总结与后续演进](#3.4.5 总结与后续演进)
      • [3.5 关键字overload的历史](#3.5 关键字overload的历史)
        • [3.5.1. 早期的 `overload` 关键字(C++ 1.0 时代)](#3.5.1. 早期的 overload 关键字(C++ 1.0 时代))
        • [3.5.2. 为什么这些担心是多余的?](#3.5.2. 为什么这些担心是多余的?)
        • [3.5. 3. `overload` 带来的真正灾难:模块化噩梦](#3.5. 3. overload 带来的真正灾难:模块化噩梦)
        • [3.5.4. 最终结论:废除 `overload`](#3.5.4. 最终结论:废除 overload)
        • [3.5.5 '关闭'重载的关键字extern "C"](#3.5.5 ‘关闭’重载的关键字extern "C")
        • [3.5.5.1 为什么它能"关闭"重载?](#3.5.5.1 为什么它能“关闭”重载?)
        • [3.5.5.2 一个常见的"坑":类型不匹配的二义性](#3.5.5.2 一个常见的“坑”:类型不匹配的二义性)
        • [3.5.5.3 最佳实践:如何安全地"混合"](#3.5.5.3 最佳实践:如何安全地“混合”)
        • [3.5.5.4 在C模块中恢复 C++ 的重载](#3.5.5.4 在C模块中恢复 C++ 的重载)
        • [3.5.5.5 总结](#3.5.5.5 总结)
      • [3.6 名称修饰和extern "C"的由来](#3.6 名称修饰和extern "C"的由来)
        • [3.6.1. C 语言连接的"不安全"本质](#3.6.1. C 语言连接的“不安全”本质)
        • [3.6. 2. 早期尝试:简单的名称映射(失败)](#3.6. 2. 早期尝试:简单的名称映射(失败))
        • [3.6. 3. 终极方案:名称修饰](#3.6. 3. 终极方案:名称修饰)
        • [3.6.4. 引入 `extern "C"`:为了兼容](#3.6.4. 引入 extern "C":为了兼容)
        • [3.6.5. 设计哲学的权衡](#3.6.5. 设计哲学的权衡)
          • [3.6.5.1 为什么不对 C 连接也做类型检查?](#3.6.5.1 为什么不对 C 连接也做类型检查?)
          • [3.6.5.2 函数指针的困境](#3.6.5.2 函数指针的困境)
        • [3.6.6 总结](#3.6.6 总结)
    • [4 对象的操作(建立和复制)](#4 对象的操作(建立和复制))
      • [4.1. 禁止操作的"私有化"技巧](#4.1. 禁止操作的“私有化”技巧)
      • [4.2. 控制内存分配](#4.2. 控制内存分配)
      • [4.3. 禁止派生](#4.3. 禁止派生)
      • [4.4. 按成员复制 vs 按位复制](#4.4. 按成员复制 vs 按位复制)
      • [4.5. "切片"问题与浅复制的隐患](#4.5. “切片”问题与浅复制的隐患)
    • [5 运算符-> 、.与++ 的重载](#5 运算符-> 、.与++ 的重载)
      • [5.1. 灵巧指针与 `operator->` 的诞生](#5.1. 灵巧指针与 operator-> 的诞生)
      • [5.2. "灵巧引用"与 `operator.` 的遗憾](#5.2. “灵巧引用”与 operator. 的遗憾)
      • [5.3. `++` 前后缀的"古怪"区分法](#5.3. ++ 前后缀的“古怪”区分法)
      • [5.4. 其他运算符的取舍](#5.4. 其他运算符的取舍)
      • [5.5 总结](#5.5 总结)
    • [6 给 C++ 增加运算符](#6 给 C++ 增加运算符)
      • [6.1. 为什么 C++ 没有指数运算符(`**`)?](#6.1. 为什么 C++ 没有指数运算符(**)?)
      • [6.2. 用户自定义运算符:诱人的陷阱](#6.2. 用户自定义运算符:诱人的陷阱)
      • [6.3. 复合运算符的设想](#6.3. 复合运算符的设想)
      • [6.4 总结](#6.4 总结)
    • [7 枚举与布尔类型](#7 枚举与布尔类型)
      • [7.1. C 语言枚举的"半生不熟"](#7.1. C 语言枚举的“半生不熟”)
      • [7.2. ANSI C 带来的"指针危机"](#7.2. ANSI C 带来的“指针危机”)
      • [7.3. C++ 的解决方案:更严格的枚举](#7.3. C++ 的解决方案:更严格的枚举)
      • [7.4. 布尔类型(`bool`)的"战国时代"](#7.4. 布尔类型(bool)的“战国时代”)
      • [7.5. 最终的妥协:特殊的整数类型](#7.5. 最终的妥协:特殊的整数类型)
      • [7.5.6 总结](#7.5.6 总结)

C++设计原理---------重载(extern "C"的由来、顺序依赖、语义依赖、overload、名称修饰符、对象操作、运算符重载、新增运算符、枚举和布尔类型)

本文探讨了C++运算符与类型系统的设计权衡。Stroustrup解释了拒绝引入指数运算符(**)及用户自定义运算符的原因,旨在避免语法歧义、优先级混乱及破坏C语言兼容性,坚持显式优于隐式。在枚举类型上,C++从继承C的弱类型转向独立强类型,禁止整数隐式转换以支持函数重载。针对布尔类型,为解决社区定义混乱并兼顾海量旧代码兼容,最终将其设计为支持整型双向隐式转换的特殊内置类型。这些决策体现了C++在类型安全、功能扩展与向后兼容之间的务实平衡。

1 背景

阐述了 C++ 如何从 C 语言的"简单映射"进化为支持运算符重载和函数重载的现代语言。

这不仅仅是语法的增加,更是 C++ 抽象能力的质变------它让程序员能够定义自己的类型,并让它们像内置类型(如 int)一样自然地工作。

2 引入原因

在 C 语言中,如果你定义了一个复数结构体 Complex,你想做加法,必须写 c_add(a, b);想打印,必须写 c_print(a)

Bjarne 认为这是不可接受的。如果 C++ 要支持面向对象和抽象数据类型,那么用户定义的类型(类)应该享有和内置类型(int, double)同等的待遇。

  • 目标 :让用户能够写出 c3 = c1 + c2; 而不是 c3 = add(c1, c2);
  • 意义:这使得代码更具可读性,并且使得编写通用的数学库、容器库成为可能。

引入重载后,增加了表达方式的灵活和自由(简单性),但是和安全性、可预见性是相互冲突的。

例如,编译器面临的挑战是:当调用 f(1) 时,到底该调用 f(int) 还是 f(double)

3 早期引入函数名和运算符重载后的改进

本小节讲述了 C++ 如何从一个简单的、基于 C 语言类型提升规则的粗糙重载系统,进化为一个强类型、高精度、支持多态层次的现代重载解析系统。

关键的演变点在于:

  1. 拒绝默认提升: 尊重 charfloat 的独立身份。
  2. 引入 Const 正确性:const 成为重载决议的一部分,提高了安全性。
  3. 遵循继承逻辑: 在类层次结构中,优先匹配最具体的类型,而不是盲目回退到通用指针。

C++ 重载解析之所以如此复杂的历史原因:它是在保持 C 语言兼容性、支持强大的面向对象特性(用户自定义转换)以及消除早期设计的顺序依赖性这三者之间进行艰难平衡的产物。最终形成的"最佳匹配"(从"基于声明顺序")和"交叉规则"(为了解决多参数函数匹配)虽然难懂,但保证了代码行为的可预测性(不再依赖声明顺序)。

具体而言,这种复杂性主要体现在以下三个维度:

  • 兼容性的代价: 这些复杂的规则很大程度上是为了兼容 C 语言的习惯(比如允许 intchar 混用)。如果完全从零设计,规则会简单得多(例如禁止所有隐式转换),但这会赶走 C 程序员,并不会发展成如今受众那么的语言。
  • 复杂度的必然性: 为了让用户自定义类型(类)能像内置类型一样自然地进行运算(例如 complex 类的加减乘除),必须引入复杂的转换序列。
  • Andrew Koenig 的贡献: 特别提到了 Andrew Koenig 在制定这些"交叉规则"时的关键作用,是他发现并形式化了那些极端的边界情况。

3.1 核心背景与目标

  • 问题起源: C++ 引入了函数名和运算符重载,但这带来了复杂性。早期的规则比较粗糙,导致编译器经常无法区分"太类似"的类型,或者产生难以预料的隐式转换。
  • 改进目标:
    • 细粒度解析: 能够区分更细微的类型差异(如 int vs charfloat vs doubleconst vs 非 const)。
    • 顺序无关性: 重载解析的结果不应依赖于函数声明的顺序。
    • 捕获错误: 新的模式不仅要能解析合法的调用,还要能捕捉到更多的歧义性错误。

3.2 细粒度解析的具体改进

3.2.1 基础类型的区分 (char, int, float)
  • 早期限制: 早期 C++ 继承了 C 语言的类型提升规则。
    • float 参数会被提升为 double
    • char 参数会被提升为 int
    • 后果: 无法为 charfloat 单独编写重载函数。例如,想写一个专门处理字符的输出函数,必须用不同的名字(如 put(char)),否则 cout << 'X' 会输出数字 88(ASCII码)而不是字符 'X'。
  • 改进方案: 修改了类型规则,允许针对未提升形式(如直接的 charfloat)进行重载解析。
    • 字面量类型变更: 在 C++ 中,字符字面量 'a' 的类型被定义为 char(而在 C 中是 int)。这是一个为了兼容性和类型安全做出的重大改变。
    • 效果: 现在可以定义 void f(char)void f(int),编译器能精确匹配。
3.2.2 const 修饰符的区分
  • 改进点: 利用 const 和非 const 之间的差异来进行重载。
  • 经典案例 (strtok):
    • ANSI C 标准库中的 strtok 返回 char*,即使传入的是 const char*,这会导致类型系统的不安全(可能通过返回值修改常量字符串)。
    • C++ 解决方案: 提供两个版本的重载:
      1. char* strtok(char*, const char*); (处理非 const 字符串,返回可修改指针)
      2. const char* strtok(const char*, const char*); (处理 const 字符串,返回只读指针)
    • 这既保持了与 C 的兼容性,又增强了类型安全性。

ANSI C标准的老版本:

cpp 复制代码
char* strtok(const char*, const char*);

基于const重载的新版本:

cpp 复制代码
char* strtok(char*, const char*);
const char* strtok(const char*, const char*);
3.2.3 类层级结构中的匹配 (继承体系)
  • 问题: 当存在类的继承关系时(如 B -> BB -> BBB),如果有一个接受基类指针 void f(B*) 和一个接受更通用指针 void f(void*) 的函数,应该匹配哪一个?
  • 规则确立: "向最后的派生类转换"优先。
    • 如果有 f(BBB*),传入 BBB* 对象时,优先匹配 f(BBB*) 而不是 f(BB*)f(B*)
    • 只有在没有更匹配的派生类版本时,才会退而求其次选择基类版本,最后才是 void*
  • 意义:
    • 这条规则与虚函数的调用规则完全匹配(总是调用最具体派生类的函数)。
    • 确立了 void* 作为类转换树的"根"(兜底选项)。
    • 消除了一个长期的错误根源,使得重载行为更符合面向对象的设计直觉。

3.3 歧义控制:从"顺序依赖"到"语义依赖"

本节揭示了 C++ 重载解析之所以如此复杂的历史原因:它是在保持 C 语言兼容性、支持强大的面向对象特性(用户自定义转换)以及消除早期设计的顺序依赖性这三者之间进行艰难平衡的产物。最终形成的"最佳匹配"和"交叉规则"虽然难懂,但保证了代码行为的可预测性(不再依赖声明顺序)。

3.3.1 旧规则的缺陷(顺序依赖)
  • 早期机制: 在 C++ 2.0 之前,重载解析非常简单粗暴------谁先声明,谁就赢。编译器按顺序尝试函数声明,第一个能匹配的(且不需要缩窄转换)就被选中。
  • 带来的问题:
    • 代码脆弱: 仅仅改变头文件中函数的声明顺序,就会完全改变程序的运行结果。
    • 维护噩梦: 这使得库的设计变得非常困难,因为用户无法控制库内部函数的声明顺序。
    • 示例分析: 书中展示了 print(int)print(double) 的例子。如果 print(int) 先声明,print(2.0) 可能会因为 double->int 是缩窄转换而被拒绝,从而选中后面的 print(double);但如果顺序反过来,或者参数类型微调,结果就会大相径庭。

示例如下:

cpp 复制代码
overload void print(int); // original (pre 2.0) rules:
void print(double);

void g()
{
    print(2.0);    // print(double): print(2.0)
                   // double->int conversion not accepted.
    print(2.0F);   // print(double): print(double(2.0F))
                   // float->int conversion not accepted
                   // float->double conversion accepted.
    print(2);      // print(int): print(2)
}
cpp 复制代码
overload void print(double); // original rules:
void print(int);

void g()
{
    print(2.0);    // print(double): print(2.0)
    print(2.0F);   // print(double): print(double(2.0F))
                   // float->double conversion accepted.
    print(2);      // print(double): print(double(2))
                   // int->double conversion accepted.
}
3.3.2 新规则的诞生(最佳匹配)
  • 设计目标: Stroustrup 意识到必须消除这种顺序依赖性。新的目标是:精确匹配 > 安全转换(提升) > 不安全转换(缩窄)
  • 权衡: 这是一个巨大的工程。为了保持与 C 语言的兼容性(C 允许很多隐式转换),同时也为了支持用户自定义类型(类),C++ 不能简单地禁止隐式转换,只能设计一套复杂的优先级系统。

为了量化"哪个匹配更好",C++ 2.0 引入了一个分层的匹配模型( 5 级匹配优先级,这也是现代 C++ 重载解析的基础):

  1. 精确匹配: 类型完全一致,或仅涉及数组名到指针、函数名到指针、Tconst T 等不可避免的转换。这是最高优先级。
  2. 提升匹配: 涉及 ANSI C 标准定义的提升,如 char -> intfloat -> double。这被认为是"安全"的。
  3. 标准转换匹配:int -> double,派生类指针 -> 基类指针,unsigned int -> int
  4. 用户定义转换: 调用构造函数或类型转换运算符。
  5. 省略号匹配: 匹配 ...(如 printf 风格),优先级最低。

核心原则: 编译器总是试图在列表中从上往下找"最好"的匹配。

3.3 多参数函数的"交叉规则"

当函数有多个参数时,问题变得复杂了。如果函数 A 在第一个参数上匹配得更好,但函数 B 在第二个参数上匹配得更好,该怎么办?

使用 ARM(Annotated Reference Manual)中的定义:

"对涉及多于一个参数的调用,一个函数要想被选中,就要求它至少在某个参数上比其他任何函数都匹配得更好 ,而对每个参数都至少与其他函数匹配得同样好。"

示例解析:

cpp 复制代码
class complex {
    // ...
    complex(double);
};

void f(int,double);
void f(double,int);
void f(complex,int);
void f(int ...);
void f(complex ...);

void g(complex z)
{
    f(1,2.0);    // f(int,double)
    f(1.0,2);    // f(double,int)
    f(z,1.2);    // f(complex,int)
    f(z,1,3);    // f(complex ...)
    f(2.0,z);    // f(int ...)
    f(1,1);      // error: ambiguous,
                 // f(int,double) or f(double,int) ?
}

这里给出了一个经典的 f(int, double), f(double, int), f(complex, int) 的例子:

  • f(1, 2.0)
    • 对比 f(int, double):参数 1 精确匹配,参数 2 精确匹配。-> 完美匹配
    • 对比 f(double, int):参数 1 需转换,参数 2 需转换。
    • 结果:f(int, double)
  • f(1, 1)
    • 对比 f(int, double):参数 1 精确,参数 2 需 int->double(标准转换)。
    • 对比 f(double, int):参数 1 需 int->double(标准转换),参数 2 精确。
    • 结果: 二义性错误! 因为没有一个函数在所有参数上都"至少同样好"且在某个参数上"更好"。这就是所谓的"交叉"死锁。

3.4 空指针

本节解释了为什么 C++ 长期容忍用整数 0 表示空指针,以及为什么后来引入了 nullptr。以下是详细解读:

3.4.1. 核心矛盾:概念 vs. 实现
  • 定义: C++ 从 C 语言继承了空指针的定义:"一个能求出 0 值的常量表达式被转换为一个指针"。
  • 误区: 很多人认为空指针在内存里一定全是二进制 0
  • ARM 警告: Stroustrup 引用了《带注释的参考手册》(ARM)中的警告:空指针不一定用与整数 0 同样的二进制模式表示
    • 解释: C++ 是强类型语言,编译器完全可以将空指针实现为特殊的内部值(例如某些机器上的非零地址),只要在源代码层面写 0 时编译器能正确处理即可。只有在省略参数检查(如变参函数 printf)时,才必须显式传递正确的底层表示。
3.4.2. 为什么不用 (void*)0

在 C 语言中,人们习惯用宏 NULL 来代替 0,通常定义为 (void*)0。但在 C++ 中,Stroustrup 拒绝了这种做法:

  • 类型安全: C++ 不允许 void* 隐式转换为其他指针类型(如 char* p = (void*)0; 在 C++ 是非法的)。
  • 设计哲学: 如果允许 void* 随意转换,会在类型系统上打开一个大洞。Stroustrup 不希望 C++ 的关键部分依赖于一个宏。
  • 结果: C++ 选择了直接使用整数 0 作为空指针常量。
3.4.3. 使用 0 的代价:重载解析的灾难

使用整数 0 表示空指针,导致了一个著名的二义性陷阱.

场景重现:

cpp 复制代码
void f(char*); // 版本 A
void f(int);   // 版本 B

void g() {
    f(0); // 调用哪个?
}
  • 直觉: 程序员写 f(0) 时,心里想的是"调用指针版本的 f",即传入空指针。
  • 编译器规则: 0 本质上是一个 int
    • 匹配 f(int):精确匹配。
    • 匹配 f(char*):需要标准转换(整型到指针)。
  • 结果: 根据重载规则,精确匹配优于转换 。所以 f(0) 会调用 f(int)
  • 后果: 这完全违背了程序员的意图,且编译器不会报错,这是一个静默的逻辑错误。
3.4.4. Stroustrup 的辩护与妥协

尽管存在上述缺陷,Stroustrup 在当时坚持使用 0

  • 历史包袱: 太多代码已经使用了 NULLNILnull 等各种宏定义。如果在语言层面引入新的关键字或强制改变规则,会破坏兼容性。
  • 规则自洽: 他认为规则本身是清晰的------0 就是整型零,只是恰好能转为指针。
  • 现实考量: 他承认这是一个不幸的副作用,但在当时的 Cfront 编译器实现中,并没有很好的办法既保持兼容又解决这个问题。他甚至引用朋友的话自嘲:"如果 0 是他们最糟的问题,那他们真是太幸运了。"
3.4.5 总结与后续演进

上面内容的完美解释了 C++11 为什么要引入 nullptr

  • 之前的困境:0 代表空指针,会导致在重载函数时,本该调用指针版本,却错误地调用了整型版本。
  • 现代解法: C++11 引入了 nullptr(类型为 std::nullptr_t)。
    • nullptr 可以隐式转换为任何指针类型。
    • nullptr 不能 转换为 int
    • 这样就彻底解决了上面提到的 f(0) 调用歧义问题:现在写 f(nullptr) 会毫无争议地调用 f(char*)

3.5 关键字overload的历史

讲述了 C++ 早期为了安全而引入的一个关键字------overload,以及它为何最终被废除的历史过程。

这段历史反映了 C++ 设计哲学从"显式声明意图"向"隐式推断与模块化兼容"的转变。

3.5.1. 早期的 overload 关键字(C++ 1.0 时代)

在 C++ 的早期版本中,重载并非默认开启。程序员必须显式地使用 overload 关键字,才能声明某个函数名将被重用。

  • 语法示例:

    cpp 复制代码
    overload max; // 必须显式声明 max 是重载的
    int max(int, int);
    double max(double, double);
  • 设计初衷: Stroustrup 当时认为,若不明确声明就允许同名函数存在,未免过于危险。他主要担心两点:

    1. 无法检查的歧义: 担心出现编译器无法解决的冲突。
    2. 链接错误: 担心若非程序员明确声明重载,链接器可能会产生混淆。
  • 反面教材: 如果没有 overload 声明,定义两个同名的 abs 函数会被视为错误。

3.5.2. 为什么这些担心是多余的?

随着 C++ 的发展,Stroustrup 发现当初的顾虑大多是不成立的:

  • 歧义性已解决: 之前讨论过的"最佳匹配"和"交叉规则"已经能很好地处理歧义,不需要额外的关键字来辅助。
  • 链接问题无关: 链接问题更多是 C 语言分别编译规则导致的一般性问题,与是否重载没有直接关系。
3.5. 3. overload 带来的真正灾难:模块化噩梦

虽然 overload 没能解决它想解决的问题,但它制造了一个巨大的新问题:头文件包含顺序依赖

场景重现:

假设你有两个库:

  • 标准数学库 (math.h) :定义了 double sqrt(double);
  • 复数库 (complex.h) :定义了 complex sqrt(complex);

在早期 C++ 中,如果你想同时使用这两个库,你必须非常小心地处理 overload 声明:

  • 正确的写法(极其脆弱):

    cpp 复制代码
    /* complex.h */
    overload sqrt; // 必须先声明我要重载 sqrt
    complex sqrt(complex);
    
    /* main.cpp */
    #include <complex.h> // 先包含复数库,因为它做了 overload 声明
    #include <math.h>    // 再包含数学库
  • 错误的写法(常见陷阱):

    cpp 复制代码
    #include <math.h>    // 先包含了 math.h,此时 sqrt 只是普通函数
    #include <complex.h> // 报错!complex.h 里试图对 sqrt 进行 overload 声明
                         // 但 sqrt 已经被定义过了,且没被标记为 overload

核心痛点: 如果 math.h 的作者没有预见到你会重载 sqrt,他就不会在里面写 overload sqrt;。当你后来引入 complex.h 时,编译器会认为你在试图重新定义一个普通函数,从而报错。

为了解决这个问题,程序员不得不使用各种丑陋的伎俩,比如重新安排 #include 的顺序,或者在所有可能用到该名字的地方都加上 overload 声明以防万一。这严重破坏了 C/C++ 的模块化特性。

3.5.4. 最终结论:废除 overload

Stroustrup 意识到,为了让不同的代码片段(特别是来自不同供应商的库)能够无缝协作,重载必须是默认的、隐式的

  • 决策: 彻底废除 overload 关键字。
  • 结果: 从 C++ 2.0 开始,只要函数签名不同,同名函数自动被视为重载。这极大地简化了头文件的管理,使得 C++ 标准库和第三方库的混合使用成为可能。

这一节的内容其实是 C++ 走向成熟的重要标志。它告诉我们:语言的复杂性不应该由用户来承担 。既然编译器有能力自动判断是否重载,就不应该强迫用户去写那个多余的 overload 关键字。

3.5.5 '关闭'重载的关键字extern "C"

这个关键字就是 extern "C"。虽然它表面上是用来链接 C 语言函数的,但在 C++ 的底层机制中,它实际上起到了**"关闭 C++ 重载机制"**的作用。

在 C++ 中,函数名会被编译器进行名称修饰(Name Mangling) ,把参数类型编码进函数名里(例如 void foo(int) 变成 _Z3fooi),这样链接器才能区分重载函数。而 extern "C" 的核心作用就是告诉编译器:"别搞那些花里胡哨的修饰了,就用最原始的 C 语言名字,也不许重载。"

3.5.5.1 为什么它能"关闭"重载?
  1. 禁止同名函数定义
    extern "C" 块中,你不能定义两个同名的函数,哪怕参数不同。因为 C 语言没有重载概念,链接器只认函数名。
cpp 复制代码
extern "C" {
    void process(int a);    // OK
    void process(double b); // ❌ 编译错误!重定义 'process'
}
  1. 生成"纯净"的符号
    它生成的符号表里只有函数名,没有参数信息。这正是 C++ 代码能调用 C 库(如 printf, malloc)的根本原因。
3.5.5.2 一个常见的"坑":类型不匹配的二义性

既然 extern "C" 关闭了重载,那如果我们在 C++ 代码里同时声明了一个 C++ 版本和一个 C 版本的同名函数,会发生什么?

cpp 复制代码
// C++ 版本(支持重载)
void log(int code);

// C 版本(关闭重载,符号名为 _log)
extern "C" void log(const char* msg);

void test() {
    log(10); // ❌ 报错!ambiguous call
}

解析:

  • 对于编译器来说,这是两个完全不同的函数(签名不同,链接名称也不同)。
  • 但是,当你调用 log(10) 时:
    • 匹配 C++ 版 log(int)精确匹配
    • 匹配 C 版 log(const char*)标准转换 (int -> const char* 是非法的,但如果是 log("hi") 就会匹配 C 版)。
  • 真正的陷阱在于: 如果你调用 log(NULL)(假设 NULL 是 0):
    • C++ 版 log(int):精确匹配。
    • C 版 log(const char*)0 可以转为空指针,也是精确匹配(在某些旧标准下)。
    • 结果: 编译器懵了。虽然一个是 C++ 符号,一个是 C 符号,但在重载解析阶段 ,它们被视为同一作用域下的候选者。由于 extern "C" 的存在,你无法通过参数类型来区分它们(因为 C 语言本身不支持重载解析逻辑),这往往导致意想不到的二义性错误。
3.5.5.3 最佳实践:如何安全地"混合"

为了既享受 C++ 的重载,又能调用 C 库,我们通常使用条件编译宏来隔离它们,而不是混在一起重载:

cpp 复制代码
#ifdef __cplusplus
extern "C" {
#endif

// 这里全是纯 C 风格的声明,没有重载
void c_style_func(int a);
void another_c_func(double b);

#ifdef __cplusplus
}
#endif

// 在 extern "C" 块之外,你可以放心地写 C++ 重载
void c_style_func(std::string s); // ✅ 安全,这是纯 C++ 重载
3.5.5.4 在C模块中恢复 C++ 的重载

extern "C++":专门用来在 extern "C" 块里'局部恢复'重载。

假设你处于一个必须保持 extern "C" 上下文的环境中,但你需要定义两个同名的 C++ 函数。

❌ 错误做法:直接在 extern "C" 中重载

cpp 复制代码
extern "C" {
    void process(int a);
    void process(double b); // 💥 编译错误!
                            // 报错信息通常是:redefinition of 'void process(int)'
                            // 因为 C 语言不允许重载,链接器无法区分这两个符号。
}

✅ 正确做法:使用 extern "C++" 嵌套

cpp 复制代码
extern "C" {
    // 这里是标准的 C 环境
    void c_function(int x); 

    // --- 局部切换回 C++ 模式 ---
    extern "C++" {
        void process(int a);      // ✅ OK
        void process(double b);   // ✅ OK,重载被允许
    } 
    // ---------------------------

    // 切回 C 环境
    void another_c_function(char c);
}
3.5.5.5 总结
  • extern "C" 是 C++ 中唯一能显式"降级"到非重载模式的关键字。
  • 它不仅影响链接名称,还改变了编译器的名称查找规则
  • 切记: 不要在 extern "C" 块内部尝试模拟重载,那是自找麻烦;也不要让 C++ 重载函数与 extern "C" 函数在同一个调用点产生竞争,除非你非常清楚转换规则。

3.6 名称修饰和extern "C"的由来

本节揭示了 C++ 为了解决"重载"与"C 语言兼容"这两个看似矛盾的需求("类型安全的连接"),是如何一步步演化出名称修饰extern "C" 机制的。

3.6.1. C 语言连接的"不安全"本质

Stroustrup 首先指出了 C 语言连接机制的致命弱点:简单但极其脆弱

  • 现象 :在 C 语言中,如果你声明了 extern void f(char*),链接器会盲目地将代码中所有的 f 符号连接在一起。
  • 后果 :如果实际定义的 fvoid f(int),或者根本不是函数,C 的链接器不会报错。它会强行连接,导致程序在运行时崩溃(如段错误、内核卸载)。
  • 背景:C 程序员习惯了这种"自求多福"的模式,但在引入重载机制后,这种不安全性变得无法容忍,因为编译器必须区分同名但参数不同的函数。
3.6. 2. 早期尝试:简单的名称映射(失败)

在 C++ 2.0 之前,曾尝试过一种"简单模式":

  • 思路 :尽量让 C++ 函数的名称保持原样。如果 C 语言输出 open,C++ 也输出 open;如果 C 输出 _open,C++ 也输出 _open
  • 局限 :这种模式无法处理重载。因为 sqrt(double)sqrt(complex) 在 C 的链接器眼里都是 sqrt,会导致符号冲突。
3.6. 3. 终极方案:名称修饰

为了解决上述问题,C++ 采用了名称修饰技术。

  • 原理 :将函数的参数类型信息编码进函数名中。
    • f(int) -> 编译为 _f_Fi
    • f(int, char*) -> 编译为 _f_FiPc
    • f(double, double) -> 编译为 _f_Fdd
  • 优势
    1. 支持重载:不同参数的同名函数拥有了唯一的链接符号。
    2. 类型安全 :如果声明和定义的参数类型不一致(例如声明是 int,定义是 double),生成的符号就会不同,链接器会直接报"未定义符号"错误,而不是运行时崩溃。
3.6.4. 引入 extern "C":为了兼容

既然 C++ 函数名都被"加密"了,那怎么调用标准的 C 库函数(如 printf, malloc)呢?它们的符号名并没有被修饰。

为此,C++ 引入了连接描述扩充 ,即 extern "C"

cpp 复制代码
extern "C" {
    double sqrt(double); // 告诉编译器:这个函数不要修饰名字,用 C 的规则
}
  • 作用 :它不影响函数的语义(参数还是 double),只影响目标代码中的命名习惯 。它告诉编译器生成 sqrt_sqrt,而不是 sqrt_Fd
3.6.5. 设计哲学的权衡
3.6.5.1 为什么不对 C 连接也做类型检查?

有人建议:即使对于 extern "C" 的函数,也应该在调用时进行严格的 C++ 类型检查。

  • Stroustrup 的决定
  • 理由
    1. 兼容性:必须允许直接使用 ANSI C 的头文件,不能修改它们。
    2. 灵活性:C 语言本身比较松散(例如指针类型的隐式转换),如果 C++ 检查太严,会导致大量合法的 C 代码无法编译。
    3. 复杂性:为了支持 Pascal、Fortran 等其他语言的连接,如果每种语言都搞一套特殊的类型规则,编译器会变得极其臃肿。
3.6.5.2 函数指针的困境

这是一个非常刁钻的技术细节。

  • 问题:如果一个 C++ 函数需要接收一个"C 语言风格的回调函数"作为参数,怎么写?

  • 解决 :必须区分"C++ 函数指针"和"C 函数指针"。

    cpp 复制代码
    typedef void (*PFV)(void*, void*); // 默认是 C++ 风格的指针
    
    // sort1: C++ 函数,接收一个 C++ 风格的函数指针
    void* sort1(void*, unsigned, PFV); 
    
    // sort2: C 函数(外部链接),接收一个 C++ 风格的函数指针
    extern "C" void* sort2(void*, unsigned, PFV); 

    Stroustrup 承认,这种混合连接(C++ 调用 C,或者 C++ 传递函数指针给 C)是非常复杂且依赖于具体实现的领域。

3.6.6 总结

这一节的核心逻辑链条是:

  1. C 的连接太弱,无法支持重载,也不安全。
  2. 名称修饰解决了重载和类型安全问题,但破坏了与 C 的兼容。
  3. extern "C" 作为一把"钥匙",允许程序员在需要时手动关闭名称修饰,从而打通 C++ 与 C 的世界。
  4. 妥协:为了保证兼容性和实用性,C++ 没有对 C 连接强制实施完美的类型安全,而是保留了部分"不安全"的特性,以便能无缝集成现有的 C 生态。

4 对象的操作(建立和复制)

本节揭示了 C++ 中几个极其重要的设计决策是如何诞生的,特别是关于如何禁止某些操作 以及默认复制行为的演变

展示了 C++ 如何在"零开销抽象"和"类型安全"之间走钢丝:

  • 为了安全,我们引入了复杂的规则来控制谁能复制、谁能创建、谁能继承。
  • 为了兼容 C 和性能,我们保留了默认的"浅复制"和"切片"行为,把责任留给了程序员。
  • 那个时代没有 delete 关键字,程序员们不得不利用 private 访问权限来实现各种"禁止"逻辑,这是现代 C++ 语法糖背后的历史积淀。

4.1. 禁止操作的"私有化"技巧

Stroustrup 提到,经常有人希望禁止类的某些操作(如复制、派生、栈分配等)。在 C++ 早期(2.0 版本之前),并没有 delete 关键字(即 C++11 的 = delete)。

当时的解决方案:

如果你希望禁止某个操作,就把完成该操作的函数声明为 私有的(private) ,并且不提供实现

  • 禁止复制:

    cpp 复制代码
    class X {
    private:
        X(const X&);            // 拷贝构造函数声明为私有
        X& operator=(const X&); // 赋值运算符声明为私有
    public:
        X(int);
    };
    • 效果: 外部代码试图复制 X 对象时,编译器会报"访问私有成员"错误。
    • 局限: 类的成员函数和友元仍然可以复制它。但在实际工程中,这通常是可以接受的。

4.2. 控制内存分配

利用"私有化"技巧,还可以控制对象在哪里创建。

  • 禁止栈/全局分配(强制堆分配):

    析构函数声明为私有。

    cpp 复制代码
    class On_free_store {
        ~On_free_store(); // 私有析构
    public:
        static void free(On_free_store* p) { delete p; } // 提供专门的释放接口
    };
    • 原理: 栈对象和全局对象在生命周期结束时会自动调用析构函数。如果析构函数是私有的,编译器就无法生成自动销毁的代码,从而报错。只有堆对象可以通过成员函数 free 来手动销毁。
  • 禁止堆分配(强制栈/全局):

    operator new 声明为私有(或者声明一个参数特殊的 new 来隐藏默认的 new)。

    cpp 复制代码
    class No_free_store {
        void* operator new(size_t, Dummy); // 隐藏标准 new
    };
    • 效果: 试图 new No_free_store 时会因为找不到匹配的 operator new 而报错。

4.3. 禁止派生

这也是一个非常巧妙的技巧,后来被 Andrew Koenig 发现并推广。

  • 方法: 将基类的构造函数 声明为私有。

    cpp 复制代码
    class Usable_lock {
        friend class Usable; // 只允许 Usable 访问
    private:
        Usable_lock() {}
    };
    
    class Usable : public virtual Usable_lock {
    public:
        Usable();
    };
  • 原理: C++ 规定,虚基类 的构造函数必须由最终派生类 直接调用。如果 Usable_lock 的构造函数是私有的,那么任何试图从 Usable 继续派生的类(如 DD)都无法调用这个构造函数,从而导致编译错误。

  • 评价: Stroustrup 认为这更多是一种"智力嗜好",虽然有效,但逻辑上比较复杂。

4.4. 按成员复制 vs 按位复制

这是 C++ 语义定义的一个关键转折点。

  • 早期的问题: 最初,C++ 的默认复制是按位复制(Bitwise Copy)。这在处理包含复杂对象(如自定义了赋值运算符的类)的成员时会出错。

    • 例子:Y 包含一个类 X 的成员 a。如果 X 禁用了赋值(或者有特殊逻辑),而 Y 使用默认的按位复制,那么 y1 = y2 就会强行把 y2.a 的内存覆盖到 y1.a 上,绕过了 X 的赋值运算符检查。
  • 修正后的规则: 在 Andrew Koenig 的推动下,C++ 改为按成员复制(Memberwise Copy)

    • 定义: 对象的复制被定义为对其所有非静态成员和基类成员逐个进行复制。
    • 意义: x = y 实际上等同于调用 x.operator=(y)。这意味着如果成员有自己的赋值运算符,它会被正确调用。

4.5. "切片"问题与浅复制的隐患

这一节最后讨论了默认行为带来的两个副作用:

  1. 切片: 当把派生类对象赋值给基类对象时(Base b = Derived d;),只会复制基类部分,派生类特有的部分被"切掉"了。Stroustrup 对此持保留态度,但为了保持与 C 的兼容性(结构体赋值)和灵活性,保留了这一行为。
  2. 浅复制陷阱: 默认的成员复制对于指针成员来说只是浅复制 (只复制指针地址,不复制指向的内容)。
    • 例子: String 类如果只包含 char* p,默认复制会导致两个对象指向同一块内存,析构时会双重释放(Double Free)。
    • 结论: 编译器很难自动判断何时需要深复制,因此 C++ 选择保留默认行为,但建议程序员如果类中有指针,必须显式定义复制构造函数和赋值运算符(即著名的**三法则(析构函数、拷贝构造函数、拷贝赋值运算符)/五法则(析构函数、拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符)**的前身)。

'切片'问题扩展:

问题:如果基类析构函数没加 virtual,切片后的对象被删除时会导致未定义行为(通过基类指针删除派生类对象,如果基类析构函数不是虚函数(virtual),结果是未定义行为。)

解决:多态基类必须有虚析构(只要一个类有任何虚函数,或者你打算让它作为基类被多态使用,它的析构函数必须是虚的。)。

cpp 复制代码
#include <iostream>

class Base {
public:
    Base() { std::cout << "Base 构造\n"; }
    
    // ❌ 错误示范:忘记加 virtual
    ~Base() { 
        std::cout << "Base 析构\n"; 
    } 
};

class Derived : public Base {
    int* data;
public:
    Derived() { 
        data = new int[100]; // 申请资源
        std::cout << "Derived 构造 (分配了内存)\n"; 
    }
    
    ~Derived() { 
        delete[] data;     // 释放资源
        std::cout << "Derived 析构 (释放了内存)\n"; 
    }
};

int main() {
    // 场景:多态使用,基类指针指向派生类对象
    Base* ptr = new Derived(); 
    
    // ❌ 灾难发生点
    delete ptr; 
    
    return 0;
}

5 运算符-> 、.与++ 的重载

本节记录了 C++ 在运算符重载设计过程中的一些**"妥协"、"意外"和"未竟的事业"**。Stroustrup 坦诚地讲述了为什么某些运算符(如 ->)被允许重载,而另一些(如 .)却被禁止,以及 ++ 前后缀区分这个"古怪"语法的由来。

5.1. 灵巧指针与 operator-> 的诞生

这是 C++ 智能指针(Smart Pointer)技术的基石。

  • 早期困境: 在 C++ 2.0 之前,-> 不允许重载。这意味着你无法创建一个"行为像指针但不是指针"的对象(即现在的智能指针或代理对象)。
  • 思维转折点: Stroustrup 最初把 -> 看作一个不可分割的二元运算符。但在 Mentor Graphics 的一次会议上,Jim Howard 指出:应该把 -> 看作一个一元运算符
    • 原理: x->m 实际上被解释为 (x.operator->())->m
    • 递归机制: 如果 operator->() 返回的还是一个重载了 -> 的对象,编译器会继续调用,直到返回一个真正的原生指针,然后才去访问成员 m
  • 意义: 这个视角的转换,使得 C++ 能够支持**句柄(Handle) 代理(Proxy)**模式,为后来的 std::unique_ptrstd::shared_ptr 铺平了道路。

5.2. "灵巧引用"与 operator. 的遗憾

既然 -> 可以重载,大家自然希望 .(点号)也能重载,以便创建"灵巧引用"(比如一个看起来像引用但可以重新绑定、或者带有额外检查的对象)。

  • 尝试与失败:
    • 如果允许重载 operator.,就会破坏 C++ 的核心语义:obj.m 的含义将不再确定。
    • 为了模拟引用行为,程序员不得不写大量繁琐的"前推函数"(Forwarding functions),手动重载 +, -, [] 等所有运算符来转发给内部指针。这非常令人讨厌。
  • 核心矛盾:
    • 如果要支持 operator.,就必须打破 a.m(&a)->m 的等价性。
    • Stroustrup 陷入了两难:是保持语法的稳定性,还是提供极致的灵活性?
  • 最终决定: 禁止重载 operator.
    • 理由: 为了保证语言的可理解性和成员访问的确定性。Stroustrup 认为,如果需要类似引用的行为,现有的机制(虽然不完美)已经够用了,没必要为了极少数场景牺牲语言的清晰度。直到今天的 C++23/26,operator. 的重载仍在讨论中,尚未完全落地。

5.3. ++ 前后缀的"古怪"区分法

这是一个关于**"向后兼容""语法洁癖"**博弈的经典案例。

  • 问题: C++ 1.0 中,++pp++ 调用的是同一个函数 operator++()。这导致无法区分前置(先加后用)和后置(先用后加)的语义,特别是对于迭代器来说,后置递增需要返回旧值,效率不同。

  • 拒绝新关键字: Stroustrup 不想引入 prefixpostfix 这种新关键字,因为这会破坏已有的代码,且显得累赘。

  • 最终方案(Hack): 利用一个无用的 int 参数来占位。

    cpp 复制代码
    X& operator++();    // 前置 ++:无参数
    X  operator++(int); // 后置 ++:有个 int 参数(虽然从来不用)
  • 评价: Stroustrup 自己承认这个方案**"既做作又微妙"**(contrived and subtle)。那个 int 参数纯粹是为了让编译器能区分两个函数签名而强行加进去的"伪参数"。但这确实是在不引入新关键字前提下的最小修改方案。

5.4. 其他运算符的取舍

  • ->* (指向成员的指针): 允许重载。理由是正交性------既然 ->* 都能重载,没理由禁止它们的组合。这在实现复杂的代理模式时很有用。
  • , (逗号运算符): 允许重载。这主要是在 Margaret Ellis 的推动下加入的。虽然 Stroustrup 担心这会改变内置语义(比如破坏求值顺序的保证),但为了保持语言的一般性(Generality),最终还是允许了。不过在实际工程中,重载逗号运算符极少见,且通常被认为是糟糕的实践。

5.5 总结

本节展示了 C++ 设计哲学中的实用主义

  1. operator-> 的成功在于视角的转换(从二元变一元),开启了智能指针时代。
  2. operator. 的失败在于对语言核心稳定性的坚守,避免了语法的过度灵活导致的混乱。
  3. operator++(int) 的妥协展示了如何在有限的语法空间内,通过"微小的古怪"来解决实际问题,而不破坏向后兼容性。

这些设计决策大多发生在 30 多年前,但它们至今仍深深影响着每一行 C++ 代码的写法。


6 给 C++ 增加运算符

本节揭示了 C++ 标准制定过程中关于"指数运算符"的激烈争论,以及 Stroustrup 对于"用户自定义运算符"这一诱人但危险的想法的最终拒绝。

6.1. 为什么 C++ 没有指数运算符(**)?

这是 C++ 程序员(尤其是从 Fortran 或 Python 转过来的)最常问的问题之一。Stroustrup 在这里详细解释了背后的权衡:

  • 历史包袱与兼容性: C++ 继承了 C 的语法。在 C 中,* 是指针解引用。如果引入 ** 作为指数运算,a**b 这种写法就会产生严重的歧义:它是 a b a^b ab(指数)还是 *(*b)(双重指针解引用)?
    • 虽然可以通过编译技巧(如根据上下文判断 b 是否是指针)来解决,但这会破坏语言的简单性原则。
  • 优先级的噩梦: 指数运算在数学上通常是右结合的( 2 3 2 = 2 9 = 512 2^{3^2} = 2^9 = 512 232=29=512),而 C/C++ 的运算符优先级体系非常复杂。引入一个新运算符需要插入到现有的优先级表中,这极易引发错误。
  • 使用频率低: 委员会审查了大量代码,发现绝大多数指数运算其实是简单的平方( a 2 a^2 a2),这完全可以用 a*a 高效解决。真正的通用指数运算(如 a 3.14 a^{3.14} a3.14)通常通过调用 pow(a, b) 函数即可,没必要为了低频场景牺牲语法的清晰度。
  • 字符集限制: 早期键盘和 ASCII 字符集有限。^ 已经被异或占用,@$ 不是标准 C 字符且不在所有键盘上。最终 Matt Austern 提议的 **^ 被认为太丑陋。

结论: 保持 pow(a,b) 函数调用虽然写起来长一点,但语义清晰,没有歧义,且不需要破坏现有的语法兼容性。

6.2. 用户自定义运算符:诱人的陷阱

既然内置运算符不够用(比如没有指数运算符),那能不能让用户自己定义运算符?比如定义 a pow b 或者 a ** b

  • Algol 68 的前车之鉴: Algol 68 允许用户定义运算符,但这导致了语法的极度复杂化。
  • 优先级地狱: 如果你定义了 powproduct 两个运算符,编译器怎么知道 a pow b product c 是先算幂还是先算乘法?
    • 方案 A:让用户指定优先级。结果:不同库定义的运算符优先级冲突,代码不可移植。
    • 方案 B:所有自定义运算符优先级相同。结果:结合性变得极其怪异,连 Stroustrup 自己和同事都无法就"正确"的优先级达成一致。
  • 可读性灾难: 代码变成了 a .pow b 或者 a ** b,不同程序员的风格完全不同,阅读别人的代码就像在猜谜。

结论: C++ 最终决定不支持用户自定义运算符 。虽然这牺牲了一些灵活性(比如矩阵运算不能写成 A * B * C 的直观形式),但保证了语言的核心语法是统一、可预测且易于解析的。

6.3. 复合运算符的设想

Stroustrup 还探讨了一种更激进的设想:复合运算符重载

  • 想法: 允许定义像 operator *=+ 这样的三元运算符,用来处理类似 a = b * 1.7 + d 这种常见的矩阵/向量运算模式。
  • 目的: 为了让编译器能识别出这种特定的计算模式,从而进行底层优化(例如融合乘加指令 FMA)。
  • 结果: 这个想法虽然在代码生成器里很常见,但在语言层面实现过于复杂,且空格的位置都会影响语法解析(如书中提到的 Matrix operator = * +),最终没有被采纳。

6.4 总结

本节体现了 C++ 设计哲学中的克制

  1. 兼容性高于便利性: 为了避免破坏 C 的指针语法,宁愿放弃直观的指数运算符。
  2. 显式优于隐式: 宁愿让你写 pow(a,b),也不愿引入可能导致歧义的 **
  3. 标准化优于个性化: 拒绝用户自定义运算符,防止语言分裂成无数种"方言"。

这些决策在当时可能让部分科学家或数学家感到失望,但从长远来看,它们维护了 C++ 作为一个通用系统编程语言的严谨性和稳定性。


7 枚举与布尔类型

本节记录了 C++ 如何从一个"仅仅是更好的 C"的语言,逐渐演变成一个拥有严格类型系统的现代语言。Stroustrup 在这里详细讲述了两个关键概念的诞生过程:强类型枚举的缺失与补救 ,以及 bool 类型是如何在混乱中统一天下的

7.1. C 语言枚举的"半生不熟"

Stroustrup 开篇就吐槽了 C 语言的枚举(enum)。

  • 本质是 int 在 C 语言中,枚举并不是一个真正的独立类型,它本质上就是整数(int)。
  • 缺乏类型安全: 你可以随意把一个整数赋值给枚举变量(如 enum Color c = 2;),也可以把枚举变量赋值给整数。这种"自由"导致了类型检查的失效。
  • C++ 的早期继承: 最初的 C++ 直接照搬了 C 的规则,因为 Stroustrup 当时觉得枚举这东西本身就不太重要,没必要花精力去改。

7.2. ANSI C 带来的"指针危机"

事情的转折点在于 ANSI C 标准的制定。

  • 新问题: ANSI C 委员会决定把枚举搞得更像"类型"一点,规定指向不同枚举类型的指针应该被视为不同类型
    • 例如:Color*Vehicle* 应该是不同的。
  • C++ 的重载冲突: 这在 C 语言里可能只是个小问题,但在支持函数重载 的 C++ 里,这成了大灾难。
    • 如果你定义了 void f(Color*)void f(Vehicle*),在旧的规则下它们被视为同一个函数(都是 int*),导致重载失败或二义性。
  • 漫长的争论: Stroustrup 和一群 C/C++ 大牛(包括 Dennis Ritchie, Brian Kernighan)讨论了很长时间。最终达成共识:必须把每个枚举看作独立的类型,以保证重载机制的正确性。

7.3. C++ 的解决方案:更严格的枚举

为了解决上述问题,C++ 对枚举规则进行了修改(这也是现代 C++ 枚举的基础):

  • 独立类型: 每个 enum 都是一个独特的用户定义类型。
  • 禁止隐式转换:
    • Color c = 2; // 报错!不能把整数隐式赋给枚举。
    • c = Color(2); // 必须显式转换。
  • 允许隐式转整数: 为了兼容 C,枚举值仍然可以隐式转换为 int(如 int i = c;)。
  • 支持运算符重载: 既然枚举是独立类型了,Martin O'Riordan 指出它应该像类一样支持运算符重载。于是 C++ 允许你为枚举定义 operator++ 等操作(虽然 Stroustrup 自己觉得用 switch 语句来实现递增有点傻,但这提供了可能性)。

7.4. 布尔类型(bool)的"战国时代"

bool 类型的诞生,这是一个关于**"标准化混乱"**的故事。

  • 早期的混乱:bool 成为标准之前,C++ 社区简直是群魔乱舞。每个人都定义了自己的布尔类型:
    • #define bool char
    • #define TRUE 1
    • typedef enum { F, T } Boolean;
    • 甚至有人用 const int true = 1;
  • 后果: 这些定义互不兼容。当你把两个使用了不同布尔定义的库链接在一起时,程序就会崩溃或行为诡异。
  • 为什么很难加 bool
    • 兼容性噩梦: Dag Brück 和 Sean Corfield 分析了数万行代码,发现现有的代码严重依赖 boolint 之间的自由转换。如果强行引入一个严格的布尔类型,会破坏海量现有代码。
    • 信仰之争: Pascal/Algol 派认为没有布尔类型是荒谬的;C 语言原教旨主义者认为加布尔类型是画蛇添足。

7.5. 最终的妥协:特殊的整数类型

最终,ANSI/ISO 委员会接受了必须引入 bool 的论点,但为了兼容旧代码,设计了一个非常巧妙的**"混合体"**:

  • bool 是内置类型: 不再是宏或枚举。
  • 关键字 truefalse 成为语言关键字。
  • 双向隐式转换(关键点):
    • 整数 → \rightarrow → 布尔: 非零值隐式转换为 true,零转换为 false
    • 布尔 → \rightarrow → 整数: true 可以隐式转换为 1false 转换为 0

7.5.6 总结

这个设计保证了高度的向后兼容性 。旧代码里的 if (int_var) 依然有效,新代码里的 bool b = true 也很清晰。虽然这种"既是布尔又是整数"的设计在纯理论派看来不够完美,但它成功地统一了 C++ 社区的布尔用法,结束了多年的"方言混战"。

相关推荐
handler011 小时前
【Linux】虚拟地址空间解析
linux·运维·c++·线程·进程·虚拟地址空间·虚拟地址
(Charon)1 小时前
【C++】网络缓冲区设计(二):Ring Buffer环形缓冲区、head/tail与跨界读写
开发语言·c++
码匠许师傅1 小时前
【设计模式精讲】24.观察者模式(Observer)
c++·观察者模式·设计模式·uml
imgsq2 小时前
S-57 数据解剖:把一个 ENC 文件拆给你看
c++·数据可视化
SuperByteMaster2 小时前
编译器将unsigned short 整体提升int和unsigned short 在内存中的分配2byte的理解
c语言
迷茫、Peanut2 小时前
中断的时钟
c++
2402_882893862 小时前
用哈希表封装 unordered_map 和 unordered_set —— 手撕 C++ 哈希容器
c++·哈希桶·unordered_map·unordered_set
熊猫钓鱼>_>2 小时前
用 OHAudioSuite 空间渲染节点搭一座「3D 有声博物馆」——从三种模式到 FreeBuds 头追的实战记录
c++·3d·ai·harmonyos·arkts·audio·ohaudiosuite
XiaoQiao6669993 小时前
C 语言常用头文件及里面核心函数
c语言·开发语言