Menu

Funkcje wirtualne w C++: polimorfizm w praktyce

Funkcje wirtualne pozwalają wskaźnikowi na klasę bazową wywołać w czasie działania programu wersję metody z klasy pochodnej. Poznaj virtual, override, klasy abstrakcyjne i dowiedz się, dlaczego destruktor klasy bazowej musi być wirtualny.

Na tej stronie są działające edytory: edytuj, uruchamiaj i od razu zobacz wynik.

Dlaczego samo dziedziczenie nie wystarcza

Na poprzedniej stronie powstaje hierarchia klas: klasa pochodna dziedziczy składowe po klasie bazowej. Jest jednak haczyk. Gdy wywołujesz metodę przez wskaźnik na klasę bazową, C++ decyduje, którą funkcję uruchomić, na podstawie typu wskaźnika, a nie rzeczywistego typu obiektu. Dlatego Animal*, który w rzeczywistości wskazuje na Dog, nadal wywołuje wersję metody z Animal.

Prawie nigdy nie o to chodzi. Najczęściej masz kolekcję wskaźników na klasę bazową, z których każdy wskazuje na inny obiekt pochodny, i chcesz, by każdy zachowywał się zgodnie ze swoim typem. Umożliwiają to funkcje wirtualne.

Obiekt naprawdę jest typu Dog, a mimo to a->speak() uruchomiło Animal::speak(). Ponieważ speak nie jest wirtualna, kompilator wybrał funkcję w czasie kompilacji na podstawie typu statycznego Animal*. To właśnie ten błąd naprawiają funkcje wirtualne.

Jak uczynić funkcję wirtualną

Dodaj słowo kluczowe virtual do metody w klasie bazowej. Teraz wywołanie jest rozstrzygane w czasie działania programu na podstawie rzeczywistego typu obiektu: to dynamiczne wiązanie (dynamic dispatch).

Jedna pętla po Animal*, trzy różne zachowania. Bazowy wskaźnik "zna" rzeczywisty typ w czasie działania programu i odpowiednio kieruje wywołanie. Ten jeden mechanizm, czyli jeden interfejs i wiele implementacji, to właśnie polimorfizm w C++.

Zauważ, że virtual musi się pojawić tylko w deklaracji bazowej: gdy funkcja jest już wirtualna, automatycznie pozostaje wirtualna w każdej klasie pochodnej. Powtórzenie tego słowa w klasie pochodnej jest opcjonalne i zbędne.

Zawsze używaj słowa kluczowego override

W powyższym przykładzie każda metoda pochodna jest oznaczona jako override. Kod zadziała i bez tego, ale traktuj to oznaczenie jako obowiązkowe. override (C++11) prosi kompilator o sprawdzenie, czy naprawdę nadpisujesz wirtualną funkcję bazową o pasującej sygnaturze. Jeśli subtelnie pomylisz sygnaturę, dostaniesz wyraźny błąd zamiast cichego błędu w działaniu.

struct Animal {
    virtual void speak() const { }   // uwaga: const
};

struct Dog : Animal {
    void speak() { }            // NIE jest const: to NOWA funkcja, a nie nadpisanie!
    void speak() override { }   // błąd: 'speak' niczego nie nadpisuje, dowiadujesz się od razu
};

Bez override pierwsze speak() kompiluje się bez problemu, ale nigdy nie zostanie wywołane przez Animal*, bo jego sygnatura różni się od bazowej (brakuje const). Można by stracić całe popołudnie na zastanawianiu się, dlaczego nadpisana metoda nic nie robi. Z override kompilator wyłapuje niezgodność na miejscu. Dodawaj je do każdej nadpisującej funkcji.

Funkcje czysto wirtualne i klasy abstrakcyjne

Czasem klasa bazowa nie ma sensownego zachowania domyślnego: jaki dźwięk wydaje ogólne "zwierzę"? Wtedy zadeklaruj funkcję jako czysto wirtualną, przypisując = 0. Nie ma ona ciała, a klasa staje się klasą abstrakcyjną, której instancji nie da się utworzyć. Istnieje tylko po to, by zdefiniować interfejs, który klasy pochodne muszą zrealizować.

Każda konkretna podklasa musi zaimplementować area(), inaczej również pozostanie abstrakcyjna. Tak C++ wyraża "interfejsy": klasa abstrakcyjna zawierająca wyłącznie funkcje czysto wirtualne to odpowiednik interfejsu z języków takich jak Java.

Zasada wirtualnego destruktora

Na tej pułapce każdy potyka się przynajmniej raz. Gdy usuwasz obiekt przez delete za pomocą wskaźnika na klasę bazową, C++ wywołuje destruktor, który znajdzie, a jeśli ten destruktor nie jest wirtualny, uruchamia się tylko destruktor bazowy. Część pochodna nigdy nie zostaje zniszczona, a wszystko, co posiadała, wycieka. Standard nazywa to niezdefiniowanym zachowaniem.

Poprawka to jedno słowo: oznacz destruktor bazowy jako virtual. Wtedy delete p uruchamia najpierw ~Derived, a potem ~Base, dokładnie tak, jak powinno.

struct Base {
    virtual ~Base() { cout << "~Base\n"; }   // poprawnie
};
// teraz: najpierw ~Derived, potem ~Base

Praktyczna zasada: gdy tylko klasa ma jakąkolwiek funkcję wirtualną, daj jej też wirtualny destruktor. Jeśli klasa ma być klasą bazową używaną przez wskaźniki, jej destruktor musi być wirtualny.

Częste błędy i pułapki

Kilka kolejnych pułapek, na które warto uważać, gdy funkcje wirtualne nie są już dla ciebie nowością:

Przycinanie obiektów (object slicing). Jeśli przekazujesz lub zapisujesz obiekt pochodny przez wartość w zmiennej bazowej, część pochodna zostaje "odcięta" i zostaje zwykły obiekt bazowy: dynamiczne wiązanie nie dociera już do nadpisanej wersji. Do polimorfizmu zawsze używaj wskaźników albo referencji:

Dog d;
Animal a = d;   // PRZYCIĘTE: a jest teraz tylko Animal, część Dog zniknęła
a.speak();      // uruchamia Animal::speak, mimo że jest wirtualna

Animal& ref = d;   // OK: referencja zachowuje rzeczywisty typ
ref.speak();       // uruchamia Dog::speak

Nie wywołuj funkcji wirtualnych z konstruktorów ani destruktorów. Podczas konstrukcji część pochodna jeszcze nie istnieje, więc wywołanie wirtualne trafia do wersji z bieżącej klasy, a nie do nadpisania w klasie pochodnej. Rzadko o to chodzi.

Dynamiczne wiązanie ma niewielki koszt. Każde wywołanie wirtualne przechodzi przez ukrytą tablicę wskaźników do funkcji ("vtable"), czyli jedno dodatkowe pośrednie odwołanie na wywołanie. To tanie, ale nie darmowe, więc nie oznaczaj funkcji jako wirtualnej, jeśli naprawdę nie potrzebujesz nadpisywania.

Celowe wywołanie wersji bazowej. Wewnątrz nadpisanej metody nadal możesz jawnie wywołać implementację bazową przez Base::method(). Przydaje się to, gdy zachowanie pochodne rozszerza bazowe, zamiast je zastępować.

Dalej: przeciążanie operatorów

Funkcje wirtualne pozwalają obiektom dostosować swoje zachowanie za pomocą wspólnego interfejsu. Następna strona pokazuje, jak dostosować operatory działające na twoich obiektach: dzięki przeciążaniu operatorów możesz nauczyć własne typy reagować na +, ==, << i inne, tak by Vector + Vector albo cout << myObject czytały się równie naturalnie jak przy typach wbudowanych.

Najczęściej zadawane pytania

Czym jest funkcja wirtualna w C++?

Funkcja wirtualna to funkcja składowa zadeklarowana w klasie bazowej ze słowem kluczowym virtual, dzięki czemu przy wywołaniu przez wskaźnik lub referencję do klasy bazowej C++ uruchamia nadpisaną wersję z klasy pochodnej zamiast wersji bazowej. Ten wybór w czasie działania programu nazywa się dynamicznym wiązaniem (dynamic dispatch) i jest podstawą polimorfizmu.

Jaka jest różnica między funkcją wirtualną a czysto wirtualną?

Funkcja wirtualna ma ciało i można ją nadpisać. Funkcja czysto wirtualna jest deklarowana z = 0 i nie ma ciała w klasie bazowej: wymusza, by każda konkretna klasa pochodna dostarczyła implementację. Każda klasa z co najmniej jedną funkcją czysto wirtualną jest klasą abstrakcyjną i nie można utworzyć jej instancji.

Dlaczego klasa bazowa potrzebuje wirtualnego destruktora w C++?

Jeśli usuwasz przez delete obiekt pochodny za pomocą wskaźnika na klasę bazową, a destruktor bazowy nie jest wirtualny, uruchamia się tylko destruktor bazowy: część pochodna nigdy nie zostaje posprzątana, co powoduje wycieki zasobów i jest niezdefiniowanym zachowaniem. Destruktor każdej klasy przeznaczonej do używania polimorficznego oznaczaj jako virtual.

Ilustracja języków programowania w Coddy

Ucz się programowania z Coddy

ZACZNIJ