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

Font:

4.5. Универсальный аргумент

4.5.1. Динамический полиморфизм

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

Как решается указанная задача в объектно-ориентированном дизайне? Объявляется базовый абстрактный класс, в котором описывается интерфейс в виде набора чисто виртуальных методов. Новый тип создается путем создания наследуемого класса, в котором объявляются нужные переменные и переопределяются методы. При инициализации создается класс нужного типа, и он сохраняется в переменной – указателе на базовый класс. Мы будем использовать аналогичный подход, только наследуемые типы будут создаваться динамически, используя параметры шаблона. Указанная техника называется «стирание типов»: при назначении нового типа аргумента предыдущий сохраненный уничтожается, и его место занимает новый22.

Графическое изображение стирания типов изображено на Рис. 17. Рассмотрим начальное состояние а), показанное в верхней части рисунка. Имеется некоторый класс, назовем его UniArgument. В этом классе объявлен перегруженный оператор вызова функции 2. Также здесь имеется указатель 3 типа Callable*, который указывает на соответствующий экземпляр класса Callable. Класс Callable 4 объявлен внутри UniArgument и имеет виртуальный перегруженный оператор вызова функции с пустой реализацией.

Когда в UniArgument происходит вызов 1 перегруженного оператора 2, последний через указатель 3 вызывает виртуальный перегруженный оператор класса Callable.

В нижней части рисунка б) показано, как назначается новый тип. Объявляется перегруженный оператор присваивания 10, на входе он принимает аргумент обратного вызова 8. При вызове этого оператора старый экземпляр класса 4, на который указывал указатель 3, уничтожается в 11, а вместо него создается новый класс CallableObject 5, который наследуется от Callable. Внутри класса имеется поле 7, в которое записывается переданный аргумент 8, тип этого поля совпадает с типом аргумента. В CallableObject переопределяется оператор вызова функции 6, который, в свою очередь, осуществляет вызов через сохраненный аргумент 7. Теперь указатель 3 указывает на новый созданный CallableObject, и при вызове 1 перегруженного оператора 2 будет вызываться перегруженный оператор указанного класса, который и выполнит обратный вызов.


Рис. 17. Стирание типов: а) исходное состояние; б) состояние после назначения нового типа аргумента


Реализация рассмотренной схемы представлена в Листинг 45.

Листинг 45. Класс, реализующий стирание типов

class UniArgument  // (1)

{

private:

  class Callable   // (2)

  {

  public:

    virtual void operator()(int) = 0;         // (3)

  };


  std::unique_ptr<Callable> callablePointer;  // (4)


  template <typename ArgType>

  class CallableObject : public Callable      // (5)

  {

  public:

    CallableObject(ArgType argument) : storedArgument(argument) { }  // (6)


    void operator() (int value) override  // (7)

    {

      storedArgument(value);  // (8)

    }

  private:

    ArgType storedArgument;   // (9)

  };


public:

  void operator() (int value)  // (10)

  {

    callablePointer->operator()(value);  // (11)

  }


  template <typename ArgType>

  void operator = (ArgType argument)  // (12)

  {

    callablePointer.reset(new CallableObject<ArgType>(argument));  // (13)

  }

};


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

В базовом классе перегружен оператор вызова функции 3. Оператор объявлен чисто виртуальным, чтобы опустить его реализацию. Предполагается, что этот оператор будет выполнять обратный вызов, но аргумента вызова здесь нет, он будет храниться в наследуемом классе. Таким образом, реализация смысла не имеет. Более того, если, допустим, нам понадобится, чтобы оператор возвращал результат, то в нем должна присутствовать команда return, и какое тогда возвращать значение?

В строке 4 объявлен указатель на базовый класс, объявленный в 2.

В строке 5 объявлен шаблонный класс, который будет хранить переданный аргумент и вызывать его. Переменная для хранения аргумента объявлена в строке 9, тип переменной задается параметром шаблона. Аргумент назначается в конструкторе 6. Также в этом классе переопределяется оператор вызова функции 7, в котором происходит обратный вызов 8 через сохраненный аргумент.

В строке 10 объявлен перегруженный оператор основного класса, в котором вызывается соответствующий переопределенный оператор через указатель на базовый класс (строка 11).

В строке 12 объявлен шаблонный оператор присваивания, который настраивает аргумент. В реализации этого оператора 13 создается новый класс CallableObject нужного типа, в конструкторе этого класса переданный аргумент сохраняется, после чего переназначается указатель. Таким образом, при вызове оператора 10 будет вызван оператор соответствующего класса 11, и последний осуществит вызов через сохраненный аргумент.

Можно заметить, что универсальный аргумент не выполняет вызов сам по себе. По сути, он является своего рода оболочкой, которая перенаправляет вызов соответствующему объекту. Таким образом, у нас появляется новое понятие – объект вызова.

Объект вызова – это некоторая конструкция C++, поддерживающая интерфейс вызова в формате функции.

В соответствии с стандартом C++ на сегодняшний день23, в качестве объектов вызова могут использоваться следующие конструкции:

• функции;

• методы класса;

• классы с перегруженным оператором вызова функции;

• лямбда-выражения.

В реализациях инициатора с помощью шаблонов, рассмотренных в предыдущих главах (см. п. 4.2.1, 4.4.1), аргумент вызова совпадает с объектом вызова. При использовании универсального аргумента эти сущности будут различаться: универсальный аргумент хранит в себе объект вызова.


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

Листинг 46. Использование универсального аргумента

class Executor

{

public:

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

  void callbackHandler(int eventID) {}

  void operator() (int eventID) {}

};


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


int main()

{

UniArgument argument;


Executor executor;

int capturedValue = 0;


using PtrExtFunc = void(*) (int, void*);

argument = CallbackConverter<PtrExtFunc, void*>(ExternalHandler, &executor);                          // (1)


using PtrStaticMethod = void(*) (int, Executor*);

argument = CallbackConverter<PtrStaticMethod, Executor*>(Executor::staticCallbackHandler, &executor);  //(2)


using PtrMemberMethod = void(Executor::*)(int);

argument = CallbackConverter<PtrMemberMethod, Executor>(&Executor::callbackHandler, &executor);       // (3)


argument = executor;  // (4)


argument = [capturedValue](int eventID) {/*Body of lambda*/};  // (5)

}


В строке 1 аргументу присваивается указатель на функцию, для преобразования вызовов используется класс CallbackConverter из Листинг 27 п. 4.2.2. Этот класс инстанциируется соответствующими типами, в конструкторе ему передается функция ExternalHandler и контекст, в качестве которого выступает указатель на класс Executor.

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

В строке 3 аргументу присваивается указатель на метод-член класса, для преобразования вызовов используется класс CallbackConverter из Листинг 28 п. 4.2.2. Этот класс инстанциируется соответствующими типами, в конструкторе ему передается указатель на класс и указатель на метод класса.

В строке 4 аргументу присваивается функциональный объект, в строке 5 – лямбда-выражение.

Отметим, что в универсальном аргументе лямбда-выражение сохраняется также просто, как и любой другой тип. Это связано с тем, что как оператор присваивания (operator = класса UniArgument, Листинг 45 п. 4.5.1), так и класс для хранения аргументов вызова (CallableObject, там же) реализованы в виде шаблонов. Когда мы вызываем указанный оператор, передавая ему лямбда-выражение, компилятор неявно выведет тип параметра шаблона из переданного аргумента, подобно тому, как это происходит в шаблонной функции для синхронных вызовов. В свою очередь, внутри оператора с помощью new динамически создается экземпляр CallableObject, инстанциированный соответствующим выведенным типом. Таким образом, явно указывать тип передаваемого аргумента не требуется, компилятор выводит его сам.

4.5.2. Настройка сигнатуры

До сих пор мы предполагали, что функция, реализующая обратный вызов, имеет тип void и на вход принимает только одно значение eventID, и исходя из этого, делали обратный вызов. А если выясняется, что функция должна иметь дополнительные параметры, нам придется изменять реализацию универсального аргумента и объектов, с ним связанных? А если нам необходимы инициаторы, которые используют функции с различными сигнатурами? Теперь что, для каждой сигнатуры придется реализовать отдельный аргумент? Есть другой путь: настройка сигнатуры вызова через параметры шаблона. Для ее реализации используется частичная специализация шаблона в сочетании с переменным числом параметров (partial template specialization, variadic templates), пример представлен в Листинг 47.

Листинг 47. Настройка сигнатуры

//General specialization

template <typename unused>  // (1)

class function;


//Partial specialization

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

class function<Return(ArgumentList…)>

{

public:


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

  {

  }

};


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

В строке 3 объявлен перегруженный оператор, выступающий в качестве функции вызова. Сигнатура оператора содержит тип возвращаемого значения Return и пакет входных параметров arguments, которые разворачиваются в список аргументов. Таким образом, в зависимости от пакета и возвращаемого значения будет сгенерирована соответствующая специализация шаблона.

Описанная реализация всего лишь демонстрирует настройку сигнатуры. Практической пользы от нее немного, потому что тело перегруженного оператора пустое, и вызов осуществлен не будет. Используя описанную технику, добавим настройку сигнатуры к аргументу, реализующему стирание типов (Листинг 48).

Листинг 48. Стирание типов с настройкой сигнатуры

template <typename unused>

class UniArgument;


template<typename Return, typename … ArgumentList>

class UniArgument<Return(ArgumentList…)>  // (1)

{

private:

  struct Callable

  {

    virtual Return operator()(ArgumentList… arguments) = 0;  // (3)

  };


  std::unique_ptr<Callable> callablePointer;


  template <typename Argument>

  struct CallableObject : Callable

  {

    Argument storedArgument;


    CallableObject(Argument argument) : storedArgument(argument) { }


    Return operator() (ArgumentList… arguments) override  // (8)

    {

      //return storedArgument(arguments…);

      return std::invoke(storedArgument, arguments…);     // (9)

    }

  };


public:

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

  {

    return callablePointer->operator()(arguments…);  // (11)

  }


  template <typename Argument>

  void operator = (Argument argument)

  {

    callablePointer.reset(new CallableObject<Argument>(argument));

  }

};


По сравнению с реализацией для фиксированной сигнатуры (Листинг 45 п. 4.5.1) изменения здесь следующие. Класс аргумента (строка 1) объявляется в виде шаблона. Параметрами шаблона выступают Return – тип значения, возвращаемого функцией, и ArgumentList – пакет параметров, определяющих типы передаваемых в функцию аргументов. При объявлении перегруженных операторов (строки 3, 8, 10), вместо конкретного типа возвращаемого значения подставляется параметр шаблона Return, вместо конкретных типов входных параметров подставляется ArgumentList. В местах, где происходит вызов оператора, пакет параметров раскрывается (строки 9 и 11), что означает, что вместо arguments будет подставлен список переменных с типами, заданными в пакете параметров.


Теперь в универсальном аргументе можно настраивать сигнатуру, как это продемонстрировано в Листинг 49.

Листинг 49. Использование аргумента с настройкой сигнатуры

void ExternalHandler1(int eventID) {/*Do something*/}            // (1)

int  ExternalHandler2(int eventID, int contextID) { return 0; }  // (2)


struct CallbackHandler  // (3)

{

  void operator() (int eventID) {}

  bool operator() (int eventID, int contextID) { return false; }

};


int main()

{

  int capturedValue = 100;

  CallbackHandler callbackObject;           // (4)


  UniArgument<void(int)> argument1;         // (5)

  UniArgument<bool(int, int)> argument2;    // (6)


  argument1 = ExternalHandler1;  // (7)

  argument2 = ExternalHandler2;  // (8)


  argument1 = callbackObject;    // (9)

  argument2 = callbackObject;    // (10)


  argument1 = [capturedValue](int eventID) {/*Do something*/};                           // (11)

  argument2 = [capturedValue](int eventID, int contextID) { /*DoSomething*/return 0; };  // (12)


  argument1(3);               // (13)

  int res = argument2(4, 5);  // (14)


  return res;

}


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

В строках 5 и 6 объявлены универсальные аргументы, в которых с помощью параметров шаблона настраивается нужная сигнатура. Далее этим аргументам будут присваиваться различные объекты вызова в зависимости от заданной сигнатуры.

В строках 7 и 8 в аргумент передаются внешние функции. В строках 9 и 10 передается функциональный объект, у которого, в зависимости от настроенной сигнатуры будет вызван соответствующий перегруженный оператор. В строках 11 и 12 передаются лямбда-выражения. В строках 13 и 14 осуществляются вызовы в соответствии с заданной сигнатурой.

4.5.3. Вызов метода класса

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

До появления стандарта C++17 реализация указанного способа была достаточно сложной: пришлось бы объявлять еще один объект, который наследовался от Callable и осуществлял вызов метода; для создания соответствующего объекта пришлось бы объявить дополнительный перегруженный оператор присваивания, который в качестве входного аргумента принимал указатель на метод. Но в новом стандарте появилась функция std::invoke, которая определяет тип принимаемого объекта вызова и осуществляет вызов для соответствующего типа. Таким образом, для поддержки вызова метода класса необходимо в реализации CallableObject изменить одну-единственную строчку:

Return operator() (ArgumentList… arguments) override  // (8)

{

  //return storedArgument(arguments…);

  return std::invoke(storedArgument, arguments…);  // (9)

}


На удивление просто, не правда ли?

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

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

struct CallbackHandler

{

  void handler1(int eventID) {};

  bool handler2(int eventID, int contextID) { return false; };

};


int main()

{

  CallbackHandler callbackObject;


  UniArgument<void(CallbackHandler*, int)> argument1;       // (1)

  UniArgument<bool(CallbackHandler*, int, int)> argument2;  // (2)


  argument1 = &CallbackHandler::handler1;  // (3)

  argument2 = &CallbackHandler::handler2;  // (4)

  argument1(&callbackObject, 100);         // (5)

  argument2(&callbackObject, 0, 1);        // (6)

}


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


Итак, универсальный аргумент практически готов. Нам осталось реализовать оператор копирования, оператор присваивания и некоторые другие операции. Но мы этим заниматься не будем: разработчики стандартной библиотеки уже обо всем позаботились, поэтому темой следующей главы будет обзор инструментов STL для организации обратных вызовов24.

4.6. Использование стандартной библиотеки

4.6.1. Организация вызовов

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

Насколько сложна реализация std::function, настолько же просто ее использование. По аналогии с универсальным аргументом, рассмотренном в предыдущей главе, достаточно объявить экземпляр класса с нужной сигнатурой, после чего ему можно назначать различные объекты вызовов (Листинг 51).

Листинг 51. Использование std::function

void External(int eventID) {};


int main()

{

  struct Call

  {

    void operator() (int eventID) {};

  } objectCall;


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


  fnt = External;

  fnt = objectCall;

  fnt = [](int evetID) {};


  fnt(0);

}


Полезной особенностью std::function является проверка настройки объекта вызова. Если объект не настроен, т. е. не было ни одного присваивания, то при попытке вызова будет выброшено исключение. Проверить, настроен ли объект, можно с помощью перегруженного оператора bool, пример приведен в Листинг 52.

Листинг 52. Проверка настройки аргумента

int main()

{

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


  fnt(0); //Error: argument is not set. Exception will be thrown


  fnt = [](int) {};

  fnt(0); //Ok, argument is set


  //Check if the argument is set

  if (fnt)

  {

    fnt(0);

  }

}

4.6.2. Инициатор с универсальным аргументом

Для реализации инициатора с универсальным аргументом необходимо для хранения аргумента объявить соответствующую класс-оболочку std::function (Листинг 53).

Листинг 53. Инициатор с оболочкой std::function

class Initiator  // (1)

{

public:

  template<typename CallbackArgument>

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

  {

    callbackHandler = argument;

  }


  void run()

  {

  int eventID = 0;

  //Some actions

  callbackHandler(eventID);

  }


private:

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

};


Если сравнить реализацию инициатора с фиксированным типом аргумента (Листинг 37 п. 4.4.1) с приведенной, то можно заметить следующие отличия. В первом случае инициатор является шаблоном, здесь он объявляется обычным способом. Далее, хранимый аргумент 3 не является переменной типа, задаваемого параметром шаблона, он объявлен как универсальный аргумент std::function. Метод настройки 2 объявлен как шаблон, параметром которого является тип назначаемого аргумента.

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

21.Термин «динамический полиморфизм» означает, что полиморфизм реализуется во время выполнения программы. В противоположность этому, статический полиморфизм реализуется на этапе компиляции программы. В строгом смысле этого термина динамический полиморфизм в C++ нереализуем, поскольку это язык со статической типизацией. Однако его можно смоделировать с помощью наследования и шаблонов, о чем пойдет речь далее.
22.Для фундаментального изучения техники стирания типов можно порекомендовать книгу «Пикус Ф.Г.
  Идиомы и паттерны проектирования в современном С++», в которой указанной технике посвящена отдельная глава.
23.На момент написания книги это C++ 20.
24.«Зачем же мы тогда разрабатывали универсальный аргумент, если в STL все уже давно реализовано?» – может воскликнуть рассерженный читатель. Ну, во-первых, грамотный разработчик отличается от обычного разработчика тем, что он не только знает, как применять те или иные инструменты, но еще и понимает, как они работают. И, во-вторых, рассмотренные методы используются не только в проектировании обратных вызовов, они могут использоваться при решении самых различных задач.