SFINAE(替换失败不是错误)这玩意儿吧,说白了就是编译器的一句潜台词:
“大哥,这个模板我照着你的要求展开失败了……没事,我不报错,我就当没看见这个,咱换下一个试试。”
这跟C++一贯的“宁可我负天下人”的报错风格完全不一样,所以第一次见容易懵。
那么,废话不多说,开干!(好吧,其实我挺想多说点废话的,奈何本人不喜欢喝墨水,导致肚子里只有饭(‾◡◝ ))
SFINAE 的基本原理
我们先从根上了解了解。
1. 模板实例化与替换
想象你是一个HR(编译器),面前有一堆简历(函数模板)。每份简历上都写着:“我能处理某类参数,但具体得看你的要求(模板参数)”。现在你要给一个具体的函数调用匹配一个人选。
于是你拿起一份简历,开始根据调用现场推导模板参数(比如 T 是 int 还是 string),然后把推导出来的东西填回简历的每一个空位——这个“填空”过程就是替换。
正常情况下,替换很顺利,这份简历就进入下一轮面试(重载决议)。
当然,有正常情况就会有不正常情况啦,如果替换过程中出现了语法上根本不可能成立的东西,比如:
template<typename T>
void foo(typename T::type x); // 期待 T 有一个叫 type 的成员类型
调用 foo(21),推导出 T=int,接着替换:int::type 是个什么鬼?这根本不合法。
这时候如果编译器直接“啪”一个错误终止,那就没有 SFINAE 什么事了。
但 C++ 的设计者说:我知道你很急,但你先别急,这只是一份简历,它不合适,你直接扔了就行,别影响你继续看下一个人。
这就是 SFINAE(Substitution Failure Is Not An Error)——替换失败不是错误。
失败的那个模板就默默地从候选名单里消失,编译器接着看下一个重载。
2. “不是错误”的真实含义
这个“不是错误”只发生在函数模板重载集生成的瞬间。
一旦模板被成功替换(即没有失败),后面如果发生别的错误(比如在函数体里用了不存在的成员),那就是硬错误,神仙来了也救不了——我说的( ̄^ ̄)。
SFINAE 的温柔只给“替换”这个阶段,过后该崩还是崩。
所以很多教科书会说:SFINAE 让我们可以根据类型特征来开关重载。
3. 最简单的例子:用 std::enable_if 控制重载
std::enable_if 是个经典的 SFINAE 触发器。
它的原理很简单:如果条件为真,它有一个 type 成员;如果条件为假,它就没有 type。
#include <iostream>
#include <type_traits>
// 只接受整数类型
template<typename T>
typename std::enable_if<std::is_integral<T>::value, void>::type
foo(T t)
{
std::cout << "integral: " << t << std::endl;
}
// 只接受浮点类型
template<typename T>
typename std::enable_if<std::is_floating_point<T>::value, void>::type
foo(T t)
{
std::cout << "floating: " << t << std::endl;
}
int main()
{
foo(21); // integral: 21
foo(3.14); // floating: 3.14
// foo("hello"); // 编译错误,找不到匹配的foo
return 0;
}
当调用 foo(21) 时:
- 第一个模板:is_integral 为真 → enable_if 有 type → 替换成功,生成一个 void foo(int)。
- 第二个模板:is_floating_point 为假 → enable_if 没有 type → 替换失败 → 这个重载被直接丢掉。
- 最后只剩下一个候选,完美。
这里 SFINAE 帮我们优雅地根据类型特征选择重载,避免了用特化或者运行时判断的麻烦。
4. SFINAE 与重载决议的“时间线”
很多人搞不清 SFINAE 和重载决议的顺序,我们画个简易流程图:
所以 SFINAE 是发生在候选集构建阶段的过滤机制,它决定了哪些候选能“活到”重载决议那一步。
5. 为什么叫“不是错误”?
因为如果替换失败导致编译错误,整个程序就挂了。
而 SFINAE 允许我们写一些试探性的模板,编译器会替我们自动尝试所有可能性,只要至少有一个能成功,就不会报错。
我们可以把它想象成编译器内部有一个“try...catch”专门为模板替换准备的:
“诶,这个模板展开到一半炸了,不慌,我把它删了,试试下一个。”
C++98/03 时代:SFINAE 的起源
我们把时间拨回到 C++98/03 年代——那个没有 auto、没有 decltype、甚至 vector::iterator 都要打两遍的时代。
1. 历史背景:SFINAE 是怎么被发现的
SFINAE 这个词其实是在 C++98 标准制定期间 才被正式命名的。
但在更早的 C++ 实现里,编译器遇到模板替换失败时,有些会直接报错退出,有些则会“跳过”那个重载。
标准委员会经过讨论,决定把这个行为规范化:替换失败不应终止整个编译过程。
也就是说,SFINAE 从一开始就是 C++ 模板重载机制的一部分,而不是后来才加的补丁。只不过当时它藏在一堆标准条文里,大多数人根本没注意到。
直到有一天,有人灵光一闪:
“等等,既然替换失败会把这个重载删掉,那我是不是可以‘故意’制造失败,来实现编译期的条件选择?”
好家伙,这一下直接打开了新世界的大门。
2. 早期应用:sizeof 侦探法
C++98/03 时代没有 <type_traits>,没有 std::enable_if。要想在编译期判断一个类型是否有某个嵌套类型或成员函数,只能靠“手工打磨” SFINAE。
最经典的套路就是 sizeof 侦探法。
它的核心思路:
- 写两个重载的辅助函数,一个用 SFINAE 检测目标特征(成功时返回一个“大”类型),另一个是回退函数(返回一个“小”类型)。
- 调用辅助函数,通过 sizeof 判断返回类型的大小,就知道走的是哪个分支。
举个栗子:检测一个类型是否有 value_type 嵌套类型。
template<typename T>
struct has_value_type
{
typedef char yes[1];
typedef char no[2];
template<typename U>
static yes& test(typename U::value_type*); // 只有 U::value_type 存在时这个重载才有效
template<typename U>
static no& test(...); // 万能回退
static const bool value = sizeof(test<T>(0)) == sizeof(yes);
};
工作原理:
- 如果 T 有 value_type,那么 typename U::value_type* 是一个合法的指针类型,第一个 test 替换成功,返回 yes&,sizeof 为 1。
- 如果没有,第一个 test 替换失败(SFINAE 把它删了),只能匹配第二个 test(...),返回 no&,sizeof 为 2(或更大)。
- 比较 sizeof,就知道结果了。
这招还能用来检测成员函数、检测是否可转换、检测是否有特定操作符……几乎所有的“编译期反射”都靠这一套打法。
3. 局限性:手工时代的痛苦
这种“远古 SFINAE”虽然强大,但用起来真的像是在石器时代磨石斧。
3.1 代码又臭又长
每个检测都要写一堆模板结构,还离不开 sizeof、typedef char[1]、typedef char[2] 这种“魔法”。
要是写十个这样的 trait,代码量直接起飞,注释都得写半页。
3.2 错误信息是噩梦
当 SFINAE 失败时,编译器不会报错,但当找不到任何可行重载时,就会吐出一整屏的模板推导失败信息。
3.3 缺乏标准库支持
没有 <type_traits>,连 is_integral 都得自己写(还得自己列一堆 int、long、short……)。
3.4 组合困难
想表达“如果类型有 begin() 成员函数,并且返回值可转换为迭代器”这种复杂条件,基本得靠多层嵌套模板,写出来的东西没人能一次看懂。
4. 总结
C++98/03 的 SFINAE 就像是原始人第一次学会用火——笨拙、危险、容易烧到自己,但它打开了一个全新的世界。
现在回头看,那些古老的 SFINAE 写法虽然丑陋,但背后的思想至今仍在发光:让编译器帮我们试探类型的特征,并在编译期做出选择。
理解那段历史,我们才能理解为什么现代 C++ 的很多设计要那样写——因为那都是前辈们在废墟上一点点垒起来的。
C++11:SFINAE 的现代化
以前写个检测 trait 要搭积木似的堆 sizeof、typedef char[1],现在有了 decltype、declval、std::enable_if 和一套标准库的 type traits,感觉整个模板元编程的逼格都被拉高了。
1. decltype 和 declval
decltype(expr):告诉编译器“你帮我看看这个表达式是啥类型,但我不用真的求值”。
declval():假装构造一个 T 类型的对象(只在未求值上下文里用),专门用来调成员函数或操作符。
这两个一联手,检测成员函数就变成了:
// 检测 T 是否有 foo() 成员函数
template<typename T>
struct has_foo
{
private:
template<typename U>
static auto test(int) -> decltype(std::declval<U>().foo(), std::true_type{});
template<typename U>
static auto test(...) -> std::false_type;
public:
static constexpr bool value = decltype(test<T>(0))::value;
};
核心逻辑:
- 如果 declval().foo() 合法,那么 decltype 会推导出该表达式的类型。
- 然后逗号表达式后面接着一个 std::true_type{},最终返回类型就是 std::true_type。
- 否则 SFINAE 把第一个重载丢掉,走回退的 ... 返回 std::false_type。
2. std::enable_if:开关模板的官方工具
std::enable_if 是 C++11 新增的 <type_traits> 里的神器。
它的原理我们之前就介绍过:当条件为真时,它有一个 type 成员;条件为假时,没有。
于是我们可以这样写:
template<typename T>
typename std::enable_if<std::is_integral<T>::value, void>::type
foo(T t)
{
// 只处理整数
}
但更现代的写法是用 默认模板参数,让返回类型清爽一点:
template<typename T, typename = typename std::enable_if<std::is_integral<T>::value>::type>
void foo(T t)
{
// 只处理整数
}
不过这种写法有个坑:两个函数模板如果只有 enable_if 条件不同,默认模板参数不会参与重载决议,容易导致歧义。
所以更稳妥的是把 enable_if 放在返回类型或者函数参数里。
C++11 还让类模板偏特化也能用 SFINAE,结合 enable_if 和偏特化,可以根据条件选择不同的类实现。
// 主模板:默认没有 value_type
template<typename T, typename = void>
struct has_value_type : std::false_type
{
static void print()
{
std::cout << "T 没有成员类型 'value_type'" << std::endl;
}
};
// 偏特化:仅当 T::value_type 是存在且合法时匹配
template<typename T>
struct has_value_type<T,
typename std::enable_if<
!std::is_void<typename T::value_type>::value
>::type> : std::true_type
{
static void print()
{
std::cout << "T 拥有一个成员类型 'value_type'" << std::endl;
}
};
int main()
{
has_value_type<int>::print(); // 输出:T 没有成员类型...
has_value_type<std::vector<int>>::print(); // 输出:T 拥有一个成员类型...
return 0;
}
说明:
- 主模板第二个模板参数默认为 void。
- 偏特化在 T::value_type 存在时,通过 std::enable_if 将第二个模板参数推导为 void,从而匹配主模板的默认实参,产生一个更特化的版本。
- 这里 std::enable_if<条件>::type 在条件为真时是 void,否则 SFINAE 丢弃该偏特化。
- 这种技法被称为 “void_t 模式” 的前身,在 C++11 中常用来检测类型成员。
这个例子看起来确实有点绕,初学者没看懂没关系,可以去泡杯茶放松放松,然后跳过它ヽ(●´ε`●)ノ。(不要难为自己,该摸鱼时就摸鱼)
3. 尾置返回类型 + decltype
C++11 引入了尾置返回类型,写法是:
auto func(Args...) -> decltype(/* 依赖参数的表达式 */)
这个语法最大的好处是:返回类型推导可以依赖函数参数的名字,而这些参数在普通前置返回类型里是看不到的。
对于 SFINAE 来说,这就意味着我们可以直接在返回类型里用参数做检测,如果检测失败,这个重载就被 SFINAE 踢掉。
经典的例子:检测一个容器是否有 size() 成员函数,并且返回类型是 size_t(或可转换为 size_t):
template<typename C>
auto size(const C& c) -> decltype(c.size())
{
return c.size();
}
但如果 C 没有 size(),SFINAE 就会把这个模板从重载集里移除。
4. 改进的类型特征
C++11 标准化了 <type_traits>,提供了大量编译期类型判断工具:
- 类型判断:std::is_integral, std::is_class, std::is_polymorphic……
- 类型转换:std::remove_const, std::add_pointer, std::decay……
这些 traits 内部其实还是基于 SFINAE 或模板特化实现的,但我们现在根本不用关心内部细节,直接拿来用就行。
例如,以前要写一个“只接受整数或浮点数”的模板,我们得手写两个 enable_if 并用 || 自己拼。现在可以直接用 std::is_arithmetic:
template<typename T,
typename = typename std::enable_if<
std::is_arithmetic<T>::value
>::type>
void numeric_func(T t) { /* ... */ }
干净,语义清晰,看代码的人也能一眼读懂我们的意图。
5. C++11 之后的“SFINAE 体验”
对比 C++98,C++11 带来的不仅仅是语法糖,更是思维方式的变化:
- 从 “我能检测到吗?” 变成 “我有现成的 trait 吗?”
- 从 手动计算 sizeof 大小 变成 直接返回 std::true_type / std::false_type
- 从 只能在函数模板里做文章 变成 类模板、变量模板(C++14)全面支持
C++14:SFINAE 的增强
C++14 在很多人眼里是个“小版本”——没有 Concepts,没有模块,甚至连 if constexpr 都要等到 C++17。
但是对于 SFINAE,C++14 其实是舒适度大幅提升的一次更新。
它没动 SFINAE 的底层规则,却从语法和工具层面给了我们三个“爽点”:变量模板、函数模板自动返回类型推导、泛型 lambda。
我们一个一个慢慢来。
1. 变量模板:让 type traits 告别 ::value
C++11 的 <type_traits> 很好用,但每次写 std::is_integral::value 总觉得啰嗦。
C++14 引入了 变量模板,给每个 traits 配了一个 _v 后缀版本:
template<class T>
inline constexpr bool is_integral_v = is_integral<T>::value;
从此:
// C++11
std::enable_if<std::is_integral<T>::value, void>::type
// C++14
std::enable_if_t<std::is_integral_v<T>, void> // 配合 _t 后缀更方便
_t 后缀(比如 enable_if_t)也是 C++14 的,它把 typename ...::type 这个样板也消灭了。
这对 SFINAE 有啥增强?
其实没有改变 SFINAE 的能力,但提高了代码可读性,也能偷个懒少打几个字符(๑¯∀¯๑)。
2. 函数模板返回类型推导
C++11 的尾置返回类型已经很好了,但有时候我们还是得写一大串 decltype(/ ... /),尤其是在返回类型完全依赖于参数表达式时。
C++14 允许普通函数模板直接写 auto 返回类型,编译器会自动推导,而且推导过程仍然遵循 SFINAE——如果推导失败,这个重载就会被丢弃。
比如写一个取容器第一个元素的函数:
template<typename C>
auto front(C& c)
{
return c.front(); // 如果 C 没有 front(),推导失败 -> SFINAE 丢弃此模板
}
如果放在重载集里,只有那些有 front() 的容器才能通过。
这里要注意一个细节:返回类型推导发生在替换之后。
C++14 的规则是:先进行模板参数推导和替换,如果替换成功,再去推导 auto 的实际类型。
如果 auto 推导失败(比如函数体里 return 语句的类型不一致),这个模板照样会被 SFINAE 丢弃。
这就允许我们把 SFINAE 的“触发点”从返回类型挪到函数体里,写起来更自然。
不过这种写法也有个隐患:函数体里的所有代码都会被检查(哪怕推导失败前)。
但好在 C++14 的 auto 返回类型推导是“先替换,再检查”,所以不会因为函数体里的非法代码而提前崩掉——前提是那些非法代码只在推导失败时才出现。
通常 SFINAE 还是建议把条件放在返回类型或参数上,避免函数体里出现硬错误。
升级版:decltype(auto)
C++14 还带来了 decltype(auto),用来完美转发返回类型。
在泛型代码中,我们经常需要保持表达式的精确类型(包括引用性)。用 decltype(auto) 可以做到,而且它也支持 SFINAE。
template<typename C>
decltype(auto) front(C& c)
{
return c.front();
}
如果 C 没有 front(),替换失败,照样 SFINAE 掉。
这个写法在返回类型上比 auto 更精确,但本质仍是 SFINAE 友好的。
3. 泛型 lambda:把 SFINAE 带到局部
模板 lambda 内部也能利用 SFINAE 吗?当然能!
一个实际的例子:用泛型 lambda 配合 decltype 和 std::void_t 检测成员类型。
struct checker
{
// 若 U::value_type 存在,此重载被选中
template <typename U, typename = std::void_t<typename U::value_type>>
static std::true_type test(int);
// 万能回退重载,使用省略号匹配
template <typename U>
static std::false_type test(...);
};
int main()
{
auto check_value_type = [](auto&& arg) {
// 去除引用和 cv 限定符
using T = std::decay_t<decltype(arg)>;
// 通过 decltype 获取 test 返回值的类型,并构造一个该类型的对象返回
return decltype(checker::test<T>(0)){};
};
std::cout << std::boolalpha;
std::cout << "T has value_type? "
<< decltype(check_value_type(std::vector<int>{}))::value
<< std::endl; // T has value_type? true
std::cout << "T has value_type? "
<< decltype(check_value_type(21))::value
<< std::endl; // T has value_type? false
return 0;
}
说明:
- 泛型 lambda:使用 auto&& arg 捕获任意类型,利用 std::decay_t 获取裸类型。
- decltype 与 test 调用:checker::test(0) 根据 SFINAE 规则选择正确的重载,decltype 捕获返回类型(std::true_type 或 std::false_type),然后构造一个临时对象返回。
- 外部提取结果:通过 decltype(has_value_type(...))::value 获取布尔值。
C++17:if constexpr 带来的变革
C++17 在 SFINAE 的发展历程中,更像是一次 “流程优化”而非“底层重构” 。
它没有改变 SFINAE 的规则,却用 if constexpr、void_t、折叠表达式这些工具,把我们从“必须用 SFINAE 做一切”的思维中解放了出来。
1. if constexpr 简介
if constexpr 语法和普通 if 一样,但条件必须是编译期常量,而且不满足条件的分支根本不会被实例化。
传统 SFINAE 写法:
想根据类型特征做不同操作,我们得写多个重载,用 enable_if 控制哪个生效,之前已经介绍过了。
template<typename T>
typename std::enable_if<std::is_integral<T>::value, void>::type
foo(T t) { /* 整数版本 */ }
template<typename T>
typename std::enable_if<std::is_floating_point<T>::value, void>::type
foo(T t) { /* 浮点版本 */ }
C++17 的 if constexpr 写法:
template<typename T>
void foo(T t)
{
if constexpr (std::is_integral_v<T>)
{
// 整数版本
} else if constexpr (std::is_floating_point_v<T>)
{
// 浮点版本
}
}
两个版本的区别:
- SFINAE 版本:两个重载独立,通过 enable_if 让不合适的消失。
- if constexpr 版本:只有一个函数模板,内部根据条件“编译期剪枝”。
在 if constexpr 的假分支里,代码不会进行语法检查(除了名字查找等基础解析)。
这意味着我们可以在假分支里写一些在当前类型下非法的代码,只要它在真分支下合法,整体编译就能通过。
template<typename T>
void process(T t)
{
if constexpr (std::is_pointer_v<T>)
{
*t = 42; // 如果 T 是指针,这里合法
} else
{
t = 42; // 如果 T 不是指针,这里可能不合法,但因为不实例化,所以完全 OK
}
}
这种能力让模板函数内部的分支变得异常灵活。
2. if constexpr vs SFINAE:不是替代,是分工
我们了解完 if constexpr 之后,会认为它将终结 SFINAE?
那就错了,它们各有各的分工:
| 场景 | SFINAE | if constexpr |
|---|---|---|
| 控制函数模板是否存在于重载集中 | 可以(让某个重载消失) | 不能(函数本体始终存在) |
| 函数内部根据类型做不同处理 | 可以(通过多个重载),但分散 | 集中在一个函数内,逻辑内聚 |
| 类模板偏特化 | 可以 | 类模板里不能用 if constexpr |
| 错误信息 | 冗长,难读 | 清晰,假分支完全不出现 |
实际开发中的常见组合:
- 用 SFINAE(或 C++20 的 requires)控制重载的存在性。
- 用 if constexpr 在函数内部做细节分支。
比如一个函数,对容器类型调用 .size(),对数组用 sizeof 计算,对普通类型返回 0:
template<typename T>
auto get_size(const T& t)
{
if constexpr (requires { t.size(); }) // C++20 的 requires
{
return t.size();
} else if constexpr (std::is_array_v<T>)
{
return sizeof(T) / sizeof(std::decay_t<decltype(t[0])>);
} else
{
return 0;
}
}
虽然这里用了 requires(请允许我偷个懒(๑• . •๑)),但原理相同:if constexpr 让整个逻辑内聚在一个函数里,不需要写三个重载。
3. void_t 技巧:标准化后的检测神器
void_t 在 C++17 正式进入标准库,定义简单到令人发指:
template<typename...> using void_t = void;
但它和 SFINAE 结合,对于类型检测就有了出其不意的效果。
经典用法:检测类型是否有某个成员类型。
template<typename T, typename = void>
struct has_value_type : std::false_type {};
template<typename T>
struct has_value_type<T, std::void_t<typename T::value_type>>
: std::true_type {};
原理:
- 主模板默认 false_type。
- 偏特化版本要求 T::value_type 存在,并用 void_t 将结果映射成 void。
- 如果 T::value_type 合法,偏特化的第二个参数是 void,正好匹配(因为主模板第二个参数默认也是 void),所以偏特化胜出。
- 如果 T::value_type 非法,偏特化替换失败(SFINAE),主模板胜出。
检测多个条件:用 void_t 把一堆表达式包起来,任意一个失败就整个失败。
template<typename T, typename = void>
struct is_container : std::false_type {};
template<typename T>
struct is_container<T, std::void_t<
typename T::value_type,
typename T::size_type,
decltype(std::declval<T>().begin()),
decltype(std::declval<T>().end())
>> : std::true_type {};
void_t 让这种复合检测变得异常简洁。
4. 折叠表达式:让变参模板的 SFINAE 更简单
C++17 的折叠表达式简化了变参模板的运算。
虽然不直接属于 SFINAE,但在编写泛型代码时经常配合使用。
之前:要写一个“所有类型都满足某条件”的 trait,需要递归或初始化列表技巧。
现在:直接折叠:
template<typename... Ts>
constexpr bool all_integral = (std::is_integral_v<Ts> && ...);
配合 enable_if,我们可以这样约束变参函数模板:
template<typename... Ts>
std::enable_if_t<(std::is_integral_v<Ts> && ...), void>
process(Ts... args)
{
// 所有参数都是整数
}
折叠表达式让变参模板的条件表达更直观。
5. 小小总结一波
C++17 并没有让 SFINAE 消失,而是给了我们更多选择:
- 函数内部逻辑:优先用 if constexpr,代码内聚,可读性高。
- 类模板偏特化检测:void_t + SFINAE 依然是主流。
- 控制重载集的存在性:仍然需要 SFINAE(或 C++20 的 requires)。
- 变参模板条件:折叠表达式让代码更简洁。
我们现在写代码时,大部分分支逻辑都可以用 if constexpr 搞定,SFINAE 只需要在类模板偏特化或需要“消除重载”的场景下才亲自出马。
C++20:Concepts —— SFINAE 的优雅替代
在 C++20 没来之前,我们需要费劲心思的去写 SFINAE;C++20 来了之后,我们还要写 SFINAE,那 C++20 不就白来了吗?
所以在 C++20,SFINAE 终于可以半退休了。
不是因为它被淘汰了——标准库内部、老代码、极端偏特化场景依然需要它。
而是因为 Concepts 给了我们一个写编译期约束的“正常语言”,再也不用靠 enable_if 那一堆尖括号和 decltype 了。
1. Concepts 简介
Concept 就是一个编译期谓词,返回bool类型,用来描述一组类型应该满足的约束。
语法很简单:
template < 模板形参列表 >
concept 概念名 = 约束表达式;
一个简单的例子:
template<typename T>
concept Integral = std::is_integral_v<T>;
我们定义了一个名为 Integral 的概念,当满足 std::is_integral_v 的类型时为 true,反之为 false。
std::is_integral_v 用于判断某个类型是否为整型,在编译期求值,返回 true 或 false。
概念将这一条件“封装”成一个可复用的命名约束。
2. 使用 requires 子句
我们了解完 Concept,那么就来看看如何使用它吧。
有了 Concepts 后,我们不再需要把 enable_if 藏在返回类型或默认模板参数里。可以直接在模板参数列表后面加 requires:
template<typename T>
void foo(T t) requires Integral<T>
{
// 只有整型能调用
}
或者更简洁的语法:
template<Integral T>
void foo(T t)
{
// 同样效果,更直观
}
也可以直接放在函数声明末尾:
void foo(Integral auto t) // 这个 t 必须是 Integral
{
// ...
}
对比一下 SFINAE 的老代码:
template<typename T>
typename std::enable_if_t<std::is_integral_v<T>, void>
foo(T t) { ... }
可读性提升了一个数量级。
3. requires 表达式:编写临时约束
有时候我们不需要定义一个 concept,只想临时表达一组要求。
requires 表达式就是干这个的:
template<typename T>
void bar(T t) requires requires(T t) { t.foo(); }
{
t.foo();
}
外层(第一个)的 requires 是子句,内层(第二个)的 requires 是表达式。
内层 requires 表达式的语法:
- requires(T t) :() 里的是 requires 表达式的参数列表,类似于 lambda 表达式的参数,它允许我们在约束中引入局部变量。
- { expr } :检查表达式合法且返回类型满足 Concept。
- expr :检查表达式合法(不关心返回值)。
当然,如果不想使用参数列表,我们也可以通过其他方式实现同样的约束,例如:
template<typename T>
void bar(T t) requires requires { typename T::value_type; } // 没有参数列表,只能检查类型
{ /* ... */ }
我们来写个复杂点的例子:
template<typename T>
void bar(T t) requires requires(T t) {
t.foo();
{ t * 2 } -> std::convertible_to<int>;
}
{
t.foo();
int x = t * 2;
}
-
t.foo() 没什么好说的,只检查表达式 t.foo() 是否合法,如果 t.foo() 无法编译,则该要求失败,整个 requires 表达式为 false。
-
重点看第二个,它是个复合要求,由两个部分组成:
- 大括号包围的表达式:{ t * 2 },同样检查表达式是否合法。
- 可选的返回类型约束:-> std::convertible_to,它检查 t * 2 是否可转换为 int。
4. 用 Concepts 重写早期 SFINAE 代码
我们拿几个经典的 SFINAE 场景,看看 Concepts 如何让代码脱胎换骨的。
场景一:检测是否存在成员函数 size()
SFINAE 写法:
template<typename T, typename = void>
struct has_size : std::false_type {};
template<typename T>
struct has_size<T, std::void_t<decltype(std::declval<T>().size())>>
: std::true_type {};
template<typename T>
constexpr bool has_size_v = has_size<T>::value;
Concepts 写法:
template<typename T>
concept HasSize = requires(T t) {
{ t.size() } -> std::same_as<typename T::size_type>; // 约束返回类型
};
当然了,requires 表达式也可以和 concept 组合使用。
requires(T t) { ... },它定义了一组要求,这些要求必须同时满足,概念才为 true。
std::same_as 说明:
- std::same_as 是 C++20 标准库中定义的一个概念(concept),其定义大致为:
template<typename T, typename U>
concept same_as = std::is_same_v<T, U>;
- 在我们的复合要求中,-> std::same_as<...>,要求表达式的类型与 T::size_type 类型完全相同。
场景二:根据类型特征实现不同版本
SFINAE 重载:
template<typename T>
std::enable_if_t<std::is_integral_v<T>>
foo(T t)
{
// 整数版
}
template<typename T>
std::enable_if_t<std::is_floating_point_v<T>>
foo(T t)
{
// 浮点版
}
Concepts 写法:
void foo(Integral auto t) { /* 整数版 */ }
void foo(FloatingPoint auto t) { /* 浮点版 */ }
或者用一个函数 + if constexpr:
void foo(auto t)
{
if constexpr (Integral<decltype(t)>) {
// 整数版
}
else if constexpr (FloatingPoint<decltype(t)>) {
// 浮点版
}
}
场景三:类模板特化(检测容器)
SFINAE 偏特化:
template<typename T, typename = void>
struct is_container : std::false_type {};
template<typename T>
struct is_container<T, std::void_t<
typename T::value_type,
typename T::iterator,
decltype(std::declval<T>().begin()),
decltype(std::declval<T>().end())
>> : std::true_type {};
Concepts 版本:
template<typename T>
concept Container = requires(T t) {
typename T::value_type;
typename T::iterator;
{ t.begin() } -> std::same_as<typename T::iterator>;
{ t.end() } -> std::same_as<typename T::iterator>;
};
// 类模板特化
template<Container C>
struct container_traits
{
// 通用实现
};
// 对某种容器特化
template<>
struct container_traits<std::vector<int>>
{
// 专门针对 vector<int>
};
偏特化时直接用 Container 约束,不需要使用 void_t 了,也更加直观。
5. 总结一下
从 C++98 的 sizeof 侦探法,到 C++11 的 enable_if 标准化,再到 C++17 的 void_t 和 if constexpr,SFINAE 一直在进化。
但我认为 C++20 的 Concepts 才是真正的范式转移——它把编译期约束从“模板元编程噩梦”变成了“语言的正常部分”。
现在我们写泛型代码时,可以像写普通函数一样自然:
- 用概念定义接口需求。
- 用 requires 子句声明约束。
- 用 requires 表达式内联检查。
- 用 auto 加概念让函数签名自我描述。
再也不用像以前那样写一大堆的东西了(。◕∀◕。)!
结尾
写了这么多的内容(真不容易啊),也是时候给这篇文章收个尾了。
写这篇文章的时候,我一直在想:为什么我们要追着 C++ 标准跑?
因为编译期编程这件事,本质上是“让代码自己学会适应环境”。
从 SFINAE 到 Concepts,我们可以很明显的感觉到 C++ 其实一直在做一件事:降低使用门槛,让我们这些普通人也能舒适使用那些工具。
最后就是为什么我要写这么多 SFINAE 的东西,从 C++98 开始一路介绍到 C++20 呢?直接介绍 Concepts 多好,一步到‘胃’。
我想说的是:我们现在学的不是一堆过时的语法,而是理解 C++ 模板底层逻辑的必经之路。
哪怕我们以后只用 Concepts,当我们看到老代码里那些 enable_if、void_t、declval 时,我们能一眼看穿它的意图,这本身就是一种能力。
好了,这篇文章就先到这里吧(¯﹃¯),最后的最后附上一张图片: