|
Boost : |
Subject: [boost] [outcome] Exception safety guarantees
From: Andrzej Krzemienski (akrzemi1_at_[hidden])
Date: 2017-05-27 16:35:09
Hi All,
This is in connection with Vicente's question: what exception safety should
we expect of copy assignment of `expected`?
Now, this is really funny because we are talking about exception safety in
something that is used as a substitute for exceptions. That is, one of the
use cases for `expected` is to be able to get rid of exception handling
altogether (while still reporting failures). But this fundamental problem,
"what state my object is in if this mutating operation fails" is not
specific to exceptions. It is speciffic to operations that might fail; and
how (and if) they signal failures is of secondary importance. So, let's
talk about *failure safety* rather than 'exception safety'.
First, let's consider the use case for `expected`, where we want to disable
exception handling altogether. This means `T` or `E` cannot throw on any
operation. But copy assignment can still fail, right? Or maybe in these
domains you are only using trivially-copyable types as `T` and `E`. Or
maybe in these domains you never have a need to copy-assign instances of
`expected`? Is so, they provide a *no-fail* guarantee, and any
implementation of `expected` will be good and offer no-fail guarantee also.
Bus if some copying operation on T or E can fail, how is the failure
reported? Through a return value? Output parameter?
But whatever the answer, we are arriving at the "nested failure" problem:
we are processing a (potential) failure report, and this processing fails.
What should we do? report the new error condition and ignore the previous?
This is very close to a double-exception during stack unwinding. In C++ it
std::terminates, other languages ignore the original error, or build a
combined error report. All these solutions not satisfactory, and maybe no
satisfactory solution exists.
I would like to hear an opinion from people who deal with `expected` in
exception-disabled environments.
On the other extreme, you fave my example with parsing input (which Vicente
observed is not parsing, but matching):
https://github.com/akrzemi1/__sandbox__/blob/master/outcome_practical_example.md
In that case, If I get an exception anywhere (not only upon copying T or E)
I want stack to be unwound so far, that all not `expected` objects will
remain. So I only care about basic failure guarantee: just let me correctly
destroy these objects.
In the middle: you have the situation where you copy-assign an `expected`
and a copy-constructor of assignment of T or E throws. But where did this
exception come from, given that you are using `excepted` for signalling
failures? Or are you signalling some failures with exceptions and some with
`expected`? And if so, are exceptions not more panic-like? And in that case
yu would like to abandon the processing of any `expected`?
Anyway, the most difficulties stem from the case where you are storing an E
in `expected` and you want to assign an `expected` storing T. You have to
first destroy E, and then may not be able to construct a T.
My solution to this would be to go to the advice from the first days of
forming exception safety guarantees: provide basic guarantee by default,
and strong guarantee only if it does not cost too much. We are used to STL
containers providing strong assignment, but this is because they are
pointers, and they can implement it for free. But does std::touple provide
a strong guarantee? No. Do aggregate types provide stron guarantee? No. And
can it result in inconsistent data? It can:
```
struct Man { std::string fist_name, last_name; };
Man m1 = {"April", "Jones"};
Man m2 = {"Theresa", "May"};
try {
m2 = m1;
}
catch(...) {
}
```
`m2` may end up being {"April", "May"}. And we are taught to write types
like this. But if this hapens, the blame is on whoever allowed these
objects to outlive the "stack unwinding bubble".
So my view, as of today, is not to strive for a strong or even never-empty
guarantee. Provide a conditional guarantee, if types T and E don't throw,
you get no-fail guarantee. If they do, you only have a basic guarantee: you
can destroy, assign to, or maybe call valueless_by_exception(). Nothing
more. I think `std::variant` made the optimal choice.
And people should code so that instances of `expected` (at least those with
T or E throwing on copy/move) should not outlive the "stack unwinding
bubbles".
Also, note what vector::push_back does when T is non-opyable and its move
constructor is potentially throwing: if T's move throws upon reallocation,
the behavior is undefined.
Regards,
&rzej;
Boost list run by bdawes at acm.org, gregod at cs.rpi.edu, cpdaniel at pacbell.net, john at johnmaddock.co.uk