Ранее: 1 часть

Проверка ошибок

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

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

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

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

  • Публичные типы ошибок увеличивают площадь API пакета.

  • Новые реализации должны возвращать только типы, указанные в объявлении интерфейса, даже если они плохо подходят.

  • Тип ошибки не может быть изменён или объявлен устаревшим после введения без нарушения совместимости, что приводит к хрупкости API.

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

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

Утверждать ошибки для поведения, а не для типа

Не утверждать, что значение ошибки относится к определенному типу, а утверждать, что значение реализует определённое поведение.

Это предложение соответствует характеру has a неявных интерфейсов Go, а не характеру is a [subtype of] языков, основанных на наследовании. Рассмотрим следующий пример:

func isTimeout(err error) bool {
	type timeout interface {
		Timeout() bool
	}
	te, ok := err.(timeout)
	return ok && te.Timeout()
}

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

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

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

В заключение

Не утверждайте ошибки для типа, утверждайте для поведения.

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

Для пользователей пакетов, если вам необходимо проверить ошибку, используйте интерфейсы для утверждения ожидаемого поведения, а не типа ошибки. Не требуйте от авторов пакетов публичных типов ошибок; попросите их привести свои типы в соответствие с общими интерфейсами, снабдив их соответствующими методами Timeout() или Temporary().

Константные ошибки

Это мысленный эксперимент о значениях дозорных ошибок в Go.

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

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

fmt.Println(io.EOF == io.EOF) // true
x := io.EOF
fmt.Println(io.EOF == x)      // true

io.EOF = fmt.Errorf("whoops")
fmt.Println(io.EOF == io.EOF) // true
fmt.Println(x == io.EOF)      // false

Вторая проблема заключается в том, что io.EOF ведёт себя как синглтон, а не как константа. Даже если мы в точности повторим процедуру, используемую пакетом io для создания собственного значения EOF, они будут несопоставимы.

err := errors.New("EOF")   // io/io.go line 38
fmt.Println(io.EOF == err) // false

Объединив эти свойства, можно получить набор странных поведений, обусловленных тем, что значения ошибок в Go, которые традиционно создаются с помощью errors.New или fmt.Errorf, не являются константами.

Решение

Прежде чем я представлю своё решение, давайте вспомним, как работает интерфейс ошибок в Go. Любой тип, имеющий строковый метод Error(), соответствует интерфейсу ошибок. Сюда входят примитивные типы подобно string, в том числе и константные строки.

На этом фоне рассмотрим реализацию этой ошибки.

type Error string

func (e Error) Error() string { return string(e) }

Она похожа на реализацию errors.errorString, которая используется в errors.New. Однако в отличие от errors.errorString этот тип является константным выражением.

const err = Error("EOF")
const err2 = errorString{"EOF"} // const initializer errorString literal is not a constant

Поскольку константы типа Error не являются переменными, они неизменяемы.

const err = Error("EOF")
err = Error("not EOF") // error, cannot assign to err

Кроме того, две константы-строки всегда равны, если равно их содержимое, а значит, равны и два значения Error с одинаковым содержимым.

const err = Error("EOF")
fmt.Println(err == Error("EOF")) // true

По-другому говоря, равные значения Error являются одинаковыми, подобно тому как константа 1 является такой же, как и любая другая константа 1.

const eof = Error("eof")

type Reader struct{}

func (r *Reader) Read([]byte) (int, error) {
	return 0, eof
}

func main() {
	var r Reader
	_, err := r.Read([]byte{})
	fmt.Println(err == eof) // true
}

Можем ли мы изменить определение io.EOF на константу? Оказалось, что это прекрасно компилируется и проходит все тесты, но для контракта Go 1 это, скорее всего, натяжка.

Однако это не мешает вам использовать данную идиому в своем коде. Хотя в любом случае не стоит использовать дозорные ошибки.

Почему в Go правильно работают с исключениями

January, 2012

Как Go правильно использует исключения? За счёт того, что их вообще нет.

Сначала немного истории

До моего времени существовал язык C, и ошибки были вашей проблемой. В общем-то, это было нормально, потому что если вы владели винтажным мини-компьютером 70-х годов, то у вас наверняка была своя доля проблем. Поскольку C был языком с одним возвратом, все становилось немного сложнее, когда нужно было узнать результат функции, которая иногда могла пойти не так. Отличным примером этого является IO, или сокеты, но есть и более опасные случаи, например, преобразование строки в целочисленное значение. Для решения этой проблемы появилось несколько идиом. Например, если у вас есть функция, которая должна возиться с содержимым структуры, вы можете передать ей указатель на неё, а код возврата покажет, была ли эта работа успешной. Есть и другие идиомы, но я не программист на Си, да и не в этом суть статьи.

Затем появился C++, который рассматривал ситуацию с ошибками и пытался её улучшить. Если у вас была функция, которая выполняла какую-то работу, она могла вернуть значение или выбросить исключение, которое вы должны были поймать и обработать. Бам! Теперь программисты на C++ могут сигнализировать об ошибках без необходимости объединять их единственным возвращаемым значением. Ещё лучше то, что исключения можно обрабатывать в любом месте стека вызовов. Если вы не знаете, как обработать это исключение, оно попадет к тому, кто знает. Все неприятности с errno и потоками решены. Решение найдено!

Вроде бы.

Недостатком исключений в C++ является то, что вы не можете сказать (не имея исходного текста и стимула для проверки), может ли какая-либо вызываемая вами функция выбросить исключение. В дополнение к заботам об утечках ресурсов и деструкторах приходится заботиться о RAII и транзакционной семантике, чтобы гарантировать безопасность своих методов от исключений, если они находятся где-то в стеке вызовов в момент выброса исключения. Решив одну проблему, C++ создал другую.

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

Это примерно то время, когда я вступил в эту историю, начало тысячелетия, примерно Java 1.4. Тогда, как и сейчас, я был согласен с тем, что способ проверки исключений в Java более цивилизованный, более безопасный, чем способ в C++. Думаю, что я был не один такой. Поскольку исключения стали безопасными, разработчики начали исследовать их границы. Появились системы корутин, построенные с использованием исключений, и, по крайней мере, одна известная мне библиотека разбора XML использовала исключения в качестве техники управления потоком данных. Обычным явлением для устоявшихся Java-веб-приложений стало то, что при запуске они выдают на экран множество исключений, послушно регистрируемых вместе со стеком вызовов. Исключения в Java перестали быть исключительными, они стали обычным явлением. Они используются во всех случаях - от благотворных до катастрофических, причём дифференцировать степень серьёзности исключений приходится вызывающему функцию.

Если этого было недостаточно, то не все исключения Java проверяются, подклассы java.Error и java.RuntimeException не проверяются. Их не нужно объявлять, достаточно просто бросить. Возможно, это было хорошей идеей, нулевые ссылки и ошибки подмассивов массивов теперь просто реализовать во время выполнения, но в то же время, поскольку каждое исключение Java расширяет java.Exception, любой кусок кода может его поймать, даже если в этом нет особого смысла, что приводит к шаблонам типа

	catch (e Exception) { // ignore }

Таким образом, Java в основном решила проблему непроверяемых исключений в C++ и ввела целый ряд своих собственных. Однако я утверждаю, что Java не решила реальной проблемы, той проблемы, которую не решил и C++. Проблема того, как сигнализировать вызывающему функцию пользователю о том, что что-то пошло не так.

Решение Go

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

В Go есть функция, называемая panic, и если сильно прищуриться, то можно подумать, что panic - это то же самое, что и throw, но вы ошибётесь. Когда вы бросаете исключение, вы делаете его проблемой вызывающей стороны

	throw new SomeoneElsesProblem();

Например, в C++ вы можете бросить исключение, когда не можете преобразовать перечисление в его строковый эквивалент, или в Java при разборе даты из строки. В мире, подключённом к Интернету, где каждый входной сигнал из сети должен рассматриваться как враждебный, является ли неспособность разобрать строку на дату действительно исключительной? Конечно, нет.

Когда вы паникуете в Go, вы сходите с ума, это не чья-то проблема, это конец игры.

	panic("inconceivable")

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

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


November, 2014

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

Java всесторонне продемонстрировала, что проверяемые исключения (фактически, наличие как проверяемых, так и непроверяемых исключений) стали катастрофой для развития языка.

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

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

Исключения в C++ по-прежнему так же трудно использовать безопасно, как и три десятилетия назад. Когда любая часть стека вызовов может взорваться без предупреждения. Неудивительно, что многие разработчики C++ предписывают не использовать исключения.

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

Два момента

Первый - это наблюдение Роба Пайка, сделанное им на GopherCon 2014.

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

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

Второе, на что я наткнулся почти через год после своего первого сообщения, - это презентация Андрея Александреску, в которой он отмечает (примерно на 11-й минуте):

... код с исключениями безнадежно последовательный. В полёте есть только одно исключение, как это ни причудливо. В любой момент полёта может быть только одно исключение. ... [они] требуют немедленного и исключительного внимания. [Исключение] выходит на первый план, вы должны справиться с ним прямо сейчас".

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

Рассмотрим этот простой пример, который использует содержимое io.Reader.

func ReadAll(r io.Reader) ([]byte, error) {
	var buf = make([]byte, 1024)
	var result []byte
	for {
		n, err := r.Read(buf)
		result = append(result, buf[:n]...)
		if err == io.EOF {
			return result, nil
		}
		if err != nil {
			return nil, err
		}
	}
}

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

Заключение

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

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


January, 2015

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

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

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

Язык, который мы имеем

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

Простая проблема

Начнём обсуждение с придуманной, но очень простой функции, которая демонстрирует необходимость обработки ошибок.

package main

import "fmt"

// Positive returns true if the number is positive, false if it is negative.
func Positive(n int) bool {
	return n > -1
}

func Check(n int) {
	if Positive(n) {
		fmt.Println(n, "is positive")
	} else {
		fmt.Println(n, "is negative")
	}
}

func main() {
	Check(1)
	Check(0)
	Check(-1)
}

Если выполнить этот код, то получим следующий результат

	1 is positive
	0 is positive
	-1 is negative

что неверно.

Как эта однострочная функция может быть ошибочной? Она неверна, потому что ноль не является ни положительным, ни отрицательным, и это не может быть точно отражено возвращаемым значением булевой функции Positive.

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

Предусловия

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

// Positive returns true if the number is positive, false if it is negative.
// The second return value indicates if the result is valid, which in the case
// of n == 0, is not valid.
func Positive(n int) (bool, bool) {
	if n == 0 {
		return false, false
	}
	return n > -1, true
}

func Check(n int) {
	pos, ok := Positive(n)
	if !ok {
		fmt.Println(n, "is neither")
		return
	}
	if pos {
		fmt.Println(n, "is positive")
	} else {
		fmt.Println(n, "is negative")
	}
}

Запустив эту программу, мы видим, что ошибка исправлена,

	1 is positive
	0 is neither
	-1 is negative

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

Это базовый вариант для сравнения с другими решениями.

Ошибка

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

// Positive returns true if the number is positive, false if it is negative.
func Positive(n int) (bool, error) {
	if n == 0 {
		return false, errors.New("undefined")
	}
	return n > -1, nil
}

func Check(n int) {
	pos, err := Positive(n)
	if err != nil {
		fmt.Println(n, err)
		return
	}
	if pos {
		fmt.Println(n, "is positive")
	} else {
		fmt.Println(n, "is negative")
	}
}

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

Это лишний раз подчеркивает гибкость методологии "ошибки - значения" в Go. Когда возникает ошибка, указывающая только на успех или неудачу (вспомните форму поиска карты с двумя результатами), вместо интерфейсного значения можно подставить булево, что устраняет путаницу, возникающую из-за типизированных nils и nilness интерфейсных значений.

Больше булевых состояний

Приведем пример, позволяющий возвращать три состояния: true, false и nil (у тех, кто знаком с теорией множеств или SQL, на этом месте передергивает).

// If the result not nil, the result is true if the number is
// positive, false if it is negative.
func Positive(n int) *bool {
	if n == 0 {
		return nil
	}
	r := n > -1
	return &r
}

func Check(n int) {
	pos := Positive(n)
	if pos == nil {
		fmt.Println(n, "is neither")
		return
	}
	if *pos {
		fmt.Println(n, "is positive")
	} else {
		fmt.Println(n, "is negative")
	}
}

В Positive появилась ещё одна строка, поскольку требуется перехватить адрес результата сравнения.

Хуже того, теперь, прежде чем возвращённое значение может быть использовано где-либо, оно должно быть проверено на то, что указывает на корректный адрес. Именно с такой ситуацией постоянно сталкиваются Java-разработчики, что приводит к глубокой ненависти к nil (с полным основанием). Очевидно, что такое решение не является жизнеспособным.

Давайте попробуем паниковать

Для полноты картины рассмотрим версию этого кода, которая пытается имитировать исключения с помощью panic.

// Positive returns true if the number is positive, false if it is negative.
// In the case that n is 0, Positive will panic.
func Positive(n int) bool {
	if n == 0 {
		panic("undefined")
	}
	return n > -1
}

func Check(n int) {
	defer func() {
		if recover() != nil {
			fmt.Println("is neither")
		}
	}()
	if Positive(n) {
		fmt.Println(n, "is positive")
	} else {
		fmt.Println(n, "is negative")
	}
}

... всё становится только хуже.

Не исключительные случаи

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

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

Забывая проверять

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

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

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

Заключение

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

Задача

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

Устранение обработки ошибок за счёт устранения ошибок

Go 2 стремится улучшить накладные расходы на обработку ошибок, но знаете ли вы, что лучше, чем улучшенный синтаксис для обработки ошибок? Отсутствие необходимости обрабатывать ошибки вообще. Я не говорю "удалить код обработки ошибок", я предлагаю изменить код таким образом, чтобы в нём не было такого количества ошибок, которые нужно обрабатывать.

Эта заметка написана на основе главы из книги Джона Оустерхаута "Философия проектирования программного обеспечения", озаглавленной "Избавление от ошибок" (Define Errors Out of Existence). Я попытаюсь применить его совет к 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.


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

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


  1. acmnu
    09.09.2023 12:55
    +6

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

    Да, подход C++ и Java (довольно старые языки), вызывает проблемы, но и подход Go тоже с сюрпризами. Почему-то, в этих статьях от "больших" специалистов полностью игнорируются такие вещи как Option типы и связанные с этим макросы, которые распространены в других языках типа Haskell, Rust, Kotlin.

    Причина такой "избирательности" в банальной старости авторов языка: они не знают ничего за пределами C/C++ похоже.


    1. dsh2dsh
      09.09.2023 12:55
      +2

      Точно. И их там таких легион. Им пишут, что они таким решением наоборот провоцируют игнорировать ошибки, т.к. в реальном коде все задолбались писать после каждой строчки ещё три if err != nil и так раз за разом, всего лишь для того, что бы эту ошибку передать на уровень выше, а они в ответ: а исключения зато все игнорируют.


      1. itmind
        09.09.2023 12:55

        В тех же Java, Kotlin, C# нужно так же чуть ли ни каждую строчку оборачивать в try-catch

        Вот пример для Kotlin:

        import java.io.IOException
        import java.nio.file.Files
        import java.nio.file.Paths
         
        fun main() {
            val fileName = "/home/data/file.txt"
            try {
                val lines = Files.readAllLines(Paths.get(fileName))
                println(lines)
            } catch (e: IOException) {
                e.printStackTrace()
            }
        }

        А то, что не обернуто в try-catch, нужно проверить на null. Пример:

        companydb = getCustomer(name, inn);
        if (companydb == null) {}

        Получается в Go много "if err != nil", а в Java/C# много if ( p != null ).

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

        И проверки на null после каждого вызова функций никого не смущает.

        Мне тоже не нравится, что в Go код "замусоривается" проверками ошибок, но прочитав статью, понял, что и механизм исключений не лучше. Получается лучший вариант, это вариант описанный в разделе "Устранение обработки ошибок за счёт устранения ошибок"


        1. dsh2dsh
          09.09.2023 12:55

          Не-не, вы не совсем правильно это поняли. Я не знаю Яву, но предполагаю, что если в примере не обернуть вызов в try, то выполнение функции будет прекращено и исключение пойдет на уровень выше. Мне очень часто именно это и нужно. Т.е. мне не нужно обрабатывать ошибку в этой функции. Мне нужно только сообщить об ошибке туда, откуда эту функцию вызвали, а там тоже нужно только сообщить в вызывающую функцию и так 10 уровней вверх. И только на самом верхнем уровне может быть будет какая-то обработка этой ошибки. А может и не будет. Может просто вернёт какую-нибудь 500 ошибку, да в лог запишет. Но в Go так не получится сделать. В результате, просто что бы прекратить выполнение текущей функции и вернуть управление выше, нужны бесконечные if err != nil.


    1. mvgeny
      09.09.2023 12:55

      При чем тут Option? Он не про работу с ошибками, это лишь индикатор того, что функция может вернуть или не вернуть значение. Ладно, предположим имелся в виду паттерн Either. 

      Наивно считать, что авторы языка «деды» и ничего не знают о ФП подходах. Подход к обработке ошибок в го - это и есть слегка изменённый вариант реализации Either. 

      Вы спросите: почему бы по умолчанию не использовать знакомый многим Either и не шатать мозги? Монады по-настоящему раскрываются только в случае если используются в связке с другими монадами. Го не может себе этого позволить, так как он не настолько функциональный. Иначе получится уродец, в которого насильно впихнули одну ФП фичу. Как итог авторы выбрали компромисс, на мой взгляд удачный. Отказаться от exception’ов и при этом не скатываться в трушную функциональщину.


      1. acmnu
        09.09.2023 12:55

        Наивно считать, что авторы языка «деды» и ничего не знают о ФП подходах.

        Да нет тут никакой наивности. Вспомните как они сделали go get и сравните это с rust, ruby или php. Ну видно же, что люди по-прежнему на autotools сидят. Или реализация Generic, которую выпрашивали миллион лет и которая осталась слегка недоделаной.

        Вы спросите: почему бы по умолчанию не использовать знакомый многим Either и не шатать мозги? Монады по-настоящему раскрываются только в случае если используются в связке с другими монадами.

        Не думаю, что надо весь ФП тянуть. Например Rust и без этого справляется. Точно нужны полноценные макросы и нормальная система типов. Не как в Haskell, конечно, но постараться можно было.


  1. antage
    09.09.2023 12:55
    -1

    Вот только натыкивание if err != nil { return err } - это по сути и есть размотка стека, как при исключениях, только вручную. То что раньше делал компилятор, теперь стало модно делать копипастным кодом.


  1. gravyzzap
    09.09.2023 12:55
    +2

    Исключения нельзя игнорировать без того, что бы программа не упала. Ошибки в go – можно. Что и приходится иногда делать для читаемости.

    То, что ошибки в го явные – это очень хорошо. Всё остальное плохо, и в этом проблема.

    Пример с `errWriter` типичный паттерн в го, чтобы спасти основную логику от исчезновения в тумане из `if err != nil { return err }`. Но, мало того, что он делает обработку ошибок неявной, очень ограниченно применим, и часто всё равно малоэффективен, он ещё и приносит проблему, которая на первый взгляд не очень заметна. В процессе эволюции программы, подготовка данных для errWriter.Write можеть стать дороже, и тогда мы хотим выходить по первой же ошибке, а не впустую жрать процессор. Надо не забыть это сделать и отказаться от errWriter.


    1. dsh2dsh
      09.09.2023 12:55
      +1

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


      1. comerc Автор
        09.09.2023 12:55

        Почему хак? Красота! Язык фактически принуждает тебя к этому решению.


    1. comerc Автор
      09.09.2023 12:55

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

      panic нельзя игнорировать без того, что бы программа не упала.