基于 SFINAE 的萃取(SFINAE-Based Traits)
SFINAE(Substitution Failure Is Not An Error):替换失败不是错误。该技术在模板参数推断过程中,将构造无效类型和表达式的潜在错误,转换为简单的推断错误,允许重载解析继续在其他待选项中间做选择。cppreference中的解释如下:
在函数模板的重载决议中会应用此规则:当模板形参在替换成显式指定的类型或推导出的类型失败时,从重载集中丢弃这个特化,而非导致编译失败。
SFINAE除了用来避免与函数模板重载的相关为的误,还可以在编译期间用来判断特定类型和表达式的有效性。基于SFINAE的两个主要技术:
- 排除某些重载函数
- 排除某些偏特化
用 SFINAE 排除某些重载函数
通过SFINAE判断一个类型是否可以默认构造,源码如下:
#include <iostream>
#include <string>
#include <type_traits>
template <typename T>
struct has_default_constructible {
private:
template <typename U, typename = decltype(U())>
static char test(void *);
template <typename>
static long test(...);
public:
static constexpr bool value = std::is_same<decltype(test<T>(nullptr)), char>::value;
};
struct A {
A() {}
};
struct B {
B() = default;
};
struct C {
};
struct D {
D() = delete;
};
struct E {
E(int) {}
};
int main(int argc, char **argv) {
std::cout << "has_default_constructible<A>::value = " << (has_default_constructible<A>::value ? "true" : "false") << std::endl;
std::cout << "has_default_constructible<B>::value = " << (has_default_constructible<B>::value ? "true" : "false") << std::endl;
std::cout << "has_default_constructible<C>::value = " << (has_default_constructible<C>::value ? "true" : "false") << std::endl;
std::cout << "has_default_constructible<D>::value = " << (has_default_constructible<D>::value ? "true" : "false") << std::endl;
std::cout << "has_default_constructible<E>::value = " << (has_default_constructible<E>::value ? "true" : "false") << std::endl;
return 0;
}
通过函数重载实现一个基于 SFINAE 的萃取的常规方式是声明两个返回值类型不同的同名 (test())重载函数模板 :第一个函数只有在检查成功时才会被匹配到;第二个函数在任何情况下都会被匹配到,但其被匹配的优先级是最低的。
为什么拥有默认构造函数的类型能够匹配到第一个函数模板,反之则不能?因为匹配第一个函数模板需要两个模板实参:一个有名模板参数U和一个匿名模板参数(使用有名模板,例如,写作template <typename U, typename V = decltype(U())>也无妨),如果U拥有默认构造函数,就能通过decltype(U())推断出一个新的类型,相当于两个模板参数,这样就成功的匹配到了第一个函数模板。
test声明为什么不直接使用模板参数T?因为对于任意的 T,所有模板参数为 T 的成员函数都会被执行模板参数替换,因此对一个不可以默认构造的类型,这些代码会遇到编译错误,而不是忽略掉第一 个 test()。
用 SFINAE 排除偏特化
使用偏特化(偏特化介绍文章《C++ 模板特化与偏特化》)实现是否具有默认构造函数的判断,源码如下:
template <typename ...>
using void_t = void;
template <typename, typename = void_t<>>
struct has_default_constructible : std::false_type {};
template <typename T>
struct has_default_constructible<T, void_t<decltype(T())>> : std::true_type {};
主模板使用两个模板参数,一个匿名参数,一个默认匿名参数void_t,void_t是策略成功的关键。很明显,无论类型是否拥有默认构造函数,都可以匹配到主模板。对于第二个模板,decltype(T())起到检测T是否拥有默认构造函数的作用,如果T没有默认构造函数,decltype(T())无法推断出一个新类型,SFINAE就会丢弃该特化,最终使用主模板。
将泛型 Lambdas 用于 SFINAE
上述无论使用哪一种技术,在定义萃取的时候总是需要用到一些样板代码:重载并调用两个 test() 成员函数,或者实现多个偏特化。接下来我们会展示在 C++17 中,如何通过指定一个泛型 lambda 来做条件测试,将样板代码的数量最小化。源码如下:
template <typename F, typename ...Args, typename = decltype(std::declval<F>() (std::declval<Args &&>()...))>
std::true_type is_valid_impl(void *);
template <typename F, typename ...Args>
std::false_type is_valid_impl(...);
inline constexpr
auto is_valid = [](auto f) {
return [](auto &&...args) {
return decltype( is_valid_impl<decltype(f), decltype(args)&&...> (nullptr)) {};
};
};
template <typename T>
struct type_t { using type = T; };
template <typename T>
constexpr auto type = type_t<T>{};
template <typename T>
T value_t(type_t<T>);
constexpr auto has_default_constructible = is_valid( [](auto x) -> decltype((void) decltype(value_t(x)) () ) {} );
//...
struct A {
A() {}
};
struct E {
E(int) {}
};
//...
std::cout << "has_default_constructible<A>::value = " << (has_default_constructible(type<A>) ? "true" : "false") << std::endl;
std::cout << "has_default_constructible<E>::value = " << (has_default_constructible(type<E>) ? "true" : "false") << std::endl;
上面的源码很复杂,需要我们一点点慢慢解析。
is_valid_impl声明
第一个is_valid_impl声明只有在匿名模板参数推导成功后才会被匹配到,匿名模板参数decltype(std::declval<F>() (std::declval<Args &&>()...))比较复杂,其作用是推导F(Args...)是否是一个合法调用,如果是则检测成功,否则检测失败。第二个is_valid_impl声明在任何情况下都会被匹配到,但其被匹配的优先级是最低的。
is_valid实现
is_valid是一个lambda表达式,该lambda表达式的返回值仍然是一个lambda表达式,返回拉lambda表达式则是对is_valid_impl的调用。注意,此处仅仅是使用lambda表达式对is_valid赋值,lambda表达是并没有执行。
has_default_constructible实现
has_default_constructible是is_valid的执行结果,仍然为一个lambda表达式,其展开后代码如下:
[](auto &&...args) {
return decltype( is_valid_impl<decltype([](auto x) -> decltype((void) decltype(value_t(x)) () ) {}), decltype(args)&&...> (nullptr)) {};
}
is_valid_impl的第一个模板参数为一个函数,是在is_valid执行,即对has_default_constructible赋值时传入,其定义为[](auto x) -> decltype((void) decltype(value_t(x)) () ) {}。此时is_valid_impl的第二个模板参数还没传入。
辅助类型
type_t为一个结构体,用于记录类型T;value_t为一个函数声明,返回type_t记录的类型T。这样做,就可以使得类型向变量一样,可以作为参数传递。
has_default_constructible调用
is_valid_impl的两个重载版本,前两个模板参数一致:第一个在对has_default_constructible赋值时由is_valid传入;第二个在has_default_constructible执行时传入。第一个重载版本还有一个匿名模板参数,需要进行推导,推导表达式decltype(std::declval<F>() (std::declval<Args &&>()...)),std::declval<F>() 根据传入的函数类型生成一个函数对象,std::declval<Args &&>()...生成传给函数。
在实例中,函数原型为[](auto x) -> decltype((void) decltype(value_t(x)) () ),...Args为type<A>,即x的类型为type<A>,value_t(x)推导结果为A,因为A拥有默认构造函数,所以A()是一个合法的表达式,推导成功,匹配第一个is_valid_impl。当...Args为type<E>,由于E没有默认构造函数,E()非法,推导失败,匹配第二个is_valid_impl。
这种技术通用性比较好,萃取模板仅需实现一次,使用时仅需实现一个类似has_default_constructible的lambda表达式,下面是两个例子:
//...
constexpr auto has_mem_first = is_valid( [](auto x) -> decltype( value_t(x).first ) {} );
constexpr auto has_mem_reserve = is_valid( [](auto x, auto s) -> decltype((void) decltype(value_t(x).reserve(s)) () ) {} );
//...
std::cout << "has_mem_first<int> = " << (has_mem_first(type<int>) ? "true" : "false") << std::endl;
std::cout << "has_mem_first<std::pair<int, int>> = " << (has_mem_first(type<std::pair<int, int>>) ? "true" : "false") << std::endl;
std::cout << "has_mem_reserve<std::vector<int>> = " << (has_mem_reserve(type<std::vector<int>>, 0) ? "true" : "false") << std::endl;
std::cout << "has_mem_reserve<std::list<int>> = " << (has_mem_reserve(type<std::list<int>>, 0) ? "true" : "false") << std::endl;
SFINAE友好的萃取
在萃取的实现(一)返回值萃取一节中,为了得到两个变量相加的返回值,使用了如下的萃取代码:
template <typename T1, typename T2>
struct plus_result { using type = decltype(std::declval<T1>() + std::declval<T2>()); };
但现实中,很多类型是不支持“+”操作符的。例如,新增一个point类型,完成代码如下:
#include <string>
#include <vector>
#include <iostream>
#include <type_traits>
template <typename T>
struct point {
point(T _x, T _y) : x(_x), y(_y) {}
T x;
T y;
};
template <typename T1, typename T2>
struct plus_result { using type = decltype(std::declval<T1>() + std::declval<T2>()); };
template <typename T1, typename T2>
using plus_result_t = typename plus_result<T1, T2>::type;
template <typename T1, typename T2>
std::vector<plus_result_t<T1, T2>> operator+(const std::vector<T1> &lhs, const std::vector<T2> &rhs) {
size_t min = std::min(lhs.size(), rhs.size());
size_t max = std::max(lhs.size(), rhs.size());
size_t i = 0;
std::vector<plus_result_t<T1, T2>> result;
for (; i < min; ++i)
result.push_back(lhs[i]+rhs[i]);
if (lhs.size() == max) {
while (i < max)
result.push_back(lhs[i++]);
}
if (rhs.size() == max) {
while (i < max)
result.push_back(rhs[i++]);
}
return result;
}
int main(int argc, char **argv) {
std::vector<point<int>> v7 {{1, 1}, {3, 3}, {5, 5}};
std::vector<point<int>> v8 {{2, 2}, {4, 4}, {5, 5}};
auto v9 = v7 + v8;
for (auto p : v9)
std::cout << p.x << "," << p.y << ";";
std::cout << std::endl;
return 0;
}
很明显,新增的point类型不支持“+”操作符,上述代码是无法通过编译的,报错如下:
错误信息很多,但很难快速的定位问题。返回值的萃取策略是没有问题的,但红框中的错误信息会误导我们。因此,代码需要对支持“+”操作符的类型和不支持“+”操作符的类型进行区分,不支持“+”操作符的类型,没有返回类型。新的萃取策略如下:
template <typename T1, typename T2, typename = std::void_t<>>
struct has_plus_t : std::false_type {};
template <typename T1, typename T2>
struct has_plus_t<T1, T2, std::void_t<decltype(std::declval<T1>() + std::declval<T2>())>> : std::true_type {};
template <typename T1, typename T2, bool = has_plus_t<T1, T2>::value>
struct plus_result { using type = decltype(std::declval<T1>() + std::declval<T2>()); };
template <typename T1, typename T2>
struct plus_result<T1, T2, false> {};
新的错误信息很简洁,更有利于我们快速的定位为题,如下:
IsConvertible
在某些情况下,需要判断一种类型是否可以转换为另一种类型,如char *是否可以转换为std::string。通过萃取,可以很简单的实现这种判断,源码如下:
template <typename FROM, typename TO>
struct is_convertible_helper {
private:
static void aux(TO);
template <typename F, typename T, typename = decltype(aux(std::declval<F>()))>
static std::true_type test(void *);
template <typename, typename>
static std::false_type test(...);
public:
using type = decltype(test<FROM, TO>(nullptr));
};
template <typename FROM, typename TO>
struct is_convertible_t : public is_convertible_helper<FROM, TO>::type {
};
//warning: variable templates are a C++14 extension [-Wc++14-extensions]
template <typename FROM, typename TO>
constexpr bool is_convertible = is_convertible_t<FROM, TO>::value;
//...
std::cout << is_convertible<char *, std::string> << std::endl;
与是否具有默认构造函数的判断相似,类型转换可行性的判断也是通过工具类的两个test函数实现的。只有类型FROM可以转换成类型TO,第一个test函数才会匹配成功;在任何情况下第二个test函数都会匹配成功,但其匹配优先级也是最低的。第一个test函数如何判断类型FROM是否能够转换为类型TO?答案是模板列表中的匿名模板参数。该模板参数通过调用辅助函数aux,对转换的可能性进行判断。aux的形参类型为TO,匿名模板参数调用aux时,传入的参数类型为FROM,如果FROM可以转换为TO,则调用成功,匹配到第一个test函数,反之,则匹配失败。
但上面的方案,还不能有效的处理下面三种情况:
- 向数组类型的转换要始终返回false(即TO为数组类型的情况),如果TO为数组类型,如int[],其会退化成int*,因此在计算时is_convertible_t<int *, int []>::value会返回true,很明显这是不合理的,所以需要禁止该种情况
- 向指针类型的转换也应该始终返回false(即TO为指针类型的情况),原因同上
- void与void之间的转换应当始终返回true,如is_convertible_t<void, void>::value应当返回true,但当TO为void时,上述实现都不能被正常实例化,因为“argument may not have 'void' type”。
为了有效处理上述三种情况,需要为is_convertible_helper主模板实现增加了一个匿名模板参数,该参数用以判断TO是否为void,数组或指针,偏特化模板直接将该模板参数赋值为false。如果TO为void,数组或指针,直接匹配主模板;否则命中偏特化模板。源码如下:
template <typename FROM, typename TO, bool = std::is_void<TO>::value || std::is_array<TO>::value || std::is_pointer<TO>::value>
struct is_convertible_helper {
using type = std::integral_constant<bool, std::is_void<FROM>::value && std::is_void<TO>::value>;
};
template <typename FROM, typename TO>
struct is_convertible_helper<FROM, TO, false> {
private:
static void aux(TO);
template <typename F, typename T, typename = decltype(aux(std::declval<F>()))>
static std::true_type test(void *);
template <typename, typename>
static std::false_type test(...);
public:
using type = decltype(test<FROM, TO>(nullptr));
};