G. Jagiella

Skrypt do wykładu Programowanie 2 (Python)

ostatnia modyfikacja: 24.04.2021

Wykład 8 - dziedziczenie

W rozdziale omówimy dziedziczenie (tu w szczególnym znaczeniu - dziedziczenie klas): koncept programowania obiektowego pozwalający na definiowanie nowych klas w oparciu o istniejące, rozszerzając lub zmieniając ich funkcjonalność.

Rozważmy przykładową klasę Animal. Myślimy o niej jak o "szkielecie" lub atrapie klasy, reprezentującej bliżej niesprecyzowane zwierzę:

class Animal:
    def __init__(self, name, speed):
        self.name = name
        self.speed = speed

    def get_name(self):
        return self.name

    def get_speed(self):
        return self.speed

    def eat(self, food):
        print(f'{self.name}: Yum!')

Dla potrzeb technicznych, identycznie zaimplementowaną klasę zaimportujemy z pliku animal.py, a wraz nią pewną funkcję:

In [1]:
from animal import Animal
from fastest_animal import simulate_race

Zwierzę posiada zatem imię (w rozumieniu "Azor" lub "Krasula", nie "Pies" lub "Krowa"), prędkość, metody do odczytywania tych atrybutów i atrapę operacji zjadania (ochoczo) podanego jedzenia.

Przypomnijmy najpierw, jak od technicznej strony wygląda proces tworzenia obiektów tej klasy (czyli instancji klasy).

Przywiązane metody odpowiadają funkcjom zdefiniowanym w Animal. Można je wywołać jak funkcję, powodując wywołanie odpowiedniej funkcji z Animal, gdzie parametr self wskazuje na obiekt, do którego dowiązana jest metoda:

In [2]:
dog = Animal("Dogmeat", 28.0)

dog.get_name()    # tłumaczy się na: Animal.get_name(dog)
dog.get_speed()   # tłumaczy się na: Animal.get_speed(dog)
dog.eat("bone")   # tłumaczy się na: Animal.eat(dog, "bone")
Dogmeat: Yum!

Przy czym metoda specjalna __init__ wywoływana jest automatycznie już przy kreacji obiektu:

In [3]:
orca = Animal("Willy", 56.0)  # tworzy "anonimowy" i "surowy" obiekt x i tłumaczy się na
                              # Animal.__init__(x, "Willy", 56.0)
                              # (tu: utworzony obiekt x zostaje później nazwany orca)

orca.eat('fish')  # tłumaczy się na: Animal.eat(orca, "fish")
Willy: Yum!

Rozważmy teraz (już zaimportowaną) funkcję simulate_race(animals_list), symulującą wyścig między obiektami typu Animal, gdzie animals_list to ich niepusta lista. Funkcja:

  • Sprawdza, czy lista jest niepusta.
  • Wypisuje proste informacje o "uczestnikach" wyścigu (imię i prędkość, z użyciem get_name() i get_speed()).
  • Wybiera najszybsze zwierzę, ogłasza je zwycięzcą (znów używając get_speed(), get_name()), daje do zjedzenia nagrodę (z użyciem eat()).
In [4]:
simulate_race([dog, orca, Animal('Nemo', 5.0)])
Competing: Dogmeat (28.0 km/h), Willy (56.0 km/h), Nemo (5.0 km/h)
Willy won!
Willy: Yum!

Szczegóły implementacji simulate_race nie są tu szczególnie istotne (funkcja znajduje się w pliku fastest_animal.py, również należy traktować ją jak "atrapę"). Zauważmy jednak, że - jak wynika z opisu - funkcja wewnętrznie używa wszystkich operacji (metod) zdefiniowanych dla klasy Animal. Aby upewnić się, że obiekty z animals_list faktycznie mają pożądane metody - oraz że nie są to tylko metody o tej samej nazwie, które obiekty z listy mają przez przypadek - funkcja przed przeprowadzeniem wyścigu dodatkowo:

  • Sprawdza, czy wszystkie obiekty na liście to instancje Animal: używa do tego isinstance(a, Animal). Jeśli nie, to rzuca wyjątek TypeError z komunikatem "A non-animal attempted to race!".

Intencją tego testu jest sprawdzenie - w bardzo silny sposób - czy przekazane funkcji dane spełniają wymagania stawiane przez jej implementację:

In [5]:
try:
    simulate_race([dog, orca, Animal('Nemo', 5.0), "Jestem tylko napisem"])
except TypeError as e:
    print("Padł wyjątek:", e)
Padł wyjątek: A non-animal attempted to race!

Rozważmy teraz sytuację, w której klasa Animal i funkcja simulate_race jest częścią większego programu, w którym mamy potrzebę reprezentowania zwierząt w sposób bardziej szczegółowy, niż pozwala na to klasa Animal: wśród obiektów-zwierząt chcemy wyróżnić np. ryby, ptaki i (bardzo konkretnie) psy. Od tych szczególnych rodzajów zwierząt wymagamy, aby:

  1. Można na nich wykonywać te same operacje, co na obiektach typu Animal.
  2. Można było wykonywać również pewne dodatkowe operacje: ryby powinny móc pływać, ptaki powinny móc latać, a psy szczekać.

Rozwijając punkt 1. - jest naszym życzeniem aby te nowe, wyspecjalizowane rodzaje zwierząt mogły być używane wymiennie ze zwierzętami-instancjami klasy Animal. W szczególności, aby były w stanie ścigać się z obiektami klasy Animal (przez funkcję fastest_animal).

Myśląc o "rodzajach" zwierząt nasuwa się pomysł, aby owe rodzaje zrealizować jako nowe klasy - Fish, Bird, Dog, w taki sposób, aby wszystkie definiowały operacje (metody) definiowane w klasie Animal, oraz pewne dodatkowe (latanie etc.).

Podejście pierwsze (które zawiedzie). Tworzymy klasę Fish1 (1 reprezentuje "pierwszą próbę"), kopiującą treść Animal, uzupełnioną o metodę swim():

In [6]:
class Fish1:
    def __init__(self, name, speed):
        self.name = name
        self.speed = speed
    
    def get_name(self):
        return self.name
    
    def get_speed(self):
        return self.speed
    
    def eat(self, food):
        print(f'{self.name}: Yum!')

    def swim(self): # znów "atrapa" metody pływania
        print(f'{self.name}: is swimming!')

Taka klasa w dość dosłowny sposób implementuje operacje Animal. W analogiczny sposób można zaimplementować Bird1, Dog1 (lub inne klasy). Rozwiązanie ma jednak wady:

  1. Aby metody Fish1 odpowiadały metodom Animal, każda zmiana w treści Animal wymagałaby odpowiednich zmian w treści Fish1 (a także każdej innej klasy reprezentującej nowy rodzaj zwierzęcia: Bird1, Dog1, etc.)
  2. Poza przypadkową (z punktu widzenia języka) zbieżnością nazw metod w Animal i metod w Fish1, klasy nie mają ze sobą nic wspólnego. Instancje Fish1 nie są rozpoznawane jako instancje Animal:
In [7]:
fish1 = Fish1('Nemo', 5.0)
isinstance(fish1, Animal)
Out[7]:
False

Problem 1. z pierwszej próby to szczególny przypadek niepotrzebnej duplikacji kodu. Problem 2. jest ciekawszy: pomimo faktu, że na obiektach typu Fish1 teoretycznie można wykonać te same operacje, co na obiektach typu Animal, nie wystarcza to, aby stosować je wymiennie. Próba wystawienia obiektu typu Fish1 w wyścigach zawiedzie: simulate_race nie dopuści tego "udawanego" obiektu do wyścigu:

In [8]:
try:
    simulate_race([orca, fish1])
except TypeError as e:
    print("Padł wyjątek:", e)
Padł wyjątek: A non-animal attempted to race!

Podejście zatem zawiodło. Skoro zależy nam, aby nowe rodzaje zwierząt były rozpoznawane jako instancje klasy Animal, do głowy przychodzi nowe rozwiązanie.

Podejście drugie (które też jest złe). Uzupełniamy klasę Animal o operacje stosowne dla ryb, ptaków, psów:

class Animal:
    def __init__(self, name, speed):
        self.name = name
        self.speed = speed
    
    def get_name(self):
        return self.name
    
    def get_speed(self):
        return self.speed
    
    def eat(self, food):
        print(f'{self.name}: Yum!')

    def swim(self):  # ryba pływa
        print(f'{self.name}: is swimming!')

    def fly(self):   # ptak lata
        print(f'{self.name}: is flying!')

    def bark(self):  # pies, rzecz jasna, szczeka
        print(f'{self.name}: WOOF!')


Znów dość dosłowne rozwiązanie. Ono też ma wady:

  1. Obiekty tracą swoją specyfikę (lub tożsamość): zamiast wydzielonych klas reprezentujących rodzaje zwierząt, każda instancja Animal jest jednocześnie rybą, ptakiem i psem (a przynajmniej pływającą, latającą i szczekającą chimerą).
  2. Rozwiązanie wymaga modyfikacji klasy Animal, co może mieć nieprzewidziane konsekwencje.
  3. Co gorsza, modyfikacja może być niepraktyczna: może być tak, że klasa Animal (i funkcje, które ją gdzieś wykorzystują, tak jak simulate_race) to część zewnętrznej biblioteki, a nie nasz własny kod. Jeśli biblioteka jest często aktualizowana, to za każdym jej uaktualnieniem musimy nanosić zmiany od nowa. Jeszcze gorzej: klasa-atrapa Animal mogłaby być wbudowanym typem Pythona.

Problem 3. z podejścia drugiego wyklucza też "oczywiste" rozwiązanie, polegające na zwyczajnym usunięciu warunku z simulate_animal odrzucającego obiekty inne, niż Animal.

Potrzebujemy innego rozwiązania problemu.

Dziedziczenie klas to mechanizm pozwalający na definiowanie nowych klas w oparciu o istniejące tak, aby poszerzać lub zmieniać ich funkcjonalność. Pozwoli nam na rozwiązanie problemu ze wstępu, jednak jego zastosowanie znacznie wykracza poza ten szczególny przykład.

Zastrzeżenie. W tym rozdziale (wykładzie) ograniczamy się jedynie do jednokrotnego dziedziczenia klas i jego konkretnej realizacji w Pythonie. Nie poruszymy między innymi tematów: dziedziczenia wielokrotnego, dziedziczenia prototypowego, oraz innych sposobów realizacji tych mechanizmów (wykorzystywanych w innych językach). Wyjąwszy to zdanie, ani razu nie zostanie też użyte słowo polimorfizm. W dalszych rozdziałach skryptu zajmiemy się też tylko dziedziczeniem wielokrotnym klas.

Klasy w Pythonie można definiować, podając w ich definicji nazwę istniejącej klasy - tzw. klasę bazową, z której nowa klasa będzie dziedziczyć (tę nową klasę będziemy też nazywać podklasą klasy bazowej):

In [9]:
class Fish(Animal): # Fish dziedziczy z klasy bazowej Animal (zaimportowana na wstępie skryptu!)
    def swim(self):
        print(f'{self.name}: is swimming!')

Nowoutworzone obiekty typu Fish zostaną wtedy wyposażone w metody zdefiniowane zarówno w Fish, jak i te z klasy bazowej Animal:

In [10]:
nemo = Fish('Nemo', 5.0)
nemo.swim()           # tłumaczy się na Fish.swim(nemo)
nemo.eat('fishfood')  # tłumaczy się na Animal.eat(nemo, 'fishfood')
Nemo: is swimming!
Nemo: Yum!

Intuicyjnie, można w tej sytuacji myśleć, że treść definicji Animal "wkleja się" w treść Fish. O "wklejonych" w ten sposób metodach myślimy jako o "odziedziczonych z Animal".

Szczególnym przypadkiem odziedziczonej metody jest konstruktor (ściślej: inicjalizator) __init__: to on pozwalał nam na kreację obiektów Fish z podaniem imienia i prędkości, zapamiętywanych następnie jako atrybuty instancji:

In [11]:
nemo = Fish('Nemo', 5.0) # tworzy anonimowy obiekt x typu `Fish`, wywołuje Animal.__init__(x, 'Nemo', 5.0), nazywa go nemo
print(nemo.name)
Nemo

W konsekwencji, atrybutu name wolno nam było użyć w definicji dodanej metody swim().

W podobny sposób możemy zdefiniować dalsze "specjalizacje" klasy Animal do klas reprezentujących inne, szczególne rodzaje zwierząt i tworzyć ich instancje:

In [12]:
class Bird(Animal):
    def fly(self):
        print(f'{self.name}: is flying!')

class Dog(Animal):
    def bark(self):
        print(f'{self.name}: WOOF!')

dog = Dog('Dogmeat', 28.0)
sparrow = Bird('Elemelek', 46.0)
dog.bark()
sparrow.fly()
Dogmeat: WOOF!
Elemelek: is flying!

Wciąż możemy też tworzyć instancje bazowej klasy Animal:

In [13]:
orca = Animal('Willy', 56.0)
orca.eat('tasty fish')
Willy: Yum!

Mechanizm dziedziczenia pozwala nam zatem na tworzenie wyspecjalizowanych "wersji" istniejących klas, uzupełniając je o nowe operacje. Na początku podrozdziału wspomnieliśmy też jednak o możliwości modyfikacji istniejących operacji. Instancje atrapowego Animal ze smakiem zjadają każde podane im jedzenie. To zachowanie można zmienić w nowej klasie, dziedziczącej z Animal, poprzez zdefiniowanie na nowo metody eat:

In [14]:
class Human(Animal):
    def eat(self, food):
        print(f'{self.name}: nie chce mi się jeść.')
        
human = Human('Agata', 10.0)
human.eat('fastfood') # Tłumaczy się na: Human.eat(human, 'fastfood')
Agata: nie chce mi się jeść.

Gdy metoda klasy zostaje zdefiniowana na nowo w jej podklasie, będziemy mówić, że została nadpisana. W dalszym podrozdziale wyjaśnimy szczegóły mechanizmu, według którego Python rozpoznaje, że powyższe wywołanie human.eat tłumaczy się na wywołanie odpowiedniej metody Human, a nie Animal.

Zbadajmy typy i zależności między klasami Animal oraz Fish, Bird i Dog. Bez zaskoczenia stwierdzamy, że typ nowoutworzonego obiektu klasy Animal to właśnie typ Animal; analogicznie dla pozostałych klas:

In [15]:
# orca = Animal(...)
# nemo = Fish(...)
# sparrow = Bird(...)
# dog = Dog(...)

print(type(orca))
print(type(nemo))
print(type(sparrow))
print(type(dog))
<class 'animal.Animal'>
<class '__main__.Fish'>
<class '__main__.Bird'>
<class '__main__.Dog'>

Nazwy typów (klas) tych obiektów podane są wraz z modułem (przestrzenią nazw) w której się znajdują (uwaga: na samym początku podrozdziału, klasę Animal importowaliśmy z modułu animal; stąd wynik).

Co natomiast o tych obiektach orzeknie wbudowana funkcja isinstance?

In [16]:
print(isinstance(dog, Dog))
print(isinstance(dog, Animal))
print(isinstance(dog, Fish))
print(isinstance(dog, Bird))
True
True
False
False

Pies jest jak widać psem, jest też zwierzęciem, ale nie rybą ani ptakiem.

W terminach klas: obiekty typu Dog są instancjami typu Dog, ale są również instancjami Animal, czyli klasy, z której Dog dziedziczy. Nie są natomiast instancjami Fish i Bird, które też dziedziczą z Animal.

Ogólna reguła: isinstance(obj, T) (gdzie obj to obiekt, T to klasa) jest prawdziwe dokładnie wtedy, gdy typ obj to T lub typ obj dziedziczy z T.

Mamy zatem utworzone cztery nowe podklasy Animal - klarownie wyodrębnione typy ryb, ptaków, psów i ludzi takie, że na ich instancjach można wykonywać operacje, które można wykonywać na instancjach Animal (oraz być może pewne dodatkowe - w zależności od klasy), i które traktowane są też jako instancje Animal.

Właściwie mimochodem rozwiązuje to problem ze wstępu:

In [17]:
from fastest_animal import simulate_race

simulate_race([dog, nemo, human, sparrow, orca])
Competing: Dogmeat (28.0 km/h), Nemo (5.0 km/h), Agata (10.0 km/h), Elemelek (46.0 km/h), Willy (56.0 km/h)
Willy won!
Willy: Yum!
In [18]:
simulate_race([nemo, human])
Competing: Nemo (5.0 km/h), Agata (10.0 km/h)
Agata won!
Agata: nie chce mi się jeść.

Dlaczego jednak instancje podklasy traktowane są jako instancje samej klasy?

Pisząc na przykład:

class Dog(Animal):
    # ...

sprawiamy, że instancje klasy Dog będą wyposażone co najmniej w te same operacje (metody), co instancje Animal. Gwarantujemy zatem, że fragmenty kodu, które oczekują obiektów klasy Animal (chcąc wywołać na nich pewne metody, lub odczytać ich atrybuty), będą działały również z obiektami typu Dog. Ta obietnica (!) jest złożona w bardzo silnym sensie, bo zostanie zachowana nawet, gdy zmienimy definicję klasy Animal. Skoro tak, to nie ma przeszkód, aby traktować obiekty typu Dog na równi z obiektami typu Animal wszędzie tam, gdzie te drugie są oczekiwane. Słowem: traktować instancje Dog również jako instancje Animal. Ogólnie: traktować instancje klasy jako instancje klasy, z której ta klasa dziedziczy.

Podkreślmy jednak wyraźnie, że o ile na instancjach podklasy można wykonywać (co najmniej) te same operacje, co na instancjach samej klasy, to nie ma gwarancji, że operacje te wykonają się tak samo. Pozwala na to mechanizm nadpisywania metod. Przykładem była metoda eat klasy Human, podklasy Animal. Ten aspekt zbadamy później na przykładach: okaże się wielką siłą i zaletą mechanizmu dziedziczenia.

Tu może pojawić się pewna wątpliwość: czy mechanizm nadpisywania metod nie pozwala nam na złamanie obietnicy ustanowionej przez dziedziczenie. Rozważmy na przykład taką podklasę Animal:

In [19]:
class Human2(Animal):
    def eat(self, food, cutlery):
        print(f'{self.name}: zaiste, nie mam ochoty spożywać.')

Tutaj cutlery to hipotetyczny parametr boolowski, zależny od tego, czy "nowy człowiek" typu Human2 je z użyciem sztućców, czy w sposób mniej wyrafinowany. Na obiektach tej klasy można wywołać metodę eat, i metoda ta będzie tłumaczyć się na wywołanie Human2.eat, wymagającej podania parametrów food i cutlery:

In [20]:
sir = Human2('Sir Aristocrat', 0.0)
sir.eat('comber', True) # Human2.eat(sir, 'comber', True)
# sir.eat('kebab') # wyjątek: brakuje drugiego argumentu!
Sir Aristocrat: zaiste, nie mam ochoty spożywać.

Zmieniając sygnaturę metody eat (przez wymaganie podania dwóch parametrów zamiast jednego), łamiemy obietnicę: sir.eat('kebab') powinno być legalną operacją podjętą na sir, instancji Animal. Klasa napisana w ten sposób wciąż będzie "działać", jednak będzie to zgłoszone jako błąd np. w edytorze kodu. Problem znika, gdy metodę napiszemy czyniąc "nadmiarowy" parametr opcjonalnym:

In [21]:
class Human2(Animal):
    def eat(self, food, cutlery=False):
        print('{self.name}: zaiste, nie mam ochoty spożywać.')

Zwracamy przy tym uwagę, że zmiany sygnatur niektórych metod (__init__ - ze względu na specyfikę użycia) nie stanowią złamania obietnicy.

O ile nie ma mechanizmu, który nam tego zabrania, w Pythonie klasę można utworzyć dziedzicząc z dowolnej klasy, w szczególności z takiej, która już dziedziczy z innej. Rozważmy przykład:

In [22]:
class First:
    def fun1(self):
        print("fun1")


class Second(First): # Second dziedziczy z First
    def fun2(self):
        print("fun2")


class Third(Second): # Third dziedziczy z Second
    def fun3(self):
        print("fun3")

W tej sytuacji, klasa Third dziedziczy z Second, oraz pośrednio z klasy First. W konsekwencji, obiekty typu Third będą wyposażone w metody zdefiniowane we wszystkich trzech klasach, i będą traktowane jak ich instancje:

In [23]:
x = Third() # w klasach nie definiowaliśmy konstruktora - użyty jest domyślny, "pusty"
print(isinstance(x, Third))
print(isinstance(x, Second))
print(isinstance(x, First))
x.fun3() # Third.func3(x)
x.fun2() # Second.func2(x)
x.fun1() # First.func1(x)
True
True
True
fun3
fun2
fun1

Relacja bycia podklasą jest zatem przechodnia: podklasa podklasy klasy jest podklasą klasy. Możliwość dziedziczenia z podklas daje nam możliwość tworzenia bogatych hierarchii dowolnie wyspecjalizowanych klas o strukturze drzewa (w którym krawędzie ustawione są "na odwrót": strzałki prowadzą od podklas do ich (bezpośrednich) klas bazowych), na przykład:

Na obrazku powyżej, poziom wyspecjalizowania gałęzi "orlej" jest przypuszczalnie przesadzony. Wszystkie przedstawione klasy dziedziczą z Animal (możemy przyjąć, że Animal dziedziczy z siebie samej). Inaczej: Animal jest wspólną nadklasą wszystkich przedstawionych klas. Nadklasą danej klasy nazywamy dowolną klasę, z której ta klasa dziedziczy (bezpośrednio lub nie). Wybierając dowolną gałąź w drzewie, każde dwie klasy w tej gałęzi stoją w relacji podklasa-nadklasa. W szczególności np. CrownedEagle jest podklasą Eagle, który jest podklasą Bird, który jest podklasą Animal.

Rozważmy bogatszy, ale wciąż "atrapowy" przykład trzech klas. Zwróćmy uwagę na komentarze przy atrapowych metodach.

In [24]:
class A:
    def f(self):  # idea: prosta operacja na obiekcie
        print(self, 'A.f')

    def g(self):  # idea: złożona operacja (np. skomplikowany algorytm), wykorzystująca f()
        self.f()


class B(A): # B dziedziczy z A
    def h(self):  # nowa operacja (nie będzie istotna w przykładzie)
        print(self, 'B.h')


class C(B): # C dziedziczy z B
    def f(self):  # nadpisana prosta operacja A.f
        print(self, 'C.f')

Skonstruujmy obiekt typu A i wywołajmy na nim f:

In [25]:
a = A()
a.f()
<__main__.A object at 0x05195C70> A.f

Tu nie ma niespodzianki: a.f() doprowadziło do wywołania A.f(a). Z kolei dla obiektu typu C:

In [26]:
c = C()
c.f()
<__main__.C object at 0x051953B0> C.f

Wywołane zostało C.f(c).

Gdy wywołujemy metodę na obiekcie danego typu, metoda ta jest wyszukiana najpierw w klasie tego obiektu, następnie jego klasie bazowej (jeśli taka jest), później jej klasie bazowej, i tak dalej, odwiedzając klasy coraz wyżej w hierarchii dziedziczenia, aż do znalezienia metody (lub wyczerpania klas).

W powyższym przykładzie, dla obiektu c, metoda f szukana jest po kolei w klasach C, B, A. Została znaleziona już w klasie C. Stąd c.f() doprowadziło do wywołania C.f(c).

Analogicznie, dla a, przeszukiwana jest tylko klasa A - typ a.

To zachowanie ma interesującą konsekwencję. Do czego doprowadzi wywołanie c.g()?

In [27]:
c.g()
<__main__.C object at 0x051953B0> C.f

Przesledźmy: definicja g znajduje się tylko w klasie A, zatem c.g() prowadzi do wywołania A.g(c). Wewnątrz A.g wywoływane jest self.f(), gdzie self jest tożsame z obiektem c. Zatem metody f poszukujemy najpierw w klasie C (typie obieku c), w której ją znajdujemy. To prowadzi do wywołania C.f na obiekcie c.

To zachowanie może być sprzeczne z odruchową intuicją, każącą myśleć, że "metoda g w klasie A wywołuje metodę f w klasie A". To niekoniecznie będzie prawda, bo zależy to w całości od faktycznego typu aktualnego parametru self, który (w tym przykładzie) może być dowolną instancją A: na przykład właśnie obiektem typu C.

Odnieśmy to do znaczenia operacji f() i g() z przykładu: f() to prosta operacja, wykorzystawana przez skomplikowną operację g(). Klasa C nadpisuje prostą operację f(), jednak nie nadpisuje operacji skomplikowanej. Gdy wywołujemy c.g(), to na obiekcie c wykonana zostaje skomplikowana operacja A.g - zaimplementowana w klasie A - jednak z wykorzystaniem prostej operacji C.f zamiast A.f.

W konsekwencji: pomimo, że w klasie C nadpisaliśmy jedynie zachowanie prostej operacji f(), zmieniliśmy również zachowanie skomplikowanego algorytmu g() wykorzystującego ją, bez konieczności przepisywania go od nowa. W pewnym sensie, "podmieniliśmy" jedynie fragment tego algorytmu.

Mechanizm ten pozwala nam na tworzenie dowolnie wyspecjalizowanych klas obiektów, realizujących indywidualne operacje na własny sposób, wpływający na realizacje algorytmów (operacji) zadanych w ich nadklasach; wszystko przy zachowaniu ścisłej hierarchii typów, pozwalającej na łatwą weryfikację, jakie operacje są dostępne dla konkretnego obiektu. Pozwala to tworzenie elastycznych rozwiązań różnych klasycznych problemów w programowaniu obiektowym.

Nadpisując metodę, często pojawia się potrzeba wywołania odpowiedniej metody z nadklasy: przykładowo, weźmy klasę reprezentującą pojazd:

In [28]:
class Vehicle:
    def __init__(self, color, speed):
        self.color = color
        self.speed = speed
    
    def do_something(self):
        print('obiekt Vehicle coś robi!')
        # jakaś skomplikowana operacja
        # ...

Chcemy utworzyć klasę Car, podklasę Vehicle, reprezentująca samochód. Chcemy, aby operacja do_something() dla obiektów typu Car wykonywała to samo, co odpowiednia operacja dla obiektów Vehicle, oraz coś jeszcze. Nie chcemy zatem w całości nadpisać metody do_something(), ale nie chcemy też niepotrzebnie duplikować kodu.

Rozwiązaniem jest wywołanie metody do_something() zdefiniowanej w Vehicle w treści do_something() zdefiniowanego w Car. Ponieważ Vehicle.do_something to zwykła funkcja, możemy jej po prostu użyć:

In [29]:
class Car(Vehicle):
    def do_something(self):
        Vehicle.do_something(self)
        print('obiekt Car coś robi!')
        
car = Car("red", 250)
car.do_something()
obiekt Vehicle coś robi!
obiekt Car coś robi!

Identyczny efekt uzyskamy używając wbudowanego obiektu super, tworzącego tak zwany "obiekt proxy" dla parametru self:

In [30]:
class Car(Vehicle):
    def do_something(self):
        super().do_something()
        print('obiekt Car coś robi!')
        
car = Car("red", 250)
car.do_something()
obiekt Vehicle coś robi!
obiekt Car coś robi!

Obiekt super utworzony w implementacji metody klasy będzie zachowywał się tak jak self, ale dla takiego obiektu inna będzie kolejność przeszukiwania metod: będą one szukane począwszy od klasy bazowej danej klasy. W tej sytuacji, super().do_something() stara się wyszukać metody do_something począwszy od klasy Vehicle, nie Car, i wywołać ją dla parametru self.

Na razie nie widać powodów używania super() zamiast zwyczajnego wywoływania metody z nadklasy. Traktujmy to jednak jako "dobrą praktykę": jej sens okaże się podczas omawiania dziedziczenia wielokrotnego.

Bardzo częstą sytuacją odwoływania się do metod z nadklas jest pisanie konstruktora podklasy. Modyfikując powyższy przykład:

In [31]:
class Car(Vehicle):
    def __init__(self, color, speed, n_wheels): # n_wheels - ile kół
        super().__init__(color, speed) # czyli tu: Vehicle.__init__(self, color, speed) - inicjalizuje atrybuty color i speed
        self.n_wheels = n_wheels # część specyficzna dla Car
    def do_something(self):
        super().do_something()
        print('obiekt Car coś robi!')
        
car = Car("red", 250, 4)
print(car.color)
red

Opiszemy teraz większy przykład, rozpoczynając od (poznanej już na wykładzie w pierwszym semestrze, po drobnej modernizacji) klasy BankAccount, reprezentującej proste konto bankowe. Konto ma numer i saldo (stan, koniecznie nieujemny) podane przy konstrukcji. Na konto można wpłacać i wypłacać pieniądze, można też dokonać przelewu wszystkich pieniędzy na inne podane konto. Interfejs klasy składa się z metod:

  • __init__(self, number, balance=0) - tworzy nowe konto o podanym numerze i początkowym stanie konta (balance).
  • deposit(self, amount) - wpłaca zadaną ilość pieniędzy na konto.
  • withdraw(self, amount) - wypłaca zadaną ilość pieniędzy z konta.
  • merge_to(self, other_account) - przelewa wszystkie pieniądze z konta self na other_account.

  • __str__(self) - zwraca napis z informacjami o koncie.

Szczegóły implementacji znajdują się w pliku bankaccount.py. Metody deposit i withdraw wykonują proste weryfikacje, czy podane ilości pieniędzy są poprawne (np. nieujemne) i czy nie próbujemy wybrać z konta więcej pieniędzy, niż na nim jest. merge_to z założenia działa na dowolnej instancji BankAccount (nie weryfikuje, czy other_account taką instancją jest; można oczywiście taki test dodać). Przykładowe użycie:

In [32]:
from bankaccount import BankAccount

account = BankAccount("1234-BANK-0000", 10000)
print(account)  # 1
account.deposit(500)
print(account)  # 2
account.withdraw(8000)
print(account)  # 3

another_account = BankAccount("9999-BANK-0000")
print(another_account)  # 4

account.merge_to(another_account)
print(account)  # 5
print(another_account)  # 6
Account number 1234-BANK-0000, balance: 10000
Account number 1234-BANK-0000, balance: 10500
Account number 1234-BANK-0000, balance: 2500
Account number 9999-BANK-0000, balance: 0
Account number 1234-BANK-0000, balance: 0
Account number 9999-BANK-0000, balance: 2500

Tak zdefiniowane konto bankowe wyspecjalizujemy teraz do bardziej konkretnego rodzaju konta bankowego: konta oszczędnościowego z oprocentowaniem. Oprocentowanie podaje się przy konstrukcji konta jako ułamek (np. 0.05 dla 5%). Jedną z operacji na takim koncie jest kapitalizacja odsetek, zwiększająca stan konta o ich wielkość. Tworzymy klasę SavingsAccount, podklasę BankAccount, wyposażoną w następujące dodatkowe lub nadpisane metody:

  • __init__(self, number, balance=0, interest_rate=0.05) - nowy konstruktor, który oprócz numeru i początkowego stanu konta przyjmuje też stopę odsetek na jeden okres kapitalizacji. Wewnętrznie, konstruktor wywołuje konstruktor BankAccount, przekazując mu number i balance.
  • get_interests(self) - wylicza odsetki, które powinny być naliczone podczas kapitalizacji. Odsetki to iloczyn stopy oprocentowania (podanej przy konstrukcji obiektu) oraz aktualnego stanu konta.
  • capitalize(self) - dolicza do stanu konta odsetki, obliczone przy pomocy get_interests.

Implementacja SavingsAccount znajduje się w savingsaccount.py. Jak widać, poza zmodyfikowanym konstruktorem, nowa klasa jedynie rozszerza funkcjonalność bazowego BankAccount:

In [33]:
from savingsaccount import SavingsAccount

savings_account = SavingsAccount("1234-SAVE-0001", 5000, 0.08)
print(savings_account) # 1
print('Next interests: ' + str(savings_account.get_interests())) # 2
for i in range(3):
    print('Capitalizing...') # 3, 5, 7
    savings_account.capitalize()
    print(savings_account) # 4, 6, 8

print('Next interests: ' + str(savings_account.get_interests())) # 9

account = BankAccount("1234-1111-0001", 30000)
# account.capitalize()   # niedostępne dla BankAccount!
print(account) # 10

account.merge_to(savings_account) #  przelewamy wszystko z instancji BankAccount na instancję SavingsAccount
print(savings_account) # 11
print(account) # 12
Account number 1234-SAVE-0001, balance: 5000
Next interests: 400.0
Capitalizing...
Account number 1234-SAVE-0001, balance: 5400.0
Capitalizing...
Account number 1234-SAVE-0001, balance: 5832.0
Capitalizing...
Account number 1234-SAVE-0001, balance: 6298.56
Next interests: 503.88480000000004
Account number 1234-1111-0001, balance: 30000
Account number 1234-SAVE-0001, balance: 36298.56
Account number 1234-1111-0001, balance: 0

Wyspecjalizujemy teraz konto oszczędnościowe, pisząc jego podklasę CreditAccount, reprezentujące konto kredytowe. Konto takie dalej funkcjonuje jak konto oszczędnościowe (naliczane są odsetki zgodnie z podaną stopą). Dopuszczamy jednak sytuację, w której stan takiego konta jest ujemny (reprezentując dług zaciągnięty wobec banku). Zmieniamy zatem pewne założenia dotyczące takich kont. Dla kont kredytowych, dopuszczamy wybieranie pieniędzy w dowolnej ilości, nawet przekraczającej stan konta (co sprawi, że nowy stan będzie ujemny). Jeśli konto ma stan ujemny, przestajemy naliczać odsetki (trochę nierealistycznie, nie naliczamy odsetek od długu). W takiej sytuacji zabraniamy też przelewów wszystkich pieniędzy na inne konto. Zgodnie z tymi nowymi regułami, modyfikujemy stosowne metody. Klasa CreditAccount implementuje zatem:

  • withdraw(self, amount) - robi to samo, co nadpisana metoda zdefiniowana w BankAccount, ale nie sprawdza, czy amount przekracza stan konta.
  • merge_to(self, other_account) - to samo, co merge_to zdefiniowana w BankAccount, ale tylko pod warunkiem, że stan konta self nie jest ujemny (w implementacji używa super(), aby wywołać merge_to z klasy wyżej w hierarchii).
  • get_interests(self) - oblicza wartość odsetek. Wartość wynosi 0, gdy stan konta jest ujemny, w przeciwnym wypadku jest naliczany identycznie, jak w SavingsAccount. Nie odnosi się jednak bezpośrednio do metod SavingsAccount.

Zwracamy uwagę, że klasa nie nadpisuje metody capitalize z SavingsAccount: to dlatego, że dodawanie odsetek przebiega identycznie dla obiektów typu SavingsAccount jak i CreditAccount, a różnica polega jedynie w sposobie naliczania odsetek. Jednak ta różnica została już wprowadzona przez nadpisanie get_interests, używanej przez capitalize. Jest to analogiczna sytuacja, do opisanej w przykładzie z podrozdziału "Hierarchia dziedziczenia i kolejność wyszukiwania metod" (capitalize jest w roli "skomplikowanego algorytmu" g, a get_interest to "prosta operacja" f).

Implementacja CreditAccount znajduje się w pliku creditaccount.py. Kończymy przykładem jej użycia:

In [34]:
from creditaccount import CreditAccount

credit_account = CreditAccount("2222-CRED-0001", 1000, 0.05)
credit_account.capitalize() # stan konta dodatni, doliczamy dodatnie odsetki
print(credit_account) # 1
credit_account.withdraw(2000)  # stan konta ujemny
print(credit_account) # 2
credit_account.capitalize()  # stan konta ujemny, get_interests() zwróci 0, stan konta się nie zmieni
print(credit_account) # 3
Account number 2222-CRED-0001, balance: 1050.0
Account number 2222-CRED-0001, balance: -950.0
Account number 2222-CRED-0001, balance: -950.0

Poniżej lista (bardzo standardowych) terminów, używanych na wykładzie w związku z dziedziczeniem:

  • B jest podklasą A gdy B dziedziczy z A, bezpośrednio lub nie. Równoważnie, A jest wtedy nadklasą B.
  • Klasa bazowa klasy A: w szerszym rozumieniu, to samo co nadklasa. W węższym rozumieniu: klasa, z której A dziedziczy bezpośrednio (czyli klasa podana w nawiasie w definicji A).
  • Metoda: występuje w dwóch znaczeniach. Nieprzywiązana metoda to funkcja, zdefiniowana w treści danej klasy. Metoda przywiązana to atrybut obiektu (z reguły instancji danej klasy), której wywołanie wywołuje stosowną funkcję (nieprzywiązaną metodę) odpowiedniej klasy, podając ten obiekt jako pierwszy parametr self.