При работе с неизменяемыми типами данных, такими как 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]
, если такой естьпервый конструктор с наибольшим числом аргументов
Например, для записи:
[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:
Mean - Arithmetic mean of all measurements
Error - Half of 99.9% confidence interval
StdDev - Standard deviation of all measurements
Median - Value separating the higher half of all measurements (50th percentile)
Gen 0 - GC Generation 0 collects per 1000 operations
Allocated - Allocated memory per single operation (managed only, inclusive, 1KB = 1024B)
1 ns - 1 Nanosecond (0.000000001 sec)
Производительность Immutype для class и struct будет близкой к производительности для record и record struct. Важно отметить, что значимые траты производительности при использовании статических методов Immutype для ссылочных типов - это проверка входных параметров на null.