C++20 给模板和算法带来了什么

C++ 模板一直是把双刃剑:表达能力极强,但报错信息能把你淹没在几百行模板实例化的深处,调用方看一眼就头皮发麻。C++20 正式引入的概念(concept)就是来解决这个问题的——它把「类型必须满足什么约束」这件事,从一团隐形的编译错误,变成一段有名字、可复用的显式契约。

同一批标准里还引入了范围(ranges):把「容器 + 迭代器」的配对操作升级成「视图 + 算法管道」。以前你要写嵌套的 for 循环加临时容器,现在可以用 std::views::filter 加 std::views::transform 一条管道描述完整的数据处理意图,代码读起来像在写声明,而不是一步步搬数据。

这两个特性互相成就:范围算法自己就大量使用概念来约束参数,而你写自己的模板时,也能用概念把约束讲清楚。可以说,C++20 让模板从「能编译就是胜利」进化到「意图明确、报错友好」。

概念和范围,一个管住模板的门槛,一个管住算法的写法——C++20 让模板代码第一次有了「说明书」。
本文核心知识点
  • concept给类型约束起名字,让约束可复用。
  • requires表达式精确描述类型需要支持的操作。
  • 标准概念用标准库概念约束模板参数。
  • 范围视图filter 与 transform 等惰性视图。
  • 管道组合把多个视图用管道符号串成处理链。
C++20 概念与范围实战配图:青蓝渐变背景,一条横向发光管道流水线贯穿画面,多个半透明筛选闸门串联,彩色几何小体经闸门分流、滑道变形重组后汇入右侧发光输出容器
闸门依次筛选、滑道逐段变形、容器统一收口——范围视图的管道,就像这条流水线。

概念:给约束起名字

概念是一组编译期可求值的约束,用 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 同理。迭代器对时代的啰嗦终于被收进了标准库。