Technique: Take a constexpr string from Compile Time to Run Time

教育   科技   2023-04-08 22:11   美国  
Constant Expressions是C++元编程进入第二个时期的标志,从C++11-23,标准在不断完善这一基础设施,从而为进入下个核心时期做准备。
本文要讨论的,是该进程中的某个点——C++20 constexpr std::string。
C++20之前,标准已经陆续增加不少constexpr特性,但还不支持容器(除了像std::array这样的静态容器)。主要问题就在于,没有解决编译期的动态内存分配。虽然C++20通过transient allocation初步解决了这个问题,但还是有一定的局限性。
这个局限性,就是本文要讨论的中心。
下面正式进入主题。
C++20的constexpr std::string,并不像其他编译期变量那样,能够直接使用。
1#include <string>
2
3int main() {
4    constexpr std::string str = "compile time string";
5}
这段代码是无法编译的,因为std::string内部需要动态内存分配。
我们只能将std::string放在constexpr/consteval修饰的函数里面使用。
1constexpr std::string make_string() {
2    std::string str{"compile time string"};
3    return str;
4}
5
6int main() {
7    static_assert(make_string() == "compile time string");
8}
为什么这里就支持动态内存分配了呢?其实,这并非完全意义上的动态内存分配,这种分配叫做transient allocation。顾名思义,这是一种瞬逝的分配,它允许在constexpr expression内分配内存,但是必须在expression结束时释放内存。编译器通过这种方式来跟踪内存分配,便于控制的同时也易于实现。
简单来说,transient allocation所分配的内存,在编译期结束前必须释放。
C++20的transient allocation只允许使用newstd::allocator::allocate,C++23增加了对std::unique_ptr的支持。
一个小例子:
 1constexpr void g(int *p) {
2    delete[] p;
3}
4
5constexpr int f(unsigned int size) {
6    auto p = new int[size];
7    std::iota(p, p + size, 1);
8    auto retval = std::count_if(p, p + size, [](int val) { return val % 4 == 0; });
9    g(p); // delete p in g
10    return retval;
11}
12
13int main() {
14    constexpr auto val = f(9);
15    std::cout << val; // output: 2
16}
transient allocation有相关的检测机制,用来检查在constexpr expression的生命周期内,是否正确分配并释放了内存,像是忘记释放,或是delete与delete[]的错配,都能够在编译期检测到。
假如我们注释掉第9行的调用,则会产生编译期错误。
介绍完了transient allocation,现在让我们回到constexpr std::string的正题中来。
由于transient allocation的局限性,我们无法保存constexpr std::string的值。
1constexpr std::string make_string() {
2    std::string str{"compile time string"};
3    return str;
4}
5
6int main() {
7    // static_assert(make_string() == "compile time string");
8    constexpr auto str = make_string(); // Error!
9}
为什么呢?transient allocation的内存在编译期结束时就已经释放了,故而make_string()的输出不能在运行期使用。
此时就需要一种方式来将constexpr string的输出保存起来,以在运行期使用。
总的来说,有两种解决思路。
第一种思路,将constexpr string的输出保存到constexpr array里,因其没有用到transient allocation,所以可以在运行期来用。
 1constexpr auto make_string(int n) {
2    std::string str;
3    std::array<char, 10> buf;
4    for (auto i : std::views::iota(0, n)) {
5        // Convert integers to strings with constexpr to_chars in C++23
6        if (auto [ptr, ec] = std::to_chars(buf.data(), buf.data() + buf.size(), i); ec == std::errc())
7            str += std::string_view(buf.data(), ptr);
8    }
9    return str;
10}
11
12constexpr auto get_length(std::string_view str) {
13    return str.size();
14}
15
16template <std::size_t Len>
17constexpr auto get_array(std::string_view str) {
18    std::array<char, Len + 1> buf { 0 };
19    std::copy(str.begin(), str.end(), buf.begin());
20    return buf;
21}
22
23
24int main() {
25    static_assert(make_string(11) == "012345678910");
26    constexpr static auto length = get_length(make_string(11));
27    constexpr static auto buf = get_array<length>(make_string(11));
28    constexpr static auto str = std::string_view(buf.begin(), buf.size());
29    std::cout << str << "\n"// Output: 012345678910
30}
其中,make_string接受一个数值,然后将[0, n)的数值依次转换成字符串,再保存到constexpr string。整个函数都在编译期执行,因此std::to_string之类的转换函数都不可以用,这里使用的是C++23的constexpr std::to_chars来完成这个工作。
constexpr std::array需要指定长度,再使用之前必须得到数据的长度,这个工作由get_length函数完成。
然后,get_array将constexpr string的输出全部拷贝到constexpr array中,如此一来,它的生命期就可以延长到运行期。也因此,我们最终才能够借其构造一个string_view,通过cout来输出这个结果。
这种方式的缺点就是要构造两次数据,因为在调用get_array之前,必须先调用一次数据来获取其长度。
第二种思路同样是要借助constexpr std::array,但是要消除获取长度这一步骤。
消除手法就是先选择一个比较大的长度,得到一个不符合实际大小的array。
1constexpr auto get_array_helper(std::string_view str) {
2    std::array<char, 10 * 1024 * 1024> buf;
3    std::copy(str.begin(), str.end(), buf.begin());
4    return std::make_pair(buf, str.size());
5}
在这个函数里面,将实际长度保存起来,接着再来将这个过长的array缩小到实际长度。这里返回值为一个pair,第一个值就是过长的array,第二个值是数据实际的长度。
然后再对这个过长的array进行缩放。
 1constexpr auto get_array2(std::string_view str) {
2    // structured binding declaration cannot be constexpr.
3    // Error!
4    constexpr auto pair = get_array_helper(str);
5    constexpr auto buf = pair.first;
6    constexpr auto size = pair.second;
7    std::array<char, size> newbuf;
8    std::copy(buf.begin(), std::next(buf.begin(), size), newbuf.begin());
9    return newbuf;
10}
但是注意,以上实现是存在问题的,编译器报如下错。
此时要采用一种方式将str转换成constant expression,一个技巧是通过lambda传递,而非直接传递实际值。然后执行传递的lambda,就可以得到一个constexpr value。于是修改代码为:
 1template <class Callable>
2constexpr auto get_array(Callable call) {

3    // structured binding declaration cannot be constexpr.
4    constexpr auto pair = get_array_helper(call());
5    constexpr auto buf = pair.first;
6    constexpr auto size = pair.second;
7    std::array<char, size> newbuf;
8    std::copy(buf.begin(), std::next(buf.begin(), size), newbuf.begin());
9    return newbuf;
10}
之后的操作,就是根据已知的实际大小,重新构造一个新的数据,它是符合实际大小的。
现在,就可以无需额外获取一次大小,再来调用get_array。
1constexpr auto make_data = [] {
2    return make_string(11);
3};
4
5constexpr static auto data = get_array(make_data);
6constexpr static auto str = std::string_view{data.begin(), data.size()};
7std::cout << str << "\n"// Output: 012345678910
但是我们依旧不能一步到位地得到一个string_view,总是要经过一个get_array,难免麻烦。
因此,让我们再进一步,将所有内容整合起来,提供一个独立的函数。
1constexpr auto to_string_view(auto callable) {
2    constexpr static auto data = get_array(callable);
3    return std::string_view{data.begin(), data.size()};
4}
5
6constexpr static auto str = to_string_view(make_data);
7std::cout << str << "\n";
现在调用起来就显得非常简明了。
但是这个代码能够编译通过是令人比较惊讶的,因为我记得C++20 constexpr function中是不允许使用constexpr static变量的,一查才知道,原来是C++23去除了这一限制。
那么在C++20如何实现这一目的呢?可以采用这个技巧。
1template <auto Data>
2constexpr const automake_it_static() {
3    return Data;
4}
5
6constexpr auto to_string_view(auto callable) {
7    constexpr auto& data = make_it_static<get_array(callable)>();
8    return std::string_view{data.begin(), data.size()};
9}
NTTP和constexpr变量具有一些相同的属性,模板在推导时会实例化出想要的字符串。
通过这种方式,就能够达到和constexpr static相同的效果。
最后,也不做小结了,说说过程中遇到的一些问题。
clang对constexpr string的支持很不足,几乎用不了;msvc的确支持constexpr string,但它在用array初始化string_view存在错误,导致无法将get_array的结果转换成string_view。按理说clang和msvc的版本目前要比gcc新,问题应该更少,gcc13都还没出来呢,但使用起来,还是gcc的问题更少一点,本文的代码就是在gcc trunk测试通过的。
本文中的代码细节极多,而且用到了一些C++23的新特性,必须手动跑一下才能理解其中的奥妙。

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