Всем привет! Решил систематизировать и записать актуальные на 2025 год вопросы и ответы программисту в позиции Senior C++. Вы можете это использовать как для проведения собеседований в качестве интервьюера, так и для подготовки к ним. Считаю, это самая скучная статья на моём сайте, но лучший способ что-то запомнить — это записать и систематизировать свои знания.
Немного о себе: пишу на C++ уже восемнадцатый год и немножко понимаю в программировании.
Кстати, недавно узнал, что человек запоминает 25% любой информации, но надолго. И вот смотрю я на эту прорву текста и думаю — если это всего лишь четверть, то ни фига себе — насколько огромная часть знаний прошла мимо меня.
Где располагаются переменные, чем инициализированы, область видимости:
int a;
static int b;
int c = 30;
const int d = 20; // без слова "extern" переменная d видна только в этом файле
void function() {
int fa;
int fb = 10;
const int fc = 5;
static int fd; // инициализируется нулём при первом вызове функции
static int fe = 1; // инициализируется единичкой при первом вызове функции
}
Если мы объявляем переменную в глобальном пространстве, то указание static делает видимость этой переменной только в этом файле. Указание константности приводит (в зависимости от желания компилятора) помещение его значения по умолчанию в секцию .rodata исполняемого файла или сразу встраивание этого значения в код вместо переменной. Все переменные объявленные вне функций инициализированы нулём по умолчанию и живут в сегменте памяти BSS, который выделяется и заполняется нулями при запуске программы, его размер записан в исполняемом файле. Не const переменные сохраняют своё значение в сегменте памяти DATA исполняемого файла. Этот сегмент копируется в память компьютера при запуске программы. Все глобальные переменные живут в статической памяти (BSS/DATA/.rodata). Размещение переменных по этим сегментам — это поведение на этапе компоновки, описанное в стандартных соглашениях (Application Binary Interface, ABI).
Сводная таблица всех переменных из примера выше:
| Переменная | Инициализация | Сегмент памяти | Видимость |
|---|---|---|---|
int a; |
Неявная (0) | BSS | Глобальная |
static int b; |
Неявная (0) | BSS | Файловая |
int c = 30; |
Явная (30) | DATA | Глобальная |
const int d = 20; |
Явная (20) | .rodata или inline | Файловая |
int fa; |
Мусор | стек | Блочная |
int fb = 10; |
Явная (10) | стек | Блочная |
const int fc = 5; |
Явная (5) | стек или inline | Блочная |
static int fd; |
Неявная (0) | BSS | Блочная |
static int fe = 1; |
Явная (1) | DATA | Блочная |
Что такое RAII?
Resource Acquisition Is Initialization — дословно «захват ресурса есть инициализация». В конструкторе захватываем ресурс, в деструкторе освобождаем.
Какие бывают умные указатели?
std::shared_ptr— RAII указатель на объект со счётчиком ссылок. Копирование увеличивает счётчик ссылок, в деструкторе счётчик уменьшается. При достижении нуля освобождает память.std::weak_ptr— слабая ссылка на объект, управляемыйstd::shared_ptr. Не увеличивает счётчик ссылок. Для использования нужно преобразовать вstd::shared_ptrс помощью методаlock().std::unique_ptr— указатель с исключительным владением объектом. Запрещено копирование, разрешено только перемещение. Для передачи владения используйтеstd::move. Имеет специализацию для массивов:std::unique_ptr<T[]>.
Почему лучше использовать std::make_shared и std::make_unique, когда возможно?
Во время вычисления параметров конструктора возможно исключение, которое приведёт к утечке памяти. Также std::make_shared выделяет память для объекта и контрольного блока за одну аллокацию. В конце концов, читать удобнее.
Что происходит при возникновении исключения.
Выполнение функции прерывается, идёт поиск подходящего catch. Сначала в текущей функции, потом идёт последовательно выход наверх по стеку. При выходе вызываются все деструкторы локальных объектов, которые покидают текущую область видимости. Если подходящий catch не найден, то std::terminate(). Если во время раскрутки стека вызовов происходит повторное исключение, то снова std::terminate().
Что происходит при возникновении исключения в конструкторе/деструкторе?
- Если исключение происходит в конструкторе, для уже созданных членов класса вызываются деструкторы, деструктор этого объекта не вызывается, а дальше всё как обычно
- Деструктор по умолчанию объявлен как
noexceptсо стандарта C++11, поэтому любое исключение, покидающее деструктор вызываетstd::terminate(). Также может возникнуть ситуация, если при обработке исключения в деструкторе во время раскрутки стека вызовов произойдёт исключение — то будетstd::terminate()
Почему память выделенная new/malloc будет утечкой, а умный указатель корректно освободит память при вызове исключения?
Потому что происходит раскрутка стека и очистка локальных переменных, выделенных на стеке, плюс вызываются деструкторы этих переменных. Сырой указатель от new/malloc не имеет деструктора и выделен в куче.
Что такое срез исключения? Когда происходит? Как избежать?
Это происходит когда мы пытаемся перехватить исключение по значению, когда тип исключения в catch-блоке является базовым к брошенному исключению, например вот так:
try {
throw MyException(what, extended_data); // наследник std::exception
}
catch (std::exception e) { // тут происходит СРЕЗ
// Значение extended_data утеряно
}
Поэтому лучший путь — это перехватывать исключение по константной ссылке.
try {
throw MyException(what, extended_data); // наследник std::exception
}
catch (const std::exception &e) {
// Сохраняется полная информация об исключении
}
Что происходит при бросании исключения из noexcept метода?
Немедленный вызов std::terminate() и аварийное завершение программы.
Что происходит при вызове виртуального метода в конструкторе?
В конструкторе таблица виртуальных методов (vtable) указывает на методы текущего класса, значит вызываются методы текущего класса, а не производного. Вызов чисто виртуального метода приведёт к неопределённому поведению или ошибке компиляции.
struct Base {
public:
Base() {
print(); // Вызывается Base::print()
}
virtual void print() { ... }
};
struct Derived : Base {
void print() override { ... }
}
Что происходит при вызове виртуального метода в деструкторе?
То же самое, что в вопросе выше. Только в конструкторе производный класс ещё не построен, а в деструкторе производный класс уже разрушен.
Какие бывают касты в C++.
Целую заметку написал, не буду повторяться: Различные касты в C++.
Что такое семантика перемещения (move semantic)?
Механизм, который, начиная с C++11, позволяет эффективно передавать ресурсы между объектами без дорогостоящего копирования. Использование выглядит так:
std::vector<int> a;
a.resize(1000000); // выделяем много памяти
std::vector<int> b = std::move(a); // перемещаем данные, исходный объект в неопределённом состоянии
После выполнения кода объект b владеет данными, объект a находится в валидном, но неопределённом состоянии.
Полезно знать, что std::move только преобразует тип в r-value ссылку и ничего никуда не перемещает, реальное перемещение выполняет конструктор перемещения или оператор перемещения.
Что такое RVO и NRVO
Это оптимизации компилятора под названием Copy elision (пропуск копий), которые позволяют избежать лишнего копирования/перемещения при возврате объектов из функций:
- RVO(Return Value Optimization) — оптимизация безымянного возвращаемого значения, гарантировано стандартом C++17:
HeavyObject create() {
return HeavyObject();
}
- NRVO(Named Return Value Optimization) — оптимизация именованного возвращаемого значения, когда возвращается именованная локальная переменная. Обратите внимание — использование
std::moveпри возврате значения ломает эту оптимизацию!
HeavyObject create() {
HeavyObject a;
return a;
}
Что такое и как использовать std::future/std::promise? В каких случаях std::future бросает ошибку?
future/promise — это механизм передачи данных или исключений между потоками. Одному std::promise соответствует один std::future.
std::promise— устанавливает значение (set_value()) или исключение (set_exception()), которое извлекается с помощьюstd::future.std::future— предоставляет доступ к результату (get()), записанному черезstd::promise. Методget()вызывает исключение, еслиstd::promiseуничтожен без установки значения или исключения. Повторный вызовget()также вызовет ошибку.
Пример для наглядности:
#include <iostream>
#include <future>
#include <thread>
int main() {
auto worker = [](auto p) {
try {
p.set_value("Hello world!");
// Делаем что-то вызывающее исключение
}
catch(...) {
// Передаём исключение
p.set_exception(std::current_exception());
}
};
std::promise<std::string> promise;
auto f = promise.get_future();
// Передаём promise по значению в поток с помощью перемещения
std::thread t(worker, std::move(promise));
try {
std::cout << f.get() << std::endl;
}
catch (const std::exception &e) {
std::cerr << "Exception : " << e.what() << std::endl;
}
t.join();
}
std::future можно использовать для получения значения только один раз и нельзя копировать, если хотите это изменить, создайте std::shared_future с помощью метода std::future::share() — этот класс лишён таких недостатков.
Что такое deadlock и как происходит?
Блокирование потоков в ожидании друг друга. Происходит, когда в потоке A блокируется мьютекс a, потом b. А в потоке B сначала мьютекс b, потом a. Исправляется использованием std::scoped_lock или одинаковым порядком блокирования мьютексов.
Зачем нужно указание memory_order в объектах типа std::atomic? Какие бывают memory_order?
Если вкратце, то memory_order позволяет контролировать модель памяти для атомарных операций. Модель памяти определяет, как операции чтения и записи памяти могут быть переупорядочены компилятором и процессором для оптимизации. Начиная от самой слабой гарантии std::memory_order_relaxed и заканчивая самой строгой std::memory_order_seq_cst (по умолчанию). Если хотите подробнее вникнуть в тему, то вот сорокаминутное видео с объяснением от разработчика из Яндекса:
Чем процесс отличается от потока?
Изолированностью в первую очередь. Процессы имеют свои ресурсы, потоки делят ресурсы одного процесса. Падение одного потока приводит к завершению всего процесса. Завершение процесса никак не затрагивает другие процессы.
Что будет если вызвать new с огромным куском памяти?
Произойдёт вызов исключения std::bad_alloc, можно использовать new (std::nothrow) если хочется получить nullptr в случае неудачи.
Какие бывают контейнеры STL. Устройство и сложность операций поиска/вставки/удаления.
std::vector— выделяет непрерывный кусок памяти, если не хватает места, то выделяется бОльший кусок, старые данные копируются туда, доступ имеет сложность O(1), вставка в конец имеет амортизированную сложность O(1), вставка в начало/середину требует сдвига всех данных вправо и имеет сложность O(n). Эффективно использует кеш процессора. Имеет возможность доступа по индексу.std::list— двусвязный список, поиск O(n), доступ по индексу O(n), вставка O(1) при наличии итератора, удаление O(1) при наличии итератора, доступ к элементам по итератору.std::forward_list— односвязный список, аналогиченstd::list, но занимает меньше памяти. Может вставлять/удалять только после известного элемента.std::deque— двусторонняя очередь, реализуется как массив указателей на блоки, доступ O(1), вставка/удаление с начала/конца O(1), в середине O(n).std::unordered_set/std::unordered_map— хеш-таблица из бакетов, поиск, вставка, удаление O(1) в среднем случае, O(n) в худшем (при коллизиях), элементы без сортировки.std::set/std::map— красно-черные деревья, поиск/вставка/удаление O(log n). Элементы отсортированы.std::array— аналог сишного массива фиксированного размера с плюшками STL в виде итераторов и деструктораstd::span— интерфейс для работы с непрерывными последовательностями данных без владения этими даннымиstd::string_view— аналогstd::spanдляstd::string, доступ к данным строки без владения
P.S. Благодарю создателей Markdown за синтаксис языка разметки, всё форматирование сделано с помощью него.