Priprema za Middle Go

Middle Go Backend interview pitanja

15 izabranih Middle Go interview pitanja za backend developere koji treba da objasne praktične trade-offs, konkurentnost i ponašanje servisa.

Započni Middle Go AI intervjuKreditna kartica nije potrebna. Dostupna je 1 besplatna sesija.
Vežba tehničkog intervjua na engleskomRežim u kome kandidati kojima engleski nije maternji mogu da vežbaju tehničke intervjue.

Sistem tipova

1Objasnite kako se nil različito ponaša kod pokazivača, isečaka (slices), mapa, kanala, funkcija i interfejsa u jeziku Go.

U jeziku Go, nil je nulta vrednost za pokazivače, isečke (slices), mape, kanale, funkcije i interfejse, ali se operacije nad tim nil vrednostima razlikuju u zavisnosti od tipa. Nil pokazivač se može porediti sa nil, ali njegovo dereferenciranje izaziva paniku (panic). Nil isečak ima dužinu i kapacitet 0, može se iterirati pomoću range petlje i u njega se mogu dodavati elementi pomoću funkcije append. Iz nil mape se može čitati i može se iterirati kroz nju, ali dodeljivanje vrednosti izaziva paniku. Slanje podataka u nil kanal ili primanje iz njega blokira izvršavanje zauvek, a zatvaranje nil kanala izaziva paniku. Pozivanje nil funkcije izaziva paniku. Interfejs je nil samo kada nema ni dinamički tip ni dinamičku vrednost; interfejs koji sadrži tipiziranu nil vrednost, poput nil pokazivača, sam po sebi nije nil.

package main

import "fmt"

type MyErr struct{}

func (*MyErr) Error() string { return "my error" }

func returnsTypedNil() error {
	var e *MyErr = nil
	return e
}

func main() {
	var p *int = nil
	var s []int = nil
	var m map[string]int = nil
	var err error = returnsTypedNil()

	fmt.Println(p == nil)
	fmt.Println(len(s), cap(s), s == nil)
	fmt.Println(m["missing"])
	fmt.Println(err == nil)
}
Pokušajte da odgovorite na ovo pitanje sa AI trenerom

2Šta su uporedivi tipovi u jeziku Go i kako pravila uporedivosti utiču na ključeve mapa, jednakost i ograničenja u genericima?

Uporedivi tipovi u jeziku Go su tipovi čije se vrednosti mogu porediti pomoću `==` i `!=`. Osnovni tipovi, pokazivači, kanali, interfejsi i strukture/nizovi čija su polja ili elementi uporedivi jesu uporedivi; isečci (slices), mape i funkcije nisu uporedivi osim sa `nil`. Ključevi mapa moraju biti uporedivi. Jednakost prati pravila poređenja datog tipa, a poređenje interfejsa zavisi od dinamičkih konkretnih vrednosti; ako interfejs koji se poredi sadrži neuporedivu dinamičku vrednost, poređenje izaziva paniku (panic). U genericima, unapred deklarisano ograničenje `comparable` omogućava poređenje parametara tipa pomoću `==`/`!=` i njihovo korišćenje kao ključeva mape.

package main

import "fmt"

type Point struct{ X, Y int }      // comparable
type Bag struct{ Items []string }  // not comparable because of slice field

func Contains[T comparable](xs []T, target T) bool {
    for _, x := range xs {
        if x == target {
            return true
        }
    }
    return false
}

func main() {
    p1, p2 := Point{1, 2}, Point{1, 2}
    fmt.Println(p1 == p2)

    m := map[Point]string{p1: "value"}
    fmt.Println(m[p2])

    fmt.Println(Contains([]string{"a", "b"}, "b"))

    var a any = []int{1}
    var b any = []int{1}
    _, _ = a, b
    // fmt.Println(a == b) // panic: comparing uncomparable type []int
}
Pokušajte da odgovorite na ovo pitanje sa AI trenerom

3Kako Go predstavlja bajtove, rune i tekst kodiran u UTF-8 formatu, i zašto se len(s) može razlikovati od broja karaktera vidljivih korisniku?

U jeziku Go, `byte` je alijas za `uint8` i predstavlja jedan sirovi bajt, dok je `rune` alijas za `int32` i predstavlja Unicode kodnu tačku. `string` je niz bajtova samo za čitanje, uglavnom UTF-8 kodiran tekst, ali može sadržati i proizvoljne bajtove. Funkcija `len(s)` vraća broj bajtova, a ne broj runa ili karaktera vidljivih korisniku. Indeksiranje stringa vraća bajt; iteracija kroz string pomoću `range` dekodira UTF-8 i daje indekse bajtova zajedno sa runama. `len(s)` se može razlikovati od broja vidljivih karaktera jer UTF-8 može koristiti više bajtova po jednoj kodnoj tački i zato što jedan korisnički vidljiv karakter može biti sastavljen od više kodnih tačaka, kao što su kombinujući znaci ili sekvence emodžija.

package main

import "fmt"

func main() {
    s := "é🙂"

    fmt.Println(len(s))         // bytes: é is 2 bytes, 🙂 is 4 bytes
    fmt.Println(len([]rune(s))) // Unicode code points

    fmt.Printf("first byte: %x\n", s[0])

    for i, r := range s {
        fmt.Printf("byte index %d: %q U+%04X\n", i, r, r)
    }
}
Pokušajte da odgovorite na ovo pitanje sa AI trenerom

4Po čemu se alijasi tipova razlikuju od definisanih tipova i kada biste koristili koje?

Definisani tip, kao što je `type UserID int64`, kreira novi, zaseban tip sa `int64` kao osnovnim tipom. Ne može se slobodno dodeliti tipu `int64` bez konverzije i može imati sopstvene metode. Alijas tipa, kao što je `type UserID = int64`, predstavlja samo drugo ime za isti tip, tako da se identitet tipa i mogućnost dodele čuvaju. Definisane tipove koristite za modelovanje domena, tipsku bezbednost i metode, dok alijase koristite uglavnom za refaktorisanje, migraciju ili kompatibilnost bez uvođenja novog tipa.

type UserID int64      // defined type
type AccountID = int64 // alias

func takeInt64(x int64) {}

var u UserID = 10
var a AccountID = 20

takeInt64(int64(u)) // explicit conversion required
takeInt64(a)        // ok: AccountID is exactly int64

func (u UserID) String() string {
    return fmt.Sprintf("user:%d", u)
}
Pokušajte da odgovorite na ovo pitanje sa AI trenerom

5Kako Go tretira specijalne vrednosti sa pokretnim zarezom i koje su zamke poređenja jednakosti važne u serverskim sistemima?

Tipovi `float32` i `float64` u jeziku Go prate ponašanje po standardu IEEE-754, uključujući specijalne vrednosti kao što su pozitivna/negativna beskonačnost i NaN. Za `float64`, pomoćne funkcije uključuju `math.Inf`, `math.IsInf`, `math.NaN` i `math.IsNaN`. NaN nije jednak ničemu, uključujući i samog sebe, tako da je `x == x` netačno (`false`) kada je `x` NaN. Provera egzaktne jednakosti nad izračunatim brojevima sa pokretnim zarezom je takođe rizična jer zaokruživanje i preciznost mogu dovesti do toga da se matematički jednake vrednosti razlikuju; koristite tolerancije prilagođene domenu ili izbegavajte brojeve sa pokretnim zarezom za precizne poslovne vrednosti poput novca. Dozvoljeno je koristiti brojeve sa pokretnim zarezom kao ključeve u mapi, ali su NaN ključevi problematični jer pretraga u mapi zavisi od jednakosti, a NaN se ne smatra jednakim čak ni samom sebi.

nan := math.NaN()
fmt.Println(nan == nan)
fmt.Println(math.IsNaN(nan))

m := map[float64]string{nan: "bad key"}
fmt.Println(m[nan]) // lookup fails because nan != nan
Pokušajte da odgovorite na ovo pitanje sa AI trenerom

6Opišite ugrađivanje struktura (struct embedding) u jeziku Go i kako se ponašaju promovisana polja i metode.

Ugrađivanje struktura u jeziku Go podrazumeva deklarisanje polja samo njegovim tipom bez eksplicitnog imena polja, na primer `type User struct { Person }`. Ugrađena vrednost je i dalje stvarno polje, dostupno kao `u.Person`, ali njena izvezena/dostupna polja i metode mogu biti promovisani tako da pozivaoci mogu pisati selektore poput `u.Name` ili `u.Greet()` kao skraćeni zapis za pristup kroz ugrađeno polje. Ugrađivanje predstavlja kompoziciju, a ne klasično nasleđivanje: spoljašnji tip ne postaje automatski podtip ugrađenog tipa. Ako su promovisani selektori u konfliktu, Go ne nagađa; dvosmislena imena moraju biti kvalifikovana ili se njima ne može pristupiti preko spoljašnje vrednosti.

package main

import "fmt"

type Person struct {
    Name string
}

func (p Person) Greet() string {
    return "hi " + p.Name
}

type User struct {
    Person // embedded field; field name is Person
    ID     int
}

func main() {
    u := User{Person: Person{Name: "Ana"}, ID: 10}

    fmt.Println(u.Name)        // promoted field: u.Person.Name
    fmt.Println(u.Greet())     // promoted method: u.Person.Greet()
    fmt.Println(u.Person.Name) // explicit qualification still works
}
Pokušajte da odgovorite na ovo pitanje sa AI trenerom

Strukture podataka

7Opišite kako ponovno isecanje (reslicing) i dodela isečka (slice) mogu dovesti do toga da više isečaka deli isti osnovni niz, kao i kakve greške to može izazvati.

Vrednost isečka (`slice`) predstavlja zaglavlje koje pokazuje na osnovni niz. Dodeljivanje isečka ili njegovo prosleđivanje funkciji kopira samo to zaglavlje, a ne i same elemente. Ponovno isecanje kreira novo zaglavlje koje pokazuje na opseg istog osnovnog niza. Zbog toga više isečaka može deliti istu memorijsku lokaciju: izmena elementa kroz jedan isečak može biti vidljiva kroz drugi, a dodavanje elemenata (`append`) u jedan isečak može prepisati podatke vidljive drugom ako i dalje ima preostalog kapaciteta. Greške koje mogu nastati obuhvataju neočekivane izmene podataka, oštećene rezultate, zadržavanje velikih osnovnih nizova u memoriji putem malih podisečaka, kao i trke podataka (`data races`) kada se deljeni isečci koriste konkurentno. Da bi se izbeglo neželjeno deljenje, napravite defanzivno kopiranje pomoću funkcije `copy` ili `append([]T(nil), s...)`, ili ograničite kapacitet izrazom za puni isečak (`full-slice expression`) pre poziva `append`.

package main

import "fmt"

func main() {
	base := []int{1, 2, 3, 4}
	a := base[:2] // len 2, cap 4
	b := base[2:] // len 2, cap 2

	a[0] = 99
	fmt.Println(base, a, b)

	a = append(a, 77) // reuses base's backing array, overwrites base[2]
	fmt.Println(base, a, b)

	c := append([]int(nil), base[:2]...) // defensive copy
	c[0] = 42
	fmt.Println(base, c)
}
Pokušajte da odgovorite na ovo pitanje sa AI trenerom

8Objasnite rast slajsa (slice) tokom operacije append na konceptualnom nivou i uticaj uzastopne realokacije na performanse.

Kada append dodaje elemente u slajs, upisuje ih u postojeći osnovni niz ako slajs ima dovoljno kapaciteta. Ako je kapacitet nedovoljan, Go alocira veći osnovni niz, kopira postojeće elemente, upisuje nove elemente i vraća zaglavlje slajsa koje pokazuje na novu memoriju. Tačna strategija rasta zavisi od implementacije, ali konceptualno kapacitet raste dovoljno da ponovljeni pozivi append budu amortizovano efikasni. Uzastopne realokacije i dalje troše CPU (Central Processing Unit) za kopiranje, prave nove alokacije, povećavaju pritisak na GC (Garbage Collector) i mogu prekinuti deljenje memorije sa starim alijasima slajsa. Ako unapred znate očekivanu veličinu, uradite prealokaciju pomoću make([]T, 0, n) kada gradite slajs pozivima append ili make([]T, n) kada ga popunjavate po indeksu kako biste smanjili broj realokacija.

package main

func collectNoPrealloc(input []int) []int {
	var out []int
	for _, v := range input {
		out = append(out, v*2)
	}
	return out
}

func collectPrealloc(input []int) []int {
	out := make([]int, 0, len(input))
	for _, v := range input {
		out = append(out, v*2)
	}
	return out
}
Pokušajte da odgovorite na ovo pitanje sa AI trenerom

Paketi

9Šta su obrasci registracije tokom inicijalizacije u programskom jeziku Go i koji rizici prate sporedne efekte praznog uvoza (blank import) i globalne registre?

Obrazac registracije tokom inicijalizacije podrazumeva da paket registruje svoju implementaciju u deljeni registar unutar funkcije `init`. Prazan uvoz (blank import) kao što je `_ "example.com/driver"` često se koristi za uvoz paketa isključivo zbog njegovih sporednih efekata, što pokreće izvršavanje njegove `init` funkcije iako se ne referencira nijedno izvezeno ime. Ovo je uobičajeno za drajvere, kodeke, priključke, metriku ili tačke proširenja serijalizatora. Rizici uključuju skrivene zavisnosti i sporedne efekte pri pokretanju, globalno promenljivo stanje, dupliranu registraciju ili zavisnost od redosleda registracije, otežanu izolaciju testova i manje eksplicitno povezivanje zavisnosti. Ovaj pristup treba koristiti promišljeno, jasno ga dokumentovati i često ublažavati eksplicitnom registracijom, idempotentnim registrima bezbednim za konkurentnost ili registrima koji se mogu injektovati ili resetovati za potrebe testiranja.

// registry/registry.go
package registry

import "fmt"

type Factory func() string

var factories = map[string]Factory{}

func Register(name string, f Factory) {
    if _, exists := factories[name]; exists {
        panic("duplicate registration: " + name)
    }
    factories[name] = f
}

func New(name string) (string, error) {
    f, ok := factories[name]
    if !ok {
        return "", fmt.Errorf("unknown implementation %q", name)
    }
    return f(), nil
}

// jsondriver/jsondriver.go
package jsondriver

import "example.com/app/registry"

func init() {
    registry.Register("json", func() string { return "json impl" })
}

// main.go
package main

import (
    "fmt"

    "example.com/app/registry"
    _ "example.com/app/jsondriver" // imported only for init side effect
)

func main() {
    v, _ := registry.New("json")
    fmt.Println(v)
}
Pokušajte da odgovorite na ovo pitanje sa AI trenerom

Obrada grešaka

10Kako defer, panic i imenovane povratne vrednosti interaguju pri implementaciji čišćenja resursa koje može izmeniti vraćene greške?

Odložene funkcije (`defer`) izvršavaju se nakon što su povratne vrednosti dodeljene, ali pre nego što se funkcija vrati svom pozivaocu. Zbog toga, odloženi closure može čitati ili menjati imenovane povratne vrednosti, kao što je imenovana greška `err`. Ovo se uobičajeno koristi za dodavanje grešaka čišćenja iz operacija kao što su `Close`, `Commit` ili slično u grešku koja se vraća, idealno uz očuvanje primarne greške umesto njenog prepisivanja. Tokom odmotavanja usled panike (`panic`), odložene funkcije se i dalje izvršavaju; odložena funkcija može pozvati `recover` i postaviti imenovanu povratnu vrednost, ali to bi trebalo ograničiti na namerne granice za hvatanje panike. Treba paziti da se ne zaseni (shadow) imenovana povratna promenljiva kao što je `err`, jer tada `defer` može posmatrati ili izmeniti drugu promenljivu umesto one predviđene.

func writeFile(path string, data []byte) (err error) {
	f, err := os.Create(path)
	if err != nil {
		return err
	}

	defer func() {
		cerr := f.Close()
		if cerr == nil {
			return
		}
		if err != nil {
			err = errors.Join(err, fmt.Errorf("close %s: %w", path, cerr))
		} else {
			err = fmt.Errorf("close %s: %w", path, cerr)
		}
	}()

	_, err = f.Write(data)
	if err != nil {
		return fmt.Errorf("write %s: %w", path, err)
	}
	return nil
}
Pokušajte da odgovorite na ovo pitanje sa AI trenerom

11Kako funkcionišu errors.Is, errors.As i %w u Go lancima omotavanja grešaka?

`fmt.Errorf` sa `%w` kreira novu grešku koja omotava osnovnu grešku dodajući joj kontekst. Omotači izlažu ugnježdene greške kroz `Unwrap`, formirajući lanac ili stablo koje standardna biblioteka može da analizira. `errors.Is(err, target)` proverava da li `err` ili bilo šta što ona omotava odgovara ciljnoj grešci. `errors.As(err, &target)` proverava da li se `err` ili bilo šta što ona omotava može dodeliti ciljnom tipu i skladišti pronađenu vrednost u prosleđeni pokazivač.

package main

import (
	"errors"
	"fmt"
)

var ErrNotFound = errors.New("not found")

type QueryError struct {
	Query string
	Err   error
}

func (e *QueryError) Error() string { return e.Query + ": " + e.Err.Error() }
func (e *QueryError) Unwrap() error { return e.Err }

func load() error {
	return fmt.Errorf("load user: %w", &QueryError{Query: "select user", Err: ErrNotFound})
}

func main() {
	err := load()
	fmt.Println(errors.Is(err, ErrNotFound))

	var qe *QueryError
	fmt.Println(errors.As(err, &qe), qe.Query)
}
Pokušajte da odgovorite na ovo pitanje sa AI trenerom

12Šta su sentinel greške (sentinel errors) i koji su kompromisi u poređenju sa prilagođenim tipiziranim greškama ili bogatijim domenskim modelima grešaka?

Sentinel greška je imenovana vrednost greške, često promenljiva na nivou paketa kao što je `var ErrNotFound = errors.New("not found")`, koja se koristi za predstavljanje određenog stanja koje pozivaoci mogu proveriti, obično pomoću `errors.Is` kada je omogućeno omotavanje. Sentinel greške su jednostavne i korisne za opšte, stabilne kategorije, ali izvezene sentinel greške postaju deo javnog API-ja (Application Programming Interface) i mogu čvrsto povezati pozivaoce sa konkretnim vrednostima. Prilagođene tipizirane greške mogu sadržati struktuirana polja i mogu se identifikovati pomoću `errors.As`. Bogatiji domenski modeli grešaka klasifikuju neuspehe prema vrsti ili kodu i mogu uključivati bezbedne poruke ili metapodatke, što je korisno kada je pozivaocima potrebno stabilno ponašanje koje prevazilazi jednu fiksnu vrednost greške.

package users

import (
	"errors"
	"fmt"
)

var ErrNotFound = errors.New("user not found")

type DomainError struct {
	Kind   string
	UserID int64
	Err    error
}

func (e *DomainError) Error() string {
	return fmt.Sprintf("%s user %d: %v", e.Kind, e.UserID, e.Err)
}
func (e *DomainError) Unwrap() error { return e.Err }

func Lookup(id int64) error {
	return &DomainError{Kind: "not_found", UserID: id, Err: ErrNotFound}
}
Pokušajte da odgovorite na ovo pitanje sa AI trenerom

13Kako Go backend treba da mapira interne greške u korisne odgovore za klijente, a da istovremeno pruži dijagnostičke signale operaterima?

Go backend treba da prevodi interne greške na granici aplikativnog ili transportnog sloja u stabilne i za klijenta bezbedne kategorije i odgovore. Te kategorije treba da se mapiraju na odgovarajuće HTTP statusne kodove ili ekvivalentne transportne statuse, uz bezbedne poruke i mašinski čitljive kodove umesto neobrađenih internih grešaka. Operateri i dalje treba da dobijaju dijagnostičke informacije putem strukturiranih logova, distribuiranih tragova (traces), metrika, identifikatora korelacije/zahteva i očuvanih osnovnih uzroka grešaka. Logovanje je obično najbolje izvršiti jednom na granici koja poseduje kontekst zahteva, kako bi se izbegli i propušteni neuspesi i suvišni duplirani logovi.

type ErrorCode string

const (
	CodeNotFound ErrorCode = "not_found"
	CodeInvalid  ErrorCode = "invalid_argument"
	CodeInternal ErrorCode = "internal"
)

type AppError struct {
	Code    ErrorCode
	Message string // safe for clients
	Err     error  // internal cause
}

func (e *AppError) Error() string { return string(e.Code) + ": " + e.Err.Error() }
func (e *AppError) Unwrap() error { return e.Err }

func statusFor(code ErrorCode) int {
	switch code {
	case CodeNotFound:
		return 404
	case CodeInvalid:
		return 400
	default:
		return 500
	}
}

// Handler boundary idea:
// - call service
// - classify or convert error to AppError
// - log original error with request_id and safe request context
// - return {"code":"not_found","message":"user not found","request_id":"..."}
Pokušajte da odgovorite na ovo pitanje sa AI trenerom

14Kako pravilno implementirati prilagođene tipove grešaka u jeziku Go i kako errors.Join utiče na ispitivanje i obradu grešaka na putanji čišćenja resursa?

Prilagođeni tip greške u jeziku Go zadovoljava interfejs `error` tako što implementira `Error() string`. Takođe može sadržati strukturisana polja i implementirati `Unwrap() error` radi otkrivanja osnovnog uzroka. Izbor između pokazivačkog primaoca (pointer receiver) i vrednosnog primaoca (value receiver) je važan: pokazivački primalac znači da samo `*T` zadovoljava `error`, dok vrednosni primalac obično znači da i `T` i `*T` zadovoljavaju interfejs, što utiče na kopiranje i tip koji pozivaoci treba da koriste uz `errors.As`. Funkcija `errors.Join` spaja više grešaka u jednu; `errors.Is` i `errors.As` ispituju ugnježdene greške. Ovo je korisno kada i grešku primarne operacije i grešku pri čišćenju ili odloženom izvršavanju (`defer`) treba vratiti bez gubitka bilo koje od njih.

package main

import (
	"errors"
	"fmt"
)

var ErrWrite = errors.New("write failed")
var ErrClose = errors.New("close failed")

type OpError struct {
	Op  string
	Err error
}

func (e *OpError) Error() string { return e.Op + ": " + e.Err.Error() }
func (e *OpError) Unwrap() error { return e.Err }

func save() (err error) {
	defer func() {
		closeErr := ErrClose
		err = errors.Join(err, closeErr)
	}()
	return &OpError{Op: "write file", Err: ErrWrite}
}

func main() {
	err := save()
	fmt.Println(errors.Is(err, ErrWrite))
	fmt.Println(errors.Is(err, ErrClose))

	var op *OpError
	fmt.Println(errors.As(err, &op), op.Op)
}
Pokušajte da odgovorite na ovo pitanje sa AI trenerom

Konkurentnost

15Kako select funkcioniše sa kanalima, uključujući izbor spremnih grana, podrazumevane grane (default) i operacije koje se mogu otkazati?

`select` čeka na više operacija nad kanalima i izvršava onu granu čije slanje ili primanje može da se nastavi. Ako nijedna grana sa kanalom nije spremna, blokira osim ako ne postoji `default` grana; `default` se izvršava odmah samo kada nijedna operacija nad kanalima ne može da se nastavi, što je korisno za neblokirajuće pokušaje slanja ili primanja. Ako je više grana spremno, Go bira jednu pseudo-nasumično, a ne prema redosledu u izvornom kodu. Operacije nad kanalima koje se mogu otkazati obično dodaju granu koja prima sa `ctx.Done()`, kako bi gorutina mogla da prestane sa čekanjem kada se kontekst otkaže ili istekne vreme.

func send(ctx context.Context, ch chan<- string, v string) error {
    select {
    case ch <- v:
        return nil
    case <-ctx.Done():
        return ctx.Err()
    }
}
Pokušajte da odgovorite na ovo pitanje sa AI trenerom