📑 C++ 演进系列总览 | C++11 | C++14 | C++17 | C++20 | C++23 | C++26

C++11 是 C++ 历史上最重要的标准之一,它彻底改变了 C++ 的编程方式,被称为"现代 C++"的起点。在 C++11 之前,C++98/03 沿用了十余年,语言表达力落后于时代。C++11 一次性引入了自动类型推导、Lambda、智能指针、移动语义、并发支持等重量级特性,使 C++ 摆脱了"带类的 C"的旧面貌,转向类型安全、资源安全、表达力强的现代风格。

自动类型推导(auto)

auto 让编译器从初始化表达式推导变量类型,取代冗长难读的类型名(如 std::vector<std::string>::const_iterator)。它不是动态类型–类型在编译期就已确定,只是省去手写。主要收益是可读性(关注"做什么"而非"是什么类型")和可维护性(重构时类型自动跟随)。

推导规则

auto 的推导规则复用模板实参推导,默认按值:丢弃引用与顶层 const

1
2
3
4
5
6
7
8
9
int i = 42;
const int ci = i;
int& ri = i;

auto a = i; // int:丢弃引用
auto b = ci; // int:丢弃顶层 const
auto c = ri; // int:引用被丢弃
auto& d = ci; // const int&:显式 & 时 const 保留
auto* e = &ci; // const int*

要从容器取可修改元素需写 auto&,只读用 const auto&。数组与函数名在按值推导时退化为指针:

1
2
3
int arr[3] = {1, 2, 3};
auto f = arr; // int*:数组退化
auto& g = arr; // int(&)[3]:引用不退化

auto&& 万能引用

auto&& 不是右值引用:经过引用折叠,左值右值都能绑,且保留 const。

1
2
auto&& r1 = 42;  // int&&:绑定右值
auto&& r2 = i; // int&:折叠成左值引用

折叠规则:&&& 任意组合,只要出现 & 结果就是 &;两个 && 才是 &&

尾置返回类型

C++11 还允许把返回类型放到参数表之后,让返回类型可以引用参数名:

1
2
3
4
template<typename A, typename B>
auto add(A a, B b) -> decltype(a + b) {
return a + b;
}

代理类型陷阱

1
2
3
std::vector<bool> v{true, false};
auto x = v[0]; // 不是 bool,是代理类
// std::vector<bool>::reference

vector<bool>operator[] 返回按位代理对象,auto 会原样接住;容器变动后代理可能悬挂。确实要 bool 时写 bool x = v[0]; 强制转换。

使用陷阱

auto x = {1, 2, 3} 推导出 std::initializer_list<int>,与预期可能不符;函数返回 auto(返回类型推导)要到 C++14 才支持。auto 适合类型名长、明显或无关紧要的场合,类型对理解逻辑重要时仍应显式写出。

Lambda 表达式

Lambda 让你在用到可调用对象的地方就地定义它,无需另起一个函数或仿函数。这在传给 STL 算法(std::sortstd::find_ifstd::for_each)时尤其有价值–以前要么写函数指针、要么定义仿函数类,都很啰嗦。

语法为 [捕获](参数) -> 返回类型 { 函数体 },返回类型可省略(由 return 推导)。捕获决定了 lambda 如何"看见"外部变量:值捕获拷贝一份、引用捕获持有引用。[=]/[&] 是按值/按引用捕获所有外部变量的简写。

1
2
3
4
5
6
7
8
9
10
11
12
// 基本语法
auto lambda = [](int x, int y) { return x + y; };
int result = lambda(3, 4); // 7

// 捕获变量
int a = 10;
auto captureByValue = [a](int x) { return x + a; };
auto captureByRef = [&a](int x) { a += x; };

// 捕获所有
auto captureAll = [=](int x) { return x + a; };
auto captureAllRef = [&](int x) { a += x; };

闭包类型是什么

每个 lambda 表达式都会让编译器生成一个唯一的匿名类(闭包类型):捕获的变量成为其数据成员,函数体成为 operator(),且默认是 const 成员函数。这就是值捕获的变量在 lambda 内默认改不了的原因。无捕获的 lambda 可隐式转换为函数指针,能与 C API 交互:

1
2
int (*fp)(int, int) =
[](int a, int b) { return a + b; };

mutable:修改值捕获的拷贝

1
2
3
4
5
int counter = 0;
auto inc = [counter]() mutable {
return ++counter; // 改的是闭包内的拷贝
};
inc(); inc(); // counter 本体仍是 0

捕获 this

成员函数里定义的 lambda 常需访问成员,[this] 按指针捕获:

1
2
3
4
5
6
struct Widget {
int value = 42;
auto make() {
return [this] { return value; };
}
};

闭包里存的是 this 指针的拷贝,Widget 销毁后再调用是未定义行为。需要整个对象拷贝时用 C++20 的 [*this]

移动捕获:C++11 做不到

捕获列表只能拷贝或引用,move-only 对象(如 unique_ptr)捕获不了。C++11 的变通是改用 shared_ptr;C++14 的初始化捕获才是正解:

1
2
3
auto p = std::make_unique<int>(42);
// C++14 起可写:
// auto f = [p = std::move(p)] { return *p; };

注意:引用捕获([&])要小心 lambda 生命周期超出被引用变量–如 lambda 存入容器后异步执行,引用的对象可能已销毁,导致悬挂引用。默认优先值捕获;确需引用且能保证生命周期时再用 [&]

智能指针

智能指针用 RAII 管理动态内存,析构自动释放,从根本上治理裸 new/delete 的内存泄漏与异常安全问题。C++11 引入了三种:

  • unique_ptr:独占所有权,不可拷贝、只能移动。零开销抽象(大小与裸指针相同,无引用计数开销)。默认选择。
  • shared_ptr:共享所有权,引用计数。多个 shared_ptr 可指向同一对象,最后一个销毁时释放。有原子计数开销。
  • weak_ptr:对 shared_ptr 的弱引用,不增加计数。用于打破循环引用、观察但不影响生命周期。
1
2
3
4
5
6
7
8
9
10
11
12
#include <memory>

// unique_ptr:独占所有权
std::unique_ptr<int> ptr1 = std::make_unique<int>(42);
// std::unique_ptr<int> ptr2 = ptr1; // 编译错误

// shared_ptr:共享所有权
std::shared_ptr<int> ptr3 = std::make_shared<int>(42);
std::shared_ptr<int> ptr4 = ptr3; // 引用计数 +1

// weak_ptr:弱引用,不增加引用计数
std::weak_ptr<int> ptr5 = ptr3;

make_unique/make_shared 优先于裸 new:一次分配(make_shared 把对象和控制块合并到一次分配)、异常安全(避免 f(unique_ptr(new A), unique_ptr(new B)) 求值顺序导致的泄漏)。注意 make_unique 实际是 C++14 才加入的,C++11 可手写一份或暂时退回 new

weak_ptr 使用前需 lock() 提升为 shared_ptr 检查对象是否仍存活。shared_ptr 控制块的原子操作有开销,热路径上应优先 unique_ptr

unique_ptr 细节

1
2
3
4
5
6
7
8
// 自定义删除器:管理任意资源
std::unique_ptr<FILE, int(*)(FILE*)>
file(std::fopen("a.txt", "r"),
&std::fclose);

// 数组版本(C++11 就有)
std::unique_ptr<int[]> buf(new int[128]);
buf[0] = 42;

无状态删除器不占额外空间;带状态的删除器(如函数指针)会使 unique_ptr 变大。

shared_ptr 的控制块

每个被共享的对象伴随一个控制块,含两个计数:强计数shared_ptr 个数,归零时析构对象)与弱计数weak_ptr 个数加一,归零才释放控制块)。make_shared 把对象与控制块合并为一次分配,代价是只要还有 weak_ptr 存活,整块内存(含已析构对象占用的部分)都不能归还。

enable_shared_from_this

对象内部要获取指向自身的 shared_ptr(典型:注册异步回调)时,直接持有成员 shared_ptr 会造成两个独立控制块、双重释放;正确做法是继承 enable_shared_from_this

1
2
3
4
5
6
7
8
9
struct Task
: std::enable_shared_from_this<Task> {
void defer() {
// 与外部 shared_ptr 共享同一控制块
auto self = shared_from_this();
queue.push([self] { self->run(); });
}
void run();
};

weak_ptr 的正确用法

1
2
3
4
5
std::weak_ptr<int> w = ptr3;
if (auto sp = w.lock()) {
// lock 原子地提升;sp 存活期间
// 对象保证有效
}

右值引用与移动语义

移动语义让"资源转移"成为一等操作:把昂贵的拷贝(如 vector 拷贝整个堆缓冲)换成廉价的指针交换。它区分了"拷贝"(复制底层资源,源对象不变)与"移动"(转移资源所有权,源对象置空)。

核心机制是右值引用 T&&——绑定到即将销毁的临时对象(右值)。std::move 是个无操作转换,把左值强转为右值引用,表示"我不再需要这个值,你可以掏空它"。移动构造/移动赋值用 T&& 接管资源后把源对象置为有效但未指定状态(通常置空)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 移动构造函数
class MyClass {
public:
MyClass(MyClass&& other) noexcept
: data_(std::move(other.data_)) {
other.data_ = nullptr;
}

// 移动赋值运算符
MyClass& operator=(MyClass&& other) noexcept {
if (this != &other) {
delete data_;
data_ = std::move(other.data_);
other.data_ = nullptr;
}
return *this;
}

private:
int* data_;
};

// std::move
std::string str1 = "Hello";
std::string str2 = std::move(str1); // str1 现在为空

关键点:移动操作应标记 noexcept——vector 扩容时只有 noexcept 移动才会真正移动,否则退化为拷贝以保证强异常安全。std::move 后不要再使用源对象(除赋值或销毁外)。返回局部变量时不要 std::move——会阻止 RVO。现代代码里手写移动构造已少见,编译器生成的版本(零规则)通常足够。

转发引用与引用折叠

T&& 出现在推导语境时是转发引用(不是右值引用):实参为左值时 T 被推导为 X&,折叠成 X&;实参为右值时 TX,得到 X&&

1
2
3
4
template<typename T>
void wrapper(T&& x) { // 转发引用
process(std::forward<T>(x)); // 保持值类别
}

注意 void f(Widget&&) 没有推导,就是普通的右值引用;auto&& 同样是转发引用。

std::forward 与 std::move 的分工

两者都只是类型转换、不生成任何代码:std::move 无条件转成右值;std::forward<T> 有条件–T 是左值引用就转左值,否则转右值。move 用于你确定要掏空的对象,forward 用于模板中对转发引用的透传,转发时误用 move 会把左值实参也掏空。

moved-from 状态

被移动的对象处于"有效但未指定"状态:可以安全析构、可以赋新值,但不能假设它的内容。惯用法:移动之后只做销毁或重新赋值。

std::function 和 std::bind

std::function 是可调用对象的类型擦除包装器:能统一存储函数指针、仿函数、Lambda、成员函数指针等任何可调用对象,只要签名匹配。这让回调、事件处理、策略注入等场景可以把"任意可调用物"当一等对象传递。

代价是有一点运行期开销(类型擦除、可能的堆分配、间接调用),热路径上不如模板或直接 Lambda。C++14 起 Lambda 捕获初始化、C++23 std::move_only_function 进一步削弱了它的地位,但 std::function 仍是"存储任意回调"的标准选择。

实现机制

std::function 内部用一个小缓冲区(通常两三个指针大小)做 SBO(小缓冲优化):闭包能塞进去就直接存对象里,塞不下才堆分配。调用走一次虚表/函数指针间接跳转,捕获大的 lambda 还可能堆分配。签名匹配是"兼容"而非精确:返回类型可隐式转换、实参按值传递时发生拷贝。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <functional>

int add(int a, int b) { return a + b; }

// std::function:可调用对象的包装器
std::function<int(int, int)> func = add;
int result = func(3, 4); // 7

// std::bind:绑定参数
auto add5 = std::bind(add, std::placeholders::_1, 5);
int result2 = add5(10); // 15

// 绑定成员函数
struct Calculator {
int multiply(int a, int b) { return a * b; }
};
Calculator calc;
auto multiplyBy2 = std::bind(&Calculator::multiply, &calc, std::placeholders::_1, 2);
int result3 = multiplyBy2(5); // 10

std::bind 的经典坑:重载与转发

1
2
3
4
5
6
7
8
9
// 重载函数无法直接绑定:不知道选哪个
void f(int);
void f(double);
// auto b = std::bind(f, _1); // 编译错误
auto b = std::bind(
static_cast<void(*)(int)>(f), std::placeholders::_1);

// bind 对绑定参数是拷贝语义,move-only
// 对象(如 unique_ptr)传不进去

成员函数绑定还需要 std::mem_fn&Class::method 加对象指针,参数占位符的顺序也容易写错。Lambda 没有这些问题。

std::unordered_map 和 std::unordered_set

基于哈希表的容器,提供平均 O(1) 的查找/插入/删除。与之相对,std::map/std::set 基于红黑树,是有序的 O(log n)。选择标准:需要按键排序遍历时用 map/set,只需要快速查找且不关心顺序时用 unordered_map/unordered_set

内部结构:桶数组 + 链表

unordered_map 是开链哈希表:一个桶数组,每个桶挂一条单链表(GCC 实现里整张表共用一条大链表,桶只存首尾指针)。负载因子 size / bucket_count 超过 max_load_factor(默认 1.0)时触发 rehash–重新分配更大的桶数组并重挂所有节点。

1
2
3
std::unordered_map<int, int> m;
m.reserve(1000); // 预留,避免中途 rehash
m.max_load_factor(0.75); // 调低负载因子

rehash 的连带后果:节点本身不搬家(指针稳定),但桶的归属关系变化导致迭代器全部失效map 则相反–插入删除只失效指向被删元素的迭代器。

自定义键类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct Point {
int x, y;
bool operator==(const Point& o) const {
return x == o.x && y == o.y;
}
};

namespace std {
template<>
struct hash<Point> {
size_t operator()(const Point& p) const {
return hash<int>{}(p.x)
^ (hash<int>{}(p.y) << 1);
}
};
} // namespace std

要求:hashoperator== 语义一致–相等的键必须有相同哈希值,否则同一键会掉进不同的桶,永远查不到。异或组合字段是常见写法,但注意两字段相等时异或结果为 0,混入移位可缓解。

哈希容器的其余代价:最坏情况 O(n)(哈希冲突,恶意输入可能触发)、无序遍历。若想保留插入顺序可配合存一个 vector 记录键的顺序。operator[] 在键不存在时会插入默认值–只想查找时用 findat(后者不存在时抛异常)。

std::tuple

std::tuple 是异质值的固定大小容器:把不同类型的若干值打包成一个对象。相比自定义结构体,它无需命名字段、可用模板泛化处理任意类型组合,适合函数多返回值、临时聚合、编译期元编程。

访问用 std::get<I>(t)(按索引)或 std::get<T>(t)(按类型,要求唯一)。C++17 结构化绑定让它解包更优雅。std::tuple_cat 拼接多个 tuple。

元组工具

1
2
3
4
5
auto t = std::make_tuple(42, 3.14, "hello");

std::tuple_size<decltype(t)>::value; // 3:元素个数
std::tuple_element<0, decltype(t)>::type; // int
// std::get<I>(t) 按索引,std::get<T>(t) 按类型

元组比较是字典序std::tie 把左值引用打包成 tuple,做"多字段比较"很方便:

1
2
3
4
5
struct Record { int id; std::string name; };
bool byIdThenName(const Record& a, const Record& b) {
return std::tie(a.id, a.name)
< std::tie(b.id, b.name);
}

std::tie 还能做多重回赋:std::tie(a, b) = std::make_pair(b, a); 交换两个变量(忽略某个返回值用 std::ignore)。

权衡:需要语义明确字段名时还是用结构体–point.xstd::get<0>(point) 可读得多。tuple 适合类型组合在编译期才能确定、或临时聚合无需命名的场合。

std::chrono

std::chrono 是类型安全的时间库,用编译期类型系统区分时间点(time_point)、时长(duration)与时钟(clock)。它消除了 C 风格 time_t/gettimeofday 的无类型算术与可移植性问题——不同单位的时长(秒、毫秒、纳秒)是不同类型,混算会编译报错而非静默出错。

三个时钟:system_clock(挂钟时间,可转 time_t)、steady_clock(单调递增,适合计时)、high_resolution_clock(最小分辨率,通常即 steady_clock)。C++14 起的 chrono_literals100ms2s)让字面量更直观。

duration 的表示

duration<Rep, Period> 是"计数值 + 单位比例":millisecondsduration<int64, milli>nanosecondsduration<int64, nano>。单位不同的 duration 相加时自动向更精细的单位对齐;反过来 duration_cast 往粗单位转时截断(毫秒转秒丢小数)。

1
2
3
4
using namespace std::chrono;
auto a = 1500ms + 1s; // 2500ms:对齐到毫秒
// seconds s = 1500ms; // 编译错误:会丢精度
seconds s = duration_cast<seconds>(1500ms); // 1s

C++17 起非截断语境有 floor/ceil/round 可选。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <chrono>

// 时间点
auto now = std::chrono::system_clock::now();

// duration
using namespace std::chrono_literals;
auto duration = 100ms; // 100 毫秒
auto seconds = 2s; // 2 秒
auto minutes = 5min; // 5 分钟

// 睡眠
std::this_thread::sleep_for(500ms);

// 计时
auto start = std::chrono::high_resolution_clock::now();
// ... 执行操作
auto end = std::chrono::high_resolution_clock::now();
auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);

测量代码耗时优先用 steady_clock(不受系统时间调整影响);跨进程/持久化时间用 system_clockduration_cast 在单位不整除时截断。

常见计时模板

1
2
3
4
5
6
7
8
template<typename F>
auto measure(F&& f) {
auto t0 = std::chrono::steady_clock::now();
f();
return std::chrono::steady_clock::now() - t0;
// 返回类型是纳秒级 duration,
// 需要时再 duration_cast 到毫秒/微秒
}

注意 high_resolution_clock 的单位是实现定义的(可能纳秒也可能别的),benchmark 应固定用 steady_clock

std::array

std::array 是 C 风格数组的零开销包装:大小在编译期固定、栈分配、不退化为指针、提供 STL 容器接口(size()begin()/end()at()front()/back())。它取代了既不安全(退化为指针、无边界检查)又缺少接口的裸数组 T[]

std::vector 的区别:array 大小固定、栈分配、无动态扩容开销;vector 大小可变、堆分配。已知大小且不需要增删时用 array,否则用 vector

为什么说"零开销"

std::array<T, N> 内部唯一的数据成员就是 T data[N]:没有容量、没有指针、没有析构逻辑。std::to_raw(a)(C++17)直接取出内部数组;std::get<I> 走的是编译期检查的元素访问。它还是聚合体,所以花括号初始化走聚合规则(未列出的元素补零)。

一个注意点:std::array<int, 0> a; 是合法的空数组,但 a.begin() == a.end()front()/back() 是未定义行为。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <array>

// 固定大小数组
std::array<int, 5> arr = {1, 2, 3, 4, 5};

// 访问元素
arr[0] = 10;
arr.at(1) = 20; // 越界抛异常,operator[] 不检查

// 迭代
for (const auto& elem : arr) {
std::cout << elem << " ";
}

// 大小
std::cout << "Size: " << arr.size() << "\n"; // 5

// 前后元素
std::cout << "Front: " << arr.front() << "\n";
std::cout << "Back: " << arr.back() << "\n";

// 填充
arr.fill(0);

at() 做边界检查(越界抛 std::out_of_range),operator[] 不检查但更快。std::get<I>(arr) 提供编译期边界检查。array 是值类型–拷贝会复制全部元素,传参用 const&span

array 支持比较运算(逐元素字典序)与 std::swap(逐元素交换,O(n));解构出指针可用 a.data(),与 C API 交互时它就是裸数组。

std::forward_list

std::forward_list 是单向链表,相比 std::list(双向)每个节点少存一个指针、更省内存。它提供 O(1) 的头部插入/删除,以及任意位置 O(1) 的"插入/删除后继"操作(insert_after/erase_after)。

适用场景:内存极其紧张、只需单向遍历、频繁头部增删。但要注意现代硬件上链表缓存不友好–元素巨大且确需中间 O(1) 增删时才考虑,否则 vector 往往更快。Core Guidelines 不推荐链表作为默认选择。

为什么基于"后继"操作

单向链表中拿到节点指针后找不到前驱,删除节点必须改前驱的 next 指针。所以接口设计成"给我你确定存在的前驱,我操作它的后继":insert_after(pos, val)erase_after(pos)。相应地,"删除第一个元素"是特例 pop_front,"在头部插入"是 push_front

遍历中想删除当前元素的标准写法:

1
2
3
4
5
6
7
8
9
10
std::forward_list<int> lst{1, 2, 3, 4, 5};
auto prev = lst.before_begin(); // 头前哨兵
for (auto cur = lst.begin(); cur != lst.end();) {
if (*cur % 2 == 0) {
cur = lst.erase_after(prev); // 返回下一个
} else {
prev = cur;
++cur;
}
}

需要 O(n) 拿长度时可遍历统计或改用 std::list(有 size());C++14 起也提供 resize

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <forward_list>

// 单向链表
std::forward_list<int> list = {1, 2, 3, 4, 5};

// 在前面插入
list.push_front(0);

// 在指定位置后插入
auto it = list.begin();
list.insert_after(it, 100);

// 删除元素
list.pop_front();
list.erase_after(it);

// 遍历
for (const auto& elem : list) {
std::cout << elem << " ";
}

// 检查是否为空
if (list.empty()) {
std::cout << "List is empty\n";
}

由于单向,只能前向遍历、没有 size()(为保 O(1) 接口而省略)、操作基于"前驱"而非"当前节点"(因为删除当前节点需要修改前驱的指针)。

std::random

C++11 的 <random> 是对 C 风格 rand() 的彻底升级。rand() 问题重重:分布不均、周期短、全局状态非线程安全、% 取模引入模偏差。新库把"随机源"与"分布"解耦:引擎(如 std::mt19937)产生均匀随机位,分布(如 uniform_int_distribution)把位映射成所需分布。

这套设计质量远超 rand()–Mersenne Twister 周期长达 2^19937-1,分布数学正确(无偏差),引擎可实例化避免全局状态。

为什么要解耦引擎与分布

rand() % 6 取 1-6 的骰子时,若 RAND_MAX + 1 不是 6 的倍数,低位区间的概率偏高,这是模偏差。分离后,uniform_int_distribution 内部做拒绝采样把偏差消掉;normal_distribution 用 Box-Muller 之类的算法从均匀位生成正态值。引擎只管"均匀随机位",分布只管"数学正确的映射",可以自由组合。

random_device 在 Linux 上默认读 /dev/urandom(每次调用有系统调用开销),只用于播种;主流平台可用它播种 mt19937。想复现结果(测试、回放)就固定种子:std::mt19937 gen(42);

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <random>

// 随机数引擎
std::random_device rd; // 硬件随机数生成器(用于播种)
std::mt19937 gen(rd()); // Mersenne Twister 引擎

// 均匀分布
std::uniform_int_distribution<> dis(1, 100);
int randomInt = dis(gen); // 1-100 之间的随机整数

// 浮点数分布
std::uniform_real_distribution<> disReal(0.0, 1.0);
double randomDouble = disReal(gen);

// 正态分布
std::normal_distribution<> normal(5.0, 2.0);
double normalValue = normal(gen);

// 伯努利分布
std::bernoulli_distribution bernoulli(0.5);
bool coinFlip = bernoulli(gen);

引擎对象较重(mt19937 状态约 2.5KB),应在作用域内复用而非频繁构造。安全敏感场景(密码学、token 生成)不要用 <random>——它的输出可预测,应用平台密码学 API。

std::regex

C++11 把正则表达式纳入标准库,支持 ECMAScript、POSIX 等多种语法。它统一了之前各平台各库(Boost.Regex、PCRE)的碎片化,提供编译期/运行期正则、匹配/搜索/替换、迭代器接口。

需注意 <regex> 的实现普遍较慢(尤其 GCC),不适合高频热路径–能用手写字符串算法(find/substr)解决就别上正则。适合一次性解析、配置校验等低频场景。

语法与匹配语义

默认语法是 ECMAScript;std::regex::extendedawkgrep 等可切换。几个关键语义:

  • regex_match整串匹配才算成功,适合"验证格式"。
  • regex_search:找第一个匹配子串,适合"提取"。
  • smatchmatches[0] 是整体匹配,matches[1] 起是捕获组;matches.str(i)/matches.position(i) 取内容与位置。
1
2
3
4
5
6
7
std::regex date(R"((\d{4})-(\d{2})-(\d{2}))");
std::smatch m;
if (std::regex_search("2026-01-18", m, date)) {
m[1]; // "2026":捕获组
m[2]; // "01"
m[3]; // "18"
}

regex_iterator 遍历所有匹配(每次解引用是一个 smatch);regex_token_iterator 还能直接取捕获组或"匹配之外的部分"(分割字符串用)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <regex>

// 正则表达式匹配
std::string text = "Hello, World! 123";
std::regex pattern(R"(\d+)"); // 匹配数字(原始字符串字面量避免反斜杠转义)

std::smatch matches;
if (std::regex_search(text, matches, pattern)) {
std::cout << "Found: " << matches[0] << "\n"; // 123
}

// 正则表达式替换
std::string result = std::regex_replace(text, std::regex(R"(\d+)"), "NUM");
std::cout << result << "\n"; // Hello, World! NUM

// 正则表达式迭代
std::regex word_pattern(R"(\w+)");
std::sregex_iterator it(text.begin(), text.end(), word_pattern);
std::sregex_iterator end;

for (; it != end; ++it) {
std::cout << it->str() << "\n";
}

std::regex 构造开销大,应构造一次复用,不要在循环里反复构造。原始字符串字面量 R"(...)" 让写正则时不用双重转义反斜杠。regex_search 找首次匹配,regex_match 要求整串匹配。

范围 for 循环

范围 for 循环遍历可迭代范围(容器、初始化列表、数组),消除了手写迭代器/索引的样板与 off-by-one 错误。它等价于对 begin()/end() 迭代器的循环,但意图清晰、不易写错。

展开形式与自定义类型支持

编译器把 for (decl : expr) body 展开为:

1
2
3
4
5
6
7
8
9
{
auto&& __range = expr;
auto __b = begin(__range);
auto __e = end(__range);
for (; __b != __e; ++__b) {
decl = *__b;
body
}
}

要点:begin/end 通过 ADL 找(成员函数或自由函数都行),所以任何类型只要提供这对函数就能被范围 for 遍历;范围表达式只求值一次;循环变量每轮都重新声明。注意临时容器的坑:for (auto x : make_vec()) 在 C++11/14 是未定义行为(临时在循环体前已析构),C++17 起通过延长 auto&& 的生命周期修复。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
std::vector<int> vec = {1, 2, 3, 4, 5};

// 只读
for (const auto& elem : vec) {
std::cout << elem << " ";
}

// 修改
for (auto& elem : vec) {
elem *= 2;
}

// 数组
int arr[] = {1, 2, 3};
for (auto& x : arr) {
x *= 2;
}

关键:遍历非 trivial 类型(string、自定义类)时用 const auto& 避免拷贝;要修改元素用 auto&;只在元素是廉价值类型(int 等)时用 auto(按值)。遍历过程中不要修改容器结构(增删元素会使迭代器失效)。

初始化列表

C++11 之前,初始化语法碎片化:内置类型用 =,数组用 {},对象用 (),容器只能逐个 push_back,动态数组根本没法初始化。C++11 引入统一初始化——用花括号 {} 统一初始化一切:变量、对象、容器、成员、堆分配、返回值、函数参数。

花括号初始化的形式

花括号可以带 =,也可以不带,两者基本等价(不带 = 的称为直接列表初始化,能调用 explicit 构造函数)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 内置类型
int x{42};
int y = {42};

// 数组与聚合类型
int arr[]{1, 2, 3};
struct Point { int x, y; };
Point p{1, 2};

// 类对象
std::string s{"hello"};
std::string s2 = {"hello"};

// 容器
std::vector<int> vec{1, 2, 3, 4, 5};
std::map<std::string, int> m{
{"apple", 1},
{"banana", 2}
};

// 堆分配与临时对象
int* p1 = new int{42};
auto f = [] { return Point{3, 4}; }; // 返回值

// 函数参数
void draw(Point p);
draw({5, 6}); // 实参处直接构造 Point

数组初始化的细节

数组是花括号初始化最古老的用户(C 语言就有),但 C++11 后它有三种等价形式,且规则比看上去细。

1
2
3
4
5
6
int a[3] = {1, 2, 3};  // 拷贝列表初始化
int b[3]{1, 2, 3}; // 直接列表初始化(C++11)
int c[3] = {1, 2}; // 部分初始化:第三个元素为 0
int d[3]{}; // 全零
int e[] = {1, 2, 3}; // 大小由元素个数推导为 3
// int f[2] = {1, 2, 3}; // 编译错误:初始值过多

关键规则:

  • 未列出的元素零初始化c 的第三个元素是 0,不是垃圾值。只要用了花括号,数组就不会有未初始化的元素;而 int a[3]; 在局部作用域是未初始化的(静态/全局数组则默认零初始化)。
  • {} 是惯用清零写法int a[3]{};int a[3] = {}; 等价,全部置零,取代 memset
  • 窄化检查对 = {...} 同样生效:带 = 的花括号仍是列表初始化,int a[2] = {3.14, 2} 编译错误。
  • 大小省略时按元素个数推导;显式给出且小于列表长度则报错,大于则余下补零。

字符数组初始化自字符串字面量时,末尾的 '\0' 计入大小:

1
2
3
char s1[] = "abc";   // 大小 4:'a','b','c','\0'
char s2[6] = "abc"; // 后两个元素补 '\0'
// char s3[3] = "abc"; // 编译错误:装不下 '\0'

(C 允许 char s3[3] = "abc" 并丢弃 '\0',C++ 不允许。)

嵌套数组按聚合体逐层初始化:

1
2
3
4
5
int m[2][3] = {{1, 2, 3},
{4, 5, 6}};
int n[2][3] = {{1}, {4}}; // 每行余下元素补 0
int o[2][3] = {1, 2, 3, // 花括号可省略(不推荐)
4, 5, 6};

省略内层花括号(括号省略)按顺序填充,编译器不检查行列对齐,易错,建议保留分组。std::array 按同样规则做聚合体初始化:std::array<int, 3> a = {1, 2}; 第三个元素零初始化。按索引指定初始化(int a[3] = {[1] = 2})是指派初始化器,要到 C++20 才进入语言。

std::initializer_list

容器之所以能写 vec = {1, 2, 3},靠的是 std::initializer_list<T>。编译器遇到同类型的花括号列表时,会在列表出现的语句处生成一个隐藏的 const T[N] 临时数组,把元素逐个拷贝进去,再构造一个指向该数组的轻量视图(本质是两个字段:首指针 + 长度)传给构造函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <initializer_list>

class IntVec {
public:
IntVec(std::initializer_list<int> init)
: size_(init.size()) {
data_ = new int[size_];
std::copy(init.begin(), init.end(), data_);
}
~IntVec() { delete[] data_; }
private:
int* data_;
size_t size_;
};

IntVec v{1, 2, 3, 4, 5};
// 编译器背后等价于:
// const int tmp[5] = {1, 2, 3, 4, 5};
// 每个元素从花括号里对应的表达式拷贝初始化
// IntVec v(initializer_list<int>(tmp, 5));
// 视图只记录首地址与长度,元素不随视图复制

底层数组的物化过程有几个关键细节:

  • 元素逐个拷贝初始化:数组是 const T[N],长度编译期已知。元素写右值(临时对象)时可直接在数组元素上构造,写左值则要先拷贝一次。
  • 存储位置跟随语境:局部作用域里通常是栈上临时;静态初始化语境则放静态存储区。任何情况下都不涉及堆分配。
  • 容器侧还有一次拷贝:构造函数要把元素从 const 数组拷进自己的存储,且因为元素是 const,这一步无法优化成移动–vector<string> v{...} 的每个元素至少"构造一次 + 拷贝一次"。
  • 嵌套列表是逐层构造,不是嵌套数组map<string, int> m{{"a", 1}, {"b", 2}} 里,内层 {"a", 1} 直接在外层 const pair<const string, int>[2] 数组的元素上构造出 pair,map 再把每个 pair 拷进节点–字符串内容至少被构造两次。

使用上有两条铁律:

  • 元素只读:底层数组是 const T[N],不能就地修改;拷贝 initializer_list 只是拷贝视图,不复制元素,多个视图共享同一数组。
  • 生命周期绑定列表对象:C++17 起标准明确,数组的存活期与它所初始化的 initializer_list 对象一致;但拷贝列表不会延长数组寿命。因此把列表存入成员、或从函数返回,视图指向的数组都已在别处销毁:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Bad {
std::initializer_list<int> items_; // 危险成员
public:
Bad(std::initializer_list<int> l)
: items_(l) {} // 拷贝的只是视图
// 构造结束后 items_ 悬挂:底层数组
// 已随构造实参一起销毁
};

std::initializer_list<int> make() {
return {1, 2, 3}; // 危险:数组随函数结束销毁
} // 返回的列表是悬挂的
// Clang/GCC 会告警 -Wreturn-stack-address
// 实测输出垃圾值,甚至在不同优化级别下
// 指向完全不同的内存

需要"存起来延后使用"的元素列表,应拷贝进 vector(值语义、拥有元素),而不是保存 initializer_list

优先匹配 initializer_list 构造函数

这是最容易踩的坑:只要类有 initializer_list 构造函数,花括号初始化就优先匹配它,哪怕其他构造函数看起来更合适。

1
2
3
4
5
std::vector<int> v1(5, 0);  // 5 个元素,值都是 0
std::vector<int> v2{5, 0}; // 2 个元素:5 和 0

std::vector<int> v3(5); // 5 个元素,默认初始化
std::vector<int> v4{5}; // 1 个元素:5

括号 () 走普通构造函数,花括号 {} 优先走 initializer_list 构造函数–语义完全不同。只有当 initializer_list 构造函数无法匹配(元素类型不符等)时,编译器才回退到普通构造函数。

禁止窄化转换

花括号初始化在编译期拒绝可能丢数据的隐式转换,这是它相对 =/() 初始化的安全优势。

1
2
3
4
5
6
7
8
9
int a = 3.14;    // 静默截断为 3
// int b{3.14}; // 编译错误:double -> int 窄化

char c1 = 999; // 静默溢出
// char c2{999}; // 编译错误

double d{1.5f}; // OK:float -> double 不丢精度
int e{42}; // OK
long f{e}; // 编译错误:非常量 int -> long 属窄化

判定规则:浮点转整数、大范围整数转小范围整数、双精度转单精度等都是窄化;常量表达式若目标类型装得下则放行(char c{65} 合法)。

解决 most vexing parse

C++ 的"最烦人的解析":Widget w(); 会被解析为"返回 Widget 的函数声明",而不是默认构造对象。花括号没有这种歧义。

1
2
3
4
Widget w1;    // 默认构造,OK
Widget w2(); // 函数声明!不是对象
Widget w3{}; // 默认构造,无歧义
Widget w4(Widget()); // 也是函数声明

使用建议

  • 容器装已知元素列表 -> 用 {}(要的就是 initializer_list)。
  • 调用特定构造函数(如 vector(n, val) 预分配)-> 用 (),明确绕过 initializer_list
  • 内置类型、聚合体、需要窄化检查 -> 用 {}
  • 模板代码中对类型参数 T 初始化要小心:T t{args...}T t(args...) 语义可能不同,保持约定一致。

nullptr

nullptr 是类型安全的空指针常量,类型为 std::nullptr_t,可隐式转换为任意指针类型,但不转换为整型。它取代了有歧义的 NULL(在 C 里 NULL 常被定义为 0(void*)0,在 C++ 里通常是 0)。

NULL 的根本问题:func(NULL)void func(int*)void func(int) 重载时,NULL 作为 0 会匹配 func(int)——出乎意料。nullptr 明确表示指针,无歧义地匹配指针重载。

1
2
3
4
5
6
7
void func(int* ptr) {}
void func(int x) {}

func(nullptr); // 调用 func(int*)
// func(NULL); // 可能有歧义:NULL 是 0,匹配 func(int)

int* ptr = nullptr;

现代 C++ 一律用 nullptr,不用 NULL0 表示空指针。模板推导时 nullptr 的类型 nullptr_t 也比 0 更准确。

nullptr 的类型规则

std::nullptr_t 与整型之间没有隐式转换,指针之间的转换是单向的:

1
2
3
4
int n = nullptr;    // 编译错误:不能转整型
bool b = nullptr; // 编译错误(C++11 起)
int* p = nullptr; // OK
void fn(nullptr_t); // 可以显式接收

它自己可以比较(nullptr == nullptr 为 true);sizeof(nullptr_t) 通常与指针相同。

constexpr

constexpr 表示"可在编译期求值",让常量计算、表格生成、模板参数等从运行期挪到编译期。相比 C 风格 #defineconstconstexpr 既类型安全又能用于更多语境(数组大小、模板参数、static_assert)。

C++11 的 constexpr 限制较严:函数体只能有一条 return(递归实现)。C++14 起大幅放宽,C++23 进一步扩展。但即便在 C++11,constexpr 递归已能做可观的编译期计算。

1
2
3
4
5
constexpr int factorial(int n) {
return n <= 1 ? 1 : n * factorial(n - 1);
}

constexpr int result = factorial(5); // 编译期计算

constexpr 函数既可在编译期(用于常量语境)也可在运行期调用——是否编译期取决于调用语境。若要求强制编译期,C++20 起用 constevalconstexpr 变量是真正的编译期常量,不像 const 只读不一定是编译期。

C++11 constexpr 的限制

1
2
3
4
5
6
constexpr int gcd(int a, int b) {
// 只能写一条 return,逻辑全靠
// 递归与条件运算符
return b == 0 ? a : gcd(b, a % b);
}
constexpr int g = gcd(36, 24); // 12

限制清单:函数体单条 return;无局部变量、无循环;参数与返回值必须是字面值类型;递归深度受编译器默认限制(clang 约 512 层)。传运行期变量调用同一函数则退化为普通调用–这正是"是否编译期取决于语境"。

constexpr 构造函数让用户类型也能进编译期:

1
2
3
4
5
6
7
8
struct Point {
int x, y;
constexpr Point(int x_, int y_)
: x(x_), y(y_) {}
};
constexpr Point p{3, 4};
constexpr int area =
p.x * p.y; // 编译期字段访问

类型别名

usingtypedef 的现代替代,语法从右到左更直观,且支持模板别名——typedef 无法做到。模板别名让"带部分参数的模板"成为一等公民,极大简化了模板元编程与复杂类型的可读性。

1
2
3
4
5
6
7
8
using String = std::string;
using IntVector = std::vector<int>;

// 模板别名(typedef 做不到)
template<typename T>
using Vec = std::vector<T>;

Vec<int> v; // std::vector<int>

usingtypedef 语义等价(都不引入新类型,只是别名),但 using 语法更清晰(using Name = Type 像赋值)且能模板化。新代码一律用 using

模板别名的威力

模板别名与"typedef 组合"不同:它接受模板参数并延迟展开,可在元编程里当"返回类型的函数"用。C++14 标准库据此补齐了 _t 后缀:

1
2
std::remove_reference_t<T>   // C++14
std::remove_reference<T>::type // C++11 只能这样写

别名不创建新类型,重载决议与原类型完全一致;这也意味着错误信息里仍会出现原始长类型。

委托构造函数

委托构造允许一个构造函数调用同类的另一个构造函数,消除多个构造函数间重复的成员初始化代码。之前要么每个构造函数各写一遍初始化列表、要么抽个 init() 函数(后者不能用于 const 成员/引用成员初始化)。

1
2
3
4
5
6
7
8
9
10
11
class MyClass {
public:
MyClass(int x, int y) : x_(x), y_(y) {}

// 委托给上面的构造函数
MyClass(int x) : MyClass(x, 0) {}
MyClass() : MyClass(0, 0) {}

private:
int x_, y_;
};

注意:委托构造后,初始化列表不能再初始化其他成员(委托即把初始化完全交给目标构造函数);构造函数体可在委托调用之后执行。避免循环委托(A 委托 B、B 委托 A)。

1
2
3
4
5
6
7
8
9
10
class Timer {
public:
explicit Timer(int ms) : ms_(ms) {}
Timer() : Timer(1000) {} // 委托
explicit Timer(const char* s)
: Timer(parse(s)) {} // 先算参再委托
static int parse(const char*);
private:
int ms_;
};

委托目标完成初始化与函数体后才回到委托者的函数体;若委托目标构造中抛异常,委托者的函数体不会执行。

override 和 final

override 显式声明"此虚函数重写基类的虚函数",让编译器检查签名是否真的匹配——若基类没有匹配的虚函数(拼错名字、签名不一致、基类非虚),编译报错。这抓住了 C++ 最常见的继承 bug 之一:以为重写了其实没有(静默创建了一个新函数)。

final 禁止进一步重写(用于虚函数)或禁止进一步继承(用于类),表达"此处就是终态"的设计意图,有时还能助优化(去虚化)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Base {
public:
virtual void func() {}
virtual void finalFunc() final {}
};

class Derived : public Base {
public:
void func() override {} // 明确表示重写

// void finalFunc() override {} // 编译错误,final 函数不能重写
};

class FinalClass final : public Base {
// 不能被继承
};

现代 C++ 规约:凡重写虚函数必加 override——零成本地获得编译期检查。final 谨慎使用,过度使用会妨碍扩展;但在叶子类或安全敏感的虚函数上用它防误重写很合理。

override 检查的匹配项:函数名、参数类型列表、cv 限定、引用限定、noexcept 是否一致;返回类型要求协变。任何一个不符(包括 const 漏写)都会从"重写"降级为"新函数",加 override 后这类错误在编译期暴露。基类函数没有 virtual 而派生类标 override 也报错。

enum class

enum class(强类型枚举)解决了裸 enum 的两大问题:枚举值会隐式转为 int(类型不安全)、枚举值泄漏到外层作用域(命名冲突)。

enum class 的枚举值不会隐式转整型(必须 static_cast)、不会泄漏作用域(必须 Color::Red 限定)、可以指定底层类型(enum class Color : uint8_t)。这让枚举真正安全。

1
2
3
4
5
6
7
8
9
enum class Color { Red, Green, Blue };
enum class Animal { Dog, Cat };

Color c = Color::Red;
// Color c2 = Red; // 编译错误,需要作用域
// if (c == Animal::Dog) {} // 编译错误,不同枚举类型不能比较

// 显式转换
int value = static_cast<int>(Color::Red); // 0

C++23 起取底层值用 std::to_underlying(c) 取代啰嗦的 static_cast。枚举转名字的 switch 末尾可用 std::unreachable() 标记不可达分支。新代码一律用 enum class,不用裸 enum

底层类型与前置声明

1
2
3
enum class Color : uint8_t {
Red, Green, Blue
}; // sizeof(Color) == 1

不指定底层类型时,由实现选择能装下所有枚举值的整型。指定底层类型后可以前置声明枚举:enum class Color : uint8_t;–两个头文件要互引枚举而不知道对方枚举值时非常有用,裸 enum 做不到(必须见到完整定义)。

位标志用法

1
2
3
4
5
6
7
8
9
10
11
enum class Permissions : unsigned {
Read = 1, Write = 2, Exec = 4
};
// 位运算符不自动定义,需自己重载:
constexpr Permissions operator|(
Permissions a, Permissions b) {
return static_cast<Permissions>(
static_cast<unsigned>(a)
| static_cast<unsigned>(b));
}
auto rw = Permissions::Read | Permissions::Write;

静态断言

static_assert 在编译期断言,条件为假时编译报错并输出消息。它让"对类型/常量的假设"在编译期就暴露,而非运行期崩溃。常用于模板约束、平台假设、布局检查。

1
2
3
4
5
6
static_assert(sizeof(int) == 4, "int must be 4 bytes");

template<typename T>
void checkSize() {
static_assert(sizeof(T) >= 4, "Type must be at least 4 bytes");
}

C++17 起 static_assert 的消息可省略(static_assert(cond);)。与运行期 assert 区别:static_assert 在编译期、NDEBUG 无关;assert 在运行期、NDEBUG 下被移除。模板中 static_assert 是早期做概念约束的常见手段(C++20 Concepts 取代了大部分这种用法)。

模板里的惯用形态是"检查类型属性并给出可读错误":

1
2
3
4
5
6
7
8
9
template<typename T>
T max3(T a, T b, T c) {
static_assert(std::is_arithmetic<T>::value,
"max3 requires arithmetic types");
T m = a;
if (b > m) m = b;
if (c > m) m = c;
return m;
}

C++11 <type_traits>static_assert 是一对搭档;C++17 的 if constexpr 让很多"断言 + 分支"能直接在编译期选择代码路径。

变参模板

变参模板接受任意数量、任意类型的模板参数(参数包 Args...),让"类型安全的可变参数"成为可能。它取代了 C 风格 printf 的可变参数(无类型检查)和 Java 风格 Object...(类型擦除),是实现 make_sharedtuplefunction 等的基础设施。

C++11 时代展开参数包靠递归模板(定义一个基础 case + 一个递归 case)。C++17 折叠表达式大幅简化了常见模式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 打印任意数量的参数
template<typename... Args>
void print(Args... args) {
((std::cout << args << " "), ...); // 折叠表达式(C++17)
std::cout << "\n";
}

// 递归展开
template<typename T>
T sum(T first) {
return first;
}

template<typename T, typename... Args>
T sum(T first, Args... rest) {
return first + sum(rest...);
}

print(1, 2, 3, "hello"); // 1 2 3 hello
int total = sum(1, 2, 3, 4, 5); // 15

变参模板的递归展开较繁琐,C++17 折叠表达式能省掉大部分递归样板。转发参数包用 std::forward<Args>(args)...(完美转发)保持值类别。sizeof...(Args) 取参数个数。

sizeof… 与模式展开

参数包的三种展开位置:模板实参列表(tuple<Args...>)、函数实参列表(f(args...))、初始化列表({args...})。sizeof...(pack) 是编译期常量,配合数组技巧可强制求值顺序:

1
2
3
4
5
6
// 保证从左到右求值(C++17 前):
template<typename... Args>
void ordered(Args... args) {
int dummy[]{(process(args), 0)...};
(void)dummy;
}

C++11 里这个"逗号 + 数组"技巧是替代折叠表达式的标准手段;包为空时数组大小为 0,需补一个哨兵元素({0, (process(args), 0)...})。

emplace_back 的实现骨架

变参模板最著名的应用就是完美转发构造:

1
2
3
4
5
6
7
8
9
10
11
12
13
template<typename T>
class Stack {
public:
template<typename... Args>
void emplace(Args&&... args) {
data_.push_back(
std::forward<Args>(args)...);
}
private:
std::vector<T> data_;
};
// Stack<std::pair<int,std::string>> s;
// s.emplace(1, "one"); // 原地构造,无临时对象

这里 Args&&... 每个都是转发引用,std::forward<Args>(args)... 逐元素保持值类别,把"多参数原地构造"的能力透传给底层容器。

常用特性总结

最常用的 C++11 特性:

  • auto 类型推导
  • Lambda 表达式
  • 智能指针(unique_ptr, shared_ptr
  • 范围 for 循环
  • nullptr
  • override 关键字
  • enum class