Concept de concept

J'ai écrit un texte sur les concepts en 2014 ou 2015 (plus tout à fait certain), pensant que le concept (!) de concept était mûr pour être officiellement intégré à C++, mais ce n'est qu'avec C++ 20 que cette brillante idée, sur laquelle des gens que j'estime énormément ont planché pendant des décennies, devient une partie véritable – et indispensable! – du langage.

J'ai donc mis de côté beaucoup de ce que j'avais écrit par le passé pour recommencer avec quelque chose de plus humble, mais de bien réel : les concepts, tels que C++ les propose à partir de C++ 20. Et cette réécriture fut fort agréable.

Si vous souhaitez escamoter la (longue) mise en contexte, vous pouvez sauter directement aux concepts tels que nous les avons depuis C++ 20.

Mise en situation

Chaque gain d'abstraction augmente la capacité d'expression des individus et mène à repenser les techniques et les philosophies qui nous accompagnent au quotidien, dans notre travail comme dans notre vie.

Souvent, en programmation, gagner en abstraction entraîne le développement de nouvelles techniques et résulte, contrairement à ce que certains croiraient, en des programmes plus rapides et plus compacts. La mauvaise presse que subissent parfois les idées novatrices (incluant la POO, que l'ont tient pour acquis aujourd'hui mais qui a radicalement transformé la programmation) tient à un examen de ces idées à la lueur de leur premiers pas : les applications initiales d'une idée ne sont pas toujours aussi élégantes qu'elles ne le pourraient, et les compilateurs peinent à traduire ces idées de manière à en tirer véritablement profit.

Une fois que les compilateurs et les optimisateurs atteignent un niveau de maturité raisonnable face à une percée conceptuelle donnée, une fois aussi que la pensée des programmeurs a intégré correctement les nouvelles idées, le monde des possibles s'en trouve grandi.

Le potentiel de généricité d'un langage OO peut s'exprimer entre autres :

Une autre catégorie, moins importante toutefois, est celle des délégués, qui est une forme de polymorphisme basée sur la signature, cousine OO des pointeurs de fonctions. C++ supporte directement cette pratique par std::function, et C# le fait depuis longtemps par delegate.

J'escamote ici plusieurs thématiques propres à l'abstraction (mixins, classes anonymes, expressions λ et foncteurs pour ne nommer que celles-là) parce qu'elles importent moins pour notre propos sur cette page.

Exprimé informellement et très sommairement, le code générique en C# donnera des messages d'erreurs plus clairs (traditionnellement) que le code générique en C++, mais au prix d'une expressivité radicalement moindre. Essayez simplement d'exprimer une Paire<T,U> dans les deux langages et cette différence sera déjà marquante...

Le polymorphisme et la programmation générique sont possibles à la fois en C++, en Java et en C# (entre autres), quoique la programmation générique soit radicalement plus puissante sous C++ pour diverses raisons techniques et conceptuelles.

Le polymorphisme dynamique traditionnel permet une abstraction opératoire basée sur une communauté structurelle; c'est une approche intrusive (le parent fait partie de l'enfant), mais que certaines techniques permettent de rendre extrusive. Les types auxquels s'applique le polymorphisme ont une parenté commune. Les opérations exploitant la généricité par le polymorphisme procèdent à travers un lien indirect (pointeur ou référence) sur quelque chose qui mène au moins vers ce parent commun, sollicitant des opérations que tous les enfants de ce parent ont nécessairement implémenté.

La programmation générique permet une abstraction opératoire basée sur la possibilité d'appliquer des opérations sur les types impliqués. Nul besoin a priori d'un lien de parenté entre les types impliqués, du moins pas en C++ (en C#, les clauses where tendent à exiger le respect intrusif d'une ou plusieurs interfaces). Rien n'empêche, évidemment, d'appliquer un algorithme de programmation générique sur des types polymorphiques – une application de l'abstraction n'empêche pas l'autre, après tout.

Toute technique a ses avantages et ses inconvénients.

Le polymorphisme traditionnel permet une généricité dynamique, où la méthode à invoquer sur une abstraction donnée ne sera véritablement connue qu'au moment de l'exécution d'un programme. C'est ce que les anglophones nomment du Late Binding.

Ce dynamisme facilite l'évolutivité des programmes et permet le développement d'outils extrêmement flexibles, s'appliquant à des types a priori inconnus.

À travers une abstraction polymorphique, des opérations arbitrairement complexes peuvent être sollicitées de manière transparente.

Le polymorphisme et le dynamisme qu'il permet constituent la base de plusieurs schémas de conception populaires comme observateur ou proxy.

Le dynamisme du polymorphisme a un coût, par contre. Tout appel de méthode polymorphique est indirect, et appelle une fonction qu'il est en général impossible de déterminer a priori, outre dans quelques cas très particulier, ce qui empêche les compilateurs de procéder à plusieurs optimisations qui seraient possibles sur des opérations non-polymorphiques.

Appeler une méthode polymorphique coûte donc généralement plus cher en temps d'exécution qu'appeler une méthode qui ne l'est pas.

// j'utilise struct plutôt que class
// seulement pour alléger l'écriture
struct B {
   virtual int f(int) = 0;
   virtual ~B() = default;
};
struct D0 : B {
   int f(int n) const {
      return -n;
   }
};
struct D1 : B {
   int f(int n) const {
      return n * n;
   }
};
int g(B *pB) {
   return pB->f(3); // -3 ou 9 ou...?
}

Implémenter le polymorphisme sur une classe demande aussi d'y insérer un peu d'information supplémentaire pour faciliter l'aiguillage des méthodes. Cette information n'occupe pas un très grand espace, mais il est sage d'en être conscient.

Ces coûts ne sont pas prohibitifs sur la majorité des systèmes, mais peuvent l'être si les ressources sont comptées (dans un système embarqué ou en temps réel, par exemple).

La programmation générique permet une généricité statique, où les opérations à solliciter sont connues au moment où le programme est compilé. Ceci permet au compilateur de pratiquer un certain nombre d'optimisations significatives, par exemple du Function Inlining. À moins que les opérations appliquées aux types par un algorithme générique ne soient elles-mêmes polymorphiques, un bon compilateur pourra générer du code extrêmement optimisé dans le cas de programmes génériques.

Évidemment, il y a aussi un coût associé à la programmation générique. En particulier, chaque algorithme appliqué à un type donné (chaque instanciation d'un modèle générique) doit être généré pour ce type, ce qui peut entraîner une croissance importante de la taille d'un programme une fois celui-ci compilé (les sources, elles, seront plus compactes).

Il y a ici encore des trucs pour s'en sortir, et les compilateurs sont devenus excellents pour optimiser au maximum le code générique. De plus, dans du code générique, plusieurs langages exigent des compilateurs que seuls les fonctions appelées soient effectivement générées, ce qui peut en pratique mener à une réduction de la taille du code généré.

template <class T>
   T minimum(T a, T b) {
      return a < b? a : b;
   }
#include <string>
#include <iostream>
int main() {
   using namespace std;
   if (int i0, i1; cin >> i0 >> i1)
      // sollicite le < entre deux int
      cout << minimum(i0, i1);
   if (string s0, s1; cin >> s0 >> s1)
      // sollicite le < entre deux std::string
      cout << minmum(s0, s1);
}

Grandeurs et misères de la programmation générique

L'une des difficultés qu'entraîne la programmation générique (du moins en C++) a trait à la capacité qu'on les programmeurs d'y identifier les bogues.

En effet, le compilateur ne reconnaîtra un problème dans l'application d'un algorithme générique à un type donné que lorsqu'il tentera de générer le code correspondant et constatera l'absence d'une caractéristique clé.

Dans le cas à droite, la classe Incomparable n'expose pas d'opérateur < à la fois const et bool qui serait capable de comparer deux instances de la classe Incomparable. Si nous essayons d'appliquer l'opérateur < à deux instances d'Incomparable, alors le compilateur détectera l'erreur, évidemment. Le problème, par contre, sera celui de la génération d'un message d'erreur significatif.

Ici, sur le plan du diagnostic, on s'en tirera pas trop mal : le compilateur signalera que l'opérateur < manque pour le type Incomparable dans la fonction minimum().

Règle générale, toutefois, surtout quand les algorithmes génériques s'appellent les uns les autres, un compilateur risque de devoir signaler une erreur sur une ligne obscure dans une fonction dont le programmeur n'a à peu près jamais entendu parler.

#include <iosfwd>
#include <string>
#include <iostream>
using namespace std;
//
// requiert bool operator<(T,T)
//
template <class T>
   T minimum(T a, T b) {
      return a < b? a : b;
   }
//
// bool operator<(Incomparable,Incomparable) n'existe pas
//
struct Incomparable {
   constexpr int valeur() const noexcept {
      return 3;
   }
};
int main() {
   // sollicite « operator<(int,int) »
   if (int i0, i1; cin >> i0 >> i1)
      cout << Min(i0, i1) << endl;
   // sollicite le « operator<(string,string) »
   if (string s0, s1; cin >> s0 >> s1)
      cout << Min(s0, s1) << endl;
   Incomparable ic0, ic1;
   // ne compile pas
   cout << minimum(ic0, ic1);
}

Diagnostics et généricité

Le message d'erreur peut tenir sur des pages et des pages, retraçant chacune des étapes menant au problème et signalant les causes possibles. C'est pour le moins indigeste, et des programmes (fautifs) dont la compilation génère plus de lignes de messages d'erreurs qu'il n'y a de lignes de code source dans le programme.

Pour être utile, il faudrait au contraire qu'un message d'erreur nous indique que l'algorithme générique sollicité ne peut être appliqué au type souhaité faute d'exposer un certain nombre d'opérations, et devrait même indiquer lesquelles : ici, quelque chose comme Incomparable n'est pas Ordonnancable, par exemple. En d'autres mots, on voudrait un moyen de déterminer le contrat que devra respecter un type pour qu'on puisse lui appliquer un algorithme générique donné.

Ces contrats sont déjà exprimés informellement dans la documentation du langage et des bibliothèques. Par exemple, on dira des types susceptibles d'être insérés dans un conteneur standard qu'ils doivent être Assignable, CopyConstructible (ou, depuis C++ 11, Moveable) et LessThanComparable. En d'autres termes, un tel type doit offrir un opérateur d'affectation, un constructeur par copie et un opérateur < booléen et const permettant de déterminer un ordre entre deux instances. La réflexion en ce sens repose au moins en partie sur les travaux d'Alexander Stepanov.

Ce sont là des noms donnés à des ensembles de règles implicites, qu'on demande au programmeur de respecter. Tout type se conformant à ces règles sera insérable dans un conteneur standard; si l'une de ces règles n'est pas respectée, alors la compilation générera des erreurs. Le souhait de tous et chacun est de faire en sorte que le contrat déterminé par ces règles soit plus explicite et qu'un compilateur puisse le valider plus clairement.

La stratégie « manuelle »

Il est possible depuis longtemps de mettre en place un contrat plus formel par une saine discipline de programmation. Par exemple, si les contraintes d'un type T donné sont d'être Assignable, CopyConstructible et LessThanComparable, dans l'optique où on veut pouvoir insérer T dans un conteneur standard, on pourrait définir une classe ContainerInsertible comme proposée à droite.

Cette stratégie fonctionne somme toute assez bien, permet d'obtenir de meilleurs messages d'erreur et formalise par une technique le respect du contrat d'un modèle générique. Ça reste un tour de passe-passe, cela dit, et on pourrait raisonnablement espérer quelque chose de plus formel.

template <class T>
   class ContainerInsertible {
   public:
      static void contraintes(T a) { // constructeur par copie supporté
         T b = a;
         a = b;      // opérateur d'affectation supporté
         if (a < b); // comparaison avec < supportée
      }
      ContainerInsertible() {
         // On déclare un pointeur de fonction sur la méthode de classe dans
         // laquelle on vérifie les contraintes. Attention: on n'appelle pas
         // cette fonction, mais on s'assure ainsi que le code de la méthode
         // ContainerInsertible::contraintes() soit généré pour notre type T.
         //
         // Si une partie du contrat n'est pas respectée, alors une erreur de
         // compilation sera générée à la ligne appropriée de cette méthode.
         void (*p)(T) = contraintes;
      }
   };
// Présumant que ChicConteneur soit un conteneur respectant les règles des
// conteneurs standard, on voudra que le type T de ses éléments soit conforme
// à la règle définie par ContainerInsertible.
template <class T>
   class ChicConteneur {
      // Ce membre privé a pour rôle de forcer l'instanciation des contraintes
      // et, au besoin, de stopper la compilation. Si on rédigeait une fonction,
      // on aurait simplement déclaré un ContainerInsertible<T>() sans nom
      ContainerInsertible <T> nePasToucher__;
      // reste du conteneur
   };

Stratégie manuelle (raffinement)

Herb Sutter, dans More Exceptional C++, met en relief une vision encore plus charmante de cette stratégie. Cette version a été proposée par Bjarne Stroustrup avec contributions d'Alex Stepanov et de Jeremy Siek.

La nuance de cette proposition est de reconnaître que dans un cas comme ceux de ChicConteneur<T> et de ContainerInsertible<T>, on pourrait affirmer que ChicConteneur<T> « contient » intrinsèquement, et pas seulement par composition, la contrainte ContainerInsertible<T>.

On donc fait face à un beau cas d'héritage privé (mais pas d'héritage public: il n'y a pas lieu de regrouper ensembles les ContainerInsertible<T> ou encore de les manipuler de manière polymorphique).

Profitant de cette intuition, le code devient :

template <class T>
   class ContainerInsertible {
      static void contraintes(T a) { // constructeur par copie supporté
         T b = a;
         a = b;      // opérateur d'affectation supporté
         if (a < b); // comparaison avec < supportée
      }
   protected:
      ContainerInsertible() {
         void (*p)(T) = contraintes;
      }
   };
template <class T>
   class ChicConteneur : ContainerInsertible<T> {
      // Manière alternative d'indiquer que ChicConteneur<T> a un
      // sens qui dépend de la validité de ContainerInsertible<T>
      // reste du conteneur
   };

Concept de... concept

Un mécanisme pour déterminer des contrats sur des types génériques à même le langage a été intégré au langage C++ à partir de C++ 20. Ce mécanisme est ce qu'on nomme les concepts.

Dans les mots d'Alexander Stepanov, tirés d'une entrevue de 2015 :

« Concepts are requirements on types; preconditions are requirements on values. A concept might indicate that a type of a value is some kind of integer. A precondition might state that a value is a prime number. »

Dans les mots d'Andrew Sutton, dans une présentation donnée à C++ Now 2015 (je paraphrase), un concept est un prédicat qui détermine l'appartenance d'un type à un ensemble de types, un peu à la manière de certains traits. Ceci inclut la vérification de certaines expressions sur le plan syntaxique comme sur le plan sémantique. Les traits se limitent aux types et aux valeurs, mais ne permettent pas de vérifier la présence d'un template; les concepts, eux, vont beaucoup plus loin.

Depuis C++ 20, donc, bien qu'il demeure possible d'écrire un algorithme comme trouver ci-dessous :

template <class It, class T>
   It trouver(It debut, It fin, const T &elem) {
      for (; debut != fin; ++debut)
         if (*debut == elem) return debut;
      return fin;
   }

... il devient aussi possible de clarifier les attente envers les types It et T pour, entre autres choses, faire en sorte que les messages d'erreurs (s'il y en a) deviennent plus clairs :

template <ForwardIterator It, Comparable T>
   It trouver(It debut, It fin, const T &elem) {
      for (; debut != fin; ++debut)
         if (*debut == elem) return debut;
      return fin;
   }

Remarquez les qualifications remplaçant le mot générique class dans la spécification des types auxquels s'applique l'algorithme générique. Ce ne sont pas des types mais des concepts, donc des règles pouvant être appliquées à des types qui ne sont pas nécessairement apparentés.

Un concept est donc un prédicat statique sur un type, exprimé en termes de ses capacités opératoires, syntaxiques et sémantiques.

Clauses requires

L'une des pierres sur lesquelles reposent les concepts est la clause requires, qui exprime une exigence sur un type. Par exemple, le programme ci-dessous affichera non puis oui car X ne satisfait par le requis exprimé alors que Y satisfait ce requis. Notez que <concepts> exprime des concepts clés (typiquement sur la base de constantes génériques construites à partir de traits) comme std::integral :

#include <iostream>
#include <concepts>
using namespace std;
struct X {};
struct Y { int f() const { return 3; } };
template <class T>
   void f() {
      if constexpr (requires(const T &arg) { { arg.f() } -> std::integral; }) {
         cout << "oui" << endl;
      } else {
         cout << "non" << endl;
      }
   }
int main() {
   f<X>(); // non
   f<Y>(); // oui
}

La manière le lire la fonction f<T>() ci-dessus est qu'à la compilation, elle ne générera la branche if du if constexpr que dans le cas où, étant donné un const T& nommé arg, un appel à arg.f() retournerait quelque chose qui satisfasse le concept std::integral (comme par exemple... un int).

De façon plus lisible, on peut imaginer la forme suivante qui donne le même résultat :

#include <iostream>
#include <concepts>
using namespace std;
struct X {};
struct Y { int f() const { return 3; } };
// le requires requires est nécessaire ci-dessous
template <class T> requires requires(const T &arg) { { arg.f() } -> std::integral; }
   void f() {
      cout << "oui" << endl;
   }
template <class T>
   void f() {
      cout << "non" << endl;
   }
int main() {
   f<X>(); // non
   f<Y>(); // oui
}

Ici, la première version de f<T>() est contrainte aux types respectant le requis qui est exprimé sur T, alors que la seconde n'a pas de telle contrainte. Le compilateur préfèrera celle imposant un requis si ce requis est satisfait.

Pourquoi requires requires?

La grammaire de C++, pour les contraintes imposées à une fonction ou à un type, demande d'écrire requires avant l'exigence, qui doit quant à elle être un booléen connu à la compilation. Ici, l'exigence est exprimée par une clause requires; il faut donc écrire requires requires... Désolé!

Une clause requires peut lister plusieurs requis pour un même type. Pour cette raison, il est d'usage de nommer ces requis par un concept. Pour un concept PrefixIncrementable exigeant qu'un type T supporte l'opérateur ++ préfixé, on pourrait écrire :

template <class T>
   concept PrefixIncrementable = requires(T e) { ++e; };
int main() {
   static_assert(PrefixIncrementable<int>);
}

Si nous avions voulu être plus précis, et exprimer que ++e doive retourner un T&, nous aurions aussi pu écrire :

#include <concepts>
template <class T>
   concept PrefixIncrementable = requires(T e) {
      { ++e } -> std::same_as<T&>;
   };
int main() {
   static_assert(PrefixIncrementable<int>);
}

... ce qui signifie « le type T est PrefixIncrementable si, pour un T nommé e, l'expression ++e compile et est de type T& ». Pour un concept plus simple, comme PasTropGros qui s'avèrerait quand un T occupe au plus deux bytes en mémoire, il serait possible de procéder plus directement et de ne pas même avoir besoin de requires :

template <class T>
   concept PasTropGros = sizeof(T) <= 2;

Ces notations sont complémentaires, et le choix en est surtout un de confort. Un concept peut s'exprimer sur la base de plus d'un type, de constantes entières, de variadiques... tout comme l'ensemble des templates. Par exemple :

template <class T>
   concept PasTropGros = sizeof(T) <= 2;
template <PasTropGros ... Ts>
   void f(Ts ...) {
   }
int main() {
   f('a', short{ 3 }, static_cast<unsigned char>(1)); // Ok
}

Notez qu'un concept est toujours booléen. Pendant le développement de ce mécanisme, l'écriture concept bool C = ... était courante, mais il a été décidé d'éliminer le bool qui, au fond, n'apportait rien de plus à l'énoncé.

Comme le signale Richard Smith (source), il est possible de simuler une expression requires à l'aide du trait std::is_invocable, d'une λ et d'un if constexpr. Il donne pour exemple ceci :

auto check = [](auto &&x) -> decltype(x < x) {}; // on veut que operator< soit défini
if constexpr(is_invocable_r_v<bool, decltype(check), T>) { /* ... */ } // ... et que son type soit convertible à bool

Exemple concret (et simpliste)

Supposons que nous souhaitions définir un concept Incrementable, au sens de « supporte l'opérateur ++ ». Ne faites pas cela en pratique; l'expérience montre que cette granularité est trop fine pour mener à des concepts pertinents. J'utilise cet exemple pour présenter la syntaxe et illustrer la mécanique, sans plus.

Tiré des Core Guildelines de C++, plus spécifiquement de https://github.com/isocpp/CppCoreGuidelines/blob/master/CppCoreGuidelines.md#t20-avoid-concepts-without-meaningful-semantics :

« The ability to specify a meaningful semantics is a defining characteristic of a true concept, as opposed to a syntactic constraint »

Exprimé autrement, un truc comme Incrementable n'est pas une avenue féconde pour définir un concept. Ne le prenez donc pas pour autre chose qu'un exemple simpliste.

J'écris « typiquement » du fait qu'un concept peut être n'importe quelle métafonction générique booléenne. Par exemple, ceci :

template <class> concept C = true;

... est une métafonction tautologique.

On exprimera typiquement un concept à partir du mot clé requires, comme ceci :

template <class T>
   concept Incrementable = requires(T arg) {
      ++arg;
      arg++;
   };

Ceci peut se lire comme suit : est Incrementable tout type T tel que si arg est de type T, alors les expressions ++arg et arg++ sont bien formées. Exprimé autrement (et très informellement), une expression requires est un peu comme une métafonction booléenne qui ne retourne true que si le code exprimé à l'intérieur compile.

Un cas d'utilisation naïf mais fonctionnel serait celui présenté à droite.

Tout d'abord, le concept Incrementable<T> est défini à partir de l'existence des opérations ++ préfixe et suffixe sur T. Plus précisément, si arg est de type T, alors ++arg est défini comme devant être de type T&, alors que arg++ est défini comme devant être de type T.

Plusieus syntaxes de fonctions utilisant le concept Incrementable (ou pas) sont ensuite démontrées :

  • La fonction prochain() est définie de manière générale (pour un T quelconque) et de manière applicable à un Incrementable. Ceci montre qu'il est possible de spécialiser une fonction générique sur la base de concepts, et que la spécialisation la plus précise pour un appel donné sera choisie à la compilation
  • Les deux fonctions nommées respectivement IncrementableOnlyA et IncrementableOnlyB n'acceptent que des paramètres incrémentables :
    • La première (IncrementableOnlyA) exprime ceci par une clause requires
    • La seconde (IncrementableOnlyB) le fait en passant par la notation concise (Terse Notation)
    • Dans ce cas, les deux notations sont équivalentes. On préfèrera celle reposant sur une clause requires quand une fonction exigera une conjonction ou une disjonction de concepts

Un programme de test appelle ces diverses fonctions pour illustrer la mécanique.

Notez que la clause requires d'une fonction peut aussi être placée après la parenthèse fermante de la fonction, ce qui peut être utile si les noms des paramètres doivent intervenir dans l'expression :

#include <concepts>
using namespace std;
template <class F>
   auto CallIntegralReturning(F f) requires integral<decltype(f())> { // Ok
      return f();
   }
int main() {
   CallIntegralReturning([]{ return 3; });
}

... ou encore, plus joliment :

#include <concepts>
using namespace std;
template <class F> concept ReturnsIntegral = requires(F f) { { f() } -> integral; };
template <class F>
   auto CallIntegralReturning(F f) requires ReturnsIntegral<F> { // Ok
      return f();
   }
int main() {
   CallIntegralReturning([]{ return 3; });
}
#include <iostream>
#include <concepts>
using namespace std;
template <class T>
concept Incrementable = requires(T arg) {
   { ++arg } -> same_as<T&>;
   { arg++ } -> same_as<T>;
};
template <Incrementable T>
T prochain(T arg) {
   return ++arg;
}
template <class T>
T prochain(T arg) {
   return arg;
}
struct X {};
template <class T> requires Incrementable<T>
   void IncrementableOnlyA(T) {
   }
void IncrementableOnlyB(Incrementable auto) {
}
ostream & operator<<(ostream &os, X) {
   return os << 'X';
}
int main() {
   cout << prochain(3) << endl;
   cout << prochain(X{}) << endl;
   IncrementableOnlyA(3);
   // IncrementableOnlyA(X{}); // non
   IncrementableOnlyB(3);
   // IncrementableOnlyB(X{}); // non
}

Forme concise des concepts

Un enjeu ayant participé à ralentir l'adoption des concepts est ce qu'on nomme la forme concise (Terse Notation). En effet, l'idée clé derrière les concepts est de rendre la programmation générique normale, donc de rendre les concepts aussi simples à utiliser que les primitifs.

À l'origine, trois formes étaient donc envisagées :

Une forme détaillée, reposant sur requires, qui permet de composer des concepts et de construire des requis complexes

template <class T>
   requires Sortable<T>
      void sort(T &);

Une forme plus directe, qui permet de spécifier le concept requis en lieu et place du traditionnel class ou typename

template <Sortable T>
   void sort(T &);

Une forme concise (Terse), qui permet de spécifier le concept requis en lieu et place du type du paramètre. Des alternatives utilisant des accolades ont aussi été proposées, mais n'ont pas été retenues (elles avaient des bons côtés, mais semblaient étranges à certain(e)s... dont votre humble serviteur)

void sort(Sortable &);

Cette forme menait toutefois à certaines... tensions, disons, en particulier chez les gens qui devaient implémententer le mécanisme et avaient identifié des cas où il devenait difficile pour un compilateur de bien comprendre le code exprimé dans la forme concise, et pour une programmeuse ou un programmeur de déboguer efficacement un programme.

La forme concise qui fut retenue utilise le mot clé auto à titre d'indicateur du fait que le code examiné est du code générique, reposant sur un concept. Ainsi, la fonction à droite accepte un concept en paramètre, pas un type.

void sort(Sortable auto &);

Concepts et traits

Les concepts s'apparentent aux traits, et peuvent être exprimés en termes de traits. Par exemple, le concept Entier<T> suivant et exprimé en termes du trait std::is_integral<T> :

template <class T>
   concept Entier = std::is_integral<T>;

Toutefois, un trait peut être spécialisé alors qu'un concept ne le peut pas. Ainsi, si nous remplaçons std::is_integral<T> par une version appauvrie maison nommée est_entier<T> :

#include <type_traits>
template <class>
   struct est_entier : std::false_type {}; // cas général
template <>
   struct est_entier<int> : std::true_type {};
template <>
   struct est_entier<long> : std::true_type {};
// et ainsi de suite pour int, short, char, signed char,
// long long, leurs versions unsigned, etc.
template <class T>
   concept Entier = est_entier<T>::value;
// ...
// on peut spécialiser est_entier:
class gros_entier {
   // ... des pages et des pages de code ...
};
template <>
   struct est_entier<gros_entier> : std::true_type {};
#include <iostream>
using namespace std;
template <class T> requires Entier<T>
void f(T x) {
   cout << "oui" << endl;
}
void f(...) {
   cout << "non" << endl;
}
int main() {
   f(3);             // oui
   f(long{});        // oui
   f(gros_entier{}); // oui
   f(3.5);           // non
}

... il ne serait pas possible de spécialiser Entier<T> comme on le fait avec est_entier<T>.

Ceci montre d'ailleurs l'un des intérêts des concepts : il est possible de spécialiser des fonctions sur la base de concepts, donc d'écrire une fonction acceptant un type entier et une autre acceptant un type à virgule flottante. Comparez cet exemple (pris de ../Maths/Assez-proches.html), légal en C++ 11 et utilisant du tag dispatching...

#include <type_traits>
class exact {};
class flottant {};
template <class T>
   constexpr T absolue(T val) {
      return val < 0 ? -val : val;
   }
template <class T>
   constexpr bool assez_proches(T a, T b, exact) {
      return a == b;
   }
template <class T>
   constexpr bool assez_proches(T a, T b, flottant) {
      return std::abs(a - b) <= static_cast<T>(0.000001);
   }
template <class T>
   constexpr bool assez_proches(T a, T b) {
      return assez_proches(a, b, typename std::conditional<
         std::is_floating_point<T>::value, flottant, exact
      >::type{});
   }
int main() {
   static_assert(assez_proches(3,3), "");
   static_assert(assez_proches(3.0,3.0), "");
}

... avec cet exemple légal en C++ 14 et utilisant std::enable_if...

#include <type_traits>
template <class T>
   constexpr T absolue(T val) {
      return val < 0 ? -val : val;
   }
template <class T>
   constexpr std::enable_if_t<bool, !std::is_floating_point<T>::value>
      assez_proches(T a, T b) {
         return a == b;
      }
template <class T>
   constexpr std::enable_if_t<bool, std::is_floating_point<T>::value>
      assez_proches(T a, T b) {
         return std::abs(a - b) <= static_cast<T>(0.000001);
      }
int main() {
   static_assert(assez_proches(3,3), "");
   static_assert(assez_proches(3.0,3.0), "");
}

... avec cet exemple légal en C++ 17 et utilisant if constexpr (déjà une nette amélioration)...

#include <type_traits>
template <class T>
   constexpr T absolue(T val) {
      return val < 0 ? -val : val;
   }
template <class T>
   constexpr auto seuil = static_cast<T>(0.000001);
template <class T>
   constexpr bool assez_proches(T a, T b) {
      if constexpr(std::isfloating_point_v<T>)
         return absolue(a - b) <= seuil<T>
      else
         return a == b;
   }
int main() {
   static_assert(assez_proches(3,3));
   static_assert(assez_proches(3.0,3.0));
}

... avec cet exemple légal en C++ 20 et reposant sur des concepts :

#include <type_traits>
template <class T>
   concept Entier = std::is_integral_v<T>;
template <class T>
   concept Flottant = std::is_floating_point_v<T>;
template <Entier T>
   constexpr bool assez_proches(T a, T b) {
      return a == b;
   }
template <class T>
   constexpr T absolue(T val) {
      return val < 0 ? -val : val;
   }
template <class T>
   constexpr auto seuil = static_cast<T>(0.000001);
template <Flottant T>
   constexpr bool assez_proches(T a, T b) {
      return absolue(a - b) <= seuil<T>;
   }
int main() {
   static_assert(assez_proches(3,3));
   static_assert(assez_proches(3.0,3.0));
}

Notez la gradation :

Concepts en tant que types de fonctions

Il est possible d'utiliser un concept à titre de type de retour d'une fonction, incluant à travers la forme concise. Par exemple :

#include <type_traits>
#include <iostream>
template <class T>
   concept Entier = std::is_integral_v<T>;
Entier auto f() {
   return 3ULL;
}
int main() {
   using namespace std;
   auto n = f();
   cout << n << endl;
}

Ici, la fonction f() retourne quelque chose qui se conforme au concept Entier. Que nous ayons retourné un int, un short ou un unsigned long long (comme c'est le cas ici) importe peu. Conséquemment, le code client ne sait pas exactement quel type il obtiendra (à moins d'utiliser decltype ou un autre mécanisme de déduction), mais il sait ce qu'il pourra faire avec l'objet obtenu.

Destructeurs contextuels

Une méthode ne satisfaisant pas un concept donné n'existe pas. Une conséquence de cette réalité est qu'il est possible, par exemple, d'exprimer plusieurs destructeurs pour une même classe générique dans la mesure où, lorsque les types impliqués sont résolus, cette classe n'a qu'un seul destructeur en pratique.

Par exemple, avec le programme suivant :

#include <concepts>
#include <iostream>
using namespace std;
template <class T>
   struct X {
      ~X() requires integral<T> {
         cout << "X<T>::~X() pour T entier" << endl;
      }
      ~X() requires floating_point<T> {
         cout << "X<T>::~X() pour T flottant" << endl;
      }
   };
int main() {
   X<int> x0;
   X<double> x1;
   // X<void*> x2; // ne compile pas si on « décommente »
}

... compilera et affichera :

X<T>::~X() pour T flottant
X<T>::~X() pour T entier

... car les destructeurs sont exécutés en ordre inverse des constructeurs correspondants. Si on « décommente » la déclaration de x2 cependant, le code ne compilera pas (le compilateur estimera qu'il n'y a pas de candidat valide pour le destructeur de X<void*>).

Lectures complémentaires

Intérêt conceptuel (!) des concepts :

Tony van Eerd propose une de ses fameuses Tony Tables pour permettre de comparer la qualité des diagnostics offerts par un compilateur avec et sans concepts : https://github.com/tvaneerd/cpp20_in_TTs/blob/master/concepts.md

Textes de Rainer Grimm :

Textes de Barry Revzin :

Textes de Glennan Carnie en 2018, expliquant les concepts :

À propos de requires :

D'intérêt historique :

Pour mieux comprendre l'utilité des concepts, voir cet exercice de Fernando Pelliccioni en 2014 qui explique comment exprimer une fonction a priori simple comme min(a,b) :

Les concepts sans les concepts :

Textes d'Andrzej Krzemieński  :

Divers :


[1] Source : http://www.informit.com/content/images/art_stroustrup_2005/elementLinks/DnE2005.pdf, où Stroustrup définit ce que pourrait être le concept applicable à Forward_Iterator.


Valid XHTML 1.0 Transitional

CSS Valide !