油管上有个视频,标题相当炸裂——The worst programming language of all time。大约半年前我第一次刷到它,当时只是快速跳着扫了一遍,心想这八成又是个满腹牢骚的初级开发者在发泄情绪。这几天在家又看到了这个视频,这一次我完整看完了全程。
如果抛开那种情绪化的语气,只看核心事实,会发现其中不少观点其实是站得住脚的。有些问题并不是单纯的个人偏好,而是写过几年 C++ 的开发者都遇到过的实际困扰。
今天就来聊聊视频里的那些观点。
变量初始化
让我们从几乎每一段程序的起点开始:创建一个变量。
在大多数编程语言中,这是一项极其简单且直接的操作。但在 C++ 里,人们为此写出了整本书,并把每一个边缘情况剖析得细致入微。
看看下面这些琳琅满目的初始化方式。它们是不同上下文中的独立片段,并不是一段可以直接放在一起编译的程序:
int f; // 若为普通局部变量,不设置初始值
static int f_static; // 静态存储期,零初始化
std::string s; // 调用默认构造函数
int* p = new int; // 动态分配,不设置初始值
int e{}; // 0
int e2 = {}; // 0
int* p2 = new int{}; // 0
int* q = new int(); // 也是 0
auto t = T{}; // 具体初始化行为取决于 T
int b(5); // 直接初始化
std::string text("hello"); // 直接初始化
T obj(a1, a2); // 直接初始化
auto ptr = new T(a1, a2); // 动态分配中的直接初始化
int x = static_cast<int>(3.5);
int y(int(5)); // 函数式转换用于初始化
int a = 5; // 拷贝初始化
std::string name = "hello"; // 拷贝初始化
T copy = other; // 拷贝初始化
int c{5}; // 直接列表初始化
int d = {5}; // 拷贝列表初始化
std::vector<int> v{1, 2, 3};
std::vector<int> v2 = {1, 2, 3};
std::vector<int> counts(10); // 10 个元素,全为 0
std::vector<int> values{10}; // 只有 1 个元素,值为 10
struct Point { int x, y; };
Point point{1, 2}; // 聚合初始化
Point point2 = {1, 2}; // 聚合初始化
int arr[3] = {1, 2, 3}; // 数组初始化
int arr2[3] = {}; // 全零数组
Point point3{.x = 1, .y = 2}; // C++20 指定成员初始化
Point point4{1}; // x=1,y=0
int& r = x; // 引用初始化
const int& cr = 5; // 绑定临时对象并延长其生命周期
int&& rr = 10; // 右值引用绑定
constexpr int n = 42;
const int m = foo();
// C++20:要求静态初始化,但不使 g 成为 const
constinit int g = bar();
struct S {
int x = 5;
int y{10};
S() : y{2}, x(1) {} // 按成员声明顺序初始化
};
auto [first, second] = std::pair{1, 2};
for (int value : arr) { } // 每轮也会初始化 value
如果我们细数这些语法,会发现存在很多不同的初始化形式,如 = v、(v)、{v}、= {v}、{}、= {},以及 new 中的括号。它们对应多种语义类别,而且语法与语义之间绝非一一对应。
根据上下文和类型的不同,同样的 {} 语法可能涉及值初始化、聚合初始化,或者通过列表初始化选择构造函数。每种初始化形式的细节都有微妙的不同。
最经典的莫过于 e 和 f 的区别:对于普通局部整数,仅仅是一对大括号的有无,就决定了变量是否保证为零。没有初始值的整数不能被当成一个合法的随机数使用,读取它也不能简单理解为"拿到了栈上的旧数据"。
简单事情复杂化
典型例子是:生成一个随机数。
在 C++11 之前,C++ 依赖从 C 继承来的 rand()。它返回一个范围在 0 到 RAND_MAX 之间的整数,而标准仅保证 RAND_MAX 至少为 32767。
于是,生成一个 1 到 100 之间的整数,很多人会这样写:
int value = std::rand() % 100 + 1;
这确实简单,但可能引入模偏差:某些数字出现的概率会比其他数字更高。
举个小一点的例子。假设生成器等概率产生 0 到 7,执行 % 3 后,原始值 0、3、6 都会得到结果 0;原始值 1、4、7 都会得到结果 1;只有原始值 2、5 会得到结果 2。
因此,结果 0 和 1 的概率都是 3/8,结果 2 的概率则是 2/8,并不是均匀分布。
rand() % 100 也是同样的道理:即使原始输出均匀,只要可能输出的总数量,也就是 RAND_MAX + 1,不能被 100 整除,取模后的结果就不均匀。最后的 + 1 只是把范围从 0~99 改成 1~100,并不能消除这种偏差。
而且,不同实现的 rand() 算法可能不同,即使使用相同的种子,也不能指望跨平台重现相同的序列。
因此,C++11 采用了借鉴自 Boost.Random 的设计,将随机数生成拆成几个部分:
- 随机数引擎: 负责生成原始伪随机序列,如
mt19937。
- 分布: 将引擎输出转换为指定范围和统计分布的结果。
- 种子来源: 提供初始化引擎所需的信息,如
std::random_device。
对应的代码是:
std::random_device rd;
std::mt19937 gen(rd());
std::uniform_int_distribution<int> dist(1, 100);
int value = dist(gen);
rd() 提供种子,gen 负责生成伪随机序列,dist 则负责得到 1 到 100 之间均匀分布的整数,而不是简单地对引擎输出取模。
类型转换
类型转换是另一个典型的例子。
C 风格转换只需要在括号里写上目标类型,但在 C++ 中,我们得到了一整套显式类型转换:static_cast、dynamic_cast、reinterpret_cast、const_cast,每一个都需要完整拼写。
double d = 3.9;
int i = static_cast<int>(d); // 3,舍去小数部分
// 前提:Base 是多态类型,Derived 继承 Base
Base* base = get_something();
if (Derived* derived = dynamic_cast<Derived*>(base)) {
derived->derived_only();
}
// uintptr_t 是可选类型,这段代码要求实现提供它
int x = 42;
auto address = reinterpret_cast<std::uintptr_t>(&x);
还有用于移除限定符的 const_cast:
int value = 42;
const int* view = &value;
// 原对象不是 const,可以修改
*const_cast<int*>(view) = 100;
const int fixed = 42;
// 修改原本就是 const 的对象,属于未定义行为
// *const_cast<int*>(&fixed) = 100;
C++20 又提供了 std::bit_cast:
float f = 1.0f;
static_assert(sizeof(float) == sizeof(std::uint32_t));
auto bits = std::bit_cast<std::uint32_t>(f);
不过,std::bit_cast 是标准库函数模板,不是语言级转换运算符。它要求源类型和目标类型大小相同,并且都可平凡复制,也不能简单概括成"任何位级转换都安全"。
C 风格的 (T)expr 可以改变数值、重新解释指针、移除 const,但语法完全相同,代码审核时很难一眼看出转换的意图。
C++ 将这些操作拆成不同的命名转换,让意图更明确,也方便搜索项目中的 const_cast 和 reinterpret_cast。
但命名转换并不等于安全转换。static_cast 不会自动检查所有范围和类型问题,reinterpret_cast 也不保证后续访问合法。冗长的名字没有消除风险,只是让风险更容易被看见。
为了兼容旧代码,C 风格转换仍被保留。于是,C++ 同时提供了新旧两套写法,也把选择和理解它们的责任交给了使用者。
一个 static,多种含义
在 C++ 中,同一个关键字往往承担着好几种职责。
以 static 为例,这个关键字究竟有多少种含义?
- 在函数内部:创建一个在函数调用之间保持生存期的变量(静态局部变量)。
- 在类内部:声明一个属于类本身而非具体实例的成员。
- 在文件作用域:使变量或函数仅在当前
.cpp 文件内可见(内部链接 / Internal Linkage)。
- 在 C++11 之后:函数内部的
static 变量初始化还隐式包含了线程安全的轻量级互斥锁保证!
Logger& logger() {
static Logger instance;
return instance;
}
在编译器底层,为了保证这行代码的线程安全,生成的代码大致相当于:
Logger& logger() {
// 快速通道:检测 Guard 标记位
if ((reinterpret_cast<volatile char&>(__guard_for_instance)) == 0) {
// 慢速通道:获取互斥锁
if (__cxa_guard_acquire(&__guard_for_instance)) {
try {
::new (&$instance_storage) Logger(); // 构造对象
__cxa_guard_release(&__guard_for_instance);
__cxa_atexit(&destroy_logger, ...); // 注册析构函数
} catch (...) {
__cxa_guard_abort(&__guard_for_instance);
throw;
}
}
}
return reinterpret_cast<Logger&>(__instance_storage);
}
在 C 语言中,static 就已经用于控制存储期和内部链接,C++ 又用它表示类的静态成员。
为不同功能引入新关键字,看起来更清晰,却可能破坏旧代码:已有程序可能已经把这些名字用作变量名或函数名。
于是,复用已有关键字减少了兼容性冲突,却让使用者需要根据上下文理解不同含义。
总结
C++ 的许多复杂之处,都与长期坚持兼容旧代码、保留底层控制能力有关。新功能不断加入,旧规则却很难退出,于是复杂性逐渐积累。
这不能说明所有设计都合理,但也正是这种坚持,让大量成熟代码和工具得以延续。
所以,视频作者的吐槽可以理解。该改进的问题仍然要改进,只是 C++ 的路还得带着这些历史包袱继续往前走,不是吗?
以上。