Document Number: | p2921r0 |
---|---|
Date: | 2023-06-16 |
Target: | LEWG |
Reply to: | gorn@microsoft.com |
This paper explores extending interface of Buffered Queue (https://wg21.link/p0260r6) with APIs
returning std::expected
as opposed to taking std::error_code
.
enum class conqueue_errc { success = 0, empty, full, closed };
class conqueue_error : runtime_exception { ... };
... make_error_code, make_error_condition, conqueue_category, ...
template <class T, class Alloc = std::allocator<T>>
class buffer_queue {
public:
...
// modifiers
void close() noexcept;
T pop();
std::optional<T> pop(std::error_code& ec);
std::optional<T> try_pop(std::error_code& ec);
void push(const T& x);
bool push(const T& x, error_code& ec);
bool try_push(const T& x, error_code& ec);
void push(T&& x);
bool push(T&& x, error_code& ec);
bool try_push(T&& x, error_code& ec);
};
T pop()
- throws if queue is empty and closed, blocks if the queue is empty
void push(T)
- throws if queue is closed, blocks if the queue is full
APIs that take error_code parameter, would set error_code
parameter to conqueue_errc::closed)
instead of throwing an exception.
Additionally, try_push
and try_pop
APIs never block and would report errors conqueue_errc::full
and conqueue_errc::empty
respectively.
After discussion in LEWG in Varna 2023, we were asked to
explore restyling of the APIs that take std::error_code
parameter with std::expected
returning ones, which we dutifully
did and present the results below.
To distinguish between throwing and expected returning flavors, we use
std::nothrow_t
parameter for disambiguation (alternative was to change the name to, say, push_nothrow
, which we find less aesthetically appealing).
void push(const T&);
bool push(const T&, error_code& ec);
vs
void push(const T&);
auto push(const T&, nothrow_t) -> expected<void, conqueue_errc>;
non-throwing: status-quo | non-throwing: expected with nothrow |
---|---|
std::error_code ec; if (q.push(5, ec)) return; println("got {}", ec); |
if (auto result = q.push(5, nothrow)) return; else println("got {}", result.error()); |
The benefit of expected here is that we don't have to declare ec before calling the API.
Another alternative suggested to us by some LEWG members was to eliminate the throwing versions altogether and replace them with the expected returning ones.
void push(const T&);
bool push(const T&, error_code& ec);
vs
auto push(const T&) -> expected<void, conqueue_errc>;
non-throwing: status-quo | non-throwing: expected only |
---|---|
std::error_code ec; if (q.push(5, ec)) return; println("got {}", ec); |
if (auto result = q.push(5)) return; else println("got {}", result.error()); |
throwing: status quo | throwing: expected only |
---|---|
q.push(5); ... catch(const conqueue_error& e) |
q.push(5).or_else([](auto code) { throw conqueue_error(code); }); ... catch(const conqueue_error& e) |
throwing: status quo | throwing: expected only (awkward exception type) |
---|---|
q.push(5); ... catch(const conqueue_error& e) |
q.push(5).value(); ... catch(const bad_expected_access<conqueue_errc>& e) |
This alternative disfavors an exception throwing and inconsistent with standard library containers. We consider this to be an inferior to the alternative offered in the previous section.
While in our paper we suggested to follow the precedent of try_emplace
that guarantees that:
If the map already contains an element whose key is equivalent to k, *this and args... are unchanged.
We recommend to follow try_emplace
example in this regard as we do not envision
that providing this guarantee for the buffer_queue
will be a burden for the implementors.
Nevertheless, we will explore in this section alternative API shapes:
bool try_push(T&& x, error_code& ec); or
auto try_push(T&& x, nothrow_t) -> expected<void, conqueue_errc>;
vs
auto try_push(T&& x, nothrow_t) -> expected<void, std::pair<T, conqueue_errc>>
unchanged guaranteed | return back |
---|---|
T val = get_value(); if (auto result = q.try_push(std::move(val))) return; else println("failed {}, value {}", result.error(), val); |
if (auto result = q.try_push(get_value())) return; else println("failed {}, value {}", result.error().second, result.error().first); |
Returning back the value, impose a burden on the user and
forces even more awkward catch clauses if bad_unexpected_access is thrown
in response to expected<T>::value()
if the expected stores error,
since now error encodes the value as well.
Staying with guarantees similar to try_emplace
remains our
preferred alternative.
There is another consideration brought up by LEWG is that clang-tidy warns on use after std::move. Luckily, clang-tidy recognizes try_emplace being special and have special comment in the documentation:
https://clang.llvm.org/extra/clang-tidy/checks/bugprone/use-after-move.html#move
There is one special case: A call to std::move inside a try_emplace call is conservatively assumed not to move. This is to avoid spurious warnings, as the check has no way to reason about the bool returned by try_emplace.
For C++26, clang-tidy will need to be updated to include relevant buffer_queue APIs that do not consume the value on failure.
Andreas Weis shared that upcoming Misra C++202x will be having a rule that bans use after move.
C++ Core Guidelines also has a rule ES.56 that
Usually, a std::move() is used as an argument to a && parameter. And after you do that, assume the object has been moved from (see C.64) and don’t read its state again until you first set it to a new value.
We may choose to update the guidelines to carve an exception to APIs like try_emplace
rather
than pessimizing try_
API requiring incurring a move-construction as opposed to a promise
not to touch the T&&
arguments on failure.
Assuming that we are restyling non-throwing versions
of the APIs along the lines that takes std::nothrow
for disambiguation only,
and follow the precedent of try_emplace
with regard to
not consuming T&&
values on failure to push, the APIs will look like:
void push(const T&);
void push(T&&);
expected<void, conqueue_errc> push(const T&, nothrow_t);
expected<void, conqueue_errc> push(T&&, nothrow_t);
expected<void, conqueue_errc> try_push(const T&);
expected<void, conqueue_errc> try_push(T&&);
T pop();
expected<T, conqueue_errc> pop(nothrow_t);
expected<T, conqueue_errc> try_pop();
If the policy of LEWG would be to always add nothrow_t argument to std::expected
returning APIs, try_
versions will become:
expected<void, conqueue_errc> try_push(const T&, nothrow_t);
expected<void, conqueue_errc> try_push(T&&, nothrow_t);
expected<T, conqueue_errc> try_pop(nothrow_t);
To complete the pictures let's compare how the status quo
and nothrow_t
versions of the API fare in the pop scenarios:
Status quo:
error_code ec;
while (auto val = q.pop(ec))
println("got {}", *val);
expected based ones (one line shorter and no unneeded ec)
while (auto val = q.pop(nothrow))
println("got {}", *val);
Status quo:
error_code ec;
while (auto val = q.try_pop(ec))
println("got {}", *val);
if (ec == conqueue_errc::closed)
return;
// do something else.
std::expected based has unfortunate duplication since we want to know why the pop failed and we have to move val out of the loop condition.
auto val = q.try_pop();
while (val) {
println("got {}", *val);
val = q.try_pop();
}
if (val.error() == conqueue_errc::closed)
return;
// do something else
The surprising observation we can make is that std::expected
based API
do not appear to be a clearcut winners and only offer
marginal improvement over std::error_code
based ones and only
in some scenarios.
Based on this thought experiment. We did not find expected
based API
a clear improvement over the status quo.