Во втором издании исправлены некоторые опечатки, а также переработана глава 5.5, в которой представлены улучшенные технические решения, основываясь на новых возможностях стандарта C++ 17.
На этом вступительную часть можно считать оконченной, приступим теперь непосредственно к изучению обратных вызовов.
1. Теоретические основы обратных вызовов
1.1. Концепция обратных вызовов
1.1.1. Интуитивное определение
Представьте следующую ситуацию. Вам нужно совершить платеж в банке. Вы идете в банк, берете талон, дожидаетесь, пока вас пригласят, и совершаете платеж. Но ведь столько времени придется потратить, в банке всегда такие очереди… Есть вариант получше: попросить свою маму (или бабушку) зайти в банк и занять очередь. Когда очередь подойдет, мама (или бабушка) позвонит, и вам остается только прийти и сделать платеж. Если же вы в этот день сильно заняты, тогда можно оставить телефон друга, и он сделает платеж вместо вас.
Итак, результат один и тот же, но последовательность действий различная. В первом случае вы сами идете в банк, отстаиваете очередь и совершаете платеж, т. е. выполняете все необходимые операции. Во втором случае вы сидите и ожидаете, когда вам позвонят, т. е. сделают вызов, и делаете только одно действие, а именно – совершаете платеж. Либо это делает ваш друг, если маме (или бабушке) дали его, а не ваши контакты. Можно утверждать, что ваша мама (или бабушка) инициировала, а вы выполнили обратный вызов.
1.1.2. Обратный вызов как паттерн
Перейдем теперь на язык программирования и дадим формальное определение.
Обратный вызов – это паттерн, в котором какой-либо исполняемый код как аргумент передается в другой код, при этом ожидается, что через сохраненный аргумент исполняемый код будет запущен в требуемый момент времени.
Возвращаясь к неформальному примеру: здесь выполнение платежа можно считать исполняемым кодом, номер телефона – аргументом, телефонный звонок – запуском кода на выполнение.
Графически описанную концепцию можно проиллюстрировать следующим образом (Рис. 1). В программе существует код, выполняющий какие-либо операции, или исполняемый код. Когда программа запускается, исполняемый код как аргумент передается в другой код, или вызывающий код. Вызывающий код сохраняет переданный аргумент и начинает работу. В нужный момент времени, используя сохраненный аргумент, вызывающий код запускает исполняемый код, т. е. осуществляет обратный вызов.
Рис. 1. Концепция обратных вызовов
1.1.3. Прямые и обратные вызовы
Различие между прямым и обратным вызовом проиллюстрировано на Рис. 2. В первом случае поток управления запускает вызывающий код, из которого вызывается исполняемый код, и далее управление возвращается в точку вызова. Во втором случае поток управления идет мимо исполняемого кода и настраивает аргумент в вызывающем коде, а вызов исполняемого кода осуществляет уже вызывающий код, т. е. поток управления идет в обратном направлении. Таким образом, мы имеем обратный вызов.
Рис. 2. Прямой и обратный вызов
1.2. Задачи, решаемые с помощью обратных вызовов
Все многообразие задач, решаемых с помощью обратных вызовов, можно разделить на следующие группы.
1.2.1. Запрос данных
Представим, что мы разрабатываем программное обеспечение для микроконтроллера управления технологическими процессами. Контроллеру требуется периодически получать показания датчиков, таких как температура, влажность, давление и т. д. Как это реализовать?
Самое простое решение – код для опроса датчиков непосредственно реализовать в ПО контроллера. Но здесь возникает множество вопросов. А если в системе понадобится использовать другую модель датчика, код опроса которого должен быть другим? А если нам нужно использовать различные датчики для различных режимов? А как быть, когда мы вообще не знаем, какие датчики будут использоваться?
Эффективный способ решения указанных проблем – разработка драйвера, т. е. модуля, поддерживающего единый интерфейс вызовов для различных реализаций. Однако одно дело подать идею, а вот реализовать – тут все гораздо сложнее: интерфейс должен быть универсальным и покрывать все возможные требования; необходимо разработать механизм для загрузки нужной реализации интерфейса; требуется каким-то образом связывать интерфейс и реализацию – в итоге нам понадобится сервис поддержки драйверов. Для операционной системы это вполне оправдано, однако для микроконтроллера с его очень ограниченными ресурсами внедрение такого сервиса чревато потерей производительности как из-за большого объема кода, так и из-за дополнительного расхода памяти.