скачать книгу бесплатно
Высокое быстродействие. А вот здесь недостатки монолитной архитектуры превращаются в достоинства. Дело в том, что поскольку инициатор сохраняет у себя объект, он имеет доступ к коду перегруженного оператора, т. е. к коду обработчика вызова. Как следствие, оптимизирующий компилятор получает возможность встроить код обработчика непосредственно в точку вызова, опуская вызов функции (перегруженный оператор тоже является функцией), что значительно ускоряет выполнение вызова. Рассмотрим этот момент подробнее.
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 - Этот код получен с помощью компилятора Microsoft Visual studio версии 19.23.28106.4. Другие компиляторы могут генерировать отличающийся код, но принцип останется прежним.], в котором производится вызов функции (Листинг 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. Код встроен в поток выполнения, вызов функции не производится. Ощутимая разница, не правда ли?
2.5. Лямбда-выражение
2.5.1. Концепция
Лямбда-выражение[12 - В литературе можно встретить термин «лямбда-функция», но в стандарте С++ он именуется как “lambda-expression”, что в переводе означает «лямбда-выражение».] – это локальная неименованная функция, которая, подобно обычной функции, может принимать входные параметры и возвращать результат. Особенностью лямбда-выражений, отличающих их от обычных функций, является возможность захвата переменных.
Графическое изображение обратного вызова с помощью лямбда-выражения представлено на Рис. 15. Исполнитель реализуется в виде какой-либо исполняемой функции, в качестве которой могут выступать глобальная функция, статический метод класса, метод-член класса, перегруженный оператор. Код обратного вызова упаковывается в лямбда-выражение, в качестве контекста выступают захваченные переменные. При настройке лямбда-выражение как аргумент сохраняется в инициаторе. Инициатор осуществляет обратный вызов посредством вызова хранимого выражения, передавая ему требуемую информацию. Контекст здесь передавать не нужно, поскольку внутри тела лямбда-выражения доступны все захваченные переменные.
Рис. 15. Реализация обратного вызова с помощью лямбда-выражения
2.5.2. Инициатор
Как хранить и передавать лямбда-выражение как аргумент? Если оно не захватывает переменные, то стандарт допускает неявное преобразование лямбда-выражения к указателю на функцию. В этом случае реализация инициатора полностью совпадает с рассмотренной в 2.1. Однако использование лямбда-выражений без захвата переменных не дает никакого преимущества по сравнению с обычной функцией, использовать их в таком виде не имеет смысла.
Другое дело, когда лямбда-выражение осуществляет захват переменных, в этом случае мы получаем мощный и гибкий инструмент управления контекстом. Однако использование таких выражений в качестве аргумента вызывает определенные сложности. Связано это с тем, что тип лямбда-выражения является анонимным. Как следствие, имя типа нам неизвестно, и мы не можем просто объявить переменную нужного типа и присвоить ей лямбда-выражение, как это происходит, например, с указателями или классами. Решается указанная проблема с помощью шаблонов, что будет рассмотрено позже в соответствующих главах. Забегая вперед, отметим, что для хранения лямбда-выражений можно объявлять шаблон с параметром – типом лямбда-выражения (п. 4.4.2) либо использовать специальные классы библиотеки STL (п. 4.6.1).
2.5.3. Исполнитель
Исполнитель реализовывается в виде лямбда-выражения, а передача его как аргумента инициатору зависит от способа реализации последнего. Если исполнитель реализован в виде шаблона класса (п. 4.4.2), лямбда-выражение должно присваиваться в конструкторе класса. В случае использования классов STL (п. 4.5.1) лямбда-выражение передается подобно любому другому аргументу. Подробно эти вопросы рассматриваются в разделе 4, посвященном использованию шаблонов.
2.5.4. Синхронный вызов
Инициатор для синхронного вызова с лямбда-выражением реализуется в виде шаблонной функции, параметром шаблона выступает тип аргумента. Подробно этот вопрос рассмотрен в п. 4.2.1.
2.5.5. Преимущества и недостатки
Преимущества и недостатки реализации обратных вызовов с помощью лямбда-выражения приведены в Табл. 6.
Табл. 6. Преимущества и недостатки обратных вызовов с помощью лямбда-выражения
Гибкое управление контекстом. Возможность захвата переменных предоставляет простые и удобные средства изменения контекста. Изменяя состав захваченных переменных, мы легко можем добавлять значения, необходимые для контекста, при этом нет необходимости изменять код инициатора. Захватив указатель this, мы получаем доступ к содержимому класса, т. е. фактически лямбда-выражение превращается в «метод внутри метода» (см. пример в Листинг 22). Элегантно, не правда ли?
Требует использования шаблонов. Использование шаблонов накладывает архитектурные ограничения на реализацию программных модулей. Это связанно с тем, что шаблоны не предполагают присутствие предварительно откомпилированного кода. Подробнее об этом мы будем говорить в соответствующей главе (4.7), посвященной ограничениям при использовании шаблонов.
Листинг 22. Лямбда-выражение с захватом указателя this
class EventCounter
{
public:
void AddEvent(unsigned int event)
{
callCounter_++;
lastEvent_ = event;
}
private:
unsigned int callCounter_ = 0;
int lastEvent_ = 0;
};
class Executor
{
public:
Executor(EventCounter* counter): counter_(counter)
{
auto lambda = [this](int eventID)
{
//It will be called by initiator
counter_->AddEvent(eventID);
processEvent(eventID);
};
//Setup lambda in initiator
}
private:
EventCounter* counter_;
void processEvent(int eventID) {/*Do something*/}
};
2.6. Итоги
В C++ обратные вызовы могут быть реализованы с помощью следующих конструкций:
• указатель на функцию;
• указатель на статический метод класса;
• указатель на метод-член класса;