Skip to content

noexcept correctness: stop lying about moves, start promising the rest - #64

Merged
martinus merged 3 commits into
mainfrom
noexcept-correctness
Jul 27, 2026
Merged

noexcept correctness: stop lying about moves, start promising the rest#64
martinus merged 3 commits into
mainfrom
noexcept-correctness

Conversation

@martinus

Copy link
Copy Markdown
Owner

Fixes #63, plus an audit of every other method.

1. The move operations promised something they cannot keep

std::vector can declare its move noexcept unconditionally because it only steals a pointer. An svector in direct mode has to relocate the inline elements, which runs T's move constructor. Claiming noexcept anyway meant a throwing move called std::terminate, and — worse — std::move_if_noexcept asks exactly this trait, so growing a std::vector<svector<T>> moved instead of falling back to copy and turned push_back's strong guarantee into an abort.

Both are now noexcept(std::is_nothrow_move_constructible_v<T>). boost::container::small_vector is conditional here for the same reason:

boost small_vector<std::string,4>  nothrow_move = 1
boost small_vector<ThrowingMove,4> nothrow_move = 0

Nothing ordinary is affected — std::string, std::vector, std::unique_ptr, anything trivially copyable all keep the answer they had.

2. A leak, found by testing the above

Once a throwing-move type could actually be tested, asan immediately caught this:

Direct leak of 96 byte(s) in 1 object(s) allocated from:
    #1 in alloc                          svector.h:171
    #2 in emplace_back_grow<int const&>  svector.h:323
    #3 in emplace_back<int const&>       svector.h:830

Growing allocates new storage, builds the new element into it, then relocates the existing elements. If T's move constructor throws partway through that relocation, the new storage was owned by nothing yet and the already-built element was never destroyed — both leaked. take_over_storage() now frees them on the way out.

This is pre-existing and only reachable with a throwing move, which is precisely why the unconditional noexcept kept it hidden.

3. Everything else was missing noexcept entirely

Probing svector<std::string, 4> against std::vector<std::string> before this PR: every entry except the two move operations answered 0 where std::vector answered 1size(), capacity(), empty(), max_size(), data(), all eight iterator accessors, operator[], front(), back(), clear(), pop_back(), swap(), and the default constructor.

None of them can throw; they simply never said so. After this PR the two contracts are byte-identical:

$ diff <(probe svector) <(probe std::vector) && echo IDENTICAL
CONTRACT IDENTICAL to std::vector

swap() is the one that needed thought — it is a move construction plus two move assignments, so it takes the same condition as those.

Left alone deliberately: at() throws out_of_range by design, shrink_to_fit() allocates. Both match std::vector in still being able to throw.

Tests

New test/unit/noexcept_contract.cpp. The contract assertions compare against std::vector rather than a hardcoded expectation, so the two cannot drift apart, and the macro stringifies the member so a failure says which one:

error: static assertion failed: clear()

Plus four runtime cases: a throwing move now unwinds instead of terminating (construction and assignment), an indirect move still never touches the elements, std::vector growth keeps the strong guarantee, and the leak above is caught under asan.

Verified green with gcc and clang under -fsanitize=address,undefined. Benchmarked the growth path A/B — the added try/catch costs nothing measurable, as expected for table-based EH.

martinus and others added 3 commits July 27, 2026 20:16
std::vector can declare its move operations noexcept unconditionally because
they only steal a pointer. An svector in direct mode has to relocate the inline
elements instead, which runs T's move constructor, so the promise was not ours
to make: a throwing move escaped a noexcept function and called std::terminate.

Worse, std::move_if_noexcept asks exactly this trait, so growing a
std::vector<svector<T>> moved the elements instead of falling back to a copy,
and push_back's strong exception guarantee became a terminate.

Types with a noexcept move, which is everything ordinary including std::string,
std::vector, std::unique_ptr and anything trivially copyable, keep the answer
they had before. boost::container::small_vector is conditional here for the
same reason.

While testing this, asan found that growing leaked the new storage when T's
move constructor threw partway through relocating: nothing owns that allocation
yet, and the element emplace_back had already built in it was never destroyed.
take_over_storage now frees it on the way out. Only reachable with a throwing
move, which is why the unconditional noexcept hid it.

Closes #63

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
svector is meant to be a drop in for std::vector, but it made almost no
noexcept promises at all: every observer, iterator accessor, clear(), pop_back()
and swap() were unspecified where std::vector guarantees them. Generic code that
asks, e.g. std::is_nothrow_swappable_v or a hand written
if constexpr (noexcept(v.size())), got a different answer for the two types.

Nothing here changes what the code does, all of these were already incapable of
throwing. swap() is the one exception: it is one move construction plus two move
assignments, so it inherits the condition those now carry.

Not touched: at() throws out_of_range by design, and shrink_to_fit() allocates.
Both match std::vector in still being able to throw.

The test asserts each one against std::vector rather than against a hardcoded
expectation, so the two contracts cannot drift apart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The macos job caught that comparing every member against std::vector was the
wrong test: for the parts the standard leaves open, the implementations
genuinely disagree. libc++ does not declare pop_back noexcept while libstdc++
and the MSVC STL do, and libc++'s shrink_to_fit is noexcept because it swallows
a failed allocation, which ours deliberately does not.

So only compare the members [vector.overview] actually spells out as noexcept,
where every conforming std::vector has to agree. The rest are svector's own
promise and are asserted on their own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@martinus
martinus merged commit 7fc7245 into main Jul 27, 2026
6 checks passed
@martinus
martinus deleted the noexcept-correctness branch July 27, 2026 18:28
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.

Move constructor and move assignment are unconditionally noexcept, but can move elements

1 participant