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

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

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

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

? Unreal

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

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

? Infinite

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

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

? Limbo

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

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

? Fail

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

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

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

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

class Converter extends Object {

  @mem fahrenheit( fahrenheit ) {
    return fahrenheit ?? this.celsius() * 9/5 + 32
  }

  @mem celsius( celsius ) {
    return celsius ?? ( this.fahrenheit() - 32 ) * 5/9
  }

}
const conv = new Converter

conv.fahrenheit(32) // 32 ✅
conv.celsius()      // 0 ✅

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

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

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

class Fibonacci extends Object {

  @mems static value( index ) {
    if( index < 2 ) return 1
    return this.value( index - 2 ) + this.value( index - 1 )
  }

}

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

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

const conv = new Converter

conv.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 ) {
    const source = this.source( fahrenheit?.valueOf && { fahrenheit } )
    return source.fahrenheit ?? source.celsius * 9/5 + 32
  }

  @mem celsius( celsius ) {
    const source = this.source( celsius?.valueOf && { celsius } )
    return source.celsius ?? (source.fahrenheit - 32) * 5/9
  }

}
const conv = new Converter

conv.celsius()     // 0 ✅
conv.fahrenheit()  // 32 ✅

conv.fahrenheit(0) // 0 ✅
conv.celsius()     // -18 ✅

conv.celsius(0)    // 0 ✅
conv.fahrenheit()  // 32 ✅

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

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

Комментарии (10)


  1. funca
    21.07.2026 20:58

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

    Температура она что в фаренгейтах, что в кельвинах обозначает буквально одно и то же физическое состояние. Модель старается выразить эту идею. Но пытается ее смоделировать каким-то замороченным способом - в виде двух отдельных свойств-состояний с квантовой запутанностью рекурсивными зависимостями между ними. Разработчику стало скучно и он решил поднасыпать себе сложностей на ровном месте?

    В итоге вы приходите к решению с одним source вместо двух, что логично - температура же одна. Но выбранная архитектура продолжает играть против вас. Количество условных ветвлений в каждой строчке буквально кричит об этом своей избыточностью для такой простой задачи. Хорошо, что фреймворк и язык здесь помогают срезать углы, просто насколько этого хватит?

    Попробуйте добавить поддержку ещё одной единицы измерения - кельвинов - и посмотрите в каких местах будет двигаться код. А конвертор для сотни валют получится сделать так же?


    1. nin-jin Автор
      21.07.2026 20:58

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


      1. funca
        21.07.2026 20:58

        По-моему реактивность это про значения, которые меняются во времени. В вашей задаче такое только одно - это температура (пара вида [valve: T, measure: M]). Для решения достаточно одного Subject<[T, M]> и чистой функции, которая преобразует значение в заданную единцу измерения convertTo(M, [T, M]) -> [T, M].


  1. andres_kovalev
    21.07.2026 20:58

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

    В эффекторе связи принято создавать через sample(), т.е. в любой момент после создания сущностей и таким образом можно сущность даже саму на себя зациклить:

    sample({
       source: $source,
       fn: (...) => { ... },
       target: $source
    });


    1. nin-jin Автор
      21.07.2026 20:58

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


      1. andres_kovalev
        21.07.2026 20:58

        Зависит от того, что в функции маппинга и что передано в source/target. Если значения после мапа получаются не эквивалентные, то будет бесконечный цикл. Известная проблема, но авторы эффектора считают ее нерешаемой.


  1. domix32
    21.07.2026 20:58

    fahrenheit( fahrenheit )

    Это оочень странный и крайне дурной api. Принимать фаренгейты для конвертации в фаренгейты, заодно конструировать фаренгейты пока конвертируешь в фаренгейты. Симметричнаяя структура и для цельсиев. Зачем там вообще ухищрения с кучей полей в разных метриках да ещё и кэшируемые? Казалось бы оставь какое-нибудь одно поле и кэшируй второе вычислимое поле. Сделай адекватный конструктор, а лучше два, чтобы точно знать какое из значений подаётся на вход и будет тебе счастье.


    1. nin-jin Автор
      21.07.2026 20:58

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


      1. domix32
        21.07.2026 20:58

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


        1. nin-jin Автор
          21.07.2026 20:58

          Какая прекрасная идея не хранить исходные данные, чтобы лепить костыли, дабы все преобразования были биекциями.