В этой главе:
□ применение структурированных привязок (декомпозиции) для распаковки набора возвращаемых значений;
□ ограничение области видимости переменных в выражениях
if
и switch
;
□ новые правила инициализатора с фигурными скобками;
□ разрешение конструктору автоматически вывести полученный тип класса шаблона;
□ упрощение принятия решений во время компиляции с помощью
constexpr-if
;
□ подключение библиотек, перечисленных в заголовочных файлах, с использованием встраиваемых переменных;
□ реализация вспомогательных функций с помощью выражений свертки.
Функциональность языка 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 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';
2. Структурированные привязки работают и для
std::tuple
. Рассмотрим следующий пример функции, которая возвращает информацию о ценах на акции:
std::tuple
std::chrono::system_clock::time_point, unsigned>
stock_info(const std::string &name);
Присваивание результата ее работы отдельным переменным выглядит так же, как и в предыдущем примере:
const auto [name, valid_time, price] = stock_info("INTC");
3. Декомпозицию можно применять и для пользовательских структур. В качестве примера создадим следующую структуру.
struct employee {
unsigned id;
std::string name;
std::string role;
unsigned salary;
};
Теперь можно получить доступ к ее членам с помощью декомпозиции. Мы даже можем сделать это в цикле, если предполагается наличие целого вектора таких структур:
int main()
{
std::vector employees {
/* Инициализируется в другом месте */};
for (const auto &[id, name, role, salary] : employees) {
std::cout << "Name: " << name
<< "Role: " << role
<< "Salary: " << salary << '\n';
}
}
Как это работает
Структурированные привязки всегда применяются по одному шаблону:
auto [var1, var2, ...] = <выражение пары, кортежа, структуры или массива>;
□ Количество переменных
var1
, var2
... должно точно совпадать с количеством переменных в выражении, в отношении которого выполняется присваивание.
□ Элементом <выражение пары, кортежа, структуры или массива> должен быть один из следующих объектов:
•
std::pair;
•
std::tuple;
• структура. Все члены должны быть нестатическими и определенными в одном базовом классе. Первый объявленный член присваивается первой переменной, второй член — второй переменной и т.д.;
• массив фиксированного размера.
□ Тип может иметь модификаторы
auto
, const auto
, const auto&
и даже auto&&
.
При необходимости пользуйтесь ссылками, а не создавайте копии. Это важно не только с точки зрения производительности.
Если в квадратных скобках вы укажете слишком мало или слишком много переменных, то компилятор выдаст ошибку.
std::tuple tup {1, 2.0, 3};
auto [a, b] = tup; // Не работает
В этом примере мы пытаемся поместить кортеж с тремя переменными всего в две переменные. Компилятор незамедлительно сообщает нам об ошибке:
error: type 'std::tuple' decomposes into 3 elements,
but only 2 names were provided
auto [a, b] = tup;
Дополнительная информация
С помощью структурированных привязок вы точно так же можете получить доступ к большей части основных структур данных библиотеки STL. Рассмотрим, например, цикл, который выводит все элементы контейнера
std::map
:
std::map 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
на каждом шаге этого процесса. Именно эти узлы распаковываются с помощью структурированных привязок (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), что позволяет избежать создания промежуточных копий.
Максимальное ограничение области видимости переменных считается хорошим тоном. Иногда, однако, переменная должна получить какое-то значение, а потом нужно его проверить на соответствие тому или иному условию, чтобы продолжить выполнение программы. Для этих целей в С++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 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 v1 {1, 2, 3};
// Вектор, содержащий три переменные типа int: 1, 2, 3
std::vector v2 = {1, 2, 3};
// Такой же вектор
std::vector v3 (10, 20);
// Вектор, содержащий десять переменных типа int,
// каждая из которых имеет значение 20
2. Использование синтаксиса инициализатора с фигурными скобками с выведением типа
auto
:
auto v {1}; // v имеет тип int
auto w {1, 2}; // ошибка: при автоматическом выведении типа
// непосредственная инициализация разрешена
// только одиночными элементами! (нововведение)
auto x = {1}; // x имеет тип std::initializer_list
auto y = {1, 2}; // y имеет тип std::initializer_list
auto z = {1, 2, 3.0}; // ошибка: нельзя вывести тип элемента
Как это работает
Отдельно от механизма выведения типа
auto
оператор {}
ведет себя предсказуемо, по крайней мере при инициализации обычных типов. При инициализации контейнеров наподобие std::vector
, std::list
и т.д. инициализатор с фигурными скобками будет соответствовать конструктору std::initializer_list
этого класса-контейнера. При этом он не может соответствовать неагрегированным конструкторам (таковыми являются обычные конструкторы, в отличие от тех, что принимают список инициализаторов).
std::vector
, например, предоставляет конкретный неагрегированный конструктор, заносящий в некоторое количество элементов одно и то же значение: std::vector v (N, value)
. При записи std::vector 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
с одним элементом) будет определен корректно, скорее всего, это не тот тип, который нужен. В С++17 та же переменная будет типа int
.
Основные правила:
□ в конструкции
auto var_name {one_element};
переменная var_name
будет иметь тот же тип, что и one_element;
□ конструкция
auto var_name {element1, element2,};
недействительна и не будет скомпилирована;
□ конструкция
auto var_name = {element1, element2,};
будет иметь тип std::initializer_list
, где T
— тип всех элементов списка.
В С++17 гораздо сложнее случайно определить список инициализаторов.
Попытка скомпилировать эти примеры в разных компиляторах в режиме C++11 или C++14 покажет, что одни компиляторы автоматически выводят тип
auto x {123};
как int
, а другие — как std::initializer_list
. Подобный код может вызвать проблемы с переносимостью!
Многие классы C++ обычно специализируются по типам, о чем легко догадаться по типам переменных, которые пользователь задействует при вызовах конструктора. Тем не менее до С++17 эти возможности не были стандартизированы. С++17 позволяет компилятору автоматически вывести типы шаблонов из вызовов конструктора.
Как это делается
Данную особенность очень удобно проиллюстрировать на примере создания экземпляров типа
std::pair
и std::tuple
. Это можно сделать за один шаг:
std::pair my_pair (123, "abc"); // std::pair
std::tuple my_tuple (123, 12.3, "abc"); // std::tuple
Как это работает
Определим класс-пример, где автоматическое выведение типа шаблона будет выполняться на основе переданных значений:
template
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 wrapper {123, 1.23, "abc"};
Теперь же можно опустить специализацию шаблона:
my_wrapper wrapper {123, 1.23, "abc"};
До появления C++17 это было возможно только при реализации вспомогательной функции:
my_wrapper 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
struct sum {
T value;
template
sum(Ts&& ... values) : value{(values + ...)} {}
};
Эта структура,
sum
, принимает произвольное количество параметров и суммирует их с помощью выражений свертки (пример, связанный с выражениями свертки, мы рассмотрим далее в этой главе). Полученная сумма сохраняется в переменную-член value
. Теперь вопрос заключается в том, что за тип — T
? Если мы не хотим указывать его явно, то ему следует зависеть от типов значений, переданных в конструктор. В случае передачи объектов-строк тип должен быть std::string
. При передаче целых чисел тип должен быть int
. Если мы передадим целые числа, числа с плавающей точкой и числа с удвоенной точностью, то компилятору следует определить, какой тип подходит всем значениям без потери точности. Для этого мы предоставляем явные правила выведения типов:
template
sum(Ts&& ... ts) -> sum<std::common_type_t >;
Согласно этим правилам компилятор может использовать типаж
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
. Во второй строке мы предоставляем экземпляр типа std::string
и строку в стиле C. В соответствии с нашими правилами компилятор создает экземпляр типа sum
.
При запуске этот код выведет значение
10
как результат сложения чисел и abcdef
в качестве результата объединения строк.
В коде, содержащем шаблоны, зачастую необходимо по-разному выполнять определенные действия в зависимости от типа, для которого конкретный шаблон был специализирован. В С++17 появились выражения
constexpr-if
, позволяющие значительно упростить написание кода в таких ситуациях.
Как это делается
В этом примере мы реализуем небольшой вспомогательный шаблонный класс. Он может работать с разными типами, поскольку способен выбирать различные пути выполнения кода в зависимости от типа, для которого мы конкретизируем шаблон.
1. Напишем обобщенную часть кода. В нашем примере рассматривается простой класс, который добавляет значение типа
U
к элементу типа T
с помощью функции add
:
template
class addable
{
T val;
public:
addable(T v) : val{v} {}
template
T add(U x) const {
return val + x;
}
};
2. Представим, что тип
T
— это std::vector<что-то>
, а тип U
— просто int
. Каков смысл выражения «добавить целое число к вектору»? Допустим, нужно добавить данное число к каждому элементу вектора. Это делается в цикле:
template
T add(U x)
{
auto copy (val); // Получаем копию элемента вектора
for (auto &n : copy) {
n += x;
}
return copy;
}
3. Следующий и последний шаг заключается в том, чтобы объединить оба варианта. Если
T
— это вектор, состоящий из элементов типа U
, то выполняем цикл. В противном случае выполняем обычное сложение.
template
T add(U x) const {
if constexpr (std::is_same_v>) {
auto copy (val);
for (auto &n : copy) {
n += x;
}
return copy;
} else {
return val + x;
}
}
4. Теперь класс можно использовать. Посмотрим, насколько хорошо он может работать с разными типами, такими как
int
, float
, std::vector
и std::vector
:
addable{1}.add(2); // результат - 3
addable{1.0}.add(2); // результат - 3.0
addable{"aa"}.add("bb"); // результат - "aabb"
std::vector v {1, 2, 3};
addable>{v}.add(10);
// is std::vector{11, 12, 13}
std::vector sv {"a", "b", "c"};
addable>{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::value
вычисляется в логическое значение true, если A и B имеют один и тот же тип. В нашем примере применяется условие std::is_same>::value
, которое имеет значение true
, если пользователь конкретизировал шаблон для класса T = std::vector
и пробует вызвать функцию add
с параметром типа U = X
.
В одном блоке
constexpr-if-else
может оказаться несколько условий (обратите внимание, что a
и b
должны зависеть от параметров шаблона, а не только от констант времени компиляции):
if constexpr (a) {
// что-нибудь делаем
} else if constexpr (b) {
// делаем что-нибудь еще
} else {
// делаем нечто совсем другое
}
С помощью C++17 гораздо легче как выразить, так и прочитать код, получающийся при метапрограммировании.
Дополнительная информация
Для того чтобы убедиться, каким прекрасным новшеством являются конструкции
constexpr-if
для C++, взглянем, как решалась та же самая задача до С++17:
template
class addable
{
T val;
public:
addable(T v) : val{v} {} template
std::enable_if_t>::value, T>
add(U x) const { return val + x; }
template
std::enable_if_t>::value,
std::vector>
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;
2. Теперь при попытке включить данный код в несколько файлов с расширением
.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.1, чтобы лучше понять это правило.
Однако будь это единственный способ решения задачи, нельзя было бы создавать библиотеки, размещенные в заголовочных файлах. Такие библиотеки очень удобны, поскольку их можно включить в любой файл программы С++ с помощью директивы
#include
, и они мгновенно станут доступны. Для использования же библиотек, размещенных не в заголовочных файлах, программист также должен адаптировать сценарии сборки так, чтобы компоновщик связал модули библиотек и файлы своих модулей. Это неудобно, особенно для библиотек, содержащих только очень короткие функции.
В таких случаях можно применить ключевое слово
inline
— оно позволяет в порядке исключения разрешить повторяющиеся определения одного символа в разных модулях. Если компоновщик находит несколько символов с одинаковой сигнатурой, но они объявлены встраиваемыми, то он выберет первый и будет считать, что остальные символы имеют такое же определение. На программиста возложена ответственность за то, чтобы все одинаковые встраиваемые символы были определены абсолютно идентично.
Что касается нашего примера, компоновщик найдет символ
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
auto sum(Ts ts);
2. Теперь у нас есть пакет параметров
ts
, функция должна распаковать все параметры и просуммировать их с помощью выражения свертки. Допустим, мы хотим воспользоваться каким-нибудь оператором (в нашем случае +
) вместе с ..., чтобы применить его ко всем значениям пакета параметров. Для этого нужно взять выражение в скобки:
template
auto sum(Ts ts)
{
return (ts + ...);
}
3. Теперь можно вызвать функцию следующим образом:
int the_sum {sum(1, 2, 3, 4, 5)}; // Значение: 15
4. Она работает не только с целочисленными типами; можно вызвать ее для любого типа, реализующего оператор
+
, например 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
auto sum(Ts ... ts)
{
return (ts + ... + 0);
}
Таким образом, вызов
sum()
возвращает значение 0
, а вызов sum(1, 2, 3)
— значение (1+(2+(3+0)))
. Подобные свертки с начальным значением называются бинарными.
Кроме того, обе конструкции,
(ts + ... + 0)
и (0 + ... + ts)
, работают как полагается, но такая бинарная свертка становится правой или левой соответственно. Взгляните на рис. 1.2.
При использовании бинарных сверток для решения такой задачи, когда аргументы отсутствуют, очень важны нейтральные элементы — в нашем случае сложение любого числа с нулем ничего не меняет, что делает
0
нейтральным элементом. Поэтому можно добавить 0
к любому выражению свертки с помощью операторов +
или –
. Если пакет параметров пуст, это приведет к возврату функцией значения 0
. С математической точки зрения это правильно. С точки зрения реализации нужно определить, что именно является правильным в зависимости от наших требований.
Тот же принцип применяется и к умножению. Здесь нейтральным элементом станет
1
:
template
auto product(Ts ts)
{
return (ts * ... * 1);
}
Результат вызова
product(2, 3)
равен 6
, а результат вызова product()
без параметров равен 1
.
В логических операторах
И (&&)
и ИЛИ (||)
появились встроенные нейтральные элементы. Свертка пустого пакета параметров с оператором &&
заменяется на true
, а свертка пустого пакета с оператором ||
— на false
.
Еще один оператор, для которого определено значение по умолчанию, когда он используется для пустых пакетов параметров, — это оператор «запятая» (
,
), заменяемый на void()
.
Давайте взглянем на другие вспомогательные функции, которые можно реализовать с помощью этих механизмов.
Соотнесение диапазонов и отдельных элементов
Как насчет функции, которая определяет, содержит ли диапазон хотя бы одно из значений, передаваемых в пакете параметров с переменной длиной:
template
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 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
bool insert_all(T &set, Ts ... ts)
{
return (set.insert(ts).second && ...);
}
Как же это работает? Функция
insert
контейнера std::set
имеет следующую сигнатуру:
std::pair insert(const value_type& value);
Документация гласит, что при попытке вставить элемент функция
insert
вернет пару из iterator
и переменной bool
. Если вставка пройдет успешно, значение переменной будет равно true
. Итератор же в этом случае укажет на новый элемент множества, а в противном случае — на существующий элемент, который помешал вставке.
Наша вспомогательная функция после вставки обращается к полю
.second
. Оно содержит переменную bool
, которая показывает, была ли вставка успешной. Если все полученные пары имеют значение true
, то все вставки прошли успешно. Свертка объединяет все результаты вставки с помощью оператора &&
и возвращает результат.
Контейнер можно использовать следующим образом:
std::set my_set {1, 2, 3};
insert_all(my_set, 4, 5, 6); // Возвращает true
insert_all(my_set, 7, 8, 2); // Возвращает false, поскольку 2 уже присутствует
Обратите внимание: если мы попробуем вставить, например, три элемента, но в процессе окажется, что второй элемент вставить нельзя, свертка
&& ...
досрочно прекратит работать и оставшиеся элементы не будут добавлены:
std::set my_set {1, 2, 3};
insert_all(my_set, 4, 2, 5); // Возвращает false
// теперь множество содержит значения {1, 2, 3, 4}, без 5!
Проверка попадания всех параметров в заданный диапазон
Поскольку можно убедиться, что одна из переменных находится в конкретном диапазоне, можно сделать то же самое для нескольких переменных с помощью выражений свертки:
template
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 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
void insert_all(std::vector &vec, Ts ... ts)
{
(vec.push_back(ts), ...);
}
int main()
{
std::vector v {1, 2, 3};
insert_all(v, 4, 5, 6);
}
Обратите внимание: мы используем оператор «запятая» (
,
), чтобы распаковать пакет параметров в отдельные вызовы vec.push_back(...)
, не выполняя свертку для самого результата. Эта функция также хорошо работает в отношении пустого пакета параметров, поскольку оператор «запятая» имеет неявный нейтральный элемент, void()
, который означает «ничего не делать».