C# record: with и ссылочные типы

C# record: with и ссылочные типы
Photo by Wesley Tingey / Unsplash

Ключевое слово record в C# существует со времен C# 9, и вы либо любите его, либо ненавидите. Независимо от вашего мнения, при работе с ключевым словом with вы, скорее всего, допустите не совсем очевидную ошибку. Эта ошибка может привести к странному поведению системы и трудно диагностируемым ошибкам. А ошибки никому не нужны, верно?!

В этой статье мы рассмотрим проблему и способы ее решения.

Описание проблемы with с records

Достоинство типов record основано на неизменяемости, что позволяет разработчику инкапсулировать данные и передавать их, не опасаясь внесения нежелательных изменений. Ключевое слово with позволяет копировать существующую запись, не изменяя исходного значения, и тем самым сохранять четкое разделение между тем, что было, и тем, что может быть. Рассмотрим простой пример.

var one = new Simple(1);
var copy = one with { Number = one.Number + 1 };

Console.WriteLine(copy);

При выполнении приведенного выше кода получается следующий консольный вывод.

Simple { Number = 2 }

Попытка изменить Number напрямую приводит к возникновению исключения компиляции с сообщением.

Init-only property 'Simple.Number' can only be assigned in an object initializer, or on 'this' or 'base' in an instance constructor or an 'init' accessor

Теперь начинается настоящее веселье. Мы рассматривали типы значений, которые копировались во время выполнения. А как насчет ссылочных типов? Давайте добавим коллекцию в запись.

var item = new Item();

var newItem = item with { };
item.State.Add(1);
newItem.State.Add(2);

Console.WriteLine(item);
Console.WriteLine(newItem);

public record Item(List<int> State)
{
    public Item() : this(new List<int>())
    {
    }

    public override string ToString()
    {
        return $"Item {{ State = {string.Join(", ", State)} }}";
    }
}

Что вы ожидаете от State (не подглядывайте)? Вы были бы правы, если бы предположили, что в обоих случаях это будет 1, 2.

Item { State = 1, 2 }
Item { State = 1, 2 }

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

Как же решить эту проблему? Есть два пути.

Использование сеттеров свойств при копировании.

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

var item = new Item();

var newItem = item with { State = item.State.Append(2).ToList() };
item.State.Add(1);

Console.WriteLine(item);
Console.WriteLine(newItem);

В результате мы получили две разные коллекции.

Item { State = 1 }
Item { State = 2 }

Такой подход допустим, но требует усилий при использовании ключевого слова with.

Другой подход - использование "конструктора копий". Конструктор копирования — это метод, который берет экземпляр в известной иерархии наследования и позволяет принимать решения в процессе создания. Давайте изменим наше определение записи, добавив конструктор копирования, который создает новый экземпляр коллекции. Ключевое слово with указывает среде выполнения на необходимость поиска конструктора копирования в вашем определении перед копированием значений.

var item = new Item();

var newItem = item with { };
item.State.Add(1);
newItem.State.Add(2);
Console.WriteLine(item);
Console.WriteLine(newItem);

public record Item(List<int> State)
{
    public Item() : this(new List<int>())
    {
    }

    // конструктор копирования
    protected Item(Item oldItem)
    {
        State = new List<int>(oldItem.State);
    }

    public override string ToString()
    {
        return $"Item {{ State = {string.Join(", ", State)} }}";
    }
}

При выполнении предыдущего кода результат показывает две отдельные коллекции для двух экземпляров записей.

Item { State = 1 }
Item { State = 2 }

У вас есть два приемлемых подхода для работы со ссылочными типами в record.

Другой вариант, который следует рассмотреть, — это использование неизменяемых ссылочных типов с самого начала. В данном примере можно использовать IReadOnlyList вместо List прямо в конструкторе записи.

Вывод

Хотя тип record имеет синтаксическое значение в C#, под капотом он представляет собой просто специализированную реализацию класса. Концепции типов значений и ссылочных типов по-прежнему применимы. Помнить о том, какие значения вы добавляете в свои реализации, очень важно, поскольку неправильное копирование записей может привести к странным ошибкам. Я рекомендую большинству пользователей, использующих record типы, как можно раньше реализовать конструктор копирования, чтобы не забывать о том, что процесс копирования должен быть преднамеренным.

Я надеюсь, что эта статья была вам полезна, и благодарю вас за то, что вы поделились ею с друзьями и коллегами. Будьте здоровы :)

Оригинал статьи вы можете прочитать по ссылке.