Last modified: June 23, 2022
This article is written in: 🇵🇱
Programowanie obiektowe (ang. Object-Oriented Programming, OOP) to jeden z najpopularniejszych i najbardziej przemyślanych sposobów tworzenia oprogramowania. Polega na organizowaniu kodu w logiczne jednostki (obiekty), które łączą dane (atrybuty) i funkcje (metody) w jedną spójną całość. Dzięki temu kod staje się łatwiejszy w utrzymaniu, rozbudowie i ponownym wykorzystaniu. Klasy i obiekty są kluczowymi elementami tego paradygmatu: klasa to pewien „przepis” (lub „szablon”), a obiektem nazywamy konkretny egzemplarz stworzony na podstawie takiego przepisu.
Paradygmat obiektowy jest bardzo intuicyjny, ponieważ często odwzorowuje realne sytuacje w kodzie. Można to porównać do planów architektonicznych (klasy), które opisują, jak ma wyglądać i działać budynek, podczas gdy obiekty są już fizycznymi budowlami wzniesionymi według tego planu. Właśnie to rozróżnienie pomiędzy projektem (klasą) a konkretną realizacją (obiektem) stanowi serce OOP.
Klasy pozwalają definiować zarówno dane, które reprezentują stan obiektu (tzw. atrybuty), jak i metody, czyli czynności wykonywane na tych danych. Kiedy tworzony jest obiekt, staje się on instancją danej klasy i „dziedziczy” wszystkie właściwości oraz zachowania zdefiniowane w tej klasie.
Korzyści płynące z tego podejścia to:
Poniżej przedstawione zostały szczegółowe wyjaśnienia związane z klasami, obiektami oraz pojęciami z nimi powiązanymi. Przedstawione przykłady kodu w Pythonie pozwolą łatwiej zrozumieć, jak w praktyce wykorzystuje się programowanie obiektowe.
Kiedy tworzymy klasę w Pythonie, zazwyczaj definiujemy w niej:
__init__) – specjalną metodę, która wywoływana jest automatycznie przy tworzeniu nowego obiektu. Służy do inicjowania (nadawania pierwszych wartości) atrybutów.Dzięki temu programista może zdefiniować, czym jest obiekt (jakie ma dane, co może robić), a także jakie operacje są na nim dozwolone czy typowe.
Poniżej znajduje się prosty przykład klasy Osoba, która przechowuje imię i nazwisko oraz może się przedstawić:
class Osoba:
    # Konstruktor klasy
    def __init__(self, imie, nazwisko):
        self.imie = imie
        self.nazwisko = nazwisko
    # Metoda klasy
    def przedstaw_sie(self):
        print(f"Cześć, jestem {self.imie} {self.nazwisko}")
# Tworzenie obiektów klasy Osoba
osoba1 = Osoba("Jan", "Kowalski")
osoba2 = Osoba("Adam", "Nowak")
# Wywołanie metody dla obiektów
osoba1.przedstaw_sie()
osoba2.przedstaw_sie()W tym przykładzie:
Osoba z dwoma atrybutami (imie i nazwisko) oraz jedną metodą (przedstaw_sie).osoba1 i osoba2. Każdy z nich ma swój własny stan (różne imię i nazwisko).przedstaw_sie każdy obiekt może wyświetlić swoją unikalną charakterystykę.Atrybuty w obiektach odczytujemy i modyfikujemy za pomocą notacji kropkowej. Oznacza to, że do atrybutu docieramy przez nazwa_obiektu.atrybut. Możemy je także zmieniać, przypisując do nich nową wartość:
osoba = Osoba("Jan", "Kowalski")
print(osoba.imie)  # Wyświetli: Jan
osoba.imie = "Adam"
print(osoba.imie)  # Wyświetli: AdamW powyższym przykładzie tworzymy obiekt osoba, a następnie uzyskujemy do niego dostęp przez pole imie. Zmieniamy jego wartość na "Adam" i ponownie wyświetlamy, co dowodzi, że atrybut został zaktualizowany. W praktyce jest to niezwykle wygodne, jednak nie zawsze chcemy, aby atrybuty były modyfikowane dowolnie z zewnątrz. Dlatego w Pythonie istnieją mechanizmy takie jak dekoratory @property oraz @setter, które umożliwiają kontrolę nad tym, w jaki sposób atrybut może być zmieniany (lub odczytywany).
W przypadku, gdy chcemy mieć większą kontrolę nad dostępem do atrybutów, możemy skorzystać z tzw. „właściwości” (properties). Dzięki dekoratorom @property oraz @nazwa_atrybutu.setter tworzymy specjalne metody wywoływane przy odczycie i zapisie atrybutu. Jest to elegancki i zalecany sposób, by uniknąć bezpośredniego modyfikowania atrybutów obiektu.
class Osoba:
    def __init__(self, imie, nazwisko):
        self._imie = imie  # Konwencja z podkreślnikiem wskazuje na "chronione" atrybuty
        self._nazwisko = nazwisko
    @property
    def imie(self):
        print('Ktoś próbuje odczytać imię')
        return self._imie
    @imie.setter
    def imie(self, nowa_wartosc):
        print('Ktoś modyfikuje imię')
        self._imie = nowa_wartosc
osoba = Osoba("Jan", "Kowalski")
print(osoba.imie)  # Ktoś próbuje odczytać imię, wyświetli: Jan
osoba.imie = "Adam"  # Ktoś modyfikuje imię
print(osoba.imie)  # Ktoś próbuje odczytać imię, wyświetli: AdamDzięki temu mechanizmowi możemy również dodać logikę walidującą czy kontrolną w setterze (np. sprawdzanie, czy nowe imię jest ciągiem znaków i spełnia konkretne warunki). W praktyce pozwala to zabezpieczyć dane obiektu przed niepożądanymi wartościami.
Klasy i obiekty nie służą wyłącznie do porządkowania kodu. Zastosowanie programowania obiektowego niesie ze sobą szereg zalet w kontekście rozwijania większych projektów, pracy zespołowej czy ponownego wykorzystania już istniejących rozwiązań w przyszłości.
Klasy umożliwiają sensowne grupowanie atrybutów i metod w jednym miejscu, co sprzyja organizacji kodu i ułatwia odnalezienie potrzebnych fragmentów. Dzięki temu, gdy chcemy np. dodać nowe funkcjonalności lub naprawić błąd, szybko zlokalizujemy odpowiednią sekcję kodu.
Przykład: W aplikacji do zarządzania biblioteką możemy mieć klasę Ksiazka, która łączy atrybuty (tytuł, autor, ISBN) z metodami (wypożycz, zwróć). Dzięki temu dokładnie wiadomo, gdzie szukać logiki związanej z obsługą książek.
class Ksiazka:
    def __init__(self, tytul, autor, isbn):
        self.tytul = tytul
        self.autor = autor
        self.isbn = isbn
    def wypozycz(self):
        print(f'Książka "{self.tytul}" została wypożyczona.')
    def zwroc(self):
        print(f'Książka "{self.tytul}" została zwrócona.')Klasy można traktować jak uniwersalne „matryce”. Raz zdefiniowaną klasę da się wykorzystać wielokrotnie, tworząc dowolną liczbę obiektów o podobnym wzorcu, ale różniących się konkretnymi wartościami atrybutów. Minimalizuje to duplikację kodu i ułatwia jego konserwację.
ksiazka1 = Ksiazka("Pan Tadeusz", "Adam Mickiewicz", "1234567890")
ksiazka2 = Ksiazka("Lalka", "Bolesław Prus", "0987654321")
ksiazka1.wypozycz()
ksiazka2.zwroc()Klasy dają możliwość ukrywania wewnętrznych szczegółów implementacji, tzn. to, jak dokładnie coś jest zrobione w środku, może być niedostępne lub niewidoczne dla zewnętrznego kodu. Dzięki temu możemy modyfikować wnętrze klasy, nie narażając istniejącego kodu na błędy z powodu zmian. Użytkownicy klasy nadal będą się nią posługiwać w taki sam sposób (ten sam interfejs).
class Ksiazka:
    def __init__(self, tytul, autor, isbn):
        self.tytul = tytul
        self.autor = autor
        self.isbn = isbn
        self._wypozyczona = False
    def wypozycz(self):
        if not self._wypozyczona:
            self._wypozyczona = True
            print(f'Książka "{self.tytul}" została wypożyczona.')
        else:
            print(f'Książka "{self.tytul}" jest już wypożyczona.')
    def zwroc(self):
        if self._wypozyczona:
            self._wypozyczona = False
            print(f'Książka "{self.tytul}" została zwrócona.')
        else:
            print(f'Książka "{self.tytul}" nie była wypożyczona.')W tym kodzie klasa sama decyduje, jaką logikę zastosować przy wypożyczeniu lub zwróceniu książki. Zewnętrzny kod jedynie wywołuje odpowiednie metody, nie martwiąc się o status wewnętrznych pól obiektu.
Dziedziczenie pozwala tworzyć nowe klasy na podstawie już istniejących. Nowa klasa (klasa pochodna) „dziedziczy” atrybuty i metody po klasie bazowej, co pozwala uniknąć dublowania kodu. Można wtedy dodawać nowe funkcjonalności lub nadpisywać już istniejące.
class Ebook(Ksiazka):
    def __init__(self, tytul, autor, isbn, rozmiar_pliku):
        super().__init__(tytul, autor, isbn)
        self.rozmiar_pliku = rozmiar_pliku
    def pobierz(self):
        print(f'Pobieranie e-booka "{self.tytul}". Rozmiar pliku: {self.rozmiar_pliku}MB')Dzięki temu klasa Ebook przejmuje już istniejącą logikę z klasy Ksiazka (jak np. metody wypozycz czy zwroc), a oprócz tego wprowadza nowe metody i atrybuty specyficzne dla e-booków (np. rozmiar pliku, metoda pobierania).
Polimorfizm oznacza, że różne obiekty mogą udostępniać wspólny interfejs, ale realizować go na swój własny sposób. Możemy wyobrazić sobie dwie klasy: Ksiazka i Ebook. Obie mogą mieć metodę wypozycz, która zachowuje się podobnie z punktu widzenia wywołującego kod, ale w praktyce może wykonywać nieco inne operacje (np. weryfikować dostępność egzemplarza fizycznego lub cyfrowego pliku).
ksiazka = Ksiazka("Pan Tadeusz", "Adam Mickiewicz", "1234567890")
ebook = Ebook("Lalka", "Bolesław Prus", "0987654321", 5)
ksiazka.wypozycz()
ebook.wypozycz()
ebook.pobierz()W przykładzie widać, że z punktu widzenia zewnętrznego kodu obiekty ksiazka i ebook zachowują się w podobny sposób przy wypożyczaniu, choć wewnętrznie mogą funkcjonować inaczej.
W programowaniu obiektowym, oprócz typowych metod instancyjnych, mamy też metody i pola statyczne oraz klasowe. Podstawowa różnica polega na tym, że:
W Pythonie metody statyczne oznaczamy dekoratorem @staticmethod. Takie metody nie przyjmują jako pierwszego parametru self ani cls. Mogą być wywoływane zarówno poprzez nazwę klasy, jak i poprzez instancję:
class Czlowiek:
    liczba_glow = 1
    @staticmethod
    def wyswietl_glowy():
        print(f'Liczba głów: {Czlowiek.liczba_glow}')
Czlowiek.wyswietl_glowy()  # Liczba głów: 1
przykladowy_czlowiek = Czlowiek()
przykladowy_czlowiek.wyswietl_glowy()  # Liczba głów: 1W tym przykładzie:
liczba_glow jest polem klasowym (jest przypisane do klasy Czlowiek, a nie do konkretnego obiektu).wyswietl_glowy jest metodą statyczną, która bezpośrednio sięga do Czlowiek.liczba_glow.Metody klasowe wykorzystują dekorator @classmethod i otrzymują jako pierwszy argument cls, czyli referencję do samej klasy (odpowiednik self dla instancji). Metody klasowe mogą zatem modyfikować pola klasowe i wywoływać inne metody klasowe:
class Czlowiek:
    liczba_glow = 1
    @classmethod
    def wyswietl_glowy(cls):
        print(f'Liczba głów: {cls.liczba_glow}')  # Używamy cls, zamiast nazwy klasy
    def zwykla_funkcja(self):
        self.wyswietl_glowy()
Czlowiek.wyswietl_glowy()             # Liczba głów: 1
przykladowy_czlowiek = Czlowiek()
przykladowy_czlowiek.wyswietl_glowy() # Liczba głów: 1
przykladowy_czlowiek.zwykla_funkcja() # Liczba głów: 1Jak widać, metoda klasowa może być wywoływana zarówno poprzez klasę, jak i przez obiekt. W praktyce najczęściej używa się jej bezpośrednio z poziomu klasy.
self.self.__class__.Przykład:
class Samochod:
    def __init__(self, marka, model):
        self.marka = marka
        self.model = model
    def przedstaw_sie(self):
        print(f"Samochód: {self.marka} {self.model}")
auto = Samochod("Toyota", "Corolla")
auto.przedstaw_sie()  # Samochód: Toyota Corollacls, będący referencją do klasy.Przykład:
class Samochod:
    liczba_kol = 4
    @classmethod
    def wyswietl_liczbe_kol(cls):
        print(f"Samochody mają {cls.liczba_kol} koła")
Samochod.wyswietl_liczbe_kol()  # Samochody mają 4 kołaself czy cls.Przykład:
class Samochod:
    @staticmethod
    def informacje_o_samochodach():
        print("Samochody to pojazdy mechaniczne służące do transportu")
Samochod.informacje_o_samochodach()  # Samochody to pojazdy mechaniczne służące do transportuWykorzystanie pól i metod statycznych/klasowych jest szczególnie przydatne, gdy chcemy:
Poniższy przykład pokazuje, jak używając metody klasowej, można zliczać liczbę utworzonych instancji:
class Osoba:
    liczba_instancji = 0
    def __init__(self, imie):
        self.imie = imie
        Osoba.liczba_instancji += 1
    @classmethod
    def ile_instancji(cls):
        return cls.liczba_instancji
osoba1 = Osoba("Jan")
osoba2 = Osoba("Anna")
print(Osoba.ile_instancji())  # 2Tutaj każdorazowe stworzenie nowej osoby zwiększa licznik instancji. Metoda ile_instancji jest metodą klasową i pozwala w prosty sposób odczytać, ile obiektów powstało.
Przykładem może być klasa zarządzająca dostępem do bazy danych. Zamiast za każdym razem tworzyć nowe połączenie, klasa może mieć jedną statyczną lub klasową zmienną, z której będą korzystać wszystkie instancje. Dzięki temu unika się wielokrotnych, kosztownych operacji na zasobach.
class BazaDanych:
    polaczenie = None
    @classmethod
    def polacz(cls):
        if cls.polaczenie is None:
            cls.polaczenie = "Połączenie do bazy danych"
        return cls.polaczenie
polaczenie1 = BazaDanych.polacz()
polaczenie2 = BazaDanych.polacz()
print(polaczenie1)  # Połączenie do bazy danych
print(polaczenie1 is polaczenie2)  # TrueW tym kodzie metoda klasowa polacz sprawdza, czy połączenie już istnieje. Jeśli nie, tworzy je. W przeciwnym wypadku zwraca istniejące połączenie. Dzięki temu zaoszczędzamy zasoby i zapobiegamy niekontrolowanemu namnażaniu połączeń.
Wzorce projektowe, takie jak Singleton, często korzystają z cech języka obiektowego (w tym pól lub metod statycznych i klasowych), by kontrolować powstawanie nowych instancji klas. Poniższy przykład pokazuje klasyczną implementację wzorca Singleton w Pythonie:
class Singleton:
    _instancja = None
    def __new__(cls, *args, **kwargs):
        if not cls._instancja:
            cls._instancja = super(Singleton, cls).__new__(cls, *args, **kwargs)
        return cls._instancja
s1 = Singleton()
s2 = Singleton()
print(s1 is s2)  # TrueW momencie tworzenia nowego obiektu Singleton sprawdza, czy _instancja już istnieje. Jeśli nie, tworzy ją, a jeśli tak, zwraca referencję do istniejącego obiektu. W efekcie w całym programie istnieje tylko jeden obiekt tej klasy.