Los C # no pueden hacer que el tipo `notnull` sea nulo

9

Estoy tratando de crear un tipo similar al de Rust Resulto Haskell Eithery he llegado hasta aquí:

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;
    }  
}

Dado que ambos parámetros de tipo están restringidos a ser notnull, ¿por qué se queja (en cualquier lugar donde haya un parámetro de tipo con el ?signo anulable después) que:

Se debe saber que un parámetro de tipo anulable es un tipo de valor o un tipo de referencia no anulable. Considere agregar una restricción de 'clase', 'estructura' o tipo.

?


Estoy usando C # 8 en .NET Core 3 con tipos de referencia anulables habilitados.

Zapato diamente
fuente
En su lugar, debe comenzar con el tipo de resultado de F # y las uniones discriminadas. Puede lograr fácilmente algo similar en C # 8, sin tener un valor muerto, pero no tendrá una coincidencia exhaustiva. Intentar poner ambos tipos en la misma estructura se encontrará con un problema tras otro, y trae de vuelta los mismos problemas. Se suponía que el resultado debía solucionarse
Panagiotis Kanavos el

Respuestas:

12

Básicamente, estás pidiendo algo que no se puede representar en IL. Los tipos de valores anulables y los tipos de referencia anulables son bestias muy diferentes, y aunque se parecen en el código fuente, el IL es muy diferente. La versión anulable de un tipo de valor Tes un tipo diferente ( Nullable<T>) mientras que la versión anulable de un tipo de referencia Tes el mismo tipo, con atributos que le dicen al compilador qué esperar.

Considere este ejemplo más simple:

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

Eso no es válido por la misma razón.

Si nos limitamos Ta ser una estructura, la IL generada para el GetNullValuemétodo tendría un tipo de retorno de Nullable<T>.

Si restringimos Ta ser un tipo de referencia no anulable, entonces la IL generada para el GetNullValuemétodo tendría un tipo de retorno de T, pero con un atributo para el aspecto de nulabilidad.

El compilador no puede generar IL para un método que tiene un tipo de retorno de ambos Ty Nullable<T>al mismo tiempo.

Esto es básicamente todo el resultado de que los tipos de referencia anulables no son un concepto CLR en absoluto: es solo magia del compilador para ayudarlo a expresar intenciones en el código y hacer que el compilador realice algunas comprobaciones en tiempo de compilación.

Sin embargo, el mensaje de error no es tan claro como podría ser. Tse sabe que es "un tipo de valor o un tipo de referencia no anulable". Un mensaje de error más preciso (pero significativamente más extenso) sería:

Se debe saber que un parámetro de tipo anulable es un tipo de valor o un tipo de referencia no anulable. Considere agregar una restricción de 'clase', 'estructura' o tipo.

En ese punto, el error se aplicaría razonablemente a nuestro código: el parámetro de tipo no "se sabe que es un tipo de valor" y no "se sabe que es un tipo de referencia no anulable". Se sabe que es uno de los dos, pero el compilador necesita saber cuál .

Jon Skeet
fuente
También existe la magia de tiempo de ejecución: no se puede convertir un nulo anulable, a pesar de que no hay forma de representar esa restricción en IL. Nullable<T>es un tipo especial que no puedes hacer tú mismo. Y luego está el punto extra de cómo se hace el boxeo con tipos anulables.
Luaan
1
@Luaan: Hay magia de tiempo de ejecución para los tipos de valores que aceptan valores NULL, pero no para los tipos de referencia que admiten valores NULL.
Jon Skeet
6

El motivo de la advertencia se explica en la sección The issue with T?de Prueba de tipos de referencia anulables . Para resumir, si lo usa T?, debe especificar si el tipo es una clase o estructura. Puede terminar creando dos tipos para cada caso.

El problema más profundo es que usar un tipo para implementar Result y mantener los valores Success y Error devuelve los mismos problemas que se suponía que Result solucionaría, y algunos más.

  • El mismo tipo tiene que llevar un valor muerto, ya sea el tipo o el error, o devolver nulos
  • La coincidencia de patrones en el tipo no es posible. Tendría que usar algunas expresiones de coincidencia de patrones posicionales elegantes para que esto funcione.
  • Para evitar nulos, tendrá que usar algo como Opción / Quizás, similar a las Opciones de F # . Sin embargo, todavía llevaría un None, ya sea por el valor o por error.

Resultado (y cualquiera) en F #

El punto de partida debe ser el tipo de resultado de F # y las uniones discriminadas. Después de todo, esto ya funciona en .NET.

Un tipo de resultado en F # es:

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

Los tipos en sí solo llevan lo que necesitan.

Los DU en F # permiten una concordancia exhaustiva de patrones sin requerir nulos:

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

Emulando esto en C # 8

Desafortunadamente, C # 8 aún no tiene DU, están programados para C # 9. En C # 8 podemos emular esto, pero perdemos una coincidencia exhaustiva:

#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;
}

Y úsalo:

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()
    };
}

Sin una exhaustiva coincidencia de patrones, tenemos que agregar esa cláusula predeterminada para evitar advertencias del compilador.

Todavía estoy buscando una manera de obtener una concordancia exhaustiva sin introducir valores muertos, incluso si son solo una Opción.

Opción / Quizás

Crear una clase de Opción por la forma en que usa una concordancia exhaustiva es más simple:

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;
}

Que se puede usar con:

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