Компиляция вложенных классов: javac и ecj

в 5:13, , рубрики: ecj, inner class, java, javac, nested class, байткод, компиляция, метки: , , ,

Как известно, в языке Java существуют вложенные (nested) классы, объявленные внутри другого класса. Их даже четыре разновидности — статические вложенные, внутренние (inner), локальные (local) и анонимные (anonymous) (в этой статье мы не затрагиваем лямбда-выражения, появившиеся в Java 8). Всех их объединяет одна интересная особенность: виртуальная машина Java не имеет понятия об особенном статусе этих классов. С её точки зрения это обычные классы, расположенные в том же пакете, что и внешний класс. Вся работа по преобразованию вложенных классов в обычные ложится на компилятор. И здесь любопытно посмотреть, как разные компиляторы с ней справляются. Мы посмотрим на поведение javac 1.8.0.20 и компилятора ecj из Eclipse JDT Core 3.10 (идёт в комплекте с Eclipse Luna).

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

  • Права доступа;
  • Передача ссылки на объект внешнего класса (неактуально для статических вложенных классов);
  • Передача локальных переменных из внешнего контекста (похоже на замыкание).

В этой статье поговорим о первых двух проблемах.

Права доступа

С правами доступа возникает большая морока. Мы можем объявить поле или метод вложенного класса как private, и согласно спецификации Java, к этому полю или методу всё равно можно обращаться из внешнего класса. Можно и наоборот: обратиться к private-полю или методу внешнего класса из вложенного, либо из одного вложенного класса использовать другой. Однако с точки зрения Java-машины обращаться к приватным членам другого класса недопустимо. То же самое касается доступа к защищённым членам родительского класса, расположенного в другом пакете. Чтобы обойти это ограничение, компиляторы создают специальные методы доступа. Они все статические, имеют доступ package-private и называются, начиная с access$. Причём ecj называет их просто access$0, access$1 и т. д., а javac добавляет минимум три цифры, где последние две кодируют конкретную операцию (чтение = 00, запись = 02), а начальные — поле или метод. Методы доступа требуются для чтения полей, записи полей и вызова методов.

Методы доступа для чтения полей имеют один параметр — объект, а методы для записи полей — два параметра (объект и новое значение). При этом в ecj методы записи возвращают void, а в javac — новое значение (второй параметр). Возьмём для примера такой код:

public class Outer {
  private int a;

  static class Nested {
    int b;

    void method(Outer i) {
      b = i.a;
      i.a = 5;
    }
  }
}

Если байткод, сгенерированный Javac, перевести назад на Java, получится примерно такое:

public class Outer {
  private int a;

  static int access$000(Outer obj) {
    return obj.a;
  }

  static int access$002(Outer obj, int val) {
    return (obj.a = val);
  }
}

class Outer$Nested {
  int b;

  void method(Outer i) {
    b = Outer.access$000(i);
    Outer.access$002(i, 5);
  }
}

Код ecj похож, только методы называются access$0, access$1 и второй возвращает void. Всё станет существенно проще, если вы уберёте слово private: тогда методы доступа не потребуются и к полям можно будет обращаться напрямую.

Интересно, что javac ведёт себя умнее при инкременте поля. Например, скомпилируем такой код:

public class Outer {
  private int a;

  static class Nested {
    void inc(Outer i) {
      i.a++;
    }
  }
}

Javac выдаст примерно следующее:

public class Outer {
  private int a;

  static int access$008(Outer obj) {
    return obj.a++;
  }
}

class Outer$Nested {
  void inc(Outer i) {
    Outer.access$008(i);
  }
}

Аналогичное поведение наблюдается при декременте (имя метода будет заканчиваться на 10), а также при преинкременте и предекременте (04 и 06). Компилятор ecj во всех этих случаях сперва вызовет метод чтения, затем добавит или вычтет единицу и вызовет метод записи. Если кому-то интересно, куда делись нечётные номера, они будут использоваться при прямом доступе к защищённым полям родителя внешнего класса (например, Outer.super.x = 2, не представляю, где бы это могло пригодиться!).

Кстати, любопытно, что javac 1.7 вёл себя ещё умнее, генерируя специальные методы для любых операций присваивания типа +=, <<= и т. д. (правая часть вычислялась и передавалась в сгенерированный метод отдельным параметром). Специальный метод генерировался, даже если вы к недоступному строковому полю применяли +=. В javac 1.8 этот функционал сломался, причём похоже, что по случайности: соответствующий код присутствует в исходниках компилятора.

Если сам программист создаст метод с соответствующей сигнатурой (например, access$000, никогда так не делайте!), javac откажется компилировать файл, выдав сообщение «the symbol (метод) conflicts with a compiler-synthesized symbol in (класс)». Компилятор ecj спокойно переносит конфликты, просто увеличивая счётчик, пока не найдёт свободное имя метода.

При попытке вызвать недоступный метод создаётся вспомогательный статический метод, который имеет те же параметры и возвращаемый тип, только добавляется дополнительный параметр для передачи объекта. Более интересная ситуация — это использование приватного конструктора. При конструировании объекта вы обязаны вызвать именно конструктор. Поэтому компиляторы генерируют новый неприватный конструктор, который вызывает нужный приватный. Как создать конструктор, который точно по сигнатуре не конфликтует с существующими? Javac для этой цели генерирует новый класс! Возьмём такой код:

public class Outer {
  private Outer() {}
  
  class Nested {
    void create() {
      new Outer();
    }
  }
}

При компиляции будет создан не только Outer.class и Outer$Nested.class, но ещё один класс Outer$1.class. Код, созданный компилятором, выглядит примерно так:

public class Outer {
  private Outer() {}

  Outer(Outer$1 ignore) {
    this();
  }
}

class Outer$1 {} // в этом классе нет вообще конструктора, даже приватного, его никак не инстанциировать

class Outer$Nested {
  void create() {
    new Outer((Outer$1)null);
  }
}

Решение удобное в том плане, что конфликта по сигнатуре конструктора гарантировано не будет. Компилятор ecj же решил обойтись без лишнего класса и добавить фиктивным параметром тот же класс:

public class Outer {
  private Outer() {}

  Outer(Outer ignore) {
    this();
  }
}

class Outer$Nested {
  void create() {
    new Outer((Outer)null);
  }
}

В случае конфликта с существующим конструктором добавляются новые фиктивные параметры. Например, у вас есть три конструктора:

  private Outer() {}
  private Outer(Outer i1) {}
  private Outer(Outer i1, Outer i2) {}

Если вы каждым из них воспользуетесь из вложенного класса, ecj создаст три новых, у которых будет три, четыре и пять параметров Outer.

Передача ссылки на объект внешнего класса

Внутренние классы (включая локальные и анонимные) привязаны к конкретному объекту внешнего класса. Чтобы этого достичь, во внутренний класс компилятором добавляется новое final-поле (обычно с именем this$0), которое содержит ссылку на окружающий класс. При этом в каждый конструктор добавляется соответствующий параметр. Если взять такой простой код:

public class Outer {
  class Nested {}
  
  void test() {
    new Nested();
  }
}

Компиляторы (здесь поведение ecj и javac похоже) превратят этот код примерно в такой (напоминаю, что это я вручную по байткоду восстанавливаю, чтобы понятнее было):

public class Outer {
  void test() {
    new Outer$Nested(this);
  }
}

class Outer$Nested {
  final Outer this$0;

  Outer$Nested(Outer obj) {
    this.this$0 = obj;
    super();
  }
}

Любопытно, что присваивание this$0 происходит перед вызовом конструктора родительского класса. В обычном Java-коде вы не можете присвоить значение в поле до выполнения родительского конструктора, но байткод этому не препятствует. Благодаря этому, если вы переопределите метод, вызванный конструктором родительского класса, this$0 у вас уже будет инициализирован и вы сможете легко обращаться к полям и методам внешнего класса.

Если создать конфликт по имени, заведя в классе Nested поле с именем this$0 (никогда так не делайте!), это не смутит компиляторы: они назовут своё внутреннее поле this$0$.

Язык Java разрешает создать экземпляр внутреннего класса не только на базе this, но и на базе другого объекта того же типа:

public class Outer {
  class Nested {}
  
  void test(Outer other) {
    other.new Nested();
  }
}

Здесь возникает интересный момент: ведь other может оказаться null. По хорошему вы должны упасть в этом месте с NullPointerException. Обычно виртуальная машина сама следит, чтобы вы не разыменовывали null, но здесь фактически разыменования не будет, пока вы не воспользуетесь внешним классом внутри объекта Nested, что может произойти гораздо позже или не произойти вообще. Компиляторам снова приходится выкручиваться: они вставляют фиктивный вызов, превращая код примерно в такой:

public class Outer {
  void test(Outer other) {
    other.getClass();
    new Outer$Nested(other);
  }
}

Вызов getClass() безопасен: для любого объекта должен успешно выполниться и занимает немного времени. Если оказалось, что в other null, исключение случится ещё до создания объекта Nested.

Если уровень вложенности классов больше одного, то в самых внутренних появляются новые переменные: this$1 и так далее. В качестве примера рассмотрим такое:

public class Outer {
  class Nested {
    class SubNested {
      {test();}
    }
  }
  
  void test() {
    new Nested().new SubNested();
  }
}

Здесь javac создаст примерно такой код:

public class Outer {
  void test() {
    Outer$Nested tmp = new Outer$Nested(this);
    tmp.getClass(); // явно излишне, но ладно
    new Outer$Nested$SubNested(tmp);
  }
}

class Outer$Nested {
  final Outer this$0;

  Outer$Nested(Outer obj) {
    this.this$0 = obj;
    super();
  }
}

class Outer$Nested$SubNested {
  final Outer$Nested this$1;

  Outer$Nested$SubNested(Outer$Nested obj) {
    this.this$1 = obj;
    super();
    this.this$1.this$0.test();
  }
}

Вызов getClass() можно было и убрать, раз мы только что создали этот объект, но компилятор не заморачивается. А вот ecj вообще неожиданно сгенерировал access-метод:

class Outer$Nested {
  final Outer this$0;

  Outer$Nested(Outer obj) {
    this.this$0 = obj;
    super();
  }

  static Outer access$0(Outer$Nested obj) {
    return obj.this$0;
  }
}

class Outer$Nested$SubNested {
  final Outer$Nested this$1;

  Outer$Nested$SubNested(Outer$Nested obj) {
    this.this$1 = obj;
    super();
    Outer$Nested.access$0(obj).test();
  }
}

Очень странно, учитывая, что this$0 не имеет флага private. С другой стороны, ecj догадался переиспользовать параметр obj вместо обращения к полю this.this$1.

Выводы

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

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

Автор: lany

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js