深入解析C++ SFINAE:从编译原理到现代Concepts演进

📅 2026/8/6 9:08:04
深入解析C++ SFINAE:从编译原理到现代Concepts演进
1. 项目概述为什么我们需要深入理解SFINAE如果你写过一段时间的C尤其是接触过标准库或者一些现代的开源库你大概率会碰到一些“神奇”的代码它们看起来像是普通的模板函数或类但当你尝试用某些类型去实例化时编译器要么选择了另一个版本要么直接报出一堆你看不懂的错误而不是你预期的那个。这背后很可能就是SFINAE在起作用。SFINAE全称是“Substitution Failure Is Not An Error”中文可以理解为“替换失败并非错误”。这十个字是C模板元编程中一个基石性的规则。它不是一个具体的函数或类而是一种编译器的行为准则。简单来说当编译器在尝试匹配一个函数模板或类模板的特化时如果因为类型替换导致产生了无效的代码比如在一个需要T::value_type的地方T是一个int编译器不会立刻把它当作一个错误而终止编译而是会默默地放弃这个候选继续尝试其他可能的重载或特化版本。这听起来有点抽象我举个生活化的例子。想象一下你去一个多功能工具店买一把能拧十字螺丝的螺丝刀。店员给你看了三把一把标准的十字螺丝刀一把需要电池的电动螺丝刀但没电了还有一把其实是开瓶器。SFINAE就像是这个挑选过程电动螺丝刀没电了“替换失败”但店员不会因此把你赶出店“并非错误”他会忽略这个无效选项继续给你看标准的十字螺丝刀最终你成功买到了工具。而如果店里只有那把开瓶器没有任何一把真正的螺丝刀那才是真正的错误交易失败。那么为什么我们需要花时间深入理解这个看似晦涩的编译技巧呢原因有三层。第一是读懂代码。现代C库如Boost、STL自身尤其是type_traits和iterator大量使用了基于SFINAE的技术来实现编译期分派和类型特性判断。看不懂SFINAE读这些源码就像看天书。第二是解决问题。当你需要编写泛型代码并根据类型的某些特性比如是否有某个成员函数、是否是某种类别来选择不同的实现路径时SFINAE是C17之前最核心的工具。第三是理解演进。SFINAE是理解C元编程从“奇技淫巧”到“现代设施”的关键桥梁。从早期的enable_if到C11/14的void_t、表达式SFINAE再到C17的if constexpr和C20的Concepts这条演进路线清晰地展示了语言如何将一种隐秘的编译技巧规范化为清晰、可读的语言特性。本文适合所有希望提升自己C内功的开发者无论你是正在准备面试被“SFINAE和模板元编程的实现”这类问题所困扰还是在实际项目中遇到了需要基于类型条件编译的难题亦或是单纯对C这门语言的深层机制感到好奇。我们将从最基础的场景出发一步步拆解SFINAE的原理、经典应用、演进历史并分享那些只有踩过坑才知道的实操细节。2. SFINAE的核心原理与工作机制拆解要理解SFINAE我们必须深入到C函数重载决议和模板实例化的细节中去。这个过程并不简单但我们可以把它分解成几个清晰的步骤。2.1 编译器的“候选名单”与“替换”阶段当编译器遇到一个函数调用时它首先会建立一个“候选函数集”。对于普通函数这很简单对于涉及模板的情况编译器会尝试将所有匹配名称的模板函数也加入这个集合。对于每个函数模板编译器会进入一个叫做“模板实参推导”和“替换”的阶段。“替换”是这个过程中的关键一步。编译器试图将调用时提供的实参类型代入到函数模板的声明中去推导出模板参数T的具体类型并用这个具体类型替换掉模板声明中所有的T。如果这个替换过程在函数模板的声明部分包括返回类型和参数类型产生了非法的C代码那么根据SFINAE原则这个替换失败不会导致编译错误这个函数模板候选会被直接从候选集中剔除。这里有一个至关重要的限制SFINAE只适用于直接上下文。所谓直接上下文主要指模板声明本身函数签名、返回类型、模板参数列表以及出现在这些地方的decltype、sizeof等表达式。如果替换失败发生在函数模板的函数体内部那这就是一个硬错误编译会失败。2.2 一个经典的“Hello World”示例让我们用一个最简单的例子来可视化这个过程。#include iostream #include type_traits // 版本1处理有value_type成员的类型如容器迭代器 templatetypename T auto print_type(const T t) - decltype(t.value_type(), void()) { std::cout Has value_type member.\n; } // 版本2处理没有value_type的普通类型如int, double templatetypename T void print_type(const T t) { std::cout No value_type member.\n; } int main() { std::vectorint vec; print_type(vec.begin()); // 调用版本1 print_type(42); // 调用版本2 }我们来模拟编译器处理print_type(42)时的思考过程建立候选集找到两个名为print_type的函数模板。尝试替换候选1推导T为int。替换到声明中auto print_type(const int t) - decltype(t.value_type(), void())。计算decltype表达式t是int它没有.value_type成员。因此t.value_type()是一个非法的表达式。关键点这个非法表达式发生在decltype内部而decltype位于函数的返回类型部分属于“直接上下文”。根据SFINAE此替换失败但非错误。编译器默默地将版本1从本次调用的可行候选集中丢弃。尝试替换候选2推导T为int。替换到声明中void print_type(const int t)。一切合法。版本2成为唯一可行的候选。重载决议只有一个候选选择它。输出“No value_type member.”。对于print_type(vec.begin())std::vectorint::iterator通常有value_type成员因此版本1的替换成功进入候选集。最终在版本1和版本2之间版本1因为更特化有尾置返回类型约束而被选中。注意这个例子中decltype(t.value_type(), void())是一个逗号操作符的用法。逗号操作符会依次求值所有表达式但最终类型是最后一个表达式的类型。这里我们利用它来检查t.value_type()是否合法同时将函数返回类型固定为void。这是一种经典的SFINAE表达式技巧。2.3 SFINAE发生的典型“无效代码”场景理解哪些情况会导致“替换失败”至关重要。以下是在直接上下文中常见的无效代码它们会触发SFINAE访问不存在的类型成员如typename T::value_typeT::iterator。访问不存在的成员函数/对象如decltype(std::declvalT().size())当T没有.size()成员时。非法的类型转换或运算如试图对非指针类型T进行*T解引用或对非算术类型进行T() T()运算。模板参数不匹配例如在templatetypename T, typename typename T::value_type中如果T没有value_type则默认模板参数推导失败。超出范围的数组维度如T[ N ]其中N被推导为零或负数。2.4 SFINAE与重载决议的配合SFINAE本身不选择函数它只是一个“过滤器”在重载决议开始前将那些因为类型不合适而导致声明无效的候选剔除出去。剩下的都是“声明有效”的候选编译器再根据常规的重载决议规则如参数匹配程度、const-ness、是否特化等从中选出最佳匹配。这种“过滤选择”的机制使得我们能够基于类型的编译期属性来引导编译器选择不同的代码路径这是编译期多态和元编程的基石。在C20之前这是实现“概念”类似功能的唯一核心手段。3. 从enable_if到void_t经典SFINAE模式实战理解了原理我们来看看在实际中如何运用SFINAE。最经典的工具莫过于std::enable_if和被称为“SFINAE探测器”的void_t。3.1std::enable_if编译期的开关std::enable_if是一个模板元函数定义在type_traits中。它的原型大致如下templatebool B, class T void struct enable_if {}; templateclass T // 偏特化版本当B为true时 struct enable_iftrue, T { using type T; };它的工作方式很简单当第一个布尔模板参数B为true时它有一个内嵌的type成员类型是第二个参数T默认为void当B为false时它没有type成员。如何将它用于SFINAE我们把它放在一个会导致“替换失败”的上下文里通常是函数返回类型或额外的模板默认参数。场景一根据类型是否有size()成员函数提供不同实现#include iostream #include type_traits // 1. 使用返回类型enable_if (C11风格) templatetypename T auto get_size(const T container) - typename std::enable_if std::is_integraldecltype(container.size())::value, // 条件size()返回整型 decltype(container.size()) ::type { std::cout [Member function] ; return container.size(); } // 2. 使用额外模板默认参数enable_if (更简洁) templatetypename T, typename typename std::enable_if !std::is_integraldecltype(std::declvalT().size())::value ::type int get_size(const T container) { std::cout [Free function or other] ; return some_other_size_calculation(container); } // 针对原生数组的特化版本 templatetypename T, std::size_t N std::size_t get_size(T (array)[N]) { std::cout [C-style array] ; return N; } int main() { std::vectorint v{1,2,3}; int arr[] {1,2,3,4}; std::cout get_size(v) std::endl; // 调用版本1 std::cout get_size(arr) std::endl; // 调用数组版本 // 对于没有.size()返回整型的类型会尝试匹配版本2 }在上面的版本1中enable_if被放在尾置返回类型里。如果container.size()的返回类型是整型那么enable_iftrue, decltype(...)::type是合法的函数声明有效。如果不是整型enable_iffalse, ...没有::type导致替换失败这个重载被SFINAE剔除。实操心得使用尾置返回类型的enable_if时条件逻辑往往比较复杂可读性差。而使用额外的模板默认参数如版本2的typename typename std::enable_if...::type是更常见、也更清晰的做法尤其是在有多个约束条件时。C11后结合std::declval可以在decltype中无需实例化对象就调用成员函数这是编写条件表达式时的常用技巧。3.2void_t探测类型成员存在的“神器”std::void_t是C17引入的一个非常简单但极其强大的元函数。它的定义就是templatetypename... using void_t void;它把所有模板参数都忽略直接映射到void。它的威力在于和SFINAE结合可以探测一个类型是否具有某个成员。原理我们利用void_t来“包装”我们想要检查的表达式。如果表达式合法void_t...就是合法的void类型如果表达式非法例如访问不存在的成员那么void_t的实例化就会在直接上下文中失败触发SFINAE。#include iostream #include type_traits #include vector // 基础模板默认没有value_type templatetypename, typename void struct has_value_type : std::false_type {}; // 偏特化模板当T::value_type合法时匹配此版本 templatetypename T struct has_value_typeT, std::void_ttypename T::value_type : std::true_type {}; // 同理探测是否有size()成员函数 templatetypename, typename void struct has_size_member : std::false_type {}; templatetypename T struct has_size_memberT, std::void_tdecltype(std::declvalT().size()) : std::true_type {}; int main() { std::cout std::boolalpha; std::cout has_value_typestd::vectorint::value std::endl; // true std::cout has_value_typeint::value std::endl; // false std::cout has_size_memberstd::vectorint::value std::endl; // true std::cout has_size_memberint::value std::endl; // false }这里发生了两次模板匹配当我们询问has_value_typeint时编译器先尝试匹配偏特化版本。它需要推导std::void_ttypename int::value_type。int::value_type不存在因此void_t的实例化失败。根据SFINAE这个偏特化版本被剔除。编译器回退到基础模板其继承自std::false_type所以::value是false。对于has_value_typestd::vectorintstd::vectorint::value_type是存在的int所以偏特化版本匹配成功它继承自std::true_type。void_t模式是编译期类型特性检查的黄金标准标准库的std::is_detected提案就是基于此思想。它比早期依赖函数重载的SFINAE检查要清晰和强大得多。3.3 表达式SFINAE与decltype的巧妙组合在C11引入decltype和尾置返回类型后一种更直接的SFINAE形式变得流行即直接在返回类型中使用decltype来构造一个依赖模板参数的表达式。我们开头的print_type例子就是这种用法。这种“表达式SFINAE”通常用于检查某个操作是否有效并推导出操作结果的类型。// 检查类型T是否支持使用输出到ostream templatetypename T class is_printable { templatetypename U static auto test(int) - decltype(std::declvalstd::ostream() std::declvalU(), std::true_type{}); templatetypename static std::false_type test(...); public: static constexpr bool value decltype(testT(0))::value; }; // 使用 if constexpr (is_printableT::value) { std::cout obj; } else { std::cout [Object not printable]; }这里使用了两个重载的test函数。接受int参数的版本尝试进行操作如果成功其返回类型为std::true_type如果失败则被SFINAE剔除。而接受...省略号的版本是“兜底”函数匹配任何类型返回std::false_type。通过检查testT(0)的返回类型0是int优先匹配第一个版本我们就能在编译期得知T是否可打印。注意事项表达式SFINAE非常灵活但也很容易写出难以调试的代码。当表达式复杂时编译器错误信息可能极其冗长。一个实用的技巧是将复杂的检查分解成多个简单的using别名或constexpr布尔变量再组合起来能显著提升代码可读性和错误信息的友好度。4. SFINAE在实际项目中的应用场景与设计模式SFINAE绝非象牙塔里的玩具它在实际C项目中有大量不可替代的应用。理解这些模式能让你更好地设计泛型库和编写健壮的模板代码。4.1 标签分发与迭代器分类这是STL算法中广泛应用的模式。算法根据迭代器的能力如是否可随机访问选择最优的实现。std::iterator_traits提供了迭代器的分类标签如std::random_access_iterator_tag而SFINAE或简单的函数重载标签分发被用来选择不同的函数重载。// 一个简化的distance实现展示标签分发思想 templatetypename InputIt typename std::iterator_traitsInputIt::difference_type my_distance(InputIt first, InputIt last, std::input_iterator_tag) { // 线性遍历的O(N)实现 typename std::iterator_traitsInputIt::difference_type n 0; while (first ! last) { first; n; } return n; } templatetypename RandomIt typename std::iterator_traitsRandomIt::difference_type my_distance(RandomIt first, RandomIt last, std::random_access_iterator_tag) { // 随机访问的O(1)实现 return last - first; } // 对外接口通过iterator_traits获取标签并分发 templatetypename InputIt typename std::iterator_traitsInputIt::difference_type my_distance(InputIt first, InputIt last) { using category typename std::iterator_traitsInputIt::iterator_category; return my_distance(first, last, category{}); }这里没有直接使用SFINAE而是利用了基于标签类型的函数重载。但在更复杂的场景下如果标签不是通过iterator_traits直接获得或者需要基于多个条件组合进行选择SFINAE就会登场。4.2 条件性地启用/禁用类模板成员函数有时我们希望一个类模板的某个成员函数仅在模板参数满足某些条件时才存在。这可以通过在成员函数上使用enable_if来实现。templatetypename T class Container { std::vectorT data; public: // 此构造函数仅当T是可拷贝构造时才启用 templatetypename U T // 引入额外的模板参数以在成员函数上使用SFINAE Container(const Container other, typename std::enable_ifstd::is_copy_constructibleU::value::type* nullptr) : data(other.data) { std::cout Copy constructor enabled.\n; } // 一个只有当T是算术类型时才存在的sum函数 templatetypename U T auto sum() const - typename std::enable_ifstd::is_arithmeticU::value, U::type { return std::accumulate(data.begin(), data.end(), U{}); } };这种模式在编写高度泛型的容器或工具类时非常有用可以避免对不支持某些操作的类型实例化出无效的成员函数从而产生更清晰的错误信息函数不存在而不是函数体内部出错。4.3 实现自定义的类型特性检查正如void_t一节所示我们可以轻松地创建自己的类型特性。这在编写库代码时尤其常见例如检查一个类是否具有特定的typedef、成员函数、或者是否支持某种操作符。// 检查是否存在名为serialize的成员函数接受一个ostream参数 templatetypename T class has_member_serialize { templatetypename U static auto check(int) - decltype(std::declvalU().serialize(std::declvalstd::ostream()), std::true_type{}); templatetypename static std::false_type check(...); public: static constexpr bool value decltype(checkT(0))::value; }; // 基于检查结果的泛型序列化函数 templatetypename T std::enable_if_thas_member_serializeT::value serialize(const T obj, std::ostream os) { obj.serialize(os); // 调用成员函数 } templatetypename T std::enable_if_t!has_member_serializeT::value std::is_arithmetic_vT serialize(const T obj, std::ostream os) { os obj; // 对于算术类型直接输出 }这种模式实现了编译期的“策略选择”是很多序列化库、日志库的基础。4.4 避免模糊的重载决议SFINAE可以用来精确地约束模板防止在重载决议时产生歧义。例如你有一个处理指针的模板和一个处理所有类型的通用模板你希望指针优先匹配指针版本。templatetypename T void process(T* ptr) { // 处理指针的版本 std::cout Processing pointer.\n; } templatetypename T typename std::enable_if!std::is_pointerT::value::type process(const T value) { // 处理非指针的版本 std::cout Processing value.\n; }如果没有enable_if约束第二个模板那么对于int* p两个模板都匹配T被推导为int和int*可能导致重载歧义。通过SFINAE将第二个模板限制为非指针类型就消除了歧义。常见问题在使用enable_if时经常需要取反条件!condition。务必小心处理逻辑确保你启用和禁用的重载集合是互斥且覆盖所有情况的否则可能导致“没有匹配的函数”错误。绘制一个简单的类型条件矩阵图有助于理清思路。5. SFINAE的痛点、局限与现代C的演进尽管SFINAE功能强大但它在实际使用中有着显著的缺点这些痛点直接推动了C语言的演进。5.1 SFINAE的“阿喀琉斯之踵”错误信息灾难当SFINAE将所有重载都排除后编译器报错指向调用处错误信息冗长且几乎不具可读性因为它会列出所有被剔除的重载及其失败原因。调试这样的错误非常痛苦。代码可读性差enable_if和复杂的decltype表达式使得函数签名变得极其臃肿和晦涩严重影响了代码的自注释性。逻辑分散约束条件分散在函数签名各处返回类型、模板参数、函数参数难以一眼看清函数的完整约束。编译期开销复杂的SFINAE表达式会显著增加编译时间因为编译器需要实例化并检查大量模板特化。“黑洞”函数问题使用...省略号作为兜底重载时它几乎匹配任何参数如果SFINAE条件设置不当可能导致意外的函数被选中。5.2 C17的救赎if constexprif constexpr是编译期if语句。它在模板上下文中尤其有用允许你根据编译期布尔条件在实例化时丢弃一部分代码分支。这极大地简化了基于类型条件的代码编写。// 使用 if constexpr 替代 SFINAE 实现可打印检查 templatetypename T void print(const T value) { if constexpr (is_printableT::value) { std::cout value; } else { std::cout [Non-printable type, size: sizeof(T) ]; } }对比之前需要两个重载函数的SFINAE实现if constexpr版本将所有逻辑集中在一个函数体内清晰直观。编译器在实例化printint时只会生成if constexpr为true的那个分支的代码另一个分支被完全丢弃就像不存在一样。if constexprvs SFINAEif constexpr适用于在函数体内部进行条件编译。它更直观易于理解和维护。SFINAE适用于在重载决议层面进行选择即决定哪个函数参与重载。当你需要提供完全不同的函数接口不同的参数列表或返回类型时SFINAE仍是必要的。5.3 C20的终极方案ConceptsConcepts是C20引入的用于对模板参数进行约束的革命性特性。它直接将“类型必须满足的条件”提升为一等公民。// 使用Concepts定义“可打印”概念 templatetypename T concept Printable requires(std::ostream os, const T val) { { os val } - std::same_asstd::ostream; }; // 使用Concepts约束模板 templatePrintable T void print_concept(const T val) { std::cout val; } // 或者作为 requires 子句 templatetypename T requires PrintableT void print_concept2(const T val) { /* ... */ } // 重载决议变得清晰 templatetypename T // 无约束版本 void process(const T) { std::cout Generic\n; } templatePrintable T // 受约束版本更特化 void process(const T) { std::cout Printable\n; }Concepts解决了SFINAE的所有主要痛点清晰的错误信息当类型不满足Concept时编译器会直接指出违反了哪个约束而不是展示一长串SFINAE失败。卓越的可读性约束条件被显式地声明在模板参数旁边意图一目了然。逻辑集中requires子句将约束集中在一处。提升编译效率编译器可以更早地检查约束减少不必要的实例化尝试。Concepts与SFINAE的关系Concepts不是SFINAE的替代品而是它的规范化、语言级的实现。在编译器内部Concepts的约束检查很可能仍然利用了类似SFINAE的机制。但对于程序员来说你几乎不再需要手动编写那些晦涩的SFINAE代码了。你可以用Concepts来表达意图让编译器去处理背后的复杂逻辑。5.4 迁移策略从SFINAE到现代C对于现有代码库和需要支持旧标准的环境SFINAE仍是必备技能。但在新项目中应优先考虑现代特性C17及以上对于函数体内的条件逻辑优先使用if constexpr。它比SFINAE重载清晰得多。C20及以上对于模板参数约束毫不犹豫地使用Concepts。这是未来的方向。过渡期如果项目需要同时支持新旧标准可以使用宏或特性检测来包装if constexpr和Concepts为旧编译器提供SFINAE的回退实现。许多现代库如Range-v3正是这样做的。6. 调试SFINAE与元编程的实战技巧即使有了现代工具理解SFINAE对于调试和阅读旧代码依然至关重要。这里分享几个我实践中总结的调试技巧。6.1 解读“恐怖”的编译器错误信息SFINAE错误信息通常很长。关键策略是从最后往前看。编译器错误堆栈的末尾通常是真正的调用点。往前找第一个“非系统”的、你自己代码中的错误那很可能是所有SFINAE重载都被剔除后编译器报告“没有匹配的函数”。然后你需要去检查你的SFINAE条件是否过于严格或者类型确实不满足任何条件。使用static_assert进行早期诊断是极佳的习惯。在编写复杂的SFINAE或Concept约束时在关键处加入static_assert可以快速定位类型不满足的具体条件。templatetypename T void my_algorithm(T iter) { static_assert(std::is_sametypename std::iterator_traitsT::iterator_category, std::random_access_iterator_tag::value, This algorithm requires random-access iterators!); // ... 算法实现 }6.2 使用类型推导工具进行可视化在调试时我们经常想知道一个模板被推导成了什么类型或者某个decltype表达式的结果是什么。有几种方法故意引发错误在代码中写static_assert(std::is_same_vT, int“”)编译器错误信息会显示T的实际类型。使用编译器扩展GCC/Clang的__PRETTY_FUNCTION__或MSVC的__FUNCSIG__在运行时打印出函数签名其中包含推导出的类型。templatetypename T void debug_type(const T) { std::cout __PRETTY_FUNCTION__ std::endl; }使用IDE调试器现代IDE如CLion、Visual Studio的调试器在悬停时可以直接显示模板实例化后的类型这是最直观的方法。6.3 编写可测试的SFINAE代码将SFINAE逻辑封装在独立的类型特性类中如我们之前写的has_value_type而不是将其分散在多个函数签名里这样更容易单独测试。// 测试我们的特性类 static_assert(has_value_typestd::vectorint::value, ); static_assert(!has_value_typeint::value, );确保这些静态断言在单元测试中通过然后再使用这些特性类去约束其他模板。6.4 避免常见陷阱非直接上下文中的失败记住SFINAE只保护“直接上下文”。在函数体内或类模板定义中的错误是硬错误。确保你的SFINAE触发点enable_if,decltype位于函数声明部分。依赖参数推导顺序SFINAE与函数模板的偏序规则相互作用时可能产生微妙的结果。通常更特化的模板会被优先选择。如果行为不符合预期可能需要调整约束条件或使用std::enable_if在多个重载上制造“互斥”的条件。enable_if的默认模板参数陷阱当enable_if放在默认模板参数时如templatetypename T, typename enable_if_tcondition要小心它可能影响模板的特化匹配并且在两个签名只有默认模板参数不同的情况下它们被视为同一个模板会导致重定义错误。通常更安全的做法是使用一个额外的、有默认值的非类型模板参数或者使用enable_if在返回类型上。7. 总结与个人体会拥抱演进理解本质回顾SFINAE的发展从一种隐晦的编译器行为到enable_if的广泛使用再到void_t模式的确立最后到if constexpr和Concepts的语言级支持这条演进路线清晰地反映了C社区对“更好的泛型编程”的不懈追求。我个人在实际项目中的体会是SFINAE是一种强大的底层机制但不应成为首选的用户接口。在新项目中对于条件编译我的选择优先级是C20 Concepts表达约束最清晰、最强大的工具。是设计泛型接口的首选。C17if constexpr处理函数体内的条件分支逻辑集中易于理解。标签分发和简单的函数重载对于基于类别如迭代器标签的分发这仍然是最清晰的方式。SFINAE当以上方法都不适用时例如需要在重载决议层面进行非常精细的、基于表达式的控制或者需要维护遗留代码时使用。然而深入理解SFINAE绝非浪费时间。它是理解C模板元编程这座大厦的基石。很多现代特性的提案文档和编译器实现讨论中都会提及SFINAE。当你阅读标准库的底层实现、调试复杂的模板错误时SFINAE的知识会让你拥有“透视”的能力。它让你不仅知道工具怎么用更明白工具为何这样设计。最后一个小技巧当你觉得SFINAE代码难以编写时不妨先用Concepts的思路写下约束条件然后再思考如何用C11/14的SFINAE技术去实现它。这能帮你理清逻辑写出更正确的SFINAE代码。毕竟Concepts的本质就是对人类友好的SFINAE。