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只允许使用new和std::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[]的错配,都能够在编译期检测到。介绍完了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,第二个值是数据实际的长度。 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 auto& make_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的新特性,必须手动跑一下才能理解其中的奥妙。