Initialisation uniforme – accolades ou parenthèses?

J'ai reçu cette question (que je paraphrase légèrement) de mon ami et ex-étudiant Gilles Brunet, mais on me la pose fréquemment :

Bonjour Patrice,

On me demande la différence entre les constructeurs appelés avec () et ceux appelés avec {}. Outre le « visuel », sont-ils différents?

Depuis C++ 11, il est possible d'initialiser des objets avec des parenthèses et avec des accolades. Étant donné que dans bien des cas, les deux ont un effet semblable, la question est légitime.

L'idée derrière l'initialisation uniforme

Avant C++ 11, l'initialisation des objets en C++ prenait une forme variant significativement selon le type. Entre autres, pour initialiser un tableau d'entiers avec une séquence connue a priori de valeurs, on aurait écrit :

int tab[] = { 2, 3, 5, 7, 11 };

... alors que pour initialiser un vector<int>, il aurait fallu faire quelque chose comme ceci :

vector<int> v;
v.push_back(2);
v.push_back(3);
v.push_back(5);
v.push_back(7);
v.push_back(11);

... ou ceci :

int tab[] = { 2, 3, 5, 7, 11 };
vector<int> v;
for(auto p = begin(tab); p != end(tab); ++p)
   v.push_back(*p);

... ou encore ceci :

int tab[] = { 2, 3, 5, 7, 11 };
vector<int> v(begin(tab), end(tab));

...ce qui n'est pas élégant. De plus, dans certains cas, la syntaxe reposant sur des parenthèses pour appeler un constructeur peut mener à des ambiguïtés grammaticales : par exemple, à l'appel d'une fonction ayant la signature suivante :

int f(int);

... passer un int par défaut pourrait s'écrire f(int()), or ceci peut signifier :

... deux choses généralement bien, bien différentes.

Depuis C++ 11, on peut appeler cette fonction f() avec l'écriture f(int{}), où les accolades suppriment l'ambiguïté syntaxique, et il est possible d'initialiser nos conteneurs de la même manière que l'on initialiser un tableau brut, soit :

vector<int> v = { 2, 3, 5, 7, 11 };
// ... ou encore ...
vector<int> v{ 2, 3, 5, 7, 11 };

Examinons la question plus en détail.

Initialisation par défaut

La première différence notable tient à l'initialisation par défaut. En effet, examinons ce qui suit :

Ici, i0 n'est pas initialisée. You don't pay for what you don't use est un credo clé de C++, après tout

int i0;

Ici, i1 vaut zéro, mais l'initialisation passe par un littéral spécifique, ce qui ne se prête pas au code générique

int i1 = 0;

Ici, i2 vaut zéro : on a appelé son constructeur par défaut (il existe), mais délibérément et explicitement

int i2 = int();

Oups! Dans ce cas, i3 est une fonction sans paramètre retournant un int... Il ne faut pas confondre i2 et i3!

int i3();

Ici, i4 vaut zéro : on a appelé son constructeur par défaut (il existe), mais délibérément et explicitement

int i4 = {};

Ici, i5 vaut zéro : on a appelé son constructeur par défaut (il existe), mais délibérément et explicitement

int i5{};

Remarquez la simplicité de i4 et, à plus forte partie, de i5, et notez que les accolades évitent le piège que l'on voit avec i3 et la confusion sur le rôle des parenthèses dans certaines expressions. De même :

Ici, s0 est une chaîne vide. Notez une différence de comportement avec le int nommé i0 plus haut

string s0;

Ici, s1 est une chaîne vide, mais l'initialisation passe par un littéral spécifique, ce qui ne se prête pas au code générique

string s1 = "";

Ici, s2 est une chaîne vide : on a appelé son constructeur par défaut (il existe), mais délibérément et explicitement

string s2 = string();

Oups! Dans ce cas, s3 est une fonction sans paramètre retournant un string... Il ne faut pas confondre s2 et s3!

string s3();

Ici, s4 est une chaîne vide : on a appelé son constructeur par défaut (il existe), mais délibérément et explicitement

string s4 = {};

Ici, s5 est une chaîne vide : on a appelé son constructeur par défaut (il existe), mais délibérément et explicitement

string s5{};

Visiblement, l'introduction d'initialisation avec accolades permet, pour les initialisations par défaut, une simplification appréciable.

Le cas plutôt irritant du Most Vexing Parse

Depuis longtemps, C++ souffre d'un mal grammatical que l'on nomme le Most Vexing Parse. Pour l'illustrer, examinons cet exemple emprunté, avec légers ajustements, à Jonathan Boccara (voir https://www.fluentcpp.com/2018/01/30/most-vexing-parse/ pour le texte entier) :

struct B {
   B();
};
struct A {
   A (B const &);
   void agir();
};
int main() {
   A a(B()); // <- ICI
   a.agir();
}

Dans ce cas, l'expression A a(B()); peut avoir deux interprétations :

Il se trouve que dans la grammaire de C++, c'est la deuxième interprétation qui prime, ce qui fait que la ligne a.agir() n'a pas de sens (a est une fonction, pas un A).

Traditionnellement, pour en arriver à la première interprétation, il fallait forcer l'évaluation de B() en un appel de B::B() en insérant une paire de parenthèses supplémentaires, un irritant difficile à comprendre et qui fait un peu mal paraître le langage. Désormais, il est possible d'utiliser des accolades plutôt que des parenthèses, et de faire disparaître l'irritant par le fait-même :

Palliatif traditionnel Palliatif contemporain
struct B {
   B();
};
struct A {
   A (B const &);
   void agir();
};
int main() {
   A a( ( B() ) ); // <- ICI
   a.agir();
}
struct B {
   B();
};
struct A {
   A (B const &);
   void agir();
};
int main() {
   A a(B{}); // <- ICI
   a.agir();
}

Éviter les pertes de précision

Les accolades sont aussi plus strictes que les parenthèses quant aux conversions permises. En effet :

struct X {
   // ...
   X(int, double);
};
void f() {
   X x0(3, 3.5); // Ok
   X x1{ 3, 3.5 }; // Ok, pareil à x0
   X x2(3.5, 3); // Oups! Erreur dans l'ordre des paramètres. Pas illégal, mais avertissement probable
   // X x3{ 3.5, 3 }; // Erreur de compilation : les "narrowing conversions" ne sont pas permises avec les accolades
}

Sur une base de code existante, ne migrez donc pas systématiquement des parenthèses aux accolades, car cela pourrait briser du code discutable mais fonctionnel. Sur du nouveau code, toutefois, préférez les accolades aux parenthèses peut aider à détecter des erreurs un peu bêtes.

Un petit irritant... mais pas si petit que ça

Les accolades ne sont tristement pas sans leurs propres irritants. Pensons tout particulièrement aux vector<int>, qui sont une illustration frappante :

vector<int> v0{ 2,3,5,7,11 }; // Ok
assert(v0.size() == 5);
vector<int> v1(10, -1); // Ok; v1 contient 10 fois la valeur -1, et v1.size() == 10
assert(v1.size() == 10 && all_of(begin(v1), end(v1), [](int n) { return n == -1; });
vector<int> v2{ 10, -1 }; // Oups! le langage a deux options, soit :
                          // * vector<int>::vector(size_type,int) et
                          // * vector<int>::vector(initializer_list<int>)
                          // Dans un tel cas, c'est celui qui prend une initializer_list<int> qui gagne
assert(v2.size() == 2 && v2[0] == 10 && v1[1] == -1) // c'est un "gotcha"

Ce problème survient dans tous les cas où un constructeur prend plusieurs paramètres potentiellement du même type :

#include <initializer_list>
#include <utility>
struct X {
   X(double, double);
   X(std::initializer_list<double>);
};
// ...
template <class ... Args>
   X fabriquer_X_v0(Args &&... args) {
      return X(std::forward<Args>(args)...);
   }
template <class ... Args>
   X fabriquer_X_v1(Args &&... args) {
      return { std::forward<Args>(args)... };
   }
int main() {
   auto x0 = fabriquer_X_v0(1.0,2.0); // appelle X::X(double,double)
   auto x1 = fabriquer_X_v1(1.0,2.0); // appelle X::X(initializer_list<double>)
}

En effet, utiliser des accolades avec une séquence de valeurs du même type, mène à la création spontanée d'une initializer_list du type en question, soit un type très léger qui n'expose que les méthodes begin(), end() et size(). Conséquemment :

vector<int> f() {
   return { 2,3,5,7,11 }; // appelle vector<int>::vector(initializer_list<int>)
}
void g() {
   for(int n : f()) cout << n << ' '; // appelle une fois f(), ramasse le vecteur dans une variable anonyme et itère dessus
}

... ce qui est souvent souhaitable, mais peut surprendre quand les syntaxes avec accolades et avec parenthèses entrent en « compétition ».

Pas encore assez uniforme, l'initialisation...

Une nouvelle initialisation à base de parenthèses sera supportée en C++ 20, du fait que le problème susmentionné (risque de créer une initializer_list quand tous les paramètres sont du même type) rend impossible l'écriture de code générique fabriquant des agrégats. Par exemple, soit le type Point suivant, qui est un agrégat :

struct Point { int x, y; };

... l'écriture suivante crée un Point à l'origine :

Point org{ 0, 0 }; // initialisation d'agrégat

... mais l'écriture suivante ne compile pas :

auto org = make_unique<Point>(0, 0); // oups!

... car dû au risque bien réel d'appeler le mauvais constructeur avec des accolades et des paramètres du même type, make_unique() appellera le constructeur de Point à l'aide de parenthèses, or les agrégats ne peuvent (jusqù'à C++ 17) être construits qu'avec des accolades.

L'initialisation est un problème complexe dans un langage comme C++...

Recommandations...?

On me demande à l'occasion si je fais une recommandation formelle quant à la syntaxe à privilégier pour l'initialisation. Une sorte de recette universelle, en quelque sorte.

En fait, je ne prends pas position pour un choix général dans ce dossier car il n'y en a pas de bon dans l'absolu. Personnellement :

Il existe donc encore des cas d'utilisation où il est spécifiquement préférable d'utiliser les accolades, tout comme il en existe pour lesquels les parenthèses sont le meilleur choix.

Lectures complémentaires

Gabriel Aubut-Lussier m'a recommandé d'ajouter un lien vers une présentation du toujours excellent Nicolai Josuttis, présentation portant sur le « cauchemar de l'initialisation en C++ ».

Voici ce lien : https://www.youtube.com/watch?v=7DTlWPgX6zs&feature=youtu.be (mais notez que la situation s'améliorera – légèrement – avec C++ 20)

Quelques liens pour enrichir le propos :


Valid XHTML 1.0 Transitional

CSS Valide !