简单点,元编程的方式简单点

目录

一直以来,C++模板元编程的代码都极不直观,直到我看到了这样一段代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
template<class... Ts>
struct example {
  mp::apply_t<std::variant,
      std::array{mp::meta<Ts>...}
    | std::views::drop(1)
    | std::views::reverse
    | std::views::filter([](auto m) { return mp::invoke<std::is_integral>(m); })
    | std::views::transform([](auto m) { return mp::invoke<std::add_const>(m); })
    | std::views::take(2)
    | std::ranges::to<mp::vector<mp::info>>()
  > v;
};

static_assert(
  typeid(std::variant<const int, const short>)
  ==
  typeid(example<double, void, const short, int>::v)
);

这不就是我的梦中情码么?类型计算和值计算完全一致!好奇心驱使下看了下代码实现,还是有点小小的失望,作者使用了有状态模板元编程,写到这里感觉要开始部分劝退了,不过在C++20的concept加持下,有状态模板元编程也得到了大幅简化,既然是有状态,那么就需要对状态进行读写:

  • 写:例化友元函数
  • 读:通过requires检测友元函数是否例化

先讲一下这个代码实现的基本思路,要让类型计算变成值计算,那么就要建立类型和值的双向映射,可以通过mp::meta<T>将类型T映射为一个值,然后可以通过mp::type_of<meta<T>>将值还原为类型T。作者利用有状态元编程的手段实现了一个计数器,每次例化mp::meta<T>时,对应的计数器加1。原始的代码做了编译时间优化,里面的一些命名也有些误导,不易理解,这里贴一个我改写的非优化的版本,代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#define MP_SIZE 16u

enum class id_t : size_t {
};

template <id_t>
struct info {
    constexpr auto friend get(info);
};

template <class T>
struct meta_impl {
    using value_type = T;

    template <size_t n = MP_SIZE - 1u>
    static constexpr auto gen() -> size_t {
        if constexpr (n == 0) {
            return 0;
        } else if constexpr (requires { get(info<id_t{n - 1}>{}); }) {
            return n + requires { get(info<id_t{n}>{}); };
        } else {
            return gen<n - 1u>();
        }
    }

    static constexpr auto id = id_t{gen()};

    constexpr auto friend get(info<id>) {
        return meta_impl{};
    }
};

可以看到gen是从大往小(这是为了与作者的代码对应)去搜索计算的,当检测到get(info<n - 1>)已经例化时那么就返回n - 1 + 1,即n,否则通过gen<n - 1>()继续搜索,对于边界情形n == 0,则直接返回0,以上代码测试如下:

1
2
3
4
5
6
7
static_assert(static_cast<std::size_t>(meta_impl<char>::id) == 0);

static_assert(static_cast<std::size_t>(meta_impl<unsigned char>::id) == 1);

static_assert(static_cast<std::size_t>(meta_impl<short>::id) == 2);

static_assert(static_cast<std::size_t>(meta_impl<unsigned short>::id) == 3);

可以看到类型已经映射为了值,且这个值对应了meta_impl<T>的例化顺序,实际使用可以再用模板变量进行简化,代码如下:

1
2
template <class T>
inline constexpr id_t meta = meta_impl<T>::id;

每次例化meta_impl<T>,都会例化一个与之对应的get(info<id>),其返回类型是meta_impl<T>,因此通过值还原类型的代码如下:

1
2
template <id_t meta>
using type_of = typename decltype(get(info<meta>{}))::value_type;

前面也提到作者的原始代码做了编译时间的优化,其实也就是使用了二分法,代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
template <size_t left = 0u, size_t right = MP_SIZE - 1u>
static constexpr auto gen() -> size_t {
    if constexpr (left >= right) {
        return left + requires { get(info<id_t{left}>{}); };
    } else if constexpr (constexpr auto mid = left + (right - left) / 2u;
                         requires { get(info<id_t{mid}>{}); }) {
        return gen<mid + 1u, right>();
    } else {
        return gen<left, mid - 1u>();
    }
}

这样,gen的时间复杂度从O(n)降低为O(log(n)),作者也做了编译时间的性能测试,这个库和mp11性能相近,在各个编译器上比拼互有胜负,最后作者也提到,由于这个库是值计算,如果将来编译器加入了完整constexpr的JIT实现,那么这个库是极具潜力的。

总结

上面提到的库是mp,库作者是Kris Jusiak,cppcon上的常客了,在cppcon2024上,作者对该库做了讲解,讲解的最后,作者给出了编译时间性能测试,这也是让我印象深刻的地方。当然,考虑到有状态元编程的种种问题,该库的学习意义可能大于实践意义,将其作为P2996开胃菜也是一个不错的选择。该作者还有其他很多有意思的库,作者已将这些库合而为一,即qlibs

本文中出现的测试代码放在了我的github仓库eespace中,以后,我也会把平时的一些实验代码放到这个仓库中。

最后,我想抛两个问题:

  • 不同编译单元中使用该库是否会违反ODR?
  • 非优化版的gen能否简化如下:
1
2
3
4
5
6
7
8
template <size_t n = MP_SIZE - 1u>
static constexpr auto gen() -> size_t {
    if constexpr (n == 0 || requires { get(info<id_t{n - 1}>{}); }) {
        return n + requires { get(info<id_t{n}>{}); };
    } else {
        return gen<n - 1u>();
    }
}
0%