I/O su file

19 marzo 2018

Le operazioni di I/O su file nello standard C++ sono demandate ad alcuni componenti specifici della Standard I/O Streams Library, definiti nello header file <fstream>, all’interno del namespace std.

Le classi coinvolte condividono lo stesso data model che caratterizza le entità preposte alle operazioni di I/O su riga di comando. Esse si basano quindi sul medesimo concetto di canale che abbiamo già incontrato in precedenza, con la sostanziale differenza che la transitabilità del canale è estesa al caso bidirezionale, cioè è possibile svolgere operazioni di lettura e scrittura sullo stesso dispositivo che quindi può assolvere entrambi i ruoli di sorgente e destinazione.

Inoltre, nella gestione delle operazioni di I/O su file entra in gioco una nuova componente nella definizione dello stato del canale; essa è la posizione corrente, intesa come numero di byte a partire da un punto di riferimento relativo al file stesso (l’inizio, la fine o la posizione antecedente l’ultima operazione di I/O).

Le classi sulle quali si modella l’interazione con il file system sono ofstream, per le operazioni di sola scrittura, ifstream per quelle di sola lettura, e fstream per entrambe.

Le API di queste classi sono simili a quelle già viste per l’interazione con i canali di I/O standard per la riga di comando, appartenendo di fatto a tipi “compatibili”, in senso gerarchico. Ritroviamo quindi molte delle API viste in precedenza più altre specifiche per la gestione di un file relative all’apertura, il posizionamento, la chiusura, e le operazioni su blocchi di dati intesi come array di char.

Si ha inoltre una certa ridondanza nel modo in cui è possibile implementare le operazioni di I/O. Tuttavia, vedremo che ciò è funzionale al particolare tipo di file dal quale dobbiamo attingere o riversare informazioni.

Tipi di file

Volendo approcciarsi al problema dell’I/O ad un bassissimo livello, esistono di fatto due soli tipi di file: quelli con contenuto testuale e quelli binari, cioè composti da una sequenza di bit non interpretabile mediante una tabella dei caratteri, indifferentemente dalla codifica.

I primi possono essere scissi in due ulteriori categorie, cioè file di testo semplice e file di testo con struttura ad esempio quelli in formato XML, JSON o CSV (comma separated values), che a parte dati testuali in senso stretto, contengono anche metadati che concorrono a definirne i criteri di interpretazione.

I file binari, invece, sono sempre dotati di una struttura, tipicamente fatta da record a lunghezza fissa o variabile, che consente di interpretare correttamente i dati al loro interno.

Sebbene molto spesso possiamo integrare nella nostra applicazione librerie appositamente progettate per la lettura e la scrittura di formati specifici, è anche possibile imbattersi nella necessità di definire un nuovo formato per la gestione di dati nel contesto di un’applicazione specifica. In questo caso, la corretta individuazione del tipo di file e del modello di interazione è rilevante ai fini delle prestazioni delle operazioni di I/O.

Nel seguito esamineremo alcuni casi d’uso per illustrare le varie API disponibili ed il loro impiego.

Lettura e scrittura riga per riga

Uno dei casi più comuni è la lettura/scrittura di un file di testo riga per riga. Nel listato seguente si usa la classe std::ifstream per scandire il file test.txt e riversarne il contenuto nel file test-out.txt aggiungendo come prefisso il numero di ogni riga, ricorrendo all’uso della classe std::ofstream.

#include <fstream>
#include <iostream>
#include <string>
#include <iomanip>

int main() 
{    
	std::ifstream iFile("test.txt");
	
	if (iFile.is_open())
	{
		std::ofstream oFile("test-out.txt", std::ios_base::out | std::ios_base::trunc);

		if (oFile.is_open())
		{
			std::string line; 
			int row = 0;
			
			while (getline(iFile, line) && oFile.good())
			{
				oFile << "#" << std::right << std::setw(5) << std::setfill('0') << row++ 
					  << "\t" << line << "\n"; 
			}
					
			oFile.close();
		}
	
		iFile.close();
	}
	
	return 0;
}

L’oggetto iFile è istanziato passando come argomento al suo costruttore il nome di un file di testo. Se il nome fornito per il file non contiene il percorso assoluto, ma è relativo, come in questo caso, esso verrà cercato all’interno della cartella che contiene l’eseguibile del nostro programma e se esiste, sarà aperto in modalità di sola lettura. La stessa cosa avviene con l’istanza oFile per l’apertura del file di output. In questo caso, però, poichè stiamo operando in scrittura, se il file non esiste viene creato al momento.

Per l’istanziazione di oFile si è usato un sovraccarico del costruttore che accetta come secondo parametro una bitmask che descrive la modalità di apertura del file. In questo caso stiamo specificando che esso deve essere aperto in sola scrittura (std::ios_base::out) e che eventuali contenuti, se presenti, saranno sovrascritti (std::ios_base::trunc, truncate).

Le altre modalità sono in per la lettura, app (append) per aggiungere i nuovi contenuti in coda senza sovrascrivere quelli già presenti, binary per indicare che stiamo operando su dati binari e non testuali, e ate (at the end) che discuteremo meglio nel seguito.

Per verificare che entrambi i file siano aperti correttamente si invoca il metodo is_open() su entrambi i canali, e si procede quindi con un ciclo che alterna rispettivamente le operazioni di lettura e scrittura su entrambi, previa valutazione dello stato dei due canali.

La funzione getline(), i cui parametri sono l’oggetto iFile stesso ed una stringa che verrà usata come variabile contenitore per il contenuto di ogni riga, restituisce un riferimento allo stesso stream che è passato come argomento, iFile. Tuttavia esso viene valutato come valore boolenano poichè le classi stream includono la definizione di un particolare operatore di conversione da stream a bool, la cui implementazione consiste nel controllare lo stato di un oggetto stream e restituire l’informazione relativa al suo stato, condensata in forma booleana. Il metodo good(), applicato a oFile, ha la stessa funzione e consente di verificare anche lo stato di quest’ultimo.

Per un controllo più fine sugli errori possiamo usare i metodi eof(), fail() e bad() che riportano rispettivamente il raggiungimento della fine del file (end of file), o la presenza di errori che rendono inconsistenti i dati estratti o inseriti nel canale.

Il corpo del ciclo è costituito dall’inserimento nel file di destinazione di dati formattati mediante l’uso dell’operatore di inserimento << e di alcuni manipolatori.

Ogni operazione di lettura aggiorna la posizione corrente del canale riportandola al byte immediatamente successivo all’ultimo che è stato letto, rendendo possibile scandire l’intero contenuto del file con più letture successive. La stessa cosa avviene per le operazioni di lettura, che di volta in volta incrementano la dimensione del file, aggiornano la posizione del canale a quella successiva all’ultimo byte inserito.

L’invocazione del metodo close(), al termine delle operazioni di I/O consente di rilasciare correttamente tutte le risorse associate ad entrambi i file, e nel caso della classe std::ofstream, comporta l’esecuzione del metodo flush() per riversare il contenuto del canale prima della chiusura vera e propria del file di output.

Lettura e scrittura carattere per carattere

Un’altra modalità di accesso consiste nello scandire e processare un file carattere per carattere. Nel listato seguente, ad esempio, il contenuto del file di testo test.txt viene epurato da tutti i caratteri di spaziatura in eccesso e riversato sul un altro file di nome clean.txt.

#include <fstream>
#include <iostream>
#include <cctype>

int main() 
{    
	std::ifstream iFile("test.txt");
	int read = 0;
	int written = 0;
	
	if (iFile.is_open())
	{
		std::ofstream oFile("clean.txt");
		
		if (oFile.is_open())
		{
			char c = '\0';
			bool isFirstSpace = true;
			
			while (iFile.get(c) && oFile.good())
			{
				read++;
							
				bool isSpace = isspace(c); // definita in <cctype>
				
				if (!isSpace || (isSpace && isFirstSpace))
				{
					if (oFile.put(c)) written++;
					
					// Se abbiamo già trovato uno spazio, il prossimo sarà ignorato. 
					// Altrimenti siamo di nuovo alla ricerca del primo spazio.
					isFirstSpace = !isSpace;
				}                    
			}
			
			oFile.close();
		}
		
		iFile.close();
		
		std::cout << "Caratteri rimossi: " << read - written << std::endl; 
	}     
	
	return 0;
}

In questo caso, dopo aver verificato la corretta apertura di entrambi i file sorgente e destinazione, procediamo alla scansione del file sorgente, carattere per carattere. Il metodo get(), similarmente alla funzione getline(), restituisce un riferimento a iFile, convertibile in valore booleano. Allo stesso modo, il metodo good(), applicato a oFile, consente di verificare anche lo stato di quest’ultimo.

La funzione isspace() verifica che il carattere letto sia uno spazio (incluso il caso di tabulazioni e andate a capo). Il metodo put() riversa il carattere letto nel file di destinazione, in questo caso solo se esso non è immediatamente successivo ad altri caratteri di spaziatura. Il riferimento a oFile da esso restituito viene usato per aggiornare il contatore dei caratteri scritti, solo nel caso in cui non si siano verificati errori.

Si procede infine con la chiusura dei due file ed il rilascio delle risorse associate. Se non si sono verificati errori, il conteggio dei caratteri rimossi corrisponde a quello dei caratteri di spaziatura in eccesso nel file destinazione.

Lettura e scrittura a blocchi simultanea e ad accesso casuale

Capita a volte di dover effettuare operazioni di scrittura e lettura simultaneamente sullo stesso file. In questo caso possiamo usare un’unica istanza della classe std::ifstream che, in quanto derivata da entrambe std:ifstream e std::ofstream, dispone di entrambe le fuzionalità di input e output.

Nel listato seguente il contenuto del file di testo test.txt viene letto a blocchi di massimo 1024 byte, convertito secondo il cifrario di Cesare, e aggiunto in coda allo stesso file, alternando le operazioni di lettura e scrittura.

#include <fstream>
#include <iostream>
#include <cctype>

int main() 
{    
	std::fstream ioFile("test.txt", std::fstream::in | std::fstream::out | std::ios_base::ate);
	int caesarShift = 23;    
	
	if (ioFile.is_open())
	{
		// dimensione del file in bytes
		std::uint64_t sizeInBytes = ioFile.tellg();
		std::uint64_t read = 0;

		while (read < sizeInBytes)
		{            
			std::uint64_t remaining = sizeInBytes - read;
			int bufferSize = (remaining < 1024) ? remaining : 1024;
			char buffer[bufferSize];

			// muove il cursore per la lettura e legge
			ioFile.seekg(read, std::ios_base::beg); 
			ioFile.read(buffer, bufferSize);
			read += bufferSize;
			
			if (ioFile.good())
			{                
				// cifrario di Cesare con chiave pari a 23
				for (int i=0; i<bufferSize; i++)
				{
					char c = buffer[i];
					if (isalpha(c))
					{
						char a = isupper(c) ? 'A' : 'a'; 
						buffer[i] = (c + caesarShift - a + 26) % 26 + a;
					}
				}
				
				// muove il cursore per la scrittura e scrive
				ioFile.seekp(0, std::ios_base::end); 
				ioFile.write(buffer, bufferSize);
			}
		}
		
		ioFile.close();
	}
	
	return 0;
}

Per conoscere la dimensione del file prima di processarne il contenuto, esso è aperto posizionando il cursore alla fine del file con l’opzione std::ios_base::ate (at the end) e invocando il metodo tellg() che consente di conoscere la posizione del cursore per la lettura. Si prosegue con un ciclo all’interno del quale si susseguono la lettura a partire dall’inizio del file, la cifratura del blocco corrente e la scrittura in coda. La condizione di uscita dal ciclo controlla che non si sia superata la dimensione iniziale del file.

La classe std::fstream deriva rispettivamente da std::ifstream e std::ofstream, le funzionalità per la gestione della posizione corrente sia per la lettura che per la scrittura. Tali posizioni sono distinte e indipendenti, consentendo quindi di leggere e scrivere allo stesso tempo da posizioni differenti dello stesso file.

Per posizionare i cursori di lettura e scrittura si usano rispettivamente i metodi seekg() e seekp(), dove ‘g’ sta per get e ‘p’ sta per put. Per conoscerne la posizione invece si usano i metodi tellg() e tellp(). Per leggere e scrivere blocchi di dati dalla dimensione variabile si usano i metodi read() e write() che accettano come parametri un array di char e la sua estensione.

Per l’implementazione della codifica si fa ricorso alle funzioni isalpha(), per determinare che il carattere sia alfabetico, e isupper() per determinare il corretto offset all’interno della tabella dei caratteri che corrisponde al carattere iniziale dell’alfabeto per lettere maiuscole o minuscole.

Ottimizzazioni e performance

Come abbiamo visto, esistono molteplici possibilità per implementare le operazioni di I/O su file, che possono risultare più o meno convenienti a seconda dei casi. Dal punto di vista della leggibilità del codice, l’uso degli operatori di inserimento ed estrazione può risultare accattivante. Tuttavia, quando la mole dei dati è significativa, il ricorso a metodi di I/O che operano su blocchi di dati è sicuramente più performante.

Inoltre, le prestazioni relative all’I/O su file dipendono anche da altri fattori, quali il sistema operativo, il file system, e il tipo di dispositivo di archiviazione.

La politica di gestione della cache del disco, ad esempio può migliorare le prestazioni su file che vengono acceduti con una certa frequenza. Inoltre, anche il sistema operativo può adottare una propria politica di caching dei dati che consente di limitare l’I/O su dispositivi, mantenendo i dati più utilizzati in RAM.

Infine, anche il modo in cui gestiamo l’I/O a blocchi ha la sua rilevanza. Infatti l’uso di buffer molto piccoli, ha il vantaggio di occupare poco spazio in RAM, ma richiede un maggiore numero di accessi, cioè di richieste al sistema operativo. Dobbiamo quindi sempre considerare che il nostro codice può esibire prestazioni differenti a seconda della piattaforma e delle caratteristiche hardware del sistema, e adattarlo al caso d’uso più frequente.

Tutte le lezioni

1 ... 50 51 52 ... 71

Se vuoi aggiornamenti su I/O su file inserisci la tua e-mail nel box qui sotto:
Tags:
 
X
Se vuoi aggiornamenti su I/O su file

inserisci la tua e-mail nel box qui sotto:

Ho letto e acconsento l'informativa sulla privacy

Acconsento al trattamento di cui al punto 3 dell'informativa sulla privacy