Відібрані питання для співбесіди C++ backend розробників, згруповані за темами та взяті з того самого каталогу питань, на якому базується практика в EngineerSpeak.
1Поясніть RAII (Resource Acquisition Is Initialization) і як цей підхід формує безпечне щодо винятків керування ресурсами в backend-сервісах на C++.
RAII (Resource Acquisition Is Initialization) означає, що об'єкт C++ володіє ресурсом і звільняє його у своєму деструкторі. Оскільки локальні об'єкти знищуються автоматично, коли закінчується їхній час життя або область видимості, зокрема під час розкручування стека при винятках, RAII забезпечує детерміноване очищення і робить шляхи обробки помилок безпечними щодо винятків. У backend-сервісах це стосується не лише пам'яті, а й дескрипторів файлів, сокетів, блокувань м'ютексів, підключень до баз даних, транзакцій та інших ресурсів ОС або застосунку.
2Порівняйте std::unique_ptr і std::shared_ptr та опишіть, коли кожен із них доречний у backend API (Application Programming Interface).
std::unique_ptr виражає виключне володіння: він дешевий, його можна переміщувати, але не копіювати, і він доречний для єдиного власника або для API, які передають володіння. std::shared_ptr виражає спільне володіння: його можна копіювати, і він утримує об'єкт живим через підрахунок посилань, доки останній сильний власник його не відпустить. У backend API використовуйте unique_ptr, коли володіння передається, shared_ptr — лише коли кілька незалежних власників мають подовжувати час життя, а посилання або «сирі» вказівники — для доступу без володіння. Під час створення об'єктів shared_ptr зазвичай надають перевагу std::make_shared, бо це ефективно та безпечно щодо винятків.
3Як працюють користувацькі функції звільнення ресурсу (custom deleters) у розумних вказівниках і коли вони корисні для сумісності C/C++?
Користувацька функція звільнення ресурсу (custom deleter) — це викликувана логіка очищення, яку розумний вказівник використовує замість операції delete за замовчуванням, коли звільняє ресурс, яким володіє. Вона корисна для сумісності C/C++, коли ресурс потрібно звільняти конкретною функцією, наприклад fclose, close, curl_easy_cleanup, SSL_free, free або функцією знищення з бібліотеки. Для unique_ptr тип функції звільнення є частиною типу unique_ptr і може впливати на його розмір; безстанові функції звільнення можуть бути оптимізовані геть, тоді як вказівники на функції або функції зі станом додають місце для зберігання. Для shared_ptr функція звільнення зберігається в керівному блоці (control block) і виконується, коли останній сильний власник звільняє об’єкт. Користувацькі функції звільнення дають змогу ресурсам C безпечно брати участь у RAII (Resource Acquisition Is Initialization).
4Поясніть weak_ptr і механіку керівного блоку shared_ptr, зокрема цикли, enable_shared_from_this та вартість лічильників посилань.
shared_ptr керує спільним володінням через керівний блок (control block), що містить сильні та слабкі лічильники посилань (refcount), а також інформацію для очищення, зокрема функцію звільнення ресурсу (deleter) та алокатор. Копіювання або знищення об'єктів shared_ptr збільшує або зменшує сильний лічильник, зазвичай атомарними операціями, тож окремі об'єкти shared_ptr можна безпечно змінювати з різних потоків, але кожне оновлення лічильника посилань має вартість. Коли сильний лічильник досягає нуля, керований об'єкт знищується; керівний блок залишається, доки не зникнуть і слабкі посилання. weak_ptr вказує на той самий керівний блок, не подовжуючи час життя об'єкта; lock() повертає shared_ptr, якщо об'єкт ще живий, і порожній shared_ptr інакше. Цикли лише з shared_ptr спричиняють витоки, бо сильні лічильники ніколи не досягають нуля, тому weak_ptr використовують для зворотних вказівників або зв'язків спостерігача. enable_shared_from_this дозволяє об'єкту, яким уже володіє shared_ptr, створити новий shared_ptr на себе з використанням наявного керівного блоку, уникаючи небезпечних окремих керівних блоків. Вартість лічильників посилань включає атомарні збільшення/зменшення, конкуренцію за кеш, виділення керівного блоку та накладні витрати в критичних за продуктивністю ділянках коду.
5Що таке семантика переміщення (move semantics) і як правильно реалізувати конструктор переміщення та оператор присвоєння переміщенням?
Семантика переміщення дозволяє C++ передавати ресурси від тимчасових або інших об'єктів, якими можна пожертвувати, замість їх копіювання. Вона використовує `rvalue`-посилання, такі як `T&&`, та `std::move`, який є приведенням типу, що дозволяє вибирати перевантаження для переміщення; сам по собі `std::move` нічого не переміщує. Правильний конструктор переміщення ініціалізує новий об'єкт, забираючи ресурс вихідного об'єкта та залишаючи вихідний об'єкт у валідному стані, придатному для знищення та присвоєння. Правильний оператор присвоєння переміщенням передає дані в наявний об'єкт, обробляє або допускає самоприсвоєння, вивільняє або повторно використовує поточний ресурс цільового об'єкта, забирає ресурс вихідного об'єкта та залишає вихідний об'єкт у безпечному стані. Операції переміщення часто мають бути `noexcept`, щоб стандартні контейнери могли використовувати їх під час перерозподілу пам'яті, зберігаючи гарантії безпеки винятків.
6Опишіть константну коректність (const-correctness) в C++ API (Application Programming Interface) та як ефективно проектувати константні функції-члени.
Константна коректність означає вираження через систему типів того, які операції не змінюють спостережуваний або логічний стан об'єкта. Константна функція-член має об'єкт this із кваліфікатором const, тому вона не може змінювати дані-члени, що не є mutable, або викликати неконстантні функції-члени для того самого об'єкта. Хороший дизайн API позначає запити лише для читання як const, повертає значення або константні посилання/вказівники, коли це доцільно, і уникає розкриття змінного (mutable) внутрішнього стану з константних функцій. Ключове слово mutable слід зарезервувати для деталей реалізації, які не змінюють логічний стан, таких як кеші, відкладені (lazy) обчислення, метрики або м'ютекси. const — це контракт API щодо мутацій, а не автоматична гарантія потокобезпеки; гарантії багатопотоковості потребують окремої реалізації та документації.
7Що таке типи, які підтримують лише переміщення (move-only types), і як вони впливають на межі API (API boundaries) для сокетів, дескрипторів файлів та блокувань?
Типи, що підтримують лише переміщення (move-only types) — це типи, які не можна скопіювати, але можна перемістити. Вони часто використовуються для унікального володіння ресурсами, такими як сокети, файлові дескриптори, дескриптори файлів, об'єкти блокування та `std::unique_ptr`. Операції копіювання для них видалені (deleted), щоб уникнути дублювання володіння та подвійного вивільнення ресурсу; операції переміщення передають володіння і залишають об'єкт-джерело у валідному, але зазвичай порожньому стані (без володіння ресурсом). API повинні робити межі володіння (ownership boundaries) явними: фабрики можуть повертати об'єкти, що тільки переміщуються, за значенням; функції, які перебирають володіння на себе, можуть приймати аргументи за значенням або за rvalue-посиланням; а функції, які лише перевіряють стан, повинні приймати посилання, вказівники або інші дескриптори запозичення (borrowing handles). Контейнери можуть зберігати значення, що тільки переміщуються, коли елементи вставляються або переміщуються за допомогою операції `move`.
8Порівняйте `auto`, `decltype`, `decltype(auto)` та виведення шаблонних аргументів (template argument deduction) у типовому бекенд-коді.
`auto` використовує подібне до шаблонів виведення типів для змінних: звичайний `auto` зазвичай відкидає посилання та специфікатор `const` верхнього рівня (top-level const), якщо оголошення їх не вимагає явно, наприклад `auto&`, `const auto&` або `auto&&`. `decltype(expr)` визначає оголошений тип або тип виразу точніше: вираз ідентифікатора (id-expression) без дужок дає оголошений тип, тоді як інші вирази `lvalue` дають `T&`, `xvalue` дають `T&&`, а `prvalue` дають `T`. `decltype(auto)` виводить тип за правилами `decltype`, часто використовується для типів повернення, коли необхідно зберегти посилання. Виведення шаблонних аргументів схоже на `auto`, але залежить від форми параметра, наприклад `T`, `T&`, `const T&` або `T&&`, і має власні правила. Поширена відмінність полягає у використанні ініціалізаторів у фігурних дужках (braced initializers): `auto x = {1,2}` виводить `std::initializer_list<int>`, тоді як звичайний шаблонний параметр зазвичай не може вивести `T` з простого ініціалізатора у фігурних дужках, якщо параметр не очікує на `initializer_list` або інший відповідний тип.
9Опишіть Правило нуля (Rule of Zero), Правило трьох (Rule of Three) та Правило п'яти (Rule of Five), а також випадки застосування кожного з них.
Правило нуля: віддавайте перевагу класам, які не оголошують користувацькі операції деструктора, копіювання чи переміщення; дозвольте членам класу, що реалізують RAII (Resource Acquisition Is Initialization), таким як `std::string`, `std::vector`, `std::unique_ptr`, обгортки файлів/сокетів тощо, керувати ресурсами. Правило трьох: якщо клас самостійно керує ресурсом і потребує користувацького деструктора, конструктора копіювання або оператора присвоєння копіюванням, йому зазвичай потрібні всі три для визначення правильної поведінки копіювання та володіння. Правило п'яти: у C++11 і пізніших версіях для таких типів також слід розглядати конструктор переміщення та оператор присвоєння переміщенням. Використовуйте Правило нуля для більшості прикладних типів; використовуйте Правило трьох/п'яти, коли тип безпосередньо володіє ресурсом або має нетривіальну семантику володіння/часу життя.
10Опишіть форми ініціалізації в C++ та поширені пастки (pitfalls), включаючи `initializer_list` та агрегати (aggregates).
У C++ існує кілька форм ініціалізації. Ініціалізація за замовчуванням (default initialization), наприклад `T x;`, викликає конструктор за замовчуванням для класових типів, але залишає автоматичні змінні фундаментальних типів неініціалізованими. Ініціалізація значенням (value initialization), наприклад `T x{};` або `T()`, виконує нульову ініціалізацію (zero-initializes) там, де це можливо, перед ініціалізацією конструктором або членів класу. Спискова ініціалізація (list initialization) використовує фігурні дужки, відхиляє звужувальні перетворення (narrowing conversions) і має спеціальні правила вирішення перевантажень, включаючи сильну перевагу конструкторам, що приймають `std::initializer_list`. Агрегатна ініціалізація (aggregate initialization) ініціалізує члени агрегату безпосередньо за допомогою фігурних дужок; C++20 також підтримує призначені ініціалізатори (designated initializers) для агрегатів у порядку їхнього оголошення. Поширені пастки включають неініціалізовані локальні скаляри, неочікуваний вибір перевантаження з `initializer_list`, помилки звуження при використанні фігурних дужок, проблему 'найбільш прикрої помилки синтаксичного аналізу' (most-vexing parse) з круглими дужками та зміну поведінки, коли тип перестає бути агрегатом.
11Що таке невизначена поведінка (undefined behavior) у C++, і як вона може проявлятися в інцидентах на робочих серверах (production) бекенду?
Невизначена поведінка (UB - Undefined Behavior) — це поведінка, щодо якої стандарт C++ не встановлює жодних вимог після виконання неприпустимої операції. Може здаватися, що програма працює, або ж вона може завершитися аварійно, пошкодити дані, створити вразливості в безпеці або бути оптимізованою до несподіваної поведінки. Компілятори припускають, що UB не трапляється, і виконують оптимізацію на основі цього припущення, тому проблеми можуть з'являтися лише в релізних збірках (release builds) або під робочим (production) навантаженням. Інциденти в бекенді можуть виникати через висячі вказівники або посилання (dangling pointers/references), використання пам'яті після її звільнення (use-after-free), порушення часу життя об'єктів, звернення за межі виділеної пам'яті (out-of-bounds access), переповнення знакових цілих чисел, стани перегонів даних (data races), неправильні приведення типів (invalid casts), читання неініціалізованих даних, подвійне звільнення пам'яті (double frees) або порушення правил строгого псевдонімінгу (strict-aliasing violations). Засоби запобігання включають RAII (Resource Acquisition Is Initialization) та чітке проектування володіння (ownership) і часу життя, безпечніші абстракції та перевірки меж, тестування/фазинг, перегляд коду, статичний аналіз, а також використання санітайзерів, таких як ASan, UBSan і TSan.
12Поясніть віртуальні функції, таблиці віртуальних методів (vtables), вартість динамічної диспетчеризації (dynamic dispatch), зрізання об'єктів (object slicing) та небезпеки віртуальних деструкторів.
Віртуальна функція забезпечує поліморфізм під час виконання (runtime polymorphism): при виклику через покажчик або посилання на базовий клас вибирається реалізація для динамічного типу об'єкта. Більшість реалізацій зберігають прихований покажчик `vptr` у кожному поліморфному об'єкті, який вказує на таблицю віртуальних методів (vtable) з адресами віртуальних функцій для цього динамічного типу. Типова вартість — це додатковий покажчик в об'єкті, непрямий виклик, можливий вплив на кеш/передбачення переходів (branch-prediction) і зменшення можливостей для вбудовування функцій (inlining), хоча компілятори іноді можуть виконувати девіртуалізацію. Зрізання об'єкта (object slicing) відбувається, коли об'єкт похідного класу копіюється або зберігається за значенням як об'єкт базового класу, втрачаючи похідну частину та динамічну поведінку. Якщо об'єкт базового класу призначений для видалення через покажчик на базовий клас, його деструктор має бути віртуальним; інакше видалення об'єкта похідного класу через цей базовий покажчик призводить до невизначеної поведінки (undefined behavior).
13Опишіть годинники, моменти часу (`time_point`) та тривалості (`duration`) у бібліотеці `std::chrono` для використання їх у тайм-аутах сервісів, метриках та позначках часу (timestamps).
Бібліотека `std::chrono` моделює час за допомогою годинників (clocks), моментів часу (`time_point`) та тривалостей (`duration`). Тривалість (`duration`) — це інтервал з одиницею виміру, наприклад, мілісекунди або секунди. Момент часу (`time_point`) — це точка на часовій шкалі конкретного годинника. Годинник `steady_clock` є монотонним і має використовуватися для вимірювання минулого часу, тайм-аутів сервісів, дедлайнів та затримок (latency), оскільки на нього не впливають зміни реального (календарного) часу. Годинник `system_clock` представляє реальний/календарний час (wall-clock time) і підходить для позначок часу, логування, збереження даних та календарних перетворень, але його значення може стрибати під час коригування системного часу. Тайм-аути зазвичай мають базуватися на конструкції `steady_clock::now() + duration`; а машинні позначки часу повинні використовувати чітко задокументований формат реального часу, зазвичай UTC (Coordinated Universal Time) на межах API (Application Programming Interface) або сховища даних.
14Опишіть std::format та сучасні засоби форматування порівняно з iostreams та API в стилі printf.
`std::format` — це типобезпечний засіб форматування стандарту C++20, натхненний бібліотекою fmtlib. Він використовує поля підстановки `{}` та специфікації формату для створення відформатованого тексту без синтаксису вставки зі збереженням стану, як в `iostreams`, або C varargs у стилі `printf`. Порівняно з `printf`, він дозволяє уникнути багатьох проблем невідповідності формату та типу; порівняно з `iostreams`, він часто зрозуміліший і його легше компонувати. fmtlib — це широко використовувана бібліотека, яка передувала та вплинула на `std::format` і може забезпечувати ширшу підтримку або новіші функції. Типи, визначені користувачем, можна форматувати за допомогою підтримки користувацьких форматерів (custom formatters). У логуванні з великим обсягом даних (high-volume logging) продуктивність залежить від уникнення непотрібного форматування, перетворень і виділення пам'яті, особливо для вимкнених рівнів логування; перевагу слід надавати API (Application Programming Interface), які відкладають форматування або спершу перевіряють рівень логування.
15Поясніть категорії значень (value categories) та ідеальне передавання (perfect forwarding) у C++, а також чому вони важливі для ефективних узагальнених API (Application Programming Interface).
Категорії значень у C++ описують вирази: `lvalue` мають ідентифікатор і на них можна посилатися після виразу; `prvalue` — це чисті `rvalue`, такі як багато тимчасових або обчислених значень; `xvalue` — це об'єкти, час життя яких закінчується, а їхні ресурси можуть бути використані повторно. Ідеальне передавання — це шаблонна техніка приймання універсального посилання (forwarding reference), зазвичай `T&&`, де `T` виводиться (deduced), і передавання за допомогою `std::forward<T>(arg)`, щоб зберегти категорію значення викликача: `lvalue` залишаються `lvalue`, а `rvalue` залишаються `rvalue`. Правила згортання посилань (reference collapsing) роблять це можливим. Це важливо для узагальнених бекенд-API, оскільки обгортки, фабрики, диспетчери та функції стилю `emplace` можуть уникати непотрібних копіювань і зберігати вибір перевантаження та поведінку переміщення.
16Опишіть стратегії запобігання взаємним блокуванням (deadlocks) та блокування кількох м'ютексів за допомогою `std::scoped_lock` та `std::lock`.
Запобігти взаємному блокуванню можна шляхом уникнення циклічних очікувань: за можливості захоплюйте блокування в узгодженому глобальному порядку, або захоплюйте кілька м'ютексів за допомогою `std::lock`/`std::scoped_lock`, які використовують алгоритм уникнення взаємних блокувань. `std::scoped_lock lock(a, b, ...)` — це найпростіша форма RAII (Resource Acquisition Is Initialization) для блокування кількох м'ютексів та їх автоматичного розблокування при виході з області видимості. У випадку з `std::lock` спочатку заблокуйте м'ютекси, а потім додайте обгортки RAII, використовуючи `std::adopt_lock`, або використовуйте `std::unique_lock` разом із `std::defer_lock`. Робіть критичні секції короткими та уникайте блокувальних операцій або невідомих зворотних викликів (callbacks) під час утримання блокувань; уникнення взаємних блокувань не гарантує автоматичної справедливості і не запобігає всім сценаріям динамічного взаємного блокування (livelock) або голодування (starvation).
17Поясніть шаблони використання condition_variable для очікування на зміни стану.
Використовуйте `std::condition_variable` для очікування зміни предикату стану, захищеного м'ютексом. Спільний стан змінюється під час утримання того самого м'ютекса, після чого `notify_one` або `notify_all` пробуджує потоки, що очікують. Потоки, що очікують, повинні використовувати `cv.wait(lock, predicate)` або еквівалентний цикл, оскільки пробудження можуть бути хибними (spurious), а сповіщення не запам'ятовуються незалежно від стану предикату. `notify_one` пробуджує один потік, що очікує; `notify_all` пробуджує всі потоки і підходить для широкомовних змін стану, таких як завершення роботи (shutdown).
18Спроєктуйте потокобезпечну обмежену чергу, використовуючи стандартні примітиви C++, та визначте гарантії її API.
Обмежену потокобезпечну чергу можна побудувати за допомогою `std::mutex`, умовних змінних (condition variables), контейнера з фіксованою ємністю та прапорця завершення роботи/закриття (shutdown/closed). Метод `push` блокується, завершується за тайм-аутом або повертає помилку, коли черга повна, що забезпечує зворотний тиск (backpressure); метод `pop` блокується, коли черга порожня. Обидві операції повинні очікувати на предикати, такі як `size < capacity || closed` та `!empty || closed`. Під час завершення роботи/закриття розбудіть заблокованих виробників (producers) та споживачів (consumers); відхиляйте нові виклики `push` і визначте, чи споживачі вичищають (drain) наявні елементи, чи негайно зупиняються. API (Application Programming Interface) повинно визначати поведінку блокування, семантику закриття, значення, що повертаються, та гарантії конкурентності.
19Як виконати міграцію схеми бази даних для сервісу на C++ без простою (downtime)?
Використовуйте поетапну міграцію за шаблоном «розширення-згортання» (expand-contract). Спочатку зробіть зворотно-сумісні (backward-compatible) доповнення до схеми, такі як стовпці, що допускають значення null, або нові таблиці, які не порушать роботу поточного сервісу на C++. Розгорніть сумісний код, який може обробляти як старе, так і нове представлення даних, заповніть наявні дані невеликими дозованими партіями (throttled batches), перевірте їх узгодженість, перемкніть читання на нову схему, і лише згодом видаліть старі стовпці або код, коли всі розгорнуті версії більше не будуть від них залежати. Для великих таблиць уникайте тривалих блокувальних операцій DDL (Data Definition Language), використовуйте онлайн/конкурентні операції, де це підтримується, відстежуйте блокування, реплікацію та ресурси, а також зберігайте шляхи відкоту (rollback), які працюють із частково розгорнутим кодом і частково мігрованими даними.
20Як сервісу на C++ слід читати дані з реплік бази даних, контролюючи застарілі дані (stale reads) та гарантії «read-your-writes» (читання власних записів)?
Ставтеся до читання з реплік як до усвідомленого компромісу узгодженості даних. Запити на читання, які можуть допускати певну застарілість даних, можуть спрямовуватись до справних реплік, але запити з гарантією «read-your-writes», транзакційні читання або критичні до свіжості дані повинні надсилатися до головного вузла (primary), якщо тільки не відомо, що обрана репліка вже відтворила відповідну позицію запису, наприклад LSN (Log Sequence Number), часову мітку або версію. Відстежуйте відставання (lag) та працездатність реплік, кодуйте необхідну узгодженість для кожного ендпоінту або запиту та виконуйте перехід на головний вузол, очікуйте синхронізації (catch-up) або переривайте операцію (fail), якщо репліки перевищують допустимий рівень застарілості. Документуйте модель узгодженості, щоб викликаючий код знав, які дані при читанні можуть бути застарілими.