Kontakt  |  PRS 170  |  ALK 420  |  PZR 420  |  SIK 420  |  JPR 222  |  SOP 121


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;
}


Valid HTML 4.01!