Clase 16. Prácticas: colecciones de la API de Java No se puede ser un programador de Java competente sin entender las partes esenciales de la biblioteca Java. Los tipos básicos están todos en java.lang, y son parte del lenguaje propiamente dicho. El paquete java.util ofrece colecciones -conjuntos, listas y mapas- y es necesario conocerlo muy bien. El paquete java.io también es importante y no basta con tener de él un conocimiento básico, hace falta profundizar. En esta clase analizaremos el diseño del paquete java.util, que suele recibir el nombre de ‘API de colecciones’. Merece la pena estudiarlo no sólo porque las clases de colecciones resulten extremadamente útiles, sino también porque la API es un ejemplo óptimo de código bien diseñado. La API es bastante fácil de comprender y existe mucha documentación al respecto. Fue diseñada y escrita por Joshua Bloch, autor del libro Effective Java que hemos recomendado al inicio del curso. Al mismo tiempo, en la API aparecen casi todas las complejidades de la programación orientada a objetos, por lo que si la estudia con detenimiento obtendrá una amplia comprensión de asuntos de programación que, probablemente, no había tenido en cuenta en su propio código. De hecho, no sería exagerado decir que si llega a comprender enteramente tan sólo una de las clases, ArrayList por ejemplo, dominará todos los conceptos de Java. Hoy no tendremos tiempo de analizar todos los códigos pero sí que nos ocuparemos de muchos de ellos. Algunos, como la serialización y la sincronización, quedan fuera del alcance de este curso. 16.1 Jerarquía de tipos Vista de un modo general, la API presenta tres tipos de colecciones: conjuntos, listas y mapas. Un conjunto es una colección de elementos que no mantiene un orden en el recuento de los elementos: cada elemento o está en el conjunto o no lo está. Una lista es una secuencia de elementos y, por tanto, mantiene el orden y el recuento. Un mapa es una asociación entre llaves y valores: mantiene un conjunto de llaves y asigna cada llave a un único valor. La API organiza sus clases mediante una jerarquía de interfaces –las especificaciones de los diversos tipos– y una jerarquía separada de clases de implementación. El siguiente diagrama muestra algunas clases de interfaces seleccionadas para ilustrar la organización jerárquica. La interfaz Collection captura las propiedades comunes de listas y conjuntos, pero no de los mapas, aunque de todas formas utilizaremos el término informal “colecciones” para referirnos también a los mapas. SortedMap y SortedSet son interfaces utilizadas por los mapas y los conjuntos que facilitan operaciones adicionales para recuperar los elementos en un orden. Las implementaciones concretas de clases, como LinkedList, están construidas en la parte superior del esqueleto de las implementaciones (por ejemplo AbstractList, de la cual LinkedList desciende). Esta estructura paralela de interfaces es un idioma importante que merece la pena estudiar. Muchos programadores inexpertos están tentados a utilizar clases abstractas cuando les sería más conveniente utilizar interfaces; pero, por regla general, es mejor para usted elegir las interfaces en vez de las clases abstractas. No es fácil aplicar un retrofit a una clase existente para extender una clase abstracta (porque una clase puede tener como máximo una superclase), pero no suele resultar difícil hacer que la clase implemente una nueva interfaz. Bloch demuestra (en el capítulo 16 de su libro: ‘Prefer interfaces to abstract classes’) cómo combinar las ventajas de ambas, utilizando una implementación de clases organizada en forma de esqueleto de jerarquías, como hace aquí en la API de colecciones. De esta forma se obtienen las ventajas de las interfaces para lograr el desacoplamiento basado en especificaciones y las ventajas de las clases abstractas para fabricar código compartido entre implementaciones relacionadas. Cada interfaz de Java viene con una especificación informal en la documentación de API Java, lo que resulta bastante útil, ya que informa sobre el comportamiento de la interfaz al usuario de una clase que implementa ésta. Si se implementa una clase y se quiere que ésta satisfaga la especificación List, por ejemplo, se deberá garantizar que cumple también con la especificación informal, pues de lo contrario no se comportará con arreglo a lo previsto por los programadores. Estas especificaciones, al igual que ocurre con muchas otras , han quedado incompletas de forma intencionada. Las clases concretas también poseen especificaciones que completan los detalles de las especificaciones de la interfaz. La interfaz List, por ejemplo, no especifica si los elementos nulos pueden ser almacenados, pero las clases ArrayList y LinkedList informan explícitamente que los elementos nulos están permitidos. La clase HashMap admite tanto valores nulos como llaves nulas, al contrario que Hashtable, que no permite ni éstas ni aquellos. Cuando escriba código que utiliza clase de API de colecciones, deberá referirse a un objeto mediante la interfaz o la clase más genérica posible. Por ejemplo, List p = new LinkedList (); es un estilo mejor que LinkedList p = new LinkedList (); Si su código compila con la primera versión del ejemplo anterior, podrá migrar fácilmente a una lista diferente en una implementación posterior: List p = new ArrayList (); ya que todo el código subsiguiente se basaba en el hecho de que p era del tipo List. Pero si utiliza la segunda versión del ejemplo anterior, seguramente descubrirá que puede hacer la alteración, porque algunas partes de su programa realizan operaciones sobre x que sólo la clase LinkedList ofrece: una operación que, de hecho, podría no ser necesaria. Esto está explicado más detalladamente en la sección 34 del libro de Bloch ('Refer to objects by their interfaces'). Veremos un ejemplo más complejo de este tipo de ocurrencia en el caso práctico Tagger en la próxima clase, donde parte del código requiere acceso a las llaves de HashMap. En lugar de pasar todo el mapa, sólo pasaremos una visión del tipo Set: Set keys = map.keySet (); Ahora el código que utiliza keys ni siquiera sabe que este conjunto es un conjunto de llaves de un mapa. 16.2 Métodos opcionales La API de colecciones permite que una clase implemente una interfaz de colecciones sin implementar todos sus métodos. Por ejemplo, todos los métodos de tipo modificadores de la interfaz List están especificados como opcionales, (optional). Esto significa que usted puede implementar una clase que satisfaga la especificación de List, pero que arroja una excepción UnsupportedOperationException cada vez que desea llamar a un método de tipo modificador (mutator) como, por ejemplo, el método add. Esta debilidad intencional de la especificación de la interfaz List es problemática, porque significa que cuando usted está escribiendo un código que recibe una lista, no puede saber, en ausencia de información adicional sobre la lista, si será compatible con el método add. Pero sin esta noción de operaciones opcionales, tendría que declarar una interfaz separada denominada ImmutableList. Estas interfaces proliferarían. A veces nos interesan algunos métodos modificadores y otros no. Por ejemplo, el método keySet de la clase HashMap devuelve un conjunto (un objeto Set) que contiene las llaves del mapa. El conjunto es una visión: al eliminar una llave del conjunto, una llave y su valor asociado desaparecen del mapa. Por lo tanto, es posible utilizar el método remove, aunque no el método add, ya que no se puede añadir una llave a un mapa sin un valor asociado a ella. Por consiguiente, la utilización de operaciones opcionales es un buen cálculo de ingeniería. Implica menos comprobaciones en tiempo de compilación, aunque reduce el número de interfaces. 16.3 Polimorfismo Todos estos contenedores –conjuntos, listas y mapas– reciben elementos de tipo Object. Se les considera polimórficos, lo que significa 'muchas formas', porque permiten construir muchas clases diferentes de contenedores: listas de enteros, listas de URL, listas de listas, etc. Este tipo de polimorfismo se denomina polimorfismo de subtipo, ya que se basa en la jerarquía de tipos. Una forma diferente de polimorfismo, denominada polimorfismo paramétrico, permite definir contenedores a través de parámetros que indican el tipo, de manera que un cliente pueda indicar qué tipo de elemento contendrá un contenedor específico: List[URL] bookmarks; // ilegal en Java Java no admite este tipo de polimorfismo, aunque han existido muchas propuestas para incorporarlo. El polimorfismo paramétrico tiene la gran ventaja de que el programador puede decir al compilador cuáles son los tipos de los elementos. Así, el compilador es capaz de interceptar errores en los que se inserta un elemento del tipo equivocado, o cuando un elemento que se extrae se trata como un tipo diferente. A través del polimorfismo de subtipo, usted deberá moldear explícitamente los elementos durante la recuperación, a través de la operación de cast. Considere el código: List bookmarks = new LinkedList (); URL u = …; bookmarks.add (u); … URL x = bookmarks.get (0); // el compilador rechazará esta sentencia La sentencia que añade u es correcta, pues el método add espera un objeto, y URL es una subclase de Object. La sentencia que recupera x, no obstante, es errónea; ya que el tipo devuelto por la sentencia del lado derecho del operador = devuelve un Object, y no se puede atribuir un Object a una variable del tipo URL, ya que no podría basarse en aquella variable como si fuera una URL. Por tanto, es precisa una operación de downcast, para lo que hay que escribir el siguiente código: URL x = (URL) bookmarks.get (0); El efecto de la operación de downcast es realizar una verificación en tiempo de ejecución. Si tiene éxito, y el resultado de la llamada del método es del tipo URL, la ejecución continuará normalmente. En el caso de que falle, porque el tipo devuelto no es el correcto, se lanzará una excepción ClassCastException y no se realizará la atribución. Asegúrese de que entiende este concepto, y no se confunda (como suelen hacer los estudiantes), pensando que la operación de cast, de alguna forma, realiza una mutación del objeto devuelto por el método. Los objetos llevan su tipo en tiempo de ejecución, y si un objeto se creó con un constructor de la clase URL, siempre tendrá ese tipo y no hay razón para modificarlo y darle otro tipo. Las operaciones de downcast pueden ser incómodas y, ocasionalmente, vale la pena escribir una clase wrapper para automatizar el proceso. En un navegador, probablemente usted emplearía un tipo abstracto de datos para representar una lista de favoritos (compatibles con otras funciones además de las ofrecidas por el tipo URL). Haciéndolo así, realizaría la operación de cast dentro del código de tipo abstracto, y sus clientes verían códigos como el siguiente: URL getURL (int i); que no exigirían la operación de cast en sus contextos de invocación, limitando así el ámbito en el cual los errores de cast podrían suceder. El polimorfismo de subtipo ofrece cierta flexibilidad de la que carece el polimorfismo paramétrico. Puede crear contenedores heterogéneos que contengan diferentes tipos de elementos. También puede colocar contenedores dentro de sí mismos –intente averiguar cómo expresar esto como un tipo polimórfico– aunque no suele ser aconsejable hacerlo. De hecho, como mencionamos en nuestra clase anterior al respecto de la igualdad, la API de clases Java se degenerará si lo hace de esta forma. Definir el tipo de elemento que un contenedor posee es muchas veces la parte más importante de una invariante Rep de tipo abstracto. Debería acostumbrarse a escribir un comentario cada vez que declare un contenedor, utilizando para ello una declaración del tipo pseudoparamétrica: List bookmarks; // List [URL] o como una parte de la invariante Rep propiamente dicha: IR: bookmarks.elems in URL 16.4 Implementaciones sobre esqueletos de jerarquías Las implementaciones concretas de las colecciones se hallan construidas sobre esqueletos de jerarquías. Estas implementaciones utilizan un patrón de diseño denominado Template Method (consulte Gamma et al, páginas 325-330). Una clase variable no tiene instancias de sí misma, pero define métodos denominados templates (plantillas) que invocan otros métodos denominados hooks (ganchos) que son declarados como abstractos y no poseen código. En la subclase, los métodos hook están superpuestos, y los métodos template se heredan sin sufrir alteraciones. La clase AbstractList, por ejemplo, hace de iterator un método template que devuelve un iterador implementado con el método get como un hook. El método equals se implementa como otro template de la misma forma que iterator. Una subclase, como ArrayList, ofrece entonces una representación (un array de elementos, por ejemplo) y una implementación para el método get (por ejemplo, el método debe devolver el i-ésimo elemento del array), pudiendo heredar los métodos iterator y equals. Algunas clases concretas sustituyen las implementaciones abstractas. LinkedList, por ejemplo, sustituye la funcionalidad del iterador ya que, al utilizar la representación de las entradas como objetos de tipo Entry directamente, es posible escribir un rendimiento mejor que si se utiliza el método get, que es un hook, y realizar una búsqueda secuencial en cada operación. 16.5 Capacidad, distribución y garbage collector Una implementación que utiliza un array para su representación –como ArrayList y HashMap– debe definir un tamaño para el array cuando se distribuye. La elección de un tamaño adecuado puede ser importante con vistas al rendimiento. Si el tamaño del array es demasiado pequeño,se deberá sustituir éste por uno nuevo, siendo necesario cargar con los costes de asignar un nuevo array y de liberarse del antiguo. Si es demasiado grande, tendremos pérdida de espacio, lo que supondrá un problema, especialmente cuando existen muchas instancias del tipo de la colección que estamos utilizando. Tales implementaciones, por tanto, ofrecen constructores en los que el cliente puede definir la capacidad inicial, a partir de la cual se puede determinar el tamaño de la distribución. ArrayList, por ejemplo, tiene el constructor: public ArrayList(int initialCapacity) Construye una lista con una capacidad inicial especificada. Parámetros: initialCapacity – la capacidad inicial de la lista. Lanza: IllegalArgumentException – si la capacidad inicial es un valor negativo. Existen también métodos que ajustan la distribución: trimToSize, que define la capacidad del contenedor de forma que sea lo suficientemente grande para los elementos actualmente almacenados, y ensureCapacity, que garantiza la capacidad hasta una determinada cuantía. Utilizar las facilidades para la gestión de la capacidad puede resultar problemático. Si no conoce exactamente la magnitud de las colecciones que necesitará la aplicación, le convendrá tratar de ejecutar una estimativa. Observe que este concepto de capacidad transforma un problema de comportamiento en uno de rendimiento: un cambio muy deseable. Los recursos de muchos programas antiguos son limitados y el programa falla cuando éstos se alcanzan. Con la propuesta de gestión de la capacidad, el programa se vuelve más lento. Es una buena idea diseñar un programa que funcione eficientemente la mayor parte del tiempo aunque ocasionalmente se produzcan problemas de rendimiento. Si estudia la implementación del método remove de ArrayList, verá este código: public Object remove(int index) { … elementData[-size] = null; // deje que el gc (garbage collector) haga su trabajo … ¿Qué ocurre? ¿El garbage collector no funciona automáticamente? Estamos ante un error común en programadores inexpertos. Si tiene un array en su representación con una variable de instancia distinta que contiene un índice para señalar qué elementos del array deben ser considerados como parte de la colección abstracta, resulta tentador pensar que basta con decrementar ese índice para eliminar los elementos. Realizar un análisis partiendo de la función de abstracción no servirá para eliminar la confusión: los elementos que se encuentran por encima del índice no son considerados parte de la colección abstracta, y sus valores son irrelevantes. No obstante, hay un problema. Si no garantiza la atribución del valor null para las posiciones no utilizadas, los elementos cuyas referencias están en esas posiciones no serán tratados por el garbage collector, aunque no existan otras referencias de estos elementos en cualquier otra parte del programa. El garbage collector no puede interpretar la función abstracta, por lo que no sabe que no es posible alcanzar esos elementos a través de la colección, aunque sí sea posible alcanzarlos a través de la representación. Si se olvida de atribuir null a estas posiciones, el rendimiento del programa puede verse gravemente afectado. 16.6 Copias, conversiones, wrappers, etc. Todas las clases de colecciones concretas ofrecen constructores que reciben colecciones como argumentos. Esto le permitirá copiar colecciones y convertir un tipo de colección en otro. Por ejemplo, la clase, LinkedList tiene: public LinkedList(Collection c) Construye una lista con los elementos de la colección especificada, en el orden en que son devueltos por el iterador de la colección. Parámetros: c – la colección cuyos elementos deben ser colocados en esta lista. que se puede utilizar para copiar: List p = new LinkedList () … List pCopy = new LinkedList (p) o para que se cree una lista encadenada a partir de otro tipo de colección: Set s = new HashSet () … List p = new LinkedList (s) Como no podemos declarar constructores en interfaces, la especificación List no establece que todas sus implementaciones deban tener tales constructores, a pesar de que los tienen. Existe una clase especial denominada java.util.Collections que contiene un grupo de métodos estáticos que realizan operaciones sobre las colecciones o que devuelven colecciones como resultado. Algunos de estos métodos son algoritmos genéricos (por ejemplo, para clasificación) y otros son wrappers. Por ejemplo, el método unmodifiableList recibe una lista y devuelve una lista con los mismos elementos, pero inmutable: public static List unmodifiableList(List list) Devuelve una visión no modificable de la lista especificada. Este método permite a los módulos ofrecer a los usuarios un acceso de sólo lectura a sus listas internas. Las operaciones de consulta sobre la lista realizan la consulta en la lista original. Las tentativas de modificar la lista devuelta, directamente o a través de un iterador, resultan en una excepción UnsupportedOperationException. La lista retornada será serializable en el caso de que la lista original también lo sea. Parámetros: list – la lista por la que se devuelve una visión no modificable. Devuelve: Una visión no modificable de la lista especificada. La lista devuelta no es exactamente inmutable, ya que su valor puede cambiar a causa de las alteraciones de la lista subyacente (vea la sección 16.8 más abajo), pero no puede modificarse directamente. Existen métodos semejantes que reciben colecciones y devuelven visiones que se sincronizan con la lista original a través de métodos wrappers. 16.7 Colecciones ordenadas Una colección ordenada debe tener alguna forma de compararse con los elementos para determinar su orden. La API de colecciones ofrece dos propuestas. Puede utilizar la 'ordenación natural', que se determina con el método compareTo del tipo de los elementos almacenados, que deben implementar la interfaz java.lang.Comparable: public int compareTo(Object o) que devuelve un entero negativo, cero, o un entero positivo en el caso de que el o objeto (this) sea menor, igual, o mayor que el objeto dado o. Cuando se añade un elemento a una colección ordenada que está utilizando la ordenación natural, el elemento deberá ser una instancia de una clase que implemente la interfaz Comparable. El método add realiza la operación de downcast para el tipo Comparable sobre el elemento añadido de forma que sea posible compararlo con los elementos ya existentes en la colección, en el caso de que no sea posible el downcast, se arrojará una excepción de moldeo de clase. La otra propuesta consiste en utilizar una clasificación independiente de los elementos, a través de un elemento que implemente la interfaz java.util.Comparator, que tiene el método public int compare(Object o1, Object o2) semejante al método compareTo, pero que recibe como argumento los dos elementos que se van a comparar. Esta es una instancia del patrón Strategy, en la que un algoritmo se desacopla del código que lo utiliza (consulte Gamma, págs. 315-323). Se elegirá una propuesta u otra dependiendo del constructor que usted emplee para crear la colección de objetos. Si emplea el constructor que recibe un Comparator como argumento, éste será utilizado para determinar el orden, mientras que si emplea el constructor sin argumentos, se utilizará la ordenación natural. La actividad de comparación se ve expuesta a los mismos problemas que la de igualdad (de la que se habló en la clase 9). Una colección ordenada tiene una invariante Rep que determina que los elementos de la representación deben estar ordenados. Si el orden de dos elementos se puede alterar a través de una invocación a un método público, tendremos una exposición de representación. 16.8 Visiones Presentamos el concepto de visiones en la clase 9. Las visiones son un mecanismo complejo y muy útil, aunque peligroso. Violan muchas de nuestras concepciones sobre qué tipos de comportamientos pueden ocurrir en un programa orientado a objetos bien formado. Se pueden citar tres tipos de visiones, según cuál sea su propósito: • Ampliación de la funcionalidad. Algunas visiones se utilizan para ampliar la funcionalidad de un objeto sin que sea necesario añadir nuevos métodos a su clase. Los iteradores caen dentro de esta categoría. Sería posible, por el contrario, colocar los métodos next y hasNext en la propia clase de la colección. Pero esto complicaría la API de la clase. Sería difícil también soportar múltiples iteraciones sobre la misma colección. Podríamos añadir un método reset a la clase que sería invocado para reiniciar una iteración, aunque esto sólo permitiría una iteración cada vez. Tal método podría conducir a errores en los que el programador se olvide de reiniciar la iteración. • Desacoplamiento. Algunas visiones ofrecen un subconjunto de las funcionalidades de la colección subyacente. El método keySet de la interfaz Map, por ejemplo, devuelve un conjunto que consiste en llaves del mapa. El método permite, por tanto, que la parte del código relacionada con las llaves (pero no la relacionada con los valores) se desacople del resto de la especificación de Map. • Transformación coordinada. La visión ofrecida por el método subList de la interfaz List da una especie de transformación coordinada. Las alteraciones en la visión producen alteraciones en la lista subyacente, pero permiten el acceso a la lista a través de un índice que es un offset pasado a través del parámetro del método subList. Las visiones son peligrosas por dos motivos. Primero, las alteraciones se producen de modo subyacente: si se invoca remove a partir de un iterador, la colección subyacente se modificará; mientras que si se invoca remove en un mapa se alterará una determinada visión del conjunto de llaves (y viceversa). Esto es un fenómeno de aliasing abstracto en el cual una alteración introducida en un objeto hace que se modifique otro objeto de un tipo diferente. Los dos objetos ni siquiera tienen que estar en el mismo ámbito léxico. Observe que el significado de la cláusula 'modifies' utilizada en las especificaciones debe perfeccionarse: si se define 'modifies c' y c tiene una visión v, ¿quiere ello decir que también se podrá modificar v? En segundo lugar, la especificación de un método que devuelve una visión limita muchas veces los tipos de alteraciones que se permiten. Para tener la certeza de que el código funciona, usted tendrá que entender la especificación de ese método. Y como es lógico, estas especificaciones resultan a menudo confusas. La cláusula 'post-requires' del texto de Liskov es una forma de ampliar nuestro concepto de especificación para manipular algunas de las complicaciones. Algunas visiones sólo permiten que se altere la colección subyacente. Otras sólo permiten que se altere la visión, por ejemplo los iteradores. Algunas permiten alteraciones para ambas, la visión y la colección subyacente, pero determinan implicaciones complejas en función de las alteraciones. La API de colecciones, sin ir más lejos, determina que cuando una visión en forma de sublist se crea a partir de una lista, la lista subyacente no debe sufrir modificaciones estructurales o, como se explica en la documentación: Las modificaciones estructurales son las que alteran el tamaño de la lista, o la perturban de tal modo que las iteraciones en curso pueden presentar resultados incorrectos. No está muy claro lo que esto significa. Mi sugerencia sería que se evite cualquier modificación de la lista subyacente. La situación se complica más debido a la posibilidad de que existan varias visiones sobre la misma colección subyacente. Así, por ejemplo, usted puede tener múltiples iteradores sobre la misma lista. En tal caso, deberá tener también en cuenta las iteraciones entre visiones. Si modifica la lista a través de uno de sus iteradores, los otros iteradores serán invalidados y no deberán utilizarse posteriormente. Existen algunas estrategias prácticas que simplifican la complejidad de las visiones. Cuando utilice una visión, considere detenidamente los siguientes consejos: • • · Puede determinar el ámbito dentro del cual la visión es accesible. Por ejemplo, utilizando un bucle de tipo for en lugar de una sentencia while para realizar la iteración. De esta forma, estará limitando el ámbito del iterador para el ámbito del propio bucle. Esta práctica ayuda a garantizar que no se produzcan interacciones no previstas durante la iteración. Esto no siempre es posible; el programa Tagger, del que hablaremos más adelante en el curso, altera un iterador a partir de un local a muchas invocaciones de métodos de distancia y en una clase diferente, a partir del local de su creación. · Puede evitar la alteración de una visión o de un objeto subyacente a través del recurso de wrapping mediante métodos de la clase Collection. Por ejemplo, si crea una visión a partir del método keySet de un mapa y no pretende modificarla, puede hacer el conjunto inmutable: Set s = map.keySey (); Set safe_s = Collections.unmodifiableSet (s);