Affectation déstructurante (Structured Bindings)

Ce qui suit est une ébauche d'un article à venir.

Depuis C++ 17, il est possible de « déstructurer » un agrégat en plusieurs variables à l'aide d'une affectation déstructurante, ou Structured Binding de son nom technique.

Un exemple simple et un peu naïf serait le programme proposé à droite, où un Point nommé pt est utilisé pour quelques fins non-déterminées (le ... placé en commentaire). L'expression suivante :

auto [x, y] = pt; // x est une copie de pt.x, y est une copie de pt.y

... crée deux variables x et y qui seront des copies des attributs pt.x et pt.y respectivement (le lien se fait par la position des attributs, pas par les noms).

Cet exemple est trop simple pour démontrer la force du mécanisme, évidemment. Notez tout d'abord qu'il aurait été possible de saisir des références aux attributs pt.x et pt.y en écrivant plutôt ceci :

auto &[x, y] = pt; // x réfère à pt.x, y réfère à pt.y
#include <iostream>
using namespace std;
struct Point {
   int x {}, y {};
   Point() = default;
   Point(int x, int y) : x{x}, y{y} {
   }
};
int main() {
   Point pt{ 2,3 };
   cout << p.x << ',' << p.y << endl;
   // ...
   auto [x, y] = pt;
   cout << x << ',' << y << endl;
}

Décomposer la valeur de retour d'une fonction

L'utilité la plus directe des Structured Bindings est la déconstruction des valeurs retournées par les fonctions. Un ancêtre de ce mécanisme est la fonction std::tie() des std::tuple, par laquelle il est possible de décomposer un std::tuple en variables individuelles, ignorant (par std::ignore) les éléments qui ne sont pas utiles dans le contexte. Cela fonctionne d'ailleurs tout autant avec les std::pair.

À titre d'exemple (un peu tiré par les cheveux), prenons la classe Personne à droite, qui n'exposerait pas de service de consultation des états sur une base individuelle (pas d'accesseur spécifiquement prévu pour le nom ou pour l'âge), mais qui exposerait un service state() retournant un std::tuple contenant à lui seul l'ensemble des attributs.

Il est possible d'extraire le nom de ce std::tuple de plusieurs manières :

  • Par sa position, à l'aide de std::get<0>(p.state())
  • Par son type (du fait qu'il n'y a qu'une seule std::string dans le std::tuple), à l'aide de std::get<std::string>(p.state())
  • Par un appel à std::tie() en ignorant les autres états du std::tuple

Notez que la technique reposant sur std::tie() a un défaut, soit imposer la déclaration au préalable des variables dans lesquelles l'affectation sera effectuée, ce qui coûte l'exécution d'un constructeur par défaut a priori inutile.

 

// ...
class Personne {
   std::string nom;
   int age;
   // ...
public:
   // ...
   std::pair<std::string, int> state() const {
      return { nom, age };
   }
};
// ...
void afficher_nom(const Personne &p) {
   // une possibilité :
   // cout << std::get<0>(p.state()) << std::endl;
   // une autre possibilité :
   // cout << std::get<std::string>(p.state()) << std::endl;
   // avec std::tie()
   std::string nom;
   std::tie(nom, std::ignore) = p.state();
   cout << nom << std::endl;
}

Les Structured Bindings viennent donc couvrir (au moins en partie) cette niche : en combinant la décomposition de l'agrégat et la déclaration des variables, le passage par un objet construit par défaut mais sans réelle raison perd sa raison d'être.

Décomposer un struct ou un class

Tel que vu plus haut, il est possible pour un Structured Binding de décomposer un struct ou un class. La décomposition se fait de manière positionnelle, pas par nom, alors mieux vaut faire attention et choisir ses noms avec soin.

Bien... ...moins bien
struct Point {
   int x {}, y {};
   Point() = default;
   Point(int x, int y) : x{x}, y{y} {
   }
};
int main() {
   Point pt{ 2,3 };
   // ...
   auto [x, y] = pt;
   // ...
}
struct Point {
   int x {}, y {};
   Point() = default;
   Point(int x, int y) : x{x}, y{y} {
   }
};
int main() {
   Point pt{ 2,3 };
   // ...
   auto [y, x] = pt; // oups!
   // ...
}

Autre caractéristique des Structured Bindings : elles ne peuvent décomposer que les attributs publics d'un type, et qu'elles doivent décomposer (sans omission) tous les attributs de ce type. Ainsi :

Dans l'exemple à droite, il n'est pas possible de décomposer un Point car au moins un de ses attributs est privé. Le phénomène serait le même si les attributs étaient protégés.

#include <string>
#include <iostream>
#include <tuple>
using namespace std;
class Point {
   int x = 3, y = 4;
};
int main() {
   auto [x,y] = Point{}; // illégal
   cout << x << ',' << y << endl;
}

De manière un peu surprenante (à mes yeux), l'exemple à droite est aussi illégal du fait que a et b ne peuvent déconstruire *this, et ce même si à ce point, this->x et this->y sont accessibles.

Notez qu'après avoir écrit ceci, j'ai constaté qu'un collègue du WG21, Timur Doumler, avait fait le même constat la veille, et qu'il s'agit probablement d'une défectuosité de C++ 17, donc il se peut que cet étrange comportement soit bientôt chose du passé... en fait, nous en avons discuté mercredi matin à Jacksonville 2018 pour adopter un correctif en tant que DR pour C++ 17

#include <string>
#include <iostream>
#include <tuple>
using namespace std;
class Point {
   int x = 3, y = 4;
public:
   pair<int,int> f() const {
      auto [a,b] = *this; // illégal... 
      return {a, b};
   }
};
int main() {
   Point{}.f();
}

Enfin, l'exemple à droite est illégal du fait que Point comprend trois attributs alors que le code client utilise deux variables pour fins de déconstruction.

Notez que rendre un des attributs privés de le masque pas au sens de ce diagnostic : si, pour un Point donné, b est privé alors que x et y sont publics, il demeure illégal de le décomposer en deux variables.

#include <string>
#include <iostream>
#include <tuple>
using namespace std;
struct Point {
    int x = 3, y = 4;
    bool b = false;
};
int main() {
    auto [x,y] = Point{}; // illégal
    cout << x << ',' << y << endl;
}

Décomposer un std::array<T,N>

Les Structured Bindings permettent ausse de décomposer un tableau dont la taille est connue à la compilation.

Ainsi, le code proposé à droite est tout à fait correct.

#include <iostream>
#include <array>
using namespace std;
array<int,5> f() { return { 2, 3, 5, 7, 11 }; }
int main() {
    auto [a,b,c,d,e] = f();
    cout << a << ',' << b << ',' << c << ',' << d << ',' << e << endl;
}

Celui-ci ne l'est pas; un exemple avec trop de variables ne le serait pas non plus.

#include <iostream>
#include <array>
using namespace std;
array<int,5> f() { return { 2, 3, 5, 7, 11 }; }
int main() {
    auto [a,b,c,d] = f(); // non, pas assez de variables
}

La décomposition de tableaux bruts est aussi possible, comme le montre l'exemple à droite...

#include <iostream>
using namespace std;
template <class T>
int f(const T (&arr)[3]) {
    auto [a,b,c] = arr;
    return a + b + c;
}
int main() {
    int tab[] { 2, 3, 5 };
    return f(tab);
}

... et notez que, bien qu'il soit difficile de bien retourner un tableau par référence (donc tel que le compilateur en comprenne la taille) d'une fonction, ce n'est pas impossible, comme en témoignent la fonction g() et son appel dans l'exemple à droite.

#include <iostream>
using namespace std;
template <class T>
decltype(auto) g(T && arg) { return arg; }
template <class T>
int f(const T (&arr)[3]) {
    auto [a,b,c] = arr;
    return a + b + c;
}
int main() {
    int tab[] { 2, 3, 5 };
    return f(g(tab));
}

Décomposer un std::pair ou un std::tuple

Les std::tuple étant une généralisation des std::pair, il n'est pas surprenant que les Structured Bindings fonctionnent dans les deux cas. De fait, l'une des utilisations les plus directes des Structured Bindings est la décomposition de la valeur de retour d'un tel agrégat lors d'un appel de fonction.

Prenant pour exemple le langage Go, il est facile d'imaginer une fonction retournant une paire comprenant le succès (ou pas) d'un calcul et, le cas échéant, le résultat de ce calcul, nous pouvons décomposer le résultat pour en arriver à du code client franchement élégant.

Ceci peut constituer une alternative aux exceptions, pour qui préfère ne pas utiliser ce mécanisme.

#include <iostream>
#include <utility>
using namespace std;
pair<bool, int> div_entiere(int num, int denom) {
   if (!denom) return { false, -1 };
   return { true, num / denom };
}
int main() {
   if (int n, d; cin >> n >> d)
      if (auto [ok, result] = div_entiere(n, d); ok)
         cout << n << '/' << d << " == " << result << endl;
}

Reprenant l'exemple de la classe Personne donné plus haut, mais remplaçant std::tie() et les variables temporaires dont son appel dépendait par des Structured Bindings, nous en arrivons à du code plus efficace, au sens où le même résultat est obtenu sans imposer le recours à la construction par défaut de variables destinées à être passées à std::tie().

Équivalent à std::ignore?

Au moment d'écrire ces lignes, les Structured Bindings n'offrent pas d'équivalent à std::ignore lors d'un appel à std::tie(). Ainsi, il faut nommer chaque variable d'un Structured Binding, que l'on compte l'utiliser ou pas, et chaque nom doit être différent de tous les autres noms de la même portée.

// ...
class Personne {
   std::string nom;
   int age;
   // ...
public:
   // ...
   std::pair<std::string, int> state() const {
      return { nom, age };
   }
};
// ...
void afficher_nom(const Personne &p) {
   auto [nom, age] = p.state();
   cout << nom << ' ' << age << std::endl;
}

Initialisation et répétitives

Une application amusante des Structured Bindings est l'initialisation de plusieurs variables de types distincts dans une répétitive for.

Prenons un exemple semi-artificiel (mais piqué à Richard Hodges), soit celui d'une répétitive où l'on souhaite afficher à la fois la valeur de chaque élément d'un conteneur et son indice.

Ici, la variable i a été déclarée hors de la répétitive du fait qu'il n'y a pas de moyen simple avec C++ 17 de déclarer plusieurs variables de types distincts dans le bloc d'initialisation d'une même répétitive for.

#include <iostream>#include <vector>
#include <tuple>
int main() {
    std::vector<int> v { 6,5,4,3,2,1 };
      std::size_t i = 0;
    for(auto n : v) {
        std::cout << "index: " << i << '\t' << n << '\n';
    }
}

Il est possible de réaliser une meilleure localité pour les variables en combinant un std::tuple et des Structured Bindings. Depuis l'avènement des Deduction Guides, il est même possible de remplacer ceci :

auto [i, first, last] = std::make_tuple(std::size_t(0), begin(v), end(v))

... par cela :

auto [i, first, last] = std::tuple(std::size_t(0), begin(v), end(v))
#include <iostream>#include <vector>
#include <tuple>
int main() {
    std::vector<int> v { 6,5,4,3,2,1 };
    for( auto [i, first, last] = std::make_tuple(std::size_t(0), begin(v), end(v))
       ; first != last
       ; ++i, ++first) {
        std::cout << "index: " << i << '\t' << *first << '\n';
    }
}

Lectures complémentaires

Quelques liens pour enrichir le propos.


Valid XHTML 1.0 Transitional

CSS Valide !