C++模板元编程详细教程(之八)

前序文章请看:
C++模板元编程详细教程(之一)
C++模板元编程详细教程(之二)
C++模板元编程详细教程(之三)
C++模板元编程详细教程(之四)
C++模板元编程详细教程(之五)
C++模板元编程详细教程(之六)
C++模板元编程详细教程(之七)

实现一个动态的get

如果读者对元组工具,也就是std::tuple很熟悉的话,那么对std::get也一定不陌生。我们可以通过std::get(tu)的方式把原组的第N个元素取出来。

但是,不知道大家有没有思考过这样一个问题,std::get是静态模板工具,所以参数N必须要编译期确定。那为什么STL不提供一个动态序号获取元组元素的能力呢?也就是说类似tu.get(n),其中n是函数参数(运行期数据)。最主要的原因就在于类型不确定,假如说我们真的要给tuple提供一个get方法,那么方法的返回值是不确定的,于是这就成了一个RTTI(Run-Time Type Identification)方法。而STL是模板库,并不提供任何RTTI行为,自然也就不会提供类似的方法。

但假如,我们在实际开发中,针对某个元组,可以确定(或可以正确处理)它的类型,在这种情况下希望可以提供一个动态的get工具应当如何来做?举例来说:

class Base {
 public:
  virtual void f() const = 0;
};

class Ch1 : public Base {
 public:
  void f() const override {}
};

class Ch2 : public Base {
 public:
  void f() const override {}
};

// 利用多态调用f方法
template <typename... Args>
std::enable_if_t<
	std::conjunction_v<
			std::is_base_of<Base, Args>...
	>, void> 
InvokeF(const std::tuple<Args...> &tup, size_t index) {
  // 考虑这里应该怎么写
}

void Demo() {
  // 一个元组,里面都是Base的子类对象
  std::tuple tu{Ch1{}, Ch2{}, Ch1{}};
  InvokeF(tu, 1); // 调用Ch2的f方法,注意这个参数1是运行期数据
}

解释一下上面例程,Ch1Ch2都是Base类的子类,InvokeF函数是传入一个元组tup和一个序号index,我们要把tup的第index个元素拿出来,去调用它的f方法,这里要求tup的元素都必须是Base的子类。

现在问题就在于,index是一个运行期数据,我们用std::get肯定是不可以的,那怎么办?只能采用编译期展开的方式。

具体来说就是,由于我们这里的元组类型是编译期确定的,那么对应的变参数量也就是确定的(比如上面例程中就是3个),那么,我们就在编译期生成分别应对假如index就是这种情况,应当怎么去做。

以上面例程为例,由于编译期不知道index可能是几,但针对std::tuple这种类型的元组来说,index只能是012这3种情况时才可以有合法处理。所以,我们就分别写出当index012时,应当做的处理。这个就叫做「编译期展开」,也就是在编译期枚举出运行时可能传入的所有数据,分别生成对应的代码指令,然后当运行期数据确定的时候去执行对应的指令。

如果手动来写,就应该写成:

template <typename... Args>
void InvokeF_0(const std::tuple<Args...> &tu) {
  std::get<0>(tu).f();
}

template <typename... Args>
void InvokeF_1(const std::tuple<Args...> &tu) {
  std::get<1>(tu).f();
}

template <typename... Args>
void InvokeF_2(const std::tuple<Args...> &tu) {
  std::get<2>(tu).f();
}

template <typename... Args>
void InvokeF(const std::tuple<Args...> &tu, size_t index) {
  if (index == 0) {
    InvokeF_0(tu);
  } else if (index == 1) {
  	InvokeF_1(tu);
  } else if (index == 2) {
  	InvokeF_2(tu);
  }
}

但这是我们假定元组是3个元素的情况。可实际场景下,元组的元素个数是不确定的,如何利用模板来生成呢?思路就是,逐个尝试,递归生成函数。请看代码:

// 辅助工具,一个用于尝试匹配的递归模板
template <int N, typename... Args>
void TryInvokeF(const std::tuple<Args...> &tup, size_t index) {
  if constexpr (N < sizeof...(Args)) { // 递归终止条件(静态的)
    // 尝试index是不是本次递归的
    if (index == N) {
      std::get<N>(tup).f(); // 如果符合,就取出元素,并调用f
      return;
    }
    // 如果不符合,就生成下一个模板实例,再去判断是否符合
    TryInvokeF<N + 1, Args...>(tup, index); // !!这一行是精华所在!!
  }
}

// 利用多态调用f方法
template <typename... Args>
std::enable_if_t<
  std::conjunction_v<
      std::is_base_of<Base, Args>...
  >, void>
InvokeF(const std::tuple<Args...> &tup, size_t index) {
  // 如果index不在合法范围中,直接按照异常处理
  if (index >= sizeof...(Args)) { // sizeof...是静态工具,用于获取变参个数
    return;
  }
  TryInvokeF<0, Args...>(tup, index); // 从0开始尝试
}

void Demo() {
  // 一个元组,里面都是Base的子类对象
  std::tuple tu{Ch1{}, Ch2{}, Ch1{}};
  InvokeF(tu, 1); // 调用Ch2的f方法,注意这个参数1是运行期数据
}

这里提供了一个辅助工具TryInvokeF,其中的if constexpr语句用于做编译期静态数据的判断,不符合条件的将不会生成代码。所以,这里我们要求N不可以大于等于变参个数。对于上面例子来说,N只能小于3,而在InvokeF中调用了TryInvokeF<0, Args...>函数,因此模板实例化时会生成N0时的情况,而TryInvokeF<0, Args...>中调用了TryInvokeF<1, Args...>,所以还会继续实例化。里面又调用了TryInvokeF<2, Args...>,所以还会实例化。直至TryInvokeF<2, Args...>中调用了TryInvokeF<3, Args...>,而TryInvokeF<3, Args...>里由于不满足if constexpr的条件,所以到此为止。

也就是说,对于std::tuple这个元组来说,编译器生成了TryInvokeF<0, Ch1, Ch2, Ch1>TryInvokeF<1, Ch1, Ch2, Ch1>TryInvokeF<2, Ch1, Ch2, Ch1>TryInvokeF<3, Ch1, Ch2, Ch1>这4个函数实例。而最后一个TryInvokeF<3, Ch1, Ch2, Ch1>是空的,前面的3个内部调用了后面的那个。

到了运行期,根据index值的不同,会调用不同深度的函数,当发现Nindex值相等时,递归结束。

请读者仔细体会模板元编程当中的「递归」,这里的递归并不是运行时的函数栈递归,而是模板实例化时的递归实例化,根据一个静态数值,递归实例化出了若干个函数。而进入运行期后,其实就不能算真正意义上的递归调用了(因为并不是在一个函数里自己调自己,而是分别链式的去调用实例化后的若干函数。)请读者仔细体会二者的区别。

展开元组

如果我们想实现一个「调用」对象的方法,这个对象可能是函数、函数指针、仿函数实例、函数对象、lambda等,总之是一个可调用的对象。要怎么办?其实非常简单,直接透传参数即可:

template <typename T, typename... Args>
decltype(auto) invoke(T &&obj, Args&&... args) {
  return obj(std::forward<Args>(args)...);
}
 
// 以下是验证Demo
void f(int a) {
  std::cout << a << std::endl;
}

void Demo() {
  invoke(f, 4); // 函数
  invoke(&f, 5); // 函数指针
  int s = invoke([](int a, int b){return a + b;}, 5, 6); // lambda
  int r = invoke(std::greater<int>{}, 6, 7); // 仿函数
  std::function<void(int)> t{f};
  invoke(t, 2); // 函数对象
}

上面展示的也是std::invoke的一个简化版实现(暂时没有考虑到非静态成员函数的问题),但假如,需要传入函数的参数并不是直接填入的,而是保存在一个元组中,那怎么做呢?

void f(int a, double b) {}

void Demo() {
  std::tuple arg{1, 3.5}; // 参数在元组中
  apply(f, arg); // 用元组调用函数,这个功能如何实现?
}

那我们就需要把元组展开,依次填写到invoke的参数中。可是,元组只能通过std::get的方式取出其中的数据,也就是说,我们需要依次使用std::get<0>std::get<1>std::get<2>……去操作元组,拿出所有的数据,然后依次填写到invoke的参数中。对于上例来说应该是

template <typename F, typename Arg0, typename Arg1>
decltype(auto) apply(F &&func, std::tuple<Arg0, Arg1> &&tup) {
  return invoke(func, std::get<0>(tup), std::get<1>(tup));
}

由此可以观察到,如果我们能得到一个静态的序列{0, 1, 2, 3, ...},那么这里就可以按照这个序列来展开,也就是说:

template <typename F, typename Tup, size_t... Index>
decltype(auto) apply(F &&func, Tup &&tup) {
  return invoke(func, std::get<Index>(tup)...); // 通过Index来展开
}

现在只要这个Index能够正确地按照{0, 1, 2, 3, ...}的方式填写进去,我们的问题就解决了。可是,我们总不能要求使用者手动去填写把?

void test(int a, double b, char c) {}

void Demo() {
  std::tuple arg{1, 3.5, 'A'}; // 参数在元组中
  apply<void(*)(int, double, char), 0, 1, 2>(test, arg); // 这不太扯了?
}

所以,现在就需要一个工具,能够根据元组的元素个数,自动生成一个序列,从0开始,一直到N - 1。因此,我们需要构造一个辅助工具,用于 把N变成0, 1, 2, ..., N - 2, N - 1这样的序列。代码如下:

// 序列类,类型本身无运行意义,仅用于静态处理,只会用到它的模板参数
template <size_t... Index>
struct sequence {};

// 用于生成序列(递归大法好)
template <size_t N, size_t... Index>
struct make_sequence : make_sequence<N - 1, N - 1, Index...> {}; // 注意这里的写法,是这个功能的重点

// 递归终点(注意不能无限递归下去,到0就要截止了)
template <size_t... Index>
struct make_sequence<0, Index...> {
  // 这个时候序列已经生成了,所以把这个序列交给sequence
  using result = sequence<Index...>;
};

现在来解释一下上面是如何生成序列的。在make_sequence中,第一个参数N表示「还剩几个数没有进入序列」,而后面的Index就是现在已经产生的序列。举个例子来说,make_sequence<3>,现在N3,表示还有3个数要处理,而后面的Index是空的,所以这是初始状态。根据代码继承的写法(递归),make_sequence<3>应当继承自make_sequence<2, 2>

对于make_sequence<2, 2>来说,还有2个数字要处理,已经处理完的序列是2,再根据继承写法进行递归,它继承自make_sequence<1, 1, 2>。同理,make_sequence<1, 1, 2>继承自make_sequence<0, 0, 1, 2>。这时,由于N变为0了,所以命中了下面的偏特化(递归结束),在make_sequence<0, 0, 1, 2>中,成员result被定义为 sequence<0, 1, 2>,这就得到了我们想要的序列。

那么这仍然是一个模板元编程中很独特的用法,虽然我们用到了继承的写法,但这里其实跟OOP中的继承完全没有关系,这些工具类也不会实例化出现在运行期,我们仅仅是通过这种方式来生成一个序列而已。请读者一定要体会这种模板元编程的「feel」。

当已经拥有这个序列以后,就好办了,在apply函数中,用它进行std::get展开即可,下面是完整代码:

// 序列类,类型本身无运行意义,仅用于静态处理,只会用到它的模板参数
template <size_t... Index>
struct sequence {};

// 用于生成序列
template <size_t N, size_t... Index>
struct make_sequence : make_sequence<N - 1, N - 1, Index...> {};
// 递归终点
template <size_t... Index>
struct make_sequence<0, Index...> {
  // 这个时候序列已经生成了,所以把这个序列交给sequence
  using result = sequence<Index...>;
};

template <typename F, typename Tup, size_t... Index>
decltype(auto) apply_detail(F &&func, Tup &&tup, sequence<Index...> &&) {
  return std::invoke(func, std::get<Index>(tup)...); // 通过Index来展开
}

template <typename F, typename Tup>
decltype(auto) apply(F &&func, Tup &&tup) {
  return apply_detail(func, tup, typename make_sequence<std::tuple_size_v<std::decay_t<Tup>>>::result{});
}

// 测试Demo
void f(int, double) {}

void Demo() {
  std::tuple tu{1, 3.4};
  apply(f, tu);
}

这里的另一个要点在于,为了让apply_detail函数能自动生成Index,我们利用了模板的实例化自动推导,根据传入的第三个参数(序列)来自动推导模板参数。所以在apply函数把生成的序列通过参数传递了进去。但这里由于辅助类都没有任何数据,也没有自定义的构造、析构函数,因此编译器并不会生成额外的汇编指令,大家可以放心去使用。

小结

本章介绍了模板元编程中一个非常重要的技巧——序列的生成。希望读者可以通过这个例子,对模板元编程的方式有一个更深入的体会。

后续还会介绍模板元编程的其他实例和技巧。
C++模板元编程详细教程(之九)

你可能感兴趣的:(C++代码,c++)