Java: leer un archivo en un ArrayList

Hay muchas formas de leer y escribir archivos en Java. Por lo general, tenemos algunos datos en la memoria, en los que realizamos operaciones y luego persistimos en...

Introducción

There are many ways to go about Leer y escribir archivos en Java.

Por lo general, tenemos algunos datos en la memoria, en los que realizamos operaciones y luego los conservamos en un archivo. Sin embargo, si queremos cambiar esa información, debemos volver a colocar el contenido del archivo en la memoria y realizar operaciones.

Si, por ejemplo, nuestro archivo contiene una lista larga que queremos ordenar, tendremos que leerlo en una estructura de datos adecuada, realizar operaciones y luego persistirlo una vez más, en este caso un ArrayList.

Esto se puede lograr con varios enfoques diferentes:

  • Archivos.readAllLines()
  • Lector de archivos
  • Escáner
  • Lector almacenado en búfer
  • ObjetoInputStream
  • API de flujos de Java

Archivos.readAllLines()

Desde Java 7, es posible cargar todas las líneas de un archivo en un ArrayList de una forma muy sencilla:

1
2
3
4
5
6
try {
    ArrayList<String> lines = new ArrayList<>(Files.readAllLines(Paths.get(fileName)));
}
catch (IOException e) {
    // Handle a potential exception
}

También podemos especificar un juego de caracteres para manejar diferentes formatos de texto, si es necesario:

1
2
3
4
5
6
7
try {
    Charset charset = StandardCharsets.UTF_8;
    ArrayList<String> lines = new ArrayList<>(Files.readAllLines(Paths.get(fileName), charset));
}
catch (IOException e) {
    // Handle a potential exception
}

Files.readAllLines() abre y cierra los recursos necesarios automáticamente.

Escáner

A pesar de lo agradable y simple que era el método anterior, solo es útil para leer el archivo línea por línea. ¿Qué pasaría si todos los datos se almacenaran en una sola línea?

Scanner es una herramienta fácil de usar para analizar cadenas y tipos primitivos. Usar Scanner puede ser tan simple o tan difícil como el desarrollador quiera hacerlo.

Un ejemplo simple de cuándo preferiríamos usar Scanner sería si nuestro archivo tuviera solo una línea, y los datos deben analizarse en algo utilizable.

Un delimitador es una secuencia de caracteres que Scanner usa para separar valores. De forma predeterminada, utiliza una serie de espacios/tabulaciones como delimitador (espacio en blanco entre valores), pero podemos declarar nuestro propio delimitador y usarlo para analizar los datos.

Echemos un vistazo a un archivo de ejemplo:

1
some-2123-different-values- in - this -text-with a common-delimiter

En tal caso, es fácil notar que todos los valores tienen un delimitador común. Simplemente podemos declarar que "-" rodeado por cualquier número de espacios en blanco es nuestro delimitador.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// We'll use "-" as our delimiter
ArrayList<String> arrayList = new ArrayList<>();
try (Scanner s = new Scanner(new File(fileName)).useDelimiter("\\s*-\\s*")) {
    // \\s* in regular expressions means "any number or whitespaces".
    // We could've said simply useDelimiter("-") and Scanner would have
    // included the whitespaces as part of the data it extracted.
    while (s.hasNext()) {
        arrayList.add(s.next());
    }
}
catch (FileNotFoundException e) {
    // Handle the potential exception
}

Ejecutar este fragmento de código nos daría una ArrayList con estos elementos:

1
[some, 2, different, values, in, this, text, with a common, delimiter]

Por otro lado, si solo hubiéramos usado el delimitador predeterminado (espacio en blanco), ArrayList se vería así:

1
[some-2-different-values-, in, -, this, -text-with, a, common-delimiter]

Scanner tiene algunas funciones útiles para analizar datos, como nextInt(), nextDouble(), etc.

Importante: Llamar a .nextInt() NO devolverá el siguiente valor int que se puede encontrar en el archivo. Devolverá un valor int solo si los siguientes elementos Scanner "scans" son un valor int válido, de lo contrario, se lanzará una excepción. Una manera fácil de asegurarse de que no surja una excepción es realizar una verificación "has" correspondiente, como .hasNextInt() antes de usar .nextInt().

Aunque no vemos eso cuando llamamos a funciones como scanner.nextInt() o scanner.hasNextDouble(), Scanner utiliza expresiones regulares en segundo plano.

Muy importante: Se produce un error extremadamente común al usar Scanner cuando se trabaja con archivos que tienen varias líneas y se usa .nextLine() junto con .nextInt(),nextDouble() , etc.

Echemos un vistazo a otro archivo:

1
2
3
12
some data we want to read as a string in one line
10

A menudo, los desarrolladores más nuevos que usan Scanner escriben código como:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
try (Scanner scanner = new Scanner(new File("example.txt"))) {
    int a = scanner.nextInt();
    String s = scanner.nextLine();
    int b = scanner.nextInt();

    System.out.println(a + ", " + s + ", " + b);
}
catch (FileNotFoundException e) {
    // Handle a potential exception
}
//catch (InputMismatchException e) {
//    // This will occur in the code above
//}

Este código parece ser lógicamente sólido: leemos un número entero del archivo, luego la siguiente línea, luego el segundo número entero. Si intenta ejecutar este código, se lanzará InputMismatchException sin una razón obvia.

Si comienza a depurar e imprimir lo que ha escaneado, verá que int a se cargó bien, pero que String s está vacío.

¿Porqué es eso? La primera cosa importante a tener en cuenta es que una vez que Scanner lee algo del archivo, continúa escaneando el archivo desde el primer carácter después de los datos escaneados previamente.

Por ejemplo, si tuviéramos "12 13 14" en un archivo y llamamos a .nextInt() una vez, el analizador luego fingiría que solo había "13 14" en el archivo. Observe que el espacio entre "12" y "13" todavía está presente.

La segunda cosa importante a tener en cuenta: la primera línea en nuestro archivo example.txt no solo contiene el número 12, contiene lo que llama un "carácter de nueva línea", y en realidad es 12\ n en lugar de solo 12.

Nuestro archivo, en realidad, se ve así:

1
2
3
12\n
some data we want to read as a string in one line\n
10

Cuando llamamos por primera vez a .nextInt(), Scanner lee solo el número 12, y deja el primer \n sin leer.

.nextLine() luego lee todos los caracteres que el escáner aún no ha leído hasta que llega al primer carácter \n, que omite y luego devuelve los caracteres que leyó. Este es exactamente el problema en nuestro caso: tenemos un carácter \n sobrante después de leer el 12.

Entonces, cuando llamamos a .nextLine(), obtenemos una cadena vacía como resultado, ya que Scanner no agrega el carácter \n a la cadena que devuelve.

Ahora el Scanner está al comienzo de la segunda línea de nuestro archivo, y cuando tratamos de llamar a .nextInt(), Scanner encuentra algo que no se puede analizar como un int y lanza el mencionada InputMismatchException.

Soluciones

  • Dado que sabemos qué es exactamente lo que está mal en este código, podemos codificar una solución alternativa. Simplemente "consumiremos" el carácter de nueva línea entre .nextInt() y .nextLine():
1
2
3
4
5
...
int a = scanner.nextInt();
scanner.nextLine(); // Simply consumes the bothersome \n
String s = scanner.nextLine();
...
  • Dado que sabemos cómo se formatea example.txt, podemos leer el archivo completo línea por línea y analizar las líneas necesarias usando Integer.parseInt():
1
2
3
4
5
...
int a = Integer.parseInt(scanner.nextLine());
String s = scanner.nextLine();
int b = Integer.parseInt(scanner.nextLine());
...

Lector en búfer

BufferedReader lee texto de un flujo de entrada de caracteres, pero lo hace almacenando en búfer los caracteres para proporcionar operaciones .read() eficientes. Dado que acceder a un HDD es una operación que consume mucho tiempo, BufferedReader recopila más datos de los que solicitamos y los almacena en un búfer.

La idea es que cuando llamamos a .read() (o una operación similar) es probable que volvamos a leer pronto del mismo bloque de datos del que acabamos de leer, y así se almacenan los datos “alrededores”. en un búfer. En caso de que quisiéramos leerlo, lo haríamos directamente desde el búfer en lugar de hacerlo desde el disco, que es mucho más eficiente.

Esto nos lleva a lo que BufferedReader es bueno para leer archivos grandes. BufferedReader tiene una memoria intermedia significativamente mayor que Scanner (8192 caracteres por defecto frente a 1024 caracteres por defecto, respectivamente).

BufferedReader se utiliza como contenedor para otros Readers, por lo que los constructores de BufferedReader toman un objeto Reader como parámetro, como FileReader.

Estamos usando prueba-con-recursos para no tener que cerrar el lector manualmente:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
ArrayList<String> arrayList = new ArrayList<>();

try (BufferedReader reader = new BufferedReader(new FileReader(fileName))) {
    while (reader.ready()) {
        arrayList.add(reader.readLine());
    }
}
catch (IOException e) {
    // Handle a potential exception
}

Se recomienda envolver un FileReader con un BufferedReader, exactamente debido a los beneficios de rendimiento.

flujo de entrada de objeto

ObjectInputStream solo debe usarse junto con ObjectOutputStream. Lo que estas dos clases nos ayudan a lograr es almacenar un objeto (o una matriz de objetos) en un archivo y luego leer fácilmente desde ese archivo.

Esto solo se puede hacer con clases que implementen la interfaz Serializable. La interfaz Serializable no tiene métodos o campos y solo sirve para identificar la semántica de ser serializable:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
public static class MyClass implements Serializable {
    int someInt;
    String someString;

    public MyClass(int someInt, String someString) {
        this.someInt = someInt;
        this.someString = someString;
    }
}

public static void main(String[] args) throws IOException, ClassNotFoundException {
    // The file extension doesn't matter in this case, since they're only there to tell
    // the OS with what program to associate a particular file
    ObjectOutputStream objectOutputStream =
        new ObjectOutputStream(new FileOutputStream("data.olivera"));

    MyClass first = new MyClass(1, "abc");
    MyClass second = new MyClass(2, "abc");

    objectOutputStream.writeObject(first);
    objectOutputStream.writeObject(second);
    objectOutputStream.close();

    ObjectInputStream objectInputStream =
                new ObjectInputStream(new FileInputStream("data.olivera"));

    ArrayList<MyClass> arrayList = new ArrayList<>();

    try (objectInputStream) {
        while (true) {
            Object read = objectInputStream.readObject();
            if (read == null)
                break;

            // We should always cast explicitly
            MyClass myClassRead = (MyClass) read;
            arrayList.add(myClassRead);
        }
    }
    catch (EOFException e) {
        // This exception is expected
    }

    for (MyClass m : arrayList) {
        System.out.println(m.someInt + " " + m.someString);
    }
}

API de flujos de Java

Desde Java 8, otra forma rápida y sencilla de cargar el contenido de un archivo en un ArrayList sería usando la API de flujos de Java:

1
2
3
4
5
6
7
// Using try-with-resources so the stream closes automatically
try (Stream<String> stream = Files.lines(Paths.get(fileName))) {
    ArrayList<String> arrayList = stream.collect(Collectors.toCollection(ArrayList::new));
}
catch (IOException e) {
    // Handle a potential exception
}

Sin embargo, tenga en cuenta que este enfoque, al igual que Files.readAllLines(), solo funcionaría si los datos se almacenan en líneas.

El código anterior no hace nada especial, y rara vez usaríamos transmisiones de esta manera. Sin embargo, dado que estamos cargando estos datos en un ArrayList para que podamos procesarlos en primer lugar, las secuencias proporcionan una excelente manera de hacerlo.

Podemos ordenar/filtrar/asignar fácilmente los datos antes de almacenarlos en una ArrayList:

1
2
3
4
5
6
7
8
9
try (Stream<String> stream = Files.lines(Paths.get(fileName))) {
    ArrayList<String> arrayList = stream.map(String::toLowerCase)
                                        .filter(line -> !line.startsWith("a"))
                                        .sorted(Comparator.comparing(String::length))
                                        .collect(Collectors.toCollection(ArrayList::new));
}
catch (IOException e) {
    // Handle a potential exception
}

Conclusión

Hay varias formas diferentes en las que puede leer datos de un archivo en una ArrayList. Cuando solo necesite leer las líneas como elementos, use Files.readAllLines; cuando tenga datos que puedan analizarse fácilmente, use Scanner; cuando trabaje con archivos grandes, use FileReader envuelto con BufferedReader; cuando se trata de una matriz de objetos, use ObjectInputStream (pero asegúrese de que los datos se escribieron usando ObjectOutputStream).

Licensed under CC BY-NC-SA 4.0