Zakres funkcji składowej, napisy jako tablice znaków, wywoływanie innych funkcji składowych.
W kolejnym przykładzie pokażemy, w jaki sposób ciało funkcji składowej tworzy swój własny, lokalny zakres. Przedstawimy także m.in. sposoby definiowania w nim obiektów oraz tworzenia zakresów podrzędnych.
Pewna metoda upraszczania kodu źródłowego. Najprawdopodobniej Czytelnika znużyło już ciągłe pisanie przedrostka std:: przed nazwami obiektów standardowych, np. std::cin i std::cout. Na szczęście zanim zaczniemy posługiwać się tymi obiektami, możemy o naszym zamiarze poinformować kompilator. Od chwili, gdy w kodzie programu umieścimy magiczne zaklęcie
|
Jesteśmy gotowi, by zaprezentować nowe wcielenie klasy InputNum.
#include <iostream>
using std::cout;
using std::cin;
class InputNum
{
public:
InputNum ()
{
cout << "Enter number ";
cin >> _num;
}
int GetValue () const { return _num; }
void AddInput ()
{
InputNum aNum; // get a number from user
_num = _num + aNum.GetValue ();
}
private:
int _num;
};
int main()
{
InputNum num;
cout << "The value is " << num.GetValue() << "\n";
num.AddInput();
cout << "Now the value is " << num.GetValue() << "\n";
return 0;
}
|
Jak widać, do klasy InputNum dodaliśmy nową metodę AddInput. W celu wczytania liczby z klawiatury posługuje się ona lokalnym obiektem typu InputNum. Liczbę tę odczytujemy za pośrednictwem jego metody GetValue() i wykorzystujemy ją do zwiększenia oryginalnej wartości obiektu num.
Przyjrzyjmy się bliżej kilku rysunkom, ilustrującym sposób wykonywania naszego programu.
Rysunek 6. Podczas wykonywania funkcji main wywoływany jest konstruktor obiektu num klasy InputNum. Prosi on użytkownika o podanie liczby. Zakładamy, że użytkownik wpisał liczbę 5. Wartość ta zostanie zapisana w prywatnej składowej _num obiektu num.
Rysunek 7. W kontekście obiektu num wywoływana jest metoda GetValue. Metoda ta odczytuje wartość liczby przechowywanej w składowej prywatnej _num obiektu num. Wartość, zwrócona przez metodę GetValue, wynosi 5.
Rysunek 8. Następnie w kontekście obiektu num wywoływana jest metoda AddInput.
Rysunek 9. Metoda AddInput obiektu num tworzy obiekt aNum klasy InputNumber. Konstruktor obiektu aNum prosi użytkownika, by ten wprowadził kolejną liczbę. W naszym przykładzie użytkownik wpisuje liczbę 11. Wartość ta jest zapisana i przechowywana w prywatnej składowej _num obiektu aNum.
Rysunek 10. Podczas wykonywania metody AddInput w kontekście obiektu num, na rzecz obiektu aNum wywoływana jest metoda GetValue. Odczytuje ona wartość przechowywaną w prywatnej składowej _num obiektu aNum. Liczba ta (w tym przypadku równa 11) jest następnie dodawana do wartości prywatnej składowej _num obiektu num. Ponieważ jej wartośc wynosiła 5, po dodaniu 11 nową wartością prywatnej składowej _num obiektu num będzie 16.
Rysunek 11. Obiekt num powtórnie otrzymuje komunikat GetValue. Metoda GetValue odczytuje wartość prywatnej składowej _num obiektu num, która jest w tej chwili równa 16.
Czytelnika ciekawi zapewne jak to się dzieje, że program wykonuje kod pewnej metody, po czym powraca dokładnie do miejsca, z którego ją wywołano. Gdzie program przechowuje informacje o tym, co i gdzie robił tuż przed wywołaniem metody? Odpowiedź brzmi: informacje przechowywane są na tzw. stosie programu (ang. program stack). Wywołanie funkcji wiąże się z umieszczeniem na tym (niewidocznym) stosie odpowiednich danych. Zakończenie wykonywania kodu funkcji wiąże się natomiast ze zdjęciem tych danych ze stosu i przywróceniem jego stanu do stanu sprzed wywołania metody.
Kolejność czynności związanych z wywoływaniem funkcji mogłaby wyglądać następująco. Najpierw na stosie programu zapisywane są wszystkie argumenty, które mają być przekazane do funkcji. Następnie zapisywany jest tam adres powrotny, czyli adres pierwszej instrukcji, która ma być wywołana po zakończeniu wykonywania funkcji. Następnie sterowanie programu przechodzi do kodu funkcji. Funkcja rozpoczyna działanie od zrobienia miejsca na stosie dla wszystkich swoich zmiennych lokalnych. Dlatego każde wywołanie danej funkcji powoduje przydzielenie jej pamięci dla nowego zestawu wszystkich jej zmiennych lokalnych. Funkcja jest wykonywana, po czym zwalnia na stosie miejsce, zarezerwowane dla swoich zmiennych lokalnych i przekazanych jej argumentów. Na końcu odczytuje adres powrotu i przekazuje sterowanie do odpowiadającej mu instrukcji. Jeżeli funkcja zwraca jakąś wartość, zazwyczaj wykorzystuje w tym celu jeden z rejestrów procesora. Należy jednak pamiętać, że przedstawiony powyżej schemat jest jedynie modelem sytuacji rzeczywistej. Szczegóły wywoływania funkcji zależą od konkretnej konwencji wywoływania funkcji (ang. calling convention), która z kolei zależy od rodzaju wykorzystywanego procesora i kompilatora. |
W tej chwili Czytelnik może zapoznać się z krótkim przewodnikiem po sposobach uzdatniania i testowania programów (debugging). |
W języku C++ można dokonywać standardowych operacji arytmetycznych na liczbach i zmiennych. Oto wykaz podstawowych operatorów arytmetycznych:
Operatory arytmetyczne dwuargumentowe | ||
+ | dodawanie | a + b |
- | odejmowanie | a - b |
* | mnożenie | a * b |
/ | dzielenie | a / b |
% | wyznaczanie reszty z dzielenia | a % b |
Operatory arytmetyczne jednoargumentowe | ||
+ | plus | +a |
- | minus (zmiana znaku liczby) | -a |
Operator przypisania | ||
= | przypisanie | a = b |
Kilka z powyższych operatorów wymaga bliższego objaśnienia. Na przykład dlaczego istnieją aż dwa operatory związane z dzieleniem?
Po pierwsze, jeżeli posługujemy się liczbami zapisanymi w reprezentacji zmiennopozycyjnej (a więc np. zmiennymi typu double), ich iloraz uzyskamy stosując operator / (operator % jest w tym przypadku zupełnie bezużyteczny). Jeżeli tylko jedna z liczb lub zmiennych jest typu zmiennopozycyjnego, wynik dzielenia będzie liczbą typu double. Jeżeli jednak zarówno dzielna jak i dzielnik są typu całkowitego (np. int), ich iloraz, uzyskany za pośrednictwem operatora /, będzie równy części całkowitej rzeczywistego ilorazu tych liczb. Tak więc 3/2 równe jest 1, czyli części całkowitej liczby 1.5. Analogicznie 10/3 równe jest 3 itd.
Operator % można stosować tylko w stosunku do typów całkowitych, np. int. Wartością wyrażenia a % b jest reszta z dzielenia a przez b. Tak więc 3 % 2 (czytaj: "3 modulo 2") równa się 1. Analogicznie 11 % 3 to 2 itd.
Wymaga się, by w każdej implementacji języka C++ spełniony był warunek
(a / b) * b + a % b jest równe a |
Jednak wynik zastosowania operatora / lub % w stosunku do dwóch liczb całkowitych, z których co najmniej jedna jest ujemna, nie jest dobrze określony (jednak powyższa zależność jest zawsze spełniona). Dlatego podczas dzielenia liczby lub przez liczbę ujemną należy zachować szczególną ostrożność. Tak, ta właściwość języka C++ jest bardzo wkurzająca! Uzasadnienie przyjęcia takiej nieprecyzyjnej definicji jest bardzo nieprzekonywujące: kompatybilność z językiem C i możliwość zapewnienia efektywnej implementacji rozumianej jako możliwie najmniejsza ilości instrukcji procesora. Ręce opadają!
Jednoargumentowy operator + nie robi niczego konkretnego - po prostu jest partnerem jednoargumentowego operatora -, który wyznacza wartość przeciwną do danej liczby. Tak więc po wykonaniu instrukcji a = 2; wyrażenie -a ma wartość -2, a +a to po prostu 2.
W naszym przykładowym programie widzimy pewną niezręczność: za każdym razem konstruktor obiektów klasy InputNum wyświetla ten sam napis: "Enter number ". Użytkownik nie ma pojęcia, co się akurat dzieje. Czyż nie lepiej byłoby mieć możliwość zmieniania tego napisu zależnie od kontekstu? Chcielibyśmy móc umieścić w funkcji main() następujące instrukcje:
InputNum num( "Enter number " ); num.AddInput( "Another one " ); num.AddInput( "One more " ); |
Jak widzimy, aby osiągnąć ten cel niektóre metody klasy InputNum będą musiały przyjmować jako argumenty napisy. Nie ma sprawy! Rozwiązanie jest proste:
#include <iostream>
using std::cout;
using std::cin;
class InputNum
{
public:
InputNum (char msg [])
{
cout << msg;
cin >> _num;
}
int GetValue () const { return _num; }
void AddInput (char msg [])
{
InputNum aNum (msg);
_num = GetValue () + aNum.GetValue ();
}
private:
int _num;
};
const char SumString [] = "The sum is ";
int main()
{
InputNum num ("Enter number ");
num.AddInput ("Another one ");
num.AddInput ("One more ");
cout << SumString << num.GetValue () << "\n";
return 0;
}
|
W kodzie tym widzimy kilka nowych elementów. Po pierwsze, posłużyliśmy się kolejnym typem wbudowanym - char. Typ ten wykorzystywany jest do przechowywania pojedynczych znaków (np. liter, cyfr, znaków interpunkcyjnych itp.). W zdecydowanej większości współczesnych komputerów zmienne typu char są wielkościami 8-bitowymi.
Widzimy także, że napis jest tablicą znaków - wynika to stąd, iż po nazwie zmiennych reprezentujących napisy umieściliśmy nawiasy kwadratowe, zarezerwowane właśnie dla tablic. Istotnym faktem, nie wynikającym z kodu naszego programu, jest to, że literały napisowe (np. "Enter number ") są tablicami zakończonymi bajtem zerowym. Oznacza to, że kompilator automatycznie uzupełnia ciąg znaków, tworzących dany literał napisowy, specjalnym, niewidocznym znakiem zwanym bajtem zerowym, dopisywanym na końcu tablicy. Tak więc napis "Tak" to w rzeczywistości tablica czterech znaków: 'T', 'a', 'k' oraz '\0' (zapis bajta zerowego często skraca się po prostu do zwykłego zera (0)). Bajta zerowego nie można utożsamiać z bajtem, reprezentującym cyfrę zero - są to dwa zupełnie różne znaki. Znak reprezentujący cyfrę zero zapisujemy jako '0'. Różnica jest wyraźna:
Warto wiedzieć, że zamiast wysyłać do obiektu cout napis "\n", możemy otrzymać dokładnie ten sam efekt przesyłając do strumienia wyjściowego specjalny obiekt endl. W tym celu posługujemy się instrukcją cout << endl;. Wyjaśnienie sposobu implementacji obiektu endl odłożę na później. Zauważmy jedynie w tym miejscu, że ponieważ obiekt endl jest częścią biblioteki standardowej, musimy albo poprzedzać jego nazwę przedrostkiem std::, albo odpowiednio wcześnie posłużyć się instrukcją using std::endl.
Uwaga! W bibliotece standardowej zdefiniowano specjalny typ string. Jest on niezwykle użyteczny, zwłaszcza jeżeli zamierzamy dokonywać jakichkolwiek manipulacji na napisach. Jego opis przedstawimy nieco później. |
Zwróćmy uwagę na jeszcze jedną, niezwykle ważną cechę naszego programu - sposób wywoływania metody GetValue() w kodzie metody AddInput(). Metodę GetValue() wywołujemy tam dwukrotnie: raz (pozornie) bez jawnego wskazania obiektu i raz na rzecz obiektu aNum. Wiemy, że drugie wywołanie funkcji GetValue() spowoduje, że będzie ona operować na obiekcie aNum. Okazuje się, że pierwsze wywołanie powoduje wywołanie metody GetValue() na rzecz obiektu, w którego kontekście wywołano daną metodę (w naszym przykładzie jest to metoda AddInput() wywoływana na rzecz obiektu num w funkcji main). W żargonie języka C++ mówimy, iż pierwsze wywołanie funkcji GetValue() odbywa się na rzecz obiektu "this". W naszym przykładzie metoda GetValue() po prostu odczytuje wartość składowej _num bieżącego obiektu. Czyni to jednak w sposób izolujący nas od szczegółów implementacji. Tutaj posłużenie się funkcją w celu odczytania wartości zmiennej może wydawać się lekką przesadą (czyż nie wystarczyłoby po prostu napisać bezpośrednio _num?), jednak w wielu sytuacjach takie podejście może zaowocować znacznym ułatwieniem przyszłych wysiłków związanych z konserwacją i rozwojem naszego oprogramowania.
Czytelnik być może zastanawia się, jaka cenę musimy płacić za zastępowanie odwołań do zmiennych wywołaniami funkcji składowych. I czy nie lepiej byłoby umieścić składowej _num w części publicznej i uprościć sobie w ten sposób metody manipulowania obiektami? Istnieje nawet odpowiednia składnia, umożliwiająca dostęp do składowych publicznych:
myNum._num |
To niewiarygodne, ale wywołanie metody GetValue() nie kosztuje dosłownie nic. To nie żarty - posługując się nią nie generujemy żadnych dodatkowych instrukcji procesora. Nada, nil, niente, zilch! (Później wyjaśnię znaczenie tego typu funkcji, tzw. funkcji wklejanych (ang. inline functions)).
Podsumowanie
Ciało funkcji składowej tworzy odrębny zakres lokalny. Dotyczy to także ciała konstruktora i destruktora. W tym lokalnym zakresie można swobodnie definiować różne obiekty a także tworzyć nowe, zagnieżdżone zakresy.
Napis jest to tablica znaków zakończona bajtem zerowym. Tablice deklarujemy umieszczając po ich nazwach nawiasy kwadratowe. Tablice znaków można inicjować w sposób bezpośredni przy pomocy literałów napisowych. Tablice można definiować zarówno zakresie globalnym jak i lokalnym. Można je także przekazywać jako argumenty funkcji.