23

I'm calculating some data at compile-time using std::vector and want to return the results as an array, so it can be used further at run-time. I'm having trouble setting the array size without doing the calculation twice.

Here's a simplified example of what I've done so far. The code compiles and works as expected.

constexpr auto make_vector() { 
  // complex calculation here
  return std::vector{1, 2, 3}; 
}

constexpr auto make_array() {
  const auto vec = make_vector();
  std::array<int, make_vector().size()> result{};

  std::copy(vec.cbegin(), vec.cend(), result.begin());
  return result;
}

int main() {
  constexpr auto result = make_array(); // std::array<int, 3>{1, 2, 3}
}

I understand why it's not possible to use vec.size() for the array size and why make_vector().size() produces a compile-time constant. It just doesn't seem the right approach to do it twice.

Is there a way to avoid calling make_vector twice? Am I missing a basic concept here?

14
  • 1
    @康桓瑋 In my project make_vector is a complex calculation, it's just a one-liner in the example above. Commented Dec 20, 2023 at 5:21
  • 5
    Maybe what we need is partially constexpr structured bindings, such as auto [vec, constexpr size] = make_vector(); Commented Dec 20, 2023 at 6:03
  • 2
    you know at compile time how many elements the vector has, why dont you just use array directly, instead of this complicated copying? Commented Dec 20, 2023 at 6:39
  • 2
    @Red.Wave won't compile, see "transient allocation" Commented Dec 20, 2023 at 6:59
  • 2
    I think calling the function twice is the only option. I would test (by profiling?) if your compiler actually repeats the calculation or not, maybe it manages to memoize the result. Commented Dec 20, 2023 at 7:11

3 Answers 3

21

Is there a way to avoid calling make_vector twice? Am I missing a basic concept here?

Not until C++26 at least. The fundamental issue is that std::vector allocates memory, so you cannot simply store it in a constexpr variable (despite it being a literal type) so that its size and contents can be used arbitrarily. A std::vector can only live temporarily during a constant expression(1).

During this first constant expression, the .size() is not a constant expression, and there's no way to "elevate it to that status". Therefore, you don't know the size of the array you're creating, and the vector contents are wasted the first time(2).

This issue is also the motivation in P0784: More constexpr containers(3):

Amongst other things, the lack of variable size [constexpr] containers forces them to use primitive fixed-size data structures in the implementation, and to parse the input JSON string twice; once to determine the size of the data structures, and once to parse the JSON into those structures.

You're not the first to run into this problem, and you'll just have to be patient until the C++ committee and compiler developers figure this one out.


(1) The reason is that even though std::vector can use new in constant expressions, all memory must be de-allocated before the end of the constant expression. This is referred to as "transient allocations".

(2) As commenter @HolyBlackCat has pointed out, it's possible that your compiler memoizes the call to make_vector() so that even though you call it twice, the function doesn't need to be evaluated twice.

(3) This paper has been accepted into C++20, but does not yet solve the issue with wasted container creation.

Future work

There is a good chance that your problem will be solved to some extent in C++26, assuming P3032: Less transient constexpr allocation (and more consteval relaxation) is accepted. With this proposal, you could write:

consteval auto make_array() {
  constexpr auto vec = make_vector();
  std::array<int, make_vector().size()> result;
  std::ranges::copy(vec, result.begin());
  return result;
}

The solution relies on the fact that the allocation done by make_vector() doesn't escape the surrounding constant expression because make_array() is consteval. No memory is actually "leaked" to run-time, so vec can be constexpr, giving us access to both the data and the size.

An older paper addressing the issue more completely is P1974: Non-transient constexpr allocation. The key issue is that any T* allocated at compile time and stored in a constexpr variable (directly, or through a container) can point to mutable memory. This constness issue means that a constexpr std::vector<std::string> would contain a mixture of mutable/immutable pointers (akin to char ** const), and that breaks the assumption that we can omit delete entirely and keep static memory instead. Refer to the paper for more details.

Sign up to request clarification or add additional context in comments.

Comments

3

(By the way, C++ Weekly - Ep 269 - How To Use C++20's constexpr std::vector and std::string is very relevant!)

The output size can't be calculated separately from the actual computation.

Good, but since it is at compile time, you can do all of that with std::integer_sequence (or std::index_sequence, if the type of the elements is std::size_t) and some metaprogramming.

Then, if you really need a std::array, you can make the conversion with this:

template<typename N, N ...ns>
consteval auto seq2array(std::integer_sequence<N, ns...>) {
      return std::array{ns...};
}
static_assert(seq2array(std::index_sequence<1, 2, 3>{}) == std::array<std::size_t, 3>{1, 2, 3});

Now, how to generate the std::index_sequence depends on what the "complex calculation" is.

Weijun Zhou suggested in a comment a toy example:

make_vector(int n) can be a function that returns a vector containing all non-negative numbers k not greater than n that satisfy a predicate f(k), where f is a very expensive function.

What does it take to do that?

Well, that just means filtering the sequence of numbers from 0 inclusive to n exclusive, with the predicate f.

Let's invent the predicate

constexpr auto f = [](auto k) {
    // expensive compuptation
    return k % 2 == 0;
};

and write a filtering utility. For the latter we need a recursive approach where we apply the predicate to the first element of the sequence to decide whether to keep it or not, make a singleton or empty sequence out of it respectively, and concatenate with the rest of the filtered sequence.

So the first thing we need is actually a way to concatenate sequences. Here it is:

template<typename N, N ...ns, N ...ms>
consteval auto cat(std::integer_sequence<N, ns...>, std::integer_sequence<N, ms...>) {
    return std::integer_sequence<N, ns..., ms...>{};
}
static_assert(std::is_same_v<decltype(cat(std::index_sequence<1, 2>{}, std::index_sequence<3, 4>{})), std::index_sequence<1, 2, 3, 4>>);
static_assert(std::is_same_v<decltype(cat(std::index_sequence<1, 2>{}, std::index_sequence<>{})), std::index_sequence<1, 2>>);
static_assert(!std::is_same_v<decltype(cat(std::index_sequence<2, 2>{}, std::index_sequence<3, 4>{})), std::index_sequence<1, 2, 3, 4>>);

Finally, we need the filtering utility.

template<typename Seq, typename Pred>
struct Filter {};

template<typename N, N n, N ... ns, typename Pred>
struct Filter<std::integer_sequence<N, n, ns...>, Pred> {
    using head = std::integer_sequence<N, n>;
    using tail = typename Filter<std::integer_sequence<N, ns...>, Pred>::type;
    using type = std::conditional_t<Pred{}(n), decltype(cat(head{}, tail{})), tail>;
};

template<typename N, typename Pred>
struct Filter<std::integer_sequence<N>, Pred> {
    using head = std::integer_sequence<N>;
    using type = head;
};

template<typename N, N ...ns, typename Pred>
consteval auto filter(std::integer_sequence<N, ns...>, Pred) {
    return typename details::Filter<std::integer_sequence<N, ns...>, Pred>::type{};
}
static_assert(std::is_same_v<decltype(filter(std::index_sequence<>{}, f)), std::index_sequence<>>);
static_assert(std::is_same_v<decltype(filter(std::index_sequence<1, 2, 3, 4>{}, f)), std::index_sequence<2, 4>>);

With all of that in place, our make_vector, which I'm renaming make_array can be written like this

template<std::size_t n>
consteval auto make_array() {
    return seq2array(filter(std::make_integer_sequence<std::size_t, n>{}, f));
}

and used like this

static_assert(make_array<10>() == std::array<std::size_t, 5>{0, 2, 4, 6, 8});

Notice that the abstractions filter and cat defined above are very general, and could be used in many other cases, so they can be part of a library. That would justify the amount of boilerplate, which is anyway not that much.

1 Comment

This is good, except that the size is limited by the available number of template arguments (at least 256, but for some implementations it's just 256). Upvoted anyway.
1

Jarod's excellent answer has given detailed explanation for why in the current c++ language standard it is impossible to convert a vector to an array without additional helper functions. However, if an upper bound of the size of the vector can be calculated in a relatively simple way, it is still possible to convert the vector to an array by first constructing a larger array. In essence, this is constructing a literal type that contains all the information of the vector and that can be used to construct the actual array later.

Assuming that we have

template <int N>
constexpr std::size_t upper_bound_for_make_vector(); //Should be cheap to calculate

template <int N>
constexpr std::vector<int> make_vector();

We can build a literal type which can be used as a NTTP by modifying the implementation of OP's code a little.

template <int N>
constexpr auto make_array_size_pair() {
  const auto vec = make_vector();
  std::array<int, upper_bound_for_make_vector<N>()> result{};

  std::copy_n(vec.cbegin(), vec.size(), result.begin());
  return std::pair{result, vec.size()};
}

Then we can use the result as a NTTP to construct the actual array,

template <auto arr_pair>
constexpr auto trim_array(){
  std::array<int, arr_pair.second> result{};
  static_assert(arr_pair.first.size() >= result.size());
  std::copy_n(arr_pair.first.cbegin(), result.size(), result.begin());
  return result;
}

With these helpers, we can write our make_array function.

template <int N>
constexpr auto make_array(){
  return trim_array<make_array_size_pair<N>()>();
}

Although this solution is not limited by the total number of template parameters (which can be as low as 256), it may be desired to make trim_array consteval to prevent generating an extremely long symbol name of no other use.

1 Comment

In principle, if the output size could be calculated separately, the problem would not exist. Trimming the upper bound is an improvement. However, it does not completely solve the original problem. For the current state of the standard it's a nice alternative. Thanks!

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.