Friday, October 31, 2014

Использование библиотеки Monads.NET для работы с монадами в C#

Сегодня мы рассмотрим популярные библиотеки для использования монад в языке 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'
А теперь давайте немного перепишем наш код с используем 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
Как видим, использование явно параметризированного 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);
    });

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

No comments:

Post a Comment