Wyślij zapytanie Dołącz do Sii

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.

Some text value w Pythonie
Ryc. 1 Some text value w Pythonie

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ń 😉

5/5 ( głosy: 4)
Ocena:
5/5 ( głosy: 4)
Autor
Avatar
Maciej Wilk

Trafił do Sii w 2020 roku. Aktualnie zajmuje się automatyzacją testów w Pythonie, choć posiada również doświadczenie w developmencie. W swojej dotychczasowej pracy miał ponadto styczność z takimi technologiami jak m.in.: PL/SQL, bazy danych Oracle czy R. Prywatnie pasjonat siłowni i jazdy na „szosie”. Lubi się uczyć. Pomieszkiwał w Finlandii i Australii

Zostaw komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *

Może Cię również zainteresować

Pokaż więcej artykułów

Bądź na bieżąco

Zasubskrybuj naszego bloga i otrzymuj informacje o najnowszych wpisach.

Otrzymaj ofertę

Jeśli chcesz dowiedzieć się więcej na temat oferty Sii, skontaktuj się z nami.

Wyślij zapytanie Wyślij zapytanie

Natalia Competency Center Director

Get an offer

Dołącz do Sii

Znajdź idealną pracę – zapoznaj się z naszą ofertą rekrutacyjną i aplikuj.

Aplikuj Aplikuj

Paweł Process Owner

Join Sii

ZATWIERDŹ

This content is available only in one language version.
You will be redirected to home page.

Are you sure you want to leave this page?