Опциональные значения и монадическая обработка ошибок


При реализации нового биллинга широко использовались типы опциональные типы и монадическая обработка ошибок - концепции отсутствующие в C#, но полезные для того, чтобы сделать сложные системы надёжными. Здесь я постараюсь ясно и подробно изложить суть этих концепций и дам примеры использования.
Опциональные значения
Не секрет, что для типов-значений платформы .NET реализована возможность выразить отсутствие значения. Это возможно благодаря типу Nullable<T>:
Nullable<int> i = null; // значение i не определено
В C# есть возможность краткой записи таких типов:
int? i = null; // данная запись эквивалентна первому примеру
Переменным типов не "обёрнутых" в nullable нельзя присвоить null, что позволяет определять поведение полей и свойств классов согласно поведению моделируемых объектов. Например, у учётной записи абонента может быть поле "отказ от смс" типа bool?, в таком случае true будет означать, что абонент явно отказался от рассылки, значение false - что абонент явно согласен с рассылкой, а значение null - что абонент не выражал явно своё мнение. Другой случай - поле "период уведомления" типа int. Это поле логически не может "не иметь" значения, в случае если период явно не указан, оно должно принять значение по-умолчанию.
Так-же для "обёрнутых" типов определён удобный оператор "поглощения null":
int? i = null;
int r = i ?? 10; // r будет присвоено значение 10
В то же время null является корректным значением, которое можно передать вместо экземпляра любого ссылочного типа в CLR. Из-за для исключения падения функции приходится делать явные проверки на null:
Tariff GetTariffFromAccount(Account acc)
{
if (acc == null) return acc;
return acc.Tariff;
}
Такое решение (давно ставшее стандартным) имеет следующие проблемы:
1. Невозможно явно указать, что для данного типа значение null семантически бессмысленно.
2. Из-за п.1 приходится делать проверки на null в каждой функции, даже если функции в конкретном случае вызываются цепочно.
Для решения этих двух проблем был разработан тип Option<T>, который, по-сути, является аналогом Nullable<T> для ссылочных типов (отмечу, что Option<T> можно с успехом использовать и для типов-значений, я буду использовать это в примерах).
Примеры использования Option<>:
Option<string> s1 = new Some<string>("test");
Option <string> s2 = Option.Some("test"); // Helper-метод, альтернатива конструкторуOption<string> s3 = new None<string>(); // Аналог присвоения null типу Nullable<T>
Option<string> s4 = Option.None<string>(); // Альтернатива конструктору None<>
Option<string> s5 = "test"; // Для типа Option<> определено неявное преобразование, это полностью аналогично первой и второй строке
В типе Option<T> определены поля HasValue и Value, их назначение полностью аналогично одноимённым полям в Nullable<>. Но если бы реализация Option<> ограничивалась этим, мы не смогли бы решить ни первой, ни второй проблемы. Для устранения проблемы 2 в типе Option<> определены 2 метода: Map и FlatMap. Рассмотрим каждый из них.
// Использование Map
Option<int> i1 = 30;
Option<int> i2 = i1.Map(x => x / 3); // i2 будет присвоено значение i1/3, т.е. 10
Option<int> i3 = Option.None<int>();
Option<int>i4 = i3.Map(x => x / 3); // нам "нечего делить", i4 будет присвоено None<int>
Таким образом, метод Map принимает функцию, принимающую один параметр того типа, который "обёрнут" в Option и возвращает результат применения этой функции к параметру, в случае если Option не пуст и None в случае если значение равно None.
К сожалению, запретить использовать ключевое слово null в C# нельзя. Но если принять за правило использовать Option<> всегда, когда переменная или параметр допускает отсутствие значения по смыслу, можно избежать избыточных проверок параметров в начале функции, применяя функции цепочно используя Map:
Option<int> i1 = 30;
Option<int>i2 = i1.Map(x => x/3).Map(x => x * 2); //20
Option<int> i3 = Option.None<int>();
Option<int>i4 = i3.Map(x => x/3).Map(x => x * 2); // Ничего не упало, в i4 - None
Бывает, что функция, передаваемая в Map, сама возвращает опциональное значение. К примеру, вот так можно определить деление Option<int>
Option<int> Dev(int x, int y)
{
if (y == 0) return Option.None<int>(); // Деление на 0 не определено
return x / y;
}
Если мы применим такую функцию к значению типа Option<int>, то получим значение типа Option<Option<int>>. Чтобы избежать этого, можно использовать FlatMap:
Option<int> i1 = 30;
Option<int> i2 = i1.FlatMap(x => Div(x, 0)); // i2 будет присвоено None
Опциональный тип может быть удобным , но как быть с тем, что стандартная библиотека .NET о нём ничего не знает и иногда классы возвращают null? Для разрешения этого неудобства определён метод расширения .ToOption, работающий следующим образом:
Option<Entity> fe = entityList.FirstOrDefault(e => e.Id == 100).ToOption(); // В случае, если вернётся null, fe будет присвоено None<Entity>
Так-же если существует вероятность получения null извне в качестве параметра, можно просто изменить тип параметра на Option<T>, преобразование будет сделано автоматически.
К сожалению, есть случай, когда автоматическое неявное преобразование типов в C# не отрабатывает и это никак не исправить:
Option<string> s1 = null; // Здесь произойдёт ошибка, null не будет сконвертирован в None<string>
string s = null;
Option<string> s2 = s; // Здесь всё нормально отработает
Option<string> s3 = (string)null; // Это тоже работает.
Такая же проблема возникает , функции, принимающей Option<T> передать null в качестве параметра.
Монадическая обработка ошибок
Традиционно для обозначения исключительных ситуации в C# используются исключения. В функции Div, которую мы рассматривали в предыдущем разделе, можно было бы выбросить InvalidArgumentException, поймать его уровнем или несколькими уровнями выше о обработать каким-либо образом. В таком случае мы бы смогли не только обозначить, что при выполнении функции произошла ошибка, но и сообщить, какая именно ошибка произошла. Это особенно значимо в случае, если мы строим цепочку функций с помощью Map/FlatMap и получаем None. Какая из функций вернула None - остаётся только гадать (по этой причине не стоит использовать Option для обработки ошибок).
Но у исключений есть один фундаментальный недостаток - если исключение не перехватить, то компилятор об этом не предупредит и программа может работать до поры до времени, после чего - упасть в продакшене. Неприятная ситуация, правда? Хотелось бы, чтобы компилятор не давал замалчивать ошибки.
Для воплощения этой идеи в жизнь был реализован тип Result<TSucc, TErr>. Тип TSucc - это тип, который получается при благоприятном исходе событий, TErr - ошибочный тип. Рассмотреная нами ранее функция Div примет следующий вид:
Result<int, string> Dev(int x, int y)
{
if (y ==0) return "Деление на ноль!";
return x / y;
}
Стоит отметить, что в коде выше работают неявные преобразования, без них код бы выглядел следующим образом:
Result<int, string> Dev(int x, int y)
{
if (y ==0) return new Failure<int, string>("Деление на ноль!");
return new Success<int>(x / y);
}
В типе Result определены два поля: IsSuccess и IsFailure, они позволяют определить, какой значение хранится в конкретном экземпляре. Так-же в классе Result определены методы Map и FlatMap, работающие так-же, как и в Option.
Result<int, string> r1 = 10;
Result<int, string>r2 = "Ошибка";
var r3 = r1.Map(x => x * 3); //30
var r4 = r2.Map(x => x * 3); //Ошибкаvar r5 = r1.Map(x => Div(x, 0)); //Деление на ноль!
На практике опять таки, можно столкнуться с ситуацией, когда ошибка пробрасывается в цепочке Map/FlatMap, но не ясно, какая-же функция вернула конкретную ошибку. Для таких ситуаций определены функции MapError и FlatMapError, работающие так же, как Map и FlatMap, но относительно ошибки:
Result<int, string> entityId = 10;
Result<Entity, string> er = entityId.FlatMap(id => provider.GetEntity(entityId)); //provider вернул "Ошибка IO"
Result<Entity, string> er2 = entityId.FlatMap(id => provider.GetEntity(entityId).MapError(err => $"Ошибка при получении {id}: {err}")); // Ошибка при получении 10: Ошибка IO
Кроме неявных преобразований можно так-же пользоваться методом расширения ToResult():
var r1 = 3.ToResult<int, string>();
var r2 = "Ошибка".ToResult<int, string>();
r1 и r2 в примере выше будут иметь одинаковый тип - Result<int, string>.
Взаимные преобразования Result и Option
Методы ToOption (в Result) и ToResult (в Option) отличаются семантикой от методов-расширений, определённых для других классов. Рассмотрим эти методы.
Метод ToResult в классе Option определён с двумя перегрузками. Первая не принимает параметров и возвращает результат типа Result<Some<T>, None<T>>. Вторая перегрузка принимает в качестве параметров две функции: первая типа Func<T, TSucc> (применяется в случае Some<T>) и вторая типа Func<TErr> (генерирует значение в случае None), тип возвращаемого результата - Result<TSucc, TErr>.
Метод ToOption в классе Result<TSucc, TErr> возвращает Some<TSucc> в Success случае и None<T> в Failure случае.
Извлечение значений из ResultResult<> облегчает контроль над обработкой ошибок, но рано или поздно нам понадобится выяснить, что-же получилось в результате выполнения функций. Тип Result не предоставляет свойст Value или Error, наиболее идиоматический путь получить значение из Result - сопоставление с образцом.
Сопоставление с образцом - пока что чуждая для C# концепция (хотя, возможно, этот механизм будет включён в C# 7), но встроенные в язык деревья выражений позволили реализовать этот механизм.
Рассмотрим пример.
var matcher = new Matcher<Result<int, string>>
{
{r => r as Success<int>, s => Console.WriteLine(s.Value)},
{r => r as Failure<string>, f => Console.WriteLine(f.Error)}
}.ToAction();
matcher будет иметь тип Action<Result<int, string>>, передав ему экземпляр Result<int, string>, на экран выведется содержимое. За деталями реализации этого механима отсылаю к этой статье: http://habrahabr.ru/post/227935/.

Приложенные файлы

  • docx 14697216
    Размер файла: 25 kB Загрузок: 0

Добавить комментарий