How would you use Alexandrescu's Expected w

2019-01-31 11:33发布

问题:

So I ran across this (IMHO) very nice idea of using a composite structure of a return value and an exception - Expected<T>. It overcomes many shortcomings of the traditional methods of error handling (exceptions, error codes).

See the Andrei Alexandrescu's talk (Systematic Error Handling in C++) and its slides.

The exceptions and error codes have basically the same usage scenarios with functions that return something and the ones that don't. Expected<T>, on the other hand, seems to be targeted only at functions that return values.

So, my questions are:

  • Have any of you tried Expected<T> in practice?
  • How would you apply this idiom to functions returning nothing (that is, void functions)?

Update:

I guess I should clarify my question. The Expected<void> specialization makes sense, but I'm more interested in how it would be used - the consistent usage idiom. The implementation itself is secondary (and easy).

For example, Alexandrescu gives this example (a bit edited):

string s = readline();
auto x = parseInt(s).get(); // throw on error
auto y = parseInt(s); // won’t throw
if (!y.valid()) {
    // ...
}

This code is "clean" in a way that it just flows naturally. We need the value - we get it. However, with expected<void> one would have to capture the returned variable and perform some operation on it (like .throwIfError() or something), which is not as elegant. And obviously, .get() doesn't make sense with void.

So, what would your code look like if you had another function, say toUpper(s), which modifies the string in-place and has no return value?

回答1:

Have any of you tried Expected; in practice?

It's quite natural, I used it even before I saw this talk.

How would you apply this idiom to functions returning nothing (that is, void functions)?

The form presented in the slides has some subtle implications:

  • The exception is bound to the value.
  • It's ok to handle the exception as you wish.
  • If the value ignored for some reasons, the exception is suppressed.

This does not hold if you have expected<void>, because since nobody is interested in the void value the exception is always ignored. I would force this as I would force reading from expected<T> in Alexandrescus class, with assertions and an explicit suppress member function. Rethrowing the exception from the destructor is not allowed for good reasons, so it has to be done with assertions.

template <typename T> struct expected;

#ifdef NDEBUG // no asserts
template <> class expected<void> {
  std::exception_ptr spam;
public:
  template <typename E>
  expected(E const& e) : spam(std::make_exception_ptr(e)) {}
  expected(expected&& o) : spam(std::move(o.spam)) {}
  expected() : spam() {}

  bool valid() const { return !spam; }
  void get() const { if (!valid()) std::rethrow_exception(spam); }
  void suppress() {}
};
#else // with asserts, check if return value is checked
      // if all assertions do succeed, the other code is also correct
      // note: do NOT write "assert(expected.valid());"
template <> class expected<void> {
  std::exception_ptr spam;
  mutable std::atomic_bool read; // threadsafe
public:
  template <typename E>
  expected(E const& e) : spam(std::make_exception_ptr(e)), read(false) {}
  expected(expected&& o) : spam(std::move(o.spam)), read(o.read.load()) {}
  expected() : spam(), read(false) {}

  bool valid() const { read=true; return !spam; }
  void get() const { if (!valid()) std::rethrow_exception(spam); }
  void suppress() { read=true; }

  ~expected() { assert(read); }
};
#endif

expected<void> calculate(int i)
{
  if (!i) return std::invalid_argument("i must be non-null");
  return {};
}

int main()
{
  calculate(0).suppress(); // suppressing must be explicit
  if (!calculate(1).valid())
    return 1;
  calculate(5); // assert fails
}


回答2:

Even though it might appear new for someone focused solely on C-ish languages, to those of us who had a taste of languages supporting sum-types, it's not.

For example, in Haskell you have:

data Maybe a = Nothing | Just a

data Either a b = Left a | Right b

Where the | reads or and the first element (Nothing, Just, Left, Right) is just a "tag". Essentially sum-types are just discriminating unions.

Here, you would have Expected<T> be something like: Either T Exception with a specialization for Expected<void> which is akin to Maybe Exception.



回答3:

Like Matthieu M. said, this is something relatively new to C++, but nothing new for many functional languages.

I would like to add my 2 cents here: part of the difficulties and differences are can be found, in my opinion, in the "procedural vs. functional" approach. And I would like to use Scala (because I am familiar both with Scala and C++, and I feel it has a facility (Option) which is closer to Expected<T>) to illustrate this distinction.

In Scala you have Option[T], which is either Some(t) or None. In particular, it is also possible to have Option[Unit], which is morally equivalent to Expected<void>.

In Scala, the usage pattern is very similar and built around 2 functions: isDefined() and get(). But it also have a "map()" function.

I like to think of "map" as the functional equivalent of "isDefined + get":

if (opt.isDefined)
   opt.get.doSomething

becomes

val res = opt.map(t => t.doSomething)

"propagating" the option to the result

I think that here, in this functional style of using and composing options, lies the answer to your question:

So, what would your code look like if you had another function, say toUpper(s), which modifies the string in-place and has no return value?

Personally, I would NOT modify the string in place, or at least I will not return nothing. I see Expected<T> as a "functional" concept, that need a functional pattern to work well: toUpper(s) would need to either return a new string, or return itself after modification:

auto s = toUpper(s);
s.get(); ...

or, with a Scala-like map

val finalS = toUpper(s).map(upperS => upperS.someOtherManipulation)

if you don't want to follow a functional route, you can just use isDefined/valid and write your code in a more procedural way:

auto s = toUpper(s);
if (s.valid())
    ....

If you follow this route (maybe because you need to), there is a "void vs. unit" point to make: historically, void was not considered a type, but "no type" (void foo() was considered alike a Pascal procedure). Unit (as used in functional languages) is more seen as a type meaning "a computation". So returning a Option[Unit] does make more sense, being see as "a computation that optionally did something". And in Expected<void>, void assumes a similar meaning: a computation that, when it does work as intended (where there are no exceptional cases), just ends (returning nothing). At least, IMO!

So, using Expected or Option[Unit] could be seen as computations that maybe produced a result, or maybe not. Chaining them will prove it difficult:

auto c1 = doSomething(s); //do something on s, either succeed or fail
if (c1.valid()) {
   auto c2 = doSomethingElse(s); //do something on s, either succeed or fail
   if (c2.valid()) { 
        ...

Not very clean.

Map in Scala makes it a little bit cleaner

doSomething(s) //do something on s, either succeed or fail
   .map(_ => doSomethingElse(s) //do something on s, either succeed or fail
   .map(_ => ...)

Which is better, but still far from ideal. Here, the Maybe monad clearly wins... but that's another story..



回答4:

I've been pondering the same question since I've watched this video. And so far I didn't find any convincing argument for having Expected, for me it looks ridiculous and against clarity&cleanness. I have come up with the following so far:

  • Expected is good since it has either value or exceptions, we not forced to use try{}catch() for every function which is throwable. So use it for every throwing function which has return value
  • Every function that doesn't throw should be marked with noexcept. Every.
  • Every function that returns nothing and not marked as noexcept should be wrapped by try{}catch{}

If those statements hold then we have self-documented easy to use interfaces with only one drawback: we don't know what exceptions could be thrown without peeking into implementation details.

Expected impose some overheads to the code since if you have some exception in the guts of your class implementation(e.g. deep inside private methods) then you should catch it in your interface method and return Expected. While I think it is quite tolerable for the methods which have a notion for returning something I believe it brings mess and clutter to the methods which by design have no return value. Besides for me it is quite unnatural to return thing from something that is not supposed to return anything.