Как стать автором
Обновить
0
Playrix
Разработчик мобильных free-to-play-игр

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

Время на прочтение 9 мин
Количество просмотров 33K
image

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

Сегодня я расскажу: почему if constexpr не является заменой макросов, каковы «внутренности» работы структурного связывания (structured binding) и его «подводные» камни и правда ли, что теперь всегда работает copy elision и можно не задумываясь писать любой return. 

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



if constexpr


Начнём, пожалуй, с самого простого — 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
    }
}

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


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

Структурное связывание (structured binding)




В 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. Нужно быть внимательными при использовании ссылочных типов для связывания.

Оптимизация возвращаемого значения (rvo, copy elision)




Пожалуй, это была одна из самых бурно обсуждаемых фичей стандарта 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 менее нужным.

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


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

Заключение


И все-таки я люблю C++ ;) 
Теги:
Хабы:
+93
Комментарии 112
Комментарии Комментарии 112

Публикации

Информация

Сайт
plrx.me
Дата регистрации
Численность
1 001–5 000 человек
Местоположение
Ирландия

Истории