Immutype упростит работу с неизменяемыми типами данных

При работе с неизменяемыми типами данных, такими как readonly struct, нам часто приходится писать методы типа или статические методы расширения, которые создают копию объекта, изменяя определенное свойство или поле. Такие методы позволяют сделать код чище и проще, обеспечивая неизменяемость. Неизменяемость может быть полезной, например, если требуется обеспечить потокобезопасность для типа с состоянием. Обычно каждый подобный метод в качестве единственного аргумента принимает значения поля или свойства, которое нужно изменить, создает неполную измененную копию текущего объекта, используя конструктор, и возвращает эту измененную копию потребителю.

Например, для структуры Person:

public readonly struct Person
{
    public readonly string Name;
    public readonly int Age;
  
    public Person(string name = "", int age = 0)
    {
        Name = name;
        Age = age;
    }
  
    public Person WithName(string name) => new Person(name, Age);
   
    public Person WithAge(int age) => new Person(Name, age);
}

— результатом выполнения методов With… является неполная измененная копия оригинального объекта Person. Копия неполная, потому что для ссылочных свойств и полей, как в данном случае, копируется ссылка на объект. Но это не страшно, так как предполагается, что все типы этих полей или свойств также неизменяемы. 

Пример использования методов типа:

var john = new Person().WithName("John").WithAge(15);

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

public readonly struct Person
{
    public readonly string Name;
    public readonly int Age;

    public Person(string name = "", int age = 0)
    {
        Name = name;
        Age = age;
    }
}

public static class PersonExtensions
{
    public static Person WithName(this Person it, string name) =>
        new Person(name, it.Age);

    public static Person WithAge(this Person it, int age) =>
        new Person(it.Name, age);
}

Такой подход применим и к обычным неизменяемым структурам и классам.

Начиная с C# версии 9, можно использовать ключевое слово record для определения ссылочного типа. Он предоставляет встроенные возможности для инкапсуляции данных. Record позволяет создавать записи с неизменяемыми свойствами, используя позиционные параметры или стандартный синтаксис свойств:

public record Person(string Name = "", int Age = 0);

Несмотря на поддержку изменений, записи предназначены в первую очередь для неизменяемых моделей данных. Начиная с C# версии 10, можно определить типы record struct также с помощью позиционных параметров или синтаксиса свойств:

public readonly record struct Person(string Name = "", int Age = 0);

Обычную запись из C# версии 9 можно обозначить более полным выражением record class, где class - это необязательное ключевое слово. Выбор между record class и record struct, соответствует выбору между между class и struct.

С появлением записей появилось и новое ключевое with. Оно создает новый экземпляр записи, который является копией оригинальной и изменяет в этой копии указанные свойства и поля, результатом чего является неполная копия. Для указания требуемых изменений используется синтаксис инициализатора объектов:

var john = new Person() with { Name = "John", Age = 15 };

Синтаксис выглядит лаконичным и легко читаемым. Но существует несколько причин, по которым вспомогательные методы из первых примеров для структур и классов, оказываются предпочтительнее ключевому слову with. Во-первых, не во всех проектах можно использовать записи из-за версии C# и целевой версии фреймворка. А во-вторых, в неизменяемых типах часто встречаются поля или свойства содержащие неизменяемые коллекции объектов.

Например:

public record Person(
  string Name = "",
  int Age = 0,
  ImmutableArray<Person> Friends = default);

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

var john = new Person() with { Name = "John", Age = 15 }
    with { Friends = ImmutableArray.Create(new Person() with {Name = "David"}) };

john = john with
{
    Friends = (john.Friends == default ? ImmutableArray<Person>.Empty : john.Friends) 
    .Add(new Person() with { Name = "Sophia" })
    .Add(new Person() with { Name = "James" })
};

john = john with
{
    Friends = (john.Friends == default ? ImmutableArray<Person>.Empty : john.Friends)
    .Remove(new Person() with { Name = "David" })
};

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

Это генератор кода .NET, который делает всю работу одновременно, пока вы пишите код. Он ищет все типы, помеченные атрибутом [Immutype.Target], и для каждого такого типа создает статический класс с методами расширения. Эти методы расширения не замусоривают основной код и не маячат своими изменениями в коммитах. Для создания копии объектов используются:

Например, для записи:

[Immutype.Target]
public record Person(
  int Age,
  string Name = "",
  ImmutableArray<Person> Friends = default);

— сценарий из примера выше, выглядит так:

var john = new Person(15).WithName("John").WithAge(15)
    .WithFriends(new Person(16).WithName("David"));

john = john.AddFriends(
	new Person(17).WithName("Sophia"),
	new Person(14).WithName("James"));

john = john.RemoveFriends(new Person(16).WithName("David"));

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

Person WithAge(this Person it, int age)

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

Person WithDefaultName(this Person it)

Для коллекций создается по несколько методов расширения. В нашем случае для Friends будет создано четыре метода.

Person WithFriends(this Person it, params Person[] friends)
Person WithFriends(this Person it, ImmutableArray<Person> firends)
Person AddFriends(this Person it, params Person[] friends)
Person RemoveFriends(this Person it, params Person[] friends)

Контраст в простоте при использовании других типов коллекций еще более очевиден. Например, для записи:

public record Person(
  string Name = "",
  int Age = 0,
  IReadOnlyCollection<Person>? Friends = default);

— наш сценарий с ключевым словом with выглядят так:

var john = new Person()
    with { Name = "John", Age = 15 }
    with { Friends = new List<Person>{ new Person() with { Name = "David" } }};

john = john with
{
    Friends = (john.Friends ?? Enumerable.Empty<Person>())
    .Concat(
        new List<Person>[]{ 
            new Person() with { Name = "Sophia" },
            new Person() with { Name = "James" }})
    .ToList()
};

john = john with
{
    Friends = (john.Friends ?? Enumerable.Empty<Person>())
    .Except(new List<Person> { new Person() with { Name = "David" } })
    .ToList()
};

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

T[]
interface IEnumerable<T>
class List<T>
interface IReadOnlyCollection<T>
interface IReadOnlyList<T>
interface ICollection<T>
interface IList<T>
class HashSet<T>
interface ISet<T>
class Queue<T>
class Stack<T>
interface IReadOnlyCollection<T>
interface IReadOnlyList<T>
interface IReadOnlySet<T>
class ImmutableList<T>
interface IImmutableList<T>
struct ImmutableArray<T>
class ImmutableQueue<T>
interface IImmutableQueue<T>
class ImmutableStack<T>
interface IImmutableStack<T>

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

Так как Immutype - это генератор кода, он работает только на этапе компиляции и не добавляет каких-либо зависимости на другие сборки, не захламляет исходный код. Для работы Immutype необходим: .NET SDK 5.0.102 или новее. Но он будет работать для разных проектов, например, для .NET Framework 4.5. 

Immutype поддерживает инкрементальную генерацию кода, поэтому нагрузка на систему в процессе работы над проектами будет минимальной. 

Чтобы начать пользоваться, просто добавьте в ваши проекты ссылку на пакет Immutype и отметьте неизменяемые типы атрибутом [Immutype.Target] по необходимости.

С дополнительными примерами можно ознакомиться на странице проекта. Буду признателен за конструктивные комментарии, новые идеи и вклад в проект.

Тест производительности

Протестировано использование record и record struct с ключевым словом With и со статическими методами, созданными Immutype, для случаев с одним, двумя и четырьмя свойствами. Типы свойств: string, int и bool.

BenchmarkDotNet=v0.13.1, OS=Windows 10.0.22000
Intel Core i7-10875H CPU 2.30GHz, 1 CPU, 16 logical and 8 physical cores
.NET SDK=6.0.100
  [Host]     : .NET 6.0.0 (6.0.21.52210), X64 RyuJIT
  DefaultJob : .NET 6.0.0 (6.0.21.52210), X64 RyuJIT

С одним свойством string:

Method

Mean

Error

StdDev

Median

Gen 0

Allocated

Immutype record struct

0.0000 ns

0.0000 ns

0.0000 ns

0.0000 ns

-

-

With record struct

0.0317 ns

0.0287 ns

0.0307 ns

0.0240 ns

-

-

With record

6.1894 ns

0.1489 ns

0.2797 ns

6.3389 ns

0.0029

24 B

Immutype record

7.2829 ns

0.1577 ns

0.1619 ns

7.2482 ns

0.0029

24 B

С двумя свойствами string и int:

Method

Mean

Error

StdDev

Median

Gen 0

Allocated

With record struct

0.0195 ns

0.0296 ns

0.0291 ns

0.0036 ns

-

-

Immutype record struct

0.0442 ns

0.0257 ns

0.0352 ns

0.0285 ns

-

-

With record

8.2670 ns

0.1532 ns

0.1280 ns

8.2548 ns

0.0038

32 B

Immutype record

10.6809 ns

0.2420 ns

0.4424 ns

10.8150 ns

0.0076

64 B

С четырьмя свойствами string, int, string и bool:

Method

Mean

Error

StdDev

Median

Gen 0

Allocated

Immutype record struct

0.0409 ns

0.0074 ns

0.0058 ns

0.0407 ns

-

-

With record struct

0.0695 ns

0.0275 ns

0.0633 ns

0.0491 ns

-

-

With record

10.0555 ns

0.2268 ns

0.3790 ns

9.8518 ns

0.0048

40 B

Immutype record

25.2299 ns

0.5267 ns

0.8200 ns

24.9839 ns

0.0191

160 B

Legends:

Производительность Immutype для class и struct будет близкой к производительности для record и record struct. Важно отметить, что значимые траты производительности при использовании статических методов Immutype для ссылочных типов - это проверка входных параметров на null.

@NikolayPyanikov
14.12.2021 20:43 UTC
Первоисточник

Комментарии

@gdt
14.12.2021 16:53 UTC
0

Интересный проект, но использовать его я конечно не буду :)

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

Для прототипирования и/или как кристаллизация устоявшихся подходов в ваших проектах в отдельный нугет - очень даже неплохо, уверен, что найдёт своё применение. Поставил звезду на гитхабе :)

@NikolayPyanikov
14.12.2021 17:20 UTC
0

Спасибо за позитивный комментарий :)

@chersun
14.12.2021 17:20 UTC
0

Удалено

@NikolayPyanikov
14.12.2021 17:21 UTC
0

Слишком краткий вопрос :) ... не могу угадать из 3-х слов

14.12.2021 17:27 UTC
0

Нельзя комментировать не дочитав до конца. Был не прав, статья на всё ответила ближе к концу.

@DistortNeo
14.12.2021 17:21 UTC
+1

А почему параметр не передаётся как in? JIT-компилятор не настолько умный, и каждая передача структуры будет сопровождаться её копированием.

Ну и преимущество оператора with: можно менять сразу несколько полей за раз.

@NikolayPyanikov
14.12.2021 17:26 UTC
0

А почему параметр не передаётся как in? JIT-компилятор не настолько умный, и каждая передача структуры будет сопровождаться её копированием.

Спасибо за идею, добавлю в ближайшее время, и напишу бенчмарки

Ну и преимущество оператора with: можно менять сразу несколько полей за раз.

Согласен

16.12.2021 22:33 UTC
0

Если это генератор кода, то может лучше использовать именованные необязательные параметры? Как раз позволит сразу все поля инициализировать.

new Person().With(name: "John", friends: new List<Person>(..))

@NikolayPyanikov
14.12.2021 20:50 UTC
0

С версии 1.0.3 для C# 7.2+ для struct и record struct используется модификатор параметра in

14.12.2021 21:27 UTC
+1

То есть вы сломали обратную совместимость даже не в минорной версии, а просто в патче?

14.12.2021 21:41 UTC
0

Компиляция не сломана, код работает также, за исключением прироста производительности, количество скачивания пока незначительное. Если вы уже активно используете Immutype, и ваш код теперь сломан используйте пока зависимость с предыдущей версией и будет время решить как лучше поступить.

@NikolayPyanikov
14.12.2021 21:23 UTC
0

Ну и преимущество оператора with: можно менять сразу несколько полей за раз.

Да вот ещё подумал, что ни что не мешает использовать with вместе с Immutype

@OkunevPY
14.12.2021 19:28 UTC
-4

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

@NikolayPyanikov
14.12.2021 21:00 UTC
+1

Код от этих нововведений читабильнее не стал.

Читабелность – это конечно же очень субъективно.

Если вам понадобилось менять не изменяемое и это делаеться через копии объектов, то в проекте явно что-то идёт не по плану.

Immutype создает методы что бы сделать модифицированные копии и ни в коем случае не пытается менять оригиналы объектов. На похожем API, например, построен Roslyn.

Оптимальный код всегда в простой и доброй, старой, самой первой спецификации языка, научитесь писать на ней, никакие обёртки будут не нужны.

Возможно, Immutype поможет сэкономит время, если человек делает подобные вещи вручную. Если не делает, то конечно же ему и не нужен Immutype. Я пытаюсь тратить побольше времени на изучение.

@aamonster
15.12.2021 08:55 UTC
0

Вопрос: а компилятор C# (с оператором with или без него) умеет оптимизировать создание таких копий? В смысле, если оригинал больше не используется – модифицировать его и возвращать вместо копии?
Грубо говоря, в вызове new Person().WithName("John").WithAge(15) не создавать три разных объекта, а создать только один с нужными полями.

@DistortNeo
15.12.2021 10:59 UTC
0

Нет, не умеет. Компилятор C# только транслирует C#-код в MSIL лишь с небольшим числом оптимизаций. Проверяется просто: через декомпиляцию. Оптимизации также может применять JIT-компилятор, но он вынужден компилировать код быстро, и потом на подобные фокусы тоже не способен.

@NikolayPyanikov
15.12.2021 20:42 UTC
0

Слишком умно для него. Скорее всего, даже старые версии .NET заинлайнят вызовы WithName и WithAge - инструкций там совсем мало и добавлен атрибут для агрессивного инлайнинга, а в .NET 6 его еще более агрессивным сделали :) Фактически останется только вызов конструкторов, а это ну оооочень быстра операция в .NET. Также можно убрать проверки аргументов для случаев, когда включена нулабилити, но они полезны, а прирост производительности будет минимальным. Я сделаю бенчмарки что бы сравнить с with. Но как я уже упомянул, ни что не мешает миксовать ключевой слово with и вызовы сгенерированных статических методов там где удобно для записей. Остальные типы можно использовать через статические методы.

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

16.12.2021 04:18 UTC
0

Если не лень бенчмарк делать – попробуйте ещё для сравнения на мутабельных типах, с fluent-синтаксисом (когда withName модифицирует объект и возвращает его же).

Только тест надо делать длинный, чтобы увидеть влияние работы GC.

18.12.2021 11:45 UTC
0

Добавил бенчмарки