Методы без аргументов — зло в ООП, и вот как его полечить

от автора

Привет!

Идея в том, что бы использовать ленивые кешируемые свойства везде в неизменяемых объектах, где в обычном случае мы бы использовали процессорно тяжелые методы без аргументов. А статья — как это задизайнить и и зачем.

Обращение к ленивому свойству объекта наглядно

Дисклеймер

Этот подход не подойдет в случаях:
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 происходит обращение не только здесь.

Что нам хочется?

  1. Удобный дизайн кода для его пользователя. Например, я не хочу думать о кешировании при обращении к объекту, я просто хочу от него данные.
  2. Бесплатность обращения к свойству. Время я плачу только за первое обращение, и это никогда не хуже, чем вызов метода (обычно — почти как обращение к полю по стоимости).
  3. Удобный дизайн кода для его разработчика. Разрабатывая новый 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<Number> tripled; }

А вот в жааве

Можно было лучше, и насколько мне известно, в джаве это решается аттрибутом Cacheable. В шарпе недавно добавленные source-генераторы код изменять не могут, а значит такой же красоты мы по-любому не получим. Поэтому этот сэмпл — лучшее, к чему я смог прийти.

А вот как пишут обычно:

Подход 1 (да зачем нам кеш?):

public sealed record Number(int Value) {     public int Number Tripled => new Number(@this.Value * 3); }

(очень дорогой по очевидным причинам)

Подход 2 (используем Lazy<T>):

public sealed record Number : IEquatable<Number> {     public int Value { get; init; }  // приходится оверлоадить конструктор, поэтому выносим сюда     public int Number Tripled => tripled.Value;     private Lazy<Number> tripled;     public Number(int value)     {         Value = value;         tripled = new(() => value * 3);  // мы не можем это сделать в конструкторе поля, потому что на тот момент this-а еще не существует     }      // потому что Equals, который генерируется для рекордов, генерируется на основе полей, и поэтому наш Lazy<T> все сломает     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<Number, Number> 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, но даже если нам повезло, и кешируемый тип не совпадает с "холдером" — нам гарантированно придется заплатить временем за эту инициализацию, которая нам может и не понадобиться.

Решение

  1. Итак, начнем с того, что наш ленивый контейнер будет структурой. Зачем лишний раз кучить мучу мучить кучу?
  2. Equals и GetHashCode всегда будут возвращать true и 0 соответственно. Это убивает смысл этих методов, но этот контейнер нам нужен только ради кеша, а значит сам по себе не должен влиять на результаты сравнения двух рекордов или получения хеша. Таким образом, мы не обязаны оверрайдить Equals и GetHashCode для каждого рекорда, пусть об этом думает Рослин.
  3. Допустим любой тип в качестве кешируемого. Лочить будем по холдеру, то есть тому, в ком объявлен наш кеш.
  4. Фабрика передается не в конструкторе, а в методе GetValue, по тому же принципу, как у ConditionalWeakTable. Тогда не придется создавать конструктор и писать спаггети-код, как мы это делали с Lazy<T>.
  5. Чтобы не сломать замечательную операцию with, вместо переменной initialized мы будем сравнивать holder, и в случае изменения референса — запускаем фабрику снова.

Коду!

Для начала, так у нас выглядят поля и оверрайденные методы:

public struct FieldCache<T> : IEquatable<FieldCache<T>> {     private T value;     private object holder; // от этой штуки нам нужен ТОЛЬКО референс, смысла делать его generic нет     // как я уже говорил, сделано, чтобы рослиновский Equals не сломался от приватного поля     public bool Equals(FieldCache<T> _) => true;     public override int GetHashCode() => 0; }

И примитивная имплементация GetValue выглядит так:

public struct FieldCache<T> : IEquatable<FieldCache<T>> {         public T GetValue<TThis>(Func<TThis, T> 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<Number> tripled; }

Код очень короткий, и его можно найти на гитхабе.

Производительность

Единственное, что быстрее, чем наш наивный FieldCache — это встроенный Lazy<T>.

Method Mean
BenchFunction 4,599.1638 ns
Lazy 0.6717 ns
FieldCache 3.6674 ns
ConditionalWeakTable 25.0521 ns

BenchFunction — это какие-то сложные страшные вычисления, которые производились бы каждый раз при обращении к методу, поэтому мы хотим его кешировать. Другие три строчки занимают три разных подхода. Как видим, FieldCache<T> немного помедленнее, чем Lazy<T>.

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

Кратый TL;DR или выводы

Хотелка: ленивые кешируемые свойства неизменяемых объектов, зависящие от первичных свойств данных объектов.

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

ссылка на оригинал статьи https://habr.com/ru/post/529454/