Código limpio y el arte del manejo de excepciones
Publicado: 2022-03-11Las excepciones son tan antiguas como la propia programación. En los días en que la programación se hacía en hardware oa través de lenguajes de programación de bajo nivel, se usaban excepciones para alterar el flujo del programa y evitar fallas en el hardware. Hoy, Wikipedia define las excepciones como:
condiciones anómalas o excepcionales que requieren un procesamiento especial, a menudo cambiando el flujo normal de ejecución del programa...
Y que manejarlos requiere:
construcciones de lenguajes de programación especializados o mecanismos de hardware de computadora.
Por lo tanto, las excepciones requieren un tratamiento especial y una excepción no controlada puede provocar un comportamiento inesperado. Los resultados son a menudo espectaculares. En 1996, la famosa falla en el lanzamiento del cohete Ariane 5 se atribuyó a una excepción de desbordamiento no controlada. Los peores errores de software de la historia contienen algunos otros errores que podrían atribuirse a excepciones no controladas o mal controladas.
Con el tiempo, estos errores y muchos otros (que quizás no fueron tan dramáticos, pero sí catastróficos para los involucrados) contribuyeron a la impresión de que las excepciones son malas .
Pero las excepciones son un elemento fundamental de la programación moderna; existen para mejorar nuestro software. En lugar de temer las excepciones, debemos abrazarlas y aprender a beneficiarnos de ellas. En este artículo, discutiremos cómo administrar las excepciones de manera elegante y usarlas para escribir código limpio que sea más fácil de mantener.
Manejo de excepciones: es algo bueno
Con el auge de la programación orientada a objetos (POO), el soporte de excepciones se ha convertido en un elemento crucial de los lenguajes de programación modernos. En la actualidad, la mayoría de los lenguajes incorporan un sólido sistema de manejo de excepciones. Por ejemplo, Ruby proporciona el siguiente patrón típico:
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
No hay nada malo con el código anterior. Pero el uso excesivo de estos patrones causará olores de código y no necesariamente será beneficioso. Del mismo modo, usarlos incorrectamente puede causar mucho daño a su base de código, haciéndola quebradiza u ofuscando la causa de los errores.
El estigma que rodea a las excepciones a menudo hace que los programadores se sientan perdidos. Es un hecho de la vida que las excepciones no se pueden evitar, pero a menudo se nos enseña que deben tratarse con rapidez y decisión. Como veremos, esto no es necesariamente cierto. Más bien, deberíamos aprender el arte de manejar las excepciones con gracia, haciéndolas armoniosas con el resto de nuestro código.
Las siguientes son algunas prácticas recomendadas que lo ayudarán a adoptar excepciones y hacer uso de ellas y sus capacidades para mantener su código mantenible , extensible y legible :
- mantenibilidad : nos permite encontrar y corregir fácilmente nuevos errores, sin temor a romper la funcionalidad actual, introducir más errores o tener que abandonar el código por completo debido a la mayor complejidad con el tiempo.
- extensibilidad : nos permite agregar fácilmente a nuestra base de código, implementando requisitos nuevos o modificados sin romper la funcionalidad existente. La extensibilidad proporciona flexibilidad y permite un alto nivel de reutilización de nuestro código base.
- legibilidad : Nos permite leer fácilmente el código y descubrir su propósito sin perder demasiado tiempo investigando. Esto es fundamental para descubrir errores y código no probado de manera eficiente.
Estos elementos son los principales factores de lo que podríamos llamar limpieza o calidad , que no es una medida directa en sí misma, sino el efecto combinado de los puntos anteriores, como se demuestra en este cómic:
Dicho esto, profundicemos en estas prácticas y veamos cómo cada una de ellas afecta esas tres medidas.
Nota: Presentaremos ejemplos de Ruby, pero todas las construcciones demostradas aquí tienen equivalentes en los lenguajes OOP más comunes.
Cree siempre su propia jerarquía de ApplicationError
La mayoría de los lenguajes vienen con una variedad de clases de excepción, organizadas en una jerarquía de herencia, como cualquier otra clase de programación orientada a objetos. Para preservar la legibilidad, el mantenimiento y la extensibilidad de nuestro código, es una buena idea crear nuestro propio subárbol de excepciones específicas de la aplicación que amplíen la clase de excepción base. Invertir algo de tiempo en estructurar lógicamente esta jerarquía puede ser extremadamente beneficioso. Por ejemplo:
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 # ...
Tener un paquete de excepciones completo y extensible para nuestra aplicación hace que el manejo de estas situaciones específicas de la aplicación sea mucho más fácil. Por ejemplo, podemos decidir qué excepciones manejar de una forma más natural. Esto no solo aumenta la legibilidad de nuestro código, sino que también aumenta la capacidad de mantenimiento de nuestras aplicaciones y bibliotecas (gemas).
Desde la perspectiva de la legibilidad, es mucho más fácil de leer:
rescue ValidationError => e
que leer:
rescue RequiredFieldError, UniqueFieldError, ... => e
Desde la perspectiva de la mantenibilidad, por ejemplo, estamos implementando una API JSON y hemos definido nuestro propio ClientError
con varios subtipos, para usar cuando un cliente envía una solicitud incorrecta. Si se genera alguno de estos, la aplicación debe representar la representación JSON del error en su respuesta. Será más fácil arreglar, o agregar lógica, a un solo bloque que maneja ClientError
s en lugar de recorrer cada posible error de cliente e implementar el mismo código de controlador para cada uno. En términos de extensibilidad, si luego tenemos que implementar otro tipo de error del cliente, podemos confiar en que ya se manejará correctamente aquí.
Además, esto no nos impide implementar un manejo especial adicional para errores de clientes específicos anteriormente en la pila de llamadas, o alterar el mismo objeto de excepción en el camino:
# 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
Como puede ver, generar esta excepción específica no impidió que pudiéramos manejarla en diferentes niveles, modificarla, volver a generarla y permitir que el controlador de la clase principal la resuelva.
Dos cosas a tener en cuenta aquí:
- No todos los idiomas admiten generar excepciones desde un controlador de excepciones.
- En la mayoría de los idiomas, generar una nueva excepción desde un controlador hará que la excepción original se pierda para siempre, por lo que es mejor volver a generar el mismo objeto de excepción (como en el ejemplo anterior) para evitar perder el rastro de la causa original de la excepción. error. (A menos que esté haciendo esto intencionalmente).
Nunca rescue Exception
Es decir, nunca intente implementar un controlador general para el tipo de excepción base. Rescatar o capturar todas las excepciones al por mayor nunca es una buena idea en ningún idioma, ya sea globalmente en un nivel de aplicación base o en un pequeño método oculto que se usa solo una vez. No queremos rescatar Exception
porque ofuscará lo que realmente sucedió, dañando tanto la mantenibilidad como la extensibilidad. Podemos perder una gran cantidad de tiempo depurando cuál es el problema real, cuando podría ser tan simple como un error de sintaxis:
# 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
Es posible que haya notado el error en el ejemplo anterior; el return
está mal escrito. Aunque los editores modernos brindan cierta protección contra este tipo específico de error de sintaxis, este ejemplo ilustra cómo rescue Exception
daña nuestro código. En ningún momento se aborda el tipo real de la excepción (en este caso, NoMethodError
), ni se expone nunca al desarrollador, lo que puede hacer que perdamos mucho tiempo dando vueltas.
Nunca rescue
más excepciones de las que necesita
El punto anterior es un caso específico de esta regla: siempre debemos tener cuidado de no generalizar demasiado nuestros controladores de excepciones. Las razones son las mismas; cada vez que rescatamos más excepciones de las que deberíamos, terminamos ocultando partes de la lógica de la aplicación de los niveles superiores de la aplicación, sin mencionar la supresión de la capacidad del desarrollador para manejar la excepción por sí mismo. Esto afecta severamente la extensibilidad y mantenibilidad del código.
Si intentamos manejar diferentes subtipos de excepción en el mismo controlador, introducimos bloques de código voluminosos que tienen demasiadas responsabilidades. Por ejemplo, si estamos creando una biblioteca que consume una API remota, manejar un MethodNotAllowedError
(HTTP 405) suele ser diferente de manejar un UnauthorizedError
(HTTP 401), aunque ambos sean ResponseError
s.
Como veremos, a menudo existe una parte diferente de la aplicación que sería más adecuada para manejar excepciones específicas de una manera más SECA.

Por lo tanto, defina la responsabilidad única de su clase o método y maneje el mínimo de excepciones que satisfagan este requisito de responsabilidad . Por ejemplo, si un método es responsable de obtener información bursátil de una API remota, entonces debe manejar las excepciones que surjan al obtener esa información únicamente, y dejar el manejo de los otros errores a un método diferente diseñado específicamente para estas responsabilidades:
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
Aquí definimos el contrato para este método para obtener solo la información sobre el stock. Maneja errores específicos del punto final , como una respuesta JSON incompleta o mal formada. No maneja el caso cuando la autenticación falla o caduca, o si el stock no existe. Estos son responsabilidad de otra persona y se pasan explícitamente a la pila de llamadas donde debería haber un mejor lugar para manejar estos errores de una manera SECA.
Resista la tentación de manejar las excepciones de inmediato
Este es el complemento del último punto. Una excepción se puede manejar en cualquier punto de la pila de llamadas y en cualquier punto de la jerarquía de clases, por lo que saber exactamente dónde manejarla puede ser desconcertante. Para resolver este enigma, muchos desarrolladores optan por manejar cualquier excepción tan pronto como surja, pero invertir tiempo en pensar en esto generalmente resultará en encontrar un lugar más apropiado para manejar excepciones específicas.
Un patrón común que vemos en las aplicaciones de Rails (especialmente aquellas que exponen API solo para JSON) es el siguiente método de controlador:
# 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
(Tenga en cuenta que aunque esto no es técnicamente un controlador de excepciones, funcionalmente tiene el mismo propósito, ya que @client.save
solo devuelve falso cuando encuentra una excepción).
En este caso, sin embargo, repetir el mismo controlador de errores en cada acción del controlador es lo opuesto a DRY y daña la capacidad de mantenimiento y la extensibilidad. En cambio, podemos hacer uso de la naturaleza especial de la propagación de excepciones y manejarlas solo una vez, en la clase de controlador principal, 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
De esta manera, podemos asegurarnos de que todos los errores de ActiveRecord::RecordInvalid
se manejen correctamente y en SECO en un solo lugar, en el nivel base de ApplicationController
. Esto nos da la libertad de jugar con ellos si queremos manejar casos específicos en el nivel inferior, o simplemente dejar que se propaguen con gracia.
No todas las excepciones necesitan manejo
Al desarrollar una gema o una biblioteca, muchos desarrolladores intentarán encapsular la funcionalidad y no permitirán que ninguna excepción se propague fuera de la biblioteca. Pero a veces, no es obvio cómo manejar una excepción hasta que se implementa la aplicación específica.
Tomemos ActiveRecord
como ejemplo de la solución ideal. La biblioteca proporciona a los desarrolladores dos enfoques para la integridad. El método save
maneja las excepciones sin propagarlas, simplemente devolviendo false
, mientras que save!
lanza una excepción cuando falla. Esto brinda a los desarrolladores la opción de manejar casos de error específicos de manera diferente, o simplemente manejar cualquier falla de manera general.
Pero, ¿qué sucede si no tiene el tiempo o los recursos para proporcionar una implementación tan completa? En ese caso, si hay alguna duda, es mejor exponer la excepción y liberarla.
Este es el motivo: estamos trabajando con requisitos cambiantes casi todo el tiempo, y tomar la decisión de que una excepción siempre se manejará de una manera específica podría dañar nuestra implementación, dañar la extensibilidad y la mantenibilidad, y potencialmente agregar una gran deuda técnica, especialmente al desarrollar bibliotecas
Tome el ejemplo anterior de un consumidor de API de acciones que busca precios de acciones. Elegimos manejar la respuesta incompleta y con formato incorrecto en el acto, y elegimos volver a intentar la misma solicitud nuevamente hasta obtener una respuesta válida. Pero más adelante, los requisitos pueden cambiar, por lo que debemos recurrir a los datos de stock históricos guardados, en lugar de volver a intentar la solicitud.
En este punto, nos veremos obligados a cambiar la biblioteca en sí, actualizando cómo se maneja esta excepción, porque los proyectos dependientes no manejarán esta excepción. (¿Cómo podrían? Nunca antes se les había expuesto). También tendremos que informar a los propietarios de los proyectos que dependen de nuestra biblioteca. Esto podría convertirse en una pesadilla si hay muchos proyectos de este tipo, ya que es probable que se hayan creado asumiendo que este error se manejará de una manera específica.
Ahora, podemos ver hacia dónde nos dirigimos con la gestión de dependencias. El panorama no es bueno. Esta situación ocurre con bastante frecuencia y, en la mayoría de los casos, degrada la utilidad, la extensibilidad y la flexibilidad de la biblioteca.
Así que aquí está el resultado final: si no está claro cómo se debe manejar una excepción, deje que se propague con gracia . Hay muchos casos en los que existe un lugar claro para manejar la excepción internamente, pero hay muchos otros casos en los que es mejor exponer la excepción. Entonces, antes de optar por manejar la excepción, piénselo dos veces. Una buena regla general es insistir únicamente en el manejo de excepciones cuando interactúe directamente con el usuario final.
Sigue la convención
La implementación de Ruby y, más aún, Rails, sigue algunas convenciones de nomenclatura, como distinguir entre method_names
y method_names!
con un "estallido". En Ruby, el bang indica que el método alterará el objeto que lo invocó, y en Rails, significa que el método generará una excepción si no logra ejecutar el comportamiento esperado. Trate de respetar la misma convención, especialmente si va a abrir su biblioteca.
¡Si tuviéramos que escribir un nuevo method!
con una explosión en una aplicación Rails, debemos tener en cuenta estas convenciones. No hay nada que nos obligue a generar una excepción cuando este método falla, pero al desviarse de la convención, este método puede inducir a error a los programadores haciéndoles creer que tendrán la oportunidad de manejar las excepciones por sí mismos, cuando, de hecho, no lo harán.
Otra convención de Ruby, atribuida a Jim Weirich, es usar fail
para indicar la falla del método, y solo usar raise
si está volviendo a generar la excepción.
Aparte, debido a que uso excepciones para indicar fallas, casi siempre uso la palabra clave
fail
en lugar de la palabra claveraise
en Ruby. Fail y raise son sinónimos, por lo que no hay diferencia, excepto que fail comunica más claramente que el método ha fallado. La única vez que uso raise es cuando atrapo una excepción y la vuelvo a generar, porque aquí no estoy fallando, sino explícitamente y deliberadamente generando una excepción. Este es un problema estilístico que sigo, pero dudo que muchas otras personas lo hagan.
Muchas otras comunidades lingüísticas han adoptado convenciones como estas sobre cómo se tratan las excepciones, e ignorar estas convenciones dañará la legibilidad y la capacidad de mantenimiento de nuestro código.
Logger.log(todo)
Esta práctica no se aplica únicamente a las excepciones, por supuesto, pero si hay algo que siempre debe registrarse, es una excepción.
El registro es extremadamente importante (lo suficientemente importante como para que Ruby envíe un registrador con su versión estándar). Es el diario de nuestras aplicaciones, e incluso más importante que mantener un registro de cómo nuestras aplicaciones tienen éxito, es registrar cómo y cuándo fallan.
No hay escasez de bibliotecas de registro o servicios basados en registros y patrones de diseño. Es fundamental realizar un seguimiento de nuestras excepciones para que podamos revisar lo que sucedió e investigar si algo no se ve bien. Los mensajes de registro adecuados pueden señalar a los desarrolladores directamente la causa de un problema, ahorrándoles un tiempo inconmensurable.
Esa confianza de código limpio
Las excepciones son una parte fundamental de todo lenguaje de programación. Son especiales y extremadamente poderosos, y debemos aprovechar su poder para elevar la calidad de nuestro código en lugar de cansarnos luchando con ellos.
En este artículo, nos sumergimos en algunas buenas prácticas para estructurar nuestros árboles de excepción y cómo puede ser beneficioso para la legibilidad y la calidad estructurarlos lógicamente. Analizamos diferentes enfoques para el manejo de excepciones, ya sea en un solo lugar o en múltiples niveles.
Vimos que es malo "atraparlos a todos", y que está bien dejarlos flotar y burbujear.
Analizamos dónde manejar las excepciones de manera SECA y aprendimos que no estamos obligados a manejarlas cuando o donde surgen por primera vez.
Discutimos cuándo es exactamente una buena idea manejarlos, cuándo es una mala idea y por qué, en caso de duda, es una buena idea dejar que se propaguen.
Finalmente, discutimos otros puntos que pueden ayudar a maximizar la utilidad de las excepciones, como seguir las convenciones y registrar todo.
Con estas pautas básicas, podemos sentirnos mucho más cómodos y confiados al tratar con casos de error en nuestro código y hacer que nuestras excepciones sean realmente excepcionales.
Un agradecimiento especial a Avdi Grimm y su increíble charla Exceptional Ruby, que ayudó mucho en la elaboración de este artículo.