Kostenlos

Обратные вызовы в C++

Text
Als gelesen kennzeichnen
Schriftart:Kleiner AaGrößer Aa

3.2.2. Выбор реализации

Итак, мы построили матрицу соответствия, проанализировали, насколько реализации соответствуют выбранным критериям. Что же выбрать для конкретного случая? Для решения этого вопроса необходимо определить, какой критерий сейчас является наиболее важным, и выбрать реализацию по этому критерию. Так, например, если самым важным является возможность проектирования системного API, то следует выбрать указатели на функцию. Если самым важным является быстродействие, то следует выбрать функциональные объекты. Если самым важным является гибкость, то следует выбрать указатели на член класса.

А как сделать выбор, если нам одновременно важны несколько критериев, причем некоторые из них противоречат друг другу (а чаще всего именно так и происходит)? У нас появляется проблема многокритериального выбора, решить которую позволяет метод интегральных оценок.

3.3. Метод интегральных оценок

3.3.1. Количественные оценки

По своей сути метод интегральных оценок повторяет качественный анализ, но с одним отличием – в матрице соответствия вместо качественных вводятся количественные оценки. В ячейках матрицы проставляются числовые значения, отражающие, насколько объект анализа поддерживает (другими словами, в какой степени реализует) соответствующее требование. Диапазон возможных значений задается шкалой оценок, которая зависит от точности, которую мы хотим получить. Примеры различных шкал оценок изображены на Рис. 16.

Рис. 16. Шкалы оценки реализуемости требований


Итак, строим матрицу соответствия, в ячейках выставляем числовые оценки, суммируем оценки по столбцам. Реализация, набравшая наибольшее количество баллов, является оптимальной.

Пример интегральных оценок по трем критериям с использованием трехбалльной шкалы приведен в Табл. 8. Здесь наибольшее количество балов набирает реализация с использованием функционального объекта, которая для конкретного случая является оптимальной.


Табл. 8. Интегральные оценки по трехбалльной шкале


3.3.2. Коэффициенты важности

Зачастую оказывается, что некоторые требования являются более важными, чем остальные. Например, быстродействие важно, но в то же время гибкость еще важнее; в свою очередь, безопасность является приоритетным фактором. Чтобы учесть такие ситуации, вводятся коэффициенты важности.

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

Введем коэффициенты важности для предыдущего примера. Ранжируем требования: считаем, что наименее важным для нас является простота, наиболее важным – безопасность. Результаты приведены в Табл. 9.


Табл. 9. Ранжирование требований


Пересчитаем показатели с учетом коэффициентов важности. Для коэффициентов важности вводим отдельный столбец, где проставляем соответствующие значения. В ячейках в скобках отображаются значения оценки без учета коэффициента, без скобок отображаются новые значения с учетом поправок (Табл. 10).


Табл. 10. Интегральные оценки с учетом коэффициентов важности.


Как видим, после введения коэффициентов важности результаты изменились: теперь максимальное количество балов набирают две реализации – указатель на функцию и лямбда-выражение.

3.3.3. Учет прогнозных показателей

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

Из списка требований выбираем те, которые не актуальны сейчас, но которые, возможно, станут актуальны в последствии. Сводим эти требования в таблицу, аналогично предыдущему примеру, но для числовых значений используем инверсную шкалу: если реализация полностью поддерживает соответствующее требование, выставляем 0, если не поддерживает, то выставляем минимальное отрицательное значение14. Так, например, если используется трехбалльная шкала, то 0 превращается в -2, 1 превращается в -1, а 2 превращается в 0. Инверсная шкала показывает, насколько сильно новые требования ухудшают текущую интегральную оценку: чем меньше значение15, тем в большей степени уменьшается текущая оценка.

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

Вернемся к примеру из предыдущего параграфа. Представим, что мы поразмыслили и решили, что в будущем для нас может стать актуальным быстродействие и необходимость реализации C++ API. Сводим эти критерии в таблицу с инверсной шкалой, считаем, что важность этих критериев одинакова. Подсчитываем сумму (Табл. 11).


Табл. 11. Интегральные оценки с инверсной шкалой


Получившиеся результаты суммируем с результатами, полученными с использованием обычной шкалы (Табл. 12).


Табл. 12. Поправки с учетом инверсной шкалы


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

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

3.4. Итоги

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

Качественный анализ используется, если необходимо выбрать реализацию, оптимальную по какому-нибудь единственному критерию. Если у нас имеется несколько критериев, то необходим количественный анализ, в качестве которого применяется метод интегральных оценок.

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

4. Обратные вызовы и шаблоны

4.1. Общие понятия шаблонов

Шаблоны в C++ являются инструментом, реализующим параметрический полиморфизм, что означает возможность построения единого (обобщенного) кода для различных типов данных16. В таком коде не задаются конкретные типы, а вводятся параметры, в которые затем подставляется нужный тип данных. Чтобы код работал корректно, типы должны удовлетворять некоторым соглашениям, или, другими словами, поддерживать определенный интерфейс.

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

Шаблоны объявляются ключевым словом template, после которого в угловых скобках перечисляются параметры. Параметрами шаблона могут быть как типы данных, так и значения.

 

Пример объявления шаблона:


template SomeTemplate<typename type, int value>


Здесь объявлен шаблон с одним параметром-типом type и параметром-значением value.

Параметрам шаблона, как типам, так и значениям, могут быть назначены значения по умолчанию:


template SomeTemplate<typename type = SomeStruct(), int value = 0>


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

Инстанциирование шаблона – это объявление экземпляра шаблона с заданными типами.

Инстанциирование шаблона может быть явным и неявным. При явном инстанциировании типы параметров шаблона объявляются, а при неявном – выводятся, исходя из типов входных аргументов. Пример объявления шаблонов и их инстанциирование представлены в Листинг 23.

Листинг 23. Объявление шаблона и его инстанциирование

template<typename type, int size = 1>  // (1)

class StaticArray

{

public:

  type array[size];

};


template <typename TYPE>          // (2)

TYPE Sum(TYPE s2, TYPE s3)

{

  return s2 + s3;

}


int main()

{

  StaticArray<int, 1> someArray;  // (3)


  int a = 0; double x = 8;

  Sum(a, a);                      // (4)

  Sum<double> (a, x);             // (5)

}


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


Вообще, шаблоны в C++ – это обширная тема, заслуживающая отдельной книги, поэтому изложить ее полностью не представляется возможным. Для лучшего понимания дальнейшего материала, кроме уже изложенных базовых понятий, рекомендуется ознакомиться со следующими темами: шаблоны с переменным числом параметров; частичная специализация шаблонов; автоматический вывод типов17.

Программирование с использованием шаблонов, или, как его еще называют, метапрограммирование, достаточно сложное, поскольку предполагает высокий уровень абстракции в сочетании с неявным генерированием кода на этапе компиляции. Здесь используется другая парадигма, которая очень отличается от привычного объектно-ориентированного подхода; по своей природе шаблоны ближе к функциональному программированию. Однако именно благодаря указанным особенностям они позволяют легко и естественно решать многие задачи, в которых использование классических средств C++ порождает немало проблем.

Применительно к нашей теме, т. е. проектированию обратных вызовов, с помощью шаблонов можно реализовать множество интересных вещей, как это будет показано в следующих главах. Начнем с синхронных вызовов, как наиболее простых.

4.2. Синхронные вызовы

4.2.1. Инициатор

Проанализируем различные реализации инициатора синхронных вызовов (Листинг 24):

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

class Executor

{

public:

  void callbackHandler(int eventID);

  void operator() (int eventID);

};


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

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

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


void run(ptr_callback ptrCallback, void* contextData = nullptr)  // (1)

{

  int eventID = 0;

  ptrCallback(eventID, contextData);

}


void run(ptr_callback_static ptrCallback, Executor* contextData = nullptr)  // (2)

{

  int eventID = 0;

  ptrCallback(eventID, contextData);

}


void run(Executor* ptrClientCallbackClass, ptr_callback_method ptrClientCallbackMethod)  // (3)

{

  int eventID = 0;

  (ptrClientCallbackClass->*ptrClientCallbackMethod)(eventID);

}


void run(Executor callbackHandler)  // (4)

{

  int eventID = 0;

  callbackHandler(eventID);

}


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

Листинг 25. Шаблон для инициатора синхронного вызова

template <typename CallbackArgument>

void run(CallbackArgument callbackHandler)

{

  int eventID = 0;

  //Some actions

  callbackHandler(eventID);

}


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

Что же нам делать для остальных реализаций? Для указателей на функцию и указателей на статический метод (строки 1 и 2) можно сделать отдельный шаблон с двумя параметрами (Листинг 26):

Листинг 26. Шаблон для инициатора с двумя параметрами

template <typename CallbackArgument, typename Context>

void run(CallbackArgument callbackHandler, Context* context)

{

  int eventID = 0;

  //Some actions

  callbackHandler(eventID, context);

}


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

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

4.2.2. Преобразование вызовов

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

Вначале рассмотрим вызовы через указатели на функцию. Создадим шаблон для функционального объекта, в котором будем хранить указатель на функцию и контекст. Перегрузим оператор вызова функции, в реализации которого по хранимому указателю вызовем функцию-обработчик и передадим ей хранимый контекст (Листинг 27).

Листинг 27. Функциональный объект для вызова функции с передачей контекста

template<typename Function, typename Context>  // (1)

class CallbackConverter  // (2)

{

public:

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

  {

    ptrFunction = argFunction; context = argContext;

  }


  void operator() (int eventID)      // (4)

  {

     ptrFunction(eventID, context);  // (5)

  }

private:

  Function ptrFunction;  // (6)

  Context context;       // (7)

};


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

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

Для указателей на метод-член класса сделаем специализацию шаблона, как это показано в Листинг 28.

Листинг 28. Функциональный объект для вызова метода класса

template<typename ClassName>  // (1)

class CallbackConverter <void(ClassName::*)(int), ClassName>  // (2)

{

public:

  using ClassMethod = void(ClassName::*)(int);  // (3)


  CallbackConverter(ClassMethod methodPointer = nullptr, ClassName* classPointer = nullptr)  // (4)

  {

    ptrClass = classPointer; ptrMethod = methodPointer;

  }


  void operator()(int eventID)       // (5)

  {

    ptrClass->*ptrMethod)(eventID);  // (6)

  }

private:

  ClassName* ptrClass;    // (7)

  ClassMethod ptrMethod;  // (8)

};


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

 

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

Итак, определив объекты для преобразования вызовов, мы теперь можем использовать в шаблоне-инициаторе, определенном в Листинг 25 п. 4.2.1, любые типы аргументов обратного вызова. Пример приведен в Листинг 29.

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

class Executor  // (1)

{

public:

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

  void callbackHandler(int eventID) {}

  void operator() (int eventID) {}

};


void ExternalHandler(int eventID, void* somePointer)

{

  Executor* ptrClass = (Executor*)somePointer;

}


int main()

{

  Executor executor;

  int capturedValue = 0;


  // (2) External function

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

  using FunctionConverter = CallbackConverter<FunctionPointer, void*>;

  run(FunctionConverter(ExternalHandler, &executor));


  // (3) Static method

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

  using StaticConverter = CallbackConverter<StaticPointer, Executor*>;

  run(StaticConverter(Executor::staticCallbackHandler, &executor));


  // (4) Member merthod

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

  using MethodConverter = CallbackConverter<MethodPointer, Executor>;

  run(MethodConverter(&Executor::callbackHandler, &executor));


  // (5) Functional object

  run(executor);


  // (6) lambda-expression

  auto lambda = [capturedValue](int eventID) {/*it will be called by initiator*/};

  run(lambda);

}


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


При использовании преобразования вызовов можно использовать сокращенную запись без дополнительного объявления промежуточных типов, в этом случае код получается более компактным, но более запутанным (см. Листинг 30)

Листинг 30. Преобразование вызовов без объявления промежуточных типов

// (2) External function

run(CallbackConverter<void(*)(int, void*), void*>(ExternalHandler, &executor));


// (3) Static method

run(CallbackConverter<void(*)(int, Executor*), Executor*>(Executor::staticCallbackHandler, &executor));


// (4) Member merthod

run(CallbackConverter<void(Executor::*)(int), Executor>(&Executor::callbackHandler , &executor));


// (6) lambda-expression

run([capturedValue](int eventID) {/*it will be called by initiator*/});

4.3. Вызовы в алгоритмах

4.3.1. Описание проблемы

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

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

Например, предположим, что мы написали код для алгоритма сортировки. Естественно предположить, что он будет сортировать числа. Но вот появилась новая задача: отсортировать строки. По сравнению с исходной реализацией у нас теперь другая структура данных (строки) и новые правила сравнения (строки сравниваются совсем не так, как числа). А ведь в будущем, возможно, появятся более сложные случаи – например, сортировка структур по отдельным полям… Как написать универсальный код, работающий с любыми типами данных?

14Минимальное отрицательное, по модулю оно будет максимальным.
15Мы говорим «меньше», поскольку числа здесь отрицательные. По модулю это значение будет «больше».
16В противоположность полиморфизму подтипов, который подразумевает исполнение потенциально разного кода для каждого типа или подтипа. В C++ полиморфизм подтипов реализуется с помощью наследования и виртуальных функций. Термины «параметрический полиморфизм» и «полиморфизм подтипов» больше характерны для академической литературы, в C++ обычно используются их эквиваленты «статический полиморфизм» и «динамический полиморфизм». С точки зрения теории, такая терминология не совсем корректна, потому что она скорее отражает не сущность полиморфизма, а способ его реализации в конкретном языке программирования. Тем не менее, в C++ эти термины прижились.
17Для изучения можно порекомендовать книгу «Вандевурд, Джосаттис, Грегор. Шаблоны C++: справочник разработчика», где подробно рассматриваются соответствующие темы.
18Здесь функциональный объект реализует паттерн «адаптер». Для знакомства с паттернами вообще, и с паттерном «адаптер» в частности можно порекомендовать книгу « Гамма Э., Хелм Р., Джонсон Р., Влиссидес Д. Приемы объектно-ориентированного проектирования. Паттерны проектирования».