SFINAE 的演进:从替换失败不是错误,到 Concepts 的优雅

0 阅读25分钟

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 侦探法。

它的核心思路:

  1. 写两个重载的辅助函数,一个用 SFINAE 检测目标特征(成功时返回一个“大”类型),另一个是回退函数(返回一个“小”类型)。
  2. 调用辅助函数,通过 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(autofront(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?

那就错了,它们各有各的分工:

场景SFINAEif 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 时,我们能一眼看穿它的意图,这本身就是一种能力。

好了,这篇文章就先到这里吧(¯﹃¯),最后的最后附上一张图片: