JavaRush /Курсы /Go SELF /panic vs error — где panic допустим, где вреден

panic vs error — где panic допустим, где вреден

Go SELF
18 уровень , 2 лекция
Открыта

1. Два вида проблем: «ожидаемые» и «невозможные»

Если очень упростить, Go предлагает вам два разных способа сказать «что-то пошло не так», потому что не все “не так” одинаковые. Иногда проблема ожидаемая: пользователь ввёл ерунду, данных нет, делить на ноль нельзя, файл не открылся. А иногда проблема означает, что код нарушил собственные обещания и продолжать опасно.

Ожидаемая проблема — это часть нормальной жизни программы. Это не стыдно, это не «провал», это просто ветка сценария. В Go такая ветка почти всегда оформляется как (T, error) или просто error. Не получилось — вернули ошибку, вызывающий решает, что делать.

Невозможная проблема — это когда программа попала в состояние, которое по логике автора кода не должно было случиться вообще. В Go это обычно ведёт к panic: обычный поток выполнения останавливается, начинают выполняться defer, стек раскручивается вверх, и если никто не перехватил панику на границе, программа падает.

Тут полезная аналогия (одна, честно): error — это «контролируемая непогода», вы взяли зонтик; panic — это «в квартире внезапно потолок решил стать полом». Можно, конечно, продолжать пить чай, но обычно хочется сначала понять, почему вы теперь сидите на люстре.

2. error как часть контракта функции

В Go ошибки — это значения. Это означает, что «ошибка» — не исключение из реальности, а один из ожидаемых результатов функции. Типичный контракт выглядит так: «я попробую сделать X; если получится — верну результат; если нет — верну ошибку».

Отсюда и знаменитый стиль if err != nil { return ... }: он делает ошибочную ветку видимой прямо в коде, без магии.

Давайте начнём развивать маленькое консольное приложение, которое мы сможем усложнять короткими кусочками кода. Пусть это будет «мини‑калькулятор команд»: читаем команду и два числа, выполняем операцию. Здесь очень удобно показать, что деление на ноль — это ожидаемая проблема (пользователь мог ввести 0), значит это error, а не panic.

Сначала заведём ошибки‑константы (sentinel errors). Мы уже знакомы с тем, что ошибки можно сравнивать через errors.Is, если они завернуты, и это удобный контракт.

package main

import "errors"

var (
	ErrUnknownCommand = errors.New("unknown command")
	ErrDivByZero      = errors.New("division by zero")
)

Теперь напишем функцию вычисления. Обратите внимание: мы не «пытаемся угадать» ввод, мы явно проверяем условия и возвращаем error. Это выглядит скучно… но скучный код часто самый надёжный (и самый дешевый в поддержке — бухгалтерия аплодирует стоя).

package main

import "fmt"

func compute(cmd string, a, b int) (int, error) {
	switch cmd {
	case "add":
		return a + b, nil
	case "div":
		if b == 0 {
			return 0, ErrDivByZero
		}
		return a / b, nil
	default:
		return 0, fmt.Errorf("%w: %q", ErrUnknownCommand, cmd)
	}
}

Заметьте важную мысль: неизвестная команда — тоже ожидаемая проблема. Пользователь может написать "mul", "hello" или "pls-work". И это не «авария программы», это просто некорректный ввод. Поэтому — error.

Если вам внутренне хочется «поругаться погромче», поругайтесь сообщением ошибки. Но не panic.

3. panic как сигнал «что-то сломано в программе»

Слово panic звучит так, будто сейчас из монитора вылезет кот и уронит вам сборку. На практике panic — встроенный механизм Go, который останавливает обычный поток выполнения, запускает раскрутку стека и гарантированно выполняет defer по пути наверх. Это важно: даже при аварии у вас остаётся шанс освободить ресурс, дописать лог, снять блокировку.

Ключевое правило, которое экономит нервы: panic — не средство управления бизнес‑логикой и не замена error. Если ситуация предсказуема и может быть частью контракта функции — возвращайте error. Если ситуация означает ошибку программиста, нарушенный инвариант или «мы в таком состоянии не умеем жить дальше» — тогда panic может быть оправдан.

Покажу плохой пример, который иногда делают новички: «если деление на ноль — паникуем». Почему плохой? Потому что деление на ноль может быть обычным пользовательским вводом.

package main

func badDivide(a, b int) int {
	if b == 0 {
		panic("division by zero") // плохо: это ожидаемый сценарий
	}
	return a / b
}

А теперь пример, где panic более уместен: «мы проверили ввод, но всё равно получили невозможное состояние». Например, у нас есть функция, которая принимает только две команды ("add", "div"), потому что выше по коду уже была строгая валидация. И если сюда попало что-то третье — значит, кто-то изменил код так, что инвариант перестал быть правдой. Это уже похоже на баг.

package main

func computeValidated(cmd string, a, b int) int {
	switch cmd {
	case "add":
		return a + b
	case "div":
		return a / b // здесь предполагаем b != 0 (проверено раньше)
	default:
		panic("unreachable: command was validated earlier")
	}
}

Обратите внимание: это не «пользователь виноват», это «наш код сломан». В таких местах panic иногда честнее, чем молча возвращать странный error, который никогда не должен происходить по задумке.

4. Типовые runtime‑паники: слайс, nil map, деление на ноль

Когда люди впервые видят panic, они часто думают, что panic бывает только если вы сами вызвали panic(...). Но Go умеет паниковать и сам: это так называемые runtime‑паники — когда вы нарушаете правила выполнения (например, выходите за границы слайса).

Разберём три самые частые «грабли» на текущем уровне курса — вы уже знаете достаточно, чтобы в них врезаться на полной скорости.

Выход за границы слайса

Начинается обычно невинно: «там точно есть третий элемент». А потом входные данные оказались короче.

package main

import "fmt"

func main() {
	s := []int{10, 20}
	fmt.Println(s[2]) // panic: index out of range
}

Правильный путь здесь почти всегда не recover и не «авось повезёт», а нормальная проверка длины и возврат error, если длина не подходит под контракт.

package main

import (
	"errors"
	"fmt"
)

var ErrNeedAtLeast3 = errors.New("need at least 3 numbers")

func third(s []int) (int, error) {
	if len(s) < 3 {
		return 0, ErrNeedAtLeast3
	}
	return s[2], nil
}

func main() {
	_, err := third([]int{10, 20})
	fmt.Println(err) // need at least 3 numbers
}

Смысл: если недостаточно данных — это ожидаемо (ввод может быть разный), значит error.

Запись в nil map

nil-map — штука коварная: читать из неё можно, а писать нельзя. Новички часто запоминают это как «магия», но на практике достаточно помнить простое правило: если собираетесь писать — делайте make.

package main

func main() {
	var m map[string]int
	m["x"] = 1 // panic: assignment to entry in nil map
}

Это почти всегда ошибка программиста, потому что nil map обычно появляется из-за забывчивости инициализации. Внутри вашего кода это ближе к panic-ситуации (инвариант «карта инициализирована» нарушен). Но если карта пришла снаружи как параметр, лучше сделать API безопаснее: либо инициализировать внутри, либо вернуть понятный error.

package main

import "errors"

var ErrNilMap = errors.New("map must be initialized")

func addCount(m map[string]int, key string) error {
	if m == nil {
		return ErrNilMap
	}
	m[key]++
	return nil
}

Заметьте тонкость: один и тот же симптом (nil map) может быть «багом» или «ожидаемым вводом» — зависит от того, чей это контракт и кто отвечает за инициализацию.

Деление на ноль в целых

В целых числах a / 0 — это runtime‑паника. Но мы уже решили, что деление на ноль часто приходит от пользователя, значит мы не доводим до a / b, пока не проверим b.

package main

import "fmt"

func main() {
	fmt.Println(10 / 0) // panic: integer divide by zero
}

В нашем compute мы сделали проверку b == 0, и это как раз взрослый Go‑подход: не ловить панику, а не создавать её.

Шпаргалка выбора: вернуть error или паниковать

Когда вы в начале пути, мозг хочет простого правила типа «panic — зло, error — добро». Но реальность чуть сложнее: panic иногда полезен как «сигнал, что код сломан».

Чтобы не гадать каждый раз, удобно держать маленькую таблицу‑шпаргалку и сверяться с ней, пока интуиция не наработается.

Ситуация Это ожидаемо? Что делать Почему
Пользователь ввёл неправильные данные Да
return ..., error
Это нормальная ветка сценария
Не найдено (ключа нет, запись отсутствует) Да
return ..., error
или
(T, bool)
Не «авария», а результат поиска
Деление на ноль из ввода Да
return ..., error
Можно предсказать и описать
Выход за границы слайса из-за неверной логики Нет исправить код; в крайнем случае
panic
Это баг, не “ввод”
Запись в nil map, потому что забыли make Нет исправить код; иногда
panic
уместнее
Нарушен инвариант инициализации
«Невозможная ветка» после полной валидации Нет (по задумке)
panic("unreachable")
или пересмотреть дизайн
Лучше упасть, чем тихо жить в лжи

Обратите внимание на практичную традицию: даже если пакет использует panic внутри, внешний API обычно всё равно возвращает ошибки явно. Пользователю вашего кода не надо «жить в паниках».

5. Учебный пример: калькулятор команд add/div

Пора собрать всё в маленький пример: читаем ввод, вычисляем, печатаем. Здесь мы одновременно закрепим стиль read → compute → print и увидим, что большая часть устойчивости появляется не от “магических техник”, а от пары честных проверок.

Сначала напишем run() error, чтобы main был минимальным и не превращался в свалку логики. Это стиль, который в Go любят: main — граница приложения, а «работа» — в отдельной функции.

package main

import (
	"fmt"
)

func run() error {
	var cmd string
	var a, b int

	if _, err := fmt.Scan(&cmd, &a, &b); err != nil {
		return err
	}

	res, err := compute(cmd, a, b)
	if err != nil {
		return err
	}

	fmt.Println(res)
	return nil
}

func main() {
	if err := run(); err != nil {
		fmt.Println("error:", err)
	}
}

Здесь пока есть одна проблема: fmt.Scan при конце ввода вернёт ошибку, и мы напечатаем "error: EOF". Для учебного примера нормально, но в реальном CLI вы бы различали «конец ввода» и настоящую ошибку.

Сейчас нам важнее другое: мы нигде не используем panic как реакцию на «плохой ввод».

Теперь вспомним, что compute уже написан и возвращает error. Деление на ноль не вызывает панику, потому что мы проверяем заранее. И это ключевой стиль: если можно предотвратить runtime‑панику простой проверкой — предотвратите. Не ради красоты, а ради предсказуемости.

Если запустить и ввести:

div 10 0

то вы получите понятную ошибку, а не падение процесса.

Must‑стиль: когда можно, когда нельзя

Иногда в Go встречаются функции вида MustXxx: template.Must, regexp.MustCompile и так далее. Их идея простая: «если тут ошибка — дальше жить смысла нет».

Это не «крутой стиль для смелых», а вполне конкретный инструмент: вы фиксируете предположение, что в этом месте ошибка невозможна, и если она всё-таки случилась — значит, программа неправильно собрана.

Сделаем для примера mustCompute, но сразу договоримся: это не для пользовательского ввода, а для случаев, когда вы сами внутри кода задаёте команду и числа и считаете это корректным.

package main

import "fmt"

func mustCompute(cmd string, a, b int) int {
	res, err := compute(cmd, a, b)
	if err != nil {
		panic(fmt.Errorf("mustCompute failed: %w", err))
	}
	return res
}

Почему это может быть нормально? Потому что если cmd, a, b — не от пользователя, а от вашего же кода (например, заранее заданные параметры), то ошибка означает баг в логике программы или в её конфигурации. И в таком случае падение иногда честнее, чем попытка «продолжить как-нибудь».

Но если вы начнёте применять mustCompute к пользовательскому вводу, вы превратите любой неправильный ввод в аварийное падение. Пользователь напишет "div 10 0", и вместо «нельзя делить на ноль» получит краш. Это не строгость, это UX‑садизм.

Ещё одна тонкость: что класть в panic(...)? Технически можно что угодно, но часто кладут строку или error. Строка быстро превращается в «ой», а error можно оборачивать, добавляя контекст.

6. Типичные ошибки при выборе между panic и error

Ошибка №1: паниковать на любой плохой ввод.
Это самая частая путаница: человек видит, что panic «громче», и начинает использовать его как «усиленный error». В итоге программа падает там, где должна была просто сказать «не понимаю команду» или «делить на ноль нельзя». Если проблему можно ожидать и корректно объяснить — это error, а не авария.

Ошибка №2: возвращать error из места, которое на самом деле означает баг.
Обратная крайность — «panic зло, поэтому я всегда возвращаю error». Тогда вы ловите ошибку вроде errors.New("impossible happened"), печатаете её, продолжаете работу… и через 30 секунд ваши данные уже частично испорчены, потому что вы продолжили выполнение после нарушения инварианта. Если состояние действительно «невозможное», лучше остановиться, чем тихо делать вид, что всё под контролем.

Ошибка №3: надеяться на recover как на универсальный “try/catch” и поэтому не проверять условия.
Даже если вы знаете, что в Go существует recover, соблазн «ладно, если что — поймаю панику» приводит к ленивым функциям, которые не проверяют длину слайса, делитель, nil map. Это ломает предсказуемость. Гораздо дешевле и понятнее предотвратить панику простыми проверками на входе, чем потом разбираться, почему она произошла.

Ошибка №4: смешивать контракты без явного соглашения.
Когда часть функций возвращает (T, error), часть иногда паникует «по настроению», а вызывающий код не знает, чего ждать, программа становится похожа на детектив: вы не решаете задачу, вы ищете, где сейчас рванёт. Лучше держать единый внешний контракт (обычно через error), а если внутри где-то используется panic как механизм «аварийного выхода», это должно быть осознанно и очень локально.

Ошибка №5: слишком абстрактные сообщения в panic и в error.
И panic, и error — это коммуникация с будущим вами. Сообщение panic("error") или return errors.New("failed") почти не несёт информации. Хорошее сообщение должно отвечать на вопрос «что пытались сделать и почему это плохо». Даже если вы упадёте, вы хотя бы упадёте с подсказкой, а не с загадкой.

1
Задача
Go SELF, 18 уровень, 2 лекция
Недоступна
Деление без драмы
Деление без драмы
1
Задача
Go SELF, 18 уровень, 2 лекция
Недоступна
Командный калькулятор
Командный калькулятор
1
Задача
Go SELF, 18 уровень, 2 лекция
Недоступна
Третья попытка
Третья попытка
1
Задача
Go SELF, 18 уровень, 2 лекция
Недоступна
Валидированная ветка
Валидированная ветка
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ