1. Як відповідати на запитання про Go: модель → наслідки → виправлення
На співбесіді часто перевіряють не пам’ять і не швидкість друку, а вміння тримати в голові модель виконання коду. У Go це особливо помітно: мова навмисно не ховає деталей — помилки ми перевіряємо явно, конкурентність збираємо вручну, інтерфейси не магічні. Тому сильна відповідь зазвичай виглядає як коротка історія: «ось що зберігається у значенні», «ось чому це призводить до такої поведінки», «ось як зробити так, щоб було безпечно».
Уявімо типову ситуацію: вам показують шматок коду і питають: «що виведе?» або «чому падає?». Ваш найкращий друг — спокійне читання коду зліва направо й проговорювання інваріантів: що може бути nil, хто володіє буфером слайса, хто закриває канал, що саме лежить усередині інтерфейсу. Якщо ви навчитеся розкладати запитання на такі «цеглинки», половина співбесіди перетвориться на передбачувану прогулянку, а не на квест «вгадай, що мав на увазі автор».
Мініприклад «проговорюємо модель» (ми повертатимемося до нього впродовж усієї лекції):
package main
import "fmt"
func main() {
var s []int
fmt.Println(s == nil, len(s)) // true 0
}
Тут ви не «пам’ятаєте факт», а пояснюєте модель: var s []int дає nil-slice, len(nil) коректний і дорівнює нулю, а порівняння з nil — теж коректне.
2. Помилки в Go: чому це «значення» і як про це питають
Помилки — улюблена тема інтерв’юерів, бо вона одразу показує стиль мислення. У Go помилку не кидають — її повертають. І це не випадковість, а принцип: помилка — звичайне значення, яке можна порівнювати, обгортати, зберігати всередині структур і аналізувати програмно.
Запитання зазвичай крутяться навколо чотирьох речей: чому error — інтерфейс, чим відрізняється fmt.Errorf("%v", err) від fmt.Errorf("%w", err), коли використовувати sentinel errors, коли typed errors і як будувати повідомлення так, щоб користувачеві було зрозуміло, а розробникові — зручно для діагностики.
error як інтерфейс і «помилки — значення»
У Go тип error — це інтерфейс з одним методом Error() string. Сенс у тому, що помилка може бути не лише рядком, а й структурованими даними: наприклад, os.PathError зберігає операцію та шлях, net.OpError зберігає деталі мережевої операції, і все це можна аналізувати, а не парсити з рядка.
Мініприклад «помилка як структура» в стилі нашого застосунку задач, наприклад для валідації вводу:
package app
type ValidationError struct {
Field string
Msg string
}
func (e ValidationError) Error() string {
return e.Field + ": " + e.Msg
}
На співбесіді важливо сказати вголос: «рядок — для людини, поля — для коду». Тоді інтерв’юер зазвичай киває, бо саме так і працює підхід у Go.
Wrapping: %w і ланцюжок причин
Дуже часте запитання: «чим відрізняються %v і %w у fmt.Errorf?». Відповідь не в тому, що «там інша літера», а в тому, що %w зберігає початкову помилку як причину в ланцюжку, а отже потім можна перевірити її через errors.Is або витягнути тип через errors.As.
Ось мікроприклад, який схожий на наш шар зберігання задач, наприклад під час читання файла:
package storage
import "fmt"
func loadTasks(path string) error {
// припустімо, тут щось пішло не так
err := fmt.Errorf("permission denied")
return fmt.Errorf("load tasks from %s: %w", path, err)
}
Якби ми використали %v, то «причина» перетворилася б просто на текст, і код вище не зміг би надійно відрізнити проблему доступу від помилки «не знайдено».
Схематично ланцюжок виглядає так:
flowchart TD
A["верхній шар: «завантаження задач з X»"] -->|wrap %w| B["нижній шар: «відмовлено в доступі»"]
B --> C[ще глибше: помилки syscall/os/net]
І це не теорія заради теорії: це практична причина, чому Go-код так любить обгортати помилки, передаючи контекст угору.
errors.Is і errors.As: що і коли
errors.Is(err, target) відповідає на запитання: «чи є в ланцюжку причина такого класу?». errors.As(err, &target) відповідає на запитання: «чи можна витягнути з ланцюжка помилку конкретного типу й отримати доступ до її полів?». Це пряме продовження ідеї, що помилки — структуровані значення, а не текст.
Мініприклад у стилі «у нас є доменна помилка “не знайдено”»:
package domain
import "errors"
var ErrNotFound = errors.New("not found")
func isNotFound(err error) bool {
return errors.Is(err, ErrNotFound)
}
На співбесіді корисно окремо проговорити: порівнювати помилки за рядком — слабка модель, бо рядки змінюються, локалізуються і взагалі не є частиною контракту.
Sentinel errors і typed errors: як обирати
Інтерв’юер може спитати: «Коли робити var ErrX = errors.New("..."), а коли робити type XError struct {...}?». Хороша відповідь — залежно від потреби: якщо коду потрібен лише клас причини (наприклад, не знайдено), sentinel зазвичай підходить. Якщо коду потрібен контекст (яке поле невалідне, який id, який шлях), краще typed error.
Уявімо наш HTTP endpoint /api/v1/tasks/{id}. Для 404 нам достатньо лише «класу причини». Для 400 під час валідації нам потрібні поля, щоб заповнити fields в error envelope.
Короткий приклад typed validation-помилки «по-нашому»:
package domain
type ValidationError struct {
Fields map[string]string
}
func (e ValidationError) Error() string {
return "validation failed"
}
На співбесіді можна сказати: «текст помилки стабільний і короткий, а деталі в Fields використовують на межі (CLI/HTTP) для UX». Це звучить по-діловому і саме так, як зазвичай будують продакшн-застосунки.
3. Інтерфейси: method set і пастка interface != nil
Інтерфейси в Go оманливо прості: «набір методів». Але на співбесідах перевіряють деталі, бо саме в них найчастіше ховаються реальні баги. Зазвичай питають: як тип реалізує інтерфейс (спойлер: неявно), що таке method set і чому T та *T поводяться по-різному, і, нарешті, класика жанру — «чому err != nil, хоча всередині лежить nil?».
Якщо ви поясните це впевнено, ви миттєво виглядаєте людиною, яка писала продакшн-Go-код, а не просто читала конспект.
Де оголошувати інтерфейс
Для співбесіди корисно пам’ятати: хороший інтерфейс маленький і оголошується там, де його використовують. У нашому застосунку це виглядало як Storage в app-шарі: сервісу байдуже, файл там чи пам’ять; йому потрібен контракт.
Мініприклад (спрощено):
package app
import "context"
type Storage interface {
Get(ctx context.Context, id int) (Task, error)
}
Якщо інтерв’юер спитає «навіщо інтерфейс?», ви відповідаєте: «щоб відокремити ядро від адаптерів і тестувати без реальної файлової системи». Це відповідь рівня інженера, а не рівня «ну так прийнято».
Method set: чому *T «бачить» більше методів
Улюблене запитання: «чому var _ IFace = T{} не компілюється, а var _ IFace = &T{} компілюється?». Відповідь: method set. Методи з pointer receiver належать *T, а не T. Це особливо помітно, коли метод змінює стан або коли тип важкий і копіювати його дорого.
Пов’язаний реальний кейс: якщо ваш Storage має методи з pointer receiver, то його реалізує лише *FileStorage, але не FileStorage.
Мініприклад:
package main
type Saver interface{ Save() error }
type FileStore struct{}
func (fs *FileStore) Save() error { return nil }
func main() {
var _ Saver = &FileStore{} // ok
// var _ Saver = FileStore{} // не скомпілюється
}
Пастка interface != nil
Це класика. Інтерв’юер показує код, де var p *T = nil; return p як IFace, і питає, чому iface != nil.
Модель така: інтерфейсне значення — це пара (динамічний тип, динамічне значення). Якщо тип відомий (*User), то інтерфейс уже не nil, навіть якщо значення всередині — nil-вказівник.
Мініприклад (коротко й боляче):
package main
import (
"fmt"
)
type Namer interface{ Name() string }
type User struct{ N string }
func (u *User) Name() string { return u.N }
func main() {
var u *User = nil
var n Namer = u
fmt.Println(u == nil) // true
fmt.Println(n == nil) // false
}
В інтерв’ю-відповіді важливо додати практичний наслідок: «тому я намагаюся повертати з функцій саме nil як інтерфейсне значення, а не typed nil». Це показує, що ви не просто знаєте пастку, а й умієте її уникати.
4. Слайси: len/cap, append, aliasing і сюрпризи
Слайси — друга улюблена тема інтерв’юерів після помилок, бо за ними видно, чи розумієте ви пам’ять і володіння. Часто питають: чим відрізняється slice від array, що таке backing array, чому append може змінити початковий масив, що таке nil slice і empty slice, і як правильно видалити елемент.
Якщо ви відповідаєте впевнено, ви не лише проходите співбесіду, а й економите собі місяці налагодження в майбутньому.
Slice як «віконце» в масив: pointer + len + cap
Хороша ментальна модель: slice — це не масив, а маленька структура-заголовок (вказівник на буфер, довжина, місткість). Тому слайси легко копіюються як значення, але водночас можуть посилатися на один і той самий backing array.
Зручна схемка:
flowchart LR
S[slice header] --> P[ptr -> backing array]
S --> L[len]
S --> C[cap]
Саме через це копія слайса не означає копію даних.
nil і empty: схоже, але не однаково
На співбесіді інколи питають: «nil slice і []int{} — однакові?». У них однаковий len (0), але nil порівнюється з nil, а порожній літерал — ні.
Мініприклад:
package main
import "fmt"
func main() {
var a []int
b := []int{}
fmt.Println(a == nil) // true
fmt.Println(b == nil) // false
}
Не варто розводити філософію: у більшості прикладних місць ви перевіряєте len(s) == 0, а не s == nil. Але пам’ятати різницю корисно, особливо під час серіалізації JSON та в контрактах API.
append: чому важливо зберігати результат
Співбесіди люблять запитання: «чому append(s, x) потрібно присвоювати?». Бо append може виділити новий буфер, і тоді повернене значення — новий slice header, який треба зберегти.
Мініприклад:
package main
import "fmt"
func main() {
s := make([]int, 0, 1)
s = append(s, 1)
s2 := append(s, 2)
fmt.Println(s, s2) // [1] [1 2]
}
Це ще не найхитріше. Найхитріше починається, коли в нас є підслайс.
Aliasing: підслайси й неочікувані зміни
Класичний баг: беремо sub := s[:1], потім застосовуємо append до sub, і раптом змінюється s. Чому? Бо у sub cap може бути більшим за len, і append пише в той самий backing array.
Мініприклад:
package main
import "fmt"
func main() {
s := []int{10, 20, 30}
sub := s[:1] // len=1, cap=3
sub = append(sub, 99) // перезапише s[1]
fmt.Println(s) // [10 99 30]
}
На співбесіді хороший тон — згадати межу володіння: якщо ви хочете зробити підслайс незалежним, ви робите copy у новий буфер або використовуєте full slice expression s[a:b:c], обмежуючи cap. Це звучить як «я знаю, як виправляти», а не лише «я знаю, що боляче».
Видалення елементів і очищення хвоста
Видалення — улюблена практична задача: «видаліть елемент i». І тут важливо не лише «зсунути», а й пам’ятати про пам’ять. У нових версіях Go стандартні функції пакета slices (наприклад, slices.Delete) додатково очищають хвіст через clear, щоб не утримувати посилання й не створювати приховані витоки пам’яті.
Але інтерв’юер часто перевіряє ще більш базову річ: ви розумієте, що багато операцій над слайсом повертають новий слайс, і його потрібно зберігати.
Мініприклад «правильно зберігаємо результат»:
package main
import (
"fmt"
"slices"
)
func main() {
s := []string{"a", "b", "c"}
s = slices.Delete(s, 1, 2)
fmt.Println(s) // [a c]
}
Якщо ви скажете «ось чому я завжди присвоюю результат Delete/Compact назад», це буде рівно той «сигнал якості», який люблять чути.
5. Конкурентність: goroutine, канали й гонки даних
Конкурентність у Go — це місце, де інтерв’юер дивиться не лише на знання, а й на дисципліну. Бо «воно інколи працює» — це не успіх, а початок детективної історії. Запитання зазвичай такі: чим конкурентність відрізняється від паралелізму, як працює WaitGroup, навіщо канали, що таке deadlock, що таке data race і як скасовувати роботу через context.
У хорошій відповіді ви показуєте, що для вас конкурентність — це протокол завершення і володіння ресурсами, а не «давайте розпаралелимо все підряд, буде швидко, мабуть».
Go-підхід: «ділитися пам’яттю через комунікацію»
У Go є сильна філософія: замість того щоб усім goroutine давати доступ до однієї структури й захищати її замками, можна будувати потік даних через канали. Це не завжди обов’язково, але як ідея дуже характерно для Go.
Якщо інтерв’юер спитає «канали чи mutex?», добра відповідь буде не релігійною («канали кращі!»), а прикладною: «канали зручні для передавання задач і результатів, mutex — для захисту спільного стану; головне, щоб протокол мав кінець».
WaitGroup: Add/Done/Wait і типові пастки
WaitGroup часто питають на рівні «чому інколи падає або висить». Найтиповіша помилка — робити Add(1) усередині горутини: тоді main може встигнути дійти до Wait() раніше, ніж Add, і ви отримаєте гонку в протоколі очікування.
Мініприклад «як треба»:
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println("worker done") // worker done
}()
wg.Wait()
}
Якщо ви проговорюєте правило «Add до запуску goroutine, Done обов’язково, краще через defer», це зазвичай зараховується як «розуміє».
Deadlock: «нікому прийняти»
Найпростіше запитання: «чому це зависне?». Бо unbuffered канал потребує одночасного отримувача.
package main
func main() {
ch := make(chan int)
ch <- 1 // deadlock: ніхто не читає
}
На співбесіді добре додати: «буферизований канал знімає частину блокувань, але не скасовує потреби в протоколі закриття та зупинки».
Data race: чому «ніби працює» — червоний прапорець
Data race — це коли дві горутини звертаються до одних і тих самих даних, і хоча б один доступ — запис, без синхронізації. І важливий момент: це не «інколи баг», а дефект моделі. Якщо спитали про go test -race, ви пояснюєте, що це інструмент, який допомагає знаходити гонки, але краще проєктувати так, щоб стан був захищений явно.
Мініприклад «поганий» (у проді так не запускаємо, але для співбесіди годиться):
package main
import "fmt"
func main() {
x := 0
go func() { x++ }()
go func() { x++ }()
fmt.Println(x) // nondeterministic
}
Правильне виправлення залежить від задачі: mutex, канал, атомік. Але на рівні співбесіди достатньо сказати: «так не можна, треба синхронізувати або не ділити стан».
Прив’язка до застосунку: паралельний імпорт задач
Уявімо, що наш таск-трекер уміє імпортувати задачі з кількох файлів. Інтерв’юер може спитати: «як би ви прискорили імпорт?». Добра відповідь: «я б зробив bounded concurrency, щоб не витратити забагато пам’яті, і протокол скасування на першу помилку».
Крихітний начерк «воркери читають завдання з каналу»:
package main
import "sync"
func worker(jobs <-chan string, wg *sync.WaitGroup) {
defer wg.Done()
for range jobs {
// читаємо файл, парсимо, зберігаємо
}
}
Навіть такий уривок, якщо ви проговорите навколо нього «хто закриває канал» (producer), «як зупиняємося» (context/cancel), «як збираємо помилки» (канал помилок або errors.Join), уже демонструє зрілість.
Комбо-запитання: коли теми стикаються
Інтерв’юери люблять ситуації, де стикаються дві моделі одразу: інтерфейс + nil, слайс + aliasing, помилки + інтерфейси, конкурентність + map. Тут перевіряють не широту знань, а те, чи ви не губитеся, коли світ трохи складніший за один абзац.
Один класичний приклад — typed error усередині інтерфейсу error і витягування через errors.As. Суть: error — інтерфейс, отже ви можете класти туди різні типи помилок, а потім діставати потрібну структуру. Це рівно те, про що говорять матеріали про помилки як значення: ми можемо «конструювати й деконструювати» помилку звичайними засобами мови.
Інший приклад — «map + конкурентність». Навіть якщо ви не пам’ятаєте точного формулювання з рантайму, важливо сказати: «звичайний map не можна безпечно читати й писати конкурентно без синхронізації». Після цього можна запропонувати захист sync.Mutex або зробити володіння через одну горутину й канал команд. Це якраз лягає на ідею «share memory by communicating».
Третій приклад — «слайс і конкурентність»: якщо ви в кількох горутинах робите append в один і той самий слайс без синхронізації, це подвійна проблема. По-перше, data race по заголовку слайса (len/cap), по-друге, можлива релокація backing array. На співбесіді достатньо сказати: «так не можна; або захищаємо mutex, або кожен воркер пише у свій буфер, а потім об’єднуємо».
6. Типові помилки на співбесіді з Go
Помилка № 1: порівнювати помилки за рядком і сподіватися, що «все одно однаково».
Рядки не є надійним контрактом: вони можуть змінюватися, доповнюватися контекстом, локалізуватися. У Go помилка — значення, і правильні інструменти — errors.Is для перевірки причини та errors.As для витягування типізованого контексту.
Помилка № 2: «обгортати» помилку через %v і втрачати причину.
Якщо ви робите fmt.Errorf("...: %v", err), ви отримуєте гарний текст, але ламаєте можливість програмно визначити причину. %w зберігає ланцюжок, і це основа нормальної діагностики в Go-застосунках.
Помилка № 3: не розуміти method set і дивуватися, чому тип «не реалізує інтерфейс».
Часто проблема не в інтерфейсі й не в імпорті, а в тому, що метод оголошено з pointer receiver. Тоді інтерфейс реалізує *T, але не T. Це особливо легко зловити в реальних проєктах (storage, сервіси) й корисно проговорювати як частину відповіді.
Помилка № 4: потрапляти в пастку interface != nil і повертати typed nil.
Якщо функція повертає інтерфейс, і ви повертаєте (*MyType)(nil) як інтерфейсне значення, інтерфейс стане не-nil через динамічний тип. Це джерело дуже неприємних багів («err != nil, але помилки ніби немає»). На співбесіді цінується вміння не лише пояснити, а й сказати, як ви цього уникаєте в коді.
Помилка № 5: вважати, що append «просто додає» і не думати про володіння буфером.
append може перерозподіляти пам’ять і змінювати backing array, а підслайси можуть ділити один буфер. Через це виникає aliasing і «неочікувані зміни» в іншому місці програми. Уміння пояснити це — майже обов’язковий мінімум для Go-розробника.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ