Haulmont corporate blog
Programming
Java
October 2018 15

Перегрузка, которая запрещена, или bridge-методы в Java

Original author: Dmytro Kostiuchenko
Translation

В большинстве моих собеседований на технические позиции есть задача, в которой кандидату необходимо реализовать 2 очень похожих интерфейса в одном классе:


Реализуйте оба интерфейса одним классом, если это возможно. Объясните, почему это возможно или нет.


interface WithPrimitiveInt {
  void m(int i);
}

interface WithInteger {
  void m(Integer i);
}

От переводчика: Эта статья не призывает вас задавать такие же вопросы на интервью. Но если вы хотите быть во всеоружии, когда этот вопрос зададут вам, то добро пожаловать под кат.


Иногда соискатели, которые не очень уверены в ответе, предпочитают решить вместо этой задачу со следующим условием (позже я в любом случае прошу ее решить):


interface S {
  String m(int i);
}

interface V {
  void m(int i);
}

И правда, вторая задача кажется намного проще, и большинство кандидатов отвечают, что включение обоих методов в один и тот же класс невозможно, потому что сигнатуры S.m(int) и V.m(int) одинаковы, в то время как тип возвращаемого значения — разный. И это абсолютно верно.


Однако иногда я задаю другой вопрос, связанный с этой темой:


Как вы думаете, есть ли смысл в том, чтобы допускать реализацию методов с одинаковой сигнатурой, но разными типами в одном классе? Например, в неком гипотетическом языке на базе JVM или хотя бы на уровне JVM?


Это вопрос, ответ на который неоднозначен. Но, не смотря на то, что я не ожидаю ответа на него, правильный ответ существует. Ответить на него смог бы человек, который часто имеет дело с API рефлексии, манипулирует байт-кодом или знаком со спецификацией JVM.


Сигнатура метода Java и дескриптор метода JVM


Сигнатура метода Java (т.е. название метода и типы параметров) применяется только Java компилятором во время компиляции. В свою очередь, JVM разделяет методы в классе с помощью неквалифицированного имени метода (то есть просто имени метода) и дескриптора метода, то есть перечня параметров дескриптора и одного return-дескриптора.


Например, если мы хотим вызвать метод String m(int i) непосредственно на классе foo.Bar, необходим следующий байт-код:


INVOKEVIRTUAL foo/Bar.m (I)Ljava/lang/String;

а для void m(int i) следующий:


INVOKEVIRTUAL foo/Bar.m (I)V

Таким образом, JVM вполне комфортно себя чувствует с String m(int i) и void m(int i) в одном классе. Все, что нужно, — это сгенерировать соответствующий байт-код.


Кунг-фу с байт-кодом


У нас есть интерфейсы S и V, теперь мы создадим класс SV, который включает оба интерфейса. В Java, если бы это было разрешено, это должно выглядеть так:


public class SV implements S, V {
  public void m(int i) {
    System.out.println("void m(int i)");
  }
  public String m(int i) {
    System.out.println("String m(int i)");
    return null;
  }
}

Чтобы сгенерировать байт-код, мы используем Objectweb ASM library, достаточно низкоуровневую библиотеку, чтобы получить представление о JVM байт-коде.


Полный исходный код залит на GitHub, здесь же я приведу и поясню только наиболее важные фрагменты.


ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES);

// package edio.java.experiments
// public class SV implements S, V
cw.visit(V1_7, ACC_PUBLIC, "edio/java/experiments/SV", null, "java/lang/Object", new String[]{
    "edio/java/experiments/S",
    "edio/java/experiments/V"
});

// constructor
MethodVisitor constructor = cw.visitMethod(ACC_PUBLIC, "<init>", "()V", null, null);
constructor.visitCode();
constructor.visitVarInsn(Opcodes.ALOAD, 0);
constructor.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "<init>", "()V");
constructor.visitInsn(Opcodes.RETURN);
constructor.visitMaxs(1, 1);
constructor.visitEnd();

// public String m(int i)
MethodVisitor mString = cw.visitMethod(ACC_PUBLIC, "m", "(I)Ljava/lang/String;", null, null);
mString.visitCode();
mString.visitFieldInsn(Opcodes.GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
mString.visitLdcInsn("String");
mString.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V");
mString.visitInsn(Opcodes.ACONST_NULL);
mString.visitInsn(Opcodes.ARETURN);
mString.visitMaxs(2, 2);
mString.visitEnd();

// public void m(int i)
MethodVisitor mVoid = cw.visitMethod(ACC_PUBLIC, "m", "(I)V", null, null);
mVoid.visitFieldInsn(Opcodes.GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
mVoid.visitLdcInsn("void");
mVoid.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V");
mVoid.visitInsn(Opcodes.RETURN);
mVoid.visitMaxs(2, 2);
mVoid.visitEnd();

cw.visitEnd();

Начнем с создания ClassWriter для генерации байт-кода.


Теперь мы объявим класс, в который входят интерфейсы S и V.


Хотя наш референсный псевдо-java код для SV не имеет конструкторов, нам все равно нужно генерировать код для него. Если мы не описываем конструкторы на Java, компилятор неявно генерирует пустой конструктор.


В теле методов мы начнем с получения поля System.out с типом java.io.PrintStream и добавления его в стек операндов. Затем загружаем константу (String или void) в стек и вызываем команду println в полученной переменной out со строковой константой в качестве аргумента.


Наконец, для String m(int i) добавляем в стек константу ссылочного типа со значением null и используем оператор return соответствующего типа, то есть ARETURN, чтобы вернуть значение в инициатор вызова метода. Для void m(int i) необходимо использовать нетипизированный RETURN только для того, чтобы вернуться к инициатору вызова метода без возврата значения. Чтобы убедиться в правильности байт-кода (что я делаю постоянно, многократно исправляя ошибки), мы записываем сгенерированный класс на диск.


Files.write(new File("/tmp/SV.class").toPath(), cw.toByteArray());

и используем jad (декомпилятор Java), чтобы перевести байт-код обратно в исходный код на Java:


$ jad -p /tmp/SV.class
The class file version is 51.0 (only 45.3, 46.0 and 47.0 are supported)
// Decompiled by Jad v1.5.8e. Copyright 2001 Pavel Kouznetsov.
// Jad home page: http://www.geocities.com/kpdus/jad.html
// Decompiler options: packimports(3) 

package edio.java.experiments;

import java.io.PrintStream;

// Referenced classes of package edio.java.experiments:
//            S, V

public class SV
    implements S, V
{

    public SV()
    {
    }

    public String m(int i)
    {
        System.out.println("String");
        return null;
    }

    public void m(int i)
    {
        System.out.println("void");
    }
}

По-моему, неплохо.


Использование сгенерированного класса


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


Чтобы использовать сгенерированный класс во время исполнения, нам необходимо каким-то образом загрузить его в JVM и затем создать его экземпляр.


Давайте реализуем собственный AsmClassLoader. Это просто удобная обёртка для ClassLoader.defineClass:


public class AsmClassLoader extends ClassLoader {
  public Class defineAsmClass(String name, ClassWriter classWriter) {
    byte[] bytes = classWriter.toByteArray();
    return defineClass(name, bytes, 0, bytes.length);
  }
}

Теперь используем этот class loader и создадим экземпляр класса:


ClassWriter cw = SVGenerator.generateClass();
AsmClassLoader classLoader = new AsmClassLoader();
Class<?> generatedClazz = classLoader.defineAsmClass(SVGenerator.SV_FQCN, cw);
Object o = generatedClazz.newInstance();

Поскольку наш класс сгенерирован во время исполнения, мы не можем использовать его в исходном коде. Зато мы можем привести его тип к реализованным интерфейсам. А вызов без рефлексии можно осуществить так:


((S)o).m(1);
((V)o).m(1);

При выполнении кода мы получим следующий вывод:


String
void

Кому-то такой вывод покажется неожиданным: мы обращаемся к одному и тому же (с точки зрения Java) методу в классе, но результаты различаются в зависимости от интерфейса, к которому мы привели объект. Сногсшибательно, правда?


Все станет понятно, если принять во внимание лежащий в основе байт-код. Для нашего вызова компилятор генерирует инструкцию INVOKEINTERFACE, и дескриптор метода исходит не из класса, а из интерфейса.


Таким образом, при первом вызове мы получим:


INVOKEINTERFACE edio/java/experiments/S.m (I)Ljava/lang/String;

а при втором:


INVOKEINTERFACE edio/java/experiments/V.m (I)V

Объект, на котором мы выполнили вызов, можно получить из стека. Это и есть могущество полиморфизма, присущее Java.


Имя ему — bridge-метод


Кто-то спросит: "Так в чем смысл всего этого? Пригодится ли это когда-нибудь?"


Смысл в том, что мы используем всё то же самое (неявно) при написании обычного Java кода. Например, ковариантные возвращаемые типы, дженерики и доступ к private-полям из внутренних классов реализуются с помощью такой же магии байт-кода.


Взгляните на такой интерфейс:


public interface ZeroProvider {
  Number getZero();
}

и его реализацию с возвратом ковариантного типа:


public class IntegerZero implements ZeroProvider {
  public Integer getZero() {
    return 0;
  }
}

Теперь подумаем над этим кодом:


IntegerZero iz = new IntegerZero();
iz.getZero();

ZeroProvider zp = iz;
zp.getZero();

Для iz.getZero() компилятор вызова будет генерировать INVOKEVIRTUAL с методом дескриптора ()Ljava/lang/Integer;, в то время как для zp.getZero() он сгенерирует INVOKEINTERFACE с дескриптором метода ()Ljava/lang/Number;. Мы уже знаем, что JVM выполняет диспетчеризацию вызова объекта с помощью имени и дескриптора метода. Так как дескрипторы разные, эти 2 вызова не могут направляться в один и тот же метод в экземпляре IntegerZero.


По сути, компилятор генерирует дополнительный метод, выполняющий роль моста между реальным методом, указанным в классе, и методом, используемым при вызове через интерфейс. Отсюда название — bridge-метод. Если бы в Java такое было возможно, конечный код выглядел бы так:


public class IntegerZero implements ZeroProvider {
  public Integer getZero() {
    return 0;
  }

  // This is a synthetic bridge method, which is present only in bytecode.
  // Java compiler wouldn't permit it.
  public Number getZero() {
    return this.getZero();
  }
}

Послесловие


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


От переводчика


Вопросы совместимости рано или поздно начинают волновать любого разработчика. В исходной статье затронут важный вопрос о неявном поведении компилятора Java и влиянии его магии на приложения, который нас как разработчиков фреймворка CUBA Platform волнует довольно сильно, — это напрямую влияет на совместимость библиотек. Совсем недавно мы рассказывали о совместимости в реальных приложениях на JUG в Екатеринбурге в докладе "API на переправе не меняют — как построить стабильный API", видео встречи можно найти по ссылке.


+20
10k 90
Comments 16
Top of the day