Сегодня
мы рассмотрим популярные библиотеки для использования монад в
языке C#. В предыдущей статье
были рассмотрены примеры использования монад, что это такое и как их применять
в своих программах, а также какие проблемы позволяют решать монады. Давайте
повторим суть понятия монады.
Монадой
называется тройка (M, Return, Bind) где:
- М – аргумент generic типа M<T>;
- Return – функция, которая возвращает результат M<T>;
- Bind – функция для связывания монадных вычислений.
Кроме
того, монаду должны связывать три монадных закона: левое тождество, правое
тождество и ассоциативность.
1. Left Identity
Bind(Unit(e),
k) = k(e)
2. Right Identity
Bind(m, Unit) =
m
3. Associative
Bind(m, x =>
Bind(k(x), y => h(y)) = Bind(Bind(m, x => k(x)), y => h(y))
Давайте
рассмотрим самый простой вариант монады на примере монады Identity (тождество).
Монада
Identity является самой
простой, суть которой является понятие "тождественный". По сути, это класс, который представляет из себя обертку над значением.
public class Identity<T>
{
public T Value
{ get; private set; }
public
Identity(T value)
{
Value = value;
}
}
Эту
монаду удобно расширять с помощью extension методов.
public static class IdentityExtensions
{
public static Identity<B>
Bind<A, B>(this Identity<A> a, Func<A, Identity<B>> func)
{
return
func(a.Value);
}
static Identity<T>
ToIdentity<T>(this T value)
{
return new Identity<T>(value);
}
}
Где метод ToIdentity принимает значение и возвращает новый экземпляр, который содержит данное
значение. Метод Bind принимает экземпляр класса Identity, извлекает
значения и вызывает делегат func с извлеченным значением.
Пример использования:
class Program
{
static void Main(string[] args)
{
var r =
5.ToIdentity().Bind(x =>
6.ToIdentity().Bind(y =>
(x + y).ToIdentity()));
Console.WriteLine(r.Value);
Console.ReadLine();
}
}
Часто
монады путают с extension методами. Как я уже
описывал раньше и повторюсь сейчас, extension метод – это всего лишь удобная форма записи через точку.
Не каждый метод расширения является монадой (смотреть выше три монадных
закона). Поскольку основная цель статьи – не дать понятие о монадах, а рассказать о том, где вы можете посмотреть и использовать готовые
монады; благо, с выходом Manage NuGet Package это стало не
проблемой. Мы просто запускаем с Visual Studio утилиту Manage NuGet Packages и вводим для поиска ключевое слово "Monads".
На
рисунке выше я выделил две библиотеки для работы с монадами в языке C#, которые на
момент написания статьи были самыми популярными. Первой по списку идет
библиотека "Monads for .NET" автора Сергея Звездина, известного MVP а также спикера многих популярных конференций в России и
Украине. Большой минус этой библиотеки заключается в том, что в ней монады вперемешку с
обычными методами расширения. Это является не очень хорошим тоном. Как библиотека
для упрощения работы, она неплохая, но так как мы рассматриваем именно
работу с монадами, то получается какая-то каша. Второй по счету является библиотека "Monads.NET" автора Эрика Хьюстона. Она простая по своему содержимому, поэтому мы рассмотрим данную библиотеку в качестве примера данной статьи.
Первой мы рассмотрим монаду, которая получила свое название как монада Maybe (в данной библиотеке она имеет несколько другое название,
а именно: With).
Рассмотрим, какую проблему решает данная монада, на примере с использованием LINQ to XML.
string str =
@"<?xml
version=""1.0""?>
<!-- comment
at the root level -->
<Root>
<Child>
<Child1>Content</Child1>
</Child>
</Root>";
XDocument doc = XDocument.Parse(str);
string result
= string.Empty;
if (doc.Root != null)
{
var child =
doc.Root.Element("Child");
if (child != null)
{
var child1 =
child.Element("Child1");
if (child1 != null)
{
result =
child.Value;
}
}
}
Console.WriteLine(result);
Для простоты я написал пример словно мы разбираем дерево, которое нам
приходит. Но на практике зачастую бывает, что дерево приходит к нам в виде XML как Stream, и нам нужно найти искомый элемент. Количество проверок на то, что искомый
объект не равен нулю, очень много. Писать такую вложенность очень непросто.
Теперь давайте посмотрим, как здесь поможет монада With.
string str =
@"<?xml
version=""1.0""?>
<!-- comment
at the root level -->
<Root>
<Child>
<Child1>Content</Child1>
</Child>
</Root>";
var doc = XDocument.Parse(str);
var result = doc.Root
.With(x =>
x.Element("Child"))
.With(x =>
x.Element("Child1"))
.With(x =>
x.Value);
Console.WriteLine(result);
Результат будет такой же, как и в предыдущем примере:
А теперь воспользуемся монадой Do, которая позволяет упростить наш код.
doc.Root
.With(x =>
x.Element("Child"))
.With(x =>
x.Element("Child1"))
.Do(x => Console.WriteLine(x.Value));
Данные монады реализуются очень просто. Думаю, вам будет интересно посмотреть,
как можно реализовать данные монады самостоятельно.
public static class MonadExtensions
{
public static TResult
With<TInput, TResult>(this TInput o, Func<TInput, TResult> evaluator)
where TInput
: class
where TResult
: class
{
return (o == null) ? default(TResult)
: evaluator(o);
}
public static TInput
Do<TInput>(this TInput o, Action<TInput> action) where TInput
: class
{
TInput
result;
if (o == null)
{
result =
default(TInput);
}
else
{
action(o);
result =
o;
}
return result;
}
}
Интересным подходом является использование монад как
замены операции if. Пример:
static void Main(string[] args)
{
string str =
@"<?xml
version=""1.0""?>
<!--
comment at the root level -->
<Root>
<Child>
<Child1>Content</Child1>
</Child>
</Root>";
var doc = XDocument.Parse(str);
doc.Root
.If(x =>
x.Element("Child") != null)
.Return(x
=> x.Element("Child"), new XElement("Nothing"))
.IfDo(x
=> x.Element("Child1") != null, x => Console.WriteLine(x.Value));
Console.ReadLine();
}
В данном примере мы продемонстрировали использование сразу трех методов расширения: как If(), Return() и IfDo().
Почему-то некоторые разработчики, которые пишут методы расширения для
проверки на null, считают данные методы расширения монадами. Пример такого подхода приведен в библиотеке Сергея Звездина.
Пример:
public static bool
NotNull<TInput>(this TInput value) where TInput
: class
{
return value
!= null;
}
Использование:
string a = null;
Console.WriteLine(a.NotNull());
Результатом
выполнения будет false.
Сергей Звездин реализовал свои методы следующим образом.
public static bool
IsNull<TSource>(this TSource? source) where TSource
: struct
{
return
!source.HasValue;
}
public static bool
IsNotNull<TSource>(this TSource? source) where TSource
: struct
{
return
source.HasValue;
}
Мне не совсем понятно, зачем делать проверку на IsNotNull() и IsNull() для value type. Учитывая тот факт, что value type не могут быть null, а для типов данных, которые nullable, это какой-то криворукий подход, так как
оптимизирует тот участок кода, который и без отдельного метода читается
нормально.
Давайте рассмотрим монаду Recover. Ее смысл
заключается в том, чтобы создать новый объект, если он не создан по каким-либо
причинам. Вот как мы писали раньше код для создания нового объекта класса Person, если до этого он не был создан:
var person = new Person();
// do something
if (person == null)
{
person = new Person();
}
Console.WriteLine(person.LastName);
Сейчас это будет немного компактнее.
var person = new Person();
// do something
Console.WriteLine(person.Recover(() => new Person()).LastName);
Есть также методы для обработки ошибок. Аналог разворачивания
кода:
try
{
}
catch (Exception)
{
}
В виде монад мы имеем два метода: TryDo() и TryLet(). Отличие между ними небольшое. Один
принимает обычный Action<T>,
а второй – функтор Func<T>.
Пример использования одного из данных методов:
var person = new Person();
person.TryDo(p => Console.WriteLine(p.Work.Address))
.If(x =>
x.Item2 != null)
.Do(x => Console.WriteLine("Error"));
На экране будет выведен текст Error.
Не хватает более сложной обработки ошибок в данной библиотеке, когда нужно
делать проверку на тип ошибки. Но так, как это сделано у Сергея Звездина (библиотеку которого мы смотрели выше), – тоже плохой вариант.
public static Tuple<TSource,
Exception> TryDo<TSource>(this TSource
source, Action<TSource> action, params Type[]
expectedException) where TSource : class
{
if (source != default(TSource))
{
try
{
action(source);
}
catch (Exception ex)
{
if
(expectedException.Any(exp => exp.IsInstanceOfType(ex)))
{
return new Tuple<TSource, Exception>(source, ex);
}
throw;
}
}
return new Tuple<TSource,
Exception>(source, null);
}
Ловить все ошибки и проверять их потом в catch – не оптимальный вариант. Когда мы ловим в catch нужный нам тип ошибки, это работает быстрее. Пример:
var person = new Person();
person
.TryDo(p => Console.WriteLine(p.Work.Address),
typeof(NullReferenceException))
.If(x =>
x.Item2 != null)
.Do(x => Console.WriteLine("Error"));
Код выше разворачивается в следующий IL код:
.method private hidebysig static
bool '<Main>b__1' (
class [mscorlib]System.Tuple`2<class ConsoleApplicationTest.Person, class [mscorlib]System.Exception> x
) cil managed
{
.custom instance void [mscorlib]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = (
01 00 00 00
)
// Method begins at RVA 0x208c
// Code size 17 (0x11)
.maxstack 2
.locals init (
[0] bool CS$1$0000
)
IL_0000: ldarg.0
IL_0001: callvirt instance !1 class [mscorlib]System.Tuple`2<class ConsoleApplicationTest.Person, class [mscorlib]System.Exception>::get_Item2()
IL_0006: ldnull
IL_0007: ceq
IL_0009: ldc.i4.0
IL_000a: ceq
IL_000c: stloc.0
IL_000d: br.s IL_000f
IL_000f: ldloc.0
IL_0010: ret
} // end of method Program::'<Main>b__1'
bool '<Main>b__1' (
class [mscorlib]System.Tuple`2<class ConsoleApplicationTest.Person, class [mscorlib]System.Exception> x
) cil managed
{
.custom instance void [mscorlib]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = (
01 00 00 00
)
// Method begins at RVA 0x208c
// Code size 17 (0x11)
.maxstack 2
.locals init (
[0] bool CS$1$0000
)
IL_0000: ldarg.0
IL_0001: callvirt instance !1 class [mscorlib]System.Tuple`2<class ConsoleApplicationTest.Person, class [mscorlib]System.Exception>::get_Item2()
IL_0006: ldnull
IL_0007: ceq
IL_0009: ldc.i4.0
IL_000a: ceq
IL_000c: stloc.0
IL_000d: br.s IL_000f
IL_000f: ldloc.0
IL_0010: ret
} // end of method Program::'<Main>b__1'
А
теперь давайте немного перепишем наш код с используем try/catch.
.try
{
IL_0007: nop
IL_0008: ldloc.0
IL_0009: callvirt instance class ConsoleApplicationTest.Address1 ConsoleApplicationTest.Person::get_Work()
IL_000e: callvirt instance string ConsoleApplicationTest.Address1::get_Address()
IL_0013: call void [mscorlib]System.Console::WriteLine(string)
IL_0018: nop
IL_0019: nop
IL_001a: leave.s IL_002c
} // end .try
catch [mscorlib]System.NullReferenceException
{
IL_001c: pop
IL_001d: nop
IL_001e: ldstr "Error"
IL_0023: call void [mscorlib]System.Console::WriteLine(string)
IL_0028: nop
IL_0029: nop
IL_002a: leave.s IL_002c
} // end handler
{
IL_0007: nop
IL_0008: ldloc.0
IL_0009: callvirt instance class ConsoleApplicationTest.Address1 ConsoleApplicationTest.Person::get_Work()
IL_000e: callvirt instance string ConsoleApplicationTest.Address1::get_Address()
IL_0013: call void [mscorlib]System.Console::WriteLine(string)
IL_0018: nop
IL_0019: nop
IL_001a: leave.s IL_002c
} // end .try
catch [mscorlib]System.NullReferenceException
{
IL_001c: pop
IL_001d: nop
IL_001e: ldstr "Error"
IL_0023: call void [mscorlib]System.Console::WriteLine(string)
IL_0028: nop
IL_0029: nop
IL_002a: leave.s IL_002c
} // end handler
Как видим,
использование явно параметризированного try/catch строит по-разному IL-код. В некоторых случаях с помощью монад мы
выигрываем в композиции нашей программы, но проигрываем в оптимальном ее
выполнении. Если мы хотим как-то обрабатывать или игнорировать наши ошибки,
которые мы получили с помощью функций TryDo() и TryLet(), мы можем вызвать методы Handle() и Ignore(). Как их использовать, показано ниже.
var person = new Person();
person
.TryDo(p => Console.WriteLine(p.Work.Address),
typeof (NullReferenceException))
.Handle(x => Console.WriteLine(x.Message));
person
.TryDo(p => Console.WriteLine(p.Work.Address),
typeof(NullReferenceException))
.Ignore();
Результат:
Давайте
рассмотрим интересную монаду Let(), которая при условии, что значение не равняется нулю, выполняет нужную нам функцию, в противном случае
возвращает значение ошибки в виде второго параметра.
var person = new Person();
person.Work.Let(x => x.Address, "Failed")
.Do(Console.WriteLine);
Если person.Work будет null, то мы получим на экране сообщение "Failed".
Заключение
В данной статье я постарался дать краткий обзор библиотеки "Monads.NET" для работы с монадами, в которой мы рассмотрели,
как монады позволяют делать композицию нашего кода. Это одна из проблем, которую
позволяют решать монады. Ясное дело, что когда ваш код не так прост на
первый взгляд, то писать Action<> или
функтор Func<> на много строк явно не стоит. Согласитесь,
что такой код не читается нормально.
person
.TryDo(p => Console.WriteLine(p.Work.Address),
typeof (NullReferenceException))
.Handle(x =>
{
using (var scope = new TransactionScope())
{
using (var connection1 = new SqlConnection(connectString1))
{
// Opening the connection automatically enlists it
in the
// TransactionScope as a lightweight transaction.
connection1.Open();
// Create the SqlCommand object and execute the
first command.
var command1 = new SqlCommand(commandText1,
connection1);
command1.ExecuteNonQuery();
}
scope.Complete();
}
Console.WriteLine(x.Message);
});
Поэтому при использовании монад нужно уметь также гармонировать код. Иначе
мы получим полнейшую кашу, которую будет очень сложно читать и еще сложнее сопровождать. Поэтому если вы видите, что код не получается
элегантным, стоит либо не использовать монады для данного вашего случая,
либо же это может быть тревожным звонком о неприятных "запахах" кода. Это отличный
повод задуматься о том, чтобы посмотреть на ваш код несколько с другой стороны. Со
стороны архитектора и дизайнера своего кода, а не с точки зрения кодера.