util: Add some more Unexpected and Expected methods #34032

pull maflcko wants to merge 8 commits into bitcoin:master from maflcko:2512-exp changing 7 files +146 −40
  1. maflcko commented at 8:01 am on December 9, 2025: member

    Reviewers requested more member functions In #34006.

    They are currently unused, but bring the port closer to the original std::expected implementation:

    • Make Expected::value() throw when no value exists
    • Add Unexpected::error() methods
    • Add Expected<void, E> specialization
    • Add Expected::value()&& and Expected::error()&& methods
    • Add Expected::swap()

    Also, include a tiny tidy fixup:

    • tidy: Set AllowCastToVoid in the bugprone-unused-return-value check
  2. DrahtBot added the label Utils/log/libs on Dec 9, 2025
  3. DrahtBot commented at 8:01 am on December 9, 2025: contributor

    The following sections might be updated with supplementary metadata relevant to reviewers and maintainers.

    Code Coverage & Benchmarks

    For details see: https://corecheck.dev/bitcoin/bitcoin/pulls/34032.

    Reviews

    See the guideline for information on the review process.

    Type Reviewers
    ACK ryanofsky, stickies-v, hodlinator, sedited

    If your review is incorrectly listed, please copy-paste <!–meta-tag:bot-skip–> into the comment that the bot should ignore.

    Conflicts

    Reviewers, this pull request conflicts with the following ones:

    • #34264 (fuzz: Extend spend coverage by Chand-ra)
    • #34208 (bench: add fluent API for untimed setup steps in nanobench by l0rinc)
    • #33689 (http: replace WorkQueue and single threads handling for ThreadPool by furszy)

    If you consider this pull request important, please also help to review the conflicting pull requests. Ideally, start with the one that should be merged first.

  4. in src/util/expected.h:84 in fa4794dfeb outdated
    81     }
    82     template <class U>
    83-    ValueType value_or(U&& default_value) &&
    84+    T value_or(U&& default_value) &&
    85     {
    86         return has_value() ? std::move(value()) : std::forward<U>(default_value);
    


    ryanofsky commented at 1:06 pm on December 9, 2025:
    Would seem good to change std::move(value()) to value() since it should already be an rvalue, and this would make the value_or implementation more consistent

    maflcko commented at 1:58 pm on December 9, 2025:
    I kept it, because it is a bit more consistent with https://en.cppreference.com/w/cpp/utility/expected/value_or, also it should be harmless to cast twice and even clarify it a bit more that the value really is moved out.
  5. ryanofsky approved
  6. ryanofsky commented at 1:19 pm on December 9, 2025: contributor

    Code review ACK fa20a50f46307b45a82a6e4283a945ef2f9f92a2. Thanks for the followups.

    I’d probably replace std::variant<std::monostate, E> with std::optional<E> but both seem reasonable.

  7. maflcko commented at 2:00 pm on December 9, 2025: member

    I’d probably replace std::variant<std::monostate, E> with std::optional<E> but both seem reasonable.

    I kept the variant, because it is more consistent and shows that the error() methods are just 1:1 copy-pasted between both templates.

  8. maflcko commented at 4:38 pm on December 9, 2025: member

    pushed a commit, so that the two compile and behave identically:

    0    const auto moved{*std::move(no_copy)};
    1    const auto moved{std::move(*no_copy)};
    

    Sorry for missing it earlier, but I think now all && methods are implemented completely and correctly.

  9. in src/test/util_expected_tests.cpp:31 in fabef9bbef


    hodlinator commented at 9:11 am on December 10, 2025:

    nit: Could use BOOST_CHECK_EQUAL when applicable:

    0    BOOST_CHECK_EQUAL(e.value().x, 42);
    

    maflcko commented at 4:31 pm on December 10, 2025:
    thx, done
  10. in src/util/expected.h:31 in fabef9bbef
    26+    constexpr const E& error() const& LIFETIMEBOUND { return err; }
    27+    constexpr E& error() & LIFETIMEBOUND { return err; }
    28+    constexpr E&& error() && LIFETIMEBOUND { return std::move(err); }
    29+
    30+private:
    31     E err;
    


    hodlinator commented at 3:22 pm on December 10, 2025:
    nit: Could rename err -> m_err for consistency with Expected::m_data.

    maflcko commented at 4:31 pm on December 10, 2025:
    thx, done
  11. in src/util/expected.h:105 in fabef9bbef outdated
    132+
    133+template <class E>
    134+class Expected<void, E>
    135+{
    136+private:
    137+    std::variant<std::monostate, E> m_data;
    


    hodlinator commented at 3:30 pm on December 10, 2025:

    nit: Could use optional instead of monostate? Ah, I see that was already discussed #34032 (comment).

     0--- a/src/util/expected.h
     1+++ b/src/util/expected.h
     2@@ -10,6 +10,7 @@
     3 
     4 #include <cassert>
     5 #include <exception>
     6+#include <optional>
     7 #include <utility>
     8 #include <variant>
     9 
    10@@ -119,16 +120,16 @@ template <class E>
    11 class Expected<void, E>
    12 {
    13 private:
    14-    std::variant<std::monostate, E> m_data;
    15+    std::optional<E> m_error;
    16 
    17 public:
    18-    constexpr Expected() : m_data{std::in_place_index<0>, std::monostate{}} {}
    19+    constexpr Expected() = default;
    20     template <class Err>
    21-    constexpr Expected(Unexpected<Err> u) : m_data{std::in_place_index<1>, std::move(u).error()}
    22+    constexpr Expected(Unexpected<Err> u) : m_error{std::move(u).error()}
    23     {
    24     }
    25 
    26-    constexpr bool has_value() const noexcept { return m_data.index() == 0; }
    27+    constexpr bool has_value() const noexcept { return !m_error.has_value(); }
    28     constexpr explicit operator bool() const noexcept { return has_value(); }
    29 
    30     constexpr void value() const
    31@@ -141,17 +142,17 @@ public:
    32     constexpr const E& error() const& LIFETIMEBOUND
    33     {
    34         assert(!has_value());
    35-        return std::get<1>(m_data);
    36+        return m_error.value();
    37     }
    38     constexpr E& error() & LIFETIMEBOUND
    39     {
    40         assert(!has_value());
    41-        return std::get<1>(m_data);
    42+        return m_error.value();
    43     }
    44     constexpr E&& error() && LIFETIMEBOUND
    45     {
    46         assert(!has_value());
    47-        return std::get<1>(std::move(m_data));
    48+        return std::move(m_error).value();
    49     }
    50 };
    
  12. hodlinator approved
  13. hodlinator commented at 3:42 pm on December 10, 2025: contributor

    ACK fabef9bbef255796f28c04c5b478df9544c749eb

    (Caveat: Not yet fully at ease with ampersands after the method parameters such as in the last two commits, example: constexpr E&& error() &&).

  14. DrahtBot requested review from ryanofsky on Dec 10, 2025
  15. maflcko force-pushed on Dec 10, 2025
  16. in src/util/expected.h:58 in fa0297540e outdated
    54@@ -49,12 +55,16 @@ class Expected
    55 
    56     constexpr const ValueType& value() const LIFETIMEBOUND
    57     {
    58-        assert(has_value());
    59+        if (!Assume(has_value())) {
    


    ryanofsky commented at 6:03 pm on December 10, 2025:

    In commit “util: Make Expected::value() throw” (fa0297540e29f077a76133d1b60d3fd35f03b4e1)

    In the standard std::expected class there is a clear difference between the high-level value() method which throws an exception when the value is unset, and lower level pointer operators which are noexcept and can’t be called without a value. This makes std::expected consistent with std::optional, which also has noexcept pointer operators and a throwing value() method. It’s also similar to std::vector which has nonthrowing operator[] methods and throwing at() methods.

    I don’t see a reason why util::Expected class shouldn’t follow the same pattern and have noexcept pointer operators, especially if it’s intended to be replaced with std::expected in the future. I also think it would clean up the implementation to have a separation between low level pointer operators and higher level accessor methods. Would suggest:

     0template <class T, class E>
     1class Expected
     2{
     3private:
     4    std::variant<T, E> m_data;
     5
     6public:
     7    // Value constructors.
     8    constexpr Expected() = default;
     9    constexpr Expected(T v) : m_data{std::in_place_index<0>, std::move(v)} {}
    10
    11    // Error constructor.
    12    template <class G>
    13    constexpr Expected(Unexpected<G> u) : m_data{std::in_place_index<1>, std::move(u).error()} {}
    14
    15    // Value accessors.
    16    constexpr explicit operator bool() const noexcept { return m_data.index() == 0; }
    17    constexpr const T& operator*() const& noexcept LIFETIMEBOUND { return *Assert(std::get_if<0>(&m_data)); }
    18    constexpr T&       operator*() &      noexcept LIFETIMEBOUND { return *Assert(std::get_if<0>(&m_data)); }
    19    constexpr T&&      operator*() &&     noexcept LIFETIMEBOUND { return std::move(**this); }
    20    constexpr const T* operator->() const noexcept LIFETIMEBOUND { return Assert(std::get_if<0>(&m_data)); }
    21    constexpr T*       operator->()       noexcept LIFETIMEBOUND { return Assert(std::get_if<0>(&m_data)); }
    22
    23    // Error accessors.
    24    constexpr const E& error() const& LIFETIMEBOUND { return *Assert(std::get_if<1>(&m_data)); }
    25    constexpr E& error() & LIFETIMEBOUND            { return *Assert(std::get_if<1>(&m_data)); }
    26    constexpr E&& error() && LIFETIMEBOUND          { return std::move(this->error()); }
    27
    28    // Higher level helpers.
    29    constexpr bool has_value() const noexcept       { return bool{*this}; }
    30    constexpr const T& value() const& LIFETIMEBOUND { if (!*this) throw BadExpectedAccess{}; return **this; }
    31    constexpr T& value() & LIFETIMEBOUND            { if (!*this) throw BadExpectedAccess{}; return **this; }
    32    constexpr T&& value() && LIFETIMEBOUND          { return std::move(this->value()); }
    33
    34    template <class U>
    35    T value_or(U&& default_value) const&
    36    {
    37        return *this ? **this : std::forward<U>(default_value);
    38    }
    39
    40    template <class U>
    41    T value_or(U&& default_value) &&
    42    {
    43        return *this ? std::move(**this) : std::forward<U>(default_value);
    44    }
    45};
    

    This implementation would also make the value() methods throw in debug builds instead of aborting, which I think is good because std::expected does that, and there isn’t much reason to call value() if exceptions are unwanted. But no strong opinion about this aspect, and this could be easily tweaked to make value() abort in debug builds by replacing if (!*this) with if (!Assume(*this)) in the two value() methods.


    hodlinator commented at 8:42 am on December 11, 2025:

    Agree on change to use the default ctor, variant will initialize with the first type (https://en.cppreference.com/w/cpp/utility/variant/variant.html, 1).

    I prefer the current way of implementing operator bool in terms of has_value() though, think it’s clearer.

    Agree on making the other operators noexcept.


    maflcko commented at 9:28 am on December 11, 2025:

    Agree on making the other operators noexcept.

    thx, done

    Agree on change to use the default ctor, variant will initialize with the first type (https://en.cppreference.com/w/cpp/utility/variant/variant.html, 1).

    shouldn’t hurt to be explicit

  17. in src/util/expected.h:32 in faa0d23ee7 outdated
    29+    constexpr const E& error() const& LIFETIMEBOUND { return m_err; }
    30+    constexpr E& error() & LIFETIMEBOUND { return m_err; }
    31+    constexpr E&& error() && LIFETIMEBOUND { return std::move(m_err); }
    32+
    33+private:
    34+    E m_err;
    


    ryanofsky commented at 6:07 pm on December 10, 2025:

    In commit “util: Add Unexpected::error()” (faa0d23ee75aac289c3766ce6ee27948b0d982d9)

    Calling this m_error would seem more consistent.


    maflcko commented at 9:28 am on December 11, 2025:
    thx, done
  18. in src/util/expected.h:102 in fa3d6c760d outdated
    104+    constexpr T* operator->() LIFETIMEBOUND { return &value(); }
    105+    constexpr const T* operator->() const LIFETIMEBOUND { return &value(); }
    106+};
    107+
    108+template <class E>
    109+class Expected<void, E>
    


    ryanofsky commented at 6:27 pm on December 10, 2025:

    In commit “util: Add Expected<void, E> specialization” (fa3d6c760db3dafbaa530401893a40fc5f6575f9)

    This specialization is more complicated than it needs to be. Would suggest simplifying with inheritance:

     0//! Specialization for void returning void from value methods.
     1template <class E>
     2class Expected<void, E> : public Expected<std::monostate, E>
     3{
     4    using Base = Expected<std::monostate, E>;
     5public:
     6    using Expected<std::monostate, E>::Expected;
     7    constexpr void operator*() const noexcept { Base::operator*(); }
     8    constexpr void value() const& { Base::value(); }
     9    constexpr void value() && { Base::value(); }
    10    void value_or() = delete;
    11};
    

    maflcko commented at 9:27 am on December 11, 2025:

    This just brings back the problem we are trying to solve: Solve exposing monostate externally?

    + Expected<void, std::string> e{std::monostate{}};


    ryanofsky commented at 6:50 pm on December 17, 2025:

    re: #34032 (review)

    This just brings back the problem we are trying to solve

    I don’t think it does. The problem I want the void specialization to solve is leaking the std::monostate values to code that isn’t otherwise using it and is expecting void, making it harder to use std::expected in places like template functions or generic lambdas. I don’t see std::expected accepting (not returning) std::monostate{} as a comparable problem, or as a real problem, even though it could be avoided by declaring a deleted constructor. The current approach does seem ok though, just repetitive.

  19. ryanofsky approved
  20. ryanofsky commented at 6:33 pm on December 10, 2025: contributor
    Code review ACK fa0a1a6a4986c1a977bbcd5f48834bedeac5dffb. Left some suggestions which I think would make this better in significant ways, but I do this is already an improvement in its current form.
  21. DrahtBot requested review from hodlinator on Dec 10, 2025
  22. Set bugprone-unused-return-value.AllowCastToVoid
    It only makes sense to turn this off with C++26, which introduces the _
    placeholder.
    fad4a9fe2b
  23. test: refactor: Use BOOST_CHECK_EQUAL over BOOST_CHECK == faa109f8be
  24. in src/util/expected.h:128 in fa0a1a6a49 outdated
    167+    constexpr E&& error() && LIFETIMEBOUND
    168+    {
    169+        assert(!has_value());
    170+        return std::get<1>(std::move(m_data));
    171+    }
    172 };
    


    hodlinator commented at 8:52 am on December 11, 2025:

    nit: Would be nice to add swap() as well. I already needed that here: #25665 (review) / https://github.com/hodlinator/bitcoin/commit/b40a36cbbab59440d84dba6d1cc16bce17d4869c

    (I forgot to include the test in that commit).

    0BOOST_AUTO_TEST_CASE(expected_swap)
    1{
    2    Expected<const char*, int> a{Unexpected{55}};
    3    Expected<const char*, int> b{"21"};
    4    a.swap(b);
    5    BOOST_CHECK_EQUAL(a.value(), "21");
    6    BOOST_CHECK_EQUAL(b.error(), 55);
    7}
    

    maflcko commented at 9:48 am on December 11, 2025:
    thx, done
  25. in src/test/util_expected_tests.cpp:29 in fa0a1a6a49 outdated
    21@@ -17,25 +22,39 @@ BOOST_AUTO_TEST_CASE(expected_value)
    22         int x;
    23     };
    24     Expected<Obj, int> e{};
    25-    BOOST_CHECK(e.value().x == 0);
    26+    BOOST_CHECK_EQUAL(e.value().x, 0);
    27 
    28     e = Obj{42};
    29 
    30     BOOST_CHECK(e.has_value());
    


    hodlinator commented at 9:05 am on December 11, 2025:

    nit: Should this be a require since otherwise we would start triggering internal asserts or exceptions further down on failure?

    0    BOOST_REQUIRE(e.has_value());
    

    maflcko commented at 9:49 am on December 11, 2025:
    Yes, but that seems fine? Not sure what problem this would be solving. Leaving as-is for now.

    hodlinator commented at 10:53 am on December 11, 2025:
    I think it’s slightly better as a general principle, to make test failures more intelligible. But it’s very unlikely that anyone would even locally tweak the code being tested here so that it fails.
  26. in src/util/expected.h:28 in fa0a1a6a49
    25-    E err;
    26+    constexpr explicit Unexpected(E e) : m_err(std::move(e)) {}
    27+
    28+    constexpr const E& error() const& LIFETIMEBOUND { return m_err; }
    29+    constexpr E& error() & LIFETIMEBOUND { return m_err; }
    30+    constexpr E&& error() && LIFETIMEBOUND { return std::move(m_err); }
    


    hodlinator commented at 9:08 am on December 11, 2025:

    maflcko commented at 9:29 am on December 11, 2025:
    thx, done
  27. hodlinator commented at 9:10 am on December 11, 2025: contributor
    re-ACK fa0a1a6a4986c1a977bbcd5f48834bedeac5dffb
  28. util: Add Unexpected::error()
    This is not needed, but a bit closer to the std lib.
    fa1de1103f
  29. maflcko force-pushed on Dec 11, 2025
  30. maflcko force-pushed on Dec 11, 2025
  31. DrahtBot added the label CI failed on Dec 11, 2025
  32. in src/util/expected.h:89 in fa874c9f05 outdated
     96     }
     97 
     98-    constexpr const E& error() const LIFETIMEBOUND
     99+    constexpr const E& error() const& noexcept LIFETIMEBOUND { return *Assert(std::get_if<1>(&m_data)); }
    100+    constexpr E& error() & noexcept LIFETIMEBOUND { return *Assert(std::get_if<1>(&m_data)); }
    101+    constexpr E&& error() && noexcept LIFETIMEBOUND { return std::move(error()); }
    


    hodlinator commented at 10:23 am on December 11, 2025:

    How come this method overload is not calling itself recursively?

    Tested adding BOOST_CHECK_EQUAL((Expected<void, int>{Unexpected{1}}.error()), 1); to the tests to confirm it doesn’t but don’t understand why.

    Is this in some kind of &-mode, making it call the method above?


    maflcko commented at 11:08 am on December 11, 2025:

    Is this in some kind of &-mode, making it call the method above?

    It is not this, but (*this), see https://eel.is/c++draft/expr.prim#id.general-2


    hodlinator commented at 1:32 pm on December 11, 2025:
    Can’t read that. :grin:

    maflcko commented at 2:11 pm on December 11, 2025:

    Can’t read that. 😁

    i’d say it says that error() in this context is (*this).error(). So it calls error()& and not error()&&. If you want to (incorrectly) recursively call error(), you’d have to add a cast: std::move(*this).error();

  33. in src/util/expected.h:98 in fa874c9f05 outdated
    105+    constexpr T& operator*() & noexcept LIFETIMEBOUND { return value(); }
    106+    constexpr const T& operator*() const& noexcept LIFETIMEBOUND { return value(); }
    107+    constexpr T&& operator*() && noexcept LIFETIMEBOUND { return std::move(value()); }
    108+
    109+    constexpr T* operator->() noexcept LIFETIMEBOUND { return &value(); }
    110+    constexpr const T* operator->() const noexcept LIFETIMEBOUND { return &value(); }
    


    hodlinator commented at 10:27 am on December 11, 2025:
    nit: Might be nice to have these assert before failing due to an exception being thrown inside of noexcept.

    maflcko commented at 11:08 am on December 11, 2025:
    Why? Just seems more code for no reason?

    hodlinator commented at 1:31 pm on December 11, 2025:

    It’s about failing with a clearer stack trace/message. An exception will unwind the stack before giving a stack-trace (depending upon how the compiler treats exceptions within noexcept?).

    Tried adding *e in the unit tests and it doesn’t pinpoint the offending line, but gets by on checkpoints.

    0 ./build/bin/test_bitcoin -t util_expected_tests
    1Running 9 test cases...
    2terminate called after throwing an instance of 'util::BadExpectedAccess'
    3  what():  Bad util::Expected access
    4unknown location(0): fatal error: in "util_expected_tests/expected_value_throws": signal: SIGABRT (application abort requested)
    5../src/test/util_expected_tests.cpp(73): last checkpoint
    

    maflcko commented at 1:39 pm on December 11, 2025:

    Yes, this is also the behavior that you get in C++23 (if you are lucky). If you are unlucky, you’ll just get the silent UB:

    https://godbolt.org/z/jYxqvoejn

    0/../include/c++/v1/__expected/expected.h:811: libc++ Hardening assertion this->__has_val() failed: expected::operator* requires the expected to contain a value
    1Program terminated with signal: SIGSEGV
    

    Also, this is the behavior with an assert/abort, so I don’t see the difference!?


    hodlinator commented at 1:42 pm on December 11, 2025:
    Yeah, we don’t need to spoil ourselves before the switcharoo to std::expected. :)
  34. hodlinator approved
  35. hodlinator commented at 10:43 am on December 11, 2025: contributor

    re-ACK fa874c9f050500dc521ef8d9c81b043687f0dcc7

    Thanks for grabbing swap!

  36. DrahtBot requested review from ryanofsky on Dec 11, 2025
  37. DrahtBot removed the label CI failed on Dec 11, 2025
  38. maflcko requested review from stickies-v on Dec 15, 2025
  39. in src/util/expected.h:70 in fa5a49c988 outdated
    68     }
    69     constexpr ValueType& value() LIFETIMEBOUND
    70     {
    71-        assert(has_value());
    72+        if (!has_value()) {
    73+            throw BadExpectedAccess{};
    


    ryanofsky commented at 6:41 pm on December 17, 2025:

    In commit “util: Make Expected::value() throw” (fa5a49c988c065526105f7d30c53298305f6e8c5)

    The change seems unsafe because none of the other functions currently calling value() are switched to use operator* instead, so they will now throw instead of assert if the value is not set. This would make it dangerous to swap the util::Expected class with std::expected in the future.

    I’d recommend reconsidering the suggestion from #34032 (review) and avoiding unnecessary uses of value() and has_value() preferring to use c++’s built in operators, and setting a better example for other code to follow. Alternately more asserts could be added to fix this. This is also sort-of fixed in a later commit fa9aad738a9bfd3969aa4e1f19f87edadc69f455 adding noexcept to pointer operators, but asserts would probably lead to clearer runtime errors than noexcept violations.


    maflcko commented at 8:01 am on December 18, 2025:

    The change seems unsafe because none of the other functions currently calling value() are switched to use operator* instead, so they will now throw instead of assert if the value is not set. This would make it dangerous to swap the util::Expected class with std::expected in the future.

    I understand that switching util::Expected::operator* to std::expected::operator* is unsafe, but this is documented in https://en.cppreference.com/w/cpp/utility/expected/operator%252A.html:

    If has_value() is false, the behavior is undefined. (until C++26)

    Though, that is true with or without this pull request, so it seems unrelated.

    As for this implementation: I think it is perfectly fine to do whatever on UB, and asserting, or throwing seems better than UB.

    but asserts would probably lead to clearer runtime errors than noexcept violations.

    I don’t understand why that would be. The only difference with an assert is that it prints the file name and line number, but this will just be a inside this util header, not in the place in the code where the assertion happens. So reading the tb is needed anyway. For reference, with noexecpt, it looks like:

     0valgrind --tool=none ./bld-cmake/bin/test_bitcoin -t util_expected_tests/expected_error --catch_system_errors=no
     1==1207793== Nulgrind, the minimal Valgrind tool
     2...
     3==1207793== Command: ./bld-cmake/bin/test_bitcoin -t util_expected_tests/expected_error --catch_system_errors=no
     4==1207793== 
     5Running 1 test case...
     6terminate called after throwing an instance of 'util::BadExpectedAccess'
     7  what():  Bad util::Expected access
     8==1207793== 
     9==1207793== Process terminating with default action of signal 6 (SIGABRT): dumping core
    10...
    11==1207793==    by 0x4F1A98A: util_expected_tests::expected_error::test_method() (expected.h:117)
    12==1207793==    by 0x4F19B56: util_expected_tests::expected_error_invoker() (./test/util_expected_tests.cpp:79)
    13...
    14Aborted (core dumped)
    

    I think this is perfectly fine, and I don’t see the benefit of the assertion.


    maflcko commented at 5:03 pm on January 14, 2026:

    @ryanofsky No rush, but if you get a chance to take another look here before branch-off [1], that’d be great. I think the pull is currently blocked because of your use of the spicy word “unsafe” :sweat_smile:

    [1] It would be good to get this in before branch-off. Otherwise, there could be (unlikely) compile-failures or silent performance regressions for backports due to #34032 (comment)


    ryanofsky commented at 2:12 pm on January 15, 2026:

    re: #34032 (review)

    @ryanofsky No rush, but if you get a chance to take another look here before branch-off [1], that’d be great. I think the pull is currently blocked because of your use of the spicy word “unsafe” 😅

    I’m not saying the pull is unsafe. I’m saying this specific commit fa90c4434176860eb01c8197fda226f6e7bb1f70 is unsafe and the updated commit message is misleading.

    This commit is unsafe and the new commit message saying that pointer operators throwing exceptions is “fine” is misleading, because if these operators throw exceptions, code can catch those exceptions, and that code will be broken when Expected is replaced with expected, in bad ways triggering UB, in code paths likely to be missing test coverage because we often don’t have tests checking error paths.

    However, the pull request as a whole is safe because a later commit fa15f71e34e53ecaa32c4b4f2102feed7601c62c adds noexcept. That later commit fixes safety issue apparently by accident because it does not even mention any change in behavior.

    IMO, this pull is not in a good state even if it is technically safe. These issues could be fixed at a surface level by adding noexcept in fa90c4434176860eb01c8197fda226f6e7bb1f70 instead of fa15f71e34e53ecaa32c4b4f2102feed7601c62c. In that case, it would be good to have comments next to these noexcepts, because normally when developers see noexcept we think “exceptions are not expected to be thrown here” not “exceptions are thrown one level down from here but this triggers std::terminate and that is good.”

    I do think the real mistake here is implementing pointer methods in terms of value() instead of the other way around. Using value() complicates implementation of this class, makes error reporting more confusing and less reliable, and sets a bad example of how you should deal with nullable values in c++. Using value instead of bool/*/-> makes unsafe dereferences look like normal method calls, and negates decades of pattern recognition c/c++ developers have dealing with code with nullable values.


    maflcko commented at 3:15 pm on January 15, 2026:

    Thanks for the reply. I think I understand your point better now.

    These issues could be fixed at a surface level by adding noexcept in https://github.com/bitcoin/bitcoin/commit/fa90c4434176860eb01c8197fda226f6e7bb1f70 instead of https://github.com/bitcoin/bitcoin/commit/fa15f71e34e53ecaa32c4b4f2102feed7601c62c.

    Thanks, done.

    normally when developers see noexcept we think “exceptions are not expected to be thrown here” not “exceptions are thrown one level down from here but this triggers std::terminate and that is good.”

    I think the first interpretation is correct, because no code should exist that dereferences a nullopt, so I’ll not add a comment for now.

    I do think the real mistake here is implementing pointer methods in terms of value() instead of the other way around. Using value() complicates implementation of this class, makes error reporting more confusing and less reliable, and sets a bad example of how you should deal with nullable values in c++. Using value instead of bool/*/-> makes unsafe dereferences look like normal method calls, and negates decades of pattern recognition c/c++ developers have dealing with code with nullable values.

    I guess I just disagree here.

    • I don’t like UB and I think we should move toward eliminating it, so I am not going to re-write this as unsafe code. Just because C++ is know for its UB for decades doesn’t mean all future code needs to be unsafe as well.
    • Also, I disagree that Assert has a nicer error message here than terminate. See my prior reply.
    • I think the implementation is simple and also uses less code than the alternative.

    ryanofsky commented at 3:54 pm on January 15, 2026:

    re: #34032 (review)

    • I don’t like UB and I think we should move toward eliminating it, so I am not going to re-write this as unsafe code.

    If you think something in the suggestion #34032 (review) is unsafe, it would help to clarify what you are referring to.

    Just because C++ is know for its UB for decades doesn’t mean all future code needs to be unsafe as well.

    We have no disagreement about this. I am not saying it is bad to use value() in general. I think it is great to use value() in code that catches exceptions. However, it is bad to use value() in code that does not catch exceptions because:

    • It is misleading, not distinguishing between preconditions that are never expected to be violated and expected errors.
    • It makes unsafe dereferences blend into code looking like normal method calls instead of standing out clearly, bypassing extra scrutiny c/c++ developers give to * and -> uses.
  40. in src/util/expected.h:97 in fa9aad738a outdated
    94+    constexpr const T& operator*() const& noexcept LIFETIMEBOUND { return value(); }
    95+    constexpr T&& operator*() && noexcept LIFETIMEBOUND { return std::move(value()); }
    96 
    97-    constexpr T* operator->() LIFETIMEBOUND { return &value(); }
    98-    constexpr const T* operator->() const LIFETIMEBOUND { return &value(); }
    99+    constexpr T* operator->() noexcept LIFETIMEBOUND { return &value(); }
    


    ryanofsky commented at 7:01 pm on December 17, 2025:

    In commit “util: Implement Expected::operator*()&&” (fa9aad738a9bfd3969aa4e1f19f87edadc69f455)

    Would be good for commit message to mention it is also adding noexcept to other overloads


    maflcko commented at 1:46 pm on December 19, 2025:
    thx, done
  41. ryanofsky commented at 7:13 pm on December 17, 2025: contributor

    Code review fa874c9f050500dc521ef8d9c81b043687f0dcc7. Looks mostly good and thanks for considering the previous suggestions. One thing that I think may be a problem is use of throwing value() method in operator methods declared noexcept but I left a more detailed comment below.

    Various changes were made since last review: renaming m_err, getting rid of Assume() in value(), testing void value() specialization, adding noexcepts, adding method & qualifiers, reformatting and using std::get_if, adding swap method.

  42. DrahtBot requested review from ryanofsky on Dec 17, 2025
  43. maflcko force-pushed on Dec 18, 2025
  44. in src/util/expected.h:91 in faebb16547 outdated
     98-    constexpr const E& error() const LIFETIMEBOUND
     99+    constexpr const E& error() const& noexcept LIFETIMEBOUND { return *Assert(std::get_if<1>(&m_data)); }
    100+    constexpr E& error() & noexcept LIFETIMEBOUND { return *Assert(std::get_if<1>(&m_data)); }
    101+    constexpr E&& error() && noexcept LIFETIMEBOUND { return std::move(error()); }
    102+
    103+    constexpr void swap(Expected& other) noexcept { m_data.swap(other.m_data); }
    


    stickies-v commented at 9:08 am on January 5, 2026:
    review note: std::variant::swap is not unconditionally noexcept but has a specification. However, I think for our minimal implementation just letting it std::terminate is fine.
  45. stickies-v approved
  46. stickies-v commented at 9:11 am on January 5, 2026: contributor
    utACK faebb1654743adcc5888464f9a7167c315ecce1f
  47. DrahtBot requested review from hodlinator on Jan 5, 2026
  48. maflcko removed review request from ryanofsky on Jan 5, 2026
  49. maflcko requested review from ryanofsky on Jan 5, 2026
  50. hodlinator approved
  51. hodlinator commented at 3:39 pm on January 6, 2026: contributor

    re-ACK faebb1654743adcc5888464f9a7167c315ecce1f

    Only improved commit messages and made operator* for Expected<void, E>-specialization throw on error since my previous review (https://github.com/bitcoin/bitcoin/pull/34032#pullrequestreview-3566738644)

  52. ryanofsky approved
  53. ryanofsky commented at 2:39 pm on January 15, 2026: contributor
    Code review ACK faebb1654743adcc5888464f9a7167c315ecce1f. I really only like the first commit fad4a9fe2b8d3a3aa09eca4f47e1741912328785 in this PR and think the other commits are neutral or bad (see below), but these changes seem ok if others want them.
  54. util: Make Expected::value() throw
    This is not expected to be needed in this codebase, but brings the
    implementation closer to std::expected::value().
    
    Also, add noexcept, where std::expected has them. This will make
    operator-> and operator* terminate, when has_value() is false.
    fa6575d6c2
  55. util: Add Expected<void, E> specialization
    This is not needed, but a bit closer to the std lib, because
    std::monostate is no longer leaked through ValueType from the value()
    method.
    fac4800959
  56. util: Implement Expected::value()&& and Expected::error()&&
    They are currently unused, but implementing them is closer to the
    std::expected.
    fab9721430
  57. maflcko force-pushed on Jan 15, 2026
  58. util: Implement Expected::operator*()&&
    It is currently unused, but implementing it is closer to std::expected.
    fabb47e4e3
  59. util: Add Expected::swap() faa59b3679
  60. maflcko force-pushed on Jan 15, 2026
  61. maflcko commented at 3:17 pm on January 15, 2026: member

    Force pushed something. Should be trivial to re-review, because nothing changed:

    0$ git diff faebb16547 faa59b3679 | wc -l
    10
    
  62. ryanofsky approved
  63. ryanofsky commented at 4:04 pm on January 15, 2026: contributor
    Code review ACK faa59b367985648df901bdd7b5bba69ef898ea08. Thanks for the update. The commit I objected to is fixed now and the rest of the implementation seems good enough for code that’s probably temporary.
  64. DrahtBot requested review from hodlinator on Jan 15, 2026
  65. DrahtBot requested review from stickies-v on Jan 15, 2026
  66. DrahtBot added the label CI failed on Jan 15, 2026
  67. DrahtBot removed the label CI failed on Jan 15, 2026
  68. stickies-v commented at 11:32 am on January 16, 2026: contributor
    re-ACK faa59b367985648df901bdd7b5bba69ef898ea08
  69. hodlinator approved
  70. hodlinator commented at 11:31 am on January 21, 2026: contributor

    re-ACK faa59b367985648df901bdd7b5bba69ef898ea08

    Range-diff shows noexcept being introduced earlier. Diff against last reviewed push faebb1654743adcc5888464f9a7167c315ecce1f confirms end state is the same.

  71. maflcko commented at 11:55 am on January 21, 2026: member
    rfm?
  72. in src/util/expected.h:11 in faa59b3679
     5@@ -6,9 +6,10 @@
     6 #define BITCOIN_UTIL_EXPECTED_H
     7 
     8 #include <attributes.h>
     9+#include <util/check.h>
    10 
    11 #include <cassert>
    


    sedited commented at 12:23 pm on January 21, 2026:
    nit (iwyu): could have removed this again.

    maflcko commented at 12:35 pm on January 21, 2026:

    yeah, i am not sure which way to go. The export pragma in

    0src/util/check.h:#include <cassert> // IWYU pragma: export
    

    was added so that iwyu does not remove the

    0#if defined(NDEBUG)
    1#error "Cannot compile without assertions!"
    2#endif
    

    later in the header (by removing the inclusion of the header).

    However, that seems a bit incomplete and brittle. It would be better to go fully in one direction, to either:

    • Remove the export pragma
    • Leave the export pragma and write a linter to disallow cassert use without including util/check.h

    Not sure which way to go, though

  73. sedited approved
  74. sedited commented at 12:23 pm on January 21, 2026: contributor
    ACK faa59b367985648df901bdd7b5bba69ef898ea08
  75. sedited merged this on Jan 21, 2026
  76. sedited closed this on Jan 21, 2026

  77. maflcko deleted the branch on Jan 21, 2026

github-metadata-mirror

This is a metadata mirror of the GitHub repository bitcoin/bitcoin. This site is not affiliated with GitHub. Content is generated from a GitHub metadata backup.
generated: 2026-02-01 18:12 UTC

This site is hosted by @0xB10C
More mirrored repositories can be found on mirror.b10c.me