В этой хабрастатье на примере паттернов Наблюдатель и Компоновщик рассмотрено, как применять принципы объектно-ориентированного программирования, когда стоит использовать композицию, а когда наследование. А так же рассмотрено, какие существуют способы повторного использования кода, кроме copy-paste.
Статья достаточно простая, в ней рассматриваются очевидные вещи, но надеюсь, что она будет интересна начинающим программистам, которые пока встречались со словами из первого абзаца только на лекциях по программированию. (На самом деле эта статья и есть кусочек практического занятия по программированию.)
Итак, пусть у нас есть следующий интерфейс для наблюдающих объектов.
И какой-нибудь такой класс для наблюдаемых объектов.
Здесь в методах
Всё это работает, но допустим в какой-то момент понимаем, что нужна возможность иметь разные способы уведомления слушателей. Например, уведомлять слушателей в разных порядках или в зависимости от каких-то условий некоторых слушателей не уведомлять о произошедших изменениях. Есть не менее двух вариантов решения этой проблемы. Во-первых, можно усложнить класс
Второй способ лучше по двум причинам. Если у нас не один класс для наблюдаемых объектов
Итак выбираем второй способ — код для уведомления слушателей выносим в отдельный класс. При этом поступим ещё чуть интереснее, а именно воспользуемся паттерном Компоновщик. Это нам даст не только выбирать различные способы уведомления слушателей, но и комбинировать их.
Тогда класс
В этом случае можно создавать целые матрёшки из слушателей.
Правда у такого решения есть и недостаток. Получив такую гибкость в создании агрегата наблюдающих объектов, можно создавать агрегаты, с таким порядком и правилами уведомления, что очень сложно будет в этом разобраться.
И снова этот код прекрасно справляется со своей задачей, но в вдруг оказывается (не зря же я везде букву
Таким образом, нам нужно как-то повторно использовать код. Есть как минимум три способа повторного использования кода. Это, во-первых, сopy-paste, во-вторых, наследование и, наконец, композиция + делегирование. Первый мы сразу же отбросим по идеологическим соображениям.
Разберём, почему не подойдёт нам и простое наследование, не смотря на то, что интерфейс
у нас сразу возникнет проблема с методом
то компилятор заботливо скажет:
Ладно, а почему бы нам не сделать проверку внутри этого метода, и если добавляемый объект не реализует интерфейс DListener, то кидать исключение? То есть почему бы не написать что-то такое:
Кроме того, что это выглядит как-то коряво, это ещё нарушает и принцип подстановки Лисков (тот, который LSP). Который можно сформулировать так: функции, которые используют ссылки на базовые классы, должны иметь возможность использовать объекты производных классов, не зная об этом. В самом деле, функция
выглядит вполне безобидно и ожидает (и оправданно), что в
и всё упадёт с исключением. А наша цель сделать так, чтобы такое даже скомпилировать нельзя было.
Да, вариант полностью отказаться от интерфейса
Итак, остаётся композиция + делегирование. Для начала из класса
Классы же, которые будут уведомлять слушателей, будут выглядеть следующим образом.
Здесь используется ограничивающие типы (
Теперь
На этом всё.
Вообще про паттерны и ООП понятно, что стоит читать в GoF. Про паттерн Компоновщик можно почитать в хабрастатье Паттерн проектирования «Компоновщик» / «Composite» хабраюзера spiff. Про универсальные типы в Java можно почитать в 13-й главе первого тома Core Java Хорстманна и Корнелла. О похожих проблемах для паттерна Строитель можно прочитать в хабрастатье Расширяемым классам — расширяемые Builder'ы! хабраюзера gvsmirnov и комментариях к той статье. К чему может привести чрезмерное увлечение идеями ООП, рассказывается в хабрастатье So we put a factory into your algorithm или как не надо считать факториалы.
Статья достаточно простая, в ней рассматриваются очевидные вещи, но надеюсь, что она будет интересна начинающим программистам, которые пока встречались со словами из первого абзаца только на лекциях по программированию. (На самом деле эта статья и есть кусочек практического занятия по программированию.)
Итак, пусть у нас есть следующий интерфейс для наблюдающих объектов.
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 или как не надо считать факториалы.
