CodeGym /Cours /C++ SELF /explicit — protection contre les conversions implicites d...

explicit — protection contre les conversions implicites dans l'API

C++ SELF
Niveau 48, Leçon 4
Disponible

1. Conversions implicites via le constructeur

Quand on commence à écrire des classes, on souhaite souvent que tout soit pratique : on passe un int — on obtient un objet, on passe une chaîne — on obtient aussi un objet. Le compilateur en C++ peut effectivement agir ainsi : si une classe a un constructeur appelable avec un seul argument, il sait créer implicitement un objet temporaire quand les types "conviennent". Au début, cela semble magique, puis astucieux, et ensuite — source d'erreurs étranges, difficiles à expliquer à soi-même à 2h30 du matin.

Voyons le problème sur un exemple simple. Imaginons que dans notre application console «TaskTracker» (suivi des tâches) nous décidions d'introduire un type distinct pour l'identifiant d'une tâche — afin de ne pas le confondre avec d'autres nombres.

#include <iostream>

class TaskId {
    int value_;
public:
    TaskId(int v) : value_(v) {}
    int value() const { return value_; }
};

void print_task(TaskId id) {
    std::cout << "Task id = " << id.value() << '\n'; // Task id = ...
}

int main() {
    print_task(42); // OUPS: le compilateur créera TaskId{42}
}

Le code se compilera. Et ici se pose la question principale : voulions-nous que int se transforme "tout seul" en TaskId ? Parfois oui. Mais très souvent non, parce que “42” dans le code sans contexte ne vous dit rien : est-ce l'id d'une tâche ? la priorité ? un nombre de minutes ? une version de format ? une remise en pourcentage ? (oups, ça sort du cadre du cours).

Les conversions implicites sont utiles lorsque les types sont réellement "interchangeables" au sens métier. Mais si un type est une "enveloppe sémantique" (identifiant, unité, marqueur d'état), l'implicite fait plus de mal que de bien.

Ce que fait explicit — et ce qu'il ne fait pas

Le mot explicit dans la déclaration d'un constructeur est une façon de dire au compilateur : «S'il vous plaît, ne construisez pas l'objet pour moi en silence. Si le programmeur veut créer ce type — qu'il le fasse explicitement». Il s'agit de lisibilité, de protection de l'API et de faire en sorte que les erreurs de sens soient attrapées par le compilateur, et non par l'utilisateur (c'est‑à‑dire vous) en production.

Il est important de comprendre la limite : explicit n'interdit pas de créer l'objet en soi, il interdit la création implicite.

Corrigeons notre TaskId :

#include <iostream>

class TaskId {
    int value_;
public:
    explicit TaskId(int v) : value_(v) {}
    int value() const { return value_; }
};

void print_task(TaskId id) {
    std::cout << "Task id = " << id.value() << '\n'; // Task id = 42
}

int main() {
    print_task(TaskId{42});  // ok: le sens est montré explicitement
    // print_task(42);       // erreur de compilation: conversion implicite interdite
}

Du point de vue du lecteur, le code est meilleur : à l'appel on voit que l'on passe un identifiant de tâche, et non un nombre quelconque. Oui, c'est un peu plus verbeux. Mais c'est un des rares cas où ces caractères supplémentaires vous épargnent des heures de débogage et des "pourquoi ça a été appelé comme ça ?!".

Une remarque importante : la norme C++ contient des règles fines sur l'endroit où explicit est pris en compte, et les différences entre initialisation "directe" et "par copie" ne sont pas une fantaisie pédagogique, mais font partie du modèle formel du langage. Même dans la liste des défauts/clarifications de la norme, les constructeurs et explicit dans le contexte de direct-initialization ont été discutés séparément.

Trois formes d'initialisation et explicit

Quand vous voyez explicit, la question suivante arrive vite : "D'accord, implicitement c'est interdit. Et explicitement — comment fait-on ?" Ici on touche aux formes d'initialisation. En C++ la même intention ("créer un objet") peut s'exprimer de différentes manières, et explicit les affecte différemment. Il est donc utile de tout clarifier une fois pour toutes, afin de ne pas deviner d'après des messages du compilateur comme on lirait les feuilles de café.

Prenons notre TaskId avec explicit et regardons les formes :

class TaskId {
    int value_;
public:
    explicit TaskId(int v) : value_(v) {}
};

Comparons maintenant les différentes options :

Notation Appellation (simplifiée) Fonctionne avec explicit ? Idée
TaskId a = 5;
copy-initialization (initialisation par copie) non c'est comme "assigne, si tu peux convertir"
TaskId b(5);
direct-initialization (initialisation directe) oui "appelle le constructeur directement"
TaskId c{5};
list-initialization (initialisation par liste) oui "appelle le constructeur via {}"
f(5) où f(TaskId) conversion implicite d'argument non le compilateur essaierait de "construire" l'objet

Règle pratique pour notre niveau : si le constructeur est explicit, alors utilisez Type{...} ou Type(...), et n'utilisez pas Type x = ... pour ces types-enveloppes. Pour les "types forts" (TaskId, Minutes, Percent) l'utilisation de {} est souvent la plus claire et la plus sûre.

D'ailleurs, la bibliothèque standard C++ ajuste parfois l'utilisation de explicit lorsqu'il apparaît que l'implicite cause trop de surprises. Par exemple, il a été discuté que certains constructeurs de string_view à partir de plages devraient être explicit, afin d'éviter des conversions accidentelles là où le programmeur ne s'y attend pas.

Paramètres par défaut : le "un argument" caché

Quand vous commencez à surcharger les constructeurs, la tentation apparaît de créer un constructeur "universel" avec des paramètres par défaut : pouvoir l'appeler avec un ou deux arguments. C'est logique, mais il y a un piège : si le constructeur est appelable avec un seul argument — il redevient candidat aux conversions implicites.

Considérons un exemple de Range :

class Range {
    int from_;
    int to_;
public:
    Range(int from, int to = 0) : from_(from), to_(to) {}
};

Formellement, Range peut maintenant être créé comme Range{10}, et le compilateur peut considérer qu'à partir d'un int "on peut faire Range". Cela peut surprendre, surtout si Range n'est pas juste "deux nombres", mais un objet avec une sémantique spécifique.

La solution : si vous autorisez l'appel à un argument mais ne voulez pas d'implicite — marquez le constructeur explicit :

class Range {
    int from_;
    int to_;
public:
    explicit Range(int from, int to = 0) : from_(from), to_(to) {}
};

void process(Range r);

int main() {
    process(Range{10}); // ok
    // process(10);     // erreur: conversion implicite interdite
}

L'idée est la même : que l'appel soit honnête. Si vous passez vraiment une plage — que cela soit écrit.

2. explicit dans les types forts de TaskTracker

Faisons maintenant un pas des exemples abstraits vers notre domaine métier. Pour rappel : nous assemblons progressivement l'application console TaskTracker, où nous avons des tâches, avec identifiant, titre, éventuellement priorité et rappels. Nous avons l'habitude d'exprimer le sens par les types, et pas seulement par des commentaires.

TaskId : ne pas confondre un id avec autre chose

Si nous avons une fonction qui recherche une tâche par id, il est facile de passer par erreur quelque chose de non approprié. Par exemple, l'utilisateur a saisi "le numéro du menu", et vous l'avez passé comme id de tâche. Avec un int le compilateur ne vous aidera pas, parce que "ça aussi c'est int".

Rendons TaskId explicite :

#include <string>

class TaskId {
    int value_;
public:
    explicit TaskId(int v) : value_(v) {}
    int value() const { return value_; }
};

class Task {
    TaskId id_;
    std::string title_;
public:
    Task(TaskId id, std::string title) : id_(id), title_(std::move(title)) {}
};

Désormais, créer un Task n'est possible que si vous indiquez explicitement "c'est un id" :

Task t{TaskId{1}, "Read C++ book"};

Oui, c'est un peu plus long. Mais si par erreur vous écrivez Task{1, "..."}, le compilateur vous arrêtera avant que les données ne soient corrompues.

Unités : Minutes pour se protéger des "nombres magiques"

Avec les unités la situation est encore plus sournoise. Le nombre 10 peut signifier "10 minutes", "10 secondes", "10 jours avant la deadline" ou "10 tâches dans la liste". Nous n'allons pas implémenter un système temporel complet maintenant (c'est un sujet à part et assez vaste), mais au niveau du quotidien on peut créer une enveloppe simple.

class Minutes {
    int value_;
public:
    explicit Minutes(int v) : value_(v) {}
    int value() const { return value_; }
};

void set_reminder(Minutes m);

int main() {
    set_reminder(Minutes{15}); // ok: 15 minutes explicites
    // set_reminder(15);       // erreur: on ne peut pas accepter int "en silence"
}

L'effet psychologique de ce code est excellent : vous commencez à écrire pour que le sens soit présent dans la ligne de code, et pas seulement "dans votre tête".

3. Quand mettre explicit : heuristique pratique

Après quelques exemples l'envie naturelle apparaît : "Bon, alors mettons explicit partout, et ce sera sûr !" C'est compréhensible, mais si vous abusez, le code devient pénible : chaque création d'objet devient un rituel et vous en viendrez à détester votre propre API (ce qui est déjà une vraie erreur architecturale).

Il est presque toujours pertinent de mettre explicit quand votre type est une enveloppe sémantique autour d'un autre type : TaskId, UserId, Minutes, Percent, PortNumber, FileDescriptor. Dans ces types l'important n'est pas "comment la valeur est stockée", mais "ce qu'elle signifie". Les conversions implicites effacent ce sens.

On peut envisager de ne pas mettre explicit lorsque le type est conçu comme une "représentation pratique" que l'on souhaite obtenir automatiquement. L'exemple classique de la bibliothèque standard est std::string, qui peut être construit implicitement à partir d'un littéral. Cela accroît la commodité au quotidien : écrire std::string s = "hi"; est plaisant. Mais la bibliothèque standard est prudente : là où l'implicite provoque de la confusion, on discute sérieusement de rendre explicit.

Une bonne heuristique "pour les étudiants" : si vous créez un type pour éviter les confusions (plutôt que pour ajouter des fonctionnalités), alors explicit est généralement requis. Si vous créez un type pour simplifier le travail avec des données et qu'il doit s'accepter facilement depuis d'autres représentations, alors explicit devient une question de goût et de contexte, et ne devrait pas être la valeur par défaut systématique.

4. Erreurs typiques en utilisant explicit

Erreur n°1 : penser que explicit "casse le constructeur".
Parfois des étudiants voient qu'après avoir ajouté explicit "tout a cessé de compiler", et concluent que explicit est nuisible. En réalité il ne casse que les endroits implicites où le compilateur construisait l'objet pour vous. Si vous remplacez f(5) par f(Type{5}), tout remarche — et le code devient plus clair.

Erreur n°2 : ne pas prendre en compte que les paramètres par défaut transforment le constructeur en "un argument".
Vous pouvez écrire un constructeur à deux paramètres et penser sincèrement qu'il "ne convertit pas". Mais si le second paramètre a une valeur par défaut, le constructeur peut être appelé avec un seul argument, et donc la porte des conversions implicites se rouvre. Dans ces cas explicit est souvent aussi nécessaire que pour un constructeur vraiment mono-argument.

Erreur n°3 : continuer à utiliser Type x = value; pour les types forts-enveloppes.
Après avoir commencé à créer TaskId, Minutes et types similaires, l'assignation via = devient la source d'erreurs de compilation (car c'est de la copy-initialization). Pour ces types, adoptez le style : "les enveloppes se créent via {}". Ainsi explicit fonctionne de manière prévisible et le code est cohérent dans tout le projet.

Erreur n°4 : mettre explicit partout sans critère et transformer l'API en quête.
Si vous rendez explicit même là où le type doit vraiment être créé "facilement", vous forcerez les utilisateurs de votre code à écrire en permanence des enveloppes et des usines verbeuses. Cela diminue la lisibilité tout autant que les conversions implicites. explicit est un outil de protection ciblée du sens, pas un "bouchon universel".

Erreur n°5 : ne pas expliquer le sens du type par son nom en espérant que explicit "résolve tout".
explicit empêche les conversions implicites, mais ne rend pas le type auto-documenté. Si vous nommez le type X, alors X{10} ne dira toujours rien au lecteur. Un bon nom (TaskId, Minutes) + explicit forment une équipe : le nom explique le sens, explicit force à faire apparaître ce sens dans le code.

1
Mission
C++ SELF, niveau 48, leçon 4
Bloqué
Numéro de la tâche
Numéro de la tâche
1
Mission
C++ SELF, niveau 48, leçon 4
Bloqué
Paramètres de l'intervalle
Paramètres de l'intervalle
1
Mission
C++ SELF, niveau 48, leçon 4
Bloqué
Remise en pourcentage
Remise en pourcentage
1
Mission
C++ SELF, niveau 48, leçon 4
Bloqué
Gestionnaire de commandes
Gestionnaire de commandes
1
Étude/Quiz
Classes : encapsulation et constructeurs, niveau 48, leçon 4
Indisponible
Classes : encapsulation et constructeurs
Classes : encapsulation et constructeurs
Commentaires
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION