Создание, анализ ирефакторинг
Скачать 3.16 Mb.
|
130 Глава 6 . Объекты и структуры данных Заключение Объекты предоставляют поведение и скрывают данные . Это позволяет програм- мисту легко добавлять новые виды объектов, не изменяя существующего поведе- ния . С другой стороны, объекты усложняют добавление нового поведения к су- ществующим объектам . Структуры данных предоставляют данные, но не обла- дают сколько-нибудь значительным поведением . Они упрощают добавление но- вого поведения в существующие структуры данных, но затрудняют добавление новых структур данных в существующие функции . Если в некоторой системе нас прежде всего интересует гибкость в добавлении новых типов данных, то в этой части системы предпочтение отдается объектной реализации . В других случаях нам нужна гибкость расширения поведения, и тог- да в этой части используются типы данных и процедуры . Хороший программист относится к этой проблеме без предубеждения и выбирает то решение, которое лучше всего подходит для конкретной ситуации . литература [Refactoring]: Refactoring: Improving the Design of Existing Code, Martin Fowler et al ., Addison-Wesley, 1999 . 130 Обработка ошибок Майкл Физерс На первый взгляд глава, посвященная обработке ошибок, в книге о чистом коде выглядит немного странно . Обработка ошибок — одна из тех рутинных вещей, которыми нам всем приходится заниматься при программировании . Программа может получить аномальные входные данные, на устройстве могут произойти сбои . Короче говоря, выполнение программы может пойти по неверному пути, и если это случается, мы, программисты, должны позаботиться, чтобы наш код сделал то, что ему положено сделать . Однако связь этих двух тем — обработки ошибок и чистого кода — очевидна . Во многих кодовых базах обработка ошибок выходит на первый план . Я вовсе не хочу сказать, что код не делает ничего полезного, кроме обработки ошибок; я имею в виду, что из-за разбросанной повсюду обработки ошибок практически невозможно понять, что же делает код . Обработка ошибок важна, но если они заслоняют собой логику программы — значит, она реализована неверно . В этой главе представлены некоторые соображения и приемы, которые помогают писать чистый и надежный код, то есть код, в котором ошибки обрабатываются стильно и элегантно . 7 131 132 Глава 7 . Обработка ошибок Используйте исключения вместо кодов ошибок В далеком прошлом многие языки программирования не поддерживали механиз- ма обработки исключений . В таких языках возможности обработки и получения информации об ошибках были ограничены . Программа либо устанавливала флаг ошибки, либо возвращала код, который проверялся вызывающей стороной . Оба способа продемонстрированы в листинге 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 представлен тот же код с выдачей исключений в методах, способ- ных обнаруживать ошибки . Обратите внимание, насколько чище стал код . Причем дело даже не в эстетике . Качество кода возросло, потому что два аспекта, которые прежде были тесно переплетены — алгоритм отключения устройства и обработка ошибок, — теперь изолированы друг от друга . Вы можете рассмотреть их по отдельности и разо- браться в каждом из них независимо . 132 Начните с написания команды try-catch-finally 133 листинг 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 Допустим, требуется написать код, который открывает файл и читает из него сериализованные объекты . 133 134 Глава 7 . Обработка ошибок Начнем с модульного теста, который проверяет, что при неудачном обращении к файлу будет выдано исключение: @Test(expected = StorageException.class) public void retrieveSectionShouldThrowOnInvalidFileName() { sectionStore.retrieveSection("invalid - file"); } Для теста необходимо создать следующую программную заглушку: public List // Пусто, пока не появится реальная реализация return new ArrayList } Тест завершается неудачей, потому что код не инициирует исключения . Затем мы изменяем свою реализацию так, чтобы она попыталась обратиться к несуще- ствующему файлу . При попытке выполнения происходит исключение: public List try { FileInputStream stream = new FileInputStream(sectionName) } catch (Exception e) { throw new StorageException("retrieval error", e); } return new ArrayList } Теперь тест проходит успешно, потому что мы перехватили исключение . На этой стадии можно переработать код . Тип перехватываемого исключения сужается до типа, реально инициируемого конструктором FileInputStream , то есть FileNot- FoundException : public List try { FileInputStream stream = new FileInputStream(sectionName); stream.close(); } catch (FileNotFoundException e) { throw new StorageException("retrieval error", e); } return new ArrayList } Определив область видимости при помощи структуры try - catch , мы можем ис- пользовать методологию TDD � для построения остальной необходимой логики . Эта логика размещается между созданием FileInputStream и закрытием, а в ее коде можно считать, что все операции были выполнены без ошибок . Попробуйте написать тесты, принудительно инициирующие исключения, а затем включите в обработчик поведение, обеспечивающее прохождение тестов . Это за- ставит вас построить транзакционную область видимости блока try и поможет сохранить ее транзакционную природу . 134 Используйте непроверяемые исключения 135 Используйте непроверяемые исключения Время споров прошло . Java-программисты годами обсуждали преимущества и недостатки проверяемых исключений (checked exceptions) . Когда проверяемые исключения появились в первой версии Java, всем казалось, что это отличная идея . В сигнатуре каждого метода должны быть перечислены все исключения, которые могут передаваться вызывающей стороне . Фактически исключения становились частью типа метода . Если сигнатура не соответствовала тому, что происходит в коде, то программа просто не компилировалась . В то время мы с энтузиазмом относились к проверяемым исключениям; в самом деле, они бывают полезными . Но сейчас стало ясно, что они не являются необхо- димыми для создания надежных программ . В C# нет проверяемых исключений, и несмотря на все доблестные попытки, в C++ они так и не появились . Их также нет в Python и Ruby . Тем не менее на всех этих языках можно писать надежные программы . А раз так, нам приходится решать, оправдывают ли проверяемые исключения ту цену, которую за них приходится платить . Какую цену, спросите вы? Цена проверяемых исключений — нарушение прин- ципа открытости/закрытости [Martin] . Если вы инициируете проверяемое ис- ключение из метода своего кода, а catch находится тремя уровнями выше, то это исключение должно быть объявлено в сигнатурах всех методов между вашим методом и catch . Следовательно, изменение на низком уровне программного продукта приводит к изменениям сигнатур на многих более высоких уровнях . Измененные модули приходится строить и развертывать заново, притом что в программе не изменилось ничего, что было бы существенно для них . Представьте иерархию вызовов большой системы . Функции верхнего уровня вызывают функции нижележащего уровня, которые, в свою очередь, вызывают функции низких уровней и т . д . Теперь допустим, что одна из низкоуровневых функций изменилась таким образом, что она должна инициировать исключение . Если это исключение является проверяемым, то в сигнатуру функции должна быть добавлена секция throws . Но тогда каждая функция, вызывающая нашу измененную функцию, тоже должна быть изменена с перехватом нового ис- ключения или присоединением соответствующей секции throws к ее сигнатуре . И так до бесконечности . В итоге мы имеем каскад изменений, пробивающихся с нижних уровней программного продукта на верхние уровни! При этом нару- шается инкапсуляция, потому что все функции на пути инициирования должны располагать подробной информацией об этом низкоуровневом исключении . Учи- тывая, что главной целью исключений является возможность обработки ошибок «на расстоянии», такое нарушение инкапсуляции проверяемыми исключениями выглядит особенно постыдно . Проверяемые исключения иногда могут пригодиться при написании особо важных библиотек: программист обязан перехватить их . Но в общем случае разработки приложений проблемы, создаваемые зависимостями, перевешивают преимущества . 135 136 Глава 7 . Обработка ошибок Передавайте контекст с исключениями Каждое исключение, инициируемое в программе, должно содержать достаточно контекстной информации для определения источника и местонахождения ошиб- ки . В 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 { … } Конструкция содержит множество повторений, и это неудивительно . В боль- шинстве ситуаций при обработке исключений выполняются относительно стан- 136 Определяйте классы исключений в контексте потребностей 137 дартные действия, не зависящие от их реальной причины . Мы должны сохранить ошибку и убедиться в том, что работа программы может быть продолжена . В этом случае, поскольку выполняемая работа остается более или менее постоянной независимо от исключения, код можно существенно упростить — для этого мы создаем «обертку» для вызываемой функции 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, который вам удобен . В предыдущем примере мы определили для всех сбоев порта один тип исключения, и код от этого стал намного чище . 137 138 Глава 7 . Обработка ошибок Часто в определенной области кода бывает достаточно одного класса исключения . Информация, передаваемая с исключением, позволяет различать разные виды ошибок . Используйте разные классы исключений только в том случае, если вы намерены перехватывать одни исключения, разрешая прохождение других типов . Определите нормальный путь выполнения Выполнение рекомендаций из преды- дущих разделов обеспечивает хорошее разделение бизнес-логики и кода обра- ботки ошибок . Основной код програм- мы начинает выглядеть как простой ал- горитм, не отягощенный посторонними вставками . Однако в результате код об- наружения ошибок смещается на пери- ферию вашей программы . Вы создаете обертки для внешних 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() { // Вернуть стандартные ежедневные затраты на питание } } 138 Не возвращайте null 139 Такое решение представляет собой реализацию паттерна ОСОБЫЙ СЛУЧАЙ [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 if (employees != null) { 139 |