Zacznijmy od motywującego przykładu, który przedstawi pewien problem:
#include <iostream> class Base { public: virtual ~Base() = default; virtual void execute() const { std::cout << "Base::execute()\n"; } }; class Derived : public Base { public: virtual void execute() const override { std::cout << "Derived::execute()\n"; } }; void process(Base *b) { Base *a = new Base(*b); a->execute(); delete a; } int main() { Base b; process(&b); }
Base::execute()
Program ten przekazuje wskaźnik do obiektu klasy Base do funkcji process, w której to obiekt ten jest kopiowany (konstruktor kopiujący) i umieszczany na stercie (operator new). Następnie wywoływana jest funkcja wirtualna execute, po czym obiekt jest niszczony (operator delete). Po uruchomieniu powyższego programu otrzymaliśmy wprawdzie spodziewany output, ale sytuacja zmieni się jeśli do funkcji process przekażemy wskaźnik do obiektu klasy pochodnej Derived (dziedziczącej po klasie bazowej Base) :
#include <iostream> class Base { public: virtual ~Base() = default; virtual void execute() const { std::cout << "Base::execute()\n"; } }; class Derived : public Base { public: virtual void execute() const override { std::cout << "Derived::execute()\n"; } }; void process(Base *b) { Base *a = new Base(*b); a->execute(); delete a; } int main() { Derived d; process(&d); }
Base::execute()
Otrzymujemy ten sam output, który tym razem nie jest tym czego się spodziewaliśmy. Zmianie uległ typ obiektu, przekazywany przez wskaźnik do funkcji process, ale program zachował się tak jakby typem tego obiektu był nadal Base zamiast Derived. Problemem jest tutaj operacja kopiowania, której końcowym efektem jest umieszczenie na stercie (ang. heap) jedynie ”fragmentu” faktycznego obiektu typu Derived w postaci obiektu typu bazowego Base. Zjawisko to nazywane określane jest jako ”odkrawanie obiektu” (ang. object slicing) i pojawia się wtedy gdy obiekt klasy pochodnej kopiowany jest do obiektu klasy bazowej. W praktyce efekt ten na ogół nie jest pożądany i nie powinno się tak robić, ponieważ tracimy wszystkie informacje dostępne w części pochodnej obiektu.
Przedstawiony problem nie wystąpiłby, gdyby konstruktor kopiujący wiązany był w sposób dynamiczny. Jest to jednak niemożliwe, ponieważ standard języka C++ nie pozwala na definiowanie wirtualnych konstruktorów (w tym konstruktora kopiującego). Po prostu konstruktor nie może być wirtualny (w przeciwieństwie do destruktora), co w efekcie powoduje że wywołanie naszego konstruktora kopiującego wiązane jest w sposób statyczny. Tracimy przez to informację o prawdziwym typie kopiowanego obiektu, jakim jest Derived.
Funkcja clone()
Przedstawiony problem można rozwiązać za pomocą funkcji wirtualnej, często nazywanej clone (funkcja klonująca obiekty), symulującej zachowanie formalnie nieistniejącego ”wirtualnego konstruktora kopiującego”:
#include <iostream> class Base { public: virtual ~Base() = default; virtual void execute() const { std::cout << "Base::execute()\n"; } virtual Base *clone() const { std::cout << "Base::clone()\n"; return new Base(*this); } }; class Derived : public Base { public: virtual void execute() const override { std::cout << "Derived::execute()\n"; } virtual Base *clone() const override { std::cout << "Derived::clone()\n"; return new Derived(*this); } }; void process(Base *b) { Base *a = b->clone(); a->execute(); delete a; } int main() { Derived d; process(&d); }
Derived::clone() Derived::execute()
Otrzymujemy spodziewany output. Wybrana zostaje prawidłowa funkcja wirtualna clone, a wskaźnik do obiektu typu Derived zostaje niejawnie rzutowany w górę (ang. upcast) do wskaźnika do obiektu klasy bazowej. W efekcie wywołana zostaje funkcja wirtualna execute, właściwa dla obiektu klasy Derived. Jest to dowód na to, iż na stercie umieszczona została kopia właściwego obiektu typu Derived. Problem został rozwiązany dzięki polimorficznemu zachowaniu funkcji wirtualnej clone. Takim zachowaniem oczywiście nie cechuje się nasz konstruktor kopiujący, co było bezpośrednią przyczyną nieprawidłowego zachowania naszego programu.
Typ kowariantny
Dokonajmy drobnej korekty naszego programu zmieniając typ zwracany przez funkcję wirtualną clone (w klasie Derived) z typu Base * na Derived *:
#include <iostream> class Base { public: virtual ~Base() = default; virtual void execute() const { std::cout << "Base::execute()\n"; } virtual Base *clone() const { std::cout << "Base::clone()\n"; return new Base(*this); } }; class Derived : public Base { public: virtual void execute() const override { std::cout << "Derived::execute()\n"; } virtual Derived *clone() const override { std::cout << "Derived::clone()\n"; return new Derived(*this); } }; void process(Base *b) { Base *a = b->clone(); a->execute(); delete a; } int main() { Derived d; process(&d); }
Derived::clone() Derived::execute()
Jak widać program skompilował się i wykonał poprawnie dokładnie tak samo jak wcześniej. Okazuje się więc, że podczas nadpisywania (ang. override) funkcji wirtualnej możemy zmienić typ wartości zwracanej. Standard języka C++ pozwala na to, ale pod warunkiem, że mamy do czynienia z typem kowariantnymi (ang. covariant type). Warunek ten jest spełniony w naszym przypadku, bowiem istnieje odpowiednia relacja między typami zwracanymi. Są to bowiem wskaźniki do typów powiązanych relacją dziedziczenia. Ogólnie rzecz ujmując, jeśli w klasie pochodnej metoda zwraca typ bardziej pochodny niż w klasie bazowej to mówimy, że jest to typ kowariantny (typy te mogą być inne niż klasa w której występują, a więc mogą pochodzić również z zupełnie innej hierarchii dziedziczenia). Inaczej mówiąc, w naszym przypadku możemy podmienić wskaźnik dla typu bazowego (w oryginalnej funkcji wirtualnej z klasy bazowej) na wskaźnik dla typu pochodnego (w nadpisującej funkcji wirtualnej z klasy pochodnej). Podejście to jest zgodne z zasadą podstawienia Liskov, która jest jedną z podstawowych zasad programowania obiektowego (SOLID).
Przedstawiona relacja nie stanowi w praktyce żadnego problemu, bowiem konwersja polegająca na rzutowaniu w górę może odbywać się niejawnie. Oznacza to, że możemy bez dalszych zmian w kodzie, nadal przypisać wynik funkcji wirtualnej clone (typu Derived *) do zmiennej typu Base *. Oczywiście jeśli typ zwracany nie spełnia odpowiedniej relacji, wymaganej dla typu kowariantnego, otrzymamy błąd kompilacji.
Rozwiązanie oparte na typie kowariantnym, może okazać się bardzo wygodne bowiem automatycznie otrzymujemy wskaźnik do obiektu właściwego typu. W pewnych okolicznościach może to być bardzo korzystne, bowiem pozwoli uniknąć dodatkowego rzutowania w dół (ang. downcast) za pomocą dynamic_cast, które wykonywane jest w czasie wykonywania programu (ang. runtime) i może odbić się negatywnie na jego wydajności (ang. performance). Spójrzmy na poniższy kod źródłowy, który nie stosuje wzorca typu kowariantnego:
#include <iostream> class Base { public: virtual ~Base() = default; virtual void execute() const { std::cout << "Base::execute()\n"; } virtual Base *clone() const { std::cout << "Base::clone()\n"; return new Base(*this); } }; class Derived : public Base { public: virtual void execute() const override { std::cout << "Derived::execute()\n"; } virtual void apply() const { std::cout << "Derived::apply()\n"; } virtual Base *clone() const override { std::cout << "Derived::clone()\n"; return new Derived(*this); } }; int main() { Derived d1; Base *b = d1.clone(); // ... b->execute(); // ... // downcast Base -> Derived Derived *d2 = dynamic_cast<Derived *>(b); if (d2) { d2->apply(); } // ... delete b; }
Derived::clone() Derived::execute() Derived::apply()
W przedstawionym powyżej kodzie źródłowym używamy wyłącznie obiektu typu Derived. Pomimo tego wykonujemy jawne rzutowanie tylko po to, aby wywołać metodę apply zdefiniowaną jedynie w klasie Derived. Kosztem rozwiązania bez typu kowariantnego jest więc rzutowanie dynamiczne (dynamic_cast) oraz dodatkowy kod sprawdzający czy rzutowanie to powiodło się. Wszystko to generuje dodatkowy narzut, ponieważ odbywa się w czasie wykonywania programu. Zastosowanie wzorca typu kowariantnego uprości powyższy kod źródłowy, bowiem pozwala pozbyć się dodatkowego kodu wraz z wspomnianym już rzutowaniem dynamicznym:
#include <iostream> class Base { public: virtual ~Base() = default; virtual void execute() const { std::cout << "Base::execute()\n"; } virtual Base *clone() const { std::cout << "Base::clone()\n"; return new Base(*this); } }; class Derived : public Base { public: virtual void execute() const override { std::cout << "Derived::execute()\n"; } virtual void apply() const { std::cout << "Derived::apply()\n"; } virtual Derived *clone() const override { std::cout << "Derived::clone()\n"; return new Derived(*this); } }; int main() { Derived d1; // ... Derived *d2 = d1.clone(); // ... d2->execute(); // ... d2->apply(); // ... delete d2; }
Derived::clone() Derived::execute() Derived::apply()
Przedstawione rozwiązanie jest bezpieczne. Ewentualne skopiowanie linijki kodu odpowiedzialnej za klonowanie obiektu, w miejsce gdzie obiekt d1 jest typu Base, spowoduje błąd kompilacji (podobnie gdy zmienimy typ wartości zwracanej z Derived * na Base * dla funkcji wirtualnej Derived::clone).
main.cpp: In function ‘int main()’: main.cpp:48:26: error: invalid conversion from ‘Base*’ to ‘Derived*’ [-fpermissive] Derived *d2 = d1.clone(); ~~~~~~~~^~
Wynika to wprost z niedozwolonego niejawnego rzutowania w dół. Może ono bowiem odbyć się wyłącznie jawnie za pomocą rzutowania dynamic_cast.
Wzorzec RAII
Przedstawione do tej pory rozwiązanie stanowi klasyczną implementację ”wirtualnego konstruktora kopiującego”. Jak widać bazuje ono jedynie na tym co oferuje starszy standard języka C++ (jedynym nowszym elementem pochodzącym ze standardu C++11 jaki został do tej pory użyty to słowo kluczowe override). Charakterystyczne jest więc jawne posługiwanie się operatorami new oraz delete. W dobie nowych standardów języka C++ odchodzi się od tego typu rozwiązań na rzecz wzorca RAII (ang. Resource Acquisition Is Initialization), który najczęściej realizowany jest przez inteligentne wskaźniki (ang. smart pointers). Problem w naszym kodzie pojawi się na przykład jeśli między wywołaniami funkcji wirtualnej clone (alokującjej pamięć na stercie operatorem new) oraz operatora delete zgłoszony zostanie wyjątek (ang. exception). W tej sytuacji operator delete, odpowiedzialny za zwolnienie pamięci ze sterty (ang. heap), nie zostanie wywołany, czego negatywną konsekwencją będzie wyciek pamięci (ang. memory leak). Rozwiązaniem tego problemu jest właśnie wzorzec RAII. Zgodnie z tym wzorcem tworzymy obiekt na stosie, który potrafi automatycznie zarządzać cyklem życia przypisanego do niego zasobu (ang. resource). Na ogół obiekt ten jest tworzony wraz z przypisaniem do niego ”uchwytu” (ang. handler) do nowo powstałego zasobu (stąd też wzięła się nazwa tego wzorca). Przykładem takiego ”uchwytu” jest wskaźnik do nowo zaalokowanej pamięci (na stercie). Automatyczne zwolnienie zasobu następuje w odpowiednim momencie po wywołaniu destruktora tego obiektu. Może to być zwykłe wyjście z zakresu (ang. scope), ale także sytuacja w której rzucony został wyjątek.
W naszym przypadku, aby wykorzystać wzorzec RAII posłużymy się inteligentnym wskaźnikiem typu std::unique_ptr (aby można było z niego korzystać załączamy plik nagłówkowy memory):
#include <iostream> #include <memory> class Base { public: virtual ~Base() = default; virtual std::unique_ptr<Base> clone() const { std::cout << "Base::cloneRAII()\n"; return std::make_unique<Base>(*this); } virtual void execute() const { std::cout << "Base::process()\n"; } }; class Derived : public Base { public: virtual std::unique_ptr<Base> clone() const override { std::cout << "Derived::clone()\n"; return std::make_unique<Derived>(*this); } virtual void execute() const override { std::cout << "Derived::process()\n"; } }; void process(Base *b) { std::unique_ptr<Base> a = b->clone(); a->execute(); } int main() { Derived d; process(&d); }
Derived::clone() Derived::process()
Przedstawiony wyżej kod źródłowy wymaga standardu C++14, z uwagi na zastosowanie funkcji std::make_unique (jej brak w standardzie C++11 to zaskakujące niedopatrzenie). Obie funkcje wirtualne clone zwracają ten typ std::unique_ptr<Base>. Wynika to z faktu, iż typy std::unique_ptr<Base> oraz std::unique_ptr<Derived> stanowią zupełnie odrębne typy, a więc nie można w tym przypadku zastosować wzorca kowariantnego (nawet jeśli odpowiadające im ”gołe” wskaźniki łączy wymagana relacja oparta na dziedziczeniu). Ewentualna próba zastąpienia typu zwracanego z std::unique_ptr<Base>na std::unique_ptr<Derived> dla funkcji wirtualnej Derived::clone spowoduje błąd kompilacji, informujący o błędnym doborze typu kowariantnego:
main.cpp:25:37: error: invalid covariant return type for ‘virtual std::unique_ptr Derived::clone() const’ virtual std::unique_ptr<Derived> clone() const override ^~~~~ main.cpp:9:34: error: overriding ‘virtual std::unique_ptr Base::clone() const’ virtual std::unique_ptr<Base> clone() const ^~~~~
Typ std::unique_ptr jest po prostu szablonem klasy, który generuje różne typy (nie łączy ich żadna relacja), w zależności od parametru jakim jest typ obsługiwanego zasobu. Jedyne co jest możliwe to niejawna konwersja typu std::unique_ptr<Derived> na typ std::unique_ptr<Base>, co wykorzystane zostało w funkcji wirtualnej Derived::clone. Okazuje się więc, że nie jest to rozwiązanie idealne (chociaż znacząco poprawia sposób obsługi zasobów), bowiem oznacza powrót do omówionego wcześniej rzutowania dynamic_cast. Jednocześnie skutecznie pozbywamy się konieczności jawnego wywołania operatora delete oraz operatora new (w funkcji wirtualnej clone), co jest przejawem nowoczesnego podejścia do programowania w języku C++ (”modern C++”):
#include <iostream> #include <memory> class Base { public: virtual ~Base() = default; virtual std::unique_ptr<Base> clone() const { std::cout << "Base::cloneRAII()\n"; return std::make_unique<Base>(*this); } virtual void execute() const { std::cout << "Base::process()\n"; } }; class Derived : public Base { public: virtual std::unique_ptr<Base> clone() const override { std::cout << "Derived::clone()\n"; return std::make_unique<Derived>(*this); } virtual void apply() const { std::cout << "Derived::apply()\n"; } virtual void execute() const override { std::cout << "Derived::process()\n"; } }; int main() { Derived d1; std::unique_ptr<Base> b = d1.clone(); // ... b->execute(); // ... // downcast Base -> Derived Derived *d2 = dynamic_cast<Derived *>(b.get()); if (d2) { d2->apply(); } // ... }
Derived::clone() Derived::process() Derived::apply()
Podsumowanie
- Według standardu języka C++, konstruktor nie może być wirtualny
- Można zaimplementować funkcję wirtualną, nazywaną tradycyjnie clone, która spełnia zadanie ”wirtualnego konstruktora kopiującego”
- Podczas implementacji funkcji wirtualnej clone, można zastosować wzorzec typu kowariantnego, który pomaga uprościć kod źródłowy (eliminacja kosztownego rzutowania dynamic_cast oraz sprawdzania jego rezultatu)
- Podczas implementacji funkcji wirtualnej clone, można zastosować wzorzec RAII (realizowany typowo poprzez inteligentny wskaźnik std::unique_ptr), automatycznie zarządzający cyklem życia przydzielonego zasobu
W artykule tym przedstawiłem jedynie podstawy tematyki ”wirtualnego konstruktora kopiującego”. Dalsze rozważania wykraczają poza zakres pojedynczego artykułu. Aby wyczerpać cały temat należałoby przedstawić rozwiązania dla innych modeli dziedziczenia (tutaj ograniczyłem się jedynie do najprostszego dziedziczenia jednobazowego), takich jak chociażby dziedziczenie wielobazowe. Ponadto kompletne rozwiązanie powinno połączyć oba wzorce: RAII (z wykorzystaniem std::unique_ptr) oraz typ kowariantny. Nie jest ono trywialne, bowiem jak się wydaje oba wzorce wzajemnie się tutaj wykluczają. Zainteresowanych tą tematyką odsyłam do artykułu na blogu Fluent C++.