Как пройти собеседование по C++: топ вопросов продвинутого уровня
Собеседование на позицию C++ разработчика — это всегда вызов. Если базовые вопросы (разница между new и malloc, что такое виртуальный деструктор) вы, скорее всего, знаете, то продвинутый уровень требует глубокого понимания внутреннего устройства языка. В этой статье мы разберем ключевые темы, которые обязательно всплывут на техническом интервью для Middle и Senior позиций. Мы не просто перечислим вопросы, но и дадим развернутые ответы с примерами кода.
1. Умные указатели и их внутреннее устройство
Вопрос: "Расскажите, чем отличается std::unique_ptr от std::shared_ptr? Как реализована потокобезопасность счетчика ссылок?"
Это классика. Интервьюер хочет увидеть, что вы понимаете не только синтаксис, но и семантику владения ресурсами.
- std::unique_ptr — эксклюзивное владение. Не копируется, только перемещается. Идеален для ресурсов, у которых один владелец.
- std::shared_ptr — разделяемое владение. Использует счетчик ссылок (control block). Когда счетчик падает до нуля, ресурс уничтожается.
- std::weak_ptr — "наблюдатель". Не увеличивает счетчик ссылок. Используется для разрыва циклических зависимостей в
shared_ptr.
Продвинутый нюанс: Счетчик ссылок в shared_ptr потокобезопасен (используются атомарные операции), но это не делает потокобезопасным сам управляемый объект. Также существует два счетчика: сильных ссылок (strong) и слабых ссылок (weak). Пока есть хотя бы одна слабая ссылка, control block не удаляется, даже если объект уже уничтожен.
#include <memory>
#include <iostream>
class Resource {
public:
Resource() { std::cout << "Resource acquired\n"; }
~Resource() { std::cout << "Resource destroyed\n"; }
void doWork() { std::cout << "Working...\n"; }
};
int main() {
std::unique_ptr<Resource> uptr = std::make_unique<Resource>();
uptr->doWork();
// std::unique_ptr<Resource> uptr2 = uptr; // Ошибка компиляции!
std::unique_ptr<Resource> uptr2 = std::move(uptr); // Ок
std::shared_ptr<Resource> sptr1 = std::make_shared<Resource>();
std::shared_ptr<Resource> sptr2 = sptr1; // Счетчик ссылок = 2
sptr1.reset(); // Счетчик = 1
sptr2.reset(); // Счетчик = 0, объект уничтожен
return 0;
}
2. Виртуальные таблицы (vtable) и механизм позднего связывания
Вопрос: "Как работает виртуальная функция на уровне памяти? Где хранится vtable?"
Этот вопрос проверяет понимание не только полиморфизма, но и устройства памяти в C++.
- Для каждого класса, имеющего хотя бы одну виртуальную функцию, компилятор создает статическую таблицу (vtable). Она содержит указатели на виртуальные функции.
- Каждый объект такого класса содержит скрытый указатель
__vptr, который указывает на vtable своего класса. - При вызове виртуальной функции через указатель или ссылку, компилятор генерирует код, который идет по
__vptrв vtable, находит нужную функцию по смещению и вызывает её. - vtable хранится в статической памяти (как глобальная переменная), а не в стеке или куче.
Продвинутый нюанс: Сложности с множественным наследованием. В этом случае у объекта может быть несколько __vptr, и возникает необходимость в thunk-функциях (корректировка указателя this). Это одна из причин, почему множественное наследование в C++ считается опасным и медленным.
#include <iostream>
class Base {
public:
virtual void foo() { std::cout << "Base::foo\n"; }
virtual ~Base() = default;
};
class Derived : public Base {
public:
void foo() override { std::cout << "Derived::foo\n"; }
};
int main() {
Base* ptr = new Derived();
ptr->foo(); // Вызов через vtable: выведет "Derived::foo"
delete ptr;
return 0;
}
3. Move-семантика и Perfect Forwarding
Вопрос: "В чем разница между std::move и std::forward? Когда использовать rvalue-ссылки?"
Move-семантика — это одна из самых мощных (и сложных) фич современного C++. Она позволяет избежать дорогих копирований.
- std::move(x) — это просто
static_cast<T&&>(x). Он безусловно превращает lvalue в rvalue, "разрешая" перемещение. - std::forward<T>(x) — условное приведение. Используется в шаблонных функциях (идеальная передача). Если T — lvalue-ссылка, forward возвращает lvalue; если T — rvalue-ссылка, возвращает rvalue.
- Правило пяти: если вы определили деструктор, конструктор копирования или оператор присваивания, скорее всего, вам нужно определить и move-конструктор, и move-оператор присваивания.
Продвинутый нюанс: Возврат из функции по значению (RVO/NRVO) часто делает явный std::move в return вредным. Компилятор и так оптимизирует код, а явный move может заблокировать NRVO.
#include <string>
#include <utility>
#include <iostream>
void processValue(std::string& s) {
std::cout << "Lvalue: " << s << std::endl;
}
void processValue(std::string&& s) {
std::cout << "Rvalue: " << s << std::endl;
}
template<typename T>
void forwarder(T&& arg) {
// Perfect forwarding