Thank you, Jonathan for your insights.
On Sat, 7 Jun 2025 at 15:15, Jonathan Wakely <cxx@kayari.org> wrote:
N.B. calling future::get() doesn't necessarily mean that calling it again is UB. It's specified to change valid() to false, but whether that's UB is up to the implementation, and recommended practice is to diagnose it by throwing, so no UB.
According to https://en.cppreference.com/w/cpp/thread/future/get,
calling get() when valid() is false is UB. The standard doesn't
say this explicitly, but it does say that get() retrieves the
value/throws the exception stored in the shared state, which
doesn't exist when valid() is false. Thus the behaviour of get()
when valid() is false is literally undefined. But I concede that
it's not called out as UB either. Perhaps this needs addressing
in a separate issue?
Agreed. Adopting the standard's language in the specification of .get(), i.e. referring to valid() and "the stored exception, if an exception was stored in the shared state" is a better way of describing the behaviour all round.and so get_exception() could be a
const member function of std::future.
I think what you want to say is that it wouldn't change valid() to false.
Although I'm not sure about that, I don't see why you should only be able to call future<void>::get() only once (when that doesn't modify the result, because there isn't one!) but call future<void>::get_exception() more than once. The main justification I can see is to support code like this that wants to check for an exception first and if it's not present, get the result:
Calling std::future<T>::get() sets valid() to false, even
for future<void>, because the future releases the shared
state as a side-effect of calling get(). The "result" may not be
changed, but the future itself is. We absolutely don't want
get_exception() to invalidate the future - at least when there is
a good result in the shared state, as we need to be able to call
get() later. This is less important when there is an exception
rather than a value in the shared state, but for get_exception()
to invalidate the future in some cases but not in others would
complicate matters to no obvious benefit.
auto fut = prom.get_future();if (auto ex = fut.get_exception())handleError(exception_ptr_cast<E>(ex));elsehandleResult(fut.get());
but if you could get expected<R,exception_ptr> out then you wouldn't need that anyway, you would only need to call the function to get the expected once, then you could test that at leisure:
auto fut = prom.get_future();if (auto r = fut.get_res())handleResult(*r);elsehandleError(exception_ptr_cast<E>(r.error()));
certainly this is a common pattern, but isn't the only one. Recall that one of the motiviations behind this is to be able to write or_else and when_any functions for futures. These should not need to not call .get(), even in the successful case:
template<typename T, typename Function>
std::future<T> or_else(std::future<T>&& fut,
Function&& f) // f() is required to return a
future<T> (c.f. optional::or_else)
{
if(auto ex = fut.get_exception())
return std::forward<Function>(f)(ex);
else
return std::move(fut);
}
template<typename T>
std::future<T>
when_any(std::vector<std::future<T>>&&
futures)
{
for(auto& future : futures) {
if (!future.get_exception())
return std::move(future);
}
return std::move(futures.back()); // Handling empty vector
ignored for this expositional example
}
In each case, working with a std::expected or calling .get() would require re-wrapping the result into a future again, when the above simply use simply moves the future.
This API would also give you all the monadic functions of expected.
prom.get_future().get_result().and_then(handleResult).or_else(handleError);
The above expression is an expected, rather than a future, so
doesn't grant monadic operations on std::future. Maybe it's my
lack of insight, but I don't see how to us this pattern to write
when_any or or_else that return futures.
*Alternatives*
It might be possible, within a given codebase, to achieve equivalent
functionality by the use of std::future<std::expected<T, exception_ptr>>
*and* careful definition of the functions used with std::future's
generators std::async & std::packaged_task. However, this has two
drawbacks:
1. std::expected does not support wrapping references - one has to wrap
the reference somehow, leading to more complicated code. This could be
mitigated by having std::expected support references. This idea was
mentioned (but not proposed) in P2988r0 and anyway was subsequently
dropped in later revisions of that paper. I am not aware of any other
work in this area.
I expect it will come back and we'll get expected<T&,E>, but not as part of the same proposal as optional<T&>.
If we get it for C++29 then this point is no longer relevant, since your idea couldn't get accepted before C++29 anyway.
There is nothing active on this front as yet so it's a big "if"
at present. I dare say both get_result() and get_exception()
could be accomodated in time, but as things stand only
get_expected is possible.
Furthermore, get_exception() is const. That may not be all that
important, as all almoost all other uses uses of future aren't
const, but there is one relevant case: future<void>.
2. Regardless, it is still possible for std::future<std::expected<T,
std::exception_ptr>>::get() to throw. To avoid this, care must be taken
with the functions given to std::future's sources (std::asyc,
std::packaged_task etc.) to ensure than the exception is supplied only
via a std::unexpected<std::exception_ptr>. Furthermore, the consumer of
the future must know that this is the case. Generic code using this
approach would still benefit from being able to tell if the future.get()
will throw or not.
Right, but I still think we could improve your program by using expected.
Sorry, phone autocorrect turned "proposal" into "program".
What if instead of get() and get_exception() we had get() and get_result() where the latter returns expected<R,exception_ptr>?