STL入门,Type Traits(类型萃取)

182 阅读16分钟

对模板元编程感兴趣的看下面这本,超级有帮助.下面是个github翻译链接.更加详细的看原版书籍
C++ Templates2

写模板代码还是用clang或者gcc编译器.微软的msvc如果报错,没有前面两个编译器准确清晰.再加个clangd插件,有时候写代码的时候就报错了,这个vs没有那么智能

Traits(萃取): 一个为物体所持有的属性
Policies(策略): 任何被作为有益因素或权宜之计而采取的行动

我对Traits(萃取)的理解

  • 通过名称就知道他是获取某个类型的特性.
    主要就是通过模板特化来实现,我们为不同类型提供不同的实现,在实现中定义我们想要的特征(比如这个类型的初始值,这个类型指针类型的别名,等等).最后传入具体的参数让编译去匹配具体的类型,我们就可以获取这些特征来做更强大的事情。
  • 萃取可以做到很多事情,经典的就是STL的iterator迭代器.

1. 类型特征判断: 标准库type_traits文件的is_reference这些 image.png 2.条件分发: 通过第一条,可以在编译期间选择性的分发到不同的实现.比如对于整形,我们希望使用更高效的排序算法.
主要就是通过上述两条实现很多功能

SFINAE技术

  • SFINAE读作[sfɪˈneɪ](全称为Substitution Failure Is Not An Error,替换失败不是错误).
  • 可以用来排除掉某些重载函数,以及排除某些偏特化.最熟悉的就是标准库thread的构造函数.
    下面这幅图的作用就是当传入参数如果不是另一个thread对象才会匹配该函数.这样就确保传入thread对象参数则调用拷贝构造或移动构造. image.png

1.1 开胃小菜(Traits的应用)

template <typename T>
T sum(T const* beg, T const* end)
{
    T total{}; // 值初始化
    while (beg != end)
    {
        total += *beg;
        ++beg;
    }
    return total;
}

int main()
{
    int nums[] = {1, 2, 3};
    char str[] = "abc";
    printf("nums: %d\n", sum(nums, nums + 3));  // 想要的结果为6
    printf("str: %d\n", sum(str, str + 2));    // a(97) + b(98),结果应该为195
    return 0;
}

对于上述结果,字符串中两个字符'a'和'b'的ASCII加起来为195,但是输出结果为-6.
问题在于对于str模板是被char实例化的,char类型的数值范围存储不了195数值.

解决上述问题就是我们定义具体类型的偏特化类模板,然后在类模板中定义一个类型.

template <typename T>
struct SumTraits;

template <>
struct SumTraits<char> {
	using RType = int;
};

template <>
struct SumTraits<short> {
	using RType = int;
};

template <>
struct SumTraits<float> {
	using RType = double;
};

template <>
struct SumTraits<int> {
	using RType = int;
};

我们为每个具体的类型,都定义了一个别名RType.在char类型特化模板中,RType为int类型,也就是返回类型为int类型.
然后需要将sum模板函数修改一下,使用上面的模板类中定义的别名成员

template <typename T>
auto sum(T const* beg, T const* end)
{
	using RType = typename SumTraits<T>::RType;
	RType total{};
	while (beg != end)
	{
		total += *beg;
		++beg;
	}
	return total;
}

修改处为第2行的auto和第4行第5行的RType
将返回类型改为auto,让编译器去推断函数返回的是什么类型.
第4行SumTraits编译器会根据T的类型匹配适当的偏特化模板类,然后我们定义一个具体的别名作为返回类型.
这样,当T为char类型时返回的就是int类型,能够存储累积和的值了

1.1.1 值萃取

还是上面的例子,sum模板函数中使用了 RType total{} 值初始化,这种可能并不会生成一个合适的初始值.比如说复合类型

struct A
{
    A(int data) {}
    // .. 省略一些重载运算符实现代码
}

构造对象A比如传入一个初始值才可以构造.解决这个问题,我们可以在特化类模板中,定义一个静态初始值或者是静态函数返回一个初始值.

template <>
struct SumTraits<A>
{
    using RType = A;
    static RType zero()	// 定义一个静态函数
    {
        return RType{ 0 };
    }
};

sum模板函数修改如下:

template <typename T>
auto sum(T const* beg, T const* end)
{
	using RType = typename SumTraits<T>::RType;
	RType total = SumTraits<T>::zero();	// 调用静态函数给定一个初始值
	while (beg != end)
	{
		total += *beg;
		++beg;
	}
	return total;
}

也可以定义为静态变量,有两种做法.C++17和之前版本的做法.

// 第一种做法: C++17之前的做法
template <>
struct SumTraits<A>
{
	using RType = A;
	static const A zero;	// 在类中声明
};
// 类外部进行定义
A const SumTraits<A>::zero = A{ 0 };



// 第二种做法: C++17定义静态变量的用法
template <>
struct SumTraits<A>
{
    using RType = A;
    inline static RType const zero = A{ 0 };	// 在C++17中可以直接这样使用
};

1.1.2 参数萃取

我们可能对short类型的sum计算,觉得返回float类型要比int性能要高.可以指定一个新的模板参数,默认值由萃取模板决定.

template <typename T, typename ST = SumTraits<T>>
auto sum(T const* beg, T const* end)
{
    typename ST::RType total = ST::zero();
    while (beg != end)
    {
        total += *beg;
        ++beg;
    }
    return total;
}

用户可以传入自定义的类型作为第二个参数.


1.2 类型函数

1.2.1 转换萃取

萃取还可以用来做类型转换,比如为某个类型添加或移除引用、const以及volatile限制符

删除引用 实现一个RemoveRefT萃取,将引用类型转换成其底层对象或者函数的类型,非引用类型保持不变

template <typename T>
struct RemoveRefT {
    using Type = T;
};

template <typename T>
struct RemoveRefT<T&> { 
    using Type = T;
};

template <typename T>
struct RemoveRefT<T&&> {
    using Type = T;
};

// 定义一个别名方便使用
template <typename T>
using RemoveRef = typename RemoveRefT<T>::Type;

添加引用
将已有类型添加左值或者右值引用:

template <typename T>
struct AddLRefT {
    using Type = T&;
};

template <typename T>
using AddLRef = typename AddLRefT<T>::Type;

template <typename T>
struct AddRRefT {
    using Type = T&&;
};

template <typename T>
using AddRRef = typename AddRRefT<T>::Type;

引用折叠规则依然适用.比如对AddLRef<int&&>, 返回的类型是int&,因此不需要进行偏特化实现. 如果只实现AddLRefT和AddRRefT,又不需要进行偏特化,最方便的别名模板简化成下面这样

template <typename T>
using AddLRefT = T&;

template <typename T>
using AddRRefT = T&&;

但是上述简化实现,不能将其用于void类型.一些显式的特化实现可以用来这些情况:

// 主模板
template <typename T>
struct AddLRefT {};

template <>
struct AddLRefT<void> {
    using Type = void;
};

template <>
struct AddLRefT<void const> {
    using TYpe = void const;
};

template <>
struct AddLRefT<void volatile> {
    using Type = void volatile;
};

template <>
struct AddLRefT<void const volatile> {
    using Type = void const volatile;
};

AddRRefT的情况与上述实现相同.
C++标准库也提供了与之相对应的类型萃取: std::add_lvalue_reference<>和std::add_rvalue_reference<>

移除限制符 x转换萃取可以分解或者引入任意种类的复合类型,并不仅限于引用.比如,如果一个类型中存在const限制符,可以将其移除:

template <typename T>
struct RemoveConstT {
    using Type = T;
};

template <typename T>
struct RemoveConstT<T const> {
    using Type = T;
};

template <typename T>
using RemoveConst = typename RemoveConstT<T>::Type;

看自己搭配,比如创建一个用来移除const和volatile的RemoveCVT萃取:

template <typename T>
struct RemoveCVT : RemoveConstT<typename RemoveVolatileT<T>::Type> {};

template <typename T>
using RemoveCV = typename RemoveCVT<T>::Type;

RemoveCVT没有定义任何成员,而是通过元函数转发从RemoveConstT继承了Type成员.元函数转发可以用来简答的减少RemoveCVT中的类型成员.

退化(Decay,msvc库看到用的比较多) 下面实现一个按值传递参数时的类型转换行为的萃取.参数类型发生退化(数组类型退化成指针类型,函数类型退化成指向函数的指针类型),而且会删除相应的顶层const、volatile以及引用限制符(因为在解析一个函数调用时,会忽略掉参数类型的顶层限制符)

下面程序展现了按值传递的效果,会打印出经过编译器退化之后的参数类型:

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

template <typename A>
void printParamType(void (*)(A))
{
    cout << "param type: " << typeid(A).name() << "\n";
    cout << "- is int: " << std::is_same<A, int>::value << "\n";
    cout << "- is const: " << std::is_const<A>::value << "\n";
    cout << "- is pointer: " << std::is_pointer<A>::value << "\n\n";
}


int main()
{
    printParamType(&f<int>);
    printParamType(&f<int const>);
    printParamType(&f<int[7]>);
    printParamType(&f<int(int)>);

    return 0;
}

输出结果:

image.png

实现功能和C++标准库的std::decay相同的,取名为DecayT

首先继承RemoveCVT,通过元函数转发,删除const和volatile限制符

template <typename T>
struct DecayT : RemoveCVT<T> {};

然后处理数组到指针的退化,需要用偏特化来处所有的数组类型(有界数组和无界数组)

template <typename T>
struct DecayT {};

template <typename T>
struct DecayT<T[]> {
    using Type = T*;
};

template <typename T, std::size_t N>
struct DecayT<T[N]> {
    using Type = T*;
};

最后处理函数到指针的退化,这需要应对所有的函数类型,不管是什么返回类型以及有多少参数.所以使用变参模板

template <typename R, typename... Args>
struct DecayT<R(Args...)> {
    using Type = R(*)(Args...);
};

template <typename R, typename... Args>
struct DecayT<R(Args..., ...)> {
    using Type = R(*)(Args..., ...);
};

第二个偏特化可以匹配任意使用了C-style可变参数的函数.

template <typename T>
void printDecayType()
{
    using A = typename DecayT<T>::Type;
    cout << "param type: " << typeid(A).name() << "\n";
    cout << "- is int: " << std::is_same<A, int>::value << "\n";
    cout << "- is const : " << std::is_const<A>::value << "\n";
    cout << "- is pointer: " << std::is_pointer<A>::value << "\n";
}

int main()
{
    printDecayType<int(int, float)>();
    printDecayType<int[7]>();
    
    return 0;
}

输出结果:

image.png

1.2.2 预测型萃取

通常而言,可以设计基于多个参数的类型函数.引出另外一个特殊的类型萃取 --- 类型萃取(产生bool数值的类型函数)

IsSameT

template <typename T1, typename T2>
struct IsSameT {
    static constexpr bool value = false;
};

// 主模板
template <typename T>
struct IsSameT<T, T> { 
    static constexpr bool value = true;
};

像下面使用用来判断传递进来的模板参数是否是整型:

if (IsSameT<T, int>::value) ...

1.2.3 返回结果类型萃取

另一个可以被用来处理多个类型的例子是返回值类型萃取.在编写操作符模板的时候很有用.为了引出这一概念,可以对两个vector容器求和的函数模板

template <typename T>
vector<T> operator+(vector<T> const&, vector<T> const&);

如果我们像对char和int类型求和,自然也希望能够对vector执行这种混合类型操作.这样就要处理该如何决定模板返回值的问题

template <typename T1, typename T2>
vector<???> operator+(vector<T1> const&, vector<T2> const&>;

定义一个PlusResultT

template <typename T1, typename T2>
struct PlusResultT {
    using Type = decltype(T1() + T2());
};

// 定义别名
template <typename T1, typename T2>
using PlusResult = typename PlusResultT<T1, T2>::Type;

通过使用decltype计算T1()+T2()类型,将决定结果类型交给编译器. 但是decltype却保留过多的信息.我们希望去掉一些const获取引用类型.

template <typename T1, typename T2>
vector<RemoveCV<RemoveReference<PlusResult<T1, T2>>>>
operator+(vector<T1> const&, vector<T2> const&);

这样的嵌套形式在模板库非常常见,元编程中也经常用到.

declval
好在我们可以很简单的不需要构造函数的情况下计算 + 表达式的值,方法就是使用一个可以为一个给定类型T生成数值的函数.为了这一目的,C++标准库提供了 std::declval<>.在<utility>中定义如下:

namespace std {
    template <typename T>
    add_rvalue_reference_t<T> declval() noexcept;
}

表达式declval<>可以在不需要使用默认构造函数(或者其他任意操作)情况下为类型T生成一个值 该函数模板被故意设计成未定义的状态,因为我们只希望它被用于decltype,sizeof或者其他不需要相关定义的上下文中.它有两个很有意思的属性:

  • 对于可引用的类型,返回类型总是相关类型的右值引用,这能够使declval适用于那些不能够正常从函数返回的类型,比如抽象类的类型(包含纯虚函数的类型)或者数组类型.因此当被用作表达式时,从类型T到T&&的转换对declval<T>()的行为是没有影响的: 其结果都是右值(如果T是对象类型的话),对于右值引用,其结果之所以不会变是因为存在引用塌缩.
  • 在noexcept异常规则中提到,一个表达式不会因为使用了declval而被认成是会抛出异常的.当 declval被用在noexcept运算符上下文中时,这一特性会很有帮助.

有了declval,我们就可以不用在PlusResultT中使用值初始化了:

template <typename T1, typename T2>
struct PlusResultT {
    using Type = decltype(std::declval<T1>() + std::declval<T2>());
};

template <typename T1, typename T2>
using PlusResult = typename PlusResultT<T1, T2>::Type;

返回值类型萃取提供了一种从特定操作中获取准确的返回值类型的方式,在确定函数模板的返回值的类型的时候,它会很有用.


1.3 SFINAE(替换失败不是错误)

1.3.1 详细介绍SFINAE技术

在C++中,编译器遇到一个重载函数的调用时,它必须分别考虑每一个重载版本,以选择其中类型最匹配的那一个.

简单的来讲,就是有多个重载函数,其中函数模板.编译器在编译期间需要决定将什么样的模板参数用于各种模板方案,然后用这些 参数替换函数模板的参数列表和返回类型(不会替换函数体内容) 后评估替换的函数模板和调用的匹配情况.这中间就会有匹配失败的情况,如果失败不会导致编译器错误,而是简单的将失败的候选项排除在外.

核心概念就是: 编译器尝试进行模板参数的推导和替换时,如果出现错误,不会中断整个编译过程,而是继续尝试其他侯选项.这使得在模板编程中可以更加的灵活处理各种条件和类型,而不会因为一个候选项的失败导致整体失败.

给个例子解释一下:

template <typename T, unsigned N>
std::size_t len(T(&)[N])
{
    return N;
}

template <typename T>
typename T::size_type len(T const& t)
{
    return t.size(); 
}

当传递的参数是数组或者字符串常量时,只有第一个匹配契合.但是从第二个函数签名来看,也就可以将int[10]或char const[4]替换类型参数T.但是这种替换在处理返回类型T::size_type时会导致错误.编译器到这里匹配失败,则去考虑其他情况,而不会终止整个匹配.

如果传递的是指针的话,以上两个模板都不会匹配.此时编译器就报错没有合适的len函数了.

int* p = nullptr;
cout << len(p) << endl;    // 没有与参数列表匹配的 重载函数 "len" 实例

又如果传递一个有size_type成员但是没有size()成员函数的情况.比如传递的是参数是std::allocator<>

std::allocator<int> alloc;
std::cout << len(alloc);    // "size": 不是 "std::allocator<int>" 的成员

此时编译器匹配第二个函数模板.因此不会报错没有匹配的len函数,而是报错一个编译器错误size()不是成员.第二个模板函数不会被忽略掉.

如果忽略掉那些替换之后都匹配失败的选项,那么编译器会选择另外一个匹配较差的备选项.比如:

template <typename T, unsigned N>
std::size_t len(T(&)[N])
{
    return N;
}

template <typename T>
typename T::size_type len(T const& t)
{
    return t.size();
}

// 提供一个匹配最差的情况
std::size_t len(...)
{
    return 0;
}

对于指针,只有最后一种情况才能够匹配上,此时编译器不会报错缺少使用的len()函数.但是对于std::allocator<int>的调用,虽然第二个和第三个都能匹配,但第二个函数还是最佳匹配.因此编译器依然报错缺少size()成员函数.

1.3.2 基于SFINAE的萃取

排除掉某些重载函数

首先定义IsSameT萃取模板,判断两个类型是否相同

// 主模板
template <typename T, typename U>
struct IsSameT {
    static const bool value = false;
};

// 部分特化:当T和U相等时
template <typename T>
struct IsSameT<T, T> {
    static const bool value = true;
};

接下来是我们要演示如何排除掉某些重载函数的模板

template <typename T>
struct IsDefConstructT
{
private:
    template <typename U, typename = decltype(U())>
    static char test(void*) { return 'a'; }
    
    // 主模板
    template <typename>
    static long test(...) { return 1; }
public:
    static constexpr bool value = 
        IsSameT<decltype(test<T>(nullptr)), char>::value;
};

首先定义两个成员函数test,返回类型不同,第一个返回char类型,第二个返回long类型.而且第一个test()函数使用了 U类型 构造函数.这也就是U类型有默认构造,才能匹配第一个test()函数.如果失败,那么就匹配第二个test()函数.
下图,给IsSameT模板类传入两个类型.第一个类型使用decltype获取 test<T>(nullptr) 的返回类型是什么. image.png

第一个test成员函数就通过模板参数是否能构造对象来排除掉这个重载函数.上述只是提供一种思路,可以用多种方式让编译器编译期间匹配失败来达到排除掉当前选项.

1.3.3 用SFINAE排除偏特化

上面是在模板参数中定义一个类型别名,使用decltype来判断U类型是否能有默认构造来排除.下面这个只是替换成了偏特化来实现相同的功能.

先定义一个辅助别名模板: VoidT

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

实现一下标准库的std::false_type和std::true_type

template <class T, T val>
struct IntConst 
{
    static constexpr T value = val;
    // ... 省略掉一些成员函数,上面的静态成员就够用了
};

template <bool val>
using B_Const = IntConst<bool, val>;   

using True_Type = B_Const<true>;
using False_type = B_Const<false>;

定义主模板IsDefConstructT,并在模板参数中使用辅助别名模板VoidT作为偏特化

template <typename, typename = VoidT<>>
struct IsDefConstructT : False_Type {};

定义另一个版本重载函数,判断是否能有默认构造函数的

template <typename T>
struct IsDefConstructT<T, VoidT<decltype(T())>> : True_Type {};

方法和1.2.3一样差不多.如果类型T,默认构造函数是无效的,SFINAE就使第二个偏特化丢弃掉,最终使用主模板.否则该偏特化有效,并且会被选用.

从C++14开始,标准委员会通过预先达成一致的特征宏来标识那些标准库的内容以及被实现了.比如 __cpp_lib_void_t 就是被建议用来标识在一个库中是否实现了上面 辅助别名模板 Void_t.

int main()
{
	
#ifdef __cpp_lib_void_t
    cout << "定义了" << endl;
#endif
    return 0;
}

1.3.4 IsConvertibleT

定义一个能够判断一种类型是否可以被转换成另外一种类型的萃取,比如当我们期望某个基类或者其某一个子类作为参数的时候.IsConvertibleT就可以判断第一个类型参数是否可以被转换成第二个类型参数:
运用的都是前面学到的知识

template <typename FROM, typename TO>
struct IsConvertibleHelper {
private:
    static void aux(TO);	// 声明静态函数,参数为TO类型

    // 第三个模板参数, 如果std::declval<F>()解析的类型能够被aux静态函数接收,
    // 那么代表这两个类型可以互相转换.否则,丢弃这一选项,编译器选择第二个test函数
    template <typename F, typename = decltype(aux(std::declval<F>()))>
    static std::true_type test(void*);

    template <typename>    // 备选选项
    static std::false_type test(...);
public:
    using Type = decltype(test<FROM>(nullptr));
};

定义别名模板

template <typename FROM, typename TO>
struct IsConvertibleT : IsConvertibleHelper<FROM, TO>::Type {};

template <typename FROM, typename TO>
using IsConvertible = typename IsConvertibleT<FROM, TO>::Type;

template <typename FROM, typename TO>
constexpr bool isConvertible = IsConvertibleT<FROM, TO>::value;

如何使用:

struct A {};
struct B : A {};

int main()
{
    cout << isConvertible<int, long> << endl;
    cout << isConvertible<B*, A*> << endl;
    cout << isConvertible<A*, B*> << endl;	

    return 0;
}

注意: 下面这种声明不可以

static void aux(TO);

template <typename = decltype(aux(std::declval<FROM>()))>
static char test(void*);

这样当成员函数模板被解析的时候,FROM和TO都已经完全确定了,因此对一组不适合做相应转换的类型,在调用test()之前就会立即触发错误.

由于这一原因,引入了成员函数模板参数的F:

static void aux(TO);

template <typename F, typename = decltype(aux(std::declval<F>()))>
static char test(void*);