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

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

    Комментарии 16

      +1
      А как будет вызываться метод dChanged() в наблюдаемом классе?
      Получается, что в методах aChanged() и bChanged() вы будете использовать оба итератора ATranslator и DTranslator? Как-то похоже больше на решение через реализацию, чем композицию. Наблюдаемый объект вообще не должен знать, что существуют два разных наблюдателя. В вашем случае для наблюдателей D я бы сделал иначе.
        +1
        А наблюдаемый класс, знает только о каком-то слушателе реализующий интерфейс AListener или DListener. В методах же aChanged() и bChanged() используется только один из «трансляторов».
        В статье я явно не написал, что есть множество слушателей, которые взаимодействуют с наблюдателями, реализующими интерфейс AListener, а есть множество тех, которые взаимодействуют с наблюдателями, реализующими интерфейс DListener. И заставить вторые работать со слушателями реализующими только AListener не удастся, чего и добивались.

        А как бы вы сделали?
          +2
          «В статье я явно не написал, что есть множество слушателей, которые взаимодействуют с наблюдателями» —

          тавтология какая-то
        +4
        для С++ имеется хорошая реализация такого механизма — сигналы и слоты в QT (есть и другие библиотеки ). Основная идея в том, что любой объект испускает в эфир сигналы о том, что что-то произошло, и любой желающий объект может этот сигнал прослушивать.
        Реализуется это с помощью надстройки над языком — Meta Object Compiler, по сути препроцессор.
          –1
          Эмммм, dependency injection, не?
            0
            Не. При DI внедряемый объект используется для вызова его основной логики (которая по хорошему должна быть единственной) и логика объекта, в который внедряется от него всё равно зависит как правило. Тут же слушатели подписываются на события, но на результат не влияют, просто делают свое дело (условно) параллельно. Области применения немного пересекаются, но только в простейших случаях. Хрестоматийный пример — при каком-то событии, например, вызов метода, нужно записать в лог сообщение или отправить его куда-нибудь. DI тут подойдет идеально, обеспечивая независимость от конкретной реализации лога или механизма отправки. Но как только нам нужно записать в лог и отправить сообщение, DI начинает потихоньку фэйлить и чем больше действий нужно совершить (записать в лог, отправить мыло, отправить смс, позвонить, сделать ролик для выкладывания на ютубе, отправить заметку в газету, вызвать корреспондента с ТВ, вызвать полицию для возбуждения дела и т. п.), тем больше фэйлит — зависмости хочешь-не хочешь нужно создавать в объекте, генрирующем события, что исключает, как минимум, его повторное использование, если в разных местах список действий разный.
              0
              Ну так AOP тогда. Любой di фрэймворк включает его поддержку.
            +2
            Язык бы указали лучше в заголовке типа "… на примере Java( или C#? — сложно различать когда только книжки читал и хелловорлды писал :) )" или хотя бы в тегах.
              0
              кстати да) указал в тегах.
              0
              «Всё это работает, но допустим в какой-то момент понимаем, что нужна возможность иметь разные способы уведомления слушателей. Например, уведомлять слушателей в разных порядках или в зависимости от каких-то условий некоторых слушателей не уведомлять о произошедших изменениях.»

              Если это основная задача, то порядок уведомления слушателей — задача достойная к рассмотрению. Вариантов решения — куча.

              А уведомлять слушателей в зависимости от каких то условий, задача в корне неверная, т.к. неправильно субъекту решать за наблюдателя важно ему это изменение или нет.
                +1
                А зачем собственно понадобился класс ListenerList? Замените его интерфейсом List<T extends *Listener> и получите то же самое
                  0
                  да, конечно же можно и так
                  +1
                  Я не понимаю, зачем вообще городить отдельные интерфейсы слушателей для разных наборов событий?
                  DCompositeListener, потом ведь пойдут какие-нибудь будут E, F… и так далее?

                  Достаточно раздать событиям номера (в смысле enum) или строковые идентификаторы, как отпадает необходимость в уникальных слушателях. Источник сгенерировал событие, а мы его раздали всем, кто таким типом событий интересуется.

                  var CPage = function() 
                  {
                  	var component = [];
                  	var events = [ 'Tick', 'Resize', 'Unload', 'MouseMove', 'MiscEvent', 'BodyLoad' ]; // writing with 1st capital is important
                  	var eventListener = {};
                  ////////////////////////////////////////////////////////////////////////////////////////////		
                  	function addComponent( which )
                  	{
                  		for( var i = 0; i < events.length; i++ )
                  		{
                  			if ( typeof which['on'+events[i]] == 'function' ) 
                  			{	// subscribe component
                  				if ( !eventListener[events[i]] )
                  					eventListener[events[i]] = [];
                  				eventListener[events[i]].push( which );				
                  			}
                  		}
                  		component.push( which );
                  	}	
                  ////////////////////////////////////////////////////////////////////////////////////////////		
                  	function onTick() 
                  	{
                  		nTicks++;
                  		for( var i = 0; i < eventListener['Tick'].length; i++ )
                  			eventListener['Tick'][i].onTick(nTicks);
                  	}
                  ////////////////////////////////////////////////////////////////////////////////////////////		
                  	function initSubscription() 
                  	{		
                  		if ( eventListener['Tick'] ) timerID = setInterval( onTick, updateInterval );
                  		if ( eventListener['Resize'] ) $(window).resize( onResize );
                  		if ( eventListener['Unload'] ) $(window).unload( onUnload );
                  		if ( eventListener['MouseMove'] ) document.onmousemove = onMouseMove;
                  	}
                  
                  ////////////////////////////////////////////////////////////////////////////////////////////		
                  	function onUnload()
                  	{
                  		var eventName = 'Unload' 
                  		var q = eventListener[]
                  		for( var i = 0; i < q.length; i++ )
                  			q[i]['on' + eventName]();
                  
                  	}
                  


                  Да, я тут рефлексию использую постоянно — но можно и без нее… добавить в слушателя метод, возвращающий вектор типов интересных ему событий, и вызывать в нем метод onEvent(eventType), внутри метода switch по типу — и всё!
                    0
                    Да, интересное решение, я даже не думал в эту сторону. А правда же, да, сам-то слушатель знает, какие события он умеет обрабатывать.
                    Хотя наверное понятно, почему не думал, в жизни у методов aChanged(), bChanged() разные сигнатуры были, но как реализовать вашу идею понятно и в этом случае.
                    0
                    Мне одному кажется, что слушатель (Listener) и наблюдатель (Observer) это все-таки разные паттерны?
                      0
                      Паттерна Listener я нигде не слышал. Этим словом мне удобно называть объект, уведомляемый о событиях.

                    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                    Самое читаемое