1. Programowanie obiektowe

1.6. Tworzenie i usuwanie obiektów

Wiecie już, że obiekty mają swój stan - czyli wartości wszystkich pól w jakiejś chwili. Wiecie też, że zmiany tego stanu można kontrolować - przenosząc pola do części prywatnej klasy, oraz zapewniając odpowiednie metody. To pierwszy krok na drodze do uzyskania pewności, że dany obiekt nigdy nie będzie w nieprawidłowym stanie - ale tylko pierwszy krok. Do pełni szczęścia brakuje nam jeszcze zapewnienia, że po utworzeniu obiektu będzie on w prawidłowym stanie. Ale  pełnia szczęścia jest w zasięgu ręki - w przypadku klas możemy mieć pełną kontrolę nad procesem tworzenia i niszczenia obiektów. Do tego celu wykorzystywane są specjalne metody - konstruktor (może mieć kilka wersji) oraz destruktor (zawsze jest dokładnie jeden). 

Zanim je jednak omówimy - warto byście poznali jedną z technik (konwencji - programming idioms) - które dobrze jest stosować we własnym kodzie. Tą techniką jest RAII (Resource Acquisition is Initialization). Możecie ją potraktować albo jako wytyczne, albo coś w rodzaju wzorca projektowego, który mówi w skrócie: 

W przypadku konieczności korzystania z jakichś zasobów w programie, ich zajęcie powinno odbywać się w trakcie tworzenia obiektu, a zwolnienie w momencie jego niszczenia,

przy czym zasoby mogą być różnego rodzaju - może to być uchwyt do pliku, gniazdo sieciowe, czy też - pamięć ręcznie alokowana na stosie. Wg tego idiomu przygotowano większość klas z biblioteki standardowej (np. strumienie wejścia / wyjścia plikowego). W przypadku prostych klas, kiedy zasobem zazwyczaj jest pamięć, RAII sprowadza się do takiego projektowania klasy, by alokacja pamięci odbywała się już w konstruktorze, a jej zwolnienie - w destruktorze. Dzięki temu, jeśli stosować będziecie typy konkretne - zminimalizujecie ryzyko wycieków pamięci. 

Wsparcie dla RAII macie także w formie inteligentnych wskaźników - omówionych w module 3. Na razie jednak wróćmy do tworzenia obiektów. 

Konstruktor to specyficzna funkcja składowa klasy, wywoływana zawsze podczas tworzenia należącego doń obiektu. Typowym zadaniem konstruktora jest zainicjowanie pól ich początkowymi wartościami, przydzielenie pamięci na stercie (alokowanej dynamicznie) wykorzystywanej przez obiekt, wykonanie inicjalizacji pól złożonych, czy przeprowadzenie czynności niezbędnych do dalszej pracy z obiektem (doprowadzenie go do prawidłowego stanu). Deklaracja konstruktora jest w C++ bardzo prosta. Deklaracja tej metody nie precyzuje  żadnej wartości zwracanej (w ogóle nie oznacza się jej słowem kluczowym void), a jej nazwa odpowiada nazwie zawierającej ją klasy.

Konstruktor może nie przyjmować żadnych parametrów, może też mieć ich dowolną liczbę dowolnego rodzaju. Przykładowo – parametrami często są początkowe wartości przypisywane do pól. Co więcej, możliwe są różne postacie konstruktora (przeciążanie), co daje nam możliwość przygotowania różnych, specjalizowanych wersji konstruktorów, np. do wykonania kopii obiektu przekazanego jako wzorzec, do wykonania przeniesienia obiektu, czy zainicjowania obiektem innego typu. Poniżej mamy przykład kilku konstruktorów, oraz przykłady ich jawnego i niejawnego wywołania: 


class CProstokat {
public:
  /** Domyślny konstruktor. Nie przyjmuje jakichkolwiek parametrów */
  CProstokat();
  /** Typowy konstruktor kopiujący. Przyjmuje referencję do wzorca, który
		ma zostać skopiowany */
  CProstokat(const CProstokat& wzor);
  // destruktor
  ~CProstokat();
  ...
};

CProstokat::CProstokat()
{
  x1 = y1 = 0;
  x2 = 1;
  ...
}

CProstokat::CProstokat(const CProstokat& wzor)
{
  x1 = wzor.x1;
  y1 = wzor.y1;
  ...
}

CProstokat::~CProstokat()
{
}

void f(CProstokat p) { ... }

// konstr. 1 przed uruchomieniem programu
CProstokat pr;
// konstr. 1 przed uruchomieniem programu
CProstokat pr2;

// konstr. 2
pr2 = pr;

// konstr. 1 po dojściu do tej linii
CProstokat *pr3 = new CProstokat();
// konstr. 2 po dojściu do tej linii
CProstokat *pr3 = new CProstokat(pr);

// wywołany zostanie destruktor
delete pr3

// konstr. 2 przed wejściem do f
f(pr);
// destruktor

// konstr. 2 po przed wejściem do f
f(*pr);
// destruktor

Obiekty typów definiowanych przez użytkownika zawsze są tworzone przy wykorzystaniu konstruktora. Nawet jeśli nie wywoła się go jawnie, w C++ zostanie wywołany w sposób niejawny (widzicie to w kodzie powyżej). I analogicznie - jeśli nie zostanie w klasie zdefiniowany żaden konstruktor, kompilator wygeneruje sam jego domyślną wersję - która nic nie robi. Podobnie wygeneruje trywialny płytki konstruktor kopiujący – który skopiuje zawartości wszystkich pól statycznych, lecz niestety – w przypadku tablic i zmiennych dynamicznych – wykona jedynie kopiowanie adresów, oraz trywialny konstruktor przenoszący.

Z wiadomych względów konstruktory czynimy prawie zawsze metodami publicznymi. Umieszczenie ich w sekcji private daje bowiem dość dziwny efekt:  niemożliwe jest utworzenie z niej obiektu w zwykły sposób. Czasem to ma sens (przykład zobaczycie dalej) - ale zazwyczaj jednak chcemy mieć możliwość tworzenia obiektów. 

OK, konstruktory mają zatem niebagatelną rolą, jaką jest powoływania do życia nowych obiektów. Doskonale jednak wiemy, że nic nie jest wieczne i nawet najdłużej działający program kiedyś będzie musiał być zakończony, a jego obiekty zniszczone. Tą niechlubną robotą zajmuje się kolejny, wyspecjalizowany rodzaj metod – destruktor. Destruktor jest metodą, wywoływaną podczas niszczenia obiektu zawierającej ją klasy.

W naszych przykładowych klasach destruktor nie miałby wiele do zrobienia - zgoła nic, ponieważ żaden z prezentowanych obiektów nie wykonywał czynności, po których należałoby sprzątać. Lecz w przypadku nietrywialnych obiektów - często sprzątanie jest potrzebne. Przykładowo - jeśli klasa alokowała pamięć na stercie - wypadałoby ją zwolnić. Jeśli otwierała pliki - warto je pozamykać, jeśli obsługiwała połączenie sieciowe - można się upewnić że zostało zamknięte. Jak widać - destruktor jest przydatny.

Destruktor tworzymy definiując metodę bez parametrów, nic nie zwracającą - podobnie jak konstruktor. Nazwą metody jest nazwa klasy poprzedzona znakiem tyldy ( ~ ).

W C++ nie istnieje formalny wymóg definicji konstruktorów czy destruktorów dla każdego typu obiektu. Często je jednak stosujemy. 

W przypadku konstruktorów generowanych automatycznie - kompilator, nawet nieproszony - wygeneruje dla nas następujące wersje: 


class A {
	A(); /// pusty, domyślny konstruktor
  A(const A& src); /// konstruktor kopiujący
  A(A&& src); /// konstruktor przenoszący
  A& operator=(const A& src); /// operator przypisania kopiujący
  A& operator=(A&& src); /// operator przypisania przenoszący

Dodatkowo, wygenerowane automatycznie zostaną operatory przypisania w wersji kopiującej i przenoszącej. Możemy jawnie definiować, które z automatycznie generowanych konstruktorów powinny być dostępne, a których zabraniamy.  Jeśli któregoś z nich nie chcecie - możecie dodać =delete po deklaracji. Jeśli chcecie którąś z wersji domyślnych jawnie wskazać - piszecie =default

  • jeśli nie zdefiniujemy żadnej z wymienionych funkcji składowych, wszystkie zostaną wygenerowane przez kompilator
  • jeśli zdefiniujemy konstruktor kopiujący lub operator przypisania kopiujący, lub destruktor - - kompilator nie wygeneruje konstruktora przenoszącego i przenoszącego operatora przypisania
  • jeśli zdefiniujemy konstruktor przenoszący lub przenoszący operator przypisania, żaden z pozostałych elementów nie zostanie automatycznie wygenerowany

Ciężko to zapamiętać, więc dobra rada:

Dobrą praktyką w programowaniu jest trzymanie się zasady, że albo nie definiujemy żadnych własnych konstruktorów, albo definiujemy wszystkie kostruktory i operatory przenoszenia wymienione wcześniej. Zawsze też definiujemy wirtualny destruktor - także z innych względów o których napiszę później.

class A {
public: 
    A() = default; 
    A(A&& src) = delete;
};
void f(A x);

int main() {
    A a, b; // ok - można utworzyć obiekt A
    b = a; // błąd - zaczęliśmy jawnie zarządzać listą konstruktorów, więc nie mamy wygenerowanego 
           // ani operatora przypisania, ani konstuktora kopiującego.
           
    f(a); // tu też będzie błąd - przekazanie parametru przez wartość wymaga kopiowania       
           
    return 0;
}

Dzięki temu możemy uniknąć generowania błędnego kodu. Przykładowo - kopiowanie jest płytkie – kopiowany jest wskaźnik a nie wskazywana wartość. Co w praktyce oznacza, że poniższy programik spowoduje ulubiony błąd programisty C++ - access violation ;->


class CMoja
{
public:
  CMoja() {
    tbl = new char[255];
  };
  ~CMoja() {
    delete[] tbl;
  }
private:
  char* tbl;
};

void ff(CMoja m) {
  ...
}

int main() {
  CMoja obiekt;
  ff(obiekt);

  CMoja *po = new CMoja();
  ff(*po);
  delete po;

  return 0;
}

Dlaczego? Mam nadzieję że się domyślacie ...

Jeśli zabronicie generowania konstruktora domyślnego - w trakcie kompilacji funkcji przyjmującej obiekt przez referencję - wystąpi błąd kompilacji.


class CMoja
{
public:
  CMoja() {
    tbl = new char[255];
  };
  CMoja(const CMoja&) = delete;
  
  ~CMoja() {
    delete[] tbl;
  }
private:
  char* tbl;
};

void ff(CMoja m) {
  ...
}

int main() {
  CMoja obiekt;
  ff(obiekt);

  CMoja *po = new CMoja();
  ff(*po);
  delete po;

  return 0;
}

Pola, zwykłe metody oraz konstruktory i destruktory to zdecydowanie najczęściej spotykane i chyba najważniejsze elementy klas. Można jeszcze wspomnieć, że wewnątrz klasy (a także struktury i unii) możemy zdefiniować kolejną klasę. Taką definicję nazywamy wtedy zagnieżdżoną. Technika ta nie jest stosowana zbyt często, ale jest dostępna. Podobnie zresztą jest z definicjami innych zagnieżdżonych typów - możecie zdefiniować wewnątrz klasy enumerację, możecie wykorzystywać typedef.

Na koniec rozważań o konstruktorach - mała rozrywka. Przygotujemy klasę, która ma tylko jedną instancję - singleton. By to uzyskać - musimy skorzystać ze składowych statycznych, prywatnego konstruktora,  oraz statycznej zmiennej lokalnej w metodzie. Działanie natomiast polega na prostym spostrzeżeniu - statyczne zmienne lokalne są tworzone przy pierwszym wywołaniu funkcji, i przechowywane na stosie pomiędzy wywołaniami aż do zakończenia programu. 


#include <iostream>

class CSingleton {
public:
    CSingleton(const CSingleton& c) = delete;
    static CSingleton* instancja() {
        static CSingleton jedynak;
        return &jedynak;
    }
    void akcja() {
        std::cout << ++licznik  << " akcja\n";
    }
private:
    CSingleton() {
        std::cout << "Tworzenie obiektu\n";
    }
    ~CSingleton() {
        std::cout << "Niszczenie obiektu\n";
    }
    int licznik{0};
};

void f() {
    auto& s = *CSingleton::instancja();
    std::cout << "W funkcji:\n";
    s.akcja();
}

int main() {
    std::cout << "Zaczynamy, jeszcze bez instancji\n";

    auto s = CSingleton::instancja();
    s->akcja();

    auto s2 = CSingleton::instancja();
    s->akcja();

    // CSingleton s3; // błąd - nie można tworzyć kopii na stosie
    // CSingleton s4{*s}; // błąd - nie można kopiować

    f();

    return 0;
}

Semantyka przeniesienia

Semantyka przeniesienia jest jedną z nowszych koncepcji, wprowadzoną do C++ wraz ze standardem C++11. Jej celem było wyeliminowanie zbędnego kopiowania danych - dając w to miejsce możliwość przenoszenia zawartości obiektu. Wprowadzenie tej idei do języka wymagało rozszerzenia jego funkcjonalności, bez którego nie dałoby się wprowadzić ich w życie. Clou tego rozszerzenia to referencje do r-wartości (r-value references).

Referencja do r-wartości przypomina tradycyjne referencje, które możemy nazwać referencjami do l-wartości. Ale zanim zagłębimy się w szczegóły, warto przejść przez podstawy...

Mam nadzieję, ż rozróżniacie l-wartości od r-wartości:

  • l-wartość to obiekt zdefiniowany z nazwą, który możemy umieścić po lewej stronie operatora przypisania (ale także po prawej) i pobrać jego adres za pomocą operatora &
  • r-wartość to obiekt bez zdefiniowanej nazwy – jest to obiekt tymczasowy, który można umieścić tylko i wyłącznie po prawej stronie operatora przypisania (nie można pobrać jego adresu za pomocą operatora &)

  int x = 4; // x to l-wartość, 4 to r-wartość
  MojaKlasa mc = MojaKlasa(); // mc to l-wartość, MojaKlasa() to r-wartość

Tworzenie referencji do obu typów wartości wygląda tak:


int x;
int& ref1 = x; // ref1 to referencja do l-wartości
int&& ref2 = 7; // ref2 to referencja do r-wartości

Referencję do r-wartości definiuje się przy użyciu podwójnego ampersandu (&&).

Jaka jest więc różnica między tymi dwoma rodzajami referencji?

Referencja do r-wartości może być przypisana do obiektu tymczasowego (r-wartości):


MojaKlasa&& ref1 = MojaKlasa(); // OK
MojaKlasa& ref2 = MojaKlasa(); // Błąd !

Daje nam to możliwość działania pozornie pozbawionego sensu: dzięki r-referencji możemy modyfikować oryginalny obiekt – w tym przypadku r-wartość, a więc np. literał - czyli coś, co potencjalnie jest niezmienne. Po co? Po to by mieć możliwość zniszczenia tego "niezmiennego" - jeśli wiemy że już nie będzie nam potrzebne. To kluczowe spostrzeżenie w kontekście semantyki przeniesienia.

Kopiowanie obiektów może być kosztowne. W przypadku niewielkich klas - zawierających pola typu podstawowego - problem nie jest duży. Jednakże, gdy mamy do czynienia z kolekcjami zawierającymi setki lub tysiące elementów, zaczynamy zauważać różnicę. A z kopiowaniem mamy często do czynienia. Czasem łatwo go uniknąć (np. przekazując parametr do funkcji przez referencję zamiast przez wartość), czasem pomoże nam kompilator (np. zamieniając operator przypisania na inicjację w przypadku prostego kodu typu string s = "Mała Megi"s), czasem jednak uniknąć go jest trudno np. jak zwracamy zmienną lokalną z funkcji przez return (tu też czasem pomoże kompilator) lub zamieniamy wartości dwóch zmiennych (tu musimy dać sobie radę sami). 

Podobne sytuacje często wynikają wprost z definicji języka - więc jest to problem strukturalny. 

W C++ od C++11 wprowadzono możliwość przeniesienia obiektu zamiast jego kopiowania, i przeprojektowano bibliotekę standardową po to, aby skutecznie wyeliminować sytuacje, w których zachodzi zupełnie niepotrzebne kopiowanie danych. Semantyka przeniesienia daje kompilatorowi możliwość zastąpienia kosztownych operacji kopiowania czymś, co jest (zazwyczaj) o wiele mniej kosztowne – tzw. operacjami przeniesienia. Jeśli kompilator wie, że kopiowany obiekt źródłowy nie będzie już używany, może po prostu zrezygnować z kopiowania ( tworzenia nowego obiektu, przenoszenia do niego danych i ewentualnego niszczenia niepotrzebnego obiektu źródłowego) na rzecz uznania obiektu źródłowego za obiekt docelowy. Co ważniejsze – programista może teraz jawnie poinformować kompilator o tym, że obiekt źródłowy może być w ten sposób użyty. 

Stosując r-referencję informujemy kompilator - oto obiekt, który możesz wykorzystać i zmodyfikować – skorzystaj z tego w celu optymalizacji. 

Pierwszą operacją, która na tym zyska - jest klasyczna zamiana wartości dwóch zmiennych. W klasycznym podejściu - mamy kopiowanie, w nowym - kopiowanie nie wystąpi. Popatrzcie na kod poniżej:


#include <iostream>

class CWektor {
public:
    CWektor(int rozmiar = 10, double wartosc = 0.0) : m_rozmiar{rozmiar}, m_dane{new double[rozmiar]} {
        std::cout << "Domyślny konstruktor\n";
        std::fill(m_dane, m_dane+m_rozmiar, wartosc);
    };
    CWektor(const CWektor& w) : m_rozmiar{w.m_rozmiar}, m_dane{new double[w.m_rozmiar]} {
        std::cout << "Konstruktor kopiujący\n";
        for (int i{0}; i<m_rozmiar; i++)
            m_dane[i] = w.m_dane[i];
    };
    CWektor(CWektor&& w) : m_rozmiar{w.m_rozmiar}, m_dane{w.m_dane} {
        std::cout << "Konstruktor przenoszący\n";
        w.m_dane = nullptr;
    };
    CWektor& operator=(const CWektor& w) {
        std::cout << "Przypisanie kopiujące\n";
        if (this != &w) {
            if (m_dane)
                delete[] m_dane;
            m_dane = new double[w.m_rozmiar];
            m_rozmiar = w.m_rozmiar;
            for (int i{0}; i < m_rozmiar; i++)
                m_dane[i] = w.m_dane[i];
        }
        return *this;
    };
    CWektor& operator=(CWektor&& w) {
        std::cout << "Przypisanie przenoszące\n";
        if (this != &w) {
            if (m_dane)
                delete[] m_dane;
            m_rozmiar = w.m_rozmiar;
            m_dane = w.m_dane;
            w.m_dane = nullptr;
        }
        return *this;
    };

    virtual ~CWektor() {
        if (m_dane)
            delete[] m_dane;
    }

private:
    int m_rozmiar{0};
    double* m_dane{nullptr};
};

void zamien(CWektor& a, CWektor& b) {
    auto t = a;
    a = b;
    b = t;
}

void noweZamien(CWektor& a, CWektor& b) {
    auto t = std::move(a);
    a = std::move(b);
    b = std::move(t);
}

int main() {
    CWektor x{1000, 1.0}, y{1000, 2.0};

    std::cout << "Klasycznie:\n";
    zamien(x, y);

    std::cout << "Bez kopiowania:\n";
    noweZamien(x, y);

    return 0;
}

W podejściu klasycznym - musieliśmy trzy razy kopiować bloki pamięci po 1000 elementów (funkcja zamien). Wykorzystując przeniesienie - nie kopiowaliśmy dużych bloków wcale - fajnie, nie?

W przeniesieniu pomógł nam szablon std::move. Jest on sposobem na to, by poinformował kompilator, by spróbował przekształcić parametr na r-wartość. Wewnętrznie jest to po prostu bezwarunkowe rzutowanie podanego argumentu na referencję do r-wartości i zwrócenie jej jako wyniku.

Oczywiście - po wykorzystaniu r-wartości do przeniesienia zawartości jednego obiektu do drugiego - nie można już korzystać z oryginału, i o to musi zadbać programista: 


    CWektor x{1000, 1.0};
    auto z = std::move(x); 
    // od tego momentu nie można już korzystać z x