Чистый код и искусство обработки исключений

Опубликовано: 2022-03-11

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

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

И для их обработки требуется:

конструкции специализированного языка программирования или аппаратные механизмы компьютера.

Таким образом, исключения требуют специальной обработки, а необработанное исключение может привести к неожиданному поведению. Результаты часто впечатляющие. В 1996 году знаменитый сбой запуска ракеты Ariane 5 был приписан необработанному исключению переполнения. Наихудшие программные ошибки в истории содержат некоторые другие ошибки, которые можно отнести к необработанным или необработанным исключениям.

Со временем эти ошибки и бесчисленное множество других (которые были, может быть, не столь драматичны, но все же катастрофичны для причастных) создавали впечатление, что исключения — это плохо .

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

Обработка исключений: это хорошо

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

 begin do_something_that_might_not_work! rescue SpecificError => e do_some_specific_error_clean_up retry if some_condition_met? ensure this_will_always_be_executed end

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

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

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

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

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

"WTFs/m" Тома Холверды, OSNews

С учетом сказанного давайте углубимся в эти практики и посмотрим, как каждая из них влияет на эти три показателя.

Примечание. Мы приведем примеры из Ruby, но все продемонстрированные здесь конструкции имеют эквиваленты в наиболее распространенных языках ООП.

Всегда создавайте собственную иерархию ApplicationError

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

 class ApplicationError < StandardError; end # Validation Errors class ValidationError < ApplicationError; end class RequiredFieldError < ValidationError; end class UniqueFieldError < ValidationError; end # HTTP 4XX Response Errors class ResponseError < ApplicationError; end class BadRequestError < ResponseError; end class UnauthorizedError < ResponseError; end # ... 

Пример иерархии исключений приложения: StandardError находится вверху. ApplicationError наследуется от него. ValidationError и ResponseError наследуются от него. RequiredFieldError и UniqueFieldError наследуются от ValidationError, тогда как BadRequestError и UnauthorizedError наследуются от ResponseError.

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

С точки зрения удобочитаемости читать намного проще:

 rescue ValidationError => e

Чем читать:

 rescue RequiredFieldError, UniqueFieldError, ... => e

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

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

 # app/controller/pseudo_controller.rb def authenticate_user! fail AuthenticationError if token_invalid? || token_expired? User.find_by(authentication_token: token) rescue AuthenticationError => e report_suspicious_activity if token_invalid? raise e end def show authenticate_user! show_private_stuff!(params[:id]) rescue ClientError => e render_error(e) end

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

Здесь следует отметить две вещи:

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

Никогда не rescue Exception

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

 # main.rb def bad_example i_might_raise_exception! rescue Exception nah_i_will_always_be_here_for_you end # elsewhere.rb def i_might_raise_exception! retrun do_a_lot_of_work! end

Вы могли заметить ошибку в предыдущем примере; return опечатан. Хотя современные редакторы обеспечивают некоторую защиту от этого конкретного типа синтаксической ошибки, этот пример иллюстрирует, как rescue Exception наносит вред нашему коду. Ни в коем случае фактический тип исключения (в данном случае NoMethodError ) не рассматривается, и он никогда не раскрывается разработчику, что может привести к тому, что мы будем тратить много времени на беготню по кругу.

Никогда не rescue больше исключений, чем вам нужно

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

Если мы попытаемся обработать разные подтипы исключений в одном и том же обработчике, мы введем толстые блоки кода, на которые возлагается слишком много обязанностей. Например, если мы создаем библиотеку, которая использует удаленный API, обработка MethodNotAllowedError (HTTP 405) обычно отличается от обработки UnauthorizedError (HTTP 401), даже если они оба являются ResponseError s.

Как мы увидим, часто существует другая часть приложения, которая лучше подходит для обработки определенных исключений более СУХИМ способом.

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

 def get_info begin response = HTTP.get(STOCKS_URL + "#{@symbol}/info") fail AuthenticationError if response.code == 401 fail StockNotFoundError, @symbol if response.code == 404 return JSON.parse response.body rescue JSON::ParserError retry end end

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

Не поддавайтесь желанию немедленно обрабатывать исключения

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

Одним из распространенных шаблонов, который мы видим в приложениях Rails (особенно в тех, которые предоставляют API только для JSON), является следующий метод контроллера:

 # app/controllers/client_controller.rb def create @client = Client.new(params[:client]) if @client.save render json: @client else render json: @client.errors end end

(Обратите внимание, что хотя технически это не обработчик исключений, функционально он служит той же цели, поскольку @client.save возвращает false только при обнаружении исключения.)

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

 # app/controllers/client_controller.rb def create @client = Client.create!(params[:client]) render json: @client end
 # app/controller/application_controller.rb rescue_from ActiveRecord::RecordInvalid, with: :render_unprocessable_entity def render_unprocessable_entity(e) render \ json: { errors: e.record.errors }, status: 422 end

Таким образом, мы можем гарантировать, что все ошибки ActiveRecord::RecordInvalid правильно и DRY-ly обрабатываются в одном месте, на базовом уровне ApplicationController . Это дает нам свободу возиться с ними, если мы хотим обрабатывать определенные случаи на более низком уровне или просто позволить им изящно распространяться.

Не все исключения нуждаются в обработке

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

Возьмем ActiveRecord в качестве примера идеального решения. Библиотека предоставляет разработчикам два подхода для полноты. Метод save обрабатывает исключения, не распространяя их, просто возвращая false , а save! вызывает исключение при сбое. Это дает разработчикам возможность по-разному обрабатывать определенные случаи ошибок или просто обрабатывать любой сбой в общем виде.

Но что, если у вас нет времени или ресурсов для такой полной реализации? В этом случае, если есть какая-то неопределенность, лучше всего выставить исключение и выпустить его на волю.

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

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

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

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

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

Следуйте конвенции

Реализация Ruby и тем более Rails следует некоторым соглашениям об именах, таким как различие между method_names и method_names! с «ударом». В Ruby челка означает, что метод изменит объект, который его вызвал, а в Rails это означает, что метод вызовет исключение, если он не сможет выполнить ожидаемое поведение. Старайтесь соблюдать то же соглашение, особенно если вы собираетесь открыть свою библиотеку.

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

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

Кроме того, поскольку я использую исключения для обозначения сбоев, я почти всегда использую ключевое слово fail , а не ключевое слово raise в Ruby. Fail и повышения являются синонимами, так что нет никакой разницы, за исключением того, что fail более четко сообщает, что метод дал сбой. Единственный раз, когда я использую повышение, — это когда я перехватываю исключение и повторно инициирую его, потому что здесь я не терплю неудачу, а явно и целенаправленно инициирую исключение. Это стилистическая проблема, которой я придерживаюсь, но я сомневаюсь, что многие другие люди делают это.

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

Logger.log(все)

Эта практика, конечно, применима не только к исключениям, но если есть что-то, что всегда следует регистрировать, так это исключение.

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

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

Эта уверенность в чистом коде

Чистая обработка исключений отправит качество вашего кода на Луну!
Твитнуть

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

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

Мы увидели, что «поймать их всех» — это плохо, и что можно позволить им плавать и пузыриться.

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

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

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

С этими основными рекомендациями мы можем чувствовать себя намного более комфортно и уверенно, имея дело с ошибками в нашем коде и делая наши исключения действительно исключительными!

Особая благодарность Авди Гримму и его потрясающему докладу Exceptional Ruby, который очень помог при написании этой статьи.

Связанный: Советы и лучшие практики для разработчиков Ruby