Методы без аргументов — зло для неизменяемых объектов, и вот как его полечить
Привет!
Идея в том, что бы использовать ленивые кешируемые свойства везде, где в обычном случае мы бы использовали процессорно тяжелые методы без аргументов. А статья — как это задизайнить и и зачем.
Несмотря на то, что я должен сделать оговорку,
Этот подход не подойдет в случаях:
1) Если вы пишете что-нибудь сверхбыстрое, и красивый код — последнее, о чем думаете
2) Если ваши объекты никогда не используются дважды (например, беспрекословно соблюдается SRP)
3) Если вы настолько ненавидете свойства, что код их содержащий в ваших глазах покрывается блюром
мне очень нравится подход, которым я хочу поделиться в этой заметке, и я считаю, что такой дизайн удобен как авторам кода, так и его пользователям.
TL; DR в самом низу.
Почему зло?
Приведу утрированный пример. Предположим, у нас есть неизменяемый рекорд Integer
, определенный следующим образом:
public sealed record Integer(int Value);
У него есть одно свойство Value
типа int
. Теперь, нам понадобился следующий метод:
public sealed record Integer(int Value)
{
public Integer Triple() => new Integer(Value * 3);
}
Каждый раз при необходимости утроить инстанс нашего числа, придется вызывать этот метод, и брать на себя ответственность за кеширование. Например, придется писать
public int SomeMethod(Integer number)
{
var tripled = number.Triple();
if (tripled.Value > 5)
return tripled.Value;
else
return 1;
}
Вместо того, что бы писать
public int SomeMethod(Integer number)
=> number.Tripled > 5 ? number.Tripled.Value : 1;
Красивее, короче, читабельнее, безопаснее. Потенциально, оно также быстрее, если у нас к одному и тому же Tripled
происходит обращение не только здесь.
Что нам хочется?
- Удобный дизайн кода для его пользователя. Например, я не хочу думать о кешировании при обращении к объекту, я просто хочу от него данные.
- Бесплатность обращения к свойству. Время я плачу только за первое обращение, и это никогда не хуже, чем вызов метода (обычно — почти как обращение к полю по стоимости).
- Удобный дизайн кода для его разработчика. Разрабатывая новый immutable object, я не хочу оверрайдить конструктор,
Equals
иGetHashCode
рекорда просто потому, что я добавил какое-то приватное поле для кеша, которое внезапно ломает мне все сравнения.
Я уже привел пример того, насколько удобнее свойства чем методы, в очень простом случае. А как разработчик объекта, я хочу писать так:
public sealed record Number(int Value)
{
public int Number Tripled => tripled.GetValue(@this => new Number(@this.Value * 3), @this);
private FieldCache tripled;
}
Можно было лучше, и насколько мне известно, в джаве это решается аттрибутом Cacheable
. В шарпе недавно добавленные source-генераторы код изменять не могут, а значит такой же красоты мы по-любому не получим. Поэтому этот сэмпл — лучшее, к чему я смог прийти.
А вот как пишут обычно:
Подход 1 (да зачем нам кеш?):
public sealed record Number(int Value)
{
public int Number Tripled => new Number(@this.Value * 3);
}
(очень дорогой по очевидным причинам)
Подход 2 (используем Lazy
):
public sealed record Number : IEquatable
{
public int Value { get; init; } // приходится оверлоадить конструктор, поэтому выносим сюда
public int Number Tripled => tripled.Value;
private Lazy tripled;
public Number(int value)
{
Value = value;
tripled = new(() => value * 3); // мы не можем это сделать в конструкторе поля, потому что на тот момент this-а еще не существует
}
// потому что Equals, который генерируется для рекордов, генерируется на основе полей, и поэтому наш Lazy все сломает
public bool Equals(Number number) => Value == number.Value;
// то же самое с GetHashCode
public override int GetHashCode() => Value.GetHashCode();
}
Как мы видим, очень сложно и неадекватно становится дизайнить наш объект. А что если там не одно кешируемое свойство, а несколько? За всем придется следить, включая все оверрайды.
Более того, у нас перестанет работать with
, который клонирует все ваши поля, кроме указанного (-ых). Ведь он скопирует и ваше поле с Lazy
, в котором будет лежать уже неверный кеш.
Подход 3 (используем ConditionalWeakTable
):
public sealed record Number
{
public Number Tripled => tripled.GetValue(this, @this => new Integer(@this.Value * 3));
private static ConditionalWeakTable tripled = new();
}
Наиболее адекватное решение среди прочих. Но для него придется писать обертку над ValueType так как ConditionalWeakTable
принимает только референс-тип. Поэтому такая штука существенно медленнее, чем что-то подобное без оверхеда (по моему бенчмарку получается разница в, по меньшей мере, 6 раз, по сравнению с типом, о котором я расскажу).
Подход 4 (сразу посчитать):
public sealed record Number
{
public int Value { get; init; }
public Number Tripled { get; }
public Number(int value)
{
Value = value;
Tripled = new Number { Value = value * 3 };
}
}
В конкретно этом случае это вообще даст stackoverflow
, но даже если нам повезло, и кешируемый тип не совпадает с «холдером» — нам гарантированно придется заплатить временем за эту инициализацию, которая нам может и не понадобиться.
Решение
- Итак, начнем с того, что наш ленивый контейнер будет структурой. Зачем лишний раз кучить мучу мучить кучу?
Equals
иGetHashCode
всегда будут возвращатьtrue
и0
соответственно. Это убивает смысл этих методов, но этот контейнер нам нужен только ради кеша, а значит сам по себе не должен влиять на результаты сравнения двух рекордов или получения хеша. Таким образом, мы не обязаны оверрайдитьEquals
иGetHashCode
для каждого рекорда, пусть об этом думает Рослин.- Допустим любой тип в качестве кешируемого. Лочить будем по холдеру, то есть тому, в ком объявлен наш кеш.
- Фабрика передается не в конструкторе, а в методе
GetValue
, по тому же принципу, как уConditionalWeakTable
. Тогда не придется создавать конструктор и писать спаггети-код, как мы это делали сLazy
. - Чтобы не сломать замечательную операцию
with
, вместо переменнойinitialized
мы будем сравниватьholder
, и в случае изменения референса — запускаем фабрику снова.
Коду!
Для начала, так у нас выглядят поля и оверрайденные методы:
public struct FieldCache : IEquatable>
{
private T value;
private object holder; // от этой штуки нам нужен ТОЛЬКО референс, смысла делать его generic нет
// как я уже говорил, сделано, чтобы рослиновский Equals не сломался от приватного поля
public bool Equals(FieldCache _) => true;
public override int GetHashCode() => 0;
}
И примитивная имплементация GetValue
выглядит так:
public struct FieldCache : IEquatable>
{
public T GetValue(Func factory, TThis @this) where TThis : class // record - это тоже класс. А ограничение нужно, чтобы тип был референсным
{
// если холдер изменился ИЛИ еще не записывался (например, если он - null)
if (!ReferenceEquals(@this, holder))
lock (@this)
{
if (!ReferenceEquals(@this, holder))
{
// мы передаем в фабрику, потому что наш FieldCache нужен для случаев, когда какие-то кешируемые проперти зависят ТОЛЬКО от полей нашего самого холдера. Можно, конечно, и захватить в передаваемой лямбде, но тогда будет реаллокация каждый раз
value = factory(@this);
holder = @this;
}
}
return value;
}
}
Таким образом, мы можем себе позволить такой дизайн:
public sealed record Number(int Value)
{
public int Number Tripled => tripled.GetValue(@this => new Number(@this.Value * 3), @this);
private FieldCache tripled;
}
Код очень короткий, и его можно найти на гитхабе.
Производительность
Единственное, что быстрее, чем наш наивный FieldCache
— это встроенный Lazy
.
BenchFunction
— это какие-то сложные страшные вычисления, которые производились бы каждый раз при обращении к методу, поэтому мы хотим его кешировать. Другие три строчки занимают три разных подхода. Как видим, FieldCache
немного помедленнее, чем Lazy
.
Я считаю, что так как он все равно занимает не очень много времени, во многих местах адекватный дизайн будет лучше, чем пару сэкономленных наносекунд.
Кратый TL; DR или выводы
Хотелка: ленивые кешируемые свойства неизменяемых объектов, зависящие от первичных свойств данных объектов.
И известные существующие подходы, по всей видимости, не дают это красиво сделать, поэтому приходится писать свое.