Posts C++ это о другом
Post
Cancel

C++ это о другом

Мой путь в 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;
}
Note Мой друг попросил меня упомянуть, что в настоящем C коде вы использовали бы typedef для `Widget`. Благодарим, Александр.

Здесь возврат сырого указателя имеет очевидные недостатки: для пользователя этой функции неочевидно, кому принадлежит этот объект, должен ли он в конце концов вызывать 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++ — это очень быстро меняющийся язык, и некоторые ваши предположения о нём могут быть ошибочными. И что, возможно, вы рассмотрите возможность изучить и использовать этот замечательный язык в своих будущих проектах, если он подходит вашим задачам.

Contents