Przygotowanie dla Middle C++

Pytania rekrutacyjne Middle C++ Backend

15 wybranych pytań rekrutacyjnych dla programistów Middle C++ backend, którzy muszą umieć dyskutować o wydajności, prawie własności i kompromisach produkcyjnych.

Rozpocznij próbną rozmowę AI dla Middle C++Karta płatnicza nie jest wymagana. Dostępna 1 darmowa sesja.
Ćwiczenie rozmów technicznych po angielskuTryb dla osób, dla których angielski nie jest językiem ojczystym, do ćwiczenia rozmów technicznych.

Zarządzanie pamięcią

1Jak w inteligentnych wskaźnikach (smart pointers) działają niestandardowe funkcje zwalniające zasób (custom deleters) i kiedy są one przydatne w przypadku interoperacyjności między C a C++?

Niestandardowa funkcja zwalniająca zasób (custom deleter) to wywoływalna logika sprzątająca, z której korzysta inteligentny wskaźnik w miejsce domyślnej operacji delete w momencie zwalniania posiadanego zasobu. Jest to przydatne w kontekście interoperacyjności pomiędzy językami C i C++, gdy zasób musi zostać zwolniony za pomocą konkretnej funkcji, takiej jak fclose, close, curl_easy_cleanup, SSL_free, free lub dedykowanej funkcji niszczącej dla danej biblioteki. W przypadku unique_ptr typ funkcji zwalniającej zasób jest częścią typu unique_ptr i może wpływać na jego rozmiar; bezstanowe funkcje zwalniające mogą zostać usunięte w procesie optymalizacji, podczas gdy wskaźniki na funkcje lub stanowe funkcje zwalniające alokują dodatkowe miejsce. W przypadku shared_ptr funkcja zwalniająca zasób jest przechowywana w bloku kontrolnym (control block) i wykonuje się, gdy ostatni silny właściciel (strong owner) zwalnia obiekt. Niestandardowe funkcje zwalniające zasób pozwalają na bezpieczne wykorzystywanie zasobów pochodzących z języka C zgodnie z paradygmatem RAII (Resource Acquisition Is Initialization).

Odpowiedz na to pytanie z trenerem AI

2Wyjaśnij mechanikę bloków kontrolnych (control blocks) dla `weak_ptr` oraz `shared_ptr`, uwzględniając cykle referencyjne, `enable_shared_from_this` oraz koszty związane z licznikami referencji.

Obiekt `shared_ptr` zarządza współdzieloną własnością za pośrednictwem bloku kontrolnego, który zawiera liczniki silnych i słabych referencji oraz informacje o czyszczeniu, takie jak niestandardowa funkcja zwalniająca zasób (custom deleter) oraz alokator. Kopiowanie lub niszczenie obiektów `shared_ptr` inkrementuje lub dekrementuje licznik silnych referencji, zazwyczaj za pomocą operacji atomowych, dzięki czemu poszczególne obiekty `shared_ptr` mogą być bezpiecznie manipulowane przez różne wątki, jednak każda aktualizacja licznika referencji niesie ze sobą koszt wydajnościowy. Kiedy licznik silnych referencji osiągnie zero, zarządzany obiekt jest niszczony; blok kontrolny pozostaje w pamięci do momentu, aż znikną również wszystkie słabe referencje. `weak_ptr` wskazuje na ten sam blok kontrolny, nie wydłużając przy tym czasu życia obiektu; metoda `lock()` zwraca `shared_ptr`, jeśli obiekt nadal żyje, lub pusty `shared_ptr` w przeciwnym razie. Cykle zbudowane wyłącznie z `shared_ptr` powodują wycieki pamięci, ponieważ liczniki silnych referencji nigdy nie osiągają zera, dlatego `weak_ptr` stosuje się do wskaźników wstecznych lub w relacjach obserwatora. Funkcja `enable_shared_from_this` pozwala obiektowi będącemu już pod własnością `shared_ptr` utworzyć nowy `shared_ptr` wskazujący na samego siebie z użyciem istniejącego bloku kontrolnego, co pozwala uniknąć niebezpieczeństwa utworzenia oddzielnych bloków kontrolnych. Koszty związane z licznikami referencji obejmują atomową inkrementację/dekrementację, rywalizację o pamięć podręczną (cache contention), alokację bloku kontrolnego oraz narzut wydajnościowy w fragmentach kodu krytycznych dla wydajności.

Odpowiedz na to pytanie z trenerem AI

Szablony

3Wyjaśnij kategorie wartości w języku C++ oraz doskonałe przekazywanie (perfect forwarding), a także dlaczego są one ważne dla wydajnych, generycznych API.

Kategorie wartości w C++ opisują wyrażenia: l-wartości (lvalues) posiadają tożsamość i można się do nich odnosić po zakończeniu wyrażenia; pr-wartości (prvalues) to czyste r-wartości (pure rvalues), takie jak wiele obiektów tymczasowych czy wartości wyliczonych; x-wartości (xvalues) to wygasające obiekty, których zasoby mogą zostać ponownie wykorzystane. Doskonałe przekazywanie (perfect forwarding) to technika szablonowa polegająca na przyjmowaniu referencji przekazującej (forwarding reference), zwykle `T&&`, gdzie `T` jest dedukowane, i przekazywaniu wartości dalej za pomocą `std::forward<T>(arg)`. Dzięki temu kategoria wartości użyta przez wywołującego zostaje zachowana: l-wartości pozostają l-wartościami, a r-wartości pozostają r-wartościami. Reguły zwijania referencji (reference collapsing rules) sprawiają, że jest to możliwe. Ma to znaczenie w przypadku generycznych API backendowych, ponieważ wrappery, fabryki, dyspozytory i funkcje typu emplace mogą unikać niepotrzebnych kopii, zachowując przy tym możliwość doboru odpowiedniego przeciążenia oraz wywołania semantyki przeniesienia (move semantics).

Odpowiedz na to pytanie z trenerem AI

System typów

4Porównaj auto, decltype, decltype(auto) oraz dedukcję argumentów szablonu w typowym kodzie backendowym.

Słowo kluczowe `auto` używa dedukcji podobnej do szablonów dla zmiennych: zwykłe `auto` przeważnie odrzuca referencje oraz najwyższego poziomu modyfikatory `const`, chyba że deklaracja wymaga ich bezpośrednio, na przykład `auto&`, `const auto&` lub `auto&&`. Instrukcja `decltype(expr)` znacznie dokładniej sprawdza zadeklarowany typ lub typ wyrażenia: wyrażenie oparte na identyfikatorze bez nawiasów (id-expression) zwraca zadeklarowany typ, podczas gdy inne wyrażenia zwracające l-wartości dają `T&`, x-wartości dają `T&&`, a pr-wartości dają `T`. Zapis `decltype(auto)` dokonuje dedukcji przy użyciu reguł `decltype`, często wykorzystywanych w typach zwracanych z funkcji, gdy konieczne jest zachowanie referencji. Dedukcja argumentów szablonu działa podobnie do `auto`, ale zależy od formy parametru (np. `T`, `T&`, `const T&` czy `T&&`) i ma własne zasady. Typową różnicą są listy inicjalizacyjne w nawiasach klamrowych: instrukcja `auto x = {1,2}` dedukuje `std::initializer_list<int>`, podczas gdy w przypadku zwykłego parametru szablonu zazwyczaj nie można wydedukować typu `T` z samej listy inicjalizacyjnej, o ile parametr nie oczekuje `initializer_list` lub innego odpowiedniego typu.

Odpowiedz na to pytanie z trenerem AI

5Porównaj `std::variant` z polimorfizmem opartym na dziedziczeniu do modelowania różnorodnych (heterogenicznych) komunikatów lub zdarzeń.

`std::variant` to typ wartościowy, który przechowuje dokładnie jedną z alternatyw z ustalonego, zamkniętego zbioru typów, a najczęściej jest obsługiwany za pomocą `std::visit` lub jawnych zapytań o typ. Jest on przydatny w przypadku wiadomości w protokołach lub zdarzeń, gdy zestaw rodzajów wiadomości jest znany i chcemy bezpiecznie dla typów przetwarzać wiadomości bez użycia wywołań wirtualnych (virtual dispatch), a często też bez alokacji każdego obiektu na stercie. Polimorfizm oparty na dziedziczeniu używa klasy bazowej i funkcji wirtualnych do przekazywania wywołań przez wspólny interfejs; sprawdza się lepiej, gdy zbiór klas pochodnych (typów wiadomości) jest otwarty, niezależnie rozszerzalny, obsługujący wtyczki (plugin-like) lub ukryty za stabilnym interfejsem. Wariant faworyzuje zamknięte typy sumaryczne (sum types), lokalność w pamięci (locality) oraz sprawdzanie w czasie kompilacji; dziedziczenie faworyzuje rozszerzalność, polimorfizm w czasie wykonywania (runtime polymorphism) i projektowanie oparte na interfejsach.

Odpowiedz na to pytanie z trenerem AI

Semantyka języka

6Czym jest niezdefiniowane zachowanie (undefined behavior) w C++ i jak może objawiać się w postaci incydentów na produkcji w kodzie backendowym?

Niezdefiniowane zachowanie (undefined behavior - UB) to zachowanie, co do którego po dokonaniu nieprawidłowej operacji standard języka C++ nie nakłada w zasadzie żadnych ścisłych wymagań. Pogram wykazujący to zjawisko może pozornie funkcjonować prawidłowo, ulegać awariom, uszkadzać zawartość w pamięci, ujawniać błędy związane z bezpieczeństwem bądź podlegać optymalizacjom skutkującymi nieoczekiwanym skutkiem końcowym. Kompilatory zakładają zwyczajnie, że zjawisko nie występuje, więc dobierają formę kompilacji adekwatnie, dlatego też usterki bywają dostrzegane dopiera w skompilowanych programach typu release czy po uruchomieniu ruchu na produkcji. Błędy backendowe wynikają niekiedy z istnienia wiszących wskaźników/referencji, odwołań do już wyczyszczonej pamięci (use-after-free), naruszenia cyklu czasu życia obiekty (object lifetime violations), czytników odczytujących z miejsc po za wyznaczoną granicą bufora, tzw. przepełnienia całkowitoliczbowego na typach ze znakiem, wyścigu o zasoby po stronie danych (data races), nieważnych rzutowań, podwójnego usunięcia czy też nieuprawnionych odwołań naruszających tzw. strict-aliasing. Obniżenie ryzyka wymusza bazowanie na RAII (Resource Acquisition Is Initialization), czytelnym zarządzaniu prawami własności/cyklem obiektu z jednoczesnym wprowadzeniu testów fuzzingu, audytu, sprawdzaniu granic (bounds checks) ze stosownym oparciem się np. na tzw. sanitizerach takich jak narzędzia typu ASan, UBSan, TSan.

Odpowiedz na to pytanie z trenerem AI

7Czym różnią się zachowanie niezdefiniowane (undefined behavior), zachowanie nieokreślone (unspecified behavior) oraz zachowanie zależne od implementacji (implementation-defined behavior)? Podaj przykłady istotne z punktu widzenia backendu.

Zachowanie niezdefiniowane (undefined behavior, UB) oznacza, że standard języka C++ nie nakłada żadnych wymagań; rezultatem mogą być awarie aplikacji, uszkodzenie danych, błędy bezpieczeństwa lub błędna kompilacja zależna od optymalizatora. Przykłady to dostęp poza granicami tablicy (out-of-bounds access), użycie zwolnionej pamięci (use-after-free), przepełnienie liczby całkowitej ze znakiem (signed integer overflow) oraz wyścigi danych (data races). Zachowanie nieokreślone (unspecified behavior) oznacza, że standard dopuszcza więcej niż jeden wynik, a implementacja nie musi dokumentować swojego wyboru; powszechnym przykładem jest kolejność ewaluacji wielu argumentów funkcji, dlatego kod nie powinien polegać na określonym porządku wykonywania efektów ubocznych. Zachowanie zależne od implementacji (implementation-defined behavior) oznacza, że kompilator musi wybrać i udokumentować zachowanie; przykłady to możliwość przechowywania wartości ujemnych w zwykłym typie `char`, rozmiary i zakresy niektórych typów podstawowych w limitach narzuconych przez standard, a także zachowanie przy przesunięciu bitowym w prawo dla liczb ze znakiem. W kodzie backendowym UB to ryzyko związane z poprawnością i bezpieczeństwem, natomiast zachowania nieokreślone i zależne od implementacji stanowią zagrożenie dla przenośności (portability), których należy unikać lub izolować w logice wieloplatformowej, warstwie przechowywania danych czy protokołach.

Odpowiedz na to pytanie z trenerem AI

Obsługa błędów

8Wyjaśnij poziomy bezpieczeństwa wyjątków (exception safety levels) oraz to, jak słowo kluczowe `noexcept` wpływa na operacje przenoszenia, kontenery i generowanie kodu.

Poziomy bezpieczeństwa wyjątków określają, co pozostaje prawdą, jeśli operacja rzuci wyjątkiem. Podstawowa gwarancja oznacza, że niezmienniki są zachowane i nie dochodzi do wycieków zasobów, choć stan mógł ulec zmianie. Silna gwarancja to inaczej semantyka zatwierdź-lub-wycofaj (commit-or-rollback): w przypadku błędu obserwowalny stan pozostaje bez zmian. Gwarancja braku wyjątków (nothrow guarantee) oznacza, że operacja wcale nie rzuca wyjątków. Powszechne techniki osiągnięcia tych gwarancji obejmują mechanizm RAII (Resource Acquisition Is Initialization), wykonywanie operacji na obiektach tymczasowych, idiom kopiuj-i-zamień (copy-and-swap) oraz porządkowanie modyfikacji w taki sposób, aby zatwierdzenie nastąpiło dopiero po pomyślnym wykonaniu pracy narażonej na rzucenie wyjątku. Słowo kluczowe `noexcept` to swego rodzaju kontrakt: jeśli funkcja `noexcept` rzuci wyjątkiem, zostanie wywołana funkcja `std::terminate`. Wpływa to również na kod generyczny i kontenery: na przykład podczas realokacji `std::vector` może przenosić elementy, jeśli konstruktor przenoszący jest `noexcept`; w przeciwnym razie może je kopiować, często za pomocą funkcji `std::move_if_noexcept`, by utrzymaować gwarancje względem wyjątków. Odpowiednie zastosowanie `noexcept` może również pomóc w generowaniu kodu i jego optymalizacji poprzez zmniejszenie wymaganych ścieżek propagacji wyjątków, pod warunkiem jednak, że powyższa obietnica jest rzeczywiście poprawna.

Odpowiedz na to pytanie z trenerem AI

9Wyjaśnij zastosowanie `std::expected` lub równoważnych typów wynikowych (outcome types) jako alternatywy dla wyjątków w operacjach mogących zakończyć się niepowodzeniem (fallible operations).

`std::expected<T, E>` reprezentuje pomyślną wartość `T` lub jawny błąd `E`, sprawiając, że niepowodzenie staje się częścią zwracanego typu funkcji, zamiast polegać na propagacji wyjątków. Jest to przydatne w przypadku operacji mogących zakończyć się błędem, gdzie błędy są spodziewane, a wywołujący powinni obsługiwać je lokalnie – takich jak parsowanie, walidacja, wywołania sieciowe, odpytywanie pamięci masowej (storage lookups) oraz ponawialne operacje backendowe. Typ błędu powinien przenosić ustrukturyzowane informacje, takie jak kod błędu, kategoria, możliwość ponowienia, komunikat lub mapowanie na kody HTTP (Hypertext Transfer Protocol) / RPC (Remote Procedure Call). Typy oczekiwane/wynikowe (expected/outcome) pozwalają na kompozycję poprzez sprawdzanie i propagację błędów, a w API (Application Programming Interface) w stylu C++23 mogą wykorzystywać operacje monadyczne, takie jak `and_then`, `transform` oraz `or_else`, do łączenia kroków w łańcuchy bez głęboko zagnieżdżonych instrukcji warunkowych. W porównaniu z wyjątkami, czynią one przepływ sterowania i kontrakty API jawnymi, a ponadto dobrze sprawdzają się w środowiskach bez wyjątków lub na granicach ABI (Application Binary Interface); wyjątki mogą nadal być odpowiednie dla rzadkich, nielokalnych lub prawdziwie wyjątkowych awarii, w zależności od przyjętej polityki projektu.

Odpowiedz na to pytanie z trenerem AI

Czas życia obiektów

10Opisz reguły określające czas życia obiektów dla obiektów automatycznych, dynamicznych, tymczasowych oraz referowanych asynchronicznie.

Obiekty automatyczne żyją od momentu ich konstrukcji do końca swojego zasięgu (scope); wskaźniki lub referencje do nich stają się po wyjściu z tego zasięgu wiszące (dangling). Obiekty dynamiczne żyją od alokacji/konstrukcji do momentu, w którym zostaną jawnie zniszczone lub zniszczy je obiekt będący ich właścicielem; surowe wskaźniki (raw pointers) i referencje nie wydłużają czasu ich życia. Obiekty tymczasowe (temporaries) żyją zazwyczaj do końca pełnego wyrażenia, a przy wiązaniu ich z referencjami obowiązują szczególne zasady wydłużania czasu życia, choć nie we wszystkich przypadkach użycia. Praca asynchroniczna, taka jak wywołania zwrotne, wątki, timery czy korutyny (coroutines), może być kontynuowana po zniszczeniu referowanego obiektu, dlatego przechwytywane zmienne (captures) i przechowywane referencje wymagają jawnego zarządzania czasem życia, by zapobiegać powstawaniu wiszących referencji oraz błędom typu use-after-free.

Odpowiedz na to pytanie z trenerem AI

11Opisz problemy związane z kolejnością statycznej inicjalizacji oraz to, jak constinit, statyczne zmienne lokalne w funkcjach (magic statics) i wstrzykiwanie zależności (dependency injection) pozwalają je złagodzić.

Problemy związane z kolejnością statycznej inicjalizacji pojawiają się, ponieważ dynamicznie inicjowane obiekty w zakresie przestrzeni nazw (namespace-scope) lub obiekty statyczne w różnych jednostkach translacji mają nieokreśloną względem siebie kolejność inicjalizacji. Jedna zmienna globalna może użyć innej, zanim ta zostanie skonstruowana; kolejność niszczenia może również tworzyć podobne problemy przy zamykaniu programu. Słowo kluczowe constinit wymusza, by zmienna statyczna lub lokalna dla wątku miała inicjalizację statyczną/stałą; w przeciwnym razie program będzie niepoprawnie sformułowany (ill-formed). Pozwala to uniknąć zależności od dynamicznej kolejności inicjalizacji dla tej zmiennej. Zmienne statyczne lokalne wewnątrz funkcji (tzw. magic statics) są inicjowane przy pierwszym użyciu i są bezpieczne wątkowo (thread-safe) począwszy od standardu C++11. Wstrzykiwanie zależności unika ukrytych zależności globalnych poprzez konstruowanie obiektów w kontrolowanej kolejności i jawne przekazywanie referencji.

Odpowiedz na to pytanie z trenerem AI

12Czym są pominięcie kopii (copy elision), NRVO (Named Return Value Optimization) oraz RVO (Return Value Optimization) i kiedy można na nich polegać?

Pominięcie kopii oznacza konstruowanie obiektu bezpośrednio w jego docelowym miejscu, zamiast tworzenia osobnego obiektu tymczasowego i jego kopiowania lub przenoszenia. RVO zazwyczaj odnosi się do zwracania nienazwanego obiektu tymczasowego lub wartości typu prvalue, np. `return T{};`; w standardzie C++17 i nowszych wiele z tych przypadków jest obowiązkowych, ponieważ prvalue bezpośrednio inicjalizuje obiekt wynikowy. NRVO polega na zwracaniu nazwanego obiektu lokalnego, np. `return x;`; kompilator ma prawo skonstruować ten obiekt lokalny bezpośrednio w miejscu przeznaczonym na wartość zwracaną (return slot) wywołującego, ale nie jest to gwarantowane. Możesz polegać na obowiązkowym w C++17 pominięciu kopii dla prvalue w określonych przypadkach, ale nie na tym, że NRVO zawsze nastąpi; unikaj używania `std::move` na nazywanej lokalnej wartości zwracanej, ponieważ może to zapobiec zastosowaniu NRVO.

Odpowiedz na to pytanie z trenerem AI

Biblioteka standardowa

13Jak wymagania dotyczące komparatora, relacji równości i funkcji haszującej wpływają na poprawność działania kontenerów asocjacyjnych?

Kontenery asocjacyjne polegają na porównywaniu, równości i haszowaniu w celu określenia tożsamości klucza oraz utrzymania swoich wewnętrznych niezmienników (invariants). Kontenery uporządkowane, takie jak std::map, wymagają, aby komparator narzucał ścisły słaby porządek (strict weak ordering); klucze uważa się za równoważne, gdy żaden z nich nie jest mniejszy od drugiego, niekoniecznie z użyciem operator==. Kontenery nieuporządkowane wymagają, aby równość była relacją równoważności, a każde dwa klucze uznawane za równe muszą generować tę samą wartość skrótu (hash). Kluczy nie wolno modyfikować po ich zapisaniu w sposób, który zmienia ich porządek, równość lub wartość skrótu, ponieważ może to spowodować nieprawidłowe działanie wyszukiwania, usuwania oraz mechanizmu zapewniania unikalności.

Odpowiedz na to pytanie z trenerem AI

14Wyjaśnij wyszukiwanie heterogeniczne (heterogeneous lookup) w uporządkowanych i nieuporządkowanych kontenerach asocjacyjnych.

Wyszukiwanie heterogeniczne pozwala na przeszukiwanie kontenera asocjacyjnego przy użyciu typu innego niż typ jego klucza, unikając tym samym tworzenia tymczasowych kluczy. W kontenerach uporządkowanych wymaga to przezroczystego komparatora (transparent comparator), na przykład `std::less<>` lub niestandardowego komparatora ze znacznikiem `is_transparent`. W kontenerach nieuporządkowanych wymaga to zarówno przezroczystego funktora mieszającego (transparent hash), jak i funktora równości, które spójnie obsługują typ klucza i typ wyszukiwany. Częstym przykładem w aplikacjach backendowych jest kontener z kluczem typu `std::string`, który można przeszukiwać za pomocą `std::string_view` lub `const char*` bez alokowania tymczasowego obiektu `std::string`.

Odpowiedz na to pytanie z trenerem AI

Współbieżność

15Wyjaśnij model pamięci w C++: wyścigi danych (data races), relację „dzieje się przed” (happens-before) oraz gwarancje synchronizacji.

Model pamięci w języku C++ dokładnie definiuje reguły, według których operacje w poszczególnych wątkach zostają uporządkowane i ustalają kiedy dokonywane operacje zapisu stają się po wykonaniu zauważalne/widoczne. Wyścig danych (data race) występuje, jeżeli dwa odrębne wątki mają równoczesny wgląd (dostęp) do identycznej alokacji, przynajmniej raz przeprowadzany jest odczyt modyfikujący/zapis i poszczególne z tych zdarzeń nie zostają zabezpieczone przez typowo atomowe procedury bądź nie opierają swego porządku w relacji „dzieje się przed” (happens-before). Obecność wyścigu danych objawia się w rezultacie jako niezdefiniowane zachowanie (undefined behavior). Koncepcja happens-before ustanawia relację i zapewnia taki porządek między wątkami, że zrealizowane przez jedne z nich początkowe skutki uboczne stają się poprawnie zauważane przez zlecane później polecenia. Te sekwencje tworzą właśnie poszczególne operacje z ramki synchronizacyjnej: przykładowo oddanie w dyspozycję uprzednio zablokowanego obiektu muteksu (unlock) wymusza bezpośrednią synchronizację w koordynacji dla przyszłej re-lokalizacji blokady (lock) wybranej na podstawie relacji synchronizes-with, a także zaadaptowane i dobrze skrojone polecenia atomowego wariantu (release/acquire). Błędoodporny system powszechnie stosuje blokady, warianty atomowe i innej postaci systemowe struktury celem zagwarantowania wymogów happens-before z uwzględnieniem całej reszty stanu wielodzielonego.

Odpowiedz na to pytanie z trenerem AI