Skip to content

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:

1
2
3
1. Número de expediente.
2. Nombre y apellidos.
3. Domicilio.

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:

1
2
3
4
5
6
7
8
9
65255
José Mateo Ruiz
C/ Paz, 56488
Ángela Lopez Villa
Av. Blas..
24645
Armando García Ledesma
C/ Tuej 

Ejemplo de registro en el fichero anterior:

1
2
3
24645
Armando García Ledesma
C/ Tuej 

Ejemplo de campo en el registro anterior:

Armando García Ledesma

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
  • FileInputStream permite leer bytes de un fichero.
  • FileOutputStream permite escribir bytes de un fichero.
  • FileReader permite leer de un fichero uno o varios caracteres.
  • FileWriter permite 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.

package UT06.P2_Ficheros;

import java.io.*;

public class P2_1_CrearFichero {

    public static void main(String[] args) {
        FileWriter f = null;
        try {
            f = new FileWriter("texto.txt");
            f.write("Este texto se escribe en el fichero\n\r");

        } catch (IOException e) {
            System.out.println("Problema al abrir o escribir ");

        } finally {
            if (f != null) {
                try {
                    f.close();
                } catch (IOException e) {
                    System.out.println("Problema al cerrar el fichero");
                }
            }
        }
    }
}
La creación del 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.

package UT06.P2_Ficheros;

import java.io.*;

public class P2_2_SobreescribirFichero {

    public static void main(String[] args) {
        try (FileWriter f = new FileWriter("texto.txt", true);) {
            f.write("Este texto se añade en el fichero\n\r");

        } catch (IOException e) {
            System.out.println("Problema al abrir o escribir ");
        }
    }
}
En este ejemplo se ha utilizado la nueva sintaxis disponible para los bloques try-catch: lo que se denomina 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.

package UT06.P2_Ficheros;

import java.io.*;

public class P2_3_LecturaSecuencialTexto {

    final static String VOCALES = "aáàeééiíoóòuúüAÁÀEÉÈIÍOÓÒUÚÜ";

    public static void main(String[] args) {
        try (FileReader f = new FileReader(new File("texto.txt"));) {
            int contadorVocales = 0;
            int caracter;
            while ((caracter = f.read()) != -1) {
                char letra = (char) caracter;
                if (VOCALES.indexOf(letra) != -1) {
                    contadorVocales++;
                }
            }
            System.out.println("Numero de vocales: " + contadorVocales);

        } catch (FileNotFoundException e) {
            System.out.println("ERROR: Probrema al abrir el fichero");

        } catch (IOException e) {
            System.out.println("ERROR: Problema al leer");
        }
    }
}
Observa que:

  • Para leer el fichero de texto usamos un InputReader.

  • Al crear el stream (InputReader) es posible indicar un objeto de tipo File.

  • 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 while combina una asignación con una comparación. En primer lugar se realiza la asignación y luego se compara carácter con -1.

  • FileNotFoundException sucede cuando el fichero no se puede abrir (no existe, permiso denegado, etc), mientras que IOException se lanzará si falla la operación read().

Ejemplo: escritura de un fichero secuencial de texto

Dada una cadena escribirla en un fichero en orden inverso:

package UT06.P2_Ficheros;

import java.io.*;

public class P2_4_EscrituraSecuencialTexto {

    final static String CADENA = "En un lugar de la mancha...";

    public static void main(String[] args) {
        try (FileWriter f = new FileWriter(new File("texto.txt"));) {
            for (int i = CADENA.length() - 1; i >= 0; i--) {
                f.write(CADENA.charAt(i));
            }
            System.out.println("FIN");

        } catch (FileNotFoundException e) {
            System.out.println("ERROR: Probrema al abrir el fichero");

        } catch (IOException e) {
            System.out.println("ERROR: Problema al escribir");

        }
    }
}
Observa que:

  • 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:

package UT06.P2_Ficheros;

import java.io.*;
import java.util.Scanner;

public class P2_6_EscrituraSecuencialBinario {

  public static void main(String[] args) {
     Scanner tec = new Scanner(System.in);
     try (DataOutputStream fs = new DataOutputStream(
                                  new BufferedOutputStream(
                                    new FileOutputStream("jugadores.dat")));) {
         for (int i = 1; i <= 5; i++) {
            //Pedimos datos al usuario
            System.out.println(" ---- Jugador " + i + " -----");
            System.out.print("Nombre: ");
            String nombre = tec.nextLine();

            System.out.print("Nacimiento: ");
            int anyo = tec.nextInt();

            System.out.print("Estatura: ");
            double est = tec.nextDouble();
            //Vaciar salto linea
            tec.nextLine();

            //Volcamos información al fichero
            fs.writeUTF(nombre);
            fs.writeInt(anyo);
            fs.writeDouble(est);
         }

      } catch (FileNotFoundException e) {
          System.out.println("ERROR: Probrema al abrir el fichero");

      } catch (IOException e) {
          System.out.println("ERROR: Problema al leer o escribir");

      }
  }
}
Observa que:

  • Para escribir información binaria usamos un DataInputStream asociado al stream. La clase tiene métodos para escribir int, 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

package UT06.P2_Ficheros;

import java.io.*;
import java.util.Scanner;

public class P2_7_LecturaSecuencialBinario {

  public static void main(String[] args) {
     Scanner tec = new Scanner(System.in);
     try (DataInputStream fe = new DataInputStream(
                                new BufferedInputStream(
                                 new FileInputStream("jugadores.dat")));) {
        while (true) {
           //Leemos nombre
           System.out.println(fe.readUTF());
           //leemos y desechamos resto de datos
           fe.readInt();
           fe.readDouble();
        }

     } catch (EOFException e) {
          //Se lanzará cuando se llegue al final del fichero

     } catch (FileNotFoundException e) {
          System.out.println("ERROR: Probrema al abrir el fichero");

     } catch (IOException e) {
          System.out.println("ERROR: Problema al leer o escribir");

     }
  }
}
Observa que:

  • 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.

package UT06.P2_Ficheros;

import java.io.*;

public class P2_5_Buffers {

    final static String ENTRADA = "texto.txt";
    final static String SALIDA = "textoMayusculas.txt";

    public static void main(String[] args) {
        try (BufferedReader fe = new BufferedReader(new FileReader(ENTRADA));
             BufferedWriter fs = new BufferedWriter(new FileWriter(SALIDA))){
            String linea;
            while ((linea = fe.readLine()) != null) {
                fs.write(linea.toUpperCase());
                fs.newLine();
            }
            System.out.println("FIN");

        } catch (FileNotFoundException e) {
            System.out.println("ERROR: Probrema al abrir el fichero");

        } catch (IOException e) {
            System.out.println("ERROR: Problema al leer o escribir");
        }
    }
}
Observa que:

  • Usamos buffers tanto para leer como para escribir. Esto permite minimizar los accesos a disco.

  • Los buffers quedan asociados a un FileReader y FileWriter respectivamente. Realizamos las operaciones de lectura/escritura sobre las clases Buffered… 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, …

  • BufferedReader dispone de un método para leer líneas completas (readLine()). Cuando se llega al final del fichero este método devuelve null.

  • Fíjate como en el bloque try with resources creamos 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:

1
2
3
4
5
6
7
8
FileReader fr = new FileReader(path);
BufferedReader br = new BufferedReader(fr);
try {
    return br.readLine();
} finally {
    br.close();
    fr.close();
}

Acaba convertida en algo parecido a esta:

1
2
3
4
5
6
static String readFirstLineFromFile(String path) throws IOException {
    try (FileReader fr = new FileReader(path);
         BufferedReader br = new BufferedReader(fr)) {
        return br.readLine();
    }
}   

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.