Salta ai contenuti

Leggere e scrivere file in Rust

Leggere e scrivere file è uno dei primi momenti in cui tutto quello che hai imparato su Result e sull’operatore ? smette di essere teoria: quasi ogni operazione sui file può fallire (il file potrebbe non esistere, potresti non avere i permessi), quindi la libreria standard ti obbliga a gestirlo in modo diretto.

Il modo più semplice per leggere il contenuto di un file in una String:

use std::fs;
fn main() {
let contents = fs::read_to_string("hello.txt");
match contents {
Ok(text) => println!("{text}"),
Err(e) => println!("Impossibile leggere il file: {e}"),
}
}

fs::read_to_string restituisce un Result<String, std::io::Error>: Ok con il testo se ha funzionato, Err con un motivo se non ha funzionato (file mancante, permessi negati e così via).

Fare match su ogni operazione sui file diventa presto ripetitivo. Se sei dentro una funzione che a sua volta restituisce un Result, l’operatore ? (della pagina su Result) mette molto ordine:

use std::fs;
use std::io;
fn read_file(path: &str) -> Result<String, io::Error> {
let contents = fs::read_to_string(path)?; // in caso di errore esce subito
Ok(contents)
}
fn main() {
match read_file("hello.txt") {
Ok(text) => println!("{text}"),
Err(e) => println!("Errore: {e}"),
}
}
use std::fs;
fn main() {
let result = fs::write("output.txt", "Ciao, file!");
match result {
Ok(()) => println!("Scrittura riuscita"),
Err(e) => println!("Scrittura fallita: {e}"),
}
}

fs::write crea il file se non esiste, e lo sovrascrive completamente se esiste già: tienilo a mente se devi aggiungere qualcosa a un file invece di sostituirlo.

Per aggiungere a un file esistente invece di sovrascriverlo, usa OpenOptions:

use std::fs::OpenOptions;
use std::io::Write;
fn main() -> std::io::Result<()> {
let mut file = OpenOptions::new()
.append(true)
.create(true)
.open("log.txt")?;
writeln!(file, "Una nuova riga di log")?;
Ok(())
}

Nota che qui è main stesso a restituire un Result: è uno schema che vale la pena conoscere. Quando main restituisce un Result, puoi usare ? direttamente dentro main invece di avvolgere tutto in un match.

Con un file grande, leggerlo tutto in memoria in un colpo non è sempre l’ideale. Ecco come leggerlo invece riga per riga:

use std::fs::File;
use std::io::{BufRead, BufReader};
fn main() -> std::io::Result<()> {
let file = File::open("hello.txt")?;
let reader = BufReader::new(file);
for line in reader.lines() {
println!("{}", line?);
}
Ok(())
}

Finora ogni errore è stato gestito allo stesso modo: stampandolo. Ma spesso vuoi reagire in modo diverso a seconda di che cosa è andato storto. Un file mancante di solito non è un problema (magari è la prima esecuzione, e parti semplicemente da zero). «Permesso negato» invece è un problema vero, da segnalare.

Un io::Error può dirti la sua categoria con .kind(), che restituisce un enum ErrorKind (documentazione in inglese) su cui puoi fare match:

use std::fs;
use std::io::ErrorKind;
fn main() {
// un file nella cartella temporanea del sistema che non esiste
let path = std::env::temp_dir().join("rusttutorial-missing-notes.txt");
match fs::read_to_string(&path) {
Ok(text) => println!("Caricate {} righe", text.lines().count()),
Err(e) => match e.kind() {
ErrorKind::NotFound => println!("Ancora nessuna nota, si parte da zero"),
ErrorKind::PermissionDenied => println!("Non hai il permesso di leggere il file delle note"),
_ => println!("Qualcos'altro è andato storto: {e}"),
},
}
}

Output:

Ancora nessuna nota, si parte da zero
▶ Provalo tu

std::env::temp_dir() ti dà il percorso della cartella temporanea del sistema (come /tmp su Linux), e .join(...) ci aggiunge il nome di un file. Le funzioni sui file accettano un percorso come questo esattamente come accettano un &str.

Tieni sempre un ramo _ alla fine: ErrorKind ha molte varianti, e col tempo se ne aggiungono di nuove. Il ramo _ prende tutto quello che non hai elencato.

Entrambi gli esercizi scrivono prima il proprio file nella cartella temporanea, quindi funzionano ovunque, anche nel Playground.

Scrivi tre cose da fare in un file con fs::write, una per riga. Poi rileggi il file con fs::read_to_string e stampa ogni riga con un numero davanti, seguita dal totale.

Suggerimento: metti \n tra gli elementi per avere righe separate. text.lines() ti dà le righe una alla volta. Fai restituire a main un std::io::Result<()>, così puoi usare ?.

Mostra la soluzione
use std::fs;
fn main() -> std::io::Result<()> {
let path = std::env::temp_dir().join("rusttutorial-todo.txt");
fs::write(&path, "imparare l'ownership\nscrivere un modulo\nleggere un file\n")?;
let text = fs::read_to_string(&path)?;
let mut count = 0;
for line in text.lines() {
count += 1;
println!("{count}. {line}");
}
println!("{count} elementi");
Ok(())
}

Output:

1. imparare l'ownership
2. scrivere un modulo
3. leggere un file
3 elementi
▶ Provalo tu

Visto che fs::write sostituisce tutto il file, ottieni le stesse tre righe per quante volte tu lo esegua. Ogni ? fa restituire l’errore da main se la scrittura o la lettura falliscono.

Crea da zero un file di log con una riga di intestazione, poi aggiungi altre due righe con OpenOptions e writeln!. Infine rileggi tutto il file e stampalo.

Suggerimento: usa fs::write per l’intestazione (così ogni esecuzione parte pulita), poi OpenOptions::new().append(true).open(&path)? per le righe in più. print! (non println!) evita una riga vuota in più alla fine, perché il file finisce già con un a capo.

Mostra la soluzione
use std::fs::{self, OpenOptions};
use std::io::Write;
fn main() -> std::io::Result<()> {
let path = std::env::temp_dir().join("rusttutorial-log.txt");
fs::write(&path, "=== log ===\n")?; // si riparte da zero
let mut file = OpenOptions::new().append(true).open(&path)?;
writeln!(file, "avviato")?;
writeln!(file, "terminato")?;
print!("{}", fs::read_to_string(&path)?);
Ok(())
}

Output:

=== log ===
avviato
terminato
▶ Provalo tu

use std::fs::{self, OpenOptions}; porta nello scope sia il modulo fs stesso sia OpenOptions. Senza il primo fs::write, ogni esecuzione continuerebbe ad aggiungere righe al file vecchio, e il log crescerebbe all’infinito. È esattamente quello che vuoi per un log vero, ma non per un esercizio con un output fisso.

  • fs::read_to_string(percorso) legge un file intero in una String e restituisce un Result.
  • fs::write(percorso, contenuto) scrive un file, sovrascrivendolo se esiste già.
  • OpenOptions::new().append(true) ti permette di aggiungere a un file invece di sostituirlo.
  • main() -> std::io::Result<()> ti permette di usare ? direttamente in main, invece di fare match su ogni chiamata.
  • e.kind() ti dice che tipo di errore è successo, come ErrorKind::NotFound, così puoi gestire ciascuno in modo diverso.
  • Gli esempi che usano file nella cartella del progetto conviene provarli in locale; std::env::temp_dir() ti dà una cartella che funziona ovunque.

Verifica veloce

1. Che cosa succede se chiami fs::write su un percorso che esiste già?

2. Come aggiungi qualcosa a un file invece di sovrascriverlo?

3. Che cosa ti permette di fare un main che restituisce std::io::Result<()>?

4. Che cosa fa l'operatore ? quando fs::read_to_string(path)? fallisce?

5. Perché leggere un file grande riga per riga con BufReader invece che con fs::read_to_string?

Punteggio: 0 / 5