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

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

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

При работе с неизменяемыми типами данных, такими как 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(
  string Name = "",
  int Age = 0,
  ImmutableArray<Person> Friends = default);

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

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

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

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

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

Person WithMyValue(this Person  it, int myValue)

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

Person WithDefaultName(this Person it)

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

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

Person WithValues(this Person it, params Person[] persons)
  • Такой же как и выше, но с оригинальным типом в качестве аргумента:

Person WithValues(this Person it, ImmutableArray<Person> persons)
  • Чтобы создать копию коллекции, добавив переменное число элементов:

Person AddValues(this Person it, params Person[] persons)
  • Чтобы создать копию коллекции, исключив некоторые элементы:

Person RemoveValues(this Person it, params Person[] persons)

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

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

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

Источник: https://habr.com/ru/post/595571/


Интересные статьи

Интересные статьи

(статья обновлена в мае 2021 г.)Какие системы управления базами данных (СУБД) распространены в мире больше всего? Как они изменились с 2006 года и какие входят ...
Google планирует заменить cookie на новую технологию сбора информации о пользователях для рекламодателей. В Евросоюзе опасаются монополии IT-гиганта в области обезличенны...
Недавно наши японские коллеги провели опрос пользователей печатной техники на территориях СНГ и очень удивились, узнав, что в России отношение к струйной печати до сих пор во многом строи...
Анамнез, так сказать: Сервер Fujitsu rx300 s6, RAID6 из 6 1Тб дисков, поднят XenServer 6.2, крутятся несколько серверов, среди них Убунта с несколькими шарами, 3,5 миллиона файлов, 1,5 Тб данн...
Если у вас есть интернет-магазин и вы принимаете платежи через Интернет, то с 01 июля 2017 года у вас есть онлайн-касса.