РЕАЛЬНОЕ СОБЕСЕДОВАНИЕ С++ разработчик LestaGames (Tank Blitz) - Middle 200 - 250 тыс.
Сегодня мы разберем динамичное техническое мини-интервью, в котором опытный C++ разработчик из телеком-сферы демонстрирует уверенное владение базовыми концепциями языка, сетевым взаимодействием и системным мышлением. Собеседование проходит в дружелюбной, но структурированной форме: интервьюеры быстро проверяют фундамент, затрагивают работу с памятью, многопоточность и STL, параллельно оценивая потенциал кандидата к более глубокому обсуждению архитектуры и низкоуровневых деталей.
Вопрос 1. Кратко рассказать о своем опыте и ключевых проектах за последние годы: стек, домен, задачи и зона ответственности.
Таймкод: 00:00:47
Ответ собеседника: правильный. Кратко описал около 3 лет опыта в телеком-сфере с C/C++ для десктопа и встраиваемых систем, ключевой проект с модулем взаимодействия радиостанции и ПК по Ethernet (TETRA), реализация софт-имитации консоли, голосовых вызовов и конференций, логирования метрик в БД и автоматизированного стенда тестирования, с сокращением времени проверки более чем в 3 раза, работал под руководством тимлида, отвечал за десктоп и часть встраиваемого софта.
Правильный ответ:
Сильный ответ на такой вступительный вопрос должен не просто перечислять места работы и технологии, а структурированно показать:
- домен (какую бизнес-проблему решали),
- ключевые технические задачи,
- масштаб и сложность систем,
- глубину ответственности и вклад,
- релевантность к текущей позиции (в данном случае — к разработке backend/сервисов, высоконагруженных и распределённых систем, сетевого взаимодействия, надёжности, перформанса).
Пример сильного, структурированного ответа (адаптируй под свой реальный опыт):
-
Опыт и контекст:
- За последние годы работал в области телекоммуникаций и встраиваемых систем.
- Основной стек: C, C++, сетевое программирование (TCP/UDP, Ethernet), протоколы реального времени, взаимодействие с железом, многопоточность, оптимизация по памяти и latency.
- Плотно взаимодействовал с командами тестирования, эксплуатации и системными инженерами, участвовал в полном цикле разработки: от требований и архитектурных решений до отладки на реальном железе и поддержки.
-
Ключевой проект: взаимодействие абонентской радиостанции с ПК по Ethernet (TETRA):
- Задача:
- Обеспечить надёжный канал связи между радиостанцией и ПК через Ethernet, реализовать функционал, аналогичный аппаратной консоли, но в виде софта.
- Что было сделано:
- Реализован программный клиент на ПК, эмулирующий поведение аппаратной консоли:
- установление и поддержание соединения;
- обработка команд управления устройством;
- передача и приём аудио (голосовые вызовы, конференции);
- обработка служебных сообщений и сигнализации.
- Реализовано логирование телеметрии и системных метрик:
- сбор состояния устройства, статистики соединений, ошибок, перезагрузок;
- запись в БД для анализа инцидентов, отладки и оптимизации.
- Реализован программный клиент на ПК, эмулирующий поведение аппаратной консоли:
- Технические акценты:
- Сетевое взаимодействие: реализация протоколов поверх TCP/UDP, контроль целостности, таймауты, реконнекты, защита от зависаний/ресурсных утечек.
- Многопоточность и конкурентный доступ:
- разделение потоков ввода-вывода, обработки сообщений, UI/логики;
- аккуратная синхронизация, избежание гонок и дедлоков.
- Перформанс и надёжность:
- минимизация задержек при передаче аудио;
- устойчивость при обрывах сети и ошибках железа;
- детализированное логирование для диагностики.
- Структура кода:
- модульность, выделение слоёв: транспорт, протокол, доменная логика, UI/интерфейс;
- внимание к читаемости и расширяемости.
- Задача:
-
Автоматизированный стенд тестирования оборудования:
- Цель:
- Уменьшить время регресса и ручного тестирования оборудования, повысить воспроизводимость проверок.
- Вклад:
- Разработка ПО, которое:
- управляет реальным оборудованием и симуляторами;
- запускает набор сценариев (подключения, разрывы, пиковая нагрузка, стресс-тесты);
- собирает результаты, статусы, логи;
- формирует отчёты.
- Достижения:
- сокращение времени тестирования более чем в 3 раза;
- снижение количества человеческих ошибок;
- возможность быстро воспроизвести пограничные сценарии.
- Разработка ПО, которое:
- Технические аспекты:
- Сценарный движок, интеграция с устройствами по протоколам управления;
- логирование в БД (например, PostgreSQL/MySQL/SQLite) для последующего анализа.
- Цель:
-
Роль и ответственность:
- Самостоятельно вел ключевые части функционала:
- десктопное приложение и его архитектуру;
- часть логики на встраиваемых устройствах.
- Работал в связке с тимлидом/архитектором:
- участвовал в обсуждении архитектурных решений;
- предлагал улучшения по перформансу и удобству эксплуатации;
- участвовал в code review, писал техническую документацию.
- Фокус на качестве:
- юнит- и интеграционные тесты;
- анализ логов и метрик;
- профилирование и устранение узких мест.
- Самостоятельно вел ключевые части функционала:
-
Связка с backend/Go-разработкой (важно для текущей вакансии):
- Опыт сетевого и системного программирования, работы с протоколами, многопоточностью, диагностикой и логированием напрямую переносится в:
- разработку высоконагруженных и отказоустойчивых сервисов;
- проектирование API и протоколов;
- оптимизацию по ресурсам и latency;
- построение надежных средств мониторинга и тестовой инфраструктуры.
- Опыт сетевого и системного программирования, работы с протоколами, многопоточностью, диагностикой и логированием напрямую переносится в:
Если хочешь краткий формат для интервью:
- Домен: телеком, встраиваемые системы, real-time взаимодействие.
- Задачи: сетевое взаимодействие (Ethernet, собственные протоколы), голосовая связь, логирование и мониторинг, автоматизация тестирования.
- Вклад: разработка ключевых модулей, улучшение тестового контура (3x ускорение), работа с перформансом и надежностью.
- Релевантность: сильная база для разработки сервисов, работы с сетевыми протоколами, высоконадежных систем и инфраструктуры на Go.
Вопрос 2. Что такое виртуальная функция в C++ и зачем она нужна?
Таймкод: 00:06:45
Ответ собеседника: неправильный. Дал неточное определение, перепутав виртуальность с внешней реализацией функции, частично связал с наследованием и полиморфизмом, но не раскрыл механизм позднего связывания и роль переопределения.
Правильный ответ:
Виртуальная функция в C++ — это метод базового класса, который объявлен с ключевым словом virtual и предназначен для переопределения в производных классах. Ее основная цель — обеспечить динамический полиморфизм, то есть выбор конкретной реализации метода во время выполнения (runtime), исходя из реального типа объекта, а не типа указателя/ссылки.
Ключевые моменты:
- Зачем нужны виртуальные функции
- Без виртуальных функций:
- При вызове метода через указатель/ссылку на базовый класс используется статическое (раннее) связывание.
- Вызывается метод, определенный в типе указателя (базовый класс), даже если фактически объект является экземпляром производного класса.
- С виртуальными функциями:
- Включается динамическое (позднее) связывание: вызывается реализация метода того класса, объект которого реально находится в памяти.
- Это и есть основа полиморфного поведения: один интерфейс — разные реализации.
- Базовый пример
Без виртуальной функции:
#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;
}
- Как это работает (глубже, с точки зрения реализации)
Компилятор обычно реализует виртуальные функции через:
- vtable (virtual table):
- Для каждого полиморфного класса создается таблица указателей на виртуальные функции.
- vptr:
- В каждом объекте полиморфного класса (т.е. класса с хотя бы одной виртуальной функцией) хранится скрытый указатель на vtable.
- При вызове виртуальной функции:
- Компилятор генерирует код, который:
- читает vptr объекта,
- находит нужную функцию в vtable,
- вызывает ее.
- Компилятор генерирует код, который:
- Это позволяет в runtime выбрать правильную реализацию на основе реального типа объекта.
Важно:
- Механизм — реализация-компиляторная деталь, но понимание концепции vtable/vptr полезно для:
- отладки;
- оценки стоимости виртуальных вызовов;
- понимания ограничений при использовании полиморфизма.
- Ключевые свойства и нюансы
- Объявление:
- Виртуальной функция становится при объявлении в базовом классе с
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.
- В реальных системах обычно это приемлемая цена за гибкость.
- Что виртуальная функция НЕ означает
- Не значит "функция, реализованная вне класса". Вынесение реализации за пределы определения класса — синтаксический прием, не связанный с виртуальностью.
- Не про перегрузку (overload), а про переопределение (override).
- Не заменяет продуманную архитектуру: виртуальные функции — один из инструментов для организации полиморфных интерфейсов.
- Связь с практикой (важно для сильного кандидата)
-
Виртуальные функции используются для:
- проектирования расширяемых модулей (плагины, адаптеры, драйверы);
- реализации интерфейсных слоев и абстракций над различными источниками данных / протоколами;
- unit-тестирования (моки и стабы через наследование);
- реализаций протоколов/обработчиков событий, когда поведение зависит от конкретного типа.
-
Важно:
- Осознавать, когда полиморфизм по наследованию оправдан, а когда лучше использовать композицию, шаблоны или другие механизмы (в т.ч. в Go — интерфейсы и внедрение зависимостей через них).
Такое объяснение показывает не только знание термина, но и понимание механизма, практических trade-off’ов и архитектурного контекста.
Вопрос 3. Что такое перегрузка функции в C++ и как она работает?
Таймкод: 00:07:26
Ответ собеседника: правильный. Корректно описал, что перегрузка — это использование одной и той же функции с одним именем, но разными параметрами, в том числе по типам аргументов.
Правильный ответ:
Перегрузка функции в C++ — это возможность объявлять несколько функций с одним и тем же именем в одной области видимости, при этом они должны отличаться сигнатурами (набором типов и/или количеством параметров). Конкретная функция выбирается на этапе компиляции по правилам разрешения перегрузки.
Важно: перегрузка — это механизм статического полиморфизма (compile-time), в отличие от виртуальных функций, которые обеспечивают динамический полиморфизм (runtime).
Основные правила и детали:
- Что считается различием сигнатур
Функции можно перегружать, если отличаются:
- количество параметров;
- типы параметров;
- порядок параметров разных типов.
Нельзя перегружать только по:
- возвращаемому типу (он не участвует в разрешении перегрузки);
- именам параметров;
- модификаторам
typedef/using, которые разворачиваются в одинаковый тип.
Примеры допустимых перегрузок:
void print(int x);
void print(double x);
void print(const std::string& s);
void print(int x, int base);
- Как компилятор выбирает перегрузку (overload resolution)
Алгоритм (упрощенно):
- Собираются все функции с подходящим именем в данной области видимости.
- Отфильтровываются те, у которых количество/типы параметров не совместимы с фактическими аргументами.
- Для оставшихся оценивается "качество" преобразований типов:
- точное совпадение типов;
- promotions (int -> long, float -> double и т.п.);
- стандартные преобразования;
- пользовательские преобразования.
- Выбирается наилучшее совпадение.
- Если есть несколько одинаково "хороших" вариантов — ошибка неоднозначности.
Пример:
void foo(int x);
void foo(double x);
foo(10); // вызывает foo(int)
foo(3.14); // вызывает foo(double)
- Роль константности, ссылок и членов класса
Перегрузка учитывает также:
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, и вызов зависит от того, константный ли объект.
- Перегрузка и параметры по умолчанию
Параметры по умолчанию работают совместно с перегрузкой, но могут приводить к неоднозначности.
Плохой пример:
void log(const std::string& msg, int level = 1);
void log(const std::string& msg);
log("test"); // неоднозначность: какая функция?
Лучше избегать комбинирования перегрузки и параметров по умолчанию в конфигурациях, ведущих к конфликтам.
- Чем перегрузка отличается от:
- Переопределения (override):
- Переопределение — в иерархии наследования, сигнатура совпадает, ключевое слово
override(и виртуальные функции). - Перегрузка — в одной области видимости / в классе, но разные сигнатуры.
- Переопределение — в иерархии наследования, сигнатура совпадает, ключевое слово
- Шаблонов:
- Шаблонная функция может быть частью набора перегрузок.
- Компилятор подбирает специализацию шаблона и сравнивает ее с обычными функциями при разрешении перегрузки.
Пример смешанной перегрузки:
void process(int x);
template<typename T>
void process(T x);
process(10); // предпочтительно выберет void process(int)
process(3.14); // выберет шаблонную process<double>
- Практические рекомендации
- Перегрузка полезна, когда:
- Операция концептуально одна и та же, но работает с разными типами (например
print,add,parse). - Нужно улучшить читаемость API: одно имя — единое понятное действие.
- Операция концептуально одна и та же, но работает с разными типами (например
- Но:
- Не стоит перегружать функции так, чтобы выбор становился неочевидным.
- Следи за тем, чтобы сигнатуры были достаточно различимыми, избегай скрытых преобразований, создающих неоднозначность.
Это понимание показывает, что перегрузка — не просто «одинаковое имя», а четкий механизм compile-time выбора реализации по набору параметров, с тонкостями, влияющими на дизайн API и читаемость кода.
Вопрос 4. Что такое переопределение виртуальной функции в C++ и как оно используется в наследуемых классах?
Таймкод: 00:08:07
Ответ собеседника: неполный. Смешал переопределение с затенением (shadowing) функции в локальном файле, затем после подсказки описал идею переопределения в наследнике, но без акцента на роль virtual, точное совпадение сигнатур и использование override.
Правильный ответ:
Переопределение виртуальной функции (override) в C++ — это механизм, при котором производный класс предоставляет собственную реализацию функции, объявленной как виртуальная в базовом классе. Цель — обеспечить корректное полиморфное поведение при работе через указатели/ссылки на базовый тип: вызывается реализация, соответствующая реальному типу объекта в runtime.
Ключевые моменты:
- Условия корректного переопределения
Чтобы функция в наследуемом классе действительно переопределяла виртуальную функцию базового класса, должны выполняться условия:
- В базовом классе метод объявлен как
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) во время выполнения.
- Динамический полиморфизм и vtable
Переопределение виртуальных функций — основа динамического полиморфизма:
- У полиморфного класса есть vtable (таблица виртуальных функций).
- У каждого объекта полиморфного класса есть скрытый указатель
vptrна vtable. - При переопределении:
- соответствующая запись в vtable производного класса указывает на новую реализацию.
- При вызове
b->Print():- по
vptrобъекта выбирается нужная функция. - Именно поэтому используется реальный тип объекта, а не тип указателя.
- по
Это позволяет:
- описать поведение через интерфейс базового класса;
- подменять реализации без изменения кода, который работает с базовым типом.
- Отличие от перегрузки и затенения
Важно явно различать три понятия:
- Переопределение (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
};
- Виртуальные деструкторы и их переопределение
Класс, который предполагается использовать полиморфно (через указатель/ссылку на базу), должен иметь виртуальный деструктор:
class Base {
public:
virtual ~Base() = default;
};
class Derived : public Base {
public:
~Derived() override {
// Освобождение ресурсов
}
};
- Это тоже пример переопределения виртуальной функции.
- При
delete basePtr;вызовется деструкторDerived, затемBase. - Без виртуального деструктора возможны утечки и неопределенное поведение.
- Чисто виртуальные функции и абстрактные классы
Переопределение часто используется с чисто виртуальными функциями:
class Shape {
public:
virtual void Draw() const = 0; // Чисто виртуальная
};
class Circle : public Shape {
public:
void Draw() const override {
// Реализация рисования круга
}
};
- Базовый класс задает контракт (интерфейс).
- Производные классы обязаны переопределить эти функции.
- Работа ведется через указатель/ссылку на базовый тип
Shape*, а реализация выбирается по реальному типу.
- Практический смысл
Переопределение виртуальных функций используется когда:
- Есть общий интерфейс (базовый тип), но разные реализации:
- обработчики протоколов,
- различные реализации хранилищ,
- драйверы устройств,
- стратегии поведения.
- Хотим писать код, не завязанный на конкретные реализации:
- принимать и возвращать указатели/ссылки на базовый тип;
- подсовывать разные реализации в рантайме (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.
- Связано с наследованием и иерархиями типов.
- Даёт гибкость, но имеет накладные расходы и ограничения.
Ключевые отличия (по сути):
-
Момент разрешения вызова:
- Перегрузка:
- Выбор реализации на этапе компиляции.
- Зависит только от статических типов аргументов.
- Виртуальные функции:
- Выбор реализации в runtime.
- Зависит от реального типа объекта (динамический тип).
- Перегрузка:
-
Основа механизма:
- Перегрузка:
- Чисто синтаксический/типовой механизм.
- Не требует наследования.
- Виртуальные:
- Требуют наследования и объявления
virtual. - Используют vtable/vptr (в типичных реализациях компилятора).
- Требуют наследования и объявления
- Перегрузка:
-
Назначение:
- Перегрузка:
- Один концепт — разные варианты для разных типов/сигнатур.
- Улучшение удобства API.
- Пример: несколько версий
Print,Parse,Add.
- Виртуальные:
- Контракт/интерфейс через базовый класс.
- Возможность подменять реализацию в зависимости от типа объекта.
- Пример: разные реализации
Storage,Transport,Handler.
- Перегрузка:
-
Взаимоотношения с наследованием:
- Перегрузка:
- Может существовать вообще без наследования.
- Виртуальные функции:
- Смысл есть только в контексте наследования (полиморфный базовый класс).
- Перегрузка:
-
Влияние на производительность:
- Перегрузка:
- Нет дополнительной стоимости; все решения приняты на этапе компиляции.
- Виртуальные:
- Небольшая, но реальная цена: косвенный вызов, 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» отношением).
- Базовые уровни доступа в классе
В самом базовом классе:
public— доступен везде, где виден объект/тип;protected— доступен внутри этого класса и его потомков;private— доступен только внутри самого класса (не наследуется по доступу).
- Типы наследования
Тип наследования влияет на то, КАК унаследованные 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».
- Важные нюансы
- Private-члены базового:
- Никогда не меняют уровень доступа в наследнике.
- Непосредственно не доступны в производном классе (только через
public/protectedинтерфейс базового).
- Доступ к базовому типу снаружи:
- При public-наследовании:
- допустимо неявное приведение
Derived*→Base*; - это основа для полиморфизма.
- допустимо неявное приведение
- При protected/private-наследовании:
- такие преобразования запрещены снаружи;
- это ломает «is-a» отношение на уровне интерфейса.
- При public-наследовании:
- Для структур (
struct) по умолчанию:- наследование без модификатора считается
public; - для
class— по умолчаниюprivate.
- наследование без модификатора считается
- Для полиморфизма и интерфейсов:
- Используем почти всегда
publicнаследование. protectedиprivate— инструмент инкапсуляции и реализации, а не полиморфных интерфейсов.
- Используем почти всегда
- Краткая формулировка для собеседования
- 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() {}
};
- 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» наследование, базовый интерфейс открыт.
- Объект
- 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-наследования - Базовый интерфейс скрыт от внешнего кода, доступен только внутри иерархии.
- Нельзя вызвать
- 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полностью инкапсулирован как деталь реализации.
- Ни
- Ключевые выводы
- Private-члены базового класса:
- Никогда не становятся доступны напрямую в потомках, вне зависимости от типа наследования.
- Тип наследования управляет не тем, что можно вызвать внутри производного (там public/protected базового доступны при public/protected/private наследовании), а тем, в каком виде унаследованные члены представлены как часть интерфейса производного класса:
- public-наследование:
public→ publicprotected→ protected
- protected-наследование:
public→ protectedprotected→ protected
- private-наследование:
public→ privateprotected→ private
- public-наследование:
- Для полиморфизма, интерфейсов и нормального «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, вызов без объекта
}
Ключевые особенности:
- Отсутствие
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; // доступ через явный объект
}
- Нельзя напрямую обращаться к нестатическим (обычным) полям/методам:
- Общие для всех экземпляров
- Статические методы часто работают со статическими полями:
- счётчики объектов;
- кэш, конфигурация, shared ресурсы;
- фабричные методы (factory methods).
- Статическое поле существует в одном экземпляре на класс, а не на объект.
- Статический метод — естественная точка доступа к этому общему состоянию.
- Вызов без создания объекта
- Идиоматичный вызов:
ClassName::StaticMethod(); - Можно вызывать и через объект, но это считается плохим стилем, так как вводит в заблуждение:
A a;
a.foo(); // так можно, но лучше A::foo();
- Связь с инкапсуляцией и архитектурой
Статические методы полезны, когда:
- операция логически относится к сущности (классу), но не требует конкретного экземпляра:
- парсинг/валидаторы:
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_;
};
- Ограничения и нюансы
- Не виртуальные:
- Статические методы не могут быть
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).
- В отличие от статических полей, для них не нужно отдельное определение вне класса (если тело задано внутри класса).
- Практический контекст
Сильный ответ подразумевает:
- Четкое понимание, что статический метод:
- часть интерфейса типа, а не конкретного объекта;
- не имеет
this; - не участвует в виртуальном полиморфизме;
- не может работать с нестатическими полями без явного объекта.
- Умение применять:
- для фабрик, счетчиков, служебных операций;
- без превращения всего в «god static» (соблюдая принципы тестируемости и инверсии зависимостей).
Краткая формулировка для интервью:
- Статический метод принадлежит классу, а не объекту, вызывается как
Class::Method(), не имеетthis, работает только со статическими членами (или с объектами, переданными явно), не может быть виртуальным и не участвует в динамическом полиморфизме.
Вопрос 10. Что обозначает this в методах класса C++?
Таймкод: 00:15:48
Ответ собеседника: правильный. Корректно определил this как указатель на текущий объект, с которым работает метод, аналогично self в Python.
Правильный ответ:
this в C++ — это неявный указатель на текущий объект, доступный внутри нестатических методов класса. Через него компилятор реализует доступ к полям и методам конкретного экземпляра.
Ключевые моменты:
- Тип
this
- В обычном (неконстантном) методе:
- тип
this—ClassName*.
- тип
- В
const-методе:- тип
this—const 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*
}
};
- Роль
thisв доступе к членам
- Любое обращение к полю или методу экземпляра внутри метода неявно идет через
this:class A {
int x;
public:
void set(int v) {
this->x = v; // эквивалентно x = v;
}
}; - Явное использование
this->бывает нужно:- при работе с шаблонами и зависимыми базовыми классами;
- для повышения читаемости или разрешения конфликтов имен.
- Нельзя менять
this
- Сам указатель
thisнеизменяем (по сутиA* constилиconst A* const):- нельзя сделать
this = &other;. - можно менять состояние объекта через
*this(если метод неconst).
- нельзя сделать
thisи цепочки вызовов / fluent API
Частый паттерн — возвращать ссылку на себя через *this:
class Builder {
public:
Builder& SetX(int) {
// ...
return *this;
}
Builder& SetY(int) {
// ...
return *this;
}
};
Builder b;
b.SetX(1).SetY(2);
- Ограничения
- В статических методах
thisнет:- они не привязаны к объекту;
- попытка использовать
thisв static-методе — ошибка.
- В конструкторах и деструкторах
thisесть:- можно ссылаться на текущий объект;
- но важно помнить:
- в конструкторе базового класса/части объект ещё не полностью построен;
- в деструкторе — уже разрушаются производные части, виртуальные вызовы работают по типу текущего уровня.
- Практический аспект
Понимание this критично для:
- корректной работы с
const-методами; - реализации fluent-интерфейсов;
- шаблонов (CRTP, зависимые имена);
- написания безопасного кода в конструкторах/деструкторах, где
thisесть, но реальный жизненный цикл объекта нужно учитывать.
Кратко:
this— неявный указатель на текущий объект внутри нестатического метода.- Через него осуществляется доступ к полям и методам экземпляра.
- В
const-методахthisуказывает на константный объект, что гарантирует отсутствие модификации состояния (за исключениемmutableполей).
Вопрос 11. Можно ли вызвать метод базового класса, если есть класс-наследник?
Таймкод: 00:16:21
Ответ собеседника: неполный. Утвердил, что вызвать метод базового класса можно, но не показал, как именно это делается через область видимости базового класса, и не пояснил отличия для виртуальных и невиртуальных методов.
Правильный ответ:
Да, метод базового класса можно явно вызвать из производного класса (и иногда снаружи), даже если он был переопределен. Важно понимать:
- как использовать синтаксис с указанием области видимости базового класса;
- как это работает для виртуальных и невиртуальных методов;
- как влияют тип наследования и уровень доступа.
- Вызов метода базового класса из кода производного
Классический пример:
#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, выбранной на этапе компиляции.
- Вызов через объект/указатель/ссылку базового типа
Если у нас есть объект-наследник, но мы смотрим на него как на базовый тип:
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()), чтобы сохранять полиморфизм.
- Отличия для виртуальных и невиртуальных методов
- Невиртуальный метод:
- Всегда статически связан по типу выражения.
- Если в наследнике объявлен метод с тем же именем (shadowing), то:
obj.method();в контексте наследника вызовет версию из наследника;Base::method();(изнутри наследника) — версию базового.
- Виртуальный метод:
- При обычном вызове через базовый указатель/ссылку — динамический диспетч через vtable.
- Явный вызов
Base::method()(через квалификацию области видимости) отключает полиморфизм и вызывает реализацию базового класса напрямую.
- Влияние типов наследования и доступа
- При public-наследовании:
- Публичные методы базового класса доступны снаружи через интерфейс наследника (если не скрыты).
- Внутри наследника всегда можно вызвать
Base::method()при наличии доступа.
- При protected/private-наследовании:
- Доступ к базовому интерфейсу снаружи ограничен.
- Но внутри производного класса (и его потомков при protected) можно использовать
Base::method()при соблюдении правил доступа.
- Если метод базового
private:- Он недоступен в производном классе напрямую, и вызвать его из наследника нельзя (кроме как через публичные/защищённые обертки базового).
- Типичный практический кейс: расширение поведения
Часто при переопределении виртуальной функции нужно добавить логику, не теряя поведение базового класса:
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 для расширения, а не полного замещения поведения.
- Краткий ответ для собеседования
- Да, можно.
- Изнутри производного класса:
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_ptrstd::shared_ptrstd::weak_ptr
Понимание семантики владения, стоимости и типичных ошибок — критично.
- 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. - Явно показывает "один владелец" на уровне типов.
- 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.
- Два объекта с
- 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";
}
}
- Практические рекомендации (важное для сильного ответа)
- По умолчанию:
- Используй
std::unique_ptrвезде, где возможно.
- Используй
std::shared_ptr:- Только когда объект реально должен иметь нескольких равноправных владельцев с неявно согласованным временем жизни.
- Осознавай накладные расходы: атомарные операции, контрольный блок, возможные кеш-промахи.
std::weak_ptr:- Всегда, когда нужно "смотреть" на объект с shared-владением, не продлевая его жизнь.
- Обязательно при потенциальных циклах ссылок (например, parent/child связи).
- Никогда:
- Не используй
new/deleteнапрямую в прикладном коде без крайней необходимости. - Не храни сырые указатели как владельцев ресурса, если есть альтернатива в виде умных указателей / RAII.
- Не используй
- Кратко, в формате для интервью
unique_ptr:- Единственный владелец.
- Перемещаемый, не копируемый.
- По умолчанию, минимальная цена.
shared_ptr:- Разделяемое владение, счетчик ссылок.
- Удобен, но дороже, требует аккуратности (особенно с циклами).
weak_ptr:- Невладеющая ссылка на объект из
shared_ptr. - Не продлевает время жизни, используется для избежания циклов и безопасных проверок "объект еще жив?".
- Невладеющая ссылка на объект из
Вопрос 13. Можно ли в деструкторе объекта создавать новые объекты и что будет, если из деструктора выбрасывается исключение?
Таймкод: 00:18:23
Ответ собеседника: правильный. Отмечает, что создание объектов в деструкторе формально не запрещено, но сомнительно с точки зрения целесообразности; правильно указывает, что исключения не должны выходить за пределы деструктора, иначе при разрушении во время обработки другого исключения будет вызван std::terminate, и упоминает проблему нескольких исключений.
Правильный ответ:
Вопрос проверяет понимание гарантий исключений, поведения в фазе разрушения объектов и архитектурной аккуратности.
- Можно ли создавать объекты в деструкторе?
Формально — да.
Деструктор — это обычная функция-член со специальным именем и особыми правилами вызова, но:
- Внутри него можно:
- создавать локальные объекты (на стеке),
- выделять память (
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 и стараться не бросать исключения.
- Что если из деструктора выбрасывается исключение?
Ключевой принцип: деструкторы не должны допускать "утечку" исключений наружу.
В стандарте:
- Начиная с 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-режиме с подавлением ошибок, либо требует от вызывающего явного вызова.
- Сводка ключевых моментов (то, что ожидают услышать на собеседовании)
- Создавать объекты внутри деструктора можно — это не нарушает правила языка.
- Но деструктор должен быть:
- максимально простой,
- по возможности
noexcept, - без логики, которая легко может бросить исключение или зависнуть.
- Исключения из деструктора:
- не должны выходить наружу;
- при выбросе во время раскрутки стека по другому исключению →
std::terminate; - в современном C++ деструктор по умолчанию
noexcept, так что любое "утекшее" исключение — прямой путь к аварийному завершению.
- Если нужно сообщить об ошибках при cleanup:
- используем явные методы (типа
Close()), которые вызываются до разрушения объекта; - внутри деструктора максимум логируем/гасим исключения.
- используем явные методы (типа
Такой ответ показывает знание не только синтаксиса, но и семантики исключений, RAII и требований к надежному production-коду.
Вопрос 14. Существуют ли указатели на функции и ссылки на функции в C++?
Таймкод: 00:19:39
Ответ собеседника: правильный. Кратко подтвердил наличие указателей и ссылок на функции, но без примеров и пояснения применения.
Правильный ответ:
Да, в C++ существуют и указатели на функции, и ссылки на функции. Это базовый инструмент для передачи поведения (callable-объектов) в функции, реализации колбэков, стратегий, таблиц диспетчеризации и т.д. В современном коде они конкурируют и дополняются лямбдами, std::function и шаблонными параметрами.
Важно понимать:
- синтаксис объявления;
- как вызывать через них функции;
- чем указатели и ссылки на функции отличаются от
std::functionи лямбд.
- Указатели на функции
Указатель на функцию хранит адрес функции с определенной сигнатурой.
Общий вид:
- Для функции:
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-интеграция (колбэки в С-библиотеки).
- Ссылки на функции
Ссылка на функцию — это алиас на функцию, который нельзя переназначить после инициализации.
Общий вид:
- Для функции:
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
}
Особенности:
- Всегда должна быть инициализирована при объявлении.
- Нельзя "перепривязать" на другую функцию.
- Вызов синтаксически идентичен обычному вызову функции.
- Указатели на функции-члены (важное отличие)
Отдельная категория — указатели на нестатические методы класса. Они отличаются от обычных указателей на функции:
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...).
- Современные альтернативы и контекст
В 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 без захвата -> указатель на функцию
}
- Краткая формулировка для собеседования
- Да, в 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<для вашего типа; - либо вы должны предоставить корректный компаратор (функтор, лямбда, функциональный объект), который задает строгое слабое упорядочивание для объектов этого класса.
Важно не просто «уметь сравнивать», а удовлетворять именно требованиям к порядку.
- Что такое строгий слабый порядок (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).
- Минимальное требование в формулировке для собеседования
Для хранения объектов типа T в std::set<T> необходимо:
- наличие корректного строго слабого порядка для
T:- либо через
bool operator<(const T&, const T&), - либо через пользовательский компаратор, переданный в
std::set<T, Compare>.
- либо через
- Примеры
Простой пример с 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определяется не==, а отношением порядка.
- Типичные ошибки
- Определить
operator<, который зависит от изменяемых полей:- Если изменить поле, участвующее в сравнении, после вставки в
std::set, структура дерева ломается логически (нарушается инвариант), поведение — неопределенное.
- Если изменить поле, участвующее в сравнении, после вставки в
- Делать неконсистентный компаратор:
- Например, нарушать транзитивность или смешивать несколько критериев без логики.
- Ориентироваться только на
==:std::setне используетoperator==для уникальности, только компаратор.
- Краткий ответ
Минимальное требование:
- Тип должен быть сравним с помощью компаратора, задающего строгое слабое упорядочивание (по умолчанию — корректно определенный
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 — ассоциативные контейнеры, но с принципиально разной моделью данных, сложностью операций, требованиями к ключам и гарантиями.
Основные отличия:
- Структура данных и сложность операций
-
std::map:
- Обычно реализован как самобалансирующееся бинарное дерево поиска (часто красно-черное).
- Ключи хранятся в отсортированном порядке.
- Сложность операций:
- поиск, вставка, удаление: O(log N);
- обход: in-order, упорядоченный по ключам, O(N).
- Гарантированная асимптотика (без завязки на хеш-функцию).
-
std::unordered_map:
- Реализован как хеш-таблица (bucket + цепочки/открытая адресация, зависит от реализации).
- Элементы не упорядочены по ключу.
- Сложность операций:
- среднее (амортизированное) для поиска, вставки, удаления: O(1);
- в худшем случае (много коллизий, плохой хеш): O(N).
- При перераспределении бакетов (rehash) итераторы инвалидируются.
- Требования к типу ключа
-
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).
- если
- Важно обеспечить:
- равномерное распределение хеша;
- отсутствие "плохих" хешей, создающих много коллизий.
- Требуется:
- Порядок элементов и итерация
-
std::map:
- Элементы всегда отсортированы по ключу.
- Итерация:
- по возрастанию (или в соответствии с компаратором).
- Это:
- позволяет эффективные range-запросы (lower_bound, upper_bound, equal_range);
- удобно для задач, где важен порядок ключей.
-
std::unordered_map:
- Не гарантирует порядок:
- порядок может зависеть от хешей, количества бакетов, rehash.
- Нельзя полагаться на стабильный порядок итерации между запусками/версиями.
- Нет "диапазонных" операций по ключевому порядку.
- Не гарантирует порядок:
- Итераторы и операции над ними
-
std::map:
- Итераторы:
- двунаправленные;
- остаются валидными при большинстве вставок/удалений (кроме удаления самого элемента).
- Структура дерева стабильна.
- Итераторы:
-
std::unordered_map:
- Итераторы:
- как минимум forward;
- инвалидируются при rehash (который возможен при вставках).
- При изменении load factor / резервировании необходимо учитывать инвалидирование.
- Итераторы:
- Выбор между std::map и std::unordered_map (важно уметь аргументировать)
Использовать std::map, когда:
- Нужен упорядоченный по ключу контейнер:
- вывод в отсортированном виде;
- поиск по диапазонам ключей;
- операции типа "найти первый >= X".
- Важно стабильное поведение и гарантированная O(log N), независимая от хеш-функции.
- Количество элементов не слишком велико, а читаемость/детерминизм важнее максимальной скорости.
Использовать std::unordered_map, когда:
- Нужен быстрый доступ по ключу:
- lookup-запросы в среднем O(1) критичны;
- порядок неважен.
- Большие объемы данных и доступ по ключу — основной сценарий.
- Есть хорошая хеш-функция для ключей.
- Готовы контролировать:
reserve/rehashдля снижения количества аллокаций и коллизий;- потенциальный worst-case.
- Краткая формулировка для собеседования
-
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 и конкретного системного потока.
Разберем по шагам.
- Модель
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 (с новым потоком),
// но это уже другой поток, а не "перезапуск" старого
- Нельзя "запустить" этот же
- Может ли поток «перезапустить сам себя»?
Под «перезапустить сам себя» иногда ошибочно подразумевают:
- завершить выполнение и снова начать с той же функции;
- или как-то пересоздать себя, не выходя за пределы текущего потока.
В стандартном 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, это не один и тот же поток.
- Причины и последствия
Почему нет самоперезапуска:
- Модель
std::thread:- однозначное владение ресурсом потока;
- отсутствие состояния "готов снова стартовать".
- Системный поток:
- после завершения его стек и ресурсы освобождаются;
- "оживить" его нельзя без создания нового потока.
Если нужен механизм повторного выполнения:
- используем цикл внутри одного потока:
void worker() {
for (;;) {
// работа
// при необходимости "перезапуска логики" просто возвращаемся к началу цикла
}
}
- или внешний контроллер, который:
- запускает новый поток, когда предыдущий завершился;
- но это уже управление жизненным циклом, а не "самоперезапуск".
- Что важно проговорить на интервью
Ожидаемый сильный ответ:
- Нельзя "перезапустить" уже завершившийся поток ни самим собой, ни через тот же
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();
};
- 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 не наследуются по доступу (недоступны напрямую в потомке).
- Это основной вид наследования для полиморфных иерархий, интерфейсов и общего контракта.
- Protected-наследование
class DerivedProtected : protected Base {
// Base::pub -> protected в DerivedProtected
// Base::prot -> protected в DerivedProtected
// Base::priv -> недоступен
};
Особенности:
- Базовый интерфейс скрыт от внешнего кода:
- снаружи
DerivedProtectedбольше не «является»Baseв терминах public API; - неявное приведение
DerivedProtected*→Base*запрещено вне иерархии.
- снаружи
- Уровни доступа внутри иерархии:
- и бывшие public, и protected базового — доступны как protected в наследнике;
- доступны в самом наследнике и его производных.
- Используется, когда базовый класс — деталь реализации, доступная только наследникам, но не внешнему миру.
- 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".
- Применяется, когда нужно переиспользовать реализацию базового, не раскрывая его интерфейс.
- Важные нюансы
- Private-члены базового:
- Никогда не становятся доступны напрямую в наследнике (независимо от вида наследования).
- Доступ к ним возможен только через public/protected методы базового.
- По умолчанию:
- Для
class Derived : Base— наследование private. - Для
struct Derived : Base— наследование public.
- Для
- Для полиморфизма и интерфейсов:
- корректный выбор — почти всегда public-наследование;
- protected/private-наследование применяют для инкапсуляции и реализации, а не для внешних контрактов.
- Краткая формулировка для интервью
- 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() {}
};
- Public-наследование
class DerivedPublic : public Base {
public:
void test() {
pub(); // доступен, остается public-членом интерфейса DerivedPublic
prot(); // доступен как protected
// priv(); // недоступен
}
};
- Внутри DerivedPublic:
pub()иprot()доступны.
- Снаружи:
pub()остается доступным:DerivedPublic d;
d.pub(); // OKDerivedPublicможно неявно привести кBase*/Base&.
- Это классическое «is-a» наследование: интерфейс базового виден пользователям наследника.
- 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 и его наследники).
- 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".
- Важные акценты
- 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; - может напрямую работать только со статическими членами класса и переданными параметрами.
По сути, это "свободная" функция, помещённая в пространство имён класса для лучшей инкапсуляции и выразительности интерфейса.
Основные особенности:
- Отсутствие
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; // допустимо через явную ссылку/указатель
}
- нельзя обращаться к нестатическим полям/методам напрямую:
- Общность для всех экземпляров
Статический метод естественным образом работает:
- с общим состоянием класса — статическими полями;
- с функциональностью, которая не зависит от конкретного экземпляра, но логически относится к типу.
Пример:
#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
}
- Вызов без создания объекта
Правильный, читаемый вариант:
ClassName::StaticMethod(args);
Вызов через объект:
ClassName obj;
obj.StaticMethod(); // синтаксически разрешено, но вводит в заблуждение
Лучше всегда подчеркивать принадлежность методу классу, а не экземпляру.
- Нельзя сделать виртуальным
- Статический метод не может быть
virtual:- виртуальность основана на динамическом выборе реализации через
thisи vtable; - у статического метода нет
this, поэтому он не участвует в динамическом полиморфизме.
- виртуальность основана на динамическом выборе реализации через
- Если в наследнике объявить статический метод с тем же именем, он не переопределяет, а затеняет (hides) метод базового класса:
struct Base {
static void f();
};
struct Derived : Base {
static void f(); // не override, а hiding
};
- Типичные применения
Статические методы уместны, когда:
- Операция концептуально относится к типу, а не конкретному объекту:
- фабричные методы:
class Connection {
public:
static Connection Create(const std::string& addr);
}; - валидация и утилиты:
IpAddress::Parse,User::ValidateName.
- фабричные методы:
- Нужен доступ к статическому состоянию:
- глобальные счетчики, конфигурация, кеши (с оговоркой по архитектуре).
- Требуется "single point of entry" к определенной функциональности, связанной с типом.
При этом важно не превращать статические методы в антипаттерн "глобальное всё":
- избегать избыточных глобальных синглтонов и скрытых зависимостей;
- помнить о тестируемости и инверсии зависимостей (в ключевых местах вместо статик-методов лучше явные объекты и интерфейсы).
- Краткая формулировка для интервью
- Статический метод объявляется с
staticвнутри класса, принадлежит классу, а не объекту. - Не имеет
this, не может обращаться к нестатическим членам без явного объекта. - Может работать со статическими полями/методами.
- Вызывается как
Class::Method(). - Не участвует в виртуальном полиморфизме.
Вопрос 10. Что обозначает this в методах класса C++?
Таймкод: 00:15:48
Ответ собеседника: правильный. Определяет this как указатель на текущий объект, аналог self в Python, используемый внутри методов.
Правильный ответ:
this в C++ — это неявный указатель на текущий объект внутри нестатических методов класса. Через него компилятор реализует доступ к полям и методам конкретного экземпляра.
Ключевые моменты, которые важно понимать глубже:
- Тип this
- В обычном методе:
- тип
this:ClassName* const - т.е. указатель постоянный (нельзя переназначить), но объект по нему (если метод неконстантный) можно менять.
- тип
- В
const-методе:- тип
this:const ClassName* const - менять состояние объекта (кроме
mutableполей) нельзя.
- тип
- В методах с ref-квалификаторами:
- сигнатура учитывает, lvalue или rvalue объект (
&,&&), но концептуальноthisостаётся указателем на текущий объект.
- сигнатура учитывает, lvalue или rvalue объект (
Примеры:
class A {
public:
void f() {
// this имеет тип A* const
}
void g() const {
// this имеет тип const A* const
}
};
- Неявное использование this
При обращении к полям и методам внутри нестатического метода, this используется неявно:
class A {
int x;
public:
void set(int v) {
x = v; // на самом деле: this->x = v;
}
};
Явное this-> бывает нужно:
- для разрешения конфликтов имён;
- в шаблонах (CRTP и зависимые имена);
- для повышения читаемости, когда важно подчеркнуть, что используется член класса.
- Доступность и ограничения
- В статических методах
thisне существует:- попытка использовать
thisвstatic-методе — ошибка.
- попытка использовать
- В конструкторах и деструкторах:
thisдоступен,- но:
- в конструкторе объект ещё не полностью сконструирован (особенно при наследовании);
- в деструкторе уже разрушены производные части (для виртуальных деструкторов — важно понимать эффект на виртуальные вызовы).
- Возврат *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 без лишних копирований.
- Практический смысл
Глубокое понимание 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()), путается с механизмом виртуальных вызовов и не разделяет случаи виртуальных и невиртуальных методов.
Правильный ответ:
Да, метод базового класса можно вызывать, даже если он переопределен в наследнике. Важно понимать:
- как явно обратиться к методу базового класса;
- как это работает для виртуальных и невиртуальных методов;
- как влияют уровни доступа и тип наследования.
- Вызов метода базового класса изнутри наследника
Используется квалификация областью видимости:
#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.
- Виртуальные vs невиртуальные методы
-
Виртуальный метод:
- Обычный вызов через указатель/ссылку на базу:
Base* b = new Derived; b->foo();→ динамический выбор, вызовDerived::foo.
- Явный вызов
Base::foo():- игнорирует vtable, вызывает базовую реализацию напрямую.
- Обычный вызов через указатель/ссылку на базу:
-
Невиртуальный метод:
- Всегда выбирается по статическому типу:
- если в наследнике объявлен метод с тем же именем, это затенение (shadowing), а не override;
- внутри
Derivedбез квалификатора будет вызванDerived::bar; Base::bar()внутриDerivedпрямо вызывает базовый вариант.
- Всегда выбирается по статическому типу:
- Вызов извне через базовый тип
Из внешнего кода:
Derived d;
Base& b = d;
b.foo(); // виртуальный: Derived::foo
b.Base::foo(); // явный вызов базовой реализации (разрешено для ref/obj)
Хотя такой явный вызов базовой реализации извне встречается редко, важно понимать, что:
- обычный виртуальный вызов (
b.foo()) выбирает реализацию по динамическому типу (Derived); - квалифицированный вызов (
b.Base::foo()) обращается к конкретной реализации, минуя виртуальный диспетчер.
- Влияние доступа и типа наследования
- Public-наследование:
- Публичные методы базового доступны (если не скрыты) и могут вызываться как обычно или через
Base::method()внутри наследника.
- Публичные методы базового доступны (если не скрыты) и могут вызываться как обычно или через
- Protected/private-наследование:
- Доступ к базовому интерфейсу снаружи ограничен.
- Но внутри наследника (и его потомков при protected) можно вызывать
Base::method(), если уровень доступа это позволяет.
- Private-методы базового:
- Непрямой вызов из наследника невозможен — они не доступны;
- доступ только через публичные/защищенные методы базы.
- Типичный паттерн: расширение поведения базового метода
Частый и правильный сценарий:
class Base {
public:
virtual void process() {
// базовая логика
}
};
class Derived : public Base {
public:
void process() override {
// дополнительная логика до
Base::process(); // используем базовую реализацию
// дополнительная логика после
}
};
Это демонстрирует:
- использование
Base::method()внутри override для надстройки над базовым поведением; - осознанное управление полиморфным поведением.
- Краткая формулировка для интервью
- Да, можно.
- Из наследника:
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>
Важно не только знать их, но и понимать, когда какой применять, какие у них накладные расходы и типичные ошибки.
- 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).
- 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 для одной из сторон связи.
- 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 оба объекта корректно освободятся
}
- Практические рекомендации
- Используй
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-обертки.
- вместо этого —
- Краткая формулировка для интервью
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+).
- деструктор должен быть максимально простым и по возможности
Исключения из деструктора: что происходит и почему это опасно
Ключевое правило: исключения не должны "утекать" из деструктора.
Основные моменты:
- Стандарт и noexcept по умолчанию
- Начиная с C++11:
- деструктор по умолчанию считается
noexcept(true).
- деструктор по умолчанию считается
- Если исключение выходит за пределы такого деструктора:
- вызывается
std::terminate().
- вызывается
- То есть любая "утечка" исключения наружу из обычного деструктора в современном C++ — фатальная ошибка.
- Ситуация "исключение во время исключения"
Самый критичный кейс:
- Уже выполняется раскрутка стека из-за исключения №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()
- Правильная практика обработки ошибок в деструкторах
Если операция в деструкторе потенциально может бросить:
- Обернуть в 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. - Особенно опасна ситуация, когда исключение выбрасывается из деструктора во время раскрутки стека по другому исключению.
- В современном C++ деструкторы по умолчанию
- Для операций, потенциально бросающих, используем:
- явные методы с возможностью выброса;
- в деструкторе — только безопасные действия или перехват/логирование без повторного броска.
Вопрос 14. Существуют ли указатели и ссылки на функции в C++?
Таймкод: 00:19:39
Ответ собеседника: правильный. Кратко подтвердил наличие указателей и ссылок на функции.
Правильный ответ:
Да, в C++ существуют и указатели на функции, и ссылки на функции. Это фундаментальный механизм для передачи поведения как аргумента, реализации колбэков, таблиц диспетчеризации, интеграции с C-API и низкоуровневого управления вызовами. Важно уметь:
- объявлять такие типы;
- корректно вызывать функцию через них;
- отличать их от указателей на методы классов, лямбд и
std::function.
- Указатели на функции (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) для парсинга, обработки команд и т.п.;
- конфигурируемые стратегии без виртуальных вызовов.
- Ссылки на функции (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-ситуаций.
- Указатели на методы классов (важное отличие)
Отдельный тип — указатели на нестатические методы:
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), поэтому синтаксис другой. - Нельзя напрямую присвоить указатель на метод-член в указатель на свободную функцию.
- Связь с современными средствами: лямбды и 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, который умеет хранить свободные функции, лямбды, функторы, указатели на методы.
- Практический контекст (что стоит показать на собеседовании)
- Понимание, что:
- указатели/ссылки на функции — низкоуровневый, быстрый механизм;
- указатели на методы классов — другой тип с другим синтаксисом вызова;
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 принципиальны: отличаются структура данных, требования к ключу, гарантии сложности, поведение итераторов и сценарии применения.
Основные аспекты:
- Структура данных и сложность операций
-
std::map:- Обычно реализован как самобалансирующееся бинарное дерево поиска (часто красно-чёрное).
- Ключи хранятся в отсортированном порядке.
- Сложность:
- вставка, поиск, удаление: O(log N) гарантированно;
- итерация по контейнеру: O(N) в отсортированном порядке.
-
std::unordered_map:- Реализован как хеш-таблица (bucket’ы + цепочки или открытая адресация — зависит от реализации).
- Порядок элементов не определён (и может меняться при rehash).
- Сложность:
- средняя (амортизированная) вставка, поиск, удаление: O(1);
- в худшем случае при плохом хеше или множественных коллизиях: O(N).
Вывод:
std::map— предсказуемая логарифмическая сложность и отсортированность.std::unordered_map— быстрее в среднем для lookup, но без порядка и с завязкой на качество хеша.
- Требования к ключу
-
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)(иначе поведение неопределено).
- если
- Качество хеш-функции напрямую влияет на производительность.
- Требует:
- Порядок элементов и доступ к данным
-
std::map:- Гарантированно упорядочен по ключу.
- Позволяет эффективные операции:
lower_bound,upper_bound,equal_range;- поиск по диапазонам ключей (range queries).
- Удобен, когда важен отсортированный вывод или нужен ближайший/граничащий ключ.
-
std::unordered_map:- Порядок:
- не определён стандартом;
- может изменяться при вставках/rehash.
- Нет range-операций по сортированному ключу: все операции — по хешу и равенству.
- Порядок:
- Итераторы и стабильность
-
std::map:- Итераторы — двунаправленные.
- Вставка/удаление:
- не инвалидирует остальные итераторы (кроме итераторов на удалённые элементы).
- Структура дерева стабильна.
-
std::unordered_map:- Итераторы — как минимум forward.
- При rehash:
- все итераторы (и многие ссылки на элементы) инвалидируются.
- Частые вставки могут приводить к перераспределениям, что важно учитывать.
- Выбор контейнера: когда какой
Использовать std::map, когда:
- Нужен упорядоченный по ключу контейнер.
- Важны операции:
- "найти первый элемент не меньше ключа X";
- "пройтись по диапазону [L, R)".
- Важен стабильный детерминированный порядок обхода.
- Хотите предсказуемую асимптотику, не зависящую от качества хеша.
Использовать std::unordered_map, когда:
- Порядок ключей не важен.
- Основной сценарий — частые lookup по точному ключу.
- Важна максимальная средняя скорость O(1).
- Есть адекватная хеш-функция:
- например, для строк, чисел, enum и т.п.
- Готовы управлять:
reserve/rehashдля контроля производительности;- потенциальным худшим случаем (например, при атакуемых ключах).
- Практические моменты и нюансы
- По памяти:
std::unordered_mapобычно использует больше памяти (bucket’ы, overhead), чемstd::map.
- По locality:
- хеш-таблица может быть более cache-friendly при хорошем распределении;
- дерево — больше разыменований указателей и хуже locality.
- Для сложных ключей:
- в
std::mapдостаточно корректного<; - в
std::unordered_mapнужно реализовать иhash, и==.
- в
- Краткая формулировка для интервью
-
std::map:- упорядоченное ассоциативное дерево,
- O(log N) операции,
- требуется сравнение (<),
- поддерживает range-операции по ключу,
- детерминированный порядок.
-
std::unordered_map:- хеш-таблица,
- среднее O(1), худшее O(N),
- требует корректного hash + equal_to,
- порядок не гарантируется,
- предпочтителен для быстрых поисков по ключу без требований к сортировке.
Вопрос 17. Может ли поток в C++ перезапустить сам себя?
Таймкод: 00:21:27
Ответ собеседника: неполный. Описывает создание новых потоков из текущего и рекурсивный запуск, но не формулирует явно, что уже завершившийся поток нельзя перезапустить; возможно только создание нового потока с той же функцией.
Правильный ответ:
Коротко: нет, поток в C++ не может "перезапустить сам себя". Конкретный поток выполнения и связанный с ним std::thread — одноразовые: после завершения их нельзя запустить снова, можно только создать новый поток.
Разберем по сути.
- Жизненный цикл 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.
- стандарт НЕ предусматривает операций "restart" или "reset" для уже использованного
Пример (что можно и нельзя):
void worker() { /* ... */ }
int main() {
std::thread t(worker);
t.join();
// t.join(); // ошибка: поток уже присоединён
// t = std::thread(worker); // возможно, но это уже новый поток,
// не "перезапуск" старого
}
- "Самоперезапуск" потока
Чего нельзя:
- Текущий поток не может "умирать и возрождаться" в рамках того же
std::thread. - Нельзя после завершения снова запустить этот же поток выполнения.
Чего можно:
-
Перед завершением поток может создать новый поток:
void worker(int gen) {
// ... работа ...
if (gen < 3) {
std::thread next(worker, gen + 1);
next.detach(); // или join в другом месте
}
// текущий поток просто заканчивает выполнение
} -
Логика может выглядеть как "перезапуск", но по факту:
- каждый запуск — новый системный поток с новым идентификатором;
- это не реанимация старого потока, а создание нового.
- Правильный способ "перезапустить" работу
Если цель — повторять задачу в рамках одного потока:
-
делаем цикл внутри функции потока:
void worker() {
for (;;) {
// выполнить задачу
// при необходимости — условия выхода или паузы
}
}
int main() {
std::thread t(worker);
t.join();
}
Если цель — управлять "перезапусками" извне:
- используем управляющий код:
- создаем новый
std::thread, если предыдущий завершился; - возможно — пул потоков, очередь задач.
- создаем новый
- Почему это важно понимать
- Модель
std::threadи большинства системных API: поток — одноразовый ресурс. - "Перезапуск" — всегда явное создание нового потока, а не магия над старым.
- Попытки проектировать логику как "поток перезапускает сам себя" обычно означают:
- либо нужно переписать на цикл;
- либо ввести внешний контроллер/диспетчер.
Краткий ответ для интервью:
- Уже завершившийся поток (и связанный
std::thread) нельзя перезапустить. - Поток может перед завершением запустить новый поток, но это будет другой поток.
- Для повторного выполнения логики используют цикл внутри потока или внешний менеджер, а не "restart" существующего потока.
Вопрос 18. Какие основные виды памяти процесса ты можешь назвать?
Таймкод: 00:25:05
Ответ собеседника: правильный. Перечисляет сегмент кода, область статических данных, стек и кучу, корректно отражая базовые области памяти процесса.
Правильный ответ:
При обсуждении модели памяти процесса важно не просто назвать области, а понимать их назначение, типичные ошибки и влияние на поведение программ (включая C++/Go и работу рантайма).
Классическая модель адресного пространства процесса (упрощенно):
- Сегмент кода (text / code segment)
- Содержит:
- машинный код программы;
- обычно также константные данные (литералы строк, константы) — иногда выделяют отдельный read-only segment.
- Особенности:
- как правило, доступен только для чтения и исполнения;
- общий для всех потоков процесса;
- может быть разделяем между процессами при использовании одной и той же бинарники/библиотек.
- Практический смысл:
- защита от записи в код (предотвращает часть классов ошибок);
- основа для layout-а и профилирования.
- Область статических и глобальных данных (data / bss)
Обычно делится логически:
- Инициализированные глобальные и статические переменные:
- сегмент data.
- Неинициализированные (нулевые) глобальные и статические переменные:
- сегмент bss (инициализируется нулями при старте процесса).
Особенности:
- Время жизни:
- от старта процесса до его завершения.
- Доступ:
- одна копия на процесс;
- доступна всем потокам (нужна синхронизация при записи).
- Типичные случаи:
- глобальные конфиги, счетчики, синглтоны;
- в C/С++ — область, где легко создать гонки при отсутствии аккуратной синхронизации.
- Стек (stack, автоматическая память)
- Выделяется для каждого потока отдельно.
- Содержит:
- кадры вызовов функций (return-адрес, сохраненные регистры);
- локальные (автоматические) переменные функций;
- временные данные.
- Аллокация/освобождение:
- очень быстрые, по принципу LIFO;
- управление полностью детерминировано компилятором/ABI: при входе в функцию — расширение стека, при выходе — сворачивание.
- Особенности:
- ограниченный размер (stack overflow при глубоких рекурсиях или больших объектах на стеке);
- переменные живут до выхода из области видимости (функции/блока).
- Типичные ошибки в C/C++:
- возврат указателя/ссылки на стековую переменную (dangling reference);
- чрезмерные стеки для больших буферов.
- Куча (heap, динамическая память)
- Управляется аллокатором (runtime, ОС).
- Выделение:
- в C:
malloc/free; - в C++:
new/delete, умные указатели; - в Go: все, что "убегает" из стека, уходит в кучу, управляется GC.
- в C:
- Особенности:
- более гибкая, чем стек (объекты живут, пока явно не освобождены или не собраны GC);
- более дорогие операции (фрагментация, локальность, синхронизация).
- Типичные ошибки в C/C++:
- утечки памяти (нет
delete); - use-after-free (использование после освобождения);
- double free (повторное освобождение);
- несогласованное владение (отсутствие ясной модели ownership).
- утечки памяти (нет
- Хорошая практика:
- RAII, умные указатели, пулл аллокаторы, аренами и т.д.
- Дополнительные аспекты, которые полезно понимать (кратко)
Не всегда спрашивают, но сильный ответ может упомянуть:
- Стек каждого потока:
- отдельная область памяти;
- важен при работе с многопоточностью и диагностикой.
- Память под mmap / shared memory:
- отображения файлов (
mmap), общая память между процессами; - используется для IPC, memory-mapped файлов, больших буферов.
- отображения файлов (
- Read-only data segment:
- строковые литералы, константные таблицы;
- попытка записи — UB/segfault.
- TLS (thread-local storage):
thread_localпеременные в C++:- отдельные копии на каждый поток;
- отдельная область памяти, логически рядом с глобальными/статическими.
- Краткая формулировка для собеседования
- Основные области:
- сегмент кода (text) — машинный код и константы;
- статические/глобальные данные (data/bss) — живут весь срок работы процесса;
- стек — автоматическая память каждого потока (кадры функций, локальные переменные);
- куча — динамическая память, управляемая аллокатором/рантаймом.
- Важно понимать их различия по:
- времени жизни;
- доступу между потоками;
- стоимости операций;
- типичным ошибкам при работе с памятью.
