Pull to refresh

Паттерн Наблюдатель: списки и матрёшки из слушателей

Reading time6 min
Views24K
В этой хабрастатье на примере паттернов Наблюдатель и Компоновщик рассмотрено, как применять принципы объектно-ориентированного программирования, когда стоит использовать композицию, а когда наследование. А так же рассмотрено, какие существуют способы повторного использования кода, кроме copy-paste.

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


Итак, пусть у нас есть следующий интерфейс для наблюдающих объектов.

interface AListener {
        public void aChanged();
        public void bChanged();
}


И какой-нибудь такой класс для наблюдаемых объектов.

class AEventSource {
        private List<AListener> listeners = new ArrayList<AListener>();

        public void add(AListener listener) {
                listeners.add(listener);
        }

        private void aChanged() {
                for (AListener listener : listeners) {
                        listener.aChanged();
                }
        }

        private void bChanged() {
                for (AListener listener : listeners) {
                        listener.bChanged();
                }
        }

        public void f() {
                ...
                aChanged();
                ...
                bChanged();
                ...
        }
        ...
}


Здесь в методах aChanged() и bChanged() все слушатели уведомляются, что произошло данное событие. А в методе f() в нужный момент эти методы вызываются.

Всё это работает, но допустим в какой-то момент понимаем, что нужна возможность иметь разные способы уведомления слушателей. Например, уведомлять слушателей в разных порядках или в зависимости от каких-то условий некоторых слушателей не уведомлять о произошедших изменениях. Есть не менее двух вариантов решения этой проблемы. Во-первых, можно усложнить класс AEventSource, добавив в него разные способы уведомления и какой-то код, который будет тот или иной способ выбирать. Во-вторых, можно всё, что относится к уведомлению слушателей, вынести в отдельный класс. Тогда нужный способ уведомления можно задать, например, передав нужный объект в качестве параметра конструктора.

Второй способ лучше по двум причинам. Если у нас не один класс для наблюдаемых объектов AEventSource, а два (или больше) класса AEventSource1, AEventSource2, а так скорее всего и будет, то, выбрав первый способ, придётся продублировать новый сложный код для уведомления в двух классах, а это уже copy-paste, то есть зло. Второй причиной будет то, что второй подход соблюдает принцип единой ответственности (тот, который SRP). В самом деле, сейчас причин для изменения класса AEventSource две: если мы хотим изменить порядок уведомдения, и если мы хотим изменить причину уведомления, то есть функцию f() из примера выше.

Итак выбираем второй способ — код для уведомления слушателей выносим в отдельный класс. При этом поступим ещё чуть интереснее, а именно воспользуемся паттерном Компоновщик. Это нам даст не только выбирать различные способы уведомления слушателей, но и комбинировать их.

class ACompositeListener implements AListener {
        private List<AListener> listeners = new ArrayList<AListener>();

        public void add(AListener listener) {
                listeners.add(listener);
        }

        @Override
        public void aChanged() {
                for (AListener listener : listeners) {
                        listener.aChanged();
                }
        }

        @Override
        public void bChanged() {
                for (AListener listener : listeners) {
                        listener.bChanged();
                }
        }
}


Тогда класс AEventSource будет выглядеть как-то так:

class AEventSource {
        private AListener listener;

        AEventSource(AListener listener) {
                this.listener = listener;
        }

        public void f() {
                ...
                listener.aChanged();
                ...
                listener.bChanged();
                ...
        }
}


В этом случае можно создавать целые матрёшки из слушателей.

ACompositeListener compositeListener1 = new ACompositeListener();
compositeListener1.add(new AConcreteListener1());
ACompositeListener compositeListener2 = new ACompositeListener();
compositeListener1.add(compositeListener2);
compositeListener2.add(new AConcreteListener2());
compositeListener2.add(new AConcreteListener3());
AEventSource source = new AEventSource(compositeListener1);


Правда у такого решения есть и недостаток. Получив такую гибкость в создании агрегата наблюдающих объектов, можно создавать агрегаты, с таким порядком и правилами уведомления, что очень сложно будет в этом разобраться.

И снова этот код прекрасно справляется со своей задачей, но в вдруг оказывается (не зря же я везде букву A добавлял в именах классов), что нужно ещё уметь работать со слушателями, у которых помимо методов интерфейса AListener есть ещё один метод, назовём его dChanged(). То есть появляется ещё второй интерфейс для слушателей DListener:

interface DListener extends AListener {
        public void dChanged();
}


Таким образом, нам нужно как-то повторно использовать код. Есть как минимум три способа повторного использования кода. Это, во-первых, сopy-paste, во-вторых, наследование и, наконец, композиция + делегирование. Первый мы сразу же отбросим по идеологическим соображениям.

Разберём, почему не подойдёт нам и простое наследование, не смотря на то, что интерфейс DListener расширяет интерфейс AListener. Но действительно, как только мы напишем

class DCompositeListener extends ACompositeListener {
        ...
}


у нас сразу возникнет проблема с методом add(AListener listener), так как он позволяет добавить объект, который может реализовывать интерфейс AListener, но не DListener. Если же мы захотим переопределить этот метод следующим образом:

@Override
public void add(DListener listener) {
        ...
} 

то компилятор заботливо скажет: method does not override or implement a method from a supertype (спасибо аннотации @Override).

Ладно, а почему бы нам не сделать проверку внутри этого метода, и если добавляемый объект не реализует интерфейс DListener, то кидать исключение? То есть почему бы не написать что-то такое:
@Override
public void add(final AListener listener) {
        if (listener instanceof DListener) {
                listeners.add(listener);
        } else {
                throw new IllegalArgumentException();
        }       
}


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

void g(ACompositeListener composite) {
        composite.add(new AConcreteListener1());
}


выглядит вполне безобидно и ожидает (и оправданно), что в composite можно добавить слушателя. Но вот мы вызовем функцию g() таким образом:
g(new DCompositeListener()); 

и всё упадёт с исключением. А наша цель сделать так, чтобы такое даже скомпилировать нельзя было.

Да, вариант полностью отказаться от интерфейса AListener, а всем слушателям, которые не имели метода dChanged() добавить пустую или бросающую исключение реализацию метода dChanged(), мы отбрасываем, так как это нарушит принцип разделения интерфейса (он же ISP).

Итак, остаётся композиция + делегирование. Для начала из класса ACompositeListener весь код, отвечающий за добавление, удаление и итерирование слушателей вынесем в новый класс ListenerList. И так как у нас слушатели разных типов, то он будет параметризованным классом.

class ListenerList<T> implements Iterable<T> {
    protected List<T> listeners = new ArrayList<T>();

    public void add(T listener) {
        listeners.add(listener);
    }

    public void remove(T listener) {
        listeners.remove(listener);
    }

    public Iterator<T> iterator() {
        return listeners.iterator();
    }
}


Классы же, которые будут уведомлять слушателей, будут выглядеть следующим образом.

class ATranslator<T extends AListener> {
    protected ListenerList<T> listeners;

    public ATranslator(ListenerList<T> listeners) {
        this.listeners = listeners;
    }

    public void aChanged() {
        for (AListener listener : listeners) {
            listener.aChanged();
        }
    }

    public void bChanged() {
        for (AListener listener : listeners) {
            listener.bChanged();
        }
    }
}

class DTranslator<T extends DListener> extends ATranslator<T> {
    public DTranslator(ListenerList<T> listeners) {
        super(listeners);
    }

    public void dChanged() {
        for (DListener listener : listeners) {
            listener.dChanged();
        }
    }
}


Здесь используется ограничивающие типы (<T extends XListener>), чтобы в список можно было добавлять только тех слушателей, которые реализуют соответствующий интерфейс. Попытка добавления слушателся не реализующего нужный интерфейс приведёт к ошибке компиляции. Чего мы и добивались.

Теперь ACompositeListener и ACompositeListener запишутся просто:
class ACompositeListener extends ListenerList<AListener> implements AListener {
    private ATranslator<AListener> aTranslator = new ATranslator<AListener>(this);

    @Override
    public void aChanged() {
        aTranslator.aChanged();
    }

    @Override
    public void bChanged() {
        aTranslator.bChanged();
    }
}


class DCompositeListener extends ListenerList<DListener> implements DListener {
    private DTranslator<DListener> dTranslator = new DTranslator<DListener>(this);

    @Override
    public void aChanged() {
        dTranslator.aChanged();
    }

    @Override
    public void bChanged() {
        dTranslator.bChanged();
    }


    @Override
    public void dChanged() {
        dTranslator.dChanged();
    }
}


На этом всё.

Вообще про паттерны и ООП понятно, что стоит читать в GoF. Про паттерн Компоновщик можно почитать в хабрастатье Паттерн проектирования «Компоновщик» / «Composite» хабраюзера spiff. Про универсальные типы в Java можно почитать в 13-й главе первого тома Core Java Хорстманна и Корнелла. О похожих проблемах для паттерна Строитель можно прочитать в хабрастатье Расширяемым классам — расширяемые Builder'ы! хабраюзера gvsmirnov и комментариях к той статье. К чему может привести чрезмерное увлечение идеями ООП, рассказывается в хабрастатье So we put a factory into your algorithm или как не надо считать факториалы.
Tags:
Hubs:
Total votes 21: ↑19 and ↓2+17
Comments16

Articles