Статьи

Неудачное моделирование на Цейлоне

Во всех языках программирования нам нужно иметь дело с операциями, которые могут «потерпеть неудачу»:

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

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

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

  1. Ошибка может быть указана через возвращаемые значения: код ошибки, null , тип объединения или тип суммы (перечисляемый) , например, Option / Maybe или Either .
  2. Сбои могут сигнализироваться и обрабатываться в рамках какого-либо исключения или средства «паники».

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

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

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

Типы неудач

Итак, какие возможности должен предложить язык для неудачи моделирования? Коды возврата или исключения? Типичный или нет? Чтобы получить частичный ответ на этот вопрос, давайте начнем со следующей классификации «отказов»:

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

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

Обработка исправимых сбоев

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

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

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

1
JsonObject|ParseError parseJson() => ... ;

Или, если кажется, что ParseError несет никакой полезной информации, я мог бы просто использовать вместо этого Null :

1
JsonObject? parseJson() => ... ;

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

1
2
3
4
5
interface ParseResult of ParseSuccessful | ParseError {}
class ParseSuccessful(shared JsonObject result) satisfies ParseResult {}
class ParseError(shared String message) satisfies ParseResult {}
 
ParseResult parseJson() => ... ;

Однако на Цейлоне это обычно не требуется.

Обработка неисправимых сбоев

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

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

В случае сомнений

Но подождите, вы, наверное, думаете, разве я не оставил здесь большой вопрос?

А как насчет отказа, который не попадает чисто в «восстанавливаемый» или «невосстановимый»?

Не существует ли там огромной серой области, заполненной сбоями, которые иногда можно исправить с помощью кода, вызывающего немедленный вызов?

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

Рассмотрим нашу parseJson() выше. Синтаксическая ошибка в данном тексте JSON может легко быть результатом ошибки в нашей программе, но, что важно, это не ошибка самого parseJson() . Код, который знает, является ли это программной ошибкой или чем-то еще, является вызывающим кодом, а не parseJson() .

И вызывающему коду всегда легко превратить восстанавливаемый сбой в необратимый сбой. Например:

1
2
assert (is JsonObject result = parseJson(json));
//result must be a JsonObject here

Или же:

1
2
3
4
5
value result = parseJson(json);
if (is ParseError result) {
    throw AssertionError(result.message);
}
//result must be a JsonObject here

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

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

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

Три вещи для рассмотрения

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

AssertionError чрезмерно используется?

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

Мои ответы были бы да, да и да. Но, возможно, это подразумевает, что было ошибкой следовать Java, делая AssertionError Error вместо простого Exception . (Это приводит к более широким дебатам о роли Error .)

Null чрезмерно используется?

Во-вторых, класс Null — это соблазнительно удобный способ представления ошибок функций, которые возвращают значение. Но злоупотребляем ли мы этим? Было бы лучше сделать тип возврата Map<Key,Item>.get() равным Item|NoItem<Key> вместо гораздо более универсального типа Item? то есть Item|Null ?

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

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

Функции без полезного возвращаемого значения

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

Или, в качестве альтернативы, должен ли язык предложить какой-либо способ заставить вызывающую функцию сделать что-то с возвращаемым значением не void функции?

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

Вывод

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