Wyzwanie Python #4: Programowanie obiektowe

Maciej Bartoszuk 4 stycznia 2022
 

Póki co programy w naszych wyzwaniach nie były zbyt rozbudowane. Raczej skupialiśmy się na poszczególnych konstrukcjach języka, a to nie wymagało od nas pisania wielu tysięcy wierszy kodu. Jednak w praktyce kody źródłowe programów mają wielką objętość. Na tyle wielką, że człowiek, nawet autor, przestaje nad takim kodem panować. Zapomina, w którym miejscu rozwiązuje jeden problem, a gdzie drugi. Co więcej, zaczynają pojawiać się problemy z dostępem do zmiennych stworzonych w jednym fragmencie kodu z innego fragmentu. Problemy są też innego rodzaju: tworzony kod, który rozwiązuje dwa dość podobne, ale jednak różne problemy, ma pewną część wspólną, a w pewnych obszarach różni się. Jak w elegancki sposób napisać kod bez powtórzeń w takiej sytuacji? Kod, który pomimo swej objętości, nie będzie zawierał powtórzeń, będzie czytelny, a za jeden problem będzie odpowiedzialny jeden fragment kodu? Okazuje się, że odpowiedzią na te wszystkie bolączki jest paradygmat programowania obiektowego, czyli w skrócie mówiąc programowanie obiektowe (to, gdzie definiujemy klasy).

Przytoczone problemy dręczą programistów od bardzo dawna, dość powiedzieć, że pierwsze idee związane z programowaniem obiektowym pojawiły się już w latach sześćdziesiątych. W latach osiemdziesiątych powstał język C++, który był rozszerzeniem języka C o paradygmat programowania obiektowego i tenże język rozpopularyzował programowanie obiektowe. Od tamtej pory ten sposób programowania jest najpopularniejszym na świecie. W dzisiejszych czasach próbuje się konstruować hybrydy, języki, które czerpią z paru paradygmatów, w tym ze zdobywającego coraz większą popularność paradygmatu programowania funkcyjnego, zazwyczaj jednak wygląda to w ten sposób, że głównym sposobem konstruowania programu są obiekty, a jedynie sam kod zawarty w obiektach korzysta z pewnego wycinku programowania funkcyjnego.

Ponieważ programowanie obiektowe jest tak popularnym i ważnym zagadnieniem, poświęcimy mu dwa wyzwania: w tym przedstawimy podstawy, by bardziej zaawansowane zagadnienia omówić w następnym.

Klasa a obiekt

Przede wszystkim: czym są te całe klasy i obiekty? Czy to się w ogóle czymś różni? Odpowiedź brzmi: tak, klasa i obiekt, zwany czasem instancją, to są dwa różne, choć powiązane koncepty. Zacznijmy jednak od początku: programowanie obiektowe stara się powiązać dane z czynnościami, jakie na tych danych można wykonać. Przykładowo, człowiek ma imię, nazwisko, datę urodzenia. To są dane. Jakie są czynności dostępne człowiekowi z takimi danymi? Może to być przestawienie się czy obliczenie swojego wieku. Spotkaliśmy się z klasami już wcześniej: były to chociażby listy czy słowniki. Danymi były tu zawarte przez nas wartości w tych strukturach danych. A czynnościami? Listę mogliśmy posortować czy odwrócić jej kolejność. W słowniku mogliśmy odszukać wartość powiązaną z danym kluczem.

Wróćmy jednak do rozróżnienia pomiędzy klasami a obiektami (instancjami). Klasa to byt abstrakcyjny: to przepis, mówiący, że człowiek składa się z imienia, nazwiska i wieku. Nic bardziej konkretnego. Obiekt zaś jest konkretnym, zajmującym pamięć komputera, wcieleniem takiej klasy, gdzie dane mają swoje wartości: np. Jan Kowalski urodzony 1 stycznia 1970. Może być wiele obiektów tej samej klasy. Np. obok Jana możemy powołać do życia (w pamięci komputera) Adama Mickiewicza, urodzonego 24 grudnia 1798.

Metody i pola

Do tej pory mówiliśmy o danych i czynnościach, jakie na tych danych możemy wykonać. Te dwa koncepty mają swoje nazwy. Dane przedstawiamy za pomocą pól. Pole to pojedyncza zmienna, np. przechowująca napis czy liczbę całkowitą. Tak więc w naszym przykładzie człowiek zawiera trzy pola: imię, nazwisko oraz datę urodzenia. Zwróćmy jednak uwagę, że każdy obiekt w programie będzie miał swój zestaw pól (zmiennych powiązanych właśnie z nim): Jan Kowalski będzie miał swoje trzy pola, a Adam Mickiewicz swoje trzy pola. Razem sześć pól.

Na czynności związane z polami mówimy metody. Metoda to tak naprawdę po prostu funkcja. Jednak jest to funkcja, która jest powiązana z daną klasą. Mówimy, że metoda jest wywoływana na rzecz konkretnego obiektu. Oznacza to, że chociażby ma dostęp do pól tego obiektu. Może je odczytywać i modyfikować. Przykładowo, metoda sortująca listę sortuje nie dowolną listę, ale konkretnie tę, na rzecz której została wywołana.

Konstruktory

Z tą wiedzą, którą mamy, możemy stworzyć swoją pierwszą klasę. Będzie to klasa z dotychczasowego przykładu: człowiek. Pojawią się tu nowe konstrukcje języka Python, w tym także nie omawiany do tej pory konstruktor, jednak wszystko wyjaśnimy poniżej.

class Osoba:
    def __init__(self, imie, nazwisko, wiek):
        self.imie = imie
        self.nazwisko = nazwisko
        self.wiek = wiek
    def przedstaw_sie(self):
        print(f"Jestem {self.imie} {self.nazwisko}. Mam {self.wiek} lat.")
    def urodziny(self):
        wiek_przed = self.wiek
        self.wiek += 1
        return wiek_przed
def main():
    # tworzymy dwa obiekty klasy Osoba
    Jan = Osoba("Jan", "Nowak", 48)
    Adam = Osoba("Adam", "Mickiewicz", 220)
    
    # wywołujemy metodę przedstaw_sie() na każdym z nich
    Jan.przedstaw_sie()
    Adam.przedstaw_sie()
    
    wiek_Adama_przed = Adam.urodziny()
    Adam.przedstaw_sie()
    print(f"Wiek Adama sprzed urodzin: {wiek_Adama_przed}")
    
    # odwołujemy się do pól, modyfikujemy je
    Jan.imie = "Stanisław"
    Jan.nazwisko = "Witkiewicz"
    Jan.wiek = 133
    
    Jan.przedstaw_sie()
if __name__ == "__main__":
    main()
## Jestem Jan Nowak. Mam 48 lat.
## Jestem Adam Mickiewicz. Mam 220 lat.
## Jestem Adam Mickiewicz. Mam 221 lat.
## Wiek Adama sprzed urodzin: 220
## Jestem Stanisław Witkiewicz. Mam 133 lat.

Zaczynamy od definicji klasy o nazwie Osoba, w tym celu używamy słowa kluczowego class:

class Osoba:

Następnie pojawia się definicja konstruktora:

def __init__(self, imie, nazwisko, wiek):

Z pozoru jest to zwykła definicja funkcji. Jednak, ponieważ jest ona definiowana w ciele klasy, powiemy, że jest to metoda. Możemy poznać, że to metoda, także po pierwszym argumencie: self. W języku Python metody przyjmują jako pierwszy parametr obiekt, na rzecz którego są wywoływane. W samym wywołaniu nie musimy go sami podawać. Wystarczy, że metoda jest napisana po kropce, do czego jeszcze wrócimy. Następnie następują trzy zwykłe parametry: imie, nazwisko oraz wiek.

Teraz omówmy, czym jest konstruktor. Jest to taka specjalna metoda, która jest wywoływana, gdy obiekt jest tworzony. Jej celem jest zainicjowanie pól w instancji. W tym wypadku jest to przypisanie podanych jako parametry wartości imienia, nazwiska oraz wieku do odpowiednich pól w klasie. Konstruktor poznajemy po jego specjalnej nazwie: __init__. Gdzie konstruktor jest wywoływany dalej w kodzie? To wszystkie te wiersze typu:

Jan = Osoba("Jan", "Nowak", 48)

Jak widzimy, w wywołaniu zamiast __init__ jest raczej nazwa klasy. I, jak wspomnieliśmy wcześniej, pomijamy w wywołaniu argument self. Wróćmy jednak do ciała konstruktora. Tutaj następują przypisania typu:

self.imie = imie

Zwróćmy uwagę na self.imie. Taka konstrukcja będzie pojawiać się niezwykle często. Przed kropką będzie zmienna będąca obiektem jakiejś klasy, podczas gdy po kropce będziemy odwoływać się do jakiejś jej składowej (tego konkretnego obiektu): metody lub klasy. Niczym nie różni się ten zapis od występującego w main(): Adam.przedstaw_sie(). Tutaj, w konstruktorze, przypisujemy wartości przekazane jako parametry do konkretnych pól obiektu. Skąd program wie, jakie pola są dostępne w klasie Osoba? Otóż nie wie. Dopiero w konstruktorze dokonujemy ich definicji.

Następne metody zdefiniowane są w sposób dość analogiczny. Spójrzmy np. na:

def przedstaw_sie(self):
        print(f"Jestem {self.imie} {self.nazwisko}. Mam {self.wiek} lat.")

Jest to metoda, która nie przyjmuje żadnego parametru (poza self, który jest obligatoryjny dla każdej metody). Metoda ta nic nie zwraca, jedynie korzysta z odpowiednich pól, aby skontruować napis, który następnie wyświetla. Zaś metoda:

def urodziny(self):
        wiek_przed = self.wiek
        self.wiek += 1
        return wiek_przed

pokazuje, że metoda może zwracać wartość tak jak zwykła funkcja, w tym wypadku jest to pole wiek przed inkrementacją (zwiększeniem o jeden). Co więcej, metoda modyfikuje pole wiek.

Przejdźmy do main(). Tworzenie obiektów już omówiliśmy. Wywoływanie metod na rzecz konkretnych obiektów także nie powinno już sprawiać nam problemów. Jednak zainteresować nas może koncówka programu:

Jan.imie = "Stanisław"
Jan.nazwisko = "Witkiewicz"
Jan.wiek = 133

Jan.przedstaw_sie()

Nie ma tu tak naprawdę nic specjalnego: pokazujemy jedynie, że “z zewnątrz”, czyli nie w metodzie klasy, ale w funkcji main(), możemy także uzyskać dostęp do pola obiektu, aby je odczytać czy wręcz zmodyfikować. Widzimy, że zmiany mają wpływ na późniejsze wykonanie metody przedstaw_sie() (uzyskujemy dane zupełnie innego człowieka!).

Widoczność składowych klasy

Nie zawsze zależy nam na tym, aby każda osoba mogła mieć dowolny dostęp do składowych klasy. Niektóre pola i metody wolelibyśmy zachować dla siebie jako tzw. “szczegół implementacyjny”. Zazwyczaj w językach obiektowych wyróżniamy trzy klasy dostępności: publiczne – dostęp mają wszyscy, chronione – dostęp mają klasy dziedziczące, co na razie może być dla nas niezrozumiałe, a także prywatne – dostęp ma tylko ta klasa.

W języku Python jednak jest zgoła inaczej: nie jesteśmy w stanie w praktyce czegokolwiek ukryć przed osobą “z zewnątrz”. Jednak są pewne zasady nazewnictwa, które działają raczej na zasadzie porozumienia dżentelmeńskiego, niż będące prawdziwą barierą. I tak, gdy poprzedzimy nazwę jednym znakiem podkreślenia (_), oznajmiamy, że dany element nie jest uwzględniony w dokumentacji, może się zmienić, raczej nie należy z niego korzystać, a środowisko programistyczne nie będzie nam go podpowiadać. Przykładowo pole _imie, np. self._imie, czy self._metoda().

Gdy użyjemy dwóch znaków podkreślenia (__), zachowanie jest trochę inne: dane pole czy metoda nie będzie widoczna pod tą nazwą wcale, ale za to będzie można się do niego odwołać (dla nazwy __element) poprzez _nazwaklasy__element.

Przetestujmy:

class Test:
    def __init__(self):
        self.publiczne, self._chronione, self.__prywatne = 1, 2, 3
def main():
    test = Test()
    print(test.publiczne)
    print(test._chronione)
    print(test._Test__prywatne)
if __name__ == "__main__":
    main()
## 1
## 2
## 3

Metody i pola statyczne

Do tej pory omawialiśmy takie pola i metody, do których, by się odwołać, trzeba było stworzyć konkretny obiekt danej klasy. Teraz pokażemy, jak stworzyć metodę lub pole, które jest jedno na całą klasę. Taką metodę lub pole nazywamy statycznym. Oczywiście możemy zadać sobie pytanie: skoro tak bardzo podkreślaliśmy, że aby skorzystać z klasy, należy stworzyć konkretną jej instancję, to czy teraz nie przeczymy sobie? Przecież idąc tym tropem zaraz przestaniemy w ogóle tworzyć obiekty, a wszystko będzie statyczne i znajdziemy się w punkcie wyjścia. Odpowiadając: oczywiście, wszystko w granicach umiaru i należy dobrze rozpoznawać, kiedy która technika jest nam bardziej potrzebna. Klasycznym przykładem, kiedy potrzebne nam są pola i metody statyczne, jest problem numeracji obiektów i liczenia instancji danej klasy:

class Licznik:
    ile = 0                    # pole statyczne
    def __init__(self):        # konstruktor
        Licznik.ile += 1       # odwołanie do pola statycznego
        self.ktory = Licznik.ile
        print(f"To jest obiekt nr {Licznik.ile}")
    def __del__(self):         # destruktor, czyli kod, który wykonuje się
                               # podczas niszczenia obiektu
        Licznik.ile -= 1
        print(f"Niszczę obiekt nr {self.ktory}, pozostało jeszcze {Licznik.ile}.")
    @staticmethod
    def policz():
        return Licznik.ile
        
def main():
    a = Licznik()
    b = Licznik()
    c = Licznik()
    print(f"a to obiekt nr {a.ktory}")
    print(f"b to obiekt nr {b.ktory}")
    print(f"c to obiekt nr {c.ktory}")
    print(f"Liczba obiektow to: {Licznik.policz()}")
    a = None
    b = None
    print(f"Liczba obiektow to: {Licznik.policz()}")
if __name__ == "__main__":
    main()
## To jest obiekt nr 1
## To jest obiekt nr 2
## To jest obiekt nr 3
## a to obiekt nr 1
## b to obiekt nr 2
## c to obiekt nr 3
## Liczba obiektow to: 3
## Niszczę obiekt nr 1, pozostało jeszcze 2.
## Niszczę obiekt nr 2, pozostało jeszcze 1.
## Liczba obiektow to: 1
## Niszczę obiekt nr 3, pozostało jeszcze 0.

W powyższym przykładzie pole ile jest polem statycznym. Stworzyliśmy je poza konstruktorem, na początku klasy. Do pola tego odwołujemy się poprzez nazwę klasy, a nie konkretnego obiektu, a więc Licznik.ile. Przy okazji pojawił się tzw. destruktor, czyli analogiczna metoda do konstruktora, której kod wykonuje się, gdy pozbywamy się danego obiektu (w kodzie main() wymusiliśmy jej wywołanie poprzez przypisanie wartości None do zmiennej przechowującej dany obiekt). Sama metoda statyczna ma nad sobą napis @staticmethod. To tzw. dekorator. Dekoratory (zaczynające się od @) służą do modyfikacji definiowanej funkcji lub metody w określony sposób, jednak nie będziemy temu zagadnieniu poświęcać szczególnej uwagi. Dość powiedzieć, że w ten właśnie sposób oznaczamy metodę statyczną. Do niej samej także odwołujemy się poprzez nazwę klasy: Licznik.policz(). Zaznaczmy, że metoda statyczna nie może odwoływać się do instancyjnych pól (czyli tych zwykłych, jak imie z poprzedniego przykładu), a jedynie do statycznych. Wynika to z faktu, że metoda statyczna nie jest wywoływana na rzecz konkretnego obiektu, który by takie właśnie pola miał.

Zadanie 4

Funkcja kwadratowa

Napisz klasę FunkcjaKwadratowa, która przechowuje funkcje typu $ax^2$+bx+c. Klasa powinna zawierać trzy pola: a, b, c, które są przypisywane w konstruktorze. Główną metodą powinna być Rozwiaz(), która zwraca miejsca zerowe podanej funkcji. Należy zwrócić uwagę na przypadki gdy a=0, b=0 lub c=0, a także obmyślić sposób informowania o nieskończonej liczbie, jednym lub zerze rozwiązań.

Liczba zespolona

Napisz klasę Zespolona, która przechowuje liczby zespolone: a+bi. Niech część rzeczywista nazywa się re (od real), a urojona im (od imagine). Poza tymi dwoma polami zdefiniuj metody:

  • modul()1, oblicza moduł liczby zespolonej a+bi: √$a^2$+$b^2$
  • dodaj(), mnoz() (statyczne) – obliczają odpowiednio sumę i iloczyn dwóch liczb zespolonych

Ułamek

Napisz klasę Ulamek, która przechowuje ułamki postaci ab. Klasa przechowuje dwa pola: licznik i mianownik. Napisz metody:

  • skroc(), skraca ułamek, wymaga obliczenia największego wspólnego dzielnika
  • dodaj(), odejmij(), mnoz(), dziel() (statyczne) – obliczają odpowiednio sumę i iloczyn dwóch ułamków

Gotowe rozwiązanie zadania 4 znajdziecie tutaj.


Wszystkie wpisy z cyklu #pythonowewyzwanie:

Wyzwanie 1 - Instalacja środowiska i pierwsze kroki

Wyzwanie 2 - Podstawowe instrukcje

Wyzwanie 3 - Algorytmy i struktury danych

Wyzwanie 4 - Programowanie obiektowe

Wyzwanie 5 - Zaawansowane aspekty programowania obiektowego

Wyzwanie 6 - Wyjątki i operacje na plikach

Wyzwanie 7 - Web scraping

Maciej Bartoszuk

Ukończył z wyróżnieniem informatykę na wydziale Matematyki i Nauk Informacyjnych Politechniki Warszawskiej, gdzie aktualnie pracuje w zakładzie Sztucznej Inteligencji i Metod Obliczeniowych. Tam też od 2013 roku prowadzi zajęcia dydaktyczne z programowania w R, Pythonie, C/C++, C#. Uczestnik studiów doktoranckich w Instytucie Podstaw Informatyki Polskiej Akademii Nauk w latach 2013-2015. W 2018 roku obronił doktorat z wyróżnieniem na swoim rodzimym wydziale: System do oceny podobieństwa kodów źródłowych w językach funkcyjnych oparty na metodach uczenia maszynowego i agregacji danych, który obejmuje zarówno algorytmy przetwarzania kodów źródłowych programów, jak i data science. Współautor książki Przetwarzanie i analiza danych w języku Python wydanej przez PWN. Ponadto trener na bootcampach Data Science, gdzie uczy programować w języku Python pod kątem analizy danych.
Komentarze
Ostatnie posty
Data Science News #204
Data Science News #203
Data Science News #202
Data Science News #201