«Скользкие» места C++17
В последние годы C++ шагает вперед семимильными шагами, и угнаться за всеми тонкостями и хитросплетениями языка бывает весьма и весьма непросто. Уже не за горами новый стандарт, однако внедрение свежих веяний — процесс не самый быстрый и простой, поэтому, пока есть немного времени перед C++20, предлагаю освежить в памяти или открыть для себя некоторые особо «скользкие» места актуального на данный момент стандарта языка.
Сегодня я расскажу: почему if constexpr не является заменой макросов, каковы «внутренности» работы структурного связывания (structured binding) и его «подводные» камни и правда ли, что теперь всегда работает copy elision и можно не задумываясь писать любой return.
Если не боишься немного «испачкать» руки, копаясь во «внутренностях» языка, добро пожаловать под кат.
Начнём, пожалуй, с самого простого — if constexpr
позволяет еще на этапе компиляции отбросить ветку условного выражения, для которой желаемое условие не выполняется.
Кажется, что это замена макросу #if
для выключения «лишней» логики? Нет. Совсем нет.
Во-первых, такой if
обладает свойствами, недоступными для макросов, — внутри можно посчитать любое constexpr
выражение, приводимое к bool
. Ну, а во-вторых, содержимое отбрасываемой ветки должно быть синтаксически и семантически корректным.
Из-за второго требования внутри if constexpr
нельзя использовать, например, несуществующие функции (таким способом нельзя явно разделять платформо-зависимый код) или плохие с точки зрения языка конструкции (например » void T = 0;
»).
В чем же тогда смысл использования if constexpr
? Основной смысл — в шаблонах. Для них есть специальное правило: отбрасываемая ветка не инстанцируется при инстанцировании шаблона. Это позволяет проще писать код, который каким-то образом зависит от свойств шаблонных типов.
Однако и в шаблонах нельзя забывать о том, что код внутри веток должен быть корректным хотя бы для какого-нибудь (даже чисто потенциального) варианта инстанцирования, поэтому просто написать, например, static_assert(false)
внутри одной из веток нельзя (нужно, чтобы этот static_assert
зависел от какого-либо зависимого от шаблона параметра).
Примеры:
void foo()
{
// в обеих ветках ошибки, поэтому не скомпилируется
if constexpr ( os == OS::win ) {
win_api_call(); // под другими платформами будет ошибка
}
else {
some_other_os_call(); // под win будет ошибка
}
}
template
void foo()
{
// Отбрасываемая ветка не инстанцируется, поэтому при правильном T код соберется
if constexpr ( os == OS::win ) {
T::win_api_call(); // если T поддерживает такой вызов, то ок под win
}
else {
T::some_other_os_call(); // если T поддерживает такой вызов, то ок под другую платформу
}
}
template
void foo()
{
if constexpr (condition1) {
// ...
}
else if constexpr (condition2) {
// ...
}
else {
// static_assert(false); // так нельзя
static_assert(trait::value); // можно, даже при том, что trait::value всегда будет false
}
}
О чём нужно помнить
- Код во всех ветках должен быть корректным.
- Внутри шаблонов содержимое отбрасываемых веток не инстанцируется.
- Код внутри любой ветки должен быть корректным хотя бы для одного чисто потенциального варианта инстанцирования шаблона.
В C++17 появился достаточно удобный механизм декомпозиции различных кортежеподобных объектов, позволяющий удобно и лаконично привязывать их внутренние элементы к именованным переменным:
// Самый частый пример использования — проход по ассоциативному массиву:
for (const auto& [key, value] : map) {
std::cout << key << ": " << value << std::endl;
}
Под кортежеподобным объектом я буду подразумевать такой объект, для которого известно количество доступных внутренних элементов на момент компиляции (от «кортеж» — упорядоченный список с фиксированным количеством элементов (вектор)).
Под это определение попадают такие типы, как: std::pair
, std::tuple
, std::array
, массивы вида »T a[N]
», а также различные самописные структуры и классы.
Стоп… В структурном связывании можно использовать свои собственные структуры? Спойлер: можно (правда, иногда придется поднапрячься (но об этом ниже)).
Как оно работает
Работа структурного связывания заслуживает отдельной статьи, но, раз мы говорим именно о «скользких» местах, я постараюсь кратко пояснить, как все устроено.
В стандарте дается следующий синтаксис для определения связывания:
attr(optional) cv-auto ref-operator(optional) [ identifier-list ] expression;
attr
— опциональный список атрибутов;cv-auto
— auto с возможными модификаторами const/volatile;ref-operator
— опциональный спецификатор ссылочности (& или &&);identifier-list
— список имен новых переменных;expression
— выражение, дающее в результате кортежеподобный объект, который используется для связывания (expression может быть в виде »= expr
»,»{expr}
» или »(expr)
»).
Важно отметить, что количество имен в identifier-list
должно совпадать с количеством элементов в объекте, получаемом в результате выполнения expression
.
Это все позволяет писать конструкции вида:
const volatile auto && [a,b,c] = Foo{};
И тут мы попадем на первое «скользкое» место: встречая выражение вида »auto a = expr;
», привычно подразумеваешь, что тип »a
» будет вычислен по выражению »expr
», и ожидаешь, что в выражении »const auto& [a,b,c] = expr;
» будет сделано то же самое, только типы для »a,b,c
» будут соответствующими const&
типами элементов »expr
»…
Истина же отличается: спецификатор »cv-auto ref-operator
» используется для вычисления типа невидимой переменной, в которую присваивается результат вычисления expr (то есть компилятор заменяет »const auto& [a,b,c] = expr
» на »const auto& e = expr
»).
Таким образом появляется новая невидимая сущность (здесь и далее буду называть ее {e}), впрочем, сущность весьма полезная: например, она может материализовывать временные объекты (поэтому можно спокойно их связывать »const auto& [a,b,c] = Foo {};
»).
Второе «скользкое» место вытекает сразу же из замены, которую делает компилятор: если тип, выведенный для {e}, не является ссылочным, то результат expr
будет скопирован в {e}.
Какие же типы будут у переменных в identifier-list
? Начнем с того, что это будут не совсем переменные. Да, они ведут себя как самые настоящие, обычные переменные, но только с тем отличием, что внутри они ссылаются на связанную с ними сущность, причем decltype
от такой «ссылочной» переменной будет выдавать тип именно сущности, на которую эта переменная ссылается:
std::tuple t(1, 2.f);
auto& [a, b] = t; // decltype(a) — int, decltype(b) — float
++a; // изменяет, как «по ссылке», первый элемент t
std::cout << std::get<0>(t); // выведет 2
Сами же типы определяются следующим образом:
- Если {e} — массив (
T a[N]
), то тип будет один — T, cv-модификаторы будут совпадать с таковыми у массива. - Если {e} имеет тип E и поддерживает интерфейс кортежей — определены структуры:
std::tuple_size
std::tuple_element
и функция:get({e}); // или {e}.get()
то тип каждой переменной будет типомstd::tuple_element_t
- В иных случаях тип переменной будет соответствовать типу элемента структуры, к которой выполняется привязка.
Итак, если совсем кратко, при структурном связывании выполняются следующие шаги:
- Вычисление типа и инициализация невидимой сущности {e} исходя из типа
expr
иcv-ref
модификаторов. - Создание псевдопеременных и привязка их к элементам {e}.
Структурное связывание своих классов/структур
Главное препятствие к связыванию своих структур — отсутствие в C++ рефлексии. Даже компилятору, который, казалось бы, должен уж точно знать о том, как устроена внутри та или иная структура, приходится несладко: модификаторы доступа (public/private/protected) и наследование сильно затрудняют дело.
Из-за подобных трудностей ограничения на использование своих классов весьма жесткие (по крайней мере пока: P1061, P1096):
- Все внутренние нестатические поля класса должны быть из одного базового класса, и они должны быть доступны на момент использования.
- Или класс должен реализовать «рефлексию» (поддержать интерфейс кортежей).
// Примеры «простых» классов
struct A { int a; };
struct B : A {};
struct C : A { int c; };
class D { int d; };
auto [a] = A{}; // работает (a -> A::a)
auto [a] = B{}; // работает (a -> B::A::a)
auto [a, c] = C{}; // ошибка: a и c из разных классов
auto [d] = D{}; // ошибка: d — private
void D::foo()
{
auto [d] = *this; // работает (d доступен внутри класса)
}
Реализация интерфейса кортежей позволяет использовать любые свои классы для связывания, однако выглядит чуть громоздкой и таит в себе еще один «подводный камень». Давайте сразу на примере:
// Небольшой класс, который должен возвращать ссылку на int при связывании
class Foo;
template<>
struct std::tuple_size : std::integral_constant {};
template<>
struct std::tuple_element<0, Foo>
{
using type = int&;
};
class Foo
{
public:
template
std::tuple_element_t const& get() const;
template
std::tuple_element_t & get();
private:
int _foo = 0;
int& _bar = _foo;
};
template<>
std::tuple_element_t<0, Foo> const& Foo::get<0>() const
{
return _bar;
}
template<>
std::tuple_element_t<0, Foo> & Foo::get<0>()
{
return _bar;
}
Теперь «привязываем»:
Foo foo;
const auto& [f1] = foo;
const auto [f2] = foo;
auto& [f3] = foo;
auto [f4] = foo;
И самое время подумать, какие типы у нас получились? (Кто смог сразу ответить правильно, заслуживает вкусную конфетку.)
decltype(f1);
decltype(f2);
decltype(f3);
decltype(f4);
decltype(f1); // int&
decltype(f2); // int&
decltype(f3); // int&
decltype(f4); // int&
++f1; // это сработает и поменяет foo._foo, хотя {e} должен был быть const
Почему так получилось? Ответ кроется в специализации по умолчанию для std::tuple_element
:
template
struct std::tuple_element
{
using type = std::add_const_t>;
};
std::add_const
не добавляет const
к ссылочным типам, поэтому и тип для Foo
будет всегда int&
.
Как это победить? Просто добавить специализацию для const Foo
:
template<>
struct std::tuple_element<0, const Foo>
{
using type = const int&;
};
Тогда все типы будут ожидаемыми:
decltype(f1); // const int&
decltype(f2); // const int&
decltype(f3); // int&
decltype(f4); // int&
++f1; // это уже не сработает
Кстати, это же поведение справедливо и для, например, std::tuple
— можно получить неконстантную ссылку на внутренний элемент, даже несмотря на то, что сам объект будет константным.
О чем нужно помнить
- »
cv-auto ref
» в »cv-auto ref [a1..an] = expr
» относится к невидимой переменной {e}. - Если выведенный тип {e} не является ссылочным, {e} будет инициализирована копированием (осторожно с «тяжеловесными» классами).
- Связанные переменные — «неявные» ссылки (они ведут себя как ссылки, хотя
decltype
возвращает для них нессылочный тип (кроме тех случаев, когда переменная ссылается на ссылку)). - Нужно быть внимательными при использовании ссылочных типов для связывания.
Пожалуй, это была одна из самых бурно обсуждаемых фичей стандарта C++17 (по крайней мере, в моем кругу общения). И действительно: C++11 принес семантику перемещения, которая сильно упростила передачу «внутренностей» объекта и создание различных фабрик, а C++17 вообще, казалось бы, дал возможность не задумываться о том, как возвращать объект из какого-нибудь фабричного метода, — теперь все должно быть без копирования и вообще, «скоро и на Марсе все зацветет»…
Но давайте будем немного реалистами: оптимизация возвращаемого значения — не самая простая для реализации штука. Очень рекомендую посмотреть вот это выступление с cppcon2018: Arthur O’Dwyer «Return Value Optimization: Harder Than It Looks», в котором автор рассказывает, почему это сложно.
Краткий спойлер:
Есть такое понятие, как «слот для возвращаемого значения». Этот слот — по сути, просто место на стеке, которое выделяет тот, кто вызывает, и передает вызываемому. Если вызываемый код точно знает, какой единственный объект будет возвращен, он может просто сразу, напрямую создать его в этом слоте (при условии, что размер и тип объекта и слота совпадают).
Что из этого следует? Давайте сразу разбирать на примерах.
Здесь все будет хорошо — сработает NRVO, объект сконструируется сразу в «слоте»:
Base foo1()
{
Base a;
return a;
}
Здесь уже нельзя однозначно определить, какой объект должен быть в итоге, поэтому будет неявно вызван move-конструктор (c++11):
Base foo2(bool c)
{
Base a,b;
if (c) {
return a;
}
return b;
}
Здесь чуточку сложнее… Так как тип возвращаемого значения отличается от объявленного типа, неявно move
вызвать нельзя, поэтому по умолчанию вызовется copy-конструктор. Чтобы этого не произошло, нужно явно вызвать move
:
Base foo3(bool c)
{
Derived a,b;
if (c) {
return std::move(a);
}
return std::move(b);
}
Казалось бы, это — то же самое, что и foo2
, но тернарный оператор — весьма своеобразная штука…
Base foo4(bool c)
{
Base a, b;
return std::move(c ? a : b);
}
Аналогично foo4
, но еще и тип другой, поэтому move
нужен точно:
Base foo5(bool c)
{
Derived a, b;
return std::move(c ? a : b);
}
Как видно из примеров, над тем, как возвращать значение даже в, казалось бы, тривиальных случаях, все еще приходится задумываться… Есть ли способы немного упростить себе жизнь? Есть: clang с некоторых пор поддерживает диагностику необходимости явного вызова move
, да и существует несколько предложений (P1155, P0527) в новый стандарт, которые сделают явный move
менее нужным.
О чем нужно помнить
- RVO/NRVO сработает только в том случае, если:
- Если есть неоднозначность в возвращаемом значении, то:
- Осторожно с тернарным оператором: он краток, но может потребовать явный move.
- Лучше использовать компиляторы с полезными диагностиками (или хотя бы статические анализаторы).
И все-таки я люблю C++ ;)