Excepciones como errores: ¿Sí o no?

Bueno, he aquí un dilema que es más o menos como el tipado estático vs el dinámico o el preferir espacios o tabuladores: es decir, preferencia personal. Pero a veces las preferencias personales se ven influenciadas por lo que conocemos (o más precisamente por lo que desconocemos)… Así que dejadme que os cuente cuatro cosas al respecto y ya si eso, luego lo discutimos en los comentarios o en un bar…

En C# es habitual usar excepciones para gestionar errores. Si llamas a File.Open y el fichero no existe, pues se lanza una excepción. Si intentas convertir la cadena «pepe» a un entero, con int.Parse, pues se genera una excepción. Por lo general las funciones en C# no devuelven un error: devuelven un resultado y si hay alguna causa que les impide generar dicho resultado, se lanza una excepción.

Otra aproximación clásica es devolver un código de error, si hay eso… un error. P. ej. File.Open podría devolver el FileStream asociado o null si no ha podido abrir el fichero. Claro, que la aproximación de devolver un código de error tiene un problema: a veces no hay valor de retorno posible para indicar un error. A veces todo el espectro de los posibles valores de retorno son valores válidos. Por ejemplo la función int.Parse… ¿qué código de error puede devolver? Cualquier entero es un valor válido que la función puede devolver. Si int.Parse devuelve un int, no hay valor que pueda indicar el código de error. Esa limitación se puede resolver con dos feas aproximaciones, ninguna de las cuales es perfecta:

  • Asumiendo que exista un tipo de retorno con un rango de valores mayor, devolver un valor de tipo de retorno. P. ej. en C, la función getchar, devuelve un int. Podría devolver un char, pero devuelve un int. El motivo es que el rango de valores de int es superior (e incluye en su totalidad) el de char y eso nos da valores adicionales a usar en caso de error. Así getchar devuelve un -1 (que es un valor int válido, pero fuera del rango de char) para indicar un error. Eso bueno… es más feo que pegarle a un padre, porque tu lees un carácter y obtienes un int, que luego debes convertir a char.
  • Se asume un valor como error. Un ejemplo de eso es la función VAL de BASIC, que convierte una cadena a un entero. Si la cadena no es un número válido, VAL devuelve 0. Pero la cadena «0» (que es un número válido), devuelve, obviamente, también 0. Por lo tanto saber si recibes un 0 porque la cadena era «0» (o «00» o «0000000») o bien era inválida se complica.

Ante esos problemas, parece que lanzar excepciones es una buena solución: no me obliga a devolver «otros tipos de datos» y siempre sé que si obtengo resultado es que todo ha ido bien. Pero, honestamente, no sé si es la mejor de las soluciones. Porque las excepciones deberían ser eso… casos excepcionales. Que abrir un fichero falle, NO es un caso excepcional. Es un caso muy normal, especialmente si la ruta del fichero la ha entrado el usuario. Que convertir un entero a cadena falle, porque la cadena no contiene ningún número, no tiene nada de excepcional, especialmente si esa cadena la obtenemos mediante entradas que no controlamos. Y eso sin hablar de las penalizaciones en el rendimiento que tienen las excepciones.

Bueno, voy a matizar: como siempre lo que es una buena o no-tan-buena solución debe entenderse en el contexto de cada lenguaje… y aquí está la clave. Porque el contexto de C#8 y el de C#1 poco tienen que ver. Lanzar excepciones podía ser quizá la mejor solución en C#1, pero… ¿lo sigue siendo con C#7?. ¿Qué otra alternativa podríamos tener? Pues, podríamos imitar lo que hace Golang: devolver tuplas (bool, resultado). El único problema es que un bool, solo dice si ha ido o bien o mal, pero podríamos tener una clase Error que nos diera más información y devolver tuplas (Error, resultado):

    struct Error
    {
        public bool IsError { get; }
        public string Message { get; }
        public static (Error error, T value) OK<T>(T t) => (new Error(error: false), t);
        public static (Error error, T value) Build<T>(string message) => (new Error(error: true, message), default(T));
        public static implicit operator bool(Error error) => error.IsError;    
        private Error(bool error, string msg = null)
        {
            IsError = error;
            Message = msg;
        }            
    }

    static (Error error, int value) ParseInt (string s)
    {
        if (int.TryParse(s, out int vs))
        {
            return Error.OK(vs);
        }
        return Error.Build<int>($"Invalid string {s}");
    }
}

Una vez tenemos esta infrastructura montada, lo podemos consumir de varias maneras. P. ej. aprovechando que Error se transforma en  bool:

var x2 = ParseInt("edu");
if (x2.error)
{
    Console.WriteLine("Error: " + x2.error.Message);
}
else
{
    Console.WriteLine("Result is: " + x2.value);
}

O usando el pattern matching de C#7:

var result = ParseInt("9");
switch (result)
{
    case var x when x.error:
        Console.WriteLine("Error: " + x.error.Message);
        break;
    case var x when !x.error && x.value < 10:
        Console.WriteLine($"Value {x.value} is too small");
        break;
    default:
        Console.WriteLine($"Value {result.value} is OK");
        break;
}

Este código mejorará cuando tengamos C#8, gracias al uso de pattern matching recursivo:

var result = ParseInt("9");
switch (result)
{
    case var (e, _) when e:
        Console.WriteLine("Error: " + e.Message);
        break;
    case var (_, v) when v < 10:
        Console.WriteLine($"Value {v} is too small");
        break;
    default:
        Console.WriteLine($"Value {result.value} is OK");
        break;
}

Por supuesto, puede haber casos en que no queremos comprobar el error:

var (_, result) = ParseInt("100");
Console.WriteLine($"Result is {result}");

Claro, que ahora perdemos una de las supuestas ventajas de usar excepciones: que es no tener mezclado el código de gestión de errores, con el código normal. Efectivamente, tengo que usar ifs para comprobar si todo ha ido bien, aunque hay que decir que el uso de pattern matching me limita bastante estos ifs. Por eso decía antes que el lenguaje importa: en C#7 tenemos un soporte para tuplas muy bueno y un pattern matching que mejora con C#8. En versiones anteriores de C# no teníamos eso.

Usando excepciones parece que todo queda más ordenado (con el código normal dentro del try y el de gestión de erroes en el catch), pero ese modelo también tiene sus problemas: el catch se ejecuta en un ámbito distinto del try, y muchas veces no tienes acceso a variables definidas dentro del try y que necesitarías usar dentro del catch. Estoy suponiendo ciertamente que puedes tratar el error (es decir que puedes hacer algo más en el catch que no sea guardar en un log y propagar la excepción).

Además usando excepciones, a veces puedes terminar con un varios bloques try/catch dentro de un mismo método (si el método puede hacer varias cosas):

void Process()
{
    try { Step1();}
    catch (Exception ex) {...}
    try { Step2();}
    catch (Exception ex) {...}
    try { Step3();}
    catch (Exception ex) {...}
}

En este caso la función Process() ejecuta tres pasos, y si falla el primero, debe ejecutar igualmente el segundo y el tercero. Las cosas se complican si la ejecución del segundo paso depende del tipo de error que ha lanzado el primero: al final terminas con ifs… exactamente que en el caso de devolver el código de error.

En mi opinión el modelo de excepciones es solo superior al de devolver el código de error en aquellos casos en que habitualmente no podrás hacer nada con el error… salvo como mucho mostrarlo por pantalla o guardarlo en un log. Pero eso debería ser en casos excepcionales, que es para lo que se creó el modelo de excepciones. Es cierto que en C# la situación es (en mi opinión) mejor que la de Java, ya que no tenemos la cláusula throws, así que al final optamos por ignorar las excepciones… menos aquellas que podemos tratar y que muchas veces terminan en bloques try/catch en medio de un método 😉

¿Y vosotros, qué pensáis al respecto? 🙂

6 comentarios sobre “Excepciones como errores: ¿Sí o no?”

  1. Para mí las excepciones más que ayudarte a gestionar errores (que también), te ayudan a evitarlos. Te dan información sobre los problemas que te puedes encontrar.

    Una excepción debe ser lanzada cuando el método que se ejecuta se encuentra con un problema con el cual, el propio método, no puede hacer nada para solucionarlo.

    Mirandolo funcionalmente. Si yo tengo un proceso de facturación y una de las condiciones para poder crear una factura es que la fecha de la factura se encuentre en un ejercicio abierto, lanzaré la excepción EjercicioCerradoException si no cumple con este requisito, ya que el propio proceso no puede hacer nada para seguir adelante. De esta manera, será quien consuma este método quien decidirá que hacer si recibe dicha excepción.

    Por esta razón, creo también importante crear excepciones propias, o sea, no lanzar un ‘Exception(..)’ genérica, y informar en el sumary del método las diferentes excepciones que has de esperar si utilizas dicho método.

  2. Hola Eduard,

    Es un tema muy interesante el que planteas.

    En mi caso, siempre que sea algo que se puede validar prefiero tratarlo como una validación y no como una excepción.

    Para ello creo clases como el struct Error que planteas o utilizo la clase Result de la librería https://github.com/vkhorikov/CSharpFunctionalExtensions disponible a través de nuget.

    En mi caso, prefiero llamarlas con un nombre que denote «Resultado» no «Error» porque me parece una palabra más neutra teniendo en cuenta lo que representa. El Resultado puede ser erróneo o válido. Un struct Error que sea válido cuesta más de entender.

    Como siempre, gracias por compartirlo 🙂

  3. Hay otro inconveniente que no se menciona, las excepciones son costosas, de hecho yo las evito lo mas posible ya que por algo se llaman excepciones, son para casos excepcionales, no donde uno mismo puede controlar lo que pasa o no pasa en el código.

  4. Cuando se trata de algo que necesito saber si efectivamente se ha hecho, o si no, o si adicionalmente se ha producido un error, sobretodo cuando se trata de dlls compartidas en un proyecto de muchas capas y muchas manos, en los que las capas «de en medio» toman decisiones y desvirtuan el mensaje … suelo apañarme con valores de retorno del tipo KeyValuePair(Of TriState ,String ), en plan: ha ido todo ok, es True, no ha fallado pero el resultado es negativo (no coincidencia de valores, etc), pues ‘Indefinido’. Fallo por error, False. En el String van los warnings. etc.
    Y en otras ocasiones directamente un enumerado de estados. De este modo los que colaboran contigo pueden o elever la información como excepción o simplemente trazarla, etc
    Ya digo que esto no es práctica habitual porque si no sería imposible.

  5. Casos especiales cuando usas envío de correo, acceso a BBDD, acceso a redes, Azure, servicios web, etcétera.

    Y usar una Exception con StatusCode. Según los StatusCode se puede hacer «Retry Action» (según Policy).

    La «resilencia».

    https://docs.microsoft.com/en-us/dotnet/architecture/microservices/implement-resilient-applications/implement-http-call-retries-exponential-backoff-polly

    catch (SmtpFailedRecipientException ex)
    {
    SmtpStatusCode statusCode = ex.StatusCode;

    if (statusCode == SmtpStatusCode.MailboxBusy ||
    statusCode == SmtpStatusCode.MailboxUnavailable ||
    statusCode == SmtpStatusCode.TransactionFailed)
    {
    smtp.Send(message);
    await smtp.SendMailAsync(message);
    }
    else { throw; }

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *