Zaczynając swoją przygodę z programowaniem, w zdecydowanej większości przypadków kieruje nami chęć zmuszenia maszyny (najczęściej komputera), aby coś dla nas zrobiła. W tym celu musimy znaleźć sposób, jak się z nią porozumieć, stąd też wybieramy odpowiedni – z punktu widzenia naszych upodobań lub zadania, które przed nami stoi – język programowania.
Aby komunikacja była skuteczna niezbędne jest poznanie instrukcji, które umożliwią nam osiągnięcie zamierzonego celu, czyli po prostu składni. Wraz z kolejnymi etapami nauki dowiadujemy się coraz więcej o strukturach i zagadnieniach typowych dla danego języka, dlatego zapewne prędzej czy później przyjdzie nam zmierzyć się również z tzw. programowaniem obiektowym.
W tym artykule chciałbym przybliżyć, posługując się językiem Python, czym charakteryzuje się ten konkretny rodzaj programowania, jak poprawnie go stosować oraz kiedy jest to zalecane.
Programowanie obiektowe – czym jest?
Programowanie zorientowane obiektowo (ang. object-oriented programming, OOP) to sposób tworzenia oprogramowania, w którym obiekty ze świata rzeczywistego przedstawiamy jako obiekty programowe, gdzie każdy z nich posiada swoje cechy (zwane atrybutami) oraz może wykonywać jakieś czynności (nazywane metodami). Innymi słowy – łączymy dane z operacjami, które mogą być na tych danych przeprowadzane.
Wyobraźmy sobie, że mamy zaprogramować prostego robota imitującego zachowania psa. W takiej sytuacji możemy przedstawić go w formie obiektu programowego o nazwie Dog o następujących atrybutach:
- imię,
- wiek,
- waga,
- rasa,
- kolor umaszczenia.
Ponadto, jak w przypadku typowego psa, chcemy, aby nasz robot potrafił:
- szczekać,
- aportować,
- merdać ogonem,
co, stosując zasady programowania obiektowego, implementujemy w formie metod.
Oczywiście jest to jedynie dość barwna próba zobrazowania, jak obiektowość może zostać wykorzystana. Zazwyczaj jednak komercyjne oprogramowanie zawiera bardziej abstrakcyjne koncepcje takie jak:
- klient poczty email,
- instancja bazy danych,
- koszyk w sklepie internetowym.
Generalnie rzecz biorąc, taka forma pozwala nam w dość prosty sposób odwzorować dowolne obiekty z naszego otoczenia oraz występujące pomiędzy nimi relacje.
Klasy i obiekty w programowaniu obiektowym
No dobrze. Teoria teorią, ale jak to wszystko wygląda w praktyce? Czym tak naprawdę są te klasy, obiekty, metody itp., i jak je tworzyć?
Ogólna definicja klasy jest wspólna dla wszystkich języków wspierających paradygmat programowania obiektowego i mówi, że klasa to nic innego jak szablon, na podstawie którego tworzone są obiekty. Zawiera więc ona wszystkie elementy niezbędne do utworzenia (lub, jak kto woli, skonkretyzowania) interesującego nas obiektu. Natomiast sam obiekt, to reprezentacja danej klasy (instancja) utworzona zgodnie z deklaracją tej klasy.
Dla lepszego zrozumienia tematu można posłużyć się inną analogią: klasa jest jak przepis kuchenny zawierający zestaw instrukcji, zaś obiekt to ciasto wykonane na podstawie tego przepisu. Takich obiektów możemy mieć dowolną ilość i każdy z nich będzie miał przypisany typ odpowiadający klasie, na podstawie której został utworzony.
Tworzenie klas
class DogClass:
pass
Powyżej zdefiniowaliśmy sobie naszą pierwszą klasę o nazwie DogClass. Jak widać, w tym celu konieczne było użycie słowa kluczowego class, a następnie podanie nazwy. Zgodnie ze standardem PEP8 (z którym zachęcam, aby każdy się zapoznał!) w Pythonie nazwy klas podajemy, używając tzw. CamelCase tj. rozpoczynając każde słowo wielką literą z pominięciem spacji pomiędzy nimi. Na tę chwilę ciało naszej klasy jest puste (pass). W kolejnych krokach będziemy ją rozbudowywać oraz tworzyć obiekty na jej podstawie.
Komentarz: w Pythonie wszystko jest obiektem, tzn. wszystko jest reprezentacją jakiejś klasy. Przykładowo, posiadając dowolną wartość tekstową (string), np. “Some text value”, jest ona niczym innym, jak po prostu reprezentacją klasy str (mówiąc inaczej: posiada typ str). Definiując własne klasy, tworzymy tak naprawdę nowe typy.

Co w takim razie może zawierać klasa, aby jej definicja miała sens oraz mogła być później wykorzystana? Otóż, klasy posiadają dwie główne składowe, którymi są atrybuty i metody.
Atrybuty
Atrybuty możemy zdefiniować po prostu jako cechy, które będzie posiadał obiekt. To nic innego jak zmienne mające jakąś wartość.
Wyróżniamy:
- atrybuty instancji – jak wskazuje nazwa, odnoszą się do instancji (obiektów) i określają, jakie cechy dana instancja posiada. Dzięki nim jesteśmy w stanie odróżnić od siebie wiele obiektów tej samej klasy. Tworzymy je zazwyczaj w konstruktorze __init__ (inaczej: metodzie inicjalizacji), a ich nazwy są poprzedzone przedrostkiem self. Odwołanie do atrybutów instancji poza ciałem klasy odbywa się jedynie z wykorzystaniem obiektu. Nie ma możliwości np. zmiany wartości atrybutu instancji bez posiadania instancji tej klasy: nie istnieje instancja → nie istnieją jej atrybuty.
Komentarz: “self” to parametr odnoszący się do instancji klasy. Dzięki niemu wiemy, że dany atrybut lub metoda dotyczy właśnie instancji i może zmieniać jej stan. “self” nie jest słowem kluczowym, a jedynie powszechnie przyjętą konwencją, dlatego można je zastąpić dowolnym słowem, aczkolwiek nie jest to zalecane, ponieważ może wprowadzać w błąd. - atrybuty klasy – zwane również statycznymi. Przechowują dane specyficzne dla klasy i nie odwołują się w żaden sposób do obiektów (nie opisują ich). Tworzymy je najczęściej bezpośrednio po nazwie klasy bez użycia przedrostka self. Można korzystać z nich zarówno z poziomu instancji jak i bez posiadania jakiegokolwiek obiektu danej klasy poprzez notację z kropką: Klasa.atrybut.
class Dog:
# class attributes
division = "mammal"
counter = 0
def __init__(self, name, age):
# instance attributes
self.name = name
self. Age = age
self.life_expectancy = 12
Dog.counter += 1
if __name__ == '__main__':
# usage of class attributes
print(f"Division: {Dog.division}")
doggie = Dog("Pluto", 5) # object initialization with parameters
print(f"Number of dogs: {doggie.counter}") # alternatively: Dog.counter
# usage of instance attributes
print(f"Name: {doggie.name}")
print(f"Age: {doggie.age}")
doggie.life_expectancy = 14 # object attribute modification
print(f"Expected length of life: {doggie.life_expectancy}")
# Output:
# Division: mammal
# Number of dogs: 1
# Name: Pluto
# Age: 5
# Expected length of life: 14
Metody
Metody to nic innego, jak funkcje zdefiniowane wewnątrz klasy. Je z kolei możemy podzielić na trzy rodzaje:
- metody instancji – podobnie jak w przypadku atrybutów instancji, dotyczą wszystkich tworzonych obiektów i wpływają na ich stan (np. modyfikują ich atrybuty). Tworząc metodę instancji, jako pierwszy parametr zawsze przekazujemy self, a dopiero po nim kolejne. self wskazuje jedynie, że odnosimy się do instancji i nie przekazujemy jego wartości podczas wywoływania danej metody.
- metody statyczne – choć są częścią klasy, to nie widzą innych jej elementów i nie muszą mieć z klasą żadnego związku (są niezależne). Nie odwołują się również do obiektów, dlatego nie posiadają parametru self. Ich definicję poprzedzamy dekoratorem @staticmethod, a wywołanie odbywa się poprzez nazwę klasy, w której się zawierają (Klasa.metoda_statyczna()).
- metody klasy – pracują na poziomie klasy. Podobnie jak metody statyczne, nie wymagają instancji, ale są świadome bycia częścią klasy i mogą odwoływać się do pozostałych jej metod (również statycznych). Tworzymy je z użyciem dekoratora @classmethod oraz poprzez przekazanie parametru cls.
Komentarz: Parametr “cls”, podobnie jak “self”, jest konwencją nazewniczą. Odnosi się do klasy i jest używany wraz z dekoratorem @classmethod. Umożliwia dostęp do atrybutów i metod klasy, dzięki czemu modyfikuje jej stan (nie wpływa jednak bezpośrednio na stan instancji!).
- metody specjalne (ang. special methods, dunder methods od “double underscore”) – zwane również metodami magicznymi. Są to metody rozpoczynające się oraz zakończone podwójnym znakiem podkreślenia oraz wywoływane przez Pythona w określonych sytuacjach. Metody magiczne mogą odwoływać się do instancji dzięki parametrowi self lub do samej klasy za pomocą cls. Najczęściej spotykaną jest metoda inicjalizacji __init__ (konstruktor), która jest wywoływana automatycznie podczas tworzenia instancji klasy.
class Dog:
# class attributes
division = "mammal"
counter = 0
def __init__(self, name, age):
# instance attributes
self.name = name
self.age = age
self.life_expectancy = 13
Dog.counter += 1
def __str__(self):
return f"Welcome {self.name} (age: {self.age}, division: {Dog.division}) to the world!"
@classmethod
def get_number_of_dogs(cls):
print(f"Currently there is {cls.counter} dog(s)!")
@staticmethod
def get_average_cost(total_cost, years):
avg_cost = total_cost / years
print(f"The average cost is {avg_cost}$ per year")
return avg_cost
def is_hungry(self, meals, hours_of_fun):
calories_eaten = meals * 150
calories_burnt = hours_of_fun * 72
if calories_eaten - calories_burnt < 350:
print(f"{self.name} has been playing for {hours_of_fun} hours and he is hungry!")
else:
print(f"{self.name} is fine")
if __name__ == '__main__':
doggie = Dog("Pluto", 5) # __init__ magic method is used
print(doggie) # __str__ magic method is used
Dog.get_number_of_dogs() # usage of class method
doggie.is_hungry(3, 2) # usage of instance method
cost_1 = Dog.get_average_cost(52, 2) # usage of static method via class
cost_2 = doggie.get_average_cost(1273, 5) # usage of static method via instance
# Output:
# Welcome Pluto (age: 5, division: mammal) to the world!
# Currently there is 1 dog(s)!
# Rex has been playing for 2 hours and he is hungry!
# The average cost is 26.0$ per year
# The average cost is 254.6$ per year
Cechy programowania obiektowego
Każdy język programowania, aby mógł być uznawany za język zorientowany obiektowo, powinien posiadać przynajmniej cztery podstawowe cechy, którymi są:
- abstrakcja,
- hermetyzacja,
- dziedziczenie,
- polimorfizm.
Abstrakcja
Programowanie obiektowe służy do definiowania rzeczywistych obiektów, które, jak wiadomo, mogą mieć niemal nieskończoną liczbę cech i właściwości. W zasadzie każdy otaczający nas obiekt jest na swój sposób unikalny. Nie ma przecież dwóch idealnie identycznych samochodów, krzeseł czy stołów. Abstrakcja pozwala określić pewien ogół z pominięciem nieistotnych detali oraz skupić się tylko na tym, co istotne.
I tak, chcąc przedstawić za pomocą abstrakcji dowolne auto, nie będziemy koncentrować się na jego kolorze, prędkości maksymalnej czy wyposażeniu – to wszystko szczegóły. Interesować nas będzie tylko to, co wspólne dla wszystkich samochodów, np.: fakt, że posiadają one kierownicę, skrzynię biegów, hamulce czy możliwość jazdy. Abstrakcja umożliwia więc skupienie się na ogólnym obrazie bez zagłębiania się w szczegóły – jest to rodzaj uproszczenia rozpatrywanego problemu (klasy/obiektu).
Z abstrakcją nieodzownie łączy się pojęcie klasy abstrakcyjnej, która jest bazą dla innych klas. Zawiera ona metody wspólne dla klas tworzonych w oparciu o nią (tzw. klas potomnych, tj. dziedziczących z niej), a także nie posiada swoich bezpośrednich reprezentacji (nie tworzy się jej instancji). Klasa abstrakcyjna w Pythonie jest szczególnie ważna w kontekście tworzenia interfejsów, ponieważ język ten, w odróżnieniu np. od Javy, nie posiada w swojej składni struktur służących bezpośrednio do ich budowy. To właśnie za pomocą klasy abstrakcyjnej jesteśmy w stanie zdefiniować dowolny interfejs, tzn. określić zbiór wszystkich metod, jakie muszą posiadać klasy dziedziczące.
from abc import ABC, abstractmethod
class Animal(ABC):
@abstractmethod
def move(self):
pass
@abstractmethod
def eat(self):
pass
@abstractmethod
def make_sound(self):
pass
Definiując własną klasę abstrakcyjną, musi ona dziedziczyć po klasie ABC z wbudowanego modułu abc. Metody abstrakcyjne poprzedzane są z kolei dekoratorem @abstractmethod. Pomimo, iż istnieje możliwość zdefiniowania ich logiki, to powinny one pozostać puste. Klasa abstrakcyjna jest szablonem dla innych klas, dlatego nigdy nie tworzymy jej instancji.
Hermetyzacja
Hermetyzacja, zwana również enkapsulacją (ang. encapsulation), to cecha, która pozwala odseparować niezależny kod poprzez ukrycie jego szczegółów. W przypadku tworzenia i używania zwykłych funkcji oznacza to, że żadna zmienna zdefiniowana w ciele funkcji nie jest dostępna poza nią. To dlatego w celu przekazania interesującej nas wartości na zewnątrz używa się parametrów i wartości zwrotnych.
W OOP natomiast hermetyzacja dotyczy atrybutów obiektu, które powinny być ukryte przed klientami, czyli przed tą częścią programu, która tych obiektów używa. Dostęp do szczegółów obiektu jest możliwy jedynie poprzez użycie interfejsów, czyli zestawu metod dostępowych, które w kontrolowany sposób umożliwiają modyfikację danych wewnątrz obiektów.
Innymi słowy, komunikacja z obiektami powinna odbywać się, podobnie jak w przypadku zwykłych funkcji, za pomocą parametrów przekazywanych do metod i zwracanych przez nie wartości (należy unikać bezpośredniego wpływu na atrybuty obiektu przez klienta). Takie podejście pozwala zachować integralność danych i ogranicza ryzyko popełnienia błędu.
Przykład hermetyzacji
Za przykład może posłużyć obiekt klasy Bank posiadający atrybut saldo oraz metody: wpłać, wypłać i sprawdź stan konta. Nie chcemy, aby klient mógł bezpośrednio zmienić saldo konta bankowego poprzez przypisanie mu dowolnej wartości, dlatego potencjalna modyfikacja jest ograniczona do metod wpłać i wypłać. To do nich przekazujemy określoną wartość i to one odpowiadają za zmianę stanu konta w zależności od tego, jaką operację klient chce przeprowadzić. Dzięki temu możemy w prosty sposób nie dopuścić do sytuacji, gdzie stan konta będzie posiadał np. wartość ujemną, zaś próba wypłacenia zbyt dużej ilości gotówki zwróci błąd.
W Pythonie wszystkie metody i atrybuty obiektu są domyślnie publiczne, co powoduje, że klient może się do nich bezpośrednio odnosić i je wywoływać. W celu ograniczenia dostępu konieczne jest zastosowanie mechanizmu znanego jako „name mangling”. Odbywa się to poprzez poprzedzenie nazwy metod i atrybutów podwójnym znakiem podkreślenia.
class BankAccount:
def __init__(self, account_name):
self.account_name = account_name # public attribute
self._operations = 0 # protected attribute
self.__account_balance = 0 # private attribute
def deposit_money(self, amount):
self.__account_balance += amount
self._operations += 1
print(f"You deposited ${amount}")
def withdraw_money(self, amount):
if amount > self.__account_balance:
raise ValueError(f"You don't have enough money!")
self.__account_balance -= amount
self._operations += 1
print(f"You withdrew ${amount}")
def get_balance(self):
print(f"You have ${self.__account_balance} in your account")
if __name__ == '__main__':
my_account = BankAccount("My private account")
my_account.get_balance()
my_account.deposit_money(1000)
my_account.get_balance()
my_account.withdraw_money(500)
my_account.get_balance()
print("Number of operations:", my_account._operations)
print(my_account.__account_balance) # AttributeError
# Output:
# You have $0 in your account
# You deposited $1000
# You have $1000 in your account
# You withdrew $500
# You have $500 in your account
# Number of operations: 2
# Traceback (most recent call last):
# File "C:\PycharmProjects\OOP_project\encapsulation_2.py", line 33, in <module>
# print(my_account.__account_balance) # AttributeError
# AttributeError: 'BankAccount' object has no attribute '__account_balance'
Atrybuty lub metody zdefiniowane za pomocą prywatnego modyfikatora dostępu (jak wyżej) są dostępne tylko poprzez inne metody tego samego obiektu. Jest to jednak głównie konwencja, której łamanie choć nie jest zalecane, to istnieje sposób na ich bezpośrednie wywołanie poza definicją klasy, dlatego ciężko tutaj mówić o całkowitym zabezpieczeniu.
Ponadto, możemy spotkać się również z elementami zdefiniowanymi z przedrostkiem w formie pojedynczego podkreślenia, które są traktowane jako „internal use”. Ich modyfikacja jest dopuszczalna tylko w klasie, w której zostały zadeklarowane oraz – dodatkowo – w jej klasach potomnych, jednakże Python nie blokuje bezpośredniego dostępu do nich.
Dziedziczenie
Dziedziczenie jest kluczową cechą programowania obiektowego, umożliwiającą tworzenie nowych klas na podstawie klas już istniejących. Jest to szczególnie użyteczne w sytuacji, gdy obiekty choć różnią się między sobą pod pewnymi względami, to posiadają też wiele cech wspólnych.
Dziedziczenie w OOP jest oparte na koncepcji relacji rodzic-dziecko. Rodzic, czyli tzw. Klasa nadrzędna (nadklasa, ang. Superclass), to klasa, która już istnieje i posiada pewien zestaw cech (metod, atrybutów). Z kolei dziecko (klasa podrzędna, potomna, ang. Subclass) to nowa klasa, która nie dość, że przejmuje (dziedziczy) cechy klasy już istniejącej, to dodatkowo może je nadpisywać lub rozszerzać oraz posiadać własne, unikalne cechy.
Zastosowanie dziedziczenia wprowadza hierarchiczność w naszym kodzie, a fakt, iż klasa podrzędna ma dostęp do wszystkich metod i atrybutów klasy nadrzędnej, pozwala rozszerzać funkcjonalności bez potrzeby przepisywania ich na nowo.
class Mammal:
division = "mammal"
@staticmethod
def make_sound():
print("Making sound!")
class Dog(Mammal):
def __init__(self, name):
self.name = name
def play(self, toy):
print(f"{self.name} is playing with a {toy}")
if __name__ == '__main__':
doggie = Dog("Pluto")
print(f"Dogs belong to {doggie.division}s") # usage of inherited attribute
doggie.play(toy="ball") # usage of defined method
doggie.make_sound() # usage of inherited method
# Output:
# Dogs belong to mammals
# Pluto is playing with a ball
# Making sound!
Powyższy kod prezentuje przypadek jedno dziedziczenia, gdzie klasa „dziecka” posiada tylko jednego „rodzica”. Nie jest to jednak wymóg i klasa potomna może dziedziczyć z wielu klas naraz. Bywa to problematyczne m.in.: w sytuacji, gdy obie klasy rodzicielskie posiadają atrybuty lub metody o identycznych nazwach, ale robiące zupełnie coś innego. O tym, która implementacja (cecha którego rodzica) zostanie w takiej sytuacji odziedziczona, decyduje kolejność w jakiej klasy rodzicielskie zostały wymienione w definicji klasy dziecka. Jest to tzw. zasada Method Resolution Order (MRO), która określa hierarchię dziedziczenia.
class Person:
@staticmethod
def get_info():
print("Info about Person...")
class Businessman(Person):
@staticmethod
def get_info():
print("Info about Businessman...")
class Sportsman(Person):
@staticmethod
def get_info():
print("Info about Sportsman...")
class SportsmanBusinessman(Sportsman, Businessman):
pass
if __name__ == '__main__':
obj = SportsmanBusinessman()
obj.get_info()
print(f"Inheritance order:\n{SportsmanBusinessman.__mro__}")
# Output:
# Info about Sportsman...
# Inheritance order:
# (<class '__main__.SportsmanBusinessman'>, <class '__main__.Sportsman'>, # <class '__main__.Businessman'>, <class '__main__.Person'>, <class 'object'>)
Polimorfizm
Polimorfizm to inaczej wielopostaciowość, a w tłumaczeniu dosłownym „wiele form” (gr. polys ‘wiele’, morfe ‘forma, kształt’). W kontekście OOP jest on ściśle związany z pojęciem abstrakcji i dziedziczenia, ponieważ dopuszcza traktowanie wielu obiektów różnych klas w taki sam sposób. Oznacza to, że mając klasę nadrzędną (rodzica) i kilka jej klas pochodnych (dzieci), posiadających w swojej implementacji metody nazwane identycznie jak metoda klasy rodzicielskiej, wywołanie tej metody z poziomu każdej z tych klas (obojętnie czy rodzica, czy któregoś z dzieci) zwróci nam co innego.
Mówiąc inaczej, polimorfizm umożliwia posiadanie wielu metod o takiej samej nazwie, ale zachowujących się w inny sposób. Zatem, pomimo że klasy potomne dziedziczą jakąś metodę, to mogą wpływać na jej zawartość poprzez całkowitą zmianę (jak wyżej) lub rozszerzenie.
class Mammal:
division = "mammal"
def __init__(self, name):
self.name = name
def make_sound(self):
print(f"{self.name} is making a sound...")
class Dog(Mammal):
def __init__(self, name, breed):
super(Dog, self).__init__(name) # extending inherited method
self.breed = breed
def make_sound(self):
super().make_sound() # extending inherited method
print("Woof woof!")
class Cat(Mammal):
def make_sound(self): # overriding inherited method
print("Miau...")
if __name__ == '__main__':
doggie = Dog("Pluto", "bulldog")
kitten = Cat("Filemon")
doggie.make_sound()
kitten.make_sound()
# Output:
# Pluto is making a sound...
# Woof woof!
# Miau...
Przykład polimorfizmu
Powyżej zdefiniowano trzy klasy: ogólną klasę Mammal, a także klasy Dog i Cat dziedziczące po niej, przez co wszystkie one mają dostęp do tych samych metod.
Klasa Dog nie tylko dziedziczy obie metody klasy nadrzędnej, ale również rozszerza ich działanie. Dzieje się tak dzięki wykorzystaniu słowa kluczowego super, które pozwala odnieść się do klasy nadrzędnej i wywołać zawartość odziedziczonej metody w klasie pochodnej.
Klasa Cat, podobnie jak Dog, posiada dostęp do obu metod klasy Mammal, ale jej zachowanie jest zupełnie inne: brak jakiegokolwiek odniesienia do metody __init__ sprawia, że pozostaje ona w niezmienionej formie. Z kolei implementacja make_sound zawiera całkowicie inną logikę, a więc ma miejsce przesłonięcie odziedziczonej metody o tej samej nazwie.
W takiej sytuacji wyraźnie widać, iż posiadamy trzy różne implementacje dla metody make_sound. Wynik, jaki otrzymujemy z jej wywołania, zależy od typu obiektu, za pomocą którego to wywołanie następuje (dla każdego z obiektów rezultat jest inny). To jest właśnie polimorfizm – pomimo, iż posiadamy tę samą metodę w różnych klasach sprzężonych ze sobą, to jej implementacje różnią się od siebie.
Jak już wspomniano, klasa Cat doprowadza do całkowitej zmiany zachowania odziedziczonej metody make_sound. Nie jest to jednak dobra praktyka z punktu widzenia OOP. Dlaczego? Ponieważ łamie trzecią zasadę SOLID, z której wynika, że głównym celem polimorfizmu w przypadku, gdy mamy do czynienia z dziedziczeniem metod, jest rozszerzanie istniejącej implementacji, a nie jej kompletna zmiana (przykład: klasa Dog).
Warto wiedzieć, że wielopostaciowość można rozdzielić na dwa osobne pojęcia:
- method overriding,
- method overloading.
Powyżej opisany przypadek z klasami Mammal, Dog i Cat, zawiera przykład method overridingu, czyli nadpisywania/przesłaniania metody klasy nadrzędnej. Method overloading to zaś tzw. przeciążanie metod i ma ono miejsce, gdy zachowanie metod/funkcji o tych samych nazwach jest podyktowane liczbą i typem parametrów, jakie te metody/funkcje przyjmują.
Jako, że język Python jest językiem interpretowanym (podczas wykonywania kod jest czytany linijka po linijce przez interpreter), a także dynamicznie typowanym (nie deklarujemy na sztywno typu zmiennych; typ zmiennej jest określany dopiero w momencie przypisania do niej wartości i może ulegać zmianie), to tradycyjny overloading tutaj nie istnieje i osiąga się go w inny sposób, aniżeli definiując tę samą metodę z różną liczbą parametrów czy z parametrami o innych typach. Jest to jednak temat na inny artykuł 😉
Zasady SOLID
Kolejny obowiązkowy element programowania obiektowego to tzw. SOLID. Jest to akronim opisujący podstawowe zasady programowania zorientowanego obiektowo. Za twórcę i popularyzatora tego pojęcia uważa się amerykańskiego programistę Roberta C. Martina, znanego szerzej jako „Wujek Bob” (ang. Uncle Bob).
Motywacją do sformułowania zasad dotyczących dobrych praktyk dla programowania obiektowego był dla niego fakt, iż – jego zdaniem – duża część programistów nie jest do końca świadoma, dlaczego tak naprawdę używa języków zorientowanych obiektowo i jakie płyną z tego prawdziwe korzyści.
Zasady SOLID ściśle dotyczą zarządzania zależnościami (ang. dependency management) tj. relacjami pomiędzy klasami i obiektami. Złe zarządzanie zależnościami powoduje, że kod jest trudny do zmiany, podatny na awarie i bardzo często nie nadaje się do ponownego użycia. Stąd, jeśli zależy nam, aby tworzone przez nas oprogramowanie było łatwiejszy do rozwijania i utrzymania, ważne jest dokładne zrozumienie i przestrzeganie owych zasad.
„S” jak Samodzielny (ang. Single Responsibility Principle, SRP) – zasada pojedynczej odpowiedzialności
Odwołując się do definicji stworzonej przez „Wujka Boba”:
Nigdy nie powinien istnieć więcej niż jeden powód do istnienia klasy lub metody, a co za tym idzie, nigdy nie powinien istnieć więcej niż jeden powód do zmiany.
Robert C. Martin
Zgodnie z powyższym – należy trzymać razem wszystkie elementy, których powód do zmiany jest taki sam. Klasa powinna odpowiadać za jedną, konkretną rzecz. Unikamy klas, które wykonują wiele różnych czynności – innymi słowy: są do wszystkiego (czyli do niczego ;). Takie podejście, owszem, zwiększa ilość klas w naszym projekcie, ale zmniejsza ryzyko awarii w sytuacji, gdy konieczne jest wprowadzenie zmian – modyfikacja w jednym miejscu nie spowoduje awarii gdzie indziej.
W skrócie: jedna funkcjonalność → jeden powód do zmian.
„O” jak Otwarty (ang. Open-Closed Principle, OCP) – zasada otwarty-zamknięty
Klasa (ale również funkcje czy moduły) powinna być otwarta na rozszerzanie, ale zamknięta na modyfikacje, tzn. w sytuacji, gdy wymagania biznesowe ulegną zmianie, programista powinien mieć możliwość rozszerzenia zachowania klasy poprzez dodanie nowych funkcjonalności bez konieczności modyfikacji starego, działającego kodu. Unikamy w ten sposób sytuacji, że pojedyncza zmiana niesie za sobą konieczność wprowadzenia korekt w wielu uzależnionych od siebie modułach, ponieważ istniejący kod pozostał nietknięty.
„L” jak Liskov (ang. Liskov Substitution Principle, LSP) – zasada podstawienia Liskov
Zasada sformułowana przez Barbarę Liskov i ściśle związana z obiektami klasy oraz dziedziczeniem. Wpływa na spójność hierarchii występującej pomiędzy klasami w następujący sposób: wszędzie tam, gdzie używamy obiektów klasy bazowej (rodzica), powinna istnieć możliwość zastosowania obiektów klas pochodnych (dzieci) bez jakichkolwiek błędów. Mówiąc prościej, kod korzystający z obiektów klasy rodzicielskiej powinien działać również z obiektami klas dziedziczących. Aby było to możliwe, klasy pochodne powinny spełniać te same założenia co klasa bazowa (tzw. spójność interfejsu) oraz posiadać nieograniczony dostęp do jej metod.
Łatwo zauważyć tutaj bezpośrednie nawiązanie do definicji polimorfizmu, gdzie zostało wspomniane, że klasa pochodna powinna rozszerzać funkcjonalność klasy bazowej, a nie całkowicie ją zmieniać. Takie podejście znacznie ułatwia przestrzeganie zasady LSP.
„I” jak Interfejs (ang. Interface Segregation Principle, ISP) – zasada segregacji interfejsów
Zasada ta wymusza zachowanie spójności interfejsów poprzez implementację tylko niezbędnych metod. Oznacza to, że tworząc interfejs mamy obowiązek definiować tylko te metody, które są niezbędne z punktu widzenia konkretnego klienta (klasy korzystającej z tego interfejsu, tj. implementującej ten interfejs). Zapobiega to sytuacjom, gdzie klient implementuje metody, które nie są mu do niczego potrzebne. W ten sposób posiadamy wiele dedykowanych interfejsów (a nie jeden ogólny), co pozwala pozbyć się zbędnych zależności i poprawia czytelność kodu.
„D” jak oDwrócenie zależności (ang. Dependency Inversion Principle, DIP) – zasada odwrócenia zależności
Ostatnia, moim zdaniem, najtrudniejsza do poprawnego zrozumienia zasada głosi, że moduły wysokiego poziomu nie powinny zależeć od modułów niskopoziomowych. Zależność ta powinna być realizowana przez abstrakcję.
Komentarz: za moduł niskopoziomowy (ang. low-level module) traktujemy klasę wykonującą konkretne operacje, np. wpis w bazie danych, nie posiadającą odwołań do innych klas (klasa jest niezależna i swobodnie możemy jej używać). Moduł wysokopoziomowy (ang. high-level module), to z kolei klasa, która potrzebuje do poprawnego działania modułów niższych poziomów (jest zależna od konkretnej implementacji i często niemożliwe jest jej użycie w innym miejscu).
Jak w takim razie wprowadzić zależność pomiędzy modułami, aby nie złamać zasady DIP? Do tego celu służy wzorzec zwany „odwróceniem sterowania” (ang. Inversion of Control, IoC). Jego najprostsza implementacja, tzw. wstrzykiwanie zależności (ang. dependency injection), w pewnym sensie sprawia, że nasze moduły wysokopoziomowe same wybierają, jakich modułów niskiego poziomu potrzebują. Jako że brzmi to dość skomplikowanie, weźmy przykład:
from abc import ABC, abstractmethod
# Abstract interface
class Figure(ABC):
@abstractmethod
def get_perimeter(self):
pass
@abstractmethod
def get_area(self):
pass
# Low-level module, sample interface implementation
class Circle(Figure):
pi_value = 3.1416
name = "circle"
def __init__(self, radius):
self.radius = radius
def get_perimeter(self):
return 2 * Circle.pi_value * self.radius
def get_area(self):
return Circle.pi_value * self.radius ** 2
# High-level module that uses abstraction instead of specific implementation
class FigureInfo:
def __init__(self, figure: Figure):
self.figure = figure
def get_figure_info(self):
print(f"The {self.figure.name} perimeter: {self.figure.get_perimeter()}")
print(f"The {self.figure.name} area: {self.figure.get_area()}")
if __name__ == '__main__':
circle = Circle(10)
info_circle = FigureInfo(circle)
info_circle.get_figure_info()
# Output:
# The circle perimeter: 62.832
# The circle area: 314.159
Mamy tutaj abstrakcyjny interfejs Figure oraz jego implementację – klasę Circle, która jest naszym modułem niskiego poziomu. Następnie definiujemy moduł wysokopoziomowy FigureInfo, w którym chcemy wykorzystać istniejącą już implementację.
Rozwiązaniem, które jako pierwsze przychodzi nam do głowy w celu zrealizowania tego założenia, jest chęć zdefiniowania instancji klasy Circle wewnątrz klasy FigureInfo. Nie jest to jednak dobre podejście, ponieważ uzależniamy w ten sposób moduł wysokiego poziomu od modułu niskopoziomowego, a zatem łamiemy zasadę DIP.
Poprawnym rozwiązaniem jest właśnie wstrzykiwanie zależności, co zaprezentowano powyżej. Ma ono miejsce poprzez utworzenie instancji klasy Circle poza ciałem klasy FigureInfo, a następnie przekazanie jej do klasy FigureInfo jako parametr. Typ tego parametru jest ponadto określony jako Figure, a więc odnosi się do ogólnej klasy abstrakcyjnej, a nie konkretnej implementacji. Dzięki temu nasz moduł wysokopoziomowy jest uzależniony od abstrakcji i tworząc jego kolejne instancje, możemy przekazywać do niego nie tylko obiekty klasy Circle, ale dowolny podtyp klasy abstrakcyjnej.
Programowanie obiektowe – dlaczego warto?
Tworzenie oprogramowania z użyciem obiektowości jest dość intuicyjne, a definicja obiektu, czyli tzw. klasa, zawiera wszystkie jego właściwości w jednym miejscu. Niesie to za sobą szereg zalet.
- Jedną z podstawowych jest fakt, że nasz kod staje bardziej zorganizowany i łatwiejszy do zrozumienia. Każdy programista może pracować nad oddzielnymi klasami bez ryzyka wystąpienia konfliktów w kodzie. Przekłada się to na jego późniejsze utrzymanie i wprowadzanie ewentualnych zmian przez zespół deweloperski. Ma to niebagatelny wpływ na proces rozwoju oprogramowania i jakość końcowego produktu.
- Kolejną zaletą jest reużywalność kodu. Dzięki klasom i obiektom możemy tworzyć mniejsze, niezależne moduły, które później łatwo przenieść do nowego projektu bez potrzeby pisania całego kodu od zera.
- Dzięki zastosowaniu hermetyzacji wzrasta bezpieczeństwo tworzonego przez nas kodu, gdyż użytkownik zewnętrzny może korzystać jedynie z określonego zestawu narzędzi (atrybutów, metod). Wszelkie dane „wrażliwe” są przed nim ukryte i dostępne tylko wewnątrz klasy, co znacząco zmniejsza ryzyko wpływania na obiekt w niepożądany sposób.
- Stosowanie klas, a wraz za tym dziedziczenia, pozwala na wprowadzenie hierarchii w budowanym kodzie. Umożliwia to ponowne użycie istniejących już struktur oraz definiowanie bardziej wyspecjalizowanych obiektów w oparciu o te już istniejące (bardziej ogólne). Zwiększa to elastyczność budowanego kodu oraz obniża ryzyko redundancji (powtarzania tych samych operacji), dzięki czemu możemy sprawniej przestrzegać zasady DRY (ang. Don’t Repeat Yourself).
- Programowanie obiektowe to również polimorfizm, z którego wynika perspektywa rozszerzania funkcjonalności programu poprzez używanie tych samych nazw metod w różnych klasach. Jest to zabieg, który pozytywnie wpływa na spójność i intuicyjność tworzonego przez nas interfejsu.
Podsumowanie
Programowanie obiektowe nie jest proste i choć samo definiowanie klas czy metod nie jest jakoś mocno skomplikowane, to sporo tu niuansów i reguł, których trzeba przestrzegać. Szczególnie na początku nauki może nam się wydawać, że im bardziej zgłębiamy temat, tym bardziej zaczyna on być dla nas zawiły i łatwo się w nim pogubić.
Z czasem okazuje się jednak, że wszystko razem tworzy spójną całość i – koniec końców – ułatwia pracę, dlatego ciężko sobie dzisiaj wyobrazić programowanie bez obiektowości. Potrzeba jednak sporo praktyki, aby dobrze zrozumieć programowanie obiektowe oraz nauczyć się je poprawnie używać. No i najważniejsze, na pewno nie należy się zniechęcać pomimo pierwszych niepowodzeń 😉
Zostaw komentarz