C++20 给模板和算法带来了什么
C++ 模板一直是把双刃剑:表达能力极强,但报错信息能把你淹没在几百行模板实例化的深处,调用方看一眼就头皮发麻。C++20 正式引入的概念(concept)就是来解决这个问题的——它把「类型必须满足什么约束」这件事,从一团隐形的编译错误,变成一段有名字、可复用的显式契约。
同一批标准里还引入了范围(ranges):把「容器 + 迭代器」的配对操作升级成「视图 + 算法管道」。以前你要写嵌套的 for 循环加临时容器,现在可以用 std::views::filter 加 std::views::transform 一条管道描述完整的数据处理意图,代码读起来像在写声明,而不是一步步搬数据。
这两个特性互相成就:范围算法自己就大量使用概念来约束参数,而你写自己的模板时,也能用概念把约束讲清楚。可以说,C++20 让模板从「能编译就是胜利」进化到「意图明确、报错友好」。
概念和范围,一个管住模板的门槛,一个管住算法的写法——C++20 让模板代码第一次有了「说明书」。
- concept给类型约束起名字,让约束可复用。
- requires表达式精确描述类型需要支持的操作。
- 标准概念用标准库概念约束模板参数。
- 范围视图filter 与 transform 等惰性视图。
- 管道组合把多个视图用管道符号串成处理链。
概念:给约束起名字
概念是一组编译期可求值的约束,用 concept 关键字定义,用法和布尔表达式差不多。最常见的写法是「类型满足某个表达式」,比如要求类型 T 支持加法运算:
template <typename T>
concept Addable = requires(T a, T b) {
a + b; // 要求 a + b 能通过编译
};
template <Addable T>
T add(T a, T b) {
return a + b;
}
int main() {
std::cout << add(1, 2) << '\n'; // 3
}
和以前 template <typename T> 的区别在于:约束被写在了参数列表里,而不再是藏在函数体内的静态断言或一堆 SFINAE 技巧。编译器在选择重载、实例化模板时,直接拿概念去匹配,不匹配就给出「约束未满足」的明确提示,而不是把实例化失败的错误倒灌给你。
概念还能参与重载决议:同一个名字,满足不同概念时选不同版本,让模板函数像普通函数一样有「重载感」,这在以前要靠 enable_if 才能实现。
requires 表达式:说清类型要什么
requires 是概念内部的主力语法,它有三种用法:约束类型必须能通过某个表达式、必须能调用某个成员、必须满足嵌套的另一个概念。组合起来,你能把「这个类型要什么」写得和需求文档一样清楚。
template <typename T>
concept Iterable = requires(T t) {
t.begin(); // 要有 begin()
t.end(); // 要有 end()
typename T::value_type; // 要有 value_type 类型
};
template <typename T>
concept Printable = requires(std::ostream &os, const T &v) {
{ os << v } -> std::convertible_to<std::ostream&>;
};
requires 表达式里还能用「表达式加箭头加概念」的形式检查表达式的返回类型,比如输出运算符的结果要能转换成 ostream 引用。这相当于在类型层做「编译期测试」,比单纯的能否编译更进一步。
还有 requires 子句(用在函数或类模板后面)和 requires 表达式是两回事:前者是「满足条件才启用这个模板」,后者是「描述一个条件」。理解了这一对区别,概念就上手了。
标准概念库
C++20 在 <concepts> 头文件里带了一套标准概念,覆盖语言层面的常见要求:same_as、convertible_to、integral、floating_point、default_initializable、copyable 等等。写模板时优先用标准概念,少造轮子。
#include <concepts>
template <std::integral T>
T fact(T n) {
T r = 1;
for (T i = 1; i <= n; ++i) r *= i;
return r;
}
static_assert(std::integral<int>); // 通过
static_assert(!std::integral<double>); // 通过:double 不是整型
标准概念还能用类型特质实现,但概念的表达力和可组合性更强。概念可以互相包含:std::integral 是 std::signed_integral || std::unsigned_integral 的缩写,这种层次让约束可以像积木一样搭。
范围库自己定义的概念(如 std::ranges::input_range、std::ranges::sized_range)更值得研究——它们描述了「一个范围要满足什么才算合格」,是理解 ranges 算法签名的钥匙。
范围:视图与管道
范围(ranges)把「迭代器对(begin/end)」抽象成「范围」这一层。配合 std::views 命名空间里的视图,你可以对数据做惰性的筛选、变换、截取,再用管道符号把它们串起来:
#include <ranges>
#include <vector>
#include <iostream>
int main() {
std::vector<int> v{1, 2, 3, 4, 5, 6};
auto r = v
| std::views::filter([](int x) { return x % 2 == 0; })
| std::views::transform([](int x) { return x * x; });
for (int x : r) std::cout << x << ' '; // 4 16 36
}
这段代码以前要写两个循环加一个临时 vector;现在一条管道搞定,而且整个过程不产生新的容器——视图只是「描述如何变换」的对象,真正遍历时才逐元素计算。数据量大时,省掉的临时分配非常可观。
范围还带来了带范围版本的算法,比如 std::ranges::sort(v) 直接接收容器,不用再写 begin 和 end;std::ranges::find_if、std::ranges::count_if 同理。迭代器对时代的啰嗦终于被收进了标准库。
惰性求值与无限范围
视图最大的特点之一是惰性:管道只是被构建出来,直到你开始遍历,每个元素才按需经过各道视图。这意味着你可以描述「无限范围」,只要不把它整个收集起来就不会爆内存。
auto naturals = std::views::iota(1); // 1,2,3,... 无限自然数
auto first_squares = naturals
| std::views::transform([](int x) { return x * x; })
| std::views::take(5); // 只要前 5 个
for (int x : first_squares) std::cout << x << ' '; // 1 4 9 16 25
std::views::iota 生成递增序列,take 截取前 N 个。因为求值是惰性的,前 5 个算完就停,后面的数根本不会产生。这在「按需取数据」的场景(分页、采样、流处理)里特别合适。
惰性也带来一个注意点:视图默认不拥有数据。如果底层容器在遍历前被销毁或改变大小,视图行为未定义。所以短生命周期内使用视图没问题,但别把视图存进长期对象、再回头用已经析构的容器。
实战:用 ranges 重写数据处理管线
把概念和范围一起用,写一个完整的小程序:读一组分数,过滤不及格、按降序取前 3 名。先定义概念约束分数类型,再用范围管道处理。
#include <ranges>
#include <vector>
#include <concepts>
#include <iostream>
template <typename T>
concept Score = std::integral<T> || std::floating_point<T>;
template <Score T>
std::vector<T> top3(std::vector<T> scores) {
auto v = scores
| std::views::filter([](T s) { return s >= 60; }) // 及格
| std::ranges::to<std::vector>(); // 收集成 vector
std::ranges::sort(v, std::greater<>{}); // 降序
v.resize(std::min<size_t>(3, v.size()));
return v;
}
int main() {
std::vector<double> s{45, 78, 91, 59, 66, 88};
for (auto x : top3(s)) std::cout << x << ' '; // 91 88 78
}
整个函数读起来就是一段「需求描述」:过滤、收集、排序、截断。概念把分数的类型约束写在签名上,传一个字符串进来编译器直接说「约束不满足」,而不是报一堆模板深处的错误。这就是 C++20 之后写模板的日常手感。
概念让约束可读,范围让算法可读——C++20 之后,模板代码第一次可以「按需读、按意图写」。