C++ 20 – spaceship operator

Bonjour à tous et bienvenue dans cet article dédié au nouvel opérateur de comparaison prévu pour C++20 : <=>, le spaceship operator.

Herb Sutter a proposé en 2017 l’ajout au langage de l’opérateur <=>, avec la P0515 intitulée “Consistent comparison”.

Le spaceship operator, <=>, permet d’effectuer une three-way comparison. C’est similaire à la fonction strcmp en C, qui retourne 0 en cas d’égalité, une valeur négative si la première opérande est inférieure à la seconde et un nombre positif dans le cas contraire.

La three-way comparison est déjà disponible dans beaucoup de langages, que ce soit au travers de l’opérateur <=> comme en PHP et Ruby ou sous forme de fonctions de comparaison (comme c’est le cas en Java, C# et Haskell par exemple, avec respectivement compareTo , CompareTo  et compare). Cela étant, nous allons voir que le fonctionnement de <=> en C++ est un peu plus complexe, mais également plus puissant.

Voici un exemple d’utilisation de <=> pour un type utilisateur :

Cet exemple montre une comparaison three-way classique. Chaque champ est ordonné par rapport aux autres (j’entends par là qu’on a considéré que deux joueurs sont d’abord comparés par nom, puis par niveau) et on considère deux objets comme égaux que si tous les champs significatifs sont égaux.

Vous avez surement remarqué que le type de retour n’est pas un int, comme on aurait pu s’y attendre pour une three-ways comparison. Il s’agit là d’une des forces de la proposition, que nous allons voir tout de suite.

Catégorie de comparaison

Le type de retour encode des informations sur la relation de comparaison de votre type. En effet, lors qu’on parle de relation d’ordre, de relation d’égalité ou d’équivalence, tout n’a pas forcément de sens en fonction des types considérés.

C++20 offre cinq types permettant chacun de représenter des relations différentes que nous allons détailler ensemble :

  • std::strong_ordering : relation d’ordre strict.
  • std::weak_ordering : relation d’ordre faible.
  • std::partial_ordering : relation d’ordre partielle.
  • std::strong_equality : relation d’égalité.
  • std::weak_equality : relation d’équivalence.

std::strong_ordering est la relation la plus forte et correspond à une relation d’ordre stricte. C’est le cas des entiers typiquement. Elle implique aussi l’égalité stricte, c’est-à-dire que si a == b, alors f(a) == f(b).

std::strong_equality implique l’égalité stricte mais sans relation d’ordre. Dans ce cas, seuls les opérateurs == et != sont disponibles.

std::weak_ordering correspond à une relation d’ordre pour laquelle l’égalité est remplacée par l’équivalence. Un exemple classique est la comparaison de chaînes de caractère sans respect de la casse (“abc” est équivalent à “ABC”) ou encore la comparaison de rectangles :

std::weak_equality correspond à une relation d’équivalence sans notion d’ordre.

std::partial_ordering représente une relation d’ordre incluant des éléments qui ne peuvent pas être ordonnés par rapport aux autres. C’est par exemple le cas des réels ( float, double), pour lesquels les valeurs telles que NaN ne sont pas ordonnées par rapport aux autres flottants :

Il existe des conversions automatiques d’un type à l’autre. std::strong_ordering est implicitement convertible en std::weak_ordering par exemple, puisque l’égalité implique l’équivalence. Le schéma ci-dessous représente la hiérarchie de ces classes de relation et donc les conversions implicites autorisées.

Hiérarchie des relations
Hiérarchie des relations

Les conversions suivent la table ci-dessous :

Valeurs pour chaque catégorie

Notez que lorsqu’on passe d’une relation ordonnée à une relation d’égalité/d’équivalence, la conversion calcule la valeur absolue ( std::weak_ordering::less devient std::weak_equality::nonequivalent par exemple).

L’avantage de ce modèle, c’est que le compilateur pourra détecter des erreurs concernant les relations tout en étant capable d’inférer lui même le type de retour d’une three-way comparison.

Génération automatique

Des opérateurs two-way

Une autre force de cette nouveauté, et ça a été le moteur de son ajout au langage, est la génération automatique des opérateurs de comparaison.

En fait, parler de génération automatique est un peu erroné puisque le compilateur effectue plutôt un travail de réécriture :

Dans cet exemple, puisque le compilateur ne trouve pas l’opérateur < défini pour MyType, il va s’appuyer sur l’opérateur <=> pour transformer la condition en if(a <=> b < 0). D’une manière générale, a @ b (avec @ = {==, !=, <, >, <=, >=}) sera réécrit en a <=> b @ 0.

Pour être exact, le compilateur va examiner l’existence des surcharges a @ b, a <=> b et b <=> a. et sélectionner la surcharge qui convient le mieux en utilisant les règles de résolution de surcharge classique. Si b <=> a est sélectionné, la comparaison sera réécrite en 0 @ b <=> a.

Outre le fait que cela implique que la totalité des comparaisons est accessible uniquement en définissant l’opérateur <=>, il est possible que cela revienne à un gain de performances puisque les opérateurs de comparaison sont souvent implémentés à partir de < et ==.

Notez qu’ici, la fonction bool operator < (const MyType&, const MyType&) n’existe pas (ni même la version membre bool MyType::operator < (const MyType&)). Si vous souhaitez absolument que les fonctions soient générées (par exemple pour en prendre l’adresse), C++20 propose une syntaxe à l’aide de = default :

Dans le second cas, l’opérateur < est supprimé. En effet, std::strong_equality ne permet pas la comparaison d’ordre et l’opérateur < est donc incompatible avec la déclaration de l’opérateur <=>, il est donc supprimé par le compilateur. Il peut être un peu déroutant de voir une fonction être deleted avec un = default, on aurait pu s’attendre à avoir un warning ici. Ce qu’il faut comprendre, c’est que le comportement par défaut de l’opérateur < sur un type ne proposant pas de relation d’ordre est de ne pas exister :).

Génération par défaut de <=>

C++20 permet aussi de générer automatiquement <=> à l’aide de la syntaxe = default :

La version auto-générée de <=> effectue successivement la comparaison des bases et des membres (non statiques). Les tableaux sont comparés en ordre croissant. Dès que deux termes ne sont pas égaux (ou pas équivalents, en fonction du type), le résultat de cette comparaison est retourné et la fonction s’arrête. Pour la classe A, la comparaison auto-générée donnerait :

Comme vous pouvez le constatez, le type de retour est déduit par le compilateur en choisissant la relation la plus faible parmi tous les membres et les bases comparés. Dans notre cas, il s’agit de std::partial_ordering imposé par le type float (sans ce membre, std::strong_ordering aurait été sélectionné).

En pratique, le type de retour est std::common_comparison_category_t<Ts...>, avec Ts  correspondant à tous les éléments comparés (bases et membres non statiques).

Remarquez que nous avons obtenu automatiquement la totalité des opérateurs de comparaison, avec en plus une information sur la relation encodée dans le type de retour, à partir d’une seule ligne de code :).

Divers

Si ça vous intéresse, Fluent Cpp a fait un article proposant une émulation du spaceship operator à l’aide du CRTP et de std::tie.

Notez par ailleurs, que si le compilateur peut réécrire les opérateurs de comparaison classiques en utilisant <=>, il n’effectueras pas l’opération inverse. C’est-à-dire que si vous souhaitez utiliser <=>  sur un type ne le définissant pas, le compilateur ne va pas le générer pour vous à partir des comparaisons classiques. Dans ce cas, utilisez std::compare_3way qui se charge de comparer à l’aide des comparaison 2way si <=> n’est pas défini.

L’opérateur <=> a une priorité supérieure aux autres opérateurs de comparaison et a une classe de priorité bien à lui. En effet, il était important de s’assurer que 0 @ b <=> a et a <=> b @ 0 aient le même comportement.

Pour le moment Clang, GCC et MSVC ne supportent pas encore le spaceship operator, les codes que je vous donne n’ont donc pas pu être testés et suivent simplement ce qui est décrit dans la proposition et sur cppreference. Je ferais peut-être un nouvel article à ce sujet lorsqu’une implémentation sera disponible.

On peut aussi se poser la question du support de <=> au sein la bibliothèque standard. Si la proposition parle des types primitifs et de std::string, ce qui est déjà un bon début, il n’y a à priori pas d’autres surcharge prévue pour le moment. Possiblement, des types tels que pair ou tuple par exemple pourront avoir une surcharge de l’opérateur <=>.

Voilà, c’est tout pour aujourd’hui. Merci de votre lecture et à bientôt 🙂

Références et ressources

%d bloggers like this: