Tutorial de Java

Nuevas Colecciones 2

Anterior | Siguiente
Elegir una Implementación

Si se observa detenidamente el diagrama de herencia de las nuevas colecciones, se puede comprobar que en realidad hay solamente tres Colecciones: Map, List y Set, y solamente dos o tres implementaciones de cada interfaz. En el caso de tener la necesidad de utilizar la funcionalidad ofrecida por un interfaz determinado, el problema viene a la hora de seleccionar que implementación particular se debe emplear.

Para comprender la respuesta, se debe tener muy presente que cada una de las diferentes implementaciones tiene sus características propias, sus virtudes y sus defectos. Por ejemplo, en el diagrama se puede observar que una de las principales características de Hashtable, Vector y Stack es que son clases ya empleadas, es decir, que ya están totalmente soportadas en el JDK 1.1, por lo que los programas que las utilicen funcionarán perfectamente con los navegadores y máquinas virtuales que están actualmente en el mercado. Pero, por otro lado, si se va a utiliza código que utilice características del JDK 1.2, es mejor no utilizar las clases anteriores.

La distinción entre las nuevas colecciones y las anteriores viene dada por la estructura de datos en que se basa cada una; es decir, la estructura de datos que físicamente implementa el interfaz deseado. Esto quiere decir que, por ejemplo, ArrayList, LinkedList y Vector (que viene a ser equivalente a un ArrayList), todos implementan el interfaz List, con lo cual un programa debe producir los mismos resultados, independientemente del que se esté utilizando. Sin embargo, ArrayList (y Vector) están basados en un array, mientras que LinkedList está implementado en la forma habitual de una lista doblemente enlazada, con los objetos conteniendo información de qué elemento está antes y después en la lista. Por ello, si se van a realizar muchas inserciones y borrado de elementos en medio de la lista, la LinkedList es una opción mucho más adecuada que cualquier otra. Y en todos los demás casos, probablemente un ArrayList sea mucho más rápido.

Otro ejemplo, un Set puede ser implementado a partir de un ArraySet o de un HashSet. Un ArraySet está basado en un ArrayList y está diseñado para soportar solamente pequeñas cantidades de elementos, especialmente en situaciones en las que se estén creando y destruyendo gran cantidad de objetos Set. Sin embargo, si el Set va a contener gran cantidad de elementos, el rendimiento del ArraySet se viene abajo muy rápidamente. A la hora de escribir un programa que necesite un Set, la elección por defecto debería ser un HashSet, y cambiar a un ArraySet solamente en aquellos casos en que se observe un rendimiento muy precario o donde las optimizaciones indiquen que es adecuado.

Operaciones No Soportadas

Es posible convertir un array en una Lista utilizando el método estático Arrays.toList(), tal como se muestra en el ejemplo java426.java.

import java.util.*;
      
public class java426 {
    private static String s[] = {
        "uno", "dos", "tres", "cuatro", "cinco",
        "seis", "siete", "ocho", "nueve", "diez",
        };
    static List a = Arrays.toList( s );
    static List a2 = Arrays.toList( new String[] { s[3],s[4],s[5] } );
      
    public static void main( String args[] ) {
        java421.print( a ); // Iteración
        System.out.println(
            "a.contains("+s[0]+") = "+a.contains(s[0]) );
        System.out.println(
            "a.containsAll(a2) = "+a.containsAll(a2) );
        System.out.println( "a.isEmpty() = "+a.isEmpty() );
        System.out.println(
            "a.indexOf("+s[5]+") = "+a.indexOf(s[5]) );
      
        // Movimientos hacia atrás
        ListIterator lit = a.listIterator( a.size() );
        while( lit.hasPrevious() )
            System.out.print( lit.previous() );
        System.out.println();
      
        // Fija algunos elementos a valores diferentes
        for( int i=0; i < a.size(); i++ )
            a.set( i,"47" );
        java421.print( a );
      
        // Lo siguente compila, pero no funciona
        lit.add( "X" ); // Operación no soportada
        a.clear(); // No soportada
        a.add( "once" ); // No soportada
        a.addAll( a2 ); // No soportada
        a.retainAll( a2 ); // No soportada
        a.remove( s[0] ); // No soportada
        a.removeAll( a2 ); // No soportada
        }
    }

El lector podrá descubrir que solamente una parte de los interfaces de Collection y List están actualmente implementados. El resto de los métodos provocan la aparición de un desagradable mensaje de algo que se llama UnsupportedOperationException. La causa es que el interfaz Collection, al igual que los otros interfaces en la nueva librería de colecciones, contienen métodos opcionales, que pueden estar o no soportados en la clase concreta que implemente ese interfaz. La llamada a un método no soportado hace que aparezca la excepción anterior para indicar el error de programación. Las líneas siguientes reproducen la ejecución del ejemplo, en donde se observa el mensaje que genera el íntérprete Java a la hora de realizar la operación no soportada sobre la Colección.

C:\>java java426
uno dos tres cuatro cinco seis siete ocho nueve diez
a.contains(uno) = true
a.containsAll(a2) = true
a.isEmpty() = false
a.indexOf(seis) = 5
dieznueveochosieteseiscincocuatrotresdosuno
47 47 47 47 47 47 47 47 47 47
java.lang.UnsupportedOperationException
at java.util.AbstractList.add(AbstractList.java:139)
at java.util.AbstractList$ListItr.add(AbstractList.java:523)
at java426.main(java426.java:57)

Me imagino la cara de incredulidad del lector. Lo anterior echa por tierra todas las promesas que se hacían con los interfaces, ya que se supone que todos los métodos definidos en interfaces y clases base tienen algún significado y están dotados de código en sus implementaciones; pero es más, en este caso no solamente no hacen nada, sino que detienen la ejecución del programa. Bueno, la verdad es que no todo está tan mal; con Collection, List, Set o Map, el compilador todavía restringe la llamada a métodos, permitiendo solamente la llamada a métodos que se encuentren en el interfaz correspondiente, y además, muchos de los métodos que tienen una Collection como argumento solamente leen desde esa Colección, y todos los métodos de lectura son no opcionales.

Esta posibilidad, o característica, evita una explosión de interfaces en el diseño. Otros diseños de librerías de colecciones siempre parecen tener al final una plétora de interfaces que describen cada una de las variaciones del tema principal, lo que hace que sean difíciles de aprender. Además que no es posible recoger todos los casos especiales en interfaces, ya que siempre hay alguien dispuesto a inventarse un nuevo interfaz. La posibilidad de la operación no soportada consigue una meta importante en las nuevas colecciones: las hace muy fáciles de aprender y su uso es muy simple. Pero para que esto funcione, hay que tener en cuenta dos cuestiones:

  1. La UnsupportedOperationException debe ser un evento muy raro. Es decir, en la mayoría de las clases todos los métodos deben funcionar, y solamente en casos muy especiales una operación podría no estar soportada. Esto es cierto en las nuevas colecciones, ya que en el 99 por ciento de las veces, tanto ArrayList, LinkedList, HashSet o HashMap, así como otras implementaciones concretas, soportan todas las operaciones. El diseño proporciona una puerta falsa si se quiere crear una nueva Collection sin asignar significado a todos los métodos del interfaz Collection, y todavía está en la librería.
  2. Cuando una operación esté no soportada, sería muy adecuado que apareciese en tiempo de compilación la UnsupportedOperationException, antes de que se genere el programa que se vaya a dar al cliente. Después de todo, una excepción indica un error de programación: hay una clase que se está utilizando de forma incorrecta.

En el ejemplo anterior, Arrays.toList() genera una Lista que está basada en un array de tamaño fijo; por lo tanto, tiene sentido el que solamente estén soportadas aquellas operaciones que no alteran el tamaño del array. Si, de otro modo, fuese necesario un interfaz nuevo para recoger este distinto comportamiento, llamado TamanoFijoList, por ejemplo; se estaría dejando la puerta abierta al incremento de la complejidad de la librería, que se volvería insoportable cuando alguien ajeno intentase utilizarla al cabo del tiempo, por el montón de interfaces que tendría que aprenderse.

La documentación de todo método que tenga como argumento Collection, List, Set o Map, debería especificar cuales de los métodos opcionales deben ser implementados. Por ejemplo, la ordenación necesita los métodos set() e Iterator.set(), pero no necesita para nada los métodos add() y remove().

Ordenación y Búsqueda

El JDK 1.2 incorpora utilidades para realizar ordenaciones y búsquedas tanto en arrays como en listas. Estas utilidades son métodos estáticos de dos nuevas clases: Arrays para la ordenación y búsqueda en arrays y Collections para la ordenación y búsqueda en Listas.

Arrays

La clase Arrays tiene los métodos sort() y binarySearch() sobrecargados para todos los tipos básicos de datos, incluidos String y Object. El ejemplo java427.java muestra la ordenación y búsqueda en un array de elementos de tipo byte, para todos los demás tipos básicos se haría de forma semejante, y para un array de String.

La primera parte de la clase contiene utilidades para la generación de objetos aleatorios de tipo String, utilizando un array de caracteres desde el cual se pueden seleccionar las letras. El método cadAleat() devuelve una cadena de cualquier longitud, y cadsAleat() crea un array de cadenas aleatorias, dada la longitud de cada String y el número de elementos que va a tener el array. Los dos métodos print() simplifican la presentación de los datos. El método Random.nextBytes() rellena el array argumento con bytes seleccionados aleatoriamente; en los otros tipos de datos básicos no hay un método semejante.

Una vez construido el array se puede observar que se hace una sola llamada al sort() o a binarySearch(). Hay una cuestión importante respecto a binarySearch(), y es que, si no se llama al método sort() antes de llamar a binarySearch() para realizar la búsqueda, se pueden producir resultados impredecibles, incluso la entrada en un bucle infinito.

La ordenación y búsqueda en el caso de String parece semejante, pero a la hora de ejecutar el programa se observa una cosa interesante: la ordenación es alfabética y lexicográfica, es decir, las letras mayúsculas preceden a las letras minúsculas. Así, todas las letras mayúsculas están al principio de la lista, seguidas de las letras minúsculas, luego ‘Z’ está antes que ‘a’.

Comparable y Comparator

¿Qué pasa si eso no es lo que se quiere? Por ejemplo, si se quieres generar el índice de un libro, esa ordenación no es admisible, ya que lo lógico es que la ‘a’ vaya después de la ‘A’.

Y ¿qué pasa en un array de elementos de tipo Object? ¿Qué determina el orden en que se encuentran dos elementos de este tipo? Desgraciadamente, los diseñadores originales de Java no consideraron que este fuese un problema importante, porque sino lo hubiesen incluido en la clase raíz Object. Como resultado, el orden en que se encuentran los elementos Object ha de ser impuesto desde fuera, y la nueva librería de colecciones proporciona una forma estándar de hacerlo, que es casi tan buena como si la hubiesen incluido en Object.

Hay un método sort() para array de elementos Object (y String, desde luego, es un Object) que tiene un segundo argumento: un objeto que implementa el interfaz Comparator, que es parte de la nueva librería, y realiza comparaciones a través de su único método compare(). Este método coge los dos objetos que van a compararse como argumentos y devuelve un entero negativo si el primer argumento es menor que el segundo, cero si son iguales y un entero positivo si el primer argumento es más grande que el segundo. Sabiendo esto, en el ejemplo java428.java, se vuelve a implementar la parte correspondiente al array de elementos String del ejemplo anterior para que la ordenación sea alfabética.

import java.util.*;
        
public class java428 implements Comparator {
    public int compare( Object obj1,Object obj2 ) {
        // Suponemos que solamente vamos a utilizar cadenas
        String s1 = ( (String)obj1).toLowerCase();
        String s2 = ( (String)obj2).toLowerCase();
        
        return( s1.compareTo( s2 ) );
        }
        
        
    public static void main( String args[] ) {
        String s[] = java427.cadsAleat( 4,10 );
        java427.print( s );
        java428 ac = new java428();
        Arrays.sort( s,ac );
        java427.print( s );
        
        // Se debe utilizar un Comparador para realizar la búsqueda
        int loc = Arrays.binarySearch( s,s[3],ac );
        System.out.println( "Posicion de "+s[3]+" = "+loc );
        }
    } 

Haciendo el moldeo a String, el método compare() implícitamente se asegura que solamente se están utilizando objetos de tipo String. Luego se fuerzan los dos Strings a minúsculas y el método String.compareTo() devuelve el resultado que se desea.

A la hora de utilizar un Comparator propio para llamar a sort(), se debe utilizar el mismo Comparator cuando se vaya a llamar a binarySearch(). La clase Arrays tiene otro método sort() que toma un solo argumento: un array de Object, sin ningún Comparator. Este método también puede comparar dos Object, utilizando el método natural de comparación que es comunicado a la clase a través del interfaz Comparable. Este interfaz tiene un único método, compareTo(), que compara el objeto con su argumento y devuelve negativo, cero o positivo dependiendo de que el objeto sea menor, igual o mayor que el argumento. El ejemplo java429.java, es un programita muy sencillo que ilustra esta circunstancia.

Listas

Una Lista puede ser ordenada y se puede buscar en ella del mismo modo que en un array. Los métodos estáticos para ordenar y buscar en una Lista están contenidos en la clase Collections, pero tienen un formato similar a los correspondientes de la clase Arrays: sort(List) para ordenar una Lista de objetos que implementen el interfaz Comparable, binarySearch(List,Object) para buscar un objeto en una lista, sort(List,Comparator) para ordenar una Lista utilizando un Comparator y binarySearch(List,Object,Comparator) para buscar un objeto en esa lista. Seguro que en nuevas versiones del JDK se irán incorporando nuevos métodos para realizar acciones específicas, como por ejemplo, ya está anunciado el método stableSort() para realizar mezclas ordenadas. El ejemplo java430.java se basa en los dos anteriores para demostrar el uso de las herramientas de ordenación sobre la clase Collections.

El uso de estos métodos es idéntico a los correspondientes de la clase Arrays, pero utilizando una Lista en lugar de un Array. El TreeMap también puede ordenar sus objetos en función de Comparable y Comparator.

Utilidades

Hay otras utilidades que pueden resultar interesantes en la clase Collections, como por ejemplo:

enumeration(Collection)

Devuelve una Enumeration al viejo estilo a partir del argumento que se le pasa

max(Collection)
min(Collection)

Devuelven el elemento máximo o el mínimo de la colección que se le pasa como argumento, utilizando el método natural de comparación entre los objetos de la Colección

max(Collection,Comparator)
min(Collection,Comparator)

Devuelven el elemento máximo o el mínimo de la colección que se le pasa como argumento, utilizando el Comparator

nCopies(int n,Object o) Devuelve una Lista de tamaño n cuyos handles apuntan a o

subList(List,int min,int max)

Devuelve una nueva Lista a partir de la Lista que se le pasa como argumento que es una porción de esta última cuyos índices comienzan en el elemento min y terminan en el elemento max

Tanto min() como max() trabajan con objetos de tipo Collection, no con List, por lo tanto es indiferente si la Colección está ordenada o no. Como ya se indicó antes, es necesario llamar a sort() en una Lista o array antes de realizar una búsqueda con binarySearch().

Colecciones o Mapas de Sólo Lectura

A menudo es conveniente crear una versión de sólo lectura de una Colección o un Mapa. La clase Collections permite que se haga esto pasando el contenedor original a un método que devuelve una versión de sólo lectura. Hay cuatro variaciones sobre este método, una para cada tipo de Collection (en caso de no querer tratar la Colección de forma más específica), List, Set y Map. El ejemplo java431.java muestra la forma en que se pueden construir versiones de sólo lectura de cada uno de ellos.

import java.util.*;
        
public class java431 {
    public static void main( String args[] ) {
        Collection c = new ArrayList();
        java421.fill( c ); // Añade datos útiles
        c = Collections.unmodifiableCollection( c );
        java421.print( c ); // La lectura es correcta
        //c.add( "uno" ); // No se puede cambiar
        List a = new ArrayList();
        java421.fill( a );
        a = Collections.unmodifiableList( a );
        ListIterator lit = a.listIterator();
        System.out.println( lit.next() ); // La lectura es correcta
        //lit.add( "uno" ); // No se puede cambiar
        
        Set s = new HashSet();
        java421.fill( s );
        s = Collections.unmodifiableSet( s );
        java421.print( s ); // La lectura es correcta
        //s.add( "uno" ); // No se puede cambiar
        Map m = new HashMap();
        java425.fill( m,java425.datosPrueba1 );
        m = Collections.unmodifiableMap( m );
        java425.print( m ); // La lectura es correcta
        //m.put( "CV","Caballo de Vapor" ); // No se puede cambiar
        }
     }

En cada caso, se debe rellenar el contenedor con datos significativos antes de hacerlo de sólo lectura. Una vez que está cargado, el mejor método es reemplazar el handle existente con el que genera la llamada al no modificable. De esta forma, no se corre el riego de cambiar accidentalmente el contenido. Por otro lado, esta herramienta permite que se pueda mantener un contenedor modificable como privado dentro de la clase y devolver un handle de sólo lectura hacia él a través de un método. De este modo se puede realizar cualquier cambio desde la propia clase, pero el resto del mundo solamente pueden leer los datos.

La llamada al método no modificable para un tipo determinado no genere ninguna comprobación en tiempo de compilación, pero una vez que se haya realizado la transformación, la llamada a cualquier método que intente modificar el contenido del contenedor obtendrá por respuesta una excepción de tipo UnsupportedOperationException.

Si en el ejemplo java431.java se descomenta la línea que añade un elemento a la primera de las Colecciones

c.add( "uno" ); // No se puede cambiar

y se vuelve a compilar el código fuente resultante, a la hora de ejecutar el programa, se obtendrá un resultado semejante al que reproducen las líneas siguientes

C:\>java java431
0 1 2 3 4 5 6 7 8 9
java.lang.UnsupportedOperationException
    at java.util.Collections$UnmodifiableCollection.add(Collections.java:583)
    at java431.main(java431.java:33)

Colecciones o Mapas Sincronizados

La palabra reservada synchronized es una parte muy importante de la programación multihilo, o multithread, que es un tema muy interesante y se tratará abundantemente en otra sección; aquí, en lo que respecta a las Colecciones basta decir que la clase Colletions contiene una forma de sincronizar automáticamente un contenedor entero, utilizando una sintaxis parecida a la del caso anterior de hacer un contenedor no modificable. El ejemplo java432.java es una muestra de su uso.

import java.util.*;
        
public class java432 {
    public static void main( String args[] ) {
        Collection c = Collections.synchronizedCollection( new ArrayList() );
        List list = Collections.synchronizedList( new ArrayList() );
        Set s = Collections.synchronizedSet( new HashSet() );
        Map m = Collections.synchronizedMap( new HashMap() );
        }
    } 

En el ejemplo, el nuevo contenedor se pasa inmediatamente a través del método adecuado de sincronización, para evitar que se produzca algún destrozo por estar expuesto a acciones concurrentes.

Las nuevas Colecciones también tienen un mecanismo de prevenir que más de un proceso esté modificando el contenido de un contenedor. El problema se presenta en el movimiento a través del contenedor mientras otro proceso se encuentra realizando acciones de inserción, borrado o cambio de alguno de los objetos que está almacenado en el contenedor. Puede ya se haya pasado sobre el objeto en cuestión, o puede que todavía no se haya alcanzado, puede que el tamaño del contenedor varíe inmediatamente de que se haya llamado a size(), en fin, que hay mil y un casos en que se puede producir una situación que desemboque en un completo desastre. La nueva librería de colecciones incorpora un mecanismo que llaman fail fast, que está monitorizando continuamente los cambios que se producen en el contenedor por parte de otros procesos, de tal modo que si detecta que se está modificando el contenedor, inmediatamente genera una excepción de tipo ConcurrentModificationException.

Navegador

Home | Anterior | Siguiente | Indice | Correo