Podczas nauki programowania w języku C++, dowiadujemy się m.in. że funkcje czysto wirtualne (ang. pure virtual function) to takie funkcje wirtualne, które nie posiadają implementacji i jako takie służą do modelowania pewnego interfejsu. Klasa zawierająca przynajmniej jedną funkcję czysto wirtualną, staje się klasą abstrakcyjną (ang. abstract class) – nie możemy tworzyć bezpośrednio obiektów tej klasy i dlatego też istnieje wyłącznie jako klasa bazowa w hierarchii dziedziczenia (ang. inheritance). Oczywiście jeśli klasa pochodna dziedzicząca po klasie abstrakcyjnej nie dostarczy własnej implementacji funkcji, która w klasie bazowej była czysto wirtualna, to klasa pochodna także staje się klasą abstrakcyjną.

Język C++ jest dużo bardziej elastyczny ze względu na możliwość dostarczenia implementacji także dla funkcji czysto wirtualnej. Jako przykład niech posłuży nam poniższy kod źródłowy:

#include <iostream>

struct Base
{
   virtual ~Base() = default;

protected:
   virtual void A() = 0;
   virtual void B() = 0;
   virtual void C() = 0;
};

void Base::A()
{
   std::cout << "Base::A()\n"; 
}

void Base::B()
{
   std::cout << "Base::B()\n";
}

void Base::C()
{
   std::cout << "Base::C()\n";
}

struct Derived : Base
{
   virtual void A() override
   {
      std::cout << "Derived::A()\n";    
   }
    
   virtual void B() override
   {
      Base::B();    
   }
    
   virtual void C() override
   {
      Base::C();
      std::cout << "Derived::C()\n"; 
   }
};

int main()
{
   Derived d;
    
   d.A();
   d.B();
   d.C();
}

Uruchom w edytorze

Derived::A()                                                                                                                                                                                
Base::B()                                                                                                                                                                                   
Base::C()                                                                                                                                                                                   
Derived::C()

W tym miejscu pozwolę sobie na małą dygresję dotyczącą destruktora. Jeśli klasa bazowa posiada przynajmniej jedną funkcję wirtualną, a więc przeznaczona jest do używania polimorficznego, wówczas jej destruktor powinien być także wirtualny (domyślnie nie jest on nigdy wirtualny). W przeciwnym wypadku destruktor obiektu klasy pochodnej nie zostanie wywołany podczas niszczenia go za pośrednictwem wskaźnika do obiektu klasy bazowej. Grozi to przede wszystkim niekontrolowanym wyciekiem zasobów takich jak pamięć (ang. memory leak). Jednocześnie należy mieć na uwadze, że operacje przenoszące (konstruktor przenoszący oraz przenoszący operator przypisania) nie zostaną wygenerowane, jeśli zdefiniujemy destruktor, co może wpłynąć negatywnie na wydajność aplikacji (jest to jednak temat na zupełnie osobny artykuł).

Przypadki Użycia

Funkcja wirtualna A() reprezentuje klasyczny przypadek, w którym klasa pochodna dostarcza własną definicję funkcji wirtualnej. W naszym przypadku po prostu oznacza to, że rezygnujemyimplementacji oferowanej przez klasę bazową Base i dostarczamy zupełnie inną implementację w klasie pochodnej Derived:

struct Base
{
   ...

protected:
   virtual void A() = 0;
   ...
};

void Base::A()
{
   std::cout << "Base::A()\n"; 
}

...

struct Derived : Base
{
   virtual void A() override
   {
      std::cout << "Derived::A()\n";    
   }
    
   ...
};

Jeśli jednak uznamy, że implementacja funkcji czysto wirtualnej nam odpowiada, nie możemy jej po prostu odziedziczyć, bowiem wtedy nasza klasa Derived stanie się abstrakcyjna (nie jest to oczywiście naszym celem). Klasa Derived ponownie musi zdefiniować własną implementację funkcji wirtualnej, przy czym implementacja ta składa się wyłącznie z wywołania funkcji czysto wirtualnej z klasy pochodnej Base (realizuje się to za pomocą nazwy kwalifikowanej, tzn nazwa funkcji poprzedzona jest przez nazwę klasy bazowej oraz operator zakresu). Funkcja czysto wirtualna dostarcza więc odpowiadającą nam implementację domyślną. Sytuacji tej odpowiada funkcja wirtualna B():

struct Base
{
   ...

protected:
   virtual void B() = 0;
   ...
};

void Base::B()
{
   std::cout << "Base::B()\n"; 
}

...

struct Derived : Base
{
   virtual void B() override
   {
      Base::B();    
   }
    
   ...
};

Funkcja wirtualna C(), stanowi połączenie dwóch poprzednich przypadków:

struct Base
{
   ...

protected:
   virtual void C() = 0;
   ...
};

void Base::C()
{
   std::cout << "Base::C()\n"; 
}

...

struct Derived : Base
{
   virtual void C() override
   {
      Base::C();
      std::cout << "Derived::C()\n";    
   }
    
   ...
};

Inaczej mówiąc, funkcja wirtualnaklasie pochodnej dostarcza własną implementację, której częścią jest wywołanie funkcji czysto wirtualnej pochodzącej z klasy bazowej. Funkcja czysto wirtualna dostarcza więc tutaj implementację częściową. Tego typu podejście charakterystyczne jest dla wzorca projektowego Dekorator.

Destruktor Czysto Wirtualny

Wyobraźmy sobie sytuację, w której nasza klasa bazowa Base nie jest klasą abstrakcyjną. Jest więc standardową klasa konkretną i możemy tworzyć obiekty jej typu:

struct Base
{
   virtual ~Base() = default;

   virtual void A();
   virtual void B();
   virtual void C();
};

void Base::A()
{
   std::cout << "Base::A()\n"; 
}

void Base::B()
{
   std::cout << "Base::B()\n";
}

void Base::C()
{
   std::cout << "Base::C()\n";
}

Załóżmy teraz, iż okazało się, że w naszym projekcie ani razu nie utworzyliśmy obiektu klasy Base, bowiem wykorzystywana była wyłącznie jako klasa po której się dziedziczy. Uznano więc, że klasa Base jest dobrym kandydatem na klasę abstrakcyjną. Pojawia się pytanie jak przerobić klasę Basekonkretnej na abstrakcyjną ? Można oczywiście przekształcić jakąś funkcję wirtualną na czysto wirtualną. Oczywiście może pojawić się kolejne pytanie: którą funkcję wirtualną przekształcić w funkcję czysto wirtualną ? W efekcie każda klasa pochodna, która nie nadpisze (ang. override) tak powstałej funkcji czysto wirtualnej stanie się klasą abstrakcyjną, co zapewne spowoduje liczne błędy kompilacji. Jednym z rozwiązań tego problemu jest dostarczenie domyślnej implementacji do każdej takiej klasy pochodnej (odpowiada to przypadkowi funkcji wirtualnej B() z naszego przykładu), co może być zajęciem pracochłonnym ”dotykającym” potencjalnie wiele klas pochodnych. Tak czy inaczej jest to rozwiązanie ”sztuczne” (arbitralnie wybieramy  funkcje wirtualną do przekształcenia ją w funkcję czysto wirtualną), wymuszone potrzebą przekształcenia konkretnej klasy bazowejklasę abstrakcyjną. Zdecydowanie lepszym rozwiązaniem będzie zdefiniowanie czysto wirtualnego destruktora:

#include <iostream>

struct Base
{
   virtual ~Base() = 0;
   
   virtual void A();
   virtual void B();
   virtual void C();
};

Base::~Base() = default;

void Base::A()
{
   std::cout << "Base::A()\n"; 
}

void Base::B()
{
   std::cout << "Base::B()\n";
}

void Base::C()
{
   std::cout << "Base::C()\n";
}

struct Derived : Base
{
   virtual void A() override
   {
      std::cout << "Derived::A()\n";    
   }
   
   virtual void C() override
   {
      Base::C();
      std::cout << "Derived::C()\n"; 
   }
};

int main()
{
   Derived d;
    
   d.A();
   d.B();
   d.C();
}

Uruchom w edytorze

Derived::A()                                                                                                                                                                                
Base::B()                                                                                                                                                                                   
Base::C()                                                                                                                                                                                   
Derived::C()

Należy zwrócić uwagę, że w nawet tym przypadku destruktor musi posiadać jakąś implementację (może być pusta lub domyślna jak w powyższym przykładzie). Wynika to z faktu, że destruktory (także czysto wirtualne) biorą udział w pewnym łańcuchu wywołań (podobnie jak konstruktory), a więc każdy z nich  musi posiadać własną implementację. Ta cecha sprawia, że destruktory nie mogą zostać nadpisane tak jak funkcje wirtualne. Składnia języka C++ wymaga aby definicja destruktora czysto wirtualnego znajdowała się poza klasą (dotyczy to zresztą także funkcji czysto wirtualnych). Zatem nie możemy zdefiniować  destruktora czysto wirtualnego chociażby w następujący sposób:

struct Base 
{
   virtual ~Base()
   {
   } = 0;
   
   ...
};

Jedyna możliwa definicja  destruktora czysto wirtualnego znajduje się poza klasą:

struct Base
{
   virtual ~Base()= 0;
   
   ...
};

Base::~Base() 
{ 
}

Powyżej przedstawiono pustą, a więc minimalistyczną implementację destruktora. Drugim równoważnym sposobem jest oznaczenie destruktora jako domyślny (ang. default):

struct Base
{
   virtual ~Base()= 0;
   
   ...
};

Base::~Base() = default;

Deklarowanie destruktora jako czysto wirtualnego jest popularną metodą przekształcenia konkretnej klasy bazowejklasę abstrakcyjną. Podejście to staje się naturalne jeśli spełnione są następujące warunki:

  • Istniejący wirtualny destruktor jest publiczny (w przeciwnym razie nie możemy zniszczyć obiektu klasy pochodnej poprzez wskaźnik do klasy bazowej)
  • Żadna funkcja wirtualna nie jest dobrym kandydatem na to, aby stać się funkcją czysto wirtualną

Podsumowanie

  • Funkcja czysto wirtualna może posiadać implementację
  • Funkcja czysto wirtualna dostarcza implementację domyślną lub implementację częściową
  • Destruktor może być czysto wirtualny, ale nawet wtedy należy dostarczyć jego implementację
  • Destruktor czysto wirtualny jest popularną metodą przekształcania konkretnej klasy bazowejklasę abstrakcyjną