Правостороннее присваивание и другие необычные приёмы программирования в C#

В этой статье будут рассмотрены с нового ракурса такие привычные и фундаментальные вещи, как присваивание и передача параметров в методы.

Вероятно, предлагаемые решения поначалу покажутся несколько странными и надуманными, но прелесть их раскроется чуть позже, когда станет видна вся картина целиком.

Будет много нового и интересного, возможно, даже полезного. А после прочтения каждый сам сможет решить, стоит ли ему применять описанные техники в дальнейшей повседневной практике.

За дело!

image


1. Правосторонние операции: присваивание, декларация переменных и приведение типа


Существует два направления присваивания: правое и левое

IModel m;
m = GetModel(); // left side assignment
GetModel().To(out m); // right side assignment


Да, все методы с `out` и частично с `ref` параметрами являются вариациями правостороннего присваивания.

С ранних версий C# поддерживает `out` и `ref` параметры, что даёт некоторые преимущества, но не очень впечатляющие, однако C# 7 совершил эволюционный скачок!

Добавление синтаксического сахара вроде `o.To (out var x)` позволило объединить правостороннее присваивание вместе с декларацией переменной, что дало возможность обобщить и уточнить некоторые распространённые сценарии в программировании…

Исторически более привычной является традиционная левостронняя ориентация при присваивании. Возможно, это влияние математики, где `y = f (x)` является стандартной нотацией. Но на практике в программировании такое положение вещей вызывает некоторые ограничения (будут упомянуты далее) и неудобства, например, визуальный переизбыток скобок ('parentheses hell') при цепочном привидении типов для урегулирования приоритетов

public void EventHandler(object sender, EventArgs args) =>
        ((IModel) ((Button) sender).DataContext).Update();

// in a general case there is not possible settle priorities without parentheses
// (IModel) (Button) sender.DataContext.Update();


что подталкивает разработчиков к использованию многословных либо плохих решений наподобие

/* NullReferenceException instead of InvalidCastException */
public void EventHandler(object sender, EventArgs args) =>
        ((sender as Button).DataContext as IModel).Update();

/* miss of InvalidCastException */
public void EventHandler(object sender, EventArgs args) =>
        ((sender as Button)?.DataContext as IModel)?.Update();

/* verbose */
public void EventHandler(object sender, EventArgs args)
{
        var button = (Button) sender;
        var model = (IModel) button.DataContext;
        model.Update();
}


Тем не менее существует менее очевидное, но более элегантное решение проблемы путём правостороннего приведения типа

public void EventHandler(object sender, EventArgs args) =>
        sender.To


При дальнейшем обобщении подхода мы получаем следующий набор методов-расширений

public static object ChangeType(this object o, Type type) =>
        o == null || type.IsValueType || o is IConvertible ?
                Convert.ChangeType(o, type, null) :
                o;

public static T To(this T o) => o;
public static T To(this T o, out T x) => x = o;
public static T To(this object o) => (T) ChangeType(o, typeof(T));
public static T To(this object o, out T x) => x = (T) ChangeType(o, typeof(T));


которые позволяют отзеркалить направление всех трёх базовых операций: декларации переменной, привидения типа и присваивания значения

sender.To(out Button b).DataContext.To(out IModel m).Update();
/* or */
sender.To(out Button _).DataContext.To(out IModel _).Update();


Эти примеры иллюстрируют, что исторически C# потерял что-то вроде оператора `to`. Сравните

((sender to Button b).DataContext to IModel m).Update();
((sender to Button _).DataContext to IModel _).Update();
/* or even */
sender to Button b.DataContext to IModel m.Update();
sender to Button _.DataContext to IModel _.Update();

2. to-with паттерн


Многим разработчикам хорошо знакомы инициализационные блоки в духе `json`

var person = new Person
{
        Name = "Abc",
        Age = 28,
        City = new City
        {
                Name = "Minsk"
        }
};


вместо

var person = new Person();
person.Name = "Abc";
person.Age = 28;
person.City = new City();
person.City.Name = "Minsk";


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

var person = CreatePerson()
{
        Name = "Abc",
        Age = 28,
        City
        {
                Name = "Minsk"
        }
}; // cause compile errors


Другими словами, простая замена конструктора на метод-фабрику может вызывать кардинальную смену структуры кода. Как этого избежать?

Для начала рассмотрим два метода-расширения

public static T To(this T o, out T x) => x = o;
public static T With(this T o, params object[] pattern) => o;


Они позволяют нам переписать код следующими способами

var person = new Person().To(out var p).With
(
        p.Name = "Abc",
        p.Age = 28,
        p.City = new City().To(out var c).With
        (
                c.Name = "Minsk"
        )
);


либо

var person = CreatePerson().To(out var p)?.With
(
        p.Name = "Abc",
        p.Age = 28,
        p.City.To(out var c)?.With
        (
                c.Name = "Minsk"
        )
);


* при желании можно поиграть с примерами в онлайн-компиляторе по ссылке

Это чуть более многословная, но обощённая запись, в сравнении с инициализационными блоками. Немаловажно, что поддерживаются рекурсивные выражения совместно с оператором проверки на `null` (`?`), а также вызовы функциональных методов, возвращающих значения, например,

var person = CreatePerson().To(out var p)?.With
(
        ...
        p.ToString().To(out var personStringView)
);


Однако предложенная реализация метода `With` имеет несколько недостатков:

  • создание массивов и выделение для них памяти (array allocations)
  • возможная упаковка для типов-значений (boxing for value types)


Эти проблемы могут быть устранены следующим образом

public static T With(this T o) => o;
public static T With(this T o, A a) => o;
public static T With(this T o, A a, B b) => o;
public static T With(this T o, A a, B b, C c) => o;
                /* ... */


Если же необходимо получить крупное, но хорошо оптимизированное `With` выражение, то допустима конкатенация (склеивание) нескольких более коротких выражений

GetModel().To(out var m)
        .With(m.A0 = a0, ... , m.AN = an).With(m.B0 = b0, ... ,m.BM = bM).Save();


Данный подход имеет производительность предельно близкую к идеальной.

Существует также неочевидный эффект, связанный со структурами. Для примера, если мы хотим модифицировать структуру и вернуть её в цепочку вызовов методов, то нам необходимо использовать `put`-паттерн

public static TX Put(this T o, TX x) => x;
public static TX Put(this T o, ref TX x) => x;


Дело в том, что при вызове метода-расширения для структуры происходит её копирование, в результате чего метод `With` возвращает её оригинал вместо модифицированного экземпляра

static AnyStruct SetDefaults(this AnyStruct s) =>
        s.With(s.Name = "DefaultName").Put(ref s);


С версии C# 7.2 поддерживаются ссылочные методы-расширения для структур `this ref`, поэтому можно использовать их

public static T WithRef(this ref T o, A a) where T: struct => o;


А с версии C# 7.3 допустимо совместное использование перегрузок

public static T With(this ref T o, A a) where T: struct => o;
public static T With(this T o, A a) where T: class => o;


Также `With` метод полезен в подобных сценариях

// possible NRE
void UpdateAppTitle() => Application.Current.MainWindow.Title = title;

// currently not supported by C#, possible, will be added later
void UpdateAppTitle() =>
        Application.Current.MainWindow?.Title = title;

// classical solution
void UpdateAppTitle() {
        var window = Application.Current.MainWindow;
        if (window != null) window.Title = title;
}

void UpdateAppTitle() =>
        Application.Current.MainWindow.To(out var w)?.With(w.Title = title);


Это базовая информация о `to-with` паттерне, но не вся.

Неочевидная, но очень важная вещь — паттерн двунаправленный и зеркально симметричный относительно присваивания.

Это означает, что мы можем его использовать для инициализации и деконструкции объектов одновременно!

GetPerson().To(out var p).With
(
        /* deconstruction-like variations */
        p.Name.To(out var name), /* right side assignment to the new variable */
        p.Name.To(out nameLocal), /* right side assignment to the declared variable */
        NameField = p.Name, /* left side assignment to the declared variable */
        NameProperty = p.Name, /* left side assignment to the property */

        /* a classical initialization-like variation */
        p.Name = "AnyName"
)


Как видно, обычные `json` подобные инициализационные блоки являются лишь ограниченной (отчасти из-за левостороннего присваивания) частной синтаксической вариацией намного более обобщённого `with` паттерна.

Кроме того, подобный подход применим и для инициализаторов коллекций

public CustomCollection GetSampleCollection() =>
        new CustomCollection().To(out var c).With(c.Name = "Sample").Merge(a, b, c, d);

/* currently not possible */
public CustomCollection GetSampleCollection() =>
        new CustomCollection { Name = "Sample" } { a, b, c, d };


где

public static TCollection Merge(
        this TCollection collection, params TElement[] items)
        where TCollection : ICollection =>
        items.ForEach(collection.Add).Put(collection);


Возможно также реализовать очень близкий по духу `check` паттерн для условных выражений

if (GetPerson() is Person p && p.Check
        (
                p.FirstName is "Keanu",
                p.LastName is string lastName,
                p.Age.To(out var age) > 23
        ).All(true)) ...
    
if (GetPerson() is Person p && p.Check
        (
                p.FirstName.Is("Keanu"), /* check for equality */
                p.LastName.Is(out var lastName), /* check for null */
                p.City.To(out var city).Put(true), /* always true */
                p.Age.To(out var age) > 23
        ).All(true)) ...

case Person p when p.Check
        (
                p.FirstName.StartWith("K"),
                p.LastName.StartWith("R"),
                p.Age.To(out var age) > 23
        ).Any(true): ...

case Point p when p.Check
                (
                p.X > 9,
                p.Y > 7 && p.Y < 221
                p.Z > p.Y
                p.T > 0
        ).Count(false) == 2: ...


Взгляните

public static bool[] Check(this T o, params bool[] pattern) => pattern;

3. Другие фишки


put паттерн


Предназначен для смены контекста в цепочке вызовов, а также декларации произвольного значения в любом контексте

use паттерн


Позволяет объявить новую переменную в цепочке вызовов либо выполнить сторонний метод

if (GetPerson() is Person p && p.Check
        (
                ...
                p.City.To(out var city).Put(true), /* always true */
                p.Age.To(out var age) > 23
        ).All(true)) ...

persons.Use(out var j, 3).ForEach(p => p.FirstName = $"Name{j++}");

private static bool TestPutUseChain() =>
        int.TryParse("123", out var i).Put(i).Use(Console.WriteLine) == 123;

new паттерн


Предоставляет возможность использовать вывод типов при декларации массивов и коллекций, а также создавать объекты с помощью обобщённого метода

var words = New.Array("hello", "wonderful", "world");
var ints = New.List(1, 2, 3, 4, 5);

var item = New.Object();

value propagation / group assignment


Добавляет поддержку групповой инициализации переменных одним значением, в том числе в процессе декларации с выводом типа

var (x, y, z) = 0;
(x, y, z) = 1;

var ((x, y, z), t, n) = (1, 5, "xyz");

lambda-styled type matching


Альтернатива классическому оператору `switch` на основе лямбда-выражений

public static double CalculateSquare(this Shape shape) =>
        shape.Match
        (
                (Line _) => 0,
                (Circle c) => Math.PI * c.Radius * c.Radius,
                (Rectangle r) => r.Width * r.Height,
                () => double.NaN
        );


Детальные реализации и примеры кода находятся по ссылкам
Github mirror: implementation / some tests
Bitbucket mirror: implementation / some tests

Результаты


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

Послесловие от автора


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

© Habrahabr.ru