C # nie może nadać typowi „notnull” wartości null

9

Próbuję stworzyć typ podobny do Rust'a Resultlub Haskella Eitheri mam tak daleko:

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

Biorąc pod uwagę, że parametry obu typów są ograniczone notnull, dlaczego narzeka (wszędzie tam, gdzie jest parametr typu ze ?znakiem null ), że:

Parametr typu dopuszczającego wartość zerową musi być znany jako typ wartości lub typ odwołania nie dopuszczający wartości zerowej. Rozważ dodanie ograniczenia „klasy”, „struktury” lub typu.

?


Używam C # 8 na .NET Core 3 z włączonymi zerowymi typami referencji.

Diamentowe buty
źródło
Zamiast tego powinieneś zacząć od typu wyniku F # i dyskryminowanych związków. Możesz łatwo osiągnąć coś podobnego w C # 8, bez przenoszenia martwej wartości, ale nie będziesz miał wyczerpującego dopasowania. Próba umieszczenia obu typów w tej samej strukturze spowoduje napotkanie jednego problemu po drugim i przywrócenie samych problemów. Wynik miał zostać naprawiony
Panagiotis Kanavos

Odpowiedzi:

12

Zasadniczo prosisz o coś, czego nie można przedstawić w IL. Typy dopuszczające wartości zerowe i typy referencyjne dopuszczające wartości zerowe są bardzo różnymi zwierzętami i chociaż wyglądają podobnie w kodzie źródłowym, IL jest zupełnie inna. Wersja dopuszczająca wartości zerowe typu wartości Tjest innym typem ( Nullable<T>), natomiast wersja dopuszczająca wartości zerowe typu referencyjnego Tjest tego samego typu, a atrybuty mówią kompilatorowi, czego się spodziewać.

Rozważ ten prostszy przykład:

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

To nieważne z tego samego powodu.

Jeśli ograniczymy się Tdo bycia strukturą, wówczas wygenerowana IL dla GetNullValuemetody miałaby typ zwracany Nullable<T>.

Jeśli ograniczymy się Tdo tego, że nie ma wartości zerowej, wówczas generowana dla tej GetNullValuemetody IL miałaby typ zwracany T, ale z atrybutem dla aspektu zerowalności.

Kompilator nie może wygenerować IL dla metody, która ma typ zwracany jednocześnie Ti Nullable<T>jednocześnie.

Zasadniczo wynika to z faktu, że typy zerowalne nie są w ogóle koncepcją CLR - to tylko magia kompilatora, która pomaga wyrazić zamiary w kodzie i zmusić kompilator do sprawdzenia podczas kompilacji.

Komunikat o błędzie nie jest jednak tak jasny, jak może być. Twiadomo, że jest „typem wartości lub niedopuszczalnym typem odniesienia”. Bardziej precyzyjnym (ale znacznie bardziej tekstowym) komunikatem o błędzie byłoby:

Parametr typu dopuszczającego wartości zerowe musi być znany jako typ wartości lub znany jako typ wartości zerowej. Rozważ dodanie ograniczenia „klasy”, „struktury” lub typu.

W tym momencie błąd miałby zasadne zastosowanie do naszego kodu - parametr typu nie jest „znany jako typ wartości” i nie jest znany jako „typ zerowy, który nie ma wartości zerowej”. Jest znany jako jeden z dwóch, ale kompilator musi wiedzieć, który .

Jon Skeet
źródło
Jest też magia środowiska uruchomieniowego - nie można zrobić zerowalnej wartości zerowej, nawet jeśli nie ma sposobu, aby przedstawić to ograniczenie w IL. Nullable<T>jest specjalnym typem, którego nie możesz zrobić sam. I jest jeszcze jeden bonus za to, jak boks jest wykonywany z zerowanymi typami.
Luaan
1
@Luaan: Istnieje magia środowiska wykonawczego dla typów zerowalnych wartości, ale nie dla typów zerowalnych.
Jon Skeet
6

Powodem ostrzeżenia jest wyjaśnione w rozdziale The issue with T?o wypróbować Nullable typów referencyjnych . Krótko mówiąc, jeśli używasz T?, musisz określić, czy typ jest klasą, czy strukturą. Możesz w końcu stworzyć dwa typy dla każdej sprawy.

Głębszy problem polega na tym, że użycie jednego typu do zaimplementowania wyniku i utrzymania zarówno wartości sukcesu, jak i błędu przywraca te same problemy, które wynik miał rozwiązać, i kilka innych.

  • Ten sam typ musi przenosić wartość martwą, typ lub błąd, lub przywracać wartości zerowe
  • Dopasowanie wzorca do typu nie jest możliwe. Aby to zadziałało, musisz użyć fantazyjnych wyrażeń dopasowujących wzorce pozycyjne.
  • Aby uniknąć null będziesz musiał użyć czegoś jak Option / Być może, podobnie jak F # 's Opcje . Nadal będziesz nosić przy sobie Brak, zarówno z powodu wartości, jak i błędu.

Wynik (i oba) w F #

Punktem wyjścia powinien być typ wyniku F # i dyskryminowane związki. W końcu działa to już w .NET.

Typ wyniku w F # to:

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

Same typy niosą tylko to, czego potrzebują.

DU w F # pozwalają na wyczerpujące dopasowanie wzorca bez konieczności zerowania:

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

Emulując to w C # 8

Niestety C # 8 nie ma jeszcze DU, są zaplanowane na C # 9. W C # 8 możemy to naśladować, ale tracimy wyczerpujące dopasowanie:

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

I użyj tego:

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

Bez wyczerpującego dopasowania wzorca musimy dodać tę domyślną klauzulę, aby uniknąć ostrzeżeń kompilatora.

Wciąż szukam sposobu na wyczerpujące dopasowanie bez wprowadzania martwych wartości, nawet jeśli są one tylko Opcją.

Opcja / Może

Tworzenie klasy Option przy użyciu wyczerpującego dopasowania jest prostsze:

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

Które mogą być używane z:

string cateGory = someValue switch { Option<Category> (_    ,false) =>"No Category",
                                     Option<Category> (var v,true)  => v.Name
                                   };
Panagiotis Kanavos
źródło