Hasta ahora, todo el estado de BiblioTech —el catálogo, los socios, el historial de préstamos— vive únicamente en la memoria del programa: en cuanto el proceso termina, todo desaparece con él. Esta lección abre el Módulo 5 (Trabajando con Datos) atacando exactamente ese problema por el camino más directo: leer y escribir ficheros en disco con el namespace System.IO. Aprenderás a comprobar si un fichero existe, a leerlo y escribirlo de una vez o línea a línea, a liberar correctamente los recursos que abres, y a manejar los errores propios de la E/S (entrada/salida). Al final de la lección, Biblioteca sabrá guardar y recuperar su catálogo completo en un fichero de texto plano, la primera forma —muy básica— de persistencia real de todo el curso.

Contenido

  1. El namespace System.IO y los dos niveles de trabajo con ficheros
  2. Comprobar la existencia de un fichero: File.Exists
  3. Todo de una vez: File.ReadAllText/File.WriteAllText y File.ReadAllLines/File.WriteAllLines
  4. Ficheros grandes: StreamReader y StreamWriter
  5. using e IDisposable: liberar recursos correctamente
  6. Manejo de excepciones de E/S: IOException y FileNotFoundException
  7. Persistiendo el catálogo de BiblioTech en texto plano

  1. El namespace System.IO y los dos niveles de trabajo con ficheros

Todo lo relacionado con ficheros y directorios en .NET vive en el namespace System.IO. Dentro de él conviven dos niveles de trabajo, pensados para necesidades distintas:

Nivel Clases principales Cuándo usarlo
Alto nivel, "todo de una vez" File (métodos estáticos) Ficheros pequeños o medianos que caben cómodamente en memoria
Bajo nivel, "poco a poco" StreamReader, StreamWriter Ficheros grandes, o cuando se quiere procesar línea a línea sin cargarlo entero

Ambos niveles acaban usando el mismo mecanismo por debajo (un Stream que representa el flujo de bytes hacia o desde el disco), pero File te ahorra la gestión manual del flujo cuando el tamaño del fichero no es un problema. Esta lección presenta primero File, por ser el punto de entrada más sencillo, y después StreamReader/StreamWriter para cuando ese enfoque no basta.

  1. Comprobar la existencia de un fichero: File.Exists

Antes de leer un fichero, conviene comprobar que existe; antes de escribirlo, no siempre hace falta (File.WriteAllText lo crea si no existe), pero sí es una buena práctica antes de cualquier lectura:

string ruta = "catalogo.txt";

if (File.Exists(ruta))
{
    Console.WriteLine("El fichero de catalogo ya existe.");
}
else
{
    Console.WriteLine("Todavia no se ha guardado ningun catalogo.");
}

File.Exists(ruta) devuelve true/false sin lanzar ninguna excepción, incluso si la ruta tiene un formato inválido o el directorio no existe (en esos casos simplemente devuelve false). Es la forma segura de anticiparse a un fichero ausente, en vez de intentar leerlo directamente y capturar la excepción que eso provocaría.

  1. Todo de una vez: File.ReadAllText/File.WriteAllText y File.ReadAllLines/File.WriteAllLines

La clase estática File ofrece cuatro métodos de uso inmediato para trabajar con el contenido completo de un fichero:

Método Lee/escribe Devuelve/recibe
File.WriteAllText(ruta, contenido) Escribe Un único string (crea o sobrescribe el fichero entero)
File.ReadAllText(ruta) Lee Un único string con todo el contenido
File.WriteAllLines(ruta, lineas) Escribe Un IEnumerable<string>, una línea por elemento
File.ReadAllLines(ruta) Lee Un string[], una posición por línea del fichero
// Escribir todo el contenido de golpe
File.WriteAllText("saludo.txt", "Bienvenido a BiblioTech");

// Leerlo de vuelta, tambien de golpe
string contenido = File.ReadAllText("saludo.txt");
Console.WriteLine(contenido); // "Bienvenido a BiblioTech"

// Escribir varias lineas a partir de un array o una lista
string[] titulos = { "Rayuela", "Ficciones", "El Aleph" };
File.WriteAllLines("titulos.txt", titulos);

// Leerlas de vuelta, una por posicion del array
string[] titulosLeidos = File.ReadAllLines("titulos.txt");
foreach (string titulo in titulosLeidos)
{
    Console.WriteLine(titulo);
}

Ambos métodos de escritura sobrescriben el fichero si ya existía (no añaden contenido al final); si necesitas añadir sin borrar lo anterior, existen las variantes File.AppendAllText y File.AppendAllLines, con la misma forma de uso. Estos cuatro métodos son la opción por defecto siempre que el fichero sea de un tamaño razonable: internamente abren el fichero, leen o escriben todo su contenido, y lo cierran automáticamente por ti, sin que tengas que gestionar nada más.

  1. Ficheros grandes: StreamReader y StreamWriter

Cuando un fichero es demasiado grande para cargarlo entero en memoria de golpe (piensa en un .csv con millones de filas), o cuando quieres empezar a procesar sus líneas antes de que termine de leerse por completo, File.ReadAllText/File.ReadAllLines dejan de ser adecuados: cargan todo el fichero en memoria antes de devolver el control. StreamReader y StreamWriter resuelven esto trabajando línea a línea, sin necesidad de tener el fichero completo en memoria en ningún momento:

// Escribir linea a linea con StreamWriter
StreamWriter escritor = new StreamWriter("catalogo_grande.txt");
escritor.WriteLine("Rayuela;Julio Cortazar");
escritor.WriteLine("Ficciones;Jorge Luis Borges");
escritor.Close(); // libera el fichero; sin esto, los datos podrian no llegar a escribirse

// Leer linea a linea con StreamReader
StreamReader lector = new StreamReader("catalogo_grande.txt");
string? linea;
while ((linea = lector.ReadLine()) != null)
{
    Console.WriteLine($"Linea leida: {linea}");
}
lector.Close();

ReadLine() devuelve null cuando llega al final del fichero, lo que permite usarlo directamente como condición de un while: el bucle se repite mientras haya líneas, y termina solo cuando ReadLine() devuelve null. Este patrón (while ((linea = lector.ReadLine()) != null)) es tan habitual en C# que conviene memorizarlo tal cual.

El ejemplo anterior tiene, a propósito, un problema: si ocurriera una excepción entre la apertura y el Close(), el fichero quedaría abierto para siempre (un fugado de recursos, resource leak). El siguiente apartado resuelve exactamente eso.

  1. using e IDisposable: liberar recursos correctamente

StreamReader y StreamWriter (igual que SqliteConnection, que verás en la lección de Conectividad con Bases de Datos) representan un recurso externo al programa —un fichero abierto por el sistema operativo— que debe liberarse explícitamente cuando se termina de usar. Todos implementan la interfaz IDisposable (recuerda IDisposable de la lección de Constructores y Destructores, Módulo 3), cuyo método Dispose() libera ese recurso.

Llamar a Dispose() (o a su equivalente Close()) manualmente, como en el apartado anterior, funciona, pero obliga a acordarse de hacerlo en todos los caminos de salida del método, incluidos los que pasan por una excepción. La sentencia using automatiza esto por completo:

using (StreamWriter escritor = new StreamWriter("catalogo_grande.txt"))
{
    escritor.WriteLine("Rayuela;Julio Cortazar");
    escritor.WriteLine("Ficciones;Jorge Luis Borges");
} // aqui se llama automaticamente a escritor.Dispose(), incluso si algo lanza una excepcion dentro del bloque

using (StreamReader lector = new StreamReader("catalogo_grande.txt"))
{
    string? linea;
    while ((linea = lector.ReadLine()) != null)
    {
        Console.WriteLine($"Linea leida: {linea}");
    }
} // idem: lector.Dispose() se llama siempre al salir del bloque

Desde C# 8 existe además la using declaration, sin llaves propias, que libera el recurso al final del bloque que la contiene (el método, o el bloque { } en el que se declaró):

using StreamReader lector = new StreamReader("catalogo_grande.txt");
string? linea;
while ((linea = lector.ReadLine()) != null)
{
    Console.WriteLine(linea);
}
// lector.Dispose() se llama automaticamente aqui, al final del metodo (o del bloque contenedor)
Forma Sintaxis Cuándo liberar el recurso
using (bloque) using (var x = ...) { ... } Al final de las llaves { }
using (declaración) using var x = ...; Al final del método o bloque contenedor
Manual (Close()/Dispose()) Llamada explícita Cuando el programador se acuerde de llamarlo —frágil, evítalo

Regla práctica para el resto del curso: cualquier objeto que implemente IDisposable (ficheros, conexiones a bases de datos, HttpClient...) se declara con using, sin excepciones, salvo casos muy concretos que quedan fuera del alcance de este curso.

  1. Manejo de excepciones de E/S: IOException y FileNotFoundException

Cualquier operación de E/S puede fallar por motivos ajenos al programa: el fichero no existe, el disco está lleno, otro proceso lo tiene bloqueado, no hay permisos suficientes... Todas estas situaciones se manifiestan mediante excepciones, que se manejan con try/catch, exactamente como en la lección de Manejo de Excepciones (Módulo 2):

Excepción Cuándo se lanza
FileNotFoundException Se intenta abrir para lectura un fichero que no existe
DirectoryNotFoundException El directorio de la ruta indicada no existe
UnauthorizedAccessException No hay permisos suficientes para leer o escribir esa ruta
IOException Clase base de errores de E/S más generales (disco lleno, fichero bloqueado por otro proceso...)

FileNotFoundException, DirectoryNotFoundException y UnauthorizedAccessException heredan, de hecho, de IOException, así que un único catch (IOException ex) captura cualquiera de ellas si no necesitas distinguirlas; si sí lo necesitas, coloca los catch más específicos primero, como aprendiste en la lección de excepciones:

try
{
    string contenido = File.ReadAllText("catalogo.txt");
    Console.WriteLine(contenido);
}
catch (FileNotFoundException)
{
    Console.WriteLine("Todavia no existe ningun catalogo guardado.");
}
catch (IOException ex)
{
    Console.WriteLine($"Error de E/S al leer el catalogo: {ex.Message}");
}

En la práctica, comprobar File.Exists de antemano evita la mayoría de los FileNotFoundException; el resto de excepciones de IOException sí conviene capturarlas siempre que el programa dependa de que la operación tenga éxito.

  1. Persistiendo el catálogo de BiblioTech en texto plano

Con todo lo anterior, Biblioteca gana dos métodos nuevos para guardar y recuperar su catálogo (Catalogo, de la lección de Colecciones) en un fichero de texto plano: una línea por material, con los campos separados por ;. Como el catálogo mezcla Libro y Revista (recuerda la lección de Herencia), cada línea empieza con un campo adicional que identifica el tipo:

class Biblioteca
{
    // ... Catalogo, Socios, Prestamos, _sociosPorId, PrestamoRegistrado sin cambios ...
    // ... AgregarMaterial, AgregarSocio, BuscarSocioPorId, RegistrarPrestamo, PrestarLibroAsync sin cambios ...

    public void GuardarCatalogoTexto(string ruta)
    {
        using StreamWriter escritor = new StreamWriter(ruta);

        foreach (MaterialBibliotecario material in Catalogo)
        {
            if (material is Libro libro)
            {
                escritor.WriteLine($"LIBRO;{libro.Titulo};{libro.Autor};{libro.Isbn};{libro.Disponible}");
            }
            else if (material is Revista revista)
            {
                escritor.WriteLine($"REVISTA;{revista.Titulo};{revista.Autor};{revista.NumeroEdicion};{revista.Disponible}");
            }
        }
    }

    public void CargarCatalogoTexto(string ruta)
    {
        if (!File.Exists(ruta))
        {
            throw new FileNotFoundException($"No se encontro el fichero de catalogo: {ruta}");
        }

        Catalogo.Clear();

        using StreamReader lector = new StreamReader(ruta);
        string? linea;
        while ((linea = lector.ReadLine()) != null)
        {
            string[] campos = linea.Split(';');
            string tipo = campos[0];
            string titulo = campos[1];
            string autor = campos[2];
            bool disponible = bool.Parse(campos[4]);

            MaterialBibliotecario material = tipo switch
            {
                "LIBRO" => new Libro(titulo, autor, campos[3]),
                "REVISTA" => new Revista(titulo, autor, int.Parse(campos[3])),
                _ => throw new InvalidOperationException($"Tipo de material desconocido: {tipo}")
            };

            if (!disponible)
            {
                material.Prestar(); // deja el material marcado como no disponible, igual que estaba al guardarlo
            }

            AgregarMaterial(material);
        }
    }
}

Algunos detalles de este parseo manual merecen explicarse:

  • linea.Split(';') (recordando Arrays y Cadenas de Texto, Módulo 1) trocea cada línea en un string[] usando ; como separador; el orden de los campos debe coincidir exactamente entre GuardarCatalogoTexto (al escribir) y CargarCatalogoTexto (al leer).
  • El switch de expresión (Pattern Matching, Módulo 4) reconstruye el tipo concreto (Libro o Revista) a partir del primer campo, reutilizando sus constructores tal cual se definieron en la lección de Herencia.
  • CargarCatalogoTexto reutiliza AgregarMaterial, ya existente en Biblioteca, en vez de llamar directamente a Catalogo.Add(...): la lógica de alta queda en un único sitio.
  • Este formato es deliberadamente frágil: si un título contuviera un ;, el Split produciría más campos de los esperados y el parseo fallaría. Es exactamente la limitación que motiva la siguiente lección, Serialización, con un formato (JSON) que no tiene este problema.
flowchart LR
    A[Biblioteca.Catalogo en memoria] -->|GuardarCatalogoTexto| B[catalogo.txt]
    B -->|CargarCatalogoTexto| C[Biblioteca.Catalogo reconstruido]

Errores Comunes y Consejos

  • Olvidar using en un StreamReader/StreamWriter: sin él, el fichero puede quedar bloqueado para otros procesos, o los datos escritos pueden no llegar a guardarse en disco si el proceso termina antes de llamar a Close()/Dispose() explícitamente. Usa siempre using, sin excepción, para cualquier tipo que implemente IDisposable.
  • Leer un fichero sin comprobar antes File.Exists: intentar File.ReadAllText sobre un fichero inexistente lanza FileNotFoundException; compruébalo antes, o captura la excepción si prefieres el enfoque "intenta y captura" en vez de "comprueba antes".
  • Confundir File.WriteAllText/WriteAllLines con "añadir al final": ambos sobrescriben el fichero completo si ya existía; usa File.AppendAllText/File.AppendAllLines si la intención es añadir contenido sin perder el anterior.
  • Un separador que puede aparecer dentro de los propios datos: el ; de este formato de texto plano no está pensado para títulos que ya contengan un ;; en un caso real habría que escapar el separador o elegir un formato más robusto (el problema que resuelve la lección siguiente).
  • Consejo: para ficheros pequeños o medianos (la inmensa mayoría de los casos en una aplicación típica), File.ReadAllText/WriteAllText/ReadAllLines/WriteAllLines son más simples y menos propensos a errores que gestionar un StreamReader/StreamWriter a mano; resérvalos para ficheros realmente grandes o para procesar línea a línea sin cargarlo entero.

Ejercicios

  1. Escribe un fragmento de código que use File.WriteAllLines para guardar tres títulos de libro en un fichero titulos.txt, y después File.ReadAllLines para leerlos de vuelta y mostrarlos por consola, uno por línea.

  2. Escribe un método void MostrarFicheroSiExiste(string ruta) que compruebe con File.Exists si el fichero existe: si existe, léelo con File.ReadAllText y muéstralo por consola dentro de un try/catch que capture IOException; si no existe, muestra un mensaje indicándolo, sin intentar leerlo.

  3. Construye GuardarCatalogoTexto y CargarCatalogoTexto en Biblioteca tal como se han presentado en esta lección. Crea una Biblioteca, añade dos libros y una revista, guarda el catálogo en catalogo.txt, crea una segunda instancia de Biblioteca vacía, carga el fichero en ella con CargarCatalogoTexto, y comprueba que Catalogo.Count coincide entre ambas instancias.

Soluciones

string[] titulos = { "Rayuela", "Ficciones", "El Aleph" };
File.WriteAllLines("titulos.txt", titulos);

string[] titulosLeidos = File.ReadAllLines("titulos.txt");
foreach (string titulo in titulosLeidos)
{
    Console.WriteLine(titulo);
}
void MostrarFicheroSiExiste(string ruta)
{
    if (!File.Exists(ruta))
    {
        Console.WriteLine($"El fichero '{ruta}' no existe.");
        return;
    }

    try
    {
        string contenido = File.ReadAllText(ruta);
        Console.WriteLine(contenido);
    }
    catch (IOException ex)
    {
        Console.WriteLine($"Error de E/S al leer el fichero: {ex.Message}");
    }
}
Biblioteca origen = new Biblioteca();
origen.AgregarMaterial(new Libro("Rayuela", "Julio Cortazar", "978-84-376-0495-4"));
origen.AgregarMaterial(new Libro("Ficciones", "Jorge Luis Borges", "978-84-376-0496-1"));
origen.AgregarMaterial(new Revista("National Geographic", "Varios autores", 302));

origen.GuardarCatalogoTexto("catalogo.txt");

Biblioteca destino = new Biblioteca();
destino.CargarCatalogoTexto("catalogo.txt");

Console.WriteLine(origen.Catalogo.Count);  // 3
Console.WriteLine(destino.Catalogo.Count); // 3

Conclusión

En esta lección has aprendido a trabajar con ficheros usando System.IO: comprobar su existencia con File.Exists, leer y escribir de golpe con File.ReadAllText/WriteAllText y ReadAllLines/WriteAllLines, procesar ficheros grandes línea a línea con StreamReader/StreamWriter, liberar recursos correctamente con using, y manejar las excepciones propias de la E/S. Biblioteca ya puede guardar y recuperar su catálogo completo entre ejecuciones, con GuardarCatalogoTexto y CargarCatalogoTexto.

El formato de texto plano con ; como separador, sin embargo, es frágil: no soporta bien títulos con caracteres especiales, obliga a un parseo manual propenso a errores, y no escala bien a estructuras de datos más complejas (como préstamos, que enlazan un libro con un socio). La siguiente lección, Serialización, sustituye este parseo manual por System.Text.Json, un formato estándar que resuelve estos problemas de raíz y que reutilizará, precisamente, los ficheros que ya sabes leer y escribir gracias a esta lección.

Curso de Programación en C#

Módulo 1: Introducción a C#

Módulo 2: Estructuras de Control

Módulo 3: Programación Orientada a Objetos

Módulo 4: Conceptos Avanzados de C#

Módulo 5: Trabajando con Datos

Módulo 6: Temas Avanzados

Módulo 7: Construcción de Aplicaciones

Módulo 8: Mejores Prácticas y Patrones de Diseño

Módulo 9: Proyecto Final

© Copyright 2026. Todos los derechos reservados