Comment manipuler des fichiers en C++

Le langage C++, comme la plupart des langages de programmation contemporains, permet de lire et d'écrire de et sur des flux de données. Vous trouverez d'ailleurs d'autres rubriques Au secours Pat! portant sur la manipulation de flux d'entrée/ sortie si le présent document ne vous donne pas satisfaction.

Parmi les flux possibles, les fichiers occupent une place particulière. On les utilise à plusieurs sauces, en particulier lorsque le souci d'automatisation des paramètres à un programme se fait sentir ou lorsqu'on a recours à une forme de support permanent sans nécessairement vouloir avoir recours à une base de données.

En situation de développement 3D ou avec contraintes de performance serrées, l'emploi d'un dévermineur n'est pas toujours une option. Ainsi, pouvoir laisser rapidement et efficacement une trace dans un fichier des variables pertinentes à l'analyse de l'exécution d'un processus peut alors constituer l'une des meilleures pratiques envisageables.

Le présent document offre des exemples de plusieurs sortes :

Dans cet article, j'ouvrirai des fichiers en utilisant une std::string directement pour représenter son nom. Notez que cela est légal en C++ 11 mais pas en C++ 03. Si votre compilateur ne supporte pas encore C++ 11, remplacez

ofstream{nom};

par

ofstream(nom.c_str());

pour utiliser le const char* dans la std::string et le tout fonctionnera normalement.

Copier tous les mots d'un fichier texte à un autre

Un programme copiant tous les mots d'un fichier texte nommé "in.txt" dans un autre fichier texte nommé "out.txt" serait:

#include <fstream>
#include <string>
int main() {
   using namespace std;
   ifstream ifs{"in.txt"};
   ofstream ofs{"out.txt"};
   //
   // la lecture d'une std::string dans un flux avec >> s'arrête au premier
   // caractère d'espacement; cela explique pourquoi on insère un espace
   // « artificiel » entre chaque mot en sortie (cela sépare les mots dans
   // le fichier de destination).
   //
   for (string s; ifs >> s; )
      ofs << s << ' ';
}

Ce programme remplacera le contenu du fichier "out.txt" si ce fichier existait déjà. Il s'arrêtera lorsque la lecture dans le fichier source aura échoué, ce qui se produira normalement lorsqu'on aura rencontré la fin du fichier.

Il ne s'agit pas d'un bon programme si on veut conserver le format complet du texte original puisque tous les caractères d'espacement originaux disparaissent en chemin, remplacés en sortie par des espaces génériques. Il est donc probable que les tailles des fichiers en entrée et en sortie soient différentes suite à l'exécution du programme.

Pour être orthodoxe, en milieu de production, on pourrait vérifier si l'ouverture du fichier en écriture a fonctionné en insérant un test sur l'échec du flux en écriture :

#include <fstream>
#include <string>
int main() {
   using namespace std;
   ifstream ifs{"in.txt"};
   ofstream ofs{"out.txt"};
   if (ofs)
      for (string s; ifs >> s; )
         ofs << s << ' ';
}

Autre solution, en apparence plus rigoureuse encore mais un peu plus lente car elle teste l'état du flux en sortie à chaque itération de la répétitive (ce qui peut être une bonne chose si on suspecte le média derrière le flux de ne pas être stable) :

#include <fstream>
#include <string>
int main() {
   using namespace std;
   ifstream ifs{"in.txt"};
   ofstream ofs{"out.txt"};
   for (string s; ofs && ifs >> s; )
      ofs << s << ' ';
}

En pratique, la version ne procédant qu'à un seul test initial est meilleure que celle testant le flux à chaque itération, du fait qu'une tentative de lecture sur un flux incorrect met le flux dans un état d'invalidité et fait en sorte que les tests sur ce flux échouent (le flux se comporte comme un booléen de valeur false).

Une solution plus performante et plus compacte reposerait sur des itérateurs de flux. Une écriture possible serait ce qui suit (pour comprendre les parenthèses autour du deuxième paramètre, voir cette note) :

#include <fstream>
#include <string>
#include <iterator>
#include <algorithm>
int main() {
   using namespace std;
   ifstream ifs{"in.txt"};
   ofstream ofs{"out.txt"};
   copy(istream_iterator<string>{ifs},
        istream_iterator<string>{},
        ostream_iterator<string>{ofs, " "});
}

L'exemple précédent peut être (avantageusement!) réduit à ce qui suit (pour comprendre les parenthèses autour du deuxième paramètre, voir cette note) :

#include <fstream>
#include <string>
#include <iterator>
#include <algorithm>
int main() {
   using namespace std;
   copy(istream_iterator<string>{ifstream{"in.txt"}},
        istream_iterator<string>{},
        ostream_iterator<string>{ofstream{"out.txt"}, " "});
}

Cette forme, vous l'aurez remarqué, est tellement concise qu'elle s'exprime en une seule instruction, où aucune variable nommée n'apparaît. Cette version :

Copier tout le texte (ligne par ligne) d'un fichier texte à un autre

Un programme copiant le texte tout entier (en conservant tous les caractères d'espacement) d'un fichier texte nommé "in.txt" dans un autre fichier texte nommé "out.txt" serait :

#include <fstream>
#include <string>
#include <iostream>
int main() {
   using namespace std;
   ifstream ifs{"in.txt"};
   ofstream ofs{"out.txt"};
   string s;
   //
   // la lecture d'une std::string dans un flux avec std::getline() s'arrête au premier
   // changement de ligne, mais ne conserve pas ce caractère dans la chaîne lue; cela
   // explique pourquoi on insère changement de ligne «artificiel» entre chaque
   // chaîne en sortie.
   //
   while (getline(ifs, s))
      ofs << s << endl;
}

Un correctif à ce léger irritant est possible mais est laissé en exercice (il faut lire la documentation sur std::getline() pour résoudre ce problème élégamment).

Les amatrices et les amateurs de forme compacte privilégieront peut être :

#include <fstream>
#include <string>
#include <iostream>
int main() {
   using namespace std;
   ifstream ifs{"in.txt"};
   ofstream ofs{"out.txt"};
   for (string s; getline(ifs, s); )
      ofs << s << endl;
}

Présumant que la source soit un fichier texte, la seule différence possible entre le fichier en entrée et le fichier en sortie se produira si la dernière ligne du fichier en entrée ne se termine pas par un changement de ligne (car notre version insère un changement de ligne à la fin de chaque ligne lue, et ce sans discrimination aucune).

Encore une fois, une solution rigoureuse vérifierait l'état du flux en sortie avant d'y entreprendre une écriture.

Copier tous les bytes d'un fichier arbitraire à un autre

Un programme copiant le contenu tout entier d'un fichier arbitraire (une image, un bout de musique, un exécutable, peu importe) nommé "in.xyz" dans un autre fichier texte nommé "out.xyz" serait :

#include <fstream>
int main() {
   using namespace std;
   ifstream ifs{"in.xyz",  ios::binary};
   ofstream ofs{"out.xyz", ios::binary};
   //
   // on lit et on écrit un octet à la fois, mais c'est très rapide car les flux standard
   // en entrée et en sortie procèdent à des accès optimisés au média sous-jacent (accès
   // en bloc et une antémémoire pour les données disponibles).
   //
   char c;
   while (ifs.get(c))
      ofs.put(c);
}

Les amatrices et les amateurs de forme compacte privilégieront peut être :

#include <fstream>
int main() {
   using namespace std;
   ifstream ifs{"in.xyz",  ios::binary};
   ofstream ofs{"out.xyz", ios::binary};
   for (char c; ifs.get(c); ofs.put(c))
      ;
}

On peut insérer une validation du flux en sortie, encore une fois.

Plus vite

Lorsque aucun traitement n'est requis sur les bytes consommés du flux source, il est possible d'utiliser des itérateurs sur des bytes bruts, représentés par les types istreambuf_iterator<char> et ostreambuf_iterator<char>. On aurait alors ceci, qui est très rapide – le plus rapide, à ma connaissance, pour qui souhaite une solution portable et officiellement supportée par le standard (pour comprendre les parenthèses autour du deuxième paramètre, voir cette note) :

#include <iterator>
#include <fstream>
#include <algorithm>
int main() {
   using namespace std;
   copy(istreambuf_iterator<char>{ifstream{"in.xyz",  ios::binary}},
        istreambuf_iterator<char>{},
        ostreambuf_iterator<char>{ofstream{"out.xyz", ios::binary}});
}

Plus vite encore

Si vous ne voulez qu'une copie brute du contenu d'un fichier, ceci est encore plus efficace (mais, comme Nicolai M. Josuttis le fait remarquer dans The C++ Standard Library, peut ne pas être portable) :

#include <fstream>
int main() {
   using namespace std;
   ifstream ifs{"in.xyz",  ios::binary};
   ofstream ofs{"out.xyz", ios::binary};
   ofs << ifs.rdbuf();
}

Pour une écriture plus compacte encore (mais moins lisible, alors privilégiez celle ci-dessus plutôt que celle ci-dessous) :

#include <fstream>
int main() {
   using namespace std;
   ofstream{"out.xyz", ios::binary} << ifstream{"in.xyz", ios::binary}.rdbuf();
}

Ici, le flux en sortie (ofs dans le premier exemple) aspire, tel un drain de lavabo, la totalité des données du flux en lecture (ifs dans le premier exemple).

Garder une trace de l'exécution d'un programme

On peut garder une trace d'un programme dans un fichier en ouvrant ce fichier en écriture au début de l'exécution du programme, puis en y ajoutant des données (formatées pour être lues par des humains) alors que le programme s'exécute.

Une manière de faire une telle trace (pour valider l'exécution de la sophistiquée procédure permuter() ci-dessous) serait telle que proposé dans le programme ci-dessous :

#include <string>
// Le fichier dans lequel nous allons écrire
const std::string SORTIE = "out.txt";
// Permute les valeurs de a et de b
template <class T>
   void permuter(T &a, T &b);
#include <iostream>
#include <fstream>
int main() {
   using namespace std;
   // Création ou remplacement du fichier dans lequel nous garderons une trace. On le ferme
   // tout de suite après pour que les autres sous-programmes puissent y écrire eux aussi
   // sans que cela ne cause de conflit.
   { ofstream{SORTIE}; } // flux ouvert puis fermé
   int x, y;
   while (cin >> x >> y) {
      permuter(x, y);
      cout << x << ' ' << y << endl;
   }
}
#include <utility> // std::swap()
template <class T>
   void permuter(T &x, T &y) {
      using namespace std;
      ofstream ofs{SORTIE, ios::append};
      ofs << "Debut de l'appel a echanger(), x = "
          << x << ", y = " << y << endl;
      swap(x, y);
      ofs << "Fin de l'appel a echanger(), x = "
          << x << ", y = " << y << endl;
   } // le destructeur de ofs ferme le fichier

L'ouverture en mode ios::append permet d'insérer des données à la fin du fichier plutôt que de remplacer le contenu du fichier existant. Ainsi, un programme peut, pendant son exécution, laisser une trace lisible par laquelle on peut ensuite procéder à une analyse détaillée de son comportement.

Un truc pour automatiser la création du fichier en sortie dès le lancement du programme est d'utiliser deux objets :

Un programme exploitant cette automatisation suit. On peut faire encore un peu mieux, d'ailleurs, mais ceci vous est laissé en exercice.

#include "Incopiable.h"
#include <string>
// Le fichier dans lequel nous allons écrire
const std::string SORTIE = "sortie.txt";
#include <fstream>
using namespace std;
class CreateurSortie : Incopiable {
   // Déclaration du singleton. Cet objet est static, donc on n'en trouve
   // qu'un seul pour la classe toute entière, et privé, donc inaccessible
   // hors de cette classe.
   static CreateurSortie singleton;
   // Le constructeur crée et ferme le fichier en sortie. Il est privé,
   // donc on ne peut l'appeler hors de la classe et risquer d'endommager
   // accidentellement le fichier en sortie.
   CreateurSortie() {
      // on crée le fichier puis il se détruit. Pas même besoin de lui
      // donner de nom!
      ofstream{FICHIER_SORTIE};
   }
};
// Définition du singleton. Ceci appelle le constructeur par défaut
// de la seule et unique instance de la classe CreateurSortie dans
// tout le programme.
CreateurSortie CreateurSortie::singleton;
struct Sortie {
   ofstream flux;
   Sortie() : flux{FICHIER_SORTIE, ios::append} {
   }
};
template<class T>
   void permuter(T &a, T &b);
#include <iostream>
int main() {
   // Pas besoin de créer le fichier, car le singleton s'en est
   // déjà chargé pour nous avant le lancement du programme
   int x, y;
   while (cin >> x >> y) {
      permuter(x, y);
      cout << x << ' ' << y << endl;
   }
}
#include <utility>
template <class T>
   void permuter(T &x, T &y) {
      // Création d'un flux en mode ajout. Pas besoin de connaître le nom
      // du fichier en sortie ou celui des constantes utilisées dans
      // l'ouverture en mode ajout.
      Sortie s;
      s.flux << "Début de l'appel à echanger(), x = "
             << x << ", y = " << y << endl;
      swap(x, y);
      s.flux << "Fin de l'appel à echanger(), x = "
              << x << ", y = " << y << endl;
   } // le destructeur de s ferme le fichier

Il y a cependant plus simple, soit l'objet global std::clog. À titre d'exemple :

#include <string>
// Le fichier dans lequel nous allons écrire
const std::string SORTIE = "sortie.txt";
#include <fstream>
template< <class T>
   void permuter(T &a, T &b);
#include <iostream>
using namespace std;
int main() {
   ofstream sortie{SORTIE};
   clog.rdbuf(sortie);
   int x, y;
   while (cin >> x >> y) {
      permuter(x, y);
      cout << x << ' ' << y << endl;
   }
   clog.rdbuf(nullptr); // fini
}
#include <utility>
template <class T>
   void permuter(T &x, T &y) {
      clog << "Début de l'appel à echanger(), x = "
           << x << ", y = " << y << endl;
      swap(x, y);
      clog << "Fin de l'appel à echanger(), x = "
           << x << ", y = " << y << endl;
   }

L'objet global std::clog est un flux en sortie standard qu'il est coutume d'associer avec un flux en sortie (typiquement sur un fichier). Puisque ce flux est global, y écrire équivaut à écrire sur le flux (dans le fichier) en question. C'est une manière simple et efficace de tenir une forme de journalisation de l'exécution d'un programme.

Connaître la taille (en bytes) d'un fichier

Une question classique, à la fois simple et trop mal documentée, est celle-ci: comment connaître la taille (en bytes) d'un fichier? Doit-on en lire le contenu tout entier? Doit-on dépendre de fonctions propres à la plateforme?

Le truc est tout simple, pourtant. Il suffit :

Un exemple de code faisant ce travail suit :

#include <fstream>
#include <string>
#include <iostream>
using namespace std;
istream::pos_type taille_fichier(const string &nom) {
   ifstream fich{nom};
   fich.seekg(0, ios::end); // aller à zéro bytes de la fin
   return fich.tellg();  // obtenir la position et la retourner
}
int main() {
   const string FICHIER_TEST = "test.dat";
   cout << "Le fichier " << FICHIER_TEST
        << " a une taille de " << taille_fichier(FICHIER_TEST)
        << " bytes." << endl;
}

Notez qu'une taille de -1 est possible dans le cas d'un fichier inexistant.

Les méthodes à retenir ici sont seekg() et tellg(), qui permettent respectivement de déplacer le point de lecture dans un flux et de connaître la position courante du point de lecture relativement au début du flux. Dans un flux en écriture, les méthodes équivalentes sont seekp() et tellp().

Le g de seekg() et de tellg() signifie get alors que le p de seekp() et de tellp() signifie put.

Une version plus sérieuse, plus complète, plus générale et plus rapide serait celle ci-dessous. N'hésitez pas à en discuter avec votre professeur favori si vous désirez comprendre les nuances de cet extrait (je le laisse ici en tant que teaser).

#include <string>
#include <fstream>
#include <iostream>
using namespace std;
template <class C>
   long long taille_de(const basic_string<C> &s) {
      // ate signifie at end
      return static_cast<long long>(basic_ifstream<C>(s, ios::ate).tellg());
   }
int main() {
   cout << "z.dat a une taille de "
        << taille_de(string("z.dat")) << " bytes" << endl;
}

Vérifier l'existence d'un fichier

Une autre question classique dont la solution est toute simple est: comment vérifier l'existence d'un fichier?

Le truc est tout simple. Il suffit :

Un exemple de code faisant ce travail suit :

#include <fstream>
#include <string>
#include <iostream>
using namespace std;

// ouvrir le fichier puis le convertir en bool... comprenez-
// vous le rôle de la double négation logique ici?
bool fichier_existe(const string &nom) {
   return !!(ifstream{nom});
}
int main() {
   const string FICHIER_TEST = "test.dat";
   cout << "Le fichier " << FICHIER_TEST;
   if (fichier_existe(FICHIER_TEST))
      cout << " existe." << endl;
   else
      cout << " n'existe pas." << endl;
}

Rien de plus simple.

Note sur la durée de vie des temporaires en fonction des compilateurs

À l'été 2012, Olivier Gauthier et Adam Salvail-Bérard, étudiants à l'Université de Sherbrooke, m'ont fait part d'un problème qu'ils vivaient sur g++ avec certains des exemples présentés ici. En effet, pour eux, étant donné les exemples ci-dessous, le code à gauche ne compile pas mais le code à droite compile sans erreur.

Ne compile pas Compile normalement
#include <fstream>
#include <string>
#include <iterator>
int main() {
   using namespace std;
   const string fichier = "test.txt";
   ifstream ifs{fichier};
   string str(
      istreambuf_iterator<char>(ifs),
      istreambuf_iterator<char>() // <-- ICI
   );
}
#include <fstream>
#include <string>
#include <iterator>
int main() {
   using namespace std;
   const string fichier = "test.txt";
   ifstream ifs{fichier};
   string str(
      istreambuf_iterator<char>(ifs),
      (istreambuf_iterator<char>()) // <-- ICI
   );
}

Ce problème porte un nom : C++'s Most Vexing Parse. En effet, pour un compilateur l'écriture istreambuf_iterator<char>() est interprétée comme « fonction sans paramètres et retournant un istreambuf_iterator<char> » plutôt que comme un appel de constructeur, alors qu'entourer cette expression de parenthèses force son évaluation et correspond alors à un appel au constructeur par défaut. Ceci est indépendant du type de l'objet à construire, d'ailleurs.

La solution réelle à cet irritant est connue et supportée par les compilateurs C++ 11 suffisamment récents : il s'agit de l'initialisation standardisée, où les paramètres au constructeur peuvent maintenant être placés entre accolades plutôt qu'entre parenthèses. écrire ce qui suit est sans ambiguïté :

Écriture contemporaine
#include <fstream>
#include <string>
#include <iterator>
int main() {
   using namespace std;
   const string fichier = "test.txt";
   ifstream ifs{fichier};
   string str(
      istreambuf_iterator<char>{ifs},
      istreambuf_iterator<char>{} // <-- ICI
   );
}

Lectures complémentaires

Quelques liens pour enrichir le propos.


Valid XHTML 1.0 Transitional

CSS Valide !