Déduire vos intentions

Andrzej Krzemieński

Dans ce tutoriel, nous allons décrire brièvement ce qu'est la déduction d'argument de modèle de classe et pourquoi cela fonctionne différemment de ce à quoi les gens s'attendent.

Commentez

- Titre : Déduire vos intentions

- Auteur : Andrzej Krzemieński

- Parution : 9 novembre 2021

- Public visé :

- Licence : Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright ® 2019 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. Déduction d'argument de modèle de classe

I - Déduction d'argument de modèle de classe

La fonctionnalité de langage en C++17, connue sous le nom de déduction d'argument de modèle de classe, était destinée à remplacer les fonctions de fabrique comme make_pair, make_tuple, make_optional, comme décrit ici : p0091r2. Cet objectif n'a pas été pleinement atteint et nous aurons peut-être encore besoin de nous en tenir aux fonctions make. Dans ce tutoriel, nous allons décrire brièvement ce qu'est la déduction d'argument de modèle de classe et pourquoi cela fonctionne différemment de ce à quoi les gens s'attendent.

Le problème original était que les gens devaient écrire ce genre de code :

f(std::pair<int, std::vector::iterator>(0, v.begin()));

Être obligé de spécifier le type de 0 et v.begin n'est pas pratique, alors la librairie est apparue avec la fonction de commodité :

f(std::make_pair(0, v.begin()));

Cela fonctionne, car bien que les paramètres modèles des modèles de classes (antérieurs à C++17) ne puissent pas être déduits à partir des arguments du constructeur, ils peuvent être déduits pour les modèles de fonction à partir des arguments de la fonction. C'est une amélioration, bien que parfois vous puissiez vouloir utiliser l'ancienne construction lorsque vous voulez ajuster les types des arguments :

f(std::pair<short, std::string>(0, "literal"));

L'amélioration suivante, en C++17, consistait à autoriser la déduction des paramètres de modèles de classe à partir des arguments passés dans la construction, de sorte que la solution de contournement avec make_pair ne soit plus nécessaire :

1. // C++17

2. f(std::pair(0, v.begin()));

Cela fonctionne pour make_pair et la plupart du temps cela fonctionne aussi pour d'autres fabriques, comme make_tuple, sauf quand ce n'est pas le cas.

Prenons le cas suivant :

1. // o est de type optional<int>

2. auto o = std::make_optional(int{});

4. // p est de type optional<Threshold>

5. auto p = std::make_optional(Threshold{});

7. // q est de type optional<optional<int>>

8. auto q = std::make_optional(std::optional<int>{});

La fonction make_optional encapsule simplement l'argument dans un std::optional. Cela est évident pour les deux premiers cas avec o et p. Concernant la troisième option, nous pouvons avoir des doutes, nous attendre à ce que les optional imbriqués se fondent en un seul, mais ce ne serait pas une bonne idée : l'imbrication peut être volontaire. Par exemple optional<int> pourrait modéliser un « seuil » où l'absence de valeur signifierait que le seuil est l'infini ; alors que <optional<int>> pourrait vouloir dire un « seuil qui ne peut pas être connu ». En fait, le seuil dans le deuxième exemple pourrait être un alias pour optional<int>.

Prenons un cas général dans un modèle :

1. template <typename T>

2. void f(T v)

3. {

4. auto o = std::make_optional(v);

5. static_assert(std::is_same_v<decltype(o), std::optional<T>>);

6. }

Nous voulons que l'assertion soit vérifiée, quel que soit le type avec lequel le modèle est instancié.

Essayons désormais de remplacer make_optional dans nos exemples par une déduction d'argument de modèle de classe :

1. // o est de type optional<int>

2. std::optional o (int{});

Ceci fonctionne comme prévu.

1. // q est de type optional<int> !

2. std::optional q (std::optional<int>{});

Ceci ne fonctionne pas comme make_optional ! Cela est dû au fait que la logique de déduction des arguments du modèle de classe essaie de deviner votre intention de manière différente dans différents contextes, au lieu de travailler uniformément. Cette logique suppose qu'à la ligne 2 ci-dessous, notre intention était plus vraisemblablement de faire une copie :

1. std::optional o (1); // intention: wrap

2. std::optional q (o); // intention: copy

Dans certains cas, cette hypothèse fonctionne, mais cela ne fonctionne pas lorsque notre intention est toujours d’encapsuler un make_optional. Voyons maintenant, ce cas :

1. // p peut être de type optional<Threshold>

2. // p peut être de type Threshold

3. std::optional p (Threshold{});

Nous ne savons pas si p est de type optional<Threshold> ou de type Threshold, car nous ne savons pas si le type Threshold est une instance de std::optional ou pas. Cela signifie qu'au sein d'un modèle, lorsque nous voulons être sûrs de toujours encapsuler T dans un optional<T>, nous devons utiliser un make_optional et nous ne pouvons pas compter sur la déduction d'argument de modèle de classes. C'est l'un des cas où le compilateur peut déduire autre chose que ce à quoi vous vous attendiez. Nous avons une situation similaire avec make_tuple :

1. std::tuple t (1);           // tuple<int>

2. std::tuple u (t);           // tuple<int>

3. std::tuple v (Threshold{}); // ???

Ce problème survient, car nous avons deux attentes contradictoires d'une déduction comme celle-ci. L'une est qu'elle devrait encapsuler, l'autre est qu'elle devrait produire exactement le même type.

1. std::optional o (1); // intention: wrap

2. std::optional q (o); // intention: copy

Ces attentes sont contradictoires dans les cas comme la ligne 2 ci-dessus.

std::optional a un deuxième problème similaire. Prenons ce cas :

1. optional<int> o = 1;

2. assert (o != nullopt);

Cela fonctionne comme prévu, car optional<U> peut être converti en optional<T>

optional<int> a = 1;
optional<long> b = a;
assert (b != nullopt);

chaque fois que U est convertissable en T, un optional<U> sans valeur est converti en optional<T> sans valeur. C'est intuitif, mais prenons ce cas :

1. optional<int> a {};

2. optional<optional<int>> b = a;

4. assert (b == nullopt); // correct?

L'initialisation à la ligne 2 doit-elle être traitée comme une conversion de T en optional<T> (dans ce cas l'assertion échouera) ? Ou doit-elle être traitée comme une conversion de U en optional<T> (dans ce cas l'assertion passera) ? Le compilateur va tenter de deviner nos intentions, mais il va probablement mal deviner. Notez que ce problème sera plus difficile à résoudre si le code ressemble à ceci :

1. Threshold a {};

2. optional<Threshold> b = a;

4. assert (b == nullopt); // correct?

ou à ceci :

1. template <typename T>

2. void f(T v)

3. {

4. optional<T> o = v;

5. assert (o == nullopt); // correct?

6. }

Pour cette raison, dans un contexte générique (et aussi dans un contexte non générique, lorsque vous ne pouvez pas être sûr des propriétés du type), pour éviter les bogues résultant de l’ambiguïté décrite ci-dessus, vous ne pouvez pas compter sur la logique « intelligente » dans les constructeurs de optional. Vous feriez mieux de déclarer directement vos intentions :

1. template <typename T>

2. void f(T v)

3. {

4. std::optional<T> o {std::in_place, v};

5. assert (o != nullopt); // correct

6. }

Pour résumer : les déductions intelligentes fonctionnent généralement comme prévu, mais elles font parfois autre chose.