现代C++ :SFINAE:不是错误的替换失败是怎么回事?

发布时间:2026/7/19 3:23:44

现代C++ :SFINAE:不是错误的替换失败是怎么回事? 引言我们已经连续讲了两讲模板和编译期编程了。今天我们还是继续这个话题讲的内容是模板里的一个特殊概念——替换失败非错substitution failure is not an error英文简称为 SFINAE。函数模板的重载决议我们之前已经讨论了不少模板特化。我们今天来着重看一个函数模板的情况。当一个函数名称和某个函数模板名称匹配时重载决议过程大致如下根据名称找出所有适用的函数和函数模板对于适用的函数模板要根据实际情况对模板形参进行替换替换过程中如果发生错误这个模板会被丢弃在上面两步生成的可行函数集合中编译器会寻找一个最佳匹配产生对该函数的调用如果没有找到最佳匹配或者找到多个匹配程度相当的函数则编译器需要报错我们还是来看一个具体的例子。虽然这例子不那么实用但还是比较简单能够初步说明一下。#include stdio.h struct Test { typedef int foo; }; template typename T void f(typename T::foo) { puts(1); } template typename T void f(T) { puts(2); } int main() { fTest(10); fint(10); }输出为12我们来分析一下。首先看 f(10); 的情况我们有两个模板符合名字 f替换结果为 f(Test::foo) 和 f(Test)使用参数 10 去匹配只有前者参数可以匹配因而第一个模板被选择再看一下 f(10) 的情况还是两个模板符合名字 f替换结果为 f(int::foo) 和 f(int)显然前者不是个合法的类型被抛弃使用参数 10 去匹配 f(int)没有问题那就使用这个模板实例了在这儿体现的是 SFINAE 设计的最初用法如果模板实例化中发生了失败没有理由编译就此出错终止因为还是可能有其他可用的函数重载的。这儿的失败仅指函数模板的原型声明即参数和返回值。函数体内的失败不考虑在内。如果重载决议选择了某个函数模板而函数体在实例化的过程中出错那我们仍然会得到一个编译错误。编译期成员检测不过很快人们就发现 SFINAE 可以用于其他用途。比如根据某个实例化的成功或失败来在编译期检测类的特性。下面这个模板就可以检测一个类是否有一个名叫 reserve、参数类型为 size_t 的成员函数template typename T struct has_reserve { struct good { char dummy; }; struct bad { char dummy[2]; }; template class U, void (U::*)(size_t) struct SFINAE {}; template class U static good reserve(SFINAEU, U::reserve*); template class U static bad reserve(...); static const bool value sizeof(reserveT(nullptr)) sizeof(good); };在这个模板里我们首先定义了两个结构 good 和 bad它们的内容不重要我们只关心它们的大小必须不一样。然后我们定义了一个 SFINAE 模板内容也同样不重要但模板的第二个参数需要是第一个参数的成员函数指针并且参数类型是 size_t返回值是 void。随后我们定义了一个要求 SFINAE* 类型的 reserve 成员函数模板返回值是 good再定义了一个对参数类型无要求的 reserve 成员函数模板不熟悉 ... 语法的可以看参考资料 [2]返回值是 bad。最后我们定义常整型布尔值 value结果是 true 还是 false取决于 nullptr 能不能和 SFINAE* 匹配成功而这又取决于模板参数 T 有没有返回类型是 void、接受一个参数并且类型为 size_t 的成员函数 reserve。那这样的模板有什么用处呢我们继续往下看。SFINAE 模板技巧enable_ifC11 开始标准库里有了一个叫 enable_if 的模板定义在 里可以用它来选择性地启用某个函数的重载。假设我们有一个函数用来往一个容器尾部追加元素。我们希望原型是这个样子的template typename C, typename T void append(C container, T* ptr, size_t size);显然container 有没有 reserve 成员函数是对性能有影响的——如果有的话我们通常应该预留好内存空间以免产生不必要的对象移动甚至拷贝操作。利用 enable_if 和上面的 has_reserve 模板我们就可以这么写template typename C, typename T enable_if_thas_reserveC::value, void append(C container, T* ptr, size_t size) { container.reserve( container.size() size); for (size_t i 0; i size; i) { container.push_back(ptr[i]); } } template typename C, typename T enable_if_t!has_reserveC::value, void append(C container, T* ptr, size_t size) { for (size_t i 0; i size; i) { container.push_back(ptr[i]); } }要记得之前我说过对于某个 type trait添加 _t 的后缀等价于其 type 成员类型。因而我们可以用 enable_if_t 来取到结果的类型。enable_if_t::value, void 的意思可以理解成如果类型 C 有 reserve 成员的话那我们启用下面的成员函数它的返回类型为 void。参考资料里同时展示了一个通用技巧可以用在构造函数无返回值或不想手写返回值类型的情况下。但那个写法更绕一些不是必需要用的话就采用上面那个写出返回值类型的写法吧。decltype 返回值如果只需要在某个操作有效的情况下启用某个函数而不需要考虑相反的情况的话有另外一个技巧可以用。对于上面的 append 的情况如果我们想限制只有具有 reserve 成员函数的类可以使用这个重载我们可以把代码简化成template typename C, typename T auto append(C container, T* ptr, size_t size) - decltype( declvalC().reserve(1U), void()) { container.reserve( container.size() size); for (size_t i 0; i size; i) { container.push_back(ptr[i]); } }这是我们第一次用到 declval [4]需要简单介绍一下。这个模板用来声明一个某个类型的参数但这个参数只是用来参加模板的匹配不允许实际使用。使用这个模板我们可以在某类型没有默认构造函数的情况下假想出一个该类的对象来进行类型推导。declval().reserve(1U) 用来测试 C 类型的对象是不是可以拿 1U 作为参数来调用 reserve 成员函数。此外我们需要记得C 里的逗号表达式的意思是按顺序逐个估值并返回最后一项。所以上面这个函数的返回值类型是 void。这个方式和 enable_if 不同很难表示否定的条件。如果要提供一个专门给没有 reserve 成员函数的 C 类型的 append 重载这种方式就不太方便了。因而这种方式的主要用途是避免错误的重载。void_tvoid_t 是 C17 新引入的一个模板。它的定义简单得令人吃惊template typename... using void_t void;换句话说这个类型模板会把任意类型映射到 void。它的特殊性在于在这个看似无聊的过程中编译器会检查那个“任意类型”的有效性。利用 decltype、declval 和模板特化我们可以把 has_reserve 的定义大大简化template typename T, typename void_t struct has_reserve : false_type {}; template typename T struct has_reserve T, void_tdecltype( declvalT().reserve(1U)) : true_type {};这里第二个 has_reserve 模板的定义实际上是一个偏特化。偏特化是类模板的特有功能跟函数重载有些相似。编译器会找出所有的可用模板然后选择其中最“特别”的一个。像上面的例子所有类型都能满足第一个模板但不是所有的类型都能满足第二个模板所以第二个更特别。当第二个模板能被满足时编译器就会选择第二个特化的模板而只有第二个模板不能被满足时才会回到第一个模板的通用情况。有了这个 has_reserve 模板我们就可以继续使用其他的技巧如 enable_if 和下面的标签分发来对重载进行限制。标签分发在上一讲我们提到了用 true_type 和 false_type 来选择合适的重载。这种技巧有个专门的名字叫标签分发tag dispatch。我们的 append 也可以用标签分发来实现template typename C, typename T void _append(C container, T* ptr, size_t size, true_type) { container.reserve( container.size() size); for (size_t i 0; i size; i) { container.push_back(ptr[i]); } } template typename C, typename T void _append(C container, T* ptr, size_t size, false_type) { for (size_t i 0; i size; i) { container.push_back(ptr[i]); } } template typename C, typename T void append(C container, T* ptr, size_t size) { _append( container, ptr, size, integral_constant bool, has_reserveC::value{}); }回想起上一讲里 true_type 和 false_type 的定义你应该很容易看出这个代码跟使用 enable_if 是等价的。当然在这个例子标签分发并没有使用 enable_if 显得方便。作为一种可以替代 enable_if 的通用惯用法你还是需要了解一下。另外如果我们用 void_t 那个版本的 has_reserve 模板的话由于模板的实例会继承 false_type 或 true_type 之一代码可以进一步简化为template typename C, typename T void append(C container, T* ptr, size_t size) { _append( container, ptr, size, has_reserveC{}); }静态多态的限制看到这儿你可能会怀疑为什么我们不能像在 Python 之类的语言里一样直接写下面这样的代码呢template typename C, typename T void append(C container, T* ptr, size_t size) { if (has_reserveC::value) { container.reserve( container.size() size); } for (size_t i 0; i size; i) { container.push_back(ptr[i]); } }如果你试验一下就会发现在 C 类型没有 reserve 成员函数的情况下编译是不能通过的会报错。这是因为 C 是静态类型的语言所有的函数、名字必须在编译时被成功解析、确定。在动态类型的语言里只要语法没问题缺成员函数要执行到那一行上才会被发现。这赋予了动态类型语言相当大的灵活性只不过不能在编译时检查错误同样也是很多人对动态类型语言的抱怨所在……那在 C 里我们有没有更好的办法呢实际上是有的。具体方法下回分解。内容小结今天我们介绍了 SFINAE 和它的一些主要惯用法。虽然随着 C 的演化SFINAE 的重要性有降低的趋势但我们仍需掌握其基本概念才能理解使用了这一技巧的模板代码。

相关新闻