“Dejar tus excepciones fluir” es una frase que Rodrigo Corral nos repite durante el desarrollo de software constantemente, pero, ¿qué quiere decir con esta frase?
Normalmente se debería de pensar lo contrario de las excepciones, es decir, capturarlas siempre para que no se produzcan errores en el software y que todo funcione correctamente. Pero vamos a ver a través de un ejemplo, como a veces es mucho mejor dejar a las excepciones fluir por la pila y no capturarlas.
Recientemente, en un equipo de Plain Concepts que está desarrollando unas aplicaciones para Windows Phone 7, hemos tenido la necesidad de crear una clase que nos guarde en el almacenamiento aislado datos, para que después podamos leer cuando la aplicación se arranque. En principio es una clase muy sencilla, utiliza DataContractSerializer para guardar todos los datos en el isolated storage. Aquí os muestro los métodos y propiedades de esta clase.
- Save
- Load
- Delete
- Exist
La clase es genérica, eso significa que para poder usarla tienes que pasar como parámetro el tipo que quieres guardar, y todos los métodos de Save y Load devuelven T. Además tiene dos constructores públicos, uno sin parámetros en el que se utiliza el nombre del tipo como nombre del fichero para guardarlo en Isolated Storage y otro constructor con un parámetro. La lista de know types de DataContractSerializer, por si los necesita para la serialización.
En principio el funcionamiento de la clase es bastante sencillo. Especificamos el tipo y ya podemos salvar, cargar y preguntar si el fichero existe para cargar.
Si tenemos una clase como Item declarado así:
public class Item { public string Name { get; set; } public int Age { get; set; } }
Podemos usar la clase SaveManager de esta manera:
SaveManager<Item> save = new SaveManager<Item>(); if (!save.Exist) { save.Save(new Item() { Name = "Jonh", Age = 30 }); } Item savedItem = save.Load();
Ahora viene la parte más importante de todas, que es decidir cómo vamos a tratar las excepciones dentro de la implementación de la clase SaveManager.
Nos queda claro que la clase es un envoltorio de la clase DataContractSerializer para así hacer el guardado y la carga de clases serializadas mucho más sencilla.
public class SaveManager<T> where T : class { public SaveManager() { } public SaveManager(List<Type> knownTypes) { serializer = new DataContractSerializer(typeof(T), knownTypes); saveFileName = typeof(T).Name; } public void Save(T value) { if (value == null) { throw new ArgumentNullException("value", "value can't be null"); } using (IsolatedStorageFile file = IsolatedStorageFile.GetUserStoreForApplication()) { using (Stream saveStream = file.CreateFile(saveFileName)) { serializer.WriteObject(saveStream, value); } } } public T LoadWithTryCatch() { T result = default(T); try { using (IsolatedStorageFile file = IsolatedStorageFile.GetUserStoreForApplication()) { using (Stream saveStream = file.OpenFile(saveFileName, FileMode.Open, FileAccess.Read)) { result = (T)(object)serializer.ReadObject(saveStream); } } } catch { } return result; } public T Load() { T result = default(T); using (IsolatedStorageFile file = IsolatedStorageFile.GetUserStoreForApplication()) { using (Stream saveStream = file.OpenFile(saveFileName, FileMode.Open, FileAccess.Read)) { result = (T)(object)serializer.ReadObject(saveStream); } } return result; } public T LoadWithAllTryCatc() { T result = default(T); try { using (IsolatedStorageFile file = IsolatedStorageFile.GetUserStoreForApplication()) { try { using (Stream saveStream = file.OpenFile(saveFileName, FileMode.Open, FileAccess.Read)) { result = (T)(object)serializer.ReadObject(saveStream); } } catch (IsolatedStorageException isolatedException) { throw isolatedException; // error del isolated Storage } catch (ArgumentNullException argumentNullException) { throw argumentNullException; // error en un argumento (referencia nula) } catch (ArgumentException argumentException) { throw argumentException; // error en un argumento } catch (DirectoryNotFoundException directoryNotFoudnException) { throw directoryNotFoudnException; // directorio no encontrado } catch (FileNotFoundException fileNotFoundException) { throw fileNotFoundException; // fichero no encontrado } catch (ObjectDisposedException objectDisposedException) { throw objectDisposedException; // objecto disposeado durante su utilización } } } catch (IsolatedStorageException isolatedException) { throw isolatedException; // se ha producido un error al acceder al isolated storage } return result; } public void Delete() { using (IsolatedStorageFile file = IsolatedStorageFile.GetUserStoreForApplication()) { if (file.FileExists(saveFileName)) { file.DeleteFile(saveFileName); } } } public bool Exist { get { bool result = false; using (IsolatedStorageFile file = IsolatedStorageFile.GetUserStoreForApplication()) { result = file.FileExists(saveFileName); } return result; } } private string saveFileName; private DataContractSerializer serializer; }
¿Qué puede ir mal?
Pensando de nuevo en las excepciones, tenemos que tener claro que situaciones excepcionales se puede dar en el código y tratarlas de manera adecuada. Por situaciones excepciones nos referimos a cosas que no están planeadas en el flujo de ejecución normal de nuestra aplicación, y las excepciones son los objetos que representan los errores ocurridos durante la ejecución de la aplicación.
Veamos por ejemplo el método Load, pensemos en la lista de errores que se pueden producir:
- Que la cuota de Isolated Storage sea 0
- Que el Isolated Storage este deshabilitado.
- Que la ruta del fichero esté mal formada
- Que la ruta del fichero sea nula
- Que el directorio del que se quiere leer no exista
- Que no se encuentre el fichero
- Que no se encuentre el fichero y el modo de apertura esté en Open
- Que el contenido del fichero no sea una serialización válida del tipo que estamos intentando leer.
La lista de excepciones es bastante larga, ¿de dónde viene esta lista de excepciones? Si miramos a la implementación del método,
public T Load() { T result = default(T); using (IsolatedStorageFile file = IsolatedStorageFile.GetUserStoreForApplication()) { using (Stream saveStream = file.OpenFile(saveFileName, FileMode.Open, FileAccess.Read)) { result = (T)(object)serializer.ReadObject(saveStream); } } return result; }
Nos damos cuenta que el método Load utiliza dos clases para hacer el trabajo, IsolatedStorageFile y DataContractSerializer. Así que viendo el uso de estas dos clases, uno ya se imagina de donde pueden venir las excepciones de la lista anterior.
Ahora bien, pongamos como ejemplo file.OpenFile(), que nos permite abrir un fichero que está en el isolated storage. Según la lista anterior tenemos 5 tipos diferentes de excepciones que se pueden producir al llamar al método OpenFile. Pero ahora bien, ¿Cuál es la mejor estrategia para tratar esas excepciones?
Deja las excepciones fluir
Hasta ahora nos hemos centrado en definir un escenario para poder discutir cual es la mejor opción para tratar esas excepciones. Según lo dicho hasta ahora, tenemos un método Load que no acepta ningún parámetro y que devuelve una instancia recién creada del tipo T leído desde el Isolaged Storage con el nombre del tipo T.
Ahora bien, tenemos dos aproximaciones a la hora de usar las excepciones, podemos por un lado, envolver el código en un gran Try/Catch cacheando una excepción de tipo System.Exception.
public T LoadWithTryCatch() { T result = default(T); try { using (IsolatedStorageFile file = IsolatedStorageFile.GetUserStoreForApplication()) { using (Stream saveStream = file.OpenFile(saveFileName, FileMode.Open, FileAccess.Read)) { result = (T)(object)serializer.ReadObject(saveStream); } } } catch { } return result; }
El envolver el código así nos permite controlas las excepciones que se producen, pero una vez que se produce una excepción, según este código, estamos haciendo un catch sin código y como la variable result se ha inicializado a default(T) nos damos cuenta que el método Load, en caso de que se produzca una excepción, devolverá null.
¿Es esto una buena aproximación?, depende del implementador de la clase, en este caso nosotros. Tendríamos que documentar que en caso de que se produzca una excepción el método devuelve null. Desde mi punto de vista esto me parece erróneo, porque lo que estamos haciendo es ocultar el problema detrás de un catch y el llamador de la función no sabrá jamás cual es el motivo por el que se produce la excepción, podría hacer sido un feichero que no existe, que el DataContractSerializer lance una excepción porque se te ha olvidado decorar con un DataContract la clase base del tipo que estás serializando, podrían ser miles de cosas, pero nosotros decidimos devolver un null. Como ya he dicho esto reduce la visibilidad del problema, hace que la depuración de código que utiliza este componente sea mucho más complicada, ya que no devuelve ninguna información sobre el error ocurrido.
La segunda aproximación que tenemos es justamente tratar todos los tipos de excepciones, escribir código para todos los tipos y relanzar las excepciones de nuevo.
public T LoadWithAllTryCatc() { T result = default(T); try { using (IsolatedStorageFile file = IsolatedStorageFile.GetUserStoreForApplication()) { try { using (Stream saveStream = file.OpenFile(saveFileName, FileMode.Open, FileAccess.Read)) { result = (T)(object)serializer.ReadObject(saveStream); } } catch (IsolatedStorageException isolatedException) { throw isolatedException; // error del isolated Storage } catch (ArgumentNullException argumentNullException) { throw argumentNullException; // error en un argumento (referencia nula) } catch (ArgumentException argumentException) { throw argumentException; // error en un argumento } catch (DirectoryNotFoundException directoryNotFoudnException) { throw directoryNotFoudnException; // directorio no encontrado } catch (FileNotFoundException fileNotFoundException) { throw fileNotFoundException; // fichero no encontrado } catch (ObjectDisposedException objectDisposedException) { throw objectDisposedException; // objecto disposeado durante su utilización } } } catch (IsolatedStorageException isolatedException) { throw isolatedException; // se ha producido un error al acceder al isolated storage } return result; }
Escribiendo un manejador por cada uno de los tipos de excepciones, como se puede apreciar, disminuye la legibilidad del código y hace que la complejidad del método aumente, haciendo que mantenibilidad disminuya.
¿Esta aproximación nos aporta algo? Pensando de nuevo en nosotros mismo, los implementadores de la clase, que me aporta saber que el fichero no existe a la hora de leer el fichero para de serializar. Aunque sea capaz de interceptar una excepción de fichero no encontrado no podría hacer nada, porque la responsabilidad de mi clase se centra en leer el fichero y de serializarlo con DataContractSerializar, no es mi responsabilidad asegurarme que el fichero este ahí, sino el llamador. Imaginaros ahora que también se lanza una excepción durante la de serialización del objeto, ¿qué debería de hacer? ¿Notificar al llamador de que se ha producido la excepción?, ¿Intentar otra aproximación?, no tengo muchas opciones puesto que si el desarrollador se ha olvidado de poner el DataMember o el DataContract en alguna de las clases no voy a poner solucionar ninguno de los problemas del serializador, así que realmente, de nuevo, la responsabilidad de este error no es el implementador sino del desarrollador que la usa.
Así que de nuevo llegamos al método que teníamos implementado al principio del artículo:
public T Load() { T result = default(T); using (IsolatedStorageFile file = IsolatedStorageFile.GetUserStoreForApplication()) { using (Stream saveStream = file.OpenFile(saveFileName, FileMode.Open, FileAccess.Read)) { result = (T)(object)serializer.ReadObject(saveStream); } } return result; }
Una implementación en la que no existe ningún bloque de Try/Catch. ¿Por qué?, porque no teniendo ningún bloque de código solucionamos los problemas de saber qué hacer con la excepción y la de notificar al usuario que algo ha ocurrido mal.
Así si durante el desarrollo de la aplicación que se esté usando esta clase, nos damos cuenta que la hacer un llamada al método Load, nos lanza una excepción directamente a nosotros, diciendo que el fichero no existe, ¿Qué representa este error?, no que tengamos un error en sí en el código del SaveManager, sino que si estamos asumiendo que debería de haber un fichero en el Isolated storage, previamente guardado con las opciones del usurario y ahora no está, que tenemos un bug en nuestro software. Es decir, que el hecho de que la excepción se lance y fluya a través de la pila hasta el gestor de preferencias de nuestra aplicación, es un síntoma de que tenemos un error en nuestro software, de que no estamos guardando correctamente las preferencias del usuario o que no estamos comprobando antes de leer el fichero que el fichero existe.
Viendo las excepciones de esta manera, uno utiliza las excepciones como mecanismo para parar la ejecución del código y notificar al usuario de que algo no está bien. Si, por ejemplo, en nuestra primera implementación hubiéramos devuelto nulo en el método Load, nunca nos habríamos dado cuenta de que no estábamos guardando los datos, o que al guardar los datos tenemos algún error.
Así que la conclusión a la que podemos llegar es que es bueno dejar que las excepciones fluyan por el código y que salvo en contadas ocasiones hagamos un catch. Esto por supuesto no es una afirmación que se pueda extender a todas las clases, porque depende de la implementación que estemos haciendo. En este caso concreto lo que estamos haciendo es utilizando bloques externos, para generar una funcionalidad concreta para nuestra aplicación, bloques, que en sí mismo manejan de manera correcta la excepciones.
La clave para decidir si tenemos que colocar un bloque de Try/Catch supongo que viene dado por la responsabilidad de las operaciones, es decir, si yo que estoy implementado la clase tengo que ser responsable de asegurarme que el fichero exista y si no existe crearlo, entonces debería de hacer las comprobaciones o poner los bloques de Try/Catch adecuados para asegurarme de que el fichero existe. Otra manera de pensar en cómo tratar las excepciones es pensar que si la ejecución del código tiene sentido cuando no existe un fichero. Es decir, ¿puedo implementar un método Load teniendo en cuenta que el fichero no existe?, en mi opinión creo que no, porque justamente ese es uno de los requisitos del método, leer el fichero y de serializarlo. Si el fichero no existe no podemos continuar.
Llevando este concepto al extremo nos encontramos con los BSOD de Windows, los pantallazos azules de Windows. ¿Por qué existen los BSOD? La primera respuesta a esta pregunta es porque existe una función para el modo kernel que se llama, KeBugCheckEx, que según la MSDN “permite apagar el sistema de manera controlada cuando el llamador descubre una inconsistencia irrecuperable que podría hacer que el sistema se corrompiese si el llamador continúa ejecutándose”. La definición lo deja bastante claro, si resulta que se ha corrompido la memoria de alguna manera y sabemos a ciencia cierta que después de esa comprobación el sistema va a dejar de funcionar correctamente, lo que tenemos que hacer es lanzar una excepción, es decir, un suicidio controlado de Windows que permita verificar el problema. Podemos ver entonces el BSOD como un síntoma de que hay un problema, no como un problema en sí. Pues esta manera que tiene Windows de avisarnos que hay un problema es el mismo ejemplo de nuestro método Load(), a diferentes niveles está claro. Pero si lo pensamos así qué sentido tiene intentar de serializar una clase de un fichero, si el fichero no existe. Pues ninguna.
Por eso no vale de nada que pongamos un catch al final del método porque es un requisito que tengamos el fichero disponible, por eso las excepciones se llaman así porque son situaciones excepcionales, que no se preveían en el flujo de ejecución. Yo como implementador del método no me espero que no exista el fichero, pero puede que el que realiza la llamada sí, así que es su responsabilidad hacer algo con esta excepción no yo.
Utilizar las excepciones como método de validación
Este es otro tópico de las excepciones que también se suele tratar de manera incorrecta, sale mucho más barato desde el punto del rendimiento comprobar que los parámetros son distintos de nulo y del tipo adecuado que envolverlo todo en un Try/Catch enorme y devolver nulo. Pero este es un tema que veremos en otro post.
El código de ejemplo aquí.
Luis Guerrero.
Excelente artículo Luis, lo dificil es conocer cuando debemos utilizarlas y cuando no, y que en un equipo de trabajo todos utilicen las mismas reglas, a veces abusamos de las excepciones, otras sin embargo no las utilizamos cuando deberiamos, espero impaciente el siguiente artículo.
Un saludo.
Gran post.
La clave, para mi, es la frase «Es decir, ¿puedo implementar un método Load teniendo en cuenta que el fichero no existe?, en mi opinión creo que no, porque justamente ese es uno de los requisitos del método, leer el fichero y de serializarlo. Si el fichero no existe no podemos continuar.»
Exactamente, la existencia del fichero es una precondición de tu método. Ni más, ni menos. Una llamada a tu método con un fichero inexistente incumple la precondición por lo que la llamada es inválida. Y como desarrolladores NO deberíamos jamás de «protegernos» ante llamadas inválidas.
Un saludo!
@Juan Irigoyen gracias por tu comentario, muchas veces también es el sentido común el que tiene que decirte cuando usarlas o no, hay que pararse tiempo para pensar que estamos implementando y cómo se va a usar.
@Eduard justamente una precondición de mi método es que exista el fichero, pero también comento que no es necesario tratar esa excepción por que el propio método OpenFile ya se encarga de hacerlo y no tengo que volver a comprobar lo mismo. De hecho como en la clase tengo una propiedad Exist podría haber escrito código para comprobar que el fichero existe antes de hacer la llamada, pero he preferido que sea el propio clr el que lance la excepción por si solo y no yo.
@Luis
Correcto, estamos diciendo lo mismo 😀 Sólo era para enfatizar 😉
Es cierto, puedes usar Exist para verificar que existe el fichero. Y si no existe qué?
Lanzas una excepción?
Devuelves null?
En el primer caso todo queda igual (excepción o excepción) y en el segundo caso volvemos al tema de los null que comentabas al principio.
Así que al final como dices… que fluyan las excepciones!
Saludos!
Esta es una de las cosas que más me generar un conflicto interno cada vez que desarrollo código, por que necesitas saber si vas a usar tú la clase o no, tener un gran conocimiento de la API del framework en sí misma, para poder determinar que el método OpenFile lanza una excepción (o por lo menos consultar la documentación).
También otra de las cosas por las que vale mucho dejar las excepciones fluir, es justamente por la búsqueda de errores. El mejor caso a la hora de buscar un error es que siguiendo una serie de pasos se lance una excepción y podamos capturarla y solucionarla. Pero si en vez de eso nos encontramos, con la típica salida de Visual Studio de: “FileNotFoundException on mscorlib.dll” ¿Qué solución le damos a eso?, ¿por dónde empezamos a buscar ese error? Hay que ser un virtuoso de WinDBG+SOS.
Saludos. Luis.
Excelente Luis! E
Estoy de acuerdo con la visión que ofreces, muy en la línea que comenta Eduard. En este caso la existencia del fichero lo vemos como una precondición del método Load, como una parte mas de su contrato => si no se cumple el contrato al realizar la llamada esta debe fallar. Muchas veces se tiende a tratar excepciones que no debemos/podemos tratar dentro de nuestros métodos.
Creo que, en general, se hace un uso «sobredimensionado» de las excepciones y las usamos para solucionar problemas que nada tiene que ver con las excepciones, por ejemplo para controlar el flujo del programa…
Saludos!
Hola Josep Maria!
Exacto, lo que comentas del uso indebido de las excepciones, es justamente la parte que quiero tratar en el siguiente post, hablar de no usar las excepciones para hacer validaciones y controlar el flujo de ejecución de tu software (salvo en contada ocasiones).
Gracias por el comentario. Luis.
Muy bueno el articulo y estoy de acuerdo en lo que dices. Pero a menudo me he encontrado en situaciones en la que justificar esto cuesta trabajo, por dos siguientes motivos.
1.- Hay muchos programadores que no entienden lo que es una excepción. Y no me refiero solo a como están implementadas internamente, si no que una excepción es algo excepcional. Y aunque suene ridículo, la gente le cuesta asumir la idea.
2.- Y otro problema en trabajar de esta forma, es que las librerías hay que depurarlas muy bien. Y eso es tiempo. Aunque así sea más fácil el depurarlas y el resultado final es bastante mejor, es difícil justificarlo.
Otro tema que hay que tener muy en cuenta es que a los usuarios no les interesan las excepciones. Con lo que las excepciones que no consigas capturar, escóndelas (al usuario, no a ti mismo) Así que mételas en un fichero o similares.
Sería interesante hacer un estudio del tiempo que se tarda en dejar fino una aplicación. Desde que supuestamente cumples todas las especificaciones, hasta que consigues que todos los posibles problemas que tenga la solución tengan un tratamiento elegante.
Hola Andrechi!
Como también dice Rodrigo Corral, “la calidad no es opcional”. Así que no tendrías que justificar el tener que hacer las cosas bien, sino que siempre las tendríamos que hacer bien. Lo que comentas en el punto 2, es que es más fácil pensar las cosas antes que intentar esconder el bulto haciendo un Try/Catch vacío, al final sale más caro.
Es cierto que a los usuarios no les interesa las excepciones, y debería de ser así. Yo lo único que estoy diciendo es que si estás haciendo una clase o API, para que otras personas la usen, que deberías de dejar fluir las excepciones para no aumentar la complejidad del componente, y ceder la responsabilidad al que usa tu clase. También digo que una excepción te muestra un síntoma de un error, y que la excepción en sí no es un problema, sino que representa una condición que no se debería de haber dado en tú código pero lo ha hecho. Por supuesto que tienes que tener esto en cuenta, pero hay maneras de hacer comprobaciones para no lanzar excepciones porque sí.
Fíjate, por ejemplo, el clásico ejemplo de conectarte a una base de datos SQL Server remota, y que la base de datos no está disponible. Donde haces el Try/Catch, ¿en la capa de acceso a datos? (por simplificar), o lo haces en la UI que consume ese servicio de datos. Según lo dicho hasta ahora no deberías de tener ningún Try/Catch en el código de la capa de acceso a datos, y si un Try/Catch cuando intentas hacer login por primera vez en la app, y si la Api de acceso a datos, lanza una excepción, entonces es el momento de que mires el tipo, el mensaje y muestres un mensaje amigable al usuario diciendo lo que ha pasado, pero nunca en la API de acceso a datos en sí.
Saludos. Luis.
Muuuy buena reflexión y bien argumentada. Es difícil que los desarrolladores entiendan esta forma de trabajar quizás guiada por el énfasis de los formadores en «controla tu código, controla tus excepciones»…
Me guardo este artículo en la cartera 😛