IT Notes

Паттерн Наблюдатель на C++11

Введение

Без паттерна Observer, то есть Наблюдатель, не обходится ни один SDK разработки графических интерфейсов. Любой объект пользовательского интерфейса является источником сигналов. Это могут быть щелчки или движения мышью, нажатия клавиш на клавиатуре и т.д. Например в Java для этих целей предусмотрен интерфейс Listener, а в Qt создана целая схема сигналов-слотов, которая расширяет синтаксис стандартного C++.

Однако иногда приходится создавать библиотеки, которые в дальнейшем могут использоваться в неопределенной среде и привязка к какому-то конкретному SDK в этом случае является существенным ограничением. Но и воссоздавать одни и те же фрагменты кода для разных реализаций паттерна Наблюдатель не слишком эффективно. Поэтому в этой заметке мы разработаем универсальный подмешиваемый класс Observable на C++11, который подойдет для любого класса-Источника.

Реклама

Коротко о паттерне Наблюдатель

Паттерн Наблюдатель включает в себя два компонента: Источник и Наблюдатель. Количество Наблюдателей одновременно взаимодействующих с Источником ничем не ограничено. Причем, к одному и тому же Источнику могут быть подключены объекты-Наблюдатели как одного класса, так и разных. Рассмотрим соответствующую диаграмму классов:

observer_class_diagram

На диаграмме представлен один Источник Source с двумя методами registerObserver() и unregisterObserver() для создания и прекращения связи с Наблюдателями. Наблюдатели должны реализовывать интерфейс SourceObserver, который содержит некий метод sourceEvent(), через который Источник будет сообщать об изменении состояния или новых событиях. На диаграмме для примера приводится две реализации ObserverImpl_1 и ObserverImpl_2, однако, как говорилось выше, их число не ограничено.

Примером Источника может служить кнопка Button в графическом интерфейсе пользователя. Тогда если мы хотим принимать события о нажатии на эту кнопку, то нам достаточно реализовать Наблюдателя на основе соответствующего интерфейса ButtonObserver и подписаться на прием сообщений с помощью метода Button::registerObserver().

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

Реклама

Шаблоны с переменным числом параметров на C++11

Для реализации универсального mixin-класса нам понадобится одна из возможностей, появившаяся в стандарте C++11. Если вы пользуетесь компилятором gcc, то для ее использования может потребоваться явно ее подключить с помощью опции -std=c++11 или -std=c++0x. Она обеспечивает поддержку шаблонов классов и функций с переменным числом параметров. Сразу же рассмотрим простой пример:

template< typename T >
T sum( const T& head ) {
    return head;
}

template< typename T, typename... Args >
T sum( const T& head, Args... args ) {
    return head + sum( args... );
}

Функция sum() принимает любое количество параметров и возвращает их сумму. Ее можно вызвать следующим образом:

sum( 1, 2, 3, 4, 5 ); // возвращает 15

Принцип ее работы основан на рекурсии. Сначала идет простой случай шаблонной функции sum(), который принимает один аргумент и сразу же его возвращает. Например, она будет вызвана в случае sum( 13 ) и вернет все те же 13. Такой код можно было написать и без C++11, но пользы от него не много.

Следующая шаблонная функция уже поинтереснее. В первом параметре шаблона у нее тоже стоит обычный параметр T. Но во втором параметре она принимает typename... Args. В качестве входных параметров у нее стоят const T& head и Args... args. Второе обозначение соответствует любому количеству аргументов любого типа. Например, если мы вызываем функцию sum() с параметрами 1, 2, 3, 4, 5 (то есть sum( 1, 2, 3, 4, 5 )), то внутри функции значение head = 1, а args = [ 2, 3, 4, 5 ]. Но прямо пройтись по args, как по массиву, мы не можем. На самом деле, мы лишь можем распаковать args с помощью записи вида args.... А этот распакованный набор переменных мы рекурсивно передаем в саму же функцию sum(). Если args у нас было равно [ 2, 3, 4, 5 ], то после распаковки вызов выглядит уже гораздо понятней:

sum( args... ); // если args = [ 2, 3, 4, 5 ], то равносильно вызову sum( 2, 3, 4, 5 );

В результате такого рекурсивного вызова мы вновь попадем в sum() со значением head = 2 и args = [ 3, 4, 5 ]. Потом произойдет еще два рекурсивных вызова, в последнем из которых head = 4 и args = [ 5 ]. Если мы распакуем такой args, то он превратится в единственное значение 5. Но ведь у нас есть функция sum(), которая как раз принимает единственный аргумент и сразу же его возвращает. Она и будет вызвана. На этом рекурсивный спуск завершен. Теперь начинает возврат по пути следования рекурсии, где мы к полученному результату вызова sum() прибавляем значение head:

return head + sum( args... );

Таким образом, вызов sum() с head = 4 и args = [ 5 ] вернет 4 + 5. Это значение получит предыдущий уровень рекурсии с head = 3 и args = [ 4, 5 ] и вернет 3 + ( 4 + 5 ). Этот процесс продолжится пока мы не дойдем до вызова sum() верхнего уровня с head = 1 и args = [ 2, 3, 4, 5 ]. Он вернет 1 + ( 2 + ( 3 + ( 4 + 5 ) ) ), то есть значение 15, которые мы и ожидали получить.

Так шаблоны с переменным числом параметров применяются для функций, но точно такой же синтаксис работает и для классов.

Mixin-класс Observable

Не будем долго рассуждать, а сразу возьмем и реализуем его:

#ifndef OBSERVABLE_H
#define OBSERVABLE_H

#include <set>
#include <vector>

template< class Observer >
class Observable {
public:
    virtual ~Observable() {
    }

    void registerObserver( Observer* observer ) {
        if( m_count != 0 ) {
            m_requests.push_back( ObserverRequest { &Observable< Observer >::registerObserver, observer } );
        } else if( observer ) {
            m_observers.insert( observer );
        }
    }

    void unregisterObserver( Observer* observer ) {
        if( m_count != 0 ) {
            m_requests.push_back( ObserverRequest { &Observable< Observer >::unregisterObserver, observer } );
        } else if( observer ) {
            m_observers.erase( observer );
        }
    }

protected:
    Observable() : m_count( 0 ) {
    }

    template< typename F, typename... Args >
    void notifyObservers( F f, Args... args ) {
        ++m_count;
        for( Observer* o : m_observers ) {
            ( o->*f )( args... );
        };
        --m_count;
        if( m_count == 0 ) {
            for( const ObserverRequest& r : m_requests ) {
                ( this->*r.operation )( r.observer );
            };
            m_requests.clear();
        }
    }

private:
    struct ObserverRequest {
        void ( Observable< Observer >::*operation )( Observer* );
        Observer* observer;
    };

    std::set< Observer* > m_observers;
    int m_count;
    std::vector< ObserverRequest > m_requests;
};

#endif // OBSERVABLE_H

Observable представляет собой шаблонный класс, параметром которого является Observer:

template< class Observer >
class Observable;

Деструктор этого класса объявлен как виртуальный, поскольку предполагается его дальнейшее наследование.

Затем идут открытые функции-члены registerObserver() и unregisterObserver(), о назначении которых мы уже говорили. Здесь следует обратить внимание на одну особенность. Класс Observable хранит указатели на объекты-Наблюдатели в множестве std::set, чтобы предотвратить создание дублирующих связей.

Кроме того, мы не можем просто добавить или убрать Наблюдателя. Это связано с тем, что отправка событий Наблюдателям осуществляется в цикле. При этом Наблюдатель может разорвать соединение в функции-обработчике, вызвав unregisterObserver().В результате нарушится структура множества и цикл будет вести себя неопределенным образом.

К похожей проблеме может привести ситуация, когда Источник, расширяющий класс Observable, может косвенно зависеть от Наблюдателей. То есть в цикле прохода по Наблюдателям может произойти некое событие, которое приведет к еще одному запуску цикла уведомлений. Конечно, это нежелательная ситуация, и она может закончиться переполнение стека, но мы разрабатываем обобщенный класс, поэтому не лишним будет предусмотреть такую возможность.

В связи с этим мы используем счетчик входа в цикл m_count. Если он не равен нулю, то добавление или удаление Наблюдателей осуществлять нельзя. В этом случае мы используем вспомогательный вектор std::vector для хранения соответствующих запросов. Сами запросы выражены следующей структурой:

struct ObserverRequest {
    void ( Observable< Observer >::*operation )( Observer* );
    Observer* observer;
};

Первым полем является указатель на функцию-член класса Observable. Этот указатель имеет сигнатуру, соответствующую функциям registerObserver() и unregisterObserver(). Они нам и нужны. А второе поле содержит указатель на Наблюдателя, для которого мы и должны вызвать функцию-член, указанную в первом поле. Добавление запроса происходит довольно просто:

// Запоминаем запрос на добавление Наблюдателя
m_requests.push_back( ObserverRequest { &Observable< Observer >::registerObserver, observer } );

// Запоминаем запрос на удаление Наблюдателя
m_requests.push_back( ObserverRequest { &Observable< Observer >::unregisterObserver, observer } );

Если счетчик m_count равен нулю, то таких сложностей не требуется и мы просто добавляем или убираем Наблюдателя.

Для уведомления всех Наблюдателей используется функция-член notifyObservers(). Она представляет собой уже знакомую нам шаблонную функцию с переменным числом параметров:

template< typename F, typename... Args >
void notifyObservers( F f, Args... args );

Первым аргументом мы ожидаем получить указатель на функцию-член класса Observer, после чего должно быть указано произвольное количество аргументов, с которыми и нужно вызвать эту функцию для каждого Наблюдателя. Перед входом в цикл мы увеличиваем значение счетчика m_count на единицу, а после завершения его работы уменьшаем на ту же единицу:

++m_count;
for( Observer* o : m_observers ) {
    ( o->*f )( args... );
};
--m_count;

В самом цикле мы всего лишь проходим по множеству указателей на Наблюдателей и вызываем для них функцию-член f с аргументами args.

После окончания цикла мы проверяем значение счетчика m_count. Если он равен нулю, то мы имеем право безопасно пройтись по всем запросам, которые за это время могли поступить, и добавить/убрать Наблюдателей:

if( m_count == 0 ) {
    for( const ObserverRequest& r : m_requests ) {
        ( this->*r.operation )( r.observer );
    };
    m_requests.clear();
}

Пример использования

Чтобы опробовать получившееся решение реализуем несложное приложение на основе mixin-класса Observable. Напишем программу для слежения за состоянием выбранного файла. Ограничимся всего лишь двумя характеристиками:

  1. Существует файл или нет;
  2. Каков размер файла.

Понятно, что на практике хотелось бы получить побольше информации, но для демонстрации этого вполне достаточно.

Начнем. Сначала просто создадим структуру для представления состояния файла с двумя полями, соответствующими приведенным выше характеристикам:

struct FileState {
    bool operator!=( const FileState& other ) {
        if( this == &other ) {
            return false;
        }
        return exists != other.exists || size != other.size;
    }

    bool exists;
    size_t size;
};

Поскольку нам потребуется уведомлять Наблюдателей об изменениях в состоянии файла, то для выполнения сравнений мы заранее определили оператор operator!=.

Теперь определим интерфейсный класс Наблюдателя:

class FileObserver {
public:
    virtual ~FileObserver() { }

    virtual void onFileChanged( const FileState& state ) = 0;
};

В нем нам понадобятся лишь виртуальный деструктор и чисто виртуальная функция-обработчик onFileChanged(), которую должны реализовать все конкретные Наблюдатели. А вот и возможная реализация:

class ConsoleFileObserver : public FileObserver {
public:
    ~ConsoleFileObserver();

    void onFileChanged( const FileState& state );
};

void ConsoleFileObserver::onFileChanged( const FileState& state ) {
    std::cout << "********************" << std::endl;
    std::cout << "FILE STATE CHANGED: " << std::endl;
    std::cout << "  Exists: " << state.exists << std::endl;
    std::cout << "  Size: " << state.size << std::endl;
    std::cout << std::endl << std::endl;
}

ConsoleFileObserver::~ConsoleFileObserver() {
}

Наблюдатель ConsoleFileObserver всего лишь выводит уведомление о произошедших изменениях в файле в стандартный поток вывода std::cout.

И наконец класс-Источник, который использует mixin-класс Observable и посылает Наблюдателям сообщения о произошедших с файлом изменениях:

#include <iostream>
#include <chrono>
#include <thread>

#include <sys/stat.h>

class FileMonitor : public Observable< FileObserver > {
public:
    FileMonitor( const char* fileName );

    void checkFile();

private:
    static FileState getFileState( const std::string& fileName );

private:
    std::string m_fileName;
    FileState m_prevFileState;
};

FileMonitor::FileMonitor( const char* fileName ) :
    m_fileName( fileName ), m_prevFileState( getFileState( m_fileName ) ) {
}

void FileMonitor::checkFile() {
    FileState fileState = getFileState( m_fileName );
    if( fileState != m_prevFileState ) {
        notifyObservers( &FileObserver::onFileChanged, fileState );
        m_prevFileState = fileState;
    }
}

FileState FileMonitor::getFileState( const std::string& fileName ) {
    FileState fileState;
    struct stat statBuf;
    fileState.exists = stat( fileName.c_str(), &statBuf ) == 0;
    fileState.size = fileState.exists ? statBuf.st_size : 0;
    return fileState;
}

Конструктор класса FileMonitor принимает имя файла, состояние которого он должен отслеживать. Кроме того, в конструкторе инициализируется переменная m_prevFileState с состоянием файла на момент создания объекта класса. Узнавать о состоянии файлов мы будем с помощью функции стандартной библиотеки C: stat(). Она вызывается в нашей статической функции класса getFileState() и возвращает заполненную структуру FileState, которую мы определили раньше.

В этом случае проверка состояния файла представляется крайне тривиальной и реализуется в функции-члене checkFile(). Мы просто запрашиваем актуальное состояние отслеживаемого файла с помощью getFileState(). Если оно изменилось (то есть не совпадает с предыдущим), то уведомляем об этом Наблюдателей и перезаписываем последнее состояние m_prevFileState, в противном случае делать ничего не нужно.

Ну и наконец реализуем функцию main(), в которой все это будет работать:

int main() {
    FileMonitor monitor( "test.txt" );
    ConsoleFileObserver observer;
    monitor.registerObserver( &observer );

    while( true ) {
        monitor.checkFile();
        std::this_thread::sleep_for( std::chrono::milliseconds( 100 ) );
    }

    return 0;
}

Сначала мы создаем объект-Источник monitor для отслеживания состояния текстового файла test.txt. Затем определяем объект-Наблюдатель observer. Связываем Наблюдателя с Источником с помощью registerObserver(). И запускаем бесконечный цикл, в котором будем обновлять состояние Источника каждые 100 миллисекунд.

Результат работы программы представлен на скриншоте:

file_observer_test

Перед запуском приложения файла с именем test.txt не существовало. На консоли ничего не отображалось. Затем я выполнил команду touch test.txt и на консоли появилось первое уведомление о том, что отслеживаемый файл существует, но в нем ничего нет. Потом я выполнил echo "Hello, World!" >> test.txt и появилось второе уведомление о том, что файл уже не пуст. Наконец я набрал rm test.txt и приложение вывело печальное известие о том, что такого файла больше с нами нет.

Заключение

В этой заметке мы вспомнили паттерн Наблюдатель. Посмотрели на одну из новых возможностей, добавленных в C++11, а именно на шаблоны с переменным числом параметров. Затем реализовали универсальный примешиваемый mixin-класс Observable для многократного повторного использования. А в конце в качестве теста нашего нового класса разработали несложное консольное приложение, которое сообщит вам, если файл, за которым оно следит, вдруг изменится.

Понравилась статья?
Не забудь поделиться ей с друзьями!
Реклама

Похожие публикации

Комментарии

Замечательная статья.

Спасибо за комментарий =)

Хорошо написано, спс

RSS RSS-рассылка

Популярное