Języki Programowania – Zajęcia 6

Konstruktory, destruktory i podział programu na jednostki kompilacji

Cel zajęć

Dzisiaj poznamy konstruktory i destruktory, czyli specjalne metody wywoływane automatycznie podczas tworzenia i usuwania obiektów. Nauczymy się inicjalizować pola obiektów za pomocą konstruktorów oraz obserwować czas ich życia. Poznamy również sposób dzielenia programu C++ na pliki nagłówkowe i źródłowe, a także podstawy automatyzacji kompilacji przy użyciu pliku Makefile.

1. Konstruktory

Konstruktor to specjalna metoda klasy, która jest wywoływana automatycznie podczas tworzenia obiektu. Najczęściej wykorzystujemy go do nadania początkowych wartości polom obiektu.

Konstruktor:

1.1. Konstruktor domyślny

Konstruktor domyślny to konstruktor, który można wywołać bez przekazywania argumentów. Może zostać zdefiniowany samodzielnie przez programistę. Jeżeli klasa nie zawiera żadnego własnego konstruktora, kompilator może wygenerować konstruktor domyślny.

class Student {
public:
    string imie;
    int numerIndeksu;

    Student() {
        imie = "Brak";
        numerIndeksu = 0;
    }
};

Utworzenie obiektu w main():

Student s1;

Podczas tworzenia obiektu s1 konstruktor zostaje wywołany automatycznie. Wartości pól zostają ustawione na "Brak" i 0.

1.2. Konstruktor z argumentami

Konstruktor może przyjmować argumenty, dzięki którym można ustawić początkowe wartości pól już w momencie tworzenia obiektu.

class Student {
public:
    string imie;
    int numerIndeksu;

    Student(string imie, int numerIndeksu) {
        this->imie = imie;
        this->numerIndeksu = numerIndeksu;
    }
};

Utworzenie obiektu w main():

Student s1("Jan", 12345);

W tym przypadku konstruktor otrzymuje dwa argumenty i ustawia odpowiednie pola obiektu.

Ważne:

Jeżeli sami zdefiniujemy w klasie konstruktor przyjmujący argumenty, kompilator nie wygeneruje automatycznie konstruktora domyślnego. Jeżeli chcemy tworzyć obiekty bez argumentów, musimy zdefiniować również konstruktor domyślny.

1.3. Przeciążanie konstruktorów

W jednej klasie można zdefiniować kilka konstruktorów, o ile różnią się liczbą lub typami argumentów. Dzięki temu obiekty można tworzyć na różne sposoby.

class Student {
public:
    string imie;
    int numerIndeksu;

    Student() {
        imie = "Brak";
        numerIndeksu = 0;
    }

    Student(string imie, int numerIndeksu) {
        this->imie = imie;
        this->numerIndeksu = numerIndeksu;
    }
};

int main() {
    Student s1;
    Student s2("Jan", 12345);

    cout << s1.imie << endl;   // Brak
    cout << s2.imie << endl;   // Jan

    return 0;
}

Pierwszy obiekt korzysta z konstruktora domyślnego, a drugi z konstruktora przyjmującego dwa argumenty.

1.4. Lista inicjalizacyjna

Innym sposobem inicjalizacji pól klasy jest wykorzystanie listy inicjalizacyjnej. Umieszcza się ją po nagłówku konstruktora, przed jego ciałem, po dwukropku.

class Student {
public:
    string imie;
    int numerIndeksu;

    Student(string imie, int numerIndeksu) : imie(imie), numerIndeksu(numerIndeksu) {
    }
};

Lista inicjalizacyjna bezpośrednio inicjalizuje pola obiektu. W przeciwieństwie do przypisania wartości w ciele konstruktora, inicjalizacja odbywa się jeszcze przed wykonaniem instrukcji znajdujących się wewnątrz konstruktora.

W niektórych sytuacjach lista inicjalizacyjna jest konieczna:

Ważne:

Lista inicjalizacyjna jest również dobrym wyborem wtedy, gdy nie jest bezwzględnie konieczna. Pozwala inicjalizować pola bezpośrednio, zamiast najpierw tworzyć je z wartościami domyślnymi, a następnie przypisywać im nowe wartości.

Pola są inicjalizowane w kolejności, w jakiej zostały zadeklarowane w klasie, a nie w kolejności podanej na liście inicjalizacyjnej.

2. Destruktory

Destruktor to specjalna metoda klasy, która jest wywoływana automatycznie w momencie kończenia czasu życia obiektu. Może służyć między innymi do zwalniania zasobów wykorzystywanych przez obiekt.

Destruktor:

Przykład:

class Student {
public:
    string imie;

    Student(string imie) {
        this->imie = imie;
        cout << "Utworzono studenta: " << imie << endl;
    }

    ~Student() {
        cout << "Usunieto studenta: " << imie << endl;
    }
};

Konstruktor wyświetla komunikat podczas tworzenia obiektu, natomiast destruktor wyświetla komunikat, gdy obiekt kończy swoje życie.

Uwaga: Destruktorów nie wywołujemy bezpośrednio. Są one uruchamiane automatycznie, gdy kończy się czas życia obiektu.

2.1. Czas życia obiektu

Obiekty tworzone jako zmienne lokalne istnieją do momentu zakończenia bloku, w którym zostały utworzone. Po opuszczeniu bloku ich destruktory są wywoływane automatycznie.

int main() {
    Student s1("Jan");

    {
        Student s2("Piotr");
        cout << "Koniec bloku wewnetrznego" << endl;
    }

    Student s3("Grzegorz");

    cout << "Koniec funkcji main" << endl;

    return 0;
}

W tym przykładzie:

  1. Najpierw zostaje utworzony obiekt s1.
  2. Następnie tworzony jest obiekt s2.
  3. Po opuszczeniu bloku wewnętrznego destruktor s2 zostaje wywołany automatycznie.
  4. Powstaje obiekt s3.
  5. Po zakończeniu funkcji main() niszczone są obiekty s3 i s1.

Obiekty lokalne są niszczone w odwrotnej kolejności do ich tworzenia.

Ważne:

Destruktor nie musi być definiowany samodzielnie. Jeżeli klasa nie posiada własnego destruktora, kompilator może wygenerować destruktor domyślny.

W przypadku obiektów tworzonych dynamicznie za pomocą new, destruktor zostaje wywołany podczas zwalniania pamięci za pomocą delete.

Ćwiczenie 1 – konstruktory i destruktory (niepunktowane)

Napisz program w języku C++, który wykorzystuje klasę KontoBankowe. Klasa powinna zawierać następujące prywatne pola:

W klasie zaimplementuj:

  1. Konstruktor domyślny, który ustawia pustą nazwę właściciela i saldo równe 0.
  2. Konstruktor parametryczny, który przyjmuje nazwę właściciela i początkowe saldo. Wykorzystaj listę inicjalizacyjną.
  3. Destruktor, który wyświetla komunikat informujący o zamknięciu konta i podaje nazwę jego właściciela.
  4. Metodę wypisz(), która wyświetla dane konta.

W funkcji main():

  1. Utwórz jedno konto za pomocą konstruktora domyślnego.
  2. Utwórz dwa kolejne konta za pomocą konstruktora parametrycznego, przekazując różne dane.
  3. Wywołaj metodę wypisz() dla każdego obiektu.
  4. Utwórz dodatkowy obiekt wewnątrz osobnego bloku kodu ({ }) i zaobserwuj, kiedy wywołuje się jego destruktor.
  5. Zaobserwuj kolejność wywoływania destruktorów dla pozostałych obiektów po zakończeniu funkcji main().

3. Podział programu na pliki – jednostki kompilacji

W większych programach umieszczanie wszystkich klas i funkcji w jednym pliku staje się niewygodne. Dlatego kod dzielimy na mniejsze części, umieszczając deklaracje i definicje w osobnych plikach.

Taki podział ułatwia organizację i ponowne wykorzystywanie kodu. Pozwala również oddzielić opis interfejsu klasy od szczegółów jej implementacji. Dzięki temu inne pliki mogą korzystać z klasy, nie musząc znać sposobu działania jej metod.

Najczęściej wykorzystujemy dwa rodzaje plików:

3.1. Plik nagłówkowy (.h)

W pliku nagłówkowym umieszczamy deklarację klasy, czyli informację o jej polach i metodach. Nie musimy tutaj umieszczać pełnej implementacji wszystkich metod.

Przykład – plik Produkt.h:

#ifndef PRODUKT_H
#define PRODUKT_H

#include <string>

using namespace std;

class Produkt {
private:
    string nazwa;
    double cena;

public:
    Produkt();
    Produkt(string nazwa, double cena);
    ~Produkt();

    void wypisz();
};

#endif

Zwróć uwagę, że w deklaracji metod podajemy ich nazwy, typy zwracane i argumenty, ale nie umieszczamy ciała metod.

3.2. Zabezpieczenia plików nagłówkowych

Ten sam plik nagłówkowy może zostać dołączony do programu z kilku różnych miejsc. Aby uniknąć wielokrotnego przetwarzania jego zawartości, stosujemy zabezpieczenia.

#ifndef PRODUKT_H
#define PRODUKT_H

// deklaracja klasy Produkt

#endif

Dyrektywa #ifndef sprawdza, czy określony identyfikator nie został wcześniej zdefiniowany. Jeżeli nie został, wykonywane są kolejne instrukcje, a #define definiuje ten identyfikator.

3.3. Plik źródłowy (.cpp)

W pliku źródłowym umieszczamy definicje metod zadeklarowanych wcześniej w pliku nagłówkowym. Aby korzystać z klasy zadeklarowanej w innym pliku, należy dołączyć odpowiedni plik nagłówkowy za pomocą dyrektywy #include.

Ważne:

W przypadku bibliotek standardowych używamy nawiasów ostrych, np. <iostream>. Dla własnych plików nagłówkowych stosujemy cudzysłowy, np. "Produkt.h".

Przykład – plik Produkt.cpp:

#include <iostream>   // biblioteka C++
#include "Produkt.h"   // własny plik

Produkt::Produkt() {
    nazwa = "Nieznany";
    cena = 0;
}

Produkt::Produkt(string nazwa, double cena) : nazwa(nazwa), cena(cena) {
}

Produkt::~Produkt() {
    cout << "Usunieto produkt: " << nazwa << endl;
}

void Produkt::wypisz() {
    cout << "Nazwa: " << nazwa << endl;
    cout << "Cena: " << cena << endl;
}

Operator :: to operator zakresu. W zapisie Produkt::wypisz() wskazuje, że metoda wypisz() należy do klasy Produkt.

3.4. Plik główny (main.cpp)

Plik main.cpp zawiera funkcję main(), od której rozpoczyna się wykonywanie programu. Umieszczamy w nim główną część programu, czyli instrukcje korzystające z utworzonych klas i funkcji.

W naszym przykładzie plik main.cpp dołącza nagłówek Produkt.h, aby móc tworzyć obiekty klasy Produkt i wywoływać ich metody.

Przykład – plik main.cpp:

#include <iostream>
#include "Produkt.h"

using namespace std;

int main() {
    Produkt p1;
    Produkt p2("Laptop", 3500);

    p1.wypisz();
    p2.wypisz();

    return 0;
}

Warto zauważyć, że main.cpp nie musi znać szczegółów implementacji metod klasy Produkt. Wystarczy, że dołączy jej plik nagłówkowy. Definicje metod znajdują się w osobnym pliku Produkt.cpp.

3.5. Kompilacja i linkowanie

Program składający się z kilku plików źródłowych musi zostać odpowiednio skompilowany i połączony. Proces ten można podzielić na kilka etapów:

  1. Preprocesowanie – przetwarzane są dyrektywy preprocesora, np. #include. Zawartość dołączanych plików nagłówkowych zostaje uwzględniona w pliku źródłowym.
  2. Kompilacja – kod źródłowy jest tłumaczony na kod niższego poziomu.
  3. Tworzenie plików obiektowych – powstają pliki pośrednie, np. main.o i Produkt.o.
  4. Linkowanie – linker łączy pliki obiektowe i potrzebne biblioteki w jeden program wykonywalny.

Każdy plik źródłowy .cpp jest osobno przetwarzany i kompilowany. Następnie otrzymane pliki obiektowe są łączone. Dzięki temu zmiana w jednym pliku źródłowym nie musi oznaczać ponownej kompilacji wszystkich pozostałych plików.

Przykładowa struktura projektu:

projekt/
│
├── Produkt.h
├── Produkt.cpp
└── main.cpp

Możemy wykonać poszczególne etapy ręcznie za pomocą kompilatora g++. Najpierw tworzymy pliki obiektowe:

g++ -c main.cpp
g++ -c Produkt.cpp

Opcja -c oznacza, że kompilator ma utworzyć plik obiektowy, ale nie wykonywać linkowania. W wyniku otrzymujemy pliki main.o i Produkt.o.

Następnie łączymy je w jeden program:

g++ main.o Produkt.o -o program

Opcja -o program określa nazwę tworzonego programu wykonywalnego.

Możemy również wykonać kompilację i linkowanie jednym poleceniem:

g++ main.cpp Produkt.cpp -o program
Ważne:

Plików nagłówkowych .h nie przekazujemy bezpośrednio jako osobnych plików do kompilacji. Są one dołączane do plików źródłowych za pomocą dyrektywy #include.

3.6. Automatyzacja kompilacji – Makefile

W większych projektach ręczne wpisywanie poleceń kompilacji może być niewygodne. Jeżeli program składa się z wielu plików, każda zmiana i ponowna kompilacja wymagałaby wpisywania odpowiednich poleceń.

Do automatyzacji tego procesu można wykorzystać narzędzie make oraz plik konfiguracyjny Makefile.

Plik Makefile zawiera reguły określające, jak tworzyć poszczególne pliki wynikowe oraz od jakich plików zależą. W naszym przykładzie wykorzystamy go do automatycznego wykonania kolejnych etapów kompilacji: utworzenia plików obiektowych, a następnie połączenia ich w program wykonywalny.

Przykładowy Makefile

program: main.cpp Produkt.cpp Produkt.h
	g++ main.cpp Produkt.cpp -o program

clean:
	rm -f program

Wyjaśnienie:

W każdej regule nazwa celu znajduje się przed dwukropkiem, a po nim wymieniamy pliki, od których ten cel zależy. W kolejnych liniach zapisujemy polecenia potrzebne do utworzenia danego celu.

Uwaga: Jest to uproszczony Makefile. Przy każdej zmianie jednego z wymienionych plików oba pliki źródłowe zostaną ponownie skompilowane. Nie tworzymy tutaj osobnych plików obiektowych (.o), dlatego ten wariant nie demonstruje wieloetapowej kompilacji.

Ważne:

Linie zawierające polecenia w pliku Makefile muszą rozpoczynać się znakiem tabulacji, a nie spacjami.

Aby zbudować program, wystarczy wykonać w terminalu:

make

Aby usunąć pliki wygenerowane podczas kompilacji:

make clean

Ćwiczenie 2 – podział programu na pliki i Makefile (niepunktowane)

Wykorzystaj program z Ćwiczenia 1 i podziel go na trzy pliki: KontoBankowe.h, KontoBankowe.cpp oraz main.cpp.

  1. W pliku KontoBankowe.h umieść deklarację klasy KontoBankowe oraz zastosuj zabezpieczenie przed wielokrotnym dołączaniem nagłówka.
  2. W pliku KontoBankowe.cpp umieść definicje konstruktorów, destruktora i metody wypisz().
  3. W pliku main.cpp umieść funkcję main() i dołącz odpowiedni plik nagłówkowy.
  4. Skompiluj program etapami, tworząc pliki obiektowe, a następnie łącząc je w jeden program wykonywalny.
  5. Utwórz plik Makefile, który automatyzuje kompilację programu.
  6. Sprawdź działanie programu i upewnij się, że jego zachowanie jest takie samo jak przed podziałem na pliki.