В мире функционального программирования существует мощная концепция композиции функций. В C# тоже можно «встроить» каррирование и композицию, но смотрится это так себе. Вместо композиции в C# широкое применение нашел pipelining:

Func<string, string> reverseWords =
    s => s.Words()
        .Select(StringExtensions.Reverse)
        .Unwords();

Pipelining, с которым мы работаем каждый день — это extension-методы для linq. На самом деле C# способен на большее и можно запрограммировать pipeline для любых входных и выходных аргументов с проверкой типов и поддержкой intellisense.

Для разработки pipeline будут использоваться:

  1. свойство nested-классов — возможность обращаться к приватным свойствам класса-родителя
  2. generics
  3. паттерн fluent interface

Получится вот так:

var pipeline = Pipeline
	.Start(() => 10, x => x + 6)
	.Pipe(x => x.ToString())
	.Pipe(int.Parse)
	.Pipe(x => Math.Sqrt(x))
	.Pipe(x => x * 5)
	.Pipe(x => new Point((int) Math.Round(x), 120))
	.Finish(x => Debug.WriteLine($"{x.X}{x.Y}"))
	.Do(() => Debug.WriteLine("Point is so cool"));

// ...
pipeline.Execute();

Или так, применительно к CQRS и прикладному коду:

    public class CreateBusinessEntity : ContextCommandBase<CreateBusinessEntityDto>
    {
        public CreateBusinessEntity(DbContext context) : base(context) {}

        public override int Execute(CreateBusinessEntityDto obj) => Pipeline
            .Pipe(obj, Map<CreateBusinessEntityDto, BusinessEntity>)
            .Pipe(SaveEntity)
            .Execute();
    }

Для начала потребуется класс-контейнер, внутренний интерфейс для вызова функций и внешний — для реализации fluent interface:

    public class Pipeline
    {
        private readonly object _firstArg;

        private object _arg;

        private readonly List<IInvokable> _steps = new List<IInvokable>();

        private Pipeline(object firstArg)
        {
            _firstArg = firstArg;
            _arg = firstArg;
        }

        internal interface IInvokable
        {
            object Invoke();
        }
        public object Execute()
        {
            _arg = _firstArg;
            foreach (IInvokable t in _steps)
            {
                _arg = t.Invoke();
            }

            return _arg;
        }

        public abstract class StepBase
        {
            protected Pipeline Pipeline;

            public Step Do([NotNull] Action action)
            {
                if (action == null) throw new ArgumentNullException(nameof(action));
                return new Step(Pipeline, action);
            }         
        }
    }

И методы для создания pipeline:

        public static Step Do(Action firstStep)
        {
            var p = new Pipeline(null);
            return new Step(p, firstStep);
        }

        public static Step<TInput, TOutput> Pipe<TInput, TOutput>(
            TInput firstArg,
            Func<TInput, TOutput> firstStep)
        {
            var p = new Pipeline(firstArg);
            // ReSharper disable once ObjectCreationAsStatement
            return new Step<TInput, TOutput>(p, firstStep);
        }

        public static Step<TInput, TOutput> Start<TInput, TOutput>(
            Func<TInput> firstArg,
            Func<TInput, TOutput> firstStep)
        {
            return Pipe(firstArg, x => x.Invoke())
                .Pipe(firstStep);
        }

Теперь дело за реализациями шаблонов для fluent interface

        public class Step : StepBase, IInvokable
        {
            private readonly Action _action;

            public Step(Pipeline pipeline, Action action)
            {
                Pipeline = pipeline;
                _action = action;
                Pipeline._steps.Add(this);
            }

            object IInvokable.Invoke()
            {
                _action.Invoke();
                return Pipeline._arg;
            }

            public void Execute() => Pipeline.Execute();
        }

        public class Step<TInput> : StepBase, IInvokable
        {
            private readonly Pipeline _pipe;

            private readonly Action<TInput> _action;

            public Step(Pipeline pipe, Action<TInput> action)
            {
                _pipe = pipe;
                _action = action;
                _pipe._steps.Add(this);
            }

            object IInvokable.Invoke()
            {
                _action.Invoke((TInput)_pipe._arg);
                return _pipe._arg;
            }

            public void Execute() => Pipeline.Execute();
        }

        public class Step<TInput, TOutput> : StepBase, IInvokable
        {
            private readonly Pipeline _pipe;

            private readonly Func<TInput, TOutput> _func;

            internal Step(Pipeline pipe, Func<TInput, TOutput> func)
            {
                _pipe = pipe;
                _func = func;
                _pipe._steps.Add(this);
            }

            object IInvokable.Invoke() => _func.Invoke((TInput) _pipe._arg);

            public Step<TOutput, TNext> Pipe<TNext>([NotNull] Func<TOutput, TNext> func)
            {
                if (func == null) throw new ArgumentNullException(nameof(func));
                return new Step<TOutput, TNext>(_pipe, func);
            }

            public Step<TOutput> Finish([NotNull] Action<TOutput> action)
            {
                if (action == null) throw new ArgumentNullException(nameof(action));
                return new Step<TOutput>(Pipeline, action);
            }

            public TOutput Execute() => (TOutput)_pipe.Execute();
        }

Шаблоны помогаю гарантировать, что в метод Pipe придет «правильный» аргумент. Отдельного внимания заслушивает метод Start, который позволяет передать в качестве аргумента не значение, а функцию:

var point = Pipeline
	.Start(() => 10, x => x + 6)
	.Pipe(x => x.ToString())
	.Pipe(int.Parse)
	.Pipe(x => Math.Sqrt(x))
	.Pipe(x => x * 5)
	.Pipe(x => new Point((int)Math.Round(x), 120))
	.Execute();

Все некрасивые моменты, связанные с работой по ссылке на тип object мы спрятали внутрь сборки:

        public object Execute()
        {
            _arg = _firstArg;
            foreach (IInvokable t in _steps)
            {
                _arg = t.Invoke();
            }

            return _arg;
        }

Полный код доступен на github. Практическое применение:

  1. объединение операций в логические цепочки и выполнение их в едином контексте (например, в транзакции)
  2. Вместо Func и Action можно использовать Command и Queryи создавать цепочки вызовов
  3. Также можно использовать Task и реализовывать фьючерсы для асинхронного программирования (не знаю на сколько это полезно, просто пришло в голову)
Поделиться с друзьями
-->

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


  1. lair
    18.08.2016 16:44
    +1

    Также можно использовать Task и реализовывать фьючерсы для асинхронного программирования

    Чем вас из-коробочное решение не устраивает?


    1. marshinov
      18.08.2016 16:55
      +3

      Устраивает. Если вдруг захочется сделать что-то вроде:

      var point = Pipeline
      	.Start(() => 10, x => x + 6)
      	.Pipe(x => x.ToString())
      	.Pipe(int.Parse)
      	.Pipe(x => Math.Sqrt(x))
      	.Async(x => x + 5) // создаем таск здесь
              .ContinueWith((t,o) => {
                   // do stuff 
              }, obj);
      

      На случай если есть 50% синхронного кода и 50% асинхронного и нужно выполнить в одном контексте. Но я не изучал этот вопрос и полезность: просто мысли вслух.


  1. Hydro
    18.08.2016 16:47
    +2

    Получается при работе со структурами будет вечный box/unbox из-за внутренней типизации по object?
    Поясните для непросвещенных в функциональщине, как это можно использовать в работе? и стоит ли?


    1. marshinov
      18.08.2016 17:06
      +1

      Не то, чтобы совсем вечный: в момент добавления и выполнения. Если не выстраивать цепочки на 100500 элементов, то вряд-ли будет заметно. Для меня основное применение из реального мира иметь возможность связать подряд много query (как deffered в js-коде). Особенно было бы удобно, если бы все Query автоматом стали ленивыми. Думаю, что можно «накрутить», но пока не пробовал.


  1. dimaaan
    18.08.2016 17:15

    ИМХО из примеров непонятно, в чем преимущество этого подхода.
    Может стоит привести сравнение с аналогичным кодом без Pipelining'а?


    1. marshinov
      18.08.2016 17:18

      interface IQuery<in TInput, out TOutput>
      {
           TOutput Ask(TInput input);
      }
      	
      IQuery<string, int> stoi = null;
      IQuery<int, double> itod = null;
      IQuery<double, string> dtos = null;
      
      Func<string, string> pipeline = x => dtos.Ask(itod.Ask(stoi.Ask(x)));
      // Логический порядок перепутан - код выполняется справа-налево
      // Это только передача параметров, без преобразований.
      // Представьте себе цепочку с преобразованиями
      
      // or
      
      var i = stoi.Ask("Hello World");
      var dbl = itod.Ask(i);
      var str = dtos.Ask(dbl);
      


    1. marshinov
      18.08.2016 18:01

      В реальном коде может быть так:

          public class CreateBusinessEntityDto
          {
              public BusinessEntityType Type { get; set; }
      
              public Address Address { get; set; }
          }
      
          public class CreateBusinessEntity : ContextCommandBase<CreateBusinessEntityDto>
          {
              public CreateBusinessEntity(DbContext context) : base(context)
              {
              }
      
              public override int Execute(CreateBusinessEntityDto obj) => Pipeline
                  .Pipe(obj, Map<CreateBusinessEntityDto, BusinessEntity>)
                  .Pipe(SaveEntity)
                  .Execute();
          }
      


      1. lair
        18.08.2016 18:04

        … берем живой и здравствующий Monads, и пишем:


        obj
          .With(Map<CreateBusinessEntityDto, BusinessEntity>)
          .Do(SaveEntity)

        Бонусом бесплатная обработка null и возможность вставить Check в произвольное место.


        1. marshinov
          18.08.2016 18:30

          WIth c появлением?.. утратил актуальность. В примере возвращается не коллекция BusinessEntity, а одна штука, а Do — расширение для коллекций.


          1. lair
            18.08.2016 18:32
            +1

            WIth c появлением?.. утратил актуальность.

            With-то утратил, а вот все остальные — нет. (впрочем, и With — тоже, если вам надо не метод вызвать, а передать куда-нибудь)


            В примере возвращается не коллекция BusinessEntity, а одна штука, а Do — расширение для коллекций.

            Не только.


      1. dimaaan
        18.08.2016 19:35

        так же проще:

        public override int Execute(CreateBusinessEntityDto obj) => 
            SaveEntity(
                Map<CreateBusinessEntityDto, BusinessEntity>(
                    obj))
        

        зачем лишнее усложнение?


        1. dimaaan
          18.08.2016 19:47

          Насколько я понял, смысл данного подхода в том, чтобы разбивать метод на анонимные функции, сохранять их во внутреннем списке _steps, а затем последовательно вызывать через foreach.
          Более того, если в этих функциях использовать замыкания, то компилятором будут сгенерированы лишние классы-обертки.
          Вопрос в том, зачем это делать, если можно просто вызывать функции последовательно?


          1. marshinov
            18.08.2016 20:01

            https://habrahabr.ru/post/308052/?reply_to=9757846#comment_9757914. Да у этого есть стековая стоимость, да в F# можно сделать все красивее, поэтому топик в т.ч. в хабе «ненормальное программирование»


    1. Sovent
      20.08.2016 22:38

      У меня такой же вопрос возник. Из-за ограничения в одну сущность на вход Pipe'а теряется гибкость, которая достигается при простом последовательном вызове функций, а «ленивость» можно и так обеспечить с помощью Lazy. Так же плохо представляю, как в пайплайн включать асинхронные вызовы, которых в реальном коде всегда предостаточно.


  1. i_user
    18.08.2016 17:47
    +4

    Зачем называть это .Pipe, если это по сути это обычный .map?


    1. marshinov
      18.08.2016 17:52

      Я часто использую Automapper, поэтому назвал Pipe, чтобы не путалось, но вы правы, это обычный Map.


    1. marshinov
      18.08.2016 18:12
      +1

      В .NET как-то вообще принято называть все по-своему: .Select, .Aggregate…


      1. lair
        18.08.2016 18:16
        +1

        Не в .net, а в LINQ. В F#, который часть .net, честный Seq.map.


  1. AlexZaharow
    18.08.2016 17:48
    -3

    Простите, а когда можно будет реально отказаться от типизации в C#? Раньше C# брал сколько мог у Java, тебе от JavaScript. Остался последний шаг — выкинуть типизацию — «да здравствует JS.NET»?

    Я, конечно, иронизирую, но с кривой улыбкой. Ведь реально похоже на JQuery?


    1. marshinov
      18.08.2016 17:53
      +1

      Если вы скомпилируете пример, то заметите, что все лямбды типизированные ;)


      1. AlexZaharow
        18.08.2016 19:59
        -7

        Вот честно, я уже не вижу особой необходимости в использовании типизации, при том что дофига логики пишется на клиенте на JavaScript и это ничуть не проще, чем серверная часть. Так зачем сегодня нужна типизация? По мне так она уже мешает. Пока ещё с её помощью хорошо работает IntelliSence, но это не имеет никакого отношения к RunTime.


        1. Hydro
          18.08.2016 20:07
          +8

          Оффтоп:
          Строгая типизация нужна затем, чтобы компилятор лишний раз за разработчиком перед сборкой проекта проверил корректность обращения к членам объекта и приведение ссылок.
          Нанимаем тестеров, чтобы за нами перепроверяли, пишем тесты, которые за нами проверяют изменения, покупаем статические анализаторы кода, чтобы видеть логические ошибки, а вот от бесплатной проверки компилятором отказываемся.


        1. impwx
          18.08.2016 21:57
          +1

          Почитайте по поводу того, какими усилиями достигается приемлемая производительность в динамических VM (например, V8 для JS). Как раз недавно статью на эту тему переводил. Некоторые оптимизации там вообще невозможны по сравнению со статически типизированными системами.


  1. indestructable
    18.08.2016 21:54
    +2

    Похоже на Reactive Extensions https://github.com/Reactive-Extensions/Rx.NET
    Плюс реактивных расширений — встроенная асинхронность и поддержка потока событий.


  1. Veikedo
    19.08.2016 16:43
    +1

    На первый взгляд, очень похоже на https://habrahabr.ru/post/267231/


    1. marshinov
      19.08.2016 17:34

      Очень похоже, потому что функциональный стиль в C# доступен по схеме generic + extension method + fluent interface, поэтому получаются цепочки вызовов. C# нельзя перегрузить операторы с поддержкой шаблонов и количество операторов, доступных для перегрузки ограничено. Иначе можно было бы писать что-то вроде

      var context = //...
      var a = obj.GetSomething(arg) => Map<Y> => x => context.DoJob(x);
      

      Но, может оно и к лучшему :)


      1. indestructable
        19.08.2016 23:52

        И хорошо, что нельзя.