Zaawansowana Inżynieria w Pythonie
Zaawansowane Programowanie Obiektowe
Magia metod specjalnych
Wiesz już, jak tworzyć klasy i obiekty w Pythonie. Ale czy zastanawiałeś się kiedyś, jak to się dzieje, że możesz dodawać dwie liczby za pomocą znaku + albo sprawdzać długość listy funkcją len()? Odpowiedzią są metody specjalne, często nazywane metodami „dunder” (od podwójnego podkreślenia, ang. double underscore).
To one pozwalają obiektom Twoich klas zachowywać się jak wbudowane typy Pythona. Definiując metody takie jak __add__ czy __len__, dajesz swoim obiektom nowe supermoce. Zamiast pisać vector1.add(vector2), możesz po prostu użyć operatora vector1 + vector2. To sprawia, że kod staje się bardziej intuicyjny i czytelny.
class Wektor:
def __init__(self, x, y):
self.x = x
self.y = y
def __add__(self, inny):
# Ta metoda jest wywoływana, gdy używamy operatora +
return Wektor(self.x + inny.x, self.y + inny.y)
def __repr__(self):
# Ta metoda kontroluje, jak obiekt jest wyświetlany
return f"Wektor({self.x}, {self.y})"
# Użycie
w1 = Wektor(2, 3)
w2 = Wektor(5, 1)
w3 = w1 + w2 # Python w tle wywołuje w1.__add__(w2)
print(w3) # Wyświetli: Wektor(7, 4)
Metody dunder pozwalają na przeciążanie operatorów, co jest kluczem do pisania kodu w stylu, który nazywamy „pythonicznym” – idiomatycznym i eleganckim.
Ukrywanie danych z @property
Enkapsulacja to jeden z filarów programowania obiektowego. Chodzi o to, by ukryć wewnętrzny stan obiektu i udostępnić tylko kontrolowany interfejs do interakcji z nim. W Pythonie nie mamy słów kluczowych private czy public, jak w innych językach. Zamiast tego używamy konwencji – atrybuty zaczynające się od jednego podkreślenia (np. _nazwa) są traktowane jako wewnętrzne.
Ale co, jeśli chcemy dodać logikę podczas odczytu lub zapisu atrybutu? Tutaj z pomocą przychodzi dekorator . Pozwala on zdefiniować metodę, która zachowuje się jak atrybut. Dzięki temu możesz dodać walidację lub inne operacje, nie zmieniając sposobu, w jaki użytkownicy Twojej klasy z niej korzystają.
class Pracownik:
def __init__(self, imie, pensja):
self.imie = imie
self._pensja = pensja # Konwencja: atrybut wewnętrzny
@property
def pensja(self):
"""To jest 'getter' dla pensji."""
print("Pobieranie pensji...")
return self._pensja
@pensja.setter
def pensja(self, wartosc):
"""To jest 'setter' dla pensji z walidacją."""
if wartosc < 0:
raise ValueError("Pensja nie może być ujemna!")
print("Ustawianie pensji...")
self._pensja = wartosc
jan = Pracownik("Jan", 5000)
# Dostęp jak do zwykłego atrybutu, ale wywołuje metodę @property
print(jan.pensja) # Wyświetli: Pobieranie pensji... 5000
# Zapis jak do atrybutu, ale wywołuje metodę @pensja.setter
jan.pensja = 6000 # Wyświetli: Ustawianie pensji...
# jan.pensja = -100 # To rzuciłoby wyjątek ValueError
Dziedziczenie wielokrotne i MRO
Python, w przeciwieństwie do niektórych języków, pozwala klasie dziedziczyć po wielu klasach nadrzędnych. To potężne narzędzie, ale niesie ze sobą pewne wyzwanie: co, jeśli dwie klasy nadrzędne mają metodę o tej samej nazwie? Którą z nich powinna wywołać klasa dziedzicząca?
Problem ten rozwiązuje algorytm (MRO). Definiuje on dokładną, przewidywalną kolejność, w jakiej Python przeszukuje hierarchię klas w poszukiwaniu metody. Możesz sprawdzić MRO dla dowolnej klasy, używając atrybutu __mro__ lub metody .mro().
class A:
def ping(self):
print('ping w A')
class B(A):
def pong(self):
print('pong w B')
class C(A):
def ping(self):
print('ping w C')
class D(B, C):
def pingpong(self):
print('pingpong w D')
d = D()
d.ping() # Wyświetli 'ping w C'
# Sprawdźmy MRO dla klasy D
# Kolejność: D -> B -> C -> A -> object
print(D.mro())
W powyższym przykładzie, mimo że B jest pierwsze na liście dziedziczenia dla D, Python wybiera metodę ping z klasy C. Dzieje się tak, ponieważ algorytm MRO dba o to, by najpierw przeszukać wszystkie klasy nadrzędne w danej gałęzi, zanim przejdzie do następnej.
Kompozycja kontra dziedziczenie
Dziedziczenie to potężny mechanizm, ale bywa nadużywany. Często prowadzi do tworzenia sztywnych i skomplikowanych hierarchii klas. Złota zasada projektowania obiektowego brzmi: (ang. favor composition over inheritance).
Na czym polega różnica? Dziedziczenie tworzy relację "jest" (np. Samochod jest Pojazdem). Kompozycja tworzy relację "ma" (np. Samochod ma Silnik). W kompozycji obiekt jednej klasy zawiera w sobie obiekt innej klasy jako swój atrybut. To podejście promuje małe, wyspecjalizowane klasy, które można łatwo łączyć w bardziej złożone struktury.
| Cecha | Dziedziczenie | Kompozycja |
|---|---|---|
| Typ relacji | "Jest" (is-a) | "Ma" (has-a) |
| Powiązanie | Ścisłe (tight coupling) | Luźne (loose coupling) |
| Elastyczność | Mniejsza, statyczna | Większa, dynamiczna |
| Ponowne użycie | Ponowne użycie implementacji | Ponowne użycie interfejsu |
| Kiedy używać | Gdy podklasa jest prawdziwą specjalizacją nadklasy | Gdy obiekt potrzebuje określonej funkcjonalności lub danych |
Definiowanie interfejsów z ABC
W dużych projektach często chcemy zdefiniować pewien "kontrakt" lub interfejs, który muszą spełniać różne klasy. Na przykład, możemy chcieć, aby każda klasa reprezentująca źródło danych miała metodę read(). Jak to wymusić?
Z pomocą przychodzą Klasy Abstrakcyjne Bazowe (Abstract Base Classes, ABC) z modułu abc. Klasa abstrakcyjna to taka, której nie można bezpośrednio utworzyć obiektu. Służy ona jako szablon dla innych klas. Możemy w niej zdefiniować metody abstrakcyjne – metody bez implementacji. Każda klasa, która dziedziczy po klasie abstrakcyjnej, musi zaimplementować wszystkie jej metody abstrakcyjne. W przeciwnym razie Python zgłosi błąd.
from abc import ABC, abstractmethod
class ZrodloDanych(ABC):
@abstractmethod
def read(self):
"""Odczytuje dane ze źródła."""
pass
class PlikLokalny(ZrodloDanych):
def read(self):
print("Czytam dane z pliku lokalnego...")
return "dane_z_pliku"
class AdresAPI(ZrodloDanych):
def read(self):
print("Pobieram dane z API...")
return "dane_z_api"
class BazaDanych(ZrodloDanych):
# Ta klasa nie implementuje metody read(), więc jest nieprawidłowa
def connect(self):
print("Łączę z bazą danych...")
# Użycie
plik = PlikLokalny()
api = AdresAPI()
# To zadziała
print(plik.read())
print(api.read())
# To rzuci błąd TypeError, ponieważ nie można utworzyć instancji klasy abstrakcyjnej
# db = BazaDanych()
Używanie ABC to świetny sposób na budowanie solidnych i przewidywalnych architektur oprogramowania, gdzie różne komponenty mogą na sobie polegać, wiedząc, że spełniają określony interfejs.
Czas sprawdzić, co zapamiętałeś.
Która metoda specjalna (dunder) musi zostać zaimplementowana w klasie, aby można było używać wbudowanej funkcji len() na jej obiektach?
Jaki jest główny cel stosowania dekoratora @property w klasie Pythona?
Opanowanie tych zaawansowanych koncepcji obiektowych pozwoli Ci pisać bardziej elastyczny, skalowalny i łatwiejszy w utrzymaniu kod w Pythonie.
