Programowanie obiektowe
Jest to paradygmat programowania, który opiera się na tworzeniu obiektów – elementów, które łączą dane i logikę w spójną całość. Każdy obiekt reprezentuje pewien byt (np. samochód, użytkownik, document) i posiada własne właściwości oraz zachowania.
Klasa
Szablon lub przepis, który definiuje strukturę i zachowanie jej instancji. Klasa określa, jakie atrybuty (cechy) i metody (działania) będą posiadały instancje należące do tej klasy. Przykładowo, klasa Samochod może zawierać takie atrybuty, jak marka, model, rok, a także metody jak przyspiesz() czy hamuj().
class Samochod:
def __init__(self, marka, model, rok):
self.marka = marka
self.model = model
self.rok = rok
Gdy mówimy o obiektach klasy, mamy na myśli strukturę (samą definicję), z których można tworzyć instancje.
Instancja
Konkretny egzemplarz klasy, stworzony na podstawie jej definicji. Instancje posiadają swoje własne wartości atrybutów, choć wszystkie należą do tej samej klasy.
moj_samochod = Samochod('Toyota', 'Corolla', 2020)
Obiekty instancji (lub po prostu instancje) to konkretne przykłady danej klasy, które istnieją w programie i posiadają własne dane.
Atrybuty
Przechowują stan obiektu. Każdy atrybut jest częścią obiektu i przechowuje informacje specyficzne dla danej instancji klasy.
Atrybuty można definiować w konstruktorze klasy (metodzie __init__), co umożliwia każdej instancji posiadanie indywidualnych wartości.
W naszym przykładzie marka, model i rok to atrybuty.
# Każda instancja posiada swoje własne (inne lub nie; unikatowe lub nie) wartości atrybutów
moj_samochod = Samochod('Toyota', 'Corolla', 2020)
kogos_innego_samochod = Samochod('Ford', 'Focus', 2018)
# Dostęp do wartości atrybutów danej instancji klasy
print(moj_samochod.model)
Metody
Funkcje zdefiniowane wewnątrz klasy, które operują na instancjach tej klasy i mogą zmieniać ich stan.
Zwykle w pierwszym argumencie metody umieszcza się self, co pozwala na dostęp do atrybutów i innych method obiektu.
# Rozszerzenie klasy Samochod o metodę przyspiesz
class Samochod:
def __init__(self, marka, model, rok):
self.marka = marka
self.model = model
self.rok = rok
def przyspiesz(self, wartosc: int = 10):
print(f"{self.marka} {self.model} przyspiesza o {wartosc}!")
moj_samochod.przyspiesz(20) # wywołanie metody
# Zwraca formatted string: Toyota Corolla przyspiesza o 20!
Po co programować obiektowo?
Programowanie obiektowe (OOP - ang. object-oriented programming) jest często stosowane w celu uporządkowania kodu i ułatwienia zarządzania złożonymi projektami.
Zalety:
- Modularność i ponowne wykorzystanie kodu: OOP sprzyja tworzeniu modułowego kodu, gdzie klasy można wykorzystać wielokrotnie w różnych częściach programu, co zmniejsza ilość powtarzanego kodu i ułatwia modyfikacje.
- Hermetyzacja: Dzięki niej można ukryć szczegóły implementacyjne klasy przed użytkownikami, którzy korzystają tylko z interfejsu, co zwiększa bezpieczeństwo kodu i minimalizuje ryzyko przypadkowego naruszenia wewnętrznego stanu obiektu.
- Dziedziczenie: Pozwala na tworzenie nowych klas na bazie istniejących, co sprzyja hierarchii i ułatwia rozszerzanie funkcjonalności, redukując potrzebę pisania kodu od podstaw.
- Polimorfizm: Dzięki polimorfizmowi różne klasy mogą reagować na te same polecenia w odmienny sposób, co upraszcza zarządzanie różnymi obiektami i zwiększa elastyczność kodu.
- Lepsze zarządzanie złożonymi systemami: OOP umożliwia tworzenie struktur, które łatwiej utrzymać w dużych projektach, co jest szczególnie przydatne w złożonych aplikacjach.
- Łatwość utrzymania i modyfikacji: Kod obiektowy jest często łatwiejszy do utrzymania, ponieważ klasy i obiekty można modyfikować niezależnie, bez wpływu na inne części systemu.
Wady:
- Wymaga poprzedzającego planowania koncepcyjnego: Tworzenie kodu obiektowego wymaga wcześniejszego przemyślenia architektury, co może być czasochłonne, zwłaszcza w mniejszych projektach.
- Złożoność: Programowanie obiektowe może wydawać się skomplikowane, szczególnie dla początkujących, przez co trudniej nauczyć się i wdrożyć OOP w prostych aplikacjach.
- Ukrywanie stanu: Chociaż hermetyzacja jest zaletą, czasem może być wadą – nie zawsze mamy pełny dostęp do informacji o obiekcie, co może ograniczać elastyczność kodu.
- Wydajność: Obiektowy kod jest zazwyczaj cięższy i wolniejszy niż podejście proceduralne, co może mieć znaczenie w aplikacjach wymagających wysokiej wydajności.
- Nadmierna abstrakcja: Nadmierne stosowanie obiektów i klas może prowadzić do abstrakcji, które nie zawsze są intuicyjne i mogą utrudniać zrozumienie kodu.
- Nie zawsze najlepsze dopasowanie do przypadków użycia: W niektórych przypadkach, zwłaszcza przy przetwarzaniu danych, podejście proceduralne jest bardziej efektywne niż OOP, co sprawia, że stosowanie klas i obiektów może być zbędne.
Dziedziczenie
Kluczowa cecha programowania obiektowego, pozwala na tworzenie nowych klas na bazie istniejących. Umożliwia to wykorzystanie już zdefiniowanych atrybutów i method w nowej klasie, co sprzyja oszczędności kodu i ułatwia jego zarządzanie.
Uwaga
Zwróć uwagę w poniższym przykładzie jak wygląda wywoływanie konstruktorów klas nadrzędnych.
class Pojazd: # Klasa bazowa
def __init__(self, marka):
self.marka = marka
def uruchom(self):
print(f"{self.marka} jest uruchamiany.")
class Samochod(Pojazd): # Klasa pochodna
def __init__(self, marka, model):
super().__init__(marka) # Wywołanie konstruktora klasy bazowej
self.model = model
Wyszukiwanie dziedziczenia
Gdy obiekt wywołuje metodę lub uzyskuje dostęp do atrybutu, Python rozpoczyna process wyszukiwania tego elementu zgodnie z tzw. MRO (Method Resolution Order) – to algorytm wyszukiwania dziedziczenia:
- Najpierw sprawdzana jest klasa, do której należy dany obiekt (czyli klasa pochodna).
- Następnie sprawdzane są klasy bazowe (superklasy) w kolejności od najbliższej do najdalszej.
- Jeśli Python znajdzie odpowiedni element w pierwszej napotkanej klasie, kończy poszukiwanie.
Klasy mogą także dziedziczyć po więcej niż jednej klasie - nazywamy to dziedziczeniem wielokrotnym. W takim wypadku wyszukiwanie z pkt. 2 odbywa się od najbliższej klasy bazowej (wymieniona jako pierwsza przy definiowaniu dziedziczenia) do najdalszej.
class Pojazd:
def uruchom(self):
print("Pojazd jest uruchamiany.")
class Silnik:
def uruchom(self):
print("Silnik jest uruchamiany.")
class Samochod(Pojazd, Silnik):
pass
moj_samochod = Samochod()
moj_samochod.uruchom()
## Zwraca: Pojazd jest uruchamiany.
Aby sprawdzić kolejność MRO, można użyć metody .__mro__ lub funkcji help():
print(Samochod.__mro__)
Jeśli metoda lub atrybut nie istnieje ani w klasie pochodnej, ani w żadnej z klas bazowych, Python zgłasza błąd, np. AttributeError.
Nadpisywanie (overriding)
Process, w którym klasa pochodna definiuje własną wersję metody o tej samej nazwie, co metoda w klasie bazowej. Dzięki temu klasa pochodna może zmienić lub rozszerzyć działanie odziedziczonej metody, dostosowując ją do własnych potrzeb.
Uwaga
Zwróć uwagę w poniższym przykładzie jak wygląda specjalizacja odziedziczonych method.
class Pojazd:
def uruchom(self):
print("Pojazd jest uruchamiany.")
class Samochod(Pojazd):
def uruchom(self): # Nadpisywanie metody uruchom
print("Samochód jest uruchamiany szybciej!")
super().uruchom() # Wywołanie oryginalnej metody klasy bazowej
moj_samochod = Samochod()
moj_samochod.uruchom()
Przeciążanie operatorów
Technika, która pozwala definiować, jak klasy będą odpowiadać na standardowe operacje, takie jak np. dodawanie, porównywanie czy wyświetlanie reprezentacji tekstowej.
W Pythonie przeciążanie odbywa się przez definiowanie w klasie specjalnych method (tzw. dunder methods – od double underscore methods), które zaczynają i kończą podwójnym podkreśleniem (np. __init__, __add__ czy __str__).
class Wektor:
# Ta metoda odpowiada za inicjalizację
def __init__(self, x, y):
self.x = x
self.y = y
# Metoda przeciążająca operator +
# Definiuje dodawanie wiektora do wektora
def __add__(self, inny_wektor):
return Wektor(self.x + inny_wektor.x, self.y + inny_wektor.y)
# Metoda przeciążająca operator <
def __lt__(self, inny_wektor):
return (self.x**2 + self.y**2) < (inny_wektor.x**2 + inny_wektor.y**2)
# Definiuje tekstową reprezentację obiektu
def __str__(self):
return f"Wektor({self.x}, {self.y})"
wektor1 = Wektor(2, 3)
wektor2 = Wektor(1, 1)
suma = wektor1 + wektor2
print(suma)
print(wektor1 < wektor2)
Rozszerzona lista method do przeciążania operatorów
| Metoda | Przeciąża | Wywoływana dla |
|---|---|---|
__init__ |
Konstruktor | Tworzenie obiektu - x = Klasa(args) |
__del__ |
Destructor | Zwolnienie obiektu x |
__add__ |
Operator + |
x + y, x += y, jeśli nie ma __iadd__ |
__or__ |
Operator ` | ` (OR poziomu bitowego) |
__repr__, __str__ |
Wyświetlanie, konwersje | print(x), repr(x), str(x) |
__call__ |
Wywołanie funkcji | x(*args, **kargs) |
__getattr__ |
Odczytanie atrybutu | x.niezdefiniowany_atrybuty |
__setattr__ |
Przypisanie atrybutu | x.atrybut = wartosc |
__delattr__ |
Usuwanie atrybutu | del x.atrybut |
__getattribute__ |
Przechwytywanie atrybutu | x.atrybut |
__getitem__ |
Indeksowanie, wycinanie, iteracje | x[klucz], x[i:j], pętle for oraz inne iteracje, jeśli nie ma __iter__ |
__setitem__ |
Przypisanie indeksu i wycinka | x[klucz] = wartosc, x[i:j] = sekwencja |
__delitem__ |
Usuwanie indeksu i wycinka | del x[klucz], del x[i:j] |
__len__ |
Długość | len(x), testy prawdziwości, jeśli nie ma __bool__ |
__bool__ |
Testy logiczne | bool(x), testy prawdziwości |
__lt__, __gt__, __le__, __ge__, __eq__, __ne__ |
Porównania | x < y, x > y, x <= y, x >= y, x == y, x != y |
__radd__ |
Prawostronny operator + |
nieinstancja + x |
__iadd__ |
Dodawanie w miejscu (rozszerzone) | x += y |
__iter__, __next__ |
Konteksty iteracyjne | i = iter(x), next(i); pętle for, jeśli nie ma __contains__, testy in, wszystkie listy składanie, funkcje map(f,x) |
__contains__ |
Test przynależności | item in x (dowolny iterator) |
__index__ |
Wartość całkowita | hex(x), bin(x), oct(x), o[x], o[x:] |
__enter__, __exit__ |
Menedżer konktekstu | with obj as var: |
__get__, __set__, __delete__ |
Atrybuty deskryptorów | x.attr, x.attr = value, del x.attr |
__new__ |
Tworzenie instancji | Tworzenie instancji, przed __init__ |
Tworzenie klas - przykład
Zadanie
Wejdź do repozytorium, zapoznaj się z gotowym kodem w pliku run_zajecia04.py oraz modułami w folderze src/zajecia04.
Zwróć uwagę na:
- Tworzenie konstruktorów -
__init__, - Dodawanie method,
- Tekstową reprezentację klas -
__str__oraz__repr__(jak przykład przeciążania operatorów), - Dostosowywanie klas poprzez klasy podrzędne.
Narzędzia introspekcji
Introspekcja pozwala na dynamiczne badanie obiektów, ich struktur oraz cech w czasie działania programu.
__class__
Atrybut ten pozwala na sprawdzenie klasy, do której należy dany obiekt.
class Zwierze:
pass
class Pies(Zwierze):
pass
reksio = Pies()
print(reksio.__class__) # <class '__main__.Pies'>
print(reksio.__class__.__name__) # Pies
__dict__
Atrybut ten to słownik, który przechowuje wszystkie atrybuty instancji obiektu. Dzięki dict można dynamicznie uzyskać dostęp do atrybutów obiektu, modyfikować je, dodawać now lub iterować po nich.
class Samochod:
def __init__(self, marka, model, rok):
self.marka = marka
self.model = model
self.rok = rok
moj_samochod = Samochod("Toyota", "Corolla", 2020)
print(moj_samochod.__dict__) # {'marka': 'Toyota', 'model': 'Corolla', 'rok': 2020}
Abstrakcyjne klasy nadrzędne
Są to klasy służące jako szablony, które definiują strukturę i wymuszone metody dla klas pochodnych, ale same nie mogą być inicjalizowane. Używają dekoratora @abstractmethod do oznaczenia method, które muszą być zaimplementowane w klasach pochodnych.
Korzyści wynikające z wykorzystywania abstrakcyjnych klas nadrzędnych
- Spójność – wymusza implementację określonych method.
- Reużywalność – pozwala dzielić metody między klasami.
- Polimorfizm – umożliwia jednolite używanie różnych klas.
from abc import ABC, abstractmethod
class Zwierze(ABC):
# Ta metoda jest abstrakcyjna,
# wymagana jest jej implementacja w klasach pochodnych
@abstractmethod
def wydaj_dzwiek(self):
pass
class Pies(Zwierze):
def wydaj_dzwiek(self):
return "Hau hau!"
class Kot(Zwierze):
def wydaj_dzwiek(self):
return "Miau miau!"
Atrybuty pseudoprywatne
Atrybuty, których nazwy zaczynają się od dwóch podkreślników, np. __nazwa. Taka konwencja nazw powoduje, że Python stosuje name mangling – czyli zmienia nazwę atrybutu w taki sposób, że jest trudniej dostępna z zewnątrz klasy, ale nie jest całkowicie prywatna. Jest to bardziej forma ochrony, niż pełne ukrycie atrybutów.
class MojaKlasa:
def __init__(self):
self.__ukryty_atrybut = "tajne"
def pokaz_ukryty(self):
return self.__ukryty_atrybut
obiekt = MojaKlasa()
print(obiekt.pokaz_ukryty()) # Poprawne: "tajne"
print(obiekt._MojaKlasa__ukryty_atrybut) # Dostęp poprzez name mangling: "tajne"
Po co używać atrybutów pseudoprywatnych?
- Ochrona przed przypadkowym nadpisaniem – przy dziedziczeniu klasy istnieje mniejsze ryzyko, że klasa pochodna przypadkowo nadpisze atrybut o tej samej nazwie.
- Czytelność – pokazują, że atrybut nie jest przeznaczony do bezpośredniego użytku z zewnątrz klasy.
Rozszerzanie typów wbudowanych
Może być przydatne, gdy chcemy dodać dodatkową funkcjonalność lub dostosować zachowanie istniejących typów (np. list, dict, str) do specyficznych potrzeb projektu.
Za pomocą osadzania (kompozycja)
Poprzez utworzenie nowej klasy, która wewnętrznie przechowuje instancję typu wbudowanego jako atrybut.
W ten sposób klasa ta może wykorzystywać typ wbudowany i rozszerzać jego funkcjonalność, delegując operacje na ten typ, ale nie dziedziczy jego method bezpośrednio. Osadzanie jest przydatne, gdy chcemy dodać now funkcje bez ingerencji w istniejące metody typu wbudowanego.
class MojaLista:
def __init__(self, elementy):
self._lista = elementy # Osadzenie typu wbudowanego list
def suma(self):
return sum(self._lista)
def dodaj(self, element):
self._lista.append(element)
def __str__(self):
return str(self._lista)
moja_lista = MojaLista([1, 2, 3])
moja_lista.dodaj(4)
print(moja_lista) # [1, 2, 3, 4]
print(moja_lista.suma()) # 10
Za pomocą klas podrzędnych (dziedziczenia)
Poprzez utworzenie klasy podrzędnej, która dziedziczy po typie wbudowanym. Dzięki temu klasa podrzędna automatycznie przejmuje wszystkie metody i atrybuty typu bazowego, co pozwala na łatwe dodanie nowych funkcji lub nadpisanie istniejących method.
class MojaLista(list):
def suma(self):
return sum(self)
moja_lista = MojaLista([1, 2, 3, 4])
print(moja_lista) # [1, 2, 3, 4]
print(moja_lista.suma()) # 10
Za i przeciw dla obu sposobów
| Za | Przeciw | |
|---|---|---|
| Osadzanie | Daje pełną kontrolę nad metodami, które są dostępne dla użytkownika klasy. Izoluje funkcjonalność rozszerzonego typu od interfejsu klasy bazowej, co może zwiększyć bezpieczeństwo i ułatwić utrzymanie kodu. | Wymaga ręcznego implementowania delegacji method, jeśli potrzebujemy pełnego dostępu do funkcji typu wbudowanego. Może być mniej wydajne i bardziej czasochłonne niż dziedziczenie, jeśli chcemy używać większości method typu wbudowanego. |
| Dziedziczenie | Klasa pochodna automatycznie przejmuje wszystkie metody typu wbudowanego, co ułatwia tworzenie nowych funkcji. Jest bardziej ekonomiczne i intuicyjne w implementacji, szczególnie gdy potrzebujemy tylko kilku dodatkowych funkcji. | Trudniej jest zmodyfikować sposób działania niektórych method w typach wbudowanych, ponieważ metody te mogą wywoływać bezpośrednie operacje na strukturze danych. Dziedziczenie może prowadzić do problemów z nieoczekiwanym zachowaniem, jeśli metody typu wbudowanego nie są dobrze przystosowane do nowych funkcji klasy pochodnej. |
Sloty
Specjalny mechanizm, który pozwala na optymalizację pamięci obiektów klasy, poprzez ograniczenie listy atrybutów, które można dodać do instancji danej klasy.
class Osoba:
__slots__ = ['imie', 'wiek'] # Ograniczamy atrybuty tylko do 'imie' i 'wiek'
def __init__(self, imie, wiek):
self.imie = imie
self.wiek = wiek
osoba = Osoba("Jan", 30)
print(osoba.imie) # Jan
print(osoba.wiek) # 30
# Próba dodania nowego atrybutu zgłosi błąd
osoba.address = "Warszawa" # AttributeError: 'Osoba' object has no attribute 'address'
Python przestaje używać dynamicznego słownika __dict__ do przechowywania atrybutów obiektu, co ogranicza możliwość dodawania nowych atrybutów poza tymi zdefiniowanymi w __slots__, ale jednocześnie zmniejsza ilość zużywanej pamięci.
Rodzaje method w klasach
Metody instancji
Domyślny sposób działania, jako pierwszy argument przyjmują self, który odnosi się do instancji.
Zastosowanie
Operacje na instancji.
Metody klasy
Deklarowane za pomocą dekoratora @classmethod. Przyjmują jako pierwszy argument cls, który odnosi się do samej klasy, a nie jej instancji.
class Pracownik:
liczba_pracownikow = 0 # Atrybut klasy
def __init__(self, imie, stanowisko):
self.imie = imie
self.stanowisko = stanowisko
Pracownik.liczba_pracownikow += 1
@classmethod
def z_nazwiska(cls, nazwisko):
# Alternatywny konstruktor
return cls(nazwisko, 'Nieznane stanowisko')
@classmethod
def ustaw_liczbe_pracownikow(cls, liczba):
cls.liczba_pracownikow = liczba
# Tworzenie instancji za pomocą metody klasy
nowy_pracownik = Pracownik.z_nazwiska('Kowalski')
print(nowy_pracownik.imie) # Kowalski
print(nowy_pracownik.stanowisko) # Nieznane stanowisko
Zastosowanie
Operacje na klasie, alternatywne konstruktory.
Metody statyczne
Deklarowane za pomocą dekoratora @staticmethod. Nie przyjmują żadnego specjalnego pierwszego argumentu i nie mają dostępu ani do instancji (self), ani do klasy (cls).
class Kalkulator:
@staticmethod
def dodaj(a, b):
return a + b
@staticmethod
def odejmij(a, b):
return a - b
# Wywoływanie method statycznych
print(Kalkulator.dodaj(5, 3)) # 8
print(Kalkulator.odejmij(10, 4)) # 6
Zastosowanie
Funkcje pomocnicze powiązane tematycznie.
Zadanie
Zapoznaj się z metodami w klasie z src.zajecia04.fleet.ambulance.
Zadania
Dodaj następujące funkcjonalności do programu przedstawionego jako przykład. Dla wszystkich dodanych funkcjonalności stwórz przykładowy kod potwierdzający, że działają (rozbudowując kod w run_zajecia04.py).
-
Zmodyfikuj każdą klasę tak, żeby posiadała atrybut
__max_id, który będzie wykorzystywany do nadawania identyfikatorów kolejnym stworzonym instancjom (zamiast podawania go jako argument przy inicjalizacji). -
Rozbuduj klasę Incident o priorytet zdarzenia, czas zgłoszenia i informacje o zgłaszającym.
-
Zaprojektuj w ramach subpakietu
fleetklasęStation, każda stacja ma posiadać identyfikator, lokalizację, karetkę, kierowcę i 1 dodatkowego pracownika. Napisz metodę, która sprawdza czy karetka jest na miejscu (czy zgadzają się lokalizacje). -
Rozbuduj aplikację (w tym zaprojektuj logikę, ale także elementy, które trzeba dodać w różnych klasach (niekoniecznie istniejących) o możliwość zarządzania incydentami – przydzielanie karetek do zgłaszanych zdarzeń. Te funkcjonalności muszą uwzględniać:
- Priorytet i czas, który upłynął od zgłoszenia,
- Aktualny stan, w którym znajduje się karetka,
- Odległość karetki od zdarzenia.