25.7 — Czyste funkcje wirtualne, abstrakcyjne klasy bazowe i klasy interfejsów

Czysto wirtualne (abstrakcyjne) funkcje i abstrakcyjne klasy bazowe

Jak dotąd wszystkie funkcje wirtualne, które mamy napisane mają ciało (definicję). Jednak C++ pozwala na utworzenie specjalnego rodzaju funkcji wirtualnej zwanej czystą funkcją wirtualną (lub funkcją abstrakcyjną), która w ogóle nie ma treści! Czysta funkcja wirtualna po prostu działa jako element zastępczy, który ma być ponownie zdefiniowany przez klasy pochodne.

Aby utworzyć czystą funkcję wirtualną, zamiast definiować treść funkcji, po prostu przypisujemy jej wartość 0.

#include <string_view>

class Base
{
public:
    std::string_view sayHi() const { return "Hi"; } // a normal non-virtual function    

    virtual std::string_view getName() const { return "Base"; } // a normal virtual function

    virtual int getValue() const = 0; // a pure virtual function

    int doSomething() = 0; // Compile error: can not set non-virtual functions to 0
};

Kiedy dodajemy do naszej klasy czysto wirtualną funkcję, w praktyce mówimy: „Zaimplementowanie tej funkcji należy do klas pochodnych”.

Używanie czystej funkcji wirtualnej ma dwie główne konsekwencje: Po pierwsze, wszelkie klasa z jedną lub większą liczbą czystych funkcji wirtualnych staje się abstrakcyjną klasą bazową, co oznacza, że nie można jej utworzyć! Zastanów się, co by się stało, gdybyśmy mogli utworzyć instancję Base:

int main()
{
    Base base {}; // We can't instantiate an abstract base class, but for the sake of example, pretend this was allowed
    base.getValue(); // what would this do?

    return 0;
}

Ponieważ nie ma definicji getValue(), do czego rozwiązałaby się base.getValue()?

Po drugie, każda klasa pochodna musi definiować treść tej funkcji, w przeciwnym razie ta klasa pochodna również będzie uważana za abstrakcyjną klasę bazową.

Przykład czystej funkcji wirtualnej

Przyjrzyjmy się na przykładzie czystej funkcji wirtualnej w działaniu. W poprzedniej lekcji napisaliśmy prostą klasę bazową Animal i wyprowadziliśmy z niej klasę Cat i Dog. Oto kod w takiej postaci, w jakiej go zostawiliśmy:

#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 "???"; }
    
    virtual ~Animal() = default;
};

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

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

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

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

Uniemożliwiliśmy ludziom przydzielanie obiektów typu Animal, chroniąc konstruktor. Jednak nadal możliwe jest tworzenie klas pochodnych, które nie definiują na nowo funkcji mówi().

Na przykład:

#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 "???"; }
    
    virtual ~Animal() = default;
};

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

    // We forgot to redefine speak
};

int main()
{
    Cow cow{"Betsy"};
    std::cout << cow.getName() << " says " << cow.speak() << '\n';

    return 0;
}

Wyświetli to:

Betsy says ???

Co się stało? Zapomnieliśmy przedefiniować funkcję mówić(), więc cow.Speak() została zmieniona na Animal.speak(), a nie o to nam chodziło.

Lepszym rozwiązaniem tego problemu jest użycie czysto wirtualnej funkcji:

#include <string>
#include <string_view>

class Animal // This Animal is an abstract base class
{
protected:
    std::string m_name {};

public:
    Animal(std::string_view name)
        : m_name{ name }
    {
    }

    const std::string& getName() const { return m_name; }
    virtual std::string_view speak() const = 0; // note that speak is now a pure virtual function
    
    virtual ~Animal() = default;
};

Jest tu kilka rzeczy, na które warto zwrócić uwagę. Po pierwsze,peak() jest teraz funkcją czysto wirtualną. Oznacza to, że Animal jest teraz abstrakcyjną klasą bazową i nie można utworzyć jej instancji. W rezultacie nie musimy już chronić konstruktora (choć nie zaszkodzi). Po drugie, ponieważ nasza klasa Cow wywodzi się z klasy Animal, ale nie zdefiniowaliśmy Cow::speak(), Cow jest również abstrakcyjną klasą bazową. Teraz, gdy spróbujemy skompilować ten kod:

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

class Animal // This Animal is an abstract base class
{
protected:
    std::string m_name {};

public:
    Animal(std::string_view name)
        : m_name{ name }
    {
    }

    const std::string& getName() const { return m_name; }
    virtual std::string_view speak() const = 0; // note that speak is now a pure virtual function
    
    virtual ~Animal() = default;
};

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

    // We forgot to redefine speak
};

int main()
{
    Cow cow{ "Betsy" };
    std::cout << cow.getName() << " says " << cow.speak() << '\n';

    return 0;
}

Kompilator zwróci nam błąd, ponieważ Cow jest abstrakcyjną klasą bazową i nie możemy utworzyć instancji abstrakcyjnych klas bazowych:

prog.cc:35:9: error: variable type 'Cow' is an abstract class
   35 |     Cow cow{ "Betsy" };
      |         ^
prog.cc:17:30: note: unimplemented pure virtual method 'speak' in 'Cow'
   17 |     virtual std::string_view speak() const = 0; // note that speak is now a pure virtual function
      |                              ^

To mówi nam, że będziemy mogli utworzyć instancję Cow tylko wtedy, gdy Cow dostarczy treść funkcji mówienia().

No to zróbmy to:

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

class Animal // This Animal is an abstract base class
{
protected:
    std::string m_name {};

public:
    Animal(std::string_view name)
        : m_name{ name }
    {
    }

    const std::string& getName() const { return m_name; }
    virtual std::string_view speak() const = 0; // note that speak is now a pure virtual function
    
    virtual ~Animal() = default;
};

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

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

int main()
{
    Cow cow{ "Betsy" };
    std::cout << cow.getName() << " says " << cow.speak() << '\n';

    return 0;
}

Teraz program się skompiluje i print:

Betsy says Moo

Czysta funkcja wirtualna przydaje się, gdy mamy funkcję, którą chcemy umieścić w klasie bazowej, ale tylko klasy pochodne wiedzą, co powinna zwrócić. Czysta funkcja wirtualna sprawia, że ​​nie można utworzyć instancji klasy bazowej, a klasy pochodne są zmuszone do zdefiniowania tych funkcji, zanim będzie można je utworzyć. Pomaga to zapewnić, że klasy pochodne nie zapomną o przedefiniowaniu funkcji, których oczekiwała od nich klasa bazowa.

Podobnie jak w przypadku normalnych funkcji wirtualnych, czyste funkcje wirtualne można wywoływać za pomocą referencji (lub wskaźnika) do klasy bazowej:

int main()
{
    Cow cow{ "Betsy" };
    Animal& a{ cow };

    std::cout << a.speak(); // resolves to Cow::speak(), prints "Moo"

    return 0;
}

W powyższym przykładzie a.speak() rozwiązuje się do Cow::speak() poprzez rozpoznawanie funkcji wirtualnych.

Przypomnienie

Każda klasa z funkcjami czysto wirtualnymi powinna również mieć wirtualny destruktor.

Funkcje czysto wirtualne z definicje

Okazuje się, że możemy stworzyć funkcje czysto wirtualne posiadające definicje:

#include <string>
#include <string_view>

class Animal // This Animal is an abstract base class
{
protected:
    std::string m_name {};

public:
    Animal(std::string_view name)
        : m_name{ name }
    {
    }

    const std::string& getName() { return m_name; }
    virtual std::string_view speak() const = 0; // The = 0 means this function is pure virtual
    
    virtual ~Animal() = default;
};

std::string_view Animal::speak() const  // even though it has a definition
{
    return "buzz";
}

W tym przypadku funkcjapeak() jest nadal uważana za funkcję czysto wirtualną ze względu na „= 0” (mimo że podano jej definicję), a Animal jest nadal uważana za abstrakcyjną klasę bazową (i dlatego nie można jej utworzyć). Każda klasa, która dziedziczy po Animal, musi podać własną definicję funkcjipeak(), w przeciwnym razie będzie również uważana za abstrakcyjną klasę bazową.

Podając definicję czystej funkcji wirtualnej, definicję należy podać osobno (nie wbudowaną).

W przypadku użytkowników programu Visual Studio

Visual Studio pozwala, aby deklaracje funkcji czysto wirtualnych były definicjami, na przykład:

virtual std::string_view speak() const = 0
{
  return "buzz";
}

Jest to niezgodne ze standardem C++ i nie może być wyłączone.

Ten paradygmat może być przydatny, gdy chcesz, aby Twoja klasa bazowa zapewniała domyślną implementację funkcji, ale jednocześnie wymuszaj na klasach pochodnych zapewnienie własnej implementacji. Jeśli jednak klasa pochodna jest zadowolona z domyślnej implementacji dostarczonej przez klasę bazową, może po prostu bezpośrednio wywołać implementację klasy bazowej. Na przykład:

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

class Animal // This Animal is an abstract base class
{
protected:
    std::string m_name {};

public:
    Animal(std::string_view name)
        : m_name(name)
    {
    }

    const std::string& getName() const { return m_name; }
    virtual std::string_view speak() const = 0; // note that speak is a pure virtual function
    
    virtual ~Animal() = default;
};

std::string_view Animal::speak() const
{
    return "buzz"; // some default implementation
}

class Dragonfly: public Animal
{

public:
    Dragonfly(std::string_view name)
        : Animal{name}
    {
    }

    std::string_view speak() const override// this class is no longer abstract because we defined this function
    {
        return Animal::speak(); // use Animal's default implementation
    }
};

int main()
{
    Dragonfly dfly{"Sally"};
    std::cout << dfly.getName() << " says " << dfly.speak() << '\n';

    return 0;
}

Powyższy kod wypisuje:

Sally says buzz

Ta możliwość nie jest używana zbyt często.

Destruktor może być czysto wirtualny, ale musi mieć podaną definicję, aby można go było wywołać, gdy obiekt pochodny ulegnie zniszczeniu.

Klasy interfejsu

An klasa interfejsu to klasa, która nie ma zmiennych składowych, oraz gdzie uniknąć funkcje są czysto wirtualne! Interfejsy są przydatne, gdy chcesz zdefiniować funkcjonalność, którą klasy pochodne muszą implementować, ale szczegóły dotyczące sposobu, w jaki klasa pochodna implementuje tę funkcjonalność, pozostawiaj całkowicie klasie pochodnej.

Nazwy klas interfejsów często zaczynają się od litery I. Oto przykładowa klasa interfejsu:

#include <string_view>

class IErrorLog
{
public:
    virtual bool openLog(std::string_view filename) = 0;
    virtual bool closeLog() = 0;

    virtual bool writeError(std::string_view errorMessage) = 0;

    virtual ~IErrorLog() {} // make a virtual destructor in case we delete an IErrorLog pointer, so the proper derived destructor is called
};

Każda klasa dziedzicząca z IErrorLog musi zapewniać implementacje wszystkich trzech funkcji, aby mogła zostać utworzona. Można utworzyć klasę o nazwie FileErrorLog, w której openLog() otwiera plik na dysku, closeLog() zamyka plik, a writeError() zapisuje komunikat do pliku. Można utworzyć inną klasę o nazwie ScreenErrorLog, w której openLog() i closeLog() nic nie robią, a writeError() wyświetla komunikat w wyskakującym okienku komunikatu na ekranie.

Załóżmy teraz, że musisz napisać kod korzystający z dziennika błędów. Jeśli napiszesz swój kod tak, aby zawierał bezpośrednio FileErrorLog lub ScreenErrorLog, w rzeczywistości utkniesz w korzystaniu z tego rodzaju dziennika błędów (przynajmniej bez przekodowania programu). Na przykład poniższa funkcja skutecznie zmusza osoby wywołujące mySqrt() do użycia FileErrorLog, który może być tym, czego chcą, ale nie musi.

#include <cmath> // for sqrt()

double mySqrt(double value, FileErrorLog& log)
{
    if (value < 0.0)
    {
        log.writeError("Tried to take square root of value less than 0");
        return 0.0;
    }

    return std::sqrt(value);
}

Znacznie lepszym sposobem zaimplementowania tej funkcji jest użycie zamiast tego IErrorLog:

#include <cmath> // for sqrt()
double mySqrt(double value, IErrorLog& log)
{
    if (value < 0.0)
    {
        log.writeError("Tried to take square root of value less than 0");
        return 0.0;
    }

    return std::sqrt(value);
}

Teraz wywołujący może przekazać dowolnego klasę zgodną z interfejsem IErrorLog. Jeśli chcą, aby błąd trafił do pliku, mogą przekazać instancję FileErrorLog. Jeśli chcą, aby trafiło na ekran, mogą przekazać instancję ScreenErrorLog. Lub jeśli chcą zrobić coś, o czym nawet nie pomyślałeś, na przykład wysłać komuś e-mail w przypadku błędu, mogą wyprowadzić nową klasę z IErrorLog (np. EmailErrorLog) i użyć jej instancji! Używając IErrorLog, Twoja funkcja staje się bardziej niezależna i elastyczna.

Nie zapomnij dołączyć wirtualnego destruktora dla klas interfejsu, aby odpowiedni destruktor pochodny został wywołany w przypadku usunięcia wskaźnika do interfejsu.

Klasy interfejsów stały się niezwykle popularne, ponieważ są łatwe w użyciu, łatwe w rozbudowie i łatwe w utrzymaniu. W rzeczywistości niektóre współczesne języki, takie jak Java i C#, dodały słowo kluczowe „interfejs”, które pozwala programistom bezpośrednio zdefiniować klasę interfejsu bez konieczności jawnego oznaczania wszystkich funkcji składowych jako abstrakcyjnych. Co więcej, chociaż Java i C# nie pozwalają na wielokrotne dziedziczenie w normalnych klasach, pozwalają na wielokrotne dziedziczenie dowolnej liczby interfejsów. Ponieważ interfejsy nie zawierają danych ani ciał funkcji, pozwalają uniknąć wielu tradycyjnych problemów związanych z wielokrotnym dziedziczeniem, a jednocześnie zapewniają dużą elastyczność.

Czysto wirtualne funkcje i wirtualna tabela

Dla zachowania spójności klasy abstrakcyjne nadal mają wirtualne tabele. Konstruktor lub destruktor klasy abstrakcyjnej może wywołać funkcję wirtualną i musi ona zostać rozstrzygnięta na właściwą funkcję (w tej samej klasie, ponieważ klasy pochodne albo nie zostały jeszcze skonstruowane, albo zostały już zniszczone).

Wpis w tabeli wirtualnej dla klasy z funkcją czysto wirtualną będzie zazwyczaj albo zawierał wskaźnik zerowy, albo wskazywał funkcję ogólną, która wypisuje błąd (czasami funkcja ta nosi nazwę __purecall).

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:  
234 Komentarze
Najnowsze
Najstarsze Najczęściej głosowane
Wbudowane opinie
Wyświetl wszystkie komentarze