Zaawansowane funkcje
Przeciążanie funkcji wewnętrznych
Przykład
Funkcja nie pobierająca parametrów wywołuje funkcję z
parametrami przekazując do niej wartości przechowywane w
zmiennych klasy. Zawsze starajcie się unikać powielania kodu w
dwóch funkcjach. Powtarzanie takich samych instrukcji w dwóch
(lub więcej) miejscach utrudnia ich modyfikację i często prowadzi
do "rozsynchronizowania" programu.
Kompilator, na podstawie liczby parametrów, decyduje, którą funkcję ma
wykonać.
Wykorzystywanie wartości domyślnych
Przykład
Podobnie jak w przypadku zwykłych funkcji, w przypadku metod klasy można
określić domyślne wartości parametrów, które mają być przyjmowane w
momencie pominięcia któregoś z argumentów.
Wartości domyślne czy przeciążanie funkcji ?
Przeciążanie funkcji stosuje się gdy:
nie ma żadnej sensownej wartości domyślnej,
wykorzystuje się różne algorytmy w zależności od liczby parametrów,
trzeba obsługiwać różne typy w liście parametrów.
Konstruktor domyślny
Jeżeli nie zadeklaruje się jawnie konstruktora klasy to przy każdym tworzeniu
obiektu klasy wywoływany jest konstruktor domyślny, nie pobierający
argumentów i nie wykonujący żadnych operacji.
Konstruktor dostarczany przez kompilator nazywany jest domyślnym, ale
konwencja mówi, że każdy konstruktor nie pobierający argumentów jest
domyślny. Pojawia się tutaj pewne zagmatwanie, zazwyczaj z kontekstu
wynika, o który konstruktor chodzi.
Przeciążanie konstruktorów
Destruktorów przeciążać nie można. Każdy destruktor, z definicji,
tworzony jest tak samo: nazwa klasy poprzedzona znakiem tyldy ().
Destruktor nie pobiera żadnych parametrów.
Konstruktory, podobnie jak wszystkie inne metody klasy, mogą być
przeciążane. Np. można stworzyć klasę Prostokat posiadającą dwa
konstruktory: pierwszy, pobierający dwa parametry określające wymiary,
drugi, bez parametrów, tworzący prostokąt o wymiarach domyślnych.
Kompilator wybierze konstruktor na podstawie typu i liczby parametrów, tak
jak w przypadku zwykłych funkcji.
Inicjalizacja obiektów
Dotychczas, wartości wewnętrznych zmiennych klasy były ustalane wewnątrz
treści konstruktorów. Jednakże, każdy konstruktor powinien składać się z
dwóch części, inicjalizującej i treści.
Większość zmiennych można zainicjować w dowolnej z tych części, albo
poprzez inicjalizację w części inicjalizującej, albo przez przypisanie wartości w
treści konstruktora.
Po nawiasie zamykającym listę parametrów konstruktora stawiamy
dwukropek. Następnie wpisujemy nazwę zmiennej i w nawiasach podaje się
wyrażenie, którego wartość ma być nadana tej zmiennej. Inicjalizację
zmiennych oddziela się przecinkami.
KOT():
// nazwa konstruktora i
parametry
nJegoWiek (5), // lista inicjalizacji
nJegoWaga (8)
{ }
//treść konstruktora
Przykład:
UWAGA: Referencje i stałe muszą być inicjalizowane, nie można im
przypisywać wartości. W celu nadania im wartości trzeba
wykorzystać metodę podaną powyżej
.
Konstruktor kopiujący
Podobnie jak w przypadku zwykłego konstruktora i destruktora, kompilator
dostarcza również domyślny konstruktor kopiujący. Konstruktor kopiujący jest
wywoływany w momencie tworzenia kopii obiektu danej klasy.
Kiedy obiekt jest przekazywany przez wartość, jako parametr lub ewentualnie
wartość zwracana, to jest wykonywana chwilowa, robocza kopia tego obiektu.
Jeśli jest to obiekt klasy zdefiniowanej przez użytkownika, to jest wywoływany
konstruktor kopiujący tej klasy.
Konstruktor kopiujący ma tylko jeden parametr: referencje do obiektu tej
samej klasy. Dobrze jest deklarować ten parametr jako const, gdyż
konstruktor kopiujący nie ma prawa zmieniać zawartości obiektu.
Przykład
KOT ( const KOT &kotek ) ;
Domyślny (dostarczany przez kompilator) konstruktor kopiujący, wykonuje
kopię każdej zmiennej wewnętrznej obiektu źródłowego i umieszcza w nowym
obiekcie. Jest to bardzo "płytkie" kopiowanie i o ile w przypadku zwykłych
zmiennych będzie działać prawidłowo, to w przypadku np. wskaźników
całkowicie zawiedzie, gdyż skopiowane zostaną adresy zawarte we
wskaźnikach.
Konstruktor kopiujący z klasy KOT
pobiera
stałą
referencję
do
istniejącego obiektu klasy KOT.
Zadaniem tego konstruktora jest
wykonanie w pamięci kopii obiektu
kotek.
Płytkie kopiowanie polega na skopiowaniu wartości zmiennych obiektu
źródłowego do obiektu tworzonego. Jeżeli w obiekcie występują wskaźniki to
w efekcie końcowym otrzymamy dwa obiekty, w których te wskaźniki
wskazują na tę samą pamięć. Wskaźników nie należy kopiować bezpośrednio.
Należy wykonać kopie wartości przechowywanych pod adresami przez nie
wskazywanymi do nowego obszaru pamięci.
Jeżeli klasa KOT będzie zawierać zmienną nJegoWiek wskazującą na wartość
typu int na stercie, to konstruktor kopiujący skopiuje wartość tej zmiennej
(czyli adres w niej zawarty) do zmiennej nJegoWiek obiektu tworzonego. Oba
wskaźniki będą wskazywać na ten sam obszar pamięci.
Oryginalny
KOT
nJegoWie
k
nowy
KOT
nJegoWie
k
5
Sterta
Takie rozwiązanie spowoduje katastrofę, gdy jeden z obiektów zostanie
usunięty z pamięci. Zostanie wtedy wywołany destruktor klasy KOT, który
zwolni zarezerwowaną na stercie pamięć.
Załóżmy, że z pamięci zostanie usunięty oryginalny obiekt KOT. Destruktor
zwolni zarezerwowaną pamięć. Jednak kopia nadal będzie wskazywać na ten
obszar. Jeżeli będzie się próbowało dostać do tej pamięci to program
przestanie działać...
Oryginalny
KOT
nJegoWie
k
nowy
KOT
nJegoWie
k
5
Sterta
Rozwiązaniem tego problemu jest napisanie własnego konstruktora
kopiującego i rezerwacja pamięci we własnym zakresie. Jeżeli pamięć
zostanie zarezerwowana, to wartości z oryginalnego obiektu (w szczególności
te wskazywane przez wskaźniki) mogą zostać do niej skopiowane. Takie
kopiowanie nazywamy głębokim.
Przykład
Deklarowane są dwie zmienne wewnętrzne, każda jako
wskaźnik do wartości typu int. Normalnie przechowywanie w
klasie
zmiennych
typu
int
pod
wskaźnikami
jest
niestosowane.
Tutaj
wykorzystałem
to
tylko
do
zademonstrowania zarządzania danymi na stercie.
Domyślny konstruktor, rezerwuje dla dwóch zmiennych
typu int pamięć na stercie i przypisuje im odpowiednie
wartości.
Konstruktor kopiujący. Zwróćmy uwagę na
parametr rhs. Jest to często spotykana
nazwa parametru konstruktora kopiującego
(z ang. right-hand side - znajdujący się po
prawej stronie).
Zasada działania konstruktora
kopiującego:
Rezerwowana jest pamięć na
stercie.
Przepisywane tam są wartości z
istniejącego obiektu klasy KOT
Parametr rhs to obiekt klasy KOT przekazany
do konstruktora kopiującego jako stała
(const)
referencja
.
Wykorzystujemy
wewnętrzne funkcje rhs.PobierzWiek () i
rhs.PobierzWage () do odczytania wartości
zmiennych
wewnętrznych
nJegoWiek
i
nJegoWaga i przepisania ich do tworzonego
obiektu.
Kiedy jest wywoływany konstruktor kopiujący, to istniejący obiekt klasy
KOT jest przekazywany do niego jako parametr. Do zmiennych
tworzonego obiektu można się odwoływać bezpośrednio, ale do
odczytania zmiennych obiektu rhs trzeba wykorzystać funkcje dostępu.
Ilustracja głębokiego kopiowania
Wartości z istniejącego obiektu są kopiowane do pamięci zarezerwowanej dla
nowego obiektu.
Oryginalny
KOT
nJegoWie
k
nowy
KOT
nJegoWie
k
5
Sterta
5
Tworzony jest obiekt o nazwie
oFilemon. Wypisywany jest jego wiek.
Tworzony jest nowy obiekt oPikus z
wykorzystaniem
konstruktora
kopiującego i obiektu oFilemon.
Gdyby oFilemon był przekazany przez
wartość do funkcji, to również
zostałby
wywołany
konstruktor
kopiujący.
Wiek oFilemon ulega zmianie na 7.
Ponownie wypisujemy wiek obu
kotów. oFilemon ma 7 lat, a oPikus
nadal 6.
Dowodzi to, że obiekty
znajdują się w oddzielnych
obszarach pamięci.
Podczas usuwania obiektów KOT z pamięci automatycznie są wywoływane
destruktory klasy. Za pomocą delete kasowane są obydwa wskaźniki,
nJegoWiek i nJegoWaga, zwalniana jest zajmowana przez nie pamięć. Dla
bezpieczeństwa, wskaźnikom jest nadawana wartość NULL.
Faktycznie, oPikus ma tyle samo
lat co oFilemon (a nie domyślną
wartość 5).
Przeciążanie operatorów
C++ posiada wiele wbudowanych typów danych, takie jak int, float, char
itp. Każdy z nich posiada pewną liczbę określonych operatorów takich jak
dodawanie (+), mnożenie (*) itd. C++ pozwala na dodawanie tych
operatorów do własnych klas.
Przykład
Jak widać, stworzyliśmy całkowicie
bezużyteczną klasę. Jej jedyna
zmienna wewnętrzna jest typu int.
Domyślny konstruktor, nadaje tej
zmiennej wartość 0.
W przeciwieństwie do zwykłej,
zmiennej typu int, obiekt klasy
Licznik nie może zostać ani
zwiększony, ani zmniejszony, nie
można mu nadać żadnej wartości
ani
nic
do
niego
dodać.
Skomplikowane
jest
również
wypisywanie jego wartości.
Funkcja inkrementująca
Przeciążenie operatora przywraca część funkcjonalności utraconej podczas
definiowania własnej klasy takiej jak Licznik.
Przykład
Implementacja operatora ++, została
zmieniona tak, że zwraca aktualny obiekt
za pomocą pośredniego odwołania do
this. Dzięki temu możemy wykonać
operację przypisania
Zauważ, że wartość zwracana
jest referencją do obiektu klasy
Licznik,
przez
co
unikamy
zbędnego
tworzenia
kopii
obiektu. Jest ona zadeklarowana
jako const, ponieważ wartość
nie powinna być zmieniona
przez funkcje wykorzystującą
ten obiekt.
Przeciążanie operatora przyrostkowego
Zanim zaczniemy przeciążać operatory przyrostkowe i przedrostkowe
musimy poznać różnicę miedzy nimi. W skrócie: operator przedrostkowy
najpierw inkrementuje zmienną, a potem zwraca jej wartość, operator
przyrostkowy odwrotnie, najpierw zwraca wartość, a potem inkrementuje
zmienną.
O ile operator przyrostkowy może zwiększyć wartość i zwrócić obiekt przez
wartość to operator przedrostkowy musi zwrócić wartość przed
inkrementacją. W tym celu trzeba stworzyć pomocniczy obiekt,
przechowujący początkową wartość obiektu przed inkrementacją. Zwrócić
należy ten chwilowy obiekt. Spójrzmy na to wszystko z innej strony. Jeśli
napisze się
a = x++;
i x było równe 5, to po wykonaniu tej instrukcji a będzie równe 5 i x będzie
równe 6. Dzieje się tak dlatego, że zwracamy wartość x i przypisujemy ją do
a. Jeśli x jest obiektem, to jego operator przyrostkowy musi przechować
oryginalną wartość x w obiekcie pomocniczym, zwiększyć x i zwrócić obiekt
pomocniczy.
Należy jednak pamiętać, że zwracając obiekt pomocniczy nie można
go zwrócić przez referencję gdyż jest to obiekt lokalny. Koniecznie
trzeba go zwrócić przez wartość.
Przykład
Zauważmy,
że
deklaracja
operatora
przedrostkowego nie zawiera parametru w
przeciwieństwie do deklaracji operatora
przyrostkowego. Parametr w deklaracji
operatora
przyrostkowego
jest
jedynie
informacją dla kompilatora i nie jest nigdy
wykorzystywany
Operator +
Operator inkrementacji jest operatorem unarnym co oznacza, że operuje
na tylko jednym obiekcie. Operator dodawania (+) jest operatorem
binarnym, działającym na dwóch obiektach. Jak przeciążyć operator
dodawania w klasie Licznik?
Celem jest możliwość zadeklarowania dwóch obiektów klasy Licznik, a
następnie dodania ich tak, jak w przykładzie:
Licznik oJeden, oDwa, oTrzy;
oTrzy = oJeden + oDwa;
Można by napisać funkcję Dodaj (), przekazać do niej obiekt Licznik, dodać
wartości i zwrócić obiekt klasy Licznik jako wynik.
Przykład
Funkcja Dodaj() jako parametr pobiera stałą
referencję do obiektu klasy Licznik, który
będzie dodany do aktualnego obiektu.
Funkcja Dodaj () zwraca obiekt, który będzie
można przypisać do innego obiektu.
Przeciążanie operatora +
W poprzednim programie zdefiniowaliśmy funkcję Dodaj (). Ona działa, lecz
jej użycie jest raczej nienaturalne. Lepszym, bardziej intuicyjnym
rozwiązaniem jest przeciążenie operatora dodawania (+)
Przykład
Niby niewielka zmiana, a program wygląda dużo lepiej i czytelniej.
oTrzy = oJeden + oDwa;
Łatwiej jest napisać:
oTrzy = oJeden.Dodaj (oDwa) ;
ni
ż
Ograniczenia przy przeciążaniu operatorów
Przede wszystkim nie jest możliwe przeciążanie operatorów wbudowanych
typów C++. Nie można zmienić kolejności wykonywania działań i arności
działań (liczby argumentów). Nie można tworzyć nowych operatorów. Próba
stworzenia np. operatora ** dla podnoszenia do potęgi nie powiedzie się.
Co i kiedy przeciążać ?
Przeciążanie operatorów jest często nadużywane przez początkujących
programistów. Próbują oni tworzyć nowe, ciekawe zastosowania dla prostych
operatorów, lecz nieodzownie prowadzi to do zbędnej komplikacji programu i
nieporozumień.
Pewnie, że można przeciążyć operator + tak, aby służył do odejmowania i
zmusić operator * do dodawania, jednak nikt tego nie robi. Większe
niebezpieczeństwo kryje się w pozornie poprawnym w zamierzeniach
przeciążeniu np. operatora + do łączenia znaków w łańcuchy lub / do
dzielenia łańcuchów. Można rozważyć takie rozwiązania ale z rozwagą.
Pamiętajcie, że przeciążanie operatorów ma zwiększać przejrzystość
kodu i łatwość korzystania z niego.