文章目录
- [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.2.1 基础类型的区分 (`char`, `int`, `float`)](#3.2.1 基础类型的区分 (
- [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.5.1. 早期的 `overload` 关键字(C++ 1.0 时代)](#3.5.1. 早期的
- [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 总结)
- [5.1. 灵巧指针与 `operator->` 的诞生](#5.1. 灵巧指针与
- [6 给 C++ 增加运算符](#6 给 C++ 增加运算符)
-
- [6.1. 为什么 C++ 没有指数运算符(`**`)?](#6.1. 为什么 C++ 没有指数运算符(
**)?) - [6.2. 用户自定义运算符:诱人的陷阱](#6.2. 用户自定义运算符:诱人的陷阱)
- [6.3. 复合运算符的设想](#6.3. 复合运算符的设想)
- [6.4 总结](#6.4 总结)
- [6.1. 为什么 C++ 没有指数运算符(`**`)?](#6.1. 为什么 C++ 没有指数运算符(
- [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 语言类型提升规则的粗糙重载系统,进化为一个强类型、高精度、支持多态层次的现代重载解析系统。
关键的演变点在于:
- 拒绝默认提升: 尊重
char和float的独立身份。 - 引入 Const 正确性: 让
const成为重载决议的一部分,提高了安全性。 - 遵循继承逻辑: 在类层次结构中,优先匹配最具体的类型,而不是盲目回退到通用指针。
C++ 重载解析之所以如此复杂的历史原因:它是在保持 C 语言兼容性、支持强大的面向对象特性(用户自定义转换)以及消除早期设计的顺序依赖性这三者之间进行艰难平衡的产物。最终形成的"最佳匹配"(从"基于声明顺序")和"交叉规则"(为了解决多参数函数匹配)虽然难懂,但保证了代码行为的可预测性(不再依赖声明顺序)。
具体而言,这种复杂性主要体现在以下三个维度:
- 兼容性的代价: 这些复杂的规则很大程度上是为了兼容 C 语言的习惯(比如允许
int和char混用)。如果完全从零设计,规则会简单得多(例如禁止所有隐式转换),但这会赶走 C 程序员,并不会发展成如今受众那么的语言。 - 复杂度的必然性: 为了让用户自定义类型(类)能像内置类型一样自然地进行运算(例如
complex类的加减乘除),必须引入复杂的转换序列。 - Andrew Koenig 的贡献: 特别提到了 Andrew Koenig 在制定这些"交叉规则"时的关键作用,是他发现并形式化了那些极端的边界情况。
3.1 核心背景与目标
- 问题起源: C++ 引入了函数名和运算符重载,但这带来了复杂性。早期的规则比较粗糙,导致编译器经常无法区分"太类似"的类型,或者产生难以预料的隐式转换。
- 改进目标:
- 细粒度解析: 能够区分更细微的类型差异(如
intvschar,floatvsdouble,constvs 非const)。 - 顺序无关性: 重载解析的结果不应依赖于函数声明的顺序。
- 捕获错误: 新的模式不仅要能解析合法的调用,还要能捕捉到更多的歧义性错误。
- 细粒度解析: 能够区分更细微的类型差异(如
3.2 细粒度解析的具体改进
3.2.1 基础类型的区分 (char, int, float)
- 早期限制: 早期 C++ 继承了 C 语言的类型提升规则。
float参数会被提升为double。char参数会被提升为int。- 后果: 无法为
char或float单独编写重载函数。例如,想写一个专门处理字符的输出函数,必须用不同的名字(如put(char)),否则cout << 'X'会输出数字 88(ASCII码)而不是字符 'X'。
- 改进方案: 修改了类型规则,允许针对未提升形式(如直接的
char或float)进行重载解析。- 字面量类型变更: 在 C++ 中,字符字面量
'a'的类型被定义为char(而在 C 中是int)。这是一个为了兼容性和类型安全做出的重大改变。 - 效果: 现在可以定义
void f(char)和void f(int),编译器能精确匹配。
- 字面量类型变更: 在 C++ 中,字符字面量
3.2.2 const 修饰符的区分
- 改进点: 利用
const和非const之间的差异来进行重载。 - 经典案例 (
strtok):- ANSI C 标准库中的
strtok返回char*,即使传入的是const char*,这会导致类型系统的不安全(可能通过返回值修改常量字符串)。 - C++ 解决方案: 提供两个版本的重载:
char* strtok(char*, const char*);(处理非 const 字符串,返回可修改指针)const char* strtok(const char*, const char*);(处理 const 字符串,返回只读指针)
- 这既保持了与 C 的兼容性,又增强了类型安全性。
- ANSI 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++ 重载解析的基础):
- 精确匹配: 类型完全一致,或仅涉及数组名到指针、函数名到指针、
T到const T等不可避免的转换。这是最高优先级。 - 提升匹配: 涉及 ANSI C 标准定义的提升,如
char->int,float->double。这被认为是"安全"的。 - 标准转换匹配: 如
int->double,派生类指针 -> 基类指针,unsigned int->int。 - 用户定义转换: 调用构造函数或类型转换运算符。
- 省略号匹配: 匹配
...(如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)时,才必须显式传递正确的底层表示。
- 解释: C++ 是强类型语言,编译器完全可以将空指针实现为特殊的内部值(例如某些机器上的非零地址),只要在源代码层面写
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:
- 历史包袱: 太多代码已经使用了
NULL、NIL、null等各种宏定义。如果在语言层面引入新的关键字或强制改变规则,会破坏兼容性。 - 规则自洽: 他认为规则本身是清晰的------
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 关键字,才能声明某个函数名将被重用。
-
语法示例:
cppoverload max; // 必须显式声明 max 是重载的 int max(int, int); double max(double, double); -
设计初衷: Stroustrup 当时认为,若不明确声明就允许同名函数存在,未免过于危险。他主要担心两点:
- 无法检查的歧义: 担心出现编译器无法解决的冲突。
- 链接错误: 担心若非程序员明确声明重载,链接器可能会产生混淆。
-
反面教材: 如果没有
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 为什么它能"关闭"重载?
- 禁止同名函数定义
在extern "C"块中,你不能定义两个同名的函数,哪怕参数不同。因为 C 语言没有重载概念,链接器只认函数名。
cpp
extern "C" {
void process(int a); // OK
void process(double b); // ❌ 编译错误!重定义 'process'
}
- 生成"纯净"的符号
它生成的符号表里只有函数名,没有参数信息。这正是 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 版)。
- 匹配 C++ 版
- 真正的陷阱在于: 如果你调用
log(NULL)(假设 NULL 是 0):- C++ 版
log(int):精确匹配。 - C 版
log(const char*):0可以转为空指针,也是精确匹配(在某些旧标准下)。 - 结果: 编译器懵了。虽然一个是 C++ 符号,一个是 C 符号,但在重载解析阶段 ,它们被视为同一作用域下的候选者。由于
extern "C"的存在,你无法通过参数类型来区分它们(因为 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符号连接在一起。 - 后果 :如果实际定义的
f是void 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_Fif(int, char*)-> 编译为_f_FiPcf(double, double)-> 编译为_f_Fdd
- 优势 :
- 支持重载:不同参数的同名函数拥有了唯一的链接符号。
- 类型安全 :如果声明和定义的参数类型不一致(例如声明是
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 的决定 :不。
- 理由 :
- 兼容性:必须允许直接使用 ANSI C 的头文件,不能修改它们。
- 灵活性:C 语言本身比较松散(例如指针类型的隐式转换),如果 C++ 检查太严,会导致大量合法的 C 代码无法编译。
- 复杂性:为了支持 Pascal、Fortran 等其他语言的连接,如果每种语言都搞一套特殊的类型规则,编译器会变得极其臃肿。
3.6.5.2 函数指针的困境
这是一个非常刁钻的技术细节。
-
问题:如果一个 C++ 函数需要接收一个"C 语言风格的回调函数"作为参数,怎么写?
-
解决 :必须区分"C++ 函数指针"和"C 函数指针"。
cpptypedef 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 总结
这一节的核心逻辑链条是:
- C 的连接太弱,无法支持重载,也不安全。
- 名称修饰解决了重载和类型安全问题,但破坏了与 C 的兼容。
extern "C"作为一把"钥匙",允许程序员在需要时手动关闭名称修饰,从而打通 C++ 与 C 的世界。- 妥协:为了保证兼容性和实用性,C++ 没有对 C 连接强制实施完美的类型安全,而是保留了部分"不安全"的特性,以便能无缝集成现有的 C 生态。
4 对象的操作(建立和复制)
本节揭示了 C++ 中几个极其重要的设计决策是如何诞生的,特别是关于如何禁止某些操作 以及默认复制行为的演变。
展示了 C++ 如何在"零开销抽象"和"类型安全"之间走钢丝:
- 为了安全,我们引入了复杂的规则来控制谁能复制、谁能创建、谁能继承。
- 为了兼容 C 和性能,我们保留了默认的"浅复制"和"切片"行为,把责任留给了程序员。
- 那个时代没有
delete关键字,程序员们不得不利用private访问权限来实现各种"禁止"逻辑,这是现代 C++ 语法糖背后的历史积淀。
4.1. 禁止操作的"私有化"技巧
Stroustrup 提到,经常有人希望禁止类的某些操作(如复制、派生、栈分配等)。在 C++ 早期(2.0 版本之前),并没有 delete 关键字(即 C++11 的 = delete)。
当时的解决方案:
如果你希望禁止某个操作,就把完成该操作的函数声明为 私有的(private) ,并且不提供实现。
-
禁止复制:
cppclass X { private: X(const X&); // 拷贝构造函数声明为私有 X& operator=(const X&); // 赋值运算符声明为私有 public: X(int); };- 效果: 外部代码试图复制
X对象时,编译器会报"访问私有成员"错误。 - 局限: 类的成员函数和友元仍然可以复制它。但在实际工程中,这通常是可以接受的。
- 效果: 外部代码试图复制
4.2. 控制内存分配
利用"私有化"技巧,还可以控制对象在哪里创建。
-
禁止栈/全局分配(强制堆分配):
将析构函数声明为私有。
cppclass On_free_store { ~On_free_store(); // 私有析构 public: static void free(On_free_store* p) { delete p; } // 提供专门的释放接口 };- 原理: 栈对象和全局对象在生命周期结束时会自动调用析构函数。如果析构函数是私有的,编译器就无法生成自动销毁的代码,从而报错。只有堆对象可以通过成员函数
free来手动销毁。
- 原理: 栈对象和全局对象在生命周期结束时会自动调用析构函数。如果析构函数是私有的,编译器就无法生成自动销毁的代码,从而报错。只有堆对象可以通过成员函数
-
禁止堆分配(强制栈/全局):
将
operator new声明为私有(或者声明一个参数特殊的new来隐藏默认的new)。cppclass No_free_store { void* operator new(size_t, Dummy); // 隐藏标准 new };- 效果: 试图
new No_free_store时会因为找不到匹配的operator new而报错。
- 效果: 试图
4.3. 禁止派生
这也是一个非常巧妙的技巧,后来被 Andrew Koenig 发现并推广。
-
方法: 将基类的构造函数 声明为私有。
cppclass 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. "切片"问题与浅复制的隐患
这一节最后讨论了默认行为带来的两个副作用:
- 切片: 当把派生类对象赋值给基类对象时(
Base b = Derived d;),只会复制基类部分,派生类特有的部分被"切掉"了。Stroustrup 对此持保留态度,但为了保持与 C 的兼容性(结构体赋值)和灵活性,保留了这一行为。 - 浅复制陷阱: 默认的成员复制对于指针成员来说只是浅复制 (只复制指针地址,不复制指向的内容)。
- 例子:
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_ptr和std::shared_ptr铺平了道路。
5.2. "灵巧引用"与 operator. 的遗憾
既然 -> 可以重载,大家自然希望 .(点号)也能重载,以便创建"灵巧引用"(比如一个看起来像引用但可以重新绑定、或者带有额外检查的对象)。
- 尝试与失败:
- 如果允许重载
operator.,就会破坏 C++ 的核心语义:obj.m的含义将不再确定。 - 为了模拟引用行为,程序员不得不写大量繁琐的"前推函数"(Forwarding functions),手动重载
+,-,[]等所有运算符来转发给内部指针。这非常令人讨厌。
- 如果允许重载
- 核心矛盾:
- 如果要支持
operator.,就必须打破a.m和(&a)->m的等价性。 - Stroustrup 陷入了两难:是保持语法的稳定性,还是提供极致的灵活性?
- 如果要支持
- 最终决定: 禁止重载
operator.。- 理由: 为了保证语言的可理解性和成员访问的确定性。Stroustrup 认为,如果需要类似引用的行为,现有的机制(虽然不完美)已经够用了,没必要为了极少数场景牺牲语言的清晰度。直到今天的 C++23/26,
operator.的重载仍在讨论中,尚未完全落地。
- 理由: 为了保证语言的可理解性和成员访问的确定性。Stroustrup 认为,如果需要类似引用的行为,现有的机制(虽然不完美)已经够用了,没必要为了极少数场景牺牲语言的清晰度。直到今天的 C++23/26,
5.3. ++ 前后缀的"古怪"区分法
这是一个关于**"向后兼容"与"语法洁癖"**博弈的经典案例。
-
问题: C++ 1.0 中,
++p和p++调用的是同一个函数operator++()。这导致无法区分前置(先加后用)和后置(先用后加)的语义,特别是对于迭代器来说,后置递增需要返回旧值,效率不同。 -
拒绝新关键字: Stroustrup 不想引入
prefix和postfix这种新关键字,因为这会破坏已有的代码,且显得累赘。 -
最终方案(Hack): 利用一个无用的
int参数来占位。cppX& operator++(); // 前置 ++:无参数 X operator++(int); // 后置 ++:有个 int 参数(虽然从来不用) -
评价: Stroustrup 自己承认这个方案**"既做作又微妙"**(contrived and subtle)。那个
int参数纯粹是为了让编译器能区分两个函数签名而强行加进去的"伪参数"。但这确实是在不引入新关键字前提下的最小修改方案。
5.4. 其他运算符的取舍
->*(指向成员的指针): 允许重载。理由是正交性------既然->和*都能重载,没理由禁止它们的组合。这在实现复杂的代理模式时很有用。,(逗号运算符): 允许重载。这主要是在 Margaret Ellis 的推动下加入的。虽然 Stroustrup 担心这会改变内置语义(比如破坏求值顺序的保证),但为了保持语言的一般性(Generality),最终还是允许了。不过在实际工程中,重载逗号运算符极少见,且通常被认为是糟糕的实践。
5.5 总结
本节展示了 C++ 设计哲学中的实用主义:
operator->的成功在于视角的转换(从二元变一元),开启了智能指针时代。operator.的失败在于对语言核心稳定性的坚守,避免了语法的过度灵活导致的混乱。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 允许用户定义运算符,但这导致了语法的极度复杂化。
- 优先级地狱: 如果你定义了
pow和product两个运算符,编译器怎么知道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++ 设计哲学中的克制:
- 兼容性高于便利性: 为了避免破坏 C 的指针语法,宁愿放弃直观的指数运算符。
- 显式优于隐式: 宁愿让你写
pow(a,b),也不愿引入可能导致歧义的**。 - 标准化优于个性化: 拒绝用户自定义运算符,防止语言分裂成无数种"方言"。
这些决策在当时可能让部分科学家或数学家感到失望,但从长远来看,它们维护了 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 1typedef enum { F, T } Boolean;- 甚至有人用
const int true = 1;
- 后果: 这些定义互不兼容。当你把两个使用了不同布尔定义的库链接在一起时,程序就会崩溃或行为诡异。
- 为什么很难加
bool?- 兼容性噩梦: Dag Brück 和 Sean Corfield 分析了数万行代码,发现现有的代码严重依赖
bool和int之间的自由转换。如果强行引入一个严格的布尔类型,会破坏海量现有代码。 - 信仰之争: Pascal/Algol 派认为没有布尔类型是荒谬的;C 语言原教旨主义者认为加布尔类型是画蛇添足。
- 兼容性噩梦: Dag Brück 和 Sean Corfield 分析了数万行代码,发现现有的代码严重依赖
7.5. 最终的妥协:特殊的整数类型
最终,ANSI/ISO 委员会接受了必须引入 bool 的论点,但为了兼容旧代码,设计了一个非常巧妙的**"混合体"**:
bool是内置类型: 不再是宏或枚举。- 关键字
true和false: 成为语言关键字。 - 双向隐式转换(关键点):
- 整数 → \rightarrow → 布尔: 非零值隐式转换为
true,零转换为false。 - 布尔 → \rightarrow → 整数:
true可以隐式转换为1,false转换为0。
- 整数 → \rightarrow → 布尔: 非零值隐式转换为
7.5.6 总结
这个设计保证了高度的向后兼容性 。旧代码里的 if (int_var) 依然有效,新代码里的 bool b = true 也很清晰。虽然这种"既是布尔又是整数"的设计在纯理论派看来不够完美,但它成功地统一了 C++ 社区的布尔用法,结束了多年的"方言混战"。