Sesión 8. Entrada/salida

Los programas muy a menudo necesitan enviar datos a un determinado destino, o bien leerlos de una determinada fuente externa, como por ejemplo puede ser un fichero para almacenar datos de forma permanente, o bien enviar datos a través de la red, a memoria, o a otros programas. Esta entrada/salida de datos en Java la realizaremos por medio de flujos (streams) de datos, a través de los cuales un programa podrá recibir o enviar datos en serie. 

Flujos de datos de entrada/salida

Existen varios objetos que hacen de flujos de datos, y que se distinguen por la finalidad del flujo de datos y por el tipo de datos que viajen a través de ellos. Según el tipo de datos  que transporten podemos distinguir:

Dentro de cada uno de estos grupos tenemos varios pares de objetos, de los cuales uno nos servirá para leer del flujo y el otro para escribir en él. Cada par de objetos será utilizado para comunicarse con distintos elementos (memoria, ficheros, red u otros programas). Estas clases, según sean de entrada o salida y según sean de caracteres o de bytes llevarán distintos sufijos, según se muestra en la siguiente tabla:

  Flujo de entrada / lector Flujo de salida / escritor
Caracteres XXXXReader XXXXWriter
Bytes XXXXInputStream XXXXOutputStream

Donde XXXX se referirá a la fuente o sumidero de los datos. Puede tomar valores como los que se muestran a continuación:

File Acceso a ficheros
Piped Comunicación entre programas mediante tuberías (pipes)
String Acceso a una cadena en memoria (solo caracteres)
CharArray Acceso a un array de caracteres en memoria (solo caracteres)
ByteArray Acceso a un array de bytes en memoria (solo bytes)

Además podemos distinguir los flujos de datos según su propósito, pudiendo ser:

Un tipo de filtros de procesamiento a destacar son aquellos que nos permiten convertir un flujo de bytes a flujo de caracteres. Estos objetos son InputStreamReader y OutputStreamWriter. Como podemos ver en su sufijo, son flujos de caracteres, pero se construyen a partir de flujos de bytes, permitiendo de esta manera acceder a nuestro flujo de bytes como si fuese un flujo de caracteres.

Para cada uno de los tipos básicos de flujo que hemos visto existe una superclase, de la que heredaran todos sus subtipos, y que contienen una serie de métodos que serán comunes a todos ellos. Entre estos métodos encontramos los métodos básicos para leer o escribir caracteres o bytes en el flujo a bajo nivel. En la siguiente tabla se muestran los métodos más importantes de cada objeto:

InputStream read(), reset(), available(), close()
OutputStream write(int b), flush(), close()
Reader read(), reset(), close()
Writer write(int c), flush(), close()

Aparte de estos métodos podemos encontrar variantes de los métodos de lectura y escritura, otros métodos, y además cada tipo específico de flujo contendrá sus propios métodos. Todas estas clases se encuentran en el paquete java.io. Para más detalles sobre ellas se puede consultar la especificación de la API de Java.

Entrada, salida y salida de error estándar

Al igual que en C, en Java también existen los conceptos de entrada, salida, y salida de error estándar. La entrada estándar normalmente se refiere a lo que el usuario escribe en la consola, aunque el sistema operativo puede hacer que se tome de otra fuente. De la misma forma la salida y la salida de error estándar lo que hacen normalmente es mostrar los mensajes y los errores del programa respectivamente en la consola, aunque el sistema operativo también podrá redirigirlas a otro destino.

En Java esta entrada, salida y salida de error estándar se tratan de la misma forma que cualquier otro flujo de datos, estando estos tres elementos encapsulados en tres objetos de flujo de datos que se encuentran como propiedades estáticas de la clase System:

  Tipo Objeto
Entrada estándar InputStream System.in
Salida estándar PrintStream System.out
Salida de error estándar PrintStream System.err

Para la entrada estándar vemos que se utiliza un objeto InputStream básico, sin embargo para la salida se utilizan objetos PrintWriter que facilitan la impresión de texto ofreciendo a parte del método común de bajo nivel write(int b) para escribir bytes, dos métodos más: print(s) y println(s). Estas funciones nos permitirán escribir cualquier cadena, tipo básico, o bien cualquier objeto que defina el método toString() que devuelva una representación del objeto en forma de cadena. La única diferencia entre los dos métodos es que el segundo añade automáticamente un salto de línea al final del texto impreso, mientras que en el primero deberemos especificar explícitamente este salto.

Para escribir texto en la consola normalmente utilizaremos:

System.out.println("Hola mundo");

En el caso de la impresión de errores por la salida de error de estándar, deberemos utilizar: 

System.err.println("Error: Se ha producido un error");

Además la clase System nos permite sustituir estos flujos por defecto por otros flujos, cambiando de esta forma la entrada, salida y salida de error estándar.

Acceso a ficheros

Podremos acceder a ficheros bien por caracteres, o bien de forma binaria (por bytes). Las clases que utilizaremos en cada caso son:

  Lectura Escritura
Caracteres FileReader FileWriter
Binarios FileInputStream FileOutputStream

Para crear un lector o escritor de ficheros deberemos proporcionar al constructor el fichero del que queremos leer o en el que queramos escribir. Podremos proporcionar esta información bien como una cadena de texto con el nombre del fichero, o bien construyendo un objeto File representando al fichero al que queremos acceder. Este objeto nos permitirá obtener información adicional sobre el fichero, a parte de permitirnos realizar operaciones sobre el sistema de ficheros.

A continuación vemos un ejemplo simple de la copia de un fichero carácter a carácter:

public void copia_fichero() 
{ int c; try
{ FileReader in = new FileReader("fuente.txt"); FileWriter out = new FileWriter("destino.txt"); while( (c = in.read()) != -1) { out.write(c); } in.close(); out.close(); } catch(FileNotFoundException e1) { System.err.println("Error: No se encuentra el fichero"); } catch(IOException e2) {        System.err.println("Error leyendo/escribiendo fichero"); } }

En el ejemplo podemos ver que para el acceso a un fichero es necesario capturar dos excepciones, para el caso de que no exista el fichero al que queramos acceder y por si se produce un error en la E/S.

Para la escritura podemos utilizar el método anterior, aunque muchas veces nos resultará mucho más cómodo utilizar un objeto PrintWriter con el que podamos escribir directamente líneas de texto:

public void escribe_fichero() 
{ FileWriter out = null; PrintWriter p_out = null; try
{ out = new FileWriter("result.txt"); p_out = new PrintWriter(out); p_out.println("Este texto será escrito en el fichero de salida"); } catch(IOException e) { System.err.println("Error al escribir en el fichero"); } finally { p_out.close(); } }

Observad también el uso del bloque finally, para cerrar el fichero tanto si se produce un error al escribir en él como si no.


Ejercicio 1: Lectura y escritura básicas

En este primer ejercicio practicaremos la lectura y escritura básica con ficheros, utilizando las dos posibles alternativas: Streams y Readers/Writers.

Echa un vistazo a la clase sesion08.Ej6 que se proporciona en la plantilla de la sesión. Verás que hay un constructor vacío, y un campo llamado cabecera, que contiene una cadena de texto. También hay dos métodos vacíos, leeEscribeStream y leeEscribeWriter, y un método main que crea un objeto de tipo Ej6 y llama a estos dos métodos. Lo que vamos a hacer es rellenar esos dos métodos de la forma que se nos indica a continuación.

El primero de los métodos leeEscribeStream va a leer un fichero de entrada (el fichero entrada.dat que se os proporciona en la plantilla), y lo va a volcar a un fichero de salida (fichero salidaStream.dat), pero añadiéndole la cadena cabecera como encabezado del fichero. Para hacer todo eso empleará flujos de tipo stream (InputStream para leer, OutputStream para escribir, o cualquier subclase derivada de éstas).

FileInputStream in = new FileInputStream("entrada.dat");

NOTA IMPORTANTE: vigilad dónde ponéis el fichero entrada.dat, porque puede que no lo encuentre. Una buena idea para que esta línea de código os funcione es ponerlo en la carpeta raíz del proyecto, y no dentro del paquete sesion08. De todas formas, podéis ponerlo en cualquier parte, siempre que después sepáis cómo encontrarlo desde Java.

Aquí tendremos que tener las mismas consideraciones que con el fichero de entrada, en cuanto a cómo localizarlo. En este caso no es tan importante, porque el fichero lo creará de todas formas, en un lugar u otro, pero debemos saber dónde lo va a crear. Con una línea como ésta, lo creará en la carpeta raíz del proyecto también.

# Esto es la cabecera del fichero que hay que introducir
Hola, este es el texto
del fichero de entrada
que debería copiarse en el fichero de salida

El segundo método, leeEscribeWriter, leerá el mismo fichero de entrada (entrada.dat), y lo volcará a otro fichero de salida diferente (salidaWriter.dat), empleando flujos de tipo Reader y Writer (como FileReader o FileWriter, o cualquier otro subtipo).

NOTA: observa la API de la clase PrintWriter, y verás que tiene constructores que permiten crear este tipo de objetos a partir de Writers (como hemos hecho aquí) como a partir de OuputStreams (como habríamos hecho en el paso 2), con lo que podemos utilizar esta clase para dar formato a la salida de un fichero en cualquiera de los casos.


Un caso particular: ficheros de propiedades

La clase java.util.Properties permite manejar de forma muy sencilla lo que se conoce como ficheros de propiedades. Dichos ficheros permiten almacenar una serie de pares nombre=valor, de forma que tendría una apariencia como esta:

#Comentarios
elemento1=valor1
elemento2=valor2
...
elementoN=valorN

Para leer un fichero de este tipo, basta con crear un objeto Properties, y llamar a su método load(), pasándole como parámetro el fichero que queremos leer, en forma de flujo de entrada (InputStream):

Properties p = new Properties();
p.load(new FileInputStream("datos.txt");

Una vez leído, podemos acceder a todos los elementos del fichero desde el objeto Properties cargado. Tenemos los métodos getProperty y setProperty para acceder a y modificar valores:

String valorElem1 = p.getProperty("elemento1");
p.setProperty("elemento2", "otrovalor");

También podemos obtener todos los nombres de elementos que hay, y recorrerlos, mediante el método propertyNames(), que nos devuelve una Enumeration para ir recorriendo:

Enumeration en = p.propertyNames();
while (en.hasMoreElements())
{
   String nombre = (String)(en.nextElement());
   String valor = p.getProperty(nombre);
}

Una vez hayamos leído o modificado lo que quisiéramos, podemos volver a guardar el fichero de propiedades, con el método store de Properties, al que se le pasa un flujo de salida (OutputStream) y una cabecera para el fichero:

p.store(new FileOutputStream("datos.txt"), "Fichero de propiedades");

Propiedades del sistema

Como se ha visto en la sesión de utilidades, la clase System tiene métodos como getProperty, setProperty a los que se les pasa un nombre de propiedad del sistema, y permiten obtener o cambiar su valor, respectivamente. Además, tenemos un método

Properties getProperties();

que devuelve un objeto Properties con todas las propiedades del sistema. Podremos recorrerlas, ver cuáles son, y aprovechar sus valores en nuestros programas.


Ejercicio 2: Trabajar con propiedades

En este segundo ejercicio practicaremos el uso de ficheros de propiedades, y el uso de la entrada y salida estándares. Echa un vistazo a la clase sesion08.Ej7 que se proporciona en la plantilla de la sesión. Sólo tiene un constructor vacío, y un método main que le llama. Vamos a completar el constructor de la forma que veremos a continuación.

Lo que vamos a hacer en el constructor es leer un fichero de propiedades (el fichero prop.txt que se proporciona en la plantilla), y luego pedirle al usuario que, por teclado, indique qué valores quiere que tengan las propiedades. Una vez establecidos los valores, volveremos a guardar el fichero de propiedades.

Lo primero que vamos a hacer es leer el fichero de propiedades. Para ello utilizaremos un objeto java.util.Properties, lo crearemos y llamaremos a su método load() para cargar las propiedades del fichero prop.txt:

Properties p = new Properties();
p.load(new FileInputStream("prop.txt"));

Observa que para cargar las propiedades, al método load le debemos pasar un InputStream desde el que leerlas. En este caso le pasamos un FileInputStream con el fichero prop.txt.

Ahora ya tenemos en el objeto p todas las propiedades del fichero. Vamos a irlas recorriendo una a una, e indicando al usuario que teclee su valor. Para recorrer las propiedades obtendremos un Enumeration con sus nombres, y luego lo iremos recorriendo, y sacándolo por pantalla:

Enumeration en = p.propertyNames();
while (en.hasMoreElements())
{
   String prop = (String)(en.nextElement());
   System.out.println("Introduzca valor para propiedad " + prop);
}

Observa el orden en que van mostrándose las propiedades. ¿Es el mismo que el que hay en el fichero? ¿A qué crees que puede deberse? (AYUDA: cuando nosotros enumeramos una serie de características, no tenemos que seguir un orden necesariamente. Del mismo modo, cuando introducimos valores en una tabla hash, el orden en que se guardan no es el mismo que el orden en que los introducimos).

Lo que hacemos con este bucle es sólo recorrer los nombres de las propiedades y sacarlos por pantalla. Nos falta hacer que el usuario teclee los valores correspondientes. Para ello utilizaremos un objeto de tipo BufferedReader, que en este caso leerá líneas de texto que el usuario entre desde teclado:

...
Enumeration en = p.propertyNames();
BufferedReader in =  new BufferedReader(new InputStreamReader(System.in));		
...	

observad que construimos el BufferedReader para leer de un InputStream (no de un Reader). Esto lo podemos hacer si nos ayudamos de la "clase puente" InputStreamReader, que transforma un tipo de lector en otro.

Lo que nos queda por hacer es pedirle al usuario que, para cada nombre de propiedad, introduzca su valor, y luego asignarlo a la propiedad correspondiente:

...	
while (en.hasMoreElements())
{
   String prop = (String)(en.nextElement());
   System.out.println("Introduzca valor para propiedad " + prop);
   String valor = in.readLine();
   p.setProperty(prop, valor);
}

Finalmente, cerramos el buffer de entrada, y guardamos las propiedades en el fichero.

in.close();
p.store(new FileOutputStream("prop.txt"), "Cabecera del fichero");

Compilad y ejecutad el programa. Para que os compile deberéis capturar las excepciones que se os indique en los errores de compilación.

Añadid el código necesario al ejercicio para que, además de poder modificar los valores de las propiedades, podamos añadir por teclado nuevas propiedades al fichero, y guardarlas con las existentes.


Lectura de tokens

Hemos visto como leer un fichero carácter a carácter, pero en el caso de ficheros con una gramática medianamente compleja, esta lectura a bajo nivel hará muy difícil el análisis de este fichero de entrada. Necesitaremos leer del fichero elementos de la gramática utilizada, los llamados tokens, como pueden ser palabras, número y otros símbolos.

La clase StreamTokenizer se encarga de partir la entrada en tokens y nos permitirá realizar la lectura del fichero directamente como una secuencia de tokens. Esta clase tiene una serie de constantes identificando los tipos de tokens que puede leer:

StreamTokenizer.TT_WORD Palabra
StreamTokenizer.TT_NUMBER Número real o entero
StreamTokenizer.TT_EOL Fin de línea
StreamTokenizer.TT_EOF Fin de fichero
Carácter de comillas establecido Cadena de texto encerrada entre comillas
Símbolos Vendrán representados por el código del carácter ASCII del símbolo

Dado que un StreamTokenizer se utiliza para analizar un fichero de texto, siempre habrá que crearlo a partir de un objeto Reader (o derivados).

StreamTokenizer st = new StreamTokenizer(reader);

El método nextToken() leerá el siguiente token que encuentre en el fichero y nos devolverá el tipo de token del que se trata. Según este tipo podremos consultar las propiedades sval o nval para ver qué cadena o número respectivamente se ha leído del fichero. Tanto cuando se lea un token de tipo TT_WORD como de tipo cadena de texto entre comillas el valor de este token estará almacenado en sval. En caso de la lectura sea un número, su valor se almacenará en nval que es de tipo double. Como los demás símbolos ya devuelven el código del símbolo como tipo de token no será necesario acceder a su valor por separado. Podremos consultar el tipo del último token leído en la propiedad ttype.

Un bucle de procesamiento básico será el siguiente:

while(st.nextToken() != StreamTokenizer.TT_EOF) 
{ switch(st.ttype)
{ case StreamTokenizer.TT_WORD: System.out.println("Leida cadena: " + st.sval); break; case StreamTokenizer.TT_NUMBER: System.out.println("Leido numero: " + st.nval); break; } } 

Podemos distinguir tres tipos de caracteres:

Ordinarios (ordinaryChars) Caracteres que forman parte de los tokens.
De palabra (wordChars) Una secuencia formada enteramente por este tipo de caracteres se considerará una palabra.
De espacio en blanco (whitespaceChars) Estos caracteres no son interpretados como tokens, simplemente se utilizan para separar tokens. Normalmente estos caracteres son el espacio, tabulador, y salto de línea.

Para establecer qué caracteres pertenecerán a cada uno de estos tipos utilizaremos los métodos ordinaryChars, wordChars y whitespaceChars del objeto StreamTokenizer respectivamente. A cada uno de estos métodos le pasamos un rango de caracteres (según su código ASCII), que serán establecidos al tipo correspondiente al método que hayamos llamado. Por ejemplo, si queremos que una palabra sea una secuencia de cualquier carácter imprimible (con códigos ASCII desde 32 a 127) haremos lo siguiente:

st.wordChars(32,127);

Los caracteres pueden ser especificados tanto por su código ASCII numérico como especificando ese carácter entre comillas simples. Si ahora queremos hacer que las palabras sean separadas por el caracter ':' (dos puntos) hacemos la siguiente llamada:

st.whitespaceChars(':', ':');

De esta forma, si hemos hecho las llamadas anteriores el tokenizer leerá palabras formadas por cualquier carácter imprimible separadas por los dos puntos ':'. Al querer cambiar un único carácter, como siempre deberemos especificar un rango, deberemos especificar un rango formado por ese único carácter como inicial y final del rango. Si además quisieramos utilizar el guión '-' para separar palabras, no siendo caracteres consecutivos guión y dos puntos en la tabla ASCII, tendremos que hacer una tercera llamada:

st.whitespaceChars('-', '-');

Así tendremos tanto el guión como los dos puntos como separadores, y el resto de caracteres imprimibles serán caracteres de palabra. Podemos ver que el StreamTokenizer internamente implementa una tabla, en la que asocia a cada carácter uno de los tres tipos mencionados. Al llamar a cada uno de los tres métodos cambiará el tipo de todo el rango especificado al tipo correspondiente al método. Por ello es importante el orden en el que invoquemos este método. Si en el ejemplo en el que hemos hecho estas tres llamadas las hubiésemos hecho en orden inverso, al establecer todo el rango de caracteres imprimibles como wordChars hubiésemos sobrescrito el resultado de las otras dos llamadas y por lo tanto el guión y los dos puntos no se considerarían separadores.

Podremos personalizar el tokenizer indicando para cada carácter a que tipo pertenece. Además de con los tipos anteriores, podemos especificar el carácter que se utilice para encerrar las cadenas de texto (quoteChar), mediante el método quoteChar, y el carácter para los comentarios (commentChar), mediante commentChar. Esto nos permitirá definir comentarios de una línea que comiencen por un determinado carácter, como por ejemplo los comentarios estilo Pascal comenzados por el carácter almohadilla ('#'). Además tendremos otros métodos para activar comentarios tipo C como los comentarios barra-barra (//) y barra-estrella (/* */).


Ejercicio 3: Leyendo matrices

Vamos a practicar la lectura de tokens de un fichero, y su almacenamiento para realizar alguna operación. Echa un vistazo a la clase sesion08.Ej8 que se proporciona en la plantilla de la sesión. Verás que hay un constructor vacío, y un método main que le llama. Rellenaremos el constructor como se indica en los siguientes pasos.

Lo que vamos a hacer es que el constructor acceda a un fichero (fichero matriz.txt de la plantilla) que tiene una matriz m x n. Dicho fichero tiene la siguiente estructura:

; Comentario de cabecera
m n
A11 A12 A13...
A21 A22 A23...
...
        

donde m son las filas, n las columnas, y después aparece la matriz puesta por filas, con un espacio en blanco entre cada elemento.

El ejercicio leerá la matriz (utilizando un StreamTokenizer sobre el fichero), construirá una matriz (array) con los datos leídos, después elevará al cuadrado cada componente, y volcará el resultado en un fichero de salida.

Primero obtendremos el flujo de entrada para leer del fichero, y el StreamTokenizer:

StreamTokenizer st =  new StreamTokenizer(new FileReader("matriz.txt"));

Después establecemos qué caracteres van a identificar las líneas de comentarios. En este caso, los comentarios se identifican por punto y coma: 

st.commentChar(';');

Después del comentario irán el número de filas y de columnas. Utilizamos el método nextToken del tokenizer para leerlos, y luego accedemos al campo nval para obtener qué valor numérico se ha leído en cada caso:

int filas, columnas;
	
st.nextToken();
filas = (int)(st.nval);                  // Filas

st.nextToken();
columnas = (int)(st.nval);         // Columnas

NOTA: asumimos que el fichero va a tener un formato correcto, y no tenemos que controlar que haya elementos no deseados por enmedio.

¿Qué se habría leído en primer lugar si no hubiésemos identificado la primera línea como comentario? ¿Dónde podríamos haber consultado ese valor leído?

Lo siguiente es ir leyendo los elementos de la matriz. Construimos un array de enteros de filas x columnas, y luego lo vamos rellenando con los valores que nos dé el StreamTokenizer:

int[][] matriz = new int[filas][columnas];
int t;
			
for (int i = 0; i < filas; i++)
   for (int j = 0; j < columnas; j++)
   {
      t = st.nextToken();
      if (t != StreamTokenizer.TT_EOF)
      {
         matriz[i][j] = (int)(st.nval);
      }
   }				

Por último, calculamos el cuadrado de cada elemento de la matriz (utilizamos el método pow de la clase Math), y guardamos la matriz resultado en otro fichero de salida (matrizSal.txt), con el mismo formato que el de entrada. Utiliza un objeto PrintWriter para facilitar el volcado de la matriz al fichero.

Compila y ejecuta el programa (captura las excepciones adecuadas para que te compile bien). Comprueba que el fichero de salida genera el resultado adecuado: 

; Matriz resultado
3 3
1 4 9 
16 25 36 
49 64 81 

Prueba también a pasarle este mismo fichero como entrada al programa, y que genere otro fichero de salida diferente.


Acceso a ficheros o recursos dentro de un JAR

Los ficheros JAR son una forma de empaquetar clases Java y otros recursos en un solo fichero, con un formato similar a los ficheros TAR que se suelen utilizar en Linux. De hecho los comandos jar (que viene con Java y se utiliza para crear ficheros JAR) y tar (de Linux) tienen los mismos parámetros.

De esta forma podremos hacer un programa más portable, al usar un sólo fichero. También hay formas de hacer que el JAR auto-ejecute las clases que lleva dentro, con lo que aumenta la facilidad de uso.

Hemos visto como leer y escribir ficheros, pero cuando ejecutamos una aplicación contenida en un fichero JAR, puede que necesitemos leer recursos contenidos dentro de este JAR.

Para acceder a estos recursos deberemos abrir un flujo de entrada que se encargue de leer su contenido. Para ello utilizaremos el método getResourceAsStream de la clase Class:

InputStream in = getClass().getResourceAsStream("/datos.txt");

De esta forma podremos utilizar el flujo de entrada obtenido para leer el contenido del fichero que hayamos indicado. Este fichero deberá estar contenido en el JAR de la aplicación.

Especificamos el carácter '/' delante del nombre del recurso para referenciarlo de forma relativa al directorio raíz del JAR. Si no lo especificásemos de esta forma se buscaría de forma relativa al directorio correspondiente al paquete de la clase actual.

Codificación de datos

Si queremos guardar datos en un fichero binario deberemos codificar estos datos en forma de array de bytes. Los flujos de procesamiento DataInputStream y DataOutputStream nos permitirán codificar y descodificar respectivamente los tipos de datos simples en forma de array de bytes para ser enviados a través de un flujo de datos.

Por ejemplo, podemos codificar datos en un array en memoria (ByteArrayOutputStream) de la siguiente forma:

String nombre = "Jose";
String edad = 25;

ByteArrayOutputStream baos = new ByteArrayOutputStream();
DataOutputStream dos = new DataOutputStream(baos);

dos.writeUTF(nombre);
dos.writeInt(edad);

dos.close();
baos.close();

byte [] datos = baos.toByteArray();

Podremos descodificar este array de bytes realizando el procedimiento inverso, con un flujo que lea un array de bytes de memoria (ByteArrayInputStream):

ByteArrayInputStream bais = new ByteArrayInputStream(datos);
DataInputStream dis = new DataInputStream(bais);

String nombre = dis.readUTF();
int edad = dis.readInt();

Si en lugar de almacenar estos datos codificados en una array en memoria queremos guardarlos codificados en un fichero, haremos lo mismo simplemente sustituyendo el flujo canal de datos ByteArrayOutputStream por un FileOutputStream. De esta forma podremos utilizar cualquier canal de datos para enviar estos datos codificados a través de él.

Serialización de objetos

Si queremos enviar un objeto complejo a través de un flujo de datos, deberemos convertirlo en una serie de bytes. Esto es lo que se conoce como serialización de objetos, que nos permitirá leer y escribir objetos.

Para leer o escribir objetos podemos utilizar los objetos ObjectInputStream y ObjectOutputStream que incorporan los métodos readObject() y writeObject(Object obj) respectivamente. Los objetos que escribamos en dicho flujo deben tener la capacidad de ser serializables.

Serán serializables aquellos objetos que implementan la interfaz Serializable. Cuando queramos hacer que una clase definida por nosotros sea serializable deberemos implementar dicho interfaz, que no define ninguna función, sólo se utiliza para identificar las clases que son serializables. Para que nuestra clase pueda ser serializable, todas sus propiedades deberán ser de tipos de datos básicos o bien objetos que también sean serializables.


Ejercicio 4: Guardar datos personales

En este ejercicio practicaremos cómo utilizar los ficheros para almacenar y leer objetos complejos. Hasta ahora sólo hemos trabajado con enteros o cadenas, y para leerlos basta con leer un stream de bytes, o utilizar un tokenizer y procesar el fichero de la forma que nos convenga.

Imaginemos que trabajamos con un objeto complejo que encapsula diferentes tipos de datos (enteros, cadenas, vectores, etc). A la hora de guardar este elemento en fichero, se nos plantea el problema de cómo representar su información para volcarla. De la misma forma, a la hora de leerlo, también debemos saber cómo extraer y recomponer la información del objeto. Veremos que hay clases Java que hacen todo este trabajo mucho más sencillo.

Vamos a retomar la clase datos.Persona que hicimos en la sesión 7. Por otro lado, tenemos en la plantilla de esta sesión la clase io.LeeGuardaPersona. Tiene dos métodos leePersonas y guardaPersonas que deberemos implementar:

ArrayList al = new ArrayList();
try {
ObjectInputStream ois = new ObjectInputStream(new FileInputStream(fichero));
while (true)
{ Persona p = (Persona)(ois.readObject());
al.add(p);
} } catch (Exception e) {
}
return al;

Te parecerá raro utilizar un bucle infinito para leer el fichero. El motivo es sencillo. Cuando trabajamos con ObjectInputStreams no hay forma de saber cuándo acaba el fichero, porque leemos objetos complejos enteros, hasta que ya no hay más. En ese momento, se provoca una excepción, y el bucle terminará cuando dicha excepción salte, lo que indicará el fin de fichero. Después de eso, devolvemos la lista con los objetos que haya guardados y listo.

¿Qué pasaría si Persona no implementase la interfaz Serializable? ¿Qué excepción saltaría al ejecutar?


PARA ENTREGAR

Guarda en la carpeta modulo3 de tu CVS los siguientes elementos para esta sesión: