G. Jagiella | ostatnia modyfikacja: 24.04.2021 |
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ę:
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:
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")
Przy czym metoda specjalna __init__
wywoływana jest automatycznie już przy kreacji obiektu:
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")
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:
get_name()
i get_speed()
).get_speed()
, get_name()
), daje do zjedzenia nagrodę (z użyciem eat()
).simulate_race([dog, orca, Animal('Nemo', 5.0)])
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:
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ę:
try:
simulate_race([dog, orca, Animal('Nemo', 5.0), "Jestem tylko napisem"])
except TypeError as e:
print("Padł wyjątek:", e)
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:
Animal
.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()
:
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:
- Aby metody
Fish1
odpowiadały metodomAnimal
, każda zmiana w treściAnimal
wymagałaby odpowiednich zmian w treściFish1
(a także każdej innej klasy reprezentującej nowy rodzaj zwierzęcia:Bird1
,Dog1
, etc.)- Poza przypadkową (z punktu widzenia języka) zbieżnością nazw metod w
Animal
i metod wFish1
, klasy nie mają ze sobą nic wspólnego. InstancjeFish1
nie są rozpoznawane jako instancjeAnimal
:
fish1 = Fish1('Nemo', 5.0)
isinstance(fish1, Animal)
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:
try:
simulate_race([orca, fish1])
except TypeError as e:
print("Padł wyjątek:", e)
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:
- 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ą).- Rozwiązanie wymaga modyfikacji klasy
Animal
, co może mieć nieprzewidziane konsekwencje.- Co gorsza, modyfikacja może być niepraktyczna: może być tak, że klasa
Animal
(i funkcje, które ją gdzieś wykorzystują, tak jaksimulate_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-atrapaAnimal
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.
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):
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
:
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')
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:
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)
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:
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()
Wciąż możemy też tworzyć instancje bazowej klasy Animal
:
orca = Animal('Willy', 56.0)
orca.eat('tasty fish')
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
:
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')
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:
# orca = Animal(...)
# nemo = Fish(...)
# sparrow = Bird(...)
# dog = Dog(...)
print(type(orca))
print(type(nemo))
print(type(sparrow))
print(type(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
?
print(isinstance(dog, Dog))
print(isinstance(dog, Animal))
print(isinstance(dog, Fish))
print(isinstance(dog, Bird))
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:
from fastest_animal import simulate_race
simulate_race([dog, nemo, human, sparrow, orca])
simulate_race([nemo, human])
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
:
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
:
sir = Human2('Sir Aristocrat', 0.0)
sir.eat('comber', True) # Human2.eat(sir, 'comber', True)
# sir.eat('kebab') # wyjątek: brakuje drugiego argumentu!
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:
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:
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:
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)
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.
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
:
a = A()
a.f()
Tu nie ma niespodzianki: a.f()
doprowadziło do wywołania A.f(a)
. Z kolei dla obiektu typu C
:
c = C()
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()
?
c.g()
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:
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ć:
class Car(Vehicle):
def do_something(self):
Vehicle.do_something(self)
print('obiekt Car coś robi!')
car = Car("red", 250)
car.do_something()
Identyczny efekt uzyskamy używając wbudowanego obiektu super
, tworzącego tak zwany "obiekt proxy" dla parametru self
:
class Car(Vehicle):
def do_something(self):
super().do_something()
print('obiekt Car coś robi!')
car = Car("red", 250)
car.do_something()
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:
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)
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:
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
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
:
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
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:
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
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
.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
).self
.