Języki Programowania – Zajęcia 6
Konstruktory, destruktory i podział programu na jednostki kompilacji
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:
- ma taką samą nazwę jak klasa,
- nie posiada określonego typu zwracanego (nawet
void), - może przyjmować argumenty,
- może być przeciążany, czyli klasa może posiadać kilka konstruktorów różniących się listą argumentów.
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.
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:
-
Pola zadeklarowane jako
const– ich wartość musi zostać ustalona podczas inicjalizacji obiektu i nie wolno jej później zmienić przez przypisanie. - Pola będące referencjami – referencja musi zostać powiązana z obiektem już podczas inicjalizacji.
- Pola będące obiektami klas bez konstruktora domyślnego – taki obiekt składowy musi zostać zainicjalizowany za pomocą odpowiedniego konstruktora.
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:
- ma taką samą nazwę jak klasa, poprzedzoną znakiem
~, - nie przyjmuje argumentów,
- nie posiada typu zwracanego,
- może występować tylko jeden w danej klasie.
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.
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:
- Najpierw zostaje utworzony obiekt
s1. - Następnie tworzony jest obiekt
s2. - Po opuszczeniu bloku wewnętrznego destruktor
s2zostaje wywołany automatycznie. - Powstaje obiekt
s3. - Po zakończeniu funkcji
main()niszczone są obiektys3is1.
Obiekty lokalne są niszczone w odwrotnej kolejności do ich tworzenia.
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:
wlasciciel– imię i nazwisko właściciela konta (typstring),saldo– aktualne saldo konta (typdouble).
W klasie zaimplementuj:
- Konstruktor domyślny, który ustawia pustą nazwę właściciela i saldo równe 0.
- Konstruktor parametryczny, który przyjmuje nazwę właściciela i początkowe saldo. Wykorzystaj listę inicjalizacyjną.
- Destruktor, który wyświetla komunikat informujący o zamknięciu konta i podaje nazwę jego właściciela.
- Metodę
wypisz(), która wyświetla dane konta.
W funkcji main():
- Utwórz jedno konto za pomocą konstruktora domyślnego.
- Utwórz dwa kolejne konta za pomocą konstruktora parametrycznego, przekazując różne dane.
- Wywołaj metodę
wypisz()dla każdego obiektu. - Utwórz dodatkowy obiekt wewnątrz osobnego bloku kodu (
{ }) i zaobserwuj, kiedy wywołuje się jego destruktor. - 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:
-
.h– plik nagłówkowy, zawierający deklaracje klas, pól i metod. -
.cpp– plik źródłowy, zawierający definicje metod i funkcji.
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.
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:
-
Preprocesowanie – przetwarzane są dyrektywy
preprocesora, np.
#include. Zawartość dołączanych plików nagłówkowych zostaje uwzględniona w pliku źródłowym. - Kompilacja – kod źródłowy jest tłumaczony na kod niższego poziomu.
-
Tworzenie plików obiektowych – powstają
pliki pośrednie, np.
main.oiProdukt.o. - 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
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:
program– nazwa celu, czyli pliku wykonywalnego, który chcemy utworzyć.main.cpp Produkt.cpp Produkt.h– pliki, od których zależy utworzenie programu. Jeśli któryś z nich zostanie zmieniony, program zostanie ponownie skompilowany.g++ main.cpp Produkt.cpp -o program– kompiluje oba pliki źródłowe i tworzy plik wykonywalny o nazwieprogram.clean– dodatkowy cel służący do usunięcia pliku wykonywalnego.rm -f program– usuwa plikprogram. Opcja-fzapobiega wyświetlaniu błędu, jeśli plik nie istnieje.
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.
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.
- W pliku
KontoBankowe.humieść deklarację klasyKontoBankoweoraz zastosuj zabezpieczenie przed wielokrotnym dołączaniem nagłówka. - W pliku
KontoBankowe.cppumieść definicje konstruktorów, destruktora i metodywypisz(). - W pliku
main.cppumieść funkcjęmain()i dołącz odpowiedni plik nagłówkowy. - Skompiluj program etapami, tworząc pliki obiektowe, a następnie łącząc je w jeden program wykonywalny.
- Utwórz plik
Makefile, który automatyzuje kompilację programu. - Sprawdź działanie programu i upewnij się, że jego zachowanie jest takie samo jak przed podziałem na pliki.