diff --git a/README.md b/README.md index 8f6066b..b64a802 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ Clang >= 22 and GCC >= 15. | `vector` | Contiguous growable sequence. | | `small_vector` | Same, holding its first `N` elements inline and spilling past them. | | `inline_vector` | Same, bounded at `N` inline elements; allocates nothing, ever, and works at compile time. | +| `constexpr_inline_vector` | Same, for an element type that is `constexpr` default-constructible but not trivially so; default-initialises its slots to get there. | | `fixed_vector` | Same, bounded at `N` with the slots on a resource instead of inline. | | `sparse_set` | Dense-packed id set, O(1) insert / erase / contains, contiguous iteration. | | `sparse_map` | `sparse_set` plus a payload per id; `keys()` and `values()` stay index-aligned. | @@ -52,13 +53,16 @@ libmem::small_vector hits{}; // allocates nothing until `reserve` returns `bool`, and nothing throws `std::bad_alloc`. No container is implicitly copyable either, since a copy constructor could not report the failure; copying is explicit through `clone()` and `try_clone()`. Everything -moves, inline buffers included, and every move is `noexcept`. See +moves, inline buffers included, and every move is `noexcept` unless the element +type's own default constructor can throw. See [docs/containers.md](docs/containers.md). **The inline containers work at compile time.** `inline_vector`, `sparse_set` and `sparse_map` over inline storage constant-evaluate end to end for trivially default-constructible and trivially destructible element types, at no cost in -size or alignment. +size or alignment. `constexpr_inline_vector` opts an element type whose default +constructor is `constexpr` but not trivial into the same thing, for the price of +running it once per slot on every such vector built, moved or cloned. **It interoperates both ways.** A `std::pmr::polymorphic_allocator` can back a libmem container, and `resource_allocator` turns any libmem resource into a diff --git a/docs/complexity.md b/docs/complexity.md index 69c4d2d..e9f70ed 100644 --- a/docs/complexity.md +++ b/docs/complexity.md @@ -70,4 +70,12 @@ element type allows it, so `sizeof` and `alignof` are identical on both branches the array is never zeroed. A container that constant-evaluates costs exactly what the same container costs at run time. +`constexpr_inline_storage` is the one exception, and the reason it is opt-in rather +than a wider trait: it default-initialises its slots, so building one runs `N` +default constructors where `inline_storage` runs none. `sizeof` and `alignof` are +unchanged, but the `N` is per storage object, not per program: a move and a clone +each build a destination and so each pay it again, whatever the element count. A +1024-slot vector holding two elements runs 1024 default constructors to be +constructed, 1024 more to be moved, and 1024 more to be cloned. + See [containers.md](containers.md#constant-evaluation) for which containers qualify. diff --git a/docs/containers.md b/docs/containers.md index ac2e232..22d0546 100644 --- a/docs/containers.md +++ b/docs/containers.md @@ -82,8 +82,11 @@ they had set. `std::vector{4}` sets the same trap; the difference is that up much later as growth that was supposed to be bounded. Every container moves, inline-backed ones included, and every move is `noexcept`. -Where the storage cannot transfer its slots the move relocates the elements and -empties the source, exactly as `std::array` does. That costs O(size) rather than +The one exception is a `constexpr_inline_storage` over an element type whose +default constructor can throw: relocating builds the destination's slots, so that +move is `noexcept` only where building them is, and it propagates rather than +terminating. Where the storage cannot transfer its slots the move relocates the +elements and empties the source, exactly as `std::array` does. That costs O(size) rather than O(1), which is what the container-level `relocatable` constant now reports: a cost, not an availability. Non-movable containers were a standing tax on generic code, so the only non-movable thing left is `spsc_ring`, deliberately, since a @@ -248,6 +251,27 @@ lets that array be left trivially default-initialised inside a `constexpr` constructor, so nothing is zeroed at runtime and `sizeof` is unchanged. `inline_storage::constexpr_usable` reports which branch a `T` takes. +A `T` that is `constexpr` default-constructible but not *trivially* so, which is +any type with a member initialiser or a user-provided `constexpr` constructor, is +not shut out; it has to ask. `constexpr_inline_vector`, or +`constexpr_inline_storage` under a sparse container, holds the same `T[N]` +and default-initialises it: + +```cpp +struct vec2 { float x{}, y{}; }; + +constexpr float folded = [] { + libmem::constexpr_inline_vector v{}; + v.push_back(vec2{1.0F, 2.0F}); + return v[0].x + v[0].y; +}(); +static_assert(folded == 3.0F); +``` + +The `N` default constructions that costs, on every such container built, moved or +cloned, are why it is a separate name rather than a wider trait; +[docs/storage.md](storage.md) has the trade in full. + Raw bytes are the obstacle, not a missing keyword: `reinterpret_cast` is forbidden during constant evaluation, so a byte array can never be viewed as a `T*`. The single-member-union trick that constexpr `std::vector` uses does not help either, diff --git a/docs/storage.md b/docs/storage.md index ef1ec8e..307889f 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -12,6 +12,7 @@ touching its own logic. | `fixed_storage` | compile-time | from a `memory_resource` | | `dynamic_storage` | runtime, geometric growth | from a `memory_resource` | | `small_storage` | runtime, geometric growth | inside the object for the first `N`, then from a `memory_resource` | +| `constexpr_inline_storage` | compile-time | inside the object, default-initialised | ```cpp libmem::spsc_ring small{}; // slots inline @@ -60,6 +61,36 @@ The resource-backed kinds cannot follow, for an unrelated reason: they reach blessed for constant evaluation. Their members carry `constexpr` but can only ever run at run time. +## Opting in: `constexpr_inline_storage` + +That table is a property of `T`, not a choice, and it is stricter than constant +evaluation itself needs. A `T` whose default constructor is `constexpr` but not +trivial (an aggregate with member initialisers, a user-provided `constexpr` +constructor) can hold a real `T[N]` perfectly well. What it cannot hold is an +*uninitialised* one, which is the only thing the trait is protecting. + +`constexpr_inline_storage` makes that trade, named at the call site: + +```cpp +struct vec2 { float x{}, y{}; }; // not trivially default constructible + +libmem::inline_vector plain{}; // raw bytes, run time only +libmem::constexpr_inline_vector folded{}; // vec2[8], constant-evaluable +``` + +The cost is `N` default constructions every time a storage is built, for slots +nobody has used yet, which is the work `inline_storage` exists to avoid. Every +storage object pays it, so a move and a clone each pay it again over the +destination's slots, independently of how many elements are live. Hence a separate +name rather than a relaxed trait: worth paying when it is asked for, not worth +paying silently. Capacity, alignment and `sizeof` are unchanged, and for a +trivially default constructible `T` the two are the same thing in all but name. + +`T` must still be trivially destructible. The slot array destroys its elements, so +a real destructor would run a second time over what the container already +destroyed. `rebind` carries both requirements, which is what a `sparse_map` over +this storage applies to its mapped type. + ## Resources `memory_resource` is the single allocation interface: `allocate(size)` and diff --git a/docs/testing.md b/docs/testing.md index 905d1e3..9924c37 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -54,6 +54,13 @@ being as far as atomics allow. The non-trivial fallback is covered too: a `std::string` element takes the byte-array branch, which cannot constant-evaluate but must still move and clone correctly at run time. +The opt-in storage is pinned the same way, over both shapes the default trait turns +away: `constexpr_inline_vector` runs the same erase-and-clone round trip over an +aggregate with member initialisers and over a user-provided `constexpr` constructor, +and a `sparse_map` over `constexpr_inline_storage` does it with such a type as its +payload. What it costs is a runtime test rather than a `static_assert`, since +counting the default constructions is exactly what a `static_assert` cannot do. + Two helper types carry most of the weight. A counting payload proves no leaks and no double destroys; a payload whose move throws once a budget runs out is what actually instantiates the rollback branch of `relocate_grow`, which every diff --git a/src/libmem/libmem.cppm b/src/libmem/libmem.cppm index 38418d6..c9b13a5 100644 --- a/src/libmem/libmem.cppm +++ b/src/libmem/libmem.cppm @@ -8,7 +8,7 @@ * - **Resources**: `default_resource`, `resource_ref`, `allocator_resource`, `threshold_policy`. * - **Interop**: `resource_allocator`, a standard Allocator over any libmem resource. * - **Identifiers**: `regular_indexable_id`, `null_id_v`, `to_index`. - * - **Storage**: `inline_storage`, `fixed_storage`, `dynamic_storage`, `small_storage`, `relocate_grow`. + * - **Storage**: `inline_storage`, `constexpr_inline_storage`, `fixed_storage`, `dynamic_storage`, `small_storage`, `relocate_grow`. * - **Sparse index**: `flat_sparse_index`, `paged_sparse_index`. * - **Allocators**: `slab`, `multislab`, `arena`, `typed_arena`. * - **Containers**: `pool`, `spsc_ring`, `vector`, `small_vector`, `sparse_set`, `sparse_map`. diff --git a/src/libmem/sparse_index.cppm b/src/libmem/sparse_index.cppm index 9153f41..99f3f49 100644 --- a/src/libmem/sparse_index.cppm +++ b/src/libmem/sparse_index.cppm @@ -94,7 +94,10 @@ public: static constexpr bool growable{growable_storage}; static constexpr bool relocatable{Storage::relocatable}; - constexpr flat_sparse_index() + /* `fill_from` is noexcept, so the storage's own construction is the only thing + * here that can throw. Spelled out because a container's move constructor + * default-constructs one of these and reports its own noexcept from it. */ + constexpr flat_sparse_index() noexcept(std::is_nothrow_default_constructible_v) requires std::default_initializable { fill_from(0); diff --git a/src/libmem/sparse_set.cppm b/src/libmem/sparse_set.cppm index 6fc2277..d430bf8 100644 --- a/src/libmem/sparse_set.cppm +++ b/src/libmem/sparse_set.cppm @@ -52,6 +52,9 @@ * - `inline_storage` gives a fixed set that allocates nothing, holding at * most `N` ids drawn from `[0, N)` (the rebound sparse array has `N` slots * too). `insert` reports failure instead of growing. + * - `constexpr_inline_storage` is that same fixed set with the slots + * default-initialised, which is what keeps a map constant-evaluable over a + * mapped type that is not trivially default constructible. * - `fixed_storage` is the same fixed extent with the slots on a * resource. * @@ -236,11 +239,14 @@ public: /** * @brief Inline-backed set: the sparse index drains and the dense ids are relocated one at a time. * - * @note Requires a non-throwing id move, which keeps every move `noexcept` and - * leaves no half-moved state to unwind: the sparse index is drained in - * the mem-initialiser, before the dense ids are touched. + * @note Requires a non-throwing id move, which leaves no half-moved state to + * unwind: the sparse index is drained in the mem-initialiser, before the + * dense ids are touched. + * @note The dense slots are default-constructed here, so the move is `noexcept` + * only where that construction is; see `constexpr_inline_storage`, the + * one storage for which it may not be. */ - constexpr sparse_set(sparse_set&& other) noexcept + constexpr sparse_set(sparse_set&& other) noexcept(std::is_nothrow_default_constructible_v) requires(!relocatable) && std::default_initializable && std::default_initializable && std::is_nothrow_move_constructible_v : sparse_{std::move(other.sparse_)} { @@ -672,11 +678,15 @@ public: /** * @brief Inline-backed map: the payloads are relocated one at a time. * - * @note Requires a non-throwing payload move, so the move stays `noexcept` and - * leaves no half-moved state. The payloads are relocated *before* the key - * set is moved, so `other.size()` still describes them while they travel. + * @note Requires a non-throwing payload move, so nothing is left half-moved. + * The payloads are relocated *before* the key set is moved, so + * `other.size()` still describes them while they travel. + * @note Both arrays are default-constructed here, so the move is `noexcept` + * only where those constructions are; see `constexpr_inline_storage`, the + * one storage for which they may not be. */ - constexpr sparse_map(sparse_map&& other) noexcept + constexpr sparse_map(sparse_map&& other) noexcept( + std::is_nothrow_default_constructible_v && std::is_nothrow_default_constructible_v) requires(!relocatable) && std::default_initializable && std::default_initializable && std::is_nothrow_move_constructible_v && std::movable { @@ -1070,8 +1080,33 @@ consteval int constexpr_map() { return found ? *found : -1; } +/* A payload with member initialisers is not trivially default constructible, so + * the dense storage has to be asked for typed slots for the map to stay + * constant-evaluable over it. */ +struct sparse_test_value { + float x{}, y{}; +}; + +using constexpr_inline_map = sparse_map>; + +consteval float constexpr_default_init_map() { + constexpr_inline_map m{}; + m.emplace(sparse_test_id{1}, sparse_test_value{1.0F, 2.0F}); + m.emplace(sparse_test_id{2}, sparse_test_value{3.0F, 4.0F}); + m.erase(sparse_test_id{1}); + + const sparse_test_value* found{m.find(sparse_test_id{2})}; + return found ? found->x + found->y : -1.0F; +} + static_assert(constexpr_set() == 8); static_assert(constexpr_map() == 20); +static_assert(constexpr_default_init_map() == 7.0F); + +/* The dense slots and the payloads are default-constructed by the move, so a map + * over the opt-in storage keeps its `noexcept` move only while those cannot throw. */ +static_assert(std::is_nothrow_move_constructible_v); +static_assert(std::is_nothrow_move_constructible_v); template concept has_clone = requires(const S& s) { s.clone(); }; diff --git a/src/libmem/storage.cppm b/src/libmem/storage.cppm index c45e89e..ee9f643 100644 --- a/src/libmem/storage.cppm +++ b/src/libmem/storage.cppm @@ -6,10 +6,13 @@ * else. It never constructs, destroys, or counts elements; the container that * holds it does all of that. * - * Four implementations, differing on where the slots live and whether the - * extent is fixed at compile time: + * Five implementations, differing on where the slots live, whether the extent is + * fixed at compile time, and whether the slots start out initialised: * * - `inline_storage`: N slots inside the object. No allocation. + * - `constexpr_inline_storage`: the same, default-initialised, so a `T` + * whose default constructor is `constexpr` but not + * trivial still constant-evaluates. * - `fixed_storage`: N slots from a `memory_resource`. Static extent, * so `sizeof` stays small no matter how big N is. * - `dynamic_storage`: runtime capacity, geometric growth (`std::vector`-like). @@ -308,25 +311,49 @@ template concept constexpr_inline_slots = std::is_trivially_default_constructible_v && std::is_trivially_destructible_v; /** - * @brief The slot array behind `inline_storage`, chosen so `T[N]` is used where it can be. + * @brief Whether `T[N]` slots are sound for a `T` that default-initialises to something. + * + * What `constexpr_inline_storage` asks of its element type. Trivial destruction is + * the part that is not negotiable: the slot array destroys every element, so a `T` + * with a real destructor would be destroyed twice, once by the container and once + * by the array. + */ +template +concept default_init_inline_slots = std::default_initializable && std::is_trivially_destructible_v; + +/** + * @brief Whether `T` can be default-constructed during constant evaluation. + * + * There is no trait for this, and `is_trivially_default_constructible_v` answers a + * stricter question. Spelled as a value-initialisation in a template argument, + * which is a substitution failure exactly when the constructor cannot run at + * compile time. + */ +template +concept constexpr_default_constructible = default_init_inline_slots && requires { typename std::bool_constant<(static_cast(T{}), true)>; }; + +/** + * @brief The slot array behind the inline storages, chosen so `T[N]` is used where it can be. * * Raw bytes cannot be reinterpreted during constant evaluation, so a byte array * shuts `inline_vector` out of constexpr entirely. A real `T[N]` has no such - * problem and costs nothing extra: P1331 permits leaving it trivially - * default-initialised inside a `constexpr` constructor, so it is not zeroed at - * runtime either. + * problem. + * + * `Typed` is the storage's choice, not a property of `T`: the default picks the + * array only where it costs nothing, `constexpr_inline_storage` asks for it + * knowing that `N` default constructors then run. */ -template > struct inline_slots { - /* Deliberately uninitialised. A braced initialiser here would zero the whole - * array on every construction, which is exactly what this storage exists to - * avoid. */ +template > struct inline_slots { + /* Default-initialised, not braced. For a trivially default constructible `T` + * that leaves the array alone, and P1331 permits leaving it alone inside a + * `constexpr` constructor too; braces would zero it on every construction. */ alignas(Align) T slots[N]; constexpr T* data() noexcept { return slots; } constexpr const T* data() const noexcept { return slots; } }; -/** Non-trivial `T`: raw bytes, and therefore no constant evaluation. */ +/** Untyped slots: raw bytes, and therefore no constant evaluation. */ template struct inline_slots { alignas(Align) std::byte slots[N * sizeof(T)]; @@ -386,6 +413,75 @@ private: detail::inline_slots slots_; }; +/* ============================================================================ + * constexpr_inline_storage: the same slots, default-initialised + * ============================================================================ */ + +/** + * @brief `N` default-initialised slots for `T` held inside the storage object itself. + * + * `N` default constructors run every time one of these is built, which is the cost + * `inline_storage` exists to avoid. Per storage object, not per program: a move or + * a clone builds a destination and so pays it again, whatever the element count. + * That is the trade this storage exists to offer: a `T` whose default constructor is `constexpr` but not trivial (an + * aggregate with member initialisers, a user-provided `constexpr` constructor) + * gets a real `T[N]` and therefore constant evaluation, where `inline_storage` + * would fall back to raw bytes and lose it. + * + * Everything else matches `inline_storage`: same capacity, same alignment, same + * `sizeof`, no allocation, and neither copyable nor movable. For a trivially + * default constructible `T` the two are the same type in all but name, cost + * included. + * + * @code + * struct vec2 { float x{}, y{}; }; // not trivially default constructible + * libmem::constexpr_inline_vector v{}; // constant-evaluable regardless + * @endcode + * + * @tparam T Element type: default-initialisable, and trivially destructible so + * that the slot array does not destroy what the container already did. + * @tparam N Slot count. + * @tparam Align Alignment of the slot array; at least `alignof(T)`. + * + * @warning A `T` whose default constructor throws terminates rather than + * propagating: a container over inline slots default-constructs them + * inside a `noexcept` move. + * @note `rebind` carries the same requirements on `U`, so a `sparse_map` over + * this storage rejects a mapped type that is not default-initialisable. + */ +export template + requires std::is_object_v && (N > 0) && detail::valid_storage_alignment && detail::default_init_inline_slots +class constexpr_inline_storage { +public: + using value_type = T; + using size_type = std::size_t; + template using rebind = constexpr_inline_storage; + + static constexpr size_type static_capacity{N}; + static constexpr size_type alignment{Align}; + static constexpr bool relocatable{false}; + + /** @brief Whether this storage can be used during constant evaluation. */ + static constexpr bool constexpr_usable{detail::constexpr_default_constructible}; + + constexpr constexpr_inline_storage() noexcept(std::is_nothrow_default_constructible_v) = default; + + constexpr_inline_storage(const constexpr_inline_storage&) = delete; + constexpr_inline_storage& operator=(const constexpr_inline_storage&) = delete; + constexpr_inline_storage(constexpr_inline_storage&&) = delete; + constexpr_inline_storage& operator=(constexpr_inline_storage&&) = delete; + + constexpr T* data() noexcept { return slots_.data(); } + constexpr const T* data() const noexcept { return slots_.data(); } + + static constexpr size_type capacity() noexcept { return N; } + +private: + /* `true`, not the trait: asking for the typed array whatever `T` is, is the + * whole point of this storage. */ + detail::inline_slots slots_; +}; + /* ============================================================================ * fixed_storage: compile-time extent, slots on a resource * ============================================================================ */ @@ -784,7 +880,7 @@ export template bool relocate_grow(S& store, const typename return true; } -/* Concept verification across the four storage kinds. */ +/* Concept verification across the five storage kinds. */ static_assert(storage_for, int>); static_assert(fixed_extent_storage>); static_assert(!growable_storage>); @@ -823,6 +919,49 @@ static_assert(small_storage::rebind::inline_capacity == 8); static_assert(std::same_as>::rebind, dynamic_storage>>); static_assert(dynamic_storage::rebind::alignment == alignof(char)); +namespace detail { + +/* Not trivially default constructible, so `inline_storage` gives it raw bytes and + * `constexpr_inline_storage` is the only way it reaches constant evaluation. */ +struct nsdmi_slot { + float x{}, y{}; +}; + +/* Default-constructible, but not at compile time. */ +struct runtime_slot { + int v; + + runtime_slot() : v{} {} +}; + +} // namespace detail + +static_assert(storage_for, int>); +static_assert(fixed_extent_storage>); +static_assert(!growable_storage>); +static_assert(!resourced_storage>); +static_assert(!constexpr_inline_storage::relocatable); +static_assert(std::same_as::rebind, constexpr_inline_storage>); + +/* The opt-in changes what is constructed, never the layout. */ +static_assert(sizeof(constexpr_inline_storage) == 64 * sizeof(detail::nsdmi_slot)); +static_assert(alignof(constexpr_inline_storage) == alignof(detail::nsdmi_slot)); +static_assert(constexpr_inline_storage::alignment == cache_line_size); + +/* What it costs, in the only terms that matter: the default constructions the + * element type itself implies, which for a trivial one is still none. */ +static_assert(std::is_trivially_default_constructible_v>); +static_assert(!std::is_trivially_default_constructible_v>); +static_assert(std::is_trivially_default_constructible_v>); + +/* What it buys, and the case it cannot rescue. */ +static_assert(!inline_storage::constexpr_usable); +static_assert(constexpr_inline_storage::constexpr_usable); +static_assert(!constexpr_inline_storage::constexpr_usable); +/* A destructor to run is refused outright rather than reported: the slot array + * would run it over what the container already destroyed. */ +static_assert(!detail::default_init_inline_slots); + } // namespace libmem // NOLINTEND(cppcoreguidelines-pro-type-reinterpret-cast, cppcoreguidelines-owning-memory, cppcoreguidelines-pro-bounds-pointer-arithmetic) diff --git a/src/libmem/vector.cppm b/src/libmem/vector.cppm index 8ba1e79..eb97a8b 100644 --- a/src/libmem/vector.cppm +++ b/src/libmem/vector.cppm @@ -22,7 +22,10 @@ * * @section vector_moves Moving and copying * - * Every variant moves, and every move is `noexcept`. A `relocatable` storage + * Every variant moves, and every move is `noexcept` where the storage can be + * default-constructed without throwing, which is every storage but a + * `constexpr_inline_storage` over an element whose own default constructor can + * throw. A `relocatable` storage * transfers its buffer; otherwise the elements are relocated into the * destination's own slots and the source is emptied. The `relocatable` constant * reports which of the two happens, not whether a move is available. @@ -164,11 +167,15 @@ public: * this object's own slots and the source is emptied. It is what `std::array` * does, and it is what keeps every vector usable in generic code. * - * @note Requires a non-throwing element move, so that every vector's move is - * `noexcept` and a `std::vector>` reallocates by moving - * rather than falling back to copying. + * @note Requires a non-throwing element move, so that a + * `std::vector>` reallocates by moving rather than + * falling back to copying. + * @note The destination's slots are default-constructed here, so the move is + * `noexcept` only where the storage's own default construction is. Every + * storage but `constexpr_inline_storage` is unconditionally nothrow, and + * that one only when its element type is. */ - constexpr basic_vector(basic_vector&& other) noexcept + constexpr basic_vector(basic_vector&& other) noexcept(std::is_nothrow_default_constructible_v) requires(!Storage::relocatable) && std::default_initializable && std::is_nothrow_move_constructible_v { take_over(other); @@ -566,6 +573,15 @@ export template using inline_vector = basic_vector>; +/** + * @brief `inline_vector` whose slots are default-initialised, for a `T` that is `constexpr` default constructible but not trivially so. + * + * The opt-in that trades `N` default constructions at construction for constant + * evaluation over an element type `inline_vector` can only hold as raw bytes. See + * `constexpr_inline_storage` for the requirements on `T` and for what the cost is. + */ +export template using constexpr_inline_vector = basic_vector>; + /** @brief Bounded vector of `N` elements with the slots on a resource instead of inline. */ export template using fixed_vector = basic_vector>; @@ -580,13 +596,16 @@ static_assert(std::same_as>, int&>); static_assert(std::same_as>, const int&>); static_assert(std::constructible_from, std::from_range_t, std::span>); -/* Every variant moves, including the inline ones, and every move is noexcept so +/* Every variant moves, including the inline ones, and every move over a + * nothrow-default-constructible storage is noexcept so * generic code relocates by moving rather than falling back to copying. */ static_assert(std::movable>); static_assert(std::movable>); static_assert(std::movable>); +static_assert(std::movable>); static_assert(std::movable>); static_assert(std::is_nothrow_move_constructible_v>); +static_assert(std::is_nothrow_move_constructible_v>); static_assert(std::is_nothrow_move_constructible_v>); /* `relocatable` is now a cost, not an availability: true where a move is a @@ -594,11 +613,14 @@ static_assert(std::is_nothrow_move_constructible_v>); static_assert(vector::relocatable); static_assert(fixed_vector::relocatable); static_assert(!inline_vector::relocatable); +static_assert(!constexpr_inline_vector::relocatable); static_assert(!small_vector::relocatable); static_assert(vector::growable && small_vector::growable); static_assert(!inline_vector::growable && !fixed_vector::growable); +static_assert(!constexpr_inline_vector::growable); static_assert(inline_vector::static_capacity == 8); +static_assert(constexpr_inline_vector::static_capacity == 8); static_assert(sizeof(small_vector) >= 8 * sizeof(int)); /* Copying is never implicit; a deep copy goes through clone / try_clone. */ @@ -638,12 +660,66 @@ consteval std::size_t constexpr_clone() { return b.size(); } +/* The two shapes the opt-in storage exists for: an aggregate with member + * initialisers, and a user-provided `constexpr` default constructor. Neither is + * trivially default constructible, so `inline_vector` holds both as raw bytes. */ +struct nsdmi_point { + int x{}, y{}; +}; + +struct summed { + int v; + + constexpr summed() : v{} {} + constexpr summed(const int a, const int b) : v{a + b} {} +}; + +/** @brief The same round trip over an element `inline_vector` could not constant-evaluate. */ +consteval int constexpr_default_init_round_trip() { + constexpr_inline_vector v{}; + + for (int i{}; i < 5; ++i) { + v.push_back(nsdmi_point{i * 2, i}); + } + v.erase(v.begin()); + v.pop_back(); + + int sum{}; + for (const nsdmi_point& p : v) { + sum += p.x; + } + return sum; +} + +/** @brief Erase, re-emplace over the freed slot, then clone: the whole lifetime path on a live `T[N]`. */ +consteval int constexpr_default_init_clone() { + constexpr_inline_vector a{}; + + a.emplace_back(2, 3); + a.emplace_back(10, 20); + a.erase(a.begin()); + a.emplace_back(1, 1); + + const constexpr_inline_vector b{a.clone()}; + + int sum{}; + for (const summed& s : b) { + sum += s.v; + } + return sum; +} + } // namespace detail /* {0,2,4,6,8} minus the first and the last is {2,4,6}. */ static_assert(detail::constexpr_round_trip() == 12); static_assert(detail::constexpr_clone() == 2); +/* Same fold, same result, over a type the trait excludes. */ +static_assert(detail::constexpr_default_init_round_trip() == 12); +/* {5,30} minus the first, plus 2, is {30,2}. */ +static_assert(detail::constexpr_default_init_clone() == 32); + /* The slot array is a real T[N] where T allows it, so the storage costs nothing * extra and stays exactly as large as before. */ static_assert(inline_storage::constexpr_usable); @@ -658,6 +734,22 @@ static_assert(!inline_storage::constexpr_usable); static_assert(sizeof(inline_storage) == 4 * sizeof(std::string)); static_assert(std::movable>); +/* The opt-in moves that line, it does not erase it: a destructor to run keeps `T` + * out either way, since the slot array would run it a second time. */ +static_assert(!inline_storage::constexpr_usable); +static_assert(constexpr_inline_storage::constexpr_usable); + +/* The move relocates into slots it default-constructs, so it is `noexcept` exactly + * where that construction is. `summed` has a user-provided constructor with no + * exception specification, which is the common shape and is potentially-throwing; + * the move must report that rather than promise `noexcept` and terminate. */ +static_assert(std::is_nothrow_default_constructible_v); +static_assert(!std::is_nothrow_default_constructible_v); +static_assert(std::is_nothrow_move_constructible_v>); +static_assert(!std::is_nothrow_move_constructible_v>); +/* Movable either way: the narrowing costs the promise, never the operation. */ +static_assert(std::movable>); + namespace detail { /* Spelled as concepts rather than as `requires` inside the static_asserts below: @@ -673,12 +765,14 @@ concept has_try_clone = requires(const V& v) { v.try_clone(); }; /* clone is offered only where it cannot report a failure; try_clone everywhere. */ static_assert(detail::has_clone>); +static_assert(detail::has_clone>); static_assert(detail::has_clone>); static_assert(!detail::has_clone>); static_assert(!detail::has_clone>); static_assert(detail::has_try_clone>); static_assert(detail::has_try_clone>); +static_assert(detail::has_try_clone>); static_assert(std::same_as&>().try_clone()), std::optional>>); static_assert(std::same_as&>().clone()), inline_vector>); diff --git a/tests/src/storage_test.cpp b/tests/src/storage_test.cpp index 23f93e5..2b6ce92 100644 --- a/tests/src/storage_test.cpp +++ b/tests/src/storage_test.cpp @@ -158,6 +158,23 @@ class limited_resource { static_assert(libmem::aligned_memory_resource); +/* A payload that is default-constructible and trivially destructible but not + * trivially default constructible: the shape `constexpr_inline_storage` exists + * for. Counts its default constructions, so the opt-in cost is measurable. */ +struct default_counted { + static int defaults; + + int value{-1}; + + default_counted() { ++defaults; } + explicit default_counted(const int v) : value{v} {} +}; + +int default_counted::defaults{}; + +static_assert(!std::is_trivially_default_constructible_v); +static_assert(std::is_trivially_destructible_v); + /* ============================================================================ * Concept surface * ============================================================================ */ @@ -242,6 +259,47 @@ TEST(InlineStorage, ConstructsNothingOnItsOwn) { EXPECT_EQ(tracked::live, 0); } +/* ============================================================================ + * constexpr_inline_storage + * ============================================================================ */ + +TEST(ConstexprInlineStorage, DefaultConstructsEverySlotWhereInlineStorageConstructsNone) { + default_counted::defaults = 0; + { + const libmem::inline_storage untouched{}; + EXPECT_EQ(untouched.capacity(), 8u); + EXPECT_EQ(default_counted::defaults, 0) << "the default storage still constructs nothing"; + } + + default_counted::defaults = 0; + { + libmem::constexpr_inline_storage store{}; + EXPECT_EQ(default_counted::defaults, 8) << "the opt-in pays N default constructions up front"; + EXPECT_EQ(store.data()[7].value, -1) << "and the slots hold what the element type says they hold"; + + std::construct_at(store.data(), 42); + EXPECT_EQ(store.data()->value, 42); + } +} + +TEST(ConstexprInlineStorage, MatchesInlineStorageEverywhereElse) { + libmem::constexpr_inline_storage store{}; + + EXPECT_EQ(store.capacity(), 16u); + EXPECT_EQ(sizeof(store), 16u * sizeof(default_counted)); + + const auto* base{reinterpret_cast(&store)}; + const auto* slots{reinterpret_cast(store.data())}; + EXPECT_EQ(base, slots) << "slots should be the object's own bytes"; +} + +TEST(ConstexprInlineStorage, HonoursAnExplicitAlignment) { + libmem::constexpr_inline_storage store{}; + + EXPECT_EQ(alignof(decltype(store)), libmem::cache_line_size); + EXPECT_EQ(reinterpret_cast(store.data()) % libmem::cache_line_size, 0u); +} + /* ============================================================================ * fixed_storage * ============================================================================ */ diff --git a/tests/src/vector_test.cpp b/tests/src/vector_test.cpp index 9a8b244..bb4face 100644 --- a/tests/src/vector_test.cpp +++ b/tests/src/vector_test.cpp @@ -214,6 +214,50 @@ TEST(VectorConcepts, ANonTrivialElementFallsBackToBytesButStillWorks) { EXPECT_EQ(cloned[0], "hello"); } +TEST(VectorConcepts, ConstexprInlineVectorTakesTheElementsTheTraitExcludes) { + /* An aggregate with member initialisers is constexpr-constructible but not + * trivially so, which is exactly what the default storage turns away. */ + struct point { + int x{}, y{}; + }; + static_assert(!libmem::inline_storage::constexpr_usable); + static_assert(libmem::constexpr_inline_storage::constexpr_usable); + + constexpr int folded{[] { + libmem::constexpr_inline_vector v{}; + v.push_back(point{10, 1}); + v.push_back(point{20, 2}); + v.emplace_back(30, 3); + v.erase(v.begin()); + + int sum{}; + for (const point& p : v) { + sum += p.x; + } + return sum; + }()}; + static_assert(folded == 50); + EXPECT_EQ(folded, 50); + + /* Nothing but the initialisation changed: same layout, same bounded + * behaviour, same explicit copy. */ + static_assert(sizeof(libmem::constexpr_inline_storage) == 1024 * sizeof(point)); + static_assert(!libmem::constexpr_inline_vector::growable); + + libmem::constexpr_inline_vector v{}; + ASSERT_NE(v.push_back(point{1, 2}), nullptr); + ASSERT_NE(v.push_back(point{3, 4}), nullptr); + EXPECT_EQ(v.push_back(point{5, 6}), nullptr) << "still reports full rather than growing"; + + const libmem::constexpr_inline_vector moved{std::move(v)}; + EXPECT_EQ(moved.size(), 2u); + EXPECT_EQ(moved[1].y, 4); + + const auto cloned{moved.clone()}; + EXPECT_EQ(cloned.size(), 2u); + EXPECT_EQ(cloned[0].x, 1); +} + TEST(VectorConcepts, IsAContiguousRangeThroughConst) { static_assert(std::ranges::contiguous_range>); static_assert(std::ranges::contiguous_range>);