- PVSM.RU - https://www.pvsm.ru -
В последние годы 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<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
}
}
В 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
Сами же типы определяются следующим образом:
T a[N]
), то тип будет один — T, cv-модификаторы будут совпадать с таковыми у массива.
std::tuple_size<E>
std::tuple_element<i, E>
и функция:
get<i>({e}); // или {e}.get<i>()
то тип каждой переменной будет типом std::tuple_element_t<i, E>
Итак, если совсем кратко, при структурном связывании выполняются следующие шаги:
expr
и cv-ref
модификаторов.
Главное препятствие к связыванию своих структур — отсутствие в C++ рефлексии. Даже компилятору, который, казалось бы, должен уж точно знать о том, как устроена внутри та или иная структура, приходится несладко: модификаторы доступа (public/private/protected) и наследование сильно затрудняют дело.
Из-за подобных трудностей ограничения на использование своих классов весьма жесткие (по крайней мере пока: P1061 [2], P1096 [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&>
— можно получить неконстантную ссылку на внутренний элемент, даже несмотря на то, что сам объект будет константным.
cv-auto ref
» в «cv-auto ref [a1..an] = expr
» относится к невидимой переменной {e}.
decltype
возвращает для них нессылочный тип (кроме тех случаев, когда переменная ссылается на ссылку)).
Пожалуй, это была одна из самых бурно обсуждаемых фичей стандарта C++17 (по крайней мере, в моем кругу общения). И действительно: C++11 принес семантику перемещения, которая сильно упростила передачу «внутренностей» объекта и создание различных фабрик, а C++17 вообще, казалось бы, дал возможность не задумываться о том, как возвращать объект из какого-нибудь фабричного метода, — теперь все должно быть без копирования и вообще, «скоро и на Марсе все зацветет»…
Но давайте будем немного реалистами: оптимизация возвращаемого значения — не самая простая для реализации штука. Очень рекомендую посмотреть вот это выступление с cppcon2018: Arthur O'Dwyer «Return Value Optimization: Harder Than It Looks [3]», в котором автор рассказывает, почему это сложно.
Краткий спойлер:
Есть такое понятие, как «слот для возвращаемого значения». Этот слот — по сути, просто место на стеке, которое выделяет тот, кто вызывает, и передает вызываемому. Если вызываемый код точно знает, какой единственный объект будет возвращен, он может просто сразу, напрямую создать его в этом слоте (при условии, что размер и тип объекта и слота совпадают).
Что из этого следует? Давайте сразу разбирать на примерах.
Здесь все будет хорошо — сработает NRVO, объект сконструируется сразу в «слоте»:
Base foo1()
{
Base a;
return a;
}
Здесь уже нельзя однозначно определить, какой объект должен быть в итоге, поэтому будет неявно вызван move-конструктор [4] (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
, но тернарный оператор — весьма своеобразная штука [4]…
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 с некоторых пор поддерживает диагностику [4] необходимости явного вызова move
, да и существует несколько предложений (P1155 [4], P0527 [4]) в новый стандарт, которые сделают явный move
менее нужным.
И все-таки я люблю C++ ;)
Автор: alex_justes
Источник [5]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/programmirovanie/328382
Ссылки в тексте:
[1] Image: https://habr.com/ru/company/playrix/blog/465181/
[2] P1061: http://www.open-std.org/JTC1/SC22/WG21/docs/papers/2018/p1061r0.html
[3] Return Value Optimization: Harder Than It Looks: https://docs.google.com/document/d/12jrwbeiJmpwCll7-mhXIeCCssgsYooR2Fjr4SkMhkfs/edit#
[4] неявно вызван move-конструктор: https://clang.llvm.org/docs/DiagnosticsReference.html#wreturn-std-move
[5] Источник: https://habr.com/ru/post/465181/?utm_campaign=465181&utm_source=habrahabr&utm_medium=rss
Нажмите здесь для печати.