「我们用的是同一个 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);
捕获列表四种最常用的写法:
5、override 和 final[=] // 按值捕获,lambda 里是副本,加 mutable 才能在内部修改
[&] // 按引用捕获,不推荐,生命周期风险极大
[x, &y] // x 按值,y 按引用,明确控制
[this] // 捕获 this 指针,访问成员变量// C++14 的初始化捕获:在捕获前对变量做变换再拿进来
auto handler = [data = std::move(raw)] mutable { /* 使用移动进来的 data */ };
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 constexprSFINAE 写多了的人都懂那种感觉:模板特化套模板特化,错误信息一屏都是垃圾,调试全靠脑补编译器行为。
// 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++20C++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::jthreadjthread 解决了 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 在此基础上把失败原因带出来:
#includestd::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::format 加 cout 的两步,变成一步。
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 操作( transform 、 and_then 、 or_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++不是一个需要追着它跑的语言。把上面那不到二十个核心特性啃透了,比把两百多个特性列表背下来管用。
热门跟贴