В Go ошибки — это значения, так как у них есть значение и тип, как и у всего остального. Это отличается от таких языков, как Java или Python, где ошибки являются исключительными вещами (каламбур), которые требуют специальной обработки и синтаксиса языка, например try / catch.

Поскольку в Go ошибки — это просто значения, мы обрабатываем их, сравнивая с другими значениями. Тут есть свои тонкости — поэтому и эта статья.

№ 1 — Прямое сравнение новых ошибок

Что выводит этот код?

// Go Playground: https://play.golang.com/p/f_1lmY16aKY
e1 := errors.New(“ohno”)
e2 := errors.New(“ohno”)
// What does this print?
fmt.Println(e1 == e2)

Ответ: false

Начнем с простого — все ошибки, созданные с помощью errors.New, различны. Вы можете знать это интуитивно или из чтения и написания кода Go, но почему именно это так?

Короткий ответ: эти ошибки e1 и e2 в конечном итоге сравниваются как указатели. Указатели равны в Go только в том случае, если они указывают на один и тот же базовый объект в памяти. Поскольку каждый вызов errors.New является новым выделением (отсюда и название), указатели не равны.

НЕ ИМЕЕТ ЗНАЧЕНИЯ, что ошибки имеют одно и то же строковое значение "ohno" внутри них.

Длинный ответ? Давайте немного остановимся на том, как интерфейсы и сравнения работают в Go. Детали помогут нам в последующих головоломках.

Код для errors.New (начиная с Go 1.19) выглядит следующим образом:

// New returns an error that formats as the given text.
// Each call to New returns a distinct error value even if the text is identical.
func New(text string) error {
  return &errorString{text}
}

// errorString is a trivial implementation of error.
type errorString struct {
  s string
}

func (e *errorString) Error() string {
  return e.s
}

Несколько замечаний:

  1. Во-первых, документы по функции сообщают вам, что все возвращаемые значения различны. Всегда читайте документы.
  2. Для вызывающих объектов errors.New возвращает интерфейс error.
  3. Фактическая возвращаемая вещь имеет тип *errorString , который реализует интерфейс error с использованием получателя указателя в методе Error() .

Поэтому, когда мы вызываем функцию дважды, мы получаем два значения интерфейса error, реализованные внутри них *errorString. Затем мы сравниваем два значения интерфейса error. Что делает Go при сравнении двух значений интерфейса? Давайте проверим Спецификацию программирования на Go, в частности ее раздел Операторы сравнения:

Значения интерфейса сопоставимы. Два значения интерфейса равны, если они имеют одинаковые динамические типы и одинаковые динамические значения или если оба имеют значение nil.

Что такое динамические типы и значения? Еще раз делегируем в spec:

Статический тип (или просто тип) переменной – это тип, указанный в ее объявлении, тип, указанный в вызове new, или составной литерал, или тип элемента. структурированной переменной. Переменные типа интерфейса также имеют отдельный динамический тип, который является (не интерфейсным) типом значения, присвоенного переменной во время выполнения (если значение не является заранее объявленным идентификатором nil, который не имеет тип).

Так вот, для errors.New:

  1. Статический тип — error.
  2. Динамический тип — *errorString.
  3. Динамическое значение равно &errorString{s: "ohno"}.

Таким образом, два значения интерфейса равны, если они имеют одинаковые динамические типы и одинаковые динамические значения. Наши две ошибки e1 и e2 определенно имеют одинаковые динамические типы: *errorString. Имеют ли они одинаковые динамические значения? Это означало бы сравнение значений указателя &errorString{s: "ohno"}. Давайте снова обратимся к спецификации:

Значения указателя сопоставимы. Два значения указателя равны, если они указывают на одну и ту же переменную или если оба имеют значение nil. Указатели на разные переменные нулевого размера могут быть равны или не равны.

Ну вот и все. Оба динамических значения являются отдельно выделенными указателями структур, например. &errorString{s: "ohno"}. Указатели указывают на другую память/разные переменные. Так что они не равны.

#2 — Сравнение новых ошибок по значению

Согласно оригинальному сообщению в блоге, представляющему их:

Функция errors.Is сравнивает ошибку со значением.

Итак, мы начинаем со сравнения двух ошибок как значений, используя errors.Is. Что выводит этот код?

// Go Playground: https://play.golang.com/p/Kl8d9MpWhc_T
e1 := errors.New("ohno")
e2 := errors.New("ohno")
// What does this print?
fmt.Println(errors.Is(e1, e2))

Ответ: false

Да. Это хорошо. errors.Is не должен изменять тот факт, что мы сравниваем две разные переменные как значения. Эти переменные представляют собой два разных значения интерфейса, которые содержат два разных указателя на структуры в качестве своих динамических значений.

#3 — Сравнение существующих ошибок как значений

Что выводит этот код?

// Go Playground: https://play.golang.com/p/cpimPBzUeOS
var (
  ErrCustom = errors.New("ohno")
)

var e1 error = ErrCustom
var e2 error = ErrCustom
// What does this print?
fmt.Println(e1 == e2)
// And this?
fmt.Println(errors.Is(e1, e2))

Отвечать:

Это хорошо. Обе переменные являются error значениями интерфейса, которые содержат одну и ту же основную ошибку в памяти и указывают на нее. Так что это соответствует всему, что мы хотим, и действительно похоже на то, как мы сравниваем значения ошибок большую часть времени.

Обратите внимание, однако — это немного надумано. В обычном коде мы обычно сравниваем некоторую ошибку, возвращаемую функцией, с переменной уровня пакета. Но это имитирует общую идею.

#4 — Сравнение пользовательских ошибок как значений

Давайте попробуем еще кое-что со значениями. Что печатает этот код?

// Go Playground: https://play.golang.com/p/8DGtBOuTQSp
type CustomError struct {
  Err string
}

func (ce CustomError) Error() string {
  return ce.Err
}

func main() {
  var e1 error = CustomError{Err: "ohno"}
  var e2 error = CustomError{Err: "ohno"}
  fmt.Println(errors.Is(e1, e2))
}

Ответ: верно!

Вы видите, что произошло? Мы создали собственный тип ошибки, реализующий интерфейс error с использованием приемников значений. Это означает, что переменные этого типа на самом деле сравниваются по значению!

Напомним, что errors.New возвращает значение интерфейса error, содержащее *errorString динамический тип и значение. Он возвращается с *errorString, потому что именно так авторы библиотеки Go решили реализовать error для типа — используя приемники указателей. И когда мы сравниваем указатели, они должны указывать на одно и то же, чтобы быть равными. Это был явно преднамеренный выбор семантики — errors.New каждый раз должно возвращать что-то отличное/не равное какой-либо другой ошибке! В противном случае они, вероятно, назвали бы это как-то иначе.

Итак, теперь у нас есть собственный тип ошибки, который назначается и содержится в значении интерфейса error. Но эти динамические значения реализуют интерфейс с приемниками значений, а значит сравниваются не как указатели, а просто как структуры! И снова по спецификации Golang:

Значения структуры сравнимы, если все их поля сравнимы. Два значения структуры равны, если равны их соответствующие непустые поля.

Ну, единственные поля здесь — это строки, а строки тоже сравниваются напрямую по содержимому:

Строковые значения сопоставимы и упорядочены лексически побайтно.

Таким образом, эти пользовательские ошибки равны.

Что произойдет, если мы изменим нашу пользовательскую ошибку, чтобы реализовать error с использованием приемников указателей? Ну, тогда наша ситуация будет полностью идентична исходному сценарию *errorString, не так ли? И мы знаем, что там происходит!

Заключение

Мы рассмотрели, как сравнивать ошибки Go по значению, в основном используя == и errors.Is. Мы не смотрели:

  • Сравнение ошибок Go по типу с помощью errors.As
  • Сравнение обернутых ошибок Go

Эти темы мы рассмотрим в следующий раз.