Compile-time sizes for range adaptors
In my previous blog post, we've discussed the static constexpr std::integral_constant
idiom to specify the size of a range at compile-time. Unlike the standard, our ranges library at think-cell already supports compile-time sizes natively, so I was eager to try the idiom there and see how it works out in practice.
We have two ways to query the size: One size
function that returns an integer like std::ranges::size
, and one constexpr_size
variable template that determines the compile-time size given the type of the range. In the latter case, we can directly apply the idiom by making constexpr_size
a variable template of std::integral_constant
type. That way the user has full flexibility in the interface:
That leaves the implementation of tc::constexpr_size
. The status quo was using trait specializations. Note that providing an implementation for tc::constexpr_size
automatically supports tc::size
as well.
Can we use static constexpr std::integral_constant
in the implementation?
I wanted to change it to somehow leverage the static constexpr std::integral_constant
idiom instead. That is, the implementation of tc::constexpr_size
checks for the well-formedness of Rng::size
as the default implementation. We then only need trait specializations to provide implementations for types we can't control like std::array
. While it worked great for range factories where the size is always known, like std::array
, tc::empty_range
, or tc::all_values<Enum>
, it does not work for range adaptors.
Consider tc::transform_adaptor
(our std::ranges::transform_view
), which returns a range whose elements are the underlying range elements transformed by applying a function. Crucially, it does not change the size of the range: If the underlying range rng
has N
elements, tc::transform(rng, fn)
also has N
elements. Thus we want to forward the size properties of rng
transparently:
- If
rng
has aconstexpr
size (i.e.tc::constexpr_size<Rng>
is well-formed), so shouldtc::transform(rng, fn)
. - Otherwise, if
rng
has a runtime size (i.e.tc::size(rng)
is well-formed), so shouldtc::transform(rng, fn)
. - Otherwise, it has neither
constexpr
nor runtime size.
A naive attempt using static constexpr std::integral_constant
for tc::constexpr_size
can look like this:
The issue here is the "somehow constrained" comment: static
member variables, unlike functions, cannot be constrained, nor overloaded with member functions. So we'd have to conditionally inherit the constexpr
size only if Rng
has a constexpr
size, which is ugly.
We also don't have any benefit from using static constexpr std::integral_constant
—users shouldn't directly call rng.size()
, they should use tc::size
or tc::constexpr_size
instead. So there is no upside to using static constexpr std::integral_constant
in the implementation.
Conditionally returning std::integral_constant
from .size()
So let's just have a single size()
function that returns either std::size_t
or std::integral_constant
, the "most constexpr
" version of the range size. This approach continues to work for range factories, but now we can also write our adaptors:
The corresponding specialization of tc::constexpr_size
calls the function inside decltype
to get the result:
The runtime version tc::size
doesn't need to be changed, as the range continues to have a .size()
member function that returns the size, just possible encoded in the return type itself.
Avoiding code duplication
While the approach is nice, there is a bit of code duplication. This becomes especially apparent for more complex ranges like tc::concat_adaptor
, whose size is the sum of all sub-range sizes:
We are computing the same value twice: once as a std::integral_constant
, and once as std::size_t
. We can unify it by splitting the size computation and the result wrapping using a helper function:
The helper function compute_range_adaptor_size()
takes all sub-ranges and computes the most constexpr
version of their sizes. This is then passed to a lambda to compute the derived size, and returned in the most constexpr
way. Note that the lambda has to be passed as a non-type template parameter, so we can use its result in a constexpr
context.
concat_adaptor::size()
then just needs to call compute_range_adaptor_size()
with an appropriate lambda and the subranges, and it will automatically work.
Conclusion
The size of a range adaptor can be available at compile-time or runtime. We can forward that information easily by returning std::integral_constant
whenever possible, the most constexpr
version of the size.
The pattern can be applied to more situations. For example, I've recently added support to tc::filter
predicates that return std::true_type
or std::false_type
. That way, we can filter e.g. a std::tuple
by type with the same mechanism used to filter a runtime range by value.
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.