Мы поговорим о восьми удобных изменениях, которые влияют на ваш повседневный код. Четыре изменения касаются самого языка, а ещё четыре — его стандартной библиотеки.
Вам также может быть интересна статья Десять возможностей C++11, которые должен использовать каждый C++ разработчик
Благодарности
Некоторые примеры я брал из докладов на конференциях Russian C++ User Group — за это огромное спасибо её организаторам и докладчикам! Я брал примеры из:
- Антон Полухин. C++17 (C++ SIBERIA 2016)
- Александр Фокин. C++17, который мы заслужили (C++ SIBERIA 2017)
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 скрыто в файле реализации класса
- для PIMPL используйте
- используйте тип 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-битный!
- на ту тему есть отличная статья 2038: остался всего 21 год
- в 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;
}
Автор: sergey_shambir