写模板的时候,你大概有过这种体验。
代码写完了,编译一下,屏幕上哗啦啦滚出几十行报错。你盯着看了半天,发现真正的问题其实就一句话——你传了个不该传的类型进去。
但编译器不会直接告诉你"这个类型不行"。它会把模板一层一层展开,每展开一层就报一次错,最后你看到的是一棵错误树,而不是一句话。
这就是 C++20 引入 Concepts 的背景。它想解决的问题很朴素:能不能在模板入口就把不合格的类型挡住,而不是等到模板内部炸了才报错?
先感受一下痛:没有 Concepts 的日子
我们从一个最简单的场景开始。
假设你想写一个函数,把两个数加起来。用模板很自然:
template <typename T>
T add(T a, T b) {
return a + b;
}
用整数调用,没问题:
int result = add(3, 5); // 8
用浮点数调用,也没问题:
double result = add(1.5, 2.3); // 3.8
但如果有人这样调用呢:
struct Point { int x, y; };
Point p1{1, 2};
Point p2{3, 4};
auto result = add(p1, p2); // 编译报错
Point 没有定义 operator+,所以编译器会报错。问题是,报错信息不会说"Point 不支持加法"。它会说一堆关于 operator+ 找不到匹配的候选函数之类的话,夹杂着模板实例化的上下文,读起来很费劲。
这还只是一层模板。如果你的 add 被另一个模板调用,那个模板又被另一个模板调用,错误信息会像俄罗斯套娃一样越滚越长。
祖传手艺:enable_if
在 C++20 之前,程序员们也想过办法来限制模板参数。最常见的手段是 std::enable_if,配合 <type_traits> 里的各种检查。
比如你想让 add 只接受算术类型(整数和浮点数),可以这样写:
#include <type_traits>
template <typename T>
std::enable_if_t<std::is_arithmetic_v<T>, T>
add(T a, T b) {
return a + b;
}
这段代码的意思是:只有当 T 满足 std::is_arithmetic_v<T>(即 T 是算术类型)时,这个函数才存在。否则编译器会当它不存在,直接跳过。
它确实能用。传 Point 进去,编译器会说"没有匹配的函数",比之前那一堆模板展开错误清爽多了。
但你看看这个函数签名。返回类型那一行写的是 std::enable_if_t<std::is_arithmetic_v<T>, T>。你第一眼能看出这个函数返回 T 吗?很难。真正的返回类型藏在 enable_if_t 的第二个参数里。
这就是 enable_if 的问题:它能干活,但它把"约束条件"和"函数签名"搅在一起了。你想表达的是"这个函数只接受算术类型",但写出来的东西看起来像在跟编译器玩文字游戏。
C++20 的回答:concept
C++20 引入了一个新关键字 concept。它的作用很直接——给模板参数定义一个"准入条件",写在模板声明上,一眼就能看出来。
我们先用标准库里现成的 concept 来改写刚才的 add:
#include <concepts>
template <typename T>
requires std::integral<T> || std::floating_point<T>
T add(T a, T b) {
return a + b;
}
std::integral<T> 是标准库提供的一个 concept,意思是"T 是整数类型"。std::floating_point<T> 意思是"T 是浮点类型"。中间用 || 连起来,表示"T 是整数或者浮点数都行"。
现在你再传 Point 进去:
Point p1{1, 2};
Point p2{3, 4};
auto result = add(p1, p2); // 编译报错
编译器会直接告诉你:Point 不满足约束条件。不会再有那一长串模板展开的噪音了。
concept 到底是什么
说白了,concept 就是一个编译期的 bool。
它接受一个或多个模板参数,返回 true 或 false。如果返回 true,说明这个类型满足条件,可以进来;如果返回 false,编译器直接拒绝,不会尝试去实例化模板。
标准库里已经定义好了很多常用的 concept。比如 std::integral<T> 的定义大致等价于:
template <typename T>
concept integral = std::is_integral_v<T>;
右边的 std::is_integral_v<T> 就是 C++11 就有的 type trait,它本身就是一个编译期的 bool。所以 concept 并没有发明新的检查机制,它只是给这些检查起了个名字,然后让你能把这个名字写在模板声明上。
自己写一个 concept
标准库的 concept 覆盖了很多常见场景,但有时候你需要自定义。
比如你想写一个函数,要求传入的类型必须支持 < 比较。你可以这样定义一个 concept:
template <typename T>
concept LessThanComparable = requires(T a, T b) {
{ a < b } -> std::convertible_to<bool>;
};
我们一行一行看。
template <typename T> 说明这个 concept 接受一个类型参数 T。
concept LessThanComparable 给这个约束起了个名字。
requires(T a, T b) 是一个 requires 表达式。它假设你有两个 T 类型的值 a 和 b,然后检查后面花括号里的条件。注意,a 和 b 不是真正的变量,不会被创建出来,它们只是编译器用来做检查的"假想值"。
{ a < b } 表示"表达式 a < b 必须合法"。如果 T 没有定义 operator<,这个检查就会失败。
-> std::convertible_to<bool> 表示"这个表达式的结果必须能转换成 bool"。这是为了防止有人定义了一个 operator< 但返回的不是布尔值。
现在你可以用它了:
template <LessThanComparable T>
T my_min(T a, T b) {
return (a < b) ? a : b;
}
用 int 调用:
int x = my_min(3, 7); // 3,没问题
用 std::string 调用:
std::string s = my_min(std::string("apple"), std::string("banana"));
// "apple",也没问题,string 支持 < 比较
但如果你传一个没有 < 运算符的类型:
struct Color { int r, g, b; };
Color c1{255, 0, 0};
Color c2{0, 255, 0};
auto c = my_min(c1, c2); // 编译报错:Color 不满足 LessThanComparable
编译器会明确告诉你 Color 不满足 LessThanComparable 约束。
requires 表达式里能检查什么
requires 表达式不只能检查"某个运算符存不存在"。它能检查的东西比你想象的多。
最基本的,你可以检查某个表达式是否合法:
template <typename T>
concept Printable = requires(T x) {
std::cout << x;
};
这个 concept 检查的是:能不能把 T 类型的值用 << 输出到 std::cout。int、double、std::string 都满足,但一个自定义的 struct 如果没有重载 operator<<,就不满足。
你也可以检查某个成员函数是否存在:
template <typename T>
concept HasSize = requires(T x) {
x.size();
};
std::vector<int>、std::string、std::map<int,int> 都满足这个约束,因为它们都有 .size() 方法。但 int 或者一个普通的 struct 就不满足。
你还可以同时检查多个条件:
template <typename T>
concept Container = requires(T c) {
c.begin();
c.end();
c.size();
};
这个 concept 要求类型同时有 begin()、end() 和 size() 三个方法。标准库的容器基本都满足。
如果你还想检查返回类型,可以加上箭头约束:
template <typename T>
concept SizedContainer = requires(T c) {
{ c.size() } -> std::convertible_to<std::size_t>;
c.begin();
c.end();
};
这里 { c.size() } -> std::convertible_to<std::size_t> 的意思是:c.size() 不仅要能调用,返回值还得能转换成 std::size_t。
四种写法,同一件事
C++20 提供了好几种语法来使用 concept,它们做的事情完全一样,只是写法不同。
假设我们有一个 concept:
template <typename T>
concept Numeric = std::integral<T> || std::floating_point<T>;
下面四种写法都表示"T 必须满足 Numeric":
第一种,requires 子句:
template <typename T>
requires Numeric<T>
T square(T x) {
return x * x;
}
requires 关键字后面跟约束条件,写在模板参数和函数签名之间。
第二种,直接替换 typename:
template <Numeric T>
T square(T x) {
return x * x;
}
把 typename T 换成 Numeric T,意思完全一样,但更简洁。这是最常用的写法。
第三种,尾置 requires:
template <typename T>
T square(T x) requires Numeric<T> {
return x * x;
}
把 requires 放在函数参数列表后面。这种写法在某些场景下更方便,比如约束条件依赖函数参数的类型时。
第四种,简写(auto 语法):
Numeric auto square(Numeric auto x) {
return x * x;
}
这是最短的写法。Numeric auto x 的意思是"x 的类型由编译器推导,但必须满足 Numeric"。返回类型前面的 Numeric auto 同理。
四种写法选哪种?看团队习惯。如果约束简单,第二种(template <Numeric T>)最清爽。如果约束复杂,第一种(requires 子句)更灵活。
组合约束:用 && 和 || 拼条件
concept 之间可以用逻辑运算符组合。
比如你想要求一个类型既是整数,又是有符号的:
template <typename T>
requires std::integral<T> && std::signed_integral<T>
T negate(T x) {
return -x;
}
这样 int、long 可以通过,但 unsigned int 不行。
你也可以把组合条件定义成一个新的 concept:
template <typename T>
concept SignedNumber = std::integral<T> && std::signed_integral<T>;
然后直接用:
template <SignedNumber T>
T negate(T x) {
return -x;
}
用 || 也一样。前面的 Numeric 就是一个例子:
template <typename T>
concept Numeric = std::integral<T> || std::floating_point<T>;
整数或浮点数,都算数值类型。
一个稍微完整的例子:安全的除法
把前面学到的东西串起来,写一个实际一点的例子。
我们想写一个除法函数,要求:参数必须是算术类型(整数或浮点数),而且除数不能是零。类型检查在编译期做,零值检查在运行时做。
先定义 concept:
template <typename T>
concept Arithmetic = std::integral<T> || std::floating_point<T>;
然后写函数:
#include <stdexcept>
template <Arithmetic T>
T safe_divide(T a, T b) {
if (b == 0) {
throw std::invalid_argument("division by zero");
}
return a / b;
}
用整数调用:
int x = safe_divide(10, 3); // 3(整数除法)
用浮点数调用:
double y = safe_divide(10.0, 3.0); // 3.33333...
传一个字符串进去:
auto z = safe_divide(std::string("a"), std::string("b"));
// 编译报错:string 不满足 Arithmetic 约束
编译器会在入口就拦住,不会等到函数体里 a / b 那一行才报错。
concept 和 enable_if 的对比
回头看一下,同样是"只接受算术类型",两种写法放在一起:
enable_if 版本:
template <typename T>
std::enable_if_t<std::is_arithmetic_v<T>, T>
safe_divide(T a, T b) { ... }
concept 版本:
template <Arithmetic T>
T safe_divide(T a, T b) { ... }
concept 版本的函数签名干干净净,返回类型就是 T,约束条件写在模板参数上。enable_if 版本的返回类型被约束条件"污染"了,你得仔细看才能找到真正的返回类型。
更重要的是报错信息。enable_if 失败时,编译器会说"没有匹配的函数",但不会告诉你为什么没匹配。concept 失败时,编译器会说"类型不满足某某约束",直接点名是哪个条件没过。
小结
Concepts 做的事情,一句话就能说清楚:把模板对类型的要求写在明面上。
以前这些要求藏在 enable_if 里,藏在模板体内部的隐式假设里。类型不对的时候,编译器只能在模板展开的深处报错,你得自己翻半天才能找到原因。
现在有了 concept,要求写在模板入口。类型不对,入口就拦住。报错信息也从"天书"变成了"人话"。
它不是什么新魔法。它只是让模板终于学会了"先说清楚自己要什么"。