Alt. Lab 02 - Wstep do klas

Alt. Lab 02 - Wstęp do klas

Proste struktury i funkcje w stylu języka C

Do tej pory tworzone na zajęciach struktury zawierały jedynie pola będące zmiennymi różnych typów. Jako przykład można potraktować strukturę Student:

struct Student {
    std::string name;
    std::string surname;
    std::vector<float> grades;
};

Zakładając, że chcemy wyliczyć średnią ocen należy napisać odpowiednią funkcję, które wykona tę operację dla danego obiektu struktury Student:

float calculate_grade(const Student &student) {
    float sum = std::accumulate(student.grades.begin(), student.grades.end(), 0.0f);
    return sum / student.grades.size();
}

Takie podejście niesie ze sobą pewne konsekwencje: szczególnie w większych projektach powstaje dużo wolnych (nie należących do żadnej klasy), globalnie dostępnych funkcji o różnych nazwach, które nie są hierarchicznie ułożone.

Proste struktury w stylu języka C++

Podstawowe informacje

Funkcje powiązane z daną strukturą można zadeklarować wewnątrz jej deklaracji. Taka funkcja jest wtedy nazywaną metodą i ma dostęp do wszystkich aktualnych wartości przechowywanych w obiekcie danej struktury.

Zmodyfikowana deklaracja struktury Student może wyglądać następująco:

struct Student {
    std::string name;
    std::string surname;
    std::vector<float> grades;

    float calculate_grade() {
        float sum = std::accumulate(grades.begin(), grades.end(), 0.0f);
        return sum / grades.size();
    }
};

Tym razem metoda calculate_grade jest zdefiniowana wewnątrz struktury Student i nie przyjmuje żadnych parametrów. Ma ona jednak dostęp do wartości wszystkich pól obiektu Student.

Odwoływanie się do metod zadeklarowanych w strukturach odbywa się na analogicznej zasadzie do odwoływania się do pól obiektu:

Student student{"Some", "Student", {2, 3, 4, 5, 3}}; // This creates object of Student type
std::cout << student.calculate_grade() << std::endl; // This calls calculate_grade function and prints the result

🛠🔥 Zadanie 🛠🔥

Dodaj metodę print wewnątrz struktury Student, która wydrukuje imię i nazwisko studenta oraz wszystkie jego oceny:

Jan Kowalski: 3.0 4.5 5.0 3.5


Weryfikacja poprawności wprowadzanych danych

Napisany program w chwili obecnej nie dokonuje żadnego sprawdzania wprowadzanych danych. Studentowi można przypisywać oceny, które będą dowolnymi liczbami.

Przykładowa metoda (wewnątrz struktury Student), która umożliwia dodanie nowej oceny wraz z weryfikacją jej poprawności może wyglądać następująco:

bool add_grade(float grade) {
    if (grade >= 2.0 && grade <= 5.0) {
        // The grade is valid; let's add it and return true
        grades.push_back(grade);
        return true;
    }
    // The grade is invalid; let's return false
    return false;
}

Powyższe rozwiązanie nie rozwiązuje jednak wszystkich problemów. Do obiektu struktury Student nadal można dodać ocenę pomijając wywołanie add_grade:

Student student;
student.grades.push_back(8.0);

Dodatkowo zmienna typu Student może być zainicjalizowana ocenami z błędnego przedziału:

Student student{"Jan", "Kowalski", {5, 10, 15}};

Korzystanie z tak przygotowanego interfejsu wymaga dużej samodyscypliny oraz przygotowania dokumentacji informującej osobę mającą używać takiego kodu o konieczności dodawania ocen tylko z użyciem metody add_grade.

Klasy jako alternatywa struktur

Problemy opisane powyżej mogą być rozwiązane przy użyciu klas. Koncepcyjnie klasy przypominają struktury: również posiadają pola i metody. Pozwalają jednak osobie projektującej klasę ograniczyć sposoby, w jaki możliwy będzie dostęp do nich „z zewnątrz“.


🛠🔥 Zadanie 🛠🔥

Zmień deklarację struktury struct Student na class Student. Spróbuj skompilować kod odwołujący się do pól lub metod klasy Student.


Istnieją trzy modyfikatory dostępu do pól i metod struktury lub klasy: public, protected i private. Początkowo pominiemy wykorzystanie modyfikatora protected.

Jedyną różnicą między klasami i strukturami w języku C++ jest domyślny modyfikator dostępu. W praktyce deklaracja następującej struktury:

struct Student {
    std::string name;
    std::string surname;
};

jest równoznaczna następującej deklaracji klasy:

class Student {
public:
    std::string name;
    std::string surname;
};

Modyfikator obowiązuje dla wszystkich pól i metod zadeklarowanych pod nim, aż do pojawienia się kolejnego modyfikatora.

Dodatkową korzyścią wynikającą z chronienia pól i umożliwienia do nich dostępu jedynie przez metody publiczne jest fakt, że osoba korzystająca z klasy nie musi przejmować się tym, w jaki sposób przechowywane są informacje wewnątrz klasy. Sposób ten może również ulec zmianie wraz z kolejnymi wersjami klasy - dla użycia klasy ważny jest jedynie jej interfejs, czyli funkcje i pola dostępne dla użytkownika klasy. Z tych względów preferowane jest deklarowanie wszystkich pól jako prywatnych, a od tej pory w instrukcjach będą się pojawiały wyłącznie przykłady wykorzystujące klasy.


🛠🔥 Zadanie 🛠🔥

Dodaj modyfikator public do poprzednio utworzonej klasy Student. Od tego momentu program powinien działać tak samo jak przed zamianą struktury na klasę.

Zmień modyfikator dostępu do pola grades tak, aby zapobiec jego bezpośredniej modyfikacji.


Konstruktor i destruktor

Konstruktor i destruktor to specjalne metody, które - jak wskazują ich nazwy - są wywoływane w momencie odpowiednio tworzenia i usuwania obiektu w pamięci. Mogą one służyć do inicjalizacji pól, alokacji pamięci czy jej zwalniania. Konstruktor ma nazwę taką samą jak nazwa klasy/struktury, a destruktor tę samą nazwę poprzedzoną tyldą (~). Domyślnie tworzone są pusty domyślny konstruktor i destruktor.

Konstruktor może mieć dodatkowo argumenty, którymi można na przykład zainicjalizować wartości:

class Student {
public:
    Student(std::string n) {
        name = n;
    }
    /* ... */
}

Zmienną można w tym momencie stworzyć w następujący sposób:

Student s1("Jan");

🛠🔥 Zadanie 🛠🔥

Dodaj do swojej klasy Student konstruktor, który umożliwi stworzenie zmiennej typu Student i jednoczesne przypisanie mu imienia i nazwiska.

Co się stanie, kiedy spróbujesz utworzyć obiekt nie podając wartości parametrów konstruktora?

Student s1;

Napraw ten problem dodając domyślną wartość argumentów do konstruktora.


Settery, gettery, nazwy pól i metod

W wielu przypadkach stworzona przez nas klasa będzie miała właściwość (pole), do którego chcemy umożliwić dostęp zarówno do modyfikacji, jak i odczytu. W tym przypadku konieczne będzie stworzenie pary metod, nazywanych często odpowiednio setterem i getterem. Warto przyjąć konwencję nazw, która pozwoli w czytelny sposób zasugerować do czego służy dana metoda i do których pól się odwołuje, jednocześnie unikając kolizji nazw pól, metod i argumentów do metod. Często można spotkać się z dodawaniem do nazw pól prefixu m_ bądź suffixu _. Nie ma narzuconego standardu, ważne jednak, aby w obrębie własnego kodu trzymać się jednej konwencji. Poniżej przedstawiono prosty przykład klasy z właściwością index i najprostszą parą settera i gettera.

class Student {
public:
    void set_index(int index) { // setter
        index_ = index;
    }
    int index() { // getter
        return index_;
    }
private:
    int index_;
};

Zadania końcowe 🛠🔥

1. Student

Rozbuduj klasę Student, uwzględniając daną funkcjonalność:

Publiczny interfejs powinien obejmować:

Pamiętaj, że oceny mogą przyjąć tylko określone wartości, a dopuszczalne numery indeksów mają od 5 do 6 cyfr. Uniemożliw wpisanie niepoprawnych wartości. Wszystkie pola oznacz jako prywatne.

2. Liczby zespolone

Zaprojektuj klasę Complex, która przechowa liczbę zespoloną. Powinna ona mieć konstruktor, który pozwoli na zainicjalizowanie wartości liczby.

Dodaj do niej metody, które pozwolą na:

Poprawnie zaprojektowana klasa powinna pozwolić na uruchomienie poniższego kodu:

Complex a(1.0, -2.0); // creates 1-2i
Complex b(3.14); // creates 3.14

b.set_im(-5);

Complex c = a.add(b);

c.print(); // prints 4.14-7i

Autorzy: Dominik Pieczyński, Jakub Tomczyński