Loe raamatut: «Обратные вызовы в C++», lehekülg 7

Font:

4.6.3. Преобразование с настройкой сигнатуры

В п. 4.2.2 реализованы объекты преобразования, которые работали с фиксированной сигнатурой. Используя технику, описанную в Листинг 47 п. 4.5.2, модифицируем их таким образом, чтобы сигнатуру можно было настроить. Для этого в параметрах шаблона вместо задания типов указателей на функцию будем задавать параметры, определяющие сигнатуру, а типы указателей будем выводить из этих параметров.

Рассмотрим вначале указатели на функцию (Листинг 54).

Листинг 54. Преобразование вызовов с настройкой сигнатуры для указателей на функцию

template<typename unused>  // (1)

class CallbackConverter;

template<typename Context, typename Return, typename … ArgumentList>  // (2)

class CallbackConverter<Return(Context, ArgumentList…)>               // (3)

{

public:

  using Function = Return(*)(Context, ArgumentList…);  // (4)

  CallbackConverter(Function argFunction = nullptr, Context argContext = nullptr)  // (5)

  {

    ptrFunction = argFunction; context = argContext;

  }

  Return operator() (ArgumentList… arguments)                 // (6)

  {

    ptrFunction(context, arguments…);                         // (7)

  }

private:

  Function ptrFunction;  // (8)

  Context context;       // (9)

};

В строке 1 вводится общая специализация шаблона. В строке 2 объявляется специализация для указателей на функцию, в которой задается тип передаваемого контекста и параметры сигнатуры. В строке 4 выводится тип указателя. В конструкторе 5 осуществляется настройка указателей. В перегруженном операторе 6 осуществляется вызов 7, в который передаются соответствующие аргументы.

Аналогично выполняется специализация для вызова методов класса (Листинг 55).

Листинг 55. Преобразование вызовов с настройкой сигнатуры для указателей на метод класса.

template<typename ClassType, typename Return, typename…ArgumentList>  // (1)

class CallbackConverter<Return(ClassType::*)(ArgumentList…)>          // (2)

{

public:

  using MemberPointer = Return(ClassType::*)(ArgumentList…);  // (3)

  CallbackConverter(MemberPointer methodPointer = nullptr, ClassType* classPointer = nullptr)  // (4)

  {

      ptrClass = classPointer; ptrMethod = methodPointer;

  }

  Return operator()(ArgumentList… arguments)  // (5)

  {

    (ptrClass->*ptrMethod)(arguments…);       // (6)

  }

private:

  ClassType* ptrClass;                          // (7)

  MemberPointer ptrMethod;                      // (8)

};

Реализация практически повторяет предыдущую, за исключением того, что в объявлениях типов сигнатуры добавляется класс (строки 2 и 3), а перегруженный оператор вызывает метод класса (строка 6).

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

Реализация исполнителя для инициатора с универсальным аргументом (см. Листинг 53 п. 4.6.2) приведена в Листинг 56, здесь используется CallbackConverter из Листинг 54 п. 4.6.3.

Листинг 56. Исполнитель для инициатора с оболочкой std::function

class Executor

{

public:

  static void staticCallbackHandler(Executor* executor, int eventID) {}

  void callbackHandler(int eventID) {}

  void operator() (int eventID) {}

};

void ExternalHandler(void* somePointer, int eventID) {}

int main()

{

  int capturedValue = 0;

  Initiator initiator;

  Executor executor;

  // Pointer to the external function

  initiator.setup(CallbackConverter<void(void*, int)>(ExternalHandler, &executor));

  // Pointer to the static method

  initiator.setup(CallbackConverter<void(Executor*, int)>(Executor::staticCallbackHandler, &executor));

  // Pointer to the class member method

  initiator.setup(CallbackConverter<void(Executor::*)(int)>(&Executor::callbackHandler, &executor));

  // Functional object

  initiator.setup(executor);

  // Lambda-expression

  initiator.setup([capturedValue](int eventID) {});

}

Если сравнить приведенную реализацию исполнителя для шаблона-инициатора с фиксированным типом аргумента (Листинг 43 и Листинг 44 п. 4.4.3) с приведенной, то можно заметить следующее. В первом случае для каждого типа аргумента приходится объявлять отдельный инициатор, инстанциируя его соответствующим типом. Здесь инициатор объявляется один раз, после чего тип аргумента вызова настраивается в процессе выполнения программы. В результате упрощается разработка, улучшается гибкость и прозрачность кода.

4.6.5. Инициатор для методов класса

До сих пор для вызова методов класса мы использовали преобразование вызовов. Однако, поскольку std::function непосредственно поддерживает вызов методов, появляется возможность реализовать специализированный инициатор для указанного случая. За основу возьмем инициатор из п. 4.6.2 и модифицируем его.

Как мы видели в реализации универсального аргумента (п. 4.5.3), для вызова метода класса первым параметром должен передаваться указатель на экземпляр класса. Поэтому, в инициатор необходимо добавить переменную для хранения этого указателя. Но поскольку тип класса заранее неизвестен, его следует задавать как параметр, т. е. инициатор должен быть объявлен в виде шаблона. Далее необходимо добавить метод для настройки указателя и, соответственно, при задании сигнатуры и выполнении вызова передавать дополнительный аргумент – указатель на экземпляр класса. Реализация приведена в Листинг 57.

Листинг 57. Инициатор с оболочкой std::function для вызова методов класса

template<typename ClassName>  // (1)

class InitiatorForClass

{

public:

  template<typename CallbackArgument>

  void setup(const CallbackArgument argument)  // (2)

  {

    callbackHandler = argument;

  }

  void setupInstance (ClassName* classObject)  // (3)

  {

    ptrClass = classObject;

  }

  void run()  // (4)

  {

      int eventID = 0;

      //Some actions

      callbackHandler(ptrClass, eventID);  // (5)

  }

private:

  std::function<void(ClassName*, int)> callbackHandler;  // (6)

  ClassName* ptrClass = nullptr;                         // (7)

};

В строке 1 объявлен шаблон класса. В строке 2 объявлен метод для настройки аргумента, в качестве которого выступает указатель на метод-член. В строке 3 объявлен метод для настройки экземпляра класса. Метод запуска 4 такой же, как и в исходном, за исключением того, что при вызове в аргумент дополнительно передается указатель на класс (строка 5). В строке 6 инстанциируется аргумент для вызова метода класса, в сигнатуре первым параметром выступает указатель на класс, задаваемый параметром шаблона-инициатора. В строке 7 объявлена переменная для хранения указателя на экземпляр класса.

Итак, модифицировав инициатор из Листинг 53 п. 4.6.2, мы реализовали отдельный инициатор для вызова методов-членов. Используя частичную специализацию шаблона, можно сделать так, чтобы оба инициатора объявлялись одинаковым способом (Листинг 58).

Листинг 58. Использование специализации шаблона-инициатора для вызова методов класса

template<typename… unused>  // (1)

class Initiator

{

  //… Implementation for origin initiator

};

template<typename ClassName>  // (2)

class Initiator<ClassName>

{

  //… Implementation for class method call initiator

};

В строке 1 объявлен исходный класс, но теперь он является шаблоном с пакетом параметров. Пакет параметров здесь не используется, он нужен только для дальнейшей специализации.

В строке 2 объявлен шаблон для вызова методов-членов. Поскольку его имя совпадает с именем предыдущего, компилятор будет считать, что здесь определяется не новый класс, а специализация объявленного ранее. В объявлении указан параметр, предполагается, что в этом качестве будет использоваться имя класса. Теперь, если при инстанциировании шаблона будет задаваться параметр, будет выбрана специализация для вызова методов-членов. При отсутствии параметров будет выбран исходный шаблон.

Использование двух типов инициатора (исходного и специализированного) для вызова методов класса приведено в Листинг 59, здесь используется преобразование вызовов из Листинг 54 п. 4.6.3.

Листинг 59. Использование инициатора с оболочкой std::function для вызова методов класса

class Executor

{

public:

  static void staticCallbackHandler(Executor* executor, int eventID) {}

  void callbackHandler(int eventID) {}

  void operator() (int eventID) {}

};

int main()

{

  Executor executor;

  Initiator initiator;  // (1)

  initiator.setup(CallbackConverter<void(Executor::*)(int)>(&Executor::callbackHandler, &executor));  // (2)

  initiator.run();

  Initiator<Executor> initiatorForClass;                // (3)

  initiatorForClass.setup(&Executor::callbackHandler);  // (4)

  initiatorForClass.setupInstance(&executor);           // (5)

  initiatorForClass.run();

}

В строке 1 объявлен исходный инициатор. В параметры шаблона мы не передаем никаких аргументов, т. е. шаблон инстанциируется подобно обычному классу. В строке 2 происходит настройка инициатора, в качестве аргумента передается объект для преобразования вызовов.

В строке 3 объявлен специализированный инициатор для вызова методов класса, он инстанциируется типом Executor. В строке 4 настраивается указатель на метод класса, в строке 5 настраивается указатель на экземпляр класса.

Какой инициатор лучше использовать для методов класса, исходный с преобразованием или модифицированный с непосредственным вызовом? Трудно однозначно ответить на этот вопрос. С одной стороны, использование специализированного класса противоречит идее обобщенного кода – в специализированном классе мы вынуждены повторять всю реализацию, даже в тех частях, где она совпадает с исходной. С другой стороны, упрощается работа с настройкой инициатора – нам не нужно использовать класс для преобразования, можно по отдельности изменять указатель на метод и указатель на экземпляр. В общем, выбор остается на усмотрение разработчика.

4.6.6. Перенаправление вызовов

Представьте следующую ситуацию: инициатор вызывает функцию с одной сигнатурой, а в клиенте реализован обработчик с другой сигнатурой. Например, в исполнителе реализована функция обработки нажатия кнопки, которая на вход принимает два параметра – идентификатор кнопки и текущее поле редактирования. В то же время инициатор вызывает функцию, передавая ей только один аргумент – идентификатор текущей нажатой кнопки, и он ничего не знает об остальных элементах управления. Можно ли сделать так, чтобы инициатор вызывал одну функцию, но при этом бы вызывалась другая функция, другими словами, происходило перенаправление вызова? В стандартной библиотеке для этого существуют специальные объекты связывания std::bind, которые при необходимости могут сохраняться в std::function подобно обычным функциональным объектам.

Графически использование связывания продемонстрировано на Рис. 18. Пусть инициатор вызывает функцию 1, которая на вход принимает аргумент 1. Исполнитель реализует обратный вызов с помощью функции 2, которая принимает на вход два аргумента. Вместо функции 1 инициатору назначается объект связывания, который имеет перегруженный оператор вызова функции с сигнатурой 1. Указанный объект хранит дополнительный параметр, значение которому присваивается во время инициализации. Перегруженный оператор, в свою очередь, вызывает функцию 2, первому аргументу передает сохраненный параметр, а второму аргументу передает значение аргумента из функции 1. Таким образом, осуществляется перенаправление вызова из функции 1 в функцию 2.

Рис. 18. Перенаправление вызовов


Использование перенаправления вызовов представлено в Листинг 60.

Листинг 60. Перенаправление вызовов

#include <functional>


void NativeHandler(int eventID)  // (1)

{

  //here eventID is 10

}


void AnotherHandler(int contextID, int eventID)  // (2)

{

  //here eventID is 1, contextID is 10;

}


int main()

{

  int eventID = 1; int contextID = 10;


  std::function<void(int)> fnt;  // (3)

  fnt = NativeHandler;           // (4)

  fnt(eventID);                  // (5) NativeHandler will be called


  fnt = std::bind(AnotherHandler, contextID, std::placeholders::_1);  // (6)

  fnt(eventID);  // (7) AnotherHandler will be called

}


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

В строке 6 вызывается функция bind, которая из переданных аргументов формирует объект связывания. На вход std::bind передается имя новой функции-обработчика и аргументы, которые будут передаваться в эту функцию. Первому аргументу здесь будет назначено значение contextID, а второму аргументу будет назначено значение 1-го по порядку аргумента из исходной функции. Здесь конструкция std::placeholders определяет номер аргумента в исходной функции, который будет подставлен в качестве аргумента в перенаправляемую функцию.

Сформированный объект связывания сохраняется в универсальном аргументе. Если мы теперь выполним вызов (строка 7), то будет вызвана функция, назначенная этому объекту, и этой функции будут переданы соответствующие аргументы.


Аналогичным образом может быть объявлено перенаправление вызовов для методов-членов класса, но здесь должно соблюдаться следующее правило: первому аргументу новой функции должен быть назначен первый аргумент исходной функции, потому что он определяет экземпляр класса, для которого вызывается метод. Пример приведен в Листинг 61.

Листинг 61. Перенаправление вызовов для методов-членов класса

#include <functional>


class CallbackHandler

{

public:

  void NativeHandler(int eventID)

  {

    //eventID = 1;

  }

  void AnotherHandler(int contextID, int eventID)

  {

    //eventID = 1, contextID = 10;

  }

};


int main()

{

  using namespace std::placeholders; // (1)


  int eventID = 1; int contextID = 10;

  CallbackHandler handler;


  std::function<void(CallbackHandler*, int)> fnt;

  fnt = &CallbackHandler::NativeHandler;

  fnt(&handler, eventID); // NativeHandler will be called


  fnt = std::bind(&CallbackHandler::AnotherHandler, _1, contextID, _2); // (2)

  fnt(&handler, eventID); // AnotherHandler will be called

}


Здесь в строке 1 мы использовали using namespace, что сокращает объявление позиций аргументов: как видно из строки 2, мы сразу пишем позицию без использования std::placeholders, что значительно компактнее и проще для восприятия. Здесь в исходной функции присутствует неявный параметр с номером 1, который определяет экземпляр класса. Этот параметр назначается первому (неявному) параметру новой функции, а второй параметр исходной функции eventID назначается последнему параметру новой функции.


В общем случае могут быть 4 варианта перенаправления вызовов:

• из функции в функцию (пример в Листинг 60);

• из функции в метод класса;

• из метода класса в другой метод этого же класса (пример в Листинг 61);

• из метода класса в метод другого класса;

• из метода класса в функцию.

Реализация указанных вариантов, по сути, одинакова, отличаются только объявления связывания. Сведем эти объявления в таблицу (Табл. 13).


Табл. 13. Связывания для различных вариантов перенаправления вызовов.


Теперь перенаправление вызовов в исполнителе не представляет сложности: при настройке вместо объекта вызова нужно всего лишь подставить необходимое связывание. Пример для варианта «функция – функция» приведен в Листинг 62, здесь используется инициатор из Листинг 53.

Листинг 62. Перенаправление вызовов в исполнителе

void NativeHandler(int eventID)

{

  //here eventID is 10

}


void AnotherHandler(int contextID, int eventID)

{

  //here eventID is 10, contextID is 1;

}


int main()

{

  int eventID = 10; int contextID = 1;

  Initiator initiator;            // (1)


  initiator.setup(NativeHandler); // (2)

  initiator.setup(std::bind(AnotherHandler, contextID, std::placeholders::_1)); // (3)


  initiator.run(); // (4)

}


В строке 1 объявлен инициатор. В строке 2 происходит настройка инициатора с передачей ему указателя на функцию с «родной» сигнатурой, т. е. сигнатурой, для которой инициатор осуществляет вызов. Если бы мы после этого запустили инициатор путем вызова метода run, то инициатор вызывал бы функцию NativeCallbackHandler. В строке 3 вместо функции с «родной» сигнатурой мы подставляем объект связывания, который будет перенаправлять вызов в другую функцию. В строке 4 запускаем инициатор, в котором после вызова функции объекта связывания будет осуществлен вызов AnotherCallbackHandler с соответствующими параметрами. Аналогичным образом, подставляя нужные связывания из Табл. 13, осуществляется перенаправление вызовов для других вариантов.


Итак, использование объектов связывания предлагает универсальный способ преобразования вызовов: вместо объектов преобразования (п. 4.2.2, 4.6.3) в универсальный аргумент подставляется объект связывания, сгенерированный соответствующим вызовом std::bind.

4.6.7. Универсальный аргумент и производительность

Может показаться, что организация обратных вызовов с использованием std::function в качестве универсального аргумента является наилучшим решением, предлагающим простоту реализации в сочетании с максимальной гибкостью. В большинстве случаев это действительно так, однако std::function обладает недостатком, который может свести на нет все остальные достоинства: большие временные затраты для осуществления вызова по сравнению с другими способами реализации. Причины этого следующие:

1) при вызове происходит проверка, настроен ли аргумент;

2) вызов происходит через промежуточный объект с виртуальной функцией (см. 4.5.1) – расходуется дополнительное время для вызова этой функции;

3) поскольку промежуточный объект создается динамически, его адрес может изменяться, что требует загрузки адреса перед вызовом;

4) на этапе компиляции тип аргумента неизвестен, поэтому код обработки не может быть встроен в точку вызова.

Первые три причины вносят незначительный вклад в общее время, затрачиваемое на выполнение вызова, а вот четвертая может привести к резкому падению производительности. Мы уже рассматривали подобную проблему при анализе функциональных объектов (п. 2.4.6): при малом объеме кода обработчика время, затраченное на вызов функции, может превысить время выполнения тела функции.

Проведем эксперимент. Напишем программу, в которой циклически будут осуществляться вызовы различных типов для кода небольшого размера25. Поскольку код обработчика один и тот же, общее время, затраченное на выполнение вызова, будет прямо пропорционально времени, затраченному на организацию вызова. Запустим программу и выполним профилирование26. Результаты профилирования представлены в Табл. 14, графически они изображены на Рис. 1927.


Табл. 14. Время, затраченное на выполнение вызовов различных типов для кода небольшого размера, млс.


Рис. 19. Гистограмма результатов профилирования вызовов различных типов для кода небольшого размера


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

Посмотрим теперь результаты при использовании универсального аргумента. Если сравнить с вызовами напрямую, время выполнения ожидаемо увеличивается. Однако если для указателя на функцию и указателя на метод увеличение незначительно, то для функционального объекта и лямбда-выражения оно увеличивается настолько, что практически исчезает отличие от других способов. Теперь код обработчика не встраивается в точку вызова, и расходы на вызов функции во много раз превышают расходы на выполнение тела функции.

Модифицируем теперь код обработчика таким образом, чтобы оптимизатор не мог встроить его в точку вызова. Числовые значения замеров представлены в Табл. 15, графически они изображены на Рис. 20. Теперь картина получается иная: прямое использование функциональных объектов и лямбда-выражений не дают заметного выигрыша в производительности, а использование универсального аргумента увеличивает время выполнения незначительно.


Табл. 15. Время, затраченное на выполнение вызовов различных типов для кода большого размера, млс.


Рис. 20. Гистограмма результатов профилирования вызовов различных типов для кода большого размера


Какой код будет встраиваться в точку вызова, а какой нет? Однозначного ответа на этот вопрос дать невозможно. Алгоритмы работы оптимизатора не документируются и принимают во внимание множество факторов: количество команд в коде; количество точек вызова; наличие рекурсивных вызовов; оценка степени увеличения результирующего кода после встраивания и т. п. Самый надежный способ – посмотреть дизассемблированный код, где однозначно видно, встроен ли код обработчика в точку вызова.

Исходя из изложенного, можно сделать следующий вывод:

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

25.Исходный код можно посмотреть здесь: https://github.com/Tkachenko-vitaliy/Callbacks/tree/master/Profiling.
26.Указатели на статические методы классов в эксперименте не участвовали, потому что с точки зрения организации вызова они идентичны указателям на обычные функции. Профилирование выполнялось в среде Microsoft Visual Stidio.
27.Если читатель попробует повторить эксперимент, то числовые значения, скорее всего, будут другими. Во-первых, они сильно зависят от используемого компилятора, точности профилировщика, производительности процессора. Во-вторых, в силу особенностей современных программно-аппаратных архитектур даже при запуске на одной и той же платформе результаты профилирования не будут повторяться, они плавают в некотором диапазоне значений. Заинтересованному читателю можно порекомендовать книгу «Крис Касперски. Техника оптимизации программ. Эффективное использование памяти», где подробно рассматривается этот вопрос.
  Тем не менее, указанные замечания не могут считаться основанием для сомнений в достоверности эксперимента, поскольку относительные значения всегда будут приблизительно одинаковыми независимо от используемых программно-аппаратных средств.
28.Снижается время выполнения – увеличивается быстродействие, т. е. эти показатели обратно пропорциональны.