Статьи

Вложенные классы и частные методы

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

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

Это становится интересным, когда мы смотрим на сгенерированный код. JVM не заботится о классах внутри других классов. Он имеет дело с классами высшего уровня JVM. Компилятор создаст .class которые будут иметь имя, подобное A$B.class когда у вас есть класс с именем B внутри класса A В B есть private метод, вызываемый из A после чего JVM видит, что код в A.class вызывает метод в A$B.class . JVM проверяет контроль доступа. Когда мы обсуждали это с юниорами, кто-то предположил, что, вероятно, JVM не заботится о модификаторе. Это неправда. Попробуйте скомпилировать A.java и B.java , два класса верхнего уровня с некоторым кодом в A вызывающем public метод в B Если у вас есть A.class и B.class измените метод в B.java , чтобы он был public чтобы он был private и перекомпилируйте B ta новый B.class . Запустите приложение, и вы увидите, что JVM очень заботится о модификаторах доступа. Тем не менее, вы можете вызвать в примере выше из A.class метод из A$B.class .

Чтобы разрешить этот конфликт, Java генерирует дополнительные синтетические методы, которые по своей природе являются общедоступными, вызывают оригинальный закрытый метод внутри того же класса и могут вызываться, если рассматривать управление доступом JVM. С другой стороны, компилятор Java не будет компилировать код, если вы выясните имя сгенерированного метода и попытаетесь вызвать его непосредственно из исходного кода Java. Я писал о деталях более 4 лет назад.

Если вы опытный разработчик, то вы, вероятно, думаете, что это странный и отвратительный взлом. Ява такая чистая, элегантная, лаконичная и чистая, кроме этого хака. А также, возможно, взлом кеша Integer который делает небольшие объекты Integer (типичные тестовые значения) равными, используя == тогда как большие значения равны только equals() но не == (типичные производственные значения). Но кроме синтетических классов и взлома кеша в Integer Java чист, элегантен, лаконичен и чист. (Вы можете понять, что я фанат Монти Пайтона.)

Причина этого в том, что вложенные классы не были частью оригинальной Java, они были добавлены только к версии 1.1. Решение было взломано, но в то время были более важные вещи, такие как внедрение JIT-компилятора, JDBC, RMI, отражение и некоторые другие вещи, которые мы принимаем сегодня как должное. Тогда не было вопроса, является ли решение красивым и чистым. Скорее вопрос был в том, выживет ли Java вообще и станет ли он основным языком программирования или умрет, и остается хорошей попыткой. В то время я все еще работал торговым представителем, а кодирование было лишь хобби, потому что в Восточной Европе работа по кодированию была скудной, в основном это были скучные бухгалтерские приложения и низкая зарплата. Это были немного другие времена, поисковая система называлась AltaVista, мы пили воду из-под крана, а у Java были разные приоритеты.

Следствием этого является то, что в течение более 20 лет у нас есть несколько большие JAR-файлы, немного медленное выполнение Java (если JIT не оптимизирует цепочку вызовов) и неприятные предупреждения в IDE, свидетельствующие о том, что у нас лучше использовать методы защиты пакетов во вложенных классах, а не private когда мы используем его из верхнего уровня или других вложенных классов.

Гнездо Хозяев

Теперь кажется, что этот 20-летний технический долг будет решен. Http://openjdk.java.net/jeps/181 попадает в Java 11, и он решит эту проблему, введя новое понятие: гнездо. В настоящее время байт-код Java содержит некоторую информацию об отношениях между классами. JVM имеет информацию о том, что определенный класс является вложенным классом другого класса, и это не только имя. Эта информация может помочь JVM принять решение о том, разрешен ли фрагмент кода в одном классе или нет доступа к private члену другого класса, но разработка JEP-181 имеет нечто более общее. С течением времени JVM больше не является виртуальной машиной Java. Ну, да, это, по крайней мере, название, однако, это виртуальная машина, которая выполняет байт-код, скомпилированный из Java. Или, кстати, из некоторых других языков. Существует много языков, нацеленных на JVM, и помня, что JEP-181 не хочет связывать новую функцию контроля доступа в JVM с определенной функцией языка Java.

JEP-181 определяет понятие NestHost и NestMembers как атрибуты класса. Компилятор заполняет эти поля, и когда есть доступ к закрытому члену класса из другого класса, тогда контроль доступа JVM может проверить: два класса находятся в одном гнезде или нет? Если они находятся в одном и том же гнезде, то доступ разрешен, иначе нет. У нас будут методы, добавленные к отражающему доступу, чтобы мы могли получить список классов, которые находятся в гнезде.

Пример простого гнезда

С помощью

1
2
3
4
$ java -version
java version "11-ea" 2018-09-25
Java(TM) SE Runtime Environment 18.9 (build 11-ea+25)
Java HotSpot(TM) 64-Bit Server VM 18.9 (build 11-ea+25, mixed mode)

Версия Java сегодня мы можем сделать уже эксперименты. Мы можем создать простой класс:

01
02
03
04
05
06
07
08
09
10
11
12
13
package nesttest;
public class NestingHost {
    public static class NestedClass1 {
        private void privateMethod() {
            new NestedClass2().privateMethod();
        }
    }
    public static class NestedClass2 {
        private void privateMethod() {
            new NestedClass1().privateMethod();
        }
    }
}

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

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
package nesttest;
 
import java.util.Arrays;
import java.util.stream.Collectors;
 
public class TestNest {
    public static void main(String[] args) {
        Class host = NestingHost.class.getNestHost();
        Class[] nestlings = NestingHost.class.getNestMembers();
        System.out.println("Mother bird is: " + host);
        System.out.println("Nest dwellers are :\n" +
                Arrays.stream(nestlings).map(Class::getName)
                      .collect(Collectors.joining("\n")));
    }
}

Распечатка, как и ожидалось:

1
2
3
4
5
Mother bird is: class nesttest.NestingHost
Nest dwellers are :
nesttest.NestingHost
nesttest.NestingHost$NestedClass2
nesttest.NestingHost$NestedClass1

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

Байт код

Компиляция с использованием компилятора JDK11 генерирует файлы

  • NestingHost$NestedClass1.class
  • NestingHost$NestedClass2.class
  • NestingHost.class
  • TestNest.class

Там нет изменений. С другой стороны, если мы посмотрим на байт-код с javap декомпилятора javap то увидим следующее:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
$ javap -v build/classes/java/main/nesttest/NestingHost\$NestedClass1.class
Classfile .../packt/Fundamentals-of-java-18.9/sources/ch08/bulkorders/build/classes/java/main/nesttest/NestingHost$NestedClass1.class
  Last modified Aug 6, 2018; size 557 bytes
  MD5 checksum 5ce1e0633850dd87bd2793844a102c52
  Compiled from "NestingHost.java"
public class nesttest.NestingHost$NestedClass1
  minor version: 0
  major version: 55
  flags: (0x0021) ACC_PUBLIC, ACC_SUPER
  this_class: #5                          // nesttest/NestingHost$NestedClass1
  super_class: #6                         // java/lang/Object
  interfaces: 0, fields: 0, methods: 2, attributes: 3
Constant pool:
 
*** CONSTANT POOL DELETED FROM THE PRINTOUT ***
 
{
  public nesttest.NestingHost$NestedClass1();
    descriptor: ()V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 6: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Lnesttest/NestingHost$NestedClass1;
}
SourceFile: "NestingHost.java"
NestHost: class nesttest/NestingHost
InnerClasses:
  public static #13= #5 of #20;           // NestedClass1=class nesttest/NestingHost$NestedClass1 of class nesttest/NestingHost
  public static #23= #2 of #20;           // NestedClass2=class nesttest/NestingHost$NestedClass2 of class nesttest/NestingHost

Если мы скомпилируем тот же класс, используя компилятор JDK10, то строки дизассемблирования будут следующими:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
$ javap -v build/classes/java/main/nesttest/NestingHost\$NestedClass1.class
Classfile /C:/Users/peter_verhas/Dropbox/packt/Fundamentals-of-java-18.9/sources/ch08/bulkorders/build/classes/java/main/nesttest/NestingHost$NestedClass1.class
  Last modified Aug 6, 2018; size 722 bytes
  MD5 checksum 8c46ede328a3f0ca265045a5241219e9
  Compiled from "NestingHost.java"
public class nesttest.NestingHost$NestedClass1
  minor version: 0
  major version: 54
  flags: (0x0021) ACC_PUBLIC, ACC_SUPER
  this_class: #6                          // nesttest/NestingHost$NestedClass1
  super_class: #7                         // java/lang/Object
  interfaces: 0, fields: 0, methods: 3, attributes: 2
Constant pool:
 
*** CONSTANT POOL DELETED FROM THE PRINTOUT ***
 
{
  public nesttest.NestingHost$NestedClass1();
    descriptor: ()V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #2                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 6: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Lnesttest/NestingHost$NestedClass1;
 
  static void access$100(nesttest.NestingHost$NestedClass1);
    descriptor: (Lnesttest/NestingHost$NestedClass1;)V
    flags: (0x1008) ACC_STATIC, ACC_SYNTHETIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method privateMethod:()V
         4: return
      LineNumberTable:
        line 6: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0    x0   Lnesttest/NestingHost$NestedClass1;
}
SourceFile: "NestingHost.java"
InnerClasses:
  public static #14= #6 of #25;           // NestedClass1=class nesttest/NestingHost$NestedClass1 of class nesttest/NestingHost
  public static #27= #3 of #25;           // NestedClass2=class nesttest/NestingHost$NestedClass2 of class nesttest/NestingHost

Компилятор Java 10 генерирует метод access$100 . Компилятор Java 11 этого не делает. Вместо этого в файле классов есть поле размещения вложенности. Мы наконец избавились от тех синтетических методов, которые вызывали сюрпризы, когда перечислили все методы в некотором рефлексивном коде платформы.

Взломать гнездо

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

01
02
03
04
05
06
07
08
09
10
11
12
13
14
package nesttest;
public class NestingHost {
//    public class NestedClass1 {
//        public void publicMethod() {
//            new NestedClass2().privateMethod(); /* <-- this is line 8 */
//        }
//    }
 
    public class NestedClass2 {
        private void privateMethod() {
            System.out.println("hallo");
        }
    }
}

мы также создаем простой тестовый класс

1
2
3
4
5
6
7
8
9
package nesttest;
 
public class HackNest {
 
    public static void main(String[] args) {
//        var nestling =new NestingHost().new NestedClass1();
//        nestling.publicMethod();
    }
}

Сначала удалите все // из начала строк и скомпилируйте проект. Это работает как шарм и печатает hallo . После этого скопируйте сгенерированные классы в безопасное место, например, в корень проекта.

1
2
$ cp build/classes/java/main/nesttest/NestingHost\$NestedClass1.class .
$ cp build/classes/java/main/nesttest/HackNest.class .

Давайте скомпилируем проект, на этот раз с комментариями, а затем скопируем два файла классов из предыдущей компиляции:

1
2
$ cp HackNest.class build/classes/java/main/nesttest/
$ cp NestingHost\$NestedClass1.class build/classes/java/main/nesttest/

Теперь у нас есть NestingHost который знает, что у него есть только один птенец: NestedClass2 . Однако тестовый код считает, что есть еще один птенец NestedClass1 и он также имеет открытый метод, который можно вызывать. Таким образом, мы пытаемся проникнуть в гнездо дополнительного птенца. Если мы выполним код, то получим ошибку:

1
2
3
4
$ java -cp build/classes/java/main/ nesttest.HackNest
Exception in thread "main" java.lang.IncompatibleClassChangeError: Type nesttest.NestingHost$NestedClass1 is not a nest member of nesttest.NestingHost: current type is not listed as a nest member
        at nesttest.NestingHost$NestedClass1.publicMethod(NestingHost.java:8)
        at nesttest.HackNest.main(HackNest.java:7)

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

Нравится нам это или нет? Где принцип быстрого отказа? Почему среда выполнения Java начинает выполнять класс и проверять структуру гнезда только тогда, когда это очень необходимо? Причина, как много раз в случае с Java: обратная совместимость. JVM может проверить согласованность структуры гнезда, когда все классы загружены. Классы загружаются только тогда, когда они используются. Было бы возможно изменить загрузку классов в Java 11 и загрузить все вложенные классы вместе с хостом, но это нарушило бы обратную совместимость. Если бы не что иное, ленивый шаблон синглтона развалился бы, и мы этого не хотим. Мы любим синглтон, но только когда односолодовый (это так).

Вывод

JEP-181 — это небольшое изменение в Java. Большинство разработчиков даже не заметят. Это устраненный технический долг, и если основной Java-проект не устраняет технический долг, то чего нам ожидать от среднего разработчика?

Как гласит старая латинская поговорка: «Debitum technica necesse est deletur».

Опубликовано на Java Code Geeks с разрешения Питера Верхаса, партнера нашей программы JCG . Смотрите оригинальную статью здесь: Вложенные классы и частные методы

Мнения, высказанные участниками Java Code Geeks, являются их собственными.