Itérateurs de points de vue (ou de filtrage)

Cet article présume chez la lectrice ou le lecteur une compréhension de plusieurs concepts avancés de la POO en général et de C++ en particulier.

Avant de lire ceci, je vous recommande donc de consulter, entre autres, les articles décrivant les itérateurs, sur les foncteurs, sur la programmation générique, sur les types internes et publics, sur les traits et sur la métaprogrammation.

Imaginons un problème simple : afficher tous les éléments pairs d'un tableau d'entiers. Ou, plus généralement : appliquer une opération à tous les éléments d'une séquence pour lesquels un certain prédicat s'avère.

Écrit à la main, la solution à ce problème dans sa version spécifique (afficher les entiers pairs) est somme tout simple :

#include <iostream>
// le prédicat qui nous servira ici
bool est_pair(int n) noexcept {
   return n % 2 == 0;
}
// programme de démonstration
int main() {
   using namespace std;
   const int N = 10;
   int tab[N];
   // remplir le tableau de valeurs distinctes
   for (int i = 0; i < N; ++i)
      tab[i] = i;
   // pour chaque élément du tableau
   for (int i = 0; i < N; ++i)
      // si le prédicat choisi est vrai lorsque appliqué à l'élément
      if (est_pair(tab[i])
         // opérer sur l'élément
         cout << tab[i] << ' ';
   cout << endl;
}

Malheureusement, elle est à réécrire chaque fois que nous avons à résoudre un problème semblable. De même, elle peut se compliquer si les données du problème se compliquent elles aussi (forme de la boucle, complexité du test, richesse des opérations à réaliser, etc.).

On peut réécrire le tout de manière manuelle mais modulaire, ce qui facilite la construction rapide de solutions à des problèmes semblables :

#include <iostream>
#include <algorithm>
using namespace std;
// le prédicat qui nous servira ici
bool est_pair(int n) noexcept {
   return n % 2 == 0;
}
// initialiser la séquence dans le tableau
template <class It, class T>
   void initialiser_sequence(It debut, It fin, class T) {
      for (; debut != fin; ++i, ++debut)
         *debut = init++;
   }
// opérer sur un élément
void operation(int n) {
   cout << n << ' ';
}
// programme de démonstration
int main() {
   const int N = 10;
   int tab[N];
   // remplir le tableau de valeurs distinctes
   initialiser_sequence(begin(tab), end(tab), 0);
   // pour chaque élément du tableau
   for(auto n : tab)
      if (est_pair(tab[i])
         operation(tab[i]);
   cout << endl;
}

La bibliothèque standard de C++, en particulier à travers les algorithmes standards de la STL, propose aussi une solution simple et élégante (et nettement plus générique, donc plus réutilisable). Notre version proposera plus de code mais l'essentiel de ce qui sera écrit ici sera pleinement réutilisable dans de multiples projets :

#include <iostream>
#include <algorithm>
using namespace std;
//
// la classe Naturels est un foncteur générant à chaque appel le
// prochain entier naturel d'une séquence d'entiers naturels
//
class Naturels {
public:
   using value_type = unsigned int;
private:
   value_type cur;
public:
   Naturels(value_type init = {}) : cur{init} {
   }
   value_type operator()() noexcept {
      return cur++;
   }
};
bool est_pair(int n) noexcept {
   return n % 2 == 0;
}
void operation(int n) {
   cout << n << ' ';
}
void operation_potentielle(int n) {
   if (est_pair(n)) operation(n);
}
int main() {
   const int N = 10;
   int tab[N];
   // générer une séquence d'entiers naturels
   generate(begin(tab), end(tab), Naturels{});
   // opérer sur chaque élément de la séquence
   for_each(begin(tab), end(tab), operation_potentielle);
}

Contrairement aux croyances, la version STL sera (lorsque compilée en mode Release) plus rapide que les versions programmées manuellement. Notez qu'il aurait été possible d'écrire operation_potentielle() de manière beaucoup plus générique, mais je vous laisse y réfléchir (c'est divertissant). Une λ aurait aussi été à propos ici, de même qu'un appel à l'algorithme std::iota().

Remarquez l'optique choisie :

C'est là presque une démarche philosophique : écrire une opération contenant son propre code de validation à travers un prédicat et l'appliquer à tous les éléments d'une séquence.

Par passage au suivant, j'entends l'opérateur ++ implémenté de manière à chercher le prochain élément de la séquence auquel correspond le prédicat de filtrage.

Une autre approche, que j'aimerais explorer ici, est de considérer la séquence comme un point de vue sur les données et de faire de l'action passer à l'élément suivant une opération capable de filtrer les éléments de manière telle que seuls les éléments pertinents à une recherche soient examinés. Cette approche sera parfois préférable, parfois pire qu'une approche STL classique[1].

L'itérateur est une abstraction du concept de séquence. Penser l'itérateur comme un point de vue sur les données est un acte naturel et s'intègre harmonieusement dans un programme, même si ce programme exploite des algorithmes standards. Ne prenez pas ce qui suit comme une solution à tous les problèmes (comme un marteau doré) mais bien comme une approche différente à des problèmes souvent rencontrés.

L'idée : un pattern_iterator

Je vous propose donc une classe que je nommerai pattern_iterator. Il s'agira d'une classe d'enrobage (d'une Wrapper Class) capable de :

Nous pourrons donc définir un itérateur qui n'ira que d'un élément valide à l'autre (le terme valide étant pris ici au sens de respectant la contrainte exprimée par le prédicat) plutôt que d'un élément à l'autre tout simplement.

Avantage de cette stratégie : imaginons une masse de personnages dans un jeu vidéo (tiens : des instances de la classe Orque et des instances de la classe Elfe, par exemple). Tout à coup, un sorcier lance un sort tel que tous les méchants sont frappés par la foudre. Parmi les options possibles pour implémenter un tel effet :

Il est probable que les deux dernières options offrent un niveau de performance comparable (ou identique), mais si un pattern_iterator est bien écrit une fois, la solution à tous les problèmes de ce genre sera de facto simplifiée. Viser la dernière option signifie chercher à la fois performance à l'exécution et économie (à moyen terme) en temps de développement.

La difficulté : ne pas dépasser la fin de la séquence

Tel que proposé, un pattern_iterator doit connaître non seulement sa position courante mais aussi la fin de la séquence. La raison pour cela est que le parcours filtré de la séquence implique une logique partielle de parcours de séquence à même l'opération passage au suivant; négliger d'informer l'itérateur de la fin de la séquence à parcourir mènerait la plupart des parcours à des débordements de séquences.

Sachant cela, il est important d'implémenter passage au suivant de manière efficace pour éviter de dégrader la performance d'ensemble. J'aurai pour ce faire recours à un algorithme standard (std::find_if()) offrant les garanties de performance attendues.

Implémentation opérationnelle de pattern_iterator

Ce qui suit détaille une implémentation opérationnelle du type pattern_iterator tel que proposé plus haut. Nous mettrons en place quelques outils qui nous permettront d'écrire du code plus élégant, puis nous montrerons comment mettre en place une première implémentation de notre classe. Par la suite, nous examinerons quelques raffinements possibles à cette implémentation dans le but d'en accroître la convivialité sans en diminuer la vitesse.

Tout d'abord, pour les fins de notre démonstration, nous aurons recours aux flux standards (pour l'affichage), aux algorithmes standards tels que std::copy() et srd::find_if(), et au petit foncteur Naturels proposé plus haut (pour générer facilement une séquence de nombres).

Pour mettre en place des traits pour notre itérateur, nous inclurons aussi le fichier d'en-tête standard <iterator>.

#include <iostream>
#include <algorithm>
#include <iterator>
// using...
class Naturels {
   // ...
};

Sans surprises, l'itérateur à mettre au point sera un type générique construit à partir de deux types : celui d'It, type d'itérateur représentant la séquence à filtrer, et celui de P, l'opération de filtrage.

Une stratégie simple pour obtenir des traits convenables pour notre itérateur est de les concevoir à partir des traits des itérateurs représentant la séquence à filtrer.

C'est une approche raisonnable : les itérateurs accédant véritablement aux données déterminent les concepts de pointeur, référence, valeur et ainsi de suite, alors que le pattern_iterator encapsule en quelque sorte le concept de séquence à proprement dit.

template <class, class>
   class pattern_iterator;
namespace std {
   template <class It, class P>
      struct iterator_traits<pattern_iterator<It, P>> : iterator_traits<It> {
      };
}
template <class It, class P>
   class pattern_iterator
      : public std:iterator<
           typename iterator_traits<It>::value_type,
           typename iterator_traits<It>::iterator_category
        >
   {

Sur le plan privé, l'itérateur connaîtra l'opération de filtrage et les extrémités de la séquence sous-jacente. Passer au suivant, opération privée, impliquera une recherche, à l'aide d'un algorithme standard, du prochain élément dans la séquence sous-jacente pour lequel le prédicat s'avère.

   private:
      It cur, fin;
      P patt;
      void seek_next() {
         cur_ = std::find_if(cur, fin, patt);
      }

Les constructeurs dont nous avons besoin ici sont la version paramétrique et la version par copie, mais la Sainte-Trinité générée par le compilateur nous sufit amplement du fait que pattern_iterator est un simple type valeur.

  Pour simplifier le travail à l'aide d'algorithmes standards, un premier passage au suivant est fait dans le constructeur paramétrique. Ceci fait en sortie que chaque pattern_iterator mène toujours à un élément valide de la séquence filtrée qu'il représente.

   public:
      pattern_iterator(It cur, It fin, P pattern) : cur{cur}, fin{fin}, patt{pattern} {
         seek_next();
      }

Les opérateurs relationnels essentiels pour définir un itérateur susceptible d'être utilisé dans des algorithmes standards sont la comparaison avec == et != de même que l'ordonnancement avec <, ce qui permet d'utiliser l'itérateur pour définir ce qu'on nomme un Strict Weak Ordering.

Retenons que SFINAE s'applique, donc si It ne supporte pas l'ordonnancement avec <, alors le pattern_iterator ne le supportera pas non plus.

Ici, l'implémentation est banale et repose sur de la simple délégation.

      bool operator==(const pattern_iterator &p) const {
         return cur == p.cur;
      }
      bool operator!=(const pattern_iterator &p) const {
         return !(*this == p);
      }
      bool operator<(const pattern_iterator &p) const {
         return cur < p.cur;
      }

Le passage au suivant public, dans sa forme préfixée ou dans sa forme postfixée, respecte les usages habituels en ce sens.

La subtilité est d'insérer dans operator++() (version préfixée) une invocation à une méthode privée cherchant le prochain élément respectant la contrainte exprimée par patt dans la séquence sous-jacente et le tour est joué.

      pattern_iterator& operator++() {
         ++cur;
         seek_next();
         return *this;
      }
      pattern_iterator operator++(int) {
         pattern_iterator temp(*this);
         operator++();
         return temp;
      }

Enfin, l'opération de déréférencement d'un itérateur, dans ses deux acceptions, est tout ce qu'il y a de plus banal.

Ceci conclut la définition de l'essentiel de notre classe pattern_iterator. Nous explorerons plus loin (section Raffinements) quelques raffinements opérationnels possibles pour permettre, par exemple, de brasser ou de trier les éléments d'une séquence filtrée.

      value_type& operator*() {
         return *cur;
      }
      value_type operator*() const {
         return *cur;
      }
   };

Ce qui reste à faire pour terminer notre démonstration est plus ardu, à tout le moins d'un point de vue syntaxique.

Examinez la spécification des instances de la classe pattern_iterator dans l'invocation de copy(), à droite.

bool est_pair(int n) noexcept {
   return n % 2 == 0;
}
int main() {
   const int N = 10;
   int tab[N];
   generate(begin(tab), end(tab), Naturels{});
   copy(
      pattern_iterator<int*, bool(*)(int)>
         (begin(tab), end(tab), est_pair),
      pattern_iterator<int*, bool(*)(int)>
         (begin(tab), end(tab), est_pair),
      ostream_iterator<int>{cout, " "}
   );
}

Chaque pattern_iterator doit être spécifié à partir de deux types (dont l'un des deux est un type de pointeur de fonction, rien de moins!) et de trois paramètres. C'est clairement une approche déraisonnable : on ne peut faire reposer sur les épaules du code client une stratégie d'écriture aussi lourde car personne ne voudra se servir de notre itérateur dans un tel cas.

S'il y a de la complexité syntaxique dans un programme, cette complexité doit reposer du côté du code serveur, estimé spécialiste de sa tâche, plutôt que du côté du code client.

Nous procéderons donc maintenant par ajout de quelques outils qui nous permettront de raffiner la convivialité de notre pattern_iterator du point de vue du code client. Comme c'est notre habitude, nous chercherons à atteindre ce raffinement à coût zéro dans le code une fois celui-ci compilé.

Première simplification : une fonction de fabrication

Remarquons tout d'abord un détail important en programmation générique (du moins en C++) quant aux fonctions en comparaison avec les types.

En effet, examinons un premier exemple de code tout simple. Cet exemple déclarera un pointeur intelligent standard capable de contenir un pointeur sur un int, d'en garantir la libération et de permettre une indirection intelligente vers le pointé.

Le code client se déclare un pointeur intelligent capable de contenir le int* en question et l'initialise avec un pointeur sur un int alloué dynamiquement.

Remarquez la présence du mot int à deux endroits sur la ligne de la déclaration. Ce qui nous intéresse ici est le mot int dans la déclaration du type de pointeur intelligent.

Dans une déclaration de variable de type générique, les types en fonction desquels le type générique doit être instancié doivent être indiqués explicitement parce que la déclaration de types, en C++, apparaît avant que les valeurs initiales ne soient connues[2].

#include <iostream>
#include <memory>
int main() {
   using namespace std;
   unique_ptr<int> p{new int{3}};
   cout << *p;
}

Cette restriction explique la lourdeur de notre déclaration de pattern_iterator : si nous reprenons la déclaration de ceux utilisés dans l'invocation de std::copy(), plus haut, nous constatons que les deux types impliqués doivent être indiqués pour chaque itérateur :

pattern_iterator<int*, bool(*)(int)>(begin(tab), end(tab), est_pair)
pattern_iterator<int*, bool(*)(int)>(end(tab), end(tab), est_pair)

Maintenant, examinons un exemple fonctionnellement identique mais structurellement différent.

La fonction générique creer() reçoit un pointeur brut et retourne un pointeur intelligent « autour » de ce pointeur brut.

Contrairement au programme principal de l'exemple précédent, celui-ci n'a pas à expliciter dans le détail les types impliqués lors de l'affichage. Le type T pour l'instance de std::unique_ptr<T> retournée par la fonction creer() est simplement déduit à partir du type T* de son paramètre.

#include <iostream>
#include <memory>
using namespace std;
template <class T>
   unique_ptr<T> creer(T *p) {
      return unique_ptr<T>{p};
   }
int main() {
   cout << *creer(new int{3});
}

Pour distinguer une fonction d'une autre en C++, il faut examiner leur signature qui comprend, entre autres choses, le type et le nombre de ses paramètres. Dans la déduction des types d'une fonction générique, le compilateur évalue donc le type de ses paramètres et fait, au besoin, les liens qui s'imposent.

Pour alléger la construction de nos itérateurs, nous utiliserons donc ici cette technique et nous passerons par une fonction de création de pattern_iterator que nous nommerons make_pattit(). Dans la mesure où notre programme principal n'a pas besoin de déclarer de variables pour contenir les instances de pattern_iterator ainsi générées, le code en sera quelque peu allégé.

Le code modifié ou ajouté suit. Je ne répéterai pas tout ce qui a déjà été exprimé par souci d'économie; référez-vous aux sections précédentes pour le reste du code.

// ...
template <class It, class P>
   pattern_iterator<It,P> make_pattit(It debut, It fin, P pattern) {
      return { debut, fin, pattern};
   }
// ...
int main() {
   const int N = 10;
   int tab[N];
   generate(begin(tab), end(tab), Naturels{});
   copy(make_pattit(begin(tab), end(tab), est_pair),
        make_pattit(end(tab), end(tab), est_pair),
        ostream_iterator<int>{cout, " "});
}

Pour bien isoler le gain de lisibilité obtenu ici, comparez la syntaxe associée à l'instanciation du pattern_iterator sur le début de la séquence sans utiliser make_pattitt() (à gauche) avec celle obtenue en utilisant cet outil (à droite). La simplification de l'écriture n'est assurément pas négligeable.

pattern_iterator<int*, bool(*)(int)> {begin(tab), end(tab), est_pair}
make_pattit(begin(tab), end(tab), est_pair)

Deuxième simplification : un type général de type

Autre élément caractéristique de la lourdeur syntaxique de notre écriture permettant d'instancier un type : elle est très répétitive. Pour chaque paire d'itérateurs, il est nécessaire d'exprimer deux fois l'itérateur de fin de séquence et il est aussi nécessaire d'exprimer deux fois le prédicat de filtrage. Pour cet irritant, une fonction ne sera pas la solution (il faudrait l'invoquer deux fois elle aussi et le problème demeurerait entier).

Une solution simple est de définir un type dont le rôle est de décrire à la fois le début et la fin d'une séquence déterminée par un pattern_iterator. Un tel type, portant le nom un peu long de pattern_iterator_descriptor, suit. À partir des trois paramètres usuels, il définit deux constantes représentant le début et la fin de la séquence et permet donc, à travers une même variable, d'obtenir sous forme compacte et allégée les deux extrémités de la séquence.

// ...
template <class It, class P>
   struct pattern_iterator_descriptor {
      using iterator_type = It;
      using pattern_type = P;
      pattern_iterator<iterator_type, pattern_type> debut;
      pattern_iterator<iterator_type, pattern_type> fin;
      pattern_iterator_descriptor(iterator_type debut, iterator_type fin, pattern_type patt) : debut{debut, fin, patt}, fin{fin, fin, patt} {
      }
      pattern_iterator<iterator_type, pattern_type> begin() {
         return debut;
      }
      pattern_iterator<iterator_type, pattern_type> end() {
         return fin;
      }
   };
template <class It, class P>
   pattern_iterator_descriptor<It, P>
      make_pattern_iterator_descriptor(It debut, It fin, P patt) {
         return { debut, fin, patt };
      } 
// ...
int main() {
   const int N = 10;
   int tab[N];
   generate(begin(tab), end(tab), Naturel{}));
   auto pidp = make_pattern_iterator_descriptor(begin(tab), end(tab), est_pair);
   copy(begin(pidp), end(pidp), ostream_iterator<int>{cout, " "});
}

L'invocation de std::copy() est encore une fois allégée, au prix d'avoir au préalable instancié un pattern_iterator_descriptor. L'exemple ci-dessous montre l'invocation de std::copy() avant (à gauche) et après (à droite) application de cette technique.

copy (
   make_pattit(begin(tab), end(tab), est_pair),
   make_pattit(end(tab), end(tab), est_pair),
   ostream_iterator<int>(cout, " ")
);
copy (
   begin(pidp),
   end(pidp),
   ostream_iterator<int>{cout, " "}
);

On peut encore simplifier le tout, mais ça devient plus subtil.

Spécialiser dans un algorithme

Revenons maintenant aux premières manipulations faites dans le but de simplifier le tout et souvenons-nous que le recours au mécanisme permettant de déduire automatiquement les types requis à partir du type des paramètres dans le cas où une fonction est générique.

L'opération d'itérer à travers une séquence et de filtrer les éléments peut se représenter sous la forme d'une fonction, qu'on pourrait nommer afficher_si(). Ce faisant, les descripteurs d'itérateurs peuvent être définis dans la fonction à partir des types des paramètres, élaguant complètement les déclarations de types complexes dans le code client.

Une application de cette stratégie suit. Notez que nous construisons ici encore sur les résultats des simplifications précédentes. Chaque étape était un gain en soi.

// ...
template <class It, class P>
   void afficher_si(It debut, It fin, P patt) {
      using value_type = typename iterator_traits<It>::value_type;
      auto pid = make_pattern_iterator_descriptor(debut, fin, patt);
      copy(begin(pid), end(pid), ostream_iterator<value_type>{cout, " "});
   }
int main() {
   const int N = 10;
   int tab[N];
   generate(begin(tab), end(tab), Naturels{});
   afficher_si(begin(tab), end(tab), est_pair);
}

Raffinements

Pour représenter habilement un itérateur de filtrage applicable à une séquence, notre petite classe pattern_iterator devrait aussi implémenter un certain nombre d'opérations telles que, dans le respect de SFINAE bien entendu :

Sachant que certaines de ces opérations ne sont permises que sur quelques catégories d'itérateurs, pas tous[3], il faudrait aussi examiner la question suivante : que faire dans un pattern_iterator dans le cas où l'itérateur sous-jacent (le type It) ne permet pas certaines opérations.

Il est possible de détecter (par des traits d'itérateurs) le support ou non de certaines opérations par le type d'itérateur sous-jacent. Cela dit, devrait-on :

Un chic exercice de programmation pour les intéressé(e)s.


[1]Si le critère est trouver le prochain élément en ordre croissant, par exemple, chaque passage au prochain élément aura une complexité linéaire et un affichage complet d'une séquence sera de complexité quadratique, bien pire qu'un tri fait au préalable.

[2]Avec C++ 11, il est possible, à l'aide d'applications novatrices du mot clé auto, qui existe depuis les années '70 mais dont personne ne se sert vraiment, et d'un nouveau mot clé (decltype), d'automatiser certaines déductions de types à la compilation... mais pas celles-ci.

[3]Par exemple, l'opérateur --, au sens de passage au précédent, n'a de sens que sur un itérateur bidirectionnel.


Valid XHTML 1.0 Transitional

CSS Valide !