跳转到正文
zeno's blog
返回

现代 C++(一):C++11/14 为什么是现代 C++ 的起点

专题: 现代 C++

Table of contents

Open Table of contents

TL;DR

C++11/14 的核心升级不是加了几个语法糖,而是把值语义 + RAII + 移动 + lambda 组合成新范式:资源用智能指针自动管理、返回大对象用移动不用拷贝、行为用 lambda 就地编写——日常 C++ 的认知负载一次性大幅下降,Bjarne 本人说”像一门新语言”不是营销话术。


1. C++03 的痛点:为什么需要一次”重新定义”

C++03 时代的 idiomatic code 与 C++11 后几乎是两种语言。典型痛点:

C++11 把这些痛点一次性扫掉。核心是四块:RAII + 智能指针、移动语义、lambda、auto/nullptr/统一初始化


2. RAII:C++ 的灵魂资源管理范式

RAII = Resource Acquisition Is Initialization。把资源生命周期绑定到对象生命周期——构造时获取,析构时释放。C++ 保证对象离开作用域一定调用析构(异常展开也不例外),所以资源一定被释放。

vs GC

vs try-finally:try-finally 把释放逻辑散在每个调用点。RAII 把释放封装进类型本身,使用者只写声明。语言级别的关注点分离

异常安全三种保证(Abrahams guarantees,来源:Herb Sutter《Exceptional C++》):

  1. basic:异常后状态一致,无泄漏
  2. strong:异常后回滚到操作前(copy-and-swap 模式)
  3. nothrow:保证不抛(析构、swap、移动构造应做到)

RAII 是实现这三种保证的基石。没有 RAII,写异常安全代码几乎不可能。


3. 智能指针体系

3.1 std::unique_ptr:独占所有权,零开销

// g++ -std=c++14
#include <memory>

struct Widget { void hello(); };

std::unique_ptr<Widget> make() {
    return std::make_unique<Widget>();  // C++14 引入
}

auto p = make();
// auto q = p;              // 编译错误:不可拷贝
auto q = std::move(p);      // 显式转移所有权,p 变为 nullptr

sizeof(unique_ptr<T>) 等于裸指针(无自定义 deleter 时),编译后与手写 T* + delete 完全等价。彻底替代 auto_ptr

为什么 make_unique 是 C++14 才有:委员会疏忽(Herb Sutter N3656 提案原话)。用它的理由不只是简洁,更是异常安全——C++17 之前 func(unique_ptr<A>(new A), unique_ptr<B>(new B)) 的参数求值顺序未指定,若 new A 成功后 new B 抛异常,A 就泄漏了。make_unique 把构造和包装绑在一起堵住这个裂缝。

3.2 std::shared_ptr:引用计数共享

控制块(control block)结构shared_ptr 不是一个指针,是两个指针——一个指向 managed object,一个指向控制块。控制块至少含:strong_count、weak_count、deleter、allocator。

两种构造方式的内存布局差异(深入点):

std::shared_ptr<T> p1(new T);          // 方式 A:两次分配
// 布局: [T object]   [control block + 裸指针指向 T]

auto p2 = std::make_shared<T>();        // 方式 B:一次分配
// 布局: [control block + T object 连续]

make_shared 的好处:一次 malloc,省分配开销 + 减少堆碎片 + 改善 cache 局部性;异常安全同前。

make_shared 的隐藏代价(面试加分点):因为 T 对象和控制块共享同一块内存,只有 weak_count 也归零时整块内存才能释放。如果有长期存活的 weak_ptr,对象那块内存(可能很大)会延迟释放,即使 strong_count 早已归零。shared_ptr<T>(new T) 不存在这个问题——对象内存和控制块分开,strong_count 归零立即释放对象。

引用计数的原子操作代价:strong_count 和 weak_count 的增减必须原子(支持跨线程拷贝)。x86 上一次原子自增约 10–20 个周期,比非原子慢 10 倍以上。热路径上频繁拷贝 shared_ptr 是常见性能 bug。正确做法是传 const shared_ptr<T>& 或直接传 T&

注意:引用计数的线程安全 不等于 被指向对象的线程安全。两个线程同时写同一个 T 对象仍需加锁。

3.3 std::weak_ptr:打破循环引用

struct Node {
    std::shared_ptr<Node> child;
    std::weak_ptr<Node>   parent;  // 必须 weak,否则循环引用
};

// 访问 parent 必须 lock
if (auto p = c->parent.lock()) {
    // lock() 是原子操作:检查 strong_count 非 0 并原子自增
    // 返回的 shared_ptr 期间保证对象存活
}

lock() 的原子性解决了”检查指针有效”和”使用指针”之间的 TOCTOU 竞态——这是 weak_ptr 存在的核心理由。

3.4 enable_shared_from_this 陷阱

struct Bad : std::enable_shared_from_this<Bad> {
    std::shared_ptr<Bad> get() { return shared_from_this(); }
};

Bad b;
auto p = b.get();               // UB:栈对象没有控制块

auto s = std::make_shared<Bad>();
auto q = s->get();              // OK

原理:enable_shared_from_this 内部存一个 weak_ptr<T>,只有当对象由 shared_ptr 管理时才被初始化。栈对象和构造函数内(控制块尚未绑定)都无法使用。


4. 移动语义与右值引用

4.1 值类别(Value Categories)

C++11 重新定义了值类别,形成 5 分类:

       expression
       /        \
    glvalue   rvalue
    /   \      /  \
 lvalue  xvalue   prvalue

判断窍门:能取地址的是 lvalue,不能取地址的是 prvalue,std::move(x) 主动转成可移动的是 xvalue。

4.2 std::move 本质是 cast

// libstdc++ 实现(简化)
template <typename T>
constexpr typename std::remove_reference<T>::type&&
move(T&& t) noexcept {
    return static_cast<typename std::remove_reference<T>::type&&>(t);
}

std::move 不移动任何东西。 它只是一个 static_cast<T&&>。真正的移动在移动构造/移动赋值里完成。

4.3 移动构造为什么必须 noexcept(面试核心)

std::vector 扩容要提供 strong exception guarantee:扩容失败必须回滚到原状态。扩容过程:分配新内存 → 把旧元素搬过去 → 释放旧内存。第二步用拷贝还是移动决定了正确性:

STL 的规则vector::push_back 扩容时用 std::move_if_noexcept——只有移动构造是 noexcept 才移动,否则退化为拷贝

class Widget {
public:
    Widget(Widget&&) noexcept;              // 关键:noexcept
    Widget& operator=(Widget&&) noexcept;
};

漏写 noexcept 的后果是 vector 扩容悄无声息地退化为拷贝,性能暴跌。Scott Meyers《Effective Modern C++》Item 14 专门强调。

4.4 完美转发与引用折叠

template <typename T, typename... Args>
std::unique_ptr<T> make_unique(Args&&... args) {
    return std::unique_ptr<T>(new T(std::forward<Args>(args)...));
}

引用折叠规则(C++11 引入):

当模板参数 T 遇到 T&&(forwarding reference,只有在类型推导中才是万能引用):

std::forward<T> 为什么需要显式模板参数:forward 的签名是

template <typename T>
T&& forward(typename std::remove_reference<T>::type& t) noexcept;

参数类型里 T:: 左边,是 non-deduced context,不参与推导。如果让它自动推导,输入永远是有名字的变量(lvalue),T 会被推成 lvalue-reference,forward 就废了。显式传 T 是把”原始值类别信息”从调用点带进来的唯一方式。

记忆法std::move 无条件转 rvalue;std::forward<T> 按 T 有条件转 rvalue。


5. Lambda 表达式

5.1 基础与编译器视角

int x = 10;
auto f1 = [](int a, int b) { return a + b; };   // 无捕获
auto f2 = [x](int a) { return a + x; };          // 按值捕获
auto f3 = [&x](int a) { x += a; return x; };     // 按引用捕获
auto f4 = [=](int a) { return a + x; };          // 按值捕获所有
auto f5 = [&](int a) { x += a; return x; };      // 按引用捕获所有

底层实现:lambda 等价于编译器生成的匿名 class,捕获变量成为成员,operator() 就是函数体:

// [x](int a) { return a + x; } 大致等价于:
struct __lambda_1 {
    int x;
    int operator()(int a) const { return a + x; }
};

无捕获的 lambda 可隐式转函数指针——能传给 C 风格回调 API。

5.2 C++14 两个关键升级

// 1. 泛型 lambda(参数用 auto)
auto add = [](auto a, auto b) { return a + b; };
// 编译器生成的 class 有 template <A, B> operator()(A, B)

// 2. 初始化捕获(generalized capture)
auto p = std::make_unique<int>(42);
auto lam = [ptr = std::move(p)]() { return *ptr; };
// 解决了 C++11 无法把 move-only 类型放进 lambda 的硬伤

5.3 三个致命陷阱

陷阱 1:按引用捕获局部变量 → 悬空

std::function<int()> make_counter() {
    int count = 0;
    return [&count]() { return ++count; };  // 返回后 count 销毁,UB
}

陷阱 2:异步场景捕获 this → 悬空(asio/gRPC 生产高频坑)

class Session {
    int id_;
public:
    void start() {
        submit_task([this]() {
            std::cout << id_;  // Session 销毁后访问 → UB
        });
    }
};

标准做法是 self-owning callback

class Session : public std::enable_shared_from_this<Session> {
public:
    void start() {
        auto self = shared_from_this();
        submit_task([self, this]() { std::cout << id_; });
    }
};

这是 asio / gRPC-cpp 里到处可见的模式。

陷阱 3:[=] 不捕获 this——但会隐式捕获 this

class Foo {
    int x_ = 10;
    void bar() {
        auto lam = [=]() { return x_; };
        // C++11/14: [=] 隐式捕获 this(不是 x_ 的拷贝!)
        // 访问 x_ 实际是 this->x_,this 悬空一样 UB
    }
};

C++20 deprecated 了 [=]this 的隐式捕获,要求显式 [=, this][=, *this]。C++11/14 时代这是大坑。


6. auto 与 decltype

6.1 auto 的推导规则

auto 的规则与模板参数推导一致(一个例外:initializer_list)。

int x = 42;
const int cx = x;
const int& rx = cx;

auto a1 = x;     // int(丢 const/ref)
auto a2 = cx;    // int(顶层 const 剥掉)
auto a3 = rx;    // int(丢 const 和 ref)

auto& a4 = cx;       // const int&(显式加 & 保留 const)
const auto& a5 = x;  // const int&

auto a6 = { 1, 2, 3 };  // std::initializer_list<int>(auto 的特殊规则)

陷阱template<typename T> void f(T)f({1,2,3}) 会推导失败——auto 与模板推导唯一的区别。

6.2 decltype 与 decltype(auto)

decltype(expr) 规则:

int x = 0;
decltype(x)   d1 = 0;   // int(变量名规则)
decltype((x)) d2 = x;   // int&(加了括号,成 lvalue 表达式)—— 著名陷阱

decltype(auto) 是 C++14 引入:像 auto 一样从初始化器推导,但用 decltype 规则(保留引用和 const)。完美转发返回类型必用:

template <typename Container, typename Index>
decltype(auto) at(Container&& c, Index i) {
    return std::forward<Container>(c)[i];  // 保留引用
}

auto 会丢引用,at(v, 0) = 42 就不能用。


7. 统一初始化、nullptr、enum class

7.1 {} 与 most vexing parse

Widget w();     // 陷阱:这是函数声明
Widget w{};     // 正确:默认构造

int j{3.14};    // 编译错误:narrowing conversion

// initializer_list 优先级陷阱
std::vector<int> v1(10, 5);   // 10 个 5
std::vector<int> v2{10, 5};   // 2 个元素 10 和 5(优先 initializer_list 构造函数)

Scott Meyers EMC++ Item 7 专门警告:{}() 不是完全等价,当一个类同时有 initializer_list 构造函数时,{} 会优先走它。

7.2 nullptr

void f(int); void f(char*);
f(0);        // f(int)
f(NULL);     // 通常走 f(int)(NULL 往往是 0)
f(nullptr);  // f(char*),意图明确

nullptr 类型是 std::nullptr_t,可隐式转任何指针,不能转整型。彻底消除 NULL 的重载歧义。

7.3 enum class

enum class Color : uint8_t { Red, Green, Blue };
Color c = Color::Red;
// int x = c;          // 编译错误
int x = static_cast<int>(c);

三个好处:作用域隔离Color::Red 不污染命名空间)、禁止隐式转 int可指定底层类型可前向声明(老 enum 不行因为大小未定)。


8. constexpr:从运行期搬到编译期

// C++11:函数体必须一个 return
constexpr int factorial(int n) {
    return n <= 1 ? 1 : n * factorial(n - 1);
}

// C++14:放宽,可含循环
constexpr int factorial_14(int n) {
    int result = 1;
    for (int i = 2; i <= n; ++i) result *= i;
    return result;
}

constexpr int f = factorial_14(5);
static_assert(f == 120, "");

陷阱constexpr 函数不是必须编译期求值,只是可以。用运行期参数调就是普通函数。只有在需要常量表达式的上下文(模板参数、数组大小、static_assertconstexpr 变量初始化)才强制编译期求值。


9. 变参模板与 C++14 小改进

// C++11 变参模板:递归展开
void print() {}
template <typename T, typename... Rest>
void print(const T& first, const Rest&... rest) {
    std::cout << first << " ";
    print(rest...);
}

// C++14 其他
int mask = 0b1010'1100;          // 二进制字面量 + 数字分隔符
int million = 1'000'000;
auto compute(int x) { return x * 2; }  // 返回类型推导
template <typename T> constexpr T pi = T(3.14159);  // 变量模板
int old = std::exchange(val, new_val);  // 原子替换返回旧值(常用于移动构造)

10. 常见陷阱总表

陷阱为什么是坑如何避免
按引用捕获局部变量lambda 出作用域后悬空按值捕获,或返回值拷贝
异步捕获 this对象销毁后回调访问悬空shared_from_this + self-owning callback
[=] 隐式捕获 this对象销毁一样 UB显式 [=, *this](C++17)或 shared_from_this
移动构造没写 noexceptvector 扩容退化为拷贝总是写 noexcept
shared_ptr 热路径拷贝原子自增开销大const shared_ptr&T&
make_shared + 大对象 + 长存 weak内存延迟释放shared_ptr<T>(new T)
shared_from_this 栈对象 / 构造函数控制块未绑定必须由 make_shared 创建
Widget w();most vexing parse 声明函数Widget w{};
vector<int> v{10, 5}被 initializer_list 抢走明确意图用 ()
decltype((x)) 多一对括号变 lvalue 表达式 → T&记住规则,测试推导结果

11. 生产 Checklist


关键信息来源

置信度说明:上述 C++11/14 语义都是 ISO 标准稳定内容,可信度高。具体标准条款号需查 N3337/N4140 对应章节(需验证)。


分享这篇文章:

上一篇
Go 基础:interface 的底层实现-eface 与 iface
下一篇
Go 基础:错误处理与 Errors Are Values