Requires-expression

Andrzej Krzemieński

Traducteur

Bruno Barthel

Dans ce tutoriel, nous allons décrire brièvement la notion de Requires expressions en C++ 20. Les concepts C++ 20 semblent vraiment stables, il y a donc de fortes chances qu'ils deviennent la norme très bientôt. Nous présentons, deux implémentations expérimentales qui peuvent être testées en ligne dans Compiler Explorer. Commentez

- Titre : Requires-expression

- Auteur : Andrzej Krzemieński

- Traducteur : Bruno Barthel

- Parution : 31 août 2023

- Licence : Copyright ® 2023 Andrzej Krzemieński. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- I. Présentation des requires-expression

- II. Quelques détails techniques

- III. Utilisations pratiques

- IV. Remerciements

I - Présentation des requires-expression

Cet article est consacré à une fonctionnalité C++ 20. Je suppose que vous êtes déjà familier, au moins superficiellement, avec les concepts C++20. Dans cet article, nous n'en explorerons qu'une partie : les requires expressions. Voici à quoi ressemblerait le plus souvent une concept declaration :

template <typename T>
concept Machine =
requires(T m) {
m.start();
m.stop();
};

Mais il s'agit en fait de deux fonctionnalités distinctes qui fonctionnent ensemble. L'une est un concept, l'autre est une requires-expression. Nous pouvons déclarer un concept sans requires-expression :

template <typename T>
concept POD =
std::is_trivial<T>::value &&
std::is_standard_layout<T>::value;

Nous pouvons également utiliser une requires-expression à d'autres fins qu’une concept declaration :

template <typename T>
void clever_swap(T& a, T& b)
{
constexpr bool has_member_swap = requires(T a, T b){
a.swap(b);
};

if constexpr (has_member_swap) {
a.swap(b);
}
else {
using std::swap;
swap(a, b);
}
}

Dans cet article, nous examinerons les requires-expression en tant que fonctionnalité autonome et explorerons ses limites.

En résumé, une requires-expression teste si un ensemble donné de paramètres de modèle fournit une interface souhaitée : fonctions membres, fonctions libres, types associés, etc. Pour ce faire, un nouveau sous-langage a été conçu pour décrire ce qui est requis d'une interface. Par exemple, pour vérifier si un type Iter donné peut être incrémenté, on peut écrire :

requires(Iter i) { ++i; }

Quelques éléments à noter ici. Nous voulons généralement (bien que ce ne soit pas strictement nécessaire) qu'Iter soit un paramètre template, de sorte que le code ci-dessus apparaisse à l'intérieur d'un template. L’extrait de code ci-dessus est une expression de type bool, il peut donc se présenter partout où une expression booléenne peut apparaître :

template <typename Iter>
struct S
{
static_assert(requires(Iter i){ ++i; }, "no increment");
};

Il s'agit toujours d'une expression constante qui peut être utilisée dans une assertion statique, une instruction if constexpr ou même comme paramètre template (bien que vous ne puissiez peut-être pas tester ce dernier dans la version expérimentale actuelle de gcc (10.0.1 20200121) en raison d'un bogue). L'expression ++i n'est jamais évaluée. C'est comme si c'était à l'intérieur de sizeof() ou decltype(). Sa signification est « ++i doit être une expression valide lorsque i est un objet de type Iter ». De même, i n'est pas vraiment un objet : sa durée de vie n’est jamais précisée, il n'est jamais initialisé. Cet Iter i dit seulement que nous utiliserons l'identifiant i pour montrer quelles expressions sont valides. L'expression est évaluée, à true ou false, lorsque le modèle est instancié. Si au moins l’une des exigences répertoriées n'est pas satisfaite, l'expression prend la valeur false ; sinon, si toutes les exigences sont satisfaites, l'expression est évaluée à true. Cela signifie que si la requires-expression n'a pas d'exigences dans son body, elle prend toujours la valeur true. De plus, la liste des paramètres de requires-expression peut être omise si nous n'introduisons aucun paramètre, donc ceci :

requires {1;}

est une requires-expression valide qui est toujours évaluée à « vrai » et qui équivaut finalement à tout simplement écrire « true » :

template <typename T, bool = requires{1;}>
struct S;

// same as:
template <typename T, bool = true>
struct S;

L'exemple ci-dessus est idiot, mais il aide à illustrer ce qu'est une requires-expression. De plus, une requires-expression n'a pas besoin d'apparaître dans un template, mais sa signification est légèrement différente : lorsqu'une exigence n'est pas satisfaite, nous obtenons une erreur de compilation plutôt qu'une valeur fausse. Cela pourrait être utilisé pour tester si une classe concrète a une interface complète implémentée :

#include "SuperIter.hpp"

constexpr bool _ = requires(SuperIter i) {
++i; // stop compilatopn if not satisfied
};

Revenons à l'expression des contraintes. Nous avons vu comment vérifier un incrément. Comment vérifier si un type donné a une fonction membre f() ou une fonction membre statique ? Il suffit d'écrire une expression qui l'invoque :

requires(T v) {
v.f();  // member function
T::g(); // static member function
h(v);   // free function
}

Comment vérifier si une fonction prend un int comme argument ? Nous devons introduire un paramètre de type int dans la liste des paramètres et l'utiliser dans l'expression :

requires(T v, int i) {
v.f(i);
}

Mais quel est le type de ces expressions ? Pour l'instant, nous ne l'avons pas précisé, ce qui signifie que le type ne nous concerne pas, car nous ne nous intéressons qu'aux effets de bord, comme l'incrémentation d'un itérateur : il peut s'agir de n'importe quel type, voire de void. Et si nous avions besoin de la fonction membre f() pour renvoyer un int ? Une requires-expression a une autre syntaxe pour l'exprimer. Mais avant de l'utiliser, nous devons répondre à la question : avons-nous besoin que la fonction renvoie exactement int, ou est-ce suffisant lorsqu'elle renvoie un type convertible en int ? Le but d’une requires-expression est de nous permettre de tester ce que nous pourrons faire avec le T. Si nous vérifions si le type de l'expression est int, c'est parce que dans certains modèles, nous appellerons cette expression comme ceci :

template <typename T>
int compute(T v)
{
int i = v.f(0);
i = v.f(i);
return v.f(i);
}

Pour que ces expressions fonctionnent, la fonction f() n'a pas besoin de renvoyer précisément int. Si c’est un small integer (short) qui est renvoyé, cela fonctionnera également. Si vous voulez que le type soit convertible en int, sa syntaxe est :

requires(T v, int i) {
{ v.f(i) } -> std::convertible_to<int>;
}

Si le type de retour doit être exactement int, codez :

requires(T v, int i) {
{ v.f(i) } -> std::same_as<int>;
}

Cette construction spécifie que (1) l'expression entre accolades doit être valide et (2) son type de retour doit satisfaire la contrainte. std::same_as et std::convertible_to sont des concepts de bibliothèque standards. Le premier prend deux types en paramètres et vérifie s'ils sont du même type :

static_assert(std::same_as<int, int>);

C'est assez semblable au trait de type std::is_same, sauf que c'est un concept et qu'il nous permet de faire quelques astuces. L'une de ces astuces est que nous pouvons "réparer" le deuxième paramètre du concept en tapant std::same_as<int>. Ceci transforme le concept en une exigence qui vérifie si le premier paramètre, appelez-le T, satisfait std::same_as<T, int>. Donc, revenons à notre déclaration d'exigence :

requires(T v, int i) {
{ v.f(i) } -> std::same_as<int>;
}

Ce que nous voyons après la flèche est un concept, et parce que c'est un concept, l'exigence se lit comme suit : "std::same_as<decltype(v.f(i)), int> doit être satisfait." Notez que, contrairement aux versions précédentes de Concepts Lite, il est incorrect de simplement mettre un type de retour souhaité après la flèche :

requires(T v, int i) {
{ v.f(i) } -> int; // compiler error
}

Bien que la syntaxe ci-dessus semble naturelle, il ne serait pas évident de savoir si la propriété same_as ou convertible_to était requise. La raison est expliquée en détail dans cet article. Ensuite, si nous voulons vérifier en plus si la fonction f() est déclarée pour ne pas émettre d'exceptions, il existe une syntaxe pour cela :

requires(T v, int i) {
{ v.f(i) } noexcept -> std::same_as<int>;
}

Notez que ce qui précède s'applique aux expressions arbitraires, pas seulement aux appels de fonctions. Si nous voulons dire que le type T a un membre data de type convertible en int nous écrirons :

requires(T v) {
{ v.mem } -> std::convertible_to<int>;
}

Nous pouvons également dire que notre classe a un type imbriqué. Nous devons utiliser le mot-clef typename :

requires(Iter it) {
typename Iter::value_type;
{ *it++ } -> std::same_as<typename Iter::value_type>;
}

requires-expression nous permet également d'évaluer des prédicats arbitraires sur nos types. Le mot-clef requirements a une signification particulière dans le corps de requires-expression : le prédicat qui suit doit être évalué à true ; sinon, l'exigence n'est pas satisfaite et l'ensemble de la requires-expression renvoie faux. Si nous voulons dire que la taille de notre itérateur Iter ne peut pas être plus grande que la taille d'un pointeur brut, nous pouvons l'exprimer comme suit :

requires(Iter it) {
requires sizeof(it) <= sizeof(void*);
}

Cette capacité à évaluer un prédicat arbitraire est très puissante, et un certain nombre des contraintes ci-dessus peuvent être réduites à celle-ci. Par exemple, le type d'une expression peut être déclaré comme ceci :

requires(Iter it) {
*it++;

// with a concept
requires std::convertible_to<decltype(*it++),
typename Iter::value_type>;

// or with a type trait
requires std::is_convertible_v<decltype(*it++),
typename Iter::value_type>;
}

Une exigence no-throw peut également être exprimée avec une expression noexcept comme :

requires(Iter it) {
*it++;
requires noexcept(*it++);
}

Et enfin, parce qu'une requires-expression est elle-même un prédicat, nous pouvons l'imbriquer :

requires(Iter it) {
*it++;
typename Iter::value_type;
requires requires(typename Iter::value_type v) {
*it = v;
v = *it;
};
}

Maintenant, si nous voulons donner un nom à l'ensemble de contraintes que nous avons créé, afin qu'il puisse être réutilisé à différents endroits de notre programme en tant que prédicat, nous avons plusieurs options. Incluez-le dans une fonction constexpr :

template <typename Iter>
constexpr bool is_iterator()
{
return requires(Iter it) { *it++; };
}

// usage:
static_assert(is_iterator<int*>());

Utilisez-le pour initialiser un modèle de variable :

template <typename Iter>
constexpr bool is_iterator = requires(Iter it) { *it++; };

// usage:
static_assert(is_iterator<int*>);

Utilisez-le pour définir un concept :

template <typename Iter>
concept iterator = requires(Iter it) { *it++; };

// usage:
static_assert(iterator<int*>);

Utilisez-le comme un ancien type de trait :

template <typename Iter>
using is_iterator =
std::bool_constant<requires(Iter it) { *it++; }>;

// usage:
static_assert(is_iterator<int*>::value);

II - Quelques détails techniques

Pour que notre analyse de la fonctionnalité soit complète, nous devons mentionner trois détails. Tout d'abord, requires-expression utilise un court-circuit. Elle vérifie les contraintes dans l'ordre où elles apparaissent et dès que la première contrainte non satisfaite est détectée, la vérification des suivantes est abandonnée. Ceci est important pour des raisons d'exactitude, car - comme nous l'avons vu dans le sujet précédent - les contraintes où une construction erronée est produite lors de l'instanciation d'un modèle (modèle de classe ou modèle de fonction ou modèle de variable) produisent une erreur de compilation matérielle plutôt qu'une vraie /fausse réponse. Ceci peut être illustré par l'exemple suivant :

template <typename T>
constexpr bool value() { return T::value; }

template <typename T>
constexpr bool req = requires {
requires value<T>();
};

constexpr bool V = req<int>;

Nous utilisons un modèle de variable req pour représenter la valeur de la requires-expression dans laquelle nous devons évaluer une fonction constexpr value(). Plus tard, lorsque nous testerons notre exigence pour le type int, on pourrait s'attendre à ce qu'elle renvoie false, mais ce n'est pas le cas. Pour évaluer la fonction, nous devons instancier le modèle de fonction. Cette instanciation déclenche une fonction mal formée et c'est une erreur matérielle. La compilation va juste s'arrêter. Cependant, si nous ajoutons une exigence « de garde » qui vérifie si T::value est une expression valide :

template <typename T>
constexpr bool req = requires {
T::value;
requires value<T>();
};

constexpr bool V = req<int>;

Le programme compilera et initialisera V à « faux » en raison d'un court-circuit. Pour une raison similaire, les deux déclarations C++14 suivantes ne sont pas équivalentes :

template <typename T>
auto fun1()
{
return T::value;
}

template <typename T>
auto fun2() -> decltype(T::value)
{
return T::value;
}

En pratique, toute utilisation de fun1<int> doit provoquer un échec de compilation, car même pour déterminer sa signature, nous devons instancier le modèle de fonction. Alors que dans fun2, l'erreur se trouve dans la signature, avant d'essayer d'instancier le corps de la fonction, et cela peut être détecté par des astuces SFINAE et utilisé comme information sans provoquer d'échec de compilation. La deuxième observation est qu'il existe un risque de malentendu quant à ce qui est testé. Nous avons des contraintes sur les expressions valides et sur les prédicats booléens :

requires {
expression;         // expression is valid
requires predicate; // predicate is true
};

Si nous déclarons :

requires (T v) {
sizeof(v) <= 4;
};

Cela se compile bien et peut donner l'impression que nous testons si T a une petite sizeof, mais en fait, la seule chose que nous testons est de savoir si l'expression sizeof(v) <= 4 est bien formée, nous ne testons pas sa valeur. Cela peut devenir encore plus déroutant si nous mettons une requires-expression imbriquée à l'intérieur :

requires (T v) {
requires (typename T::value_type x) { ++x; };
};

Il semble que nous vérifions si ++x est une expression valide, mais ce n'est pas le cas : nous vérifions si requieres (typename T::value_type x) { ++x ; } est une expression valide et c'est le cas, que ++x soit valide ou non. Heureusement, aucune des implémentations existantes n'accepte ce code mandaté dans N4849 (voir ici). Il est également prévu de le corriger avant la livraison de C++20, afin que l'expression dont la validité est testée ne puisse pas commencer par requires. Vous serez obligé d'écrire :

requires (T v) {
// check if ++x is valid
requires requires (typename T::value_type x) { ++x; };
};

Le troisième piège potentiel concerne la manière dont les types mal formés sont traités dans la liste des paramètres de la requires-expression. Considérez :

template <typename T>
struct Wrapper
{
constexpr static bool value =
requires (typename T::value_type x) { ++x; };
};

Désormais, le type potentiellement mal formé (T::value_type) se trouve dans la liste des paramètres plutôt que dans le corps. Si la classe Wrapper est instanciée avec int, la requires-expression retournera-t-elle false ou échouera-t-elle à se compiler ? La réponse donnée par la norme est qu'elle devrait échouer à compiler : seules les contraintes dans le corps de la requires-expression ont cette propriété que les types et expressions invalides sont transformés en une valeur booléenne. Cela peut être contre-intuitif pour deux raisons. Tout d'abord, si nous posons cette requires-expression comme définition du concept :

template <typename T>
concept value_incrementable =
requires (typename T::value_type x) { ++x; };

constexpr bool V = value_incrementable<int>;

Cela compilera correctement. Cependant, dans ce cas, la raison est différente : les concepts eux-mêmes ont des propriétés spéciales qui font que cela fonctionne, mais ce ne sont pas les propriétés de la requires-expression. De même, si nous plaçons notre requires-expression dans une clause requires d'un modèle de fonction :

// constrained template:
template <typename T>
requires requires (typename T::value_type x) { ++x; }
void fun(T) {}

// unconstrained template
template <typename T>
void fun(T) {}

f(0);

Ce double requires n'est pas une erreur. Ce code se compilera bien à nouveau, mais ici, les règles spéciales de résolution de surcharge font que cela fonctionne : ce n'est pas à cause des propriétés de la requires-expression. Deuxièmement, il peut être difficile de croire que l'instanciation de Wrapper<int>::value échouera à se compiler, car GCC le compile réellement : voir ici. Mais c'est un bogue dans GCC.

III - Utilisations pratiques

Enfin, pour quelque chose de pratique. Où une clause requires est-elle utile autre part que pour définir un concept ? Nous allons voir deux cas d'utilisation. Le premier que nous avons déjà vu, c'est pour définir des contraintes ad-hoc de template anonymes : si on ne l'utilise qu'une seule fois, ça ne sert à rien de lui donner un nom :

template <typename T>
requires requires (T& x, T& y) { x.swap(y); }
void swap(T& x, T& y)
{
return x.swap(y);
}

Le premier requieres introduit la clause requieres ; cela signifie, "le prédicat contraignant suit". Le second requires introduit la requires expression qui est le prédicat. Au passage, notez ici qu'il n'y a pas de différence entre taper

requires (T& x, T& y) { x.swap(y); }

et

requires (T x, T y) { x.swap(y); }

et même

requires (T&& x, T&& y) { x.swap(y); }

Pour le deuxième cas d'utilisation, nous pourrions essayer d'écrire un test de compilation pour une fonctionnalité "négative". Une caractéristique négative est que lorsque nous fournissons une garantie que certaines constructions ne pourront pas être compilées. Nous en avons vu un exemple dans ce sujet. Imaginons que nous ayons une fonction membre qui commence à surveiller certaines données. Elle utilise son paramètre par référence, et parce qu'il n'y a aucune intention de modifier les données, c'est une référence à un objet constant :

struct Machine
{
void monitor(const Data& data);
// ...
};

La référence sera stockée et utilisée longtemps après le retour de la fonction monitor(), nous devons donc nous assurer que cette référence n'est jamais liée à un objet temporaire. Il existe plusieurs façons d'y parvenir : par exemple, fournir une autre surcharge supprimée ou utiliser lvalue_ref. Mais nous voulons aussi le tester : nous voulons créer un static_assert qui teste si le passage d'une rvalue à monitor() échoue à la compilation. Mais nous voulons une réponse vrai/faux, plutôt qu'une erreur dure du compilateur. Cette tâche est réalisable en C++11 avec quelques astuces de modèles d'experts, mais C++20 requires-expression pourrait rendre la tâche un peu plus facile. On pourrait essayer d'écrire :

static_assert( !requires(Machine m, Data d) {
m.monitor(std::move(d));
});

Notez l'opérateur bang qui exprime notre attente "négative". Mais cela ne fonctionne pas en raison d'une autre propriété de requires-expression : lorsqu'il est déclaré en dehors de tout modèle, les expressions et les types à l'intérieur sont testés immédiatement, et si l'un d'entre eux est invalide, nous obtenons une erreur matérielle. Ceci est semblable à SFINAE : l'échec de la substitution n'est pas une erreur lors de la résolution de surcharge, mais il s'agit d'une erreur dans les contextes non modèles. Donc, pour que notre test fonctionne, nous devons introduire - même artificiellement - un modèle, d'une manière ou d'une autre. Par exemple :

template <typename M>
constexpr bool can_monitor_rvalue =
requires(M m, Data d) { m.monitor(std::move(d)); };

static_assert(!can_monitor_rvalue<Machine>);

Et c'est tout. Tous les secrets des requires-expressions sont révélés.

IV - Remerciements

Nous remercions Andrzej Krzemieński de nous avoir autorisés à publier ce tutoriel.

Nous tenons également à remercier escartefigue pour la traduction et la relecture orthographique