Introduction▲
Dans cet article, je vais explorer et démolir cinq mythes populaires sur le C++ :
- « Pour comprendre le C++, vous devez d'abord apprendre le C ».
- « Le C++ est un langage orienté objet ».
- « Pour écrire un programme fiable, il faut un ramasse-miettes (garbage collector) ».
- « Du code performant doit être de bas niveau ».
- « Le C++ n'est pertinent que pour les programmes gros ou compliqués ».
Si vous croyez en un seul de ces mythes, ou si vous avez des collègues qui les perpétuent, ce court article vous est destiné. Plusieurs de ces mythes ont pu être vrais pour certaines personnes, pour certaines tâches, à une certaine époque. Désormais, avec le C++ d'aujourd'hui et en utilisant des compilateurs à jour avec le standard ISO C++2011 (disponibles un peu partout), ils ne sont plus que de simples mythes.
Je qualifie ces mythes de « courants », car je les entends souvent. Parfois, ils sont étayés par des arguments, mais ils sont le plus souvent énoncés comme évidents et autosuffisants. Parfois, ils servent à disqualifier le C++ pour certains usages.
Chaque mythe mériterait un long article, voire un livre, pour être totalement éradiqué, mais mon objectif ici est simplement de soulever le problème et de brièvement énoncer mes arguments.
1. Mythe n° 1 : « Pour comprendre le C++, vous devez d'abord apprendre le C »▲
Non. Apprendre les bases de la programmation est bien plus simple en C++ qu'en C.
Le C est presque un sous-ensemble du C++, mais ce n'est pas le sous-ensemble idéal pour commencer à apprendre, car il lui manque les notations, la sécurité des types et une bibliothèque standard bien plus simple à utiliser, toutes sortes de choses que fournit le C++ pour simplifier les tâches basiques. Prenons par exemple une fonction triviale qui permet de composer une adresse mail :
string compose(const
string&
name, const
string&
domain)
{
return
name+
'@'
+
domain;
}
On peut l'utiliser ainsi :
string addr =
compose("gre"
,"research.att.com"
);
La version C nécessite de manipuler les caractères à la main, et de gérer explicitement les allocations/libérations de mémoire :
char
*
compose
(
const
char
*
name, const
char
*
domain)
{
char
*
res =
malloc
(
strlen
(
name)+
strlen
(
domain)+
2
); // assez d'espace pour les chaînes, '@', et le 0 final
char
*
p =
strcpy
(
res,name);
p +=
strlen
(
name);
*
p =
'
@
'
;
strcpy
(
p+
1
,domain);
return
res;
}
On peut l'utiliser ainsi :
char
*
addr =
compose
(
"
gre
"
,"
research.att.com
"
);
// …
free
(
addr); // libérer la mémoire quand on a fini
Quelle version préféreriez-vous enseigner ? Quelle version est la plus simple d'usage ? Est-ce que ma version en C est correcte ? En êtes-vous sûr ? Pourquoi ?
Et finalement, quelle version a des chances d'être la plus efficace ? Eh oui, la version en C++, car elle n'a pas besoin de compter le nombre de caractères dans les chaînes, et elle n'utilise pas d'allocation dynamique pour les chaînes de petite taille.
1-1. Apprendre le C++▲
Il ne s'agit pas là d'un cas particulier isolé. Je le considère comme totalement typique. Pourquoi alors de nombreux enseignants persistent à utiliser l'approche « le C d'abord » ?
- Parce que c'est ce qu'ils ont toujours fait.
- Parce que c'est ce que le programme d'enseignement demande.
- Parce que c'est la manière avec laquelle ils ont eux-mêmes débuté dans leur jeunesse.
- Parce que la plus petite taille du C donne l'illusion qu'il est plus simple à utiliser que le C++.
- Parce que de toute façon, un jour ou l'autre, les étudiants auront aussi à apprendre le C (ou le sous-ensemble C du C++).
Néanmoins, le C n'est ni le sous-ensemble le plus simple ni le plus utile à apprendre en premier. De plus, une fois que vous comprenez raisonnablement le C++, le sous-ensemble C vient facilement. Enfin, commencer par le C implique de faire face à des erreurs qui seraient aisément évitées en C++, et à devoir apprendre des techniques pour en minimiser les effets.
Pour une approche moderne de l'apprentissage du C++, je vous invite à consulter mon livre, Programmation - Principes et pratique avec C++(1)[13]. Il contient même un chapitre vers la fin montrant comment utiliser le C. Il a été utilisé, avec un succès raisonnable, par des centaines de milliers d'étudiants débutants dans plusieurs universités. Sa seconde édition(2) utilise le C++11 et le C++14 pour faciliter l'apprentissage.
Avec le C++11[11-12], le C++ est devenu plus abordable pour les novices. Par exemple, voici un vector de la bibliothèque standard initialisé avec une séquence d'éléments :
vector<
int
>
v =
{
1
,2
,3
,5
,8
,13
}
;
En C++98, il n'y avait que les tableaux que l'on pouvait initialiser à l'aide de liste. En C++11, on peut définir, dans tous les types où on le souhaite, un constructeur qui accepte une liste d'initialisation définie à l'aide de {}.
On peut alors parcourir ce vecteur à l'aide d'une boucle sur intervalle(3) :
for
(int
x : v) test(x);
Ce code appellera test() une fois par élément de v.
Une boucle sur intervalle peut parcourir toutes sortes d'intervalles. On aurait ainsi pu simplifier l'exemple en utilisant directement la liste d'initialisation :
for
(int
x : {
1
,2
,3
,5
,8
,13
}
) test(x);
L'un des objectifs du C++11 était de rendre simples les choses simples. Tout ça bien entendu, sans causer de dégradation des performances.
2. Mythe n° 2 : « Le C++ est un langage orienté objet »▲
Non. Le C++ supporte la programmation orientée objet, et d'autres styles de programmation, mais il n'est pas limité à une vision dogmatique de l'orienté objet. Il prend en charge une synthèse de différentes techniques de programmation, parmi lesquelles l'orienté objet et la programmation générique. Dans la majorité des cas, la meilleure solution à un problème demande la mise en œuvre de plusieurs styles (aussi nommés « paradigmes »). Et par « meilleure », je veux dire plus courte, plus compréhensible, plus efficace, plus maintenable, etc.
Le mythe « Le C++, c'est de la POO » conduit beaucoup de gens à penser que le C++ n'apporte rien (par rapport au C), sauf si vous avez besoin de grandes hiérarchies de classes avec beaucoup de fonctions virtuelles (polymorphisme lors de l'exécution) ; et pour beaucoup de gens, dans beaucoup de domaines, ce n'est pas le cas. La croyance en ce mythe conduit d'autres personnes à condamner le C++ pour ne pas être uniquement orienté objet : après tout, si pour vous, « orienté objet » est synonyme de « bon », le fait que le C++ contienne visiblement beaucoup d'éléments qui ne sont pas orientés objet doit être synonyme de « pas terrible ». Dans les deux cas, ce mythe fournit une bonne excuse pour ne pas apprendre le C++.
Prenons un exemple :
void
rotate_and_draw(vector<
Shape*>&
vs, int
r)
{
for_each(vs.begin(),vs.end(), [](Shape*
p) {
p->
rotate(r); }
); // tourne tous les éléments de vs
for
(Shape*
p : vs) p->
draw(); // dessine tous les éléments de vs
}
Ce code est-il orienté objet ? Bien sûr : tout tourne autour d'une hiérarchie de classes avec des fonctions génériques. Ce code est-il générique ? Bien sûr : tout tourne autour d'un conteneur paramétré (vector) et de la fonction générique for_each. Ce code est-il fonctionnel ? Plus ou moins : il utilise une fonction lambda (la construction avec les []). Qu'est-ce que c'est alors ? C'est du C++ moderne, du C++11.
J'ai utilisé côte à côte l'itération sur un intervalle et l'algorithme for_each de la bibliothèque standard pour les besoins de la démonstration. Dans du vrai code, j'aurais utilisé un seul type de boucle, au choix.
2-1. Programmation générique▲
Souhaiteriez-vous rendre ce code plus générique ? Après tout, il ne fonctionne que pour des vectors de pointeurs sur Shape. Comment faire pour les listes et les tableaux de base du langage ? Et si les objets sont gérés par des « pointeurs intelligents » (des pointeurs qui aident à gérer les ressources), comme shared_ptr et unique_ptr ? Comment faire pour les objets qui ne s'appellent pas Shape, mais que l'on peut quand même dessiner (draw()) et tourner (rotate()) ?
template
<
typename
Iter>
void
rotate_and_draw(Iter first, Iter last, int
r)
{
for_each(first,last,[](auto
p) {
p->
rotate(r); }
); // tourne tous les éléments compris dans [first:last)
for
(auto
p =
first; p!=
last; ++
p) p->
draw(); // dessine tous les éléments compris dans [first:last)
}
Ce code fonctionne pour n'importe quel intervalle que l'on peut parcourir de first à last. C'est le style utilisé par les algorithmes de la bibliothèque standard. J'ai utilisé auto pour ne pas avoir à nommer le type servant d'interface pour les « objets qui se comportent comme des Shapes». C'est une fonctionnalité du C++11 qui signifie « utilise le type de l'expression servant à initialiser la variable », donc le type de p dans la boucle est résolu vers le type de first, quel qu'il soit. L'utilisation de auto pour le type d'un argument d'une lambda est une fonctionnalité du C++14, mais déjà disponible de nos jours.
Regardons maintenant :
void
user(list<
unique_ptr<
Shape>>&
lus, Container<
Blob>&
vb)
{
rotate_and_draw(lst.begin(),lst.end());
rotate_and_draw(begin(vb),end(vb));
}
Ici, je suppose que Blob est un type graphique qui dispose des opérations draw() et rotate(), et que Container est un conteneur de données. Le type std::list (les listes chaînées de la bibliothèque standard) possède des fonctions membres begin() et end() pour permettre à l'utilisateur de parcourir ses éléments. C'est pratique, et classique en POO. Mais que faire si Container n'utilise pas la même notation que la bibliothèque standard pour parcourir un intervalle semi-ouvert [b,e) ? S'il n'a pas de fonctions begin() et end() ? Je n'ai jamais rencontré de conteneur que l'on ne puisse pas parcourir, on peut donc définir des fonctions libres begin() et end() avec la sémantique appropriée. Le standard fournit déjà ces fonctions pour les tableaux C, et donc si Container est un tableau C, le problème est déjà résolu ; et les tableaux C restent couramment utilisés.
2-2. Adaptation▲
Voyons maintenant un cas plus complexe : Que faire si Container contient des pointeurs sur des objets et possède un modèle d'accès et de parcours différent ? Par exemple, supposons que l'on soit censé parcourir un Container de la manière suivante :
for
(auto
p =
c.first(); p!=
nullptr
; p=
c.next())
{
/* on fait quelque chose avec *p */
}
Ce style n'est pas rare. On peut le faire correspondre avec une séquence [b, e) comme suit :
template
<
typename
T>
struct
Iter {
T*
current;
Container<
T>&
c;
}
;
template
<
typename
T>
Iter<
T>
begin(Container<
T>&
c) {
return
Iter<
T>{
c.first(),c}
; }
template
<
typename
T>
Iter<
T>
end(Container<
T>&
c) {
return
Iter<
T>{
nullptr
}
; }
template
<
typename
T>
Iter<
T>
operator
++
(Iter<
T>
p) {
p.current =
c.next(); return
this
; }
template
<
typename
T>
T*
operator
*
(Iter<
T>
p) {
return
p.current; }
On peut remarquer que cette modification est non intrusive : on n'a pas eu à modifier le code de Container ni une éventuelle hiérarchie de classes autour de Container pour adapter un Container au modèle de parcours attendu par la bibliothèque standard du C++. Il s'agit d'une adaptation, pas d'une modification.
J'ai choisi cet exemple pour montrer que ces techniques de programmation génériques ne sont pas limitées à la bibliothèque standard (où elles sont omniprésentes). De plus, d'après les définitions les plus communes de « orienté objet », elles ne sont pas orientées objet.
Cette idée que le code C++ devrait être orienté objet (en utilisant des hiérarchies de classes et des fonctions virtuelles partout) peut grandement détériorer les performances. Cette vision de l'orienté objet est géniale si, à l'exécution, vous avez besoin de résoudre un type parmi un ensemble. Je l'utilise souvent à cet effet. En revanche, elle est assez rigide (tous les types reliés entre eux ne rentrent pas aisément dans une hiérarchie de classes) et les appels virtuels de fonctions empêchent les possibilités d'inlining (ce qui peut se traduire par un rapport de vitesse d'exécution de 50 dans des cas simples et importants).
3. Mythe n° 3 : « Pour écrire un programme fiable, il faut un ramasse-miettes (garbage collector) »▲
Les ramasse-miettes font un travail correct, mais pas parfait, quand il s'agit de récupérer de la mémoire non utilisée. Ce n'est pas la panacée. La mémoire peut rester bloquée indirectement, et il y a de nombreuses ressources autres que de la mémoire.
Par exemple :
class
Filter {
// lit une entrée sur le fichier iname et écrit sur le fichier oname
public
:
Filter(const
string&
iname, const
string&
oname); // constructeur
~
Filter(); // destructeur
// ...
private
:
ifstream is;
ofstream os;
// ...
}
;
Le constructeur de ce Filter ouvre deux fichiers. Puis, il effectue des opérations sur les données lues en entrée pour générer des données en sortie. Ces opérations pourraient être codées en dur dans la classe Filter, fournies par un lambda ou spécifiées dans une fonction virtuelle redéfinie dans une classe fille. Ces détails importent peu dans une discussion sur la gestion des ressources. On peut créer des Filter ainsi :
void
user()
{
Filter flt {
"books"
,"authors"
}
;
Filter*
p =
new
Filter{
"novels"
,"favorites"
}
;
// on utilise flt et *p
delete
p;
}
D'un point de vue gestion de ressources, le problème ici est de garantir que les fichiers sont fermés et que les ressources associées à ces flux sont bien libérées pour pouvoir être réutilisées dans un autre contexte.
La solution traditionnelle dans les langages avec garbage collector est d'éliminer le delete (qui est aisément oublié, ce qui conduit à des fuites mémoire) et le destructeur (car les langages à base de garbage collector ont rarement la notion de destructeur, et il vaut mieux éviter les «finalizers » qui sont remplis de pièges et peuvent dégrader les performances). Un garbage collector va récupérer toute la mémoire, mais on a besoin d'actions de l'utilisateur (du code) pour fermer les fichiers et libérer les ressources autres que la mémoire (les verrous, par exemple) associées aux flux. La mémoire est donc automatiquement (et dans ce cas parfaitement) gérée, mais la gestion des autres ressources est manuelle et sujette à des erreurs et fuites.
La solution classiquement recommandée en C++ consiste à utiliser les destructeurs pour s'assurer que les ressources sont libérées. En général, ces ressources sont acquises dans un constructeur, ce qui a donné à cette technique simple et universelle le nom maladroit de RAII (Resource acquisition is initialization, l'acquisition de ressource se fait à l'initialisation). Dans la fonction user(), le destructeur de flt appelle implicitement les destructeurs des flux is et os. À leur tour, ces destructeurs ferment le fichier et libèrent les ressources associées aux flux. Le delete ferait de même pour *p.
Les développeurs C++ expérimentés auront remarqué que la fonction user() est assez maladroite, et contient des risques d'erreurs. La version qui suit serait meilleure :
void
user2()
{
Filter flt {
"books"
,"authors"
}
;
unique_ptr<
Filter>
p {
new
Filter{
"novels"
,"favorites"
}}
;
// on utilise flt et *p
}
Maintenant, *p est automatiquement libéré à la sortie de user(). Le développeur ne risque plus d'oublier de le faire. unique_ptr est une classe de la bibliothèque standard conçue pour gérer les ressources sans coût additionnel, ni en temps ni en mémoire, par rapport aux pointeurs « nus ».
Néanmoins, le new est encore visible, cette solution est un peu verbeuse (le type Filter est répété deux fois), et séparer la construction du pointeur ordinaire (avec new) de celle du pointeur intelligent (ici, unique_ptr) désactive certaines optimisations importantes. On peut améliorer la situation à l'aide d'une fonction utilitaire du C++14, make_unique, qui construit un objet d'un type spécifié, et retourne un unique_ptr pointant dessus :
void
user3()
{
Filter flt {
"books"
,"authors"
}
;
auto
p =
make_unique<
Filter>
("novels"
,"favorites"
);
// on utilise flt et *p
}
À moins que le second Filter ne nécessite d'avoir une sémantique de pointeurs (ce qui est improbable), cette solution serait encore meilleure :
void
user3()
{
Filter flt {
"books"
,"authors"
}
;
Filter flt2 {
"novels"
,"favorites"
}
;
// on utilise flt et flt2
}
Cette dernière version est plus courte, plus simple, plus claire, et plus rapide que l'originale.
Mais que fait le destructeur de Filter? Il libère les ressources possédées par un Filter; c'est-à-dire qu'il ferme les fichiers (en appelant leur destructeur). En fait, ceci est fait implicitement et, sauf si on a besoin d'un traitement supplémentaire pour Filter, on peut omettre de mentionner explicitement le destructeur de ce dernier et laisser le compilateur s'en charger. On aurait donc pu écrire simplement :
class
Filter {
// lit une entrée sur le fichier iname et écrit sur le fichier oname
public
:
Filter(const
string&
iname, const
string&
oname);
// ...
private
:
ifstream is;
ofstream os;
// ...
}
;
void
user3()
{
Filter flt {
"books"
,"authors"
}
;
Filter flt2 {
"novels"
,"favorites"
}
;
// utilise flt et flt2
}
Il se trouve que cela est plus simple que ce que vous auriez écrit avec la plupart des langages utilisant un garbage collector (Java ou C#, par exemple) et ça ne laisse aucune possibilité de fuites mémoire causées par des développeurs distraits. C'est aussi plus rapide que les alternatives usuelles (pas d'allocation dynamique, pas de ramasse-miettes à exécuter). En règle général, le RAII diminue aussi la durée de rétention d'une ressource comparé à une approche manuelle.
Je considère qu'il s'agit de la solution idéale pour la gestion de ressources : elle gère non seulement la mémoire, mais tout type de ressources telles que les descripteurs de fichiers et de tâches et les verrous. Mais est-ce vraiment général ? Que faire pour les objets qui doivent être transmis d'une fonction à l'autre ? Ou pour les objets qui n'ont pas un propriétaire unique évident ?
3-1. Transfert de propriété : le déplacement▲
Considérons déjà le problème de déplacement d'objets d'une portée à une autre. La question fondamentale est comment faire sortir une grande quantité d'informations sans passer par une copie coûteuse ou une dangereuse manipulation de pointeurs. L'approche historique consiste à utiliser un pointeur :
X*
make_X()
{
X*
p =
new
X:
// ... on remplit X ..
return
p;
}
void
user()
{
X*
q =
make_X();
// ... on utilise *q ...
delete
q;
}
Qui est alors responsable de la destruction de l'objet ? Dans ce cas simpliste, c'est bien évidemment l'appelant de make_X(), mais dans le cas général, la solution n'est pas si triviale. Et si make_X() gardait un cache d'objets pour minimiser le coût des allocations mémoire ? Et si user() transférait le pointeur à un other_user() ? Il y a un risque important de confusion, et les fuites mémoire ne sont pas rares dans ce style de programmation.
Je pourrais utiliser un shared_ptr ou un unique_ptr pour être explicite sur la propriété de l'objet créé. Par exemple :
unique_ptr<
X>
make_X();
Mais pourquoi utiliser un pointeur (intelligent ou pas) à la base ? Je ne veux pas un pointeur et bien souvent, un pointeur apporterait des distractions par rapport à l'usage normal d'un objet. Par exemple, une fonction d'addition de matrice (Matrix) crée un nouvel objet (la somme) à partir de deux arguments, mais retourner un pointeur conduirait à devoir écrire du code vraiment étrange :
unique_ptr<
Matrix>
operator
+
(const
Matrix&
a, const
Matrix&
b);
Matrix res =
*
(a+
b);
La présence de * est nécessaire pour obtenir la somme et non un pointeur sur elle. Ce que j'aurais voulu obtenir dans beaucoup de cas, c'est un objet et non un pointeur sur un objet. Dans de nombreux cas, je pourrais l'obtenir facilement. En particulier, la copie des petits objets est peu coûteuse et il ne me viendrait même pas à l'idée de passer par un pointeur :
double
sqrt(double
); // une fonction racine carrée
double
s2 =
sqrt(2
); // récupérer la racine carrée de 2
D'un autre côté, les objets contenant une grande quantité de données ne sont en général que des indirections vers la plupart de ces données. Prenons istream, string, vector, list et thread. Ce ne sont que quelques octets qui permettent d'accéder à des quantités de données potentiellement importantes. Si on revient sur l'addition de matrice, ce que l'on aurait voulu, c'est :
Matrix operator
+
(const
Matrix&
a, const
Matrix&
b); // retourne la somme de a et b
Matrix r =
x+
y;
On peut aisément y parvenir :
Matrix operator
+
(const
Matrix&
a, const
Matrix&
b)
{
Matrix res;
// ... on remplit res avec la somme des éléments ...
return
res;
}
Par défaut, les éléments de res vont être copiés dans r. Mais comme res est sur le point d'être détruite, et donc la mémoire contenant ses éléments libérée, il n'y a pas besoin de copier : il est possible de « voler » les éléments. N'importe qui pouvait mettre ça en place dès les premiers jours du C++, et beaucoup l'ont fait, mais c'était un peu complexe à mettre en œuvre et la technique n'était pas comprise de tous. Le C++11 supporte directement de « vol de représentation » depuis une indirection sous la forme d'opérations de déplacement qui transfèrent la propriété. Regardons une Matrice 2D de doubles :
class
Matrix {
double
*
elem; // pointeur sur les éléments
int
nrow; // nombre de lignes
int
ncol; // nombre de colonnes
public
:
Matrix(int
nr, int
nc) // constructeur : alloue les éléments
:
elem{
new
double
[nr*
nc]}
, nrow{
nr}
, ncol{
nc}
{
for
(int
i=
0
; i<
nr*
nc; ++
i) elem[i]=
0
; // initialise les éléments
}
Matrix(const
Matrix&
); // constructeur de copie
Matrix operator
=
(const
Matrix&
); // opérateur d'affectation
Matrix(Matrix&&
); // constructeur de déplacement
Matrix operator
=
(Matrix&&
); // affectation par déplacement
~
Matrix() {
delete
[] elem; }
// destructeur : libère les éléments
// …
}
;
Une opération de copie se reconnaît à son argument par référence (&). De même, une opération de déplacement se reconnaît à son argument par rvalue reference (&&). Une opération de déplacement est supposée « voler » la représentation et laisser un « objet vide » derrière elle. Pour Matrix, ça se traduit comme suit :
Matrix::
Matrix(Matrix&&
a) // constructeur de déplacement
:
nrow{
a.nrow}
, ncol{
a.ncol}
, elem{
a.elem}
// « vole » la représentation
{
a.elem =
nullptr
; // ne laisse « rien » derrière
}
Et voilà ! Quand le compilateur voit le return res , il se rend compte que res est sur le point d'être détruite. C'est-à-dire qu'elle ne sera pas utilisée au-delà du return. En conséquence, il utilisera le constructeur par déplacement plutôt que le constructeur de copie pour transférer la valeur de retour. En particulier pour :
Matrix r =
a+
b;
le res à l'intérieur de operator+() devient vide - ce qui rend la tâche du destructeur triviale - et les éléments de res sont désormais la propriété de r. Nous avons réussi à faire sortir les éléments du résultat - potentiellement des mégaoctets de mémoire - de la fonction operator+() pour les déplacer dans la variable du code appelant. On l'a fait en minimisant les coûts (probablement des affectations de quatre mots mémoire).
Des développeurs C++ experts ont fait remarquer qu'il existe des cas où un bon compilateur peut totalement éliminer la copie d'une valeur de retour (ce qui dans ce cas économiserait la copie de quatre mots et l'appel du destructeur). Néanmoins, ça dépend de l'implémentation, et je n'aime pas que les performances de mes techniques de programmation de base dépendent du niveau d'intelligence de tel ou tel compilateur. Qui plus est, un compilateur capable d'éliminer la copie est tout aussi capable d'éliminer le déplacement. Nous avons ici un moyen simple, fiable et général d'éliminer la complexité et le coût associé au déplacement de beaucoup d'informations d'une portée à l'autre.
De plus la sémantique de déplacement s'applique également aux affectations, ainsi pour r = a+b, nous profitons de l'optimisation de déplacement grâce à l'opérateur d'affectation par déplacement. Optimiser une affectation est bien plus complexe à réaliser pour un optimiseur (de compilateur), sans le support du langage ou du développeur, que des optimisations à l'initialisation.
Très souvent, on n'a même pas besoin de définir toutes ces opérations de copie et de déplacement. Si une classe est composée d'éléments qui se comportent comme l'on veut, on peut se reposer sur les fonctions générées par le compilateur. Ainsi :
class
Matrix {
vector<
double
>
elem; // éléments
int
nrow; // nombre de lignes
int
ncol; // nombre de colonnes
public
:
Matrix(int
nr, int
nc) // constructeur : alloue les éléments
:
elem(nr*
nc), nrow{
nr}
, ncol{
nc}
{
}
// ...
}
;
Cette version de la classe Matrix se comporte comme la version précédente, excepté qu'elle gère un peu mieux les erreurs, et qu'elle prend un peu plus de place en mémoire (un vector est généralement représenté avec trois mots).
Et pour les objets qui ne sont pas de simples indirections ? S'ils sont petits, comme un int ou un complex<double>, pas la peine de s'inquiéter. Sinon, on peut construire une indirection par-dessus ou les retourner en utilisant des pointeurs « intelligents », tels que unique_ptr et shared_ptr. Ne vous salissez pas les mains avec des opérations new et delete manuelles.
Il n'y a, hélas, pas de classe de matrice telle que celle que j'ai utilisée dans les exemples dans la bibliothèque standard du C++, mais différentes variantes sont disponibles (en open source ou commercial). Par exemple, cherchez sur le Web : « Origin Matrix Sutton » ou consultez le chapitre 29 de mon livre The C++ Programming Language (Fourth Edition) [11] pour une discussion sur la conception d'une telle classe de matrice.
3-2. Propriété partagée : shared_ptr▲
Dans les discussions sur les ramasse-miettes, on constate souvent que tous les objets n'ont pas forcément un possesseur unique. Ce qui signifie que l'objet doit être détruit/libéré quand la dernière référence le concernant disparaît. Dans notre modèle, ça signifie que l'on a besoin d'un mécanisme pour détruire l'objet quand son dernier possesseur est détruit. C'est-à-dire que l'on a besoin d'une notion de propriété partagée. Prenons l'exemple d'une queue synchronisée, sync_queue, utilisée pour communiquer entre tâches. On donne un pointeur sur sync_queue à un producteur et à un consommateur :
void
startup()
{
sync_queue*
p =
new
sync_queue{
200
}
; // on va avoir des ennuis !
thread t1 {
task1,iqueue,p}
; // task1 lit depuis *iqueue et écrit dans *p
thread t2 {
task2,p,oqueue}
; // task2 lit depuis *p et écrit dans *oqueue
t1.detach();
t2.detach();
}
Je suppose que task1, task2, iqueue et oqueue ont tous été définis convenablement par ailleurs, et je m'excuse de laisser les threads vivre plus longtemps que la portée où ils sont définis, en utilisant detach(). On pourrait aussi imaginer des branchements plus complexes entre plus de tâches et plus de sync_queue. Cependant, une seule question m'intéresse ici : « Qui va détruire la sync_queue créée dans startup() ? ». Tel que le code est écrit, il n'y a qu'une seule bonne réponse : « la dernière personne à utiliser la sync_queue ». C'est un cas classique pour justifier l'utilisation d'un ramasse-miettes. La solution initiale pour mettre en œuvre un ramasse-miettes était des pointeurs avec comptage de références : on maintient un compteur d'utilisations pour l'objet, et au moment où ce compteur va passer à zéro, on détruit l'objet. Beaucoup de langages de nos jours utilisent une variante de cette stratégie, et la solution C++ pour le faire repose sur shared_ptr. Cet exemple devient :
void
startup()
{
auto
p =
make_shared<
sync_queue>
(200
); // crée une sync_queue et retourne un stared_ptr pointant dessus
thread t1 {
task1,iqueue,p}
; // task1 lit depuis *iqueue et écrit dans *p
thread t2 {
task2,p,oqueue}
; // task2 lit depuis *p et écrit dans *oqueue
t1.detach();
t2.detach();
}
Maintenant, les destructeurs de task1 et task2 peuvent détruire leurs shared_ptrs (de manière implicite dans la plupart des bonnes conceptions) et la dernière tâche à le faire détruira la sync_queue.
C'est simple, et raisonnablement efficace. Il n'y a pas besoin d'un système complexe de ramasse-miettes s'exécutant pendant que le programme tourne. Plus important : ça va plus loin que le fait de juste récupérer la mémoire associée avec une sync_queue. Ce mécanisme récupère aussi les objets de synchronisation (les mutex, les verrous ou n'importe quoi d'autre) contenus dans sync_queue pour gérer la synchronisation des threads exécutant les deux tâches. Encore une fois, nous avons affaire à un mécanisme non seulement de gestion de la mémoire, mais aussi de gestion de ressources au sens large. Cet objet de synchronisation « caché » est géré exactement comme les descripteurs de fichiers et les buffers des flux de l'exemple précédent.
On pourrait essayer d'éliminer l'utilisation d'un shared_ptr en introduisant un possesseur unique dans une portée qui englobe les deux tâches, mais ce n'est pas toujours très aisé à faire, et le C++11 fournit donc à la fois les unique_ptr (propriété unique) et les shared_ptr (propriété partagée).
3-3. Sûreté par l'utilisation de types▲
Jusqu'à présent, j'ai uniquement discuté de ramasse-miettes en rapport avec l'aspect gestion des ressources. Il a aussi un rôle à jouer dans la sûreté par l'utilisation de types. Dès que l'on a une opération de destruction manuelle, elle peut être utilisée incorrectement. Par exemple :
X*
p =
new
X;
X*
q =
p;
delete
p;
// ...
q->
do_something(); // la mémoire qui contenait *p a peut-être été réutilisée
Ne faites pas ça. Les delete utilisés directement sont dangereux - et inutiles dans du code classique/client. Laissez les delete à l'intérieur de classes de gestion des ressources, comme string, ostream, thread, unique_ptr et shared_ptr. Ces delete sont précautionneusement mis en correspondance avec des new, et sont alors sans danger.
3-4. Résumé : idéaux pour la gestion de ressources▲
Pour la gestion des ressources, je considère un ramasse-miettes comme le dernier choix souhaitable, et non pas comme « la solution » ni un idéal :
- Utilisez des abstractions appropriées qui gèrent leurs propres ressources récursivement et implicitement. Ces objets seront, de préférence, des variables liées à une portée(4).
- Si vous avez besoin d'une sémantique de pointeurs/références, utilisez des « pointeurs intelligents », comme unique_ptr et shared_ptr pour représenter la possession.
- Si tout le reste échoue (par exemple parce que votre code est intégré dans un programme utilisant des pointeurs dans tous les sens sans utiliser de stratégie supportée par le langage pour la gestion des ressources ou des erreurs), essayez de gérer les ressources autres que la mémoire à la main, et branchez un ramasse-miettes conservatif pour gérer les presque inévitables fuites mémoire.
Cette stratégie est-elle parfaite ? Non, mais elle est générique et simple. Les stratégies traditionnelles à base de ramasse-miettes ne sont pas parfaites non plus, et elles ne gèrent pas directement les ressources autres que la mémoire.
4. Mythe n° 4 : « Du code performant doit être de bas niveau »▲
Beaucoup de gens semblent croire que du code efficace doit être de bas niveau. Certains semblent même croire que du code bas niveau est forcément efficace (« Si c'est si moche, ça doit être rapide ! Quelqu'un doit avoir investi beaucoup de temps et d'intelligence pour écrire ça ! »). Vous pouvez, bien entendu, écrire du code efficace en utilisant uniquement des fonctionnalités bas niveau, et une partie du code doit être bas niveau pour accéder directement aux ressources de la machine. Néanmoins, mesurez pour vérifier si vos efforts étaient rentables ; les compilateurs C++ modernes sont très efficaces et les architectures des machines modernes sont pleines de pièges. Quand il est nécessaire, il vaut mieux généralement cacher ce code bas niveau derrière une interface destinée à en faciliter l'usage. Souvent, cacher le code bas niveau derrière une interface plus haut niveau permet aussi de meilleures optimisations (par exemple, en protégeant le code bas niveau contre des usages « absurdes »). Quand les performances comptent, essayez déjà de les obtenir en écrivant la solution à un niveau d'abstraction élevé. Ne vous ruez pas sur les bits et les pointeurs !
4-1. Le qsort du C▲
Prenons un exemple simple : si vous vouliez trier un ensemble de flottants par ordre décroissant, vous pourriez écrire du code qui le fait. Néanmoins, à moins que vous ayez des particularités très spécifiques (par exemple, plus de nombres à trier qu'il n'y a de place en mémoire), faire ainsi serait très naïf. Depuis des décennies, on a à disposition des bibliothèques avec des algorithmes de tri fournissant des performances acceptables. Celle que j'aime le moins est la bibliothèque standard du C avec qsort() :
int
greater(const
void
*
p, const
void
*
q) // comparaison à 3 possibilités
{
double
x =
*
(double
*
)p; // récupérer le double stocké à l'adresse p
double
y =
*
(double
*
)q;
if
(x>
y) return
1
;
if
(x<
y) return
-
1
;
return
0
;
}
void
do_my_sort(double
*
p, unsigned
int
n)
{
qsort(p,n,sizeof
(*
p),greater);
}
int
main()
{
double
a[500000
];
// ... on remplit a ...
do_my_sort(a,sizeof
(a)/
sizeof
(*
a)); // on passe le pointeur et le nombre d'éléments
// ...
}
Si vous n'êtes pas un développeur C, ou si vous n'avez pas utilisé qsort() récemment, ça mérite quelques explications : qsort() prend quatre arguments :
- un pointeur sur une suite d'octets ;
- un nombre d'éléments ;
- la taille d'un élément présent dans ces octets ;
- une fonction comparant deux éléments, passés sous forme de pointeur sur leur premier octet.
On peut remarquer que cette interface jette de l'information. On ne trie pas vraiment des octets. On trie des double, mais qsort() ne le sait pas, et on doit lui fournir des détails sur comment comparer des double, ou le nombre d'octets pour stocker un double. Bien entendu, le compilateur connaît déjà ces informations, mais l'interface bas niveau de qsort() l'empêche de tirer parti de ces informations de types. Devoir indiquer explicitement de telles informations est aussi un risque d'erreur. N'aurais-je pas inversé les deux arguments entiers de qsort() ? Si tel était le cas, le compilateur ne s'en rendrait pas compte. Est-ce que ma fonction compare() respecte les conventions d'une comparaison à trois possibilités du C ?
Si vous regardez des implémentations de qualité industrielle de qsort() (faites-le, s'il vous plaît), vous verrez qu'elles travaillent dur pour compenser le manque d'informations. Par exemple, échanger deux éléments exprimés comme un nombre d'octets n'est pas aussi trivial à faire efficacement qu'échanger deux double. L'appel indirect à la fonction de comparaison peut uniquement être éliminé si le compilateur propage les constantes sur les pointeurs de fonctions.
4-2. Le sort() du C++▲
Comparez qsort() à son équivalent en C++, sort() :
void
do_my_sort(vector<
double
>&
v)
{
sort(v,[](double
x, double
y) {
return
x>
y; }
); // trie v par ordre décroissant
}
int
main()
{
vector<
double
>
vd;
// ... on remplit vd ...
do_my_sort(vd);
// ...
}
On a besoin de moins d'explications ici. Un vector connaît sa taille, on n'a donc pas besoin de passer explicitement le nombre d'éléments. On ne « perd » jamais le type d'un élément, donc pas besoin de s'occuper de la taille des éléments. Par défaut, sort() trie par ordre croissant, j'ai donc du préciser le critère de comparaison, comme avec qsort(). Ici, je l'ai passé sous forme d'expression lambda comparant deux doubles avec >. Il se trouve que cette lambda est trivialement mise en ligne(5) par tous les compilateurs C++ que je connais, donc la comparaison se réduit juste à un appel à l'opération machine plus-grand-que ; il n'y a pas de (coûteux) appels indirects de fonction.
J'ai utilisé une version de sort fonctionnant avec les conteneurs, pour ne pas avoir à gérer explicitement les itérateurs et devoir écrire :
std::
sort(v.begin(),v.end(),[](double
x, double
y) {
return
x>
y; }
);
Je pourrais aller plus loin, et utiliser un objet de comparaison du C++14 :
sort(v,greater<>
()); // trie v par ordre décroissant
Quelle version est la plus rapide ? Vous pouvez compiler la version qsort() en C comme en C++ sans aucune différence de performances, il s'agit donc d'une comparaison de styles de programmation, plutôt que de langages. Les implémentations des bibliothèques semblent toujours utiliser les mêmes algorithmes pour sort() et qsort(), c'est donc une comparaison de styles de programmation, et non d'algorithmes. Des bibliothèques et des compilateurs différents donnent des résultats différents, bien entendu, mais pour chaque implémentation, on a une image assez raisonnable des effets des différents niveaux d'abstraction.
J'ai récemment exécuté les exemples, et j'ai trouvé la version avec sort() 2,5 fois plus rapide que la version avec qsort(). Votre mesure peut varier d'un compilateur à l'autre ou d'une machine à l'autre, mais jamais je n'ai vu qsort() battre sort(). J'ai déjà vu sort() aller 10 fois plus vite que qsort(). Pourquoi ? Le sort() de la bibliothèque standard du C++ est clairement de plus haut niveau que qsort(), tout en étant plus générique et plus flexible. Elle est sûre au niveau du typage, et paramétrée en fonction du type de stockage, du type des éléments et du critère de tri. Pas de pointeurs, de cast, de taille ou d'octets à l'horizon. La bibliothèque standard STL du C++, dont sort() fait partie, prend bien garde à ne pas jeter d'information. Ce qui permet une mise en ligne (inlining) excellente, et de bonnes optimisations.
La généricité et le code haut niveau peuvent battre le code bas niveau. Ce n'est pas toujours le cas, bien sûr, mais la comparaison sort()/qsort() n'est pas un exemple isolé. Commencez toujours par une solution haut niveau, précise et respectant le typage. N'optimisez que si nécessaire.
5. Mythe n° 5 : « Le C++ n'est pertinent que pour les programmes imposants ou compliqués »▲
Le C++ est un gros langage. La taille de ses spécifications est très comparable à celle du C# ou du Java. Mais ça ne signifie pas que vous ayez besoin d'en connaître tous les détails pour l'utiliser ni d'utiliser toutes ses fonctionnalités dans chaque programme. Prenons un exemple utilisant uniquement les composants de base de la bibliothèque standard :
set<
string>
get_addresses(istream&
is)
{
set<
string>
addr;
regex pat {
R"((\w+([.-]\w+)*)@(\w+([.-]\w+)*)
)"}
; // motif d'une adresse mail
smatch m;
for
(string s; getline(is,s); ) // lit une ligne
if
(regex_search(s, m, pat)) // recherche le motif
addr.insert(m[0
]); // sauve l'adresse dans le set
return
addr;
}
Je suppose que vous connaissez les expressions régulières. Sinon, le moment est bien choisi pour vous documenter dessus. Remarquez comment je m'appuie sur la sémantique de déplacement pour retourner un ensemble de chaînes potentiellement gros. Tous les conteneurs de la bibliothèque standard fournissent des constructeurs de déplacement, donc inutile de bricoler avec des new.
Pour que ça marche, je dois inclure les en-têtes de la bibliothèque standard :
#include
<string>
#include
<set>
#include
<iostream>
#include
<sstream>
#include
<regex>
using
namespace
std;
Testons-le :
istringstream test {
// un flux initialisé par une chaîne contenant quelques adresses
"asasasa
\n
"
"bs@foo.com
\n
"
"ms@foo.bar.com$aaa
\n
"
"ms@foo.bar.com aaa
\n
"
"asdf bs.ms@x
\n
"
"$$bs.ms@x$$goo
\n
"
"cft foo-bar.ff@ss-tt.vv@yy asas"
"qwert
\n
"
}
;
int
main()
{
auto
addr =
get_addresses(test); // récupère les adresses mail
for
(auto
&
s : addr) // affiche les adresses
cout <<
s <<
'
\n
'
;
}
C'est juste un exemple. Il est aisé de modifier get_addresses() pour prendre le motif de la regex en argument, afin de pouvoir rechercher des URL ou n'importe quoi d'autre. Il est aisé de modifier get_addresses() pour reconnaître plusieurs apparitions d'un motif dans une ligne. Après tout, le C++ est conçu pour la flexibilité et la généricité, mais tous les programmes n'ont pas à être des bibliothèques complètes ou des frameworks applicatifs. Quoi qu'il en soit, ce qu'il faut retenir ici est qu'extraire des adresses mail d'un flux peut s'exprimer simplement, et se tester facilement.
5-1. Bibliothèques▲
Quel que soit le langage, écrire un programme en utilisant uniquement les fonctionnalités de base (comme if, for, etc.) est très fastidieux. En revanche, avec les bibliothèques adaptées (comme des bibliothèques graphiques, de planification de trajets, de base de données) à peu près n'importe quelle tâche peut être réalisée avec une quantité d'efforts raisonnable.
La bibliothèque standard du C++ ISO est relativement petite (comparée à des bibliothèques commerciales), mais il y a pléthore de bibliothèques, open source ou commerciales, dans la nature. Par exemple, si on utilise des bibliothèques (libres ou propriétaires) comme Boost [3], POCO [2], AMP [4], TBB [5], Cinder [6], wxWidgets [7], et CGAL [8], beaucoup de tâches, classiques ou plus spécifiques, deviennent plus simples. Par exemple, modifions le programme précédent pour lire des URL depuis une page Web. Nous allons commencer par rendre get_addresses() plus générique pour trouver n'importe quelle chaîne correspondant à un motif :
set<
string>
get_strings(istream&
is, regex pat)
{
set<
string>
res;
smatch m;
for
(string s; getline(is,s); ) // lit une ligne
if
(regex_search(s, m, pat))
res.insert(m[0
]); // sauve le résultat dans set
return
res;
}
C'est juste une simplification. Ensuite, nous devons trouver un moyen d'aller sur le Web lire un fichier. Boost fournit une bibliothèque, asio, pour communiquer sur le Web :
#include
"boost/asio.hpp"
// récupère boost.asio
Parler à un serveur Web est un peu compliqué :
try
{
string server =
"www.stroustrup.com"
;
boost::asio::ip::tcp::
iostream s {
server,"http"
}
; // ouvre une connexion
connect_to_file(s,server,"C++.html"
); // vérifie et ouvre le fichier
regex pat {
R"((http://)?www([./#\+-]\w*)+
)"}
; // URL
for
(auto
x : get_strings(s,pat)) // recherche les URL
cout <<
x <<
'
\n
'
;
}
catch
(std::
exception&
e) {
std::
cout <<
"Exception: "
<<
e.what() <<
"
\n
"
;
return
1
;
}
Quand on lit le fichier C++.html sur www.stroustrup.com, on obtient :
http://www-h.eng.cam.ac.uk/help/tpl/languages/C++.html
http://www.accu.org
http://www.artima.co/cppsource
http://www.boost.org
...
J'ai utilisé un set, les URL apparaissent donc triées par ordre alphabétique.
J'ai sournoisement, mais de manière assez réaliste, « caché » la vérification et la connexion HTTP dans une fonction (connect_to_file()) :
void
connect_to_file(iostream&
s, const
string&
server, const
string&
file)
// ouvre une connexion à un serveur, ouvre un fichier et l'attache à s
// on saute les en-têtes
{
if
(!
s)
throw
runtime_error{
"can't connect
\n
"
}
;
// Demande à lire le fichier depuis le serveur:
s <<
"GET "
<<
"http://"
+
server+
"/"
+
file <<
" HTTP/1.0
\r\n
"
;
s <<
"Host: "
<<
server <<
"
\r\n
"
;
s <<
"Accept: */*
\r\n
"
;
s <<
"Connection: close
\r\n\r\n
"
;
// Vérifie que la réponse est OK:
string http_version;
unsigned
int
status_code;
s >>
http_version >>
status_code;
string status_message;
getline(s,status_message);
if
(!
s ||
http_version.substr(0
, 5
) !=
"HTTP/"
)
throw
runtime_error{
"Invalid response
\n
"
}
;
if
(status_code!=
200
)
throw
runtime_error{
"Response returned with status code"
}
;
// Saute les en-têtes de la réponse, qui sont terminés par une ligne vide :
string header;
while
(getline(s,header) &&
header!=
"
\r
"
)
;
}
Comme très souvent, je ne suis pas parti de zéro. La gestion d'une connexion HTTP a été largement reprise de la documentation d'asio par Christopher Kohlhoff's [9].
5-2. Hello, World!▲
Le C++ est un langage compilé, conçu avec comme but principal de fournir du code propre et maintenable là où les performances et la fiabilité comptent (par exemple, les infrastructures[10]). Il n'a pas pour objectif d'entrer en compétition directe sur des petits programmes avec des langages « de script » interprétés ou compilés de manière minimaliste. En réalité, de tels langages (par exemple JavaScript), mais d'autres aussi (par exemple Java) sont souvent implémentés en C++. Néanmoins, il y a de nombreux programmes en C++ qui font d'une douzaine à quelques centaines de lignes.
Les auteurs de bibliothèques C++ pourraient donner un coup de main ici. Au lieu de se concentrer (uniquement) sur les parties intelligentes et avancées des bibliothèques, ils pourraient fournir des programmes « Hello, World! » faciles à essayer. En proposant une version triviale à installer de la bibliothèque et un exemple à la « Hello, World! » d'une page maximum démontrant ce que la bibliothèque peut faire. Nous sommes tous novices à un moment ou à un autre. Au fait, ma version du « Hello, World! » pour le C++ est :
#include
<iostream>
int
main()
{
std::
cout <<
"Hello, World
\n
"
;
}
Je trouve les versions plus longues et plus complexes très peu amusantes quand il s'agit d'illustrer le C++ ISO et sa bibliothèque standard.
6. Les usages multiples des mythes▲
Les mythes ont parfois une origine dans la réalité. Pour chacun de ces mythes, il a existé une époque et une situation où quelqu'un pouvait raisonnablement croire en leur réalité avec de bons arguments. De nos jours, je les considère tout simplement faux, de simples malentendus, parfois de toute bonne foi. Un problème, c'est que les mythes servent toujours un objectif, sinon, ils auraient disparu. Ces cinq mythes ont joué et jouent encore un ensemble de rôles :
- Ils peuvent apporter du confort : pas besoin de changer, de réévaluer ses préjugés. Ce qui est familier rassure. Le changement peut être déstabilisant, il serait donc pratique que la nouveauté ne soit pas viable.
- Ils peuvent économiser le temps nécessaire à démarrer un nouveau projet : si vous savez (ou croyez savoir) ce qu'est le C++, pas besoin de passer du temps à apprendre quelque chose de neuf. Pas besoin d'expérimenter de nouvelles techniques. Pas besoin de mesurer de nouveaux freins aux performances. Pas besoin d'entraîner de nouveaux développeurs.
- Ils peuvent vous éviter d'avoir à apprendre le C++ : si ces mythes étaient vrais, pourquoi diable perdre son temps à apprendre le C++ ?
- Ils peuvent vous aider à promouvoir d'autres langages ou d'autres techniques : si ces mythes étaient vrais, des substituts seraient sans aucun doute indispensables.
Mais ces mythes ne sont pas vrais, on ne peut donc pas les utiliser en toute honnêteté intellectuelle pour promouvoir le statu quo, des substituts au C++ ou la mise à l'écart des styles de programmation du C++ moderne. Suivre son bonhomme de chemin avec une vision dépassée du C++ (un sous-ensemble du langage et des techniques familières) peut être confortable, mais le monde du logiciel est tel qu'il est nécessaire d'évoluer. On peut faire tellement mieux qu'en C, en « C with classes », en C++98, etc.
S'accrocher aux bonnes vieilles méthodes n'est pas sans coût. Le coût de maintenance est souvent plus élevé qu'avec du code moderne. Les vieux compilateurs et outils génèrent des performances moindres et des analyses moins fines que les outils modernes qui bénéficient de la structuration améliorée du code moderne. Et les bons développeurs choisissent souvent de ne pas travailler sur du code trop « antédiluvien ».
Le C++ moderne (C++11, C++14), et les techniques de programmation qu'il permet, sont différents et bien meilleurs que ce que les mythes populaires classiques veulent laisser entendre.
Si vous croyez en l'un de ces mythes, je ne vous demande pas de me croire sur parole. Essayez. Testez. Mesurez les manières « traditionnelles » et les variantes sur un problème qui vous tient à cœur. Essayez de vous faire une vraie opinion du temps nécessaire pour apprendre les nouvelles fonctionnalités et techniques, du temps pour écrire le code de manière moderne, du temps d'exécution du code moderne. N'oubliez pas de prendre en compte le surcoût de maintenance probable attaché à la manière « traditionnelle » de coder. La seule manière efficace d'exterminer un mythe est de présenter des preuves. Je vous ai juste donné des exemples et des arguments.
Et non, je n'essaye pas de dire que le C++ est parfait. Le C++ n'est pas parfait, ce n'est pas le meilleur langage pour toutes les situations et tout le monde. Aucun langage ne l'est. Prenez le C++ pour ce qu'il est, plutôt que pour ce qu'il était il y a 20 ans, ou pour ce que ceux qui défendent une alternative veulent faire croire qu'il est. Pour effectuer un choix rationnel, documentez-vous sérieusement et, autant que le temps le permet, essayez vous-même pour voir comment le C++ peut répondre sur le genre de problèmes que vous rencontrez.
7. Résumé▲
Ne croyez pas la « rumeur populaire » sur le C++ et son utilisation sans preuve. Cet article analyse cinq opinions souvent entendues à propos du C++, et argumente qu'elles ne sont que des mythes :
- « Pour comprendre le C++, vous devez déjà apprendre le C ».
- « Le C++ est un langage orienté objet ».
- « Pour écrire un programme fiable, il faut un ramasse-miettes (garbage collector) ».
- « Du code performant doit être de bas niveau ».
- « Le C++ n'est pertinent que pour les programmes imposants ou compliqués ».
Ces mythes causent des dommages.
8. Retours▲
Pas convaincu ? Dites-moi pourquoi. Quels autres mythes avez-vous rencontrés ? Pourquoi sont-ils des mythes, plutôt que des expériences valides ? Quelles preuves avez-vous qui pourraient réfuter un mythe ?
9. Références▲
Quelques références(6) :
1. ISO/IEC 14882:2011 Programming Language C++
2. Bibliothèques POCO : http://pocoproject.org/
3. Bibliothèques Boost : http://www.boost.org/
4. AMP: C++ Accelerated Massive Parallelism. http://msdn.microsoft.com/en-us/library/hh265137.aspx
5. TBB: Intel Threading Building Blocks. www.threadingbuildingblocks.org/
6. Cinder: A library for professional-quality creative coding. http://libcinder.org/
7. vxWidgets: A Cross-Platform GUI Library. www.wxwidgets.org
8. Cgal - Computational Geometry Algorithms Library. www.cgal.org
9. Christopher Kohlhoff : Documentation de Boost.Asio. http://www.boost.org/doc/libs/1_55_0/doc/html/boost_asio.html
10. B. Stroustrup: Software Development for Infrastructure. Computer, vol. 45, no. 1, pp. 47-58, Jan. 2012, doi:10.1109/MC.2011.353.
11. Bjarne Stroustrup: The C++ Programming Language (4th Edition). Addison-Wesley. ISBN 978-0321563842. May 2013.
12. Bjarne Stroustrup: A Tour of C++. Addison Wesley. ISBN 978-0321958310. September 2013.
13. B. Stroustrup: Programming: Principles and Practice using C++ (2nd edition). Addison-Wesley. ISBN 978-0321992789. May 2014.
Remerciements▲
Je tiens tout d'abord à remercier Bjarne Stroustrup, pour avoir écrit cet article, et en avoir autorisé la traduction. Je remercie aussi LittleWhite, Luc Hermitte, Francis Walter et Bousk pour leur relecture technique et ClaudeLELOUP pour sa relecture orthographique toujours aussi pointue !