colecciones de la API de Java

Anuncio
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);
Descargar