Pull to refresh

Бинарная совместимость в примерах и не только

Java *
Tutorial
Возможно, многие из вас задавались вопросами вроде «А что будет, если кто-то подложит к моему приложению неправильную версию библиотеки?». Вопрос хороший, а ответ на него и некоторые другие вы найдёте в этом топике. Для затравки задачка: пусть есть два интерфейса и класс, реализующий один из них:

public interface A {
    //...
}

public interface B {
    //...
}

public class C implements A {
    //...
}

А также класс, в котором есть метод foo, перегруженный для A и B. Этот метод вызывают от экземпляра класса C:

public class CompatibilityChecker {

    public String foo(A a) {
        return "A";
    }

    public String foo(B b) {
        return "B";
    }

    public static void main(String[] args) {
        CompatibilityChecker checker = new CompatibilityChecker();
        System.out.println(checker.foo(new C()));
    }
}

Вполне очевидно, что выведется «A». Не менее очевидно, что если сказать, что C implements A, B, то получится ошибка компиляции (тем, кому последнее не очевидно, могу порекомендовать почитать про то, как происходит выбор методов. Например, в стандарте в разделе 15.12.2 или в более просто описывающих местах).
Но вот что произойдёт, если мы перекомпилируем только C.java, а потом запустим CompatibilityChecker из уже имеющегося класс-файла, является уже более сложным вопросом. Заинтересованы? Прошу под кат!

Static dispatch

Те, кто знают, что перегруженные методы выбираются во время компиляции, могут сообразить, что по этой причине в класс-файле будет сразу записана информация о том, какой же метод вызывать, и потому в результате выведется «A». Проверим это предположение:

public static void main(java.lang.String[]);
  Code:
   0:	new	#4; //class CompatibilityChecker
   3:	dup
   4:	invokespecial	#5; //Method "<init>":()V
   7:	astore_1
   8:	getstatic	#6; //Field java/lang/System.out:Ljava/io/PrintStream;
   11:	aload_1
   12:	new	#7; //class C
   15:	dup
   16:	invokespecial	#8; //Method C."<init>":()V
   19:	invokevirtual	#9; //Method foo:(LA;)Ljava/lang/String;
   22:	invokevirtual	#10; //Method java/io/PrintStream.println:(Ljava/lang/String;)V
   25:	return

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

Итак, запустим-таки наш код и убедимся, что изначальное предположение было правильным: выведется действительно «А».

Кроме того, можно предположить, что в виртуальной таблице адресов может оказаться какой-то неправильный адрес, и потому всё сломается в рантайме с NoSuchMethodError. Это предположение тоже ошибочно, так как вызывается метод foo(A), и в виртуальной таблице он такой один. Другое дело, если бы были наследники, его переопределяющие…

Dynamic dispatch

Пусть у нас есть три следующих класса:

public class A {
    public String foo() {
        return "A";
    }
}

public class B extends A {
    @Override
    public String foo() {
        return "B";
    }
}

public class C extends A {
    @Override
    public String foo() {
        return super.foo() + "C";
    }
}

И класс, вызывающий foo в разных вариантах:

public class CompatibilityChecker {

    public static void main(String[] args) {
        A a = new A();

        A ab = new B();
        B bb = new B();

        A ac = new C();
        C cc = new C();

        System.out.println(a.foo());
        System.out.println(ab.foo());
        System.out.println(bb.foo());
        System.out.println(ac.foo());
        System.out.println(cc.foo());
    }
}

Все, конечно же, знают, что поскольку у переотпределённых типов методы выбираются в рантайме, изначальный вывод будет таким:
A
B
B
AC
AC

Самое время сделать пакость, подменив класс-файл от A на результат компиляции следующего кода:

public class A {
    public String foo(Object dummy) {
        return "A";
    }
}

Понять, что в данном случае произойдёт, довольно просто. Во-первых, все попытки вызвать методы, где foo вызывается у экземпляпа класса A, точно вылетят с NoSuchMethodError. Среди этих попыток также находится и вызов super.foo() в классе C. Во-вторых, как мы уже видели раньше, метод B.foo() вызовется успешно.

Теперь изменим тактику: снова сделаем A.foo таким, каким он и был, но теперь поменяем B и C, вообще удалив из них переопределение метода foo:

public class B extends A {}

public class C extends A {}

При запуске кода dynamic dispatch обнаружит только по одной записи для A.foo, и потому во всех случаех вызовет его, в результате чего мы увидем в консоли только буквы «A» и полное отсутствие каких-либо исключений.

Продолжим наши изыскания, снова переопределив методы в B и C. После запуска, как мы и можем ожидать, dynamic dispatch обнаружит все записи в виртуальной таблице, и даст точно такой же вывод, как и тот, который мы получили бы, заново перекомпилировав всё.

Поля несоответствующих типов

Ранее мы пробовали экспериментировать только с методами. Посмотрим теперь, что бывает с полями. Пусть есть класс, хранящий значение типа int и наследник этого класса:

public class A {
    int answer;
}

public class B extends A {}

И, традиционно, потребитель класса B:

public class CompatibilityChecker {
    public static void main(String[] args) {
        B b = new B();
        b.answer = 42;
    }
}

Добавим теперь в класс B собственное поле с таким же именем:

public class B extends A {
    String answer;
}

Посмотрим, какой байт-код был сгенерирован для CompatibilityChecker:

public static void main(java.lang.String[]);
  Code:
   0:	new	#2; //class B
   3:	dup
   4:	invokespecial	#3; //Method B."<init>":()V
   7:	astore_1
   8:	aload_1
   9:	bipush	42
   11:	putfield	#4; //Field B.answer:I
   14:	return

Этот листинг может сбить с толку, полкольку на отступе 11 в комменте вроде как сказано, что поле принадлежит B. Потому стоит полагать, что при перекомпиляции B мы столкнёмся с ошибкой. Однако оказывается, что это вовсе не так. Поскольку физически поле есть только у базового класса, операнд команды putfield указывает именно на то самое нужное поле, в результате чего после изменений код продолжает работать.

А что вообще говорит спецификация?


В спецификации под бинарную совместимость отведена целая глава, в которой основным понятием является «двоично совместимое» или «безопасное» изменение. В спецификации утверждается, что при внесении только безопасных изменений гарантируется безопасное выполнение приложения без перекомпиляции всего остального и ошибок линковки. Как ни странно, но во всей огромной главе нет точного определения двоично совместимой операции, однако есть куча примеров:
  • Изменение реализации существующих методов, конструкторов или блоков инициализации
  • Добавление новых полей, методов и конструкторов существующим классам и интерфейсам
  • Удаление приватных полей, методов и конструкторов класса
  • Перемещение методов наверх по иерархии классов
  • Добавление новых классов и интерфейсов

Возможно, именно из-за такого слабого определения двоично совместимых операций, возникают проблемы.

Ложка дёгтя

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

public interface A {}

public class B implements A {}

public class CompatibilityChecker {
    public static void main(String[] args) {
        A b = new B();
    }
}

Произведём два безопасных изменения: добавим метод foo в интерфейс A и изменим реализацию метода main класса CompatibilityChecker:

public interface A {
    void foo();
}

public class CompatibilityChecker {
    public static void main(String[] args) {
        A b = new B();
        b.foo();
    }
}

При запуске, как вы могли понять, произойдёт ошика, а именно AbstractMethodError: B.foo()V, чего по идее быть не должно. Эту проблема известна и лежит в самой основе обработки байт-кода Java. Были предложения по исправлению ситуации, но они пока ни к чему не привели.

Конец


Итак, ответ на вопрос, профигурировавший в самом начале статьи («А что будет, если кто-то подложит к моему приложению неправильную версию библиотеки?») такой: «А кто ж его знает. Смотря в чём та версия, которая использовалась при компиляции, отличается от той, которая используется в рантайме».

В статье не затронуты некоторые очевидные вещи. Например, что при несовместимых изменениях вроде удаления методов и классов или превращения класса в интерфейс, будут кидаться беспощадные ошибки вроде NoSuchMethodError, NoClassDefFoundError или IncompatibleClassChangeError.

Буду рад ответить на вопросы и прочитать замечания и дополнения. Кстати, это мой первый топик во второй жизни на Хабрахабре. Даже не знаю, к чему это говорю.
Tags:
Hubs:
Total votes 71: ↑68 and ↓3 +65
Views 17K
Comments Comments 12