1. Ошибки в Go: значения и интерфейс error
Если вы приходите в Go после языков, где есть исключения (exceptions), первый культурный шок обычно выглядит так: «Почему мне нужно писать проверку ошибки после каждого второго вызова?». Это чувство нормально: мозг привык, что ошибка — это что-то сверхъестественное, а в Go она максимально приземлённая — буквально переменная, которую можно присваивать, возвращать из функций и печатать.
Один из ключевых лозунгов Go‑мира звучит так: errors are values — «ошибки являются значениями». Именно поэтому Go поощряет явную проверку err, а не «вылет» куда-то вверх по стеку, где потом кто-то где-то попытается «поймать» исключение. Такой стиль делает поток управления более предсказуемым: видно, где программа может “споткнуться”, и видно, что именно произойдёт в этом месте.
Практически это означает простую вещь: если функция может не справиться (например, парсит число, читает ввод, открывает файл), она обычно возвращает второй результат типа error. И дальше вы решаете, что делать: завершить программу, попросить пользователя ввести заново, выбрать значение по умолчанию, пропустить запись и продолжить. Но решение принимает код, а не «система исключений».
Тип error: что это такое на самом деле
Сейчас будет важный момент: error — это тип, причём не просто тип, а интерфейсный тип. В стандартной библиотеке Go он определён примерно так:
type error interface {
Error() string
}
Если вы ещё не изучали интерфейсы «системно» — всё равно не страшно. Для сегодняшней лекции достаточно понимать интерфейс очень прагматично: интерфейс — это договор “у значения есть такие-то методы”. Здесь договор минимальный: если у значения есть метод Error(), возвращающий string, то такое значение можно использовать как error.
И вот тут начинаются хорошие новости: это объясняет сразу несколько странностей, которые новичка обычно пугают.
- Во-первых, error — не строка. Он может давать строку через Error(), но сам по себе он не string.
- Во-вторых, ошибки бывают разных “внутренних” (конкретных) типов: strconv.Atoi возвращает один конкретный тип ошибки, fmt.Scan может вернуть другой, и так далее. Но снаружи мы держим их в переменной типа error, потому что нас устраивает общий договор: “можно получить текст”.
- В-третьих, именно поэтому fmt умеет красиво печатать ошибки: он знает, что у ошибки есть метод Error(), и может его вызвать.
Чтобы увидеть идею “интерфейс = договор методов” на пальцах, вот крошечная демонстрация:
package main
import "fmt"
type myError struct{}
func (myError) Error() string { // реализуем договор error
return "something went wrong"
}
func main() {
var err error = myError{}
fmt.Println(err) // something went wrong
}
Здесь ноль магии: у myError есть метод Error(), значит его можно положить в переменную типа error.
nil как признак успеха: zero value для error
Теперь про самое прикладное правило Go‑кода: nil в error означает успех. А не‑nil означает, что произошла ошибка. Это не “особый оператор”, а договорённость, на которой держится весь стиль Go.
Важно, что zero value (значение по умолчанию) для переменной типа error — это как раз nil. То есть если вы объявили var err error, то “по умолчанию” у вас “ошибки нет”.
package main
import "fmt"
func main() {
var err error // zero value для error — nil
fmt.Println(err == nil) // true
fmt.Println(err) // <nil>
}
Обратите внимание на вторую печать: fmt.Println(err) выводит <nil>. Это не строка “nil”, а специальный формат вывода nil‑значения.
Теперь свяжем это с тем, что мы видели раньше в strconv.Atoi. Если парсинг удался, err будет nil. Если не удался — err будет не nil, и внутри будет конкретный объект ошибки.
package main
import (
"fmt"
"strconv"
)
func main() {
n, err := strconv.Atoi("42")
fmt.Println(n) // 42
fmt.Println(err == nil) // true
}
А теперь — провокация:
package main
import (
"fmt"
"strconv"
)
func main() {
n, err := strconv.Atoi("4x")
fmt.Println(n) // 0
fmt.Println(err) // strconv.Atoi: parsing "4x": invalid syntax
}
Здесь видно сразу два факта.
- Первый: при ошибке n стал равен 0 — это zero value для int.
- Второй: ошибка содержит текст, который объясняет, что случилось.
И это подводит нас к дисциплине: если err != nil, результат (n) нельзя считать валидным, даже если он выглядит «похожим на правду». Сегодня может быть 0, завтра (в других функциях) может быть пустая строка, nil‑слайс и так далее.
Как fmt печатает ошибки
Очень частый вопрос новичка: «Почему fmt.Println(err) печатает нормальный текст, хотя err — это не строка?». Правильный ответ: fmt печатает ошибки, вызывая метод Error() у значения ошибки.
То есть когда вы пишете fmt.Println(err), это концептуально похоже на “распечатай err.Error()”, только fmt ещё умеет аккуратно обработать случай err == nil.
package main
import (
"fmt"
"strconv"
)
func main() {
_, err := strconv.Atoi("x")
fmt.Printf("as %%v: %v\n", err) // as %v: strconv.Atoi: parsing "x": invalid syntax
fmt.Println("as Println:", err) // as Println: strconv.Atoi: parsing "x": invalid syntax
}
Оба варианта используют одну и ту же идею: fmt видит значение типа error и понимает, как превратить его в текст.
Ещё один полезный приём для диагностики: печать типа через %T. Это особенно полезно, когда вы хотите понять, какая именно ошибка внутри интерфейса error:
package main
import (
"fmt"
"strconv"
)
func main() {
_, err := strconv.Atoi("x")
fmt.Printf("type: %T\n", err) // type: *strconv.NumError
}
Вывод *strconv.NumError — это подсказка: “внутри error лежит конкретная структура ошибки из пакета strconv”. Полезно помнить, что error — это оболочка (интерфейс), а внутри может быть что угодно, лишь бы был метод Error().
err.Error() и почему мы не анализируем текст ошибки
Когда вы видите err.Error(), это не “магия”, а обычный вызов метода через точку: “у объекта err вызови метод Error”. Строго по договору error этот метод возвращает string.
Иногда вам действительно нужна именно строка, например, чтобы склеить её с каким‑то своим сообщением или вывести пользователю только текст. Тогда err.Error() уместен.
package main
import (
"fmt"
"strconv"
)
func main() {
_, err := strconv.Atoi("x")
if err != nil {
fmt.Println("message:", err.Error()) // message: strconv.Atoi: parsing "x": invalid syntax
}
}
Но здесь есть важная дисциплина: мы не принимаем решения, анализируя текст ошибки. То есть плохая идея — делать так:
// пример того, как делать НЕ надо (не копируйте)
if err != nil && strings.Contains(err.Error(), "invalid syntax") {
...
}
Почему плохо? Потому что текст ошибки — это сообщение для человека (лог/вывод), а не стабильный API для программы. Сегодня оно одно, завтра другое.
Правильная база сегодняшнего дня простая: решение “успех/ошибка” мы делаем по err == nil или err != nil, а не по строкам.
strconv.Atoi как фабрика реальных error
Слово “интерфейс” иногда звучит слишком абстрактно, поэтому полезно превратить его в самый знакомый нам источник ошибок — strconv.Atoi. Давайте посмотрим на два запуска: “успех” и “ошибка”, и в обоих случаях напечатаем и тип, и значение err.
package main
import (
"fmt"
"strconv"
)
func main() {
_, err1 := strconv.Atoi("10")
fmt.Printf("err1: %v (%T)\n", err1, err1) // err1: <nil> (<nil>)
_, err2 := strconv.Atoi("1O") // буква O вместо нуля
fmt.Printf("err2: %v (%T)\n", err2, err2) // err2: ... (*strconv.NumError)
}
Обратите внимание: у err1 тип печатается как <nil>. Это нормально: интерфейсная переменная error не содержит конкретного значения, и поэтому %T показывает <nil>.
У err2 появляется конкретный тип *strconv.NumError. Это и есть “конкретная ошибка”, спрятанная за интерфейсом.
И вот почему в Go так удобно жить: вам не нужно в каждом месте знать, что там *strconv.NumError. Вам достаточно знать: если err != nil, операция не удалась, и ошибку можно напечатать или вернуть дальше.
2. Мини-приложение «Учёт расходов»
Сейчас соберём небольшой учебный пример, который будет развиваться дальше в рамках дня про ошибки. Идея максимально бытовая: у нас есть список расходов, каждый расход — это категория и сумма. Мы хотим посчитать общий итог и сумму по категориям. А ещё мы хотим, чтобы программа не делала вид, что всё хорошо, если пользователь ввёл “сто евро” вместо 100.
Формат ввода сделаем простым: сначала число n (сколько записей), потом n пар категория сумма. Сумма приходит как строка, а мы парсим её через strconv.Atoi, чтобы получить классическую ошибку (а не “молчаливое превращение в ноль”).
Для начала напишем маленькую функцию чтения одного токена. Это позволит нам в одном месте увидеть, что даже fmt.Scan может вернуть error.
package main
import "fmt"
func readToken() (string, error) {
var s string
_, err := fmt.Scan(&s)
return s, err
}
Теперь сделаем функцию чтения суммы: она читает строковый токен и пытается превратить его в int.
package main
import (
"strconv"
)
func readAmount() (int, error) {
token, err := readToken()
if err != nil {
return 0, err
}
return strconv.Atoi(token)
}
Здесь важен стиль возврата: если возникла ошибка, мы возвращаем 0 (zero value для int) и саму ошибку. Мы пока не улучшаем текст ошибки — сегодня нам важно понять, что это просто значение и оно либо nil, либо нет.
Теперь соберём main, который читает n, затем n записей, и складывает суммы по категориям в map[string]int.
package main
import (
"fmt"
)
func main() {
n, err := readAmount()
if err != nil {
fmt.Println("failed to read n:", err)
return
}
totals := make(map[string]int)
for i := 0; i < n; i++ {
cat, err := readToken()
if err != nil {
fmt.Println("failed to read category:", err)
return
}
amount, err := readAmount()
if err != nil {
fmt.Println("failed to read amount:", err)
return
}
totals[cat] = totals[cat] + amount
}
fmt.Println("totals:", totals)
}
Эта версия нарочно простая: при первой же ошибке мы печатаем сообщение и выходим. С точки зрения сегодняшней темы это идеально, потому что показывает главный контракт: пока err == nil, мы продолжаем; как только err != nil, мы выбираем ветку “ошибка”.
Чтобы увидеть, что программа ведёт себя предсказуемо, представьте такой ввод:
3
food 120
taxi 300
food x
На третьей записи strconv.Atoi("x") вернёт не‑nil ошибку, amount станет 0, и мы не будем добавлять “нулевой расход” как будто всё нормально — мы остановимся и покажем пользователю проблему.
Заметьте ещё одну важную вещь про map: если в категории ещё нет суммы, totals[cat] даёт 0 (zero value для int). Поэтому выражение totals[cat] = totals[cat] + amount работает и для первой записи, и для последующих.
Небольшая схема потока управления у нас такая (и это хороший шаблон на будущее):
flowchart TD
A[Прочитать токен/число] --> B{err == nil?}
B -- да --> C[Использовать значение]
C --> A
B -- нет --> D[Показать ошибку]
D --> E[Остановиться]
3. Типичные ошибки при первых шагах с error
Ошибка №1: воспринимать error как строку и проверять “пустая / не пустая”.
Иногда хочется сделать что-то вроде if err != "", потому что “ну ошибка же текст”. Но error — это отдельный тип, не string. Проверка корректна только через err == nil или err != nil. Если вам нужен текст, его получают через err.Error(), но это уже последующий шаг, а не способ определить факт ошибки.
Ошибка №2: печатать ошибку и продолжать вычисления с невалидным результатом.
Ситуация: n, err := strconv.Atoi(...), затем вы печатаете ошибку, но всё равно используете n. Проблема в том, что при ошибке n почти всегда будет zero value (для int это 0), и вы тихо получите неправильный итог. Если вы решили, что ошибка критична, то после сообщения должен быть явный переход потока управления (например, return из main).
Ошибка №3: путать “успех” и “ошибка” в условии.
Бывает написать if err == nil { ... обработка ошибки ... }. Держите в голове правило: err != nil — ветка ошибки.
Ошибка №4: не понимать, что fmt.Println(err) не просто “печатает переменную”.
Текст появляется потому, что fmt вызывает метод Error() у ошибки. Это полезно помнить, чтобы не ожидать, что error “сам по себе строка”.
Ошибка №5: пытаться принимать решения по тексту err.Error().
Соблазн велик: текст же понятный. Но он не предназначен для логики ветвления. Привыкайте к дисциплине: “есть ошибка или нет ошибки” решаем через nil/не‑nil. Текст — для пользователя/лога, а не для алгоритма.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ