Перейти к основному содержимому

РЕАЛЬНОЕ СОБЕСЕДОВАНИЕ С++ разработчик LestaGames (Tank Blitz) - Middle 200 - 250 тыс.

· 79 мин. чтения

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

Вопрос 1. Кратко рассказать о своем опыте и ключевых проектах за последние годы: стек, домен, задачи и зона ответственности.

Таймкод: 00:00:47

Ответ собеседника: правильный. Кратко описал около 3 лет опыта в телеком-сфере с C/C++ для десктопа и встраиваемых систем, ключевой проект с модулем взаимодействия радиостанции и ПК по Ethernet (TETRA), реализация софт-имитации консоли, голосовых вызовов и конференций, логирования метрик в БД и автоматизированного стенда тестирования, с сокращением времени проверки более чем в 3 раза, работал под руководством тимлида, отвечал за десктоп и часть встраиваемого софта.

Правильный ответ:

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

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

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

  1. Опыт и контекст:

    • За последние годы работал в области телекоммуникаций и встраиваемых систем.
    • Основной стек: C, C++, сетевое программирование (TCP/UDP, Ethernet), протоколы реального времени, взаимодействие с железом, многопоточность, оптимизация по памяти и latency.
    • Плотно взаимодействовал с командами тестирования, эксплуатации и системными инженерами, участвовал в полном цикле разработки: от требований и архитектурных решений до отладки на реальном железе и поддержки.
  2. Ключевой проект: взаимодействие абонентской радиостанции с ПК по Ethernet (TETRA):

    • Задача:
      • Обеспечить надёжный канал связи между радиостанцией и ПК через Ethernet, реализовать функционал, аналогичный аппаратной консоли, но в виде софта.
    • Что было сделано:
      • Реализован программный клиент на ПК, эмулирующий поведение аппаратной консоли:
        • установление и поддержание соединения;
        • обработка команд управления устройством;
        • передача и приём аудио (голосовые вызовы, конференции);
        • обработка служебных сообщений и сигнализации.
      • Реализовано логирование телеметрии и системных метрик:
        • сбор состояния устройства, статистики соединений, ошибок, перезагрузок;
        • запись в БД для анализа инцидентов, отладки и оптимизации.
    • Технические акценты:
      • Сетевое взаимодействие: реализация протоколов поверх TCP/UDP, контроль целостности, таймауты, реконнекты, защита от зависаний/ресурсных утечек.
      • Многопоточность и конкурентный доступ:
        • разделение потоков ввода-вывода, обработки сообщений, UI/логики;
        • аккуратная синхронизация, избежание гонок и дедлоков.
      • Перформанс и надёжность:
        • минимизация задержек при передаче аудио;
        • устойчивость при обрывах сети и ошибках железа;
        • детализированное логирование для диагностики.
      • Структура кода:
        • модульность, выделение слоёв: транспорт, протокол, доменная логика, UI/интерфейс;
        • внимание к читаемости и расширяемости.
  3. Автоматизированный стенд тестирования оборудования:

    • Цель:
      • Уменьшить время регресса и ручного тестирования оборудования, повысить воспроизводимость проверок.
    • Вклад:
      • Разработка ПО, которое:
        • управляет реальным оборудованием и симуляторами;
        • запускает набор сценариев (подключения, разрывы, пиковая нагрузка, стресс-тесты);
        • собирает результаты, статусы, логи;
        • формирует отчёты.
      • Достижения:
        • сокращение времени тестирования более чем в 3 раза;
        • снижение количества человеческих ошибок;
        • возможность быстро воспроизвести пограничные сценарии.
    • Технические аспекты:
      • Сценарный движок, интеграция с устройствами по протоколам управления;
      • логирование в БД (например, PostgreSQL/MySQL/SQLite) для последующего анализа.
  4. Роль и ответственность:

    • Самостоятельно вел ключевые части функционала:
      • десктопное приложение и его архитектуру;
      • часть логики на встраиваемых устройствах.
    • Работал в связке с тимлидом/архитектором:
      • участвовал в обсуждении архитектурных решений;
      • предлагал улучшения по перформансу и удобству эксплуатации;
      • участвовал в code review, писал техническую документацию.
    • Фокус на качестве:
      • юнит- и интеграционные тесты;
      • анализ логов и метрик;
      • профилирование и устранение узких мест.
  5. Связка с backend/Go-разработкой (важно для текущей вакансии):

    • Опыт сетевого и системного программирования, работы с протоколами, многопоточностью, диагностикой и логированием напрямую переносится в:
      • разработку высоконагруженных и отказоустойчивых сервисов;
      • проектирование API и протоколов;
      • оптимизацию по ресурсам и latency;
      • построение надежных средств мониторинга и тестовой инфраструктуры.

Если хочешь краткий формат для интервью:

  • Домен: телеком, встраиваемые системы, real-time взаимодействие.
  • Задачи: сетевое взаимодействие (Ethernet, собственные протоколы), голосовая связь, логирование и мониторинг, автоматизация тестирования.
  • Вклад: разработка ключевых модулей, улучшение тестового контура (3x ускорение), работа с перформансом и надежностью.
  • Релевантность: сильная база для разработки сервисов, работы с сетевыми протоколами, высоконадежных систем и инфраструктуры на Go.

Вопрос 2. Что такое виртуальная функция в C++ и зачем она нужна?

Таймкод: 00:06:45

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

Правильный ответ:

Виртуальная функция в C++ — это метод базового класса, который объявлен с ключевым словом virtual и предназначен для переопределения в производных классах. Ее основная цель — обеспечить динамический полиморфизм, то есть выбор конкретной реализации метода во время выполнения (runtime), исходя из реального типа объекта, а не типа указателя/ссылки.

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

  1. Зачем нужны виртуальные функции
  • Без виртуальных функций:
    • При вызове метода через указатель/ссылку на базовый класс используется статическое (раннее) связывание.
    • Вызывается метод, определенный в типе указателя (базовый класс), даже если фактически объект является экземпляром производного класса.
  • С виртуальными функциями:
    • Включается динамическое (позднее) связывание: вызывается реализация метода того класса, объект которого реально находится в памяти.
    • Это и есть основа полиморфного поведения: один интерфейс — разные реализации.
  1. Базовый пример

Без виртуальной функции:

#include <iostream>
using namespace std;

class Base {
public:
void foo() { cout << "Base::foo\n"; }
};

class Derived : public Base {
public:
void foo() { cout << "Derived::foo\n"; }
};

int main() {
Base* b = new Derived();
b->foo(); // Выведет: Base::foo (статическое связывание)
delete b;
}

С виртуальной функцией:

#include <iostream>
using namespace std;

class Base {
public:
virtual void foo() { cout << "Base::foo\n"; }
};

class Derived : public Base {
public:
void foo() override { cout << "Derived::foo\n"; }
};

int main() {
Base* b = new Derived();
b->foo(); // Выведет: Derived::foo (динамический полиморфизм)
delete b;
}
  1. Как это работает (глубже, с точки зрения реализации)

Компилятор обычно реализует виртуальные функции через:

  • vtable (virtual table):
    • Для каждого полиморфного класса создается таблица указателей на виртуальные функции.
  • vptr:
    • В каждом объекте полиморфного класса (т.е. класса с хотя бы одной виртуальной функцией) хранится скрытый указатель на vtable.
  • При вызове виртуальной функции:
    • Компилятор генерирует код, который:
      • читает vptr объекта,
      • находит нужную функцию в vtable,
      • вызывает ее.
  • Это позволяет в runtime выбрать правильную реализацию на основе реального типа объекта.

Важно:

  • Механизм — реализация-компиляторная деталь, но понимание концепции vtable/vptr полезно для:
    • отладки;
    • оценки стоимости виртуальных вызовов;
    • понимания ограничений при использовании полиморфизма.
  1. Ключевые свойства и нюансы
  • Объявление:
    • Виртуальной функция становится при объявлении в базовом классе с virtual.
    • В производных классах при переопределении достаточно override (рекомендуется), virtual можно не повторять.
  • Переопределение:
    • Сигнатура должна совпадать (учет const, ref-qualifiers и т.д.).
    • Ключевое слово override:
      • не делает функцию виртуальной,
      • заставляет компилятор проверить, что она действительно переопределяет виртуальную функцию предка.
  • Виртуальный деструктор:
    • Если класс предполагает использование через указатель на базовый тип, деструктор базового класса должен быть виртуальным:
      class Base {
      public:
      virtual ~Base() = default;
      };

      class Derived : public Base {
      public:
      ~Derived() {
      // очистка ресурсов
      }
      };

      int main() {
      Base* p = new Derived();
      delete p; // Корректно вызовется ~Derived, потом ~Base
      }
    • Без виртуального деструктора — утечки/UB при удалении через базовый указатель.
  • Чисто виртуальные функции:
    • Объявляются как virtual void foo() = 0;
    • Делают класс абстрактным (нельзя создать объект такого класса).
    • Используются для объявления интерфейсов.
  • Стоимость:
    • Виртуальный вызов чуть дороже прямого — косвенный вызов через таблицу.
    • Есть накладные расходы на хранение vptr и vtable.
    • В реальных системах обычно это приемлемая цена за гибкость.
  1. Что виртуальная функция НЕ означает
  • Не значит "функция, реализованная вне класса". Вынесение реализации за пределы определения класса — синтаксический прием, не связанный с виртуальностью.
  • Не про перегрузку (overload), а про переопределение (override).
  • Не заменяет продуманную архитектуру: виртуальные функции — один из инструментов для организации полиморфных интерфейсов.
  1. Связь с практикой (важно для сильного кандидата)
  • Виртуальные функции используются для:

    • проектирования расширяемых модулей (плагины, адаптеры, драйверы);
    • реализации интерфейсных слоев и абстракций над различными источниками данных / протоколами;
    • unit-тестирования (моки и стабы через наследование);
    • реализаций протоколов/обработчиков событий, когда поведение зависит от конкретного типа.
  • Важно:

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

Такое объяснение показывает не только знание термина, но и понимание механизма, практических trade-off’ов и архитектурного контекста.

Вопрос 3. Что такое перегрузка функции в C++ и как она работает?

Таймкод: 00:07:26

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

Правильный ответ:

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

Важно: перегрузка — это механизм статического полиморфизма (compile-time), в отличие от виртуальных функций, которые обеспечивают динамический полиморфизм (runtime).

Основные правила и детали:

  1. Что считается различием сигнатур

Функции можно перегружать, если отличаются:

  • количество параметров;
  • типы параметров;
  • порядок параметров разных типов.

Нельзя перегружать только по:

  • возвращаемому типу (он не участвует в разрешении перегрузки);
  • именам параметров;
  • модификаторам typedef/using, которые разворачиваются в одинаковый тип.

Примеры допустимых перегрузок:

void print(int x);
void print(double x);
void print(const std::string& s);
void print(int x, int base);
  1. Как компилятор выбирает перегрузку (overload resolution)

Алгоритм (упрощенно):

  • Собираются все функции с подходящим именем в данной области видимости.
  • Отфильтровываются те, у которых количество/типы параметров не совместимы с фактическими аргументами.
  • Для оставшихся оценивается "качество" преобразований типов:
    • точное совпадение типов;
    • promotions (int -> long, float -> double и т.п.);
    • стандартные преобразования;
    • пользовательские преобразования.
  • Выбирается наилучшее совпадение.
  • Если есть несколько одинаково "хороших" вариантов — ошибка неоднозначности.

Пример:

void foo(int x);
void foo(double x);

foo(10); // вызывает foo(int)
foo(3.14); // вызывает foo(double)
  1. Роль константности, ссылок и членов класса

Перегрузка учитывает также:

  • const у параметров-ссылок;
  • квалификаторы методов класса (const, &, && для *this).

Пример перегрузки методов:

struct S {
void set(int x);
void set(double x);
void set(const std::string& s);
};

struct Vec {
int x;

int get() const { return x; } // для const Vec&
int get() { return x; } // для неконстантных объектов
};

Здесь get() перегружен по квалификатору const, и вызов зависит от того, константный ли объект.

  1. Перегрузка и параметры по умолчанию

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

Плохой пример:

void log(const std::string& msg, int level = 1);
void log(const std::string& msg);

log("test"); // неоднозначность: какая функция?

Лучше избегать комбинирования перегрузки и параметров по умолчанию в конфигурациях, ведущих к конфликтам.

  1. Чем перегрузка отличается от:
  • Переопределения (override):
    • Переопределение — в иерархии наследования, сигнатура совпадает, ключевое слово override (и виртуальные функции).
    • Перегрузка — в одной области видимости / в классе, но разные сигнатуры.
  • Шаблонов:
    • Шаблонная функция может быть частью набора перегрузок.
    • Компилятор подбирает специализацию шаблона и сравнивает ее с обычными функциями при разрешении перегрузки.

Пример смешанной перегрузки:

void process(int x);
template<typename T>
void process(T x);

process(10); // предпочтительно выберет void process(int)
process(3.14); // выберет шаблонную process<double>
  1. Практические рекомендации
  • Перегрузка полезна, когда:
    • Операция концептуально одна и та же, но работает с разными типами (например print, add, parse).
    • Нужно улучшить читаемость API: одно имя — единое понятное действие.
  • Но:
    • Не стоит перегружать функции так, чтобы выбор становился неочевидным.
    • Следи за тем, чтобы сигнатуры были достаточно различимыми, избегай скрытых преобразований, создающих неоднозначность.

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

Вопрос 4. Что такое переопределение виртуальной функции в C++ и как оно используется в наследуемых классах?

Таймкод: 00:08:07

Ответ собеседника: неполный. Смешал переопределение с затенением (shadowing) функции в локальном файле, затем после подсказки описал идею переопределения в наследнике, но без акцента на роль virtual, точное совпадение сигнатур и использование override.

Правильный ответ:

Переопределение виртуальной функции (override) в C++ — это механизм, при котором производный класс предоставляет собственную реализацию функции, объявленной как виртуальная в базовом классе. Цель — обеспечить корректное полиморфное поведение при работе через указатели/ссылки на базовый тип: вызывается реализация, соответствующая реальному типу объекта в runtime.

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

  1. Условия корректного переопределения

Чтобы функция в наследуемом классе действительно переопределяла виртуальную функцию базового класса, должны выполняться условия:

  • В базовом классе метод объявлен как virtual.
  • В производном классе метод имеет:
    • ту же сигнатуру (тип возвращаемого значения, набор и типы параметров, const-квалификаторы, ref-qualifiers и т.д.);
    • совместимый тип возвращаемого значения (covariant return type допускается: можно вернуть указатель/ссылку на более специфичный тип).
  • Рекомендуется использовать ключевое слово override в производном классе:
    • оно не делает функцию виртуальной само по себе;
    • оно заставляет компилятор проверить, что функция действительно переопределяет виртуальную функцию предка (если нет — будет ошибка компиляции).
  • Если сигнатура не совпадает, то это не переопределение, а перегрузка или затенение (shadowing), и полиморфизма не будет.

Пример корректного переопределения:

#include <iostream>

class Base {
public:
virtual void Print() {
std::cout << "Base::Print\n";
}
};

class Derived : public Base {
public:
void Print() override { // Переопределение виртуальной функции
std::cout << "Derived::Print\n";
}
};

int main() {
Base* b = new Derived();
b->Print(); // Вызывает Derived::Print (динамический полиморфизм)
delete b;
}

Здесь:

  • Base::Print — виртуальная функция.
  • Derived::Print с override — корректное переопределение.
  • Вызов через Base* выбирает реализацию по фактическому типу объекта (Derived) во время выполнения.
  1. Динамический полиморфизм и vtable

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

  • У полиморфного класса есть vtable (таблица виртуальных функций).
  • У каждого объекта полиморфного класса есть скрытый указатель vptr на vtable.
  • При переопределении:
    • соответствующая запись в vtable производного класса указывает на новую реализацию.
  • При вызове b->Print():
    • по vptr объекта выбирается нужная функция.
    • Именно поэтому используется реальный тип объекта, а не тип указателя.

Это позволяет:

  • описать поведение через интерфейс базового класса;
  • подменять реализации без изменения кода, который работает с базовым типом.
  1. Отличие от перегрузки и затенения

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

  • Переопределение (override):
    • Одинаковое имя + совместимая сигнатура + virtual в базовом классе.
    • Позднее связывание, полиморфизм.
  • Перегрузка (overload):
    • Одинаковое имя, но разные параметры (тип/количество).
    • Работает внутри одной области видимости.
    • Выбор функции — на этапе компиляции.
  • Затенение (shadowing):
    • В производном классе объявляется метод с тем же именем, но другой сигнатурой или без связи с виртуальностью.
    • Скрывает методы базового класса с тем же именем при обращении по имени в контексте производного.
    • Может случайно "сломать" ожидаемый полиморфизм.

Пример затенения вместо переопределения:

class Base {
public:
virtual void Print(int x) {
std::cout << "Base: " << x << "\n";
}
};

class Derived : public Base {
public:
void Print(double x) { // НЕ переопределение, а новая функция
std::cout << "Derived: " << x << "\n";
}
};

int main() {
Base* b = new Derived();
b->Print(10); // Вызовет Base::Print(int), а не Derived::Print(double)
delete b;
}

Использование override помогает отлавливать такие ошибки:

class Derived : public Base {
public:
void Print(double x) override; // Ошибка компиляции: нет подходящей виртуальной функции в Base
};
  1. Виртуальные деструкторы и их переопределение

Класс, который предполагается использовать полиморфно (через указатель/ссылку на базу), должен иметь виртуальный деструктор:

class Base {
public:
virtual ~Base() = default;
};

class Derived : public Base {
public:
~Derived() override {
// Освобождение ресурсов
}
};
  • Это тоже пример переопределения виртуальной функции.
  • При delete basePtr; вызовется деструктор Derived, затем Base.
  • Без виртуального деструктора возможны утечки и неопределенное поведение.
  1. Чисто виртуальные функции и абстрактные классы

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

class Shape {
public:
virtual void Draw() const = 0; // Чисто виртуальная
};

class Circle : public Shape {
public:
void Draw() const override {
// Реализация рисования круга
}
};
  • Базовый класс задает контракт (интерфейс).
  • Производные классы обязаны переопределить эти функции.
  • Работа ведется через указатель/ссылку на базовый тип Shape*, а реализация выбирается по реальному типу.
  1. Практический смысл

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

  • Есть общий интерфейс (базовый тип), но разные реализации:
    • обработчики протоколов,
    • различные реализации хранилищ,
    • драйверы устройств,
    • стратегии поведения.
  • Хотим писать код, не завязанный на конкретные реализации:
    • принимать и возвращать указатели/ссылки на базовый тип;
    • подсовывать разные реализации в рантайме (dependency injection на уровне С++).

Грамотный ответ на этот вопрос показывает понимание:

  • роли virtual и override;
  • различий между override / overload / shadowing;
  • связи с полиморфизмом, vtable и безопасным дизайном интерфейсов.

Вопрос 5. В чем разница между перегрузкой функций и виртуальными функциями (статический vs динамический полиморфизм) в C++?

Таймкод: 00:09:38

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

Правильный ответ:

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

Перегрузка функций (overload) — статический полиморфизм:

  • Суть:
    • Несколько функций с одним именем и разными сигнатурами (типы/число параметров).
    • Конкретная функция выбирается на этапе компиляции.
  • Характеристики:
    • Решение принимается по типам аргументов, известных компилятору в момент компиляции.
    • Не зависит от реального динамического типа объекта.
    • Не требует виртуальных функций, vtable и т.п.
  • Пример:
void print(int x)        { std::cout << "int: " << x << "\n"; }
void print(double x) { std::cout << "double: " << x << "\n"; }
void print(std::string s){ std::cout << "string: " << s << "\n"; }

int main() {
print(42); // выбор функции по типу аргумента -> print(int)
print(3.14); // -> print(double)
print("hello"s); // -> print(std::string)
}
  • Здесь:
    • Компилятор однозначно выбирает нужную функцию.
    • Вызов не зависит от каких-либо таблиц или runtime-информации.
    • Стоимость вызова — как у обычной функции.

Виртуальные функции — динамический полиморфизм:

  • Суть:
    • Виртуальная функция объявляется в базовом классе (virtual).
    • Производные классы могут ее переопределить.
    • Конкретная реализация выбирается во время выполнения по реальному типу объекта.
  • Использование:
    • Работает при вызове через указатель/ссылку на базовый класс.
  • Пример:
class Shape {
public:
virtual void Draw() const {
std::cout << "Shape\n";
}
};

class Circle : public Shape {
public:
void Draw() const override {
std::cout << "Circle\n";
}
};

void Render(const Shape& s) {
s.Draw(); // динамический выбор реализации
}

int main() {
Circle c;
Shape& s = c;
Render(s); // вызовет Circle::Draw
}
  • Как это работает:
    • У полиморфных объектов есть скрытый указатель vptr.
    • vptr указывает на таблицу виртуальных функций (vtable).
    • При вызове s.Draw() компилятор генерирует:
      • чтение vptr,
      • выбор функции из vtable,
      • непрямой вызов.
  • Признаки:
    • Решение о том, какую функцию вызвать, принимается в runtime.
    • Связано с наследованием и иерархиями типов.
    • Даёт гибкость, но имеет накладные расходы и ограничения.

Ключевые отличия (по сути):

  1. Момент разрешения вызова:

    • Перегрузка:
      • Выбор реализации на этапе компиляции.
      • Зависит только от статических типов аргументов.
    • Виртуальные функции:
      • Выбор реализации в runtime.
      • Зависит от реального типа объекта (динамический тип).
  2. Основа механизма:

    • Перегрузка:
      • Чисто синтаксический/типовой механизм.
      • Не требует наследования.
    • Виртуальные:
      • Требуют наследования и объявления virtual.
      • Используют vtable/vptr (в типичных реализациях компилятора).
  3. Назначение:

    • Перегрузка:
      • Один концепт — разные варианты для разных типов/сигнатур.
      • Улучшение удобства API.
      • Пример: несколько версий Print, Parse, Add.
    • Виртуальные:
      • Контракт/интерфейс через базовый класс.
      • Возможность подменять реализацию в зависимости от типа объекта.
      • Пример: разные реализации Storage, Transport, Handler.
  4. Взаимоотношения с наследованием:

    • Перегрузка:
      • Может существовать вообще без наследования.
    • Виртуальные функции:
      • Смысл есть только в контексте наследования (полиморфный базовый класс).
  5. Влияние на производительность:

    • Перегрузка:
      • Нет дополнительной стоимости; все решения приняты на этапе компиляции.
    • Виртуальные:
      • Небольшая, но реальная цена: косвенный вызов, vptr в объекте, ограничения для некоторых оптимизаций.
      • В современных системах это обычно оправдано читаемостью и гибкостью.

Типичные ошибки (которые важно уметь не допускать):

  • Путать перегрузку и переопределение:
    • Разные сигнатуры — это перегрузка, даже в наследнике.
    • Для переопределения нужна виртуальность и совпадающая сигнатура.
  • Ожидать динамический полиморфизм от перегрузки:
    • Вызов выбирается компилятором, а не по runtime-типу.
  • Не использовать override:
    • Усложняет поиск ошибок (вместо override получается shadowing/overload).

Краткая формула для собеседования:

  • Перегрузка: один идентификатор, разные сигнатуры, выбор в compile-time.
  • Виртуальные функции: один интерфейс, разные реализации в иерархии, выбор в runtime по vtable.

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

Таймкод: 00:11:34

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

Правильный ответ:

Для этого вопроса важен не только факт "compile-time vs runtime", но и понимание, как это связано с моделью типов, архитектурой и практическим применением.

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

  • Характеристики:
    • Решение о том, какую функцию вызывать, принимается компилятором.
    • Основан на статических типах аргументов.
    • Не требует наследования и виртуальных функций.
  • Пример с перегрузкой:
void Handle(int)        { std::cout << "int\n"; }
void Handle(double) { std::cout << "double\n"; }
void Handle(std::string){ std::cout << "string\n"; }

int main() {
Handle(1); // выбор во время компиляции -> Handle(int)
Handle(1.0); // -> Handle(double)
}
  • Пример со статическим полиморфизмом через шаблоны (часто используется вместо виртуальных функций, когда тип известен на этапе компиляции):
template <typename T>
void Process(T& obj) {
obj.Do(); // вызывается Do() конкретного типа T
}
  • Важные последствия:
    • Нет vtable, нет runtime-диспатча.
    • Компилятор может инлайнить вызовы и агрессивно оптимизировать код.
    • Хорошо подходит для высокопроизводительных и generic-решений.

Динамический полиморфизм (виртуальные функции):

  • Характеристики:
    • Решение о том, какую реализацию вызвать, принимается в runtime.
    • Основан на динамическом типе объекта при использовании указателя/ссылки на базовый класс.
    • Требует объявления virtual в базовом классе и переопределения в наследниках.
  • Пример:
class Transport {
public:
virtual void Send(const std::string& msg) = 0;
virtual ~Transport() = default;
};

class TcpTransport : public Transport {
public:
void Send(const std::string& msg) override {
std::cout << "TCP: " << msg << "\n";
}
};

class UdpTransport : public Transport {
public:
void Send(const std::string& msg) override {
std::cout << "UDP: " << msg << "\n";
}
};

void SendMessage(Transport& t, const std::string& msg) {
t.Send(msg); // выбор реализации в runtime
}
  • Важные последствия:
    • Гибкость: конкретная реализация может подменяться во время выполнения.
    • Основа плагиноподобных систем, драйверов, абстракций над внешними системами.
    • Небольшая цена: косвенный вызов через vtable, чуть меньшая прозрачность для оптимизатора.

Кратко, без повторений:

  • Статический полиморфизм:

    • compile-time диспатч;
    • основан на типах, видимых компилятору;
    • дешевле по рантайму, но менее гибок для подмены поведения после компиляции.
  • Динамический полиморфизм:

    • runtime-диспатч через виртуальные функции и vtable;
    • основан на реальном типе объекта;
    • удобен для расширяемых архитектур и инверсии управления.

Если отвечать на интервью емко:

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

Вопрос 7. Какие типы наследования классов в C++ (public, protected, private) существуют и как они влияют на доступность членов базового класса?

Таймкод: 00:12:11

Ответ собеседника: правильный. Перечислил public/protected/private наследование и корректно описал, как каждый тип влияет на уровни доступа унаследованных членов.

Правильный ответ:

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

  • уровень доступа самих членов внутри производного класса;
  • то, как объект производного класса видится «снаружи» по отношению к базовому типу (является ли это «is-a» отношением).
  1. Базовые уровни доступа в классе

В самом базовом классе:

  • public — доступен везде, где виден объект/тип;
  • protected — доступен внутри этого класса и его потомков;
  • private — доступен только внутри самого класса (не наследуется по доступу).
  1. Типы наследования

Тип наследования влияет на то, КАК унаследованные public и protected члены базового класса будут видны внутри производного класса.

  • Public-наследование:
class Base {
public:
int pub;
protected:
int prot;
private:
int priv;
};

class Derived : public Base {
// Base::pub -> public в Derived
// Base::prot -> protected в Derived
// Base::priv -> недоступен в Derived напрямую
};

Семантика:

  • Сохраняет «is-a» отношение: Derived — это Base.

  • Внешний код может использовать указатель/ссылку на Base для объекта Derived.

  • Это основной и почти всегда правильный выбор для полиморфизма и API.

  • Protected-наследование:

class Derived : protected Base {
// Base::pub -> protected в Derived
// Base::prot -> protected в Derived
// Base::priv -> недоступен
};

Семантика:

  • Для внешнего кода Derived больше не «является» Base:

    • нельзя неявно преобразовать Derived* к Base* вне иерархии.
  • Базовый интерфейс скрыт от пользователей, но доступен внутри Derived и его потомков.

  • Используется редко и осознанно:

    • когда базовый класс — это деталь реализации, а не внешний контракт.
  • Private-наследование:

class Derived : private Base {
// Base::pub -> private в Derived
// Base::prot -> private в Derived
// Base::priv -> недоступен
};

Семантика:

  • Полная инкапсуляция: Base становится деталью реализации Derived.
  • Не поддерживает «is-a» снаружи:
    • Derived* нельзя неявно преобразовать к Base* вне класса Derived.
  • Больше похоже на композицию, чем на «настоящее» наследование по контракту:
    • но с возможностью переиспользовать protected-функциональность.
  • Применяется:
    • когда нужно использовать функциональность базового класса, не раскрывая этот факт наружу;
    • когда по смыслу «является реализовано через» (implemented-in-terms-of), а не строго «is-a».
  1. Важные нюансы
  • Private-члены базового:
    • Никогда не меняют уровень доступа в наследнике.
    • Непосредственно не доступны в производном классе (только через public/protected интерфейс базового).
  • Доступ к базовому типу снаружи:
    • При public-наследовании:
      • допустимо неявное приведение Derived*Base*;
      • это основа для полиморфизма.
    • При protected/private-наследовании:
      • такие преобразования запрещены снаружи;
      • это ломает «is-a» отношение на уровне интерфейса.
  • Для структур (struct) по умолчанию:
    • наследование без модификатора считается public;
    • для class — по умолчанию private.
  • Для полиморфизма и интерфейсов:
    • Используем почти всегда public наследование.
    • protected и private — инструмент инкапсуляции и реализации, а не полиморфных интерфейсов.
  1. Краткая формулировка для собеседования
  • Public: «is-a», сохраняет уровни доступа (pub → pub, prot → prot), основа полиморфизма.
  • Protected: скрывает базовый интерфейс от внешнего кода (pub/prot → prot), доступен только в иерархии.
  • Private: делает базу внутренней деталью (pub/prot → private), нет внешнего «is-a», похоже на композицию через наследование.

Вопрос 8. Что происходит с доступностью методов базового класса при вызове из производного класса для разных типов наследования?

Таймкод: 00:13:17

Ответ собеседника: правильный. На примере метода базового класса корректно описал поведение: при public-наследовании публичный метод остаётся публичным, при protected — публичные и защищённые становятся защищёнными, при private — публичные и защищённые становятся приватными.

Правильный ответ:

В этом вопросе важно четко разделить:

  • как унаследованные члены видны ВНУТРИ производного класса;
  • как доступ к базовому интерфейсу выглядит СНАРУЖИ (включая возможность использовать объект потомка как объект базового класса).

Рассмотрим базовый класс:

class Base {
public:
void pub() {}

protected:
void prot() {}

private:
void priv() {}
};
  1. Public-наследование
class DerivedPublic : public Base {
public:
void test() {
pub(); // доступен, как public-член Base
prot(); // доступен, как protected-член Base
// priv(); // недоступен (private Base)
}
};
  • Внутри DerivedPublic:
    • pub() — доступен (уровень: public);
    • prot() — доступен (уровень: protected);
    • priv() — недоступен.
  • Снаружи:
    • Объект DerivedPublic можно использовать как Base:
      DerivedPublic d;
      d.pub(); // OK
      Base* b = &d; // OK (is-a)
    • Это нормальное «is-a» наследование, базовый интерфейс открыт.
  1. Protected-наследование
class DerivedProtected : protected Base {
public:
void test() {
pub(); // доступен, но в DerivedProtected он уже protected
prot(); // доступен, protected
// priv(); // недоступен
}
};
  • Внутри DerivedProtected:
    • pub() и prot() доступны, но уже как защищённые члены.
  • Снаружи:
    • Нельзя вызвать pub() через объект DerivedProtected:
      DerivedProtected d;
      // d.pub(); // Ошибка: pub теперь protected в контексте DerivedProtected
      // Base* b = &d; // Ошибка: нет public-наследования
    • Базовый интерфейс скрыт от внешнего кода, доступен только внутри иерархии.
  1. Private-наследование
class DerivedPrivate : private Base {
public:
void test() {
pub(); // доступен, но как private в DerivedPrivate
prot(); // доступен, как private
// priv(); // недоступен
}
};
  • Внутри DerivedPrivate:
    • pub() и prot() доступны, но становятся приватными членами реализации.
  • Снаружи:
    • Ни pub(), ни prot() нельзя вызвать:
      DerivedPrivate d;
      // d.pub(); // Ошибка: pub приватен в DerivedPrivate
      // Base* b = &d; // Ошибка: наследование не public
    • Base полностью инкапсулирован как деталь реализации.
  1. Ключевые выводы
  • Private-члены базового класса:
    • Никогда не становятся доступны напрямую в потомках, вне зависимости от типа наследования.
  • Тип наследования управляет не тем, что можно вызвать внутри производного (там public/protected базового доступны при public/protected/private наследовании), а тем, в каком виде унаследованные члены представлены как часть интерфейса производного класса:
    • public-наследование:
      • public → public
      • protected → protected
    • protected-наследование:
      • public → protected
      • protected → protected
    • private-наследование:
      • public → private
      • protected → private
  • Для полиморфизма, интерфейсов и нормального «is-a» используется public-наследование.
  • Protected/private-наследование — инструмент скрыть базовый интерфейс и использовать базовый класс как реализацию, а не как внешний контракт.

Для данного уточняющего вопроса этого уровня детализации достаточно, опираясь на уже ранее разобранные принципы.

Вопрос 9. Что такое статический метод класса в C++ и в чем его особенности?

Таймкод: 00:14:47

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

Правильный ответ:

Статический метод класса в C++ — это метод, объявленный с ключевым словом static внутри класса, который:

  • не привязан к конкретному объекту;
  • не имеет неявного указателя this;
  • может вызываться по имени класса, без создания экземпляра;
  • имеет доступ только к:
    • статическим полям и методам класса,
    • глобальным/свободным функциям и данным,
    • параметрам и локальным переменным самого метода.

По сути, это функция, логически принадлежащая классу (его интерфейсу/абстракции), но не работающая с конкретным состоянием объекта.

Пример базового объявления и использования:

class Counter {
public:
static int total; // Статическое поле: общее для всех экземпляров

Counter() {
++total;
}

static int GetTotal() {
// нет this, можно использовать только статические члены
return total;
}
};

// Определение статического поля
int Counter::total = 0;

int main() {
Counter c1;
Counter c2;
int n = Counter::GetTotal(); // 2, вызов без объекта
}

Ключевые особенности:

  1. Отсутствие this
  • В статическом методе нет указателя this.
  • Следствия:
    • Нельзя напрямую обращаться к нестатическим (обычным) полям/методам:
      class A {
      int x;
      public:
      static void foo() {
      // x = 10; // Ошибка: нет this, нет доступа к нестатическому члену
      // bar(); // Ошибка: bar() нестатический
      }

      void bar() {}
      };
    • Чтобы работать с нестатическими членами, статическому методу нужно явно получить объект:
      static void SetX(A& obj, int value) {
      obj.x = value; // доступ через явный объект
      }
  1. Общие для всех экземпляров
  • Статические методы часто работают со статическими полями:
    • счётчики объектов;
    • кэш, конфигурация, shared ресурсы;
    • фабричные методы (factory methods).
  • Статическое поле существует в одном экземпляре на класс, а не на объект.
  • Статический метод — естественная точка доступа к этому общему состоянию.
  1. Вызов без создания объекта
  • Идиоматичный вызов:
    ClassName::StaticMethod();
  • Можно вызывать и через объект, но это считается плохим стилем, так как вводит в заблуждение:
    A a;
    a.foo(); // так можно, но лучше A::foo();
  1. Связь с инкапсуляцией и архитектурой

Статические методы полезны, когда:

  • операция логически относится к сущности (классу), но не требует конкретного экземпляра:
    • парсинг/валидаторы: User::Parse, Config::LoadFromFile;
    • фабрики: Connection::Create(), Logger::Instance();
    • вспомогательные функции, тесно связанные с доменом класса.
  • хотим скрыть реализацию и предоставить контролируемую точку создания объектов:
    class Connection {
    private:
    Connection(int fd) : fd_(fd) {}

    public:
    static Connection CreateFromFd(int fd) {
    // Валидация, настройка, логгирование
    return Connection(fd);
    }

    private:
    int fd_;
    };
  1. Ограничения и нюансы
  • Не виртуальные:
    • Статические методы не могут быть virtual:
      • виртуальность — про динамический полиморфизм по объекту (this), а у статического метода его нет.
  • Наследование:
    • Статические методы могут быть "переопределены" в производном классе (фактически — скрыты/затенены), но это не полиморфизм:
      class Base {
      public:
      static void foo() { std::cout << "Base\n"; }
      };

      class Derived : public Base {
      public:
      static void foo() { std::cout << "Derived\n"; }
      };

      int main() {
      Base::foo(); // Base
      Derived::foo(); // Derived

      Base* b = new Derived();
      b->foo(); // Base::foo (выбор по типу указателя, не по объекту)
      delete b;
      }
    • Вызов статического метода определяется по статическому типу, а не по runtime-типу.
  • Линковка:
    • Статические методы — обычные функции с особым именованием (name mangling).
    • В отличие от статических полей, для них не нужно отдельное определение вне класса (если тело задано внутри класса).
  1. Практический контекст

Сильный ответ подразумевает:

  • Четкое понимание, что статический метод:
    • часть интерфейса типа, а не конкретного объекта;
    • не имеет this;
    • не участвует в виртуальном полиморфизме;
    • не может работать с нестатическими полями без явного объекта.
  • Умение применять:
    • для фабрик, счетчиков, служебных операций;
    • без превращения всего в «god static» (соблюдая принципы тестируемости и инверсии зависимостей).

Краткая формулировка для интервью:

  • Статический метод принадлежит классу, а не объекту, вызывается как Class::Method(), не имеет this, работает только со статическими членами (или с объектами, переданными явно), не может быть виртуальным и не участвует в динамическом полиморфизме.

Вопрос 10. Что обозначает this в методах класса C++?

Таймкод: 00:15:48

Ответ собеседника: правильный. Корректно определил this как указатель на текущий объект, с которым работает метод, аналогично self в Python.

Правильный ответ:

this в C++ — это неявный указатель на текущий объект, доступный внутри нестатических методов класса. Через него компилятор реализует доступ к полям и методам конкретного экземпляра.

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

  1. Тип this
  • В обычном (неконстантном) методе:
    • тип thisClassName*.
  • В const-методе:
    • тип thisconst ClassName*.
  • В методах с ref-квалификаторами (&, &&) this учитывает квалификацию:
    • например, для void foo() &; тип this трактуется как ClassName* для lvalue-объекта,
    • для void foo() &&; — как ClassName* для rvalue-объекта (важно для perfect forwarding и fluent-интерфейсов).

Пример:

class A {
public:
void f() {
// здесь this имеет тип A*
}

void g() const {
// здесь this имеет тип const A*
}
};
  1. Роль this в доступе к членам
  • Любое обращение к полю или методу экземпляра внутри метода неявно идет через this:
    class A {
    int x;
    public:
    void set(int v) {
    this->x = v; // эквивалентно x = v;
    }
    };
  • Явное использование this-> бывает нужно:
    • при работе с шаблонами и зависимыми базовыми классами;
    • для повышения читаемости или разрешения конфликтов имен.
  1. Нельзя менять this
  • Сам указатель this неизменяем (по сути A* const или const A* const):
    • нельзя сделать this = &other;.
    • можно менять состояние объекта через *this (если метод не const).
  1. this и цепочки вызовов / fluent API

Частый паттерн — возвращать ссылку на себя через *this:

class Builder {
public:
Builder& SetX(int) {
// ...
return *this;
}

Builder& SetY(int) {
// ...
return *this;
}
};

Builder b;
b.SetX(1).SetY(2);
  1. Ограничения
  • В статических методах this нет:
    • они не привязаны к объекту;
    • попытка использовать this в static-методе — ошибка.
  • В конструкторах и деструкторах this есть:
    • можно ссылаться на текущий объект;
    • но важно помнить:
      • в конструкторе базового класса/части объект ещё не полностью построен;
      • в деструкторе — уже разрушаются производные части, виртуальные вызовы работают по типу текущего уровня.
  1. Практический аспект

Понимание this критично для:

  • корректной работы с const-методами;
  • реализации fluent-интерфейсов;
  • шаблонов (CRTP, зависимые имена);
  • написания безопасного кода в конструкторах/деструкторах, где this есть, но реальный жизненный цикл объекта нужно учитывать.

Кратко:

  • this — неявный указатель на текущий объект внутри нестатического метода.
  • Через него осуществляется доступ к полям и методам экземпляра.
  • В const-методах this указывает на константный объект, что гарантирует отсутствие модификации состояния (за исключением mutable полей).

Вопрос 11. Можно ли вызвать метод базового класса, если есть класс-наследник?

Таймкод: 00:16:21

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

Правильный ответ:

Да, метод базового класса можно явно вызвать из производного класса (и иногда снаружи), даже если он был переопределен. Важно понимать:

  • как использовать синтаксис с указанием области видимости базового класса;
  • как это работает для виртуальных и невиртуальных методов;
  • как влияют тип наследования и уровень доступа.
  1. Вызов метода базового класса из кода производного

Классический пример:

#include <iostream>

class Base {
public:
virtual void foo() {
std::cout << "Base::foo\n";
}
};

class Derived : public Base {
public:
void foo() override {
std::cout << "Derived::foo\n";
}

void callBaseFoo() {
Base::foo(); // Явный вызов реализации базового класса
}
};
  • foo() в Derived переопределяет виртуальный метод.
  • Внутри Derived мы можем:
    • вызвать переопределенный метод: foo();Derived::foo;
    • явно вызвать реализацию базового класса: Base::foo();.

Такой вызов:

  • не является виртуальным диспетчингом через vtable;
  • это прямое обращение к конкретной реализации Base::foo, выбранной на этапе компиляции.
  1. Вызов через объект/указатель/ссылку базового типа

Если у нас есть объект-наследник, но мы смотрим на него как на базовый тип:

Derived d;
Base& b = d;

b.foo(); // Виртуальный вызов: выберет Derived::foo()
b.Base::foo(); // Явный вызов реализации Base::foo (редко используется, но возможно для ref/obj)

Через указатель:

Base* pb = &d;
pb->foo(); // динамический полиморфизм: Derived::foo()
pb->Base::foo(); // принудительный вызов Base::foo

Однако в нормальном коде почти всегда используется просто виртуальный вызов (pb->foo()), чтобы сохранять полиморфизм.

  1. Отличия для виртуальных и невиртуальных методов
  • Невиртуальный метод:
    • Всегда статически связан по типу выражения.
    • Если в наследнике объявлен метод с тем же именем (shadowing), то:
      • obj.method(); в контексте наследника вызовет версию из наследника;
      • Base::method(); (изнутри наследника) — версию базового.
  • Виртуальный метод:
    • При обычном вызове через базовый указатель/ссылку — динамический диспетч через vtable.
    • Явный вызов Base::method() (через квалификацию области видимости) отключает полиморфизм и вызывает реализацию базового класса напрямую.
  1. Влияние типов наследования и доступа
  • При public-наследовании:
    • Публичные методы базового класса доступны снаружи через интерфейс наследника (если не скрыты).
    • Внутри наследника всегда можно вызвать Base::method() при наличии доступа.
  • При protected/private-наследовании:
    • Доступ к базовому интерфейсу снаружи ограничен.
    • Но внутри производного класса (и его потомков при protected) можно использовать Base::method() при соблюдении правил доступа.
  • Если метод базового private:
    • Он недоступен в производном классе напрямую, и вызвать его из наследника нельзя (кроме как через публичные/защищённые обертки базового).
  1. Типичный практический кейс: расширение поведения

Часто при переопределении виртуальной функции нужно добавить логику, не теряя поведение базового класса:

class Base {
public:
virtual void process() {
std::cout << "Base::process\n";
}
};

class Derived : public Base {
public:
void process() override {
std::cout << "Derived pre\n";
Base::process(); // вызов базовой реализации
std::cout << "Derived post\n";
}
};

Это важный паттерн: использовать Base::method() внутри override для расширения, а не полного замещения поведения.

  1. Краткий ответ для собеседования
  • Да, можно.
  • Изнутри производного класса: Base::method(); — явная квалификация областью видимости.
  • Это работает как для виртуальных, так и для невиртуальных методов; при такой записи вызывается именно реализация базового класса, без динамического полиморфизма.
  • Снаружи — можно вызвать метод базового через объект/указатель/ссылку базового типа; при виртуальных методах — включается динамический полиморфизм.

Вопрос 12. Какие виды умных указателей в C++ существуют и каково их назначение?

Таймкод: 00:17:05

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

Правильный ответ:

Современный C++ (начиная с C++11) предлагает стандартные умные указатели в заголовке <memory>, решающие ключевые задачи управления динамической памятью и временем жизни объектов без ручного new/delete. Основные:

  • std::unique_ptr
  • std::shared_ptr
  • std::weak_ptr

Понимание семантики владения, стоимости и типичных ошибок — критично.

  1. std::unique_ptr — уникальное владение

Назначение:

  • Гарантирует, что у объекта есть ровно один владелец.
  • Автоматически освобождает ресурс в деструкторе.
  • Нельзя копировать (копирующий конструктор удален), но можно перемещать:
    • тем самым "передавать" владение.

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

  • Легковесен: обычно содержит только сырой указатель.
  • Отлично оптимизируется, не требует счетчика ссылок.
  • Подходит по умолчанию, если нет явной необходимости в shared-владении.

Пример:

#include <memory>

std::unique_ptr<int> MakeInt() {
return std::make_unique<int>(42);
}

int main() {
auto p = std::make_unique<int>(10);
// auto p2 = p; // Ошибка: копирование запрещено
auto p2 = std::move(p); // OK: p2 владеет, p == nullptr
} // при выходе из main p2 освободит память

Использование:

  • RAII-обертки над ресурсами (файлы, сокеты, мьютексы, системные handle).
  • В контейнерах: std::vector<std::unique_ptr<Foo>> для иерархий объектов без slicing.
  • Явно показывает "один владелец" на уровне типов.
  1. std::shared_ptr — разделяемое (совместное) владение

Назначение:

  • Позволяет нескольким владельцам разделять один объект.
  • Хранит счетчик ссылок (reference count).
  • При уничтожении последнего shared_ptr освобождает ресурс.

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

  • Более тяжелый, чем unique_ptr:
    • контрольный блок (счетчики strong/weak, deleter, типы);
    • атомарные операции (обычно) при инкременте/декременте.
  • Использовать только когда действительно нужно разделенное владение.

Пример:

#include <memory>
#include <iostream>

int main() {
auto p1 = std::make_shared<int>(42);
{
auto p2 = p1; // ++refcount
std::cout << *p2 << "\n";
} // p2 уничтожен, refcount--, объект жив, т.к. p1 еще жив

// при уничтожении p1 refcount станет 0, память освободится
}

Опасные моменты:

  • Циклические ссылки:
    • Два объекта с shared_ptr друг на друга → утечка, т.к. счетчик никогда не станет нулём.
    • Для разрыва циклов нужен std::weak_ptr.
  1. std::weak_ptr — слабая ссылка (наблюдатель)

Назначение:

  • Не владеет объектом, не увеличивает счетчик ссылок.
  • Используется для:
    • избежания циклических зависимостей в графах объектов;
    • "наблюдения" за объектом, жив ли он еще.

Работа:

  • weak_ptr создается из shared_ptr.
  • Чтобы получить доступ к объекту, используется lock():
    • если объект ещё жив, lock() возвращает shared_ptr;
    • если уже удален — возвращает пустой shared_ptr.

Пример:

#include <memory>
#include <iostream>

struct Node {
std::shared_ptr<Node> next;
std::weak_ptr<Node> prev; // слабая ссылка, чтобы не циклить shared_ptr
};

int main() {
auto n1 = std::make_shared<Node>();
auto n2 = std::make_shared<Node>();

n1->next = n2;
n2->prev = n1; // weak_ptr: не увеличивает refcount

if (auto p = n2->prev.lock()) {
std::cout << "Prev is alive\n";
}
}
  1. Практические рекомендации (важное для сильного ответа)
  • По умолчанию:
    • Используй std::unique_ptr везде, где возможно.
  • std::shared_ptr:
    • Только когда объект реально должен иметь нескольких равноправных владельцев с неявно согласованным временем жизни.
    • Осознавай накладные расходы: атомарные операции, контрольный блок, возможные кеш-промахи.
  • std::weak_ptr:
    • Всегда, когда нужно "смотреть" на объект с shared-владением, не продлевая его жизнь.
    • Обязательно при потенциальных циклах ссылок (например, parent/child связи).
  • Никогда:
    • Не используй new/delete напрямую в прикладном коде без крайней необходимости.
    • Не храни сырые указатели как владельцев ресурса, если есть альтернатива в виде умных указателей / RAII.
  1. Кратко, в формате для интервью
  • unique_ptr:
    • Единственный владелец.
    • Перемещаемый, не копируемый.
    • По умолчанию, минимальная цена.
  • shared_ptr:
    • Разделяемое владение, счетчик ссылок.
    • Удобен, но дороже, требует аккуратности (особенно с циклами).
  • weak_ptr:
    • Невладеющая ссылка на объект из shared_ptr.
    • Не продлевает время жизни, используется для избежания циклов и безопасных проверок "объект еще жив?".

Вопрос 13. Можно ли в деструкторе объекта создавать новые объекты и что будет, если из деструктора выбрасывается исключение?

Таймкод: 00:18:23

Ответ собеседника: правильный. Отмечает, что создание объектов в деструкторе формально не запрещено, но сомнительно с точки зрения целесообразности; правильно указывает, что исключения не должны выходить за пределы деструктора, иначе при разрушении во время обработки другого исключения будет вызван std::terminate, и упоминает проблему нескольких исключений.

Правильный ответ:

Вопрос проверяет понимание гарантий исключений, поведения в фазе разрушения объектов и архитектурной аккуратности.

  1. Можно ли создавать объекты в деструкторе?

Формально — да.

Деструктор — это обычная функция-член со специальным именем и особыми правилами вызова, но:

  • Внутри него можно:
    • создавать локальные объекты (на стеке),
    • выделять память (new, умные указатели),
    • вызывать функции, методы и т.д.
  • Язык это не запрещает.

Пример (допускается):

struct Logger {
~Logger() {
// Локальный объект
std::string msg = "destroying Logger";

// Временный объект
auto t = std::make_unique<int>(42);

// Логирование, метрики и т.п.
// ...
}
};

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

  • Сам факт создания объектов в деструкторе — не проблема.
  • Проблема — сложность логики:
    • вызовы, которые могут кинуть исключение;
    • новые зависимости (I/O, логирование, аллокации), которые могут упасть в "чувствительный" момент.
  • Деструктор — часть cleanup-пути. Он должен быть максимально простым, надежным и по возможности noexcept.

Вывод:

  • Можно создавать объекты, но делать это стоит только для простых, надёжных операций (RAII-обёртки, локальные буферы, безопасное логирование и т.п.).
  • Если логика сложная — лучше вынести ее в явный метод (Close(), Stop(), Flush()), а деструктор использовать как last resort и стараться не бросать исключения.
  1. Что если из деструктора выбрасывается исключение?

Ключевой принцип: деструкторы не должны допускать "утечку" исключений наружу.

В стандарте:

  • Начиная с C++11, деструкторы по умолчанию noexcept(true):
    • если исключение "пробьётся" наружу из такого деструктора → вызов std::terminate().
  • Особенно критично в контексте "stack unwinding":
    • если во время обработки одного исключения (уже летит exception №1) при разрушении локальных объектов деструктор кидает второе исключение (exception №2), возникает ситуация нескольких активных исключений.
    • Язык не допускает одновременную обработку двух исключений — это приводит к std::terminate().

Типичный пример проблемы:

struct A {
~A() {
throw std::runtime_error("error in destructor"); // Плохая идея
}
};

void foo() {
A a;
throw std::runtime_error("original");
} // при раскрутке стека вызовется ~A и бросит второе исключение -> std::terminate()

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

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

Пример безопасного деструктора:

struct SafeCloser {
~SafeCloser() {
try {
close_resource(); // может бросать
} catch (const std::exception& e) {
// логирование, но не пробрасываем
// log(e.what());
} catch (...) {
// логируем неизвестное
}
}
};

Если требуется сигнализировать об ошибках при освобождении ресурсов:

  • Реализовать явный метод (например, Close() или Flush()), который может бросать:
    • вызывать его явно, в контролируемом месте;
    • деструктор либо вызывает Close() в noexcept-режиме с подавлением ошибок, либо требует от вызывающего явного вызова.
  1. Сводка ключевых моментов (то, что ожидают услышать на собеседовании)
  • Создавать объекты внутри деструктора можно — это не нарушает правила языка.
  • Но деструктор должен быть:
    • максимально простой,
    • по возможности noexcept,
    • без логики, которая легко может бросить исключение или зависнуть.
  • Исключения из деструктора:
    • не должны выходить наружу;
    • при выбросе во время раскрутки стека по другому исключению → std::terminate;
    • в современном C++ деструктор по умолчанию noexcept, так что любое "утекшее" исключение — прямой путь к аварийному завершению.
  • Если нужно сообщить об ошибках при cleanup:
    • используем явные методы (типа Close()), которые вызываются до разрушения объекта;
    • внутри деструктора максимум логируем/гасим исключения.

Такой ответ показывает знание не только синтаксиса, но и семантики исключений, RAII и требований к надежному production-коду.

Вопрос 14. Существуют ли указатели на функции и ссылки на функции в C++?

Таймкод: 00:19:39

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

Правильный ответ:

Да, в C++ существуют и указатели на функции, и ссылки на функции. Это базовый инструмент для передачи поведения (callable-объектов) в функции, реализации колбэков, стратегий, таблиц диспетчеризации и т.д. В современном коде они конкурируют и дополняются лямбдами, std::function и шаблонными параметрами.

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

  • синтаксис объявления;
  • как вызывать через них функции;
  • чем указатели и ссылки на функции отличаются от std::function и лямбд.
  1. Указатели на функции

Указатель на функцию хранит адрес функции с определенной сигнатурой.

Общий вид:

  • Для функции:
    • R f(Args...);
  • Тип указателя:
    • R (*pf)(Args...);

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

#include <iostream>

int add(int a, int b) {
return a + b;
}

int sub(int a, int b) {
return a - b;
}

int main() {
int (*op)(int, int); // указатель на функцию int(int, int)

op = &add; // & можно опустить: op = add;
std::cout << op(2, 3) << "\n"; // 5

op = sub;
std::cout << op(5, 2) << "\n"; // 3
}

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

  • Тип должен строго совпадать с сигнатурой: возвращаемый тип и параметры.
  • Вызов:
    • op(args...) или (*op)(args...) — оба варианта корректны.
  • Часто используются:
    • таблицы функций (jump table);
    • стратегии обработки;
    • C API-интеграция (колбэки в С-библиотеки).
  1. Ссылки на функции

Ссылка на функцию — это алиас на функцию, который нельзя переназначить после инициализации.

Общий вид:

  • Для функции:
    • R f(Args...);
  • Тип ссылки:
    • R (&ref)(Args...) = f;

Пример:

#include <iostream>

int mul(int a, int b) {
return a * b;
}

int main() {
int (&mul_ref)(int, int) = mul; // ссылка на функцию
std::cout << mul_ref(3, 4) << "\n"; // 12
}

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

  • Всегда должна быть инициализирована при объявлении.
  • Нельзя "перепривязать" на другую функцию.
  • Вызов синтаксически идентичен обычному вызову функции.
  1. Указатели на функции-члены (важное отличие)

Отдельная категория — указатели на нестатические методы класса. Они отличаются от обычных указателей на функции:

struct Foo {
void bar(int x) {
std::cout << x << "\n";
}
};

int main() {
void (Foo::*pm)(int) = &Foo::bar; // указатель на метод-член

Foo obj;
(obj.*pm)(42); // вызов через объект

Foo* p = &obj;
(p->*pm)(43); // вызов через указатель на объект
}

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

  • Указатель на метод-член хранит не просто адрес функции — нужен объект (this) для вызова.
  • Сигнатура: Ret (Class::*)(Args...).
  • Это принципиально другой тип, чем Ret (*)(Args...).
  1. Современные альтернативы и контекст

В production-коде:

  • Указатели/ссылки на функции:
    • хороши для простых C-подобных колбэков;
    • без накладных расходов, подходят для высокопроизводительных путей.
  • Ограничения:
    • могут указывать только на "голые" функции/методы с фиксированной сигнатурой;
    • не хранят состояние (в отличие от лямбда-замыканий и функциональных объектов).
  • std::function:
    • тип-обертка, способный хранить:
      • обычные функции;
      • лямбды;
      • функторы;
      • указатели на методы;
    • более универсален, но дороже по производительности.
  • Лямбды:
    • удобный синтаксис анонимных функций;
    • могут захватывать контекст (состояние);
    • могут неявно конвертироваться в указатель на функцию, если не захватывают контекст:
      void call(int (*f)(int)) {
      std::cout << f(10) << "\n";
      }

      int main() {
      auto lambda = [](int x) { return x * 2; };
      call(lambda); // OK: lambda без захвата -> указатель на функцию
      }
  1. Краткая формулировка для собеседования
  • Да, в C++ есть:
    • указатели на функции: R (*p)(Args...);
    • ссылки на функции: R (&ref)(Args...);
    • указатели на методы классов: R (C::*)(Args...).
  • Они позволяют передавать поведение как параметр, строить таблицы диспетчеризации, писать гибкие и эффективные API.
  • В современном коде часто дополняются лямбдами и std::function, но базовые указатели на функции остаются важным низкоуровневым инструментом.

Вопрос 15. Какое минимальное требование должен удовлетворять класс, чтобы его объекты можно было хранить в std::set?

Таймкод: 00:20:01

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

Правильный ответ:

std::set — это отсортированное упорядоченное множество. Ключевое требование к типу элемента: для него должна быть определена операция строгого слабого упорядочивания (strict weak ordering), используемая как критерий сортировки и уникальности.

По умолчанию std::set<T> использует std::less<T>, то есть, грубо говоря, оператор < для типа T. Значит, минимальное практическое требование:

  • либо должен быть корректно определён operator< для вашего типа;
  • либо вы должны предоставить корректный компаратор (функтор, лямбда, функциональный объект), который задает строгое слабое упорядочивание для объектов этого класса.

Важно не просто «уметь сравнивать», а удовлетворять именно требованиям к порядку.

  1. Что такое строгий слабый порядок (strict weak ordering)

Компаратор Compare (по умолчанию std::less<T>) должен задавать отношение, удовлетворящее свойствам:

  • Антирефлексивность:
    • Для любого x: Compare(x, x) всегда false.
  • Транзитивность:
    • Если Compare(a, b) и Compare(b, c) — истинны, то Compare(a, c) тоже истинен.
  • Транзитивность эквивалентности:
    • Если !(Compare(a, b)) и !(Compare(b, a)), считаем a и b эквивалентными.
    • Это отношение эквивалентности тоже должно быть транзитивным.
  • Никаких противоречий:
    • Нельзя допускать циклы вида: Compare(a, b) == true, Compare(b, c) == true, но Compare(c, a) == true.

std::set использует именно это отношение:

  • два элемента считаются равными (ключ уже существует), если:
    • !comp(a, b) && !comp(b, a).
  1. Минимальное требование в формулировке для собеседования

Для хранения объектов типа T в std::set<T> необходимо:

  • наличие корректного строго слабого порядка для T:
    • либо через bool operator<(const T&, const T&),
    • либо через пользовательский компаратор, переданный в std::set<T, Compare>.
  1. Примеры

Простой пример с operator<:

#include <set>
#include <string>

struct User {
int id;
std::string name;
};

bool operator<(const User& a, const User& b) {
// Сначала сравниваем по id, при равенстве id — по имени
if (a.id < b.id) return true;
if (a.id > b.id) return false;
return a.name < b.name;
}

int main() {
std::set<User> users;
users.insert({1, "Alice"});
users.insert({2, "Bob"});
users.insert({1, "Alice"}); // не добавится, эквивалентный ключ
}

Пример с пользовательским компаратором:

#include <set>
#include <string>

struct User {
int id;
std::string name;
};

struct UserById {
bool operator()(const User& a, const User& b) const {
return a.id < b.id; // строгий порядок по id
}
};

int main() {
std::set<User, UserById> users;
users.insert({2, "Bob"});
users.insert({1, "Alice"});
users.insert({1, "Charlie"}); // не добавится: по компаратору id совпадает
}

Здесь:

  • Элементы считаются "дубликатами", если их id равны (так как !(a<b) && !(b<a) при равных id).
  • Это демонстрирует, что понятие "уникальности" в std::set определяется не ==, а отношением порядка.
  1. Типичные ошибки
  • Определить operator<, который зависит от изменяемых полей:
    • Если изменить поле, участвующее в сравнении, после вставки в std::set, структура дерева ломается логически (нарушается инвариант), поведение — неопределенное.
  • Делать неконсистентный компаратор:
    • Например, нарушать транзитивность или смешивать несколько критериев без логики.
  • Ориентироваться только на ==:
    • std::set не использует operator== для уникальности, только компаратор.
  1. Краткий ответ

Минимальное требование:

  • Тип должен быть сравним с помощью компаратора, задающего строгое слабое упорядочивание (по умолчанию — корректно определенный operator<).
  • Именно этот порядок используется для организации дерева std::set и определения уникальности элементов.

Вопрос 16. В чем различие между std::map и std::unordered_map в C++?

Таймкод: 00:20:27

Ответ собеседника: правильный. Корректно указал, что std::map реализован на основе сбалансированного дерева (обычно красно-черного) с O(log N) на операции, а std::unordered_map — на основе хеш-таблицы с амортизированным O(1) и возможным ухудшением до O(N).

Правильный ответ:

std::map и std::unordered_map — ассоциативные контейнеры, но с принципиально разной моделью данных, сложностью операций, требованиями к ключам и гарантиями.

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

  1. Структура данных и сложность операций
  • std::map:

    • Обычно реализован как самобалансирующееся бинарное дерево поиска (часто красно-черное).
    • Ключи хранятся в отсортированном порядке.
    • Сложность операций:
      • поиск, вставка, удаление: O(log N);
      • обход: in-order, упорядоченный по ключам, O(N).
    • Гарантированная асимптотика (без завязки на хеш-функцию).
  • std::unordered_map:

    • Реализован как хеш-таблица (bucket + цепочки/открытая адресация, зависит от реализации).
    • Элементы не упорядочены по ключу.
    • Сложность операций:
      • среднее (амортизированное) для поиска, вставки, удаления: O(1);
      • в худшем случае (много коллизий, плохой хеш): O(N).
    • При перераспределении бакетов (rehash) итераторы инвалидируются.
  1. Требования к типу ключа
  • std::map:

    • Требуется строгий слабый порядок:
      • по умолчанию std::less<Key> → обычно operator<.
      • или пользовательский компаратор.
    • Компаратор определяет:
      • порядок;
      • критерий "эквивалентности" ключей.
  • std::unordered_map:

    • Требуется:
      • хеш-функция: std::hash<Key> или пользовательская;
      • функция равенства: KeyEqual (по умолчанию std::equal_to<Key>).
    • Условие корректности:
      • если KeyEqual(a, b) == true, то hash(a) == hash(b) (иначе undefined behavior).
    • Важно обеспечить:
      • равномерное распределение хеша;
      • отсутствие "плохих" хешей, создающих много коллизий.
  1. Порядок элементов и итерация
  • std::map:

    • Элементы всегда отсортированы по ключу.
    • Итерация:
      • по возрастанию (или в соответствии с компаратором).
    • Это:
      • позволяет эффективные range-запросы (lower_bound, upper_bound, equal_range);
      • удобно для задач, где важен порядок ключей.
  • std::unordered_map:

    • Не гарантирует порядок:
      • порядок может зависеть от хешей, количества бакетов, rehash.
      • Нельзя полагаться на стабильный порядок итерации между запусками/версиями.
    • Нет "диапазонных" операций по ключевому порядку.
  1. Итераторы и операции над ними
  • std::map:

    • Итераторы:
      • двунаправленные;
      • остаются валидными при большинстве вставок/удалений (кроме удаления самого элемента).
    • Структура дерева стабильна.
  • std::unordered_map:

    • Итераторы:
      • как минимум forward;
      • инвалидируются при rehash (который возможен при вставках).
    • При изменении load factor / резервировании необходимо учитывать инвалидирование.
  1. Выбор между std::map и std::unordered_map (важно уметь аргументировать)

Использовать std::map, когда:

  • Нужен упорядоченный по ключу контейнер:
    • вывод в отсортированном виде;
    • поиск по диапазонам ключей;
    • операции типа "найти первый >= X".
  • Важно стабильное поведение и гарантированная O(log N), независимая от хеш-функции.
  • Количество элементов не слишком велико, а читаемость/детерминизм важнее максимальной скорости.

Использовать std::unordered_map, когда:

  • Нужен быстрый доступ по ключу:
    • lookup-запросы в среднем O(1) критичны;
    • порядок неважен.
  • Большие объемы данных и доступ по ключу — основной сценарий.
  • Есть хорошая хеш-функция для ключей.
  • Готовы контролировать:
    • reserve / rehash для снижения количества аллокаций и коллизий;
    • потенциальный worst-case.
  1. Краткая формулировка для собеседования
  • std::map:

    • Дерево, отсортированные ключи.
    • O(log N) на операции.
    • Требует корректного порядка (less/comp).
    • Подходит, когда нужен порядок или range-операции.
  • std::unordered_map:

    • Хеш-таблица, порядок не гарантируется.
    • Амортизированное O(1) на операции, но возможен O(N) в худшем случае.
    • Требует корректного hash и equal_to.
    • Подходит, когда важна скорость доступа, а порядок не имеет значения.

Вопрос 17. Может ли поток в C++ перезапустить сам себя?

Таймкод: 00:21:27

Ответ собеседника: неправильный. Не дал четкого ответа, ушел в рассуждения, не сформулировав, что стандартными средствами C++ поток нельзя «перезапустить» после завершения, в том числе самим собой.

Правильный ответ:

Краткий и принципиальный ответ: нет, поток в C++ не может «перезапустить сам себя» стандартными средствами. Объект std::thread не поддерживает рестарт: после завершения потока он либо_join_ится, либо_detach_ится и больше не привязан к выполняющемуся потоку. Создание и запуск потока — одноразовая операция для конкретного объекта std::thread и конкретного системного потока.

Разберем по шагам.

  1. Модель std::thread в C++
  • Объект std::thread представляет собой "владельца" одного системного потока.
  • Жизненный цикл:
    • создается: std::thread t(f); — сразу запускает новый поток, выполняющий функцию f;
    • завершается:
      • когда функция потока закончилась (нормально или через исключение, долетевшее до вершины стека → std::terminate),
      • после этого поток считается завершенным (terminated).
  • После завершения:
    • t.joinable() становится true до вызова join() или detach();
    • после join() или detach():
      • t.joinable() == false;
      • объект t больше не связан с активным потоком.
  • Важный момент:
    • Нельзя "запустить" этот же std::thread повторно:
      std::thread t(f);
      t.join();
      // t = std::thread(f); // можно присвоить новый thread (с новым потоком),
      // но это уже другой поток, а не "перезапуск" старого
  1. Может ли поток «перезапустить сам себя»?

Под «перезапустить сам себя» иногда ошибочно подразумевают:

  • завершить выполнение и снова начать с той же функции;
  • или как-то пересоздать себя, не выходя за пределы текущего потока.

В стандартном C++:

  • Текущий поток может:
    • завершить свою функцию (тем самым завершить поток);
    • перед завершением создать новый поток (в том числе, который будет выполнять "такую же" функцию).
  • Но:
    • это будет уже другой поток (другой объект std::thread, другой thread id);
    • текущий поток, завершившись, не может "возродиться" в рамках того же std::thread.
  • Нет механизма:
    • "reset/restart" для уже завершённого потока;
    • автоперезапуска текущего потока "самим собой" на уровне стандартной библиотеки.

Условно, можно написать:

#include <thread>
#include <iostream>

void worker(int depth) {
std::cout << "run " << depth << " in thread " << std::this_thread::get_id() << "\n";
if (depth < 3) {
std::thread t(worker, depth + 1);
t.detach(); // или join
}
// после выхода из функции поток завершается
}

int main() {
std::thread t(worker, 0);
t.join();
}

Здесь:

  • "перезапуск" реализован созданием нового потока из старого;
  • но каждый запуск — новый thread id, это не один и тот же поток.
  1. Причины и последствия

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

  • Модель std::thread:
    • однозначное владение ресурсом потока;
    • отсутствие состояния "готов снова стартовать".
  • Системный поток:
    • после завершения его стек и ресурсы освобождаются;
    • "оживить" его нельзя без создания нового потока.

Если нужен механизм повторного выполнения:

  • используем цикл внутри одного потока:
void worker() {
for (;;) {
// работа
// при необходимости "перезапуска логики" просто возвращаемся к началу цикла
}
}
  • или внешний контроллер, который:
    • запускает новый поток, когда предыдущий завершился;
    • но это уже управление жизненным циклом, а не "самоперезапуск".
  1. Что важно проговорить на интервью

Ожидаемый сильный ответ:

  • Нельзя "перезапустить" уже завершившийся поток ни самим собой, ни через тот же std::thread.
  • Можно:
    • внутри потока создать новый std::thread с тем же runnable (эффект "перезапуска логики"), но это другой поток.
    • организовать перезапуск логики через цикл в одном потоке.
  • Объект std::thread:
    • одноразового запуска; после завершения потока — либо join/detach, либо уничтожение/переприсвоение новым потоком.
  • Любые попытки моделировать "самоперезапуск" должны быть сделаны явно в коде (цикл, менеджер потоков, пул потоков), а не через магию std::thread.

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

Таймкод: 00:11:34

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

Правильный ответ:

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

Статический полиморфизм (перегрузка, шаблоны):

  • Характеристика:
    • Решение, какую функцию вызвать, принимается на этапе компиляции.
    • Основан на статических типах, известных компилятору.
  • Перегрузка функций:
    • Несколько функций с одинаковым именем и разными сигнатурами.
    • Компилятор выбирает наиболее подходящую по типам аргументов.
    • Пример:
      void Print(int)   { /* ... */ }
      void Print(double){ /* ... */ }

      Print(10); // выбирается Print(int) на этапе компиляции
  • Шаблоны (часто упоминаются как форма статического полиморфизма):
    • Конкретная версия функции/класса генерируется под конкретные типы на этапе компиляции.
    • Вызовы инлайнятся и оптимизируются.
  • Особенности:
    • Нет накладных расходов на виртуальные вызовы.
    • Нельзя в runtime подменить поведение, не перекомпилировав код.
    • Отлично подходит для высокопроизводительных generic-решений.

Динамический полиморфизм (виртуальные функции):

  • Характеристика:
    • Конкретная реализация выбирается во время выполнения.
    • Основан на динамическом типе объекта при обращении через указатель/ссылку на базовый класс.
  • Виртуальные функции:
    • В базовом классе помечаются virtual.
    • В производных переопределяются (override).
    • Вызов через Base* / Base& диспетчеризуется через vtable/vptr.
    • Пример:
      struct Base {
      virtual void Foo() { /* ... */ }
      };

      struct Derived : Base {
      void Foo() override { /* ... */ }
      };

      void Call(Base& b) {
      b.Foo(); // выбирается реализация по реальному типу b в runtime
      }
  • Особенности:
    • Даёт гибкость: подмена реализации в runtime.
    • Стоимость: косвенный вызов через таблицу виртуальных функций, потенциально меньше возможностей для инлайна.
    • Используется для иерархий типов, плагинов, драйверов, extensible API.

Ключевые отличия, которые важно явно проговаривать:

  • Момент выбора реализации:
    • Статический полиморфизм (перегрузка/шаблоны): выбор делается компилятором.
    • Динамический полиморфизм (virtual): выбор делается в runtime по динамическому типу объекта.
  • Механизм:
    • Статический: перегрузка по сигнатуре, инстанцирование шаблонов.
    • Динамический: vtable/vptr (в типичных реализациях), виртуальный вызов.
  • Связь с архитектурой:
    • Статический: хорош, когда все варианты типов известны на этапе компиляции, важна максимальная производительность.
    • Динамический: нужен, когда конкретная реализация может определяться в runtime, и код должен работать через абстракции (базовые интерфейсы).
  • Связь с наследованием:
    • Перегрузка не требует наследования.
    • Виртуальные функции осмысленны только в контексте наследования.

Кратко для ответа на интервью:

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

Вопрос 7. Какие существуют типы наследования классов в C++ (public, protected, private) и как они влияют на доступность членов базового класса?

Таймкод: 00:12:11

Ответ собеседника: правильный. Перечисляет public, protected и private наследование и корректно объясняет, что при public уровни доступа сохраняются, при protected публичные и защищённые члены базового становятся защищёнными в наследнике, при private — публичные и защищённые становятся приватными.

Правильный ответ:

Тип наследования в C++ определяет, как члены базового класса (public/protected) будут видны в интерфейсе производного класса, и формирует семантику отношений между типами.

Рассмотрим базовый класс:

class Base {
public:
void pub();
protected:
void prot();
private:
void priv();
};
  1. Public-наследование
class DerivedPublic : public Base {
// Base::pub -> public в DerivedPublic
// Base::prot -> protected в DerivedPublic
// Base::priv -> недоступен напрямую
};

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

  • Сохраняет «is-a» отношение:
    • DerivedPublic можно использовать там, где ожидается Base.
    • Неявное приведение DerivedPublic*Base* доступно.
  • Уровни доступа:
    • public члены базы остаются public в наследнике;
    • protected остаются protected;
    • private не наследуются по доступу (недоступны напрямую в потомке).
  • Это основной вид наследования для полиморфных иерархий, интерфейсов и общего контракта.
  1. Protected-наследование
class DerivedProtected : protected Base {
// Base::pub -> protected в DerivedProtected
// Base::prot -> protected в DerivedProtected
// Base::priv -> недоступен
};

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

  • Базовый интерфейс скрыт от внешнего кода:
    • снаружи DerivedProtected больше не «является» Base в терминах public API;
    • неявное приведение DerivedProtected*Base* запрещено вне иерархии.
  • Уровни доступа внутри иерархии:
    • и бывшие public, и protected базового — доступны как protected в наследнике;
    • доступны в самом наследнике и его производных.
  • Используется, когда базовый класс — деталь реализации, доступная только наследникам, но не внешнему миру.
  1. Private-наследование
class DerivedPrivate : private Base {
// Base::pub -> private в DerivedPrivate
// Base::prot -> private в DerivedPrivate
// Base::priv -> недоступен
};

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

  • Полная инкапсуляция базового:
    • снаружи нет отношения «is-a»;
    • DerivedPrivate* нельзя неявно привести к Base*.
  • Уровни доступа:
    • и public, и protected базового становятся private в наследнике;
    • доступны только внутри DerivedPrivate.
  • Семантически ближе к композиции:
    • "реализовано через Base", а не "является Base".
  • Применяется, когда нужно переиспользовать реализацию базового, не раскрывая его интерфейс.
  1. Важные нюансы
  • Private-члены базового:
    • Никогда не становятся доступны напрямую в наследнике (независимо от вида наследования).
    • Доступ к ним возможен только через public/protected методы базового.
  • По умолчанию:
    • Для class Derived : Base — наследование private.
    • Для struct Derived : Base — наследование public.
  • Для полиморфизма и интерфейсов:
    • корректный выбор — почти всегда public-наследование;
    • protected/private-наследование применяют для инкапсуляции и реализации, а не для внешних контрактов.
  1. Краткая формулировка для интервью
  • public: сохраняет уровни доступа, формирует «is-a», используется для полиморфизма.
  • protected: прячет базовый интерфейс от внешнего кода, оставляя доступ внутри иерархии.
  • private: делает базу внутренней реализацией, без «is-a» для внешнего мира.

Вопрос 8. Что происходит с доступностью методов базового класса при вызове из наследника для разных типов наследования?

Таймкод: 00:13:17

Ответ собеседника: правильный. На примере публичного метода базового класса описывает, что при public наследовании метод остаётся публичным, при protected становится защищённым, а при private — приватным в производном классе.

Правильный ответ:

Суть вопроса — понять, как тип наследования влияет:

  • на уровни доступа унаследованных членов внутри производного класса;
  • на то, как производный класс «экспортирует» (или скрывает) интерфейс базового для внешнего кода.

Возьмем базовый класс:

class Base {
public:
void pub() {}

protected:
void prot() {}

private:
void priv() {}
};
  1. Public-наследование
class DerivedPublic : public Base {
public:
void test() {
pub(); // доступен, остается public-членом интерфейса DerivedPublic
prot(); // доступен как protected
// priv(); // недоступен
}
};
  • Внутри DerivedPublic:
    • pub() и prot() доступны.
  • Снаружи:
    • pub() остается доступным:
      DerivedPublic d;
      d.pub(); // OK
    • DerivedPublic можно неявно привести к Base* / Base&.
  • Это классическое «is-a» наследование: интерфейс базового виден пользователям наследника.
  1. Protected-наследование
class DerivedProtected : protected Base {
public:
void test() {
pub(); // доступен, но теперь как protected
prot(); // доступен как protected
// priv(); // недоступен
}
};
  • Внутри DerivedProtected:
    • pub() и prot() доступны.
  • Но уровни доступа изменены:
    • и бывший public, и protected из Base становятся protected в DerivedProtected.
  • Снаружи:
    DerivedProtected d;
    // d.pub(); // Ошибка: pub больше не public
    // Base* b = &d; // Ошибка: нет public-наследования
  • Базовый интерфейс скрыт от внешнего кода; он доступен только внутри иерархии (DerivedProtected и его наследники).
  1. Private-наследование
class DerivedPrivate : private Base {
public:
void test() {
pub(); // доступен, но как private в DerivedPrivate
prot(); // доступен, но как private
// priv(); // недоступен
}
};
  • Внутри DerivedPrivate:
    • pub() и prot() доступны.
  • Но:
    • оба становятся private-членами DerivedPrivate.
  • Снаружи:
    DerivedPrivate d;
    // d.pub(); // Ошибка: pub теперь private
    // Base* b = &d; // Ошибка: нет public-наследования
  • Базовый класс полностью инкапсулирован, нет внешнего "is-a".
  1. Важные акценты
  • Private-члены базового (priv()) не становятся доступны в наследнике никогда, вне зависимости от типа наследования.
  • Тип наследования:
    • не меняет факт, что внутри производного класса можно вызывать унаследованные public/protected методы (если они не стали недоступными по правилам доступа);
    • определяет, как эти методы экспонируются во внешнем интерфейсе производного класса.
  • Для полиморфизма и работы через базовый интерфейс:
    • используется public-наследование;
    • protected/private-наследование применяют, когда базовый класс — внутренняя реализация, а не внешний контракт.

Кратко:

  • public: public остается public, protected — protected; доступ из наследника есть, пользователи видят базовый интерфейс.
  • protected: public и protected базового становятся protected; доступ из наследника есть, но пользователи не видят базовый интерфейс.
  • private: public и protected базового становятся private; доступны только внутри наследника, полностью скрыты снаружи.

Вопрос 9. Что такое статический метод класса в C++ и в чем его особенности?

Таймкод: 00:14:47

Ответ собеседника: неполный. Говорит, что статический метод не привязан к конкретному объекту, вызывается без создания экземпляра, работает со статическими полями и единый для всех объектов. Частично путает с реализацией вне класса и не акцентирует отсутствие this и ограничения на доступ к нестатическим членам.

Правильный ответ:

Статический метод класса в C++ — это функция, объявленная внутри класса с ключевым словом static, которая:

  • логически принадлежит классу;
  • не привязана к конкретному объекту;
  • вызывается без создания экземпляра (ClassName::Method()), либо через объект (но это менее корректный стиль);
  • не имеет неявного указателя this;
  • может напрямую работать только со статическими членами класса и переданными параметрами.

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

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

  1. Отсутствие this
  • В нестатическом методе компилятор под капотом передаёт скрытый параметр this — указатель на текущий объект.
  • В статическом методе this нет:
    • нельзя обращаться к нестатическим полям/методам напрямую:
      class A {
      int x;
      public:
      static void f() {
      // x = 10; // ошибка: нет this
      // g(); // ошибка: g() нестатический
      }

      void g() {}
      };
    • чтобы использовать нестатические члены, их нужно получать через явный объект:
      static void SetX(A& obj, int v) {
      obj.x = v; // допустимо через явную ссылку/указатель
      }
  1. Общность для всех экземпляров

Статический метод естественным образом работает:

  • с общим состоянием класса — статическими полями;
  • с функциональностью, которая не зависит от конкретного экземпляра, но логически относится к типу.

Пример:

#include <memory>

class Counter {
public:
static int total;

Counter() {
++total;
}

~Counter() {
--total;
}

static int GetTotal() {
return total; // допустимо: total статическое
}
};

int Counter::total = 0;

int main() {
auto p1 = std::make_unique<Counter>();
auto p2 = std::make_unique<Counter>();
int n = Counter::GetTotal(); // 2
}
  1. Вызов без создания объекта

Правильный, читаемый вариант:

ClassName::StaticMethod(args);

Вызов через объект:

ClassName obj;
obj.StaticMethod(); // синтаксически разрешено, но вводит в заблуждение

Лучше всегда подчеркивать принадлежность методу классу, а не экземпляру.

  1. Нельзя сделать виртуальным
  • Статический метод не может быть virtual:
    • виртуальность основана на динамическом выборе реализации через this и vtable;
    • у статического метода нет this, поэтому он не участвует в динамическом полиморфизме.
  • Если в наследнике объявить статический метод с тем же именем, он не переопределяет, а затеняет (hides) метод базового класса:
    struct Base {
    static void f();
    };

    struct Derived : Base {
    static void f(); // не override, а hiding
    };
  1. Типичные применения

Статические методы уместны, когда:

  • Операция концептуально относится к типу, а не конкретному объекту:
    • фабричные методы:
      class Connection {
      public:
      static Connection Create(const std::string& addr);
      };
    • валидация и утилиты: IpAddress::Parse, User::ValidateName.
  • Нужен доступ к статическому состоянию:
    • глобальные счетчики, конфигурация, кеши (с оговоркой по архитектуре).
  • Требуется "single point of entry" к определенной функциональности, связанной с типом.

При этом важно не превращать статические методы в антипаттерн "глобальное всё":

  • избегать избыточных глобальных синглтонов и скрытых зависимостей;
  • помнить о тестируемости и инверсии зависимостей (в ключевых местах вместо статик-методов лучше явные объекты и интерфейсы).
  1. Краткая формулировка для интервью
  • Статический метод объявляется с static внутри класса, принадлежит классу, а не объекту.
  • Не имеет this, не может обращаться к нестатическим членам без явного объекта.
  • Может работать со статическими полями/методами.
  • Вызывается как Class::Method().
  • Не участвует в виртуальном полиморфизме.

Вопрос 10. Что обозначает this в методах класса C++?

Таймкод: 00:15:48

Ответ собеседника: правильный. Определяет this как указатель на текущий объект, аналог self в Python, используемый внутри методов.

Правильный ответ:

this в C++ — это неявный указатель на текущий объект внутри нестатических методов класса. Через него компилятор реализует доступ к полям и методам конкретного экземпляра.

Ключевые моменты, которые важно понимать глубже:

  1. Тип this
  • В обычном методе:
    • тип this: ClassName* const
    • т.е. указатель постоянный (нельзя переназначить), но объект по нему (если метод неконстантный) можно менять.
  • В const-методе:
    • тип this: const ClassName* const
    • менять состояние объекта (кроме mutable полей) нельзя.
  • В методах с ref-квалификаторами:
    • сигнатура учитывает, lvalue или rvalue объект (&, &&), но концептуально this остаётся указателем на текущий объект.

Примеры:

class A {
public:
void f() {
// this имеет тип A* const
}

void g() const {
// this имеет тип const A* const
}
};
  1. Неявное использование this

При обращении к полям и методам внутри нестатического метода, this используется неявно:

class A {
int x;
public:
void set(int v) {
x = v; // на самом деле: this->x = v;
}
};

Явное this-> бывает нужно:

  • для разрешения конфликтов имён;
  • в шаблонах (CRTP и зависимые имена);
  • для повышения читаемости, когда важно подчеркнуть, что используется член класса.
  1. Доступность и ограничения
  • В статических методах this не существует:
    • попытка использовать this в static-методе — ошибка.
  • В конструкторах и деструкторах:
    • this доступен,
    • но:
      • в конструкторе объект ещё не полностью сконструирован (особенно при наследовании);
      • в деструкторе уже разрушены производные части (для виртуальных деструкторов — важно понимать эффект на виртуальные вызовы).
  1. Возврат *this и fluent-интерфейсы

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

class Builder {
public:
Builder& SetX(int) {
// ...
return *this;
}

Builder& SetY(int) {
// ...
return *this;
}
};

// Использование:
Builder b;
b.SetX(1).SetY(2);

Здесь:

  • *this — ссылка на текущий объект;
  • позволяет строить fluent API без лишних копирований.
  1. Практический смысл

Глубокое понимание this важно для:

  • корректной реализации const-методов и соблюдения контрактов неизменяемости;
  • построения удобных API (chainable методы, builder-паттерны);
  • шаблонных паттернов типа CRTP:
    template <typename Derived>
    class Base {
    public:
    void interface() {
    static_cast<Derived*>(this)->implementation();
    }
    };

Кратко:

  • this — неявный указатель на текущий объект внутри нестатических методов.
  • В const-методах — указатель на const-объект.
  • Нет this в статических методах.
  • Через this реализуются доступ к членам, fluent-интерфейсы, многие паттерны и корректная работа с наследованием.

Вопрос 11. Можно ли вызвать метод базового класса при наличии класса-наследника?

Таймкод: 00:16:21

Ответ собеседника: неполный. Утверждает, что вызвать метод базового класса можно, но не показывает явный синтаксис (Base::method()), путается с механизмом виртуальных вызовов и не разделяет случаи виртуальных и невиртуальных методов.

Правильный ответ:

Да, метод базового класса можно вызывать, даже если он переопределен в наследнике. Важно понимать:

  • как явно обратиться к методу базового класса;
  • как это работает для виртуальных и невиртуальных методов;
  • как влияют уровни доступа и тип наследования.
  1. Вызов метода базового класса изнутри наследника

Используется квалификация областью видимости:

#include <iostream>

class Base {
public:
virtual void foo() {
std::cout << "Base::foo\n";
}

void bar() {
std::cout << "Base::bar\n";
}
};

class Derived : public Base {
public:
void foo() override {
std::cout << "Derived::foo\n";
}

void bar() { // затеняет Base::bar, не виртуальный
std::cout << "Derived::bar\n";
}

void callBase() {
Base::foo(); // Явный вызов реализации Base::foo
Base::bar(); // Явный вызов Base::bar, несмотря на наличие Derived::bar
}

void callDerived() {
foo(); // Вызовет Derived::foo (обычный виртуальный вызов через this)
bar(); // Вызовет Derived::bar
}
};

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

  • Base::foo(); внутри Derived:
    • принудительный вызов реализации базового класса;
    • отключает динамический полиморфизм для этого вызова.
  • Аналогично для невиртуальных методов: Base::bar(); вызовет именно реализацию из Base.
  1. Виртуальные vs невиртуальные методы
  • Виртуальный метод:

    • Обычный вызов через указатель/ссылку на базу:
      • Base* b = new Derived; b->foo(); → динамический выбор, вызов Derived::foo.
    • Явный вызов Base::foo():
      • игнорирует vtable, вызывает базовую реализацию напрямую.
  • Невиртуальный метод:

    • Всегда выбирается по статическому типу:
      • если в наследнике объявлен метод с тем же именем, это затенение (shadowing), а не override;
      • внутри Derived без квалификатора будет вызван Derived::bar;
      • Base::bar() внутри Derived прямо вызывает базовый вариант.
  1. Вызов извне через базовый тип

Из внешнего кода:

Derived d;
Base& b = d;

b.foo(); // виртуальный: Derived::foo
b.Base::foo(); // явный вызов базовой реализации (разрешено для ref/obj)

Хотя такой явный вызов базовой реализации извне встречается редко, важно понимать, что:

  • обычный виртуальный вызов (b.foo()) выбирает реализацию по динамическому типу (Derived);
  • квалифицированный вызов (b.Base::foo()) обращается к конкретной реализации, минуя виртуальный диспетчер.
  1. Влияние доступа и типа наследования
  • Public-наследование:
    • Публичные методы базового доступны (если не скрыты) и могут вызываться как обычно или через Base::method() внутри наследника.
  • Protected/private-наследование:
    • Доступ к базовому интерфейсу снаружи ограничен.
    • Но внутри наследника (и его потомков при protected) можно вызывать Base::method(), если уровень доступа это позволяет.
  • Private-методы базового:
    • Непрямой вызов из наследника невозможен — они не доступны;
    • доступ только через публичные/защищенные методы базы.
  1. Типичный паттерн: расширение поведения базового метода

Частый и правильный сценарий:

class Base {
public:
virtual void process() {
// базовая логика
}
};

class Derived : public Base {
public:
void process() override {
// дополнительная логика до
Base::process(); // используем базовую реализацию
// дополнительная логика после
}
};

Это демонстрирует:

  • использование Base::method() внутри override для надстройки над базовым поведением;
  • осознанное управление полиморфным поведением.
  1. Краткая формулировка для интервью
  • Да, можно.
  • Из наследника: Base::method(); — явный вызов реализации базового класса.
  • Для виртуальных методов такой вызов отключает динамический полиморфизм и вызывает именно базовую версию.
  • Для невиртуальных методов — обычная квалификация областью видимости, используется при затенении.
  • Доступ зависит от модификаторов доступа и типа наследования, но сам механизм языка это напрямую поддерживает.

Вопрос 12. Какие виды умных указателей в C++ известны и какова их роль?

Таймкод: 00:17:05

Ответ собеседника: правильный. Называет unique_ptr, shared_ptr и weak_ptr и корректно описывает их семантику владения и поведения.

Правильный ответ:

Современный C++ (начиная с C++11) предлагает стандартные умные указатели в <memory>, которые инкапсулируют управление ресурсами и устраняют необходимость ручного delete в корректно написанном коде. Их ключевая роль — выразить на уровне типов модель владения ресурсом (ownership).

Базовые виды:

  • std::unique_ptr<T>
  • std::shared_ptr<T>
  • std::weak_ptr<T>

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

  1. std::unique_ptr — уникальное владение

Роль:

  • Представляет эксклюзивного владельца ресурса.
  • Гарантирует освобождение ресурса в деструкторе.
  • Нельзя копировать, можно только перемещать (semantics of move-only ownership).

Основные свойства:

  • Легковесен: обычно только сырой указатель + (опционально) deleter в типе.
  • Идеален как "по умолчанию" для владения динамическими объектами.
  • Хорошо комбинируется с RAII и контейнерами.

Пример:

#include <memory>
#include <utility>

struct Conn {
void Send(const char*) {}
};

std::unique_ptr<Conn> MakeConn() {
return std::make_unique<Conn>();
}

int main() {
auto c1 = std::make_unique<Conn>();
// auto c2 = c1; // Ошибка: копирование запрещено
auto c2 = std::move(c1); // OK: владение передано c2
if (!c1) {
// c1 больше не владеет ресурсом
}
} // при выходе c2 освободит Conn

Типичные применения:

  • Владение ресурсом в одном месте: файл, сокет, мьютекс, буфер, объект доменной модели.
  • Элементы полиморфных иерархий: std::vector<std::unique_ptr<Base>>.
  • Инкапсуляция RAII вокруг внешних ресурсов (дескрипторов ОС, С-API).
  1. std::shared_ptr — разделяемое владение

Роль:

  • Позволяет нескольким владельцам совместно владеть объектом.
  • Объект жив, пока существует хотя бы один std::shared_ptr на него.
  • Ведет счетчики:
    • strong (shared);
    • weak (weak_ptr-наблюдателей).

Основные свойства:

  • Дороже unique_ptr:
    • отдельный контрольный блок (счетчики, deleter, иногда type-erasure);
    • обычно атомарные инкременты/декременты.
  • Нужно использовать осознанно, только когда действительно требуется shared-владение.

Пример:

#include <memory>
#include <iostream>

struct Data {
int x;
};

int main() {
auto p1 = std::make_shared<Data>(Data{42});
{
auto p2 = p1; // refcount++
std::cout << p2->x << "\n";
} // p2 уничтожен, refcount--
// объект жив, пока p1 существует
} // p1 уничтожен, refcount==0 -> Data освобождается

Важный подводный камень:

  • Циклические ссылки:
struct Node {
std::shared_ptr<Node> next;
// при двусторонней связи shared/shared память не освободится
};

Решение — использовать std::weak_ptr для одной из сторон связи.

  1. std::weak_ptr — невладеющая (слабая) ссылка

Роль:

  • Не владеет объектом, не увеличивает счетчик strong-ссылок.
  • Используется:
    • для избежания циклов shared_ptr;
    • как "наблюдатель": проверить, жив ли еще объект.

Механика:

  • std::weak_ptr создаётся из std::shared_ptr.
  • Для доступа к объекту используется lock():
    • возвращает std::shared_ptr, если объект ещё жив;
    • возвращает пустой shared_ptr, если объект уже уничтожен.

Пример разрыва цикла:

#include <memory>

struct Node {
std::shared_ptr<Node> next;
std::weak_ptr<Node> prev; // слабая ссылка
};

int main() {
auto n1 = std::make_shared<Node>();
auto n2 = std::make_shared<Node>();

n1->next = n2;
n2->prev = n1; // weak_ptr, не держит объект живым

// при выходе из main оба объекта корректно освободятся
}
  1. Практические рекомендации
  • Используй std::unique_ptr по умолчанию:
    • если непонятно, нужен ли shared_ptr, почти наверняка он не нужен.
  • std::shared_ptr:
    • применяй, когда реально есть несколько долгоживущих владельцев одного объекта, и сложно/дорого организовать явное владение.
    • избегай бессмысленного "оборачивания всего" в shared_ptr.
    • помни про накладные расходы и риск циклических зависимостей.
  • std::weak_ptr:
    • используй для:
      • кэшей;
      • обратных ссылок (parent -> child via shared, child -> parent via weak);
      • подписчиков/наблюдателей, которые не должны продлевать жизнь объекта.
  • Избегай ручных new/delete в пользовательском коде:
    • вместо этого — std::make_unique, std::make_shared, фабричные функции, RAII-обертки.
  1. Краткая формулировка для интервью
  • unique_ptr — эксклюзивное владение, перемещаемый, не копируемый; самый дешевый и предпочтительный способ управления ресурсом.
  • shared_ptr — совместное владение с подсчётом ссылок; удобен, но дороже, требует аккуратности (особенно с циклами).
  • weak_ptr — слабая, наблюдающая ссылка на объект из shared_ptr, не продлевает его жизнь и позволяет безопасно проверять, существует ли ещё объект.

Вопрос 13. Можно ли в деструкторе объекта создавать новые объекты и что будет, если из деструктора выбрасывается исключение?

Таймкод: 00:18:23

Ответ собеседника: правильный. Указывает, что создание объектов внутри деструктора не запрещено, но ключевое правило — исключения не должны покидать деструктор; при выходе исключения наружу, особенно во время раскрутки стека по другому исключению, произойдет std::terminate.

Правильный ответ:

Вопрос проверяет понимание гарантий исключений, модели RAII и того, почему деструктор — "последнее место", где код обязан быть максимально безопасным.

Разберём по двум частям.

Создание объектов в деструкторе

  • Язык не запрещает создавать объекты (локальные или динамические) в теле деструктора.

  • Деструктор — это обычная функция со спец-именем, в которой можно:

    • объявлять локальные переменные (в том числе с нетривиальными конструкторами/деструкторами);
    • вызывать функции;
    • использовать new, умные указатели и т.д.
  • Пример допустимого кода:

    struct Resource {
    ~Resource() {
    // Локальный объект
    std::string msg = "cleanup";

    // Временный RAII-объект
    auto guard = std::unique_ptr<int>(new int(42));

    // Логирование, метрики и т.п.
    }
    };
  • Практический смысл:

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

    • деструктор должен быть максимально простым и по возможности noexcept (что по умолчанию так и есть со стандартом C++11+).

Исключения из деструктора: что происходит и почему это опасно

Ключевое правило: исключения не должны "утекать" из деструктора.

Основные моменты:

  1. Стандарт и noexcept по умолчанию
  • Начиная с C++11:
    • деструктор по умолчанию считается noexcept(true).
  • Если исключение выходит за пределы такого деструктора:
    • вызывается std::terminate().
  • То есть любая "утечка" исключения наружу из обычного деструктора в современном C++ — фатальная ошибка.
  1. Ситуация "исключение во время исключения"

Самый критичный кейс:

  • Уже выполняется раскрутка стека из-за исключения №1.
  • В это время вызывается деструктор локального/члена/умного указателя.
  • Этот деструктор выбрасывает исключение №2.
  • Теперь в системе два активных непойманных исключения — стандарт запрещает это.
  • Результат: немедленный вызов std::terminate().

Пример:

struct Bad {
~Bad() {
throw std::runtime_error("error in destructor");
}
};

void f() {
Bad b;
throw std::runtime_error("original error");
} // при выходе из f во время раскрутки стека ~Bad бросит второе исключение -> std::terminate()
  1. Правильная практика обработки ошибок в деструкторах

Если операция в деструкторе потенциально может бросить:

  • Обернуть в try/catch внутри деструктора.
  • Не позволять исключению выйти наружу.

Пример безопасного деструктора:

struct SafeCloser {
~SafeCloser() {
try {
Close(); // может бросить
} catch (const std::exception& e) {
// логируем, метрики, тихое поглощение
// но не rethrow!
} catch (...) {
// логируем неизвестную ошибку
}
}

void Close() {
// может бросать, но это вызывается явно, не из деструктора
}
};

Правильный паттерн:

  • Если нужно корректно сообщить об ошибке при закрытии ресурса:
    • предоставить явный метод (Close, Stop, Flush), который может бросать;
    • дергать его в контролируемом месте;
    • деструктор воспринимать как best-effort cleanup без броска наружу.

Краткая формулировка для интервью

  • Создавать объекты в деструкторе можно — это разрешено и иногда полезно (RAII-хэлперы, логирование и т.п.).
  • Главное правило: исключения не должны выходить за пределы деструктора.
    • В современном C++ деструкторы по умолчанию noexcept; утечка исключения приведёт к std::terminate.
    • Особенно опасна ситуация, когда исключение выбрасывается из деструктора во время раскрутки стека по другому исключению.
  • Для операций, потенциально бросающих, используем:
    • явные методы с возможностью выброса;
    • в деструкторе — только безопасные действия или перехват/логирование без повторного броска.

Вопрос 14. Существуют ли указатели и ссылки на функции в C++?

Таймкод: 00:19:39

Ответ собеседника: правильный. Кратко подтвердил наличие указателей и ссылок на функции.

Правильный ответ:

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

  • объявлять такие типы;
  • корректно вызывать функцию через них;
  • отличать их от указателей на методы классов, лямбд и std::function.
  1. Указатели на функции (free function pointers)

Общий вид:

  • Есть функция:
    Ret f(Args...);
  • Тип указателя:
    Ret (*p)(Args...);

Пример:

#include <iostream>

int add(int a, int b) { return a + b; }
int sub(int a, int b) { return a - b; }

int main() {
int (*op)(int, int); // указатель на функцию int(int, int)

op = &add; // & можно опустить: op = add;
std::cout << op(2, 3) << "\n"; // 5

op = sub;
std::cout << (*op)(5, 2) << "\n"; // 3
}

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

  • Тип должен строго совпадать по возвращаемому типу и параметрам.
  • Вызов через указатель: op(args...) или (*op)(args...).
  • Основные применения:
    • C-style колбэки;
    • таблицы функций (jump tables) для парсинга, обработки команд и т.п.;
    • конфигурируемые стратегии без виртуальных вызовов.
  1. Ссылки на функции (function references)

Ссылка на функцию — некопируемый алиас на функцию (всегда должна быть инициализирована).

Общий вид:

Ret (&ref)(Args...) = f;

Пример:

#include <iostream>

int mul(int a, int b) { return a * b; }

int main() {
int (&mul_ref)(int, int) = mul;
std::cout << mul_ref(3, 4) << "\n"; // 12
}

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

  • Нельзя "перепривязать" на другую функцию после инициализации.
  • Вызов идентичен обычному вызову функции.
  • Полезно в API, когда нужно гарантировать валидный callable без nullptr-ситуаций.
  1. Указатели на методы классов (важное отличие)

Отдельный тип — указатели на нестатические методы:

struct Foo {
void bar(int x) {
std::cout << x << "\n";
}
};

int main() {
void (Foo::*pm)(int) = &Foo::bar; // указатель на метод-член

Foo obj;
(obj.*pm)(42); // вызов через объект

Foo* p = &obj;
(p->*pm)(43); // вызов через указатель
}

Отличия:

  • Тип: Ret (Class::*)(Args...), а не Ret (*)(Args...).
  • Для вызова всегда нужен конкретный объект (this), поэтому синтаксис другой.
  • Нельзя напрямую присвоить указатель на метод-член в указатель на свободную функцию.
  1. Связь с современными средствами: лямбды и std::function
  • Лямбды без захвата могут неявно конвертироваться в указатель на функцию:
    void call(int (*f)(int)) {
    std::cout << f(10) << "\n";
    }

    int main() {
    auto twice = [](int x) { return x * 2; }; // без захвата
    call(twice); // ОК, преобразуется к int (*)(int)
    }
  • Если лямбда захватывает контекст, она уже не совместима с простым указателем на функцию; для таких случаев:
    • используют шаблоны (universal callables),
    • или std::function, который умеет хранить свободные функции, лямбды, функторы, указатели на методы.
  1. Практический контекст (что стоит показать на собеседовании)
  • Понимание, что:
    • указатели/ссылки на функции — низкоуровневый, быстрый механизм;
    • указатели на методы классов — другой тип с другим синтаксисом вызова;
    • std::function — универсальная, но более тяжелая обертка;
    • лямбды — современный удобный способ создания callable-объектов.
  • Умение выбрать:
    • указатель на функцию — когда нужна простая, статически известная сигнатура и минимум overhead;
    • std::function/шаблоны — когда нужна гибкость и поддержка разных видов callable.

Кратко:

  • Да, в C++ есть указатели и ссылки на функции.
  • Они позволяют хранить и передавать адрес исполняемого кода.
  • Отличаются от указателей на методы классов и от std::function как по типу, так и по применению.

Вопрос 15. Какое минимальное требование должен удовлетворять класс, чтобы его объекты можно было хранить в std::set?

Таймкод: 00:20:01

Ответ собеседника: неполный. Говорит, что нужен компаратор для сравнения элементов, но не уточняет требование строгого слабого упорядочивания и роль этого порядка для корректной работы std::set.

Правильный ответ:

Ключевое требование: для типа, хранящегося в std::set, должен быть определен строгий слабый порядок (strict weak ordering), по которому контейнер будет:

  • упорядочивать элементы;
  • определять равенство ключей (отсутствие дубликатов).

Формально:

  • Для std::set<T> по умолчанию используется std::less<T>:
    • то есть требуется, чтобы T был корректно сравним через operator< (или была задана специализация/корректная реализация std::less<T>).
  • Либо вы явно задаете свой компаратор Compare:
    • std::set<T, Compare>;
    • где Compare задает строгое слабое упорядочивание.

Важно: std::set не использует operator== для определения уникальности. Два элемента считаются равными, если:

  • !comp(a, b) && !comp(b, a)

то есть ни один не "меньше" другого по заданному порядку.

Что такое строгое слабое упорядочивание (по сути, что требуется от компаратора):

Компаратор comp(a, b) должен удовлетворять:

  • Антирефлексивность:
    • comp(x, x) всегда false.
  • Транзитивность:
    • если comp(a, b) и comp(b, c) истинны, то comp(a, c) тоже истинен.
  • Согласованность "эквивалентности":
    • если !comp(a, b) и !comp(b, a), то a и b считаются эквивалентными;
    • это отношение эквивалентности тоже должно быть транзитивным.
  • Никаких логических циклов:
    • нельзя допустить comp(a, b), comp(b, c) и comp(c, a) одновременно.

Если эти требования нарушены:

  • дерево std::set логически "ломается":
    • возможны дубликаты, потеря элементов, бесконечные циклы при поиске;
    • это ведет к неопределенному поведению.

Простой пример корректного operator<:

#include <set>
#include <string>

struct User {
int id;
std::string name;
};

bool operator<(const User& a, const User& b) {
if (a.id < b.id) return true;
if (a.id > b.id) return false;
return a.name < b.name;
}

int main() {
std::set<User> s;
s.insert({1, "Alice"});
s.insert({2, "Bob"});
s.insert({1, "Alice"}); // не добавится: эквивалентный ключ
}

Альтернатива с пользовательским компаратором:

struct UserById {
bool operator()(const User& a, const User& b) const {
return a.id < b.id; // строгий порядок только по id
}
};

int main() {
std::set<User, UserById> s;
s.insert({2, "Bob"});
s.insert({1, "Alice"});
s.insert({1, "Charlie"}); // не добавится: по компаратору id совпадает
}

Здесь:

  • уникальность определяется только по id:
    • !(a<b) && !(b<a) истинно при равных id;
    • имя игнорируется.

Типичные ошибки, которые важно избегать:

  • Использовать в сравнении поля, которые меняются после вставки:
    • изменение ключевых полей нарушает инварианты дерева.
  • Писать неконсистентный operator< (нарушение транзитивности или антирефлексивности).
  • Думать, что std::set полагается на ==:
    • фактически уникальность и поиск завязаны на компаратор.

Краткая формулировка для интервью:

  • Минимальное требование: тип должен быть сравним с помощью компаратора, задающего строгое слабое упорядочивание (по умолчанию — корректный operator<), потому что именно этот порядок используется для организации дерева и определения уникальности элементов в std::set.

Вопрос 16. В чем различие между std::map и std::unordered_map в C++?

Таймкод: 00:20:27

Ответ собеседника: правильный. Корректно описывает, что std::map обычно реализован как сбалансированное дерево (красно-чёрное) с O(log N) для вставки и поиска, а std::unordered_map — как хеш-таблица с амортизированным O(1) и возможным ухудшением до O(N) при коллизиях.

Правильный ответ:

Различия между std::map и std::unordered_map принципиальны: отличаются структура данных, требования к ключу, гарантии сложности, поведение итераторов и сценарии применения.

Основные аспекты:

  1. Структура данных и сложность операций
  • std::map:

    • Обычно реализован как самобалансирующееся бинарное дерево поиска (часто красно-чёрное).
    • Ключи хранятся в отсортированном порядке.
    • Сложность:
      • вставка, поиск, удаление: O(log N) гарантированно;
      • итерация по контейнеру: O(N) в отсортированном порядке.
  • std::unordered_map:

    • Реализован как хеш-таблица (bucket’ы + цепочки или открытая адресация — зависит от реализации).
    • Порядок элементов не определён (и может меняться при rehash).
    • Сложность:
      • средняя (амортизированная) вставка, поиск, удаление: O(1);
      • в худшем случае при плохом хеше или множественных коллизиях: O(N).

Вывод:

  • std::map — предсказуемая логарифмическая сложность и отсортированность.
  • std::unordered_map — быстрее в среднем для lookup, но без порядка и с завязкой на качество хеша.
  1. Требования к ключу
  • std::map<Key, T, Compare>:

    • Требует строгое слабое упорядочивание по ключу:
      • по умолчанию std::less<Key> (часто использует operator<).
    • На основании компаратора:
      • строится дерево;
      • определяется уникальность ключей (через !comp(a,b) && !comp(b,a)).
  • std::unordered_map<Key, T, Hash, KeyEqual>:

    • Требует:
      • хеш-функцию (Hash), по умолчанию std::hash<Key>;
      • функцию равенства (KeyEqual), по умолчанию std::equal_to<Key>.
    • Важное требование:
      • если KeyEqual(a, b) == true, то Hash(a) == Hash(b) (иначе поведение неопределено).
    • Качество хеш-функции напрямую влияет на производительность.
  1. Порядок элементов и доступ к данным
  • std::map:

    • Гарантированно упорядочен по ключу.
    • Позволяет эффективные операции:
      • lower_bound, upper_bound, equal_range;
      • поиск по диапазонам ключей (range queries).
    • Удобен, когда важен отсортированный вывод или нужен ближайший/граничащий ключ.
  • std::unordered_map:

    • Порядок:
      • не определён стандартом;
      • может изменяться при вставках/rehash.
    • Нет range-операций по сортированному ключу: все операции — по хешу и равенству.
  1. Итераторы и стабильность
  • std::map:

    • Итераторы — двунаправленные.
    • Вставка/удаление:
      • не инвалидирует остальные итераторы (кроме итераторов на удалённые элементы).
    • Структура дерева стабильна.
  • std::unordered_map:

    • Итераторы — как минимум forward.
    • При rehash:
      • все итераторы (и многие ссылки на элементы) инвалидируются.
    • Частые вставки могут приводить к перераспределениям, что важно учитывать.
  1. Выбор контейнера: когда какой

Использовать std::map, когда:

  • Нужен упорядоченный по ключу контейнер.
  • Важны операции:
    • "найти первый элемент не меньше ключа X";
    • "пройтись по диапазону [L, R)".
  • Важен стабильный детерминированный порядок обхода.
  • Хотите предсказуемую асимптотику, не зависящую от качества хеша.

Использовать std::unordered_map, когда:

  • Порядок ключей не важен.
  • Основной сценарий — частые lookup по точному ключу.
  • Важна максимальная средняя скорость O(1).
  • Есть адекватная хеш-функция:
    • например, для строк, чисел, enum и т.п.
  • Готовы управлять:
    • reserve / rehash для контроля производительности;
    • потенциальным худшим случаем (например, при атакуемых ключах).
  1. Практические моменты и нюансы
  • По памяти:
    • std::unordered_map обычно использует больше памяти (bucket’ы, overhead), чем std::map.
  • По locality:
    • хеш-таблица может быть более cache-friendly при хорошем распределении;
    • дерево — больше разыменований указателей и хуже locality.
  • Для сложных ключей:
    • в std::map достаточно корректного <;
    • в std::unordered_map нужно реализовать и hash, и ==.
  1. Краткая формулировка для интервью
  • std::map:

    • упорядоченное ассоциативное дерево,
    • O(log N) операции,
    • требуется сравнение (<),
    • поддерживает range-операции по ключу,
    • детерминированный порядок.
  • std::unordered_map:

    • хеш-таблица,
    • среднее O(1), худшее O(N),
    • требует корректного hash + equal_to,
    • порядок не гарантируется,
    • предпочтителен для быстрых поисков по ключу без требований к сортировке.

Вопрос 17. Может ли поток в C++ перезапустить сам себя?

Таймкод: 00:21:27

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

Правильный ответ:

Коротко: нет, поток в C++ не может "перезапустить сам себя". Конкретный поток выполнения и связанный с ним std::thread — одноразовые: после завершения их нельзя запустить снова, можно только создать новый поток.

Разберем по сути.

  1. Жизненный цикл std::thread
  • Создание:
    std::thread t(f); // немедленно запускает новый поток, выполняющий f
  • Завершение:
    • когда функция f возвращает (или падает до top-level исключением → std::terminate), связанный системный поток завершается.
  • После завершения:
    • t.joinable() == true до join() или detach();
    • после t.join() или t.detach() объект t больше не связан с потоком.
  • Важно:
    • стандарт НЕ предусматривает операций "restart" или "reset" для уже использованного std::thread.

Пример (что можно и нельзя):

void worker() { /* ... */ }

int main() {
std::thread t(worker);
t.join();

// t.join(); // ошибка: поток уже присоединён
// t = std::thread(worker); // возможно, но это уже новый поток,
// не "перезапуск" старого
}
  1. "Самоперезапуск" потока

Чего нельзя:

  • Текущий поток не может "умирать и возрождаться" в рамках того же std::thread.
  • Нельзя после завершения снова запустить этот же поток выполнения.

Чего можно:

  • Перед завершением поток может создать новый поток:

    void worker(int gen) {
    // ... работа ...
    if (gen < 3) {
    std::thread next(worker, gen + 1);
    next.detach(); // или join в другом месте
    }
    // текущий поток просто заканчивает выполнение
    }
  • Логика может выглядеть как "перезапуск", но по факту:

    • каждый запуск — новый системный поток с новым идентификатором;
    • это не реанимация старого потока, а создание нового.
  1. Правильный способ "перезапустить" работу

Если цель — повторять задачу в рамках одного потока:

  • делаем цикл внутри функции потока:

    void worker() {
    for (;;) {
    // выполнить задачу
    // при необходимости — условия выхода или паузы
    }
    }

    int main() {
    std::thread t(worker);
    t.join();
    }

Если цель — управлять "перезапусками" извне:

  • используем управляющий код:
    • создаем новый std::thread, если предыдущий завершился;
    • возможно — пул потоков, очередь задач.
  1. Почему это важно понимать
  • Модель std::thread и большинства системных API: поток — одноразовый ресурс.
  • "Перезапуск" — всегда явное создание нового потока, а не магия над старым.
  • Попытки проектировать логику как "поток перезапускает сам себя" обычно означают:
    • либо нужно переписать на цикл;
    • либо ввести внешний контроллер/диспетчер.

Краткий ответ для интервью:

  • Уже завершившийся поток (и связанный std::thread) нельзя перезапустить.
  • Поток может перед завершением запустить новый поток, но это будет другой поток.
  • Для повторного выполнения логики используют цикл внутри потока или внешний менеджер, а не "restart" существующего потока.

Вопрос 18. Какие основные виды памяти процесса ты можешь назвать?

Таймкод: 00:25:05

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

Правильный ответ:

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

Классическая модель адресного пространства процесса (упрощенно):

  1. Сегмент кода (text / code segment)
  • Содержит:
    • машинный код программы;
    • обычно также константные данные (литералы строк, константы) — иногда выделяют отдельный read-only segment.
  • Особенности:
    • как правило, доступен только для чтения и исполнения;
    • общий для всех потоков процесса;
    • может быть разделяем между процессами при использовании одной и той же бинарники/библиотек.
  • Практический смысл:
    • защита от записи в код (предотвращает часть классов ошибок);
    • основа для layout-а и профилирования.
  1. Область статических и глобальных данных (data / bss)

Обычно делится логически:

  • Инициализированные глобальные и статические переменные:
    • сегмент data.
  • Неинициализированные (нулевые) глобальные и статические переменные:
    • сегмент bss (инициализируется нулями при старте процесса).

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

  • Время жизни:
    • от старта процесса до его завершения.
  • Доступ:
    • одна копия на процесс;
    • доступна всем потокам (нужна синхронизация при записи).
  • Типичные случаи:
    • глобальные конфиги, счетчики, синглтоны;
    • в C/С++ — область, где легко создать гонки при отсутствии аккуратной синхронизации.
  1. Стек (stack, автоматическая память)
  • Выделяется для каждого потока отдельно.
  • Содержит:
    • кадры вызовов функций (return-адрес, сохраненные регистры);
    • локальные (автоматические) переменные функций;
    • временные данные.
  • Аллокация/освобождение:
    • очень быстрые, по принципу LIFO;
    • управление полностью детерминировано компилятором/ABI: при входе в функцию — расширение стека, при выходе — сворачивание.
  • Особенности:
    • ограниченный размер (stack overflow при глубоких рекурсиях или больших объектах на стеке);
    • переменные живут до выхода из области видимости (функции/блока).
  • Типичные ошибки в C/C++:
    • возврат указателя/ссылки на стековую переменную (dangling reference);
    • чрезмерные стеки для больших буферов.
  1. Куча (heap, динамическая память)
  • Управляется аллокатором (runtime, ОС).
  • Выделение:
    • в C: malloc/free;
    • в C++: new/delete, умные указатели;
    • в Go: все, что "убегает" из стека, уходит в кучу, управляется GC.
  • Особенности:
    • более гибкая, чем стек (объекты живут, пока явно не освобождены или не собраны GC);
    • более дорогие операции (фрагментация, локальность, синхронизация).
  • Типичные ошибки в C/C++:
    • утечки памяти (нет delete);
    • use-after-free (использование после освобождения);
    • double free (повторное освобождение);
    • несогласованное владение (отсутствие ясной модели ownership).
  • Хорошая практика:
    • RAII, умные указатели, пулл аллокаторы, аренами и т.д.
  1. Дополнительные аспекты, которые полезно понимать (кратко)

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

  • Стек каждого потока:
    • отдельная область памяти;
    • важен при работе с многопоточностью и диагностикой.
  • Память под mmap / shared memory:
    • отображения файлов (mmap), общая память между процессами;
    • используется для IPC, memory-mapped файлов, больших буферов.
  • Read-only data segment:
    • строковые литералы, константные таблицы;
    • попытка записи — UB/segfault.
  • TLS (thread-local storage):
    • thread_local переменные в C++:
      • отдельные копии на каждый поток;
      • отдельная область памяти, логически рядом с глобальными/статическими.
  1. Краткая формулировка для собеседования
  • Основные области:
    • сегмент кода (text) — машинный код и константы;
    • статические/глобальные данные (data/bss) — живут весь срок работы процесса;
    • стек — автоматическая память каждого потока (кадры функций, локальные переменные);
    • куча — динамическая память, управляемая аллокатором/рантаймом.
  • Важно понимать их различия по:
    • времени жизни;
    • доступу между потоками;
    • стоимости операций;
    • типичным ошибкам при работе с памятью.