Loe raamatut: «Обратные вызовы в C++», lehekülg 10

Font:

5.6. Динамический набор получателей

5.6.1. Распределение в динамическом наборе

В предыдущих параграфах мы рассматривали статический набор получателей, когда типы и количество получателей определены на этапе компиляции и остаются неизменными. Теперь рассмотрим динамический набор, когда типы и количество получателей заранее неизвестны и изменяются в процессе выполнения программы. В какой-то степени реализация здесь получается проще: у нас не будет специализаций, рекурсий, выведения типов и прочей так называемой «шаблонной магии», все решается обычными методами классического программирования.

Итак, поскольку количество объектов заранее не определено, для их хранения необходим динамический контейнер. Однако он не может хранить объекты непосредственно, поскольку они могут иметь разные типы, а динамический контейнер работает с данными одного строго определенного типа. Выходом будет хранить универсальные аргументы, а уже в них сохранять объекты вызова. Структурная схема изображена на Рис. 24.

Рис. 24. Структурная схема распределителя для динамического набора получателей


Оптимальным решением будет реализация распределителя в виде класса, который, кроме выполнения распределения, будет поддерживать операции с контейнером. Конечно же, проектировать динамический контейнер и универсальный аргумент не нужно – в STL имеется все необходимое. Контейнер, в общем-то, можно использовать любой, а на роль универсального аргумента нет ничего лучше, чем std::function. Реализация приведена в Листинг 81.

Листинг 81. Распределитель для динамического набора получателей

template<typename unused> class DynamicDistributor;  // (1)


template<typename Return, typename… ArgumentList>  // (2)

class DynamicDistributor<Return(ArgumentList…)>

{

public:

  template <typename CallObject>

  void addCallObject(CallObject object)        // (3)

  {

    callObjects.push_back(object);

  }


  void operator ()(ArgumentList… arguments)  // (4)

  {

    for (auto& callObject : callObjects)

    {

      callObject(arguments…);

    }

  }

private:

  std::list< std::function<Return(ArgumentList …)> > callObjects;  // (5)

};


В строке 1 объявлена общая специализация шаблона. Реализация класса здесь отсутствует, поскольку для каждой сигнатуры она будет различной. В строке 2 объявлен шаблон для частичной специализации, в котором два аргумента: тип возвращаемого значения и пакет параметров, передаваемых на вход вызова. Подобную конструкцию мы использовали, когда рассматривали настройку сигнатуры для универсального аргумента (п. 4.5.2).

В строке 3 объявлен метод, который добавляет объект вызова в контейнер, сам контейнер объявлен в строке 5. Тип контейнера мы выбираем список, поскольку он не перемещает элементов при вставке/удалении, а произвольный доступ здесь не требуется. Типом хранимых данных в контейнере является объект std::function, аргументы которого задаются исходя из параметров в объявлении шаблона класса.

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

5.6.2. Получение возвращаемых значений

Как получить возвращаемые значения для динамического набора? На момент вызова распределяющей функции количество получателей может быть любым, и, соответственно, число возвращаемых значений заранее не определено. Использовать динамический контейнер как возвращаемое значение функции является плохой идеей: во-первых, заполнение контейнера и создание его копии в стеке требует значительного расхода времени и увеличивает фрагментацию памяти; во-вторых, если возвращаемое значение не используется, то все вышеописанное будет работать «вхолостую», выполняя совершенно ненужные операции. Использовать контейнер как входной параметр – это тоже идея не очень: мы вынуждаем привязаться к контейнеру определенного типа, а если нам результаты нужно хранить в других структурах? А если нам вообще их не нужно хранить, а нужно всего лишь проверить? Вопросы, вопросы… Можно предложить следующее решение: для возврата результата использовать обратный вызов, а пользователь сам решает, что делать с возвращаемыми значениями. Реализация приведена в Листинг 82.

Листинг 82. Возврат значений для динамического набора получателей

template <typename Return, typename… ArgumentList>

class DynamicDistributor<Return(ArgumentList…)>

{

  /**********************……**********************************/


  template<typename CallbackReturn >  // (1)

  void operator()(CallbackReturn callbackReturn, ArgumentList… arguments)

  {

    for (auto& callObject : callObjects)

    {

        callbackReturn(callObject(arguments…));  // (2)

    }

  }

private:

  std::list< std::function<Return(ArgumentList …)> > callObjects;

};


Реализация совпадает с Листинг 82 п. 5.6.1, только добавляется еще один перегруженный оператор. Его шаблон объявлен строке 1, параметром шаблона является тип аргумента, через который будет выполняться обратный вызов. В строке 2 происходит вызов объекта, результат возвращается через аргумент, переданный как входной параметр функции.


Пример распределения вызовов для динамического набора получателей приведен в Листинг 83.

Листинг 83. Распределение вызовов для динамического набора получателей

struct FO

{

  int operator() (int eventID) { return 10; }

  int callbackHandler(int eventID) { return 100; }

};


int ExternalHandler(int eventID)

{

  return 0;

}


int main()

{

  int eventID = 0;


  FO fo;

  auto lambda = [](int eventID) { return 0; };

  auto binding = std::bind(&FO::callbackHandler, fo, std::placeholders::_1);


  DynamicDistributor<int(int)> distributor;       // (1)


  distributor.addCallObject(fo);                  // (2)

  distributor.addCallObject(ExternalHandler);     // (3)

  distributor.addCallObject(binding);             // (4)

  distributor.addCallObject(lambda);              // (5)


  distributor(eventID);                           // (6)


  auto onReturnValue = [](int callResult) {};     // (7)

  distributor(onReturnValue, eventID);            // (8)

}


В строке 1 инстанциирован класс распределителя с заданной сигнатурой функции. В строке 2, 3, 4, 5 в распределитель добавляются объекты вызова различного типа. В строке 6 запускается распределение вызовов, в результате которого будут вызваны добавленные объекты. В строке 7 объявлено лямбда-выражение для получения результатов, при вызове соответствующего оператора 8 это выражение будет вызвано для каждого возвращаемого значения.


Касательно модификации содержимого контейнера наш распределитель поддерживает только одну операцию – добавление получателя. Ни удаление, ни модификация получателей не поддерживается. Это связано с тем, что получатели не идентифицированы, и поэтому невозможно узнать, в каком элементе контейнера хранится соответствующий объект вызова33. Далее мы рассмотрим, как можно решить указанную проблему.

5.7. Адресное распределение

5.7.1. Понятие адресного распределения

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

Как это реализовать? Прежде всего, необходимо как-то идентифицировать получателей, для чего вводится понятие адреса. Каждому получателю присваивается адрес, и с каждым адресом связывается универсальный аргумент, который хранит объект вызова. Таким образом, зная адреса получателей, можно осуществлять вызовы только для конкретных объектов. Попутно решается задача изменения списка получателей: по заданному адресу возможно удаление/изменение соответствующего аргумента.

Что может быть адресом? Все что угодно: числа, строки, структуры и т. п. Единственное требование, предъявляемое к адресу, заключается в том, что он должен быть уникальным, в противном случае невозможно однозначно идентифицировать получателя. Мы сделаем тип адреса параметром шаблона, а пользователь сам решит, что использовать в качестве адреса.

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

Какой выбрать контейнер? На эту роль лучше других подойдет std::map. Во-первых, не нужно вводить новую структуру для хранения адреса и аргумента, контейнер реализует ее естественным образом в виде пары «ключ-значение». И, во-вторых, std::map осуществляет быстрый поиск по ключу, в качестве которого выступает адрес. Структурная схема изображена на Рис. 25.


Рис. 25. Структурная схема адресного распределения

5.7.2. Адресный распределитель

Реализация адресного распределителя приведена в Листинг 84.

Листинг 84. Распределитель для адресного набора получателей

template <typename Address, typename AddressCompare, typename Function> class AddressDistributor;  // (1)


template <typename Address, typename AddressCompare, typename Return, typename… ArgumentList>    // (2)

class AddressDistributor<Address, AddressCompare, Return(ArgumentList…)>                         // (3)

{

public:

  template<typename CallObject>  // (4)

  void addReceiver(Address address, CallObject object)

  {

    callObjects.insert({ address,object } );

  }


  void deleteReceiver(Address address)  // (5)

  {

    callObjects.erase(address);

  }


  Return operator()(Address address, ArgumentList… arguments)  // (6)

  {

    auto iterator = callObjects.find(address);  // (7)

    if (iterator != callObjects.end())

    {

      return iterator->second(arguments…);    // (8)

    }

    else

    {

      throw std::invalid_argument("Invalid receiver address");  // (9)

    }

  }

private:

  std::map< Address, std::function<Return(ArgumentList…)>, AddressCompare  > callObjects;  // (10)

};


В строке 1 объявлена общая специализация шаблона, параметрами выступают адрес получателя Address, предикат для сравнения AddressCompare и сигнатура распределяющей функции Function. Реализация здесь отсутствует, поскольку для каждой сигнатуры требуется отдельная специализация – аналогично настройке сигнатуры для универсального аргумента (п. 4.5.2).

В строке 2 объявлена частичная специализация, в которой дополнительно представлены параметр для возвращаемого значения Return и пакет параметров ArgumentList для аргументов функции. В строке 3 объявлен класс, который специализируется сигнатурой из указанных параметров.

В строке 4 объявлен шаблон метода для добавления получателя, который принимает адрес address, вызываемый объект object и добавляет их в контейнер. В строке 5 объявлен метод для удаления получателя. Оба метода работают с контейнером, который объявлен в строке 10. Контейнер объявлен как std::map, ключом является адрес, а значением – объект std::function с заданной сигнатурой.

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

В строке 7 происходит поиск получателя по адресу. Если получатель найден, то происходит вызов объекта (строка 8). Если получатель не найден, то генерируется исключение (строка 9), иначе какой результат нам возвратить?

5.7.3. Использование адресного распределения

Пример использования адресного распределения приведен в Листинг 85.

Листинг 85. Использование адресного распределения

struct FO

{

  int operator() (int eventID)

  {

    return 10;

  }

};


int ExternalHandler(int eventID)

{

  return 0;

}


struct ReceiverAddress  // (1)

{

  ReceiverAddress(int idGroup = 0, int idNumber = 0)

  {

    group = idGroup; number = idNumber;

  }


  int group;

  int number;

};


template<>

struct std::less<ReceiverAddress>  // (2)

{

  bool operator() (const ReceiverAddress& addr1, const ReceiverAddress& addr2) const

  {

    if (addr1.group < addr2.group)

    {

      return true;

    }

    else

    {

      if (addr1.group == addr2.group)

        return addr1.number < addr2.number;

      else

        return false;

      }

  }

};


int main()

{

  int eventID = 0;

  FO fo;

  auto lambda = [](int eventID) { return 0; };


  AddressDistributor<ReceiverAddress, std::less<ReceiverAddress>, int(int)> distributor;  // (3)


  distributor.addReceiver({ 1,1 }, fo);               // (4)

  distributor.addReceiver({ 2,2 }, ExternalHandler);  // (5)

  distributor.addReceiver({ 3,3 }, lambda);           // (6)


  distributor({ 1,1 }, eventID);  // (7)

  distributor({ 2,2 }, eventID);  // (8)

  distributor({ 3,3 }, eventID);  // (9)

}


В строке 1 объявлена структура для адреса, которая состоит из двух полей: идентификатор группы и номер получателя в группе. Сравнить эти две структуры напрямую нельзя, поэтому потребуется реализовать предикат.

В строке 2 объявлен функциональный объект, реализующий предикат для сравнения адресов. Почему именно в таком виде? Дело в том, что std::map требует, чтобы в качестве предиката использовался именно функциональный объект, мы не можем для этого использовать внешнюю функцию или лямбда-выражение. Это связано с тем, что в контейнере предикат хранится в виде переменной с конструктором, тип переменной определяется параметром шаблона. А наличие конструктора может обеспечить только функциональный объект.

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

В STL уже объявлен шаблон структуры для предикатов std::less, параметром которого выступает тип данных, которые необходимо сравнить. Этот предикат принимает на вход две переменные и возвращает true, если первая меньше второй34. std::less реализует арифметическое сравнение, поэтому для типов, которые поддерживают арифметические операции, предикат объявлять не нужно, он будет сгенерирован компилятором. Однако в нашем случае данные арифметически сравниваться не могут, поэтому мы специализируем этот шаблон своим типом (строка 2) и реализуем перегруженный оператор, который будет сравнивать две структуры. При инстанциировании контейнера компилятор сам выберет подходящую специализацию предиката, исходя из типа хранимых элементов.

В строке 3 объявлен объект распределителя путем инстанциирования соответствующего шаблона. Аргументами шаблона выступают тип адреса, предикат для сравнения и сигнатура для вызова объектов. В строках 4, 5, 6 в распределитель добавляются объекты вызова различных типов, в строках 7, 8, 9 эти объекты будут вызваны в соответствии с их адресами.

5.8. Итоги

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

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

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

Если требуются результаты выполнения вызовов, то они реализуются с помощью отдельной распределяющей функции, которая возвращает результаты в виде кортежа.

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

Если типы и количество получателей заранее неизвестны и изменяются в процессе выполнения программы, то мы имеем динамический набор получателей. Он реализуется в виде класса с контейнером, в котором хранятся универсальные аргументы.

Если необходима передача вызовов не всем получателям, а только некоторым, то используется адресное распределение. Поскольку тип используемого адреса заранее не определен, то для сравнения адресов нужно использовать предикаты.

На этом изложение теоретического материала можно считать законченным. Далее рассмотрим, как обратные вызовы используются в практике разработки ПО.

6. Практическое использование обратных вызовов

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

Подробное описание всех компонентов модуля заняло бы слишком много места и навряд ли имеет практическую ценность, поэтому мы будем рассматривать самые общие принципы функционирования с акцентом на использование обратных вызовов. Полностью проект можно посмотреть здесь: https://github.com/Tkachenko-vitaliy/Callbacks/tree/master/Sensor.

6.1. Разработка архитектуры

6.1.1. Техническое задание

Первый вопрос, который должен быть задан перед началом разработки чего бы то ни было, звучит следующим образом: что мы будем разрабатывать и что мы хотим в итоге получить? Этот вопрос совсем не тривиальный, как может показаться вначале. Без ясного осознания конечной цели, без четкого понимания свойств и характеристик, которыми должна обладать проектируемая система, разработка может растянуться до бесконечности: происходят постоянные переделки, доработки, хаотичная реализация все новых и новых функций с не очень понятной ценностью, и т. п. В итоге, вместо результата мы сосредотачиваемся на процессе, а конечная цель пропадает где-то за горизонтом. Не сталкивались с такими проектами? Что ж, вам крупно повезло; чтобы также везло в дальнейшем, и подобные проекты в вашей карьере отсутствовали, любое проектирование нужно начинать с постановки целей, которые выражаются в требованиях, предъявляемых к системе. В нашем случае они будут следующими.


Разработать модуль управления датчиками, который должен обеспечивать:

1. Настройку конфигурации датчиков и возможность ее изменения в процессе работы.

2. Отслеживание состояния и определение неисправности датчиков.

3. Считывание показаний отдельных датчиков.

4. Считывание показаний всех работоспособных датчиков.

5. Асинхронный опрос показаний.

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

7. Настройка пороговых значений показателей и уведомление при их превышении.

8. Возможность работы как с реальными физическими датчиками, так и с их программными моделями.

33.Справедливости надо отметить, что идентификация получателей все-таки возможна. Для этого можно использовать, например, итератор контейнера либо указатель на объект std::function, либо, например, динамически присваивать объекту контейнера какое-нибудь значение. Однако это было бы плохим решением в силу целого ряда причин:
  1) нарушается важнейший принцип проектирования – разделение интерфейса и реализации. Мы жестко завязываемся на структуру хранения объектов вызовов, поэтому архитектура получается монолитной;
  2) идентификаторы объектов не несут никакой смысловой нагрузки, это просто некие абстрактные значения;
  3) идентификаторы не детерминированы, при добавлении объекта в контейнер идентификатор получит произвольное значение;
  4) идентификаторам объектов невозможно назначить заранее заданные значения;
  5) в силу вышеуказанных причин невозможно реализовать логические протоколы обмена.
34.Контейнер std::map требует именно такой предикат, less, который возвращает истину в случае, если первый элемент меньше второго. Другие контейнеры могут требовать иные предикаты, например, проверку на равенство equal.