Библиотека boost::signals2 изнутри

Автор: Григорьев Вячеслав Владимирович
Источник: RSDN Magazine #1-2010
Опубликовано: 06.09.2010
Версия текста: 1.1
Введение
Простой пример использования
Внутренние детали
Класс signal_base
Класс slot_base
Классы detail::connection_body_base, connection_body, connection и scoped_connection
Класс detail::grouped_list
Класс detail::tracked_objects_visitor
Классы slot, slotN и slot[N]
Шаблонные классы Combiner
Шаблон класса итератора вызова detail::slot_call_iterator_t
Классы signal, signalN, signal[N] и signal[N]_impl
Соберём всё вместе
Темы, оставшиеся «за бортом»
Зачем?
Список литературы

Введение

Многие C++-программисты слышали о библиотеке boost. Точнее будет сказать, что это целый конгломерат библиотек, которые можно скачать с сайта http://www.boost.org. Наверное, многие изучали код библиотек в качестве упражнения для ума, или с целью доработки их под свои задачи. И, смею предположить, многие из этих любопытных оставили это занятие в связи с весьма нетривиальной структурой кода этих библиотек. Многие классы, выглядящие для пользователя простыми и понятными, на поверку оказываются хитрым сплетением множества шаблонов, назначение которых иногда ставит в тупик даже весьма продвинутого специалиста. Библиотеки часто оказываются связанными между собой, и изучение внутренностей одной из них подталкивает к ковырянию в соседних. Кроме того, если связанные библиотеки были написаны одной и той же группой авторов, они могут использовать недокументированные возможности друг друга. В этом случае простого прочтения tutorial’а точно не хватает.

Одна из библиотек, входящих в состав boost’а, называется signals2. Её назначение описано в документации в разделе Introduction. Если перевести кратко: библиотека signals2 является реализацией инфраструктуры управляемых сигналов и слотов. Сигналы представляют собой вызывающие сущности, поддерживающие множество точек вызова. В некоторых системах они именуются событиями или источниками вызова. Сигналы подключаются к множеству слотов, иногда называемых приёмниками вызова. Слоты вызываются в тот момент, когда соответствующий сигнал активируется. Интересной особенностью реализации является поддержка отслеживаемых (trackable) объектов, чьи методы вызываются слотами. Объекту слота можно передать слабый указатель на объект (weak_ptr). В случае если объект будет удалён, соответствующее подключение будет разорвано.

В статье предпринята попытка разобраться с назначением основных классов библиотеки изнутри.

ПРЕДУПРЕЖДЕНИЕ

1. В тексте часто используются понятия «интеллектуальный указатель» или «сильная ссылка» (smart_ptr) и «слабая ссылка» (weak_ptr). Для понимания статьи желательно знакомство читателя с другой boost-библиотекой «Smart Pointers».

2. Описание назначения и смысла классов отражает мое понимание библиотеки. Консультаций с авторами библиотеки не проводилось.

ПРИМЕЧАНИЕ

Несмотря на пункт 1 предупреждения, приведу вольное объяснение часто используемых далее распространенных терминов. Интеллектуальный указатель (или сильная ссылка) – объект, эмулирующий обычный указатель, управляющий временем жизни объекта, на который указывает. Когда в программе более не остаётся копий интеллектуального указателя, объект, на который все они указывали, автоматически удаляется. Слабая ссылка – вспомогательный объект, из которого может быть получен интеллектуальный указатель, если конечный объект всё ещё существует. Если конечный объект был удалён (в силу того, что в программе не осталось ссылающихся на него интеллектуальных указателей), объект слабой ссылки в том или ином виде вернёт ошибку.

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

В инструкции по библиотеке в секции для новичков приведён пример использования сигналов. Для изучения работы библиотеки используем подобный пример. В нём создаются два вызываемых объекта. Второй делается отслеживаемым и удаляется сразу после подключения. Это позволит рассмотреть, как работает trackable-поддержка.

      #include <boost/signals2.hpp>

usingnamespace std;
usingnamespace boost::signals2;

struct HelloWorld 
{
  voidoperator()(int n) const 
  {
    cout << "HelloWorld invocation with param " << n << endl;
  }
};

struct TrackedHelloWorld 
{
  voidoperator()(int n) const 
  {
    cout << "TrackedHelloWorld invocation with param " << n << endl;
  }
};


int _tmain(int argc, _TCHAR* argv[])
{
  // Сигнал, принимающий 1 целочисленный аргумент и возвращающий void
  boost::signals2::signal<void (int)> sig;

  // Создать вызываемый объект HelloWorld и подключить его
  HelloWorld hello;
  sig.connect(hello);

  // Создать вызываемый объект TrackedHelloWorld и подключить  // его посредством сильной ссылки; сделать его отслеживаемым.  // После выхода из блока объект будет удалён, и соответствующее  // подключение будет разорвано.
  {
    boost::shared_ptr<TrackedHelloWorld> p(new TrackedHelloWorld());
    boost::signals2::signal<void (int)>::slot_type s(*p);
    s.track(p);
    sig.connect(s);

    // Вызвать оба объекта
    sig(2);
  }

  // Вызвать первый объект (слот второго стал «неактивным»)
  sig(3);

  return 0;
}

Вывод примера на консоль будет таким:

HelloWorld invocation with param 2
TrackedHelloWorld invocation with param 2
HelloWorld invocation with param 3

С точки зрения пользователя пример достаточно прост. В стеке создаётся объект hello типа HelloWorld и подключается к сигналу. Далее в куче создаётся объект TrackedHelloWorld, адрес которого сохраняется в интеллектуальном указателе shared_ptr<>. Интеллектуальные указатели в данном случае выбраны для того, чтобы показать, как инфраструктурой библиотеки signals2 обрабатывается ситуация, когда удаляется объект с подключённым в качестве слота методом. В следующей строке создаётся слот, которому в качестве параметра инициализации предлагается собственно вызываемый объект. Указатель на данный объект подключается к слоту через метод track(...), то есть теперь слот будет отслеживать наличие этого объекта. Обратим внимание на то, что этот метод получает слабую ссылку на объект, которая неявно получается из предоставленного указателя. В противном случае объект бы оказался заблокированным в самом слоте, и его нельзя было бы удалить, пока слот существует. Полученный слот подключается к сигналу. Осуществляется первая активация сигнала – оба слота срабатывают. А в следующей строке указатель p и объект ссылки удаляются. Так что при повторной активации сигнала будет вызван только первый слот (объект HelloWorld). При разыменовании слабой ссылки на второй слот будет обнаружено, что его нет, и соответствующий слот будет пропущен.

Внутренние детали

Чтобы понять, что происходит внутри, нужно рассмотреть несколько ключевых классов. Их описание приводится ниже. Некоторые из них представляют собой законченные самостоятельные метафункции и классы, решающие чётко ограниченные задачи. Другие же являются «кубиками» для сборки, и потому имеют сильные зависимости друг от друга. Их рассмотрение осложняется ещё тем, что зачастую в них используются приёмы препроцессорного программирования с использованием другой интересной boost-библиотеки «Препроцессор». Для них приводятся примеры кода, который получается после обработки исходных текстов препроцессором.

ПРИМЕЧАНИЕ

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

Чтобы не дублировать в статье библиотеку, приводятся только существенные для понимания детали. Где возможно, предлагаются ссылки на сайт библиотеки, по которым можно посмотреть содержимое того или иного файла. Это может быть удобно, если у вас не установлена локальная копия библиотеки, но вы читаете электронную версию статьи.

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


Рисунок 1.

Класс signal_base

Служит базовым классом для всех сигналов. Фактически, это чистый интерфейс всего с одним защищённым методом lock_pimpl().

        class signal_base : public noncopyable
{
  protected:
      ...
      virtual shared_ptr<void> lock_pimpl() const = 0;
};

Каково назначение этого метода? Сигналы в библиотеке реализуются с помощью идиомы pimpl (http://en.wikipedia.org/wiki/Pimpl). То есть, реализиция всей функциональности сигналов выполнена во внутренних классах signal[N]_impl (см. ниже). А класс сигнала, видимый пользователю библиотеки, является всего лишь обёрткой над ней. Он содержит сильную ссылку на внутренний объект реализации, которому и делегируется вся работа. Можно представить случай, когда все обёртки удаляются во время выполнения процедуры вызова слотов. Это приведёт к удалению объекта реализации, что, скорее всего, вызовет падение программы. Метод lock_pimpl() позволяет получить копию интеллектуального указателя на реализацию и тем самым защитить её от возможного удаления до окончания обработки вызова. (Исходный код можно посмотреть в файле signal_base.hpp).

Класс slot_base

Служит базовым классом для всех слотов. Содержит вектор слабых ссылок на отслеживаемые (trackable) объекты. Для того чтобы осуществить вызов, внешний код должен, во-первых, убедиться, что отслеживаемые объекты не удалены, а во-вторых, запретить их удаление на время вызова. Обе задачи решаются с помощью метода lock(). В нём для каждой слабой ссылки получается сильная ссылка, которая кладётся во временный вектор. Если какой-то отслеживаемый объект был удалён, преобразование в сильную ссылку завершится неудачей, и метод сгенерирует исключение. Если же всё прошло успешно, сконструированный вектор возвращается вызывающему коду. Последний, удерживая его, может защитить отслеживаемые объекты от удаления.

Метод expired() просто проверяет, есть ли среди отслеживаемых хотя бы один удалённый объект. (Исходный код можно посмотреть в файле slot_base.hpp).

        class slot_base
{
  public:
      typedef std::vector<boost::weak_ptr<void> > tracked_container_type;
      typedef std::vector<boost::shared_ptr<void> > locked_container_type;

      const tracked_container_type& tracked_objects() const;
      locked_container_type lock() const;
      bool expired() const;

  protected:
      void track_signal(const signal_base &signal);
      tracked_container_type _tracked_objects;
};

Блокировка может осуществляться примерно таким кодом:

        class slot: public slot_base {...};
...
slot some_slot_obj;
try 
{
  slot_base::locked_container_type l_obj(some_slot_obj.lock());
  ... // осуществить вызов
} 
catch(expired_slot &e) 
{
  // обработать ошибку «неактивный слот»
}

Классы detail::connection_body_base, connection_body, connection и scoped_connection

Объект «подключение» создаётся, когда к сигналу методом connect(...) подключается какой-либо слот. Описываемые здесь классы служат основой для построения объектов подключения. Класс connection_body_base определяет основной интерфейс подключения и реализует семантику блокирования подключения. Он также определяет интерфейс внутренней блокировки в условиях многопоточности с помощью методов lock и unlock.

        class connection_body_base
{
  public:
    void disconnect();
    void nolock_disconnect();
    virtualbool connected() const = 0;
    shared_ptr<void> get_blocker();
    bool blocked() const;
    bool nolock_nograb_blocked() const;
    ...

    // Методы для многопоточной блокировкиvirtualvoid lock() = 0;
    virtualvoid unlock() = 0;

  protected:
    mutablebool _connected;
    weak_ptr<void> _weak_blocker;
};

Блокирование подключения реализовано с использованием слабых и сильных ссылок. Вызывающий код может в любое время получить экземпляр ссылки через метод get_blocker(). Пока во внешнем коде сохраняется любое количество этих экземпляров, подключение считается заблокированным. Подключение может также быть активным (_connected == true) или неактивным. С помощью метода nolock_nograb_blocked() можно выяснить, является ли подключение заблокированным или неактивным. В обоих случаях оно не может быть использовано.

Класс connection_body реализует основной интерфейс подключения и интерфейс внутренней блокировки, переопределяя виртуальные методы, приведённые выше. Внутренняя блокировка многопоточности реализуется тривиальным образом, перенаправляя вызовы lock() и unlock() соответствующим методам экземпляра класса Mutex.

        template<typename GroupKey, typename SlotType, typename Mutex>
class connection_body: public connection_body_base
{
  public:
        virtualbool connected() const;
        ...
        bool nolock_slot_expired() const;
        template<typename OutputIterator>
          void nolock_grab_tracked_objects(OutputIterator inserter) const;
        ...
        SlotType slot;
  private:
      mutable mutex_type _mutex;
      ...
};

SlotType в реализации библиотеки всегда является потомком класса slot_base, описанного выше. Исходя из этого, становится ясным назначение и реализация метода nolock_grab_tracked_objects(...). Метод выполняет сразу две задачи:

а) проверяет, что ни один из отслеживаемых объектов слота не удалён;

б) получая сильные ссылки на них, копирует их через итератор вывода, защищая тем самым объекты от удаления.

Если первое условие нарушено, состояние самого connection_body устанавливается в (_connected = false). Таким образом, подготовка к вызову может быть выполнена таким кодом:

connection_body some_conn_obj;
std::vector<shared_ptr<void> > v_blockers;
some_conn_obj.nolock_grab_tracked_objects(std::back_inserter(v_blockers));
if(some_conn_obj.nolock_nograb_connected())
{
  ... // осуществить вызов
}
v_blockers.clear();

Метод nolock_slot_expired() – вспомогательный. Он служит просто для проверки наличия отслеживаемых объектов без их блокировки. Если какой-то объект удалён, подключение считается неактивным.

Метод connected() делает практически то же самое. Только он устанавливает при этом блокировку на mutex’е и возвращает инверсный результат. (Исходный код можно посмотреть в файле connection.hpp).

Классы connection и scoped_connection являются просто value-обёртками над объектом-потомком connection_body_base (в данной реализации библиотеки это может быть только класс connection_body). Причём эти обёртки хранят слабые ссылки на подключение, так как время жизни подключения определяется не пользователем (который может сохранить для себя connection или scoped_connection), а инфраструктурой библиотеки. Обёртка scoped_connection позволяет разрывать подключение автоматически при удалении самой себя.

Класс detail::grouped_list

Только что рассмотренные объекты подключений нужно где-то хранить. Для этой цели в библиотеке существует шаблонный класс grouped_list, интерфейс которого приведён ниже.

        template<typename Group, typename GroupCompare, typename ValueType>
class grouped_list
{
  private:
      typedef std::list<ValueType> list_type;
      typedef std::map<group_key<Group>::type, list_type::iterator, ...> map_type;

  public:
      typedeftypename list_type::iterator iterator;
      typedeftypename list_type::const_iterator const_iterator;
      typedeftypename group_key<Group>::type group_key_type;

      ...
      iterator begin();
      iterator end();
      iterator lower_bound(const group_key_type &key);
      iterator upper_bound(const group_key_type &key);
      void push_front(const group_key_type &key, const ValueType &value);
      void push_back(const group_key_type &key, const ValueType &value);
      void erase(const group_key_type &key);
      iterator erase(const group_key_type &key, const iterator &it);

  private:
      ...
      // в хэше – итераторы списка, указывающие на начало каждой группы
      map_type _group_map;
      list_type _list;
      ...
};

Интерфейс класса сильно напоминает типовой STL-контейнер. Собственно, и реализован он с помощью двух контейнеров – map и list. Его задача – хранить экземпляры типа ValueType в подмножествах по группам. Методы позволяют управлять содержимым списка по каждой группе, идентифицируемой соответствующим ключом. Итераторы этого контейнера обходят весь список от начала до конца. group_key_type определяет тип ключа, по которому находится начало и конец определённой группы методами lower_bound(...) и upper_bound(...), соответственно. В данной статье типы групп подробно не рассматриваются. (Исходный код можно посмотреть в файле slot_groups.hpp).

Класс detail::tracked_objects_visitor

Данный шаблонный класс представляет собой пример нетривиального метапрограммирования. Фактически это метафункция, используемая при инициализации нового объекта слота (чуть ниже) для того, чтобы выяснить, является ли переданная пользователем вызываемая сущность отслеживаемым объектом. В библиотеке signals2 сделать объект отслеживаемым можно двумя способами: сделать его наследником базового класса типа trackable или передать слабую ссылку на объект в метод track(...) слота. Описываемый вспомогательный класс реализует первый способ. В конструкторе ему передаётся слот, а в шаблонном методе operator(...) – вызываемая сущность. Далее следует примерно такая последовательность распознавания типа:

  1. Если передан объект типа – boost::ref<T> – снять обёртку ref и получить обычный указатель на объект типа T, ссылка на который хранилась в обёртке.
  2. Если передан не указатель, а какой-то объект T, получить обычный указатель на него.
  3. Если полученный адрес указывает на функцию, дальше объект не рассматривать.
  4. Если адрес указывает на сигнал – подключить его к слоту как отслеживаемый объект.
  5. Если адрес указывает на объект, неявно преобразуемый к trackable – подключить его к слоту.

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

Классы slot, slotN и slot[N]

Наконец теперь можно рассмотреть, что представляет собой законченный объект слота. Первое, что необходимо отметить, разработчики библиотеки столкнулись с типовой проблемой – в языке C++ нет безопасной передачи переменного числа аргументов с проверкой типа. И решение в данном случае такое же, как и везде – определить классы или методы, поддерживающие от 1 до N аргументов. Для этого в библиотеке широко используется препроцессорное программирование.

Для начала повторным включением файла slot_template.hpp в preprocessed_slot.hpp определяются версии класса slot[N] (где N равно от 1 до максимального числа аргументов, установленного директивой). В статье используются обозначения типа slot[N], чтобы показать множество классов slot1, slot2, ... , которые будут существовать в скомпилированной программе.

        template<typename R, typename T1, typename T2, ..., typename T[N],
    typename SlotFunction = boost::functionN<R, T1, T2, ..., T[N]> >
class slot[N] : public slot_base, detail::std_functional_base
{
  public:
      typedef SlotFunction slot_function_type;
      typedef R result_type;

      ...
      template<typename F> slot[N](const F& f) { init_slot_function(f); }
      R operator()(T1 arg1, T2 arg2, ...TN argN);
      slot[N]& track(const weak_ptr<void> &tracked);
      slot[N]& track(const signal_base &signal);
      slot[N]& track(const slot_base &slot);

  private:
      ...
      SlotFunction _slot_function;
};

Как правило, слоты неявно создаются в методе connect(...) сигнала, когда туда передаются вызываемые сущности. Часто эти сущности имеют некоторые параметры, поэтому программист должен связать (bind) сущность с какими-то значениями этих параметров, прежде чем делать вызов метода connect(...). Для облегчения задачи программисту предлагается множество перегрузок конструктора класса slot[N], принимающих различное число параметров дополнительно к самой вызываемой сущности. Эти конструкторы сами связывают сущность и переданные параметры. С помощью препроцессорного программирования в библиотеке создаётся M (задаётся препроцессорной директивой) конструкторов для этих целей:

        template<Func, BindArgT1>
slot[N](const Func &f, const BindArgT1 &arg1)
{
    init_slot_function(boost::bind(f, arg1));
}

...
template<Func, BindArgT1, BindArgT2, ...BindArgTM>
slot[N](const Func &f, const BindArgT1 &arg1, const BindArgT1 &arg2, ... const BindArgTM &argM)
{
    init_slot_function(boost::bind(f, arg1, arg2, ... argM));
}

Внутренняя шаблонная функция init_slot_function(...), во-первых, присваивает внутреннему функтору слота вызываемую сущность, полученную из аргумента посредством применения метафункции get_invocable_slot(...). А во-вторых, используя класс tracked_objects_visitor, рассмотренный выше, пытается сделать переданный объект отслеживаемым. Метафункция get_invocable_slot(f) определяет, был ли переданный объект f сигналом или чем-то иным. В первом случае она возвращает экземпляр вспомогательной прослойки weak_signal[N], позволяя при активации перенаправлять вызов объекту сигнала, если последний не был удалён. Во втором случае она просто возвращает переданный ей объект f.

Оператор вызова в классе slot[N] тривиален – он просто передает выполнение объекту _slot_function вместе с параметрами. Исходя из только что сказанного, самое простое использование класса slot[N] могло бы быть таким:

        void f(int n) { cout << n; };
...
signal<void (void)>::slot_type s(&f, 1);  // используется связывающий конструктор класса slot1
s();                                      // вызов функции f; будет напечатано «1»

Специализации класса slotN (не slot[N]) служат только для получения конкретного типа slot[N] для заданного числа аргументов. Например, выражение slotN<2, ...>::type приводит к slot2<...>.

Класс slot – просто обёртка (производный класс) над slotN::type, введённая, чтобы из сигнатуры функции получить число параметров и передать его первым шаблонным аргументом в slotN. В обёртке нет ничего, кроме вынужденного переопределения всех конструкторов, конструируемого так же, как и в классе slot[N].

Шаблонные классы Combiner

Библиотека signals2 предусматривает возможность подключения множества слотов к одному сигналу. Возникает вопрос – как объединять результаты вызова этих слотов при возврате из оператора вызова сигнала? Проектное решение состоит в том, чтобы вынести этот вопрос в отдельный класс, предоставить пользователю реализацию по умолчанию или возможность задействовать свой собственный класс. Интерфейс такого класса прост – он должен предоставлять всего один оператор вызова, принимающий диапазон, заданный двумя итераторами в духе STL. В операторе вызова перебираются значения, попадающие в этот диапазон, а значения, получаемые разыменовыванием итераторов, комбинируются так, как это нужно пользователю. Вызывающий код предоставляет итераторы специального вида, которые во время разыменования передают управление нижележащим слотам, и возвращают значения, возвращённые этими слотами. В качестве примера можно обратиться к тривиальному Combiner’у, представленному в файле optional_last_value.hpp.

Шаблон класса итератора вызова detail::slot_call_iterator_t

Данный класс фактически является адаптером итераторов списка подключений. Его работа становится более-менее очевидной, если уточнить, что тип Iterator, которым он специализируется, является типом detail::grouped_list::iterator, то есть:

std::list<shared_ptr<connection_body<...> > >::iterator

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

        template<typename Function, typename Iterator, typename ConnectionBody>
class slot_call_iterator_t
  : public boost::iterator_facade<slot_call_iterator_t<Function, Iterator, ConnectionBody>...
{
      ...
  public:
      slot_call_iterator_t(Iterator iter_in, Iterator end_in,
          slot_call_iterator_cache<result_type, Function> &c);

      typename inherited::reference dereference() const {
        ...
        cache->result.reset(cache->f(*iter));
        ...
      }
      void increment();
      bool equal(const slot_call_iterator_t& other) const;

   private:
      ...
      slot_call_iterator_cache<result_type, Function> *cache;
};

При инкременте происходит передвижение позиции внутреннего итератора, а затем осуществляется попытка получить все временные блокировки. Если это не удаётся, попытка повторяется на следующей позиции внутреннего итератора. В итоге либо будет найден итератор подключения, слот которого удалось заблокировать, и он готов к вызову, либо будут исчерпаны все итераторы списка подключений. (Исходный код можно посмотреть в файле slot_call_iterator.hpp).

Классы signal, signalN, signal[N] и signal[N]_impl

Реализация сигналов выполнена с использованием хорошо известной идиомы pimpl, заключающейся в том, что класс, видимый пользователю, представляет всего лишь обёртку, имеющую указатель на объект реализации (pointer-to-implementation). Все операции, которые пользователь выполняет с классом, транслируются в соответствующие вызовы к объекту реализации. Такая архитектура помогает подключать к сигналу другой сигнал, организуя тем самым цепочки вызовов. Происходит это следующим образом. Допустим, программист написал: sig1.connect(sig2). Как уже известно, перед вызовом метода connect(...) неявно создаётся объект слота, инициализируемый сигналом sig2. Этот сигнал попадает в функцию get_invocable_slot(...) (см. описание классов slot[N], slot выше), которая возвращает объект weak_signal[N], полученный из sig2. Он является обёрткой для реализации signal[N]_impl из исходного объекта sig2. Эта обёртка и сохраняется в объекте слота, который подключается к сигналу sig1.


Итак, множество классов signal[N]_impl (где N равно от 1 до максимального числа аргументов, установленного директивой) представляет собой реализацию сигнала, примерный вид которой представлен ниже:

        template<typename R, typename T1, typename T2, ...typename TN,
  typename Combiner, typename Group, typename GroupCompare,
  typename SlotFunction, typename ExtendedSlotFunction, typename Mutex>
class signal[N]_impl
{
  public:
    typedef SlotFunction slot_function_type;
    typedef slot[N]<R, T1, T2, ...TN, slot_function_type> slot_type;
    typedef ExtendedSlotFunction extended_slot_function_type;
    typedef slot[N+1]<R, const connection&, T1, T2, ...TN, extended_slot_function_type> extended_slot_type;
    typedeftypename nonvoid<typename slot_function_type::result_type>::type nonvoid_slot_result_type;
    typedef slot_call_iterator_cache<nonvoid_slot_result_type, slot_invoker> slot_call_iterator_cache_type;
    typedeftypename group_key<Group>::type group_key_type;
    typedef shared_ptr<connection_body<group_key_type, slot_type, Mutex> > connection_body_type;
    typedef grouped_list<Group, GroupCompare, connection_body_type> connection_list_type;
      ...
  public:
    typedef Combiner combiner_type;
    typedeftypename result_type_wrapper<typename combiner_type::result_type>::type result_type;
    typedeftypename detail::slot_call_iterator_t<slot_invoker,
      typename connection_list_type::iterator,
      connection_body<group_key_type, slot_type, Mutex> > slot_call_iterator;

    ...
    connection connect(const slot_type &slot, connect_position position = at_back);
    connection connect(const group_type &group, const slot_type &slot,
      connect_position position = at_back);
    template <typename T> void disconnect(const T &slot);

    result_type operator ()(T1 arg1, T2 arg2, ...TN argN);

    ...
    mutable shared_ptr<invocation_state> _shared_state;
    mutabletypename connection_list_type::iterator _garbage_collector_it;
    ...
};

Некоторые компиляторы не поддерживают возврат значения void. Поэтому во многих местах в библиотеке используется распознавание типа возврата R и реализуются две версии кода. В других же местах используется замена типа – если тип возврата void, используется фиктивный тип void_type. Метафункция nonvoid предназначена для такой замены. В коде встречается ещё одна функция result_type_wrapper, делающая то же самое. Разница только в том, что функциональность последней управляется препроцессорной директивой.

Для хранения подключений используется объект класса detail::grouped_list, уже рассмотренный выше. Причём экземпляры списка и Combiner’а вынесены в виде сильных ссылок во вспомогательный класс invocation_state, ссылка на который хранится в рассматриваемом signal[N]_impl.

Методы connect(...) просто добавляют слот в список подключений с различными параметрами. Метод disconnect(...) меняет статус подключения на статус «не активно», но не удаляет его. Это делается вспомогательным методом nolock_cleanup_connections(...), вызываемым из разных мест класса.

В операторе вызова используется вспомогательный класс slot_invoker, который является тем самым функтором, через который осуществляется вызов итератором detail::slot_call_iterator_t. После расшифровки всех препроцессорных директив получается тривиальная реализация, которая в конструкторе принимает ссылки на параметры, а в операторе вызова осуществляет вызов функтора слота через переданный объект подключения.

        class slot_invoker {
  public:
      T1 &arg1;
      T2 &arg2;
      ...
      slot_invoker(T1 &arg1, T2 &arg2, ...) : arg1(arg1), arg2(arg2), ... {}
      nonvoid_slot_result_type operator ()(const connection_body_type &connectionBody) const;
      ...
      nonvoid_slot_result_type m_invoke(const connection_body_type &connectionBody, ...) const
      {
        return connectionBody->slot.slot_function()(arg1, arg2, ...);
      }
};

Оператор вызова выполняет следующие операции. Сперва методом nolock_cleanup_connections() производится очистка списка от устаревших подключений, то есть тех, отслеживаемые объекты которых были удалены. Затем создаётся вспомогательный объект slot_invoker и кэш slot_call_iterator_cache. Создаётся ещё один вспомогательный объект invocation_janitor, единственная цель которого – после завершения оператора вызова очистить список от устаревших подключений, которые стали таковыми за время осуществления вызова сигнала. Кроме того, возможно, он способствует оптимизации возвращаемого Combiner’ом значения, так как позволяет записать одним выражением и вызов Combiner’а, и возврат из функции.

Собственно последним выражением следует вызов Combiner’а с передачей ему двух итераторов slot_call_iterator_t. Combiner осуществит вызов слотов, разыменовывая эти итераторы, как было описано выше. В этом выражении используется ещё одна прослойка detail::combiner_invoker, опять же с целью различить возврат типа void и заменить его возвратом фиктивного типа void_type.

Специализация класса signal[N] является обёрткой над реализацией signal[N]_impl и служит только для того, чтобы предоставить публичниый интерфейс сигналов, делегирующий все вызовы через указатель _pimpl объекту реализации.

С помощью специализаций шаблонного класса signalN можно получить соответствующий тип signal[N], задавая число аргументов. Пример: signalN<2>::type.

Наконец, класс signal – просто обёртка (производный класс) над signalN::type введённая для того, чтобы из сигнатуры функции получить число параметров и передать его первым шаблонным аргументом в signalN. Идея та же, что и с классами slot, slotN и slot[N]. (Исходный код можно посмотреть в файле signal_template.hpp).

Соберём всё вместе

Если уважаемый читатель добрался до этого места, то теперь он имеет все сведения, чтобы понять, что же происходит в примере, приведённом в начале статьи. Рассмотрим работу примера по шагам.

signal<void (int)> sig;
...
sig.connect(hello);

Подключение. Метод connect(…) принимает слоты, а объект hello таковым не является. Следовательно, ещё до вызова метода в программе будет создан временный объект типа slot_type с передачей в его конструктор объекта hello. slot_type в этом примере раскрывается в slot1<void, int>. В конструкторе этого класса вызывается функция init_slot_function, которая присваивает объект внутренней переменной _slot_function. Так как HelloWorld не является отслеживаемым, tracked_objects_visitor ничего не делает. Далее объект слота передаётся сигналу (а это всего лишь обёртка), а тот передаёт вызов реализации signal1_impl<void, int, ...>. Последняя добавляет его в список detail::grouped_list, вызывая его метод push_back(...).

{
    boost::shared_ptr<TrackedHelloWorld> p(new TrackedHelloWorld());
    signal<void (int)>::slot_type s(*p);
    s.track(p);

В куче создаётся второй объект и передаётся новому объекту slot1<void, int>. И теперь также происходит всего лишь присвоение объекта переменной _slot_function, а tracked_objects_visitor ничего не делает. Однако в третьей строке «вручную» осуществляется подключение объекта к системе слежения. В метод track передаётся слабая ссылка weak_ptr<TrackedHelloWorld>, полученная неявным образом, а тот вставляет её в массив _tracked_objects, определённый в базовом классе slot_base.

    sig.connect(s);

    // вызов обоих слотов
    sig(2);
}

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

sig(3);

Здесь происходит всё самое интересное.

  1. Вызывается operator()(3) у сигнала signal1. Тот передаёт вызов реализации signal1_impl::operator()(3). Для лучшего понимания она представлена ниже.
result_type operator (…) const
{
  shared_ptr<invocation_state> local_state;
  typename connection_list_type::iterator it;
  {
    unique_lock<mutex_type> list_lock(_mutex);
    if(_shared_state.unique())
      nolock_cleanup_connections(false);
    local_state = _shared_state;
  }
  slot_invoker invoker = slot_invoker(…);
  slot_call_iterator_cache_type cache(invoker);
  invocation_janitor janitor(…);
  return detail::combiner_invoker<typename combiner_type::result_type>()
  (
    local_state->combiner(),
    slot_call_iterator(local_state->connection_bodies().begin(),
      local_state->connection_bodies().end(), cache),
    slot_call_iterator(local_state->connection_bodies().end(),
      local_state->connection_bodies().end(), cache)
  );
}
  1. signal1_impl::operator()(...) предварительно производит очистку неактивных подключений методом nolock_cleanup_connections(...). Очистка осуществляется только до момента нахождения первого нормального подключения. В данном случае это и есть первое подключение, поэтому ничего очищено не будет.
  2. Создаются вспомогательные объекты slot_invoker, slot_call_iterator_cache_type и invocation_janitor. В конструкторе slot_invoker’а запоминается ссылка на параметр вызова: int(3).
  3. В последнем выражении функции signal1_impl::operator()(...) создаётся пара итераторов detail::slot_call_iterator_t. Конструктор этих объектов выполняет предварительный поиск итератора подключения, который можно использовать для вызова. Так как с первым добавленным подключением всё в порядке, на нём поиск и прекращается.
  4. Создаётся вспомогательная прослойка combiner_invoker, которая просто вызывает Combiner с новыми итераторами.
  5. Combiner пытается получить значение, разыменовывая итератор, таким образом, вызывается метод slot_call_iterator_t::dereference(...). В специализации Combiner’а для типа возврата void последний не запоминает никакого значения, а просто разыменовывает итератор.
  6. Итератор вызывает объект класса slot_invoker, передавая ему разыменованный внутренний итератор, то есть, в данном случае ссылку на connection_body.
  7. slot_invoker обращается к методу slot_function(...) и получает копию вызываемого объекта, то есть boost::function<void (int)>.
  8. Функтор boost::function<> уже вызывает сохранённый в нём объект HelloWorld с параметром 3.
  9. Управление возвращается в Combiner. Он передвигает (инкрементирует) итератор slot_call_iterator_t.
  10. Операция инкремента, передвигая внутренний итератор, пытается заблокировать объект connection_body, на который тот указывает. Однако после вызова connection_body::nolock_grab_tracked_objects() оказывается, что подключение разорвано, так как при выполнении этого метода возникла попытка получить сильную ссылку на удалённый объект. Операция инкремента переходит на следующую позицию, но во внутреннем диапазоне заканчиваются объекты.
  11. Combiner обнаруживает, что после инкремента итератор стал равен концевому итератору, и возвращает управление.
  12. Происходит возврат управления из прослойки combiner_invoker.
  13. В результате очистки деструктор объекта invocation_janitor подчищает неактивные подключения. Однако в текущей реализации библиотеки делается сравнение количества активных и неактивных подключений. Только если неактивных больше – делается очистка. В примере получается, что и тех и других – по одному, а значит, очистка производиться не будет.
  14. Происходит возврат из signal1_impl, затем из signal1. Всё.

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

Темы, оставшиеся «за бортом»

В статье рассмотрены не все аспекты проектирования библиотеки signals2. Есть несколько моментов, рассмотрение которых можно рекомендовать очень пытливому читателю или просто пропустить. Во-первых, это использование шаблонов с переменным числом параметров. В коде постоянно встречаются препроцессорные директивы, проверяющие макрос BOOST_NO_VARIADIC_TEMPLATES. Если он не определён, во многих местах подключаются другие файлы с исходными текстами и генерируются другие классы, нежели те, что были рассмотрены выше. Их содержимое не анализировалось.

Во-вторых, в статье не рассматриваются вопросы, связанные с реализацией групп подключений. Это не оказывает существенного влияния на приведённый выше анализ.

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

Зачем?

По ходу анализа у меня возникали вопросы или просто недоумение выбранным проектным решением. Может быть, я просто что-то недопонял. Хочу привести список вопросов. Возможно, ответ на них будет очевиден для читателя.

  1. Зачем делать более одного отслеживаемого объекта на каждый слот? В 99% случаев имеет смысл только объект, в котором непосредственно находится вызываемый метод. Ведь в случае множества подключений итак создаётся множество объектов слотов, каждый из которых может отслеживать «свой» объект.
  2. Зачем в классе connection_body_base блокирование реализовывать посредством слабых и сильных ссылок? Можно же было просто сделать булев флажок.
  3. Множество слотов, подключённых к одному сигналу, можно разбить на подмножества – группы, и управлять ими отдельно. Насколько нужна пользователю поддержка групп подключений? Эта семантика видится надуманной.
  4. Метод connection_body::connected зачем-то для проверки состояния отслеживаемых объектов использует тяжеловесный nolock_grap_tracked_objects, получая сильные ссылки, которые всё равно нигде не используются. Почему было просто не воспользоваться методом nolock_slot_expired()?
  5. Зачем нужно было переусложнять классы arg и набор preprocessed_arg_type[N] с помощью препроцессорного программирования, когда та же задача получения типа из списка типов в библиотеке Loki решается с помощью рекурсивного инстанциирования шаблона. См. в качестве примера метафункцию TypeAt в файле typelist.h библиотеки Loki.
  6. Где-то в коде используется перегрузка для различения возврата void, а где-то – используется фиктивный тип void_type, полученный с помощью метафункции nonvoid. Почему не привести весь код к виду, когда везде используется void_type, а замену сделать на самом нижнем уровне, в slot[N]::operator()(...)? Здесь же: зачем набор из nonvoid и result_type_wrapper?

На сайте www.rsdn.ru в разделе «Философия программирования» есть интересная статья «Закон сохранения сложности». Статья спорная, что видно по размеру переписки в форуме с комментариями. Однако при изучении многих библиотек boost'a вспоминается и её содержимое и эпиграф: «Усложнять - просто, упрощать – сложно». И не покидает ощущение, что сложность реализации этих библиотек вызвана не столько объективной необходимостью, сколько: а) желанием написать по-академически сложно; б) включить зачатки как можно большего числа концепций, которые, скорее всего, никогда не понадобятся; в) просто неумением сделать проще или непониманием концепции до конца; г) может быть, намеренной обфускацией кода, чтобы создать ореол элитарности. Мне не хотелось бы быть категоричным, поэтому хочу подчеркнуть, что это лишь моё ощущение. Однако оно возникает уже не первый раз. Чувство дежавю появилось с того дня, когда я провёл сравнение реализации функторов в boost’е и в библиотеке Loki. Однако сравнительный анализ функторов – это тема уже отдельной статьи.

Список литературы

  1. Скотт Мэйерс. Эффективное использование C++. «ДМК», Москва, 2006
  2. Скотт Мэйерс. Наиболее эффективное использование C++. «ДМК», Москва, 2000.
  3. Андрей Александреску. Современное проектирование на C++. «Вильямс», 2002.
  4. Мэтью Уилсон. Расширение библиотеки STL для C++. Наборы и итераторы. «ДМК», Москва, 2008.
  5. Игорь Ткачёв. Закон сохранения сложности. Статья на сайте www.rsdn.ru, раздел «Философия программирования». 2002-2009.


Эта статья опубликована в журнале RSDN Magazine #1-2010. Информацию о журнале можно найти здесь