Утиная типизация в Java

Если что-то ходит как утка, и крякает как утка, то будем относиться к этому как к утке. Так неформально описывается принцип утиной типизации (Duck Typing). Утиная типизация «развязывает нам руки», позволяя полиморфно работать с объектами, которые не связаны в иерархии наследования, но имеют необходимый набор методов. Здесь мы подходим к извечному спору о том, что лучше – динамическая или статическая типизация. Обойдём его стороной, сославшись на известную статью Мейера и Дрейтона под названием «The End of the Cold War Between Static and Dynamic Languages», и лучше посмотрим, как реализовать «утиное» поведение на Java.

Зачем мне эти утки?

Зачем может нам понадобиться утиная типизация в Java? Например, мы работаем с классом, к исходному коду которого мы не имеем доступа. Этот класс, как и некоторые наши собственные, имеет некий одинаковый набор методов, так что мы хотели бы работать со всеми этими классами через единый интерфейс. Если для наших классов мы можем залезть в исходный текст и указать, что они реализуют этот интерфейс, то с чужими классами такое не пройдёт. Конечно, мы можем написать класс-адаптер, или обвёртку, которая реализует необходимый интерфейс и вызывает нужные методы. Но мы можем выполнить всё это «на лету», воспользовавшись рефлексией, proxy-классами и прочими полезными штуками из пакета java.lang.reflect, эмулируя утиную типизацию.

Рассмотрим ситуацию на тривиальном примере. У нас есть интерфейс

      public interface IQuackable {
         void quack();
      }

И какие-то классы:

     public class Duck {
         public void quack () {
             System.out.println(“I am a Duck!”);
         }
     }

    public class Frog {
         public void quack () {
              System.out.println(“I am a Frog!”);
         }
     }

Видно, в определениях Duck и Frog не указано, что они реализует IQuackable, однако необходимый метод у них имеется. Мы хотим получить такое поведение:

    // ...
    IQuackable[] quakers = new IQuackable[] {
         Cast(IQuackable.class, new Duck()),
         Cast(IQuackable.class, new Frog())
    };
    for(IQuackable q : quakers) {
          q.quack();
    }
    // ...

Стоит отметить, что наши искомые классы должны быть объявлены с модификатором доступа public, иначе во время преобразования может возникнуть исключение java.lang.IllegalAccessException. Это требование обусловлено тем, что реализация должна иметь доступ к искомому классу, чтобы вызывать его методы.

Необходимость в использовании интерфейса вытекает из статической типизации Java. Например, в Groovy, который напрямую поддерживает динамическую и утиную типизацию, мы могли бы поступить так и обойтись без интерфейсов вовсе:

    class Duck {
        quack() { println "I am a Duck" }
    }
    class Frog {
        quack() { println "I am a Frog" }
    }

    quackers = [ new Duck(), new Frog() ]

    for (q in quackers) {
        q.quack()
    }

Реализация и Proxy-уточки

Вернёмся к Java, и посмотрим, как же всё это реализовать.

Так, мы создадим три статических метода, которые будут преобразовывать объекты к соответствующим интерфейсам:

  • <I> I Cast(Class<I> i, Object o) – приводит ссылку на Object к ссылке на интерфейс I. o должен иметь соответствующий этому интерфейсу набор методов.
  • <C> C UnCast(Class<C> c, Object o) – обратное преобразование.
  • boolean CanCast(Class<?> i, Object o) – проверяет, может ли объект o быть приведён к соответствующему интерфейсу.

Прежде, чем приступить к реализации этих методов следует уделить время рассмотрению динамических proxy-классов, которые впервые появились в стандартной библиотеке Java 1.3. Они полезны в случае, когда программист на этапе разработки ещё не знает, какие интерфейсы ему предстоит реализовать. Proxy-классы особо полезны для генерации классов-заглушек в технологиях вроде RMI. Но здесь мы воспользуемся ими для эмуляции утиной типизации.

Подробную информацию можно найти на странице руководства.

Динамические proxy-классы реализуют интерфейсы во время выполнения. Каждый вызов методы этого класса через какой-то интерфейс приводит к вызову единственного метода объекта обработчика вызовов, в который передаётся информация о вызванном методе в виде объекта java.lang.reflect.Method и массив Object, содержащий аргументы. Внутри обработчика вызовов мы можем определить необходимое нам поведение. Однако мы не можем генерировать новый код во время выполнения, а всего лишь выполнять заранее подготовленный.

Мы создадим обработчик вызова DuckHandler, который будет вызывать необходимый метод на экземпляре искомого объекта. Сам искомый объект будем хранить внутри обработчика вызовов. Изображение всего этого в виде произвольной схемы будет выглядеть следующим образом:

Так, определение класса-обработчика:

    class DuckHandler implements InvocationHandler {
        public DuckHandler(Object t) {
            target = t;
            targetclass = target.getClass();
    }

    @Override
    public Object invoke(Object p, Method m, Object[] args)
      throws Throwable {
        Method me = targetclass.getMethod(m.getName(),
                                         m.getParameterTypes());
        return me.invoke(getTarget(),args);
    }

    public Object getTarget() {
        return target;
    }

    private Object target;
    private Class<?> targetclass;
}

Всё «мясо» находится в методе invoke. Видно, в ему передаётся экземпляр proxy-класса, который мы игнорируем, метод и аргументы.

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

    public static boolean CanCast(Class<?> i, Object o) {
        Class<?> c = o.getClass();
        for (Method method : i.getMethods()) {
            try {
                c.getMethod(method.getName(), method.getParameterTypes());
            }
            catch (NoSuchMethodException e) {
                return false;
            }
        }
        return true;
    }

Далее, само «преобразование». Перед созданием proxy-класса мы проверяем с помощью метода, определённого выше, может ли класс нашего объекта реализовать необходимый интерфейс. Если может, используя статический метод Proxy.newProxyInstance мы создаём proxy-класс. Первый аргумент этого метода – загрузчик класса, второй – массив интерфейсов, которые необходимо реализовать (у нас один интерфейс). И наконец, третий аргумент – обработчик вызовов. Сюда мы передаём экземпляр нашего класса, в котором сохраняется искомый объект о (помните, на нём вызываются все методы).

    @SuppressWarnings("unchecked")
    public static <I> I Cast(Class<I> c, Object o) {
         if (!CanCast(c, o))
             throw new ClassCastException();
         Object proxy = Proxy.newProxyInstance(
                     ClassLoader.getSystemClassLoader(),
                     new Class[]{c}, new DuckHandler(o));
         return (I) proxy;
    }

Ну и обратный предыдущему метод, который принимает тип искомого класса и объект proxy-класса, и возвращает исходный объект. Он хранится внутри обработчика вызовов, так что получить его не составляет особого труда.

    @SuppressWarnings("unchecked")
    public static <C> C UnCast(Class<C> c, Object o) {
         DuckHandler h = (DuckHandler) Proxy.getInvocationHandler(o);
         return (C) h.getTarget();
    }

Заключение

Утиная типизация представляется одной из тех вещей, без которых с успехом можно обойтись. Но в некоторых случаях она может прийтись весьма кстати, помогая сократить ручное кодирование. Так, в этой статье было рассмотрено, как с помощью рефлексии и динамических Proxy-классов Java добиться эмуляции такого «утиного» поведения. Была так же создана совсем крохотная инструментальная библиотека для демонстрации.

Исходный код вы можете загрузить отсюда.

Как раз во время написания статьи наткнулся на аналогичную. Вот ссылка: Java does Duck Typing (англ).

5 ответов на “Утиная типизация в Java

  1. Интересно, но я всё же считаю, что в Java использовать подобные вещи можно ну только когда прям ну кровь из носа нужно.

    Ибо интерфейсы они для того и нужны чтобы выступать constrain’ами, а так ничто не гарантирует того, что что-то в интерфейсе поменяется и всё не упадёт.

Оставьте комментарий