Przygotowanie do rozmowy Middle Go

Pytania rekrutacyjne Middle Go Backend

15 wyselekcjonowanych pytań rekrutacyjnych Middle Go dla programistów backend, którzy muszą umieć wyjaśnić praktyczne kompromisy (trade-offs), współbieżność oraz zachowanie usług.

Rozpocznij próbną rozmowę AI Middle GoKarta 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.

System typów

1Wyjaśnij, w jaki sposób zachowanie nil różni się w języku Go dla wskaźników, wycinków (slices), map, kanałów, funkcji i interfejsów.

W języku Go nil jest wartością zerową dla wskaźników, wycinków, map, kanałów, funkcji i interfejsów, jednak operacje na tych wartościach nil różnią się w zależności od typu. Wskaźnik równy nil można porównać z nil, ale jego dereferencja wywołuje błąd typu panic. Wycinek (slice) równy nil ma długość i pojemność (capacity) wynoszącą 0, można po nim iterować (range) oraz dodawać do niego elementy za pomocą append. Mapę równą nil można odczytywać i po niej iterować, ale przypisanie do niej nowej wartości wywołuje panic. Wysyłanie do lub odbieranie z kanału będącego nil blokuje się na zawsze, a zamknięcie takiego kanału powoduje panic. Wywołanie funkcji równej nil również wywołuje panic. Interfejs jest nil tylko wtedy, gdy nie posiada ani dynamicznego typu, ani dynamicznej wartości; interfejs przechowujący stypizowaną wartość nil, taką jak wskaźnik równy nil, sam w sobie nie jest nil.

Odpowiedz na to pytanie z trenerem AI

2Czym są typy porównywalne w Go i jak reguły porównywalności wpływają na klucze map, równość i ograniczenia typów generycznych?

Typy porównywalne w Go to takie, których wartości można porównywać za pomocą `==` i `!=`. Typy podstawowe, wskaźniki, kanały, interfejsy oraz struktury/tablice, których pola lub elementy są porównywalne, należą do tej grupy; wycinki (slices), mapy i funkcje nie są porównywalne, z wyjątkiem porównania z `nil`. Klucze map muszą być porównywalne. Równość podlega regułom porównywania danego typu, a porównywanie interfejsów zależy od dynamicznych wartości konkretnych; jeśli porównywany interfejs zawiera nieporównywalną wartość dynamiczną, porównanie kończy się błędem wykonania (panic). W typach generycznych wbudowane ograniczenie `comparable` pozwala parametrom typów na porównywanie za pomocą `==`/`!=` i używanie ich jako kluczy map.

Odpowiedz na to pytanie z trenerem AI

3W jaki sposób Go reprezentuje bajty, runy (runes) i tekst zakodowany w UTF-8, oraz dlaczego wynik `len(s)` może różnić się od liczby znaków widocznych dla użytkownika?

W języku Go `byte` służy jako alias dla formatu `uint8`, i wyraża jeden tzw. surowy bajt, zaś format typujący tzw. `rune` występuje w formie zamiennej reprezentacji dla `int32` na opis każdego punktu w kodowaniu Unicode. Z kolei sam ciąg znaków (`string`) określa nie modyfikowalną kolekcję z ciągiem bajtowym o typowo zakodowanym wyrazie formy tekstu o charakterze w formacie UTF-8 ale otwartym na obsługę innych zestawień dla samych danych numerycznych na bajtach. Stąd też, instrukcja `len(s)` wskaże dokładną zbitkę całości objętościowej liczby bajtów w formacie wyjściowym za co w żadnej mierze nie musi odpowiadać finalna długości run lub wyświetlany ekran widocznych wizualnie u użytkownika. Wyprowadzenia do poszczególnych indeksacji w string zwróci formację określającą pożądany bajt, z kolei przejście cykliczne z zastosowaniem zakresu `range` iteracji na objętości wyjściowej ciągu zdekoduje w formacje formatu UTF-8 wyświetlając wskaźniki na konkretny indeks by dalej uzupełnić zwrot formatami dla run. To dlatego w ostatecznym stopniu wynikowy wykaz ze strony funkcji `len(s)` z reguły charakteryzuje się rozbieżnością od liczby ostatecznie widzianych punktów wyjściowych co bierze się w równej mierze z możliwości wielokrotnego alokowania miejsca na objętość bitową dla odpowiednich zestawów, zwłaszcza wielokrotnie przy kombinacji o zastosowaniu graficznym bądź powiązań typowo w relacjach łączących tzw. emoji.

Odpowiedz na to pytanie z trenerem AI

4Czym różnią się aliasy typów (type aliases) od typów zdefiniowanych (defined types) i kiedy należy używać każdego z nich?

Typ zdefiniowany (defined type), na przykład `type UserID int64`, tworzy nowy, odrębny typ, którego typem bazowym jest `int64`. Nie można go swobodnie przypisywać do zmiennych typu `int64` bez konwersji i może on posiadać własne metody. Alias typu (type alias), na przykład `type UserID = int64`, to po prostu inna nazwa dla tego samego typu, dlatego identyczność typów oraz możliwość swobodnego przypisywania zostają zachowane. Typów zdefiniowanych należy używać do modelowania domeny, zachowania bezpieczeństwa typów (type safety) oraz dla wprowadzania metod; z kolei aliasów typów powinno się używać głównie podczas refaktoryzacji, migracji lub w celu zapewnienia kompatybilności bez wprowadzania całkowicie nowego typu.

Odpowiedz na to pytanie z trenerem AI

5W jaki sposób język Go obsługuje specjalne wartości zmiennoprzecinkowe i jakie pułapki związane z weryfikacją równości mają szczególne znaczenie w systemach backendowych?

Typy `float32` oraz `float64` w Go wykazują zachowanie zgodne ze standardem IEEE-754, wspierając wartości specjalne, takie jak dodatnia i ujemna nieskończoność oraz NaN (Not a Number). W przypadku `float64`, wbudowane funkcje pomocnicze obejmują `math.Inf`, `math.IsInf`, `math.NaN` oraz `math.IsNaN`. Wartość NaN nie jest równa niczemu, w tym również samej sobie, w związku z czym wyrażenie `x == x` zwraca `false`, jeśli `x` wynosi NaN. Sprawdzanie dokładnej równości obliczonych liczb zmiennoprzecinkowych jest ponadto ryzykowne, gdyż błędy zaokrągleń oraz braki precyzji mogą sprawić, że wartości równe z matematycznego punktu widzenia zaczną się od siebie różnić. Dlatego zaleca się stosowanie odpowiedniej dla danej domeny tolerancji w obliczeniach, a w celu reprezentowania dokładnych wartości w logice biznesowej, jak na przykład pieniądze, preferuje się unikanie typów zmiennoprzecinkowych. Zmienne oparte na liczbach zmiennoprzecinkowych są dozwolone jako klucze map, niemniej klucze równe NaN są mocno problematyczne, ponieważ działanie mechanizmu wyszukiwania w mapach opiera się na porównaniach w sensie równości, a przecież NaN nie uważa się za równe niczemu, nawet z samym sobą.

Odpowiedz na to pytanie z trenerem AI

6Opisz osadzanie struktur (struct embedding) w Go oraz to, jak zachowują się promowane pola i metody.

Osadzanie struktur w Go polega na zadeklarowaniu pola poprzez podanie jego typu bez jawnej nazwy pola, na przykład `type User struct { Person }`. Osadzona wartość nadal jest pełnoprawnym polem, dostępnym jako `u.Person`, ale jej wyeksportowane (dostępne z zewnątrz) pola i metody mogą zostać promowane, dzięki czemu wywołujący mogą używać selektorów takich jak `u.Name` czy `u.Greet()` jako skrótu, zamiast odwoływać się bezpośrednio przez osadzone pole. Osadzanie to kompozycja, a nie klasyczne dziedziczenie: typ zewnętrzny nie staje się automatycznie podtypem osadzonego typu. W przypadku konfliktu promowanych selektorów Go nie zgaduje; dwuznaczne nazwy muszą być w pełni kwalifikowane lub nie są w ogóle dostępne z poziomu wartości zewnętrznej.

Odpowiedz na to pytanie z trenerem AI

Struktury danych

7Opisz, jak ponowne wycinanie wycinków (slice reslicing) i ich przypisywanie może sprawić, że wiele wycinków będzie współdzielić tę samą tablicę bazową w Go, oraz jakie błędy może to powodować.

Wartość wycinka to nagłówek wskazujący na tablicę pod spodem. Przypisanie wycinka lub przekazanie go do funkcji kopiuje tylko ten nagłówek, a nie same elementy. Ponowne wycięcie (reslicing) tworzy kolejny nagłówek wskazujący na zakres tej samej tablicy bazowej. W związku z tym wiele wycinków może wskazywać na to samo miejsce w pamięci (aliasowanie): zmiana elementu poprzez jeden wycinek może być widoczna z poziomu innego, a operacja `append` na jednym z nich może nadpisać dane widoczne dla drugiego, jeśli wciąż istnieje wolna pojemność. Błędy obejmują nieoczekiwane mutacje, uszkodzone wyniki, zatrzymywanie w pamięci dużych tablic bazowych przez małe podwycinki oraz wyścigi danych, gdy aliasy są używane współbieżnie. Aby uniknąć niezamierzonego współdzielenia, należy utworzyć kopię defensywną za pomocą funkcji `copy` lub `append([]T(nil), s...)`, albo ograniczyć pojemność za pomocą wyrażenia pełnego wycinania (full-slice expression) przed wywołaniem `append`.

Odpowiedz na to pytanie z trenerem AI

8Wyjaśnij koncepcyjnie, jak rośnie wycinek (slice) podczas wywoływania funkcji append, oraz omów wpływ wielokrotnych realokacji na wydajność.

Kiedy funkcja `append` dodaje elementy do wycinka, zapisuje je do istniejącej tablicy bazowej (backing array), o ile wycinek ma wystarczającą pojemność (capacity). Jeśli pojemność jest niewystarczająca, Go alokuje większą tablicę bazową, kopiuje istniejące elementy, zapisuje nowe elementy i zwraca nagłówek wycinka wskazujący na nową pamięć. Dokładna polityka wzrostu zależy od implementacji, ale koncepcyjnie pojemność rośnie na tyle, by wielokrotne wywołania `append` były wydajne w ujęciu zamortyzowanym. Mimo to wielokrotne realokacje pochłaniają czas procesora na kopiowanie, powodują dodatkowe alokacje, zwiększają presję na GC (Garbage Collector) i mogą przerwać współdzielenie danych ze starymi aliasami wycinka. Jeśli znasz oczekiwany rozmiar, dokonaj prealokacji za pomocą `make([]T, 0, n)` przy budowaniu wycinka z użyciem `append`, lub `make([]T, n)` przy wypełnianiu po indeksach, aby ograniczyć liczbę realokacji.

Odpowiedz na to pytanie z trenerem AI

Pakiety

9Czym są wzorce rejestracji w czasie inicjalizacji (init-time registration) w języku Go oraz jakie ryzyko wiąże się ze skutkami ubocznymi używania pustych importów (blank import) i globalnych rejestrów?

Wzorzec rejestracji w czasie inicjalizacji występuje, gdy pakiet rejestruje implementację we współdzielonym rejestrze za pomocą funkcji `init`. Pusty import (blank import), np. `_ "example.com/driver"`, jest często używany do zaimportowania pakietu wyłącznie dla jego skutków ubocznych, wymuszając uruchomienie jego funkcji `init`, nawet jeśli nie odwołano się do żadnych z jego wyeksportowanych nazw. Jest to powszechne w punktach rozszerzeń dla sterowników, kodeków, wtyczek, metryk czy serializatorów. Związane z tym ryzyka to ukryte zależności i skutki uboczne podczas startu aplikacji, globalny mutowalny stan, duplikacja lub wrażliwość na kolejność rejestracji, trudniejsza izolacja testów i mniej jawne wiązanie zależności. Wzorzec ten powinien być używany z namysłem, jasno dokumentowany, a jego skutki uboczne często łagodzi się stosując jawną rejestrację, rejestry idempotentne i bezpieczne dla współbieżności, lub wstrzykiwane i resetowalne rejestry na potrzeby testów.

Odpowiedz na to pytanie z trenerem AI

Obsługa błędów

10Jak współpracują ze sobą defer, panic i nazwane wartości zwracane (named return values) podczas implementowania mechanizmu sprzątania, który może modyfikować zwracane błędy?

Odroczone funkcje (defer) uruchamiają się po przypisaniu wartości zwracanych, ale przed powrotem z funkcji do jej miejsca wywołania. Z tego powodu odroczone domknięcie (closure) może odczytywać lub modyfikować nazwane wartości zwracane, takie jak nazwana zmienna `err`. Często stosuje się to do dodawania błędów podczas operacji zwalniania zasobów, przy użyciu `Close`, `Commit` lub podobnych metod, aby wpłynęły na zwracany z funkcji błąd, najlepiej w sposób zachowujący wewnątrz błąd główny zamiast bezpowrotnego jego nadpisywania. Podczas zwijania stosu w wyniku paniki (panic unwinding), odroczone funkcje są nadal uruchamiane; odroczona funkcja może wyłapać panikę (recover) i ustawić nazwaną wartość zwracaną, ale powinno to być ograniczone tylko do z góry przemyślanych barier dla błędów celowych. Należy uważać, aby nie przesłonić (shadow) nazwanej zmiennej zwracanej (np. `err`), ponieważ wywołanie odroczone z funkcji defer mogłoby zacząć obserwować lub modyfikować wtedy inną zmienną niż pierwotnie planowano.

Odpowiedz na to pytanie z trenerem AI

11Jak działają errors.Is, errors.As oraz %w w łańcuchach opakowanych błędów w Go?

`fmt.Errorf` z `%w` tworzy nowy błąd, który opakowuje błąd bazowy, dodając jednocześnie kontekst. Typy opakowujące (wrappers) udostępniają błędy bazowe za pomocą metody `Unwrap`, tworząc łańcuch lub drzewo, które może być inspekcjonowane przez bibliotekę standardową. `errors.Is(err, target)` sprawdza, czy `err` lub jakikolwiek błąd przez niego opakowany pasuje do błędu docelowego. `errors.As(err, &target)` sprawdza, czy `err` lub jakikolwiek błąd przez niego opakowany można przypisać do typu docelowego, i zapisuje dopasowaną wartość w przekazanym wskaźniku.

Odpowiedz na to pytanie z trenerem AI

12Czym są błędy typu sentinel (sentinel errors) i jakie są ich wady i zalety w porównaniu do niestandardowych typowanych błędów lub bogatszych modeli błędów domenowych?

Błąd typu sentinel to nazwana wartość błędu, często zmienna na poziomie pakietu, taka jak `var ErrNotFound = errors.New("not found")`, używana do reprezentowania określonego warunku, który wywołujący mogą sprawdzić. Zazwyczaj używa się do tego `errors.Is`, gdy możliwe jest opakowywanie błędów (error wrapping). Błędy typu sentinel są proste i przydatne dla szerokich, stabilnych kategorii, ale wyeksportowane zmienne tego typu stają się częścią publicznego API (Application Programming Interface) i mogą silnie wiązać wywołujących z konkretnymi wartościami. Niestandardowe, typowane błędy mogą przenosić ustrukturyzowane pola i można je zidentyfikować za pomocą `errors.As`. Bogatsze modele błędów domenowych klasyfikują awarie według rodzaju lub kodu i mogą zawierać bezpieczne komunikaty lub metadane, co jest przydatne, gdy wywołujący wymagają stabilnego zachowania, wykraczającego poza jedną ustaloną wartość błędu.

Odpowiedz na to pytanie z trenerem AI

13Jak backend napisany w Go powinien mapować błędy wewnętrzne na użyteczne odpowiedzi dla klienta, jednocześnie dostarczając operatorom odpowiednie dane diagnostyczne?

Backend w Go powinien na granicy aplikacji lub warstwy transportowej tłumaczyć wewnętrzne błędy na stabilne, bezpieczne dla klienta kategorie i odpowiedzi. Te kategorie powinny mapować się na odpowiednie kody statusu HTTP lub ich odpowiedniki w warstwie transportowej, wykorzystując bezpieczne komunikaty i kody do odczytu maszynowego zamiast surowych błędów wewnętrznych. Operatorzy nadal powinni otrzymywać informacje diagnostyczne poprzez ustrukturyzowane logi, ślady (traces), metryki, identyfikatory korelacji lub żądania oraz zachowane przyczyny pierwotne. Logowanie zazwyczaj najlepiej przeprowadzić raz, na granicy warstwy posiadającej kontekst żądania, aby uniknąć zarówno pominięcia awarii, jak i nadmiarowych, zduplikowanych logów.

Odpowiedz na to pytanie z trenerem AI

14Jak poprawnie zaimplementować niestandardowe typy błędów w Go i jak `errors.Join` wpływa na inspekcję oraz obsługę błędów w ścieżce czyszczenia zasobów (cleanup path)?

Niestandardowy typ błędu w Go spełnia interfejs `error`, implementując metodę `Error() string`. Może również zawierać ustrukturyzowane pola i implementować `Unwrap() error`, aby ujawnić błąd źródłowy. Istotna jest różnica między odbiornikami wskaźnikowymi (pointer receivers) a odbiornikami przez wartość (value receivers): odbiornik wskaźnikowy oznacza, że tylko `*T` spełnia interfejs `error`, podczas gdy odbiornik przez wartość zazwyczaj oznacza, że spełniają go zarówno `T`, jak i `*T`. Wpływa to na to, jak obiekty są kopiowane oraz jakiego typu powinny używać z `errors.As` systemy wywołujące. Funkcja `errors.Join` łączy wiele błędów w jeden; z kolei `errors.Is` i `errors.As` pozwalają na inspekcję tak połączonych błędów potomnych. Jest to użyteczne, gdy należy zwrócić błąd głównej operacji oraz błąd operacji czyszczenia, nie tracąc przy tym informacji o żadnym z nich.

Odpowiedz na to pytanie z trenerem AI

Współbieżność

15Jak działa instrukcja `select` z kanałami, biorąc pod uwagę wybór spośród gotowych przypadków, działanie bloków domyślnych (`default`) i obsługę operacji anulowalnych?

Słowo kluczowe `select` wyczekuje na wykonanie operacji na podanych mu wielu kanałach. Uruchamia ten jedyny przypadek (case), dla którego nadanie lub odbiór można pomyślnie przeprocesować. Jeżeli brakuje jakichkolwiek przygotowanych przypadków, pętla zostaje zawieszona (blokuje wykonanie) – o ile nie posiada bloku domyślnego. Blok `default` uruchamia się błyskawicznie tylko gdy operacja żadnego kanału nie jest możliwa do przeprowadzenia, to rozwiązanie przydaje się m.in. dla nieblokujących prób wysyłania/odbierania. Jeżeli wiele przypadków jest gotowych jednocześnie, to zamiast działać liniowo z kodem, Go pseudo-losowo dokonuje wyboru tego pojedynczego bloku do realizacji. Możliwe do zatrzymania operacje na kanałach rutynowo dokładają oddzielny przypadek z odczytem z kontekstu przez `ctx.Done()`, aby dana gorutyna w wypadku wstrzymania (cancel) czy czasu wygaśnięcia (timeout) nie musiała nieskończenie dalej czekać.

Odpowiedz na to pytanie z trenerem AI