9. C++编译时元编程(一)

C++ 编译时元编程,是对 类型 的计算。类型自不必说,不存在 mutable 的可能性;而数值,C++ 也规定了 必须是常量,即 immutable 的数值即对象。而任何基于常量的计算,如果其计算能力是图灵完备的,那么其从实际效果上,必然与 函数式编程是等价的。

为了理解 C++ 编译时元编程的思想,最好有一门函数式编程语言做为引导。最为知名的函数式编程语言 Haskell 是一个直觉上最佳的选择。 不幸的是,由于 C++ 编译时,不仅仅可以操纵 数值 ,还可以操纵 类型 ,而这两者高度融合在一个体系中,没有清晰界限。 但 Haskell 对于类型和数据区分的非常清楚:其每个数据都有自己的类型,但参与计算的是数据,没有计算类型的能力。 而 Agda 的类型系统高了一个层次,在计算层面,数值和类型的边界被消除,拥有同等的计算能力。因而,本文主要通过 Agda 来做为引导, 来理解 C++ 编译时元编程的思想。

9.1. List

我们先通过一段 Agda 代码来熟悉其语法和语意:

data List (A : Set) : Set where
  []   : List A
  _::_ : A -> List A -> List A

这段代码里,data 说明这是在定义一个数据类型,其名字是 List , 而这个类型,需要另外一个类型做为输入,即 A ,而 A 的类型为 Set ;而被定义的类型: List, 其类型也是 Set

在类型系统理论中,每一个类型都是一个 集合 ,比如:bool 是一个集合,集合里的元素是其 值域truefalse 。 而 int 则是整个整数集合。加法类型,即 C/C++ 中的 union ,其 值域 则是 union 中每种类型 值域 的并集 (这就是为何被称做加法类型);而乘法类型,即 C/C++ 中的 struct ,其 值域 则是 struct 中每个 field值域 相乘 。总之,不难理解,每一个类型都是一个集合。

但所有类型放在一起,也是一个集合。这个集合被称做 Set1 。之所以被叫做 Set1 ,因为理论上,所有的 Set1 也是一个集合,那个集合被称做 Set2 ,从而可以 Set3, ..., SetN, ... 无穷的递归上去。

当然,我们现实中的类型系统,到 Set1 即足够了。所以上面代码中的 Set ,就是 Set1 ,即所有类型的集合。因而,任何时候 你看到一个符号的类型是 Set ,那就意味着那个符号是一个类型。

接着看上面的代码。第 2 行和第 3 行,分别定义了两个 构造函数

  1. 第一个构造了一个空列表,其类型当然是 List A

  2. 第二个构造函数有两个参数: 类型分别为 AList A , 即把一个 A 类型的数据追加到列表前面,得到计算结果:一个新的列表。 这就是 A -> List A -> List A 的含义。

如果我们把上述代码直接映射到 C++ ,就是下面的代码:

template <typename A>
struct List {
   List();
   List(A, List<A>);
};

请花上一点时间,仔细对照这两段代码。它们之间的语意映射关系相当直接和清楚。 这其中,typename 正是 Agda 中的 Set 。另外, C++ 的构造函数的返回值类型正是其构造的类型本身,因而无须描述。

你或许会感叹 Agda 可以直接使用符号来定义构造。但那不是本文的重点。

当然,C++ 只是那么描述,是无法完成一个 List 真正的职责的。下面给出一个可以工作的实现:

template<typename T, size_t N = 0>
struct List {
  const T head;
  const List<T, N-1> tail;
};

template<typename T>
struct List<T, 0> {};

这里通过 模版部分特化 的方式实现 if-else 逻辑。而这也正是函数式编程的主要方式:通过模式匹配选择不同的函数实现。比如:

fib   :: (Integral a) => a -> a
fib n = (fib n-1) + (fib n-2)
fib 1 = 1
fib 0 = 0

这是一段 Haskell 代码,其中 a 代表这是一种范型参数,其属于 Integral TypeClass 。 而对应的 C++ 实现为:

template <std::integral auto N>
            constexpr auto fib    = fib<N-1> + fib<N-2>;
template <> constexpr auto fib<1> = 1;
template <> constexpr auto fib<0> = 0;

其中 std::integralC++20 引入的 concept ,它比 HaskellTypeClass 更为强大。但在本例中,起到的作用 一样。

但这个例子只是通过模式匹配在做 数值 演算。而对于 List 的例子,我们则是通过模式匹配进行类型选择。从本质上理解,如果模糊 数值和类型的差异,那么类模版也是一个函数。比如,下面的模版类:

template <typename T, int I>
struct Class {
   using type = T;
};

其语意上,是下面的类 agda 语法描述(并不是真正的语法,因为 agda 没有 struct/class ):

Class     : (T : Set) -> int -> Set
Class T I = struct { using type = T; }

即, 模版名字是函数名,其有两个参数,其中 T 通过花括号里 {T : Set} 说明 T 是一个类型。其参数为 Tint 类型的数值 I ,函数的求值结果是一个类型:即后面的结构体内容定义。

因而,上面的对于 List 的定义,转化为类 agda 语法为:

List     : (T : Set) -> size_t -> Set
List T N = struct { const T head; const List<T, N-1> tail; }
List T 0 = struct { }

通过不同的输入参数,我们匹配到了不同函数,返回了不同的类型。空列表为空实现,非空列表才会有 headtail

将类模版理解为函数,将模版特化看作函数调用的模式匹配,这就把对于数值的计算,和对于类型的计算,完全统一在一起。这会极大的拓展对于类型 操作能力的理解。要知道,C++ 范型对于类型的操作能力是 图灵完备 的。不要只是把它当作简单的实例化一个容器那样的基本泛型,否则你 会错过 C++ 最为强大,也最为精彩的能力。

我们接着实现上面的 List 。为了让用户可以不要在每次使用 List 时都要指明类型,我们定义两个 Deduction Guide , 而第 2 个正是 Agda 例子中的第 2 个构造。而另外一个构造,则是对只有一个元素情况下的简便写法。 至于空列表构造,C++ 已经帮我们生成了默认构造,我们无须再写。

template<typename T>
List(T) -> List<T, 1>;

template<typename T, size_t N>
List(T, List<T, N>) -> List<T, N+1>;

这里如果不使用 Deduction Guide ,换成构造函数,则是如下写法:

template<typename T, size_t N = 0>
struct List {
  List(T head) : head{head}, tail{} {}
  List(T head, List<T, N-1> tail) : head{head}, tail{tail} {}

  const T head;
  const List<T, N-1> tail;
};

很明显,这种写法要啰嗦的多。毕竟都是非常平凡的构造,写起来很无聊。另外,最重要的是,这样的写法,C++ 无法自动推演 出来 N 的值。因为构造函数的参数 List<T, N-1> 里,N 处于一个计算表达式里。这在 C++ 的定义中属于 不可推演上下文。

13.10.2.5 [temp.deduct.type]:

A non-type template argument or an array bound in which a subexpression references a template parameter.

另外,Deduction Guide 本身并不是构造函数。此处之所以通过 Deduction Guide 就可以构造,是因为 List 类型本身 是一个 聚合 (Aggregate) ,聚合本身就可以直接构造其成员。比如下面的聚合以及初始化:

struct Foo { int a; int b; };

Foo foo1 = { 1, 2 }; // a=1, b=2
Foo foo2 = { 1 };    // a=1, b=0

所以,Deduction Guide 并没有创建任何构造函数,而只是根据 Deduction Guide 的指导,在调用聚合的初始化而已。

下面我们再定义与空列表有关的类型和常量:

template<typename T>
using Nil_t = List<T, 0>;

template<typename T>
constexpr Nil_t<T> Nil{};

有了 Deduction Guide 的指导, 和 Nil 常量的辅助,我们就可以定义 List 常量了:

constexpr auto emtpy = Nil<int>;                   // int 型空列表,由于类型无法推演,必须明确指明
constexpr auto list1 = List{1, List{2, List{3}}};  // 构造 1::2::3::Nil

从中,你可以清晰的看出函数式语言中的 List 就是这样的递归构造。Agda 在构造一个 list 时,则是如下语法:

let emtpy = []
let list1 = 1 :: 2 :: 3 :: []

明显比我们上面的定义看起来要清晰。当然 C++ 也可以重载操作符,比如:

struct NilList {};
constexpr NilList nil = {};

template<typename A, size_t N>
constexpr auto operator>>=(const A& value, const List<A, N>& list) -> List<A, N+1> {
  return List{ value, list };
}

template<typename A>
constexpr auto operator>>=(const A& value, const NilList&) -> List<A, 1> {
   return List{ value };
}

然后,我们就可以做和 Agda 类似的写法:

constexpr auto list1 = 1 >>= 2 >>= 3 >>= nil;

之所以选择这个符号,因为 C++ 只有 @= (其中 @ 代表 +, - , >> 等二元操作符)是右结合的。

另外,你会发现 nil 没有指明任何类型信息。而不像之前必须指明类型: Nil<int> 。这是因为,在它所在的 operator>>= 环境里,List 的类型可以从做左边的操作数 3 获取到。可以回到 operator>>= 里理解这一点。 如果没有上下文可以推演类型,则仍然必须亲自指明类型。

如果你还想更加简洁,则可以使用变参模版大法:

template<auto H, auto ... RESTs>
constexpr auto makeList                   = List{H};

template<auto H, auto H1, auto ... RESTs>
constexpr auto makeList<H, H1, RESTs...>  = H >>= makeList<H1, RESTs...>;

这是一个完全递归的计算,典型的函数式计算方式。另外,makeList 从参数上约束了必须至少有一个元素,否则在空列表的情况下, 其类型由于缺乏上下文而无法推导。

现在,用户就可以非常简单的创建列表了:

constexpr auto list1 = makeList<1,2,3,4>;

下面我们来看与 List 有关的操作。比如最典型的 map 操作。下面是 Agda 的实现:

map             : {A B : Set} -> (A -> B) -> List A -> List B
map f []        = []
map f (x :: xs) = f x :: map f xs

第一行类型声明。其意思是:有两个类型 AB ,函数的输入参数有两个 : 第一个参数 (A->B) ,这是从 A 类型到 B 类型 的映射函数,List A 是一个元素为 A 类型的 List ,函数的求值结果是元素类型为 BList

C++ 的实现非常类似:

template<typename A, typename B>
constexpr auto map(Nil_t<A>, auto (*f) (A) -> B) -> Nil_t<B> {
   return Nil<B>;
}

template<typename A, typename B, size_t N>
constexpr auto map(List<A, N> xs, auto (*f) (A) -> B) -> List<B, N> {
   return f(xs.head) >>= map(xs.tail, f);
}

然后,你就可以这样使用:

constexpr auto result = map(makeList<1,2,3>, +[](int value) {
   return double(value + 1) * 1.2;
});

你或许已经注意到,我们定义的所有变量和函数都有 constexpr 声明,因为我们在做编译时的计算,只能是常量。编译时计算是完全 没有副作用的。并且如果你的计算代码使用了任何标准中的未定义行为,都会导致编译出错。运行时计算则不会如此。

现在,我们再来定义另外一个 List 操作函数:将两个 List 衔接在一起。我们先来看 Agda 的实现:

_++_            : {A : Set} -> List A -> List A -> List A
[] ++ ys        = ys
(x :: xs) ++ ys = x :: (xs ++ ys)

这个函数的类型很容易懂,不再赘述。其中新的元素是 _++_ ,这是这个函数的名字,两边的下划线说明这是一个中位操作符。所以其下面 定义函数实现时,也直接使用了中位操作方式。

C++ 的实现则是重载操作符。但算法一摸一样:

template<typename A, size_t N>
constexpr auto operator+(const Nil_t<A>&, const List<A, N>& rhs) -> List<A, N> {
   return rhs;
}

template<typename A, size_t M, size_t N>
constexpr auto operator+(const List<A, M>& lhs, const List<A, N>& rhs) -> List<A, M + N> {
   return lhs.head >>= (lhs.tail + rhs);
}

注意,C++ 的实现里,模版参数多了 size 参数,因为它是 C++ 实现的 List 类型的一部分,但它 属于自动推演参数,用户永远不需要亲自指定它。

然后用户就可以这么使用:

constexpr auto result1 = nil + makeList<1, 2, 3>;
constexpr auto result2 = makeList<'a', 'b', 'c'> + makeList<'d','e'>;

本文中 List 的例子,展示了在 C++ 编译时元编程时,和函数式编程完全一样的思路。当然,你永远不会在现实项目中 使用 List 这样低效的结构。有很多支持常量计算的数据结构才是你真正应该选择的。

重要

  • C++ 编译时元编程都是常量语意;

  • 类型 ,在 C++ 编译时元编程的世界里,从概念上没有本质区别。typename 是类型的 Set

  • C++ 的类模版也是函数语意;其求值结果的类型是 Set ,即类型;

  • 模式匹配递归 ,是函数式编程处理条件选择和循环问题的典型手段;同样也是 C++ 编译时计算的主要手段。

9.2. TypeList

自从 C++11 引入了变参模版,极大的增强了范型编程的能力。这就意味着,我们在前面的 数值 列表,现在有了 类型 列表。因而, 在实际项目中,我们也需要对类型列表有着和类型列表一样的操作。

9.2.1. Elem

比如,我们想从一个 类型列表 中取出第 N 个类型:

template<size_t N, typename ... Ts>
struct Elem;

template<size_t N, typename H, typename ... Ts>
struct Elem<N, H, Ts...> {
   using type = typename Elem<N-1, Ts...>::type;
};

template<typename H, typename ... Ts>
struct Elem<0, H, Ts...> {
   using type = H;
};

然后,我们再定义一个别名,让用户使用时可以有更简单的表达式:

template<size_t N, typename ... Ts>
using Elem_t = typename Elem<N, Ts...>::type;

如下的 Agda 伪代码,反映了上述同样的算法:

Elem           :: size_t -> [Set] -> Set
Elem N (H::Ts) = Elem (N-1) Ts
Elem 0 (H::Ts) = H

当然,代码中没有明确应对 N 值超过列表长度的情况,在 C++ 下,这回导致一个编译错误。而这正是我们想要的结果。

另外,你如果仔细观察,会发现 Elem 模版里只有一个元素,即 type 。这种情况下,其实 Elem 类模版是没有价值的;因为它 一则是静态的,即没有人会用它所实例化得到的类型去创建对象;二则,它里面只有一个静态元素,并不起到包的作用(当一个类,或者模版里 有多个元素时,并且这些元素都是静态的,那么类某种程度就像是一个包,或者名字空间一样)。

因而,如果我们可以直接这么写,就可以让代码更加简洁:

template<size_t N, typename ... Ts>
using Elem = typename Elem<N, Ts...>;

template<size_t N, typename H, typename ... Ts>
using Elem<N, H, Ts...> = typename Elem<N-1, Ts...>;

template<typename H, typename ... Ts>
using Elem<0, H, Ts...> = H;

但很不幸, using 一个类型,在 C++ 里属于 别名 (Alias),而别名模版只是别名而已,不支持特化。

所以,我们还是只能使用类模版的方式。但对比两者,我们我们可以看出,我们真正关心的是其中的 type 。而不是外面的类模版。所以后者 才更真实直接的在表达我们本来的语意。之所以采取前者那种间接表达方式,是因为 C++ 的限制。

或许未来 C++ 标准委员会意识到这是一种广泛的需求,因为这是 C++ 泛型编程极为常见的需求,标准库里到处都是这种用法。

事实上,在 C++11 之前,你只有两种模版类型:类模版和函数模版。因而,对于处理结构性问题时,只有类模版一条途径,哪怕只是简单的值计算。 我们还是举之前 斐波那契数列 的例子,在 C++11 之前,

template <unsigned int N>
struct Fib {
   static const unsigned int value = Fib<N-1>::value + fib<N-2>::value;
}

template <> struct Fib<1> {
   static const unsigned int value = 1;
}

template <> struct Fib<0> {
   static const unsigned int value = 0;
}

同样的, 模版 Fib 存在的唯一目的,是为了提供静态的 value ,(正如上面为了提供静态的 type )。 那时候,C++ 也不支持 constexpr ,所以也无法通过函数来进行这样的编译时值计算。因而只能以这样的方式实现。

随后,C++11 引入了 constexpr ,而 C++14 终于提供了 变量模版 。于是程序员终于可以在意图只是 通过模版定义单个常量时,可以直接使用更加简洁的方式,直接表达的自己意图。

值的枷锁已经被解除,单个类型的枷锁或许很快也会被打破。要知道,在 C++11 之前,甚至 类型别名模版 都不支持。

但无论如何,我们必须明确,Elem 的例子里,如果将 Elem 看作一个函数,从语意上,函数真正的求值结果是里面 的 type ,而不是外面的 struct/class

9.2.2. Drop

现在,我们再来实现另外一个经典函数:drop ,即将列表中的前 N 个元素抛弃掉之后所得到的列表。

这从算法上和 Elem 极为相似,唯一的差别是,Elem 只要第 N (从 0 开始计数) 的元素,而 Drop 则是抛弃掉 前 N 个元素(此时 N 不像 ElemN 一样是索引,而是一个总数 ),得到一个列表。

结果是单个类型,还是一个类型列表,对于设计的影响完全不同。因为 C++ 类型却可以指代,因而,才可以通过:

template<typename H, typename ... Ts>
struct Elem<0, H, Ts...> {
    using type = H; // 这里通过type指代
};

template<size_t N, typename ... Ts>
using Elem_t = typename Elem<N, Ts...>::type; // 这里访问那个指代

但不幸的是,C++ 类型列表无法指代。你无法通过诸如:using type... = Ts... 的方式, 来指代一个从模版参数传入的参数列表。因而,也就无法直接返回一个结果列表。

我们在之前已经看到,模版参数可以是 类型 或者 数值 。还有一种允许的模版参数类型就是 模版 。之前我们一直强调, 模版的语意是求类型的 函数 ;这就意味着,一个函数的参数也可以是函数,而能将函数当作参数,或者能返回函数的函数,被称作 高阶函数。而允许高阶函数是函数做为一等公民的关键特征。与之对应,模版也是 C++ 泛型编程的一等公民。

高阶函数(Higher Order Function):

In mathematics and computer science, a higher-order function is a function that does at least one of the following:

  • takes one or more functions as arguments (i.e. procedural parameters),

  • returns a function as its result.

All other functions are first-order functions

所以,对于刚才的问题,我们可以传入一个函数(也就是模版),其参数是一个变参(即类型列表),这样我们就可以把计算出来的参数列表做为结果 传递给那个模版,由那个模版根据调用者的需要,随意处理。下面是我们的实现:

template<
   size_t                          N,
   template<typename ...> typename RESULT,
   typename                    ... Ts>
struct Drop;

template<
   size_t                          N,
   template<typename ...> typename RESULT,
   typename                        H,
   typename                    ... Ts>
struct Drop<N, RESULT, H, Ts...> {
   using type = typename Drop<N-1, RESULT, Ts...>::type;
};

template<
   template<typename ...> typename RESULT,
   typename                        H,
   typename                    ... Ts>
struct Drop<0, RESULT, H, Ts...> {
   using type = RESULT<H, Ts...>;
};

template<
   template<typename ...> typename RESULT>
struct Drop<0, RESULT> {
   using type = RESULT<>;
};

其中,RESULT 就是用户指定的回调模版,而将 TypeList 传递给它之后,它就被实例化为一个类型,从而就得到了指代能力。

Agda 伪代码描述如下:

Drop           :: size_t -> ([Set] -> Set) -> [Set] -> Set
Drop N RESULT (H::Ts) = Drop (N-1) RESULT Ts
Drop 0 RESULT (H::Ts) = RESULT H::Ts
Drop 0 RESULT []      = Result []

当然,其实最后两种情况,从道理上是可以合并的,但是从 C++ 模式匹配的角度,合并之后,C++ 无法判断两种情况那种更特别。 只有将 H , Ts... 展开写, C++ 才能对比它和前面那个模版的特化度。

然后我们就可以定义 Drop_t 别名,以简化用户的表达式:

template<
  size_t                          N,
  template<typename ...> typename RESULT,
  typename                    ... Ts>
using Drop_t = typename Drop<N, RESULT, Ts...>::type;

我们之前已经谈到, ElemDrop 的算法完全一直,无非就是所要的结果不同。因而,我们可以废弃掉之前 Elem 的实现, 复用 Drop 的实现,而复用的方式,是通过传入自己特定的 RESULT 函数。

template<typename ... Ts>
struct Head;

template<typename H, typename ... Ts>
struct Head<H, Ts...> {
   using type = H;
};

template<
  size_t N,
  typename ... Ts>
using Elem_t = Drop_tt<N, Head, Ts...>;

我们传入的 RESULT 函数就是 Head ,它拿到了一个 TypeList 之后,只取出第一个,即 H ,而把其余的全部都丢弃掉。 而这正是如果利用这种回调机制操作类型的一个示例。

除了直接的回调方式,还有另外一种方式:先让 Drop 自身计算结束,然后返回给一个 高阶模版 。用户可以在那个时候, 通过这个 高阶模版 ,以回调的方式获取结果:

template<
   size_t       N,
   typename ... Ts>
struct Drop {
   template<template<typename ...> typename RESULT>
   using output = RESULT<>;
};

template<
   typename     H,
   typename ... Ts>
struct Drop<0, H, Ts...> {
   template<template<typename ...> typename RESULT>
   using output = RESULT<H, Ts...>;
};

template<
   size_t       N,
   typename     H,
   typename ... Ts>
struct Drop<N, H, Ts...> {
   template<template<typename ...> typename RESULT>
   using output = typename Drop<N-1, Ts...>::template output<RESULT>;
};

其中, 模版 output 即是计算返回的高阶模版。 而回调的时机则推迟到:

template<
  size_t                          N,
  template<typename ...> typename RESULT,
  typename                    ... Ts>
using Drop_t = typename details::Drop<N, Ts...>::template output<RESULT>;

这种返回 高阶模版 的做法,让模版计算和 lambda 一样,拥有了 闭包 ,即计算时自由访问环境的能力。我们先来看 一个 C++ lambda 的例子:

auto l1 =
[](int a, int b) -> int {
   return [=](int c) -> int {
      return a + b + c;  // 访问 a, b
   }
}

auto l2     = l1(10, 20); // l2 is a lambda
auto result = l2(30);

对比一下模版的例子:

template<
   size_t       N,
   typename     H,
   typename ... Ts>
struct Drop<N, H, Ts...> {
   template<template<typename ...> typename RESULT>
   using output = // 访问外围模版的 Ts...
     typename Drop<N-1, Ts...>::template output<RESULT>;
};

using T1 = Drop<1, int, double, char>;  // 调用Drop得到 T1 类型
using T2 = T1::template output<Result>; // 调用T1类型的 output模版,得到我们的结果

9.2.3. Transform

现在我们再实现另外一个非常经典的函数 map ,由于在 C++ 中, map 在标准库里代表一个 k/v 容器,而将 fp 领域 里的 map 称做 transform ,我们也继续遵守这个习俗。

Transform 操作是将一组类型,通过一个转化函数,转化成另外一组相同数量的其它类型。用 Agda 伪代码描述如下:

Transform  :: [Set] -> (Set -> Set) -> [Set]

注意,这里面有两个 TypeList ,一个是输入,一个是输出。而两者的内容经过 (Set -> Set) 转换后,很可能是完全不同的。

虽然之前的 Drop 也是输入一个 TypeList ,输出一个 TypeList ,但后者只是前者的一部分。 因而事实上只需要在一个 TypeList 上操作即可。

对于 Transform 算法来说,它一边从输入列表中读取单个元素,将其转化后,不断追加新生成的输出列表上。另外,由于类型列表无法指代, 所以,不可能使用诸如下面的算法:

{- 后面的 map f xs 有一个返回输出,但在 `C++`` 下无法通过这种方式直接输出一个列表 -}
map f (x :: xs) = f x :: map f xs

因而,我们必须将输出的列表随时保存在模版参数上,然后将最终结果想 Drop 算法一样,传递用户的回调。

同时,C++ 有另外一个约束,即类模版的变参列表只允许有一个,并且必须放在最后。(不知道未来 C++ 是否可以放开这样的限制, 如果两个类型列表中,存在一个非类型参数,其实是可以区分的)。所以这个宝贵的变参列表位置一旦留给输出,那么输入列表该放在何处?

答案是,将其保存在另外一个模版里,其操作方式,应该和我们最初定义 List 非常类似:

template<typename ... Ts>
struct TypeList {};

template<typename H, typename ... Ts>
struct TypeList<H, Ts...> {
   using Head = H;
   using Tail = TypeList<Ts...>;
};

这个定义精准的反映了一个 List 的本质,这是一个递归结构。因而,你总是可以通过如下方式读取任何元素:

using list = TypeList<int, double, char>;

list::head                     // int
list::tail::head               // double
list::tail::tail::head         // char
list::tail::tail::tail::head   // 编译出错

TypeList<>::head               // 编译出错

所以,我之前其实撒谎了。我一直在宣称 C++TypeList 无法指代。其实指的是 Ts... 形式的列表无法指代。但一旦 将其保存在刚刚定义的 TypeList 里,它就可以指代了。

但是,以 TypeList<Ts...> 的方式给用户,但需用需要的是 Ts... ,最终用户还是必须通过回调的方式才能使用 Ts... 形式, (这个我们后面会谈到),既然迟早都要回调,那还不如让用户完全意识不到 TypeList<Ts...> 这样一个中间结构,尽早回调。

下面就是 Transform 的实现:

template<
   typename                    IN,
   template<typename> typename F,
   typename = void,
   typename                ... OUT>
struct Transform {
   using type = TypeList<OUT...>;
};

template<
   typename                    IN,
   template<typename> typename F,
   typename                ... OUT>
struct Transform<IN, F, std::void_t<typename IN::Head>, OUT...> {
   using type =
      typename Transform<
         typename IN::Tail,
         F,
         void,
         __TYPE_LIST_APPEND(OUT..., typename F<typename IN::Head>::type)
      >::type;
};

代码看起来很长,但只是只是参数列表很长,真正的有逻辑的地方只有三处:

  1. 第一个模版是整个 Transform 结束时的情况。所以将 OUT... ,即转换最终得到的输出列表传递给用户的回调模版。

  2. std::void_t<typename IN::Head> 是一个 SFINAE 条件,即要求输入列表中还有 Head ,如果还存在, 则说明 IN 列表还没有遍历结束;因而,

  3. OUT..., typename F<typename IN::Head>::type 将通过 F 转换后的类型,追加到输出列表 OUT 后面。 注意,其中的宏 TYPE_LIST_APPEND 什么都没做,只是为了表明代码意图。

上述算饭,用 agda 伪代码表现即为:

Transform              : [Set] -> (Set -> Set) -> (Set -> Set) -> [Set]
Transform []     F OUT = OUT
Transform (x:xs) F OUT = Transform xs F (xs ++ [F x])

当然这只是内部实现,给用户提供的真正接口是:

template<
  template<typename>     typename F,
  template<typename ...> typename RESULT,
  typename                    ... IN>
using Transform_t =
  typename Transform<
    TypeList < IN...>,          // 将 IN... 保存到 TypeList
    F,
    void                        // 为了SFINAE条件判断
    __EMPTY_OUTPUT_TYPE_LIST___ // 输出列表最初为空
  >::type::template output<RESULT>;

所以,用户真正提供的参数只有三个, F 转化函数, IN 输入列表,以及用来回传最终结果回调模版 RESULT 。 而 宏 __EMPTY_OUTPUT_TYPE_LIST___ 背后什么都没有,正如一个 Ts... 形式的列表如果为空是,就什么都没有一样, 这样在阅读代码时,很容易忽略这里还有一个空参数。而通过 __EMPTY_OUTPUT_TYPE_LIST___ 则可以起到提示的作用。

Transform 非常有用,比如,我们想给一个类型加上 Wrapper

template <typename T> struct Wrapper {...};

template <typename ... Ts>
struct Bar {
   // Ts... 里每一个类型都是 Wrapper<T> 的形式。
};

template <typename ... Ts>
struct Foo {
   template <typename T>
   struct AddWrapper { using type = Wrapper<T>; };

   using bar = Transform_t<AddWrapper, Bar, Ts...>;
   // ...
}

或者,识别出一组类型里,所有继承自某个类的类型:

template <typename T, typename = void>
struct ActionTrait {
   using type = void;
};

template <typename T>
struct ActionTrait<T, std::enabled_if_t<std::is_base_class_v<Action, T>>> {
   using type = T;
};

template <typename ... Ts>
struct Bar {
   // Ts... 里,要么是 void, 要么是Action的子类
};

template <typename ... Ts>
struct Foo {
   using bar = Transform_t<ActionTrait, Bar, Ts...>;
   // ...
}

9.2.4. Split

现在我们再来实现 split 。其语意是在第 N 个位置,将一个输入列表一分为二。

这个需求,又增加了新的困难,Transform 只要求输出一个列表,但这要要求输出两个。而模版的变参列表只允许有一个。怎么办?

首先,在有输出的情况下,用户必然是要传入回调。既然现在有两个输出,那么用户自然就需要两个回调。既然一个模版只允许有一个变参, 那我们就分别两个回调传递个两个变参模版,一个给 TypeList<Ts...>,一个给计算模版。

所以,我需要先改造我们的 TypeList<Ts...> ,重新定义新的形式:

template<typename ... Ts>
struct TypeList {
  template<template <typename ...> typename RESULT>
  using output = RESULT<>;
};

template<typename H, typename ... Ts>
struct TypeList<H, Ts...> {
  using Head = H;
  using Tail = TypeList<Ts...>;

  template<template <typename ...> typename RESULT>
  using output = RESULT<H, Ts...>;
};

注意,这与之前的 TypeList 的唯一差别是多了一个高阶模版 output ,其用法和语意与我们在 Transform 一节讨论的一样。

Split 的实现,则关注在另外一个输出列表上:

template<
   size_t       N,
   typename     IN,
   typename ... OUT>
struct Split {
   using type = typename Split<
         N - 1,
         typename IN::Tail,
         __TYPE_LIST_APPEND(OUT..., typename IN::Head)
      >::type;
};

template<
   typename     IN,
   typename ... OUT>
struct Split<0, IN, OUT...> {
   struct type {
      using first  = TypeList<OUT...>;
      using second = IN;
   };
};

第一个模版处理的是 N 还没有递减到 0 的中间过程,所以继续递归。注意,那里有两个递归:一是 typename IN::Tail , 代表输入列表也在不断的通过递归抛弃 Head ;而另一个递归则是 Split 自身的递归, __TYPE_LIST_APPEND(OUT..., typename IN::Head) 则是将 IN 抛弃的 Head 拿过来,追加到第一个输出列表的 后边。

所以,每一次递归,都像是游标在原始列表上移动,不断把后半部分的第一个元素,变为前半部分的最后一个元素。

而第二个模版则是已经到达了分割点, 需要生成输出结果。因为有两个输出,因而分别被定义为 firstsecond 。前一个, 是将生成的 OUT... 打包传递给 RESULT_1 ,后半部分,则向 IN 索要;而 IN 正是我们之前定义 的 TypeList

最终,上述的内部算法,在如下代码处得到应用:

template<
   size_t N,
   template<typename ...> typename RESULT_1,
   template<typename ...> typename RESULT_2,
   typename ... IN>
class Splitter {
   using RawType =
     type_list::Split_t
         < N
         , TypeList<IN...>                 // 将IN...传入,得到输入列表
         __EMPTY_OUTPUT_TYPE_LIST___       // 前半部分最初列表为空
         >;
public:
   using type = __TL_make_pair(typename RawType::first::template output<RESULT_1>,
                               typename RawType::second::template output<RESULT_2>);
};

template<
   size_t N,
   template<typename ...> typename RESULT_1,
   template<typename ...> typename RESULT_2,
   typename ... IN>
using Split_t = typename Splitter<N, RESULT_1, RESULT_2, IN...>::type;

Split_t 的输入参数,清晰的反映了用户需要指定的信息: N 分割的位置;RESULT_1, RESULT_2 分别为分割后两个 部分的回调。IN 则是需要分割的输入列表。

重要

  • 类模版 的参数列表只允许有一个变参列表;因而,当需要多个变参列表时,则需要通过其它模版相助。

9.2.5. Fold

foldlist 非常重要的一个操作。其语意是遍历整个 list ,两两计算,最终得到一个结果。

它有两个版本:从左边 fold ,还是从右边 fold ,即:

(((x[1] op x[2]) op x[3]) op ...)      // 从左边开始fold
(... op (x[n-2] op (x[n-1] op x[n])))  // 从右边开始fold

而两个版本,都分为 有初始值无初初始值 两种情况,即:

(((init op x[1]) op [x2]) op ...)
(... op (x[n-1] op (x[n] op init)))

C++17 提供了 fold expression ,从而让用户不再像过去一样,必须通过类模版的递归演算,来计算一个类型列表相关的值。比如:

template <typename ... Ts>
struct Foo {
   enum { Sum_Of_Size = (0 + ... + sizeof(Ts) };
};

这里用到的是,有初识值 0 的从左边开始的,操作为 +fold 计算。其结果是列表中所有类型的大小的总和。

从这个简单的例子可以看出,C++fold expression 不只在做 fold ,还先做了 map ,本例中即 sizeof 。 所以,其实际上是 map-reduce :先 map ,然后将 map 后的结果进行 fold

当然,对于 + 这种性质的计算,你也可以用从右边开始的 fold

template <typename ... Ts>
struct Foo {
   enum { Sum_Of_Size = (sizeof(Ts) + ... + 0) };
};

如果你可以确保,输入的类型列表非空,你甚至不用写初识值,因而更加简洁:

template <typename ... Ts>
struct Foo {
   enum { Sum_Of_Size = (sizeof(Ts) + ... ) };
};

但如果不能保证列表非空,则必须有初始值,否则,编译就会出错。(本身也的确让 fold 无法得到一个计算结果)

当然,fold 的能力,不会只是像上述的加法一样简单。比如,我想求所有类型尺寸的最大尺寸:

struct MaxSize {
   constexpr MaxSize(size_t size = 0) : size(size) {}
   constexpr operator size_t() const { return size; }
   size_t size = 0;
};

constexpr MaxSize operator<<(MaxSize maxSize, size_t size) {
   return std::max(maxSize.size, size);
}

template <typename ... Ts>
struct Foo {
   enum { Max_Size = ( MaxSize{} << ... << sizeof(Ts) ) };
};

这个例有三个关键点:

  1. C++fold expression 的操作必须是 C++ 已经存在的二元操作符。几乎所有的二元操作符都可以用于 fold express 。如果一个二元操作符的 builtin 语意不满足你要求,或无法计算你的类型,你可以针对你的类型 重载某个二元操作符。

  2. 为了让重载的二元操作符不要影响其它类型,你必须定义一个自己的类型,在本例中,就是 MaxSize

  3. 任何一个可以在被编译时计算使用到的函数必须是 constexpr 。本例子中,其构造函数,及类型转换函数, 都必须是 constexpr 。当然,本例中,MaxSize 可以没有构造函数,那它将是一个 聚合 。 那么使用它的地方都必须是 聚合 语法。比如:

struct MaxSize {
   constexpr operator size_t() const { return size; }
   size_t size = 0;
};

constexpr MaxSize operator<<(MaxSize maxSize, size_t size) {
   return MaxSize{ std::max(maxSize.size, size) };
}

template <typename ... Ts>
struct Foo {
   enum { Max_Size = ( MaxSize{} << ... << MaxSize{sizeof(Ts)} ) };
};

这使用起来,麻烦一些,所以我们为 MaxSize 提供了 constexpr 构造函数。

如果参与 fold 演算的类型到值的映射,不像直接调用 sizeof 这么简单, 也可以使用模版变量来辅助:

template <typename ... Ts>
struct Foo {
   template <typename T>
   constexpr static size_t Size_Of  = std::is_base_of_v<Bar, T> ? sizeof(T) : 0;

   enum { Max_Size = ( MaxSize{} << ... << Size_Of<Ts> ) };
};

其算法是,只计算 Bar 子类的最大尺寸,其它类型则不参与计算。

运行时(而不是我们上述编译时例子)也可以使用 fold expression

struct Node {
  int value;
  Node* left;
  Node* right;
  Node(int i=0) : value(i), left(nullptr), right(nullptr) {}
  ...
};

auto left = &Node::left;
auto right = &Node::right;
// traverse tree, using fold expression:
template<typename T, typename... TP>
Node* traverse (T np, TP... paths) {
  return (np ->* ... ->* paths);
}

或者,我们想在输出对象的时候,在后面增加一个空格:

template<typename T>
class AddSpace {
   AddSpace(T const& r): ref(r) {}
   T const& ref;
};

auto operator<< (std::ostream& os, AddSpace<T> s) -> std::ostream& {
   return os << s.ref << ' '; // output passed argument and a space
}


template<typename... Args>
auto print (Args... args) {
   ( std::cout << ... << AddSpace(args) ) << '\n';
}

可以看出,C++17 推出的 fold expression ,极大的简化了程序员对于 变参 的计算。

但不幸的是,fold expression 只能计算 数值 ,而不能用来计算 类型 (前面例子中与类型有关的计算,也是要把 类型映射到数值)。

所以,我们必须得自己实现一个:

template
  < template<typename, typename> typename OP
  , typename                          ... Ts>
struct FoldR;

template
  < template<typename, typename> typename OP
  , typename                              ACC
  , typename                          ... Ts>
struct FoldR<F, ACC, Ts...> {
   using type = ACC; // 列表为空,返回计算结果 ACC
};

template
  < template<typename, typename> typename OP
  , typename                              ACC
  , typename                              H
  , typename                          ... Ts>
struct FoldR<OP, ACC, H, Ts...> {
   using type = typename OP<ACC, typename FoldR<OP, H, Ts...>::type>::type;
};

这是一个右折叠算法。其中 OP 即二元操作,ACC 代表 累计值Ts... 即输入列表。

其算法用伪代码描述,即为:

foldr                 : {A B : Set}  (A  B  B)  B  List A  B
foldr op acc []       =  acc
foldr op acc (x::xs)  =  x op (foldr op acc xs)

当然,从类型看,这个描述是计算值的,但无关紧要。

注意,FoldR 的主模版里是没有 ACC 的,这就意味着,主模版没有特别为初始值预留参数。但别担心,这样的实现,同时支持 有初始值和无初始值两种场景:

template
  < template <typename, typename> typename OP
  , typename                           ... Ts>
using FoldR_t = typename details::FoldR<OP, Ts...>::type;

template
  < template <typename, typename> typename OP
  , typename                               INIT
  , typename                           ... Ts>
using FoldR_Init_t = FoldR_t<OP, Ts..., INIT>;

也就是说,当你有初始值时,做为 右折叠 ,只需要将初始值追加到类型列表最后即可。

下面是 左折叠 的实现:

template
  < template<typename, typename> typename OP
  , typename                          ... Ts>
struct FoldL;

template
  < template<typename, typename> typename OP
  , typename                              ACC
  , typename                          ... Ts>
struct FoldL<OP, ACC, Ts...> {
   using type = ACC;
};

template
  < template<typename, typename> typename OP
  , typename                              ACC
  , typename                              H
  , typename                          ... Ts>
struct FoldL<OP, ACC, H, Ts...> {
   using type = typename FoldL<OP, typename OP<ACC, H>::type, Ts...>::type;
};

其算法用 Agda 代码描述如下:

foldr                 : {A B : Set} -> (A -> B -> B) -> B -> List A -> B
foldr op acc []       =  acc
foldr op acc (x::xs)  =  foldl op (op acc x) xs

注意, 左折叠 是一个 尾递归 (tail recursion) 算法,因而可以进行优化,但 右折叠 则不是,因为其必须层层递归,将 右边的结果得到之后,才可以和左边一起进行 OP 计算,这从递归本质上,无法优化。

同样, 左折叠 主模版也没有 ACC ,因为只需要在有 INIT 的情况,将 INIT 放在列表头即可:

template
  < template <typename, typename> typename OP
  , typename                           ... Ts>
using FoldL_t = typename details::FoldL<OP, Ts...>::type;

template
  < template <typename, typename> typename OP
  , typename                               INIT
  , typename                           ... Ts>
using FoldL_Init_t = FoldL_t<OP, INIT, Ts...>;

有了 Fold ,我们就可以进行这样的计算:

template<typename T1, typename T2>
struct Combine {
   struct type : private T1, private T2 {
     auto createAction(ID id) -> Action* {
        auto action = T1::createAction(id);
        return action == nullptr ? T2::createAction(tid) : action;
   };
};

using type = FoldL_t<Combine, Ts...>;

从而将一些列的 ActionCreator 折叠成一个 ActionCreator

9.2.6. Flatten

比如,有这样一个结构:

Seq<int, Seq<double, Seq<short, float>, char>, long>

我们希望将内部的 Seq 展开到外部的 Seq 里,变为:

Seq<int, double, short, float, char, long>

这就是经典的 list 里有 list ,里面的 list 里可以再有 list …… ,无论嵌套有多深,都可以通过 flatten 操作将其展开到最外层的 list 里。即将一个树状结构转化为一个平面结构。

稍加思考,我们就知道这是一个 fold 操作,即遍历整个列表,将每一个元素递归性的进行 flatten , 然后将得到到每个 list 不断连接,合成一个 list

我们知道 Fold 需要一个如下原型的 OP :

template <typename ACC, typename T>
struct OP;

即,将 T 和与前面的 ACC 进行某种计算,得到新的 ACC 做为结果。

而对于我们的 flatten 问题,传入的 T 需要 OP 将其展开成一个 Ts... (也可能只是 T ,如果其不是一个 List 的话)。

然后将得到的 T 或者 Ts... ,追加到 ACC 所代表的 Ts... 后面,得到一个新的 Ts...

这就意味着,ACC 自身要么是一个 Ts... ,要么 保存 了一个 Ts... ,否则,上述算法不可能实现。

template <typename ACC, typename T> struct OP 的原型看, ACC 自身肯定不是 Ts... ,所以它 只能 保存 一个 Ts... ,并且这个 Ts.. 还可以和另外一个 Ts... 进行合并。

问题是,怎么可能让一个 类型 (原型里 ACC 是一个 typename ,代表 ACC类型 ) 保存一个 Ts... ?我们之前早就讨论过, Ts... 是不可能直接指代的。

但我们也同样讨论过,模版具有 闭包 的性质,我们可以利用这个性质,结合 高阶模版 ,就可以保存一个 Ts... ,并且这个 Ts... 可以和其它的 Ts... 进行合并。这就是下面的定义:

template<typename ... Ts1>
struct Accumulator {
   template<typename ... Ts2>
   using type = Accumulator<Ts1..., Ts2...>;
};

外面的是一个 Accumulator ,是一个 高阶模版 ,其参数用来 保存 一个 Ts1... , 其返回的 type 是另外一个模版,其职责是将其环境中 Ts1... 和用户新传入的 Ts2... 进行连接, 得到一个新的 Accumulator ,这个结构是不是很漂亮?

另外一个新的问题是,我们如何区分一个类型是可展开的?而另外一些类型不可以?很简单,要求可展开的类型都继承这样一个类:

struct FlattenableSignature {};

template<typename ... Ts>
struct Flattenable : FlattenableSignature {
   template<template<typename ...> typename RESULT>
   using OutputAllTypesTo = RESULT<Ts...>;
};

其中 FlattenableSignature 是一个签名类,用于判别,而 Flattenable 也是高阶模版, 它让外界可以通过 OutputAllTypesTo ,以回调的方式得到它的 Ts...

另外,注意,无论是 FlattenableSignature ,还是高阶模版 Flattenable 都没有任何数据成员,也没有任何 虚函数或虚基类,所以继承它们不会增加任何子类自身的开销。

下面定义我们真正符合 Fold 要求的 ACC :

template<typename Accumulator, typename T, typename = void>
struct FlattenAcc {
   using type = typename Accumulator::template type<T>;
};

template<typename Accumulator, typename T>
struct FlattenAcc
   < Accumulator
   , T
   , std::enable_if_t < std::is_base_of_v < CUB_NS::FlattenableSignature, T>>> {
   using type = typename T::template OutputAllTypesTo<Accumulator::template type>;
};

FlattenAcc 模版的前两个参数,正是 FoldOP 原型所要求的两个参数,而第三个是在 C++20 之前 ( C++20 直接使用 concept 即可),不得不使用的 SFINAE 技术, 通过 std::is_base_of_v < CUB_NS::FlattenableSignature, T>> 来区分两种情况,而不得不额外引入的参数。

第一种情况, T 不是一个 Flattenable 的,那就直接将 T 合并到 Accumulator 里保存的 Ts... 里。

第二中情况,T 是一个 Flattenable 的,则将模版 Accumulator::template type 当作回调从 Flattenable 的高阶模版 OutputAllTypesTo 那里拿到其 Ts... ,而 Accumulator::template type 会将这两个 Ts... 进行衔接,并返回一个新的 Accumulator

而为了消除掉 FlattenAcc 那个额外的 void 参数:

template<typename ACC, typename T>
using FlattenAcc_t = FlattenAcc<ACC, T, void>;

这样我们就我们就可以直接调用 FoldL_Init_t :

using Acc = FoldL_Init_t<FlattenAcc_t, Accumulator<>, Ts...>;

其中, OPFlattenAcc_t ,由于初始时,结果列表为空, 所以 INITAccumulator<> 。 而结算的结果,是一个 Accumulator<Ts...> ,而为了取出其中的 Ts... ,我们故伎重演,给它增加 一个回调接口:

template<typename ... Ts>
struct Accumulator {
   template<typename ... NewTs>
   using type = Accumulator<Ts..., NewTs...>;

   // 取回 Ts... 的回调接口
   template<template<typename ...> typename RESULT>
   using output = RESULT<Ts...>;
};

然后,我们就可以将其输出到我们提供的回调模版 MyClass 上了:

template <typename ... Ts>
struct MyClass {
  // 回调之后,Ts ... 即 Flatten 之后的结果
  // 可以使用 Ts... 继续自己的计算
};

// result 是模版 MyClass ,被 Ts... 实例化后的类型
using result = typename Acc::template output<MyClass>;

9.3. pipeline

pipeline 事实上是函数的 compose 操作。其语意为:

(.) :: (b->c) -> (a->b) -> (a->c)
f . g = \x -> f (g x)

g 根据输入 x ,计算出的结果,做为 f 的输入,并计算出最终结果。(在 Haskell 语法里,使用 () 表示这是 一个中位符。)

pipeline 则是将一连串的函数,依此计算,前一个函数的计算结果,做为后一个函数的输入。对函数式编程而言, 这是一种强大的基本抽象:一个个函数,通过 compose 组合在一起,完成更为复杂的计算。

但是,并非所有函数都是只有一个输入,所以,如果想让任何具有至少一个参数的函数都能被 compose , 首先必须解决如何设置其它输入参数的问题(除了参与 pipeline 的那个参数)。一个设计良好的函数式编程语言,会支持 curry 。 事实上,对于 haskell 这样的语言,其函数天然是 curry 的。对于一个 f :: a -> b -> c -> d 的函数,由于箭头是右结合 的,所以其语意是 f :: a -> (b -> (c -> d)) ,即,你给出参数 a ,求值结果是另外一个类型为 b -> (c -> d) 的函数; 而你继续给出参数 b ,则求值结果为类型为 c->d 的函数。

这就是给编程带来极大的便利,比如:

sum . takeWhile (<10000) . filter odd . map (^2) $ [1..]

首先产生一个无穷列表( $ 的优先级高于 . ),然后这个无穷列表被传递给左边的整个 compose 之后的组合。

从右向左,map (^2)filter oddtakeWhile (<10000) ,每一个函数都给定了其它参数。最后,计算 sum

因而,如果想让 C++ 泛型计算支持 pipeline 首先要解决是 curry 的问题。

9.3.1. Curry

一个最简单的 curry 实现如下:

template <template <typename ...> F, typename ... ARGS>
struct Curry {
    template <typename ... MORE>
    using apply = F<ARGS..., MORE...>;
};

其背后的思想是:给定一个函数 F ,同时给出其前面的一部分参数,然后返回另外一个函数 apply ,其参数是 MORE

这样的实现,无法达到 haskell 每给出一个参数,估值结果还是一个函数的效果。因为这个实现只允许一次给出前面的参数,然后得到一个函数。 如果你想继续给出一部分参数,就必须再次 Curry 。比如:

template <typename T1, typename T2, typename T3>
struct Foo {...};

Curry<Foo, int>::apply<double, char>; // Foo<int, double, char>

Curry<Curry<Foo, int>::apply, double>::apply<char>; // Foo<int, double, char>

注意,我们的 Curry 实现里,其输入参数 F ,其参数列表是 typename ... ,这样的原型可以匹配任意参数列表全部是 类型 的模版。 比如:

template <typename> struct S1;
template <typename, typename> struct S2;
template <typename, typename ...> struct S1_N;
template <typename ...> struct S0_N;

Curry<S1>   // OK
Curry<S2>   // OK
Curry<S1_N> // OK
Curry<S0_N> // OK

但是,如果你的模版参数里包含有非类型参数,比如 int 或者 模版 ,则会导致编译失败。比如:

template <template <typename> typename> struct S1;
template <typename, int> struct S2;
template <auto, typename ...> struct S3;
template <int> struct S4;

Curry<S1>   // Fail
Curry<S2>   // Fail
Curry<S3>   // Fail
Curry<S4>   // Fail

接着回到我们的 Curry 。之前那个实现,不仅过于简单,甚至是不健壮的。比如,我们有如下定义:

template<typename T1, typename T2>
struct Foo {};

template<typename T1, typename T2>
using Foo_t = Foo<T1, T2>;

此时,如果我们对 Foo_t 使用我们的 Curry

Curry<Foo_t, int>::apply<double> obj;

将会出现编译错误。

原因是,我们在使用别名模版时,对一个固定参数的 别名模版 ,在 Curry 里给其传入的变参。无论是 G++ 还是 clang 编译器 都不允许这么做。除非将 别名模版 也设计为变参。

这种检查发生在语意分析阶段。任何违背这种约束的代码都会被编译器查出。比如:

template<typename T1, typename T2>
using Foo_t = Foo<T1, T2>;

template <typename ... Ts>
using type = Foo<Ts...>;  // 错误。因为Foo_t是定长参数别名模版

template <typename ... Ts>
struct Class {
   using type = Foo<Ts...>; // 错误。原因同上
};

不过,这样的限制仅仅是针对 别名模版 ,对于类模版,则完全没有这类限制。对于任何固定参数的类模版,你总是可以用一个变参包去实例化它。比如:

template<typename T1, typename T2> struct Foo {};

template <typename ... Ts>
using Foo_t = Foo<Ts...>; // Foo 定义为固定参数,但却可以给它传递一个变参包;

编译器会根据包实际展开之后,再检查是否符合类模版的需要。如果不匹配,则类型替换失败。

对于类模版,正是因为其对变参包实例化时才展开检查的性质。 C++ 的变参模版灵活到了失去接口约束的作用,比如:

template <template <typename> F>
struct Service { ... };

template <typename ... Ts>
struct Client1 {...};

Service<Client1>  c;

这样的接口是满足正常的约束的。因为 Service 要求任何 Client 都提供能通过单参数回调的函数。但 Client 提供了 一个变参,可以支持任意参数数量。这当然满足 Service 的要求。

但是,对于这种情况:

template <template <typename...> F>
struct Service { ... };

template <typename T>
struct Client1 {...};

Service<Client1>  c;

从设计语意上,Service 要求任何 Client 都必须提供一个可变参数的函数 F ,但 Client1 竟然只提供了单参数的版本。 这很明显违背了契约。 但 C++ 对于这种情况竟然也是允许的。

C++11 提案过程中,本来这种情况是不允许的。但后来发现提供这样的灵活度,可以给其它设计带来很大便利。比如:

template<template <typename ...> typename C, typename ... U>
struct Foo {
     using Container = C<U...>;
};

Foo<std::pair, int, double>::Container c; // std::pair<int, double>

所以, 变参模版,可以匹配属于同一类别(比如都是值或者类型)的任意模版。而最终的正确性,则在实例化时再进行检查。

我们之前的 Curry 实现太简单,并且会因为固定参数的别名模版而导致错误。如果我们想追求可以多批次提供参数, 而不想 curry 多次;并且要避免别名模版错误,则需要更加复杂的实现:

template <template <typename ...> typename F, typename ... ARGS>
struct Curry {
  template <typename ... MORE>
  using apply = Curry<F, ARGS..., MORE...>;
};

template <template <typename ...> typename F, typename ... ARGS>
requires requires { typename F<ARGS...>; }
struct Curry<F, ARGS...> {
   using type = F<ARGS...>;
};

这其中,使用了一个 concept ,来查看 F<ARGS...> 是否已经是一个类型(即其参数已经足够),如果是,则直接返回类型; 否则,返回一个函数 apply ,当调用 apply 时,如果继续给出一部分参数,但对于 F 依然不够,则 继续返回一个 Curry

然后,就可以这样使用:

Curry<Foo, int>::apply<double>::apply<char>::type; // Foo<int, double, char>
Curry<Foo, int, double>::apply<char>::type;        // Foo<int, double, char>
Curry<Foo, int>::apply<double, char>::type;        // Foo<int, double, char>

这个实现,也避免了之前实现的 别名模版 问题。你一定会感到奇怪,对于这个版本的第二个特化:

template <template <typename ...> typename F, typename ... ARGS>
requires requires { typename F<ARGS...>; }
struct Curry<F, ARGS...> {
   using type = F<ARGS...>;
};

如果传入的 F 是一个定长参数 别名模版 ,里面不同样在给它传递变参吗?

这其中的差别在于,对于表达式 F<ARGS...> ,在模版实例化之前,编译器无从得知 F 是一个定长参数 别名模版 。 因而,在模版的第一阶段检查中(未实例化之前的检查),无论从语法还是语意,这个表达式都没有任何问题。

随后,我们我们给出这样一个表达式: Curry<Foo_t, int, double> , 在实例化的过程中, F 被替换为 Foo_t , 而 ARGS... 则被替换为 int, double ;此时,虽然编译器 已经知道 Foo_t 是一个定长参数 别名模版 ,但用 ARGS... 此时已经不再是变参,而是非常具体的 int, double 。 因而,此时编译器只需要检查 Foo_t<int, double> 是否是一个合法的表达式即可。

而我们之前的版本:

template <template <typename ...> F, typename ... ARGS>
struct Curry {
    template <typename ... MORE>
    using apply = F<ARGS..., MORE...>;
};

在给出表达式 Curry<Foo_t, int, double> 之后,编译器同样进入替换阶段。将 F 替换为 Foo_t ,将 ARGS... 替换 为 int, double ,然后我们就得到了表达式:

struct named_mangled_Curry {
    template <typename ... MORE>
    using apply = Foo_t<int, double, MORE...>;
};

对于 apply 模版,此时再次进行语意检查,发现定长参数 别名模版 Foo_t 需要传递一个变参包 MORE... ,这当然 是一种语意错误。编译失败。

新版本的 Curry 避免了 别名模版 错误,但却依然有一个未解决的问题。如果用户直接( Curry 时)或间接 ( apply 时)给出了超出 Foo_t 所需要的参数,则特化版本将永远也不可能被选择,只能选择主模版:

template <template <typename ...> typename F, typename ... ARGS>
struct Curry {
  template <typename ... MORE>
  using apply = Curry<F, ARGS..., MORE...>;
};

而这个模版,将永远也无法真正调用到 F ,只能被困在虚幻的 Curry 世界里打转。

究其原因,是两种写法都没有解决关键的问题, 传入的 F 究竟有几个参数?

在一个变参模版内部,计算一个变参包里的参数数量是非常简单的: sizeof...(Ts) 即可。 但是对于传入的、可以 匹配 template <typename ...> typename F 的模版,其参数个数可以是任意数目,比如:

template <typename T> struct C1;                                 // 1个参数
template <typename T1, typename T2> struct C2;                   // 2个参数
template <typename T1, typename ... Ts> struct C1s;              // 至少1个参数
template <typename T1, typename T2, typename ... Ts> struct C2s; // 至少2个参数
template <typename ... Ts> struct Cs;                            // 任意多个参数

它们都可以匹配 F ; 并且没有任何直接的办法,站在 F 的角度求一个传入的模版的参数个数。

或许第一个跳到你脑海里的方法是模版萃取:

template<template<typename> typename>
struct TemplateTrait {
   constexpr static size_t num = 1;
};

template<>
struct TemplateTrait<template<typename, typename > typename> {
  constexpr static size_t num = 2;
}

或者:

template< template<typename> typename >
struct TemplateTrait {
   constexpr static size_t num = 1;
};

template < template< typename, typename > typename C>
struct TemplateTrait {
  constexpr static size_t num = 2;
}

很不幸,C++ 规定,模版的 模版参数 不能用来做特化。而对于 类模版 ,不同的模版头,不允许存在两个主模版。

而高度灵活的函数模版这时候成了救世主。因为函数的重载非常灵活:两个同名函数可以除了名字一样,其它都不一样。 比如,参数个数,参数类型。如果是模版的话,模版的参数列表也可以完全不同。所以,我们可以定义两个同名函数:

template <template<typename> typename>
auto DeduceArgs() -> Value<1>;

template <template<typename, typename> typename>
auto DeduceArgs() -> Value<2>;

然后,

template <typename T> struct C1;                   // 1个参数
template <typename T1, typename T2> struct C2;     // 2个参数

decltype(DeduceArgs<C1>())    // Value<1>
decltype(DeduceArgs<C2>())    // Value<2>

So far so good。但我们忘了一种重要的情况:变参。

template <typename T1, typename ... Ts> struct C1s;              // 至少1个参数
template <typename ... Ts> struct Cs;                            // 任意多个参数

decltype(DeduceArgs<C1s>())    // 二义性
decltype(DeduceArgs<Cs>())     // 二义性

正如我们之前所讨论的,这两个变参模版均可以匹配上面的两个 DeduceTemplate 函数。

你或许会想,是否可以补充一个变参版本,让它可以匹配变参场景,比如:

template <template<typename> typename>
auto DeduceArgs() -> Value<1>;

template <template<typename, typename> typename>
auto DeduceArgs() -> Value<2>;

template <template <typename...> typename>
auto DeduceArgs() -> Variadic; // 专门匹配变参

很不幸,这只会让情况变得更糟。因为变参版本可以匹配一切。有了它,所有的匹配都会出现二义性。

另外,需要特别强调的是,这几个函数都是主模版,不存在特化关系。并且,对于模版的 模版参数 ,是无法通过特化方式来进行区分的。所以, 我们根本不需要尝试特化方案。

所以,现在核心的问题是:如何让函数模版能够在变参模版万能匹配的情况下,能够区分出定长参数和变参?

下面出场的就是 C++ 社区著名的惯用法 : tag dispatch

我们先定义两个 tag class

struct variadic_tag {};
struct fixed_tag : variadic_tag {};

你应该已经注意到,它们其中一个继承自另外一个。

然后,我们再定义两个重载函数,来使用这两个 tag :

template<template<typename...> typename F>
auto DeduceTemplateArgs(variadic_tag) -> Variadic;

template<template<typename...> typename F>
auto DeduceTemplateArgs(fixed_tag)    -> decltype(DeduceArgs<F>());

现在,如果我们给出这样的表达式: DeduceTemplateArgs<C1>(fixed_tag{}) ,将会导致所有 候选函数 的的类型替换 (将 F 替换为 C1 ),因而,第 2 个候选函数的返回类型变为 decltype<DeduceArgs<C1>()> ;而其中的 表达式 DeduceArgs<C1>() 则会进一步触发 DeduceArgs重载决议 (因为有多个候选);而决议的结果找到了最佳 匹配版本:

template <template<typename> typename>
auto DeduceArgs() -> Value<1>;

而其返回值类型为 Value<1> ,所以 decltype 的结果也是 Value<1>

做为最为匹配的版本,DeduceTemplateArgs<C1>(fixed_tag) -> Value<1> 是最终的选择。 因而 decltype(DeduceTemplateArgs<C1>(fixed_tag{})) 的返回类型正是我们所需要的 Value<1>

如果此时我们提供一个变参模版 Cs ,那么表达式 ``DeduceTemplateArgs<Cs>(fixed_tag{}) 同样会触发上述过程; 但 DeduceArgs<Cs>() 会因为二义性而决议失败,进而导致 decltype(DeduceArgs<Cs>()) 失败,最终 导致 DeduceTemplateArgs(fixed_tag) 从候选集中被排除出去。

此时,就只剩下 DeduceTemplateArgs(variadic_tag) 一个版本,但因为 fixed_tagvariadic_tag 之间的 继承关系,所以这个版本也是匹配的,并且是现在候选集合中唯一的版本。所以,最终推演的结果是 Variadic ,代表可变参数。

这里特别说明一下 fixed_tagvariadic_tag ,我们之所以在 表达式 DeduceTemplateArgs<Cs>(fixed_tag{}) 里明确指明 fixed_tag ,是因为我们希望优先使用第二个版本(在两个 版本都匹配的情况下,fixed_tag 更为匹配);只有在第二个版本失败的情况下,第一个版本才会得到选择。

在能够得知一个模版的参数个数的情况下,实现一个正确版本的 Curry 就变为可能。无论你选择上面的那种 Curry 实现, 都可以结合参数个数信息,给出一个健壮的实现。在次就不再赘述。

9.4. Compose

Compose 的实现就简单很多。如下:

template <CallableConcept ... OPs>
struct Compose;

template <CallableConcept LAST>
struct Compose<LAST> : LAST {};

template <CallableConcept H, CallableConcept ... OPs>
struct Compose<H, OPs...> {
   template <typename INPUT>
   using apply = typename Compose<OPs...>::template apply<typename H::template apply<INPUT>>;
};

CallableConcept 要求每一个参与组合的 OP 都必须提供一个 apply 函数。之所以产生这样的约束, 因为一则模版参数不能以变参的方式表达传递多个模版,而只能传递类型或值的变参包。二则,一个 Curry 的结果类型自身 就是一个带有 apply 函数的类型。

compose 内部的算法,则非常直接,首先让 H 进行计算 typename H::template apply<INPUT> ,其输出 做为 Compose<OPs...>::template apply 的输入。

haskell 版本不同的是,其 compose 是从右向左,这符合数学函数表达习惯。而我们的实现是从左向右,这符合程序员 对于 pipeline 的认知。

然后,我们将我们对 list 的各种操作进行 Curry ,并用宏让一切看起来更简洁:

#define take(n)       Curry<Take, Value<n>>
#define drop(n)       Curry<Drop, Value<n>>
#define filter(f)     Curry<Filter, Value<f>>
#define sort(f)       Curry<Sort, Value<f>>
#define transform(f)  Curry<Transform, Value<f>>

之所以,有 Value 就是因为 Curry 要求一个模版的参数必须都是类型 (因为 C++ 要求一个变参包里必须是同一种 kind )。

然后我们就可以在 pipeline 中直接使用它们:

Pipeline<List<1,2,3,4,5,6,7>,
   take(5),
   filter([](int n){ return n % 2 > 0; }),
   transform([](int n) { return n + 10; })>;

9.5. 延迟估值

C++ 的泛型天然就是延迟估值的,因而你可以构造一个无穷列表:

template <typename T, T N, T STEP = 1>
struct InfiniteValueList {
   constexpr static T Head = N;
   using Tail = InfiniteValueList<T, N + STEP, STEP>;
};

template <auto V>
struct RepeatValueList {
   constexpr static auto Head = V;
   using Tail = RepeatValueList<V>;
};

你不用担心这样的结构定义会无穷递归下去而导致编译器崩溃。并且你可以安全的写如下算法的代码:

Pipeline
  < InfiniteValueList<int, 1, 2>
  , Drop<2>
  , Take<5>>
  // 结果是 5, 7, 9, 11, 13

Pipeline
  < TypeList<int, double, char>
  , ZipWith<RepeatValueList<5>>>
  // 结果是 [(int, 5), (double, 5), (char, 5)]

9.6. Optional

Optional 是一个存在非常广泛的语意。比如,指针空与非空,非法值与合法值,存在与不存在 … 等等;在 Haskell 语言里, 这种概念被称做 Maybe

而在类型的世界里,则可以将 void 看作 None (或 Nothing ), void 和其它类型一样,本身也是一个类型, 但其值域为空。也就是说,你无法用它实例化任何数据。

所以,在对类型进行,transform , filter , 或者 fold 操作时,你总是可以用 void 当作 None 语意。比如:

template <typename T, typename = void>
struct ActionTrait {
   using type = void;
};

template <typename T>
struct ActionTrait<T, std::enabled_if_t<std::is_base_class_v<Action, T>>> {
   using type = T;
};

其语意是,一个类型如果是 Action 的子类,返回的则是 Maybe<T> ,否则返回 Nothing

template <typename ... Ts>
struct Bar {
   // Ts... 里,全是Action的子类
};

typename Pipeline
         < TypeList<Ts...>
          , transform(ActionTrait)
          , filter(Maybe)
          >::template exportTo<Bar>;

甚至,你可以将对于 void 的过滤操作自动内嵌到操作中,比如, Transform 可以提供一个版本, 自动抛弃掉结果为 void 的类型;或者,在 Fold 时,自动跳过是 void 的类型。但这都是优化的事情,即便不提供, 由于有了 Filter ,也都可以完成计算。

重要

  • C++ 泛型计算,是完全函数式的;

  • C++ 泛型,即可以计算数值,也可以计算类型,而两者都是 图灵完备 的;

  • C++ 泛型计算是 lazy 的;

  • 模式匹配 用来进行路径选择(辅助以 SFINAECONCEPT ), 递归 用来解决 循环 问题;

  • 模版 是泛型计算的一等公民:对于 高阶模版 的支持,及 闭包 性质,可以将其理解为泛型计算时的 lambda