В последнее десятилетие мы успешно пользовались тем, что Go обрабатывает ошибки как значения. Хотя в стандартной библиотеке была минимальная поддержка ошибок: лишь функции errors.New и fmt.Errorf, которые генерируют ошибку, содержащую только сообщение — встроенный интерфейс позволяет Go-программистам добавлять любую информацию. Нужен лишь тип, реализующий метод Error:

type QueryError struct {
    Query string
    Err   error
}

func (e *QueryError) Error() string { return e.Query + ": " + e.Err.Error() }

Такие типы ошибок встречаются во всех языках и хранят самую разную информацию, от временных меток до названий файлов и адресов серверов. Часто упоминаются и низкоуровневые ошибки, предоставляющие дополнительный контекст.

Паттерн, когда одна ошибка содержит другую, встречается в Go столь часто, что после жаркой дискуссии в Go 1.13 была добавлена его явная поддержка. В этой статье мы рассмотрим дополнения к стандартной библиотеке, обеспечивающие упомянутую поддержку: три новые функции в пакете errors и новая форматирующая команда для fmt.Errorf.

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

Ошибки до Go 1.13


Исследование ошибок


Ошибки в Go являются значениями. Программы принимают решения на основе этих значений разными способами. Чаще всего ошибка сравнивается с nil, чтобы понять, не было ли сбоя операции.

if err != nil {
    // something went wrong
}

Иногда мы сравниваем ошибку, чтобы узнать контрольное значение и понять, не возникла ли конкретная ошибка.

var ErrNotFound = errors.New("not found")

if err == ErrNotFound {
    // something wasn't found
}

Значение-ошибка может быть любого типа, который удовлетворяет определённому в языке интерфейсу ошибок. Программа может использовать утверждение типа или переключатель типа для просмотра значения-ошибки более специфического типа.

type NotFoundError struct {
    Name string
}

func (e *NotFoundError) Error() string { return e.Name + ": not found" }

if e, ok := err.(*NotFoundError); ok {
    // e.Name wasn't found
}

Добавление информации


Зачастую функция передаёт ошибку вверх по стеку вызовов, добавляя к ней информацию, например, короткое описание того, что происходило в момент возникновения ошибки. Это сделать просто, достаточно сконструировать новую ошибку, включающую в себя текст из предыдущей ошибки:

if err != nil {
    return fmt.Errorf("decompress %v: %v", name, err)
}

При создании новой ошибки с помощью fmt.Errorf мы выбрасываем из исходной ошибки всё, за исключением текста. Как мы видели в примере с QueryError, иногда нужно определять новый тип ошибки, который содержит исходную ошибку, чтобы сохранить её для анализа с помощью кода:

type QueryError struct {
    Query string
    Err   error
}

Программы могут заглянуть внутрь значения *QueryError и принять решение на основе исходной ошибки. Иногда это называется «распаковкой» (unwrapping) ошибки.

if e, ok := err.(*QueryError); ok && e.Err == ErrPermission {
    // query failed because of a permission problem
}

Тип os.PathError из стандартной библиотеки — ещё пример того, как одна ошибка содержит другую.

Ошибки в Go 1.13


Метод Unwrap


В Go 1.13 в пакетах стандартной библиотеки errors и fmt упрощена работа с ошибками, которые содержат другие ошибки. Самым важным является соглашение, а не изменение: ошибка, содержащая другую ошибку, может реализовать метод Unwrap, который возвращает исходную ошибку. Если e1.Unwrap() возвращает e2, то мы говорим, что e1 упаковывает e2 и можно распаковать e1 для получения e2.

Согласно этому соглашению, можно дать описанный выше тип QueryError методу Unwrap, который возвращает содержащуюся в нём ошибку:

func (e *QueryError) Unwrap() error { return e.Err }

Результат распаковки ошибки тоже может содержать метод Unwrap. Последовательность ошибок, полученных с помощью повторяющихся распаковок, мы называем цепочкой ошибок.

Исследование ошибок с помощью Is и As


В Go 1.13 пакет errors содержит две новые функции для исследования ошибок: Is и As.

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

// Similar to:
//   if err == ErrNotFound { … }
if errors.Is(err, ErrNotFound) {
    // something wasn't found
}

Функция As проверяет, относится ли ошибка к конкретному типу.

// Similar to:
//   if e, ok := err.(*QueryError); ok { … }
var e *QueryError
if errors.As(err, &e) {
    // err is a *QueryError, and e is set to the error's value
}

В простейшем случае функция errors.Is ведёт себя как сравнение с контрольной ошибкой, а функция errors.As ведёт себя как утверждение типа. Однако работая с упакованными ошибками, эти функции оценивают все ошибки в цепочке. Давайте посмотрим на вышеприведённый пример распаковки QueryError для исследования исходной ошибки:

if e, ok := err.(*QueryError); ok && e.Err == ErrPermission {
    // query failed because of a permission problem
}

С помощью функции errors.Is можно записать так:

if errors.Is(err, ErrPermission) {
    // err, or some error that it wraps, is a permission problem
}

Пакет errors также содержит новую функцию Unwrap, которая возвращает результат вызова метода Unwrap ошибки, или возвращает nil, если у ошибки нет метода Unwrap. Обычно лучше использовать errors.Is или errors.As, поскольку они позволяют исследовать всю цепочку одним вызовом.

Упаковка ошибок с помощью %w


Как я упоминал, нормальной практикой является использование функции fmt.Errorf для добавления к ошибке дополнительной информации.

if err != nil {
    return fmt.Errorf("decompress %v: %v", name, err)
}

В Go 1.13 функция fmt.Errorf поддерживает новая команда %w. Если она есть, то ошибка, возвращаемая fmt.Errorf, будет содержать метод Unwrap, возвращающий аргумент %w, который должен быть ошибкой. Во всех остальных случаях %w идентична %v.

if err != nil {
    // Return an error which unwraps to err.
    return fmt.Errorf("decompress %v: %w", name, err)
}

Упаковка ошибки с помощью %w делает её доступной для errors.Is и errors.As:

err := fmt.Errorf("access denied: %w", ErrPermission)
...
if errors.Is(err, ErrPermission) ...

Когда стоит упаковывать?


Когда вы добавляете к ошибке дополнительный контекст с помощью fmt.Errorf или реализации пользовательского типа вам нужно решить, будет ли новая ошибка содержать в себе исходную. На это нет однозначного ответа, всё зависит от контекста, в котором создана новая ошибка. Упакуйте, чтобы показать её вызывающим. Не упаковывайте ошибку, если это приведёт к раскрытию подробностей реализации.

Например, представьте функцию Parse, которая считывает из io.Reader сложную структуру данных. Если возникает ошибка, нам захочется узнать номер строки и столбца, где она произошла. Если ошибка возникла при чтении из io.Reader, нам нужно будет упаковать её, чтобы выяснить причину. Поскольку вызывающий был предоставлен функции io.Reader, имеет смысл показать сгенерированную им ошибку.

Другой случай: функция, которая делает несколько вызовов базы данных, вероятно, не должна возвращать ошибку, в которой упакован результат одного из этих вызовов. Если БД, которая использовалась этой функцией, является частью реализации, то раскрытие этих ошибок нарушит абстракцию. К примеру, если функция LookupUser из пакета pkg использует пакет Go database/sql, то она может столкнуться с ошибкой sql.ErrNoRows. Если вернуть ошибку с помощью fmt.Errorf("accessing DB: %v", err), тогда вызывающий не может заглянуть внутрь и найти sql.ErrNoRows. Но если функция вернёт fmt.Errorf("accessing DB: %w", err), тогда вызывающий мог бы написать:

err := pkg.LookupUser(...)
if errors.Is(err, sql.ErrNoRows) …

В таком случае функция должна всегда возвращать sql.ErrNoRows, если вы не хотите сломать клиенты, даже при переключении на пакет с другой базой данных. Иными словами, упаковка делает ошибку частью вашего API. Если не хотите в будущем коммитить поддержку этой ошибки как часть API, не упаковывайте её.

Важно помнить, что вне зависимости от того, упаковываете вы её или нет, ошибка останется неизменной. Человек, который будет в ней разбираться, будет иметь одну и ту же информацию. Принятие решения об упаковке зависит от того, нужно ли дать дополнительную информацию программам, чтобы они могли принимать более информированные решения; или если нужно скрыть эту информацию ради сохранения уровня абстракции.

Настройка тестирования ошибок с помощью методов Is и As


Функция errors.Is проверяет каждую ошибку в цепочке на соответствие целевому значению. По умолчанию ошибка соответствует этому значению, если они эквивалентны. Кроме того, ошибка в цепочке может объявлять о своём соответствии целевому значению с помощью реализации метода Is.

Рассмотрим ошибку, вызванную пакетом Upspin, которая сравнивает ошибку с шаблоном и оценивает только ненулевые поля:

type Error struct {
    Path string
    User string
}

func (e *Error) Is(target error) bool {
    t, ok := target.(*Error)
    if !ok {
        return false
    }
    return (e.Path == t.Path || t.Path == "") &&
           (e.User == t.User || t.User == "")
}

if errors.Is(err, &Error{User: "someuser"}) {
    // err's User field is "someuser".
}

Функция errors.As также консультирует метод As при его наличии.

Ошибки и API пакетов


Пакет, который возвращает ошибки (а это делают большинство пакетов), должен описать свойства этих ошибок, на которые может опираться программист. Хорошо спроектированный пакет также будет избегать возвращения ошибок со свойствами, на которые нельзя опираться.

Самое простое: говорить, была ли операция успешной, возвращая, соответственно, значение nil или не-nil. Во многих случаях другой информации не требуется.

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

var ErrNotFound = errors.New("not found")

// FetchItem returns the named item.
//
// If no item with the name exists, FetchItem returns an error
// wrapping ErrNotFound.
func FetchItem(name string) (*Item, error) {
    if itemNotFound(name) {
        return nil, fmt.Errorf("%q: %w", name, ErrNotFound)
    }
    // ...
}

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

В любом случае, не раскрывайте пользователю внутренние подробности. Как упоминалось в главе «Когда стоит упаковывать?», если возвращаете ошибку из другого пакета, то преобразуйте её, чтобы не раскрывать исходную ошибку, если только не собираетесь брать на себя обязательство в будущем вернуть эту конкретную ошибку.

f, err := os.Open(filename)
if err != nil {
    // The *os.PathError returned by os.Open is an internal detail.
    // To avoid exposing it to the caller, repackage it as a new
    // error with the same text. We use the %v formatting verb, since
    // %w would permit the caller to unwrap the original *os.PathError.
    return fmt.Errorf("%v", err)
}

Если функция возвращает ошибку с упакованным сигнальным значением или типом, то не возвращайте напрямую исходную ошибку.

var ErrPermission = errors.New("permission denied")

// DoSomething returns an error wrapping ErrPermission if the user
// does not have permission to do something.
func DoSomething() {
    if !userHasPermission() {
        // If we return ErrPermission directly, callers might come
        // to depend on the exact error value, writing code like this:
        //
        //     if err := pkg.DoSomething(); err == pkg.ErrPermission { … }
        //
        // This will cause problems if we want to add additional
        // context to the error in the future. To avoid this, we
        // return an error wrapping the sentinel so that users must
        // always unwrap it:
        //
        //     if err := pkg.DoSomething(); errors.Is(err, pkg.ErrPermission) { ... }
        return fmt.Errorf("%w", ErrPermission)
    }
    // ...
}

Заключение


Хотя мы обсудили всего лишь три функции и форматирующую команду, надеемся, что они помогут сильно улучшить обработку ошибок в программах на Go. Мы надеемся, что упаковка ради предоставления дополнительного контекста станет нормальной практикой, помогающей программистам принимать более взвешенные решения и быстрее находить баги.

Как сказал Расс Кокс (Russ Cox) в своём выступлении на GopherCon 2019, на пути к Go 2 мы экспериментируем, упрощаем и отгружаем. И теперь, отгрузив эти изменения, мы принимаемся за новые эксперименты.

Комментарии (18)


  1. pawlo16
    30.10.2019 19:18

    Опять фэйл. Где стектрейс? Где множественное наследование ошибок? Всё ещё использую merry


    1. peresada
      30.10.2019 20:19

      Стектрейс есть в панике, для всего остального есть логи, Объединенные контекстом


      1. pawlo16
        30.10.2019 22:57

        Не надо путать божий дар с яичницей. При чём тут паника и логи если речь идёт об ошибках? Ошибки в сторонних либах в месте их возникновения нельзя ни залоггировать ни превратить в панику. Стектрейс — единственный способ точно установить где именно возникла ошибка. Стектрейс есть в любом уважающем себя рантайме в исключениях. Тот факт, что по умолчанию в любой ошибке, созданной с помощью fmt.Errorf и errors.New, нет стектрейса — это грёбаный стыд и феерический косяк разработчиков голанга. Один из самых дебильных способов сэкономить на спичках и сломать ноги в темноте


        1. Mikanor
          31.10.2019 17:27
          -3

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

          Вы, простите, поиском по коду пользоваться умеете?


          Стектрейс есть в любом уважающем себя рантайме в исключениях.

          Убирая тот факт, что захват фрейма не самая дешевая операция (хотя может быть и дешевой, если компилятор языка умеет преобразовывать вызовы в константные выражения), то развертка стека (в случае исключения) операция по определению недешевая.


          В Go стек динамического размера, что делает исключения еще дороже. Вы предлагаете пожертвовать скоростью ради сомнительной выгоды.


          нет стектрейса — это грёбаный стыд и феерический косяк разработчиков голанга

          В первых версиях errors стектрейс был, но его убрали как раз по той самой причине что а) стектрейс на каждую ошибку это довольно дорого б) непонятно как настроить инспекцию, ведь далеко не всегда нужен стектрейс, а достаточно вывода самой ошибки. Можете глянуть /x/xerrors — там стектрейс до сих пор остался.


          1. pawlo16
            31.10.2019 17:56

            Каким образом умение поиска по коду поможет найти место возникновения ошибки во внешней библиотеке?

            захват фрейма не самая дешевая операция
            прикрепить к ошибке несколько байт дебаг-символов — дорогая операция?? с чего вы это взяли? Где бенчмарки?
            развертка стека операция по определению недешевая.
            совершенно наплевать на развёртку стека. она происходит один раз на самом верху в момент логгирования ошибки


            1. Mikanor
              31.10.2019 18:16

              Каким образом умение поиска по коду поможет найти место возникновения ошибки во внешней библиотеке?

              А чем отличается поиск кода в своих проектах и поиска кода в чужих? В Go все довольно просто — ищите где создается ошибка с нужным вам текстом и как она прокидывается.


              прикрепить к ошибке несколько байт дебаг-символов — дорогая операция?? с чего вы это взяли? Где бенчмарки?

              https://play.golang.org/p/xo3FBPqm1AK 600 ns против 20 ns.


              совершенно наплевать на развёртку стека. она происходит один раз на самом верху в момент логгирования ошибки

              Почитайте про stack unwinding и как работает panic/recover/defer и что конкретно за вас делает рантайм, чтоб у вас не утекали ресурсы.


              1. Deosis
                01.11.2019 08:32

                ищите где создается ошибка с нужным вам текстом и как она прокидывается.

                Ошибка может создаваться в специально предназначенном для этого методе, который вызывается из 50 мест.


    1. TonyLorencio
      31.10.2019 09:27

      Я не большой сторонник стектрейсов в ошибках (все-таки, это не исключения), но, помнится мне, в merry была ещё одна интересная возможность — завернуть ошибку err1 в другую ошибку err2 используя Cause. При этом остается возможность получить и err1 и err2 как отдельные ошибки и достать из них нужную информацию.


      Без подобного враппинга идея в Go 1.13 кажется неполной.


      1. tendium
        31.10.2019 09:41

        А как на счет этого пакета: github.com/pkg/errors? Там и стектрейс есть, и всё прочее.


        1. TonyLorencio
          31.10.2019 11:15

          В том то и дело, что там есть только стектрейс и возможность заврапить ошибку с сохранением стектрейса. Возможности добавить в ошибку какой-то дополнительный контекст, кроме сообщения, я не вижу.


  1. loki82
    30.10.2019 19:34

    Не совсем понял. Могу ли упаковать ошибку в ошибку?

    func file{
    f, err := os.Open(filename)
    if err != nil {return fmt.Errorf("%w", err) }
    
    func catalog{
    f, err := os.Open(filename)
    if err != nil {return fmt.Errorf("%w", err) }
    
    func someFunc{
    err1, f1 := file() 
    err2, f2 := catalog()
    return fmt.Errorf("%w %w", err1, err2)
    
    Так можно?


    1. peresada
      30.10.2019 20:15

      Непонятно что вы имеете ввиду


      1. loki82
        30.10.2019 20:26

        Ну вот в фмт можно указать. ("%v %v", " значение 1, значение 2). Здесь так можно? И потом получить err.Is(знч1, знч2, ошибка1, ошибка2)


    1. Mikanor
      31.10.2019 17:16

      Стандартными средствами — нет, так как агрегирование (когда функция одновременно возвращает множество ошибок) хоть и обсуждалось, но придти к единому решению пока не смогли. Но можно быстро накидать самому, например. Однако и у этой реализации есть минусы/особенности — так, например, если функция возвращает две ошибки одного типа (например оба os.Open вернули *os.PathError) то errors.As/Is позволит посмотреть только одну из них. Способы решения для этой проблемы то-же есть — либо добавить к типу multiError итератор, либо завести собственный тип реализующий ошибку, для функции someFunc у которого тонко настроить As/Is. Если хотите, могу через личку уточнить как сделать оба подхода.


  1. pawlo16
    30.10.2019 21:08
    -1

    всё это можно. Но. В someFunc:

    errors.Is(fmt.Errorf("%w %w", err1, err2), err1) == err2 //true
    errors.Is(fmt.Errorf("%w %w", err1, err2), err1) == err1 //false


    1. Mikanor
      31.10.2019 17:19
      +1

      Нет. Читаем документацию fmt.Errorf:

      It is invalid to include more than one %w verb or to supply it with an operand that does not implement the error interface.


      1. pawlo16
        31.10.2019 19:32

        В чём это противоречит тому, что я написал, и с чего вы взяли что я не читал документацию?


  1. loki82
    30.10.2019 22:59
    -1

    Вот кто ставит минусы. Не за себя прошу. И за вопрос, и за ответ минус. Может тогда развернете свою мысль, раз вы гуру в Golang.