Manejo de errores de Kotlin / Java: ¿cómo hacerlo bien?


Fuente


El manejo de errores en cualquier desarrollo juega un papel crucial. Casi todo puede salir mal en el programa: el usuario ingresará datos incorrectos, o pueden venir a través de http, o cometimos un error al escribir la serialización / deserialización y durante el procesamiento, el programa se bloquea con un error. Sí, puede quedarse sin espacio en el disco.


spoiler

¯_ (ツ) _ / ¯, no existe una única forma, y ​​en cada situación específica tendrá que elegir la opción más adecuada, pero hay recomendaciones sobre cómo hacerlo mejor.


Prólogo


Desafortunadamente (¿o tal vida?), Esta lista sigue y sigue. El desarrollador necesita pensar constantemente en el hecho de que en algún lugar puede ocurrir un error, y hay 2 situaciones:


  • cuando se produce el error esperado al llamar a la función que hemos proporcionado y que puede intentar procesar;
  • cuando ocurre un error inesperado durante la operación que no previmos.

Y si los errores esperados están al menos localizados, el resto puede suceder en casi todas partes. Si no procesamos nada importante, simplemente podemos bloquearnos con un error (aunque este comportamiento no es suficiente y al menos debe agregar un mensaje al registro de errores). Pero si en este momento el pago se está procesando y simplemente no puede caer, pero al menos necesita devolver una respuesta sobre la operación fallida.


Antes de ver formas de manejar errores, algunas palabras sobre Excepción (excepciones):


Excepción



Fuente


La jerarquía de excepciones está bien descrita y puede encontrar mucha información al respecto, por lo que no tiene sentido describirla aquí. Lo que a veces todavía causa una discusión acalorada se checked y se unchecked errores. Y aunque la mayoría aceptó excepciones unchecked como preferidas (en Kotlin no hay excepciones checked ), no todos están de acuerdo con esto.


Las excepciones checked realmente tenían una buena intención de convertirlas en un mecanismo conveniente para el manejo de errores, pero la realidad hizo sus ajustes, aunque la idea de introducir todas las excepciones que se pueden lanzar desde esta función a la firma es comprensible y lógica.


Veamos un ejemplo. Supongamos que tenemos una función de method que puede lanzar una PanicException marcada. Tal función se vería así:


 public void method() throws PanicException { } 

Según su descripción, está claro que puede lanzar una excepción y que solo puede haber una excepción. ¿Se ve bastante cómodo? Y si bien tenemos un pequeño programa, eso es todo. Pero si el programa es un poco más grande y hay más funciones, aparecen algunos problemas.


Las excepciones marcadas requieren la especificación de que todas las posibles excepciones marcadas (o un antecesor común para ellas) se enumeran en la firma de la función. Por lo tanto, si tenemos una cadena de llamadas a -> b -> c y la función más anidada arroja algún tipo de excepción, entonces se debe poner para todos en la cadena. Y si hay varias excepciones, entonces la función superior de la firma debe tener una descripción de todas ellas.


Entonces, a medida que el programa se vuelve más complejo, este enfoque lleva al hecho de que las excepciones en la función superior se colapsan gradualmente a antepasados ​​comunes y finalmente se reducen a Exception . Lo que en este formulario se vuelve similar a una excepción unchecked y niega todas las ventajas de las excepciones marcadas.


Y dado que el programa, como organismo vivo, cambia y evoluciona constantemente, es casi imposible prever de antemano qué excepciones pueden surgir en él. Y como resultado, la situación es que cuando agregamos una nueva función con una nueva excepción, tenemos que pasar por toda la cadena de su uso y cambiar las firmas de todas las funciones. De acuerdo, esta no es la tarea más agradable (incluso teniendo en cuenta que los IDE modernos hacen esto por nosotros).


Pero el último, y probablemente el mayor clavo en las excepciones marcadas "condujo" lambdas de Java 8. No hay excepciones marcadas ¯_ (ツ) _ / ¯ en su firma (ya que cualquier función se puede llamar en lambda, con cualquier firma), por lo que cualquier llamada de función con una excepción marcada de lambda obliga a que se envuelva en un reenvío de excepción como desmarcado:


 Stream.of(1,2,3).forEach(item -> { try { functionWithCheckedException(); } catch (Exception e) { throw new RuntimeException("rethrow", e); } }); 

Afortunadamente, en la especificación JVM no hay excepciones comprobadas, por lo que en Kotlin no puede envolver nada en el mismo lambda, sino simplemente llamar a la función deseada.


aunque a veces ...

Aunque esto a veces conduce a consecuencias inesperadas, como, por ejemplo, el funcionamiento incorrecto de @Transactional en Spring Framework , que "espera" solo excepciones sin unckecked . Pero esto es más una característica del marco, y tal vez este comportamiento en la primavera cambiará en el próximo problema de github .


Las excepciones en sí mismas son objetos especiales. Además del hecho de que pueden "lanzarse" a través de métodos, también recopilan stacktrace en la creación. Luego, esta característica ayuda con el análisis de problemas y la búsqueda de errores, pero también puede generar algunos problemas de rendimiento si la lógica de la aplicación está fuertemente ligada a las excepciones lanzadas. Como se muestra en el artículo , deshabilitar el ensamblaje stacktrace puede aumentar significativamente su rendimiento en este caso, ¡pero debe recurrir a él solo en casos excepcionales cuando realmente sea necesario!


Manejo de errores


Lo principal que debe hacer con los errores "inesperados" es encontrar un lugar donde pueda interceptarlos. En los lenguajes JVM, este puede ser un punto de creación de flujo o un punto de filtro / entrada para el método http, donde puede poner un try-catch con el manejo de errores unchecked . Si usa algún marco, lo más probable es que ya tenga la capacidad de crear controladores de errores comunes, como, por ejemplo, en Spring Framework, puede usar métodos con la anotación @ExceptionHandler .


Puede "aumentar" las excepciones a estos puntos de procesamiento central que no queremos manejar en lugares específicos lanzando las mismas excepciones sin unckecked (cuando, por ejemplo, no sabemos qué hacer en un lugar en particular y cómo manejar el error). Pero este método no siempre es adecuado, porque a veces puede requerir manejar el error en su lugar, y debe verificar que todos los lugares de las llamadas a funciones se procesen correctamente. Considere formas de hacer esto.


  1. Todavía usa excepciones y el mismo try-catch:


      int a = 10; int b = 20; int sum; try { sum = calculateSum(a,b); } catch (Exception e) { sum = -1; } 

    El principal inconveniente es que podemos "olvidar" envolverlo en un try-catch en el lugar de la llamada y omitir el intento de procesarlo en su lugar, por lo que la excepción se lanzará al punto común de procesamiento de errores. Aquí podemos ir a excepciones checked (para Java), pero luego obtendremos todas las desventajas mencionadas anteriormente. Este enfoque es conveniente de usar si no siempre se requiere el manejo de errores, pero en casos raros es necesario.


  2. Use la clase sellada como resultado de una llamada (Kotlin).
    En Kotlin, puede limitar el número de herederos de clase, hacerlos computables en la etapa de compilación; esto permite que el compilador verifique que todas las opciones posibles se analicen en el código. En Java, puede crear una interfaz común y varios descendientes, sin embargo, perder las comprobaciones de nivel de compilación.


     sealed class Result data class SuccessResult(val value: Int): Result() data class ExceptionResult(val exception: Exception): Result() val a = 10 val b = 20 val sum = when (val result = calculateSum(a,b)) { is SuccessResult -> result.value is ExceptionResult -> { result.exception.printStackTrace() -1 } } 

    Aquí obtenemos algo así como un golang error de golang cuando necesita verificar explícitamente los valores resultantes (o ignorar explícitamente). El enfoque es bastante práctico y especialmente conveniente cuando necesita arrojar muchos parámetros en cada situación. La clase de Result se puede ampliar con varios métodos que facilitan la obtención del resultado con una excepción lanzada arriba, si la hay (es decir, no necesitamos manejar el error en el lugar de la llamada). El principal inconveniente será solo la creación de objetos superfluos intermedios (y una entrada un poco más detallada), pero también se puede eliminar usando clases en inline (si un argumento es suficiente para nosotros). y, como ejemplo particular, hay una clase Result de Kotlin. Es cierto que es solo para uso interno, ya que en el futuro, su implementación puede cambiar ligeramente, pero si desea usarlo, puede agregar el indicador de compilación -Xallow-result-return-type .


  3. Como uno de los tipos posibles de la reivindicación 2, el uso de un tipo de programación funcional Either , que puede ser un resultado o un error. El tipo en sí puede ser una clase sealed o una clase en inline . A continuación se muestra un ejemplo del uso de la implementación de la biblioteca de arrow :


     val a = 10 val b = 20 val value = when(val result = calculateSum(a,b)) { is Either.Left -> { result.a.printStackTrace() -1 } is Either.Right -> result.b } 

    Either más adecuado para aquellos que aman un enfoque funcional y que les gusta construir cadenas de llamadas.


  4. Use Option o tipo nullable de Kotlin:


     fun testFun() { val a = 10 val b = 20 val sum = calculateSum(a,b) ?: throw RuntimeException("some exception") } fun calculateSum(a: Int, b: Int): Int? 

    Este enfoque es adecuado si la causa del error no es muy importante y solo es una. Una respuesta vacía se considera un error y se arroja más alto. El registro más corto, sin crear objetos adicionales, pero este enfoque no siempre se puede aplicar.


  5. Similar al elemento 4, solo utiliza un valor de código duro como marcador de error:


     fun testFun() { val a = 10 val b = 20 val sum = calculateSum(a,b) if (sum == -1) { throw RuntimeException(“error”) } } fun calculateSum(a: Int, b: Int): Int 

    Este es probablemente el enfoque de manejo de errores más antiguo que regresó de C (o incluso de Algol). No hay gastos generales, solo un código que no está completamente claro (junto con restricciones en la elección del resultado), pero, a diferencia del párrafo 4, es posible hacer varios códigos de error si se requiere más de una posible excepción.



Conclusiones


Todos los enfoques se pueden combinar según la situación, y ninguno de ellos es adecuado en todos los casos.


Entonces, por ejemplo, puede lograr un enfoque de golang para los errores usando clases sealed , y donde no sea muy conveniente, pasar a errores unchecked .


O, en la mayoría de los lugares, nullable tipo nullable como marcador de que no fue posible calcular el valor u obtenerlo de alguna parte (por ejemplo, como un indicador de que el valor no se encontró en la base de datos).


Y si tiene un código completamente funcional junto con la arrow o alguna otra biblioteca similar, lo más probable es que use Either .


En cuanto a los servidores http, es más fácil elevar todos los errores a puntos centrales y solo en algunos lugares se combina el enfoque nullable con clases sealed .


¿Me alegrará ver en los comentarios que está utilizando esto, o tal vez hay otros métodos convenientes para el manejo de errores?


¡Y gracias a todos los que leyeron hasta el final!

Source: https://habr.com/ru/post/471766/


All Articles