В этой хабрастатье на примере паттернов Наблюдатель и Компоновщик рассмотрено, как применять принципы объектно-ориентированного программирования, когда стоит использовать композицию, а когда наследование. А так же рассмотрено, какие существуют способы повторного использования кода, кроме 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 или как не надо считать факториалы.