【元编程】C++ Parameter Pack 从编译期循环到编译期判断质数

2024-01-03 00:37:04

写在前面

本来是想在网上找一下这个问题:

利用C++实现编译期确定1~100内的质数。

但是并没有找到很好的教程,于是打算自己从编译期循环一步步做一个简单的实现。

本文将包含以下内容:

  • Parameter Pack基础用法;
  • 利用Parameter Pack实现一些常用的方法;
  • 编译期与运行期;
  • 利用Parameter Pack实现编译期循环;
  • 编译期判断质数;

C++核心特性 Parameter Pack

Parameter PackC++11引入的模板新特性,其可以用来接收任意数量(在栈内存允许的情况下)的相同或者不同类型的参数。

其基本语法如下:

template<class ...Args>
void test(Args ...args) {}

test();
test(1); // test(int);
test(1, 2, 3); // test(int, int, int);
test(0, 1.0, 'f', "OK"); // test(int, double, const char *);

上面的所有调用都是可行的,除了上述的基础用法,还可以将Parameter Pack和普通的模板参数一同使用,不过这时需要保证Parameter Pack是最后一个参数:

template<class T0, class ...Args>
void test(T0 t0, Args ...args) {}

// test() ill-formed t0 cannot be empty.
test(1); // T0 is int; Args... is empty.
test(0, 1, 1.0, "f"); // T0 is int, Args... are double and const char *;

Parameter Pack展开

Parameter Pack的使用往往需要通过展开来实现。

基础展开

通过...即可对参数类型或者参数进行展开:

// Provided calling test(int, double);
template<class ...Args>
void test(Args ...args) {
	// Args... -> int, double
	// args... -> arg0, arg1
	std::tuple<Args...>(args...);
}

掌握了上述的展开方式之后,可以更改...之前的部分进行不同的展开方式:

// Provided call test(int, double)
// Args*... -> int*, double*
// &args... -> &arg0, &arg1
// ++args...-> ++arg0, ++arg1
// (std::cout << args)... -> std::cout << arg0, std::cout << arg1
// std::forward<Args>(args)... -> std::forward<int>(arg0), std::forward<double>(arg1)

注意:上面的展开结尾均没有分号。

利用Parameter Pack实现一些常用的方法

利用Parameter Pack实现任意个数求最大值

这里只介绍求最大值的方法,求最小值、和、乘积等稍作修改即可实现:

// this need to be corrected.
template<class T0, class ...Args>
auto max(T0 t0, Args ...args)
{
    auto res = max(args...);
    return t0 > res ? t0 : res;
}

作为刚学会Parameter Pack的你可能会写出上面的代码,上面的代码不能够生成,这是因为上述的代码在递归的最后一层会调用max(),而我们并没有定义这个函数。

补充:上述的代码发生了递归调用,对于max(int, int, int)上面的代码会依次调用max(int, int), max(int), max()由于没有max()函数所以上述的代码在实例化的时候会发生错误。

解决方法很简单既然没有max()我们增加一个即可,但是增加的max()返回值应该是什么呢?你可能认为是-inf,但是这样并不具有一般性,对于重载了<符号的类来说,如果返回一个整数可能并不能够进行比较。其实我们可以增加一个只有一个模板参数的实现:

template<class T>
T max(T t) { return t; }

这样也可行的原因是:在进行模板匹配的时候,对于单个参数的情况,不使用Parameter Pack的匹配度更高,此时编译器会调用不使用Parameter Pack的模板实现,因此也就不会发生调用max()的情况了。

那么类似的,把上面的代码的max改为min同时将>改成<便可以实现多个变量中找出最小值的代码,求和、连乘等也很容易可以写出。

上面的代码除了使用递归实现,还可以通过非递归的方式实现:

template<class ...Args>
auto max(Args ...args) {
    using T = typename std::common_type<Args...>::type;
	T t[sizeof...(Args)] = {args...};
    T res = t[0];
    for (int i = 1; i < sizeof...(Args); i++) {
        if (t[i] > res) {
            res = t[i];
        }
    }
    return res;
}

上面的代码中sizeof...(Args)可以获取Args中有多少个参数。

编译期与运行期

在通常情况下非递归的代码效率可能会比递归的代码效率更高。但是在模板展开中可以发生意外,例如这里给出递归和非递归的多个数求和实现:

template<class T>
constexpr T sum(T t) { return t; }

template<class T0, class ...Args>
constexpr auto sum(T0 t0, Args ...args)
{
    return t0 + sum(args...);
}

template<class ...Args>
auto sum(Args ...args)
{
    using T = typename std::common_type<Args...>::type;
    T t[sizeof...(Args)] = {args...};
    T sum = t[0];
    for (int i = 1; i < sizeof...(Args); i++) { sum += t[i]; }
    return sum;
}

如果依然按照上面实现max的方式通过一个数组来对变量求和的话,那么非递归版的效率可能不如递归版本,这主要有两个原因:

  1. 对于模板函数,默认是具有inline属性的;
  2. 对于递归的方法中,如果传入的参数均是编译期常量,那么编译器可能在完成编译的时候,就计算出了max的返回值,而在运行的时候不会进行计算。

对于编译期和运行期这里给出一个简单的例子,在我们实际开发过程中我们可能需要进行相关的变量修改,例如有一个变量表示日期,我们希望将日期更改到一小时三十分钟后(也就是九十分钟后),假设以下两种写法都是合法的:

Data date = now;
// way 1
date += 1hour + 30minutes;
// way 2
date += 90minutes;

上述代码可能看着第二种方式更加高效,只需要进行一次加法,实际上对于上面的情况编译器可以将编译期就能确定的计算给优化掉,在上面的例子中1hour + 30minutes会被编译器计算成90minutes后替换。但是如果上述的1hour + 30minutes变成了两个变量相加(a+b),那么通常情况下则不会触发优化。

编译期循环

对于编译期循环,其名字虽然叫做编译期循环但是叫做“去除循环”则更加贴切。
对于通常需要计算多个数的和,我们可以向上一节中使用循环进行计算,如果使用循环,那么这往往就和编译期计算脱离了关系,起始上面的求和代码我们可以使用C++17的折叠表达式新特性进行编译期计算:

template<class ...Args>
auto sum(Args ...args) {
    return (args + ...); // return (arg0 + arg1 + arg2 + ...);
}

上面的代码由于被展开成了多个数字相加,编译期可以在编译时进行优化直接使用结果替换。

由于上面的方法C++17才开始支持,这里给出C++11也支持的方法:

template<int ...args>
struct Sum {};

template<int t0>
struct Sum<t0> {
    static constexpr int value = t0;
};
template<int t0, int ...args>
struct Sum<t0, args...> {
    static constexpr int value = t0 + Sum<args...>::value;
};

// usage:
Sum<1, 2, 3, 4>::value;

上面的代码通常只能实现同种类型的变量求和,没有C++17中的折叠表达式使用方便。上面的代码利用到了模板偏特化。

见识了上面的例子后,对于编译期循环,我相信大家已经有了基本的认识,即想发设发将运行时的循环通过模板递归或者折叠表达式的方式去除掉,这样就能够在编译期实现循环的效果。

更进一步的编译期循环:输出0-100的数

对于输出0~100的数任务,即使去掉了循环,也不能够进行任何优化,依然会调用一百次std::cout或者printf函数,这里的编译期循环,指的是,在循环期间这些数字必须全部是constexpr的,你可以理解成需要实现如下的代码:

std::cout << 0 << std::endl;
std::cout << 1 << std::endl;
...
std::cout << 99 << std::endl;

检验一个数是不是编译期常量可以通过检查这个数是否能够出现在模板参数的位置,例如:

int x = 10;
std::array<int, x> a;// WRONG, x is not constexpr
std::array<int, 10> b; // OK, 10 is constexpr
constexpr int y = 10;
std::array<int, y> c; // OK y is constexpr

具体地,上面所要求的编译期循环我们可以通过如下的方法实现(需要C++17):

template<class T, T val>
struct constexprT {
    static constexpr T value = val;
};
template<size_t Beg, size_t End, class Lambda>
void staticFor(Lambda lambda) {
    if constexpr (Beg < End) {
        lambda(constexprT<size_t, Beg>{});
        staticFor<Beg + 1, End>(lambda);
    }
}

// usage:
staticFor<0, 100>([] (auto i) {
	std::cout << i.value << std::endl;
});

上面的constexprTstd中已经有对应的实现std::integral_constant使用方法和上面的类似,通过value访问到的值是编译期常量。在上面的代码中,我们向模板参数中传入一个匿名函数,这个匿名函数用于输出i,经过上面的操作后我们明显的发现上面的每一个i.value均是constexpr符合我们的要求。

使用std::make_index_sequence也能实现上面的效果:

// Index... -> 0, 1, 2, 3, ..., 99
template<size_t ...Index, class Lambda>
void staticForImpl(Lambda lambda, std::index_sequence<Index...>) {
    (lambda(std::integral_constant<size_t, Index>{}), ...); // (lambda(0), lambda(1), ..., lambda(99));
}

template<size_t N, class Lambda>
void staticFor(Lambda lambda) {
    staticFor(lambda, std::make_index_sequence<N>{});
}

// usage:
staticFor<100>([] (auto i) {
	std::cout << i.value << std::endl;
});

实现编译期判断质数,并输出1-100的质数

由于使用编译期计算只能实现简单的循环操作,对于数组的访问的实现比较复杂,我们这是判断质数的方法使用非筛的方法。

我们首先要明确的是在编译期计算并输出1-100的质数的步骤:

  1. 由于需要在编译期进行质数的判断,第一步需要进行编译期循环,即保证每个循环的变量是编译期常量;
  2. 对一个编译期常量实现编译期的质数判断;
  3. 输出质数;

对于上述过程的第二步判断质数,我们可以分解成如下的步骤:

  1. 对于数Number依次在编译期计算 N u m b e r ? % ? i , i = 2 , 3 , . . . , N u m b e r ? 1 Number\ \%\ i, i = 2,3,...,Number-1 Number?%?i,i=2,3,...,Number?1
  2. 对于上述的计算结果编译期间确定是否全部非0如果结果全部非0则说明当前的数字是质数。

为此我们先实现判断多个数字是否全部是0的代码:

template<class ...Args>
constexpr bool anyZero(Args ...args) {
    return (0 || ... || (args == 0));
}

接下来我们需要计算 N u m b e r ? % ? i Number\ \%\ i Number?%?i的值,在这一步中我们需要使用到前文中提到的编译期循环:

constexpr int MAX_NUMBER = 100;
template<size_t ...Index>
constexpr bool isPrimeImpl(std::index_sequence<Index...>) {
    return !anyZero(((sizeof...(Index) %
                    ((std::integral_constant<size_t, Index>().value == 0 || 
                      std::integral_constant<size_t, Index>().value == 1) ?
                      MAX_NUMBER + 1 : 
                      std::integral_constant<size_t, Index>().value)))...);
}
template <int Number>
constexpr bool isPrime()
{
    if (Number == 0 || Number == 1) { return false; }
    return isPrimeImpl(std::make_index_sequence<Number>{});
}

// usage:
isPrime<10>(); // false;
isPrime<7>(); // true;

由于我们不需要计算除以01的余数我们将01进行特殊处理,将其变成MAX_NUMBER+1这样就相当于跳过了01,同时我们需要对01进行特殊判断。

有了上面的isPrime方法,我们只需要在调用一次staticFor在匿名函数中调用isPrime进行判断即可:

template<size_t ...Index, class Lambda>
void staticForImpl(Lambda lambda, std::index_sequence<Index...>) {
    bool isPrime[sizeof...(Index)] = {(lambda(constexprT<size_t, Index>{}))...};
    for (int i = 0; i < sizeof...(Index); i++) {
        if (isPrime[i]) {
            std::cout << i << " ";
        }
    }
    std::cout << std::endl;
}

template<size_t N, class Lambda>
void staticFor(Lambda lambda) {
    staticForImpl(lambda, std::make_index_sequence<N>{});
}

staticFor<MAX_NUMBER>([](auto i) {
    return isPrime<i.value>();
});

参考

parameter pack cppreference
C++ 变长模板参数与折叠表达式教学 bilibili
std::get cppreference
std::integral_constant cppreference

文章来源:https://blog.csdn.net/qq_45523675/article/details/135347782
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。