В мире функционального программирования существует мощная концепция композиции функций. В C# тоже можно «встроить» каррирование и композицию, но смотрится это так себе. Вместо композиции в C# широкое применение нашел pipelining:
Func<string, string> reverseWords =
s => s.Words()
.Select(StringExtensions.Reverse)
.Unwords();
Pipelining, с которым мы работаем каждый день — это extension-методы для linq. На самом деле C# способен на большее и можно запрограммировать pipeline для любых входных и выходных аргументов с проверкой типов и поддержкой intellisense.
Для разработки pipeline будут использоваться:
- свойство nested-классов — возможность обращаться к приватным свойствам класса-родителя
- generics
- паттерн 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. Практическое применение:
- объединение операций в логические цепочки и выполнение их в едином контексте (например, в транзакции)
- Вместо Func и Action можно использовать Command и Queryи создавать цепочки вызовов
- Также можно использовать Task и реализовывать фьючерсы для асинхронного программирования (не знаю на сколько это полезно, просто пришло в голову)
Комментарии (27)
Hydro
18.08.2016 16:47+2Получается при работе со структурами будет вечный box/unbox из-за внутренней типизации по object?
Поясните для непросвещенных в функциональщине, как это можно использовать в работе? и стоит ли?marshinov
18.08.2016 17:06+1Не то, чтобы совсем вечный: в момент добавления и выполнения. Если не выстраивать цепочки на 100500 элементов, то вряд-ли будет заметно. Для меня основное применение из реального мира иметь возможность связать подряд много query (как deffered в js-коде). Особенно было бы удобно, если бы все Query автоматом стали ленивыми. Думаю, что можно «накрутить», но пока не пробовал.
dimaaan
18.08.2016 17:15ИМХО из примеров непонятно, в чем преимущество этого подхода.
Может стоит привести сравнение с аналогичным кодом без Pipelining'а?marshinov
18.08.2016 17:18interface 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);
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(); }
lair
18.08.2016 18:04… берем живой и здравствующий Monads, и пишем:
obj .With(Map<CreateBusinessEntityDto, BusinessEntity>) .Do(SaveEntity)
Бонусом бесплатная обработка
null
и возможность вставитьCheck
в произвольное место.marshinov
18.08.2016 18:30WIth c появлением?.. утратил актуальность. В примере возвращается не коллекция BusinessEntity, а одна штука, а Do — расширение для коллекций.
dimaaan
18.08.2016 19:35так же проще:
public override int Execute(CreateBusinessEntityDto obj) => SaveEntity( Map<CreateBusinessEntityDto, BusinessEntity>( obj))
зачем лишнее усложнение?dimaaan
18.08.2016 19:47Насколько я понял, смысл данного подхода в том, чтобы разбивать метод на анонимные функции, сохранять их во внутреннем списке _steps, а затем последовательно вызывать через foreach.
Более того, если в этих функциях использовать замыкания, то компилятором будут сгенерированы лишние классы-обертки.
Вопрос в том, зачем это делать, если можно просто вызывать функции последовательно?marshinov
18.08.2016 20:01https://habrahabr.ru/post/308052/?reply_to=9757846#comment_9757914. Да у этого есть стековая стоимость, да в F# можно сделать все красивее, поэтому топик в т.ч. в хабе «ненормальное программирование»
Sovent
20.08.2016 22:38У меня такой же вопрос возник. Из-за ограничения в одну сущность на вход Pipe'а теряется гибкость, которая достигается при простом последовательном вызове функций, а «ленивость» можно и так обеспечить с помощью Lazy. Так же плохо представляю, как в пайплайн включать асинхронные вызовы, которых в реальном коде всегда предостаточно.
AlexZaharow
18.08.2016 17:48-3Простите, а когда можно будет реально отказаться от типизации в C#? Раньше C# брал сколько мог у Java, тебе от JavaScript. Остался последний шаг — выкинуть типизацию — «да здравствует JS.NET»?
Я, конечно, иронизирую, но с кривой улыбкой. Ведь реально похоже на JQuery?marshinov
18.08.2016 17:53+1Если вы скомпилируете пример, то заметите, что все лямбды типизированные ;)
AlexZaharow
18.08.2016 19:59-7Вот честно, я уже не вижу особой необходимости в использовании типизации, при том что дофига логики пишется на клиенте на JavaScript и это ничуть не проще, чем серверная часть. Так зачем сегодня нужна типизация? По мне так она уже мешает. Пока ещё с её помощью хорошо работает IntelliSence, но это не имеет никакого отношения к RunTime.
Hydro
18.08.2016 20:07+8Оффтоп:
Строгая типизация нужна затем, чтобы компилятор лишний раз за разработчиком перед сборкой проекта проверил корректность обращения к членам объекта и приведение ссылок.
Нанимаем тестеров, чтобы за нами перепроверяли, пишем тесты, которые за нами проверяют изменения, покупаем статические анализаторы кода, чтобы видеть логические ошибки, а вот от бесплатной проверки компилятором отказываемся.
indestructable
18.08.2016 21:54+2Похоже на Reactive Extensions https://github.com/Reactive-Extensions/Rx.NET
Плюс реактивных расширений — встроенная асинхронность и поддержка потока событий.
Veikedo
19.08.2016 16:43+1На первый взгляд, очень похоже на https://habrahabr.ru/post/267231/
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);
Но, может оно и к лучшему :)
lair
Чем вас из-коробочное решение не устраивает?
marshinov
Устраивает. Если вдруг захочется сделать что-то вроде:
На случай если есть 50% синхронного кода и 50% асинхронного и нужно выполнить в одном контексте. Но я не изучал этот вопрос и полезность: просто мысли вслух.