Zagadnienia

6. Klasy w C++

Klasy w C++

6.1. Klasy jako struktury

6.1.1. Klasy - podstawowe pojęcia

  • Klasa jest nowym typem danych zdefiniowanym przez użytkownika.

  • Wartości tak zdefiniowanego typu nazywamy obiektami.

  • Najprostsza klasa jest po prostu strukturą (rekordem w Pascalu), czyli paczką kilku różnych zmiennych.

  • Składnia deklaracji klasy:

    . specyfikator_klasy:
    . . nagłówek_klasy { specyfikacja\_ składowych_{{opt}} }
    . nagłówek_klasy:
    . . słowo_kluczowe_klasy identyfikator_{{opc}} klauzula\_ klas\_ bazowych_{{opc}}
    . . słowo_kluczowe_klasy specyfikator_zagnieżdżonej_nazwy identyfikator_{{opc}} klauzula\_ klas\_ bazowych_{{opc}}

6.1.2. Przykład klasy - liczby zespolone

W tym rozdziale przyjrzymy się definiowaniu klas na przykładzie klasy Zespolona, której obiekty reprezentują (oczywiście) liczby zespolone.

Bez klas byłoby tak:

struct Zespolona{
 double re, im;
};

Dokładnie to samo można wyrazić używając klas:

  class Zespolona{
   public:
    double re, im;
  };
 

Ale taka definicja nie wystarcza, potrzebujemy operacji na tym typie danych. Możemy je zdefiniować tak:

 Zespolona dodaj(Zespolona z1, Zespolona z2){
  Zespolona wyn;
  wyn.re = z1.re + z2.re;
  wyn.im = z1.im + z2.im;
  return wyn;
 }
Ma to jednak tę wadę, że trudno się zorientować, czym tak naprawdę jest typ Zespolona (trzeba przeczytać cały program, żeby znaleźć wszystkie definicje dotyczące liczb zespolonych).

6.2. Klasy jako struktury z operacjami

6.2.1. Operacje w klasie

W C++ możemy powiązać definicję typu danych z dozwolonymi na tym typie operacjami:

class Zespolona{
 public:
  double re, im;

  Zespolona dodaj(Zespolona);
  Zespolona odejmij(Zespolona);
  double modul();
};

Zauważmy, że:

  • Zwiększyła się czytelność programu: od razu widać wszystkie operacje dostępne na naszym typie danych.

  • Zmieniła się liczba parametrów operacji.

  • Nie podaliśmy (jeszcze) ani treści operacji, ani nazw parametrów.

To co podaliśmy powyżej jest specyfikacją interfejsu typu Zespolona. Oczywiście trzeba też określić implementację (gdzieś dalej w programie).

6.2.2. Implementacja operacji z klasy

Zespolona Zespolona::dodaj(Zespolona z){
 Zespolona wyn;
 wyn.re = re + z.re;
 wyn.im = im + z.im;
 return wyn;
}

Zespolona Zespolona::odejmij(Zespolona z){
 Zespolona wyn;
 wyn.re = re - z.re;
 wyn.im = im - z.im;
 return wyn;
}

double Zespolona::modul(){
 return sqrt(re*re + im*im);
}
 

6.3. Kapsułkowanie

6.3.1. Klasy potrafią chronić swoje dane

Przy poprzedniej definicji klasy Zespolona, można było pisać następujące instrukcje:

	Zespolona z;
	double mod;
	.....
	mod = sqrt(z.re*z.im+z.im*z.im);  // Błąd !!!

6.3.2. Po co jest potrzebna ochrona danych

Nie znamy na razie metody zmuszającej użytkownika do korzystania tylko z dostarczonych przez nas operacji. To bardzo źle, bo:

  • Upada poprzednio postawiona teza, że wszystkie operacje na typie danych są zdefiniowane tylko w jednym miejscu.

  • Użytkownik pisząc swoje operacje może (tak jak w przykładzie z mod) napisać je źle.

  • Projektując klasę, zwykle nie chcemy, żeby użytkownik mógł bez naszej wiedzy modyfikować jej zawartość (przykład ułamek: nie chcielibyśmy, żeby ktoś wpisał nam nagle mianownik równy zero).

  • Program użytkownika odwołujący się do wewnętrznej reprezentacji klasy niepotrzebnie się od niej uzależnia (np. pola re nie możemy teraz nazwać czesc_rzeczywista).

6.3.3. Składowe prywatne i publiczne

Na szczęście w C++ możemy temu bardzo łatwo zaradzić. Każda składowa klasy (zmienna lub metoda) może być:

  • Prywatna (private:).

  • Publiczna, czyli ogólnodostępna (public:).

Domyślnie wszystkie składowe klasy są prywatne, zaś wszystkie składowe struktury publiczne. Jest to zresztą (poza domyślnym trybem dziedziczenia i oczywiście słowem kluczowym) jedyna różnica pomiędzy klasami a strukturami w C++.

6.3.4. Klasa Zespolona z ochroną danych

Zatem teraz mamy następującą deklarację:

class Zespolona{
 private:  // tu można pominąć private:
  double re, im;
 public:
  Zespolona dodaj(Zespolona);
  Zespolona odejmij(Zespolona);
  double modul();
};

6.3.5. Klasa Zespolona z ochroną danych - konsekwencje

Teraz zapis:

 mod = sqrt(z.re*z.re+z.im*z.im);  // Błąd składniowy (choć wzór poprawny)
jest niepoprawny. Użytkownik może natomiast napisać:
mod = z.modul();

6.4. Konstruktory i destruktory

6.4.1. Czy chcemy mieć niezainicjowane obiekty?

Czy chcemy mieć niezainicjowane obiekty? Oczywiście nie:

 {
  Zespolona z1;
  cout << z1.modul();  // Wypisze się coś bez sensu
 }
 

Jak temu zaradzić? Można dodać metodę ini(), która będzie inicjować liczbę, ale … to nic nie daje. Dalej nie ma możliwości zagwarantowania, że zmienna typu Zespolona będzie zainicjowana przed pierwszym jej użyciem.

6.4.2. Konstruktory

Na szczęście w C++ możemy temu skutecznie zaradzić. Rozwiązaniem są konstruktory. Konstruktor jest specjalną metodą klasy. Ma taką samą nazwę jak klasa. Nie można podać typu wyniku konstruktora. Nie można przekazać z niego wyniku instrukcją return. Można w nim wywoływać funkcje składowe klasy. Można go wywołać jedynie przy tworzeniu nowego obiektu danej klasy. W klasie można (i zwykle tak się robi) zdefiniować wiele konstruktorów. Konstruktor może mieć (nie musi) parametry. Konstruktor jest odpowiedzialny za dwie rzeczy:

  • zapewnienie, że obiekt będzie miał przydzieloną pamięć (to jest sprawa kompilatora),

  • inicjację obiektu (to nasze zadanie, realizuje je treść konstruktora).

6.4.3. Rodzaje konstruktorów

Wyróżnia się kilka rodzajów konstruktorów:

  • Konstruktor bezargumentowy:

    • można go wywołać bez argumentów,

    • jest konieczny, jeśli chcemy mieć tablice obiektów tej klasy.

  • Konstruktor domyślny:

    • jeśli nie zdefiniujemy żadnego konstruktora, to kompilator sam wygeneruje konstruktor domyślny (bezargumentowy).

    • ten konstruktor nie inicjuje składowych typów prostych,

    • dla składowych będących klasami lub strukturami wywołuje ich konstruktory bezargumentowe,

    • jeśli składowa będąca klasą lub strukturą nie ma konstruktora bezargumetowego bądź jest on niedostępny, generowanie konstruktora domyślnego kończy się błędem kompilacji.

  • Konstruktor kopiujący:

    • można go wywołać z jednym argumentem tej samej klasy, przekazywanym przez referencję,

    • jeśli żadnego takiego konstruktora nie zdefiniujemy, to kompilator wygeneruje go automatycznie. Uwaga: automatycznie wygenerowany konstruktor kopiujący kopiuje obiekt składowa po składowej, więc zwykle się nie nadaje dla obiektów zawierających wskaźniki!!!

    • jest wywoływany niejawnie przy przekazywaniu parametrów do funkcji i przy przekazywaniu wyników funkcji!!!

6.4.4. Klasa Zespolona z konstruktorem

Teraz deklaracja naszej klasy wygląda następująco:

class Zespolona{
 private:  // tu można pominąć private:
  double re, im;
 public:
  // konstruktory
   Zespolona(double, double);
  // operacje
   Zespolona dodaj(Zespolona);
   Zespolona odejmij(Zespolona);
   double modul();
};

Zespolona::Zespolona(double r, double i){
	re = r;
	im = i;
}

// ... reszta definicji

6.4.5. Konsekwencje zdefiniowania konstruktora

Jakie są konsekwencje zdefiniowania konstruktora?

Zespolona z;		// Błąd! Nie ma już konstruktora domyślnego
Zespolona z(3,2);	// OK, taki konstruktor jest zdefiniowany.
 
Zatem nie można teraz utworzyć niezainicjowanego obiektu klasy Zespolona!

6.4.6. Konstruktory a obiekty tymczasowe

Każde użycie konstruktora powoduje powstanie nowego obiektu. Można w ten sposób tworzyć obiekty tymczasowe:

double policz_cos(Zespolona z){
	// .....
}
 
Można tę funkcję wywołać tak:
Zespolona z(3,4);
policz_cos(z);
 
ale jeśli zmienna z nie jest potrzebna, to można wywołać tę funkcję także tak:
policz_cos( Zespolona(3,4) );
 

Utworzony w ten sposób obiekt tymczasowy będzie istniał tylko podczas wykonywania tej jednej instrukcji.

6.4.7. Konstruktor kopiujący w klasie Zespolona

Dla klasy Zespolona nie ma potrzeby definiowania konstruktora kopiującego (ten wygenerowany automatycznie przez kompilator zupełnie nam w tym przypadku wystarczy). Gdybyśmy jednak chcieli, to musielibyśmy zrobić to następująco:

class Zespolona{
 private:  // tu można pominąć private:
  double re, im;
 public:
  // konstruktory
   Zespolona(double, double);
   Zespolona(Zespolona&);
  // operacje
   Zespolona dodaj(Zespolona);
   Zespolona odejmij(Zespolona);
   double modul();
};

Zespolona::Zespolona(const Zespolona& z){
	re = z.re;
	im = z.im;
}
 

6.4.8. Ułatwianie sobie życia

Jest zupełnie naturalne, by chcieć używać liczb zespolonych, które są tak naprawdę liczbami rzeczywistymi. Możemy to teraz robić następująco:

	Zespolona z(8,0);
 
Gdybyśmy mieli często używać takich liczb, to wygodniej by było mieć konstruktor, który sam dopisuje zero:
class Zespolona{
// ...
	public:
		// konstruktory
		Zespolona(double);
	// ...
};

Zespolona::Zespolona(double r)
{
	re = r;
	im = 0;
}

Przedstawione rozwiązanie jest zupełnie poprawne. Można definiować wiele konstruktorów, kompilator C++ na podstawie listy argumentów zdecyduje, którego należy użyć. Możemy to jednak zapisać prościej korzystając z parametrów domyślnych:

class Zespolona{
 private:  // tu można pominąć private:
  double re, im;
 public:
  // konstruktory
  Zespolona(double, double = 0);
  Zespolona(Zespolona&);
  // operacje
  Zespolona dodaj(Zespolona);
  Zespolona odejmij(Zespolona);
  double modul();
};

Zespolona::Zespolona(double r, double i){
	re = r;
	im = i;
}

// .....

Uwaga: nie można deklarować wartości domyślnej i w nagłówku funkcji i w jej implementacji.

Zdefiniowanie konstruktora liczb zespolonych z jednym argumentem (liczbą typu double) ma dalsze konsekwencje. Poniższe wywołanie jest teraz poprawne:

policz_cos( 6 );
 
Innymi słowy zdefiniowanie w klasie K konstruktora, którego można wywołać z jednym parametrem typu T, oznacza zdefiniowanie konwersji z typu T do typu K. O tym jak definiować konwersje w drugą stronę powiemy później (omawiając operatory).

6.4.9. Zwalnianie zasobów

Gdy obiekt kończy swoje istnienie automatycznie zwalnia się zajmowana przez niego pamięć. Nie dotyczy to jednak zasobów, które obiekt sam sobie przydzielił w czasie swego istnienia. Rozwiązaniem tego problemu są destruktory. Destruktor to metoda klasy. Klasa może mieć co najwyżej jeden destruktor. Destruktor nie ma parametrów. Nie można specyfikować typu wyniku destruktora. Nie można w nim używać instrukcji return z parametrem. Nazwa destruktora jest taka sama jak nazwa klasy, tyle że poprzedzona tyldą. Destruktor jest odpowiedzialny za dwie rzeczy:

  • zwolnienie pamięci zajmowanej przez obiekt (to sprawa kompilatora),

  • zwolnienie zasobów (to nasze zadanie, zwalnianie zasobów zapisujemy jako treść destruktora).

Zasobami, które obiekty przydzielają sobie najczęściej są fragmenty pamięci.

6.4.10. Destruktor w klasie Zespolona

W klasie Zespolona destruktor nie jest potrzebny, ale można go zdefiniować:

class Zespolona{
 private:  // tu można pominąć private:
  double re, im;
 public:
  // konstruktory i destruktory
   Zespolona(double, double = 0);
   Zespolona(Zespolona&);
   ~Zespolona();
  // operacje
   Zespolona dodaj(Zespolona);
   Zespolona odejmij(Zespolona);
   double modul();
};

Zespolona::~Zespolona(){
  // W tej klasie nie mamy żadnych zasobów do zwolnienia
}

6.5. Uwaga o szablonie complex

W tym rozdziale przedstawiliśmy definiowanie klasy na przykładzie liczb zespolonych. Taki przykład wybrano, gdyż reprezentuje dobrze znane pojęcie, jest prosty, a jednocześnie pozwala na pokazanie wielu interesujących własności klas. Warto jednak zaznaczyć, że standardowa biblioteka C++ zawiera własną definicję liczb zespolonych w postaci szablonu complex (szablony omawiamy w osobnym rozdziale). Oto fragment programu używającego szablonu complex.

 #include <iostream>
 #include <complex>

 using namespace std;

 int main(){
   complex<double> c1;
   complex<double> c2(7.0, 3.5);
   cout << "c1 = " << c1 << ", c2 = " << c2;
   cout << ", c1 + c2 = " << c1+c2 << endl;
 }

Wynikiem działania tego programu jest wypisanie

c1 = (0,0), c2 = (7,3.5), c1 + c2 = (7,3.5)

Treść automatycznie generowana z plików źródłowych LaTeXa za pomocą oprogramowania wykorzystującego LaTeXML.

Projekt współfinansowany przez Unię Europejską w ramach Europejskiego Funduszu Społecznego.

Projekt współfinansowany przez Ministerstwo Nauki i Szkolnictwa Wyższego i przez Uniwersytet Warszawski.