C# 8 nullables и контейнер результатов - PullRequest
4 голосов
/ 12 января 2020

У меня есть IResult<T> контейнер, который я использую для обработки ошибок. Это выглядит так:

public interface IResult<out T>
{
    ResultOutcome Outcome { get; }   //enum: {Failure, Uncertain, Success}
    string Description { get; }      //string describing the error, in case of !Success
    bool IsSuccess();                //Outcome == Success
    T Data { get; }                  //If success, it contains the data passed on, otherwise NULL
}

И вы бы использовали это так:

IResult<int> GetSomething()
{
    try{
        int result = //things that might throw...
        return Result<int>.Success(result);  
    } 
    catch(Exception e) 
    {
        return Result<int>.Failure($"Something went wrong: {e.Message}");
    }
}

А потом:

var result = GetSomething();
if (!result.IsSuccess()) return result; //<- error passed on.

int resultData = result.Data; //<- no errors, so there is something in here.


До сих пор, все хорошо. Однако когда я представляю обнуляемые типы, у меня возникает проблема:

public interface IResult<out T> where T : class // unfortunately this is necessary
{
    ...
    T? Data { get; }                  //If success, it contains the data passed on, otherwise NULL
}
var result = GetSomething();
if (!result.IsSuccess()) return result; //<- error passed on.

int resultData = result.Data; //<- WARNING!!! POSSIBLE DEREFERENCE OF NULL


Теперь вопрос : я уверен, что result.Data содержит что-то, как он прошел IsSuccess() шаг. Как я могу заверить компилятор об этом? Есть ли способ или концепция обнуляемого C # 8 просто не совместима с этим?
Есть ли другие способы обработки результатов подобным образом? (передача контейнеров вместо исключений).

Ps 1
Пожалуйста, не предлагайте использовать result.Data!;.

Ps 2
Этот код уже используется на тысячах строк или более, поэтому, если изменение может быть связано с интерфейсом, а не с использованием, это будет лучше.

Ответы [ 2 ]

6 голосов
/ 13 января 2020

Обновление

Если вы сделали изменили использование и преобразовали IsSuccess в свойство, вы могли бы избавиться от проблем обнуляемости и получить исчерпывающее соответствие. Это выражение переключения является исчерпывающим ie, компилятор может проверить, что все возможности были выполнены. Требуется, чтобы каждая ветвь только получала действительное свойство:

var message=result switch { {IsSuccess:true,Data:var data} => $"Got some: {data}",
                            {IsSuccess:false,Description:var error} => $"Oops {error}",
             };  

Если ваши методы принимают и возвращают IResult<T> объекты, вы можете написать что-то вроде:

IResult<string> Doubler(IResult<string> input)
{
    return input switch { {IsSuccess:true,Data:var data} => new Ok<string>(data+ "2"),
                          {IsSuccess:false} => input
    };  
}

...

var result2=new Ok<string>("3");
var message2=Doubler(result2) switch { 
                     {IsSuccess:true,Data:var data} => $"Got some: {data}",
                     {IsSuccess:false,Description:var error} => $"Oops {error}",
             };  

Оригинальный ответ

Похоже, проблема real заключается в реализации шаблона Result . Этот шаблон имеет две характеристики:

  • Он предотвращает использование недопустимых значений результата на уровне типа. Он делает это, используя два разных типа для представления хороших и плохих результатов. При этом каждый тип несет только то, что ему нужно.
  • Это заставляет клиентов обрабатывать все случаи или явно игнорировать их.

Некоторые языки, такие как Rust, имеют встроенный тип для этого. Функциональные языки, которые поддерживают типы опций / различимые объединения, такие как F #, могут легко реализовать это с помощью:

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

Исчерпывающее сопоставление с образцом означает, что клиенты должны обрабатывать оба случая. Этот тип настолько распространен, что превратил его в сам язык.

C# 8

В C# 8 мы можем реализовать два типа без исчерпывающего сопоставления с образцом. На данный момент типам необходим общий класс, либо интерфейс, либо абстрактный класс, который на самом деле не должен иметь никаких членов. Есть много способов их реализации, например:

public interface IResult<TSuccess,TError>{}

public class Ok<TSuccess,TError>:IResult<TSuccess,TError>
{
    public TSuccess Data{get;}

    public Ok(TSuccess data)=>Data=data;

    public void Deconstruct(out TSuccess data)=>data=Data;
}

public class Fail<TSuccess,TError>:IResult<TSuccess,TError>
{
    public TError Error{get;}

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

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

Мы могли бы использовать структуры вместо классов.

Или, если использовать синтаксис, близкий к разграниченным C# 9 объединениям, классы могут быть вложенными. Тип по-прежнему может быть интерфейсом, но мне действительно не нравится писать new IResult<string,string>.Fail или называть интерфейс Result вместо IResult:

public abstract class Result<TSuccess,TError>
{
    public class Ok:Result<TSuccess,TError>
    {
        public TSuccess Data{get;}
        public Ok(TSuccess data)=>Data=data;
        public void Deconstruct(out TSuccess data)=>data=Data;
    }

    public class Fail:Result<TSuccess,TError>
    {
       public TError Error{get;}
        public Fail(TError error)=>Error=error;
        public void Deconstruct(out TError error)=>error=Error;
    }

    //Convenience methods
    public static Result<TSuccess,TError> Good(TSuccess data)=>new  Ok(data);
    public static Result<TSuccess,TError> Bad(TError error)=>new  Fail(error);
}

Мы можем использовать сопоставление с шаблоном для обработки Result ценности. К сожалению, C# 8 не предлагает исчерпывающего соответствия, поэтому нам нужно добавить регистр по умолчанию.

var result=Result<string,string>.Bad("moo");
var message=result switch { Result<string,string>.Ok (var Data) => $"Got some: {Data}",
                            Result<string,string>.Fail (var Error) => $"Oops {Error}"
                            _ => throw new InvalidOperationException("Unexpected result case")
                      };

C# 9

C# 9 (вероятно) собирается добавить различимые объединения через перечислимые классы . Мы сможем написать:

enum class Result
{
    Ok(MySuccess Data),
    Fail(MyError Error)
}

и использовать его при сопоставлении с образцом. Этот синтаксис уже работает в C# 8, пока есть соответствующий деконструктор. C# 9 добавит исчерпывающее сопоставление и, возможно, также упростит синтаксис:

var message=result switch { Result.Ok (var Data) => $"Got some: {Data}",
                            Result.Fail (var Error) => $"Oops {Error}"
                          };

Обновление существующего типа с помощью DIM

Некоторые из существующих функций, такие как IsSuccess и Outcome - это просто удобные методы. Фактически, типы опций F # также представляют «вид» значения как тег . Мы можем добавить такие методы в интерфейс и вернуть фиксированное значение из реализаций:

public interface IResult<TSuccess,TError>
{
    public bool IsSuccess {get;}
    public bool IsFailure {get;}
    public bool ResultOutcome {get;}
}

public class Ok<TSuccess,string>:IResult<TSuccess,TError>
{
    public bool IsSuccess     =>true;
    public bool IsFailure     =>false;
    public bool ResultOutcome =>ResultOutcome.Success;
    ...
}

Свойства Description и Data также могут быть реализованы как мера остановки пробела - они нарушают Результат сопоставление с образцом и шаблоном делает их в любом случае устаревшими:

public class Ok<TSuccess,TError>:IResult<TSuccess,TError>
{
    ...
    public TError Description=>throw new InvalidOperationException("A Success Result has no Description");
    ...
}

Члены интерфейса по умолчанию могут использоваться для предотвращения засорения конкретных типов:

public interface IResult<TSuccess,TError>
{
    //Migration methods
    public TSuccess Data=>
        (this is Ok<TSuccess,TError> (var Data))
        ?Data
        :throw new InvalidOperationException("An Error has no data");

    public TError Description=> 
        (this is Fail<TSuccess,TError> (var Error))
        ?Error
        :throw new InvalidOperationException("A Success Result has no Description");

    //Convenience methods
    public static IResult<TSuccess,TError> Good(TSuccess data)=>new  Ok<TSuccess,TError>(data);
    public static IResult<TSuccess,TError> Bad(TError error)=>new  Fail<TSuccess,TError>(error);

}

Модификации для добавления исчерпывающего сопоставления

Мы могли бы избежать случаев по умолчанию в исключениях сопоставления с образцом, если бы использовали только один флаг и свойства миграции:

public interface IResult<TSuccess,TError>
{
    public bool IsSuccess{get;}
    public bool IsFailure=>!IsSuccess;
    //Migration methods
    ...
}

var message2=result switch { {IsSuccess:true,Data:var data} => $"Got some: {data}",
                             {IsSuccess:false,Description:var error} => $"Oops {error}",
             };  

На этот раз компилятор обнаруживает Есть только два случая, и оба охвачены. Свойства миграции позволяют компилятору получать правильный тип. Код потребления должен изменить и , используя правильный шаблон, но я подозреваю, что это уже сработало, что

1 голос
/ 20 февраля 2020

Вот часть C# 8.0 из вышеперечисленного (отлично!) @PanagiotisKanavos ответ в одной части:

using System;

#nullable enable

namespace ErrorHandling {
  public interface IResult<TSuccess, TError> {
    public bool OK { get; }

    public TSuccess Data => (this is Ok<TSuccess, TError>(var Data)) ? Data : throw new InvalidOperationException("An Error has no data");
    public TError Error => (this is Fail<TSuccess, TError>(var Error)) ? Error : throw new InvalidOperationException("A Success Result has no Description");

    public static IResult<TSuccess, TError> Good(TSuccess data) => new Ok<TSuccess, TError>(data);
    public static IResult<TSuccess, TError> Bad(TError error) => new Fail<TSuccess, TError>(error);
  }

  public class Ok<TSuccess, TError> : IResult<TSuccess, TError> {
    public bool OK => true;
    public TSuccess Data { get; }

    public Ok(TSuccess data) => Data = data;
    public void Deconstruct(out TSuccess data) => data = Data;
  }

  public class Fail<TSuccess, TError> : IResult<TSuccess, TError> {
    public bool OK => false;
    public TError Error { get; }

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

  class Main {
    public IResult<int, string> F() {
      if (DateTime.Now.Year < 2020) return IResult<int, string>.Good(3);
      return IResult<int, string>.Bad("error");
    }

    public void F1() {
      var message = F() switch { 
        { OK: true, Data: var data } => $"Got some: {data}",
        { OK: false, Error: var error } => $"Oops {error}",
      };
      Console.WriteLine(message);
    }


    public void F2() {
      if (F() is { OK: false, Error: var error }) {
        Console.WriteLine(error);
        return;
      }

      if (F() is { OK: true, Data: var data }) { // Is there a way to get data without a new scope ?
        Console.WriteLine(data);
      }
    }
  }
}
Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...