Alt. Lab 03 - klasy, operatory

Alt. Lab 03 - klasy, operatory

Klasa Rational

Zaprojektujmy klasę Rational służącą do operacji na ułamkach zwykłych. Zaprogramowanie takiej klasy możemy podzielić na trzy etapy:

Ustalenie wymagań dotyczących klasy

Ustalenie interfejsu

Klasa powinna zawierać metody pozwalające na:

Implementacja

Rozbijmy kod na plik nagłówkowy oraz źródłowy. Jeśli implementacja metody znajduje się poza definicją klasy, należy jej nazwę poprzedzić ciągiem NazwaKlasy::, aby wskazać kompilatorowi, że jest to metoda należąca do klasy, a nie wolna funkcja. Dodatkowo, metody które nie powodują zmian w obiekcie, na którym są wywoływane (np. gettery, funkcja drukująca) warto oznaczyć modyfikatorem const występującym po liście argumentów - dzięki temu będzie możliwe ich wywołanie w kontekście, do którego obiekt danej klasy został przekazany jako const. Poniżej przedstawiono przykładową implementację klasy:

Rational.h:

class Rational {
public:
    Rational(int num = 0, int den = 1); // default constructor

    void set(int num, int den); // sets numerator and denominator
    int num() const; // gets numerator
    int den() const; // gets denominator
    void set_num(int num); // sets numerator
    void set_den(int den); // sets denominator
    Rational add(const Rational &other) const; // adds second rational number, returns result
    Rational subtract(const Rational &other) const; // subtracts second rational number, returns result
    void print() const; // prints number to console
private:
    int num_, den_;
};

Rational.cpp:

Rational::Rational(int num, int den) : num_(num) { // inicjalizuje pole num_ wartoscia num
    if (den) {
        den_ = den;
    } else {
        den = 1;
    }
}

void Rational::set(int num, int den) {
    num_ = num;
    if (den) {
        den_ = den;
    }
}

int Rational::num() const {
    return num_;
}

int Rational::den() const {
    return den_;
}

void Rational::set_num(int num) {
    num_ = num;
}

void Rational::set_den(int den) {
    if (den) {
        den_ = den;
    }
}

Rational Rational::add(const Rational &other) const {
    return Rational(num_ * other.den_ + other.num_ * den_,
                    den_ * other.den_);
}

Rational Rational::subtract(const Rational &other) const {
    return Rational(num_ * other.den_ - other.num_ * den_,
                    den_ * other.den_);
}

void Rational::print() const {
    if (den_ == 1) {
        std::cout << num_;
    } else {
        std::cout << num_ << "/" << den_;
    }
}

🛠🔥 Zadanie 🛠🔥

Przetestuj działanie klasy uruchamiając poniższy kod.


Rational quarter(1, 4);
Rational one_third(1, 3);

Rational add_result, sub_result;
add_result = one_third.add(quarter);
sub_result = one_third.subtract(quarter);

one_third.print(); cout << " + "; quarter.print(); cout << " = "; add_result.print(); cout << endl;
one_third.print(); cout << " - "; quarter.print(); cout << " = "; sub_result.print(); cout << endl;

Przeciążanie operatorów

Napisana w poprzednim podpunkcie klasa pozwala na operacje na ułamkach, ciągle jednak nie jest tak intuicyjna w użyciu jak typy wbudowane:

int a = 1, b = 2;
int c = a + b;

Język C++ umożliwia jednak zdefiniowanie (przeciążenie) operatorów we własnych klasach, tak aby realizowały dowolną zaimplementowaną przez nas funkcjonalność. Funkcje przeciążające operatory, mogą przyjmować tyle argumentów, ile przyjmują w przypadku typów wbudowanych. Zamianie nie ulegają również priorytety operatorów, w związku z czym kolejność wykonania wyrażenia d = a+b*c będzie zawsze a+(b*c), niezależnie od tego czy a, b, c i d to typy wbudowane, czy definiowane przez użytkownika i jaką tak naprawdę operację realizują poszczególne operatory. Chociaż każdy operator to tylko symbol graficzny i może realizować dowolną funkcjonalność, zalecane jest aby przeciążone operatory pełniły funkcje zbliżone lub analogiczne do oryginałów, tak aby zachować czytelność kodu i intuicyjność ich użycia.

Deklaracja operatora wygląda podobnie jak deklaracja metody:

typ_zwracany operator#(lista argumentow)

gdzie # to przeciążany operator

Przykładowe operatory dodawania i odejmowania dla klasy Rational mogą wyglądać następująco (analogicznie do metod add i subtract):

Rational operator+(const Rational &rhs) const; // rhs - Right Hand Side - prawy operand
Rational operator-(const Rational &rhs) const;

Zwróć uwagę, że powyższe operatory przyjmują tylko jeden argument - prawy operand. Operator, tak jak metody add i subtract jest wykonywany na obiekcie będącym lewym operandem:

Rational Rational::operator+(const Rational &rhs) const {
    return Rational(num_ * rhs.den_ + rhs.num_ * den_,
                    den_ * rhs.den_);
}

Rational Rational::operator-(const Rational &rhs) const {
    return Rational(num_ * rhs.den_ - rhs.num_ * den_,
                    den_ * rhs.den_);
}

Kod wykonujący obliczenia może mieć w tym momencie postać:

add_result = one_third + quarter;
sub_result = one_third - quarter;

🛠🔥 Zadanie 🛠🔥

Dodaj do klasy Rational powyższe operatory. Samodzielnie zaimplementuj operator mnożenia *. Przetestuj jego działanie.


Klasy zaprzyjaźnione, operator strumieniowy

Nie zawsze działanie operatorów jest tożsame z ich pierwotnym przeznaczeniem. Przykładowo, operatory << oraz >>, oryginalnie służyły do przesunięć bitowych na liczbach, jednak ze względu na intuicyjność graficzną (kształt strzałki) w C++ znalazły zastosowanie w strumieniach.

Wyświetlanie wartości ułamka przy użyciu metody print() nie jest wygodne, nie umożliwia też np. zapisu do pliku tekstowego. Wygodniejsza byłaby forma:

cout << one_third << " - " << quarter << " = " << sub_result << endl;

W przypadku operatora << lewym operandem jest strumień, a prawym - element do dodania do strumienia. Na przykładzie operatorów + i - widzieliśmy jednak, że lewy operand jest niejako narzucony - jest to obiekt klasy, w której piszemy metodę. Nie możemy również zmodyfikować klasy strumienia - jest ona częścią systemu.

Możemy w takim wypadku napisać wolną funkcję, która przyjmie strumień i nasz ułamek, i zwróci zmodyfikowany strumień:

std::ostream &operator<<(std::ostream &str, const Rational &rhs){
    if (rhs.den_ == 1) {
        str << rhs.num_;
    } else {
        str << rhs.num_ << "/" << rhs.den_;
    }
    return str;
}

Dodaj powyższy kod do pliku Rational.cpp

Wolne funkcje nie mają jednak dostępu do pól prywatnych klasy, w związku z czym musimy zadeklarować przyjaźń klasy z funkcją. Wewnątrz definicji klasy Rational dodaj:

friend std::ostream &operator<<(std::ostream &str, const Rational &rhs);

🛠🔥 Zadanie 🛠🔥

Dodaj do klasy Rational operator >>, dzięki któremu będzie można pobrać ze strumenia typu istream (np. z konsoli poprzez cin) ułamek. Dopuść dwa możliwe formaty wejścia:

Do realizacji zadania najlepiej wykorzystać z string::find i string::substring opisanych poniżej.

string::find(...)

Metoda find pozwala znaleźć w string-u pierwsze wystąpienie znaku lub ciągu znaków (w zależności od tego, czy przekażemy jako argument pojedynczy znak - char lub ciąg znaków - std::string). Do przechowywania indeksu położenia znaku będziemy posługiwali się typem size_t - typem całkowitym, bez znaku, zwracanym standardowo przez metody typu find, size(), length() itp. Przykładowo:

std::string text = "Ala ma kota";
size_t position = text.find('a');
std::cout << "Pierwsze wystąpienie litery a znajduje się na pozycji " << position << std::endl;

Opcjonalny drugi argument pozwala podać pozycję, od której rozpocznie się przeszukiwanie. Rozbudowując powyższy przykład, możemy rozpocząć przeszukiwanie od znaku następującego po poprzednim trafieniu, szukając tym samym kolejnego wystąpienia.

position = text.find('a', position + 1);
std::cout << "Kolejne: " << position << std::endl;

W przypadku nie znalezienia żądanego znaku/ciągu, string::find() zwraca wartość std::string::npos. Sprawdzając zwróconą wartość możemy łatwo zapętlić wyszukiwanie w celu znalezienia wszystkich wystąpień:

position = 0; // zaczynamy wyszukiwanie od początku

// zapisujemy wynik wyszukiwania w zmiennej position, rozpoczynamy wyszukiwanie od znaku
// następującego po poprzedniej wartości position
while ((position = text.find('a', position + 1)) != std::string::npos) {
    // wyszukujemy tak długo, jak find() zwraca wartości różne od npos
    std::cout << "Trafienie: " << position << std::endl;
}

string::substr(...)

Metoda susbstr zwraca fragment oryginalnego napisu, zgodnie z przekazanymi wartościami - indeksem początku fragmentu, oraz, opcjonalnie - jego długością.

Przykładowo:

std::string text = "Ala ma kota";

std::string text2 = text.substr(7); // "kota" - od pozycji 7 do końca
std::string text3 = text.substr(7, 3); // "kot" - od pozycji 7, 3 znaki

size_t position_of_ma = text.find("ma"); // 4

std::string text4 = text.substr(position_of_ma); // od początku wyrazu "ma" do końca

Konwersje

W wielu przypadkach kompilator, jeśli nie znajdzie funkcji, która przyjmuje zestaw parametrów typu który jej przekazaliśmy, wykona niejawną konwersję do pasującego typu. Dzięki temu możliwe jest wykonanie kodu:

Rational p = quarter + 1;

Kompilator będzie się starał wykonać działanie na zmiennej typu Rational, bo taki jest pierwszy operand. Zaimplementowaliśmy operację Rational + Rational, zatem potrzebna jest jedynie konwersja liczby całkowitej do Rational - którą też zaimplementowaliśmy, jako efekt uboczny konstruktora Rational(int num, int den). Powyższy kod sprowadzi się zatem niejawnie (bez naszej wiedzy/ingerencji) do:

Rational p = quarter + Rational(1);

Operacja odwrotna (1 + quarter) nie powiedzie się jednak, ponieważ kompilator będzie "szukał" operatora działającego na typie int.

Możemy również sami deklarować funkcje konwertujące z innego typu do naszej klasy w postaci konstruktora:

Rational(double r); // konstruktor konwertujacy z double
Rational::Rational(double r) {
    int den = 1;
    int it = 0;
    while ((r != (int)r)&&(it<9)){
        den *= 10;
        r *= 10;
        it++;
    }
    num_ = r;
    den_ = den;
}

oraz na inny typ w postaci operatora o symbolu typu, na który konwertujemy:

operator double() const; // operator konwertujacy na double
Rational::operator double() const{
    return std::static_cast<double>(num_) / den_;
}

Po dodaniu powyższych konwersji, możliwe jest nawet wykonanie poniższego kodu, mimo że nie zdefiniowaliśmy operatora / dla klasy Rational.

Rational p = one_third / quarter;

Dlaczego powyższy kod działa? Czy wynik jest dokładnie taki jak powinien być?

Zadania końcowe 🛠🔥

1. Klasa Time

Zaprojektuj i zaimplementuj klasę pozwalającą na przechowywanie czasu i podstawowe operacje (dodawanie i odejmowanie, mnożenie przez skalar).

Klasa powinna umożliwiać wyświetlenie wartości czasu w konsoli, w formacie czytelnym dla użytkownika, a także pobieranie czasu od użytkownika oraz konwersję z/na liczbę całkowitą sekund.

Przykładowe użycie:

Time t1(200);
cout << t1 << endl; // wyswietli 03m:20s
Time t2;
cin >> t2; // uzytkownik wprowadza 10h:12m:01s

Time t3 = t2 - t1; // 10h:8m:41s
int t3s = t3; // 36521

2. Program SuperFaktura

Napisz program umożliwiający generowanie faktur w konsoli. Rozbij funkcjonalność na klasy:

Invoice - klasa opisująca fakturę, posiadająca następujące atrybuty:

Faktura w najprostszej wersji powinna mieć możliwość dodawania elementów i "wydruku" całości informacji.

Item - klasa opisująca pojedynczą pozycję na fakturze, z poniższymi atrybutami:

Przykładowa faktura po "wydruku" może wyglądać następująco:

------------------FAKTURA VAT------------------
===============================================
Sprzedawca: 7770003699      Nabywca: 0123456789

                  c.j. VAT   il.  netto  brutto
1. Sruba M3     | 0.37  A | 100 | 37.00 | 45.51
2. Wiertlo 2 mm | 2.54  B |   2 |  5.08 |  5.49

------------------------------------ RAZEM ----
                                  42.08 | 51.00

Zastanów się jaką funkcjonalność i jaki interfejs musi mieć każda z klas. Drukowanie zrealizuj przez operator strumieniowy, zarówno w klasie Item jak i Invoice.

Ostatecznie klasy powinny umożliwić wykonanie kodu podobnego do poniższego:

Invoice inv(7770003699, 0123456789);
inv.add_item(Item("Sruba M3", 0.37, 'A', 100));
inv.add_item(Item("Wiertlo 2 mm", 2.54, 'B', 2));
cout << inv << endl;

Bonus:

Zaimplementuj operator dodawania faktur (sprawdź zgodność NIP-ów, pozycje występujące na obu fakturach złącz w jedną). Zastanów się jak możesz przechowywać pozycje wewnątrz faktury, aby łatwo znaleźć dopasowania.


Autorzy: Jakub Tomczyński, Michał Fularz, Rafał Kabaciński, Piotr Kaczmarek, Michał Nowicki, Jan Wietrzykowski