简体   繁体   中英

Nullable Reference Types and Either Pattern

I'm trying out the new Nullable Reference Types in C# 8.0 , and I'm facing the following issue.

Given this struct:

public readonly struct Either<TReturn, TError>
    where TReturn : struct
    where TError : struct
{
    public TError? Error { get; }
    public TReturn? Response { get; }

    public Either(TError? error, TReturn? response)
    {
        if (error == null && response == null)
        {
           throw new ArgumentException("One argument needs not to be null.");
        }
        if (error != null && response != null)
        {
            throw new ArgumentException("One argument must be null.");
        }
        Error = error;
        Response = response;
    }
}

How can I tell the compiler that either Error or Response is not null, and that they can't both be null? Is there a way to do such a thing with the new attributes?

Update for structs

The code doesn't change when the result types change to structs. To use struct type parameters, the following constraints have to be added to the interface and the types:

where TResult : struct
where TError  : struct

When I think about the Either pattern, I think about F#, pattern matching and discriminated unions, not nulls. In fact, Either is a way to avoid nulls. In fact, the question's code looks like an attempt to create a Result type , not just an Either. Scott Wlaschin's Railway Oriented Programming shows how such a type can be used to implement error handling in a functional language.

In F#, the Result type is defined as:

type Result<'T,'TError> = 
    | Ok of ResultValue:'T 
    | Error of ErrorValue:'TError

We can't do that in C# 8 yet, because there are no discriminated unions. Those are planned for C# 9.

Pattern Matching

What we can do, is use pattern matching to get the same behavior eg:

interface IResult<TResult,TError>{} //No need for an actual implementation

public class Success<TResult,TError>:IResult<TResult,TError>

{
    public TResult Result {get;}

    public Success(TResult result) { Result=result;}
}

public class Error<TResult,TError>:IResult<TResult,TError>
{
    public TError ErrorValue {get;}

    public Error(TError error) { ErrorValue=error;}
}

This way there's no way to create an IResult<> that is both a success and error. This can be used with pattern matching, eg:

IResult<int,string> someResult=.....;

if(someResult is Success<int,string> s)
{
    //Use s.Result here
}

Simplifying the expressions

Given C# 8's property patterns , this could be rewritten as:

if(someResult is Success<int,string> {Result: var result} )
{
    Console.WriteLine(result);
}

or, using switch expressions, a typical railway-style call:

IResult<int,string> DoubleIt(IResult<int,string> data)
{
    return data switch {    Error<int,string> e=>e,
                            Success<int,string> {Result: var result}=>
                                       new Success<int,string>(result*2),
                            _ => throw new Exception("Unexpected type!")
                            };
}    

F# wouldn't need that throw as there's no way that an Result<'T,'TError> would be something other than Ok or Error . In C#, we don't have that feature yet .

The switch expression allows exhaustive matching. I think the compiler will generate a warning if the default clause is missing too.

With deconstructors

The expressions can be simplified a bit more if the types have deconstructors, eg:

public class Success<TResult,TError>:IResult<TResult,TError>
{
    public TResult Result {get;}

    public Success(TResult result) { Result=result;}

    public void Deconstruct(out TResult result) { result=Result;}
}

public class Error<TResult,TError>:IResult<TResult,TError>
{
    public TError ErrorValue {get;}

    public Error(TError error) { ErrorValue=error;}

    public void Deconstruct(out TError error) { error=ErrorValue;}
}

In that case the expression can be written as:

return data switch {    
                Error<int,string> e => e,
                Success<int,string> (var result) => new Success<int,string>(result*3),
                _ => throw new Exception("Unexpected type!")
};

Nullability

The question started with nullable reference types, so what about nullability? Will we get a warning in C# 8 if we try to pass a nulll?

Yes, as long as NRTs are enabled. This code:

#nullable enable

void Main()
{
     IResult<string,string> data=new Success<string,string>(null);
     var it=Append1(data);
     Console.WriteLine(it);
}

IResult<string,string> Append1(IResult<string,string> data)
{
    return data switch {    Error<string,string> e=>e,
                            Success<string,string> (var result)=>
                                new Success<string,string>(result+"1"),
                            _ => throw new Exception("Unexpected type!")
                            };
}

Genereates CS8625: Cannot convert null literal to non-nullable reference type

Trying

string? s=null;
IResult<string,string> data=new Success<string,string>(s);

Generates CS8604: Possible null reference argument....

When implementing Either monad, you should use two different constructors. That way, you can easily get away from those checks because your implementation will make sure that there is no way to have both properties assigned at the same time.

public readonly class Either<TReturn, TError>
{
    bool _successful;
    private TError _error { get; }
    private TReturn _response { get; }

    public Either(TError error)
    {
        _error = error;
    }

    public Either(TReturn response)
    {
       _successful = true;
       _response = response;
    }
}

Besides that, you need to add a method (to the struct) which will be used to extract the value from the struct, and transform it into common return type:

public Match<T>(Func<TError, T> errorFunc, Func<TResponse, T> successFunc)
    => _successful ? successFunc(_response) : errorFunc(_error);

That way, you are enforcing users to handle both cases (success, error) and provide functions which will do transformation into common type:

var errorEither = new Either<string, int>(10); // example of error code
var successEither = new Either<string, int>("success"); // example of success

var commonValueError = errorEither.Match<bool>(err => false, succ => true);
var commonValueSuccess = successEither.Match<bool>(err => false, succ => true);

You can do something like this with Resharper ContractAnnotation . It's not C# 8 specific, (but... I don't think your example is really using nullable reference types anyway, is it? You are using nullable structs.)

[ContractAnnotation(
   "=> pError: null, pResponse: notnull; => pError: notnull, pResponse: null"
)]
public void Get(out TError? pError, out TReturn? pResponse) {
   pError = Error;
   pResponse = Response;
}

(The meaning of the string is that left of the => is input conditions, and right of the => is output conditions, with ; separating different cases, and an unlabled value referring to the method return value. So in this case: regardless of input, the output condition is ether of null/notnull or notnull/null.)

Then use C# 7 feature out var :

GetAnEitherFromSomewhere()
.Get(out var error, out var response);
if (error != null) {
   // handle error
   return;
}

// response is now known to be not null, because we can only get here if error was null

Honestly I'm finding JetBrains's annotations [NotNull] , [CanBeNull] , and [ContractAnnotation] to be a lot more flexible (though more verbose) than nullable reference types. Basically they allow for the intermediate case, where there are situations where the value can be null , but also situations where the value cannot be null , and those situations are distinguishable at run-time. With nullable reference types, I can't specify the in-between case, I have to choose either definitely nullable or definitely not nullable.

Even something common like TryParse :

// should the result be nullable with nullable reference types on?
// you have to either lie with ! or else use ? and assume it can always be null
public bool TryParse(string pString, out SomeClass pResult) {
   if (<cant parse>) {
      pResult = null;
      return false;
   }

   pResult = value;
   return true;
}

// works great with JetBrains annotations and nullable reference types off
// now you can know that the result is null or notnull
// based on testing the bool return value
[ContractAnnotation("=> true, pResult: notnull; => false, pResult: null")]
public bool TryParse(string pString, out SomeClass pResult) {
   ...
}

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM