「我们用的是同一个 C++ 吗?」,这句是 我做研发 20 多年,听的最多一句。

其实 C++11 之前我们用的确实是同一,但 C++11 之后,在准确点,应该是 2012 年起。

打开网易新闻 查看精彩图片

C++11 来了,它把 auto、lambda、移动语义、智能指针、线程库塞进来,彻底改变了 C++ 的写法。

从此每三年一个新标准,彻底将 C++ 工程师细化。

本文把水分挤干净,直接告诉你哪些值得死磕,哪些看完就忘。

一、C++11 是原点,吃透了再往下走

C++11 是现代 C++ 的起点,它改变了 C++ 的基本写法。

1、auto

auto 推的是编译期类型,运行时没有任何额外开销,不存在性能问题。

auto x = 42; // int
auto y = 3.14; // double
auto p = std::unique_ptr(newint(5)); // unique_ptr
auto& ref = container; // auto 保留左值引用语义

唯一要小心的是函数签名和类成员变量。

这两处类型名对外部可见,必须写清楚。

其余场景用 auto 能让代码更干净,尤其在遍历 STL 容器和写泛型回调的时候。

一个容易踩的坑:auto 推的是初始化表达式的类型,不是你想当然的类型。

std::vector v;
auto it = v.begin; // std::vector::iterator,正确
auto x = *v.begin; // int,正确


// 陷阱
conststd::vector& ref = v; // 写了 const 声明,ref 是 const vector&
auto& auto_ref = ref; // auto_ref 推导成 const vector&,不是 vector&

当你觉得 auto 推出来的类型不符合预期,问题往往出在变量的职责定义上,不是 auto 的问题。

2、移动语义,搞清楚三件事就够了

左值有稳定地址,右值是临时对象。移动构造函数把别人手里的资源直接抢过来,省掉一次内存分配。这个「抢」的动作发生在对象构造阶段,析构阶段把资源释放掉,不重复。

classBuffer {
int* data_;
public:
Buffer(Buffer&& other) noexcept : data_(other.data_) {
other.data_ = ptr; // 必须置空,否则析构时 double free
}
};

noexcept 不能漏。

标准库容器在 rehash、reserve、扩容的时候,需要把元素从一个地方挪到另一个地方。

如果元素是 noexcept 可移动的,容器选移动;不是的话,容器会退而求其次选拷贝,这是标准库的保底策略。如果你的移动构造函数漏了 noexcept,容器在关键路径上会默默选更慢的拷贝。

完美转发 解决的是参数在层层传递过程中值类别丢失的问题:

template
voidwrapper(T&& arg){
// T&& 在模板推导中是转发引用,不是单纯的右值引用
process(std::forward(arg)); // 保持调用者传入的值类别
}

没有 forward ,所有参数在转发函数里都被当左值处理,移动语义在第一层传递之后就断了。

emplace_back vs push_back

v.emplace_back(Widget{1, 2, 3}); // 参数直接传给 vector 构造函数,原地构造
v.push_back(Widget{1, 2, 3}); // 先构造临时对象,再移动(或拷贝)进去

对有资源管理的类型,优先用 emplace_back。现代编译器能优化返回值构造,但带显式构造函数的有资源类型不一定触发 copy elision,emplace_back 永远少一次临时对象的构造开销。

3、智能指针,搞清楚谁拥有谁

std::unique_ptr 是首选,开销和裸指针完全一样,析构时自动释放。

std::unique_ptr cfg = std::unique_ptr(new Config("config.json"));
// 独占语义,作用域结束自动析构,不需要手动 delete

std::shared_ptr 在需要共享所有权时用,内部靠引用计数。引用计数归零才释放资源。

问题在于循环引用,两个 shared_ptr 互相指向对方,引用计数永远不为零,内存泄漏。

structNode {
std::shared_ptr next;
};
Node a, b;
a.next = std::make_shared;
b.next = std::make_shared;
a.next->next = std::make_shared(b);
b.next->next = std::make_shared(a); // 循环引用,计数永远归不了零

打破循环用 std::weak_ptr ,它不参与计数,只是一个「观察者」。

父节点持有 weak_ptr 指向子节点,子节点持有 shared_ptr 指向父节点,循环就断了。

缓存里用 weak_ptr 也是同一个道理。

对象被外部释放后, weak_ptr 自动失效,不需要手动清理缓存。

4、lambda

lambda 本质是编译器生成的匿名函数对象,写法比 std::bind 直观得多。

std::bind 那种 Placeholder 占位符的写法,现在能用 lambda 解决的就坚决不用 bind。

auto cmp = (const Person& a, const Person& b) { return a.age < b.age; };
std::sort(persons.begin, persons.end, cmp);

捕获列表四种最常用的写法:

[=] // 按值捕获,lambda 里是副本,加 mutable 才能在内部修改
[&] // 按引用捕获,不推荐,生命周期风险极大
[x, &y] // x 按值,y 按引用,明确控制
[this] // 捕获 this 指针,访问成员变量


// C++14 的初始化捕获:在捕获前对变量做变换再拿进来
auto handler = [data = std::move(raw)] mutable { /* 使用移动进来的 data */ };
5、override 和 final

classBase {
virtualvoidrun= 0;
virtualvoidinit{ }
virtual ~Base = default;
};


classDerived : Base {
voidrunoverride{ } // 编译器检查基类是否有 run,没有则报错
voidInitoverride{ } // 编译报错:基类没有 Init,只有 init
voidinitoverride{ } // 对
voiddonefinal{ } // 子类禁止重写 done
};

参数类型差一个 const,编译器不报错,运行起来行为全变。

这种 bug 在大型继承体系里极难定位。

加上 override,编译器在定义阶段就把问题抓出来。

final 同理,告诉编译器此类或此方法禁止派生,编译器可以据此做更激进的内联优化。

6、constexpr

C++11 的 constexpr 只能一条 return,功能极其受限。

C++14 解除了这个限制,for 循环、局部变量、条件分支都能用 constexpr 函数体了。

constexprintfactorial(int n){
int result = 1;
for (int i = 2; i <= n; ++i) result *= i;
return result;
}
static_assert(factorial(10) == 3628800, "compile-time check");

编译阶段算出来的值可以作为数组大小、模板参数、 static_assert 的条件。

运行时零开销,不占栈空间。

7、线程,避坑比会用更重要

std::atomic counter{0};
std::vector workers;
for (int i = 0; i < 4; ++i) {
workers.emplace_back([&counter] {
for (int j = 0; j < 10000; ++j)
counter.fetch_add(1, std::memory_order_relaxed);
});
}
for (auto& t : workers) t.join;

内存顺序按需选择:

场景

选哪个

单个 atomic 变量做计数,不需要和其他变量同步

memory_order_relaxed

,最快

多个 atomic 之间有 happens-before 关系

memory_order_seq_cst

,最安全

自定义 lock-free 算法

acquire

+ release

最大的坑是捕获局部变量引用。

上面的 [&counter] 没问题是因为 counter 在主线程里存活,线程结束前它一直在。

但如果捕获列表写 [&] 而不是指名道姓,作用域里有个局部变量在某个时刻析构了,线程里拿到的就是悬空引用。

二、C++14,记住三个特性就够了

C++14 被很多人直接跳过,觉得没什么可看的。

实际上值得专门记的只有三个。

1、泛型 lambda

auto 进 lambda 参数,编译器自动生成对应的函数模板:

auto twice = (auto x) { return x * 2; };
twice(5); // int
twice(3.14); // double

STL 算法里写回调,一个 lambda 覆盖所有类型:

std::transform(v.begin, v.end, v.begin,
(auto&& x) { returnstd::forward(x) * 2; });

auto&& 在 lambda 参数里是转发引用的简写,配合 std::forward 可以把值类别原封不动地透传。

2、decltype(auto)

decltype(auto) 是 C++14 新增的组合关键字,让编译器用 decltype 的规则推导类型,而不是用 auto 的规则。

auto 在推导返回类型时会退化掉引用, decltype(auto) 按照 decltype 的精确规则来:

decltype(auto) clone(const Widget& w){ return w; }
// decltype(w) 是 const Widget&,返回 const Widget&,正确


autoclone(Widget w){ return w; }
// 返回 Widget 值类型,调用者收到拷贝,多了一次构造

这条规则在实现转发函数和工厂函数时极其实用。

3、变量模板

template
constexpr T pi = T(3.1415926535897932385L);


float area_f = pi * r * r;
double area_d = pi * r * r;

之前写 MathConstants::pi 需要一个完整的类包装,变量模板把类包装甩掉了。

标准库也跟进了, std::numeric_limits的相关常量在 C++14 里补了变量模板版本。

三、C++17 三个特性,改变最大

C++17 有六十多个特性,大多数没人用,但这三个值得你仔细研究一下。

1、结构化绑定

std::unordered_map m = {{"apple", 1}, {"banana", 2}};
for (auto& [key, value] : m) {
std::cout << key << ": " << value << '\n';
}

auto [min_val, max_val] = std::minmax({3, 1, 4, 1, 5, 9, 2, 6});
auto [it, inserted] = m.insert({"cherry", 3});

structPoint {double x, y, z; };
Point p{1.0, 2.0, 3.0};
auto [a, b, c] = p;

可以解包 array、tuple、pair 以及任何带公开成员的结构体。 auto& 绑定到成员的引用上, auto 绑定到副本上。

坑:结构化绑定声明的是一个新变量,不是给已有变量起别名。如果绑定出来的变量名和作用域里已有变量名重名,两个变量各是各的,编译器不会报错,容易埋下不易察觉的 bug。

std::pair p{1, 2};
auto& [x, y] = p; // x 和 y 是 p.first、p.second 的引用
x = 10; // p.first 被改了,就这么简单,别想复杂了
2、if constexpr

SFINAE 写多了的人都懂那种感觉:模板特化套模板特化,错误信息一屏都是垃圾,调试全靠脑补编译器行为。

// C++17 之前
template>
autoderef(T t) -> decltype(*std::declval){
return *t;
}


// C++17
template
autoget_value(T t){
ifconstexpr(std::is_pointer_v){
return *t;
} else {
return t;
}
}

if constexpr 的核心在于 else 分支在模板实例化阶段根本不进入编译器视线,类型不匹配不影响编译。

这叫编译期条件,是 C++17 最有价值的特性之一。

3、std::optional

「可能没有」从约定变成强制语义:

std::optional find(int id);
auto r = find(42);
if (r) {
use(*r);
} else {
notFound;
}
int value = find(42).value_or(-1); // 有值返回值,无值返回 -1

optional 和异常的边界是,异常表达「不应该发生」,optional 表达「可能没有」。

文件找不到用哪个?取决于业务语义,「配置文件不存在」用默认值就行,用 optional;程序严重依赖某个文件才能运行,缺失是故障,用异常。

optional 的大小和裸指针一样,不适用于大对象。

如果值本身很大,不要用 optional 包装值,而是把 optional 放进容器里:

std::optional> data; // 不推荐,vector 很大,多一次间接
std::vector> data; // 推荐,每个元素独立管理有无状态
四、C++20

C++20 被吹得最厉害,协程、Modules、Concepts、Ranges 四大旗舰特性齐发。但生产环境里稳定好用的,我只推荐三个。

1、std::format

snprintf 的类型安全问题在编译期就堵死了:

#include

std::string s = std::format("Hello {} {}, value={:.2f}", "world", 42, 3.14159);

// 对齐和填充
std::format("{:>10}", "right"); // 右对齐,宽度 10
std::format("{:*std::format("{:^10}", "center"); // 居中

// 进制
std::format("0x{:08x}", 255); // 十六进制零填充
std::format("{:b}", 10); // 二进制
std::format("{:o}", 64); // 八进制

// C++23
std::print("Progress: {:.1f}%\n", 75.5);
std::println("Error: {}", msg);

sprintf("%d", "hello") 在 C 里不报错,运行才崩。 std::format{} 占位符,类型不匹配编译器直接报。

2、std::span

把「裸指针加长度」这对搭档合二为一:

voidprocess(std::span data){
for (int x : data) { /* 直接当容器用 */ }
}


std::vector v{1, 2, 3, 4, 5};
process(v);
process(std::span{v.data, 2}); // 只取前两个元素

span 本质是非拥有型范围,只读场景用 span,要修改用 span

所有接口里出现裸指针加长度的地方,都值得换成 span。

// 之前
voidread_data(constuint8_t* buffer, size_t size);
// 现在
voidread_data(std::span data);
3、std::jthread

jthread 解决了 std::thread 析构时还没 join,程序直接 std::terminate 的问题。

std::jthread worker((std::stop_token st) {
while (!st.stop_requested) {
do_work;
}
});
// 析构时自动 request_stop + join

stop_token 让工作线程可以感知到停止信号,干净利落地退出。

新起线程默认用 jthread ,不要再用裸 std::thread

4、Concepts,读懂标准库就够了

template
T abs_add(T a, T b){ return a >= 0 ? a + b : a - b; }


abs_add(10, 6); // OK
// abs_add(3.14, 2.71); // 编译器报:不满足 std::integral,直接指向调用点

没有 Concepts,模板参数不满足的错误信息从模板深处冒出来,一屏都是垃圾。

有了 Concepts,编译器直接定位到调用点,告诉你哪个类型不满足哪个概念。

普通业务代码不需要自己写 Concepts,标准库已经预定义了大量 concepts。

真正需要的是能读懂标准库头文件里的 requires 子句。

三路比较运算符

auto operator<=>(const Point&) const = default;

一行生成六个比较运算符。

大多数业务类型不需要六个比较关系,等真的要用 std::map时再加。

五、C++23 三件事值得掌握

C++23 是个小版本。

1、std::expected

optional 只能表示「有没有值」,expected 在此基础上把失败原因带出来:

#include


std::expected parse(conststd::string& s){
if (s.empty) returnstd::unexpected(std::errc::invalid_argument);
try {
returnstd::stoi(s);
} catch (...) {
returnstd::unexpected(std::errc::invalid_argument);
}
}
auto r = parse("42");
if (r) {
use(*r);
} else {
handle_error(r.error); // 拿到具体错误码,不是「没值」这个模糊状态
}

错误可能来自多种原因的场景,用 expected 比 optional 加全局错误码清晰得多。

2、std::print / std::println

std::print("Hello {} {}\n", "world", 42);
std::println("Error: {}", msg);
std::print(stderr, "Error: {}: {}\n", code, msg); // 错误流

std::formatcout 的两步,变成一步。

3、std::views::enumerate

for (auto [i, x] : std::views::enumerate(v)) {
std::print("{}: {}\n", i, x);
}

std::ranges::contains

std::find(v.begin, v.end, x) != v.end

换成一句 std::ranges::contains(v, x)

C++23 给 optional 加了 monadic 操作( transformand_thenor_else ),链式处理比手写分支干净:

auto result = parseInt("42")
.transform((int x) { return x * 2; }) // 有值 → 变换
.and_then((int x) { // 有值 → 做检查,失败返回 opt
return (x > 0) ? std::optional(x) : std::opt;
})
.value_or(-1);
六、总结

现代 C++ 必须掌握的

C++11:auto、范围 for、lambda、移动语义、智能指针、override/final、constexpr、线程基础

C++14:泛型 lambda、decltype(auto)、变量模板

C++17:结构化绑定、if constexpr、std::optional、std::string_view、[[nodiscard]]

C++20:std::format、std::span、std::jthread、Concepts(会读标准库即可)

C++23:std::expected、std::print、std::views::enumerate、std::ranges::contains、monadic optional

以上加在一起,不到二十个,覆盖了日常代码 90% 的场景,其他的知识点用到再查就好。

C++不是一个需要追着它跑的语言。把上面那不到二十个核心特性啃透了,比把两百多个特性列表背下来管用。