Trip Report: Fall ISO C++ Meeting in Wrocław, Poland

Last week, I attended the fall 2024 meeting of the ISO C++ standardization committee in Wrocław, Poland. This was the fifth meeting for the upcoming C++26 standard and the feature freeze for major C++26 features. For an overview of all the papers that made progress, read Herb Sutter's trip report. Contracts and profiles are the big ticket items that made the most progress this meeting. Contracts is forwarded to wording review, while still being fiercely opposed by some. Profiles is essentially standardized static analysis to improve memory safety, although some deem it ineffective. Due to various scheduling conflicts I did not attend any of the relevant discussions in these spaces. Instead, I am going to share my thoughts about ranges, relocation, and reflection.

Ranges

I've spent the first day and a half co-chairing SG 9, the study group for std::ranges. During discussions, we realized two big holes in the range library that we need to address as soon as possible. They both concern the concept of sized ranges.

The first hole is related to proposal P3179—C++ parallel range algorithms, which adds an execution policy to the algorithms in std::ranges. This makes it trivial to run multi-threaded algorithms: Instead of writing std::ranges::transform(vec, output, fn), you write std::ranges::transform(std::execution::par, vec, output, fn) and now your input is split into multiple chunks and processed in parallel.

This splitting of input into multiple chunks requires random access ranges with a known size, so the parallel algorithms have these stronger requirements compared to the single-threaded algorithms. For algorithms that take two input ranges, both need to be sized. Otherwise, std::ranges::transform(std::execution::par, a, b, output, fn), which evaluates fn(a[i], b[i]) for all 0 ≤ i < min(std::ranges::size(a), std::ranges::size(b)) would first need a serial loop to find the correct size before being able to split the input.

One vendor disliked the requirement that both ranges need to be sized. They have customers who would want to write code like std::ranges::transform(std::execution::par, vec, std::views::repeat(n), output, fn) or std::ranges::transform(std::execution::par, vec, std::views::iota(0), output, fn). Both std::views::repeat(n) which just repeats n forever, and std::views::iota(0) which generates an infinite sequence of numbers, are clearly longer than std::ranges::size(vec). So, the obvious semantics is to call fn(vec[i], n) or fn(vec[i], i) for all 0 ≤ i < std::ranges::size(vec). However, the views do not provide std::ranges::size, as they are infinite. The vendor's implementation thus required that only one range is sized and assumed that the other one is longer, but there is no way to check that statically. A better solution needs a new concept std::ranges::infinite_range. Then we can require that std::ranges::transform takes either two sized ranges or one sized range and one infinite range.

Such a concept would also help with some library issues, like LWG4019 which points out that std::views::iota(0) | std::views::reverse is just an infinite loop as std::views::reverse tries to find the end of an infinite range. A compiler error would be a much better user experience, and can be achieved by requiring a non-infinite range passed to std::views::reverse.

Matthias Kretz, author of the std::simd proposal (which got accepted into the working draft in C++26!), discovered the second hole. In P3299, he wants to replace the unsafe iterator constructor of std::simd with safer range constructors. The ideal semantics would be to allow implicit conversion from a range whose size matches the SIMD size, and explicit conversion with custom out-of-bounds semantics otherwise. However, to allow implicit conversion conditionally, we need ranges whose size is not only known dynamically without looping (std::ranges::sized_range), but statically at compile-time. Right now, all that can be done is hard-code types such as std::array and a statically-sized std::span instantiation.

A proper concept is needed here. At think-cell, we have such a concept built on top of tc::constexpr_size, which allows us to query the size of a range given its type. I've blogged about a useful idiom in that space before, and how it can be seamlessly extended into all range adapters.

I will work with the relevant parties to bring both std::ranges::infinite_range and std::ranges::constexpr_sized_range (names to be determined) to SG 9 for the next meeting in February. With some luck and sufficient pressure by the national bodies voting on the final committee draft, they can make it in time for C++26 so the existing proposals can already benefit.

Relocation

Ever since move semantics were added, people have been asking for a form of destructive move. Right now, the moved-from state of an object needs to be partially formed, as the destructor will still run. This means that something like a moveable non-null std::unique_ptr is not possible: Adding a move constructor requires a moved-from state, for which the destructor is a no-op. This requires setting it to nullptr.

Additionally, operations like std::vector::reserve, which first allocate new memory, then move the existing elements over, and finally destroy the elements in the old memory, are not efficient as they could be. The move + destroy step could in principle be replaced by a fancy version of std::memcpy. The move constructor only has to create a moved-from state to ensure that destruction is a no-op; if destruction is never performed, it can often be done by just copying bytes.

The current iteration of this feature, P2786, focuses on the optimization aspect by introducing the idea of "trivially relocatable" types. These are types where a call to the move constructor followed by a call to the destructor can be replaced by a new primitive operation std::trivially_relocate, which essentially does a std::memcpy with some extra magic to end and start lifetimes. The trivial relocatable property is computed automatically: a class is trivially relocatable if all the members are trivially relocatable and it does not have custom move operations. For types with custom move operations you have to opt-in by declaring that a move + destroy can be replaced by std::trivially_relocate, the compiler cannot determine that for you.

Similarly, the paper also introduces "replaceable" types. A type is replaceable, if the move assignment operator is equivalent to a call to the destructor followed by a call to the move constructor. This is true for most types, unless they contain references or have allocators where propagate_on_container_move_assignment is false. Like trivially relocatable, a type is replaceable if all members are and if it does not have custom move operators, otherwise the type has to opt-in. If a type is replaceable and trivially relocatable, the invocation of the move assignment operator followed by the destructor can also be replaced by std::trivially_relocate. Otherwise, only the move constructor followed by destructor can be replaced.

Apparently, there is a lot of drama and history behind this particular proposal. It left EWG, the group responsible for C++ language features, with really tight consensus, and was handed to LEWG, which was the first time I saw the feature. Besides looking at the interface of the std::trivially_relocate function, EWG also asked us to look at the keywords required for opt-in. As nobody came up with a better option, we ended up with a trivially_relocatable and replaceable contextual keyword, put after the name of the class:

class unique_ptr trivially_relocatable replaceable { … };

Want to make it final and [[nodiscard]]? No problem, add those as well:

class [[nodiscard]] unique_ptr final trivially_relocatable replaceable { … };

P2822 proposes syntax for controlling the namespaces visible to ADL, which is a feature I really like. Specifying that our unique_ptr does not participate in ADL means our class declaration looks like this:

class [[nodiscard]] unique_ptr final trivially_relocatable replaceable namespace() { … };

Needless to say, this is awful and I hate everything about it.

A vote to forward P2786's trivially relocatable failed in LEWG, so now the status of the paper is unclear.

Reflection

The good news is that P2996—Reflection for C++26 is on track for C++26. The last big issue to resolve was a syntax ambiguity with objective C blocks (yes, really). This was resolved by changing the reflection operator from ^foo to ^^foo.

The bad news is that my paper, P3429—Reflection header should minimize standard library dependencies, was soundly rejected, with the exception of what's essentially a drive by fix to the wording. I expected this rejection: LEWG, the group responsible for designing the standard library, is historically not really sympathetic to people who don't want to use the standard library, but I am still disappointed. I really hope the underlying compiler built-ins remain decoupled so you can provide your own lightweight abstractions over them that don't rely on constructing std::vector at compile-time. That way you are not forced to include <meta> to use reflection if you are fine with using #ifdef to detect the compiler.

The silver lining is that because the reflection APIs are so expensive, compiler implementers will almost certainly put some engineering effort into faster constexpr evaluation. For example, they could treat types like std::vector<std::meta::info> as a built-in implemented in native code or use bytecode instructions to evaluate constexpr.

Maybe that way we will end up with C++ as an interpreted language running in a VM.

— by Jonathan Müller

Do you have feedback? Send us a message at devblog@think-cell.com !

Sign up for blog updates

Don't miss out on new posts! Sign up to receive a notification whenever we publish a new article.

Just submit your email address below. Be assured that we will not forward your email address to any third party.

Please refer to our privacy policy on how we protect your personal data.

Share