Как пройти собеседование по C++: топ вопросов продвинутого уровня
Собеседование на позицию C++ разработчика среднего и старшего уровня — это не просто проверка синтаксиса. Интервьюеры хотят увидеть глубокое понимание внутреннего устройства языка, умение работать с памятью, шаблонами и многопоточностью. В этой статье мы разобрали самые частые и сложные вопросы, которые задают на собеседованиях по C++ продвинутого уровня. Каждый вопрос сопровождается объяснением и примером кода.
1. Умные указатели и RAII
Вопрос об умных указателях — классика. Ожидается, что вы не просто назовете std::unique_ptr, std::shared_ptr и std::weak_ptr, но и объясните их внутреннее устройство и когда какой использовать.
Типичный вопрос: В чем разница между unique_ptr и shared_ptr? Как работает счетчик ссылок в shared_ptr? Зачем нужен weak_ptr?
Ответ: std::unique_ptr — это эксклюзивное владение ресурсом. Его нельзя копировать, только перемещать (move semantics). std::shared_ptr использует подсчет ссылок (reference counting) для разделяемого владения. Когда счетчик падает до нуля, объект удаляется. std::weak_ptr — это "наблюдатель", который не увеличивает счетчик ссылок. Он используется для предотвращения циклических зависимостей (например, в графах или деревьях).
#include <memory>
#include <iostream>
struct Resource {
Resource() { std::cout << "Resource acquired\n"; }
~Resource() { std::cout << "Resource released\n"; }
};
int main() {
// unique_ptr
std::unique_ptr<Resource> ptr1 = std::make_unique<Resource>();
// std::unique_ptr<Resource> ptr2 = ptr1; // Ошибка компиляции - копирование запрещено
std::unique_ptr<Resource> ptr2 = std::move(ptr1); // Перемещение разрешено
// shared_ptr
std::shared_ptr<Resource> sptr1 = std::make_shared<Resource>();
std::shared_ptr<Resource> sptr2 = sptr1; // Копирование разрешено, счетчик = 2
// weak_ptr
std::weak_ptr<Resource> wptr = sptr1; // Счетчик не увеличивается
if (auto locked = wptr.lock()) {
std::cout << "Resource is still alive\n";
}
return 0;
}
Дополнительный вопрос: Как реализовать свой SharedPtr? Здесь важно упомянуть RAII (Resource Acquisition Is Initialization) — принцип, при котором захват ресурса происходит в конструкторе, а освобождение — в деструкторе.
2. Виртуальные деструкторы и таблица виртуальных функций (vtable)
Этот вопрос проверяет понимание полиморфизма и устройства виртуальных функций в C++.
Типичный вопрос: Почему в базовом классе с виртуальными функциями обязательно должен быть виртуальный деструктор? Как работает vtable?
Ответ: Если деструктор базового класса не виртуальный, то при удалении объекта производного класса через указатель на базовый класс будет вызван только деструктор базового класса. Это приведет к утечке ресурсов, которые выделил производный класс.
Каждый класс, имеющий хотя бы одну виртуальную функцию, содержит статическую таблицу (vtable). В ней хранятся указатели на реализации виртуальных функций. Каждый объект такого класса имеет скрытый указатель (vptr), который указывает на vtable своего класса. Вызов виртуальной функции происходит через двойную косвенность: объект → vptr → vtable → функция. Это добавляет небольшой оверхед по сравнению с обычным вызовом.
#include <iostream>
class Base {
public:
virtual void show() { std::cout << "Base\n"; }
virtual ~Base() = default; // Виртуальный деструктор
};
class Derived : public Base {
public:
void show() override { std::cout << "Derived\n"; }
~Derived() override { std::cout << "Derived destructor\n"; }
};
int main() {
Base* ptr = new Derived();
ptr->show(); // Выведет "Derived"
delete ptr; // Вызовет сначала ~Derived(), потом ~Base()
return 0;
}
Дополнительный вопрос: Можно ли вызвать виртуальную функцию из конструктора? Ответ: Да, но это опасно. Во время работы конструктора базового класса vptr указывает на vtable базового класса, а не производного. Поэтому будет вызвана версия из базового класса, даже если вы переопределили функцию в производном.
3. Move-семантика и идеальная передача (perfect forwarding)
Move-семантика — одна из ключевых фич C++11. Ожидается, что вы понимаете, что такое rvalue-ссылки, std::move и std::forward.
Типичный вопрос: В чем разница между std::move и std::forward? Что такое collapsing rules?
Ответ: std::move — это просто static_cast<T&&>. Он безусловно превращает lvalue в rvalue, чтобы можно было вызвать move-конструктор или move-оператор присваивания. std::forward используется в шаблонах для идеальной передачи. Он сохраняет категорию значения (lvalue/rvalue) аргумента, переданного в шаблонную функцию.
Collapsing rules (сворачивание ссылок) работают так: если в шаблоне тип выводится как T&, то T& & сворачивается в T&, а T&& — в T&. Только T&& && дает T&&. Это позволяет отличать lvalue от rvalue.
#include <iostream>
#include <utility>
#include <vector>
void process(const std::vector<int>& v) {
std::cout << "Lvalue overload\n";
}
void process(std::vector<int>&& v) {
std::cout << "Rvalue overload\n";
}
template <typename T>
void wrapper(T&& arg) {
process(std::forward<T>(arg)); // Идеальная передача
}
int main() {
std::vector<int> v = {1