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

Привет!


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



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


Дисклеймер

Этот подход не подойдет в случаях:
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 или выводы


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


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

@WhiteBlackGoose
23.11.2020 20:19 UTC
Первоисточник

Комментарии

@WhiteBlackGoose
23.11.2020 15:29 UTC
+1

Сам FieldCache использую в своем проекте (через NuGet). Не уверен, что кому-то нужен сам пакет, потому что гениального тут ничего нет, и, вероятно, проще написать свой велосипед.


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


Примеры: InnerSimplified, Evaled.


Тут вызываются рекурсивные методы, которые в общем случае должны были бы обходить все дерево выражения. В данном случае, при первом обращении к свойству, вычисления будут происходить основываясь на свойствах детей. К примеру, если мы хоть раз обратились к свойству Evaled у выражения 2^5, то при обращении к этому же свойству у выражения 2^5 + 3 у нас не будет заново вычисляться 2^5. И это, по моему мнению, должно инкапсулироваться именно холдером, чтобы при обращении к свойству ни о чем думать не нужно было, и считать это обращение — бесплатным.

@
23.11.2020 20:11 UTC
0
НЛО прилетело и опубликовало эту надпись здесь
24.11.2020 02:47 UTC
0

Это и есть ленивая инициализация. Но так как у нас неизменяемые объекты, то и сами закешированные значения никогда не станут неверными

24.11.2020 10:15 UTC
0

Смотрите, может быть кейс когда их много и когда одно свойство ссылается на пару других. Ваш подход поощряет стиль написания кода, который может привести к такой ситуации.
Как тогда искать начало и конец инициализации?

24.11.2020 10:44 UTC
0
Смотрите, может быть кейс когда их много и когда одно свойство ссылается на пару других. Ваш подход поощряет стиль написания кода, который может привести к такой ситуации.

Да, верно. А вот что значит "искать начало и конец инициализации" — не очень понятно. Если вы хотите что-то отдебажить, достаточно изолировать ваш объект от других, и при первом обращении попадете туда, куда нужно. Хотя можно и проще: поставить условные бряки и когда-нибудь ваш метод вызывется.

@zhaparoff
23.11.2020 22:40 UTC
+3
Если честно, мне не совсем понятно почему нельзя сделать похожую обертку над Lazy, вместо того чтобы изобретать свой велосипед, который, будем откровенными, получился практически на порядок медленнее Lazy и содержит потенциальные проблемы в реализации double-checked locking анти-паттерна. Ну и, в добавок, lock(this) я тоже не назвал бы удачным ходом.
@WhiteBlackGoose
24.11.2020 02:49 UTC
0

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

24.11.2020 08:59 UTC
0

я перефразирую, не делайте плз так в продакшн коде, это плохая идея, по этим граблям рано или поздно ходит каждый разработчик на С#
смотрите в сторону отдельных функций это даст вам больше степеней свободы:
как их компоновать и где и как долго хранить результаты
это другая ответственность


PS я просто оставлю это тут
https://github.com/Aaronontheweb/FastAtomicLazy/tree/master/FastAtomicLazy

24.11.2020 09:07 UTC
0

Вы можете сказать, в чем грабли?


Сейчас я использую это в библиотеке. Вместо того, что бы думать, где запускать Eval(), а где нет, а где один раз запустить, я просто обращаюсь к одному свойству. Это сильно увеличило скорость разработки и производительность (потому что все результаты все время кешируются).


А ваших аргументов я пока не услышал.

24.11.2020 09:56 UTC
0

вот пример, не увернен насчет рекордов
https://codereview.stackexchange.com/questions/129081/property-caching
но как вы думаете почему после изобретения всех подходов для CachedProperty
автор вопроса всеравно пришел к стандартному?


 Lazy<int> LazyMul;
 public override int Mul => LazyMul.Value;
24.11.2020 10:33 UTC
0
24.11.2020 10:48 UTC
0

LazyInitializer требует референса на тип (или на булеву), ничто из чего я могу себе позволить в рекорде

24.11.2020 10:47 UTC
0

Я уже говорил, почему приватное поле с Lazy я не могу позволить себе. И я же в итоге делаю что-то похожее, просто лишенное недостатков классического подхода.


И на всякий случай скажу: меня верно подправили выше, это скорее ленивая инициализация, а не кеш, потому что в ленивой инициализации не надо ничего инвалидировать (в отличии от топика, что вы кинули).

@vassabi
24.11.2020 11:54 UTC
+3
скажите, а зачем вам lock (@this) если у вас тип неизменяемый?
Ну вызовет кто-то его два раза, в худшем случае где-то будет «нетот» трипл. Но у него будет тот же hash и то же value что и «правильного» трипла — не вижу в чем проблема (а зато выигрыш в скорости без лока и второй проверки можете сами прикинуть)
@WhiteBlackGoose
24.11.2020 12:00 UTC
-3

Лок у меня один раз вызывается. По сравнению с вызовом еще одного метода он почти бесплатный, так что почему нет?


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