Programowanie obiektowe w C++

Wprowadzenie do programowania obiektowego

Cechy podejścia obiektowego

  1. Wszystko jest obiektem. Można wyobrazić sobie obiekt jako wymyślną zmienną - taką, która nie tylko przechowuje dane, ale może także realizować żądania, czyli wykonywać pewne operacje. Dowolne pojęcia ze świata rozwiązywanego problemu można reprezentować za pomocą obiektu.

  2. Program jest zbiorem obiektów, które poprzez wysyłanie komunikatów mówią sobie nawzajem, co robić. „Wysyłanie komunikatu” to inaczej zażądanie wykonania przez niego pewnej operacji. Można myśleć o komunikacie jako o wywołaniu funkcji przynależnej do konkretnego obiektu.

  3. Każdy obiekt posiada własną pamięć, na którą składają się inne obiekty. Nowy obiekt tworzymy, łącząc w jeden pakiet grupę już istniejących obiektów. Budujemy w ten sposób złożone programy, ukrywając jednocześnie ich złożoność za prostą obiektów.

  4. Każdy obiekt posiada swój typ. Każdy obiekt jest instancją pewnej klasy, gdzie klasa jest synonimem typu.

  5. Wszystkie obiekty danego typu mogą otrzymywać te same komunikaty.

Programowanie obiektowe polega na zidentyfikowaniu co/kto składa się na opisywany system, a dopiero w dalszej kolejności jak wykonać poszczególne zadania. Zadaniem programisty jest stworzenie klas - szablonów według których zbudowane będą konkretne obiekty czyli instancje.

Obiekty i klasy

Wszystkie obiekty mają:

  • Zestaw atrybutów (danych) dla nich charakterystycznych

  • Zestaw zachowań, które wykorzystują te dane

Obiekt ma stan, zachowanie i tożsamość.

Obiekty o podobnych właściwościach i zachowaniach tworzą wspólną klasę.

Konkretny obiekt danej klasy nazywa się instancją klasy.

Klasa może zawierać:

  • Pola (atrybuty) opisujące właściwości. Polami są zmienne reprezentujące typy proste lub inne obiekty. Wartości pól obiektu opisują stan obiektu.

  • Metody (funkcje składowe) implementujące zachowania. Zestaw dostępnych dla klienta (innego obiektu) metod definiuje interfejs obiektu.

  • Typy zagnieżdżone.

Abstrakcja

Abstrakcja - uproszczony opis rozpatrywanego problemu, polegający na ograniczeniu zakresu cech obiektu wyłącznie do cech kluczowych, niezależnych od implementacji.

Cele stosowania abstrakcji:

  • Ułatwienie rozwiązania problemu

  • Zwiększenie jego ogólności (tym samym stosowalności)

Abstrakcyjny typ danych - zestaw operacji z ich implementacją.

Etapy tworzenia abstrakcyjnego typu danych:

  1. Wybór opisowej nazwy dla typu.

  2. Sporządzenie listy operacji. * Abstrakcyjny typ danych jest identyfikowany przez czynności, w których uczestniczy. * Definicja operacji inicjalizacji (konstruktory) oraz czyszczenia (destruktory).

  3. Zaprojektowanie interfejsu typu. * Interfejs typu powinien upraszczać jego poprawne zastosowania i utrudniać zastosowanie niepoprawne.

  4. Implementacja typu - nie wolno dopuścić aby wpływała na interfejs.

Enkapsulacja

Enkapsulacja (kapsułkowanie, hermetyzacja lub inaczej ukrywanie informacji) polega na:

  • ukrywaniu pewnych danych składowych lub metod obiektów danej klasy tak, aby były one (i ich modyfikacja) dostępne tylko metodom wewnętrznym danej klasy,

  • zastosowaniu funkcji (metod) w celu ochrony danych przed omyłkową bądź nieupoważnioną modyfikacją.

Enkapsulacja tworzy dwa odrębne obszary zainteresowania (uwagi) - segment kodu, który używa i segment kodu, który nie używa nazw pól danych. Ma ogromne znaczenie z punktu widzenia czytelności kodów i niezależności poszczególnych komponentów kodu.

Zalety hermetyzacji:

  • Umożliwia lepszą kontrolę - dostęp do danych składowych jest możliwy tylko przy pomocy wybranych metod publicznych.

  • Umożliwia zmiany - jakakolwiek zmiana wewnętrznej (prywatnej) reprezentacji obiektów nie pociąga za sobą konieczności zmiany kodu aplikacji.

Tworzenie klasy

Klasa umożliwia tworzenie typów użytkownika.

Dane i metody są zgrupowane razem w obrębie klasy. Publiczne metody opisują zachowanie obiektu i są wykorzystywane przez klientów. Prywatne pola definiują stan wewnętrzny obiektu i nie powinny być dostępne dla klientów.

class BankAccount
{
public:
   void withdraw(double amount);
   void deposit(double amount);
private:
   double balance_ = 0.0;
   long number_ = -1;
   std::string owner_ = "unknown";
};

„Zmienne” należące do obiektu danej klasy określają stan obiektu. Metody klasy mają dostęp do składowych klasy.

Składowe obiektu mogą być inicjowane w miejscu deklaracji wewnątrz klasy. Jeśli pole nie zostanie zainicjowane to w przypadku typów prostych przyjmuje wartość przypadkową lub w przypadku typów użytkownika przyjmuje wartość domyślną.

Implementacja metody może zostać zrealizowana wewnątrz klasy lub poza nią:

class BankAccount
{
private:
    double balance_ = 0.0;
    // ...
public:
    void deposit(double amount)
    {
        assert(amount > 0.0);
        balance_ += amount;
    }

   void withdraw(double amount);
};

void BankAccount::withdraw(double amount)
{
    assert(amount > 0.0);
    balance_ -= amount;
}

Publiczne metody (funkcje składowe) definiują interfejs obiektu (klasy). Wywoływane są na rzecz konkretnego obiektu.

int main()
{
    BankAccount my_account;

    my_account.deposit(100.0);
    my_account.withdraw(50.0);
}

Modyfikatory dostępu

Modyfikatory dostępu definiują poziom dostępności do składowych klasy. Słowa kluczowe określające dostęp do składowych klasy mogą pojawiać się w jej ciele dowolną liczbę razy i w dowolnej kolejności.

Deklaracja

Zakres dostępności

public

Nielimitowany dostęp z poziomu kodu

private

Dostęp możliwy tylko w obrębie klasy lub funkcji i klas zaprzyjaźnionych

protected

Dostęp możliwy w obrębie klasy klas pochodnych lub funkcji i klas zaprzyjaźnionych

Klasa ma wszystkie składowe domyślnie prywatne.

Struktura ma wszystkie składowe domyślnie publiczne.

Tworzenie obiektów

Tworzenie obiektów na stosie

void bank_app()
{
    BankAccount my_account;

    my_account.deposit(200.0);
}

Tworzenie obiektów na stercie

std::unique_ptr<BankAccount> your_account = std::make_unique<BankAccount>();

(*your_account).deposit(100.0);
your_account->withdraw(40.0);

store_in_database(std::move(your_account));

assert(your_account.get() == nullptr);

Wskaźnik this

W obrębie metody wskaźnik this jest wskaźnikiem do obiektu, dla którego wywołano metodę.

class BankAccount
{
    double balance_ = 0.0;
public:
    void deposit(double amount);
    //…
};

void BankAccount::deposit(double amount)
{
    this->balance_ += amount;
}

Metody const

Jeśli sygnatura metody jest zakończona modyfikatorem const oznacza to, że jej implementacja nie zmienia stanu wewnętrznego obiektu:

class BankAccount
{
    double balance_ = 0.0;
    // ...
public:
    double balance() const
    {
        balance_ -= 1.0; // błąd kompilacji
        return balance_;
    }
};

W rezultacie utworzenia metody jako metody const wewnątrz tej metody wskaźnik this otrzymuje również modyfikator const. W przypadku powyżej typem wskaźnika do obiektu na rzecz którego została wywołana metoda jest const BankAccount*.

Cykl życia obiektów

Obiekty lokalne - czas życia zależny od bloku kodu.

//...
{
    BankAccount ba;
    ba.deposit(100.0);
} // moment zniszczenia obiektu ba

Obiekty kontrolowane przez inteligentne wskaźniki (obiekty na stercie):

  • jeśli prawo własności do obiektu jest wyłączne (używany jest std::unique_ptr) obiekt jest niszczony w tym samym momencie co wskaźnik, który nim zarządza

  • jeśli prawo własności do obiektu jest współdzielone, obiekt jest niszczony, kiedy ostatni wskaźnik typu std::shared_ptr odnoszący się do obiektu przestanie istnieć (lub zostanie zresetowany)

//...
{
    std::unique_ptr<BankAccount> account {};

    // ...
    {
        account = get_account_from_db(1);
        account->deposit(100.0);
    }
} // moment zniszczenia account i wskazywanego przez niego obiektu

Konstruktory

Konstruktory służą do poprawnej inicjalizacji instancji klasy

  • Nazwa konstruktora jest taka sama jak nazwa klasy

  • Nie zwraca żadnej wartości

Konstruktor domyślny

Konstruktor domyślny - nie pobiera żadnych argumentów

class BankAccount
{
public:
    BankAccount();  // jawny konstruktor domyślny
    //...
};

BankAccount::BankAccount()
{
   cout << "Tworzenie nowego konta!" << endl;
}

Kiedy klasa nie posiada żadnego jawnego konstruktora kompilator automatycznie generuje automatyczny konstruktor domyślny.

Cechy automatycznego konstruktora domyślnego:

  • Publicznie dostępny

  • Nazwa taka sama jak nazwa klasy

  • Brak zwracanej wartości

  • Nie pobiera żadnych argumentów

  • Inicjuje każdą składową, która posiada konstruktor domyślny - składowe bez konstruktorów domyślnych nie są inicjalizowane

Gdy konstruktor domyślny generowany przez kompilator nie jest odpowiedni można go zastąpić własnym.

Przeładowanie konstruktorów

Przeładowane konstruktory wewnątrz klasy posiadają odmienne sygnatury.

class BankAccount
{
public:
    BankAccount();
    BankAccount(double balance, long number, const std::string& owner);
};

BankAccount::BankAccount()
{
    balance_ = 0.0;
    number_ = -1;
    owner_ = "brak";
}

BankAccount::BankAccount(double balance, long number, const std::string& owner)
{
    balance_ = balance;
    number_ = number;
    owner_ = owner;
}

Lista inicjalizacji atrybutów

Konstruktor może inicjować składowe obiektu zanim wykonana zostanie jakakolwiek instrukcja. Służy do tego lista inicjalizacji. Jest to jedyny sposób inicjalizacji stałych pól obiektu.

class BankAccount
{
public:
    //...
    BankAccount(double balance, long number, const std::string& owner);
private:
    double balance_;
    const long number_;
    string owner_;
};

BankAccount::BankAccount(double balance, long number, const std::string& owner)
    : balance{balance}, number_{n}, owner_{o}
{
}

Delegowanie konstruktorów

Konstruktor może wywoływać inne konstruktory tej samej klasy. Odbywa się to z wykorzystaniem listy inicjalizacji.

class BankAccount
{
public:
    BankAccount(double balance, long number, const std::string& owner);
    BankAccount(long number, const std::string& owner);
private:
    double balance_;
    long number_;
    string owner_;
};

BankAccount::BankAccount(double balance, long number, const std::string& owner)
    : balance{balance}, number_{n}, owner_{o}
{
    // a
}

BankAccount::BankAccount(long number, const std::string& owner)
    : BankAccount{0.0, number, owner}
{
    // b (wywołane po a)
}

Ważne

Obiekt jest uznany za poprawnie skonstruowany, gdy pierwszy konstruktor wywołany zakończy się bez wyjątku (=> wywołany będzie destruktor obiektu).

Stałe składowe klasy (pola const)

Stałe pola - pola z modyfikatorem const:

  • Inicjalizacja w kodzie - wartość musi być zainicjowana na liście inicjalizacyjnej konstruktora

  • Wartość musi być znana w momencie tworzenia obiektu - w trakcie pracy programu (run-time)

class BankAccount
{
public:
    BankAccount(double balance, long number, const std::string& owner);
    BankAccount(long number, const std::string& owner);
private:
    double balance_;
    const long number_;
    string owner_;
};

BankAccount::BankAccount(double balance, long number, const std::string& owner)
    : balance{balance}, number_{n}, owner_{o}
{
    ++number_;  // błąd kompilacji
}

Statyczne składowe klasy

Zmienna, która jest częścią klasy (a nie konkretnego obiektu), jest statyczną daną składową.

Funkcja, która potrzebuje dostępu do danych klasy, a nie do danych składowych konkretnego obiektu, jest nazywana statyczną funkcją składową.

Statyczne dane składowe przechowują informacje wspólne dla wszystkich obiektów danej klasy

  • należą do klasy a nie do konkretnego obiektu

  • są inicjalizowane przed utworzeniem jakiejkolwiek instancji

  • są współdzielone przez wszystkie instancje danej klasy

Statyczne metody mają dostęp jedynie do statycznych pól danej klasy.

Składowe statyczne - zarówno funkcje jaki i dane składowe - muszą zostać zdefiniowane.

public class BankAccount
{
private:
    static double interest_; // deklaracja statycznego pola klasy
public:
    static double get_interest_rate(); // deklaracja metody statycznej
    static void set_interest_rate(double interest_rate);
};

double BankAccount::interest_ = 0.0;    // definicja statycznego pola klasy

// definicja metody statycznej
void BankAccount::set_interest_rate(double interest_rate)
{
   interest_ = interest_rate;
}

double BankAccount::get_interest_rate()
{
   return interest_;
}

Metoda statyczna nie ma wskaźnika this. Metody statyczne można wywoływać nawet, gdy nie istnieją instancje klasy, używając składni NazwaKlasy::metoda_statyczne().

Metody statyczne można również wywoływać przy pomocy istniejących już obiektów.

int main()
{
    BankAccount::set_interest_rate(0.07);

    cout << "Wysokość oprocentowania kont: "
         << BankAccount::get_interest_rate() << endl;
    // ...

    BankAccount ba{132423, "Jan Kowalski"};
    ba.set_interest_rate(0.05);

    cout << "Zmiana oprocentowania kont: "
         << ba.get_interest_rate() << endl;
}

Funkcje oraz klasy zaprzyjaźnione

Funkcje zaprzyjaźnione

Funkcja zaprzyjaźniona - funkcja, która nie jest składową klasy, ale ma dostęp do składowych prywatnych tej klasy. Deklaracja przyjaźni odbywa się za pomocą słowa kluczowego friend.

Możliwe są dwa rodzaje deklaracji przyjaźni funkcji z klasą:

  1. Funkcja niezależna foo() jest zaprzyjaźniona z klasą A

    class A
    {
        //...
        friend void foo();
    };
    
  2. Metoda foo() z klasy B jest zaprzyjaźniona z klasą A

    class A
    {
        //...
        friend void B::foo();
    };
    

Klasy zaprzyjaźnione

W przypadku przyjaźni klasy A z klasą B wszystkie metody klasy B mają dostęp do prywatnych składowych klasy A.

class A
{
    //...
    friend class B;
};