Zaawansowane Techniki Programistyczne Skrypt Jan Pustelnik

Transkrypt

Zaawansowane Techniki Programistyczne Skrypt Jan Pustelnik
Zaawansowane Techniki Programistyczne
Skrypt
Jan Pustelnik,
Grudzień 2004
Wstęp
Skrypt “Zaawansowane techniki programistyczne” ma na celu zapoznanie czytelnika z
technikami programistycznymi, które są obecnie powszechnie stosowane przez informatyków
zajmujących się tworzeniem oprogramowania komputerowego. Słowo “zaawansowane” pojawia się ze
względu na to, że techniki te wykraczają poziomem trudności poza “elementarz” potrzebny do
tworzenia najprostszych programów. Zwykle to wyłącznie ten “elementarz”, na który składają się
deklaracje zmiennych i funkcji oraz podstawowe struktury sterujące, nauczany jest na podstawowych
kursach, które mają za zadanie przekazanie nauczanemu samej umiejętności programowania. Niestety
we współczesnej praktyce programistycznej ten podstawowy zakres umiejętności jest dalece
niewystarczający. By móc używać popularnych technologii takich jak programowanie “wizualne”,
polegające na tworzeniu aplikacji korzystających intensywnie z udogodnień oferowanych przez tak
zwany “graficzny interfejs użytkownika” (w skrócie GUI) – przykładem może tu być środowisko
systemu Microsoft Windows, czy też popularny ostatnio język Java (Java Beans, Enterprise Java Beans,
Java Servlets) programista zmuszony jest używać pojęć i technik z zakresu tzw. programowania
obiektowego. Jednakże nawet wtedy, gdy środowisko programistyczne nie wymusza na nas używania
jakichś konkretnych technik, programowanie strukturalne i będące jego rozwinięciem programowanie
obiektowe są czesto stosowane, gdyż poprawiają czytelność kodu, a dzięki temu zmniejszają koszty
projektu informatycznego. Wielokrotnie byłem świadkiem sytuacji, w których duże projekty ulegały
“zawaleniu” wskutek niechlujnej praktyki programistycznej po przekroczeniu pewnego krytycznego
rozmiaru.
Skrypt ten powstał na potrzeby zajęć eksternistycznych z przedmiotu o nazwie “Zaawansowane
techniki programistyczne”, realizowanego na Wydziale Matematyki Uniwerstytetu Łódzkiego. Może
on jednak być materiałem pomocniczym na przedmiotach “Programowanie” i “Języki programowania”
na różnych stopniach zaawansowania tych przedmiotów.
Autor zakłada, że uczestnik kursu potrafi programować w zakresie podstawowym, przez co
rozumie się umiejętność napisania prostego programu (w dowolnym języku programowania), na
przykład takiego, który pobiera z wejścia liczby całkowite aż do momentu wpisania przez użytkownika
zera, a następnie wypisuje największą z podanych liczb.
Materiał objęty kursem podzielony jest na dziewięć rozdziałów. Każdy rozdział odpowiada
dwóm godzinom zajęć, czyli całość odpowiada osiemnastu godzinom lekcyjnym. Niemniej jednak ilość
czasu spędzona nad każdym z rozdziałów w dużej mierze zależy od indywidualnych predyspozycji
kursanta i może być większa od wspomnianych dwóch godzin lekcyjnych. Każdy rozdział zaczyna się
przedstawieniem omawianego zagadnienia wraz z przykładami je ilustrującymi. Następnie pojawiają
się zadania, z których pierwsze kilka jest przykładowo rozwiązanych, zaś pozostałe są do rozwiązania
przez kursanta. Na początku został umieszczony rozdział zerowy – w którym prezentowane jest
używane w trakcie kursu środowisko pracy.
Językiem z wyboru używanym w przykładach w niniejszym skrypcie jest język C++, poza
ostatnim rozdziałem, gdzie prezentowane są sposoby użycia opisywanych technik programistycznych w
języku Java. C++ został wybrany ze względu na to, że bazuje na popularnym języku C, który z kolei
jest podobny do często używanego w szkołach i na kursach dla początkujących języka Pascal.
Jednocześnie C++ jest nowoczesnym językiem zorientowanym obiektowo, zawierającym
zaawansowane mechanizmy oparte na wzorcach (ang. template). Z drugiej strony C++, w odróżnieniu
od języka Java, jest językiem w którym łatwo dokonać prezentacji pewnych pojęć takich jak wskaźniki
czy też funkcje wirtualne. Przejście do programowania w języku Java po opanowaniu prezentowanych
w niniejszym skrypcie podstaw będzie bardzo łatwe. Język Java zasługuje na szczególną pochwałę ze
względu na stosowany w nim rozbudowany mechanizm tzw. wyjątków (ang. exception), które
pozwalają na szybką diagnostykę przyczyn błędów występujących w trakcie działania programu (ang.
run-time errors).
Rozdział 1
Wprowadzenie do środowiska pracy
Przez cały czas kursu będziemy pracować w środowisku Bloodshed Dev-Cpp, które stanowi
darmowe i wygodne w użyciu IDE (integrated development environment) bazujące na wysokiej jakości
kompilatorze GNU C/C++. Samo środowisko jest do ściągnięcia z następującego adresu:
http://www.bloodshed.net/devcpp.html
Więcej informacji o kompilatorach GNU można znaleźć na stronie:
http://gcc.gnu.org
Oczywiście Czytelnik może używać dowolnego innego narzędzia do edycji, kompilacji i
debugowania programów pisanych w języku C++, niemniej jednak wspomniane środowisko jest dobrze
dopracowane, wygodne w użyciu i działa w systemach operacyjnych rodziny Microsoft Windows.
Instalacja programu Dev-Cpp nie powinna nastręczyć problemów, korzysta się bowiem z wygodnego w
użyciu programu instalacyjnego. Istnieje możliwość wybrania języka polskiego jako
Zajmiemy się teraz prezentacją zastosowania środowiska Bloodshed Dev-Cpp do edycji,
kompilowania i debugowania programów komputerowych pisanych w języku C++. Spróbujemy
prześledzić razem wszystkie kroki po kolei. Zaczynamy od stworzenia nowego pliku:
Nowy plik jest już stworzony, więc go zapisujemy:
I już możemy z nim pracować:
Spróbujemy teraz program skompilować:
Pokazuje się okienko kompilacji:
Ale chyba coś nie wyszło – widać wyraźnie czerwoną linię w kodzie źródłowym:
i uaktywnił się dolny pasek:
Możemy jeszcze zobaczyć jak wygląda wyjście z kompilatora:
Oczywiście poprawiamy błąd (brakuje średnika w linii 6) i kompilujemy ponownie:
Możemy teraz uruchomić nasz program. Najlepiej zrobić to korzystając z linii poleceń.
Uruchamiamy cmd:
W cmd musimy jeszcze tylko zmienić katalog roboczy na katalog w którym przechowywane są
nasze programy (w wypadku mojej instalacji jest to c:\Dev-Cpp\Projects) i juz mozemy uruchomić
program program_testowy.exe:
Postaramy się teraz przedstawić jeszcze okienko debuggera:
Wśród zakładek po lewej stronie pojawia się zakładka “Debug”, gdzie np. możemy oglądać
zawartość zmiennych wskazanych przez “Add Watch”:
Zachęca się Czytelnika do poeksperymentowania celem lepszego zapoznania się z narzędziem
Bloodshed Dev-Cpp.
Rozdział 2
Elementy programowania proceduralnego
Rozdział ten ma na celu przypomnienie Czytelnikowi zagadnień programowania
proceduralnego. Ma również na celu “oswojenie” się ze sposobem prezentacji materiału używanym w
niniejszym opracowaniu. Dla ułatwienia przyswojenia materiału przez Czytelników, którzy dotąd
wyłącznie programowali w języku Pascal i nie znają języka C będziemy prezentowali fragmenty kodu
w języku Pascal odpowiadające omawianym fragmentom kodu. Przy uczeniu się języka C++
znajomość C nie jest wymagana. Co więcej obecnie odradza się już uczenia się języka C przed
nauczeniem się języka C++. W rozdziale tym postaramy się też ilustrować niektóre zagadnienia
przykładami z języka Pascal, co może pomóc tym Czytelnikom, którzy mieli ograniczony kontakt z
językami wywodzącymi się z języka C, zaś programowali już w Pascalu.
Podstawą programowania są zmienne. Zmienne odpowiadają obszarom pamięci, które mogą
przyjmować różne wartości, zmieniające się dynamicznie w trakcie działania programu. Jedną z
najważniejszych właściwości zmiennych jest to, że przyjmują one wartości z pewnego, z góry
ustalonego zakresu. Zakres taki nazywany jest typem zmiennej. Typ pełni dwojaką rolę: pozwala
ustalić jak duży obszar pamięci należy zarezerwować dla zmiennej oraz określa wartości, które są
dopuszczalne dla danej zmiennej. Poniżej prezentujemy przykład deklaracji kilku zmiennych różnych
typów w C++:
// to jest komentarz jednolinijkowy w C++
int x; // zmienna całkowita
float y; // zmienna zmiennoprzecinkowa pojedynczej precyzji
double z; // zmienna zmiennoprzecinkowa podwójnej precyzji
unsigned int a; // zmienna całkowita bez znaku
i odpowiadający fragment w Pascalu:
{ to jest komentarz w języku Pascal }
x : Integer; { x jest zmienną całkowitą }
y : Single; { y jest zmienną zmiennoprzecinkową pojedynczej
precyzji }
z : Double; { z jest zmienną podwójnej precyzji }
a : Cardinal; { a jest zmienną bez znaku }
Zmienne w języku C++ są przechowywane na tzw. stosie. Jest to pewien obszar pamięci
przydzielony programowi, który dzięki wsparciu ze strony architektury procesora może być
obsługiwany albo jako obszar o dostępie swobodnym albo jako stos. Zwykle ten obszar pamięci nie jest
zbyt duży. Pozostała część pamięci tworzy tzw. stertę – obszar gdzie pamięć przydzielana jest
dynamicznie, zaś dostęp jest wyłącznie dostępem swobodnym.
Należy zauważyć, że w przeciwieństwie do Pascala czy też C deklaracja zmiennej w C++
(podobnie jak w Java) może wystąpić w każdym miejscu, w którym może wystąpić instrukcja.
Deklaracja taka jest ważna do końca bloku w którym wystąpiła. Bloki oznaczane są nawiasami
sześciennymi (takimi jak komentarze w Pascalu):
...
int
int
x =
for
int
...
x;
y;
7;
(; x < 10; ++x) ;
y;
int z = 7;
{
int t;
...
}
// tutaj t już nie jest zadeklarowane
...
Szczególnymi typami zmiennych są wskażniki i referencje. Wskażnik to zmienna (przydzielona
na stosie), przechowująca adres w pamięci, pod którym znajdują się właściwe dane. Wskaźniki,
podobnie do zwykłej zmiennej przechowuje informację o typie. Pozwala to na poprawną interpretację
danych przechowywanych w obszarze pamięci do którego wskaźnik się odnosi. Referencja stanowi
alternatywną nazwę dla zmiennej. Jedynym sensem instnienia referencji w C++ jest przekazywanie
zmiennych przez wartość do i z funkcji. Zasadniczo referencje pełnią taką samą rolę jak słowo “var” w
Pascalu. Poniżej przedstawiamy przykłady użycia wskaźników i referencji.
Deklarowanie wskaźników i referencji:
int x;
int* y; // y jest wskaźnikiem na zmienną całkowitą
int* a, b;
// uwaga! częsty błąd – a jest wskaźnikiem,
// ale b już nie
y = &x;
// y wskazuje na obszar pamięci, w którym jest x
// operator & oznacza pobranie adresu
*y = 7;
// teraz w pamięci na którą wskazuje y została
// umieszczona liczba 7 – oznacza to, że wartością
// zmiennej x jest teraz 7
int& z = x; // z jest referencją dla zmiennej x
z = z + 1; // teraz x ma wartość 8
Używanie wskaźników i referencji w wywołaniach funkcji:
// ch1pr1.cpp
// program demonstruje użycie wskaźników i referencji
#include <iostream>
using namespace std;
void zamien1(int x, int y) {
y
wartość
int tmp = x;
x = y;
y = tmp;
}
void zamien2(int* x, int* y) {
jako efekt uboczny
wskaźnik
int tmp = *x;
*x = *y;
*y = tmp;
}
// ta funkcja nie zamieni x i
// przekazywanie przez
// ta funkcja zamieni x i y
// przekazywanie przez
void zamien3(int& x, int& y) {
jako efekt uboczny
referencję
w C
przekazywanie przez
}
int tmp = x;
x = y;
y = tmp;
// ta funkcja zamieni x i y
// przekazywanie przez
// ta składnia nie występuje
// występuje w Pascalu jako
// zmienną (var)
int main() {
int a = 4;
int b = 5;
cout << "Na poczatku a=" << a << " b=" << b << endl;
zamien1(a, b);
cout << "Po wywolaniu zamien1 a=" << a << " b=" << b <<
endl;
zamien2(&a, &b);
// uwaga! tu w wywołaniu musimy
przekazać adresy
cout << "Po wywolaniu zamien2 a=" << a << " b=" << b <<
endl;
zamien3(a, b);
// tutaj składnia taka sama jak w
wywołaniu zamien1
cout << "Po wywolaniu zamien3 a=" << a << " b=" << b <<
endl;
}
return 0;
I zapis wyjścia wygenerowanego przez program:
Po wywolaniu zamien1 a=4 b=5
Po wywolaniu zamien2 a=5 b=4
Po wywolaniu zamien3 a=4 b=5
Jak widać funkcje zamien2 i zamien3 zamieniły wartości zmiennych a i b.
W przykładzie tym tylko dwie funkcje działają zgodnie z naszymi oczekiwaniami. Zawsze
bowiem w języku C argumenty funkcji są kopiowane na początku wywołania funkcji. Takie
przekazywanie zmiennych do funkcji nosi żargonową nazwę przekazywania przez wartość. Stąd
potrzeba operowania na wskaźnikach. Język Pascal w wypadku zmiennych mających podlegać zmianie
oferował przekazywanie zmiennych “przez zmienną” (przy użyciu słowa kluczowego var) w opozycji
do przekazywania “przez wartość”. Podobną możliwość wprowadzono do języka C++ i jest to
preferowany w tym języku sposób przekazywania do funkcji zmiennych, których wartość ma ulec
zmianie wewnątrz tej funkcji (zwane jest to często efektem ubocznym funkcji). Ze względu na
składnię języka konieczne było wprowadzenie referencji jako modyfikatora typu (tak jak ma to miejsce
ze wskaźnikiem), ale zasadniczo nie używa się zmiennych o typie referencja do zmiennej; referencja
służy celom przekazywania wartości do funkcji.
Próba przypisania zmiennej wartości spoza zakresu dopuszczonego typem może powodować
wystąpienie błędu lub też wywołanie procedury konwersji typu. Konwersja typu (zwana też
rzutowaniem) powoduje zmianę wartości jednego typu na wartość innego typu. To czy w danej sytuacji
zostanie wywołana automatyczna procedura konwersji typu czy też zostanie zgłoszony błąd zależy od
użytego języka programowania. Programista zawsze ma możliwość jawnego wywołania procedury
konwersji typu. Poniżej prezentujemy przykłady różnych konwersji typów:
// ch1pr2.cpp
// program demonstruje problemy związane z konwersją typów
int main() {
int x = 5;
float y = 3.14;
double z = 2.718;
x=y;
// automatyczne rzutowanie; ostrzeżenie o utracie
precyzji
x=z;
// automatyczne rzutowanie; ostrzeżenie o utracie
precyzji
y=x;
// automatyczne rzutowanie
x = (int) y;
// rzutowanie w starym stylu
x = static_cast<int>(z); // rzutowanie w nowym stylu
}
Niezgodność typów, sygnalizowana wystąpieniem błędu, świadczy często o niewłaściwej
konstrukcji programu. Dzięki temu, że błędy niezgodności typów wykrywane są na etapie kompilacji
(ang. compile time), a nie w czasie działania programu (ang. run-time) pozwala na zaoszczędzenie
dużej ilości czasu. Niemniej jednak programiści nie lubią nadopiekuńczych języków, na przykład
takich, które wymagają jawnej konwersji typu nawet w wypadku zamiany wartości całkowitej na
rzeczywistą. Dlatego dużą popularnością cieszy się język C i jego pochodne, które posiadają znaczny
zakres automatycznych konwersji typów. Twórcy tych języków zakładają , że programista jest osobą
działającą świadomie i wie co robi. Jeżeli użytkownik pragnie popełnić błąd, to nie należy go przed tym
powstrzymywać, choć powinno się go ostrzec o konsekwencjach (patrz fragment dotyczący błędów i
ostrzeżeń kompilatora znajdujący się w rozdziale 1).
W języku C++ używane są przede wszystkim następujące typy proste (dla ułatwienia w
nawiasach przedstawiono również odpowiadające typy z języka Turbo/Object Pascal):
1 Typy całkowite
char – służy do reprezentacji znaków (Char)
unsigned char – typ całkowity bez znaku o niewielkim zakresie (Byte)
int – typ całkowity ze znakiem (Integer) – zwykle zakres typu zgodny z długością słowa procesora,
zatem dla procesorów 32-bitowych użyte będzie do reprezentacji 32 bity.
unsigned int – typ całkowity bez znaku (Cardinal) – zakres zgodny z długością słowa procesora
short int, long int unsigned short int unsigned long int – kwalifikatory short i long oznaczają, że do
reprezentacji typu powinno być użyte odpowiednio mniej lub więcej bitów niż dla reprezentacji typu
int; rzeczywista długość zależy od implementacji (użytego kompilatora)
uwaga: Pascal precyzyjnie definiuje zakresy typów takich jak Shortint, Smallint czy Longint. W
wypadku C++ typy te mają zakresy zdefiniowane przez implementację. Java natomiast postępuje
podbnie jak Pascal – przypisując używanym typom konkretne zakresy.
2 Typy zmiennoprzecinkowe
float – typ zmiennoprzecinkowy pojedynczej precyzji (Single)
double – typ zmiennoprzecinkowy podwójnej precyzji (Double)
uwaga: W Pascalu występuje typ Real, który oznacza typ zmiennoprzecinkowy, ale o nieokreślonej
precyzji (zależy to od implementacji).
Do dyspozycji mamy również następujące typy złożone (przez złożony rozumiemy typ, który w
jakiś sposób stanowi kolekcję wielu elementów, z których każdy jest pewnego typu prostego):
3 Typy enumerowane
typedef enum {element1, element2, element3, ...} nazwa-typu;
w Pascalu: trzeba umieścić w sekcji Type:
nazwa-typu = (element1, element2, element3, ...);
przykład:
typedef enum {czerwony, zielony, niebieski} koloryRGB;
4 Typy tablicowe
w języku C używa się tablic następująco:
– deklaracja tablicy: typ nazwa-zmiennej[rozmiar];
– deklaracja typu tablicowego: typedef typ nazwa-typu[rozmiar];
tablice w C indeksowane są od zera!!!
w języku Pascal odpowiednio:
– deklaracja tablicy: x
– deklaracja typu tablicowego: y
tablice w Pascalu indeksowane są od jedynki.
przykład:
int tablica[10];
5 Typ łańcuchowy (łańcuch znaków)
w języku C tradycyjnie łańcuchy były po prostu tablicą znaków:
char lancuch[10];
przy czym zawsze zakłada się, że na końcu łańcucha znajduje się znak '\0' – czyli znak o wartości zero.
w języku C++ używa się łańcuchów będących zmiennymi typu string (dokładniej std::string). Aby ich
użyć, należy włączyć nagłówek <string> (i ewentualnie zadeklarować using namespace std).
Przykład: string lancuchcpp;
W Pascalu używane są dwa rodzaje łańcuchów: String (o z góry określonym rozmiarze, ale bez
kończącego go znaku zerowego oraz AnsiString o nieograniczonym rozmiarze, w którym na końcu
musi się znajdować znak zerowy.
Drugim podstawowym budulcem programu są struktury kontrolujące przepływ sterowania. Są
to tak zwane instrukcje warunkowe, które pozwalają na wykonanie jednego z kilku alternatywnych
fragmentów programu w zależności od wartości pewnego wyrażenia oraz instrukcje pętli, które
pozwalają na wielokrotne wykonywanie tych samych fragmentów programu. Zwykle wykonywanie
instrukcji pętli jest również kontrolowane przez pewne wyrażenie, choć zdarza się, że pętle opuszcza
się poprzez wykonanie instrukcji skoku (ang. goto). Niektóre języki nie dopuszczają instrukcji skoku
ze względu na to, że zmniejsza ona w wielu wypadkach czytelność kodu, inne zaś nakładają na jej
użycie dosyć znaczne ograniczenia. I tutaj obowiązuje podstawowa zasada języka C++, która mówi, że
w wypadku wątpliwości należy ufać programiście.
Struktury kontrolujące przepływ sterowania są w językach C, C++ i Java bardzo podobne. Dla
osób, które programowały w języku Pascal przedstawimy obok odpowiadające struktury z tego języka:
C++
for(int i = x1; i < x2; ++i) instrukcja;
Pascal
Integer I; {ta zmienna musi być wcześniej
zadeklarowana}
while (warunek) do instrukcja;
do instrukcja while(warunek);
if (warunek) instrukcja-1; else instrukcja-2;
For I:= x1 to x2-1 Do instrukcja;
While warunek Do instrukcja;
Repeat instrukcja Until zaprzeczenie-warunku;
If warunek Then instrukcja-1 Else instrukcja-2;
switch(variable) {
{zwróć uwagę na brak średnika przed Else}
case variable of
case 1: action-1; break;
1: action-1;
case 2:
2..4: action-2;
case 3:
else action-3;
case 4: action-2; break;
end;
default: action-3; break;
}
Ostatnim podstawowym elementem składni języka programowania są funkcje i procedury.
Pozwalają one na modularyzacje programu, czyli podział na wiele niezależnych od siebie elementów.
Użycie funkcji i procedur pozwala zwiększyć przejrzystość programu, a także pozwala na wielokrotne
użycie tego samego kodu poprzez wydzielenie często powtarzających się operacji. Programowanie
obiektowe rozwija te idee.
Poniżej prezentujemy definicje funkcji w językach C i C++ (odkładając kwestie związane z
językiem Java na później), jednocześnie umieszczając obok odpowiadające konstrukcje składniowe z
języka Pascal.
C++
int funkcja(int x, float y) {
Pascal
funkcja(Integer x, Single y) : Integer;
return 7;
begin
}
return 7;
end;
Przykładowe zadania:
1. Napisać prosty program, który dodaje do siebie dwie liczby całkowite podane przez użytkownika i
wypisuje ich sumę.
2. Napisać program, który pobiera liczby od użytkownika tak długo, aż nie zostanie wprowadzone
zero. Wówczas wypisuje średnią tych liczb.
Rozdział 3
Kontrolowanie przydziału zasobów
Rozdział ten zaczniemy od przypomnienia pojęć: tablicy i arytmetyki na wskaźnikach,
pokażemy też, że w C++ związek między wskaźnikiem a tablicą jest bardzo bezpośredni.
Tablica jest naturalnym przetłumaczeniem na informatykę matematycznego pojęcia wektora,
czy też macierzy. Interpretacją tablicy w C++ jest spójny obszar pamięci zapełniony zmiennymi tego
samego typu, do którego dostęp odbywa się za pomocą indeksowania. Tablice używane są do
interpretowania pewnych zbiorowości – przykładowo tablica zawierająca listę nazwisk wszystkich
uczniów w klasie. Istnieje duża klasa algorytmów działających na tablicach – np. należą do nich
standardowe algorytmy sortowania. W C++ tablice indeksowane są od zera (inaczej niż w Pascalu),
indeks podawany jest w nawiasie kwadratowym. W wypadku tablic wielowymiarowych kolejne
indeksy umieszczane są w osobnych nawiasach kwadratowych, a nie oddzielane przecinkami, jak ma to
miejsce w niektórych językach programowania. Sposób używania tablic jest intuicyjny, warto jednak
zauważyć, że w C++ tablica i wskaźnik na zerowy element tablicy to ta sama rzecz. Indeksowanie jest
zaś równoważne arytmetyce na wskaźnikach. Postaramy się to zilustrować poniższymi przykładami:
//ch3pr1.cpp
// arytmetyka na wskaznikach
int main() {
int tab[10];
int* ntab;
tab
}
*(tab); // odwolujemy sie do zerowego elementu tabeli tab
*(tab+3); // odwolujemy sie do trzeciego elementu tabeli
tab[3]; // rownowazne powyzszemu
ntab=tab+7; // ntab wskazuje teraz na tab[7]
W języku C++, podobnie jak w Pascalu lub Java można deklarować zmienne, dla których
pamięć będzie przydzielana dynamicznie, na tzw. stercie (ang. heap). W celu operowania na takich
zmiennych należy wykorzystywać wskaźniki. Poniżej prezentujemy przykłady użycia zmiennych
przydzielanych dynamicznie.
//ch3pr2.cpp
#include <iostream>
using namespace std;
int main() {
int x; // zmienna przydzielana na stosie
int* px; // wskaznik na zmienna, sam wskaznik jest
przydzielany na stosie
px = new int; // teraz px wskazuje na obszar pamieci
przydzielony dynamicznie
*px = 7; // zapisujemy 7 do obszaru na ktory wskazuje px
int *z; // inny wskaznik
z = px; // teraz z i px wskazuja na to samo
cout << *z << endl; // wypisujemy to na co wskazuje z
}
Ze zmiennymi przydzielanymi dynamicznie związane jest pewne bardzo ważne zagadnienie,
które często nie jest wystarczająco mocno podkreślane na kursach programowania. Chodzi tutaj o
kontrolowanie przydziału i zwalniania pamięci, która jest dynamicznie przydzielana na stercie (ang.
heap) w czasie działania programu. Kontrola nad przydziałem i zwalnianiem pamięci jest o tyle istotna,
że w wypadku programu, który wiele razy (np. w pętli) wykonuje działania polegające na przydziale i
zwolnieniu pamięci może nastąpić zjawisko wycieku pamięci (ang. memory leak). Polega ono na tym,
że program zwalnia mniejszą ilość pamięci niż ta, która została przydzielona, albo też w ogóle nie
zwalnia przydzielonej pamięci. Działanie takie prowadzi w dłuższej perspektywie do wyczerpania całej
dostępnej pamięci, a tym samym do awaryjnego zakończenia programu. Oto przykład najprostszego
programu demonstrującego opisywane kłopoty:
//ch3pr3.cpp
int main() {
for(;;) // forever
int* z = new int[1000000];
}
Problem kontroli przydziału i zwalniania pamięci rozwiązać może mechanizm automatycznego
mechanizmu odzyskiwania nieużytków (ang. garbage collector). Mechanizm taki został zastosowany
w języku Java, gdzie nie ma potrzeby zwalniania przydzielonych dynamicznie zmiennych. Dostępne
jest również wiele bibliotek oferujących mechanizmy odzyskiwania nieużytków w języku C++.
Niestety garbage collector zajmuje się wyłącznie kwestią zwalniania nieużywanej pamięci, natomiast
nie pomaga w sytuacjach, w których następuje wyciek innych zasobów, takich jak np. połączenia
sieciowe. Dlatego też zawsze należy pamiętać o zwalnianiu przydzielonych zasobów. Gdy projektuje
się program od razu myśląc o takich problemach, nie tylko brak automatycznego mechanizmu
odzyskiwania nieużytej pamięci przestaje sprawiać kłopot, ale unikamy sytuacji, w której zapomnimy
na przykład o zwolnieniu połączeń sieciowych. Należy zawsze pamiętać, że istnienie mechanizmów
automatycznego odzyskiwania pamięci ma na celu ułatwienie życia programisty, ale nie zwalnia go z
myślenia.
Drugim aspektem związanym z pamięcią są błędy polegające na próbie dostępu do obszarów
leżących poza pamięcią przydzieloną programowi. Sytuacje te związane są z pojęciem wskaźnika.
Poniżej przybliżymy typowe problemy wynikające z używania wskaźników. Problemy te są typowe dla
języków C i C++ i nie występują w języku Java, gdzie nie używa się wskaźników, a zamiast nich
operuje się pojęciem referencji. Poniżej przedstawimy typową sytuację, w której dochodzi do próby
dostępu do obszaru pamięci, który nie należy do programu:
//ch3pr4.cpp
#include <iostream>
using namespace std;
int main() {
int tab[10];
int* el;
*el = 7; // oops
}
for (int i = 0; i < 10000;/*oops!*/ ++i)
tab[i]=i;
for (int i = 0; i < 10; ++i)
cout << tab[i] << endl;
Należy za wszelką cenę unikać takich sytuacji. Istnieją narzędzia pomagające w wykrywaniu tego typu
błędów, ale ich opis wykracza poza zakres niniejszego opracowania. Jednym z takich narzędzi jest tzw.
Electric Fence, który jest biblioteką dołączaną do destowanych programów.
Zadania:
1. Napisać przykładowy program ilustrujący zagadnienie wycieku pamięci.
2. Poszukać narzędzi typu garbage collector dla języka C++
Rozdział 4
Struktury danych – programowanie strukturalne
Jednym z ważniejszych udogodnień ułatwiających programowanie są tzw. typy złożone, takie
jak tablice czy też struktury. My skupimy się w niniejszym skrypcie na strukturach i ich rozszerzeniu –
klasach. W poprzednim rozdziale Czytelnik miał okazję oswoić się z tablicami. Teraz przejdziemy do
drugiego ważnego typu złożonego – struktury. Struktura jest specjalnym ułatwieniem pozwalającym na
zgrupowanie w jednej zmiennej kilku informacji o danym obiekcie (często mówi się atrybutów
obiektu) różniących się od siebie typem. Przykładowo dla obiektu osoba w programie o charakterze
kadrowo-płacowym możemy chcieć zgromadzić następujące informacje: imię (łańcuch znaków),
nazwisko (łańcuch znaków), wiek (mała liczba całkowita), numer pesel (duża liczba całkowita), pensja
brutto (liczba rzeczywista) itd. Dzięki zebraniu wszystkich tych danych w polach struktury możliwe
jest łatwe operowanie nimi. Język Java nie używa pojęcia struktury. Operuje od razu na klasach, o
których obszerniej powiemy w następnych rozdziałach.
Najprostsza struktura wygląda następująco:
struct pracownik {
string imie;
string nazwisko;
int wiek;
}; // uwaga – ten średnik jest ważny!
Struktury używane są bardzo często jako elementy tablic oraz jako argumenty funkcji. Przykład
takiego zastosowania umieszczono poniżej:
pracownik pracownicy[50];
for (int i = 0; i < 50; ++i)
cout << “Pracownik: “ << pracownicy[i].imie << “ “ <<
pracownicy[i].nazwisko << endl;
int podajWiekPracownika(const pracownik& prac) {
return prac.wiek;
}
Często struktury są używane do implementacji list łączonych i drzew. W takich zastosowaniach
oddają one nieocenione usługi. Poniżej prezentujemy przykład zastosowania struktury do
implementacji listy jednokierunkowo łączonej. Inne przykłady znajdują się w dalszej części niniejszego
modułu.
//ch4pr1.cpp
//elementarna demonstracja list
#include <iostream>
using namespace std;
struct elem {
int val;
elem* next;
};
struct list {
elem* head;
};
elem* tail;
// funkcje sluzace do operowania na listach
void list_insert_element(list& l, int el) {
elem* cur = new elem;
cur->val = el;
cur->next=NULL;
if(l.tail==NULL) { // brak elementow na liscie
l.head=cur;
l.tail=cur;
} else { // dodajemy na koncu
l.tail->next=cur;
l.tail=cur;
}
}
int list_remove_element(list& l) {
int tval=0;
if(l.head != NULL) { // lista nie moze byc pusta
elem* tmp = l.head;
l.head=l.head->next; // usuwamy z poczatku listy
tval=tmp->val;
delete tmp; // zwalniamy pamiec!
}
return tval;
}
bool list_is_empty(list& l) {
if (l.head==NULL)
return true;
else
return false;
}
int main() {
list lista;
lista.head = NULL;
lista.tail = NULL;
for(int i=0; i < 10; ++i)
list_insert_element(lista, i);
cout << "wprowadzono do listy 10 elementow" << endl;
cout << "wypisuje elementy:" << endl;
while (!list_is_empty(lista))
cout << list_remove_element(lista) << endl;
}
Jedną z najważniejszych korzyści z programowania strukturalnego jest modularyzacja kodu
programu. Dzięki modularyzacji ograniczamy wpływ błędów w jednym fragmencie programu na
poprawność działania innej części programu. Podstawą modularyzacji jest rozdzielna kompilacja,
wykorzystywanie deklaracji i definicji oraz korzystanie z plików nagłówkowych. Obowiązują tutaj
następujące zasady:
1. Deklaracja jest wyłącznie informacją dla kompilatora o istnieniu jakiegoś obiektu i ewentualnie
związanych z nim typach. Dla zmiennych deklaracja ma taki sam kształt jak definicja zmiennej,
jednakże jest poprzedzona słowem extern. Dla funkcji deklaracja ma postać taką samą jak nagłowek
funkcji, tyle, że zamiast ciała funkcji jest średnik. Poniżej mamy przykłady definicji i deklaracji:
extern int x; // deklaracja
int x; // definicja
struct xyz {
int a;
int b;
}; //deklaracja typu
xyz abc; // definicja zmiennej
int funkcja(int); //deklaracja
int funkcja(int x) {return x;} //definicja
2. Deklaracja musi być włączona do każdego pliku źródłowego, w którym się z niej korzysta (odwołuje
do zmiennej lub funkcji wymienionej w deklaracji). Z drugiej strony definicja może wystąpić w
całym kodzie programu wyłącznie raz.
Dlatego dzieląc program na moduły stosujemy następujące reguły:
1. Dla każdego pliku źródłowego zawierającego kod (czyli definicje) tworzymy osobny plik, tzw.
nagłówkowy, który zawiera wyłącznie deklaracje. Plik taki jako rozszerzenie nazwy stosuje “.h” lub
“.hpp”.
2. Każdy plik nagłówkowy jest włączany przy pomocy dyrektywy “#include” do każdego z plików z
definicjami składających się na program.
Przykład zastosowania poniższych reguł można prześledzić na przykładzie poniższego kodu:
//ch4pr2.h
struct elem {
int val;
elem* next;
};
struct list {
elem* head;
elem* tail;
};
// funkcje sluzace do operowania na listach
void list_insert_element(list&, int);
int list_remove_element(list&);
bool list_is_empty(list&);
//ch4pr2.cpp
//elementarna demonstracja list
#include <iostream> //dla NULLa
#include "ch4pr2.h"
// funkcje sluzace do operowania na listach
void list_insert_element(list& l, int el) {
elem* cur = new elem;
cur->val = el;
}
cur->next=NULL;
if(l.tail==NULL) { // brak elementow na liscie
l.head=cur;
l.tail=cur;
} else { // dodajemy na koncu
l.tail->next=cur;
l.tail=cur;
}
int list_remove_element(list& l) {
int tval=0;
if(l.head != NULL) { // lista nie moze byc pusta
elem* tmp = l.head;
l.head=l.head->next; // usuwamy z poczatku listy
tval=tmp->val;
delete tmp; // zwalniamy pamiec!
}
return tval;
}
bool list_is_empty(list& l) {
if (l.head==NULL)
return true;
else
return false;
}
//ch4pr2test.cpp
//elementarna demonstracja list
#include "ch4pr2.h"
#include <iostream>
using namespace std;
int main() {
list lista;
lista.head = NULL;
lista.tail = NULL;
for(int i=0; i < 10; ++i)
list_insert_element(lista, i);
cout << "wprowadzono do listy 10 elementow" << endl;
cout << "wypisuje elementy:" << endl;
while (!list_is_empty(lista))
cout << list_remove_element(lista) << endl;
}
Proszę pamiętać, że w celu przetestowania tego kodu należy stworzyć odrębny projekt, do
którego zostaną dodane wszystkie trzy pliki, a następnie wywołać komendę “Build All”. Otrzyma się
program wykonywalny o nazwie zgodnej z nazwą projektu.
W wypadku programowania modularnego ważną rolę odgrywa przeciążanie funkcji. Służy ono
do tego by funkcja o tej samej nazwie mogła przyjmować parametry różnych typów. Chodzi o to, by
było możliwe np. wywołanie funkcji dodaj dla dwóch liczb całkowitych (realizuje ona wówczas
dodawanie całkowitoliczbowe) ale i dla dwóch liczb rzeczywistych (dodawanie zmiennoprzecinkowe)
a także dla dwóch łańcuchów (wykona ona wtedy konkatenację, czyli złączenie tych dwóch
łańcuchów). Przykład takiej funkcji mamy w kodzie poniżej:
//ch4pr3.cpp
#include <iostream>
using namespace std;
void fun(int x) {
cout << "wywolano funkcje ze zmienna calkowita" << endl;
}
void fun(double x) {
cout << "wywolano funkcje ze zmienna zmiennoprzecinkowa"
<< endl;
}
int main() {
int x = 1;
double y = 2.1;
}
fun(x);
fun(y);
Co da na wyjściu:
wywolano funkcje ze zmienna calkowita
wywolano funkcje ze zmienna zmiennoprzecinkowa
Mechanizmy rozpoznawania właściwej funkcji, która zostanie wywołana w konkretnym wypadku, gdy
przekazywany argument nie pasuje dokładnie do argumentów żadnej z funkcji można streścić
następująco:
1) wybierz wszystkie funkcje, dla których typ argumentu da się dopasować przy pomocy
standardowych konwersji
2) spośród funkcji wybranych w 1) wybierz te, które wymagają najmniejszej liczby konwersji
3) jeśli liczba funkcji wybranych w punkcie 2) jest różna od jednej poinformuj o błędzie
W wypadku, gdy do konkurencji stają funkcje pobierające więcej niż jeden argument – sytuacja się
komplikuje. Zainteresowanych odsyłam do literatury.
Rozdział 5
Klasy – łączenie struktur i procedur
Klasa, w najbardziej elementarnym przypadku stanowi połączenie struktury i procedur
operujących na niej. Zwykle w takim wypadku mówi się o abstrakcyjnych typach danych, a więc
takich typach, które stanowią abstrakcję, czyli modelują pewne obiekty ze świata zewnętrznego, łącząc
w sobie stany obiektu i operacje, które są dla tego obiektu dozwolone. Przykładowo klasa
reprezentująca pojazd będzie miała następujące pola (czyli zmienne reprezentujące stan): lokalizacja
oraz następujące metody (które reprezentują operacje): przemiesc, podaj_lokalizacje. Klasa taka może
być zadeklarowana jak następuje:
class pojazd {
private:
int lokalizacja;
public:
void przemiesc(int nowa_lokalizacja) {
lokalizacja=nowa_lokalizacja;
}
int podaj_lokalizacje() {
return lokalizacja;
}
};
Oczywiście klasy nie zawsze modelują jakieś obiekty, czy to ze świata zewnętrznego, jak np.
pojazd, czy to abstrakcyjne, jak np. węzeł drzewa binarnego, często są po prostu środkiem służącym do
modularyzacji kodu, podobnie jak miało to miejsce w wypadku funkcji i procedur.
W celu zaprezentowana wszystkich tajników klas, będziemy teraz budować przykładową klasę
modelującą proste ułamki. Dla uproszczenia zdecydujemy, że ułamki te nie muszą być ułamkami
właściwymi, różne rozszerzenia pozostawiając inwencji czytelnika. Poniżej przedstawiamy pierwsze
podejście:
// plik: r5ulamek1.cpp
#include <iostream>
using namespace std;
struct ulamek {
int licznik;
unsigned int mianownik;
void inicjalizuj (int l, int m) {
licznik = l;
mianownik = m;
}
void dodaj (const ulamek& rhs) {
licznik
=
licznik*rhs.mianownik;
licznik
+=
rhs.licznik*mianownik;
mianownik *=
rhs.mianownik;
}
void dodaj2 (const ulamek& rhs) {
this -> licznik
=
this->licznik *
rhs.mianownik;
this -> licznik
+=
rhs.licznik * this>mianownik;
this -> mianownik
*=
rhs.mianownik;
}
void pomnoz (const ulamek& rhs) {
licznik
*= rhs.licznik;
mianownik *= rhs.mianownik;
}
void wypisz () {
cout << "wartosc ulamka to: " << licznik << " / " <<
mianownik << endl;
}
};
int main() {
ulamek a; a.inicjalizuj(1,2);
ulamek b; b.inicjalizuj(3,4);
ulamek c; c.inicjalizuj(1,1);
c.dodaj(a);
c.wypisz();
a.pomnoz(b);
a.wypisz();
c.dodaj2(a);
c.wypisz();
}
// 1/2
// 3/4
// 1/1 czyli jeden ;)
Efekt działania programu jest zgodny z naszymi oczekiwaniami.
Klasa ulamek zadeklarowana jest przy pomocy stosownej deklaracji. Proszę zwrócić uwagę na
funkcję inicjalizuj. Funkcja ta służy do nadania właściwych wartości początkowych nowopowstającym
ułamkom. Proszę się zastanowić co się zdarzy gdy zapomnimy użyć tej funkcji. Oprócz tego
zadeklarowaliśmy i zdefiniowaliśmy cztery przydatne funkcje: dodaj, dodaj2, pomnoz i wypisz.
Funkcja dodaj dodaje do swojego obiektu klasy ulamek inny obiekt klasy ulamek (czyli realizuje to co
operator += dla liczb), zaś funkcja pomnoz mnoży swój obiekt przez inny obiekt klasy ulamek
(realizując to co operator *= dla liczb). Funkcja wypisz wypisuje nam ulamek na standardowym
wyjściu. Odrobiny komentarza wymaga funkcja dodaj2. Otóż jak Czytelnik na pewno zauważył do
metod (czyli funkcji składowych) klasy odwołujemy się podobnie jak do jej pól (czyli zmiennych
składowych). Wewnątrz metod natomiast do pól odwołujemy się bez żadnego specjalnego
kwalifikatora, gdyż przyjmuje się, że jeżeli nazwa podanej zmiennej zgadza się z nazwą pola w klasie
to chodzi właśnie o to pole. Istnieje jednak alternatywny sposób wskazywania pól klasy wewnątrz jej
metod, który jest warty omówienia, dlatego, że obrazuje mechanizm który działa “pod maską”. Sposób
ten jest bardzo żadko wykorzystywany w praktyce programistycznej, choć zdarzają się sytuacje, w
których bez niego nie można się obejść. Chodzi o tak zwany wskaźnik this. Wskaźnik ten jest
niejawnie przekazywany jako ukryty pierwszy argument do każdej metody i wskazuje na obiekt klasy
na którym działa metoda. Wskaźnik ten ulega automatycznej dereferencji. Z punktu widzenia kodu
maszynowego metoda klasy jest zwykłą funkcją, która pobiera dodatkowy argument w postaci
wskaźnika this.
Przedstawimy teraz ponownie klasę ułamek, tym razem rozszerzoną o pewne udogodnienia.
Mianowicie, żeby dodać do siebie dwa ułamki i otrzymać trzeci należy w naszym przykładzie utworzyć
dodatkowy ułamek, zaincjalizować go parą wartości (1,1), następnie wywołać jego metodę pomnoz,
żeby pomnożyć go przez pierwszy z dodawanych ułamków, a następnie wywołać jego metodę dodaj,
żeby dodać do niego drugi z dodawanych ułamków. Jest to naturalnie bardzo nieporęczne, więc w
poniższym przykładzie dorzuciliśmy globalną funkcję, która dodaje do siebie dwa ułamki dając jako
rezultat trzeci. Skorzystaliśmy też z pewnej bardzo przyjemnej właściwości składni języka C++, która
pozwala na definiowanie własnych operatorów, dzięki czemu będziemy mogli pisać c = a + b, zamiast
dodaj (c, dodaj (a, b)), czy czegoś równie niezrozumiałego. Właściwości tej nie posiada język Java, na
czym, w opinii różnych osób, wiele traci.
// plik: r5ulamek2.cpp
#include <iostream>
using namespace std;
struct ulamek {
int licznik;
unsigned int mianownik;
void inicjalizuj (int l, int m) {
licznik = l;
mianownik = m;
}
void dodaj (const ulamek& rhs) {
licznik
*=
rhs.mianownik;
licznik
+=
rhs.licznik*mianownik;
mianownik *=
rhs.mianownik;
}
void operator+= (const ulamek& rhs) {
licznik
*=
rhs.mianownik;
licznik
+=
rhs.licznik*mianownik;
mianownik *=
rhs.mianownik;
}
void pomnoz (const ulamek& rhs) {
licznik
*= rhs.licznik;
mianownik *= rhs.mianownik;
}
void operator*= (const ulamek& rhs) {
licznik
*= rhs.licznik;
mianownik *= rhs.mianownik;
}
void wypisz () {
cout << "wartosc ulamka to: " << licznik << " / " <<
mianownik << endl;
}
};
ulamek dodaj (const ulamek& lhs, const ulamek& rhs) {
ulamek tmp = lhs;
tmp.dodaj(rhs);
return tmp;
}
ulamek operator+ (const ulamek& lhs, const ulamek& rhs) {
ulamek tmp = lhs;
tmp += rhs;
// operacja na ulamkach!
return tmp;
}
int main() {
ulamek a; a.inicjalizuj(1,2);
// 1/2
}
ulamek b; b.inicjalizuj(3,4);
ulamek c = a + b;
c.wypisz();
c *= a;
c.wypisz();
// 3/4
Często zdarza się tak, że pragniemy ukryć przed użytkownikiem wewnętrzne mechanizmy,
zgodnie z którymi działa klasa. Powodów może być wiele, przykładowo, istnieje możliwość, że
będziemy chcieli zmienić sposób implementacji, np. w klasie reprezentującej stos zastępując
implementację tablicową przez implementację przy pomocy list łączonych. Gdyby użytkownik (tj.
programista korzystający z naszej klasy) uzależnił działanie swojego programu od zastosowanej przez
nas implementacji, wówczas jej zmiana pociągałaby konieczność dokonania zmian w programie
użytkownika. Proszę mi wierzyć, nie jest to problem wyłącznie teoretyczny. Sytuacja taka ma bardzo
poważne konsekwencje dla serwisowania programu, dlatego konieczne jest ukrywanie implementacji.
Ukryciu implementacji służą kwalifikatory dostępu. Jeżeli zmienna lub funkcja zadeklarowana jest z
kwalifikatorem public, wówczas może być wywoływana zarówno z zewnątrz obiektu danej klasy, przy
pomocy zwykłej składni dostępu do składowej, jak i z wewnątrz klasy (wewnątrz metody
zadeklarowanej w danej klasie). W wypadku zmiennej lub funkcji zadeklarowanej przy użyciu
kwalifikatora private, dostęp możliwy jest wyłącznie z wewnątrz klasy, tj. w obrębie ciała metody tej
klasy. Kontrola dostępu dotyczy klasy jako takiej, a nie poszczególnych obiektów, tj. możliwy jest
dostęp do zmiennej prywatnej innego obiektu tej samej klasy. Zaczniemy od prostego przykładu, który
zademonstruje nam zasady kontroli dostępu, a następnie przejdziemy do prezentacji klasy ulamek z
wprowadzoną kontrolą dostępu.
struct tescik { // dla
int a;
int fa() {
return a; //
}
public:
int b;
int fb() {
return b; //
}
private:
int c;
int fc() {
return c; //
}
public:
int fcc() {
return c; //
}
};
struct bez kwalifikatora == public
ok, to działa
ok, to działa
ok, to działa
ok, to działa
class tescik2 { // dla class bez kwalifikatora == private
int a;
int fa() {
return a; // ok, to działa
}
public:
int b;
int fb() {
return b; // ok, to działa
}
private:
int c;
int fc() {
return c; // ok, to działa
}
public:
int fcc() {
return c; // ok, to działa
}
};
int main() {
tescik t;
tescik2 t2;
t.a = 7; t.fa();
t2.a = 7; t2.fa();
t.b = 7; t.fb();
t2.b = 7; t2.fb();
t.c = 7; t.fc();
t2.c = 7; t2.fc();
t.fcc(); t2.fcc();
}
//
//
//
//
//
//
//
ok
obydwa
ok
ok
obydwa
obydwa
obydwa
niedozwolone
niedozwolone
niedozwolone
ok
Kwalifikator obowiązuje od miejsca w którym się pojawił do następnego kwalifikatora lub do
końca klasy, jeżeli przed końcem klasy nie następuje już żaden inny kwalifikator. W tym przykładzie
warto jeszcze zauważyć, że w C++ można deklarować klasy zarówno przy pomocy słowa kluczowego
struct jak i słowa kluczowego class. Jedyną różnicą jest to, że struct zakłada domyślnie zasięg
publiczny, zaś class – zasięg prywatny. Niemniej jednak w praktyce stosuje się dwie zasady:
– dla klas używa się słowa kluczowego class
– zawsze stosuje się kwalifikator, nie korzystając z ustaleń domyślnych
Od tego miejsca dla klas będziemy już używać słowa kluczowego class.
Kwestia odpowiedniego rozmieszczenia sekcji wewnątrz klasy oraz zmiennych i funkcji
wewnątrz sekcji jest kwestią gustu. Zwykle programista wraz ze wzrastającym doświadczeniem potrafi
przejrzyście uporządkować składowe klasy. Wskazówek dostarczają tu tak zwane programming
guidelines, czyli wytyczne dotyczące stylu programowania.
Jesteśmy teraz gotowi aby przejść do przykładu naszej klasy ulamek, w której zastosowano
kwalifikatory dostępu. Zgodnie z tradycją programowania obiektowego pola w klasie zostały
zadeklarowane z kwalifikatorem private. Niezwykle żadko zdarza się sytuacja, w której programista
decyduje się dać użytkownikowi bezpośredni dostęp do pól i zwykle jest to świadectwo złego stylu
programowania. W celu umożliwienia użytkownikowi poznawania wartości składowych zmiennej
ulamek oraz dokonywania ich zmian udostępniono specjalne funkcje zwane funkcją dostępu (ang.
accessor) oraz funkcją zmiany (ang. mutator). Funkcje te zostały zadeklarowane na dwa różne
sposoby. Oba te sposoby są często spotykane w praktyce programistycznej. Poza tym w poniższym
przykładzie prezentujemy też pewną specjalną konstrukcję językową pozwalającą na wyprowadzanie
zawartości zmiennych typu ulamek na standardowe wyjście przy pomocy cout. Jest to specjalny
operator << działający na obiektach typu ostream. Należy zwrócić uwagę, że funkcja ta jest
zadeklarowana w obrębie klasy z kwalifikatorem friend. Konstrukcja taka “zaprzyjaźnia” funkcję z
klasą, dzięki czemu funkcja może uzyskać dostęp do zmiennych prywatnych klasy. W naszym
przykładzie nie jest to konieczne ze względu na omawiane już funkcje dostępu, ale często bywa
potrzebne. Można również zaprzyjaźniać klasy ze sobą nawzajem.
// plik: r5ulamek3.cpp
#include <iostream>
#include <ostream>
using namespace std;
class ulamek {
friend ostream& operator << (ostream &, const ulamek&);
private:
int licznik;
unsigned int mianownik;
public:
void inicjalizuj (int l, int m) {
licznik = l;
mianownik = m;
}
void operator+= (const ulamek& rhs) {
licznik
*=
rhs.mianownik;
licznik
+=
rhs.licznik*mianownik;
mianownik *=
rhs.mianownik;
}
void operator*= (const ulamek& rhs) {
licznik
*= rhs.licznik;
mianownik *= rhs.mianownik;
}
};
ostream& operator << (ostream & os, const ulamek& ul) {
os << "wartosc ulamka to: " << ul.licznik << " / " <<
ul.mianownik;
return os;
}
ulamek operator+ (const ulamek& lhs, const ulamek& rhs) {
ulamek tmp = lhs;
tmp += rhs;
// operacja na ulamkach!
return tmp;
}
int main() {
ulamek a; a.inicjalizuj(1,2);
ulamek b; b.inicjalizuj(3,4);
ulamek c = a + b;
cout << c << endl;
c *= a;
cout << c << endl;
}
// 1/2
// 3/4
Rozdział 6
Klasy – automatyczna konstrukcja i destrukcja
W tym rozdziale zajmiemy się jedną z najważniejszych właściwości języka C++ w odniesieniu
do klas. Właściwością tą jest automatyczna konstrukcja i destrukcja obiektów klas przy pomocy
konstruktorów i destruktorów. Już w poprzednim rozdziale zasygnalizowaliśmy tę właściwość i
zademonstrowaliśmy jej użyteczność. W bierzącym rozdziale rozwiniemy ten temat.
Konstruktory i destruktory są specjalnymi metodami zdefiniowanymi w specyfikacji gramatyki
języka, które pozwalają użytkownikowi kontrolować proces tworzenia i niszczenia obiektów. Dzięki
automatyzmowi wywoływania konstruktora i destruktora, użytkownik nie musi się martwić o
wywołanie tych metod w odpowiednim momencie, zrobi to za niego fragment kodu wstawiony po
cichu przez kompilator.
#include <iostream>
using namespace std;
class example {
public:
example() {
cout << “Hej, to ja – konstruktor!” << endl;
}
~example() {
cout << “A to ja – destruktor!” << endl;
}
};
int main() {
cout << “przed stworzeniem obiektu ...” << endl;
example one;
cout << “... po stworzeniu obiektu” << endl;
}
Jak zatem widać, konstruktor został automatycznie wywołany w momencie stworzenia obiektu
(czyli zmiennej o typie zdefiniowanym przez klasę), natomiast destruktor – w momencie zniszczenia
obiektu, czyli w momencie dojścia do klamry zamykającej zasięg, w którym obiekt został utworzony.
Nazwa funkcji będącej konstruktorem musi być zgodna z nazwą klasy, nazwa destruktora – to
nazwa klasy poprzedzona znakiem tyldy (~), należy przy tym pamiętać, że C++ rozróżnia duże i małe
litery. Ani konstruktor, ani destruktor nie zwracają żadnych wartości, dlatego ich deklaracja nie zawiera
żadnego typu dla wartości zwracanej.
Prześledzimy teraz jeszcze jeden przykład, tym razem dotyczący obiektu tworzonego na stercie:
#include <iostream>
using namespace std;
class example {
public:
example() {
cout << "Hej, to ja - konstruktor!" << endl;
}
~example() {
cout << "A to ja - destruktor!" << endl;
};
}
int main() {
cout << "przed stworzeniem obiektu ..." << endl;
example* one = new example;
cout << "... po stworzeniu obiektu ..." << endl;
delete one;
cout << "... i po jego zniszczeniu" << endl;
}
Teraz wyraźnie widać, że destruktor wywoływany jest w momencie zniszczenia obiektu.
Następuje ono dla obiektów znajdujących się na stercie w momencie wywołania operatora delete.
Destruktor nigdy nie pobiera żadnych argumentów. Konstruktor natomiast, który służy do
inicjowania pól obiektu pewnymi wartościami początkowymi, zwykle pobiera argumenty. Przykład
klasy zawierającej konstruktor pobierający argumenty mamy poniżej:
#include <iostream>
#include <string>
using namespace std;
class osoba {
private:
string imie_;
string nazwisko_;
unsigned int wiek_;
public:
osoba(string imie, string nazwisko, unsigned int wiek) {
imie_ = imie;
nazwisko_ = nazwisko;
wiek_ = wiek;
cout << "Stworzono osobe!" << endl;
}
~osoba() {
cout << "Zniszczono osobe!" << endl;
}
string imie() {
return imie_;
}
string nazwisko() {
return nazwisko_;
}
unsigned int wiek() {
return wiek_;
}
void wiek(unsigned int w) {
wiek_ = w;
}
};
int main() {
cout << "przed stworzeniem obiektu ..." << endl;
osoba* os = new osoba("Jan", "Kowalski", 27);
cout << "... po stworzeniu obiektu ..." << endl;
cout << os->imie() << " " << os->nazwisko() << " ma lat:
" << os->wiek() << endl;
delete os;
cout << "... i po jego zniszczeniu" << endl;
}
W wypadku, gdy użytkownik nie zadeklaruje konstruktora tworzony jest domyślnie konstruktor
bezparametrowy, który nic nie robi. Analogicznie w wypadku destruktora. Na obecnym etapie naszego
zaawansowania nie ma to większego znaczenia, ale jest to niuans o którym warto pamiętać na
przyszłość.
Zastanowimy się teraz nad sensem istnienia destruktorów i konstruktorów. Intuicyjnie istnienie
konstruktora wydaje się być oczywiste. Wyznacza on w kodzie używającym klasy pojedyncze miejsce,
w którym dokonuje się inicjalizacja wszystkich istotnych zmiennych klasy. W szczególności organizuje
on inicjalizację zmiennych prywatnych, które nie mogą potem być zmienione (patrz poprzedni przykład
– imię i nazwisko nie mają funkcji pozwalających na ich zmianę). W celu ułatwienia korzystania z tej
funkcji konstruktora dodano specjalną konstrukcję składniową – zwaną składnią inicjalizatora, poniżej
zamieszczamy konstruktor klasy osoba przepisany przy użyciu tej składni:
osoba(string imie, string nazwisko, unsigned int wiek) :
imie_(imie), nazwisko_(nazwisko), wiek_(wiek) {
cout << "Stworzono osobe!" << endl;
}
Proszę zwrócić uwagę na to, że przypisania zostały usunięte z kodu konstruktora, za to pojawiła
się pogrubiona sekcja między nawiasem okrągłym zamykającym a nawiasem klamrowym otwierającym
w nagłówku konstruktora. Ta zmiana jednak to w zasadzie tylko kosmetyka, ale w niektórych
przypadkach znacznie ułatwia pisanie kodu.
Deklarowanie destruktora uzasadnione jest wyłącznie w jednym wypadku – kiedy klasa zawiera
pola przydzielane dynamicznie. Przykład takiej sytuacji mamy w poniższym przykładzie:
#include <iostream>
#include <string>
using namespace std;
class obiektDynamiczny {
private:
int pole_;
public:
obiektDynamiczny(int pole) : pole_(pole) {
cout << "obiektDynamiczny - konstruktor" << endl;
}
~obiektDynamiczny() {
cout << "obiektDynamiczny - destruktor" << endl;
}
};
class bezDestruktora {
private:
obiektDynamiczny* x_;
public:
bezDestruktora(int wartosc) : x_(new obiektDynamiczny
(wartosc)) {
cout << "bezDestruktora - konstruktor" << endl;
}
};
class zDestruktorem {
private:
obiektDynamiczny* x_;
public:
zDestruktorem(int wartosc) : x_(new obiektDynamiczny
(wartosc)) {
cout << "zDestruktorem - konstruktor" << endl;
}
~zDestruktorem() {
delete x_;
cout << "zDestruktorem - destruktor" << endl;
}
};
int main() {
bezDestruktora one(1);
zDestruktorem two(2);
}
Łatwo zauważyć, że w wypadku klasy bezDestruktora obiektDynamiczny x_ nie został
zniszczony, a zatem pamięć mu przydzielona wyciekła. W wypadku klasy zDestruktorem zniszczenie
obiektu x_ przebiegło poprawnie, a zatem nie doszło do wycieku pamięci. Oczywiście sytuacja ta
dotyczy wszelkich zasobów, nie tylko pamięci operacyjnej.
Przejdziemy teraz do drugiej, bardzo istotnej postaci konstruktora – tak zwanego konstruktora
kopiującego. Przeanalizujmy na początek poniższy fragment kodu:
//...
class przyklad {
//...
};
przyklad
przyklad
a = b;
przyklad
przyklad
a;
b;
c(b);
d = b;
// (1) przypisanie
// (2) kopiowanie
// (3) też kopiowanie (taka mała zmyłka)
//...
Mamy tutaj zadeklarowaną klasę przyklad. Następnie tworzymy dwa obiekty tej klasy, a oraz b.
Następnie dokonujemy przypisania obiektu b na obiekt a. Oznacza, to, że zawartość istniejącego już
obiektu a zostanie zastąpiona zawartością obiektu b, w ten sposób, że po tej operacji będą one nie do
rozróżnienia (takie przynajmniej jest założenie). Kopiowanie natomiast oznacza utworzenie nowego
obiektu c o zawartości zgodnej z zawartością obiektu b. Różnica polega na tym, że w wypadku
przypisania nastąpić musi zniszczenie poprzedniej zawartości obiektu a, czyli w sytuacji gdy zawierał
on pola przydzielone dynamicznie, muszą one ulec poprawnemu usunięciu. Ostatnia linijka zawiera
również kopiowanie (a nie przypisanie) – jest to postać alternatywna dla notacji z nawiasem. To co
różni ją od przypisania to fakt, że obiekt d jest tworzony w tym samym momencie, w którym
wykonywane jest przepisanie do niego zawartości obiektu b.
W wypadku klas kopiowanie wykonywane jest domyślnie bit po bicie. Jest to dobre rozwiązanie
dla klas nie zawierających pól przydzielanych dynamicznie (czy też innych zasobów, których dotyczy
podobna semantyka). W wypadku gdy takie pola występują, sytuacja się komplikuje, co widać na
poniższym przykładzie:
#include <iostream>
#include <string>
using namespace std;
class obiektDynamiczny {
private:
int pole_;
public:
obiektDynamiczny(int pole) : pole_(pole) {
cout << "obiektDynamiczny - konstruktor" << endl;
}
~obiektDynamiczny() {
cout << "obiektDynamiczny - destruktor" << endl;
}
int pole() {
return pole_;
}
void pole(int w) {
pole_ = w;
}
};
class klopotyZKopiowaniem {
private:
int w_;
obiektDynamiczny* x_;
public:
klopotyZKopiowaniem(int w, int x) : w_(w), x_(new
obiektDynamiczny(x)) {
}
~klopotyZKopiowaniem() {
delete x_;
}
int w() {
return w_;
}
void w(int t) {
w_=t;
}
int x() {
return x_->pole();
}
void x(int t) {
x_->pole(t);
};
}
int main() {
klopotyZKopiowaniem a(1, 2);
klopotyZKopiowaniem b(a);
cout << "a: " << a.w() << " /
cout << "b: " << b.w() << " /
b.w(3);
b.x(4);
cout << "a: " << a.w() << " /
cout << "b: " << b.w() << " /
}
" << a.x() << endl;
" << b.x() << endl;
" << a.x() << endl;
" << b.x() << endl;
Inaczej niż mogłoby się pozornie wydawać po przypisaniu wartości 4 do b.x_->pole_, zmieniła
się również wartość a.x_->pole_. Czary??? Nie, oczywiście nie czary, a prosta konsekwencja tego, że
wskutek kopiowania bit po bicie wartości pól a.x_ i b.x_ są takie same, czyli oba te pola wskazują ten
sam obiektDynamiczny, a zatem tę samą zmienną pole_ (konstruowana jest tylko jedna zmienna typu
obiektDynamiczny). Dodatkowo, co jeszcze komplikuje sytuację, ten sam obiektDynamiczny jest
niszczony dwukrotnie, co może doprowadzić do nieoczekiwanego zakończenia programu.
Naturalnie, poprawnym rozwiązaniem w takiej sytuacji jest utworzenie w klasie b odrębnej
zmiennej typu obiektDynamiczny i przekopiowanie zawartości odpowiadającej zmiennej z klasy a.
To kopiowanie może już odbywać się bit po bicie. Specjalną konstrukcją językową, która pozwala na
takie operacje jest konstruktor kopiowania, czy też konstruktor kopiujący. Przykład takiego
konstruktora znajduje się w poniższym programie:
#include <iostream>
using namespace std;
class obiektDynamiczny {
private:
int pole_;
public:
obiektDynamiczny(int pole) : pole_(pole) {
cout << "obiektDynamiczny - konstruktor" << endl;
}
~obiektDynamiczny() {
cout << "obiektDynamiczny - destruktor" << endl;
}
int pole() {
return pole_;
}
void pole(int w) {
pole_ = w;
}
};
class kopiowanieOK {
private:
int w_;
obiektDynamiczny*
x_;
public:
kopiowanieOK(int w, int x) : w_(w), x_(new
obiektDynamiczny(x)) {
}
~kopiowanieOK() {
delete x_;
}
// konstruktor kopiujacy
kopiowanieOK(const kopiowanieOK& rh)
: w_(rh.w_),
x_(new obiektDynamiczny(rh.x_->pole())) {
}
};
int w() {
return w_;
}
void w(int t) {
w_=t;
}
int x() {
return x_->pole();
}
void x(int t) {
x_->pole(t);
}
int main() {
kopiowanieOK a(1, 2);
kopiowanieOK b(a);
cout << "a: " << a.w()
cout << "b: " << b.w()
b.w(3);
b.x(4);
cout << "a: " << a.w()
cout << "b: " << b.w()
}
<< " / " << a.x() << endl;
<< " / " << b.x() << endl;
<< " / " << a.x() << endl;
<< " / " << b.x() << endl;
Proszę zwrócić uwagę, że teraz otrzymaliśmy poprawny wynik.
Podobny problem ma miejsce w wypadku przypisania. Proszę przeanalizować poniższy
przykład:
#include <iostream>
using namespace std;
class obiektDynamiczny {
private:
int pole_;
public:
obiektDynamiczny(int pole) : pole_(pole) {
cout << "obiektDynamiczny - konstruktor" << endl;
};
}
~obiektDynamiczny() {
cout << "obiektDynamiczny - destruktor" << endl;
}
int pole() {
return pole_;
}
void pole(int w) {
pole_ = w;
}
class klopotyZPrzypisaniem {
private:
int w_;
obiektDynamiczny* x_;
public:
klopotyZPrzypisaniem(int w, int x) : w_(w), x_(new
obiektDynamiczny(x)) {
}
~klopotyZPrzypisaniem() {
delete x_;
}
// konstruktor kopiujacy
klopotyZPrzypisaniem(const klopotyZPrzypisaniem& rh)
: w_(rh.w_),
x_(new obiektDynamiczny(rh.x_->pole())) {
}
};
int w() {
return w_;
}
void w(int t) {
w_=t;
}
int x() {
return x_->pole();
}
void x(int t) {
x_->pole(t);
}
int main() {
klopotyZPrzypisaniem a(1,
klopotyZPrzypisaniem b(5,
b = a;
cout << "a: " << a.w() <<
cout << "b: " << b.w() <<
b.w(3);
b.x(4);
cout << "a: " << a.w() <<
cout << "b: " << b.w() <<
2);
6);
" / " << a.x() << endl;
" / " << b.x() << endl;
" / " << a.x() << endl;
" / " << b.x() << endl;
}
Rozdział 7
Klasy – dziedziczenie i funkcje wirtualne
W tym rozdziale pragniemy mówić o tym, co tak naprawdę stanowi esencję programowania
obiektowego. Bowiem bez dziedziczenia i funkcji wirtualnych nie może być mowy o programowaniu
obiektowym, a co najwyżej o programowaniu z użyciem abstrakcyjnych typów danych, czy też po
prostu o programowaniu z użyciem struktur. Tak zwane funkcje wirtualne są tak istotne dla
programowania obiektowego, że język Java, który jest w swojej architekturze od początku
zaprojektowany jako język obiektowy domyślnie zakłada, że funkcje są wirtualne. Jest to najbardziej
naturalne zachowanie dla funkcji w sytuacji, w której mamy do czynienia z dziedziczeniem. Jedynym
powodem dla którego w języku C++ funkcje muszą być jawnie deklarowane jako wirtualne, jest to, że
wirtualizacja funkcji powoduje spadek szybkości działania programu. Najlepiej zrobimy jednak od razu
przechodząc do konkretów.
Załóżmy teraz, że piszemy teraz małą bibliotekę służącą do symulacji graficznych. Biblioteka ta
ma udostępniać dwuwymiarowe figury, takie jak np. trójkąty, okręgi, kwadraty, itp. Figury te mogą być
przesuwane, obracane, rysowane, ukrywane, itd. Dodatkowym założeniem jest to, że chcemy wszystkie
figury przechowywać w pojedynczej strukturze danych, na przykład na liście figur, co pozwoli nam na
wykonywanie operacji na wszystkich figurach znajdujących się na liście. Pierwsze nasze podejście
mogłoby wyglądać następująco:
#include <iostream>
using namespace std;
// uwaga! figury te sa zaprojektowane wylacznie na potrzeby
// skrytpu; nie nalezy ich w zadnym wypadku stosowac
// w prawdziwej bibliotece ;)
enum figury {TROJKAT, KWADRAT};
class trojkat {
private:
// srodek geometryczny trojkata
int center_x, center_y;
// wspolrzedne wierzcholkow WZGLEDEM SRODKA GEOMETRYCZNEGO
int x1, y1, x2, y2, x3, y3;
public:
int x() {
return center_x;
}
int y() {
return center_y;
}
void przesun(int new_x, int new_y) {
center_x = new_x;
center_y = new_y;
cout << "trojkat przesun" << endl;
}
void obroc(int kat) {
//tutaj trzeba zaimplementowac obracanie dla trojkata
cout << "trojkat obroc" << endl;
}
};
class kwadrat {
private:
// srodek geometryczny kwadratu
int center_x, center_y;
// wspolrzedne lewego dolnego i prawego gornego rogu
int ldx, ldy, pgx, pgy;
public:
int x() {
return center_x;
}
int y() {
return center_y;
}
void przesun(int new_x, int new_y) {
center_x = new_x;
center_y = new_y;
cout << "kwadrat przesun" << endl;
}
void obroc(int kat) {
// zaimplementowac obracanie :-)
cout << "kwadrat obroc" << endl;
}
};
struct figura {
void* f;
figury typ_figury;
};
void figura_obroc(void* fig, int kat, figury typ) {
trojkat* tmpt;
kwadrat* tmpk;
switch(typ) {
case TROJKAT:
tmpt = (trojkat*) (fig);
tmpt->obroc(kat);
break;
case KWADRAT:
tmpk = (kwadrat*) (fig);
tmpk->obroc(kat);
break;
default:
break;
}
//niedobrze - powinien byc wyjatek!
}
int main() {
figura tablica_figur[10];
figura tmp;
for (int i = 0; i < 10; ++i)
if (i%2) {
tmp.f = (void *)
tmp.typ_figury =
} else {
tmp.f = (void *)
{
new trojkat;
TROJKAT;
new kwadrat;
tmp.typ_figury = KWADRAT;
}
tablica_figur[i] = tmp;
}
for (int i = 0; i < 10; ++i)
figura_obroc(tablica_figur[i].f, 10, tablica_figur
[i].typ_figury);
}
Taki kod ma kilka bardzo istotnych wad. Najważniejszą z nich jest to, że w wypadku dodania
nowej figury do biblioteki, kod wszystkich programów które korzystają z biblioteki musi być
zmodyfikowany o fragment powodujący wykonanie operacji na nowej figurze. Proszę zwrócić uwagę,
że jedynym sposobem umieszczenia różnych figur na liście jest użycie wskaźnika do typu void oraz
osobnego pola, w którym zakodujemy typ figury. Oto powyższy kod z dodaną dodatkową figurą:
//ch7pr2.cpp
#include <iostream>
using namespace std;
// uwaga! figury te sa zaprojektowane wylacznie na potrzeby
// skrytpu; nie nalezy ich w zadnym wypadku stosowac
// w prawdziwej bibliotece ;)
// tutaj pierwsza modyfikacja (kod biblioteki)
enum figury {TROJKAT, KWADRAT, OKRAG};
class trojkat {
private:
// srodek geometryczny trojkata
int center_x, center_y;
// wspolrzedne wierzcholkow WZGLEDEM SRODKA GEOMETRYCZNEGO
int x1, y1, x2, y2, x3, y3;
public:
int x() {
return center_x;
}
int y() {
return center_y;
}
void przesun(int new_x, int new_y) {
center_x = new_x;
center_y = new_y;
cout << "trojkat przesun" << endl;
}
void obroc(int kat) {
//tutaj trzeba zaimplementowac obracanie dla trojkata
cout << "trojkat obroc" << endl;
}
};
class kwadrat {
private:
// srodek geometryczny kwadratu
int center_x, center_y;
// wspolrzedne lewego dolnego i prawego gornego rogu
int ldx, ldy, pgx, pgy;
public:
int x() {
return center_x;
}
int y() {
return center_y;
}
void przesun(int new_x, int new_y) {
center_x = new_x;
center_y = new_y;
cout << "kwadrat przesun" << endl;
}
void obroc(int kat) {
// zaimplementowac obracanie :-)
cout << "kwadrat obroc" << endl;
}
};
class okrag {
private:
// srodek geometryczny trojkata
int center_x, center_y;
// wspolrzedne wierzcholkow WZGLEDEM SRODKA GEOMETRYCZNEGO
int x1, y1, x2, y2, x3, y3;
public:
int x() {
return center_x;
}
int y() {
return center_y;
}
void przesun(int new_x, int new_y) {
center_x = new_x;
center_y = new_y;
cout << "okrag przesun" << endl;
}
void obroc(int kat) {
//nie ma co implementowac - okregow sie nie obraca!
cout << "okrag obroc" << endl;
}
};
struct figura {
void* f;
figury typ_figury;
};
// odtad kod klienta!
void figura_obroc(void* fig, int kat, figury typ) {
trojkat* tmpt;
kwadrat* tmpk;
//UWAGA! zmiana
}
okrag* tmpo;
switch(typ) {
case TROJKAT:
tmpt = (trojkat*) (fig);
tmpt->obroc(kat);
break;
case KWADRAT:
tmpk = (kwadrat*) (fig);
tmpk->obroc(kat);
break;
//UWAGA! zmiana
case OKRAG:
tmpo = (okrag*) (fig);
tmpo->obroc(kat);
default:
break;
}
//niedobrze - powinien byc wyjatek!
int main() {
figura tablica_figur[10];
figura tmp;
for (int i = 0; i < 10; ++i) {
if (i%2) {
tmp.f = (void *) new trojkat;
tmp.typ_figury = TROJKAT;
} else {
tmp.f = (void *) new okrag;
tmp.typ_figury = OKRAG;
}
tablica_figur[i] = tmp;
}
for (int i = 0; i < 10; ++i)
figura_obroc(tablica_figur[i].f, 10, tablica_figur
[i].typ_figury);
}
Natrualnie takie podejście jest niemożliwe ze względów praktycznych (nikt nie kupi biblioteki,
która przy każdej zmianie kodu przez jej producenta będzie wymagać od klienta szczegółowego
przeszukiwania kodu w poszukiwaniu funkcji, które mogą być przez taką zmianę dotknięte). Zwiększa
też ono prawdopodobieństwo popełnienia trudnego do znalezienia błędu. W celu poradzenia sobie z
takimi problemami wprowadzone zostało tak zwane dziedziczenie. Działa ono w ten sposób, że ze
wszystkich klas, które są konkretyzacjami pewnego pojęcia abstrakcyjnego (takiego jak np. figura)
wydzielamy największą wspólną część. Część ta staje się tak zwaną klasą bazową i dostarcza interfejsu
dla wszystkich klas od niej się wywodzących. W naszym programie taką klasą będzie klasa Figura.
Najwieksza wspólna część to dla nas trzy rzeczy: punkt zaczepienia figury (środek geometryczny) – a
raczej jego współrzędne, operacja przesun oraz operacja obroc. Przedstawiamy poniżej przykładową
postać takiej klasy figura, oraz to w jaki sposób klasy reprezentujące poszczególne figury realizują
dziedziczenie z tej klasy. Jak się za chwilę przekonamy, nie rozwiązuje to niestety wszystkich
problemów.
//ch7pr3.cpp
#include <iostream>
using namespace std;
// uwaga! figury te sa zaprojektowane wylacznie na potrzeby
// skrytpu; nie nalezy ich w zadnym wypadku stosowac
// w prawdziwej bibliotece ;)
//to nie jest dobry styl programowania,
//raczej przyklad czego nie nalezy robic
class trojkat;
class kwadrat;
void obroc_trojkat(trojkat*, int);
void obroc_kwadrat(kwadrat*, int);
//zaczynamy od klasy bazowej
class figura {
protected:
int center_x, center_y;
enum typyfigur {TROJKAT, KWADRAT} typf;
void settyp(typyfigur t) {typf = t;}
public:
int x() {
return center_x;
}
int y() {
return center_y;
}
void przesun(int new_x, int new_y) {
// to mozemy zrealizowac w klasie bazowej!
center_x = new_x;
center_y = new_y;
cout << "figura przesun" << endl;
}
void obroc(int kat) {
//ale tego juz nie!!!
switch(typf) {
case TROJKAT:
obroc_trojkat((trojkat*) this, kat);
break;
case KWADRAT:
obroc_kwadrat((kwadrat*) this, kat);
break;
default:
break;
}
}
};
class trojkat : public figura {
private:
// wspolrzedne wierzcholkow WZGLEDEM SRODKA GEOMETRYCZNEGO
int x1, y1, x2, y2, x3, y3;
public:
trojkat() {settyp(TROJKAT);}
int x() {
return center_x;
}
int y() {
return center_y;
}
void obroc(int kat) {
//tutaj trzeba zaimplementowac obracanie dla trojkata
cout << "trojkat obroc" << endl;
}
};
class kwadrat : public figura {
private:
// wspolrzedne lewego dolnego i prawego gornego rogu
int ldx, ldy, pgx, pgy;
public:
kwadrat() {settyp(KWADRAT);}
int x() {
return center_x;
}
int y() {
return center_y;
}
void obroc(int kat) {
// zaimplementowac obracanie :-)
cout << "kwadrat obroc" << endl;
}
};
void obroc_trojkat(trojkat* t, int kat) {
t->obroc(kat);
}
void obroc_kwadrat(kwadrat* k, int kat) {
k->obroc(kat);
}
int main() {
figura* tablica_figur[10];
figura* tmp;
for (int i = 0; i < 10; ++i) {
if (i%2) {
tmp = (figura *) new trojkat;
} else {
tmp = (figura *) new kwadrat;
}
tablica_figur[i] = tmp;
}
for (int i = 0; i < 10; ++i)
tablica_figur[i] -> obroc(90);
}
Przeanalizujmy teraz w jaki sposób wprowadzenie wspólnej klasy bazowej poprawiło naszą
sytuację. Po pierwsze można teraz przechowywać na liście wskaźniki do różnych figur jako wskaźniki
do klasy figura. Ma to o tyle ważne znaczenie, że kod klienta wywołuje bezpośrednio funkcje przesun i
obroc, dzięki czemu uzyskaliśmy pewną niezależność kodu klienta od kodu naszej biblioteki.
Niezależność ta polega na tym, że jeśli nie zmieni się interfejs oferowany przez klasę figura to zmiany
polegające na dodaniu nowych figur lub nowej funkcjonalności do jakiejś konkretnej figury nie
powoduje konieczności zmian w kodzie klienta. Pozostają jednak pewne niedogodności: w klasie
figura musi być umieszczony kod pozwalający na poprawną identyfikację rzeczywistej figury na którą
wskazuje wskaźnik do figury, a w wypadku dodania nowej figury należy zmodyfikować wszystkie
funkcje klasy figura, tak by pozwolić na wywoływanie odpowiednich funkcji nowej figury. Dodatkowo,
każda taka zmiana wymaga od klienta przekompilowania całego kodu korzystającego z klasy figura i
klas pochodnych. Wymaganie to nie wygląda na specjalnie restrykcyjne, ale w wypadku dużych
systemów (np. tam gdzie czas rekompilacji systemu przekracza dobę) może być nieakceptowalne.
Takie podejście, opierające się o mechanizm rozpoznawania typów (zwany RTTI, ang. Run Time Type
Identification), jest wspierane przez niektóre języki programowania, jak np. Smalltalk. C++ (i podobnie
Java) wybrały inne wyjście, dzięki czemu pisanie programów jest prostsze. Wyjściem tym są tak zwane
funkcje wirtualne. Przedstawimy teraz powyższy program w wersji używającej funkcji wirtualnych.
Proszę zwrócić uwagę, o ile prostsza jest implementacja klasy figura. Co więcej dodanie nowej figury
nie wymaga żadnej modyfikacji kodu, ani po stronie klienta, ani po stronie biblioteki (klasy figura).
//ch7pr4.cpp
#include <iostream>
using namespace std;
// uwaga! figury te sa zaprojektowane wylacznie na potrzeby
// skrytpu; nie nalezy ich w zadnym wypadku stosowac
// w prawdziwej bibliotece ;)
//zaczynamy od klasy bazowej
class figura {
protected:
int center_x, center_y;
public:
int x() {
return center_x;
}
int y() {
return center_y;
}
virtual void przesun(int new_x, int new_y) {
// to mozemy zrealizowac w klasie bazowej!
center_x = new_x;
center_y = new_y;
cout << "figura przesun" << endl;
}
virtual void obroc(int kat) {}
};
class trojkat : public figura {
private:
// wspolrzedne wierzcholkow WZGLEDEM SRODKA GEOMETRYCZNEGO
int x1, y1, x2, y2, x3, y3;
public:
int x() {
return center_x;
}
int y() {
return center_y;
}
void obroc(int kat) {
//tutaj trzeba zaimplementowac obracanie dla trojkata
cout << "trojkat obroc" << endl;
}
};
class kwadrat : public figura {
private:
// wspolrzedne lewego dolnego i prawego gornego rogu
int ldx, ldy, pgx, pgy;
public:
int x() {
return center_x;
}
int y() {
return center_y;
}
void obroc(int kat) {
// zaimplementowac obracanie :-)
cout << "kwadrat obroc" << endl;
}
};
int main() {
figura* tablica_figur[10];
figura* tmp;
for (int i = 0; i < 10; ++i) {
if (i%2) {
tmp = (figura *) new trojkat;
} else {
tmp = (figura *) new kwadrat;
}
tablica_figur[i] = tmp;
}
for (int i = 0; i < 10; ++i)
tablica_figur[i] -> obroc(90);
}
Użycie funkcji wirtualnych ma również tę zaletę, że nie ma konieczności rekompilacji kodu
klienckiego w wypadku wprowadzenia zmian w którejś z konkretnych figur lub dodaniu nowej figury,
o ile tylko kod ten nie używa bezpośrednio zmienionej lub dodanej klasy. Zachęcam Czytelnika do
szerszego zapozniania się z tą tematyką.
Rozdział 8
Wzorce i wyjątki
W rozdziale tym dokonamy krótkiego omówienia dwóch istotnych konstrukcji języka C++.
Pierwszą z nich są wzorce. Wejście wzorców do C++ zrewolucjonizowało język i otworzyło nowe
horyzonty. Praktycznie całe nowoczesne programowanie w C++ jest zorientowane pod kątem używania
wzorców. Jest to zasadnicza cecha, która odróżnia nowoczesne C++ od języków pokrewnych, takich
jak Object Pascal, Java, C# i podobne, które bazowały na ideach pochodzących z pierwotnego,
obiektowo zorientowanego C++. Wzorce są bardzo, bardzo obszernym tematem, który tutaj możemy
potraktować wyłącznie bardzo skrótowo. Ze względu na jego znaczenie dla współczesnego C++
zalecamy Czytelnikowi zapoznanie się z rozdziałem dotyczących wzorców książki Bruce Eckel'a
Thinking In C++ Część 2.
W niniejszym paragrafie przedstawimy wyłącznie prosty przykład prezentujący zastosowanie
wzorców w C++ oraz podstawowe udogodnienia biblioteczne wykorzystujące wzorce. Nie będziemy
analizować zaawansowanych zagadnień związanych z tym tematem. Każdy, kto jest bardziej niż tylko
pobieżnie zainteresowany tematem winien przeczytać wcześniej wspomniany fragment z książki Bruce
Eckel'a.
Rozpoczniemy od prezentacji prostego przykładu, który pokazuje dlaczego wzorce są
potrzebne. Załóżmy, że napisaliśmy klasę, która stanowi pojemnik do przechowywania obiektów.
Najprostszym przykładem będzie tutaj jednokierunkowa lista łączona. Na początek przykładowy kod
listy łączonej przechowującej liczby całkowite:
//ch8pr1.cpp
//elementarna demonstracja list
#include <iostream>
using namespace std;
struct elem {
int val;
elem* next;
};
//prosta klasa listowa, ktora realizuje
//liste laczona jednokierunkowa, umozliwiajaca
//wstawianie elementow na poczatku i usuwanie z konca
class list {
private:
elem* head;
elem* tail;
public:
list() : head(NULL), tail(NULL) {}
void insert_element(int el) {
elem* cur = new elem;
cur->val = el;
cur->next=NULL;
if(tail==NULL) { // brak elementow na liscie
head=cur;
tail=cur;
} else { // dodajemy na koncu
tail->next=cur;
tail=cur;
};
}
}
int remove_element() {
int tval=0;
if(head != NULL) { // lista nie moze byc pusta
elem* tmp = head;
head=head->next; // usuwamy z poczatku listy
tval=tmp->val;
delete tmp; // zwalniamy pamiec!
}
return tval;
}
bool is_empty() {
if (head==NULL)
return true;
else
return false;
}
int main() {
list lista;
for(int i=0; i < 10; ++i)
lista.insert_element(i);
cout << "wprowadzono do listy 10 elementow" << endl;
cout << "wypisuje elementy:" << endl;
while (!lista.is_empty())
cout << lista.remove_element() << endl;
}
Załóżmy teraz, że przetestowaliśmy już naszą listę i chcemy teraz przechowywać w niej liczby
zmiennoprzecinkowe (przykładowo typu float). Bez wzorców mamy dwie możliwości, które
zilustrujemy przykładami kodu:
1) przechowywanie nie konkretnych liczb, tylko wskaźników do nich przetworzonych na typ
“wskaźnik do typu pustego” - void * - takie podejście jest typowe dla języka C (i Smalltalk); ma ono
kilka istotnych wad, między innymi to, że źle współpracuje z systemem kontroli typów – jeśli np.
zapisaliśmy na liście liczby całkowite, a potem próbujemy je odczytać jako liczby
zmiennoprzecinkowe, to nie zostaniemy ostrzeżeni o tym, że coś jest nie w porządku. Cała
odpowiedzialność za pamiętanie o właściwym typie danych spoczywa w tym wypadku na
programiście.
//ch8pr2.cpp
//elementarna demonstracja list
#include <iostream>
using namespace std;
struct elem {
void* val;
elem* next;
};
//prosta klasa listowa, ktora realizuje
//liste laczona jednokierunkowa, umozliwiajaca
//wstawianie elementow na poczatku i usuwanie z konca
class list {
private:
elem* head;
elem* tail;
public:
list() : head(NULL), tail(NULL) {}
void insert_element(void* el) {
elem* cur = new elem;
cur->val = el;
cur->next=NULL;
if(tail==NULL) { // brak elementow na liscie
head=cur;
tail=cur;
} else { // dodajemy na koncu
tail->next=cur;
tail=cur;
}
}
void* remove_element() {
void* tval=0;
if(head != NULL) { // lista nie moze byc pusta
elem* tmp = head;
head=head->next; // usuwamy z poczatku listy
tval=tmp->val;
delete tmp; // zwalniamy pamiec!
}
return tval;
}
bool is_empty() {
if (head==NULL)
return true;
else
return false;
}
};
int main() {
list lista;
for(int i=0; i < 10; ++i)
lista.insert_element((void*) (new int(i+1)));
cout << "wprowadzono do listy 10 elementow" << endl;
cout << "wypisuje elementy:" << endl;
while (!lista.is_empty()) {
int* tmp= (int*) lista.remove_element();
cout << *tmp << endl;
delete tmp;
}
}
Proszę zwrócić uwagę, że pojawiają się tu problemy o naturze podobnej do tych z poprzedniego
rozdziału. W tamtym wypadku pozytywny efekt przyniosło programowanie obiektowe. W tym
wypadku programowanie obiektowe przynosi tylko częściowe rozwiązanie. Nawet taki “czysto
obiektowy” język jak Java na pewnym etapie wprowadził wzorce, gdyż rozwiązanie w postaci
obiektowej nie było satysfakcjonujące.
2) zduplikowanie kodu klasy – w takiej sytuacji musimy “ręcznie” napisać niemal wierną kopię
napisanej już wcześniej klasy. Takie postępowanie jest pracochłonne i może wprowadzić błędy (np.
zmienimy nie tylko typ przechowywanych zmiennych z int na float, ale zmienimy też typ licznika
przechowującego liczbę obiektów w liście – o ile takowy będziemy mieli). Sprytni programiści,
zamiast duplikować kod używali zwykle wbudowanego w język C makroprocesora (polecenia
#define). Mają one jednak tę wadę, że potrafią powodować zupełnie nieoczekiwane zachowania
kodu, a także są bardzo skomplikowane. Podejście używające #define wykracza zdecydowanie poza
ramy niniejszego opracowania swoim poziomem technicznym. Poza tym nie należy go stosować,
dlatego nie zostanie tutaj przedstawione. Zainteresowani mogą poszukać w dobrym podręczniku
języka C lub którymś ze starszych podręczników C++ (z czasów sprzed wprowadzenia wzorców do
języka). Co do duplikowania kodu - proszę obejrzeć poniższy program:
//ch8pr3.cpp
//elementarna demonstracja list
#include <iostream>
using namespace std;
struct elem_int {
int val;
elem_int* next;
};
//prosta klasa listowa, ktora realizuje
//liste laczona jednokierunkowa, umozliwiajaca
//wstawianie elementow na poczatku i usuwanie z konca
class list_int {
private:
elem_int* head;
elem_int* tail;
public:
list_int() : head(NULL), tail(NULL) {}
void insert_element(int el) {
elem_int* cur = new elem_int;
cur->val = el;
cur->next=NULL;
if(tail==NULL) { // brak elementow na liscie
head=cur;
tail=cur;
} else { // dodajemy na koncu
tail->next=cur;
tail=cur;
}
}
int remove_element() {
int tval=0;
if(head != NULL) { // lista nie moze byc pusta
elem_int* tmp = head;
head=head->next; // usuwamy z poczatku listy
tval=tmp->val;
delete tmp; // zwalniamy pamiec!
}
return tval;
};
}
bool is_empty() {
if (head==NULL)
return true;
else
return false;
}
struct elem_double {
double val;
elem_double* next;
};
//prosta klasa listowa, ktora realizuje
//liste laczona jednokierunkowa, umozliwiajaca
//wstawianie elementow na poczatku i usuwanie z konca
class list_double {
private:
elem_double* head;
elem_double* tail;
public:
list_double() : head(NULL), tail(NULL) {}
void insert_element(double el) {
elem_double* cur = new elem_double;
cur->val = el;
cur->next=NULL;
if(tail==NULL) { // brak elementow na liscie
head=cur;
tail=cur;
} else { // dodajemy na koncu
tail->next=cur;
tail=cur;
}
}
double remove_element() {
double tval=0;
if(head != NULL) { // lista nie moze byc pusta
elem_double* tmp = head;
head=head->next; // usuwamy z poczatku listy
tval=tmp->val;
delete tmp; // zwalniamy pamiec!
}
return tval;
}
bool is_empty() {
if (head==NULL)
return true;
else
return false;
}
};
int main() {
list_double listad;
list_int listai;
for(int i=0; i < 10; ++i)
listad.insert_element((double) i/10.0);
cout << "wprowadzono do listy 10 elementow" << endl;
cout << "wypisuje elementy:" << endl;
while (!listad.is_empty())
cout << listad.remove_element() << endl;
for(int i=0; i < 10; ++i)
listai.insert_element(i);
cout << "wprowadzono do listy 10 elementow" << endl;
cout << "wypisuje elementy:" << endl;
while (!listai.is_empty())
cout << listai.remove_element() << endl;
}
Język C++ dostarcza specjalnego udogodnienia pozwalającego na obejście powyższych
komplikacji. Udogodnienie to nazywa się wzorcem. Zostało ono wprowadzone do języka C++ pod
wpływem języka Ada. Z punktu widzenia funkcjonalnego jest równoważne powyżej prezentowanej
metodzie z dyrektywą define, ale jest pozbawione jej wielu istotnych wad. Spójrzmy na poniższym
przykładzie jak wygląda klasa listowa zdefiniowana przy użyciu wzorców:
//ch8pr4.cpp
//elementarna demonstracja list
#include <iostream>
using namespace std;
template <typename T> struct elem {
T val;
elem* next;
};
//prosta klasa listowa, ktora realizuje
//liste laczona jednokierunkowa, umozliwiajaca
//wstawianie elementow na poczatku i usuwanie z konca
template <typename T> class list {
private:
elem<T>* head;
elem<T>* tail;
public:
list() : head(NULL), tail(NULL) {}
void insert_element(T el) {
elem<T>* cur = new elem<T>;
cur->val = el;
cur->next=NULL;
if(tail==NULL) { // brak elementow na liscie
head=cur;
tail=cur;
} else { // dodajemy na koncu
tail->next=cur;
tail=cur;
}
}
T remove_element() {
T tval=0;
if(head != NULL) { // lista nie moze byc pusta
elem<T>* tmp = head;
head=head->next; // usuwamy z poczatku listy
tval=tmp->val;
delete tmp; // zwalniamy pamiec!
}
return tval;
};
}
bool is_empty() {
if (head==NULL)
return true;
else
return false;
}
int main() {
list<int> listai;
list<double> listad;
for(int i=0; i < 10; ++i)
listad.insert_element((double) (i+1)/10.0);
cout << "wprowadzono do listy 10 elementow" << endl;
cout << "wypisuje elementy:" << endl;
while (!listad.is_empty())
cout << listad.remove_element() << endl;
for(int i=0; i < 10; ++i)
listai.insert_element(i+1);
cout << "wprowadzono do listy 10 elementow" << endl;
cout << "wypisuje elementy:" << endl;
while (!listai.is_empty())
cout << listai.remove_element() << endl;
}
Zwróćmy uwagę, że w momencie użycia wzorca do zadeklarowania zmiennej listowej następuje
automatyczne wygenerowanie odpowiedniej klasy lista (używającej odpowiedniego typu bazowego).
Podejście takie jest nieco inne niż stosowane w Adzie (gdzie trzeba sobie wygenerować uprzednio
konkretną wersję klasy), ale dzięki temu ma ogromne możliwości. Przykładowo standardowa biblioteka
języka C++ używa wzorców (w odróżnieniu np. od standardowej biblioteki języka Java – która jest
obiektowa). Unika w ten sposób komplikacji wynikających z użycia obiektowej biblioteki, gdzie
wszystkie obiekty, które mogą być przechowywane w pojemniku muszą dziedziczyć z klasy Root (albo
Object). Oto jeden z najprostszych przykładów używających biblioteki standardowej języka C++
(STL).
//ch8pr5.cpp
#include <vector>
#include <iostream>
using namespace std;
int main() {
vector<int> listai;
vector<double> listad;
for(int i=0; i < 10; ++i)
listad.push_back((double) (i+1)/10.0);
cout << "wprowadzono do listy 10 elementow" << endl;
cout << "wypisuje elementy:" << endl;
for(int i=0; i < listad.size(); ++i)
cout << listad[i] << endl;
for(int i=0; i < 10; ++i)
listai.push_back(i+1);
cout << "wprowadzono do listy 10 elementow" << endl;
cout << "wypisuje elementy:" << endl;
for(int i=0; i < listai.size(); ++i)
cout << listai[i] << endl;
}
Dodatkowo biblioteka STL daje możliwość korzystania z algorytmów również bazujących na
wzorcach i współpracujących ze standardowymi pojemnikami. Zachęca się Czytelnika do lektury
wspomnianej książki Bruce Eckela.
Drugim bardzo ważnym zagadnieniem w nowoczesnym programowaniu w języku C++ są
wyjątki. Stanowią one wygodny sposób komunikowania przez program sytuacji nietypowych lub
błędnych. Do niedawna język C++ wykorzystywał tradycyjne podejście pochodzące z języka C –
mianowicie w wypadku błędu funkcja zwraca niestandardową wartość sygnalizującą błąd. Pojawiają
jednak wtedy dwa problemy: po pierwsze trzeba przy każdym powrocie z funkcji sprawdzać, czy nie
wystąpił błąd, co bardzo komplikuje program (kto z Czytelników w swoich programach po każdym
użyciu funckji printf sprawdza wartość powrotu by stwierdzić czy i ile znaków udało się tej funkcji
przekazać na standardowe wyjście programu?), po drugie nie zawsze można określić co jest wartością
nietypową. Dla funkcji, która może zwrócić dowolną liczbę całkowitą nie ma wartości nietypowych, a
co za tym idzie sygnalizowanie sytuacji błędnych jest bardzo skomplikowane. Dlatego też język C++
idąc śladem języka Ada postanowił wprowadzić wyjątki. Zagadnienie to jest dosyć skomplikowane, a
my w tym miejscu chcemy je wyłącznie zasygnalizować, dlatego przedstawimy tutaj prosty przykład
programu wykorzystującego wyjątki. Będzie to lista podobna do tych prezentowanych powyżej, tyle, że
zamiast funkcji is_empty do sprawdzenia w pętli wypisującej czy lista nie została opróżniona
wykorzystany zostanie wyjątek. Zwróćmy uwagę, że ten sam wyjątek jest generowany w wypadku
próby usuwania z listy pustej i jeżeli program nie jest przygotowany do jego obsłużenia, to nastąpi jego
awaryjne zakończnie. Proszę się przyjrzeć poniższemu programikowi:
//ch8pr6.cpp
//elementarna demonstracja list
#include <iostream>
using namespace std;
class lista_pusta {};
struct elem {
int val;
elem* next;
};
//prosta klasa listowa, ktora realizuje
//liste laczona jednokierunkowa, umozliwiajaca
//wstawianie elementow na poczatku i usuwanie z konca
class list {
private:
elem* head;
elem* tail;
public:
list() : head(NULL), tail(NULL) {}
void insert_element(int el) {
elem* cur = new elem;
cur->val = el;
cur->next=NULL;
if(tail==NULL) { // brak elementow na liscie
head=cur;
tail=cur;
} else { // dodajemy na koncu
tail->next=cur;
tail=cur;
}
}
int remove_element() {
int tval=0;
if(head != NULL) { // lista nie moze byc pusta
elem* tmp = head;
head=head->next; // usuwamy z poczatku listy
tval=tmp->val;
delete tmp; // zwalniamy pamiec!
} else throw lista_pusta();
return tval;
}
bool is_empty() {
if (head==NULL)
return true;
else
return false;
}
};
int main() {
list lista;
for(int i=0; i < 10; ++i)
lista.insert_element(i);
cout << "wprowadzono do listy 10 elementow" << endl;
cout << "wypisuje elementy:" << endl;
try {
while (true)
cout << lista.remove_element() << endl;
} catch (lista_pusta x) {
}
}
Rozdział 9
Język Java
Język programowania Java przypomina częściowo język C++. Istnieje jednak kilka istotnych
różnic.
1) Java nie posiada struktur. W języku Java operujemy wyłącznie na obiektach. W związku z tym nie
ma możliwości napisania programu, który by nie zawierał ani jednej klasy. Dlatego najprostszy
“hello world” wygląda następująco:
class myfirstjavaprog
{
public static void main(String args[])
{
System.out.println("Hello World!");
}
}
2) W języku Java nie używa się wskaźników. Używane są wyłącznie referencje. Dzięki temu istnieje
możliwość odzyskiwania nieużytków (ang. garbage collector). Dlatego też w Java nie stosuje się
operatora delete dla uwalniania pamięci.
Niestety, nie oznacza to, że Java uwalnia nas od dbałości o poprawne zwalnianie przydzielonych
zasobów. Odzyskiwanie nieużytków dotyczy wyłącznie pamięci przydzielanej na stercie. Inne
zasoby muszą być należycie zwalniane. Ze względu na wspomnianą wcześniej technikę zarządzania
pamięcią Java została pozbawiona (w porównaniu do C++) prawidłowych mechanizmów
inicjalizacji i niszczenia obiektów (konstruktor i destruktor). Połączenie tego z częstym brakiem
dbałości o dobrą praktykę programistyczną u programistów Java (argumenty “po co zwalniać
cokolwiek – jest przecież garbage collector) prowadzą do poważnych błędów w programach.
3) Język Java oferuje doskonale zintegrowany mechanizm wyjątków. Dzięki temu, że mechanizm ten
był od początku przewidziany w filozofii języka i bibliotek, nie mamy takich problemów jak w C++.
Wyjątki są podstawowym sposobem komunikacji w wypadkach nieprzewidzianych zdarzeń. Nie ma
też obaw, że trzeba uwzględniać sytuację, że kod z którym nasza biblioteka będzie łączona może nie
obsługiwać wyjątków. Wyjątki w Java obsługiwane są podobnie jak w C++.
4) Wszystkie funkcje występujące w Java są wirtualne. Nie ma ani potrzeby ani możliwości używania
funkcji niewirtualnych. Problem niedostatecznej wydajności funkcji wirtualnych został rozwiązany
systemowo przy pomocy adaptacyjnych technik kompilacji w locie (JIT Compilation).
Czytelnik zainteresowany poszerzeniem swojej wiedzy dotyczącej języka programowania Java
powinien koniecznie zapoznać się z książką autorstwa Bruce Eckel'a Thinking in Java. Książka ta
stanowi najlepsze wprowadzenie do programowania w Java. Należy również odwiedzić stronę firmy
Sun Microsystems celem zainstalowania pakietu deweloperskiego Java. Celem ułatwienia
programowania w Java zaleca się jedno z dwóch popularnych środowisk graficznych – NetBeans lub
Eclipse.

Podobne dokumenty