IdentifiantMot de passe
Loading...
Mot de passe oublié ?Je m'inscris ! (gratuit)

Lecture de CSV en C++ : le faire « comme il faut »

Cet article vise à présenter la mise en pratique d'une bonne conception, au travers d'un exemple simple, mais qui revient régulièrement sur le forum, à savoir la lecture de données au format CSV. Y seront abordées les différentes problématiques que l'on peut rencontrer et les solutions mises en oeuvre afin d'arriver à une solution robuste, réutilisable et performante.

Il s'adresse à des développeurs ayant déjà des connaissances en C++, et souhaitant améliorer leurs pratiques ou approfondir certaines notions.

N'hésitez pas à donner votre avis sur ce tutoriel : 23 commentaires Donner une note à l´article (5)

Article lu   fois.

L'auteur

Profil Pro

Liens sociaux

Viadeo Twitter Facebook Share on Google+   

I. Introduction

Le format de fichier CSV est un format très ancien et très simple. Pour autant, il n'a pendant longtemps fait l'objet d'aucune normalisation, puisque ce n'est que fin 2005 qu'est sortie la RFC 4180, qui se contente principalement d'entériner l'existant, la plupart des logiciels ayant heureusement recours aux mêmes conventions.

Ce format peut globalement se résumer de la sorte :

  • c'est un fichier textuel ;
  • les valeurs sont séparées par un « délimiteur », qui est un caractère unique. Généralement, il s'agit du caractère « , », mais on trouve aussi « ; » ou « TAB » ;
  • les enregistrements sont séparés par des fins de ligne. Ici, on trouve, suivant les systèmes, du LF, du CRLF ou plus rarement du CR ;
  • parfois, une ligne d'en-tête est présente afin de donner des titres aux colonnes. Cette ligne d'en-tête suit le même format que les enregistrements ;
  • les valeurs peuvent être délimitées par des guillemets doubles, « " ». Dans ce cas, elles peuvent contenir des sauts de ligne, ainsi que d'autres guillemets, qui devront être doublés. Il n'y a pas de séquences d'échappement particulières autres ;
  • les valeurs peuvent être vides, auquel cas on peut trouver deux délimiteurs successifs, ou alors des guillemets doubles (« ;; » ou « ;""; » ;
  • rarement aussi, on trouve des lignes de commentaires, qui commencent alors par « # ». Ces lignes ne font alors pas partie des données CSV.
 
Sélectionnez
NOM,PRENOM,COMMENTAIRE
"MARTIN","PIERRE","M. Pierre Martin, qui habite au ""12 rue de la Poste"""
"DUPONT","JEAN","M. Jean Dupont, qui habite au 6 rue de la Fontaine,
44324 Trifouilly"
"DURAND","LOUIS",

Étonnamment, bien que le CSV soit très répandu, les outils pour le lire ne font pas partie des bibliothèques « standard », comme peuvent l'être les lecteurs XML. La raison en tient aussi à la simplicité du format : la plupart des programmes vont se contenter d'une approche « naïve » pour la lecture, facile à implémenter, mais peu robuste et pas toujours performante.

Nous allons remédier à ce manque, en concevant une bibliothèque simple d'emploi, facilement réutilisable et performante.

II. Le problème, les choix

Lire des données CSV recoupe plusieurs aspects, qu'on peut identifier de la sorte :

  • lire le fichier contenant les données CSV ;
  • gérer convenablement les problématiques d'encodage, de différences entre les retours chariots selon les systèmes ;
  • contrôler la bonne structure du fichier CSV, que les champs sont bien délimités ;
  • contrôler le format des données par rapport à ce qui est attendu (par exemple, « chaque ligne de mon CSV a-t-elle bien huit colonnes ? ») ;
  • faire un traitement (éventuellement un contrôle) sur ces données.

Outre ces différents aspects, nous devons faire des choix architecturaux pour notre bibliothèque. Puisque nous voulons une bibliothèque performante, celle-ci ne stockera jamais plus d'un enregistrement en mémoire (l'enregistrement courant). À charge de l'utilisateur de la bibliothèque de stocker ce dont il a besoin. Nous allons donc plutôt utiliser une approche par fonction de rappel (callbacks), c'est à dire qu'au fur et à mesure de la lecture des données CSV, la bibliothèque appellera des fonctions définies par l'utilisateur, dans les cas suivants :

  • chaque fois qu'un enregistrement est lu en entier (y compris pour les enregistrements vides) ;
  • à chaque fin de ligne ;
  • à la fin du fichier.

L'utilisateur devra aussi pouvoir interrompre la lecture si les données ne sont pas valides. Cela pourra être fait à tout moment, au travers de la valeur de retour des fonctions de rappel.

Si l'on résume bien, on a besoin de :

  • lire un caractère ;
  • identifier le type de chaque caractère, à savoir, si c'est un délimiteur, un séparateur, une fin de ligne, etc. ;
  • construire une chaîne de caractères avec les caractères lus, pour construire la valeur qui sera renvoyée à la fonction de rappel qui gère les champs ;
  • réinitialiser cette chaîne de caractères au début d'un nouveau champ ;
  • appeler une méthode chaque fois qu'un champ est terminé ;
  • appeler une méthode chaque fois qu'un enregistrement (« ligne ») est terminé ;
  • appeler une méthode en cas d'erreur ;
  • appeler une méthode à la fin du fichier ;
  • appeler une méthode quand un commentaire est terminé.

Et c'est tout. Notre lecteur CSV ne devrait pas avoir besoin de plus que cela. Nous allons donc le concevoir avec cette idée en tête, et tenter de supprimer, chaque fois que cela est possible, toute contrainte supplémentaire que notre implémentation introduirait.

III. Analyser les données CSV

L'analyse de fichiers textes se réalise très bien au moyen de ce qu'on appelle une machine à états. Il existe plusieurs modèles de machine à états, très bien décrits par ailleurs (par exemple ici ou ).

III-A. Le concept de machine à états

Le concept d'une machine à états est assez simple. Plutôt que de stocker diverses valeurs relatives à tel ou tel événement qui vient de se produire (par exemple, « la valeur précédente était un séparateur »), on va stocker l'ensemble de ces informations dans une même donnée, qu'on appelle l'état courant. Cela nous fournit une vue plus synthétique de l'état réel dans lequel on est, et empêche d'avoir des combinaisons qui donneraient un état impossible ou incohérent (par exemple, « la valeur précédente était un séparateur » et « la valeur précédente était une fin de ligne » sont clairement incompatibles entre eux). L'état de notre machine va évoluer en fonction de ce qu'elle reçoit comme caractères à consommer.

III-B. Les différents états

On peut déjà considérer que notre machine va avoir trois états, les états classiques, à savoir :

  • état initial ;
  • état final ;
  • état d'erreur.

Ensuite, il nous faut analyser les éléments qui vont provoquer un changement d'état. On peut assez facilement identifier la liste suivante :

  • un séparateur, « , » ;
  • un délimiteur, « " » ;
  • une fin de ligne « \n » ;
  • un début de commentaire « # » ;
  • un autre caractère quelconque, qu'on notera « * ».

La fin de fichier sera gérée à part, puisqu'elle n'est pas un caractère à proprement parler dans le modèle que nous avons choisi. Nous allons donc définir ce que nous appelons des lexèmes (token en anglais), qui vont être au nombre de cinq, et qui seront utilisés par notre analyse.

Maintenant, nous allons, en partant de l'état initial, construire les états successifs en fonction de l'élément que nous consommons :

  • un commentaire va nous mener à un nouvel état : « comment » ;
  • un délimiteur va nous mener à un nouvel état : « quoted_record » ;
  • une fin de ligne va nous mener à un nouvel état « new_line » ;
  • un caractère quelconque va nous mener à un nouvel état « record » ;
  • un séparateur signifie que le premier champ était vide, cela va donc nous mener à un état « between_record ».

Pour chacun de ces nouveaux états, nous allons répéter le processus, en agissant de la sorte :

  • chaque fois que cela a du sens, nous allons réutiliser un état existant ;
  • si cela n'est pas possible ou n'a pas de sens, nous allons créer un nouvel état.

Ce qui va caractériser l'état est une notion de déterminisme. Pour un état donné et une entrée consommée, il ne doit y avoir qu'un unique comportement. Si deux comportements sont possibles, cela signifie que l'état doit être scindé en deux.

Cela nous donne la machine à états suivante :

Image non disponible

Le seul cas qui mérite un peu d'attention est le cas « quote_quoted_record ». En effet, lorsqu'on rencontre un guillemet au sein d'un champ entre guillemets, deux possibilités s'offrent à nous : soit c'est la fin du champ, soit c'est un guillemet doublé parce qu'il fait partie de la valeur du champ. Le seul moyen de faire la différence est d'aller voir le caractère suivant. Il nous faut donc cet état intermédiaire, afin de mémoriser qu'on vient de rencontrer un guillemet, et que donc le caractère suivant doit servir à déterminer la nature de ce guillemet, et agir en fonction.

Enfin, la machine précédente a été simplifiée : nous avons volontairement enlevé le cas de la fin de fichier. Il nous reste à le gérer. Cela revient à déterminer, en réalité, dans quelles conditions notre machine peut s'arrêter. En fait, elle peut s'arrêter à tout moment, sauf au sein d'un « quoted_record », qui ne serait pas terminé, ce qui est une erreur de syntaxe.

III-C. Les actions

Pour l'instant, notre machine à états ne fait que consommer du contenu. Elle permet de valider qu'un CSV est bien formé, mais elle ne permet pas de le traiter. Nous allons y remédier en ajoutant des actions sur nos différentes transitions. Les actions vont être les suivantes :

  • accumuler un caractère dans le champ ;
  • terminer l'élément courant, notifier et le remettre à vide ;
  • terminer l'enregistrement courant ;
  • annoncer une erreur ;
  • annoncer la fin de fichier ;
  • Annoncer la fin d'un commentaire.

On retrouve en réalité la liste des actions qu'on a identifiées initialement, ce qui est assez logique. Nous allons maintenant mettre les actions sur les transitions, ce qui nous donne la machine suivante :

Image non disponible

On remarque que les états « initial » et « newline » sont identiques (sauf pour la fin de fichier). On utilisera cette propriété ultérieurement pour factoriser le code.

III-D. Une première implémentation

Maintenant que nous avons notre machine à états, nous allons définir les différentes choses dont nous avons besoin pour l'analyse. Notre ensemble de lexèmes étant très réduit, nous allons simplement définir quatre fonctions, permettant d'identifier si un caractère est d'un type particulier. Ceci afin d'éviter de faire un premier test pour déterminer le lexème, puis un second pour tester ce lexème.

Cela nous donne les fonctions suivantes :

 
Sélectionnez
bool is_separator(char c);
bool is_new_line(char c);
bool is_double_quote(char c);
bool is_start_comment(char c);

Enfin, les différents états de la machine à états :

 
Sélectionnez
enum class csv_parser_state {
	initial,
	comment,
	record,
	quoted_record,
	quote_quoted_record,
	new_line,
	between_records
};

Les différentes erreurs que peut remonter notre parseur :

 
Sélectionnez
enum class csv_error {
	no_error = 0 /* pour pouvoir stocker ensemble l'erreur et l'absence d'erreur */,
	error_other,
	error_user_aborted,
	error_malformed_quoted_string,
	error_unterminated_quoted_string,
	error_empty_file
};

La définition des fonctions de rappel que notre parseur utilise :

 
Sélectionnez
typedef std::string string_type; // Pour pouvoir le changer plus facilement
std::function<bool(string_type const &)> comment_handler;
std::function<bool(string_type const &)> field_handler;
std::function<bool()> end_line_handler;
std::function<bool()> end_file_handler;
std::function<void(csv_error,int,int)> error_handler;

Quelques petites fonctions « utilitaires ». Ces fonctions seront utiles si l'on souhaite, par exemple, changer le type de chaîne utilisée. On verra dans la partie « Généricité » pourquoi on procède ainsi.

 
Sélectionnez
					void truncate(string_type & s) { s.clear(); }
					void append(string_type& s, char c) { s.push_back(c); }

Quelques petites fonctions utilitaires, pour des bouts de code qui vont se retrouver un peu partout :

 
Sélectionnez
void call_field_handler();
void call_end_line_handler();
void call_comment_handler();
void call_error_handler(csv_error err);
void update_line_counter();

Et enfin, la fonction principale d'analyse :

 
Sélectionnez
void csv_parser::consume(char c)
{
	current_pos_ += 1;
	switch(state_)
	{
        case initial:
        case new_line:
        {
        	if(is_separator(c))
            {
                call_field_handler();
                state_ = state::between_records;
            }
            else if(is_double_quote(c))
                state_ = state::quoted_record;
            else if(is_start_comment(c))
                state_ = state::comment;
            else if(is_new_line(c))
            {
                call_end_line_handler();
                state_ = state::new_line;
            }
            else
            {
            	last_value_.push_back(c);
                state_ = state::record;
            }
            break;
        }
        case state::record
        {
            if(is_separator(c))
            {
                call_field_handler();
                state_ = state::between_records;
            }
            else if(csvis_new_line(c))
            {
                call_field_handler();
                call_end_line_handler();
                state_ = state::new_line;
            }
            else
                last_value_.push_back(c);
            break;
        }
        case state::quoted_record:
        {
            if(is_double_quote(c))
                state_ = state::quote_quoted_record;
            else
            {
                if(is_new_line(c))
                    update_line_counter();
                last_value_.push_back(c);
            }
            break;
        }
        case state::quote_quoted_record:
        {
        	case state::quote_quoted_record:
	        {
	            if(is_separator(c))
	            {
	                call_field_handler();
	                state_ = state::between_records;
	            }
	            else if(is_double_quote(c))
                {
                    last_value_.push_back(c);
                    state_ = state::quoted_record;
                }
                else if(is_new_line(c))
                {
	                call_field_handler();
	                call_end_line_handler();
	                state_ = state::new_line;
	            }
	            else
	                call_error_handler(csv_error::malformed_quoted_string);
	            break;
	        }
        	
        }
        case state::between_records:
        {
            if(is_separator(c))
            {
                call_field_handler();
                state_ = state::between_records;
            }
            else if(is_double_quote(c))
                state_ = state::quoted_record;
            else if(is_new_line(c))
            {
                call_field_handler();
                call_end_line_handler();
                state_ = state::new_line;
            }
            else
            {
                last_value_.push_back(c);
                state_ = state::record;
            }
            break;
        }
        case state::comment:
        {
            if(is_new_line(c))
            {
                call_comment_handler();
                state_ = state::new_line;
                update_line_counter();
            }
            else
                csv_traits::append(last_value_, c);
            break;
        }
    }
}

Et enfin, le traitement de la fin de fichier :

 
Sélectionnez
void csv_parser::end_of_data()
{
    switch(state_)
    {
        case state::quote_quoted_record:
        case state::record:
        case state::between_records:
            call_field_handler();
            call_end_line_handler();
            break;
        case state::quoted_record:
            call_error_handler(csv_error::unterminated_quoted_string);
            break;
        case state::new_line:
            break;
        case state::comment:
            call_comment_handler();
            break;
        case state::initial:
            call_error_handler(csv_error::error_empty_file);
            break;
    }
    if(end_file_handler && error_ == csv_error::no_error)
    {
        if(!end_file_handler())
            error_ = csv_error::error_user_aborted;
    }
}

IV. Lire le fichier et gérer l'encodage

Ou plutôt, comment ne pas le faire. Lire un fichier est assez simple et un exercice de débutant, en revanche, gérer les encodages est un peu plus complexe. Comme nous devons faire les deux, et que l'informaticien est fainéant de nature, nous allons décider de n'en faire aucun. Nous allons donc laisser l'utilisateur se charger de lire le fichier, en utilisant les outils qui lui conviennent le mieux. Pour l'instant, contentons-nous simplement de l'analyse du contenu.

Nous avons vu que notre parseur a seulement besoin qu'on l'alimente en caractères. Pour l'instant, nous ne gérons que les caractères de type « char ». Toutefois, il semble assez simple de le modifier pour prendre en compte d'autres types de caractères, tels que les wchar_t.

Puisque nous ne lisons plus le fichier, la question de gérer l'encodage ne relève plus vraiment de notre responsabilité non plus. Toutefois, il faut définir le cadre dans lequel notre bibliothèque peut fonctionner. Il parait raisonnable de dire que nous voulons que notre parseur fonctionne pour :

  • n'importe quel encodage à longueur fixe ;
  • pour les autres encodages (à longueur variable), une conversion vers un encodage à longueur fixe sera à priori nécessaire en amont.

En effet, notre analyse se faisant « caractère par caractère », elle est indépendante de l'encodage du fichier utilisé. Il nous faut seulement pouvoir isoler un séparateur, un guillemet ou un retour chariot d'un autre type de caractère.

Pour ce qui est des encodages UTF-8 et UTF-16, ce sont des encodages à longueur variable : certains caractères vont occuper 8 bits (le minimum en UTF-8), d'autres 16 bits, d'autres encore 24 ou 32 bits. À priori, cela pose problème pour notre analyse. Toutefois, ces encodages ont deux propriétés intéressantes :

  • les caractères les plus usuels (ceux dits ASCII 7 bits), dont font partie nos guillemets, séparateurs et retour chariot, sont encodés sur un seul « mot » (8 bits en UTF-8, 16 bits en UTF-16), qui va être la taille de base, dont le bit le plus à gauche est toujours à 0 (puisqu'ils tiennent sur 7 bits). De plus, ils ont la valeur « standard » qu'on trouve dans le codage ASCII 7-bit. Aucun traitement particulier n'est à prévoir ici ;
  • pour les caractères codés sur plusieurs mots, prenons le cas d'UTF-8 : le bit le plus à gauche est toujours à 1 pour toutes les composantes de ce caractère (c'est une contrainte de l'encodage). Le cas d'UTF-16 est un peu plus complexe, mais il présente une propriété similaire.

Ces propriétés nous donnent une garantie : il est impossible de prendre à tort une composante d'un caractère pour un séparateur. Notre analyse, bien que fonctionnant sur des caractères de taille fixe, va être parfaitement compatible avec ces encodages, qui sont pourtant de taille variable. Ceci nous amène une première règle de conception :

Ne soyez pas restrictif dans votre analyse. Réfléchissez à la manière dont vous exposez vos contraintes, et si elles correspondent à la réalité. Toute contrainte que vous imposez sera un frein à la réutilisation du code dans d'autres contextes, aussi formulez-les de la manière la plus précise possible.

Nous laissons donc cet aspect de côté, puisque nous considérons pour l'instant que nous sommes capables d'analyser de l'UTF-8 aussi bien que tout encodage tenant sur 8 bits, ce qui est suffisant. Nous verrons ensuite comment étendre cela facilement à tout encodage, en utilisant des wchar_t si nécessaire.

Le lecteur aura remarqué qu'en procédant ainsi, notre bibliothèque acceptera sans broncher des fichiers dans un encodage invalide (par exemple, des caractères latin-1 au milieu de l'UTF-8). C'est voulu : détecter de telles erreurs d'encodage doit se faire soit avant, lors de la lecture du fichier, soit après, lors du traitement des chaînes de caractères. La seule garantie que nous offrons est de ne pas « casser » des données valides par notre traitement.

V. Vérifier la structure de mon fichier

Maintenant que nous savons découper notre fichier CSV, une des premières choses que nous allons vouloir vérifier est que celui-ci est conforme à ce que nous attendons. Cela va concerner :

  • le nombre de champs par ligne ;
  • le contenu de chacun de ces champs ;
  • une éventuelle séquentialité entre les lignes ;
  • etc.

Il n'existe pas, pour CSV, de notion de « schéma » comme cela peut exister pour XML. Les vérifications que l'on va devoir faire sont donc très dépendantes du contexte d'utilisation, et pas du tout du parseur lui-même. C'est donc le client qui va déterminer, au final, si les données sont valides ou non.

Le client a donc besoin d'une chose : pouvoir informer le parseur qu'une erreur est survenue. Ceci afin d'interrompre le traitement dès lors qu'une erreur qu'il considère non récupérable survient. Pour cela, le plus simple est d'utiliser la valeur de retour de la fonction de rappel appelée sur le traitement d'un champ ou d'une nouvelle ligne : on va utiliser un simple booléen. S'il est à faux, un problème est survenu, on s'arrête. S'il est à vrai, on continue le traitement.

V-A. Tolérance aux erreurs

Parfois, certaines erreurs sont récupérables. Par exemple, un fichier CSV qui doit faire douze colonnes de large peut se retrouver avec, pour une raison quelconque, avec une ligne qui ferait treize colonnes de large. De même, certaines valeurs entières peuvent être hors limites, sans que cela ne soit catastrophique. Cette notion d'« avertissement », toutefois, est indépendante de notre parseur et se gère très bien au niveau du client.

La seule difficulté pour le client serait de localiser correctement les avertissements. En dehors du parseur, cela peut être un peu complexe. Nous verrons plus loin comment y remédier.

V-B. Localiser les erreurs

Afin que l'utilisateur de notre parseur puisse retrouver facilement les erreurs dans son fichier, il est nécessaire de lui donner des indications sur l'endroit où se trouvent les erreurs. Nous allons pour cela lui donner deux indications :

  • la ligne où a lieu l'erreur/l'avertissement ;
  • la colonne où elle a lieu.

À cet effet, nous allons rajouter à notre parseur deux compteurs, un pour les lignes et un pour les colonnes. Attention toutefois, le compteur de lignes doit être incrémenté à chaque nouvelle ligne, pas uniquement à la fin d'un enregistrement CSV. Quant au compteur de colonnes, il doit bien évidemment être remis à zéro à chaque changement de ligne, mais il se pose aussi un autre problème : comment gérer les caractères multi-octets ?

La première solution consiste à ne pas les gérer. Après tout, les caractères multi-octets ne sont pas si fréquents, et la position ne sera que légèrement fausse, ce qui pourrait être acceptable. Il est possible de faire mieux que cela, l'exercice est laissé au lecteur.

V-C. Vérifier la structure, traiter les données

Comme nous l'avons vu, tous les traitements vont s'effectuer dans les fonctions de rappel appelées par le parseur CSV. L'enchaînement va être généralement quelque chose comme :

 
Sélectionnez
				field_handler("chaîne lue");
				field_handler("chaîne lue 2");
				field_handler("chaîne lue 3");
				end_line_handler();
				field_handler("colonne 1");
				field_handler("colonne 2");
				...

À partir de là, l'utilisateur est libre de faire tous les traitements qu'il désire. Pour simplement compter le nombre de champs dans le fichier CSV ainsi que le nombre d'enregistrements, on pourra simplement utiliser quelque chose comme :

 
Sélectionnez
				class csv_count {
					int nbFields;
					int nbLines;
				public:
					CsvCount() : nbFields(0), nbLines(0) {}
					bool handle_field(std::string const& field) { nbFields += 1; }
					bool handle_end_line() { nbLines += 1; }
					int number_of_fields() const { return nbFields; }
					int number_of_lines() const { return nbLines; }
				};

Il est très aisé, à partir de là, de, par exemple, vérifier qu'un enregistrement n'a pas un nombre trop important de colonnes. C'est la valeur de retour de la fonction « handle_field/handle_end_line » qui va servir à indiquer qu'un problème a eu lieu lors du traitement. Lorsqu'un problème survient, notre parseur va appeler le « error_handler » qui lui a été défini, en lui passant en paramètres la localisation et le type d'erreur.

Si on souhaite générer un niveau intermédiaire, de type avertissement, comme on l'a vu plus haut, il faut modifier le type de retour de « handle_field/handle_end_line » et rajouter un warning_handler.

VI. Traiter des données arrivant à la volée

VI-A. Les difficultés

Les sources de données CSV peuvent être nombreuses et diverses. Le plus fréquemment, on part d'un fichier qui est en local sur le disque dur. Mais cela ne représente qu'une infime partie des cas d'utilisation :

  • fichier sur le réseau ;
  • fichier récupéré depuis une requête http ;
  • données lues depuis la console, redirigées depuis un autre programme ;
  • etc.

L'approche « naïve » à base de getline s'accommode très mal de cette contrainte : en effet, il est impossible de savoir à l'avance où va se trouver le prochain saut de ligne. Aussi, sauf à charger toutes les données en mémoire au préalable, si celles-ci arrivent à la volée, il est très probable que getline coupe au milieu des données, parce qu'il est arrivé à la fin du buffer qu'on lui passe. Ou alors qu'il bloque, en attendant désespérément une fin de ligne qui met du temps à arriver, figeant totalement notre programme, nous forçant à éventuellement déporter la lecture dans un thread séparé, ce qui commence à beaucoup complexifier les choses.

VI-B. Pourquoi il n'y a rien à changer : les bénéfices d'une conception soignée

Nous l'avons vu, notre parseur est conçu pour être alimenté caractère par caractère. Il peut être interrompu puis reprendre à tout moment de la lecture du fichier, puisqu'il conserve un état interne pour cela. Cette particularité va nous permettre de contourner toutes les difficultés évoquées plus haut, en effet :

  • il n'y a pas besoin de savoir à l'avance quand sera la prochaine fin de ligne ;
  • si un buffer est incomplet, ce n'est pas un problème : le parseur pourra reprendre où il en était, de manière totalement transparente. Et les fonctions de rappel appelées par le parseur ne verront même pas qu'il y a eu une interruption.

Il est ainsi possible d'intégrer notre parseur à tout système de lecture synchrone ou asynchrone, bloquant ou non bloquant, car celui-ci est totalement neutre de ce point de vue, et de par sa conception supporte parfaitement de traiter des données partielles. C'est un aspect très important dès que l'on veut traiter des données reçues depuis le réseau ou toute autre source de données « lente ».

VII. Revenir à une lecture pilotée par le consommateur

Notre parseur est très efficace, compatible avec de nombreuses méthodes de récupération de données, mais peut, peut-être, paraitre un tout petit peu compliqué à utiliser pour le débutant. Après tout, il est vrai qu'écrire quelque chose comme :

 
Sélectionnez
				csv_data data = csv_reader("monfichier.csv").readAll();

a ce côté confortable et rassurant pour le débutant. Mais après tout, puisque nous avons fait tout ce travail pour réaliser un beau parseur CSV, nous pouvons bien faire un petit effort supplémentaire et fournir une interface plus simple. Rien n'étant gratuit en ce monde, cette interface n'offrira pas la même flexibilité, mais qu'à cela ne tienne : celui qui veut une interface simple l'aura, celui qui veut plus de flexibilité l'aura aussi. Le tout est de ne pas payer pour ce qu'on n'utilise pas.

VII-A. Définir une structure de données pour stocker les résultats

La première chose dont nous allons avoir besoin est de définir ce qu'est notre csv_data. On peut raisonnablement estimer plusieurs choses :

  • il parait raisonnable de stocker l'ensemble des enregistrements (« lignes » du fichier csv) dans un « tableau » et d'y fournir un accès par indice, ainsi que des itérateurs ;
  • pour chaque enregistrement, de la même manière, chacun des champs devrait pouvoir être accédé de différentes manières. Soit par indice, soit par le nom de l'en-tête de la colonne, soit via des itérateurs ;
  • l'utilisateur ne pourra pas modifier les données lues. C'est un choix arbitraire, il serait possible de faire autrement. Le lecteur est libre de procéder aux modifications adéquates. Toutefois, il parait beaucoup plus logique, si l'on souhaite modifier les données, de passer par une structure idoine, qu'on alimentera à l'aide du parseur plus « bas niveau ».

Nous allons donc définir plusieurs classes :

  • une première pour stocker l'ensemble des données, qui sera responsable de la durée de vie de celles-ci. Appelons-la tout simplement csv_data ;
  • une deuxième pour stocker les données relatives à un enregistrement unique. Celle-ci contiendra une référence vers la première (pour la liste des en-têtes), et les fonctions permettant d'accéder aux données.

VII-A-1. Classe csv_record

Cela donne, pour la class csv_record :

 
Sélectionnez
class csv_record {
	friend class csv_data; // csv_data a un accès privilégié aux données de cette classe, 
	                       // c'est elle qui l'initialise
	csv_data const& owner_;
	std::vector<std::string> fields_;
public:
	csv_record(csv_data const& owner) : owner_(owner) {}
	typedef std::vector<std::string>::const_iterator const_iterator;
	const_iterator begin() const;
	const_iterator end() const;
	std::string const& operator[](size_t i) const;
	boost::optional<std::string const&> operator[](std::string const& header) const;
	size_t size() const;
};

Un petit commentaire s'impose pour l'opérateur []. En effet, nous en avons deux versions différentes. La première prend un indice en paramètre, et renvoie directement une chaîne. Il est raisonnable d'exiger de l'utilisateur qu'il renvoie un indice valide : en effet, il dispose de la fonction size() pour le vérifier simplement. Aussi, nous ne prenons pas la peine de valider que celui-ci est valide : s'il ne l'est pas, le comportement sera indéterminé.

La deuxième, en revanche, prend une chaîne en paramètre, et notre interface n'offre pas de moyen simple de valider si une colonne est bien présente dans le résultat. De plus, si l'on voulait exiger de l'utilisateur qu'il fasse la vérification avant, on ferait la recherche de la colonne deux fois, ce qui est idiot. On décide donc de renvoyer la valeur de retour au moyen d'un « optional », qui sera vide si la colonne n'existe pas.

VII-A-2. Classe csv_data

Nous décidons que la classe csv_data est seulement un conteneur, elle ne joue pas le rôle du « handler » :

 
Sélectionnez
class csv_data
{
	friend class csv_data_handler; // the handler, to fill data

    std::vector<csv_record> records_;
	std::vector<std::string> headers_;
	void new_record() { records_.emplace_back(*this); };
	void add_field(std::string const& field) { records_.back().add_field(field); };
public:
    typedef std::vector<csv_record>::const_iterator const_iterator;
    const_iterator begin() const;
    const_iterator end() const;
    csv_record const& operator[](size_t i);
    size_t size() const;
    std::vector<std::string> const& headers() const;
};

VII-A-3. Classe csv_data_handler

Cette classe a pour rôle de remplir la structure csv_data, à partir des informations qui seront données par le csv_parser. Sa réalisation est assez triviale : il faut simplement :

  • configurer les fonctions de rappel du parseur à l'initialisation ;
  • interpréter les données envoyées par les fonctions de rappel ;
  • traiter différemment le cas ligne d'en-tête présente/pas présente.

Ce qui donne, dans sa plus simple expression (c'est à dire, sans gestion des erreurs ou contrôle sur le fichier) :

 
Sélectionnez
class csv_data_handler {
    bool has_headers_;
    csv_data& data_;
    bool at_beginning_of_line;
    bool at_first_line;
public:
    bool field_handler(std::string const & s)
    {
        if(at_first_line && has_headers_)
        {
            data_.headers_.push_back(s);
        }
        else
        {
            if(at_first_line)
                data_.headers_.push_back("header" + std::to_string(data_.headers_.size()));
            if(at_beginning_of_line)
            {
                data_.new_record();
                at_beginning_of_line = false;
            }
            data_.add_field(s);
        }
    }
    bool end_line_handler()
    {
        at_beginning_of_line = true;
        at_first_line = false;
    }

    csv_data_handler(csv_data& data, bool has_header_line, csv_parser & parser) :
        data_(data),
        has_headers_(has_header_line),
        at_beginning_of_line(true),
        at_first_line(true)
    {
        parser.field_handler = [this](std::string const& s) { return field_handler(s); };
        parser.end_line_handler = [this]() { return end_line_handler(); };
    }
};

Cette implémentation est minimale : elle ne contrôle rien, ne gère pas les erreurs. Mais il est très simple de la changer par une implémentation plus évoluée, chargée de contrôler le contenu du fichier : il suffit pour cela de changer les implémentations de field_handler et end_line_handler. Là où notre conception est très intéressante, c'est qu'elle permet assez simplement ce changement, sans toucher à toute la logique de lecture du CSV.

Il serait aussi possible d'optimiser ce traitement en utilisant le move semantic au niveau des « handlers », pour éviter une copie inutile. L'exercice est laissé au lecteur.

L'implémentation de ces classes est disponible dans l'archive à la fin de l'article.

VII-B. Lire le fichier et fournir la structure en résultat

Cette partie ne présente aucune difficulté, il s'agit simplement de lire un fichier et d'alimenter le parseur en conséquence. Ce peut être fait, par exemple avec le code suivant :

 
Sélectionnez
std::unique_ptr<dvp::csv_data> read_csv_file(std::string path, bool header_line)
{
    std::fstream f(path.c_str());
    std::unique_ptr<dvp::csv_data> data(new dvp::csv_data());
    dvp::csv_parser parser;
    dvp::csv_data_handler(*data, header_line, parser);
    char buf[16192];
    while(f.good() && !parser.error())
    {
        int nb = sizeof(buf);
        f.read(buf, nb);
        if(!f.good())
            nb = f.gcount();
        for(int i = 0; i < nb; ++i)
        {
            parser.consume(buf[i]);
        }
    }
    if(f.eof())
    {
        parser.end_of_data();
        if(parser.complete())
            return data;
    }
    return nullptr;
}

VIII. Utiliser la généricité

Nous avons vu que nous pouvions avoir différentes tailles de caractères. Et, quels que soient les caractères, l'analyse est la même. Tout ce dont nous avons besoin, c'est d'identifier les nouvelles lignes, les séparateurs, les guillemets. Cela peut être fait indépendamment du fait que nous travaillions sur des char, des wchar_t, des QChar, etc.

En C++, la bonne méthode pour gérer différents types au sein d'une classe est l'utilisation de templates. Il nous faut deux types différents : un pour les caractères, et un pour les chaînes de caractères que nous allons renvoyer dans nos fonctions de rappel. Comme ce n'est qu'un début, nous allons définir tout cela dans une classe de traits, et donc définir notre classe comme :

 
Sélectionnez
template<typename csv_traits>
class csv_parser {
    //...
    // demander au lecteur csv de consommer le caractère
    bool consume(csv_traits::char_type c);
	
    // fonction appelée chaque fois qu'un champ du CSV est lu
    std::function<bool(csv_traits::string_type const& field)> field_handler;
    //...
}

Et nous allons définir nos traits pour la STL, par exemple :

 
Sélectionnez
struct csv_stl_traits {
    typedef std::string string_type;
    typedef std::string::value_type char_type;
};

Le lecteur attentif aura remarqué que nous avons utilisé « std::string::value_type » plutôt que char. En effet, notre classe de traits pourrait elle-même être template... Après tout, elle sera quasiment identique pour std::wstring. Le code proposé au téléchargement en fin de l'article est fait en ce sens.

Ensuite, il nous faut voir comment nous pouvons identifier les différents tokens. Nous allons donc intégrer nos fonctions « utilitaires » dans notre classe de traits. En effet, cela nous ouvre plus de flexibilité (comme le fait de gérer différents séparateurs, nous le verrons plus loin). Nous allons aussi inclure nos fonctions utilitaires :

 
Sélectionnez
struct csv_stl_traits {
    ...
    bool is_separator(char_type c);
    bool is_new_line(char_type c);
    bool is_double_quote(char_type c);
    bool is_start_comment(char_type c);
};

Et pour le parseur, nous allons le rendre dépendant de cette classe de traits :

 
Sélectionnez
template<typename csv_traits>
class csv_parser {
...

};

Chaque fois que vous avez des contraintes sur des valeurs d'un type, utilisez une classe de traits ou de politiques. Ceci laisse à l'utilisateur la possibilité de fournir, au moyen d'une classe de traits spécifiques, les informations dont vous avez besoin, y compris dans des cas que vous n'auriez pas imaginés au départ.

Il nous reste encore un petit souci. Lorsque nous construisons les enregistrements, nous avons besoin d'ajouter des caractères à notre chaîne. Cela se fait au moyen de la méthode « push_back ». Problème : que faire si le type chaîne ne fournit pas cette méthode, ou la fournit sous un autre nom ? Là encore, c'est une contrainte trop forte, nous allons donc ajouter ces méthodes à notre classe de traits :

 
Sélectionnez
template<typename string_type, typename char_type = typename string_type::value_type>
struct csv_stl_traits {
    static void append(string_type& ref, char_type val) { ref.push_back(val); }
    static void truncate(string_type& ref) { ref.resize(0); }
};

Et nous allons modifier notre parseur pour qu'il appelle ces fonctions-là, plutôt que directement « push_back ». Ainsi, il devient possible pour l'utilisateur de fournir un type qui ne fournirait pas sous ce nom-là de telles méthodes (par exemple, les CString des MFC), sans devoir intervenir ni sur le code du lecteur csv, ni sur sa classe, mais simplement en fournissant une classe de traits différente. Il est intéressant de noter qu'on aurait pu obtenir le même résultat avec des std::function, mais cela aurait alors eu un coût à l'exécution, là où la solution d'une classe template ne coûte rien, les appels pouvant être tous inlinés par le compilateur.

Chaque fois que vous avez des contraintes sur l'existence d'une fonction membre pour un type donné, pensez à la possibilité d'utiliser une fonction libre ou une classe de traits spécialisable à la place. Cela ne coûte rien à l'exécution, et laisse plus de portes ouvertes.

Plus de détails sur cette approche sont disponibles dans cet article.

Notre code, une fois rendu générique, va donc ressembler à cela :

 
Sélectionnez
template<typename csv_traits>			
void csv_parser<csv_traits>::consume(typename csv_traits::char_type c)
{
	switch(state_)
    {
        case initial:
            if(csv_traits::is_separator(c))
            {
            	call_field_handler();
            	...
            }
            ...
            else
            {
            	csv traits::append(last_value_, c);
            	state_ = state::record;
            }
       // ...

Quelques remarques s'imposent. La première est que le csv_parser_state est indépendant du type du parseur instancié. Aussi est-il défini en dehors de la classe. La même remarque s'impose pour le type csv_error : il n'y a pas de plus-value à ce qu'il soit intégré à une spécialisation particulière de la classe template.

VIII-A. Et pour gérer les différents séparateurs ?

On l'a vu, le séparateur fait partie de la classe de traits. Donc, pour changer de séparateur, il faut changer de type de parseur. En fait, cela peut se révéler particulièrement gênant dans certains contextes, quand par exemple on veut proposer à l'utilisateur de choisir le séparateur au moment de la lecture du fichier.

Ce cas se gère en fait assez facilement, en faisant preuve d'un peu d'astuce. En effet, il suffirait que notre classe de traits puisse stocker le caractère en question, et adapter la fonction analyze_char en conséquence. Problème : notre classe csv_parser ne possède pas de membre de type « csv_traits ». Et celui-ci étant la plupart du temps vide, ce serait un gâchis de place.

Plutôt que d'utiliser un membre, nous allons donc utiliser un héritage privé. L'avantage est que si notre classe de traits est vide (le cas le plus fréquent), nous n'avons aucun coût supplémentaire.

Reste encore à voir comment on définit ce séparateur. Puisqu'on a utilisé un héritage privé, il n'est pas possible d'utiliser une méthode publique qui viendrait se « rajouter » à l'interface du parseur. En revanche, nous pouvons utiliser une fonction déjà existante, à savoir le constructeur, en utilisant une nouveauté de c++11, les « templates variadic » :

 
Sélectionnez
					template<typename... Args>
					csv_parser(Args... args) : csv_traits(args...) { }

Notre constructeur dépend désormais de notre classe de traits, et cela nous permet qu'il prenne, par exemple, un paramètre de type « char » étant le séparateur. Ce paramètre sera transmis au constructeur de la classe de base csv_traits, dans laquelle il sera stocké. Ceci est un exemple d'utilisation de ce mécanisme, mais celui-ci permet en réalité beaucoup plus, le tout étant laissé à l'imagination des utilisateurs de notre parseur.

Il est aussi possible de définir un héritage public pour la classe de traits, ce qui présenterait l'avantage de pouvoir enrichir l'interface (avec par exemple, une méthode pour récupérer le séparateur utilisé). L'inconvénient est que cela implique un polymorphisme dynamique qui fait peu de sens ici. Pour le cas particulier de récupérer le séparateur utilisé, on va plutôt rajouter une méthode dans notre parseur csv, qui fera appel à la méthode de la classe de traits.

VIII-B. Pourquoi des fonctions de rappel et pas des politiques statiques ?

Le choix qui a été fait ici, à savoir, d'utiliser des politiques statiques pour l'analyse, mais des fonctions de rappel pour le comportement utilisateur, relève, comme tous les choix, d'une part d'arbitraire et de compromis. On aurait très bien pu exiger de l'utilisateur de, plutôt que de fournir des fonctions de rappel pour field_handler, fournir ces fonctions sous forme d'une classe de politiques template. Il y a plusieurs raisons de ne pas le faire :

  • en procédant ainsi, chaque parseur, lié à une fonction de rappel différente, serait d'un type différent. Cela peut réellement complexifier le code du client ;
  • le gain de perf ne sera pas aussi conséquent que pour les fonctions utilitaires, appelées beaucoup plus souvent ;
  • cela peut aussi augmenter de manière conséquente la taille occupée en mémoire par le code.

Néanmoins, cette approche permettrait certaines combinaisons intéressantes, en particulier en terme d'optimisations. Une solution de compromis serait d'utiliser des politiques statiques, mais de fournir par défaut une politique configurable équivalente à ce que nous avons introduit ici. L'exercice est laissé au lecteur.

IX. Comparaisons de performances

Cette section finale vise simplement à évaluer si tout ce que nous avons fait a un coût en matière de performance. Nous comparerons donc quatre approches :

  • notre bibliothèque, dans sa plus simple expression, utilisant des std::string et des fonctions de rappel ;
  • notre bibliothèque, mais cette fois-ci en utilisant la structure csv_data ;
  • l'approche « naïve », à base de getline, qu'on voit souvent conseillée sur le forum ;
  • enfin, libcsv, une bibliothèque en C utilisant aussi des fonctions de rappel.

Ces quatre approches seront comparées dans différents scénarios, dont les traitements se veulent assez simples, afin que le gros du temps soit passé dans la lecture csv. Ces cas d'utilisation sont les suivants :

  • lecture simple, comptage du nombre d'enregistrements et de champs d'un gros csv ;
  • lecture simple, comptage du nombre d'enregistrements et de champs d'un csv contenant de grosses données ;
  • somme des éléments de la 2ème et de la 3ème colonne du csv, sortie sur stdout monocolonne (redirigé dans /dev/null pour les mesures de perf).

Le code utilisé pour les tests est disponible dans le fichier archive. Les programmes sont compilés avec gcc et l'option -O2. Les tests sont lancés dix fois, à chaud (le premier lancement est ignoré), et la valeur moyenne est retenue.

Il y a une chose importante à garder à l'esprit quand on compare les temps de la méthode dite « getline ». Celle-ci, telle qu'implémentée basiquement, ne respecte pas le standard csv et risque de renvoyer des résultats erronés sur certains csv. De plus, il existe énormément d'optimisations possibles à différents niveaux. Le choix de l'auteur a été d'essayer, lorsque c'était possible, de respecter l'esprit du test, c'est à dire que toutes les données doivent être potentiellement traitées.

IX-A. Lecture d'un gros csv

Le fichier utilisé est un fichier de 1 600 004 lignes contenant chacune 48 colonnes, pour une taille totale de 460 801 152 octets.

Image non disponible

La première chose à constater, c'est que les performances de notre parseur sont comparables à celles de libcsv, lorsqu'il est utilisé de la même manière (fonctions de rappel). Ensuite, il faut déplorer qu'il soit impossible de lire le fichier à l'aide de notre structure csv_data : l'occupation mémoire est tout simplement trop importante pour la machine de test. Cela est lié à l'utilisation des std::string (sur la même machine, allouer 76 800 192 « std::string » donne le même résultat : ça swappe à mort). Cela nous conforte dans l'idée que cette méthode ne peut s'appliquer que pour des fichiers de taille moyenne.

Enfin, le cas de getline est un peu particulier. Si on se contente juste de compter les « , » et les « \n », elle est bien la plus rapide. En revanche, si on souhaite se donner la possibilité d'exploiter toutes les données lues, elle devient plus lente que libcsv ou csv_parser.

IX-B. Lecture d'un csv avec de gros éléments

Le fichier utilisé est un fichier de 7113 lignes, chacune d'entre elles faisant 19 623 caractères, répartis sur trois colonnes, soit 21 449 champs au total.

Image non disponible

Peu de remarques particulières, si ce n'est que libcsv est étonnamment plus lent (il faudrait analyser le code, mais il est vraisemblable que la stratégie de réallocation de chaîne ne soit pas adaptée à des chaînes de caractères aussi grandes), et que csv_data se comporte très bien : l'empreinte mémoire étant plus réduite, la structure redevient parfaitement utilisable et on constate que ses performances sont plus que correctes. À l'inverse, getline est nettement plus rapide dans ce contexte.

IX-C. Somme des éléments de la deuxième et de la troisième colonne

Le but est, à partir des données suivantes :

 
Sélectionnez
12,34,46
34,76,28
1,87,32

D'obtenir la sortie suivante :

 
Sélectionnez
80
104
119

Le fichier utilisé fait 1 000 000 lignes, chacune de trois colonnes, pour une taille totale de 15 625 400 octets.

Image non disponible

Là encore, on constate que notre parseur se comporte au même niveau que libcsv, et fait mieux que le code naïf écrit à l'aide de getline. La structure csv_data, qui elle impose de tout garder en mémoire, est logiquement beaucoup plus lente. Enfin, la ligne csv_int_parser mérite quelques commentaires.

En effet, dans ce dernier test, nous ne traitons que des entiers. Quel intérêt de construire une chaîne, pour ensuite la convertir en un entier ? Autant construire un entier directement. csv_int_parser utilise donc cette propriété, en fournissant une classe de traits un peu particulière, définie de la sorte :

 
Sélectionnez
struct csv_int_traits
{
    typedef int string_type;
    typedef char char_type;
    void append(int& val, char c)
    {
        val = val * 10 + (c - '0');
    }
    void truncate(int & val)
    {
        val = 0;
    }
	...
}

Ce qui permet tout simplement d'améliorer les performances de 40 % environ, le tout sans toucher à notre parseur lui-même, et d'atteindre des performances qui sont tout simplement inaccessibles avec libcsv, et qui demanderaient de refondre complètement le code utilisant std::getline.

Attention tout de même avec ce type d'approche : le code présenté en exemple est particulièrement naïf, aucune validation n'y est effectuée. Dans la réalité, il faudra donc prévoir un système de gestion d'erreur (par exemple, des exceptions, une gestion par valeur de retour n'étant pas prévue ici, quoique pouvant être implémentée facilement), qui peut engendrer un léger surcoût.

IX-D. Récapitulatif

Si l'on devait tirer un rapide bilan de ces mesures de performances, certes forcément partielles, il serait le suivant :

  • les performances de notre approche sont de manière générale un peu meilleures que celles de libcsv, assez similaires dans le cas général. C'est logique, car libcsv utilise une approche similaire à la notre, inclut un peu plus de contrôle d'erreur que ce que nous avons fait, ce qui peut expliquer certaines différences (quoique la différence dans le test 2 est vraisemblablement plutôt à chercher du côté de l'allocation mémoire) ;
  • les performances en utilisant getline sont tantôt bien meilleures (dans des cas extrêmes), tantôt bien pires. Il serait logique qu'en améliorant chaque test, il soit possible de toujours faire mieux. Il ne faut néanmoins pas oublier qu'avec cette approche, on échoue sur certains fichiers csv ;
  • l'approche csv_data présente un surcoût qui est largement plus fonction du nombre de chaînes traitées que de la taille de celles-ci. Ce surcoût est systématique et réel, et peut conduire dans certains cas à un échec complet du traitement. De plus, la consommation mémoire maximale du programme, non reportée ici, est bien évidemment de plusieurs ordres de grandeur supérieure avec cette approche ;
  • l'approche que nous avons choisie, grâce à la généricité et l'usage de classe de traits, permet des optimisations intéressantes qui ne sont tout simplement pas réalisables avec libcsv, et qui demanderaient de repenser complètement la boucle de traitements dans une approche type getline.

X. Conclusions

Dans cet article, nous sommes partis d'un problème somme toute assez simple, pour arriver à une solution en apparence complexe, mais en réalité d'une grande flexibilité, et qui peut rester très simple à utiliser pour celui qui le désire. Au-delà de certaines techniques présentées ici, qui peuvent être un peu complexes à mettre en oeuvre ou encore relever pour certaines de la micro-optimisation, les grands principes à retenir sont plutôt les suivants :

  • séparez toujours la lecture de la source (fichier, socket, etc.), l'analyse des données et son traitement ;
  • privilégiez toujours, lors de l'analyse des données, les approches traversantes et l'usage de fonctions de rappel, afin d'être le moins intensif en mémoire. C'est le traitement qui doit déterminer ce qui a besoin d'être stocké en mémoire, pas la lecture des données ;
  • si votre bibliothèque a vocation à être utilisée de manière assez universelle, utilisez des classes de traits pour les types élémentaires. Vos utilisateurs vous en seront reconnaissants, car ils éviteront de nombreuses conversions de type inutiles ;
  • ne vous préoccupez que des problèmes qui vous concernent directement. Certains problèmes seront beaucoup mieux traités en amont ou en aval des services que vous fournissez ;
  • fournissez quand même quelques interfaces plus haut niveau, plus simples à utiliser, afin de ne pas réserver l'usage de votre bibliothèque à des experts/confirmés. La seule règle est que l'utilisateur ne doit payer que pour ce dont il a besoin.

J'espère que la lecture de cet article vous aura intéressé et qu'elle vous aura apporté de nouveaux éléments et de nouvelles idées pour vos prochaines réalisations. N'hésitez pas à donner votre avis sur ce tutoriel : 23 commentaires Donner une note à l´article (5). Les liens suivants sont aussi particulièrement intéressants ou en lien avec l'article :

Enfin, l'archive ZIP contenant le code utilisé dans cet article : Télécharger l'archive

XI. Remerciements

Merci à Luc Hermitte, LittleWhite et Claude Leloup pour leur relecture attentionnée, leurs remarques, ainsi que plus généralement à tous les membres de l'équipe développez.

Vous avez aimé ce tutoriel ? Alors partagez-le en cliquant sur les boutons suivants : Viadeo Twitter Facebook Share on Google+   

Licence Creative Commons
Le contenu de cet article est rédigé par Julien Blanc et est mis à disposition selon les termes de la Licence Creative Commons Attribution - Partage dans les Mêmes Conditions 3.0 non transposé.
Les logos Developpez.com, en-tête, pied de page, css, et look & feel de l'article sont Copyright © 2013 Developpez.com.