深入浅出C++模板元编程(1)

读者阅读本文可能需要的前置知识:类型别名、模板别名、模板基本语法、模板实例化。

前言

  1. 模板元编程把程序当成数据操作,我们可以通过模板元编程生成代码。比如,std::vector是一个类模板,如果要使用std::vector<int>这个类,则编译器会根据前者生成后者的代码。

  2. 模板元编程的代码在编译时进行运算,而非运行时。我们可以利用这一特性在把一部分运行时计算挪到编译时进行,提高运行时效率。其中,文中会给出编译时计算阶乘的例子,这是直接的、普通的数值运算;除此之外,模板还能实现静态多态。

  3. 模板元编程具有不变性(immutability),它没有副作用(side effects)。在很长一段时间里,C++的核心语言对模板元编程的支持不足,导致其编程语法的晦涩。然而,由于编译时运算的“变量”都是常量,除去语法的晦涩,不变性使得模板元编程的代码能做到“所见即所得”。

下面我们通过两个标准库的类型萃取(type traits)元函数为例来说明以上几点。在C++中,模板元编程通常以元函数的形式进行。下面的例子展示了元函数std::is_copy_constructiblestd::is_move_constructible的使用:

using uptr_int = std::unique_ptr<int>;

constexpr bool copyable = std::is_copy_constructible<uptr_int>::value;
constexpr bool movable  = std::is_move_constructible<uptr_int>::value;

static_assert(!copyable);
static_assert(movable);

两个bool常量copyablemovable的值分别是falsetrue,因为std::unique_ptr是不能复制而只能移动构造的。进一步地,我们可以利用类型萃取来做类型限制,以下这个函数声明限定其参数必须能够移动构造:

template <typename T>
std::enable_if<std::is_move_constructible<T>>
accept_move_only(T t);

编写第一个元函数:identity

本文将从最简单的模板元函数开始,一点点构建元编程的大楼。元函数通常以struct的形式实现,并把结果存放在其公开的静态常量数据成员(通常命名为value)或成员类型(通常命名为type)中。下面,我们从类型和值的恒等函数写起:

template <typename T>
struct type_identity {
    using type = T;
};

template <typename T, T v> 
struct value_identity : type_identity<T> {
    static constexpr T value = v;
};

可见,元函数的struct对应着一般意义的函数,模板参数对应着函数参数,而成员类型和数据成员对应着函数的返回值,而继承另一个元函数结构体可以对应为一个函数调用——value_identity通过公有继承type_identity拥有后者的成员类型type

对于一些常见的基本常量,比如bool类的真、假两个取值,可以定义:

using true_type  = value_identity<bool, true>;
using false_type = value_identity<bool, false>;

标准库中的恒等元函数是std::integral_constant

模板特例化的编程idiom:is_same

利用这两个常量类型,我们可以编写一个元函数来比较两个类型是否相同。我们先用非元编程的语法,编写这个函数的伪代码以帮助理解:

bool equals(T1, T2) {
    if (T1 == T2) {
        return true;
    }
    else {
        return false;
    }
}

要实现这个元函数,用到了模板部分特例化partial template specialization)的技巧。通例模板(以下称primary template)有两个类型参数,而特例化的模板(以下称specliazed template)被指定了两个相同的类型参数。

模板元编程的中文译名似乎没有完全一致的叫法。比如partial template specialization,我见过部分特例化、偏特化等译法,而primary template有译作原模板、模板通例(这种译法似乎对应着“特例化”)等。

// Primary template
template <typename T1, typename T2>
struct equals : false_type {};

// Specialized template
template <typename T> // 提供模板参数
struct equals<T, T> : true_type {};

模板特例化(template specialization)指的是根据模板参数自定义模板的代码。如果在特例化模板时,仅“部分地”指定模板参数,就称为模板的部分特例化。语法上,“部分地”可以这样理解:声明特例化模板时template关键字后面的方括号内容非空。

当模板进行实例化时,如果有多个版本可供选择,编译器需要在primary template和specialized template(可能有多个)间进行选择,而编译器会选择更加特化(more specialized)的版本。简单来说,“A比B特化程度更高”可以理解为:假设A接受的类型为集合set_a,B的是set_bset_aset_b的子集”。但仍然存在编译器发现歧义,不知选择哪个特例化模板的编译错误情况。

因此,如果我们编写这样的代码,cout将分别输出0和1(即falsetrue):

cout << equals<int, int const>::value << '\n'; // 0
// struct equals<int, const int> : false_type;

cout << equals<int, int>::value << '\n'; // 1
// struct equals<int, int> : true_type;

模板特例化的技巧可以当作条件语句来使用。当类型参数分别为intconst int时,编译器在模板参数推断(template argument deduction)后,发现这两个参数并不相同,因此选择了primary template;当参数都是int时,编译器会发现提供的参数更加“符合”specialized template,因为它的两个是参数相同的,因此编译器选择specialized template。

标准库中对应的元函数是std::is_same

看似平凡,实则微妙:is_void

在标准库的<type_traits>头文件中,定义了十四种primary type categories,C++中的任意一个类型都属于且仅属于这十四个类型类别中的一个。这十四个类型包括:voidnull_pointer、整型、浮点数、数组、枚举、union、类、函数、指针、左值引用、右值引用、成员对象指针、成员函数指针。

其中is_void看起来是最简单,让我们来实现它。使用上节的继承和部分特例化技巧,很快能写出来:

template <typename T>
struct is_void : false_type {};
template <> #1
struct is_void<void> : true_type {};

然而,constvolatile(统称为cv-qualifiers)也可以用来限定void。所以,所以,当这个is_void的参数为cv-qualified的void(包括void const, void volatile, void const volatile)时,它的value将是false。而根据primary type categories的规则,任意一个类型都要属于十四个类别中的一个,这个naive实现需要包括上述cv-qualified的情况。

那么我们可以接着用模板特例化来处理它们:

template <> #2
struct is_void<void const> : true_type {};
template <> #3
struct is_void<void volatile> : true_type {};
template <> #4
struct is_void<void const volatile> : true_type {};

但是这代码太丑了,没有伸缩性——试想,如果要检查一个类型是否为整型,而C++中整型类型一共有十个,难道要写10*4=40个特例化模板吗?我们需要这样一个元函数:输入一个类型T,返回它去掉顶级cv-qualifiers后的类型。

关于“顶级cv-qualifiers”,考虑这样一个类型:int const * const,它是一个指向常量int的常量指针。其中,*左边的const修饰的是int,而*右边的const修饰的是指针本身。由于这个类型是一个指针类型,因此顶级qualifier便是*右边的const;而左边的修饰指针指向的数据本身,不能去除。

先来实现去除const的元函数:

template <typename T>
struct remove_const : type_identity<T> 
{};

template <typename T>
struct remove_const<T const> : type_identity<T> 
{};

又一次地,使用了模板部分特例化的技巧:如果T本身非const修饰的类型,则选择primary template,并直接返回T(成员类型为T);否则,通过部分特例化为其元函数提供一个T const,那么对于某个const修饰的类型,编译器便可以正确推断出该类型除去const的部分。

举例来说,如果我们实例化remove_const<int const>,编译器会查看remove_const的primary template和specialized template;当查看后者时,进行了如下模板参数推断,并确定后者是更匹配的模板:

template < typename T       -> int> 
struct remove_const<T const -> int const>;

remove_volatile的实现几乎是一样的,只需要把const改成volatile即可。最后,我们把这两个元函数链式调用即可去除一个类型的顶级cv-qualifiers了:

template <typename T> // Helper
using remove_const_t = typename remove_const<T>::type;

template <typename T>
using remove_cv = remove_volatile<remove_const_t<T>>;

template <typename T> // Helper
using remove_cv_t = remove_volatile<remove_const_t<T>>::type;

这里,我们为元函数添加了helper,调用时可以省去::type::value(其他的元函数同理)。其中,由于编译器事先并不知道remove_const的成员type到底是个什么东西(type可能是数据成员),因此用typename关键字告诉编译器把type这个符号当成类型看待。那么现在is_void的实现如下:

template< class T >
struct is_void : equals<void, remove_cv_t<T>>
{};

下面再用remove_cv来编写另一个primary type categories元函数来说明它的用处:判断一个类型是否为浮点(整型同理):

template <typename T1, typename T2>
inline constexpr bool equals_raw_v = equals<remove_cv_t<T1>, remove_cv_t<T2>>::value;

template <typename T>
using is_floating_point = value_identity<bool, 
    equals_raw_v<T, float>
    || equals_raw_v<T, double>
    || equals_raw_v<T, long double>>;

std::is_floating_point

递归

用模板编写循环结构并不方便,但是可以用递归达到类似的效果。下面的例子,能在编译时通过递归计算正整数阶乘,我们把这个元函数命名为factorial。为了帮助对照理解,先给出非元函数的版本:

unsigned factorial(unsigned n) {
    if (n == 0 || n == 1) {
        return n;
    }
    else {
        return n * factorial(n - 1);
    }
}

要编写元函数版本,我们在primary template中定义value成员的值为n * (n-1)!,而在特例化的模板中定义递归的终结和边界条件(n为1和0):

template <unsigned N>
struct factorial {
    static constexpr unsigned value = N * factorial<N - 1>::value;
};
template <>
struct factorial<1> {
    static constexpr unsigned value = 1;
};
template <>
struct factorial<0> {
    static constexpr unsigned value = 0;
};

C++11还添加了参数包(prameter pack),即一个能接受零或任意多个模板实参的模板参数。通过结合使用参数包和递归,我们可以实现一个泛化的equalsis_any_of,它能检测一个类型是否属于一个类型集合,这个类型集合用参数包实现。照例先写出伪代码:

bool is_any_of(T, Us...) {
    for (U : Us) {
        if (equals(T, U)) { // #1
            return true;
        }
        else { // #2
            continue;
        }
    }
    return false; // #3
}

再来写元函数版本。老规矩,先写出primary template——但这次我们并不实现它。因为该primary template只提供interface,实际调用会匹配到后续定义的specilized template。随后,我们通过部分特例化的模板实现#1~#3。值得注意的是,这一次我们在类继承处实现了递归。

// Primary template
template <typename T, typename... Us>
struct is_any_of;

// #1 匹配集合中的第一个类型
template <typename T, typename... U1_to_Un> 
struct is_any_of<T, T, U1_to_Un...> : true_type {};

// #2 第一个类型不匹配
template <typename T, typename U0, typename... U1_to_Un> 
struct is_any_of<T, U0, U1_to_Un...> : is_any_of<T, U1_to_Un...> {};

// #3 集合为空集
template <typename T> 
struct is_any_of<T> : false_type {};
  1. 特例#1的情况是,类型T与类型集合(即参数包Us)中的第一个类型相匹配,因此继承true_type
  2. 特例#2的情况是,类型T与类型集合(即参数包Us)中的第一个类型不匹配。
  3. 特例#3的情况是,类型集合为空集。这个特例匹配两种情况,一种是用户调用is_any_of时就没有提供类型;另一种是特例#2一致递归到最后,遍历了Us所有的类型也没有找到相匹配的。此外,之所以primary template不会被选择,是因为C++标准规定...总是优先级最低的一种,因此Us为空集时会匹配到只接受一个模板参数特例#3,而非接受任意数量参数的primary template。

所以,is_void也可以这样实现(可以,但没必要):

template <typename T>
struct is_void : 
    is_any_of<remove_cv_t<T>
            , void, void const, void volatile, void const volatile>
{};

小结

这篇文章简单介绍了模板元编程的概念、特性。并以实现标准库(但本文的代码不能替代标准库的实现哦)的type traits为例介绍了模板元编程的技巧:

  1. 通过类继承复用元函数,大部分元函数都以继承true_typefalse_type的方式来存放结果。
  2. 通过模板特例化实现条件选择,利用了编译器选择并实例化与模板实参最匹配的特例化模板的机制。
  3. 通过对类型系统的了解实现代码重构,如remove_cv之于primary type categories元函数。
  4. 通过递归实现模板参数遍历。我们再次利用了编译器选择模板版本的机制来实现它,也侧面说明模板元编程很多情况下缺乏核心语言支持,因此需要兜着圈子来表达意图。

参考资料

CppCon 2014: Walter E. Brown "Modern Template Metaprogramming: A Compendium, Part I"
CppCon 2014: Walter E. Brown "Modern Template Metaprogramming: A Compendium, Part II"
Template Metaprogramming: Type Traits (part 1 of 2) - Jody Hagins - CppCon 2020

posted @ 2021-06-04 15:36  hungb1u  阅读(1525)  评论(0)    收藏  举报