Wybrane pytania rekrutacyjne Go dla programistów backendowych, pogrupowane tematycznie i generowane z tego samego katalogu pytań, który zasila platformę ćwiczeniową EngineerSpeak.
1Wyjaśnij, w jaki sposób wartości zerowe w języku Go działają dla typów wbudowanych i typów przypominających referencje (reference-like), oraz dlaczego mają one znaczenie podczas deklarowania zmiennych bez ich jawnej inicjalizacji.
W języku Go zmienna zadeklarowana bez jawnego inicjalizatora jest automatycznie inicjowana wartością zerową (zero value) przypisaną do danego typu. Typy liczbowe przyjmują wartość 0, bool staje się false, string to "", a tablice lub struktury są zerowane element po elemencie, bądź pole po polu. Typy wskaźnikowe lub przypominające referencje, takie jak wskaźniki, wycinki (slices), mapy, kanały, funkcje oraz interfejsy, mają nil jako swoją wartość zerową. Ma to kluczowe znaczenie, ponieważ zmienne w Go oraz pomijane pola w strukturach zawsze uruchamiają się w zdeterminowanym stanie, a nie przechowują wartości losowe (garbage), a wiele interfejsów API zostało zaprojektowanych tak, aby ich wartość zerowa stanowiła użyteczną wartość domyślną, choć niektóre wartości nil nadal mogą wymagać inicjalizacji przed wykonaniem określonych operacji.
2Jak Go obsługuje równość struktur i co się dzieje, gdy struktura zawiera nieporównywalne pola?
Wartości struktur w Go mogą być porównywane za pomocą `==` i `!=` tylko wtedy, gdy każde pole w strukturze jest porównywalne. Równość opiera się na porównaniu odpowiadających sobie pól, korzystając z reguły równości dla każdego z nich. Jeśli struktura zawiera nieporównywalne pole, takie jak wycinek (slice), mapa lub funkcja, cały typ struktury staje się nieporównywalny, a porównanie dwóch wartości takiego typu za pomocą `==` powoduje błąd kompilacji. W przypadku takich struktur należy użyć niestandardowej logiki porównywania lub odpowiedniej funkcji pomocniczej do głębokiego porównywania (deep equality), co znajduje zastosowanie szczególnie w testach.
3Wyjaśnij niemutowalność ciągów znaków (string) w Go oraz związek pomiędzy typami `string`, `[]byte`, `bytes.Buffer` i `strings.Builder`.
W języku Go `string` to niemutowalna sekwencja bajtów, będąca w większości przypadków tekstem w formacie UTF-8, choć nie ma odgórnego wymogu posiadania przez nią poprawnego kodowania UTF-8. Zawartości `string` nie można modyfikować w miejscu (in-place); w tym celu zazwyczaj najpierw konwertuje się ciąg na tablicę `[]byte` dla edycji na poziomie bajtów, bądź `[]rune` dla edycji na punktach kodowych (code points), by potem powrócić do formatu bazowego. Zwykłe konwersje na linii `string` i `[]byte` połączone są z kopiowaniem i alokacją w pamięci, z tej racji powtarzalne konwersje czy wielokrotna konkatenacja ciągów wewnątrz pętli okazują się dość kosztowne. Biblioteczna struktura `strings.Builder` uchodzi za zoptymalizowaną pod kątem sprawnego konstruowania łańcuchów znaków, tymczasem `bytes.Buffer` operuje mutowalnym buforem, wyjątkowo dogodnym do zadań wykorzystujących dane na poziomie bajtów z operacjami wejścia/wyjścia, ale z możliwością późniejszej produkcji formy końcowej dla obiektu typu `string`.
4Wyjaś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.
5Czym 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.
6W 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.
7Opisz różnicę między tablicami (arrays) a wycinkami (slices) w języku Go, uwzględniając zachowanie długości, pojemności oraz mechanizmu przechowywania pod spodem.
Tablica w Go ma stałą długość, która jest częścią jej typu, na przykład `[3]int`; przechowuje ona swoje elementy bezpośrednio, a przypisanie lub przekazanie tablicy jako argumentu powoduje skopiowanie całej jej wartości. Wycinek, taki jak `[]int`, to mały deskryptor wskazujący na tablicę bazową: koncepcyjnie zawiera wskaźnik do elementów, długość oraz pojemność. Długość wycinka to liczba widocznych w nim elementów; jego pojemność określa, ile elementów można użyć od początku wycinka do końca tablicy bazowej. Wycinki są elastyczne: ponowne wycinanie (reslicing) zmienia deskryptor, a funkcja `append` może ponownie wykorzystać tę samą tablicę bazową, jeśli pozwala na to pojemność, lub zaalokować nową w przeciwnym wypadku.
8Jak zachowuje się typ mapy w języku Go w odniesieniu do typów kluczy, brakujących kluczy, map o wartości nil oraz kolejności iteracji?
Typy kluczy map w Go muszą być porównywalne; wycinki (slices), mapy i funkcje nie mogą być używane bezpośrednio jako klucze. Wyszukiwanie nieobecnego klucza zwraca wartość zerową (zero value) dla typu elementu, dlatego idiom "comma-ok" (`v, ok := m[k]`) jest używany do odróżnienia braku klucza od obecności wartości zerowej. Mapę o wartości `nil` można odczytywać i iterować po niej, ale przypisanie do niej wywołuje panikę (panic); należy ją zainicjować przed zapisem. Kolejność iteracji po mapie jest nieokreślona i kod nie może na niej polegać.
9Opisz, 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`.
10Wyjaś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.
11Jak w Go działa pusty identyfikator (blank identifier) w kontekście nieużywanych wartości, importów oraz sprawdzania interfejsów w czasie kompilacji?
Pusty identyfikator (blank identifier) `_` pełni rolę symbolu zastępczego (placeholder), przeznaczonego tylko do zapisu. Przypisanie do niego odrzuca wartość i nie tworzy zmiennej, której można by użyć. Wykorzystuje się go do ignorowania niepotrzebnych wartości zwracanych z funkcji lub zmiennych pętli, do importowania pakietu wyłącznie dla jego efektów ubocznych za pomocą `import _ "pkg"`, a także do sprawdzania w czasie kompilacji (compile-time interface checks), czy dany typ implementuje interfejs, na przykład poprzez wyrażenie `var _ io.Reader = (*MyReader)(nil)`. Pusty import (blank import) nadal powoduje wykonanie kodu inicjalizacyjnego w importowanym pakiecie. Z kolei przypisanie mające na celu sprawdzenie interfejsu zakończy się błędem kompilacji, jeśli zbiór metod (method set) konkretnego typu nie spełnia jego wymagań.
12Jak w Go przebiega kolejność inicjalizacji pakietów z uwzględnieniem funkcji `init` oraz zaimportowanych zależności?
Kompilator Go dokonuje inicjalizacji struktury poszczególnych pakietów ułożonych względem siebie w sposób adekwatny do specyfiki i rozmiaru wzajemnie występujących zależności w kodzie programu. Zaimportowane zależności wewnątrz struktury danego środowiska uruchamiają swoje mechanizmy przed rozpoczęciem procesów u samego nadawcy ich wezwań do importu. W obrębie danego modułu deklarowane na poziomie skali pakietu parametry (variables) powoływane są do funkcjonowania na najwcześniejszym etapie, przed uruchomieniem jakiejkolwiek definicji `init`, w oparciu o ich umiejscowienie uzależnione pod kątem ogólnego szyku deklaracji usankcjonowanych narzuceniami wynikającymi z założeń samego języka programowania. Zaraz po zakończeniu inicjalizacji wyżej wspomnianych składowych system wywołuje pod kątem automatycznego trybu uruchomieniowego wbudowane moduły typu `init`; określona biblioteka jest w stanie integrować pod swoją postacią znacznie większą ich ilość, aczkolwiek one również pozbawione są prawa do samodzielnych i zarazem wprost egzekwowalnych wywołań przez użytkownika z obszaru kodu. Równocześnie każdemu zainicjalizowanemu po raz pierwszy z powyższych układów narzucono wymóg operowania ograniczonym wyłącznie do pojedynczej sekwencji inicjalizacyjnej. Dla powołanej formy finalnie budowanego programu wykonawczego procedura ładująca przetwarza jako priorytet wyciągnięty wzór całościowego powiązanego ze sobą strukturalnie drzewa poleceń do importu; następnie uaktywnia główny, nadrzędny pakiet o nazwie `main`, co nieuchronnie docelowo skutkuje wywołaniem definicji zagnieżdżonej procedury opisanej pod składnią słów `main.main`.
13Wyjaśnij zasady widoczności pakietów w Go, uwzględniając eksportowane identyfikatory oraz konwencję katalogu internal/.
W języku Go widoczność pakietów jest kontrolowana przez nazewnictwo identyfikatorów, a nie przez słowa kluczowe definiujące dostęp. Identyfikator, którego nazwa zaczyna się od wielkiej litery Unicode, jest eksportowany i może być używany w innych pakietach; pozostałe identyfikatory są nieeksportowane i można z nich korzystać tylko w obrębie tego samego pakietu. Dotyczy to funkcji, typów, metod, zmiennych, stałych oraz pól struktur. Pakiety używają eksportowanych identyfikatorów do definiowania swojego publicznego API (Application Programming Interface), a szczegóły implementacyjne pozostawiają nieeksportowane. Ponadto, pakiet znajdujący się w katalogu internal/ może być importowany tylko przez kod, którego ścieżka importu znajduje się w strukturze nadrzędnej drzewa tego katalogu internal; jest to wymuszane przez zestaw narzędzi języka Go (Go toolchain).
14Jak działa instrukcja defer w języku Go, wliczając w to kolejność wykonania, moment ewaluacji argumentów oraz jej interakcję ze zwracanymi wartościami?
Instrukcja `defer` planuje wywołanie funkcji do uruchomienia w momencie, gdy funkcja okalająca kończy działanie, niezależnie od tego, czy kończy się ona zwykłym powrotem, czy w wyniku zwijania stosu po wystąpieniu błędu panic. Wiele odroczonych wywołań uruchamia się w kolejności LIFO (Last-In, First-Out). Wartość odroczonej funkcji oraz jej argumenty są ewaluowane natychmiast po napotkaniu instrukcji `defer`, ale same wywołanie następuje później. Przy nazwanych wartościach zwracanych, instrukcja `return` najpierw przypisuje zwracane wartości, a następnie uruchamiane są funkcje odroczone, dzięki czemu odroczone domknięcie (closure) może obserwować lub modyfikować nazwane zmienne wynikowe, zanim otrzyma je kod wywołujący. Sprawia to, że `defer` jest bardzo przydatne do porządkowania (cleanup), np. zamykania plików, zwalniania muteksów i zasobów.
15Wyjaśnij model obsługi błędów w języku Go oraz konwencjonalne sposoby tworzenia, zwracania i sprawdzania błędów.
Go traktuje błędy jako zwykłe wartości, a nie wyjątki. Wbudowany interfejs `error` jest spełniany przez każdy typ posiadający metodę `Error() string`. Funkcje konwencjonalnie zwracają `error` jako ostatni wynik, gdzie `nil` oznacza sukces, a błąd różny od nil oznacza, że wywołujący musi obsłużyć lub przekazać niepowodzenie dalej. Proste błędy zazwyczaj tworzy się za pomocą `errors.New`, sformatowane przy użyciu `fmt.Errorf`, a wywołujący przeważnie sprawdzają błąd instrukcją `if err != nil { ... }`.
16Jak należy obsługiwać odzyskiwanie sprawności po błędach typu panic w usługach backendowych w języku Go, wliczając w to zachowanie systemu, gdy `goroutine` wywoła panic oraz kiedy proces powinien zostać odzyskany, a kiedy zakończyć się awarią?
Błąd typu panic rozwija stos bieżącego `goroutine`, uruchamiając powiązane z nim funkcje opóźnione (deferred functions). Funkcja `recover` działa tylko wtedy, gdy zostanie wywołana z funkcji opóźnionej w tym samym `goroutine`; jeden `goroutine` nie może przechwycić (recover) błędu panic innego `goroutine`. Jeśli panic nie zostanie przechwycony, proces kończy się awarią. W usługach backendowych mechanizm odzyskiwania powinien być zazwyczaj umieszczany na granicach izolacji, takich jak procedury obsługi żądań (request handlers), oprogramowanie pośredniczące RPC (Remote Procedure Call) lub punkty wejścia dla przetwarzających dane `goroutine`, aby jedno nieudane żądanie lub zadanie nie doprowadziło do awarii całej usługi. Jeśli jednak panic mógł uszkodzić współdzielony stan lub sprawić, że integralność procesu stała się niewiarygodna, bezpieczniej jest pozwolić procesowi na awarię i restart, niż odzyskiwać jego działanie i kontynuować pracę w ciemno.
17Czym są kanały typu nil w języku Go i jak mogą one przypadkowo zepsuć kod lub celowo wyłączyć klauzule instrukcji select?
Kanał typu nil to zmienna kanałowa, której wartość wynosi nil, często dlatego, że nie została zainicjowana za pomocą make lub została jawnie ustawiona na nil. Wysyłanie do kanału nil lub odbieranie z niego blokuje operację w nieskończoność. Wewnątrz instrukcji select, klauzula case obejmująca kanał nil nigdy nie jest gotowa, więc przypisanie wartości nil do zmiennej kanałowej może celowo wyłączyć daną gałąź. Przypadkowe użycie kanału nil może spowodować zawieszenie się goroutines lub sprawić, że logika instrukcji select przestanie obsługiwać oczekiwane zdarzenia.
18Czym różnią się operacje atomowe w pakiecie `sync/atomic` od synchronizacji opartej na muteksach i kiedy są one odpowiednie?
Pakiet `sync/atomic` zapewnia niepodzielne operacje na pojedynczych lokacjach pamięci, takie jak load, store, add, swap i compare-and-swap, z gwarancjami synchronizacji i porządkowania pamięci. Muteks chroni sekcję krytyczną, dzięki czemu może zabezpieczać dowolny kod oraz niezmienniki obejmujące wiele odczytów, zapisów lub pól. Zmienne atomowe są odpowiednie dla prostego, niezależnego stanu, takiego jak liczniki, flagi, numery sekwencyjne lub starannie zaprojektowane struktury bezblokowe. Preferuj użycie muteksu, gdy operacje są złożone, wiele wartości musi pozostać spójnych, lub gdy wersja atomowa byłaby trudna do analizy lub udowodnienia jej poprawności.
19Jak należy zaprojektować własność kanału i czas życia gorutyn, aby uniknąć wycieków gorutyn?
Projektuj gorutyny z wyraźnym właścicielem, jasnym sygnałem zakończenia i gwarantowaną ścieżką wyjścia. Strona produkująca zazwyczaj posiada prawo własności do zamykania kanału, zwłaszcza kanału wyjściowego; odbiorcy nie powinni zamykać kanału, dopóki nadawcy mogą wciąż być aktywni. Każde blokujące wysyłanie, odbieranie, pętla, timer lub wywołanie zewnętrzne powinno mieć zagwarantowane zakończenie lub możliwość odblokowania w przypadku anulowania, najczęściej poprzez `context.Context` lub kanał `done`. Używaj `WaitGroup`, `errgroup` lub podobnej koordynacji, aby procesy robocze były oczekiwane, a kanały zamykane dopiero po zakończeniu pracy nadawców.
20Jakie są najczęstsze przyczyny wycieków `goroutine` w usługach Go oraz jak je wykrywać i naprawiać na środowisku produkcyjnym?
Najczęstsze przyczyny wycieków `goroutine` w usługach w języku Go to rutyny zablokowane na stałe podczas wysyłania lub odbierania danych z kanałów, oczekujące na inne blokujące operacje bez obsługi anulowania, uwięzione w wejściu/wyjściu (I/O) bez określonych limitów czasu (deadlines), pętle w tle lub czasomierze (tickers), które nigdy się nie zatrzymują, a także wywołane na rzecz żądania (request-scoped) rutyny `goroutine`, których czas życia przekracza czas życia samego żądania. W środowisku produkcyjnym należy szukać trwałego wzrostu liczby `goroutine` i powiązanych z tym objawów, a następnie badać zrzuty ich stanu lub profile powiązane z narzędziem pprof, aby ustalić ich miejsce zablokowania. Naprawa wycieku polega na modyfikacji kodu tak, aby zablokowane `goroutine` mogły zostać zakończone: należy wdrożyć anulowanie i ograniczenia czasowe, poprawnie zamykać kanały i czasomierze, unikać stosowania odłączonych rutyn dla żądań (detached request goroutines) oraz ograniczać współbieżność w niezbędnym stopniu.