Todo proceso tiene un punto de inicio y un punto de finalización, una serie de pasos
que hay que cumplir para llegar del punto A al punto B. Generalmente esto constituye
el llamado “camino feliz” (Happy Path). Frecuentemente este camino tiene muchas
bifurcaciones y situaciones a considerar para el software funcione de manera adecuada.
El cómo estructuramos dichos “caminos” es lo que puede hacer a nuestro código enredoso,
poco escalable y difícil de mantener (y crear).
Si es bien es cierto que uno se tiene que prepara para los fallos (para que el software
falle) la ruta principal (de ejecución) debe ser un camino limpio y fácilmente trazable.
Incluso desde el momento del diseño, no podemos dejarnos abrumar con todas las excepciones
y giros posibles de la lógica de negocio, debemos diseñar el camino feliz, y asumir
que habrá excepciones en algún momento. Esto es porque si bien el camino principal
suele ser sencillo, los recovecos, bifurcaciones y excepciones suelen ser muchas,
muy variadas y a veces difíciles de ver, si el cambio principal y los secundarios
están entrelazados obtendremos un código sumamente difícil de comprender.
Repasemos un poco de la historia de los controles de errores en los lenguajes, para
entender como interfieren en la flujo de un sistemas.
El siguiente ejemplo es de C (Extraído de
https://en.wikibooks.org/wiki/C_Programming/Error_handling)
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | #include <stdio.h> /* perror */ #include <errno.h> /* errno */ #include <stdlib.h> /* malloc, free, exit */ int main( void ) { /* Pointer to char, requesting dynamic allocation of 2,000,000,000 * storage elements (declared as an integer constant of type * unsigned long int). (If your system has less than 2 GB of memory * available, then this call to malloc will fail.) */ char *ptr = malloc (2000000000UL); if (ptr == NULL) { perror ( "malloc failed" ); /* here you might want to exit the program or compensate for that you don't have 2GB available */ } else { /* The rest of the code hereafter can assume that 2,000,000,000 * chars were successfully allocated... */ free (ptr); } exit (EXIT_SUCCESS); /* exiting program */ } |
#include <stdio.h> /* perror */ #include <errno.h> /* errno */ #include <stdlib.h> /* malloc, free, exit */ int main(void) { /* Pointer to char, requesting dynamic allocation of 2,000,000,000 * storage elements (declared as an integer constant of type * unsigned long int). (If your system has less than 2 GB of memory * available, then this call to malloc will fail.) */ char *ptr = malloc(2000000000UL); if (ptr == NULL) { perror("malloc failed"); /* here you might want to exit the program or compensate for that you don't have 2GB available */ } else { /* The rest of the code hereafter can assume that 2,000,000,000 * chars were successfully allocated... */ free(ptr); } exit(EXIT_SUCCESS); /* exiting program */ }
Antes de nada recordemos algunas cosas de C.
- C no tiene un tipo booleano, cualquier cosas diferente de cero es verdadero y cero es falso.
- C solo permite pasar parámetros por valor, si queremos modificar el valor contenido en una dirección de memoria tenemos que pasarla mediante un puntero (que a su vez se pasa por valor).
- Las funcionen de C generalmente devuelve un puntero a un resultado, o cero en el caso que no hayan tenido éxito, también pueden devolver un valor numérico y devolver un resultado en un putero pasando como parámetro.
Esto genera un problema puesto que una función tiene que devolver dos cosas, primero
indicar si ha tenido éxito o no, y después un valor con el resultado. Una funciona
solo y exclusivamente debiera devolver su resultado, que falle por cualquier motivo,
es una circunstancia excepcional que no debiera estar involucrado en su resultado.
C no tiene un control de errores establecido como otros lenguajes modernos, esto
es que si ocurre un fallo, posiblemente no nos demos cuenta (si no lo revisamos),
y el programa siga corriendo hasta que comience a suceder cosas extrañas y falle
al querer acceder a una posición de memoria prohibida (lo que se conoce por como
segment
fault). Esto puede ocurrir bastante lejos, en líneas de código y en
tiempo de ejecución, desde donde está realmente el problema, con lo que es terriblemente
difícil diagnosticar este tipo de problemas.
Lo anterior provoca que tenga
validarse el resultado de cada función antes de poder usarlo, con lo que después
de cada llamada, debe hacer una serie de sentencias de verificación. veámoslo en
el ejemplo:
1 2 3 4 5 6 7 8 | char *ptr = malloc (2000000000UL); if (ptr == NULL) { perror ( "malloc failed" ); /* here you might want to exit the program or compensate for that you don't have 2GB available */ } |
char *ptr = malloc(2000000000UL); if (ptr == NULL) { perror("malloc failed"); /* here you might want to exit the program or compensate for that you don't have 2GB available */ }
El objetivo es reservar un número determinado de memoria, la función nos devolverá
NULL (que es una constante para cero) o una dirección de memoria en caso
contrario, aunque el ejemplo es trivial vemos que debemos comparar el resultado
de la función siempre que la usemos para estar seguros que se nos regresa una valor
apropiado. si usamos la función N veces, debemos comprobar el resultado N
veces y prácticamente así con todas las funciones que lleguemos a usar. Esto fue
una práctica común de programación durante mucho tiempo.
El problema es que nuestro código se llenaba de condicionales (if) para verificar
resultados y no solo eso sino que de alguna forma las funciones que creábamos debieran
considerar esas situación, con lo que estamos obligados a implementarlas de igual
manera, es decir debíamos devolver de alguna forma, ya sea como resultado de la
función o como un parámetro de salida (en forma de puntero), si la función a tenido
éxito y por otro lado el resultado.
Una vez que hemos establecido que debemos comprobar si una funciona ha tenido éxito
antes de poder usar su resultado, tenemos el problema de que debemos hacer si ha
fracaso y como comunicar al usuario el motivo del fracaso. Evidentemente debemos
interrumpir el flujo del programa, pudiéramos volver a intentarlo o podríamos retornar
a la función llamadora, en cualquier caso debemos usar una estructura if,
de forma más o menos complica, y que en un determinado momento, haría que nuestro
código se convergiera en código espagueti.
En lenguajes más modernos las situaciones inesperadas o errores se controlan fuera
del flujo principal, son las llamadas excepciones, cuya estructura en general es
parecida a esta (esta es en c#, sacado de
https://docs.microsoft.com/en-us/dotnet/csharp/programming-guide/exceptions/)
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | class ExceptionTest { static double SafeDivision( double x, double y) { if (y == 0) throw new System.DivideByZeroException(); return x / y; } static void Main() { // Input for test purposes. Change the values to see // exception handling behavior. double a = 98, b = 0; double result = 0; try { result = SafeDivision(a, b); Console.WriteLine( "{0} divided by {1} = {2}" , a, b, result); } catch (DivideByZeroException e) { Console.WriteLine( "Attempted divide by zero." ); } } } |
class ExceptionTest { static double SafeDivision(double x, double y) { if (y == 0) throw new System.DivideByZeroException(); return x / y; } static void Main() { // Input for test purposes. Change the values to see // exception handling behavior. double a = 98, b = 0; double result = 0; try { result = SafeDivision(a, b); Console.WriteLine("{0} divided by {1} = {2}", a, b, result); } catch (DivideByZeroException e) { Console.WriteLine("Attempted divide by zero."); } } }
La estructura es try/catch/finally. lo interesante de esta estructura es
que mantiene el flujo principal de la proceso dentro de la instrucción try,
mientras que la gestión de errores y situaciones anormales se mantiene dentro de
instrucciones catch (una por cada tipo de excepción)
Analicemos la primera función
1 2 3 4 5 6 | static double SafeDivision( double x, double y) { if (y == 0) throw new System.DivideByZeroException(); return x / y; } |
static double SafeDivision(double x, double y) { if (y == 0) throw new System.DivideByZeroException(); return x / y; }
El objetivo de la función es realizar divisiones evitando dividir por cero, para
lo cual valida si el segundo parámetro de entrada (el dividendo, la y), es un cero.
Si es cero emerge una excepción. Esta es la principal diferencia, no se devuelve
un parámetro de salida o un retomo de función indicando si tuvo éxito la ejecución,
el único resultado posible de la función (como tal) es la misma división, si ocurriera
algo inesperado se lanzaría una excepción que interrumpirá el flujo normal de ejecución.
Analicemos la segunda función
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 | static void Main() { // Input for test purposes. Change the values to see // exception handling behavior. double a = 98, b = 0; double result = 0; try { result = SafeDivision(a, b); Console.WriteLine( "{0} divided by {1} = {2}" , a, b, result); } catch (DivideByZeroException e) { Console.WriteLine( "Attempted divide by zero." ); } } |
static void Main() { // Input for test purposes. Change the values to see // exception handling behavior. double a = 98, b = 0; double result = 0; try { result = SafeDivision(a, b); Console.WriteLine("{0} divided by {1} = {2}", a, b, result); } catch (DivideByZeroException e) { Console.WriteLine("Attempted divide by zero."); } }
El bloque try como tal no tiene ningún control de errores, ni ninguna sentencia
condición que altere el flujo de ejecución
1 2 | result = SafeDivision(a, b); Console.WriteLine( "{0} divided by {1} = {2}" , a, b, result); |
result = SafeDivision(a, b); Console.WriteLine("{0} divided by {1} = {2}", a, b, result);
los esperado es que la función devuelva la división y que se muestre el mensaje
en pantalla.
El catch maneja (de forma aparte), cualquier tipo de error en el proceso.
1 2 3 4 | catch (DivideByZeroException e) { Console.WriteLine( "Attempted divide by zero." ); } |
catch (DivideByZeroException e) { Console.WriteLine("Attempted divide by zero."); }
Como vemos queda perfectamente clara cuál es el proceso principal y cuáles son las
excepciones, siendo además extremadamente escalable.
El único problema que pudiéramos es cuantas bloque try/catch podemos usar
y donde es idóneo colocarlas. Bueno aquí tener en cuenta un principio básico y es
que las funciones deben ser lo más reducidas posibles, y realizar una exclusiva
tarea, así que jamás debiéramos tener que anidar dos (o más) bloques try/catch.
Sobre donde debemos poner los bloques try/catch, por el mismo motivo, funciones
simplificada y únicas, las funciones mas internas (o privadas) realizarían menos
actividades, con lo que posiblemente tengan una necesidad menor de controlar excepciones
y si de emitirlos, así que se podría decir que las capas más internas de nuestra
aplicación (private, internal) emiten excepciones y las mas externas
los controlan.
Hasta ahora hemos repasado situación de control de errores o excepciones, situaciones
anómalas, ¿Qué pasa cuando no son situaciones anormales, si no que con simplemente
caminos a tomar, correctos, pero fuera del flujo principal de nuestra aplicación?
El objetivo primordial a evitar sigue siendo el mismo, evitar las sentencias condicionales
y crear un camino claro hacia el flujo principal.
Aquí entra en juego la programación orientada objetos:
-
Mediante la herencia la POO, nos permite personalizar acciones, cada camino puede ser un objeto que sobrescriba las diferencias entre una necesidad (un camino y otro).
-
Mediante El principio de la Inversión de Control, nuestro camino principal está definido claramente, pero delega en objetos (que generalmente se establecer por Inyección de Dependencias), para realizar sus acciones. Es decir si queremos cambiar el comportamiento solo tenemos que cambiar el objeto concreto que realiza las opciones.
Por ejemplo, pensemos que estamos implementando el sistema de una tienda, El flujo
principal podría ser, que el cliente selecciona un objeto y lo paga, con el pago
pudiera haber una bifurcación según el medio de pago (efectivo, tarjeta, cheque),
pero el flujo principal seria siempre el mismo, solo usaríamos un objeto que procesa
el pago acorde a la necesidad presentada.
La regla final seria "Primero el camino principal luego las excepciones", esto aplicaría
no solo a efectos de código, sino también a efectos de diseño.
No hay comentarios:
Publicar un comentario