一、概念(Concepts):C++20 的约束与概念,如何编写更清晰的模板接口

模板元编程写多了,你一定会遇到这种场景:一个函数模板接收了不该接收的类型,然后编译器吐出一百多行错误信息。你盯着屏幕,从最后一行往上翻,翻到头皮发麻才找到真正的原因——类型不匹配。

我个人习惯把这种体验叫做「模板地狱」。C++20 的概念(Concepts)就是来终结这个地狱的。说白了,它让你能明确告诉编译器:我这个模板参数,必须满足什么条件。

1.1 为什么需要概念?

先看一个老代码的例子:

template <typename T>
T add(T a, T b) {
    return a + b;
}

这段代码有什么问题?你传两个 int 没问题,传两个 double 也没问题。但如果你传两个指针呢?或者传两个 std::string?嗯,string 确实支持 +,但如果你传一个自定义类型,它没重载 operator+,编译器就会报错。

问题在于:这个模板接口没有表达出它对类型的要求。调用者只能靠猜,或者靠试。

我在项目中遇到过好几次这种问题。有一次同事写了一个排序模板,传进去一个没有 operator< 的类型,编译报错后他花了半小时才定位到问题。我当时就想:要是能在接口上直接写清楚「这个类型必须可比较」,该多好。

概念就是干这个的。

1.2 概念的基本语法

概念的定义很简单:

template <typename T>
concept Addable = requires(T a, T b) {
    { a + b } -> std::convertible_to<T>;
};

这段代码的意思是:类型 T 必须满足——a + b 合法,并且结果可以转换成 T 类型。

然后你就可以这样用:

template <Addable T>
T add(T a, T b) {
    return a + b;
}

现在接口清晰了。调用者一看就知道:这个函数只接受「可相加」的类型。如果传了不满足条件的类型,编译器给出的错误信息也会直接指向这个约束,而不是在模板实例化深处报错。

核心变化:从「隐式约束」变成了「显式约束」。错误信息从几十行变成一两行。

1.3 概念的几种用法

概念可以用在多个地方。我整理了一下:

用法示例说明
模板参数约束template <Addable T>最常用,直接约束类型参数
auto 占位符Addable auto x = ...变量声明时约束类型
函数参数void f(Addable auto x)简化函数模板写法
requires 子句requires Addable<T>更灵活的约束组合

举个例子,用 requires 子句可以组合多个约束:

template <typename T>
    requires Addable<T> && std::copyable<T>
T add_and_copy(T a, T b) {
    T result = a + b;
    return result;
}

你想想看,这种写法是不是比之前清晰多了?每个约束都写在明面上。

1.4 标准库中的概念

C++20 标准库已经提供了一组常用概念。我挑几个常用的:

  • std::same_as<T, U>:T 和 U 是同一个类型
  • std::convertible_to<T, U>:T 可以隐式转换成 U
  • std::integral<T>:T 是整数类型
  • std::floating_point<T>:T 是浮点类型
  • std::copyable<T>:T 可拷贝
  • std::equality_comparable<T>:T 支持 == 比较

这些概念可以直接用,省得自己写。比如:

template <std::integral T>
T square(T x) {
    return x * x;
}

这个函数只接受整数类型。传个 double 进来?编译不过。这就是约束的力量。

小技巧:写模板时,先用标准库概念。不够用再自己定义。标准库的概念经过了充分测试,比你手写的更可靠。

1.5 自定义概念:从简单到复杂

标准库概念不够用怎么办?自己写。我带你从简单到复杂看几个例子。

最简单的概念

template <typename T>
concept HasSize = requires(T t) {
    t.size();
};

这个概念要求类型 T 有 size() 成员函数。任何有 size() 的类型都满足,比如 std::vector、std::string、std::map。

带返回类型约束的概念

template <typename T>
concept HasSizeReturningSizeT = requires(T t) {
    { t.size() } -> std::same_as<std::size_t>;
};

这个更严格:不仅要有 size(),返回值还必须是 std::size_t。

多参数概念

template <typename T, typename U>
concept PairLike = requires(T t, U u) {
    t.first;
    t.second;
    { t.first } -> std::same_as<U>;
};

这个概念检查 T 是否有 first 和 second 成员,并且 first 的类型是 U。

我曾经在写一个序列化库时用过类似的概念。我需要确保传入的类型有 serialize 方法,并且返回值是 std::vector<char>。用概念写出来,接口一目了然。

1.6 概念与重载决议

概念还有一个很实用的功能:影响重载决议。当多个模板函数都匹配时,编译器会选择约束更精确的那个。

template <std::integral T>
void process(T x) {
    // 整数版本
}

template <std::floating_point T>
void process(T x) {
    // 浮点版本
}

调用 process(42) 走第一个,process(3.14) 走第二个。编译器自动选择最匹配的约束。这在以前你得用 SFINAE 或者 tag dispatch 才能实现,代码又长又难读。

注意:概念之间不能有歧义。如果两个概念同时匹配,编译器会报错。所以设计概念时,要确保它们是互斥的,或者有明确的优先级。

1.7 概念与 requires 表达式

requires 表达式是定义概念的核心工具。它有四种形式:

  1. 简单要求:检查表达式是否合法
  2. 类型要求:检查类型是否存在
  3. 复合要求:检查表达式的结果类型
  4. 嵌套要求:在 requires 内部再写约束

看个综合例子:

template <typename T>
concept Container = requires(T t, const T ct) {
    // 简单要求:有 begin() 和 end()
    t.begin();
    t.end();
    
    // 类型要求:有 value_type 类型别名
    typename T::value_type;
    
    // 复合要求:begin() 返回迭代器
    { t.begin() } -> std::input_iterator;
    
    // 嵌套要求:迭代器指向的类型是 value_type
    requires std::same_as<
        std::iter_value_t<decltype(t.begin())>,
        typename T::value_type
    >;
};

这个 Container 概念几乎涵盖了 STL 容器的所有要求。写模板时,你可以用它来约束参数必须是容器类型。

1.8 避坑指南

概念虽好,但用起来也有坑。我踩过几个,分享给你:

  • 概念不能递归:概念的定义中不能引用自身。编译器会报循环依赖。
  • 概念不能有定义:概念只是约束,不是函数。你不能在概念里写实现逻辑。
  • 概念求值是短路求值:requires 表达式中的条件从左到右求值,一旦失败就停止。可以利用这一点优化编译速度。
  • 概念不是类型:你不能把概念当作类型用。比如 std::vector<std::integral> 是错的。

我曾经在项目里写了一个递归概念,想检查类型是否满足某种嵌套结构。结果编译器直接崩溃了。嗯,从那以后我记住了:概念不能递归。

1.9 概念的实际应用场景

概念在真实项目中有哪些用武之地?我列几个:

  • 算法库:约束算法参数必须是随机访问迭代器
  • 序列化框架:约束类型必须有序列化/反序列化方法
  • 数学库:约束数值类型必须支持加减乘除
  • 容器适配器:约束元素类型必须可拷贝、可比较
  • 工厂模式:约束工厂函数返回的类型必须继承自某个基类

每个场景都能让接口更清晰,错误信息更友好。

1.10 本章小结

概念是 C++20 给模板编程带来的一次「接口革命」。它让模板从「黑盒」变成了「白盒」——调用者一眼就能看出参数需要满足什么条件。

我个人建议:新项目尽量用概念。老项目可以逐步迁移。先从标准库概念开始,再慢慢自定义。你会发现,代码的可读性和可维护性都会上一个台阶。

记住一句话:好的接口,应该让正确用法显而易见,让错误用法编译不过。概念就是帮你做到这一点的工具。

核心要点回顾:

  • 概念用 requires 表达式定义约束
  • 标准库提供了常用概念,优先使用
  • 概念影响重载决议,约束更精确的优先
  • 概念不能递归,不能有定义
  • 概念让模板接口更清晰,错误信息更友好
概念(Concepts)知识体系 C++20 Concepts 概念定义 template <typename T> concept C = requires(T t) { t.method(); }; 四种用法 ① 模板参数:template <C T> ② auto占位:C auto x ③ 函数参数:void f(C auto) ④ requires子句 标准库概念 std::integral std::floating_point std::same_as std::convertible_to std::copyable 应用场景 算法库 · 序列化 · 数学库 容器适配 · 工厂模式 注意事项 不能递归 · 不能有实现 短路求值 · 不是类型
公众号:蓝海资料掘金营,微信 deep3321