Книга: Чистый код: создание, анализ и рефакторинг. Библиотека программиста
Назад: 6. Объекты и структуры данных
Дальше: 8. Границы

7. Обработка ошибок

Майкл Физерс

07_01.tif 

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

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

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

Используйте исключения  вместо кодов ошибок

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

Листинг 7.1. DeviceController.java

public class DeviceController {

  ...

  public void sendShutDown() {

    DeviceHandle handle = getHandle(DEV1);

    // Проверить состояние устройства

    if (handle != DeviceHandle.INVALID) {

      // Сохранить состояние устройства в поле записи

      retrieveDeviceRecord(handle);

      // Если устройство не приостановлено, отключить его

      if (record.getStatus() != DEVICE_SUSPENDED) {

        pauseDevice(handle);

        clearDeviceWorkQueue(handle);

        closeDevice(handle);

      } else {

        logger.log("Device suspended.  Unable to shut down");

      }

    } else {

      logger.log("Invalid handle for: " + DEV1.toString());

    }

  }

  ...

}

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

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

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

Листинг 7.2. DeviceController.java (с исключениями)

public class DeviceController {

  ...

 

  public void sendShutDown() {

    try {

      tryToShutDown();

    } catch (DeviceShutDownError e) {

      logger.log(e);

    }

  }

 

  private void tryToShutDown() throws DeviceShutDownError {

    DeviceHandle handle = getHandle(DEV1);

    DeviceRecord record = retrieveDeviceRecord(handle);

 

    pauseDevice(handle);

    clearDeviceWorkQueue(handle);

    closeDevice(handle);

  }

 

  private DeviceHandle getHandle(DeviceID id) {

    ...

    throw new DeviceShutDownError("Invalid handle for: " + id.toString());

    ...

  }

 

  ...

}

Начните с написания команды  try-catch-finally

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

Блоки try в каком-то отношении напоминают транзакции. Секция catch должна оставить программу в целостном состоянии, что бы и произошло в секции try. По этой причине написание кода, который может инициировать исключения, рекомендуется начинать с конструкции try-catch-finally. Это поможет вам определить, чего должен ожидать пользователь кода, что бы ни произошло в коде try.

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

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

@Test(expected = StorageException.class)

public void retrieveSectionShouldThrowOnInvalidFileName() {

  sectionStore.retrieveSection("invalid - file");

}

Для теста необходимо создать следующую программную заглушку:

public List<RecordedGrip> retrieveSection(String sectionName) {

  // Пусто, пока не появится реальная реализация

  return new ArrayList<RecordedGrip>();

}

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

public List<RecordedGrip> retrieveSection(String sectionName) {

  try {

    FileInputStream stream = new FileInputStream(sectionName)

  } catch (Exception e) {

    throw new StorageException("retrieval error", e);

  }

  return new ArrayList<RecordedGrip>();

}

Теперь тест проходит успешно, потому что мы перехватили исключение. На этой стадии можно переработать код. Тип перехватываемого исключения сужается до типа, реально инициируемого конструктором FileInputStream, то есть FileNotFoundException:

public List<RecordedGrip> retrieveSection(String sectionName) {

  try {

    FileInputStream stream = new FileInputStream(sectionName);

    stream.close();

  } catch (FileNotFoundException e) {

    throw new StorageException("retrieval error", e);

  }

  return new ArrayList<RecordedGrip>();

}

Определив область видимости при помощи структуры try-catch, мы можем использовать методологию TDD для построения остальной необходимой логики. Эта логика размещается между созданием FileInputStream и закрытием, а в ее коде можно считать, что все операции были выполнены без ошибок.

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

Используйте непроверяемые исключения

Время споров прошло. Java-программисты годами обсуждали преимущества и недостатки проверяемых исключений (checked exceptions). Когда проверяемые исключения появились в первой версии Java, всем казалось, что это отличная идея. В сигнатуре каждого метода должны быть перечислены все исключения, которые могут передаваться вызывающей стороне. Фактически исключения становились частью типа метода. Если сигнатура не соответствовала тому, что происходит в коде, то программа просто не компилировалась.

В то время мы с энтузиазмом относились к проверяемым исключениям; в самом деле, они бывают полезными. Но сейчас стало ясно, что они не являются необходимыми для создания надежных программ. В C# нет проверяемых исключений, и несмотря на все доблестные попытки, в C++ они так и не появились. Их также нет в Python и Ruby. Тем не менее на всех этих языках можно писать надежные программы. А раз так, нам приходится решать, оправдывают ли проверяемые исключения ту цену, которую за них приходится платить.

Какую цену, спросите вы? Цена проверяемых исключений — нарушение принципа открытости/закрытости [Martin]. Если вы инициируете проверяемое исключение из метода своего кода, а catch находится тремя уровнями выше, то это исключение должно быть объявлено в сигнатурах всех методов между вашим методом и catch. Следовательно, изменение на низком уровне программного продукта приводит к изменениям сигнатур на многих более высоких уровнях. Измененные модули приходится строить и развертывать заново, притом что в программе не изменилось ничего, что было бы существенно для них.

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

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

Передавайте контекст с исключениями

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

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

Определяйте классы исключений в контексте потребностей  вызывающей стороны

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

Рассмотрим пример неудачной классификации исключений. Далее приводится конструкция try-catch-finally для сторонней библиотечной функции. Она учитывает все исключения, которые могут быть инициированы при вызовах:

ACMEPort port = new ACMEPort(12);

 

try {

  port.open();

} catch (DeviceResponseException e) {

  reportPortError(e);

  logger.log("Device response exception", e);

} catch (ATM1212UnlockedException e) {

  reportPortError(e);

  logger.log("Unlock exception", e);

} catch (GMXError e) {

  reportPortError(e);

  logger.log("Device response exception");

} finally {

  …

}

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

  LocalPort port = new LocalPort(12);

  try {

    port.open();

  } catch (PortDeviceFailure e) {

    reportError(e);

    logger.log(e.getMessage(), e);

  } finally {

    …

  }

Класс LocalPort представляет собой простую обертку, которая перехватывает и преобразует исключения, инициированные классом ACMEPort:

public class LocalPort {

  private ACMEPort innerPort;

 

  public LocalPort(int portNumber) {

    innerPort = new ACMEPort(portNumber);

  }

 

  public void open() {

    try {

      innerPort.open();

    } catch (DeviceResponseException e) {

      throw new PortDeviceFailure(e);

    } catch (ATM1212UnlockedException e) {

      throw new PortDeviceFailure(e);

    } catch (GMXError e) {

      throw new PortDeviceFailure(e);

    }

  }

  …

}

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

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

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

Определите нормальный путь выполнения

07_02.tif

Выполнение рекомендаций из преды-

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

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

try {

  MealExpenses expenses = expenseReportDAO.getMeals(employee.getID());

  m_total += expenses.getTotal();

} catch(MealExpensesNotFound e) {

  m_total += getMealPerDiem();

}

Если работник предъявил счет по затратам на питание, то сумма включается в общий итог. Если счет отсутствует, то работнику за этот день начисляется определенная сумма. Исключение загромождает логику программы. А если бы удалось обойтись без обработки особого случая? Это позволило бы заметно упростить код:

MealExpenses expenses = expenseReportDAO.getMeals(employee.getID());

m_total += expenses.getTotal();

Можно ли упростить код до такой формы? Оказывается, можно. Мы можем изменить класс ExpenseReportDAO, чтобы он всегда возвращал объект MealExpense. При отсутствии предъявленного счета возвращается объект MealExpense, у которого в качестве затрат указана стандартная сумма, начисляемая за день:

public class PerDiemMealExpenses implements MealExpenses {

  public int getTotal() {

    // Вернуть стандартные ежедневные затраты на питание

  }

}

Такое решение представляет собой реализацию паттерна ОСОБЫЙ СЛУЧАЙ [Fowler]. Программист создает класс или настраивает объект так, чтобы он обрабатывал особый случай за него. Это позволяет избежать обработки исключительного поведения в клиентском коде. Все необходимое поведение инкапсулируется в объекте особого случая.

Не возвращайте null

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

public void registerItem(Item item) {

  if (item != null) {

    ItemRegistry registry = persistentStore.getItemRegistry();

    if (registry != null) {

      Item existing = registry.getItem(item.getID());

      if (existing.getBillingPeriod().hasRetailOwner()) {

        existing.register(item);

      }

    }

  }

}

Если ваша кодовая база содержит подобный код, возможно, вы не видите в нем ничего плохого, но это не так! Возвращая null, мы фактически создаем для себя лишнюю работу, а для вызывающей стороны — лишние проблемы. Стоит пропустить всего одну проверку null, и приложение «уходит в штопор».

А вы заметили, что во второй строке вложенной команды if проверка null отсутствует? Что произойдет во время выполнения, если значение persistentStore окажется равным null? Произойдет исключение NullPointerException; либо кто-то перехватит его на верхнем уровне, либо не перехватит. В обоих случаях все будет плохо. Как реагировать на исключение NullPointerException, возникшее где-то в глубинах вашего приложения?

Легко сказать, что проблемы в приведенном коде возникли из-за пропущенной проверки null. В действительности причина в другом: этих проверок слишком много. Если у вас возникает желание вернуть null из метода, рассмотрите возможность выдачи исключения или возвращения объекта «особого случая». Если ваш код вызывает метод стороннего API, способный вернуть null, создайте для него обертку в виде метода, который инициирует исключение или возвращает объект особого случая.

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

List<Employee> employees = getEmployees();

if (employees != null) {

  for(Employee e : employees) {

    totalPay += e.getPay();

  }

}

Сейчас метод getEmployees может возвращать null, но так ли это необходимо? Если изменить getEmployee так, чтобы метод возвращал пустой список, код станет чище:

List<Employee> employees = getEmployees();

for(Employee e : employees) {

  totalPay += e.getPay();

}

К счастью, в Java существует метод Collections.emptyList(), который возвращает заранее определенный неизменяемый список, и мы можем воспользоваться им для своих целей:

public List<Employee> getEmployees() {

  if( .. there are no employees .. )

    return Collections.emptyList();

}

Такое решение сводит к минимуму вероятность появления NullPointerException, а код становится намного чище.

Не передавайте null

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

Следующий пример поясняет, почему не следует передавать null. Возьмем простой метод для вычисления метрики по двум точкам:

public class MetricsCalculator

{

  public double xProjection(Point p1, Point p2) {

    return (p2.x — p1.x) * 1.5;

  }

  …

}

Что произойдет, если при вызове будет передан аргумент null?

calculator.xProjection(null, new Point(12, 13));

Конечно, возникнет исключение NullPointerException.

Как исправить его? Можно создать новый тип исключения и инициировать его в методе:

public class MetricsCalculator

{

  public double xProjection(Point p1, Point p2) {

    if (p1 == null || p2 == null) {

      throw InvalidArgumentException(

        "Invalid argument for MetricsCalculator.xProjection");

    }

    return (p2.x — p1.x) * 1.5;

  }

}

Стало лучше? Пожалуй, лучше, чем NullPointerException, но вспомните: для InvalidArgumentException приходится определять обработчик. Что должен делать этот обработчик? Возьметесь предложить хорошую идею?

Существует другая альтернатива: можно воспользоваться набором проверочных директив assert:

public class MetricsCalculator

{

  public double xProjection(Point p1, Point p2) {

    assert p1 != null : "p1 should not be null";

    assert p2 != null : "p2 should not be null";

    return (p2.x — p1.x) * 1.5;

  }

}

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

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

Заключение

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

Литература

[Martin]: Agile Software Development: Principles, Patterns, and Practices, Robert C. Martin, Prentice Hall, 2002.

Назад: 6. Объекты и структуры данных
Дальше: 8. Границы