Referencje
Referencja jest czymś w rodzaju synonimu lub odsyłacza. Gdy tworzy się
referencje, to trzeba ją zainicjalizować nazwą innego, docelowego obiektu. Od
tego momentu referencja spełnia rolę alternatywnej nazwy dla obiektu i
wszystkie operacje wykonywane na referencji będą również wykonywane na
obiekcie docelowym.
Niektórzy programiści twierdzą, że referencja to wskaźnik.
Nie jest to prawdą!
Oczywiście, zazwyczaj referencje tworzone są z wykorzystaniem wskaźników,
ale jest to sprawa tylko i wyłącznie twórców kompilatora.
Trzeba rozróżniać te dwa pojęcia:
wskaźniki są zmiennymi przechowującymi adresy innych obiektów.
referencje są "synonimami" lub odsyłaczami do obiektów.
Tworzenie referencji
Referencję tworzy się poprzez wpisanie nazwy typu docelowego
poprzedzonego symbolem operatora referencji ( & ). Następnie należy podać
nazwę dla referencji. Nazwa referencji może być dowolną, poprawną nazwą
C++.
Przyjmijmy że będziemy referencje nazywać zaczynając nazwę od litery r.
Przykład:
Jeżeli mamy zmienną całkowitą nPom, to można stworzyć do niej referencję w
następujący sposób:
int &rPom = nPom ;
Taką deklarację można przeczytać: "rPom jest referencją do zmiennej typu int
zainicjowaną tak, aby odsyłała do zmiennej nPom".
Przykład:
Zauważ, że referencja jest od razu
inicjalizowana.
Gdybyśmy
pominęli
inicjalizację, kompilator zgłosiłby błąd.
Wykorzystanie operatora adresu na
referencjach
Jeżeli pobierze się adres referencji, to otrzyma się adres zmiennej, którą ta
referencja reprezentuje. Taka jest natura referencji - są one odsyłaczami do
obiektów docelowych.
Przykład:
W C++ nie ma możliwości otrzymania fizycznego adresu referencji gdyż
byłoby to zbędne (w przeciwieństwie do wskaźników). Referencje są
inicjalizowane w momencie tworzenia i zawsze stanowią synonim obiektu
docelowego, nawet w przypadku użycia operatora adresu.
Jeżeli mielibyśmy klasę o nazwie Prezydent, to wówczas można zadeklarować
obiekt tej klasy następująco:
Przykład:
Prezydent oLechWalesa;
Prezydent &rWalesa = oLechWalesa;
Teraz można zadeklarować referencję do obiektu klasy Prezydent:
W pamięci będzie istnieć tylko jeden obiekt. Obydwa stworzone identyfikatory
odnoszą się do tego obiektu. Każda operacja wykonana na obiekcie rWalesa
zostanie również wykonana na oLechWalesa.
Zapamiętaj: aby odróżniać symbol & który deklaruje referencje (o
nazwie rWalesa) do obiektu klasy lub zmiennej, a symbolem &
zwracającym adres zmiennej. Normalnie, przy wykorzystywaniu
referencji, nie jest potrzebny operator adresu. Po prostu używa się
referencji tak, jak obiektu docelowego.
Czasami nawet doświadczeni programiści nie są pewni, co się stanie, przy
próbie ponownego przypisania do referencji. Okazuje się, że taka operacja
powoduje przypisanie nowej wartości do obiektu docelowego (co po chwili
namysłu jest oczywiste i zgodne z definicją referencji).
Przykład:
Rezultat tej operacji jest inny niż
zamierzony. rRef nadal jest powiązana
ze zmienną nPom1, lecz zmianie uległa
wartość, która jest teraz równa 8.
Chcieliśmy zmienić znaczenie referencji
rRef i przypisać jej nowy obiekt
docelowy – nPom2.
Dzieje się tak dlatego ponieważ przypisanie:
nPom1 = nPom2 ;
rRef = nPom2 ;
Do czego można tworzyć referencje?
Referencje można stworzyć dla dowolnego obiektu, niezależnie od tego, czy
jest on standardowym obiektem C++ czy też zdefiniowanym przez
użytkownika. Ważne jest, że referencja odnosi się do konkretnego obiektu, a
nie do klasy.
Oznacza to, że nie można napisać tak: int &rIntRef = int;
Trzeba zainicjować referencje za pomocą konkretnej zmiennej typu int:
int nPom = 200;
int &rIntRef = nPom ;
Podobnie rzecz się ma z obiektami zdefiniowanymi przez użytkownika. Nie
można napisać tak:
KOT &rKotRef = KOT ;
Tutaj, podobnie jak w przypadku zmiennej całkowitej, trzeba zainicjować
referencję za pomocą konkretnego obiektu klasy KOT:
KOT oFilemon ;
KOT &rKotRef = oFilemon ;
Referencje do obiektów wykorzystuje się tak, jak obiekty. Dane i funkcje
wewnętrzne dostępne są z wykorzystaniem operatora dostępu do klasy -
kropki ( . ).
Puste (null) wskaźniki i puste (null) referencje.
Kiedy wskaźnik jest niezainicjowany lub ewentualnie skasowany za pomocą
delete, to powinna być my przypisana wartość null (0).
Referencja nie może być pusta ( null ). Program, w którym znajdują się
referencje do obiektów null jest błędny i jego działanie jest
nieprzewidywalne. Może on działać pozornie prawidłowo, lecz w pewnym
momencie może np. niespodziewanie skasować wszystkie zbiory na dysku.
Ta reguła nie obowiązuje w przypadku referencji !
Większość kompilatorów honoruje puste obiekty. Problemy pojawiają się
jedynie przy próbie wykonania operacji na takich obiektach. Pamiętajcie, że w
takiej sytuacji, próba przeniesienia programu pod inny kompilator lub na inną
maszynę może skończyć się niepowodzeniem jeśli to inne środowisko nie
zezwala na istnienie pustych ( null ) obiektów.
Przekazywanie argumentów funkcji przez
referencje.
Powiedzieliśmy sobie, że funkcje mają dwa ograniczenia:
argumenty są przekazywane przez wartość
funkcja może zwrócić tylko jedną wartość.
Przekazywanie argumentów przez referencje pozwala na obejście obydwu
tych ograniczeń.
W C++, przekazywanie przez referencje, może być przeprowadzone
na dwa sposoby:
♦
wykorzystaniem wskaźników,
♦ referencji.
Wewnątrz funkcji nie jest tworzona kopia przekazywanego obiektu. Do
funkcji przekazywany jest oryginalny obiekt.
Przekazywanie obiektów przez referencje pozwala funkcji na
wykonywanie modyfikacji w tych obiektach.
Każde rozwiązanie ma własną składnię jednak efekt końcowy jest taki
sam.
Przykład:
Problem polega tutaj na
tym, że zarówno x jak i y są
przekazywane
przez
wartość. Oznacza to, że są
tworzone lokalne kopie tych
zmiennych
i
wszystkie
operacje są wykonywane na
kopiach, nie powodując
żadnych zmian zmiennych
oryginalnych.
To,
czego
tutaj
potrzeba,
to
przekazywanie x i y przez
referencje.
Można ten problem rozwiązać na dwa sposoby:
1. można zamienić parametry funkcji Zamien() na wskaźniki do oryginalnych
wartości
2. przekazywać referencje.
Ad.1. Implementacja funkcji Zamien () z wykorzystaniem
wskaźników
Kiedy przekazuje się wskaźnik, to faktycznie przekazuje się adres zmiennej,
co pozwala funkcji na manipulowanie jej wartością. Żeby funkcja Zamien ()
mogła zamieniać dwie zmienne wartościami, to trzeba ją zadeklarować tak,
aby jako parametry pobierała dwa wskaźniki do zmiennych typu int. Taki
zabieg pozwoli funkcji na pośrednie odwołanie się do tych zmiennych i
zamianę ich wartości.
Funkcja Zamien () jest wywoływana z
parametrami którymi są adresy zmiennych,
które chcemy zamienić.
Zmienna lokalna nTemp nie musi być wskaźnikiem ponieważ
jej zadaniem jest przechowanie wartości *pX
Ad.2. Implementacja funkcji Zamien () z wykorzystaniem
referencji
Poprzedni program działa, lecz funkcja Zamien () jest raczej nieporęczna z
następujących powodów:
1. wielokrotne pośrednie odwoływanie się do zmiennych wskazywanych
powoduje, że takie rozwiązanie jest podatne na błędy
2. konieczność przekazywania adresów zmiennych powoduje "ujawnienie"
wewnętrznych mechanizmów funkcji Zamien () .
Zamierzeniem C++ jest oddzielenie użytkownika funkcji od jej wewnętrznych
mechanizmów i implementacji. Przekazywanie wskaźników przenosi część
ciężaru wykonania funkcji na funkcję wywołującą (na użytkownika).
Tym razem, bezpośrednio przekazywane są do
funkcji zmienne, a nie ich adresy.
W funkcji Zamien () zmienne x i y są
identyfikowane przez referencje. Można tak
napisać, gdyż jak już wcześniej wielokrotnie
mówiłem, stanowią one odnośniki, synonimy
oryginalnych zmiennych.
Interpretacja nagłówków i prototypów funkcji
Poprzez analizę zadeklarowanych parametrów w prototypie, który zazwyczaj
znajduje się w specjalnym pliku nagłówkowym (*.hpp , *h ) wraz z innymi
prototypami, programista dowiaduje się, że parametry przekazywane do
funkcji zamień są przekazywane przez referencje, co w przypadku funkcji
Zamien () oznacza, że zostaną one prawidłowo zamienione.
Skąd jednak funkcja wywołująca wie, czy wartości są przekazywane
przez referencje czy przez wartość?
Zwracanie wielu wartości
Jak już mówiliśmy, każda funkcja, normalnie może zwrócić tylko jedną
wartość.
Jedna z możliwości polega na przekazaniu do funkcji przez referencje dwóch
roboczych obiektów. Funkcja może nadać im wymagane wartości.
Przekazanie przez referencje gwarantuje, ze przypisanie wartości nastąpi do
obiektów oryginalnych, co umożliwi ich odczytanie. W efekcie uzyskujemy
funkcję, która zwraca dwie wartości.
Co jednak zrobić, gdy chcemy zwrócić z funkcji dwie wartości?
Takie rozwiązanie pozwala na ominięcie normalnej wartości zwracanej przez
funkcję, którą można w tym momencie wykorzystać np. do zwracania kodu
błędu.
Zmienna
nLiczba,
i
adresy
dwóch
pozostałych zmiennych, są przekazywane
do funkcji Potegi () .
Dodatkowe wartości, kwadratu i sześcianu podanej
liczby,
nie
są
zwracane
z
wykorzystaniem
mechanizmów oferowanych przez return. Zmieniona
zostaje zawartość pamięci wskazywanej przez
przekazane do funkcji wskaźniki, co powoduje
modyfikację
wartości
zmiennych
w
funkcji
wywołującej, do których te adresy nalezą.
Ten program można udoskonalić, np. poprzez wprowadzenie deklaracji dla
wartości błędu i sukcesu:
enum WARTOSC_BLEDU {SUKCES, PORAZKA};
Teraz, zamiast zwracać niewiele mówiące wartości 0 lub 1, możemy zwracać
SUKCES albo PORAZKA.
Zwracanie wartości przez referencje
Efektywność przekazywania przez referencje
Za każdym razem, kiedy przekazuje się przez wartość obiekt do funkcji,
tworzona jest kopia obiektu. Również, kiedy zwraca się obiekt z funkcji (za
pomocą return) jest tworzona kopia. W przypadku dużych, stworzonych
przez użytkownika obiektów, koszt tworzenia kopii jest znaczący.
Wykorzystywane jest więcej pamięci niż faktycznie potrzeba i program działa
wolniej.
Rozmiar obiektu stworzonego przez użytkownika na stosie jest równy sumie
wielkości jego zmiennych wewnętrznych. Każda zmienna wewnętrzna również
może być kolejnym obiektem stworzonym przez użytkownika. Przekazywanie
tak złożonego i dużego obiektu i kopiowanie go na stos może bardzo obciążać
system i spowalniać działanie programu, przy jednoczesnym wzroście
zapotrzebowania na pamięć.
Występuje tutaj jeszcze jeden, dodatkowy koszt.
Każda kopia obiektu jest wykonywana za pomocą specjalnego konstruktora, a
mianowicie konstruktora kopiującego. Kiedy taki chwilowy obiekt jest
kasowany (np. w momencie wyjścia z funkcji) to zostaje wywołany destruktor
klasy, do której ten obiekt należy. Jeśli obiekt jest wartością zwracaną, to
również musi być najpierw stworzony, a następnie skasowany.
W przypadku dużych obiektów, zarówno konstruktor jak i destruktor,
pochłaniają dużo czasu procesora i pamięci.
Przykład:
Rzeczywisty obiekt byłby dużo większy,
ale ten przykład dostatecznie dobrze
pokazuje jak często są wywoływane
konstruktory i destruktory w trakcie
normalnej pracy programu.
Ponieważ Funkcja1() pobiera obiekt klasy ZwyklyKot przez wartość, dlatego
na stosie musi zostać stworzona kopia obiektu. Zostaje wywołany konstruktor
kopiujący. Wartość zwracana z Funkcja1() nie jest przypisywana do żadnego
obiektu, zatem obiekt tworzony w momencie wyjścia z funkcji jest
natychmiast wyrzucany z pamięci poprzez wywołanie destruktora. Ponieważ
Funkcja1() się skończyła, dlatego lokalna kopia obiektu również ulega
skasowaniu poprzez wywołanie destruktora.
W funkcji Funkcja2 () parametr jest przekazywany przez wskaźnik. Jak widać
na wydruku, nie jest tworzona żadna kopia obiektu. Funkcja2 () wypisuje
komunikat i zwraca wskaźnik do obiektu klasy ZwyklyKot. Znowu nie jest
wywoływany ani konstruktor ani destruktor klasy.
Jak widać, wywołanie funkcji Funkcja1 (), ze względu na przekazywanie
parametrów przez wartość, spowodowało dwukrotne wywołanie konstruktora
i destruktora, w przeciwieństwie do funkcji Funkcja2 (), której wywołanie
odbywało się bez uczestnictwa konstruktora i destruktora klasy.
Przekazywanie stałych (const) wskaźników
Mimo że przekazywanie wskaźników jest bardzo efektywne, to jest ono
jednocześnie bardzo niebezpieczne. Funkcja2 () (z ostatniego programu) nie
ma prawa zmieniać przekazanego obiektu, który aktualnie jest przekazywany
w formie adresu. Takie rozwiązanie niesie ze sobą niebezpieczeństwo
nieświadomej (spowodowane błędem programisty) zmiany oryginalnego
obiektu, w przeciwieństwie do przekazywania przez wartość, gdzie obiekt
oryginalny jest "bezpieczny".
Przekazywanie przez wartość, to jakby dawanie do muzeum fotografii dzieła
sztuki, a nie oryginalnego dzieła. Jeśli ktoś zniszczy kopie to oryginał i tak
zostanie nienaruszony. Przekazywanie przez referencje można porównać do
wysyłania do muzeum swojego, domowego adresu i zaproszenia do oglądania
własnych zasobów.
Rozwiązaniem tego problemu jest przekazywanie stałych (const)
wskaźników. Takie posunięcie zabezpiecza przed użyciem metod, które nie są
zadeklarowane jako const, przez co mogą zmieniać obiekt.
Przykład:
W tym przykładzie konstruktor kopiujący nie jest nigdy wywoływany,
ponieważ obiekt jest przekazywany do funkcji przez wskaźnik i nie jest
wykonywana jego kopia.
Parametr i wartość zwracana zostały zadeklarowane tak, aby funkcja
pobierała stały wskaźnik (const) do stałego (czyli również const) obiektu i
żeby zwracała również stały wskaźnik do stałego obiektu. Ponieważ zarówno
parametr jak i wartość zwracana są przekazywane przez wskaźnik, to nie jest
tworzona żadna kopia obiektu i, co się z tym ściśle wiąże, nie jest
wywoływany konstruktor kopiujący. Wskaźnik występujący w funkcji Funkcja2
() jest stały i nie mamy możliwości wykorzystania go do wywołania funkcji
UstawWiek () (zadeklarowanej bez użycia słowa kluczowego const).
Gdybyśmy usunęli komentarz to kompilator
zgłosiłby błąd.
Kiedy wykorzystywać referencje, a kiedy
wskaźniki?
Programiści C++ preferują referencje. Są łatwiejsze, bardziej eleganckie i
pozwalają na lepsze ukrycie szczegółów implementacyjnych.
Jednak raz stworzona referencja nie może być przypisana do innego
obiektu.
Jeżeli chce się mieć wskazanie najpierw na jeden obiekt, a następnie na
drugi, trzeba wykorzystać wskaźniki.
Referencje nie mogą być puste (null) i jeżeli jest możliwość, że obiekt
będzie pusty to wykorzystanie referencji w takim przypadku jest błędem,
trzeba użyć wskaźnika.
Inny problem pojawia się przy wykorzystaniu operatora new. Jeżeli new nie
jest w stanie zarezerwować pamięci na stercie to zwraca pusty (null)
wskaźnik. Referencja nie może być pusta dlatego nie wolno inicjalizować
referencji do zarezerwowanej pamięci zanim nie upewnimy się, że wskaźnik
nie jest pusty.
Przykład:
Oto przykładowa sekwencja instrukcji, która prawidłowo obsługuje tworzenie
referencji do pamięci na stercie:
int *pInt = new int;
if (pInt != NULL)
{
int &rInt = *pInt;
}
Nie zwracaj referencji do obiektów lokalnych
Często, kiedy programista pozna referencje, to wykorzystuje je, gdzie się
tylko da. Można wtedy, mówiąc prostym językiem, "przedobrzyć".
Pamiętajcie, że referencja jest zawsze odnośnikiem do innego, istniejącego
obiektu. Jeżeli przekazuje się referencje do lub z funkcji zawsze zadajcie sobie
pytanie, czy obiekt, do którego się dana referencja odnosi, będzie nadal
istniał.
Przykład:
Ten program nie skompiluje
się pod kompilatorami firmy
Borland. Kompilatory firmy
Microsoft skompilują ten kod,
ale trzeba zaznaczyć, że jest
to błędne podejście.
Funkcja
deklaruje
lokalny
obiekt
klasy
ZwyklyKot
i
inicjalizuje jego wagę i wiek.
Następnie zwraca referencje to
tego,
lokalnego
obiektu.
Niektóre kompilatory wychwycą
ten oczywisty błąd i nie
pozwolą
na
uruchomienie
programu. Inne skompilują to
bez
zastrzeżeń,
ale
efekt
działania takiego programu jest
nieprzewidywalny.
Kiedy
Funkcja
ulegnie
zakończeniu, to lokalny obiekt,
oFilemon, zostanie wyrzucony z
pamięci. Referencja zwrócona z
funkcji
odnosiłaby
się
do
nieistniejącego obiektu co jest
oczywiście
sprzeczne
z
definicją referencji.
Zwracanie referencji do obiektu na stercie
Można rozwiązać stworzony w poprzednim programie problem poprzez
zmianę Funkcja tak, aby tworzyła obiekt klasy ZwyklyKot na stercie. Dzięki
temu, po zakończeniu funkcji, taki obiekt będzie nadal istniał.
Pojawia się tutaj kolejny problem: Co zrobić z pamięcią zarezerwowaną dla
obiektu oFilemon po zakończeniu wszystkich operacji.
Ten program się kompiluje i pozornie działa. Jednak jest to
klasyczna, programistyczna bomba zegarowa z opóźnionym
zapłonem!
Funkcja Funkcja () nie zwraca już referencji do
lokalnego
obiektu.
Teraz
pamięć
jest
rezerwowana na stercie i przypisywana do
wskaźnika.
Adres
przechowywany
przez
wskaźnik jest wypisywany, a następnie są
zwracane referencje do obiektu przez niego
wskazywanego.
Wartość zwracana z funkcji jest przypisywana
do referencji obiektu klasy ZwyklyKot. Obiekt
ten wykorzystujemy do odczytania wieku kota.
Dowodem na to, że referencja zadeklarowana w
main () odnosi się do obiektu stworzonego na
stercie jest odczytanie adresu tej referencji. Jest
on taki sam jak adres wypisany w Funkcja().
Ale
jak
teraz
zwolnić
zarezerwowaną pamięć? Nie
można skasować referencji za
pomocą
delete.
Jedyne
nasuwające się rozwiązanie
polega
na
zadeklarowaniu
kolejnego
wskaźnika
i
zainicjowaniu
go
adresem
odczytanym z rKot. W ten
sposób
możemy
zwolnić
zarezerwowaną
pamięć.
Jednak i tu pojawia się kolejny
problem. Do jakiego obiektu
odnosi się rKot? Jak już
mówiliśmy, referencja musi
odnosić się do istniejącego
obiektu. Jeśli jest inaczej
(obiekt jest null) to program
jest nieprawidłowo napisany.
Istnieją trzy rozwiązania tego problemu:
Pierwsze polega na zadeklarowaniu obiektu klasy ZwyklyKot i
zwrócenie go z funkcji Funkcja () przez wartość.
Drugie idzie dalej i polega na zadeklarowaniu w funkcji Funkcja ()
obiektu klasy ZwyklyKot na stercie i zwróceniu wskaźnika do tego
obiektu. Funkcja wywołująca będzie wtedy miała możliwość
skasowania wskaźnika.
Trzecie, najbardziej eleganckie, polega na zadeklarowaniu obiektu
w funkcji wywołującej i przekazaniu go do funkcji Funkcja () przez
referencje.