Pull to refresh

Функциональный C#

Reading time 5 min
Views 25K
C# — язык мультипарадигмальный. В последнее время крен наметился в сторону функциональщины. Можно пойти дальше и добавить еще немного методов-расширений, позволяющих писать меньше кода, не «залезая» при этом на территорию F#.

PipeTo


Пока Pipe Operator не собираются включать в следующий релиз. Что-ж, можно обойтись и методом.

public static TResult PipeTo<TSource, TResult>(
    this TSource source, Func<TSource, TResult> func)
    => func(source);

Императивный вариант

public IActionResult Get()
{
    var someData = query
        .Where(x => x.IsActive)
        .OrderBy(x => x.Id)
        .ToArray();
    return Ok(someData);
}

С PipeTo

public IActionResult Get() =>  query
    .Where(x => x.IsActive)
    .OrderBy(x => x.Id)
    .ToArray()
    .PipeTo(Ok);

Заметили? В первом варианте мне нужно было вернуть взгляд к объявлению переменной и потом перейти к Ok. С PipeTo execution-flow строго слева-направо, сверху-вниз.

Either


В реальном мире алгоритмы чаще содержат ветвления, чем бывают линейными:

public IActionResult Get(int id) =>  query
    .Where(x => x.Id == id)
    .SingleOrDefault()
    .PipeTo(x => x != null ? Ok(x) : new NotFoundResult(“Not Found”));

Выглядит уже не так хорошо. Исправим это с помощью метода Either:

public static TOutput Either<TInput, TOutput>(this TInput o, Func<TInput, bool> condition,
    Func<TInput, TOutput> ifTrue, Func<TInput, TOutput> ifFalse)
    => condition(o) ? ifTrue(o) : ifFalse(o);

public IActionResult Get(int id) =>  query
    .Where(x => x.Id == id)
    .SingleOrDefault()
    .Either(x => x != null, Ok, _ => (IActionResult)new NotFoundResult("Not Found"));

Добавим перегрузку с проверкой на null:

public static TOutput Either<TInput, TOutput>(this TInput o, Func<TInput, TOutput> ifTrue,
    Func<TInput, TOutput> ifFalse)
    => o.Either(x => x != null, ifTrue, ifFalse);

public IActionResult Get(int id) =>  query
    .Where(x => x.Id == id)
    .SingleOrDefault()
    .Either(Ok, _ => (IActionResult)new NotFoundResult("Not Found"));

К сожалению вывод типов в C# еще не идеален, поэтому пришлось добавить явный каст к IActionResult.

Do


Get-методы контроллеров не должны создавать побочных эффектов, но иногда «очень надо».

public static T Do<T>(this T obj, Action<T> action)
{
    if (obj != null)
    {
        action(obj);
    }

    return obj;
}

public IActionResult Get(int id) =>  query
    .Where(x => x.Id == id)
    .Do(x => ViewBag.Title = x.Name)
    .SingleOrDefault()
    .Either(Ok, _ => (IActionResult)new NotFoundResult("Not Found"));

При такой организации кода побочный эффект с Do обязательно бросится в глаза во время code review. Хотя в целом использование Do — очень спорная идея.

ById


Не находите, что повторять постоянно q.Where(x => x.Id == id).SingleOrDefault() муторно?

public static TEntity ById<TKey, TEntity>(this IQueryable<TEntity> queryable, TKey id)
    where TEntity : class, IHasId<TKey> where TKey : IComparable, IComparable<TKey>, IEquatable<TKey>
    => queryable.SingleOrDefault(x => x.Id.Equals(id));

public IActionResult Get(int id) =>  query
    .ById(id)
    .Do(x => ViewBag.Title = x.Name)
    .Either(Ok, _ => (IActionResult)new NotFoundResult("Not Found"));

А если, я не хочу получать сущность целиком и мне нужна проекция:

public static TProjection ById<TKey, TEntity, TProjection>(this IQueryable<TEntity> queryable, TKey id,
Expression<Func<TEntity, TProjection>> projectionExpression)
    where TKey : IComparable, IComparable<TKey>, IEquatable<TKey>
    where TEntity : class, IHasId<TKey>
    where TProjection : class, IHasId<TKey>
    => queryable.Select(projectionExpression).SingleOrDefault(x => x.Id.Equals(id));   

public IActionResult Get(int id) =>  query
    .ById(id, x => new {Id = x.Id, Name = x.Name, Data = x.Data})
    .Do(x => ViewBag.Title = x.Name)
    .Either(Ok, _ => (IActionResult)new NotFoundResult("Not Found"));

Я думаю, что к текущему моменту (IActionResult)new NotFoundResult("Not Found")) уже тоже примелькалось и вы сами без труда напишете метод OkOrNotFound

Paginate


Пожалуй, не бывает приложений, работающих с данными без постраничного вывода.

Вместо:

.Skip((paging.Page - 1) * paging.Take)
.Take(paging.Take);

Можно сделать так:

public interface IPagedEnumerable<out T> : IEnumerable<T>
{
    long TotalCount { get; }
}

public static IQueryable<T> Paginate<T>(this IOrderedQueryable<T> queryable, IPaging  paging) 
=> queryable
    .Skip((paging.Page - 1) * paging.Take)
    .Take(paging.Take);

public static IPagedEnumerable<T> ToPagedEnumerable<T>(this IOrderedQueryable<T> queryable,
    IPaging paging)
    where T : class
    => From(queryable.Paginate(paging).ToArray(), queryable.Count());

public static IPagedEnumerable<T> From<T>(IEnumerable<T> inner, int totalCount)
    =>  new PagedEnumerable<T>(inner, totalCount);

public IActionResult Get(IPaging paging) =>  query
    .Where(x => x.IsActive)
    .OrderBy(x => x.Id)
    .ToPagedEnumerable(paging)
    .PipeTo(Ok);

IQueryableSpecification IQueryableFilter


Если вы дочитали до этого места, возможно, Вам понравится идея по другому компоновать Where и OrderBy в LINQ выражениях:

public class MyNiceSpec : AutoSpec<MyNiceEntity>
{
    public int? Id { get; set; }

    public string Name { get; set; }

    public string Code { get; set; }

    public string Description { get; set; }
}

public IActionResult Get(MyNiceSpec spec) =>  query
    .Where(spec)
    .OrderBy(spec)
    .ToPagedEnumerable(paging)
    .PipeTo(Ok);

При этом иногда имеет смысл применять Where до вызова Select, а иногда — после. Добавим метод MaybeWhere, который сможет работать как с IQueryableSpecification, так и с Expression<Func<T, bool>>

public static IQueryable<T> MaybeWhere<T>(this IQueryable<T> source, object spec)
    where T : class
{
    var specification = spec as IQueryableSpecification<T>;
    if (specification != null)
    {
        source = specification.Apply(source);
    }

    var expr = spec as Expression<Func<T, bool>>;
    if (expr != null)
    {
        source = source.Where(expr);
    }

    return source;
}

И теперь можно написать метод, учитывающий разные варианты:

public static IPagedEnumerable<TDest> Paged<TEntity, TDest>(
this IQueryableProvider queryableProvider, IPaging spec ,
Expression<Func<TEntity, TDest>> projectionExpression)
    where TEntity : class, IHasId
    where TDest : class, IHasId
    => queryableProvider
        .Query<TEntity>()
        .MaybeWhere(spec)
        .Select(projectionExpression)
        .MaybeWhere(spec)
        .MaybeOrderBy(spec)
        .OrderByIdIfNotOrdered()
        .ToPagedEnumerable(spec);

Или с применением Queryable Extensions AutoMapper:

public static IPagedEnumerable<TDest> Paged<TEntity, TDest>(this IQueryableProvider queryableProvider,
    IPaging spec)
    where TEntity : class, IHasId
    where TDest : class, IHasId => queryableProvider
        .Query<TEntity>()
        .MaybeWhere(spec)
        .ProjectTo<TDest>()
        .MaybeWhere(spec)
        .MaybeOrderBy(spec)
        .OrderByIdIfNotOrdered()
        .ToPagedEnumerable(spec);

Если вы считаете, что лепить IPaging, IQueryableSpecififcation и IQueryableOrderBy на один объект богомерзко, то ваш вариант такой:

public static IPagedEnumerable<TDest> Paged<TEntity, TDest>(this IQueryableProvider queryableProvider,
    IPaging paging, IQueryableOrderBy<TDest> queryableOrderBy,
IQueryableSpecification<TEntity> entitySpec = null, IQueryableSpecification<TDest> destSpec = null)
    where TEntity : class, IHasId where TDest : class
    => queryableProvider
        .Query<TEntity>()
        .EitherOrSelf(entitySpec, x => x.Where(entitySpec))
        .ProjectTo<TDest>()
        .EitherOrSelf(destSpec, x => x.Where(destSpec))
        .OrderBy(queryableOrderBy)
        .ToPagedEnumerable(paging);

В итоге получаем три строчки кода для метода, который фильтрует, сортирует и обеспечивает постраничный вывод для любых источников данных с поддержкой LINQ.

public IActionResult Get(MyNiceSpec spec) =>  query
    .Paged<int, MyNiceEntity, MyNiceDto>(spec)
    .PipeTo(Ok);

К сожалению сигнатуры методов в C# выглядят монструозно из-за обилия generic'ов. К счастью, в прикладном коде параметры методов можно опустить. Сигнатуры extension'ов LINQ выглядят примерно также. Как часто вы указываете возвращаемый из Select тип? Хвала var, который избавил нас от этого мучения.
Tags:
Hubs:
+25
Comments 59
Comments Comments 59

Articles