Hablemos de serialización

Gracias a $root, normalmente no hay una única forma de afrontar una tarea. Eso ya lo hemos visto aquí, en posts tales como La taza de café de la vergüenza o Tres funciones de pertenencia (la fea, la guapa y la estúpida). El objetivo primordial suele ser buscar la opción que sea más sencilla de entender, o aquella que sea más mantenible (aunque normalmente sencillez y mantenibilidad van de la mano). Sin embargo, en algunas ocasiones, la solución que ha de primar por encima de todas las demás es la eficiente.

Ilustración 1, un fragmento del algoritmo de compresión LZ4

Ilustración 1, un fragmento del algoritmo de compresión LZ4

Hay escenarios en los que no podemos entregar velocidad de ejecución o tamaño en memoria a cambio de legibilidad. Un ejemplo de ello que me encanta son los algoritmos de compresión con aritmética de punteros. Que me aspen si los puedes entender al vuelo, pero seguro que funcionan a las mil maravillas.

Hace unos pocos meses estuve elaborando comparaciones de rendimiento entre diferentes técnicas de serialización. De hecho, escribí un post sobre ello y lo dejé almacenado en mi repositorio personal, pero no me decidí a publicarlo. Así que, aprovechando que le he dado un aspecto remozado al blog, y lo que le falta para terminar de estar bonito es el contenido, he re-adaptado el contenido que tenía. Metámonos en harina, que se suele decir en España.

Poniéndonos un poco formales, y para refrescar la memoria de aquellos menos familiarizados con el tema, serializar y deserializar es, en informática, el proceso de transformar una estructura de datos (comúnmente, un objeto), a una representación de más bajo nivel (normalmente, un stream de bytes, aunque una representación en JSON técnicamente también entraría en la definición de serialización), para que esta pueda ser almacenada en disco, transmitida por red, etc.

Ilustración 2, serialización y deserialización

Ilustración 2, serialización y deserialización

Tal y como comenzaba diciendo en el post, hay varias formas de hacer esto, y, como es una operación relativamente común, la mayoría de lenguajes de programación incluyen mecanismos para serializar y deserializar de una forma gratuita en cuanto a esfuerzo por parte del programador.


El mecanismo de reflexión

Pero, Sergio, si tengo todos los miembros de clase como privados, ¿cómo son leídos para ser serializados? ¿Mediante sus getters? ¿Cómo se inicializan de nuevo al deserializar? ¿Mediante los parámetros del constructor y los setters? ¿Y los atributos que definan el estado del objeto, pero que no sean accesibles de ninguna forma? ¿No se serializan? Qué grandes preguntas hacéis.

En los lenguajes que lo soportan, todos los atributos se recogen para ser serializados mediante reflexión, que es la habilidad de introspección de un programa para modificar su propia estructura y comportamiento. Vale, seguramente si estás empezando en la informática, o no te hayas topado con esto antes, te hayas quedado como estás con esta definición (que es la que da Wikipedia). Usemos gatos para comprenderlo mejor, aunque no vaya a ser la definición más exacta.

Ilustración 3, el gato a través de la reflexión

Ilustración 3, el gato a través de la reflexión

En nuestro lenguaje orientado a objetos, el gato que vemos es una instancia concreta de su clase. Un objeto, vaya. Es lo que dicha clase muestra a sus clientes, y a lo que podemos acceder de una forma normal, mediante sus métodos y propiedades públicas. El león es lo que el gato esconde, sus miembros privados. Y el espejo es el mecanismo de reflexión que nos permite, a partir del gato, acceder al león.

El problema es que estas operaciones no son baratas. Si vas a hacer una tarea repetitiva mediante reflexión, lo primero que escucharás decirte a alguien que tenga más experiencia -de la que yo mismo tengo- es “no es una buena idea”. Es decir, si únicamente vas a, por ejemplo, guardar un objeto de configuración de usuario cuando se produzcan cambios en él, realmente no pasa nada. Pero si vas a tener que serializar varios cientos de objetos para mandarlos a través de red, el resultado general se va a resentir.

Entonces, ¿qué hacemos? Pues vamos a escribir nuestra propia serialización. Si nuestras clases son complejas, forman parte de una jerarquía de herencia, o hay atributos que no deberían de/serializarse porque no son relevantes para el estado del objeto, el código de serialización va a terminar siendo largo y repetitivo, y querrás tener tests unitarios que garanticen su corrección (con el overhead en cuestiones de código que conllevarán, pues tendrás que escribir métodos para crear tus objetos y después para compararlos), pero, al final, merecerá la pena. Y lo mejor es que no te tendrás que fiar de mi palabra, ¡tengo mediciones que lo demuestran y que tú mismo podrás repetir en tu ordenador!


La serialización casera

Al final, por muy complejos que sean nuestros objetos, en última instancia podemos reducirlo todo a una composición de tipos básicos. Y cada uno de estos tipos básicos se puede reducir al duro y frío byte.

  • int: representación numérica que únicamente soporta números enteros. 4 bytes.
  • uint: entero sin signo. Es decir, el bit más significativo no ha de interpretarse como el signo del entero formado por los 31 bits siguientes.
  • short / int16: igual que un int, pero de 16 bits de longitud.
  • ushort / uint16: igual que un uint, pero de 16 bits de longitud.
  • double: representación numérica que soporta fracciones. 8 bytes.
  • long: un entero de 64 bits.
  • ulong: igual que uint, pero de 64 bits de longitud.
  • float: dependiendo de la plataforma, de simple (4 bytes) o de doble precisión (8 bytes).
  • boolean: representación de un valor de verdad, verdadero o falso. Técnicamente con un único bit bastaría, pero nadie se complica tanto la vida. Un byte.
  • char: un carácter. Su longitud variará según la codificación. Ver punto siguiente.
  • String: varía según la codificación de caracteres. Por ejemplo, en UTF-32 se usan siempre 4 bytes por carácter, mientras que en UTF-16 se usan solo 2, y en UTF-8 puede variar entre uno y seis. ASCII usa un byte por carácter. A la hora de serializar un string, escribiremos primero la longitud del mismo como un número entero, por lo que podemos acordar que, para serializar un string de longitud n, se usarán (n + 4) bytes (ASCII), o (n * 2 + 4) bytes (UTF-16).

Entonces, ¿cómo serializamos y deserializamos nuestros objetos? Sencillo. Creamos un stream de bytes, lo envolvemos con un writer que nos facilite escribir los tipos primitivos arriba enumerados, y los vamos escribiendo en orden.

Ilustración 4, el BinaryWriter con su Stream

Ilustración 4, el BinaryWriter con su Stream

Para deserializar, envolvemos el stream que contenga los datos con un reader que nos permita leer n bytes en uno de los tipos primitivos, y lo vamos asignando a los miembros de la clase. Simplemente habremos de tener cuidado de conservar el orden.

Explicando un poco el código de arriba, lo que he hecho ha sido añadir a cada objeto dos métodos, Serialize y Deserialize. Lo ideal, si todos estos objetos han de pasar por un punto común para, por ejemplo, ser transmitidos por red, es que estos métodos hubiesen sido declarados en una interfaz que implementasen mis objetos, pero como esto es un ejemplo rápido tampoco le vamos a pedir mucho.

Lo que conseguimos pasando el BinaryWriter y el BinaryReader como parámetros, en vez de obtener de cada objeto su representación como array de bytes, es evitar, para gran cantidad de serializaciones/deserializaciones, añadir presión al recolector de basura. Podríamos haberlo hecho mediante buffers de bytes, pero recuerdo, de nuevo, que esto es un ejemplo sencillo.

Cada clase lleva la etiqueta [Serializable] para que sea el propio runtime quien los pueda serializar también. ¿Por qué? Porque hemos llegado a la comparación de rendimiento.


Los tests

Vamos a crear 100.000 objetos SeaMonster aleatorios. Seguidamente, los serializalizaremos y deserializaremos mediante los mecanismos de reflexión del lenguaje con un BinaryFormatter (notar que algunos objetos tienen atributos privados complejos, como listas genéricas), midiendo el tiempo en cada paso. Compararemos los resultados con los originales para comprobar que no han variado en el proceso, lanzando una excepción en caso de que la comparación falle. Por último, repetiremos el mismo ciclo con nuestra implementación de serialización, midiendo también el tiempo en cada paso y comparando los objetos al finalizar, para garantizar que en nuestra ansiosa búsqueda de velocidad no hemos perdido corrección.

Se admiten apuestas sobre los resultados. ¿Cuánto habrá mejorado la serialización al implementarla por nosotros mismos?

Created 100000 SeaMonster objects in 1422 ms
 Serialized 100000 SeaMonster objects the C# way in 7718 ms.
 The stream has 82183122 bytes written.
 Deserialized 100000 SeaMonster objects the C# way in 263516 ms.
 Serialized 100000 SeaMonster objects the custom way in 328 ms.
 The stream has 31914195 bytes written.
 Deserialized 100000 SeaMonster objects the custom way in 735 ms.

Con BinaryFormatter (the C# way) los resultados son dolorosos. Casi ocho segundos en serializar, varis MINUTOS en deserializar, y 82MB escritos a memoria. Con BinaryWritter y BinaryReader, en apenas ~400 milisegundos la seralización estaba hecha, menos de un segundo en deserializar, y apenas se escribieron a memora 32MB. ¿Tenemos claro ganador? Yo creo que sin lugar a discusión.

Por supuesto, el ejemplo de serialización mostrado es muy sencillo. Si hubiese datos repetitivos en nuestras estructuras de datos (por ejemplo, el objeto Monster tiene una referencia a su dueño, un objeto Player, y queremos serializar cien Monster que pertenecen al mismo Player) podríamos optimizarlo todavía más mediante cachés, que resuelvan, en el momento de deserialización, el objeto real.

También habrá serializaciones más complicadas. Aquí he utilizado una List<InventoryItem> como el objeto más complejo, pero podríamos toparnos con HashSets o árboles. El mecanismo, en el fondo, será el mismo. Escribir los objetos en un orden y recuperarlos en el mismo. Para un HashMap, por ejemplo, obteniendo un iterador sobre las claves del mismo, y escribiendo los pares al stream.

El proyecto utilizado para estos ejemplos está disponible en GitHub.

Deja un comentario