Новые возможности С++17 и библиотеки STL

Илья Дергунов

Илья Дергунов

Автор статьи. ИТ-специалист с 20 летним стажем, автор большого количества публикаций на профильную тематику (разработка ПО, администрирование, новостные заметки). Подробнее.

С++17 и STL - новые функции и возможностиФункциональность языка C++ значительно расширилась с выходом C++11, C++14 и недавней версии C++17. На текущий момент он совсем не похож на себя образца десятилетней давности. Стандарт С++ упорядочивает не только язык, но и STL. В моем блоге на большом количестве примеров показаны наилучшие способы использования возможностей STL. Но для начала в текущей главе мы сконцентрируемся на самых важных особенностях языка. Изучив их, вы сможете писать легко читаемый, удобный в сопровождении и выразительный код.


Оглавление статьи[Показать]


Мы рассмотрим, как получить доступ к отдельным элементам пар, кортежей и структур с помощью структурированных привязок и ограничить область види­мости переменных благодаря новым возможностям по инициализации переменных внутри выражений if и switch. Синтаксические двусмысленности, появившиеся в C++11 из-за нового синтаксиса инициализатора с фигурными скобками, который выглядит так же, как синтаксис списков инициализаторов, были исправлены в новых правилах инициализатора с фигурными скобками. Точный тип экземпляра шаблонного класса может быть определен по аргументам, переданным его конструк­тору, а если разные специализации шаблонного класса выполняются в разном коде, то это легко выразить с помощью constexpr-if. Обработка переменного количества параметров в шаблонных функциях значительно упростилась благодаря новым выражениям свертки. Наконец, стало гораздо удобнее определять доступные глобально статические объекты в библиотеках, указанных в заголовочных файлах, благодаря новой возможности объявлять встраиваемые переменные, что ранее было выполнимо только для функций.

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

 

Применяем структурированные привязки (декомпозицию) для распаковки набора возвращаемых значений

В C++17 появилась новая возможность, объединяющая синтаксический сахар и автоматическое определение типа, — структурированные привязки. Эта функция помогает присваивать отдельные значения пар, кортежей и структур отдельным переменным. В других языках программирования этот механизм называется распаковкой.

 

Как это делается

Применение декомпозиции для присвоения значений нескольким переменным на основе одной упакованной структуры всегда выполняется за один шаг. Сначала рассмотрим, как это делалось до появления С++17. Затем взглянем на несколько примеров, в которых показаны способы воплощения этого в С++17.

  1. Получаем доступ к отдельным значениям std:: pair. Представьте, что у нас есть математическая функция divide_remainder, которая принимает в качестве параметров делимое и делитель и возвращает частное и остаток в std::pair.
std::pair<int, int> divide_remainder(int dividend, int divisor);

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

const auto result (divide_remainder(16, 3));
std::cout << "16 / 3 is "
          << result.first << " with a remainder of "
          << result.second << '\n';

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

auto [fraction, remainder] = divide_remainder(16, 3);
std::cout << "16 / 3 is "
          << fraction << " with a remainder of "
          << remainder << '\n';
  1. Структурированные привязки работают и для std::tuple. Рассмотрим следу­ющий пример функции, которая возвращает информацию о ценах на акции:
std::tuple<std::string,
           std::chrono::system_clock::time_point, unsigned>
stock_info(const std::string &name);

Присваивание результата ее работы отдельным переменным выглядит так же, как и в предыдущем примере:

const auto [name, valid_time, price] = stock_info("INTC");
  1. Декомпозицию можно применять и для пользовательских структур. В качестве примера создадим следующую структуру.
struct employee {
   unsigned id;
   std::string name;
   std::string role;
   unsigned salary;
};

Теперь можно получить доступ к ее членам с помощью декомпозиции. Мы даже можем сделать это в цикле, если предполагается наличие целого вектора таких структур:

int main()
{
   std::vector<employee> employees {
      /* Инициализируется в другом месте */};
   for (const auto &[id, name, role, salary] : employees) {
      std::cout << "Name: " << name
                << "Role: " << role
                << "Salary: " << salary << '\n';
   }
}

 

 

Как это работает

Структурированные привязки всегда применяются по одному шаблону:

auto [varl, var2, ...] = <выражение пары, кортежа, структуры или массива>;

□ Количество переменных var1, var2... должно точно совпадать с количеством переменных в выражении, в отношении которого выполняется присваивание.

  • Элементом <выражение пары, кортежа, структуры или массива> должен быть один из следующих объектов:
    • std::pair;
    • std::tuple;
    • структура. Все члены должны быть нестатическими и определенными в одном базовом классе. Первый объявленный член присваивается первой переменной, второй член — второй переменной и т. д.;
    • массив фиксированного размера.
  • Тип может иметь модификаторы auto, constauto, constauto& и даже auto&&.

Важно!

Если в квадратных скобках вы укажете слишком мало или слишком много пере­менных, то компилятор выдаст ошибку.

std::tuple<int, float, long> tup {1, 2.0, 3};
auto [a, b] = tup; // Не работает

В этом примере мы пытаемся поместить кортеж с тремя переменными всего в две переменные. Компилятор незамедлительно сообщает нам об ошибке:

error: type 'std::tuple<int, float, long>' decomposes into 3 elements, but
only 2 names were provided
auto [a, b] = tup;

 

Дополнительная информация

С помощью структурированных привязок вы точно так же можете получить доступ к большей части основных структур данных библиотеки STL. Рассмотрим, например, цикл, который выводит все элементы контейнера std::map:

std::map<std::string, size_t> animal_population {
   {"humans", 7000000000},
   {"chickens", 17863376000},
   {"camels", 24246291},
   {"sheep", 1086881528},
   /* … */
};

for (const auto &[species, count] : animal_population) {
   std::cout << "There are " << count << " " << species
             << " on this planet.\n";
}

Пример работает потому, что в момент итерации по контейнеру std::map мы получаем узлы std::pair<const key_type, value_type> на каждом шаге этого процесса. Именно эти узлы распаковываются с помощью структурированных при­вязок (key_type представляет собой строку с именем species, а value_type — пере­менную count типа size_t), что позволяет получить к ним доступ по отдельности в теле цикла.

До появления C++17 аналогичного эффекта можно было достичь с помощью std ::tie:

int remainder;
std::tie(std::ignore, remainder) = divide_remainder(16, 5);
std::cout << "16 % 5 is " << remainder << '\n';

Здесь показано, как распаковать полученную пару в две переменные. Примене­ние контейнера std::tie не так удобно, как использование декомпозиции, ведь нам надо заранее объявить все переменные, которые мы хотим связать. С другой сто­роны, пример демонстрирует преимущество std::tie перед структурированными привязками: значение std::ignore играет роль переменной-пустышки. В данном случае частное нас не интересует и мы отбрасываем его, связав с std::ignore.

Когда мы применяем декомпозицию, у нас нет переменных-пустышек tie, поэтому нужно привязывать все значения к именованным переменным. Это может оказаться неэффективным, если позже не задействовать не­которые переменные, но тем не менее компилятор может оптимизировать неиспользованное связывание.


Раньше функцию divide_remainder можно было реализовать следующим об­разом, используя выходные параметры: 

bool divide_remainder(int dividend, int divisor,
                      int &fraction, int &remainder);

 Получить к ним доступ можно так:

int fraction, remainder;
const bool success {divide_remainder(16, 3, fraction, remainder)};
if (success) {
    std::cout << "16 / 3 is " << fraction << " with a remainder of "
              << remainder << '\n';
}

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

Помимо того что аналогичной возможности нет в языке C, возврат сложных структур в качестве выходных параметров долгое время счи­тался медленным, поскольку объект сначала нужно инициализировать в возвращающей функции, а затем скопировать в переменную, которая должна будет содержать возвращаемое значение на вызывающей сторо­не. Современные компиляторы поддерживают оптимизацию возвраща­емых значений (return value optimization, RVO), что позволяет избежать создания промежуточных копий. 

 

Ограничиваем область видимости переменных в выражениях if и switch

Максимальное ограничение области видимости переменных считается хорошим тоном. Иногда, однако, переменная должна получить какое-то значение, а потом нужно его проверить на соответствие тому или иному условию, чтобы продолжить выполнение программы. Для этих целей в С++17 была введена инициализация переменных в выражениях if и switch.

 

Как это делается

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

  • Выражение if. Допустим, нужно найти символ в таблице символов с помощью метода findконтейнера std::map:
if (auto itr (character_map.find(c)); itr != character_map.end()) {
   // *itr корректен. Сделаем с ним что-нибудь.
} else {
   // itr является конечным итератором. Не разыменовываем.
}
// здесь itr недоступен
  • Выражение switch. Так выглядит код получения символа из пользовательско­го ввода и его одновременная проверка в выражении switch для дальнейшего управления персонажем компьютерной игры:
switch (char c (getchar()); c) {
   case 'a': move_left(); break;
   case 's': move_back(); break;
   case 'w': move_fwd(); break;
   case 'd': move_right(); break;
   case 'q': quit_game(); break;

   case '0'...'9': select_tool('0' - c); break;

   default:
      std::cout << "invalid input: " << c << '\n';
}

 

 Как это работает

Выражения if и switch с инициализаторами по сути являются синтаксическим сахаром. Два следующих фрагмента кода эквивалентны:

До C++17:

{
   auto var (init_value);
   if (condition) {
      // Ветвь A. К переменной var можно получить доступ
   } else {
      // Ветвь B. К переменной var можно получить доступ
}
   // К переменной var все еще можно получить доступ
}

Начиная с C++17:

if (auto var (init_value); condition) {
   // Ветвь A. К переменной var можно получить доступ
} else {
   // Ветвь B. К переменной var можно получить доступ
}
// К переменной var больше нельзя получить доступ

То же верно и для выражений switch.

До C++17:

   auto var (init_value);
   switch (var) {
   case 1: ...
   case 2: ...
   ...
   }
   // К переменной var все еще можно получить доступ
}

Начиная с C++17:

switch (auto var (init_value); var) {
case 1: ...
case 2: ...
   ...
}
// К переменной var больше нельзя получить доступ

Благодаря описанному механизму область видимости переменной остается минимальной. До С++17 этого можно было добиться только с помощью дополни­тельных фигурных скобок, как показано в соответствующих примерах. Короткие жизненные циклы уменьшают количество переменных в области видимости, что позволяет поддерживать чистоту кода и облегчает рефакторинг.

 

Дополнительная информация

Еще один интересный вариант — ограниченная область видимости критических секций. Рассмотрим следующий пример:

if (std::lock_guard<std::mutex> lg {my_mutex}; some_condition) {
   // Делаем что-нибудь
}

Сначала создается std::lock_guard. Этот класс принимает мьютекс в каче­стве аргумента конструктора. Он запирает мьютекс в конструкторе, а затем, когда выходит из области видимости, отпирает его в деструкторе. Таким об­разом, невозможно забыть отпереть мьютекс. До появления С++17 требовалась дополнительная пара скобок, чтобы определить область, где мьютекс снова откроется.

Не менее интересный пример — это область видимости слабых указателей. Рас­смотрим следующий фрагмент кода:

if (auto shared_pointer (weak_pointer.lock()); shared_pointer != nullptr) {
   // Да, общий объект еще существует
} else {
   // К указателю shared_pointer можно получить доступ, но он является нулевым
}
// К shared_pointer больше нельзя получить доступ

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

Выражения if с инициализаторами особенно хороши при работе с устаревшими API, имеющими выходные параметры:

if (DWORD exit_code; GetExitCodeProcess(process_handle, &exit_code)) {
   std::cout << "Exit code of process was: " << exit_code << '\n';
}
// Бесполезная переменная exit_code не попадает за пределы условия if

GetExitCodeProcess — функция API ядра Windows. Она возвращает код для заданного дескриптора процесса, но только в том случае, если данный дескриптор корректен. После того как мы покинем этот условный блок, переменная станет бесполезной, поэтому она не нужна в нашей области видимости.

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

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

 

Новые правила инициализатора с фигурными скобками

В C++11 появился новый синтаксис инициализатора с фигурными скобками {}. Он предназначен как для агрегатной инициализации, так и для вызова обычного конструктора. К сожалению, когда вы объединяли данный синтаксис с типом пере­менных auto, был высок шанс выразить не то, что вам нужно. В C++17 появился улучшенный набор правил инициализатора. В следующем примере вы увидите, как грамотно инициализировать переменные в С++17 и какой синтаксис при этом использовать.

 

Как это делается

Переменные инициализируются в один прием. При использовании синтаксиса инициализатора могут возникнуть две разные ситуации.

  1. Применение синтаксиса инициализатора с фигурными скобками без выведения типа auto:
// Три идентичных способа инициализировать переменную типа int:
int x1 = 1;
int x2 {1};
int x3 (1);
std::vector<int> v1 {1, 2, 3};
// Вектор, содержащий три переменные типа int: 1, 2, 3
std::vector<int> v2 = {1, 2, 3}; // Такой же вектор
std::vector<int> v3 (10, 20);
// Вектор, содержащий десять переменных типа int,
// каждая из которых имеет значение 20
  1. Использование синтаксиса инициализатора с фигурными скобками с выведе­нием типа auto:
auto v {1};            // v имеет тип int
auto w {1, 2};         // ошибка: при автоматическом выведении типа
                       // непосредственная инициализация разрешена
                       // только одиночными элементами! (нововведение)
auto x = {1};          // x имеет тип std::initializer_list<int>
auto y = {1, 2};       // y имеет тип std::initializer_list<int>
auto z = {1, 2, 3.0};  // ошибка: нельзя вывести тип элемента

 

Как это работает

Отдельно от механизма выведения типа auto оператор {} ведет себя предсказуемо, по крайней мере при инициализации обычных типов. При инициализации кон­тейнеров наподобие std::vector, std::list и т. д. инициализатор с фигурными скобками будет соответствовать конструктору std:: initializer_list этого класса контейнера. При этом он не может соответствовать неагрегированным конструкто­рам (таковыми являются обычные конструкторы, в отличие от тех, что принимают список инициализаторов).

std::vector, например, предоставляет конкретный неагрегированный кон­структор, заносящий в некоторое количество элементов одно и то же значение: std::vector<int> v (N, value). При записи std::vector<int> v {N, value}

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

Есть интересное различие между оператором {} и вызовом конструктора с по­мощью обычных скобок (). В первом случае не выполняется неявных преобразова­ний типа: int x (1.2); и int x = 1.2; инициализируют переменную x значением 1, округлив в нижнюю сторону число с плавающей точкой и преобразовав его к типу int. А вот выражение int x {1.2}; не скомпилируется, поскольку должно точно соответствовать типу конструктора.

Кто-то может поспорить о том, какой стиль инициализации является лучшим. Любители стиля с фигурными скобками говорят, что последние делают процесс явным, переменная инициализируется при вызове конструктора и эта строка кода ничего не инициализирует повторно. Более того, при использовании фигурных скобок {} будет выбран единственный подходящий конструктор, в то время как в момент применения обычных скобок () — ближайший похожий конструктор, а также выполнится пре­образование типов.

Дополнительное правило, включенное в С++17, касается инициализации с выведением типа auto: несмотря на то что в C++11 тип переменной auto x {123}; (std::initializer_list<int> с одним элементом) будет определен кор­ректно, скорее всего, это не тот тип, который нужен. В С++17 та же переменная будет типа int.

Основные правила:

  • в конструкции autovar_name {one_element}; переменная var_nameбудет иметь тот же тип, что и one_element;
  • конструкция autovar_name {element1, element2, ...}; недействительна и не будет скомпилирована;
  • конструкция autovar_name= {element1, element2, ...}; будет иметь тип std::initializer_list<T>, где T— тип всех элементов списка.

В С++17 гораздо сложнее случайно определить список инициализаторов.

Попытка скомпилировать эти примеры в разных компиляторах в режи­ме C++11 или C++14 покажет, что одни компиляторы автоматически выводят тип auto x {123}; как int, а другие — как std::initializer_list<int>. Подобный код может вызвать проблемы с переносимостью!

 

Разрешаем конструктору автоматически выводить полученный тип класса шаблона

Многие классы C++ обычно специализируются по типам, о чем легко догадаться по типам переменных, которые пользователь задействует при вызовах конструктора. Тем не менее до С++17 эти возможности не были стандартизированы. С++17 позво­ляет компилятору автоматически вывести типы шаблонов из вызовов конструктора.

 

Как это делается

Данную особенность очень удобно проиллюстрировать на примере создания экзем­пляров типа std::pair и std::tuple. Это можно сделать за один шаг:

std::pair my_pair (123, "abc");         // std::pair<int, const char*>
std::tuple my_tuple (123, 12.3, "abc"); // std::tuple<int, double, const char*>

 

Как это работает

Определим класс-пример, где автоматическое выведение типа шаблона будет вы­полняться на основе переданных значений:

template <typename T1, typename T2, typename T3>
class my_wrapper {
   T1 t1;
   T2 t2;
   T3 t3;
public:
   explicit my_wrapper(T1 t1_, T2 t2_, T3 t3_)
      : t1{t1_}, t2{t2_}, t3{t3_}
   {}
   /* … */
};

О’кей, это всего лишь еще один класс шаблона. Вот как мы раньше создавали его объект (инстанцировали шаблон):

my_wrapper<int, double, const char *> wrapper {123, 1.23, "abc"};

Теперь же можно опустить специализацию шаблона:

my_wrapper wrapper {123, 1.23, "abc"};

До появления C++17 это было возможно только при реализации вспомогатель­ной функции:

my_wrapper<T1, T2, T3> make_wrapper(T1 t1, T2 t2, T3 t3)
{
   return {t1, t2, t3};
}

Используя подобные вспомогательные функции, можно было добиться такого же эффекта:

auto wrapper (make_wrapper(123, 1.23, "abc"));

STL предоставляет множество аналогичных инструментов: std::make_ shared, std::make_unique, std::make_tuple и т. д. В C++17 эти функции могут считаться устаревшими. Но, конечно, они все еще будут работать для обеспечения обратной совместимости.

 

Дополнительная информация

Из данного примера мы узнали о неявном выведении типа шаблона. Однако в не­которых случаях на этот способ нельзя полагаться. Рассмотрим следующий класс­пример:

template <typename T>
struct sum {
   T value;

   template <typename ... Ts>
   sum(Ts&& ... values) : value{(values + ...)} {}
};

Эта структура, sum, принимает произвольное количество параметров и сумми­рует их с помощью выражений свертки (пример, связанный с выражениями свертки, мы рассмотрим далее в этой главе). Полученная сумма сохраняется в переменную-член value. Теперь вопрос заключается в том, что за тип — T? Если мы не хотим указывать его явно, то ему следует зависеть от типов значений, переданных в кон­структор. В случае передачи объектов-строк тип должен быть std: :string. При передаче целых чисел тип должен быть int. Если мы передадим целые числа, числа с плавающей точкой и числа с удвоенной точностью, то компилятору следует опре­делить, какой тип подходит всем значениям без потери точности. Для этого мы предоставляем явные правила выведения типов:

template <typename ... Ts>
sum(Ts&& ... ts) -> sum<std::common_type_t<Ts...>>;

Согласно этим правилам компилятор может использовать типаж std::common_ type_t, который способен определить, какой тип данных подходит всем значениям. Посмотрим, как его применить:

sum s {1u, 2.0, 3, 4.0f};
sum string_sum {std::string{"abc"}, "def"};
std::cout << s.value << '\n'
          << string_sum.value << '\n';

В первой строке мы создаем объект типа sum на основе аргументов конструкто­ра, имеющих типы unsigned, double, int и float. Типаж std:: common_type_t возвра­щает тип double, поэтому мы получаем объект типа sum<double>. Во второй строке мы предоставляем экземпляр типа std::string и строку в стиле C. В соответствии с нашими правилами компилятор создает экземпляр типа sum<std::string>.

При запуске этот код выведет значение 10 как результат сложения чисел и abcdef в качестве результата объединения строк.

 

Упрощаем принятие решений во время компиляции с помощью constexpr-if

В коде, содержащем шаблоны, зачастую необходимо по-разному выполнять опре­деленные действия в зависимости от типа, для которого конкретный шаблон был специализирован. В С++17 появились выражения constexpr-if, позволяющие значительно упростить написание кода в таких ситуациях.

 

Как это делается

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

    1. Напишем обобщенную часть кода. В нашем примере рассматривается простой класс, который добавляет значение типа Uк элементу типа Tс помощью функ­ции add:
template <typename T>
class addable
{
   T val;
   public:
   addable(T v) : val{v} {}
   template <typename U>
   T add(U x) const {
      return val + x;
   }
};

};

  1. Представим, что тип T — это std::vector<что-то>, а тип U— просто int. Каков смысл выражения «добавить целое число к вектору»? Допустим, нужно добавить данное число к каждому элементу вектора. Это делается в цикле:
template <typename U>
T add(U x)
{
   auto copy (val); // Получаем копию элемента вектора
   for (auto &n : copy) {
      n += x;
   }
   return copy;
      }
  1. Следующий и последний шаг заключается в том, чтобы объединить оба вари­анта. Если T— это вектор, состоящий из элементов типа U, то выполняем цикл. В противном случае выполняем обычное сложение.
template <typename U>
T add(U x) const {
   if constexpr (std::is_same_v<T, std::vector<U>>) {
      auto copy (val);
      for (auto &n : copy) {
         n += x;
      }
      return copy;
   } else {
      return val + x;
   }
}
  1. Теперь класс можно использовать. Посмотрим, насколько хорошо он мо­жет работать с разными типами, такими как int, float, std::vector<int> и std::vector<string>:
addable<int>{1}.add(2);                // результат - 3
addable<float>{1.0}.add(2);            // результат - 3.0
addable<std::string>{"aa"}.add("bb");  // результат - "aabb"

std::vector<int> v {1, 2, 3};
addable<std::vector<int>>{v}.add(10);
   // is std::vector<int>{11, 12, 13}

std::vector<std::string> sv {"a", "b", "c"};
addable<std::vector<std::string>>{sv}.add(std::string{"z"});
   // is {"az", "bz", "cz"}

 

Как это работает

Новая конструкция constexpr-if работает точно так же, как и обычные конструк­ции if-else. Разница между ними заключается в том, что значение условного выражения определяется во время компиляции. Весь код завершения, который компилятор сгенерирует из нашей программы, не будет содержать дополнительных ветвлений, относящихся к условиям constexpr-if. Кто-то может сказать, что эти механизмы работают так же, как и макросы препроцессора #if и #else, предназначенные для подстановки текста, но в данном случае всему коду даже не нужно быть синтаксически правильным. Ветвления конструкции constexpr-if должны быть синтаксически правильными, но неиспользованные ветви не обязаны быть семантически корректными.

Чтобы определить, должен ли код добавлять значение х к вектору, задействуем типаж std::is_same. Выражение std::is_same<A, B>::value вычисляется в логи­ческое значение true, если A и B имеют один и тот же тип. В нашем примере приме­няется условие std::is_same<T, std::vector<U>>::value, которое имеет значение true, если пользователь конкретизировал шаблон для класса T = std::vector<X> и пробует вызвать функцию add с параметром типа U = X.

В одном блоке constexpr-if-else может оказаться несколько условий (обра­тите внимание, что a и b должны зависеть от параметров шаблона, а не только от констант времени компиляции):

if constexpr (a) {
   // что-нибудь делаем
} else if constexpr (b) {
   // делаем что-нибудь еще
} else {
   // делаем нечто совсем другое
}

С помощью C++17 гораздо легче как выразить, так и прочитать код, получа­ющийся при метапрограммировании.

 

Дополнительная информация

Для того чтобы убедиться, каким прекрасным новшеством являются конструкции constexpr-if для C++, взглянем, как решалась та же самая задача до С++17:

 

template <typename T>
class addable
{
   T val;

   public:
      addable(T v) : val{v} {}
      template <typename U>
      std::enable_if_t<!std::is_same<T, std::vector<U>>::value, T>
      add(U x) const { return val + x; }

      template <typename U>
      std::enable_if_t<std::is_same<T, std::vector<U>>::value,
                       std::vector<U>>

      add(U x) const {
         auto copy (val);
         for (auto &n : copy) {
            n += x;
         }
         return copy;
      }
};

 Без конструкций constexpr-if этот класс работает для всех необходимых нам типов, но кажется очень сложным. Как же он работает?

Сами реализации двух разных функций add выглядят просто. Все усложняет объявление возвращаемого типа — выражение наподобие std::enable_if_t<условие, тип> обращается в тип, если выполняется условие. В противном случае вы­ражение std::enable_if_t ни во что не обращается. Обычно такое положение дел считается ошибкой. Далее мы рассмотрим, почему в нашем случае это не так.

Для второй функции add то же условие используется противоположным обра­зом. Следовательно, условие может иметь значение true только для одной из двух реализаций в любой момент времени.

Когда компилятор видит разные шаблонные функции с одинаковым именем и должен выбрать одну из них, в ход вступает важный принцип: он обозначается аббревиатурой SFINAE, которая расшифровывается как Substitution Failure is not an ErrorСбой при подстановке — не ошибка»). В данном случае это значит, что компилятор не генерирует ошибку, если возвращаемое значение одной из функций нельзя вывести на основе неверного шаблонного выражения (то есть std::enable_if, когда условие имеет значение false). Он просто продолжит работу и попробует обработать другие реализации функции. Вот и весь секрет.

Столько возни! Радует, что после выхода C++17 делать это стало гораздо проще.

 

Подключаем библиотеки с помощью встраиваемых переменных

Несмотря на то что в C++ всегда была возможность определить отдельные функ­ции как встраиваемые, C++17 дополнительно позволяет определять встраиваемые переменные. Это значительно упрощает реализацию библиотек, размещенных в за­головочных файлах, для чего раньше приходилось искать обходные пути.

 

Как это делается

В этом примере мы создаем класс-пример, который может служить членом ти­пичной библиотеки, размещенной в заголовочном файле. Мы хотим предоставить доступ к статическому полю класса через глобально доступный элемент класса и сделать это с помощью ключевого слова inline, что до появления C++17 было невозможно.

    1. Класс process_monitorдолжен содержать статический член и быть доступным глобально сам по себе, что приведет (при включении его в несколько единиц трансляции) к появлению символов, определенных дважды:
// foo_lib.hpp
class process_monitor {
public:
   static const std::string standard_string
      {"some static globally available string"};
};
process_monitor global_process_monitor;
  1. Теперь при попытке включить данный код в несколько файлов с расширением .cpp, а затем скомпилировать и связать их произойдет сбой на этапе связывания. Чтобы это исправить, добавим ключевое слово inline:
// foo_lib.hpp
class process_monitor {
public:
   static const inline std::string standard_string
      {"some static globally available string"};
};
inline process_monitor global_process_monitor;

Вуаля! Все работает!

 

Как это работает

Программы, написанные на C++, зачастую состоят из нескольких исходных фай­лов C++ (они имеют расширения .cpp или .cc). Они отдельно компилируются в модули/объектные файлы (обычно с расширениями .o). На последнем этапе все эти модули/объектные файлы компонуются в один исполняемый файл или разделяемую/статическую библиотеку.

На этапе связывания ошибкой считается ситуация, когда компоновщик встре­чает вхождение одного конкретного символа несколько раз. Предположим, у нас есть функция с сигнатурой int foo();. Если в двух модулях определены одинако­вые функции, то какую из них считать правильной? Компоновщик не может про­сто подбросить монетку. Точнее, может, но вряд ли хоть один программист сочтет такое поведение приемлемым.

Традиционный способ создания функций, доступных глобально, состоит в объявлении их в заголовочном файле, впоследствии включенном в любой мо­дуль С++, в котором их нужно вызвать. Эти функции будут определяться в от­дельных файлах модулей. Далее они связываются с теми модулями, которые должны использовать эти функции. Данный принцип также называется правилом одного определения (one definition rule, ODR). Взгляните на рис. 1, чтобы луч­ше понять это правило.

Однако будь это единственный способ решения задачи, нельзя было бы созда­вать библиотеки, размещенные в заголовочных файлах. Такие библиотеки очень удобны, поскольку их можно включить в любой файл программы С++ с помощью директивы #include, и они мгновенно станут доступны. Для использования же библиотек, размещенных не в заголовочных файлах, программист также должен адаптировать сценарии сборки так, чтобы компоновщик связал модули библиотек и файлы своих модулей. Это неудобно, особенно для библиотек, содержащих толь­ко очень короткие функции.

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

Правило одного определения (one definition rule, ODR).

Рис. 1. Правило одного определения (one definition rule, ODR)

Что касается нашего примера, компоновщик найдет символ process_monitor::standard_string в каждом модуле, который включает файл foo_lib.hpp. Без ключевого слова inline он не будет знать, какой символ выбрать, так что прекратит работу и сообщит об ошибке. Это же верно и для символа global_process_monitor. Как же выбрать правильный символ?

При объявлении обоих символов с помощью ключевого слова inline компонов­щик просто примет первое вхождение символа и отбросит остальные.

До появления C++17 единственным явным способом сделать это было предостав­ление символа с помощью дополнительного файла модуля C++, что заставляло пользователей библиотеки включать данный файл на этапе компоновки.

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

 

Дополнительная информация

Одним из способов решения такой задачи до появления C++17 было создание функции static, которая возвращает ссылку на объект static:

class foo {
public:
   static std::string& standard_string() {
      static std::string s {"some standard string"};
      return s;
   }
};

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

Проблему можно решить еще одним способом: сделав класс foo шаблонным и воспользовавшись преимуществами шаблонов.

В C++17 оба варианта становятся неактуальны.

 

Реализуем вспомогательные функции с помощью выражений свертки

Начиная с C++11, в языке появились пакеты параметров для шаблонов с перемен­ным количеством аргументов. Такие пакеты позволяют реализовывать функции, принимающие переменное количество параметров. Иногда эти параметры объ­единяются в одно выражение, чтобы на его основе можно было получить результат работы функции. Решение этой задачи значительно упростилось с выходом C++17, где появились выражения свертки.

 

Как это делается

Реализуем функцию, которая принимает переменное количество параметров и воз­вращает их сумму.

  1. Сначала определим ее сигнатуру:
template <typename ... Ts>
auto sum(Ts ... ts);
  1. Теперь у нас есть пакет параметров ts, функция должна распаковать все параме­тры и просуммировать их с помощью выражения свертки. Допустим, мы хотим воспользоваться каким-нибудь оператором (в нашем случае +) вместе с ..., чтобы применить его ко всем значениям пакета параметров. Для этого нужно взять выражение в скобки:
template <typename ... Ts>
auto sum(Ts ... ts)
{
   return (ts + ...);
}

 

  1. Теперь можно вызвать функцию следующим образом: 
int the_sum {sum(1, 2, 3, 4, 5)}; // Значение: 15
  1. Она работает не только с целочисленными типами; можно вызвать ее для любого типа, реализующего оператор +, например std::string:

 

std::string a {"Hello "};
std::string b {"World"};
std::cout << sum(a, b) << '\n'; // Вывод: Hello World

 

Как это работает

Только что мы написали код, в котором с помощью простой рекурсии бинарный оператор (+) применяется к заданным параметрам. Как правило, это называется сверткой. В C++17 появились выражения свертки, которые помогают выразить ту же идею и при этом писать меньше кода.

Подобное выражение называется унарной сверткой. C++17 позволяет приме­нять к пакетам параметров свертки следующие бинарные операторы: +, , *, /, %, ^, &, |, =, <, >, <<, >>, +=, –=, *=, /=, %=, ^=, &=, |=, <<=, >>=, ==, !=, <=, >=, &&, ||, ,,.*, –>*.

Кстати, в нашем примере кода неважно, какую использовать конструкцию, (ts + ...) или (... + ts);. Они обе работают так, как нужно. Однако между ними есть разница, которая может иметь значение в других случаях: если многоточие ... нахо­дится с правой стороны оператора, то такое выражение называется правой сверткой. Если же оно находится с левой стороны, то это левая свертка.

В нашем примере с суммой левая унарная свертка разворачивается в конструк­цию 1 + (2 + (3 + (4 + 5))), а правая унарная свертка развернется в (((1 + 2) + + 3) + 4) + 5. В зависимости от того, какой оператор используется, могут про­явиться нюансы. При добавлении новых чисел ничего не меняется.

 

Дополнительная информация

Если кто-то вызовет функцию sum() и не передаст в нее аргументы, то пакет па­раметров произвольной длины не будет содержать значений, которые могут быть свернуты. Для большинства операторов такая ситуация считается ошибкой (но для некоторых — нет, вы увидите это чуть позже). Далее нужно решить, генерировать ошибку или же вернуть конкретное значение. Очевидным решением будет вернуть значение 0.

Это делается так:

template <typename ... Ts>
auto sum(Ts ... ts)
{
   return (ts + ... + 0);
}

 

Таким образом, вызов sum() возвращает значение 0, а вызов sum(1, 2, 3) — зна­чение (1 + (2 + (3 + 0))). Подобные свертки с начальным значением называются бинарными.

Кроме того, обе конструкции, (ts + ... + 0) и (0 + ... + ts), работают как полагается, но такая бинарная свертка становится правой или левой соответственно. Взгляните на рис. 2.

Бинарные свертки

Рис. 2. Бинарные свертки

При использовании бинарных сверток для решения такой задачи, когда ар­гументы отсутствуют, очень важны нейтральные элементы — в нашем случае сложение любого числа с нулем ничего не меняет, что делает 0 нейтральным элементом. Поэтому можно добавить 0 к любому выражению свертки с помощью
операторов + или -. Если пакет параметров пуст, это приведет к возврату функ­цией значения 0. С математической точки зрения это правильно. С точки зрения реализации нужно определить, что именно является правильным в зависимости от наших требований.

Тот же принцип применяется и к умножению. Здесь нейтральным элементом станет 1:

template <typename ... Ts>
auto product(Ts ... ts)
{
   return (ts * ... * 1);
}

 Результат вызова product(2, 3) равен 6, а результат вызова product() без па­раметров равен 1.

В логических операторах И (&&) и ИЛИ (||) появились встроенные нейтраль­ные элементы. Свертка пустого пакета параметров с оператором && заменяется на true, а свертка пустого пакета с оператором || — на false.

Еще один оператор, для которого определено значение по умолчанию, когда он используется для пустых пакетов параметров, — это оператор «запятая» (,), заменяемый на void() .

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

 

Соотнесение диапазонов и отдельных элементов

Как насчет функции, которая определяет, содержит ли диапазон хотя бы одно из значений, передаваемых в пакете параметров с переменной длиной:

template <typename R, typename ... Ts>
auto matches(const R& range, Ts ... ts)
{
   return (std::count(std::begin(range), std::end(range), ts) + ...);
}

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

В нашем выражении свертки мы всегда передаем в функцию std::count начальный и конечный итераторы одного диапазона параметров. Однако в ка­честве третьего параметра мы всякий раз отправляем один параметр из пакета.

В конечном счете функция складывает все результаты и возвращает их вызыва­ющей стороне.

Ее можно использовать следующим образом:

std::vector<int> v {1, 2, 3, 4, 5};
matches(v, 2, 5);                  // возвращает 2
matches(v, 100, 200);              // возвращает 0
matches("abcdefg", 'x', 'y', 'z'); // возвращает 0
matches("abcdefg", 'a', 'd', 'f'); // возвращает 3

Как видите, вспомогательная функция matches довольно гибкая — ее можно вызвать для векторов или даже строк. Она также будет работать для списка инициализаторов, контейнеров std::list, std::array, std::set и прочих!

 

Проверка успешности вставки нескольких элементов в множество

Напишем вспомогательную функцию, которая добавляет произвольное количество параметров в контейнер std::set и возвращает булево значение, показывающее, успешно ли прошла операция:

template <typename T, typename ... Ts>
bool insert_all(T &set, Ts ... ts)
{
   return (set.insert(ts).second && ...);
}

Как же это работает? Функция insert контейнера std::set имеет следующую сигнатуру:

std::pair<iterator, bool> insert(const value_type& value);

Документация гласит, что при попытке вставить элемент функция insert вернет пару из iterator и переменной bool. Если вставка пройдет успешно, зна­чение переменной будет равно true. Итератор же в этом случае укажет на новый элемент множества, а в противном случае — на существующий элемент, который помешал вставке.

Наша вспомогательная функция после вставки обращается к полю .second. Оно содержит переменную bool, которая показывает, была ли вставка успешной. Если все полученные пары имеют значение true, то все вставки прошли успешно. Свертка объединяет все результаты вставки с помощью оператора && и возвращает результат.

Контейнер можно использовать следующим образом:

std::set<int> my_set {1, 2, 3};
insert_all(my_set, 4, 5, 6);    // Возвращает true
insert_all(my_set, 7, 8, 2);    // Возвращает false, поскольку 2 уже присутствует

Обратите внимание: если мы попробуем вставить, например, три элемента, но в процессе окажется, что второй элемент вставить нельзя, свертка && ... досрочно прекратит работать и оставшиеся элементы не будут добавлены:

std::set<int> my_set {1, 2, 3};
insert_all(my_set, 4, 2, 5); // Возвращает false
// теперь множество содержит значения {1, 2, 3, 4}, без 5!

 

Проверка попадания всех параметров в заданный диапазон

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

template <typename T, typename ... Ts>
bool within(T min, T max, Ts ...ts)
{
   return ((min <= ts && ts <= max) && ...);
}

Выражение (min <= ts && ts <= max) определяет, находится ли каждый элемент пакета параметров в диапазоне между min и max (включая min и max). Мы выбрали оператор &&, чтобы свести все результаты булева типа к одному, который имеет значение true только в том случае, если все отдельные результаты имеют такое же значение.

Это работает следующим образом:

within( 10, 20, 1, 15, 30);     // --> false
within( 10, 20, 11, 12, 13);    // --> true
within(5.0, 5.5, 5.1, 5.2, 5.3) // --> true

Что интересно: эта функция очень гибкая, поскольку единственным требова­нием, которое она предъявляет к типам, служит возможность сравнения экзем­пляров с помощью оператора <=. Это требование выполняется, например, типом std::string:

std::string:

std::string aaa {"aaa"};
std::string bcd {"bcd"};
std::string def {"def"};
std::string zzz {"zzz"};

within(aaa, zzz, bcd, def); // --> true
within(aaa, def, bcd, zzz); // --> false

 

Отправка нескольких элементов в вектор

Кроме того, вы можете написать вспомогательную функцию, которая не обобщает никаких результатов, но обрабатывает несколько действий одного вида. Такими действиями могут быть вставки элементов в контейнер std: :vector, поскольку они не возвращают никаких результатов (функция std: :vector: :insert() сообщает об ошибке, генерируя исключения):

template <typename T, typename ... Ts>
void insert_all(std::vector<T> &vec, Ts ... ts)
{
   (vec.push_back(ts), ...);
}

int main()
{
   std::vector<int> v {1, 2, 3};
   insert_all(v, 4, 5, 6);
}

Обратите внимание: мы используем оператор «запятая» (,), чтобы распаковать пакет параметров в отдельные вызовы vec.push_back(...), не выполняя свертку для самого результата. Эта функция также хорошо работает в отношении пустого пакета параметров, поскольку оператор «запятая» имеет неявный нейтральный элемент, void(), который означает «ничего не делать».

 

Вас заинтересует / Intresting for you:

Компиляция и запуск кода на C+...
Компиляция и запуск кода на C+... 4337 просмотров gabrielantonio Sun, 07 Oct 2018, 09:27:57
Преобразование проекта Eclipse...
Преобразование проекта Eclipse... 2132 просмотров Андрей Волков Wed, 07 Nov 2018, 10:53:54
Как грамотно создавать дизайн ...
Как грамотно создавать дизайн ... 1339 просмотров Александров Попков Sun, 17 Mar 2019, 14:57:23
Проектирование программного об...
Проектирование программного об... 2597 просмотров Александров Попков Tue, 22 Jan 2019, 11:32:56
Войдите чтобы комментировать

ildergun аватар
ildergun ответил в теме #9933 3 года 1 мес. назад
Apv, пожалуйста!)
apv аватар
apv ответил в теме #9326 5 года 3 мес. назад
Подробно описали, что нового появилось в С++17 и библиотеки STL. Спасибо за полезную статью!