Skip to content

Add C++23 expected polyfill (#13) - #266

Open
thirtytwobits wants to merge 4 commits into
claude/issue-39-saturatingfrom
claude/issue-13-expected
Open

thirtytwobits wants to merge 4 commits into
claude/issue-39-saturatingfrom
claude/issue-13-expected

Conversation

@thirtytwobits

@thirtytwobits thirtytwobits commented Oct 25, 2026 •

Copy link
Copy Markdown
Member

Stacked on #265 (claude/issue-39-saturating). Review and merge that first; this PR's diff is only the expected work. Supersedes #256.

Summary

Adds cetl::pf23::expected<T, E> (including expected<void, E>), unexpected<E>, unexpect_t/unexpect and bad_expected_access<E> for C++14 and newer, plus cetl/pf23/cetlpf.hpp. Closes #13.

  • Standard-exact, following the current wording including the DRs libraries apply in C++23 mode: LWG 3836 (expected<bool, E> from another expected), LWG 4222 (value ctor excludes unexpect_t), P3379R0 (equality operators are constrained), remove_cv_t<T> default template args.
  • Monadic operations: and_then, or_else, transform, transform_error in all four value categories, using a detail-only C++17 INVOKE (callables, member function/data pointers, reference_wrapper). In C++17+ transform constructs its result in place (guaranteed elision).
  • Strong exception guarantee on assignments and swap that switch between value and error (reinit-expected), exactly as specified; assignment is deleted unless T or E is nothrow-move-constructible.
  • Triviality: copy/move construction and destruction are trivial when T and E are; assignment is too (unspecified by the standard).
  • No exceptions: value() on an error calls std::terminate (as pf17::optional does). Unchecked access (*, ->, error()) has CETL_DEBUG_ASSERTs (CDE_expected_001–015).
  • Selection: cetl/pf23/cetlpf.hpp uses std::expected when __cpp_lib_expected >= 202211L (has the monadic ops), else the polyfill. cetl/pf26/cetlpf.hpp now includes it so the polyfill headers chain.

Documented differences (C++14 language limits / scope): in C++14 the in-place ctors take cetl::pf17::in_place_t (from C++17 on std::in_place_t, which is what cetl::in_place is there); members needing placement new aren't constexpr in C++14; transform needs a movable result in C++14; cv-qualified void value type is not supported (plain void is).

Tests

  • test_pf23_expected.cpp (type properties, construction, assignment, emplace, swap, exception safety) and test_pf23_expected_operations.cpp (observers, bad_expected_access, monadic ops, comparison, unexpected, cetlpf.hpp) are typed over the implementation: where the library has std::expected, every behavioural test also runs against it, so the expectations are checked against a real standard library too. libstdc++ 13 predates LWG 3836; that one oracle check is skipped only when the defect is detected at runtime.
  • test_pf23_expected_asserts.cpp: death tests for the debug assertions and for value() with -fno-exceptions.
  • Compile-failure tests: and_then returning a non-expected, transform returning a reference, unexpected<int> as value type.
  • Example 14 (example_14_expected.cpp) for the docs.

Validation

  • Unit tests + example pass on: GCC 13 C++14 / C++20 / C++23 (libstdc++ oracle), Homebrew clang 23 C++17 / C++23 (libc++ oracle), g++-15 C++26 (libstdc++ oracle). Debug and ReleaseEP everywhere; GCC 13 C++14 and clang C++23 also in DebugEP and Release.
  • Full regression (run_unittests + run_examples, GCC 13 C++14 Debug): all 71 executables pass. pf26 suites pass under g++-15 C++26 with the new include chain.
  • ASan + UBSan (clang, C++14 and C++23): clean.
  • Coverage of expected.hpp (GCC 13, C++14): 99% lines, 79% branches.
  • Compile-failure tests fail on the intended static assertions.
  • clang-format 21/22/23 agree on the headers. Doxygen: no new warnings except a "recursive class relation" false positive for bad_expected_access<void> (same as the existing one in type_traits_ext.hpp; WARN_AS_ERROR=NO).
  • CI: because this PR's base is not main, CETLVaSt does not trigger on it automatically; it is dispatched manually on the branch: run 40208489520.

🤖 Generated with Claude Code

thirtytwobits and others added 4 commits October 25, 2026 08:45
libcyphal wants cetl::expected (its types.hpp aliases Expected<S, F> to
cetl::variant<S, F> as a stand-in) and embedded code built without
exceptions needs a way to return a value or the reason there is none.
C++23 standardized that as std::expected; CETL now provides it for C++14
and newer as cetl::pf23::expected, unexpected, unexpect_t/unexpect and
bad_expected_access, including expected<void, E> and the monadic
operations (and_then, or_else, transform, transform_error).

The polyfill follows the current standard, including the defect reports
that libraries apply in C++23 mode: LWG 3836 (expected<bool, E> from
another expected), LWG 4222 (the value constructor excludes unexpect_t),
P3379R0 (the equality operators are constrained rather than mandated),
and remove_cv_t<T> as the default template argument of the value
constructor and value_or. Assignments that switch between the value and
the error give the strong exception guarantee the standard specifies.
Copy/move construction and destruction are trivial when T and E are;
assignment is too, which the standard leaves unspecified. Without
exceptions value() calls std::terminate, as cetl::pf17::optional does.
Unchecked access (operator*, operator->, error()) has debug assertions.

Known differences, documented on the class: in C++14 the in-place
constructors take cetl::pf17::in_place_t (std::in_place_t from C++17 on,
which is what cetl::in_place is there), members that need placement new
are not constexpr, and transform() needs a movable result because C++14
has no guaranteed copy elision; cv-qualified void is not supported as
the value type.

cetl/pf23/cetlpf.hpp selects std::expected when __cpp_lib_expected >=
202211L (the monadic operations) and the polyfill otherwise, and
cetl/pf26/cetlpf.hpp now includes it so the polyfill headers chain.

Tests are typed over the implementation: where the standard library
has std::expected every behavioural test also runs against it, which
checks the expectations as well as the polyfill. libstdc++ 13 predates
LWG 3836, so that one oracle check is skipped where the defect is
detected. Coverage of expected.hpp is 99% of lines and 79% of branches
(GCC 13, C++14). There are compile-failure tests for the mandates,
death tests for the debug assertions and for value() without
exceptions, and example 14 for the documentation.

Stacked on the issue #39 branch, which adds the C++23/26 presets that
exercise the std::expected path in CI.

Closes #13.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Assignment, emplace(), swap() and the constructors that convert from
another expected change which member of the storage union is active.
C++20 allows that in a constant expression (std::construct_at and
constexpr destructors), and std::expected is constexpr throughout, so
the polyfill now marks those operations constexpr when the language and
library support it (__cpp_constexpr_dynamic_alloc and
__cpp_lib_constexpr_dynamic_alloc). Before C++20 they cannot be constant
expressions; the class documentation now says "before C++20" rather
than "in C++14".

A typed test runs every such operation in a constant expression, for a
trivial and a non-trivial literal type, so in C++23 builds it is also
checked against std::expected.

A compile-failure test pins that value() && still requires a copy
constructible error type: [expected.object.obs] mandates it for the
rvalue overloads too, and libc++ 23 and libstdc++ 15 reject a move-only
error type the same way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The standard returns the comparison itself from the equality operators
of expected and unexpected, and the argument itself from error_or, so
the conversion applied is the implicit one that the constraints check.
The polyfill used static_cast instead, which can pick a different,
explicit conversion: a comparison result with a better-matching
explicit operator bool gave the opposite answer (or did not compile if
that operator was deleted), and error_or could select an explicit
constructor of E over the implicit conversion from G.

The comparisons now return the comparison result, as LWG 4366 (C++26)
specifies for expected and as C++23 already specifies for unexpected,
and error_or returns its forwarded argument. libc++ 23 behaves the same
way; libstdc++ 13 and 15 still cast in the expected comparisons, so
that oracle check is skipped where the cast is detected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
[expected.un.swap] mandates is_swappable_v<E> for the member swap and
constrains the friend swap on it. libc++ follows that wording;
libstdc++ constrains the member swap as well, so code that only asks
whether x.swap(y) is callable gets different answers. The polyfill
keeps the standard's wording.

A typed test now checks, against libc++ and libstdc++ as well as the
polyfill, the parts they agree on: the friend swap is constrained, so
the swappable traits follow E (including nothrow), and swapping works
for swappable error types. A compile-failure test checks that calling
the member swap with a non-swappable error does not compile, which
holds under either reading.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@thirtytwobits
thirtytwobits force-pushed the claude/issue-13-expected branch from 7eb5d28 to 6906a9a Compare October 25, 2026 15:45
@thirtytwobits
thirtytwobits added this pull request to stack #269 October 25, 2026 22:08

This branch has not been deployed

No deployments
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