Основы C++: от нуля до разработчика среднего уровня

Курс последовательно проводит через базовый и продвинутый C++: от синтаксиса и ООП до шаблонов, STL, управления памятью и многопоточности. В каждой главе предусмотрены практические задачи и комплексные задания, приближённые к реальным рабочим сценариям разработки.

1. Старт: компиляция, типы, переменные, ввод-вывод, операторы

Старт: компиляция, типы, переменные, ввод-вывод, операторы

Эта статья запускает курс: вы настроите базовое понимание того, как C++-код превращается в исполняемую программу, научитесь писать минимальные программы, объявлять переменные, работать с базовыми типами, вводом-выводом и операторами. Это фундамент для всех следующих тем: функций, контейнеров, классов, памяти и многопоточности.

Как C++-код превращается в программу

C++ — компилируемый язык. Это значит, что исходный код (файл main.cpp) обычно проходит несколько этапов, прежде чем вы получите исполняемый файл.

!Схема показывает основные этапы компиляции и линковки

Этапы сборки

  • Препроцессинг
  • - Обрабатывает директивы вроде #include. - Вставляет содержимое подключаемых заголовков и выполняет макросы.

  • Компиляция
  • - Превращает C++-код в машинно-независимое представление и затем в машинный код конкретной платформы. - Результат часто попадает в объектный файл (например, main.o или main.obj).

  • Линковка
  • - Собирает объектные файлы вместе. - Подключает нужные части стандартной библиотеки и других библиотек. - Результат — исполняемый файл.

    Компиляторы и стандарт языка

    На практике вы чаще всего встретите:

  • GCC (g++) на Linux и Windows (через MinGW)
  • Clang (clang++) на macOS, Linux и Windows
  • MSVC (Visual Studio) на Windows
  • Важно: C++ имеет версии стандарта (C++17, C++20, C++23). Один и тот же код может компилироваться или не компилироваться в зависимости от выбранного стандарта.

    Полезные справочники:

  • cppreference: C++
  • GCC: Invoking G++
  • Clang documentation
  • Первая программа

    Минимальная программа на C++:

    Разберём построчно:

  • #include <iostream> подключает заголовок для ввода-вывода (потоки std::cin, std::cout).
  • int main() — точка входа программы.
  • std::cout << ... печатает в стандартный вывод.
  • return 0; сообщает операционной системе, что программа завершилась успешно.
  • Пример компиляции из командной строки

    GCC или Clang:

    Что означают ключевые флаги:

  • -std=c++20 выбирает стандарт языка.
  • -O2 включает оптимизации.
  • -Wall -Wextra включает полезные предупреждения.
  • -pedantic делает проверку стандарта строже.
  • Если вы работаете в IDE (Visual Studio, CLion, Qt Creator), она делает примерно то же самое, но скрывает команды за кнопками Build и Run.

    Типы данных: что хранится в памяти

    Тип определяет:

  • какие значения можно хранить
  • сколько памяти требуется
  • какие операции разрешены
  • Базовые типы

    | Тип | Пример | Для чего обычно используют | |---|---|---| | int | int x = 10; | целые числа общего назначения | | long long | long long big = 9000000000LL; | большие целые значения | | double | double pi = 3.14; | числа с дробной частью | | char | char c = 'A'; | символы (байт) | | bool | bool ok = true; | логика: истина или ложь |

    Важная деталь: точный размер int и других целочисленных типов зависит от платформы и компилятора. Для задач, где важна точная разрядность, в C++ есть фиксированные типы.

    Фиксированная разрядность: когда важно точно

    Эти типы удобны в сетевых протоколах, бинарных форматах, криптографии, при работе с файлами и железом.

    Строки

    В C++ строки чаще всего представлены типом std::string:

    std::string — это не массив char, а полноценный класс стандартной библиотеки.

    Переменные: объявление и инициализация

    Переменная — это имя, связанное с областью памяти.

    Объявление

    Важно: локальные переменные без инициализации могут содержать неопределённое значение. Это частый источник ошибок.

    Инициализация

    В C++ несколько стилей инициализации:

    Практическое правило для начала:

  • Используйте {} там, где это удобно.
  • Инициализируйте переменные сразу.
  • const: запрет изменения

    const помогает компилятору ловить ошибки и делает код понятнее.

    auto: вывод типа

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

    Ввод и вывод: std::cout и std::cin

    Вывод

    << — оператор вставки в поток.

    "\n" — символ перевода строки. Часто лучше использовать "\n", чем std::endl, потому что std::endl дополнительно принудительно сбрасывает буфер, что может замедлять вывод.

    Ввод

    >> — оператор извлечения из потока.

    Важная особенность: std::cin >> x пропускает пробелы и читает до следующего пробела. Это особенно заметно со строками.

    Чтение строки целиком

    Чтобы читать строку с пробелами, используйте std::getline:

    Если перед getline у вас был ввод через >>, может остаться перевод строки в буфере. Позже вы научитесь корректно управлять этим, но базовое правило такое: старайтесь не смешивать >> и getline без явного понимания, что остаётся во входном потоке.

    Операторы: как строятся выражения

    Оператор — это символ или комбинация символов, которые выполняют действие над операндами (значениями).

    Арифметика

    | Оператор | Пример | Смысл | |---|---|---| | + | a + b | сложение | | - | a - b | вычитание | | | a b | умножение | | / | a / b | деление | | % | a % b | остаток от деления (только для целых типов) |

    Ключевая ловушка новичков — целочисленное деление:

    Если нужен результат с дробной частью, хотя бы один операнд должен быть вещественным:

    Присваивание и составные присваивания

  • = присваивает значение.
  • +=, -=, *=, /=, %= сокращают запись.
  • Сравнение

    Результат сравнения — bool.

    | Оператор | Пример | Смысл | |---|---|---| | == | a == b | равно | | != | a != b | не равно | | < | a < b | меньше | | <= | a <= b | меньше или равно | | > | a > b | больше | | >= | a >= b | больше или равно |

    Частая ошибка — перепутать = и ==. = меняет переменную, а == сравнивает.

    Логические операторы

    | Оператор | Пример | Смысл | |---|---|---| | && | a && b | логическое И | | || | a || b | логическое ИЛИ | | ! | !a | логическое НЕ |

    && и || используют короткое замыкание: если результат уже ясен, правая часть может не вычисляться.

    Инкремент и декремент

    Разница между ++x и x++ важна в выражениях, но на старте лучше избегать сложных выражений и писать изменения отдельной строкой.

    Приоритет операторов

    Операторы имеют приоритет, но на практике простой совет сильнее таблиц:

  • Если в выражении есть сомнения — добавьте скобки.
  • Делайте выражения короткими и читаемыми.
  • Мини-программа, объединяющая всё

    Что вы должны уметь после этой статьи:

  • Понимать разницу между компиляцией и линковкой.
  • Писать и собирать простую программу с main.
  • Объявлять и инициализировать переменные базовых типов.
  • Делать ввод-вывод через std::cin и std::cout.
  • Использовать основные операторы и понимать базовые ловушки (целочисленное деление, = против ==).
  • 2. Управление потоком: условия, циклы, функции, области видимости

    Управление потоком: условия, циклы, функции, области видимости

    В прошлой статье вы научились собирать программу, объявлять переменные базовых типов, делать ввод-вывод и составлять выражения с операторами. Следующий шаг превращает набор выражений в программу, которая принимает решения, повторяет действия и делит логику на переиспользуемые части. Это и есть управление потоком выполнения.

    Что такое управление потоком выполнения

    По умолчанию C++ выполняет инструкции сверху вниз. Управляющие конструкции позволяют:

  • выполнить блок кода только при условии
  • повторить блок кода несколько раз или пока условие истинно
  • вынести логику в функции и вызывать её из разных мест
  • !Базовая схема ветвления и цикла, чтобы видеть, как программа выбирает путь выполнения

    Полезные ссылки (справочник):

  • cppreference: Statements
  • cppreference: if statement
  • cppreference: switch statement
  • cppreference: while statement
  • cppreference: for statement
  • cppreference: Functions
  • cppreference: Scope
  • cppreference: Lifetime
  • Условные конструкции

    if, else if, else

    if выбирает, выполнять ли блок кода. Условие должно иметь тип bool или приводиться к bool.

    Практические замечания:

  • Пишите условие так, чтобы оно читалось как утверждение: if (age >= 18).
  • Не путайте = и ==.
  • Пример ошибки, которую компилятор не всегда сможет спасти:

    Инициализация прямо в if

    Иногда удобно создать переменную и сразу проверить её.

    x существует только внутри if и else.

    switch: выбор по значению

    switch удобно использовать, когда нужно выбрать вариант по целому значению (или enum).

    Ключевые детали switch:

  • case сравнивает значение со константами.
  • break почти всегда нужен, иначе произойдёт проваливание в следующий case.
  • default срабатывает, если не подошёл ни один case.
  • Иногда проваливание делают специально, но тогда стоит явно обозначать намерение.

    Тернарный оператор ?:

    Это выражение, которое выбирает одно из двух значений.

    Рекомендация: используйте ?: для простого выбора значения. Если внутри выборки много действий, лучше if.

    Циклы

    Циклы повторяют выполнение тела.

    while: пока условие истинно

    Типичный шаблон: читать вход, пока он корректен.

    Такой цикл остановится на конце файла или при ошибке ввода.

    do while: минимум один раз

    Тело выполнится хотя бы один раз, а потом проверится условие.

    for: счётный цикл

    Классический for состоит из трёх частей:

  • инициализация (выполняется один раз)
  • условие (проверяется перед каждой итерацией)
  • шаг (выполняется после тела)
  • Важно: i существует только внутри цикла for.

    Диапазонный for: обход контейнеров

    Он читается проще, когда вы перебираете элементы массива, строки, std::vector.

    Если вы хотите менять элементы, используйте ссылку &.

    Если менять не нужно, часто правильно писать const:

    break и continue

  • break выходит из ближайшего цикла или switch.
  • continue пропускает остаток тела и переходит к следующей итерации.
  • Функции

    Функция позволяет:

  • дать имя кусочку логики
  • переиспользовать код
  • упростить тестирование и чтение
  • Объявление и определение

    Минимально функция состоит из:

  • типа возвращаемого значения
  • имени
  • списка параметров
  • тела
  • Иногда объявление (прототип) пишут выше, а определение ниже.

    Это важно, потому что компилятор читает файл сверху вниз и должен знать сигнатуру функции до первого вызова.

    Возврат значений и void

  • Если функция возвращает значение, используйте return value;.
  • Если функция ничего не возвращает, используйте void.
  • return; допустим в void-функции для раннего выхода.

    Параметры: по значению и по ссылке

    Главный выбор: передавать аргумент по значению или по ссылке.

  • По значению (int x) создаёт копию. Подходит для маленьких типов (int, double, bool).
  • По ссылке (T& x) позволяет изменять исходный объект.
  • По константной ссылке (const T& x) позволяет не копировать большой объект и запрещает изменения.
  • Практическое правило:

  • маленькие типы передавайте по значению
  • большие объекты передавайте как const T&, если не нужно менять
  • меняете объект внутри функции, тогда T&
  • Значения по умолчанию

    Можно задавать аргументы по умолчанию.

    Важно: значения по умолчанию обычно указывают в объявлении (в заголовочном файле), а не дублируют в определении.

    Перегрузка функций

    В C++ можно иметь несколько функций с одним именем, но разными параметрами.

    Рекурсия

    Рекурсия это когда функция вызывает саму себя. Она полезна в некоторых алгоритмах, но требует аккуратности: всегда должна быть база, которая останавливает вызовы.

    На практике многие задачи можно решить циклом, и это часто проще для отладки на старте.

    Области видимости и время жизни

    Область видимости отвечает на вопрос: где в коде доступно имя переменной?.

    Время жизни отвечает на вопрос: когда существует объект в памяти?.

    Блочная область видимости

    Переменные, объявленные в блоке { ... }, видны только внутри этого блока.

    Область видимости в if и for

    Переменные, объявленные в заголовке if или for, имеют область видимости внутри соответствующей конструкции.

    Затенение имён (shadowing)

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

    Это законно, но часто ухудшает читаемость. На старте лучше избегать затенения.

    Локальные и глобальные переменные

    Переменные, объявленные вне функций, имеют глобальную область видимости (точнее, область видимости пространства имён). Их легко использовать, но они усложняют поддержку и тестирование.

    Рекомендация: по возможности держите данные локально и передавайте через параметры функций.

    Время жизни локальных переменных

    Локальные переменные создаются при входе в блок и уничтожаются при выходе из него.

    !Иллюстрация того, что локальные переменные живут только внутри своего блока и обычно размещаются на стеке

    Осторожно: нельзя возвращать ссылку или указатель на локальную переменную, потому что после выхода из функции она уничтожится.

    Компилятор может предупредить, но важно понимать причину на уровне модели исполнения.

    Сборка всего вместе: пример с меню и функциями

    Ниже пример программы, где одновременно используются:

  • цикл while для повторения меню
  • switch для выбора команды
  • функции для логики
  • аккуратные области видимости
  • Важные детали примера:

  • read_int делает программу устойчивой к нечисловому вводу.
  • cmd, a, b имеют понятные области видимости и не живут дольше, чем нужно.
  • break в switch предотвращает проваливание.
  • Что вы должны уметь после этой статьи

  • Писать ветвления через if/else и выбирать варианты через switch.
  • Использовать while, do while, for, диапазонный for.
  • Управлять циклами с break и continue.
  • Объявлять и вызывать функции, понимать разницу между объявлением и определением.
  • Выбирать передачу параметров по значению, по ссылке и по const ссылке.
  • Понимать области видимости и время жизни локальных переменных, избегать затенения и опасных возвратов ссылок на локальные объекты.
  • 3. Память и данные: массивы, строки, указатели, ссылки, RAII

    Память и данные: массивы, строки, указатели, ссылки, RAII

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

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

    Полезные справочники:

  • cppreference: Object
  • cppreference: Lifetime
  • cppreference: Arrays
  • cppreference: std::array
  • cppreference: std::vector
  • cppreference: std::string
  • cppreference: Pointers
  • cppreference: References
  • cppreference: RAII
  • cppreference: std::unique_ptr
  • cppreference: std::shared_ptr
  • Как программа хранит данные

    В C++ почти всё сводится к объектам в памяти.

  • Объект — участок памяти с типом и временем жизни.
  • Адрес — число, которое указывает, где объект лежит в памяти.
  • Время жизни — период, когда объект существует и к нему можно безопасно обращаться.
  • Одна из главных причин ошибок на C++: обращение к памяти после окончания времени жизни объекта.

    !Иллюстрация различий между стеком и кучей и связи через указатели

    Практическая модель для старта:

  • Локальные переменные (например, int x{} внутри функции) обычно живут в стеке и уничтожаются при выходе из блока { ... }.
  • Динамически выделенные объекты (через new) живут, пока вы явно не освободите память (через delete). Это мощно, но опасно.
  • Объекты стандартной библиотеки (например, std::string, std::vector) обычно сами управляют своей динамической памятью и освобождают её в деструкторе.
  • Массивы: фиксированный размер и непрерывная память

    C-массивы

    C-массив — это непрерывный блок элементов фиксированного размера:

    Ключевые свойства:

  • Размер фиксирован и известен во время компиляции.
  • Элементы лежат подряд в памяти.
  • Индексация начинается с 0.
  • Опасность C-массивов: выход за границы. C++ не обязан проверять границы, и ошибка может проявиться как угодно.

    Получение размера C-массива через sizeof работает только там, где массив ещё массив, а не «превратился» в указатель:

    std::array: безопаснее, но размер фиксирован

    std::array<T, N> — контейнер-обёртка над массивом фиксированной длины.

    Почему часто лучше std::array, чем T[N]:

  • Умеет .size().
  • Лучше работает с алгоритмами.
  • Явно передаётся как единый объект.
  • std::vector: динамический массив

    std::vector<T> — динамический массив, который хранит элементы подряд, но умеет увеличивать размер.

    Важные термины:

  • v.size() — сколько элементов логически хранится.
  • v.capacity() — сколько элементов можно хранить без перевыделения.
  • Практическое правило:

  • Если размер фиксирован — рассмотрите std::array.
  • Если размер меняется — почти всегда нужен std::vector.
  • Строки: std::string и C-строки

    std::string: стандартная строка

    std::string хранит последовательность символов и управляет памятью сам.

    Ввод строки целиком обычно делается через std::getline:

    C-строка: массив char с нулём в конце

    C-строка — это char[], где конец отмечен символом \0.

    Особенности:

  • Нужно следить, чтобы \0 присутствовал.
  • Нужно следить за размером буфера.
  • Многие функции из <cstring> ожидают именно \0-терминированную строку.
  • std::string при необходимости может дать C-совместимое представление:

    Важно: указатель c остаётся валидным только пока исходная строка s не изменилась так, что потребовалось перевыделение памяти.

    std::string_view: «вид» на строку без владения

    std::string_view удобен для передачи строк в функции без копирования и без владения памятью.

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

    Указатели: адреса и доступ к памяти

    Указатель хранит адрес объекта.

  • &x — взять адрес.
  • *p — разыменовать указатель (получить объект по адресу).
  • nullptr — «ни на что не указывает».
  • const и указатели

    Есть два разных смысла const:

  • const int* p — нельзя менять значение по адресу (объект только для чтения через этот указатель).
  • int* const p — нельзя менять сам указатель (он всегда указывает на один и тот же адрес).
  • Ошибки указателей: nullptr, висячие указатели и мусор

    Три классических проблемы:

  • Указатель равен nullptr, но его разыменовали.
  • Указатель указывает на объект, который уже уничтожен.
  • Указатель содержит «мусор», потому что его не инициализировали.
  • Практика: инициализируйте указатели сразу и предпочитайте nullptr.

    Динамическая память: new и delete

    Иногда объект создают динамически:

    Проблемы динамической памяти:

  • Если забыть delete, будет утечка памяти.
  • Если сделать delete дважды, будет двойное освобождение.
  • Если сохранить указатель и обратиться после delete, будет висячий указатель.
  • Отдельно для массивов:

    Практическое правило современного C++: не владейте памятью через «сырой» указатель. Для владения используйте RAII и умные указатели.

    Ссылки: безопасный «псевдоним» объекта

    Ссылка — это альтернативное имя (псевдоним) уже существующего объекта.

    Ключевые свойства ссылок:

  • Ссылка должна быть инициализирована сразу.
  • Ссылка не может быть «пустой» как nullptr.
  • Ссылка обычно не «переназначается» на другой объект.
  • const ссылка

    const T& позволяет принимать объект без копирования и запретить изменение.

    Это одна из самых частых сигнатур функций в реальном C++.

    RAII: главный способ безопасно управлять ресурсами

    RAII (Resource Acquisition Is Initialization) — идиома, где владение ресурсом привязано к времени жизни объекта.

    Идея:

  • В конструкторе объект захватывает ресурс (память, файл, мьютекс, сокет).
  • В деструкторе объект освобождает ресурс.
  • Благодаря областям видимости ресурс освобождается автоматически при выходе из блока, даже при раннем return.
  • !Схема автоматического освобождения ресурсов при выходе из области видимости

    RAII уже работает в std::string и std::vector

    Когда вы пишете:

    вы используете RAII, потому что std::vector сам выделяет и освобождает свою память.

    Умные указатели: std::unique_ptr и std::shared_ptr

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

    #### std::unique_ptr: единоличное владение

    Свойства:

  • Владелец один.
  • Можно перемещать владение (через std::move).
  • #### std::shared_ptr: совместное владение

    Свойства:

  • Несколько владельцев.
  • Объект удаляется, когда последний shared_ptr уничтожен.
  • Практическое правило:

  • По умолчанию выбирайте std::unique_ptr.
  • std::shared_ptr используйте, когда действительно нужна разделяемая модель владения.
  • Мини-пример: RAII против ручного управления

    Ручное управление легко ломается при раннем выходе:

    С RAII так делать безопасно:

    Частые ошибки и практические правила

    Ошибка: вернуть ссылку или указатель на локальный объект

    Правильные подходы:

  • Возвращать значение.
  • Возвращать объект, который живёт дольше (например, хранится снаружи).
  • Ошибка: хранить «сырой» владеющий указатель

    Если вы видите new, спросите себя:

  • Почему не std::vector или std::string?
  • Почему не std::unique_ptr?
  • Правило: владение и невладение должны быть очевидны

  • std::unique_ptr<T> обычно означает владение.
  • T* и T& чаще всего означают невладение (ссылка на чужой объект).
  • Правило: предпочитайте «правило нуля»

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

    Что вы должны уметь после этой статьи

  • Отличать время жизни объекта и область видимости имени.
  • Выбирать между std::array, std::vector и C-массивом.
  • Понимать разницу между std::string и C-строками.
  • Понимать основы указателей: &, *, nullptr, const-варианты.
  • Понимать, чем ссылка отличается от указателя и когда использовать const T&.
  • Объяснить RAII и применять std::unique_ptr и std::shared_ptr вместо ручных new/delete.
  • 4. ООП в C++: классы, наследование, полиморфизм, правила 0/3/5

    ООП в C++: классы, наследование, полиморфизм, правила 0/3/5

    В прошлой статье вы разобрались с памятью, временем жизни объектов, ссылками/указателями и RAII. Это напрямую связано с ООП в C++: класс — это способ описать тип данных вместе с его поведением, а конструкторы/деструкторы и правила 0/3/5 определяют, как объект безопасно живёт, копируется, перемещается и освобождает ресурсы.

    Цель этой статьи: научиться проектировать классы так, чтобы они были:

  • понятными (инкапсуляция и интерфейсы)
  • безопасными (корректное владение ресурсами)
  • расширяемыми (наследование и полиморфизм)
  • Справочники:

  • cppreference: class
  • cppreference: constructor
  • cppreference: destructor
  • cppreference: Special member functions
  • cppreference: Inheritance
  • cppreference: virtual function
  • cppreference: std::unique_ptr
  • Что такое ООП в C++ и что такое класс

    Объектно-ориентированное программирование в C++ — это стиль, где вы моделируете программу через объекты.

  • Объект — конкретный экземпляр некоторого типа в памяти.
  • Класс — описание пользовательского типа: какие данные он хранит и какие операции над ними возможны.
  • Инкапсуляция — сокрытие деталей реализации и защита инвариантов.
  • Инвариант — это условие, которое всегда должно быть истинным для объекта, чтобы он считался корректным. Пример: баланс банковского счёта не должен становиться отрицательным.

    !Инкапсуляция: внешний код работает через публичный интерфейс, а внутренние данные защищены

    Структура класса: поля, методы, модификаторы доступа

    Класс состоит из:

  • полей (данные)
  • методов (функции-члены)
  • правил доступа (что видно снаружи)
  • В C++ есть три уровня доступа:

  • public — доступно всем
  • private — доступно только самому классу
  • protected — доступно классу и его наследникам
  • Пример класса с инвариантом:

    Ключевые моменты:

  • поле названо balance_, чтобы визуально отличать от локальных переменных
  • проверка инвариантов находится внутри класса, а не размазана по программе
  • метод balance() помечен const, потому что он не меняет объект
  • Конструкторы, деструкторы и время жизни объекта

    Конструктор — специальная функция, которая создаёт корректный объект.

  • Вызывается при создании объекта.
  • Должен приводить объект в валидное состояние.
  • Деструктор — специальная функция, которая завершает время жизни объекта.

  • Вызывается при выходе из области видимости или при delete.
  • Используется для освобождения ресурсов (RAII).
  • Простейший пример RAII-класса: владеем динамическим массивом.

    Этот класс уже демонстрирует причину существования правил 0/3/5: как только класс владеет ресурсом через сырой указатель, становится важно правильно реализовать копирование и перемещение.

    Специальные функции-члены и правила 0/3/5

    У класса в C++ есть набор специальных функций-членов. Компилятор может их сгенерировать, но иногда это опасно или неправильно.

    Список (упрощённо):

  • конструктор по умолчанию
  • деструктор
  • копирующий конструктор
  • копирующее присваивание
  • перемещающий конструктор
  • перемещающее присваивание
  • Правило нуля

    Правило 0: если ваш класс не владеет ресурсами напрямую и использует RAII-типы стандартной библиотеки, то обычно не нужно писать ни деструктор, ни копирование, ни перемещение.

    Пример: класс владеет ресурсом через std::vector.

    Здесь всё корректно “само”: std::vector правильно копируется/перемещается и освобождает память.

    Правило трёх

    Правило 3: если вы вручную определили хотя бы одну из трёх функций:

  • деструктор
  • копирующий конструктор
  • копирующее присваивание
  • то, скорее всего, вам нужно определить и остальные две.

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

    Наивная ошибка:

  • вы написали деструктор с delete[]
  • компилятор сгенерировал копирование “по полям”
  • два объекта начали указывать на один и тот же массив
  • оба деструктора сделали delete[] по одному адресу
  • Это приводит к ошибке времени выполнения.

    Правило пяти

    Правило 5: в современном C++ добавляются перемещающие операции.

    Если вы определили или отключили копирующие операции и класс владеет ресурсом, то обычно нужно продумать и:

  • перемещающий конструктор
  • перемещающее присваивание
  • Перемещение — это способ передать владение ресурсом без дорогого копирования.

    Пример: корректный RAII-класс по правилу пяти

    Ниже тот же буфер, но с корректным копированием и перемещением.

    Практический вывод:

  • писать правило 5 вручную можно, но это сложно и легко ошибиться
  • в прикладном коде почти всегда лучше применить правило 0 и хранить ресурсы в std::vector, std::string, std::unique_ptr
  • Наследование: когда один класс расширяет другой

    Наследование — механизм, позволяющий описать отношение “является разновидностью”.

    Пример: есть базовый тип “Фигура”, а есть конкретные фигуры “Круг” и “Прямоугольник”.

    Что важно понимать про доступ:

  • public-наследование сохраняет смысл “is-a” и открытый интерфейс базового класса
  • protected обычно означает “это часть реализации базового класса для наследников, но не для внешнего мира”
  • Рекомендация для проектирования:

  • используйте наследование, когда действительно нужен общий интерфейс и подстановка базового типа на производный
  • если вам нужно просто “использовать” другой объект внутри — чаще подходит композиция (поле другого типа внутри класса)
  • Полиморфизм: один интерфейс, разные реализации

    Полиморфизм в C++ чаще всего означает динамический полиморфизм через virtual:

  • вызываем метод через указатель/ссылку на базовый класс
  • фактически выполняется метод производного класса
  • Для этого базовый класс должен иметь virtual-функции.

    !Идея виртуального вызова: выбор реализации происходит по реальному типу объекта

    Виртуальная функция и override

    Пример полиморфного интерфейса:

    Термины:

  • virtual — функция может быть переопределена в наследниках и вызываться динамически
  • = 0чисто виртуальная функция, класс становится абстрактным (нельзя создать Shape напрямую)
  • override — просим компилятор проверить, что мы действительно переопределяем виртуальную функцию
  • Зачем виртуальный деструктор

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

    Поэтому практическое правило:

  • если в классе есть хотя бы одна виртуальная функция, делайте деструктор virtual (часто = default)
  • Важное ограничение: объектное срезание

    Если вы копируете производный объект в базовый по значению, “лишняя” часть производного отбрасывается. Это называется object slicing.

    Практика полиморфизма:

  • храните полиморфные объекты через Shape*, Shape&, std::unique_ptr<Shape>
  • не храните их “по значению” в переменной базового типа
  • Полиморфизм и RAII: std::unique_ptr как основной инструмент

    Самая частая связка в современном C++:

  • интерфейс (абстрактный базовый класс)
  • владение через std::unique_ptr
  • Почему это хорошо:

  • std::unique_ptr освобождает память автоматически
  • Shape имеет виртуальный деструктор, поэтому удаление корректное
  • полиморфизм достигается без ручных new/delete
  • Правильные “точки роста” для уровня middle

    Ниже практические принципы, которые будут полезны в реальном коде.

  • Держите инварианты внутри класса, не перекладывайте ответственность на пользователя класса.
  • Предпочитайте правило 0: храните ресурсы в RAII-объектах стандартной библиотеки.
  • Используйте override для всех переопределений виртуальных функций.
  • Если класс предназначен для полиморфного использования, делайте виртуальный деструктор.
  • Предпочитайте композицию наследованию, если нет строгого “is-a”.
  • Что вы должны уметь после этой статьи

  • Объяснить, что такое класс, объект, инкапсуляция и инвариант.
  • Писать классы с корректными конструкторами и const-методами.
  • Понимать, зачем нужны деструкторы и как RAII связывает ресурс со временем жизни объекта.
  • Объяснить правила 0/3/5 и распознавать ситуации, когда компиляторское копирование опасно.
  • Использовать наследование и виртуальные функции для полиморфизма.
  • Избегать object slicing и владеть полиморфными объектами через std::unique_ptr.
  • 5. STL и современные возможности: контейнеры, алгоритмы, итераторы, lambda, move

    STL и современные возможности: контейнеры, алгоритмы, итераторы, lambda, move

    В предыдущих статьях вы разобрались с управлением потоком, временем жизни объектов, указателями/ссылками и RAII, а также с тем, как проектировать классы. Теперь следующий шаг к уровню уверенного разработчика: научиться писать код на стандартной библиотеке C++ (STL), опираясь на контейнеры, итераторы и алгоритмы, и понимать современные механики языка: lambda и перемещение (move semantics).

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

  • контейнер хранит данные
  • итераторы дают унифицированный доступ к данным
  • алгоритмы выполняют операции над диапазонами
  • lambda задают поведение “на месте” (предикаты, преобразования)
  • move позволяет эффективно передавать владение и избегать лишних копий
  • Полезные ссылки:

  • cppreference: Containers library
  • cppreference: Algorithms library
  • cppreference: Iterators
  • cppreference: Lambda expressions
  • cppreference: std::move
  • cppreference: std::unique_ptr
  • Зачем STL и как она связана с RAII и ООП

    STL — это набор готовых, хорошо протестированных компонентов, которые почти всегда предпочтительнее “самописных” аналогов.

  • Контейнеры (std::vector, std::string, std::map) реализуют RAII: сами выделяют и освобождают память.
  • Алгоритмы (std::sort, std::find_if, std::accumulate) отделяют что сделать от где лежат данные.
  • Итераторы и диапазоны позволяют алгоритмам работать одинаково с разными контейнерами.
  • Move semantics позволяют контейнерам и вашим классам быть быстрыми без ручного управления памятью.
  • Это напрямую продолжает прошлые темы:

  • из статьи про память: вы больше не хотите владеть ресурсами через “сырой” new/delete
  • из статьи про ООП: вы проектируете типы так, чтобы они хорошо копировались/перемещались и безопасно жили
  • Контейнеры: какие бывают и как выбрать

    Контейнеры STL делятся на несколько групп:

  • последовательные (элементы идут “по порядку”)
  • ассоциативные (поиск по ключу)
  • неупорядоченные ассоциативные (хеш-таблицы)
  • Быстрая шпаргалка выбора

    | Задача | Частый выбор | Почему | |---|---|---| | Динамический массив, быстрый доступ по индексу | std::vector | хранит подряд, быстрый operator[] | | Частые вставки/удаления в начале | std::deque | эффективно растёт с двух сторон | | Хранить уникальные ключи с сортировкой | std::set, std::map | ключи упорядочены | | Быстрый поиск по ключу без сортировки | std::unordered_set, std::unordered_map | обычно быстрый поиск по хешу | | Строки | std::string | стандартная строка с RAII |

    Практическое правило для старта:

  • по умолчанию берите std::vector
  • если нужен ключ → std::unordered_map или std::map
  • если нужен порядок ключей → std::map
  • std::vector: базовый контейнер для большинства задач

    Ключевые свойства:

  • элементы лежат в памяти подряд
  • доступ по индексу быстрый
  • при росте может происходить перевыделение памяти
  • Если вы заранее знаете размер, уменьшайте перевыделения:

    Важно различать:

  • size() — сколько элементов реально хранится
  • capacity() — сколько можно хранить без перевыделения
  • std::map и std::unordered_map: ключ → значение

    std::map хранит пары ключ-значение в порядке ключа.

    std::unordered_map хранит пары в хеш-таблице, порядок не гарантируется, но часто быстрее для поиска.

    Практические нюансы:

  • operator[] у map/unordered_map создаёт элемент, если ключа не было (это удобно, но иногда неожиданно)
  • если нужно “просто проверить наличие” без создания, используйте .find()
  • Контейнеры и инвалидирование итераторов

    При модификации контейнера ссылки/указатели/итераторы на элементы могут стать невалидными.

  • std::vector: при перевыделении памяти обычно инвалидируются все итераторы и ссылки
  • std::map: итераторы обычно стабильнее при вставках (но удалённый элемент, конечно, пропадает)
  • Это напрямую связано с темой времени жизни: даже если объект “логически тот же”, его адрес может измениться.

    Итераторы: единый способ обхода данных

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

    Минимальная модель:

  • begin() — итератор на первый элемент
  • end() — итератор на позицию “за последним”
  • const_iterator и cbegin/cend

    Если вы не хотите (или не можете) менять элементы, используйте константные итераторы.

    Диапазонный for — это синтаксический сахар

    Вы уже использовали диапазонный for. Важно понимать идею: он использует begin() и end().

    Если хотите менять элементы, берите ссылку:

    !Визуализация идеи begin/end и обхода итератором

    Алгоритмы: сила STL

    Алгоритмы в <algorithm> и <numeric> работают с диапазонами итераторов.

    Классическая форма: algo(begin, end, ...).

    std::sort и компаратор

    Сортировка по убыванию через компаратор:

    Поиск: std::find и std::find_if

    Агрегация: std::accumulate

    std::accumulate лежит в <numeric>.

    Третий параметр 0 — это начальное значение суммы. От него также зависит тип результата.

    Преобразования: std::transform

    Удаление по условию и идиома erase-remove

    Важно: алгоритм std::remove_if не удаляет элементы физически из контейнера. Он переставляет элементы и возвращает итератор на “новый конец” логических данных.

    Чтобы реально уменьшить std::vector, применяют идиому erase-remove:

    Почему так устроено:

  • одни и те же алгоритмы должны работать с разными контейнерами
  • не все контейнеры умеют “удалить элемент по итератору” одинаково
  • !Наглядно показать, что remove_if не уменьшает size, а erase делает удаление

    Lambda: функции “на месте” и захваты

    Lambda-выражение — это способ создать объект-функцию прямо в месте использования.

    Базовый вид:

    Захваты: [ ], [&], [=], точечные захваты

    Часто lambda нужна, чтобы использовать внешние переменные.

  • [] ничего не захватывает
  • [&] захватывает всё используемое по ссылке
  • [=] захватывает всё используемое по значению
  • [a, &b] захватывает a по значению, b по ссылке
  • Пример: порог снаружи и фильтрация.

    Важно понимать разницу:

  • захват по значению копирует переменную в объект lambda
  • захват по ссылке хранит ссылку на внешнюю переменную, и она должна жить достаточно долго
  • Это снова возвращает нас к времени жизни объектов: нельзя безопасно хранить lambda с ссылочными захватами дольше, чем живут захваченные переменные.

    Generic lambda: параметры с auto

    std::function и “стоимость” универсальности

    Иногда нужно хранить вызываемый объект с фиксированной сигнатурой.

    Практический нюанс:

  • std::function удобен, но может добавлять накладные расходы
  • если тип известен на этапе компиляции, часто достаточно auto (и шаблонов)
  • Move semantics: быстро и безопасно передаём ресурсы

    Перемещение — это механизм, который позволяет “забрать” ресурсы у одного объекта и передать другому без глубокого копирования.

    std::move не двигает, а разрешает двигать

    std::move(x) — это приведение x к rvalue-ссылке, сигнал: “этот объект можно переместить”. Реальное перемещение происходит в конструкторе/операторе перемещения.

    Пример со строкой:

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

  • объект после перемещения остаётся валидным, но полагаться на его содержимое нельзя (обычно он “пустой”, но это не гарантируется)
  • Перемещение и std::unique_ptr: передача владения

    std::unique_ptr нельзя копировать, но можно перемещать.

    Это прямое продолжение RAII: владение ресурсом выражено типом, а move даёт способ передать владение безопасно.

    Возврат объектов из функции и эффективность

    Современный C++ умеет эффективно возвращать объекты по значению.

    Не стоит преждевременно оптимизировать и пытаться “везде добавить ссылки”: часто правильнее возвращать по значению и доверять оптимизациям компилятора.

    Практический стиль: комбинируем контейнеры, алгоритмы и lambda

    Пример: есть список строк, нужно:

  • оставить только строки длиной минимум min_len
  • отсортировать по длине, затем по алфавиту
  • Этот пример показывает “идиоматичный” современный C++:

  • данные живут в std::vector<std::string> (RAII)
  • фильтрация через remove_if + erase
  • сортировка через std::sort + lambda-компаратор
  • Что вы должны уметь после этой статьи

  • Выбирать подходящий контейнер (std::vector, std::map, std::unordered_map) под задачу.
  • Понимать модель begin()/end() и уметь работать с итераторами.
  • Применять алгоритмы STL (sort, find_if, accumulate, transform, remove_if) и знать идиому erase-remove.
  • Писать lambda с захватами и понимать, когда нужен захват по значению, а когда по ссылке.
  • Понимать, что делает std::move, что такое “состояние после перемещения” и как move связан с RAII (std::unique_ptr).
  • 6. Шаблоны и проектирование: templates, SFINAE, перегрузка, паттерны, архитектура

    Шаблоны и проектирование: templates, SFINAE, перегрузка, паттерны, архитектура

    В прошлых статьях вы научились управлять временем жизни (RAII), проектировать классы (правило 0/3/5), использовать STL, лямбды и move. Следующий шаг к уровню уверенного middle — понять, как в C++ строится обобщённый код и как язык выбирает “правильную” функцию или тип во время компиляции.

    Шаблоны (templates) — это механизм параметризации кода типами и значениями.

  • Вы пишете алгоритм один раз.
  • Компилятор “порождает” версии под конкретные типы.
  • Вы получаете производительность и статическую проверку типов.
  • Но вместе с этим появляются темы, без которых сложно читать и писать современный код:

  • перегрузка и разрешение перегрузок
  • ошибки компиляции как часть “интерфейса” шаблонов
  • SFINAE (отбрасывание неподходящих перегрузок)
  • проектирование шаблонных интерфейсов и архитектурные приёмы
  • Справочники:

  • cppreference: Templates
  • cppreference: Function template
  • cppreference: Class template
  • cppreference: Template specialization
  • cppreference: Overload resolution
  • cppreference: SFINAE
  • cppreference: std::enable_if
  • cppreference: Type traits
  • cppreference: Concepts
  • Что такое шаблон на практике

    Шаблон — это описание семейства функций или типов.

    Шаблон функции

    Простой пример — обобщённый max:

    Ключевые идеи:

  • template <class T> вводит параметр типа T.
  • my_max не “универсальная функция во время выполнения”. Это генерация кода во время компиляции.
  • Ошибки внутри шаблона часто проявляются только при инстанцировании (когда шаблон реально используется с конкретным типом).
  • Шаблон класса

    Классический пример — контейнер “пара значений”:

    Вы уже активно используете шаблонные классы, даже если не замечали этого:

  • std::vector<int>
  • std::unordered_map<std::string, int>
  • std::unique_ptr<Shape>
  • Параметры шаблонов: типы и значения

    Шаблон может параметризоваться:

  • типами
  • значениями, известными на этапе компиляции
  • Например, фиксированный размер у std::array<T, N> задаётся числом N:

    Здесь 4не переменная, а параметр шаблона, который влияет на тип.

    Вывод типов и явное указание параметров

    Иногда компилятор выводит типы сам:

    Но если тип вывести нельзя (или вы хотите указать явно), можно написать:

    Практическое правило для проектирования API:

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

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

    !Пошаговая диаграмма процесса разрешения перегрузок

    Пример: есть точная перегрузка для int и общий шаблон для остальных.

    Важно понимать:

  • обычная (нешаблонная) перегрузка часто выигрывает у шаблонной, если она подходит “точнее”
  • но правила выбора сложные, и при росте числа перегрузок легко получить неоднозначность
  • Практика:

  • держите набор перегрузок минимальным
  • предпочитайте “один хороший шаблон” вместо набора почти одинаковых функций
  • Специализация и частичная специализация

    Иногда для конкретного типа нужно особое поведение.

    Полная специализация

    Частичная специализация

    Частичная специализация доступна только для шаблонов классов. Она помогает “узнать форму типа”. Пример: отличить указатель T* от не-указателя.

    С точки зрения проектирования:

  • специализации — мощный инструмент, но они усложняют поддержку
  • если можно решить задачу перегрузкой или if constexpr, часто так будет проще
  • SFINAE: как шаблон “мягко” отбрасывается

    SFINAE расшифровывается как Substitution Failure Is Not An Error: “ошибка подстановки параметров шаблона — не ошибка компиляции”.

    Смысл:

  • компилятор пробует подставить типы в шаблон
  • если при этом получается некорректная конструкция, такая перегрузка просто исключается из кандидатов
  • если после этого остаются подходящие перегрузки — всё компилируется
  • !Схема как SFINAE отбрасывает неподходящую перегрузку

    std::enable_if: классический способ включать/выключать перегрузки

    Обычно SFINAE используют вместе с std::enable_if и traits из <type_traits>.

    Пример: разрешим функцию только для целых типов.

    Что здесь происходит:

  • std::is_integral_v<T>предикат на тип (true для int, long long, std::int32_t и т.д.)
  • std::enable_if_t<cond> определён только если cond == true
  • если cond == false, подстановка ломается, и SFINAE исключает шаблон
  • Практическое замечание:

  • сообщения об ошибках при enable_if могут быть тяжёлыми для чтения
  • в современном C++ часто лучше использовать concepts
  • Concepts: современная альтернатива SFINAE

    Начиная с C++20 можно писать ограничения читаемо:

    С точки зрения проектирования:

  • SFINAE — важен, потому что вы будете встречать его в библиотеках и существующем коде
  • concepts — предпочтительнее для нового кода, если ваш стандарт и компилятор позволяют
  • if constexpr: “ветвление” на этапе компиляции

    Если вы хотите разное поведение в зависимости от типа, часто удобно писать одну функцию и выбирать путь на этапе компиляции.

    Важно:

  • ветка, которая не выбирается, не компилируется (в этом отличие от обычного if)
  • это часто делает код проще, чем сеть специализаций
  • Как шаблоны связаны с архитектурой

    Шаблоны — не только про “генерики”. Это инструмент архитектуры: они определяют, что проверяется во время компиляции, а что переносится во время выполнения.

    Ниже — ключевой архитектурный выбор.

    Статический полиморфизм против динамического

    Вы уже делали динамический полиморфизм через virtual и std::unique_ptr<Shape>.

  • динамический полиморфизм: гибче, но есть виртуальный вызов и владение объектами по указателям
  • статический полиморфизм (шаблоны): быстрее и проще для оптимизации, но тип должен быть известен при компиляции
  • Пример “статического интерфейса” для алгоритма: нужна сущность, у которой есть .area().

    Если T не имеет area(), вы получите ошибку компиляции. Это контракт, но выраженный не через базовый класс, а через требования к типу.

    С C++20 лучше выражать это явно через concepts, но понимать принцип важно в любом стандарте.

    Policy-based design: поведение как параметр

    Иногда вы хотите менять стратегию работы, не меняя основной код. Типичный приём — “политика” как параметр шаблона.

    Архитектурный эффект:

  • вы убираете if (logging_enabled) из логики
  • выбор “включить/выключить логирование” становится выбором типа
  • компилятор может полностью выкинуть код логирования для NullLogger
  • CRTP: шаблонная форма “наследования”

    CRTP — приём, когда базовый класс параметризован производным.

    Он часто применяется для:

  • добавления общих методов без виртуальных функций
  • построения миксинов
  • Ограничение:

  • это не замена виртуальному полиморфизму, потому что вы не можете хранить разные типы в одном контейнере “через базу” без дополнительных техник
  • Type erasure: “стереть тип” и хранить разные реализации

    Иногда вы хотите:

  • снаружи видеть единый интерфейс
  • внутри иметь разные типы без шаблонов на уровне API
  • Вы уже видели пример такой идеи в std::function: внутри может храниться лямбда любого типа, но наружу он даёт единый вызов.

    Типичное применение:

  • публичный API библиотеки без раскрытия шаблонов
  • плагины и модульность
  • снижение времени компиляции для пользователей библиотеки
  • Эта тема крупная и заслуживает отдельного блока практики, но важно распознать её как архитектурный инструмент.

    Паттерны проектирования в контексте шаблонов

    Сами по себе паттерны — не “магия”, а словарь решений.

    Ниже несколько паттернов и как шаблоны с ними связаны.

  • Стратегия: часто реализуется как policy (параметр шаблона) или как объект, переданный в алгоритм (например, компаратор в std::sort).
  • Фабрика: в C++ часто выражается как функция-шаблон make_* (пример: std::make_unique).
  • Адаптер: шаблонный класс, который приводит один интерфейс к другому (в STL это повсеместно).
  • Декоратор: можно реализовать через композицию или через шаблонные обёртки.
  • Архитектурное правило уровня middle:

  • если решение должно быть выбрано на этапе компиляции и влияет на производительность, шаблоны подходят хорошо
  • если решение должно быть выбрано во время выполнения (конфиг, плагины), чаще нужен интерфейс с виртуальными функциями или type erasure
  • Практические правила для шаблонного кода

  • Держите шаблонные интерфейсы минимальными и чёткими.
  • Предпочитайте “простые” механики: перегрузка, if constexpr, traits.
  • Используйте SFINAE, когда действительно нужно отбрасывать перегрузки.
  • Для нового кода в C++20+ предпочитайте concepts: они улучшают читаемость и ошибки компиляции.
  • Помните о стоимости: шаблоны ускоряют выполнение, но могут увеличить время компиляции и размер бинарника.
  • Что вы должны уметь после этой статьи

  • Объяснить разницу между шаблоном и обычной функцией/классом.
  • Понимать, как компилятор выбирает перегрузку, когда есть и шаблонные, и нешаблонные варианты.
  • Объяснить идею SFINAE и распознавать std::enable_if в чужом коде.
  • Применять if constexpr и базовые type traits (std::is_integral_v, std::is_same_v).
  • Выбирать архитектурно: шаблоны (статический полиморфизм) или virtual/type erasure (динамический полиморфизм).
  • Узнавать и применять базовые приёмы проектирования: policy-based design, CRTP, фабрики, стратегия.
  • 7. Продвинутые темы и практика: исключения, файлы, тестирование, потоки, финальный проект

    Продвинутые темы и практика: исключения, файлы, тестирование, потоки, финальный проект

    Вы уже умеете писать функции и управлять потоком выполнения, понимаете время жизни объектов и RAII, проектируете классы по правилам 0/3/5 и используете STL, лямбды и move. Эта статья добавляет ключевые навыки уровня уверенного разработчика: обработку ошибок через исключения, работу с файлами, базовую культуру тестирования и основы многопоточности. В конце вы соберёте всё в финальный проект.

    Полезные справочники:

  • cppreference: Exceptions
  • cppreference: std::exception
  • cppreference: std::ifstream
  • cppreference: std::ofstream
  • cppreference: std::filesystem
  • cppreference: std::thread
  • cppreference: std::mutex
  • cppreference: std::lock_guard
  • cppreference: std::condition_variable
  • cppreference: std::future
  • Catch2 documentation
  • GoogleTest documentation
  • Исключения

    Исключения в C++ решают задачу сигнализации об ошибках так, чтобы:

  • код обработки ошибки можно было отделить от основного сценария
  • ошибка могла “подняться” вверх по стеку вызовов
  • RAII-объекты гарантированно освободили ресурсы при выходе из области видимости
  • !Как исключение поднимается по стеку и почему RAII освобождает ресурсы автоматически

    Базовая конструкция try/catch/throw

    Минимальный пример:

    Ключевые моменты:

  • throw создаёт исключение и прерывает текущий путь выполнения.
  • catch ловит исключение, если тип подходит.
  • Обычно ловят const std::exception&, чтобы:
  • - не копировать исключение - не потерять полиморфизм
  • e.what() возвращает диагностическое сообщение.
  • Почему исключения хорошо сочетаются с RAII

    Вы уже знаете RAII: ресурсы захватываются в конструкторе, освобождаются в деструкторе. При исключении происходит раскрутка стека и вызываются деструкторы всех локальных объектов, которые успели быть созданными.

    Практический вывод:

  • держите ресурсы в RAII-объектах (std::vector, std::string, std::unique_ptr, файловые потоки)
  • избегайте “голых” new/delete и ручных close() в прикладном коде
  • Пример: файл закроется автоматически.

    Как выбирать: исключения или коды ошибок

    В C++ встречаются оба подхода.

    Частая практика:

  • исключения используют для исключительных ситуаций (невозможно продолжать нормальный сценарий)
  • коды ошибок используют там, где ошибка является частью обычного потока (например, поиск элемента, который может отсутствовать)
  • Примеры:

  • std::unordered_map::find возвращает итератор, а не кидает исключение
  • .at() у контейнеров кидает исключение при выходе за границы, а operator[] обычно нет
  • Гарантии исключений и практические правила

    При проектировании функций и классов полезно мыслить гарантиями:

  • базовая гарантия: при исключении программа остаётся в валидном состоянии, утечек нет
  • сильная гарантия: операция либо полностью успешна, либо не меняет состояние
  • В учебной практике достаточно держать такие правила:

  • Не меняйте объект “по частям” до конца операции, если вам важна сильная гарантия.
  • Используйте временные объекты и std::swap для безопасного обновления.
  • Не бросайте исключения из деструкторов.
  • noexcept

    noexcept означает: “эта функция не должна бросать исключения”. Если исключение всё же вылетит из noexcept-функции, будет вызван std::terminate.

    Где это важно:

  • перемещающие операции часто помечают noexcept, чтобы контейнеры могли безопаснее и эффективнее перемещать элементы
  • деструкторы по умолчанию подразумеваются как noexcept в большинстве случаев
  • Пример:

    Файлы и файловая система

    Работа с файлами обычно состоит из трёх слоёв:

  • получение путей и перечисление файлов
  • чтение или запись
  • парсинг формата (строки, CSV-подобные данные, JSON-подобные данные, бинарные структуры)
  • !Типичный конвейер обработки файловых данных

    Текстовое чтение: getline против operator>>

  • std::getline(in, s) читает строку целиком (включая пробелы) до \n.
  • in >> token читает токен до пробела.
  • Пример чтения всех строк:

    Запись в файл

    Практические нюансы:

  • проверяйте, что поток открылся
  • при сложной логике чтения различайте: “конец файла” и “ошибка ввода” (через состояние потока)
  • Бинарный ввод-вывод

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

    Пример идеи (без привязки к конкретному формату):

    Практические правила для бинарных форматов:

  • фиксируйте разрядность типов (std::uint32_t и т.д.)
  • определяйте endianness (порядок байтов) в формате, если данные будут переноситься между системами
  • не пишите “как лежит struct в памяти” без явного контроля выравнивания и формата
  • std::filesystem

    std::filesystem (C++17+) позволяет работать с путями и файлами на уровне стандартной библиотеки.

    Пример перечисления файлов:

    Практические нюансы:

  • операции файловой системы могут бросать исключения std::filesystem::filesystem_error
  • у многих функций есть перегрузки с std::error_code, если вы хотите обойтись без исключений
  • Тестирование

    Тестирование в C++ важно не потому, что “так принято”, а потому что:

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

    Хорошая стратегия для учебного проекта и для реального кода:

  • чистые функции (детерминированные вход → выход)
  • парсинг и сериализация
  • преобразования данных (фильтрация, сортировка, агрегация)
  • граничные случаи (пустой ввод, неверный формат, большие данные)
  • Структура тестов

    Обычно тесты строятся в стиле:

  • Arrange: подготовить входные данные
  • Act: вызвать тестируемую функцию
  • Assert: проверить результат
  • Это можно делать даже без фреймворка через assert, но удобнее использовать библиотеку тестирования.

    Пример с Catch2

    Catch2 популярен как “один заголовок/простая интеграция” (подробности и инструкции по подключению — в репозитории).

    Мини-идея теста:

    Пример с GoogleTest

    GoogleTest часто выбирают в “больших” проектах.

    Практические правила хороших unit-тестов

  • тесты должны быть быстрыми
  • тесты должны быть детерминированными
  • не смешивайте много проверок разных смыслов в одном тесте
  • тестируйте логику отдельно от ввода-вывода: I/O сложнее стабильно проверять
  • Потоки и многопоточность

    Многопоточность в C++ позволяет ускорять программы на многоядерных системах, но добавляет сложность: возможны гонки данных и взаимные блокировки.

    !Почему без синхронизации результат в многопоточном коде становится непредсказуемым

    std::thread: запуск и join

    Минимальный пример:

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

  • если объект std::thread уничтожится, оставаясь joinable, программа завершится через std::terminate
  • значит, поток нужно либо join() (дождаться), либо detach() (отпустить в фон)
  • detach() полезен редко и требует аккуратного управления временем жизни данных, которые использует поток.

    Гонки данных и mutex

    Гонка данных возникает, когда несколько потоков одновременно обращаются к одной памяти, и хотя бы один поток пишет.

    Пример защиты счётчика:

    Почему std::lock_guard важен:

  • это RAII для блокировки
  • даже если внутри критической секции будет throw, мьютекс корректно разблокируется при выходе из области видимости
  • condition_variable: ожидание события

    std::condition_variable используют, когда поток должен ждать, пока выполнится условие (например, появится задача в очереди), а не крутиться в цикле.

    Идея: один поток добавляет задачу, другой ждёт, пока очередь не станет непустой.

    Практические детали:

  • ожидание делайте через cv.wait(lock, predicate), чтобы корректно обрабатывать “ложные пробуждения”
  • std::unique_lock нужен, потому что wait временно отпускает мьютекс и затем захватывает снова
  • async и future: результат из фоновой работы

    Для задач вида “запустить и получить результат” часто удобнее std::async и std::future, чем ручной std::thread.

    fut.get():

  • ждёт завершения
  • возвращает результат
  • пробрасывает исключение из фоновой задачи (если оно было)
  • Где многопоточность уместна в учебном проекте

    Многопоточность имеет смысл, если у вас есть:

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

    Финальный проект: мини-приложение уровня middle

    Финальный проект должен заставить вас применить все ключевые темы курса:

  • STL-контейнеры и алгоритмы
  • собственные классы и инварианты
  • RAII и безопасное владение ресурсами
  • исключения и устойчивость к ошибкам
  • файловый ввод-вывод и работа с путями
  • тестирование логики
  • (опционально) многопоточность
  • Ниже вариант проекта, который хорошо укладывается в учебный формат.

    Тема проекта: анализатор логов

    Суть: программа читает один или несколько текстовых файлов логов, парсит строки, строит статистику и сохраняет отчёт.

    Пример строки лога (условный формат):

  • 2026-02-05T12:34:56Z INFO auth user=alice action=login
  • Цели проекта:

  • корректно и устойчиво разобрать вход
  • посчитать метрики
  • вывести результаты
  • покрыть ключевую логику тестами
  • Минимальные требования (обязательная часть)

  • Ввод:
  • 1. Путь к файлу или директории через аргументы командной строки. 2. Если директория, обработать все файлы внутри (через std::filesystem).
  • Парсинг:
  • 1. Разбор времени, уровня (INFO/WARN/ERROR) и пары ключ=значение. 2. Ошибочные строки не должны “ронять” всю программу: они должны учитываться в статистике ошибок.
  • Статистика:
  • 1. Счётчик строк по уровням. 2. Топ-N пользователей по количеству событий. 3. Количество ошибочных строк.
  • Вывод:
  • 1. Человекочитаемый отчёт в консоль. 2. Сохранение отчёта в файл.
  • Тесты:
  • 1. Тесты на парсер (валидные и невалидные строки). 2. Тесты на агрегацию статистики.

    Продвинутая часть (по желанию)

  • Многопоточность:
  • 1. Обработка файлов параллельно. 2. Потокобезопасная агрегация статистики через локальные частичные результаты и последующее слияние.
  • Производительность:
  • 1. Предварительное резервирование unordered_map. 2. Минимизация лишних копий строк (аккуратно, не ломая время жизни).
  • Форматы:
  • 1. Сохранение отчёта в JSON-подобном формате (даже без внешней библиотеки, если формат простой).

    Архитектура: разбиение на модули

    !Пример архитектуры финального проекта с разделением ответственности

    Рекомендуемая структура (идея, не строгий шаблон):

  • LogEntry как модель данных
  • Parser как компонент, который переводит строку в LogEntry или сообщает об ошибке
  • Stats как структура статистики
  • Aggregator как класс, который принимает LogEntry и обновляет Stats
  • Reporter как вывод отчёта
  • Практическое правило проектирования:

  • пусть парсер не пишет в консоль
  • пусть статистика не знает о файлах
  • пусть I/O будет тонкой оболочкой вокруг чистой логики
  • Стратегия обработки ошибок

    Здесь удобно сочетать исключения и “мягкие” ошибки:

  • ошибка открытия файла: исключение (без файла работу продолжать нельзя)
  • ошибка парсинга конкретной строки: не исключение, а “результат с ошибкой”, чтобы продолжить обработку
  • Пример сигнатуры парсера через std::optional:

    Или через std::variant (если хотите хранить причину ошибки):

    План работ

  • Сначала реализуйте “ядро”:
  • 1. parse_line 2. Aggregator 3. Reporter
  • Затем подключите I/O:
  • 1. чтение файла 2. обход директории
  • Затем добавьте тесты для ядра.
  • Затем добавьте многопоточность, только если базовая версия уже стабильна и протестирована.
  • Что вы должны уметь после этой статьи

  • Использовать исключения: try/catch/throw, ловить const std::exception&, понимать связь с RAII.
  • Работать с файлами через std::ifstream/std::ofstream, выбирать getline или >>, понимать бинарный режим.
  • Использовать std::filesystem для путей и перечисления файлов.
  • Понимать, зачем нужны unit-тесты, и как тестировать чистую логику отдельно от I/O.
  • Использовать std::thread и базовую синхронизацию (std::mutex, std::lock_guard, std::condition_variable) и понимать, где это уместно.
  • Спроектировать и собрать финальный проект, объединяющий темы курса.