Циклические реактивные зависимости

от автора

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

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

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

🚫 Unreal: Невозможно
💤 Infinite: Бесконечный цикл
🎰 Limbo: Произвольный результат
🌋 Fail: Вызывает ошибку

🚫 Unreal

Очень заманчиво сделать синтаксически невозможным создание циклов. Например, можно требовать при создании состояния, чтобы все его зависимости уже существовали. Обычно так делают push-библиотеки типа RxJS или Effector.

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

💤 Infinite

Некоторые библиотеки просто уходят в бесконечный цикл, постоянно обновляя одни и те же состояния.

Для Angular и React, например, это типичное поведение. Есть даже костыль — ограничение на количество пересчётов одного инварианта. Но об этом мы поговорим позже.

🎰 Limbo

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

В зависимости от порядка вычислений такой подход даёт разные результаты. То есть состояние не только оказывается несогласованным, но и поведение приложения становится нестабильным и начинает зависеть от погоды на Марсе.

🌋 Fail

Лучшее решение — обнаруживать цикл во время выполнения и выбрасывать исключение.

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

Практика разрезания циклов

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

class Converter extends Object {  @mem fahrenheit( fahrenheit?: number ) {    return fahrenheit ?? this.celsius() * 9/5 + 32  }  @mem celsius( celsius?: number ) {    return celsius ?? ( this.fahrenheit() - 32 ) * 5/9  }}
const conv = new Converterconv.fahrenheit(32) // 32 ✅conv.celsius()      // 0 ✅

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

const conv = new Converterconv.celsius() // Ошибка: Циклическая подписка ❌

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

class Fibonacci extends Object {  @mems static value( index: number ) {    if( index < 2 ) return 1    return this.value( index - 2 ) + this.value( index - 1 )  }}

Благодаря мемоизации даже тысячное число вычисляется мгновенно. Но цена — создание тысяч кэширующих атомов.

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

const conv = new Converterconv.fahrenheit(32) // 32 ✅conv.celsius()      // 0 ✅conv.celsius(32)    // 32 ✅conv.fahrenheit()   // 32 ❌

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

class Converter extends $mol_object2 {  @mem source( value = { celsius: 0 } ) {    return value  }  @mem fahrenheit( fahrenheit: number ) {    const source = this.source( fahrenheit?.valueOf && { fahrenheit } )    return source.fahrenheit ?? source.celsius * 9/5 + 32  }  @mem celsius( celsius: number ) {    const source = this.source( celsius?.valueOf && { celsius } )    return source.celsius ?? (source.fahrenheit - 32) * 5/9  }}
const conv = new Converterconv.celsius()     // 0 ✅conv.fahrenheit()  // 32 ✅conv.fahrenheit(0) // 0 ✅conv.celsius()     // -18 ✅conv.celsius(0)    // 0 ✅conv.fahrenheit()  // 32 ✅

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

А пока, подписывайтесь на что-нибудь, вступайте во что-то там, и держите руку на пульсе вот этого вот.

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