В моей предыдущей статье я описал, как реализовать паттерн "Монада Maybe" с помощью операторов async / await. В этот раз я расскажу, как реализовать другой популярный шаблон проектирования "Монада Reader", используя те же приемы.


Этот шаблон позволяет неявно передать некий контекст в иерархию вызовов функции без использования параметров или полей классов, и его можно рассматривать как еще один способ реализации внедрения зависимости (Dependency Injection). Например:


class Config { public string Template; }

public static async Task Main()
{
    Console.WriteLine(await GreetGuys().Apply(new Config {Template = "Hi, {0}!"}));
    //(Hi, John!, Hi, Jose!)

    Console.WriteLine(await GreetGuys().Apply(new Config {Template = "?Hola, {0}!" }));
    //(?Hola, John!, ?Hola, Jose!)
}

//В этих функциях нет явных ссылок на какой-либо экземпляр класса “Config".
public static async Reader<(string gJohn, string gJose)> GreetGuys() 
    => (await Greet("John"), await Greet("Jose"));

static async Reader<string> Greet(string name) 
    => string.Format(await ExtractTemplate(), name);

static async Reader<string> ExtractTemplate() 
    => await Reader<string>.Read<Config>(c => c.Template);

Классический "Reader"


Сначала давайте посмотрим, как можно реализовать этот паттерн без операторов async / await:


public class Config { public string Template; }

public static class ClassicReader
{
    public static void Main()
    {
        var greeter = GreetGuys();

        Console.WriteLine(greeter.Apply(new Config{Template = "Hello, {0}"}));
        //(Hello, John, Hello, Jose)

        Console.WriteLine(greeter.Apply(new Config{Template = "?Hola, {0}!" }));
        //(?Hola, John!, ?Hola, Jose!)    
    }

    public static Reader<(string gJohn, string gJose), Config> GreetGuys() =>
        from toJohn in Greet("John")
        from toJose in Greet("Jose")
        select (toJohn, toJose);
        //Без использования "query syntax" это код выглядел бы следующим образом:
        //Greet("John")
        //    .SelectMany(
        //          toJohn => Greet("Jose"), 
        //          (toJohn, toJose) => (toJohn, toJose))

    public static Reader<string, Config> Greet(string name) 
        => new Reader<string, Config>(cfg => string.Format(cfg.Template, name));
}

(Reader)


public class Reader<T, TCtx>
{
    private readonly Func<TCtx, T> _exec;

    public Reader(Func<TCtx, T> exec) => this._exec = exec;

    public T Apply(TCtx ctx) => this._exec(ctx);
}

public static class Reader
{
    public static Reader<TJoin, TCtx> SelectMany<TIn, TOut, TCtx, TJoin>(
        this Reader<TIn, TCtx> source, 
        Func<TIn, Reader<TOut, TCtx>> bind, 
        Func<TIn, TOut, TJoin> join) 
    =>
        new Reader<TJoin, TCtx>(ctx =>
        {
            var inValue = source.Apply(ctx);
            var outValue = bind(inValue).Apply(ctx);
            return join(inValue, outValue);
        });
}

Этот код работает, но без использования синтаксиса запросов (который сам по себе не всегда удобен) читабельность его сильно падает и это неудивительно, поскольку монады пришли из функциональных языков, где подобный код выглядит естественно и хорошо читается (хотя даже в Хаскелеле придумали "do" нотацию, что бы повысить читабельность). Однако классическая реализация помогает понять суть паттерна — вместо немедленного выполнения некоторого кода он помещается в функцию, которая будет вызвана только тогда, когда получит свой контекст.



public static Reader<string, Config> Greet(string name) 
    => new Reader<string, Config>(cfg => string.Format(cfg.Template, name));

//Если бы контекст передавался в виде параметра,то это выглядело бы следующим образом:
//public static string Greet(string name, Config cfg) 
//    => string.Format(cfg.Template, name);

SelectMany может связать несколько таких функций в одну, поэтому вы можете создать целую подпрограмму, выполнение которой будет отложено до применения ее контекста. С другой стороны, этот подход напоминает написание асинхронного кода, где выполнение программы останавливается, если требуется результат некой асинхронной операции. Когда результат операции будет готов, то выполнение программы продолжится. Возникает предположение, что инфраструктура C #, предназначенная для работы с асинхронными операциями ( async / await ), может быть каким-то образом использована при реализации монады "Reader" и… это предположение верно! Если функции требуются доступ к контексту, то её выполнение можно "приостановить" до тех пор, пока этот контекст не будет задан извне.


Aсинхронный "Reader"


В моей предыдущей статье я показал как получить контроль над операторами async/await используюя Обобщенные асинхронные типы возвращаемых значений. Тот же подход будет использован и в этот раз. Начнем с класса Reader который будет использован как тип результата асинхронных операций:


[AsyncMethodBuilder(typeof(ReaderTaskMethodBuilder<>))]
public class Reader<T> : INotifyCompletion, IReader
{
...

У этого класса есть две задачи(теоретически, мы могли бы создать два разных класса):


  1. Извлечение значений из контекста.
  2. Создание связанного списка экземпляров класса Reader, который будет использован для распределения контекста по всей иерархии вызовов.

Для каждой из этих задач мы создадим отдельный конструктор:


private readonly Func<object, T> _extractor;

//1. Используется для извлечения значений из контекста
public static Reader<T> Read<TCtx>(Func<TCtx, T> extractor) 
    => new Reader<T>(ctx => extractor((TCtx)ctx));

private Reader(Func<object, T> exec) => this._extractor = exec;

//2. Нужен для ReaderTaskMethodBuilder в коде генерируемом препроцессором C#
internal Reader() { }

Когда экземпляр класса Reader используется в качестве аргумента оператора await, то этот экземпляр получает ссылку на делегат, который продолжит выполнение программы. Этот делегат должен быть вызван после получения контекста из извлечения из него необходимых для продолжения работы программы данных.



Для того что бы связать экземпляры класса Reader давайте создадим метод SetChild:


private IReader _child;

internal void SetChild(IReader reader)
{
    this._child = reader;
    if (this._ctx != null)
    {
        this._child.SetCtx(this._ctx);
    }
}

который будет вызываться внутри ReaderTaskMethodBuilder:


public class ReaderTaskMethodBuilder<T>
{
    ...
    public void GenericAwaitOnCompleted<TAwaiter, TStateMachine>(
        ref TAwaiter awaiter, 
        ref TStateMachine stateMachine)
        where TAwaiter : INotifyCompletion
        where TStateMachine : IAsyncStateMachine
    {
        if (awaiter is IReader reader)
        {
            this.Task.SetChild(reader);
        }
        awaiter.OnCompleted(stateMachine.MoveNext);
    }

    public Reader<T> Task { get; }
}

Внутри метода SetChild мы вызываем функцию SetCtx для того, чтобы распространить контекст по иерархии вызовов. Если при вызове SetCtx на данном уровне иерархии задана функция _extractor (первый конструктор клссса Reader), непосредственно извлекающая данные из контекста, то теперь ее можно вызвать, получить необходимые данные и завершить текущую асинхронную операцию чререз вызов SetResult:


public void SetCtx(object ctx)
{
    this._ctx = ctx;
    if (this._ctx != null)
    {
        this._child?.SetCtx(this._ctx);

        if (this._extractor != null)
        {
            this.SetResult(this._extractor(this._ctx));
        }
    }
}

SetResult сохраняет значение, извлеченное из контекста, и вызывает делегат продолжающий выполнение программы:


internal void SetResult(T result)
{
    this._result = result;
    this.IsCompleted = true;
    this._continuation?.Invoke();
}

В случае когда экземпляр класса Reader не имеет инициированной функции _extractor (второй конструктор класса Reader), тогда SetResult должен быть вызван ReaderTaskMethodBuilder когда сгенерированный конечный автомат переход в завершающее состояние.


SetCtx также вызывается в методе Apply для установки контекста в корневом узле иерархии:


public Reader<T> Apply(object ctx)
{
    this.SetCtx(ctx);
    return this;
}

Полная версия кода на GitHub


Теперь, можно взглянуть на более реалистичный пример использования асинхронного Reader-a:


Щелкните для того, чтобы развернуть пример.
public static class ReaderTest
{
    public class Configuration
    {
        public readonly int DataBaseId;

        public readonly string GreetingTemplate;

        public readonly string NameFormat;

        public Configuration(int dataBaseId, string greetingTemplate, string nameFormat)
        {
            this.DataBaseId = dataBaseId;
            this.GreetingTemplate = greetingTemplate;
            this.NameFormat = nameFormat;
        }
    }

    public static async Task Main()
    {
        int[] ids = { 1, 2, 3 };

        Configuration[] configurations =
        {
            new Configuration(100, "Congratulations, {0}! You won {1}$!", "{0} {1}"),
            new Configuration(100, "?Felicidades, {0}! Ganaste {1} $", "{0}"),
        };

        foreach (var configuration in configurations)
        {
            foreach (var userId in ids)
            {
                //"Логика" получает только один явный параметр - userId
                var logic = GetGreeting(userId);

                //Остальные параметры (database Id, templates) могу быть переданы неявно 
                var greeting = await logic.Apply(configuration);

                Console.WriteLine(greeting)
            }
        }
        //Congratulations, John Smith! You won 110$!
        //Congratulations, Mary Louie! You won 30$!
        //Congratulations, Louis Slaughter! You won 47$!
        //?Felicidades, John! Ganaste 110 $
        //?Felicidades, Mary! Ganaste 30 $
        //?Felicidades, Louis! Ganaste 47 $
    }

    private static async Reader<string> GetGreeting(int userId)
    {
        var template = await Reader<string>.Read<Configuration>(cfg => cfg.GreetingTemplate);

        var fullName = await GetFullName(userId);

        var win = await GetWin(userId);

        return string.Format(template, fullName, win);
    }

    private static async Reader<string> GetFullName(int userId)
    {
        var template = await Reader<string>.Read<Configuration>(cfg => cfg.NameFormat);

        var firstName = await GetFirstName(userId);
        var lastName = await GetLastName(userId);

        return string.Format(template, firstName, lastName);
    }

    private static async Reader<string> GetFirstName(int userId)
    {
        var dataBase = await GetDataBase();
        return await dataBase.GetFirstName(userId);
    }

    private static async Reader<string> GetLastName(int userId)
    {
        var dataBase = await GetDataBase();
        return await dataBase.GetLastName(userId);
    }

    private static async Reader<int> GetWin(int userId)
    {
        var dataBase = await GetDataBase();
        return await dataBase.GetWin(userId);
    }

    private static async Reader<Database> GetDataBase()
    {
        var dataBaseId = await Reader<int>.Read<Configuration>(cfg => cfg.DataBaseId);
        return Database.ConnectTo(dataBaseId);
    }
}

public class Database
{
    public static Database ConnectTo(int id)
    {
        if (id == 100)
        {
            return new Database();
        }
        throw new Exception("Wrong database");
    }

    private Database() { }

    private static readonly (int Id, string FirstName, string LastName, int Win)[] Data =
    {
        (1, "John","Smith", 110),
        (2, "Mary","Louie", 30),
        (3, "Louis","Slaughter", 47),
    };

    public async Task<string> GetFirstName(int id)
    {
        await Task.Delay(50);
        return Data.Single(i => i.Id == id).FirstName;
    }

    public async Task<string> GetLastName(int id)
    {
        await Task.Delay(50);
        return Data.Single(i => i.Id == id).LastName;
    }

    public async Task<int> GetWin(int id)
    {
        await Task.Delay(50);
        return Data.Single(i => i.Id == id).Win;
    }
}

Программа показывает приветствия для некоторых пользователей, но мы не знаем заранее их имен поскольку у нас есть только их идентификаторы, так что нам надо прочитать их имена из "базы данных". Для подключения к этой "базе данных" необходимо знать параметры соединения, и для формирования приветствий нам так же нужны шаблоны этих приветствий. Вся эта информация передается неявно, через асинхронный Reader.


Внедрение зависимостей чрез асинхронный "Reader"


В сравнении с классической реализацией, асинхронный Reader имеет один недостаток – мы не можем указать тип передаваемого контекста. Это ограничение происходит от того факта, что компилятор C# допускает лишь один параметризованный тип данных (generic type) в классе ReaderTaskMethodBuilder (может быть это будет исправлено в следующих версиях).


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


public static class Reader
{
    public static Reader<TService> GetService<TService>() => 
        Reader<TService>.Read<IServiceProvider>(serviceProvider 
            => (TService)serviceProvider
                .GetService(typeof(TService)));
}

...
private static async Reader<string> Greet(string userName)
{
    var service = await Reader.GetService<IGreater>();
    return service.GreetUser(userName);
}
...

(Здесь вы можете найти полную версию...)


В отличии от асинхронного “Maybe”, который я не рекомендовал использовать в любом промышленном коде, я бы рассмотрел использование асинхронного Reader-a в некоторых реальных проектах как замену (или дополнение) традиционных механизмов внедрения зависимостей (когда все зависимости передаются в виде параметров конструктора) поскольку у Reader-a есть ряд преимуществ:


  1. Нет необходимости в полях, класса которые бы хранили ссылки на внедренные ресурсы. На самом деле вообще не будет нужды в настоящих классах, поскольку вся логика может быть реализована в статических методах.
  2. Использование Reader-a будет склонять к написанию неблокирующего кода поскольку все методы будут асинхронными и ничто не будет мешать использовать асинхронные версии библиотечных функций.
  3. Код будет чуть-чуть более читабельным, поскольку каждый раз, когда мы видим Reader как тип возвращаемого значения некоторого метода, мы будем знать, что он требует доступа к некоторому неявному контексту
  4. Асинхронный Reader не использует рефлексию.

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

Комментарии (9)


  1. math_coder
    27.11.2019 01:04

    Встречал утверждение, что async/await — это комонадный синтаксис. Можете это как-то прокомментировать?


  1. webmascon
    27.11.2019 01:25

    Код должен быть под катом. Вы поломали rss ленту


    1. 0x1000000 Автор
      27.11.2019 10:57

      Спасибо! Исправил


  1. koldyr
    27.11.2019 09:20

    Монады это не шаблоны проектирования.


    1. 0x1000000 Автор
      27.11.2019 10:48

      Вот первая строчка из статьи на Википедии:


      In functional programming, a monad is a design pattern[1] that allows structuring programs


      1. koldyr
        27.11.2019 12:08

        Дело в том, что монада чтения это вполне конкретный эндофунктор, со вполне конкретными естественными преобразованиями. И да, с помощью неё можно организовать доступ к некоторому контексту. Но это не значит, что любой способ доступа к контексту будет монадой и, тем более, монадой Reader.


  1. 0xd34df00d
    27.11.2019 16:12

    А можно аналогично сделать монадные трансформеры с классами типа MonadReader?


    1. 0x1000000 Автор
      27.11.2019 16:47

      Ну… пока у меня только есть две Монады, реализованные подобным образом, и в теории можно было бы объединить их в одну, но вот по поводу трансформеров не уверен.


    1. mayorovp
      27.11.2019 17:20

      Вручную-то или там с динамической диспетчеризацией можно всё сделать, проблема сделать это удобным и без накладных расходов.


      В .NET просто нет того, что в Хаскеле называется тайпклассом.