第27章 C++17/20新特性:结构化绑定、if constexpr、折叠表达式、概念、协程简介、范围库
说实话,每次C++标准更新,我身边都有不少同事抱怨「又得学新语法了」。但以我十几年的工程经验来看,这些新特性真不是花架子——它们能实实在在地帮你写出更干净、更安全的代码。今天咱们就挑几个最实用的新特性,掰开揉碎聊一聊。
27.1 结构化绑定:解包的艺术
先问个问题:你从函数返回一个pair或tuple时,是不是还得手动声明变量再赋值?我以前写代码时经常这样:
std::map<std::string, int> scores;
auto ret = scores.insert({"Alice", 95});
bool inserted = ret.second;
auto& iter = ret.first;
嗯,看着就啰嗦。C++17的结构化绑定直接把这个过程简化了:
auto [iter, inserted] = scores.insert({"Alice", 95});
一行搞定。我个人习惯在遍历map时也用它:
for (const auto& [name, score] : scores) {
std::cout << name << ": " << score << "\n";
}
auto& [key, val] = myMap;
我曾经在重构一个旧项目时,把几十处手动解包全换成了结构化绑定。代码量减少了将近30%,而且可读性明显提升。你想想看,读代码的人一眼就能看出「哦,这里解出了两个东西」,多清爽。
27.2 if constexpr:编译期的分支选择
模板编程里有个老难题:怎么在编译期根据类型做不同的事情?以前得靠SFINAE(替换失败不是错误)那一套,说实话,那玩意儿写出来跟天书似的。
C++17的if constexpr直接把这个痛点解决了。说白了,它就是在编译期计算条件,然后只编译符合条件的那个分支:
template <typename T>
auto getValue(T t) {
if constexpr (std::is_integral_v<T>) {
return t + 1;
} else if constexpr (std::is_floating_point_v<T>) {
return t * 2.0;
} else {
return t;
}
}
注意,这里else分支里的return t,如果T是int,编译器根本不会生成这段代码。这就避免了编译错误——比如你没法对int类型做t * 2.0再返回double?不,这里每个分支都是独立的。
if constexpr的条件必须是编译期常量。你不能写if constexpr (runtimeVar > 0),那会编译报错。
我在项目中遇到过这样一个场景:需要写一个通用的序列化函数,对整数类型用二进制写入,对字符串类型用文本写入。用if constexpr几行就搞定了,换成SFINAE至少得写二十行模板特化。
27.3 折叠表达式:变参模板的瑞士军刀
变参模板(variadic templates)在C++11就引入了,但处理参数包一直挺麻烦。C++17的折叠表达式让这件事变得异常优雅。
举个例子,你想写一个函数,把任意数量的参数加起来:
template <typename... Args>
auto sum(Args... args) {
return (args + ...); // 一元右折叠
}
调用sum(1, 2, 3, 4),编译器会展开成1 + (2 + (3 + 4))。你也可以用左折叠:(... + args),展开成((1 + 2) + 3) + 4。
折叠表达式有四种形式:
| 形式 | 语法 | 展开示例(args = 1, 2, 3) |
|---|---|---|
| 一元右折叠 | (args op ...) | 1 op (2 op 3) |
| 一元左折叠 | (... op args) | (1 op 2) op 3 |
| 二元右折叠 | (args op ... op init) | 1 op (2 op (3 op init)) |
| 二元左折叠 | (init op ... op args) | ((init op 1) op 2) op 3 |
我个人最常用的是检查所有参数是否满足某个条件:
template <typename... Args>
bool allPositive(Args... args) {
return ((args > 0) && ...);
}
这比递归模板实例化不知道高到哪里去了。我曾经在日志系统里用折叠表达式拼接参数,代码量从50行缩到了5行。
27.4 概念(Concepts):给模板加上类型约束
C++20的概念,说白了就是给模板参数加「门槛」。以前模板编译报错那叫一个酸爽——几百行的错误信息,最后发现是传了个不支持++操作的类型。
有了概念,你可以这样写:
template <std::integral T>
T increment(T value) {
return value + 1;
}
这里的std::integral就是一个概念,它要求T必须是整数类型。如果你传个std::string进去,编译器直接报错:template argument deduction/substitution failed: constraints not satisfied。错误信息清晰多了。
你还可以自定义概念:
template <typename T>
concept HasSize = requires(T t) {
{ t.size() } -> std::convertible_to<std::size_t>;
};
template <HasSize T>
void printSize(const T& container) {
std::cout << container.size() << "\n";
}
我记得有一次团队里新来的同事写模板,传错了类型,编译报错刷了满屏。我帮他改成概念约束后,错误信息直接告诉他「你需要一个可迭代的类型」。他当场就感叹:这玩意儿早该有了。
27.5 协程(Coroutines)简介
协程是C++20引入的「可暂停、可恢复」的函数。它跟线程不一样——线程是操作系统调度的,协程是程序员自己控制的。
一个最简单的协程例子:
#include <coroutine>
#include <iostream>
struct Generator {
struct promise_type {
int current_value;
auto get_return_object() { return Generator{*this}; }
auto initial_suspend() { return std::suspend_always{}; }
auto final_suspend() noexcept { return std::suspend_always{}; }
void unhandled_exception() { std::terminate(); }
auto yield_value(int value) {
current_value = value;
return std::suspend_always{};
}
void return_void() {}
};
struct iterator {
Generator& gen;
bool operator!=(std::default_sentinel_t) { return !gen.done; }
void operator++() { gen.resume(); }
int operator*() { return gen.promise.current_value; }
};
bool done = false;
std::coroutine_handle<promise_type> handle;
explicit Generator(promise_type& p) : handle(std::coroutine_handle<promise_type>::from_promise(p)) {}
~Generator() { if (handle) handle.destroy(); }
void resume() {
handle.resume();
if (handle.done()) done = true;
}
iterator begin() { resume(); return iterator{*this}; }
std::default_sentinel_t end() { return {}; }
};
Generator range(int start, int end) {
for (int i = start; i < end; ++i) {
co_yield i;
}
}
int main() {
for (int v : range(0, 5)) {
std::cout << v << " "; // 输出: 0 1 2 3 4
}
}
协程的核心关键字有三个:
co_await:暂停当前协程,等待某个操作完成co_yield:返回一个值,然后暂停co_return:结束协程并返回值
final_suspend,导致内存泄漏。嗯,调试了一下午才发现。
协程最适合的场景是异步编程、惰性生成器、以及需要保存状态的复杂控制流。不过说实话,目前C++20的协程库支持还不够完善,很多功能得自己手写promise_type。等C++23的标准库完善后,应该会好用很多。
27.6 范围库(Ranges):STL算法的现代替代
C++20的范围库,说白了就是给STL算法加了一层「管道操作符」。以前写STL算法,你得传一堆迭代器对:
std::vector<int> v = {1, 2, 3, 4, 5};
std::vector<int> result;
std::copy_if(v.begin(), v.end(), std::back_inserter(result),
[](int x) { return x % 2 == 0; });
用范围库,你可以这样写:
auto result = v | std::views::filter([](int x) { return x % 2 == 0; })
| std::views::transform([](int x) { return x * x; })
| std::ranges::to<std::vector>();
看到那个|操作符了吗?它把数据流从一个视图「管道」到下一个视图。这种写法更接近问题本身的描述:「从v中过滤出偶数,然后平方,最后转成vector」。
范围库的核心组件:
- 视图(Views):惰性求值,不拷贝数据。比如
std::views::filter、std::views::transform - 动作(Actions):立即求值,修改容器。比如
std::ranges::sort、std::ranges::reverse - 范围适配器(Range adaptors):用
|连接视图和动作
filter视图,它不会立即遍历整个容器。只有当你真正遍历这个视图时,才会逐个元素执行过滤。这避免了创建临时容器,节省了内存和CPU。
我在项目中用范围库重构过一个数据处理流水线。原来的代码有七八个for循环,每个循环都创建临时vector。改成范围库后,整个流水线变成了一个管道表达式,性能还提升了15%——因为省去了中间容器的拷贝开销。
不过要注意,范围库目前对编译器的要求比较高。GCC 10+、Clang 13+才支持得比较好。如果你还在用老编译器,可能得等等。
好了,这一章的内容就到这里。这些新特性我每天都在用,尤其是结构化绑定和范围库,几乎成了我的「肌肉记忆」。你如果刚开始接触,建议先从结构化绑定和if constexpr入手——它们最容易上手,也最能立刻提升代码质量。概念和范围库可以慢慢消化,等用熟了再回头看协程——那个确实需要一点时间适应。