Pobierz informator

Wysyłając niniejszy formularz, wyrażam zgodę na przesyłanie informacji handlowych drogą elektroniczną przez firmę Sages Sp. z o.o. z siedzibą w Warszawie przy ul. Nowogrodzkiej 62c, zgodnie z ustawą z dnia 18 lipca 2002 r. o świadczeniu usług drogą elektroniczną (Dz. U. z 2002 r. Nr 144, poz. 1204 z późn. zm.).

Wyzwanie Python #5: Zaawansowane aspekty programowania obiektowego

3 stycznia 2019

Wyzwanie Python #5: Zaawansowane aspekty programowania obiektowego

W poprzednim wyzwaniu nakreśliliśmy podstawy programowania obiektowego. Wiemy już, czym różni się klasa od jej instancji, czym jest pole, a czym metoda, a także jak zdefiniować konstruktor. Jednak we wstępie do poprzedniego wyzwania powiedzieliśmy sobie, że obiektowość została wymyślona, aby radzić sobie z coraz to większymi partiami kodu, aby zaprowadzić w nim porządek. Aby wydzielić podobne partie kodu, a także wskazać, gdzie kod rozwiązań dwóch problemów się różni.

W tym wyzwaniu omówimy bardziej zaawansowane aspekty programowania obiektowego, takie jak dziedziczenie, polimorfizm, hermetyzacja i abstrakcja. Dopiero poznając te koncepty poznamy prawdziwą moc programowania obiektowego, które pozwala zaprowadzić realny porządek w kodzie.

Dziedziczenie

Bardzo często pojawia się następujący scenariusz: chcemy stworzyć dwie lub więcej klas, które, choć reprezentują trochę inne idee, mają jednak całkiem sporo części wspólnych. Może to być klasa reprezentująca dokument tekstowy oraz w HTML. Taka, która reprezentuje koło oraz prostokąt. Albo osoby: wykładowcę i studenta na uczelni. Spójrzmy na przykład z figurami geometrycznymi: muszą być inne pola, koło jest definiowane przez środek oraz promień, podczas gdy prostokąt przez długości swoich boków i jeden wierzchołek (są to pola w klasach). Jednak dla obu tych figur można policzyć ich obwód oraz pole (są to metody). Z drugiej strony, wykładowca oraz student mają swoje imiona oraz nazwiska (pola), jednak ich czynności na uczelni są zupełnie inne (metody).

Gdyby chcieć tworzyć osobne klasy dla każdej z tych idei, to szybko okazałoby się, że mamy bardzo dużo powtórzonego kodu, a jak wspomninaliśmy w jednym z pierwszych wyzwań, sztuka programowania to sztuka pisania kodu tylko raz, tak aby w razie błędu nie musieć wprowadzać tych samych poprawek wielokrotnie (poza tym jest mniej czytania, gdy wrócimy do kodu za jakiś czas po przerwie). Dlatego wymyślono dziedziczenie. Polega ono na tym, że tworzymy jedną klasę (nazwijmy ją klasą bazową), która ma pewne cechy (pola i metody), a następnie tworzymy drugą klasę, która po niej dziedziczy, co oznacza, że ma te same pola i metody, jednak po pierwsze może dodać swoje własne nowe składowe, a po drugie zmienić kod samych metod klasy bazowej.

W naszym wyzwaniu posłużymy się znanym i lubianym przykładem ZOO: stworzymy klasy reprezentujące słonia, lwa i papugę. Na początku stworzymy klasę bazową, Zwierze, która wyekstrachuje pewne cechy, które są wspólne dla wszystkich tych zwierząt: pola nazwa, wiek oraz waga. Do tego dodajmy dwie metody: przedstaw_sie() oraz urodziny(). W końcu każde zwierzę może powiedzieć, jak się nazywa oraz każde się starzeje.

class Zwierze:
    def __init__(self, nazwa, wiek, waga):
        self.nazwa = nazwa
        self.wiek = wiek
        self.waga = waga
    def przedstaw_sie(self):
        print(f"Jestem zwierzęciem {self.nazwa}, mam {self.wiek} lat oraz wazę {self.waga} kg.")
        
    def urodziny(self):
        self.wiek += 1
class Slon(Zwierze):
    pass
    
class Lew(Zwierze):
    pass
    
class Papuga(Zwierze):
    pass
def main():
    Dumboo = Slon("Dumboo", 77, 6000)
    Simba = Lew("Simba", 24, 100)
    Jago = Papuga("Jago", 32, 3)
    jakis_zwierz = Zwierze("Katarzyna", 31, 80) 
    
    Dumboo.przedstaw_sie()
    Simba.przedstaw_sie()
    jakis_zwierz.przedstaw_sie()
    
    Jago.urodziny()
    Jago.przedstaw_sie()
if __name__ == "__main__":
    main()
## Jestem zwierzęciem Dumboo, mam 77 lat oraz wazę 6000 kg.
## Jestem zwierzęciem Simba, mam 24 lat oraz wazę 100 kg.
## Jestem zwierzęciem Katarzyna, mam 31 lat oraz wazę 80 kg.
## Jestem zwierzęciem Jago, mam 33 lat oraz wazę 3 kg.

Sama definicja klasy Zwierz nie powinna nam sprawiać problemów, gdyż nie odbiega ona od tego, czego nauczyliśmy się w poprzednim wyzwaniu. Jednak ciekawsze są kolejne definicje klas. Spójrzmy na klasę Slon:

class Slon(Zwierze):
    pass

Obok nazwy klasy napisaliśmy w okrągłych nawiasach nazwę klasy bazowej (tej, z której dziedziczymy). Dzięki temu klasa Slon ma te same pola i metody, co klasa Zwierze. W samym ciele klasy napisaliśmy słówko pass, co znaczy tyle, że ciało to jest zwyczajnie puste.

Przyjrzyjmy się teraz funkcji main(). Możemy stworzyć instancje zarówno klasy bazowej, jak i klas dziedziczących. Każda z nich ma metody przedstaw_sie() oraz urodziny(), a także taki sam konstruktor.

Póki co, poza tym, że wprowadziliśmy pojęcie dziedziczenia, niewiele ciekawego zdarzyło się w naszym programie. Tak naprawdę wprowadzenie klas poszczególnych zwierząt jest sztuczne i niczego nam nie ułatwia. Zastanowić też można się, czy możliwość stworzenia instancji klasy Zwierze, bez podania konkretnego gatunku, ma sens. Jednak to dopiero początek, zaraz pokażemy, jak wprowadzać drobne zmiany na poziomie każdej klasy, a także ciekawiej korzystać z dobrodziejstw wspólnego “przodka”.

Przesłanianie metod

Tak jak już wspominaliśmy, z dziedziczenia korzystamy, gdy chcemy mieć wiele cech wspólnych z klasą bazową, jednak chcemy także mieć pewien fragment kodu, który różni się od odziedziczonego. Aby zilustrować to zjawisko, wprowadzimy inny sposób przedstawiania się poszczególnych zwierząt:

class Zwierze:
    def __init__(self, nazwa, wiek, waga):
        self.nazwa = nazwa
        self.wiek = wiek
        self.waga = waga
    def przedstaw_sie(self):
        print(f"Jestem zwierzęciem {self.nazwa}, mam {self.wiek} lat oraz wazę {self.waga} kg.")
        
    def urodziny(self):
        self.wiek += 1
class Slon(Zwierze):
    def przedstaw_sie(self):
        print(f"Jestem słoniem {self.nazwa}, mam {self.wiek} lat oraz wazę {self.waga} kg.")
    
class Lew(Zwierze):
    def przedstaw_sie(self):
        super().przedstaw_sie()
        print("A tak w ogóle to jestem lwem")
        
class Papuga(Zwierze):
    pass
def main():
    Dumboo = Slon("Dumboo", 77, 6000)
    Simba = Lew("Simba", 24, 100)
    Jago = Papuga("Jago", 32, 3)
    jakis_zwierz = Zwierze("Katarzyna", 31, 80) 
    
    Dumboo.przedstaw_sie()
    Simba.przedstaw_sie()
    jakis_zwierz.przedstaw_sie()
    
    Jago.urodziny()
    Jago.przedstaw_sie()
if __name__ == "__main__":
    main()
## Jestem słoniem Dumboo, mam 77 lat oraz wazę 6000 kg.
## Jestem zwierzęciem Simba, mam 24 lat oraz wazę 100 kg.
## A tak w ogóle to jestem lwem
## Jestem zwierzęciem Katarzyna, mam 31 lat oraz wazę 80 kg.
## Jestem zwierzęciem Jago, mam 33 lat oraz wazę 3 kg.

Przedstawiliśmy tutaj dwa typowe scenariusze: jeden, zastosowany w klasie Slon, pokazuje sytuacje, gdy kompletnie rezygnujemy z metody klasy bazowej i wprowadzamy swój własny kod. W drugim zaś, w klasie Lew, najpierw wywołujemy metodę klasy bazowej, by następnie dodać coś od siebie. Zwróćmy uwagę na to, że super() zwraca nam instancję klasy bazowej: są to wszystkie pola i metody naszego obiektu, jakie otrzymaliśmy dzięki klasie bazowej.

Konstruktory

Teraz dokonajmy jeszcze jednej zmiany: niech papugi mają pewną cechę, która jest właściwa tylko im. Będzie to kolor, który będzie przechowywany jako dodatkowe pole. W tym celu musimy zmodyfikować konstruktor: konstruktor papugi będzie przyjmował cztery parametry, gdzie oryginalne trzy będą przekazywane do konstruktora klasy bazowej, podczas gdy czwarty będzie przypisywany w nowo napisanym konstrukorze. Znów, wszystko ma na celu uniknięcie pisania dwa razy tego samego kodu (w tym wypadku przypisań do pól nazwa, wiek, waga).

class Zwierze:
    def __init__(self, nazwa, wiek, waga):
        self.nazwa = nazwa
        self.wiek = wiek
        self.waga = waga
    def przedstaw_sie(self):
        print(f"Jestem zwierzęciem {self.nazwa}, mam {self.wiek} lat oraz wazę {self.waga} kg.")
        
    def urodziny(self):
        self.wiek += 1
class Slon(Zwierze):
    def przedstaw_sie(self):
        print(f"Jestem słoniem {self.nazwa}, mam {self.wiek} lat oraz wazę {self.waga} kg.")
    
class Lew(Zwierze):
    def przedstaw_sie(self):
        super().przedstaw_sie()
        print("A tak w ogóle to jestem lwem")
        
class Papuga(Zwierze):
    def __init__(self, nazwa, wiek, waga, kolor):
        super().__init__(nazwa, wiek, waga)
        self.kolor = kolor
        
    def przedstaw_sie(self):
        super().przedstaw_sie()
        print(f"Jako papuga mój kolor to {self.kolor}")
def main():
    Dumboo = Slon("Dumboo", 77, 6000)
    Simba = Lew("Simba", 24, 100)
    # Jago = Papuga("Jago", 32, 3) # będzie błąd
    Jago = Papuga("Jago", 32, 3, "czerwony")
    jakis_zwierz = Zwierze("Katarzyna", 31, 80) 
    
    Dumboo.przedstaw_sie()
    Simba.przedstaw_sie()
    jakis_zwierz.przedstaw_sie()
    
    Jago.urodziny()
    Jago.przedstaw_sie()
if __name__ == "__main__":
    main()
## Jestem słoniem Dumboo, mam 77 lat oraz wazę 6000 kg.
## Jestem zwierzęciem Simba, mam 24 lat oraz wazę 100 kg.
## A tak w ogóle to jestem lwem
## Jestem zwierzęciem Katarzyna, mam 31 lat oraz wazę 80 kg.
## Jestem zwierzęciem Jago, mam 33 lat oraz wazę 3 kg.
## Jako papuga mój kolor to czerwony

Jak widzimy, super().__init__() pozwola odwołać się do konstruktora klasy bazowej. Co więcej, definicja nowego, czteroargumentowego konstruktora w klasie dziedziczącej zabrania nam skorzystania z oryginalnego, trójargumentowego konstruktora w main(). Ma to sens: nie chcemy papugi, która nie ma zdefiniowanego koloru. Dodatkowo, nadpisaliśmy metodę przedstaw_sie() wzorem klasy Lew. W tym wypadku ma ona więcej sensu, gdyż wywołujemy oryginalną metodę, która działa na trzech polach z klasy bazowej, a także dodajemy jeszcze jeden komunikat, wypisujący wartość pola specyficznego tylko dla papug.

Gdy umiemy już modyfikować istniejące metody, wywoływać metody i konstruktory klasy bazowej a także wprowadzać własne pola do klasy dziedziczącej, pora przedstawić bardziej wysublimowane niuanse programowania obiektowego.

Polimorfizm

Na dobry początek omówmy czym jest polimorfizm w kontekście programowania obiektowego. Oznacza to, że klasa dziedzicząca może być użyta wszędzie tam, gdzie może być użyta klasa bazowa. Oznacza to, że instancja klasy dziedziczącej jest uznawana za instancję klasy bazowej. W języku Python sprawdzenie przynależności danego obiektu do klasy wykonuje się metodą isinstance():

def main():
    Dumboo = Slon("Dumboo", 77, 6000)
    Simba = Lew("Simba", 24, 100)
    Jago = Papuga("Jago", 32, 3, "czerwony")
    jakis_zwierz = Zwierze("Katarzyna", 31, 80) 
    
    print(f"isinstance(Dumboo, Slon): {isinstance(Dumboo, Slon)}")
    print(f"isinstance(Dumboo, Lew): {isinstance(Dumboo, Lew)}")
    print(f"isinstance(Jago, Papuga): {isinstance(Jago, Papuga)}")
    print(f"isinstance(Jago, Zwierze): {isinstance(Jago, Zwierze)}")
    print(f"isinstance(jakis_zwierz, Zwierze): {isinstance(jakis_zwierz, Zwierze)}")
    print(f"isinstance(jakis_zwierz, Papuga): {isinstance(jakis_zwierz, Papuga)}")
if __name__ == "__main__":
    main()
## isinstance(Dumboo, Slon): True
## isinstance(Dumboo, Lew): False
## isinstance(Jago, Papuga): True
## isinstance(Jago, Zwierze): True
## isinstance(jakis_zwierz, Zwierze): True
## isinstance(jakis_zwierz, Papuga): False

Jakie może być zastosowanie polimorfizmu? Łatwiej jest uogólniać pewne czynności: np. wywołać tę samą metodę na wielu elementach listy, które są instancjami różnych klas dziedziczących. W naszym przypadku będziemy chcieli, np. z dniem pierwszego stycznia, postarzeć wszystkie zwierzęta obecne w ZOO:

def nowy_rok(zoo):
    for zwierze in zoo:
        zwierze.urodziny()
def przedstaw_zwierzeta(zoo):
    for zwierze in zoo:
        zwierze.przedstaw_sie()
        
def main():
    Dumboo = Slon("Dumboo", 77, 6000)
    Simba = Lew("Simba", 24, 100)
    Jago = Papuga("Jago", 32, 3, "czerwony")
    jakis_zwierz = Zwierze("Katarzyna", 31, 80) 
    
    zoo = [Dumboo, Simba, Jago, jakis_zwierz]
    nowy_rok(zoo)
    przedstaw_zwierzeta(zoo)
if __name__ == "__main__":
    main()
## Jestem słoniem Dumboo, mam 78 lat oraz wazę 6000 kg.
## Jestem zwierzęciem Simba, mam 25 lat oraz wazę 100 kg.
## A tak w ogóle to jestem lwem
## Jestem zwierzęciem Jago, mam 33 lat oraz wazę 3 kg.
## Jako papuga mój kolor to czerwony
## Jestem zwierzęciem Katarzyna, mam 32 lat oraz wazę 80 kg.

Jak widzimy, polimorfizm zapewnia nam dużą wygodę: możemy potraktować wszystkie zwierzęta jako przedstawicieli klasy bazowej: Zwierze.

Abstrakcja

W naszym przykładzie cały czas jest pewien mankament: tak naprawdę nie ma sensu tworzyć instancji klasy Zwierze. Chcemy zakazać tworzenia instancji tej klasy. Poza tym, tak naprawdę każdy gatunek zwierzęcia ma swoją nazwę. Gdyby umieć wprowadzić tę nazwę na poziomie klasy Zwierze (a przecież wtedy jeszcze jej nie znamy, gdyż ustali się ona dopiero podczas dziedziczenia), można by napisać raz a dobrze metodę przedstawiającą zwierzę, z użyciem nazwy gatunku, i to już w klasie bazowej.

Aby zrealizować nasze cele, wprowadzimy nową metodę w klasie bazowej: nazwa_gatunku(). Będzie ona zwracać nazwę gatunku, np. "Lew". Jednak jak wypełnić ciało takiej funkcji? Moglibyśmy oczywiście napisać cokolwiek, np. zwracać napis pusty. Jednak lepszym rozwiązaniem będzie oznaczenie tej metody jako abstrakcyjnej. Oznacza to, że definiujemy w klasie bazowej taką metodę, której nie implementujemy, ale spodziewamy się, że klasy dziedziczące się tym zajmą. Ma to jeszcze jedno następstwo: nie możemy stworzyć instancji klasy bazowej, gdyż obiekt taki miałby niezdefiniowaną metodę. Co ciekawe, mimo to możemy wywoływać tę metodę w klasie bazowej. Spójrzmy:

from abc import ABC, abstractmethod
class Zwierze(ABC):
    def __init__(self, nazwa, wiek, waga):
        self.nazwa = nazwa
        self.wiek = wiek
        self.waga = waga
        
    @abstractmethod # tutaj wymuszamy implementację tej metody w klasach pochodnych
    def nazwa_gatunku(self): 
        pass
    def przedstaw_sie(self):
        print(f"Jestem {self.nazwa_gatunku()}. Mam na imię {self.nazwa}, mam {self.wiek} lat oraz wazę {self.waga} kg.")
        
    def urodziny(self):
        self.wiek += 1
class Slon(Zwierze):
    def nazwa_gatunku(self):
        return "Słoń"
    
class Lew(Zwierze):
    def nazwa_gatunku(self):
        return "Lew"
        
class Papuga(Zwierze):
    def nazwa_gatunku(self):
        return "Papuga"
        
    def __init__(self, nazwa, wiek, waga, kolor):
        super().__init__(nazwa, wiek, waga)
        self.kolor = kolor
        
    def przedstaw_sie(self):
        super().przedstaw_sie()
        print(f"Jako papuga mój kolor to {self.kolor}")
def main():
    Dumboo = Slon("Dumboo", 77, 6000)
    Simba = Lew("Simba", 24, 100)
    Jago = Papuga("Jago", 32, 3, "czerwony")
    # jakis_zwierz = Zwierze("Katarzyna", 31, 80) # będzie błąd
    
    Dumboo.przedstaw_sie()
    Simba.przedstaw_sie()
    # jakis_zwierz.przedstaw_sie()
    
    Jago.urodziny()
    Jago.przedstaw_sie()
if __name__ == "__main__":
    main()
## Jestem Słoń. Mam na imię Dumboo, mam 77 lat oraz wazę 6000 kg.
## Jestem Lew. Mam na imię Simba, mam 24 lat oraz wazę 100 kg.
## Jestem Papuga. Mam na imię Jago, mam 33 lat oraz wazę 3 kg.
## Jako papuga mój kolor to czerwony

Niestety, mechanizm klas i metod abstrakcyjnych (klasa jest abstrakcyjna gdy ma co najmniej jedną metodę abstrakcyjną) w języku Python jest wprowadzony trochę sztucznie. Klasa bazowa (abstrakcyjna) musi dziedziczyć po sztucznej klasie ABC, a metoda abstrakcyjna jest opatrzona dekoratorem @abstractmethod. Zwróćmy uwagę, że jedno i drugie zostało zaimportowane. Jednak po tych czynnościach rzeczywiście nie jesteśmy w stanie stworzyć instancji klasy bazowej.

Zwróćmy uwagę na ten zaawansowany mechanizm: w klasie Zwierze tworzymy metodę, zakładamy, co ta metoda będzie zwracać, a następnie korzystamy z niej w innej metodzie, pomimo, że prawdziwa jej implementacja nastąpi dopiero w klasie pochodnej. Dzięki temu musimy napisać mniej kodu w klasach pochodnych: musimy jedynie zaimplementować metodę nazwa_gatunku(), jednak nie musimy już od zera pisać kodu na przedstawienie zwierzęta. Jedynie w klasie Papuga, gdzie wprowadziliśmy nowe pole, dopisujemy kod odpowiedzialny za wypisanie jego wartości.

Hermetyzacja

Pisząc programy obiektowo, staramy się, aby wnętrze klasy, w szczególności sposób przechowywania poszczególnych danych w polach, pozostawał dla użytkownika nieznany. Dlatego raczej chcemy udostępniać interfejs klasy (możliwość użycia danej klasy do rozwiązania swoich problemów) poprzez metody, niż pola. W szczególności nie chcemy, aby użytkownik sam, ręcznie, modyfikował pola w klasie. Dlatego zazwyczaj tworzy się metody, które pozwalają na uzyskanie wartości danego pola, jak i nadanie mu nowej. Weźmy za przykład pole nazwa.W wielu językach programowania stworzylibyśmy metodę get_name() oraz set_name(nowa_nazwa), które pozwoliłyby na obie te rzeczy. Czemu tak bardzo zależy nam na hermetyzacji i odcięciu użytkownika od pól? Powody są dwa. Po pierwsze możemy chcieć wykonać pewne dodatkowe czynności podczas zmiany pola. Być może jest to bardzo ważne pole, którego zmiana pociąga za sobą w konsekwencji przeliczenie i modyfikację także innych pól? Albo chcemy sprawdzić poprawność danego pola, tak by nie pozwolić na wprowadzenie ujemnego wieku? Po drugie sama implementacja klasy może się zmienić. Być może pojawią się nowe pola, a stare zostaną usunięte. Jednak kod użytkowników naszej klasy został już napisany. Jeśli jest to kod, który operuje na metodach, to prawdopodobnie uda nam się zachować taką samą metodę zwracającą daną informację, choć wyliczającą ją w inny sposób w swym wnętrzu. Przy polach nie mamy takiej możliwości.

Jednak oczywiście używanie metod, zwłaszcza z przedrostkiem get_ czy set_, jest mniej wygodne. Dlatego nowoczesne języki programowania umożliwiają tworzenie tzw. właściwości (ang. property). Z punktu widzenia możliwości, są to po prostu metody, jednak z punktu widzenia zapisu i wygody, przypominają one pola.

Dla większej czytelności pozostaniemy co prawda przy zwierzęciu, jednak zostawimy jedno pole, a także pominiemy klasy dziedziczące. Polem będzie wiek, który musi być dodatni, jednak nie większy niż 200:

class Zwierze:
    def __init__(self, wiek):
        self.wiek = wiek
        
    @property
    def wiek(self):
        return self.__wiek
    @wiek.setter
    def wiek(self, wiek):
        if wiek < 0:
            self.__wiek = 0
        elif wiek > 200:
            self.__wiek = 200
        else:
            self.__wiek = wiek
def main():
    jakis_zwierz = Zwierze(202)
    print(jakis_zwierz.wiek)
    jakis_zwierz.wiek = -10
    print(jakis_zwierz.wiek)
    jakis_zwierz.wiek = 30
    print(jakis_zwierz.wiek)
    
if __name__ == "__main__":
    main()
## 200
## 0
## 30

Tym razem w osiągnięciu naszego celu pomógł nam dekorator @property. Zwróćmy uwagę, że już na etapie konstruktora został wywołany kod, który sprawdził poprawność przekazanej wartości wieku.

Zadanie 5

Figury geometryczne

Stwórz hierarchię klas reprezentujących figury geometryczne. Każda figura powinna umieć wypisać informacje o sobie, a także obliczyć swój obwód i pole. W grę niech wchodzą koła, prostokąty, kwadraty oraz trójkąty. Czy prostokąt i kwadrat mogą być połączone relacją dziedziczenia?

Struktura wyrażeń arytmetycznych

Stwórz hierarchię klas: węzeł dodawania, odejmowania, mnożenia i dzielenia, a także silnii. Poza tym powinien być węzeł zwykłej wartości typu float. Węzły dodawania, odejmowania, mnożenia i dzielenia mają po dwa węzły potomne (być może inne działanie, a być może po prostu wartość), silnia jeden węzeł potomny, a wartość nie ma żadnych dzieci. Kluczową będzie tu metoda abstrakcyjne oblicz_wartosc(), która zwraca obliczoną wartość danego węzła. Polami powinny być węzły potomne.


Gotowe rozwiązanie zadania 5 znajdziecie tutaj.