Вопросы и ответы для собеседования и подготовки Senior C++ разработчика

Всем привет! Решил систематизировать и записать актуальные на 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 за синтаксис языка разметки, всё форматирование сделано с помощью него.

Комментариев нет. Будьте первым!
Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Блог Евгения Жирнова