Replacing Exceptions With Either/Maybe/Option

2020-05-29 06:44发布

问题:

I came across this dead end while trying to replace exceptions with either monad in c#. Which leads me to think maybe it is not only language specific problem and more technique related missing feature.

Let me try to re-explain it more globally:

Given:

  • I have a 3rd party function( a function that is imported into my code and I have no access to) which receives a lazy list (c# IEnumerable,f# Seq...) and consume it

I Want:

  • To apply a function (LINQ select,map...) on the method's lazy list argument and will take each element of the list (lazily) and will do computation that might fail (throwing an exception or returning Error/Either).

  • The list to be consumed only "inside" the 3rd party function, I don't want to have to iterate over each element more then once.

With Exceptions/side effects this can be achieved easily with throwing exception from the select, map functions if error was found, this will stop the execution "inside" the 3rd party function. Then I could handle the exception outside of it (without the 3rd party being "aware" of my error handling), leaving the responsibility of the error handling to me.

While with Either it does not seem to be possible to get the same behavior without altering the 3rd party function. Intuitively I was trying to convert the list from the list of Eithers to Either of list, but this can be done only by consuming the list with functions. Like, aggregate or reduce (does Haskell's Sequence function act the same?).

All this leads me to the question are Maybes/Eithers or Error as return type, missing this behavior? Is there another way to achive the same thing with them?

回答1:

As far as I can tell, Haskell Either is isomorphic to C#/Java-style exceptions, meaning that there's a translation from Either-based code to exception-based code, and vice versa. I'm not quite sure about this, though, as there may be some edge cases that I'm not aware of.

On the other hand, what I am sure of is that Either () a is isomorphic to Maybe a, so in the following, I'm going to stick with Either and ignore Maybe.

What you can do with exceptions in C#, you can also do with Either. The default in C# is to do no error handling1:

public IEnumerable<TResult> NoCatch<TResult, T>(
    IEnumerable<T> source, Func<T, TResult> selector)
{
    return source.Select(selector);
}

This will iterate over source until an exception happens. If no exception is thrown, it'll return IEnumerable<TResult>, but if an exception is thrown by selector, the entire method throws an exception as well. However, if elements of source were handled before an exception was thrown, and there were side-effects, that work remains done.

You can do the same in Haskell using sequence:

noCatch :: (Traversable t, Monad m) => (a -> m b) -> t a -> m (t b)
noCatch f = sequence . fmap f

If f is a function that returns Either, then it behaves in the same way:

*Answer> noCatch (\i -> if i < 10 then Right i else Left i) [1, 3, 5, 2]
Right [1,3,5,2]
*Answer> noCatch (\i -> if i < 10 then Right i else Left i) [1, 3, 5, 11, 2, 12]
Left 11

As you can see, if no Left value is ever returned, you get a Right case back, with all the mapped elements. If just one Left case is returned, you get that, and no further processing is done.

You could also imagine that you have a C# method that suppresses individual exceptions:

public IEnumerable<TResult> Suppress<TResult, T>(
    IEnumerable<T> source, Func<T, TResult> selector)
{
    foreach (var x in source)
        try { yield selector(x) } catch {}
}

In Haskell, you could do this with Either:

filterRight :: (a -> Either e b) -> [a] -> [b]
filterRight f = rights . fmap f

This returns all the Right values, and ignores the Left values:

*Answer> filterRight (\i -> if i < 10 then Right i else Left i) [1, 3, 5, 11, 2, 12]
[1,3,5,2]

You can also write a method that processes the input until the first exception is thrown (if any):

public IEnumerable<TResult> ProcessUntilException<TResult, T>(
    IEnumerable<T> source, Func<T, TResult> selector)
{
    var exceptionHappened = false;
    foreach (var x in source)
    {
        if (!exceptionHappened)
            try { yield selector(x) } catch { exceptionHappened = true }
    }
}

Again, you can achieve the same effect with Haskell:

takeWhileRight :: (a -> Either e b) -> [a] -> [Either e b]
takeWhileRight f = takeWhile isRight . fmap f

Examples:

*Answer> takeWhileRight (\i -> if i < 10 then Right i else Left i) [1, 3, 5, 11, 2, 12]
[Right 1,Right 3,Right 5]
*Answer> takeWhileRight (\i -> if i < 10 then Right i else Left i) [1, 3, 5, 2]
[Right 1,Right 3,Right 5,Right 2]

As you can see, however, both the C# examples and the Haskell examples need to be aware of the style of error-handling. While you can translate between the two styles, you can't use one with a method/function that expects the other.

If you have a third-party C# method that expects exception handling to be the way things are done, you can't pass it a sequence of Either values and hope that it can deal with it. You'd have to modify the method.

The converse isn't quite true, though, because exception-handling is built into C# (and Haskell as well, in fact); you can't really opt out of exception-handling in such languages. Imagine, however, a language that doesn't have built-in exception-handling (PureScript, perhaps?), and this would be true as well.


1 C# code may not compile.



回答2:

I haven't got a compiler handy, but you may want to check my language-ext project. It's a functional base class library for C#.

For your needs it has:

  • Seq<A> which is a cons like lazy enumerable which will only evaluate once
  • Try<A> which is a delegate based monad which allows you to capture exceptions from third party code
  • Other common error handling monads: Option<A>, Either<L, R>, etc.
  • Bonus variants of those monads: OptionAsync<A>, TryOption<A>, TryAsync<A>, TryOptionAsync<A>
  • Ability to easily convert between those types: ToOption(), ToEither(), etc.

To apply a function (LINQ select,map...) on the method's lazy list argument and will take each element of the list (lazily) and will do computation that might fail (throwing an exception or returning Error/Either). The list to be consumed only "inside" the 3rd party function, I don't want to have to iterate over each element more then once.

This is a little unclear of the actual goal. In language-ext you could do this:

using LanguageExt;
using static LanguageExt.Prelude;

// Dummy lazy enumerable
IEnumerable<int> Values()
{
    for(int i = 0; i < 100; i++)
    {
        yield return UnreliableExternalFunc(i);
    }
}

// Convert to a lazy sequence
Seq<int> seq = Seq(Values());

// Invoke external function that takes an IEnumerable
ExternalFunction(seq);

// Calling it again won't evaluate it twice
ExternalFunction(seq);

But if the Values() function threw an exception then it would end its yielding and would return. So you'd ideally have this:

// Dummy lazy enumerable
IEnumerable<Try<int>> Values()
{
    for(int i = 0; i < 100; i++)
    {
        yield return Try(() => UnreliableExternalFunc(i));
    }
}

Try is the constructor function for the Try monad. So your result would be a sequence of Try thunks. If you don't care about the exception you could convert it to an Option

// Dummy lazy enumerable
IEnumerable<Option<int>> Values()
{
    for(int i = 0; i < 100; i++)
    {
        yield return Try(() => UnreliableExternalFunc(i)).ToOption();
    }
}

The you could access all successes via:

var validValues = Values().Somes();

Or you could instead use Either:

// Dummy lazy enumerable
IEnumerable<Either<Exception, A>> Values()
{
    for(int i = 0; i < 100; i++)
    {
        yield return Try(() => UnreliableExternalFunc(i)).ToEither();
    }
}

Then you can get the valid results thus:

var seq = Seq(Values());

var validValues = seq.Rights();

And the errors:

var errors = seq.Lefts();

I converted it to a Seq so it doesn't evaluate twice.

One way or another, if you want to catch an exception that happens during the lazy evaluation of the enumerable, then you will need to wrap each value. If the exception can occur from the usage of the lazy value, but within a function then your only hope is to surround it with a Try:

// Convert to a lazy sequence
Seq<int> seq = Seq(Values());  // Values is back to returning IEnumerable<int>

// Invoke external function that takes an IEnumerable
var res = Try(() => ExternalFunction(seq)).IfFail(Seq<int>.Empty);

// Calling it again won't evaluate it twice
ExternalFunction(seq);

Intuitively I was trying to convert the list from the list of Eithers to Either of list, but this can be done only by consuming the list with functions. Like, aggregate or reduce (does Haskell's Sequence function act the same?).

You can do this in language-ext like so:

IEnumerable<Either<L, R>> listOfEithers = ...;

Either<L, IEnumerable<R>> eitherList = listOfEithers.Sequence();

Traverse is also supported:

Either<L, IEnumerable<R>> eitherList = listOfEithers.Traverse(x => map(x));

All combinations of monads support Sequence() and Traverse; so you could do it with a Seq<Either<L, R>> to get a Either<L, Seq<R>>, which would guarantee that the lazy sequence isn't invoked multiple times. Or a Seq<Try<A>> to get a Try<Seq<A>>, or any of the async variants for concurrent sequencing and traversal.

I'm not sure if any of this is covering what you're asking, the question is a bit broad. A more specific example would be useful.