Introduction aux initializer_lists

Étonnamment, l'initialisation est un sujet complexe en C++. Ce qui suit couvre quelques aspects de cette question, mais vous voudrez sans doute en savoir aussi sur :

Depuis C++ 11, une vieille tare syntaxique de C++ 03 est réglée, soit celle qui permet d'initialiser un tableau comme suit :

int tab[] = { 2, 3, 5, 7, 11 }; // légal

alors qu'il n'est pas possible d'initialiser un autre conteneur, standard ou maison, de la même manière :

vector<int> v = { 2, 3, 5, 7, 11 }; // illégal en C++ 03, Ok depuis C++ 11
vector<int> w { 2, 3, 5, 7, 11 }; // idem

Ceci nous forçait à procéder ainsi, ce qui est un peu pénible :

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

...ou encore de passer par un tableau intermédiaire, ce qui n'est guère mieux :

int tab[] = { 2, 3, 5, 7, 11 };
enum { N = sizeof(tab) / sizeof(*tab) };
vector<inv> v(tab + 0, tab + N);

Ce qui permet de régler cet irritant est l'introduction du type std::initializer_list. Ce type est générique sur la base du type des éléments, type qui ne doit donc pas être ambigu, et est généré par le compilateur au besoin.

Ainsi, dans l'initialisation du vector<int> plus haut, le type de { 2,3,5,7,11 } est std::initializer_list<int>, et ce type a le comportement d'un conteneur standard (méthodes comme size(), begin(), end() et ainsi de suite).

Puisque les répétitives for sur des intervalles utilisent les fonctions globales std::begin() et std::end() pour déterminer les extrémités de la séquence à parcourir, et puisque ces fonctions sollicitent les méthodes begin() et end() de l'objet à parcourir quand elles existent – et c'est le cas avec une std::initializer_list – nous pouvons même écrire quelque chose comme ceci :

#include <iostream>
int main() {
   using namespace std;
   for(auto & val : { 2,3,5,7,11 })
      cout << val << endl;
}

... pour parcourir et afficher les éléments d'une std::initializer_list sans la placer dans un conteneur.

De même, pour une fonction acceptant une séquence variadique d'entiers, il est possible de créer sur-le-champ une std::initializer_list à partir de cette séquence et la parcourir aisément :

#include <iostream>
template <int ... Vals>
   int somme() {
      int cumul{};
      for(int n : { Vals... })
         cumul += n;
      return cumul;
   }
int main() {
   using namespace std;
   cout << somme<2,3,5,7,11>() << endl;
}

Sympathique, n'est-ce pas?

Un exemple de mini conteneur constructible à partir d'une liste d'initialisation suit.

L'en-tête déclarant le type standard initializer_list est, sans surprises, <initializer_list>.

#include <iostream>
#include <algorithm>
#include <vector>
#include <initializer_list>

Notre exemple reposera sur une classe Buffer, espèce de petit tableau de taille connue à l'exécution mais fixe par la suite.

La plupart des services offerts par cette classe sont classiques. Notez au passage l'habituelle Sainte-Trinité, mais aussi une implémentation de la sémantique de mouvement.

template <class T>
   class Buffer {
   public:
      using value_type = T;
      using size_type = std::size_t;
      using iterator = value_type *;
      using const_iterator = const value_type *;
   private:
      value_type *p;
      size_type n;
   public:
      iterator begin() noexcept
         { return p; }
      iterator end() noexcept
         { return begin() + size(); }
      const_iterator begin() const noexcept
         { return p; }
      const_iterator end() const noexcept
         { return begin() + size(); }
      Buffer() : p{}, n{} {
      }
      bool empty() const noexcept
         { return !size(); }
      size_type size() const noexcept
         { return n; }
      Buffer(const Buffer &buf)
         : p{new value_type[buf.size()]}, n{buf.size()}
      {
         using std::copy;
         try {
            copy(buf.begin(), buf.end(), p);
         } catch(...) {
            delete [] p;
            throw;
         }
      }
      Buffer(Buffer &&buf) noexcept
         : p{std::move(buf.p)}, n{std:::move(buf.n)}
      {
         buf.p = {};
         buf.n = {};
      }
      void swap(Buffer &buf) noexcept {
         using std::swap;
         swap(p, buf.p);
         swap(n, buf.n);
      }
      Buffer& operator=(const Buffer &buf) {
         Buffer{buf}.swap(*this);
         return *this;
      }
      Buffer& operator=(Buffer &&buf) noexcept {
         swap(buf);
         return *this;
      }

J'ai placé côte à côte le constructeur de séquence, qui fait partie des usages de C++ depuis longtemps maintenant, et le constructeur recevant une std::initializer_list.

Sans grande surprise, je présume, vous remarquerez les similitudes entre les deux. La seule réelle différence entre eux tient d'un souci d'efficacité, donc au moment où est évaluée la taille de la séquence (implicitement connue dans un initializer_list, à évaluer avec une paire d'itérateurs quelconque) et au moment où l'allocation de l'espace est réalisée, qui en découle.

      template <class It>
         Buffer(It debut, It fin)
            : p{}, n{std::distance(debut, fin)}
         {
            p = new value_type[size()];
            using std::copy;
            try {
               copy(debut, fin, p);
            } catch(...) {
               delete [] p;
               throw;
            }
         }
      template <class U>
         Buffer(const std::initializer_list<U> &init)
            : p{new value_type[init.size()]}, n{init.size()}
         {
            using std::copy;
            try {
               copy(init.begin(), init.end(), p);
            } catch(...) {
               delete [] p;
               throw;
            }
         }

Le reste des opérations est banal. Vous pouvez vous amuser à enrichier Buffer si le coeur vous en dit.

      bool operator==(const Buffer &buf) const {
         using std::equal;
         return size() == buf.size() &&
                equal(begin(), end(), buf.begin());
      }
      bool operator!=(const Buffer &buf) const
         { return !(*this == buf); }
   };

Le programme principal met en relief qu'il est désormais possible de traiter le tableaux bruts, les conteneurs standards et les conteneurs maison d'une manière homogène.

En effet, les tableaux bruts sont enrichis par des services standards comme les fonctions std::begin() et std::end(), alors que les conteneurs qui ne sont pas des tableaux peuvent être intialisés de la même manière que leurs congénères primitifs.

int main() {
   using namespace std;
   int tab[] = { 2, 3, 5, 7, 11 };
   vector<int>v = { 2, 3, 5, 7, 11 };
   Buffer<short> buf = { 2, 3, 5, 7, 11 };
   for(auto n : tab)
      cout << n << ' ';
   cout << endl;
   for(auto n : v)
      cout << n << ' ';
   cout << endl;
   for(auto n : buf)
      cout << n << ' ';
   cout << endl;
}

Sympathique, n'est-ce pas?

Un piège un peu vilain

Les listes d'initialisation ne sont pas une panacée, et il faut porter attention à un détail un peu vilain lorsqu'on décide de les utiliser. Prenons par exemple la classe Buffer ci-dessus, et ajoutons-lui un constructeur de la forme suivante :

// ...
Buffer(size_type n, const value_type &init = {})
   : p{ new value_type[n] }, n{ n }
{
   try {
      std::fill(begin(), end(), init);
   } catch(...) {
      delete [] p;
      throw;
   }
}
// ...

Plusieurs classes, dont std::vector, offrent un tel constructeur, ce qui permet traditionnellement de construire l'objet avec un certain nombre d'éléments de même valeur (par défaut, la valeur par défaut du value_type). Ainsi, le code suivant créera un vector<int> de 10 éléments, tous de valeur -1, et un Buffer<int> lui aussi de 10 éléments, tous de valeur -1 :

// ...
int main() {
   vector<int> v(10, -1);
   Buffer<int> buf(10, -1);
}

Le piège tient au fait que les initializer_lists sont une notation gourmande, qui domine dans l'ordre d'évaluation les autres constructeurs, ce qui fait qu'en situation « d'ambivalence », c'est cette notation qui l'emporte, ainsi :

// ...
int main() {
   vector<int> v0(10, -1); // -1 -1 -1 -1 -1 -1 -1 -1 -1 -1
   Buffer<int> buf0(10, -1);  // -1 -1 -1 -1 -1 -1 -1 -1 -1 -1
   vector<int> v1{ 10, -1 }; // 10 -1 ... oups!
   Buffer<int> buf1{ 10, -1 };  // 10 -1 ... oups!
}

Ce comportement peut surprendre; conséquemment, lorsqu'il y a risque de confusion, particulièrement en situation de Perfect Forwarding, on préférera souvent les parenthèses aux accolades.

Lectures complémentaires

Quelques liens pour enrichir le propos.


Valid XHTML 1.0 Transitional

CSS Valide !