24.4 — Konstruktory i inicjalizacja klas pochodnych

W ciągu ostatnich dwóch lekcji omówiliśmy pewne podstawy dotyczące dziedziczenia w C++ i kolejności inicjowania klas pochodnych. W tej lekcji przyjrzymy się bliżej roli konstruktorów w inicjalizacji klas pochodnych. W tym celu będziemy w dalszym ciągu korzystać z prostych klas Base i Derived, które opracowaliśmy w poprzedniej lekcji:

class Base
{
public:
    int m_id {};
 
    Base(int id=0)
        : m_id{ id }
    {
    }
 
    int getId() const { return m_id; }
};
 
class Derived: public Base
{
public:
    double m_cost {};
 
    Derived(double cost=0.0)
        : m_cost{ cost }
    {
    }
 
    double getCost() const { return m_cost; }
};

W przypadku klas niepochodnych konstruktorzy muszą martwić się jedynie o własne składowe. Rozważmy na przykład Bazę. Możemy stworzyć obiekt Base w następujący sposób:

int main()
{
    Base base{ 5 }; // use Base(int) constructor

    return 0;
}

Oto co faktycznie się dzieje, gdy tworzona jest instancja bazy:

  1. Pamięć dla bazy jest odkładana
  2. Wywoływany jest odpowiedni konstruktor Base
  3. Lista inicjatorów składowych inicjuje zmienne
  4. Treść konstruktora jest wykonywana
  5. Kontrola jest zwracana do obiektu wywołującego

To całkiem proste. W przypadku klas pochodnych sytuacja jest nieco bardziej złożona:

int main()
{
    Derived derived{ 1.3 }; // use Derived(double) constructor

    return 0;
}

Oto, co faktycznie dzieje się po utworzeniu instancji klasy pochodnej:

  1. Pamięć dla pochodnych jest rezerwowana (wystarczająca zarówno dla części Base, jak i Derived)
  2. Wywoływany jest odpowiedni konstruktor Derived
  3. Obiekt Base jest najpierw konstruowany przy użyciu odpowiedniego konstruktora Base. Jeśli nie określono konstruktora bazowego, zostanie użyty konstruktor domyślny.
  4. Lista inicjatorów składowych inicjuje zmienne
  5. Treść konstruktora jest wykonywana
  6. Kontrola jest zwracana do obiektu wywołującego

Jedyna rzeczywista różnica między tym przypadkiem a przypadkiem niedziedziczonym polega na tym, że zanim konstruktor Derived będzie mógł zrobić cokolwiek istotnego, najpierw wywoływany jest konstruktor Base. Konstruktor Base konfiguruje część Base obiektu, kontrola jest zwracana do konstruktora Derived, a konstruktor Derived może dokończyć swoje zadanie.

Inicjowanie elementów klasy bazowej

Jedną z obecnych wad naszej klasy Derived w zapisanej formie jest to, że nie ma możliwości inicjalizacji m_id podczas tworzenia obiektu Derived. A co jeśli chcemy ustawić zarówno m_cost (z części pochodnej obiektu), jak i m_id (z części bazowej obiektu), kiedy tworzymy obiekt pochodny?

Nowi programiści często próbują rozwiązać ten problem w następujący sposób:

class Derived: public Base
{
public:
    double m_cost {};

    Derived(double cost=0.0, int id=0)
        // does not work
        : m_cost{ cost }
        , m_id{ id }
    {
    }

    double getCost() const { return m_cost; }
};

To dobra próba i prawie słuszny pomysł. Zdecydowanie musimy dodać kolejny parametr do naszego konstruktora, w przeciwnym razie C++ nie będzie w stanie dowiedzieć się, do jakiej wartości chcemy zainicjować m_id.

Jednak C++ uniemożliwia klasom inicjowanie dziedziczonych zmiennych składowych na liście inicjatorów składowych konstruktora. Innymi słowy, wartość zmiennej składowej można ustawić tylko na liście inicjatorów składowych konstruktora należącego do tej samej klasy co zmienna.

Dlaczego C++ to robi? Odpowiedź dotyczy zmiennych stałych i referencyjnych. Zastanów się, co by się stało, gdyby m_id było stałą. Ponieważ zmienne const muszą zostać zainicjowane wartością w momencie tworzenia, konstruktor klasy bazowej musi ustawić jej wartość podczas tworzenia zmiennej. Jednak po zakończeniu działania konstruktora klasy bazowej wykonywane są listy inicjatorów składowych konstruktora klasy pochodnej. Każda klasa pochodna miałaby wtedy możliwość zainicjowania tej zmiennej, potencjalnie zmieniając jej wartość! Ograniczając inicjalizację zmiennych do konstruktora klasy, do której należą te zmienne, C++ zapewnia, że ​​wszystkie zmienne zostaną zainicjowane tylko raz.

Efekt końcowy jest taki, że powyższy przykład nie działa, ponieważ m_id został odziedziczony z Base i tylko niedziedziczone zmienne mogą być inicjowane na liście inicjatorów składowych.

Jednakże wartości odziedziczonych zmiennych nadal można zmieniać w treści konstruktora za pomocą przypisania. W związku z tym nowi programiści często również próbują tego:

class Derived: public Base
{
public:
    double m_cost {};

    Derived(double cost=0.0, int id=0)
        : m_cost{ cost }
    {
        m_id = id;
    }

    double getCost() const { return m_cost; }
};

Chociaż to faktycznie działa w tym przypadku, nie zadziałałoby, gdyby m_id było stałą lub referencją (ponieważ wartości const i referencje muszą zostać zainicjowane na liście inicjatorów składowych konstruktora). Jest to również nieefektywne, ponieważ m_id otrzymuje wartość dwukrotnie: raz na liście inicjatorów składowych konstruktora klasy Base, a następnie ponownie w treści konstruktora klasy Derived. I na koniec, co by było, gdyby klasa Base potrzebowała dostępu do tej wartości podczas budowy? Nie ma możliwości uzyskania do niego dostępu, ponieważ nie jest on ustawiony do czasu wykonania konstruktora Derived (co prawie dzieje się jako ostatnie).

Jak więc poprawnie zainicjować m_id podczas tworzenia obiektu klasy Derived?

We wszystkich dotychczasowych przykładach, gdy tworzymy instancję obiektu klasy Derived, część klasy Base została utworzona przy użyciu domyślnego konstruktora Base. Dlaczego zawsze używa domyślnego konstruktora Base? Ponieważ nigdy nie mówiliśmy, żeby robił inaczej!

Na szczęście C++ daje nam możliwość jawnego wyboru, który konstruktor klasy Base zostanie wywołany! Aby to zrobić, po prostu dodaj wywołanie konstruktora klasy Base na liście inicjatorów składowych klasy pochodnej:

class Derived: public Base
{
public:
    double m_cost {};

    Derived(double cost=0.0, int id=0)
        : Base{ id } // Call Base(int) constructor with value id!
        , m_cost{ cost }
    {
    }

    double getCost() const { return m_cost; }
};

Teraz, gdy wykonamy ten kod:

#include <iostream>

int main()
{
    Derived derived{ 1.3, 5 }; // use Derived(double, int) constructor
    std::cout << "Id: " << derived.getId() << '\n';
    std::cout << "Cost: " << derived.getCost() << '\n';

    return 0;
}

Konstruktor klasy bazowej Base(int) zostanie użyty do zainicjowania m_id na 5, a konstruktor klasy pochodnej zostanie użyty do zainicjowania m_cost na 1.3!

W ten sposób program print:

Id: 5
Cost: 1.3

Mówiąc bardziej szczegółowo, dzieje się tak:

  1. Pamięć dla pochodnych jest przydzielana.
  2. Wywoływany jest konstruktor Derived(double, int), gdzie koszt = 1,3, a id = 5.
  3. Kompilator sprawdza, czy poprosiliśmy o konkretny konstruktor klasy Base. Mamy! Wywołuje zatem Base(int) o id = 5.
  4. Lista inicjatorów składowych konstruktorów klasy bazowej ustawia m_id na 5.
  5. Wykonuje się treść konstruktora klasy bazowej, co nic nie robi.
  6. Konstruktor klasy bazowej zwraca.
  7. Lista inicjatorów składowych konstruktorów klasy pochodnej ustawia m_cost na 1.3.
  8. Wyprowadzenie wykonywana jest treść konstruktora klasy, co nic nie robi.
  9. Konstruktor klasy pochodnej zwraca.

Może się to wydawać nieco skomplikowane, ale w rzeczywistości jest bardzo proste. Dzieje się tak tylko dlatego, że konstruktor Derived wywołuje konkretny konstruktor Base w celu zainicjowania części Base obiektu. Ponieważ m_id znajduje się w części Base obiektu, konstruktor Base jest jedynym konstruktorem, który może zainicjować tę wartość.

Zauważ, że nie ma znaczenia, gdzie na liście inicjatorów składowych konstruktora Derived wywoływany jest konstruktor Base — zawsze zostanie on wykonany jako pierwszy.

Teraz możemy ustawić nasze elementy jako prywatne

Teraz, gdy wiesz, jak inicjować elementy klasy bazowej, nie ma potrzeby zachowywania nasze zmienne członkowskie public. Zmieniamy nasze zmienne członkowskie ponownie na prywatne, tak jak powinny.

Gwoli przypomnienia, dostęp do publicznych elementów członkowskich może uzyskać każdy. Dostęp do elementów prywatnych mogą uzyskać tylko funkcje członkowskie tej samej klasy. Pamiętaj, że oznacza to, że klasy pochodne nie mają bezpośredniego dostępu do prywatnych elementów klasy bazowej! Klasy pochodne będą musiały używać funkcji dostępu, aby uzyskać dostęp do prywatnych elementów klasy bazowej.

Rozważ:

#include <iostream>

class Base
{
private: // our member is now private
    int m_id {};
 
public:
    Base(int id=0)
        : m_id{ id }
    {
    }
 
    int getId() const { return m_id; }
};

class Derived: public Base
{
private: // our member is now private
    double m_cost;

public:
    Derived(double cost=0.0, int id=0)
        : Base{ id } // Call Base(int) constructor with value id!
        , m_cost{ cost }
    {
    }

    double getCost() const { return m_cost; }
};

int main()
{
    Derived derived{ 1.3, 5 }; // use Derived(double, int) constructor
    std::cout << "Id: " << derived.getId() << '\n';
    std::cout << "Cost: " << derived.getCost() << '\n';

    return 0;
}

W powyższym kodzie ustawiliśmy m_id i m_cost na prywatne. Nie ma w tym nic złego, ponieważ do ich inicjalizacji używamy odpowiednich konstruktorów, a do pobierania wartości używamy publicznego akcesora.

Wypisuje zgodnie z oczekiwaniami:

Id: 5
Cost: 1.3

O specyfikatorach dostępu porozmawiamy więcej w następnej lekcji.

Kolejny przykład

Przyjrzyjmy się innej parze klas, z którymi wcześniej pracowaliśmy:

#include <string>
#include <string_view>

class Person
{
public:
    std::string m_name;
    int m_age {};

    Person(std::string_view name = "", int age = 0)
        : m_name{ name }, m_age{ age }
    {
    }

    const std::string& getName() const { return m_name; }
    int getAge() const { return m_age; }
};

// BaseballPlayer publicly inheriting Person
class BaseballPlayer : public Person
{
public:
    double m_battingAverage {};
    int m_homeRuns {};

    BaseballPlayer(double battingAverage = 0.0, int homeRuns = 0)
       : m_battingAverage{ battingAverage },
         m_homeRuns{ homeRuns }
    {
    }
};

Jak już wcześniej pisaliśmy, BaseballPlayer inicjuje tylko swoje własne elementy i nie określa konstruktora Person do użycia. Oznacza to, że każdy BaseballPlayer, który utworzymy, będzie korzystał z domyślnego konstruktora Person, który zainicjalizuje nazwę na pustą, a wiek na 0. Ponieważ nadanie naszemu BaseballPlayerowi nazwy i wieku podczas ich tworzenia ma sens, powinniśmy zmodyfikować ten konstruktor, aby dodać te parametry.

Oto nasze zaktualizowane klasy, które używają prywatnych członków, przy czym klasa BaseballPlayer wywołuje odpowiedni konstruktor Person w celu zainicjowania odziedziczonego elementu Person zmienne:

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

class Person
{
private:
    std::string m_name;
    int m_age {};

public:
    Person(std::string_view name = "", int age = 0)
        : m_name{ name }, m_age{ age }
    {
    }

    const std::string& getName() const { return m_name; }
    int getAge() const { return m_age; }

};
// BaseballPlayer publicly inheriting Person
class BaseballPlayer : public Person
{
private:
    double m_battingAverage {};
    int m_homeRuns {};

public:
    BaseballPlayer(std::string_view name = "", int age = 0,
        double battingAverage = 0.0, int homeRuns = 0)
        : Person{ name, age } // call Person(std::string_view, int) to initialize these fields
        , m_battingAverage{ battingAverage }, m_homeRuns{ homeRuns }
    {
    }

    double getBattingAverage() const { return m_battingAverage; }
    int getHomeRuns() const { return m_homeRuns; }
};

Teraz możemy stworzyć takich baseballistów:

#include <iostream>

int main()
{
    BaseballPlayer pedro{ "Pedro Cerrano", 32, 0.342, 42 };

    std::cout << pedro.getName() << '\n';
    std::cout << pedro.getAge() << '\n';
    std::cout << pedro.getBattingAverage() << '\n';
    std::cout << pedro.getHomeRuns() << '\n';

    return 0;
}

To daje:

Pedro Cerrano
32
0.342
42

Jak widać, imię i wiek z klasy bazowej zostały poprawnie zainicjowane, podobnie jak liczba home runów i średnia odbijania z klasy pochodnej.

Łańcuchy dziedziczenia

Klasy w łańcuchu dziedziczenia działają dokładnie w ten sam sposób.

#include <iostream>

class A
{
public:
    A(int a)
    {
        std::cout << "A: " << a << '\n';
    }
};

class B: public A
{
public:
    B(int a, double b)
    : A{ a }
    {
        std::cout << "B: " << b << '\n';
    }
};

class C: public B
{
public:
    C(int a, double b, char c)
    : B{ a, b }
    {
        std::cout << "C: " << c << '\n';
    }
};

int main()
{
    C c{ 5, 4.3, 'R' };

    return 0;
}

W tym przykładzie klasa C pochodzi z klasy B, która jest pochodną klasy A. Co się więc dzieje kiedy tworzymy instancję obiektu klasy C?

Najpierw funkcja main() wywołuje funkcję C(int, double, char). Konstruktor C wywołuje B(int, double). Konstruktor B wywołuje A(int). Ponieważ A nie dziedziczy od nikogo, jest to pierwsza klasa, którą skonstruujemy. Konstruuje się A, wypisuje wartość 5 i zwraca kontrolę do B. Konstruuje B, wypisuje wartość 4,3 i zwraca kontrolę do C. Konstruuje C, wypisuje wartość „R” i zwraca sterowanie do funkcji main(). I gotowe!

W ten sposób program wypisuje:

A: 5
B: 4.3
C: R

Warto wspomnieć, że konstruktory mogą wywoływać konstruktory tylko z ich bezpośredniej klasy nadrzędnej/bazowej. W rezultacie konstruktor C nie mógł wywołać ani przekazać parametrów bezpośrednio do konstruktora A. Konstruktor C może wywołać jedynie konstruktor B (który jest odpowiedzialny za wywołanie konstruktora A).

Destruktory

Gdy klasa pochodna zostaje zniszczona, każdy destruktor jest wywoływany w odwrotnej kolejności konstrukcji. W powyższym przykładzie, gdy c zostaje zniszczone, najpierw wywoływany jest destruktor C, następnie destruktor B, a następnie destruktor A.

Ostrzeżenie

Jeśli twoja klasa bazowa ma funkcje wirtualne, twój destruktor również powinien być wirtualny, w przeciwnym razie w niektórych przypadkach nastąpi niezdefiniowane zachowanie. Omówiliśmy ten przypadek w lekcji 25.4 -- Wirtualne destruktory, przypisanie wirtualne i wirtualizacja zastępująca.

Streszczenie

Podczas konstruowania klasy pochodnej konstruktor klasy pochodnej jest odpowiedzialny za określenie, który konstruktor klasy bazowej zostanie wywołany. Jeśli nie określono żadnego konstruktora klasy bazowej, zostanie użyty domyślny konstruktor klasy bazowej. W takim przypadku, jeśli nie można znaleźć domyślnego konstruktora klasy bazowej (lub utworzyć go domyślnie), kompilator wyświetli błąd. Klasy są następnie konstruowane w kolejności od najbardziej podstawowej do najbardziej pochodnej.

W tym momencie wiesz już wystarczająco dużo o dziedziczeniu w C++, aby stworzyć własne klasy dziedziczone!

Czas na quiz!

  1. Zaimplementujmy nasz przykład Fruit, o którym mówiliśmy we wstępie do dziedziczenia. Utwórz klasę bazową Fruit zawierającą dwa prywatne elementy członkowskie: nazwę (std::string) i kolor (std::string). Utwórz klasę Apple, która dziedziczy Fruit. Apple powinien mieć dodatkowego prywatnego członka: światłowód (podwójny). Utwórz klasę Banana, która również dziedziczy Fruit. Banan nie ma dodatkowych członków.

Powinien uruchomić się następujący program:

#include <iostream>

int main()
{
	const Apple a{ "Red delicious", "red", 4.2 };
	std::cout << a << '\n';

	const Banana b{ "Cavendish", "yellow" };
	std::cout << b << '\n';

	return 0;
}

I wydrukuj następujący tekst:

Apple(Red delicious, red, 4.2)
Banana(Cavendish, yellow)

Wskazówka: Ponieważ a i b są stałymi, musisz pamiętać o swoich stałych. Upewnij się, że parametry i funkcje są odpowiednio stałe.

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