“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.
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.