6.2 Ficheros
En ocasiones necesitamos que los datos que introduce el usuario o que produce un programa persistan cuando éste finaliza; es decir, que se conserven cuando el programa termina su ejecución. Para ello es necesario el uso de una base de datos o de ficheros, que permitan guardar los datos en un almacenamiento secundario como un pendrive, disco duro, DVD, etc.
Abordaremos distintos aspectos relacionados con el almacenamiento en ficheros:
- Introducción a conceptos básicos como los de registro y campo.
- Clasificación de los ficheros según el contenido y forma de acceso.
- Operaciones básicas con ficheros de distinto tipo.
1. Registros y campos¶
Llamamos campo a un dato en particular almacenado en una base de datos o fichero. Un campo puede ser el nombre de un cliente, la fecha de nacimiento de un alumno, el número de teléfono de un comercio. Los campos pueden ser de distintos tipos: alfanuméricos, numéricos, fechas, etc.
La agrupación de uno o más campos forman un registro. Un registro de alumno podría consistir, por ejemplo, de los siguientes campos:
Un fichero puede estar formado por registros, lo cual dotaría al archivo de estructura. En un fichero de alumnos tendríamos un registro por cada alumno. Los campos del registro serían cada uno de los datos que se almacena del alumno: nº expediente, nombre, etc …
En Java no existen específicamente los conceptos de campo y registro. Lo más similar que conocemos son las clases (similares a un registro) y, dentro de las clases, los atributos (similares a campos).
Tampoco en Java los ficheros están formados por registros. Java considera los archivos simplemente como flujos secuenciales de bytes. Cuando se abre un fichero se asocia a él un flujo (stream) a través del cual se lee o escribe en el fichero.
Ejemplo de fichero:
Ejemplo de registro en el fichero anterior:
Ejemplo de campo en el registro anterior:
2. Ficheros de texto VS ficheros binarios¶
Desde un punto de vista a muy bajo nivel, un fichero es un conjunto de bits almacenados en memoria secundaria, accesibles a través de una ruta y un nombre de archivo.
Este punto de vista a bajo nivel es demasiado simple, pues cuando se recupera y trata la información que contiene el fichero, esos bits se agrupan en unidades mayores que las dotan de significado. Así, dependiendo de cuál es el contenido del fichero (de cómo se interpretan los bits que contiene el fichero), podemos distinguir dos tipos de ficheros:
- Ficheros de texto (o de caracteres).
- Ficheros binarios (o de bytes).
Un fichero de texto está formado únicamente por caracteres. Los bits que contiene se interpretan atendiendo a una tabla de caracteres, ya sea ASCII o Unicode. Este tipo de ficheros se pueden abrir con un editor de texto plano y son, en general, legibles. Por ejemplo, los ficheros .java que contienen los programas que elaboramos, son ficheros de texto.
Por otro lado, los ficheros binarios contienen secuencias de bytes que se agrupan para representar otro tipo de información: números, sonidos, imágenes, etc. Un fichero binario se puede abrir también con un editor de texto plano pero, en este caso, el contenido será ininteligible. Existen muchos ejemplos de ficheros binarios: el archivo .exe que contiene la versión ejecutable de un programa es un fichero binario.
Las operaciones de lectura/escritura que utilizamos al acceder desde un programa a un fichero de texto están orientadas al carácter: leer o escribir un carácter, una secuencia de caracteres, una línea de texto, etc. En cambio las operaciones de lectura/escritura en ficheros binarios están orientadas a byte: se leen o escriben datos binarios, como enteros, bytes, double, etc.
3. Acceso secuencial VS acceso directo¶
Existen dos maneras de acceder a la información que contiene un fichero:
- Acceso secuencial.
- Acceso directo (o aleatorio).
Con acceso secuencial, para poder leer el byte que se encuentra en determinada posición del archivo es necesario leer, previamente, todos los bytes anteriores. Al escribir, los datos se sitúan en el archivo uno a continuación del otro, en el mismo orden en que se introducen. Es decir, la nueva información se coloca en el archivo a continuación de la que ya existe. No es posible realizar modificaciones de los datos existentes, tan solo añadir al final.
Sin embargo, con el acceso directo, es posible acceder a determinada posición (dirección) del fichero de manera directa y, posteriormente, hacer la operación de lectura o escritura deseada.
No siempre es necesario realizar un acceso directo a un archivo. En muchas ocasiones el procesamiento que realizamos de sus datos consiste en la escritura o lectura de todo el archivo siguiendo el orden en que se encuentran. Para ello basta con un acceso secuencial.
4. Streams para trabajar con ficheros¶
Para trabajar con ficheros disponemos de las siguientes clases:
| Streams para ficheros | Ficheros binarios | Ficheros de texto |
|---|---|---|
| para lectura | FileInputStream |
FileReader |
| para escritura | FileOutputStream |
FileWriter |
FileInputStreampermite leer bytes de un fichero.FileOutputStreampermite escribir bytes de un fichero.FileReaderpermite leer de un fichero uno o varios caracteres.FileWriterpermite escribir en un fichero uno o varios caracteres o un String.
Consulta
Consulta en la documentación los distintos constructores disponibles para estas clases.
Ejemplo: crear un fichero
En el siguiente ejemplo vemos cómo crear un fichero de texto y escribir una frase en él.
FileWriter puede provocar IOException, lo mismo que el método write. Por ello las instrucciones se encuentran en un bloque try-catch.Al finalizar su uso, y tan pronto como sea posible, hay que cerrar los streams (
close) .
Ejemplo: sobreescribir un fichero
Es muy importante tener en cuenta que cuando se crea un FileWriter o un FileOutputStream y se escribe en él:
- si el fichero no existe se crea.
- si el fichero existe, su contenido se reemplaza por el nuevo. El contenido previo que tuviera el fichero se pierde.
Vamos a ver una serie de ejemplos que muestren cómo leer y escribir secuencialmente sobre/en un fichero y también escribir en un fichero indicando que la información se añada a la que ya hay y no se reescriba el fichero. Para esto último usaremos el constructor de FileWriter que recibe dos parámetros; el primer parámetro es el nombre del fichero y el segundo parámetro, append, lo pasaremos con el valor true.
El siguiente ejemplo muestra como añadir una línea al final de un fichero de texto.
try with resource. Esta sintaxis permite crear un objeto en la cabecera del bloque try. El objeto creado se cerrará automáticamente al finalizar. El objeto debe pertenecer al interface Closeable, es decir, debe tener método close().
4.1. Lectura y escritura de información estructurada¶
Si observamos la documentación de las clases FileInputStream y FileOutputStream veremos que las operaciones de lectura y escritura son muy básicas y permiten únicamente leer o escribir uno o varios bytes. Es decir, son operaciones de muy bajo nivel. Si lo que queremos es escribir información binaria más compleja, como por ejemplo un dato de tipo double, boolean o int, tendríamos que hacerlo a través de un stream que permitiese ese tipo de operaciones y asociarlo al FileInputStream o FileOutputStream.
Podríamos, por ejemplo, asociar un DataInputStream a un FileInputStream para leer del fichero un dato de tipo int.
En ejemplos posteriores se ilustrará cómo asociar un stream a un FileXXXStream.
Ejemplo: lectura de un fichero secuencial de texto
Leer un fichero de texto y mostrar el número de vocales que contiene.
-
Para leer el fichero de texto usamos un
InputReader. -
Al crear el stream (
InputReader) es posible indicar un objeto de tipoFile. -
La operación
read()devuelve un entero. Para obtener el carácter correspondiente tenemos que hacer una conversión explícita de tipos. -
La operación
read()devuelve -1 cuando no queda información que leer del stream. -
La guarda del bucle
whilecombina una asignación con una comparación. En primer lugar se realiza la asignación y luego se compara carácter con -1. -
FileNotFoundExceptionsucede cuando el fichero no se puede abrir (no existe, permiso denegado, etc), mientras queIOExceptionse lanzará si falla la operaciónread().
Ejemplo: escritura de un fichero secuencial de texto
Dada una cadena escribirla en un fichero en orden inverso:
-
Para escribir el fichero de texto usamos un
FileWriter. -
Tal y como se ha creado el stream, el fichero (si ya existe) se sobreescribirá.
-
El manejo de excepciones es como el del caso previo.
Ejemplo: escritura de un fichero secuencial binario
Ya hemos visto que con FileInputStream y FileOutputStream se puede leer y escribir bytes de información de/a un archivo.
Sin embargo esto puede no ser suficiente cuando la información que tenemos que leer o escribir es más compleja y los bytes se agrupan para representar distintos tipos de datos.
Imaginemos por ejemplo que queremos guardar en un fichero “jugadores.dat”, el año de nacimiento y la estatura de cinco jugadores de baloncesto:
-
Para escribir información binaria usamos un
DataInputStreamasociado al stream. La clase tiene métodos para escribirint,byte,double,boolean, etc. -
Además, como hemos hecho en ejemplos previos, usamos un buffer. Fíjate como en el constructor se enlazan unas clases con otras.
-
A pesar de que en Java los ficheros son secuencias de bytes, estamos dotando al fichero de cierta estructura: primero aparece el nombre, luego el año y finalmente la estatura. Cada uno de estos tres datos constituirían un registro de formado por tres campos. Para poder recuperar información de un fichero binario es necesario conocer cómo se estructura ésta dentro del fichero.
Ejemplo: lectura de un fichero secuencial binario
-
A pesar de que necesitamos solamente el nombre de cada jugador, es necesario leer también el año y la estatura. No es posible acceder al nombre del segundo jugador sin leer previamente todos los datos del primer jugador.
-
La lectura se hace a través de un bucle infinito (while (true)), que finalizará cuando se llegue el final del fichero y al leer de nuevo se produzca la excepción EOFException.
5. Ficheros con buffering¶
Cualquier operación que implique acceder a memoria externa es muy costosa, por lo que es interesante intentar reducir al máximo las operaciones de lectura/escritura que realizamos sobre los ficheros, haciendo que cada operación lea o escriba muchos caracteres. Además, eso también permite operaciones de más alto nivel, como la de leer una línea completa y devolverla en forma de cadena.
En el libro Head First Java, describe los buffers de la siguiente forma: "Si no hubiera buffers, sería como comprar sin un carrito: debería llevar los productos uno a uno hasta la caja. Los buffers te dan un lugar en el que dejar temporalmente las cosas hasta que está lleno. Por ello has de hacer menos viajes cuando usas el carrito."
Las clases BufferedReader, BufferedWritter, BufferedInputStream y BufferedOutputStream permiten realizar buffering. Situadas "por delante" de un stream de fichero acumulan las operaciones de lectura y escritura y cuando hay suficiente información se llevan finalmente al fichero.
Recuerda
Recuerda la importancia de cerrar los flujos para asegurarte que se vacía el buffer.
Ejemplo: usando buffers para leer y escribir de/en fichero
En el siguiente codigo se usan buffers para leer líneas de un fichero y escribirlas en otro convertidas a mayúsculas.
-
Usamos buffers tanto para leer como para escribir. Esto permite minimizar los accesos a disco.
-
Los buffers quedan asociados a un
FileReaderyFileWriterrespectivamente. Realizamos las operaciones de lectura/escritura sobre las clasesBuffered…y cuando es necesario la clase accede internamente al stream que maneja el fichero. -
Es necesario escribir explícitamente los saltos de línea. Esto se hace mediante el método
newLine(). Este método permite añadir un salto de línea sin preocuparnos de cuál es el carácter de salto de línea. El salto de línea es distinto en distintos sistemas: en unos es\n, en otros\r, en otros\n\r, … -
BufferedReaderdispone de un método para leer líneas completas (readLine()). Cuando se llega al final del fichero este método devuelvenull. -
Fíjate como en el bloque
try with resourcescreamos varios objetos. Si la creación de cualquiera de ellos falla, se cerrarán todos los stream que se han abierto.
6. try VS try with resources¶
En ocasiones el propio IDE nos sugiere que usemos el bloque try with resources en lugar de un simple try, así una sentencia como esta:
Acaba convertida en algo parecido a esta:
utilizar el método close() ??
La principal diferencia es que hasta Java 7 sólo se podía hacer como en la primera versión. Además, en la segunda versión nos "ahorramos" tener que cerrar los recursos, puesto que lo realizará automáticamente en caso de que se produzca algún error evitando así el enmascaramiento de excepciones. Por tanto, sigue siendo necesario cerrar el stream por ejemplo al usar un buffer para que se vacíe totalmente en el fichero de destino.