Подготовка за Middle C++

Въпроси за интервю за Middle C++ бекенд разработчици

15 избрани въпроса за интервю за Middle C++ бекенд разработчици, които трябва да обсъждат производителност, управление на собствеността и компромиси в продукционна среда.

Започнете интервю с ИИ за Middle C++Не се изисква кредитна карта. Налична е 1 безплатна сесия.
Практика за техническо интервю на английски езикРежим, в който хора, за които английският не е роден език, могат да се упражняват за преминаване на технически интервюта.

Управление на паметта

1Как работят потребителските функции за изтриване (custom deleters) в интелигентните указатели и кога са полезни за съвместимостта между C и C++?

Потребителската функция за изтриване е извикваема логика за почистване, която се използва от интелигентния указател вместо операцията delete по подразбиране, когато указателят освобождава притежавания ресурс. Тя е полезна за съвместимостта между C и C++, когато даден ресурс трябва да бъде освободен със специфична функция като fclose, close, curl_easy_cleanup, SSL_free, free или функция за унищожаване от библиотека. За unique_ptr типът на функцията за изтриване е част от типа unique_ptr и може да повлияе на неговия размер; функциите за изтриване без състояние могат да бъдат оптимизирани и премахнати, докато указателите към функции или функциите за изтриване със състояние добавят допълнителна памет. За shared_ptr функцията за изтриване се съхранява в контролния блок и се изпълнява, когато последният силен собственик освободи обекта. Потребителските функции за изтриване позволяват на ресурси от C да участват безопасно в RAII (Resource Acquisition Is Initialization).

Опитайте да отговорите на този въпрос с треньор с ИИ

2Обяснете работата на контролния блок при `weak_ptr` и `shared_ptr`, включително цикли, `enable_shared_from_this` и цената за поддържане на броячите на препратки.

Един `shared_ptr` управлява споделената собственост чрез контролен блок, съдържащ броячи на силните и слабите препратки, плюс информация за изчистване, като например обекти за изтриване (`deleter`) и заделяне на памет (`allocator`). Копирането или унищожаването на `shared_ptr` обекти увеличава или намалява брояча на силните препратки, обикновено с атомарни операции, така че отделните `shared_ptr` обекти да могат да се манипулират безопасно от различни нишки, но всяко обновяване на брояча има цена. Когато броят на силните препратки достигне нула, управляваният обект се унищожава; контролният блок остава, докато не изчезнат и слабите препратки. Типът `weak_ptr` сочи към същия контролен блок, без да удължава времето на живот на обекта; методът `lock()` връща `shared_ptr`, ако обектът е все още жив, и празен `shared_ptr` в противен случай. Циклите, съставени само от `shared_ptr`, предизвикват изтичане на памет, тъй като броячите на силните препратки никога не достигат нула, затова `weak_ptr` се използва за обратни указатели или връзки при шаблона наблюдател. Класът `enable_shared_from_this` позволява на обект, който вече е притежаван от `shared_ptr`, да създаде нов `shared_ptr` към себе си, използвайки съществуващия контролен блок, избягвайки опасни отделни контролни блокове. Разходите, свързани с броячите на препратки, включват атомарни увеличения/намаления, конкуренция за кеша, заделяне на памет за контролния блок и допълнително натоварване в критичните за производителността части от кода.

Опитайте да отговорите на този въпрос с треньор с ИИ

Шаблони

3Обяснете категориите на стойностите и перфектното препращане (perfect forwarding) в C++, и защо те са важни за създаването на ефективни обобщени програмни интерфейси.

Категориите на стойностите в C++ описват изразите: `lvalues` имат идентичност и към тях може да се реферира след израза; `prvalues` са чисти `rvalues`, като например много временни или изчислени стойности; `xvalues` са обекти с изтичащ живот, чиито ресурси могат да бъдат преизползвани. Перфектното препращане е шаблонна техника, при която се приема препращаща референция, обикновено `T&&`, където `T` се извежда, и се препраща чрез `std::forward<T>(arg)`, така че категорията на стойността на извикващия код да се запази: `lvalues` остават `lvalues`, а `rvalues` остават `rvalues`. Това е възможно благодарение на правилата за свиване на референциите. Този механизъм е от значение за обобщените бекенд програмни интерфейси, защото обвиващи класове, фабрики, диспечери и функции от тип `emplace` могат да избегнат ненужни копия и да запазят избора на презаредена функция и поведението при преместване.

Опитайте да отговорите на този въпрос с треньор с ИИ

Система от типове

4Сравнете `auto`, `decltype`, `decltype(auto)` и извличането на аргументи на шаблон (template argument deduction) в често срещан бекенд код.

`auto` използва извличане, подобно на шаблоните, за променливи: обикновеният `auto` обикновено премахва референциите и модификаторите `const` на най-високо ниво, освен ако декларацията не ги изисква изрично, като например `auto&`, `const auto&` или `auto&&`. `decltype(expr)` проверява декларирания тип или типа на израза по-точно: идентификатор без скоби връща декларирания тип, докато други lvalue изрази дават `T&`, xvalue изразите дават `T&&`, а prvalue изразите дават `T`. `decltype(auto)` извлича типа, използвайки правилата на `decltype`, често за връщани типове, когато референциите трябва да бъдат запазени. Извличането на аргументи на шаблон е подобно на `auto`, но зависи от формата на параметъра, като `T`, `T&`, `const T&` или `T&&`, и има свои собствени правила. Инициализаторите във фигурни скоби са често срещана разлика: `auto x = {1,2}` извлича `std::initializer_list<int>`, докато обикновен параметър на шаблон обикновено не може да извлече `T` от чист инициализатор във фигурни скоби, освен ако параметърът не очаква `initializer_list` или друг подходящ тип.

Опитайте да отговорите на този въпрос с треньор с ИИ

5Сравнете `std::variant` с полиморфизма, базиран на наследяване, за моделиране на хетерогенни съобщения или събития.

`std::variant` е стойностен тип, който съдържа точно една алтернатива от фиксирано, затворено множество от типове и обикновено се обработва чрез `std::visit` или явни заявки за типа. Той е полезен за протоколни съобщения или събития, когато наборът от видове съобщения е известен и искате безопасно спрямо типовете обработване без виртуално диспечиране и често без заделяне на памет в хийпа за всеки обект. Полиморфизмът, базиран на наследяване, използва базов клас и виртуални функции за диспечиране чрез общ интерфейс; той е по-подходящ, когато наборът от производни типове съобщения е отворен, независимо разширяем, подобен на плъгин или скрит зад стабилен интерфейс. Вариантът благоприятства затворените типове сума, локалността на данните и проверките по време на компилация; наследяването благоприятства разширяемостта, полиморфизма по време на изпълнение и дизайна, базиран на интерфейси.

Опитайте да отговорите на този въпрос с треньор с ИИ

Семантика на езика

6Какво е недефинирано поведение в C++ и как може да се прояви в инциденти в продукционна бекенд среда?

Недефинираното поведение е поведение, за което стандартът на C++ не налага никакви изисквания след възникване на невалидна операция. Програмата може да изглежда, че работи, да се срине, да повреди данни, да разкрие уязвимости в сигурността или да бъде оптимизирана в изненадващо поведение. Компилаторите предполагат, че недефинирано поведение (UB) не се случва, и оптимизират въз основа на това предположение, така че проблемите могат да се появят само в release компилации или под продукционен трафик. Инцидентите в сървърната част могат да произтичат от висящи указатели или референции, използване след освобождаване на паметта, нарушения на времето на живот на обектите, достъп извън границите на масива, препълване на целочислени типове със знак, състезания за данни, невалидни преобразувания на типове, четения на неинициализирани стойности, двойно освобождаване на памет или нарушения на правилата за стриктно припокриване на типовете. Смекчаването на тези проблеми включва RAII (Resource Acquisition Is Initialization) и ясен дизайн на притежанието и времето на живот, по-безопасни абстракции и проверки на границите, тестване чрез фъзинг, преглед на кода, статичен анализ и инструменти за динамична проверка като ASan, UBSan и TSan.

Опитайте да отговорите на този въпрос с треньор с ИИ

7Разграничете недефинирано, неуточнено и дефинирано от имплементацията поведение с примери, свързани с бекенд разработката.

Недефинираното поведение означава, че C++ стандартът не налага никакви изисквания; резултатите могат да включват сривове, повреждане на данни, уязвимости в сигурността или зависима от оптимизатора неправилна компилация. Примерите включват достъп извън границите на масив, използване на памет след освобождаване, препълване на цяло число със знак и състезания за данни. Неуточненото поведение означава, че стандартът позволява повече от един възможен резултат и имплементацията не е длъжна да документира кой от тях е избран; често срещан пример е редът на оценяване на много аргументи на функция, поради което кодът не трябва да разчита, че страничните ефекти се оценяват в определен ред. Поведението, дефинирано от имплементацията, означава, че тя трябва да избере и документира поведението си; примерите включват това дали стандартният тип `char` е със или без знак, размерите и диапазоните на някои основни типове в рамките на стандартизираните граници, както и някои аспекти на побитовото отместване надясно при числа със знак. В бекенд кода недефинираното поведение (UB) е риск за коректността и сигурността, докато неуточненото и дефинираното от имплементацията поведение представляват рискове за преносимостта, които трябва да бъдат избягвани или изолирани в логиката за протоколи, съхранение и междуплатформена съвместимост.

Опитайте да отговорите на този въпрос с треньор с ИИ

Обработка на грешки

8Обяснете нивата на безопасност спрямо изключения и как `noexcept` влияе на операциите за преместване, контейнерите и генерирането на код.

Нивата на безопасност спрямо изключения описват какво остава вярно, ако дадена операция хвърли изключение. Основната гаранция означава, че инвариантите са запазени и ресурсите не изтичат, въпреки че състоянието може да се е променило. Силната гаранция означава семантика на потвърждаване или отмяна (commit-or-rollback): при неуспех, видимото състояние остава непроменено. Гаранцията без хвърляне (nothrow guarantee) означава, че операцията не хвърля изключения. Често срещаните техники включват RAII (Resource Acquisition Is Initialization), извършване на работа върху временни обекти, копиране и размяна (copy-and-swap) и подреждане на промените така, че потвърждаването да се случи едва след като работата, която може да хвърли изключение, е успешна. `noexcept` е договор: ако `noexcept` функция хвърли изключение, се извиква `std::terminate`. Това също влияе на обобщения код и контейнерите: например, по време на преалокация `std::vector` може да премести елементите, когато конструкторът за преместване е `noexcept`; в противен случай може да ги копира, често чрез `std::move_if_noexcept`, за да запази гаранциите за изключения. `noexcept` може също да помогне при генерирането или оптимизацията на код, като намали необходимите пътища за разпространение на изключения, но само когато обещанието е коректно.

Опитайте да отговорите на този въпрос с треньор с ИИ

9Обяснете `std::expected` или еквивалентните типове за резултат като алтернативи на изключенията при операции, които могат да завършат с грешка.

`std::expected<T, E>` представлява или успешна стойност `T`, или изрична грешка `E`, като по този начин превръща неуспеха в част от връщания тип на функцията, вместо да разчита на разпространението на изключения. Този подход е полезен за операции, податливи на грешки, при които грешките са очаквани и извикващият код трябва да ги обработи локално, като например синтактичен анализ (парсване), валидация, мрежови извиквания, търсене в хранилища за данни и бекенд операции, позволяващи повторен опит. Типът на грешката трябва да носи структурирана информация, като например код на грешката, категория, индикация дали операцията може да се повтори, съобщение или съответствие с HTTP (Hypertext Transfer Protocol) / RPC (Remote Procedure Call) код. Типовете за резултат се композират чрез проверка и препредаване на грешки, а в програмни интерфейси в стила на C++23 могат да се използват монадични операции като `and_then`, `transform` и `or_else` за верижно свързване на стъпки без дълбоко вложени условни конструкции. В сравнение с изключенията, те правят потока за управление и договорите на програмните интерфейси изрични и работят добре в код без изключения или през границите на двоичния приложен интерфейс (ABI - Application Binary Interface); изключенията все още могат да бъдат подходящи за редки, нелокални или наистина изключителни ситуации, в зависимост от правилата на конкретния проект.

Опитайте да отговорите на този въпрос с треньор с ИИ

Време на живот на обектите

10Опишете правилата за времето на живот на автоматичните, динамичните, временните и асинхронно реферираните обекти.

Автоматичните обекти съществуват от създаването си до края на своя обхват; указателите или референциите към тях стават висящи след излизане от този обхват. Динамичните обекти съществуват от заделянето/създаването си, докато не бъдат изрично унищожени или докато притежаващият ги обект не ги унищожи; суровите указатели и референциите не удължават времето на живот. Временните обекти обикновено съществуват до края на пълния израз, като има специфични правила за удължаване на времето на живот, когато са присвоени на референции, но това не важи при всяко използване. Асинхронни задачи, като обратни извиквания, нишки, таймери или съпрограми, могат да се изпълнят след като реферираният обект е бил унищожен, така че прихванатите променливи и съхранените референции се нуждаят от изрично управление на времето на живот, за да се избегнат висящи референции и използване след освобождаване.

Опитайте да отговорите на този въпрос с треньор с ИИ

11Опишете проблемите с реда на статичната инициализация и как `constinit`, магическите статични променливи (magic statics) и инжектирането на зависимости ги смекчават.

Проблемите с реда на статичната инициализация възникват, защото динамично инициализираните обекти на ниво пространство от имена или статичните обекти в различни транслационни единици имат неуточнен относителен ред на инициализация. Една глобална променлива може да използва друга, преди тя да е конструирана; редът на унищожаване също може да създаде подобни проблеми при спиране на програмата. Ключовата дума `constinit` задължава статичните или локалните за нишката променливи да имат статична или константна инициализация, в противен случай програмата е невалидна, което избягва зависимостта от реда на динамичната инициализация за съответната променлива. Магическите статични променливи, или локалните за функцията статични променливи, се инициализират при първото им използване и са безопасни за нишки от C++11 насам. Инжектирането на зависимости избягва скритите глобални зависимости, като конструира обектите в контролиран ред и предава зависимостите явно.

Опитайте да отговорите на този въпрос с треньор с ИИ

12Какво представляват пропускането на копирането, NRVO (Named Return Value Optimization) и RVO (Return Value Optimization) и кога можете да разчитате на тях?

Пропускането на копирането означава конструиране на обект директно в неговото крайно местоназначение, вместо да се създава отделен временен обект и той да се копира или премества. RVO обикновено се отнася до връщане на безименен временен обект или `prvalue`, като например `return T{};`; в C++17 и по-нови версии много от тези случаи са задължителни, защото `prvalue` инициализира резултатния обект директно. NRVO е връщане на именуван локален обект, като например `return x;`; на компилатора е позволено да конструира този локален обект директно в мястото за връщаната стойност на извикващия код, но това не е гарантирано. Можете да разчитате на задължителното пропускане за `prvalue` в C++17 в определените случаи, но не и че NRVO винаги ще се случи; избягвайте използването на `std::move` върху именувана локална връщана стойност, защото това може да попречи на NRVO.

Опитайте да отговорите на този въпрос с треньор с ИИ

Стандартна библиотека

13Как изискванията за функция за сравнение, равенство и хеширане влияят върху коректността на асоциативните контейнери?

Асоциативните контейнери разчитат на функции за сравнение, равенство и хеширане, за да определят идентичността на ключовете и да поддържат своите вътрешни инварианти. Подредените контейнери, като std::map, изискват функцията за сравнение да налага строго слабо подреждане; ключовете се считат за еквивалентни, когато нито един от тях не е по-малък от другия, а не непременно чрез operator==. Неподредените контейнери изискват равенството да бъде релация на еквивалентност, и всеки два ключа, които се считат за равни, трябва да генерират една и съща хеш стойност. Ключовете не трябва да бъдат променяни, докато се съхраняват, по начин, който променя тяхното подреждане, равенство или хеш, защото това може да направи поведението при търсене, изтриване и проверка за уникалност некоректно.

Опитайте да отговорите на този въпрос с треньор с ИИ

14Обяснете хетерогенното търсене в подредени и неподредени асоциативни контейнери.

Хетерогенното търсене позволява претърсването на асоциативен контейнер чрез тип, различен от типа на неговия ключ, избягвайки създаването на временен ключ. При подредените контейнери това изисква прозрачен компаратор, например `std::less<>`, или персонализиран компаратор с маркер `is_transparent`. При неподредените контейнери се изискват прозрачен хеш и прозрачни функтори за равенство, които могат да работят консистентно както с типа на ключа, така и с типа, използван за търсене. Често срещан пример в бекенд разработката е контейнер с ключ от тип `std::string`, в който може да се търси със `std::string_view` или `const char*`, без да се заделя памет за временен `std::string`.

Опитайте да отговорите на този въпрос с треньор с ИИ

Паралелно изпълнение

15Обяснете модела на паметта в C++: състезания за данни (data races), релацията "случва се преди" (happens-before) и гаранциите за синхронизация.

Моделът на паметта в C++ дефинира кога операциите в различни нишки са подредени и кога записите стават видими. Състезание за данни възниква, когато две нишки достъпват един и същ адрес в паметта едновременно, като поне един от достъпите е запис, и достъпите не са подредени чрез `happens-before` или не са подсигурени чрез атомарни операции; състезанието за данни води до недефинирано поведение (undefined behavior). `Happens-before` е релацията на подредба, която прави по-ранните странични ефекти видими за по-късните операции. Операциите за синхронизация създават тази подредба: например, отключването на мютекс се синхронизира (synchronizes-with) с последващо успешно заключване на същия мютекс, а подходящи атомарни операции за освобождаване и придобиване (release/acquire) могат да синхронизират различни нишки. Коректните програми използват мютекси, атомарни типове или друга синхронизация, за да установят `happens-before` за споделеното състояние.

Опитайте да отговорите на този въпрос с треньор с ИИ