21 фича современного C++, которые вам обязательно пригодятся
- понедельник, 19 июня 2023 г. в 00:00:14
Итак, судьба снова свела вас с C++, и вы поражены его возможностями с точки зрения производительности, удобства и выразительности кода. Но вот незадача: вы теряетесь в этом многообразии замечательных новых фич и, как следствие, затрудняетесь сходу определить, что из этого всего вам действительно стоило бы взять на вооружение в своей повседневной работе по написанию кода. Не стоит расстраиваться, в этой статье вашему вниманию будут представлены 21 новая фича современного C++, которые помогут сделать ваш проект лучше, а работу над ним легче.
Сообщество C++ дополняет стандарт чаще, чем Apple выпускает новые iPhone. Благодаря этому C++ теперь больше похож на большого слона, а съесть целого слона за один присест невозможно. Вот почему я решил написать эту статью, чтобы дать вашему путешествию по современному C++ своего рода отправную точку. Моя целевая аудитория здесь — люди, которые переходят со старого (т.е. 98/03) С++ на современный (т.е. 2011 и далее) С++.
Я отобрал ряд фич современного C++ и постарался объяснить их на лаконичных примерах, чтобы вы научились определять места, где их можно использовать.
int no = 1'000'000; // визуальное разделение единиц, тысяч, миллионов и т.д.
long addr = 0xA000'EFFF; // визуальное разделение 32-битного адреса на
uint32_t binary = 0b0001'0010'0111'1111; // удобочитаемые сегменты
Раньше вам нужно было считать цифры или нули, но, начиная с C++14, вы можете сделать большие числа намного нагляднее.
Эта фича помогает облегчить навигацию по словам и цифрам. Или, допустим, вы можете повысить читаемость номера кредитной карты или социального страхования.
Благодаря сгруппированным разрядам, ваш код станет немного выразительнее.
template <typename T>
using dyn_arr = std::vector<T>;
dyn_arr<int> nums; // эквивалентно std::vector<int>
using func_ptr = int (*)(int);
Семантически похоже на использование typedef
, однако псевдонимы типов легче читаются и совместимы с шаблонами С++. Поблагодарите С++11.
using ull = unsigned long long;
constexpr ull operator"" _KB(ull no)
{
return no * 1024;
}
constexpr ull operator"" _MB(ull no)
{
return no * (1024_KB);
}
cout<<1_KB<<endl;
cout<<5_MB<<endl;
По большей части это будут какие-нибудь реальные единицы, такие как kb, mb, км, см, рубли, доллары, евро и т.д. Пользовательские литералы позволяют вам не определять функции, для выполнения преобразования единиц измерения во время выполнения, а работать с ним как с другими примитивными типами.
Очень удобно для единиц и измерения.
Благодаря добавлению constexpr вы можете добиться нулевого влияния на производительность во время выполнения, что мы увидим позже в этой статье, и более подробно вы можете почитать об этом в другой статье, которую я написал, — “Использование const и constexpr в С++”.
Раньше вам нужно было инициализировать поля их значениями по умолчанию в конструкторе или в списке инициализации. Но начиная с C++11 можно задавать обычным переменным-членам класса (тем, которые не объявлены с ключевым словом static
) инициализирующее значение по умолчанию, как показано ниже:
class demo
{
private:
uint32_t m_var_1 = 0;
bool m_var_2 = false;
string m_var_3 = "";
float m_var_4 = 0.0;
public:
demo(uint32_t var_1, bool var_2, string var_3, float var_4)
: m_var_1(var_1),
m_var_2(var_2),
m_var_3(var_3),
m_var_4(var_4) {}
};
demo obj{123, true, "lol", 1.1};
Это особенно полезно, когда в качестве полей выступают сразу несколько вложенных объектов, определенных, как показано ниже:
class computer
{
private:
cpu_t m_cpu{2, 3.2_GHz};
ram_t m_ram{4_GB, RAM::TYPE::DDR4};
hard_disk_t m_ssd{1_TB, HDD::TYPE::SSD};
public:
// ...
};
В этом случае вам не нужно инициализировать их в списке инициализации. Вместо этого вы можете напрямую указать значение по умолчанию во время объявления.
class X
{
const static int m_var = 0;
};
// int X::m_var = 0; // не требуется для статических константных полей
Вы также можете инициализировать во время объявления const static члены класса, как показано выше.
std::pair<int, int> p = {1, 2};
std::tuple<int, int> t = {1, 2};
std::vector<int> v = {1, 2, 3, 4, 5};
std::set<int> s = {1, 2, 3, 4, 5};
std::list<int> l = {1, 2, 3, 4, 5};
std::deque<int> d = {1, 2, 3, 4, 5};
std::array<int, 5> a = {1, 2, 3, 4, 5};
// Не работает для адаптеров
// std::stack<int> s = {1, 2, 3, 4, 5};
// std::queue<int> q = {1, 2, 3, 4, 5};
// std::priority_queue<int> pq = {1, 2, 3, 4, 5};
Присваивайте значения контейнерам непосредственно с помощью списка инициализаторов, как это можно делать с C-массивами.
Это справедливо и для вложенных контейнеров. Скажите спасибо С++11.
auto a = 3.14; // double
auto b = 1; // int
auto& c = b; // int&
auto g = new auto(123); // int*
auto x; // error -- `x` requires initializer
auto-типизированные переменные выводятся компилятором на основе типа их инициализатора.
Чрезвычайно полезно с точки зрения удобочитаемости, особенно для сложных типов:
// std::vector<int>::const_iterator cit = v.cbegin();
auto cit = v.cbegin(); // альтернатива
// std::shared_ptr<vector<uint32_t>> demo_ptr(new vector<uint32_t>(0);
auto demo_ptr = make_shared<vector<uint32_t>>(0); // альтернатива
Функции также могут выводить тип возвращаемого значения с помощью auto
. В C++11 тип возвращаемого значения должен быть указан либо явно, либо с помощью decltype
, например:
template <typename X, typename Y>
auto add(X x, Y y) -> decltype(x + y)
{
return x + y;
}
add(1, 2); // == 3
add(1, 2.0); // == 3.0
add(1.5, 1.5); // == 3.0
Приведенная выше форма определения возвращаемого типа называется trailing return type, т.е. -> return-type
.
Синтаксический сахар для перебора элементов контейнера.
std::array<int, 5> a {1, 2, 3, 4, 5};
for (int& x : a) x *= 2;
// a == { 2, 4, 6, 8, 10 }
Обратите внимание на разницу при использовании int
в противовес int&
:
std::array<int, 5> a {1, 2, 3, 4, 5};
for (int x : a) x *= 2;
// a == { 1, 2, 3, 4, 5 }
C++11 добавляет в язык новые умные указатели: std::unique_ptr
, std::shared_ptr
, std::weak_ptr
.
А std::auto_ptr
устарел, и в конечном итоге удален в C++17.
std::unique_ptr<int> i_ptr1{new int{5}}; // Не рекомендуется
auto i_ptr2 = std::make_unique<int>(5); // Так лучше
template <typename T>
struct demo
{
T m_var;
demo(T var) : m_var(var){};
};
auto i_ptr3 = std::make_shared<demo<uint32_t>>(4);
Гайдлайны ISO CPP рекомендуют избегать явных вызовов new
и delete
, выразив это в правиле “никаких голых new”.
Я уже писал об этом в статье “Разбираемся с unique_ptr в С++ на примерах”.
C++11 добавил новый тип пустого указателя, предназначенный для замены макроса C NULL.
nullptr имеет тип std::nullptr_t
и может быть неявно преобразован в типы непустых указателей, и в отличие от NULL, не конвертируем в целочисленные типы, за исключением bool.
void foo(int);
void foo(char*);
foo(NULL); // ошибка -- неоднозначность
foo(nullptr); // вызывает foo(char*)
enum class STATUS_t : uint32_t
{
PASS = 0,
FAIL,
HUNG
};
STATUS_t STATUS = STATUS_t::PASS;
STATUS - 1; // больше не валидно, начиная с C++11
Типобезопасные перечисления, которые решают множество проблем с C-перечислениями, включая неявные преобразования, арифметические операции, невозможность указать базовый тип, загрязнение области видимости и т.д.
Приведение в стиле C изменяет только тип, не затрагивая сами данные. В то время как старый C++ имел небольшой уклон в типобезопасность, он предоставлял фичу указания оператора/функции преобразования типа. Но это было неявное преобразование типов. Начиная с C++11, функции преобразования типов теперь можно сделать явными с помощью спецификатора explicit
следующим образом:
struct demo
{
explicit operator bool() const { return true; }
};
demo d;
if (d); // OK, вызывает demo::operator bool()
bool b_d = d; // ОШИБКА: не может преобразовать 'demo' в 'bool' во время инициализации
bool b_d = static_cast<bool>(d); // OK, явное преобразование, вы знаете, что делаете
Если приведенный выше код кажется вам странным, то можете прочитать мой подробный разбор этой темы — “Приведение типов в С++”.
Когда объект будет уничтожен или не будет более использоваться после выполнения выражения, целесообразнее переместить (move) ресурс, а не копировать его.
Копирование включает в себя ненужные накладные расходы, такие как выделение памяти, высвобождение и копирование содержимого памяти и т.д.
Рассмотрим следующую функцию, меняющую местами два значения:
template <class T>
swap(T& a, T& b) {
T tmp(a); // теперь у нас есть две копии a
a = b; // теперь у нас есть две копии b (+ отброшена копия a)
b = tmp; // теперь у нас есть две копии tmp (+ отброшена копия b)
}
Использование move позволяет вам напрямую обменивать ресурсы вместо их копирования:
template <class T>
swap(T& a, T& b) {
T tmp(std::move(a));
a = std::move(b);
b = std::move(tmp);
}
А теперь представьте, что происходит, когда Т
это, скажем, vector<int>
размера n. И n достаточно велико.
В первой версии вы читаете и записываете 3*n элементов, во второй версии вы в по сути читаете и записываете только 3 указателя на буферы векторов плюс 3 размера буферов.
Конечно, класс Т
должен знать, как ему перемещаться; ваш класс должен иметь оператор присваивания перемещением и конструктор перемещения для класса Т
, чтобы это работало.
Эта фича даст вам значительный прирост в производительности — именно то, поэтому люди используют C++ (т.е., чтобы выжать последние 2-3 капли скорости).
В официальной терминологии известные как forwarding references (передаваемые ссылки). Универсальная ссылка объявляется с помощью синтаксиса Т&&
, где Т
является шаблонным параметром типа, или с помощью auto&&
. Они в свою очередь служат фундаментом для двух других крупных фич:
move-семантика
И perfect forwarding, возможность передавать аргументы, которые являются либо lvalue
, либо rvalue
.
Универсальные ссылки позволяют ссылаться на привязку либо к lvalue
, либо к rvalue
в зависимости от типа. Универсальные ссылки следуют правилам свертывания ссылок:
T& &
становится T&
T& &&
становится T&
T&& &
становится T&
T&& &&
становится T&&
Вывод шаблонного параметра типа с lvalue
и rvalue
:
// Начиная с C++14 и далее:
void f(auto&& t) {
// ...
}
// Начиная с C++11 и далее:
template <typename T>
void f(T&& t) {
// ...
}
int x = 0;
f(0); // выводится как f(int&&)
f(x); // выводится как f(int&)
int& y = x;
f(y); // выводится как f(int& &&) => f(int&)
int&& z = 0; // ПРИМЕЧАНИЕ: z — это lvalue типа int&&.
f(z); // выводится как f(int&& &) => f(int&)
f(std::move(z)); // выводится как f(int&& &&) => f(int&&)
Если вам это кажется сложным и странным, тогда для начала прочитайте это, а затем возвращайся обратно.
void print() {}
template <typename First, typename... Rest>
void print(const First &first, Rest &&... args)
{
std::cout << first << std::endl;
print(args...);
}
print(1, "lol", 1.1);
Синтаксис ... создает пакет параметров или расширяет уже существующий. Шаблонный пакет параметров — это шаблонный параметр, который принимает ноль или более аргументов-шаблонов (нетипизированных объектов, типов или шаблонов). Шаблон С++ с хотя бы одним пакетом параметров называется вариативный шаблоном с переменным количеством аргументов (variadic template).
constexpr uint32_t fibonacci(uint32_t i)
{
return (i <= 1u) ? i : (fibonacci(i - 1) + fibonacci(i - 2));
}
constexpr auto fib_5th_term = fibonacci(6); // равноценно auto fib_5th_term = 8
Константные выражения — это выражения, вычисляемые компилятором во время компиляции. В приведенном выше примере функция fibonacci
выполняется/вычисляется компилятором во время компиляции, и будет заменена на результат в вызове места.
Я написал подробную статью, раскрывающую эту тему, “Использование const и constexpr в С++”.
struct demo
{
demo() = default;
};
demo d;
У вас вполне закономерно может возникнуть вопрос, зачем вам писать 8+ букв (т.е. = default;
), когда можно просто использовать {}
, т.е. пустой конструктор? Никто вас не останавливает. Но подумай о конструкторе копирования, операторе копирования присваиванием, и т.д.
Пустой конструктор копирования, например, не то же самое, что конструктор копирования по умолчанию (который будет выполнять почленную копию всех членов).
Вы можете ограничить определенную операцию или способ инстанцирования объекта, просто удалив соответствующий метод, как показано ниже:
class demo
{
int m_x;
public:
demo(int x) : m_x(x){};
demo(const demo &) = delete;
demo &operator=(const demo &) = delete;
};
demo obj1{123};
demo obj2 = obj1; // ОШИБКА -- вызов удаленного конструктора копирования
obj2 = obj1; // ОШИБКА -- оператор = удален
В старом С++ вы должны были сделать его приватным. Но теперь в вашем распоряжении есть директива компилятора delete
.
struct demo
{
int m_var;
demo(int var) : m_var(var) {}
demo() : demo(0) {}
};
demo d;
В старом C++ вам нужно создавать функцию-член для инициализации и вызывать ее из всех конструкторов для достижения универсально инициализации.
Но начиная с C++11 конструкторы теперь могут вызывать другие конструкторы из того же класса с помощью списка инициализаторов.
auto generator = [i = 0]() mutable { return ++i; };
cout << generator() << endl; // 1
cout << generator() << endl; // 2
cout << generator() << endl; // 3
Я думаю, что эта фича не нуждается в представлении и является фаворитом среди других фич.
Теперь вы можете объявлять функции где угодно. И это не будет стоить вам никаких дополнительных накладных расходов.
Я написал отдельную статью на эту тему — “Разбираемся с лямбда-выражениями в C++ на примерах”.
В более ранних версиях C++ инициализатор либо объявлялся перед оператором и просачивался во внешнюю область видимости, либо использовалась явная область видимости.
В C++17 появилась новая форма if/switch, которую можно записать более компактно, а улучшенный контроль области видимости делает некоторые ранее подверженные ошибкам конструкции немного более надежными:
switch (auto STATUS = window.status()) // Объявляем объект прямо в операторе ветвления
{
case PASS:// делаем что-то
break;
case FAIL:// делаем что-то
break;
}
Как это работает
{
auto STATUS = window.status();
switch (STATUS)
{
case PASS: // делаем что-то
break;
case FAIL: // делаем что-то
break;
}
}
auto employee = std::make_tuple(32, " Vishal Chovatiya", "Bangalore");
cout << std::get<0>(employee) << endl; // 32
cout << std::get<1>(employee) << endl; // "Vishal Chovatiya"
cout << std::get<2>(employee) << endl; // "Bangalore"
Кортежи представляют собой набор разнородных значений фиксированного размера. Доступ к элементам std::tuple
производится с помощью std::tie
или std::get
.
Вы также можете выхватывать произвольные и разнородные возвращаемые значения следующим образом:
auto get_employee_detail()
{
// делаем что-нибудь . . .
return std::make_tuple(32, " Vishal Chovatiya", "Bangalore");
}
string name;
std::tie(std::ignore, name, std::ignore) = get_employee_detail();
Используйте std::ignore
в качестве плейсхолдера для игнорируемых значений. В С++ 17, вместо этого следует использовать структурированные привязки.
std::pair<std::string, int> user = {"M", 25}; // раньше
std::pair user = {"M", 25}; // C++17
std::tuple<std::string, std::string, int> user("M", "Chy", 25); // раньше
std::tuple user2("M", "Chy", 25); // выведение в действии!
Автоматическое выведение аргументов шаблона очень похоже на то, как это делается для функций, но теперь также включает и конструкторы классов.
Здесь мы только слегка коснулись огромного набора новых фич и возможности их применения. В современном C++ можно найти еще очень много чего, но тем не менее вы можете считать этот набор хорошей отправной точкой. Современный C++ расширяется не только с точки зрения синтаксиса, но также добавляется гораздо больше других функций, таких как неупорядоченные контейнеры, потоки, регулярное выражение, Chrono, генератор/распределитель случайных чисел, обработка исключений и множество новых алгоритмов STL (например, all_of()
, any_of()
, none_of()
, и т.д).
Да пребудет с вами C++!
Завтра вечером пройдет открытое занятие, посвященное Boost. На уроке вы узнаете, как подключать Boost в проект с помощью cmake; познакомитесь подробнее с библиотеками Boost и научитесь их использовать. Записаться на урок можно на странице курса "C++ Developer. Professional".
прк