CodeGym /Cours /C++ SELF /RTTI comme dernier recours

RTTI comme dernier recours

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

1. Introduction

Quand vous avez une hiérarchie avec des méthodes virtuelles, l'esprit du débutant commence parfois à jouer au «devine» : «Et si je dois savoir quel objet se cache derrière IStorage* ?». C'est là que RTTI (Run-Time Type Information) entre en scène — un mécanisme qui permet d'obtenir à l'exécution des informations sur le type dynamique d'un objet et d'essayer de convertir de façon sûre un pointeur de base vers un pointeur sur un dérivé concret.

Il est important de mettre l'ambiance au clair : RTTI n'est ni «mauvais» ni «honteux». C'est un outil. Simplement, c'est comme un extincteur : c'est bien d'en avoir un, mais c'est étrange de le laisser allumé en permanence et de cuire des œufs dessus. Dans une architecture où vous utilisez déjà le polymorphisme, RTTI signale généralement que soit l'interface n'exprime pas la différence de comportement nécessaire, soit vous essayez d'extraire de la logique au lieu de l'exprimer via une méthode virtuelle.

De RTTI en C++ nous nous intéressons principalement à deux outils : dynamic_cast et typeid.

Type statique et type dynamique

Avant de toucher à dynamic_cast, il faut une fois pour toutes «graver» dans sa tête la différence entre type statique et type dynamique. Sinon RTTI devient de la magie, et la magie en programmation finit en général par un message «Segmentation fault» et un regard philosophique vers le mur.

Le type statique est ce qui est écrit dans le code pour la variable/paramètre : IStorage*, Shape&, Base*. Le type dynamique est le type réel de l'objet en mémoire : FileStorage, Circle, Derived. Quand vous appelez une méthode virtuelle via Base*, C++ choisit l'implémentation en fonction du type dynamique de l'objet.

Mini‑exemple «sur le bout des doigts» :


#include <iostream>

struct Base {
    virtual ~Base() = default;
    virtual void ping() const { std::cout << "Base\n"; } // Base
};

struct Derived : Base {
    void ping() const override { std::cout << "Derived\n"; } // Derived
};

int main() {
    Derived d;
    Base* p = &d;     // type statique de p: Base*, type dynamique de l'objet: Derived
    p->ping();        // Derived
}

Cette divergence «pointeur Base*, mais objet Derived» est la vie normale du polymorphisme. RTTI intervient quand on veut soudain demander : «tu es vraiment Derived?» — et agir différemment.

2. dynamic_cast : tentative sûre pour «connaître le dérivé»

Quand vous tenez un objet par un pointeur/référence de type de base et voulez essayer de le convertir vers un dérivé concret, dynamic_cast est la façon officielle et sûre de le faire. Dans le cadre de cette leçon nous considérons exactement le cas des pointeurs, car il n'y a pas besoin de discuter des exceptions et le contrat est très simple : «si ça échoue, on obtient nullptr».

Condition clé : le type de base doit être polymorphe, c'est‑à‑dire posséder au moins une fonction virtuelle. En pratique c'est presque toujours le destructeur virtuel dans une interface, que nous devons de toute façon déclarer : virtual ~I() = default.

Exemple :

#include <iostream>

struct Shape {
    virtual ~Shape() = default; // rend le type polymorphe
};

struct Circle : Shape {
    int r = 10;
};

struct Square : Shape {
    int a = 5;
};

void debug_print_shape(Shape* s) {
    if (auto* c = dynamic_cast<Circle*>(s)) {
        std::cout << "Circle r=" << c->r << "\n"; // Circle r=10
        return;
    }
    if (auto* sq = dynamic_cast<Square*>(s)) {
        std::cout << "Square a=" << sq->a << "\n"; // Square a=5
        return;
    }
    std::cout << "Unknown shape\n"; // Unknown shape
}

Ici la discipline est importante : on vérifie le résultat sur nullptr avant de le déréférencer. C'est cela la «sécurité» de dynamic_cast dans la version pointeur.

Pourquoi les chaînes de dynamic_cast sont presque toujours le symptôme d'une mauvaise interface

Imaginez que vous ayez écrit :

«Si c'est FileStorage alors faire ceci, si MemoryStorage — autre chose, si NullStorage — une troisième option…»

Le problème n'est pas que cela «ne fonctionne pas». Le problème est que vous implémentez manuellement une dispatch par type, au lieu d'exprimer la différence de comportement via une méthode virtuelle de l'interface. Cela mène à deux désagréments classiques.

D'abord, l'ajout d'un nouveau dérivé casse du code à des endroits inattendus. Vous avez ajouté CloudStorage, et découvrez ensuite qu'il faut trouver dans le projet tous les endroits avec dynamic_cast<FileStorage*> et ajouter une branche. Autrement dit votre «interface» cesse d'être une interface : le code client connaît à nouveau les classes concrètes.

Ensuite, cette logique devient facilement contradictoire : à un endroit on a oublié de traiter le nouveau type, ailleurs on l'a traité différemment, ailleurs encore on a copié la branche par erreur.

Le contraste avec le polymorphisme normal est simple : au lieu de «deviner le type» on «demande à l'objet d'effectuer l'action». Si la différence est importante pour le comportement, elle doit être exprimée dans l'interface par une méthode virtuelle.

3. typeid : quand il faut le nom du type

Parfois vous n'avez pas besoin de convertir l'objet vers un dérivé. Vous voulez juste savoir ce qui est arrivé et l'afficher dans le log. Par exemple, vous avez une factory et vous souhaitez imprimer : «j'ai créé tel storage». Là typeid est approprié.

typeid(expr) retourne une référence sur un objet std::type_info. L'usage le plus fréquent est typeid(expr).name(). Mais attention : name() n'est pas un «nom joli et stable de la classe». Le format dépend du compilateur et des options (souvent il est «manglé»). Donc cela convient pour le diagnostic développeur, mais pas comme «protocole» ou clé de logique.

Exemple révélateur :

#include <iostream>
#include <typeinfo>

struct Base { virtual ~Base() = default; };
struct Derived : Base { };

int main() {
    Derived d;
    Base& b = d;

    std::cout << typeid(b).name() << "\n"; // par exemple: 7Derived (dépend du compilateur)
}

Pourquoi il est important que Base soit polymorphe ? Parce que pour les types polymorphes typeid via référence/déréférencement tient compte du type dynamique. Sinon vous obtiendrez simplement «Base», même si à l'intérieur c'est «Derived».

Je note en aparté (sans entrer dans les exceptions) : tenter de faire typeid(*p) quand p == nullptr pour un type polymorphe est une mauvaise idée. C'est au minimum une situation d'erreur pour le programme. Donc règle simple : avant d'écrire typeid(*p), assurez-vous que p n'est pas nul.

4. Exemple : diagnostiquer quel Storage la factory a créé

Poursuivons notre mini‑projet pédagogique — application console TaskPad, où les tâches sont stockées via l'abstraction ITaskStorage. Dans les leçons précédentes nous avons déjà une factory qui crée une implémentation selon la config ou le CLI, et le code client travaille via std::unique_ptr<ITaskStorage>.

Faisons une interface simplifiée :

#include <string>
#include <string_view>
#include <vector>

struct Task {
    int id = 0;
    std::string title;
};

struct ITaskStorage {
    virtual ~ITaskStorage() = default;
    virtual void add(Task t) = 0;
    virtual std::vector<Task> list() const = 0;
};

Ensuite nous avons des implémentations. Pour la leçon l'idée est ce qui compte, donc les implémentations seront conditionnelles et courtes :

#include <utility>
#include <vector>

struct MemoryTaskStorage : ITaskStorage {
    std::vector<Task> tasks;

    void add(Task t) override { tasks.push_back(std::move(t)); }
    std::vector<Task> list() const override { return tasks; }
};

Et, disons, une implémentation sur fichier :

#include <string>
#include <utility>
#include <vector>

struct FileTaskStorage : ITaskStorage {
    std::string path;
    std::vector<Task> tasks;

    explicit FileTaskStorage(std::string p) : path(std::move(p)) {}

    void add(Task t) override { tasks.push_back(std::move(t)); }
    std::vector<Task> list() const override { return tasks; }
};

Imaginons maintenant que nous ayons une factory :

#include <memory>
#include <string>
#include <string_view>

enum class StorageKind { memory, file };

std::unique_ptr<ITaskStorage> make_storage(StorageKind kind, std::string_view arg) {
    if (kind == StorageKind::file) {
        return std::make_unique<FileTaskStorage>(std::string(arg));
    }
    return std::make_unique<MemoryTaskStorage>();
}

Et nous voulons afficher un diagnostic : quel storage a été choisi et, si c'est un fichier, quel est le chemin. La façon la plus directe est RTTI :

#include <iostream>

void debug_print_storage(const ITaskStorage& s) {
    if (auto* fs = dynamic_cast<const FileTaskStorage*>(&s)) {
        std::cout << "Storage: FileTaskStorage, path=" << fs->path << "\n";
        return; // Storage: FileTaskStorage, path=tasks.txt
    }
    std::cout << "Storage: (non-file)\n"; // Storage: (non-file)
}

Pourquoi cela est acceptable ? Parce que c'est une fonction purement diagnostique. Elle ne prend pas de décisions, ne change pas l'algorithme ni les règles métier. Elle aide simplement le développeur à voir «ce que la factory a créé». C'est un bon exemple d'un RTTI «rare et local».

5. Comment faire mieux : méthode virtuelle pour le diagnostic

Faisons maintenant un pas vers plus de maturité. Nous comprenons que nous voulons parfois afficher des informations sur l'implémentation — sans pour autant que tout le projet sache ce qu'est FileTaskStorage. Il est donc plus logique de demander à l'objet de se décrire lui‑même via l'interface.

Ajoutons dans l'interface la méthode debug_describe(...). Important : nous précisons d'emblée que c'est un outil de diagnostic, pas une partie de la logique métier. Le contrat : la méthode ne doit pas modifier l'état (const) et doit produire une chaîne lisible par un humain.

#include <ostream>

struct ITaskStorage {
    virtual ~ITaskStorage() = default;

    virtual void add(Task t) = 0;
    virtual std::vector<Task> list() const = 0;

    virtual void debug_describe(std::ostream& out) const = 0;
};

Implémentation dans MemoryTaskStorage :

#include <ostream>

struct MemoryTaskStorage : ITaskStorage {
    std::vector<Task> tasks;

    void add(Task t) override { tasks.push_back(std::move(t)); }
    std::vector<Task> list() const override { return tasks; }

    void debug_describe(std::ostream& out) const override {
        out << "MemoryTaskStorage, tasks=" << tasks.size() << "\n";
        // MemoryTaskStorage, tasks=0
    }
};

Et dans FileTaskStorage :

#include <ostream>

struct FileTaskStorage : ITaskStorage {
    std::string path;
    std::vector<Task> tasks;

    explicit FileTaskStorage(std::string p) : path(std::move(p)) {}

    void add(Task t) override { tasks.push_back(std::move(t)); }
    std::vector<Task> list() const override { return tasks; }

    void debug_describe(std::ostream& out) const override {
        out << "FileTaskStorage, path=" << path << ", tasks=" << tasks.size() << "\n";
        // FileTaskStorage, path=tasks.txt, tasks=0
    }
};

Maintenant le code client peut faire :

#include <iostream>
#include <memory>

int main() {
    auto storage = make_storage(StorageKind::file, "tasks.txt");
    storage->debug_describe(std::cout); // FileTaskStorage, path=tasks.txt, tasks=0
}

Remarquez qu'ici nous n'avons plus besoin de RTTI. Nous utilisons à nouveau le polymorphisme «comme prévu» : le comportement diffère donc il est exprimé par une méthode virtuelle.

6. Quand RTTI est approprié et comment choisir l'outil

Il est maintenant important de ne pas tomber dans l'extrême opposé : «RTTI est strictement interdit». Non, on peut l'utiliser. Mais mieux vaut que ce soit local, conscient et motivé.

RTTI convient dans le code d'infrastructure : logging, commandes de débogage, dumps diagnostiques, statistiques, télémétrie dans des builds de dev. Là vous ne voulez souvent pas étendre l'interface «pour un seul log», car une interface est un contrat que tous les héritiers doivent maintenant implémenter.

RTTI peut aussi faire sens comme pont lors d'intégration avec du legacy. Par exemple, vous avez une API externe qui prend précisément ConcreteX*, alors qu'en interne vous manipulez I*. Dans ce cas dynamic_cast devient une «vérification de compatibilité» : si l'objet n'est pas du bon type vous refusez l'opération ou utilisez un fallback.

Un troisième cas rare : tests ou outils développeur. Par exemple vous voulez vérifier que la factory, selon une config, crée bien FileTaskStorage. En code produit cette connaissance n'est pas nécessaire, mais dans un test ou un diagnostic elle est utile.

dynamic_cast et typeid : mini‑tableau

Outil Que fait‑il Résultat typique Quand approprié
dynamic_cast<Derived*>(basePtr)
Tente de convertir vers le dérivé de façon sûre
Derived* ou nullptr
Quand il faut essayer d'utiliser l'API spécifique du dérivé localement (généralement diagnostic ou intégration)
typeid(expr)
Récupère des infos sur le type de l'expression à l'exécution
std::type_info (on prend souvent .name())
Pour les logs et diagnostics «qu'est‑ce que c'est que cet objet»

Et encore un mini‑schéma montrant l'usage typique dans un projet :

flowchart TD
    A[ITaskStorage& présent] --> B{Besoin de comportement?}
    B -->|Oui| C[Ajouter ou utiliser une méthode virtuelle dans l'interface]
    B -->|Non, seulement diagnostic| D{Accès aux champs du dérivé nécessaire?}
    D -->|Oui| E[dynamic_cast + vérification nullptr]
    D -->|Non, juste le nom| F["typeid(...) pour le log"]

Oui, c'est un «schéma de bon sens», pas une preuve mathématique. Mais dans du vrai code le bon sens est une technologie très compétitive.

7. Erreurs typiques lors de l'utilisation de RTTI

Erreur n°1 : utiliser RTTI comme mécanisme principal de branchement logique.
Il est très simple d'écrire «si c'est A — on fait ça, si B — autrement…», et au début on pense avoir bien fait : tout sous contrôle. Mais en réalité vous annulez le polymorphisme et forcez le code client à connaître toutes les implémentations. Après quelques itérations cela devient douloureux lors de l'ajout de nouveaux types. Si la différence affecte le comportement, elle doit être exprimée par une méthode virtuelle dans l'interface.

Erreur n°2 : oublier que dynamic_cast exige une base polymorphe.
dynamic_cast s'appuie sur les métadonnées RTTI qui n'apparaissent que pour les types polymorphes. Si la classe de base n'a pas de fonctions virtuelles, la conversion ne compile pas ou n'a pas de sens. Bonne pratique : une interface a toujours virtual ~I() = default, ce qui rend automatiquement la base polymorphe.

Erreur n°3 : déréférencer le résultat de dynamic_cast sans vérifier nullptr.
La version pointeur est pratique car en cas d'échec elle retourne nullptr. Mais si après dynamic_cast vous faites directement p->field, vous obtenez l'accident classique. Règle simple : d'abord if (auto* d = dynamic_cast<...>(p)), puis utilisez.

Erreur n°4 : utiliser typeid(...).name() comme identifiant stable.
Les débutants comparent parfois des chaînes : if (typeid(x).name() == "FileTaskStorage"). C'est extrêmement peu fiable : le format dépend du compilateur et peut être manglé. name() convient pour l'affichage humain mais pas comme élément logique du programme.

Erreur n°5 : étendre l'usage de RTTI dans tout le projet au lieu de le garder dans une petite fonction diagnostique.
Le scénario le plus sain pour RTTI est une petite fonction en un seul endroit (par exemple debug_print_storage) qui n'influence pas le fonctionnement principal de l'application. Si dynamic_cast commence à apparaître dans des fonctions critiques (parsing, exécution de commandes, calculs), c'est presque toujours le signe que l'interface n'exprime pas le comportement requis ou que les responsabilités sont mal réparties.

1
Mission
C++ SELF, niveau 52, leçon 4
Bloqué
Débogage des formes
Débogage des formes
1
Mission
C++ SELF, niveau 52, leçon 4
Bloqué
Étiquettes de messages
Étiquettes de messages
1
Mission
C++ SELF, niveau 52, leçon 4
Bloqué
Chemin du stockage
Chemin du stockage
1
Mission
C++ SELF, niveau 52, leçon 4
Bloqué
Description des formes
Description des formes
1
Étude/Quiz
Les interfaces comme contrat, niveau 52, leçon 4
Indisponible
Les interfaces comme contrat
Les interfaces comme contrat
Commentaires
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION