Go2 имеет целью уменьшить накладные расходы на обработку ошибок, но знаете ли вы, что лучше, чем улучшенный синтаксис для обработки ошибок?

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

Эта статья черпает вдохновение из главы «Define Errors Out of Existence”» книги « A philosophy of Software Design» Джона Оустерхаута. Я постараюсь применить его совет к Go.

Пример первый


Вот функция для подсчета количества строк в файле:

func CountLines(r io.Reader) (int, error) {
        var (
                br    = bufio.NewReader(r)
                lines int
                err   error
        )

        for {
                _, err = br.ReadString('\n')
                lines++
                if err != nil {
                        break
                }
        }

        if err != io.EOF {
                return 0, err
        }
        return lines, nil
 }

Мы создаем bufio.Reader, затем сидим в цикле, вызывая метод ReadString, увеличивая счетчик до тех пор, пока не достигнем конца файла, а затем возвращаем количество прочитанных строк. Это код, который мы хотели написать, вместо этого CountLines усложняется обработкой ошибок.

Например, есть такая странная конструкция:

_, err = br.ReadString('\n')
lines++
if err != nil {
  break
}

Мы увеличиваем количество строк перед проверкой ошибки — это выглядит странно. Причина, по которой мы должны написать это таким образом, заключается в том, что ReadString вернет ошибку, если он встретит конец файла — io.EOF — до нажатия символа новой строки. Это может произойти также, если нет символа новой строки.

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

Но мы еще не закончили проверять ошибки. ReadString вернет io.EOF, когда достигнет конца файла. Это ожидаемо, ReadString нужен какой-то способ сказать стоп, больше нечего читать. Поэтому, прежде чем вернуть ошибку вызывающей стороне CountLine, нам нужно проверить, не была ли ошибка io.EOF, и в этом случае вернуть ее вызывающему, иначе мы вернем nil, когда все хорошо. Вот почему последняя строка функции не просто

return lines, err

Я думаю, что это хороший пример наблюдения Расса Кокса о том, что обработка ошибок может затруднить работу функции. Давайте посмотрим на улучшенную версию.

func CountLines(r io.Reader) (int, error) {
        sc := bufio.NewScanner(r)
        lines := 0

        for sc.Scan() {
                lines++
        }

        return lines, sc.Err()
}

Эта улучшенная версия переход от использования bufio.Reader к bufio.Scanner. Под капотом bufio.Scanner использует bufio.Reader, добавляя слой абстракции, который помогает убрать обработку ошибок, которая затруднила работу нашей предыдущей версии CountLines (bufio.Scanner может сканировать любой шаблон, по умолчанию он ищет новые строки).

Метод sc.Scan() возвращает true, если сканер нашел строку текста и не обнаружил ошибку. Таким образом, тело нашего цикла for будет вызываться только тогда, когда в буфере сканера есть строка текста. Это означает, что наша переделанная CountLines правильно обрабатывает случай, когда нет завершающего символа новой строки. Также теперь правильно обрабатывается случай, когда файл пуст.

Во-вторых, поскольку sc.Scan возвращает false при возникновении ошибки, наш цикл for завершится, когда будет достигнут конец файла или возникнет ошибка. Тип bufio.Scanner запоминает первую ошибку обнаруженную ошибку, и мы исправляем эту ошибку после выхода из цикла с помощью метода sc.Err().

Наконец, buffo.Scanner позаботится об обработке io.EOF и преобразует его в nil, если конец файла достигнут без возникновения ошибки.

Пример второй


Мой второй пример вдохновлен записью Errors are values в блоге Роба Пайкса.

При работе с открытием, записью и закрытием файлов обработка ошибок есть, но не очень впечатляющая, поскольку операции могут быть заключены в помощники, такие как ioutil.ReadFile и ioutil.WriteFile. Однако при работе с сетевыми протоколами низкого уровня часто возникает необходимость в построении ответа напрямую с использованием примитивов ввода-вывода, поэтому обработка ошибок может начать повторятся. Рассмотрим этот фрагмент HTTP-сервера, который создает ответ HTTP / 1.1:

type Header struct {
        Key, Value string
}

type Status struct {
        Code   int
        Reason string
}

func WriteResponse(w io.Writer, st Status, headers []Header, body io.Reader) error {
        _, err := fmt.Fprintf(w, "HTTP/1.1 %d %s\r\n", st.Code, st.Reason)
        if err != nil {
                return err
        }
        
        for _, h := range headers {
                _, err := fmt.Fprintf(w, "%s: %s\r\n", h.Key, h.Value)
                if err != nil {
                        return err
                }
        }

        if _, err := fmt.Fprint(w, "\r\n"); err != nil {
                return err
        } 

        _, err = io.Copy(w, body) 
        return err
}

Сначала мы создаем строку состояния, используя fmt.Fprintf, и проверяем ошибку. Затем для каждого заголовка мы записываем ключ и значение заголовка, каждый раз проверяя ошибку. Наконец, мы завершаем раздел заголовка дополнительным \r \n, проверяем ошибку и копируем тело ответа клиенту. Наконец, хотя нам не нужно проверять ошибку из io.Copy, нам нужно преобразовать ее из формы с двумя возвращаемыми значениями, которую io.Copy возвращает в одно возвращаемое значение, которое ожидает WriteResponse.

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

type errWriter struct {
        io.Writer
        err error
}

func (e *errWriter) Write(buf []byte) (int, error) {
        if e.err != nil {
                return 0, e.err
        }

        var n int
        n, e.err = e.Writer.Write(buf)
        return n, nil
}

errWriter выполняет контракт io.Writer, поэтому его можно использовать для переноса существующего io.Writer. errWriter передает записи своему базовому устройству записи до тех пор, пока не будет обнаружена ошибка. С этого момента он отбрасывает любые записи и возвращает предыдущую ошибку.

func WriteResponse(w io.Writer, st Status, headers []Header, body io.Reader) error {
        ew := &errWriter{Writer: w} 
        fmt.Fprintf(ew, "HTTP/1.1 %d %s\r\n", st.Code, st.Reason)

        for _, h := range headers {
                fmt.Fprintf(ew, "%s: %s\r\n", h.Key, h.Value)
        }

        fmt.Fprint(ew, "\r\n")
        io.Copy(ew, body)

        return ew.err
}

Применение errWriter к WriteResponse значительно улучшает ясность кода. Каждой из операций больше не нужно ограничивать себя проверкой ошибок. Сообщение об ошибке перемещается в конец функции, проверяя поле ew.err и избегая назойливого перевода возвращаемых значений io.Copy

Заключение


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

Об авторе


Автор данной статьи, Дейв Чини, является автором многих популярных пакетов для Go, например github.com/pkg/errors и github.com/davecheney/httpstat.

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


  1. acmnu
    18.02.2019 11:28

    Тут возникает приславутый вопрос zero cost abstractions. Насколько адекватно компилятор реагирует на подобные усложнения? Чисто теоретически все должно быть нормально, но на деле тот же Write во втором примере будет inline фукнцией или будет дополнительным вызовом с сохранением стека? Все же для такого низкоуровневого кода, каждый лишний call может быть критичен.


    1. Sly_tom_cat
      18.02.2019 18:12

      Скорее уж errWriter заинлайнится и там внутри получится примерно то же, что в изначальном коде. Но код с использованием errWriter гораздо легче читается.


      1. acmnu
        20.02.2019 08:58

        errWriter стуктура, она никуда инлайнится не будет. Я имел ввиду функцию


        func (e *errWriter) Write(buf []byte) (int, error)


        1. Sly_tom_cat
          20.02.2019 10:58

          я тоже имел в виду эту функцию.


  1. DmitriyTitov
    18.02.2019 14:03
    +1

    Смысл понятен, статья хорошая.
    Вопрос по сути: разве такое вот сокрытие ошибок не идёт в разрез с самой идеей Роба Пайка? Насколько я помню тот его тезис, он как раз говорил о том, что обрабатывать ошибки надо везде обычным образом, трактуя их просто как один из результатов вызова функции. Да и в целом любое сокрытие кода и введение дополнительных конструкций и абстракций как по мне не очень-то «the way to Go». Как считаете?


    1. zuborg
      18.02.2019 14:46

      Нет ошибки в том, чтобы исправить свою ошибку…
      Это я про то, что way of Go можно подкорректировать, если есть way получше )

      Задача любого языка — предоставить программисту хороший инструмент для реализации его (программиста) идей. Понятие «хороший», имхо, включает в себя и лаконичность — когда алгоритм записывается кратко и четко, без необходимости излишне углубляться в низкоуровневые детали, как выделение/освобождение памяти, управление блокировками либо постоянная обработка ошибок на каждом! шагу (наш случай). И если с управлением памятью и многопоточностью у Go получилось вполне неплохо, то обработке ошибок в первой версии Go уделили все-таки недостаточно внимания.
      Так что очень хорошо, что этот вопрос не теряет в актуальности и активно прорабатывается.


      1. DmitriyTitov
        18.02.2019 15:05

        Я не увидел сразу, что это перевод м-ра Чейни :) Это многое объясняет, у него уже давно есть какая-то тактика, и он её старается придерживаться. Толковый мужик, безусловно.
        Хотя лично мне его взгляды не всегда близки, и понятность кода в Go очень нравится. Пускай даже с вот этими шаблонами для обработки ошибок.
        Впрочем, разработчики языка скорее на Вашей с Дейвом стороне, и свою недоработку при выборе метода обработки ошибок они признают. Подождём версии 1.13 или 2.0.


        1. r3code Автор
          18.02.2019 21:30

          Я добавил последний раздел «Об авторе», чтобы люди сразу понимали про перевод )


  1. alexesDev
    18.02.2019 14:09
    +1

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


    1. DmitriyTitov
      18.02.2019 15:09

      Кстати говоря, да! Очень резонное замечание. Как сказали бы шахматисты: «идея опровергнута».


  1. KirEv
    18.02.2019 20:50

    предложенные вариенты добавляют не ясности в код, а магию…

    ошибки нужно контролировать… и в идеале — лучше это делать (контроль над ошибками) непосредственно в созданной функции, а не где-то там в десятой по счету под слоями абстракций… это и ясность кода, и логики, и легче модифицировать\отлаживать.

    а вообще, мне странно воспринимать когда в ошибку пишут конец файла и т.п., почему тогда не сделать чтото типо:

    _, eof, err = br.ReadString('\n')


    где `eof` — булевый тып, говорит что достигнут конец файла.

    да, способ покажется избиточным (+1 переменная ради такого), но тогда `err != nill` когда и правда есть ошибка, по моему — так больше явности.


    1. Kechnshoug9
      18.02.2019 21:27

      по-моему, никакой магии, а только чуточка элегантности


    1. bat
      19.02.2019 10:38

      внутри это +1 операция чтения, чтобы понять а есть там что-то или нет, если не конец, то надо сохранить результат упреждающего чтения а соответственно при очередном вызове проверять было ли что-то прочитано заранее…
      сомнительное такое преимущество


  1. SergeySlukin
    18.02.2019 21:27

    В чем проблема написать в первом случае так?

    for {
                    _, err = br.ReadString('\n')
                    if err != nil {
                            if err == io.EOF {
                                break;
                            }
                          return 0, err
                    }
                   lines++
            }
    


    1. Kechnshoug9
      18.02.2019 21:29

      я думаю, ещё один уровень вложенности будет лишним


      1. SergeySlukin
        18.02.2019 21:44

        Очень распространено данное решение, да и вполне логично как мне кажется.


        1. Kechnshoug9
          18.02.2019 22:32

          Тогда лучше через switch-case