Хабрахабр

«Скользкие» места C++17

image

Уже не за горами новый стандарт, однако внедрение свежих веяний — процесс не самый быстрый и простой, поэтому, пока есть немного времени перед C++20, предлагаю освежить в памяти или открыть для себя некоторые особо «скользкие» места актуального на данный момент стандарта языка.  В последние годы C++ шагает вперед семимильными шагами, и угнаться за всеми тонкостями и хитросплетениями языка бывает весьма и весьма непросто.

Сегодня я расскажу: почему 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()
else { some_other_os_call(); // под win будет ошибка }
}

template<class T> void foo() { // Отбрасываемая ветка не инстанцируется, поэтому при правильном T код соберется if constexpr ( os == OS::win ) { T::win_api_call(); // если T поддерживает такой вызов, то ок под win } else { T::some_other_os_call(); // если T поддерживает такой вызов, то ок под другую платформу } }

template<class T>
void foo()
{ if constexpr (condition1) { // ... } else if constexpr (condition2) { // ... } else { // static_assert(false); // так нельзя static_assert(trait<T>::value); // можно, даже при том, что trait<T>::value всегда будет false }
}

О чём нужно помнить

  1. Код во всех ветках должен быть корректным. 
  2. Внутри шаблонов содержимое отбрасываемых веток не инстанцируется.
  3. Код внутри любой ветки должен быть корректным хотя бы для одного чисто потенциального варианта инстанцирования шаблона.

В 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<int, float> t(1, 2.f);
auto& [a, b] = t; // decltype(a) — int, decltype(b) — float
++a; // изменяет, как «по ссылке», первый элемент t
std::cout << std::get<0>(t); // выведет 2

Сами же типы определяются следующим образом:

  1. Если {e} — массив (T a[N]), то тип будет один — T, cv-модификаторы будут совпадать с таковыми у массива. 
  2. Если {e} имеет тип E и поддерживает интерфейс кортежей — определены структуры:

    std::tuple_size<E>

    std::tuple_element<i, E>

    и функция:

    get<i>({e}); // или {e}.get<i>()

    то тип каждой переменной будет типом std::tuple_element_t<i, E>

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

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

  1. Вычисление типа и инициализация невидимой сущности {e} исходя из типа expr и cv-ref модификаторов.
  2. Создание псевдопеременных и привязка их к элементам {e}.

Структурное связывание своих классов/структур

Главное препятствие к связыванию своих структур — отсутствие в C++ рефлексии. Даже компилятору, который, казалось бы, должен уж точно знать о том, как устроена внутри та или иная структура, приходится несладко: модификаторы доступа (public/private/protected) и наследование сильно затрудняют дело.

Из-за подобных трудностей ограничения на использование своих классов весьма жесткие (по крайней мере пока: P1061, P1096):

  1. Все внутренние нестатические поля класса должны быть из одного базового класса, и они должны быть доступны на момент использования.
  2. Или класс должен реализовать «рефлексию» (поддержать интерфейс кортежей).

// Примеры «простых» классов
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<Foo> : std::integral_constant<std::size_t, 1> {}; template<>
struct std::tuple_element<0, Foo>
{ using type = int&;
}; class Foo
{
public: template<std::size_t i> std::tuple_element_t<i, Foo> const& get() const; template<std::size_t i> std::tuple_element_t<i, Foo> & 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<std::size_t i, class T>
struct std::tuple_element<i, const T>
{ using type = std::add_const_t<std::tuple_element_t<i, 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<T&>
— можно получить неконстантную ссылку на внутренний элемент, даже несмотря на то, что сам объект будет константным. 

О чем нужно помнить

  1. «cv-auto ref» в «cv-auto ref [a1..an] = expr» относится к невидимой переменной {e}.
  2. Если выведенный тип {e} не является ссылочным, {e} будет инициализирована копированием (осторожно с «тяжеловесными» классами).
  3. Связанные переменные — «неявные» ссылки (они ведут себя как ссылки, хотя decltype возвращает для них нессылочный тип (кроме тех случаев, когда переменная ссылается на ссылку)).
  4. Нужно быть внимательными при использовании ссылочных типов для связывания.

И действительно: C++11 принес семантику перемещения, которая сильно упростила передачу «внутренностей» объекта и создание различных фабрик, а C++17 вообще, казалось бы, дал возможность не задумываться о том, как возвращать объект из какого-нибудь фабричного метода, — теперь все должно быть без копирования и вообще, «скоро и на Марсе все зацветет»… Пожалуй, это была одна из самых бурно обсуждаемых фичей стандарта 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 менее нужным.

О чем нужно помнить

  1. RVO/NRVO сработает только в том случае, если:
    • однозначно известно, какой единственный объект должен быть создан в «слоте возвращаемого значения»;
    • типы возвращаемого объекта и функции совпадают.
  2. Если есть неоднозначность в возвращаемом значении, то:
    • если типы возвращаемого объекта и функции совпадают — move будет вызван неявно;
    • иначе — надо явно вызвать move.
  3. Осторожно с тернарным оператором: он краток, но может потребовать явный move.
  4. Лучше использовать компиляторы с полезными диагностиками (или хотя бы статические анализаторы).

И все-таки я люблю C++ 😉 

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть