Мой путь в C++ начался несколько лет назад, и довольно долгое время я пребывал в так называемом «C++-пузыре». Моё окружение состояло в основном из таких же энтузиастов, которые день за днём изучали и применяли этот язык. Однако recently я стал чаще общаться с разработчиками, работающими на других языках, и был поражён тем, каким они видят C++. Дело не просто в том, что распространённые стереотипы неверны. Всё гораздо серьёзнее: современный C++ — это совершенно другой язык, нежели тот, каким его представляют со стороны. И проблемы, с которыми на самом деле сталкиваются C++-программисты, могут сильно отличаться от ваших представлений.
История
C++ начинался как “C with classes” и давал пользователям возможность лучшей инкапсуляции и абстракции, оставаясь при этом обратно совместимым с C. Возможность писать классы и шаблоны позволила программистам мыслить на более высоком уровне абстракции и при этом знать, какой машинный код будет сгенерирован. Однако проблемы, о которых программисты часто беспокоились, в целом оставались теми же: выделить память, сделать дело, освободить память. В течение следующих 20 лет язык постепенно развивался, люди начали понимать метапрограммирование на шаблонах, и именно в это время была разработана потрясающая стандартная библиотека.
Начиная с 2011 года скорость эволюции C++ резко возросла, и то, как выглядит и ощущается программа на C++, начало меняться. С 2011 года комитет по стандартизации C++ начал регулярно обновлять язык. Это означает, что каждые 3 года пользователи получали новые полезные функции: как в самом языке, так и в его стандартной библиотеке. Спустя 14 лет и 5 (скоро будет 6) новых стандартов, накопленные изменения действительно преобразили язык. С появлением лямбда-выражений, constexpr-функций, диапазонов (ranges), умных указателей (о которых я расскажу позже) и многих других функций, вид типичной программы на C++ координально изменился. Вместо более обобщенного C с лучшей абстракцией программисты теперь имеют мощный и выразительный язык с хорошей безопасностью. Да, я говорю, что C++ действительно безопасен (в той же мере, как безопасен кухонный нож). Давайте обсудим некоторые из наиболее важных отличий современного C++.
Стандартные контейнеры и умные указатели
В любой дискуссии о C++ неизбежно услышать о проблемах безопасности памяти (memory safety) и о том, почему «вам стоит использовать Rust» (неплохой язык, кстати). Общие проблемы, которые публика (а также правительство США) имеет с C++, заключаются в том, что в нём «легко» получить такие уязвимости памяти, как утечки памяти или чтение/запись неинициализированных значений. Однако я утверждаю, что это относится к старому C++, в то время как подобные заявляения о современном C++ будут неверны. Я уверен, что вы, вероятно, уже слышали этот аргумент, но я не мог его не включить. Встречайте.
Умные указатели
Допустим, вы хотите написать factory-функцию, выделяющую переменную типа Widget на куче (например, Widget является абстрактным базовым классом и следовательно не может быть возвращён по значению). Вот как вы бы сделали это в C:
1
2
3
4
5
6
struct Widget *factory() {
struct Widget *w = (struct Widget *)malloc(sizeof(struct Widget));
assert(w);
// Здесь инициализация w
return w;
}
Здесь возврат сырого указателя имеет очевидные недостатки: для пользователя этой функции неочевидно, кому принадлежит этот объект, должен ли он в конце концов вызывать free и может ли эта функция вернуть NULL. В том случае, если он должен освободить объект, он может забыть это сделать. Проблема здесь возникает из-за того, что указатель является слишком обобщённым типом. Он может использоваться для любого типа хранения и владения данными (включая отсутствие такового). C++ начал решать эту проблему в стандарте C++11, введя умные указатели. В том маловероятном случае, если вы никогда о них не слышали, я приведу два примера. Давайте снова рассмотрим нашу factory-функцию. Если ваше API предоставляет пользователю эксклюзивное владение виджетом, то функция имела бы сигнатуру, содержащую std::unique_ptr:
1
std::unique_ptr<Widget> factory();
Смотря на эту сигнатуру пользователь чётко видит, что эта функция возвращает указатель с эксклюзивным владением объектом (его можно уничтожить, когда он не нужен), и ему не нужно сильно беспокоиться о времени его жизни, так как он будет жить до тех пор, пока существует unique_ptr. Пользователю также не нужно беспокоиться об освобождении памяти, так как умный указатель позаботится и об этом.
Теперь рассмотрим случай, когда наша фабрика является классом, который сохраняет указатели на все созданные виджеты внутри (для внутреннего доступа и управления). В C такая структура данных возвращала бы тот же указатель на виджет, делая сигнатуру неотличимой от предыдущего примера (и тем самым создавая возможные риски освобождения выделенной памяти незнакомым с документацией пользователем). Теперь посмотрим, как это можно сделать в C++:
1
2
3
4
5
class Application {
std::vector<std::shared_ptr<Widget>> Widgets;
public:
std::shared_ptr<Widget> factory();
};
Смотря на этот код пользователь сразу понимает, что его владение виджетом не является эксклюзивным и поэтому он не должен уничтожать объект, когда тот перестанет быть нужным. Пользователю по-прежнему не нужно беспокоиться о времени жизни и освобождении памяти, так как виджет будет как минимум столько же, сколько его копия shared_ptr и будет автоматически освобождён, когда и клиент, и пользователь потеряют свои копии указателя.
Контейнеры
Теперь допустим, вы хотите выделить массив в куче (потому что его размер неизвестен во время компиляции) и инициализировать его элементы единицами. Посмотрим, как это можно сделать в C:
1
2
3
4
int *arr = (int *)malloc(size * sizeof(int));
assert(arr);
for (unsigned i = 0; i < size; ++i)
arr[i] = 1;
Здесь у вас сохраняются все те же проблемы с выделением и освобождением памяти, которые мы обсуждали выше, но теперь мы также должны беспокоиться о выходе за границы массива и инициализации. Как уже было сказано ранее, проблемы с управлением памятью здесь можно решить с помощью умных указателей. Но это не всё. Я посмею заявить, что благодаря тому, насколько хороши стандартные контейнеры и тому как развиты сторонние библиотеки (возьми те же boost и llvm), использование даже умных указателей составляет лишь малую часть всего использования динамической памяти. Управление памятью обычно уже сделано за вас стандартными и сторонними библиотеками. Ситуации, когда вам приходится использовать умные указатели, помимо unique_ptr (для динамического полиморфизма или идиомы pimpl), очень редки. Я видел лишь горстку действительно хороших применений для shared_ptr. 90% реальных задач програмистов полностью покрываются стандартными контейнерами. А это значит, что как современный C++-разработчик вы беспокоитесь об управлении памятью всего около 5-10% времени (и даже в этом случае умные указатели решают большинство задач): в основном когда вы пишете код с динамическим полиморфизмом в виде наследования, разновидность идиомы pimpl или же когда вы пишете свои собственные контейнеры. Давайте посмотрим, как мы можем решить нашу задачу с динамическим массивом, используя стандартный контейнер. Для этого тривиального примера достаточно std::vector:
1
std::vector<int> arr (size, 1);
Эта строка одновременно выделит достаточно памяти и инициализирует её значением 1.
Все приведенные выше примеры были очень простой демонстрацией того, как современный C++ позволяет вам меньше думать об управлении памятью и больше о бизнес-логике, а именно этого мы и хотим.
Управление временем жизни
Время жизни объектов — это задача, которая вызывает много проблем у новичков (и не только). В C++ несложно получить неинициализировааную переменную или провисшую ссылку/указатель. Компиляторы C++ имеют хорошее, но ограниченное количество warning-ов, которые могут помочь вам найти самые очевидные ошибки. Однако этого явно недостаточно для того чтобы поймать из всех. Время жизни объектов — очень важная тема для изучения каждым C++ разработчиком. И тут я не могу сказать, что вам не нужно об этом думать, потому что вам нужно. Однако не всё время. Я бы сказал, что в 95% ситуаций объекты, с которыми вы работаете, имеют очень чёткую область видимости и время жизни, и любой человек, имеющий хотя бы некоторый опыт работы с C++, не допустит ошибок. Разумеется, 95% недостаточно. И здесь мы можем поговорить об отличном наборе инструментов, которые очень эффективно отлавливают неопределённое поведение (UB), основанное на времени жизни. Такие инструменты, как valgrind и санитайзеры, отлично справляются со своей работой и не позволят вам допустить утечки памяти или прочитать провисшую ссылку.
Давайте взглянем на пример. Если как начинающий программист на C++ вы услышите, что время жизни временного объекта можно продлить, привязав к нему константную ссылку, и воспримете эту фразу буквально, вы можете написать что-то вроде этого:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <iostream>
struct my_int {
int value;
my_int(int v) : value(v) {}
};
const my_int &create_my_int(int x) {
my_int a (x);
return a;
}
int main() {
const auto &ref = create_my_int(0);
std::cout << ref.value << '\n';
}
Здесь функция create_a возвращает ссылку на локальную переменную, которая уничтожается при возврате, поэтому эта ссылка является висячей (dangling), и поведение программы не определено, несмотря на наличие константной ссылки. В таком тривиальном случае UB каждый компилятор, который я пробовал (включая gcc, clang и MSVC), поймал эту ошибку и выдал хорошее предупреждение. Но допустим, вы странный человек, который не использует флаг -Werror для обработки предупреждений как ошибок и даже не читает свои предупреждения (допустим, перенаправляя весь вывод компилятора в /dev/null). При выполнении этой программы на экране может быть напечатан любой результат, не напечатано ничего, получен Segmentation fault или вообще что угодно другое. Используя clang на своей машине я получил 0 на экране:
1
2
❯ ./main
0
Давайте теперь используем некоторые из инструментов, доступных для C++ разработчиков, чтобы отладить и диагностировать проблему. Во-первых, мы используем санитайзер, который представляет собой библиотеку, с которой вы линкуетесь для диагностики проблем с доступом к памяти, UB или проблем с многопоточностью. Санитайзеры — это отличные инструменты, которые в идеале следует использовать в ваших регулярных сборках (например, nightly или weekly). Прежде всего они помогают обнаруживать проблемы, которые не были видны ранее, а не используются для отладки. Сначала мы перекомпилируем наш исполняемый файл с Address и Undefined санитайзерами. Затем, после запуска, мы получим отчёт о сбое:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
❯ clang++ main.cpp -o main -fsanitize=address,undefined
main.cpp:11:12: warning: reference to stack memory associated with local variable 'a' returned [-Wreturn-stack-address]
11 | return a;
❯ ./main
=================================================================
==8365==ERROR: AddressSanitizer: stack-use-after-return on address 0x7f6b91900020 at pc 0x5634c25d2229 bp 0x7ffe45a1e580 sp 0x7ffe45a1e578
READ of size 4 at 0x7f6b91900020 thread T0
#0 0x5634c25d2228 (/tmp/lifetime/main+0x16f228)
#1 0x7f6b9382a4d7 (/nix/store/q4wq65gl3r8fy746v9bbwgx4gzn0r2kl-glibc-2.40-66/lib/libc.so.6+0x2a4d7) (BuildId: 3938ea5fdb2ce18cf9de6ebbd07b2ed43407cf53)
#2 0x7f6b9382a59a (/nix/store/q4wq65gl3r8fy746v9bbwgx4gzn0r2kl-glibc-2.40-66/lib/libc.so.6+0x2a59a) (BuildId: 3938ea5fdb2ce18cf9de6ebbd07b2ed43407cf53)
#3 0x5634c248f344 (/tmp/lifetime/main+0x2c344)
Address 0x7f6b91900020 is located in stack of thread T0 at offset 32 in frame
#0 0x5634c25d1fc7 (/tmp/lifetime/main+0x16efc7)
This frame has 1 object(s):
[32, 36) 'a' <== Memory access at offset 32 is inside this variable
HINT: this may be a false positive if your program uses some custom stack unwind mechanism, swapcontext or vfork
(longjmp and C++ exceptions *are* supported)
SUMMARY: AddressSanitizer: stack-use-after-return (/tmp/lifetime/main+0x16f228)
Shadow bytes around the buggy address:
0x7f6b918ffd80: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x7f6b918ffe00: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x7f6b918ffe80: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x7f6b918fff00: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x7f6b918fff80: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
=>0x7f6b91900000: f5 f5 f5 f5[f5]f5 f5 f5 00 00 00 00 00 00 00 00
0x7f6b91900080: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x7f6b91900100: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x7f6b91900180: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x7f6b91900200: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x7f6b91900280: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
Shadow byte legend (one shadow byte represents 8 application bytes):
Addressable: 00
Partially addressable: 01 02 03 04 05 06 07
Heap left redzone: fa
Freed heap region: fd
Stack left redzone: f1
Stack mid redzone: f2
Stack right redzone: f3
Stack after return: f5
Stack use after scope: f8
Global redzone: f9
Global init order: f6
Poisoned by user: f7
Container overflow: fc
Array cookie: ac
Intra object redzone: bb
ASan internal: fe
Left alloca redzone: ca
Right alloca redzone: cb
==8365==ABORTING
Теперь ясно, что у нас есть проблема. Диагностика указывает на использование стека после возврата из функции в функции main с переменной ‘a’, которая была создана в строке 10.
Диапазоны (Ranges)
Индексы и итераторы — это отличные способы итерирования по контейнерам. Однако им не хватает выразительности. Это одна из причин, по которой в C++ теперь есть стандартные диапазоны, которые позволяют вам безопасно итерироваться, фильтровать и изменять контейнер (вообще говоря любую последовательность), не думая о выходе за границы и косвенных обращениях (indirections). Вы можете легко комбинировать их друг с другом без потери производительности (благодаря их ленивой природе). С диапазонами современные C++-программисты получают более выразительный язык и сильно сниженную вероятность выхода за границы контейнера.
Чтобы продемонстрировать свою точку зрения, я хочу рассмотреть задачу итерации по каждым 2-м последовательным элементам std::vector, значения которых положительны. Ниже приведено лучшее решение, которое я смог написать, не использующее диапазоны.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <vector>
#include <algorithm>
#include <iostream>
#include <iterator>
void do_the_thing(int first, int second) {
std::cout << first << " " << second << '\n';
}
int main() {
std::vector<int> arr {1, 2, -3, 4, 5, 6, -7, -8, 9, -10, -11};
auto is_positive = [](int e) { return e > 0; };
auto first = std::find_if(arr.begin(), arr.end(), is_positive);
for (;first != arr.end();) {
auto second = std::find_if(std::next(first), arr.end(), is_positive);
if (second == arr.end()) break;
do_the_thing(*first, *second);
first = std::find_if(std::next(second), arr.end(), is_positive);
}
}
Этот код работает (насколько я знаю), но он ужасен. Даже в таком небольшом примере становится трудно рассуждать об итераторах. Я и сам получил выход за границы массива в процессе написания этого раздела. Мы даже не будем думать о более сложных случаях (например, итерации по каждым 5 или более элементам). Давайте теперь посмотрим, как вы можете сделать это с помощью C++23 ranges:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <iostream>
#include <iterator>
#include <vector>
#include <ranges>
void do_the_thing(int first, int second) {
std::cout << first << " " << second << '\n';
}
namespace views = std::views;
namespace ranges = std::ranges;
int main() {
std::vector<int> arr {1, 2, -3, 4, 5, 6, -7, -8, 9, -10, -11};
auto is_positive = [](int e) { return e > 0; };
for (auto &&chunk : arr | views::filter(is_positive) | views::chunk(2)) {
auto first = std::next(chunk.begin());
if (first == chunk.end()) break;
do_the_thing(*chunk.begin(), *first);
}
}
Это всё ещё работает. И при этом код стал намного более читаемым и выразительным. Здесь мы сначала фильтруем наш массив по положительным числам, а затем просматриваем каждый элемент кусками размера 2. Затем мы проверяем последний chunk, который может содержать менее 2 элементов, и просто обрабатываем все остальные случаи. Теперь рассуждать об этом коде и изменять его по нашему желанию стало легко и приятно.
Обработка ошибок
Ещё со времён создания языка исключения являются основным методом обработки ошибок в C++. Их легко бросить, и невозможно забыть их проверить. Однако я понимаю людей, которые предпочитают errors-as-values исключениям. А иногда у вас восвсе нет выбора. Вы можете работать над LLVM или любым другим проектом с отключенными исключениями. Вы можете писать C API, которое никогда не должно бросать исключений. Если вы подходите под любой из этих критериев, то не волнуйтесь, вам не нужно откатываться к целочисленным кодам возврата или выставлениям архаичного errno. В C++ для вас есть std::expected. Он прост в использовании и эффективен с точки зрения памяти.
Вернемся к примеру с factory-функцией. Мы уже установили, что она будет возвращать unique_ptr при успехе. Теперь мы обработаем возможные ошибки. Для этого мы будем использовать std::expected с ошибками, представленными в виде std::string:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include <iostream>
#include <expected>
#include <utility>
#include <memory>
#include <string>
#include <cassert>
class widget {};
void add_widget_to_app(std::unique_ptr<widget> w) {
// Тут какая-то реализация
}
std::expected<std::unique_ptr<widget>, std::string> factory(int x, int y) {
if (x < 0) return std::unexpected("x should be greater than or equal to zero");
if (y < 0) return std::unexpected("y should be greater than or equal to zero");
return std::make_unique<widget>();
}
int main() {
auto r = factory(1, 1);
assert(r.has_value());
add_widget_to_app(std::move(*r));
r = factory(-1, 1);
if (!r.has_value())
std::cerr << "error: " << r.error() << '\n';
else
ass_widget_to_app(std::move(*r));
}
Этот пример отлично работает, код выглядит чистым и может использоваться в средах без исключений и вообще везде, где вы сочтёте нужным.
Заключение
Я очень надеюсь, что с помощью всех этих параграфов мне удалось убедить вас в том, что C++ — это очень быстро меняющийся язык, и некоторые ваши предположения о нём могут быть ошибочными. И что, возможно, вы рассмотрите возможность изучить и использовать этот замечательный язык в своих будущих проектах, если он подходит вашим задачам.