4. Przeciążanie operatorów Funkcje operatorowe



Pobieranie 400.26 Kb.
Strona1/5
Data10.05.2016
Rozmiar400.26 Kb.
  1   2   3   4   5

Programowanie obiektowe – Klasy i obiekty


4. Przeciążanie operatorów

4.1. Funkcje operatorowe
Definiując klasy w C++ tworzymy nowe typy danych wykorzystywane w naszym programie. Nazywane są one zwykle typami definiowanymi przez użytkownika. Powstają oczywiście pytania dotyczące różnic między nimi a typami wbudowanymi. Odpowiedź w tym przypadku nie jest prosta. Istnieje tu szereg delikatnych kwestii, którymi zajmiemy się później w rozdziale poświęconym tworzeniu wzorców. Niewątpliwie jeśli chcemy traktować typy definiowane przez użytkownika jako pełnoprawne typy danych, musimy mieć możliwość wykonywania na niech pewnych typowych i prostych działań.

Wykonując operacje na typach wbudowanych możemy korzystać ze zdefiniowanych dla nich operatorów. Dostarczają one dobrze zdefiniowanego i powszechnie znanego wspólnego słownictwa dla projektantów i budowniczych systemów. Nikomu nie trzeba wyjaśniać na czym polega dla przykładu operacja odejmowania dwu liczb całkowitych. Nie musimy również tworzyć i uzgadniać wspólnej nazwy dla realizującego ją fragmentu kodu. jeśli x i y są wartościami całkowitymi, to wystarczy, że napiszemy po prostu:


x - y
W przypadku danych typów definiowanych przez użytkownika również przecież możemy wyróżnić pewne standardowe operacje, które my ludzie zwyczajowo i standardowo zapisujemy przy zastosowaniu operatorów. Czymś oczywistym dla nas jest np. różnica dwu dat, albo dodanie do daty liczby całkowitej. Jeśli C++ rzeczywiście ma być językiem ułatwiającym modelowanie rzeczywistości za pośrednictwem aparatu pojęciowego człowieka dobrze byłoby, gdybyśmy nie musieli rezygnować z tak powszechnie utartych zwyczajów. Ułatwiają one nie tylko zespołową pracę nad budową systemów, ale również choćby czytanie kodu programu przez osoby konserwujące go i ulepszające. Oczywiście C++ takich możliwości dla wyrażeń obiektowych dostarcza.

Jeśli spojrzymy na przedstawiony wyżej przykład odejmowania dwu liczb całkowitych, to możemy go traktować jak wywołanie specjalnej funkcji operatorowej wbudowanej w kompilatorze:


int operator - (int operand1, int operand2);
Ponieważ operacja ta zdefiniowana jest również dla innych typów liczb (np. double), to możemy mówić o całym zbiorze operatorów odejmowania przeciążonych ze względu na typ argumentu.

Taki sposób rozumienia operatorów w C++ otwiera drogę do definiowania funkcji operatorowych dla definiowanych przez nas klas. Po prostu musimy przeciążyć funkcję realizującą określony operator. Jeśli chcemy zdefiniować odejmowanie dat:


data1 - data2
to podobnie jak dla innych operatorów mamy dwie możliwości przeciążenia funkcji operatorowej:

- możemy w klasie TJDate zdefiniować funkcję składową, z jednym argumentem typu takiego jak typ drugiego operandu, stojącego po prawej stronie wyrażenia (czyli w tym przypadku TJDate lub TJDate&):

int TJDate::operator - (const TJDate& operand2);
W takim przypadku operand znajdujący się po lewej stronie operatora będzie traktowany jako obiekt na rzecz którego wywołana zostanie powyższa metoda. Różnica dat będzie interpretowana jako wywołanie funkcji:
data1.operator - (data2);
- możemy zdefiniować funkcję globalną, której argumenty odpowiadają typowi operandów użytych w wyrażeniu. W naszym przypadku:
int operator - (const TJDate& operand1, const TJDate& operand2);
W tym przypadku, z kolei, wyrażenie traktowane jest jako wywołanie funkcji dwuargumentowej, przy czym operandy znajdujące się po obu stronach operatora pasowane są do kolejnych jego parametrów:
operator - (data1, data2);
Szczegóły związane ze sposobem implementacji operatora na razie pominiemy. Dyskutować będziemy je dla poszczególnych operatorów. Możliwych jest tu szereg różnego rodzaju wariantów. Jeśli operator "-" traktowany ma być jako jednoargumentowy, jak to jest np. przy zmianie znaku to możliwości jego przeciążenia będą nieco inne:
int TJDate::operator - ();

int operator - (const TJDate& operand1);


Składowa klasy jest funkcją bezparametrową, zaś funkcja globalna ma jeden parametr. Różnice te wynikają po prostu z jednoargumentowego charakteru operatora. Istotne jest tu, że przy przeciążaniu operatora niemal zawsze mamy do czynienia z dylematem: zdefiniować funkcję operatorową jako metodę klasy czy też jako funkcję globalną.

Nie ma ogólnych zasad pozwalających rozwiązać ten problem. Istnieją jednak pewne wskazówki dla poszczególnych operatorów, tam też je analizujemy. Musimy także pamiętać, że należy wybrać zawsze jedną z tych dwu możliwości. Nie możemy przeciążyć tego samego operatora, dla takich samych typów argumentów, jednocześnie jako metodę klasy i jako funkcję globalną. Kompilator w takiej sytuacji nie będzie wiedział, którą z nich wybrać i zgłosi tę niejednoznaczność jako błąd kompilacji.

W naszym przykładzie wszystkie operandy w wyrażeniu były typu klasy. Czy jest to wymaganie formalne? Nie, wystarczy aby jeden z nich spełniał ten warunek, do zróżnicowania aspektów funkcji przeciążonej wystarczy przecież typ jednego z argumentów. Z prowadzonych wyżej rozważań wynika natychmiast jeden wniosek:

Jeśli dla operatora wieloargumentowego pierwszy operand musi być typu wbudowanego, zaś dopiero drugi jest typu zdefiniowanego przez użytkownika, to funkcja operatorowa nie może być składową klasy, lecz funkcją globalną.

Prowadząc dalej rozumowanie: Czy możemy zdefiniować operator, którego żaden z operandów nie będzie typu zdefiniowanego przez użytkownika? Nie. Co najmniej jeden argument musi być typu klasy. Operator


int operator - (int operand1, int operand2);
jest przecież wbudowany w kompilator.

Definiując funkcje operatorowe dla klasy pamiętajmy więc zawsze, że przeciążamy operatory istniejące. Nie powinniśmy więc zmieniać w sposób zasadniczy semantyki operatora. Główną ich zaletą jest to, że są czytelne i zrozumiałe dla wszystkich. Język nie nakłada tu co prawda żadnych ograniczeń, ale jeśli operator odejmowania dat zamiast obliczać liczbę dni jaka między nimi upłynęła, będzie np. wyszukiwał wszystkie pliki, których data jest równa operandowi po lewej stronie i ustawiał ją na datę po prawej stronie, nie jest to chyba najczytelniejszym rozwiązaniem. Nie możemy zmienić ani kolejności wykonywania operatorów, ani zasad ich łączenia w wyrażeniach.

Aby zilustrować te problemy rozważmy przykład klasy TDowInt, przechowującej liczby całkowite o reprezentacji na dowolnej liczbie bajtów. Przykład implementacji podobnej klasy znaleźć można np. w [Hanse1994].
class TDowInt {
public:

// szczegóły pominięte


TDowInt operator + (const TDowInt& operand2);

TDowInt operator ^ (int pow);

};

W klasie zdefiniowano operator dodawania liczb oraz ich potęgowania. Ponieważ w wielu językach programowania implementujących potęgowanie, operacja ta oznaczana jest przez "^", więc tu również skorzystano z tego znaku. Zatrzymajmy się nad następującym wyrażeniem, w którym x jest zmienną typu TDowInt:


x^3+5
Czytając je wydaje się, że jego wartość powinna być obliczona przez podniesienie x do trzeciej potęgi, a następnie dodanie 5. Niestety. Kompilator wykona to tak:
x^(3+5)
Błąd bierze się ze zmiany semantyki operatora "^", który przecież w C++ jest operatorem różnicy symetrycznej - XOR, a nie potęgowania. Zmiana ta dokonana została przy tym bez właściwego zrozumienia kolejności wykonywania działań i zasad rządzących zdefiniowanymi operatorami. Operator różnicy symetrycznej ma niższy priorytet niż operator dodawania, zostanie więc wykonany w dalszej kolejności. Jest to w sposób oczywisty niezgodne z semantyką działania operatora potęgowania. Wyposażenie klasy w tego typu operatory może więc łatwo doprowadzić do nieporozumień związanych z jej wykorzystaniem w kodzie klientów, a w konsekwencji do poważnych błędów w działaniu aplikacji.

Funkcje operatorowe najczęściej i najszerzej wykorzystywane są w klasach narzędziowych, definiujących konkretne robocze typy danych, używane następnie w aplikacji. Przykładem może być tu rozważana już wcześniej klasa daty TJDate. W rozdziale bieżącym wielokrotnie będziemy odwoływać się do tego właśnie przykładu. Określenie zestawu funkcji operatorowych dla tego typu klasy pozwala na naturalne i wygodne zdefiniowanie zbioru operacji, jakie mogą być wykonywane podczas manipulacji danymi tworzonego typu. Poniżej prezentujemy przykładowy nagłówek klasy TJDate, rozszerzonej o podstawowe grupy operatorów, takie jak operatory przypisania, porównywania, arytmetyczne, inkrementacji i dekrementacji, oraz strumieniowe.

// Plik Jdate4.h

class TJDate {


public:

TJDate(int dd, int mm, int yy);

TJDate(char* strDate);

TJDate(long numDate = 0);

//Konstruktor roboczy. Tylko wyswietla komunikat

TJDate(const TJDate& date);

//Destruktor roboczy. Tylko wyswietla komunikat

~TJDate();


//Operatory

//Operatory przypisania

TJDate& operator = (const TJDate& date);

TJDate& operator += (long dni );

TJDate& operator -= (long dni);
//Operatory porownywania

friend bool operator == (const TJDate& date1, const TJDate& date2);

friend bool operator != (const TJDate& date1, const TJDate& date2);

friend bool operator < (const TJDate& date1, const TJDate& date2);

friend bool operator <= (const TJDate& date1, const TJDate& date2);

friend bool operator > (const TJDate& date1, const TJDate& date2);

friend bool operator >= (const TJDate& date1, const TJDate& date2);
//Operatory arytmetyczne

friend const TJDate operator + (const TJDate& date, long dni);

friend const TJDate operator - (const TJDate& date, long dni);

friend long operator - (const TJDate& date1, const TJDate& date2);


//Operatory inkrementacji i dekrementacji

TJDate& operator ++ (); // przedrostkowy

TJDate operator ++ (int); // przyrostkowy

TJDate& operator -- ();

TJDate operator -- (int);
//Operatory strumieniowe

friend ostream& operator << (ostream& s, const TJDate& date);

friend istream& operator >> (istream& s, TJDate& date);

int WkDay() const;

void GetDate( int& dd, int& mm, int& yy) const;

void GetDate(char* strDate) const;

void GetDate(const char* format, char* strDate) const;

long GetDayNo() const { return JulDate; }


bool IsValid();
private:
long JulDate; //Numer dnia julianskiego

bool Status;


static bool Parse(char* strDate, int &dd, int &mm, int &yy);

static bool ValidDate(int dd, int mm, int yy);

static long JDNum(int dd, int mm, int yy);

};
Zwróćmy uwagę na występujące w deklaracji klasy TJDate deklaracje funkcji poprzedzonych słowem kluczowym friend. Są to deklaracje tak zwanych funkcji zaprzyjaźnionych. Język C++ pozwala bowiem na selektywny dostęp do hermetyzowanych składowych klasy dla wybranych klientów. Funkcje zaprzyjaźnione nie są składowymi klasy, lecz funkcjami globalnymi (nowsze wersję języka dopuszczają również zaprzyjaźnienia ze składowymi innych klas), w których można odwoływać się bezpośrednio do chronionych (prywatnych lub omawianych w dalszej części zabezpieczonych) pól i metod klasy. Tak więc deklaracja


class TJDate {

...


friend const TJDate operator + (const TJDate& date, long dni);

...


};
nie oznacza, że funkcja ta jest składową klasy TJDate. Jest to tylko deklaracja zaprzyjaźnienia z globalną funkcją operator + (const TJDate& date, long dni). Funkcja ta mimo, że nie jest metodą klasy TJDate będzie mogła odwoływać się do składowych prywatnych tej klasy. Funkcje operatorowe muszą zwykle dosyć głęboko wnikać w implementacje struktur wewnętrznych obiektów na których działają. Zwykle więc operatory zadeklarowane jako funkcje globalne w programie muszą być zaprzyjaźnianie z klasami z których pochodzą operandy.

Podobnie jak w przypadku funkcji możemy deklarować zaprzyjaźnienia klas. Należy to rozumieć w ten sposób, że wszystkie metody klasy zaprzyjaźnionej do hermetyzowanych składowych klasy w której deklaracja friend wystąpiła.

Klasa TJDate implementująca operacje na dacie jest stosunkowo prosta, działania na jej obiektach nie wymagają skomplikowanego zarządzania zasobami. Z tego powodu w bieżącym rozdziale wprowadzimy również inną klasę o podobnym charakterze, mianowicie klasę TString.

Klasa implementująca łańcuchy znaków, jest typowym przykładem klasy narzędziowej, pojawiającej się w większości aplikacji w C++. Operacje na stosowanych w języku C łańcuchach typu char* są nie tylko niewygodne, ale również stosunkowo łatwo podatne na błędy. Wymagają one bowiem nie tylko częstego wywoływania funkcji z biblioteki string.h, ale również zwykle zarządzania pamięcią przydzieloną tablicom znaków. Potrzeba klasy implementującej w sposób wygodny i bezpieczny operacje na łańcuchach jest na tyle oczywista, że większość kompilatorów języka C++ zawiera ją w standardowej bibliotece STL (Standard Template Library).

W rozdziale bieżącym również pokażemy kilka możliwych implementacji klasy łańcuchów. Posłuży nam ona jako ilustracja do problematyki budowy bardziej skomplikowanych funkcji operatorowych, wymagających wykonywania operacji na zasobach zarządzanych przez obiekt. Poniżej znajduje się standardowa implementacja klasy łańcuchów TString.
/**********************************************************************

Plik string1.h

Naglowek klasy TString zawierajacej deklaracje podstawowych operatorow lancuchowych.

***********************************************************************/


class TString {
public:

//Konstruktory

TString(const char* s);

TString(char ch);

TString();

TString(const TString& x);

//Destruktor

~TString();


int Length() const { return Len; }

const char* c_Str() { return Str; }


//Operatory przypisania

TString &operator = (const TString&);

TString &operator += (const TString&);
//Operatory porownywania

friend bool operator == (const TString&x, const TString& y);

friend bool operator != (const TString&x, const TString& y);

friend bool operator < (const TString&x, const TString& y);

friend bool operator <= (const TString&x, const TString& y);

friend bool operator > (const TString&x, const TString& y);

friend bool operator >= (const TString&x, const TString& y);
// Operator laczenia

friend const TString

operator + (const TString& s1, const TString& s2);
// Operatory indeksowania

char& operator [] (int i);

char operator [] (int i) const;

//Kopiuje n-znak. podlancuch od posn

TString operator () (unsigned posn, unsigned n) const;
//Operatory strumieniowe

friend ostream& operator << (ostream&, const TString&);

//Mozna wczytywaclancuchy do 1024 bajtow

friend istream& operator >> (istream&, TString&);


// Konwerter

//operator char*() { return Str; }

//Raczej nie implementowac - KLOPOTY!
private:

char* Str; //Wskaznik do lancucha typu C

int Dim; //Rozmiar alokowanego obszaru

int Len; //Dlugosc lancucha

};
Obiekty klasy TString przechowują całą informację niezbędną do manipulowania łańcuchem znaków. Dane przechowywane są w łańcuchu typu C, tzn. tablicy znaków zakończonej znakiem o kodzie 0, uchwyt do której, znajduje się w polu Str. W polach Dim i Len, przechowywany jest odpowiednio rozmiar alokowanego bloku pamięci, oraz rzeczywista długość łańcucha. Obiekty tej klasy same zarządzają pamięcią przeznaczoną dla przechowywanych danych, realokując ją lub zwalniając w miarę potrzeby. Kod klienta korzystającego z łańcucha zwolniony jest więc z konieczności wykonywania tych operacji, co zwiększa bezpieczeństwo korzystania ze zmiennych tego typu. Większość działań związanych z zarządzaniem zasobami wykonywana jest przez prezentowane poniżej konstruktory i destruktor klasy TString, ale jak zobaczymy to wkrótce pamiętać musimy o nich również podczas projektowania niektórych funkcji operatorowych.
TString::TString(const char* s) : Dim(0),Len(0),Str(0) {

if ( !s ) return; //Lancuch pusty

int rl= strlen(s);

if ( !rl ) return; //Rowniez pusty

Str = new char[rl+1]; //Alokacja srodowiska

if ( !Str ) return; //Blad! Mozemy np. zglosic wyjatek

strcpy(Str, s); //OK. Mozemy kopiowac

Len = rl;

Dim = Len+1;

}
TString::TString(char ch): Dim(0),Len(0),Str(0) {

Str = new char[2]; //Alokacja jednoznakowego lancucha

if ( !Str ) return; //Blad! Mozemy np. zglasic wyjatek

Str[0] = ch; //OK, mozemy przepisac

Str[1] = 0;

Dim=2;

Len=1;


}
TString::TString(): Dim(0),Len(0),Str(0) {

} //Tworzymy lancuch pusty


TString::TString(const TString& x): Dim(0),Len(0),Str(0) {

if ( !x.Len ) return; //Czy zrodlo niepuste

Str = new char[x.Len+1]; //Alokacja

if ( !Str ) return; //Mozemy np. Powinnismy zglosic wyjatek

strcpy(Str, x.Str); //OK. Mozemy kopiowac

Len = x.Len;

Dim = Len+1;

}
TString::~TString() {

if ( Str ) delete [] Str; //Dodatkowe zabezpieczenie

Len=Dim=0;

Str=0;

}
Klasa TString implementuje ponadto szereg operatorów, pozwalających w wygodny sposób porównywać, rozszerzać i zapisywać łańcuchy do pliku. Zauważmy, że również w tym przypadku globalne funkcje operatorowe zostały z tą klasą zaprzyjaźnione.



Funkcje operatorowe wykorzystywane są nie tylko w klasach o charakterze narzędziowym. Zwłaszcza niektóre rodzaje operatorów definiowane są również często dla klas opisujących pojęcia z dziedziny aplikacji. Poniżej prezentujemy kolejną wersję klasy TTowar. W stosunku do omawianych poprzednio dodane zostały operatory przypisania oraz porównywania towarów.
class TTowar {
public:

TTowar(const char *aNazwa,const char *aJm,int aIlosc,

double aCena, const TJDate& data);

TTowar(const TTowar& towar);

~TTowar();
bool Rozchod(int aIlosc, const TJDate& data);

void Przychod(int aIlosc, const TJDate& data);

//Akcesory

const char* GetNazwa() const { assert(Status==true); return Nazwa;}

const char* GetJm() const { assert(Status==true); return Jm; }

int GetIlosc() const { assert(Status==true); return Ilosc; }

double GetCena() const { assert(Status==true); return Cena; }

double GetWartosc() const { assert(Status==true); return Wartosc; }

TJDate GetOstAkt() const { assert(Status==true); return OstAkt; }
bool SetCena(double aCena, const TJDate& data);

bool SetJm(const char* aJm);


TTowar& operator = (const TTowar&);

friend bool operator == (const TTowar& x, const TTowar& y);

friend bool operator != (const TTowar& x, const TTowar& y);
bool IsValid();

private:
const char* const Nazwa; //Nazwa towaru

const char* Jm; //Jednostka miary

int Ilosc; //Ilosc towaru pozostajacego na stanie

double Cena,Wartosc; //Cena towaru, Wartosc towaru

TJDate OstAkt; //Data ostatniej aktualizacji


bool Status;

static char* KopiujString(const char* src);

void ObliczWartosc();

};
W dalszej części rozdziału omówimy podstawowe grupy operatorów definiowane zwykle dla klas Abstrakcyjnych Typów Danych (ADT), tworzonych przez użytkownika. Tematyka ta kontynuowana będzie po części również w rozdziale następnym, w którym dyskutować będziemy pewne klasy operatorów związane z tworzeniem obiektów oraz odwoływaniem się do ich składowych.



4.2. Operatory przypisania
4.2.1. Semantyka przypisania obiektów
Przypisanie jest jedną z najczęściej wykorzystywanych operacji w niemal każdej aplikacji. Zapewnienie poprawnego jej wykonania dla obiektów tworzonych Abstrakcyjnych Typów Danych ma więc podstawowe znaczenie dla jakości tworzonego kodu. Nie ma więc dziwnego w tym, że operatory przypisania stanowią jedną z najczęściej definiowanych funkcji operatorowych. Wchodzą one, obok konstruktora kopiującego w skład bloku kopiowania obiektów danej klasy, mającego szczególnie istotne znaczenie zwłaszcza w przypadku gdy zarządzają one dodatkowymi zasobami.

Rozważmy przykład prostego operatora przypisania dla klasy TJDate:


TJDate& TJDate::operator = (const TJDate& date) {

assert(date.Status == true);

JulDate = date.JulDate;

cout<<"Operator przypisania: //tylko dla celow dydaktycznych

TJDate::operator = (const TJDate&)"<

return *this;

}
Operator przypisania dwu dat zaimplementowany został jako funkcja składowa klasy TJDate. Jest to w zasadzie rozwiązanie standardowe (patrz dyskusja w punkcie 4.5). W przypadku wystąpienia przypisania
data1=data2;
dla obiektu data1 znajdującego się po lewej stronie wywołana zostanie więc funkcja operatorowa, przyjmująca jako argument wyrażenie (obiekt data2) znajdujące się po prawej stronie przypisania:
data1.operator = (data2);
Operator przypisania sprawdza warunki wstępne, polegające na stwierdzeniu czy przypisywany obiekt jest poprawną datą:
assert(date.Status == true);
Zwróćmy uwagę, że w powyższej instrukcji występuje odwołanie do pola prywatnego Status parametru date, czyli obiektu data2. Funkcja operatorowa wywoływana jest dla obiektu data1. Kwestie tę już kiedyś dyskutowaliśmy (patrz punkt 2.3). Odwołanie to jest poprawne, ponieważ w języku C++ hermetyzacja realizowana jest na poziomie klasy, a nie obiektu. Tak więc wszystkie obiekty danej klasy mogą się wzajemnie odwoływać do swych składowych prywatnych.

Następnie data przechowywana w obiekcie źródłowym, przypisywana jest dacie znajdującej się w obiekcie docelowym.


JulDate = date.JulDate;
Kolejna instrukcja, wyprowadzająca na ekran komunikat dodana została wyłącznie dla celów dydaktycznych, abyśmy mogli śledzić wywołania operatorów w programie ćwiczebnym. Wynikiem przypisania jest obiekt dla którego wywołana została funkcja operatorowa (czyli znajdujący się po lewej stronie przypisania):
return *this;
Jest to jeden z nielicznych przypadków w których musimy w sposób jawny odwołać się do wartości wskaźnika this. Wyrażenie *this oznacza zmienną wskazywaną przez wskaźnik this, czyli obiekt dla którego wywołano daną metodę. Analizowany operator przypisania zachowuje więc zgodność działania z ogólną semantyką tej operacji w językach C i C++. Wartość znajdującą się po jego prawej stronie przypisywana jest operandowi znajdującemu się po jego lewej stronie i zwracana jest ona jako wynik działania operatora.

Przyjrzyjmy się jeszcze krótko nagłówkowi funkcji operatorowej


TJDate& TJDate::operator = (const TJDate& date)
Argumentem funkcji operatorowej jest referencja do TJDate. Jak już dyskutowaliśmy w rozdziale 3, przekazywanie obiektów przez referencję jest bardziej efektywne od przekazywania przez wartość. Nie wymaga ono bowiem wywołania konstruktora kopiującego w celu utworzenia obiektu lokalnego dla parametru podprogramu.

Podobnie sytuacja wygląda w przypadku typu wartości zwracanej operatora. Wynikiem jego działania jest referencja TJDate&. Zastosowanie zamiast typu referencyjnego, samego typu TJDate wymagałoby tworzenia chwilowych obiektów pośredniczących w przekazywaniu tej wartości. Byłoby to ponadto niezgodne z ogólną semantyką przypisania, w myśl której wynikiem jego działania powinna być l-wartość, stojąca po lewej stronie tego operatora, czyli nadpisany obiekt docelowy. Użycie jako typu wartości powrotnej TJDate spowodowałoby, że wynikiem działania operatora nie byłby l-wartość, lecz powstała w wyniku kopiowania zmienna chwilowa, która ma taką samą wartość jak obiekt docelowy. Do zagadnień tych wrócimy jeszcze w punkcie 4.7.

W klasie TJDate zaimplementowano również złożone operatory przypisania. Umożliwiają one przypisanie dacie znajdujące się po lewej stronie daty wcześniejszej lub późniejszej o określoną liczbę dni. Ich budowa jest stosunkowo prosta:
TJDate& TJDate::operator += (long dni ) {

assert(Status == true);

JulDate += dni;

cout<<"Operator przypisania: TJDate::operator += (long)"<

return *this;

}
TJDate& TJDate::operator -= (long dni) {

cout<<"Operator przypisania: TJDate::operator -= (long)"<

return *this+=-dni;

}
Oczywiście podobnie jak w poprzednim przypadku komunikaty ekranowe dodane zostały wyłącznie dla celów dydaktycznych. Zwróćmy uwagę, że operator TJDate::operator -= (long dni) zaimplementowany został przy wykorzystaniu drugiego z przedstawionych złożonych operatorów przypisania. Ten sposób postępowania jest zdecydowanie zalecany, mimo że w czasie wykonania programu wymaga dodatkowych niewielkich nakładów obliczeniowych związanych z obsługą wywołania dodatkowej funkcji. Pozwala on jednak nie tylko na ponowne wykorzystanie kodu istniejącego już podprogramu, ale przede wszystkim ułatwia zachowanie zgodności działania operatorów w warunkach ewolucji kodu. Nie ma niebezpieczeństwa, że ewentualne modyfikacje w którymś z operatorów tej grupy omyłkowo zostaną pominięte.

Korzystanie z operatorów przypisania zdefiniowanych dla klasy TJDate jest już w zasadzie oczywiste. Przyjrzyjmy się następującemu prostemu programowi:


//plik mn4data.cpp

#include

#include "jdate4.h"
char* dt[] = {

"Niedziela",

"Poniedzialek",

"Wtorek",

"Sroda",

"Czwartek",

"Piatek",

"Sobota"


};
void print_data(const TJDate& data) {

char buf[80];

data.GetDate("%2d.%2d.%2d",buf);

cout<

}
void main() {

TJDate dataUrodz(25,9,1999), dataRob;


dataRob = dataUrodz;

print_data(dataRob);


dataRob = "12111913";

print_data(dataRob);


dataRob += 7;

print_data(dataRob);


dataRob -= 2;

print_data(dataRob);

}
Jego wykonanie powoduje wyprowadzenie na ekran monitora następujących informacji (pominięto komunikaty pochodzące z konstruktorów i destruktora klasy TJDate):
Operator przypisania: TJDate::operator = (const TJDate&)

Sobota, 25.09.1999

Operator przypisania: TJDate::operator = (const TJDate&)

Sroda, 12.11.1913

Operator przypisania: TJDate::operator += (long)

Sroda, 19.11.1913

Operator przypisania: TJDate::operator -= (long)

Operator przypisania: TJDate::operator += (long)

Poniedzialek, 17.11.1913
Jak wiec widzimy w wyniku pierwszego przypisania w zmiennej dataRob zapisana została dataUrodz. Działanie drugiego przypisania wymaga pewnych dodatkowych wyjaśnień, które zostaną przedyskutowane w punkcie 4.6. Następnie wykonywane są złożone operatory przypisania, przy czym jak już wspomnieliśmy operator zmniejszający wartość daty wymaga wywołania operatora +=.

Prezentowany powyżej operator prostego przypisania dla klasy TJDate jest w zasadzie zbędny. Operator przypisania zachowuje się bowiem w sposób zbliżony do konstruktora kopiującego, tzn. w sytuacji gdy klasa definiuje własnego w miarę potrzeby generowany jest automatycznie przez kompilator. Realizuje on wówczas, podobnie jak generowany automatycznie konstruktor kopiujący, strategię kopiowania płytkiego, przepisując kolejne składowe z obiektu źródłowego, znajdującego się po jego prawej stronie, do obiektu docelowego, znajdującego się po stronie lewej. Dokładnie to samo robi w zasadzie operator przypisania klasy TJDate. W sytuacji gdy w obiekcie istnieją podobiekty mające zdefiniowany operator przypisania, znów podobnie jak w przypadku konstruktorów kopiujących, kopiowanie odbywa się przy ich wykorzystaniu.

Operator przypisania wraz z konstruktorem kopiującym definiują więc kopiowania obiektów klasy. Każda bardziej złożona klasa, na przykład zarządzająca zasobami, powinna więc definiować również własny operator przypisania. Zwróćmy jednak uwagę, że właśnie w tych nietrywialnych przypadkach, mimo całego szeregu podobieństw kopiowanie i przypisanie są nieco odmiennymi operacjami. Konstruktor kopiujący wykonywany jest bowiem w sytuacji gdy obiekt do którego kopiujemy jest nie zainicjowany. Nie ma więc dla przykładu przydzielonych zasobów. W przypadku operatora przypisania obiekt docelowy jest już zainicjowany. Zanim więc go nadpiszemy musimy zwykle podjąć pewne działania porządkujące jego pierwotny stan. Powinniśmy na przykład zabezpieczyć poprawne zwolnienie przydzielonych wcześniej zasobów.

W przypadku klasy TString, której nagłówek prezentowaliśmy w poprzednim punkcie, operator przypisania staje się nietrywialny. Obiekty tej klasy zarządzają zasobami, w związku z tym semantyka płytkiego kopiowania realizowana przez operator generowany automatycznie jest już niewystarczająca, i prowadzi do dyskutowanych już wcześniej w rozdziale 2 błędów w zwalnianiu pamięci. Klasa TString musi mieć więc własny operator przypisania, na przykład powielający nie tylko obiekt, ale również utrzymywane przez niego zasoby (kopiowanie głębokie). Kwestie te były już dyskutowane dokładniej w rozdziale 2, przy okazji omawiania konstruktorów kopiujących. Poniżej prezentujemy przykładowy operator przypisania, realizujący tę strategię:

TString &TString::operator = (const TString& x) {

if ( this == &x ) return *this; //Czy nie nadpisujemy na siebie?

if ( Dim > x.Len ) { //Lancuch zrodlowy miesci sie w wynikowym

if ( x.Len)

strcpy(Str, x.Str); //OK. Kopiujemy

else


*Str = 0; //Zrodlowy pusty - wynikowy tez musi byc Len = x.Len;

return *this;

}

if ( !x.Len ) return *this; //Obydwa puste (Dim=x.Len=0)



//Zrodlo nie zmiesci sie w docelowym

char* ref = new char[x.Len+1]; //Alokujemy tyle ile potrzeba

if ( !ref ) return *this; //Blad nie zmieniamy stanu.

//Mozemy np. zglosic wyjatek

strcpy(ref, x.Str); //Ok. Kopiujemy

if ( Str ) delete [] Str; //Usuwamy stary lancuch

Str = ref; //Zapamietujemy nowy

Len = x.Len; //Porzadkujemy dlugosci

Dim = Len+1;

return *this;

}
Operator przypisania klasy TString jest również (podobnie jak dla TJDate) funkcją składową tej klasy. Zgodnie z zasadami wywoływania operatorów, wywoływana zostanie ona dla obiektu znajdującego się po lewej stronie operatora. Wyrażenie
s1 = s2
obliczane więc będzie jako
s1.operator = ( s2 )
Tak więc obiektem docelowym, dla którego musimy uporządkować wykorzystywane zasoby jest obiekt bieżący. Obiekt źródłowy natomiast przekazywany jest jako argument funkcji. Sprawdzamy, czy zasoby mogą być one użyte ponownie, tzn. czy łańcuch wynikowy zmieści się w alokowanej przez obiekt bieżący pamięci. Jeśli tak to korzystamy z niej, jeśli nie to zwalniamy starą tablicę przechowywanych znaków i przydzielamy nową. Po uporządkowaniu zasobów możemy powielić obiekt przypisywany. Oczywiście, jak już dyskutowaliśmy to w przypadku klasy TJDate, operator przypisania zwraca referencję do obiektu nadpisanego (lewej strony).

Zwróćmy jeszcze uwagę na pierwszą linie kodu analizowanego operatora


if ( this == &x ) return *this;
Jest to kolejny przykład bardzo przydatnego jawnego odwołania się do wskaźnika this. Instrukcja ta sprawdza po prostu czy obiekt bieżący (docelowy) i źródłowy nie są przypadkiem tą samą zmienną. Jest to standardowy test, który w powinien znaleźć się w warunkach początkowych operatora przypisania niemal każdej klasy. Pozwala to co najmniej uniknąć niepotrzebnego (a czasami kosztownego) powielania obiektów, ale w wielu przypadkach również zabezpiecza przed poważnymi błędami zarządzania zasobami. Jeśli po lewej i prawej stronie operatora przypisania znajduje się ten sam obiekt, to zwolnienie zasobów w obiekcie nadpisywanym mogłoby spowodować również ich zwolnienie w obiekcie źródłowym.

Dla klasy TString zaimplementowano również złożony operator przypisania, działający na zasadzie rozszerzania łańcucha. Schemat jego budowy jest zbliżony do przypisania prostego:


TString &TString::operator += (const TString& x) {

if ( !x.Len ) return *this;

if ( Dim > Len + x.Len ) { //Suma zmiesci sie w wynikowym

strcat(Str, x.Str); //OK. Kopiujemy

Len += x.Len;

return *this;

}

//Suma nie zmiesci sie w wynikowym



char* ref = new char[Len+x.Len+1]; //Alokujemy tyle ile potrzeba

if ( !ref ) return *this; //Blad nie zmieniamy stanu.

//Mozemy np. zglosic wyjatek

if ( Str) strcpy(ref, Str); //Ok. Kopiujemy

else *ref = 0;

strcat(ref, x.Str);

if ( Str ) delete [] Str; //Usuwamy stary lancuch

Str = ref; //Zapamietujemy nowy

Len += x.Len; //Porzadkujemy dlugosci

Dim = Len+1;

return *this;

}
Poniżej prezentujemy prosty program demonstrujący sposób stosowania operatorów z operatorów przypisania. Wykorzystane w nim zostały również operatory dodawania (łączenia łańcuchów), oraz strumieniowe, które zostaną omówione w dalszej części rozdziału.


//plik mnstr1a.cpp

#include

#include "string1.h"
void main() {
TString s1="Jan",s2(' '),s3("Kowalski");
cout<

s2 = "Nowak";

out<

s2+=" i ";

s2+=s3;

s2+=" do tablicy!";



cout<

}
Program ten powoduje wyprowadzenie następującego komunikatu:


Jan Kowalski

Nowak


Nowak i Kowalski do tablicy!
Zwróćmy uwagę, że zarówno prosty jak i złożony operator przypisania działają również w przypadku gdy łańcuch źródłowy jest typu char*. Możliwe jest to dzięki temu, że klasa TString posiada konstruktor konwertujący z odpowiedniego typu argumentem. Zagadnienia konwersji w operatorach definiowanych przez użytkownika dyskutowane będą dokładniej w punkcie 4.6.

Na koniec przyjrzyjmy się jeszcze dokładniej deklaracji łańcucha s1:


TString s1="Jan";
Składnia użyta w tym przypadku może być nieco myląca, aczkolwiek jest całkowicie zgodna z całą spuścizną pochodzącą z języka C. Wbrew temu co mogłoby się wydawać na pierwszy rzut oka nie mamy tutaj do czynienia z wywołaniem operatora przypisania. Takie wywołanie dla niezainicjowanego obiektu skończyłoby się zresztą niedobrze (nie można sprawdzić czy wskaźnik Str ma przydzieloną pamięć). Użycie znaku "=" w deklaracji zmiennej oznacza jednak jej inicjację, a nie przypisanie. W tym przypadku spowoduje więc ono wywołanie odpowiedniego konstruktora. Tak samo jest przecież również w przypadku deklaracji zmiennej typu wbudowanego.

Klasa TString alokuje tylko jeden obszar pamięci operacyjnej. W przypadku klasy TTowar sytuacja jest nawet bardziej skomplikowana. Jej obiekty zarządzają kilkoma zasobami, które muszą być poprawnie obsłużone podczas przypisania. Różnica jest jednak wyłącznie ilościowa. Operator przypisania klasy TTowar ma podobny schemat budowy jak omawiane poprzednio:


// towar7.cpp

TTowar& TTowar::operator = (const TTowar& towar) {

assert(towar.Status == true);

if ( this == &towar ) return *this;

char* ref1 = KopiujString(towar.Nazwa);

char* ref2 = KopiujString(towar.Jm);

if ( !ref1 || !ref2) {

if ( ref1 ) delete [] ref1;

if ( ref2 ) delete [] ref2;

return *this;

}

if ( Nazwa ) delete [] (char*)Nazwa; // rzutowanie w celu



if ( Jm ) delete [] (char*)Jm; // pozbycia sie const

((char*)Nazwa) = ref1;

Jm = ref2;

Ilosc = towar.Ilosc;

Cena = towar.Cena;

Wartosc = towar.Wartosc;

OstAkt = towar.OstAkt;

return *this;

}
Przeanalizowaliśmy operatory przypisania dla kilku różnych klas. Mimo różnic w budowie i przeznaczeniu klas są one do siebie dosyć zbliżone. Na ich podstawie możemy pokusić się o stworzenie ogólnego schematu budowy operatora przypisania:

- Sprawdzenie warunków wstępnych. Najczęściej sprawdzamy tu:

- upewnij się, że nie przypisujesz obiektu do siebie samego

- sprawdź czy obiekt źródłowy jest poprawnie zbudowany

- wykorzystaj ponownie zasoby obiektu docelowego lub je zwolnij i alokuj nowe

- skopiuj wszystko co trzeba z obiektu źródłowego do docelowego

- zwróć referencję do obiektu docelowego.

Oczywiście zastosowanie głębokiego kopiowania, realizowanego przez powyższy schemat nie zawsze jest rozwiązaniem najlepszym. Do zagadnień tych będziemy jeszcze wracać w punkcie 4.8 i w następnych rozdziałach.

Na koniec wróćmy jeszcze do operatora przypisania w klasie TTowar. Ponieważ obiekty tej klasy zarządzają całym zestawem zasobów, cały jej blok kopiujący jest dosyć skomplikowany. Powstaje oczywiście pytanie, czy nie można go w jakiś sposób uprościć.

Rozwiązaniem może być tu powierzenie zarządzania poszczególnymi zasobami wyspecjalizowanym obiektom. Poniżej widzimy przykład klasy TTowar, w której dla pól Nazwa i Jm, uchwyty do alokowanej pamięci w postaci wskaźników char*, zostały zastąpione łańcuchami klasy TString.


//towar8.h

class TTowar {


public: //Konstruktor

TTowar(const TString& aNazwa,const TString& aJm,

int aIlosc,double aCena, const TJDate& data);
bool Rozchod(int aIlosc, const TJDate& data);

void Przychod(int aIlosc, const TJDate& data);


//Akcesory

TString GetNazwa() const { assert(Status==true); return Nazwa; }

TString GetJm() const { assert(Status==true); return Jm; }

int GetIlosc() const { assert(Status==true); return Ilosc; }

double GetCena() const { assert(Status==true); return Cena; }

double GetWartosc() const { assert(Status==true); return Wartosc; }

TJDate GetOstAkt() const { assert(Status==true); return OstAkt; }
bool SetCena(double aCena, const TJDate& data);

bool SetJm(const TString& aJm);


friend bool operator == (const TTowar& x, const TTowar& y);

friend bool operator != (const TTowar& x, const TTowar& y);


bool IsValid();
private:

TString Nazwa; //Nazwa towaru

TString Jm; //Jednostka miary

int Ilosc; //Ilosc towaru pozostajacego na stanie

double Cena,Wartosc; //Cena towaru, Wartosc towaru

TJDate OstAkt; //Data ostatniej aktualizacji


bool Status;

void ObliczWartosc();

};
Zwróćmy uwagę, że klasa ta w ogóle nie ma konstruktora kopiującego, operatora przypisania, ani destruktora. Są one po prostu niepotrzebne, ponieważ kopiowanie i ogólniej mówiąc zarządzanie zasobami przerzucone zostało na poszczególne składowe klasy. Mówiliśmy już wcześniej, że jeśli podobiekty składowe mają zdefiniowane konstruktory kopiujące i operatory przypisania, to domyślne operacje generowane przez kompilator powielają obiekt za ich pośrednictwem. Podobnie podczas niszczenia obiektu automatycznie wywoływane są również destruktory podobiektów.

Oczywiście po tej modyfikacji składowe Nazwa i Jm muszą być inicjowane w liście inicjacyjnej konstruktora:


//Towar8.cpp

TTowar::TTowar(const TString& aNazwa, const TString& aJm,

int aIlosc,double aCena, const TJDate& data) :

Cena(aCena), Ilosc(aIlosc),

Nazwa(aNazwa), Jm(aJm), OstAkt(data) {

assert( aCena>0 );

assert( aIlosc>0 );

if ( !Nazwa.Length() || !Jm.Length() ) {

Status = false;

return;


}

Status = true;

ObliczWartosc();

}
Zlecenie zarządzania zasobami wyspecjalizowanym podobiektom, jak widzimy bardzo upraszcza implementację całego obiektu. Jest to nie tylko kwestia wygody, ale również bezpieczeństwa. Sama definicja obiektu jako taka zapewnia, że zasoby obsługiwane będą prawidłowo. Nie ma niebezpieczeństwa, że jakieś operacje zostaną pominięte podczas implementacji metod obiektu. Stosowanie jako pól obiektu surowych wskaźników pochodzących języka C, może być również niebezpieczne podczas stosowania mechanizmów obsługi wyjątków. Do zagadnień tych wrócimy jeszcze znacznie szerzej w rozdziale 5 i 6, prezentując koncepcję tzw. wskaźników automatycznych oraz inteligentnych.


  1   2   3   4   5


©absta.pl 2016
wyślij wiadomość

    Strona główna