При работе с неизменяемыми типами данных, такими как 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 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], если такой есть

  • первый конструктор с наибольшим числом аргументов

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

[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] по необходимости.

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

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


  1. gdt
    14.12.2021 19:53

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

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

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


    1. NikolayPyanikov Автор
      14.12.2021 20:20

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


  1. chersun
    14.12.2021 20:20

    Удалено


    1. NikolayPyanikov Автор
      14.12.2021 20:21

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


      1. chersun
        14.12.2021 20:27

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


  1. DistortNeo
    14.12.2021 20:21
    +1

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

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


    1. NikolayPyanikov Автор
      14.12.2021 20:26

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

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

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

      Согласен


      1. NoofSaeidh
        17.12.2021 01:33

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

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


    1. NikolayPyanikov Автор
      14.12.2021 23:50

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


      1. mayorovp
        15.12.2021 00:27

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


        1. NikolayPyanikov Автор
          15.12.2021 00:41
          -1

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


    1. NikolayPyanikov Автор
      15.12.2021 00:23

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

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


  1. OkunevPY
    14.12.2021 22:28
    -4

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


    1. NikolayPyanikov Автор
      15.12.2021 00:00
      +1

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

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

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

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

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

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


  1. aamonster
    15.12.2021 11:55

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


    1. DistortNeo
      15.12.2021 13:59

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


    1. NikolayPyanikov Автор
      15.12.2021 23:42

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

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


      1. aamonster
        16.12.2021 07:18

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

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