User Defined Literals en C++

Pierre Gradot - Oct 31 '20 - - Dev Community

Les user defined literals ne sont pas une fonctionnalité récente, ils sont disponibles depuis C++11. Pourtant, j'ai constaté récemment qu'ils n'étaient clairement pas connus de tous. C'est l'occasion de faire un article pour être certain que vous aussi, vous connaissiez leur existence !

Point de départ : un type pour stocker une distance

Pour illustrer l'utilité des user defined literals, prenons comme fil rouge un type permettant de stocker une distance :

class Distance {
public:
    explicit Distance(double meters) :
            meters_m(meters) {}

    friend std::ostream& operator<<(std::ostream& os, const Distance& distance) {
        os << distance.meters_m << " m";
        return os;
    }

private:
    double meters_m = 0;
};
Enter fullscreen mode Exit fullscreen mode

Cette classe est volontairement basique mais elle est suffisante pour cet article. Voici un exemple de création et d'utilisation :

Distance a{11.26};
std::cout << a << '\n';
Enter fullscreen mode Exit fullscreen mode

Très bien, donc ?

Oui et non... Oui, parce qu'elle fait ce qu'on lui demande. Non, parce que son utilisation n'est pas forcément simple. Si vous avez une distance en mètres, c'est facile d'appeler le constructeur puisque c'est ce qu'il attend. Mais si vous avez des distances dans d'autres unités, les valeurs à passer au constructeur peuvent être un peu compliquées à lire :

auto marathon = Distance(42.195 * 1000);
auto micrometer = Distance(1e-6);
auto feet = Distance(0.3048);
Enter fullscreen mode Exit fullscreen mode

Comment pourrait-on créer des Distances à partir de valeurs dans une autre unité que le mètre ?

Il n'est pas possible d'avoir plusieurs constructeurs car on ne pourrait pas faire d'overloading avec un seul paramètre. Pour rappel, le nom du paramètre n'a aucun effet, seul son type importe. Il est donc impossible d'avoir plusieurs constructeurs prenant chacun en paramètre un double mais dans des unités différentes.

On pourrait imaginer des techniques plus ou moins pratiques :

  • un enum à passer en deuxième paramètre au constructeur pour préciser l'unité de la valeur,
  • des fonctions statiques type "builder" comme Distance::fromFoot() ou Distance::fromKilometers().

Mais je vous le donne en mille, il y a une autre solution en C++ : les user defined literals.

C'est quoi en fait un user defined literal ?

La définition d'un user defined literal est sur cppreference :

Allows integer, floating-point, character, and string literals to produce objects of user-defined type by defining a user-defined suffix.

C'est donc une possibilité d'écrire quelque chose comme 42_km, 1_um, 0.3048_ft. Ca a l'air cool, hein ?

Comment créer des user defined literals pour un type ?

Il suffit d'implémenter des operator""() pour supporter les suffixes de votre choix.

Voici par exemple les opérateurs à implementer pour supporter plusieurs suffixes, et ainsi créer des Distances à partir de valeurs dans différentes unités :

Distance operator ""_km(long double value) {
    return Distance(value * 1e3);
}

Distance operator ""_m(long double value) {
    return Distance(value);
}

Distance operator ""_um(long double value) {
    return Distance(value * 1e-6);
}

Distance operator ""_ft(long double value) {
    return Distance(value * 0.3048);
}
Enter fullscreen mode Exit fullscreen mode

Ces opérateurs ne peuvent pas être définis à l'intérieur de la classe, ils doivent être libres. Vous pouvez essayer mais votre compilateur devrait vous rappeler à l'ordre, comme par exemple avec gcc :

error: 'Distance Distance::operator""_km(long double)' must be a non-member function

Voici un exemple d'utilisation :

std::cout << 42.195_km;
std::cout << 1.1_ft;
std::cout << 1.0_um;
Enter fullscreen mode Exit fullscreen mode

Euh ? On ne peut pas simplement écrire 1_um ? Avec les opérateurs présentés ci-dessus : non. L'erreur émise par gcc est la suivante (et elle n'est pas super claire de prime abord) :

error: unable to find numeric literal operator 'operator""_um'

Pour comprendre pourquoi, je vais d'abord expliquer pourquoi j'ai mis des long doubles en paramètre de mes opérateurs, alors que des doubles semblent suffisants puisque le constructeur de la classe Distance attend un double paramètre. La raison est simple : les types de paramètres possibles pour ces opérateurs ne sont pas libres. La liste des possibles est fournie sur cppreference dans la section Literal operators :

( const char * )
( unsigned long long int )
( long double )
( char )
( wchar_t )
( char8_t ) (since C++20)
( char16_t )
( char32_t )
( const char * , std::size_t )
( const wchar_t * , std::size_t )
( const char8_t * , std::size_t ) (since C++20)
( const char16_t * , std::size_t )
( const char32_t * , std::size_t )

Le type le plus proche de double est bien long double.

Le type du literal avant le suffixe est utilisé par le compilateur pour trouver le bon opérateur. Ainsi, 1.0_um va trouver ma surcharge avec long double mais 1_um ne trouvera pas la surcharge prenant en paramètre unsigned long long int et donc ne compilera pas. Il suffit de l'implémenter pour que le code fonctionne.

Peut-on avoir des user defined literals négatifs ?

Stricto sensu, on ne peut pas avoir de user defined literals négatifs, mais il y a en fait une astuce pour y arriver. Il suffit d'essayer pour que le compilateur nous donne la technique. Par exemple std::cout << -12.0_m << '\n'; génère l'erreur error: no match for 'operator-' (operand type is 'Distance'). Il suffit d'ajouter un tel opérateur à la classe :

Distance operator-() {
    return Distance(-meters_m);
}
Enter fullscreen mode Exit fullscreen mode

Le tour est joué : std::cout << -12.0_m << '\n'; compile désormais et affiche bien -12000 m.

Exemple plus avancé : la bibliothèque Units

Si vous voulez vraiment gérer des distances (ou d'autres dimensions physiques avec plein d'unités), inutile de coder ça vous-même. Je vous propose d'aller faire un tour sur Github et de trouver une bonne bibliothèque. J'ai notamment testé et bien aimé UNITS, créée par Nic Holthaus. Je vous parle de celle-ci en particulier car elle fait une utilisation massive de user defined literals. Ecrite en C++14, cette bibliothèque est single header et est donc facile à intégrer à votre projet. Voici un exemple d'utilisation :

#include "units.h"

int main() {
    using namespace units::area;
    using namespace units::length;
    using namespace units::volume;
    using namespace units::literals;

    meter_t width = 15_m;
    std::cout << width << '\n';
    meter_t height = 2.50_ft;
    std::cout << height << '\n';

    square_meter_t area = width * height;
    std::cout << area << '\n';

    cubic_meter_t volume = area * 1_m;
    std::cout << volume << '\n';
    std::cout << 2 * volume << '\n';
}
Enter fullscreen mode Exit fullscreen mode

A chaque étape, il y a des vérifications de types qui empêchent d'écrire du code avec des erreurs de dimensions, tel que square_meter_t width = 1_m;, qui provoque une erreur de compilation.

Conclusion

Les user defined literals permettent de créer des objets à partir d'un literal (integer, floating-point, character ou string) et de user-defined suffixes. On peut ainsi écrire des constantes telles ques 42.195_km, 3.3_Volts ou encore "192.168.0.1"_IPV4, très probablement pour obtenir des objets de type Distance, Voltage et IPAddress.

Pour créer un objet de type Foo à partir d'un literal de type literal_type et du suffixe _suffix, il faut implémenter un literal operator de la forme Foo operator ""_suffix(literal_type).

Cette fonctionnalité semble particulière au C++. Il y a visiblement des propositions pour intégrer des features semblables dans d'autres langages, tels Rust, C# ou encore Python, mais elles ne semblent pas avoir abouties.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Terabox Video Player