向非C++程序员解释C++ SFINAE

Jim*_*Jim 39 c++ programming-languages c++-faq sfinae

什么是C++中的SFINAE?

能不能用不熟悉C++的程序员理解的话来解释它?另外,像Python这样的语言中的SFINAE对应的概念是什么?

Jer*_*fin 99

警告:这是一个非常长的解释,但希望它不仅能解释SFINAE的功能,还能让您了解何时以及为何使用它.

好的,为了解释这一点,我们可能需要备份和解释模板.众所周知,Python使用通常所说的鸭子类型 - 例如,当您调用函数时,只要X提供函数使用的所有操作,就可以将对象X传递给该函数.

在C++中,普通(非模板)函数要求您指定参数的类型.如果你定义了一个函数:

int plus1(int x) { return x + 1; }
Run Code Online (Sandbox Code Playgroud)

只能将该功能应用于int.它使用的事实,x在一个方式可能只是以及适用于其他类型,如longfloat没有区别-它仅适用于一个int反正.

为了更接近Python的鸭子类型,您可以创建一个模板:

template <class T>
T plus1(T x) { return x + 1; }
Run Code Online (Sandbox Code Playgroud)

现在我们plus1更像是在Python中 - 特别是,我们可以同样很好地调用它定义x的任何类型的对象x + 1.

现在,考虑一下,我们想要将一些对象写入流中.不幸的是,其中一些对象使用了写入流stream << object,但是其他对象则使用object.write(stream);.我们希望能够处理任何一个而无需用户指定哪一个.现在,模板专门化允许我们编写专用模板,所以如果它是一种使用object.write(stream)语法的类型,我们可以做类似的事情:

template <class T>
std::ostream &write_object(T object, std::ostream &os) {
    return os << object;
}

template <>
std::ostream &write_object(special_object object, std::ostream &os) { 
    return object.write(os);
}
Run Code Online (Sandbox Code Playgroud)

这是罚款一类,如果我们想诚心的,我们可以为增加更多的特所有不支持的类型stream << object-但只要(例如)用户添加了一个新的类型不支持stream << object,事情再次休息.

我们要的是一个方法,可使用第一个特对任何支持对象stream << object;,但第二次为别的(尽管我们有时可能会想添加第三个用于使用对象x.print(stream);来代替).

我们可以使用SFINAE来做出这个决定.为此,我们通常依赖于C++的其他一些奇怪的细节.一种是使用sizeof操作员.sizeof确定的类型或表达式的大小,但它确实通过查看所以完全在编译时类型参与,而不计算表达式本身.例如,如果我有类似的东西:

int func() { return -1; }
Run Code Online (Sandbox Code Playgroud)

我可以用sizeof(func()).在这种情况下,func()返回一个int,所以sizeof(func())相当于sizeof(int).

经常使用的第二个有趣的项目是数组的大小必须是正数而不是零.

现在,把它们放在一起,我们可以这样做:

// stolen, more or less intact from: 
//     http://stackoverflow.com/questions/2127693/sfinae-sizeof-detect-if-expression-compiles
template<class T> T& ref();
template<class T> T  val();

template<class T>
struct has_inserter
{
    template<class U> 
    static char test(char(*)[sizeof(ref<std::ostream>() << val<U>())]);

    template<class U> 
    static long test(...);

    enum { value = 1 == sizeof test<T>(0) };
    typedef boost::integral_constant<bool, value> type;
};
Run Code Online (Sandbox Code Playgroud)

这里我们有两个重载test.其中第二个需要一个变量参数列表(...),这意味着它可以匹配任何类型-但它也是最后的选择,编译器会在选择过载使,所以它会如果第一个不匹配不会.另一个重载test更有趣:它定义了一个带有一个参数的函数:一个返回函数的指针数组char,其中数组的大小(实质上)sizeof(stream << object).如果stream << object不是有效的表达式,sizeof则将产生0,这意味着我们已经创建了一个大小为零的数组,这是不允许的.这就是SFINAE本身所处的位置.试图替代不支持类型operator<<U会失败,因为它会产生一个零大小的数组.但是,这不是错误 - 它只是意味着从过载集中消除了函数.因此,另一个功能是唯一可以在这种情况下使用的功能.

然后在enum下面的表达式中使用 - 它查看所选重载的返回值test并检查它是否等于1(如果是,则表示char选择了返回的函数,否则,long选择返回的函数) .

其结果是,has_inserter<type>::value将是l,如果我们可以用some_ostream << object;将编译,0如果它不会.然后,我们可以使用该值来控制模板特化,以选择正确的方式来写出特定类型的值.

  • *强制性"希望我能不止一次地赞成"评论*很好的解释,我也学到了一些东西:) (12认同)
  • "如果`stream << object`不是有效的表达式,`sizeof`将产生0" - >这是错误的.`sizeof`永远不能应用于无效表达式,并且它肯定永远不会返回0,因为即使是空结构也会占用1个字节的内存."零大小的数组是重要的" - >也错了.我在原始代码中使用数组的原因是我需要一种方法将任意表达式映射到某种类型.`sizeof`只是该目标的垫脚石.它将表达式映射到`size_t`,数组类型构造函数将`size_t`映射到一个类型.我现在获得诺贝尔奖?;-) (10认同)
  • 请注意,在C++ 0x中,由于`decltype`运算符,将表达式映射到类型是微不足道的.因此,第一个`test`重载可以简化为`test(decltype(ref <std :: ostream>()<< val <U>())*);`.注意完全没有数组.我需要的只是一个带有指向某个有效类型的指针的函数.它是指向输出流(C++ 0x解决方案)的指针或指向大小无关紧要的字符数组的指针(C++ 98解决方案)并不重要.所以你看,零大小的数组实际上根本没有任何关系. (10认同)
  • "指向返回`char`的函数的指针数组 - 如所写的不是参数实际上是'指向'char`数组的指针'? (5认同)
  • 这是一个彻底的解释,也有不错的例子. (2认同)

小智 10

如果您有一些重载的模板函数,则在执行模板替换时可能无法编译某些可能的候选项,因为被替换的东西可能没有正确的行为.这不被认为是编程错误,简单地从可用于该特定参数的集合中移除失败的模板.

我不知道Python是否有类似的功能,并且不知道为什么非C++程序员应该关心这个功能.但是,如果您想了解有关模板的更多信息,最好的书就是C++模板:完整指南.

  • @Jim嗯,SFINAE应该是你真正需要知道的事情清单. (8认同)
  • +1重要的是要注意SFINAE仅在确定替换时可用,即编译器检查签名时.一旦匹配签名并且编译器选择特定模板,任何后来的错误都是错误,编译器将不会测试其他潜在的模板候选. (7认同)
  • "[我]真的不明白为什么非C++程序员应该关心这个功能." < - 因为我现在正在学习C++. (3认同)
  • 现在知道它很好,但是在你对语言有更多经验之前,你不会完全理解它.我已经使用C++十多年了,这不是我需要熟悉的一个方面才能有效. (3认同)

jpa*_*cek 7

SFINAE是C++编译器用于在重载决策期间过滤掉一些模板化函数重载的原理(1)

当编译器解析特定的函数调用时,它会考虑一组可用的函数和函数模板声明,以找出将使用哪一个.基本上,有两种机制可以做到这一点.一个可以被描述为句法.鉴于声明:

template <class T> void f(T);                 //1
template <class T> void f(T*);                //2
template <class T> void f(std::complex<T>);   //3
Run Code Online (Sandbox Code Playgroud)

解析f((int)1)将删除版本2和3,因为int不等于complex<T>T*某些T.同样,f(std::complex<float>(1))将删除第二个变体并f((int*)&x)删除第三个变体.编译器通过尝试从函数参数中推导出模板参数来完成此操作.如果推断失败(如T*反对int),则丢弃重载.

我们想要这个的原因很明显 - 我们可能想为不同的类型做一些稍微不同的事情(例如,复数的绝对值是由x*conj(x)并计算得到一个实数,而不是一个复数,这与浮点数的计算不同).

如果你之前做过一些声明性编程,这个机制类似于(Haskell):

f Complex x y = ...
f _           = ...
Run Code Online (Sandbox Code Playgroud)

C++采取这种方式的方式是,即使推导出的类型是正确的,推论也可能失败,但是反向替换到另一个会产生一些"荒谬的"结果(稍后会有更多).例如:

template <class T> void f(T t, int(*)[sizeof(T)-sizeof(int)] = 0);
Run Code Online (Sandbox Code Playgroud)

推导时f('c')(我们用单个参数调用,因为第二个参数是隐式的):

  1. 编译器匹配T针对char其产生平凡T作为char
  2. 编译器T将声明中char的所有s 替换为s.这产生了void f(char t, int(*)[sizeof(char)-sizeof(int)] = 0).
  3. 第二个参数的类型是指向数组的指针int [sizeof(char)-sizeof(int)].该阵列的大小可以是例如.-3(取决于您的平台).
  4. 长度数组<= 0无效,因此编译器会丢弃重载.替换失败不是错误,编译器不会拒绝该程序.

最后,如果仍然存在多个函数重载,编译器将使用转换序列比较和模板的部分排序来选择一个"最佳".

还有更多这样的"荒谬"结果,它们在标准列表中列举(C++ 03).在C++ 0x中,SFINAE的领域几乎扩展到任何类型错误.

我不会写出大量的SFINAE错误列表,但最受欢迎的一些是:

  • 选择没有它的类型的嵌套类型.例如.typename T::typefor T = intT = Awhere A是没有嵌套类型的类type.
  • 创建非正大小的数组类型.举个例子,请看这个litb的答案
  • 创建一个指向不是类的类型的成员指针.例如.int C::*对于C = int

这个机制与我所知道的其他编程语言中的任何东西都不相似.如果你在Haskell中做类似的事情,你会使用更强大但在C++中不可能的防护.


1:或谈论类模板时的部分模板特化


Pot*_*ter 5

Python根本不会帮助你.但你确实说你已基本熟悉模板了.

最基本的SFINAE结构是使用enable_if.唯一棘手的部分是class enable_if封装 SFINAE,它只是暴露它.

template< bool enable >
class enable_if { }; // enable_if contains nothing…

template<>
class enable_if< true > { // … unless argument is true…
public:
    typedef void type; // … in which case there is a dummy definition
};

template< bool b > // if "b" is true,
typename enable_if< b >::type function() {} //the dummy exists: success

template< bool b >
typename enable_if< ! b >::type function() {} // dummy does not exist: failure
    /* But Substitution Failure Is Not An Error!
     So, first definition is used and second, although redundant and
     nonsensical, is quietly ignored. */

int main() {
    function< true >();
}
Run Code Online (Sandbox Code Playgroud)

在SFINAE中,有一些结构可以设置错误条件(class enable_if这里)和一些并行的,否则相互矛盾的定义.除了一个定义之外的所有定义都会发生一些错误,编译器选择并使用它而不会抱怨其他定义.

什么样的错误是可以接受的是一个最近才被标准化的重要细节,但你似乎并没有问这个问题.