找回密码
立即注册
搜索
发回帖 发新帖

6456

积分

0

好友

815

主题
发表于 15 小时前 | 查看: 1| 回复: 0

油管上有个视频,标题相当炸裂——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++ 的路还得带着这些历史包袱继续往前走,不是吗?

以上。




上一篇:Claude访问限制放宽:Anthropic向认证安全人员开放三级权限
下一篇:C++26 template for 异构遍历:用扩展语句遍历 tuple 与结构体
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-10-11 19:32 , Processed in 0.073133 second(s), 39 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表