Въпроси за интервю за Middle Go бекенд разработчици
15 подбрани въпроса за интервю по Go за Middle бекенд разработчици, които трябва да обяснят практически компромиси, конкурентност и поведение на услугите.
1Обяснете как `nil` работи по различен начин при указатели, `slice` стойности, `map` структури, канали, функции и интерфейси в Go.
В Go `nil` е нулевата стойност за указатели, `slice` стойности, `map` структури, канали, функции и интерфейси, но операциите с тези `nil` стойности се различават според типа. Един `nil` указател може да бъде сравняван с `nil`, но дереференцирането му предизвиква `panic`. Един `nil` `slice` има дължина и капацитет 0, по него може да се итерира чрез `range` и да се добавят елементи чрез `append`. От една `nil` `map` структура може да се чете и да се итерира чрез `range`, но присвояването на стойности в нея предизвиква `panic`. Изпращането към или получаването от `nil` канал блокира завинаги, а затварянето на `nil` канал предизвиква `panic`. Извикването на `nil` функция предизвиква `panic`. Един интерфейс е `nil` само когато няма нито динамичен тип, нито динамична стойност; интерфейс, който съдържа типизирана `nil` стойност, като например `nil` указател, не е `nil` сам по себе си.
2Какво представляват сравнимите типове в Go и как правилата за сравнимост влияят на ключовете в `map`, равенството и ограниченията при генериците?
Сравнимите типове в Go са типове, чиито стойности могат да бъдат сравнявани с `==` и `!=`. Базовите типове, указателите, каналите, интерфейсите и структурите или масивите, чиито полета или елементи са сравними, също са сравними; `slice`, `map` и функциите не са сравними, освен ако не се сравняват с `nil`. Ключовете в даден `map` трябва да бъдат сравними. Равенството следва правилата за сравнение на съответния тип, а сравнението на интерфейси зависи от техните динамични конкретни стойности; ако сравняван интерфейс съдържа несравнима динамична стойност, сравнението предизвиква паника. При генериците предварително декларираното ограничение `comparable` позволява параметрите на типа да бъдат сравнявани с `==`/`!=` и да се използват като ключове в `map`.
3Как Go представя байтове, руни и UTF-8 кодиран текст, и защо `len(s)` може да се различава от броя на видимите за потребителя символи?
В Go типът `byte` е псевдоним за `uint8` и представя един суров байт, докато `rune` е псевдоним за `int32` и представя Unicode кодова точка. Типът `string` е последователност от байтове само за четене, обикновено UTF-8 кодиран текст, но може да съдържа произволни байтове. Извикването на `len(s)` връща броя на байтовете, а не на руните или на видимите за потребителя символи. Индексирането на низ връща байт; итерирането по низ с `range` декодира UTF-8 и дава байтовите индекси заедно с руните. Стойността от `len(s)` може да се различава от броя на видимите символи, тъй като UTF-8 може да използва множество байтове за кодова точка и защото един видим за потребителя символ може да бъде съставен от множество кодови точки, като например комбиниращи знаци или последователности от емоджита.
4Как се различават псевдонимите на типове от дефинираните типове и кога бихте използвали всеки от тях?
Дефинираният тип, като например `type UserID int64`, създава нов отделен тип с `int64` като негов базов тип. Той не може да бъде свободно присвояван на `int64` без преобразуване и може да има свои собствени методи. Псевдонимът на тип, като например `type UserID = int64`, е просто друго име за същия тип, така че идентичността на типа и възможността за присвояване се запазват. Използвайте дефинирани типове за моделиране на предметната област, безопасност на типовете и добавяне на методи; използвайте псевдоними главно за рефакториране, миграция или съвместимост, без да въвеждате нов тип.
5Как Go обработва специалните стойности с плаваща запетая и какви уловки при проверка за равенство са от значение в бекенд системите?
В Go `float32` и `float64` използват поведение в стил IEEE-754, включително специални стойности като положителна/отрицателна безкрайност и NaN. За `float64` се използват помощни функции като `math.Inf`, `math.IsInf`, `math.NaN` и `math.IsNaN`. NaN не е равно на нищо, включително на себе си, така че `x == x` връща лъжа (false), когато `x` е NaN. Точното равенство при изчислени числа с плаваща запетая също е рисковано, тъй като закръглянето и прецизността могат да доведат до различие между математически равни стойности; използвайте подходящи за домейна толеранси или избягвайте числа с плаваща запетая за точни бизнес стойности като пари. Числата с плаваща запетая са позволени като ключове в `map` структури, но ключовете със стойност NaN са проблематични, тъй като търсенето в `map` зависи от равенството, а NaN не е равно на нищо, дори на себе си.
6Опишете влагането на структури в Go и как се държат издигнатите полета и методи (promoted fields and methods).
Влагането на структури в Go означава деклариране на поле само чрез неговия тип, без изрично име, например `type User struct { Person }`. Вложената стойност продължава да бъде реално поле, достъпно като `u.Person`, но нейните експортирани и достъпни полета и методи могат да бъдат издигнати. Това позволява на извикващия код да използва селектори като `u.Name` или `u.Greet()` като съкратен запис, вместо да преминава през вложеното поле. Влагането представлява композиция, а не класическо наследяване: външният тип не става автоматично подтип на вложения тип. Ако възникне конфликт между издигнати селектори, Go не се опитва да гадае; двусмислените имена трябва да бъдат изрично квалифицирани (указани напълно), в противен случай те не могат да бъдат достъпени директно през външната стойност.
7Опишете как промяната на границите и присвояването на `slice` могат да доведат до споделяне на един и същ базов масив от няколко `slice` стойности, и какви програмни грешки може да създаде това.
Стойността на един `slice` представлява заглавна структура, сочеща към базов масив. Присвояването на `slice` или подаването му към функция копира само тази заглавна структура, а не самите елементи. Промяната на границите създава друга заглавна структура, сочеща към диапазон от същия базов масив. Следователно множество `slice` стойности могат да сочат към една и съща памет: промяната на елемент чрез един `slice` може да бъде видима през друг, а добавянето с `append` към един `slice` може да презапише данни, видими за друг, ако все още има свободен капацитет. Грешките включват неочаквани промени, повредени резултати, задържане в паметта на големи базови масиви чрез малки под-`slice` стойности и състезания за данни (data races), когато споделените препратки се използват конкурентно. За да избегнете нежелано споделяне, направете защитно копие с функцията `copy` или `append([]T(nil), s...)`, или ограничете капацитета с израз с три индекса (full-slice expression) преди използването на `append`.
8Обяснете концептуално нарастването на `slice` при използване на `append` и последиците за производителността от многократно презаделяне на памет.
Когато `append` добавя елементи към `slice`, той записва в съществуващия основен масив, ако `slice` има достатъчен капацитет. Ако капацитетът е недостатъчен, Go заделя по-голям основен масив, копира съществуващите елементи, записва новите елементи и връща нова заглавна структура на `slice`, сочеща към новата памет. Точната политика на нарастване зависи от реализацията, но концептуално капацитетът нараства достатъчно, за да направи многократните извиквания на `append` амортизирано ефективни. Въпреки това, многократните презаделяния на памет костват процесорно време за копиране, създават нови заделяния на памет, увеличават натоварването на GC (Garbage Collector) и могат да нарушат споделянето на данни със стари препратки към същия `slice`. Ако знаете очаквания размер, предварително заделете памет с `make([]T, 0, n)`, когато изграждате с `append`, или `make([]T, n)`, когато попълвате по индекс, за да намалите презаделянията.
9Какво представляват шаблоните за регистрация по време на инициализация в Go и какви рискове крият страничните ефекти от празните импорти (blank imports) и глобалните регистри?
Шаблонът за регистрация по време на инициализация е този, при който даден пакет регистрира имплементация в споделен регистър чрез функцията `init`. Празен импорт (blank import), като например `_ "example.com/driver"`, често се използва за импортиране на пакет само заради неговите странични ефекти, което предизвиква изпълнението на функцията му `init`, въпреки че не се реферират никакви експортирани имена. Това е често срещана практика за точки на разширение като драйвери, кодеци, плъгини, метрики или сериализатори. Рисковете включват скрити зависимости и странични ефекти при стартиране, глобално изменяемо състояние, дублирана или чувствителна към реда регистрация, по-трудна изолация на тестовете и по-малко явно навързване на зависимостите. Този подход трябва да се използва обмислено, да бъде ясно документиран и често се смекчава чрез изрична регистрация, идемпотентни и безопасни при паралелност регистри, или инжектируеми и нулируеми регистри за целите на тестовете.
10Как си взаимодействат `defer`, `panic` и именуваните върнати стойности при имплементиране на изчистване, което може да промени върнатите грешки?
Отложените чрез `defer` функции се изпълняват, след като са присвоени върнатите стойности, но преди функцията да се върне към извикващия код. Поради това, една отложена анонимна функция може да чете или променя именувани върнати стойности, като например именувана грешка `err`. Това често се използва за добавяне на грешки при изчистване от операции като `Close`, `Commit` или подобни към връщаната грешка, като в идеалния случай първоначалната грешка се запазва, вместо да се презаписва. По време на разгъване на стека при `panic`, отложените функции все пак се изпълняват; отложената функция може да възстанови изпълнението (чрез `recover`) и да зададе именувана върната стойност, но това трябва да се ограничава до умишлените граници за прихващане на паника. Внимавайте да не припокриете (shadow) именувана върната променлива като `err`, защото тогава отложената функция може да наблюдава или променя различна променлива от предвидената.
11Как работят `errors.Is`, `errors.As` и `%w` във веригите от обвити грешки в Go?
`fmt.Errorf` с `%w` създава нова грешка, която обвива базова грешка, като същевременно добавя контекст. Обвиващите грешки предоставят достъп до базовите грешки чрез метода `Unwrap`, образувайки верига или дърво, което стандартната библиотека може да инспектира. `errors.Is(err, target)` проверява дали `err` или някоя от грешките, които тя обвива, съвпада с целевата грешка. `errors.As(err, &target)` проверява дали `err` или някоя от грешките, които тя обвива, може да бъде присвоена на целевия тип, и съхранява съвпадащата стойност в предоставения указател.
12Какво представляват `sentinel` грешките и какви са компромисите при тях в сравнение с потребителски типизирани грешки или по-богати домейнови модели за грешки?
`sentinel` грешката е именувана стойност за грешка, често променлива на ниво пакет, като `var ErrNotFound = errors.New("not found")`, която се използва за представяне на специфично състояние, което извикващият код може да провери, обикновено чрез `errors.Is`, когато е възможно обвиване. Тези грешки са прости и полезни за широки, стабилни категории, но експортираните `sentinel` стойности стават част от публичния API и могат да обвържат извикващия код с конкретни стойности. Потребителските типизирани грешки могат да носят структурирани полета и да бъдат намерени чрез `errors.As`. По-богатите домейнови модели за грешки класифицират неуспехите по вид или код и могат да включват безопасни съобщения или метаданни, което е полезно, когато извикващите системи се нуждаят от стабилно поведение отвъд една фиксирана стойност на грешка.
13Как един бекенд на Go трябва да преобразува вътрешните грешки към полезни отговори за клиента, като същевременно предоставя на операторите данни за диагностика?
Един бекенд на Go трябва да преобразува вътрешните грешки на границата на приложението или транспортния слой в стабилни и безопасни за клиента категории и отговори. Тези категории трябва да съответстват на подходящи HTTP кодове на състоянието или еквивалентни транспортни състояния, съдържащи безопасни съобщения и машинночетими кодове, вместо необработени вътрешни грешки. Операторите на системата все пак трябва да получават диагностична информация чрез структурирани логове, проследявания, метрики, идентификатори за корелация/заявка и запазване на първопричините. Логването обикновено е най-добре да се извършва еднократно на граница, която разполага с контекста на заявката, за да се избегнат както пропуснати грешки, така и излишен шум от дублирани логове.
14Как се реализират правилно персонализирани типове за грешки в Go и как `errors.Join` влияе върху проверката и обработката на грешки при почистващи операции?
Персонализираният тип за грешка в Go удовлетворява интерфейса `error`, като реализира метода `Error() string`. Той може също така да съдържа структурирани полета и да реализира метода `Unwrap() error`, за да разкрие първоначалната причина за грешката. Изборът между указател и стойност като получател (receiver) има значение: получател-указател означава, че само `*T` удовлетворява `error`, докато получател-стойност обикновено означава, че и `T`, и `*T` го правят, което влияе върху копирането и типа, който извикващите функции трябва да използват с `errors.As`. Функцията `errors.Join` обединява няколко грешки в една; `errors.Is` и `errors.As` могат да проверяват обединените дъщерни грешки. Това е полезно, когато трябва да бъдат върнати както грешката от основната операция, така и грешка от почистваща или отложена операция, без да се губи нито една от двете.
15Как работи `select` с канали, включително избора при няколко готови операции, блока по подразбиране (`default`) и отменяемите операции?
`select` изчаква множество операции с канали и изпълнява един `case`, при който изпращането или получаването може да продължи. Ако нито една операция с канал не е готова, `select` блокира, освен ако няма `default` блок; `default` се изпълнява незабавно само когато нито една операция с канал не може да продължи, което е полезно за неблокиращи опити за изпращане или получаване. Ако няколко случая са готови, Go избира един от тях псевдослучайно, а не според реда им в изходния код. Операциите с канали, които могат да бъдат отменени, обикновено добавят `case`, който получава от `ctx.Done()`, така че съответната `goroutine` да спре да чака, когато контекстът бъде отменен или изтече времето му.