В большинстве моих собеседований на технические позиции есть задача, в которой кандидату необходимо реализовать 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", видео встречи можно найти по ссылке.


Комментарии (16)


  1. usrsse2
    15.10.2018 11:51
    +1

    В C# такое разрешено при помощи явной реализации интерфейса

    public class SV : S, V
    {
        string S.m(int i)
        {
            System.Console.WriteLine("string");
            return null;
        }
    
        void V.m(int i)
        {
            System.Console.WriteLine("void");
        }
    }
    


    1. jreznot Автор
      15.10.2018 11:52

      Мне всегда такие перегрузки в C# ломали мозг, это тот ещё паззл для интервью.


      1. Vestild
        15.10.2018 12:07

        а кульбиты с байткодом в яве не ломают?


        1. jreznot Автор
          15.10.2018 12:45

          Они обычно вылезают только при появлении проблем, компилятор их в основном скрывает.


      1. mayorovp
        15.10.2018 13:11

        Хм, а каким именно образом они ломают мозг? Вроде бы простейший синтаксис же…


    1. Vestild
      15.10.2018 12:08

      public не лишний?


      1. usrsse2
        15.10.2018 12:11

        лишний) спасибо, поправил


  1. Mingun
    15.10.2018 13:41

    Так я не понял, о первоначальный вопрос про интерфейсы WithPrimitiveInt и WithInteger зачем задавали, если на него так и не ответили… Хоть он и легонький, но все же.


  1. Px2
    15.10.2018 15:04
    +3

    Не могу не спросить: зачем нужна черная магия с созданием методов с одинаковой сигнатурой? Иногда мне кажется, что людям заняться нечем, кроме как показать «пацаны, смотрите, как я умею» или, цитирую «Сногсшибательно, правда?». Практической ценности такое знание не несет, скорее, оно из разряда эзотерических. Ну так к чему все это?


    1. jreznot Автор
      15.10.2018 15:34

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


  1. Maccimo
    16.10.2018 03:06
    +1

    ?Вы после того, как текст от переводчика получили, хотя бы вычитку делайте. Ещё лучше — подготовьте ему глоссарий. Это же форменная дичь, переводить «implemented interfaces» как «встроенные интерфейсы» вместо «реализованные».


    ?Как из фразы «For example, if we wanted to invoke a method String m(int i) directly on a class foo.Bar» при переводе можно было получить «Например, если мы хотим включить метод String m(int i) непосредственно в класс foo.Bar, необходим следующий байт-код» — вообще загадка. Речь в оригинале идёт о вызове метода, а не о включении его куда-либо.


    ?По теме: для экспериментов по созданию нестандартных class-файлов удобнее использовать asmtools. Как пример, исходник класса с реализацией интерфейсов из статьи становится намного читаемее:


    package  edio/java/experiments;
    
    public class SV
        implements S, V
        version 51:0
    {
    
    public Method "<init>":"()V"
        stack 1 locals 1
    {
            aload_0;
            invokespecial   Method java/lang/Object."<init>":"()V";
            return;
    }
    
    public Method m:"(I)Ljava/lang/String;"
        stack 2 locals 2
    {
            getstatic   Field java/lang/System.out:"Ljava/io/PrintStream;";
            ldc String "String";
            invokevirtual   Method java/io/PrintStream.println:"(Ljava/lang/String;)V";
            aconst_null;
            areturn;
    }
    
    public Method m:"(I)V"
        stack 2 locals 2
    {
            getstatic   Field java/lang/System.out:"Ljava/io/PrintStream;";
            ldc String "void";
            invokevirtual   Method java/io/PrintStream.println:"(Ljava/lang/String;)V";
            return;
    }
    
    } // end Class SV

    Декомпилировать *.class-файл в 2018 году можно просто открыв его в IntelliJ IDEA, старичок JAD не обязателен.


    1. jreznot Автор
      16.10.2018 09:05

      Спасибо, поправил


  1. thatsme
    16.10.2018 08:58

    Я возможно чего-то не понимаю, но по моему в статье слишком много всего ненужного.


    Проще про наследование нельзя было объяснить?


    Файл jtest/WithPrimitiveInt.java


    package jtest;
    
    public interface WithPrimitiveInt {
       void m(int a);
    }

    Файл jtest/WithInteger.java


    package jtest;
    
    public interface WithInteger  extends WithPrimitiveInt {
      void m(Integer a);
    }

    Файл имплементации интерфейсов jtest/Mimpl.java


    package jtest;
    
    public class Mimpl implements WithPrimitiveInt, WithInteger {
      public void m(int a)
      {
        System.out.println("m(int a), with a="+new Integer(a).toString());
      }
      public void m(Integer a)
      {
        System.out.println("m(Integer a), with a="+a.toString());
      }
    }
    

    Тестируем (Test.java):


    public class Test
    {
      public static void main(String [] args)
      {
        jtest.WithPrimitiveInt a=new jtest.Mimpl();
        a.m(1);
        a.m(new Integer(2));
        jtest.WithInteger b=new jtest.Mimpl();
        b.m(3);
        b.m(new Integer(4));
      }
    }

    Результат:


    m(int a), with a=1
    m(int a), with a=2
    m(int a), with a=3
    m(Integer a), with a=4
    

    Обратите внимание на перегрузку в кейсе 2…


    И никакой магии… от слова вообще.


  1. dididididi
    16.10.2018 09:21

    Тут был цикл статей, почему не стоит нанимать джуниоров, а это начался цикл статей, почему не стоит нанимать сеньоров? Они высосут проблему из пальца и решат ее очень сложно)))


    1. thatsme
      16.10.2018 12:20

      Это к сеньорам отношения никакого не имеет. Задача джуновская на множественное наследование. Зачем все танцы с бубном вокруг тривиальной задачи, — непонятно. Я например вообще на ява лет 10 уже не пишу. Но это элементарные вещи (см. пост выше).


  1. dididididi
    16.10.2018 14:48

    Я это и сказал. Задача простая, а вокруг у нее устроили сеньорские танцы с бубном.