Języki programowania - ćwiczenia 5
Temat zajęć: Język C++: klasy abstrakcyjne, przeciążanie operatorów.
Literatura:
- B. Kernighan, D. Ritchie: "Język C"
- B. Stroustrup: "Język C++"
- V. Sthern: "C++ Inżynieria Programowania"
Errata do ćwiczeń nr 3
W przykładzie 4 do ćwiczeń 3 pojawił się błąd w funkcji porownaj_lancuchy,
który skutkował tym, że tablica liczb całkowitych była sortowana
poprawnie, ale tablica łańcuchów w ogóle nie była
sortowana. Problem polegał na tym, że tablica łańcuchów jest
formalnie typu char**, zatem wskaźniki przekazywane do porownaj_lancuchy jako parametry aktualne również są typu char** (mimo, że parametry formalne są typu void*). Zatem parametry należy zrzutować z void* na char**, a następnie, przy samym porównywaniu łańcuchów za pomocą strcmp, posługiwać się zdereferowanymi wskaźnikami (wtedy parametry do strcmp będą faktycznie typu char*).
W pierwotnej wersji przykładu porównywane były wskaźniki bez
dereferencji, czyli zamiast porównywać łańcuchy,
porównywane były wskaźniki na łańcuchy. Poprawna wersja funkcji porownaj_lancuchy
znajduje się poniżej (można ją wkleić do kodu przykładu w miejsce
wadliwej wersji i po kompilacji sprawdzić, że teraz tablica
łańcuchów jest już sortowana poprawnie).
int porownaj_lancuchy(void *l1, void *l2)
{
char **la1, **la2;
la1 = (char**) l1;
la2 = (char**) l2;
return strcmp(*la1, *la2);
}
Klasy abstrakcyjne
Klasy abstrakcyjne to klasy, które określają pewną
funkcjonalność, lecz nie posiadają implementacji (niektórych)
metod, zatem nie jest dozwolone tworzenie obiektów takich klas.
W języku C++ nie występuje pojęcie interfejsu (które poznamy w
Javie), ich miejsce zajmują właśnie klasy abstrakcyjne. Klasą
abstrakcyjną nazywamy każdą klasę zawierającą co najmniej jedną metodę
abstrakcyjną. Metoda abstrakcyjna oznaczana jest w definicji klasy
przez dodanie =0; po jej definicji, np.
class A {
public:
void m1(); // normalna metoda - musimy podać jej implementację
void m2()=0; // metoda abstrakcyjna - nie podajemy implementacji
};
W tym przypadku cała klasa A jest klasą abstrakcyjną. Nie można więc w programie tworzyć obiektów klasy A. Można jednak wyprowadzić z A
klasę potomną, zaimplementować w niej (co najmniej) wszystkie metody
abstrakcyjne, po czym tworzyć obiekty takiej klasy. Jest to nieco
silniejszy mechanizm niż interfejsy w Javie, ponieważ interfejs nie
może posiadać implementacji żadnych metod i nie może definiować
atrybutów, natomiast w klasie abstrakcyjnej możemy zdefiniować
atrybuty oraz podać implementację niektórych metod, a inne
pozostawić jako abstrakcyjne do implementacji przez klasy potomne.
Poniższy (trywialny) przykład pokazuje użycie klasy abstrakcyjnej.
Przykład 1
Demonstracja składni definicji klasy abstrakcyjnej.
Solution Visual Studio pobierz
Plik c6p1.cpp pobierz
#include <iostream>
#include <string>
using namespace std;
class A {
public:
virtual void metoda() = 0;
};
class B: public A {
public:
virtual void metoda();
};
void B::metoda()
{
cout << "implementacja metody" << endl;
}
int main()
{
A a; // błąd!!! A jest abstrakcyjna!
B b; // poprawne
b.metoda();
return 0;
}
Zadanie 1
Dzisiejsze zadanie do samodzielnego wykonania będzie nieco bardziej
skomplikowane niż zwykle. Rozważmy model klas opisujący pewien
abstrakcyjny procesor wykonujący różne, wstawione do niego wcześniej, zadania.
Zaprojektujmy najpierw abstrakcyjną klasę Zadanie:
class Zadanie {
public:
virtual void wykonaj() = 0;
};
Nasza klasa reprezentująca zadania posiada tylko jedną metodę - wykonaj(),
której uruchomienie symbolizuje wykonanie zadania. Pierwszym
etapem projektu jest wyprowadzenie i implementacja dwóch klas
potomnych z klasy Zadanie: klasy Zadanie1 i klasy Zadanie2. W metodzie Zadanie1::wykonaj() na wyjście należy wysłać łańcuch "Wykonywanie zadania typu 1\n", a w metodzie Zadanie2::wykonaj() należy na wyjście wysłać łańcuch "Wykonywanie zadania typu 2\n".
W drukim kroku zajmiemy się procesorem. Oto szkielet klasy Procesor:
class Procesor {
protected:
Zadanie** zadania; // tablica wskaźników na zadania
int ile_zadan;
int ile_max_zadan;
public:
// 1. Zaimplementować konstruktor bezparametrowy
// domyślna maksymalna liczba zadań to 10
// 2. Zaimplementować konstruktor z parametrem,
// którym jest maksymalna liczba zadań, jaką
// procesor może przechować
// 3. Zaimplementować metodę dodaj_zadanie(Zadanie*)
// 4. Zaimplementować metodę wykonaj_zadania()
// 5. Zaimplementować destruktor
};
Klasa Procesor posiada trzy atrybuty:
- zadania - tablica wskaźników na obiekty klasy Zadanie
(w naszym przypadku wiemy, że tak naprawdę znajdą się tam wskaźniki na
klasy potomne, ponieważ w programie nie będzie możliwe tworzenie
obiektów tej klasy - jest ona abstrakcyjna)
- ile_zadan - licznik informujący, ile aktualnie zadań znajduje się w tablicy
- ile_max_zadan - informuje jaki jest rozmiar tablicy zadania, czyli ile maksymalnie zadań procesor może przechować
Należy zaimplementować konstruktor bezparametrowy, który zainicjuje procesor tak, aby mógł przechować 10 zadań. Dodatkowo należy zaimplementować konstruktor, w którym jawnie (jako parametr) podawana będzie maksymalna liczba zadań procesora. W konstruktorach należy przydzielić pamięć tablicy zadania oraz ustawić odpowiednio pozostałe atrybuty.
Implementacji wymaga również destruktor, który będzie zwalniał pamięć przydzieloną tablicy zadania (ale nie powinien usuwać zadań jako takich - mogą one przecież należeć do więcej niż jednego procesora!) oraz metody dodaj_zadanie (ma umieszczać zadanie w pierwszym wolnym miejscu w tablicy i zwiększać ile_zadan, chyba że nie ma już miejsca w tablicy - wówczas metoda nic nie powinna robić) i wykonaj_zadania (ma w pętli wywoływać metodę wykonaj po kolei ze wszystkich zadań, jakie znajdują się w tablicy zadania).
Implementację należy przeprowadzić tak, aby poniższa funkcja main, dołączona do Państwa kodu, działała poprawnie.
int main()
{
Procesor p1;
Procesor *p2 = new Procesor(3);
Zadanie1 z11,z12,z13,z14;
Zadanie2 z21,z22,z23,z24;
p1.dodaj_zadanie(&z11);
p1.dodaj_zadanie(&z12);
p1.dodaj_zadanie(&z21);
p1.dodaj_zadanie(&z13);
p1.dodaj_zadanie(&z24);
p1.dodaj_zadanie(&z14);
p1.dodaj_zadanie(&z22);
p1.dodaj_zadanie(&z23);
p2->dodaj_zadanie(&z11);
p2->dodaj_zadanie(&z22);
p2->dodaj_zadanie(&z21);
p2->dodaj_zadanie(&z12);
p1.wykonaj_zadania();
p2->wykonaj_zadania();
delete p2;
return 0;
}
Przeciążanie operatorów
Język C++ oferuje bardzo ciekawą technikę, rzadko spotykaną w innych
językach programowania. Możemy mianowicie zmienić znaczenie (przeciążyć) standardowe operatory języka tak, aby w przypadku obiektów naszych klas zachowywały się one inaczej niż normalnie.
Sztandarowym przykładem przeciążania operatorów jest zdefiniowanie operatorów << i >>
tak aby możliwe było wypisywanie / pobieranie danych na standardowym
wyjściu / ze standardowego wejścia. W standardowej bibliotece C++ (w
przestrzeni nazw std) zdefiniowane są obiekty: std::cout klasy std::ostream oraz std::cin klasy std::istream.
Reprezentują one strumienie powiązane ze standardowym wyjściem
(domyślnie konsola) i standardowym wejściem (domyślnie klawiatura)
programu. Biblioteka standardowa C++ dostarcza przeciążonych
operatorów << i >> dla standardowych typów danych, dzięki czemu możliwe są konstrukcje
std::cout << wyrażenie << std::endl;
oraz
std::cin >> zmienna;
Obiekt std::endl oznacza przejście do nowego wiersza. Możemy pominąć kwalifikowanie obiektów przestrzenią nazw std:: umieszczając po odpowiednich dyrektywach #include instrukcję
using namespace std;
Jeśli chcemy, aby obiekty naszych klas również
współpracowały ze standardowym wejściem i wyjściem programu,
musimy zaimplementować odpowiednie funkcje operatorowe. Dla klasy klasa mają one prototypy:
ostream& operator<<(ostream &o, const klasa &obiekt);
istream& operator>>(istream &i, klasa &obiekt);
Rozważmy dla przykładu klasę reprezentującą liczby zespolone przedstawioną poniżej.
Plik lzesp1.cpp pobierz
class LZesp
{
protected:
double re;
double im;
public:
LZesp(double are=0, double aim=0)
{
re = are; im = aim;
}
LZesp(const LZesp &z)
{
re = z.getRe();
im = z.getIm();
}
double getRe() const
{
return re;
}
double getIm() const
{
return im;
}
void set(double are, double aim)
{
re = are; im = aim;
}
};
Chcielibyśmy, aby na naszej implementacji liczb zespolonych można
było dokonywać "standardowych" operacji, czyli aby np. poniższy kod był
poprawny:
LZesp l1(4,3), l2(10,2), l3;
l3 = (l2 + l1) * (l2 - l1);
cout << "wartosc : " << l3 << endl;
Prawda, że byłoby elegancko?
Oczywiście kompilator C++ nie jest w stanie przewidzieć co rozumiemy przez l2 + l1 czy cout << l3. Musimy więc mu to powiedzieć. Zatem musimy zdefiniować, co w przypadku obiektów klasy LZesp
oznacza dodawanie, odejmowanie, mnożenie czy przesłanie do strumienia
wyjściowego. Taką możliwość daje właśnie przeciążenie operatorów.
Na przykład, aby określić co oznacza operator + w przypadku obiektów klasy LZesp, musimy zdefiniować odpowiednią metodę (lub funkcję) operatorową o prototypie LZesp operator+(const LZesp &z). Wówczas wyrażenie l1 + l2 będzie oznaczało dla kompilatora l1.operator+(l2). Aby możliwe było wyrażenie l3 = l1 + l2 należy również przeciążyć operator przypisania = (za pomocą np. metody LZesp& operator=(const LZesp &z)). Poniżej znajduje się listing przeciążający operatory +, = i przesłania do strumienia.
Państwa zadaniem jest zaimplementowanie operatorów wymienionych
poniżej oraz wzbogacenie programu demonstracyjnego o odpowiedni kod,
który z nich korzysta.
- LZesp operator-(const LZesp &z) - odejmowanie liczb zespolonych
- LZesp operator*(const LZesp &z) - mnożenie liczb zespolonych
- bool operator==(const LZesp &z) - porównanie (stwierdzenie równości)
- bool operator!=(const LZesp &z) - porównanie (stwierdzenie różności)
- double operator[](int idx) - zwrot części rzeczywistej (jeśli idx==0) lub urojonej (jeśli idx!=0). Inaczej mówiąc: z[0] ma być tym samym, co z.getRe(), a z[1] tym samym, co z.getIm().
- istream& operator>>(istream &i, LZesp &l) - wczytanie liczby zespolonej z klawiatury (należy wykorzystać zdefiniowany już operator >> dla typu double)
Zadanie 2
Poniżej znajduje się kod klasy LZesp z zaimplementowanymi operatorami +, = i <<. Należy doimplementować wymienione wyżej operatory, tak aby kod dał się poprawnie skompilować i działał zgodnie z intuicją.
Solution Visual Studio pobierz
Plik lzesp.cpp pobierz
#include <iostream>
// aby można było użyć cin i cout
using namespace std;
// aby pomijać std::
class LZesp {
protected:
double re;
double im;
public:
LZesp(double are=0, double aim=0)
{
re = are; im = aim;
}
LZesp(const LZesp &z)
{
re = z.getRe();
im = z.getIm();
}
double getRe() const
{
return re;
}
double getIm() const
{
return im;
}
void set(double are, double aim)
{
re = are; im = aim;
}
LZesp operator+(const LZesp &l) const
{
return LZesp(re + l.getRe(), im + l.getIm());
}
LZesp& operator=(const LZesp &l)
{
if (&l != this) {
re = l.getRe();
im = l.getIm();
}
return *this;
}
};
ostream& operator<<(ostream &o, const LZesp &l)
{
o << l.getRe() << " + " << l.getIm() << "i";
return o;
}
int main(void)
{
LZesp l1(2,3), l2(3,4), l3;
l3 = l1 + l2;
// tak naprawdę:
// l3.operator=( l1.operator+(l2) );
cout << l1 << " + " << l2 << " = " << l3 << endl;
// kod poniżej można odkomentować dopiero po implementacji
// odpowiednich operatorów
//LZesp l4, l5;
//cin >> l4;
//cin >> l5;
//cout << l4 << " * " << l5 << " = " << l4*l5 << endl;
//cout << l4 << " - " << l5 << " = " << l4-l5 << endl;
//if (l4 == l5) cout << l4 << " jest rowna " << l5 << endl;
//else cout << l4 << " nie jest rowna " << l5 << endl;
//if (l4 != l5) cout << l4 << " rozni sie od " << l5 << endl;
//else cout << l4 << " nie rozni sie od " << l5 << endl;
return 0;
}