Idiom for initializing an std::array using a generator function taking the index?
Asked Answered
A

2

6

Suppose I have a function T foo(size_t i). What would be an elegant and succinct way of constructing an object arr, of type std::array<T, N>, so that we have arr[i] == foo(i)?

If possible, I would like for this construction to work even when T is not a default-initializable type.

Notes:

  • Since T is not default-initializable, the code can't start with std::array<T, N> arr; and then some initialization.
  • The code must work for any value of N, generically.
Amagasaki answered 26/6, 2024 at 12:28 Comment(16)
Dupe: Create STL array of struct with const class memberOlen
@user12002570: Related, but not a dupe.Amagasaki
Does eel.is/c++draft/array#cons not implying that T must be default constructible? (honestly I'm not able to follow the threads of the standard to the end...)? I would have obviously say so if we were using the array implicit default constructor but if we don't?Zinc
@Oersted: (Edited) I think you're misunderstanding... in order to have a default ctor for the array, the element type needs to be default constructible, and so on. That's what I believe that paragraph is about.Amagasaki
@Amagasaki There was no need to reopen this question. The dupe does answer your question. It is trivial to change the demo in the dupe. For example just by changing double to Generator and p to p(I) it starts working. Here is the demo after doing the changes. Also the answer is just a copy of the answer in the dupe with some minor rephrasing.Olen
Does this answer your question? Create STL array of struct with const class member. I agree that there was no need to reopen the question and reopening this looks like misuse of power as there is an exact dupe.Babblement
While the only answer so far is not too different from the answer in the other question, in general it does not work for "any value of N" due to limits related to templates in the implementation, as I have already pointed out in the comment in the only answer so far. The other obvious option, which is constructing a local array from a lambda and returning it to initialize the actual array, works for const array cases but not when the type is not default-constructible. So there are solutions that is specific to the question here to address that issue. I don't agree it's a dupe.Spenser
Another possible solution is to use ::operator new and take advantage of the fact that arrays are implicit lifetime to create a local array in a lambda, then returning it. This requires C++20 though.Spenser
@WeijunZhou can you elaborate on this idea? I did something similar with plain arrays (allocating a storage for an array then using placement new to initialize each object individually) but I don't see how to apply it to an std::arrayZinc
@Zinc It depends on whether std::array itself is implicit lifetime (I suppose it is). On the other hand, I have tried to construct a plain array as you described and convert it using std::to_array, but I'm surprised to see that std::to_array has the same limitation for N like std::index_sequence, I had thought that the implementation can do it via intrinsics without relying on std::index_sequence.Spenser
@WeijunZhou aggregates are implicit lifetime, thus std::array is also. I could go with that but it's unsurprisingly leaking. It may work only if your keeping a smart pointer to std::array instead of a plain one. Maybe it's possible to improve on that. I don't see for now Besides it won't work in constant initialization context.Zinc
@Zinc You can have a local std::array that is initialized from your reinterpret_cast result, deallocate the buffer in the lambda, then return the local std::array. This will result in extra copy and/or moves, but fixes the leak (except when the constructor of T throws). I don't see how it can be done in a better way either. Also I'm not sure whether you need to std::launder the pointer in the lambda.Spenser
@WeijunZhou It gives that. I'd have to dig into what kind of constructor is called (move/copy, for the array and or the objects). Still can't use it in constant evaluated context due to placement new. I don't know if it would be possible to wrap also the placement deletion of the objects and getting constexpr but this post answer seem to say no.Zinc
@WeijunZhou it may also temporarily double the memory usage (I don't know if it can be optimized away)Zinc
@Zinc It looks good enough to me. TIL that placement new cannot be used in constexpr function at all (unlike the default operator new), that is sad. You may consider posting it as an answer (after fixing the alignment issue), and list the caveats you mentioned in the answer.Spenser
@Zinc Adding a std::string seems to have issues.Spenser
A
6

You could implement a helper function template:

template<std::size_t N, typename Generator>
auto generate_array(Generator gen) -> std::array<decltype(gen(0)), N>;

using some template metaprogramming voodoo:

namespace detail {

template<typename T, std::size_t... Is, typename Generator>
auto generate_array(std::index_sequence<Is...>, Generator gen)
-> std::array<T, sizeof...(Is)>
{
    return std::array<T, sizeof...(Is)>{ gen(Is) ... };
}

} // namespace detail

template<std::size_t N, typename Generator>
auto generate_array(Generator gen) -> std::array<decltype(gen(0)), N>
{
    return detail::generate_arr<decltype(gen(0))>(
        std::make_index_sequence<N>{}, std::forward<Generator>(gen));
}

(this uses a technique known as the "indices trick".)

Example of use

If you write:

auto gen = [](std::size_t i) { return  11*(i+1); };
auto arr = generate_array<5>(gen);
for(auto i : arr) { std::cout << i << ' ';}
std::cout << '\n';

the program should produce:

11 22 33 44 55

And you can see it in action on GodBolt.

Notes

  • The implementation is C++14; but only because of the use of std::index_sequence; if you implement it yourself in C++11, you can adapt the generator function and have a pure-C++11 solution.
  • As @WeijunZhou notes, this solution is limited by the compilers ability to expand template parameter packs. So, an N of 500,000 may well fail.
  • This utility function can be thought of as generalizing this one, in an answer by @Jarod42, which creates an std::array of constant values.
  • With C++20, as @Jarod42 points out, you can implement detail::generate_array() as a templated lambda inside the "outer" generate_array().
Amagasaki answered 26/6, 2024 at 12:28 Comment(11)
And in C++20, details can be omit thanks to "template lambda".Dniester
This does not really work for any N, it is limited by the implementation limit of template instantiations. godbolt.org/z/TzEvdTs89Spenser
Not sure whether something like this is UB or not. If not UB it would work for large N.Spenser
@WeijunZhou: Fair point. About your second link... is that supposed to work? :-OAmagasaki
@Amagasaki It is supposed to work, but I'm not sure whether it is UB or not because I used arr in the lambda and the lambda is used as the initializer for arr. Edit: Sorry I missed your emoji.Spenser
@WeijunZhou: It is the emoji of one's jaw dropping. I still wonder how that can be legitimate.Amagasaki
I'm not sure about how that can be legitimate either, but I will leave that comment here and see what others comment about it. If it indeed turns out to be legitimate (I would also be surprised if it is), I will make it an answer. This is the link with sanitizers enabled.Spenser
@Dniester Question posted. I look forward to your answer and I would also be grateful if anyone can help with the wordings.Spenser
@WeijunZhou ASFAU the "self-capturing-lambda" version is UB: https://mcmap.net/q/492648/-assigning-an-int-39-s-own-address-to-its-value/21691539. Capturing the reference is legal but not accessing it.Zinc
I'm not so sure about my precedent comment. I can't find (so far) if "point of declaration" can starts the lifetime of an implicit lifetime object. I don't think so that can't find the appropriate rule so far. The linked question (and the one that it refers to) does not seem to be properly answered in fact...Zinc
@WeijunZhou I saw your specific question on your solution (https://mcmap.net/q/865020/-is-it-legal-to-initialize-an-array-via-a-functor-which-takes-the-array-itself-as-a-parameter-by-reference) and asked for precisions on the answer(s).Zinc
Z
0

Sorry for this quite long answer but the more I dig, the more I find (IMHO) interesting things to present and discuss.

I'm proposing two solutions:

  • the first one uses directly the desired stored type at the price of low level memory handling;
  • the second one is more a workaround, by wrapping the stored type.

The first solution is a debatable technique, built from discussion with @WeijunZhou, aiming at overcoming the limitation of a std::index_sequence-based solution:

template <std::size_t N, std::constructible_from<std::size_t> T,
          typename Generator>
    requires(!std::is_default_constructible_v<T>)
std::array<T, N> make_array(Generator gen) {
    // is std::array<T, N> aliasable with a plain array
    static_assert(sizeof(std::array<T, N>) == N * sizeof(T));

    // T is not default constructible: low level memory manipulation

    // creating storage on std::byte to benefit from "implicit object creation"
    // adding a few bytes in order to absorb alignment
    // NB probably useless for non-overaligned or not new-extended aligned type
    constexpr std::size_t RequiredSize = sizeof(std::array<T, N>) + alignof(T);
    auto Storage = new std::byte[RequiredSize];
    void* AlignedStorage = Storage;
    auto Size = RequiredSize;
    if (!std::align(alignof(T), N, AlignedStorage, Size)) {
        delete[] Storage;
        throw std::bad_alloc();
    }
    // laundering
    std::array<T, N>* const Array =
        std::launder(static_cast<std::array<T, N>*>(AlignedStorage));

    // initializing objects in the storage
    T* it = Array->data();
    for (std::size_t i = 0; i != N; ++i) {
        new (it) T(gen(static_cast<int>(i)));
        ++it;
    }

    if constexpr (std::is_trivially_destructible_v<T>) {
        // T is trivially destructible: objects don't need to be destroyed,
        // we can merely release storage after moving the usefull data
        std::unique_ptr<std::byte[]> p(Storage);
        return std::move(*Array);
    } else {
        // T is not trivially destructible: objects explicit destruction
        // required after moving the usefull data
        auto DestroyPuned = [toDelete = Array->data()](std::byte p[]) {
            for (std::size_t i = 0; i != N; ++i) {
                toDelete[i].~T();
            }
            delete[] p;
        };
        std::unique_ptr<std::byte[], decltype(DestroyPuned)> p(Storage,
                                                               DestroyPuned);
        return std::move(*Array);
    }
}

And a working example:

#include <array>
#include <concepts>
#include <cstddef>
#include <iostream>
#include <memory>
#include <new>
#include <string>
#include <type_traits>
#include <utility>

struct Int {
    int i{};
    std::string s;
    explicit Int(int val) : i(val), s{} {}
    Int() = delete;
    ~Int() = default;

    // leaving other operators there in order to not miss one by accident
    Int(const Int&) = default;
    Int(Int&&) = default;
    Int& operator=(const Int&) = default;
    Int& operator=(Int&&) = default;
};

template <std::size_t N, std::constructible_from<std::size_t> T,
          typename Generator>
    requires(std::is_default_constructible_v<T>)
constexpr std::array<T, N> make_array(Generator gen) {
    // simple version when T is default-constructible
    std::array<T, N> Storage;
    for (std::size_t i = 0; i != N; ++i) {
        Storage[i] = gen(static_cast<int>(i));
    }
    return Storage;
}

template <std::size_t N, std::constructible_from<std::size_t> T,
          typename Generator>
    requires(!std::is_default_constructible_v<T>)
std::array<T, N> make_array(Generator gen) {
    // T is not default constructible: low level memory manipulation

    // creating storage on std::byte to benefit from "implicit object creation"
    // adding a few bytes in order to absorb alignment
    // NB probably useless for non-overaligned or not new-extended aligned type
    constexpr std::size_t RequiredSize = sizeof(std::array<T, N>) + alignof(T);
    auto Storage = new std::byte[RequiredSize];
    void* AlignedStorage = Storage;
    auto Size = RequiredSize;
    if (!std::align(alignof(T), N, AlignedStorage, Size)) {
        delete[] Storage;
        throw std::bad_alloc();
    }
    // laundering
    std::array<T, N>* const Array =
        std::launder(static_cast<std::array<T, N>*>(AlignedStorage));

    // initializing objects in the storage
    T* it = Array->data();
    for (std::size_t i = 0; i != N; ++i) {
        new (it) T(gen(static_cast<int>(i)));
        ++it;
    }

    if constexpr (std::is_trivially_destructible_v<T>) {
        // T is trivially destructible: objects don't need to be destroyed,
        // we can merely release storage after moving the usefull data
        std::unique_ptr<std::byte[]> p(Storage);
        return std::move(*Array);
    } else {
        // T is not trivially destructible: objects explicit destruction
        // required after moving the usefull data
        auto DestroyPuned = [toDelete = Array->data()](std::byte p[]) {
            for (std::size_t i = 0; i != N; ++i) {
                toDelete[i].~T();
            }
            delete[] p;
        };
        std::unique_ptr<std::byte[], decltype(DestroyPuned)> p(Storage,
                                                               DestroyPuned);
        return std::move(*Array);
    }
}

int main() {
    constexpr std::size_t N = 50000;
    auto gen = [](int i) { return Int(i); };
    std::array<Int, N> arr = make_array<N, Int>(gen);
    // // following line does not compile because make_array couldn't be made
    // // constexpr
    // constexpr std::array<Int, N> arr2 = make_array<N, Int>(gen);
    arr[10] = Int(2);
    std::cout << (arr[10].i) * (arr[3].i) << '\n';
    return (arr[10].i) * (arr[3].i);
}

Live with sanitizer
Live without sanitizer
Live without sanitizer, nor string (more readable assembly)

It allows for larger arrays than the std::index_sequence-based solution with, also, shorter build time.

The idea is to dynamically initialize a storage for a std::array<Int, N> which starts its lifetime (which is possible because its an aggregate and has implicit lifetime property). Im' laundering the array address, then doing placement new to create the objects with the desired constructor. NB the dynamically allocated storage is managed by a std::unique_ptr in order to be automatically released when leaving make_array, after having moved the data. It requires a custom deleter in order to properly destroy the objects created with placement new. NB if the given type is default-constructible, I'm providing a simpler overload It has several drawbacks though:

  • might require up to 2 times the required memory, if the newly allocated array is not optimized out;
  • it can't be used in constant evaluated context because of the pointer casting and, up to some point, the usage of std::unique_ptr;
  • I'm unsure of the actual constructor used for std::array and Int (copy or move);
  • obviously (see generated assembly) compilers are not smart enough to optimize away generation;
  • for msvc, I must increase the stack with /F option.

NB The alignment management maybe simplified (see Getting heap storage with proper alignment in C++ for non-overaligned type).


Second version: here is a possible improvement replacing the dynamic array by a static one wrapped into a structure whos destructor will be used to destroy the individual objects. I keep the original version above for reference:

template <std::size_t N, std::constructible_from<std::size_t> T,
          typename Generator>
    requires(!std::is_default_constructible_v<T>)
std::array<T, N> make_array(Generator gen) {
    // creating storage on std::byte to benefit from "implicit object creation"
    alignas(alignof(T)) std::byte storage[sizeof(std::array<T, N>)];
    std::array<T, N>* const Array =
        std::launder(reinterpret_cast<std::array<T, N>*>(storage));

    // initializing objects in the storage
    T* it =
        Array->data();  // construct objects in storage through placement new
    for (std::size_t i = 0; i != N; ++i) {
        std::construct_at(it, gen(static_cast<int>(i)));
        // new (it) T(gen(static_cast<int>(i)));
        ++it;
    }

    // forcing move semantic for moving the contained objects instead of
    // copying should be legal as std::array has the correct layout and is
    // implicit lifetime
    if constexpr (!std::is_trivially_destructible_v<T>) {
        // auxiliary struct used to trigger the call of destructors on
        // the stored objects also managed alignement
        struct Deleter {
            T* toDelete;
            ~Deleter() {
                for (std::size_t i = 0; i != N; ++i) {
                    toDelete[i].~T();
                }
            }
        };
        Deleter todelete{Array->data()};
        return std::move(*Array);
    } else {
        return std::move(*Array);
    }
}

Live with sanitizer
Live without sanitizer
Live without sanitizer, nor string


Here is the workaround technique:

template <std::constructible_from<std::size_t> T>
struct MakeDefaultConstructible : public T {
    using T::T;
    constexpr MakeDefaultConstructible() : T(0) {};
};

template <std::size_t N, std::default_initializable T, typename Generator>
constexpr std::array<T, N> make_array(Generator gen) {
    // is there a way to remove the named array in order to guarantee RVO?
    // through a lambda taking a std::array<T, N>&&?
    std::array<T, N> tmp;
    for (std::size_t i = 0; i < N; ++i) {
        tmp[i] = gen(i);
    }
    return tmp;
};

I merely inherit publicly the original type into a default constructible type using the "by integral value" constructor of the original type for default construction.

Then the initialization function is straightforward.

It can be used like this:

#include <array>
#include <concepts>
#include <cstddef>

#define USESTRING
#ifdef USESTRING
#include <string>
#endif

struct Int {
    int i;
#ifdef USESTRING
    std::string s;
    Int(int val) : i(val), s{} {}
#else
    constexpr Int(int val) : i(val) {}
#endif
    Int() = delete;
    Int(const Int&) = default;
    Int(Int&&) = default;
    Int& operator=(const Int&) = default;
    Int& operator=(Int&&) = default;
    ~Int() = default;
};

template <std::constructible_from<std::size_t> T>
struct MakeDefaultConstructible : public T {
    using T::T;
    constexpr MakeDefaultConstructible() : T(0) {};
};

template <std::size_t N, std::default_initializable T, typename Generator>
constexpr std::array<T, N> make_array(Generator gen) {
    // is there a way to remove the named array in order to guarantee RVO?
    // through a lambda taking a std::array<T, N>&&?
    std::array<T, N> tmp;
    for (std::size_t i = 0; i < N; ++i) {
        tmp[i] = gen(i);
    }
    return tmp;
};

int main() {
    constexpr std::size_t N = 50000;
    auto gen = [](std::size_t i) {
        return MakeDefaultConstructible<Int>(static_cast<int>(i));
    };
    std::array<MakeDefaultConstructible<Int>, N> arr =
        make_array<N, MakeDefaultConstructible<Int>>(gen);
    arr[10] = MakeDefaultConstructible<Int>(2);
#ifdef USESTRING
    return (arr[10].i) * (arr[3].i);
#else
    constexpr std::array<MakeDefaultConstructible<Int>, N> arr2 =
        make_array<N, MakeDefaultConstructible<Int>>(gen);
    return (arr[10].i) * (arr2[3].i);
#endif
}

Live with sanitizer
Live without sanitizer
Live without sanitizer, nor string

NB the concept check can be used also in the first solution

This time the compiler can optimize more aggressively. Yet in make_array, tmp is not required to be optimized out (NRVO not mandatory). I think that it can be improved to use RVO but I don't see how at the moment. An idea in the comments would be appreciated.


Open question: the first solution, in both versions, scales up fine with the number of elements (I tried 500000 in compiler explorer, without std::string, successfuly) but it fails with the second one:

note: constexpr evaluation hit maximum step limit; possible infinite loop?

while, when in works, the code is fully optimized out:

main:
        mov     eax, 6
        ret

I don't feel it worth another question but if someone understands why, a comment could be left.
(my guess, as hinted by the compiler note, is that in first version, I cannot use constexpr initialization and in second one, it's when I try to initialize a constexpr array that the compiler fails (and, indeed, if I remove the constexpr the code compiles successfully on clang and times out on gcc (which is merely a compiler explorer limitation, I think)).
Strangely enough, is also the fact that the most agressive optimization is reached even though one of the array is not constexpr. I thought it was because arr[10] value was in plain sight for compilers but if I remove its modification gcc still optimizes everything away (but not clang)...

Zinc answered 27/6, 2024 at 8:53 Comment(8)
Upvoted but please fix the issue of alignment.Spenser
@WeijunZhou done. It might be possible to pass std::align_val_t(alignof(Int)) to new but I'm unsure. I don't find the doc really clear. Notice that std::aligned_alloc is not suitable as it does not start objects lifetime.Zinc
Suggest you separate the general solution from the example of its use, to clarify things for readers. Also, while I appreciate this idea, I don't think I can in good conscience suggest this to people for putting in their codebases. I'd rather people fall on their face with the limit on N, than succeeding only to be hit by the UB or the extra construction, in some oblique and surprising way at some point down the line.Amagasaki
@Amagasaki I don't think that there is UB in this code. My primary concern would be with the extra construction and the leak that was demonstrated (if I cannot fix it, then it would be a no go for me). If there is an UB (which is possible) could you pinpoint it? Btw what refactoring are you asking for? Replacing the lambda by a free function as in your answer?Zinc
Yes, the free function template would be the answer to the question.Amagasaki
investigating if I should not std::move *reinterpret_cast<std::array<T, N>*>(p.get() + Offset)Zinc
@Amagasaki I see a potential UB: I made the assumption that the memory layout of std::array is the same as a plain array (aggregate type and so on). But I'm far from being sure of that. A [language-lawyer] would be necessary.Zinc
There are currently exactly 2 downvotes on the question and both the answers. Given the discussion/disagreement in the comments on the question, about whether the question is a duplicate, I suspect (but obviously can't prove) that the downvotes are a related to curation rather than the technical content in this post.Christmas

© 2022 - 2025 — McMap. All rights reserved.