domingo, 23 de diciembre de 2018

Regla N°17 de la Ingeniería de Software: Primero el camino principal luego las excepciones


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)

Ejemplo de código en C

  
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:

Análisis de la preservación de memoria

  
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/)

Ejemplo de código en C#

  
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

Función SafeDivision

  
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

Función Main

  
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

Bloque Try

  
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.

Bloque Catch

  
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.