Базовый Go: типы, control flow, ошибки, defer/panic/recover
Это темы, которые спринт-подготовка пользователя не покрывала целиком (основной фокус был на конкурентности, stdlib, SQL и system design) — но именно они всплывают на живом кодировании как мелкие, но обидные ловушки, если написать функцию "на автомате", не задумываясь.
Типы и приведение типов
В Go нет неявного приведения типов — даже между числовыми типами одного семейства. var a int32 = 5; var b int64 = a не скомпилируется: нужно явно int64(a). Это осознанное отличие от C/Java, где int32 → int64 происходит без спроса: неявное расширение/сужение — источник трудноуловимых багов (незаметная потеря точности при сужении, неожиданный знаковый бит), и Go выбрал явность вместо удобства.
Zero values — у каждого типа своё нулевое значение при объявлении без инициализации, а не общий null/undefined: int/float64 → 0, string → "" (пустая строка, не nil), bool → false, указатель/slice/map/channel/func/interface → nil. Разница между nil-слайсом и пустым ([]int{}) важна практически: len() и append() работают одинаково на обоих, но nil-слайс сериализуется в JSON как null, а пустой — как [].
Value types vs reference-like типы. int, string, bool, struct, массив ([N]T) — копируются целиком при присваивании и передаче в функцию. slice, map, channel, func формально не называются в спецификации Go "reference types", но ведут себя похоже: сама переменная — это маленькая структура (для слайса — указатель на backing array + len + cap), и копия этой структуры продолжает указывать на общие данные.
Отсюда: передача слайса в функцию не копирует его элементы, но функция получает свою копию заголовка (len/cap/pointer) — append внутри функции, если он реаллоцирует, не изменит слайс у вызывающей стороны.
Управляющие конструкции
switch без выражения — идиоматичная замена длинной цепочки if/else if: switch { case x > 10: ...; case x > 5: ...; default: ... }. В отличие от C, каждый case в Go автоматически завершается break — проваливание в следующий case нужно запрашивать явно через fallthrough.
switch n := 2; n {
case 1:
fmt.Println("one")
case 2:
fmt.Println("two")
fallthrough
case 3:
fmt.Println("three (fell through from two)")
case 4:
fmt.Println("four")
}
// вывод:
// two
// three (fell through from two)fallthrough переходит на следующий case безусловно, не проверяя его условие — практически используется редко, но важно знать, что это единственный способ получить C-подобное поведение.
Type switch — способ определить конкретный тип значения, лежащего в interface{}/any:
func describe(x interface{}) string {
switch v := x.(type) {
case int:
return fmt.Sprintf("int: %d", v)
case string:
return fmt.Sprintf("string: %q", v)
default:
return fmt.Sprintf("other: %v (%T)", v, v)
}
}
// describe(42) -> "int: 42"
// describe("hi") -> "string: \"hi\""
// describe(3.14) -> "other: 3.14 (float64)"Внутри каждого case переменная v уже имеет конкретный тип этого case, а не interface{} — компилятор сужает тип автоматически.
Отдельная деталь про for-циклы, подробно разобранная в конспекте по конкурентности: начиная с Go 1.22 каждая итерация for i := range n / for i := 0; i < n; i++ получает свою собственную копию переменной цикла, а не одну общую на весь цикл — это меняет поведение горутин/замыканий, созданных внутри тела цикла, по сравнению с более старыми версиями Go.
Обработка ошибок — идиомы
Go не использует исключения — if err != nil после каждого вызова, который может завершиться неудачно, это не многословный антипаттерн, а осознанный дизайн: путь ошибки виден прямо в потоке управления, а не скрыт в невидимом стеке try/catch, который можно случайно не поймать.
errors.New vs fmt.Errorf("...: %w", err). errors.New("сообщение") создаёт новую независимую ошибку. fmt.Errorf("контекст: %w", err) оборачивает существующую ошибку, сохраняя доступ к оригиналу через цепочку Unwrap() — это и называется error wrapping.
errors.Is/errors.As вместо ==. Прямое сравнение err == ErrNotFound ломается, как только ошибка была обёрнута через %w — обёрнутая ошибка это уже другое значение, не равное оригиналу указателю/значению. errors.Is(err, ErrNotFound) разворачивает всю цепочку Unwrap() и сравнивает каждый уровень — работает независимо от того, сколько раз ошибка была обёрнута по дороге. errors.As(err, &target) делает то же самое, но для приведения к конкретному типу ошибки (не sentinel-значению), например чтобы достать *MyValidationError откуда-то из середины цепочки оборачивания.
Sentinel errors и их ограничение. var ErrNotFound = errors.New("not found") — простой и предсказуемый способ сигнализировать конкретный случай, который вызывающий код может проверить через errors.Is. Ограничение: sentinel-ошибка сама по себе не может нести дополнительный контекст (какой именно ID не найден) без потери прямой сравнимости — добавить контекст можно только оборачиванием (fmt.Errorf("user %d: %w", id, ErrNotFound)), а не мутацией самой sentinel-переменной. Кастомный тип ошибки (type MyError struct{...} с методом Error() string) решает обе задачи сразу: несёт произвольные поля с контекстом и всё ещё узнаваем через errors.As.
defer, panic, recover
Несколько defer выполняются в порядке LIFO (последний объявленный — первый выполненный), как стек, а не как очередь:
func deferOrder() {
for i := 0; i < 3; i++ {
defer fmt.Println("defer LIFO:", i)
}
fmt.Println("function body done")
}Реальный вывод:
function body done
defer LIFO: 2
defer LIFO: 1
defer LIFO: 0
Аргументы defer вычисляются в момент объявления defer, а не в момент фактического выполнения — частая ловушка, если ожидать значение "на момент выхода из функции":
func deferArgs() {
x := 1
defer fmt.Println("captured at defer time, x was:", x)
x = 100
fmt.Println("x is now:", x)
}Реальный вывод:
x is now: 100
captured at defer time, x was: 1
defer захватывает x как 1 в момент строки defer fmt.Println(...), хотя выполнится этот вызов только в конце функции, когда x уже 100. Если нужно видеть актуальное значение на момент выхода — оборачивают в defer func(){ fmt.Println(x) }() (замыкание без аргументов читает x по ссылке в момент вызова, а не копирует его как аргумент заранее).
recover работает только внутри defer и только в той же горутине, где произошла паника. Горутина без собственного recover, запаниковавшая где угодно, роняет всю программу целиком — recover в main или в любой другой горутине её не перехватит:
// recover в СВОЕЙ горутине — работает
func safeCall(wg *sync.WaitGroup) {
defer wg.Done()
defer func() {
if r := recover(); r != nil {
fmt.Println("recovered in this goroutine:", r)
}
}()
panic("boom in goroutine A")
}
// вывод: recovered in this goroutine: boom in goroutine A
// main continues normally after goroutine A recovered// recover в main НЕ ловит панику из ДРУГОЙ горутины без своего recover
defer func() {
if r := recover(); r != nil {
fmt.Println("main recovered:", r)
}
}()
go func() {
panic("boom in unrecovered goroutine")
}()Во втором случае программа падает целиком (panic: boom in unrecovered goroutine, exit status 2) — строка main recovered не печатается никогда, потому что defer в main видит только панику в собственной горутине. Практический вывод: любая горутина, запущенная "и забытая" (go func(){...}() без обработки паники внутри), должна сама себя защищать через defer recover() внутри своего собственного тела — надеяться на recover где-то выше по стеку вызовов бессмысленно, если это другая горутина.