Обработка ошибок в Go 2

title

Буквально пару дней назад в Денвере закончилась очередная, уже 5-я по счёту, крупнейшая конференция по Go — GopherCon. На ней команда Go сделала важное заявление — черновики предварительного дизайна новой обработки ошибок и дженериков в Go 2 опубликованы, и все приглашаются к обсуждению.

Я постараюсь подробно пересказать суть этих черновиков в трёх статьях.

Как многим, наверняка, известно, в прошлом году (также на GopherCon) команда Go объявила, что собирает отчёты (experience reports) и предложения для решения главных проблем Go — тех моментов, которые по опросам собирали больше всего критики. В течении года все предложения и репорты изучались и рассматривались, и помогли в создании черновиков дизайна, о которых и будет идти речь.

Итак, начнём с черновиков нового механизма обработки ошибок.

Для начала, небольшое отступление:


  1. Go 2 это условное название — все нововведения будут частью обычного процесса выпуска версий Go. Так что пока неизвестно, будет ли это Go 1.34 или Go2. Сценария Python 2/3 не будет железно.
  2. Черновики дизайна это даже не предложения (proposals), с которых начинается любое изменение в библиотеке, тулинге или языке Go. Это начальная точка для обсуждения дизайна, предложенная командой Go после нескольких лет работы над данными вопросами. Всё, что описано в черновиках с большой долей вероятности будет изменено, и, при наилучших раскладах, воплотится в реальность только через несколько релизов (я даю ~2 года).

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

Возьмём пример гипотетического Go с исключениями:

func CopyFile(src, dst string) throws error {
    r := os.Open(src)
    defer r.Close()

    w := os.Create(dst)
    io.Copy(w, r)
    w.Close()
}

Это приятный, чистый и элегантный код. Он также некорректый: если io.Copy или w.Close завершатся неудачей, данный код не удалит созданный и недозаписанный файл.

С другой стороны, код на реальном Go выглядит так:

func CopyFile(src, dst string) error {
    r, err := os.Open(src)
    if err != nil {
        return err
    }
    defer r.Close()

    w, err := os.Create(dst)
    if err != nil {
        return err
    }
    defer w.Close()

    if _, err := io.Copy(w, r); err != nil {
        return err
    }
    if err := w.Close(); err != nil {
        return err
    }
}

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

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

Проще говоря, в Go слишком много проверки ошибок и недостаточно обработки ошибок. Более полноценная версия кода выше будет выглядеть вот так:

func CopyFile(src, dst string) error {
    r, err := os.Open(src)
    if err != nil {
        return fmt.Errorf("copy %s %s: %v", src, dst, err)
    }
    defer r.Close()

    w, err := os.Create(dst)
    if err != nil {
        return fmt.Errorf("copy %s %s: %v", src, dst, err)
    }

    if _, err := io.Copy(w, r); err != nil {
        w.Close()
        os.Remove(dst)
        return fmt.Errorf("copy %s %s: %v", src, dst, err)
    }

    if err := w.Close(); err != nil {
        os.Remove(dst)
        return fmt.Errorf("copy %s %s: %v", src, dst, err)
    }
}

Исправление проблем сделало код корректным, но никак не чище или элегантней.

Команда Go ставит перед собой следующие цели для улучшения обработки ошибок в Go 2:


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

Черновик дизайна предлагает изменить или дополнить семантику обработки ошибок в Go.

Предложенный дизайн вводит две новых синтаксические формы.


  • check(x,y,z) или check err обозначающую явную проверку ошибки
  • handle — определяющую код, обрабатывающий ошибки

Если check возвращает ошибку, то контроль передаётся в ближайший блок handle (который передаёт контроль в следущий по лексическому контексту handler, если такой есть, и. затем, вызывает return)

Код выше будет выглядеть так:

func CopyFile(src, dst string) error {
    handle err {
        return fmt.Errorf("copy %s %s: %v", src, dst, err)
    }

    r := check os.Open(src)
    defer r.Close()

    w := check os.Create(dst)
    handle err {
        w.Close()
        os.Remove(dst) // (только если check упадёт)
    }

    check io.Copy(w, r)
    check w.Close()
    return nil
}

Этот синтаксис разрешён также в функциях, которые не возвращают ошибки (например main). Следующая программа:

func main() {
    hex, err := ioutil.ReadAll(os.Stdin)
    if err != nil {
        log.Fatal(err)
    }

    data, err := parseHexdump(string(hex))
    if err != nil {
        log.Fatal(err)
    }

    os.Stdout.Write(data)
}

может быть переписана как:

func main() {
    handle err {
        log.Fatal(err)
    }

    hex := check ioutil.ReadAll(os.Stdin)
    data := check parseHexdump(string(hex))
    os.Stdout.Write(data)
}

Вот ещё пример, чтобы почувствовать предложенную идею лучше. Оригинальный код:

func printSum(a, b string) error {
    x, err := strconv.Atoi(a)
    if err != nil {
        return err
    }
    y, err := strconv.Atoi(b)
    if err != nil {
        return err
    }
    fmt.Println("result:", x + y)
    return nil
}

может быть переписан как:

func printSum(a, b string) error {
    handle err { return err }
    x := check strconv.Atoi(a)
    y := check strconv.Atoi(b)
    fmt.Println("result:", x + y)
    return nil
}

или даже вот так:

func printSum(a, b string) error {
    handle err { return err }
    fmt.Println("result:", check strconv.Atoi(x) + check strconv.Atoi(y))
    return nil
}

Давайте рассмотрим подробнее детали предложенных конструкций check и handle.

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

Следующий пример:

v1, ..., vN := check <выражение>

равнозначен этому коду:

v1, ..., vN, vErr := <выражение>
if vErr != nil {
     = handlerChain(vn)
    return
}

где vErr должен иметь тип error и  означает ошибку, возвращённую из обработчика.

Аналогично,

foo(check <выражение>)

равнозначно:

v1, ..., vN, vErr := <выражение>
if vErr != nil {
     = handlerChain(vn)
    return
}
foo(v1, ..., vN)


Check против try

Изначально пробовали слово try вместо check — оно более популярное/знакомое, и, например, Rust и Swift используют try (хотя Rust уходит в пользу постфикса ? уже).

try неплохо читался с функциями:

data := try parseHexdump(string(hex))

но совершенно не читался со значениями ошибок:

data, err := parseHexdump(string(hex))
if err == ErrBadHex {
    ... special handling ...
}
try err

Кроме того, try всё таки несёт багаж cхожести с механизмом исключений и может вводить в заблуждение. Поскольку предложенный дизайн check/handle существенно отличается от исключений, выбор явного и красноречивого слова check кажется оптимальным.

handle описывает блок, называемый «обработчик» (handler), который будет обрабатывать ошибку, переданную в check. Возврат (return) из этого блока означает незамедлительный выход из функции с текущими значениями возвращаемых переменных. Возврат без переменных (то есть, просто return) возможен только в функциях с именованными переменными возврата (например func foo() (bar int, err error)).

Поскольку обработчиков может быть несколько, формально вводится понятие «цепочки обработчиков» — каждый из них это, по сути, функция, которая принимает на вход переменную типа error и возвращает те же самые переменные, что и функция, для которой обработчик определяется. Но семантика обработчика может быть описана вот так:

func handler(err error) error {...}

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


Порядок обработчиков

Важный момент для понимания — в каком порядке будут вызываться обработчики, если их несколько. Каждая проверка (check) может иметь разные обработчики, в зависимости от скопа, в котором они вызываются. Первым будет вызван обработчик, который ближе всего объявлен в текущем скопе, вторым — следующий в обратном порядке объявления. Вот пример для лучшего понимания:

func process(user string, files chan string) (n int, err error) {
    handle err { return 0, fmt.Errorf("process: %v", err)  }      // handler A
    for i := 0; i < 3; i++ {
        handle err { err = fmt.Errorf("attempt %d: %v", i, err) } // handler B
        handle err { err = moreWrapping(err) }                    // handler C

        check do(something())  // check 1: handler chain C, B, A
    }
    check do(somethingElse())  // check 2: handler chain A
}

Проверка check 1 вызовет обработчики C, B и A — именно в таком порядке. Проверка check 2 вызовет только A, так как C и B были определены только для скопа for-цикла.

Конечно, в данном дизайне сохраняется изначальный подход к ошибкам как к обычным значениям. Вы всё также вольны использовать обычный if для проверки ошибки, а в обработчике ошибок (handle) можно (и нужно) делать то, что наилучшим образом подходит ситуации — например, дополнять ошибку деталями перед тем, как обработать в другом обработчике:

type Error struct {
    Func string
    User string
    Path string
    Err  error
}

func (e *Error) Error() string

func ProcessFiles(user string, files chan string) error {
    e := Error{ Func: "ProcessFile", User: user}
    handle err { e.Err = err; return &e } // handler A
    u := check OpenUserInfo(user)         // check 1
    defer u.Close()
    for file := range files {
        handle err { e.Path = file }       // handler B
        check process(check os.Open(file)) // check 2
    }
    ...
}

Стоит отметить, что handle несколько напоминает defer, и можно решить, что порядок вызова будет аналогичным, но это не так. Эта разница — одна из слабых место данного дизайна, кстати. Кроме того, handler B будет исполнен только раз — аналогичный вызов defer в том же месте, привёл бы ко множественным вызовам. Go команда пыталась найти способ унифицировать defer/panic и handle/check механизмы, но не нашла разумного варианта, который бы не делал язык обратно-несовместимым.

Ещё важный момент — хотя бы один обработчик должен возвращать значения (т.е. вызывать return), если оригинальная функция что-то возвращает. В противном случае это будет ошибкой компиляции.

Паника (panic) в обработчиках исполняется так же, как и в теле функции.


Обработчик по-умолчанию

Ещё одна ошибка компиляции — если код обработчика пуст (handle err {}). Вместо этого вводится понятие «обработчика по-умолчанию» (default handler). Если не определять никакой handle блок, то, по-умолчанию, будет возвращаться та же самая ошибка, которую получил check и остальные переменные без изменений (в именованных возвратных значениях; в неименованных будут возвращаться нулевые значения — zero values).

Пример кода с обработчиком по-умолчанию:

func printSum(a, b string) error {
    x := check strconv.Atoi(a)
    y := check strconv.Atoi(b)
    fmt.Println("result:", x + y)
    return nil
}


Сохранение стека вызова

Для корректных стектрейсов Go трактует обработчики как код, вызывающийся из функции в своем собственном стеке. Нужен будет какой-то механизм, позволяющий игнорировать код обработчика в стектрейсе, например для табличных тестов. Скорее всего, вот использование t.Helper() будет достаточно, но это ещё открытый вопрос:

func TestFoo(t *testing.T) {
    handle err {
        t.Helper()
        t.Fatal(err)
    }
    for _, tc := range testCases {
        x := check Foo(tc.a)
        y := check Foo(tc.b)
        if x != y {
            t.Errorf("Foo(%v) != Foo(%v)", tc.a, tc.b)
        }
    }
}


Затенение (shadowing) переменных

Использование check может практически убрать надобность в переопределении переменных в краткой форме присваивания (:=), поскольку это было продиктовано именно необходимостью переиспользовать err. С новым механизмом handle/check затенение переменных может вообще стать неактуальным.


defer/panic

Использование похожих концепций (defer/panic и handle/check) увеличивает когнитивную нагрузку на программиста и сложность языка. Не очень очевидные различия между ними открывают двери для нового класса ошибок и неправильного использования обоих механизмов.

Поскольку handle всегда вызывается раньше defer (и, напомню, паника в коде обработчика обрабатывается так же, как и в обычном теле функции), то нет способа использовать handle/check в теле defer-а. Вот этот код не скомпилируется:

func Greet(w io.WriteCloser) error {
    defer func() {
        check w.Close()
    }()
    fmt.Fprintf(w, "hello, world\n")
    return nil
}

Пока не ясно, как можно красиво решить эту ситуацию.


Уменьшение локальности кода

Одним из главных преимуществ нынешнего механизма обработки ошибок в Go является высокая локальность — код обработчика ошибки находится очень близко к коду получения ошибки, и исполняется в том же контексте. Новый же механизм вводит контекстно-зависимый «прыжок», похожий одновременно на исключения, на defer, на break и на goto. И хотя данный подход сильно отличается от исключений, и больше похож на goto, это всё ещё одна концепция, которую программисты должны будут учить и держать в голове.


Имена ключевых слов

Рассматривалось использование таких слов как try, catch, ? и других, потенциально более знакомых из других языков. После экспериментирования со всеми, авторы Go считают, что check и handle лучше всего вписываются в концепцию и уменьшают вероятность неверного трактования.

Что делать с кодом, в котором имена handle и catch уже определены, пока тоже не ясно (не факт, что это будут ключевые слова (keywords) ещё).


Когда выйдет Go2?

Неизвестно. Учитывая прошлый опыт нововведений в Go, от стадии обсуждения до первого экспериментального использования проходит 2–3 релиза, а официальное введение — ещё через релиз. Если отталкиваться от этого, то это 2–3 года при наилучших раскладах.

Плюс, не факт, что это будет Go2 — это вопрос брендинга. Скорее всего, будет обычный релиз очередной версии Go — Go 1.20 например. Никто не знает.


Разве это не то же самое, что исключения?

Нет. В исключениях главная проблема в неявности/невидимости кода и процесса обработки ошибок. Данный дизайн лишен такого недостатка, и является, фактически, синтаксическим сахаром для обычной проверки ошибок в Go.


Не разделит ли это Go программистов на 2 лагеря — тех, кто останется верным if err != nil {} проверкам, и сторонников handle/check?

Неизвестно, но расчёт на то, что if err будет мало смысла использовать, кроме специальных случаев — новый дизайн уменьшает количество символов для набора, и сохраняет явность проверки и обработки ошибок. Но, время покажет.


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

Является. Расчёт на то, что выгода от этого усложнения перевесит минусы самого факта усложнения.


Это окончательный дизайн и точно ли он будет принят?

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


Я знаю, как сделать дизайн лучше! Что мне делать?

Напишите статью с объяснением вашего видения и добавьте её в вики-страничку Go2ErrorHandlingFeedback — (https://go.googlesource.com/proposal/+/master/design/go2draft-error-handling.md)


  • Предложен новый механизм обработки ошибок в будущих версиях Go —  handle/check
  • Обратно-совместим с нынешним
  • Проверка и обработка ошибок остаются явными
  • Сокращается количество текста, особенно в кусках кода, где много повторений однотипных ошибок
  • В грамматику языка добавляются два новых элемента
  • Есть открытые/нерешённые вопросы (взаимодействие с defer/panic)

Мысли? Комментарии?


© Habrahabr.ru