MBL
Go / avito / Изучение / Базовый Go: типы, control flow, ошибки, defer/panic/recover
Go средний

Базовый Go: типы, control flow, ошибки, defer/panic/recover

fundamentalserror-handlingdefer

Это темы, которые спринт-подготовка пользователя не покрывала целиком (основной фокус был на конкурентности, 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 где-то выше по стеку вызовов бессмысленно, если это другая горутина.

на play.golang.org
Самопроверка 0 / 7
Помню, что Go не приводит типы неявно даже между int32 и int64
Могу назвать zero value для int, string, bool, слайса, мапы, указателя, интерфейса
Знаю, что несколько defer выполняются в порядке LIFO, а не в порядке объявления
Понимаю, что аргументы defer вычисляются в момент объявления, а не в момент выполнения
Знаю, что recover ловит панику только в той же горутине и только внутри defer
Могу объяснить, почему errors.Is надёжнее ==, если ошибка обёрнута через fmt.Errorf("%w")
Понимаю ограничение sentinel-ошибок — нельзя добавить контекст без потери сравнимости через ==
Как усвоено?