Konstruktory i destruktory w C++

Konstruktory i destruktory w C++

Sposób inicjacji obiektu klasy można określić poprzez zdefiniowanie konstruktora. Jako dopełnienie konstruktorów można zdefiniować destruktor „robiący porządek” w chwili usuwania obiektu (gdy np. obiekt znajdzie się poza zakresem dostępności). Niektóre najbardziej efektywne techniki języka C++ dotyczące zarządzania zasobami bazują na wykorzystywaniu par konstruktor-destruktor. Podobnie jest z technikami opartymi na parach uzupełniających się czynności, takich jak zrób-cofnij, włącz-wyłącz, przed-po itd. Na przykład:

struct Tracer {
    string mess;
    Tracer(const string& s) :mess{s} { clog << mess; }
    ~Tracer() {clog << "˜" << mess; }
};
void f(const vector& v)
{
    Tracer tr {"in f() "};
        for (auto x : v) {
            Tracer tr {string{"v loop "}+to(x)+' '}; // 25.2.5.1
            //...
        }
    }

Możemy spróbować wykonać takie wywołanie:

f({2,3,5});

Jego efektem będzie wydruk danych w strumieniu logowania:

in_f()
v loop 2
~v loop 2
v loop 3
~v loop 3
v loop 5
~v loop 5
~in_f()

Konstruktory i niezmienniki

Składowa klasy o takiej samej nazwie jak ta klasa nazywa się konstruktorem. Na przykład:

class Vector {
public:
    Vector(int s);
    //...
};

Deklaracja konstruktora zawiera listę argumentów (dokładnie tak samo jak funkcji), ale nie ma określenia typu zwrotnego. Nazwy klasy nie można nadać zwykłej funkcji ani zmiennej składowej, typowi składowemu itd. Na przykład:

struct S {
    S(); // w porządku
    void S(int); // błąd: konstruktor nie może mieć określonego typu
    int S; // błąd: nazwa klasy musi oznaczać konstruktor
    enum S { foo, bar }; // błąd: nazwa klasy musi oznaczać konstruktor
};

Zadaniem konstruktora jest inicjacja obiektów swojej klasy. Często w procesie tym musi zostać ustalony niezmiennik klasy, tzn. warunek, który musi być spełniony, gdy wywoływana jest jakakolwiek funkcja składowa (na zewnątrz klasy). Na przykład:

class Vector {
public:
    Vector(int s);
    //...
private:
    double* elem; // elem wskazuje tablicę sz liczb typu double
    int sz; // sz jest liczbą nieujemną
};

W tym kodzie (co jest często spotykaną praktyką) niezmiennik jest opisany w komentarzach: „elem wskazuje sz liczb typu double” oraz „sz jest liczbą nieujemną”. Zadaniem konstruktora jest dopilnowanie, by oba te warunki były spełnione. Na przykład:

Vector::Vector(int s)
{
    if (s<0) throw Bad_size{s};
    sz = s;
    elem = new double[s];
}

Ten konstruktor próbuje ustanowić niezmiennik i jeśli mu się to nie uda, zgłasza wyjątek. Jeżeli konstruktor nie może ustanowić niezmiennika, nie tworzy żadnego obiektu i wykonuje działania mające na celu zapobieżenie wyciekowi zasobów. Zasobem jest wszystko, co można zająć, a potem (jawnie lub niejawnie) zwrócić (zwolnić), gdy nie jest już potrzebne. Przykładami zasobów są pamięć, blokady, uchwyty do plików i uchwyty do wątków. 

Po co definiuje się niezmienniki:

  • aby skoncentrować prace nad projektem klasy,
  • aby wyjaśnić zachowanie klasy (np. w przypadku błędów_,
  • aby uprościć definicje funkcji składowych,
  • aby uprościć dokumentację klasy.

Zazwyczaj zdefiniowanie niezmiennika pozwala zaoszczędzić trochę pracy.

Destruktory i zasoby

Konstruktor inicjuje obiekt albo inaczej mówiąc, tworzy środowisko, w którym mogą działać funkcje składowe. Czasami do utworzenia tego środowiska konieczne jest zajęcie pewnych zasobów — np. pliku, blokady albo pamięci — które później trzeba jakoś zwolnić, gdy nie będą już potrzebne. W związku z tym w niektórych klasach potrzebna jest funkcja wywoływana
podczas usuwania obiektu, podobnie jak konstruktor jest wywoływany przy tworzeniu obiektu. Oczywiście funkcja ta nazywa się destruktorem. Nazwa destruktora składa się z tyldy (~) i nazwy klasy, np. ~Vector(). Jednym ze znaczeń znaku ~ jest „dopełnienie”, a destruktor klasy stanowi dopełnienie jej konstruktorów. Destruktor nie może przyjmować argumentów i może być tylko jeden w klasie. Destruktory są wywoływane niejawnie, gdy zmienna automatyczna wychodzi poza zakres dostępności, zostaje usunięty obiekt z pamięci wolnej itd. Potrzeba jawnego wywołania destruktora przez użytkownika występuje rzadko. Zadaniem destruktorów jest porządkowanie i zwalnianie zasobów. Na przykład:

class Vector {
public:
    Vector(int s) :elem{new double[s]}, sz{s} { }; // konstruktor: zajmuje pamięć
    ~Vector() { delete[] elem; } // destruktor: zwalnia pamięć
    //...
private:
    double* elem; // elem wskazuje tablicę sz liczb typu double
    int sz; // sz jest liczbą nieujemną
};

Na przykład:

Vector* f(int s)
{
    Vector v1(s);
    //...
    return new Vector(s+s);
}
void g(int ss)
{
    Vector* p = f(ss);
    //...
    delete p;
}

W tym przykładzie obiekt Vector v1 jest usuwany w momencie zakończenia działania funkcji f(). Ponadto obiekt klasy Vector utworzony w pamięci wolnej przez funkcję f() przy użyciu operatora new zostaje usunięty przez wywołanie operatora delete. W obu przypadkach wywoływany jest destruktor klasy Vector w celu zwolnienia (dezalokowania) pamięci alokowanej przez konstruktor.

A co by było, gdyby konstruktor nie zdołał zająć odpowiedniej ilości pamięci? Na przykład s*sizeof(double) lub (s+s)*sizeof(double) może oznaczać większy rozmiar niż ilość dostępnej pamięci (mierzonej w bajtach). W takim przypadku operator new zgłasza wyjątek std::bad_alloc  i mechanizm obsługi wyjątków wywołuje odpowiednie destruktory, aby zwolnić pamięć, która została zajęta (i tylko ją).

Ten styl zarządzania zasobami przy użyciu konstruktorów i destruktorów nazywa się RAII (ang. Resource Acquisition Is Initialization — zajęcie zasobu jest jego inicjacją). Pary konstruktor-destruktor są standardowo wykorzystywane w implementacji obiektów o zmiennym rozmiarze. Pewne warianty tej techniki są na przykład stosowane w budowie kontenerów vector i unordered_map z biblioteki standardowej języka C++ do przechowywania elementów. Jeśli typ nie ma zadeklarowanego destruktora, jak np. typ wbudowany, to uważa się, że ma on destruktor, który nic nie robi. Programista deklarujący destruktor dla klasy musi od razu zdecydować, czy obiekty tej klasy będą mogły być kopiowane i przenoszone.

Destruktory klas bazowych i składowych klas

Konstruktory i destruktory zachowują się prawidłowo w hierarchiach klas. Obiekt jest tworzony przez konstruktor „od dołu”:

1. Najpierw konstruktor wywołuje konstruktory klas bazowych.
2. Następnie wywołuje konstruktory składowych.
3. A na koniec wykonuje własny kod źródłowy.

Destruktor natomiast „niszczy” obiekt w odwrotnej kolejności:

1. Najpierw wykonuje własny kod źródłowy.
2. Potem wywołuje destruktory składowych.
3. A na koniec wywołuje destruktory klas bazowych.

W szczególności wirtualna baza jest tworzona przed wszystkimi innymi, które mogą jej potrzebować, oraz usuwana po wszystkich innych. Taka kolejność daje pewność, że klasa bazowa ani składowa klasy nie zostanie użyta przed zainicjowaniem ani po usunięciu. Programista może to obejść, ale musi się postarać, przekazując wskaźniki do niezainicjowanych zmiennych jako argumenty. Działania takie są wbrew zasadom języka i ich wynik jest zwykle katastrofalny.

Konstruktory wywołują konstruktory składowych i klas bazowych w kolejności deklaracji (nie w kolejności inicjatorów): gdyby dwa konstruktory zmieniły kolejność, destruktor nie mógłby (bez poważnego spowolnienia działania) zagwarantować niszczenia w kolejności odwrotnej do porządku tworzenia. 

Jeżeli klasa jest używana w sposób wymagający dostępności konstruktora domyślnego i nie zawiera żadnego innego konstruktora, kompilator próbuje wygenerować konstruktor domyślny. Na przykład:

struct S1 {
    string s;
};
S1 x; // OK: x.s zostanie zainicjowana wartością ""

Podobnie gdy potrzebne są inicjatory, może zostać zastosowana inicjacja składowych. Na przykład:

struct X { X(int); };
struct S2 {
    Xx;
};
S2 x1; // błąd: brak wartości dla x1.x
S2 x2 {1}; // OK: x2.x jest zainicjowana wartością 1

Wywoływanie konstruktorów i destruktorów

Destruktor jest wywoływany niejawnie przy wychodzeniu z zakresu lub przez operator delete. Najczęściej nie tylko nie ma potrzeby bezpośredniego wywoływania destruktora, lecz może to być wręcz źródłem paskudnych błędów. Istnieją jednak rzadkie (ale ważne) przypadki, w których destruktor musi zostać jawnie wywołany. Zastanówmy się na kontenerem, który (podobnie jak std::vector) utrzymuje pulę pamięci, w obrębie której może się powiększać i zmniejszać (np. przy użyciu funkcji push_back() i pop_back()). Gdy dodajemy element, kontener musi wywołać swój konstruktor dla konkretnego adresu:

void C::push_back(const X& a)
{
    //...
    new(p) X{a}; // konstrukcja kopiująca X z wartością a zapisaną pod adresem p
    //...
}

Ten sposób użycia konstruktora nazywa się „lokacyjnym operatorem new” (ang. placement new). Gdy usuwamy element, kontener musi wywołać destruktor:

void C::pop_back()
{
    //...
    p−>~X(); // usuwa X pod adresem p
}

Notacja p−>~X() powoduje wywołanie destruktora klasy X dla *p. Nigdy nie należy jej używać w odniesieniu do obiektów usuwanych w normalny sposób (z powodu wyjścia z zakresu dostępności lub usunięcia przez operator delete). 

Destruktor zadeklarowany dla klasy X będzie niejawnie wywoływany za każdym razem, gdy obiekt tej klasy znajdzie się poza zakresem dostępności lub zostanie usunięty przy użyciu operatora delete. To znaczy, że można uniemożliwić usunięcie obiektu klasy X, deklarując jej destruktor przy użyciu =delete (17.6.4) lub jako prywatny. Z tych dwóch możliwości bardziej elastycznym rozwiązaniem jest użycie specyfikatora private. Możemy na przykład utworzyć klasę, której obiekty mogą być usuwane tylko jawnie:

class Nonlocal {
public:
    //...
    void destroy() { delete this; } // jawna destrukcja
private:
    //...
    ~Nonlocal(); // nie usuwać niejawnie
};
void user()
{
    Nonlocal x; // błąd: nie można usunąć obiektu klasy Nonlocal
    Nonlocal* p = new Nonlocal; // OK
    //...
    delete p; // błąd: nie można usunąć obiektu klasy Nonlocal
    p.destroy(); // OK
}

Destruktory wirtualne

Destruktor może być wirtualny i zazwyczaj taki powinien być w klasach zawierających funkcje wirtualne. Na przykład:

class Shape {
public:
    //...
    virtual void draw() = 0;
    virtual ~Shape();
};
class Circle :public Shape {
public:
    //...
    void draw();
    ~Circle(); // przesłania ~Shape()
    //...
};

Destruktor wirtualny jest potrzebny dlatego, że jeśli obiekt jest używany poprzez interfejs klasy bazowej, to także do jego usunięcia wykorzystywany jest ten sam interfejs: 

void user(Shape* p)
{
    p−>draw(); // wywołuje odpowiednią funkcję draw()
    //...
    delete p; // wywołuje odpowiedni destruktor
};

Gdyby destruktor klasy Shape nie był wirtualny, to operator delete nie wywołałby destruktora odpowiedniej klasy pochodnej (np. ~Circle()). To z kolei mogłoby być przyczyną wycieku zasobów będących w posiadaniu usuwanego obiektu.

 Język C++. Kompendium wiedzy. Wydanie IV, Autor: Bjarne Stroustrup, Wydawnictwo: Helion 

Podobne artykuły

Podziel się ze znajomymi tym artykułem - udostępnij na FB lub wyślij e-maila korzystając z poniższych opcji:

wszystkie oferty