Стратегии обработки ошибок

Введение

Есть разные способы обработки ошибок в программировании. Это включает в себя отказ от обращения с ним. Многие языки создали более современные способы обработки ошибок. Ошибка - это когда программа умышленно, но чаще всего не выходит из строя. Ниже я расскажу о 4 основных, которые я знаю: try / catch, явное возвращение или управление сбоями. Мы сравним различные языки по их подходу к ошибкам: Python, JavaScript, Lua, Go, Scala, Akka и Elixir. Как только вы поймете, как работают новые способы, надеюсь, это побудит вас отказаться от использования потенциально аварийных программных ошибок через датированный _1 _ / _ 2_ в ваших программах.

Почему даже забота?

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

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

Бросить, попробовать, поймать, наконец

Первоначальный способ реализации стратегии обработки ошибок - это выбросить свои собственные ошибки.

// a type example
validNumber = n => _.isNumber(n) && _.isNaN(n) === false;
add = (a, b) => {
   if(validNumber(a) === false) {
     throw new Error(`a is an invalid number, you sent: ${a}`);
   }
   if(validNumber(b) === false) {
     throw new Error(`b is an invalid number, you sent: ${b}`);
   }
   return a + b;
};
add('cow', new Date()); // throws

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

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

Мы не хотим разрушать часть кода или целые программы.

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

Явный возврат

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

Примеры явного возврата на различных языках

Функции Lua будут вызывать ошибки, как Python и JavaScript. Однако, используя функцию, называемую защищенным вызовом, pcall она перехватит исключение как часть второго возвращаемого значения:

function datBoom()
  error({reason='kapow'})
end
ok, error = pcall(datBoom)
print("did it work?", ok, "error reason:", error.reason)
-- did it work? false, error: kapow

В Go встроены следующие функции:

func datBoom() (err error)
ok, err := datBoom()
if err != nil {
  log.Fatal(err)
}

… Как и Elixir (с возможностью отказаться с помощью символа! В конце вызова функции):

def datBoom do
  {:error, "kapow"}
end
{:error, reason} = datBoom()
IO.puts "Error: #{reason}" ## kapow

Хотя Python и JavaScript не имеют этих возможностей, встроенных в язык, вы можете легко их добавить.

Python может делать то же самое, используя кортежи:

def datBoom():
  return (False, 'kapow')
ok, error = datBoom()
print("ok:", ok, "error:", error) # ('ok:', False, 'error:', 'kapow')

JavaScript может сделать то же самое с деструктуризацией объекта:

const datBoom = () => ({ok: false, error: 'kapow'});
const {ok, error} = datBoom();
console.log("ok:", ok, "error:", error); // ok: false error: kapow

Влияние на кодирование

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

Во-вторых, вы знаете, ГДЕ произошла ошибка (в основном). «Почему» все еще остается предметом споров.

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

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

Минусы явного возврата

Исключая особенности языка (например, Go panics, JavaScript async / await), вам нужно посмотреть в 2–3 местах, чтобы увидеть, что пошло не так. Это один из аргументов против обратных вызовов Node. Люди говорят не использовать throw для потока управления, но все, что вы сделали, это создали надежную ok переменную. Конечно, это положительный шаг, но все же не очень полезный скачок. Ошибки, если они обнаруживаются, формируют поток вашего кода.

Например, давайте попробуем разобрать какой-нибудь JSON в JavaScript. Вы увидите, что отсутствие try/catch заменено на if(ok === false):

const parseJSON = string => {
  try {
    const data = JSON.parse(string);
    return {ok: true, data};
  } catch(error) {
    return {ok: false, error};
  }
};
const {ok, error, data} = parseJSON(new Date());
if(ok === false) {
  console.log("failed:", error);
} else {
  console.log("worked:", data);
}

Либо, влево или вправо, и сопоставление с образцом

Любой тип

Функции, которые могут возвращать 2 типа значений, решаются в функциональном программировании с использованием типа Either, также известного как несвязное объединение. Typescript (строго типизированный язык и компилятор для JavaScript) поддерживает псевдо Either в качестве алгебраического типа данных (также известного как ADT).

Например, эта функция TypeScript getPerson вернет Error или Person, и ваш компилятор поможет вам в этом:

// Notice TypeScript allows you to say 2 possible return values
function getPerson() : Error | Person

getPerson вернет либо Error, либо Person, но не то и другое одновременно.

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

Условные обозначения следующие: «Право правильно» и «Левое неверно» (Правое правильно, левое неверно).

Многие языки уже поддерживают это в той или иной форме:

JavaScript через Promises как значения: .then справа, .catch слева) и Python через отложенные значения через Twisted сетевой движок: addCallback справа, addErrback слева.

Либо Примеры

Вы можете сделать это с помощью класса или объекта в Python и JavaScript. Мы уже показали вам версию объекта выше, используя {ok: true, data} для правой и {ok: false, error} для левой.

Вот пример объектно-ориентированного JavaScript:

class Either {
  constructor(right=undefined, left=undefined) {
    this._right = right;
    this._left = left;
  }
  isLeft() {
    return this.left !== undefined;
  }
  isRight() {
    return this.right !== undefined;
  }
  get left() {
    return this._left;
  }
  get right() {
    return this._right;
  }
}
const datBoom = () => new Either(undefined, new Error('kapow'));
const result = datBoom();
if(result.isLeft()) {
  console.log("error:", result.left);
} else {
  console.log("data:", result.right);
}

… Но вы, вероятно, уже можете видеть, что Promise - гораздо лучший тип данных (несмотря на то, что он подразумевает асинхронность). Это неизменное значение, и методы then и catch уже изначально доступны для вас. Кроме того, независимо от того, сколько then или «прав», один оставшийся может испортить всю связку, и все это перетекает вниз по одной catch функции за вас. Именно здесь создание Eithers (в данном случае Promises) настолько мощно и полезно.

const datBoom = () => Promise.reject('kapow');
const result = datBoom();
result
.then(data => console.log("data:", data))
.catch(error => console.log("error:", error));

Соответствие шаблону

Однако независимо от того, является ли он синхронным или нет, существует более эффективный способ сопоставления типов Either ‘esque с помощью сопоставления с образцом. Если вы разработчик ООП, подумайте о замене:

if ( thingA instanceof ClassA ) {

с участием:

ClassA: ()=> "it's ClassA",
ClassB: ()=> "it's ClassB"
.

Это похоже на switch и case для типов.

Elixir делает это почти со всеми своими функциями (_ является традиционным ключевым словом default):

case datBoom do
  {:ok, data}      -> IO.puts "Success: #{data}"
  {:error, reason} -> IO.puts "Error: #{reason}"
  _                -> IO.puts "No clue, brah..."
end

В JavaScript вы можете использовать Библиотеку сказок.

const datBoom = () => Result.Error('kapow');
const result = datBoom();
const weGood = result.matchWith({
  Error: ({value}) => "negative...",
  Ok: ({value}) => "OH YEAH!"
});
console.log("weGood:", weGood); // negative...

Python имеет сопоставление с образцом с Hask:

def datBoom():
  return Left('kapow')
def weGood(value):
    return ~(caseof(value)
                | m(Left(m.n)) >> "negative..."
                | m(Right(m.n)) >> "OH YEAH!")
result = datBoom()
print("weGood:", weGood(result)) # negative...

Scala делает то же самое, больше похоже на традиционный оператор switch:

def weGood(value: Either): String = value match {
  case Left => "negative..."
  case Right => "OH YEAH!"
  case _ => "no clue, brah..."
}
weGood(Left('kapow'))  // negative...

Let It Crash

Математики придумали Either. Три крутых ката в Ericsson в 1986 году придумали другую стратегию в Erlang: пусть он рухнет. Позже, в 2009 году, Akka взяла ту же идею для виртуальной машины Java на Scala и Java.

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

Это также становится возможным только в том случае, если вы выполняете такую ​​работу, при которой время безотказной работы с большим объемом трафика является целью номер один. Erlang (или Elixir) создает процессы для управления вашим кодом. Если вы знаете Redux или Elm, концепцию хранилища для хранения ваших (в основном) неизменяемых данных, тогда вы поймете концепцию процесса в Elixir и актера в Akka. Вы создаете процесс, и он запускает ваш код.

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

Итак, они создали супервизоры Эликсир | Скала. Вместо создания 1 процесса, который запускает ваш код, он создает 2: один для запуска вашего кода, а другой для наблюдения за ним в случае сбоя, чтобы перезапустить новый. Эти процессы очень легкие (0,5 КБ памяти в Elixir, 0,3 КБ в Akka).

Хотя в Elixir есть поддержка попытаться, поймать и поднять, обработка ошибок в Erlang / Elixir - это запах кода. Пусть произойдет сбой, супервизор перезапустит процесс, вы можете отладить код, загрузить новый код на работающий сервер, и процессы, порожденные с этого момента, будут использовать ваш новый код. Это похоже на движение неизменной инфраструктуры вокруг Docker в Amazon’s EC2 Container Service и Kubernetes.

Выводы

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

В следующий раз, когда вы подумаете об использовании throw, попробуйте выполнить явный return или Either. Затем выполните модульное тестирование. Сделайте так, чтобы он возвращал ошибку в более крупной программе, и посмотрите, будет ли вам легче ее найти, учитывая, что вы являетесь тем, кто ее вызвал. Я думаю, вы обнаружите явные возвраты или Eithers, которые легче отлаживать, проще проводить модульное тестирование и могут привести к созданию более продуманных приложений.