JS: Introducción a la POO

Teoría: Excepciones

Errores y excepciones en la programación

Una de las áreas más importantes en la programación es el manejo de errores. Hasta ahora hemos logrado evitarlos, pero en el mundo real, donde las aplicaciones contienen miles, decenas de miles e incluso millones de líneas de código, el manejo de errores afecta muchas cosas: la facilidad de modificación y extensión, el comportamiento adecuado del programa para el usuario en diferentes situaciones.

En esta lección, vamos a examinar el mecanismo de excepciones. Pero antes de estudiar las nuevas construcciones, hablemos sobre los errores en general.

En JavaScript, las cadenas de texto tienen un método llamado text.indexOf(str). Busca una subcadena str dentro del texto text y devuelve el índice de inicio de esa subcadena en el texto. ¿Qué sucede si la subcadena no se encuentra? ¿Es esto un error? No, este es el comportamiento normal de la función. No pasa nada grave si la subcadena no se encuentra. Imagina cualquier editor de texto y su mecanismo de búsqueda. La situación en la que no se encuentra nada ocurre constantemente y no afecta el funcionamiento del programa.

Por cierto, echa un vistazo a la documentación de esta función para ver cómo indica que la subcadena no se encontró.

Otra situación. En los mismos editores de texto, hay una función llamada "abrir archivo". Imagina que algo salió mal durante la apertura del archivo, por ejemplo, si se eliminó. ¿Es esto un error o no? Sí, en esta situación se produce un error, pero no es un error de programación. Este tipo de error puede ocurrir siempre, independientemente de la voluntad del programador. No puede evitar que ocurra. Lo único que puede hacer es implementar correctamente su manejo.

Otra pregunta interesante es: ¿qué tan crítico es este error? ¿Debe detener toda la aplicación o no? En aplicaciones mal escritas, donde el manejo de errores está implementado incorrectamente, esta situación llevará al colapso de toda la aplicación y se cerrará. En una aplicación bien escrita, no sucederá nada grave. El usuario verá una advertencia de que el archivo no se puede leer y podrá elegir acciones posteriores, como intentar leerlo nuevamente o realizar otra acción.

Lo dicho anteriormente tiene consecuencias muy serias. La misma situación puede ser considerada un error o una situación normal en diferentes niveles. Por ejemplo, si la tarea de una función es leer un archivo y no pudo hacerlo, desde el punto de vista de esa función, se produjo un error. ¿Debe esto llevar a la detención de toda la aplicación? Como descubrimos anteriormente, no debería. La aplicación que utiliza esta función puede tomar la decisión de qué tan crítica es esta situación, pero no la función en sí misma.

Códigos de retorno

En los lenguajes de programación que aparecieron antes de 1990 (aproximadamente), el manejo de errores se realizaba a través de un mecanismo de retorno de un valor especial por parte de la función. Por ejemplo, en C, si una función no puede realizar su tarea, debe devolver un valor especial, ya sea NULL o un número negativo. El valor de este número indica qué error ocurrió. Por ejemplo:

int write_log()
{
    int ret = 0; // valor de retorno 0 si tiene éxito
    FILE *f = fopen("logfile.txt", "w+");

    // Verificar si se pudo abrir el archivo
    if (!f)
        return -1;

    // Verificar que no se haya alcanzado el final del archivo
    if (fputs("hello logfile!", f) != EOF) {
        // continuar usando el recurso del archivo
    } else {
        // El archivo ha terminado
        ret = -2;
    }

    // No se pudo cerrar el archivo
    if (fclose(f) == EOF)
        ret = -3;

    return ret;
}

Observa las construcciones condicionales y la asignación constante de la variable ret. Básicamente, cada operación potencialmente peligrosa debe ser verificada para asegurarse de que se haya realizado correctamente. Si algo sale mal, la función devuelve un código de error especial.

Aquí es donde comienzan los problemas. Como la vida nos ha enseñado, en la mayoría de los casos, el error no se maneja en el lugar donde ocurrió, ni siquiera en el nivel superior. Supongamos que hay una función A que llama a un código que potencialmente puede generar un error, y A debe manejarlo correctamente y notificar al usuario sobre el problema. Sin embargo, el error en sí ocurre dentro de la función E, que se llama dentro de A no directamente, sino a través de una cadena de funciones: A => B => C => D => E. ¿Qué crees que sucede en este caso? Todas las funciones en esta cadena, a pesar de que no manejan el error, deben conocerlo, capturarlo y devolver el código de error al exterior. Como resultado, el código que se ocupa de los errores se vuelve tan grande que se pierde el código que realiza la tarea original.

Cabe mencionar que existen esquemas de manejo de errores que no tienen estas desventajas, pero funcionan según el principio de retorno. Por ejemplo, el monad Either.

Excepciones

Es en este contexto que surge el mecanismo de excepciones. Su objetivo principal es transmitir el error desde el lugar donde ocurrió hasta el lugar donde se puede manejar, evitando todos los niveles intermedios. En otras palabras, el mecanismo de excepciones desenrolla automáticamente la pila de llamadas.

Hay dos cosas que debes recordar sobre las excepciones: el código en el que se produce el error lanza la excepción y el código en el que se maneja el error la captura.

// Función que puede lanzar una excepción
const readFile = (filepath) => {
  if (!isFileReadable(filepath)) {
    // throw: forma de lanzar una excepción
    throw new Error(`'${filepath}' no se puede leer`);
  }
  // ...
};

// En otro lugar del programa

const run = (filepath) => {
  try {
    // Función que llama a readFile. Puede ser llamada no directamente, sino a través de 
    // otras funciones.
    // Para el mecanismo de excepciones, esto no es importante.
    readFile(filepath);
  } catch (e) {
    // Este bloque se ejecuta solo si se lanzó una excepción en el bloque try
    // Cualquier manejo de errores, por ejemplo, imprimir en la consola
    console.log(e);
  }
  // Si hay código aquí, se seguirá ejecutando
};

Las excepciones en sí son objetos Error. Estos objetos contienen un mensaje pasado al constructor, una traza de la pila y otros datos útiles.

Las excepciones se lanzan con la palabra clave throw:

const e = new Error('Cualquier texto');
throw e; // También puedes crear una excepción por separado o lanzarla directamente donde 
         // se utiliza throw

También puedes lanzar cualquier expresión:

const message = 'Cualquier texto';
throw message;

throw interrumpe la ejecución del código. En este sentido, es similar a return, pero a diferencia de este último, no solo interrumpe la ejecución de la función actual, sino también de todo el código, hasta el bloque catch más cercano en la pila de llamadas.

La construcción try catch es una instrucción especial con dos bloques que permite capturar todas las excepciones y manejarlas.

El primer bloque se coloca después de try:

try {
  // ...
}

Cualquier excepción lanzada por el código dentro de este bloque será capturada y pasada al segundo bloque. Si no hay errores, este bloque se omite.

catch (e) {
  // ...
}

Dentro de este bloque, el error estará disponible en la variable e. Puedes elegir cualquier nombre para la variable:

try {
  // ...
} catch (myError) {
  console.log(myError);
}

Dentro del bloque catch, puedes ejecutar cualquier código, incluso lanzar nuevas excepciones que pueden ser capturadas por un bloque try catch en un nivel superior:

const myFunc = () => {
  try {
    // ...
  } catch (e) {
    throw new Error('nuevo error');
  }
};

try {
  myFunc();
} catch (e) {
  console.log(e); // => nuevo error
}

El bloque try/catch generalmente se coloca en el nivel superior del programa, pero no es obligatorio. Es muy probable que haya varios bloques intermedios que puedan capturar errores y lanzarlos nuevamente. Este tema es bastante complejo y requiere cierta experiencia en el trabajo.