Остерегайтесь записей с выражениями и вычисляемыми свойствами


Вступление

С момента появления этой функции я часто использую записи в C#, возможно, даже слишком часто 😅.

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

Запись с вычисляемым свойством

В качестве примера рассмотрим следующую запись:

public record SomeRecordWithCalculatedProperty(string SomeValue)
{
    public string SomeCalculatedValue { get; } = SomeValue + " *calculated*";
}
Вход в полноэкранный режим Выход из полноэкранного режима

Здесь происходит то, что мы используем первичный конструктор для объявления и инициализации свойства SomeValue, но у нас также есть другое свойство, SomeCalculatedProperty, которое вычисляется с помощью SomeValue. Кажется, все достаточно просто, верно?

Теперь давайте посмотрим на следующее использование этой записи:

var x = new SomeRecordWithCalculatedProperty("This is some value");
Console.WriteLine(x);
var y = x with {SomeValue = "This is another some value"};
Console.WriteLine(y);
Вход в полноэкранный режим Выход из полноэкранного режима

Итак, мы создаем запись, выводим ее на консоль, затем с помощью выражения with создаем копию этой записи, изменяя свойство SomeValue.

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

Это следующее (новые строки добавлены для удобства чтения):

SomeRecordWithCalculatedProperty 
{ 
    SomeValue = This is some value,
    SomeCalculatedValue = This is some value *calculated*
}
SomeRecordWithCalculatedProperty 
{
    SomeValue = This is another some value, 
    SomeCalculatedValue = This is some value *calculated* 
}
Вход в полноэкранный режим Выход из полноэкранного режима

Можете ли вы заметить проблему?

SomeCalculatedValue во второй строке все еще имеет то же значение, что и в первой. Это имеет смысл, поскольку при использовании выражения with происходит клонирование записи и перезапись свойств, указанных в скобках.

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

Вычисление свойства на лету

Быстрый и простой способ исправить это — вместо того, чтобы вычислять значение свойства и устанавливать его, просто вычислять его на лету, например, так:

public record SomeRecordWithOnTheFlyCalculatedProperty(string SomeValue)
{
    public string SomeCalculatedValue => SomeValue + " *calculated*";
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Это решает проблему и может быть правильным решением в целом. Однако у него есть одна потенциальная проблема: каждый раз, когда мы используем SomeCalculatedValue, как метод, которым является аксессор get свойства, значение будет вычисляться. В зависимости от того, как используется свойство, это может быть не проблемой, а может быть проблемой, поскольку мы постоянно повторяем одну и ту же логику и создаем новые объекты для возврата. В моем случае я использовал свойство несколько раз, и логика вычисления была немного сложнее, чем в этом упрощенном примере, поэтому было бы неплохо избежать выполнения этого кода чаще, чем это необходимо.

Что если мы сделаем его ленивым

Предупреждение о спойлерах, вы, вероятно, увидите проблему, как только я опубликую фрагмент кода, но что, если мы сделаем его ленивым, используя тип Lazy<T>? Код будет выглядеть следующим образом:

public record SomeRecordWithLazilyCalculatedProperty(string SomeValue)
{
    private readonly Lazy<string> _someCalculatedValue = new(() => SomeValue + " *calculated*");

    public string SomeCalculatedValue => _someCalculatedValue.Value;
}
Вход в полноэкранный режим Выход из полноэкранного режима

Что вы думаете, это решение наших проблем? Подумайте об этом, прежде чем смотреть на результат ниже 😉.

SomeRecordWithLazilyCalculatedProperty
{
    SomeValue = This is some value,
    SomeCalculatedValue = This is some value *calculated*
}
SomeRecordWithLazilyCalculatedProperty
{
    SomeValue = This is another some value,
    SomeCalculatedValue = This is some value *calculated*
}
Вход в полноэкранный режим Выход из полноэкранного режима

Да, это то же самое. Проблема та же, но на этот раз вместо копирования свойства было скопировано поле подложки.

Если вам интересно, то с помощью инструмента декомпиляции (я использовал sharplab.io) мы можем увидеть сгенерированный конструктор копирования, который заботится о клонировании объекта:

protected SomeRecordWithLazilyCalculatedProperty(
[System.Runtime.CompilerServices.Nullable(1)] SomeRecordWithLazilyCalculatedProperty original)
{
    <SomeValue>k__BackingField = original.<SomeValue>k__BackingField;
    _someCalculatedValue = original._someCalculatedValue;
}
Вход в полноэкранный режим Выход из полноэкранного режима

Таким образом, если копируется поле lazy, которое уже было инициализировано, так как мы уже напечатали первый экземпляр записи, то его содержимое будет таким же в новом клонированном экземпляре.

Переопределение конструктора копирования

Давайте сделаем еще одну попытку решить эту проблему. Оставим ленивое поле, но переопределим конструктор копирования.

Конструктор копирования — это конструктор, который получает экземпляр того же типа, что и единственный параметр. Он должен быть защищенным, если запись не запечатана, и приватным в противном случае.

public record YetAnotherRecordWithLazilyCalculatedPropertyAndCopyCtor(string SomeValue)
{
    private readonly Lazy<string> _someCalculatedValue = new(() => SomeValue + " *calculated*");

    public string SomeCalculatedValue => _someCalculatedValue.Value;

    protected YetAnotherRecordWithLazilyCalculatedPropertyAndCopyCtor(
        YetAnotherRecordWithLazilyCalculatedPropertyAndCopyCtor original)
    {
        SomeValue = original.SomeValue;
        _someCalculatedValue = new(() => SomeValue + " *calculated*");
    }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Как мы видим, мы переопределяем конструктор копирования, и когда приходит время инициализировать _someCalculatedValue, вместо того, чтобы скопировать значение из оригинала, мы создаем новый экземпляр (но теперь определение записи не выглядит таким лаконичным 😭).

Решает ли это нашу проблему? Вывод:

YetAnotherRecordWithLazilyCalculatedPropertyAndCopyCtor
{
    SomeValue = This is some value,
    SomeCalculatedValue = This is some value *calculated*
}
YetAnotherRecordWithLazilyCalculatedPropertyAndCopyCtor
{
    SomeValue = This is another some value,SomeCalculate
    dValue = This is another some value *calculated*
}
Вход в полноэкранный режим Выход из полноэкранного режима

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

Обратите внимание, что это не сработало бы, если бы не лень, так как в момент вызова конструктора копирования мы еще не знаем, каким будет новое SomeValue.

Другие варианты

Конечно, есть и другие варианты решения этой проблемы.

Например, мы можем объявить SomeValue вне первичного конструктора и сделать его только getter, вместо getter и init setter, которые создаются при использовании первичного конструктора. Побочным эффектом будет то, что мы больше не сможем использовать выражение with для клонирования записи и изменения SomeValue.

Мы также можем вручную реализовать init setter для SomeValue, чтобы он заставлял пересчитывать SomeCalculatedValue.

Думаю, есть еще варианты, которые я сейчас не помню, но суть вы поняли.

Outro

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

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

Спасибо, что заглянули, cyaz! 👋

Оцените статью
devanswers.ru
Добавить комментарий