Подготовка за Middle Go

Въпроси за интервю за Middle Go бекенд разработчици

15 подбрани въпроса за интервю по Go за Middle бекенд разработчици, които трябва да обяснят практически компромиси, конкурентност и поведение на услугите.

Започнете интервю с ИИ за Middle GoНе се изисква кредитна карта. Налична е 1 безплатна сесия.
Практика за техническо интервю на английски езикРежим, в който хора, за които английският не е роден език, могат да се упражняват за преминаване на технически интервюта.

Типова система

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` сам по себе си.

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)
}
Опитайте да отговорите на този въпрос с треньор с ИИ

2Какво представляват сравнимите типове в Go и как правилата за сравнимост влияят на ключовете в `map`, равенството и ограниченията при генериците?

Сравнимите типове в Go са типове, чиито стойности могат да бъдат сравнявани с `==` и `!=`. Базовите типове, указателите, каналите, интерфейсите и структурите или масивите, чиито полета или елементи са сравними, също са сравними; `slice`, `map` и функциите не са сравними, освен ако не се сравняват с `nil`. Ключовете в даден `map` трябва да бъдат сравними. Равенството следва правилата за сравнение на съответния тип, а сравнението на интерфейси зависи от техните динамични конкретни стойности; ако сравняван интерфейс съдържа несравнима динамична стойност, сравнението предизвиква паника. При генериците предварително декларираното ограничение `comparable` позволява параметрите на типа да бъдат сравнявани с `==`/`!=` и да се използват като ключове в `map`.

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
}
Опитайте да отговорите на този въпрос с треньор с ИИ

3Как Go представя байтове, руни и UTF-8 кодиран текст, и защо `len(s)` може да се различава от броя на видимите за потребителя символи?

В Go типът `byte` е псевдоним за `uint8` и представя един суров байт, докато `rune` е псевдоним за `int32` и представя Unicode кодова точка. Типът `string` е последователност от байтове само за четене, обикновено UTF-8 кодиран текст, но може да съдържа произволни байтове. Извикването на `len(s)` връща броя на байтовете, а не на руните или на видимите за потребителя символи. Индексирането на низ връща байт; итерирането по низ с `range` декодира UTF-8 и дава байтовите индекси заедно с руните. Стойността от `len(s)` може да се различава от броя на видимите символи, тъй като UTF-8 може да използва множество байтове за кодова точка и защото един видим за потребителя символ може да бъде съставен от множество кодови точки, като например комбиниращи знаци или последователности от емоджита.

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)
    }
}
Опитайте да отговорите на този въпрос с треньор с ИИ

4Как се различават псевдонимите на типове от дефинираните типове и кога бихте използвали всеки от тях?

Дефинираният тип, като например `type UserID int64`, създава нов отделен тип с `int64` като негов базов тип. Той не може да бъде свободно присвояван на `int64` без преобразуване и може да има свои собствени методи. Псевдонимът на тип, като например `type UserID = int64`, е просто друго име за същия тип, така че идентичността на типа и възможността за присвояване се запазват. Използвайте дефинирани типове за моделиране на предметната област, безопасност на типовете и добавяне на методи; използвайте псевдоними главно за рефакториране, миграция или съвместимост, без да въвеждате нов тип.

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)
}
Опитайте да отговорите на този въпрос с треньор с ИИ

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 не е равно на нищо, дори на себе си.

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
Опитайте да отговорите на този въпрос с треньор с ИИ

6Опишете влагането на структури в Go и как се държат издигнатите полета и методи (promoted fields and methods).

Влагането на структури в Go означава деклариране на поле само чрез неговия тип, без изрично име, например `type User struct { Person }`. Вложената стойност продължава да бъде реално поле, достъпно като `u.Person`, но нейните експортирани и достъпни полета и методи могат да бъдат издигнати. Това позволява на извикващия код да използва селектори като `u.Name` или `u.Greet()` като съкратен запис, вместо да преминава през вложеното поле. Влагането представлява композиция, а не класическо наследяване: външният тип не става автоматично подтип на вложения тип. Ако възникне конфликт между издигнати селектори, Go не се опитва да гадае; двусмислените имена трябва да бъдат изрично квалифицирани (указани напълно), в противен случай те не могат да бъдат достъпени директно през външната стойност.

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
}
Опитайте да отговорите на този въпрос с треньор с ИИ

Структури от данни

7Опишете как промяната на границите и присвояването на `slice` могат да доведат до споделяне на един и същ базов масив от няколко `slice` стойности, и какви програмни грешки може да създаде това.

Стойността на един `slice` представлява заглавна структура, сочеща към базов масив. Присвояването на `slice` или подаването му към функция копира само тази заглавна структура, а не самите елементи. Промяната на границите създава друга заглавна структура, сочеща към диапазон от същия базов масив. Следователно множество `slice` стойности могат да сочат към една и съща памет: промяната на елемент чрез един `slice` може да бъде видима през друг, а добавянето с `append` към един `slice` може да презапише данни, видими за друг, ако все още има свободен капацитет. Грешките включват неочаквани промени, повредени резултати, задържане в паметта на големи базови масиви чрез малки под-`slice` стойности и състезания за данни (data races), когато споделените препратки се използват конкурентно. За да избегнете нежелано споделяне, направете защитно копие с функцията `copy` или `append([]T(nil), s...)`, или ограничете капацитета с израз с три индекса (full-slice expression) преди използването на `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)
}
Опитайте да отговорите на този въпрос с треньор с ИИ

8Обяснете концептуално нарастването на `slice` при използване на `append` и последиците за производителността от многократно презаделяне на памет.

Когато `append` добавя елементи към `slice`, той записва в съществуващия основен масив, ако `slice` има достатъчен капацитет. Ако капацитетът е недостатъчен, Go заделя по-голям основен масив, копира съществуващите елементи, записва новите елементи и връща нова заглавна структура на `slice`, сочеща към новата памет. Точната политика на нарастване зависи от реализацията, но концептуално капацитетът нараства достатъчно, за да направи многократните извиквания на `append` амортизирано ефективни. Въпреки това, многократните презаделяния на памет костват процесорно време за копиране, създават нови заделяния на памет, увеличават натоварването на GC (Garbage Collector) и могат да нарушат споделянето на данни със стари препратки към същия `slice`. Ако знаете очаквания размер, предварително заделете памет с `make([]T, 0, n)`, когато изграждате с `append`, или `make([]T, n)`, когато попълвате по индекс, за да намалите презаделянията.

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
}
Опитайте да отговорите на този въпрос с треньор с ИИ

Пакети

9Какво представляват шаблоните за регистрация по време на инициализация в Go и какви рискове крият страничните ефекти от празните импорти (blank imports) и глобалните регистри?

Шаблонът за регистрация по време на инициализация е този, при който даден пакет регистрира имплементация в споделен регистър чрез функцията `init`. Празен импорт (blank import), като например `_ "example.com/driver"`, често се използва за импортиране на пакет само заради неговите странични ефекти, което предизвиква изпълнението на функцията му `init`, въпреки че не се реферират никакви експортирани имена. Това е често срещана практика за точки на разширение като драйвери, кодеци, плъгини, метрики или сериализатори. Рисковете включват скрити зависимости и странични ефекти при стартиране, глобално изменяемо състояние, дублирана или чувствителна към реда регистрация, по-трудна изолация на тестовете и по-малко явно навързване на зависимостите. Този подход трябва да се използва обмислено, да бъде ясно документиран и често се смекчава чрез изрична регистрация, идемпотентни и безопасни при паралелност регистри, или инжектируеми и нулируеми регистри за целите на тестовете.

// 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)
}
Опитайте да отговорите на този въпрос с треньор с ИИ

Обработка на грешки

10Как си взаимодействат `defer`, `panic` и именуваните върнати стойности при имплементиране на изчистване, което може да промени върнатите грешки?

Отложените чрез `defer` функции се изпълняват, след като са присвоени върнатите стойности, но преди функцията да се върне към извикващия код. Поради това, една отложена анонимна функция може да чете или променя именувани върнати стойности, като например именувана грешка `err`. Това често се използва за добавяне на грешки при изчистване от операции като `Close`, `Commit` или подобни към връщаната грешка, като в идеалния случай първоначалната грешка се запазва, вместо да се презаписва. По време на разгъване на стека при `panic`, отложените функции все пак се изпълняват; отложената функция може да възстанови изпълнението (чрез `recover`) и да зададе именувана върната стойност, но това трябва да се ограничава до умишлените граници за прихващане на паника. Внимавайте да не припокриете (shadow) именувана върната променлива като `err`, защото тогава отложената функция може да наблюдава или променя различна променлива от предвидената.

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
}
Опитайте да отговорите на този въпрос с треньор с ИИ

11Как работят `errors.Is`, `errors.As` и `%w` във веригите от обвити грешки в Go?

`fmt.Errorf` с `%w` създава нова грешка, която обвива базова грешка, като същевременно добавя контекст. Обвиващите грешки предоставят достъп до базовите грешки чрез метода `Unwrap`, образувайки верига или дърво, което стандартната библиотека може да инспектира. `errors.Is(err, target)` проверява дали `err` или някоя от грешките, които тя обвива, съвпада с целевата грешка. `errors.As(err, &target)` проверява дали `err` или някоя от грешките, които тя обвива, може да бъде присвоена на целевия тип, и съхранява съвпадащата стойност в предоставения указател.

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)
}
Опитайте да отговорите на този въпрос с треньор с ИИ

12Какво представляват `sentinel` грешките и какви са компромисите при тях в сравнение с потребителски типизирани грешки или по-богати домейнови модели за грешки?

`sentinel` грешката е именувана стойност за грешка, често променлива на ниво пакет, като `var ErrNotFound = errors.New("not found")`, която се използва за представяне на специфично състояние, което извикващият код може да провери, обикновено чрез `errors.Is`, когато е възможно обвиване. Тези грешки са прости и полезни за широки, стабилни категории, но експортираните `sentinel` стойности стават част от публичния API и могат да обвържат извикващия код с конкретни стойности. Потребителските типизирани грешки могат да носят структурирани полета и да бъдат намерени чрез `errors.As`. По-богатите домейнови модели за грешки класифицират неуспехите по вид или код и могат да включват безопасни съобщения или метаданни, което е полезно, когато извикващите системи се нуждаят от стабилно поведение отвъд една фиксирана стойност на грешка.

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}
}
Опитайте да отговорите на този въпрос с треньор с ИИ

13Как един бекенд на Go трябва да преобразува вътрешните грешки към полезни отговори за клиента, като същевременно предоставя на операторите данни за диагностика?

Един бекенд на Go трябва да преобразува вътрешните грешки на границата на приложението или транспортния слой в стабилни и безопасни за клиента категории и отговори. Тези категории трябва да съответстват на подходящи HTTP кодове на състоянието или еквивалентни транспортни състояния, съдържащи безопасни съобщения и машинночетими кодове, вместо необработени вътрешни грешки. Операторите на системата все пак трябва да получават диагностична информация чрез структурирани логове, проследявания, метрики, идентификатори за корелация/заявка и запазване на първопричините. Логването обикновено е най-добре да се извършва еднократно на граница, която разполага с контекста на заявката, за да се избегнат както пропуснати грешки, така и излишен шум от дублирани логове.

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":"..."}
Опитайте да отговорите на този въпрос с треньор с ИИ

14Как се реализират правилно персонализирани типове за грешки в Go и как `errors.Join` влияе върху проверката и обработката на грешки при почистващи операции?

Персонализираният тип за грешка в Go удовлетворява интерфейса `error`, като реализира метода `Error() string`. Той може също така да съдържа структурирани полета и да реализира метода `Unwrap() error`, за да разкрие първоначалната причина за грешката. Изборът между указател и стойност като получател (receiver) има значение: получател-указател означава, че само `*T` удовлетворява `error`, докато получател-стойност обикновено означава, че и `T`, и `*T` го правят, което влияе върху копирането и типа, който извикващите функции трябва да използват с `errors.As`. Функцията `errors.Join` обединява няколко грешки в една; `errors.Is` и `errors.As` могат да проверяват обединените дъщерни грешки. Това е полезно, когато трябва да бъдат върнати както грешката от основната операция, така и грешка от почистваща или отложена операция, без да се губи нито една от двете.

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)
}
Опитайте да отговорите на този въпрос с треньор с ИИ

Конкурентност

15Как работи `select` с канали, включително избора при няколко готови операции, блока по подразбиране (`default`) и отменяемите операции?

`select` изчаква множество операции с канали и изпълнява един `case`, при който изпращането или получаването може да продължи. Ако нито една операция с канал не е готова, `select` блокира, освен ако няма `default` блок; `default` се изпълнява незабавно само когато нито една операция с канал не може да продължи, което е полезно за неблокиращи опити за изпращане или получаване. Ако няколко случая са готови, Go избира един от тях псевдослучайно, а не според реда им в изходния код. Операциите с канали, които могат да бъдат отменени, обикновено добавят `case`, който получава от `ctx.Done()`, така че съответната `goroutine` да спре да чака, когато контекстът бъде отменен или изтече времето му.

func send(ctx context.Context, ch chan<- string, v string) error {
    select {
    case ch <- v:
        return nil
    case <-ctx.Done():
        return ctx.Err()
    }
}
Опитайте да отговорите на този въпрос с треньор с ИИ