Qué es una excepción
El manejo de errores es uno de los puntos más importantes al escribir un programa. Y los errores se detectan en dos momentos distintos, que conviene no confundir:
- En la compilación: son los errores de sintaxis. Acá el compilador ayuda mucho, porque indica el número de línea donde detectó el error y el mensaje.
- En la ejecución: son los errores de semántica o lógicos. El caso típico es una división por cero, que el compilador no detecta porque el divisor puede ser un dato que se lee en tiempo de ejecución y que en algún momento puede valer cero.
Si esos errores de ejecución no se detectan a tiempo, el programa termina abortando, da resultados que no corresponden y en algunos casos se pierde información. Por eso conviene manejarlos en el momento en que ocurren, para que la ejecución no se vea afectada.
La idea es aislar la posible causa de error y manejarla, de modo que no cause problemas: ya sea indicando que se haga «algo» y el programa aborte, o que prosiga. Una excepción es eso: interrumpe la ejecución de una parte del programa porque no se tiene la información suficiente para resolver esa situación inesperada.
Java permite seleccionar la parte del código donde se prevé que puedan pasar cosas inesperadas, indicar cuáles podrían ser y qué se hace en cada caso. Y ahí está la ganancia de fondo, que es una ganancia de diseño: como los errores son inevitables, las excepciones dan una estructura de control que permite escribir los «casos normales» con facilidad y tratar por separado los «casos excepcionales».
Además, las excepciones permiten mantener información sobre lo que falló —el tipo de error, los detalles y el lugar donde se produjo— y enviarla al método que se quiera que se encargue del problema, sin interferir con el funcionamiento normal del programa.
La jerarquía: las excepciones son objetos
En Java las excepciones son objetos y tienen su propio árbol de jerarquía. La clase raíz es Throwable, que es subclase de Object. Los errores y las excepciones son subclases de Throwable, así que heredan sus métodos, y esos son los que mandan los mensajes de error:
toString()— muestra el nombre de la excepción junto con el mensaje que devuelvegetMessage().getMessage()— devuelve el mensaje de error asociado con la excepción.printStackTrace()— imprime el registro de la pila en el lugar donde se inició la excepción, que es la lista de llamadas que llevaron hasta ahí.
Con el manejo de excepciones un programa puede seguir ejecutándose —en vez de terminar— después de resolver un problema en tiempo de ejecución. Eso es exactamente lo que hace que una aplicación sea robusta.
try y catch
El nombre «excepción» ya dice bastante: sugiere que el problema no ocurre con frecuencia. Si la regla es que una instrucción por lo general se ejecuta bien, el problema es la excepción a la regla. El manejo de excepciones permite crear programas tolerantes a fallas, que resuelven el problema o al menos lo informan, y en muchos casos siguen funcionando como si nada hubiera pasado.
Cuando la JVM o un método detecta un problema —un índice de arreglo inválido, un argumento que no corresponde— lanza una excepción. Y cuando se lanza, alguien tiene que capturarla y hacer algo. Los controladores de excepciones determinan qué excepción se lanzó.
La herramienta es un bloque try que delimita el código que puede generar una excepción, y una serie de cláusulas catch que la controlan. Un mismo bloque try puede tener varios catch, para detectar distintas causas y seguir distintas acciones. Cada catch funciona como un método de un solo parámetro: ese parámetro indica qué tipo de error activa ese manejador.
try {
// Cuerpo del bloque que puede generar excepciones
}
catch (nombreDelError1 identificador1) {
// Qué se hace ante nombreDelError1
}
catch (nombreDelError2 identificador2) {
// Qué se hace ante nombreDelError2
}
// Tantos catch como excepciones distintas se puedan generar
También se puede atrapar cualquier tipo de excepción usando la clase base Exception:
catch (Exception e) {
// Bloque que maneja la excepción en general
}
Y adentro del bloque se pueden usar los métodos de Throwable, que es la superclase de Exception.
Qué pasa exactamente cuando se lanza una excepción
- Se sale inmediatamente del bloque de código actual.
- Si ese bloque tiene asociada una cláusula
catchadecuada para el tipo de la excepción, se ejecuta el cuerpo de esecatch. - Si no, se sale también del bloque o método que lo contenía y se sigue buscando una cláusula
catchapropiada hacia afuera. - El proceso continúa hasta llegar al
main. Si ahí tampoco hay uncatchadecuado, la máquina virtual termina la ejecución con un mensaje de error.
Ese modelo —el control no vuelve al punto donde se lanzó, sino que sigue después del último catch— se llama modelo de terminación. Otros lenguajes, como Visual Basic, usan el modelo de reanudación, en el que el control vuelve justo al punto siguiente al que falló.
Y una advertencia que vale para cualquier bloque: cuando un try o un catch termina, se destruyen todas las variables locales declaradas adentro y quedan fuera de alcance. Si una variable se declaró dentro del try, no existe en el catch.
El ejemplo clásico, con una división por cero:
public class EjemploExcepcion {
public static void main(String args[]) {
int num = 5, den = 0;
int res = 0;
try {
res = num / den; // división por cero
System.out.println("Resultado = " + res); // si hay excepción, no se imprime
}
catch (ArithmeticException e) {
System.out.println("El denominador no puede ser 0");
}
System.out.println("Resultado = " + res);
}
}
Mirá las dos cosas que muestra ese código: la instrucción que estaba después de la división dentro del try no se ejecuta, y el println que está después del catch sí: el programa no se cayó, siguió. res queda en 0, que es lo que se ve en la última línea.
El orden de los bloques catch
Hay dos reglas que van juntas, y la segunda es la que produce errores silenciosos:
- Es un error de compilación tener más de un
catchque coincida exactamente con el mismo tipo de excepción. No tiene sentido atrapar lo mismo dos veces. - Pero sí puede haber varios catch para excepciones de una misma jerarquía, y ahí el orden importa muchísimo.
Supongamos que se ordenan de la superclase a las subclases:
try {
// código
}
catch (Exception e) { // la superclase primero
// ...
}
catch (ArithmeticException ae) { // la subclase después: nunca va a llegar
// ...
}
Si dentro del try se produce una división por cero, va a ser atrapada por el primer catch, porque ArithmeticException es de tipo Exception. El segundo bloque no se ejecuta nunca: es código muerto. Y el problema es que el programa compila perfectamente, así que el error no se nota hasta que algo falla y se comporta de una manera que no se entiende.
La regla, entonces: los catch se ordenan de la subclase a la superclase, del tipo más específico al más general. Así cada error lo atrapa el manejador que le corresponde.
finally
Muchas veces hace falta ejecutar un fragmento de código independientemente de si se produjo o no una excepción. El caso típico es cerrar un archivo que se estaba manipulando.
Las reglas de combinación son estas:
- Después de un
trytiene que haber al menos uncatcho unfinally. - Bloques
catchpuede haber varios; bloquefinally, uno solo o ninguno. - Si no hay ningún
catch, elfinallyes obligatorio. Si hay al menos uncatch, elfinallyes optativo.
try {
// código
}
catch (tipo_excepcion nombre_parametro) {
// código
}
finally {
// código que se ejecuta siempre
}
Como el bloque finally casi siempre se ejecuta, se lo usa para liberar recursos: cerrar archivos, cerrar conexiones a bases de datos, cerrar conexiones de red. Es el lugar donde se garantiza que la limpieza se haga aunque el código de adentro haya fallado.
Lanzar excepciones: throw y throws
Los programas Java también pueden lanzar excepciones explícitamente, con la instrucción throw. Eso facilita devolver un «código de error» al método que llamó al que causó el problema.
Y acá aparece una pregunta de diseño que hay que responder cada vez: si un método puede fallar, ¿quién maneja la excepción?, ¿el propio método o el código que lo llamó? Con lo visto hasta ahora sería el propio método, pero eso complica su código y mezcla la tarea con el manejo de errores. La otra posibilidad es que la maneje quien lo llamó, y para eso está throws.
Si ocurre una excepción dentro del método, el código abandona el método y vuelve al lugar desde donde se lo llamó, y ahí se ubica en el catch apropiado.
La diferencia entre las dos palabras es la que más se confunde:
| Palabra | Dónde va | Qué hace |
|---|---|---|
throws |
En la declaración del método, después de la lista de parámetros y antes del cuerpo. | Avisa que ese método puede lanzar esas excepciones —o alguna de sus subclases—. Si son varias, van separadas por comas. |
throw |
En el cuerpo del método. | Lanza la excepción en ese momento. Para eso hay que crear un objeto de tipo Exception o de alguna de sus subclases. |
public static void dividir(int num, int den) throws ArithmeticException {
if (num / den < 1) {
throw new ArithmeticException();
} else {
System.out.println(num / den);
}
}
Ese método declara que puede lanzar ArithmeticException y la lanza a mano cuando la división da menos de 1. Quien lo llame tiene que estar preparado para recibirla.
Tipos de excepciones
La jerarquía tiene tres niveles que hay que reconocer, porque determinan qué se puede hacer con cada error:
| Clase | Qué representa |
|---|---|
Throwable |
La clase base de todo lo que se puede lanzar en Java. Contiene una instantánea del estado de la pila en el momento en que se creó el objeto —el stack trace—, guarda un mensaje de texto que detalla qué error se produjo, y puede tener una causa, también de tipo Throwable, que representa el error que provocó este error. |
Error |
Problemas graves que una aplicación no debería intentar solucionar: memoria agotada, un error interno de la JVM. Ocurren con poca frecuencia y no hay mucho que hacer. |
Exception |
Situaciones que una aplicación sí debería tratar de forma razonable. Es el tipo básico que puede generar cualquiera de los métodos de la biblioteca estándar. |
RuntimeException es no verificado; el resto de Exception es verificado.Verificadas y no verificadas
Java divide las excepciones en dos categorías, y la diferencia tiene consecuencias concretas en si el programa compila o no:
-
Verificadas: todas las subclases de
Exceptionmenos las deRuntimeException. El compilador exige que esas excepciones sean atrapadas o declaradas:- Para atraparlas, el código que las genera tiene que estar dentro de un
trycon uncatchde ese tipo o de alguna superclase. - Para declararlas, la declaración del método que las genera tiene que llevar la cláusula
throwscon el tipo correspondiente.
- Para atraparlas, el código que las genera tiene que estar dentro de un
-
No verificadas: todas las subclases de
Errory deRuntimeException. LosErrorrepresentan situaciones anormales de la JVM y no deben ser atrapados por las aplicaciones; en general la aplicación no puede recuperarse de ellos. Las subclases deRuntimeException—InputMismatchException,ArithmeticException,ArrayIndexOutOfBoundsException,ClassCastException— son errores del programador. No hace falta declararlas enthrows, y aunque se las declare, no es obligatorio atraparlas.
Las clases de excepción se definen como verificables cuando son lo suficientemente importantes como para obligar a atraparlas o declararlas. Es una decisión de la biblioteca, no del que programa: IOException es verificada porque un acceso a disco puede fallar por causas que el programador no controla, y el lenguaje obliga a ocuparse de eso.
El esquema completo
public void f() {
// Fragmento de código libre de excepciones
try {
// Fragmento que puede lanzar una IOException
// (por ejemplo, un acceso a un archivo en el disco)
}
catch (IOException error) {
// Tratamiento de la excepción
}
finally {
// Liberar recursos: se hace siempre, haya o no error
}
}
Excepciones propias
Crear un tipo de excepción nuevo es sencillo: basta con definir una subclase de un tipo de excepción que ya exista. Y eso es obligatorio: una clase de excepción propia tiene que extender de alguna excepción existente para que funcione con el mecanismo del lenguaje.
public class DivisionPorCero extends ArithmeticException {
public DivisionPorCero(String message) {
super(message);
}
}
Y una vez definida, se lanza como cualquier otra:
public double dividir(int num, int den) throws DivisionPorCero {
if (den == 0) {
throw new DivisionPorCero("Error, el denominador no puede ser 0!");
}
return ((double) num / (double) den);
}
Una clase de excepción propia puede tener atributos y métodos, pero en general conviene que tenga solo dos constructores:
- Uno sin argumentos, que le pasa un mensaje predeterminado al constructor de la superclase.
- Otro con un argumento, que recibe un mensaje personalizado y se lo pasa a la superclase.
Las aplicaciones suelen definir sus propias subclases de Exception para representar situaciones específicas de ese sistema: SaldoInsuficienteException, AlumnoNoInscriptoException. La ventaja es enorme: el catch deja de ser genérico y pasa a decir qué pasó, en el idioma del problema que se está resolviendo.
Vectores de referencias a objetos
Hasta acá se vieron vectores de tipos primitivos, pero en Java también se pueden definir vectores cuyos elementos son referencias a objetos. Si se quieren guardar los datos de varias personas:
Persona vectorPersonas[] = new Persona[10];
donde la clase Persona podría ser así:
public class Persona {
private String nombre;
private long DNI;
private int edad;
public Persona(String n, long dni, int ed) {
this.nombre = n;
this.DNI = dni;
this.edad = ed;
}
}
Para inicializar cada posición con una referencia a una persona, hay que crear el objeto y asignarlo:
Persona vectorPersonas[] = new Persona[10];
String nom;
long dni;
int edad;
for (int i = 0; i < vectorPersonas.length; i++) {
nom = JOptionPane.showInputDialog("Ingrese nombre");
dni = Long.parseLong(JOptionPane.showInputDialog("Ingrese su DNI"));
edad = Integer.parseInt(JOptionPane.showInputDialog("Ingrese su edad"));
vectorPersonas[i] = new Persona(nom, dni, edad);
}
new Persona[10] reserva lugar para diez referencias, no crea diez personas. Al principio, las diez posiciones valen null. Si se intenta usar vectorPersonas[0].getNombre() antes de asignarle un objeto, el programa falla con una NullPointerException. Hay que crear cada objeto, uno por uno, como en el bucle del ejemplo. Con los vectores de tipos primitivos esto no pasa porque las casillas se inicializan en cero; con las referencias, en null.
Nota: JOptionPane es una clase de javax.swing que muestra un cuadro de diálogo con un campo de texto y devuelve siempre un String, por eso hay que convertirlo con Integer.parseInt o Long.parseLong. Las interfaces gráficas se ven en las lecciones 6 y 7; acá solo se usa como una forma cómoda de pedir datos.
La clase Arrays
Dentro del paquete java.util está la clase Arrays, que provee métodos estáticos para trabajar con vectores de cualquier tipo. Esos métodos están sobrecargados para objetos y para todos los tipos primitivos.
| Método | Qué hace |
|---|---|
equals(v1, v2) | Devuelve true si v1[i] = v2[i] para todas las posiciones dentro del rango. |
fill(v, x) | Llena el vector v con el valor x. |
sort(v) | Ordena los elementos de forma ascendente. Los elementos tienen que ser de un tipo primitivo, o su tipo tiene que implementar la interfaz Comparable. |
binarySearch(v, x) | Devuelve la posición donde está x, o un número negativo si no está. El vector tiene que estar ordenado antes, si no el resultado no tiene sentido. |
deepToString(Object[] v) | Devuelve una representación en texto del contenido profundo del arreglo, es decir, también de los arreglos que tenga adentro. |
deepEquals(v1, v2) | Devuelve true si los dos arreglos son iguales en todos sus niveles. |
copyOfRange(v1, pos, cant) | Devuelve un vector nuevo con la copia de cant elementos desde la posición pos. |
toString(v) | Devuelve el contenido del vector en texto. Aparece en el ejemplo aunque no esté en la tabla del apunte, y es el que hace que println muestre los elementos en lugar de la dirección de memoria. |
Un ejemplo completo, con la salida que produce:
public class PruebaArrays {
public static void main(String[] args) {
char[] miArray1 = { 'b', 'e', 'd', 'h', 'j', 'a', 'c', 'f', 'g', 'i' };
char[] miArray2 = { 'b', 'e', 'd', 'h', 'j', 'a', 'c', 'f', 'g', 'i' };
// Compara los dos vectores
if (Arrays.equals(miArray1, miArray2)) {
System.out.println("Los dos arrays son iguales!");
} else {
System.out.println("Los dos arrays NO son iguales!");
}
miArray1[8] = 'k';
System.out.println("Array sin ordenar...");
System.out.println(Arrays.toString(miArray1));
Arrays.sort(miArray1);
System.out.println("Array ordenado ...");
System.out.println(Arrays.toString(miArray1));
// El índice de un valor particular
int index = Arrays.binarySearch(miArray1, 'k');
System.out.println("k esta en la posición " + index);
String[][] taTeTi = { { "X", "O", "O" }, { "O", "X", "X" }, { "X", "O", "X" } };
System.out.println(Arrays.deepToString(taTeTi));
// Llena un vector entero con el valor 8
int[] vecInt = new int[5];
Arrays.fill(vecInt, 8);
System.out.println(Arrays.toString(vecInt));
// Copiar de un vector a otro
char[] vecA = { 'p', 'r', 'o', 'g', 'r', 'a', 'm', 'a', 'e', 'n', 'J', 'A', 'V', 'A' };
char[] vecB = new char[7];
char[] vecC = Arrays.copyOfRange(vecA, 2, 9); // devuelve un vector nuevo
System.arraycopy(vecA, 3, vecB, 0, 4); // copia con el método de System
System.out.println(new String(vecB));
System.out.println(new String(vecC));
System.out.println(Arrays.toString(vecB));
System.out.println(Arrays.toString(vecC));
}
}
Los dos arrays son iguales!
Array sin ordenar...
[b, e, d, h, j, a, c, f, k, i]
Array ordenado ...
[a, b, c, d, e, f, h, i, j, k]
k esta en la posición 9
[[X, O, O], [O, X, X], [X, O, X]]
[8, 8, 8, 8, 8]
gram
ogramae
[g, r, a, m]
[o, g, r, a, m, a, e]
Fijate en dos detalles de la salida: binarySearch devuelve 9 para la 'k', que es su posición después de ordenar —por eso hay que ordenar antes—, y el extremo final del rango 2 a 9 se excluye, que es la razón por la que vecC tiene 7 caracteres y no 8. Los rangos en Java casi siempre son inclusivos al principio y exclusivos al final.
El Collections Framework
El Java Collections Framework (JCF) es un conjunto de clases e interfaces incluidas en la API de Java que permiten almacenar, manipular y procesar grupos de objetos de manera eficiente. Antes de que existiera, los programadores tenían que implementar sus propias estructuras de datos para guardar elementos. El framework proporciona estructuras reutilizables y algoritmos optimizados para los problemas comunes.
La diferencia fundamental con los vectores: los arrays tienen tamaño estático; las colecciones pueden crecer o disminuir durante la ejecución. Y eso resuelve el problema que tienen los vectores en la práctica: hay que saber de antemano cuántos elementos van a ser, o desperdiciar memoria reservando de más.
Las colecciones permiten almacenar múltiples objetos, hacer búsquedas y recorridos, ordenar datos, eliminar elementos y manipular información de manera dinámica. Y sus ventajas principales son cuatro:
- Reutilización de código: son estructuras ya probadas y optimizadas.
- Mayor productividad: menos código propio, menos probabilidad de errores.
- Seguridad de tipos: se integran con los genéricos, lo que evita errores de conversión.
- Algoritmos integrados: traen el ordenamiento y la búsqueda ya resueltos.
Los métodos que comparten todas las colecciones
| Método | Qué hace |
|---|---|
boolean add(Object o) | Agrega el objeto. Devuelve true si se pudo completar; si la colección no cambió como resultado, devuelve false. |
boolean remove(Object o) | Elimina el objeto indicado. |
int size() | Devuelve el número de objetos almacenados. |
boolean isEmpty() | Indica si la colección está vacía. |
boolean contains(Object o) | Devuelve true si la colección contiene ese objeto. |
void clear() | Elimina todos los elementos. |
boolean addAll(Collection otra) | Agrega todos los elementos de la otra colección. |
boolean removeAll(Collection otra) | Elimina todos los elementos que estén en la otra colección. |
boolean retainAll(Collection otra) | Elimina todos los elementos que no estén en la otra colección: deja solo la intersección. |
boolean containsAll(Collection otra) | Indica si la colección contiene todos los elementos de la otra. |
Object[] toArray() | Convierte la colección en un arreglo de objetos. |
Iterator iterator() | Obtiene el iterador de la colección, que es lo que se usa para recorrerla. |
List, Set, Queue y Map
El framework se divide en dos jerarquías principales: la interfaz raíz Collection y la interfaz Map, que es aparte porque está basada en pares de clave y valor.
Map no es una Collection y por eso no usa los mismos métodos.List: listas
Representa una secuencia ordenada que permite elementos duplicados y acceso por índice. Se puede generar un ListIterator para moverse por la lista en las dos direcciones.
ArrayList: implementación basada en arreglos dinámicos. Es excelente para el acceso rápido por índice, pero costosa para insertar y eliminar en el medio, porque hay que correr los elementos siguientes. Para agregar se usaaddy para obtener el de una posición,get.LinkedList: basada en nodos enlazados. Muy eficiente para inserciones y eliminaciones frecuentes, pero más lenta en el acceso aleatorio. Además tiene los métodosaddLast(),getFirst(),getLast(),removeFirst()yremoveLast(), que no están en ninguna interfaz o clase base y permiten usar la lista como pila, cola o cola doble.
//---- Ejemplo ArrayList ----
import java.util.ArrayList;
import java.util.List;
public class EjemploList {
public static void main(String args[]) {
List<String> nombres = new ArrayList<>();
nombres.add("Lucía");
nombres.add("Brianna");
nombres.add("Lucía"); // Permite duplicados
System.out.println("Lista: " + nombres); // Lista: [Lucía, Brianna, Lucía]
}
}
//---- Ejemplo LinkedList ----
LinkedList<String> ciudades = new LinkedList<>();
ciudades.add("Buenos Aires");
ciudades.add("Cordoba");
ciudades.addFirst("Rosario"); // método propio de LinkedList
Set: conjuntos
Representa una colección que no admite elementos duplicados. Hay que decidir qué pasa con el orden, y de eso se encarga la implementación:
HashSet: almacena los elementos sin un orden específico, usando una tabla hash para las búsquedas rápidas. La naturaleza de las tablas hash hace que no haya valores duplicados, pero no garantiza ningún orden. Cada valor nuevo entra en la siguiente posición libre, y si llega otro igual, se descarta.LinkedHashSet: mantiene el orden de inserción. Por dentro es una lista doblemente enlazada.TreeSet: mantiene los elementos ordenados automáticamente, por el orden natural o por unComparator. Por dentro es un árbol balanceado: una estructura donde los datos se organizan en nodos que se relacionan con otros, y de cada nodo cuelgan como mucho dos, con los valores menores a la izquierda y los mayores a la derecha. Al recorrerla, los datos aparecen ya en orden. Agregar elementos es más lento, pero el recorrido ordenado es mucho más eficiente.
El requisito del TreeSet es el que hay que entender bien: los objetos tienen que poder compararse para determinar su orden dentro del árbol. Eso implica implementar la interfaz Comparable<E> de Java, que está en java.lang. Esa interfaz define el método compareTo, que recibe el objeto a comparar y devuelve 0 si son iguales, un número positivo si el primero es mayor y negativo si es menor.
//---- Ejemplo HashSet ----
import java.util.HashSet;
import java.util.Set;
public class EjemploSet {
public static void main(String[] args) {
Set<String> materias = new HashSet<>();
materias.add("Programación");
materias.add("Base de Datos");
materias.add("Programación"); // No se agrega el duplicado
System.out.println("Materias: " + materias);
}
}
//---- Ejemplo TreeSet ----
import java.util.TreeSet;
public class Alumno implements Comparable<Alumno> {
protected String nombre;
protected double nota;
public Alumno(String name, double nota) {
this.nombre = name;
this.nota = nota;
}
public String toString() {
return "Alumno [nombre=" + nombre + ", nota=" + nota + "]";
}
public int compareTo(Alumno o) {
int comparacion = nombre.compareToIgnoreCase(o.nombre);
if (comparacion == 0) {
return (int) (nota - o.nota);
} else {
return comparacion;
}
}
public static void main(String[] args) {
Alumno a1 = new Alumno("Lucía", 7);
Alumno a2 = new Alumno("Lucía", 6);
Alumno a3 = new Alumno("Lucía", 7);
Alumno a4 = new Alumno("Brianna", 7);
Alumno a5 = new Alumno("Lucía", 7);
Alumno a6 = new Alumno("Brianna", 8);
TreeSet<Alumno> tre = new TreeSet<Alumno>();
tre.add(a1);
tre.add(a2);
tre.add(a3); // ya existe uno igual: no entra
tre.add(a4);
tre.add(a5); // tampoco
tre.add(a6);
for (Alumno alumno : tre) {
System.out.print(alumno + "");
}
}
}
Alumno [nombre=Brianna, nota=7.0]Alumno [nombre=Brianna, nota=8.0]Alumno [nombre=Lucía, nota=6.0]Alumno [nombre=Lucía, nota=7.0]
La salida muestra las dos cosas a la vez: los elementos salen ordenados —Brianna antes que Lucía, y dentro de cada nombre, por nota— y no hay repetidos: se agregaron seis objetos y quedaron cuatro. El método compareTo de este ejemplo compara primero por nombre y, si empatan, por nota. Ahí está la decisión de qué significa «igual» para un Alumno, y es la clase la que la toma.
Queue: colas
Representa una estructura tipo cola: los elementos se procesan generalmente en el orden en que ingresan. Queue y PriorityQueue permiten modelar colas FIFO —el primero que entra es el primero que sale— y estructuras basadas en prioridades, donde sale primero el de mayor prioridad sin importar cuándo llegó.
Map: mapas
Almacena pares de clave y valor. Las claves son únicas, lo que permite un acceso muy rápido al dato asociado. Es importante notar que esta interfaz no deriva de Collection, así que no usa iteradores ni ninguno de los métodos vistos antes, porque la búsqueda, la obtención y el borrado se hacen de manera muy distinta. Los mapas no permiten insertar objetos nulos como clave.
HashMap: la implementación más común; no garantiza ningún orden, y da un rendimiento constante al insertar y localizar.LinkedHashMap: mantiene el orden de inserción.TreeMap: ordena las claves automáticamente, con un árbol balanceado. Es el único que define el métodosubMap(), que permite recuperar una parte del árbol.
//---- Ejemplo HashMap ----
import java.util.HashMap;
import java.util.Map;
public class EjemploMap {
public static void main(String[] args) {
Map<Integer, String> alumnos = new HashMap<>();
alumnos.put(1, "Lucía");
alumnos.put(2, "Brianna");
System.out.println("Alumno legajo 1: " + alumnos.get(1));
}
}
Acá la clave es el número de legajo y el valor, el nombre. El acceso alumnos.get(1) no recorre nada: la estructura va derecho al dato. Es la diferencia con una lista, donde encontrar un elemento obliga a recorrerla, aunque sea con un algoritmo rápido.
Recorrido y ordenamiento
Para recorrer estas estructuras se usan principalmente los iteradores o el bucle for-each.
Iterator y ListIterator
El iterador permite recorrer y eliminar elementos de forma segura durante la iteración —hacerlo con un for común sobre la colección produce un error—. Los iteradores permiten crear listas simplemente enlazadas, en las que los nuevos elementos se agregan al final y solo se pueden recorrer de izquierda a derecha.
| Método | Qué hace |
|---|---|
Object next() | Obtiene el siguiente objeto. Si se llegó al final y se intenta seguir, lanza NoSuchElementException. |
boolean hasNext() | Indica si hay un elemento siguiente, y así evita esa excepción. |
void remove() | Elimina el último elemento devuelto por next(). |
void add(Object o) | Agrega el elemento delante de la posición actual del iterador. |
void set(Object o) | Sustituye el elemento señalado por el iterador. |
Object previous() | Obtiene el elemento anterior. Si no lo hay, lanza NoSuchElementException. |
boolean hasPrevious() | Indica si hay elemento anterior al actual. |
int nextIndex() | El índice del elemento siguiente. |
int previousIndex() | El índice del elemento anterior. |
//---- Ejemplo Iterator ----
Iterator<String> it = lista.iterator();
while (it.hasNext()) {
System.out.println(it.next());
}
El patrón es siempre el mismo: preguntar con hasNext() y pedir con next(). Nunca al revés.
El for-each para colecciones
Desde la versión 1.5 de Java existe una versión del for que recorre una colección de la misma forma que un iterador, sin el manejo manual:
for (Object elemento : nombreColeccion) {
// ...
}
En cada vuelta, el siguiente elemento de la colección se asigna al objeto indicado. Es la forma que se usa el 90 % de las veces, y es la misma que ya se vio para los vectores.
Comparable y Comparator
Se usan para definir criterios de ordenamiento, y la diferencia entre los dos es esta:
Comparabledefine el orden natural de una clase. Se implementa dentro de la clase, y por eso solo puede haber uno.Comparatorpermite crear criterios externos, y por eso pueden existir varios: ordenar alumnos por nombre, o por nota, o por legajo.
//---- Ejemplo Comparable ----
public class Alumno implements Comparable<Alumno> {
protected String nombre;
protected double nota;
public Alumno(String name, double nota) {
this.nombre = name;
this.nota = nota;
}
public String toString() {
return "Alumno [nombre=" + nombre + ", nota=" + nota + "]";
}
public int compareTo(Alumno otro) {
return this.nombre.compareTo(otro.nombre);
}
}
//---- Ejemplo Comparator ----
// Ordena la lista por promedio, con un criterio externo a la clase
Collections.sort(lista, (a, b) -> Double.compare(a.getPromedio(), b.getPromedio()));
Ese (a, b) -> ... es una expresión lambda: una forma corta de escribir el objeto que implementa Comparator, sin crear una clase aparte. Se lee «dados dos elementos a y b, compará sus promedios».
La clase Collections
Hay una clase llamada Collections —ojo, con s final, no confundir con la interfaz Collection— que contiene numerosos métodos estáticos para usar con todo tipo de colecciones. Proporciona los algoritmos reutilizables del framework.
| Método | Qué hace |
|---|---|
addAll(c, elementos...) | Agrega a la colección c la lista de elementos indicados, separados por comas. |
binarySearch(l, valor, comparador) | Busca de forma binaria en la lista, que debe estar ordenada según ese comparador. |
copy(lista1, lista2) | Copia los elementos de la segunda lista en la primera. La primera tiene que ser tan larga como la segunda, porque los elementos copiados reemplazan a los originales. Si no lo es, se lanza IndexOutOfBoundsException. |
emptyList() / emptyMap() / emptySet() | Devuelven una lista, un mapa o un conjunto vacío del tipo indicado. |
fill(lista, obj) | Reemplaza los elementos de la lista con el objeto indicado. Si la lista tenía 10 elementos, los diez pasan a ser el mismo objeto. |
indexOf(lista, sublista) | Busca todos los elementos de la sublista dentro de la lista y devuelve la posición donde empieza. Si no está, devuelve -1. |
reverse(lista) | Invierte el orden de la lista. |
shuffle(lista) | Mezcla aleatoriamente los elementos. |
sort(lista) | Ordena según el orden natural de los elementos, cuya clase debe implementar Comparable. |
swap(lista, i1, i2) | Intercambia los elementos de las posiciones indicadas. La lista tiene que tener elementos en esas posiciones, si no se produce IndexOutOfBoundsException. |
La API de Stream
Permite aplicar programación funcional sobre colecciones. Las operaciones más frecuentes son filter, map, reduce, collect y forEach.
import java.util.List;
import java.util.Arrays;
public class EjemploStream {
public static void main(String[] args) {
List<Integer> numeros = Arrays.asList(1, 2, 3, 4, 5);
numeros.stream()
.filter(n -> n % 2 == 0) // se queda con los pares
.forEach(System.out::println); // y los imprime: 2 y 4
}
}
Ese código hace en dos líneas lo que con un bucle ocuparía seis: filter deja pasar solo los que cumplen la condición y forEach hace algo con cada uno. System.out::println es otra forma corta de escribir una lambda, que se llama referencia a método.
Flujos de datos
La comunicación entre el programa y el origen o el destino de la información se hace mediante un flujo de datos: un objeto que actúa de intermediario entre el programa y de dónde vienen o a dónde van los datos. La idea es que el programa lea o escriba en el flujo, sin importarle de dónde viene la información ni adónde va, ni tampoco el tipo de los datos.
El paquete java.io contiene todas las clases relacionadas con las funciones de entrada y salida. Se habla de E/S —o I/O— para referirse a la entrada y salida de datos hacia o desde un programa.
- Los datos fluyen en serie, byte a byte, de forma secuencial. Se habla entonces de un stream o flujo de bytes.
- Hay otro tipo de flujo que lanza caracteres —tipo
charUnicode, de dos bytes—: es unReadersi es de lectura y unWritersi es de escritura.
Los problemas de entrada y salida suelen causar excepciones de tipo IOException o de sus derivadas, así que la mayoría de las operaciones tienen que ir dentro de un try. Es exactamente por eso que IOException es una excepción verificada.
| Clases de caracteres | Clases de bytes equivalentes |
|---|---|
Reader | InputStream |
BufferedReader | BufferedInputStream |
LineNumberReader | LineNumberInputStream |
CharArrayReader | ByteArrayInputStream |
InputStreamReader | (ninguna) |
FileReader | FileInputStream |
FilterReader | FilterInputStream |
PushbackReader | PushbackInputStream |
PipedReader | PipedInputStream |
StringReader | StringBufferInputStream |
Writer | OutputStream |
BufferedWriter | BufferedOutputStream |
CharArrayWriter | ByteArrayOutputStream |
FilterWriter | FilterOutputStream |
OutputStreamWriter | (ninguna) |
FileWriter | FileOutputStream |
PrintWriter | PrintStream |
PipedWriter | PipedOutputStream |
StringWriter | (ninguna) |
La tabla completa se entiende mucho mejor cuando se ve el patrón: cada clase de caracteres tiene su equivalente en bytes, con el mismo nombre y el mismo comportamiento. Si se aprende a usar una, se sabe usar la otra. Las tres que no tienen equivalente son justamente las que hacen la conversión entre los dos mundos, y de eso se ocupa la sección de lectura por caracteres.
La entrada y la salida estándar
java.lang.System es una clase que tiene muchas clases relacionadas con la configuración del sistema. Entre ellas están in, que es un InputStream y representa la entrada estándar —normalmente el teclado—, y out, que es un OutputStream y representa la salida estándar —normalmente la pantalla—. Es lo que se ha estado usando todo el curso sin decirlo: System.out.println.
InputStream ingreso = System.in;
OutputStream salida = System.out;
El método read() permite leer un byte, y puede lanzar una IOException, así que hay que capturarla:
int valor = 0;
try {
valor = System.in.read();
}
catch (IOException e) {
// ...
}
System.out.println(valor);
Pero ese código no sirve para nada útil, y vale la pena entender por qué: read() lee un byte, y por la entrada estándar lo que llega son caracteres. El método devuelve el número correspondiente al byte —si se escribe una «A», devuelve 65—, que no es lo que uno quiere. read() acepta un argumento que es un array de bytes, donde guarda lo leído, y devuelve la cantidad de caracteres leídos, pero eso exige una codificación bastante complicada.
Una opción intermedia es leer carácter por carácter y acumularlos en un StringBuffer:
public static void main(String arg[]) {
StringBuffer salida = new StringBuffer();
char caracter;
System.out.println("\nEscriba un texto: ");
try {
do {
caracter = (char) System.in.read(); // conversión de int a char
salida.append(caracter);
} while (caracter != '\n');
}
catch (Exception e) {
System.err.println(e);
}
System.out.println("\nLa línea escrita es: ");
System.out.println(salida);
}
El (char) de la tercera línea es una conversión: read() devuelve un int y hay que convertirlo a carácter para poder guardarlo. Y el bucle corta cuando aparece el salto de línea, que es un carácter más de lo que se escribió.
Reader: leer caracteres de verdad
El hecho de que InputStream y OutputStream trabajen con bytes complica mucho su uso, y desde que se impuso Unicode —con sus clases Reader y Writer— había que resolver el problema de tener que usar las dos. La solución fueron dos clases puente: InputStreamReader y OutputStreamWriter. Sirven para convertir secuencias de bytes en secuencias de caracteres según una configuración regional, y se construyen a partir de un InputStream o un OutputStream. Como derivan de Reader y Writer, el problema queda resuelto.
El constructor de InputStreamReader pide un InputStream y, opcionalmente, una cadena con el código de caracteres a usar —por ejemplo "ISO-8859-1", el Latín 1—. Sin ese segundo parámetro se construye con la codificación actual, que es lo normal. Lo que se obtiene es un objeto convertidor, y a partir de ahí se puede usar la lectura orientada a caracteres Unicode:
InputStreamReader stdin = new InputStreamReader(System.in);
char caracter[] = new char[1024];
int numero = -1;
try {
numero = stdin.read(caracter);
} catch (IOException e) {
System.out.println("Error en la lectura");
}
for (int i = 0; i < numero; i++) {
System.out.print(caracter[i]);
}
Leer con readLine
Leer con un array de caracteres sigue siendo enrevesado, así que para leer cadenas se usa la clase BufferedReader: tiene el método readLine(), que devuelve una línea completa como String, que es mucho más fácil de manipular. Su constructor acepta objetos Reader —y por lo tanto InputStreamReader, que deriva de él— y, opcionalmente, la cantidad de caracteres a leer.
String texto = "";
try {
// Obtención del objeto Reader
InputStreamReader conv = new InputStreamReader(System.in);
// Obtención del BufferedReader
BufferedReader entrada = new BufferedReader(conv);
System.out.println("Ingrese un texto");
texto = entrada.readLine();
} catch (IOException e) {
System.out.println("Error");
}
System.out.println(texto);
BufferedReader recibe el InputStreamReader, que recibe el System.in. Cada capa agrega una capacidad, y el programa solo habla con la de afuera. A esto se lo llama decorador, y es uno de los patrones de diseño de la lección 5.
Archivos: la clase File
Un archivo es una colección de datos relacionada lógicamente, que se almacena en algún soporte físico —memoria, disco rígido— para poder manipularla en cualquier momento. Los archivos son transmitidos como flujos de datos por el sistema.
En el paquete java.io está la clase File, pensada para hacer operaciones de información sobre archivos. Es importante entender qué no hace: no proporciona métodos de acceso al contenido, sino operaciones a nivel del sistema de archivos: listar, crear carpetas, borrar, cambiar el nombre.
La dificultad de estas operaciones está en que los sistemas de archivos son distintos en cada sistema operativo, y aunque Java intenta aislar esas particularidades, no lo consigue del todo.
Cómo se construye una referencia a un archivo
Se usa como argumento una cadena que representa una ruta. También puede recibir un segundo parámetro con una ruta que se define a partir de la primera.
File archivo1 = new File("/datos/archivo.txt"); // ruta absoluta
File archivo2 = new File("archivo.txt"); // ruta relativa
File carpeta1 = new File("c:/datos"); // o c:\\datos
File archivo3 = new File(carpeta1, "archivo.txt"); // dentro de esa carpeta
La ruta absoluta se cuenta desde la raíz de la unidad de disco; la relativa, desde la carpeta actual de trabajo. Y si el archivo o la carpeta no existen, File no lanza ninguna excepción: hay que preguntar con el método exists().
El problema de los separadores
Cuando se escriben programas en Java hay que tener presente que no se sabe qué sistema operativo va a usar el que los ejecute. Eso hace que armar rutas sea problemático, porque cada sistema las nombra y las recorre de manera distinta: en Windows se puede usar la barra / o la doble barra invertida \\, pero en los sistemas tipo Unix solo funciona la primera.
Para no depender de eso, la clase File tiene variables estáticas que dan el separador correcto del sistema en uso:
| Propiedad | Qué da |
|---|---|
char separatorChar | El separador de nombres de archivo y carpetas. En GNU/Linux es / y en Windows \, que se escribe como \\ porque la barra invertida sirve para poner caracteres de control. |
String separator | Lo mismo, pero como String. |
char pathSeparatorChar | El separador de rutas de archivo, que permite poner más de un archivo en una misma ruta. En GNU/Linux suele ser : y en Windows ;. |
String pathSeparator | Lo mismo, como String. |
Y así se garantiza que la ruta use el separador del sistema donde corre el programa:
String ruta = "documentos/manuales/java.pdf";
ruta = ruta.replace('/', File.separatorChar);
Mejor todavía: usar las clases Swing, como JFileChooser, para que el usuario elija el archivo desde un cuadro de diálogo. Además de ser más cómodo, el resultado es independiente de la plataforma.
Los métodos de File
Estos son los métodos más usados, agrupados por lo que hacen:
| Método | Qué hace |
|---|---|
| Consultar información | |
String toString() | La cadena descriptiva del objeto. |
boolean exists() | true si la carpeta o el archivo existe. |
boolean canRead() / canWrite() | true si se puede leer o escribir. |
boolean isHidden() | true si es oculto. |
boolean isAbsolute() | true si la ruta es absoluta. |
boolean isDirectory() / isFile() | Indican si el objeto es una carpeta, un archivo, o ninguna de las dos cosas. |
boolean equals(File f2) | Compara con otro File. |
String getAbsolutePath() / File getAbsoluteFile() | La ruta absoluta, como texto o como objeto. |
String getName() | El nombre. |
String getParent() / File getParentFile() | El nombre de la carpeta superior, o null si no la hay. |
long length() | El tamaño del archivo, en bytes. |
URL toURL() / URI toURI() | La notación URL o URI correspondiente. toURL() puede lanzar MalformedURLException. |
| Operar sobre el sistema de archivos | |
boolean setReadOnly() | Activa el atributo de solo lectura. |
boolean mkdir() | Intenta crear una carpeta y devuelve true si pudo. |
boolean mkdirs() | Crea la carpeta y, si hace falta, toda la estructura de carpetas necesaria para llegar a ella. |
boolean delete() | Borra la carpeta o el archivo, y devuelve true si pudo. |
boolean renameTo(File f2) | Cambia el nombre por el del archivo que se pasa como argumento. |
boolean createNewFile() | Crea un archivo vacío en la ruta dada. Devuelve true si lo creó y false si ya existía otro con ese nombre. Puede lanzar IOException. |
String[] list() | La lista de nombres de archivo de la carpeta. |
File[] listFiles() | Igual, pero devuelve objetos File en lugar de nombres. |
static File[] listRoots() | Un arreglo con la carpeta raíz de cada unidad de disco. |
static File createTempFile(prefijo, sufijo) | Crea un archivo temporal en la carpeta de temporales del sistema. El prefijo y el sufijo deben tener al menos tres caracteres, si no se produce IllegalArgumentsException. Hay una variante que recibe además el directorio. |
void deleteOnExit() | Borra el archivo cuando termina la ejecución del programa. |
Lectura y escritura byte a byte
Los archivos binarios guardan una representación de los datos, no los datos tal cual: cuando se guarda texto, no se guardan las letras sino su representación en un código, como UTF-8.
Para leer y escribir datos en archivos, Java usa dos clases especializadas que trabajan orientadas a byte: FileInputStream para la lectura y FileOutputStream para la escritura. Se construyen con un parámetro que puede ser una ruta o un objeto File:
FileInputStream flujo1 = new FileInputStream(objetoFile);
FileInputStream flujo2 = new FileInputStream("/textos/texto25.txt");
Los FileOutputStream se construyen igual, pero aceptan un segundo parámetro booleano: con true permite agregar datos al final del archivo; con false —lo habitual— se abre para grabar desde cero y se borra el contenido anterior.
Estos constructores intentan abrir el archivo, y ahí lanzan FileNotFoundException si el archivo no existe o si hay un error en la apertura. Los métodos read y write son los heredados de InputStream y OutputStream, y read devuelve -1 al llegar al final del archivo.
Una opción mucho más interesante es usar DataInputStream y DataOutputStream, que están preparados para escribir datos de todo tipo. El proceso de escritura es:
- Crear un
FileOutputStreama partir de unFilecon la ruta del archivo. - Crear un
DataOutputStreamasociado al anterior, en su construcción. - Usar el
DataOutputStreampara escribir los datos con los métodoswriteTipo—writeInt,writeDouble—, pasándole el dato como único argumento. - Cerrar el archivo con
close().
La lectura es análoga, con una advertencia: al llegar al final del archivo se produce una excepción EOFException, que es subclase de IOException, así que hay que controlarla. De hecho es lo que se usa para detectar el final y cortar el bucle.
public static void main(String args[]) {
File f = new File("/home/usuario/Documentos/prueba.out");
Random r = new Random();
boolean finArchivo = false;
double d = 1.00;
// Escritura
try {
FileOutputStream fos = new FileOutputStream(f);
DataOutputStream dos = new DataOutputStream(fos);
for (int i = 0; i < 256; i++) {
dos.writeDouble(r.nextDouble() * 10); // aleatorios entre 0 y 10
}
dos.close();
}
catch (FileNotFoundException e) {
System.out.println("No se encontró el archivo");
}
catch (IOException e) {
System.out.println("Error al escribir");
}
System.out.println("<< Se escribió en el archivo >>");
// Lectura del archivo
try {
FileInputStream fis = new FileInputStream(f);
DataInputStream dis = new DataInputStream(fis);
while (!finArchivo) {
d = dis.readDouble();
System.out.println(d);
}
dis.close();
}
catch (EOFException e) {
finArchivo = true; // la excepción corta el bucle: llegamos al final
}
catch (FileNotFoundException e) {
System.out.println("No se encontró el archivo");
}
catch (IOException e) {
System.out.println("Error al leer");
}
}
Mirá lo que hace el catch (EOFException e): usa una excepción como condición de corte. Es una técnica frecuente en la lectura de archivos, y explica por qué el while no puede tener una condición normal: no se sabe cuántos datos hay hasta que se terminan. El orden de los catch también importa acá: EOFException y FileNotFoundException van antes que IOException, porque son subclases suyas.
Lectura y escritura por caracteres
Como pasa con la entrada estándar, un FileInputStream o un FileOutputStream se pueden convertir a forma de Reader o Writer con InputStreamReader y OutputStreamWriter. Pero además existen dos clases que manejan caracteres directamente, lo que hace todo mucho más cómodo: FileReader y FileWriter.
Se construyen con un File o un String que represente al archivo. El FileWriter acepta un segundo parámetro booleano: con true abre el archivo para agregar datos, y con false lo abre para grabar desde cero, borrando el contenido.
Para escribir se usa write, que es void y recibe lo que se quiere escribir en formato int, String o array de caracteres. Para leer se usa read, que devuelve un int y puede recibir un array de caracteres donde guardar lo leído. Los dos pueden lanzar IOException.
public static void main(String args[]) {
File f = new File("archivo.txt");
int x = 77;
try {
FileWriter fw = new FileWriter(f);
fw.write(x); // escribe M (ASCII 77) en el archivo
fw.close();
}
catch (IOException e) {
System.out.println("error");
return;
}
// Lectura de los datos
try {
FileReader fr = new FileReader(f);
x = fr.read(); // lee el entero que corresponde al ASCII de M
fr.close();
}
catch (FileNotFoundException e) {
System.out.println("Error al abrir el archivo");
}
catch (IOException e) {
System.out.println("Error al leer");
}
System.out.println(x);
}
Y para texto —donde lo que se escribe son líneas—, lo que se usa son BufferedWriter y BufferedReader, con el finally cerrando los dos objetos:
public static void main(String[] args) {
File f = new File("archivo.txt");
FileWriter fw = null;
BufferedWriter bw = null;
FileReader fr = null;
BufferedReader br = null;
// Escribir en el archivo
try {
fw = new FileWriter(f);
bw = new BufferedWriter(fw);
String s = "Lucía Fernández";
for (int i = 0; i < 10; i++) {
bw.write(s);
bw.write("\n");
}
}
catch (FileNotFoundException e) {
System.out.println("Error al abrir el archivo");
}
catch (IOException e) {
System.out.println("Error al escribir");
}
finally {
try {
if (bw != null) bw.close();
if (fw != null) fw.close();
}
catch (IOException ex) {
ex.printStackTrace();
}
}
// Leer el archivo
try {
fr = new FileReader(f);
br = new BufferedReader(fr);
String s;
do {
s = br.readLine();
System.out.println(s);
} while (s != null);
}
catch (FileNotFoundException e) {
System.out.println("Error al abrir el archivo");
}
catch (IOException e) {
System.out.println("Error al leer");
}
finally {
try {
if (br != null) br.close();
if (fr != null) fr.close();
}
catch (IOException ex) {
ex.printStackTrace();
}
}
}
Dos cosas que enseña este ejemplo. Primero: los objetos se declaran afuera del try y se inicializan adentro, así el finally puede verlos para cerrarlos; si se declararan adentro, no existirían ahí. Segundo: el if (bw != null) es necesario porque si el new falló, el objeto nunca se creó y llamar a close() daría NullPointerException. Y el while (s != null) usa el mismo truco que EOFException: readLine devuelve null cuando no hay más nada que leer. Nota: el último println imprime un null de más, porque el bucle se ejecuta una vez más antes de darse cuenta; con while ((s = br.readLine()) != null) eso no pasa.
Archivos de acceso aleatorio
Todos los archivos que se vieron hasta ahora son secuenciales: se leen desde el primer byte hasta el último. La clase RandomAccessFile permite leer cualquier posición del archivo en cualquier momento, situando el puntero en una posición determinada sin recorrer todo lo anterior. No permite serializar objetos, eso es otra cosa que se ve enseguida.
Para ubicarse en distintas posiciones del archivo se usa un puntero de lectura y escritura. Es una clase primitiva que implementa las interfaces DataInput y DataOutput, así que sirve para leer y escribir datos.
El constructor pide una ruta válida o un File, y un segundo parámetro obligatorio llamado modo: una cadena que puede contener r (lectura), w (escritura) o rw (las dos). Como en las clases anteriores, hay que capturar la FileNotFoundException cuando se ejecuta el constructor.
File f = new File("archivo.out");
RandomAccessFile archivo = new RandomAccessFile(f, "rw");
| Método | Qué hace |
|---|---|
seek(long pos) | Se coloca en una posición concreta, contada en bytes. Lo que se mueve es el puntero de acceso, que marca dónde se va a leer o escribir. |
skipBytes(int n) | Posicionamiento relativo, saltando n bytes. |
long getFilePointer() | La posición actual del puntero. |
long length() | El tamaño del archivo. |
readBoolean(), readByte(), readChar(), readInt(), readDouble(), readFloat(), readUTF(), readLine() | Funciones de lectura: leen un dato del tipo indicado. readUTF lee una cadena en formato Unicode. |
writeBoolean(), writeByte(), writeChar(), writeInt(), writeDouble(), writeFloat(), writeUTF(), writeLine() | Funciones de escritura. Todas reciben como parámetro el dato a escribir y escriben encima de lo que ya estaba. Para escribir al final hay que mover el puntero hasta el final del archivo. |
El uso típico es un editor que se mueve a una posición y cambia un byte: se hace seek(posicion), se lee el valor, se vuelve a mover el puntero al mismo lugar con seek(getFilePointer()) y se escribe el valor nuevo. Es la razón por la que getFilePointer() existe: hay que guardar dónde se estaba, porque leer y escribir mueven el puntero.
Serialización
A la operación de enviar una serie de objetos a un archivo para hacerlos persistentes se la llama seriación, y a la de leerlos y reconstruirlos en memoria, deserialización. Es una forma automática de guardar y cargar el estado de un objeto.
Se basa en la interfaz Serializable: si un objeto la implementa, puede ser guardado y restaurado mediante una secuencia. Con este mecanismo se pueden almacenar objetos en archivos o incluso en bases de datos como BLOBs (Binary Large Object), o enviarlos a través de sockets en aplicaciones cliente-servidor.
Para que un objeto se pueda guardar así, hay que incluir implements Serializable en la cabecera de la clase, además de importar java.io.Serializable. Esa interfaz no tiene métodos: es solo un requisito, una marca que le dice a la máquina virtual que ese objeto se puede serializar.
De hacer el trabajo se encargan ObjectOutputStream y ObjectInputStream. Son herederas de OutputStream e InputStream, y son casi iguales a DataInput/DataOutputStream, solo que incorporan los métodos readObject y writeObject, que son muy poderosos: trabajan con el objeto completo, con todos sus atributos.
import java.io.Serializable;
public class Persona implements Serializable {
// Esta clase debe ser Serializable para poder
// ser escrita en un stream de objetos
private String nombre;
private int edad;
public Persona(String s, int i) {
this.nombre = s;
this.edad = i;
}
public String toString() {
return nombre + ":" + edad;
}
}
import java.io.*;
import java.util.Date;
public class PruebaSerializable {
public static void main(String args[]) {
try {
// Un OutputStream sobre el que escribir los bytes
FileOutputStream f = new FileOutputStream("prueba.dat");
// El objeto serializador
ObjectOutputStream ost = new ObjectOutputStream(f);
Persona persona1 = new Persona("Lucía", 35);
Date fecha = new Date();
ost.writeObject(persona1);
ost.writeObject(fecha);
ost.writeInt(48);
System.out.println("Se serializo el objeto en el archivo");
ost.flush(); // vaciar el buffer
ost.close();
} catch (IOException e) {
System.err.println(e);
}
try {
// Un InputStream subyacente del cual leer los bytes
FileInputStream f = new FileInputStream("prueba.dat");
// El objeto deserializador
ObjectInputStream ist = new ObjectInputStream(f);
Persona personaN = (Persona) ist.readObject();
Date fecha = (Date) ist.readObject();
int entero = ist.readInt();
System.out.println(personaN);
System.out.println(fecha);
System.out.println(entero);
System.out.println("Se deserializo el objeto");
ist.close();
} catch (IOException e) {
System.err.println(e);
} catch (ClassNotFoundException e) {
System.err.println(e);
}
}
}
Tres detalles que importan: el (Persona) y el (Date) de la lectura son conversiones obligatorias, porque readObject() devuelve un Object genérico y el programa tiene que decirle a qué tipo corresponde. Hay que leer en el mismo orden en que se escribió. Y la lectura agrega una excepción propia —ClassNotFoundException—, porque el archivo tiene el nombre de la clase y podría no existir en la máquina que lo lee.
Elegir un archivo: JFileChooser
Para que el usuario seleccione el archivo que quiere abrir de forma visual, se usa la clase JFileChooser, que tiene dos métodos fundamentales:
showOpenDialog()— muestra el cuadro de diálogo para que el usuario seleccione archivos o directorios.getSelectedFile()— devuelve el archivo o directorio que eligió.
import java.io.File;
import javax.swing.JFileChooser;
import javax.swing.JOptionPane;
public class PruebaArchivosGUI {
public static void main(String[] args) {
JFileChooser v = new JFileChooser();
v.setFileSelectionMode(JFileChooser.FILES_AND_DIRECTORIES);
int result = v.showOpenDialog(null);
if (result == JFileChooser.CANCEL_OPTION) {
System.exit(1);
}
File nom = v.getSelectedFile();
if (nom == null || nom.getName().equals("")) {
JOptionPane.showMessageDialog(null, "nombre de archivo inválido",
"Error", JOptionPane.ERROR_MESSAGE);
} else {
JOptionPane.showMessageDialog(null,
(nom.isDirectory() ? "el Directorio " : "el archivo")
+ " seleccionado es: " + nom.getName());
}
}
}
Fijate en el CANCEL_OPTION: el diálogo devuelve un valor que indica qué hizo el usuario, y hay que controlarlo antes de usar el resultado. Si se cancela y el programa sigue, getSelectedFile() devuelve null y cualquier intento de usar el nombre falla. Es el mismo tipo de control que se hace con los set de la lección 3: no dar por hecho que el dato que llega de afuera es válido.
Ejercicios
1. Verificadas o no verificadas
Decidir si cada excepción es verificada o no verificada, y explicar qué obliga eso al programador: ArithmeticException, IOException, ArrayIndexOutOfBoundsException, FileNotFoundException, NumberFormatException.
2. El orden de los catch
Corregir el orden de este código y explicar qué pasaría si se dejara como está:
try {
int[] v = new int[3];
v[5] = 10;
}
catch (Exception e) {
System.out.println("Error general");
}
catch (ArrayIndexOutOfBoundsException e) {
System.out.println("Índice fuera de rango");
}
3. finally
Escribir un método que abra un archivo para escritura y garantice que se cierra pase lo que pase. Después responder: ¿qué pasa con el archivo si se lanza una excepción en el medio de la escritura y no hay finally?
4. Elegir la colección
Para cada caso, decidir qué estructura conviene —ArrayList, LinkedList, HashSet, TreeSet, HashMap— y justificar:
- La lista de los 30 alumnos de un curso, que se recorre en orden de llegada.
- Las materias aprobadas de un alumno, sin repetidos y en orden alfabético.
- El padrón electoral: buscar rápidamente el nombre a partir del DNI.
- Una cola de trabajos que se van procesando en el orden en que llegan.
- Una lista en la que se insertan y eliminan elementos muy seguido en el medio.
5. Comparar dos formas de leer
Explicar la diferencia entre estos dos fragmentos y cuál conviene usar:
// A
FileReader fr = new FileReader("datos.txt");
int c = fr.read();
// B
FileReader fr = new FileReader("datos.txt");
BufferedReader br = new BufferedReader(fr);
String linea = br.readLine();
6. Serializar una lista
Escribir una clase Libro con título y autor, que se pueda guardar en un archivo, y un programa que guarde tres libros y después los recupere e imprima.
Respuestas sugeridas
1. ArithmeticException: no verificada — es subclase de RuntimeException. IOException: verificada. ArrayIndexOutOfBoundsException: no verificada. FileNotFoundException: verificada — es subclase de IOException. NumberFormatException: no verificada, aunque es fácil confundirse porque aparece en la lectura de datos escritos por el usuario. Lo que obliga la diferencia: las verificadas hay que atraparlas o declararlas, o el programa no compila; las no verificadas se pueden ignorar, y si se propagan el programa falla en ejecución. Son las que dependen de una decisión del programador, y por eso el compilador no las exige.
2. El orden correcto es el más específico primero:
try {
int[] v = new int[3];
v[5] = 10;
}
catch (ArrayIndexOutOfBoundsException e) {
System.out.println("Índice fuera de rango");
}
catch (Exception e) {
System.out.println("Error general");
}
Como está en el enunciado, compila, pero el mensaje que se ve es siempre «Error general»: ArrayIndexOutOfBoundsException es una subclase de Exception, así que la atrapa el primer catch y el segundo nunca se ejecuta. Se pierde justamente el mensaje útil.
3.
public void escribir(String ruta, String contenido) {
FileWriter fw = null;
try {
fw = new FileWriter(ruta);
fw.write(contenido);
}
catch (IOException e) {
System.out.println("Error al escribir: " + e.getMessage());
}
finally {
try {
if (fw != null) fw.close();
}
catch (IOException ex) {
ex.printStackTrace();
}
}
}
Sin el finally, si se lanza una excepción en el medio de la escritura el archivo queda abierto: el sistema operativo sigue considerando que el programa lo tiene tomado, y ninguna otra aplicación —ni el propio programa, si vuelve a intentarlo— puede usarlo hasta que termine. Además los datos que estaban en el buffer pueden no haberse escrito nunca. El finally resuelve las dos cosas.
4. (a) ArrayList: se recorre en orden y por índice, y las búsquedas son por posición. (b) TreeSet: sin repetidos y ordenado automáticamente. Para que funcione, Materia tiene que implementar Comparable. (c) HashMap: la clave es el DNI y el valor, el nombre; el acceso es directo. (d) LinkedList usada como cola, o directamente la interfaz Queue: los elementos salen en el orden en que entran. (e) LinkedList: insertar y eliminar en el medio es donde el arreglo dinámico se vuelve costoso, porque tiene que correr todos los elementos siguientes.
5. El fragmento A lee un solo byte y lo devuelve como int —el código del carácter—, así que para leer un texto habría que hacer un bucle y acumular. El fragmento B lee una línea completa como String. Conviene el B: no solo es más corto, sino que respeta la codificación de caracteres y devuelve un tipo de dato que se puede usar directamente. El A queda para cuando lo que se lee es un archivo binario.
6.
import java.io.Serializable;
public class Libro implements Serializable {
private String titulo;
private String autor;
public Libro(String titulo, String autor) {
this.titulo = titulo;
this.autor = autor;
}
public String toString() {
return titulo + " — " + autor;
}
}
import java.io.*;
public class PruebaLibros {
public static void main(String[] args) {
Libro[] libros = {
new Libro("El Eternauta", "H. G. Oesterheld"),
new Libro("Cien años de soledad", "G. García Márquez"),
new Libro("Rayuela", "Julio Cortázar")
};
// Guardar
try {
ObjectOutputStream salida = new ObjectOutputStream(
new FileOutputStream("libros.dat"));
for (Libro libro : libros) {
salida.writeObject(libro);
}
salida.close();
System.out.println("Se guardaron los " + libros.length + " libros");
} catch (IOException e) {
System.err.println("Error al guardar: " + e.getMessage());
}
// Recuperar
try {
ObjectInputStream entrada = new ObjectInputStream(
new FileInputStream("libros.dat"));
try {
while (true) {
Libro libro = (Libro) entrada.readObject();
System.out.println(libro);
}
} catch (EOFException fin) {
// Se terminaron los objetos: es la condición de corte
}
entrada.close();
} catch (IOException e) {
System.err.println("Error al leer: " + e.getMessage());
} catch (ClassNotFoundException e) {
System.err.println("No se encontró la clase Libro");
}
}
}
Otra opción, más directa, es serializar una colección en lugar de objetos sueltos: ArrayList ya implementa Serializable, así que alcanza con un writeObject(lista) y un solo readObject() al recuperarla. Es la forma habitual cuando la cantidad de elementos no es fija.
Resumen
- Los errores se detectan al compilar —sintaxis, y ahí el compilador ayuda— o al ejecutar —semántica, como la división por cero—. Las excepciones son objetos que permiten aislar la causa del error y tratarla sin que el programa se caiga.
- La jerarquía arranca en
Throwable, que se divide enError—no se intenta resolver— yException—sí se debe tratar—. Los métodos útiles songetMessage(),toString()yprintStackTrace(). - El bloque
trydelimita el código que puede fallar; loscatchatrapan los tipos de error, y se ordenan del más específico al más general. Elfinallyse ejecuta siempre y se usa para liberar recursos. throwsavisa en la declaración del método;throwlanza en el cuerpo. Las excepciones verificadas —todo lo que esExceptionmenosRuntimeException— hay que atraparlas o declararlas o el programa no compila.- Se pueden crear excepciones propias heredando de una existente, con un constructor sin argumentos y otro con mensaje.
- Un vector de objetos se crea con
new Clase[n], pero eso solo reserva las referencias: hay que crear cada objeto. Hasta entonces, las casillas valennull. - La clase
Arraystiene métodos estáticos para vectores:equals,fill,sort,binarySearch,toString,deepToString,copyOfRange. - El Collections Framework da estructuras que crecen y disminuyen, con los métodos
add,remove,size,contains,clear. Sus ramas sonList—ordenada, admite repetidos, con índice—,Set—sin repetidos—,Queue—por orden de llegada o prioridad— yMap—pares clave-valor, que no deriva deCollection—. - Se recorren con iteradores o con el for-each. El orden se define con
Comparable—el orden natural de la clase— o conComparator—criterios externos, y puede haber varios—. La claseCollectionsaportasort,reverse,shuffleybinarySearch, y la API de Stream permite filtrar y recorrer con lambdas. - Un flujo es el intermediario entre el programa y el origen o destino de los datos. Los de bytes son
InputStream/OutputStream; los de caracteres,Reader/Writer, y cada clase tiene su equivalente en la otra familia. - La entrada estándar es
System.iny la salida,System.out. Se leen líneas conBufferedReader.readLine(), envolviendo elSystem.inen unInputStreamReader. - La clase
Fileopera a nivel de sistema de archivos, no sobre el contenido: consulta si existe, crea carpetas, borra, lista. Los separadores de ruta se resuelven conFile.separatorChar. - Los archivos se leen y escriben byte a byte —
FileInputStream/FileOutputStream, o con tipos de dato conDataInputStream/DataOutputStream— o por caracteres —FileReader/FileWriter, y con líneas,BufferedReader/BufferedWriter—. El final del archivo se detecta conEOFExceptiono conreadLinedevolviendonull. RandomAccessFilepermite leer y escribir en cualquier posición, moviendo el puntero conseeky consultándolo congetFilePointer.- La serialización guarda objetos completos en un archivo: la clase implementa
Serializabley el trabajo lo hacenObjectOutputStreamyObjectInputStreamconwriteObjectyreadObject.
Con esto el programa ya resiste errores, trabaja con colecciones y guarda información. Lo que falta es lo que hace que el código se pueda mantener: medir si un diseño está bien y aplicar los patrones que ya resolvieron esos problemas. Eso es la lección 5.
Licencia. Obra derivada de los apuntes de clase del Prof. Matías E. García —Clase Teórica 3—, publicada en profmatiasgarcia.com.ar bajo CC BY-NC-SA 2.5 Argentina; el desarrollo del texto, los diagramas y los ejercicios son de Prof Mauro Bobyk.