Pull to refresh

Предотвращение рекурсии и ленивые вычисления в Java

Reading time5 min
Views5.2K
Не так давно, я написал небольшой класс для автоматического кэширования и обновления произвольных значений. Пользоваться им было очень легко — стоило лишь создать анонимный класс с перегруженным методом update, а потом, когда нужно, вызывать функции для пометки значения устаревшим и для получения самого значения:
  public static void main(String[] args)
  {
    LazyValue<Integer> ultimateQuestionOfLife = new LazyValue<Integer>()
    {
      @Override
      protected Integer update()
      {
        return findNewUltimateAnswer();
      }
    };
    
    // пометить устаревшим
    ultimateQuestionOfLife.invalidate();
    
    // вызовется update()
    System.out.println("Answer is: " + ultimateQuestionOfLife.get());
    
    // update() не вызовется, вернется кэшированное значение
    System.out.println("Answer is: " + ultimateQuestionOfLife.get());
    
    // пометить устаревшим
    ultimateQuestionOfLife.invalidate();
    
    // update() вызовется во второй раз
    System.out.println("Answer is: " + ultimateQuestionOfLife.get());
  }


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

  private static LazyValue<Integer> lv1 = new LazyValue<Integer>()
  {
    @Override
    protected Integer update()
    {
      return lv2.get(); // нарочно создаем губительную рекурсию
    }
  };

  private static LazyValue<Integer> lv2 = new LazyValue<Integer>()
  {
    @Override
    protected Integer update()
    {
      return lv1.get() + 1;
    }
  };

  public static void main(String[] args)
  {
    // хитроумный способ вызвать StackOverflowException
    System.out.println(lv2.get());
  }


Довольно быстро мне порядком надоело вставлять костыли в код, чтобы предотвратить рекурсию тут и там, и я решил встроить в класс кэширования детектор рекурсии, который бы сработав, разматывал стэк до «зачинщика» рекурсии и выдавал какое-то значение по-умолчанию тем самым предотвращая StackOverflowException. Вот какая получилась реализация:
  private static LazyValue<Integer> lv1 = new RSLazyValue<Integer>()
  {
    @Override
    protected Integer update()
    {
      return lv2.get();
    }

    @Override
    protected Integer getDefault()
    {
      return 0;
    }
  };

  private static LazyValue<Integer> lv2 = new RSLazyValue<Integer>()
  {
    @Override
    protected Integer update()
    {
      return lv1.get() + 1;
    }

    @Override
    protected Integer getDefault()
    {
      return 10;
    }
  };

  public static void main(String[] args)
  {
    // выведет 10
    System.out.println(lv2.get());
  }


Вся магия кроется в классе RSLazyValue (RS — это Recursion-Safe). В тот момент, когда обновление началось, ставится специальный флаг, который потом обязательно по окончанию обновления снимается. Если мы только что зашли в ту же функцию, а обновление в процессе, значит мы поймали рекурсию за хвост и надо с ней что-то делать. Решение в лоб — сразу вернуть значение по-умолчанию. Однако это не лучший способ, ведь тогда обработанное значение вернется в первый вызов функции и она вернет значение, основанное на своем же, только обработанном извне. Наиболее подходящий вариант — выкинуть исключение, которое размотает стэк вызовов до предыдущей «ипостаси» этой же функции и вернуть значение по-умолчанию оттуда. Вот как это работает:
  public ValueType get()
  {
    // вызов update() находится ниже по коду, следовательно
    // выполнение этого условия означает, что get() косвенно
    // или напрямую был вызван из update()
    if (updateInProgress)
      throw new RecursionDetectedException(this);

    // выполняем update() только если значение устарело
    if (isInvalid)
    {
      // этот флаг обязательно нужно снять по выходу из update()
      updateInProgress = true;
      try
      {
        // выполняем обновление
        value = update();

        // isInvalid станет false только если update()
        // выполнился штатно без эксепшенов
        isInvalid = false;
      }
      catch (RecursionDetectedException e)
      {
        if (e.getRecursionStarter() != this)
          throw e; // выкидываем исключение дальше, если рекурсию начали не мы
        else
          return getDefault(); // возвращаем значение по-умолчанию
      }
      finally
      {
        // снимаем флаг в любом случае, как бы не завершилась get()
        updateInProgress = false;
      }
    }

    return value;
  }


Решение позволило в большом проекте сохранить массу времени. Как времени программиста, так и процессорного. Экономия производительности состоит в том, что данные рассчитываются только по запросу, и если запроса так и не произошло, то следовательно эти данные никому понадобились и считать их не нужно. Надеюсь эти 2 маленьких класса позволят сэкономить и ваше время. Буду рад конструктивной критике и случаям, при которых «ничего не работает».

Полный код классов:
LazyValue.java
RSLazyValue.java
Tags:
Hubs:
Total votes 47: ↑38 and ↓9+29
Comments38

Articles