From 859ffa00b05d3bbb758dafd786b4c297a9d506c7 Mon Sep 17 00:00:00 2001 From: Steve Downey Date: Fri, 16 Oct 2026 23:10:21 -0400 Subject: [PATCH 1/2] Add clang toolchains that build against libstdc++ Every local clang toolchain forces -stdlib=libc++, and the std::expected equivalence gate is skipped on libc++, so no local build ran that gate with clang. CI's clang jobs run libstdc++ too, and that is where they fail. clang-22-libstdc++ and clang-23-libstdc++ share clang-libstdc++-flags.cmake, which takes clang-flags.cmake and swaps only the standard library. Also add a plain clang-23 toolchain alongside clang-22. --- cmake/clang-22-libstdc++-toolchain.cmake | 10 ++++++++++ cmake/clang-23-libstdc++-toolchain.cmake | 10 ++++++++++ cmake/clang-23-toolchain.cmake | 10 ++++++++++ cmake/clang-libstdc++-flags.cmake | 16 ++++++++++++++++ 4 files changed, 46 insertions(+) create mode 100644 cmake/clang-22-libstdc++-toolchain.cmake create mode 100644 cmake/clang-23-libstdc++-toolchain.cmake create mode 100644 cmake/clang-23-toolchain.cmake create mode 100644 cmake/clang-libstdc++-flags.cmake diff --git a/cmake/clang-22-libstdc++-toolchain.cmake b/cmake/clang-22-libstdc++-toolchain.cmake new file mode 100644 index 0000000..899f233 --- /dev/null +++ b/cmake/clang-22-libstdc++-toolchain.cmake @@ -0,0 +1,10 @@ +# cmake/clang-22-libstdc++-toolchain.cmake -*-cmake-*- +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +include_guard(GLOBAL) + +set(CMAKE_C_COMPILER clang-22) +set(CMAKE_CXX_COMPILER clang++-22) +set(GCOV_EXECUTABLE "llvm-cov-22 gcov" CACHE STRING "GCOV executable" FORCE) + +include("${CMAKE_CURRENT_LIST_DIR}/clang-libstdc++-flags.cmake") diff --git a/cmake/clang-23-libstdc++-toolchain.cmake b/cmake/clang-23-libstdc++-toolchain.cmake new file mode 100644 index 0000000..df0eb7b --- /dev/null +++ b/cmake/clang-23-libstdc++-toolchain.cmake @@ -0,0 +1,10 @@ +# cmake/clang-23-libstdc++-toolchain.cmake -*-cmake-*- +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +include_guard(GLOBAL) + +set(CMAKE_C_COMPILER clang-23) +set(CMAKE_CXX_COMPILER clang++-23) +set(GCOV_EXECUTABLE "llvm-cov-23 gcov" CACHE STRING "GCOV executable" FORCE) + +include("${CMAKE_CURRENT_LIST_DIR}/clang-libstdc++-flags.cmake") diff --git a/cmake/clang-23-toolchain.cmake b/cmake/clang-23-toolchain.cmake new file mode 100644 index 0000000..479c036 --- /dev/null +++ b/cmake/clang-23-toolchain.cmake @@ -0,0 +1,10 @@ +# cmake/clang-23-toolchain.cmake -*-cmake-*- +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +include_guard(GLOBAL) + +set(CMAKE_C_COMPILER clang-23) +set(CMAKE_CXX_COMPILER clang++-23) +set(GCOV_EXECUTABLE "llvm-cov-23 gcov" CACHE STRING "GCOV executable" FORCE) + +include("${CMAKE_CURRENT_LIST_DIR}/clang-flags.cmake") diff --git a/cmake/clang-libstdc++-flags.cmake b/cmake/clang-libstdc++-flags.cmake new file mode 100644 index 0000000..243b19c --- /dev/null +++ b/cmake/clang-libstdc++-flags.cmake @@ -0,0 +1,16 @@ +# cmake/clang-libstdc++-flags.cmake -*-cmake-*- +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +include_guard(GLOBAL) + +# The clang flags, built against libstdc++ instead of libc++. CI's clang jobs +# run both standard libraries; the libc++-only local toolchains skip the +# std::expected equivalence gate, which is only built off libc++. +include("${CMAKE_CURRENT_LIST_DIR}/clang-flags.cmake") + +set(CMAKE_CXX_FLAGS + "-stdlib=libstdc++ -Wall -Wextra -std=gnu++20" + CACHE STRING + "CXX_FLAGS" + FORCE +) From e440a38c02621747463e4f70dd5f2780876b813d Mon Sep 17 00:00:00 2001 From: Steve Downey Date: Fri, 16 Oct 2026 23:46:24 -0400 Subject: [PATCH 2/2] Keep expected trivially copyable under clang when no copy assignment is eligible For a move-only E, neither the trivial nor the non-trivial copy assignment of expected is satisfied. [class.prop] counts only eligible special members, so expected is trivially copyable, and gcc says so. Clang (17 through at least 24) does not: a class whose copy-assignment candidates all have unsatisfied constraints is, to clang, not trivially copyable. libstdc++ 16 made std::expected trivially copyable, so the equivalence gate's "beman is at least as trivial as std" check now fails in every clang job built against libstdc++ in CI. Add a deleted copy assignment, satisfied exactly when neither path is, to the primary template, expected and expected. A deleted candidate is not eligible either, so the triviality it restores is the standard's. It is `\omit`ted from the wording; the rationale sits after the primary template because specgen carries a plain comment on an omitted member into the synopsis. Move assignment is left alone: a satisfied deleted move assignment would stop an rvalue falling back to copy assignment. The same bug was behind the equivalence gate's Immovable skip, which is removed. expected_trivial gains a direct check, which runs on libc++ as well. --- include/beman/expected/expected.hpp | 40 +++++++++++++++++++ .../expected_std_equivalence.test.cpp | 28 ++----------- .../beman/expected/expected_trivial.test.cpp | 39 ++++++++++++++++++ 3 files changed, 83 insertions(+), 24 deletions(-) diff --git a/include/beman/expected/expected.hpp b/include/beman/expected/expected.hpp index d1bfdbb..7bc015f 100644 --- a/include/beman/expected/expected.hpp +++ b/include/beman/expected/expected.hpp @@ -384,6 +384,16 @@ class expected { std::is_trivially_destructible_v && std::is_trivially_copy_constructible_v && std::is_trivially_copy_assignable_v && std::is_trivially_destructible_v)); + //! \omit + constexpr expected& operator=(const expected&) + requires(!(std::is_trivially_copy_constructible_v && std::is_trivially_copy_assignable_v && + std::is_trivially_destructible_v && std::is_trivially_copy_constructible_v && + std::is_trivially_copy_assignable_v && std::is_trivially_destructible_v) && + !(std::is_copy_constructible_v && std::is_copy_assignable_v && + (std::is_reference_v || (std::is_copy_constructible_v && std::is_copy_assignable_v)) && + (std::is_nothrow_move_constructible_v || std::is_nothrow_move_constructible_v))) + = delete; + // Move assignment (trivial path) //! \at expected.object.assign //! \effects Trivially moves `rhs`'s active member into `*this`. @@ -639,6 +649,22 @@ class expected { }; }; +// The deleted copy assignment, the `\omit`ted one after the trivial and +// non-trivial paths, is satisfied exactly when neither of them is, so some +// candidate always is. It is here for clang. Clang (17 through at least 24) +// treats a class whose copy-assignment candidates all have unsatisfied +// constraints as not trivially copyable, although none of them is eligible +// ([special]) and [class.prop] counts only eligible ones; gcc gets it right. +// Without it, expected is trivially copyable under gcc and not +// under clang, while libstdc++'s std::expected is under both. A deleted +// candidate is not eligible either, so the triviality it restores is the one +// the standard gives. It is copy assignment only: a satisfied deleted move +// assignment would win overload resolution for an rvalue and stop it falling +// back to copy assignment. expected and expected carry the +// same overload for the same reason. It is not wording, so it is `\omit`ted, +// and a comment on it would be carried into the synopsis, which is why this +// one sits here. + // \rSec3[expected.object.cons]{Constructors} //! \effects Value-initializes `val`. @@ -2025,6 +2051,13 @@ class expected { !(std::is_trivially_copy_constructible_v && std::is_trivially_copy_assignable_v && std::is_trivially_destructible_v)); + //! \omit + constexpr expected& operator=(const expected&) + requires(!(std::is_trivially_copy_constructible_v && std::is_trivially_copy_assignable_v && + std::is_trivially_destructible_v) && + !(std::is_reference_v || (std::is_copy_constructible_v && std::is_copy_assignable_v))) + = delete; + // Move assignment (trivial path) //! \at expected.void.assign //! \merge @@ -3363,6 +3396,13 @@ class expected { !(std::is_trivially_copy_constructible_v && std::is_trivially_copy_assignable_v && std::is_trivially_destructible_v)); + //! \omit + constexpr expected& operator=(const expected&) + requires(!(std::is_trivially_copy_constructible_v && std::is_trivially_copy_assignable_v && + std::is_trivially_destructible_v) && + !(std::is_reference_v || (std::is_copy_constructible_v && std::is_copy_assignable_v))) + = delete; + // Move assignment (trivial path) //! \at expected.ref.assign //! \effects If `rhs.has_value()` is `true`: if `has_value()` is `true`, diff --git a/tests/beman/expected/expected_std_equivalence.test.cpp b/tests/beman/expected/expected_std_equivalence.test.cpp index 9711163..7467548 100644 --- a/tests/beman/expected/expected_std_equivalence.test.cpp +++ b/tests/beman/expected/expected_std_equivalence.test.cpp @@ -91,24 +91,6 @@ struct NoexceptNonTrivial { NoexceptNonTrivial& operator=(NoexceptNonTrivial&&) noexcept { return *this; } }; -// Immovable is fully non-copyable and non-movable (all four special members -// deleted). Whether a class template's copy/move assignment operator, when -// declared only via two constraint-guarded candidates that both fail to be -// satisfied for a given instantiation, still triggers implicit fallback -// generation -- and whether that fallback is trivial-or-deleted -- is a -// corner of the conditionally-trivial special member function rules -// ([class.mem]/[dcl.fct.def.default]) where GCC and Clang currently disagree -// for this one pathological error type. It predates and is independent of -// this change (which only re-keys the *conditions* from unexpected to E; -// it does not alter the constrained-candidate declaration technique). Skip -// the "beman is at least as trivial as std" checks for Immovable only, so the -// gate does not flake on a pre-existing toolchain quirk unrelated to what it -// is checking. -template -inline constexpr bool skip_trivial_parity = false; -template <> -inline constexpr bool skip_trivial_parity = true; - // Every trait parity for one (E, T) pair, each one reported. `Bm` and `St` are // named in the failure output so a divergence says which pair of types it was // found on; the trait's own name is carried by the CHECK expression and by the @@ -149,12 +131,10 @@ void check_parity() { // Drive-by fix: beman is at least as trivially copyable/assignable as std. // Each is an implication, so it is one bool rather than a comparison; the // extra parentheses keep Catch2 from trying to decompose the `||`. - if constexpr (!skip_trivial_parity) { - INFO("beman is at least as trivial as std"); - CHECK((!std::is_trivially_copy_assignable_v || std::is_trivially_copy_assignable_v)); - CHECK((!std::is_trivially_move_assignable_v || std::is_trivially_move_assignable_v)); - CHECK((!std::is_trivially_copyable_v || std::is_trivially_copyable_v)); - } + INFO("beman is at least as trivial as std"); + CHECK((!std::is_trivially_copy_assignable_v || std::is_trivially_copy_assignable_v)); + CHECK((!std::is_trivially_move_assignable_v || std::is_trivially_move_assignable_v)); + CHECK((!std::is_trivially_copyable_v || std::is_trivially_copyable_v)); } } // namespace diff --git a/tests/beman/expected/expected_trivial.test.cpp b/tests/beman/expected/expected_trivial.test.cpp index ce85ec3..e088f39 100644 --- a/tests/beman/expected/expected_trivial.test.cpp +++ b/tests/beman/expected/expected_trivial.test.cpp @@ -87,3 +87,42 @@ TEST_CASE("trivial SMFs: expected is trivially copyable", "[trivial]") CHECK(!b.has_value()); CHECK(b.error() == 7); } + +// --------------------------------------------------------------------------- +// A trivially movable, non-copyable E: no copy assignment is eligible +// --------------------------------------------------------------------------- + +namespace { +struct MoveOnly { + MoveOnly() = default; + MoveOnly(const MoveOnly&) = delete; + MoveOnly(MoveOnly&&) = default; + MoveOnly& operator=(const MoveOnly&) = delete; + MoveOnly& operator=(MoveOnly&&) = default; +}; +} // namespace + +// Neither the trivial nor the non-trivial copy assignment is satisfied for a +// move-only E. The class is still trivially copyable -- [class.prop] counts +// only eligible members -- but clang said otherwise until a deleted candidate +// was added for this case (see the note after the primary template). +TEST_CASE("trivial SMFs: a trivially movable, non-copyable E stays trivially copyable", "[trivial]") { + int i = 0; + CHECK(std::is_trivially_copyable_v>); + CHECK(std::is_trivially_copyable_v>); + CHECK(std::is_trivially_copyable_v>); + CHECK_FALSE(std::is_copy_assignable_v>); + CHECK_FALSE(std::is_copy_assignable_v>); + CHECK_FALSE(std::is_copy_assignable_v>); + + expected a(42); + expected b(unexpect); + b = std::move(a); + CHECK(b.has_value()); + CHECK(*b == 42); + + expected r(i); + expected s(unexpect); + s = std::move(r); + CHECK(&*s == &i); +}