Lavorare con i file e l'input/output in C#

Lavorare con i file e l'input/output in C#

Prima o poi ogni applicazione deve interagire con il mondo esterno: leggere un file di configurazione, salvare i dati di un utente, esportare un report. Il namespace System.IO della libreria standard fornisce tutto il necessario per lavorare con file e directory. In questo articolo, che conclude la serie intermedia, vediamo come leggere e scrivere file, come gestire i percorsi e le cartelle e come applicare le tecniche di gestione delle risorse e asincronia apprese in precedenza.

Leggere e scrivere testo in modo semplice

Per le operazioni più comuni, la classe statica File offre metodi pronti all'uso che leggono o scrivono l'intero contenuto di un file in una sola chiamata. Sono la scelta ideale per file di piccole dimensioni, perché caricano tutto in memoria.

using System.IO;

// Scrittura dell'intero contenuto, sovrascrivendo il file
File.WriteAllText("note.txt", "Prima riga\nSeconda riga");

// Lettura dell'intero contenuto in una stringa
string content = File.ReadAllText("note.txt");
Console.WriteLine(content);

// Lettura riga per riga in un array
string[] lines = File.ReadAllLines("note.txt");
Console.WriteLine($"Il file ha {lines.Length} righe");

Per aggiungere testo in coda a un file esistente senza cancellarne il contenuto si usa File.AppendAllText, mentre per scrivere una sequenza di righe da una collezione è disponibile File.WriteAllLines.

Verificare, copiare ed eliminare file

La classe File fornisce anche metodi per gestire l'esistenza e il ciclo di vita dei file. Verificare la presenza di un file prima di operarci è una precauzione utile per evitare eccezioni.

if (File.Exists("note.txt"))
{
    File.Copy("note.txt", "backup.txt", overwrite: true);
    File.Delete("note.txt");
}

Gestire i percorsi

Costruire percorsi concatenando manualmente le stringhe è fragile, perché i separatori di cartella differiscono tra sistemi operativi. La classe Path compone e analizza i percorsi in modo indipendente dalla piattaforma, garantendo portabilità.

// Combina le parti usando il separatore corretto per il sistema
string fullPath = Path.Combine("documenti", "report", "2026.txt");

// Estrae le componenti di un percorso
string fileName = Path.GetFileName(fullPath);        // "2026.txt"
string extension = Path.GetExtension(fullPath);      // ".txt"
string directory = Path.GetDirectoryName(fullPath);  // "documenti/report"

Lavorare con le directory

In modo speculare alla classe File, la classe Directory gestisce le cartelle: consente di crearle, verificarne l'esistenza ed elencarne il contenuto. Questo è indispensabile quando un'applicazione organizza i propri dati in una struttura di cartelle.

// Crea la cartella se non esiste (non fa nulla se e gia presente)
Directory.CreateDirectory("output");

// Elenca tutti i file con una determinata estensione
string[] textFiles = Directory.GetFiles("documenti", "*.txt");
foreach (string file in textFiles)
{
    Console.WriteLine(file);
}

Gli stream e i reader

I metodi visti finora caricano l'intero file in memoria, il che non è adatto a file molto grandi. Per un controllo più fine si usano gli stream, astrazioni che rappresentano un flusso di dati leggibile o scrivibile in modo incrementale. Le classi StreamReader e StreamWriter lavorano sopra gli stream per elaborare il testo riga per riga.

// Lettura riga per riga senza caricare tutto in memoria
using (var reader = new StreamReader("grande.txt"))
{
    string? line;
    while ((line = reader.ReadLine()) != null)
    {
        Console.WriteLine(line);
    }
}

Nell'esempio, l'istruzione using merita attenzione: garantisce che lo stream venga chiuso e le risorse rilasciate automaticamente al termine del blocco, anche in caso di eccezione. È l'applicazione diretta del principio di pulizia visto con il blocco finally, ma in forma più concisa. Esiste anche la variante senza parentesi, la dichiarazione using, che rilascia la risorsa alla fine del metodo o del blocco contenitore.

// Scrittura incrementale con StreamWriter
using var writer = new StreamWriter("log.txt", append: true);
writer.WriteLine($"Evento registrato alle {DateTime.Now}");
// Lo stream viene chiuso automaticamente alla fine del blocco

Input e output asincroni

Le operazioni su file sono per natura lente, poiché comportano l'accesso al disco. Applicando quanto appreso sull'asincronia, possiamo eseguirle senza bloccare il thread usando le versioni asincrone dei metodi, che restituiscono un Task e si attendono con await.

static async Task ProcessFileAsync(string path)
{
    // Lettura asincrona dell'intero contenuto
    string content = await File.ReadAllTextAsync(path);

    string processed = content.ToUpper();

    // Scrittura asincrona del risultato
    await File.WriteAllTextAsync("output.txt", processed);

    Console.WriteLine("Elaborazione completata");
}

Gestire gli errori di input/output

Le operazioni sui file possono fallire per molte ragioni indipendenti dal programma: un file inesistente, permessi insufficienti, un disco pieno. È quindi buona pratica racchiuderle in blocchi try-catch che intercettino le eccezioni specifiche del namespace, come IOException e le sue derivate.

try
{
    string data = File.ReadAllText("configurazione.txt");
    Console.WriteLine(data);
}
catch (FileNotFoundException)
{
    Console.WriteLine("Il file di configurazione non esiste");
}
catch (UnauthorizedAccessException)
{
    Console.WriteLine("Permessi insufficienti per leggere il file");
}
catch (IOException ex)
{
    Console.WriteLine($"Errore di input/output: {ex.Message}");
}

Conclusione

Abbiamo imparato a interagire con il file system usando System.IO: le operazioni immediate della classe File, la composizione portabile dei percorsi con Path, la gestione delle cartelle con Directory e l'elaborazione incrementale tramite stream, StreamReader e StreamWriter. Abbiamo applicato l'istruzione using per il rilascio automatico delle risorse, le versioni asincrone per non bloccare il thread e la gestione mirata delle eccezioni di input/output.

Con questo articolo si chiude anche la serie intermedia. Partendo dalle eccezioni, abbiamo attraversato generics, delegati ed eventi, LINQ, programmazione asincrona, i tipi valore e infine l'input/output. Insieme alla prima serie introduttiva, questi quattordici articoli offrono una base ampia e solida su C#, da cui muovere verso argomenti più avanzati come i framework applicativi, l'accesso ai dati e l'architettura del software.