Статьи

Шаблоны проектирования в XXI веке: шаблон адаптера

Это третья часть моего выступления « Шаблоны проектирования в 21 веке» .

Шаблон Адаптер соединяет миры. В одном мире у нас есть интерфейс для концепции; в другом мире у нас другой интерфейс. Эти два интерфейса служат разным целям, но иногда нам нужно передавать вещи через. В хорошо написанном юниверсе мы можем использовать адаптеры, чтобы заставить объекты, следующие одному протоколу, придерживаться другого.

Существует два вида шаблона адаптера. Мы не будем говорить об этом:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
interface Fire {
    <T> Burnt<T> burn(T thing);
}
 
interface Oven {
    Food cook(Food food);
}
 
class WoodFire implements Fire { ... }
 
class MakeshiftOven extends WoodFire implements Oven {
    @Override public Food cook(Food food) {
        Burnt<Food> noms = burn(food);
        return noms.scrapeOffBurntBits();
    }
}

Эта форма, шаблон адаптера класса , выводит меня из себя, потому что extends дает мне хиби джиби. Почему выходит за рамки этого эссе; не стесняйтесь спрашивать меня в любое время, и я с радостью отговорю ваши уши (и, вероятно, ваш нос) об этом.

Вместо этого давайте поговорим об объектном шаблоне Adapter , который обычно считается гораздо более полезным и гибким во всех отношениях.

Давайте посмотрим на тот же класс, следуя этой альтернативе:

01
02
03
04
05
06
07
08
09
10
11
12
class MakeshiftOven implements Oven {
    private final Fire fire;
 
    public MakeshiftOven(Fire fire) {
        this.fire = fire;
    }
 
    @Override public Food cook(Food food) {
        Burnt<Food> noms = fire.burn(food);
        return noms.scrapeOffBurntBits();
    }
}

И мы будем использовать это так:

1
2
Oven oven = new MakeshiftOven(fire);
Food bakedPie = oven.cook(pie);

Шаблон обычно следует этой простой структуре:

Адаптер-паттерн-ОМЛ

Это хорошо, правда?

Да. Вроде, как бы, что-то вроде. Мы можем сделать лучше.

У нас уже есть ссылка на Fire , поэтому создание другого объекта, чтобы поиграть с ним, кажется немного … излишним. И этот объект реализует Oven . Который имеет один абстрактный метод . Я вижу тенденцию здесь.

Вместо этого мы можем сделать функцию, которая делает то же самое.

1
2
Oven oven = food -> fire.burn(food).scrapeOffBurntBits();
Food bakedPie = oven.cook(pie);

Мы могли бы пойти еще дальше и составить ссылки на методы, но на самом деле все становится еще хуже.

1
2
3
4
5
// Do *not* do this.
Function<Food, Burnt<Food>> burn = fire::burn;
Function<Food, Food> cook = burn.andThen(Burnt::scrapeOffBurntBits);
Oven oven = cook::apply;
Food bakedPie = oven.cook(pie);

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

Наша новая UML-диаграмма будет выглядеть примерно так:

Адаптер-паттерн-ОМЛ-функциональный

Однако часто все, что нам действительно нужно, — это ссылка на метод. Например, возьмем интерфейс Executor .

1
2
3
4
5
6
7
8
package java.util.concurrent;
 
/**
 * An object that executes submitted {@link Runnable} tasks.
 */
public interface Executor {
    void execute(Runnable command);
}

Он потребляет объекты Runnable , и это очень полезный интерфейс.

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

1
2
Executor executor = ...;
Stream<Runnable> tasks = ...;

Как мы выполняем их все на нашем Executor ?

Это не сработает:

1
tasks.forEach(executor);

Оказывается, метод forEach в Stream действительно принимает потребителя, но очень специфического типа:

1
2
3
4
5
6
7
public interface Stream<T> {
    ...
 
    void forEach(Consumer<? super T> action);
 
    ...
}

Consumer выглядит так:

1
2
3
4
5
6
7
@FunctionalInterface
public interface Consumer<T>
{
    void accept(T t);
 
    ...
}

На первый взгляд, это не выглядит таким уж полезным. Но обратите внимание, что Consumer — это функциональный интерфейс, поэтому мы можем использовать лямбда-выражения для их простого определения. Это означает, что мы можем сделать это:

1
tasks.forEach(task -> executor.execute(task));

Что можно упростить до этого:

1
tasks.forEach(executor::execute);

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