1. Programowanie obiektowe

1.7. Korzystanie z obiektów

Nawet dziesiątki wyśmienitych klas nie stanowią jeszcze gotowego programu, a jedynie pewien rodzaj reguł, wedle których będzie on realizowany. Wprowadzenie tych reguł w życie wymaga utworzenia obiektów na podstawie zdefiniowanych klas, oraz wykonanie przy ich pomocy zadań stawianych przed programem. W C++ mamy dwa główne sposoby "obchodzenia" się z obiektami; różnią się one pod wieloma względami, inne jest też zastosowanie każdego z nich. Naturalną i rozsądną koleją rzeczy będzie więc przyjrzenie się im obu

Pierwszą strategię znamy już bardzo dobrze, używaliśmy jej bowiem niejednokrotnie nie tylko dla samych obiektów, lecz także dla wszystkich innych zmiennych – tworzymy statyczne zmienne obiektowe. W tym trybie korzystamy z klasy dokładnie tak samo, jak ze wszystkich innych typów w C++ - czy to wbudowanych, czy też definiowanych przez nas samych (jak enum 'y, struktury itd.). Każde pojawienie się definicji nowej zmiennej, np takiej:


CMoja obiekt;

Wykonuje jednak znacznie więcej czynności, niż jest to widoczne na pierwszy czy nawet drugi rzut oka. Pisząc tą jedną linijkę wykonuję następujące czynności:

  • wprowadzam nową zmienną obiekt typu CMoja. Nie jest to rzecz jasna żadna nowość, ale dla porządku warto o tym przypomnieć.
  • tworzę w pamięci operacyjnej obszar, w którym będą przechowywane pola obiektu. To także nie jest zaskoczeniem: pola, jako bądź co bądź zmienne, muszą rezydować gdzieś w pamięci, więc robią to w identyczny sposób jak pola struktur.
  • wywołuję domyślny konstruktor klasy CMoja (czyli metodę CMoja::CMoja() ), by dokończył aktu kreacji obiektu. Po jego zakończeniu możemy uznać nasz obiekt za ostatecznie stworzony i gotowy do użycia.

Te trzy etapy są niezbędne, abyśmy mogli bez problemu korzystać z obiektu. W tym przypadku są one jednak realizowane całkowicie automatycznie i nie wymagają od nas żadnej uwagi. Przekonamy się później, że nie zawsze tak jest i, co ciekawe, wcale nie będziemy tym zmartwieni. Muszę jeszcze wspomnieć o pewnym drobnym wymaganiu, stawianym nam przez kompilator, któremu chcemy podać wiersz kodu umieszczony na początku paragrafu. Otóż klasa CMoja musi tutaj posiadać bezparametrowy konstruktor - utworzony jawnie w definicji klasy, lub też wygenerowany domyślnie. 

W innym przypadku potrzebne jest jeszcze przekazanie odpowiednich parametrów konstruktorowi, który takowych wymaga. Konieczność tą realizujemy podobną metodą, co wywołanie zwyczajnej funkcji. Zakładając, że CMoja posiada konstruktor przyjmujący jedną liczbę całkowitą, oraz napis, możliwe jest wywołanie:


    CMoja moja( 10 , "jakiś tekst" );

Zadeklarowane przed chwilą zmienne obiektowe są w istocie takimi samymi zmiennymi, jak wszystkie inne w programach C++. Możliwe jest przeprowadzanie na takich zmiennych operacji, którym podlegają na przykład liczby całkowite, napisy czy tablice. Nie mam tu wcale na myśli jakichś złożonych manipulacji, wymagających skomplikowanych algorytmów, lecz całkiem zwyczajnych i codziennych -  przypisanie czy przekazywanie do funkcji. Czy można powiedzieć cokolwiek ciekawego o tak trywialnych czynnościach? Okazuje się, że tak. Zwrócimy wprawdzie uwagę na dość oczywiste fakty z nimi związane, lecz znajomość owych "banałów" okaże się później niezwykle przydatna.

Na użytek dalszych wyjaśnień wróćmy i nieco rozszerzmy początkową definicję diody:


#ifndef cdiodaH
#define cdiodaH

#include "string"
using namespace std;

/* Deklaracja klasy CDioda*/
class CDioda {
public:
	/// konstruktor podstawowy
	CDioda();
	/// konstruktor z ustawieniem koloru
	CDioda(string _kolor);
  /// metoda włączająca diodę
  void zapal();
  /// metoda wyłączająca diodę
  void zgas();
  /// metoda wyświetlająca stan diody
  void pokaz();
private:
  string kolor;
	bool zapalona;
};

#endif

#include "iostream"

#include "cdioda.h"

using namespace std;

CDioda::CDioda() {
	kolor = "bialy";
	zapalona = false;
}
CDioda::CDioda(string _kolor) {
	kolor = _kolor;
	zapalona = false;
}

void CDioda::zapal() {
  zapalona = true;
}

void CDioda::zgas() {
  zapalona = false;
}

void CDioda::pokaz() {
  if (zapalona)
    cout << "Swieci w kolorze " << kolor << endl;
  else
    cout << "Nie swieci\n";
}

Natychmiast też zadeklarujemy i stworzymy dwa obiekty należące do naszej klasy:


CDioda dioda1("czerwona”), dioda2("zielona”);

Tym sposobem mamy więc diody, sztuk dwie, w kolorze czerwonym oraz zielonym. Moglibyśmy użyć ich metod, aby je obie włączyć; zrobimy jednak coś dziwniejszego - przypiszemy jedną lampę do drugiej:


dioda1=dioda2;

Co to oznacza? By dobrze zrozumieć powyższą operację - musimy pamiętać, że dioda1 oraz dioda2 są to przede wszystkim zmienne , które przechowują pewne wartości. Fakt, że tymi wartościami są obiekty,  nie ma większego znaczenia. Pomyślmy zatem, jaki efekt spowodowałby ten kod, gdybyśmy zamiast klasy CDioda użyli jakiegoś zwykłego, fundamentalnego typu zmiennej? Dawna wartość zmiennej, do której nastąpiło przypisanie, zostałaby zapomniana i obie zmienne zawierałyby tę samą liczbę.

Dla obiektów rzecz ma się identycznie: po wykonaniu przypisania zarówno Lampa1 , jak i Lampa2 reprezentować będą obiekty zielonych lamp. Czerwona lampa, pierwotnie zawarta w zmiennej Lampa1 , zostanie zniszczona, a w jej miejsce pojawi się kopia zawartości zmiennej Lampa2. Nie bez powodu zaakcentowałem wyżej słowo "kopia". Obydwa obiekty są bowiem od siebie całkowicie niezależne - ich tożsamości są inne. Jeżeli włączylibyśmy jeden z nich:


dioda1.zapal();

drugi nie zmieniłby się wcale i nie obdarzył nas swym własnym światłem. Możemy więc podsumować nasz wywód krótką uwagą na temat zmiennych obiektowych:

Zmienne obiektowe przechowują obiekty w ten sam sposób, w jaki czynią to zwykłe zmienne ze swoimi wartościami. Identycznie odbywa się też przypisywanie takich zmiennych - tworzone są wtedy odpowiednie kopie obiektów.

Wspominałem, że wszystko to może wydawać się naturalne, oczywiste i niepodważalne - warto na to jednak zwrócić uwagę zanim zaczniemy ręcznie zarządzać czasem życia obiektów. 

Korzystając z obiektu zazwyczaj odwołujemy się do jego części składowych - metod lub pól. Tu z pomocą przychodzi nam zawsze operator wyłuskania - kropka ( . ). Stawiamy więc go po nazwie obiektu, by potem wpisać nazwę metody / pola, do którego chcemy się odwołać. Pamiętajmy, że posiadamy wtedy dostęp jedynie do składowych publicznych klasy, do której należy obiekt.

Dalsze postępowanie zależy już od tego, czy naszą uwagę zwróciliśmy na pole, czy na metodę. W tym pierwszym, rzadszym przypadku nie odczujemy żadnej różnicy w stosunku do pól w strukturach - i nic dziwnego, gdyż nie ma tu rzeczywiście najmniejszej rozbieżności. Wywołanie metody jest natomiast łudząco zbliżone do uruchomienia zwyczajnej funkcji - tyle że w grę wchodzą tutaj nie tylko jej parametry, ale także obiekt, dla którego daną metodę wywołujemy.

Każdy stworzony obiekt musi prędzej czy później zostać zniszczony, aby móc odzyskać zajmowaną przez niego pamięć i spokojnie zakończyć program. Dotyczy to także zmiennych obiektowych, lecz dzieje się to trochę jakby za plecami programisty. Zauważmy bowiem, iż w żadnym z naszych dotychczasowych programów, wykorzystujących techniki obiektowe, nie pojawiły się instrukcje, które jawnie odpowiadałyby za niszczenie stworzonych obiektów. Nie oznacza to bynajmniej, że zalegają one w pamięci operacyjnej, zajmując ją niepotrzebnie. Po prostu kompilator sam dba o to, by ich destrukcja nastąpiła w stosownej chwili - analogicznie do typów prostych. Omawialiśmy już zasięg zmiennej - czyli w uproszczeniu fragment kodu, w którym dana zmienna jest dostępna. Dostępna - to znaczy zadeklarowana, z przydzieloną dla siebie pamięcią. Moment opuszczenia zasięgu zmiennej przez program jest więc kresem jej istnienia. Jeśli nieszczęsna zmienna była obiektową, do akcji wkracza destruktor klasy (jeżeli został określony), sprzątając ewentualny bałagan po obiekcie i niszcząc go. Dalej następuje już tylko zwolnienie pamięci zajmowanej przez zmienną i jej kariera kończy się w niebycie uśmiech

Wskaźniki do obiektów

O wskaźnikach pisałem już wcześniej. Teraz pokażę kilka przykładów zastosowania wskaźników do pracy z obiektami. Zacznijmy więc ... Hem, od czegóż to mielibyśmy zacząć, jeżeli nie od jakiejś zmiennej? W końcu bez zmiennych nie ma obiektów, a bez obiektów nie ma programowania (obiektowego :D). Na początek trywialny przykład:


CDioda *pDioda1 = new CDioda();
CDioda *pDioda2 = pDioda1;

pDioda2->pokaz();
pDioda1->zapal();
pDioda2->pokaz();

/// niszczenie diody
delete pDioda1;
pDioda2->pokaz() /// Błąd !! - nie ma już diody ...
delete pDioda2; /// Błąd !! - nie ma już czego niszczyć

To chyba oczywiste – mamy teraz tylko jeden obiekt, i dwie metody dostępu do niego. Wyjaśnienie należy się jednak odnośnie operatora wyłuskania – teraz ma on nieco inną postać, nie jest nim kropka, ale strzałka ( -> ). Otrzymujemy ją, wpisując kolejno dwa znaki: myślnika oraz symbolu większości. Oczywiście, możemy ciągle wykorzystywać kropkę, ale - ze względu na priorytety operatorów - składnia wtedy wygląda nieco dziwacznie:


(*pDioda1).pokaz();

Wszelkie obiekty kiedyś należy zniszczyć; czynność ta, oprócz wyrabiania dobrego nawyku sprzątania po sobie, zwalnia pamięć operacyjną, które te obiekty zajmowały. Po zniszczeniu wszystkich możliwe jest bezpieczne zakończenie programu. Podobnie jak tworzenie, tak i niszczenie obiektów dostępnych poprzez wskaźniki nie jest wykonywane automatycznie. Wymagana jest do tego odrębna instrukcja delete – widzicie ją w kodzie powyżej. Delete wywołuje destruktor obiektu, a następnie zwalnia pamięć zajętą przez obiekt, który kończy wtedy definitywnie swoje istnienie. To tyle jeśli chodzi o życiorys obiektu. Co się jednak dzieje z samym wskaźnikiem? Otóż nadal wskazuje on na miejsce w pamięci , w którym jeszcze niedawno egzystował nasz obiekt. Teraz jednak już go tam nie ma; wszelkie próby odwołania się do tego obszaru skończą się więc błędem, zwanym naruszeniem zasad dostępu (ang. access violation ).

Ręczne zarządzanie pamięcią wiąże się zawsze ze zwiększonym ryzykiem wycieków pamięci - dlatego też wspomniałem wcześniej o RAII - technice zmniejszającej to ryzyko.  W przypadku obiektów RAII implemetuje się korzystając z inteligentnych wskaźników. Popatrzcie na poniższy przykład: 


void mrugaj(int x) {
  CDioda *pDioda1 = new CDioda();
  
  if (x < 0) {
     std::cout << "Błędny parametr - nie wiem ile razy mrugnąć"; 
     return; 
  }
  for (int i=0; i<x; i++) {
     pDioda->zapal();
     pDioda->pokaz();
     pDioda->zgas();
     pDioda->pokaz();
  }

   /// niszczenie diody
   delete pDioda1;
}

Niby jest ok na pierwszy rzut oka - ale istnieje ryzyko wycieku pamięci. Jeśli wywołamy tą funkcję z parametrem ujemnym - zaalokowany obiekt pDioda nie zostanie nigdy skasowany. Natomiast inteligentny wskaźnik sam zniszczy obiekt wskazywany po opuszczeniu zasięgu:


void mrugaj(int x) {
  std::unique_pointer<CDioda>{new CDioda()};
  
  if (x < 0) {
     std::cout << "Błędny parametr - nie wiem ile razy mrugnąć"; 
     return; 
  }
  for (int i=0; i<x; i++) {
     pDioda->zapal();
     pDioda->pokaz();
     pDioda->zgas();
     pDioda->pokaz();
  }
}

Tu już wycieku pamięci nie będzie.