Wyzwanie Python #4: Programowanie obiektowe

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 dzielnikadodaj()
,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



