C++/Funkcje wirtualne

< C++

Wstęp

edytuj

Funkcje wirtualne to specjalne funkcje składowe, które przydają się szczególnie, gdy używamy obiektów posługując się wskaźnikami lub referencjami do nich. Dla zwykłych funkcji z identycznymi nazwami to, czy zostanie wywołana funkcja z klasy podstawowej, czy pochodnej, zależy od typu wskaźnika, a nie tego, na co faktycznie on wskazuje. Dysponując funkcjami wirtualnymi będziemy mogli użyć prawdziwego polimorfizmu – używać metod klasy pochodnej wszędzie tam, gdzie spodziewana jest klasa podstawowa. W ten sposób będziemy mogli korzystać z metod klasy pochodnej korzystając ze wskaźnika, którego typ odnosi się do klasy podstawowej.

Chyba największą zaletą polimorfizmu jest to, że stanowi on „lepszą wersję” instrukcji warunkowych. Lepszą, bo wszelkie decyzje zależne od typu obiektu podejmowane są przez programistę tylko jeden raz: podczas tworzenia obiektu. W klasycznym podejściu, znanym z języka C, typ obiektu przechowuje się np. jako typ wyliczeniowy. Jest to proste i szybkie, ale zarazem kłopotliwe, bo przy każdej operacji zależnej od typu programista musi wstawić np. nowy blok 'if' lub 'case'. Gdy zachodzi potrzeba zmodyfikowania programu, np. dodania nowego typu obiektu, trzeba też znaleźć wszystkie miejsca, gdzie wstawiony był jakiś 'if', sprawdzający typ obiektu! Przy odpowiednio dużym programie wprowadzenie modyfikacji staje się w ten sposób bardzo trudne. Korzystając z polimorfizmu, programista tworzy po prostu obiekt odpowiedniej klasy, a wszystkie późniejsze decyzje, do której konkretnie funkcji skoczyć, podejmowane są już automatycznie, na podstawie informacji dostarczonych przez kompilator. Dzięki temu programista unika wielu możliwości popełnienia błędu, a dodanie nowego typu obiektu sprowadza się do napisania nowej klasy – nie trzeba już przekopywać się przez cały kod.

Na początek rozpatrzymy przykład, który pokaże, dlaczego zwykłe, niewirtualne funkcje składowe nie zdają egzaminu gdy posługujemy się wskaźnikiem, który może wskazywać i na obiekt klasy podstawowej i na obiekt dowolnej z jej klas pochodnych.

Mając klasę bazową wyprowadzamy od niej klasę pochodną:

class Baza
{
public:
   void pisz()
   {
      std::cout << "Tu funkcja pisz z klasy Baza" << std::endl;
   }
};

class Baza2 : public Baza
{
public:
   void pisz()
   {
      std::cout << "Tu funkcja pisz z klasy Baza2" << std::endl;
   }
};

Jeżeli teraz w funkcji main stworzymy wskaźnik do obiektu typu Baza, to możemy ten wskaźnik ustawiać na dowolne obiekty tego typu. Można też ustawić go na obiekt typu pochodnego, czyli Baza2:

int main()
{

   Baza  *wsk;
   Baza   objB;
   Baza2  objB2;

   wsk = &objB;
   wsk -> pisz();

// Teraz ustawiamy wskaźnik wsk na obiekt typu pochodnego

   wsk = &objB2;
   wsk -> pisz();
   return 0;
}

Po skompilowaniu na ekranie zobaczymy dwa wypisy: "Tu funkcja pisz z klasy Baza". Stało się tak dlatego, że wskaźnik jest do typu Baza. Gdy ustawiliśmy wskaźnik na obiekt typu pochodnego (wolno nam), a następnie wywołaliśmy funkcję składową, to kompilator sięgnął po funkcję pisz z klasy bazowej.

Można jednak określić żeby kompilator nie sięgał po funkcję z klasy bazowej, ale sam się zorientował na co wskaźnik pokazuje. Do tego służy przydomek virtual, a funkcja składowa nim oznaczona nazywa się wirtualną. Różnica polega tylko na dodaniu słowa kluczowego virtual, co wygląda tak:

class Baza
{
public:
   virtual void pisz()
   {
      std::cout << "Tu funkcja pisz z klasy baza" << std::endl;
   }
};

class Baza2 : public Baza
{
public:
   virtual void pisz()
   {
      std::cout << "Tu funkcja pisz z klasy Baza2" << std::endl;
   }
};

Konsekwencje

edytuj

Gdy funkcja jest oznaczona jako wirtualna, kompilator nie przypisuje na stałe wywołania funkcji z tej klasy, na którą pokazuje wskaźnik, już podczas kompilacji. Pozostawia decyzję co do wyboru właściwej wersji funkcji aż do momentu wykonania programu - jest to tzw. późne wiązanie. Wtedy program skorzysta z krótkiej informacji zapisanej w obiekcie a określającej klasę, do jakiej należy dany obiekt. Dopiero po odczytaniu informacji o klasie danego obiektu wybierana jest właściwa metoda.

Jeśli klasa ma choć jedną funkcję wirtualną, to do każdego jej obiektu dopisywany jest identyfikator tej klasy a do wywołania funkcji dopisywany jest kod, który ten identyfikator czyta i odnajduje odpowiednią funkcję. Gdy klasa funkcji wirtualnych nie posiada, takie informacje nie są dodawane, bo nie są potrzebne.

Zauważmy też, że nie zawsze decyzja o wyborze funkcji jest dokonywana dopiero na etapie wykonania. Gdy do obiektów odnosimy się przez zmienną, a nie przez wskaźnik lub referencję to kompilator już na etapie kompilacji wie, jaki jest typ (klasa) danej zmiennej (bo do zmiennej, w przeciwieństwie do wskaźnika lub referencji, nie można przypisać klasy pochodnej). Tak więc wirtualność nie gra roli gdy nie używamy wskaźników; kompilator generuje wtedy taki sam kod, jakby wszystkie funkcje były niewirtualne. Przy wskaźnikach musi orientować się czytając informację o klasie obiektu, na który wskazuje wskaźnik, bo moglibyśmy np. losować, czy do wskaźnika przypiszemy klasę bazową czy jej pochodną - wtedy przy każdym uruchomieniu programu byłaby wywoływana inna funkcja.

Jak widać, za wirtualność się płaci - zarówno drobnym narzutem pamięciowym na każdy obiekt (identyfikator klasy), jak i drobnym narzutem czasowym (odnajdywanie przy każdym wywołaniu odpowiedniej klasy i jej funkcji składowej). Jednak zyskujemy możliwość płynnego rozwoju naszego programu przez zastępowanie klas ich podklasami, co bez wirtualności jest niewykonalne. Przy możliwościach obecnych komputerów koszt wirtualności jest zaniedbywalny, ale wciąż warto przemyśleć, czy potrzebujemy wirtualności dla wszystkich funkcji.

Przykład

edytuj

Poniższy program zawiera deklaracje 3 klas: Figura, Kwadrat i Kolo. W klasie Figura została zadeklarowana metoda wirtualna (słowo kluczowe virtual) virtual float pole(). Każda z klas pochodnych od klasy Figura ma zaimplementowane swoje metody float pole(). Następnie (w funkcji main) znajdują się deklaracje obiektów każdej z klas i wskaźnika mogącego pokazywać na obiekty klasy bazowej Figura.

 #include <iostream>

 const float pi = 3.14159;
 class Figura 
 {
   public:
     virtual float pole() const 
     {
       return -1.0;
     }
 };

 class Kwadrat : public Figura 
 {
   public:
     Kwadrat( const float bok ) : a( bok ) {}

     float pole() const 
     {
       return a * a;
     }
   private:
     float a; // bok kwadratu
 };

 class Kolo : public Figura 
 {
   public:
     Kolo( const float promien ) : r( promien ) {}

     float pole() const 
     {
       return pi * r * r;
     }
   private:
     float r; // promien kola
 };

 void wyswietlPole( Figura &figura ) 
 {
   std::cout << figura.pole() << std::endl;
   return;
 }

 int main() 
 {
   // deklaracje obiektow:
   Figura jakasFigura;
   Kwadrat jakisKwadrat( 5 );
   Kolo jakiesKolo( 3 );
   Figura *wskJakasFigura = 0; // deklaracja wskaźnika

   // obiekty -------------------------------
   std::cout << jakasFigura.pole() << std::endl; // wynik: -1
   std::cout << jakisKwadrat.pole() << std::endl; // wynik: 25
   std::cout << jakiesKolo.pole() << std::endl; // wynik: 28.274...

   // wskazniki -----------------------------
   wskJakasFigura = &jakasFigura;
   std::cout << wskJakasFigura->pole() << std::endl; // wynik: -1
   wskJakasFigura = &jakisKwadrat;
   std::cout << wskJakasFigura->pole() << std::endl; // wynik: 25
   wskJakasFigura = &jakiesKolo;
   std::cout << wskJakasFigura->pole() << std::endl; // wynik: 28.274...
 
   // referencje -----------------------------
   wyswietlPole( jakasFigura ); // wynik: -1
   wyswietlPole( jakisKwadrat ); // wynik: 25
   wyswietlPole( jakiesKolo ); // wynik: 28.274...

   return 0;
 }

Wywołanie metod składowych dla każdego z obiektów powoduje wykonanie metody odpowiedniej dla klasy danego obiektu. Następnie wskaźnikowi wskJakasFigura zostaje przypisany adres obiektu jakasFigura i zostaje wywołana metoda float pole(). Wynikiem jest "-1" zgodnie z treścią metody float pole() w klasie Figura. Następnie przypisujemy wskaźnikowi adres obiektu klasy Kwadrat - możemy tak zrobić ponieważ klasa Kwadrat jest klasą pochodną od klasy Figura - jest to tzw. rzutowanie w górę. Wywołanie teraz metody float pole() dla wskaźnika nie spowoduje wykonania metody zgodnej z typem wskaźnika - który jest typu Figura* lecz zgodnie z aktualnie wskazywanym obiektem, a więc wykonana zostanie metoda float pole() z klasy Kwadrat (gdyż ostatnie przypisanie wskaźnikowi wartości przypisywało mu adres obiektu klasy Kwadrat). Analogiczna sytuacja dzieje się gdy przypiszemy wskaźnikowi adres obiektu klasy Kolo. Następnie zostaje wykonana funkcja void wyswietlPole(Figura&) która przyjmuje jako parametr obiekt klasy Figura przez referencję. Tutaj również zostały wykonane odpowiednie metody dla obiektów klas pochodnych a nie metoda zgodna z obiektem jaki jest zadeklarowany jako parametr funkcji czyli float Figura::pole(). Takie działanie jest spowodowane przez przyjmowanie obiektu klasy Figura przez referencję. Gdyby obiekty były przyjmowane przez wartość (parametr bez &) zostałaby wykonana 3 krotnie metoda float Figura::pole() i 3 krotnie wyświetlona wartość -1.

Wyżej opisane działanie zostało spowodowane przez określenie metody w klasie bazowej jako wirtualnej. Gdyby zostało usunięte słowo kluczowe virtual w deklaracji metody w klasie bazowej, zostałyby wykonane metody zgodne z typem wskaźnika lub referencji, a więc za każdym razem zostałaby wykonana metoda float pole() z klasy Figura.

Rzutowanie dynamiczne - dynamic_cast

edytuj

Rzutowanie dynamiczne pozwala w czasie wykonywania konwertować wskaźniki lub referencje klas bazowych do klas pochodnych - jest to tzw. rzutowanie w dół (hierarchii). Rzutowanie to realizuje operator dynamic_cast, jednak dostępny jest jedynie dla klas posiadających metody wirtualne (klasy polimorficzne). Ogólnie C++ pozwala na odczytywanie informacji o zależnościach między klasami polimorficznymi, jest to tzw. RTTI (ang. RunTime Type Information), dynamic_cast korzysta z tych danych.

Jakie jest zastosowanie takiego rzutowania? Wyobraźmy, że posiadamy listę figur z przykładu. Figura jednak udostępnia jedynie swój interfejs, a my np. chcielibyśmy wykonać jakieś działanie wyłącznie na obiektach typu Kwadrat. Dzięki dynamic_cast możemy sprawdzić, czy figura jest odpowiedniego typu, dokonać konwersji i używać obiektu Kwadrat w żądany sposób.

Figura* figura = new NazwaFigury(...);

Kwadrat* kwadrat = dynamic_cast<Kwadrat*>(figura);
if (kwadrat)
{
    // działania na kwadracie
}
else 
{
    std::cout << "figura nie jest kwadratem" << '\n';
}

Wynikiem poprawnego rzutowania wskaźników jest niepusty wskaźnik. Jeśli rzutowanie jest niemożliwe wskaźnik jest pusty.

Z kolei wynikiem rzutowania referencji może być tylko referencja, niemożliwość konwersji sygnalizowana jest wyjątkiem std::bad_cast.

Metody i klasy abstrakcyjne

edytuj

Niekiedy tworząc klasy nie wiadomo, jak jedną lub więcej metod zrealizować. Np. są to metody mające zapisywać wyniki - a one mogą być zapisywane do pliku, na konsolę, wysyłane przez sieć, być może użytkownik będzie chciał dostać dodatkowe podsumowanie itp. Czyli dana metoda musi się wykonać, ale z punktu widzenia projektu klasy nie chcemy bądź nie możemy wnikać w szczegóły jej działania.

Wówczas można użyć metod abstrakcyjnych, które posiadają jedynie deklarację (zakończoną dodatkowo "= 0"); takie metody można wywoływać w innych metodach. Klasa posiadająca przynajmniej jedną metodę abstrakcyjną staje się klasą abstrakcyjną i nie można utworzyć instancji takiej klasy. Jedynym sposobem na utworzenie instancji jest odziedziczenie po takiej klasie i dodanie definicji wszystkich metod abstrakcyjnych. Oczywiście możliwe jest dziedziczenie, gdy nie definiuje się wszystkich metod wirtualnych, wówczas taka klasa pochodna nadal jest abstrakcyjna.

Przykład deklaracji:

class KlasaAbstrakcyjna
{
    virtual int wyswietl() = 0;
};

Nadpisywanie metod wirtualnych - override (C++11)

edytuj

Dodanie do klasy pochodnej metody wirtualnej o tej samej nazwie co metoda w klasie bazowej, ale innym zestawie argumentów jest jak najbardziej możliwe - mamy wówczas do czynienia z przeciążeniem nazw funkcji i to od parametrów wywołania zależy, która metoda zostanie uruchomiona.

Jednak dodawanie metody o tej samej nazwie ma w 99% przypadków jeden cel - nadpisanie metody w klasie pochodnej. Problemem jest gdy lista parametrów się nie zgadza (na skutek pomyłki, zmian w klasie bazowej, itp.), wtedy wbrew intencjom wprowadzona jest nowa metoda. Aby zapobiec takim problemom od wersji C++11 dostępny jest nowy kwalifikator metod override, który jasno mówi kompilatorowi, że metodę o podanej nazwie chcemy nadpisać. Jeśli metody o tej nazwie nie ma w klasie bazowej, bądź posiada inną sygnaturę, wówczas zgłaszany jest błąd kompilacji.

class Bazowa
{
    virtual void wyswietl(int);
};

class Pochodna: public Bazowa
{
    virtual void wyswietl(int) override;

    // błąd: różne sygnatury
    // virtual void wyswietl(double) override

    // błąd: brak metody
    // virtual void drukuj() override
};