Priprema za Middle C++

Middle C++ Backend interview pitanja

15 izabranih Middle C++ interview pitanja za backend developere koji treba da razgovaraju o performance, ownership i production trade-offs.

Započni Middle C++ AI intervjuKreditna kartica nije potrebna. Dostupna je 1 besplatna sesija.
Vežba tehničkog intervjua na engleskomRežim u kome kandidati kojima engleski nije maternji mogu da vežbaju tehničke intervjue.

Upravljanje memorijom

1Kako funkcionišu prilagođeni brisači (custom deleters) u pametnim pokazivačima i kada su korisni za interoperabilnost između C i C++ jezika?

Prilagođeni brisač je pozivna logika za oslobađanje resursa koju pametni pokazivač koristi umesto podrazumevane delete operacije kada otpušta resurs kojim upravlja. Koristan je za interoperabilnost između C i C++ jezika kada resurs mora biti oslobođen specifičnom funkcijom kao što su fclose, close, curl_easy_cleanup, SSL_free, free ili funkcija za uništavanje iz neke biblioteke. Kod unique_ptr, tip brisača je deo tipa unique_ptr i može uticati na njegovu veličinu; brisači bez stanja (stateless) mogu biti optimizovani tako da ne zauzimaju memoriju, dok pokazivači na funkcije ili brisači sa stanjem dodaju memorijsko zauzeće. Kod shared_ptr, brisač se čuva u kontrolnom bloku i izvršava se kada poslednji jaki vlasnik otpusti objekat. Prilagođeni brisači omogućavaju da C resursi bezbedno učestvuju u RAII (Resource Acquisition Is Initialization) obrascu.

using FilePtr = std::unique_ptr<FILE, int(*)(FILE*)>;

FilePtr open_file(const char* path) {
    return FilePtr(std::fopen(path, "r"), &std::fclose);
}

struct CurlDeleter {
    void operator()(CURL* h) const noexcept {
        if (h) curl_easy_cleanup(h);
    }
};

using CurlPtr = std::unique_ptr<CURL, CurlDeleter>;
CurlPtr make_curl() {
    return CurlPtr(curl_easy_init());
}
Pokušajte da odgovorite na ovo pitanje sa AI trenerom

2Objasnite mehaniku kontrolnog bloka za weak_ptr i shared_ptr, uključujući ciklične reference, enable_shared_from_this i troškove brojanja referenci.

`shared_ptr` upravlja deljenim vlasništvom putem kontrolnog bloka koji sadrži brojače jakih i slabih referenci, kao i informacije o brisanju resursa kao što su brisač (`deleter`) i alokator. Kopiranje ili uništavanje `shared_ptr` objekata uvećava ili umanjuje broj jakih referenci, obično pomoću atomičnih operacija, pa se zasebnim `shared_ptr` objektima može bezbedno manipulisati kroz više niti, ali svako ažuriranje brojača referenci nosi određeni trošak. Kada broj jakih referenci padne na nulu, upravljani objekat se uništava; kontrolni blok ostaje u memoriji sve dok ne nestanu i sve slabe reference. `weak_ptr` pokazuje na isti kontrolni blok bez produžavanja životnog veka objekta; `lock()` vraća `shared_ptr` ako je objekat još uvek živ, a prazan `shared_ptr` u suprotnom. Ciklične reference sačinjene isključivo od `shared_ptr` dovode do curenja memorije jer broj jakih referenci nikada ne dostiže nulu, pa se `weak_ptr` koristi za povratne pokazivače ili veze posmatrača (`observer`). `enable_shared_from_this` omogućava objektu koji već poseduje `shared_ptr` da kreira novi `shared_ptr` ka samom sebi koristeći postojeći kontrolni blok, čime se izbegava opasnost od stvaranja višestrukih zasebnih kontrolnih blokova. Troškovi brojanja referenci obuhvataju atomično uvećavanje/umanjivanje, konflikte u kešu (`cache contention`), alokaciju kontrolnog bloka i dodatno opterećenje u delovima koda kritičnim za performanse.

struct Parent;

struct Child {
    std::weak_ptr<Parent> parent; // non-owning back-reference
};

struct Parent {
    std::vector<std::shared_ptr<Child>> children; // owns children
};

void example() {
    auto p = std::make_shared<Parent>();
    auto c = std::make_shared<Child>();
    c->parent = p;
    p->children.push_back(c);

    if (auto locked = c->parent.lock()) {
        // safe use while Parent is still alive
    }
}
Pokušajte da odgovorite na ovo pitanje sa AI trenerom

Templates

3Objasnite kategorije vrednosti u jeziku C++ i savršeno prosleđivanje (perfect forwarding), kao i zašto su oni važni za efikasne generičke API-je (Application Programming Interface).

Kategorije vrednosti u jeziku C++ opisuju izraze: l-vrednosti (lvalues) imaju identitet i na njih se može referencirati nakon izraza; pr-vrednosti (prvalues) su čiste r-vrednosti poput mnogih privremenih/izračunatih vrednosti; x-vrednosti (xvalues) su objekti kojima ističe životni vek i čiji se resursi mogu ponovo iskoristiti. Savršeno prosleđivanje je šablonska tehnika uzimanja prosleđujuće reference, tipično T&& gde se T dedukuje, i prosleđivanja pomoću std::forward<T>(arg) tako da se očuva kategorija vrednosti pozivaoca: l-vrednosti ostaju l-vrednosti, a r-vrednosti ostaju r-vrednosti. Pravila kolapsiranja referenci (reference collapsing) ovo čine mogućim. Ovo je važno za generičke backend API-je jer omotači, fabrike, dispečeri i funkcije u stilu emplace mogu da izbegnu nepotrebna kopiranja i očuvaju izbor preopterećenja i ponašanje premeštanja.

#include <iostream>
#include <string>
#include <utility>

void sink(const std::string&) { std::cout << "const ref\n"; }
void sink(std::string&&) { std::cout << "rvalue ref\n"; }

template <class T>
void wrapper(T&& arg) {
    sink(std::forward<T>(arg));
}

int main() {
    std::string s = "hello";
    wrapper(s);
    wrapper(std::string{"x"});
}
Pokušajte da odgovorite na ovo pitanje sa AI trenerom

Sistem tipova

4Uporedite auto, decltype, decltype(auto) i dedukciju argumenata šablona u uobičajenom backend kodu.

auto koristi dedukciju nalik na šablone za promenljive: obično auto uglavnom odbacuje reference i const na najvišem nivou (top-level const) osim ako deklaracija to ne zahteva, kao što su auto&, const auto& ili auto&&. decltype(expr) preciznije proverava deklarisani tip ili tip izraza: nezagrađeni id-izraz daje deklarisani tip, dok ostali lvalue izrazi daju T&, xvalue izrazi daju T&&, a prvalue izrazi daju T. decltype(auto) vrši dedukciju koristeći decltype pravila, često za povratne tipove kada reference moraju biti očuvane. Dedukcija argumenata šablona je slična kao kod auto, ali zavisi od oblika parametra, kao što su T, T&, const T& ili T&&, i ima svoja specifična pravila. Inicijalizatori u vitičastim zagradama predstavljaju čestu razliku: auto x = {1,2} dedukuje std::initializer_list<int>, dok običan parametar šablona generalno ne može dedukovati T iz samog inicijalizatora u vitičastim zagradama osim ako parametar ne očekuje initializer_list ili drugi odgovarajući tip.

int x = 1;
const int cx = 2;
int& rx = x;

auto a = rx;        // int, copy of x
auto b = cx;        // int, top-level const dropped
const auto& c = cx; // const int&, reference preserved by declaration

a = 10;             // does not change x
Pokušajte da odgovorite na ovo pitanje sa AI trenerom

5Uporedite std::variant sa polimorfizmom zasnovanim na nasleđivanju za modelovanje heterogenih poruka ili događaja.

std::variant je vrednosni tip koji čuva tačno jednu alternativu iz fiksnog, zatvorenog skupa tipova i njime se obično rukuje pomoću std::visit ili eksplicitnih upita nad tipom. Koristan je za protokolske poruke ili događaje kada je skup vrsta poruka poznat i kada želite tipski bezbednu (type-safe) obradu bez virtuelnog otpremanja (virtual dispatch) i često bez dinamičke alokacije na hipu po objektu. Polimorfizam zasnovan na nasleđivanju koristi osnovnu klasu i virtuelne funkcije za otpremanje poziva kroz zajednički interfejs; bolji je kada je skup izvedenih tipova poruka otvoren, nezavisno proširiv, zasnovan na dodacima (plugins) ili sakriven iza stabilnog interfejsa. Variant favorizuje zatvorene sume tipova, lokalnost podataka i proveru u vreme kompajliranja; nasleđivanje favorizuje proširivost, polimorfizam u vreme izvršavanja i dizajn zasnovan na interfejsima.

struct UserCreated { std::string id; };
struct UserDeleted { std::string id; };
struct PasswordChanged { std::string id; };

using Event = std::variant<UserCreated, UserDeleted, PasswordChanged>;

void handle(Event const& e) {
    std::visit([](auto const& msg) {
        using T = std::decay_t<decltype(msg)>;
        if constexpr (std::is_same_v<T, UserCreated>) {
            // handle creation
        } else if constexpr (std::is_same_v<T, UserDeleted>) {
            // handle deletion
        } else if constexpr (std::is_same_v<T, PasswordChanged>) {
            // handle password change
        }
    }, e);
}
Pokušajte da odgovorite na ovo pitanje sa AI trenerom

Semantika jezika

6Šta je nedefinisano ponašanje u jeziku C++ i kako se može manifestovati u produkcionim incidentima na backend-u?

Nedefinisano ponašanje jeste ponašanje za koje C++ standard ne postavlja nikakve zahteve nakon što dođe do nevalidne operacije. Program može izgledati kao da radi, može se srušiti, oštetiti podatke, stvoriti bezbednosne propuste ili se usled optimizacija ponašati neočekivano. Kompajleri pretpostavljaju da se nedefinisano ponašanje ne dešava i vrše optimizacije na osnovu te pretpostavke, pa se problemi često manifestuju tek u release verzijama ili pod produkcionim opterećenjem. Incidenti na backend-u mogu nastati usled visećih pokazivača/referenci, korišćenja memorije nakon oslobađanja (use-after-free), narušavanja životnog veka objekata, pristupa van granica niza, prekoračenja označenih celih brojeva, trka za podacima, nevalidnih kastovanja, čitanja neinicijalizovane memorije, dvostrukog oslobađanja memorije ili kršenja pravila strogog aliasovanja (strict aliasing). Ublažavanje ovih problema uključuje primenu RAII (Resource Acquisition Is Initialization) idioma, jasno definisano vlasništvo i životni vek, bezbednije apstrakcije i proveru granica, testiranje/fuzzing, pregled koda, statičku analizu i sanitizere kao što su ASan, UBSan i TSan.

int grow(int x) {
    if (x + 1 < x) {
        return 0; // compiler may assume this is unreachable
    }
    return x + 1;
}
Pokušajte da odgovorite na ovo pitanje sa AI trenerom

7Objasnite razlike između nedefinisanog, nespecifikovanog i implementaciono definisanog ponašanja uz primere relevantne za backend.

Nedefinisano ponašanje (UB – Undefined Behavior) znači da C++ standard ne propisuje nikakve zahteve; posledice mogu obuhvatati padove programa, oštećenje podataka, bezbednosne propuste ili pogrešnu kompilaciju zavisnu od optimizatora. Primeri obuhvataju pristup van granica niza, upotrebu memorije nakon oslobađanja (use-after-free), prekoračenje označenog celog broja i trke za podacima. Nespecifikovano ponašanje znači da standard dozvoljava više mogućih ishoda i implementacija nije dužna da dokumentuje koji je izabran; čest primer je redosled evaluacije argumenata funkcije, pa kod ne bi trebalo da zavisi od toga kojim se redosledom sporedni efekti evaluiraju. Implementaciono definisano ponašanje znači da implementacija mora izabrati i dokumentovati ponašanje; primeri obuhvataju označenost običnog tipa `char`, veličine i opsege nekih osnovnih tipova unutar standardnih granica i pojedina ponašanja pri pomeranju označenih brojeva udesno. U backend kodu, UB predstavlja rizik za korektnost i bezbednost, dok su nespecifikovano i implementaciono definisano ponašanje rizici po prenosivost koje treba izbegavati ili izolovati u logici protokola, skladištenja i međuplatformskoj podršci.

int i = 0;
f(i++, i++); // both increments happen, but the order of argument evaluation is unspecified

// Prefer:
int a = i++;
int b = i++;
f(a, b);
Pokušajte da odgovorite na ovo pitanje sa AI trenerom

Obrada grešaka

8Objasnite nivoe bezbednosti u odnosu na izuzetke i kako noexcept utiče na operacije premeštanja, kontejnere i generisanje koda.

Nivoi bezbednosti u odnosu na izuzetke opisuju šta ostaje važeće ako operacija baci izuzetak. Osnovna garancija znači da su invarijante očuvane i da nema curenja resursa, iako se stanje možda promenilo. Jaka garancija podrazumeva semantiku potvrde ili poništavanja: u slučaju neuspeha, vidljivo stanje ostaje nepromenjeno. Garancija bez izuzetaka znači da operacija nikada ne baca izuzetak. Uobičajene tehnike uključuju RAII (Resource Acquisition Is Initialization), rad nad privremenim objektima, idiom „kopiraj i zameni” (engl. copy-and-swap) i takvo raspoređivanje mutacija da se konačna promena izvrši tek nakon što sav kôd koji može baciti izuzetak uspešno završi. `noexcept` predstavlja ugovor: ako funkcija označena sa `noexcept` baci izuzetak, poziva se `std::terminate`. To takođe utiče na generički kôd i kontejnere: na primer, tokom realokacije `std::vector` može premeštati elemente ako je konstruktor premeštanja `noexcept`; u suprotnom može vršiti kopiranje, često putem `std::move_if_noexcept`, kako bi očuvao garancije pri izuzecima. `noexcept` takođe može pomoći pri generisanju koda ili optimizaciji smanjenjem potrebnih putanja za propagaciju izuzetaka, ali samo kada je obećanje tačno.

std::vector<T> v;
// During reallocation, an implementation may effectively do:
T* dest = allocate(new_cap);
for (T& x : old_storage) {
    construct(dest++, std::move_if_noexcept(x));
}
Pokušajte da odgovorite na ovo pitanje sa AI trenerom

9Objasnite `std::expected` ili ekvivalentne tipove ishoda kao alternative izuzecima za operacije koje mogu da ne uspeju.

`std::expected<T, E>` predstavlja ili uspešnu vrednost `T` ili eksplicitnu grešku `E`, čineći neuspeh delom povratnog tipa funkcije umesto oslanjanja na propagaciju izuzetaka. Koristan je za operacije sklone greškama gde su greške očekivane i gde pozivaoci treba lokalno da ih obrade, kao što su parsiranje, validacija, mrežni pozivi, pretrage u skladištu podataka i ponovljive backend operacije. Tip greške treba da nosi strukturirane informacije kao što su kod greške, kategorija, mogućnost ponovnog pokušaja, poruka ili mapiranje na HTTP (Hypertext Transfer Protocol) / RPC (Remote Procedure Call). Tipovi ishoda se komponuju proverom i propagiranjem grešaka, a u API-jima u stilu C++23 mogu koristiti monadske operacije kao što su `and_then`, `transform` i `or_else` za ulančavanje koraka bez duboko ugnježdenih uslova. U poređenju sa izuzecima, oni čine tok kontrole i API ugovore eksplicitnim i odlično funkcionišu preko granica bez izuzetaka ili granica ABI-ja (Application Binary Interface); izuzeci mogu i dalje biti prikladni za retke, nelokalne ili zaista izuzetne neuspehe u zavisnosti od pravila projekta.

enum class Errc { BadRequest, NotFound, Timeout, DbUnavailable };

struct Error {
    Errc code;
    bool retryable;
    std::string message;
};

std::expected<User, Error> load_user(UserId id);

HttpResponse handle(UserId id) {
    auto user = load_user(id);
    if (!user) {
        switch (user.error().code) {
            case Errc::BadRequest: return {400, user.error().message};
            case Errc::NotFound: return {404, "not found"};
            case Errc::Timeout: return {503, "try again"};
            case Errc::DbUnavailable: return {503, "service unavailable"};
        }
    }
    return {200, serialize(*user)};
}
Pokušajte da odgovorite na ovo pitanje sa AI trenerom

Životni vek objekta

10Opišite pravila životnog veka objekata za automatske, dinamičke, privremene i asinhrono referencirane objekte.

Automatski objekti žive od trenutka konstrukcije do kraja svog opsega; pokazivači ili reference na njih postaju viseći nakon izlaska iz tog opsega. Dinamički objekti žive od trenutka alokacije/konstrukcije dok se eksplicitno ne unište ili dok ih vlasnički objekat ne uništi; sirovi pokazivači i reference ne produžavaju njihov životni vek. Privremeni objekti obično žive do kraja punog izraza, sa specifičnim pravilima produženja životnog veka kada se vežu za reference, ali ne u svakom slučaju upotrebe. Asinhroni rad, kao što su povratni pozivi, niti, tajmeri ili korutine, može se izvršavati nakon što je referencirani objekat uništen, pa je za preuzimanja i sačuvane reference neophodno eksplicitno upravljanje životnim vekom kako bi se izbegle viseće reference i upotreba nakon oslobađanja.

std::function<void()> make_cb() {
    std::string s = "hello";
    return [&] { std::cout << s << '\n'; }; // BAD: captures local by reference
} // s is destroyed here

std::function<void()> safe_cb() {
    auto s = std::make_shared<std::string>("hello");
    return [s] { std::cout << *s << '\n'; }; // OK: callback owns the string
}
Pokušajte da odgovorite na ovo pitanje sa AI trenerom

11Opišite probleme sa redosledom statičke inicijalizacije i kako ih constinit, magične statičke promenljive (magic statics) i ubrizgavanje zavisnosti (dependency injection) ublažavaju.

Problemi sa redosledom statičke inicijalizacije nastaju zato što dinamički inicijalizovani objekti u opsegu imenskog prostora (namespace) ili statički objekti u različitim prevodilačkim jedinicama (translation units) imaju neodređen međusobni redosled inicijalizacije. Jedna globalna promenljiva može koristiti drugu pre nego što je ona konstruisana; redosled destrukcije takođe može stvoriti slične probleme pri gašenju programa. `constinit` primorava statičku promenljivu ili promenljivu lokalnu za nit (thread-local) da ima statičku/konstantnu inicijalizaciju, inače program nije sintaksno ispravan (ill-formed), čime se izbegava zavisnost od redosleda dinamičke inicijalizacije za tu promenljivu. Magične statičke promenljive, odnosno statičke promenljive lokalne za funkciju, inicijalizuju se pri prvoj upotrebi i bezbedne su za rad sa nitima još od standarda C++11. Ubrizgavanje zavisnosti izbegava skrivene globalne zavisnosti konstruisanjem objekata kontrolisanim redosledom i eksplicitnim prosleđivanjem zavisnosti.

// a.cpp
extern std::string configPath;
Logger logger(configPath); // may run before configPath is dynamically initialized

// b.cpp
std::string configPath = readConfigPath();
Pokušajte da odgovorite na ovo pitanje sa AI trenerom

12Šta su elizija kopiranja (copy elision), NRVO (Named Return Value Optimization) i RVO (Return Value Optimization) i kada se možete osloniti na njih?

Elizija kopiranja (copy elision) podrazumeva konstruisanje objekta direktno na njegovom konačnom odredištu umesto kreiranja zasebnog privremenog objekta i njegovog kopiranja ili premeštanja. RVO se obično odnosi na vraćanje neimenovanog privremenog objekta ili prvalue izraza, kao što je `return T{};`; od standarda C++17 pa nadalje, mnogi od ovih slučajeva su obavezni jer prvalue direktno inicijalizuje rezultujući objekat. NRVO predstavlja vraćanje imenovanog lokalnog objekta, kao što je `return x;`; kompajleru je dozvoljeno da konstruiše taj lokalni objekat direktno u povratnom memorijskom prostoru pozivaoca, ali to nije garantovano. Možete se osloniti na obaveznu eliziju prvalue izraza u C++17 u navedenim slučajevima, ali ne i na to da će se NRVO uvek dogoditi; izbegavajte `std::move` nad imenovanom lokalnom povratnom vrednošću jer to može sprečiti NRVO.

struct T {
    T();
    T(const T&) = delete;
    T(T&&) = delete;
};

T make() {
    return T{}; // OK in C++17+: result object is constructed directly
}
Pokušajte da odgovorite na ovo pitanje sa AI trenerom

Standardna biblioteka

13Kako zahtevi za komparator, jednakost i heš funkciju utiču na ispravnost asocijativnih kontejnera?

Asocijativni kontejneri se oslanjaju na poređenje, jednakost i heširanje kako bi definisali identitet ključa i očuvali svoje interne invarijante. Uređeni kontejneri kao što je `std::map` zahtevaju da komparator uspostavi strogo slabo uređenje (strict weak ordering); ključevi se smatraju ekvivalentnim kada nijedan nije manji od drugog, a ne nužno preko operatora `operator==`. Neuređeni kontejneri zahtevaju da relacija jednakosti bude relacija ekvivalencije, a bilo koja dva ključa koja se smatraju jednakim moraju proizvesti istu heš vrednost. Ključevi se ne smeju menjati dok su uskladišteni na način koji menja njihov redosled, jednakost ili heš vrednost, jer to može narušiti ispravnost operacija pretrage, brisanja i provere jedinstvenosti.

struct Key {
    int tenant;
    std::string id;
};

struct KeyEq {
    bool operator()(Key const& a, Key const& b) const {
        return a.tenant == b.tenant && a.id == b.id;
    }
};

struct KeyHash {
    std::size_t operator()(Key const& k) const {
        return std::hash<int>{}(k.tenant) ^
               (std::hash<std::string>{}(k.id) << 1);
    }
};

std::unordered_map<Key, int, KeyHash, KeyEq> m;
Pokušajte da odgovorite na ovo pitanje sa AI trenerom

14Objasnite heterogeno pretraživanje u uređenim i neuređenim asocijativnim kontejnerima.

Heterogeno pretraživanje omogućava da se asocijativni kontejner pretražuje korišćenjem tipa koji se razlikuje od tipa njegovog ključa, čime se izbegava kreiranje privremenog ključa. U uređenim kontejnerima, ovo zahteva transparentni komparator, na primer std::less<> ili prilagođeni komparator sa oznakom is_transparent. U neuređenim kontejnerima, potrebni su i transparentna heš funkcija i transparentni funktor jednakosti koji mogu dosledno da rukuju tipom ključa i tipom za pretragu. Čest primer na serverskoj strani jeste kontejner čiji je ključ std::string, a koji se može pretraživati pomoću std::string_view ili const char* bez alokacije privremenog std::string objekta.

std::map<std::string, int, std::less<>> counts;
counts.emplace("alpha", 1);

std::string_view key = "alpha";
auto it = counts.find(key); // no temporary std::string required
Pokušajte da odgovorite na ovo pitanje sa AI trenerom

Konkurentnost

15Objasnite memorijski model jezika C++: trke za podacima (data races), relaciju „desilo se pre“ (happens-before) i garancije sinhronizacije.

C++ memorijski model definiše kada su operacije u različitim nitima uređene i kada upisi postaju vidljivi. Trka za podacima nastaje kada dve niti istovremeno pristupaju istoj memorijskoj lokaciji, pri čemu je najmanje jedan pristup upis, a pristupi nisu uređeni relacijom „desilo se pre“ niti na drugi način učinjeni bezbednim pomoću atomičnih operacija; trka za podacima dovodi do nedefinisanog ponašanja. Relacija „desilo se pre“ je relacija uređenja koja čini ranije sporedne efekte vidljivim kasnijim operacijama. Operacije sinhronizacije uspostavljaju ovo uređenje: na primer, otključavanje mutex-a sinhronizuje se sa kasnijim uspešnim zaključavanjem istog mutex-a, a odgovarajuće atomične operacije otpuštanja i preuzimanja (`release`/`acquire`) mogu da sinhronizuju podatke između niti. Ispravni programi koriste mutex-e, atomične promenljive ili druge mehanizme sinhronizacije kako bi uspostavili relaciju „desilo se pre“ za deljeno stanje.

std::mutex m;
int x = 0;

void producer() {
    std::lock_guard<std::mutex> lk(m);
    x = 42;
} // unlock

void consumer() {
    std::lock_guard<std::mutex> lk(m); // lock after producer unlock synchronizes-with it
    std::cout << x << '\n';
}
Pokušajte da odgovorite na ovo pitanje sa AI trenerom