Niech dane będą klasy Base (bazowa) i Derived (pochodna dziedzicząca po Base), przy czym tworzony jest obiekt d klasy Derived wywołujący metodę g (odziedziczoną z klasy Base):
#include <iostream> class Base { public: Base() { std::cout << "Base::Base()" << "\n"; } virtual ~Base() { std::cout << "Base::~Base()" << "\n"; } void f() { std::cout << "Base::f()" << "\n"; } void g() { std::cout << "Base::g()" << "\n"; this->f(); } }; class Derived : public Base { public: Derived() { std::cout << "Derived::Derived()" << "\n"; } virtual ~Derived() { std::cout << "Derived::~Derived()" << "\n"; } void f() { std::cout << "Derived::f()" << "\n"; } }; int main() { Derived d; d.g(); }
Wiązanie Statyczne
Początkowo może się wydawać, że metoda g (wywołana na rzecz obiektu d klasy Derived) wywoła metodę f dla obiektu klasy pochodnej Derived. Analizując output z powyższego kodu widzimy jednak że tak się nie stało, a wywołana metoda f jest właściwa dla obiektu klasy bazowej Base:
Base::Base() Derived::Derived() Base::g() Base::f() Derived::~Derived() Base::~Base()
Problem pojawił się ponieważ metoda g zdefiniowana w klasie bazowej Base, ”widzi” jedynie statyczny typ (Base) dla wywoływanej funkcji f. Przypuszczalnie efekt jest więc niezgodny z oczekiwaniem programisty, bowiem kompilator poprzestaje tutaj jedynie na statycznym typie obiektu – znanym na etapie kompilacji (wczesne wiązanie). Modyfikując nieco powyższy kod wyświetlimy dodatkowo informacje o typie wskaźnika this (w obrębie metody f):
#include <iostream> #include <typeinfo> class Base { public: Base() { std::cout << "Base::Base()" << "\n"; } virtual ~Base() { std::cout << "Base::~Base()" << "\n"; } void f() { std::cout << "Base::f() : " << typeid(this).name() << "\n"; } void g() { std::cout << "Base::g()" << "\n"; this->f(); } }; class Derived : public Base { public: Derived() { std::cout << "Derived::Derived()" << "\n"; } virtual ~Derived() { std::cout << "Derived::~Derived()" << "\n"; } void f() { std::cout << "Derived::f() : " << typeid(this).name() << "\n"; } }; int main() { Derived d; d.g(); }
Uruchamiając powyższy kod widzimy, iż wewnątrz metody f, typem wskaźnika this jest Base * (P4Base – to zmanglowana nazwa typu Base *, gdzie P oznacza pointer czyli wskaźnik) zamiast spodziewanego Derived *:
Base::Base() Derived::Derived() Base::g() Base::f() : P4Base Derived::~Derived() Base::~Base()
Nazwę typu P4Base można najprościej zdemanglować używając narzędzia c++filt z przełącznikiem -t (uwzględniającym samą nazwę typu):
c++filt -t P4Base Base*
Wiązanie Dynamiczne
Powyższy przykład pokazuje, że podczas rozstrzygania typu (w ramach hierarchii dziedziczenia) domyślnym mechanizmem jest wiązanie statyczne. Aby rozwiązać przedstawiony problem należy zmienić domyślne wiązanie statyczne (ang. dynamic binding) na inteligentne wiązanie dynamiczne. Zapewni ono poprawne i automatyczne rozpoznawanie faktycznego, dynamicznego typu obiektu – ustalonego dopiero w czasie wykonywania programu (ang. runtime) (późne wiązanie). Mechanizm ten oczywiście realizuje się za pośrednictwem funkcji wirtualnych, które stanowią podstawę polimorfizmu. Oznaczmy zatem funkcję f słowem kluczowym virtual:
#include <iostream> #include <typeinfo> class Base { public: Base() { std::cout << "Base::Base()" << "\n"; } virtual ~Base() { std::cout << "Base::~Base()" << "\n"; } virtual void f() { std::cout << "Base::f() : " << typeid(this).name() << "\n"; } void g() { std::cout << "Base::g()" << "\n"; this->f(); } }; class Derived : public Base { public: Derived() { std::cout << "Derived::Derived()" << "\n"; } virtual ~Derived() { std::cout << "Derived::~Derived()" << "\n"; } virtual void f() override { std::cout << "Derived::f() : " << typeid(this).name() << "\n"; } }; int main() { Derived d; d.g(); }
Dodatkowo użyto słowa kluczowego override (wprowadzonego w standardzie C++11), aby upewnić się, że przedefiniowana została właściwa funkcja wirtualna klasy bazowej. Słowo kluczowe virtual jest dziedziczone w klasie bazowej i pomimo tego, że jest opcjonalne, dobrą praktyką jest używanie go także w klasach pochodnych. Po uruchomieniu powyższego kodu otrzymamy spodziewany output:
Base::Base() Derived::Derived() Base::g() Derived::f() : P7Derived Derived::~Derived() Base::~Base()
Wewnątrz metody f typem wskaźnika this jest oczekiwany Derived * (P7Derived – to zmanglowana nazwa typu Derived *, gdzie P oznacza pointer czyli wskaźnik). Ponownie możemy użyć narzędzia c++filt aby to potwierdzić:
c++filt -t P7Derived Derived*
Konstruktor i Destruktor
Rozważmy teraz scenariusz który jest właściwym tematem tego wpisu. Dodajmy mianowicie do naszych klas Base i Derived dwie funkcje wirtualne: alloc i dealloc, które symulują odpowiednio przydzielanie i zwalnianie pewnych zasobów (aby łatwiej można było się na nich skupić, usunięto metody f i g). Funkcja alloc zostaje wywołana w konstruktorze, a dealloc w destruktorze klasy bazowej Base:
#include <iostream> #include <typeinfo> class Base { public: Base() { std::cout << "Base::Base()" << "\n"; this->alloc(); } virtual ~Base() { std::cout << "Base::~Base()" << "\n"; this->dealloc(); } virtual void alloc() { std::cout << "Base::alloc() : " << typeid(this).name() << "\n"; } virtual void dealloc() { std::cout << "Base::dealloc() : " << typeid(this).name() << "\n"; } }; class Derived : public Base { public: Derived() { std::cout << "Derived::Derived()" << "\n"; } virtual ~Derived() { std::cout << "Derived::~Derived()" << "\n"; } virtual void alloc() override { Base::alloc(); std::cout << "Derived::alloc() : " << typeid(this).name() << "\n"; } virtual void dealloc() override { std::cout << "Derived::dealloc() : " << typeid(this).name() << "\n"; Base::dealloc(); } }; int main() { Derived d; }
Po uruchomieniu powyższego kodu otrzymujemy niespodziewany rezultat ponieważ wywołane metody alloc i dealloc pochodzą z obiektu klasy Base:
Base::Base() Base::alloc() : P4Base Derived::Derived() Derived::~Derived() Base::~Base() Base::dealloc() : P4Base
Pomimo tego iż funkcje alloc i dealloc są wirtualne, typem wskaźnika this jest Base *. Oznacza to, że w przedstawionym przypadku, wiązanie dynamiczne nie działa. Podczas wykonywania konstruktora dla obiektu klasy Base, obiekt klasy Derived nie jest jeszcze utworzony (obiekt d nie ma jeszcze typu Derived), więc najlepsze co może zrobić kompilator to wykonać statyczne wiązanie i wywołać metodę alloc z klasy bazowej Base. W tym momencie bowiem konstruktor klasy bazowej Base powoduje, iż tworzony jest subobiekt klasy Base i tak też na tym etapie zachowuje się częściowo skonstruowany obiekt d. W przypadku destruktora klasy bazowej Base, obiekt klasy pochodnej Derived już nie istnieje (jego destruktor został wywołany wcześniej), a więc możliwe jest jedynie wywołanie metody dealloc dla obiektu klasy bazowej Base. Inaczej mówiąc metody alloc i dealloc zachowują się tak jakby nie były wirtualne. W efekcie podczas tworzenia obiektu d klasy pochodnej Derived przydzielone i zwolnione zostają jedynie zasoby odpowiednie dla obiektu klasy bazowej Base. Będzie to powodować błędne działanie obiektu d, ponieważ nie zostały przydzielone właściwe mu zasoby. Generalnym wnioskiem wynikającym z powyższego przykładu jest zasada zgodnie z którą funkcje wirtualne nie powinny być uruchamiane w konstruktorze i destruktorze. Najprostszym rozwiązaniem przedstawionego problemu jest rezygnacja z metod alloc i dealloc oraz bezpośrednie umieszczenie kodu przydzielającego i zwalniającego zasoby w odpowiednich konstruktorach i destruktorach. Jeżeli jednak zależy nam na zachowaniu tych metod, z pomocą może przyjść dwufazowa konstrukcja (destrukcja) obiektu. W tym celu dodamy do klasy bazowej Base metody init i deInit (nie muszą być wirtualne), które będą odpowiednio wywoływać odpowiednio nasze metody wirtualne: alloc i dealloc.
#include <iostream> #include <typeinfo> class Base { public: Base() { std::cout << "Base::Base()" << "\n"; } virtual ~Base() { std::cout << "Base::~Base()" << "\n"; } void init() { std::cout << "Base::init()" << "\n"; this->alloc(); } void deInit() { std::cout << "Base::deInit()" << "\n"; this->dealloc(); } virtual void alloc() { std::cout << "Base::alloc() : " << typeid(this).name() << "\n"; } virtual void dealloc() { std::cout << "Base::dealloc() : " << typeid(this).name() << "\n"; } }; class Derived : public Base { public: Derived() { std::cout << "Derived::Derived()" << "\n"; } virtual ~Derived() { std::cout << "Derived::~Derived()" << "\n"; } virtual void alloc() override { Base::alloc(); std::cout << "Derived::alloc() : " << typeid(this).name() << "\n"; } virtual void dealloc() override { std::cout << "Derived::dealloc() : " << typeid(this).name() << "\n"; Base::dealloc(); } }; int main() { Derived d; d.init(); d.deInit(); }
Jest to więc klasyczne podejście z dynamicznym wiązaniem (analogicznym do jednego z poprzednich przykładów. w którym metoda g wywołuje funkcję wirtualną f). Po uruchomieniu powyższy przykład da spodziewany output:
Base::Base() Derived::Derived() Base::init() Base::alloc() : P4Base Derived::alloc() : P7Derived Base::deInit() Derived::dealloc() : P7Derived Base::dealloc() : P4Base Derived::~Derived() Base::~Base()
Na zakończenie zmodyfikujmy przykład z funkcjami wirtualnymi alloc i dealloc wywoływanymi odpowiednio w konstruktorze i destruktorze. Modyfikacja polegać będzie na zmianie funkcji wirtualnych alloc i dealloc na funkcje czysto wirtualne (ang. pure virtual) z zachowaniem ich implementacji (funkcja wirtualna może mieć implementację i na ogół traktuje się ją jako domyślną lub częściową):
#include <iostream> #include <typeinfo> class Base { public: Base() { std::cout << "Base::Base()" << "\n"; this->alloc(); } virtual ~Base() { std::cout << "Base::~Base()" << "\n"; this->dealloc(); } virtual void alloc() = 0; virtual void dealloc() = 0; }; void Base::alloc() { std::cout << "Base::alloc() : " << typeid(this).name() << "\n"; } void Base::dealloc() { std::cout << "Base::dealloc() : " << typeid(this).name() << "\n"; } class Derived : public Base { public: Derived() { std::cout << "Derived::Derived()" << "\n"; } virtual ~Derived() { std::cout << "Derived::~Derived()" << "\n"; } virtual void alloc() override { Base::alloc(); std::cout << "Derived::alloc() : " << typeid(this).name() << "\n"; } virtual void dealloc() override { std::cout << "Derived::dealloc() : " << typeid(this).name() << "\n"; Base::dealloc(); } }; int main() { Derived d; }
Warto zauważyć, że w tym momencie klasa bazowa Base staje się jednocześnie klasą abstrakcyjną (nie możemy w bezpośredni sposób tworzyć obiektów tej klasy). Problemem w powyższym kodzie jest fakt, iż według standardu języka C++, takie wywołanie funkcji czysto wirtualnej stanowi niezdefiniowane zachowanie (ang. undefined behaviour), co oznacza, że program może zakończyć się ”crashem”, ale może też wykonać funkcję czysto wirtualną (co wydaje się najniebezpieczniejszą opcją, ponieważ programista może nie być świadomy zaistniałego problemu). Nawet jeśli wywołanie takiej funkcji było intencją programisty, to nie należy na tym polegać, ponieważ ze względu na niezdefiniowane zachowanie kod staje się nieprzenośny. Jednym z uzasadnień niezdefiniowanego zachowania może być fakt, iż kompilator widząc wywołanie takiej funkcji w konstruktorze (destruktorze) z jednej strony wie, że nie może wywołać funkcji wirtualnej z obiektu klasy pochodnej, ale jednocześnie ewentualne wywołanie funkcji czysto wirtualnej z obiektu klasy bazowej jest nieprawidłowo zapisane (ang. ill-formed), bowiem w takim przypadku używa się nazwy kwalifikowanej z operatorem zakresu. W naszym przypadku mamy więc wywołania this->alloc() i this->dealloc() (lub po prostu alloc() i dealloc()), o niezdefiniowanym zachowaniu, zamiast prawidłowych Base::alloc() i Base::dealloc(). W przypadku klasycznej funkcji czysto wirtualnej, czyli bez implememtacji, kompilacja takiego kodu w najlepszym przypadku zakończy się ostrzeżeniem kompilatora (ang. warning) oraz błędem linkera (co jest najlepszą możliwą opcją, ponieważ już na etapie kompilacji/linkowania programista jest świadomy zaistniałego problemu):
main.cpp: In constructor ‘Base::Base()’: main.cpp:10:19: warning: pure virtual ‘virtual void Base::alloc()’ called from constructor this->alloc(); ^ main.cpp: In destructor ‘virtual Base::~Base()’: main.cpp:16:21: warning: pure virtual ‘virtual void Base::dealloc()’ called from destructor this->dealloc(); ^ /tmp/ccB6Opl5.o: In function `Base::Base()': main.cpp:(.text._ZN4BaseC2Ev[_ZN4BaseC5Ev]+0x3c): undefined reference to `Base::alloc()' /tmp/ccB6Opl5.o: In function `Base::~Base()': main.cpp:(.text._ZN4BaseD2Ev[_ZN4BaseD5Ev]+0x3c): undefined reference to `Base::dealloc()' /tmp/ccB6Opl5.o: In function `Derived::alloc()': main.cpp:(.text._ZN7Derived5allocEv[_ZN7Derived5allocEv]+0x15): undefined reference to `Base::alloc()' /tmp/ccB6Opl5.o: In function `Derived::dealloc()': main.cpp:(.text._ZN7Derived7deallocEv[_ZN7Derived7deallocEv]+0x49): undefined reference to `Base::dealloc()' collect2: error: ld returned 1 exit status
Niezdefiniowane zachowanie może objawić się także tym, że kompilator ”zdecyduje się” na wywołanie funkcji wirtualnej zdefioniowanej w klasie pochodnej. Jest to niebezpieczne z takich samych powodów jak wspomniane wcześniej niezdefiniowane wywołanie funkcji czysto wirtualnej z implementacją.
Podsumowanie
- Domyślnie realizowane jest wiązanie statyczne (typ obiektu ustalany jest na etapie kompilacji)
- Wiązanie dynamiczne wybierane jest za pośrednictwem funkcji wirtualnych (typ obiektu ustalany jest w czasie wykonywania programu)
- Nie należy wywoływać funkcji wirtualnych w konstruktorze i destruktorze (dynamiczne wiązanie nie działa dla takich funkcji)
- Funkcje wirtualne wywołane w konstruktorze i destruktorze zachowują się jak zwykłe funkcje i podlegają wiązaniu statycznemu, przy czym zachowanie to zostało zdefiniowane przez standard języka C++
- Aby uniknąć problemu wynikającego z wywołania funkcji wirtualnej w konstruktorze i destruktorze można stosować dwufazową konstrukcję (destrukcję) obiektu
- Wywołanie funkcji czysto wirtualnej możliwe jest wyłącznie za pomocą nazwy kwalifikowanej (próba wywołania jej w sposób standardowy prowadzi do niezdefiniowanego zachowania, tak jak może to mieć miejsce w konstruktorze / destruktorze)