Читать книгу Обратные вызовы в C++ - Виталий Евгеньевич Ткаченко - Страница 10

2. Реализация обратных вызовов
2.4. Функциональный объект

Оглавление

2.4.1. Концепция

С точки зрения C++ функциональный объект – это класс, который имеет перегруженный оператор вызова функции7.

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


Рис. 14. Реализация обратного вызова с помощью функционального объекта.


2.4.2. Инициатор

Предварительно необходимо объявить функциональный объект (см. Листинг 15), потому что его объявление должен видеть как инициатор, так и исполнитель.

Листинг 15.Объявление функционального объекта

class CallbackHandler

{

public:

  void operator() (int eventID) //This is an overloaded operator

  {

      //It will be called by server

  };

};


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

Листинг 16. Инициатор с функциональным объектом

class Initiator  // (1)

{

public:

  void setup(const CallbackHandler& callback)  // (2)

  {

      callbackObject = callback;

  }


  void run()  // (3)

  {

      int eventID = 0;

      //Some actions

      callbackObject(eventID);  // (4)

  }


private:

  CallbackHandler callbackObject;  // (5)

};


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

2.4.3. Исполнитель

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

Листинг 17. Исполнитель с функциональным объектом

int main()

{

  Initiator initiator;       // (1)

  CallbackHandler executor;  // (2)

  initiator.setup(executor); // (3)

  initiator.run();           // (4)

}


В строке 1 объявляется переменная класса-инициатора, в строке 2 объявляется функциональный объект, в строке 3 производится настройка, в строке 4 – запуск.

2.4.4. Синхронный вызов

Реализация инициатора для синхронного вызова представлена в Листинг 18. В отличие от асинхронного вызова, здесь функциональный объект не сохраняется как аргумент, он передается через входные параметры функции.

Листинг 18. Инициатор для синхронного вызова с функциональным объектом

void run(CallbackHandler& callbackObject)

{

  int eventID = 0;

  //Some actions

  callbackObject(eventID);

}

2.4.5. Преимущества и недостатки

Преимущества и недостатки реализации обратных вызовов с помощью функционального объекта приведены в Табл. 5.


Табл. 5. Преимущества и недостатки обратных вызовов с помощью функционального объекта.


Простая реализация. Самая простая из всех рассмотренных. Необходима только одна переменная – экземпляр класса, весь контекст хранится внутри этого класса. Прозрачный и понятный синтаксис.

Безопасность. При настройке в инициаторе создается копия переданного функционального объекта. Исходный экземпляр становится ненужным, его можно безопасно удалить.

Отсутствие трансляции контекста. Код вызова хранится внутри перегруженного оператора, контекст инкапсулирован внутри класса вместе с кодом.

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

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

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

2.4.6. Производительность

С точки зрения машинных команд, вызов функции – не слишком быстрая операция. Необходимо несколько команд для сохранения стека10; команда перехода к коду функции; команда возврата управления; несколько команд для восстановления стека. А если код тела функции небольшой, к примеру, всего лишь сравнение двух величин, то время, затраченное на вызов функции, может значительно превысить время выполнения кода функции.

Поясним сказанное на примере. Напишем маленькую простую программу, которая считывает из консоли два числа, складывает их и результат выводит на экран (Листинг 19).

Листинг 19. Маленькая простая программа

#include <iostream>


int Calculate(int a, int b)

{

  return a + b;

}


int main()

{

  int a, b;

  std::cin >> a >> b;

  int result = Calculate(a, b);

  std::cout << result;

}


Откомпилируем код с выключенной оптимизацией и запустим на выполнение. Посмотрим дизассемблерный участок кода 11, в котором производится вызов функции (Листинг 20):

Листинг 20. Дизассемблерный код с выключенной оптимизацией:

int Calculate(int a, int b)

{

00007FF6DA741005  and         al,8               // 1

return a + b;

00007FF6DA741008  mov         eax,dword ptr [b]  // 2

00007FF6DA74100C  mov         ecx,dword ptr [a]  // 3

00007FF6DA741010  add          ecx,eax           // 4

00007FF6DA741012  mov         eax,ecx            // 5

}

00007FF6DA741014  ret                            // 6


int main()

{

…….

int result = Calculate(a, b);

00007FF6DA741053  mov         edx,dword ptr [b]             // 7

00007FF6DA741057  mov         ecx,dword ptr [a]             // 8

00007FF6DA74105B  call           Calculate (07FF6DA741000h) // 9

00007FF6DA741060  mov         dword ptr [result],eax        // 10

…….


В строках 7 и 8 введенные значения a и b сохраняются в регистрах. В строке 9 выполняется вызов функции. В строке 1 выполняется обнуление результата, в строках 2 и 3 переданные значения копируются в регистры, в строке 4 выполняется сложение, в строке 5 результат копируется обратно в регистр, в строке 6 выполняется выход из функции, в строке 10 результат вычисления функции копируется в переменную результата.

Теперь включим оптимизацию, откомпилируем и посмотрим на код (Листинг 21):

Листинг 21. Дизассемблерный код с включенной оптимизацией

int main()

{

…….

int result = Calculate(a, b);

00007FF7D5B11033  mov         edx,dword ptr [b]

00007FF7D5B11037  add          edx,dword ptr [a]  


Как видим, для вычислений у нас всего две операции: запись в регистр значения b и добавление к нему значения a. Код встроен в поток выполнения, вызов функции не производится. Ощутимая разница, не правда ли?

7

Другое название, которое встречается в литературе, – функтор.

8

В инициаторе хранится копия экземпляра класса. Не ссылка, не указатель, а именно копия. Из этого вытекает несколько важных следствий, которые будут рассмотрены далее.

9

Частично этот недостаток устраняется с помощью шаблонов, что будет рассматриваться в соответствующем разделе.

10

Количество таких команд зависит от количества входных параметров функции.

11

Этот код получен с помощью компилятора Microsoft Visual studio версии 19.23.28106.4. Другие компиляторы могут генерировать отличающийся код, но принцип останется прежним.

Обратные вызовы в C++

Подняться наверх