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

Як відомо, в мові 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: в цьому випадку компілятор обійдеться без автогенерированных методів. Звичайно, сучасні віртуальні машини майже завжди їх заинлайнят, але все одно наявність цих методів потребує більше пам'яті, роздмухує пул констант класу, подовжує стек-трейсы і додає зайві кроки при налагодженні.

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

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.