浅析Cpp中的SFINAE


概述

SFINAE(Substitution Failure Is Not An Error,替换失败并非错误)一听就非常高级,什么是“替换”?这里的替换,实际上指的正是模板实例化;也就是说,当模板实例化失败时,编译器并不认为这是一个错误。这个概念晦涩难懂,因为它牵扯到编译器对模板的处理,而且很多时候不知道怎么去用(唉,语言学家),下面通过一个例子先学习下。

进化之路

cpp 11

在正式编写代码前要介绍一个重要的库 #include <type_traits>

该库是模板编程中最重要核心的库之一,若缺少本库就不会有如此丰富强大的泛型编程的实现。

std:: enable_if<>

此模板可以说是 SFINA 的灵魂一般的存在。

我们的目的是让模板参数符合某一种约束条件,而 enable_if 可以根据约束条件进行特定展开。根据这一特点,可以对模板的展开进行限制。具体的:

  • 如果满足约束条件,在 enable_if 中会存在等同于 T 的公开成员 typedef type
  • 如果不满足条件,则没有此 typedef type

std:: enable_if 可能的实现:

template<bool B, class T = void>
struct enable_if {};

template<class T>
struct enable_if<true, T> { 
    typedef T type; 
};

std:: is_xxx<>

这是在 type_traits 中的一系列模板,这类模板可以对模板参数进行特定条件判断符合与否。

  • 若符合确定条件,则提供等于 true 的成员常量 value
  • 若不成功,则提供等于 false 的成员常量 value

而对于本示例,我们可以使用std::is_integral<T>,本模板可以判断T是否为整数类型。
如果是整数类型,则要求展开失败,否则展开成功。

#include <iostream>
#include <type_traits>
#include <vector>

namespace my {

template <typename Type>
class vector {
public:
    vector(size_t len, Type val) {
        std::cout << "vector(size_t len, Type val)" << std::endl;
    }

    /**
     * 引入第二个模板参数
     * 根据第二个模板参数展开失败与否
     * 决定是否最终使用该模板
     */
    template <typename Iter, 
        typename SFINA = typename std::enable_if<!std::is_integral<Iter>::value>::type>
    vector(Iter begin, Iter end) {
        std::cout << "vector(Iter begin, Iter end)" << std::endl;
    }
};

}  // namespace my

my::vector<Type> vec0(5, val);进行模板匹配时候,首先会找到vector(Iter begin, Iter end)并尝试展开,但该模板的第二个参数约束Iter不是整形类型时,才能展开成功。因此再重新寻找其他的匹配函数。

cpp 14 ~ cpp 17

对于每次写std::is_integral<T>::valuestd::enable_if<B>::type都比较麻烦。因此 cpp 14 建议可以通过另一个简单的符号表示该内容。也就是type trait variable templates的概念。直到 cpp 17 才在正式标准中进行了全面的完善。

// cpp14
template< bool B, class T = void >
using enable_if_t = typename enable_if<B,T>::type;
// cpp17
template< class T >
inline constexpr bool is_integral_v = is_integral<T>::value;

因此我们可以得到以下的简洁版本。

#include <iostream>
#include <type_traits>
#include <vector>

namespace my {

template <typename Type>
class vector {
public:
    vector(size_t len, Type val) {
        std::cout << "vector(size_t len, Type val)" << std::endl;
    }

    /**
     * 相对于cpp11更加简洁的写法
     * 同时第二个模板参数没有具体使用到,可以省略成一个匿名形式
     */
    template <typename Iter, 
        typename = typename std::enable_if_t<!std::is_integral_v<Iter>>>
    vector(Iter begin, Iter end) {
        std::cout << "vector(Iter begin, Iter end)" << std::endl;
    }
};

}  // namespace my

cpp 20

c++ 是一门不断发展的现代语言,在 cpp 20 中提出了概念和约束到标准中。

requires

requires 是一个关键字。可以直接在模板函数中进行使用。

requires是在template和函数体之间编写,提升可代码可阅读性。

注意一点,requires 子句需要是一个初等表达式 或者 带括号的表达式。

#include <concepts>
#include <iostream>
#include <type_traits>
#include <vector>

namespace my {

template <typename Type>
class vector {
public:
    vector(size_t len, Type val) {
        std::cout << "vector(size_t len, Type val)" << std::endl;
    }

    // 使用 requires 关键字
    // 直接写出约束条件
    template <typename Iter>
    requires (!std::is_integral_v<Iter>)
    vector(Iter begin, Iter end) {
        std::cout << "vector(Iter begin, Iter end)" << std::endl;
    }
};

}  // namespace my

concept

concept 是一个关键字。可以通过该关键字定义一个基于模板参数的约束条件。再将该约束条件运用到具体的模板函数中。

这样可以使约束条件和具体的模板函数进行分离,大大化简编码的复杂度。同时这个约束条件可以进行复用,再次减少了代码的冗余度。

#include <concepts>
#include <iostream>
#include <type_traits>
#include <vector>

namespace my {

// 定义一个约束条件
// 约束为不能是整形数值
template <typename Type>
concept IterType = !std::is_integral_v<Type>;

template <typename Type>
class vector {
public:
    vector(size_t len, Type val) {
        std::cout << "vector(size_t len, Type val)" << std::endl;
    }

    // 直接写定义的约束条件
    template <IterType Iter>
    vector(Iter begin, Iter end) {
        std::cout << "vector(Iter begin, Iter end)" << std::endl;
    }
};

}  // namespace my

requires 表达式

requires 表达式 是将上述的conceptrequires结合使用。

requires 表达式的编写形式与函数编写非常相似,将约束条件挨个写在requires后的大括号域内。

这种展现形式简洁明了,容易扩充。能同时兼具单独使用conceptrequires的优点。

#include <concepts>
#include <iostream>
#include <type_traits>
#include <vector>

namespace my {

// requires 表达式
template <typename Type>
concept IterType = requires { 
    std::is_integral_v<Type> == false; 
};

template <typename Type>
class vector {
public:
    vector(size_t len, Type val) {
        std::cout << "vector(size_t len, Type val)" << std::endl;
    }

    // 使用 requires 表达式
    template <IterType Iter>
    vector(Iter begin, Iter end) {
        std::cout << "vector(Iter begin, Iter end)" << std::endl;
    }
};

}  // namespace my

问题引入

假如设计一个加法接口,可以对两个基础类型数据、两个支持加法的自定义类型数据、两个相同容器里的数据、两个相同类型的数组里的数据进行加操作,应当怎么做?这个加法接口有两个输入,返回相加后的结果,对于数组或者容器类型的数据返回的是逐个相加的结果。

根据这个要求,我们首先可以写出以下的函数:

template<typename T>
T add( T a, T b )
{
    return a + b;
} 

这样写会带来一个问题:当形参a和b不是相同的类型的时候,会造成匹配不上的结果

int a = 10;
float b = 10;
auto c = add( a, b );

此时编译器会报错:

main.cpp:13:21: error: no matching function for call to ‘add(int&, float&)auto c = add( a, b );
                     ^
main.cpp:4:3: note: candidate: template<class T> T add(T, T)
 T add( T a, T b )
   ^~~
main.cpp:4:3: note:   template argument deduction/substitution failed:
main.cpp:13:21: note:   deduced conflicting types for parameter ‘T’ (intandfloat)
  auto c = add( a, b );

可以看到我们在调用add方法的时候,我们的模板函数钟的T只有一种类型,没办法推导出两种类型,编译失败。

我们接着对这个函数进行改进,让它能够支持两种类型的输入,我们想着既然一个模板参数对应一个类型,那么我们再加一个模板参数不就可以对应两个不同类型的参数了吗,答案是可以的,正当我们兴奋的想写下这个函数的时候,问题来了,返回类型怎么写?

template<typename T1, typename T2>
返回类型?add( T1 a, T2 b )
{
    return a + b;
} 

当两个不同类型的值相加的时候,怎么确定函数的返回类型呢,我们知道一般我们写一个float的数和int类型的数相加得到的一定是一个float数,但是在模板中输入有成千上万种可能,我们没办法写出一个具体的类型来指代。这种事情最好还是让编译器来自己决定,那就是尾置返回类型:

template<typename T1, typename T2>
auto add( T1 a, T2 b )->decltype( a + b )
{
    return a + b;
} 

这里的auto只是一个占位符,并不是指实际的类型,真正的类型是靠decltype来推导出来的,decltype是c++11之后引入的关键词,作用是在编译期推导出括号里的表达式、变量或者类型本身的类型。这里它会自动推导出表达式( a + b )的类型,并取代前面的auto作为返回值类型。

在c++14之后,这个函数可以取消尾置返回类型,auto也具备了自动推导返回值类型的能力:

template<typename T1, typename T2>
auto add( T1 a, T2 b ) // #接口实现1
{
    return a + b;
} 

到这里一切都很好,我们这个模板函数可以支持两种不同类的输入,并且能够自动推导返回类型了,但是这还不够,因为对于容器类型的数据来说,我们还没办法对其进行支持,因此我们要写一个可以支持两个容器类型数据加法的重载函数模板:

template<template<typename >class Container1, typename T1,
         template<typename >class Container2, typename T2>
auto add( Container1<T1> a, Container2<T2> b )// #接口实现2
{
    Container1<decltype( std::declval<T1>() + std::declval<T2>() )> ret;
    auto a_iter = a.begin(), b_iter = b.begin();
    for ( ; a_iter != a.end(), b_iter != b.end(); a_iter ++, b_iter ++ ) {
        ret.emplace_back( *a_iter + *b_iter );
    }
    if ( a_iter != a.end() ) { // 处理剩下的数据
        for ( ; a_iter != a.end(); a_iter ++ ) ret.emplace_back( *a_iter );
    }
    if ( b_iter != b.end() ) { // 处理剩下的数据
        for ( ; b_iter != b.end(); b_iter ++ ) ret.emplace_back( *b_iter );
    }

    return ret;
}

测试一下:

std::vector<int> a1 = {1, 2, 3, 4};
std::vector<float> b1 = { 1, 2, 3 };
auto c1 = add( a1, b1 ); // ok,会选择接口实现2而不是接口实现1
for ( const auto& val : c1 ) std::cout<<val<<std::endl; // ok; 输出 2 4 6 4

注意,这里的Container1和Container2是模板模板参数。T1和T2是模板模板参数中的具体参数。接口实现2可以看成是对接口实现1的重载,在调用时,如果输入是两个容器类型的数据,编译器会认为接口实现2匹配的更好,从而实例化接口实现2的函数模板。

但是这样同时会带来新的问题,当我们想对两个由自定义的模板类定义的对象操作add接口的时候,会出现棘手的现象。

新的问题

首先,只考虑有接口实现1的情况下,我们自己定义了一个模板类,例如:

template<typename T>
struct B{
        B() {}
        B( const T b_ ) : b( b_ ) {}

        B operator+( const B rhs ) // 加法运算符重载1
        {
                return B( rhs.b + b );
        }

        template<typename U>
        auto operator+( const B<U> rhs ) // 加法运算符重载2 
        {
                return B<decltype( rhs.b + b )>( rhs.b + b ); // 注意这里让编译器去决定加法之后的模板参数类型是什么
        }

        T b = 10;
};

这里我们重载了加法运算符,让它得以支持加法操作,同时我们还实现了一个加法运算符的模板函数重载,让这个类型的变量能对不同的T类型实现加法操作,譬如:

B<int> a1, a2;
a1 + a2; // ok,a1和a2是相同类型的数据,调用加法运算符重载1
B<float> a3;
a1 + a3; // ok,a1和a3不是相同类型的数据,调用加法运算符重载2

在一个只考虑有接口实现1的版本中,我们调用add方法:

template<typename T1, typename T2>
auto add( T1 a, T2 b ) // #接口实现1
{
    return a + b;
} 
int main()
{
   B<int> a1;
   B<float> b1;
   auto c1 = add( a1, b1 ); // ok, a1和b1虽然是不同的类型,但是因为重载了运算符,可以做加法
   return 0;
}

在一个既有接口实现1又有接口实现2的版本中,我们调用add方法:

template<typename T1, typename T2>
auto add( T1 a, T2 b ) // #接口实现1
{
    return a + b;
} 

template<template<typename >class Container1, typename T1,
         template<typename >class Container2, typename T2>
auto add( Container1<T1> a, Container2<T2> b )// #接口实现2
{
    Container1<decltype( std::declval<T1>() + std::declval<T2>() )> ret;
    auto a_iter = a.begin(), b_iter = b.begin();
    for ( ; a_iter != a.end(), b_iter != b.end(); a_iter ++, b_iter ++ ) {
        ret.emplace_back( *a_iter + *b_iter );
    }
    if ( a_iter != a.end() ) { // 处理剩下的数据
        for ( ; a_iter != a.end(); a_iter ++ ) ret.emplace_back( *a_iter );
    }
    if ( b_iter != b.end() ) { // 处理剩下的数据
        for ( ; b_iter != b.end(); b_iter ++ ) ret.emplace_back( *b_iter );
    }

    return ret;
}

int main()
{
   std::vector<int> a1 = {1, 2, 3, 4};
   std::vector<float> b1 = { 1, 2, 3 };
   auto c1 = add( a1, b1 ); // ok,匹配到接口实现2上

   B<int> a2;
   B<float> b2;
   auto c2 = add( a2, b2 ); // error,编译器同样会匹配到接口实现2,但是B类型中没有begin,end等函数,报错
   return 0;
}

我们本来想让接口实现1只为基础类型或者支持加法的自定义类型服务,而让接口实现2为容器类型服务的。现在好了,在为自定义模板类型的变量调用接口的时候,也会匹配到接口实现2上去,与我们的预期大大不同。

这个问题的根本原因在于我们的接口实现2的形参在与实参进行匹配的时候,因为我们的自定义类型是模板类,与模板模板参数匹配的更好,这样编译器会优先决策匹配的更好的模板函数实现,而这整个过程就是有c++重载决议机制实现的。

那我们有没有办法去人为的控制这种重载呢,让最终的调用能够选择正确的匹配函数呢?答案是可以的,这就是我们要利用SFINAE机制来做的事情了,而这正是本文接下来要说的重点。

在讲SFINAE之前,我们要大致了解一下我们实现的这个add接口从定义到实例化再到最终调用编译器做了哪些事情,这有助于后面理解SFINAE机制。

实例化过程

  1. 两阶段命名查找(Two-Phase Name Lookup)

在c++中,将各种变量、函数、模板等命名分为受限型命名(qualified name),非受限型命名(unqualified name),依赖型命名(dependent name),非依赖型命名(non-dependent)几种。所谓受限型命名,指的是在作用域符(::)或者成员访问运算符(->和.)之后的名称(即属于类成员或者命名空间内的变量或者函数)。非受限型命名则是除了受限型名之外的名称。依赖型命名是指以某种方式依赖于模板参数的名称。相同的,非依赖型命名就是不属于依赖性名称的名称。

我们这里的add接口在调用的时候既没有用作用域符,也没有用成员访问运算符,因此是一个非受限型名称,同时add接口又依赖模板参数,因此也属于依赖性名称,因此可以称之为unqulified dependent name。对于这类名称,编译器在首次看到的时候会先对其进行一次查找,这时候由于模板还没有实例化,这个时候找到的只是这个add函数的“蓝图”,即我们所写的函数模板,这个阶段只能对其做基础的模板解析和语法检查,等模板经历了实参推导和替换之后,才会生成实际的函数,这时候还需要在进行一次查找以找到实例化后的函数,这个过程就是两阶段命名查找

第一阶段的命名查找一般使用普通查找规则(Ordinary Lookup rule),第二阶段会使用著名的实参依赖(Aurgument Dependent Lookup, ADL)查找规则来进行查找,具体的原理不是本文的重点,这里不再深入,有兴趣的朋友可以自己去了解。通过命名查找机制找到的实例化之后的函数会放入重载集中(Overload Set),随后会以候选者的角色送入重载决议中进行裁决。

  1. 两阶段编译检查(Two-Phase Translation)

两阶段编译检查是配合两阶段命名查找的一种机制,同样以add接口为例,在第一次查找这个函数模板的时候,会对其进行解析,同时检查它的语法合法性,这时候不管类型是不是支持加法操作,编译器都不会报错,因为编译器此时还不知道模板的参数具体是什么。

在第二阶段命名查找之后,编译器已经能够找到了add模板实例化之后函数,经过重载决议之后,编译器会从这些实例化函数中选取一个最合适的重载函数作为最终要执行的函数,这时候会进行第二阶段的编译器检查,这时候检查就会更加严格,会检查函数内部的表达式是否能够成立,例如上面的例子中:

int main()
{
   std::vector<int> a1 = {1, 2, 3, 4};
   std::vector<float> b1 = { 1, 2, 3 };
   auto c1 = add( a1, b1 ); // ok,匹配到接口实现2上

   B<int> a2;
   B<float> b2;
   auto c2 = add( a2, b2 ); // error,编译器同样会匹配到接口实现2,但是B类型中没有begin,end等函数,报错
   return 0;
}

其中的auto c2 = add( a2, b2 ); 就是在这个第二段编译检查阶段才报错的。

  1. 模板实参推导(Template Argument Deduction, TAD)

为啥需要模板实参推导这个机制呢?最主要的原因是我们想要在使用add这个模板函数的时候要像使用普通函数一样简单,虽然可以通过显示的指定模板的参数来调用,但是这种方法显得非常的繁琐。我们先举一个简单的例子来直观感受一下:

template <typename T>
void f( T t ) 
{
}


int main() 
{
    f(1);         // f<int>(1); 模板参数T被推导为int
    f(1.1);       // f<double>(1.1); 模板参数T被推导为double
    f();          // error: T cannot be duduced
    ......
}

实际上模板实参推导涉及到许许多多的内容,会遇到诸如类型退化、左值引用传参、右值引用传参、万能引用、完美转发、可变参数等诸多情况,模板实参推导的规则非常复杂,其原理也不在本文中详细说明。

  1. 模板实参替换(Template Argument Substitution, TAS)

在用推导出的实参替换模板参数之后,再使用推导出来的类型替换模板参数,从而完成实例化一个函数模板的过程。

这里就要讲到本文的重点了:参数替换过程中,并不总是能够替换成功的,举个例子:

template<typename T>
void test( T a ) // 重载1
{
}

template<typename T>
void test( T::value_type a ) // 重载2
{
}

int main()
{
    int a = 10;
    test( a ); // ok, 重载2替换失败,选择重载1
    return 0;
}

这里编译器会通过命名查找机制找到重载1和重载2两种函数模板,然后模板参数T都可以被推导为int类型,但是由于int类型中没有value_type这个类型,会造成参数替换失败,此时编译器并不会立即报错,只是将其从重载集中移除,后面的重载决议也就不会再选择它,这个机制就是SFINAE。

我们利用这种机制可以实现对模板参数类型的限制,也可以通过强行禁用某些模板来实现人为的重载选择。

  1. 重载决议

重载决议的核心就是在重载集中找到唯一的一个与调用情况最优匹配的实现函数,如果重载决议找不到最匹配的函数,则编译器会报” no matching function for call to xxx”的错误。

SFINAE

我们的思路是想利用SFINAE机制来让下面这个调用:

B<int> a2;
B<float> b2;
auto c2 = add( a2, b2 );

排除掉接口实现2,方法是强行让编译器对这个调用在和接口实现2的函数模板进行模板实参替换的时候失败,这样重载决议中就会忽略掉这个模板实现,从而让它选择接口实现1。具体的实现需要借助标准模板库里提供的两个工具:std::enable_if以及std::voit_t;

enable_if的一个可能的实现是这样的(参考cppreference):

template<bool B, class T = void>
struct enable_if {};

template<class T>
struct enable_if<true, T> { using type = T; };

template< bool B, class T = void >
using enable_if_t = typename enable_if<B,T>::type;// c++14:别名而已,可以少写一个type

这个元函数是一个类型萃取(Type Trait),在模板的第一个参数B为true的时候,它的type成员会返回一个类型:如果没有第二个模板参数,返回类型是默认的void,否则,返回的是其第二个参数的类型。如果参数B为false的时候,其成员类型是未定义的,根据SFINAE机制,编译期会忽略包含该std::enable_if_t<>表达式的模板。说人话:

意思就是我们可以设立一个条件,当条件满足的时候,就让编译器生成这个函数实例,否则忽略它。非常方便!我们只要在我们的add接口实现2上去用这个工具判断输入的模板实参是不是容器类型就行了,如果是容器类型,那么就实例化这个函数模板,如果不是,则忽略它。

那么问题就变成了怎么去判断输入类型是不是容器类型。我们可以简单的认为一个类中如果有迭代器,并且具有begin()或者end()这样的成员方法,那么这就是一个容器类型(当然并不严谨,因为stack和queue中并没有迭代器,这里只是作为一个例子,并不考虑这种情况,严谨点的也可以单独将stack和queue拎出来做特化处理即可,问题都不大)。

我们可以利用成员探测技术(Detecting Members)来让编译器在自己去探测一个类型是否具有迭代器成员和begin()成员。我们可以写出下面这种元函数代码:

// 判断类型否是一个容器类型的元函数
template<typename, typename, typename = std::void_t<>>
struct is_container_type : std::false_type{  };  // 普通版本

template<typename T1, typename T2> // 偏特化版本
struct is_container_type<T1, T2, std::void_t<typename T1::iterator, typename T2::iterator, decltype( std::declval<T1>().begin(), std::declval<T2>.begin() )>>
:std::true_type{  };

std::void_t也是一种利用SFINAE机制实现的元函数,它的一种可能的实现如下所示(参考cppreference):

template<typename ...>
using void_t = void;

它就是一个别名模板(alias template),是void类型的一个别名,但是它的模板参数是一个可变参数,这同时也是一个可变参数模板(variable template)。这个元函数的意思是它可以接收任意个类型作为模板参数,编译器会在实参替换阶段检查看你输入的每个类型能否被替换成功,如果替换失败,编译器会忽略这个模板。

回到我们这个判断类型否是一个容器类型的元函数里,如果模板参数T1和T2中含有iterator类型,并且有begin()函数,那么std::void_t中的模板参数可以被替换成功,从而void_t类型就成了void的别名,进一步的编译器会选择is_container_type这个模板类的偏特化版本。注意这里偏特化的版本继承了std::true_type这个类(这是个语法糖),这个类里面有个编译期布尔常量value,其值为true。

如果模板参数T1和T2中没有iterator类型或者没有begin()函数,那么std::void_t中模板参数会替换失败,但是由于SFINAE,编译器不会报错,此时编译器会选择is_container_type这个模板类的普通版本,这个普通版本继承了std::false_type这个类,这里类里面的编译期布尔常量value的值为false。

int main()
{
    std::cout<<is_container_type<std::vector<int>, std::vector<float>>::value<<std::endl;// ok, 打印1
    std::cout<<is_container_type<B<int>, B<float>>::value<<std::endl; // ok, 打印0
    std::cout<<is_container_type<int, float>::value<<std::endl; // ok, 打印0
}

我们可以把这个判断类型的元函数与std::enable_if_t结合在一起来对类型做一些限制,从而可以帮助编译器排除一些模板,选择正确的模板。

template<typename T1, typename T2>
auto add( T1 a, T2 b ) // #接口实现1
{
    return a + b;
} 

// 判断类型否是一个容器类型的元函数
template<typename, typename, typename = std::void_t<>>
struct is_container_type : std::false_type{  };  // 普通版本

template<typename T1, typename T2> // 偏特化版本
struct is_container_type<T1, T2, std::void_t<typename T1::iterator, typename T2::iterator, decltype( std::declval<T1>().begin(), std::declval<T2>.begin() )>>
:std::true_type{  };

template<template<typename >class Container1, typename T1,
         template<typename >class Container2, typename T2>
std::enable_if_t<is_container_type<Container1<T1>, Container2<T2>>::value, Container1<decltype( std::declval<T1>() + std::declval<T2>() )>> 
add( Container1<T1> a, Container2<T2> b )// #接口实现2
{
    Container1<decltype( std::declval<T1>() + std::declval<T2>() )> ret;
    auto a_iter = a.begin(), b_iter = b.begin();
    for ( ; a_iter != a.end(), b_iter != b.end(); a_iter ++, b_iter ++ ) {
        ret.emplace_back( *a_iter + *b_iter );
    }
    if ( a_iter != a.end() ) { // 处理剩下的数据
        for ( ; a_iter != a.end(); a_iter ++ ) ret.emplace_back( *a_iter );
    }
    if ( b_iter != b.end() ) { // 处理剩下的数据
        for ( ; b_iter != b.end(); b_iter ++ ) ret.emplace_back( *b_iter );
    }

    return ret;
}

int main()
{
   std::vector<int> a1 = {1, 2, 3, 4};
   std::vector<float> b1 = { 1, 2, 3 };
   auto c1 = add( a1, b1 ); // ok,匹配到接口实现2上

   B<int> a2;
   B<float> b2;
   auto c2 = add( a2, b2 ); // ok,由于B类型不是容器类型,编译器会匹配到接口实现1上
   return 0;
}

问题解决!现在我们可以让B这种自定义的模板类的数据在调用add接口的时候不再匹配到接口实现1上去了。这就是SFINAE机制的强大威力。

但是对于接口的提供者来说,这还远远不够,因为你永远不知道用户在调用这个接口的时候会传进去个什么玩意,我们必须对输入的类型做一些限制:a. 比如我们这里的接口实现1中,我们不能允许两个不支持加法操作的类型数据传进来,b. 同样在接口实现2中,我们不能允许两个异型容器进行加法操作。

针对条件a,我们仿照仿照上面的方法可以写出判断两个类型是否支持加法的元函数,针对b,我们可以写出判断两个容器类型是否是同型容器的元函数:

// 判断两个类型是否支持加法的元函数
template<typename, typename, typename = std::void_t<>>
struct is_add_supported_type : std::false_type{  };

template<typename T1, typename T2> // 如果支持加法,is_add_supported_type继承的value = 1
struct is_add_supported_type<T1, T2, std::void_t<decltype( std::declval<T1>() + std::declval<T2>() )>>::std::true_type{  };

// 判断两个容器是否是相同类型容器的元函数
template<template<typename >class Container1,
         template<typename >class Container2>
struct is_same_container_type
{
    static constexpr bool value = std::is_same_v<Container1<int>, Container2<int>>;
};

int main()
{
    // 测试
    std::cout<<is_add_supported_type<int, float>::value<<std::endl; // ok, 打印1
    std::cout<<is_add_supported_type<std::vector<int>, std::vector<float>>::value<<std::endl;// ok, 打印0,两个容器不支持直接加
    std::cout<<is_same_container_type<std::vector, std::vector>::value<<std::endl;// ok, 打印1
    std::cout<<is_same_container_type<std::vector, std::list>::value<<std::endl;//ok, 打印0, vector和list不是同型容器
}

注意这里的判断两个容器是否是同型容器的元函数中用到了std::is_same_v,这个元函数属于预测型萃取,可以用来判断两个类型是否相同,其源码为:(参考cppreference)

template<class T, class U>
struct is_same : std::false_type {};

template<class T>
struct is_same<T, T> : std::true_type {};

template< class T, class U >
inline constexpr bool is_same_v = is_same<T, U>::value; // c++ 17

我们利用上面所写的两个元函数来改进我们的接口:

// 判断两个类型是否支持加法的元函数
template<typename, typename, typename = std::void_t<>>
struct is_add_supported_type : std::false_type {  };

template<typename T1, typename T2> // 如果支持加法,is_add_supported_type继承的value = 1
struct is_add_supported_type<T1, T2, std::void_t<decltype( std::declval<T1>() + std::declval<T2>() )>>::std::true_type{  };

template<typename T1, typename T2>
std::enable_if_t<is_add_supported_type<T1, T2>::value, decltype( std::declval<T1>() + std::declval<T2>() )> 
add( T1 a, T2 b ) // #接口实现1
{
    return a + b;
} 

// 判断类型否是一个容器类型的元函数
template<typename, typename, typename = std::void_t<>>
struct is_container_type : std::false_type{  };  // 普通版本

template<typename T1, typename T2> // 偏特化版本
struct is_container_type<T1, T2, std::void_t<typename T1::iterator, typename T2::iterator, decltype( std::declval<T1>().begin(), std::declval<T2>.begin() )>>
:std::true_type{  };

// 判断两个容器是否是相同类型容器的元函数
template<template<typename >class Container1,
         template<typename >class Container2>
struct is_same_container_type
{
    static constexpr bool value = std::is_same_v<Container1<int>, Container2<int>>;
};

template<template<typename >class Container1, typename T1,
         template<typename >class Container2, typename T2>
std::enable_if_t<is_container_type<Container1<T1>, Container2<T2>>::value && is_same_container_type<Container1, Container2>::value, Container1<decltype( std::declval<T1>() + std::declval<T2>() )>> 
add( Container1<T1> a, Container2<T2> b )// #接口实现2
{
    Container1<decltype( std::declval<T1>() + std::declval<T2>() )> ret;
    auto a_iter = a.begin(), b_iter = b.begin();
    for ( ; a_iter != a.end(), b_iter != b.end(); a_iter ++, b_iter ++ ) {
        ret.emplace_back( *a_iter + *b_iter );
    }
    if ( a_iter != a.end() ) { // 处理剩下的数据
        for ( ; a_iter != a.end(); a_iter ++ ) ret.emplace_back( *a_iter );
    }
    if ( b_iter != b.end() ) { // 处理剩下的数据
        for ( ; b_iter != b.end(); b_iter ++ ) ret.emplace_back( *b_iter );
    }

    return ret;
}

int main()
{
   int a0 = 10;
   float b0 = 20.2;
   auto c0 = add( a0, b0 ); // ok, a0 和 b0是可加的

   std::vector<int> a1 = {1, 2, 3, 4};
   std::vector<float> b1 = { 1, 2, 3 };
   auto c1 = add( a1, b1 ); // ok,匹配到接口实现2上

   B<int> a2;
   B<float> b2;
   auto c2 = add( a2, b2 ); // ok,匹配到接口实现1上
   return 0;
}

写到这里发现这个接口已经像那么回事了,但是还是存在问题,因为我们针对容器类型的函数模板中,使用了emplace_back这个函数,不幸的是并不是所有的容器类型里面都支持这个函数,比如std::set,std::map里面只有insert方法。我们需要根据容器类型里有没有emplace_back函数来区分处理。这时候,可以先写一个判断类型中是否有emplace_back方法的元函数,然后利用编译期if来分别做处理:

// 判断类型中是否有emplace_back成员的元函数
template<typename, typename = std::void_t<>>
struct has_emplace_back_method : std::false_type{  };

template<typename T>
struct has_emplace_back_method<T, std::void_t<decltype( std::declval<T>.emplace_back() )>> : std::true_type{  };

// # 改进后的接口实现2
template<template<typename >class Container1, typename T1,
         template<typename >class Container2, typename T2>
std::enable_if_t<is_container_type<Container1<T1>, Container2<T2>>::value && is_same_container_type<Container1, Container2>::value, Container1<decltype( std::declval<T1>() + std::declval<T2>() )>> 
add( Container1<T1> a, Container2<T2> b )// 
{
    Container1<decltype( std::declval<T1>() + std::declval<T2>() )> ret;
    auto a_iter = a.begin(), b_iter = b.begin();
    for ( ; a_iter != a.end(), b_iter != b.end(); a_iter ++, b_iter ++ ) {
        // 编译期if,由于我们事先已经约束了Container1和Container2是同型容器,因此只要判断一个就行
        if constexpr ( has_emplace_back_method<Container1<T1>>::value ) ret.emplace_back( *a_iter + *b_iter );
        else ret.insert( *a_iter + *b_iter );
    }
    if ( a_iter != a.end() ) { // 处理剩下的数据
        for ( ; a_iter != a.end(); a_iter ++ ) {
            if constexpr ( has_emplace_back_method<Container1<T1>>::value ) ret.emplace_back( *a_iter );
            else ret.insert( *a_iter );
        }
    }
    if ( b_iter != b.end() ) { // 处理剩下的数据
        for ( ; b_iter != b.end(); b_iter ++ ) {
            if constexpr ( has_emplace_back_method<Container1<T1>>::value ) ret.emplace_back( *b_iter );
            else ret.insert( *b_iter );
        }
    }

    return ret;
}

int main()
{
    // 测试
    std::vector<int> a1 = {1, 2, 3, 4};
    std::vector<float> b1 = { 1, 2, 3 };
    auto c1 = add( a1, b1 ); // ok

    std::set<int> a2 = {1, 2, 3, 4};
    std::set<double> b2 = {1, 2, 3};
    auto c2 = add( a2, b2 ); // ok, 也可以支持了
}

编译期if是在c++17标准之后引入的东西,与std::enable_if其实非常类似,其原理也是利用了SFINAE机制,在某些时候甚至可以和std::enable_if互换着用。比如这里如果不用编译期if表达式的话,我们可以再写一个针对具有insert方法的容器类型的重载函数模板,通过std::enable_if来控制编译器选择哪个模板来实例化。

编译期if的好处是可以根据条件来启用或禁止某些特定语句,而不用像std::enable_if一样重新写一个模板,此外它的可读性更好一些。

当我们觉得这个函数接口已经写完了的时候,又发现了重大的问题,我们这个接口虽然对容器类型的数据很好的支持了,但是对数组却不支持,好家伙,这是万万不能行的。不过问题其实不大,我们只要对数组类型的数据整一个特化的版本就好了。

template<int N1, int N2>
struct min_val // 元函数:求两个数中的最小值
{
    static constexpr int value = N1 < N2 ? N1 : N2;
};

// 元函数:求两个数中的最大值
constexpr int max_val( int N1, int N2 ) // 常量表达式,C++14
{
    return N1 > N2 ? N1 : N2;
}

// 针对数组类型的模板特化
template<typename T1, int N1,
         typename T2, int N2>
std::enable_if_t<is_add_supported_type<T1, T2>::value, decltype( std::declval<T1>() + std::declval<T2>() )*>
add( T1 (&a)[N1], T2 (&b)[N2] )
{
    // 注意这里要用static修饰要返回用结果变量,不然你返回一个临时变量的指针,运行时会出错
    static decltype( std::declval<T1>() + std::declval<T2>() ) ret[max_val( N1, N2 )];

    int i = 0;
    for ( ; i < min_val<N1, N2>::value; i ++ ) ret[i] = a[i] + b[i];
    for ( ; i < N1; i ++ ) ret[i] = a[i]; // 处理剩余的元素
    for ( ; i < N2; i ++ ) ret[i] = b[i];

    return ret; // 返回数组退化后的指针(注意c++是不允许返回数组类型的)
}

min_val和max_val是典型的值元编程技巧,可以用来在编译期间进行值计算。其中常量表达式可读性更好一些。

到这里,我们的add接口才算是写完了,现在将完整的代码贴在下面供大家参考:

#ifndef MY_ADD_H
#define MY_ADD_H

#include <iostream>
#include <type_traits>

// 判断两个类型是否支持加法的元函数
template<typename, typename, typename = std::void_t<>>
struct is_add_supported_type : std::false_type {  };

template<typename T1, typename T2> // 如果支持加法,is_add_supported_type继承的value = 1
struct is_add_supported_type<T1, T2, std::void_t<decltype( std::declval<T1>() + std::declval<T2>() )>>::std::true_type{  };

template<typename T1, typename T2>
std::enable_if_t<is_add_supported_type<T1, T2>::value, decltype( std::declval<T1>() + std::declval<T2>() )> 
add( T1 a, T2 b ) // #接口实现1
{
    return a + b;
} 

// 判断类型否是一个容器类型的元函数
template<typename, typename, typename = std::void_t<>>
struct is_container_type : std::false_type{  };  // 普通版本

template<typename T1, typename T2> // 偏特化版本
struct is_container_type<T1, T2, std::void_t<typename T1::iterator, typename T2::iterator, decltype( std::declval<T1>().begin(), std::declval<T2>.begin() )>>
:std::true_type{  };

// 判断两个容器是否是相同类型容器的元函数
template<template<typename >class Container1,
         template<typename >class Container2>
struct is_same_container_type
{
    static constexpr bool value = std::is_same_v<Container1<int>, Container2<int>>;
};

// 判断类型中是否有emplace_back成员的元函数
template<typename, typename = std::void_t<>>
struct has_emplace_back_method : std::false_type{  };

template<typename T>
struct has_emplace_back_method<T, std::void_t<decltype( std::declval<T>.emplace_back() )>> : std::true_type{  };

// # 改进后的接口实现2
template<template<typename >class Container1, typename T1,
         template<typename >class Container2, typename T2>
std::enable_if_t<is_container_type<Container1<T1>, Container2<T2>>::value && is_same_container_type<Container1, Container2>::value, Container1<decltype( std::declval<T1>() + std::declval<T2>() )>> 
add( Container1<T1> a, Container2<T2> b )// 
{
    Container1<decltype( std::declval<T1>() + std::declval<T2>() )> ret;
    auto a_iter = a.begin(), b_iter = b.begin();
    for ( ; a_iter != a.end(), b_iter != b.end(); a_iter ++, b_iter ++ ) {
        // 编译期if,由于我们事先已经约束了Container1和Container2是同型容器,因此只要判断一个就行
        if constexpr ( has_emplace_back_method<Container1<T1>>::value ) ret.emplace_back( *a_iter + *b_iter );
        else ret.insert( *a_iter + *b_iter );
    }
    if ( a_iter != a.end() ) { // 处理剩下的数据
        for ( ; a_iter != a.end(); a_iter ++ ) {
            if constexpr ( has_emplace_back_method<Container1<T1>>::value ) ret.emplace_back( *a_iter );
            else ret.insert( *a_iter );
        }
    }
    if ( b_iter != b.end() ) { // 处理剩下的数据
        for ( ; b_iter != b.end(); b_iter ++ ) {
            if constexpr ( has_emplace_back_method<Container1<T1>>::value ) ret.emplace_back( *b_iter );
            else ret.insert( *b_iter );
        }
    }

    return ret;
}

template<int N1, int N2>
struct min_val // 元函数:求两个数中的最小值
{
    static constexpr int value = N1 < N2 ? N1 : N2;
};

// 元函数:求两个数中的最大值
constexpr int max_val( int N1, int N2 ) // 常量表达式,C++14
{
    return N1 > N2 ? N1 : N2;
}

// 针对数组类型的模板特化版本
template<typename T1, int N1,
         typename T2, int N2>
std::enable_if_t<is_add_supported_type<T1, T2>::value, decltype( std::declval<T1>() + std::declval<T2>() )*>
add( T1 (&a)[N1], T2 (&b)[N2] )
{
    static decltype( std::declval<T1>() + std::declval<T2>() ) ret[max_val( N1, N2 )];

    int i = 0;
    for ( ; i < min_val<N1, N2>::value; i ++ ) ret[i] = a[i] + b[i];
    for ( ; i < N1; i ++ ) ret[i] = a[i]; // 处理剩余的元素
    for ( ; i < N2; i ++ ) ret[i] = b[i];

    return ret; // 返回数组退化后的指针
}

#endif

测试:

#include "my_add.h"

struct A
{
    A() {}
    A( const int a_ ) : a( a_ ) {}

    A operator+( const A& rhs ) const
    {
        return A( rhs.a + a );
    }

    int a = 10;
};

template<typename T>
struct B
{
        B() {}
        B( const T b_ ) : b( b_ ) {}

        B operator+( const B rhs ) const // 加法运算符重载1
        {
                return B( rhs.b + b );
        }

        template<typename U>
        auto operator+( const B<U> rhs ) const // 加法运算符重载2 
        {
                return B<decltype( rhs.b + b )>( rhs.b + b ); // 注意这里让编译器去决定加法之后的模板参数类型是什么
        }

        T b = 10;
};

int main()
{
    int a0 = 1; float b0 = 2.1;
    auto c0 = add( a0, b0 );
    std::cout<<c0<<std::endl; // ok, 打印3.1

    A a1( 10 ), b1( 20 );
    auto c1 = add( a1, b1 );
    std::cout<<c1.a<<std::endl; // ok, 打印30

    B<int> a2( 10 ); B<float> b2( 22.2 );
    auto c2 = add( a2, b2 );
    std::cout<<c2.b<<std::endl; // ok, 打印30.2

    std::vector<int> a0_vec = { 1, 2, 3, 4 };
    std::vector<float> b0_vec = { 1, 2, 3 };
    auto c0_vec = add( a0_vec, b0_vec ); // ok, 打印2, 4, 6, 4
    for ( auto& val : c0_vec ) std::cout<<val<<std::endl;

    std::set<int> a0_set = { 1, 2, 3, 4 };
    std::set<float> b0_set = { 1, 2, 3 };
    auto c0_set = add( a0_set, b0_set ); // ok, 打印2, 4, 6, 4
    for ( auto& val : c0_set ) std::cout<<val<<std::endl;

    std::vector<A> a1_vec( 4 );
    std::vector<A> b1_vec( 3 );
    auto c1_vec = add( a1_vec, b1_vec ); // ok

    int a0_array[4] = { 1, 2, 3, 4 };
    float b0_array[3] = { 1, 2, 3 };
    auto c0_array = add( a0_array, b0_array ); // ok, 打印2, 4, 6, 4
    for ( int i = 0; i < 4; i ++ ) std::cout<<c0_array[i]<<std::endl; 

    A a1_array[4];
    A b1_array[3];
    auto c1_array = add( a1_array, b1_array ); // ok
}

以上就是我们为了实现一个范型的add接口所做的全部工作,当然了可以发现template那写的一坨阅读性非常差,所以在C++20版本中添加了新的特性: concept和require,赞美20。


文章作者: JoyTsing
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 JoyTsing !
评论
  目录