Una colección para cada ocasión

No hace mucho en twitter, esa interminable fuente de inspiración para posts, salía el tema de qué tipo de colecciones era mejor utilizar y, para variar, mi respuesta era que depende del contexto.

Como esa respuesta no sirve absolutamente de nada, en este post vamos a ver varias alternativas y analizar qué nos puede aportar cada una y cuando puede tener más sentido usarla. En realidad, cada situación es diferente y siempre habrá más factores a tener en cuenta, por lo que no te tomes esto como una verdad absoluta e inamovible.

Colecciones privadas

Cuando estamos definiendo una colección interna a una clase y que, por tanto, sólo será usada por ella, suele ser más fácil elegir el tipo de colección adecuado puesto que conocemos la forma en que se usará y los requisitos que tenemos.

Por ejemplo, si necesitamos garantizar que no tiene elementos repetidos, lo lógico será utilizar un conjunto, pero si nos importa el orden de los elementos, necesitaremos una lista. En ocasiones, tendremos requisitos más concretos que harán que podamos usar estructuras de datos más específicas como pilas o colas.

Al final, es una cuestión de conocer el comportamiento de las distintas estructuras de datos que existen y escoger la que más se ajuste a nuestras necesidades. Lo bueno es que, al tratarse de algo interno a la clase, siempre podremos cambiarlo en un futuro si es necesario sin afectar a los consumidores de la clase.

Colecciones públicas

La cosa se complica cuando la colección se expone públicamente, ya sea a través de una propiedad o de un método, porque en ese caso estaremos introduciendo una dependencia entre los usuarios de la clase y el tipo de colección elegido y además no es fácil (a veces es imposible) saber a priori qué usos se darán a esa colección.

Dentro de este escenario podemos distinguir dos casos distintos:

  • Colecciones contenidas por la clase que las expone. Es el típico caso de un cliente y sus direcciones de envío. Existe una relación de composición entre la clase cliente y la colección de dirección de envío, por lo que al exponer esa colección hacia el exterior debemos tenerlo en cuenta.

    Es muy importante asegurarse de que la forma en que exponemos la colección hacia el exterior nos permite seguir manteniendo el invariante de la clase. De todas formas, hay que tener en cuenta que, independientemente del tipo de colección que expongamos hacia el exterior, si los objetos que hay en la colección son mutables, siempre habrá que tener un cuidado adicional.

  • Colecciones devueltas como resultado de cálculos. Un ejemplo de esto sería un método que devuelve varias facturas obtenidas de alguna fuente externa. En este caso no tenemos ningún invariante que mantener y lo único que debe preocuparnos es cómo se supone que se va a utilizar esa colección.

Ahora que hemos establecido ciertas bases, vamos a ver cómo se comportan distintos tipos de colección con respecto a ellas.

Array

El array es la colección más básica que podemos utilizar. La mayor ventaja del array es su simplicidad. No puedes exponer nada más sencillo que un array; el array no esconde nada por lo que es muy previsible. Además, los arrays son rápidos (si es que te preocupa eso), se llevan muy bien con las librerías de serialización y son muy interoperables.

Las dos mayores limitaciones del array son su tamaño fijo y, sobre todo, que si se expone un array que pertenece a otro objeto, hace muy difícil mantener el invariante del objeto puesto que podemos manipularlo desde el exterior sin que el objeto padre se entere. Esto hace que se tienda a hacer copias defensivas (devolver una copia del array en lugar la referencia al array interno) para evitar ese problema, pero eso conlleva una penalización en rendimiento y consumo de memoria.

En general, suelo utilizar arrays como propiedades de DTOs u otros objetos sin lógica, que no tienen invariante que mantener y pueden acabar siendo serializados. También los uso de vez en cuando como valor de retorno de métodos que devuelven colecciones, aunque en ese caso también uso IEnumerables por los motivos que veremos ahora.

IEnumerable

En orden de complejidad, el siguiente tipo sería el IEnumerable. Entre las ventajas de IEnumerable encontramos que no permite modificar externamente el contenido de la colección, lo que ayudaría a mantener el invariante de la clase que lo contiene, y que no es necesario materializarlo en memoria completamente, por lo que permite hacer una especie de streaming de datos, por ejemplo devolviendo registros de la base de datos según se procesan, pero sin llegar a tener que cargarlos simultáneamente todos en memoria.

A cambio, también tenemos algunos problemas con IEnumerable. Las posibilidades que ofrece son menores que las del array, puesto que no podemos acceder directamente a un elemento concreto o calcular su longitud (al menos no nos garantiza poder hacerlo en tiempo constante).

Aun así, el mayor inconveniente para mi de IEnumerable es que nunca puedes estar seguro del coste que tendrá recorrerlo ni de si al recorrerlo varias veces obtendrás el mismo resultado. Esto es una consecuencia lógica de una de sus ventajas (el poder ir calculando resultados según son necesarios), pero hace que a veces sea complicado tratar con ellos y obligue a los consumidores del API a saber demasiado sobre cómo se ha generado el IEnumerable que están consumiendo.

Por eso es (relativamente) frecuente acabar viendo llamadas a ToArray o ToList para forzar la materialización del IEnumerable y tener una estructura de datos más predecible. Lo malo es que cuando vas pasando un IEnumerable de clase en clase, y todas lo primero que hacen es un ToArray para guardarlo internamente y luego lo exponen otra vez como IEnumerable, acabas realizando un montón de copias innecesarias y estás en una situación similar a la de la copia defensiva del array.

Pese a estos problemas, IEnumerable es una de las opciones que más utilizo tanto para devolver colecciones contenidas en un objeto como para devolver resultados de un cálculo. Es la colección más limitada y, por ello, la que menos compromete al API que la expone.

Otras colecciones

Además del array y de IEnumerable existen otros tipos de colecciones que se exponen con cierta frecuencia: ICollection, IList y sus versiones de sólo lectura, IReadOnlyCollection e IReadOnlyList.

La diferencia entre usar IList e ICollection reside en si vamos a necesitar acceder a una posición concreta. La ventaja que tienen frente a un IEnumerable es que (generalmente) podemos conocer su tamaño en tiempo constante.

Sin embargo, no suelo emplearlas demasiado porque permiten añadir o eliminar elementos. Si las colecciones forman parte de otra clase, esto hace que sea complicado mantener el invariante de la clase, y si son valores devueltos por un método, muchas veces lo único que necesitamos es poder consultarlos, no añadir o eliminar elementos.

Las versiones de sólo lectura permiten mitigar estos problemas y además, al ser interfaces más simples, comprometen a menos a las APIs que las exponen.

Existen algunas colecciones más específicas, como ISet o IDictionary, pero normalmente es fácil saber cuando usarlas porque sus propiedades son muy diferentes de las que hemos estado viendo.

Conclusiones

Excepto el caso del array, todos los demás tipos de colección que hemos visto son interfaces. En general, es mejor devolver un interface que una implementación concreta del interface porque así podremos cambiarla en un futuro si fuera necesario.

A la hora de elegir la colección que usamos internamente y que luego expondremos en el API pública, es importante tener en cuenta las propiedades de la misma y el coste que tiene cada operación. Ya hemos visto alguna vez que elegir bien los algoritmos y estructuras de datos pueden tener un impacto muy grande una aplicación aparentemente trivial.

También necesitamos pensar en cómo van a utilizar los clientes del API la colección. Las operaciones que necesitan, el rendimiento, etc. No podemos saber a ciencia cierta cómo se usará, pero si podemos predecir (o incluso documentar) la forma en que se supone que se va a usar.

Siendo sincero, entre arrays e IEnumerables seguramente cubra más del 80% de los casos en que expongo una colección a través de un API pública. Esto indudablemente está influido por el tipo de aplicaciones que desarrollo más a menudo (típicas aplicaciones “de negocio”) pero no creo que se aleje mucho del caso típico.

5 comentarios en “Una colección para cada ocasión

  1. Si no se quiere devolver un array y sólo se quiere «conceder acceso de lectura», debería usarse un índice (un getter, vamos).

    Para un hash (ej. Dictionary) lo mismo, un getter.

    Si son operaciones más «exóticas» (fifo, lifo, tree, …) se wrappear.

    Para secuencias, IEnumerable, que permiten ser perezosas, etc…

    Pero en la práctica y como bien dices, «siempre habrá más factores a tener en cuenta».

  2. Si tienes un método que construye un array (u otro objeto mutable), su labor es «construir arrays» y por tanto el «llamante» deberá encargarse de modificarlo y liberarlo.

    Si tienes una clase que gestiona un array y quieres que un «llamante» pueda acceder al mismo pero no pueda modificarlo «libremente» ni ser responsable de liberarlo u otras operaciones, usa índices (http://msdn.microsoft.com/en-us/library/2549tw02.aspx).

    En general, he diferenciado entre tres tipos de estructuras: las mutables indexables (array, hash, secuencias estúpidas que cambian el valor cuando se recorren varias veces, …), las que no admiten un índice (fifo, lifo, …) y las inmutables (desde el «llamante» como ienumerable). Cada una de ellas (en mi «día a día») tiene soluciones típicas (aka patrones) diferentes.

  3. Coincido en los tres tipos generales de estructuras que comentas, lo que no me convence es lo de usar un índice para exponer una colección.

    Lo veo bien para casos concretos en que la propia clase que encapsula el array tiene un cierto «aire» de coleccion, pero no para una relación típica de composición. Por ejemplo, si tienes una clase Cliente con una colección de Contactos y otra de Direcciones, la salida del índice me parece mala.

    De todas formas, sin duda las mejores colecciones son las «secuencias estúpidas que cambian el valor cuando se recorren varias veces» ;)

  4. «pero no para una relación típica de composición» ~> creo haber incluido todas las posibilidades. Lo que tu llamás «relación típica» supongo que para tí formará parte del grupo de tree, fifo, … por lo que ahí wrapearías con funciones específicas (según mi clasificación). Indexar (o implementar enumerable) es obligado cuando quieres que la clase exponga una secuencia inerentemente mutable de forma que sea «de sólo lectura».

Comentarios cerrados.