Archivos
En esta guía (9)
En esta lección vas a leer y escribir archivos. Vas a usar Source para leer texto línea por línea o carácter por carácter, convertir lo leído a números, leer desde una URL y desde archivos binarios, y escribir archivos con PrintWriter. También vas a ver cómo asegurarte de cerrar siempre lo que abrís. Al final vas a leer y exportar cuentas, transacciones y clientes en formato CSV, que es exactamente lo que hace cualquier proceso de datos.
Para esta lección necesitás la instalación local: Scastie no tiene acceso a archivos de tu máquina. Las rutas relativas (como "archivo.txt") se resuelven desde la carpeta donde ejecutás el programa; en un proyecto sbt, es la raíz del proyecto.
Leer líneas y caracteres
La forma más simple de leer un archivo de texto es scala.io.Source.fromFile, que crea una fuente de entrada:
import scala.io.Source
// Leer línea por línea
val fuente = Source.fromFile("archivo.txt", "UTF-8")
for (linea <- fuente.getLines()) {
println(linea)
}
fuente.close()
import scala.io.Source
// Leer carácter por carácter
val fuente = Source.fromFile("archivo.txt", "UTF-8")
for (caracter <- fuente) {
print(caracter)
}
fuente.close()
getLines()devuelve un iterador de líneas, sin el salto de línea final.- Recorrer la fuente directamente da los caracteres uno por uno.
- El segundo argumento de
fromFilees la codificación. Si no lo pasás, se usa la del sistema, que puede no ser UTF-8 (sobre todo en Windows). - Siempre hay que cerrar la fuente con
close(). Un archivo abierto ocupa un recurso del sistema operativo.
Si el archivo no existe, fromFile lanza una FileNotFoundException.
Cerrar siempre: try/finally y Using
En el ejemplo anterior, si algo falla entre la apertura y el close(), la fuente queda abierta. La forma segura es cerrarla en un finally, que se ejecuta pase lo que pase:
import scala.io.Source
val fuente = Source.fromFile("archivo.txt", "UTF-8")
try {
fuente.getLines().foreach(println)
} finally {
fuente.close()
}
Desde Scala 2.13 (y en Scala 3) existe scala.util.Using, que hace exactamente eso por vos: abre el recurso, lo usa y lo cierra al final, haya error o no. Devuelve un Try con el resultado:
import scala.io.Source
import scala.util.{Failure, Success, Using}
val resultado = Using(Source.fromFile("archivo.txt", "UTF-8")) { fuente =>
fuente.getLines().toList
}
resultado match {
case Success(lineas) => println(s"Leí ${lineas.length} líneas")
case Failure(error) => println(s"No se pudo leer: ${error.getMessage}")
}
Try es parecido a Option: es Success(valor) si todo salió bien o Failure(excepción) si algo falló. Así el error no corta el programa: queda como un valor que podés revisar.
Un detalle importante de getLines(): es un iterador, que lee a medida que lo recorrés. Si cerrás la fuente antes de recorrerlo, no vas a poder leer nada. Por eso, adentro del Using, se convierte a List con toList.
Leer números
Lo que se lee de un archivo es siempre texto. Para convertirlo, se usan toInt, toDouble y compañía:
import java.io.PrintWriter
import scala.io.Source
// Preparamos un archivo de ejemplo
val escritor = new PrintWriter("numeros.txt")
escritor.println("12")
escritor.println("13")
escritor.println("14")
escritor.println("15")
escritor.close()
val fuente = Source.fromFile("numeros.txt", "UTF-8")
val numeros = try {
fuente.getLines().map(_.trim.toDouble).toList
} finally {
fuente.close()
}
println(numeros) // List(12.0, 13.0, 14.0, 15.0)
println(numeros.sum) // 54.0
Si alguna línea no es un número, toDouble lanza una NumberFormatException y se cae todo. Con datos que no controlás, usá toDoubleOption y descartá o reportá las líneas inválidas:
val lineas = List("12", "trece", "14", "")
val validos = lineas.flatMap(_.trim.toDoubleOption)
println(validos) // List(12.0, 14.0)
flatMap con un Option se queda solo con los Some y descarta los None.
Leer desde una URL y otras fuentes
Source no se limita a archivos. También puede leer desde una URL, desde la entrada estándar o desde un string:
import scala.io.Source
val desdeTexto = Source.fromString("línea 1\nlínea 2")
println(desdeTexto.getLines().toList) // List(línea 1, línea 2)
desdeTexto.close()
// Desde una URL (necesita conexión a internet)
// val web = Source.fromURL("https://www.ejemplo.com/archivo.txt", "UTF-8")
// val contenido = web.mkString
// web.close()
fromString es muy práctico para probar tu código de lectura sin depender de un archivo real. mkString lee todo el contenido de una vez como un único string.
Leer archivos binarios
Para archivos que no son texto (imágenes, archivos comprimidos, formatos propios), se leen los bytes directamente. La forma más corta es con java.nio:
import java.nio.file.{Files, Paths}
val bytes: Array[Byte] = Files.readAllBytes(Paths.get("numeros.txt"))
println(s"El archivo tiene ${bytes.length} bytes")
La forma clásica, con un FileInputStream de Java:
import java.io.{File, FileInputStream}
val archivo = new File("numeros.txt")
val flujo = new FileInputStream(archivo)
try {
val bytes = new Array[Byte](archivo.length.toInt)
flujo.read(bytes)
println(bytes.take(4).mkString(", ")) // los primeros 4 bytes
} finally {
flujo.close()
}
Escribir archivos de texto
Para escribir texto se usa un PrintWriter de Java, con los mismos print y println que ya conocés:
import java.io.PrintWriter
val escritor = new PrintWriter("salida.txt", "UTF-8")
try {
escritor.println("Hola, mundo!")
escritor.println("Segunda línea")
} finally {
escritor.close()
}
Si el archivo ya existe, PrintWriter lo reemplaza. Si querés agregar al final en lugar de pisarlo, abrilo en modo append:
import java.io.{FileWriter, PrintWriter}
val escritor = new PrintWriter(new FileWriter("salida.txt", true)) // true: agregar al final
try {
escritor.println("Una línea más")
} finally {
escritor.close()
}
Igual que con la lectura, cerrar es obligatorio: hasta que no cerrás el PrintWriter, parte de lo escrito puede quedar en memoria y no llegar al archivo.
Practicá
Para los ejercicios 1 y 3 vas a necesitar estos dos archivos. Creálos en la carpeta desde donde ejecutás el programa (son los mismos que trae el proyecto final del curso, en src/main/scala/).
cuentas.csv:
numero_cuenta,saldo,tipo_cuenta
12345,1000,Ahorro
54321,500,Corriente
98765,2000,Inversion
clientes.txt:
id|nombre|direccion|cuentas
1|Alice|Calle Principal|CA,CC
2|Bob|Avenida Secundaria|CC
3|Charlie|Calle Terciaria|CA
4|Dave|Avenida Cuaternaria|CC
5|Eve|Calle Quintenaria|CA,CC
- Leer cuentas desde un CSV. Leé
cuentas.csvy armá una lista de cuentas a partir de sus filas (salteá el encabezado). Mostrálas por consola. - Exportar transacciones a un archivo de texto. Dada una lista de transacciones, escribilas en
transacciones.txt, una por línea, con un encabezado y el formatoorigen,destino,monto,fecha. - Leer clientes desde un archivo de texto. Leé
clientes.txt, donde cada línea tiene id, nombre, dirección y la lista de cuentas separadas por coma, y armá una lista de clientes. Mostrálos por consola. - Exportar clientes a CSV. Escribí los clientes del ejercicio 3 en
clientes.csv, con el encabezadoID,Nombre,Direccion,Cuentas. Pensá qué pasa con la lista de cuentas, que ya tiene comas adentro.
Soluciones
import java.io.PrintWriter
import scala.io.Source
case class Cuenta(numeroCuenta: String, saldo: Double, tipoCuenta: String)
case class Transaccion(origen: String, destino: String, monto: Double, fecha: String)
case class Cliente(id: Int, nombre: String, direccion: String, cuentas: List[String])
// Ejercicio 1
val archivoCuentas = Source.fromFile("cuentas.csv", "UTF-8")
val cuentas: List[Cuenta] = try {
archivoCuentas.getLines().drop(1).map { linea =>
val Array(numeroCuenta, saldo, tipoCuenta) = linea.split(',').map(_.trim)
Cuenta(numeroCuenta, saldo.toDouble, tipoCuenta)
}.toList
} finally {
archivoCuentas.close()
}
cuentas.foreach(println)
// Ejercicio 2
val transacciones = List(
Transaccion("12345", "54321", 100.0, "2023-01-01"),
Transaccion("54321", "98765", 50.0, "2023-02-01")
)
val archivoTransacciones = new PrintWriter("transacciones.txt", "UTF-8")
try {
archivoTransacciones.println("origen,destino,monto,fecha")
transacciones.foreach { t =>
archivoTransacciones.println(s"${t.origen},${t.destino},${t.monto},${t.fecha}")
}
} finally {
archivoTransacciones.close()
}
// Ejercicio 3
val archivoClientes = Source.fromFile("clientes.txt", "UTF-8")
val clientes: List[Cliente] = try {
archivoClientes.getLines().drop(1).filter(_.trim.nonEmpty).map { linea =>
val Array(id, nombre, direccion, cuentasStr) = linea.split('|')
Cliente(id.toInt, nombre, direccion, cuentasStr.split(',').toList)
}.toList
} finally {
archivoClientes.close()
}
clientes.foreach(println)
// Ejercicio 4
val archivoClientesCSV = new PrintWriter("clientes.csv", "UTF-8")
try {
archivoClientesCSV.println("ID,Nombre,Direccion,Cuentas")
clientes.foreach { c =>
archivoClientesCSV.println(s"${c.id},${c.nombre},${c.direccion},${c.cuentas.mkString(";")}")
}
} finally {
archivoClientesCSV.close()
}
Cuenta(12345,1000.0,Ahorro)
Cuenta(54321,500.0,Corriente)
Cuenta(98765,2000.0,Inversion)
Cliente(1,Alice,Calle Principal,List(CA, CC))
Cliente(2,Bob,Avenida Secundaria,List(CC))
Cliente(3,Charlie,Calle Terciaria,List(CA))
Cliente(4,Dave,Avenida Cuaternaria,List(CC))
Cliente(5,Eve,Calle Quintenaria,List(CA, CC))
Y clientes.csv queda así:
ID,Nombre,Direccion,Cuentas
1,Alice,Calle Principal,CA;CC
2,Bob,Avenida Secundaria,CC
3,Charlie,Calle Terciaria,CA
4,Dave,Avenida Cuaternaria,CC
5,Eve,Calle Quintenaria,CA;CC
Puntos a revisar:
val Array(numeroCuenta, saldo, tipoCuenta) = linea.split(',')desarma el array en tres valores en una sola línea. Si una fila no tiene exactamente tres campos, lanza unMatchError: es una forma rápida de detectar filas mal formadas.- En el ejercicio 3,
split('|')usa unChar. Con un string habría que escaparlo,split("\\|"), porque|es un carácter especial en las expresiones regulares (lección 10):split("|")partiría la línea carácter por carácter. - En el ejercicio 4, si escribieras las cuentas separadas por coma dentro de un CSV separado por comas,
1,Alice,Calle Principal,CA,CCtendría cinco columnas en lugar de cuatro, y quien lo lea no podría saber dónde termina cada campo. Por eso la lista interna usa;. La otra alternativa estándar es encerrar el campo entre comillas:"CA,CC". - El
filter(_.trim.nonEmpty)descarta líneas vacías, como la que suele quedar al final de un archivo.
Resumen
Source.fromFile(ruta, "UTF-8")abre un archivo de texto;getLines()lo recorre por líneas y recorrer la fuente da los caracteres.- Todo lo que se abre se cierra: con
try/finallyo conscala.util.Using, que además devuelve unTry. - Lo leído es texto: se convierte con
toInt/toDouble, o contoIntOption/toDoubleOptionsi los datos pueden venir mal. Sourcetambién lee de URLs (fromURL) y strings (fromString); los binarios se leen como bytes conFiles.readAllBytesoFileInputStream.PrintWriterescribe texto y reemplaza el archivo; connew FileWriter(ruta, true)agrega al final.- Al escribir CSV, cuidá que los datos no contengan el separador.