25.2 -- Funkcje wirtualne i polimorfizm

W poprzedniej lekcji na temat wskaźniki i odniesienia do klasy bazowej obiektów pochodnych, przyjrzeliśmy się wielu przykładom, w których użycie wskaźników lub odwołań do klasy bazowej może potencjalnie uprościć kod. Jednak w każdym przypadku napotykaliśmy problem polegający na tym, że wskaźnik bazowy lub odwołanie było w stanie wywołać jedynie podstawową wersję funkcji, a nie wersję pochodną.

Oto prosty przykład takiego zachowania:

#include <iostream>
#include <string_view>

class Base
{
public:
    std::string_view getName() const { return "Base"; }
};

class Derived: public Base
{
public:
    std::string_view getName() const { return "Derived"; }
};

int main()
{
    Derived derived {};
    Base& rBase{ derived };
    std::cout << "rBase is a " << rBase.getName() << '\n';

    return 0;
}

Ten przykład wypisuje wynik:

rBase is a Base

Ponieważ rBase jest referencją bazową, wywołuje Base::getName(), nawet jeśli w rzeczywistości odwołuje się do części bazowej funkcji pochodnej obiektu.

W tej lekcji pokażemy, jak rozwiązać ten problem za pomocą funkcji wirtualnych.

Funkcje wirtualne

A funkcja wirtualna to specjalny typ funkcji składowej, która po wywołaniu daje najbardziej pochodną wersję funkcji dla rzeczywistego typu obiektu, do którego się odwołuje lub na który wskazuje.

Funkcja pochodna jest uważana za dopasowaną, jeśli ma ten sam podpis (nazwa, typy parametrów i to, czy jest to const) i typ zwracany, co podstawowa wersja funkcji. Takie funkcje nazywane są przesłonięciami.

Aby uczynić funkcję wirtualną, wystarczy umieścić słowo kluczowe „virtual” przed deklaracją funkcji.

Oto powyższy przykład z funkcją wirtualną:

#include <iostream>
#include <string_view>

class Base
{
public:
    virtual std::string_view getName() const { return "Base"; } // note addition of virtual keyword
};

class Derived: public Base
{
public:
    virtual std::string_view getName() const { return "Derived"; }
};

int main()
{
    Derived derived {};
    Base& rBase{ derived };
    std::cout << "rBase is a " << rBase.getName() << '\n';

    return 0;
}

Ten przykład wypisuje wynik:

rBase is a Derived

Wskazówka

Niektóre nowoczesne kompilatory mogą zgłaszać błąd dotyczący posiadania funkcji wirtualnych i dostępnego niewirtualnego destruktora. W takim przypadku dodaj wirtualny destruktor do klasy bazowej. W powyższym programie dodaj to do definicji Base:

    virtual ~Base() = default;

Wirtualne destruktory omawiamy na lekcji 25.4 -- Wirtualne destruktory, przypisanie wirtualne i wirtualizacja zastępująca.

Ponieważ rBase jest odniesieniem do części bazowej obiektu pochodnego, gdy oceniana jest funkcja rBase.getName() , zwykle zostanie ona rozwiązana na Base::getName(). Jednak Base::getName() jest wirtualna, co nakazuje programowi sprawdzenie, czy dla obiektu Derived dostępne są więcej pochodnych wersji funkcji. W tym przypadku zostanie rozwiązany Derived::getName()!

Przyjrzyjmy się nieco bardziej złożonemu przykładowi:

#include <iostream>
#include <string_view>

class A
{
public:
    virtual std::string_view getName() const { return "A"; }
};

class B: public A
{
public:
    virtual std::string_view getName() const { return "B"; }
};

class C: public B
{
public:
    virtual std::string_view getName() const { return "C"; }
};

class D: public C
{
public:
    virtual std::string_view getName() const { return "D"; }
};

int main()
{
    C c {};
    A& rBase{ c };
    std::cout << "rBase is a " << rBase.getName() << '\n';

    return 0;
}

Jak myślisz, co wyświetli ten program?

Spójrzmy, jak to działa. Najpierw tworzymy instancję obiektu klasy C. rBase jest referencją A, którą ustawiamy tak, aby odnosiła się do części A obiektu C. Na koniec wywołujemy rBase.getName(). rBase.getName() daje w wyniku A::getName(). Jednakże funkcja A::getName() jest wirtualna, więc kompilator wywoła najbardziej pochodne dopasowanie pomiędzy A i C. W tym przypadku jest to C::getName(). Zauważ, że nie wywoła to D::getName(), ponieważ naszym pierwotnym obiektem był C, a nie D, więc brane są pod uwagę tylko funkcje pomiędzy A i C.

W rezultacie nasz program wyprowadza:

rBase is a C

Zauważ, że rozpoznawanie funkcji wirtualnych działa tylko wtedy, gdy funkcja elementu wirtualnego jest wywoływana poprzez wskaźnik lub odwołanie do obiektu typu klasy. Działa to, ponieważ kompilator może odróżnić typ wskaźnika lub odniesienia od typu obiektu, na który wskazuje lub do którego się odwołuje. Widzimy to w powyższym przykładzie.

Wywołanie wirtualnej funkcji składowej bezpośrednio na obiekcie (a nie poprzez wskaźnik lub odwołanie) zawsze wywoła funkcję składową należącą do tego samego typu obiektu. Na przykład:

C c{};
std::cout << c.getName(); // will always call C::getName

A a { c }; // copies the A portion of c into a (don't do this)
std::cout << a.getName(); // will always call A::getName

Kluczowa informacja

Rozpoznawanie funkcji wirtualnych działa tylko wtedy, gdy funkcja składowa jest wywoływana poprzez wskaźnik lub odniesienie do obiektu typu klasy.

Polimorfizm

W programowaniu polimorfizm odnosi się do zdolności jednostki do posiadania wielu form (termin „polimorfizm” dosłownie oznacza „wiele form”). Rozważmy na przykład następujące dwie deklaracje funkcji:

int add(int, int);
double add(double, double);

Identyfikator add ma dwie formy: add(int, int) i add(double, double).

Polimorfizm w czasie kompilacji odnosi się do form polimorfizmu, które są rozpoznawane przez kompilator. Należą do nich rozdzielczość przeciążenia funkcji, a także rozdzielczość szablonu.

Polimorfizm w czasie wykonywania odnosi się do form polimorfizmu, które są rozwiązywane w czasie wykonywania. Obejmuje to rozpoznawanie funkcji wirtualnych.

Bardziej złożony przykład

Przyjrzyjmy się jeszcze raz przykładowi Animal, z którym pracowaliśmy w poprzedniej lekcji. Oto oryginalna klasa wraz z kodem testowym:

#include <iostream>
#include <string>
#include <string_view>

class Animal
{
protected:
    std::string m_name {};

    // We're making this constructor protected because
    // we don't want people creating Animal objects directly,
    // but we still want derived classes to be able to use it.
    Animal(std::string_view name)
        : m_name{ name }
    {
    }

public:
    const std::string& getName() const { return m_name; }
    std::string_view speak() const { return "???"; }
};

class Cat: public Animal
{
public:
    Cat(std::string_view name)
        : Animal{ name }
    {
    }

    std::string_view speak() const { return "Meow"; }
};

class Dog: public Animal
{
public:
    Dog(std::string_view name)
        : Animal{ name }
    {
    }

    std::string_view speak() const { return "Woof"; }
};

void report(const Animal& animal)
{
    std::cout << animal.getName() << " says " << animal.speak() << '\n';
}

int main()
{
    Cat cat{ "Fred" };
    Dog dog{ "Garbo" };

    report(cat);
    report(dog);

    return 0;
}

Wypisuje:

Fred says ???
Garbo says ???

Oto równoważna klasa z wirtualną funkcjąpeak():

#include <iostream>
#include <string>
#include <string_view>

class Animal
{
protected:
    std::string m_name {};

    // We're making this constructor protected because
    // we don't want people creating Animal objects directly,
    // but we still want derived classes to be able to use it.
    Animal(std::string_view name)
        : m_name{ name }
    {
    }

public:
    const std::string& getName() const { return m_name; }
    virtual std::string_view speak() const { return "???"; }
};

class Cat: public Animal
{
public:
    Cat(std::string_view name)
        : Animal{ name }
    {
    }

    virtual std::string_view speak() const { return "Meow"; }
};

class Dog: public Animal
{
public:
    Dog(std::string_view name)
        : Animal{ name }
    {
    }

    virtual std::string_view speak() const { return "Woof"; }
};

void report(const Animal& animal)
{
    std::cout << animal.getName() << " says " << animal.speak() << '\n';
}

int main()
{
    Cat cat{ "Fred" };
    Dog dog{ "Garbo" };

    report(cat);
    report(dog);

    return 0;
}

Ten program generuje wynik:

Fred says Meow
Garbo says Woof

To działa!

Gdy oceniana jest funkcja Animal.speak(), program zauważa, że ​​Animal::speak() jest funkcją wirtualną. W przypadku, gdy zwierzę odwołuje się do części Animal obiektu Cat, program sprawdza wszystkie klasy pomiędzy Animal i Cat, aby sprawdzić, czy może znaleźć bardziej pochodną funkcję. W takim przypadku znajduje Cat::speak(). W przypadku, gdy zwierzę odwołuje się do części Animal obiektu Dog, program rozwiązuje wywołanie funkcji Dog::speak().

Zauważ, że nie uczyniliśmy Animal::getName() wirtualnym. Dzieje się tak, ponieważ funkcja getName() nigdy nie jest zastępowana w żadnej z klas pochodnych, dlatego nie ma takiej potrzeby.

Podobnie następujący przykład tablicy działa teraz zgodnie z oczekiwaniami:

Cat fred{ "Fred" };
Cat misty{ "Misty" };
Cat zeke{ "Zeke" };
 
Dog garbo{ "Garbo" };
Dog pooky{ "Pooky" };
Dog truffle{ "Truffle" };

// Set up an array of pointers to animals, and set those pointers to our Cat and Dog objects
Animal* animals[]{ &fred, &garbo, &misty, &pooky, &truffle, &zeke };

for (const auto* animal : animals)
    std::cout << animal->getName() << " says " << animal->speak() << '\n';

Co daje wynik:

Fred says Meow
Garbo says Woof
Misty says Meow
Pooky says Woof
Truffle says Woof
Zeke says Meow

Nawet jeśli w tych dwóch przykładach używane są tylko Kot i Pies, wszelkie inne klasy, które wywodzimy z Animal, będą również działać z naszą funkcją report() i tablicą zwierząt bez dalszych modyfikacji! To chyba największa zaleta funkcji wirtualnych — możliwość ustrukturyzowania kodu w taki sposób, że nowo wyprowadzone klasy będą automatycznie działać ze starym kodem bez modyfikacji!

Słowo ostrzeżenia: sygnatura funkcji klasy pochodnej musi dokładnie pasować do sygnatury funkcji wirtualnej klasy bazowej, aby funkcja klasy pochodnej mogła zostać użyta. Jeśli funkcja klasy pochodnej ma różne typy parametrów, program prawdopodobnie nadal będzie się poprawnie kompilował, ale funkcja wirtualna nie zostanie rozwiązana zgodnie z zamierzeniami. Na następnej lekcji omówimy, jak się przed tym uchronić.

Zauważ, że jeśli funkcja jest oznaczona jako wirtualna, wszystkie pasujące przesłonięcia w klasach pochodnych są również domyślnie uważane za wirtualne, nawet jeśli nie są wyraźnie oznaczone jako takie.

Reguła

Jeśli funkcja jest wirtualna, wszystkie pasujące przesłonięcia w klasach pochodnych są domyślnie wirtualne.

To nie działa w drugą stronę — wirtualne przesłonięcie w klasie pochodnej nie powoduje w sposób dorozumiany wirtualnej funkcji klasy bazowej.

Typy zwracanych funkcji wirtualnych

W normalnych okolicznościach typ zwracany przez funkcję wirtualną i jej przesłonięcie muszą być zgodne. Rozważmy następujący przykład:

class Base
{
public:
    virtual int getValue() const { return 5; }
};

class Derived: public Base
{
public:
    virtual double getValue() const { return 6.78; }
};

W tym przypadku funkcja Derived::getValue() nie jest uważana za pasujące nadpisanie dla Base::getValue() i kompilacja zakończy się niepowodzeniem.

Nie wywołuj funkcji wirtualnych z konstruktorów lub destruktorów

Oto kolejny problem, który często wychwytuje niczego niepodejrzewających nowych programistów. Nie powinieneś wywoływać funkcji wirtualnych z konstruktorów lub destruktorów. Dlaczego?

Pamiętaj, że kiedy tworzona jest klasa pochodna, najpierw tworzona jest część podstawowa. Jeśli wywołasz funkcję wirtualną z konstruktora Base, a część klasy Derived nie została jeszcze utworzona, nie będzie można wywołać wersji funkcji Derived, ponieważ nie ma obiektu Derived, nad którym funkcja Derived mogłaby pracować. W C++ zamiast tego wywoła wersję podstawową.

Podobny problem występuje w przypadku destruktorów. Jeśli wywołasz funkcję wirtualną w destruktorze klasy Base, zawsze zostanie ona przetłumaczona na wersję funkcji klasy Base, ponieważ część pochodna klasy zostanie już zniszczona.

Najlepsza praktyka

Nigdy nie wywołuj funkcji wirtualnych z konstruktorów lub destruktorów.

Wadą funkcji wirtualnych

Skoro w większości przypadków chcesz, aby Twoje funkcje były wirtualne, dlaczego po prostu nie uczynić wszystkich funkcji wirtualnymi? Odpowiedź jest taka, że ​​jest nieefektywna — rozwiązanie wywołania funkcji wirtualnej zajmuje więcej czasu niż rozwiązanie zwykłego.

Ponadto, aby funkcje wirtualne działały, kompilator musi przydzielić dodatkowy wskaźnik dla każdego obiektu klasy, który ma funkcje wirtualne. Dodaje to dużo narzutu do obiektów, które w przeciwnym razie miałyby mały rozmiar. Porozmawiamy o tym szerzej na przyszłych lekcjach w tym rozdziale.

Czas quizu

  1. Co drukują poniższe programy? To ćwiczenie należy wykonać poprzez inspekcję, a nie kompilację przykładów za pomocą kompilatora.

1a)

#include <iostream>
#include <string_view>

class A
{
public:
    virtual std::string_view getName() const { return "A"; }
};

class B: public A
{
public:
    virtual std::string_view getName() const { return "B"; }
};

class C: public B
{
public:
// Note: no getName() function here
};

class D: public C
{
public:
    virtual std::string_view getName() const { return "D"; }
};

int main()
{
    C c {};
    A& rBase{ c };
    std::cout << rBase.getName() << '\n';

    return 0;
}

Pokaż rozwiązanie

1b)

#include <iostream>
#include <string_view>

class A
{
public:
    virtual std::string_view getName() const { return "A"; }
};

class B: public A
{
public:
    virtual std::string_view getName() const { return "B"; }
};

class C: public B
{
public:
    virtual std::string_view getName() const { return "C"; }
};

class D: public C
{
public:
    virtual std::string_view getName() const { return "D"; }
};

int main()
{
    C c;
    B& rBase{ c }; // note: rBase is a B this time
    std::cout << rBase.getName() << '\n';

    return 0;
}

Pokaż rozwiązanie

1c)

#include <iostream>
#include <string_view>

class A
{
public:
    // note: no virtual keyword
    std::string_view getName() const { return "A"; }
};

class B: public A
{
public:
    virtual std::string_view getName() const { return "B"; }
};

class C: public B
{
public:
    virtual std::string_view getName() const { return "C"; }
};

class D: public C
{
public:
    virtual std::string_view getName() const { return "D"; }
};

int main()
{
    C c {};
    A& rBase{ c };
    std::cout << rBase.getName() << '\n';

    return 0;
}

Pokaż rozwiązanie

1d)

#include <iostream>
#include <string_view>

class A
{
public:
    virtual std::string_view getName() const { return "A"; }
};

class B: public A
{
public:
    // note: no virtual keyword in B, C, and D
    std::string_view getName() const { return "B"; }
};

class C: public B
{
public:
    std::string_view getName() const { return "C"; }
};

class D: public C
{
public:
    std::string_view getName() const { return "D"; } 
};

int main()
{
    C c {};
    B& rBase{ c }; // note: rBase is a B this time
    std::cout << rBase.getName() << '\n';

    return 0;
}

Pokaż rozwiązanie

1e)

#include <iostream>
#include <string_view>

class A
{
public:
    virtual std::string_view getName() const { return "A"; }
};

class B: public A
{
public:
    // Note: Functions in B, C, and D are non-const.
    virtual std::string_view getName() { return "B"; }
};

class C: public B
{
public:
    virtual std::string_view getName() { return "C"; }
};

class D: public C
{
public:
    virtual std::string_view getName() { return "D"; }
};

int main()
{
    C c {};
    A& rBase{ c };
    std::cout << rBase.getName() << '\n';

    return 0;
}

Pokaż rozwiązanie

1f)

#include <iostream>
#include <string_view>

class A
{
public:
	A() { std::cout << getName(); } // note addition of constructor (getName() now called from here)

	virtual std::string_view getName() const { return "A"; }
};

class B : public A
{
public:
	virtual std::string_view getName() const { return "B"; }
};

class C : public B
{
public:
	virtual std::string_view getName() const { return "C"; }
};

class D : public C
{
public:
	virtual std::string_view getName() const { return "D"; }
};

int main()
{
	C c {};

	return 0;
}

Pokaż rozwiązanie

guest
Twój adres e-mail nie zostanie wyświetlony
Znalazłeś błąd? Zostaw komentarz powyżej!
Komentarze związane z poprawkami zostaną usunięte po przetworzeniu, aby pomóc zmniejszyć bałagan. Dziękujemy za pomoc w ulepszaniu witryny dla wszystkich!
Awatary z https://gravatar.com/ są połączone z podanym adresem e-mail.
Powiadamiaj mnie o odpowiedziach:  
278 Komentarze
Najnowsze
Najstarsze Najczęściej głosowane
Wbudowane opinie
Wyświetl wszystkie komentarze