Na rozmowach z Go najczęściej pada kilkanaście pytań: czym różni się slice od tablicy, dlaczego zapis do nil mapy powoduje panikę, jak działają interfejsy i obsługa błędów, co robideferoraz czym są goroutine, kanały icontext. Rekruter nie sprawdza, czy znasz całą bibliotekę standardową, tylko czy rozumiesz, jak Go zarządza pamięcią i współbieżnością. Dobra odpowiedź to jedno zdanie definicji i krótki przykład kodu.
Go (GoLang) pojawia się w ofertach na backend i infrastrukturę, a rozmowa o nim ma zwykle prosty schemat: kilkanaście tematów, w których rekruter sprawdza, czy rozumiesz mechanizm, a nie czy pamiętasz nazwy funkcji. Poniżej znajdziesz pytania, które wracają najczęściej, razem z tym, jak na nie odpowiadać. Jeśli dopiero zaczynasz, najpierw przeczytaj artykuł o tym, jak zainstalować Go, gdzie jest używany i jak wygląda rynek pracy, a ten tekst potraktuj jako listę kontrolną. Podobne pytania o język, który często jest drugim w CV Go developera, znajdziesz w artykule o pytaniach rekrutacyjnych z Pythona.
Wszystkie przykłady uruchomiliśmy w Go 1.27.3: sprawdziliśmy zachowanie slice'ów i map, defer, interfejsów, kanałów, select, context, generyków, detektora wyścigów i testów. Wyniki w tekście, na przykład wartości len i cap czy komunikaty błędów, pochodzą z tych uruchomień.
Jak odpowiadać na pytania rekrutacyjne z Go?
Najlepiej w trzech krokach: jedno zdanie definicji, krótki przykład (kilka linijek kodu albo sytuacja z projektu) i informacja, kiedy to ma znaczenie w praktyce. Taka odpowiedź zajmuje 30–60 sekund i pokazuje, że rozumiesz mechanizm, a nie wkułeś formułkę. Jeśli czegoś nie wiesz, powiedz, co wiesz, i rozumuj na głos. Rekruterzy oceniają sposób myślenia, a nie tylko wynik.
Pytania są ułożone od podstaw do tematów, które częściej pojawiają się przy ofertach na mid-poziom.
Podstawy Go
Czym jest Go i czym różni się od Pythona i Javy?
Go to kompilowany, statycznie typowany język stworzony w Google, z automatycznym zarządzaniem pamięcią (garbage collector) i wbudowaną współbieżnością. Kompilator zamienia kod na jeden plik wykonywalny, więc nie potrzebujesz maszyny wirtualnej ani interpretera na serwerze, jak w Javie czy Pythonie. Go nie ma klas ani dziedziczenia, tylko struktury, metody i interfejsy, a obsługę błędów realizuje przez zwracanie ich jako wartości zamiast wyjątków. Więcej o historii i zastosowaniach piszemy w artykule o języku Go.
Czym jest wartość zerowa (zero value)?
Każda zmienna w Go ma wartość domyślną, nawet jeśli jej nie ustawisz: 0 dla liczb, "" dla napisów, false dla wartości logicznych i nil dla wskaźników, slice'ów, map, kanałów i interfejsów. Dlatego new(int) wskazuje na 0, a nie na „nic”. Dobrze zaprojektowane typy w Go działają od razu po utworzeniu z wartością zerową.
Czym różni się var od :=?
var x int deklaruje zmienną z wartością zerową i działa wszędzie, także poza funkcjami. Skrócone x := 5 deklaruje i przypisuje wartość jednocześnie, ale jest dozwolone tylko wewnątrz funkcji, a typ wynika z wartości.
Czym różni się make od new?
new(T) alokuje pamięć na wartość typu T i zwraca wskaźnik do niej, wypełniony wartością zerową. make działa tylko dla slice'ów, map i kanałów i zwraca gotową do użycia strukturę. W teście new(int) dało 0, make([]int, 2, 5) slice o długości 2 i pojemności 5, a make(chan int, 1) kanał o pojemności 1. Dlatego mapę zawsze tworzysz przez make (albo literał), a nie przez new.
Jak Go decyduje, co jest publiczne?
Przez wielką literę. Nazwa zaczynająca się od wielkiej litery (Divide) jest eksportowana z pakietu, a z małej (divide) jest prywatna. Nie ma słów kluczowych public ani private.
Czym jest moduł i po co go mod init?
Moduł to jednostka zarządzania zależnościami, opisana w pliku go.mod. go mod init nazwa tworzy go w bieżącym katalogu. Uważaj na nazwę: moduł nazwany tak samo jak pakiet biblioteki standardowej (slices, maps, embed) kończy się błędem ambiguous import: found package … in multiple modules. Trafiliśmy na to podczas przygotowywania przykładów do tego artykułu.
Slice'y, mapy i napisy
Czym różni się tablica od slice'a?
Tablica ma stałą długość będącą częścią typu ([3]int) i przy przypisaniu jest kopiowana. Slice to „okno” na tablicę: ma wskaźnik do danych, długość (len) i pojemność (cap), i przy przypisaniu kopiuje się tylko ten opis, a nie dane. W teście zmiana kopii tablicy nie zmieniła oryginału ([0 0 0] i [5 0 0]), a dwa slice'y mogą współdzielić te same dane.
Co się stanie po append, gdy slice współdzieli dane?
To najpopularniejsze pytanie o slice'y. Przykład:
a := []int{1, 2, 3}
b := a[:2]
b = append(b, 99)
fmt.Println(a, b, len(b), cap(b)) // [1 2 99] [1 2 99] 3 3
c := append(a, 4)
c[0] = -1
fmt.Println(a, c, len(c), cap(c)) // [1 2 99] [-1 2 99 4] 4 6
Pierwszy append zmieścił się w pojemności slice'a b, więc nadpisał trzeci element wspólnej tablicy i zmienił a. Drugi append przekroczył pojemność, więc Go zaalokowało nową tablicę (pojemność 6) i zmiana c[0] nie dotknęła a. Wniosek: po append zawsze przypisuj wynik do zmiennej i nie zakładaj, czy dane zostały skopiowane.
Dlaczego zapis do nil mapy powoduje panikę?
Mapa, której nie utworzyłeś przez make, ma wartość nil. Odczyt z niej jest bezpieczny (zwraca wartość zerową i len równe 0), ale zapis kończy się paniką assignment to entry in nil map. Ten błąd ma na Stack Overflow ponad 200 tys. wyświetleń. Rozwiązanie to m := make(map[string]int) albo literał map[string]int{}. Do sprawdzania, czy klucz istnieje, służy forma z drugą wartością: v, ok := m["b"] zwraca 0 false dla brakującego klucza.
Czy kolejność elementów w mapie jest stała?
Nie. Specyfikacja języka nie gwarantuje żadnej kolejności iteracji po mapie, więc nie wolno na niej polegać. Jeśli potrzebujesz porządku, posortuj klucze osobno.
Czym różni się bajt, rune i napis?
Napis w Go to ciąg bajtów w kodowaniu UTF-8. len("zażółć") zwraca 10 (bajtów), a nie 6 (znaków), bo polskie znaki zajmują po dwa bajty. Liczbę znaków (runes) zwraca utf8.RuneCountInString(s) albo len([]rune(s)): w teście oba dały 6. Pętla for i, r := range s iteruje po znakach, ale i to indeks bajtowy, dlatego dla "żó" zobaczyliśmy indeksy 0 i 2.
Metody, interfejsy i błędy
Czym różni się receiver wartościowy od wskaźnikowego?
Metoda z receiverem wartościowym (func (c Counter) Inc()) pracuje na kopii, więc nie zmienia oryginału. Z receiverem wskaźnikowym (func (c *Counter) Inc()) zmienia oryginał. W teście IncValue zostawiło licznik na 0, a IncPointer ustawił go na 1. Receiver wskaźnikowy wybierasz, gdy metoda zmienia stan albo struktura jest duża, a trzymanie jednego rodzaju receivera w typie to dobra praktyka. To jedno z najczęściej pytanych zagadnień: pytanie o receivery ma na Stack Overflow ponad 87 tys. wyświetleń.
Jak działają interfejsy w Go?
Interfejs to zestaw metod, a typ go spełnia, jeśli ma te metody, bez żadnej deklaracji (implicit). Nie piszesz implements. Dzięki temu interfejs możesz zdefiniować po stronie kodu, który go używa, a nie po stronie typu. Pamiętaj o szczegółach: jeśli metoda ma receiver wskaźnikowy, interfejs spełnia wskaźnik (&c), a nie wartość. W teście var i Incer = &c zadziałało właśnie dlatego.
Dlaczego interfejs z nil wskaźnikiem nie jest równy nil?
Interfejs w Go przechowuje dwie rzeczy: typ i wartość. Jest równy nil tylko wtedy, gdy obie są puste. Przykład:
type MyErr struct{}
func (*MyErr) Error() string { return "błąd" }
func doWork() error {
var e *MyErr // nil wskaźnik
return e
}
err := doWork()
fmt.Println(err == nil) // false
Funkcja zwraca interfejs error z typem *MyErr i pustą wartością, więc porównanie z nil daje false, a kod if err != nil uzna, że wystąpił błąd. Zasada: gdy nie ma błędu, zwracaj jawnie nil, a nie nil-owy wskaźnik konkretnego typu.
Czy w Go jest dziedziczenie?
Nie, zamiast niego jest osadzanie (embedding). W teście Dog z osadzonym Animal dostał jego pola i metody, ale ponieważ zdefiniowaliśmy własną metodę Speak, zadziałała ona jako pierwsza: d.Speak() zwróciło „Reks szczeka”, a d.Animal.Speak() „Reks robi hałas”. To kompozycja, a nie polimorfizm przez klasę bazową.
Jak działa obsługa błędów w Go?
Funkcje zwracają błąd jako ostatnią wartość, a wywołujący sprawdza go przez if err != nil. Błąd można opakować (fmt.Errorf("find %d: %w", id, err)) i później rozpakować funkcjami errors.Is (czy w łańcuchu jest dany błąd) i errors.As (czy jest błąd danego typu). W teście errors.Is(e1, ErrNotFound) zwróciło true mimo opakowania, a errors.As wyciągnęło *ValidationError i jego pole Field. Panikę (panic) zostaw na sytuacje naprawdę wyjątkowe, a nie na zwykłe błędy.
Jak działa defer, panic i recover?
defer odkłada wywołanie funkcji do momentu zakończenia funkcji zewnętrznej. Odroczone wywołania wykonują się w kolejności odwrotnej (LIFO): pętla z defer fmt.Println("defer", i) wypisała 2, 1, 0. Argumenty są obliczane w chwili wywołania defer, nie wykonania: po x := 1; defer fmt.Println(x); x = 2 zobaczyliśmy 1. defer może też zmienić nazwany wynik funkcji: defer func() { n *= 2 }() przy return 21 dało 42. recover wywołany w odroczonej funkcji przechwytuje panikę, jak w przykładzie z nil mapą. Najczęstsze zastosowanie defer to zamykanie plików i zwalnianie blokady (defer mu.Unlock()).
Współbieżność
Czym jest goroutine i czym różni się od wątku?
Goroutine to lekka jednostka wykonania zarządzana przez środowisko uruchomieniowe Go, a nie bezpośrednio przez system operacyjny. Startujesz ją słowem go przed wywołaniem funkcji, a środowisko rozdziela wiele goroutine na niewielką liczbę wątków systemowych. Dzięki temu możesz uruchomić ich tysiące bez dużego kosztu. Podstawowy przykład z sync.WaitGroup opisaliśmy w artykule o języku Go.
Czym różni się kanał buforowany od niebuforowanego i skąd deadlock?
Kanał niebuforowany działa jak przekazanie z ręki do ręki: wysyłający czeka, aż ktoś odbierze. Kanał buforowany (make(chan int, 1)) pozwala wysłać tyle wartości, ile mieści bufor, bez czekania. Wysyłanie i odbieranie na niebuforowanym kanale w tej samej goroutine kończy się komunikatem fatal error: all goroutines are asleep - deadlock! (to pytanie ma na Stack Overflow ponad 90 tys. wyświetleń). Rozwiązanie: odbiorca w osobnej goroutine albo bufor, przy czym bufor często tylko odsuwa problem.
Do czego służy select?
Pozwala czekać na kilka kanałów naraz. Z gałęzią default nie blokuje (w teście wypisał „brak danych”), a z time.After pozwala ustawić limit czasu (po 100 ms wypisał „timeout”). Odczyt z zamkniętego kanału zwraca wartość zerową i false w drugiej zmiennej ({} false), więc tak sprawdza się, że kanał został zamknięty.
Czym jest wyścig danych (data race) i jak go uniknąć?
To sytuacja, w której kilka goroutine jednocześnie czyta i zapisuje tę samą zmienną bez synchronizacji. W teście tysiąc goroutine wykonujących count++ zwróciło 952 zamiast 1000, a wynik był za każdym razem inny. Uruchomienie z flagą -race (go run -race .) wypisało WARNING: DATA RACE ze wskazaniem linii. Rozwiązania to sync.Mutex (z ochroną mu.Lock() i defer mu.Unlock(), co dało 1000), pakiet sync/atomic albo przekazywanie danych kanałami. Zasada, którą powtarza się w świecie Go: nie komunikuj się przez współdzielenie pamięci, współdziel pamięć przez komunikację.
Do czego służy context?
Pakiet context przenosi przez wywołania sygnał anulowania i limit czasu. Funkcja, która wykonuje pracę, nasłuchuje na ctx.Done(). W teście context.WithTimeout z limitem 100 ms przerwał pracę trwającą 500 ms i zwrócił context deadline exceeded. Dobra praktyka: context jest pierwszym argumentem funkcji, a po utworzeniu zawsze wywołujesz defer cancel().
Dlaczego pętla z goroutine wypisywała same trójki?
Do wersji Go 1.21 zmienna pętli i była jedna dla całej pętli, więc goroutine uruchomione w pętli widziały jej końcową wartość. W teście z go 1.21 w pliku go.mod wynik to [3 3 3], a z go 1.22 [0 1 2], bo od wersji 1.22 każda iteracja ma własną kopię zmiennej. To ważne pytanie, bo starsze projekty nadal mogą mieć niższą wersję w go.mod, a rozwiązaniem jest przekazanie i jako argumentu funkcji. Wersję języka sprawdzisz w pliku go.mod.
Jak zbudować prostą pulę workerów?
To popularne zadanie z kodem. Kilka goroutine czyta z kanału zadań i wysyła wyniki na drugi kanał:
jobs := make(chan int, 10)
results := make(chan int, 10)
var wg sync.WaitGroup
for w := 0; w < 3; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := range jobs {
results <- j * j
}
}()
}
for i := 1; i <= 5; i++ {
jobs <- i
}
close(jobs)
wg.Wait()
close(results)
Po zebraniu i posortowaniu wyników otrzymaliśmy [1 4 9 16 25]. Kluczowe elementy: zamknięcie kanału zadań (close(jobs)) kończy pętle range w workerach, WaitGroup czeka na workery, a dopiero potem zamykamy kanał wyników. Kolejność zamykania jest częstym powodem błędów.
Generyki i testy
Czym są generyki (typy generyczne) w Go?
Pozwalają pisać funkcje działające na wielu typach, z ograniczeniem opisanym interfejsem. Pojawiły się w Go 1.18. W teście funkcja Sum[T Number] zsumowała zarówno []int (6), jak i []float64 (4), a Map[T, U any] zamieniła []int na []string ([1 4 9]). Używaj ich, gdy powtarzasz tę samą logikę dla różnych typów, a nie „na zapas”.
Jak testuje się kod w Go?
Narzędziem wbudowanym w język: plik calc_test.go, funkcje TestXxx(t *testing.T) i polecenie go test. Typowy wzorzec to test tabelaryczny (table-driven) z podtestami t.Run:
tests := []struct {
name string
a, b int
want int
wantErr bool
}{
{"zwykłe", 6, 3, 2, false},
{"zero", 1, 0, 0, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Divide(tt.a, tt.b)
if (err != nil) != tt.wantErr || got != tt.want {
t.Fatalf("Divide(%d,%d) = %d, %v", tt.a, tt.b, got, err)
}
})
}
W naszym przykładzie go test -v uruchomiło oba podtesty i zwróciło PASS. Benchmarki (testing.B) i flagę -race wspominaj, gdy rozmowa dotyczy jakości kodu.
Zadania z kodem i mini-test
Jakie zadania z Go pojawiają się na rozmowach?
Najczęściej krótkie zadania typu: „napisz pulę workerów”, „zsumuj liczby z kilku goroutine bez wyścigu”, „odwróć napis ze znakami Unicode” albo „znajdź błąd w tym kodzie”. Przy takich zadaniach liczy się nie szybkość pisania, tylko to, czy rozumiesz, co zrobi środowisko uruchomieniowe. Mów na głos, co się stanie z Twoim kodem.
Co jest nie tak w tym kodzie? Mini-test na rozgrzewkę
func main() {
var counts map[string]int
words := []string{"a", "b", "a"}
for _, w := range words {
go func() {
counts[w]++
}()
}
fmt.Println(counts)
}
Są tu co najmniej trzy problemy:
- Nil mapa.
countsnie została utworzona przezmake, więc zapiscounts[w]++powoduje panikęassignment to entry in nil map. W pięciu uruchomieniach program za każdym razem kończył się tą paniką, a w dwóch z nich zdążył wcześniej wypisać pustą mapę (map[]). - Brak oczekiwania na goroutine.
mainwypisuje mapę bez czekania na goroutine, więc wynik zależy od tego, kto był pierwszy. To dlatego zachowanie różniło się między uruchomieniami. Potrzebny jestsync.WaitGroupalbo kanał. - Wyścig danych na mapie. Nawet po poprawce mapy wiele goroutine zapisuje do niej jednocześnie, a mapy w Go nie są bezpieczne współbieżnie. Potrzebny jest
sync.Mutex(lub przekazywanie wyników kanałem). Zmienną pętliww nowszych wersjach Go (od 1.22) każda goroutine widzi poprawnie, ale w starszych byłby tu czwarty problem.
Jak przygotować się do rozmowy z Go?
- Zbuduj małą aplikację od zera. Najlepiej prosty serwer HTTP z pakietem
net/httpalbo narzędzie wiersza poleceń, z testami tabelarycznymi. Instalację i pierwszy program opisaliśmy w artykule o języku Go. Gotowy kod pokaż doświadczonej osobie do code review: uwagi o idiomach Go, obsłudze błędów i goroutine to najszybszy sposób, żeby poprawić się przed rozmową. - Uruchom wszystko z flagą
-race. Zobacz na własne oczy wyścig danych, a potem go napraw. Rozumienie tej różnicy odróżnia kandydatów. - Przerób kilka klasycznych zadań. Pula workerów, pipeline z kanałami, anulowanie przez
context. - Przećwicz odpowiedzi na głos. Dla każdego pytania z tego artykułu sformułuj w 30 sekund definicję i przykład.
Na czym juniorzy najczęściej tracą punkty?
Nie na trudnych tematach, tylko na trzech drobiazgach, które wychodzą dopiero przy pytaniach uzupełniających:
- Slice'y jako „listy”. Odpowiedź „slice to dynamiczna tablica” kończy temat. Wyjaśnij długość, pojemność i współdzielenie danych, bo to właśnie pytanie uzupełniające o
append. - Goroutine bez synchronizacji. Uruchamianie goroutine bez
WaitGroupi bez ochrony współdzielonych danych. Pokaż, że znasz-race. - Mylenie
nilw interfejsie. Wiele osób nie wie, że interfejs z nil wskaźnikiem nie jest równynil. Wspomnij o tym przy obsłudze błędów.
Jak brzmi dobra odpowiedź, a jak słaba?
Weźmy pytanie o slice'y:
| Odpowiedź | Dlaczego | |
|---|---|---|
| Słaba | „Slice to taka tablica, która może rosnąć.” | Brak mechanizmu. Pytanie uzupełniające („co się stanie z drugim slice'em po append?”) od razu to pokaże. |
| Dobra | „Slice to okno na tablicę: ma wskaźnik, długość i pojemność. Dwa slice'y mogą współdzielić dane, więc append w granicach pojemności zmienia obie, a po przekroczeniu pojemności Go tworzy nową tablicę. Dlatego zawsze przypisuję wynik append.” | Jest definicja, konsekwencja i reguła postępowania. |
Ten sam schemat działa przy każdym pytaniu z tego artykułu: definicja, mechanizm, przykład użycia.
Co zrobić, gdy nie znasz odpowiedzi?
Nie zgaduj i nie milcz. Powiedz, co wiesz na pewno, a potem rozumuj na głos, np.: „Nie pamiętam dokładnej nazwy tej funkcji, ale wiem, że szukam sposobu na bezpieczne zliczanie z wielu goroutine, więc użyłbym mutexa albo kanału i sprawdził w dokumentacji.” Taka odpowiedź pokazuje, że potrafisz sobie poradzić w pracy, a umiejętność znalezienia odpowiedzi jest w codziennej pracy ważniejsza niż pamięć do szczegółów.
Ogólny przebieg całego procesu rekrutacji opisujemy w poradniku jak przygotować się do rekrutacji na juniora.


