Как стать автором
Обновить

Восемь возможностей C++17, которые должен применять каждый разработчик

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

Мы поговорим о восьми удобных изменениях, которые влияют на ваш повседневный код. Четыре изменения касаются самого языка, а ещё четыре — его стандартной библиотеки.


Вам также может быть интересна статья Десять возможностей C++11, которые должен использовать каждый C++ разработчик

Благодарности


Некоторые примеры я брал из докладов на конференциях Russian C++ User Group — за это огромное спасибо её организаторам и докладчикам! Я брал примеры из:



1. Декомпозиция при объявлении (англ. structural bindings)


  • используйте декомпозицию при объявлении переменных: auto [a, b, c] = std::tuple(32, "hello"s, 13.9)
    • возвращайте из функции структуру или кортеж вместо присваивания out-параметров

Удобно декомпозировать std::pair, std::tuple и структуры с помощью нового синтаксиса:


#include <string>

struct BookInfo
{
    std::string title; // In UTF-8
    int yearPublished = 0;
};

BookInfo readBookInfo();

int main()
{
    // Раскладываем поля структуры на переменные title и year, тип которых выведен автоматически 
    auto [title, year] = readBookInfo();
}

В C++17 есть ограничения декомпозиции при объявлении:


  • нельзя явно указывать типы декомпозируемых элементов
  • нельзя использовать вложенную декомпозицию вида auto [title, [header, content]] = ...

Декомпозиция при объявлении в принципе может раскладывать любой класс — достаточно один раз написать подсказку путём специализации tuple_element, tuple_size и get. Подробнее читайте в статье Adding C++17 structured bindings support to your classes (blog.tartanllama.xyz)


Декомпозиция при объявлении хорошо работает в контейнерах std::map<> и std::unordered_map<> со старым методом .insert() и двумя новыми методами :


  • Метод try_emplace выполняет вставку тогда и только тогда, когда заданного ключа ещё нет в контейнере
    • Если заданный ключ уже есть в контейнере, ничего не происходит: в частности, rvalue-значения не перемещаются
  • Метод insert_or_assign выполняет либо вставку, либо присваивание значения существующего элемента

Пример декомпозиции с try_emplace и декомпозиции key-value при обходе map:


#include <string>
#include <map>
#include <cassert>
#include <iostream>

int main()
{
    std::map<std::string, std::string> map;
    auto [iterator1, succeed1] = map.try_emplace("key", "abc");
    auto [iterator2, succeed2] = map.try_emplace("key", "cde");
    auto [iterator3, succeed3] = map.try_emplace("another_key", "cde");

    assert(succeed1);
    assert(!succeed2);
    assert(succeed3);

    // Вы можете раскладывать key и value прямо в range-based for
    for (auto&& [key, value] : map)
    {
        std::cout << key << ": " << value << "\n";
    }
}

2. Автоматический вывод параметров шаблонов


Ключевые правила:


  • функции вида std::make_pair больше не нужны: смело пишите выражения std::pair{10, "hello"s}, компилятор сам выведет тип
  • шаблонные RAII вида std::lock_guard<std::mutex> guard(mutex); станут короче: std::lock_guard guard(mutex);
  • функции std::make_unique и std::make_shared по-прежнему нужны

Вы можете создавать свои подсказки для автоматического вывода параметров шаблона: см. Automatic_deduction_guides


Интересная особенность: конструктор из initializer_list<> пропускается для списка из одного элемента. Для некоторых JSON библиотек (таких как json_spirit) это может оказаться фатальным. Не играйтесь с рекурсивными типами и контейнерами STL!


#include <vector>
#include <type_traits>
#include <cassert>

int main()
{
    std::vector v{std::vector{1, 2}};

    // Это vector<int>, а не vector<vector<int>>
    static_assert(std::is_same_v<std::vector<int>, decltype(v)>);

    // Размер равен двум
    assert(v.size() == 2);
}

3. Объявление вложенных пространств имён


Избегайте вложенности пространств имён, а если не избежать, то объявляйте их так:


namespace product::account::details
{
    // ...ваши классы и функции...
}

4. Атрибуты nodiscard, fallthrough, maybe_unused


Ключевые правила:


  • завершайте все блоки case, кроме последнего, либо атрибутом [[fallthrough]], либо инструкцией break;
  • используйте [[nodiscard]] для функций, возвращающих код ошибки или владеющий указатель (неважно, умный или нет)
  • используйте [[maybe_unused]] для переменных, которые нужны только для проверки в assert

Более подробно об атрибутах рассказано в статье Как пользоваться атрибутами из C++17. Здесь будут краткие выдержки.

В C++ приходится добавлять break после каждого case в конструкции switch, и об этом легко забыть даже опытному разработчику. На помощь приходит атрибут fallthrough, который можно приклеить к пустой инструкции. Фактически атрибут приклеивается к case, следующему за пустой инструкцией.


enum class option { A, B, C };

void choice(option value)
{
    switch (value)
    {
    case option::A:
        // ...
    case option::B: // warning: unannotated fall-through between
                    //          switch labels
        // ...
        [[fallthrough]];
    case option::C: // no warning
        // ...
        break;
    }
}

Чтобы воспользоваться преимуществами атрибута, в GCC и Clang следует включит предупреждение -Wimplicit-fallthrough. После включения этой опции каждый case, не имеющий атрибута fallthrough, будет порождать предупреждение.


В проектах с высокими требованиями к производительности могут практиковать отказ от выброса исключений (по крайней мере в некоторых компонентах). В таких случаях об ошибке выполнения операции сообщает код возврата, возвращённый из функции. Однако, очень легко забыть проверить этот код.


[[nodiscard]] std::unique_ptr<Bitmap> LoadArrowBitmap() { /* ... */ }

void foo()
{
    // warning: ignoring return value of function declared
    //          with warn_unused_result attribute
    LoadArrowBitmap();  
}

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


class [[nodiscard]] error_code { /* ... */ };

error_code bar();

void foo()
{
    // warning: ignoring return value of function declared
    //          with warn_unused_result attribute
    bar();
}

Иногда программисты создают переменную, используемую только в отладочной версии для хранения кода ошибки вызванной функции. Возможно, это просто ошибка дизайна кода, и возвращаемое значение следовало обрабатывать всегда. Тем не менее:


// ! старый код!
auto result = DoSystemCall();
(void)result; // гасим предупреждение об unused variable
assert(result >= 0);

// современный код
[[maybe_unused]] auto result = DoSystemCall();
assert(result >= 0);

5. Класс string_view для параметров-строк


Правила:


  • в параметрах всех функций и методов вместо const string& старайтесь принимать невладеющий string_view по значению
    • возвращайте из функций и методов владеющий string, как и раньше
  • будьте осторожны с возвратом string_view из функции: это может привести к проблеме висячих ссылок (англ. dangling pointers)

Подробнее о том, почему string_view лучше всего применять только для параметров, читайте в статье std::string_view конструируется из временных экземпляров строк

Класс string_view хорош тем, что он легко конструируется и из std::string и из const char* без дополнительного выделения памяти. А ещё он имеет поддержку constexpr и повторяет интерфейс std::string. Но есть минус: для string_view не гарантируется наличие нулевого символа на конце.


6. Классы optional и variant


Применение optional<> и variant<> настолько широко, что я даже не буду пытаться полностью описать их в этой статье. Ключевые правила:


  • предпочитайте optional<T> вместо unique_ptr<T> для композиции объекта T, время жизни которого короче времени жизни владельца
    • для PIMPL используйте unique_ptr<Impl>, потому что определение Impl скрыто в файле реализации класса
  • используйте тип variant вместо enum или полиморфных классов в ситуации, когда состояния, такие как состояние лицензии, не могут быть описаны константами enum из-за наличия дополнительных данных в каждом из состояний
  • используйте тип variant вместо enum в ситуации, когда данные, такие как код ошибки в исключении, должны быть обработаны во всех вариантах, и неполная обработка вариантов должна приводить к ошибке компиляции
  • используйте тип variant вместо any везде, где это возможно
  • optional можно использовать для композиции объекта, время жизни которого короче времени жизни владельца
  • не применяйте optional для обработки ошибок: он не несёт никакой информации об ошибке
    • для возврата значения либо ошибки можно написать свой класс Expected<Value, Error>, основанный на boost::variant<...>
    • а можно не писать и взять готовый: github.com/martinmoene/expected-lite

Пример кода с optional:


// nullopt - это специальное значение типа nullopt_t, которое сбрасывает
//  значение optional (аналогично nullptr для указателей)
std::optional<int> optValue = std::nullopt;
// ... инициализируем optValue ...
// забираем либо значение, либо -1
const int valueOrFallback = optValue.value_or(-1);

  • optional имеет operator* и operator->, а также удобный метод .value_or(const T &defaultValue)
  • optional имеет метод value, который, в отличие от operator*, бросает исключение std::bad_optional_access при отсутствии значения
  • optional имеет операторы сравнения “==”, “!=”, “<”, “<=”, “>”, “>=”, при этом std::nullopt меньше любого допустимого значения
  • optional имеет оператор явного преобразования в bool

Пример кода с variant: здесь мы используем variant для хранения одного из нескольких состояний в случае, когда разные состояния могут иметь разные данные


struct AnonymousUserState
{
};

struct TrialUserState
{
    std::string userId;
    std::string username;
};

struct SubscribedUserState
{
    std::string userId;
    std::string username;
    Timestamp expirationDate;
    LicenseType licenceType;
};

using UserState = std::variant<
    AnonymousUserState,
    TrialUserState,
    SubscribedUserState
>;

Преимущество variant в его подходе к управлению памяти: данные хранятся в полях значения типа variant без дополнительных выделений памяти. Это делает размер типа variant зависимым от типов, входящих в его состав. Так может выглядеть таблица размеров на 32-битных процессорах (но это неточно):


Иллюстрация


7. Используйте функции std::size, std::data, std::begin, std::end


  • используйте std::size для измерения длины C-style массива
    • эта функция работает с массивами и с контейнерами STL, но выдаст ошибку компиляции при попытке передать ей обычный указатель
  • используйте std::data для получения изменяемого указателя начало строки, массива или std::vector<>
    • раньше для получения такого указателя использовали выражение &text[0], но оно имеет неопределённое поведение на пустых строках

Может быть, для манипуляций с байтами лучше опираться на библиотеку GSL (C++ Core Guidelines Support Library).


8. Используйте std::filesystem


Ключевые правила:


  • передавайте std::filesystem::path вместо строк во всех параметрах, в которых подразумевается путь
  • будьте осторожны с функцией canonical: возможно, вы имели ввиду метод lexically_normal
    • canonical обрабатывает символические ссылки, а lexically_normal — нет
    • canonical требует, чтобы путь существовал, а lexically_normal — нет
    • на Windows попытка склеить почти-слишком-длинный путь и "..", а затем применить canonical может закончиться фиаско: Boost кинет исключение из-за слишком длинного пути к файлу
  • будьте осторожны с функцией relative: возможно, вы имели ввиду lexically_relative
  • старайтесь использовать noexcept-версии функций (с кодом ошибки), если ошибка для вас приемлема
    • например, используйте noexcept версию функции exists, иначе вы получите исключения для некоторых сетевых путей!
  • не используйте boost::filesystem

Чем плох boost::filesystem? Оказывается, у него есть несколько проблем дизайна:


  • в Boost не решена проблема 2038 года; точнее, эту задачу переложили на time_t, но в Linux он до сих пор 32-битный!
  • в STL-версии filesystem есть все средства для работы с кодировкам

Любой опытный программист знает о разнице в обработке путей между Windows и UNIX-системами:


  • в Windows пути принимаются в виде UTF-16 строк (или даже UCS-2 строк, т.е. суррогатных пар в путях надо избегать!), часто используемый тип wchar_t представляет 2-байтный символ в кодировке UTF-16, а разделителем путей служит обратный слеш “\”
  • в UNIX пути принимаются в виде UTF-8 строк, редко используемый wchar_t представляет 4-байтный символ в кодировке UCS32, а разделителем путей служит прямой слеш “/”

Конечно же filesystem абстрагируется от подобных различий и позволяет легко работать как с платформо-зависимыми строками, так и с универсальным UTF-8:


  • для получения UTF-8 версии пути служит метод u8string
  • для конструирования пути из UTF-8 строки служит свободная функция u8path
  • не используйте конструктор std::filesystem::path из std::string — на Windows конструктор считает входной кодировкой кодировку ОС!

Бонусное правило: прекратите переизобретать clamp, int_to_string и string_to_int


Функция std::clamp дополняет функции min и max. Она обрезает значение и сверху, и снизу. Аналогичная функция boost::clamp доступна в более ранних версиях C++.


Правило "не переизобретайте clamp" можно обобщить: в любом крупном проекте избегайте дублирования маленьких функции и выражений для округлений, обрезаний значений и т.п. — просто один раз добавьте это в свою библиотеку.


Аналогичное правило работает для задач обработки строк. У вас есть своя маленькая библиотека для строк и парсинга? В ней есть парсинг или форматирование чисел? Если есть, замените свою реализацию на вызовы to_chars и from_chars


Функции to_chars и from_chars поддерживают обработку ошибок. Они возвращают по два значения:


  • первое имеет тип char* или const char* соответственно и указывает на первый code unit (т.е. char или wchar_t), который не удалось обработать
  • второе имеет тип std::error_code и сообщает подробную информацию об ошибке, пригодную для выброса исключения std::system_error

Поскольку в прикладном коде способ реакции на ошибку может различаться, следует помещать вызовы to_chars и from_chars внутрь своих библиотек и утилитных классов.


#include <utility>

// конвертирует строку в число, в случае ошибки возвращает 0
// (в отличии от atoi, у которого местами есть неопределённое поведение)
template<class T>
T atoi_17(std::string_view str)
{
    T res{};
    std::from_chars(str.data(), str.data() + str.size(), res);
    return res;
}
Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
О какой возможности C++17 вам нужна отдельная статья?
14.51% модуль filesystem 64
15.87% optional, variant и any 70
6.8% string_view 30
7.03% изменения в контейнерах map, vector и т.д. 31
7.03% изменения в алгоритмах STL (sample, search и т.д.) 31
21.77% параллельные алгоритмы STL 96
10.2% изменения в стиле шаблонного кода 45
7.94% почему устарел codecvt 35
8.84% а давайте поговорим про Go! 39
Проголосовал 441 пользователь. Воздержались 110 пользователей.
Теги:
Хабы:
+31
Комментарии 47
Комментарии Комментарии 47

Публикации

Истории

Работа

QT разработчик
13 вакансий
Программист C++
122 вакансии

Ближайшие события

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн
Геймтон «DatsEdenSpace» от DatsTeam
Дата 5 – 6 апреля
Время 17:00 – 20:00
Место
Онлайн