Blog

C++

Kolejność inicjalizacji

20 Sie 2018

Kolejność inicjalizacji obiektów w C++ to temat długi i szeroki jak rzeka. Warto się z nim bliżej zapoznać, gdyż konstruktor i destruktor to niezbędne narzędzia w warsztacie każdego programisty C++. Jest to również jedno z najczęściej pojawiających się pytań rekrutacyjnych i to zarówno na stanowiska juniorskie jak i seniorskie.

Artykuł podzieliłem na kilka części. Ponieważ programiści niechętnie sięgają po artykuły, nie zawierające kodu źródłowego – każdy fragment będzie bazował na przykładzie.

Inicjalizacja instancji klasy

Zacznijmy od prostego przykładu, nim sięgniemy po bardziej złożone struktury.

#include <iostream>
 
using namespace std;
 
class Chocolate
{
public:
    Chocolate() { 
    	cout << "Chocolate ctor" << endl; 
    }
    ~Chocolate() {
        cout << "Chocolate dtor" << endl;
    }
};

int main(int argc, char *argv[]) {
    Chocolate chocolate;
    return 0;
}

Oczywiście obiekt chocolate zostanie wpierw utworzony, a następnie zniszczony. Jak wszystkie zmienne o automatic storage duration czas jego życia wyznaczają okalające go klamerki. Wynik działania programu raczej nie będzie zaskoczeniem.

Chocolate ctor
Chocolate dtor

Nikt się nie spodziewał, że polegniemy na wstępie. Jak wyglądają relacje między klasą i jej składowymi?

Inicjalizacja składowych klasy

Wykorzystamy klasę Chocolate z poprzedniego przykładu. Tym razem będzie wchodziła w skład klasy ChocolateCake.

#include <iostream>
 
using namespace std;
 
class Chocolate
{
public:
    Chocolate() { 
    	cout << "Chocolate ctor" << endl; 
    }
    ~Chocolate() {
        cout << "Chocolate dtor" << endl;
    }
};

class ChocolateCake
{
public:
    ChocolateCake() {
        cout << "ChocolateCake ctor" << endl;
    }
    ~ChocolateCake() {
        cout << "ChocolateCake dtor" << endl;
    }
private:
    Chocolate chocolate;
};

int main(int argc, char *argv[]) {
    ChocolateCake cake;
    return 0;
}

Ten przypadek jest już trochę bardziej złożony. Tym razem tworzymy obiekt, który zawiera w sobie inny. Spodziewamy się, że należy utworzyć zarówno jeden jak i drugi. A skoro utworzyć to i zniszczyć. Tylko w jakiej kolejności? Podejdźmy do tematu intuicyjnie, czy w konstruktorze obiektu mamy dostęp do jego składowych?

Oczywiście, że tak.

Musiały być więc zainicjalizowane już wcześniej. A co z destrukcją? Czy w destruktorze mamy dostęp do pól klasy? Również mamy. Tak więc muszą być niszczone później. Wynik działania programu nie powinien być więc zaskoczeniem.

Chocolate ctor
ChocolateCake ctor
ChocolateCake dtor
Chocolate dtor

Warto tutaj zapamiętać pewną regułę kciuka. Destrukcja przebiega w odwrotnej kolejności niż konstrukcja. Kolejnym tematem, który poruszymy, będą relacje inicjalizacji w przypadku dziedziczenia.

Inicjalizacja klas bazowych

Pozbawmy naszą klasę jej składowej, tak by uprościć kolejny przykład.

#include <iostream>
 
using namespace std;

class ChocolateCake
{
public:
    ChocolateCake() {
        cout << "ChocolateCake ctor" << endl;
    }
    ~ChocolateCake() {
        cout << "ChocolateCake dtor" << endl;
    }
};

class BirthdayCake : public ChocolateCake
{
public:
    BirthdayCake() {
        cout << "BirthdayCake ctor" << endl;
    }
    ~BirthdayCake() {
        cout << "BirthdayCake dtor" << endl;
    }
};
 
int main(int argc, char *argv[]) {
    BirthdayCake birthdayCake;
    return 0;
}

Nasza nowa klasa BirthdayCake dziedziczy po ChocolateCake. Oczywiście zarówno konstruktor jednej jak i drugiej muszą zostać wywołane. Kolejność można ponownie wywnioskować. Czy w konstruktorze BirthdayCake mamy dostęp do pól i metod klasy bazowej?

Oczywiście, że tak.

Konstruktor tej klasy musiał być więc już wywołany wcześniej. Tak samo można uzasadnić kolejność destrukcji. Albo powołać się na regułę odnośnie kolejności konstrukcji/destrukcji. Wynik działania programu poniżej:

ChocolateCake ctor
BirthdayCake ctor
BirthdayCake dtor
ChocolateCake dtor

Podsumowanie

Połączmy wcześniejsze przykłady. Stwórzmy dwie klasy, pochodną i bazową. Każda z nich będzie posiadała jakąś składową. To zadanie, którego można się już spodziewać na rozmowie.

#include <iostream>
 
using namespace std;
 
class Chocolate
{
public:
    Chocolate() { 
    	cout << "Chocolate ctor" << endl; 
    }
    ~Chocolate() {
        cout << "Chocolate dtor" << endl;
    }
};
 
class ChocolateCake
{
public:
    ChocolateCake() {
        cout << "ChocolateCake ctor" << endl;
    }
    ~ChocolateCake() {
        cout << "ChocolateCake dtor" << endl;
    }
private:
    Chocolate chocolate;
};
 
class Candles
{
public:
    Candles() {
        cout << "Candles ctor" << endl;
    }
    ~Candles() {
        cout << "Candles dtor" << endl;
    }
};
 
class BirthdayCake : public ChocolateCake
{
public:
    BirthdayCake() {
        cout << "BirthdayCake ctor" << endl;
    }
    ~BirthdayCake() {
        cout << "BirthdayCake dtor" << endl;
    }
private:
    Candles candles;
};
 
int main(int argc, char *argv[]) {
    BirthdayCake birthdayCake;
    return 0;
}

Ideone przykład

Zostaniemy zapytani o wynik działania programu, albo precyzyjniej o kolejność wywołania konstruktorów i destruktorów. Łącząc ze sobą wnioski z poprzednich przykładów łatwo dojdziemy do takiego porządku:

Konstrukcja:

  • konstruktory składowych klasy bazowej
  • konstruktor klasy bazowej
  • konstruktory składowych klasy
  • konstruktor klasy

Destrukcja:

  • destruktor klasy
  • destruktory składowych klasy
  • destruktor klasy bazowej
  • destruktory składowych klasy bazowej

Podanie wyniku działania programu to już czysta formalność.

Chocolate ctor
ChocolateCake ctor
Candles ctor
BirthdayCake ctor
BirthdayCake dtor
Candles dtor
ChocolateCake dtor
Chocolate dtor

Czy ten przykład wyczerpuje temat? Niestety nie do końca. Pozostaje kilka otwartych punktów:
– zmienne globalne,
– zmienne statyczne,
– statyczne pola klasy,
– kolejność inicjalizacji wielu pól składowych,
– zmienne w pamięci lokalnej wątku,
– dziedziczenie wielokrotne i wirtualne.

To już jednak materiał na kolejny artykuł.

Dawid Trendota

Software Engineer po przejściach. Na swojej drodze miał okazję zmierzyć się zarówno z rozwiązaniami wbudowanymi, aplikacjami bazodanowymi jak i kernelem Linuksa. Prywatnie użytkownik i fan Gentoo Linux. W wolnym czasie programuje w C#/Unity.

@Dawid Trendota

Angielski (4)
C++ (7)
Junior (12)
Python (3)
Regular (6)
ReverseEngineering (1)
Rozmowa (7)
Różne (6)
Senior (1)
Tips&Tricks (10)
angielski c++ junior python regular rozmowa rozmowa kwalifikacyjna