Lekcja 7: Programowanie obiektowe - klasy, obiekty, ochrona
danych.
Wstęp
Umiecie już pisać programy strukturalne (przynajmniej mamy taką nadzieję). Prawdopodobnie słyszeliście też o obiektach - może
jeszcze nie w naszym podręczniku, ale gdzieś na pewno. Aby w pełni skorzystać z możliwości programowania wizualno-obiektowego
w BCB, musicie poznać choćby podstawy programowania zorientowanego obiektowo, będącego istotą programowania w C++. To
przede wszystkim bezpośrednim wsparciem dla takiego programowania różni się język C++ od "zwykłego", czyli strukturalnego języka
C.
Oprócz zwykłej dawki teorii zamieściliśmy więc w tej lekcji przykład obiektowej analizy problemu. Natomiast nie będzie tu już
kalkulatora. Zamiast niego - zamieszczamy przykład przydatnej klasy, która realizuje rotacyjny bufor na liczby o wysokiej wydajności.
Obiekty i klasy
W trakcie dotychczasowego kursu programowania poznaliście (mamy taką nadzieję) podstawy programowania strukturalnego. Jest to
historycznie najstarsza metodologia programowania. Mimo tego jest do tej pory znana i często stosowana, szczególnie wszędzie tam,
gdzie nie mamy dostępu do nowoczesnych języków obiektowych lub gdzie należy szybko rozwiązać niezbyt skomplikowany problem.
Ta lekcja poświęcona będzie innemu podejściu do programowania, tzn.
programowaniu zorientowanemu obiektowo. Aby pokazać
różnicę pomiędzy obiektowym a strukturalnym podejściem do programowania, spróbujmy jeszcze raz sprecyzować, czym
charakteryzuje się programowanie strukturalne. W tego typu podejściu do programowania najważniejszy jest algorytm, natomiast
sposób zapisu i pamiętania danych, na jakich operuje, jest drugorzędny.
Wady programowania strukturalnego
Analizując problem strukturalnie, zawsze układamy pewną kolejność działań, operacji wykonywanych przez komputer. Efektem tego
jest powstawanie funkcji. Każdy z algorytmów wymaga pewnych danych wejściowych, które przekształca, produkując dane
wyjściowe. Tak więc wybór czy też opracowanie algorytmu determinuje, jakich danych będzie potrzebował i jak mają być one
zapamiętywane. W ten sposób dostajemy listę parametrów i zmiennych przekazywanych do procedury.
Ż
eby nie być gołosłownym, przeanalizujmy proces powstawania prostego programiku rysującego np. prostokąt na ekranie komputera i
dokonującego na nim podstawowych przekształceń, tzn. obrotu i przesunięcia. Celem jest otrzymanie rysunku i jego późniejsze
przekształcenia. Najpierw zastanówmy się, jak rysuje się prostokąt. Wystarczy, że narysujemy cztery odcinki łączące jego wierzchołki.
Potrzebujemy więc zapamiętać położenia czterech punktów stanowiących wierzchołki prostokąta. Oczywiście nie jest to jedyny sposób
opisu, jaki możemy zastosować, lecz wydaje się, że jest najbardziej naturalny. Jeśli chcemy przesunąć prostokąt o wektor (x,y),
wystarczy że do odpowiednich współrzędnych dodamy współrzędne wektora. Inaczej z obrotem - aby go dokonać, musimy znać
ś
rodek i kąt, o jaki należy prostokąt obrócić. W tym przypadku wygodniej byłoby pamiętać prostokąt jako położenie jego środka,
długość przekątnej oraz dwa kąty: pomiędzy przekątnymi i pomiędzy jedną z nich a osią układu współrzędnych. W wyniku
przedstawionej analizy otrzymujemy kilka procedur oraz strukturę danych, w jakiej pamiętamy prostokąt. Przykładowo, mogłoby to
wyglądać tak:
struct
SPr1
{
int
x1, y1, x2, y2, x3, y3, x4, y4;
};
struct
SPr2
{
int
xs, ys;
double
dl_p;
double
kat_p, kat_ox;
};
void
rysuj(SPr1 p);
SPr1 przesun (SPr1 p,
int
x,
int
y);
SPr2 obroc(SPr2 p,
double
kat);
I jeszcze dwie procedury pomocnicze, wykorzystywane przez procedurę obracającą, a służące do przekształcenia opisu opartego na
czterech punktach w opis oparty na środku, przekątnej i dwu kątach - i na odwrót:
SPr2 wierzcholki_na_katy(SPr1 p);
SPr1 katy_na_wierzcholki(SPr2 p);
Dobrym zwyczajem w programowaniu jest przewidywanie sytuacji błędnych i reagowanie na nie, tak więc każdą z wymienionych
procedur należałoby poszerzyć o sprawdzanie błędów (np. czy dane cztery punkty rzeczywiście tworzą prostokąt, czy długość
przekątnej nie jest ujemna, itp ...).
Jeżeli uważnie przeczytaliście powyższy tekst, zauważyliście zapewne, że teraz stosunkowo proste zadanie narysowania prostokąta, po
analizie i rozpisaniu na procedury, nie jest już takie przejrzyste. Musimy pamiętać, która procedura potrzebuje jakich danych, mamy
dwa niezależne opisy prostokąta i musimy dbać o synchronizację pomiędzy nimi; przy tym zadania wykonywane przez procedury
częściowo się pokrywają (kontrola poprawności danych wejściowych powinna znaleźć się w każdej procedurze) itd. Jeśli chcielibyśmy
dalej rozbudowywać ten program o nowe figury, bardzo szybko dostaniemy olbrzymią liczbę procedur i zmiennych w luźny sposób
powiązanych ze sobą.
Zaprezentowane powyżej podejście do problemu nie jest do końca typowe dla człowieka. Na ogół jak myślimy o prostokącie, to
myślimy o nim jako o całości. Prostokąt jest figurą geometryczną składającą się z czterech boków parami równoległych, o wszystkich
kątach prostych. Prostokąt można opisać na kilka sposobów, można narysować, przemieścić, obrócić, zetrzeć. W sposób naturalny
traktujemy prostokąt jako obiekt. Podobnie traktujemy np. samochód - blaszane pudełko na kółkach, określone kolorem, marką,
kształtem, które przemieszcza się, skręca, ma pewne osiągi itd... Powstaje więc pytanie - dlaczego takiego właśnie postrzegania świata
nie przenieść do programowania? Odpowiedzią na nie jest właśnie
programowanie zorientowane obiektowo.
Co to jest obiekt i czym jest klasa
Skoro mówimy o programowaniu zorientowanym obiektowo, potrzebna jest jakaś definicja obiektu. Podczas tego kursu
obiektem
będziemy nazywali dane i algorytmy na nich operujące. Zatem w powyższym przykładzie w skład obiektu nazywanego prostokątem
będą wchodzić nie tylko dane stanowiące jego opis, ale i wszystkie procedury służące zarówno do zmiany czy przekształcania tych
danych, czy też do wykonywania odpowiednich działań żądanych przez użytkownika (np. narysowania obiektu na ekranie). W dalszym
opisie dane będziemy często nazywali
polami lub właściwościami obiektu, natomiast algorytmy zapisane w postaci funkcji lub
procedur -
metodami.
Na początek możecie sobie wyobrazić obiekty jako trochę bardziej złożone rekordy, które poznaliście już wcześniej. Oprócz pól, do
których już jesteśmy przyzwyczajeni, dokładamy jeszcze metody, definiowane w sposób zbliżony do definiowania funkcji.
rekordy składają się z pól
obiekty składają się z pól i metod działających na tych polach
I podobnie jak definiowaliśmy
typ rekordowy
, by opisać strukturę rekordu, możemy teraz zdefiniować
typ obiektowy, by opisać
strukturę obiektu. Przy czym zamiast pojęcia typu obiektowego w C++ używa się pojęcia
klasa (klasa obiektów).
Zamiast mówić
typ obiektowy możemy mówić po prostu: klasa.
Definicja klasy jest podobna do definicji typu rekordowego (struktur), musi tylko uwzględniać metody. W opisie typu rekordowego
używaliśmy słowa kluczowego
struct. W opisie typu obiektowego (klasy) będziemy używać słowa kluczowego class.
W przypadku najprostszych klas, w których nie jest potrzebne definiowanie widoczności poszczególnych pól czy też metod,
możemy pozostać przy słowie
struct lub używać go zamiennie ze słowem class, lecz lepiej od razu przyzwyczajać się do dobrego
standardu używania słowa
class, którego wymaga prawdziwe programowanie obiektowe.
Załóżmy, że chcemy zdefiniowac klasę będącą bardzo prostą komputerową reprezentację diody. Przyjmijmy, że dioda, traktowana jako
ogólne pojęcie, będzie określona przez dwie właściwości (dwa pola):
ma jakiś kolor (pole typu string)
jest zapalona albo nie (pole typu bool)
Na polach tych chcemy móc wykonywać następujące operacje (metody):
"zapalenie diody"
"zgaszenie" diody
obejrzenie jej stanu: czy świeci i w jakim kolorze.
Naszą klasę opisującą pojęcie diody nazwiemy CDioda - zaczynając jej nazwę od dużego C, by od razu było widać, że jest to nazwa
klasy. To nieobowiązująca, ale wygodna konwencja, analogiczna do zapisu struktur - typów rekordowych, których nazwy zaczynaliśmy
od dużego S (lub T).
Możemy więc już zaproponować definicję klasy CDioda:
class
CDioda {
public
:
string kolor;
bool
zapalona;
void
zapal() {
zapalona =
true
;
}
void
zgas() {
zapalona =
false
;
}
void
pokaz() {
if
(zapalona)
cout <<
"Swieci w kolorze "
<< kolor << endl;
else
cout <<
"Nie swieci\n"
;
}
};
W ten sposób pokazaliśmy Wam, jak można zdefiniować nowy typ obiektowy - klasę. Lecz typ obiektowy, czyli klasa obiektów, albo
krócej klasa, to jeszcze nie obiekt - pamiętajcie o tym. Definicja klasy jest opisem wspólnych metod i pól, specyficznych dla
wszystkich obiektów tej klasy, bez podawania ich konkretnych wartości. Przykładowo klasa obiektów znana pod pojęciem człowiek
definiuje, iż każdy człowiek ma kolor oczu. Nie jest powiedziane jaki - wiadomo tylko, że ma. Natomiast doprecyzowanie (określenie,
jaki to jest kolor) - odbywa się w odniesieniu do konkretnego obiektu - takiego lub innego człowieka. Podobnie klasa CDioda
definiuje, że każdy obiekt tej klasy (dioda) ma jakiś kolor. A jaki - to już zależy od konkretnej diody.
Tutaj mamy więc podobną sytuację jak w przypadku rekordów. Najpierw definiowaliśmy typ rekordowy (strukturę), a dopiero potem
rekordy - zmienne danego typu. I dopiero z tych zmiennych mogliśmy zacząć korzystać. Podobnie wygląda sytuacja w przypadku
obiektów. Żeby można było różne obiekty - diody wykorzystać w naszym programie, musimy najpierw zdefiniować klasę CDioda, a
potem utworzyć jakieś zmienne klasy CDioda (zmienne typu obiektowego CDioda).
Zobaczcie to na przykładzie:
1.
#include <iostream>
2.
#include <cstdlib>
3.
4.
using
namespace
std;
5.
6.
/* Definicja typu obiektowego - klasy CDioda*/
7.
class
CDioda {
8.
public
:
9.
// definicje pól o nazwach kolor i zapalona
10.
string kolor;
11.
bool
zapalona;
12.
// definicja metody zapal
13.
void
zapal() {
14.
zapalona =
true
;
15.
}
16.
// definicja metody zgas
17.
void
zgas() {
18.
zapalona =
false
;
19.
}
20.
// definicja metody pokaz
21.
void
pokaz() {
22.
if
(zapalona)
23.
cout <<
"Swieci w kolorze "
<< kolor << endl;
24.
else
25.
cout <<
"Nie swieci\n"
;
26.
}
27.
};
28.
29.
int
main(
int
argc,
char
*argv[])
30.
{
31.
cout <<
"Diody ..."
<< endl;
32.
33.
// utworzenie dwóch obiektów typu CDdioda, o nazwach d1 i d2
34.
CDioda d1, d2;
35.
36.
// ustawienie wartości ich pól
37.
d1.kolor =
"zielony"
;
38.
d2.kolor =
"czerwony"
;
39.
d1.zapalona =
false
;
40.
d2.zapalona =
false
;
41.
42.
// a teraz główna pętla progamu - będzie prosić użytkownika
43.
// o podanie działania - i po każdym poleceniu wyświetlać
44.
// stan diod
45.
char
zn;
46.
do
{
47.
cout <<
"Stan diod:\n"
;
48.
d1.pokaz();
49.
d2.pokaz();
50.
cout <<
"\nCo chcesz zrobic?\n1 - zapal diode 1\n"
;
51.
cout <<
"2 - zgas diode 1\n"
;
52.
cout <<
"3 - zapal diode 2\n"
;
53.
cout <<
"4 - zgas diode 2\n"
;
54.
cout <<
"0 - zakoncz program\n"
;
55.
cin >> zn;
56.
switch
(zn) {
57.
case
'1'
: d1.zapal();
break
;
58.
case
'2'
: d1.zgas();
break
;
59.
case
'3'
: d2.zapal();
break
;
60.
case
'4'
: d2.zgas();
break
;
61.
};
62.
}
while
(zn !=
'0'
);
63.
64.
return
0;
65.
}
No tak ... uważny czytelnik może stwierdzić - tyle pisania, tyle teorii, tyle hałasu, a jedyny zysk to fakt, że zamiast pisać
pokaz(d1)
piszemy
d1.pokaz()
. I będzie miał rację - jak na razie ... przynajmniej dopóki nie pokażemy możliwości ochrony pól i metod w
obiekcie. A prawdziwą potęgę programowania obiekowego zobaczycie dopiero w następnej lekcji - jak omówimy zupełnie nowe
pojęcia - dziedziczenie i polimorfizm.
Trochę składni
Po tym prostym przykładzie z diodą możemy już podać uproszczoną, ogólną postać deklaracji klasy:
class nazwa_klasy
{
public: // co znaczy public - w następnym segmencie
// najpierw definiujemy pola różnych typów
typ_pola nazwa_pola, ...nazwa_pola;
...
// potem metody (z parametrami lub bez) operujące na tych polach
typ_zwracany nazwa_metody(lista_parametrów);
...
// potem znowu mogą być inne pola
typ_pola nazwa_pola, ...nazwa_pola;
...
// i inne metody
typ_zwracany nazwa_metody(lista_parametrów);
// i tak dalej...
};
Występującym tutaj słowem kluczowym
public na razie się nie przejmujcie - tylko przyjmijcie, że być musi. Co ono znaczy, i dlaczego
być musi - wyjaśnimy w dalszej części tej lekcji.
W C++, mimo że możliwa jest jednoczesna definicja i deklaracja klasy (tak zrobiliśmy w przykładzie wprowadzającym - diody) -
zwykle postępuje się inaczej. Każdą klasę rozbija się na dwa pliki:
plik z deklaracją (a nie definicją) klasy
plik zawierający implementację klasy, czyli definicje jej metod.
Klasę deklaruje się w pliku nagłówka (*.h), natomiast definicje metod wchodzących w jej skład umieszcza w implementacji (*.cpp).
Tak więc klasa CDioda po poprawkach powinna wyglądać następująco: najpierw tworzymy nowy moduł, następnie w jego pliku
nagłówkowym umieszczamy deklarację klasy:
1.
#ifndef cdiodaH
2.
#define cdiodaH
3.
4.
#include <string>
5.
using
namespace
std;
6.
7.
/* Deklaracja klasy CDioda*/
8.
class
CDioda {
9.
public
:
10.
// pole pamiętające stan diody
11.
bool
zapalona;
12.
// kolor diody
13.
string kolor;
14.
// informacja, że dioda ma metodę zapal
15.
void
zapal();
16.
// informacja, że dioda ma metodę zgas
17.
void
zgas();
18.
// informacja, że dioda ma metodę pokaz
19.
void
pokaz();
20.
};
21.
22.
#endif
W pliku z implementacją podajemy natomiast treść (definicję) metod - to tam piszemy, co należy zrobić. Kierujemy się przy tym
zasadami takimi samymi, jak w przypadku tworzenia klasycznych funkcji, z dwoma istotnymi różnicami:
1. Nazwa metody podczas implementacji składa się z dwóch części. Implementacje (definicje) kolejnych metod tworzą tzw.
wnętrze obiektu, bo należą w całości do obiektów danej klasy. W implementacji metody konieczne jest więc zastosowanie
desygnatora (oznacznika), określającego, do jakiej klasy należy dana metoda. Za desygnatorem umieszcza się podwójny
dwukropek, a dopiero po nim nazwę metody:
typ_zwracany nazwa_klasy::nazwa_metody(lista_parametrów)
{
...
};
2. Pisząc ciało (treść) funkcji będącej metodą jakiejś klasy, możemy odwoływać się do pól tej klasy bezpośrednio, nie podając
nazwy klasy, do której dane pole należy - bo przecież wewnątrz klasy wszystkie pola są znane i bezpośrednio dostępne (to tak
jak my - na polecenie "rusz swoją ręką" - wiemy, która ręka jest nasza). Inaczej mówiąc - wewnątrz metody wszystkie pola klasy
można traktować jak zdefiniowane zmienne lokalne.
Przyjrzyjmy się więc przykładowej implementacji:
1.
#include <iostream>
2.
#include <cstdlib>
3.
4.
#include "cdioda.h"
5.
6.
using
namespace
std;
7.
8.
// definicje metod
9.
void
CDioda::zapal()
10.
{
11.
zapalona =
true
;
12.
}
13.
14.
void
CDioda::zgas()
15.
{
16.
zapalona =
false
;
17.
}
18.
19.
void
CDioda::pokaz()
20.
{
21.
if
(zapalona)
22.
cout <<
"Swieci w kolorze "
<< kolor << endl;
23.
else
24.
cout <<
"Nie swieci\n"
;
25.
}
Jak widać, pisząc metodę
zapal poprzedzamy jej nazwę nazwą klasy, natomiast do pola zapalona odwołujemy się bezpośrednio, już
nie umieszczając nazwy obiektu, do którego dane pole należy.
Jeśli już mamy przygotowaną klasę, możemy zacząć z niej korzystać. Zmienne typu obiektowego możemy tworzyć podobnie do
zmiennych złożonych (typu rekordowego). Możemy odwoływać się do ich pól (nadawać im wartości, drukować itp.) i wywoływać ich
metody, zgodnie ze składnią:
nazwa_klasy obiekt_1, obiekt_2, ... , obiekt_n;
obiekt_1.nazwa_pola = wartość;
cout << obiekt_n.nazwa_pola;
obiekt_1.nazwa_metody();
Kontynuując nasz przykład wprowadzający, po umieszczeniu deklaracji i definicji klasy w niezależnym module, otrzymamy program
postaci:
1.
#include <iostream>
2.
#include <cstdlib>
3.
4.
#include "cdioda.h"
5.
6.
using
namespace
std;
7.
8.
int
main(
int
argc,
char
*argv[])
9.
{
10.
cout <<
"Diody ..."
<< endl;
11.
12.
// utworzenie obiektów typu CDioda
13.
CDioda d1, d2;
14.
15.
// ustawienie wartości ich pól
16.
d1.kolor =
"zielony"
;
17.
d2.kolor =
"czerwony"
;
18.
d1.zapalona =
false
;
19.
d2.zapalona =
false
;
20.
21.
// główna pętla progamu - będzie prosić użytkownika
22.
// o podanie działania - i po każdym poleceniu wyświetlać
23.
// stan diod
24.
char
zn;
25.
do
{
26.
cout <<
"Stan diod:\n"
;
27.
d1.pokaz();
28.
d2.pokaz();
29.
cout <<
"\nCo chcesz zrobic?\n1 - zapal diode 1\n"
;
30.
cout <<
"2 - zgas diode 1\n"
;
31.
cout <<
"3 - zapal diode 2\n"
;
32.
cout <<
"4 - zgas diode 2\n"
;
33.
cout <<
"0 - zakoncz program\n"
;
34.
cin >> zn;
35.
switch
(zn) {
36.
case
'1'
: d1.zapal();
break
;
37.
case
'2'
: d1.zgas();
break
;
38.
case
'3'
: d2.zapal();
break
;
39.
case
'4'
: d2.zgas();
break
;
40.
};
41.
}
while
(zn !=
'0'
);
42.
43.
return
0;
44.
}
Obiekty dynamiczne
W przykładzie z diodą wykorzystywaliśmy obiekty statyczne (nazywane dotychczas po prostu obiektami). Można też tworzyć obiekty
dynamiczne, i odwoływać się potem do nich przez wskaźnik:
nazwa_klasy *obiekt_1, *obiekt_2, ... , *obiekt_n;
obiekt_1 = new nazwa_klasy();
...
obiekt_1->nazwa_pola = wartość;
cout << obiekt_i->nazwa_pola;
obiekt_1->nazwa_metody();
...
delete obiekt_1;
Jeśli miałyby być wykorzystywane obiekty dynamiczne, zmienia się jedynie postać programu głównego:
1.
#include <iostream>
2.
#include <cstdlib>
3.
4.
#include "cdioda.h"
5.
6.
using
namespace
std;
7.
8.
int
main(
int
argc,
char
*argv[])
9.
{
10.
cout <<
"Diody ..."
<< endl;
11.
12.
// utworzenie obiektów typu dioda
13.
CDioda *d1, *d2;
14.
d1 =
new
CDioda();
15.
d2 =
new
CDioda();
16.
17.
// ustawienie wartości ich pól
18.
d1->kolor =
"zielony"
;
19.
d2->kolor =
"czerwony"
;
20.
d1->zapalona =
false
;
21.
d2->zapalona =
false
;
22.
23.
// główna pętla progamu - będzie prosić użytkownika
24.
// o podanie działania - i po każdym poleceniu wyświetlać
25.
// stan diód
26.
char
zn;
27.
do
{
28.
cout <<
"Stan diod:\n"
;
29.
d1->pokaz();
30.
d2->pokaz();
31.
cout <<
"\nCo chcesz zrobic?\n1 - zapal diode 1\n"
;
32.
cout <<
"2 - zgas diode 1\n"
;
33.
cout <<
"3 - zapal diode 2\n"
;
34.
cout <<
"4 - zgas diode 2\n"
;
35.
cout <<
"0 - zakoncz program\n"
;
36.
cin >> zn;
37.
switch
(zn) {
38.
case
'1'
: d1->zapal();
break
;
39.
case
'2'
: d1->zgas();
break
;
40.
case
'3'
: d2->zapal();
break
;
41.
case
'4'
: d2->zgas();
break
;
42.
};
43.
}
while
(zn !=
'0'
);
44.
45.
delete
d1;
46.
delete
d2;
47.
48.
return
0;
49.
}
Termometr
W ten oto sposób poznaliśmy podstawy podstaw programowania obiektowego ;). Spróbujmy więc utrwalić tę wiedzę tworząc jeszcze
jedną klasę - klasę termometru, który będzie pamiętał podaną mu temperaturę (w stopniach Celsjusza), oraz potrafił ją wyświetlić w
skali Celsjusza, Kelwina i Fahrenheita.
Nagłówek:
1.
#ifndef CTERMOMETR_H
2.
#define CTERMOMETR_H
3.
4.
class
CTermometr {
5.
public
:
6.
// pole określające stan termometru - temperaturę
7.
double
Temperatura;
8.
// metoda, która podaje temperaturę w skali Celsjusza
9.
double
dajCelsjusz();
10.
// metoda, która podaje temperaturę w skali Kelwina
11.
double
dajKelwin();
12.
// metoda, która podaje temperaturę w skali Fahrenheita
13.
double
dajFahrenheit();
14.
};
15.
16.
#endif
Implementacja:
1.
#include <cstdlib>
2.
3.
#include "ctermometr.h"
4.
5.
double
CTermometr::dajCelsjusz()
6.
{
7.
return
Temperatura;
8.
}
9.
10.
double
CTermometr::dajKelwin()
11.
{
12.
return
273.15 + Temperatura;
13.
}
14.
15.
double
CTermometr::dajFahrenheit()
16.
{
17.
return
32.0 + 9.0 * Temperatura / 5.0;
18.
}
Jak wykorzystać tę klasę w programie? To już zadanie dla Was, drodzy studenci. Napiszcie program wykorzystujący tę klasę w ramach
własnych ćwiczeń.
Prostokąt
Na zakończenie tego segmentu wróćmy do wprowadzenia - opisu prostokąta. Teraz, już częściowo w obiektowy sposób, moglibyśmy
zaproponować inne rozwiązanie. Nie byłby to zestaw luźno powiązanych struktur danych i funkcji, lecz jeden obiekt, który mógły
wyglądać w następujący sposób:
Nagłówek:
1.
class
CProstokat
2.
{
3.
public
:
4.
// to są pola:
5.
int
x1, y1, x2, y2, x3, y3, x4, y4;
6.
7.
// a to metody, a ściślej tylko ich deklaracje (nagłówki):
8.
void
rysuj();
9.
void
przesun(
int
x,
int
y);
10.
void
obroc(
double
kat);
11.
// metoda zwracająca współrzędną x wybranego wierzchołka
12.
int
x_wspolrzedna(
int
nr_wierzcholek);
13.
// metoda zwracająca współrzędną y wybranego wierzchołka
14.
int
y_wspolrzedna(
int
nr_wierzcholek);
15.
// metoda zwracająca wsp. x środka prostokąta:
16.
int
x_srodek();
17.
// metoda zwracająca wsp. y środka prostokąta:
18.
int
y_srodek();
19.
// metoda zwracająca długość przekątnych
20.
double
dl_przekatna();
21.
// metoda zwracająca kąt pomiędzy przekątnymi
22.
double
kat_przekatna();
23.
// metoda zwracająca kąt między przekątną a osią x
24.
void
kat_os();
25.
};
A to schemat implementacji:
void
CProstokat::rysuj()
{
...
};
void
CProstokat::przesun(
int
x,
int
y)
{
...
};
void
CProstokat::obroc(
double
kat)
{
...
}
int
CProstokat::x_wspolrzedna(
int
nr_wierzcholek)
{
...
}
int
CProstokat::y_wspolrzedna(
int
nr_wierzcholek)
{
...
}
int
CProstokat::x_srodek()
{
...
}
int
CProstokat::y_srodek()
{
...
}
double
CProstokat::dl_przekatna()
{
...
}
double
CProstokat::kat_przekatna()
{
...
}
void
kat_os()
{
...
}
Na koniec
Zapamiętajcie, że:
Klasa i obiekt to nie to samo. Klasa to typ obiektowy, a nie konkretny obiekt.
Mylenie klasy z obiektem jest częstym błędem początkujących adeptów programowania. Na szczęście tego typu błędy prawie zawsze
wyłapie kompilator, i zwróci Wam na nie uwagę.
Zamiast pojęcia:
obiekty danej klasy
spotyka się też pojęcie:
instancje danej klasy
. Czasem też:
egzemplarze danej klasy
. Brzmi
mądrzej, ale znaczy to samo.
Obiektowa analiza problemu
Zanim zaczniemy pokazywać coraz dokładniej konstrukcję klasy - przeczytajcie poniższy wywód ... potraktujcie go częściowo jako
uzasadnienie, dlaczego
warto uczyć się programowania obiektowego.
Z punktu widzenia użytkownika obiekt stanowi nierozerwalną i spójną całość - i my musimy to zapewnić. Przy obiektowym podejściu
do programowania najważniejszy jest zawsze etap analizy problemu. W tym przypadku nigdy nie powinniście zaczynać projektu od
opracowania algorytmu, jak to ma miejsce podczas programowania strukturalnego. Na początku należy odpowiedzieć sobie na pytanie,
co stanowi przedmiot naszego zainteresowania. Programując obiektowo, musimy najpierw "wykryć" obiekty w problemie. Trywialny
przykład - jeśli będziemy mieli napisać program rysujący na ekranie koła, prostokąty i trójkąty - sprawę analizy mamy już za sobą: na
pierwszy rzut oka widać, że stworzymy trzy obiekty odpowiadające poszczególnym figurom i, ewentualnie, obiekt odpowiadający
ekranowi. Zajmiemy się więc przykładem bardziej zaawansowanej analizy obiektowej.
Przykład
Załóżmy, że postawiono przed nami zadanie stworzenia programu odczytującego z czujników temperaturę i rysującego jej wykres na
ekranie. To zadanie nie jest już tak proste, jak poprzednie. Jedną z metod analizy obiektowej problemu jest wyszukanie w jego opisie
rzeczowników i zastanowienie się, które z nich powinny mieć odbicie w programie w postaci klas obiektów. Jeśli analizę taką
zastosować do omawianego przypadku, wyglądałoby to następująco:
Zadanie: Napisać program odczytujący z czujników temperaturę i rysujący jej wykres na ekranie.
Program.
Czy program może być obiektem ? - jak najbardziej. Co więcej, we wszystkich nowych językach wspierających programowanie
zorientowane obiektowo program zawsze jest obiektem. W BCB jest klasa nazywająca się
TApplication - ona właśnie
reprezentuje program jako całość. Niemniej jednak nie jest to teraz na tyle istotne, by zamieszczać dokładny opis implementacji
tej klasy w BCB. Na razie zadowolimy się prostą informacją - ktoś już wykonał całą pracę i BCB zawiera klasę reprezentującą
programy - my nic nie musimy dodatkowo z tym robić.
Czujnik.
W tym przypadku czujnik stanowi rzeczywiste urządzenie istniejące fizycznie, więc naturalnym wydaje się stworzenie jego
odpowiednika w postaci obiektu. Czujnik jest opisany przez wiele danych (odczytana temperatura, masa, wymiary zewnętrzne,
zakres pomiarowy, dokładność, typ, itd...), z których przy implementacji obiektu go opisującego musimy wybrać te, które
wydają się nam potrzebne. Nasza propozycja pól klasy opisującej czujnik: aktualna temperatura (dlaczego, chyba każdy
rozumie), zakres pomiarowy (dwie liczby, będą nam przydatne do ustalenia maksymalnej i minimalnej wartości na wykresie) i
dokładność (znając ją, możemy określić przedział, w którym temperatura znajduje się na pewno). Podobnie postępujemy
wybierając metody. Stawiamy sobie pytanie: co czujnik może zrobić? Zmierzyć temperaturę - więc mamy metodę "pomiar".
Ponadto, jako metody musimy także zaimplementować metody umożliwiające komunikację z rzeczywistym urządzeniem -
czujnikiem.
Temperatura.
Czy temperatura powinna być obiektem ? - to już jest kwestią dyskusyjną. Zauważcie, że już mamy pole opisujące aktualną
temperaturę w obiekcie reprezentującym czujnik. Ponadto, temperatura jest tak naprawdę tylko i wyłącznie liczbą - więc
bezsensowne wydaje się tworzenie obiektu ją reprezentującego. Na tym etapie odrzucamy więc temperaturę z listy obiektów do
utworzenia.
Wykres.
Zastanówmy się, czym jest wykres - w naszym przypadku będzie to przedstawienie serii danych (liczb odpowiadających
temperaturze w danym czasie) na płaszczyźnie zdefiniowanej przez osie (pozioma - czas, pionowa - wartość temperatury).
Brzmi to nieco skomplikowanie. Ale przecież nic nie stoi na przeszkodzie, abyśmy do powyższego zdania także zastosowali
analizę obiektową.
A więc: wykres będzie klasą obiektów zawierających następujące pola: osie (sztuk dwie), listę serii danych (sztuk nie wiadomo
ile), wymiary fragmentu ekranu, na którym będzie narysowany (odpowiada płaszczyźnie z pierwszego zdania).
Metody wykresu:
rysuj
- będzie rysował się na ekranie,
dodaj_serie
,
usun_serie
- metody umożliwiające dodanie i
usunięcie serii danych. Dodatkowo, potrzebne będą pomocnicze procedury skalujące dane, stawiające punkty itd ..., lecz osoba
korzystająca z wykresu nie musi ich znać - więc ich wybór pozostawmy na później.
Tworząc klasę wykres wprowadziliśmy nowe pojęcia:
osi i serii danych, tak więc musimy także opracować odpowiadające im
klasy obiektów. Ponadto pojawiło się pojęcie czasu - dane muszą być odczytywane w określonych odstępach czasowych, więc
potrzebny jest nam
zegarek taktujący cały program.
Oś.
W naszym przypadku oś będzie linią charakteryzującą się kierunkiem (pozioma lub pionowa), wartością maksymalną i
minimalną oraz podziałką. Dane te stanowią pola klasy definiującej oś. Metoda będzie jedna -
rysuj
.
Seria danych.
Serię danych w naszym przypadku stanowią kolejne pomiary temperatury. Czyli popełniliśmy błąd odrzucając w
punkcie 3 temperaturę. Tworzymy więc klasę
seria_danych zawierającą kolejne wartości odczytanej temperatury wraz
z czasem pomiaru. Pola obiektu: tablica [n,2] zawierająca w pierwszej kolumnie czas, a w drugiej wartość pomiaru oraz
liczba pamiętanych pomiarów n. Metody -
dodaj_pomiar
.
Zegar.
Zegar będzie obiektem taktującym cały program. To, czy będziemy go potrzebować, czy nie, zależy od dostępnego
sprzętu. Są dwie możliwości: albo zegar jest wbudowany w czujnik lub kartę łączącą go z komputerem - wtedy w
naturalny sposób zegar wchodzi w skład klasy czujnik, lub fizycznie nie istnieje, więc powinniśmy zaimplementować
go jako oddzielny typ obiektowy. Zakładamy, że występuje sytuacja druga, więc implementujemy zegar jako klasę o
dwu polach: czas taktu i aktualny czas. Udostępniamy także jedną metodę:
tik
, wywoływaną po każdym upłynięciu
czasu taktu.
Ekran.
Wracamy do pierwszego zdania - ekran. To, czy ekran powinien być typem obiektowym, zależy od tego, jak zaimplementujemy
wykres. Zakładamy, że wszystkie procedury obsługi ekranu zostaną zawarte w klasie obiektów wykres i rezygnujemy z typu
obiektowego ekran.
Skoro zdefiniowaliśmy odpowiednie klasy obiektów występujące w naszym programie, pozostaje nam jeszcze zdefiniować wzajemne
powiązania między nimi. Działanie programu powinno być następujące: co określony czas odczytywana jest temperatura i uaktualniany
wykres. Przenosząc to na powiązania pomiędzy naszymi obiektami:
1. Zegar stwierdza, że upłynął zadany czas, więc każe czujnikom odczytać temperaturę.
2. Czujniki kolejno odczytują temperaturę, wynik przekazując odpowiednim seriom danych.
3. Każda seria danych zapamiętuje nową wartość i nakazuje wykresowi się przerysować.
4. Wykres sprawdza i w razie potrzeby przerysowuje osie, następnie odświeża serie danych.
Bardziej algorytmicznie:
1. Metoda
tik
zegara wywołuje kolejno procedurę pomiar wszystkich czujników, przekazując jako parametr czas.
2. Metoda
pomiar
czujnika odczytuje wartość temperatury i wywołuje procedurę
dodaj_pomiar.
powiązaną z danym
czujnikiem serii danych, przekazując jako parametry czas i wartość temperatury.
3. Metoda
dodaj_pomiar
serii danych zapisuje wartość w odpowiednim polu tablicy i wywołuje procedurę
rysuj
wykresu.
4. Metoda
rysuj
wykresu najpierw sprawdza, czy zakresy obu osi są prawidłowe. Jeżeli nie są (np. oś czasu kończy się za
wcześnie), zmienia zakres osi i wywołuje ich metody
rysuj
. Następnie rysuje kolejno każdą serię danych, pobierając z niej
kolejne punkty i umieszczając je na ekranie.
W ten sposób dostaliśmy szkic programu obiektowego wykonującego postawione przed nami zadanie. Teraz pozostaje implementacja
poszczególnych klas obiektów. Oczywiście nie jest to kompletne rozwiązanie tego problemu. W rzeczywistości klasy wchodzące w
skład programu będą znacznie bardziej skomplikowane. Mamy nadzieję jednak, że ten uproszczony opis pomógł Wam zrozumieć, na
czym polega programowanie obiektowe.
Uwagi ogólne
Na koniec tego punktu kilka uwag bardziej ogólnych:
To, co przedstawione zostało powyżej, nazywamy programowaniem obiektowym, a nie programowaniem zorientowanym
obiektowo.
Programowanie obiektowe polega na wykorzystaniu obiektów w programie, programowanie zorientowane obiektowo
to znacznie więcej - powoduje, że program dopiero w trakcie działania "orientuje się" według obiektów, co powinien zrobić -
wykorzystując pewne specyficzne własności obiektów zwane polimorfizmem i dziedziczeniem. Programowaniu zorientowanemu
obiektowo poświęcona jest następna lekcja.
Zwróćcie także uwagę na jeszcze jedną cechę programowania obiektowego. Otóż jest ono bardzo przydatne wszędzie tam, gdzie
nad programem pracuje więcej niż jeden programista. Wtedy każdy z nich ma do opracowania jeden obiekt, który z innymi
komunikuje się tylko i wyłącznie w ściśle określony sposób. Więc zamiast zastanawiać się nad dużym problemem, ma do rozwiązania
mały problemik - czyli może pracować z dużą dozą niezależności. Trochę przypomina to składanie budowli z klocków - obiekt jest
właśnie takim klockiem. Niestety, nie ma róży bez kolców - w przypadku małych programów styl obiektowy wydłuża powstanie
programu, ułatwiając w zamian jego późniejszą modyfikację.
Pisanie każdego niebanalnego programu powinno składać się z cyklicznie następujących po sobie faz:
projektowania,
kodowania, testowania, projektowania, kodowania, testowania itd... aż uzyskamy zadowalający efekt. W powyższym
przykładzie następnym krokiem byłaby implementacja poszczególnych obiektów. W jej trakcie na pewno okaże się, że
popełniliśmy jakieś błędy w definicjach klas obiektów lub powiązań między nimi. Wtedy cofamy się do projektu i modyfikujemy
go. Podobnie, część błędów wyjdzie także w trakcie testowania. Można więc postawić pytanie - po co przywiązywać aż taką wagę
do projektu? Aby zaoszczędzić sobie niepotrzebnej pracy. Jeśli pominiecie analizę problemu i rozpoczniecie pisać program,
najprawdopodobniej po jakimś czasie będziecie musieli zaczynać od początku lub prawie od początku. Dogłębna analiza nigdy
nie wyeliminuje wszystkich błędnych założeń, ale pozwoli uniknąć błędów poważnych. Odnosząc to do przykładu - prawie na
pewno zmieni się zawartość poszczególnych typów obiektów, ale mało prawdopodobne jest, aby któryś z nich stał się
niepotrzebny.
Ochrona danych w klasach
Część prywatna, część publiczna
Do tej pory nie napisaliśmy o jednej istotnej własności programowania obiektowego. Podczas opisu modułów wspomnieliśmy o
definiowaniu zmiennych i procedur lokalnych, które nie są widoczne dla programu głównego (patrz
lekcja 6
). Dość szybko okazało się,
ż
e ta właściwość jest bardzo przydatna w praktyce - tak więc wprowadzono ją w rozszerzonej formie do programowania obiektowego.
Właściwość tę nazywamy
ukrywaniem pól lub metod. W praktyce oznacza to, że daje się programiście możliwość zdefiniowania
obszaru widoczności danej zmiennej lub procedury poprzez odpowiednie jej zdefiniowanie. Standard programowania obiektowego (o
ile można o czymś takim mówić) przewiduje występowanie trzech typów ochrony zmiennych.
Pola i metody mogą być publiczne (ang.
public), co oznacza, że są one dostępne z dowolnego miejsca w programie.
Jeśli chcemy uniemożliwić jakikolwiek dostęp do zmiennej lub metody spoza implementacji metod wchodzących w jej skład,
deklarujemy ją jako prywatną (ang.
private). W C++ jest to zachowanie domyślnie (jeśli nie zadeklarujemy inaczej, wszystkie
pola i metody będą prywatne).
Trzecim typem są zmienne chronione (ang.
protected) - dostępne podczas implementacji danego obiektu i wszystkich
obiektów pochodnych.
Konstrukcja klasy w dialekcie C++, na którym oparty jest BCB, jest szersza - udostępnia aż cztery rodzaje ochrony danych:
public (publiczna) - dane z tej sekcji są zawsze dostępne.
private (prywatna) - wszystkie pola i metody umieszczone w tym bloku są dostępne dla danej klasy i tylko dla niej.
protected (chroniona) - pola i metody z tej sekcji dostępne są w implementacji danej klasy i wszystkich klas pochodnych.
published (publikowana) - sekcja ta jest podobna do sekcji publicznej, lecz kompilator w generuje także dla danych tu
umieszczonych informację o typie potrzebną do automatycznego przekierowywania odwołań do danych tam
umieszczonych. Nie będziemy się tym tutaj dokładniej zajmować - nie jest to częścią standardu C++.
W tym punkcie zajmiemy się dokładniej sekcją publiczną i prywatną, do części chronionej wrócimy później. Wygląda to nieco
zagmatwanie, więc może prosty przykład objaśni na początek, jak pokazywać, co jest prywatne, a co publiczne:
class
CMojObiekt
{
private
:
int
zmienna_prywatna;
void
metoda_prywatna();
int
inna_metoda_prywatna();
public
:
int
zmienna_publiczna;
void
metoda_publiczna();
int
inna_metoda_publiczna();
}
Podobnie jak dla programowania strukturalnego powstał jednolity styl zapisu algorytmu w postaci graficznej, dla programowania
obiektowego również utworzono specjalny język opisu klas obiektów i ich wzajemnych powiązań. Nazywa się on
UML (ang.
Unified Modelling Language
). Korzystając z niego możemy przedstawić obiekty jako prostokąty, każdy z nich ma wydzieloną
część, w kórej zapisuje się zmienne (nazywane argumentami bądź polami) oraz drugą przeznaczoną na procedury i funkcje
(nazywane też operacjami bądź metodami). To, czy dane pole lub metoda jest prywatna czy publiczna, definiuje się stawiając
znak
+ dla części publicznej, znak - dla prywatnej oraz znak # dla części chronionej. Zgodnie więc z zapisem UML nasz typ
obiektowy wyglądałby następująco:
W ten oto sposób zadeklarowaliśmy klasę obiektu do niczego nieprzydatnego ;). Widzicie, że ma ona część prywatną i część publiczną,
a każda z nich zawiera jedną zmienną i dwie metody. Teraz, zgodnie z tym, co napisaliśmy powyżej, do danych i metod umieszczonych
w części publicznej możemy odwoływać się bez żadnych ograniczeń w dowolnym punkcie programu. Inaczej jest ze zmiennymi i
metodami prywatnymi - do nich można odwołać się jedynie z "wnętrza" klasy. Przypominamy, że przez wnętrze klasy rozumiemy
implementację (jak kto woli - definicję) metod wchodzących w jej skład.
void
CMojObiekt::metoda_prywatna()
{
zmienna_publiczna = 1;
metoda_publiczna();
zmienna_prywatna = 1;
inna_metoda_prywatna();
};
void
CMojObiekt::metoda_publiczna()
{
zmienna_publiczna = 1;
inna_metoda_publiczna();
zmienna_prywatna = 1;
metoda_prywatna();
};
Obie powyżej zdefiniowane metody należą do wnętrza klasy CMojObiekt, dlatego też w każdej z metod możemy odwoływać się do
wszystkich metod i pól zdefiniowanych w całej tej klasie (i publicznych, i tych prywatnych)- żadna ochrona zmiennych ani metod
prywatnych nie występuje. Inaczej jest już w przypadku korzystania z obiektów w głównym pliku programu. Jeśli zdefiniujemy jakiś
obiekt, np. o nazwie
mo
, jak pokazano poniżej:
CMojObiekt mo;
i gdzieś dalej w programie będziemy wywoływać jego metody czy też odwoływać się do pól w nim zawartych, to możemy to zrobić
jedynie w odniesieniu do pól i metod z części publicznej:
...
mo.zmienna_publiczna = 1;
// prawidłowo
mo.metoda_publiczna();
// prawidłowo
mo.zmienna_prywatna = 1;
// źle - ta zmienna jest pod ochroną, tak się nie da
mo.metoda_prywatna();
// źle - ta metoda jest pod ochroną, tak się nie da
...
Jak widać na powyższym przykładzie, poza obiektem możecie odwoływać się jedynie do zmiennych publicznych, natomiast
bezpośredni dostęp do zmiennych prywatnych nie jest Wam dany.
Wcześniej wspominaliśmy, że
zmienne prywatne są bardzo przydatne, może więc teraz uściślimy, dlaczego jest to taki
genialny wynalazek. Ukrywanie zmiennych stosujemy z paru powodów:
Ukrywamy w ten sposób przed programistą wykorzystującym nasz obiekt nieistotne dla niego szczegóły implementacji.
Czy do tego aby jeździć samochodem wymagana jest wiedza o budowie komór spalania czy aktualnym składzie
mieszanki paliwowej? Nie. Podczas pisania programów zwykle częściej korzysta się z obiektów napisanych przez innych
niż tworzy się własne. Więc - aby nie utrudniać sobie nawzajem życia,
wszystkie dane i metody, które nie są niezbędne
do korzystania z danego obiektu, ukrywa się przed użytkownikiem. Jeśli odniesiemy to do przykładu
wprowadzającego do obiektowej analizy problemu, możecie zauważyć, że wszystkie pola i metody obiektów
zaproponowanych przez nas są publiczne. Natomiast np. obiekt implementujący zegar będzie wymagał dodatkowych,
niewymienionych pól i metod, przykładowo odczytujących czas ze sprzętowego zegara umieszczonego w komputerze.
Jednak ich znajomość czy w ogóle świadomość ich istnienia nie wpływa w żaden sposób na korzystanie z obiektu - więc
zadeklarujemy je jako prywatne na etapie implementacji.
Czasem deklarujemy jako zmienne prywatne także pola, do których użytkownik powinien mieć dostęp (np. długość boku
w kwadracie). W tym przypadku naszym celem jest kontrolowany dostęp do zmiennej. Zamiast dać użytkownikowi
możliwość bezpośredniego zapisywania i odczytywania danego pola, udostępniamy mu odpowiednie procedury
wykonujące te działania, zwyczajowo nazywane
getNazwaZmiennej i setNazwaZmiennej (lub po polsku daj i zmien).
Kontrola dostępu daje nam możliwość sprawdzenia poprawności przekazywanej wartości, co przy konsekwentnym
stosowaniu zapewnia nam, że parametry obiektu są zawsze prawidłowe (mieszczą się w zbiorze parametrów
prawidłowych, np. długość boku kwadratu jest nieujemna).
Kontrola dostępu do zmiennej może także posłużyć do wykonania określonych czynności przed lub po zmianie
zawartości pola. Przykładowo, metoda zmieniająca kolor kwadratu może oprócz zmiany wartości pola kolor także
narysować figurę w nowym kolorze.
Ortodoksyjne podejście do programowania obiektowego (jak np. w języku SmallTalk czy Java) wymaga wręcz, aby
wszystkie
pola obiektu były prywatne, a dostęp do nich był jedynie poprzez metody. Jest to oczywiście przesadą, jednakże nim
zadeklarujecie jakąś zmienną jako publiczną, głęboko to przemyślcie.
Przykład zastosowania
Wykorzystajmy możliwość kontroli zawartości pól do przykładu klasy termometru z
pierwszego
segmentu poprzedniej lekcji.
Zauważmy, że nie każda wartość temperatury jest możliwa do przyjęcia. Z fizyki wiadomo, że temperatury poniżej -273,15 st.
Celsjusza nie istnieją - nie ma temperatury niższej niż absolutne 0. Więc zabezpieczmy naszą klasę przed wprowadzeniem do niej
niedopuszczalnej wartości temperatury. W tym celu musimy przenieść pole temperatura do części prywatnej klasy - w ten sposób nie
będzie można do niego odwoływać się bezpośrednio z programu głównego, i co za tym idzie - nie będzie można podać nowej wartości
bez jej sprawdzenia. Musimy też dodać funkcję umożliwiającą ustawienie nowej temperatury.
Nasza klasa będzie wyglądać więc następująco:
Nagłówek:
1.
#ifndef CTERMOMETR_H
2.
#define CTERMOMETR_H
3.
4.
class
CTermometr {
5.
public
:
6.
// pobranie temperatury w skali Celsjusza
7.
double
dajCelsjusz();
8.
// pobranie temperatury w skali Kelwina
9.
double
dajKelwin();
10.
// pobranie temperatury w skali Fahrenheita
11.
double
dajFahrenheit();
12.
13.
// zmiana wartości temperatury wskazywanej przez termometr
14.
void
zmienTemperature(
double
_v);
15.
16.
private
:
17.
// stan termometru - temperatura, tym razem jako zmienna chroniona
18.
double
Temperatura;
19.
20.
};
21.
22.
#endif
Implementacja:
1.
#include <cstdlib>
2.
#include <iostream>
3.
4.
#include "ctermometr.h"
5.
6.
using
namespace
std;
7.
8.
double
CTermometr::dajCelsjusz()
9.
{
10.
return
Temperatura;
11.
}
12.
13.
double
CTermometr::dajKelwin()
14.
{
15.
return
273.15 + Temperatura;
16.
}
17.
18.
double
CTermometr::dajFahrenheit()
19.
{
20.
return
32.0 + 9.0 * Temperatura / 5.0;
21.
}
22.
23.
void
CTermometr::zmienTemperature(
double
_v)
24.
{
25.
if
(_v < -273.15) {
26.
// komunikat o błędnej wartości parametru
27.
cout <<
"Podano zla wartosc temperatury. "
;
28.
cout <<
"Stara wartosc pozostaje niezmieniona\n"
;
29.
// kończymy działanie funkcji bez wprowadzania zmian
30.
return
;
31.
}
32.
33.
// w przeciwnym wypadku zapamiętujemy nową wartość temperatury
34.
Temperatura = _v;
35.
};
Napiszemy też krótki program, który korzysta z tej klasy.
1.
#include <iostream>
2.
#include <cstdlib>
3.
4.
#include "ctermometr.h"
5.
6.
using
namespace
std;
7.
8.
int
main(
int
argc,
char
*argv[])
9.
{
10.
cout <<
"Test termometru"
<< endl;
11.
12.
CTermometr t;
13.
14.
// ustawiamy początkową temperaturę
15.
t.zmienTemperature(20.0);
16.
17.
// i pozwalamy użytkownikowi "pobawić się" termometrem
18.
char
zn;
19.
double
tv;
20.
do
{
21.
cout <<
"Termometr pokazuje "
<< t.dajCelsjusz() <<
" st. C\n"
;
22.
cout <<
"\nCo chcesz zrobic?\n1 - zmien temp. 1\n"
;
23.
cout <<
"2 - pokaz w skali Kelwina\n"
;
24.
cout <<
"3 - pokaz w skali Fahrenheita\n"
;
25.
cout <<
"0 - zakoncz program\n"
;
26.
cin >> zn;
27.
switch
(zn) {
28.
case
'1'
:
29.
cout <<
"Nowa temperatura: "
;
30.
cin >> tv;
31.
t.zmienTemperature(tv);
32.
break
;
33.
case
'2'
:
34.
cout <<
"W skali Kelwina: "
<< t.dajKelwin() << endl;
35.
break
;
36.
case
'3'
:
37.
cout <<
"W skali Fahrenheita: "
<< t.dajFahrenheit() << endl;
38.
break
;
39.
};
40.
}
while
(zn !=
'0'
);
41.
42.
43.
return
0;
44.
}
Zabezpieczyliśmy się więc przed wprowadzaniem do klasy termometr nieprawidłowych wartości temperatury. Lecz - paradoksalnie -
jeszcze nie mamy pewności, że termometr zawsze pokazuje prawidłową wartość. Dlaczego? Spróbujcie uruchomić powyższy program
z usuniętą linijką:
t.zmienTemperature(20.0);
Wtedy za pierwszym razem termometr wyświetli nam wartość przypadkową, i może się zdarzyć, że będzie to temperatura spoza
dopuszczalnego zakresu... Jak więc się przed tym zabezpieczyć? Poprzez inicjowanie wartości pól w konstruktorze - czym za chwilę.
Naszej klasie termometru brakuje jeszcze sporo do doskonałości, więc mamy dla Was teraz ćwiczenie - zmieńcie tę klasę tak, by
termometr pokazywał zawsze temperaturę zgodnie z wybraną skalą. Jeśli użytkownik chce mieć podaną temperaturę w skali
Kelwina - niech tak będzie, przynajmniej dopóki nie zmieni sposobu wyświetlania na skalę Celsjusza albo Fahrenheita.
Konstruktor i destruktor
W przypadku tworzenia własnych typów obiektowych, ważną rzeczą jest możliwość inicjacji wszystkich jej pól domyślnymi
wartościami. Do tego celu w programowaniu obiektowym służą specjalne metody określane jako
konstruktory. Korzystając z nich
można przypisać domyślne wartości wszystkim polom, utworzyć zmienne dynamiczne wchodzące w skład klasy, czy też dokonać
wszystkich innych niezbędnych w trakcie tworzenia obiektu działań. Ich zachowanie może zmieniać się w zależności od parametrów
przekazywanych metodzie konstruktora, np. mogą posłużyć do wykonania kopii obiektu przekazanego jako wzorzec. A skoro mamy
możliwość zainicjowania obiektu, powinniśmy mieć także możliwość posprzątania po sobie ... Czyli każda klasa może zawierać dwa
typy specjalnych metod:
Konstruktor: metoda, w której umieszczamy kod inicjujący obiekt i wykonujemy wszelkie działania niezbędne do
prawidłowego funkcjonowania obiektu. Konstruktor jest metodą, która nic nie zwraca, i
nazywa się tak samo jak klasa.
Następnie, podobnie jak w zwykłej funkcji, można podać listę parametrów. Ponieważ C++ umożliwia przeciążanie funkcji
(możliwe jest utworzenie dwóch funkcji o takiej samej nazwie lecz innej liście parametrów), możliwe jest zadeklarowanie
kilku różnych konstruktorów w danej klasie.
Obiekty zawsze są tworzone przy wykorzystaniu konstruktora. Nawet jeśli nie wywoła się go jawnie, w C++ zostanie
wywołany w sposób niejawny. I analogicznie - jeśli nie zostanie w klasie zdefiniowany żaden konstruktor, kompilator
wygeneruje sam jego domyślną wersję - która nic nie robi.
Destruktor: metoda, która jest wywoływana w trakcie niszczenia obiektu. Destruktor jest zawsze bezparametrowy i nie zwraca
wartości, zawsze
nazywa się tak jak klasa, poprzedzona znakiem tyldy ~ .
W C++ nie istnieje formalny wymóg definicji konstruktorów czy destruktorów dla każdego typu obiektu, jednak - dobry styl wymaga,
aby konstruktor zawsze był, choćby w celu przypisania zmiennym wartości domyślnych. Tak więc - wszystkie nasze dotychczas
napisane klasy nie były stworzone w dobrym stylu ...
Rodzaje konstruktorów, sposób ich tworzenia oraz wywoływania, nie są w C++ trywialną kwestią. Nie jest też trywialna zasada
działania. Lecz ponieważ nie jest to podręcznik poświęcony niuansom C++, nie będziemy omawiać tego zagadnienia bardzo
szczegółowo, zainteresowanych natomiast odsyłamy do literatury. Tutaj natomiast pokażemy działanie konstruktora na przykładzie
klasy termometru omówionej w poprzednim segmencie.
Po pierwsze - chcielibyśmy, by obiekty tworzone były zawsze z prawidłową wartością temperatury, i zakładamy, że ma być to
temperatura pokojowa. Dlatego też potrzebujemy domyślnego, bezparametrowego konstruktora.
Po drugie - chcielibyśmy mieć możliwość podania wartości początkowej temperatury w momencie tworzenia obiektu. Do tego celu
wykorzystamy konstruktor z jednym parametrem, którym będzie temperatura.
Po trzecie - chcielibyśmy zapewnić możliwość kopiowania termometru, wraz z pokazywaną przez niego temperaturą, więc
potrzebujemy konstruktora kopiującego.
Do kompletu dodamy jeszcze destruktor - nie będzie on wykorzystywany w tej klasie (będzie pusty).
Modyfikujemy więc deklarację klasy następująco - zwróćcie uwagę na nazwy konstruktorów i destruktora:
...
class
CTermometr {
public
:
// Domyślny, pusty konstruktor
CTermometr();
// konstruktor z parametrem - jest nim temperatura
CTermometr(
double
_t);
// konstruktor kopiujący z parametrem - jest nim nazwa obiektu kopiowanego
CTermometr(
const
CTermometr& s);
// destruktor
~CTermometr();
// pobranie temperatury w st. Celsjusza
double
dajCelsjusz();
...
Dodajemy implementację konstruktorów i destruktora:
...
using
namespace
std;
CTermometr::CTermometr()
{
// ustalamy, że wartość temperatury będzie równa na początku
// 20 st. Celsjusza.
Temperatura = 20.0;
}
CTermometr::CTermometr(
double
_t)
{
// jeśli użytkownik podał wartość początkowej temperatury
// sprawdzimy, czy jest to wartość prawidłowa
if
(_t >= -273.15)
// jeśli tak - przyjmujemy tę temperaturę jako początkową
Temperatura = _t;
else
// jeśli nie - zakładamy, że będzie to 20 st. Celsjusza
Temperatura = 20.0;
}
CTermometr::CTermometr(
const
CTermometr& s)
{
// ponieważ kopiujemy istniejący termometr o nazwie s, to na pewno temperatura
// w nim ustawiona jest prawidłowa - nie musimy nic sprawdzać
Temperatura = s.Temperatura;
}
CTermometr::~CTermometr()
{
// destruktor w naszym przypadku nic nie będzie robił
}
double
CTermometr::dajCelsjusz()
{
return
Temperatura;
}
...
Teraz kolejne deklaracje i polecenia będą powodowały wywołanie odpowiednich wersji konstruktora:
...
// termometr zainicjowany domyślną temperaturą
CTermometr t;
// termometr, który po utworzeniu pokazuje 100 st. C
CTermometr t2(100);
// kopia termometru t2
CTermometr t3(t2);
...
I jeszcze wersja dla dynamicznego tworzenia obiektów:
...
// termometr zainicjowany domyślną temperaturą
CTermometr *t =
new
CTermometr();
// termometr, który po utworzeniu pokazuje 100 st. C
CTermometr *t2 =
new
CTermometr(100);
// kopia termometru t2
CTermometr *t3 =
new
CTermometr(*t2);
...
Przykład - bufor danych
Na koniec tej lekcji zamieścimy przykład klasy, która będzie realizowała bufor na n ostatnich liczb.
W pewnym sensie - funkcjonalnie - jest to odpowiednik jednowymiarowej tablicy, tyle że znacznie od niej wygodniejszy. Po pierwsze,
zupełnie swobodnie, i w dowolnym momencie, możemy zmieniać rozmiar naszego bufora. Po drugie - zawsze ostatnio wstawiony do
niego element będzie pamiętany jako ten o indeksie 0, a wszystkie pozostałe przesuną się o jedno pole dalej (zerowy zostanie jedynką,
jedynka dwójką, itd..., a ostatni zostanie "wyrzucony" z bufora).
Zacznijmy od nagłówka klasy (plik cbufor.h):
1.
#ifndef cbuforH
2.
#define cbuforH
3.
4.
#include <string>
5.
6.
/* klasa bufora danych o zmiennej długości, działającego rotacyjnie
7.
8.
Służy do pamiętania n ostatnich wartości */
9.
class
CBufor {
10.
public
:
11.
/* Domyślny konstruktor - brak parametrów, przypisuje wartości domyślne */
12.
CBufor();
13.
/* konstruktor kopiujący */
14.
CBufor(
const
CBufor& src);
15.
/* Destruktor */
16.
virtual
~CBufor();
17.
18.
/* Umieszczenie nowej wartości na początku bufora */
19.
void
dodaj(
double
_v);
20.
21.
/* Pobranie wartości z bufora opóźnionej o k kroków */
22.
double
wartosc(
int
_k);
23.
24.
/* resetowanie bufora - ustawienie we wszystkich próbkach wartości równej
25.
podanej jako parametr */
26.
void
czysc(
double
_v);
27.
28.
/* Wyświetlenie bufora na ekranie */
29.
void
pokaz();
30.
31.
/* ustawienie długości bufora */
32.
void
zmienRozmiar(
int
_n);
33.
/* pobranie długości bufora */
34.
int
dajRozmiar();
35.
36.
protected
:
37.
38.
// Wstaźnik do tablicy będącej buforem
39.
double
*FDane;
40.
// Rozmiar bufora
41.
int
FRozmiar;
42.
// indeks aktualnej pozycji w buforze
43.
int
FPozycja;
44.
};
45.
46.
#endif
Implementacja klasy:
1.
#include <iostream>
2.
3.
#include "cbufor.h"
4.
5.
using
namespace
std;
6.
7.
// domyślny konstruktor - pusty
8.
CBufor::CBufor()
9.
{
10.
// inicjowanie pól
11.
FDane = NULL;
12.
FRozmiar = 0;
13.
// ustawienie domyślnego rozmiaru
14.
zmienRozmiar(2);
15.
}
16.
17.
// konstruktor kopiujący
18.
CBufor::CBufor(
const
CBufor& src)
19.
{
20.
// inicjowanie pól
21.
FDane = NULL;
22.
FRozmiar = 0;
23.
24.
// zmiana rozmiaru
25.
zmienRozmiar(src.FRozmiar);
26.
// kopiowanie danych
27.
for
(
int
i = 0; i < FRozmiar; ++i)
28.
FDane[i] = src.FDane[i];
29.
}
30.
31.
CBufor::~CBufor()
32.
{
33.
// jak usuwany jest obiekt - musimy też zwolnić należącą
34.
// do niego pamięć wykorzystywaną przez tablicę na dane
35.
if
(FDane)
36.
delete
[] FDane;
37.
}
38.
39.
void
CBufor::dodaj(
double
_v)
40.
{
41.
// tutaj zobaczycie fragment kodu "profesjonalnego" ;)
42.
++FPozycja %= FRozmiar;
43.
FDane[FPozycja] = _v;
44.
45.
/* A oznacza on dokładnie coś takiego:
46.
FPozycja++;
47.
if (FPozycja >= FRozmiar) // jak dojdziemy do końca - zaczynamy
48.
// od początku
49.
FPozycja = FPozycja - FRozmiar
50.
FDane[FPozycja] = _v; */
51.
}
52.
53.
double
CBufor::wartosc(
int
_k)
54.
{
55.
// i znów kod "profesjonalny"
56.
return
FDane[(FRozmiar + FPozycja - _k ) % FRozmiar];
57.
58.
/* A oznacza on następujące działania:
59.
k - ma oznaczać wartość umieszczoną w tablicy k kroków wcześniej
60.
czyli FPozycja - k (jeśli k jest mniejsze lub równe FPozycja ) lub
61.
FPozycja + FRozmiar - k ( w przeciwnym wypadku )
62.
int idx;
63.
if (k <= FPozycja)
64.
idx = FPozycja - _k;
65.
else
66.
idx = FPozycja + FRozmiar - _k;
67.
68.
return FDane[idx]; */
69.
}
70.
71.
void
CBufor::czysc(
double
_v)
72.
{
73.
// kasowanie dotychczasowej zawartości bufora - i ustawienie pozycji na jego początek
74.
FPozycja = 0;
75.
for
(
int
i = 0; i < FRozmiar; i++)
76.
FDane[i] = _v;
77.
}
78.
79.
void
CBufor::zmienRozmiar(
int
_n)
80.
{
81.
// sprawdźmy na początek czy użytkownik podał prawidłowy i inny niż dotychczas rozmiar
82.
if
(_n <= 0 || _n == FRozmiar)
83.
// jeśli nie - to go nie posłuchamy - i nic nie zrobimy
84.
return
;
85.
86.
// Tworzymy nową tablicę na dane
87.
double
*ptr =
new
double
[_n];
88.
// wypełniamy zerami
89.
for
(
int
i = 0; i < _n; i++)
90.
ptr[i] = 0.0;
91.
92.
// jeśli mamy w buforze jakieś dane
93.
if
(FDane) {
94.
// to je skopiujemy
95.
int
kk;
96.
if
(_n < FRozmiar)
97.
kk = _n;
98.
else
99.
kk = FRozmiar;
100.
for
(
int
i = 0; i < kk; i++)
101.
ptr[_n-i-1] = wartosc(i);
102.
103.
// kasujemy starą tablicę
104.
delete
[] FDane;
105.
}
106.
107.
// nowo utworzona tablica staje się aktualną
108.
FDane = ptr;
109.
FRozmiar = _n;
110.
FPozycja = FRozmiar - 1;
111.
112.
}
113.
114.
void
CBufor::pokaz()
115.
{
116.
cout <<
"[ "
<< wartosc(0);
117.
for
(
int
i = 1; i < FRozmiar; i++)
118.
cout <<
", "
<< wartosc(i);
119.
cout <<
" ]\n"
;
120.
}
Na koniec sam program. Przyjrzyjcie się mu uważnie ...po raz pierwszy bowiem możecie wyraźnie zauważyć potęgę programowania
obiektowego. Klasę
CBufor wystarczy napisać raz - i raz na to poświęcić czas. A potem ... a potem, w każdym Waszym programie
można korzystać z jej funkcjonalności bardzo prosto dostępnej. Zobaczcie - mamy rotacyjną tablicę, której możemy dynamicznie
zmieniać rozmiar, i której nawet nie musimy kasować!
1.
#include <iostream>
2.
#include <cstdlib>
3.
4.
#include "cbufor.h"
5.
6.
using
namespace
std;
7.
8.
int
main(
int
argc,
char
*argv[])
9.
{
10.
// utwórzmy obiekt klasy CBufor
11.
CBufor b;
12.
13.
cout <<
"Bufor rotacyjny o zmiennym rozmiarze"
<< endl;
14.
15.
// będziemy użytkownika prosili o wprowadzanie poleceń, podawanie kolejnych
16.
// liczb, lub o zmianę rozmiaru bufora.
17.
// Po każdej zmianie będziemy wyświetlać bufor na ekranie
18.
char
zn;
19.
int
ti;
20.
double
tv;
21.
do
{
22.
// wyświetlenie zawartości bufora
23.
b.pokaz();
24.
// możliwe dla użytkownika operacje
25.
cout <<
"Co chcesz zrobic?\n"
;
26.
cout <<
"r - zmiana rozmiaru\n"
;
27.
cout <<
"w - wstaw nowa wartosc\n"
;
28.
cout <<
"k - koniec\n"
;
29.
cin >> zn;
30.
switch
(zn) {
31.
case
'r'
:
case
'R'
:
32.
cout <<
"Nowy rozmiar: "
;
33.
cin >> ti;
34.
b.zmienRozmiar(ti);
35.
break
;
36.
case
'w'
:
case
'W'
:
37.
cout <<
"Podaj nowa wartosc: "
;
38.
cin >> tv;
39.
b.dodaj(tv);
40.
break
;
41.
}
42.
}
while
(zn !=
'k'
);
43.
44.
return
0;
45.
}
Zadania
Zadań szukaj w wersji on-line podręcznika