Kitabı oku: «Обратные вызовы в C++», sayfa 2
1.5. Итоги
Обратный вызов – это паттерн, в котором какой-либо исполняемый код как аргумент передается в другой код, при этом ожидается, что через сохраненный аргумент исполняемый код будет запущен в нужный момент времени. Основные классы задач, решаемые с помощью обратных вызовов, следующие: запрос данных; вычисления по запросу; перебор элементов; уведомления о событиях.
Модель обратных вызовов включает в себя следующие понятия: исполнитель, инициатор, аргумент, настройка, контекст.
В синхронных вызовах при вызове функции инициатора обратный вызов осуществляется до выхода из тела функции. В асинхронных вызовах вызов может быть выполнен в любое время.
Обратные вызовы часто используются в системных и C++ API. При использовании в системных API на реализацию обратных вызовов накладываются ограничения.
Рассмотрев общую концепцию, приступим к обзору способов реализации обратных вызовов.
2. Реализация обратных вызовов
2.1. Указатель на функцию
2.1.1. Концепция
Графическое изображение реализации обратного вызова с помощью указателя на функцию представлено на Рис. 10. Исполнитель реализован в виде глобальной функции, в качестве контекста могут выступать любые данные. При настройке указатель на функцию как аргумент и указатель на данные как контекст сохраняются в инициаторе. Инициатор осуществляет обратный вызов посредством вызова функции через сохраненный указатель, передавая ей требуемые значения и контекст – указатель на данные. Поскольку инициатор не интерпретирует контекст и не выполняет с ним никаких операций, для хранения контекста используется нетипизированный указатель.
Рис. 10. Обратный вызов с указателем на функцию
2.1.2. Инициатор
Реализация инициатора представлена в Листинг 12.
Листинг 1.Иинициатор с указателем на функцию
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).
Листинг 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 производится вызов функции по соответствующему указателю. Как видим, объектная реализация практически полностью повторяет процедурную, только все объявления сделаны внутри класса. Другими словами, мы провели инкапсуляцию данных и процедур внутри некоторой сущности, в качестве которой выступает класс.
Конечно, поскольку мы программируем на C++, мы должны следовать объектно-ориентированному дизайну, и любые реализации делать в его рамках. Для чего тогда мы привели реализацию инициатора в процедурном дизайне, в стиле языка C? Дело в том, что процедурный дизайн является единственно возможным для проектирования системных API, поскольку в объявлениях интерфейсов таких API допускается использование только глобальных функций и простых структур данных (см. п. 1.4.2).
2.1.3. Исполнитель
Реализация исполнителя для случая, когда инициатор разработан в процедурном дизайне, представлена в Листинг 3.
Листинг 3. Исполнитель для инициатора в процедурном дизайне
struct СontextData // (1)
{
//some context data
};
void callbackHandler(int eventID, void* somePointer) // (2)
{
//It will be called by initiator
СontextData* pContextData = (СontextData*)somePointer; // (3)
//Do something here
}
int main() // (4)
{
СontextData clientContext; // (5)
setup(callbackHandler, &clientContext); // (6)
run(); // (7)
//Wait finish
}
В строке 1 объявляется тип данных для контекста. Структура здесь показана для примера, в качестве контекста могут выступать любые типы: числа, указатели, смеси и т. п. В строке 2 объявляется функция – обработчик обратного вызова, ее сигнатура должна совпадать с сигнатурой, с которой работает инициатор. Указанная функция будет вызвана инициатором, в нее будут переданы два параметра: первый передается инициатором (информация вызова, в нашем случае это eventID), а второй – это контекст. Клиент должен интерпретировать контекст; нет другого способа это сделать, кроме как приведением типов (строка 3).
Далее, в строке 4 объявлена основная функция, в которой осуществляются все необходимые операции. В строке 5 объявляются данные контекста; в строке 6 производится настройка обратного вызова, в функцию настройки передаются указатель на функцию-обработчик и указатель на контекст; в строке 7 инициатор запускается.
Реализация исполнителя для случая, когда инициатор реализован в объектно-ориентированном дизайне, представлена в Листинг 4. Как видим, она очень похожа на предыдущую реализацию с той разницей, что мы объявляем экземпляр класса-инициатора (строка 5), и все вызовы осуществляем через вызов соответствующих методов класса.
Листинг 4. Исполнитель для инициатора в объектно-ориентированном дизайне
struct СontextData // (1)
{
//some context data
};
void callbackHandler(int eventID, void* somePointer) // (2)
{
//It will be called by initiator
СontextData* pContextData = static_cast<СontextData*>(somePointer); // (3) cast to context
//Do something here
}
int main() // (4)
{
Initiator initiator; // (5)
СontextData clientContext; // (6)
initiator.setup(callbackHandler, &clientContext); // (7) callback setup
initiator.run(); // (8) initiator has been run
//Wait finish
}
2.1.4. Синхронный вызов
Реализация инициатора для синхронного вызова приведена в Листинг 5. Как видим, для синхронных вызовов код значительно упрощается: нет необходимости хранить переменные, информация вызова и контекст передаются непосредственно в функцию.
Листинг 5. Инициатор для синхронного обратного вызова с указателем на функцию
using ptr_callback = void(*) (int, void*);
void run(ptr_callback ptrCallback, void* contextData = nullptr)
{
int eventID = 0;
//Some actions
ptrCallback (eventID, contextData);
}
2.1.5. Преимущества и недостатки
Достоинства и недостатки реализации обратных вызовов с помощью указателя на функцию представлены в Табл. 1.
Табл. 1. Преимущества и недостатки обратных вызовов с указателем на функцию
Простая реализация. Как мы видели, инициатор реализуется достаточно просто: две переменных, синтаксис вызова функции через указатель очень похож на вызов обычной функции.
Независимость инициатора и исполнителя. Любое изменение кода исполнителя никак не влияет на код инициатора, который при этом остается неизменным
Совместим с кодом на языке C. В некоторых случаях приходится разрабатывать смешанный код, т. е. часть кода пишется C, а часть – на С++. Если код исполнителя написан на C++, и этот код должен быть вызван инициатором, написанным на C, то использование указателей на функцию является единственно доступным механизмом. 4
Подходит для реализации любых API. Можно реализовать как С++, так и системные API. Для C++ API инициатор разрабатывается в виде набора классов, для системных API – в виде набора функций.
Инициатор хранит контекст исполнителя. Как мы видели, инициатор вынужден сохранять контекст исполнителя. Это усложняет реализацию и способствует увеличению расхода памяти.
Небезопасный способ трансляции контекста. Контекст передается клиенту в виде нетипизированного указателя, интерпретация указателя возлагается на клиента. В большой программной системе это чревато ошибками, поскольку нет никакой возможности проверить корректность полученного указателя.
2.2. Указатель на статический метод класса
2.2.1. Концепция
Графическое изображение обратного вызова с помощью указателя на статический метод класса представлено на Рис. 11. Исполнитель реализуется в виде класса, код упаковывается в статический метод класса, в качестве контекста выступает указатель на экземпляр класса. При настройке указатель на статический метод как аргумент и указатель на класс как контекст сохраняются в инициаторе. Инициатор осуществляет обратный вызов посредством вызова метода, передавая ему требуемую информацию и контекст – указатель на класс.
Рис. 11. Обратный вызов с указателем на статический метод класса
2.2.2. Инициатор
По своей сути статический метод класса – это обычная функция, ограниченная областью видимости класса. Поэтому реализация инициатора, представленная в Листинг 6, практически полностью повторяет реализацию для указателей на функцию, только в качестве контекста выступает указатель на экземпляр класса.
Листинг 6. Инициатор с указателем на статический метод класса
class Executor; //(1)
class Initiator // (2)
{
public:
using ptr_callback_static = void(*) (int, Executor*); // (3)
void setup(ptr_callback_static pPtrCallback, Executor* pContextData) // (4)
{
ptrCallback = pPtrCallback; contextData = pContextData; // (5)
}
void run() // (6)
{
int eventID = 0;
//Some actions
ptrCallback(eventID, contextData); // (7)
}
private:
ptr_callback_static ptrCallback = nullptr; // (8)
Executor* contextData = nullptr; // (9)
};
В строке 1 делается предварительное объявление типа класса исполнителя. В строке 2 объявляется класс – инициатор, в строке 3 объявляется тип указателя на функцию с контекстом – экземпляром класса. В строке 4 объявлена функция для настройки указателей, соответствующие переменные (указатель на статический метод и указатель на контекст – экземпляр класса) объявлены в строках 8 и 9. В строке 6 объявлена функция запуска, внутри этой функции в строке 7 производится вызов функции по соответствующему указателю c передачей информации вызова и контекста.
2.2.3. Исполнитель
Реализация исполнителя приведена в Листинг 7.
Листинг 7. Исполнитель с указателем на статический метод класса
class Executor // (1)
{
public:
Executor(Initiator* initiator) // (2)
{
initiator->setup(callbackHandler, this);
}
static void callbackHandler(int eventID, Executor* executor) // (3)
{
//It will be called by initiator
executor->onCallbackHandler(eventID); // (4)
}
private:
void onCallbackHandler(int eventID) // (5)
{
//Do what is necessary
}
};
int main() // (6)
{
Initiator initiator; // (7)
Executor executor(&initiator); // (8)
initiator.run(); // (9)
//Wait finish
}
В строке 1 объявляется класс – исполнитель. В строке 2 объявляется конструктор с входным параметром – указателем на инициатор, здесь происходит настройка обратного вызова.5
В строке 3 объявлен статический метод как обработчик обратного вызова. Входными параметрами здесь являются информация вызова (в нашем случае это eventID) и указатель на контекст, в качестве которого выступает указатель на экземпляр класса. Внутри метода можно обращаться к содержимому класса, используя полученный указатель как квалификатор. Таким образом, прямо здесь можно реализовать код обработчика, а можно вызвать обычный (нестатический) метод класса (строка 4).
Далее, в строке 6 объявлена основная функция, в которой осуществляются все необходимые операции. В строке 7 объявлен класс-инициатор; в строке 8 объявлен класс- исполнитель, в конструктор передается указатель на инициатор; в строке 9 происходит запуск инициатора.
Особенностью реализации исполнителя с помощью указателя на статический метод является возможность работы с инициатором, предназначенным для указателей на функцию. В этом случае метод класса в качестве контекста должен принимать нетипизированный указатель с последующим приведением типов. Пример использования показан в Листинг 8, инициатор здесь используется из Листинг 1 п. 2.1.2.
Листинг 8. Исполнитель с указателем на статический метод класса для инициатора с нетипизированным контекстом
class Executor // (1)
{
public:
Executor() // (2)
{
setup(callbackHandler, this);
}
static void callbackHandler(int eventID, void* somePointer) // (3)
{
//It will be called by initiator
Executor* executor = static_cast<Executor*>(somePointer); // (4)
executor->onCallbackHandler(eventID);
}
private:
void onCallbackHandler(int eventID) // (5)
{
//Do what is necessary
}
};
int main() // (6)
{
Executor executor; // (7)
run(); // (8)
//Wait finish
}
Настройка обратного вызова осуществляется в конструкторе (строка 2). В обработчике обратного вызова (строка 3) мы делаем приведение типов (строка 4), чтобы получить указатель на экземпляр класса. В главной функции (строка 6) происходит запуск инициатора.
2.2.4. Синхронный вызов
Реализация инициатора для синхронного вызова приведена в Листинг 9. Как видим, она практически полностью повторяет реализацию, рассмотренную в предыдущей главе, только в качестве указателя на контекст используется указатель на экземпляр класса.
Листинг 9. Инициатор для синхронного обратного вызова с указателем на статический метод класса
class Executor;
using ptr_callback_static = void(*) (int, Executor*);
void run(ptr_callback_static ptrCallback, Executor * contextData = nullptr)
{
int eventID = 0;
//Some actions
ptrCallback (eventID, contextData);
}
2.2.5. Преимущества и недостатки
Преимущества и недостатки реализации обратных вызовов с помощью указателя на статический метод класса приведены в Табл. 2.
Табл. 2. Преимущества и недостатки обратных вызовов с указателем на статический метод класса
Простая реализация. Не сложнее, чем для указателей на функцию.
Совместим с инициатором в процедурном дизайне. Можно использовать для работы с системными API.
Инициатор хранит контекст исполнителя. Так же, как и в случае указателей на функцию, усложняет реализацию и способствует увеличению расхода памяти.
2.3. Указатель на метод-член класса
2.3.1. Концепция
В предыдущей главе мы рассматривали использование указателя на статический метод класса, в который в качестве контекста передавали указатель на экземпляр класса. А почему бы нам напрямую не вызвать метод-член класса, минуя прослойку в виде статического метода, из которого вызывается метод-член класса? Для этого нам понадобятся указатель на класс и указатель на метод.
Графическое изображение обратного вызова с помощью указателя на метод-член класса (далее – метод класса) представлено на Рис. 12. Исполнитель реализуется в виде класса, код упаковывается в метод класса, в качестве контекста выступает экземпляр класса. При настройке указатель на метод и указатель на класс как как аргументы сохраняются в инициаторе. Инициатор осуществляет обратный вызов посредством вызова метода, передавая ему требуемую информацию. Контекст здесь передавать не нужно, поскольку внутри метода доступно все содержимое класса.
Рис. 12. Реализация обратного вызова с помощью указателя на метод-член класса
2.3.2. Инициатор
Реализация инициатора приведена в Листинг 10.
Листинг 10. Инициатор с указателем на метод-член класса
class Executor; // (1)
class Initiator // (2)
{
public:
using ptr_callback_method = void(Executor::*)(int); // (3)
void setup(Executor* argCallbackClass, ptr_callback_method argCallbackMethod) // (4)
{
ptrCallbackClass = argCallbackClass; ptrCallbackMethod = argCallbackMethod; // (5)
}
void run() // (6)
{
int eventID = 0;
//Some actions
(ptrCallbackClass->*ptrCallbackMethod)(eventID); // (7)
}
private:
Executor* ptrCallbackClass = nullptr; // (8)
ptr_callback_method ptrCallbackMethod = nullptr; // (9)
};
В строке 1 делается предварительное объявление типа класса исполнителя. В строке 2 объявляется класс-инициатор, в строке 3 объявляется тип указателя для класса-исполнителя. В строке 4 объявляется функция для настройки указателей, соответствующие переменные (указатель на метод класса и указатель на экземпляр класса) объявлены в строках 8 и 9. В строке 6 объявлена функция запуска, внутри этой функции в строке 7 через соответствующий указатель производится вызов метода класса.
2.3.3. Исполнитель
Реализация исполнителя приведена в Листинг 11.
Листинг 11. Исполнитель с указателем на метод-член класса
class Executor // (1)
{
public:
void callbackHandler(int eventID) // (2)
{
//It will be called by initiator
}
};
int main() // (3)
{
Initiator initiator; // (4)
Executor executor; // (5)
initiator.setup(&executor, &Executor::callbackHandler); // (6)
initiator.run(); // (7)
}
В строке 1 объявляется класс-исполнитель. В строке 2 объявлен метод класса, который будет выполнять функцию обработчика обратного вызова. В указанный метод передается информация вызова (в нашем случае это eventID). В строке 3 объявлена основная функция, в которой осуществляются все необходимые операции. В строке 4 объявлен класс-инициатор, в строке 5 объявлен класс-исполнитель. В строке 6 осуществляется настройка обратного вызова, в строке 7 производится запуск инициатора.
2.3.4. Управление контекстом
Рассматриваемая реализация позволяет осуществлять управление контекстом тремя способами: настройка экземпляра класса-исполнителя, настройка указателя на метод, переопределение виртуальных функций. Это приводит к интересным эффектам.
Пусть у нас будут объявления классов-исполнителей с наследованием, как показано в Листинг 12. Графически иерархия наследования изображена на Рис. 13.
Листинг 12. Классы-исполнители с наследованием
class Executor
{
public:
virtual void callbackHandler1(int eventID);
virtual void callbackHandler2(int eventID);
};
class Executor1: public Executor
{
public:
void callbackHandler1(int eventID) override;
};
class Executor2: public Executor
{
public:
void callbackHandler2(int eventID) override;
};
class Executor3: public Executor1, public Executor2
{
};
Рис. 13. Иерархия наследования классов-исполнителей
Итак, будем назначать различные указатели на экземпляры классов и методы-члены, как показано в Листинг 13.
Листинг 13. Настройка указателей на классы и методы
int main()
{
Initiator initiator;
Executor executor;
Executor1 executor1;
Executor2 executor2;
Executor3 executor3;
initiator.setup(&executor, &Executor::callbackHandler1); // (1)
initiator.setup(&executor, &Executor::callbackHandler2); // (2)
initiator.setup(&executor1, &Executor::callbackHandler1); // (3)
initiator.setup(&executor1, &Executor::callbackHandler2); // (4)
initiator.setup(&executor2, &Executor::callbackHandler1); // (5)
initiator.setup(&executor2, &Executor::callbackHandler2); // (6)
//initiator.setup(&executor3, &Executor::callbackHandler1); //Incorrect, base class is ambiguous // (7)
//initiator.setup(&executor3, &Executor::callbackHandler2); //Incorrect, base class is ambiguous // (8)
initiator.setup((Executor1*)&executor3, &Executor::callbackHandler1); // (9)
initiator.setup((Executor1*)&executor3, &Executor::callbackHandler2); // (10)
initiator.setup((Executor2*)&executor3, &Executor::callbackHandler1); // (11)
initiator.setup((Executor2*)&executor3, &Executor::callbackHandler2); // (12)
}
В строках 1 и 2 все прозрачно: какой метод назначен, такой и будет вызван.
В строке 3 мы назначаем указатель на метод Executor::callbackHandler1, но поскольку в классе Executor1 он переопределен, будет вызван метод Executor1::callbackHandler1.
В строке 4 мы назначаем указатель на Executor::callbackHandler2; в классе Executor1 такого метода нет (т.е. он не переопределен), поэтому будет вызван метод базового класса Executor::callbackHandler2.
В строке 5 мы назначаем указатель на Executor::callbackHandler1; в классе Executor2 метод не переопределен, поэтому будет вызван метод базового класса Executor::callbackHandler2.
В строке 6 мы назначаем указатель на Executor::callbackHandler2; в классе Executor2 он переопределен, поэтому будет вызван метод Executor2:: callbackHandler2.
С классом Executor3 ситуация еще интереснее, поскольку он использует множественное наследование6. Мы не можем напрямую назначать указатели на методы базового класса, как это приведено в строках 7 и 8, потому что если взглянуть на иерархию наследования, то можно увидеть, что к базовому классу можно добраться двумя путями – через Executor1 либо через Executor2. Таким образом, компилятор не знает, по какому пути выполнять поиск методов, и выдает ошибку. По указанной причине мы должны явно указать в цепочке наследования класс-предшественник. Если в пути наследования какая-нибудь функция окажется переопределена, то она будет вызвана, в противном случае будет вызвана функция базового класса.
В строке 9 мы в качестве предшественника указываем класс Executor1 и назначаем указатель на метод callbackHandler1. В Executor1 этот метод переопределен, и он будет вызван. В строке 10 мы назначаем указатель на метод callbackHandler2; в Executor1 этот метод не переопределен, поэтому будет вызван метод базового класса Executor::callbackHandler2. Если мы в качестве предшественника будем указывать Executor2, как это показано в строках 11 и 12, то получится все наоборот: в строке 11 будет вызван метод базового класса Executor:: callbackHandler1, а в строке 12 будет вызван соответствующий переопределенный метод Executor2::callbackHandler2.
Для наглядности сведем результаты в Табл. 3.
Табл. 3. Вызовы методов по цепочке наследования
Используя рассмотренные способы управления контекстом, можно реализовать довольно изощренную логику обработки и динамически ее изменять в процессе выполнения программы.