理解C++模板元编程

6

为了更好地理解C++中的模板和元编程,我正在阅读这篇文章,但是我对代码片段的理解很快就降低了,例如:

template<class A, template<class...> class B> struct mp_rename_impl;

template<template<class...> class A, class... T, template<class...> class B>
    struct mp_rename_impl<A<T...>, B>
{
    using type = B<T...>;
};

template<class A, template<class...> class B>
    using mp_rename = typename mp_rename_impl<A, B>::type;

这段代码的使用方式如下:

mp_rename<std::pair<int, float>, std::tuple>        // -> std::tuple<int, float>
mp_rename<mp_list<int, float>, std::pair>           // -> std::pair<int, float>
mp_rename<std::shared_ptr<int>, std::unique_ptr>    // -> std::unique_ptr<int>

有人能否像我这样五岁的孩子一样解释一下代码呢?我对非模板化的C++有一个普通和基本的理解。
我不明白的是:
为什么mp_rename_impl使用两个类型参数(class A,template<class...> class B)进行前向声明,然后在同一时间[*]用三个类型参数(template<class...> class A, class... T, template<class...> class B)和两个(A<T...>, B)分别定义和特化?
我知道它将type别名(using type = B<T...>;)为B<T...>而不是A<T...>,但我真的不明白它是如何完成的。
还有为什么A只在特化中作为模板模板参数?
[*] 我肯定这里理解错了。

请推荐一个! - Paul
有一个在StackOverflow上的问题正好问到了这个:最好的C++模板元编程介绍? - paddy
1
你也可以观看Walter E. Brown在CppCon2014上的演讲。这是一个关于模板元编程的非常好的演讲:https://www.youtube.com/watch?v=Am2is2QCvxY。这是演讲的第一部分,你可以在建议中找到第二部分。 - LoPiTaL
我一定会看的。 - Paul
1
到目前为止,tmp 的最佳推荐是:https://www.youtube.com/watch?v=Am2is2QCvxY。完成后请查找第二部分。 - Germán Diago
那个关于编程的演讲(P1和P2)太棒了!它为手头的问题提供了一些启示! - Paul
3个回答

5
为什么 mp_rename_impl 的前向声明有两个类型参数(class A, template<class...> class B),然后它在同一时间[*]定义并特化为三个(template<class...> class A, class... T, template<class...> class B)和两个(A<T...>, B)类型参数?
前向声明确定了实例化 mp_rename_impl 所需的参数数量,以及前者应该是一个实际类型,后者是一个模板。
然后当有实际实例化时,它尝试匹配特化结构体 struct mp_rename_impl<A<T...>, B>,并在这样做时可以考虑任何组合的值来匹配特化结构体的期望:即 template<class...> class A, class... T, template<class...> class B。请注意,特化中的 A 参数与声明中的 A 共享名称,但不是相同的——前者是一个模板,而后者是一个类型。有效地,要匹配特化,必须将模板实例作为声明的 A 参数传递,并且该模板的参数被捕获在 T... 中。它对可以作为 B 传递的内容没有新的限制(尽管 using 语句有限制——B<T...> 需要有效,否则您将获得编译错误——太迟了,SFINAE 无法启动)。
此外,为什么只有在特化中 A 是模板模板参数?
特化调用该参数为 A,但它在概念上与声明中的 A 不同。相反,前者的 A<T...> 对应于后者的 A。也许特化应该将其称为“TA”或其他东西,以指示它是从中可以与 T... 参数组合形成实际的 A 的模板。然后,特化是关于 A<T...>, B 的,因此编译器从实际尝试的任何实例化开始向后工作,以找到有效的替换 AT...B,并根据 template<template<class...> class A, class... T, template<class...> class B> 中指定的其形式的限制进行指导。
这样做的实际效果是,当两个参数分别为已给定一些参数类型的模板和能够接受参数类型列表的模板时,才会匹配到特殊化。匹配过程有效地隔离了 T 类型列表,以便可以将其与 B 重复使用。

这段代码的解释,同时也涉及模板的一般部分。我已经点赞了其他人,你的回答也被接受了。谢谢! - Paul

1
我的第一次尝试不是你想要的,所以让我简单地尝试回到像你六岁时解释的方式。
这并不是在函数有原型和定义的意义上进行前向声明。对于任何A,都有一个实现,并且编译成一个空结构体(对于编译器来说是唯一的类型,但不需要任何实际存储或运行时代码)。然后,只有模板类A才有第二个实现。

第二个定义中实际上有两个模板。其含义是,第二个定义接受两个参数A... T,并将它们转换为类型A<T>,这个类型成为了 mp_rename_impl<A<T...>,B>的第一个参数。因此,它适用于任何A,只要它是一个模板类。但那是一种更具体的A!所以,它需要声明一个在其作用域中带有类型定义的结构来进行特化。最后,第三个变量根本不是模板的特化。它将模板化的mp_rename声明为别名,指定了第二个声明中每个结构体所存储的更复杂类型的标识符type所在的作用域。
信不信由你,这使他的模板代码更易读。

为什么顶部的更通用的定义会扩展成一个空结构体?当A不是一个模板类时,它的内容是微不足道的,但它确实需要一些类型来区别于其他所有类型。 (更酷的做法是编写下面的示例以生成具有静态常量作为成员的类,而不是函数。事实上,我刚刚做到了。)
更新为威胁性地使我的模板更像他的模板:
好的,模板元编程是一种编程方式,它不是在程序运行时计算某些东西,而是编译器提前计算并将答案存储在程序中。它通过编译模板来实现这一点。有时候这样运行会更快!但是你所能做的有限。主要是,您不能修改任何参数,并且必须确保计算停止。
如果你想,“你的意思是,就像函数式编程一样?”那么你就是一个非常聪明的五岁孩子。你通常最终会编写递归模板,其中基本情况会扩展到展开的简化代码或常量。以下是一个示例,可能会让您感到熟悉,因为您在三岁或四岁时进行了计算机科学课程的介绍:
#include <iostream>

using std::cout;
using std::endl;

/* The recursive template to compute the ith fibonacci number.
 */
template < class T, unsigned i >
  struct tmp_fibo {
    static const T fibo = tmp_fibo<T,i-1>::fibo + tmp_fibo<T,i-2>::fibo;
  };

/* The base cases for i = 1 and i = 0.  Partial struct specialization
 * is allowed.
 */
template < class T >
  struct tmp_fibo<T,1U> {
    static const T fibo = (T)1;
  };

template < class T >
  struct tmp_fibo<T,0U> {
    static const T fibo = (T)0;
  };

int main(void) {
  cout << "fibo(50) = " << tmp_fibo<unsigned long long, 50>::fibo
       << ". fibo(10) = " << tmp_fibo<int, 10>::fibo << "."
       <<  endl;

  return 0;
}

编译成汇编语言,我们可以看到编译器为tmp_fibo<unsigned long long, 50>::fibo这一行生成的代码。以下是完整代码:

movabsq $12586269025, %rsi

该模板在编译时为每个结构生成一个整数常量。由于可以在结构中声明类型名称,这些示例所做的就是为类型执行相同的操作。

问题在于我对函数式编程(Haskell)有一定的了解,并且我可以理解这个简单的模板元编程示例,但我想要理解更高级的代码示例。 - Paul
哦,好的。那不是什么惊天动地的例子,或者说并没有什么必要非得用内联函数来实现;作者只是在展示一些复杂类型的生成方式。 - Davislor
我不理解:“在你给出的例子中,它并不是真正的前向声明,只是声明了一个空结构体”,对我来说,“声明”与空值无关,而是与类型有关。我错了吗? - Paul
它并非在函数具有原型和定义的意义上进行前向声明。对于任何 A,都有一个实现,并且编译为一个空结构体(这是编译器的唯一类型,但不需要任何实际存储或运行时代码)。然后,只有针对模板类 A<T> 的第二个实现。使用模板类调用它,您将获得第二个实现。使用非模板类调用它,您将获得第一个实现。 - Davislor
并且,我清理了我的示例,使其更像他的。现在,我正在我的结构中定义常量。您还可以为类型定义执行此操作,这些类型定义本身可以是模板,这就是他正在做的事情。但是,这确实是为STL容器中的::value_type之类的东西而设计的。 - Davislor
显示剩余2条评论

1

我会尽力让它简单易懂。模板元编程是关于在编译时计算类型的(你也可以计算值,但让我们专注于此)。

因此,如果您有这个函数:

int f(int a, int b);

你有一个能够接受两个int值作为参数并返回int值的函数。

你可以这样使用它:

int val = f(5, 8);

元函数是针对类型而非值进行操作的。一个元函数看起来像这样:
//The template parameters of the metafunction are the
//equivalent of the parameters of the function
template <class T, class U>
struct meta_f {
    typedef /*something here*/ type;
};

即,一个元函数中嵌套了一个type,按照惯例,嵌套的类型称为type

在非泛型上下文中,您可以像这样调用元函数:

using my_new_type = meta_f<int, float>::type;

在通用上下文中,您必须使用 typename:
using my_new_type = typename meta_f<T, U>::type;

这返回一个类型,而不是运行时值,因为我们说元函数操作类型。
标准库中的元函数示例可以在头文件type_traits等中找到。您可以使用add_pointer<T>decay<T>来返回给定类型的新类型。
在C++14中,为了避免冗长的代码片段,例如:
using my_computed_type = typename std::add_pointer<T>::type;

按照惯例,一些带有_t后缀的模板别名被创建,可以直接为您调用元函数:

template <class T>
using add_pointer_t = typename std::add_pointer<T>::type;

现在你可以写:

using my_computed_type = std::add_pointer_t<T>;

总的来说,在一个函数中,你有运行时值作为参数,在元函数中,参数是类型。在一个函数中,你使用常规语法调用并获得运行时值。在一个元函数中,你获取::type嵌套类型并获得一个新的计算类型。
//Function invocation, a, b, c are values of type A, B, C
auto ret = f(a, b, c);

//Meta function invocation. A, B, C are types
using ret_t = typename meta_f<A, B, C>::type;

//Typical shortcut, equivalent to metafunction invocation.
using ret_t = meta_f_t<A,B,C>;

对于第一个函数,您会获得一个值;对于其他函数,您会获得类型而不是值。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接