C++/Konstruktor i destruktor


Teoria

edytuj

Wstęp

edytuj

Pisząc klasy każdy kiedyś dotrze do momentu, w którym będzie odczuwał potrzebę napisania funkcji wykonującej jakieś niezbędne instrukcje na początku lub na końcu istnienia obiektu. W takim momencie programista powinien sięgnąć po dwa niezwykle przydatne narzędzia: konstruktory i destruktory.

Konstruktor

edytuj

Konstruktor jest to funkcja w klasie, wywoływana w trakcie tworzenia każdego obiektu danej klasy. Funkcja może stać się konstruktorem gdy spełni poniższe warunki

  • Ma identyczną nazwę jak nazwa klasy
  • Nie zwraca żadnej wartości (nawet void)

Należy dodać że każda klasa ma swój konstruktor. Nawet jeżeli nie zadeklarujemy go jawnie zrobi to za nas kompilator (stworzy wtedy konstruktor bezparametrowy i pusty).

Mamy na przykład klasę Miesiac. Chcielibyśmy, aby każdy obiekt tej klasy tuż po utworzeniu wygenerował tablicę z nazwami dni tygodnia w zależności od miesiąca i roku. A może dało by się to zrobić w trakcie tworzenia klasy?
Przyjrzyj się poniższej klasie, oraz funkcji konstruktora:

   class Miesiac
   {
        public:
             int dni[31];
             int liczbaDni;
             string nazwa;
             Miesiac();//deklaracja konstruktora
   };

   Miesiac::Miesiac()//definicja konstruktora
   {
        // instrukcje tworzące 
   }

Konstruktor może też przyjmować argumenty. Jak?
To zależy od sposobu w jaki tworzymy obiekt:

  • jako obiekt
   MojaKlasa obiekt(argumenty);
  • jako wskaźnik do obiektu:
   MojaKlasa* wsk = new MojaKlasa(argumenty);

Teraz powyższa klasa miesiąca może być stworzona z uwzględnieniem numeru miesiąca i roku:

   class Miesiac
   {
        public:
             int dni[31];
             int liczbaDni;
             string nazwa;
             Miesiac(int numer, int rok);
   };

   Miesiac::Miesiac(int numer,int rok)
   {
        /* instrukcje tworzące */
   }

Aby utworzyć nowy obiekt tej klasy trzeba będzie napisać:

   Miesiac styczen2000(1, 2000);

lub jako wskaźnik do obiektu:

   Miesiac* styczen2000 = new Miesiac(1, 2000);

otrzymawszy w ten sposób kalendarz na styczeń.


Najczęstszą funkcją konstruktora jest inicjalizacja obiektu oraz alokacja pamięci dla dodatkowych zmiennych (w tym celu lepiej jest użyć instrukcji inicjujących, które poznasz już za chwilę).

Instrukcje inicjalizujące

edytuj

Instrukcje inicjalizujące to instrukcje konstruktora spełniające specyficzne zadanie. Mianowicie mogą one zostać wywołane przez kompilator zaraz po utworzeniu klasy. Służą do inicjalizowania pól klasy, w tym stałych i referencji.

Jeśli nie zaimplementujemy instrukcji inicjalizujących, niczego nie będą one robiły.

Jeżeli chcemy zaimplementować instrukcje inicjalizujące, musimy po liście argumentów konstruktora, użyć dwukropka, podać nazwę pola, które chcemy zainicjalizować i jego wartość ujętą w nawiasy okrągłe.

 Rok()
 : miesiace(new Miesiac[12])
 , liczbaDni(7)
 /*
 zamiast średników stosuje się przecinki
 przy ostatniej instrukcji przecinka nie stosuje się
 */
 {}

Działa to podobnie jak użycie inicjalizowania w konstruktorze, jednak w przypadku instrukcji inicjalizujących pola będą zainicjalizowane w trakcie tworzenia klasy, a nie po utworzeniu jej obiektu.

Konstruktor kopiujący

edytuj

Konstruktor kopiujący to konstruktor spełniający specyficzne zadanie. Mianowicie może on zostać wywoływany przez kompilator niejawnie jeżeli zachodzi potrzeba stworzenia drugiej instancji obiektu (np. podczas przekazywania obiektu do funkcji przez wartość).

Jeżeli nie zaimplementujemy konstruktora kopiującego, kompilator zrobi to automatycznie. Konstruktor taki będzie po prostu tworzył drugą instancję wszystkich pól obiektu. Możemy go jawnie wywołać np. tak:

 Miesiac miesiac(12, 2005);
 Miesiac kopia(miesiac); //tu zostanie wywołany konstruktor kopiujący
 /* obiekt kopia będzie miał taką samą zawartość jak obiekt miesiąc */

Jeżeli chcemy sami zaimplementować konstruktor kopiujący musimy zadeklarować go jako konstruktor o jednym parametrze będącym referencją na obiekt tej samej klasy.

   class Miesiac
   {
        public:
             int numer;
             int rok;
             Miesiac(const Miesiac &miesiac)
             {
                  numer = miesiac.numer;
                  rok = miesiac.rok;
             }
   };

Delegacja konstruktorów (C++11)

edytuj

W przypadku wielu wariantów konstruktorów często zdarza się, że muszą one powielać różne testy poprawności argumentów lub jakieś szczególne operacje konieczne do inicjalizacji obiektu. Niekiedy taki wspólny kod wyciąga się do osobnych prywatnych lub chronionych metod.

W C++11 dodano możliwość użycia na liście inicjalizacyjnej innych konstruktorów klasy.

Przyjrzyjmy się poniższej klasie - ma ona za zadanie przechowywać szczegóły dotyczące błędów składniowych np. w pliku konfiguracyjnym. Przechowuje numer linii, nazwę pliku i komunikat. Niekiedy jednak pliku nie ma, bo dane czytamy ze standardowego wejścia; czasem numer linii też nie jest dostępny, bo np. dopiero po przeczytaniu całego pliku wiadomo, że jest coś nie tak w strukturze.

Klasa zrealizowana bez delegacji konstruktorów.

class BladSkladniowy {
    
    int         numer_linii;
    std::string plik;
    std::string komunikat;         

    BladSkladniowy(int numer_linii, const std::string& plik, const std::string& komunikat)
        : numer_linii(numer_linii)
        , plik(plik)
        , komunikat(komunikat) {

        if (numer_linii < 0)   throw "niepoprawny numer linii";
        if (plik.empty())      throw "nazwa pliku nie może być pusta";
        if (komunikat.empty()) throw "komunikat nie może być pusty";
    }

    BladSkladniowy(int numer_linii, const std::string& komunikat)
        : numer_linii(numer_linii)
        , plik("<standardowe wejście>")
        , komunikat(komunikat) {

        if (numer_linii < 0)   throw "niepoprawny numer linii";
        if (komunikat.empty()) throw "komunikat nie może być pusty";
    }

    BladSkladniowy(const std::string& komunikat)
        : numer_linii(0)
        , plik("<standardowe wejście>")
        , komunikat(komunikat) {

        if (komunikat.empty()) throw "komunikat nie może być pusty";
    }
};

Przy użyciu delegacji kod skraca się znacząco:

class BladSkladniowy {
    
    int         numer_linii;
    std::string plik;
    std::string komunikat;         

    BladSkladniowy(int numer_linii, const std::string& plik, const std::string& komunikat)
        : numer_linii(numer_linii)
        , plik(plik)
        , komunikat(komunikat) {

        if (numer_linii < 0)   throw "niepoprawny numer linii";
        if (plik.empty())      throw "nazwa pliku nie może być pusta";
        if (komunikat.empty()) throw "komunikat nie może być pusty";
    }

    BladSkladniowy(int numer_linii, const std::string& komunikat)
        : BladSkladniowy(numer_linii, "<standardowe wejście>", komunikat) {}

    BladSkladniowy(const std::string& komunikat)
        : BladSkladniowy(0, komunikat) {}
};

Konstruktor explicit - zabronienie niejawnych konwersji (C++)

edytuj

Czasem niepożądane jest, żeby można było "przez przypadek" utworzyć klasę bądź przypisać do niej wartość. Jeśli klasa posiada konstruktor konwertujący, to kompilator jest w stanie wydedukować sposób na przekształcenie jednego typu w drugi, tj. dokonać niejawnej konwersji. Takie zachowanie nie zawsze jest pożądane i w dużych systemach jest dość trudne do przewidzenia i rozpoznania przez programistę.

Zobaczmy na przykład:

class Klasa {
public:
    Klasa(int x) {}
};

int main() {

    Klasa k(42);

    k = -1;
}

Ostatnie przypisanie choć wygląda dziwnie, nie jest błędem. Kompilator widzi, że z typu int może utworzyć Klasę, a także dla Klasy istnieje domyślny operator przypisania, więc ostatnia linijka zostanie zainterpretowana jako:

    k.operator=(Klasa(-1));

Do rozwiązania tego typu problemów w C++11 wprowadzono nowy klasyfikator dla konstruktorów explicit. Jeśli istnieje konstruktor z explicit wówczas utworzenie klasy, która byłaby wynikiem konwersji niejawnej stanie się niemożliwe.

class Klasa {
public:
    explicit Klasa(int x) {}
};

int main() {

    Klasa k(42);

    // k = -1; // błąd: kompilator nie wie jak skonwertować int na Klasa
}

Dopiero wprowadzenie jawnej konwersji operatorem static_cast umożliwia zastosowania konstruktora konwertującego. Taki operator jest dobrze widoczny w kodzie źródłowym i jasno oddaje intencje użycia:

    k = static_cast<Klasa>(-1);


Destruktor

edytuj

Destruktor jest natomiast funkcją, którą wykonuje się w celu zwolnienia pamięci przydzielonej dodatkowym obiektom lub innych zasobów.

Zasady "przemiany" zwykłej funkcji do destruktora, są podobne do tych tyczących się konstruktora. Jedyna zmiana tyczy się nazwy funkcji: Musi się ona zaczynać od znaku tyldy - ~.

   class MojaKlasa
   {
        MojaKlasa();//to oczywiście jest konstruktor
        ~MojaKlasa();//a to - destruktor
   };

Najczęstszą funkcją destruktora jest zwolnienie pamięci (zwykle poprzez zniszczenie wszystkich pól używanych przez ten obiekt).

Ćwiczenia

edytuj

Ćwiczenie 1

edytuj

Napisz definicje instrukcji inicjujących do poniższej klasy:

   class Vector
   {
        private:
             double x;
             double y;
        public:
             Vector();
             Vector(double, double);
   };

Klasa ma reprezentować wektor w przestrzeni dwuwymiarowej, a instrukcje inicjujące mają realizować inicjalizację tego wektora. Pierwsze instrukcje inicjujące powinny ustawiać wektor na wartość domyślną (0,0).

Ćwiczenie 2

edytuj

Dopisz do kodu z poprzedniego ćwiczenia konstruktor kopiujący.

   Vector(const Vector&);

Po wykonaniu tego ćwiczenia zastanów się, czy napisanie konstruktora kopiującego było konieczne. Jeżeli nie jesteś pewien - napisz program który testuje działanie Twojego konstruktora kopiującego i sprawdź jak program działa bez niego. Wyjaśnij dlaczego konstruktor kopiujący nie jest potrzebny.

Ćwiczenie 3

edytuj

Poniższa klasa miała implementować dowolnej wielkości tablicę obiektów klasy Vector z poprzednich ćwiczeń. Niestety okazało się, że powoduje wycieki pamięci - programista zapomniał o napisaniu destruktora:

   class VectorsArray
   {
        public:
             Vector* vectors;

             VectorsArray(size_t);
             Vector GetVector(size_t);
             size_t GetSize();
             size_t size;
   };

   VectorsArray::VectorsArray(size_t argSize)
   : size(argSize)
   , vectors(new Vector[argSize])
   {
   }
   Vector VectorsArray::GetVector(size_t i)
   {
        return vectors[i];
   }
   size_t VectorsArray::GetSize()
   {
      return size;
   }

Do powyższej klasy dopisz definicję destruktora. Nie zapomnij o dealokacji pamięci!