Модель обратных вызовов включает в себя следующие понятия: исполнитель, инициатор, аргумент, настройка, контекст.
В синхронных вызовах при вызове функции инициатора обратный вызов осуществляется до выхода из тела функции. В асинхронных вызовах вызов может быть выполнен в любое время.
Обратные вызовы часто используются в системных и C++ API. При использовании в системных API на реализацию обратных вызовов накладываются ограничения.
Рассмотрев общую концепцию, приступим к обзору способов реализации обратных вызовов.
2. Реализация обратных вызовов
2.1. Указатель на функцию
2.1.1. Концепция
Графическое изображение реализации обратного вызова с помощью указателя на функцию представлено на Рис. 10. Исполнитель реализован в виде глобальной функции, в качестве контекста могут выступать любые данные. При настройке указатель на функцию как аргумент и указатель на данные как контекст сохраняются в инициаторе. Инициатор осуществляет обратный вызов посредством вызова функции через сохраненный указатель, передавая ей требуемые значения и контекст – указатель на данные. Поскольку инициатор не интерпретирует контекст и не выполняет с ним никаких операций, для хранения контекста используется нетипизированный указатель.
Рис. 10. Обратный вызов с указателем на функцию
2.1.2. Инициатор
Реализация инициатора представлена в Листинг 12.
typedef void(*ptr_callback) (int eventID, void* pContextData); // (1)
ptr_callback ptrCallback = NULL; // (2)
void* contextData = NULL; // (3)
void setup(ptr_callback pPtrCallback, void* pContextData) // (4)
{
ptrCallback = pPtrCallback;
contextData = pContextData;
}
void run() // (5)
{
int eventID = 0;
//Some actions
ptrCallback(eventID, contextData); // (6)
}
В строке 1 объявлен тип – указатель на функцию, в строке 2 объявлена переменная этого типа, в строке 3 объявлен указатель на данные контекста. В строке 4 объявлена функция для настройки указателей, в которой инициализируются соответствующие переменные. В строке 5 объявлена функция запуска, внутри этой функции инициатор в строке 6 производит вызов функции по сохраненному указателю. Сигнатура функции, объявленная в строке 1, в качестве первого параметра принимает значение, которое передается инициатором, т. е. информацию вызова, а второй параметр – это контекст. Указанная сигнатура здесь только для примера; конечно же, в зависимости от поставленных задач количество параметров и их порядок может быть произвольным. Мы также опустили моменты, связанные с созданием потока, ожиданием окончания работы сервера и т. п. – для понимания принципов организации вызова это несущественно.
Итак, мы реализовали инициатор в процедурно-ориентированном дизайне. Приведенная реализация имеет серьезный недостаток: указатель на функцию и указатель на контекст хранятся в глобальных переменных. Это создает множество проблем: изменения настроек указателей в разных частях программы не изолированы, т. е. влияют друг на друга; инициатор может работать только с одним-единственным исполнителем; невозможна одновременная работа нескольких потоков. Выходом из сложившейся ситуации будет реализация инициатора в объектно-ориентированном дизайне3 (Листинг 2).
class Initiator //(1)
{
public:
using ptr_callback = void(*) (int, void*); //(2)
void setup(ptr_callback pPtrCallback, void* pContextData) // (3)
{
ptrCallback = pPtrCallback; contextData = pContextData; // (4)
}
void run() // (5)
{
int eventID = 0;
//Some actions
ptrCallback (eventID, contextData); // (6)
}
private:
ptr_callback ptrCallback = nullptr; // (7)
void* contextData = nullptr; // (8)
};
В строке 1 мы объявляем класс – инициатор, в строке 2 мы объявляем тип указателя на функцию. В строке 3 объявляем функцию настройки указателей, соответствующие переменные – (указатель на функцию и указатель на контекст) объявлены соответственно в строках 7 и 8. В строке 5 объявлена функция запуска, внутри этой функции в строке 6 производится вызов функции по соответствующему указателю. Как видим, объектная реализация практически полностью повторяет процедурную, только все объявления сделаны внутри класса. Другими словами, мы провели инкапсуляцию данных и процедур внутри некоторой сущности, в качестве которой выступает класс.
2
Мы здесь (и в дальнейших листингах тоже) не будем разделять заголовочные файлы и файлы реализации: это всего лишь пример, а разделение загромождает описание и усложняет понимание.
3
Конечно же, описанные проблемы могут быть решены и в процедурном дизайне, но код при этом значительно усложняется. В общем-то, объектно-ориентированная парадигма и разрабатывалась как средство борьбы с возрастающей сложностью программного кода.