Кладовочка

О разном, чтобы было под рукой онлайн

C++ для студентов: шпаргалка к экзамену, коллоквиуму и контрольным (МИФИ, МГУ, МФТИ)

C++ для студентов — это не только синтаксис, но и набор требований, которые повторяются от вуза к вузу: парадигмы и ООП, специальные функции-члены, полиморфизм и таблица виртуальных методов, исключения, шаблоны, контейнеры STL, move-семантика и алгоритмы на графах и деревьях. Ниже собраны экзаменационные вопросы МИФИ, МГУ ВМК и МФТИ с краткими, но полными ответами, разобраны типовые практические задачи продвинутого потока и приведён план подготовки. Это отдельная шпаргалка, дополняющая общий справочник по языку: здесь акцент на том, что реально спрашивают на зачёте, коллоквиуме и письменной контрольной. Держите её под рукой перед сессией и разбирайте блоки по порядку — материал выстроен от теории к задачам.

Что вы получите, пройдя эту шпаргалку до конца. Вы научитесь не только перечислять термины, но и объяснять почему C++ устроен именно так: зачем нужна таблица виртуальных методов, почему правило пяти, а не правило трёх, как move-семантика ускоряет vector в десятки раз. Эти «почему» — то, что отличает ответ студента, получившего четвёрку, от ответа, получившего пятёрку с замечаниями.

Ключевые термины простыми словами — что именно спрашивают на экзамене:

  • Парадигмы и ООП — разные стили мышления о коде (процедурный, объектно-ориентированный, обобщённый). ООП держится на трёх китах: инкапсуляция (сокрытие деталей за интерфейсом), наследование (переиспользование кода потомками), полиморфизм (один вызов — разное поведение у разных типов). Почему именно эти три, а не пять и не семь: процедурный код быстро превращается в «спагетти», где любое изменение ломает всё подряд; ООП вводит чёткие границы между сущностями, и эти три механизма закрывают три главных источника хаоса — неконтролируемое изменение данных, дублирование кода и запутанные ветки if (type == ...). Практический совет: на экзамене, прежде чем отвечать, назовите все три кита и приведите по одному короткому примеру из жизни — это сразу переводит вас с первого уровня на второй. Типичная ошибка: назвать только два кита из трёх — студент вспоминает инкапсуляцию и полиморфизм, забывая наследование; преподаватель сразу ставит «неполный ответ».

  • Специальные функции-члены — это методы, которые компилятор умеет генерировать сам, если их не написать: конструктор, деструктор, копирующий конструктор, копирующий оператор присваивания, перемещающий конструктор, перемещающий оператор присваивания (правило пяти). Если определяете хотя бы одну — определите все пять, иначе компилятор сгенерирует «не то». Почему именно пять, а не три: до C++11 было «правило трёх» (конструктор копирования, оператор копирования, деструктор); с появлением move-семантики добавились ещё две — теперь, если вы написали деструктор, копирующий конструктор генерируется как = delete, а перемещающие операции — тем более. Без этого правила unique_ptr не работал бы как уникальный владелец. Типичная ошибка: определить деструктор ради delete[] buf, но забыть про копирование — в итоге компилятор молча сделает почленное копирование указателя и получится двойной delete и UB (undefined behavior, неопределённое поведение). Ловушка преподавателя: если спросят «что сгенерирует компилятор, если определить только деструктор», ответ — «копирующий конструктор и копирующий оператор присваивания как = default», а не «ничего».

  • Полиморфизм и таблица виртуальных методов (vtable) — возможность через указатель/ссылку на базовый класс вызвать «правильную» функцию-член производного класса. Реализуется через скрытый указатель vptr в каждом объекте и общую для класса таблицу vtable — компилятор делает позднее связывание (late binding) во время выполнения. Почему именно через таблицу, а не через switch(type): альтернатива в стиле C — switch по типу в каждом вызове — была бы медленной и требовала бы изменять код базового класса при добавлении нового наследника. Таблица делает диспетчеризацию за O(1) и позволяет расширять иерархию без правки базы (принцип открытости/закрытости). Ловушка преподавателя: если спросят «сколько памяти добавляет virtual к объекту» — ответ «один указатель» (на типичных 64-битных системах это 8 байт), а не «нисколько» или «зависит от компилятора». Аналогия: vtable — это расписание уроков в школе; vptr в каждом ученике — это запись «в каком я классе»; вызов виртуальной функции — звонок с урока, когда диспетчер смотрит в расписание нужного класса.

  • Исключения — механизм сообщения об ошибке, при котором стек вызовов «разматывается» до ближайшего подходящего catch. Связан с идиомой RAII: ресурсы освобождаются в деструкторах по дороге к обработчику. Почему throw/catch, а не коды возврата: коды возврата легко проигнорировать (и они конфликтуют с «нормальным» возвращаемым значением); исключения отделяют «счастливый путь» от обработки ошибок и автоматически прокидываются через все промежуточные функции без правки их сигнатур. Практический совет: в ответе обязательно упомяните, что деструкторы по умолчанию noexcept — если из деструктора «вылетит» исключение во время разматывания стека, программа аварийно завершится (std::terminate). Типичная ошибка: ловить всё подряд через catch (...) — это маскирует ошибки и не даёт корректно обработать std::bad_alloc или std::out_of_range.

  • Шаблоны — способ писать типобезопасный обобщённый код: компилятор делает инстанцирование — генерирует отдельный код под каждый конкретный тип аргумента на этапе компиляции. Почему это быстрее, чем generics в Java или void* в C: в Java generics стираются до Object — проверка типов только в момент вызова, а внутри контейнера лежат указатели; в C++ код шаблона реально компилируется под каждый тип, поэтому std::vector<int> и std::vector<MyClass> — это два разных класса с разными оптимизациями. Ловушка: ошибки из шаблонов читать больно — компилятор вываливает 50 строк «instantiated from here»; не пугайтесь, идите от первой ошибки к последней, обычно причина именно в первой. Практический совет: на устном экзамене по шаблонам спросят разницу между typename и class внутри шаблона — ответ: «в данном контексте они синонимы, но typename подчёркивает, что зависимое имя — это тип, а не значение».

  • Контейнеры STL — готовые структуры данных (vector, list, map, set, unordered_map, stack, queue, priority_queue…), экономящие месяцы ручной работы. Почему именно STL, а не свои классы: контейнеры STL написаны лучшими экспертами мира, протестированы в каждом крупном проекте и интегрированы с алгоритмами из <algorithm>; колесо изобретать не нужно — собственная реализация почти всегда окажется медленнее и с багами. Типичная ошибка: использовать vector там, где нужно часто вставлять в середину (O(n)) — возьмите list или deque; использовать map там, где порядок не важен и важна скорость поиска — возьмите unordered_map (амортизированное O(1) против O(log n)). Ловушка преподавателя: вопрос «почему std::vector не может быть noexcept-перемещаемым, если у элемента нет перемещающего конструктора» — ответ: «потому что компилятор не сможет гарантировать отсутствие исключений, и vector будет копировать при реаллокации, теряя в производительности».

  • Move-семантика — техника «кражи» внутренностей у временного объекта (rvalue) вместо дорогого копирования. Работает через rvalue-ссылки T&& и функцию std::move; после перемещения источник находится в valid but unspecified state. Почему это важно на практике: копирование большого вектора — это копирование всех элементов и выделение новой памяти; перемещение — это копирование трёх указателей и переключение владения буфером. На миллионе элементов это секунды против микросекунд. Типичная ошибка: вызвать std::move на объект, который ещё используется ниже по коду — это классический use-after-move и неопределённое поведение; std::move — это не волшебное «сделай быстро», это «разрешаю забрать внутренности, больше не трогай». Ловушка преподавателя: вопрос «что вернёт std::move от const-объекта» — ответ: «rvalue-ссылку на const, и перемещающий конструктор не скомпилируется — вызовется копирующий».

  • Алгоритмы на графах и деревьях — обходы (DFS/BFS), кратчайшие пути (Дейкстра, Флойд-Уоршелл), минимальное остовное дерево (Краскал, Прим), динамическое программирование на деревьях. Почему это обязательно: любая задача про «как быстрее добраться», «как связать все города минимальной сетью дорог», «как проверить, что граф связный» сводится к одной из этих категорий; на олимпиадах МФТИ это треть контеста. Практический совет: Дейкстру на экзамене просят написать на доске чаще всего — выучите её с priority_queue<pair<int,int>> (расстояние, вершина) и не забудьте про if (d != dist[v]) continue;, иначе получите TLE на тестах преподавателя.

Почему именно эти темы повторяются от вуза к вузу? Они образуют индустриальный фундамент C++: на них построены реальные проекты — драйверы, игры, высоконагруженные сервисы, базы данных. Эти же темы проверяют на собеседованиях в Яндекс, Google, Intel, VK, и они же составляют ядро профильного экзамена. Уверенное владение ими закрывает ~80% вопросов билета.

Конкретная статистика по билетам (по наблюдениям последних лет). В МИФИ билет по ООП содержит ровно один вопрос про vtable и один про RAII; в МГУ ВМК — обязательно про шаблоны и контейнеры STL (часто — «напишите свой vector»); в МФТИ — минимум одна задача на граф или ДП на дереве (олимпиадный стиль). Это не гарантия на следующий год, но вероятность именно такой структуры очень высока.

Аналогия из жизни. Представьте университет как программу: ООП — это факультеты с уставом (инкапсуляция), передача знаний между кафедрами (наследование) и единым брендом «МГУ», но с разными программами у мехмата и ВМК (полиморфизм). Шаблоны — пустой бланк заявления, в который можно вписать любое имя. STL — библиотека готовых формуляров в деканате. Исключения — пожарная сигнализация: сработала где-то в корпусе — тревога «разматывается» до ближайшего выхода. Move-семантика — передача дела временному сотруднику: новый забирает папку со стола, старый уходит «с пустыми руками, но в сознании».

Как пользоваться шпаргалкой. Каждый раздел устроен одинаково: сначала — объяснение простыми словами (что это и зачем), затем — код с построчными комментариями, затем — таблица с пояснением «что это значит на практике». Если времени мало, читайте только объяснения и таблицы; если готовитесь отвечать у доски — разбирайте код и прогоняйте примеры через Compiler Explorer. Экзаменационные вопросы вынесены в отдельный раздел в конце и сгруппированы по вузам.

Подробнее о режимах чтения (выбирайте по оставшемуся времени):

  • Экспресс-режим (1–2 дня до экзамена): бегло прочитайте только жирные определения и итоговые таблицы. Цель — вспомнить терминологию и не путать vptr с vtable, перемещение с копированием.
  • Базовый режим (1–2 недели): пройдите все разделы по порядку, вчитываясь в объяснения простыми словами. Цель — понять «почему именно так», а не только «как написать».
  • Глубокий режим (продвинутый зачёт/олимпиада): разберите код руками, измените примеры, специально сломайте их — посмотрите, что выдаст компилятор и что напечатает программа. Затем ответьте устно на экзаменационные вопросы без подглядывания.

Ловушка, в которую попадают почти все студенты. Не пытайтесь выучить всё за ночь — материал устроен так, что каждый следующий блок опирается на предыдущий: исключения опираются на RAII из ООП, move-семантика — на rvalue-ссылки и специальные функции-члены, контейнеры — на шаблоны, графовые алгоритмы — на контейнеры STL. Поэтому идти надо строго по порядку, а не выборочно по «самым лёгким» темам.

Конкретный пример провала из-за нарушения порядка. Студент выучил move-семантику, но не знает правила пяти — он не понимает, почему unique_ptr некопируем, но перемещаем; на устном экзамене это всплывает как «не знаю, что такое deleted-функция» и тянет на незачёт. Тратятся три недели на попытку выучить «сложную» тему с нуля вместо того, чтобы пройти 30-минутный блок про специальные функции-члены.

Экзаменационные тезисы к этому разделу (запомните перед сессией):

  • Compiler Explorer (godbolt.org) — must-have: вставляете код, видите ассемблер и сообщения компилятора, проверяете свои гипотезы за секунды.
  • Шпаргалка — не замена учебнику, а «карта местности»: сначала пройдите маршрут по учебнику, потом используйте её для повторения.
  • Если не понимаете раздел — вернитесь назад и пройдите предыдущие: 90% «непонятного move» — это непонятые ссылки и специальные функции-члены.

Три уровня владения материалом, которые различает преподаватель. Первый — «могу назвать механизм» (инкапсуляция, наследование, полиморфизм). Второй — «могу объяснить, как он реализован» (vtable, vptr, разматывание стека, инстанцирование шаблона). Третий — «могу написать без подсказки» (правило пяти, свой unique_ptr, Дейкстра). Экзамен чаще проверяет второй уровень, коллоквиум — первый и второй, продвинутый зачёт — третий. Держите этот ориентир в голове: на вопрос «что такое виртуальная функция» недостаточно ответить «это полиморфизм» — ждут упоминания позднего связывания и таблицы виртуальных методов.

Подробное пояснение по каждому уровню с примерами.

  • Уровень 1 — студент отвечает по формуле «термин = одно предложение». Это уровень «прочитал и запомнил». Например: «Инкапсуляция — это сокрытие данных». Преподаватель видит, что студент знаком с темой, но не понимает её. Оценка — 3.
  • Уровень 2 — студент может рассказать, как это устроено «под капотом». «Инкапсуляция в C++ реализуется через секции private/protected/public и friend-функции; компилятор проверяет доступ на этапе компиляции, а нарушение — это ошибка компиляции, а не runtime». Здесь видна связь с устройством языка. Оценка — 4–5.
  • Уровень 3 — студент пишет код. «Вот мой класс Matrix, я закрыл прямой доступ к указателю data_, открыл at(i,j) с проверкой границ, оператор присваивания и копирующий конструктор реализовал по правилу пяти, деструктор освобождает data_». Здесь студент доказывает, что может применить теорию. Оценка — 5+ (или автомат).

Таблица трёх уровней — что именно требуется в ответе и за что обычно снижают баллы:

Уровень Что нужно сказать в ответе Типичная ошибка студента Где проверяется
1. Назвать механизм Перечислить ключевые слова и идеи: «полиморфизм — это virtual + override», «инкапсуляция — private/public» Перепутать термины или назвать только один из трёх китов ООП; назвать «виртуальную функцию» без слова virtual Коллоквиум, блиц-опрос, тест
2. Объяснить реализацию Упомянуть позднее связывание, vptr, vtable, разматывание стека, инстанцирование шаблона, RAII Сказать «оно само работает магически» без указания механизма; смешать vptr и vtable Экзамен у доски, письменный экзамен
3. Написать без подсказки Реализовать unique_ptr, двусвязный список, итератор, контейнер, алгоритм Дейкстры с нуля Допустить утечку памяти, забыть noexcept у перемещающих операций, использовать копирование вместо перемещения Продвинутый зачёт, курсовой проект

Аналогия из жизни. Уровни — это как вождение автомобиля: 1) знать ПДД и названия педалей, 2) понимать, как работает сцепление, тормозная система и ABS, 3) реально вести машину в плотном потоке МКАДа. Преподаватель проверяет не «читал ли ты учебник», а «сядешь ли ты за руль этого кода». На экзамене можно «проскочить» второй уровень на авторитете первого, но третий без первых двух не получится — как нельзя научиться водить, зная только названия педалей.

Экзаменационные тезисы к разделу (коротко, для повторения в последний вечер):

  • vptr и vtable: vptr хранится в начале каждого объекта класса с виртуальными функциями (обычно первым скрытым полем); vtable — в статической памяти, ровно одна на класс; vptr указывает на vtable своего класса.
  • Правило пяти: определили одну из пяти — определите все пять (иначе компилятор сгенерирует то, что вы не ожидаете: например, копирующий конструктор вместо перемещающего).
  • Move-семантика: включается для rvalue (временных объектов) и явно — через std::move(lvalue); после перемещения источник в valid but unspecified state (можно уничтожать, нельзя читать).
  • noexcept критически важен: без noexcept на перемещающих операциях std::vector будет копировать элементы при реаллокации вместо того, чтобы перемещать — производительность падает в разы.
  • Алгоритм Дейкстры: работает за O((V + E) log V) с приоритетной очередью; не работает на графах с отрицательными весами ребер (для них — Беллман-Форд).
  • RAII — главная идиома C++: ресурс (память, файл, мьютекс) захватывается в конструкторе и освобождается в деструкторе; это защищает от утечек даже при исключениях, потому что стек «разматывается» через деструкторы.
  • Ловушка экзаменатора: на вопрос «что такое чисто виртуальная функция» не отвечайте «это абстрактный класс» — чистая виртуальная функция — это функция = 0 в объявлении, а абстрактный класс — это следствие: класс, у которого она есть.

Что проверяют на экзамене (по вузам)

Определение. «Экзамен по C++» — это не проверка памяти на синтаксис, а проверка того, умеете ли вы мыслить категориями языка: выбирать между копированием и перемещением, понимать, когда срабатывает виртуальный вызов, обосновывать дизайн класса. Комиссия хочет увидеть связь между тремя уровнями: «что это», «как устроено внутри компилятора/рантайма», «зачем так сделано в C++, а не иначе». Если вы отвечаете только первый уровень — это «выучил», но не «понимаю».

Простое определение. Экзамен по C++ — это разговор на трёх языках одновременно: на языке терминов (что называется «полиморфизм»), на языке механизмов (где лежит слот в vtable) и на языке решений (почему выбрали перемещение, а не копию). Студент, который говорит только на первом языке — зубрилка, на двух — хорошист, на трёх — тот, кого берут в профессию.

Форматы различаются, но ядро тем совпадает: ООП и полиморфизм, память и RAII, шаблоны, STL, а для части направлений — ещё теория трансляции и алгоритмы. Смысл таблицы в том, чтобы не готовить «весь C++» одинаково: посмотрите строку своего вуза и поймите, какой блок для вас ключевой. Если сдаёте коллоквиум, важнее терминология и ответы на «почему»; если дифференцированный зачёт — умение писать код на время.

Аналогия. Представьте C++ как автомобиль. Базовая программа МФТИ — это «умею водить и знаю ПДД» (синтаксис, контейнеры, алгоритмы). МИФИ — «понимаю устройство двигателя и трансмиссии» (ООП, vtable, RAII, перегрузка операторов). МГУ ВМК — «способен спроектировать двигатель с нуля» (грамматики, автоматы, кодогенерация, ПОЛИЗ). Готовиться «ко всему сразу» — как учить одновременно правила ПДД и термодинамику: в обоих случаях получается посредственный результат. Поэтому в шпаргалке вузы разнесены, а общая теория — это фундамент, поверх которого лежит специфика вашего формата.

Ещё одна аналогия. Экзамен по C++ — это как собеседование на работу инженера-механика. Спрашивают не «назовите три детали двигателя», а «почему здесь стоит шестерня, а не ремень, и что будет, если её убрать». Поэтому простой пересказ учебника не работает: нужно уметь обосновывать устройство программы так же, как инженер обосновывает выбор материала или типа соединения.

Как читать таблицу. В столбце «Формат» указано, что именно вас ждёт: зачёт (обычно собеседование по билетам), коллоквиум (устная проверка по ходу семестра), контест (решение задач с автоматической проверкой), курсовая (проект). Столбец «Ключевые темы» — это и есть ваша программа подготовки: он пересекается с остальными темами шпаргалки, но порядок изучения лучше брать именно отсюда.

Практическая подсказка. Прежде чем готовиться, определите: (а) какой у вас формат — устный билет, контест или защита кода; (б) какие три темы заявлены первыми в вашей строке таблицы; (в) какие блоки шпаргалки пересекаются именно с ними. Это даёт вам 70% прироста к подготовке: не «весь C++», а «конкретный срез C++ под ваш экзамен».

Вуз Курс Формат Ключевые темы
НИЯУ МИФИ «ООП» (4–5 сем.), далее Java, C#/.NET зачёт (4 сем.) + экзамен (5 сем.) + курсовая инкапсуляция, наследование, полиморфизм, перегрузка операторов, виртуальные функции, шаблоны, исключения, UML, паттерны
МГУ, ВМК «Системы программирования» коллоквиумы + зачёт семантика C++, RTTI, шаблоны, STL, C++11, теория трансляции: грамматики, НКА/ДКА, рекурсивный спуск, ПОЛИЗ
МФТИ, ФПМИ базовый поток зачёт, контесты указатели и массивы, рекурсия, строки, vector/map, базовые алгоритмы
МФТИ, ФПМИ продвинутый поток дифференцированный зачёт: 1 задача + 2 вопроса, контесты move-семантика, умные указатели, аллокаторы, SFINAE, концепты, структуры данных, графы

Как пользоваться таблицей — подробнее. Колонка «Курс» часто указывает на уровень глубины: «базовый поток» — это про написание корректного кода, «продвинутый» — про написание обоснованно корректного кода (почему именно так, что произойдёт при нарушении контракта). Колонка «Формат» диктует стиль подготовки: контест — набивать руку на краевых случаях; коллоквиум — учиться объяснять термины дословно; курсовая — заранее подготовить защиту по вашему коду (почему поле закрыто, почему деструктор виртуальный).

МИФИ ведёт линию «C++ → Java → C#/.NET»: на зачёте в 4 семестре спрашивают базовое ООП (класс, объект, инкапсуляция, наследование, полиморфизм), на экзамене в 5 семестре — виртуальные функции, перегрузку операторов, исключения и шаблоны.

Определение. Программа МИФИ проверяет не «знание стандартной библиотеки», а ООП-мышление: способность спроектировать класс так, чтобы его интерфейс был безопасен, инвариант сохранялся, а наследники не ломали базовый контракт. Это ближе к «инженерному» подходу, чем к алгоритмическому.

Простое определение. «ООП-мышление» на МИФИ — это умение смотреть на класс как на маленький механизм с инвариантом (что всегда истинно) и публичным контрактом (что обещано клиенту). Если инвариант ломается после любого вызова — класс спроектирован плохо; если private-поля открыты через геттеры «для удобства» — контракт протекёт.

Аналогия. Представьте класс как контракт между автором и пользователями: public-методы — это обещания, private-поля — это внутренние книги, которые автор не обязан показывать, виртуальный деструктор — это пункт контракта «при передаче владения обе стороны обязаны корректно завершить работу». МИФИ проверяет, что вы умеете писать такие контракты, а не просто компилирующийся код.

Аналогия из жизни. Инкапсуляция в C++ — это как касса самообслуживания в магазине: вы видите кнопки (публичный интерфейс), но не видите, что происходит внутри (private-реализация). Если бы касса выставила наружу каждый внутренний рычаг, любой покупатель мог бы случайно сломать механизм. То же самое с классом: чем меньше «внутренних ручек» торчит наружу, тем меньше шансов, что клиенты сломают инвариант.

Почему именно C++. МИФИ выбрал C++ как первый ООП-язык, потому что в нём мало «магии под капотом»: если вы написали виртуальную функцию — vtable действительно появится в бинарнике; если перегрузили оператор — это действительно функция с именем operator+. В Java/C# многое спрятано за сборщиком мусора и аннотациями, поэтому переход на них после C++ даёт более глубокое понимание, а не «выучить новый синтаксис».

Почему «мало магии» — это плюс для обучения. Когда студент пишет virtual void f(), а потом смотрит в дизассемблер и видит двойную косвенность через vtable — он понимает полиморфизм физически. В Java это было бы «виртуальный вызов, JVM делает что-то»; разница — как между «знаю, что автомобиль едет на бензине» и «знаю, как именно бензин превращается во вращение коленвала». Первое понимание — инженерное.

Ждут уверенного ООП-мышления, а не знания стандартной библиотеки наизусть: сказать «есть такой метод у vector» недостаточно, нужно объяснить, почему выбрана именно эта структура. Курсовая обычно — реализация класса с перегрузкой операторов и наследованием (матрица, комплексное число, вектор). Отдельно просят показать UML-диаграмму классов и объяснить, где в иерархии какой полиморфизм. Ключевые слова, которые любит комиссия: «инвариант класса», «целостность типа», «позднее связывание», «rule of five».

Ловушка. Самая частая ошибка на МИФИ — отвечать «как в Java/C#». Комиссия намеренно противопоставляет C++ остальным языкам: final в Java запрещает наследование статически, а в C++ запрет делается через private-деструктор или удалённый конструктор — это другое решение той же задачи. Если вы отвечаете «в Java так-то», вы показываете, что не чувствуете, зачем C++ спроектирован иначе.

Ловушка — подробнее. На МИФИ не любят три категории ответов: (1) «в Java так, значит, и в C++ так» — отсутствие независимого мышления; (2) «ну, оно работает, и ладно» — отсутствие инженерной дисциплины; (3) «тут много вариантов, я бы выбрал любой» — отсутствие сформированной позиции. Хороший ответ — это всегда «вот решение X, потому что Y, а альтернатива Z здесь не подходит, потому что W».

Экзаменационные тезисы МИФИ (можно брать дословно в ответ):

  • «Инкапсуляция в C++ — это связка private + const-методы + геттеры, возвращающие ссылки на const, а не модификатор доступа сам по себе».
  • «Позднее связывание реализовано через таблицу виртуальных функций; слот деструктора в ней не один — компилятор генерирует D1 и D0 для корректной очистки в иерархии».
  • «Перегрузка операторов — это синтаксический сахар над функцией с именем operator+; семантика остаётся обычной, поэтому нельзя перегрузить &&/|| с сохранением короткого замыкания».
  • «Rule of five: если вы определили хотя бы одну из пяти специальных функций (деструктор, копирующий конструктор, копирующий operator=, перемещающий конструктор, перемещающий operator=), подумайте обо всех остальных — компилятор не сгенерирует то, что вы не просили».

Как защитить тезис. Каждый из четырёх тезисов легко «добить» до полного ответа: после тезиса добавьте один абзац про механизм (как именно работает) и один абзац про последствие (что будет, если нарушить). Например, к первому тезису: «Если геттер возвращает копию, нарушается инвариант "внутренности неизменяемы" — клиент может менять поле мимо класса; поэтому правильный геттер на C++ возвращает const T&, а не T».

Где именно спрашивают: билет на экзамене — это два вопроса, теория плюс задача. Теорию берут из блоков «ООП», «Специальные функции-члены», «Наследование и полиморфизм»; задача — написать класс с перегрузкой операторов. На защите курсовой спросят по вашему коду: почему поле закрытое, почему деструктор виртуальный, почему оператор — друг.

Практическая подсказка — курсовая. На защите курсовой 80% вопросов — это «почему вы это сделали именно так». Поэтому перед защитой пройдитесь по своему классу и для каждого решения сформулируйте одно предложение-обоснование: «поле закрытое, потому что…», «деструктор виртуальный, потому что…», «оператор — друг, потому что…». Если вы не можете объяснить хоть одно решение — переделайте код до защиты.

МГУ ВМК («Системы программирования») соединяет язык и компилятор: половина вопросов про семантику C++, RTTI, шаблоны и STL, вторая — про грамматики, автоматы и генерацию кода.

Определение. Программа МГУ ВМК проверяет не «владение C++ как языком», а понимание того, как этот язык порождается компилятором: от лексемы и грамматики до ассемблера и vtable. C++ здесь изучается как объект, который строит компилятор, а не только как инструмент для написания кода.

Простое определение. На ВМК студент должен одновременно видеть две картины: «вот программа на C++» и «вот как компилятор превращает её в машинный код». Если он видит только первую картину — он программист, если обе — он специалист по системам программирования. Экзамен проверяет именно второе умение.

Аналогия. Если обычный экзамен по C++ — это «написать письмо по-английски», то МГУ ВМК — это «объяснить, как английский устроен: почему "a/an" зависит от следующего звука, почему порядок слов фиксирован, как работает местоименное замещение». Вы должны видеть и использование языка, и его грамматику одновременно.

Аналогия из жизни. C++ — это иероглифическая письменность, где каждый знак имеет историю. Обычный программист учит «какой иероглиф что значит». Студент ВМК учит ещё и «почему этот иероглиф состоит из таких частей, и как его нарисовать карандашом». Второе знание требует больше усилий, но именно оно позволяет проектировать новые знаки — то есть создавать новые DSL, предметно-ориентированные языки и компиляторы.

Почему так. Сочетание «язык + компилятор» нужно, чтобы показать: чтобы грамотно использовать шаблоны и перегрузку, нужно понимать, как они разрешаются в момент компиляции. Без этого вы будете писать «магический код», который случайно работает — а на экзамене это первый признак непрофессионального программиста.

Почему нельзя без теории трансляции. Шаблоны в C++ — это, по сути, встроенный механизм кодогенерации: компилятор инстанцирует функцию по типу. Чтобы понимать, почему SFINAE работает, а ошибка в теле функции — нет, нужно знать, как устроен вывод типов и что такое «непосредственный контекст подстановки». Без этого знания шаблоны превращаются в заклинания, которые иногда срабатывают.

На коллоквиуме просят назвать правило вывода перегрузки, объяснить, где в vtable лежит деструктор и почему записей о нём может быть несколько (D1/D0), или построить НКА по регулярному выражению. Это единственный из трёх вузов, где теория трансляции — полноценная часть билета, а не факультатив.

Ловушка. На ВМК нельзя «приблизительно» отвечать: определения грамматики Хомского, НКА, ДКА, ПОЛИЗа нужно давать дословно — «N → α, где α ∈ (T ∪ N)*» и т. п. Если перепутаете типы грамматик (0/1/2/3 по Хомскому) или скажете «ε-переход есть в ДКА» — это сразу минус балл, даже если общая идея верна. Готовьтесь писать формулы: на ВМК формула — это не иллюстрация, а часть ответа.

Ловушка — подробнее. На ВМК не прощают три класса ошибок в формулировках: (1) путаница уровней иерархии Хомского (тип 0/1/2/3); (2) утверждения вида «в ДКА есть ε-переходы» — их там нет по определению; (3) «грамматика — это то, что порождает язык» без уточнения «порождает все цепочки и только цепочки языка». Любая из этих трёх ошибок — минус балл, даже если дальнейший ход решения верный.

Типичные ошибки в формулировках (запомните и не повторяйте):

  • «Грамматика G — это четвёрка (N, T, P, S)» — правильно; «грамматика — это набор правил» — неточно, нет N и T.
  • «НКА — это автомат, который может находиться в нескольких состояниях одновременно» — грубое приближение; точнее — «есть функция перехода δ: Q × (Σ ∪ {ε}) → 2^Q».
  • «ПОЛИЗ — это обратная польская запись» — нестрого; точнее — «бесскобочная запись, в которой операция следует за операндами».

Экзаменационные тезисы МГУ ВМК:

  • «Разрешение перегрузки в C++ — это многошаговый процесс: сначала кандидаты отбираются по имени, потом по числу аргументов, потом по типам с учётом стандартных и пользовательских преобразований; ambiguity error возникает, когда выживших ровно ноль или больше одного».
  • «SFINAE — substitution failure is not an error: ошибка подстановки типа в шаблон отбрасывает перегрузку, а не валит компиляцию; именно на этом построены std::enable_if и концепты».
  • «НКА детерминизируется алгоритмом построения подмножеств (subset construction): состояния нового ДКА — это множества состояний НКА, достижимых по ε-переходам».
  • «ПОЛИЗ (польская обратная запись) — это промежуточное представление, в котором операнды предшествуют операции; стековая машина выполняет его за один проход без возвратов».

Как защитить тезис на ВМК. После формулировки тезиса добавьте строгий термин и формулу, если это уместно. Например, про разрешение перегрузки: «Согласно стандарту [over.match], процесс состоит из трёх фаз — отбор кандидатов по имени, отбор жизнеспособных функций по типам, выбор наилучшей. Наилучшая выбирается по рангу неявных преобразований согласно [over.ics.rank]». Чем больше «точная ссылка на раздел стандарта» — тем выше доверие комиссии.

Где именно спрашивают: коллоквиумы идут по ходу семестра — первый по семантике языка (перегрузка, шаблоны, ООП), второй по компилятору (грамматики Хомского, детерминизация, рекурсивный спуск, ПОЛИЗ). Зачёт письменный: задачи на разбор выражений и вопросы на понимание. Здесь ценится точность формулировок — определения грамматики и автомата нужно давать дословно.

Практическая подсказка — ВМК. Перед зачётом пропишите рукой на бумаге все определения (грамматика, НКА, ДКА, ПОЛИЗ, трансляция, RTTI, SFINAE) — без подсказки, без интернета. Если хотя бы одно даёте приблизительно — доводите до точной формулировки. На письменном зачёте время ограничено, и вспоминать «примерно» — самая дорогая по времени стратегия.

МФТИ делит поток. Базовый спрашивает алгоритмы и стандартные контейнеры: указатели и массивы, рекурсию, строки, vector/map, обход графа. Продвинутый — семантику перемещения, unique_ptr/shared_ptr, аллокаторы, SFINAE, концепты и задачи уровня «реализуй std::string с copy-on-write» или «напиши свой unique_ptr». На дифференцированном зачёте формат жёсткий: одна задача и два вопроса, без права на долгие раздумья.

Определение. Программа МФТИ — это проверка инженерной зрелости: способности написать работающий, безопасный по памяти и эффективный по асимптотике код. Базовый поток — «написать правильно», продвинутый — «написать правильно и понимать, почему именно так, а не иначе».

Простое определение. На МФТИ проверяют не «может ли студент написать код, который пройдёт тесты», а «может ли он написать код, который будет проходить тесты на любом корректном вводе, не упадёт по памяти и уложится в асимптотику». Для базового потока акцент на корректности, для продвинутого — на обоснованности.

Аналогия. Базовый поток — как вождение в городе: езжайте по правилам, доезжайте за разумное время, не задевайте пешеходов. Продвинутый — как вождение гоночного болида: вы обязаны знать, что делает каждая настройка, иначе либо не доедете, либо разобьётесь. На экзамене это превращается в разницу между «написал vector<int> v — работает» и «написал vector<int, MyPoolAllocator<int>> v — и могу объяснить, когда allocator_traits вызовут construct, а когда нет».

Аналогия из жизни. Базовый поток МФТИ — это «правильно собрать шкаф из IKEA по инструкции». Продвинутый — «понять, как спроектировать сам шкаф, чтобы дверцы не перекашивались, и собрать его без инструкции с первого раза». В обоих случаях результат — шкаф, но инженерная глубина разная.

Почему так. Контесты на МФТИ автоматические, поэтому важны асимптотика и краевые случаи: пустой массив, один элемент, переполнение, nullptr, максимальное значение size_t. Продвинутый поток дополнительно проверяет, понимаете ли вы, где именно компилятор/рантайм берёт на себя работу: например, при noexcept move-конструктора стандартный контейнер переключается на перемещение вместо копирования, иначе — fallback на копирование ради безопасности исключений.

Почему асимптотика важнее всего. На контесте тайм-лимит — это, как правило, 1–2 секунды. Решение с асимптотикой O(n²) при n = 10^6 — это превышение памяти или времени в 1000 раз; автопроверка покажет TL/ML. Решение с O(n log n) пройдёт, даже если написано не самым чистым кодом. Поэтому первое, что вы делаете в задаче — оцениваете порядок роста: если данных много — сортировка и unordered_map, если мало — что угодно.

Ловушка (базовый поток). Самая частая ошибка — «работает на моих тестах». На контесте дают тесты, которые вы не видели: пустой ввод, n = 1, n = 10^9, все элементы одинаковые, отсортированные, обратно отсортированные. Пишите так, чтобы любой корректный ввод обрабатывался корректно — без out_of_range, без бесконечной рекурсии, без int вместо long long.

Ловушка (базовый поток) — подробнее. Четыре «классических» краевых случая, которые забывают чаще всего: (1) n == 0 — нужно явно обработать, не падать в v[0]; (2) n == 1 — некоторые алгоритмы на одном элементе дают неверный ответ; (3) int переполнение — 1e9 * 2 уже не лезет в int, нужен long long; (4) негативные числа — abs(-2^31) не помещается в int. Перед сдачей прогоните код на этих четырёх случаях.

Ловушка (продвинутый поток). Часто пишут unique_ptr, но забывают про согласованность специальных функций: если в классе объявлен пользовательский деструктор или копирующий оператор присваивания, компилятор не сгенерирует move-конструктор и move-присваивание автоматически. В итоге vector::push_back(std::move(x)) скопирует, а не переместит, и на больших объектах это сразу видно по времени. Помните rule of five и проверяйте, что все пять функций живут «в согласии».

Ловушка (продвинутый поток) — подробнее. Ещё одна частая ошибка: написать noexcept-move-конструктор, но внутри него вызвать функцию, которая может бросить исключение. noexcept — это обещание компилятору, что функция не бросит; если обещание нарушено, программа вызывает std::terminate. Поэтому либо move-конструктор реально ничего не бросает, либо слово noexcept нужно убрать, и тогда контейнер не будет использовать перемещение, а пойдёт по fallback-копированию.

Экзаменационные тезисы МФТИ (продвинутый поток):

  • «unique_ptr — это RAII-обёртка над сырым указателем с монопольным владением; передаётся через std::move, после чего исходный unique_ptr становится nullptr».
  • «shared_ptr использует атомарный счётчик ссылок; make_shared выделяет счётчик и объект одной аллокацией — это и быстрее, и безопаснее (исключение в конструкторе не оставит висящий счётчик)».
  • «SFINAE работает в непосредственном контексте подстановки: если ошибка возникает глубже (например, внутри тела инстанцируемой функции), это уже hard error».
  • «noexcept на move-конструкторе — это контракт: стандартные контейнеры выбирают перемещение вместо копирования только если is_nothrow_move_constructible истинно; иначе — fallback на копирование для безопасности исключений».

Как защитить тезис на МФТИ. К каждому тезису добавьте либо асимптотику (для структур данных и алгоритмов), либо обоснование через стандарт (для продвинутого потока). Например, про make_shared: «Согласно [util.smartptr.shared.const], make_shared выполняет одно выделение памяти под контрольный блок и объект, тогда как конструктор shared_ptr(new T) — два, плюс риск утечки, если между new и конструктором shared_ptr будет исключение». Подобная ссылка на стандарт добавляет «инженерной взрослости» ответу.

Где именно спрашивают: контесты проверяют код автоматически (важны асимптотика и корректность на краевых случаях), зачёт — устно. В задачах продвинутого потока любят просить объяснить, почему решение noexcept-корректно и где именно вы гарантируете отсутствие утечки. Готовьтесь писать код от руки: на бумаге нет автодополнения, а std:: придётся помнить самому.

Практическая подсказка — контест. Перед сдачей прогоните решение на «жёстких» краевых случаях: (а) минимальный размер (n = 0, n = 1); (б) максимальный размер (n = 2 * 10^5 и больше); (в) «всё одинаковое» (часто ломает алгоритмы, завязанные на неравенства); (г) «всё разное» (сортирует в обратную сторону); (д) nullptr/отсутствие ввода (если формат позволяет). Это занимает 3 минуты, но ловит 80% ошибок, которые на контесте приводят к WA.

Практическая подсказка — продвинутый зачёт. На бумаге сначала напишите интерфейс класса (какие методы, какие сигнатуры, какие const/noexcept), потом — реализацию, потом — отдельный абзац с обоснованием (rule of five, инварианты, выбор аллокатора). Экзаменатор видит, что вы продумали дизайн, а не «лишь бы компилировалось».

Как выглядит хороший ответ на билет. Схема из четырёх шагов работает почти на любом экзамене.

Определение. «Хороший ответ» — это не самый длинный, а самый структурированный: комиссия должна за 1–2 минуты услышать, что вы владеете терминологией, механизмом, примером и предвидите подводный камень. Если вы уложились в эти четыре уровня — вы получили максимум баллов за развёрнутость без потери фокуса.

Простое определение. Хороший ответ на экзамене по C++ — это четыре слоя: «как это называется», «как оно работает внутри», «как это выглядит в коде», «где оно ломается». Если вы дали все четыре слоя — комиссия видит зрелое владение темой, а не пересказ учебника.

Аналогия. Это как устройство хорошего рецепта: сначала название и краткое описание («что это»), потом ингредиенты («из чего состоит»), потом шаги приготовления («как работает»), потом «как не испортить» (типичные ошибки). Без любой из частей рецепт либо непонятен, либо ненадёжен.

Аналогия из жизни. Хороший ответ — это как хорошее техническое собеседование. Интервьюер задаёт вопрос «что такое HTTPS?». Слабый кандидат говорит: «это когда сайтик с замочком». Хороший кандидат: «HTTPS — это HTTP поверх TLS» (определение), «TLS использует асимметричную криптографию для обмена ключом и симметричную для потока» (механизм), «вот как выглядит handshake» (пример), «атака: downgrade, MITM, отзыв сертификата» (ловушка). В C++ — то же самое: четыре слоя вместо одного.

Почему так. Экзаменатор проверяет не память, а модель в голове. Определение показывает, что вы знаете термин; механизм — что вы представляете реализацию; пример — что вы умеете перевести мысль в код; ловушка — что вы видели, как эта штука ломается. Без последнего пункта ответ выглядит «заученным», а с ним — «осмысленным».

Почему ловушка — самый важный слой. Именно «ловушка» отличает студента, который зубрил учебник, от студента, который видел, как эта штука ломается. Преподаватели это ценят на интуитивном уровне: если вы называете типичную ошибку, значит вы либо сами её делали, либо думали о ней, и в обоих случаях ваше понимание глубже, чем у того, кто просто пересказал страницу.

(1) Дайте определение — одно-два предложения, точными терминами. (2) Объясните механизм — «как реализовано» (vtable, разматывание стека, инстанцирование). (3) Приведите короткий пример кода или схемы. (4) Назовите подводный камень или практическое следствие — именно это отличает «выучил» от «понимаю». Например, про виртуальный деструктор: определение, затем «позднее связывание через слот деструктора», затем пример delete basePtr, затем «без него — UB и утечка».

Разбор по шагам на примере виртуального деструктора:

  1. Определение: «Виртуальный деструктор — это деструктор, объявленный с ключевым словом virtual в базовом классе; он обеспечивает корректный вызов деструктора производного класса через указатель/ссылку на базу».
  2. Механизм: «Реализован через таблицу виртуальных функций: в vtable базового класса есть слот деструктора, и компилятор подставляет туда адрес деструктора производного класса при наследовании».
  3. Пример: «Base* p = new Derived; delete p; — без virtual вызовется только ~Base(), и поля Derived утекут».
  4. Ловушка: «Если в базовом классе нет виртуальных функций вообще, компилятор не сгенерирует vtable, и virtual будет проигнорирован — это не UB, но и не работает; поэтому правило: если класс предназначен для полиморфного удаления, у него должен быть виртуальный деструктор».

Ловушка при ответе. Самая частая ошибка — уйти в детали, которые не спрашивали. Например, на вопрос «что такое полиморфизм» не нужно рассказывать про CRTP, std::variant и type erasure — достаточно трёх видов (ad-hoc через перегрузку, подтиповый через виртуальные функции, параметрический через шаблоны) и того, какой из них где применяется. Если комиссия захочет глубже — она задаст уточняющий вопрос.

Ловушка при ответе — подробнее. Три «соблазна», которые сбивают студентов: (1) «Расскажу-ка я побольше, наверняка пригодится» — нет, это размывает фокус и показывает неумение слышать вопрос; (2) «Приведу самый сложный пример» — наоборот, простой пример показывает понимание яснее; (3) «Упомяну все смежные темы, чтобы комиссия знала, что я образованный» — это выглядит как неуверенность, а не как широта знаний. Хороший ответ — это ровно столько, сколько нужно, и ни словом больше.

Экзаменационные тезисы (общие для всех форматов):

  • «Сначала определение, потом механизм, потом пример, потом ловушка» — это не шаблон ради шаблона, а структура, которая закрывает все четыре уровня проверки.
  • «Если не знаете точный термин — скажите "не помню точный термин, но идея в том, что…" — за честность обычно не снижают, за выдуманный термин — снижают почти всегда».
  • «На устном ответе делайте паузы перед ключевыми словами: комиссия успевает записать, а вы — собраться с мыслями».
  • «Если в задаче что-то не получается — опишите алгоритм словами и напишите заглушку: за частичное решение дают больше, чем за пустой лист».

Держите ответ в пределах 1–2 минут и не уходите в детали, которых не спрашивали.

ООП: теория с ответами

ООП — это способ организовать программу вокруг объектов, а не вокруг функций. Объект — это сущность, которая одновременно хранит данные (состояние) и умеет с ними работать (поведение). Поэтому детали реализации спрятаны внутри, а снаружи виден только интерфейс — набор «кнопок», которые можно нажимать. Это как с бытовой техникой: вам не нужно знать, как именно работает стиральная машина внутри, достаточно нажать кнопку «пуск» и получить результат. На экзамене эту тему спрашивают почти всегда: она открывает билеты МИФИ и МГУ, а без неё не объяснить ни наследование, ни полиморфизм. Ловушка для студента: путать «инкапсуляцию» с «сокрытием данных» — на самом деле это разные вещи (подробнее ниже). Практический совет: отвечая на экзамене, начните с определения, потом дайте аналогию (1–2 предложения), потом перечислите три столпа ООП — это покажет экзаменатору системное мышление и принесёт дополнительные баллы.

Парадигмы программирования

Парадигма — это стиль мышления о программе, способ разложить задачу на части. По сути, это ответ на вопрос «из чего состоит программа и как эти части общаются друг с другом». C++ мультипарадигменный: он поддерживает сразу несколько стилей, и выбор диктуется задачей, а не модой. На коллоквиуме просят назвать четыре парадигмы и объяснить, чем ООП отличается от процедурной. Как запомнить: удобно держать в голове «формулу» каждой парадигмы — процедурная = данные + функции, ООП = объекты с состоянием и поведением, обобщённая = алгоритмы + параметры типов, функциональная = функции без побочных эффектов.

  • Процедурная — программа как последовательность функций над данными; данные и код разделены. Представьте кулинарную инструкцию: «возьми данные (продукты), выполни шаг 1 (помой), шаг 2 (нарежь), шаг 3 (пожарь)». Данные лежат отдельно, функции их обрабатывают по очереди. Пример такого стиля — язык Си, где есть только структуры и свободные функции. Почему так исторически: ранние языки (Fortran, C) проектировались вокруг математических процедур, и понятие «состояние, привязанное к функции», было чуждо — данные были глобальными либо передавались явно.
  • ООП — программа как совокупность объектов, объединяющих состояние и поведение; ключевые механизмы: инкапсуляция (сокрытие данных и связывание их с методами), наследование (переиспользование кода через иерархию «общее → частное»), полиморфизм (единый интерфейс для разных типов). Данные и код, который с ними работает, живут вместе — как кассир и касса в одном окне: кассир знает правила работы с кассой, и вам не нужно приносить свою. Типичная ошибка студента: думать, что «ООП — это когда есть классы». На самом деле класс — лишь синтаксический инструмент; суть ООП — в трёх механизмах (инкапсуляция, наследование, полиморфизм), а не в самом факте использования ключевого слова class.
  • Обобщённая (generic) — алгоритмы и структуры параметризуются типами; в C++ это шаблоны (std::vector<T>). Один раз написали алгоритм — работает для любого типа. Почему это важно: не нужно писать vectorInt, vectorDouble, vectorString — один шаблон покрывает все случаи. Практический плюс: ошибки типов ловятся на этапе компиляции (а не в рантайме, как в динамически типизированных языках), что критично для высоконагруженных систем. Историческая справка: шаблоны появились в C++ позже классов (в 1990-х), под влиянием языков Ada и ML, и превратили C++ в так называемый «язык с двумя половинами» — одна для ООП, другая для обобщённого программирования.
  • Функциональная — вычисление как применение функций без изменения состояния; в современном C++ поддерживается лямбдами и <algorithm>. Данные не меняются, а «трансформируются» от одного неизменного значения к другому — это упрощает рассуждение о корректности программы. Где это в C++: std::transform, std::accumulate, std::find_if — всё это функции высшего порядка, принимающие другие функции как аргументы. Ловушка: студенты думают, что for_each «функциональнее» for. На самом деле разница — в побочных эффектах: функциональный стиль запрещает лямбде менять внешние переменные (хотя компилятор C++ это не контролирует — ответственность на программисте).

C++ — мультипарадигменный язык: все четыре стиля сосуществуют, и вопрос «какая парадигма лучше» некорректен без контекста задачи. Обычно в одной программе видны все: классы из ООП, шаблоны из обобщённого программирования, лямбды из функционального. Именно поэтому C++ называют «языком с несколькими языками внутри». Экзаменационный тезис: умение смешивать парадигмы в одном проекте — признак зрелости программиста на C++. Сравнительная таблица для быстрого ответа на экзамене:

Парадигма Главная единица Данные и код Типичный инструмент в C++
Процедурная функция разделены свободные функции, Си-style
ООП объект объединены в классе class, наследование, virtual
Обобщённая параметризованный алгоритм отделены от типов шаблоны template<T>
Функциональная чистая функция неизменяемы лямбды, <algorithm>

Класс и объект

Класс — это чертёж, объект — построенный по чертежу дом. В чертеже описаны поля и методы, но сам чертёж не занимает памяти под данные конкретного дома: память появляется, когда вы создаёте объект. Эту метафору стоит воспроизвести на экзамене: класс — тип, объект — экземпляр типа. Ещё одна полезная аналогия: класс — это Cookie-Cutter (форма для печенья), а объект — само печенье. Одна форма — много печенек, но каждое со своими «данными» (своей крошкой, глазурью). Аналогия из ООП-мира: класс — это понятие «Студент» в базе данных (с полями «имя», «курс»), а объект — конкретный студент Иванов, 3-й курс.

Класс — это тип: описание полей (состояния) и методов (поведения). Объект — экземпляр класса, занимающий память. Методы не дублируются в каждом объекте: они хранятся один раз, а объект передаёт им скрытый указатель this. Иными словами, у каждой квартиры (объекта) своя мебель (поля), но лифт (метод) один на подъезд — и когда жилец нажимает кнопку, лифт «знает», в какой именно квартире его вызвали (через this).

class Point {
    double x_, y_;                    // состояние: у каждого объекта своё
public:
    Point(double x, double y) : x_(x), y_(y) {}
    double norm() const { return x_ * x_ + y_ * y_; }   // поведение: общее
};

Point p(3.0, 4.0);   // p — объект класса Point
// p.x_ — ошибка: x_ закрыто; состояние доступно только через методы

norm() не хранится внутри p: компилятор вызывает общую функцию и передаёт ей адрес p как this. Поэтому размер объекта равен сумме размеров полей (с учётом выравнивания), а не «полей плюс методов». Ловушка: не думайте, что sizeof(Point) равен «размеру двух double плюс размер кода метода» — код метода лежит в отдельной области памяти (в сегменте кода), а не в самом объекте. Почему так: это экономит память — если бы каждый объект хранил свою копию метода, тысяча точек занимала бы в тысячу раз больше места. Дополнение для ответа на экзамене: упомяните также, что методы компилируются в обычные функции с «переименованием» (name mangling), например norm превращается в _ZN5Point4normEv — это позволяет им сосуществовать с методами других классов с тем же именем.

Инкапсуляция и целостность типа

Инкапсуляция — это упаковка данных и методов в один класс плюс запрет трогать внутренности снаружи. Зачем? Чтобы никто не смог сломать правила, по которым объект обязан существовать. Эти правила называют инвариантом класса — условием, которое истинно для любого корректного объекта в любой момент времени (кроме момента работы конструктора и деструктора). Без инкапсуляции инвариант становится «общественной ответственностью» — и его обязательно кто-нибудь нарушит. Простой пример инварианта: у класса Date инвариант — день от 1 до 31, месяц от 1 до 12; у Stack — размер неотрицательный и не превышает ёмкости; у Student — номер курса от 1 до 6.

Инкапсуляция скрывает внутреннее представление за публичным интерфейсом. Выгода — целостность типа: невозможно перевести объект в противоречивое состояние, потому что менять поля снаружи нельзя. Классический пример — дробь, знаменатель которой не должен быть нулём: поле закрыто, а конструктор проверяет аргумент. Если бы den_ был публичным, любой код мог бы записать туда ноль и превратить правильный объект в бессмыслицу — а потом где-то далеко в программе случилось бы деление на ноль, и вы бы долго искали виновника. С инкапсуляцией такой сценарий просто невозможен: «дверь» закрыта на уровне компилятора.

class Rational {
    int num_ = 0, den_ = 1;           // инвариант: den_ != 0
public:
    Rational(int n, int d) : num_(n), den_(d) {
        if (d == 0) throw std::invalid_argument("нулевой знаменатель");
    }
    int num() const { return num_; }
    int den() const { return den_; }
};

Формулировка для экзамена: инкапсуляция нужна не «ради секретности», а ради целостности типа — гарантии, что объект всегда валиден. Отсюда правило: конструктор либо создаёт корректный объект, либо бросает исключение; «полувалидных» объектов не бывает. Ловушка для студента: путать инкапсуляцию (объединение данных и методов + контроль доступа) с простым private. На самом деле private — лишь один из инструментов инкапсуляции; главное — это сама идея «внутреннее устройство скрыто, наружу выставлен интерфейс». Экзаменационный тезис: если вас просят привести пример инкапсуляции, не ограничивайтесь фразой «спрятал поля под private» — обязательно упомяните инвариант и объясните, как именно публичный интерфейс его защищает. Дополнительная ловушка: многие студенты называют геттеры и сеттеры «признаком хорошей инкапсуляции». Это верно лишь отчасти: сеттер, который просто field = value, не защищает инвариант — он лишь формально прячет поле. Настоящая инкапсуляция требует, чтобы сеттер проверял условия (if (d != 0)), а лучше — отсутствовал вовсе, если поле нельзя менять после создания объекта.

struct и class: секции доступа

Определение. struct и class в C++ — это два ключевых слова для объявления пользовательского типа. Технически они почти полностью взаимозаменяемы, и компилятор обрабатывает их одинаково. Различие между ними — ровно одно: режим доступа по умолчанию для членов и для базы при наследовании.

Новички часто думают, что struct и class — разные конструкции, но различие ровно одно: режим доступа по умолчанию. Полезно помнить это как «одна сущность с двумя режимами».

В C++ struct и class отличаются только доступом по умолчанию: у struct он public, у class — private. Наследование по умолчанию тоже различается: public у структуры, private у класса. Соглашение: struct — для простых агрегатов данных, class — для типов с инвариантом. То есть если у типа есть правило, которое нужно защищать, — берите class; если это просто «пачка полей», которую удобно передавать вместе, — struct. Практический совет: в реальных проектах (Google C++ Style Guide, LLVM Coding Standards) принято struct использовать только для пассивных агрегатов данных (POD-типов), а всё, где есть логика, — делать class. Это сразу читается в коде: увидел struct — значит, внутри просто данные, никаких неожиданностей.

Секция Доступ Кто видит
public всем любой код
protected класс и наследники сам тип и производные
private только класс сам тип и friend

Что это значит на практике. Три секции образуют три уровня доверия: public — для всех, protected — только для иерархии наследования, private — только для самого класса (и friend). Публичным делайте минимум: методы, которыми реально пользуются снаружи. protected для полей — палка о двух концах: наследник получит прямой доступ и сможет нарушить инвариант, поэтому чаще оставляют поля private, а наследнику дают защищённые методы или геттеры. Закономерность: строгость доступа идёт по убыванию — public доступен всем, protected — только иерархии, private — только самому классу. Это не случайно: чем больше кода имеет доступ к деталям, тем выше риск нарушить инвариант.

Почему так. Страуструп унаследовал struct из C, где все поля по традиции открыты, и оставил ему public по умолчанию ради совместимости с C-кодом. А для нового «настоящего» типа с инкапсуляцией добавил class с private по умолчанию — чтобы у типа сразу был «забор». Так одно техническое отличие породило два культурных соглашения: struct — пассивные данные, class — активная сущность с правилами поведения.

Аналогия из жизни. struct — это открытая веранда со столами: всё на виду, заходи кто хочет. class — это квартира с дверью: войти можно только через публичный интерфейс (звонок, домофон). private-поля — личные комнаты, protected — семейная гостиная (доступна детям-наследникам), friend — доверенное лицо с запасным ключом.

Ловушки.

  • Написали class A { int x; }; — поле x оказалось private, в отличие от struct, где оно было бы public. Переключая ключевое слово, легко потерять видимость.
  • При наследовании: class D : B {}; — это private-наследование по умолчанию (а struct D : B {}; означало бы public). Публичное наследование нужно писать явно: class D : public B {};.
  • Если секции доступа (public:, private:, protected:) указаны явно, переключение struct↔class ничего не меняет в правах. Стиль важнее ключевого слова.
  • struct в C++ — это не C-шная «просто пачка полей»: у него могут быть методы, конструкторы, наследование, виртуальные функции.

Экзаменационные тезисы.

  • Единственное техническое отличие struct от class — режим доступа по умолчанию (для членов и для базы наследования).
  • public видят все; protected — сам класс и наследники; private — сам класс и friend.
  • Соглашение: struct для POD-агрегатов, class для типов с инвариантом.
  • protected-поля нарушают инкапсуляцию сильнее, чем кажется: любой наследник может записать туда любое значение. Почему: потому что цепочка наследования может быть длинной, и кто-то на пятом уровне иерархии запишет туда некорректное значение, которое нарушит инвариант, — и компилятор это не поймает. Поэтому правило: поля всегда private, наследникам давайте методы.

Указатель this

Определение. this — это скрытый параметр каждого нестатического метода, неявно добавляемый компилятором. Его тип — указатель на текущий объект: T* для обычного метода и const T* для константного метода.

this — это «я» метода, ссылка на тот объект, у которого метод вызвали. Поскольку один и тот же код метода обслуживает все объекты, кому-то нужно объяснить, с чьими полями работать; этим и занимается this. Внутри метода запись x_ на самом деле означает this->x_. Связь с предыдущей темой: именно благодаря this код метода хранится в одном экземпляре, а не копируется в каждый объект — это ключевая оптимизация C++.

this — указатель на объект, для которого вызван нестатический метод. Его тип — ClassName *, а в константном методе — const ClassName *. Из этого следует важное: в const-методе нельзя менять поля (кроме mutable), потому что через const-указатель запись запрещена.

class Counter {
    int value_ = 0;
public:
    Counter& inc() { ++value_; return *this; }    // возвращаем себя для цепочки
};
Counter c;
c.inc().inc().inc();                               // value_ == 3

Почему this вообще существует. Метод — это обычная функция, которая лежит в памяти в одном экземпляре, но объектов класса может быть миллион. Чтобы один и тот же код мог работать с разными данными, нужен «адрес владельца». Это и делает this: компилятор тайно добавляет его первым параметром ко всем нестатическим методам. Когда вы пишете ++value_, на самом деле происходит ++this->value_. Экзаменационный нюанс: некоторые преподаватели любят спрашивать «что такое this и где он лежит». Ответ: this — это скрытый параметр на стеке (или в регистре, в зависимости от calling convention), а не поле объекта. Если ответите «this лежит в объекте» — это грубая ошибка.

Аналогия. Официант в ресторане — это «метод». Он один, но обслуживает разные столики. Чтобы он знал, к какому столику нести заказ, у него в голове (или в блокноте) сидит «этот столик» — this. Когда он говорит «принести счёт», он имеет в виду конкретный столик, а не абстрактный.

Приём return *this называется fluent interface: метод возвращает ссылку на себя, и вызовы выстраиваются в цепочку. Если вернуть Counter по значению, вы получите копию, и цепочка изменит не исходный объект, а временный — классическая ошибка при перегрузке операторов. Статический метод this не имеет вовсе: он не привязан ни к какому объекту. Ловушка: забыть * и вернуть просто this — тогда цепочка вызовов будет работать с указателем, и при попытке вызвать метод от временной переменной получите неопределённое поведение. Правило: если метод возвращает «себя» — почти всегда это *this (разыменованный указатель), а не this (указатель). Связь с операторами: при перегрузке operator= или operator<< возврат *this — обязательное условие, иначе код a = b = c не скомпилируется корректно: промежуточный результат должен быть ссылкой, а не копией. Типичные ловушки.

  • Возврат по значению (return *this; без &) при перегрузке операторов — частая причина трудноуловимых багов: выражение a = b = c может работать не так, как ожидалось.

    • Что происходит: без & компилятор создаёт копию *this, и return возвращает именно её. Цепочка a = b = c разбирается справа налево: сначала b = c возвращает копию b, и эта копия присваивается в a. Если в operator= есть побочные эффекты (счётчик присваиваний, ленивая пересборка кэша, инкремент идентификатора), они отрабатывают на временной копии, а не на b. В итоге наблюдаемое состояние объектов расходится с ожиданиями, а отладчик показывает «всё нормально», потому что баг живёт только в одном временном объекте.
    • Как распознать: компилируется без ошибок и предупреждений (компилятор не считает это ошибкой — это нарушение контракта, а не синтаксиса). Проявляется в неочевидных сценариях: цепочки присваиваний, использование результата operator= в шаблоне, где возвращаемый тип — шаблонный параметр.
    • Лечение: все «операторы-присваивания» и операторы +=, -=, *=, /= должны возвращать T&, то есть return *this; именно по ссылке. Это правило закреплено в идиоме «copy-and-swap» и в канонической форме operator= Этчелла-Мейерса — запомните его как формулу.
  • Забыли const у геттера — компилятор не разрешит вызвать его у const-объекта: у const-объекта this имел бы тип const T*, а обычный метод обещает принимать T*.

    • Почему так работает: const-объект обещает не меняться. Любой не-const-метод теоретически может изменить поля (даже если в реализации этого не делает), и компилятор обязан запретить такой вызов статически. Геттер (метод, который только читает поля) — это как раз тот случай, когда обещание «не менять» легко дать: достаточно дописать const после сигнатуры. После этого метод становится доступен и у обычных, и у константных объектов, и его можно вызывать из const-методов других классов.
    • Практический совет: привычка ставить const у любого метода, который только читает поля, делает класс совместимым с константными алгоритмами STL (std::find, std::count_if, range-for по const-контейнеру и т. п.). Без const ваш метод просто не вызовут из константного контекста, и это формальный повод для отказа на экзамене.
  • this нельзя переприсвоить: this = &other; не скомпилируется — this это rvalue.

    • Что стоит за этим: this — не обычная переменная-указатель, которую можно переприсваивать. Это rvalue, неявный параметр, который компилятор передаёт при каждом вызове метода. Его можно только читать (*this, this->field, &*this), но не записывать. Это часть контракта C++: адрес объекта фиксирован на всё время жизни метода.
    • Зачем так: иначе тело метода могло бы «прыгнуть» в середине к другому объекту, что разрушило бы все рассуждения о времени жизни и инвариантах класса. В C++ такой защиты нет, поэтому this сделан rvalue.
  • Внутри конструктора и деструктора this существует, но обращаться к виртуальным функциям через него опасно: в конструкторе полиморфизм ещё не работает, в деструкторе — уже разрушен.

    • Что это значит на практике: если в конструкторе базового класса вызвать виртуальный метод, будет вызвана версия базового класса, даже если реально создаётся наследник. Причина — поле vptr/vtable в момент работы конструктора базы ещё указывает на таблицу базового класса; наследная часть объекта формально не существует. В деструкторе — симметрично: вызов виртуального метода пойдёт в версию текущего разрушаемого класса (а не наследника), потому что наследная часть уже разрушена к этому моменту.
    • Следствие: избегайте виртуальных вызовов в конструкторах/деструкторах. Если очень нужно — вызывайте явно Base::method(), либо вынесите логику в отдельный не-виртуальный метод, либо заведите двухфазную инициализацию (конструктор создаёт, отдельный init() уже работает с виртуальностью). Это классическая ловушка и любимый вопрос экзаменаторов МИФИ/МФТИ/МГУ.

Экзаменационные тезисы.

  • this — неявный параметр метода, указатель на текущий объект. (Передаётся компилятором скрытно, в коде явно не виден; именно он превращает «функцию с параметром T*» в «метод класса».)
  • В const-методе тип this — const T*, поэтому запись полей запрещена. (Это и есть статический анализ «const-корректности»: компилятор ловит все попытки мутации на этапе компиляции.)
  • Fluent interface строится через return *this; с типом T& (ссылка, не копия). (Возврат ссылки сохраняет идентичность объекта; возврат по значению — скрытый баг с побочными эффектами на временной копии.)
  • Статический метод не имеет this, потому что не привязан ни к какому объекту. (Поэтому не может быть const, virtual, не видит нестатические поля и вызывается через Class::method().)

Статические и константные члены

Определение. Статический член (static) — это член класса, существующий в одном экземпляре на весь класс, а не на каждый объект. Константный метод (... const) — это метод, обещающий не изменять наблюдаемое состояние объекта; обещание проверяется компилятором статически.

Статические члены принадлежат классу целиком, а не отдельному объекту: представьте общий счётчик посетителей на входе, который один на всё здание. Константные методы, наоборот, — это обещание «я не изменяю объект», которое проверяет компилятор. Эти две темы любят смешивать в одном вопросе: «может ли статический метод быть const?» — не может, потому что у него нет this, а значит и менять нечего; «может ли константный метод вызвать статический?» — да, потому что статический не меняет this вообще, ему const не нужен и невозможен.

  • Статическое поле существует в одном экземпляре на весь класс, не связано с this. В C++17 объявляется через inline static, иначе требует определения в .cpp. Инициализируется один раз до main и живёт до конца программы.

    • Почему так: исторически статические поля — способ хранить «глобальные» данные, логически принадлежащие классу (например, Person::population_), а не всей программе. В C++17 появился inline static, чтобы избавиться от раздражающего требования отдельного определения в .cpp и связанных с ним ошибок линковки (undefined reference to Class::field_).
    • Практическая подсказка: если поле константное целочисленное (static const int N = 5;), его можно инициализировать прямо в классе уже с C++11 — это не требует inline и не требует определения в .cpp. Для более сложных типов до C++17 — обязательно выносите определение в .cpp либо переходите на inline static.
  • Статический метод не имеет this, не может быть const и вызывается как Class::method(). Часто используется для фабрик (Class::create()).

    • Почему не может быть const: const у метода превращает this в const T*. У статического метода this нет вовсе, поэтому приписывать const не к чему — компилятор выдаст ошибку «cannot have const qualifier».
    • Почему не может быть virtual: виртуальность — это механизм выбора реализации по динамическому типу объекта, доступному через vptr. Без объекта нет динамического типа, нет виртуальности. То же относится к override и final.
    • Типичное использование: фабрики (static T create(...)), утилитарные функции, тесно связанные с классом (T::fromString(...)), счётчики (Person::population()), реализации паттерна «именованный конструктор».
  • Константный метод (... const) обещает не менять наблюдаемое состояние; в нём this — указатель на константу. Такой метод можно вызвать у константного объекта.

    • Что разрешено/запрещено: в const-методе запрещена запись в любое не-mutable поле, запрещён вызов не-const-метода, запрещёна не-const-операция над самим объектом. Разрешено читать поля, вызывать другие const-методы, вызывать статические методы, работать с копиями полей, принимать и возвращать по значению.
    • Почему это важно для STL: контейнеры STL дают итераторы двух типов — iterator и const_iterator. Методы begin()/end() у const-контейнера возвращают const_iterator, и через них можно вызвать только const-методы элементов. Без const ваш элемент просто не «влезет» в константный контейнер и в любой const-контекст.
  • mutable снимает const с конкретного поля, например для кэша. Применяйте редко: mutable — это лазейка в обещании константности.

    • Когда уместно: ленивая инициализация кэша (mutable std::optional<T> cache_;), счётчик обращений для отладки/профилирования, синхронизация (mutable std::mutex m_; — std::mutex даже в const-методе обязан быть mutable, чтобы взять lock_guard).
    • Когда неуместно: «мне лень писать не-const-версию метода, поэтому сделаю поле mutable» — это антипаттерн, ломающий смысл const для читающего ваш код.
class Registry {
    inline static int count_ = 0;     // C++17: без внешнего определения
    mutable int cache_ = 0;           // можно менять даже в const-методе
public:
    Registry() { ++count_; }
    static int count() { return count_; }     // нет this
    int cache() const { return ++cache_; }    // законно из-за mutable
};

Почему это работает так. Статические члены исторически появились как способ хранить «глобальные» данные, логически принадлежащие классу, а не всей программе. Они инициализируются до main — поэтому их нельзя использовать с объектами, требующими сложной инициализации (до C++20 constexpr-конструкторов это было серьёзным ограничением: например, inline static std::vector<int> v_; нельзя было заполнить «сложно» на старте). Константные методы — это статический анализ: компилятор видит тип this и запрещает запись. Если вам нужно временно изменить поле (например, для кэширования результата), mutable открывает одну конкретную лазейку, не разрушая обещание в целом. На экзамене любят спрашивать, как сочетаются static и const у метода — ответ «никак, потому что нет this» работает почти всегда.

Аналогия из жизни.

  • Статическое поле — общий счётчик посетителей торгового центра: один на здание, не привязан к конкретному магазину.
  • Статический метод — справочная служба ТЦ: можно позвонить, не заходя ни в один магазин (без объекта).
  • Константный метод — экскурсовод по музею: обещает не трогать экспонаты, поэтому его пускают и в обычные залы, и в хранилище (в const-объекты).
  • mutable — табличка «служебный вход» на одном экспонате: посетитель не трогает, а служащий может переставить.

Правило константной корректности: метод, который только читает поля, помечайте const. Тогда он будет работать и у константного объекта, и у временного. Это не формальность: без const многие алгоритмы и контейнеры просто не смогут вызвать ваш метод. На собеседованиях и экзаменах умение сразу писать const там, где это уместно, считается признаком «взрослого» кода.

Ловушки.

  • Определение статического поля в .cpp (int Registry::count_;) легко забыть — линковщик выдаёт unresolved symbol. С C++17 это решается через inline static.

    • Симптом: компилируется без ошибок, но при линковке — undefined reference to 'Registry::count_'. Это особенно неприятно в шаблонах и header-only библиотеках, где определение «размазано» по заголовкам.
    • Лечение: используйте inline static для поля прямо в классе — и проблема исчезает на уровне стандарта, без ухищрений с .cpp.
  • Объявление static const (без inline) до C++17 требовало отдельного определения в .cpp, иначе ошибка линковки.

    • Тонкость: для целочисленных типов (int, char, bool, enum) компилятор обычно обходится без отдельного определения — он подставляет значение как константу прямо в места использования. Для типов с нетривиальным устройством (например, static const std::string или static const double) — определение в .cpp обязательно, иначе ODR-нарушение.
  • Попытка вызвать нестатический метод из статического — компилятор ругается непонятной формулировкой «нельзя вызвать без объекта». Решение: передать объект параметром.

    • Пример ошибки: в static int foo() { return bar(); } компилятор сообщит что-то вроде «cannot call member function 'int bar()' without object». Решение — либо сделать bar() статическим, либо принимать объект параметром: static int foo(const Registry& r) { return r.bar(); }.
  • mutable соблазняет использовать его везде, где «лень делать копию». Не надо: это разрушает смысл const для читающего ваш код.

    • Хороший аргумент «за» mutable: ленивое кэширование, ленивая инициализация, счётчики для отладки, мьютексы.
    • Плохой аргумент «за» mutable: «я хочу изменить поле, но метод уже помечен const и лень менять сигнатуру». Это почти всегда ошибка дизайна.
  • Метод с const всё равно может изменять mutable-поля — помните об этом при проектировании инвариантов.

    • Следствие: инвариант класса, который должен сохраняться в const-методе, должен опираться на «наблюдаемое состояние» (то, что видно снаружи через const-интерфейс), а не на побайтовое состояние полей. Если mutable-поле участвует в наблюдаемом инварианте — это уже не инвариант, а пожелание.

Экзаменационные тезисы.

  • Статическое поле — одно на весь класс, инициализируется до main. (С C++17 объявляется через inline static прямо в классе, иначе требует определения в .cpp.)
  • Статический метод не имеет this, поэтому не может быть const или virtual. (Также не имеет доступа к нестатическим полям без явного объекта; вызывается как Class::method().)
  • Константный метод обещает не менять наблюдаемое состояние; компилятор это проверяет на этапе компиляции. (Делает метод доступным у const-объектов и в const-контексте STL.)
  • mutable — единственный способ менять поле из const-метода; применяйте редко и осознанно. (Классические применения: кэш, мьютекс, счётчик для отладки.)

Друзья

Определение. Дружба (friend) в C++ — это явная привилегия, которую класс выдаёт конкретной функции или другому классу, открывая доступ к своим private и protected членам. Это исключение из инкапсуляции, а не её отмена.

Дружба в C++ — это выдача пропуска в закрытую зону. Иногда оператору или вспомогательной функции нужен доступ к приватным полям, но делать метод публичным нельзя: друг существует отдельно от класса и не является его частью, поэтому публичный интерфейс не раздувается. Дружба объявляется внутри класса, но сам друг живёт «снаружи» — это принципиальное отличие от метода.

friend даёт функции или классу доступ к private/protected, не делая его частью класса. Дружба не наследуется, не транзитивна и не взаимна: если A — друг B, это не значит, что B — друг A, а наследник A не получает доступ автоматически. Чаще всего friend нужен для симметричных операторов ввода-вывода, где левый операнд — поток, а не ваш тип.

class Vec2 {
    double x_ = 0, y_ = 0;
public:
    Vec2(double x, double y) : x_(x), y_(y) {}
    friend std::ostream& operator<<(std::ostream& os, const Vec2& v);
};

std::ostream& operator<<(std::ostream& os, const Vec2& v) {
    return os << '(' << v.x_ << ", " << v.y_ << ')';   // доступ к закрытым полям
}

Почему именно friend, а не публичные геттеры. Оператор << обязан иметь std::ostream левым операндом — это требование синтаксиса C++. Сделать его членом Vec2 нельзя: тогда слева оказался бы Vec2, а не поток, и запись os << v стала бы невозможной. Значит, это внешняя функция, а ей нужен доступ к полям. Альтернатива — публичные getX()/getY(), которые раздувают интерфейс и убивают инкапсуляцию сильнее, чем один аккуратный friend. Это и есть канонический случай применения дружбы. То же касается operator== для двух разных типов и итераторов контейнера, тесно связанных с реализацией.

Аналогия из жизни. Закрытая лаборатория завода — это класс. Начальник цеха (друг) имеет пропуск и может заходить внутрь. Обычные посетители — нет. Передача пропуска не делает всех сотрудников начальника допущенными: дружба не транзитивна. Если начальник уходит на пенсию, его преемник не получает пропуск автоматически — дружба не наследуется. И начальник не может пройти в лабораторию соседнего цеха только потому, что там работает его знакомый — дружба не взаимна.

Дружба — это инструмент, а не стиль. Если сделать друзьями всех, инкапсуляция исчезнет. Здоровое применение: операторы <</>>, сравнение двух разных типов, класс-итератор, тесно связанный с контейнером, тесты в стиле дружественных классов для проверки приватных инвариантов (Google Test использует именно этот приём).

Ловушки.

  • Объявление friend внутри класса — это только декларация. Функция всё равно должна быть определена где-то снаружи (исключение — friend-определение прямо внутри класса).

    • Тонкость: если определить функцию прямо в теле класса с ключевым словом friend, она станет inline и доступной только через ADL (argument-dependent lookup). Это удобно для коротких операторов, но скрывает реализацию в декларации и ухудшает читаемость больших функций.
    • Симптом забытого определения: «неразрешённый внешний символ» на этапе линковки — компилятор видит friend-декларацию, но реализации нигде нет.
  • friend от класса A к B не делает B другом A. Если нужна взаимность — прописывайте friend в обоих классах.

    • Проверка на экзамене: «класс Vec2 объявил другом Mat3. Может ли Vec2 обращаться к приватным полям Mat3?» Ответ — нет, нужно объявить friend и в Mat3. Это два независимых заявления.
  • Дружба не наследуется: наследник друга — не друг. Это часто путают с «другом семьи», который якобы передаётся детям.

    • Следствие: если Vec2 — друг Registry, а SpecialRegistry наследует Registry, то Vec2 всё равно не имеет доступа к приватным полям SpecialRegistry (если это явно не оговорено в SpecialRegistry). Каждый наследник — отдельная сущность с точки зрения прав доступа.
    • Что работает: сам наследник, как и любой метод, может объявить friend в своём теле — это легальный способ расширить дружбу.
  • Слишком много friend-ов — симптом плохого дизайна: возможно, стоит вынести часть логики в отдельный класс-помощник.

    • Практическое правило: если у класса больше 2–3 друзей, скорее всего, его интерфейс неправильно разложен и часть логики просится наружу в виде отдельной сущности (например, Vec2Adapter, который сам инкапсулирует дружбу и предоставляет публичный API остальному миру).
    • Признак «code smell»: при рефакторинге вам приходится перетаскивать всех друзей вместе с классом — значит, они должны были стать частью новой сущности.
  • friend нарушает «закон Деметры» (минимум знаний): внешний код знает о внутренностях класса. Это допустимая цена, но не норма; оправдывайте каждый friend.

    • Принцип: «говори только с друзьями», но не «заводи друзей с каждым встречным». Каждый friend увеличивает связность кода и усложняет рефакторинг: переименование поля «ломает» все файлы друзей.
    • Когда оправдано: формат вывода/ввода, итераторы, тесты, мост между двумя тесно связанными классами.

Экзаменационные тезисы.

  • friend даёт доступ к private/protected, не делая функцию членом класса. (Друг живёт «снаружи» и не влияет на публичный интерфейс класса.)
  • Дружба не наследуется, не транзитивна и не взаимна по умолчанию. (Три отдельных свойства, проверяйте каждое отдельно на экзамене — это любимая ловушка составителей.)
  • Канонический случай friend — перегрузка operator<< для потокового вывода. (Потому что левый операнд — std::ostream, а не ваш тип; методом класса его сделать нельзя.)
  • Злоупотребление friend разрушает инкапсуляцию, используйте его осознанно и редко. (Если друзей много — стоит пересмотреть архитектуру класса и вынести логику наружу.)

Специальные функции-члены

Специальные функции-члены — это набор операций над объектом (создание, копирование, перемещение, уничтожение, присваивание), которые компилятор C++ умеет генерировать самостоятельно, если программист их явно не написал. Простыми словами: это «встроенные кнопки» класса — Ctrl+C, Ctrl+V, Cut, Delete, New. У каждого класса есть шесть таких кнопок, и их совокупность называют «правилом пяти» (или «правилом трёх» в старом C++98). Понимание того, когда какая кнопка нажимается, — половина успеха на экзамене и 90% причин падений студенческих программ.

Аналогия из жизни. Представьте класс как «коробку с вещами». Специальные функции — это инструкции для робота-грузчика: что делать, когда коробку создают на конвейере (конструктор), когда её фотографируют и делают копию (копирование), когда её содержимое пересыпают в другую коробку (перемещение), когда коробку ставят на полку (присваивание) и когда коробку выбрасывают (деструктор). Если в коробке лежит общий ресурс — например, один ключ от квартиры на двоих — наивное копирование приведёт к тому, что ключ окажется в обеих коробках, и при «выбросе» одной из них второй останется не у дел. Ещё одна бытовая аналогия: специальные функции — это «инструкция по эксплуатации» вашего класса. Если вы её не написали, компилятор составил её за вас на основе общих правил. Но общие правила не учитывают нюансов вашего ресурса — поэтому для классов с владением писать инструкцию нужно самому.

У каждого класса есть набор операций, которые компилятор умеет генерировать сам: создать, скопировать, переместить, уничтожить. Их называют специальными функциями-членами. Половина ошибок в студенческом коде — из-за того, что автор не понимает, какая из них и когда вызывается. Запомните главное: если класс владеет ресурсом (памятью, файлом, мьютексом), наивно сгенерированные операции приведут к двойному освобождению или утечке. Практический совет: если ваш класс содержит «голый» указатель, дескриптор файла, идентификатор мьютекса или любой другой ресурс — значит, вы автоматически попадаете в зону действия «правила пяти» и должны явно описать все специальные функции.

Почему это важно. Компилятор не телепат: если вы вручную написали деструктор, он делает вывод, что класс управляет каким-то ресурсом, и перестаёт автоматически генерировать конструктор копии и оператор присваивания. Это так называемое «правило пяти» (подробнее ниже). Студенты часто удивляются: «Я же ничего плохого не делал, почему код перестал работать?» — именно потому, что вмешательство в одну операцию каскадно отключает остальные. Почему так устроено: исторически (C++98) действовало правило: «если программист написал деструктор, копирующий конструктор или operator= — значит, класс нестандартный, и компилятор не должен генерировать оставшиеся операции». В C++11 это расширили: пользовательское объявление копирующих операций теперь ещё и подавляет автоматическую генерацию перемещающих. Это сознательное архитектурное решение — компилятор «перестраховывается», предполагая, что наивная автогенерация сделает хуже.

Компилятор синтезирует шесть операций: конструктор по умолчанию, конструктор копии, конструктор перемещения, копирующее присваивание, перемещающее присваивание и деструктор. Знать, когда какая вызывается, — половина экзамена.

Экзаменационный тезис №1. Шесть специальных функций-членов в C++11 и выше:

  1. Конструктор по умолчанию (T()) — создаёт объект «без ничего».
  2. Деструктор (~T()) — вызывается при уничтожении объекта; должен освободить все принадлежащие ему ресурсы.
  3. Конструктор копии (T(const T&)) — создаёт новый объект как копию существующего; ожидается «глубокое» копирование.
  4. Копирующее присваивание (operator=(const T&)) — заменяет содержимое существующего объекта копией другого.
  5. Конструктор перемещения (T(T&&)) — создаёт новый объект, забирая ресурсы у временного объекта-источника.
  6. Перемещающее присваивание (operator=(T&&)) — забирает ресурсы у временного объекта в уже существующий.

Ловушка. Студенты часто путают инициализацию и присваивание. Строка Buffer b = a; — это инициализация (вызывается конструктор копии), а b = a; после того, как b уже создан — присваивание (вызывается operator=). Оператор = в C++ для пользовательских типов — это не математическое равенство, а присваивание, и компилятор перегружает его под ваш класс. Типичная ошибка на экзамене: студент видит = и думает «конструктор копии». Но в C++ синтаксис T x = y; всегда означает инициализацию (даже если справа стоит не литерал, а переменная), а синтаксис x = y; после объявления x — присваивание. Эта разница формально не зависит от типа и проверяется по фазе жизненного цикла объекта: до завершения конструктора — инициализация, после — присваивание.

class Buffer {
    char* data_ = nullptr;
    std::size_t size_ = 0;
public:
    Buffer() = default;                                  // 0. по умолчанию: data_ == nullptr
    explicit Buffer(std::size_t n)                        // 1. преобразование (explicit!)
        : data_(new char[n]), size_(n) {}                 //    explicit запрещает Buffer b = 10;
    Buffer(const Buffer& other)                           // 2. копия: своя память, глубокое копирование
        : data_(new char[other.size_]), size_(other.size_) {
        std::copy(other.data_, other.data_ + size_, data_);   // копируем содержимое
    }
    Buffer(Buffer&& other) noexcept                       // 3. перемещение: забираем буфер
        : data_(other.data_), size_(other.size_) {
        other.data_ = nullptr;                            // не владеем чужим буфером
        other.size_ = 0;                                  // источник остаётся валидным
    }
    Buffer& operator=(Buffer other) {                     // 4. присваивание (copy-and-swap)
        swap(other);                                      //    other — копия; обмен без утечек
        return *this;
    }
    ~Buffer() { delete[] data_; }                         // 5. деструктор: парен new[]
    void swap(Buffer& o) noexcept {
        std::swap(data_, o.data_);                        // только обмен указателей, без исключений
        std::swap(size_, o.size_);
    }
};

Пошаговый разбор примера.

  • Строка Buffer() = default; — конструктор по умолчанию. Слово =default означает «компилятор, сгенерируй сам». Поскольку поля класса имеют инициализаторы = nullptr и = 0, объект сразу после создания находится в валидном состоянии: указатель пуст, размер ноль. Это идиома default-initialization safety — «безопасное состояние по умолчанию». Зачем это важно: представьте, что в деструкторе стоит delete[] data_. Если конструктор по умолчанию оставит data_ неинициализированным (мусором), то первый же деструктор на свежесозданном объекте упадёт. Поэтому в любом классе с ресурсами либо инициализируйте поля при объявлении (как здесь), либо пишите тело конструктора. Экзаменационная подсказка: пустой конструктор Buffer() {} без = default компилятор считает пользовательским и тоже отключает автогенерацию копирующих/перемещающих операций (см. правило пяти). Если вам нужно «пустое тело» — используйте именно = default.

  • Строка explicit Buffer(std::size_t n) — конструктор от размера. Ключевое слово explicit запрещает неявное преобразование из std::size_t в Buffer. Без него компилятор разрешил бы странные вещи вроде Buffer b = 10; или передачу числа вместо буфера в функцию — и получил бы не тот объект, который вы ожидали. Студенческая ошибка: забыть explicit и потом долго ловить баг с неожиданным конструктором в цепочке вычислений. Когда ставить explicit: почти всегда на конструкторах с одним аргументом. Исключение — «прокси-типы» вроде std::string, для которых неявное преобразование из const char* осмысленно. Что под капотом: explicit блокирует только синтаксис копирующей инициализации (T x = value;) и неявные преобразования в параметрах функций; явный вызов T(value) или static_cast<T>(value) по-прежнему работает.

  • Конструктор копии Buffer(const Buffer& other) — создаёт новую независимую копию буфера. Здесь выделена отдельная память через new char[other.size_] и затем содержимое скопировано побайтово через std::copy. Без этого оба объекта указывали бы на один и тот же char*, и при уничтожении одного второй попытался бы освободить уже освобождённую память — классическая ошибка двойного delete, приводящая к undefined behavior. Типичная ошибка: написать «ленивый» конструктор копии как data_(other.data_), size_(other.size_) без new char[...] и std::copy. Программа скомпилируется, но при втором деструкторе случится UB. На экзамене это любимый вопрос: «почему этот код не работает на третьем объекте, хотя первые два в порядке?» — ответ: потому что деструкторы запускаются в обратном порядке и дважды освобождают одну память.

  • Конструктор перемещения Buffer(Buffer&& other) noexcept — самый экономный: вместо копирования содержимого мы просто забираем указатель у источника (data_(other.data_)), а источнику оставляем «пустоту» (nullptr, 0). Стоимость — два перемещения указателя и числа, независимо от размера буфера. Пометка noexcept говорит компилятору: «эта операция не выбросит исключение», что критично для производительности стандартных контейнеров (см. ниже). Что значит «источник остаётся валидным»: после перемещения other — это полноценный объект типа Buffer, у которого data_ == nullptr и size_ == 0. К нему можно применять operator=, деструктор, даже снова перемещать — он просто ведёт себя как «пустой» буфер. Это требование стандарта: перемещённый объект должен быть в «valid but unspecified state», что для нашего класса конкретно означает пустоту.

  • operator=(Buffer other) через copy-and-swap — здесь параметр other передаётся по значению, то есть при вызове a = b сначала создаётся копия b через конструктор копии. Затем в теле функции мы просто меняем внутренности *this и other местами. Когда функция завершится, локальный other (а на самом деле — бывший b) будет уничтожен и освободит старую память *this. Идиома решает сразу три задачи: защита от самоприсваивания (a = a безопасно, потому что копия уже сделана), строгая гарантия исключений (если копирование упадёт, *this не изменится), и отсутствие дублирования кода. Маленькая хитрость: обратите внимание, что operator= принимает Buffer other по значению — это значит, что перемещающее присваивание T&& компилятор сгенерирует автоматически из этой же сигнатуры (через привязку rvalue к параметру по значению). Поэтому здесь нет отдельного operator=(Buffer&&) — он просто не нужен. Минус copy-and-swap: одна лишняя копия (или перемещение) аргумента. Для дешёвых типов это незаметно, для очень тяжёлых — может быть существенно. Тогда пишут две отдельные версии оператора =.

  • Деструктор ~Buffer() — вызывает delete[] data_, потому что память была выделена через new char[n]. Не перепутайте: для new[] нужен delete[], для одиночного new — обычный delete. Смешивание — UB. Правило: «что выделено через new[], освобождается через delete[]; что через new — через delete». Что делать, если data_ == nullptr: стандарт гарантирует, что delete nullptr безопасен и ничего не делает. Поэтому в деструкторе не нужна защитная проверка if (data_). Практический совет: деструктор никогда не должен бросать исключений. Если внутри есть код, который может бросить (например, запись в лог-файл), оберните его в try { } catch(...) { } и подавите исключение — иначе во время раскрутки стека из-за другого исключения программа аварийно завершится через std::terminate.

  • Метод swap — обменивает указатели и размеры двух буферов за два вызова std::swap. Помечен noexcept, потому что std::swap для встроенных типов и указателей исключений не бросает. Это «кирпичик» для copy-and-swap. Зачем вообще свой swap: стандартный std::swap для пользовательских типов работает через три перемещения (создать копию, переместить в первый, переместить во второй, уничтожить копию), а наш — через три обмена указателями. Для большого буфера разница огромна. Кроме того, наличие swap — обязательное условие для идиомы copy-and-swap.

Когда вызывается конструктор копии: при инициализации объектом того же типа (Buffer b = a;), при передаче по значению (void f(Buffer)) и при возврате по значению без гарантированного copy elision. Обратите внимание: Buffer b = a; — это инициализация, а не присваивание, поэтому вызывается конструктор копии, а не operator=. Приём «copy-and-swap» одновременно решает self-assignment и даёт строгую гарантию исключений: если копирование упадёт, исходный объект не изменится. Тонкость про copy elision: начиная с C++17, в некоторых случаях компилятор обязан «устранить» копирование — например, при return Buffer{100}; из функции, возвращающей Buffer. Это называется guaranteed copy elision. В таком случае конструктор копии/перемещения вообще не вызывается, объект создаётся прямо «на месте». Поэтому в современных реализациях возврат локального объекта по значению практически бесплатен. Частая ошибка: думать, что NRVO (Named Return Value Optimization) — это оптимизация, на которую можно положиться. В C++11–C++14 NRVO разрешена, но не обязательна, поэтому для надёжности перемещающий конструктор всё равно должен быть написан.

Когда вызывается конструктор перемещения. В трёх ситуациях: (а) инициализация через std::move(x) или rvalue: Buffer b = std::move(a);, (б) передача по значению аргумента, который сам является rvalue: f(Buffer{100}), (в) возврат локального объекта по значению, если компилятор не применил copy elision. Студенческая ловушка: написать Buffer b = a; и думать, что сработает перемещение — нет, a это lvalue, будет копирование. Чтобы получить перемещение, нужен явный std::move(a) или временный объект. Что такое rvalue: это «временное» выражение, у которого нет имени. Литералы (42, Buffer{100}), результат std::move(x), результат вызова функции, возвращающей значение. Что такое lvalue: это «именованное» выражение, у которого есть адрес. Переменные, поля класса, результат *ptr. Тонкость: именованный локальный объект в области видимости — это lvalue, даже если переменная вот-вот выйдет из области видимости. Поэтому Buffer b = std::move(local_var); вызовет перемещение (потому что std::move явно превращает lvalue в rvalue), а без std::move — копирование.

Когда вызывается деструктор. Для каждого полностью построенного объекта — при выходе из области видимости ({ }), для статических объектов — при завершении программы, для объектов в new — при вызове delete. Деструктор также вызывается в процессе stack unwinding при раскрутке стека из-за исключения — поэтому деструкторы не должны бросать исключения. Важный нюанс: деструктор вызывается только для полностью построенных объектов. Если в теле конструктора вылетит исключение (после того как часть полей уже создана), деструктор самого объекта не запустится, но деструкторы уже сконструированных полей и базовых классов запустятся автоматически — это и есть «stack unwinding» на уровне полей. Типичная ошибка: «забыть delete для объекта, созданного через new» — утечка памяти. Или, что хуже, «дважды delete на тот же адрес» — UB, может проявиться через минуту работы программы или через час.

=default и =delete. Первое явно просит сгенерировать операцию, второе запрещает её. Зачем запрещать? Например, объект с мьютексом нельзя копировать: копия мьютекса бессмысленна. Удаление конструктора копии делает тип не копируемым, и ошибку поймает компилятор на этапе сборки, а не рантайм. Разница между «удалить» и «сделать приватным»: в старом C++ (до C++11) некопируемость делали через приватный конструктор копии и никогда его не определяли. Сейчас = delete лучше: даёт понятную диагностику компилятора, не зависит от уровня доступа, работает для любых специальных функций, включая нешаблонные методы и шаблоны (template<class T> void f(T) = delete;).

Разница между =default и ручным написанием. =default гарантирует, что компилятор сгенерирует «правильную» версию — с правильной сигнатурой, с noexcept там, где это положено по стандарту. Если вы напишете Buffer() {} руками, компилятор не пометит его автоматически нужными атрибутами, и вы потеряете оптимизации. Поэтому для тривиальных операций всегда предпочитайте = default, а пишите вручную только когда нужно изменить поведение. Конкретный пример: пустой деструктор ~T() {} — компилятор считает его пользовательским, отключает автогенерацию копирующих операций и лишает класс свойства «trivial destructor» (важно для placement new, memcpy, оптимизаций). А ~T() = default; сохраняет все эти свойства и говорит компилятору «я согласен с твоей версией».

=delete — это не «отключить», а «запретить». Любая попытка вызвать удалённую функцию — это ошибка компиляции с понятным сообщением, а не runtime-assert. Это один из принципов C++: «поймать ошибку как можно раньше», желательно до запуска программы. Антипаттерн: вместо = delete писать приватный конструктор без определения. Старый трюк из C++98, который сейчас считается устаревшим. Тонкость: можно делать = delete для функций, у которых есть сигнатура, но вы хотите запретить конкретную перегрузку. Например, void f(int) = delete; запретит вызов f(5), но f(5.0) будет работать через другую перегрузку — компилятор выдаст ошибку «use of deleted function» ещё на этапе компиляции.

struct NonCopyable {
    NonCopyable() = default;
    NonCopyable(const NonCopyable&) = delete;
    NonCopyable& operator=(const NonCopyable&) = delete;
};

Разбор примера. Структура NonCopyable может быть создана через NonCopyable x; (конструктор по умолчанию разрешён), но попытка NonCopyable y = x; или x = y; вызовет ошибку компиляции с диагностикой вроде «use of deleted function». Это стандартный паттерн RAII-обёрток: std::unique_ptr, std::thread, std::mutex именно так и устроены внутри — они некопируемы, потому что владеют ресурсом единолично. Что ещё стоит удалить: в реальных RAII-классах (unique_ptr, thread) удаляют ещё и перемещающее присваивание, но оставляют перемещающий конструктор — чтобы разрешить std::unique_ptr<T> p = std::make_unique<T>();, но запретить случайное копирование. Тонкость: удаление копирующего конструктора в C++11 автоматически не удаляет перемещающий конструктор — он продолжает генерироваться. Это позволяет делать «только-перемещаемые» типы вроде std::unique_ptr. Но если вы вручную напишете деструктор — перемещение перестанет генерироваться автоматически, и тип станет «только-копируемым» (что почти всегда плохо).

Экзаменационный тезис №2. Удаление конструктора копии каскадно не удаляет конструктор перемещения и наоборот, но если объявлен хотя бы один пользовательский конструктор копии/присваивания/деструктор, компилятор перестаёт генерировать перемещающие операции автоматически. Это деталь, на которой любят ловить студентов. Как это запомнить: правило такое — пользовательское объявление любой копирующей операции или деструктора подавляет автогенерацию перемещающих операций. Пользовательское объявление перемещающей операции подавляет автогенерацию копирующих. Удаление (= delete) считается объявлением, поэтому тоже подавляет. Что делать, если хочется «только-перемещаемый» тип: объявите копирующий конструктор = delete явно (а не полагайтесь на автогенерацию), и перемещающий сгенерируется сам. Или, что эквивалентно, напишите только перемещающий конструктор — копирующий будет подавлен.

Rule of 0/3/5. Если класс управляет ресурсом и определяет хотя бы один из деструктора, копирования или присваивания (правило трёх), нужны и перемещающие операции (правило пяти). Но лучше (правило нуля) не управлять ресурсом вручную вовсе: хранить std::string, std::vector, std::unique_ptr — тогда все шесть операций корректно сгенерируются сами. Почему rule of zero побеждает: каждое ручное написание специальной функции — это место для ошибки. Если вы доверили управление ресурсом стандартному классу (например, std::string сам распределяет память и сам её освобождает), то вам не нужно писать ничего — компилятор сгенерирует копирование через почленное копирование std::string, что приведёт к корректному глубокому копированию. Практический тест: если в вашем классе нет ни одного «голого» T*, FILE*, int (дескриптора), мьютекса и т.п. — вы в зоне rule of zero. Если есть хотя бы один такой ресурс — вы в зоне rule of five.

Простое определение «правила пяти». Если вы написали вручную любую из пяти специальных функций (деструктор, копирующий конструктор, копирующий присваивание, перемещающий конструктор, перемещающее присваивание), почти наверняка вам нужно явно написать или удалить (= delete) все остальные. Иначе компилятор сгенерирует недостающие, и они могут работать неправильно (например, деструктор освобождает память, а копия просто копирует указатель → двойной delete). Экзаменационный пример: «Дан класс с деструктором и без других специальных функций. Будет ли работать T a, b; a = b;?» — Ответ: компилятор сгенерирует копирующее присваивание, которое скопирует все поля почленно. Если среди полей есть указатель, оба объекта будут указывать на одну память, и в деструкторе случится двойной delete. UB.

Почему именно «пять», а не «шесть». Шестая специальная функция — конструктор по умолчанию. Его поведение не зависит от наличия ресурса, и если вам нужен конструктор без аргументов, его легко добавить отдельно (= default или с параметрами). Поэтому в классическое правило пяти он не входит. Дополнение: на самом деле конструктор по умолчанию тоже подавляется: если вы написали конструктор с параметрами, компилятор перестаёт генерировать конструктор по умолчанию. Поэтому иногда говорят «правило шести», включая конструктор по умолчанию, или отдельно правило: «объявил конструктор с параметрами — объяви и = default без параметров, если он нужен».

Правило трёх на пальцах. Если вы написали деструктор, значит у класса есть ресурс, который надо освобождать. Но тогда наивная копия приведёт к двойному delete — значит, нужен копирующий конструктор. А если нужен конструктор копии, нужен и operator=, иначе a = b сломает владение. Отсюда «три»: деструктор плюс копирующий конструктор плюс копирующее присваивание. С приходом move-семантики добавились перемещающие версии — это «пять». Аналогия для правила трёх: если вы сами моете посуду после вечеринки (деструктор), то гость, который захочет забрать ваш набор тарелок домой (копирование), должен получить свой собственный набор (глубокое копирование), а если он перепутает и заберёт ваши — будет конфликт (двойной delete).

Логическая цепочка «правила трёх». Шаг 1: «Я написал деструктор, потому что нужно освободить ресурс» → значит, класс владеет чем-то уникальным. Шаг 2: «Если я просто скопирую такой объект, оба будут указывать на один ресурс» → нужен конструктор копии с глубоким копированием. Шаг 3: «Если я напишу конструктор копии, но не operator=, то присваивание a = b снова приведёт к двойному владению» → нужен и оператор присваивания. Цепочка логически неизбежна, поэтому в C++98 это правило было «железным». Четвёртый шаг (C++11): после появления move-семантики та же логика требует ещё перемещающего конструктора и перемещающего присваивания. Итого пять. Пятый шаг (rule of zero): если не управлять ресурсом вручную, то и цепочка не запускается — все шесть операций генерируются автоматически и работают правильно.

Мнемоника: rule of five — «пять друзей класса». Деструктор, конструктор копии, копирующее присваивание, конструктор перемещения, перемещающее присваивание. Если хотя бы один из пяти написан вручную, остальные компилятор может «потерять» или сгенерировать неправильно — поэтому их объявляют явно (а перемещающие ещё и noexcept). Альтернатива — rule of zero: не владеть ресурсом напрямую, а положиться на std::string, std::vector, std::unique_ptr; тогда «друзей» вообще не нужно писать. Совет для запоминания: заучите порядок «друг копий — друг движений — друг разрушений»: ~T, T(const T&), T& operator=(const T&), T(T&&), T& operator=(T&&). Этот порядок удобно проверять на экзамене.

Аналогия для запоминания. Представьте пять друзей, которые всегда ходят вместе: заходишь в гости к одному — приходят все. Если кто-то из них «выбыл» (объявлен пользователем), остальные тоже должны явно подтвердить своё присутствие. Если друзья вам не нужны (нет ресурса) — просто не приглашайте никого (rule of zero). Более точная аналогия: представьте пять инспекторов, которые проверяют ваш класс в разных ситуациях. Если хотя бы один инспектор нашёл проблему (например, деструктор обнаружил, что нужно освободить память), остальные тоже должны прийти и проверить — а вдруг проблема проявится и при копировании, и при перемещении.

Каверзный вопрос на экзамене. «Что произойдёт, если определить только деструктор?» Ответ: компилятор перестанет генерировать перемещающие операции, а копирующие сгенерирует наивно (как почленное копирование) — и это почти наверняка сломает владение ресурсом. Это и есть причина, по которой правило пяти обязывает трогать всё сразу. Пошаговое объяснение для ответа на экзамене: (1) пользовательский деструктор подавляет автогенерацию перемещающих операций; (2) копирующие операции генерируются как почленные (поле за полем); (3) если среди полей есть указатель на ресурс — копия получит тот же указатель; (4) при выходе из области видимости обе копии запустят деструктор, каждая попытается освободить память → двойной delete → UB. Как защититься: написать T(const T&) = delete; и T& operator=(const T&) = delete; явно, если копирование не нужно. Или реализовать глубокое копирование, если нужно.

Почему noexcept у перемещения важен. Контейнер при росте (vector::reserve) выбирает между копированием и перемещением элементов: если конструктор перемещения помечен noexcept, элементы переносятся, иначе — копируются, чтобы сохранить строгую гарантию при исключении. Отсутствие noexcept делает рост контейнера медленнее, иногда драматически: vector<MyString> начнёт копировать строки вместо переноса указателей. Проверить можно статически: static_assert(std::is_nothrow_move_constructible_v<Buffer>);. Что такое is_nothrow_move_constructible_v: это переменная шаблона из <type_traits>, которая на этапе компиляции проверяет, что у типа Buffer есть конструктор перемещения, помеченный noexcept. Если проверка не прошла — static_assert вызовет ошибку компиляции с понятной диагностикой. Дополнительно: есть ещё std::is_nothrow_move_assignable_v<T> — проверяет noexcept у перемещающего присваивания. Обе проверки стоит использовать в критичном коде.

Глубокое объяснение про noexcept и std::vector. Когда std::vector исчерпывает ёмкость и ему нужно переехать в новый массив, он для каждого элемента должен решить: переместить или скопировать. Логика такая: «Если перемещение может бросить исключение, я не буду рисковать и скопирую — зато если перемещение упадёт на середине, старые элементы останутся целыми и я смогу восстановить инвариант». Для больших объектов (например, std::string с длинным текстом) разница между копированием и перемещением может быть в десятки раз. Без noexcept вы платите эту цену каждый раз при push_back/emplace_back. Где ещё noexcept критичен: при сортировке std::sort — алгоритм может выбрать более быструю реализацию (например, introsort вместо merge sort), если перемещение гарантированно не бросит; при использовании std::swap для вашего типа в обобщённом коде; при выборе между std::move_if_noexcept и обычным std::move (используется в std::vector::emplace_back и других контейнерах). Практическое правило: если ваш конструктор перемещения состоит только из перемещения указателей/чисел/RAII-обёрток — он noexcept по определению. Пишите noexcept всегда, когда это так. Не пишите noexcept, если внутри есть, например, выделение памяти через new — оно может бросить std::bad_alloc. Аналогия. Перемещение — это как переезд: вы вытаскиваете вещи из старой квартиры и складываете в новую (дёшево, потому что сами вещи не дублируются — только перекладываются). Копирование — это как заказать грузовик, который сначала купит дубликаты всех ваших вещей в магазине и перевезёт их (дорого, потому что нужно сначала потратиться на копии). Если при переезде возможна авария (исключение), а у вас нет страховки (noexcept), логичнее сначала сделать дубликаты (копию), а потом перевозить — на случай если что-то пойдёт не так, у вас останется оригинал.

Экзаменационный тезис №3. Любая перемещающая операция, которая реально переносит указатель/handle на ресурс (а не копирует его), должна быть помечена noexcept. Иначе стандартные контейнеры будут вынуждены использовать медленное копирование вместо быстрого перемещения. Это одна из самых частых «скрытых» потерь производительности в студенческом коде.

Почему это критично для контейнеров (например, std::vector)? При добавлении элемента, когда старый буфер уже заполнен, vector должен перебросить все элементы в новый буфер большего размера. На этом этапе для каждого элемента он выбирает: переместить (быстро) или скопировать (медленно). Решение принимается на основе двух факторов:

  1. Доступен ли noexcept-перемещающий конструктор у типа элемента.
  2. Применяется ли во время перемещения Copy-on-Write, exception-safety или другие факторы, делающие копирование безопаснее.

Если перемещающий конструктор не помечен noexcept, стандартная библиотека (начиная с C++11) в целях гарантии исключительной безопасности выбирает копирование — потому что при перемещении исходный объект может остаться в «полусломанном» состоянии, а при копировании либо всё получилось, либо исходник не тронут. Это и есть та самая «скрытая» потеря скорости: вы пишете std::move, а компилятор всё равно делает копию.

Простая проверка на экзамене. Если видите в своём классе:

  • сырой указатель (T* ptr_), владеющий ресурсом;
  • деструктор, освобождающий память;
  • конструктор копирования / оператор присваивания;

то автоматически добавьте Правило пяти: деструктор, копирующий конструктор, копирующий оператор присваивания, перемещающий конструктор, перемещающий оператор присваивания. И перемещающие операции должны быть помечены noexcept. Это не рекомендация — это требование корректного современного C++.

Типичные ошибки студентов.

  1. «Забыл noexcept». Перемещающий конструктор написан правильно, но без noexcept. Результат: вектор всё равно копирует, профит от перемещения теряется. Найти такую ошибку в чужом коде — типичная задача на защите лабораторных.

  2. «noexcept на копирующих операциях». Иногда студенты по привычке лепят noexcept на всё подряд. Помните: копирующие операции могут бросать исключения (например, при аллокации памяти), поэтому noexcept на них бессмысленен и даже вреден — он не сработает как маркер для оптимизаций и введёт компилятор в заблуждение.

  3. «Перемещающий конструктор, который на самом деле копирует». Если внутри MyClass(MyClass&& other) вы вместо ptr_ = other.ptr_; other.ptr_ = nullptr; написали ptr_ = new int(*other.ptr_); — это не перемещение, а маскирующееся копирование. На экзамене это сразу «красный флаг», потому что лишает смысла саму операцию перемещения.

  4. «Думал, что std::move сам что-то делает». std::move — это не функция перемещения, а всего лишь каст к rvalue-ссылке. Он лишь разрешает компилятору вызвать перемещающий конструктор/оператор. Если перемещающих операций у типа нет, произойдёт копирование — точно так же, как если бы вы вызвали std::move без него.

Практическая подсказка для экзамена. Когда вас просят написать класс, владеющий ресурсом (например, динамической строкой или умным указателем старого типа), перед сдачей пробегитесь по чек-листу:

  • Деструктор? → Нужен.
  • Копирующий конструктор и оператор присваивания? → Нужны.
  • Перемещающий конструктор и оператор присваивания? → Нужны и обязательно noexcept.
  • После перемещения исходный объект находится в валидном, но неопределённом состоянии (например, ptr_ = nullptr;)? → Проверьте.
  • Самоприсваивание обрабатывается корректно? → if (this != &other).

Связь с другими темами экзамена.

  • RAII и умные указатели: std::unique_ptr уже реализует Правило пяти идеально — его перемещение noexcept, а копирование запрещено. Это эталон для ваших собственных классов.
  • std::move и std::forward: понимание noexcept обязательно для объяснения, почему std::move_if_noexcept существует и где он применяется (например, в std::vector::emplace_back при выборе между перемещением и копированием).
  • noexcept-спецификатор vs noexcept-оператор: не путайте — первое обещает компилятору «исключений не будет» (влияет на оптимизации и выбор move/copy в контейнерах), второе проверяет это свойство во время компиляции (noexcept(expr)).

Перегрузка операторов

Определение. Перегрузка операторов — это механизм языка C++, позволяющий задать собственную реализацию встроенных операторов (+, -, *, <<, [] и т. д.) для пользовательских типов (классов и перечислений). Технически это объявление функции со специальным именем вида operator@, и компилятор вызывает её всякий раз, когда встречает знак @ в выражении, где хотя бы один операнд имеет тип вашего класса. Это не магия и не «переопределение поведения процессора»: компилятор просто подставляет вызов функции в точке, где вы написали оператор.

Зачем вообще это нужно? Встроенные операторы в C++ умеют работать только со встроенными типами (int, double, указателями). Для типов вроде std::string, std::complex, вашего собственного Complex или BigInteger логика «сложения», «сравнения», «печати» должна быть определена вручную — именно это и делает перегрузка. Без неё пришлось бы писать a.add(b) вместо a + b, и код стал бы похож на Java с её обязательными методами.

Перегрузка операторов — это механизм C++, позволяющий задать собственное поведение встроенных операторов (+, -, *, <<, [] и т.д.) для пользовательских типов. По сути, вы объявляете функцию с именем operator@, и компилятор вызывает её, когда видит знак @ в выражении с участием вашего класса.

Перегрузка операторов позволяет писать a + b вместо a.add(b) — код становится ближе к математике. Но это не «магия»: оператор — обычная функция с особым именем (operator+), и компилятор вызывает её, когда видит соответствующий знак. На экзамене эту тему проверяют задачей: «напишите класс комплексного числа и перегрузите арифметику».

Аналогия из жизни. Представьте, что вы учите иностранца: «символ + для чисел означает обычное сложение, а для нашего класса Complex — сложение комплексных чисел по формуле (a+bi)+(c+di)=(a+c)+(b+d)i». Компилятор делает то же самое: встретив знак, он ищет подходящую функцию operator+ по обычным правилам overload resolution.

Почему это вообще возможно? В C++ запись a + b для пользовательского типа — это синтаксический сахар: компилятор превращает её в вызов operator+(a, b) (для свободной функции) или a.operator+(b) (для метода). Никакой магии нет — просто поиск функции по имени и типам аргументов, как для любой перегруженной функции.

Экзаменационный тезис №1. Перегрузка — это не создание новых операторов и не изменение их приоритета или арности. Это просто функция с «хитрым» именем operator@, вызываемая в точке, где в коде встретился знак @.

Экзаменационный тезис №2. Минимум один операнд бинарного оператора должен иметь пользовательский тип. Нельзя переопределить int + int — только операции с участием вашего класса. Иначе правила языка были бы сломаны.

Как компилятор ищет подходящий оператор? (это важно для понимания friend-версий). При виде a + b компилятор выполняет поиск имени (name lookup) в несколько фаз:

  1. Поиск среди членов класса левого операнда (a.operator+).
  2. Обычный неквалифицированный поиск в текущей области видимости.
  3. ADL (Argument-Dependent Lookup, поиск по Кёнигу) — компилятор заглядывает в пространства имён всех классов-аргументов. Именно поэтому operator*, объявленный как friend внутри class Complex, находится без using и без явного std::: ADL «видит» аргумент типа Complex и подключает его область видимости.
  4. Из найденных кандидатов выбирается лучшее соответствие по правилам overload resolution; при равенстве — ошибка неоднозначности.

Аналогия. Представьте, что вы спрашиваете у прохожих дорогу. Сначала вы спрашиваете тех, кого знаете лично (методы класса). Потом — тех, кто стоит рядом (обычный поиск). Потом — тех, кто связан с вашими спутниками (ADL). Из всех, кто что-то подсказал, выбираете самый точный ответ.

Член класса или функция-друг

Главный вопрос при перегрузке — где объявить оператор: внутри класса или отдельной функцией. Правило простое: посмотрите на левый операнд. Если это всегда ваш объект — можно метод; если слева бывает чужой тип (поток, число) — нужна свободная функция или друг.

Аналогия из жизни. Метод класса — это личный помощник, у которого всегда есть ключ от вашего кабинета (this). Свободная функция — посредник на рынке: ему явно передают оба товара, зато он может работать с любыми продавцами, в том числе с чужими (с потоками, с числами, с чужими классами).

  • Оператор как метод получает this левым операндом — годится, когда левый операнд всегда объект класса.
  • Оператор как функция-друг (или свободная) нужен, если левый операнд — не наш тип (std::ostream, число) или если требуется симметричное неявное преобразование обеих сторон.

Решающее дерево «куда объявить оператор?»:

  1. Левый операнд — НЕ ваш тип (std::ostream, std::istream, int, double, чужой класс)? → Только свободная функция или friend. Иначе чужой тип просто «не умеет» вызывать ваш метод — у литерала 2.0 нет метода operator+(Complex).
  2. Левый операнд — ваш тип, и нужна симметрия (чтобы 2.0 + c работало так же, как c + 2.0)? → Свободная функция (с friend, если нужен доступ к приватным полям). Неявное преобразование при вызове метода идёт только справа.
  3. Левый операнд — всегда ваш тип, симметрия не важна? → Метод класса: он компактнее (один аргумент вместо двух) и автоматически имеет доступ к this.

Мнемоника. Запомните фразу: «слева чужой — делай другом». Если левый операнд чужой (поток, число, другой класс), без friend/свободной функции не обойтись. Во всех остальных случаях метод проще и естественнее.

Типичная ловушка №1. Если operator+ сделан методом Complex, выражение 2.0 + c не скомпилируется: у литерала double нет метода operator+(Complex). Решение — свободная функция operator+(double, Complex) либо неявный конструктор Complex(double) плюс симметричный оператор, который пользуется ADL.

Типичная ловушка №2. Не путайте friend operator* с членом класса. friend-функция — это свободная функция, которой разрешён доступ к приватным полям. Она не становится методом, у неё нет this. Это важно для понимания overload resolution и того, какую функцию выберет компилятор.

Типичная ловушка №3. Если вы не объявили копирующий operator=, компилятор сгенерирует его автоматически (почленное копирование). Для классов с указателями или RAII-ресурсами это источник двойного удаления. Помните про правило трёх/пяти: если объявили деструктор, копирующий конструктор или копирующий operator= — скорее всего, нужно объявить все три (а в современном C++ ещё и перемещающие версии).

class Complex {
    double re_ = 0, im_ = 0;
public:
    Complex(double re = 0, double im = 0) : re_(re), im_(im) {}

    Complex& operator+=(const Complex& o) {               // метод: левый — Complex
        re_ += o.re_; im_ += o.im_; return *this;
    }
    Complex operator+(const Complex& o) const {           // бинарный, возвращаем новый
        return Complex(re_ + o.re_, im_ + o.im_);
    }
    Complex& operator++() { ++re_; return *this; }        // префикс: ссылка, без копии
    Complex  operator++(int) {                            // постфикс: dummy-параметр int
        Complex old = *this; ++*this; return old;
    }
    double operator[](int i) const { return i == 0 ? re_ : im_; }
    bool operator==(const Complex& o) const { return re_ == o.re_ && im_ == o.im_; }
    friend Complex operator*(const Complex& a, const Complex& b);
    friend std::ostream& operator<<(std::ostream& os, const Complex& c);
};

Complex operator*(const Complex& a, const Complex& b) {
    return Complex(a.re_ * b.re_ - a.im_ * b.im_, a.re_ * b.im_ + a.im_ * b.re_);
}
std::ostream& operator<<(std::ostream& os, const Complex& c) {
    return os << c.re_ << (c.im_ < 0 ? '-' : '+') << std::abs(c.im_) << 'i';
}

Разбор операторов из примера (строка за строкой):

Оператор Вид функции Возвращает Зачем именно так
operator+= метод Complex& Изменяет *this и возвращает ссылку — чтобы работала цепочка a += b += c; результат ссылается на уже существующий объект, копировать нечего
operator+ метод, const Complex (по значению) Возвращает новый объект, старые не меняет; const гарантирует, что левый аргумент не изменится
operator++() (префикс) метод Complex& Сначала ++, затем возврат нового значения; ссылка — без лишней копии
operator++(int) (постфикс) метод Complex (по значению) Сохраняем копию, инкрементируем оригинал, возвращаем старую копию; фиктивный int отличает от префикса
operator[] метод, const double Доступ по индексу: 0 — вещественная, 1 — мнимая часть; const разрешает чтение из const Complex
operator== метод, const bool Поэлементное сравнение; const, чтобы работал и для констант
operator* свободная friend Complex Доступ к приватным полям двух объектов; не метод, поэтому симметрична
operator<< свободная friend std::ostream& Левый операнд — поток, поэтому только свободная; ссылка нужна для цепочки cout << a << b

Почему operator+= возвращает Complex&, а operator+ — Complex (по значению)? Потому что += изменяет сам левый операнд и должен быть частью цепочек (a += b += c), а + создаёт новый объект, оставляя исходные без изменений. Возврат по ссылке там, где нового объекта нет, экономит копирование и делает возможным цепочное присваивание.

Почему у постфиксного ++ фиктивный параметр int? Формально префиксный и постфиксный ++ имеют одинаковую сигнатуру по операндам (оба принимают один «параметр» — this). Чтобы их различать, стандарт требует у постфиксной формы фиктивный параметр типа int (его значение никому не интересно, важен только сам тип). Это «синтаксический костыль» ради обратной совместимости с C, где ++ исторически работает именно так.

Почему operator== помечен const? Потому что сравнение по определению не должно менять ни левый, ни правый аргумент. Пометка const гарантирует это на уровне типов: вызвать operator== можно и на const Complex, и на неконстанте, а вот внутри функции нельзя случайно изменить поля. То же касается operator[], operator+, operator*, operator<< (для правого аргумента) — всё, что логически «только читает», должно быть const-методом.

Почему operator<< возвращает именно std::ostream&, а не std::ostream? Если вернуть std::ostream по значению, это будет временный объект, который живёт до конца полного выражения. В цепочке cout << a << b второй << применился бы уже к этому временному, и его результат никто не сохранил бы — вывод оборвался бы после первого <<. Ссылка же указывает на тот же cout, что и исходный объект, поэтому цепочка работает корректно.

Ловушка с постфиксным ++. Реализация постфиксной формы делает две операции: сохраняет копию и инкрементирует оригинал. Для сложных объектов это лишние затраты — поэтому в циклах по возможности пишите ++it, а не it++ (разница заметна для итераторов контейнеров вроде std::vector).

Ловушка с operator[]. В примере объявлен только const-вариант. Это значит, что код Complex c; c[0] = 1.0; не скомпилируется: нет неконстантного operator[] для записи. В реальных классах объявляют две перегрузки: const T& operator[](size_t) const (для чтения из констант) и T& operator[](size_t) (для записи и чтения из неконстант). То же касается пар begin()/cbegin(), at() и т.д.

Ловушка с operator<<. Возврат ссылки на поток обязателен — иначе цепочка cout << a << b запишет b во временный объект, а не в cout, и вывод оборвётся после первого <<. Поэтому operator<< почти всегда возвращает std::ostream&, а не std::ostream.

Ловушка с самоприсваиванием в operator=. В показанном классе Complex нет указателей, поэтому автоматически сгенерированный operator= безопасен. Но в классе с динамической памятью (char*, std::unique_ptr) оператор a = a (или arr[i] = arr[j] при совпадении индексов) должен либо проверять this != &other, либо использовать идиому copy-and-swap. Без этого вы сначала освободите свои ресурсы, а потом будете читать из уже освобождённой памяти — классический use-after-free.

Ловушка с неоднозначностью перегрузки. Если у вас есть и operator+(Complex, Complex), и operator+(double, Complex) (через неявный конструктор Complex(double)), то выражение 2.0 + 3.0 станет неоднозначным между double + double (встроенный) и двумя вызовами operator+ с неявным преобразованием. Компилятор выберет встроенный вариант (он «точнее»), но если оба аргумента — Complex-совместимые — может возникнуть сюрприз. Решение: помечайте конструктор explicit (C++11+), если не хотите неявных преобразований.

Что нельзя перегружать: ., ::, ?:, sizeof, typeid, . и .*; нельзя менять арность и приоритет. =, [], (), -> обязаны быть методами. << и >> для потоков — только свободными. Почему так? Потому что перегрузка не должна менять синтаксис языка: sizeof, ?: и . завязаны на встроенные правила, и разрешить их переопределять значило бы сломать предсказуемость кода. А operator= и operator[] обязаны быть методами, потому что компилятор должен иметь доступ к this без участия ADL.

Экзаменационные тезисы по правилам перегрузки:

  • Нельзя изобретать новые операторы (**, :=, <> и т.п.) — только переопределять существующие знаки.
  • Нельзя менять приоритет и ассоциативность: * всегда сильнее +, что бы вы ни перегружали; иначе рухнул бы весь привычный код.
  • Нельзя перегружать операторы доступа к членам (., .*, ::, ->*) — они завязаны на систему имён и типов, и их переопределение сделало бы код непредсказуемым.
  • sizeof, typeid, тернарный ?: не перегружаются: они работают на этапе компиляции и завязаны на встроенные правила, которые пользователю менять нельзя.
  • operator-> обязан либо возвращать указатель (в том числе «умный»), либо возвращать объект с собственным operator-> — иначе цепочки it->method()->next() просто не скомпилируются.
  • Оператор преобразования типа (operator double()) не имеет типа возврата в объявлении — тип указан прямо в имени (operator T()). Это часто путают на экзамене. С C++11 такой оператор можно пометить explicit, чтобы запретить неявные преобразования (как у explicit конструкторов).
  • Минимум один операнд бинарного оператора должен быть пользовательского типа — иначе перегрузка просто не применится, и компилятор возьмёт встроенный вариант.
  • Нельзя перегрузить оператор так, чтобы у него появился/пропал операнд: == всегда бинарный, ++ всегда унарный, иначе сломается парсинг выражений.
  • В C++20 появился оператор «космический корабль» <=> (three-way comparison), который генерирует все шесть операторов сравнения; его перегрузка имеет свои правила, но это уже отдельная большая тема.
  • noexcept для операторов — не магия, а контракт: например, operator= разрушающего присваивания (move-assignment) логично пометить noexcept, чтобы std::vector мог выбирать оптимальный путь перемещения.

Памятка-таблица «где объявлять оператор»:

Оператор Рекомендуемое место Причина
+, -, *, /, % Свободная функция Симметрия; неявное преобразование должно работать для обоих аргументов
+=, -=, *=, /=, %=, &=, ` =, ^=, <<=, >>=` Метод класса
==, !=, <, >, <=, >=, <=> (C++20) Свободная функция или friend Симметрия; нужны оба аргумента как равноправные
<<, >> (потоки) Только свободная функция Левый операнд — std::ostream/std::istream, чужой тип
[] Только метод Синтаксически требует this; без него не получить доступ к «внутренностям»
(), -> Только метод Синтаксически требуют this
Преобразование типа (operator T()) Только метод Требует this; синтаксически нельзя объявить как свободную функцию
++, -- Метод класса Левый операнд — наш объект, есть this; свободная функция не имела бы к нему доступа
= (присваивание) Только метод Требует this; иначе невозможна цепочка (a = b) = c
new, delete Свободная или статический метод Особые правила; не имеют this
!, &&, ` `

Финальная шпаргалка-аббревиатура «МСАП» (метод-свободный, аргумент-приоритет):

  • Метод — если левый операнд всегда наш (+=, -=, [], (), ->, ++, --, =, приведение типа).
  • Свободная функция — если левый операнд чужой (<<, >> для потоков) или нужна симметрия (+, -, *, /, ==, < и т.п.).
  • Арность неизменна: унарный оператор нельзя сделать бинарным и наоборот.
  • Приоритет неизменен: * всегда сильнее +, даже если вы перегрузили оба.

На экзамене почти всегда дают задачу «напишите класс комплексного (или матричного, или денежного) числа и перегрузите арифметику + потоковый вывод». Сначала объявите operator+= (методом), затем operator+ через += (свободной функцией), затем operator<< (friend). Это покрывает 90% требований и сразу демонстрирует понимание, где какой оператор должен жить.

Ловушка с operator=. Возврат *this по ссылке обязателен — иначе цепочка (a = b) = c запишет c во временный объект, а переменная a останется равной b. Поэтому operator= всегда возвращает T&, а не T. Это же правило относится к составным присваиваниям (+=, -= и т.д.).

Ловушка с самоприсваиванием. В реализации operator= первая строка почти всегда — проверка if (this != &other). Без неё в классе с динамической памятью вы сначала освободите собственные ресурсы, а потом попытаетесь скопировать уже освобождённые — получите двойное удаление или копирование «мусора». Эта ошибка — классика экзамена по RAII.

Ловушка с перегрузкой && и ||. Если вы перегрузите эти операторы как методы, потеряется встроенная семантика короткого замыкания: компилятор будет вынужден вычислить оба аргумента до вызова вашего метода, чтобы передать их как параметры. Для пользовательских типов это редко то, что нужно, — в большинстве случаев лучше дать осмысленные имена методам (push_all, merge и т.п.).

Ловушка с operator&&/operator|| и встроенными операторами. Для встроенных типов && и || имеют гарантированную семантику короткого замыкания (a && b не вычисляет b, если a уже false). Это правило не действует для перегруженных версий — ещё одна причина не злоупотреблять их перегрузкой.

Правила best matching

Перегрузка функций (function overloading) — это механизм C++, позволяющий объявить несколько функций с одинаковым именем, но с разным набором параметров (по типам и/или по количеству). Когда компилятор встречает вызов такой функции, ему нужно выбрать конкретную реализацию — и именно этим занимается разрешение перегрузки.

Разрешение перегрузки (overload resolution) — процесс, в ходе которого компилятор выбирает одну наиболее подходящую функцию из набора перегруженных вариантов по типам фактических аргументов.

Аналогия из жизни. Представьте, что вы звоните в поликлинику и говорите «мне нужен доктор». Регистратура (компилятор) ищет подходящего врача среди всех доступных: терапевта, окулиста, хирурга. Сначала она собирает список всех врачей с именем «Доктор» (кандидаты), затем проверяет, кто из них может принять пациента с вашими симптомами (пригодные), и наконец выбирает того, чья специализация наиболее точно совпадает с жалобой (лучшая). Если два врача одинаково подходят (например, терапевт и хирург при лёгкой простуде) — регистратура откажет в записи и попросит уточнить, а не станет выбирать наугад. Так же ведёт себя и C++.

Когда функций с одним именем несколько, компилятор должен выбрать одну — этот процесс называют разрешением перегрузки (overload resolution). Понимание трёх шагов объясняет, почему иногда вызывается «не та» функция и откуда берётся ошибка «ambiguous call». Это любимый вопрос МГУ: просят перечислить шаги и назвать порядок предпочтений.

Почему это важно знать «наизусть». Разрешение перегрузки происходит при каждом вызове функции с перегруженным именем. Непонимание его правил ведёт к трудноуловимым багам — компиляция успешна, но вызывается не та функция, которую вы имели в виду. Поэтому правила разрешения перегрузки — это фундамент, без которого невозможно уверенно читать и писать C++.

Компилятор выбирает перегрузку в три шага и на каждом отбирает кандидатов:

  1. Отбор кандидатов (candidate functions) — все функции с таким именем, видимые в точке вызова (объявленные в текущей области видимости, во включающих пространствах имён и, при отсутствии using-директив, в глобальной области).
  2. Отбор пригодных (viable functions) — из кандидатов остаются лишь те, к чьим параметрам фактические аргументы можно преобразовать хотя бы одним способом. Если ни одна функция не пригодна, компилятор сообщает об ошибке «no matching function for call».
  3. Выбор лучшей (best match) — среди пригодных компилятор ищет ту, чья последовательность преобразований аргументов «дешевле» остальных. Именно здесь работает иерархия предпочтений.

При прочих равных побеждает нешаблонная функция над специализацией шаблона, а специализация — над общим шаблоном. Неоднозначность даёт ошибку компиляции, а не «случайный» выбор.

Иерархия предпочтений (от лучшей последовательности преобразований к худшей):

Категория Что означает Пример
Точное совпадение Никаких преобразований (включая identity, добавление const, ссылочные свёртки) int → int
Повышающее преобразование (promotion) «Расширение» по правилам языка char/short → int, float → double, nullptr_t → T*
Стандартное преобразование (standard conversion) Любое другое встроенное преобразование int → double, double → int (сужающее)
Пользовательское преобразование (user-defined) Сработал operator Type() или конструктор Type(...) MyString → bool

Ловушка. Даже если «логически» функция f(int) кажется вам подходящей лучше всего, компилятор сравнивает только стоимость последовательностей преобразований по каждому аргументу. Если две функции требуют одинаковой «суммы» преобразований — это неоднозначность. Никакой эвристики «более естественная функция» не существует.

void f(int);            // точное совпадение: f(42) → сюда
void f(double);
void f(long);
// f(42) — int выигрывает; f(42.0f) вызовет неоднозначность float→int/double

В примере f(42.0f) даёт ошибку: float одинаково «далеко» и от int, и от double (оба — стандартное преобразование), поэтому лучшей перегрузки нет. Как только появляется неоднозначность, компилятор обязан сообщить об ошибке, а не выбирать наугад — на это и опирается предсказуемость C++. Если хотите явно указать вариант, используйте приведение: f(static_cast<double>(42.0f));.

Экзаменационный тезис. Неоднозначность (ambiguous call) — это всегда ошибка компиляции, а не предупреждение. C++ принципиально не делает «неявного выбора», когда варианты равнозначны по стоимости. Это гарантирует, что код ведёт себя ровно так, как описано в исходнике, без скрытых решений компилятора.

Частая ловушка студентов. Путают «promotion» и «standard conversion». Например, char → int — это promotion (дешевле), а int → double — это standard conversion (дороже). Поэтому при вызове f('A') функция f(int) выигрывает у f(double), несмотря на то что char «содержит меньше бит», чем double. А вот f(42L) при наличии f(int) и f(long) выберет f(long) (точное совпадение лучше сужающего преобразования long → int).

Операторы преобразования

Оператор преобразования (conversion operator) — это особый метод класса, который позволяет объекту этого класса неявно или явно приводиться к другому типу. Синтаксис объявления: operator Тип() [const]. Объявляется без указания возвращаемого типа (тип возврата совпадает с именем оператора).

Аналогия из жизни. Представьте, что у вас в кармане лежит банковская карта. В магазине вы говорите «дайте мне кофе», и кассир принимает оплату, потому что карта «умеет» представлять деньги. Если бы карта могла неявно становиться, скажем, паспортом или водительскими правами, было бы странно и опасно — отсюда и необходимость explicit, чтобы превращение происходило только в осмысленных контекстах.

Оператор преобразования позволяет объекту «прикинуться» другим типом, например спросить if (flag) у класса-флага. Это удобно, но опасно: неявное преобразование может неожиданно выбрать чужую перегрузку и запутать читателя. Поэтому современный стиль — помечать такие операторы explicit.

Почему неявные преобразования опасны. Компилятор может «сцепить» несколько пользовательских преобразований в цепочку (хотя стандарт прямо запрещает цепочку из двух пользовательских преобразований подряд, но обходные пути существуют), и результат становится трудно предсказуемым. Классический баг — std::shared_ptr без explicit operator bool() мог бы участвовать в арифметике указателей и числовых сравнениях, превращаясь в bool там, где это не задумано, и порождать абсурдные ситуации вроде «сложения двух указателей как чисел».

Пользовательское преобразование объявляется как operator Type(). Его опасность — неявные цепочки; в C++11+ пишут explicit, чтобы преобразование срабатывало только при явном приведении или в контексте if/while:

class Flag {
    bool set_ = false;
public:
    explicit operator bool() const { return set_; }   // только явно
};

Flag f;
if (f) { /* OK: контекст bool */ }
// bool b = f;                                     // ошибка: нужен explicit

Где explicit пропускает преобразование (так называемый «bool-контекст»):

Контекст Пример Допустимо?
Условие if/while/for if (f) { … } Да
Тернарный оператор bool x = f ? 1 : 0; Да
Логические операторы !f, f && x, f || x Да
Явное приведение static_cast<bool>(f) Да
Оператор noexcept noexcept(f) (внутри выражения) Да

Где explicit блокирует преобразование:

Контекст Пример Допустимо?
Копирующая инициализация bool b = f; Нет — ошибка
Прямая инициализация (T x(expr);) bool b(f); Нет — ошибка
Арифметические операции int n = ptr + 1; Нет — ошибка
Неявное приведение к int через bool int n = f; (двойное неявное) Нет — ошибка

Правило: если оператор преобразования может привести к неожиданному выбору перегрузки — делайте его explicit. Классический пример — умные указатели: explicit operator bool() разрешает if (ptr) и !ptr, но запрещает int n = ptr;. Без explicit два указателя могли бы «сложиться» как числа, что абсурдно. Современный стиль C++ почти всегда требует explicit для конструкторов с одним аргументом и для операторов преобразования.

Экзаменационный тезис. Помните: explicit у оператора преобразования блокирует только неявные преобразования, но НЕ блокирует использование в логическом контексте (if, while, !, &&, ||, тернарный оператор, noexcept(expr)). Это сделано специально для идиомы «Safe Bool» в умных указателях и обёртках ресурсов: проверка «объект валиден?» должна работать без static_cast.

Ловушка. operator bool() без explicit может привести к участию объекта в арифметических операциях (так как bool неявно конвертируется в int), к неявным сравнениям с числами, к ошибочному выбору перегрузки (например, при наличии двух перегрузок f(int) и f(MyClass) — компилятор может выбрать неожиданную). Всегда предпочитайте explicit, если только вы не пишете тип-обёртку для совместимости со старым C-кодом или не реализуете идиому, где неявное преобразование семантически оправдано (что бывает крайне редко).

Наследование и полиморфизм

Наследование — это механизм, позволяющий объявить новый тип на базе уже существующего и автоматически получить все его поля и методы. Простое определение: наследование — это способ сказать компилятору «возьми всё, что уже есть в классе A, и положи это в основу нового класса B, который я сейчас опишу». B при этом называется наследником (или производным классом, подклассом), а A — базовым классом (или родителем, суперклассом). В C++ отношение «B наследник A» записывается как class B : public A.

Полиморфизм — это свойство кода работать с объектами разных конкретных типов через указатель или ссылку на общий базовый тип, вызывая «правильную» реализацию метода во время выполнения программы. Простое определение: полиморфизм — это когда один и тот же вызов (s.area()) делает разные вещи в зависимости от того, какой реально объект стоит за s (круг, квадрат, треугольник). Само слово переводится как «много форм» — один интерфейс, много реализаций.

Простая аналогия из жизни: наследование — это как генеалогическое древо, где ребёнок наследует черты родителя (цвет глаз, группу крови), но может добавлять свои (талант к музыке, имя собственное); полиморфизм — это как единый пульт «устройство», который одинаково управляет и телевизором, и кондиционером, потому что оба они «являются» устройствами с кнопкой «включить», но выполняют команду по-своему (телевизор показывает картинку, кондиционер дует воздухом). Почему это работает: пульт знает только абстракцию «устройство с кнопкой включить», а как именно каждое устройство реагирует — это уже его личное дело.

Вместе они дают главный инструмент ООП: написали area() у Shape — и цикл по фигурам не знает и не должен знать, какая именно фигура перед ним, потому что выбор реализации происходит автоматически. Именно в этом блоке живут vtable, slicing и виртуальный деструктор — самые частые вопросы на экзамене, потому что они проверяют понимание того, что происходит «под капотом» при работе с указателями и ссылками на базовый класс. Практический совет: если вы понимаете, как именно компилятор реализует виртуальный вызов (через vtable и vptr), половина экзаменационных вопросов по ООП перестаёт пугать — потому что за «магией» стоит простая и понятная механика.

Виды наследования

Наследование бывает трёх видов, и различаются они тем, что видит внешний код и что видит наследник. Простое определение: ключ наследования (public, protected, private) определяет, с каким уровнем доступа поля и методы базового класса «прорастут» внутрь наследника. Сам базовый класс при этом не меняется — меняется только видимость его членов из нового класса. Смысл важнее формы: public — про отношение «является» (is-a), private — про «реализован через» (is-implemented-in-terms-of). Проще говоря, ключ наследования — это фильтр доступа, через который проходят секции базового класса, когда они «прорастают» в наследника.

Вид Смысл Секции базового класса внутри наследника
public «является» (is-a) public → public, protected → protected
protected доступно наследникам public/protected → protected
private «реализовано через» всё → private

Почему именно так. При public наследовании сохраняется контракт базового типа: всё, что было публично доступно снаружи, остаётся публично доступно и через наследника — значит, наследника можно подставить вместо базового объекта (принцип подстановки Лисков). При private и protected этот контракт намеренно ломается: внешний код больше не может пользоваться наследником как базой — и это правильно, потому что отношение «является» в таких случаях не выполняется. Подробнее про Лисков: если B : public A, то везде, где ожидается A, можно передать B без сюрпризов. Если же наследование private, то передать B туда, где ждут A, уже нельзя — компилятор запретит неявное преобразование.

Аналогия: public — это публичное заявление «я тоже Device, могу стоять на полке рядом с другими Device»; private — это инженерное решение «я внутри себя использую чужой мотор, но клиенту об этом знать не нужно». Разбор типичной ошибки: студенты часто пишут class Stack : private std::vector<int> и думают, что это удобно — Stack получит все методы vector. Но это плохо: (1) ломается отношение «является», (2) клиент видит у Stack метод insert(в_середину), что для стека абсурд, (3) при изменении vector в libc придётся перекомпилировать всех клиентов. Правильно — композиция: хранить vector как поле.

protected полезен, когда наследник должен работать с полем, но внешний код — нет. Злоупотребление им разрушает инкапсуляцию: предпочитайте private + защищённые методы. Почему так: protected поле доступно всем классам-потомкам, а не только тому, который вы сейчас пишете, — это сильно расширяет «аудиторию» изменения поля. Защищённый метод безопаснее: он может контролировать, как именно потомки работают с состоянием. Частая ошибка — наследовать public ради переиспользования кода, хотя отношение «является» не выполняется: Stack, унаследованный от vector, формально имеет методы вставки в середину, что ломает абстракцию стека. Правильнее — хранить vector внутри стека (композиция). Экзаменационный тезис: наследование нужно для моделирования отношения «является», а не для экономии на написании кода; если хочется только переиспользовать поля — используйте композицию (has-a). Практическая примета: спрашивайте себя «можно ли сказать, что B является A?» Если да — public наследование. Если только «B содержит A» или «B реализован через A» — это композиция или private наследование.

Множественное и виртуальное наследование, ромб

При множественном наследовании класс может получить одну и ту же базу по двум разным путям. Тогда в объекте окажутся две копии базы — это и есть «ромб» (diamond problem). Простое определение: множественное наследование — это когда у класса больше одного прямого родителя (class C : public A, public B). Ромб — частный случай, когда два родителя сами имеют общего предка. Читать поле через такой объект неоднозначно: непонятно, к какой из двух копий вы обращаетесь.

Простая аналогия из жизни: представьте, что у человека два родителя, а у обоих родителей один и тот же дедушка. Если просто «объединить» родителей, дедушка окажется в генеалогии дважды — и вопрос «чей ты внук?» теряет смысл, пока вы не уточните путь. Как в коде: если написать d.grandfather, компилятор не знает — это дедушка по маминой линии или по папиной, и требует уточнения d.Left::grandfather или d.Right::grandfather. В больших иерархиях такое уточнение превращается в кошмар, поэтому используют виртуальное наследование, чтобы дедушка был один.

Если класс наследует Base по двум путям, в объекте появятся два подобъекта Base — это ромб (diamond problem), а обращение к общему полю станет неоднозначным.

Base
       /    \
    Left    Right
       \    /
     Derived

Решение — виртуальное наследование: подобъект Base существует в единственном экземпляре.

struct Base { int value = 0; };
struct Left  : virtual Base {};
struct Right : virtual Base {};
struct Derived : Left, Right {};

Derived d;
d.value = 42;                      // без virtual — ошибка неоднозначности

Виртуальное наследование требует дополнительной косвенности при доступе к базе: инициализацию виртуальной базы выполняет самый производный класс, а не промежуточные. Поэтому компилятор хранит дополнительный указатель (или смещение) на общий подобъект Base, и доступ к полю становится чуть дороже. Почему так: промежуточные классы вроде Left и Right не знают заранее, будут ли они в итоге частью ромба или одиночной цепочки, поэтому они не могут сами инициализировать Base окончательно — это делает тот класс, который стоит на вершине иерархии и точно знает, что ромб состоялся. Механика «почему это работает»: конструктор Derived вызывает конструктор Base напрямую, минуя Left и Right. Если бы Left и Right тоже вызывали конструктор Base, он выполнился бы дважды и Base оказался бы инициализирован некорректно.

Практический вывод: не применяйте виртуальное наследование «на всякий случай» — это плата за размер объекта и скорость; оно оправдано там, где ромб реально возникает (например, iostream наследует istream и ostream от общей basic_ios). Экзаменационные ловушки: (1) забыть virtual в одном из промежуточных классов — ромб не разрывается, и компилятор не выдаёт ошибку в точке наследования, но выдаёт её при обращении к полю; (2) попытаться инициализировать виртуальную базу в конструкторе промежуточного класса — это запрещено, инициализация должна быть только в самом производном; (3) считать, что виртуальное наследование — это то же самое, что виртуальная функция: эти механизмы независимы и решают разные задачи. Типичная ошибка студентов: путать ключевое слово virtual в двух совершенно разных контекстах. virtual Base в списке наследования говорит «делись мной с братьями», а virtual void f() в объявлении метода говорит «выбирай реализацию в рантайме». Это ортогональные механизмы — их можно (и нужно) использовать независимо.

Виртуальные функции и vtable

Виртуальная функция — это способ сказать: «решение о том, какой код выполнить, примем в рантайме, по фактическому типу объекта». Простое определение: виртуальная функция — это метод, помеченный словом virtual, для которого вызов через указатель или ссылку на базовый класс приводит к исполнению версии из реального типа объекта, а не из типа указателя. Без этого пришлось бы писать switch по типу фигуры. Компилятор реализует это через таблицу указателей на функции — vtable. На экзамене ждут именно механику: где хранится таблица, где указатель на неё, как происходит вызов.

Мнемоника: vtable — таблица маршрутов такси. Объект не везёт с собой всех водителей (реализаций); он везёт лишь адрес диспетчерской (vptr), а в диспетчерской лежит таблица маршрутов (vtable) с номерами: «area → слот 0», «деструктор → слот 1». Вызов s->area() — это «диспетчер, маршрут 0», то есть лишнее разыменование. Аналогия ещё проще: представьте, что у каждого объекта есть «визитка» с адресом его «наставника» (vptr), а у каждого класса есть «наставник» — тот, кто точно знает, как выполнять методы этого класса (vtable). Объект сам ничего не решает, он лишь перенаправляет вызов.

Виртуальная функция объявляется virtual в базовом классе. Тогда выбор реализации — позднее связывание (late binding, динамическое связывание): адрес берётся из объекта во время выполнения, а не на этапе компиляции. Обычный вызов — раннее связывание (early binding), адрес известен компилятору и встраивается напрямую. Почему так: компилятор не знает реальный тип объекта, пока программа работает, — он видит только тип указателя. Раннее связывание вставляет в код прямой call <адрес> ещё на этапе компиляции, и адрес известен. Позднее — компилятор генерирует «заготовку» mov rax, [obj.vptr]; call [rax + offset], и адрес подгружается в момент вызова.

struct Shape {
    virtual ~Shape() = default;          // виртуальный деструктор обязателен
    virtual double area() const = 0;     // чистая виртуальная: Shape абстрактен
};
struct Circle : Shape {
    double r = 1.0;
    double area() const override { return 3.14159265 * r * r; }
};
struct Square : Shape {
    double a = 1.0;
    double area() const override { return a * a; }
};

void print(const Shape& s) { std::cout << s.area() << '\n'; }   // полиморфизм
Circle c; Square q;
print(c);    // вызывается Circle::area
print(q);    // вызывается Square::area

Таблица виртуальных методов (vtable). Для каждого класса с виртуальными функциями компилятор строит статическую таблицу указателей на реализации, а в каждый объект добавляет скрытый указатель vptr на эту таблицу. Таблица — одна на класс (не на объект!), а vptr — своя у каждого объекта. Порядок слотов фиксирован и одинаков у всех классов иерархии: если area объявлена первой, она лежит в слоте 0 и у Circle, и у Square — поэтому вызов по номеру слота работает единообразно. Почему vtable одна на класс: все объекты одного типа ведут себя одинаково, незачем хранить копию таблицы в каждом. vptr — это «ссылка» из объекта на общую таблицу.

Объект Circle                vtable класса Circle
+------------------+        +--------------------------------+
| vptr  ---------->|------->| &Circle::area                  |
| r = 1.0          |        | &Circle::~Circle (D1/D0)       |
+------------------+        | &Circle::~Circle               |
                            +--------------------------------+
Вызов s->area() => (*(s->vptr[k]))(s);   // косвенный вызов, одну лишнюю разыменовку

Как устроены слоты (порядок слотов). Компилятор обходит объявления виртуальных функций: сначала они идут в том порядке, в каком встречаются в базовом классе, затем к ним дописываются новые виртуальные функции наследника в порядке объявления. Переопределённая функция занимает слот, унаследованный от базы (её адрес в таблице наследника подменяется). Именно поэтому все классы иерархии «согласны» о номере слота, и косвенный вызов не требует знать динамический тип. Деструктор обычно располагается в конце набора слотов; у него может быть несколько записей (D1 — удаляющий деструктор, D0 — вызывающий базовые) — на вопрос «где в vtable деструктор» стоит ответить именно так. Экзаменационная тонкость: на разных компиляторах порядок слотов может немного отличаться (Itanium ABI, MSVC ABI), но идея «номер слота согласован в пределах иерархии» всегда верна. На собеседовании достаточно сказать: «vtable — массив указателей на функции, в каждом классе свой, в объекте — указатель на vtable его класса».

Отсюда практические следствия экзаменационного уровня:

  • Объект с виртуальными функциями больше на размер указателя (обычно 8 байт) — это цена vptr. Когда это важно: если у вас миллион маленьких объектов, лишние 8 байт превращаются в 8 МБ.
  • Вызов через указатель/ссылку на базу — виртуальный (позднее связывание); вызов через объект по значению — невиртуальный: компилятор знает точный тип. Пример: Shape s = Circle(); s.area(); вызовет Shape::area (или не скомпилируется, если area чистая), а Shape& r = c; r.area(); вызовет Circle::area. Это и есть slicing + потеря полиморфизма.
  • vptr устанавливается в конструкторе (после инициализации полей базы) и восстанавливается в деструкторе, поэтому виртуальные вызовы внутри конструктора/деструктора ведут себя статически: вызовется версия текущего, а не самого производного класса. Почему так: объект ещё не достроен, его «настоящий» vptr не установлен, или уже разрушается, и обращаться к vtable наследника было бы небезопасно.
  • Косвенный вызов через vtable слегка дороже прямого (лишнее разыменование) и мешает встраиванию кода, но это плата за полиморфизм. Совет: если метод точно не будет переопределяться (например, int id() в Employee), не делайте его виртуальным — это ускорит вызов и уменьшит размер vtable.

Чистая виртуальная функция, абстрактный класс, интерфейс

Иногда у базы нет осмысленной реализации метода: что такое «площадь абстрактной фигуры»? В таком случае метод объявляют чистым виртуальным, а класс — абстрактным, то есть запрещают создавать его объекты. Простое определение: чистая виртуальная функция — это метод, у которого есть только объявление, но нет реализации в базовом классе (сигнатура заканчивается = 0). Абстрактный класс — класс, у которого есть хотя бы одна такая функция; создать его экземпляр нельзя. Это язык описания контракта: «любой наследник обязан реализовать area()». Аналогия: абстрактный класс — это должностная инструкция с пунктом «обязан уметь делать X», но без описания, как именно. Конкретные классы — это люди на этой должности, и каждый делает X по-своему.

virtual ret f() = 0; — чистая виртуальная функция. Класс с хотя бы одной такой функцией абстрактный: его объекты создавать нельзя (компилятор запретит Shape s;). Класс только с чистыми виртуальными и виртуальным деструктором — это интерфейс: он не несёт состояния и не даёт реализации. Практический совет: даже для интерфейса обязательно объявляйте виртуальный деструктор (virtual ~I() = default;), иначе удаление по указателю на интерфейс приведёт к неопределённому поведению — компилятор не вызовет деструкторы наследника.

struct Printable {                        // интерфейс: без состояния, без реализации
    virtual ~Printable() = default;
    virtual void print(std::ostream&) const = 0;
};

Класс Printable — чистый интерфейс: у него нет полей и ни одной готовой реализации, только контракт. Абстрактный класс, в отличие от интерфейса, может хранить состояние и давать наследникам готовые методы, которые они доопределяют. Если на экзамене просят «разницу», отвечайте именно так: интерфейс — подмножество абстрактных классов без данных; на практике интерфейсы любят за то, что они не навязывают реализацию. Разбор типичной ошибки: студенты иногда пишут абстрактный класс с полями и называют его «интерфейсом». Это не ошибка компиляции, но это другой паттерн — NVI (Non-Virtual Interface) или просто абстрактная база. Интерфейс в строгом смысле — это контракт без состояния и без реализации. Когда что выбирать: интерфейс — если хотите описать «что умеет делать объект» (Drawable, Serializable, Comparable). Абстрактный класс с полями — если хотите ещё и дать часть общей реализации (например, Stream с общим буфером, но разной логикой чтения).

Виртуальный деструктор и slicing

Что такое виртуальный деструктор — это деструктор базового класса, помеченный ключевым словом virtual. Он нужен для одной цели: гарантировать, что при удалении объекта через указатель (или ссылку) на базовый класс будет вызван деструктор реального (наиболее производного) типа, а не только базового.

Аналогия из жизни. Представьте, что вы арендовали помещение в бизнес-центре и у вас там стоит дорогое оборудование. Когда контракт заканчивается, вы забираете оборудование, а потом здание сдают под снос. Если «снос» знает только о здании и забывает про оборудование арендаторов — техника пропадает. Виртуальный деструктор — это как пометка «при сносе не забудьте про оборудование арендаторов». Без неё диспетчер сноса выполнит только свою часть работ.

Удаление через указатель на базу — самая коварная тема: код компилируется и часто даже работает, пока у наследника нет собственных ресурсов. Но как только у наследника появляются поля, требующие освобождения (динамическая память, файлы, сокеты, мьютексы), невиртуальный деструктор базы становится бомбой замедленного действия.

Почему компилятор «молчит»? Без virtual компилятор выбирает метод по статическому типу указателя. Для Base* p это всегда ~Base, потому что компилятор во время компиляции не знает, что на самом деле там лежит Derived. Механизм виртуальных функций (vtable) включается только если в классе есть хотя бы одна virtual функция — именно она добавляет в объект скрытое поле vptr (указатель на таблицу виртуальных функций), через которое потом ищется настоящий деструктор. Нет virtual — нет vptr — нет шанса вызвать ~Derived.

Правило: если собираетесь удалять наследника через указатель на базу, деструктор базы обязан быть виртуальным. Иначе delete basePtr; вызовет только ~Base, и ресурсы наследника утекут — это UB (undefined behavior, неопределённое поведение). Невиртуальный деструктор молча «срежет» очистку: вызвана будет лишь часть цепочки, и ни один компилятор в типичном случае не предупредит.

struct Base { ~Base(); };                          // НЕ виртуальный
struct Derived : Base { std::vector<int> v; };     // утечёт ~Derived

Base* p = new Derived;
delete p;                                          // UB: вызовется только ~Base

Slicing — «срезание» производной части при копировании по значению в базовый тип. Это происходит потому, что компилятор при копировании по значению копирует ровно столько памяти, сколько занимает базовый тип — поля наследника просто некуда положить, они физически не помещаются в слот памяти базового размера.

Аналогия. У вас есть толстый документ с тремя приложениями. Если вы копируете только обложку в папку «основные документы», приложения исчезают. Чтобы сохранить всё, нужна либо ссылка на оригинал, либо копия с сохранением всех приложений (в C++ это эмулируется через указатели/умные указатели).

void take(Shape s);        // плохо: параметр по значению — копируется только Shape
void take(const Shape& s); // правильно: ссылка сохраняет динамический тип

std::vector<Shape> v;
v.push_back(Circle{});     // slicing: Circle превращается в Shape, area() теряется

Почему slicing — не «мелкая ошибка». Вектор из Shape хранит объекты базового размера (sizeof(Shape)), а в него пытаются положить Circle. Лишние поля отбрасываются молча, без предупреждения, и полиморфизм исчезает: после v[0].area() всегда вызовется Shape::area(), а не Circle::area(), потому что статический тип элемента вектора — Shape. Лечится хранением указателей (std::vector<std::unique_ptr<Shape>>) или ссылок; но тогда владение должно быть явным — голые указатели без владельца опасны и легко превращаются в UB.

Типичная ошибка студентов. Студент пишет void f(Animal a) и удивляется, почему вызывается Animal::voice(), а не Dog::voice(). Решение: void f(Animal& a) или void f(const Animal& a). Это один из самых любимых вопросов МИФИ и МГУ — покажите, что понимаете причину: копирование по значению не может сохранить неизвестный заранее размер и тип, потому что размер объекта определяется на этапе компиляции по статическому типу.

Практический совет. Если класс наследуется и через его указатель планируется удаление — деструктор должен быть virtual. Если класс не наследуется — деструктор можно не делать виртуальным (и компилятор это оптимизирует: нет vptr, нет лишнего указателя в каждом объекте, нет vtable). Чтобы запретить наследование явно, можно пометить сам класс как final — это даст компилятору право на devirtualization.

override и final

Что такое override. Это спецификатор, который говорит компилятору: «этот метод обязан переопределять виртуальную функцию из базового класса». Если такой виртуальной функции в базе нет или сигнатуры не совпадают — это ошибка компиляции, а не тихий «новый метод, живущий своей жизнью».

Что такое final. Это спецификатор, запрещающий дальнейшее переопределение метода в наследниках или дальнейшее наследование от класса. Компилятор знает, что ниже по иерархии переопределения не будет, и может убрать позднее связывание (devirtualization) — вызов становится обычным прямым вызовом, что ускоряет код.

Аналогия. override — это как подпись на документе с пометкой «является редакцией предыдущего документа №...». Если номера документа нет — подпись недействительна, документ не примут. final — это штамп «окончательная редакция, изменения запрещены». Снимает неопределённость для всех участников процесса.

Описать виртуальную функцию в наследнике — значит переопределить её, но компилятор не всегда помогает: опечатка в сигнатуре или забытый const превращают переопределение в новую функцию без предупреждения. Это происходит потому, что переопределение в C++ определяется по совпадению сигнатуры, а не по совпадению имени — две функции с одинаковым именем, но разным const или разным типом параметра — это разные функции. override и final — это подсказки компилятору, которые превращают такую ошибку в ошибку сборки, а не в многочасовые поиски «почему не вызывается мой метод».

struct A { virtual void f(); };
struct B : A {
    void f() override;          // ловит опечатку в сигнатуре
    void g() final;             // дальше переопределить нельзя
};
struct C final : B {};          // от C нельзя наследовать

Разница между override и final проста: первый проверяет, что вы действительно переопределяете, второй запрещает переопределять дальше. Оба нужны для читаемости и ранней диагностики: без override опечатка в имени метода создаст новую функцию, а не переопределит виртуальную, и вы будете долго искать, почему полиморфизм «не работает».

Типичные ошибки с override.

  • Забыли const в сигнатуре: void f() против void f() const — это разные функции (метод с const имеет тип void (B::*)(...) const, без const — другой тип), и наследник заведёт новую, а не переопределит базовую.
  • Не совпал тип параметра: int вместо long, Shape& вместо const Shape& или std::string вместо const char* — компилятор без override это пропустит, и нужная виртуальная функция не перекроется.
  • Разный уровень доступа: метод был protected, стал public — технически не запрещено, но меняет контракт и часто ломает инкапсуляцию.
  • Забыли virtual в базе: если в базе метод не виртуальный, override в наследнике даст ошибку компиляции — это и хорошо (диагностика), но студенты пугаются.

Типичные ошибки с final.

  • Поставили final на виртуальную функцию, а потом унаследовали и попытались её переопределить — ошибка компиляции.
  • Поставили final на сам класс, а потом попытались от него наследоваться — тоже ошибка компиляции; лечение: убрать final или пересмотреть архитектуру.
  • Забыли override, но поставили final — final без override синтаксически допустим, но семантически странен: запрещаем переопределение метода, который сам никого не переопределяет.

Практический совет. Ставьте override на все методы наследника, которые по смыслу переопределяют базовые. Это бесплатная страховка: вы ничего не теряете, но получаете ошибку компиляции вместо трудноуловимого бага в рантайме. На экзамене это хороший тон — показывает, что вы понимаете, как работает vtable и что компилятор может проверить за вас.

RTTI и dynamic_cast

Что такое RTTI. RTTI (run-time type information) — это механизм, который хранит информацию о реальном типе объекта во время выполнения программы и позволяет эту информацию получить. В C++ RTTI доступно только для полиморфных типов (то есть типов, у которых есть хотя бы одна виртуальная функция), потому что описание типа крепится к тому же vptr, что и таблица виртуальных функций — без vptr у объекта просто нет «паспорта».

Аналогия из жизни. RTTI — это как паспортный стол: у каждого «жителя» (объекта) есть паспорт с указанием настоящего имени (типа), хотя в гости к вам он мог прийти под маской «знакомого» (через указатель на базовый класс). Когда вам нужно убедиться, что перед вами именно тот, за кого себя выдаёт, вы смотрите в паспорт — это и есть typeid/dynamic_cast. Если паспорта нет (класс не полиморфный) — проверять нечего.

RTTI (run-time type information) — это информация о типе, доступная во время выполнения. Она позволяет спросить у объекта «кто ты?» (typeid) и безопасно привести указатель к конкретному наследнику (dynamic_cast). Это инструмент для тех редких случаев, когда полиморфизма через виртуальные функции не хватает, и его тоже часто спрашивают на МГУ.

RTTI даёт два основных инструмента: typeid (спросить «кто ты?») и dynamic_cast (безопасно привести указатель/ссылку к конкретному наследнику). Это инструмент для тех редких случаев, когда полиморфизма через виртуальные функции не хватает — например, когда нужно вызвать метод, специфичный именно для производного класса, а виртуальной альтернативы в базе нет или добавить её невозможно.

Почему RTTI требует полиморфного типа. Безопасное приведение вниз по иерархии возможно только для полиморфного типа (есть хотя бы одна виртуальная функция), потому что ему нужен vptr: именно через него среда находит описание типа (std::type_info). Если vptr нет, у объекта нет «паспорта», и проверять нечего — dynamic_cast просто не скомпилируется с диагностикой «source type is not polymorphic».

Shape* s = make_shape();
if (auto* c = dynamic_cast<Circle*>(s)) {       // указатель: nullptr при неудаче
    use(*c);
}
try {
    Circle& ref = dynamic_cast<Circle&>(*s);    // ссылка: бросает std::bad_cast
} catch (const std::bad_cast&) { /* не тот тип */ }

dynamic_cast для указателя возвращает nullptr, для ссылки бросает исключение; приведение к базовому типу (static_cast вверх) проверок не требует и в рантайме бесплатно, потому что производный тип всегда «является» базовым и сдвиг указателя известен на этапе компиляции. Динамическое приведение стоит дороже обычного, потому что идёт по описанию типа: проверяется, совпадает ли реальный тип с целевым, и если нет — обход всей иерархии вверх (для множественного наследования ситуация ещё сложнее из-за cross-cast).

Когда использовать, а когда нет. Если dynamic_cast нужен в горячем коде (внутри цикла, в обработчике каждого события) — это признак того, что иерархия спроектирована неудачно. Логичнее добавить виртуальный метод в базовый класс и переопределить его в наследнике, чем узнавать тип и ветвиться по if. Практическая проверка в примере: if (auto* c = dynamic_cast<Circle*>(s)) — так безопасно «узнают» конкретный тип перед обращением к его полям, и если тип не тот — переменная просто будет nullptr, без исключений и без UB.

Типичные ошибки.

  • Применить dynamic_cast к не полиморфному типу — ошибка компиляции: «cannot dynamic_cast ... source type is not polymorphic». Лечение: добавить виртуальную функцию (часто хватает виртуального деструктора virtual ~Base() = default;).
  • Использовать dynamic_cast там, где хватит static_cast вверх по иерархии — это бесплатнее, потому что проверка не нужна.
  • Ловить std::bad_cast через catch (...) или вообще забыть ловить — это исключение, его нужно обработать явно, иначе программа аварийно завершится.
  • Применить dynamic_cast к ссылке и забыть, что он бросает исключение при неудаче (в отличие от указателя) — поведение асимметрично, и студенты об этом часто забывают.
  • Каскад из нескольких dynamic_cast подряд вместо виртуального метода — код становится нечитаемым и тормозит.

Практический совет. Правило хорошего тона: сначала попробуйте решить задачу через виртуальные функции — это и быстрее, и расширяемее (новый наследник автоматически вписывается). dynamic_cast оправдан, когда вы интегрируетесь с чужим кодом или обрабатываете граничные случаи (сериализация, логирование, обработка ошибок), где добавить виртуальный метод в базу невозможно или нежелательно. На экзамене МФТИ любят спрашивать: «почему dynamic_cast дороже static_cast?» — ответ: потому что проверяет тип в рантайме через описание типа в vtable.

Исключения

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

Аналогия из жизни. Представьте пожарную тревогу в бизнес-центре. Когда датчик дыма срабатывает (аналог throw), сигнал идёт на пульт охраны. Охрана смотрит на план здания и ищет ближайшую ответственную службу — это похоже на поиск подходящего catch. Пока сигнал идёт наверх, двери на этажах закрываются автоматически — это работа деструкторов локальных объектов при разматывании стека (stack unwinding). Самое важное: если бы двери не закрывались автоматически, часть ценного имущества осталась бы «утечкой ресурсов» при эвакуации.

Зачем всё это нужно. Раньше ошибки кодировали возвращаемыми значениями (-1, nullptr, код ошибки). Это заставляло проверять результат каждого вызова и легко приводило к тому, что забытая проверка превращалась в «бомбу замедленного действия». Исключения вынесли обработку ошибок в отдельный «канал», который невозможно случайно проигнорировать: если catch не написан, программа аварийно завершится, и это сразу бросается в глаза при тестировании.

Исключение прерывает нормальный поток управления: throw создаёт объект-исключение, среда исполнения ищет подходящий catch, разматывая стек и вызывая деструкторы локальных объектов на пути «наружу». Именно это разматывание обеспечивает RAII-гарантии: открытые файлы закрываются, захваченные мьютексы освобождаются, динамическая память, обёрнутая в unique_ptr, удаляется. Без RAII разматывание стека не защитило бы от утечек.

#include <stdexcept>
#include <vector>

double safe_div(double a, double b) {
    if (b == 0.0) throw std::domain_error("деление на ноль");
    return a / b;
}

int main() {
    try {
        std::cout << safe_div(1.0, 0.0) << '\n';
    } catch (const std::domain_error& e) {     // ловим по константной ссылке
        std::cerr << "ошибка: " << e.what() << '\n';
    } catch (const std::exception& e) {        // порядок: от частного к общему
        std::cerr << "общая ошибка: " << e.what() << '\n';
    } catch (...) {
        std::cerr << "неизвестное исключение\n";   // последний рубеж
    }
}

Как читать этот код. Функция safe_div проверяет делитель и при b == 0.0 создаёт временный объект типа std::domain_error с поясняющим сообщением — это и есть throw. Объект «улетает» вверх по стеку вызовов, пока main не перехватит его. В main стоит цепочка catch-блоков: среда исполнения пробует их по порядку сверху вниз и применяет первый, чей тип совместим с брошенным объектом.

Правила приёма исключений (важно для экзамена).

  • Ловите по константной ссылке (const std::exception&) — без лишних копий и без «срезания» (object slicing). Почему это критично? Если поймать по значению, то компилятор скопирует только базовую часть иерархии — например, std::domain_error превратится в std::exception, и поле, которое добавил производный класс, исчезнет. По ссылке вы работаете с исходным объектом и сохраняете всю диагностическую информацию.
  • Перечисляйте catch от частного к общему. Первый подходящий блок выигрывает — это правило виртуальной диспетчеризации на этапе поиска. Если сверху поставить catch (const std::exception&), то стоящий ниже catch (const std::domain_error&) никогда не сработает, потому что исключение уже перехвачено выше. Сортировка как в матрешке: сначала «маленькие» типы, потом всё более общие.
  • Не бросайте сырые указатели и встроенные типы без нужды (throw 5, throw "error"). Они не несут информации, не имеют what(), плохо различимы в catch. Используйте иерархию std::exception или собственные классы, наследующие от подходящего базового исключения.
  • catch (...) ставят последним — это «ловушка всего»: перехватывает даже исключения встроенных типов и неизвестные классы, не входящие в std::exception. Внутри такого блока вы не знаете тип исключения, поэтому работаете только с логированием и аварийным завершением — продолжать работу после catch (...) безопасно только в редких, контролируемых случаях.

Типичные ошибки студентов.

  1. Поставить catch (...) первым — тогда компилятор выдаст предупреждение «универсальный обработчик перекрывает все нижестоящие», а более специфичные блоки станут «мёртвым кодом».
  2. Поймать по значению и попытаться вызвать метод производного класса — не получится, потому что объект «срезан» до базового типа.
  3. Забыть, что throw e; внутри catch без точного типа снова срезает исключение. Чтобы перебросить тот же объект, нужно писать просто throw; (без выражения).
  4. Бросать из деструктора — почти всегда приводит к std::terminate, особенно если деструктор вызван во время разматывания стека.
Гарантия Обещание Пример
Basic (базовая) Инварианты класса сохранены, утечек памяти и других ресурсов нет, объект остаётся в валидном, но, возможно, изменённом состоянии vector::push_back при перемещении
Strong (строгая) Операция либо выполнена полностью, либо состояние объекта в точности как до вызова (commit-or-rollback) copy-and-swap operator=
Nothrow (без исключений) Операция не бросает вовсе, можно не оборачивать в try std::swap, перемещение unique_ptr

Что это значит на практике. Гарантии важны при написании собственных контейнеров и при выборе приёмов проектирования. Техника copy-and-swap даёт строгую гарантию, потому что работает на временной копии объекта и подменяет состояние только после успешного завершения копирования: если копирование бросит, оригинал не пострадал. Если операция обещает nothrow, вызывающий код может спокойно использовать её в деструкторах и других критичных местах, не думая о защите от утечек. Обратите внимание: vector::push_back даёт строгую гарантию, только если перемещение (или копирование) элементов не бросает; иначе — откатывается к базовой, и часть элементов может остаться перемещённой.

Как проектировать свои классы с учётом гарантий. Полезный алгоритм: сначала убедитесь, что деструкторы и swap — noexcept (это почти бесплатная базовая гигиена). Затем реализуйте операции через copy-and-swap, чтобы автоматически получить строгую гарантию. Для сложных операций, где copy-and-swap не подходит (например, изменение одного поля), используйте идиому «запомнить старое состояние → выполнить → при исключении откатить» или признайте, что даёте только базовую гарантию, и задокументируйте это.

noexcept — спецификатор функции, обещающий, что она не выпустит исключение наружу. Если исключение всё-таки вылетит за пределы noexcept-функции, вызывается std::terminate, и программа аварийно завершается, причём стек не разматывается — деструкторы локальных объектов не вызываются. noexcept критичен для конструкторов перемещения, деструкторов и swap: контейнеры вроде std::vector по этому спецификатору решают, копировать элементы или перемещать. Если перемещение не помечено noexcept, контейнер на всякий случай выберет более безопасное копирование.

Тонкости noexcept. Деструкторы неявно noexcept (вплоть до C++17 — полностью, в C++17+ это поведение стало жёстче стандартизировано). Это логично: если деструктор бросит во время разматывания стека, произойдёт «двойное исключение» — а это уже std::terminate. Отсюда правило — деструктор никогда не бросает. Если нужно сообщить об ошибке при разрушении, используйте флаги состояния, журналирование и проверенные коды возврата, а не исключения.

struct Guard {
    ~Guard() noexcept { /* деструктор не бросает */ }
};
void may_throw();
void must_not() noexcept { may_throw(); }   // при исключении — terminate

Практическое следствие. Помечая функцию noexcept, вы обещаете не только «я не бросаю», но и даёте компилятору право не генерировать код обработки исключений (раскрутки) в этом вызове — это ускоряет код и уменьшает бинарник. Но если обещание нарушено и исключение всё-таки вылетит, будет вызван std::terminate, а стек не размотается: ресурсы не освободятся через деструкторы, и вы получите аварийное завершение без штатного завершения работы. Поэтому noexcept ставят осознанно и в первую очередь на перемещение, деструкторы и swap. Не помечайте noexcept функцию «на всякий случай» — компилятор не проверит обещание статически, и нарушение обнаружится только в продакшене.

Когда ставить noexcept, а когда нет. Ставьте смело: деструкторы (они и так noexcept), swap, конструктор/оператор перемещения объектов с простыми полями (unique_ptr, vector<T>::~vector и т. п.), простые геттеры (size(), empty()), обмены двух полей. Не ставьте, пока не уверены: операции, вызывающие пользовательский код (в т. ч. аллокации памяти), сложные шаблонные функции, всё, что зависит от политик. Есть и компромисс — noexcept(noexcept(expression)): функция наследует noexcept-статус от своих выражений. Это удобно для шаблонов, где конкретный тип параметра неизвестен на этапе компиляции.

Шаблоны

Определение. Шаблон (template) — это «чертёж» для функции, класса или переменной, в котором вместо конкретного типа стоит параметр, подставляемый компилятором. Полученная при подстановке сущность называется инстанцированием (instantiation). Именно так в C++ реализуется обобщённое программирование: один и тот же алгоритм работает для int, double, std::string и любых пользовательских типов без копирования кода вручную.

Аналогия из жизни. Шаблон — это форма для печенья: тесто каждый раз разное (шоколадное, овсяное, творожное), а форма одна и та же. Из одной формы получаются печенья разных видов, но все они сделаны по одному чертежу. Точно так же шаблон max_of<T> «выпекает» функцию max_of<int>, max_of<double>, max_of<std::string> из одного описания. Каждое такое «печенье» — самостоятельная функция в машинном коде, а не ссылка на общий код.

Тема обязательна для МИФИ и МГУ и особенно важна для продвинутого потока МФТИ.

Почему шаблоны устроены именно так. Шаблон — это не код, а шаблон для генерации кода: компилятор создаёт конкретную сущность при подстановке аргументов (инстанцирование). То есть Array<int> и Array<double> — это два разных класса, сгенерированных из одного описания. Это принципиально отличается от полиморфизма времени выполнения (виртуальные функции): шаблоны дают нулевые накладные расходы в рантайме, но платят за это размером бинарника и временем компиляции — об этом подробнее в разделе про code bloat ниже.

template <typename T>
T max_of(const T& a, const T& b) { return a < b ? b : a; }   // шаблон функции

template <typename T, std::size_t N>
class Array {                                                 // шаблон класса
    T data_[N]{};
public:
    constexpr std::size_t size() const { return N; }
    T& operator[](std::size_t i) { return data_[i]; }
};

template <typename T>
struct Printer { void print(const T& v) { std::cout << v; } };

template <>                                   // полная специализация
struct Printer<bool> { void print(bool v) { std::cout << (v ? "true" : "false"); } };

template <typename T, typename U>             // частичная специализация
struct Pair { T first; U second; };
template <typename T>
struct Pair<T, T> { T both; };

Что здесь важно заметить.

  • template <typename T> — параметр шаблона: T — это «заглушка» для типа, которая подставится при инстанцировании.
  • Полная специализация template <> заменяет поведение для одного конкретного типа (bool) — компилятор выберет именно её вместо общего Printer<T>.
  • Частичная специализация Pair<T, T> срабатывает, когда оба параметра совпадают, — компилятор выберет именно её вместо общего Pair<T, U>. Это механизм, позволяющий делать «более специализированные» версии шаблона.

Дополнительные возможности шаблонов.

  • Параметры по умолчанию задают типичный случай: Box<T = int> означает, что при использовании Box<> без аргументов будет выбран int.
  • Variadic-шаблоны принимают любое число аргументов через typename... Args.
  • Fold expressions (C++17) сворачивают пакет параметров бинарным оператором за один шаг и заменяют ручную рекурсию.

Вариадические шаблоны устроены рекурсивно: «один аргумент плюс остальные», и раскручиваются на этапе компиляции. Аналогия: как матрёшка — сначала отделяешь первый элемент, потом раскрываешь оставшуюся матрёшку, и так до самого маленького элемента; либо, в C++17+, как складывание чисел «по цепочке» с помощью fold-выражения.

template <typename T = int> struct Box { T v{}; };

template <typename... Args>
auto sum(Args... args) { return (args + ...); }              // fold: (((a+b)+c)+...)

template <typename... Args>
void log(const Args&... args) { ((std::cout << args << ' '), ...); }

Типичная ошибка (variadic). Fold expression требует, чтобы оператор (+, <<, , и т.п.) был корректен для всех типов в пакете. Если в sum передать и int, и std::string, оператор + найдётся, но если передать, например, nullptr_t или std::unique_ptr без value_type — компиляция упадёт. Поэтому вариадики часто комбинируют с if constexpr или concepts, ограничивая типы в пакете. Ещё одна частая ошибка — забыть, что унарный и бинарный fold отличаются: (args + ...) и (... + args) сворачивают пакет в разном порядке (слева направо и справа налево соответственно).

Code bloat — плата за обобщённость. Каждая комбинация аргументов порождает отдельный код, растут размер бинарника и время сборки. Например, std::vector для десяти разных типов — это десять независимых реализаций в исполняемом файле. Иногда с этим борются, выделяя общую часть в нешаблонный код (type erasure), но по умолчанию шаблоны «мономорфизируются»: обобщённость бесплатна в рантайме, но не на этапе сборки.

Практический совет. Чтобы не раздувать бинарник, выносите платформонезависимую логику в базовые классы или используйте extern template в заголовочном файле (запрещает инстанцирование в одной единице трансляции) и явное template class ...; в одном .cpp (форсирует инстанцирование в одном месте). Это типичный приём в крупных библиотеках — например, так делает libstdc++ для std::basic_string.

SFINAE

Определение. SFINAE (Substitution Failure Is Not An Error, «неудавшаяся подстановка — не ошибка») — это правило языка C++: если при подстановке конкретного типа в параметры шаблона получается некорректный код, компилятор не падает с ошибкой, а тихо отбрасывает этот шаблон из списка кандидатов и пробует следующий.

Аналогия из жизни. Представьте, что вы ищете ключ, который подходит к замку. Вы перебираете связку ключей: каждый неподходящий ключ вы откладываете и пробуете следующий — это не ошибка, а часть процесса поиска. SFINAE работает так же: «не та форма сигнатуры» означает «этот шаблон не подходит, пробуем следующий».

Зачем это нужно. SFINAE позволяет писать шаблоны, которые работают только для типов с определёнными свойствами. Классический пример — функция advance(it, n): для итератора произвольного доступа она работает за O(1) (it += n), а для двунаправленного — за O(n) (цикл). Без SFINAE компилятор не смог бы выбрать правильную перегрузку и выдал бы ошибку «нет подходящего +=».

SFINAE — главный приём «метапрограммирования» до C++20. Он позволяет включить шаблон только для подходящих типов, не ломая компиляцию для остальных. Идея в том, что подстановка конкретного типа в сигнатуру может быть невозможна; стандарт разрешает считать это не ошибкой, а лишь поводом отбросить кандидата и попробовать другой. Именно поэтому std::enable_if и void_t работают.

SFINAE (Substitution Failure Is Not An Error) — ошибка подстановки в сигнатуре не ломает компиляцию, а просто убирает кандидата из набора перегрузок. Через это включают и выключают шаблоны по условию.

// До C++17: включаем шаблон только для целочисленных типов
template <typename T>
typename std::enable_if<std::is_integral<T>::value, T>::type
twice(T v) { return v * 2; }

// void_t — определение свойства «у типа есть value_type»
template <typename T, typename = void> struct has_value_type : std::false_type {};
template <typename T>
struct has_value_type<T, std::void_t<typename T::value_type>> : std::true_type {};

Что здесь происходит построчно.

  • std::enable_if<условие, T>::type существует (равен T) только если условие == true. Если условие ложно, подстановка ::type «проваливается», и компилятор применяет SFINAE — шаблон отбрасывается.
  • std::void_t<...> всегда даёт void — но если внутри ... стоит некорректный тип (например, typename T::value_type, когда такого поля нет), компилятор применяет SFINAE именно к этой специализации has_value_type, и она тоже отбрасывается. Остаётся «базовая» версия с std::false_type — это типичная идиома «детекции свойств» до C++20.

Типичная ошибка. SFINAE работает только в непосредственной сигнатуре шаблона (типы параметров, возвращаемое значение, параметры шаблона). Если ошибка случается в теле функции — SFINAE уже не действует, и это будет hard error, ломающее компиляцию. Поэтому условия прячут в enable_if в типе возврата или в типе лишнего параметра шаблона. Ещё одна ошибка — перепутать is_integral<T>::value (старый стиль, bool-член) и is_integral_v<T> (C++17, переменная шаблона); первая форма требует ::value, вторая — нет.

Практический совет. Современный код (C++20+) предпочитает concepts и requires вместо SFINAE: ошибки понятнее и писать проще. Но в библиотечном коде, написанном до C++20, SFINAE встречается повсеместно — его нужно уметь читать и распознавать, особенно в type_traits и старых версиях STL/Boost.

Concepts и requires (C++20)

Определение. Концепт (concept) — это именованное требование к типу, проверяемое компилятором во время инстанцирования шаблона. Это «контракт»: «тип T обязан иметь такое-то свойство, иначе шаблон не подходит». Записывается через ключевое слово concept и используется либо прямо в сигнатуре шаблона, либо в requires-предложении/выражении.

Аналогия из жизни. Концепт — это «должностная инструкция». На вакансию «водитель» подходят люди с правами категории B; человек без прав не получает отказ с грубым текстом, а просто не проходит отбор, причём в письме указано, чего именно не хватило. Concepts работают так же: компилятор аккуратно сообщает, какое именно требование не выполнено (например, «T не удовлетворяет std::integral<T>»), вместо трёхстраничной диагностики про enable_if.

Почему это лучше enable_if. Старый подход через enable_if записывал ограничение «сбоку» от сигнатуры — длинно и непрозрачно, как typename std::enable_if<...>::type в типе возврата. Concepts выносят ограничение в сигнатуру, поэтому её можно прочитать как обычное объявление функции/класса. И главное — если шаблон не подходит, диагностика компилятора говорит что именно не сошлось, а не выдаёт многоэтажную портянку про вложенные шаблоны и SFINAE.

Концепты — это современная замена enable_if. Они делают ограничения на тип частью сигнатуры: компилятор не просто отбрасывает кандидата, а пишет, какое именно требование не выполнено. На вопрос «зачем концепты» правильный ответ: читаемость объявлений и понятные диагностики.

Концепты заменяют громоздкий enable_if читаемыми ограничениями и дают понятные сообщения об ошибках.

#include <concepts>

template <typename T>
concept Numeric = std::integral<T> || std::floating_point<T>;

template <Numeric T>
T add(T a, T b) { return a + b; }          // только для чисел

template <typename T>
    requires std::copyable<T>
void store(T v);

// add(std::string("a"));   // ошибка: не удовлетворяет Numeric

Разбор примера.

  • concept Numeric объединяет встроенные концепты std::integral и std::floating_point логическим «или» — это позволяет одновременно описывать и int, и double.
  • template <Numeric T> — короткая форма записи template <typename T> requires Numeric<T>. Обе формы эквивалентны и принимаются компилятором.
  • requires std::copyable<T> — ограничение «после параметров шаблона». Такая форма удобна для длинных условий и для нескольких требований, перечисленных через &&.
  • Закомментированный вызов показывает, как именно concepts сообщают об ошибке: компилятор напишет, что std::string не подходит под Numeric, и явно укажет нарушенные требования — это намного понятнее, чем классическая SFINAE-диагностика.

Типичные ошибки.

  • Забыть подключить <concepts> — компилятор не найдёт std::integral, std::floating_point и другие стандартные концепты.
  • Использовать короткую форму template <Concept T> с концептом, который сам зависит от параметров шаблона — это иногда приводит к неоднозначностям при перегрузке; в таких случаях безопаснее перейти на форму с requires.
  • Перепутать requires-выражение (внутри requires(...) { ... } пишут проверки свойств, например requires(T x) { x + x; }) и requires-предложение (ограничение шаблона после параметров). Это разные синтаксические конструкции с похожим ключевым словом.
  • Перегружать функции с концептами, образующими «пересекающиеся» множества типов: компилятор не сможет выбрать, и вы получите ошибку неоднозначности.

Практический совет. Используйте стандартные концепты (std::integral, std::floating_point, std::ranges::range, std::copyable, std::movable, std::constructible_from) вместо ручных is_integral_v — это короче, понятнее и читается как документация к функции.

if constexpr и type traits

Определение.

  • if constexpr (C++17) — это ветвление, проверяемое во время компиляции. Невыбранная ветка полностью отбрасывается: она даже не проверяется на корректность, как если бы её вырезали из исходника перед компиляцией.
  • Type traits (тип-свойства) — это шаблонные структуры из <type_traits>, которые дают ответ на вопросы вида «а является ли тип T целым?», «а можно ли его скопировать?», «а совпадают ли T и U?» — причём ответ известен на этапе компиляции и стоит ноль в рантайме.

Аналогия из жизни. if constexpr — это план здания с двумя вариантами крыши. Архитектор ещё до стройки выбирает нужный вариант, а на стройплощадку попадает уже один, готовый чертёж. Лишний чертёж не строится, и нагрузка на фундамент не растёт. Type traits — это «справочная таблица» архитектора, где про каждый материал заранее сказано, подходит он или нет.

if constexpr — это «ветвление на этапе компиляции»: невыбранная ветка даже не компилируется. Это позволяет писать в одной функции код, который корректен лишь для части типов. Вместе с type traits (свойствами типов) он заменяет большую часть ручной специализации.

if constexpr (C++17) отбрасывает невыбранную ветку на этапе компиляции, поэтому в ней допустим код, не компилируемый для данного типа.

template <typename T>
std::string describe(const T& v) {
    if constexpr (std::is_integral_v<T>)      return "целое";
    else if constexpr (std::is_floating_point_v<T>) return "вещественное";
    else                                        return "другое";
}

Разбор. Внутри отброшенных веток можно писать выражения, которые не компилируются для текущего T — они просто не будут видны компилятору. Например, в ветке is_integral_v<T> можно безопасно вызвать v.bit_count() или подобный метод, которого нет у std::string. Если убрать constexpr — компилятор проверит обе ветки и сломается на той, что не подходит.

Тип-свойство Проверяет
is_integral_v<T> целочисленный тип (int, long, char, bool, …)
is_floating_point_v<T> вещественный тип (float, double, long double)
is_pointer_v<T> указатель (включая nullptr_t и указатели на члены)
is_same_v<T, U> совпадение типов (с учётом const/volatile)
is_base_of_v<B, D> D наследует B (допускает private/protected)
is_constructible_v<T, Args...> можно ли сконструировать T из аргументов Args...

Соглашения об именах (это важно для читаемости кода).

  • is_..._v<T> — bool-значение (от _v — value). Используйте в if constexpr и static_assert.
  • is_...::value — то же самое, но в виде constexpr static bool внутри структуры (старый стиль до C++17).
  • ..._t<T> (например, remove_reference_t<T>, add_pointer_t<T>) — производный тип (от _t — type). Используйте в using X = ...; и в сигнатурах шаблонов.

Что это значит на практике. Type traits — это запросы к компилятору: «а является ли T целым?», «а можно ли его скопировать?». Ответ известен на этапе компиляции и стоит ноль в рантайме. Соглашение об именах: is_..._v<T> — логическое значение, ..._t<T> — производный тип, std::remove_reference_t<T> — преобразованный тип. Их применяют в static_assert и if constexpr, чтобы подсказать компилятору корректное поведение для разных категорий типов.

Типичные ошибки.

  • Писать is_integral<T> вместо is_integral_v<T> — это не bool, а тип, и в if ( ... ) он не скомпилируется без явного ::value. В C++17+ всегда используйте суффикс _v.
  • Забывать, что if constexpr обязан проверять условие, зависящее от параметров шаблона (или от constexpr-значения). Если написать if constexpr (some_runtime_var) внутри шаблона, отбрасывание веток всё равно произойдёт по правилу dead-code-elimination, но в отброшенной ветке нельзя писать «некомпилируемый» для этого типа код — компилятор всё равно проверит её синтаксис.
  • Пытаться использовать if constexpr для значений, известных только в рантайме: такая ветка не отбросится, и код, зависящий от T, в другой ветке сломает сборку.
  • Писать static_assert(is_integral<T>::value, "...") без ::value в C++17+ — опять же, нужен суффикс _v или явное обращение к ::value.

Практический совет. В шаблонной функции всегда задавайте себе вопрос: «а корректен ли мой код для всех типов, которые могут прийти?». Если нет — используйте if constexpr + trait, чтобы вынести «специфичный» код в отдельные ветки, и добавляйте static_assert в начале шаблона, чтобы поймать неподдерживаемые типы как можно раньше с понятным сообщением.

STL

Что такое STL простыми словами. STL (Standard Template Library) — это набор готовых «деталей» для C++: контейнеры для хранения данных (vector, map, set...), итераторы для обхода элементов, алгоритмы для обработки (sort, find, count...) и умные указатели для безопасной работы с памятью. Все они сделаны через шаблоны, поэтому одинаково работают с любыми типами — целыми числами, строками, вашими собственными классами.

Главная идея — отделить хранение данных от операций над ними: алгоритмы работают через итераторы и не знают, vector перед ними или list. Представьте себе разъём USB: вы втыкаете флешку, клавиатуру или мышь — и всё работает через один и тот же интерфейс. Так же и здесь: алгоритм «знает» только интерфейс итератора, а какой под ним контейнер — ему не важно. На экзамене STL спрашивают через асимптотики и инвалидацию итераторов — это самое практичное, что можно выучить, потому что именно на этом пишут быстрый и корректный код.

Контейнеры: асимптотики и инвалидация итераторов

Контейнер — это «коробка с правилами»: она решает, как хранятся элементы, как быстро к ним обращаться и что происходит при добавлении или удалении. Выбор контейнера — это компромисс между скоростью доступа, скоростью вставки и порядком элементов. Таблица ниже — ваша шпаргалка на задачу «какую структуру взять»; выучите её, потому что это самый частый практический вопрос.

Аналогия из жизни. vector — это ряд стульев в кинотеатре: кресло №5 находится мгновенно (обращение по индексу), но вставить нового зрителя в середину ряда — это попросить всех остальных подвинуться (сдвиг элементов). list — цепочка людей, взявшихся за руки: вклиниться между двумя легко (вставка по итератору), но «найди пятого человека» — это пересчитать всех по порядку (нет произвольного доступа). map — алфавитный указатель в книге: ищете слово — быстро открываете нужную букву (логарифм по ключу).

Контейнер Доступ Вставка/удаление Итераторы Особенность
vector O(1) по индексу амортиз. O(1) в конец, O(n) в середину при реаллокации все (кроме erase до конца) непрерывная память, лучший кэш
deque O(1) O(1) с двух концов при вставке в середину все блочное хранение
list O(n) O(1) по итератору только стёртые двусвязный список, нет []
set/map O(log n) O(log n) только стёртые красно-чёрное дерево, порядок
unordered_set/map O(1) средн. O(1) средн. при рехешировании все хеш-таблица, нет порядка
priority_queue O(1) максимум O(log n) push/pop нет бинарная куча
stack/queue O(1) верх O(1) нет адаптеры над deque

vector — контейнер по умолчанию. reserve(n) заранее резервирует память под n элементов и убирает реаллокации, shrink_to_fit() пытается освободить лишнюю память (не обязан сработать — это «просьба», не команда).

Что это значит на практике. «Амортизированно O(1)» для vector означает: обычная вставка в конец дешёвая, но иногда контейнер перевыделяет память и копирует все элементы — это дорогой, но редкий случай, а усреднённая стоимость остаётся константой. Грубо говоря, вы платите дорого 1 раз из 1000, но в среднем выходит константа. Выбирайте vector, если нужен быстрый доступ по индексу; deque, если активно добавляете и в начало, и в конец; list, если часто удаляете из середины по итератору (но помните про промахи кэша — на практике vector часто быстрее даже там). map/set берите, когда нужен упорядоченный обход; unordered_map/set — когда порядок не важен, а нужна скорость. Инвалидация итераторов — самая опасная часть: после push_back у vector все итераторы могут стать «висячими» (указывать на старую, уже освобождённую память), поэтому не сохраняйте их между изменениями. Это как запомнить номер места в зале, а потом кто-то сделал перестановку рядов — ваш номер теперь указывает «в пустоту».

std::vector<int> v;
v.reserve(1000);                          // одна аллокация вместо серии
v.push_back(1);
v.emplace_back(2);                        // конструирует на месте, без временного объекта

std::map<std::string, int> m;
m["a"] = 1;                               // создаёт элемент по умолчанию, если нет
m.try_emplace("b", 2);                    // не конструирует value без нужды
m.insert_or_assign("a", 5);

std::unordered_map<int, std::string> h;
h.reserve(1024);                          // снижает число рехеширований

push_back или emplace_back? emplace_back конструирует элемент прямо в памяти контейнера из переданных аргументов, без временного объекта. Это как строить кирпичную стену: либо вы делаете кирпич в одном углу и несёте его на место (лишняя работа), либо сразу лепите его там, где нужно. Для std::string это экономит перемещение; для простых типов разницы почти нет. push_back же требует готового объекта. Аналогично try_emplace в map не создаёт значение, если ключ уже есть, — в отличие от operator[], который молча вставит элемент по умолчанию. Это типичная ловушка: код if (m["key"] == 0) всегда создаёт запись, даже если ключа не было — и портит статистику, и считает «промахи» как «попадания».

Типичные ошибки.

  • Сохранить auto it = v.begin() + 5, затем сделать v.push_back(...) — итератор почти наверняка «протух», разыменование = UB (undefined behavior).
  • Использовать std::sort на std::list — не компилируется (нужен random access). У list свой .sort().
  • Использовать std::binary_search на неотсортированном диапазоне — компилируется, но ответ бессмысленный.
  • Ждать от unordered_map стабильного порядка обхода — порядок меняется при рехешировании.

Итераторы и их категории

Итератор — это обобщённый «указатель на элемент» контейнера, который умеет переходить к следующему и читать/писать значение. Он придуман, чтобы алгоритмы не зависели от типа контейнера: один и тот же std::find работает и для vector, и для list, потому что оба предоставляют итераторы. Категория итератора говорит, какие операции над ним разрешены: чем «мощнее» категория, тем больше алгоритмов с ним работает. Это как классы водительских прав: есть «только легковые», а есть «плюс грузовые и автобусы» — чем шире категория, тем больше маршрутов вам доступно.

Категория Умеет Примеры
Input читать один проход istream_iterator
Output писать один проход ostream_iterator
Forward читать/писать много проходов forward_list
Bidirectional двигаться назад list, set, map
Random access +, -, [], сравнение vector, deque, массив

begin()/end() задают полуинтервал [begin, end). Константные итераторы (cbegin()/cend()) запрещают запись через этот итератор. std::advance, std::next, std::distance работают с любой категорией, но для random access-итераторов дешевле (сдвиг за O(1) вместо линейного прохода).

Почему [begin, end), а не [begin, end]? Полуинтервал удобен тем, что пустой диапазон описывается как begin == end, а разбиение не требует «минус единицы». Отсюда идиома for (auto it = v.begin(); it != v.end(); ++it) вместо сравнения с последним элементом. Константные итераторы — признак хорошего тона: если функция только читает, она принимает cbegin()/cend() (или, что ещё лучше, диапазон через пары итераторов), и компилятор не позволит случайно изменить контейнер через такой интерфейс.

Типичные ошибки.

  • Сравнивать итераторы != для итераторов из разных контейнеров — UB (undefined behavior).
  • Разыменовать end() — то же самое, что спросить «что в воздухе за последним сиденьем в ряду»: либо ничего, либо UB.
  • Думать, что у list тоже есть it + 5 — нет, list даёт только bidirectional, переход на 5 позиций — это 5 раз сделать ++it.

Алгоритмы

STL-алгоритмы — это готовые «рецепты» обработки данных, уже написанные и оптимизированные в <algorithm> (а числовые — в <numeric>). Правило звучит так: если вы пишете цикл по контейнеру, сначала загляните в <algorithm>. Это короче, безопаснее (меньше шансов ошибиться в границах) и часто быстрее — авторы стандартной библиотеки оптимизировали эти функции лучше, чем средний разработчик пишет ручные циклы.

#include <algorithm>
#include <numeric>

std::vector<int> v{5, 1, 4, 2, 3};

std::sort(v.begin(), v.end());                    // O(n log n), нестабильная
std::stable_sort(v.begin(), v.end());             // сохраняет порядок равных
auto it = std::find(v.begin(), v.end(), 4);       // линейный поиск
bool has = std::binary_search(v.begin(), v.end(), 4);       // O(log n) на сортированном
auto lo = std::lower_bound(v.begin(), v.end(), 4);          // первый >= 4
auto hi = std::upper_bound(v.begin(), v.end(), 4);          // первый > 4

int total = std::accumulate(v.begin(), v.end(), 0);         // сумма
std::for_each(v.begin(), v.end(), [](int& x) { x *= 2; });
auto [mn, mx] = std::minmax_element(v.begin(), v.end());

Правило: если пишете цикл по контейнеру — сначала проверьте <algorithm>; явные циклы остаются для сложной логики и для доступа к индексу. Обратите внимание на требования: std::sort и binary_search требуют random access-итераторов (значит, vector/deque, но не list), а binary_search работает только на отсортированном диапазоне. find — линейный поиск, lower_bound — логарифмический, но лишь на сортированных данных. Эти детали и спрашивают на экзамене.

Почему sort нестабильная и когда это важно. Нестабильный sort (обычно IntroSort) не гарантирует, что равные элементы сохранят исходный порядок. Если вы сортируете массив пар (оценка, имя) по оценке, то студенты с одинаковыми оценками могут перемешаться. Если важен порядок равных — берите stable_sort (он чуть медленнее и требует больше памяти, но сохраняет относительный порядок).

Что возвращают lower_bound и upper_bound. Итератор на первый элемент >= x и на первый > x соответственно. Разность upper - lower даёт количество вхождений элемента — это стандартный приём «счётчик вхождений на отсортированном массиве за O(log n)».

Типичные ошибки.

  • Вызвать binary_search на неотсортированном диапазоне — компилируется, работает, но выдаёт ерунду.
  • Сравнивать результат find с v.end() через * без проверки — UB, если элемент не найден.
  • Забыть #include <numeric> для accumulate — компилятор скажет «не знаю такой функции».
  • Писать свой «пузырёк» в продакшен-коде: std::sort обычно в 5–20 раз быстрее.

Аллокаторы

Аллокатор — это «поставщик памяти» для контейнера: маленькая прослойка, которая говорит «дай мне кусок памяти под N элементов» и «верни этот кусок обратно». Обычно вы о нём не думаете, но если элементов очень много или важна фрагментация, стратегию выделения меняют. Разделение стратегии выделения памяти и структуры данных — это классический пример принципа «разделяй ответственность»: контейнер (vector) решает, как организовать элементы, а аллокатор решает, откуда брать сырую память.

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

Аллокатор отделяет стратегию выделения памяти от контейнера. Стандартный по умолчанию — std::allocator<T>, обёртка над operator new/delete. Свой аллокатор позволяет брать память из пула или арены: например, аллоцировать блоки по 4 КБ и нарезать их мелкими объектами — это снижает накладные расходы malloc.

template <typename T>
struct PoolAllocator {
    using value_type = T;                                   // обязательный член
    T* allocate(std::size_t n) { return static_cast<T*>(::operator new(n * sizeof(T))); }
    void deallocate(T* p, std::size_t) noexcept { ::operator delete(p); }
    template <typename U> bool operator==(const PoolAllocator<U>&) const { return true; }
    template <typename U> bool operator!=(const PoolAllocator<U>&) const { return false; }
};

std::vector<int, PoolAllocator<int>> v;                     // контейнер со своим аллокатором

Требования к аллокатору: value_type, allocate/deallocate, сравнение на равенство, rebind-конструктор. С C++17 большая часть boilerplate убрана через std::allocator_traits.

Когда аллокатор реально нужен. В обычном коде — почти никогда: стандартный std::allocator достаточно. Он оправдан в играх и высоконагруженных системах, где важны предсказуемость и борьба с фрагментацией: пул блоков фиксированного размера, арена, освобождаемая целиком, или память, размеченная под NUMA. На экзамене достаточно знать, что аллокатор отделяет стратегию от структуры данных и что allocator_traits предоставляет разумные значения по умолчанию.

Типичные ошибки.

  • Писать свой аллокатор «на всякий случай» — почти всегда оверкилл, сначала профилируйте.
  • Забыть noexcept у deallocate — стандарт требует это обещание, иначе возможны проблемы в vector::push_back при исключениях.
  • Пытаться в аллокаторе логировать или бросать исключения — аллокатор вызывается в «горячих» местах, любая лишняя работа бьёт по скорости.

Умные указатели и RAII

Умные указатели — это маленькие объекты-обёртки, которые сами следят за временем жизни выделенной памяти: при копировании/перемещении они корректно обновляют счётчики, а при разрушении — сами вызывают delete. Они избавляют от ручного delete и связанных с ним утечек и двойных освобождений. Они основаны на RAII — самой важной идиоме C++. На экзамене почти гарантированно спросят «что такое RAII» и «чем unique_ptr отличается от shared_ptr».

Мнемоника: RAII — автоматический уборщик. Вы не ходите по аудитории и не выключаете свет вручную; уходя, дверь закрывается сама. Ресурс захватывается в конструкторе, а освобождается в деструкторе — то есть ровно тогда, когда объект покидает область видимости, в том числе при исключении. Это работает даже когда между строк вылетает исключение: деструктор всё равно вызовется (так называемая «stack unwinding» — раскрутка стека), и ресурс освободится.

RAII (Resource Acquisition Is Initialization) связывает время жизни ресурса с временем жизни объекта: захват — в конструкторе, освобождение — в деструкторе. Умные указатели — его канонический пример. То же касается std::lock_guard для мьютексов, std::fstream для файлов, std::ofstream сам закроет файл в деструкторе, даже если вы забыли это сделать.

#include <memory>

std::unique_ptr<int> u = std::make_unique<int>(42);   // единоличное владение
// std::unique_ptr<int> u2 = u;                        // ошибка копирования
std::unique_ptr<int> u3 = std::move(u);               // передача владения

std::shared_ptr<int> s1 = std::make_shared<int>(7);   // разделяемое владение
std::shared_ptr<int> s2 = s1;                         // счётчик ссылок = 2
std::weak_ptr<int> w = s1;                            // не увеличивает счётчик: наблюдение

if (auto locked = w.lock()) std::cout << *locked;     // временно «повышаем»

unique_ptr vs shared_ptr vs weak_ptr. unique_ptr — эксклюзивный владелец, как единственный ключ от квартиры: можете передать ключ другу (std::move), но скопировать нельзя — у вас тогда было бы два ключа, а ключ один. Дешёвый, без накладных расходов. shared_ptr — общий доступ через счётчик ссылок: каждый новый владелец увеличивает счётчик на 1, при выходе — уменьшает, объект удаляется, когда счётчик доходит до нуля. Это как общий чат: пока кто-то в нём сидит, чат жив; последний вышел — чат удалился. weak_ptr — наблюдатель без владения: не мешает удалению объекта, нужен, чтобы разорвать циклические ссылки.

Почему make_unique и make_shared, а не new напрямую. make_unique<T>(args...) сразу передаёт аргументы в конструктор T без временного new T(...) — короче и безопаснее (нельзя «забыть» передать в умный указатель и получить утечку). make_shared<T>(...) дополнительно делает одну аллокацию под сам объект и под счётчик, а не две — это быстрее и снижает фрагментацию памяти.

Типичные ошибки.

  • Циклическая ссылка shared_ptr: A хранит shared_ptr<B>, B хранит shared_ptr<A> — счётчики никогда не дойдут до нуля, объект не удалится, утечка. Лечится заменой одной из ссылок на weak_ptr.
  • Вызов .lock() на просроченном weak_ptr — вернёт пустой shared_ptr, и разыменование без проверки → UB. Всегда проверяйте результат, как в примере выше.
  • shared_ptr для всего подряд — это дороже (атомарный счётчик, +несколько аллокаций). По умолчанию берите unique_ptr и переходите на shared_ptr только когда владение реально разделяется между независимыми частями программы.
  • delete через «голый» указатель, доставание его из unique_ptr.get() — вы нарушаете инвариант, умный указатель потом попытается удалить то же самое повторно (double free).
  • Передавать сырой указатель в функцию, если она не знает, что указатель «голый»: легко получить висячий указатель после unique_ptr выходит из скоупа.
Указатель Владение Копирование Когда нужен
unique_ptr единоличное запрещено (только move) владение по умолчанию, фабрики
shared_ptr разделяемое, атомарный счётчик разрешено общий ресурс, графы с общим узлом
weak_ptr не владеет разрешено разрыв циклов, кэши, наблюдатели

shared_ptr держит управляющий блок с двумя счётчиками (сильных и слабых ссылок); shared_ptr в цикле создаёт leak, лечится weak_ptr. make_unique/make_shared предпочтительнее new: одна аллокация для shared_ptr и безопасность исключений (если второй аргумент функции выбросит, уже выделенный объект не утечёт). Кастомный деструктор — через второй параметр: std::shared_ptr<FILE> f(fopen(...), fclose);.

Правило выбора. По умолчанию берите unique_ptr: он не имеет накладных расходов на счётчик и выражает «у ресурса один владелец». shared_ptr нужен только там, где владение действительно общее (граф, кэш, наблюдатели) — за атомарный счётчик приходится платить. Если shared_ptr в цикле ссылается сам на себя, счётчик никогда не дойдёт до нуля: одна из ссылок должна быть weak_ptr. И помните: weak_ptr не владеет, поэтому перед использованием его нужно «поднять» через lock(), проверив, что объект ещё жив.

«Правило пяти» на практике

На практике правило пяти редко пишут руками. Гораздо безопаснее — правило нуля: не хранить сырых владеющих указателей, а собрать класс из std::string, std::vector и std::unique_ptr. Тогда компилятор сгенерирует все операции правильно, а вы не забудете освободить ресурс.

Класс, владеющий ресурсом, проходит полный набор операций. Современный вариант — вообще не писать их:

class Document {
    std::string title_;                       // сами управляют памятью
    std::vector<std::string> paragraphs_;
    std::unique_ptr<Index> index_;
public:
    Document() : index_(std::make_unique<Index>()) {}
    // деструктор, копирование и перемещение сгенерированы корректно (rule of zero)
};

std::vector<Document> docs;
docs.push_back(Document{});                   // перемещение, а не глубокое копирование

В Document ни одного деструктора и ни одного operator=: все поля управляют памятью сами, и сгенерированные операции корректны. Это и есть правило нуля в действии — чем меньше ручного управления ресурсами, тем меньше шансов ошибиться.

Move-семантика и современный C++

Определение. Move-семантика — это набор правил и механизмов языка C++, позволяющих передавать «внутренности» одного объекта другому без поэлементного копирования. Вместо того чтобы «переписывать» содержимое, мы «перевешиваем» указатель на ресурс с одного владельца на другого.

Move-семантика — главное нововведение C++11 и любимая тема продвинутого потока МФТИ. Она позволяет не копировать ресурс, а «передать» его новому владельцу: у std::string можно перекинуть указатель на буфер, а не копировать все символы. Чтобы понять перемещение, сначала нужно разобраться в категориях значений — без них std::move кажется магией.

Зачем это нужно на практике. Представьте функцию, которая возвращает std::vector<std::string> на миллион элементов. Без move-семантики компилятор вынужден скопировать весь вектор при возврате — это и время, и память, и лишние аллокации в куче. С move-семантикой «буфер» (capacity, указатель на данные) просто переезжает к возвращаемому объекту: ноль копий, одна аллокация. На больших данных выигрыш — в десятки и сотни раз.

Мнемоника: move — передача ключа, а не копия ключа. У вас есть ключ от квартиры; вы можете отдать его другу (перемещение — у вас больше нет доступа, у друга есть), а можете сделать дубликат (копирование — ключей два). std::move — это заявление «отдаю ключ», а реальный «переезд» выполняет конструктор перемещения.

Почему именно так. До C++11 компилятор не различал «мне этот объект ещё пригодится» и «это временная мусорная копия, всё равно через секунду умрёт». Из-за этого он всегда выбирал самый безопасный путь — копирование. Появилась возможность сказать компилятору: «вот это — временный объект, его можно разобрать на запчасти». Отсюда две раздельные сущности — категория значения (lvalue/prvalue/xvalue) и тип ссылки (T& / T&&).

rvalue-ссылки и категории значений

Определение. Категория значения (value category) — это свойство выражения (не объекта!), отвечающее на вопрос: можно ли от этого выражения «передать ресурсы дальше» или оно обозначает что-то долгоживущее и именованное.

Категория значения отвечает на вопрос: это долгоживущий именованный объект или временный? От этого зависит, можно ли его «обобрать» при перемещении.

  • lvalue (locator value) — именованное выражение, у которого можно взять адрес (x, v[i]). Живёт долго, обирать нельзя. Буквально — «значение, у которого есть локация в памяти».
  • prvalue (pure rvalue) — чистый временный объект (x + y, литерал 42, результат вызова функции, не возвращающей ссылку). Это «безымянное» выражение, которое живёт до конца выражения, в котором появилось.
  • xvalue (eXpiring value) — объект, помеченный как «перемещаемый» (std::move(x)), «истекающее значение». У него ещё есть имя, но компилятору сказали: «его ресурсы можно забирать».

Аналогия. lvalue — это ваш собственный автомобиль с номерным знаком (есть «адрес» — номер). prvalue — одноразовая арендованная машина без номера (побыла и исчезла). xvalue — ваш же автомобиль, но вы подписали акт передачи: «я отдаю, кто следующий — тот и хозяин».

Тонкость для экзамена. Категория значения — это свойство выражения, а не переменной. У переменной int x = 1; есть имя, но x + 0 — это уже prvalue (результат выражения), а x в той же строке — это lvalue. Поэтому нельзя говорить «переменная — это lvalue», правильно — «выражение, состоящее из одного имени переменной — это lvalue».

Ссылка T&&, привязанная к шаблонному T, называется универсальной (forwarding): она сворачивается к T& для lvalue и к T&& для rvalue. Этот приём называется reference collapsing и работает только в шаблонах — за его пределами T&& означает «обычную rvalue-ссылку».

void sink(std::string&);         // только lvalue
void sink(std::string&&);        // только rvalue

std::string s = "abc";
sink(s);                         // lvalue → первая перегрузка
sink(std::move(s));              // xvalue → вторая, s «опустошён» (но валиден)

template <typename T>
void wrapper(T&& arg) {
    sink(std::forward<T>(arg));  // сохраняет категорию значения
}

Почему две перегрузки sink, а не одна. Перегрузка с std::string& гарантирует, что строка не изменится (или, как минимум, что мы имеем право читать). Перегрузка с && сообщает: «я могу забрать буфер, потому что вызывающий явно разрешил». Без раздельных перегрузок мы потеряли бы возможность «безопасно принять чужой объект и ничего с ним не сделать».

std::move ничего не перемещает — это всего лишь static_cast<T&&>, то есть приведение к rvalue-ссылке. Реальное перемещение делает конструктор (или оператор присваивания) перемещения, который вызывается благодаря этой категории. std::forward нужен именно в шаблонах, чтобы не превратить lvalue в rvalue: он сохраняет исходную категорию аргумента и реализует «идеальную передачу».

Типичная ошибка №1. Использовать std::move на const-объекте. После std::move(const_obj) категория становится xvalue, но конструктор перемещения имеет сигнатуру T(T&&) — она не примет const T&&. Компилятор «откатится» к конструктору копирования, и вы получите медленное копирование плюс выброшенное исключение, если кто-то попытается изменить объект. Решение: std::move от const — это антипаттерн, не делайте так.

Типичная ошибка №2. Вызвать std::move(s) и затем продолжить читать s. Классика: std::cout << s; сразу после std::move(s) — в лучшем случае увидите пустую строку, в худшем — мусор. Смотрите правило ниже.

Правило: после std::move объект находится в валидном, но неопределённом состоянии — его можно использовать только для присваивания или уничтожения. «Валидном» означает: деструктор сработает корректно, объект можно снова присвоить. «Неопределённом» — нельзя полагаться на его содержимое: строка может стать пустой, а может нет — это не контракт языка. Отсюда типичная ошибка — «переместил, а потом прочитал».

Аналогия. Это как «подарить» вещь: после акта дарения вы формально не должны ею пользоваться. Но вы можете положить её в коробку (деструктор) или купить новую (присвоить). А вот «подарить и спросить у получателя, сколько она теперь весит» — некорректный вопрос.

Подсказка для экзамена. Если в задаче видно std::move(x), а потом use(x) — это UB или, как минимум, ошибка логики. Корректный код: auto y = std::move(x); и дальше работать только с y.

Что появилось в стандартах

Зачем эта таблица. Экзаменаторы любят спрашивать «что нового в C++17?» или «чем C++20 отличается от C++14?». Не нужно помнить всё — нужно держать в голове «якорные» пункты по каждому стандарту.

Версия Ключевые нововведения
C++11 move-семантика, rvalue-ссылки, auto, лямбды, nullptr, constexpr, умные указатели, std::thread, variadic-шаблоны
C++14 обобщённые лямбды, auto в возвращаемом типе, бинарные литералы, std::make_unique
C++17 if constexpr, structured bindings, std::optional/variant/any, string_view, fold expressions, инлайн-переменные, параллельные алгоритмы
C++20 концепты, модули, ranges, корутины, std::span, spaceship-оператор <=>, calendar/timezone
C++23 std::expected, std::mdspan, std::print, import std, deducing this

Краткие пояснения к столбцам — что именно появилось и зачем.

  • C++11 — «перезагрузка языка». До этого C++ был «C с классами»; C++11 закрыл разрыв с managed-языками: лямбды заменили функторы, auto вывел тип без typeof, nullptr отделил «настоящий» ноль от целого числа (помните NULL == 0?), constexpr позволил считать константы на этапе компиляции, умные указатели (unique_ptr/shared_ptr/weak_ptr) убрали 90% ручной работы с new/delete, std::thread стандартизировал многопоточность.
  • C++14 — «полировка C++11». Лямбды научились принимать auto в параметрах ([](auto x){...}), появился std::make_unique, которого почему-то забыли в C++11 (приходилось писать std::unique_ptr<T>(new T(args)) — гарантия утечки при исключении).
  • C++17 — «сахар для шаблонов и типов». if constexpr убрал кошмар с std::enable_if. Structured bindings превратили уродливое auto p = std::make_pair(1, "a"); int a = p.first; в auto [a, b] = std::make_pair(1, "a");. Типы-обёртки (optional, variant, any) закрыли три главные «дыры»: «значение или его нет», «одно из нескольких» и «что угодно». string_view решил проблему «передать подстроку без аллокации».
  • C++20 — «большой рефакторинг». Концепты формализовали ограничения на шаблонные параметры (requires Integral<int> вместо enable_if<voodoo>). Ranges — алгоритмы, которые работают с «пайплайнами» вместо пары итераторов. Корутины позволили писать асинхронный код в стиле последовательного. Modules пытаются заменить #include (пока спорно). Spaceship-оператор <=> трёхстороннее сравнение: «меньше/равно/больше» одним выражением.
  • C++23 — «закрепители». std::expected<T, E> — «значение или ошибка» (типа Result из Rust), замена самодельных пар. std::print — наконец-то нормальный println. import std — стандартная библиотека как модуль. Deducing this (this Foo& self) — рекуррентные лямбды внутри классов.

Что это значит на практике. Не пытайтесь запомнить всё — держите в голове «якоря» по стандартам: C++11 принёс move и лямбды (с него начинается «современный C++»), C++14 — обобщённые лямбды и make_unique, C++17 — if constexpr, structured bindings и optional/variant, C++20 — концепты, ranges и корутины, C++23 — expected и print. На вопрос «что нового в C++ заданной версии» достаточно назвать два-три пункта и объяснить, зачем они: например, std::optional заменяет «магические» значения вроде -1, а std::variant — самодельные объединения с тегом.

Подсказка для экзамена. Если спрашивают «что появилось в C++XX?» — не перечисляйте всё подряд. Возьмите один пункт и объясните мотивацию: «std::optional появился, потому что раньше возвращали -1 или nullptr для обозначения «значения нет», и это путало с настоящими -1 и nullptr. Optional же делает «нет значения» частью типа».

const-корректность

Определение. Const-корректность (const-correctness) — это дисциплина проектирования кода, при которой каждый метод, параметр и переменная помечены const, если они не должны (и не могут) изменять наблюдаемое состояние объекта. Это не «рекомендация», а часть интерфейса и типа.

Const-корректность — это привычка заранее помечать то, что не меняется. Она делает интерфейс честнее: по сигнатуре видно, какие методы только читают, а какие меняют объект. Компилятор следит за соблюдением обещания, поэтому ошибки ловятся ещё при сборке.

Зачем это нужно, если и так «работает». Без const ссылку на объект можно случайно изменить из read-only-функции (через цепочку const_cast-ов, через глобальное состояние, через указатель внутри). С const компилятор запрещает любое изменение константного объекта на уровне типов. Это превращает целый класс багов «поменял не там» из runtime-в-классе в compile-error.

Аналогия. const — это запрет «выходить из комнаты с книгой». Библиотекарь (компилятор) проверяет при выдаче, и вы физически не можете вынести книгу, если попросили только почитать. Если попросили передать — T&, без const — можно забрать домой.

const — часть интерфейса и часть типа: константный метод вызывается у константного объекта, const&-параметр принимает всё и ничего не копирует.

class Matrix {
    std::vector<double> data_;
    std::size_t cols_ = 0;
public:
    double at(std::size_t r, std::size_t c) const { return data_[r * cols_ + c]; }
    const std::vector<double>& raw() const { return data_; }
};

void print(const Matrix& m);      // обещает не менять и не копировать

Тонкости, которые любят спрашивать.

  • Перегрузка по const. Можно иметь два метода с одним именем: T& value() и const T& value() const. Первая — для не-константного объекта (можно менять и присваивать), вторая — для константного (только чтение). Это легальная перегрузка по константности.
  • mutable — исключение из правила. Если поле помечено mutable, его можно менять даже в const-методе. Это используется для кешей и ленивых вычислений (std::once_flag, кэш результата). Использовать mutable для обычных данных — антипаттерн.
  • const и move. Нельзя переместить из const-объекта: конструктору перемещения нужна не-const ссылка. Это одна из причин, почему конструктор перемещения не должен быть помечен const.
  • Передача «всё что угодно». const T& принимает и lvalue, и rvalue (потому что привязка rvalue к const-ссылке разрешена). Именно поэтому в старом коде void f(const std::string&) ловит и f("hello"), и f(my_string).

Типичная ошибка. Забыть const у геттера, а потом пытаться вызвать его у const Matrix. Компилятор напишет «не найден метод at для const Matrix». Решение: вспомнить правило — если метод логически не меняет состояние, он обязан быть const-методом.

Подсказка для экзамена. Если в задаче функция void f(T x) принимает большой объект по значению — это либо (а) намеренное копирование, либо (б) ошибка, и должно быть void f(T const& x) или void f(T&& x). Спрашивайте на собеседовании: «почему не const&?» — правильный ответ демонстрирует понимание move-семантики.

Модель памяти (кратко)

Определение. Модель памяти (memory model) — это набор правил, описанных в стандарте, который формализует, что именно видит каждый поток о записях других потоков и в каком порядке. До C++11 этот вопрос оставался на «честном слове» каждой платформы.

Когда несколько потоков работают с одними данными, порядок операций перестаёт быть очевидным: компилятор и процессор могут переставлять чтения и записи. Модель памяти описывает, какие гарантии вам даёт стандарт и как их запросить.

Почему это вообще проблема. Без модели памяти ответ на вопрос «что увидит поток B, если поток A записал в x» зависит от версии gcc, флагов оптимизации, архитектуры процессора и фазы луны. C++11 впервые сказал: «вот правила, вот атомарные типы, вот уровни упорядочивания, и больше никакой фазы луны». Это сделало многопоточный C++ переносимым.

Стандарт C++11 формализовал модель памяти и data race: одновременная запись и чтение без синхронизации — UB. Инструменты: std::atomic<T> с уровнями memory_order (relaxed, acquire/release, seq_cst), мьютексы, std::thread, std::async. Уровни упорядочивания — это разная цена за разную степень гарантий: relaxed даёт только атомарность без порядка, acquire/release — синхронизацию между потоками, seq_cst — строгий глобальный порядок, самый простой для понимания и самый дорогой.

Аналогия уровней memory_order.

  • memory_order_seq_cst — «каждое действие во всей системе происходит в одном общем порядке, видном всем». Как если бы все потоки смотрели одну и ту же запись с камер наблюдения. Самая дорогая, но самая понятная модель; на большинстве платформ реализуется через полный memory barrier.
  • memory_order_acquire/release — «после release-записи другой поток с соответствующим acquire-чтением увидит все предыдущие записи». Это классический паттерн «producer-consumer»: один поток публикует данные через release-запись в указатель, другой подписывается через acquire-чтение того же указателя. Дешевле, чем seq_cst, но требует аккуратной пары.
  • memory_order_relaxed — «операция атомарна, но порядок относительно других операций не гарантирован». Подходит для счётчиков, которые никто не «защищает». Самый дешёвый, но самый коварный.
#include <atomic>
#include <thread>

std::atomic<int> counter{0};
void worker() { for (int i = 0; i < 1000; ++i) counter.fetch_add(1, std::memory_order_relaxed); }

int main() {
    std::thread a(worker), b(worker);
    a.join(); b.join();
    // counter == 2000 без гонки
}

Здесь важен именно std::atomic<int>: обычный int при одновременном ++ из двух потоков даст гонку и почти наверняка потеряет часть приращений. Атомарная операция выполняется неделимо. Если нужно изменить несколько связанных полей, одной атомарности мало — понадобится мьютекс.

Типичная ошибка №1. Думать, что «std::atomic решает все проблемы многопоточности». Нет: std::atomic<int> гарантирует только атомарность одной переменной. Если у вас структура struct { int a; int b; }, и два потока независимо меняют a и b, атомарность каждой по отдельности не спасает — нужен либо std::atomic<struct> (начиная с C++20, и то с оговорками), либо std::mutex, либо std::lock_guard.

Типичная ошибка №2. Использовать volatile вместо std::atomic. volatile в C++ — это только запрет оптимизации компилятора для отображения памяти в IO, а не средство синхронизации. Многопоточность с volatile — классический красный флаг в ревью.

Подсказка для экзамена. Если спрашивают «что такое data race?» — короткий ответ: «два потока обращаются к одной ячейке памяти, хотя бы один пишет, и нет happens-before связи между обращениями». Если есть хоть какой-то std::atomic с правильным порядком или мьютекс на пути — race нет, UB нет.

Простое правило выбора инструмента. В 95% случаев достаточно std::mutex + std::lock_guard. std::atomic с ручным memory_order нужен, когда (а) профилирование показало, что мьютекс — узкое место, или (б) вы пишете lock-free структуру данных. В обоих случаях вы уже знаете, что делаете. Начинающим — std::mutex и забудьте про relaxed.

Теория трансляции (МГУ, «Системы программирования»)

Определение. Теория трансляции — раздел информатики, изучающий, как из текста программы (написанной человеком) получается машинный код (исполняемый процессором). Само слово «трансляция» означает «перевод»: транслятор переводит с одного языка на другой, только в роли исходного языка выступает C++, а в роли целевого — машинные инструкции x86/ARM.

Теория трансляции отвечает на вопрос «как из текста программы получается машинный код». Это вторая половина билета МГУ ВМК: грамматики, автоматы, разбор и генерация кода. Зачем это будущему программисту, если компилятор уже написан?

Аналогия из жизни. Программист пишет на «верхнем» языке (C++, Python), процессор понимает только «нижний» (числа-коды операций). Транслятор — это переводчик с верхнего на нижний, причём работающий по строгим формальным правилам, а не «по смыслу», как живой переводчик. Чтобы перевод был точным, нужна грамматика (правила языка) и автомат (алгоритм, который применяет эти правила).

Чтобы читать сообщения об ошибках, писать парсеры (конфиги, DSL, языки запросов) и понимать, что именно оптимизирует компилятор.

Практические причины изучать теорию трансляции:

  • Сообщения об ошибках компилятора (expected ';' — это про синтаксис, undefined reference — про линковку, deduction failed — про шаблоны) становятся понятными, если вы знаете фазы трансляции.
  • Конфиги (JSON, YAML, TOML), DSL (внутренние языки компании), языки запросов (SQL-подобные) и форматы данных (CSV) разбираются парсерами — это та же теория в миниатюре.
  • Оптимизации компилятора (-O2, -O3) перестают быть «магией», когда вы понимаете, что именно компилятору доступно.

Жизненный цикл программного обеспечения

Определение. Жизненный цикл ПО (SDLC, Software Development Life Cycle) — это последовательность этапов, через которые проходит программа от зарождения идеи до прекращения сопровождения. Транслятор — лишь один из инструментов одного из этапов, но именно он превращает текст в то, что исполняет процессор.

Аналогия из жизни. Жизненный цикл ПО похож на постройку дома: сначала рисуют чертёж (проектирование), потом закупают материалы (требования и планирование), строят (реализация), принимают у комиссии (тестирование), сдают жильцам (внедрение), а потом делают ремонт и перепланировки (сопровождение). Транслятор — это «строительный кран», без которого не собрать стены из кирпичей исходного кода в здание исполняемого файла.

Анализ требований → проектирование → реализация → тестирование → внедрение → сопровождение.

Что происходит на каждом этапе (для контекста на экзамене):

  • Анализ требований — заказчик формулирует, что должна делать программа. Результат: ТЗ (техническое задание), user stories.
  • Проектирование — выбирают архитектуру, модули, алгоритмы, структуры данных. Результат: диаграммы, спецификации модулей.
  • Реализация — пишут код. Именно здесь включается транслятор: g++ main.cpp -o main переводит текст в исполняемый файл.
  • Тестирование — проверяют, что код делает то, что обещали требования. Транслятор здесь тоже работает: тесты собираются тем же компилятором.
  • Внедрение — программа попадает к пользователю (релиз, деплой).
  • Сопровождение — исправление багов, добавление функций, миграция на новые версии.

Транслятор появляется на этапе реализации: он превращает исходный текст в исполняемый код.

Почему именно этап реализации. До реализации кода нет — нечего транслировать. После реализации код уже работает и его отлаживают, не меняя сам факт перевода. На экзамене достаточно перечислить этапы по порядку и показать, где находится трансляция: после проектирования и до тестирования. Смысл вопроса — показать, что вы видите программу как процесс, а не только как набор функций.

Типичная ошибка студента. Путать «жизненный цикл ПО» с «жизненным циклом программы при компиляции» (препроцессинг → компиляция → ассемблирование → линковка). Это разные вещи: первое — организационное про весь проект от идеи до сопровождения, второе — техническое про то, что происходит с одним файлом при сборке.

Виды трансляторов

Определение. Транслятор — это программа, которая переводит (транслирует) входное представление алгоритма в выходное. Виды различаются тем, что на входе и что на выходе, и когда происходит перевод.

Аналогия из жизни. Представьте разные способы прочесть лекцию иностранному студенту. Можно заранее перевести весь текст в письменный перевод и отдать ему (это компилятор). Можно сидеть рядом и переводить каждое предложение по очереди (это интерпретатор). Можно дождаться, пока он переспросит сложное место, и тогда уже объяснить подробно (это JIT-компилятор, который компилирует «горячие» участки). А «препроцессор» — это корректор, который сначала правит опечатки, а уже потом отдаёт основному переводчику.

  • Компилятор — переводит весь модуль целиком до запуска, порождая объектный или исполняемый код. Запуск программы = запуск уже готового машинного кода.
  • Интерпретатор — исполняет программу по частям, читая очередную конструкцию и сразу выполняя её, без порождения отдельного машинного кода.
  • Ассемблер — частный случай компилятора: транслирует язык ассемблера (мнемоники вроде MOV, ADD) в машинный код. Отдельный инструмент (as, nasm), потому что исходный текст очень близок к машинному коду.
  • Препроцессор — текстовая подготовка исходника перед основной трансляцией (раскрытие #include, #define, #if). Выход препроцессора — чистый C++-файл, без директив.
  • Кросс-компилятор — генерирует код для платформы, отличной от той, где работает сам. Например, на ноутбуке собирают .exe под Windows или .hex для микроконтроллера STM32.
  • JIT-компилятор — компилирует во время исполнения (JVM, .NET, V8). Программа стартует в интерпретируемом режиме, а «горячие» функции компилируются в машинный код по ходу работы.

Сравнительная таблица по поведению (полезно для устного ответа):

Вид Когда переводит Что порождает Пример
Компилятор до запуска машинный код g++, clang++
Интерпретатор во время исполнения действия python, bash
Ассемблер до запуска машинный код nasm, as
Препроцессор до основной трансляции текст на C++ cpp, встроен в gcc -E
Кросс-компилятор до запуска машинный код чужой платформы arm-none-eabi-g++
JIT во время исполнения машинный код, кэшируемый HotSpot JVM, V8

Мини-пример. g++ main.cpp -o main — это компилятор: получаем готовый исполняемый файл до запуска. python script.py — интерпретатор: код выполняется по мере чтения. А node с V8 сначала интерпретирует JavaScript, а «горячие» функции компилирует в машинный код на лету — это JIT.

Почему различия важны на практике:

  • Компилятор даёт быстрый старт (код уже готов) и раннее обнаружение ошибок (все типы проверены до запуска), но требует пересборки под каждую платформу.
  • Интерпретатор даёт переносимость (один и тот же байт-код или исходник работает везде, где есть интерпретатор) и гибкость (можно менять код «на лету»), но медленный старт.
  • JIT — компромисс: стартует быстро как интерпретатор, а потом для частых функций получает скорость компилятора. Минус — сложность реализации и непредсказуемые паузы на «прогрев».

Формальные грамматики и классификация Хомского

Определение. Грамматика в теории трансляции — это математическое описание того, какие цепочки символов «правильные» (принадлежат языку), а какие нет. Идея в том, чтобы задать язык не перечислением всех допустимых программ (их бесконечно много), а конечным набором правил.

Аналогия из жизни. Грамматика — это свод правил русского языка, по которому можно понять, какие предложения грамматически верны, а какие нет. Правила конечны, а корректных предложений — бесконечно. Точно так же: правила грамматики C++ конечны (хотя и сложны), а программ, которые им удовлетворяют, — бесконечно много.

Классификация Хомского упорядочивает грамматики по «силе» (выразительности): от неограниченных (тип 0, могут описать вообще всё, что можно описать алгоритмически) до регулярных (тип 3, описывают самые простые языки). Чем сильнее грамматика, тем сложнее её распознаватель.

Грамматика — четвёрка G = (T, N, P, S):

  • T — терминалы (реальные символы, которые встречаются в программе: цифры, буквы, +, ;, скобки).
  • N — нетерминалы (вспомогательные понятия, которые описывают структуру: «выражение», «оператор», S — стартовый).
  • P — правила вывода (как одно понятие заменяется другим, например E → E + T).
  • S — начальный символ (с чего начинается вывод, обычно обозначается S или верхний нетерминал грамматики).

Например, для языка «десятичное число без ведущих нулей» можно задать правило число → цифра | число цифра. Здесь цифра — терминал из T, число — нетерминал из N. Из правил видно, что цепочка 123 выводится как число → число цифра → число цифра цифра → цифра цифра цифра → 123 (три применения правил → три цифры в числе).

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

Тип Название Вид правил Распознаватель
0 Неограниченные α → β машина Тьюринга
1 Контекстно-зависимые αAβ → αγβ линейно-ограниченный автомат
2 Контекстно-свободные A → γ магазинный автомат, МП-автомат
3 Регулярные A → aB / A → a конечный автомат

Что значат правила в таблице:

  • Тип 0: α и β — любые строки, левая часть не короче правой (в смысле теории, могут быть исключения). Самый широкий класс.
  • Тип 1 (КС-зависимые): нетерминал A заменяется на γ только в окружении α...β — контекст важен. Нужны для языков, где значение зависит от соседей (например, aⁿbⁿcⁿ — поровну a, b и c).
  • Тип 2 (КС): A → γ — один нетерминал заменяется на цепочку терминалов и нетерминалов, контекст неважен. Описывает вложенные структуры.
  • Тип 3 (регулярные): A → aB или A → a — справа только один терминал и (опционально) один нетерминал. Самый узкий класс.

Почему вложенность лежит именно на уровне КС. Последовательность скобок (()) — это «конструкция из вложенных одинаковых пар», и для её распознавания нужна память, соответствующая глубине вложенности. Конечный автомат (тип 3) такой памяти не имеет, поэтому скобочные структуры описываются минимум КС-грамматикой.

Языки программирования в основном описываются КС-грамматиками, лексика — регулярными. Дерево разбора отражает вывод; для однозначных грамматик оно единственно.

Что это значит на практике. Грамматика типа 3 (регулярная) распознаётся конечным автоматом — так устроены лексеры: они режут текст на токены (имена, числа, операторы). Грамматика типа 2 (КС) требует магазина — так устроены парсеры выражений: вложенность скобок и блоков нельзя описать конечным автоматом. Понимание этого разделения объясняет, почему в компиляторе два независимых этапа — лексический и синтаксический анализ.

Практический совет на экзамене. Если видно правило с пустой правой частью (A → ε) и нетерминалом слева в той же строке (например, E' → + T E' | ε) — это почти всегда КС-грамматика с ε-правилами, типичный ход для устранения левой рекурсии (см. раздел про рекурсивный спуск ниже).

НКА, ДКА и детерминизация

Определение. Конечный автомат — это «машина состояний»: у неё есть текущее состояние и переходы по символам. Он читает входную цепочку по одному символу и переходит из состояния в состояние; если после прочтения всего входа автомат оказался в допускающем состоянии — цепочка «принята» (допускается). НКА и ДКА различаются строгостью переходов, и любой НКА можно превратить в ДКА — это и есть детерминизация.

Аналогия из жизни. Автомат похож на путешественника по карте города с улицами-символами. Каждый перекрёсток — состояние. ДКА — это карта с односторонним движением и понятной навигацией: на каждом перекрёстке ровно одна улица с данным названием ведёт наружу. НКА — карта старого района, где на одном перекрёстке может быть три одинаковые улицы, и ещё есть порталы (телепорты = ε-переходы), которые переносят без чтения символа.

Мнемоника: ДКА — турникет, НКА — развилка. Турникет точно знает, куда ведёт следующая карта (одно состояние на пару «состояние, символ»). Развилка допускает несколько путей одновременно, но её всегда можно переиграть в набор турникетов.

ДКА — автомат с ровно одним переходом на каждую пару (состояние, символ). У одного состояния для одного символа нет двух альтернатив. НКА допускает несколько переходов по одному символу из одного состояния и ε-переходы (переходы без чтения символа, «по воздуху»). Любой НКА эквалентен некоторому ДКА (теорема о детерминизации), но полученный ДКА может экспоненциально превзойти исходный НКА по числу состояний (классический пример — язык (a|b)*abb, где НКА компактный, а наивный ДКА разрастается).

Детерминизация (подмножества):
  start = ε-closure({s0})
  для каждого состояния D и символа a:
      D' = ε-closure( move(D, a) )
  допускающие ДКА — подмножества, содержащие допускающее состояние НКА

Мини-пример, НКА для (a|b)*abb:
  s0 -a,b-> s0 ; s0 -a-> s1 ; s1 -b-> s2 ; s2 -b-> s3(финальное)
  Детерминизация стартует с подмножества {s0} и достраивает состояния
  {s0,s1}, {s0,s2}, {s0,s3} по символам a и b.

Как читать алгоритм детерминизации (пошагово):

  1. Начальное состояние нового ДКА — это ε-замыкание множества, состоящего из начального состояния НКА (то есть сам s0 плюс всё, куда можно долететь «по воздуху»).
  2. Для каждого ещё не рассмотренного состояния D (которое на самом деле подмножество состояний НКА) и каждого входного символа a собираем все состояния, в которые НКА может перейти из любого состояния подмножества D по символу a, и берём их ε-замыкание.
  3. Полученное множество становится новым состоянием ДКА (если его ещё не было).
  4. Состояние ДКА помечается допускающим, если в нём есть хотя бы одно допускающее состояние исходного НКА.

Типичная ошибка студента. Забыть про ε-замыкание. Например, считать, что move({s0}, a) — это то же самое, что результат перехода. На самом деле move — это «куда можно шагнуть по символу», а ε-closure — «куда ещё можно долететь бесплатно». Без ε-замыкания состояния теряются, и автомат перестаёт быть эквивалентным исходному НКА.

Зачем это будущему программисту. Регулярные выражения, лексические анализаторы, валидация ввода, поиск по шаблону — всё это конечные автоматы. Инженер, понимающий детерминизацию, представляет, почему «жадный» шаблон иногда откатывается и почему НКА с ε-переходами может быть удобнее в описании, но дороже в исполнении на большом числе состояний. Практический вывод: библиотека регулярных выражений сначала строит НКА из регулярного выражения (это удобно и компактно), а потом детерминизирует его в ДКА ради скорости матчинга.

Метод рекурсивного спуска

Определение. Рекурсивный спуск — это нисходящий алгоритм синтаксического разбора, в котором каждому нетерминалу грамматики соответствует функция (или метод), которая читает входную цепочку и вызывает функции других нетерминалов в соответствии с правилами. Это самый «человеческий» способ разбора: код почти один в один повторяет грамматику, поэтому его легко читать и отлаживать.

Аналогия из жизни. Представьте генерала, который отдаёт приказы через цепочку подчинённых: «разведка, доложи обстановку» → разведка зовёт наблюдателя → наблюдатель зовёт связиста и т.д. Каждый уровень вызывает следующий, а потом возвращает свой результат наверх. Так же работает парсер: функция parseExpr() вызывает parseTerm(), та — parseFactor(), та — саму себя или функции для терминалов. Вложенность вызовов отражает вложенность конструкций в исходном тексте.

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

Нисходящий разбор без возвратов: каждому нетерминалу соответствует функция.

Критерий применимости — грамматика должна быть LL(1): без левой рекурсии и с однозначным выбором правила по одному символу (направляющему множеству FIRST/FOLLOW). Смысл LL(1) прост: посмотрев на один входной токен вперёд, парсер обязан понять, какое правило применить, иначе спуск с возвратами станет медленным и сложным.

Расшифровка LL(1):

  • первая L — разбор слева направо (как читаем),
  • вторая L — строим левый вывод (левая ветка дерева растёт первой),
  • (1) — смотрим на 1 токен вперёд для принятия решения.

Если FIRST-множества правил одного нетерминала пересекаются — значит, по одному токену невозможно выбрать правило, и рекурсивный спуск либо становится недетерминированным, либо требует возвратов (backtracking), что сильно усложняет код.

Алгоритм проверки «можно ли применить рекурсивный спуск» (для самоконтроля):

  1. Устранить левую рекурсию.
  2. Проверить, что для каждого нетерминала FIRST-множества его правил попарно не пересекаются. Если пересекаются — нужен либо lookahead больше 1 (тогда это уже LL(k)), либо факторизация (выделение общего префикса в отдельное правило).
E  → T E'
E' → + T E' | ε
T  → F T'
T' → * F T' | ε
F  → ( E ) | number

Что показывает этот пример. Классическая грамматика арифметических выражений уже в форме, пригодной для рекурсивного спуска: нет левой рекурсии (она устранена введением E' и T'), + и * — однозначные направляющие символы, ε-правила срабатывают, когда очередной токен ; или ) (то, что идёт в FOLLOW(E')).

// Фрагмент рекурсивного спуска: разбор суммы
int parseExpr();
int parseTerm();

int parseExpr() {
    int value = parseTerm();
    while (peek() == '+') {      // левая рекурсия заменена циклом
        next();
        value += parseTerm();
    }
    return value;
}

Как читать этот код. В исходной грамматике правило было E → E + T, что вызвало бы бесконечный вызов parseExpr() из самого начала. После устранения левой рекурсии правило стало E → T E', E' → + T E' | ε. В коде это выглядит так: первый шаг — parseTerm() (это бывший T), а дальше в цикле while мы повторяем «хвост» E' — пока видим +, читаем его, разбираем следующий T и прибавляем.

Левая рекурсия устраняется преобразованием A → Aα | β в A → βA', A' → αA' | ε. Интуиция: в исходном правиле левый операнд повторяется, и функция parseExpr() вызвала бы саму себя первой же строкой — получилась бы бесконечная рекурсия. Замена превращает повторение в цикл while (как в примере выше): сначала разбираем «базу» β, затем добираем повторы α в цикле.

Пошаговая интуиция устранения левой рекурсии:

  1. В правиле A → Aα₁ | Aα₂ | β₁ | β₂ | γ — слева A повторяется, и это мешает нисходящему разбору (функция вызовет сама себя и зациклится).
  2. Заменяем на пару правил A → β₁A' | β₂A' | γA' и A' → α₁A' | α₂A' | ε.
  3. В коде parseA() делает выбор по FIRST(β...) или FIRST(γ), вызывает соответствующую «базу», а потом в цикле достраивает «хвосты» через parseAprime().

Типичные ошибки при ручном устранении левой рекурсии:

  • Забыть правила, которые начинаются НЕ с A (все такие правила становятся «базами» β и должны быть перечислены в правой части нового A).
  • Забыть про ε-правило в A' (без него нельзя закончить разбор, если α не встречается дальше).
  • Не посмотреть на FIRST/FOLLOW после преобразования — может оказаться, что на стыке правил по-прежнему неоднозначность.

Зачем это будущему программисту. Рекурсивный спуск — это то, что вы напишете, если понадобится разобрать свой мини-язык: конфиг, выражение формул, простой DSL. Понимание FIRST/FOLLOW помогает вовремя заметить неоднозначную грамматику и переписать её, а не бороться с загадочными ошибками парсинга. Совет: при написании своего парсера начинайте с явной грамматики в БНФ-нотации, проверьте её на LL(1), а уже потом превращайте в код рекурсивного спуска — это экономит часы отладки.

ПОЛИЗ и синтаксически управляемый перевод

Определение. ПОЛИЗ (польская инверсная запись, также известная как постфиксная запись) — это способ записи выражений, в котором операция идёт после своих операндов. Запись названа «инверсной» потому, что это «перевёрнутая» польская запись Яна Лукасевича (в исходной префиксной операция шла перед операндами). Удобен тем, что вычисляется однозначно слева направо с помощью стека: не нужно помнить о приоритетах и вложенности. Компиляторы и интерпретаторы часто используют такую внутреннюю форму для промежуточного представления.

Аналогия из жизни. Обычное выражение 2 + 3 в школе пишут «операция между операндами» (инфикс). В ПОЛИЗе то же самое выглядит как 2 3 + — как будто вы выстроили операнды в очередь, а оператор «забирает» сверху два числа и кладёт результат обратно. Так работает калькулятор на обратной польской записи: набираешь 2 Enter 3 +, и получаешь 5.

ПОЛИЗ (постфиксная запись) не требует скобок: операнды идут перед операцией.

Инфикс:  (a + b) * c
ПОЛИЗ:   a b + c *
Вычисление стеком: a b + → (a+b); затем * c → результат

Пошаговое вычисление стеком (для ответа на экзамене «как работает машина ПОЛИЗ»):

  1. Читаем токен a — кладём в стек: [a].
  2. Читаем b — кладём: [a, b].
  3. Читаем + — выталкиваем два верхних значения b и a, вычисляем a + b, кладём результат: [a+b].
  4. Читаем c — кладём: [a+b, c].
  5. Читаем * — выталкиваем c и a+b, вычисляем (a+b) * c, кладём результат: [результат].
  6. Вход закончился — в стеке один элемент, это и есть значение выражения.

Почему не нужны скобки. Приоритет операций «зашит» в порядок записи: если * должно выполниться раньше +, его просто пишут раньше в выводе. Скобки в инфиксе нужны именно потому, что человек не хочет запоминать таблицу приоритетов и группирует операции явно. Стек-машина работает строго по порядку и в скобках не нуждается.

Синтаксически управляемый перевод приписывает правилам грамматики семантические действия: например, при разборе выражения формируется последовательность инструкций по ЛОКАЦИЯМ (адресам) — это и есть генерация ПОЛИЗ. Действие выполняется в момент применения правила, поэтому генерацию кода можно «вшить» прямо в парсер, не строя дерево разбора в явном виде.

Как это выглядит на практике (для самопроверки). Каждой переменной или константе выделяется «локация» — место в памяти или регистр. Семантическое действие правила F → number — выделить новую локацию, положить туда значение числа, выдать в выходную последовательность имя локации. Действие правила E₁ → E₂ + T — выделить локацию для результата, выдать последовательность «возьми значение из локации(E₂), возьми значение из локации(T), выполни +, положи в локацию(E₁)». Так каждое применение правила дописывает «кусочек» ПОЛИЗА в общий выход.

Сравнение с другими внутренними представлениями:

  • Дерево разбора (AST) — сохраняет структуру, удобно для оптимизаций, но требует отдельного обхода для генерации кода.
  • Трёхадресный код — каждая инструкция имеет вид x = y op z, ближе к машинному коду, используется в GCC/Clang в виде SSA.
  • ПОЛИЗ — компактен, удобен для интерпретации стековой машиной (JVM-байт-код очень близок к этому), легко генерируется синтаксически управляемым переводом.

Зачем это будущему программисту. Стек-машина ПОЛИЗ лежит в основе байт-кода JVM и .NET, вычисления выражений в интерпретаторах и калькуляторах. Если вы поймёте, как инфиксное выражение превращается в постфиксное и вычисляется стеком, вы сможете написать свой вычислитель формул за десятки строк.

Алгоритм перевода инфикса в ПОЛИЗ (классический, через приоритеты):

  1. Завести два стека: для операторов и для выходной последовательности.
  2. Числа и переменные сразу выводить.
  3. Оператор — класть в стек операторов, при этом выталкивая из стека все операторы с большим или равным приоритетом и выводя их в результат.
  4. Открывающую скобку — класть в стек операторов как «потолок приоритета».
  5. Закрывающую скобку — выталкивать и выводить операторы до открывающей скобки, саму скобку удалить.
  6. В конце входа — вытолкнуть всё, что осталось в стеке операторов.

Типичная ошибка студента. Сравнивать приоритеты «на глаз» и забыть про правило ассоциативности: при одинаковом приоритете (+ и +) левый оператор должен вытолкнуться раньше правого (для левоассоциативных операций). Это даёт корректный порядок вычисления a + b - c как ((a+b) - c), а не (a + (b - c)).

Оптимизация и распределение памяти

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

Оптимизации: свёртка констант (2 + 3 заменяется на 5 ещё при компиляции), удаление мёртвого кода (недостижимые ветки), вынесение инвариантов из цикла (вычисление, не зависящее от счётчика, поднимается наружу), кэширование подвыражений, разворачивание циклов (снижает накладные расходы на проверку условия и прыжки). Распределение памяти: статическая область (глобальные переменные и константы), стек (локальные переменные и кадры вызовов), куча (new/malloc), а для объектного кода — регистры под наиболее горячие переменные. Соглашения вызова (cdecl, stdcall, fastcall) задают, кто и как чистит стек при вызове функции: аргументы кладут в регистры или на стек, а результат возвращают через регистр.

Зачем это будущему программисту. Понимание областей памяти объясняет, почему локальная переменная «умирает» при выходе из функции, почему большие массивы нельзя держать на стеке (переполнение стека), и почему new дороже локальной переменной. А оптимизации полезно узнавать в дизассемблере: если вы видите, что компилятор вынес вычисление из цикла, значит, ваш код уже эффективен — не нужно «оптимизировать» вручную то, что и так сделано.

Алгоритмы и структуры данных (МФТИ/МГУ)

Этот блок — про то, как хранить данные и искать в них быстро. Алгоритм — это точная пошаговая инструкция решения задачи, а структура данных — это контейнер, в котором информация лежит в удобном для обработки виде. Базовый поток МФТИ спрашивает обходы графов и сортировки, МГУ добавляет автоматы и регулярные языки, продвинутый поток — хеширование, деревья и кучу. Здесь важна не только реализация, но и асимптотика: на зачёте почти всегда просят назвать сложность и объяснить, почему она такая. Поэтому перед кодом полезно проговаривать: «что лежит во входе, что на выходе, сколько памяти и времени мы тратим — и почему именно столько».

Хеширование

Хеш-функция — это «отпечаток» данных: число фиксированного размера, которое вычисляется из объекта по детерминированному правилу. Хорошая хеш-функция распределяет близкие, но разные входы по максимально разным значениям. Аналогия из жизни: как отпечаток пальца у человека — он короткий, но почти уникальный; двух людей с одинаковым отпечатком «в природе» найти сложно. Хеш позволяет сравнивать большие объекты быстро: сначала сравнили хеши (это O(1) — одно число с одним числом), а содержимое проверяем только при совпадении. Главная тонкость — коллизии: разные объекты могут дать одинаковый хеш, и это нужно обрабатывать, потому что на практике идеальных хеш-функций не бывает.

Полиномиальный хеш строки: h(s) = ((s0·p^(n-1) + s1·p^(n-2) + ... ) mod m), считается префиксными суммами за O(1) на подстроку. Идея в том, что строка трактуется как значение многочлена в точке p, а коэффициенты — это коды символов. Если две строки разной длины дали одинаковый остаток от деления на m, считаем их равными с точки зрения хеша. Почему так быстро: мы префиксно насчитываем массив h[i] = h[i-1]*p + s[i-1], и хеш подстроки [l, r) получается как h[r] - h[l]·p^(r-l). Остаётся только предподсчитать степени p, и каждая подстрока — это два обращения к массиву.

// Полиномиальный префиксный хеш (переполнение беззнакового — по модулю 2^64)
std::vector<unsigned long long> build_hash(const std::string& s, unsigned long long p = 131) {
    std::vector<unsigned long long> h(s.size() + 1, 0);
    for (std::size_t i = 0; i < s.size(); ++i)
        h[i + 1] = h[i] * p + static_cast<unsigned char>(s[i]);
    return h;
}
double hash_of(const std::vector<unsigned long long>& h, int l, int r);   // h[r] - h[l]*p^(r-l)

Типичные ошибки и подводные камни.

  • Забывают static_cast<unsigned char>(s[i]) — без него знаковый char даст отрицательные коды, и хеши «поедут».
  • Используют int вместо unsigned long long — переполнение в C++ для знаковых типов не определено, а нам как раз нужен «естественный» модуль 2^64.
  • Считают, что одного хеша достаточно для задач про антиплагиат — на больших объёмах коллизии неизбежны, поэтому в спорных случаях берут два независимых хеша с разными основаниями/модулями.

Рабин–Карп — поиск образца за среднее O(n+m) сравнением хешей со скользящим окном. Почему «среднее»: если хеш редко совпадает, мы просто сдвигаем окно за O(1) на символ, итого O(n) сравнений хешей плюс O(m) на первый. Если же много совпадений хешей, алгоритм проверяет содержимое посимвольно, что в худшем случае превращает его в O(n·m). Фильтр Блума — битовый массив без ложных отрицаний, но с ложными срабатываниями; применяется для «точно нет» перед дорогой проверкой. Идея фильтра Блума: если биты для элемента не выставлены, элемента точно нет; если выставлены — возможно, он есть (ложное срабатывание). Поэтому его ставят перед обращением к диску или сети: дешёвая проверка отсеивает заведомо отсутствующие ключи. Аналогия: охранник на входе в клуб со списком VIP-гостей — если в списке есть имя, это ещё не значит, что человек VIP (возможно, тёзка), но если имени точно нет — дальше можно не проверять.

Зачем это будущему программисту. Хеши — основа словарей, кэшей, дедупликации и проверки целостности данных (например, контрольные суммы файлов в git, md5, sha256). Полиномиальный хеш применяют для быстрого сравнения подстрок и в задачах на строки; важно помнить о риске коллизий (модуль и основание подбирают так, чтобы вероятность совпадения была пренебрежимо мала, а иногда берут два независимых хеша). На собеседованиях любят вопрос «а что будет, если кто-то подаст специально подобранные строки?» — отвечайте: «атакующий может спровоцировать много коллизий, поэтому в продакшене используют криптографические хеши или рандомизацию».

Хеш-таблицы

Хеш-таблица — это массив «корзин», в которую элемент попадает по значению хеша. Аналогия: зал с пронумерованными камерами хранения; ключ у посетителя — это «хеш», по которому мы говорим «иди в камеру №17». Если камера занята — есть две стратегии: либо повесить ещё один крючок внутри (цепочки), либо пройтись до следующей свободной (открытая адресация). В среднем операции занимают O(1), но при плохом хеше всё вырождается в линейный поиск — все посетители оказываются в одной камере, и очередь в неё превращается в список. Это ровно то, что внутри std::unordered_map (и std::unordered_set — он устроен так же, только значения не хранит).

  • Цепочки (chaining): корзина — список; при плохом хеше вырождается в O(n). Простая в реализации, легко удалять, дополнительная память на указатели.
  • Открытая адресация: линейное/квадратичное пробирование, двойное хеширование; требует коэффициента заполнения < 1. Экономит память (нет указателей), но удаление сложнее — нужно помечать «удалённые» ячейки, иначе поиск сломается.
  • Перехеширование при превышении load factor: размер умножают на ~2 и переносят элементы (амортизированно O(1)). На практике load factor держат ниже 0.7–0.75, чтобы пробирования не вырождались в длинные серии.
Операция Среднее Худшее
Поиск O(1) O(n)
Вставка O(1) O(n)
Удаление O(1) O(n)

Что это значит на практике. «Среднее O(1)» держится, только пока хеш распределяет ключи равномерно и коэффициент заполнения невелик. Отсюда reserve() у unordered_map: он заранее выделяет достаточно корзин и убирает серию перехеширований. Почему это важно: если вы знаете, что в словаре будет примерно N элементов, скажите reserve(N) один раз — иначе контейнер будет расти по степеням двойки, перетасовывая всё содержимое на каждом расширении. Худший случай O(n) достигается, если все ключи попали в одну корзину (плохой хеш или злонамеренный ввод). Именно поэтому в промышленных реализациях защищаются от «hash flooding», добавляя случайную соль. Типичная ошибка студентов — использовать unordered_map для пар «ключ-ключ» как замену сортировки слиянием; помните, что порядок обхода в unordered_map не определён, и для задач, где важен порядок, нужен std::map.

Сбалансированные деревья

Дерево поиска хранит данные упорядоченно, поэтому поддерживает поиск, вставку, удаление и обход в порядке возрастания за O(log n). Аналогия: библиотечный каталог, в котором книги расставлены по разделам, а внутри каждого раздела — по полкам; искать нужную книгу мы идём «по лестнице» от корня, на каждом шаге отсекая половину вариантов. Но обычное дерево поиска может «выродиться» в список при неудачных вставках (например, если подавать отсортированные данные — каждый новый элемент становится правым потомком предыдущего, и глубина растёт линейно). Отсюда идея балансировки: держать дерево «пушистым», чтобы глубина оставалась логарифмической.

АВЛ-дерево поддерживает разность высот ≤ 1; балансировка — повороты (LL, RR, LR, RL), высота O(log n). Поиск/вставка/удаление — O(log n), но частые повороты при вставке. Зачем вообще «разность высот»: это простой инвариант, который легко поддерживать и проверять; после каждой модификации мы поднимаемся вверх и, если где-то баланс нарушен, делаем один из четырёх поворотов — и всё дерево снова в равновесии.

Красно-чёрное дерево слабее сбалансировано (высота ≤ 2·log n), зато дешевле в перестройке; на нём основаны std::map/std::set. Узлы раскрашены в красный и чёрный цвета, и инвариант про пути от корня до листьев: ни один путь не более чем вдвое длиннее другого. Это даёт чуть большую глубину, но каждая вставка/удаление перекрашивает и поворачивает меньше.

Что выбрать: АВЛ или красно-чёрное? АВЛ строже балансирует, поэтому поиск чуть быстрее, но вставка и удаление чаще требуют поворотов. Красно-чёрное дерево допускает больший разброс высот, зато перестраивается реже — это выгоднее для смешанной нагрузки, поэтому стандартные map/set построены на нём. Аналогия: АВЛ — как педантичный библиотекарь, который после каждой новой книги переставляет полки, чтобы всё было идеально ровно; красно-чёрное — как прагматичный, который допускает небольшой перекос, зато быстрее обслуживает читателей.

// AVL: правый поворот вокруг узла y
Node* rotateRight(Node* y) {
    Node* x = y->left;
    Node* t = x->right;
    x->right = y;
    y->left = t;
    updateHeight(y);
    updateHeight(x);
    return x;                 // новый корень поддерева
}

Типичные ошибки. Забывают обновить высоту у обоих узлов после поворота — и тогда балансировка «ломается», хотя код компилируется. Путают LL и RR случаи — на экзамене проверяйте, какое поддерево «тяжелее». Ещё частая ошибка: думать, что АВЛ и красно-чёрное дерево — это разные структуры, в которые нужно класть разные данные; на самом деле интерфейс у них один (как у std::map), разница только в стратегии балансировки внутри.

Конечные автоматы и регулярные языки

Конечный автомат — это воображаемая машина с конечным числом состояний, которая читает входной поток по одному символу и переходит между состояниями по правилам. Регулярный язык — это множество строк, которое можно описать либо регулярным выражением, либо автоматом. Аналогия: турникет в метро принимает монеты по одной; пока есть монеты нужного номинала — он «в состоянии» принять ещё одну, как только накопил нужную сумму — открывается (это допускающее состояние). Здесь сходятся теория трансляции и алгоритмы: одно и то же понятие вы встретите и в лексере (разбор текста программы на токены), и в задаче на строки.

Алгоритм Томпсона строит НКА по регулярному выражению с ε-переходами; детерминизация даёт ДКА; минимизация (алгоритм Хопкрофта) объединяет эквивалентные состояния за O(n log n). Зачем нужна детерминизация: в НКА из одного состояния по одному символу можно перейти в несколько, и при чтении входа нужно исследовать все ветки — это медленно и плохо предсказуемо. ДКА делает ровно один переход на каждый символ, и его можно реализовать одной таблицей state[состояние][символ] → новое_состояние. Зачем минимизация: два состояния, которые неразличимы (ведут себя одинаково на любом суффиксе), можно склеить; минимальный ДКА — это каноническая форма регулярного языка. Два регулярных языка равны, если их минимальные ДКА изоморфны. Это даёт практический критерий: чтобы проверить эквивалентность двух регулярных выражений, постройте минимальные ДКА и сравните их — если они совпадают с точностью до переименования состояний, языки одинаковы. Типичная ошибка: путать НКА и ДКА на экзамене и рисовать один вместо другого; держите в голове простое правило — у ДКА из каждого состояния по каждому символу ровно одно ребро.

(a|b)*abb:
  НКА:  s0 -a,b-> s0 -a-> s1 -b-> s2 -b-> s3(финальное)

Графы

Граф — это вершины и рёбра между ними: так моделируют дороги, зависимости задач, связи людей, состояния автоматов. Аналогия: схема метро — станции это вершины, перегоны это рёбра; чтобы найти кратчайший путь, мы «шагаем» по рёбрам и запоминаем длину маршрута. Алгоритмы на графах — обязательная часть базового потока МФТИ, и это самые «прикладные» задачи из всего раздела: они буквально каждый день встречаются в маршрутизаторах, навигаторах, социальных сетях и компиляторах.

Алгоритм Задача Сложность
DFS обход, компоненты, мосты, точки сочленения O(V + E)
BFS кратчайшие пути в невзвешенном графе, двудольность O(V + E)
Топосортировка порядок в DAG O(V + E)
Краскал минимальное остовное дерево (DSU) O(E log E)
Дейкстра кратчайшие пути без отрицательных рёбер O((V+E) log V)
Форд–Беллман кратчайшие пути с отрицательными рёбрами, поиск цикла O(V·E)
Флойд–Уоршелл все пары кратчайших путей O(V³)

Почему такие сложности. DFS и BFS обрабатывают каждую вершину и каждое ребро ровно по одному разу — отсюда O(V+E). Топосортировка — это, по сути, DFS с «выходным временем» или подсчёт входящих степеней, та же линейная сложность. Краскал сортирует ррёбра (O(E log E)) и затем жадно берёт самые дешёвые, проверяя каждое ребро через DSU почти за O(1). Дейкстра работает, потому что из очереди с приоритетом мы всегда достаём ближайшую вершину, и её расстояние уже финально — отсюда O((V+E) log V). Форд–Беллман перебирает все рёбра V−1 раз, потому что самый длинный кратчайший путь не длиннее V−1 ребра. Флойд–Уоршелл тройной вложенный цикл — для каждой пары вершин пробуем каждую «пересадочную» вершину.

Практические подсказки и типичные ошибки.

  • Дейкстра не работает с отрицательными рёбрами — если подать такой граф, ответ будет неверным. Для отрицательных — Форд–Беллман, но он медленнее.
  • BFS ищет кратчайший путь по числу рёбер, а не по весу — для взвешенных графов нужен Дейкстра.
  • DFS легко «падает» на большом графе из-за рекурсии (стек переполняется); в продакшене обычно используют итеративный DFS со своим стеком.
  • Краскал требует DSU (систему непересекающихся множеств) — без неё проверка «не образует ли ребро цикл» превратится в O(V) на ребро, и общая сложность станет O(E·V).
  • Флойд–Уоршелл кажется «волшебным», но при V > 500 он уже слишком медленный; используйте его только для маленьких графов или когда нужны все пары расстояний.
// Итеративный DFS + подсчёт компонент связности
int components(const std::vector<std::vector<int>>& g) {
    std::vector<char> seen(g.size(), 0);
    int count = 0;
    for (std::size_t s = 0; s < g.size(); ++s) {
        if (seen[s]) continue;
        ++count;
        std::vector<int> stack{s};
        seen[s] = 1;
        while (!stack.empty()) {
            int v = stack.back(); stack.pop_back();
            for (int to : g[v])
                if (!seen[to]) { seen[to] = 1; stack.push_back(to); }
        }
    }
    return count;
}
// Дейкстра с очередью с приоритетом
std::vector<long long> dijkstra(const std::vector<std::vector<std::pair<int,int>>>& g, int src) {
    const long long INF = (1LL << 62);
    std::vector<long long> dist(g.size(), INF);
    std::priority_queue<std::pair<long long,int>,
                        std::vector<std::pair<long long,int>>,
                        std::greater<>> pq;          // минимум сверху
    dist[src] = 0;
    pq.push({0, src});
    while (!pq.empty()) {
        auto [d, v] = pq.top(); pq.pop();
        if (d != dist[v]) continue;                  // устаревшая запись
        for (auto [to, w] : g[v])
            if (d + w < dist[to]) { dist[to] = d + w; pq.push({dist[to], to}); }
    }
    return dist;
}
// BFS: кратчайшие расстояния (в рёбрах) от источника в невзвешенном графе
std::vector<int> bfs(const std::vector<std::vector<int>>& g, int src) {
    std::vector<int> dist(g.size(), -1);      // -1 означает «не посещено»
    std::queue<int> q;                        // очередь = обход по слоям
    dist[src] = 0; q.push(src);
    while (!q.empty()) {
        int v = q.front(); q.pop();
        for (int to : g[v])
            if (dist[to] == -1) {             // первый визит даёт кратчайший путь
                dist[to] = dist[v] + 1;
                q.push(to);
            }
    }
    return dist;
}

BFS (Breadth-First Search, поиск в ширину) — это способ обхода графа «волной»: мы стартуем в заданной вершине, сначала посещаем всех её соседей (расстояние 1), потом соседей соседей (расстояние 2), потом соседей соседей соседей (расстояние 3) и так далее. Представьте, что вы бросили камень в пруд — круги расходятся от точки падения. Каждый «круг» — это уровень расстояния от источника. Именно поэтому BFS гарантирует, что первое посещение вершины даёт кратчайший путь в невзвешенном графе (где все рёбра равны по стоимости).

Зачем нужна очередь (std::queue)? Очередь работает по принципу FIFO — «первым вошёл, первым вышел». Это идеально подходит для послойного обхода: кто попал в очередь раньше (ближе к источнику), тот раньше и обработается. Если бы мы взяли стек (LIFO) — получили бы DFS и ушли бы вглубь, а не по слоям.

Почему -1 означает «не посещено»? Это удобный трюк: расстояние в графе неотрицательное (нельзя пройти минус рёбер), поэтому -1 заведомо не является валидным расстоянием и служит надёжной меткой «вершина ещё не достигнута». Альтернативы — отдельный std::vector<bool> visited (тратим память) или INT_MAX (менее читаемо).

Ключевая идея кода: условие if (dist[to] == -1) — это «ещё не были в этой вершине». Поскольку BFS обходит по возрастанию расстояния, мы не можем найти вершину to второй раз более коротким путём. Первое присваивание dist[to] = dist[v] + 1 и есть кратчайшее расстояние.

Обратите внимание на разницу с DFS: там использовался стек (stack/рекурсия) и обход уходил вглубь, а здесь очередь и обход «волной» — именно поэтому BFS находит ближайшие вершины первыми и годится для кратчайших путей в невзвешенных графах.

Типичные ошибки при написании BFS:

  • Забыть dist[src] = 0 — тогда источник останется с -1 и функция «не сработает» для всех вершин.
  • Проверять dist[to] == 0 вместо == -1 — сломается, если в графе есть вершина 0, куда можно дойти за 0 шагов (источник сам).
  • Использовать рекурсию вместо очереди — получится уже DFS, а не BFS.
  • Путать g[v] со списком рёбер, когда граф задан матрицей смежности — нужно перебирать столбцы.
  • Возвращать dist без проверки «а все ли вершины достижимы?» — если граф несвязный, часть -1 останется в ответе (это нормально, но важно понимать).

Часто нужно не только расстояние, но и сам путь. Для этого заводят массив parent[], в который при первом посещении записывают, откуда мы пришли в вершину. Затем путь восстанавливают «с конца»: v → parent[v] → parent[parent[v]] → … → src, переворачивая порядок.

Топосортировка (топологическая сортировка) — это линейный порядок вершин ориентированного ациклического графа (DAG), в котором каждое ребро ведёт из более ранней вершины в более позднюю. Пример из жизни: расписание экзаменов, где каждый предмет зависит от предварительных курсов; или порядок компиляции модулей (make, npm), где библиотека должна быть собрана раньше, чем программа, которая её использует. Существует два классических способа:

  1. DFS-вариант: выполняем обход в глубину, вершину добавляем в ответ после полной обработки всех её потомков. Поскольку стек рекурсии «разворачивается» в обратном порядке, итоговый массив нужно развернуть (или добавлять в начало deque/vector через push_front, что дороже).
  2. Алгоритм Кана: берём все вершины с нулевой степенью входа (от которых ничего не зависит), удаляем их, уменьшаем степени входа соседей, повторяем. Если в процессе не все вершины были удалены — в графе есть цикл и топосортировка невозможна.

Двудольность графа — можно ли разбить все вершины на две группы так, чтобы ребро всегда соединяло вершины из разных групп (никаких рёбер внутри одной группы). Аналогия: рассадить гостей за двумя столами так, чтобы знакомые сидели за разными столами — невозможно, если есть «треугольник» из взаимных знакомств. Проверяется BFS-раскраской в два цвета: стартуем из любой вершины, красим её в цвет 0, всех соседей — в цвет 1, соседей соседей — снова в 0, и так далее. Если в какой-то момент сосед уже окрашен в тот же цвет, что и текущая вершина — граф не двудольный. BFS здесь удобен тем, что послойная проверка даёт чёткое чередование.

Мосты и точки сочленения. Это «критические» элементы графа: мост — ребро, при удалении которого граф распадается на большее число компонент связности; точка сочленения — аналогичная вершина. Аналогия: мост в реальной жизни — единственная дорога между двумя деревнями; если её разрушить, связь пропадёт. Ищутся классическим DFS с массивами tin[] (время входа) и low[] (минимальное время входа, достижимое из поддерева по одному обратному ребру):

  • Если low[ребёнка] > tin[вершины] — ребро (вершина, ребёнок) является мостом (из поддерева ребёнка нельзя вернуться в вершину или выше без этого ребра).
  • Точка сочленения: вершина — корень DFS, если у неё больше одного ребёнка; или не корень, если существует ребёнок с low[ребёнка] ≥ tin[вершины].

Зачем это будущему программисту. Обходы графов — это планировщики задач, поиск зависимостей в сборке (тот же npm/make), маршрутизация, анализ соцсетей. Топосортировка говорит, в каком порядке собирать модули, Дейкстра — как проложить кратчайший маршрут с весами, Краскал — как соединить точки дешевле всего. Выбор алгоритма зависит от условий: есть ли отрицательные рёбра, нужен ли путь от одной вершины или между всеми парами. Например, Google Maps сначала строит маршрут алгоритмом Дейкстры (с весами — длина/время дороги), а на уровне «найти ближайшую кофейню» используется BFS по сетке улиц. Социальные сети (Facebook, VK) находят кратчайший путь «друзья друзей» через BFS — это ровно то, что мы только что написали.

Бинарная куча и heapsort

Бинарная куча (binary heap) — это структура данных, которая позволяет очень быстро (за O(1)) узнать максимальный (max-heap) или минимальный (min-heap) элемент и за O(log n) добавить или удалить элемент. Это «контракт», который не дают ни массив, ни список: в массиве максимум ищется за O(n), в списке — тоже. Куча лежит в основе std::priority_queue (которая по умолчанию и есть max-heap), алгоритма Дейкстры, планировщика задач в ОС и кодеков Хаффмана. Она же даёт сортировку heapsort, которую спрашивают, когда просят «отсортировать на месте за O(n log n)».

Куча — полное двоичное дерево в массиве: дерево называется полным, если все уровни заполнены, кроме, возможно, последнего, и тот заполняется слева направо без дырок. Это позволяет хранить его без указателей, как обычный массив: parent(i) = (i-1)/2, дети 2i+1, 2i+2; инвариант a[parent] ≥ a[child]. Раз дерево полное, его удобно хранить без указателей — прямо в векторе, а индекс родителя и детей вычислять арифметикой.

Почему именно полное дерево? Потому что оно плотно упаковывается в массив без «дыр»: у каждого узла есть предсказуемые индексы детей и родителя, ничего не сдвигается при добавлении в конец. Если бы дерево было произвольным, пришлось бы хранить указатели, и мы потеряли бы в локальности данных (кэше процессора). Полное дерево — это компромисс: оно даёт O(log n) гарантированную высоту (логарифм — это очень медленно растущая функция: даже при n = 10⁹ высота ≈ 30), и при этом идеально ложится на массив.

Инвариант кучи — это ключевое условие, которое всегда должно выполняться после любой операции. Для max-heap: каждый родитель ≥ своих детей. Отсюда автоматически следует, что максимум лежит в корне — то есть в a[0]. Элементы внутри одного уровня порядка не имеют (в отличие от BST), и это важно понимать.

Аналогия из жизни. Представьте пирамиду из людей (как в акробатике): самый тяжёлый (или самый высокий) — наверху, более лёгкие — ниже. Если кто-то внизу окажется тяжелее того, кто над ним — нужно «просеять» его вниз или поднять наверх, восстанавливая инвариант.

void siftDown(std::vector<int>& a, std::size_t n, std::size_t i) {
    while (true) {
        std::size_t l = 2 * i + 1, r = l + 1, largest = i;
        if (l < n && a[l] > a[largest]) largest = l;
        if (r < n && a[r] > a[largest]) largest = r;
        if (largest == i) break;
        std::swap(a[i], a[largest]);
        i = largest;
    }
}

void heapSort(std::vector<int>& a) {
    for (std::size_t i = a.size() / 2; i-- > 0;) siftDown(a, a.size(), i);   // построение O(n)
    for (std::size_t n = a.size(); n > 1; --n) {
        std::swap(a[0], a[n - 1]);      // максимум в конец
        siftDown(a, n - 1, 0);
    }
}

Что делает siftDown (просеивание вниз)? Она «утрясает» поддерево с корнем в i, чтобы инвариант кучи восстановился: среди родителя и двух его детей выбирается наибольший; если это не родитель — он меняется местами с наибольшим ребёнком, и процедура повторяется для новой позиции. Глубина рекурсии максимум O(log n) (высота дерева).

Почему цикл по i = a.size() / 2; i-- > 0; строит кучу за O(n)? Потому что мы просеиваем только нелистовые узлы (листья — это уже кучи из одного элемента, их трогать не надо). Анализ показывает, что суммарная работа пропорциональна n, несмотря на то что каждый вызов siftDown в худшем случае стоит O(log n): нижние уровни содержат больше узлов, но просеиваются на маленькую глубину. Это контр-интуитивный, но важный факт — стоит запомнить.

Heapsort: построение за O(n), каждый из n шагов — O(log n), итого O(n log n) в худшем случае, память O(1), но сортировка неустойчива. Алгоритм: сначала строим max-heap из массива. Затем повторяем n - 1 раз: меняем максимум (a[0]) с последним элементом неотсортированной части, уменьшаем её размер на 1 и просеиваем новый корень вниз. После каждой итерации в конце массива оказывается очередной максимум — и он больше не двигается. Неустойчивость означает, что равные элементы могут поменяться местами (потому что мы их физически «перетаскиваем» через весь массив).

Ключевое отличие от std::sort: heapsort гарантирует O(n log n) даже в худшем случае, тогда как у quicksort худший случай O(n²). За это платят неустойчивостью и худшей локальностью (прыжки по массиву бьют по кэшу), поэтому в стандартной библиотеке предпочитают introsort — гибрид, включающий heapsort на «неудачных» участках.

Когда выбирают heapsort на практике:

  • Когда важна гарантия O(n log n) в худшем случае (встраиваемые системы, системы реального времени, где деградация скорости недопустима).
  • Когда нужна минимальная дополнительная память (O(1)) — например, сортировка огромного файла «на месте».
  • Когда данные приходят «не случайно» (уже отсортированы, обратно отсортированы, много дубликатов) — quicksort в этих случаях деградирует, heapsort — нет.

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

  • Когда нужна устойчивость (сохранение порядка равных элементов) — используйте std::stable_sort (merge sort).
  • Когда критична локальность данных: heapsort прыгает по массиву 2i+1, что плохо для кэша CPU; quicksort сканирует последовательно и работает быстрее на практике.
  • В типичных «средних» задачах используйте std::sort — это introsort, который берёт лучшее от quicksort, heapsort и insertion sort.

Типичные ошибки при реализации кучи:

  • Забыть проверку l < n и r < n — выход за границы массива, UB (undefined behavior).
  • Перепутать < и > в сравнении — куча станет min-heap вместо max-heap, и сортировка пойдёт в обратную сторону.
  • Использовать int вместо std::size_t для индексов — предупреждение о сравнении знакового и беззнакового, потенциальный баг на очень больших массивах.
  • В heapSort забыть уменьшить n - 1 в siftDown — после обмена максимум окажется на своём месте, но мы будем просеивать его обратно и испортим результат.
  • Путать siftDown (просеивание вниз, после удаления корня) и siftUp (просеивание вверх, после вставки в конец) — это две разные операции, обе важные.

Зачем это будущему программисту. Куча — стандартный инструмент для «всегда доступного минимума»: планировщики, слияние потоков, алгоритм Дейкстры, построение дерева Хаффмана. Понимание операции siftDown (просеивание вниз) объясняет, почему приоритетная очередь работает за логарифм. В реальных системах куча используется везде: ядро Linux использует её в O(1) планировщике (через красно-чёрное дерево, но по тому же принципу приоритезации), сетевое оборудование — для QoS-очередей, видеокодеки — для сортировки DCT-коэффициентов. Если вы понимаете, как работает std::priority_queue, вы понимаете, как принимаются решения в реальных системах, обрабатывающих миллионы событий в секунду.

Вопросы к экзамену и коллоквиуму с ответами

Ниже банк вопросов, сгруппированный по вузам, с расширенными ответами. Проговаривайте их вслух: на экзамене важна не только правильность, но и умение уложиться в 1–2 минуты. После каждой группы указано, где именно эти вопросы задают.

Как читать эту шпаргалку. Каждый вопрос — это мини-тема с пятью слоями: (1) определение термина простым языком, (2) аналогия из жизни, (3) «почему именно так» — мотивация в языке, (4) практическая подсказка для кода, (5) типичная ошибка, за которую снижают балл. На устном ответе достаточно дать слои 1–3, но если преподаватель копает глубже — у вас заготовлены 4 и 5.

МИФИ (зачёт 4 сем., экзамен 5 сем., защита курсовой)

  1. Чем отличается перегрузка от переопределения? Перегрузка — несколько функций с одним именем и разными сигнатурами; выбор происходит на этапе компиляции (статическое связывание, overload resolution). Переопределение — замена виртуальной функции в наследнике; выбор откладывается до рантайма и идёт через vtable (динамическое, позднее связывание). Ключевые термины ответа: «разрешение перегрузки» и «динамическая диспетчеризация». Пример: f(int)/f(double) — перегрузка, Shape::area и Circle::area — переопределение.

    Аналогия из жизни. Перегрузка — это один и тот же номер телефона в записной книжке, но с разными подписями («мама», «работа», «скорая»): кто звонит — выбираем по подписи заранее. Переопределение — это call-центр, где трубку снимает тот сотрудник, который реально свободен прямо сейчас: решение принимается в момент звонка.

    Почему так устроено. Компилятор видит типы аргументов статически, поэтому перегрузка возможна без потерь производительности. Переопределение требует рантайма, потому что компилятор заранее не знает, объект какого именно наследника лежит за указателем/ссылкой на базу — для этого и существует vtable.

    Практическая подсказка. Если в override-методе вы хотите вызвать базовую версию — пишите Base::method(...) явно. Это и есть «вызов по статическому типу», а не полиморфный вызов.

    Типичная ошибка. Говорят «переопределение — это тоже перегрузка». Нет: перегрузка — это про разные сигнатуры на этапе компиляции, переопределение — про замену реализации в наследнике при той же сигнатуре. Экзаменатор это слышит сразу.

  2. Зачем виртуальный деструктор? Если наследника удаляют через указатель на базу, delete обязан вызвать деструктор самого производного класса. Без virtual сработает только ~Base, ресурсы наследника не освободятся — это утечка и неопределённое поведение. Правило: деструктор базы делают виртуальным всегда, когда тип полиморфный и предполагается полиморфное удаление. Внутри деструктора виртуальные вызовы статичны, поэтому полиморфизму там доверять нельзя.

    Аналогия из жизни. Представьте, что вы сдаёте в багаж чемодан, на бирке написано просто «багаж». Если при разгрузке ориентируются только на эту бирку, то внутренние отделения чемодана никто не проверит. Виртуальный деструктор — это инструкция «загляни внутрь, прежде чем выкинуть».

    Почему так устроено. В C++ delete ptr вызывает деструктор статического типа указателя, если деструктор не виртуальный. Это сознательный компромисс ради скорости: без виртуальности нет vptr, нет лишнего чтения из памяти, нет потери в производительности.

    Практическая подсказка. В базовом классе, где есть хотя бы одна виртуальная функция, первой строкой пишите virtual ~Base() = default;. Это стоит ноль копеек и страхует команду от UB на ровном месте. Если наследование запрещено намеренно — используйте final, тогда компилятор сам оптимизирует вызовы.

    Типичная ошибка. Пишут virtual ~Base() {} — пустое тело, но без = default или override в наследниках. Либо забывают, что деструктор тоже может быть override в наследнике — компилятор это поддерживает, и сигнатуры должны совпадать неявно.

  3. Что такое инкапсуляция? Это сокрытие внутреннего представления за публичным интерфейсом. Выгода — целостность типа: объект невозможно перевести в противоречивое состояние извне. Механизм — приватные поля плюс методы, поддерживающие инвариант класса. Не путайте инкапсуляцию (сокрытие) с абстракцией (выделение существенного) — на экзамене это различие любят.

    Аналогия из жизни. Банкомат: вы видите кнопки, экран и щель для карты. Внутри — механика, платы, прошивка. Вам не дают паяльник — иначе вы сломаете и себе, и банку. Кнопки — это публичный интерфейс, который сохраняет инвариант «нельзя выдать деньги без авторизации».

    Почему так устроено. Если бы все поля были публичными, любая строчка кода могла бы записать в balance отрицательное число или в size минус единицу. Тогда каждый вызов метода приходится защищать проверками. Инкапсуляция переносит эти проверки в одно место — в методы класса — и делает остальной код простым.

    Практическая подсказка. Хороший критерий: «если я выставлю это поле в public, программа сломается в неожиданном месте» — значит, поле должно быть private и защищено геттером/сеттером (или доступом через метод).

    Типичная ошибка. Студенты говорят «инкапсуляция — это сокрытие данных». На самом деле это сокрытие реализации (деталей хранения). Сами данные можно отдавать, но через контролируемые методы, поддерживающие инвариант.

  4. Можно ли вызвать виртуальную функцию в конструкторе? Можно, но вызовется версия текущего (конструируемого) класса, а не самого производного. Причина — vptr устанавливается по мере прохождения конструкторов: объект ещё «не дорос» до наследника. В деструкторе симметрично: после ~Derived указатель переключается на Base. Практический вывод: не полагайтесь на полиморфизм внутри конструкторов и деструкторов.

    Аналогия из жизни. Строящийся дом: пока строится фундамент, нельзя «по-полиморфному» позвонить в дверь готового коттеджа — её ещё физически нет. Сейчас доступно только то, что уже построено на текущем этаже.

    Почему так устроено. Если бы конструктор Base мог вызвать метод Derived, объект был бы сконструирован лишь частично, и обращение к его полям дало бы UB. Поэтому компилятор на время конструкторов и деструкторов «замораживает» виртуальную диспетчеризацию на уровне текущего класса.

    Практическая подсказка. Если ваш базовый класс нуждается в инициализации, зависящей от типа наследника — вынесите это в отдельный метод, например void init(), и вызывайте его после конструирования. Это типичный паттерн «two-phase initialization», хотя в современном C++ его стараются избегать.

    Типичная ошибка. Пишут фабрику, которая в базовом конструкторе вызывает виртуальный метод, а потом удивляются, почему наследник «не отзывается». Лечение — вызов явный Derived::method() либо перенос в init-фазу.

  5. Что такое абстрактный класс? Класс, содержащий хотя бы одну чисто виртуальную функцию (= 0); его объекты создавать нельзя. Он задаёт интерфейс и может при этом нести состояние и готовую реализацию части методов. Если в классе только чистые виртуальные функции и виртуальный деструктор — это интерфейс. Абстрактный класс — это инструмент описания контракта для наследников.

    Аналогия из жизни. Должностная инструкция: «инженер должен уметь делать проект». Саму должность «инженер» занять нельзя — это абстракция. А «инженер-проектировщик мостов» — конкретный наследник, который реализует абстрактное требование под конкретную область.

    Почему так устроено. Чисто виртуальная функция — это обещание, что метод будет, но сейчас его реализация неизвестна. Это позволяет описать контракт, не вдаваясь в детали, и заставить наследников его выполнить — иначе компиляция не пройдёт.

    Практическая подсказка. Если у интерфейса нет ни состояния, ни реализации, пометьте деструктор как virtual ~Iface() = default; (а не = 0) — иначе наследникам придётся его реализовывать. В современном C++ есть альтернатива: обычные функции-члены, но без = 0, а класс помечен как абстрактный через CRTP или статический анализ.

    Типичная ошибка. Студенты называют абстрактным классом любой, в котором есть «хоть какие-то виртуальные функции». Это неверно: абстрактность даёт только = 0, иначе класс конкретный.

  6. Разница struct и class? Только доступ по умолчанию (public у struct, private у class) и вид наследования по умолчанию. Соглашение: struct — для простых агрегатов данных, class — для типов с инвариантом. Никаких других различий в языке нет.

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

    Почему так устроено. Исторически C++ наследует struct из C, где поля по умолчанию публичны (это нормально для POD-типов). Когда появились классы с инвариантами, добавили class с приватностью по умолчанию — для безопасности. Внутри компилятора они обрабатываются одинаково.

    Практическая подсказка. Если ваш тип — это пара координат Point { double x, y; }, берите struct. Если это счёт с балансом и операциями withdraw/deposit — берите class. Это вопрос читаемости, а не синтаксиса.

    Типичная ошибка. Пишут struct S : B и думают, что это публичное наследование по умолчанию — а потом удивляются, что S obj; не компилируется, потому что у B деструктор приватный. Реально struct S : B — это public-наследование, проблема была в B. Но путаница между class/struct именно в этом и состоит — будьте точны.

  7. Когда вызывается конструктор копии? При инициализации объектом того же типа, при передаче по значению и при возврате по значению (если компилятор не применил copy elision). Отдельно подчеркните: T b = a; — это инициализация, а не присваивание, и вызывает конструктор копии, а не operator=.

    Аналогия из жизни. Конструктор копии — это «снять ксерокс с документа». А operator= — это «стереть то, что в файле, и на его место положить ксерокс нового документа». Разница в том, был ли документ раньше.

    Почему так устроено. В C++ разделены два момента: «объект рождается» (конструктор) и «объект уже существует, меняем его содержимое» (присваивание). Это даёт типу возможность корректно отработать оба сценария — например, освободить старый ресурс в присваивании.

    Практическая подсказка. С C++17 компилятор обязан применять copy elision для prvalue (временных объектов) — например, return T(); не вызывает ни копирования, ни перемещения. Это называется «guaranteed copy elision». Значит, ваш конструктор копии может не вызываться вообще, и полагаться на побочные эффекты в нём нельзя.

    Типичная ошибка. Считают, что T b = a; — это «присваивание». На самом деле это определение переменной b с одновременной инициализацией, поэтому вызывается конструктор копии. Сравните с T b; b = a; — здесь действительно сначала конструктор по умолчанию, потом operator=.

  8. Что такое rule of three/five? Если классу нужен деструктор, копирующий конструктор или копирующее присваивание — нужны все три (правило трёх), потому что класс управляет ресурсом. С move-семантикой к ним добавляются перемещающий конструктор и перемещающее присваивание (правило пяти, «пять друзей класса»). Более безопасный вариант — правило нуля: не владеть ресурсом напрямую, а хранить std::string, std::vector, std::unique_ptr.

    Аналогия из жизни. Если вы сами храните паяльник, провод и канифоль (сырой ресурс), вы сами отвечаете за уборку: после работы надо выключить, убрать, почистить. Если же вы купили готовый паяльник с кейсом — кейс сам закрывается, чистится и убирается. Правило нуля — это «купите готовый кейс» (RAII-обёртку).

    Почему так устроено. Если класс владеет ресурсом вручную (например, указатель T*), то компилятор сгенерирует «тупые» копирование и присваивание — они скопируют указатель, и два объекта будут думать, что они оба владельцы. Это classic double-free. Правило трёх говорит: «раз ты вступил в игру с ресурсом — отвечай за все пять ходов».

    Практическая подсказка. Прежде чем писать деструктор — подумайте, нельзя ли обойтись std::unique_ptr<T> или std::vector<T>. В 90% случаев можно, и тогда rule of three/five вам не нужен.

    Типичная ошибка. Пишут «правило трёх» и забывают про move-семантику. В современном C++ правильнее говорить «правило пяти» или, что ещё лучше, «правило нуля». Экзаменаторы это ценят.

  9. Что делает explicit? Запрещает неявные преобразования через конструктор или оператор преобразования: Buffer b = 10; перестанет компилироваться, останется только Buffer b(10);. Это снижает риск неожиданного выбора перегрузки. Для операторов преобразования explicit разрешает контекст if/while (contextual conversion), например explicit operator bool().

    Аналогия из жизни. Конструктор без explicit — это автоматическая дверь, которая открывается при любом приближении. С explicit — дверь с явной кнопкой: открывается только если вы намеренно нажали.

    Почему так устроено. В C++ конструктор одного аргумента исторически считался «конструктором преобразования» — то есть Buffer(10) могло неявно превратить 10 в Buffer. Это удобно для типов вроде std::string, но опасно для собственных классов: Buffer b = 10; может оказаться там, где вы хотели совсем другое.

    Практическая подсказка. Почти все конструкторы с одним аргументом стоит помечать explicit. Исключение — типы-прокси, специально созданные для удобной неявной конвертации (но таких мало). В C++11 и далее можно писать explicit для конструкторов с несколькими аргументами — это запрещает copy-initialization через braced-list.

    Типичная ошибка. Забывают, что explicit нужно и для операторов преобразования. Классика — класс BoolWrapper без explicit, который позволяет писать BoolWrapper w = "hello"; через неявный operator bool. Это жуткий источник багов.

  10. Как работают friend-функции? Получают доступ к private/protected, не становясь методами класса. Дружба не наследуется, не транзитивна и не взаимна. Типичный пример — симметричный operator<<, где левый операнд — поток, а не ваш тип. Злоупотребление дружбой разрушает инкапсуляцию.

Аналогия из жизни. friend — это доверенность: нотариус (ваша функция) получает право заглянуть в личный сейф хозяина, но сам хозяином не становится. И ребёнок хозяина (наследник) автоматически доверенности не получает.

Почему так устроено. Бывают операции, которые по семантике «снаружи» вашего типа, но требуют доступа к его внутренностям. Самый яркий пример — operator<<(std::ostream&, const MyType&): левый операнд — поток, а ваш тип — справа. Сделать его методом MyType::operator<< нельзя, потому что тогда cout << obj компилировалось бы как obj.operator<<(cout), что контринтуитивно.

Практическая подсказка. Если вашему классу нужен friend для тестирования — это сигнал, что класс спроектирован неудобно для тестов. Лучше вынести тестируемое поведение через публичный интерфейс. friend оправдан в основном для операторов и фабрик.

Типичная ошибка. Пытаются использовать friend как «лазейку в инкапсуляцию». Это антипаттерн: если friend-функция пользуется приватными полями напрямую, любое их изменение ломает и функцию, и всех её клиентов. Лучше держать friend-функцию короткой и договориться, что она — единственный потребитель.

Где именно спрашивают. Вопросы 1–10 — ядро билета МИФИ: зачёт 4 семестра (базовое ООП) и экзамен 5 семестра. На защите курсовой этими же вопросами проверяют ваш собственный код.

МГУ ВМК (коллоквиумы и зачёт «Системы программирования»)

  1. Что такое RTTI? Run-time type information — сведения о типе, доступные в рантайме: typeid и dynamic_cast. Полноценно работают только для полиморфных типов, у которых есть vptr, потому что информация о типе берётся из таблицы. Применяются там, где виртуального интерфейса не хватает для решения.

Аналогия из жизни. На конвейер приходит коробка без этикетки, но с встроенным паспортом (RTTI). Сканер считывает паспорт через специальный разъём — vptr. Если коробка без паспорта (неполиморфный тип) — сканер ничего не покажет.

Почему так устроено. Хранить информацию о типе в каждом объекте дорого по памяти и времени. Поэтому компилятор добавляет её только тогда, когда она реально может пригодиться, — то есть когда в классе есть виртуальные функции. Это компромисс: «не плати за то, что не используешь».

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

Типичная ошибка. Используют dynamic_cast для «всего подряд» как костыль. Это антипаттерн: код начинает «пахнуть» — нарушается открытость/закрытость, появляются хрупкие цепочки if (auto* c = dynamic_cast<Circle*>(s)) ....

  1. Когда dynamic_cast к указателю даёт nullptr? Если объект не принадлежит целевому типу, либо если база не полиморфна (нет виртуальных функций). Приведение к ссылке в этом случае бросает std::bad_cast. Приведение вверх (к базе) через dynamic_cast всегда успешно и проверок не требует.

Аналогия из жизни. Девушка пришла на собеседование, у неё резюме «программист». dynamic_cast к «Senior» — смотрим её опыт. Если опыта нет — вернётся nullptr (для указателя) или раздастся скандал (std::bad_cast для ссылки).

Почему так устроено. Указатель — это опциональная сущность: «может быть, а может и нет». Ссылка обязана быть валидной, поэтому её нельзя оставить «пустой» — иначе любой разыменование был бы UB. Отсюда разные сигналы ошибки.

Практическая подсказка. Используйте указатель для dynamic_cast, если «может и не быть» — нормальный сценарий. Используйте ссылку, если тип обязан совпасть, и вы хотите, чтобы программа громко упала при нарушении контракта.

Типичная ошибка. Применяют dynamic_cast к неполиморфному типу в надежде «на авось». Компилятор это не пропустит — точнее, пропустит, но в рантайме приведёт к UB. Или к статической ошибке в static_cast. Сюрприз.

  1. Что такое slicing? Утрата производной части при копировании наследника в объект базового типа по значению. Например, Shape s = Circle{}; копирует только базовый подобъект, и area() теряется. Лечится передачей по ссылке/указателю и хранением указателей (или reference_wrapper).

Аналогия из жизни. У вас есть характеристика студента: имя, курс, средний балл. Если вы хотите описать его словом «человек» и записать только имя — вы потеряли информацию о курсе и балле. Это и есть slicing: в типе Человек просто нет полей, где можно хранить специфику Студента.

Почему так устроено. В C++ срезка возможна, потому что объект базового типа имеет фиксированный размер. Когда вы копируете Circle в Shape, компилятор честно копирует ровно sizeof(Shape) байт — а остальное просто игнорирует. Никакой магии.

Практическая подсказка. Правило: если класс полиморфный — передавайте его по ссылке или указателю, никогда по значению. Точка. Это не рекомендация, это закон выживания в C++.

Типичная ошибка. Студент пишет void foo(Shape s) { ... } и удивляется, почему foo(circle) теряет площадь круга. Решение: void foo(const Shape& s) или void foo(Shape* s).

  1. Как устроена vtable? Это статическая таблица указателей на виртуальные функции, одна на класс, а не на объект. Каждый объект хранит скрытый указатель vptr на свою vtable; порядок слотов фиксирован и одинаков во всей иерархии. Вызов s->area() превращается в косвенный вызов через слот: сначала читается vptr, затем адрес функции, затем вызов. Переопределение в наследнике занимает тот же слот — в этом и смысл позднего связывания.

Аналогия из жизни. Vtable — это меню ресторана: список блюд и кто их готовит. Объект хранит ссылку на «своё» меню (vptr). Когда вы заказываете «основное блюдо» — официант смотрит в меню ресторана, в который вы зашли (через vptr), и зовёт того повара, который там указан.

Почему так устроено. Виртуальность требует косвенного вызова, потому что компилятор не знает реальный тип за указателем. Хранить таблицу в каждом объекте — слишком дорого (она большая и одинаковая для всех объектов одного класса). Хранить только указатель — дёшево: 8 байт на 64-битной системе.

Практическая подсказка. Если вы хотите понять, как это реально выглядит — скомпилируйте простой код с -fdump-class-hierarchy в GCC и увидите граф наследования, или скомпилируйте в ассемблер (-S) и увидите загрузку vptr из объекта и переход по слоту.

Типичная ошибка. Студенты говорят «vtable лежит в каждом объекте». Нет: в объекте — только указатель на таблицу, а сама таблица — одна на класс. Экзаменатор любит это уточнять.

  1. Почему шаблоны «раздувают» код? Каждая подстановка конкретного типа порождает отдельную сущность (инстанцирование) — это называют code bloat. Растут размер бинарника и время сборки, хотя в рантайме обобщённость бесплатна. Пять разных vector<T> — это пять независимых реализаций в объектном коде.

Аналогия из жизни. Шаблон — это лекало, по которому вырезают детали. Одно лекало, но деталей много. Если нужны разные размеры — для каждого свой набор деталей. Сколько типов — столько и наборов.

Почему так устроено. В C++ шаблоны инстанцируются на этапе компиляции. Нельзя «один раз скомпилировать и запускать с разными типами» — потому что тип может изменить поведение функций (например, перегрузки), и компилятор должен учитывать это для каждого T отдельно.

Практическая подсказка. Чтобы уменьшить code bloat, выносите код, не зависящий от T, в общие функции (принимайте void*, char*, std::byte*). В библиотеках типа STL активно используется паттерн «type erasure» для борьбы с bloat.

Типичная ошибка. Думают, что шаблоны «как макросы» и работают через препроцессор. Нет: шаблоны — это полноценная сущность C++, инстанцирование происходит после семантического анализа, с проверкой типов.

  1. Что такое SFINAE? Substitution Failure Is Not An Error: неудачная подстановка типа в сигнатуру не ломает компиляцию, а лишь убирает кандидата из набора перегрузок. Через это включают и выключают шаблоны по условию (enable_if, void_t). В C++20 на смену пришли концепты с более понятными сообщениями.

Аналогия из жизни. SFINAE — это фильтр в трубопроводе: неподходящая деталь просто не проходит дальше, а не взрывает весь завод. Если калибр не тот — деталь выпадает, остальные продолжают путь.

Почему так устроено. Когда компилятор подбирает перегрузку, он пробует подставить типы в каждую сигнатуру. Если подстановка не удалась — без SFINAE вся компиляция упала бы с ошибкой. SFINAE делает этот процесс «мягким»: «ну не подошло — ну и ладно, попробуем следующий шаблон».

Практическая подсказка. Если вы видите в legacy-коде typename std::enable_if<...>::type* = nullptr — это классический SFINAE-приём. В C++20 замените всё это на requires(...) или на концепт.

Типичная ошибка. Путают SFINAE с hard error: если ошибка возникает внутри тела функции (а не в сигнатуре), SFINAE уже не поможет — будет hard error. SFINAE работает только на уровне подстановки в декларацию.

  1. Зачем std::forward? Сохраняет категорию значения аргумента при «идеальной передаче» в шаблонах: lvalue остаётся lvalue, rvalue — rvalue. Без него внутри шаблона аргумент стал бы lvalue (у него есть имя), и перемещение не сработало бы. std::move наоборот всегда приводит к rvalue-ссылке и ничего не перемещает.

Аналогия из жизни. std::forward — это почтальон, который передаёт посылку в той же категории срочности, в которой её прислали: срочная — срочная, обычная — обычная. std::move — это курьер, который любую посылку переводит в режим «срочно». Сам он ничего не перевозит — это делает водитель (конструктор перемещения).

Почему так устроено. В шаблоне template<typename U> void f(U&& x) параметр x имеет имя, поэтому он lvalue — даже если изначально пришёл rvalue. Чтобы снова получить rvalue и позволить move-конструктору сработать, нужно явно «снять» категорию. Это и делает std::forward.

Практическая подскапка. Правило большого пальца: std::move — на выходе из функции (когда вы точно хотите отдать свой ресурс), std::forward — внутри шаблона с универсальной ссылкой (когда вы хотите передать аргумент «как есть»).

Типичная ошибка. Пишут std::move вместо std::forward в forwarding-функции — и теряют информацию о категории значения. Или пишут std::forward без T&& — и получают UB при попытке переместить lvalue.

  1. Разница vector и list? vector — непрерывная память, O(1) доступ по индексу, быстрый проход и хорошая локальность кэша, но дорогая вставка в середину. list — двусвязный список: O(1) вставка/удаление по итератору, но O(n) доступ и промахи кэша. На практике vector чаще выигрывает даже там, где «должен» проигрывать.

Аналогия из жизни. vector — это полка в библиотеке: книги стоят плотно, найти по номеру мгновенно, но вставить книгу в середину — надо подвинуть все остальные. list — это цепочка конвертов: каждый ссылается на следующий, вставить легко (разрезал цепочку, вставил), но чтобы найти пятый конверт — надо пройти первые четыре.

Почему так устроено. Процессоры любят последовательную память: данные загружаются в кэш-линию и обрабатываются пачкой. У list каждый узел в своей памяти — кэш не используется, и каждый переход — это потенциальный cache miss. На практике это в десятки раз медленнее, чем кажется по «O-большому».

Практическая подсказка. Если вы думаете «мне нужно много вставок в середину — возьму list» — сначала измерьте. В 90% случаев vector всё равно быстрее, потому что реальные данные невелики и помещаются в кэш. Если данные действительно огромные и вставок много — посмотрите на deque (гибрид).

Типичная ошибка. Говорят «list имеет сложность вставки O(1), значит он быстрее». Забывают, что O(1) — это теоретическая оценка, а на практике cache miss съедают всё преимущество.

  1. Что инвалидирует итераторы vector? Реаллокация при росте (например, push_back, если ёмкости не хватило) — инвалидирует все итераторы и ссылки. Вставка или удаление не в конце также сдвигает элементы и инвалидирует итераторы от точки изменения до конца. erase возвращает итератор на следующий элемент — используйте его, а не сохраняйте старый.

Аналогия из жизни. Итератор — это номерок из гардероба с номером вешалки. Когда гардероб перестраивается (реаллокация) — все номерки становятся недействительны. Когда меняют половину вешалок (вставка в середину) — старые номерки на сдвинутых вешалках тоже не работают.

Почему так устроено. vector гарантирует непрерывность памяти. Если массив вырос и не помещается в старый блок — выделяется новый блок, всё копируется, старые адреса умирают. Это плата за плотное хранение.

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

Типичная ошибка. Сохраняют итератор it = vec.begin() + 5, делают vec.push_back(x), потом разыменовывают *it — UB. Особенно часто это всплывает в многопоточном коде, где push_back случается в соседнем потоке.

  1. Что такое RAII? Resource Acquisition Is Initialization: привязка времени жизни ресурса к времени жизни объекта — захват в конструкторе, освобождение в деструкторе. Даёт автоматическое освобождение даже при исключении (разматывание стека вызывает деструкторы). Канонические примеры — std::vector, std::unique_ptr, std::lock_guard.

Аналогия из жизни. RAII — это «умная зажигалка»: пока вы её держите — газ идёт; отпустили — клапан закрылся. Если во время использования случится «взрыв» (исключение) — деструктор всё равно сработает при разматывании стека и закроет клапан.

Почему так устроено. В C++ деструкторы вызываются гарантированно — даже при исключениях, даже при return, даже при goto (с оговорками). Это позволяет превратить любой ресурс (память, файл, мьютекс, сетевое соединение) в «объект с деструктором», и компилятор сам позаботится о его освобождении.

Практическая подскапка. Если вы видите блок try { ... } finally { ... } — знайте, в C++ этого нет и не нужно. RAII делает то же самое через деструкторы.

Типичная ошибка. Студенты думают, что RAII — это «про std::unique_ptr». На самом деле это идиома, применимая к любому ресурсу: файл, сокет, OpenGL-контекст, транзакция в БД. Каждый раз, когда вы пишете деструктор, вы применяете RAII.

  1. Разница unique_ptr и shared_ptr? unique_ptr — единоличное владение, копирование запрещено, передаётся только через move, накладных расходов нет. shared_ptr — разделяемое владение с атомарным счётчиком в управляющем блоке, копирование разрешено, но есть цена на синхронизацию. По умолчанию выбирают unique_ptr; shared_ptr — только при реально общем владении.

Аналогия из жизни. unique_ptr — это одноразовый ключ от квартиры: один человек владеет, передать можно, но тогда прежний владелец уже не войдёт. shared_ptr — это копия ключа на каждый заинтересованный: каждый может войти, но сколько копий — столько и придётся вернуть, чтобы закрыть дверь.

Почему так устроено. Атомарный счётчик нужен, потому что в многопоточном коде счётчик может инкрементироваться из разных потоков одновременно. Атомарность — это стоимость на каждом make_shared и копировании. Это не бесплатно.

Практическая подскапка. Если вы думаете «а вдруг понадобится общее владение» — берите unique_ptr. Перейти к shared_ptr можно позже с помощью std::shared_ptr<T>(uptr.release()). Обратный путь невозможен.

Типичная ошибка. Возвращают shared_ptr из фабрики, когда достаточно unique_ptr. Это сразу удваивает расходы на каждый объект. Либо создают циклы shared_ptr, забывая про weak_ptr.

  1. Как разорвать цикл shared_ptr? Заменить одну из сильных ссылок в цикле на weak_ptr и при обращении «поднимать» её через lock(), проверяя, что объект ещё жив. Иначе счётчик сильных ссылок не дойдёт до нуля и память утечёт. weak_ptr не участвует в подсчёте владельцев, поэтому цикл разрывается.

Аналогия из жизни. Цикл shared_ptr — это дверь с двусторонней пружиной: А держит Б, Б держит А, и никто не выходит. weak_ptr — это замена одной пружины на верёвочку: верёвочка не удерживает дверь, и при выходе последнего сильного владельца дверь наконец закрывается.

Почему так устроено. Цикл возникает, когда два объекта «хотят жить друг ради друга». С точки зрения подсчёта ссылок они оба живы — счётчик никогда не 0. weak_ptr не считается «жизнью», но помнит, что объект был — и через lock() можно безопасно его получить (если он ещё жив).

Практическая подскапка. В иерархиях «родитель — ребёнок» родитель держит shared_ptr<Child>, а ребёнок хранит weak_ptr<Parent> (или голый указатель, если родитель точно переживёт ребёнка). Это стандартный паттерн.

Типичная ошибка. Забывают проверить результат lock(): weak.lock()->method() — а если объект уже умер, получится UB. Всегда if (auto p = weak.lock()) { ... }.

  1. Классификация грамматик Хомского? Четыре типа: 0 — неограниченные (машина Тьюринга), 1 — контекстно-зависимые (линейно-ограниченный автомат), 2 — контекстно-свободные (магазинный автомат), 3 — регулярные (конечный автомат). Языки программирования в основном КС-грамматики, а лексика — регулярная.

Аналогия из жизни. Это лестница от самого мощного к самому простому: наверху — машина Тьюринга (может всё, но сложно), внизу — конечный автомат (может мало, зато быстро). Между ними — компромиссы по выразительности и скорости разбора.

Почему так устроено. Чем мощнее грамматика — тем сложнее алгоритм разбора и тем больше памяти он ест. Поэтому для практических задач берут самый слабый класс, который «вмещает» нужный язык. Для лексики хватает регулярных выражений, для синтаксиса — КС-грамматик с LL(1) или LALR(1).

Практическая подскапка. Когда проектируете свой мини-язык — начните с самой слабой грамматики. Если получается — отлично. Если нет — двигайтесь вверх по лестнице Хомского, и каждый шаг будет стоить всё в производительности.

Типичная ошибка. Студенты путают порядок «от 0 к 3»: 0 — самые мощные, 3 — самые слабые. На экзамене говорят «0 — регулярные» — это сразу ноль баллов за вопрос.

  1. Критерий применимости рекурсивного спуска? Грамматика должна быть LL(1): без левой рекурсии и с однозначным выбором правила по одному символу (по направляющим множествам FIRST/FOLLOW). Иначе нужен либо разбор с возвратами, либо предварительное преобразование грамматики. Левая рекурсия устраняется заменой A → Aα | β на A → βA', A' → αA' | ε.

Аналогия из жизни. Рекурсивный спуск — это чтец, который заглядывает в текст на один символ вперёд и сразу решает, какое правило применить. Если на один символ вперёд непонятно — нужны «возвраты» (backtracking), это медленно и непредсказуемо.

Почему так устроено. LL(1) — это компромисс: «посмотри на одну лексему и выбери правило». Этого хватает для большинства императивных языков (без else-конфликта, без неоднозначностей if then else). Если нужно больше — переходите на GLR или генераторы типа ANTLR с поддержкой возвратов.

Практическая подскапка. Если вы пишете парсер руками — всегда проверяйте на левую рекурсию первой: даже маленькая левая рекурсия уронит ваш парсер в бесконечную рекурсию. Устранять её нужно до написания кода.

Типичная ошибка. Считают, что устранение левой рекурсии меняет язык. На самом деле оно меняет грамматику (правила), но порождает тот же язык (множество выводимых строк). Экзаменатор любит это уточнение.

  1. Что такое ПОЛИЗ? Постфиксная (польская инверсная) запись: операция идёт после операндов, скобки не нужны. Вычисляется однозначно слева направо с помощью стека: встретили операнд — положили, встретили операцию — сняли два и положили результат. Используется как внутренняя форма вычисления выражений и в байт-коде виртуальных машин.

Аналогия из жизни. В обычной записи 2 + 3 вы думаете: «что сделать сначала — сложить или прочитать?». В постфиксе 2 3 + сомнений нет: видишь числа — кладёшь в стопку, видишь операцию — снимаешь два верхних и кладёшь результат. Однозначность и скорость.

Почему так устроено. Постфиксная запись не требует приоритетов и скобок — это удобно для компилятора. JVM, .NET CLR, Python-байткод используют вариации стековой машины. Алгоритм вычисления тривиален и работает за один проход.

Практическая подскапка. Если хотите быстро перевести инфикс в ПОЛИЗ — используйте алгоритм сортировочной станции (Shunting-yard) Дейкстры. Он за один проход с операционным стеком делает всю работу.

Типичная ошибка. Путают ПОЛИЗ с обратной польской записью (RPN): это одно и то же, разные названия. Главное — операция после операндов.

Где именно спрашивают. Вопросы 11–25 — второй коллоквиум МГУ ВМК (семантика C++ плюс теория трансляции) и письменный зачёт. Здесь ценят точные определения и умение нарисовать автомат или дерево разбора.

МФТИ (базовый и продвинутый потоки)

  1. Как найти компоненты связности? Запустить DFS или BFS из каждой непосещённой вершины; каждая такая «волна» — одна компонента. Сложность O(V + E). Достаточно хранить массив посещённых вершин и счётчик компонент.

Аналогия из жизни. Представьте карту островов: вы бросаете камень в воду из каждой точки суши, до которой ещё не дошла волна. Каждая волна заливает ровно один остров. Подсчёт волн = подсчёт островов.

Почему так устроено. DFS/BFS обходят все достижимые вершины — это и есть компонента. Из непосещённой вершины стартует новая волна, потому что она не достижима из предыдущей. Это точное определение компоненты связности.

Практическая подскапка. DFS даёт меньше кода в рекурсивной форме, BFS — в итеративной с очередью. На больших графах рекурсивный DFS может упасть на стеке — тогда переходите на итеративный или BFS.

Типичная ошибка. Забывают пометить стартовую вершину как посещённую до запуска обхода. Получается бесконечный цикл или повторный обход уже посещённой компоненты.

  1. Чем BFS отличается от DFS? BFS использует очередь, обходит вершины «по слоям» и даёт кратчайшие пути в невзвешенном графе. DFS использует стек (или рекурсию), уходит вглубь и удобен для топосортировки, поиска мостов и точек сочленения. Обе O(V + E), но решают разные задачи.

Аналогия из жизни. BFS — это разлившееся по полу молоко: растекается во все стороны равномерно. DFS — это кошка, идущая по следу: пока след не закончится, не оглядывается.

Почему так устроено. Очередь — FIFO (первый вошёл, первый вышел) — естественно реализует «волну». Стек — LIFO (последний вошёл, первый вышел) — естественно реализует «нырнуть вглубь и вернуться».

Практическая подскапка. Если в задаче спрашивают про «кратчайший путь в графе» — по умолчанию BFS. Если про «достижимость» или «топологический порядок» — DFS. Это правило работает в 90% случаев.

Типичная ошибка. Используют DFS для поиска кратчайшего пути в невзвешенном графе. Он тоже найдёт путь, но не обязательно кратчайший — потому что «нырнёт» в первом же направлении.

  1. Сложность heapsort? O(n log n) в худшем случае, дополнительная память O(1), сортировка неустойчива. Построение кучи — O(n), затем n раз просеивание по O(log n). Гарантированный худший случай отличает его от quicksort.

Аналогия из жизни. Heapsort — это сортировка «по старшинству»: каждый раз достаём самого старшего (корень кучи), ставим в конец и перестраиваем. Гарантия старшинства — это инвариант кучи.

Почему так устроено. Куча (heap) — это структура, где родитель больше (или меньше) детей. Извлечение максимума — O(log n), итого n * log n. Построение кучи за O(n) — это математический факт: bottom-up построение работает быстрее, чем кажется.

Практическая подскапка. На практике heapsort редко используют напрямую — обычно быстрее работает introsort (гибрид quick+heap), который std::sort внутри libc++. Но знать heapsort полезно для экзамена и понимания гарантий.

Типичная ошибка. Считают, что «построение кучи — это O(n log n)». На самом деле оно O(n), и это контр-интуитивно — но доказуемо суммированием глубин просеивания.

  1. Что такое полиномиальный хеш? Хеш h(s) = Σ s[i]·p^(n-1-i) mod m, вычисляемый префиксными суммами, что позволяет сравнивать подстроки за O(1). Основание p и модуль m подбирают так, чтобы минимизировать коллизии. Применяется в задачах на строки и в алгоритме Рабина–Карпа.

Аналогия из жизни. Это «отпечаток» строки как числа: каждому символу присваиваем вес, складываем — получаем число. У двух одинаковых строк отпечаток одинаковый. У разных — почти наверняка разный, но иногда совпадает (коллизия).

Почему так устроено. Если умеем считать «префиксный хеш» за O(1), то хеш подстроки s[l..r] — это просто h[r] - h[l-1] * p^(r-l+1). Это превращает сравнение подстрок в одну операцию с числами — дёшево и быстро.

Практическая подскапка. Берите p = простое число вроде 91138233, а m = 2^64 (через переполнение unsigned long long) — это даёт хорошую аваланш-стойкость и работает быстрее, чем % m. Для экзамена достаточно любых разумных констант.

Типичная ошибка. Считают, что хеш — это «идеальная» проверка равенства. Нет: при m = 2^64 коллизия возможна, вероятность ~1/2^64. На экзамене скажите «вероятностный алгоритм, на практике работает».

  1. Гарантии безопасности исключений? Basic — инварианты сохранены, утечек нет, но состояние может измениться; strong — commit-or-rollback (либо успех, либо всё как было); nothrow — операция не бросает вовсе. Copy-and-swap даёт строгую гарантию. vector::push_back даёт строгую гарантию только при noexcept-перемещении.

Аналогия из жизни. Basic — ремонт в квартире: после ремонта всё работает, но обои могут быть другие. Strong — ремонт с откатом: либо всё сделано как задумано, либо вообще ничего не тронуто. Nothrow — операция, после которой не о чем беспокоиться (например, noexcept).

Почему так устроено. Обеспечить strong-гарантию дорого: при ошибке нужно «отменить» все изменения. Поэтому не все операции её дают. Nothrow — самая сильная: если компилятор знает, что функция не бросает, он может оптимизировать (например, vector будет перемещать, а не копировать).

Практическая подскапка. Деструкторы должны быть noexcept неявно. Если деструктор бросает — программа завершается аварийно (стандарт). Поэтому никогда не пишите код, который может бросить из деструктора.

Типичная ошибка. Пишут «strong — это всегда лучше». На самом деле strong требует атомарности, которая бывает дорогой или невозможной (например, для сетевых операций). Basic — разумный компромисс.

  1. Почему move-конструктор помечают noexcept? Контейнер при росте выбирает между перемещением и копированием элементов. Если перемещение помечено noexcept, он переносит элементы (дёшево), иначе копирует, чтобы сохранить строгую гарантию при исключении. Отсутствие noexcept делает рост vector медленнее.

Аналогия из жизни. Контейнер — это переезжающая компания. Если грузчики говорят «точно не побьём» (noexcept) — компания разрешает им кидать коробки прямо в кузов. Если не уверены — компания требует аккуратно носить каждую коробку (копировать). Быстрее, но безопаснее.

Почему так устроено. Если при перемещении элемента в vector бросит исключение на полпути — половина элементов переехала, половина нет, исходный вектор потерян. Чтобы этого не было, контейнер выбирает копирование — оно «откатываемо», потому что исходный объект не меняется.

Практическая подскапка. Правило: если ваш move-конструктор не бросает — помечайте noexcept. Это не формальность, это оптимизация, которую компилятор применит сам. Без noexcept — он обязан перестраховаться копированием.

Типичная ошибка. Добавляют noexcept к конструктору, который на самом деле бросает. Если в рантайме исключение всё-таки вылетит — std::terminate() и мгновенный крэш. Хуже, чем было бы без noexcept.

  1. Разница std::move и std::forward? std::move безусловно даёт rvalue-ссылку (static_cast<T&&>), то есть «разрешает обобрать». std::forward сохраняет исходную категорию значения и нужен в шаблонах для идеальной передачи. Ни один из них не перемещает данные сам по себе — перемещает конструктор/присваивание.

Аналогия из жизни. std::move — это заявление «я готов отдать». std::forward — это бережная передача в той же упаковке, в которой получил. Оба не звонят в транспортную компанию — это делает получатель, решивший вызвать перевозчика (move-конструктор).

Почему так устроено. std::move — это просто static_cast<T&&>(x), синтаксический сахар. std::forward — это static_cast<T&&>(x) только при определённом условии (T выводится как ссылочный тип). В обоих случаях никакого «перемещения» в самой функции нет — это просто преобразование категории значения.

Практическая подскапка. Используйте std::move в трёх местах: (1) return из функции, возвращающей по значению; (2) явно отдаёте локальный объект «на съедение»; (3) передаёте в sink-функцию, которая поглощает. Во всех остальных случаях — обычно достаточно передачи по значению.

Типичная ошибка. Пишут std::move для параметра функции и потом удивляются, что вызывающий код «работает, но как-то странно». После std::move переменная в «valid, но unspecified» состоянии — использовать её снова нельзя.

  1. Что такое концепты C++20? Именованные требования к типам, записываемые в сигнатуре (template <Numeric T> или requires). Заменяют громоздкий enable_if и дают понятные сообщения об ошибках. Концепты проверяются при подстановке и участвуют в разрешении перегрузок.

Аналогия из жизни. Концепт — это допуск к профессии: «врач должен иметь диплом и сертификат». Без допуска — на работу не возьмут, и компилятор честно скажет «отсутствует сертификат», а не «не компилируется по непонятной причине».

Почему так устроено. До C++20 SFINAE с enable_if давал то же самое, но через многослойные конструкции в сигнатуре. Сообщения об ошибках были чудовищными. Концепты — это «вынесенный ярлык»: одно имя, одно требование, понятный диагноз.

Практическая подскапка. В C++20 есть готовые концепты в <concepts>: std::integral, std::floating_point, std::ranges::range, std::movable и т.д. Не пишите свои, пока не упёрлись в задачу, которую они не покрывают.

Типичная ошибка. Путают концепты с concepts::constraints в requires-выражении. Концепт — это именованное ограничение (template <C T>), requires-выражение — это способ описать ограничение внутри концепта. Это разные уровни.

Где именно спрашивают. Вопросы 26–33 — основной поток МФТИ: алгоритмы, грамматики, гарантии безопасности. Здесь любят, когда студент пишет псевдокод или рисует дерево разбора на доске. 34. Зачем аллокаторы? Определение. Аллокатор — это объект, который инкапсулирует стратегию запроса и освобождения памяти. Он говорит контейнеру: «дай мне N байт под T» и «верни этот блок обратно». STL ожидает, что любой аллокатор умеет минимум четыре вещи: allocate(n) — выделить сырую память под n объектов, deallocate(ptr, n) — вернуть её системе, construct(ptr, args...) — создать объект в этой памяти (по сути placement new), destroy(ptr) — вызвать деструктор. По сути аллокатор — это «фасад» над operator new / operator delete, который можно подменить.

Аналогия из жизни. Представь ресторан. Контейнер — это официант с блокнотом, который принимает заказы (хранит элементы). Аллокатор — это кухня и склад. Официанту не важно, где именно на складе лежат тарелки и как их туда положили: на верхнюю полку, в морозилку или на тележку у входа. Он просто говорит «дай тарелку». Если ресторан работает в аэропорту — одна стратегия, в дорогом отеле — другая, на фудкорте — третья. Точно так же std::vector<T, MyAlloc> не знает, берёт ли MyAlloc память из обычной кучи, из пула или из разделяемой памяти между процессами.

Зачем это нужно?

  • Разделение ответственности. Контейнер занимается только логикой хранения (двусвязный список, дерево, хеш-таблица), а память — отдельная задача. Это классический пример стратегии (strategy pattern): меняем аллокатор — меняем поведение контейнера по памяти, не трогая код самого контейнера.
  • Снижение фрагментации. Много мелких new / malloc оставляют «дырки» в куче. Пуловый аллокатор заранее откусывает большой кусок и раздаёт его порциями — без дырок.
  • Скорость. malloc / free в glibc дорогие: нужно взять мьютекс, обновить списки свободных блоков. Пуловый аллокатор — это обычно один pointer += offset, без блокировок. Выигрыш на миллионах элементов может быть x5–x10, особенно в многопоточных сценариях (mimalloc, jemalloc, tcmalloc — это всё пуловые аллокаторы).
  • Размещение в особой памяти. Можно отдавать элементы из mmap-области, из shared memory (для IPC между процессами), из arena, которая живёт на стеке (placement vector), или из памяти, выровненной по границе кеш-линии (для SIMD и lock-free структур).
  • Контроль времени жизни. Arena-аллокатор позволяет освободить всю память разом (arena.reset()), без обхода всех элементов — критично для парсеров и игровых уровней.

Где это встречается в STL? Все стандартные контейнеры (std::vector, std::list, std::map, std::unordered_map и т.д.) принимают аллокатор вторым шаблонным параметром: std::vector<int, MyPoolAlloc<int>>. По умолчанию используется std::allocator<T>, который внутри зовёт operator new / operator delete. В C++11 появились scoped allocators (std::scoped_allocator_adaptor) — они пробрасывают аллокатор во вложенные контейнеры, чтобы, например, std::vector<std::string, Alloc> использовал тот же аллокатор и для внутренностей std::string. С C++17 rebind и allocator_traits берут на себя всю рутину с типами.

Типичные ошибки.

  • Забыть про выравнивание (alignment). Аллокатор должен возвращать указатель, подходящий под любой объект типа T (не хуже alignof(T), а для over-aligned типов — не хуже alignof(T) на конкретной платформе). В C++17 есть std::align_val_t и перегрузки operator new с выравниванием.
  • Сделать аллокатор с внутренним состоянием, но без правильных traits. Если у аллокатора есть указатель на пул, нужно корректно указать propagate_on_container_copy_assignment, propagate_on_container_move_assignment, propagate_on_container_swap, select_on_container_copy_construction. Иначе контейнер после v = other продолжит использовать старый пул и упадёт при deallocate.
  • Считать, что аллокатор должен быть stateless. Это типичная реализация, но не требование. Stateful-аллокаторы законны, просто с ними больше мороки (traits, совместимость с контейнерами до C++11).
  • Звать new T(...) напрямую вместо std::allocator_traits<Alloc>::construct(a, ptr, args...). Стандартный путь позволяет контейнеру работать с не полностью инициализированной памятью и поддерживать C++20 destroy_at / construct_at.
  • Игнорировать C++17 pmr. С появлением std::pmr::polymorphic_allocator и std::pmr::memory_resource можно менять стратегию памяти в runtime (через виртуальные вызовы), а не перекомпилировать шаблон. На экзамене про это любят спрашивать как про «современную» замену старому подходу.

Практическая подсказка для экзамена. Если в задаче говорят «сделайте свой контейнер», вынесите аллокатор в отдельный шаблонный параметр и используйте std::allocator_traits — это сразу покажет преподавателю, что вы понимаете, как устроена STL «под капотом». Минимальный pool-аллокатор занимает ~15–20 строк (буфер + freelist + allocate + deallocate), а впечатление на ответе производит сильное.

Где именно спрашивают. Вопросы 26–29 — базовый поток МФТИ (контесты и зачёт), 30–34 — продвинутый (дифференцированный зачёт: одна задача плюс два вопроса). На контестах важна асимптотика, на зачёте — умение объяснить решение и его гарантии. На МИФИ тема аллокаторов чаще всплывает в виде «почему STL-контейнер принимает второй шаблонный параметр» и «где это пригодится на практике» — здесь достаточно понимания идеи и пары примеров (пул, arena, shared memory). На МГУ любят уточняющий вопрос «а как это работает с std::list / std::map?» — важно помнить, что узловые контейнеры аллоцируют каждый узел отдельно, поэтому выигрыш от пула там максимальный. На МФТИ в продвинутом потоке могут попросить написать простейший pool-аллокатор с подсчётом свободных блоков и обработкой deallocate — это задача на стыке шаблонов, RAII и понимания времени жизни объектов; на контестах же здесь про аллокаторы не спрашивают, только про reserve / shrink_to_fit у vector.

Типовые практические задачи (продвинутый поток)

Эти задачи — «золотой стандарт» продвинутого потока: их просят написать на бумаге или за компьютером за ограниченное время. Схема ответа всегда одна: сформулировать постановку, объяснить идею словами, назвать подводные камни и показать каркас кода с комментариями. Даже если не успеете дописать реализацию, разбор идеи и краевых случаев спасёт оценку.

Зачем вообще это знать? На собеседованиях в Яндекс, Google, ABBYY и других компаниях эти задачи появляются чаще всего, потому что они проверяют не знание синтаксиса, а инженерное мышление: умение выбрать представление данных, поддержать инвариант, разделить владение и справиться с краевыми случаями. Если вы уверенно решаете хотя бы три-четыре такие задачи, вы уже впереди большинства кандидатов.

Типичная структура ответа на экзамене (универсальный шаблон).

  1. Краткая постановка — что моделируем, какой API нужен.
  2. Идея — какую структуру данных выбираем и почему.
  3. Инвариант — что всегда истинно для объекта (и что мы поддерживаем после каждой операции).
  4. Подводные камни — что может сломаться.
  5. Каркас кода — публичный интерфейс и 2–3 ключевые приватные функции.
  6. Сложность — O(...) для основных операций (спрашивают почти всегда).

Cow String (copy-on-write)

Что это такое? Copy-on-Write (CoW, «копирование при записи») — это приём оптимизации, при котором копия объекта не создаётся сразу, а откладывается до момента реальной модификации. Идея простая: если большинство операций — чтение, зачем платить за дорогое копирование при копировании? Это упрощённая модель std::string до C++11.

Аналогия из жизни. Представьте, что в библиотеке есть редкая книга в единственном экземпляре. Когда к ней приходят читатели, библиотека не делает копию книги сразу — она заводит карточку учёта и пишет «эту книгу сейчас читают N человек». Если кто-то хочет сделать пометки на полях, библиотекарь делает персональную копию только для него (а основной экземпляр остаётся нетронутым). Так и здесь: пока все только читают, делим один буфер; как только кто-то захочет писать — делаем ему личную копию.

Постановка. Реализовать строку, копирование которой дешево: несколько объектов разделяют один и тот же буфер, а настоящая копия создаётся только перед первой модификацией. Это упрощённая модель std::string до C++11.

Идея словами. Строка хранит не сами символы, а указатель на разделяемый «репрезентант» (Rep) со счётчиком ссылок. Копирование строки просто увеличивает счётчик — это O(1). Перед любой записью вызывается detach(): если ссылок больше одной, создаётся собственная копия буфера и счётчик старого уменьшается. Так читатели не платят за копию, а писатель «отсоединяется» от общих данных.

Почему именно так? Без CoW каждое std::string s2 = s1 копирует всю строку за O(n). Если у вас функция принимает строку по значению (типичный паттерн в до-C++11 коде), это дорого. CoW делает передачу по значению почти такой же дешёвой, как по константной ссылке — пока никто не пишет, копии как таковой нет. После C++11 std::string отказался от CoW, потому что (а) атомарный счётчик в многопоточном коде дороже, чем обычное копирование коротких строк; (б) move-семантика решает ту же задачу без подводных камней.

Ключевые подводные камни. (1) Забыть detach() хотя бы перед одной изменяющей операцией — и правка одной строки изменит другую, что нарушит семантику значения. Это самая частая ошибка на экзамене. (2) Счётчик не потокобезопасен: если строки разделяются между потоками, понадобится атомарный счётчик или мьютекс — иначе будет data race при ++refs/--refs. (3) Исключение по ходу копирования при detach() должно оставлять объект валидным — сначала создайте новый Rep, потом подменяйте указатель. Если сделать наоборот и new Rep бросит bad_alloc, объект уже отцеплен от старого буфера и окажется в «подвисшем» состоянии. (4) operator[] const не должен вызывать detach() — иначе рухнет весь смысл оптимизации, ведь чтение самое частое. (5) Не забудьте про operator= — он тоже должен учитывать уже существующий буфер объекта.

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

class CowString {
    struct Rep {                        // разделяемый буфер с счётчиком ссылок
        std::size_t refs = 1;           // сколько строк смотрят на этот буфер
        std::string data;
    };
    Rep* rep_ = new Rep;                // всегда владеем указателем
public:
    CowString() = default;              // пустая строка: собственный Rep
    CowString(const char* s) { rep_->data = s; }
    CowString(const CowString& o) : rep_(o.rep_) { ++rep_->refs; }   // O(1), копии нет
    ~CowString() { if (--rep_->refs == 0) delete rep_; }             // последний гасит буфер

    void detach() {                     // перед записью — делаем копию эксклюзивной
        if (rep_->refs > 1) {
            --rep_->refs;               // отцепляемся от общего буфера
            Rep* fresh = new Rep;       // создаём новый (может бросить — старый цел)
            fresh->data = rep_->data;   // копируем содержимое
            rep_ = fresh;               // только теперь подменяем указатель
        }
    }
    char operator[](std::size_t i) const { return rep_->data[i]; }   // чтение без копии
    char& operator[](std::size_t i) { detach(); return rep_->data[i]; }  // запись копирует
};

Разбор. CowString(const CowString&) — идеальный пример дешёвого копирования: он не трогает символы, а лишь увеличивает счётчик. detach() реализует принцип «не копируй, пока не пишешь». Деструктор гарантирует, что буфер освободит ровно последний владелец. На защите спросят, почему счётчик должен быть потокобезопасным и почему operator[] const не вызывает detach() — ответ: чтение не изменяет содержимое, значит копия не нужна.

Что могут спросить дополнительно. «А как сделать CoW для std::vector?» — точно так же: Rep с указателем на массив, размером и счётчиком; detach() при push_back, operator[] не-const, resize и т. д. «А почему в современных строках отказались от CoW?» — из-за атомарности итераторов (после модификации старые итераторы в других потоках могут стать невалидными), многопоточности (атомарный инкремент дороже простого копирования для коротких строк) и того, что move-семантика эффективнее решает ту же задачу.

BigInteger и Rational

Что это такое? BigInteger — целое число, ограниченное только объёмом памяти (вместо 64 бит у long long); Rational — точная дробь p/q, которая не теряет точность, как это делает double. Обе задачи проверяют умение проектировать класс с инвариантом и перегружать операторы.

Аналогия из жизни. BigInteger — это как если бы вы считали в столбик на бумаге: у вас есть тетрадный лист, на каждой строке — блок из 9 цифр (это и есть «разряд»). Когда блоки заканчиваются, вы добавляете новую «строку» в тетради. Rational — это как кухонный рецепт: вместо того чтобы мерить «полторы чашки», вы записываете «3/2 чашки», чтобы не округлять и не терять точность при умножении ингредиентов.

Постановка. BigInteger — целое произвольной длины, которое не помещается в long long; Rational — точная дробь без потери точности. Обе задачи проверяют умение проектировать класс с инвариантом и перегружать операторы.

BigInteger: идея словами. Храним цифры не по одной, а «пачками» по 10^9 в std::vector<int>, где digits_[0] — младший разряд. Тогда сложение — это перенос по разрядам, умножение — «в столбик» за O(n·m), а вывод — печать старшего разряда без ведущих нулей и остальных с добавлением нулей слева. Основание 10^9 выбирают потому, что 10^18 ещё влезает в long long при умножении разрядов.

Почему именно основание 10^9? Если взять 10^10, то при умножении двух разрядов 10^10 * 10^10 = 10^20 уже не помещается в long long (максимум ≈ 1.8·10^19). Если взять 10^8, то тратим в 1.125 раз больше памяти и делаем больше разрядов при той же длине числа. 10^9 — оптимальный баланс: умножение двух разрядов даёт максимум 10^18 - 2·10^9 + 1, что помещается в int64_t с запасом.

BigInteger: подводные камни. (1) Перенос (carry) надо аккуратно пробрасывать до конца, а не только по длине меньшего числа — иначе 999 + 1 даст неправильный ответ, потому что перенос из младшего разряда должен дойти до старшего. (2) Ноль и знак требуют отдельного представления и нормализации: удобно хранить знак отдельным bool, а пустой вектор интерпретировать как ноль. (3) При умножении int-разряды нельзя перемножать напрямую без long long — переполнение. (4) Копирование и перемещение должны быть корректны (вектор делает это сам — правило нуля). (5) Деление столбиком — самая сложная операция; часто реализуют через бинарный поиск частного, чтобы не гадать «сколько раз a помещается в b» наугад.

class BigInteger {
    static const int BASE = 1000000000;   // основание: 1e9 помещается в int
    std::vector<int> digits_;             // младший разряд — digits_[0]
public:
    BigInteger& operator+=(const BigInteger& o) {   // перенос по разрядам
        /* сложить поразрядно, затем пробросить carry до конца */ return *this;
    }
    friend BigInteger operator+(BigInteger a, const BigInteger& b) {
        return a += b;                    // a — копия: её и меняем (симметрия)
    }
    friend std::ostream& operator<<(std::ostream&, const BigInteger&);
};

Разбор оператора +. Обратите внимание на сигнатуру friend BigInteger operator+(BigInteger a, const BigInteger& b): параметр a принимается по значению специально. Это идиома, которая делает оператор + симметричным (не важно, левый или правый аргумент — оба копируются одинаково) и легко реализуется через +=. Компилятор сделает copy elision, поэтому лишнего копирования обычно нет.

Rational: идея словами. Храним числитель и знаменатель, поддерживая инвариант: дробь сокращена, знаменатель положителен. Любое изменение проходит через normalize(), который делит на gcd и переносит минус в числитель. Арифметика идёт по формулам a/b + c/d = (ad + cb)/(bd) с последующим сокращением.

Почему важна нормализация? Без неё Rational(1, 2) == Rational(2, 4) вернёт false, хотя математически дроби равны. Без знака в числителе Rational(1, -2) и Rational(-1, 2) будут разными объектами, и сравнения запутаются. Нормализация — это инвариант, который говорит: «у каждой дроби есть ровно одно каноническое представление».

Rational: подводные камни. (1) Забыть нормализацию — и 2/4 не будет равно 1/2 при сравнении. (2) Знаменатель не должен быть нулём — это нарушение инварианта, бросайте исключение. (3) Приведение к double делайте explicit, чтобы не получить неявных преобразований. (4) Переполнение long long при умножении знаменателей — либо используйте __int128 (GCC/Clang), либо сокращайте раньше. (5) Сравнение лучше делать через num_ * o.den_ == o.num_ * den_ — проще, чем сравнивать нормализованные дроби, и заодно корректно работает с переполнением, если сначала проверить знаки.

long long gcd(long long a, long long b) { return b ? gcd(b, a % b) : a; }

class Rational {
    long long num_ = 0, den_ = 1;         // инвариант: den_ > 0 и gcd(|num_|, den_) == 1
    void normalize() {                     // приводим дробь к каноническому виду
        long long g = gcd(std::llabs(num_), std::llabs(den_));
        num_ /= g; den_ /= g;             // сокращаем
        if (den_ < 0) { num_ = -num_; den_ = -den_; }   // знак — в числитель
    }
public:
    Rational(long long n, long long d) : num_(n), den_(d) {
        if (d == 0) throw std::invalid_argument("нулевой знаменатель");
        normalize();
    }
    Rational operator+(const Rational& o) const {      // общий знаменатель
        return Rational(num_ * o.den_ + o.num_ * den_, den_ * o.den_);
    }
    explicit operator double() const { return static_cast<double>(num_) / den_; }
};

Типичная ошибка на экзамене. Студенты часто забывают normalize() в operator+=, operator*= и других изменяющих операторах — там, где числитель и знаменатель меняются местами или перемножаются. Инвариант может нарушиться даже после +=, если знаменатели не привести к общему виду и не сократить. Правило: после любой операции, меняющей num_ или den_, вызывайте normalize().

Smart Pointers

Что это такое? Умные указатели — это RAII-обёртки над сырыми указателями, которые автоматически вызывают delete в деструкторе. Они решают классическую проблему C++ — «кто владеет этим объектом и когда его удалять». Задача «напишите свои unique_ptr и shared_ptr» — это проверка того, понимаете ли вы move-семантику, счётчики ссылок и разделение владения.

Аналогия из жизни. UniquePtr — это как ключи от единственной машины в семье: машина одна, ключи одни, передал — значит отдал. SharedPtr — это как общий автомобиль в каршеринге: пока хоть один пользователь арендовал, машина существует; как только все вернули — машина уезжает на стоянку. WeakPtr — это как подписка на обновления про эту машину: вы знаете, что она существует, но не можете ею воспользоваться, пока не «прокатите» подписку до полного владения.

Постановка. Написать свои UniquePtr (единоличный владелец, только перемещение) и SharedPtr (разделяемое владение со счётчиком; WeakPtr наблюдает, не увеличивая счётчик). Это задача «на понимание RAII»: демонстрирует и владение, и move-семантику.

Идея словами. UniquePtr хранит сырой указатель и владеет им: копирование запрещено, перемещение отбирает владение и обнуляет источник, деструктор освобождает. SharedPtr дополнительно держит управляющий блок с двумя счётчиками (сильных и слабых ссылок): при копировании сильный счётчик растёт, при обнулении — объект удаляется; WeakPtr увеличивает только слабый счётчик и не мешает удалению.

Зачем два счётчика в SharedPtr? Может показаться, что достаточно одного. Но WeakPtr должен знать, жив ли ещё управляющий блок (чтобы понять, можно ли «прокатить» себя до SharedPtr). Если бы был только сильный счётчик, WeakPtr не смог бы отличить «объект удалён, но блок жив» от «объект жив, и мы — слабая ссылка». Слабый счётчик гарантирует, что блок живёт, пока есть хотя бы одна слабая ссылка, — даже если объект уже разрушен.

Ключевые подводные камни. (1) reset() должен корректно обрабатывать nullptr (сам delete nullptr безопасен по стандарту). (2) Self-assignment при перемещающем присваивании: нужна проверка this != &o, иначе reset() сначала удалит объект, а потом мы попытаемся переместить из уже мёртвого указателя. (3) Перемещающий конструктор/присваивание обязаны быть noexcept, иначе контейнеры (std::vector, std::map) при реаллокации будут не перемещать, а копировать ваши умные указатели — а копирование для UniquePtr запрещено. (4) operator bool делайте explicit, иначе указатель начнёт неявно преобразовываться в числа и будет конфликтовать с арифметикой указателей. (5) В SharedPtr удаление объекта и управляющего блока — не одно и то же: слабые ссылки могут жить дольше объекта. (6) Не забудьте про шаблонный конструктор от другого типа указателя — например, UniquePtr<Derived> должен конструироваться из Derived*, но не из Base* без явного приведения.

template <typename T>
class UniquePtr {
    T* ptr_ = nullptr;
public:
    explicit UniquePtr(T* p = nullptr) noexcept : ptr_(p) {}
    UniquePtr(const UniquePtr&) = delete;                 // копировать нельзя
    UniquePtr& operator=(const UniquePtr&) = delete;
    UniquePtr(UniquePtr&& o) noexcept                     // перемещение забирает владение
        : ptr_(o.ptr_) { o.ptr_ = nullptr; }              // источник становится пустым
    UniquePtr& operator=(UniquePtr&& o) noexcept {
        if (this != &o) { reset(); ptr_ = o.ptr_; o.ptr_ = nullptr; }   // защита от self-move
        return *this;
    }
    ~UniquePtr() { reset(); }                             // RAII: освобождение автоматически
    void reset(T* p = nullptr) noexcept { delete ptr_; ptr_ = p; }
    T& operator*() const { return *ptr_; }                // разыменование
    T* operator->() const { return ptr_; }                // доступ к членам
    T* get() const { return ptr_; }                       // наблюдение без передачи владения
    explicit operator bool() const { return ptr_ != nullptr; }   // if (u) ...
};

Разбор. Обратите внимание на мелкие, но важные детали: noexcept на каждом методе, который не бросает исключений; explicit на конструкторе от сырого указателя (иначе конструктор UniquePtr может неявно «съесть» любой указатель в списке инициализации); удалённые копирующие операции; обязательное обнуление источника после перемещения (иначе два UniquePtr будут считать себя владельцами). Это всё — «кирпичики», из которых строится корректный RAII.

Что могут спросить дополнительно. «А как сделать make_unique?» — это функция, которая за один вызов конструирует объект и оборачивает его в UniquePtr. Преимущество — не нужно дважды думать про безопасность исключений и не нужно писать new T(args). «А почему reset() принимает аргумент по умолчанию nullptr?» — чтобы можно было писать u.reset() для простого освобождения без замены, и u.reset(newValue) для замены. Разбор. Обратите внимание: operator* и operator-> помечены const, но возвращают неконстантные ссылку/указатель — это позволяет менять объект через константный UniquePtr, что соответствует семантике самого указателя. Для SharedPtr в управляющем блоке счётчики делают атомарными (std::atomic<long>), а WeakPtr::lock() временно «повышает» слабую ссылку до сильной, возвращая nullptr (или пустой SharedPtr), если объект уже удалён.

Аналогия из жизни. Умный указатель — это как арендованная квартира: UniquePtr — это когда договор только на одного жильца (передать другому = перезаключить), SharedPtr — коммуналка с общим счётчиком жильцов, а WeakPtr — гость, который знает адрес, но не «живёт» там. Как только все жильцы съехали (use_count == 0), комната уничтожается, и гостю (WeakPtr::lock()) остаётся только развести руками (nullptr).

Почему const, но возвращает неконстантное? Сам указатель «логически константен» — его нельзя переставить на другой объект, нельзя «обнулить». Но объект, на который он указывает, менять можно — это всё ещё «тот же самый» объект. Сравните с указателем на стеке: сам указатель может быть в const-переменной, но данные по нему менять можно.

Шаблонная Matrix

Постановка. Реализовать шаблонную матрицу с доступом m(i, j), поэлементным сложением и умножением, проверкой согласованности размеров. Задача показывает и шаблоны, и перегрузку operator(), и работу с памятью.

Что такое матрица в математике и в C++. Математически матрица — это прямоугольная таблица чисел, у которой есть строки (rows) и столбцы (cols). Элемент на пересечении i-й строки и j-го столбца обозначается m[i][j] или m(i, j). В программировании мы должны решить, как именно хранить эту таблицу в памяти компьютера: построчно, по столбцам, списком списков, блоками и т. д. От этого выбора зависят скорость доступа, расход памяти и удобство кода.

Идея словами. Матрицу хранят не как «вектор векторов» (это лишние аллокации и плохая локальность), а как одномерный std::vector<T> плюс число столбцов. Индекс (i, j) переводится в линейный как i * cols + j — это построчное (row-major) представление. operator() выбран вместо operator[], потому что принимает два индекса. Умножение — тройной цикл; порядок циклов i-k-j даёт лучшую локальность.

Аналогия из жизни. Представьте длинную узкую ленту, на которую вы записываете числа по порядку: сначала первую строку слева направо, потом вторую, потом третью. Если знаете номер строки i и столбца j, то нужная ячейка находится на расстоянии «i строк полностью + j шагов» от начала ленты. Именно так i * cols + j и работает.

Почему плоский vector, а не vector<vector<T>>?

  • Меньше аллокаций: внутренний vector для каждой строки — это отдельный new (или несколько, по мере роста). Плоский массив — одна аллокация на всю матрицу.
  • Кэш-дружелюбность: процессор тащит данные из RAM блоками (кэш-линиями ~64 байта). Если элементы лежат подряд, в одну кэш-линию попадает сразу 8–16 штук double (или 16–32 int). Для vector<vector<T>> соседние строки лежат в разных местах памяти.
  • Проще арифметика указателей: один .data() и один T* вместо «двойного разыменования».

Почему operator(), а не operator[]? operator[] в C++ принимает ровно один аргумент. Для матрицы нужно два индекса, поэтому перегружают operator(). Альтернатива — сделать operator[] возвращающим «прокси-объект» строки, у которого снова operator[] берёт столбец. Это работает, но усложняет код.

Ключевые подводные камни. (1) Не забыть проверку размеров: сложение — только для одинаковых, умножение — когда a.cols() == b.rows(). (2) operator() нужен в двух версиях: константной и неконстантной. (3) Индексация i * cols_ + j должна использовать std::size_t, чтобы не переполняться на больших матрицах. (4) Возврат по значению из operator+ — норма: это временный объект, его перемещение дёшево.

Типичная ошибка №1: забыть проверку размеров. Студент пишет operator+ без проверки и получает «магические» значения из неинициализированной памяти или падение по std::bad_alloc из-за попытки создать «минус-вторую» матрицу. Всегда бросайте std::invalid_argument или std::out_of_range с понятным сообщением — преподаватель это ценит.

Типичная ошибка №2: одна версия operator(). Если написать только T& operator()(size_t, size_t), то код const Matrix m; m(0, 0) = 5; не скомпилируется, потому что m — константный, а оператор обещает изменение. Поэтому делают две перегрузки: одна для изменяемых матриц, другая (с суффиксом const) для константных. Возвращают const T& и T& соответственно.

Типичная ошибка №3: знаковый int для индексов. Если int 32-битный, то матрица размером 2³¹ × 2³¹ уже не помещается. Используйте std::size_t (он же size_t) — он на 64-битных системах 64-битный.

Типичная ошибка №4: лишние копирования. Возврат Matrix r; ... return r; компилятор оптимизирует через NRVO (Named Return Value Optimization), но если вы напишете return r + something; где-то ещё, лучше явно return std::move(r) — но только в конце функции и только если NRVO не сработает. Иначе можно навредить.

template <typename T>
class Matrix {
    std::size_t rows_ = 0, cols_ = 0;
    std::vector<T> data_;                 // плоское хранение: строка за строкой
public:
    Matrix(std::size_t r, std::size_t c) : rows_(r), cols_(c), data_(r * c) {}
    T& operator()(std::size_t i, std::size_t j) { return data_[i * cols_ + j]; }
    const T& operator()(std::size_t i, std::size_t j) const { return data_[i * cols_ + j]; }
    std::size_t rows() const { return rows_; }
    std::size_t cols() const { return cols_; }

    friend Matrix operator+(const Matrix& a, const Matrix& b) {
        if (a.rows_ != b.rows_ || a.cols_ != b.cols_)          // размеры обязаны совпасть
            throw std::invalid_argument("размеры");
        Matrix r(a.rows_, a.cols_);
        for (std::size_t k = 0; k < r.data_.size(); ++k)
            r.data_[k] = a.data_[k] + b.data_[k];              // поэлементно, линейно по памяти
        return r;
    }
};

template <typename T>
Matrix<T> multiply(const Matrix<T>& a, const Matrix<T>& b) {
    if (a.cols() != b.rows()) throw std::invalid_argument("размеры");   // n x m * m x k
    Matrix<T> r(a.rows(), b.cols());
    for (std::size_t i = 0; i < a.rows(); ++i)
        for (std::size_t k = 0; k < a.cols(); ++k)             // k-цикл снаружи j — локальность
            for (std::size_t j = 0; j < b.cols(); ++j)
                r(i, j) += a(i, k) * b(k, j);                  // накопление слагаемых
    return r;
}

Разбор. Сложность умножения — O(n³) по числу элементов; ускорить можно порядком циклов и блочным умножением (кэш-блокинг). На защите спросят, почему плоское хранение лучше «вектора векторов»: меньше аллокаций, непрерывная память, лучшая локальность кэша. И почему friend, а не метод: оператор симметричен и не должен быть привязан ни к левому, ни к правому операнду.

Подробнее про порядок циклов i-k-j. Школьный вариант умножения матриц — i-j-k: «для каждой строки a для каждого столбца b пройдись по общей размерности». Но в этом варианте b(k, j) прыгает по строкам b — каждое обращение в новую кэш-линию. В варианте i-k-j внутренний цикл по j идёт по строке b(k, *) — а это непрерывный кусок памяти. Процессор тащит его в кэш один раз, и все BLOCK_SIZE / sizeof(T) обращений к b(k, j) — попадания в кэш. То же с a(i, k): k меняется медленно, j — быстро, значит a(i, k) во внутреннем цикле вообще не меняется и его можно даже вынести в локальную переменную.

Что такое кэш-блокинг? Это приём, при котором матрицы разбивают на квадратные подблоки (например, 64×64) и перемножают блоками. Подблок целиком помещается в L1-кэш, поэтому обращения к нему — мгновенные. Это ускоряет умножение в 4–10 раз на практике. Реализуется через шесть вложенных циклов: три по блокам, три внутри блока. На экзамене достаточно упомянуть, что такой приём существует.

Почему friend? Симметричные операторы (+, -, *, ==) не должны «принадлежать» ни левому, ни правому операнду — иначе пришлось бы менять дизайн классов или делать бесполезные преобразования. friend даёт свободной функции доступ к приватным полям, при этом не нарушая инкапсуляцию снаружи.

Deque

Постановка. Реализовать двустороннюю очередь: O(1) доступ по индексу, амортизированно O(1) вставка и удаление с обоих концов. Это задача про компромисс между непрерывностью vector и гибкостью list.

Что такое deque. deque (от double-ended queue, «двусторонняя очередь») — это контейнер, который позволяет за O(1) добавлять и удалять элементы как в начало, так и в конец. Доступ по индексу тоже O(1), в отличие от list, где индекс — это O(n) (надо пройти по списку). При этом deque не обязан хранить данные в одном непрерывном куске памяти — это его главное отличие от vector.

Идея словами. std::deque — это набор блоков фиксированного размера (обычно 512 байт) и circular-буфер указателей на эти блоки. Внутри блока элементы лежат подряд, поэтому локальность кэша хорошая; но блоки не обязаны быть рядом. Индекс i превращается в пару «номер блока, позиция внутри блока»: (head_ + i) / BLOCK и (head_ + i) % BLOCK.

Аналогия из жизни. Представьте поезд. Вагоны (blocks_) стоят в депо в определённом порядке, но не обязательно друг за другом — между ними могут быть другие составы. Внутри каждого вагона — купе фиксированной длины (BLOCK), и пассажиры сидят по порядку. Если вы знаете номер «места от головы поезда» (head_ + i), то можно посчитать: «в каком вагоне» (/ BLOCK) и «в каком купе/месте» (% BLOCK). Это и есть дек.

Почему блоки, а не один большой массив? Потому что при добавлении элемента в начало vector пришлось бы сдвигать все остальные — это O(n). А в деке мы просто «прицепили вагон спереди» — старые вагоны не сдвигаются. Аналогично при переполнении блока: выделяем новый блок и добавляем его в «расписание», не трогая остальные.

Что такое head_? Это индекс «логического» начала очереди внутри блоков. Чтобы push_front был дешёвым, мы не сдвигаем данные, а двигаем «голову» влево по кольцу. При head_ == 0 и попытке добавить элемент в начало — выделяем новый блок слева и ставим head_ в конец этого блока.

Ключевые подводные камни. (1) При выходе за границы буфера указателей нужно либо сдвигать его, либо расширять и переносить — это сложнее всего. (2) push_front сдвигает head_ назад (с переходом через ноль буфера). (3) Линейный доступ O(1) достигается арифметикой индексов, но он медленнее, чем у vector (два разыменования). (4) Итераторы deque нельзя сравнивать произвольно, как у vector.

Подробнее про (1). Буфер указателей на блоки (vector<unique_ptr<T[]>>) сам по себе конечен. Когда мы добавляем новый блок, а индекс в буфере уже за пределами — нужно либо перевыделить буфер указателей покрупнее, либо сделать его «кольцевым» (circular). В стандартной библиотеке используется кольцевой буфер: индексы head_ и tail_ ходят по модулю размера буфера. На экзамене часто просят реализовать именно кольцевой буфер — это самая тонкая часть.

Подробнее про (3). В vector обращение к v[i] — это одно умножение и одно разыменование: *(v.data() + i). В deque — это два разыменования: сначала найти указатель на блок blocks_[block_index], потом внутри блока — block[pos_in_block]. На практике разница в 2–5 раз, не в разы. Но если вам нужен чистый линейный проход по «горячим» данным — vector всё равно выигрывает.

Подробнее про (4). Итераторы deque в стандартной библиотеке — это «умные» итераторы: они хранят указатель на текущий блок и позицию в нём. Сравнение < между произвольными итераторами может потребовать линейного прохода по блокам. Поэтому в общем случае для deque корректен только it1 - it2 (за O(1)), а произвольные сравнения могут быть линейными.

Когда выбирать deque? Когда вам нужны частые вставки/удаления с обоих концов и при этом нужен доступ по индексу. Типичные примеры:

  • скользящее окно фиксированного размера (push_back новый, pop_front старый);
  • реализация очереди с приоритетами как адаптера;
  • алгоритмы, где данные поступают и уходят с двух сторон. Когда НЕ выбирать deque? Если данные почти всегда добавляются только в конец — берите vector (он быстрее и компактнее). Если нужен произвольный доступ к середине с вставками туда же — берите list или специализированные структуры.
template <typename T, std::size_t BLOCK = 8>
class Deque {
    std::vector<std::unique_ptr<T[]>> blocks_;   // блоки фиксированного размера
    std::size_t head_ = 0, size_ = 0;             // позиция первого элемента и размер
public:
    T& operator[](std::size_t i) {
        std::size_t pos = head_ + i;              // «логический» индекс от начала очереди
        return blocks_[pos / BLOCK][pos % BLOCK]; // блок — частное, позиция — остаток
    }
    void push_front(const T& v) { /* при переполнении — добавить блок слева, затем --head_ */ }
    void push_back(const T& v)  { /* при переполнении — новый блок справа */ }
};

Разбор. Из-за блочного хранения deque имеет O(1) доступ, но не обязан быть непрерывным (в отличие от vector). Он выбирается, когда важны вставки в начало: vector::insert(begin(), ...) сдвигает все элементы за O(n), а deque::push_front — амортизированно O(1). Плата — чуть большее потребление памяти и медленный линейный доступ.

Что значит «амортизированно O(1)»? Это значит, что в среднем одна операция стоит константу, хотя некоторые операции могут стоить дорого. Например, при добавлении нового блока мы тратим O(BLOCK) на аллокацию, но это случается раз в BLOCK операций. Среднее: (BLOCK + 1 + 1 + ... + 1) / BLOCK ≈ O(1). Преподаватели любят этот термин — обязательно используйте его в ответах.

Гарантии std::deque по стандарту. Стандарт C++ гарантирует, что push_front, push_back, pop_front, pop_back — амортизированно O(1). operator[] — O(1). Доступ к первому и последнему элементу — O(1). Итераторы (кроме специализированных) — минимум итераторы произвольного доступа, но сравнения могут быть медленными (формально не O(1) для произвольной пары).

Unordered Map

Постановка. Реализовать хеш-таблицу (словарь) с insert, find и автоматическим перехешированием при росте. Это задача на понимание того, что скрывается внутри std::unordered_map.

Что такое хеш-таблица и зачем она нужна. Хеш-таблица — это структура данных, которая хранит пары «ключ — значение» и позволяет искать значение по ключу в среднем за O(1). Это контрастирует с std::map, где поиск — O(log n) (красно-чёрное дерево), но зато элементы всегда отсортированы. Если порядок не важен, а важна скорость — выбирают хеш-таблицу.

Идея словами. Есть массив «корзин», в каждую попадает ключ по значению hash(key) % buckets_.size(). Коллизии разрешаются цепочками: корзина — связный список узлов. Коэффициент заполнения (load factor = size / число корзин) держат ниже порога (например, 0.75); при превышении таблицу расширяют примерно вдвое и переносят узлы в новые корзины.

Аналогия из жизни. Хеш-таблица — это библиотека с N залами. Каждая книга (ключ) попадает в зал по хешу своего шифра (hash % N). В одном зале может быть несколько полок (цепочка). Библиотекарь следит за «плотностью»: если в среднем на зале стало слишком много книг, он переезжает в здание побольше (вдвое больше залов) и пересчитывает шифры — часть книг окажется в других залах. Это и есть перехеширование.

Что такое коллизия и почему она неизбежна? Коллизия — это когда два разных ключа дают одинаковый индекс корзины. По «парадоксу дней рождения» это случается гораздо чаще, чем кажется: в таблице из 365 корзин уже 23 ключа с вероятностью 50% дадут коллизию. Поэтому коллизии надо уметь обрабатывать, а не надеяться, что их не будет.

Два способа разрешения коллизий:

  • Цепочки (chaining): в каждой корзине — связный список (или дерево при большой длине) всех ключей, попавших в неё. Просто удалять, легко увеличивать таблицу. Минус: лишние указатели, фрагментация памяти.
  • Открытая адресация (open addressing): при коллизии пробуем следующую корзину по правилу (линейно, квадратично, двойное хеширование). Лучшая локальность кэша (данные рядом), нет лишних аллокаций. Минус: сложнее удалять (нужны «удалённые» метки), коэффициент заполнения должен быть строго меньше 1.

Ключевые подводные камни. (1) При перехешировании меняются индексы ВСЕХ узлов — нельзя просто копировать массив. (2) % buckets_.size() требует ненулевого размера: пустую таблицу нельзя перехешировать в ноль. (3) Удаление в цепочке — двусвязный или «с предыдущим» обход, иначе не сцепить соседей. (4) Плохая хеш-функция превращает операции в O(n); хорошая должна равномерно распределять ключи. (5) Ключи должны иметь std::hash<K> и operator==.

Подробнее про (1). Очень частая ошибка студентов: написать rehash() через std::copy(buckets_.begin(), buckets_.end(), new_buckets.begin()). Так нельзя! Указатели на узлы останутся в тех же корзинах, но правильный индекс вычисляется от нового размера таблицы. Нужно пройтись по всем узлам и для каждого заново посчитать hash % new_size, распределяя их по новым корзинам. В среднем это O(n) операций, что и окупается за счёт того, что потом поиск снова дешёвый.

Подробнее про (2). Если buckets_.size() == 0, то % 0 — это UB (undefined behavior, деление на ноль). Правильно: либо начинать с buckets_(1), либо проверять if (buckets_.empty()) buckets_.resize(1); перед каждым обращением к корзине. Стандартный unordered_map обычно стартует с какого-то разумного размера (часто 8 или 16) и запрещает размер 0.

Подробнее про (3). Если у вас односвязный список, удаление узла требует знать указатель на предыдущий узел — иначе не перекинуть next. Значит, в erase нужно либо хранить два указателя (prev, cur), либо сделать список двусвязным (std::list), либо перейти на открытую адресацию (но там удаление тоже не бесплатно).

Подробнее про (4). Хеш-функция — это сердце хеш-таблицы. Примеры плохих хешей для строк:

  • return s.size(); — все строки одной длины попадут в одну корзину.
  • return s[0]; — все строки, начинающиеся на 'A', в одной корзине. Хороший хеш распределяет ключи равномерно по диапазону. В учебных задачах достаточно std::hash<K>{}(key) — он уже «правильный» для стандартных типов. Но помните: для пользовательских типов нужно писать свою специализацию std::hash.

Про (5). Чтобы положить ключ в таблицу, нужно уметь:

  • Посчитать хеш: std::hash<K>{}(key) возвращает size_t.
  • Сравнить на равенство: key1 == key2, потому что хеш-функция может давать коллизии (одинаковый хеш для разных ключей), и тогда надо сравнить сами ключи. Без этих двух операций ваш класс нельзя использовать как ключ unordered_map.
template <typename K, typename V>
class HashMap {
    struct Node { K key; V value; Node* next = nullptr; };   // узел цепочки
    std::vector<Node*> buckets_;                              // массив корзин
    std::size_t size_ = 0;                                    // число элементов
    double max_load_ = 0.75;                                  // порог перехеширования

    std::size_t index(const K& key) const {                   // корзина для ключа
        return std::hash<K>{}(key) % buckets_.size();
    }
public:
    HashMap() : buckets_(8) {}                                // стартовый размер
    void rehash(std::size_t n) { /* перераспределить все узлы между новыми корзинами */ }
    void insert(const K& key, const V& value) {
        if (static_cast<double>(size_ + 1) / buckets_.size() > max_load_)
            rehash(buckets_.size() * 2);                      // рост до вставки
        // найти цепочку корзины: если ключ уже есть — обновить value, иначе добавить узел
    }
    V* find(const K& key) {                                   // поиск по ключу
        for (Node* n = buckets_[index(key)]; n; n = n->next)
            if (n->key == key) return &n->value;              // нашли — вернули значение
        return nullptr;                                       // не нашли
    }
};

Разбор. Амортизированно insert и find дают O(1), но при плохом хеше возможен O(n). Именно поэтому в unordered_map есть reserve() и max_load_factor(). На защите спросят про разницу между цепочками и открытой адресацией: в цепочках проще удаление, в открытой адресации лучше локальность, но требуется коэффициент заполнения меньше единицы.

Что делают reserve() и max_load_factor()? reserve(n) говорит таблице: «ожидаю примерно n элементов, выдели столько корзин, чтобы коэффициент заполнения остался ниже порога». Это позволяет избежать перехеширования во время вставок. max_load_factor(0.5) — понижаем порог: корзин будет в два раза больше, цепочки короче, поиск быстрее, но больше памяти. Полезно, когда много поиска и мало вставок.

Сравнение map vs unordered_map (часто спрашивают на защите).

Критерий std::map std::unordered_map
Внутренняя структура Красно-чёрное дерево Хеш-таблица
Поиск O(log n) гарантированно O(1) в среднем, O(n) в худшем
Вставка O(log n) O(1) в среднем
Порядок элементов Отсортированы по ключу Не определён
Требования к ключу Только operator< (или компаратор) std::hash и operator==
Память Меньше (только узлы дерева) Больше (корзины + узлы)
Когда выбирать Нужен порядок, диапазоны, стабильная сложность Важна скорость, порядок не важен

Подробнее про «худший случай O(n)». Если злоумышленник (или неудачные данные) подберёт ключи так, что все они попадают в одну корзину, хеш-таблица превращается в связный список. В реальности для защиты используют: (1) криптографические хеш-функции с секретным ключом (хеширование с универсальным хешированием), (2) рандомизацию хеша (в GCC libstdc++ для строк применяется MurmurHash с рандомным seed). На экзамене достаточно упомянуть, что такая проблема существует и как её обходят.

Tuple

Постановка. Реализовать кортеж — контейнер разнородных значений, доступный по индексу (get<0> даёт первый элемент, get<1> — второй). Это задача на вариадические шаблоны и рекурсивное наследование — «высший пилотаж» МГУ и продвинутого МФТИ.

Что такое кортеж простыми словами? Это как список покупок, где в каждой ячейке может лежать что угодно: на первой позиции — число (например, вес в граммах), на второй — строка (название товара), на третьей — double (цена). Главное отличие от массива: все «ячейки» могут содержать значения разных типов.

Аналогия из жизни. Представьте себе составной поезд или матрёшку: каждый вагон — отдельный «слой» данных, который знает только своё значение и указатель на следующий вагон. Чтобы добраться до третьего элемента, вы проходите цепочку с первого по третий — ровно так же работает и get<I> в реализации ниже.

Зачем это нужно на практике? Кортеж позволяет вернуть из функции сразу несколько значений без заведения структуры struct Result { ... }; — в сочетании со structured bindings (auto [a, b] = f();) получается почти «магия».

Идея словами. Вариадический шаблон Tuple<Ts...> рекурсивно наследует Tuple<остальные...>, а каждый уровень добавляет одно поле value. Тогда Tuple<int, string, double> — это цепочка из трёх базовых классов. std::get<I> рекурсивно поднимается по этой цепочке до нужного уровня и возвращает соответствующее поле. Индекс в таком решении известен на этапе компиляции, поэтому доступ бесплатен.

Почему именно рекурсивное наследование, а не, скажем, массив T[] или std::variant?

  • Массив не подходит, потому что все его элементы должны быть одного типа, а у нас — int, string, double вперемешку.
  • std::variant хранит только одно значение из нескольких возможных, а нам нужны все одновременно.
  • Рекурсивное наследование даёт чистый compile-time доступ по индексу: компилятор знает, на каком «этаже» находится I-й элемент, и поднимается туда по цепочке баз без единого штрафа в рантайме.

Как «разворачивается» цепочка — шаг за шагом. При инстанцировании Tuple<int, string, double> компилятор делает следующее:

  1. Видит параметры T = int, Ts... = string, double → наследует Tuple<1, string, double> и хранит у себя поле int value.
  2. Внутри Tuple<1, string, double> параметры T = string, Ts... = double → наследует Tuple<2, double> и хранит у себя поле string value.
  3. Tuple<2, double> — параметры T = double, Ts... = ∅ → наследует пустой Tuple<3> и хранит у себя поле double value.
  4. Tuple<3> — терминальный случай (пустой класс, формально размер 1 байт, хотя EBO может дать 0).

В итоге получается «лесенка» из трёх базовых классов: std::get<0> прыгает в самый нижний (текущий), std::get<1> — в средний (родитель), std::get<2> — в верхний полный (дедушка).

Ключевые подводные камни. (1) Нужен кортектный терминальный случай рекурсии — пустой Tuple<>. (2) Тип на уровне I должен быть выведен правильно (частичная специализация). (3) Несколько полей одного типа по разным индексам: с доступом по индексу проблем нет, а вот get<T> (по типу) потребует дополнительной логики и уникальности типа. (4) Возвращать поля нужно по ссылке на базовый класс, иначе получите копию. (5) Порядок баз в памяти и выравнивание могут добавлять padding.

Разбор каждого пункта — почему это ловушка и как её обойти.

(1) Терминальный случай рекурсии. Без него компилятор не остановится: Tuple<...> будет бесконечно пытаться наследовать от Tuple<...> со сдвинутым индексом. Решение — частичная специализация template <std::size_t I> struct Tuple<I> {}; (пустое тело). Без этого вы получите ошибку вида «too few template arguments» либо компилятор просто зависнет, разворачивая бесконечную рекурсию инстанцирования. Практический совет: терминал объявляется отдельной строкой после основного шаблона — порядок важен, потому что специализации должны быть видны компилятору в момент инстанцирования.

(2) Корректный вывод типа на нужном уровне. Когда вы пишете get<I>(t), шаблонная функция должна «подняться» по базам ровно I раз и вернуть поле нужного типа. Если использовать обычный рекурсивный вызов без частичной специализации или if constexpr, компилятор не сможет вывести тип хранимого значения на нужном уровне. Приём — рекурсивная шаблонная функция с «опусканием» индекса: база — if constexpr (I == 0) return static_cast<LeafT<I, T>&>(t).value;, шаг — else return get<I-1>(static_cast<NextBase&>(t));. Альтернатива — частичная специализация get<0> отдельно от get<N> для N > 0.

(3) Несколько полей одного типа. Если у вас Tuple<int, int, string> и вы хотите get<int> — стандартный std::tuple это запрещает (требует уникальности типа, иначе неоднозначность). Через индекс get<0> и get<1> — всё работает без проблем, потому что индекс всегда указывает на конкретное место в цепочке. Практический вывод: на экзамене обычно достаточно показать доступ по индексу; если спрашивают про доступ по типу — упоминайте требование уникальности.

(4) Возврат по ссылке на базовый класс. Если написать T get() { return value; } без ссылки, вы получите копию значения, и любые изменения через get не отразятся в оригинальном кортеже. Стандартный приём — static_cast<TupleLeaf<I, T>&>(t).value (или static_cast<Base&>(t).value) — это даёт T&, lvalue-ссылку на нужное поле. Типичная ошибка студентов: забывают static_cast и пишут t.value — это не скомпилируется, потому что value лежит в базовом классе, а не в самом Tuple<...> верхнего уровня.

(5) Padding и порядок баз в памяти. Компилятор может разместить базовые классы в памяти в порядке, оптимальном с точки зрения выравнивания (стандарт требует лишь, чтобы первая непустая база шла по тому же адресу, что и сам объект, ради совместимости с C). Для каждой базы с полем T может добавиться выравнивание. Для экзамена достаточно упомянуть, что sizeof(Tuple<int, char>) может быть неожиданно большим (например, 8 байт вместо 5) из-за выравнивания int по 4-байтовой границе.

template <std::size_t I, typename T>
struct TupleLeaf { T value; };                 // узел: индекс и значение

template <typename... Ts>
struct Tuple;                                  // общее объявление

template <std::size_t I, typename T, typename... Ts>
struct Tuple<I, T, Ts...> : Tuple<I + 1, Ts...> {   // наследуем хвост с индексом I+1
    T value;                                   // моё поле — на позиции I
    Tuple(T v, Ts... rest)
        : Tuple<I + 1, Ts...>(rest...), value(v) {}   // хвост инициализируется первым
};

// Терминальный случай (пустой кортеж) объявляется отдельно:
// template <std::size_t I> struct Tuple<I> {};
// std::get<I> рекурсивно поднимается по базовым классам до нужного листа

Мини-шпаргалка по коду (что есть что в этом примере).

  • TupleLeaf<I, T> — «кирпич» на каждом уровне: знает свой индекс и тип значения. В данной минимальной реализации он объявлен, но не используется — поле value хранится прямо в Tuple<I, T, Ts...>. В более аккуратных версиях Tuple наследует именно от TupleLeaf, чтобы get мог делать одинаковый static_cast независимо от позиции.
  • template <typename... Ts> struct Tuple; — первичное объявление без определения. Нужно, чтобы компилятор увидел имя Tuple и не ругался на Tuple<I+1, Ts...> в списке баз рекурсивной специализации. Само тело лежит ниже в частичных специализациях. Это классический приём «forward declaration» в шаблонах.
  • Частичная специализация Tuple<I, T, Ts...> отделяет первый тип (T) от остальных (Ts...) — это и есть рекурсивный шаг. Каждый такой класс хранит свой T и наследует «хвост» со сдвинутым индексом.
  • Конструктор инициализирует базу первой (Tuple<I+1, Ts...>(rest...)), а потом своё поле (value(v)). Порядок инициализации в C++ строго соответствует порядку объявления членов и баз в классе, поэтому хвост гарантированно готов к моменту, когда мы пишем value(v) (хотя здесь это и не критично — поле инициализируется обычным v).
  • Закомментированная строка template <std::size_t I> struct Tuple<I> {}; — терминальный случай (когда Ts... пуст). Без неё код не скомпилируется: цепочка наследования не сможет остановиться. std::get<I> в комментарии — намёк, что саму функцию доступа тоже нужно реализовать отдельно (рекурсивно с if constexpr или через специализации).

Разбор. Такое рекурсивное наследование — учебный приём, показывающий, что вариадический шаблон «разворачивается» в цепочку конкретных типов. В стандартной библиотеке std::tuple устроен похоже, но реализация аккуратнее (учитывает пустые типы через EBO, [[no_unique_address]] в C++20). На экзамене достаточно объяснить идею и нарисовать цепочку баз для Tuple<int, string>: Tuple<0,int,string> : Tuple<1,string> : Tuple<2>. Вместе с std::apply и structured bindings (auto [a, b] = t;) кортеж становится удобным способом вернуть из функции несколько значений.

Что происходит «под капотом» у настоящего std::tuple. В libc++ / libstdc++ рекурсивное наследование действительно используется, но с важными оптимизациями, которые студенты часто не знают:

  • EBO (Empty Base Optimization) — если один из типов пустой (например, пользовательский tag-тип вроде struct Empty {};), пустой базовый класс не занимает места в sizeof. До C++20 это работало «само собой» только для пустых баз; начиная с C++20 появился атрибут [[no_unique_address]], который разрешает компилятору уплотнить размещение даже для непустых типов.
  • Реальный std::tuple наследует не от одного TupleLeaf, а от цепочки специализированных узлов, которые умеют хранить значение и предоставлять типобезопасный get<I> без dynamic_cast или «длинного» static_cast по всей цепочке.
  • Доступ по индексу компилируется в обычное смещение поля, потому что компилятор знает всю цепочку баз и расположение value в каждой из них — никакого рантайм-поиска.
  • В GCC/Clang доступ по индексу для не слишком длинных кортежей дополнительно ускорен за счёт switch-таблицы по I — это уже реализационная деталь.

Что нужно отвечать на экзамене — чеклист.

  1. Нарисовать цепочку наследования для Tuple<int, string>: Tuple<0,int,string> → Tuple<1,string> → Tuple<2>. Каждый прямоугольник — отдельный базовый класс с полем value. Это «картинка», которую преподаватель ждёт на доске.
  2. Объяснить, почему get<0>(t) — это, по сути, static_cast<Tuple<0,int,string>&>(t).value: прыжок к самому нижнему полному базовому классу в иерархии.
  3. Подчеркнуть compile-time природу: индекс I известен в момент компиляции, поэтому доступ «бесплатный» — никакого рантайма, никакого dynamic_cast.
  4. Показать на доске конструктор: как Tuple(T v, Ts... rest) «раздаёт» аргументы по уровням — первый аргумент остаётся текущему узлу, остальные уходят родителю через Tuple<I+1, Ts...>(rest...).
  5. (Бонус) Связь с современным C++: упомянуть std::apply(f, tuple) — вызов функции f с распакованными аргументами кортежа; structured bindings auto [a, b] = t; — синтаксический сахар поверх get<0> и get<1>. Это покажет, что вы понимаете не только «как устроено», но и «как используется».

План подготовки за 7 дней

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

Как работать с планом. Читайте теорию активно: выписывайте ключевые термины на бумагу, рисуйте схемы (vtable, дерево разбора, стек для ПОЛИЗ). Сразу после теории пишите код руками — без копирования из репозитория, только так мозг запоминает синтаксис C++. Самопроверку проводите вслух (перед зеркалом, другу или на диктофон): устная речь выявляет пробелы, которые глаз пропускает. Галочку в чекбоксе ставьте только когда уверены на 100% — если тема «не идёт», не перескакивайте через день, лучше вернитесь к ней позже.

  • День 1. ООП и классы. Начните с определения: ООП — парадигма, в которой программа строится из объектов, объединяющих данные (поля) и поведение (методы) и общающихся через интерфейс. Прочитайте разделы про инкапсуляцию (сокрытие внутреннего состояния за public/private), разницу struct/class (struct по умолчанию всё public, class — всё private), указатель this (адрес текущего объекта, доступен внутри нестатических методов), статические члены (общие для всех объектов класса, существуют в одном экземпляре на весь класс) и константные члены (не изменяют состояние объекта, могут читать, но не писать). Напишите класс с friend-оператором — это нужно, когда оператор должен иметь доступ к приватным полям двух разных классов, например operator<< для красивой печати в std::ostream.

    Аналогия из жизни. Класс — это чертёж дома (размеры, планировка), объект — построенный по нему конкретный дом. У каждого дома свои жильцы и состояние (объекты), но чертёж один (класс). Статическое поле — это общедомовая котельная: одна на весь класс объектов, а не на каждый дом. Инвариант — это правило «прочности» (например, «баланс счёта всегда неотрицательный»), которое должно выполняться для любого корректного объекта.

    Частая ошибка. Путают static внутри класса (общий для всех объектов) и static внутри функции (локальная переменная с временем жизни до конца программы). Также забывают, что const-метод не может изменять поля, даже если они mutable, без явного объявления mutable. Самопроверка: объясните метафору «класс — чертёж, объект — дом» и своими словами — зачем нужен инвариант.

  • День 2. Специальные функции. Rule of 0/3/5 — это свод правил: если класс не управляет ресурсами вручную, следуйте Rule of 0 (положитесь на std::string, std::vector, умные указатели — компилятор сгенерирует всё сам). Если управляете — реализуйте 3 функции (деструктор + копирующий конструктор + копирующий оператор=) или 5 (плюс перемещающий конструктор и перемещающий оператор=). Идиома copy-and-swap реализует копирующее и перемещающее присваивание через одну функцию: создаём копию аргумента, обмениваем её с *this через std::swap — это автоматически даёт сильную гарантию исключений и устраняет дублирование кода. Обязательно помечайте перемещение как noexcept — иначе стандартные контейнеры (std::vector) при push_back/reserve будут копировать, а не перемещать элементы.

    Почему так. std::vector при росте (reallocation) должен перенести старые элементы в новый буфер. Если перемещение может бросить исключение, а в середине процесса часть элементов уже скопирована, а часть нет — состояние контейнера становится неконсистентным (нарушается strong exception safety). Поэтому vector выбирает копирование, если noexcept на перемещении не указан.

    Частая ошибка. Пишут оператор= через самоприсваивание без проверки if (this != &other) — без этого в copy-and-swap будет двойное освобождение памяти. Также забывают, что после std::move(x) переменную x использовать нельзя — она в valid-but-unspecified state. Самопроверка: расскажите «пять друзей класса» (деструктор + copy ctor + copy = + move ctor + move =) и почему без noexcept vector копирует вместо перемещения.

  • День 3. Полиморфизм. Полиморфизм в C++ — это механизм, при котором вызов виртуального метода определяется фактическим типом объекта, а не типом указателя или ссылки. Нарисуйте vtable (таблицу виртуальных функций) для иерархии из трёх классов: базовый класс хранит указатель vptr на свою таблицу type_info + адреса виртуальных методов; каждый наследник имеет свою таблицу с перезаписанными адресами переопределённых методов. Повторите slicing — при присваивании объекта наследника объекту базы по значению «отрезается» наследуемая часть, специфичные поля и методы теряются. Разберитесь, зачем нужен виртуальный деструктор (иначе через Base* деструктор наследника не вызовется — утечка памяти), ключевые слова override (явно показать, что метод переопределяет виртуальный метод базы — компилятор проверит сигнатуру) и final (запретить дальнейшее переопределение в наследниках или наследование от класса), а также dynamic_cast (безопасное приведение вниз по иерархии с проверкой typeid во время выполнения — дорого, используйте только при реальной необходимости).

    Аналогия из жизни. vtable — это меню ресторана: каждое блюдо-класс знает свои ингредиенты (методы), а vptr — это указатель официанта на нужное меню. Виртуальный деструктор обязателен: иначе «ресторан» не уберёт за собой — delete base_ptr вызовет только ~Base(), а ресурсы наследника утекут.

    Частая ошибка. Забывают виртуальный деструктор в базовом классе, у которого есть наследники с динамическими ресурсами. Также путают override с virtual: override пишется только в наследнике и заменяет virtual (конструкция virtual override — ошибка компиляции). Самопроверка: нарисуйте порядок слотов в vtable и объясните, почему объект больше на размер vptr (один машинный указатель — обычно 8 байт на 64-битной платформе).

  • День 4. Шаблоны и STL. Шаблон (template) — это «рецепт» функции или класса, в котором типы данных подставляются при компиляции, а не исполнении (так называемое метапрограммирование времени компиляции). STL (Standard Template Library) — набор контейнеров, итераторов и алгоритмов. Выпишите асимптотики основных контейнеров: vector — O(1) доступ по индексу, O(n) вставка в середину, амортизированный O(1) push_back; list — O(1) вставка/удаление по итератору, O(n) доступ по индексу; map/set — O(log n) на всё (красно-чёрное дерево); unordered_map/unordered_set — амортизированный O(1) на всё, но O(n) в худшем случае (хеш-коллизии); deque — O(1) начало/конец, O(n) середина. Запомните правила инвалидации итераторов: у vector любая push_back, вызвавшая reallocation, инвалидирует все итераторы, указатели и ссылки; у map/set итераторы остаются валидными после вставки (кроме итератора, указывающего на только что удалённый элемент). Напишите шаблонную функцию с enable_if (старый синтаксис SFINAE — std::enable_if_t<cond, T> ставится в тип возврата), затем перепишите на requires (C++20 concepts — T requires std::integral<T> в шапке шаблона, читается намного чище).

    Когда что выбирать. unordered_map быстрее на практике (O(1) против O(log n)), но не сохраняет порядок обхода, может деградировать при плохой хеш-функции, итераторы нестабильны при rehash. map гарантирует упорядоченность, стабильную сложность и предсказуемость — для экзамена безопаснее и проще аргументировать выбор.

    Частая ошибка. Использование vector::push_back в цикле без reserve — приводит к многократным reallocation и инвалидации итераторов/ссылок, которые «вдруг перестают работать». Также забывают, что unordered_map[key] создаёт элемент с дефолтным значением, если ключа нет, в отличие от at(key), который бросает исключение std::out_of_range. Самопроверка: назовите, что инвалидирует итераторы vector (push_back/pop_back, если был reallocation; insert/erase в середине; resize; reserve с ростом) и когда выбирать map вместо unordered_map (нужен порядок обхода, стабильная сложность, маленький размер данных — log n быстрее линейного хеша).

  • День 5. Move-семантика и современный C++. Move-семантика — это способ «забрать» внутренности у временного объекта (rvalue) без копирования: после перемещения исходный объект находится в valid but unspecified state (например, nullptr для указателя, пустая строка для std::string). std::move — это не операция перемещения, а каст к rvalue-ссылке (T&&), который разрешает вызов перемещающих конструкторов/операторов; само перемещение делает принимающая сторона. std::forward — условный каст (perfect forwarding): в шаблоне передаёт аргумент дальше с сохранением его value-категории (lvalue остаётся lvalue, rvalue — rvalue). Выучите таблицу стандартов: C++98 (база — классы, шаблоны, STL, исключения), C++03 (мелкие правки), C++11 (move, auto, lambda, unique_ptr/shared_ptr, nullptr, range-for, constexpr), C++14 (generic lambda, make_unique, std::integer_sequence), C++17 (optional, variant, string_view, structured bindings, if constexpr, filesystem), C++20 (concepts, ranges, modules, coroutines, three-way comparison <=>), C++23 (deducing this, expected, flat_map, mdspan). Напишите свой UniquePtr<T> с шаблонным конструктором, операторами *, ->, move-конструктором, move-оператором=, удалёнными копирующими (= delete).

    Мнемоника «передача ключа». Объект — это машина, а указатель на ресурс — ключ зажигания. std::move — это не «угон», а «передача ключей»: владелец отдаёт ключи, новый хозяин едет на машине. unique_ptr — у машины один ключ, после передачи старый владелец не может её завести; shared_ptr — у машины связка ключей со счётчиком, все владельцы равноправны, машина уничтожается, когда последний ключ выброшен.

    Частая ошибка. Возврат std::move(local_var) из функции: компилятор уже умеет делать NRVO (Named Return Value Optimization) и без move, а явный move может запретить оптимизацию, сделав результат хуже. Также не делайте const T&& — такой объект нельзя ни переместить, ни скопировать, получится «висячее» значение. Самопроверка: объясните мнемонику «передача ключа» и разницу unique_ptr/shared_ptr (уникальное владение против разделяемого со счётчиком ссылок; unique_ptr — нулевой оверхед, shared_ptr — атомарный счётчик, дороже; циклические ссылки ломают shared_ptr — решение weak_ptr).

  • День 6. Теория трансляции и алгоритмы. Повторите иерархию Хомского (от самой общей к самой строгой): Тип 0 — неограниченные грамматики (правила α → β без ограничений, эквивалентны машинам Тьюринга, рекурсивно перечислимые языки); Тип 1 — контекстно-зависимые (правила αAβ → αγβ, где |γ| ≥ 1); Тип 2 — контекстно-свободные (правила A → γ, описывают синтаксис большинства языков программирования); Тип 3 — регулярные (правила A → aB или A → a, описывают лексемы и шаблоны поиска). Повторите детерминизацию НКА — сначала постройте НКА по регулярному выражению (алгоритм Томпсона с ε-переходами), затем преобразуйте его в ДКА через subset construction: каждое состояние ДКА — множество состояний НКА, достижимых из стартового по текущему входному символу. Поймите рекурсивный спуск — метод нисходящего разбора: для каждого нетерминала пишется функция, которая последовательно проверяет токены и рекурсивно вызывает функции для других нетерминалов; работает только для LL(k)-грамматик (однозначный выбор альтернативы по k токенам lookahead, чаще всего k = 1). Повторите ПОЛИЗ (польская инверсная запись, postfix notation): операторы записываются после операндов, вычисляются стековой машиной (встретили оператор — сняли два операнда со стека, положили результат); преобразование инфиксной записи в ПОЛИЗ — через алгоритм Дейкстры (shunting-yard) с приоритетами (* и / выше + и -, скобки повышают приоритет). Реализуйте алгоритм Дейкстры (кратчайшие пути от одной вершины в графе с неотрицательными весами через очередь с приоритетами — O((V + E) log V)) и heapsort (построение max-heap за O(n), затем n раз извлечение максимума — итого O(n log n), сортировка на месте, нестабильная).

    Зачем нужен рекурсивный спуск. Это самый простой способ написать парсер выражений вручную, без генераторов типа yacc/bison: каждому правилу грамматики соответствует одна функция в коде. Он работает только для LL(1)-грамматик — если у одного нетерминала две альтернативы начинаются с одного токена, рекурсивный спуск не сможет выбрать, нужен более мощный алгоритм (LR/LALR, табличный).

    Частая ошибка. В рекурсивном спуске забывают про левую рекурсию: правило A → A b зациклит парсер; нужно сначала устранить рекурсию (через A → b A', A' → b A' | ε). В heapsort путают направление кучи: для сортировки по возрастанию нужна max-heap (максимум в корне), не min-heap. Самопроверка: постройте НКА по регулярному выражению (например, (a|b)*c) и объясните, зачем нужен рекурсивный спуск (самый простой ручной парсер для LL-грамматик, лежит в основе учебных интерпретаторов и калькуляторов).

  • День 7. Задачи и пробный экзамен. Это день симуляции реального экзамена: минимум новой теории, максимум практики. Решите 2–3 задачи из списка выше с таймером (как на экзамене — обычно 30–60 минут на задачу). Ответьте вслух на 30 вопросов из теоретической части — запишите себя на диктофон и прослушайте: если слышите «эээ» и паузы дольше 5 секунд — это пробел, по которому нужно вернуться. Закройте пробелы: вернитесь к тем дням, где самопроверка не сработала, перечитайте теорию и перепишите примеры. Вечером напишите UniquePtr или Rational от руки за 20 минут — это проверка, можете ли вы воспроизвести класс целиком, а не «узнать» его при виде готового кода.

    Стратегия дня. Утро — задачи (самая сложная когнитивная работа, мозг свежий). День — теория вслух (повторение наговоренного). Вечер — финальный прогон UniquePtr/Rational. Перед сном — просмотр таблицы стандартов C++ и раздела «Частые ошибки».

    Частая ошибка. Тратить весь день на «дорешивание» одной сложной задачи — лучше решить две задачи на 80%, чем одну на 100%, потому что экзамен проверяет широту охвата тем, а не глубину одной задачи. Также забывают про перерыв каждые 50 минут (правило Помодоро: 50 минут работы — 10 минут отдыха): уставший мозг запоминает в 3 раза хуже и допускает глупые опечатки в коде. Самопроверка: напишите UniquePtr или Rational от руки за 20 минут (без подглядывания в чужие реализации и свои старые коды).

  • Перед экзаменом: повторить «Частые ошибки» ниже и таблицу стандартов C++ (C++98 → база; C++11 → move, auto, lambda, smart pointers; C++14 → generic lambda, make_unique; C++17 → optional/variant/string_view, if constexpr; C++20 → concepts, ranges, coroutines, <=>; C++23 → deducing this, expected).

Частые ошибки

Для справки. Здесь собраны типовые ловушки C++, на которых студенты чаще всего «горят» на экзаменах МИФИ, МГУ и МФТИ. Каждая строка таблицы — это не абстрактная теория, а реальная ситуация, в которой ваш код либо компилируется молча и падает в рантайме, либо выдаёт неправильный ответ на тесте. Запомните не только формулировки, но и причины (столбец «Причина») — на устном экзамене именно их и спрашивают.

Ошибка Причина Решение
Slicing (срезка объекта) При копировании объекта наследника в переменную типа базы по значению компилятор копирует только «базовую» часть, а всё, что добавил наследник, буквально отрезается и теряется. Аналогия: вы записали в анкету только имя и фамилию, а отчество, рост и хобо выбросили. Всегда передавайте и храните наследников по ссылке или указателю на базу. Если нужна коллекция разнородных наследников — храните std::vector<std::unique_ptr<Base>> или std::vector<std::shared_ptr<Base>>. Никогда не пишите Base b = derived;.
Нет виртуального деструктора Если в базовом классе деструктор не виртуальный, то delete ptr; через указатель на базу вызовет только деструктор базы. Деструкторы наследников не запустятся — ресурсы (память, файлы, мьютексы) утекут. Объявляйте virtual ~Base() = default; в любом классе, который планируете удалять полиморфно. Если класс — чистый интерфейс, делайте деструктор virtual ~IFace() = default;.
Виртуальная функция в конструкторе/деструкторе Во время работы конструктора базы объект ещё «не является» наследником: таблица виртуальных функций (vptr) уже настроена на текущий конструируемый класс. В деструкторе — симметрично: наследник уже «разобран». Аналогия: во время ремонта фундамента дом ещё не стоит — нельзя открыть дверь на втором этаже. Не вызывайте виртуальные функции в конструкторах и деструкторах. Если нужна инициализация, зависящая от типа, — используйте двухфазное создание, фабричные методы или статически вызывайте нужный метод.
Most vexing parse (самый раздражающий разбор) Любая конструкция вида T x(T()); синтаксически неоднозначна: компилятор обязан трактовать её как объявление функции x, которая принимает один аргумент типа «указатель на функцию, возвращающую T и не принимающую аргументов», а не как переменную. Это требование стандарта, а не каприз конкретного компилятора. Используйте инициализацию через фигурные скобки T x{U{}}; либо дополнительные круглые скобки T x((U()));. Идеально — вообще отказаться от синтаксиса T() для value-инициализации в пользу T{}.
Self-assignment (самоприсваивание) Наивный оператор присваивания сначала освобождает старый ресурс (delete[] data;), а затем копирует из аргумента. Если this == &other, то копировать уже не из чего — получаем use-after-free и/или копирование мусора. Аналогия: выбросить содержимое коробки и тут же попытаться пересыпать в неё то же самое содержимое. Добавьте проверку if (this != &other) { ... } либо используйте идиому copy-and-swap (скопировать во временный объект, затем swap(*this, tmp)) — она автоматически безопасна при самоприсваивании.
Dangling string_view (висячее представление) std::string_view хранит лишь указатель и длину, но не владеет символами. Если исходная std::string уже разрушена, view указывает в освобождённую память. Аналогия: записка-стикер с адресом друга, но самого друга выселили из квартиры. Всегда держите владеющую std::string живой дольше, чем все ссылающиеся на неё string_view. Не возвращайте string_view из функции, если он указывает на локальную строку. Для параметров функций string_view безопасен, пока вызывающий код контролирует время жизни аргумента.
Возврат ссылки на локальную переменную Локальная переменная создаётся на стеке функции и уничтожается при выходе из }. Ссылка или указатель на неё после возврата висят в воздухе. UB при любом обращении. Аналогия: вернуть номер чужого телефона, а потом выбросить телефон в реку. Возвращайте объект по значению (RVO/NRVO сделают это без копий), либо возвращайте владеющий указатель (std::unique_ptr<T>), либо возвращайте ссылку на данные, которыми кто-то действительно владеет (поле объекта, элемент статического контейнера).
Забыт override Если в наследнике вы опечатались в сигнатуре (void f() const вместо void f()), компилятор молча создаст новую виртуальную функцию вместо переопределения базовой. Базовая продолжает вызываться через полиморфизм. Всегда помечайте переопределяющие виртуальные методы ключевым словом override. Тогда компилятор проверит совпадение сигнатуры и выдаст ошибку при опечатке. Включите предупреждение -Wsuggest-override.
Скрытие имени (name hiding) Как только вы объявляете в наследнике метод с тем же именем, все одноимённые методы базы становятся невидимы без using. Даже перегрузки пропадают. Аналогия: в шкафу ребёнка лежит только его куртка, а папины куртки как будто исчезли. Добавьте using Base::method; в начале класса-наследника, чтобы все перегрузки базы стали доступны. Либо явно вызывайте через Base::method(...).
delete без [] (и наоборот) new T[N] создаёт массив, у которого компилятор хранит счётчик элементов (обычно перед блоком). delete без [] освобождает только первый элемент, остальные теряются, возможен UB и краш. delete[] для одиночного объекта читает мусор как счётчик. Либо строго соблюдайте парность new[] ↔ delete[] и new ↔ delete, либо (что гораздо лучше) вообще не используйте сырые указатели для массивов — берите std::vector<T>, std::array<T, N> или std::unique_ptr<T[]>.
Двойное удаление (double free) Два «владельца» независимо вызвали delete по одному и тому же сырому указателю. Куча повреждена, поведение непредсказуемо: от «всё работает» до краша через час в произвольном месте. Аналогия: двое выбросили один и тот же билет и оба получили штраф за безбилетный проезд. Один владелец — std::unique_ptr<T> (RAII, удаление в деструкторе). Остальные участники должны только наблюдать через сырой указатель или std::weak_ptr, но не удалять. Правило: у каждого ресурса ровно один владелец.
Цикл в shared_ptr Два объекта владеют друг другом через std::shared_ptr. Счётчик ссылок каждого никогда не падает до нуля — оба утекают навсегда. Аналогия: два чиновника держат друг друга на работе и никто не уходит. Замените одну из сильных ссылок на std::weak_ptr (наблюдатель). Типичное место — обратный указатель «родитель → ребёнок»: от родителя к ребёнку — shared_ptr, от ребёнка к родителю — weak_ptr.
std::move без пользы Если вы применяете std::move к const-объекту, компилятор не может вызвать move-конструктор (он принимает не-const rvalue). Происходит fallback на обычное копирование — вы теряете весь смысл перемещения. Никогда не объявляйте перемещаемые объекты const, если собираетесь их перемещать. В локальных переменных const для нетривиальных типов почти всегда вреден.
vector<bool> Стандартная специализация std::vector<bool> упаковывает биты в байты для экономии памяти. Из-за этого operator[] возвращает не bool&, а прокси-объект vector<bool>::reference, который неявно не конвертируется в bool&. Код bool& b = vec[i]; не компилируется, а vec[i] = true работает через магию перегрузок. Используйте std::deque<bool> (там элементы — нормальные bool), либо std::vector<char>/std::vector<uint8_t>, либо std::bitset<N> (если размер известен на этапе компиляции).
Итератор после изменения контейнера Любая вставка в std::vector или std::deque может инвалидировать все итераторы (при перевыделении буфера). У std::map/std::set инвалидируются только итераторы на удалённые элементы. Использование «висячего» итератора — UB. После вставки/удаления перевычисляйте итераторы заново. У вектора есть идиома erase-remove: it = vec.erase(it); — erase возвращает валидный итератор на следующий элемент. Для массовых удалений — std::erase_if (C++20).
Сравнение знакового и беззнакового Если в одном выражении участвуют int и size_t, по «обычным арифметическим преобразованиям» int приводится к беззнаковому. Отрицательное значение становится огромным положительным. Условие for (int i = 0; i < v.size() - 1; ++i) при пустом векторе — классический пример. Приводите типы явно: static_cast<std::ptrdiff_t>(v.size()) или static_cast<std::size_t>(i). Включайте предупреждения -Wsign-compare и -Wconversion. В современном коде предпочитайте знаковые типы для индексов.
Отсутствие noexcept у move std::vector при реаллокации может либо переместить, либо скопировать элементы — в зависимости от того, гарантирует ли move-конструктор отсутствие исключений. Без noexcept вектор перестраховывается и делает копии (потому что откатить перемещение при исключении нельзя, а копию — можно). Помечайте move-конструктор, move-присваивание и пользовательский swap как noexcept. Если move может бросить — лучше сделать его копирующим, чтобы компилятор не делал «тихое» копирование под видом перемещения.
Утечка ресурса при исключении Классический код T* p = new T; use(*p); throw std::runtime_error("oops"); — если use или throw между new и delete бросают, delete p не выполнится. Память утекает. Аналогия: открыли файл, не положили в контейнер — при падении программы файл никто не закроет. Применяйте RAII: сразу оборачивайте сырой ресурс в умный указатель (std::unique_ptr<T> p = std::make_unique<T>();) или храните в контейнере. Контейнер/умный указатель сам вызовет освобождение при раскрутке стека.
Наследование реализации вместо интерфейса Глубокая иерархия с public-наследованием ради повторного использования кода приводит к хрупкому дизайну, проблемам с ромбовидным наследованием и виртуальным базам. «is-a» превращается в «кто-то когда-то так сделал». Предпочитайте композицию («has-a»): возьмите нужный класс полем, делегируйте ему работу. Наследование используйте только для чистых интерфейсов (все методы — чисто виртуальные) и для случаев, когда наследник действительно «является» базой по LSP.
std::move из const-объекта Константный rvalue не может быть привязан к T&& в move-конструкторе (стандарт запрещает снимать const через ссылку). Поэтому компилятор выбирает копирующий конструктор — перемещение превращается в копию безо всякого предупреждения. Не помечайте объект, который собираетесь перемещать, как const. Если функция возвращает по значению const T, она запрещает move — снимите const.
Частичное правило пяти Вы объявили копирующий конструктор (например, чтобы корректно скопировать поле-указатель) — компилятор по правилам C++ тут же перестаёт генерировать move-конструктор и move-присваивание. Тип становится «только копируемым», std::vector не сможет его эффективно перемещать. Либо реализуйте все пять специальных методов (copy ctor, copy assign, move ctor, move assign, деструктор) сами, либо (лучше) применяйте правило нуля: вынесите владение ресурсом в поле типа std::unique_ptr/std::vector/другой RAII-объект, тогда компилятор сгенерирует все пять правильно автоматически.
map::operator[] на отсутствующем ключе m[key] не просто читает — он вставляет элемент со значением по умолчанию, если ключа не было. Для std::map<std::string, int> это создаст запись {"key", 0}. Побочный эффект: размер контейнера увеличивается, предыдущие find() начинают возвращать новый ключ. Для чтения используйте m.at(key) (бросает std::out_of_range) или m.find(key) (возвращает end() если нет). operator[] — только когда вам действительно нужно значение по умолчанию или нужно обновить запись.
Два shared_ptr из одного сырого указателя Два независимых std::shared_ptr<T> имеют два разных управляющих блока. Каждый думает, что он единственный владелец. При разрушении каждого счётчик доходит до нуля и delete вызывается дважды — классический double free. Аналогия: двое независимых бухгалтеров считают, что именно они платят зарплату одному сотруднику — задвоение. Используйте std::make_shared<T>(...) — он создаёт объект и управляющий блок одной операцией, вы точно получаете один владелец. Если нужно «передать» сырой указатель в shared_ptr, делайте это ровно один раз. Никогда не пишите shared_ptr<T> p1(raw); shared_ptr<T> p2(raw);.
Ловля исключения по значению catch (std::exception e) создаёт копию пойманного объекмента через срезку (slicing). Если в throw был наследник MyDerived, в e останется только базовая часть — методы наследника не вызвать. Плюс лишнее копирование. Ловите по константной ссылке: catch (const std::exception& e). Это не делает срезки, не копирует и не добавляет уровень косвенности. Если нужны поля наследника — ловите конкретный тип или используйте std::rethrow_exception.
Изменение контейнера внутри range-for for (auto& x : v) { v.push_back(x); } инвалидирует x (и сам итератор) при первой же вставке. UB. Используйте алгоритмы std::remove_if, std::erase_if (C++20) или собирайте индексы/итераторы в отдельный контейнер, удаляйте после прохода. Если нужно изменить элементы «на месте», меняйте их значения, а не сам контейнер.
Забытый explicit Конструктор с одним аргументом без explicit становится конвертирующим: std::string неявно конструируется из const char*, ваш Vector неявно из int и т.д. Это приводит к неожиданным неявным преобразованиям и выбору «не той» перегрузки. Помечайте все конструкторы с одним аргументом и операторы преобразования как explicit. Исключение — только прокси-типы вроде std::string_view, которые специально спроектированы для прозрачных преобразований. Включите -Wnon-explicit-constructor для самоконтроля.
Забытый break в switch После выполнения тела одного case управление «проваливается» в следующий case (fall-through). Это редко то, что вы хотите, — обычно приводит к багам вроде выполнения двух веток подряд. Ставьте break (или return) в конце каждой непустой ветки case. Если fall-through нужен осознанно — пишите [[fallthrough]]; (C++17), компилятор не выдаст предупреждение, читатель кода поймёт намерение. Включите -Wimplicit-fallthrough.
Возврат ссылки/указателя на локальную Локальная переменная живёт только до закрывающей } функции. Возвращать T& или T* на неё — UB: указатель остаётся валидным по адресу, но содержимое стека уже затёрто следующими вызовами. Аналогия: вернуть домашний адрес, а дом снести. Возвращайте объект по значению (компилятор применит NRVO и обойдётся без копий), либо возвращайте владеющий указатель (std::unique_ptr<T>), либо возвращайте ссылку на данные, которыми владеет переданный параметр, поле объекта или статическое хранилище.
float/double сравнивают на равенство Числа с плавающей точкой хранятся в двоичной системе и не могут точно представить 0.1, 0.2, 0.3. Поэтому 0.1 + 0.2 == 0.3 ложно (получается 0.30000000000000004). Любое накопление ошибок округления делает точное равенство бессмысленным. Сравнивайте с допуском (эпсилон): std::abs(a - b) < eps. Эпсилон подбирайте под масштаб чисел — для нормализованных значений подходит eps = 1e-9, для больших величин — относительная ошибка std::abs(a - b) < eps * std::max(std::abs(a), std::abs(b)).
Целочисленное деление там, где нужна дробь 5 / 2 == 2, потому что оба операнда — int. Остаток просто отбрасывается. 1 / 2 == 0, что особенно больно бьёт в вычислениях процентов, средних и индексов. Сделайте хотя бы один операнд вещественным: 5 / 2.0, static_cast<double>(a) / b, или используйте суффикс 5.0 / 2. Внимательно с int / int * double — умножение тоже даст дробь, но int / int уже отрежет. Помните о приоритете: деление и умножение выполняются раньше сложения.
Путаница = и == в условии if (x = 0) — это присваивание нуля в x, а потом проверка результата (который равен 0, то есть false). Ветка никогда не выполняется. Компилятор в C++ почти всегда разрешает такой код молча (ради совместимости с C). Пишите условие с «Yoda-стилем» if (0 == x) — тогда опечатка if (0 = x) не скомпилируется. Современный стиль — обычный порядок if (x == 0) плюс обязательное предупреждение -Wparentheses (GCC/Clang выдают suggest parentheses around assignment used as truth value).

Как не наступать на эти грабли. Экзаменаторы МИФИ, МГУ и МФТИ любят давать задачи, в которых ошибка прячется в одной из строк таблицы выше, и просят найти её «на лету». Поэтому держитесь трёх принципов.

  1. Включайте предупреждения и санитайзеры с самого начала. Минимальный набор флагов: -Wall -Wextra -Wpedantic -Werror (превращает предупреждения в ошибки — студенту это дисциплинирует) плюс -fsanitize=address,undefined (ловит use-after-free, выходы за границы массива, неинициализированные переменные, целочисленные переполнения). Эти инструменты ловят ~80% типовых ошибок до запуска, превращая молчаливый UB в громкое сообщение компилятора с указанием файла и строки. На экзамене санитайзеры недоступны — но привычка писать «чистый» код с ними поможет и без них.
  2. Соблюдайте одно правило владения ресурсом. У каждого сырого ресурса (память, файл, сокет, мьютекс) должен быть ровно один владелец. Владелец — это RAII-объект: std::unique_ptr<T>, std::vector<T>, std::ofstream, std::lock_guard<std::mutex>. Остальные участники получают либо сырые указатели (если точно знают, что владелец живёт дольше), либо std::weak_ptr/observer_ptr. Это правило одно уничтожает целый пласт ошибок: утечки, double free, циклы shared_ptr, гонки.
  3. Применяйте современный стиль C++. Правило нуля (класс не управляет ресурсами вручную, всё делегирует RAII-объектам в полях) автоматически делает корректными все пять специальных методов. Слово override на каждом переопределении виртуальной функции. explicit на каждом конструкторе с одним аргументом. noexcept на move-конструкторе и swap. Предпочитайте алгоритмы STL (std::find_if, std::erase_if) ручным циклам. Большинство пунктов таблицы выше исчезают сами, когда класс спроектирован по этим правилам, — а те, что остаются, становятся очевидны при первом же взгляде на код.

Ресурсы

Этот раздел — ваш «навигатор» по литературе и онлайн-источникам по C++. Он разделён по назначению так же, как вы разделили бы домашнюю библиотеку: учебники нужны, чтобы разобраться в теме с нуля или заполнить пробел; справочники — чтобы быстро уточнить конкретную деталь (сигнатуру функции, поведение оператора, требования стандарта); инструменты — чтобы проверить себя на практике и увидеть, что на самом деле делает компилятор. Подойдите к выбору ресурса прагматично: начинайте с курса своего вуза, потому что на экзамене МИФИ, МГУ или МФТИ формулировки и акценты будут совпадать именно с тем, что дают на лекциях; остальные источники подключайте по мере необходимости, когда нужно углубиться в конкретную тему.

Совет преподавателя: не пытайтесь прочитать всё подряд — это самый частый путь к выгоранию перед экзаменом. Лучше выбрать одну «главную» книгу (для МИФИ/МГУ это учебник Волковой и Карпова) и использовать остальные как справочники по непонятным местам.

Учебники и курсы.

  • Бьёрн Страуструп. «Язык программирования C++» (4-е изд.) — это полный справочник от автора языка; книга огромная и плотная, поэтому читайте её выборочно по темам (например, «RAII», «шаблоны», «исключения»), а не подряд от корки до корки. Хороша как «словарь», к которому возвращаются, когда в других источниках что-то непонятно.
  • Скотт Мейерс. «Эффективный современный C++» и «Эффективный C++» — это сборники из 55 + 40 конкретных советов по практике (use emplace_back, избегайте std::endl, предпочитайте nullptr и т. п.); это лучший источник по move-семантике (конструктор перемещения, std::move, rvalue-ссылки) и умным указателям (unique_ptr, shared_ptr, weak_ptr). Аналогия: если Страуструп — это правила дорожного движения, то Мейерс — это советы опытного водителя, как не попасть в аварию.
  • Т. А. Волкова, А. В. Карпов. «Основы объектно-ориентированного программирования. Язык C++» — это базовый учебник курса МГУ/МИФИ; его стиль и терминология ближе всего к формулировкам экзамена, поэтому при подготовке к зачёту/экзамену именно по нему удобнее всего сверять «правильные» определения (например, что такое полиморфизм в трактовке кафедры).
  • Т. А. Волкова, А. В. Вылиток, А. В. Карпов. «Сборник задач по языку C++» — это задачи к коллоквиумам и контрольным; обязательно прорешайте хотя бы часть, потому что на экзамене часто встречаются именно «классические» задачи из этого сборника (перегрузка операторов, шаблоны, наследование, контейнеры).
  • Энтони Уильямс. «Параллельное программирование на C++ в действии» — это подробное руководство по потокам и модели памяти C++; книга незаменима для темы std::atomic, std::thread, std::mutex и понимания того, почему гонка данных — это UB (undefined behavior).

Почему важно иметь несколько источников: один и тот же термин (например, «идиома RAII» или «аллокатор») в разных книгах раскрывается с разных сторон. Если одна формулировка кажется туманной — откройте ту же тему у другого автора, и картина сложится.

Справочники и инструменты.

  • cppreference.com — это точная справка по стандарту C++ с примерами кода; используйте его как первый источник при сомнениях в сигнатуре функции, наличии перегрузки, требованиях к исключениям или сложности контейнера. Практический совет: если преподаватель ссылается на «стандарт C++17» — фильтруйте сайт именно по этой версии.
  • learncpp.com — это пошаговый учебник с упражнениями; хорош для системного повторения с нуля, например, если вы пропустили первые лекции или хотите выровнять знания перед курсом по ООП. Аналогия: это как методичка «C++ за 20 уроков» с самопроверкой после каждой главы.
  • C++ Core Guidelines — это набор правил современного C++ от Страуструпа и Саттера; отвечает на вопрос «как писать правильно» (R.1: Manage resources automatically, ES.20: Always initialize an object, I.27: Prefer std::array or std::vector over C-style arrays). Эти гайдлайны часто цитируются преподавателями на старших курсах МФТИ.
  • Compiler Explorer — это онлайн-компилятор, в котором можно смотреть ассемблерный код, vtable (таблицу виртуальных функций) и работу оптимизаций; здесь же удобно проверять, вызывает ли компилятор move или copy (для этого пишите конструкторы с printf или std::cerr и смотрите, что выведется). Практический пример: напишите std::vector<std::string> v; v.push_back(std::string("hello")); против v.emplace_back("hello"); — и вы увидите, что во втором случае лишнего копирования нет.

Типичная ошибка студентов: пытаться выучить C++ только по видео на YouTube или по обрывкам статей в интернете. Для сдачи экзамена нужна система: один базовый учебник + один справочник по стандарту (cppreference) + практика на godbolt. Без любого из этих трёх элементов подготовка будет «дырявой».

Практическая подсказка по подготовке к экзамену МИФИ/МГУ/МФТИ:

  1. Прочитайте соответствующие главы у Волковой–Карпова (это ваш «фундамент» в терминах вуза).
  2. Если тема осталась мутной — откройте Страуструпа (концептуальный уровень) или Мейерса (практический уровень).
  3. Уточните сигнатуру и детали поведения в cppreference.
  4. Проверьте свою гипотезу экспериментально на godbolt.org — распечатайте результат, если нужно показать преподавателю, что «компилятор реально делает именно так».
  5. Решите хотя бы 3–5 задач из сборника Волковой–Вылиток–Карпова по каждой теме — это даст уверенность на коллоквиуме.

Что дальше

Вы освоили (или почти освоили) C++ — поздравляем! Чтобы систематизировать знания и уверенно выступить на экзамене в МИФИ, МГУ или МФТИ, полезно иметь под рукой набор связанных шпаргалок. Каждая из них закрывает свою задачу — выбирайте по ситуации и уровню подготовки.

  • C++: большая шпаргалка по языку от основ до умных указателей

    Это главная шпаргалка по C++: одна страница с полным обзором языка — от переменных, циклов и функций до RAII, move-семантики и умных указателей (unique_ptr, shared_ptr). Открывайте её, когда нужно за минуту вспомнить синтаксис конкретной конструкции или когда идёт комплексное повторение перед экзаменом. Аналогия из жизни: это «настольная энциклопедия» по C++ — держите вкладку открытой во время решения задач, чтобы быстро находить ответы вместо листания учебника.

  • C++ быстрый справочник конструкций

    Плотный справочник «всё на одной странице»: типы, операторы, функции, классы, шаблоны и concepts, память, контейнеры и алгоритмы STL, ranges, многопоточность — с примерами кода и краткими пояснениями. Когда открывать: на последнем повторении перед экзаменом, чтобы за час пробежать все конструкции и освежить синтаксис. Чем отличается от большой шпаргалки: здесь нет объяснений «почему так» — только конструкция и её смысл, поэтому страница читается в разы быстрее и работает как шпаргалка-выжимка. Совет: держите её открытой на втором экране, когда пишете пробные задачи — это ускоряет поиск забытого синтаксиса.

  • Си для студентов: шпаргалка к экзамену и контрольным

    Здесь собран минимально необходимый набор Си: указатели, массивы, строки, ручная работа с памятью — ровно то, что обычно спрашивают на контрольных и экзаменах. Удобно пролистать вечером перед контрольной, чтобы освежить в памяти базовые конструкции. Почему это важно для C++: даже если вы пишете на плюсах, понимание «голого» Си обязательно — на нём основана вся модель памяти и работа с указателями. Типичная ошибка студентов: пытаться сразу писать на C++, не разобравшись в Си — отсюда потом непонимание, почему delete[] отличается от delete.

  • Си (C): большая шпаргалка по синтаксису, указателям и памяти

    Расширенная версия предыдущей шпаргалки: подробный разбор синтаксиса, арифметики указателей, динамической памяти (malloc/free) и типичных ошибок вроде утечек памяти и segmentation fault. Используйте её, если тема указателей вызывает сложности — именно на ней «горят» большинство студентов на экзаменах. Аналогия: указатель — это номер ячейки на почтовом складе; арифметика указателей — это умение «шагать» по соседним ячейкам, прибавляя к номеру нужное смещение.

  • Rust: шпаргалка — владение, заимствование, типы, трейты

    Rust — современный язык от Mozilla, который решает главные проблемы C/C++ (висячие указатели, гонки данных, двойное освобождение памяти) на этапе компиляции через модель владения (ownership) и заимствования (borrowing). Шпаргалка пригодится для общего развития и чтобы увидеть, как можно писать системный код без new/delete и без сборщика мусора. Аналогия: если в C++ вы сами следите за временами жизни объектов и надеетесь, что ничего не забыли, то в Rust компилятор заставляет вас писать безопасный код — и отказывается собирать программу, пока вы этого не сделаете.

  • Go: шпаргалка — горутины, каналы, интерфейсы, модули

    Go — язык от Google, заточенный под серверные, сетевые и конкурентные задачи. В шпаргалке разобраны горутины (лёгкие «потоки», которые в разы дешевле системных), каналы (безопасный способ общения между горутинами по принципу «очереди сообщений»), интерфейсы и модули. Откройте её после C++, чтобы на контрасте прочувствовать разницу подходов: сложная машинерия шаблонов и RAII в плюсах против лаконичного и прямолинейного Go. Практическая подсказка: понимание альтернативных языков помогает глубже осознать, зачем в C++ нужны те или иные абстракции — вы начинаете видеть их как решение конкретных проблем, а не как «магию».


Связанные шпаргалки: C++, C++ быстрый справочник, Си для студентов, Си, Rust