C#'s can't make `notnull` type nullable

2020-02-13 08:57发布

问题:

I'm trying to create a type similar to Rust's Result or Haskell's Either and I've got this far:

public struct Result<TResult, TError>
    where TResult : notnull
    where TError : notnull
{
    private readonly OneOf<TResult, TError> Value;
    public Result(TResult result) => Value = result;
    public Result(TError error) => Value = error;

    public static implicit operator Result<TResult, TError>(TResult result)
        => new Result<TResult, TError>(result);

    public static implicit operator Result<TResult, TError>(TError error)
        => new Result<TResult, TError>(error);

    public void Deconstruct(out TResult? result, out TError? error)
    {
        result = (Value.IsT0) ? Value.AsT0 : (TResult?)null;
        error = (Value.IsT1) ? Value.AsT1 : (TError?)null;
    }  
}

Given that both types parameters are restricted to be notnull, why is it complaining (anywhere where there's a type parameter with the nullable ? sign after it) that:

A nullable type parameter must be known to be a value type or non-nullable reference type. Consider adding a 'class', 'struct', or type constraint.

?


I'm using C# 8 on .NET Core 3 with nullable reference types enabled.

回答1:

Basically you're asking for something that can't be represented in IL. Nullable value types and nullable reference types are very different beasts, and while they look similar in source code, the IL is very different. The nullable version of a value type T is a different type (Nullable<T>) whereas the nullable version of a reference type T is the same type, with attributes telling the compiler what to expect.

Consider this simpler example:

public class Foo<T> where T : notnull
{
    public T? GetNullValue() => 
}

That's invalid for the same reason.

If we constraint T to be a struct, then the IL generated for the GetNullValue method would have a return type of Nullable<T>.

If we constraint T to be a non-nullable reference type, then the IL generated for the GetNullValue method would have a return type of T, but with an attribute for the nullability aspect.

The compiler can't generate IL for a method which has a return type of both T and Nullable<T> at the same time.

This is basically all the result of nullable reference types not being a CLR concept at all - it's just compiler magic to help you express intentions in code and get the compiler to perform some checking at compile-time.

The error message isn't as clear as it might be though. T is known to be "a value type or non-nullable reference type". A more precise (but significantly wordier) error message would be:

A nullable type parameter must be known to be a value type, or known to be a non-nullable reference type. Consider adding a 'class', 'struct', or type constraint.

At that point the error would reasonably apply to our code - the type parameter is not "known to be a value type" and it's not "known to be a non-nullable reference type". It's known to be one of the two, but the compiler needs to know which.



回答2:

The reason for the warning is explained in the section The issue with T? of Try out Nullable Reference Types. Long story short, if you use T? you have to specify whether the type is a class or struct. You may end up creating two types for each case.

The deeper problem is that using one type to implement Result and hold both Success and Error values brings back the same problems Result was supposed to fix, and a few more.

  • The same type has to carry a dead value around, either the type or the error, or bring back nulls
  • Pattern matching on the type isn't possible. You'd have to use some fancy positional pattern matching expressions to get this to work.
  • To avoid nulls you'll have to use something like Option/Maybe, similar to F#'s Options. You'd still carry a None around though, either for the value or error.

Result (and Either) in F#

The starting point should be F#'s Result type and discriminated unions. After all, this already works on .NET.

A Result type in F# is :

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

The types themselves only carry what they need.

DUs in F# allow exhaustive pattern matching without requiring nulls :

match res2 with
| Ok req -> printfn "My request was valid! Name: %s Email %s" req.Name req.Email
| Error e -> printfn "Error: %s" e

Emulating this in C# 8

Unfortunately, C# 8 doesn't have DUs yet, they're scheduled for C# 9. In C# 8 we can emulate this, but we lose exhaustive matching :

#nullable enable

public interface IResult<TResult,TError>{}​

​struct Success<TResult,TError> : IResult<TResult,TError>
{
    public TResult Value {get;}

    public Success(TResult value)=>Value=value;

    public void Deconstruct(out TResult value)=>value=Value;        
}

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

    public Error(TError error)=>ErrorValue=error;

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

And use it :

IResult<double,string> Sqrt(IResult<double,string> input)
{
    return input switch {
        Error<double,string> e => e,
        Success<double,string> (var v) when v<0 => new Error<double,string>("Negative"),
        Success<double,string> (var v)  => new Success<double,string>(Math.Sqrt(v)),
        _ => throw new ArgumentException()
    };
}

Without exhaustive pattern matching, we have to add that default clause to avoid compiler warnings.

I'm still looking for a way to get exhaustive matching without introducing dead values, even if they are just an Option.

Option/Maybe

Creating an Option class by the way that uses exhaustive matching is simpler :

readonly struct Option<T> 
{
    public readonly T Value {get;}

    public readonly bool IsSome {get;}
    public readonly bool IsNone =>!IsSome;

    public Option(T value)=>(Value,IsSome)=(value,true);    

    public void Deconstruct(out T value,out bool isSome)=>(value,isSome)=(Value,IsSome);
}

//Convenience methods, similar to F#'s Option module
static class Option
{
    public static Option<T> Some<T>(T value)=>new Option<T>(value);    
    public static Option<T> None<T>()=>default;
}

Which can be used with :

string cateGory = someValue switch { Option<Category> (_    ,false) =>"No Category",
                                     Option<Category> (var v,true)  => v.Name
                                   };