1. Wprowadzenie
Kiedy pojawia sie u was hierarchia z metodami wirtualnymi, umysl poczatkujacego czasem zaczyna bawic sie w „zgadywanke”: „A co, jesli musze sprawdzic, jaki dokladnie obiekt kryje sie za IStorage*?”. I wlasnie wtedy na scene wchodzi RTTI (Run-Time Type Information) — mechanizm, ktory pozwala w czasie wykonania uzyskac informacje o typie dynamicznym obiektu i sprobowac bezpiecznie sprowadzic wskaznik bazowy do wskaznika na konkretnego potomka.
Wazne jest, by dobrze uchwycic klimat: RTTI to nie jest cos „zlego” ani „wstydliwego”. To narzedzie. Po prostu jest jak gasnica: swietnie, ze jest pod reka, ale dziwnie byloby trzymac ja stale wlaczona i smazyc na niej jajecznice. W architekturze, w ktorej i tak stosujecie polimorfizm, RTTI zwykle sygnalizuje jedno z dwojga: albo interfejs nie wyraza potrzebnej roznicy w zachowaniu, albo probujecie „wyniesc mozg” na zewnatrz zamiast zamknac go w metodzie wirtualnej.
Z RTTI w C++ interesuja nas dwa glowne narzedzia: dynamic_cast i typeid.
Typ statyczny i typ dynamiczny
Zanim dotkniemy dynamic_cast, trzeba raz (i na dlugo) „wgrac” sobie do glowy roznice miedzy typem statycznym a dynamicznym. Inaczej RTTI zamienia sie w magie, a magia w programowaniu zwykle konczy sie komunikatem „Segmentation fault” i filozoficznym wpatrywaniem sie w sciane.
Typ statyczny to to, co jest zapisane w kodzie przy zmiennej lub parametrze: IStorage*, Shape&, Base*. Typ dynamiczny to rzeczywisty typ obiektu w pamieci: FileStorage, Circle, Derived. Gdy wywolujecie metode wirtualna przez Base*, C++ wybiera implementacje wedlug dynamicznego typu obiektu.
Mini-przyklad „na chlopski rozum”:
#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; // typ statyczny p: Base*, typ dynamiczny obiektu: Derived
p->ping(); // Derived
}
Ta rozbieznosc „wskaznik Base*, a obiekt Derived” to normalne zycie polimorfizmu. RTTI pojawia sie wtedy, gdy nagle chcemy zapytac: „czy ty na pewno jestes Derived?” — i zrobic cos inaczej.
2. dynamic_cast: bezpieczna proba „rozpoznania potomka”
Kiedy trzymacie obiekt przez wskaznik lub referencje do typu bazowego i chcecie sprobowac sprowadzic go do konkretnego potomka, dynamic_cast jest oficjalnym sposobem, by zrobic to bezpiecznie. W ramach tego wykladu rozpatrujemy wlasnie wariant ze wskaznikami, bo nie wymaga on omawiania wyjatkow i daje bardzo prosty kontrakt: „jesli sie nie udalo — bedzie nullptr”.
Kluczowy warunek: typ bazowy musi byc polimorficzny, czyli miec co najmniej jedna funkcje wirtualna. W praktyce prawie zawsze jest to wirtualny destruktor w interfejsie, ktory i tak musimy zrobic: virtual ~I() = default.
Przyklad:
#include <iostream>
struct Shape {
virtual ~Shape() = default; // czyni typ polimorficznym
};
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
}
Tutaj wazna jest dyscyplina: sprawdzamy wynik pod katem nullptr przed dereferencja. Na tym polega „bezpieczenstwo” dynamic_cast w wariancie ze wskaznikiem.
Dlaczego lancuchy dynamic_cast prawie zawsze sa objawem zlego interfejsu
Wyobrazcie sobie, ze napisalisci tak:
„Jesli to FileStorage, rob jedno, jesli MemoryStorage — drugie, jesli NullStorage — trzecie…”
Problem nie polega na tym, ze to „nie dziala”. Problem polega na tym, ze recznie implementujecie dyspozycje wedlug typu zamiast wyrazic roznice w zachowaniu przez metode wirtualna interfejsu. To prowadzi do dwoch typowych nieprzyjemnosci.
Po pierwsze, dodanie nowego potomka psuje kod w nieoczekiwanych miejscach. Dodaliscie CloudStorage, a potem okazalo sie, ze trzeba znalezc w projekcie wszystkie miejsca z dynamic_cast<FileStorage*> i dopisac jeszcze jedna galaz. Wasz „interfejs” nagle przestal wiec byc interfejsem: kod kliencki znow zna konkretne klasy.
Po drugie, taka logika bardzo latwo staje sie niespojna: w jednym miejscu zapomniano obsluzyc nowy typ, w innym obsluzono go inaczej, a w trzecim przez pomylke skopiowano niewlasciwa galaz.
Kontrast z normalnym polimorfizmem wyglada tak: zamiast „zgadywac typ”, „prosmy obiekt, aby zrobil to, czego potrzebujemy”. Jesli roznica jest naprawde istotna dla zachowania, powinna byc wyrazona w interfejsie przez metode wirtualna.
3. typeid: kiedy potrzebna jest nazwa typu
Czasami nie trzeba „rzutowac” obiektu do potomka. Trzeba po prostu zrozumiec, co to za rzecz przyleciala, i wypisac to do logu. Na przyklad zrobiliscie fabryke, a potem chcecie wypisac: „utworzono taki a taki storage”. Wlasnie tutaj odpowiedni jest typeid.
typeid(expr) zwraca referencje do obiektu std::type_info. Najczesciej uzywa sie typeid(expr).name(). Ale wazne jest jedno: name() to nie jest „ladna, stabilna nazwa klasy”. Format zalezy od kompilatora i ustawien (czesto jest „manglowany”). Dlatego nadaje sie to do diagnostyki dla programisty, ale slabo sprawdza sie jako „protokol” albo klucz logiki.
Przyklad pogladowy:
#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"; // na przyklad: 7Derived (zalezy od kompilatora)
}
Dlaczego wazne jest tutaj to, ze Base jest polimorficzny? Poniewaz dla typow polimorficznych typeid przez referencje lub dereferencje uwzglednia typ dynamiczny. W przeciwnym razie bardzo latwo otrzymacie po prostu „Base”, nawet jesli w srodku jest „Derived”.
Osobno zaznacze (bez zaglebiania sie w wyjatki): proby wykonywania typeid(*p) przy p == nullptr dla typu polimorficznego to zly pomysl. To co najmniej sytuacja awaryjna dla programu. Zasada jest wiec prosta: zanim napiszecie typeid(*p), upewnijcie sie, ze p nie jest nullem.
4. Przyklad: diagnozujemy, jaki Storage utworzyla fabryka
Kontynuujmy nasz edukacyjny mini-projekt — aplikacje konsolowa TaskPad, w ktorej zadania sa przechowywane przez abstrakcje ITaskStorage. W poprzednich wykladach powinna juz pojawic sie fabryka, ktora tworzy implementacje na podstawie konfiguracji albo CLI, a kod kliencki pracuje przez std::unique_ptr<ITaskStorage>.
Stworzmy uproszczony interfejs:
#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;
};
Dalej mamy implementacje. Dla potrzeb wykladu wazna jest sama idea, dlatego implementacje beda umowne i krotkie:
#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; }
};
A teraz, zalozmy, plikowa:
#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; }
};
Teraz wyobrazmy sobie, ze mamy fabryke:
#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>();
}
I nagle chcemy dodac diagnostyczny wydruk: jaki storage zostal wybrany i, jesli jest plikowy, jaka jest sciezka. Najbardziej „na wprost” wariant to 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)
}
Dlaczego jest to dopuszczalne? Poniewaz to funkcja czysto diagnostyczna. Nie podejmuje decyzji, nie zmienia algorytmu pracy, nie definiuje zasad biznesowych. Po prostu pomaga programiscie zobaczyc „co utworzyla fabryka”. To dobry przyklad „rzadkiego i lokalnego” uzycia RTTI.
5. Jak zrobic to lepiej: metoda wirtualna do diagnostyki
Teraz zrobmy kolejny krok dojrzalosci. Rozumiemy, ze czasem chcemy wypisywac informacje o implementacji — ale nie chcemy, aby caly projekt wiedzial o FileTaskStorage. A zatem bardziej logiczne jest poprosic obiekt, by sam potrafil sie opisac przez interfejs.
Dodajmy do interfejsu metode debug_describe(...). Wazne: z gory ustalamy, ze to rzecz diagnostyczna, a nie czesc logiki dziedzinowej. Kontrakt jest taki: metoda nie powinna zmieniac stanu (const) i ma wypisywac ciag czytelny dla czlowieka.
#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;
};
Implementacja w 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
}
};
I w 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
}
};
Teraz kod kliencki moze zrobic tak:
#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
}
Zauwazcie, ze tutaj RTTI nie jest nam juz potrzebne. Znow korzystamy z polimorfizmu „tak, jak zostal zaplanowany”: zachowanie jest rozne — czyli wyrazamy je metoda wirtualna.
6. Kiedy RTTI jest na miejscu i jak wybrac narzedzie
Teraz wazne, by nie popasc w druga skrajnosc: „RTTI nigdy nie wolno uzywac”. Wolno. Ale lepiej, zeby bylo to lokalne, swiadome i mialo zrozumiala przyczyne.
RTTI bywa na miejscu w kodzie infrastrukturalnym: logowanie, polecenia debugowe, zrzuty diagnostyczne, statystyka, telemetria w buildach deweloperskich. Tam czesto nie chcecie rozszerzac interfejsu „dla jednego logu”, bo interfejs to kontrakt, ktory od teraz musza realizowac wszyscy potomkowie.
RTTI moze tez byc rozsadnym mostem przy integracji ze starym kodem. Na przyklad macie zewnetrzne API, ktore przyjmuje dokladnie ConcreteX*, a wy wewnatrz trzymacie I*. Wtedy dynamic_cast zamienia sie w „sprawdzenie zgodnosci”: jesli obiekt nie jest tym, czego potrzeba — uczciwie odmawiacie operacji albo uzywacie fallbacku.
Trzeci rzadki przypadek to testy albo narzedzia deweloperskie. Na przyklad chcecie upewnic sie, ze fabryka dla okreslonej konfiguracji tworzy wlasnie FileTaskStorage. W kodzie produkcyjnym ta wiedza nie jest potrzebna, ale w tescie albo diagnostyce — bywa bardzo przydatna.
dynamic_cast i typeid: mini-tabela
| Narzedzie | Co robi | Typowy wynik | Kiedy jest na miejscu |
|---|---|---|---|
|
Probuje bezpiecznie sprowadzic do potomka | |
Kiedy trzeba sprobowac lokalnie uzyc specyficznego API potomka (zwykle diagnostyka albo integracje) |
|
Pobiera informacje o typie wyrazenia w czasie wykonania | |
Do logow i diagnostyki „co to w ogole za obiekt” |
I jeszcze jeden mikro-schemat pokazujacy, jak to zwykle wyglada w projekcie:
flowchart TD
A[Jest ITaskStorage&] --> B{Potrzebne zachowanie?}
B -->|Tak| C[Dodaj lub uzyj metody virtual w interfejsie]
B -->|Nie, tylko diagnostyka| D{Potrzebny dostep do pol potomka?}
D -->|Tak| E[dynamic_cast + sprawdzenie nullptr]
D -->|Nie, tylko nazwa| F["typeid(...) do logu"]
Tak, to „schemat zdrowego rozsadku”, a nie matematyczny dowod. Ale w prawdziwym kodzie zdrowy rozsadek to technologia bardzo konkurencyjna.
7. Typowe bledy przy pracy z RTTI
Blad nr 1: uzywanie RTTI jako glownego mechanizmu rozgaleziania logiki.
Bardzo latwo napisac „jesli to A — robimy tak, jesli B — inaczej…”, i na poczatku wydaje sie, ze wszystko jest pod kontrola. Ale w praktyce uniewazniacie polimorfizm i zmuszacie kod kliencki do znajomosci wszystkich implementacji. Po kilku iteracjach zamienia sie to w bol przy dodawaniu nowych typow. Jesli roznica wplywa na zachowanie, powinna byc wyrazona metoda wirtualna interfejsu.
Blad nr 2: zapomnienie, ze dynamic_cast wymaga polimorficznej bazy.
dynamic_cast dziala na podstawie metadanych RTTI, ktore pojawiaja sie tylko dla typow polimorficznych. Jesli w klasie bazowej nie ma funkcji wirtualnych, rzutowanie albo sie nie skompiluje, albo nie ma sensu. Dobry nawyk: interfejs zawsze ma virtual ~I() = default, a to automatycznie czyni baze polimorficzna.
Blad nr 3: dereferencja wyniku dynamic_cast bez sprawdzenia nullptr.
Wariant ze wskaznikiem jest wygodny wlasnie dlatego, ze przy niepowodzeniu dostajecie nullptr. Ale jesli po dynamic_cast od razu napiszecie p->field, dostaniecie klasyczna awarie. Zasada jest prosta: najpierw if (auto* d = dynamic_cast<...>(p)), potem uzywamy wyniku.
Blad nr 4: uzywanie typeid(...).name() jako stabilnego identyfikatora.
Poczatkujacy czasem zaczynaja porownywac napisy: if (typeid(x).name() == "FileTaskStorage"). To skrajnie zawodne: format zalezy od kompilatora i moze byc manglowany. name() nadaje sie do wypisania w logu „dla czlowieka”, ale slabo nadaje sie jako czesc logiki programu.
Blad nr 5: przeciagniecie RTTI przez caly projekt zamiast ograniczenia go do lokalnej funkcji diagnostycznej.
Najzdrowszy scenariusz uzycia RTTI to mala funkcja w jednym miejscu (na przyklad debug_print_storage), ktora nie wplywa na glowna prace aplikacji. Jesli dynamic_cast zaczyna pojawiac sie w funkcjach „produkcyjnych” (parsowanie, wykonywanie komend, obliczenia), prawie zawsze jest to sygnal, ze interfejs nie opisuje potrzebnego zachowania albo ze odpowiedzialnosci zostaly pomieszane.
GO TO FULL VERSION