Skip to content

Match std::vector on size limits, cover the untested move path, add the C++23 range members - #100

Merged
martinus merged 4 commits into
mainfrom
conformance-and-ranges
Aug 5, 2026
Merged

Match std::vector on size limits, cover the untested move path, add the C++23 range members#100
martinus merged 4 commits into
mainfrom
conformance-and-ranges

Conversation

@martinus

@martinus martinus commented Aug 5, 2026

Copy link
Copy Markdown
Owner

Three commits, one per item, reviewable separately. Item 4 needed no code and is a comment on #79.

1. length_error where the standard says length_error, and max_size() counts elements

Two divergences from std::vector, measured rather than assumed:

std::vector svector before svector now
reserve(max_size()+1) length_error bad_alloc length_error
resize(max_size()+1) length_error bad_alloc length_error
reserve(max_size()) bad_alloc bad_alloc bad_alloc
max_size() for int PTRDIFF_MAX/4 PTRDIFF_MAX PTRDIFF_MAX/4

max_size() was a byte count wearing a count's clothes: it claimed a size no svector could reach, because alloc() refuses anything whose bytes pass PTRDIFF_MAX. It stays static, for the reason the comment there already gives.

The exception split is the one std::vector draws: a size that cannot exist is length_error; a size that can but that the allocator will not give is bad_alloc. That is why reserve(max_size()) stays bad_allocmax_size() is a legal size and the allocation is what fails.

This is a visible behaviour change for anyone catching bad_alloc around a too-large insert or reserve. The two tests that spelled the old behaviour are updated rather than deleted.

I checked the obvious worry and it is not there: a count that is under max_size() but whose byte size overflows size_t still throws and leaves the container intact.

2. Covering the half of the extended move constructor nobody ran

Aggregated gcov over the header (lcov reports a 398% line rate for it, so this was done by hand) had 9 source lines no test reached. One was svector(svector&&, Allocator const&) taking over other's allocation: every test that reached that constructor named an allocator that did not compare equal, so it always moved elements one at a time — including the compile-time-yes case that every user of the default std::allocator gets. That is new 1.3.0 code in move-and-relocate logic, which is where this container's bugs have historically been.

Now covered in both storage modes, and the stateful cases prove the takeover rather than assume it: the ledger shows no second allocation and the elements are still at the address they started at.

Three of the nine are gone. The remaining six are provably unreachable rather than merely untested — two are already LCOV_EXCL_LINE, two are byte-overflow guards that item 1 makes impossible to reach, realloc()'s direct-to-direct return cannot happen because reserve() only calls it when growing and shrink_to_fit() returns before it in direct mode, and calculate_new_capacity()'s wrap clamp cannot fire while max_size() is under PTRDIFF_MAX. They are cheap and they document invariants, so they stay. That also means one of my new tests was misnamed at first — it does not reach the clamp, and now says what it actually checks.

3. The C++23 range members

assign_range, append_range, insert_range and the std::from_range constructor. The README claimed svector implements all of std::vector's API; libstdc++ has had these for a while, so that had quietly stopped being true.

All four go through the iterator-pair members, so a range gets the same growth, exception guarantees and self-referencing checks an iterator pair already got, rather than a second implementation of all three. A range cannot always be handed over as a pair — its sentinel need not be its iterator, and its iterator need not publish an iterator_category, which is what is_input_iterator is built on. views::filter over views::iota is both, and is tested. Those are materialised first, which costs an allocation for exactly the ranges that could not have been sized anyway.

Guarded on __cpp_lib_containers_ranges, not on the language version, because what they need is std::from_range_t. A C++17 build is exactly what it was: 126 cases at C++17 and C++20, 131 at C++23, all green locally.

New public API, so this bumps to 1.4.0 by the rule the version macros state.

Two limitations I would rather state than hide

  • clang-tidy cannot see the range members. The pinned clang 18 image's standard library has no std::from_range_t, so that code is not compiled there whatever -std is passed. Documented in the linter. Raising the pin would cover them at the cost of re-curating the check list.
  • The range members are never exercised under sanitizers. The sanitizers leg is C++17, so it compiles them away. The risk is low, since the new paths are thin wrappers over primitives the suite already hammers, but a sanitizers-cpp23 leg would close it if you want one.

🤖 Generated with Claude Code

…ents

Two things about size limits that did not match std::vector.

max_size() answered PTRDIFF_MAX for every T, which is a byte count wearing a
count's clothes. It claimed a size no svector could ever reach, because alloc()
refuses anything whose bytes pass PTRDIFF_MAX, so the real ceiling has always
been PTRDIFF_MAX/sizeof(T) -- which is what std::vector answers. Still static,
for the reason the comment there already gives.

And asking for more than that threw bad_alloc, where std::vector throws
length_error. They are different questions: one is a size that cannot exist,
the other is a size that can but that the allocator would not give. This now
draws the same line std::vector does, so reserve(max_size()) is still a
bad_alloc -- max_size() is a legal size and the allocation is what fails --
while one past it is a length_error.

This is a visible behaviour change for anyone catching bad_alloc around a
too-large insert or reserve. The two tests that spelled the old behaviour are
updated rather than deleted, and there are new ones for the size error itself
and for the growth path clamping at max_size() instead of wrapping.
Coverage over the header, aggregated across instantiations, had nine source
lines that no test reached. One of them was svector(svector&&, Allocator const&)
taking over other's allocation. Every test that reached that constructor named
an allocator that did not compare equal, so it always took the other branch and
moved the elements one at a time -- including the case that is a compile time
yes and needs no allocator at all, which is what every user of the default
std::allocator gets.

That is new code in 1.3.0, in move and relocation logic, which is where this
container's bugs have historically been: #54, #63, #74. It works, but nothing
was checking.

Now covered, in both storage modes, and the stateful cases prove the takeover
rather than assume it: the ledger shows no second allocation and the elements
are still at the address they started at. The unequal case is kept alongside so
the contrast is visible in one place.

Also covers construct_each()'s copy loop, which only runs for an allocator with
a construct() of its own, and the new length_error.

Three of the nine are gone. The remaining six are all provably unreachable
rather than merely untested: two are LCOV_EXCL_LINE already, two are byte
overflow guards that max_size() dividing by sizeof(T) now makes impossible to
reach, realloc()'s direct to direct return cannot happen because reserve() only
calls it when growing and shrink_to_fit() returns before it in direct mode, and
calculate_new_capacity()'s wrap clamp cannot fire while max_size() is under
PTRDIFF_MAX. They are cheap and they document invariants, so they stay.
The README said svector implements all of std::vector's API. C++23 added
assign_range, append_range, insert_range and the from_range constructor, and
libstdc++ has had them for a while, so that claim had quietly stopped being
true.

All four go through the iterator pair members, so a range gets the same growth,
the same exception guarantees and the same self referencing checks an iterator
pair already got, rather than a second implementation of all three.

A range cannot always be handed over as a pair: its sentinel need not be its
iterator, and its iterator need not publish an iterator_category, which is what
is_input_iterator is built on. views::filter over views::iota is both. One that
cannot is built into a temporary first, which costs an allocation for exactly
the ranges that could not have been sized anyway.

Guarded on __cpp_lib_containers_ranges rather than on the language version,
because what these need is std::from_range_t and the range concepts, and a
C++17 build has neither. That build is exactly what it was: 126 test cases at
C++17 and C++20, 131 at C++23.

The constraint is one concept spelled the way the standard spells it rather
than a requires clause on each member, which also keeps clang-format from
folding the clause onto the declaration.

New public API, so the version goes to 1.4.0 by the rule the macros state.
The hardened leg broke on the new tests, and not for a reason about svector.
max_size() answering PTRDIFF_MAX/sizeof(T) instead of PTRDIFF_MAX made it small
enough for gcc to constant fold, so at -O2 it followed max_size() + 1 into
reasoning about an array of that many std::string and reported an out of bounds
subscript, which -Werror turned into a failure. gcc 13 then hit an internal
compiler error on the same test.

Nothing about that is wrong with the code under test: a size like this is a
runtime value in any real use. So it is one here too, through a volatile, which
is also what keeps the existing reserve_bad_alloc test out of the same trap now
that its argument folds as well.
@martinus
martinus merged commit 00f6d4d into main Aug 5, 2026
13 checks passed
@martinus
martinus deleted the conformance-and-ranges branch August 5, 2026 05:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant