Не так просто, как кажется

Мой коллега Кевин работает (помимо всего прочего) над механизмом рефакторингов для C# IDE. В конце прошлого года мы с ним обсуждали возможные варианты рефакторинга «устранить переменную» (eliminate variable). Мне кажется, что вам должно быть интересным взглянуть на те проблемы, с которыми ежедневно сталкивается команда разработчиков IDE.

Прежде всего, что за рефакторинг «устранить переменную»? Он весьма прост, но как мы вскоре увидим, первое впечатление может быть обманчивым. Предположим у вас есть локальная переменная, которая инициализируется при ее объявлении, ее значение никогда не изменяется, и есть только одно место ее использования. (В общем случае нужно заменить все места использования; однако для простоты, давайте рассмотрим случай, когда переменная используется только в одном месте.) Например:

 void M()
{
    int x = 2 + R();
    Q();
    N(x);
}

Вы можете избавиться от локальной переменной изменив код на:

 void M()
{
    Q();
    N(2 + R());
}

Однако теперь, очевидно, изменилась семантика этого кода. Если метод Q() сгенерирует исключение, то теперь метод R() никогда не будет вызван, хотя до этого он вызывался всегда. Если метод Q() использует побочные эффекты метода R(), то после рефакторинга это поведение изменится. И т.д.; существует множество вариантов, когда этот код не будет эквивалентным. Мы предполагаем, что пользователь готов пойти на эти изменения; именно поэтому он использует этот рефакторинг.

Как, на высоком уровне, вы бы это реализовали? Кажется все просто:

  • Найти текст инициализатора
  • Найти единственное место использования (IDE уже знает, как это сделать)
  • Заменить текст использования переменной на текст инициализатора
  • Удалить объявление переменной и ее инициализацию

Последний шаг может быть не совсем простым, например, в следующем случае:

 int y, x = 2 + R();

Поскольку мы должны удалить запятую, объявление и инициализацию переменной x, но не удалять "int y;". Но это не сложно.

Также нужно подумать и о комментариях. Что должно произойти в следующем случае?

     // x is blah blah blah
    int x = 2 /* blahblah */ + R(); // Blah!
    Q();
    N(x);

Должен ли этот код быть преобразован к

     // x is blah blah blah
    /* blahblah */  // Blah!
    Q();
    N(2 + R());

Или все три комментария, перед, внутри и после объявления, должны быть вставлены туда, где использовалась переменная x? Обратите внимание, что при перемещении комментария, который идет после объявления переменной x, этот комментарий должен быть изменен на встроенный комментарий (inline comment), или в месте вызова метода должна быть добавлена новая строка. Кроме того, в комментарии идет речь о локальной переменной, которой уже нет, что будет сбивать с толку читателя.

Давайте предположим, что мы можем решить эти проблемы. Все, мы справились? Давайте воспользуемся нашим рефакторингом для нескольких тестовых сценариев. В каждом случае, мы будем удалять переменную “x”. Код:

     const short s = 123;
    int x = s;
    N(x);

будет преобразован в

     const short s = 123;
    N(s);

Все нормально? Не обязательно. Что если метод N содержит две перегруженные версии, одну для int, а вторую для short? До этого мы вызвали N(int); а теперь будем вызывать N(short)! Это не просто изменение порядка возникновения побочных эффектов, теперь наш рефакторинг изменил результат разрешения перегрузки методов, что кажется неправильным. На самом деле, мы должны сгенерировать следующий код:

     const short s = 123;
    N((int)s);

Хорошо, это правильное преобразование? Давайте попробуем другие сценарии тестирования.

     int x = 123;
    N(ref x);

Преобразуется… во что? Мы не может записать N(ref 123) или даже N(ref (int) 123).

В этом случае, рефакторинг должен завершится неудачно. Нельзя устранить переменную, если ссылка на нее передается в другую функцию.

А как насчет этого кода?

     int x = R() + S();
    N(Q() * x);

Он преобразовывается в

     N(Q() * R() + S());

Упс, из-за приоритетов операторов, мы изменили семантику программы. Мы должны получить

     N(Q() * (R() + S()));

А как насчет такого?

     Func<int, int> x = z=>z+1;
    object n = x;

Будет преобразован в:

     object n = z=>z+1;

Нет; мы не можем неявно преобразовывать лямбда-выражение ни к чему другому кроме делегата или дерева выражений (expression tree). Этот код должен быть преобразован в

     object n = (Func<int, int>)(z=>z+1);

Ух ты. Так что мы узнали? При устранении выражения T x = expr, существуют следующие сценарии:

Ни делать ничего; просто выдать ошибку

  • Заменить на expr
  • Заменить на (expr)
  • Заменить на (T)expr
  • Заменить на (T)(expr)

Задание читателю: найти сценарии, когда ни один из этих вариантов не подходит. Сможете ли найти сценарии, в которых x нужно заменять на ((T)expr)? Или на ((T)(expr))? Последний вариант оказался худшим случаем, который я смог легко найти; я ничего не упустил?

Понятно, что мы не собираемся реализовывать этот рефакторинг в будущих версиях Visual Studio, если каждый раз для устранения переменной компилятор будет вставлять четыре скобки и приведение типа; нам нужны эвристики, которые подскажут нам, какая из приведенных выше форм нам нужна. Это на удивление хитрая проблема в анализе языков!

И это лишь те проблемы, которые отыскали мы с Кевином за несколько минут рисования на доске; вполне возможно, что при более детальном анализе мы бы нашли и более интересные проблемы.

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

Оригинал статьи