Data Science

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

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

Примеры выполняемого кода, рассматриваемые в статье, являются знакомой нам структурой данных стека. Их реализация представлена в Java, но и в других современных объектно-ориентированных языках она будет похожей. Если у вас возникнет желание поэкспериментировать с этими примерами, можете обратиться к исходному коду в моем репозитории на GitHub (@maxipaxi). В нем каждый способ реализован как уровеньповерх самой продвинутой версии. 

Принято выделять два вида ошибок в стеке: 1) (pop) удаление значения из пустого стека (опустошение); 2) (push) добавление элемента в переполненный стек (переполнение). В нашей реализации у каждого элемента есть указатель на следующий, что исключает вероятность ошибки второго типа. Поэтому мы сосредоточимся на pop. Чтобы не усложнять, предположим, что pop только удаляет элемент без его возврата, так как мы можем использовать peek для просмотра стека. 

Коды ошибок 

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

Пример подобного случая: 

boolean pop() {
  if (isEmpty()) {
    return false;
  } else {
    advanceHeadPointer();
    return true;
  }
}

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

Исключения 

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

void pop() {
  if (isEmpty()) {
    throw new EmptyStackException();
  } else {
    advanceHeadPointer();
  }
}

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

Блокировка 

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

Stack() {
  available = new Semaphore(0);
}

void push(T element) {
  putElement(T);
  available.release();
}

void pop() {
  available.acquireUninterruptibly();
  advanceHeadPointer();
}

T peek() {
  available.acquireUninterruptibly();
  available.release();
  return head.element;
}

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

Типизация 

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

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

Эти стеки находятся в одном из двух состояний: 

  1. Первое состояние, в котором нам известен некий инвариант, например: 
  • данный стек содержит X элементов; 

или 

  • данный стек содержит по крайней мере элемент X. 

Это находит свое выражение в типе стека. Например, известный нам тип стека, содержащий точно два инварианта, будет таким:

NonEmptyStack<Integer,NonEmptyStack<Integer,EmptyStack<Integer>>>

В данном случае мы можем напрямую удалять элементы, не беспокоясь о каких-либо сбоях, поскольку компилятор нам это гарантирует!

2. Второе состояние, в котором нам ничего не известно о стеке. 

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

stack.apply(new Visitor<>() {
  public T apply(EmptyStack<T> s) {
    throw new EmptyStackException();
  }
  public <R extends Stack<T, R>> T apply(NonEmptyStack<T, R> s) {
    return s.peek;
  }
});

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

var st = new EmptyStack<Integer>()
        .push(3)
        .push(2)
var st2 = st.push(1);
Assertions.assertEquals(2, st.peek); // st остается без изменений
Assertions.assertEquals(1, st2.peek);

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

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

Заключение 

Мы рассмотрели 4 разных способа обработки ошибок: 

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

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

Читайте также:


Перевод статьи Christian Clausen: Four Models of Error Handling for Stacks