Accueil
Club Emploi Blogs   TV   Dév. Web PHP XML Python Autres 2D-3D-Jeux Sécurité Windows Linux PC Mac
Accueil Conception Java DotNET Visual Basic  C  C++ Delphi Eclipse MS-Office SQL & SGBD Oracle  4D  Business Intelligence

C++0x : Les rvalue references et le perfect forwading

Date de publication : 31 mai 2009

Par Arzar
 

En plus d'introduire la sémantique de déplacement, les rvalue reference du C++0x permettent de résoudre un autre problème sans rapport direct, le problème de la "transmission". Le problème de la "transmission" consisite à passer les paramètres d'une fonction générique à une autre, sans perdre aucun paramètre et en conservant le maximum d'information sur ces paramètres. En C++98, le problème peut se résoudre approximativement en écrivant de nombreuses surcharges et en négligeant certains cas. Nous verrons que le C++0x permet d'obtenir le même résultat sans aucune surcharge, pour tous les cas.

I. Introduction
I-A. Pré-requis
I-B. Le problème de la transmission
II. En C++98
II-A. Par valeur
II-B. Par référence constante
II-C. Par référence
II-D. Un pis-aller : la surcharge
III. En C++0x
III-A. Les rvalue references à la rescousse
III-B. Un raffinement : les templates variadiques
IV. Exemples d'utilisation de la transmission parfaite
IV-A. make_shared
IV-A-1. Deux défauts de shared_ptr
IV-A-2. Solution : make_shared()
IV-B. emplace
IV-B-1. La STL et les copies superflues
IV-B-2. Solution : la construction sur place avec emplace()


I. Introduction


I-A. Pré-requis

Les rvalues references : voir l'article : rvalue reference et sémantique de déplacement en C++0x
Programmation générique avec des templates : voir ...
Les pointeurs intelligents : http://loic-joly.developpez.com/tutoriels/cpp/smart-pointers/
La STL : voir ...


I-B. Le problème de la transmission

Imaginez une fonction fonction template générique, nommée exterieur(), dont le but est de prendre un certain nombre de paramètre en argument et de les passer (les "transmettre") à une fonction nommée intérieur().

template<typename... Arguments>
void exterieur(Arguments... arguments)
{
   // pré-traitment optionnel...
   
   interieur(arguments...);
   
   // post-traitment optionnel...
}
L'idéal à atteindre est le suivant : la transmission parfaite.

La fonction exterieur() doit transmettre ses paramètres de la manière la plus transparente et la plus fidèle possible à interieur(). Par exemple, tout usage d'interieur() qui compile avec un certain jeu de paramètre doit aussi compiler en passant les mêmes paramètres à exterieur(). De même, la qualification des paramètres (const, volatile...) ainsi que la manière dont on passe les paramètres (par valeur, par référence...) doit être préservée.

Cette article se propose d'étudier plus en détails le problème de la transmission, d'abord en C++98, puis détaillera l'apport du C++0x dans ce domaine. Il est décomposé en quatre parties :

Chapitre I : présente introduction.
Chapitre II : Décrit les limitations que l'on rencontre en essayant de résoudre le problème de la transmission en C++98.
Chapitre III : Décrit la solution apportée par le C++0x, solution qui utilise les rvalue references.
Chapitre IV : Décrit en détails trois utilisation de transmission parfaite en C++0x.


II. En C++98

Pour fixer les choses, limitons-nous provisoirement à deux arguments. La question est la suivante :

template<typename A, typename B>
void exterieur(___ a, ___ b)
{
   interieur(a, b);
}
Dans ce code, que faut-il écrire à la place des "___" pour obtenir la meilleure transmission possible ? Nous allons examiner successivement les trois possibilités disponibles en C++98 - par valeur, par référence et par référence constante, et montrer qu'à chaque fois la transmission obtenue ne couvre pas tous les cas.


II-A. Par valeur


template<typename A, typename B>
void exterieur(A a, B b)
{
   interieur(a, b);
}
Supposons que la signature d'interieur() soit interieur(std::istream& a, int b). Dans ce cas, interieur() prend une référence sur un flux istream existant. Cette utilisation est parfaitement légitime et compile. En revanche exterieur() ne peut pas passer par valeur le flux istream, car les flux ne sont pas copiables.

La compilation échoue. Le transmission par valeur ne couvre donc pas tous les cas.


II-B. Par référence constante


template<typename A, typename B>
void exterieur(const A& a, const B& b)
{
   interieur(a, b);
}
Supposons que la signature d'interieur soit interieur(A&, B&). La constance est alors violée. En effet, la fonction exterieur() en prenant des références constantes s'engage à ne pas modifier a et b, mais elle brise immédiatement cette promesse en appellant la fonction interieur() qui, elle, peut modifier a et b.

La compilation échoue. La transmission par référence constante ne couvre donc pas tous les cas.


II-C. Par référence


template<typename A, typename B> 
void exterieur(A& a, B& b)
{
   interieur(a, b);
}
Supposons que la signature s'interieur() soit interieur(std::string& a, int b). Il est dans ce cas parfaitement possible d'écrire ce code :

std::string s = "foo";
interieur(s, 1984); // OK 
Et pourtant

std::string s = "foo";
exterieur(s, 1984); // erreur 
ne compile pas !

Le problème vient du fait qu'exterieur() prend son deuxième argument par référence et qu'on tente de lui passer un int temporaire. Or il n'est pas possible de prendre un temporaire par référence. Cela n'aurait en effet pas beaucoup de sens de vouloir modifier un pur nombre, comme "1984", qui n'est pas une variable possédant un nom, comme s, et qui s'évapore à la fin de l'expression (au point virgule).

La compilation échoue. La transmission par référence ne couvre donc pas tous les cas.


II-D. Un pis-aller : la surcharge

Les trois tentatives précédentes montrent que le problème de la transmission ne peut pas être résolue de manière simple en C++98.

Il existe cependant une solution qui vaut ce qu'elle vaut : la surcharge. Il est en effet possible de créer un jeu de surcharge de la fonction exterieur() comprenant toutes les combinaisons possible de référence et de référence constantes. Les règles de résolution de surcharge entrent alors en jeu et le compilateur séléctionne automatiquement la bonne surcharge en fonction des paramètres requis par interieur().

Un tel jeu de surcharge permet effectivement de couvrir toutes les possibilitées et résout le problème de la transmission. Malheureusement, le nombre de surcharge à écrire double avec le nombre d'arguments... En pratique, cette explosion du nombre de surcharge devient vite rédhibitoire à mettre en oeuvre.

// 1 argument, 2 surcharges
template<typename A> void exterieur(A&);
template<typename A> void exterieur(const A&);

//2 arguments, 4 surcharges
template<typename A, typename B> void exterieur(A&, B&);
template<typename A, typename B> void exterieur(const A&, B&);
template<typename A, typename B> void exterieur(A&, const B&);
template<typename A, typename B> void exterieur(const A&, const B&);

//3 arguments, 8 surcharges
template<typename A, typename B, typename C> void exterieur(A&, B&, C&);
template<typename A, typename B, typename C> void exterieur(A&, const B&, C&);
template<typename A, typename B, typename C> void exterieur(A&, B&, const C&);
template<typename A, typename B, typename C> void exterieur(A&, const B&, const C&);
template<typename A, typename B, typename C> void exterieur(const A&, B&, C&);
template<typename A, typename B, typename C> void exterieur(const A&, const B&, C&);
template<typename A, typename B, typename C> void exterieur(const A&, B&, const C&);
template<typename A, typename B, typename C> void exterieur(const A&, const B&, const C&);

// 4 arguments, 16 surcharges !
// ... 

III. En C++0x


III-A. Les rvalue references à la rescousse

C'est ici qu'interviennent les rvalue references. Il faut d'ailleurs noter qu'il est assez remarquable qu'une feature comme les rvalue reference, introduite principalement pour autoriser la sémantique de déplacement ait trouvé une utilisation dans un contexte complètement différent.

Sans plus de cérémonie, voici la solution du C++0x au problème de la transmission :

#include <utility> // pour bénéficier de std::forward
template<typename A, typename B> 
void exterieur(A&& a, B&& b)
{
   interieur(std::forward<A>(a), std::forward<B>(b));
}
La transmission parfaite se résume à ce bout de code ! exterieur() doit prendre en argument des rvalue reference et les transmettre à interieur() par le biais d'une nouvelle fonction template de la bibliothèque standart, nommée std::forward(), disponible dans le header <utility>.

Plus precisement, la transmission parfaite fait intervenir :

1) Des règles spéciales lors de la déduction des templates. Ces règles spéciales, nouvelles et conçues pour la transmission parfaite, s'activent uniquement en présence de rvalue reference.
2) std::forward. Cette nouvelle fonction de la bibliothèque standard, elle aussi concue spécialement pour la transmission parfaite, permet de "reconstruire" les qualifications d'un paramètre (par exemple const) à partir d'une rvalue reference.

Les détails des règles spéciales de déduction des templates et du fonctionnement de std::forward sont difficiles. La diversité des cas à prendre en compte est un vrai challenge, comme nous l'a montré le chapitre II. Nous ne les aborderont donc pas ici. La bonne nouvelle, c'est que la compréhension de ces détails n'est absolument pas requis pour utiliser la transmission parfaite. En fait, son utilisation est même particulièrement simple ! Par exemple :

#include <utility> // pour bénéficier de std::forward
template<typename A, typename B> 
void exterieur(A&& a, B&& b)
{
   interieur(std::forward<A>(a), std::forward<B>(b));
}

void interieur(std::string& s, int i)
{
   //...
}

int main()
{
   std::string s = "foo";
   exterieur(s, 1984); // transmission parfaite!
}  
Un petit bémol : il faut garder en tête que la transmission parfaite n'est atteinte que si exterieur() est une fonction template, car les règles spéciales de déduction des template sont obligatoires. Une fonction exterieur() non template échoue dans certains cas, comme en C++98.

exterieur(string&& s, int&& i)
{
   interieur(std::forward<std::string>(s), std::forward<int>(i));
}

void interieur(std::string& s, int i)
{
   //...
}

int main()
{
   std::string s = "foo";
   exterieur(s, 1984); // erreur
}

III-B. Un raffinement : les templates variadiques

Une autre nouveauté du C++0x permet de porter le coup de grâce au problème de la transmission : Il s'agit des template variadiques. Les fonctions template qui utilisent les templates variadiques sont capables de recevoir un nombre quelconque de paramètre en argument. La syntaxe des templates variadiques, en "...", est d'ailleurs similaire à celle des fonctions variadiques héritées du C (comme printf).

Les rvalue reference et les templates variadiques forment un mariage particulièrement heureux : La transmission des paramètres de exterieur() vers interieur() se résume alors à une seule fonction qui marche dans tous les cas !

#include <utility> 

// template variadic, syntaxe en ...
template<typename... Args>  
void exterieur(Args&&... args)
{
   interieur(std::forward<Args...>(args));
}

struct MyStruct
{
   MyStruct() = default
   MyStruct(int i){}
}

// quelques surcharges de la fonction interieur()
void interieur(int i){}
void interieur(int i, float f){}
void interieur(int i, float f, double d){}
void interieur(int i, float f, double d, std::string s){}
void interieur(int i, float f, double d, std::string s, MyClass mc){}

int main()
{
   // exterieur() transmet fidèlement ses paramètres à la bonne surcharge d'intérieur()
   exterieur(1);
   exterieur(1, 2.0f);
   exterieur(1, 2.0f, 3.0);
   exterieur(1, 2.0f, 3.0, "4");
   exterieur(1, 2.0f, 3.0, "4", MyClass(5));
}  
Le résultat est impressionant. L'unique fonction exterieur() est parfaitement générique et uniforme. Quelles que soient le nombre de paramètre, quels que soient le type des paramètres, quelle que soient la manière de passer ces paramètres (par valeur, par référence...) et quelle que soient la qualification de ces paramètres (const, volatile...), exterieur() transmet fidèlement à interieur() sans aucune perte d'information.


IV. Exemples d'utilisation de la transmission parfaite

L'élaboration de la technique de la transmission parfaite est récente (2007) mais il est d'ores et déjà prévu de l'utiliser ponctuellement en C++0x pour certaines fonctions de la librairie standard. Nous proposons de détailler dans cette article make_shared() et emplace() deux fonctions qui seront dans la bibliothèque standard du C++0x ainsi qu'un exemple du design pattern Factory tirant partit de la transmission parfaite.


IV-A. make_shared

Le C++0x propose une classe de pointeur intelligent particulièrement utile : shared_ptr.

shared_ptr détient un pointeur sur une ressource ainsi qu'un compteur de référence. Ce compteur s'incrémente lors d'une copie du shared_ptr et se décrémente lorsqu'un des possesseurs du shared_ptr est détruit. Si le compteur atteint zéro, alors plus personne ne possède le shared_ptr et la ressource est automatiquement libérée. La gestion de la mémoire est considérablement simplifiée par shared_ptr, qui, de fait, est une des nouveauté les plus attendus du C++0x.


IV-A-1. Deux défauts de shared_ptr

Cependant, shared_ptr est perfectible. En particulier l'utilisation basique d'un shared_ptr possède deux petits défauts qui apparaissent lors de son initialisation.
Un exemple simple suffit pour les mettre en lumière :

#include <memory> // pour bénéficier de shared_ptr
class MaClasse
{
   MaClasse() = default;
   MaClasse(std::string s1, int& i, const std::string& s2){/*...*/}
};

int main()
{
   std::string s = "foo";
   int i = 3;

   std::shared_ptr<MaClasse> sp = std::shared_ptr<MaClasse>( new MaClasse(s, i, "bar"));
}
 
1) La syntaxe est inutilement verbeuse. Le type de l'objet (MaClasse) est répété deux fois.

2) Le shared_ptr lance deux allocations, une pour l'objet MaClasse (le new MaClasse(s, i "bar")) et une pour le compteur de référence (un autre new, dans le constructeur de shared_ptr). C'est parce que shared_ptr est non-intrusif, c'est à dire que ce n'est pas l'objet lui-même qui gère la logique du comptage de référence mais un compteur externe. Si l'on utilise le constructeur de shared_ptr comme dans l'exemple, le compteur est instancié dans un bloc à part, ailleurs en mémoire et doit posséder un pointeur sur l'objet MaClasse. Ce n'est clairement pas optimal en terme de performance. Il serait préferable de n'avoir qu'une seule allocation regroupant l'objet et son compteur dans le même bloc.

Est-ce que nous tombons dans le piège de l'optimisation prématurée ? Ici, définitivement non. shared_ptr est destiné à être utilisé par le plus grand nombre possible de programmeur C++, dans une myriade de situations différentes. shared_ptr se doit donc d'exhiber des perfomances maximales s'il veut convaincre.


IV-A-2. Solution : make_shared()

C'est finalement une nouvelle fonction de la bibliothèque standard, nommée make_shared(), disponible dans le header <memory>, qui vient corriger ces deux petits défauts. Voici un exemple d'utilisation de make_shared :

#include <memory> // pour bénéficier de shared_ptr et de make_shared
class MaClasse
{
   MaClasse() = default;
   MaClasse(std::string s1, int& i, const std::string& s2){/*...*/}
};

int main()
{
   std::string s = "foo";
   int i = 3;

   std::shared_ptr<MaClasse> sp = std::make_shared<MaClasse>(s, i, "bar");
}
 
La syntaxe est plus courte et plus claire car le type MaClasse n'est pas répété. Plus intéressant, l'utilisation de make_shared ressemble à s'y méprendre aux exemples de transmission parfaite que nous avons étudié dans le chapitre II. Un regard sur le prototype fini par nous convaincre :

template<class T, class... Args> 
shared_ptr<T> make_shared(Args&&... args);
 
Nous avons effectivement affaire à de la transmission parfaite. Elle permet en fait ici de délayer l'allocation de l'objet MaClasse dans la fonction make_shared(). Sans que l'utilisateur ne s'en rende compte, make_shared() va pouvoir allouer l'objet et le compteur à sa sauce, en une seule fois, et en les collant l'un à l'autre pour obteir des performances maximales.

En résumé, make_shared exhibe une utilisation brillante de la transmission parfaite. Plus claire, plus performante, l'utilisation de make_shared est aussi plus sûre, car elle est exception-safe dans certains cas (non détaillés ici) où l'utilsation basique du constructeur de shared_ptr ne l'est pas.

idea Conseil : Utilisez make_shared() dès que possible pour initialiser vos shared_ptr en C++0x.

IV-B. emplace


IV-B-1. La STL et les copies superflues

L'utilisation de la copie est un principe fondateur qui serpente dans toute la STL. Cette approche possède de très nombreux avantages (non détaillé ici), mais parfois certaines copies semblent un peu superflue. Par exemple :

#include <vector>
class MaClasse
{
   MaClasse() { std::cout << "Constructeur" << std::endl; }
   MaClasse(const MaClasse&) { std::cout << "Constructeur par copie" << std::endl; }
};

std::vector<MaClasse> vec;
vec.push_back(MaClasse()); // création d'un MaClasse temporaire, puis copie de ce temporaire dans vec.
 
Le code ci-dessus affiche :
1) Constructeur
2) Constructeur par copie

Pourtant l'intention du programmeur était d'ajouter directement une instance de MaClasse au conteneur. La création d'une instance temporaire de MaClasse n'est donc pas vraiment justifiée.

En C++0x, on peut faire mieux grace à la sémantique de déplacement :

class MaClasse
{
   MaClasse() { std::cout << "Constructeur" << std::endl; }
   MaClasse(const MaClasse&) { std::cout << "Constructeur par copie" << std::endl; }
   MaClasse(MaClasse &&) { std::cout << "Constructeur par déplacement" << std::endl; }
};
(
std::vector<MaClasse> vec;
vec.push_back(MaClasse()); // création d'un MaClasse temporaire, puis déplacement de ce temporaire dans vec.
 
Le code ci-dessus affiche :
1) Constructeur
2) Constructeur par déplacement

Si MaClasse possède des membres adaptés au déplacement, comme des std::string ou des unique_ptr, alors le constructeur par déplacement peut être redoutablement efficace. Par exemple, si MaClasse possède une std::string comme membre alors l'opération de déplacement se résume à :

1) Création de l'objet MaClasse temporaire contenant la string. (couteux)
2) Le constructeur par déplacement de l'objet MaClasse d'arrivée (dans le vecteur) crée une string vide (peu couteux)
3) Le pointeur sur la chaine de caractère de la string vide est echangé avec le pointeur sur la chaine de caractère de la string de l'objet MaClasse temporaire. (peu couteux)
4) L'objet MaClasse temporaire, qui possède donc maintenant une string vide, est détruit (peu couteux).

Au final, la seule opération véritablement couteuse est la création de l'instance temporaire. Le déplacement, lui, est efficace. Mais remplaçons maintenant MaClasse par cette classe :

struct Vecteur3D
{
   float x;
   float y;
   float z;
};
 
Dans ce cas, le constructeur par déplacement n'est d'aucun secours, car les types natifs, comme les float; ne peuvent pas être déplacés, seulement copiés. De même les classes ne possédant pas de constructeur par déplacement se rabattent sur leur contructeur par copie. La sémantique de déplacement ne pallie donc pas tous les cas.

Il manque en fait une notion fondamentale au conteneur de la STL : la construction directe, "sur-place", d'un objet dans un conteneur, sans aucune copie.


IV-B-2. Solution : la construction sur place avec emplace()

C'est une nouvelle famille de fonction qui règle le problème : la famille des "emplace" Par exemple, emplace_back(), le pendant de push_back() permet de construire sur place un nouvel objet en queue de conteneur.

#include <vector>
struct Vecteur3D
{
   Vecteur3D(float x, float y, float z): x(x), y(y), z(z){}
   float x, y, z;
};

std::vector<Vecteur3D> base;
base.emplace_back(1.0f, 0.0f, 0.0f);
base.emplace_back(0.0f, 1.0f, 0.0f):
base.emplace_back(0.0f, 0.0f, 1.0f):
Comme pour make_shared, l'utilisation d'emplace_back() fait imanquablement penser à de la transmission parfaite. Le prototype confirme :
template <typename... Args> void emplace_back(Args&&... args);
Ici encore, la transmission parfaite permet de délayer la création du nouvel objet, ce qui permet de le construire directement, "en place", dans le vecteur.

En C++0x la famille des emplace() est disponible pour tous les conteneurs de la STL. Grosso modo, chaque insert(), push_back() et push_front() possède maintenant son équivalent en terme d'emplace(). Par exemple, pour une map, emplace() s'utilise comme ceci :

#include <map>
#include <string>
struct Vecteur3D
{
   Vecteur3D(float x, float y, float z): x(x), y(y), z(z){}
   float x, y, z;
};

std::map<std::string, Vecteur3D> base;

// C++98 : première solution, insert
// Nombreuses copies, d'abord pour construire la paire, ensuite pour copier la paire dans la map.
base.insert(std::make_pair("X", Vecteur3D(1.0f, 0.0f, 0.0f)));
base.insert(std::make_pair("Y", Vecteur3D(0.0f, 1.0f, 0.0f)));
base.insert(std::make_pair("Z", Vecteur3D(0.0f, 0.0f, 1.0f)));

// C++98 : deuxième solution, l'operateur []
// Crée un Vecteur3D temporaire, puis assigne le temporaire dans la map.
base["X"] = Vecteur3D(1.0f, 0.0f, 0.0f);
base["Y"] = Vecteur3D(0.0f, 1.0f, 0.0f);
base["X"] = Vecteur3D(0.0f, 0.0f, 1.0f);

// C++0x
// Pas de copie, construction du Vecteur3D sur place.
base.emplace("X", 1.0f, 0.0f, 0.0f);
base.emplace("Y", 0.0f, 1.0f, 0.0f):
base.emplace("Z", 0.0f, 0.0f, 1.0f):
Dans le cas d'une std::map, l'avantage d'emplace() est très net. En effet, une std::map manipule des std::pair, rajoutant une couche suplémentaire entre la map et l'objet, ce qui a tendance à créer des copies superflues. emplace() permet de couper court à ces copies en "percant" toutes les couches intérmédiaires pour construire directement sur place, dans la map.

L'exemple qui suit permet de prendre la mesure du nombre de copie effectuées en sous-main lors d'une utilisation basique d'une map en C++98.

struct Vecteur3D
{ 
   Vecteur3D(): x(0.0f), y(0.0f), z(0.0f){std::cout << "Constructeur()" << std::endl;}
   Vecteur3D(float x, float y, float z): x(x), y(y), z(z){std::cout << "Constructeur(float, float,  float)" << std::endl;}
   Vecteur3D(const Vecteur3D&){std::cout << "Constructeur par copie" << std::endl;}
   Vecteur3D& operator=(const Vecteur3D&){std::cout << "Opérateur =" << std::endl; return *this;}
   float x, y, z;
};

std::map<std::string, Vecteur3D> base;

std::cout << "insert, clé non présente"  << std::endl;
base.insert(std::make_pair("X", Vecteur3D(1.0f, 0.0f, 0.0f)));
std::cout << "insert, clé présente"  << std::endl;
base.insert(std::make_pair("X", Vecteur3D(0.0f, 1.0f, 0.0f)));
std::cout << std::endl;
std::cout << "op[], clé non présente"  << std::endl;
base["Y"] = Vecteur3D(0.0f, 0.0f, 1.0f);
std::cout << "op[], clé présente"  << std::endl;
base["Y"] = Vecteur3D(1.0f, 1.0f, 0.0f);
std::cout << std::endl;
std::cout << "emplace, clé non présente"  << std::endl;
//base.emplace("Z", 0.0f, 1.0f, 1.0f); // c++0x, pas encore implémenté
std::cout << "emplace, clé présente"  << std::endl;
//base.emplace("Z", 1.0f, 1.0f, 1.0f); //  c++0x, pas encore implémenté
On obtient, en release avec Visual Studio 2010 Beta :

 insert  operateur[]  emplace (prévision)
 clé non présente  clé non présente  clé non présente
 Constructeur(float, float, float)
Constructeur par copie
Constructeur par copie
 Constructeur(float, float, float)
Constructeur()
Constructeur par copie
Constructeur par copie
Opérateur =
 Constructeur(float, float, float)
 clé présente  clé présente  clé présente
 Constructeur(float, float, float)
Constructeur par copie
Constructeur par copie
 Constructeur(float, float, float)
Opérateur =
 Constructeur(float, float, float)


Les sources présentés sur cette page sont libres de droits, et vous pouvez les utiliser à votre convenance. Par contre cette page de présentation de ces sources constitue une oeuvre intellectuelle protégée par les droits d'auteurs. Copyright ©2009  Developpez LLC. Tous droits réservés Developpez LLC. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents et images sans l'autorisation expresse de Developpez LLC. Sinon vous encourez selon la loi jusqu'à 3 ans de prison et jusqu'à 300 000 E de dommages et intérêts. Cette page est déposée à la SACD.

Vos questions techniques : forum d'entraide Accueil - Publiez vos articles, tutoriels, cours et rejoignez-nous dans l'équipe de rédaction du club d'entraide des développeurs francophones. Nous contacter - Copyright 2000..2005 www.developpez.com