C++98-26:元编程之编译期拆解技术

教育   科技   2023-08-22 19:51   陕西  

人们往往会将一个大问题拆解成许多小问题,通过解决一个个小问题,最终就能解决整个大问题。

若是拆解过后,这些小问题的处理逻辑不变,变化的只是输入状态,那么此时就是一种代码复用。对应编程世界,一种是自上而下的拆解组合方式,称为递归;一种是自下而上的拆解组合方式,称为迭代。

拆解后还能组合,拆解才有意义,递归和迭代本身就带有一种约束,必须具备起始状态和终止状态。若是没有起始状态,递归就没有起点,循环就没有开始;若是没有终止状态,递归就没有终点,循环就没有结束,

本文所说的元编程拆解技术,就是编译期的问题拆解与组合技术,编译期不像运行期那样能够动态地改变输入与输出状态,C++ 诞生了许多技术来解决这个问题,这便是后文要介绍的。

本文以一个需求为例,来讲解这些技术:

要求编写一个 unroll<N>(callback) 函数,该函数将调用 N 次传入的 callback 函数。

起始状态就是 N,终止状态就是 N 为零,拆解后处理逻辑不变的小问题就是 callback。

先不看文,你首先想到的是什么解法?文读毕,对比一下文中的各种拆解技术思路,获益更多。

原始递归法

模板元编程最开始就只支持递归这一种拆解方式,每次输入一个状态,依次产生下一个状态,若状态与递归终止状态相同,则结束。

采用这种方式,实现需求如下:

 1namespace cpp98 {
2
3    // declaration
4    template <std::size_tclass Fvoid unroll(F);
5
6    template <std::size_t N, class F>
7    struct unroll_helper {

8        void operator()(F f) {
9            f();
10            unroll<N-1, F>(f);
11        }
12    };
13
14    // terminated state
15    template <class F>
16    struct unroll_helper<0, F> {

17        void operator()(F) {}
18    };
19
20    // definition
21    template <std::size_t N, class F>
22    void unroll(F f) {

23        unroll_helper<N, F>()(f);
24    }
25
26    void print_cpp98() {
27        std::puts("hello cpp98");
28    }
29}
30
31int main() {
32    cpp98::unroll<2>(cpp98::print_cpp98);
33    // output:
34    // hello cpp98
35    // hello cpp98
36}

由于函数模板不支持偏特化,于是需要借助一个 unroll_helper 类模板,来实现递归终止条件,再在 unroll 函数中调用该帮助类,不断拆解问题。

递归输入条件 N 为起始状态,每次拆解执行完成之后,通过 N - 1 得到下一个状态,从而不断解决问题。

这个时期,C++ 的元编程拆解技术还很弱,一个简单的需求,实现起来也颇为繁琐。

可变参数模板

时间来到 C++11,模板元编程迎来强大扩展,非常有用的一个新特性就是可变参数模板,它能够让我们摆脱递归这种原始拆解方式。

但是 C++11 还只是开始,基础组件不完善,所以并不能非常有效地实现目标。

什么意思呢?看如下这个不太完善的实现:

 1namespace cpp11 {
2
3    template <std::size_t... Is>
4    class index_sequence {};
5
6    template <class Fstd::size_t... Is>
7    void unroll(F f, index_sequence<Is...>) {
8        using expand = std::size_t[];
9        expand{ (f(), Is)... };
10    }
11
12}
13
14
15int main() {
16    cpp11::unroll([] { std::puts("hello cpp11"); },
17        cpp11::index_sequence<21>());
18}

原始递归法是采用不断递归来动态地产生状态,而有了可变参数模板,状态可以直接在编译期初期产生,从而直接拿来用就可以。

这里定义了一个 index_sequence 用来接收所有状态,然后借助一些逗号表达式技巧展开参数包,在参数包展开的过程当中,执行处理逻辑。

C++11 起也支持 Lambda,因此也不用再提供一个额外的调用函数。

这个实现的唯一缺点就是由于缺乏相应的组件,需要手动产生状态,导致使用起来较为麻烦。

完善版可变参数模板

C++14 增加了 std::index_sequencestd::make_index_sequence,于是就能将手动产生状态变成动态产生,完善实现。

代码如下:

 1namespace cpp14 {
2
3    template <class Fstd::size_t... Is>
4    void helper(F f, std::index_sequence<Is...>) {
5        using expand = std::size_t[];
6        expand{ (f(), Is)... };
7    }
8
9    // variable template
10    template <std::size_t N>
11    auto unroll = [](auto f) { // generic lambda
12        helper(f, std::make_index_sequence<N>{});
13    };
14}
15
16int main() {
17    cpp14::unroll<3>([] { std::puts("hello cpp14"); });
18}

同时,C++14 还支持 variable template 和 generic lambda,这进一步简化了实现。

Fold Expression

前面的方式是采用逗号表达式技巧来展开参数包,C++17 支持 Fold expression,可以直接展开,因此代码得到进一步简化。

变成:

 1namespace cpp17 {
2
3    template <class Fstd::size_t... Is>
4    void helper(F f, std::index_sequence<Is...>) {
5        ((f(), Is), ...); // fold expression
6    }
7
8    template <std::size_t N>
9    auto unroll = [](auto f) { // generic lambda
10        helper(f, std::make_index_sequence<N>{});
11    };
12}

constexpr if

C++17 的另一种拆解技术是借助 constexpr if,它的好处在于能够直接在本函数内判断终止状态,这样就不再需要去定义一个递归终止函数。

 1namespace cpp17 {
2    // variable template + constexpr if
3    template <std::size_t N>
4    auto unroll = [](auto expr) {
5        if constexpr (N) {
6            expr();
7            unroll<N-1>(expr);
8        }
9    };
10}
11
12int main() {
13    cpp17::unroll<3>([] { std::puts("hello cpp17"); });
14}

与原始递归法相比,这种方式除了消除递归终止函数,还免于编写一个额外的 helper 类,generic lambda 更是减少了模板参数。

这是目前为止,最简洁的实现。

C++20 双层 Lambda 法

有没有非递归的简洁拆解方式呢?当然也有。

看如下实现:

 1namespace cpp20 {
2
3    template <auto N> constexpr auto unroll = [](auto f) {
4        [f]<auto... Is>(std::index_sequence<Is...>) {
5            ((f(), void(Is)), ...);
6        }(std::make_index_sequence<N>());
7    };
8}
9
10int main() {
11    cpp20::unroll<3>([] { std::puts("hello cpp20"); });
12}

这里的关键是 C++20 的 template lambda,它支持为 lambda 编写模板参数,基于此才能够编写索引的模板参数。

Lambda 函数里面再套一个 Lambda 函数,外层用于提供调用接口,内层用于管理状态和处理调用。如果没有 template lambda,内层 Lambda 的 std::index_sequence 参数就无法写,也就接收不了状态。、

Structured Binding Packs

原本有些新特性是应该在 C++23 就进入标准的,但由于种种原因,我们只有期望 C++26 能用上了。Structured binding packs 就是这么一个特性。

前面除了递归以外的所有拆解方法,都得借助 std::index_sequence,这就是代码依旧复杂的原因所在。

有没有一种方式可以直接让我们访问参数包,而不必再定义一个参数为 std::index_sequence 的函数才能拿到那些参数包?Structured binding packs 就提供了这一能力。

这是 P1061 所提出的一种方式,让我们能够通过 Structured bindings 直接得到参数包。

于是实现变为:

 1namespace p1061 {
2
3    template <auto N> constexpr auto unroll = [](auto f) {
4        auto [... Is] = std::make_index_sequence<N>();
5        ((f(), void(Is)), ...);
6    };
7
8}
9
10int main() {
11    p1061::unroll<3>([] { std::puts("hello p1061"); });
12}

这种拆解技术才是最直观的方式,两行代码解决一切。

Expansion Statements

另外一种方式就是我们在反射中经常使用到的一个特性:template for

这种方式比 Structured Binding Packs 更强大,是静态反射里面的一个扩展特性,能够支持编译期迭代。

对于本次需求的实现为:

 1namespace p1306 {
2    template <auto N> constexpr auto unroll = [](auto f) {
3        constexpr std::array<int, N> dummy{};
4        template for (auto& e : dummy)
5            f()
;
6    };
7}
8
9int main() {
10    p1306::unroll<3>([] { std::puts("hello p1306"); });
11}

这里借助了 std::array,构建了一个并不会实际使用的变量,目的是为了当作遍历次数。

总结

本文从 C++98 开始介绍了许多拆解技术,在不断的优化过程中,也能够看到 C++ 的发展历程。

由最原始的复杂、难用,到最后的两行代码搞定,也能够看到 C++ 元编程的发展。

利用好这些技术,对大家的元编程能力会有显著提高。

有收获,还希望大家点赞、分享、转发。


CppMore
Dive deep into the C++ core, and discover more!
 最新文章